free-games-claimer/epic-claimer-new.js
root 393f70d409
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
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
2026-03-08 14:26:44 +00:00

296 lines
11 KiB
JavaScript

import { firefox } from 'playwright-firefox';
import { authenticator } from 'otplib';
import path from 'node:path';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import {
jsonDb,
datetime,
stealth,
filenamify,
prompt,
notify,
html_game_list,
handleSIGINT,
} from './src/util.js';
import { cfg } from './src/config.js';
import { EPIC_CLIENT_ID, GRAPHQL_ENDPOINT, FREE_GAMES_PROMOTIONS_ENDPOINT, STORE_HOMEPAGE_EN, EPIC_PURCHASE_ENDPOINT, ID_LOGIN_ENDPOINT } from './src/constants.js';
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 => {
const response = await page.evaluate(async () => {
const resp = await fetch(FREE_GAMES_PROMOTIONS_ENDPOINT + '?locale=en-US&country=US&allowCountries=US,DE,AT,CH,GB');
return await resp.json();
});
return response?.Catalog?.searchStore?.elements
?.filter(g => g.promotions?.promotionalOffers?.[0])
?.map(g => {
const offer = g.promotions.promotionalOffers[0].promotionalOffers[0];
const mapping = g.catalogNs?.mappings?.[0];
return {
title: g.title,
namespace: mapping?.id || g.productSlug,
pageSlug: mapping?.pageSlug || g.urlSlug,
offerId: offer?.offerId,
};
}) || [];
};
const URL_CLAIM = 'https://store.epicgames.com/en-US/free-games';
const URL_LOGIN = 'https://www.epicgames.com/id/login?lang=en-US&noHostRedirect=true&redirectUrl=' + URL_CLAIM;
const COOKIES_PATH = path.resolve(cfg.dir.browser, 'epic-cookies.json');
const BEARER_TOKEN_NAME = 'EPIC_BEARER_TOKEN';
// Claim game function
const claimGame = async (page, game) => {
const purchaseUrl = `https://store.epicgames.com/${game.pageSlug}`;
console.log(`🎮 ${game.title}${purchaseUrl}`);
const notify_game = { title: game.title, url: purchaseUrl, status: 'failed' };
await page.goto(purchaseUrl, { waitUntil: 'networkidle' });
const purchaseBtn = page.locator('button[data-testid="purchase-cta-button"]').first();
await purchaseBtn.waitFor({ timeout: cfg.timeout });
const btnText = (await purchaseBtn.textContent() || '').toLowerCase();
if (btnText.includes('library') || btnText.includes('owned')) {
notify_game.status = 'existed';
return notify_game;
}
if (cfg.dryrun) {
notify_game.status = 'skipped';
return notify_game;
}
await purchaseBtn.click({ delay: 50 });
try {
await page.waitForSelector('#webPurchaseContainer iframe', { timeout: 15000 });
const iframe = page.frameLocator('#webPurchaseContainer iframe');
if (cfg.eg_parentalpin) {
try {
await iframe.locator('.payment-pin-code').waitFor({ timeout: 10000 });
await iframe.locator('input.payment-pin-code__input').first().pressSequentially(cfg.eg_parentalpin);
await iframe.locator('button:has-text("Continue")').click({ delay: 11 });
} catch {
// no PIN needed
}
}
await iframe.locator('button:has-text("Place Order"):not(:has(.payment-loading--loading))').click({ delay: 11 });
try {
await iframe.locator('button:has-text("I Accept")').click({ timeout: 5000 });
} catch {
// not required
}
await page.locator('text=Thanks for your order!').waitFor({ state: 'attached', timeout: cfg.timeout });
notify_game.status = 'claimed';
} catch (e) {
notify_game.status = 'failed';
const screenshotPath = path.resolve(cfg.dir.screenshots, 'epic-games', 'failed', `${game.offerId || game.pageSlug}_${filenamify(datetime())}.png`);
await page.screenshot({ path: screenshotPath, fullPage: true }).catch(() => { });
console.error(' Failed to claim:', e.message);
}
return notify_game;
};
// 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';
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 {
const { verificationUrl, userCode, expiresAt } = await startDeviceAuthLogin(user);
// 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`,
);
console.log(`🔐 Device Auth URL: ${verificationUrl}`);
console.log(`🔐 User Code: ${userCode}`);
console.log(`⏰ Expires in: ${timeRemaining} minutes`);
// 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,
);
accessToken = authToken.access_token;
L.info('Device auth completed successfully');
} catch (err) {
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',
};
await context.addCookies([bearerCookie]);
// Visit store to get session cookies
await page.goto(STORE_HOMEPAGE_EN, { waitUntil: 'networkidle', timeout: cfg.timeout });
// 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');
}
}
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 displayName;
};
// Main function to claim Epic Games
export const claimEpicGamesNew = async () => {
console.log('Starting Epic Games claimer (new mode, browser-based)');
const db = await jsonDb('epic-games.json', {});
const notify_games = [];
const context = await firefox.launchPersistentContext(cfg.dir.browser, {
headless: cfg.headless,
viewport: { width: cfg.width, height: cfg.height },
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0',
locale: 'en-US',
recordVideo: cfg.record ? { dir: 'data/record/', size: { width: cfg.width, height: cfg.height } } : undefined,
recordHar: cfg.record ? { path: `data/record/eg-${filenamify(datetime())}.har` } : undefined,
handleSIGINT: false,
});
handleSIGINT(context);
await stealth(context);
if (!cfg.debug) context.setDefaultTimeout(cfg.timeout);
const page = context.pages().length ? context.pages()[0] : await context.newPage();
await page.setViewportSize({ width: cfg.width, height: cfg.height });
// Load cookies from file if available
if (existsSync(COOKIES_PATH)) {
try {
const cookies = JSON.parse(readFileSync(COOKIES_PATH, 'utf8'));
await context.addCookies(cookies);
console.log('✅ Cookies loaded from file');
} catch (error) {
console.error('Failed to load cookies:', error);
}
}
// Use device auths if available (from legacy mode)
const deviceAuths = await getAccountAuth();
if (deviceAuths && cfg.eg_email) {
const accountAuth = deviceAuths[cfg.eg_email];
if (accountAuth) {
console.log('🔄 Reusing device auth from legacy mode');
const cookies = [
{ name: 'EPIC_SSO_RM', value: accountAuth.deviceAuth?.refreshToken || '', domain: '.epicgames.com', path: '/' },
{ name: 'EPIC_DEVICE', value: accountAuth.deviceAuth?.deviceId || '', domain: '.epicgames.com', path: '/' },
{ name: 'EPIC_SESSION_AP', value: accountAuth.deviceAuth?.accountId || '', domain: '.epicgames.com', path: '/' },
];
await context.addCookies(cookies);
console.log('✅ Device auth cookies loaded');
}
}
let user;
try {
await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' });
user = await ensureLoggedIn(page, context);
db.data[user] ||= {};
const freeGames = await fetchFreeGamesAPI(page);
console.log('Free games via API:', freeGames.map(g => g.pageSlug));
for (const game of freeGames) {
const result = await claimGame(page, game);
notify_games.push(result);
db.data[user][game.offerId || game.pageSlug] = {
title: game.title,
time: datetime(),
url: `https://store.epicgames.com/${game.pageSlug}`,
status: result.status,
};
}
// Save cookies to file
const cookies = await context.cookies();
writeFileSync(COOKIES_PATH, JSON.stringify(cookies, null, 2));
} catch (error) {
process.exitCode ||= 1;
console.error('--- Exception (new epic):');
console.error(error);
if (error.message && process.exitCode !== 130) notify(`epic-games (new) failed: ${error.message.split('\n')[0]}`);
} finally {
await db.write();
if (notify_games.filter(g => g.status === 'claimed' || g.status === 'failed').length) {
notify(`epic-games (new ${user || 'unknown'}):<br>${html_game_list(notify_games)}`);
}
}
if (cfg.debug && context) {
console.log(JSON.stringify(await context.cookies(), null, 2));
}
await context.close();
};