feat: Add OAuth Device Flow login to bypass Cloudflare
- 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:
parent
f1d647bcb2
commit
393f70d409
4 changed files with 361 additions and 103 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
217
src/device-login.js
Normal 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
64
src/logger.js
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue