diff --git a/epic-claimer-new.js b/epic-claimer-new.js index 86649c8..3aba57f 100644 --- a/epic-claimer-new.js +++ b/epic-claimer-new.js @@ -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:
${verificationUrl}
` + + `User Code: ${userCode}
` + + `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 diff --git a/src/config.js b/src/config.js index 89b6e04..ae5b754 100644 --- a/src/config.js +++ b/src/config.js @@ -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 diff --git a/src/device-login.js b/src/device-login.js new file mode 100644 index 0000000..6fa21f9 --- /dev/null +++ b/src/device-login.js @@ -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 }; diff --git a/src/logger.js b/src/logger.js new file mode 100644 index 0000000..0d7e5e4 --- /dev/null +++ b/src/logger.js @@ -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;