/** * Codex OAuth PKCE flow for ChatGPT Plus/Pro subscription authentication. * * Paste-back flow (no local HTTP callback) — the dashboard is typically * served from a Pi via Cloudflare tunnel, so a browser-side `localhost:1455` * callback can't reach the host running this code. We send the user through * OpenAI's auth page; their browser redirects to the (unreachable) callback * URL but its URL bar contains the `code`. The user pastes that URL or code * back into the wizard, which POSTs it here for token exchange. * * Credentials are stored in ~/.codex/auth.json in the same shape Codex CLI * itself writes, so a spawned `codex app-server` process can use them directly. * * Shape on disk (chatgpt mode): * { * "OPENAI_API_KEY": null, * "auth_mode": "chatgpt", * "tokens": { * "id_token": "", * "access_token": "", * "refresh_token": "...", * "account_id": "" * }, * "last_refresh": "2026-05-03T12:34:56.789Z" * } */ import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; import os from 'os'; import { log } from '../shared/logger.js'; const OAUTH_CONFIG = { AUTHORIZE_URL: 'https://auth.openai.com/oauth/authorize', TOKEN_URL: 'https://auth.openai.com/oauth/token', REDIRECT_URI: 'http://localhost:1455/auth/callback', CLIENT_ID: 'app_EMoamEEZ73f0CkXaXp7hrann', SCOPES: 'openid profile email offline_access', }; const AUTH_DIR = path.join(os.homedir(), '.codex'); const AUTH_FILE = path.join(AUTH_DIR, 'auth.json'); const LEGACY_AUTH_FILE = path.join(AUTH_DIR, 'codedeck-auth.json'); /** Refresh access tokens this many ms before they actually expire. */ const REFRESH_LEEWAY_MS = 5 * 60 * 1000; let codeVerifier: string | null = null; let oauthState: string | null = null; interface AuthDotJson { OPENAI_API_KEY: string | null; auth_mode?: 'apikey' | 'chatgpt' | 'chatgptAuthTokens' | 'agentIdentity'; tokens?: { id_token: string; access_token: string; refresh_token: string; account_id?: string | null; }; last_refresh?: string; [key: string]: unknown; } /* ── File I/O ── */ function readAuthFile(): AuthDotJson | null { try { if (!fs.existsSync(AUTH_FILE)) return null; return JSON.parse(fs.readFileSync(AUTH_FILE, 'utf-8')); } catch { return null; } } function writeAuthFile(auth: AuthDotJson): void { fs.mkdirSync(AUTH_DIR, { recursive: true }); fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), 'utf-8'); try { fs.chmodSync(AUTH_FILE, 0o600); } catch {} } /** Decode a JWT and return its parsed payload, or null on failure. */ function decodeJwt(token: string): Record | null { try { const parts = token.split('.'); if (parts.length < 2) return null; return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8')); } catch { return null; } } /** Read the JWT `exp` claim as a Unix epoch (ms). Null if missing/invalid. */ function jwtExpiryMs(token: string): number | null { const payload = decodeJwt(token); if (!payload || typeof payload.exp !== 'number') return null; return payload.exp * 1000; } /** Pull `chatgpt_account_id` out of the id_token JWT claims, if present. */ function extractAccountId(idToken: string): string | undefined { const payload = decodeJwt(idToken); const authClaims = payload?.['https://api.openai.com/auth'] || {}; return authClaims.chatgpt_account_id || undefined; } /** One-shot migration from the old `codedeck-auth.json` layout (pre-codex-native). */ function migrateLegacyFile(): void { if (fs.existsSync(AUTH_FILE)) { const existing = readAuthFile(); if (existing?.tokens?.refresh_token) return; } if (!fs.existsSync(LEGACY_AUTH_FILE)) return; try { const legacy = JSON.parse(fs.readFileSync(LEGACY_AUTH_FILE, 'utf-8')); if (!legacy.access_token || !legacy.refresh_token) return; const existing = readAuthFile() || { OPENAI_API_KEY: null }; const next: AuthDotJson = { ...existing, auth_mode: 'chatgpt', tokens: { id_token: legacy.id_token || '', access_token: legacy.access_token, refresh_token: legacy.refresh_token, account_id: legacy.chatgpt_account_id || extractAccountId(legacy.access_token), }, last_refresh: new Date().toISOString(), }; writeAuthFile(next); try { fs.unlinkSync(LEGACY_AUTH_FILE); } catch {} log.ok('Codex: migrated legacy codedeck-auth.json → auth.json'); } catch (err: any) { log.warn(`Codex: legacy migration failed — ${err.message}`); } } /* ── Token exchange & refresh ── */ function storeTokens(tokens: { access_token: string; refresh_token?: string; id_token?: string }): void { const existing = readAuthFile() || { OPENAI_API_KEY: null }; const prev = existing.tokens; const idToken = tokens.id_token ?? prev?.id_token ?? ''; const next: AuthDotJson = { ...existing, OPENAI_API_KEY: existing.OPENAI_API_KEY ?? null, auth_mode: 'chatgpt', tokens: { id_token: idToken, access_token: tokens.access_token, refresh_token: tokens.refresh_token ?? prev?.refresh_token ?? '', account_id: idToken ? extractAccountId(idToken) ?? prev?.account_id : prev?.account_id, }, last_refresh: new Date().toISOString(), }; writeAuthFile(next); } async function refreshTokens(refreshToken: string): Promise { try { log.ok('Codex: refreshing access token...'); const response = await fetch(OAUTH_CONFIG.TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_id: OAUTH_CONFIG.CLIENT_ID, grant_type: 'refresh_token', refresh_token: refreshToken, }), }); if (!response.ok) { log.warn(`Codex: refresh failed (${response.status})`); return false; } const tokens = await response.json(); if (!tokens.access_token) { log.warn('Codex: refresh response missing access_token'); return false; } storeTokens(tokens); log.ok('Codex: token refreshed'); return true; } catch (err: any) { log.warn(`Codex: refresh error — ${err.message}`); return false; } } /** * Parse what the user pasted — accepts: * - the full callback URL (`http://localhost:1455/auth/callback?code=...&state=...`) * - just the query string (`?code=...&state=...` or `code=...&state=...`) * - just the raw code (`ac_XXX...`) */ function parsePastedInput(input: string): { code: string; state?: string } | { error: string } { const trimmed = input.trim(); if (!trimmed) return { error: 'Paste the URL or code from your browser.' }; // Full URL or just a path if (/^https?:\/\//i.test(trimmed) || trimmed.startsWith('/')) { try { const url = new URL(trimmed.startsWith('/') ? `http://x${trimmed}` : trimmed); const code = url.searchParams.get('code'); if (!code) return { error: 'URL is missing the code parameter.' }; return { code, state: url.searchParams.get('state') || undefined }; } catch { return { error: 'Could not parse the pasted URL.' }; } } // Bare query string if (trimmed.includes('=') && (trimmed.includes('&') || trimmed.startsWith('?'))) { try { const qs = trimmed.startsWith('?') ? trimmed.slice(1) : trimmed; const params = new URLSearchParams(qs); const code = params.get('code'); if (!code) return { error: 'Query string is missing the code parameter.' }; return { code, state: params.get('state') || undefined }; } catch { return { error: 'Could not parse the pasted query string.' }; } } // Treat as raw code return { code: trimmed }; } /* ── Public API ── */ export function startCodexOAuth(): { success: boolean; authUrl?: string; error?: string } { codeVerifier = crypto.randomBytes(32).toString('base64url'); const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url'); oauthState = crypto.randomUUID(); const params = new URLSearchParams({ response_type: 'code', client_id: OAUTH_CONFIG.CLIENT_ID, redirect_uri: OAUTH_CONFIG.REDIRECT_URI, scope: OAUTH_CONFIG.SCOPES, code_challenge: codeChallenge, code_challenge_method: 'S256', state: oauthState, id_token_add_organizations: 'true', codex_cli_simplified_flow: 'true', }); log.ok('Codex OAuth flow started (paste-back mode)'); return { success: true, authUrl: `${OAUTH_CONFIG.AUTHORIZE_URL}?${params.toString()}` }; } export async function exchangeCodexCode(input: string): Promise<{ success: boolean; error?: string }> { if (!codeVerifier || !oauthState) { return { success: false, error: 'Authentication wasn\'t started. Click "Authenticate" first.' }; } const parsed = parsePastedInput(input); if ('error' in parsed) return { success: false, error: parsed.error }; if (parsed.state && parsed.state !== oauthState) { log.warn(`Codex OAuth: state mismatch (got=${parsed.state}, expected=${oauthState})`); return { success: false, error: 'State mismatch — start the flow again from the wizard.', }; } try { const payload = new URLSearchParams({ grant_type: 'authorization_code', client_id: OAUTH_CONFIG.CLIENT_ID, code: parsed.code, redirect_uri: OAUTH_CONFIG.REDIRECT_URI, code_verifier: codeVerifier, }); const response = await fetch(OAUTH_CONFIG.TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: payload.toString(), }); if (!response.ok) { const body = await response.text().catch(() => ''); log.warn(`Codex OAuth exchange failed (${response.status}): ${body.slice(0, 200)}`); return { success: false, error: `Authentication failed (${response.status}). Codes are single-use — start over and paste a fresh one.`, }; } const tokens = await response.json(); if (!tokens.access_token) { return { success: false, error: 'OAuth response missing access_token.' }; } storeTokens(tokens); codeVerifier = null; oauthState = null; try { fs.unlinkSync(LEGACY_AUTH_FILE); } catch {} log.ok('Codex credentials stored'); return { success: true }; } catch (err: any) { return { success: false, error: err.message }; } } export function cancelCodexOAuth(): void { codeVerifier = null; oauthState = null; } export async function getCodexAuthStatus(): Promise<{ authenticated: boolean; plan?: string; error?: string; }> { try { migrateLegacyFile(); const auth = readAuthFile(); const tokens = auth?.tokens; if (!tokens?.access_token) return { authenticated: false }; const expMs = jwtExpiryMs(tokens.access_token); const valid = expMs ? Date.now() + REFRESH_LEEWAY_MS < expMs : true; if (!valid && tokens.refresh_token) { const ok = await refreshTokens(tokens.refresh_token); if (!ok) return { authenticated: false, error: 'Token expired and refresh failed' }; } const fresh = readAuthFile(); const idClaims = fresh?.tokens?.id_token ? decodeJwt(fresh.tokens.id_token) : null; const plan = idClaims?.['https://api.openai.com/auth']?.chatgpt_plan_type || 'plus'; return { authenticated: true, plan }; } catch (err: any) { return { authenticated: false, error: err.message }; } } /** * Read a valid Codex access token, refreshing if expired. Returns null if unavailable. * This is what the Codex harness should call before each app-server invocation. */ export async function getCodexAccessToken(): Promise { migrateLegacyFile(); const auth = readAuthFile(); const tokens = auth?.tokens; if (!tokens?.access_token) return null; const expMs = jwtExpiryMs(tokens.access_token); if (!expMs || Date.now() + REFRESH_LEEWAY_MS < expMs) { return tokens.access_token; } if (!tokens.refresh_token) return null; const ok = await refreshTokens(tokens.refresh_token); if (!ok) return null; return readAuthFile()?.tokens?.access_token ?? null; } /** Synchronous accessor — returns the stored access token without refreshing. */ export function readCodexAccessToken(): string | null { const auth = readAuthFile(); const token = auth?.tokens?.access_token; if (!token) return null; const expMs = jwtExpiryMs(token); if (expMs && Date.now() >= expMs) return null; return token; } /* ──────────────────────────────────────────────────────────────────────────── * Device-code flow — preferred for headless / remote dashboards. * * 1. POST {AUTH_BASE}/api/accounts/deviceauth/usercode body {client_id} * → { device_auth_id, user_code, interval } * 2. Poll POST {AUTH_BASE}/api/accounts/deviceauth/token body {device_auth_id, user_code} * 403/404 = pending, 2xx = { authorization_code, code_challenge, code_verifier } * 3. POST {AUTH_BASE}/oauth/token body {grant_type, client_id, code, code_verifier, * redirect_uri="{AUTH_BASE}/deviceauth/callback"} → standard token response * * The user opens DEVICE_VERIFICATION_URL and types user_code there. We poll * in the background; the wizard polls /api/auth/codex/device/status for the * current state. * ──────────────────────────────────────────────────────────────────────────── */ const AUTH_BASE = 'https://auth.openai.com'; const DEVICE_USER_CODE_URL = `${AUTH_BASE}/api/accounts/deviceauth/usercode`; const DEVICE_POLL_URL = `${AUTH_BASE}/api/accounts/deviceauth/token`; const DEVICE_REDIRECT_URI = `${AUTH_BASE}/deviceauth/callback`; const DEVICE_VERIFICATION_URL = `${AUTH_BASE}/codex/device`; const DEVICE_TIMEOUT_MS = 15 * 60 * 1000; const DEVICE_DEFAULT_INTERVAL_SEC = 5; interface DeviceLoginState { /** Bumped on each `startDeviceCodeLogin` so a stale poll loop can self-cancel. */ generation: number; state: 'idle' | 'pending' | 'success' | 'error'; userCode?: string; verificationUrl?: string; expiresAt?: number; // epoch ms error?: string; } let deviceLogin: DeviceLoginState = { generation: 0, state: 'idle' }; export function getDeviceCodeStatus(): { state: DeviceLoginState['state']; userCode?: string; verificationUrl?: string; expiresInSec?: number; error?: string; } { const expiresInSec = deviceLogin.expiresAt ? Math.max(0, Math.round((deviceLogin.expiresAt - Date.now()) / 1000)) : undefined; return { state: deviceLogin.state, userCode: deviceLogin.userCode, verificationUrl: deviceLogin.verificationUrl, expiresInSec, error: deviceLogin.error, }; } export function cancelDeviceCodeLogin(): void { // Bumping the generation makes any in-flight poll loop a no-op on its next tick. deviceLogin = { generation: deviceLogin.generation + 1, state: 'idle' }; log.ok('Codex device-code login cancelled'); } export async function startDeviceCodeLogin(): Promise<{ success: boolean; userCode?: string; verificationUrl?: string; error?: string; }> { // Cancel any existing in-flight login before starting a new one. const generation = deviceLogin.generation + 1; deviceLogin = { generation, state: 'pending' }; try { const res = await fetch(DEVICE_USER_CODE_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'User-Agent': 'bloby-bot', }, body: JSON.stringify({ client_id: OAUTH_CONFIG.CLIENT_ID }), }); if (!res.ok) { const body = await res.text().catch(() => ''); const error = `Failed to request device code (${res.status}). ${body.slice(0, 200)}`; deviceLogin = { generation, state: 'error', error }; log.warn(`Codex device-code start failed: ${error}`); return { success: false, error }; } const data = await res.json(); const userCode: string = data.user_code || data.usercode; const deviceAuthId: string = data.device_auth_id; const intervalSec: number = Number(data.interval) || DEVICE_DEFAULT_INTERVAL_SEC; if (!userCode || !deviceAuthId) { const error = 'Device code response missing user_code or device_auth_id.'; deviceLogin = { generation, state: 'error', error }; return { success: false, error }; } deviceLogin = { generation, state: 'pending', userCode, verificationUrl: DEVICE_VERIFICATION_URL, expiresAt: Date.now() + DEVICE_TIMEOUT_MS, }; log.ok(`Codex device-code login started (code=${userCode}, poll every ${intervalSec}s)`); // Fire-and-forget background poll. void pollDeviceCode(generation, deviceAuthId, userCode, intervalSec); return { success: true, userCode, verificationUrl: DEVICE_VERIFICATION_URL }; } catch (err: any) { const error = err?.message || String(err); deviceLogin = { generation, state: 'error', error }; log.warn(`Codex device-code start error: ${error}`); return { success: false, error }; } } async function pollDeviceCode( generation: number, deviceAuthId: string, userCode: string, intervalSec: number, ): Promise { const startedAt = Date.now(); while (true) { // Stale generation = a newer login was started or this one was cancelled. if (deviceLogin.generation !== generation) { log.ok(`Codex device-code poll for gen ${generation} stopped (superseded)`); return; } if (Date.now() - startedAt > DEVICE_TIMEOUT_MS) { deviceLogin = { generation, state: 'error', error: 'Device-code login timed out (15 minutes). Try again.', }; log.warn('Codex device-code login timed out'); return; } let res: Response; try { res = await fetch(DEVICE_POLL_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'User-Agent': 'bloby-bot', }, body: JSON.stringify({ device_auth_id: deviceAuthId, user_code: userCode }), }); } catch (err: any) { // Transient network errors — log and keep polling within timeout. log.warn(`Codex device-code poll network error: ${err?.message || err}`); await sleep(intervalSec * 1000); continue; } if (res.status === 403 || res.status === 404) { // Pending — user hasn't approved yet. await sleep(intervalSec * 1000); continue; } if (!res.ok) { const body = await res.text().catch(() => ''); const error = `Device-code poll failed (${res.status}). ${body.slice(0, 200)}`; if (deviceLogin.generation === generation) { deviceLogin = { generation, state: 'error', error }; } log.warn(error); return; } // 2xx — server returned the authorization_code + PKCE verifier. let data: { authorization_code?: string; code_verifier?: string }; try { data = await res.json(); } catch (err: any) { const error = `Device-code poll: malformed JSON (${err?.message || err})`; if (deviceLogin.generation === generation) { deviceLogin = { generation, state: 'error', error }; } return; } if (!data.authorization_code || !data.code_verifier) { const error = 'Device-code poll: response missing authorization_code or code_verifier.'; if (deviceLogin.generation === generation) { deviceLogin = { generation, state: 'error', error }; } return; } // Final step: exchange for tokens. const exchanged = await exchangeDeviceCode(data.authorization_code, data.code_verifier); if (deviceLogin.generation !== generation) return; if (exchanged.success) { deviceLogin = { generation, state: 'success' }; log.ok('Codex device-code login complete'); } else { deviceLogin = { generation, state: 'error', error: exchanged.error || 'Token exchange failed.', }; } return; } } async function exchangeDeviceCode( authorizationCode: string, codeVerifierFromServer: string, ): Promise<{ success: boolean; error?: string }> { try { const payload = new URLSearchParams({ grant_type: 'authorization_code', client_id: OAUTH_CONFIG.CLIENT_ID, code: authorizationCode, redirect_uri: DEVICE_REDIRECT_URI, code_verifier: codeVerifierFromServer, }); const res = await fetch(OAUTH_CONFIG.TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': 'bloby-bot', }, body: payload.toString(), }); if (!res.ok) { const body = await res.text().catch(() => ''); log.warn(`Codex device-code token exchange failed (${res.status}): ${body.slice(0, 200)}`); return { success: false, error: `Token exchange failed (${res.status}).` }; } const tokens = await res.json(); if (!tokens.access_token) { return { success: false, error: 'Token exchange response missing access_token.' }; } storeTokens(tokens); try { fs.unlinkSync(LEGACY_AUTH_FILE); } catch {} return { success: true }; } catch (err: any) { return { success: false, error: err?.message || String(err) }; } } function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); }