The changes replace old API endpoints with current Epic Games' Public Account Service URLs and update the client ID across all OAuth requests (device authorization, token exchange, and refresh). This resolves authentication failures caused by deprecated endpoints and credentials.
406 lines
15 KiB
JavaScript
406 lines
15 KiB
JavaScript
import axios from 'axios';
|
|
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 { getDeviceAuths, setAccountAuth } from './src/device-auths.js';
|
|
|
|
|
|
const URL_CLAIM = 'https://store.epicgames.com/en-US/free-games';
|
|
const COOKIES_PATH = path.resolve(cfg.dir.browser, 'epic-cookies.json');
|
|
const BEARER_TOKEN_NAME = 'EPIC_BEARER_TOKEN';
|
|
|
|
// Screenshot Helper Function
|
|
|
|
|
|
// Fetch Free Games from API
|
|
const fetchFreeGamesAPI = async () => {
|
|
const resp = await axios.get('https://store-site-backend-static-ipv4.ak.epicgames.com/freeGamesPromotions', {
|
|
params: { locale: 'en-US', country: 'US', allowCountries: 'US,DE,AT,CH,GB' },
|
|
});
|
|
return resp.data?.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,
|
|
};
|
|
}) || [];
|
|
};
|
|
|
|
// Poll for OAuth tokens
|
|
const pollForTokens = async (deviceCode, maxAttempts = 30) => {
|
|
for (let i = 0; i < maxAttempts; i++) {
|
|
try {
|
|
const response = await axios.post('https://account-public-service-prod.ol.epicgames.com/account/api/oauth/token', {
|
|
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
device_code: deviceCode,
|
|
client_id: '875a3b57d3a640a6b7f9b4e883463ab4',
|
|
});
|
|
if (response.data?.access_token) {
|
|
console.log('✅ OAuth successful');
|
|
return response.data;
|
|
}
|
|
} catch (error) {
|
|
if (error.response?.data?.error === 'authorization_pending') {
|
|
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
continue;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
throw new Error('OAuth timeout');
|
|
};
|
|
|
|
// Exchange token for cookies
|
|
const exchangeTokenForCookies = async accessToken => {
|
|
const response = await axios.get('https://store.epicgames.com/', {
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
},
|
|
});
|
|
const cookies = response.headers['set-cookie']?.map(cookie => {
|
|
const [name, value] = cookie.split(';')[0].split('=');
|
|
return { name, value, domain: '.epicgames.com', path: '/' };
|
|
}) || [];
|
|
cookies.push({ name: BEARER_TOKEN_NAME, value: accessToken, domain: '.epicgames.com', path: '/' });
|
|
return cookies;
|
|
};
|
|
|
|
// Get valid authentication
|
|
const getValidAuth = async ({ otpKey, reuseCookies, cookiesPath }) => {
|
|
if (reuseCookies && existsSync(cookiesPath)) {
|
|
const cookies = JSON.parse(readFileSync(cookiesPath, 'utf8'));
|
|
const bearerCookie = cookies.find(c => c.name === BEARER_TOKEN_NAME);
|
|
if (bearerCookie?.value) {
|
|
console.log('🔄 Reusing existing bearer token from cookies');
|
|
return { bearerToken: bearerCookie.value, cookies };
|
|
}
|
|
}
|
|
|
|
console.log('🔐 Starting fresh OAuth device flow (manual approval required)...');
|
|
let deviceResponse;
|
|
|
|
try {
|
|
const params = new URLSearchParams();
|
|
params.append('clientId', '875a3b57d3a640a6b7f9b4e883463ab4');
|
|
params.append('scope', 'account.basicprofile account.userentitlements');
|
|
|
|
deviceResponse = await axios.post('https://account-public-service-prod.ol.epicgames.com/account/api/oauth/deviceAuthorization', params.toString(), {
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error('Device code flow failed (fallback to manual login):', error.response?.status || error.message);
|
|
return { bearerToken: null, cookies: [] };
|
|
}
|
|
|
|
const { deviceCode, userCode, verificationUriComplete } = deviceResponse.data;
|
|
console.log(`📱 Open: ${verificationUriComplete}`);
|
|
console.log(`💳 Code: ${userCode}`);
|
|
|
|
const tokens = await pollForTokens(deviceCode);
|
|
|
|
if (otpKey) {
|
|
const totpCode = authenticator.generate(otpKey);
|
|
console.log(`🔑 TOTP Code (generated): ${totpCode}`);
|
|
try {
|
|
const refreshed = await axios.post('https://account-public-service-prod.ol.epicgames.com/account/api/oauth/token', {
|
|
grant_type: 'refresh_token',
|
|
refresh_token: tokens.refresh_token,
|
|
client_id: '875a3b57d3a640a6b7f9b4e883463ab4',
|
|
});
|
|
tokens.access_token = refreshed.data.access_token;
|
|
} catch {
|
|
// Ignore if refresh fails; use original token
|
|
}
|
|
}
|
|
|
|
const cookies = await exchangeTokenForCookies(tokens.access_token);
|
|
writeFileSync(cookiesPath, JSON.stringify(cookies, null, 2));
|
|
console.log('💾 Cookies saved to', cookiesPath);
|
|
return { bearerToken: tokens.access_token, cookies };
|
|
};
|
|
|
|
// Ensure user is logged in
|
|
const ensureLoggedIn = async (page, context) => {
|
|
const isLoggedIn = async () => await page.locator('egs-navigation').getAttribute('isloggedin') === 'true';
|
|
|
|
const attemptAutoLogin = async () => {
|
|
if (!cfg.eg_email || !cfg.eg_password) return false;
|
|
|
|
try {
|
|
await page.goto('https://www.epicgames.com/id/login?lang=en-US&noHostRedirect=true&redirectUrl=' + URL_CLAIM, {
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: cfg.login_timeout,
|
|
});
|
|
|
|
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();
|
|
|
|
// Step 1: Email + continue
|
|
if (await emailField.count() > 0) {
|
|
await emailField.fill(cfg.eg_email);
|
|
await continueBtn.click();
|
|
}
|
|
|
|
// Step 2: Password + submit
|
|
await passwordField.waitFor({ timeout: cfg.login_visible_timeout });
|
|
await passwordField.fill(cfg.eg_password);
|
|
|
|
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();
|
|
} catch (err) {
|
|
console.error('Auto login failed:', err);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const isChallenge = async () => {
|
|
const cfFrame = page.locator('iframe[title*="Cloudflare"], iframe[src*="challenges"]');
|
|
const cfText = page.locator('text=Verify you are human');
|
|
return await cfFrame.count() > 0 || await cfText.count() > 0;
|
|
};
|
|
|
|
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' });
|
|
|
|
if (await isChallenge()) {
|
|
console.warn('Cloudflare challenge detected. Solve the captcha in the browser (no automation).');
|
|
await notify('epic-games (new): Cloudflare challenge, please solve manually in browser.');
|
|
await page.waitForTimeout(cfg.login_timeout);
|
|
continue;
|
|
}
|
|
|
|
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.');
|
|
|
|
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 (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}`);
|
|
|
|
if (!cfg.debug) context.setDefaultTimeout(cfg.timeout);
|
|
return user;
|
|
};
|
|
|
|
// 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;
|
|
};
|
|
|
|
// Main function to claim Epic Games
|
|
export const claimEpicGamesNew = async () => {
|
|
console.log('Starting Epic Games claimer (new mode, cookies + API)');
|
|
const db = await jsonDb('epic-games.json', {});
|
|
const notify_games = [];
|
|
|
|
const freeGames = await fetchFreeGamesAPI();
|
|
console.log('Free games via API:', freeGames.map(g => g.pageSlug));
|
|
|
|
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 });
|
|
|
|
// Use device auths if available (from legacy mode)
|
|
const deviceAuths = await getDeviceAuths();
|
|
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 {
|
|
const auth = await getValidAuth({
|
|
otpKey: cfg.eg_otpkey,
|
|
reuseCookies: true,
|
|
cookiesPath: COOKIES_PATH,
|
|
});
|
|
|
|
if (auth.cookies?.length) {
|
|
await context.addCookies(auth.cookies);
|
|
console.log('✅ Cookies loaded:', auth.cookies.length);
|
|
} else {
|
|
console.log('⚠️ No cookies loaded; using manual login via browser.');
|
|
}
|
|
|
|
await page.goto(URL_CLAIM, { waitUntil: 'domcontentloaded' });
|
|
user = await ensureLoggedIn(page, context);
|
|
db.data[user] ||= {};
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
await writeFileSync(COOKIES_PATH, JSON.stringify(await context.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();
|
|
};
|
|
|
|
export default claimEpicGamesNew;
|
|
|
|
|