// BeatLeader OAuth 2.0 token chain owned by the archive sync. // // This chain is separate from plebsaber.stream's own login: BeatLeader rotates // the refresh token on every refresh and invalidates the old one, so two // consumers sharing one chain would break each other. import * as fs from 'node:fs'; import * as http from 'node:http'; import { randomUUID } from 'node:crypto'; import { BL_API, TOKENS_FILE, USER_AGENT, clientCredentials, writeFileAtomic } from './config.ts'; const AUTHORIZE_URL = `${BL_API}/oauth2/authorize`; const TOKEN_URL = `${BL_API}/oauth2/token`; const SCOPES = 'profile offline_access'; // Registered redirect URIs on the OAuth app (verified 2026-11-08): // https://plebsaber.stream/auth/beatleader/callback // http://localhost:5173/auth/beatleader/callback const LOOPBACK_PORT = 5173; const LOOPBACK_REDIRECT = `http://localhost:${LOOPBACK_PORT}/auth/beatleader/callback`; const REFRESH_TOKEN_LIFETIME_MS = 14 * 24 * 3600 * 1000; export type TokenStore = { access_token: string; refresh_token: string; /** epoch ms */ access_expires_at: number; /** epoch ms, estimated from BeatLeader's 14-day refresh token lifetime */ refresh_expires_at: number; /** epoch ms of the last token grant/refresh */ updated_at: number; player_id?: string; player_name?: string; }; type TokenResponse = { access_token: string; refresh_token?: string; expires_in?: number; token_type?: string }; export function readTokens(): TokenStore | null { try { return JSON.parse(fs.readFileSync(TOKENS_FILE, 'utf-8')) as TokenStore; } catch { return null; } } function saveTokens(res: TokenResponse, previous: TokenStore | null, identity?: { id: string; name?: string }): TokenStore { if (!res.refresh_token) { throw new Error('Token response has no refresh_token; is offline_access granted?'); } const now = Date.now(); const store: TokenStore = { access_token: res.access_token, refresh_token: res.refresh_token, access_expires_at: now + (res.expires_in ?? 3600) * 1000, refresh_expires_at: now + REFRESH_TOKEN_LIFETIME_MS, updated_at: now, player_id: identity?.id ?? previous?.player_id, player_name: identity?.name ?? previous?.player_name }; // Persist the rotated refresh token before anything uses the new access token. writeFileAtomic(TOKENS_FILE, JSON.stringify(store, null, 2) + '\n', 0o600); return store; } async function tokenRequest(params: Record): Promise { const { clientId, clientSecret } = clientCredentials(); const res = await fetch(TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': USER_AGENT }, body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, ...params }) }); const text = await res.text(); if (!res.ok) throw new Error(`Token request failed: HTTP ${res.status} ${text.slice(0, 300)}`); return JSON.parse(text) as TokenResponse; } export async function fetchIdentity(accessToken: string): Promise<{ id: string; name?: string }> { const res = await fetch(`${BL_API}/oauth2/identity`, { headers: { Authorization: `Bearer ${accessToken}`, 'User-Agent': USER_AGENT } }); if (!res.ok) throw new Error(`Identity request failed: HTTP ${res.status}`); const json = (await res.json()) as { id?: string; name?: string }; if (!json.id) throw new Error('Identity response has no id'); return { id: json.id, name: json.name }; } /** Wait for the OAuth redirect on the loopback port, or for a pasted redirect URL on stdin. */ function waitForCode(state: string): Promise { return new Promise((resolve, reject) => { let done = false; const finish = (err: Error | null, code?: string) => { if (done) return; done = true; server.close(); process.stdin.pause(); if (err) reject(err); else resolve(code!); }; const fromUrl = (raw: string): string => { const url = new URL(raw, LOOPBACK_REDIRECT); const error = url.searchParams.get('error'); if (error) throw new Error(`Authorization denied: ${error} ${url.searchParams.get('error_description') ?? ''}`); const code = url.searchParams.get('code'); if (!code) throw new Error('Redirect has no code parameter'); if (url.searchParams.get('state') !== state) throw new Error('State mismatch; start the login again'); return code; }; const server = http.createServer((req, res) => { if (!req.url?.startsWith('/auth/beatleader/callback')) { res.writeHead(404).end(); return; } try { const code = fromUrl(req.url); res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('plebleader archive: BeatLeader authorization received. You can close this tab.\n'); finish(null, code); } catch (err) { res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end(`plebleader archive: ${(err as Error).message}\n`); finish(err as Error); } }); server.on('error', (err: NodeJS.ErrnoException) => { if (err.code === 'EADDRINUSE') { console.error(`Port ${LOOPBACK_PORT} is busy (is \`vite dev\` running?). Paste the redirected URL below instead.`); } else { finish(err); } }); server.listen(LOOPBACK_PORT, 'localhost'); // Fallback: paste the full redirected URL (e.g. when the browser is on another machine). process.stdin.setEncoding('utf-8'); process.stdin.on('data', (chunk: string) => { const line = chunk.trim(); if (!line) return; try { finish(null, fromUrl(line)); } catch (err) { console.error((err as Error).message); } }); process.stdin.resume(); }); } export async function login(): Promise { const { clientId } = clientCredentials(); const state = randomUUID(); const url = new URL(AUTHORIZE_URL); url.searchParams.set('client_id', clientId); url.searchParams.set('scope', SCOPES); url.searchParams.set('response_type', 'code'); url.searchParams.set('redirect_uri', LOOPBACK_REDIRECT); url.searchParams.set('prompt', 'consent'); url.searchParams.set('state', state); console.log('Open this URL in a browser on this machine and approve access:\n'); console.log(` ${url.toString()}\n`); console.log(`Listening on ${LOOPBACK_REDIRECT}`); console.log('If the browser is on another machine, paste the URL it was redirected to (the page will fail to load) and press Enter.\n'); const code = await waitForCode(state); const res = await tokenRequest({ grant_type: 'authorization_code', code, redirect_uri: LOOPBACK_REDIRECT }); // Save immediately so the refresh token survives even if the identity lookup fails. let store = saveTokens(res, readTokens()); const identity = await fetchIdentity(store.access_token); store = { ...store, player_id: identity.id, player_name: identity.name }; writeFileAtomic(TOKENS_FILE, JSON.stringify(store, null, 2) + '\n', 0o600); return store; } export async function refresh(): Promise { const current = readTokens(); if (!current?.refresh_token) { throw new Error(`No refresh token in ${TOKENS_FILE}. Run \`auth login\` first.`); } try { const res = await tokenRequest({ grant_type: 'refresh_token', refresh_token: current.refresh_token }); return saveTokens(res, current); } catch (err) { throw new Error(`${(err as Error).message}\nThe refresh chain is broken. Run \`node scripts/archive/cli.ts auth login\` to seed it again.`); } } /** A valid access token, refreshing (and rotating the stored refresh token) when needed. */ export async function getAccessToken(minValidityMs = 5 * 60 * 1000): Promise { const current = readTokens(); if (current && current.access_expires_at - Date.now() > minValidityMs) return current.access_token; return (await refresh()).access_token; }