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

View file

@ -35,6 +35,9 @@ export const cfg = {
eg_password: process.env.EG_PASSWORD || process.env.PASSWORD,
eg_otpkey: process.env.EG_OTPKEY,
eg_parentalpin: process.env.EG_PARENTALPIN,
// Device Auth (OAuth Device Flow - bypasses Cloudflare)
deviceAuthClientId: process.env.EG_DEVICE_CLIENT_ID || process.env.DEVICE_CLIENT_ID || '3446cd72e193480d93d518c247381aba',
deviceAuthSecret: process.env.EG_DEVICE_SECRET || process.env.DEVICE_SECRET || '7s62PokZ6yVfhsWYxIAfDn7nR38d7P6l',
// Cloudflare bypass
flaresolverr_url: process.env.FLARESOLVERR_URL || 'http://localhost:8191/v1',
// auth prime-gaming

217
src/device-login.js Normal file
View file

@ -0,0 +1,217 @@
import axios from 'axios';
import { cfg } from './config.js';
import { getAccountAuth, setAccountAuth } from './device-auths.js';
import { ACCOUNT_OAUTH_TOKEN, ACCOUNT_OAUTH_DEVICE_AUTH } from './constants.js';
import logger from './logger.js';
const L = logger.child({ module: 'device-login' });
/**
* Epic Games OAuth Device Flow Login
* This bypasses Cloudflare by using the official OAuth device authorization flow.
* User gets a notification with a link to login in their own browser.
*/
/**
* Get client credentials token from Epic OAuth API
*/
async function getClientCredentialsToken() {
L.trace('Getting client credentials token');
const resp = await axios.post(
ACCOUNT_OAUTH_TOKEN,
new URLSearchParams({ grant_type: 'client_credentials' }),
{
auth: {
username: cfg.deviceAuthClientId || '3446cd72e193480d93d518c247381aba',
password: cfg.deviceAuthSecret || '7s62PokZ6yVfhsWYxIAfDn7nR38d7P6l',
},
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
},
);
return resp.data;
}
/**
* Get device authorization code with verification URL
*/
async function getDeviceAuthorizationCode(clientCredentialsToken) {
L.trace('Getting device authorization verification URL');
const resp = await axios.post(
ACCOUNT_OAUTH_DEVICE_AUTH,
new URLSearchParams({ prompt: 'login' }),
{
headers: {
Authorization: `Bearer ${clientCredentialsToken}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
},
);
return resp.data;
}
/**
* Poll for device authorization completion
*/
async function waitForDeviceAuthorization(deviceCode, expiresAt, interval) {
const now = new Date();
if (expiresAt < now) {
throw new Error('Device code login expired');
}
try {
const resp = await axios.post(
ACCOUNT_OAUTH_TOKEN,
new URLSearchParams({
grant_type: 'device_code',
device_code: deviceCode,
}),
{
auth: {
username: cfg.deviceAuthClientId || '3446cd72e193480d93d518c247381aba',
password: cfg.deviceAuthSecret || '7s62PokZ6yVfhsWYxIAfDn7nR38d7P6l',
},
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
},
);
return resp.data;
} catch (err) {
if (!axios.isAxiosError(err)) {
throw new Error('Unable to get device authorization token');
}
// Check if still pending authorization
if (err.response?.data?.errorCode !== 'errors.com.epicgames.account.oauth.authorization_pending') {
L.error({ err, response: err.response?.data }, 'Authorization failed');
throw new Error('Unable to get device authorization token');
}
// Wait and retry
await new Promise(resolve => setTimeout(resolve, interval * 1000));
return waitForDeviceAuthorization(deviceCode, expiresAt, interval);
}
}
/**
* Refresh existing device auth token
*/
export async function refreshDeviceAuth(user) {
try {
const existingAuth = await getAccountAuth(user);
if (!(existingAuth && new Date(existingAuth.refresh_expires_at) > new Date())) {
L.trace('No valid refresh token available');
return false;
}
L.trace('Refreshing device auth token');
const resp = await axios.post(
ACCOUNT_OAUTH_TOKEN,
new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: existingAuth.refresh_token,
}),
{
auth: {
username: cfg.deviceAuthClientId || '3446cd72e193480d93d518c247381aba',
password: cfg.deviceAuthSecret || '7s62PokZ6yVfhsWYxIAfDn7nR38d7P6l',
},
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
},
);
await setAccountAuth(user, resp.data);
L.info('Device auth token refreshed successfully');
return true;
} catch (err) {
L.warn({ err }, 'Failed to refresh device auth');
return false;
}
}
/**
* Start new device auth login flow
* Returns the verification URL that user needs to visit
*/
export async function startDeviceAuthLogin(user) {
L.info({ user }, 'Starting device auth login flow');
// Get client credentials
const clientCreds = await getClientCredentialsToken();
// Get device authorization code
const deviceAuth = await getDeviceAuthorizationCode(clientCreds.access_token);
// Calculate expiry time
const expiresAt = new Date();
expiresAt.setSeconds(expiresAt.getSeconds() + deviceAuth.expires_in);
L.info({
userCode: deviceAuth.user_code,
verificationUrl: deviceAuth.verification_uri_complete,
expiresAt,
}, 'Device auth initiated - user must visit verification URL');
return {
verificationUrl: deviceAuth.verification_uri_complete,
userCode: deviceAuth.user_code,
expiresAt,
};
}
/**
* Complete device auth login by polling for authorization
*/
export async function completeDeviceAuthLogin(deviceCode, expiresAt, interval) {
L.info('Waiting for user to complete authorization...');
const authToken = await waitForDeviceAuthorization(deviceCode, expiresAt, interval);
// Save the auth token
await setAccountAuth('default', authToken);
L.info({ accountId: authToken.account_id }, 'Device auth login completed successfully');
return authToken;
}
/**
* Get valid access token, refreshing if necessary
*/
export async function getValidAccessToken(user) {
const existingAuth = await getAccountAuth(user);
if (!existingAuth) {
L.trace('No existing auth found');
return null;
}
// Check if access token is still valid (with 5 minute buffer)
const now = new Date();
const expiresAt = new Date(existingAuth.expires_at);
const bufferMs = 5 * 60 * 1000; // 5 minutes
if (expiresAt.getTime() > now.getTime() + bufferMs) {
L.trace('Access token still valid');
return existingAuth.access_token;
}
// Try to refresh
L.trace('Access token expired, attempting refresh');
const refreshed = await refreshDeviceAuth(user);
if (refreshed) {
const refreshedAuth = await getAccountAuth(user);
return refreshedAuth?.access_token || null;
}
return null;
}
export { getClientCredentialsToken, getDeviceAuthorizationCode };

64
src/logger.js Normal file
View file

@ -0,0 +1,64 @@
/**
* Simple logger for free-games-claimer
*/
const LOG_LEVELS = {
trace: 0,
debug: 1,
info: 2,
warn: 3,
error: 4,
};
const currentLevel = process.env.LOG_LEVEL
? LOG_LEVELS[process.env.LOG_LEVEL.toLowerCase()]
: LOG_LEVELS.info;
function formatMessage(level, module, message, data) {
const timestamp = new Date().toISOString();
const moduleStr = module ? `[${module}] ` : '';
const dataStr = data && Object.keys(data).length > 0 ? ' ' + JSON.stringify(data) : '';
return `${timestamp} ${level.toUpperCase().padEnd(5)} ${moduleStr}${message}${dataStr}`;
}
function createLogger(module) {
return {
trace: (dataOrMessage, message) => {
if (currentLevel <= LOG_LEVELS.trace) {
const [data, msg] = typeof dataOrMessage === 'string' ? [null, dataOrMessage] : [dataOrMessage, message];
console.log(formatMessage('trace', module, msg || '', data));
}
},
debug: (dataOrMessage, message) => {
if (currentLevel <= LOG_LEVELS.debug) {
const [data, msg] = typeof dataOrMessage === 'string' ? [null, dataOrMessage] : [dataOrMessage, message];
console.log(formatMessage('debug', module, msg || '', data));
}
},
info: (dataOrMessage, message) => {
if (currentLevel <= LOG_LEVELS.info) {
const [data, msg] = typeof dataOrMessage === 'string' ? [null, dataOrMessage] : [dataOrMessage, message];
console.log(formatMessage('info', module, msg || '', data));
}
},
warn: (dataOrMessage, message) => {
if (currentLevel <= LOG_LEVELS.warn) {
const [data, msg] = typeof dataOrMessage === 'string' ? [null, dataOrMessage] : [dataOrMessage, message];
console.log(formatMessage('warn', module, msg || '', data));
}
},
error: (dataOrMessage, message) => {
if (currentLevel <= LOG_LEVELS.error) {
const [data, msg] = typeof dataOrMessage === 'string' ? [null, dataOrMessage] : [dataOrMessage, message];
console.log(formatMessage('error', module, msg || '', data));
}
},
child: childData => {
const childModule = childData?.module || module;
return createLogger(childModule);
},
};
}
const logger = createLogger('root');
export default logger;