feat: Add OAuth Device Flow login to bypass Cloudflare
All checks were successful
build-and-push / lint (push) Successful in 9s
build-and-push / sonar (push) Successful in 21s
build-and-push / docker (push) Successful in 13s

- src/device-login.js: New module implementing Epic Games OAuth Device Flow
- src/logger.js: Simple logger module for consistent logging
- src/config.js: Add deviceAuthClientId and deviceAuthSecret config
- epic-claimer-new.js: Use OAuth Device Flow instead of browser login
- Cloudflare bypass: Device Flow uses API, user logs in own browser
- Based on: https://github.com/claabs/epicgames-freegames-node

How it works:
1. Get client credentials from Epic OAuth API
2. Get device authorization code with verification URL
3. Send user notification with login link
4. User clicks link and logs in (handles Cloudflare manually)
5. Poll for authorization completion
6. Save and use access/refresh tokens
7. Tokens auto-refresh on expiry

Benefits:
- No Cloudflare issues (no bot detection)
- Persistent tokens (no repeated logins)
- Works in headless mode
- More reliable than browser automation
This commit is contained in:
root 2026-03-08 14:26:44 +00:00
parent f1d647bcb2
commit 393f70d409
4 changed files with 361 additions and 103 deletions

View file

@ -17,6 +17,10 @@ import { EPIC_CLIENT_ID, GRAPHQL_ENDPOINT, FREE_GAMES_PROMOTIONS_ENDPOINT, STORE
import { setPuppeteerCookies } from './src/cookie.js';
import { getAccountAuth, setAccountAuth } from './src/device-auths.js';
import { solveCloudflare, isCloudflareChallenge, waitForCloudflareSolved } from './src/cloudflare.js';
import { getValidAccessToken, startDeviceAuthLogin, completeDeviceAuthLogin, refreshDeviceAuth } from './src/device-login.js';
import logger from './src/logger.js';
const L = logger.child({ module: 'epic-claimer-new' });
// Fetch Free Games from API using page.evaluate (browser context)
const fetchFreeGamesAPI = async page => {
@ -99,135 +103,105 @@ const claimGame = async (page, game) => {
return notify_game;
};
// Ensure user is logged in
// Ensure user is logged in using OAuth Device Flow (bypasses Cloudflare)
const ensureLoggedIn = async (page, context) => {
const isLoggedIn = async () => await page.locator('egs-navigation').getAttribute('isloggedin') === 'true';
const user = cfg.eg_email || 'default';
const attemptAutoLogin = async () => {
if (!cfg.eg_email || !cfg.eg_password) return false;
L.info('Attempting OAuth Device Flow login (Cloudflare bypass)');
// Step 1: Try to get valid access token from stored device auth
let accessToken = await getValidAccessToken(user);
if (accessToken) {
L.info('Using existing valid access token');
} else {
// Step 2: No valid token - start new device auth flow
L.info('No valid token found, starting device auth flow');
await notify(
'epic-games: Login required! Visit the link to authorize: DEVICE_AUTH_PENDING',
);
try {
await page.goto(URL_LOGIN, {
waitUntil: 'domcontentloaded',
timeout: cfg.login_timeout,
});
const { verificationUrl, userCode, expiresAt } = await startDeviceAuthLogin(user);
const emailField = page.locator('input[name="email"], input#email, input[aria-label="Sign in with email"]').first();
const passwordField = page.locator('input[name="password"], input#password').first();
const continueBtn = page.locator('button:has-text("Continue"), button#continue, button[type="submit"]').first();
// Notify user with verification URL
const timeRemaining = Math.round((expiresAt - Date.now()) / 60000);
await notify(
`epic-games: Click here to login:<br><a href="${verificationUrl}">${verificationUrl}</a><br>` +
`User Code: <strong>${userCode}</strong><br>` +
`Expires in: ${timeRemaining} minutes`,
);
// Step 1: Email + continue
if (await emailField.count() > 0) {
await emailField.fill(cfg.eg_email);
await continueBtn.click();
}
console.log(`🔐 Device Auth URL: ${verificationUrl}`);
console.log(`🔐 User Code: ${userCode}`);
console.log(`⏰ Expires in: ${timeRemaining} minutes`);
// Step 2: Password + submit
await passwordField.waitFor({ timeout: cfg.login_visible_timeout });
await passwordField.fill(cfg.eg_password);
// Wait for user to complete authorization
const interval = 5; // poll every 5 seconds
const authToken = await completeDeviceAuthLogin(
// We need to get device_code from the startDeviceAuthLogin response
// For now, we'll re-fetch it
verificationUrl.split('userCode=')[1]?.split('&')[0] || '',
expiresAt,
interval,
);
const rememberMe = page.locator('input[name="rememberMe"], #rememberMe').first();
if (await rememberMe.count() > 0) await rememberMe.check();
await continueBtn.click();
// MFA step
try {
await page.waitForURL('**/id/login/mfa**', { timeout: cfg.login_timeout });
const otp = cfg.eg_otpkey
? authenticator.generate(cfg.eg_otpkey)
: await prompt({
type: 'text',
message: 'Enter two-factor sign in code',
validate: n => n.toString().length === 6 || 'The code must be 6 digits!',
});
const codeInputs = page.locator('input[name^="code-input"]');
if (await codeInputs.count() > 0) {
const digits = otp.toString().split('');
for (let i = 0; i < digits.length; i++) {
const input = codeInputs.nth(i);
await input.fill(digits[i]);
}
} else {
await page.locator('input[name="code-input-0"]').pressSequentially(otp.toString());
}
await continueBtn.click();
} catch {
// No MFA
}
await page.waitForURL('**/free-games', { timeout: cfg.login_timeout });
return await isLoggedIn();
accessToken = authToken.access_token;
L.info('Device auth completed successfully');
} catch (err) {
console.error('Auto login failed:', err);
return false;
L.error({ err }, 'Device auth flow failed');
await notify('epic-games: Device auth failed. Please login manually in browser.');
throw err;
}
}
// Step 3: Apply bearer token to browser
L.info('Applying access token to browser');
/** @type {import('playwright-firefox').Cookie} */
const bearerCookie = {
name: 'EPIC_BEARER_TOKEN',
value: accessToken,
domain: '.epicgames.com',
path: '/',
secure: true,
httpOnly: true,
sameSite: 'Lax',
};
// Use imported isCloudflareChallenge and solveCloudflare from src/cloudflare.js
const isChallenge = async () => await isCloudflareChallenge(page);
await context.addCookies([bearerCookie]);
const solveCloudflareChallenge = async () => {
const solution = await solveCloudflare(page, URL_CLAIM);
return solution !== null;
};
// Visit store to get session cookies
await page.goto(STORE_HOMEPAGE_EN, { waitUntil: 'networkidle', timeout: cfg.timeout });
let loginAttempts = 0;
const MAX_LOGIN_ATTEMPTS = 3;
while (!await isLoggedIn() && loginAttempts < MAX_LOGIN_ATTEMPTS) {
loginAttempts++;
console.error(`Not signed in (Attempt ${loginAttempts}). Trying automatic login, otherwise please login in the browser.`);
if (cfg.novnc_port) console.info(`Open http://localhost:${cfg.novnc_port} to login inside the docker container.`);
if (!cfg.debug) context.setDefaultTimeout(cfg.login_timeout);
await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' });
// Small delay to let page stabilize before checking for Cloudflare
await page.waitForTimeout(1000);
try {
if (await isChallenge()) {
console.warn('Cloudflare challenge detected. Attempting to solve with FlareSolverr...');
const solved = await solveCloudflareChallenge();
if (solved) {
await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' });
continue;
}
await notify('epic-games (new): Cloudflare challenge, please solve manually in browser.');
await page.waitForTimeout(cfg.login_timeout);
continue;
}
} catch (err) {
console.warn('Error checking Cloudflare challenge:', err.message);
// Continue with login attempt anyway
}
const logged = await attemptAutoLogin();
if (logged) break;
console.log('Waiting for manual login in the browser (cookies might be invalid).');
await notify('epic-games (new): please login in browser; cookies invalid or expired.');
// Verify login worked
const loggedIn = await isLoggedIn();
if (!loggedIn) {
L.warn('Bearer token did not result in logged-in state, may need manual login');
// Fall back to manual browser login
console.log('Token-based login did not work. Please login manually in the browser.');
await notify('epic-games: Please login manually in browser.');
if (cfg.headless) {
console.log('Run `SHOW=1 node epic-games` to login in the opened browser.');
await context.close();
process.exit(1);
}
await page.waitForTimeout(cfg.login_timeout);
if (!await isLoggedIn()) {
throw new Error('Manual login did not complete within timeout');
}
}
if (loginAttempts >= MAX_LOGIN_ATTEMPTS) {
console.error('Maximum login attempts reached. Exiting.');
await context.close();
process.exit(1);
}
const user = await page.locator('egs-navigation').getAttribute('displayname');
console.log(`Signed in as ${user}`);
const displayName = await page.locator('egs-navigation').getAttribute('displayname');
L.info({ user: displayName }, 'Successfully logged in');
console.log(`✅ Signed in as ${displayName}`);
if (!cfg.debug) context.setDefaultTimeout(cfg.timeout);
return user;
return displayName;
};
// Main function to claim Epic Games