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;