import { readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs'; import { resolve } from 'path'; import { homedir } from 'os'; const ISSUER = 'https://auth.openai.com'; const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'; const DEVICE_URL = `${ISSUER}/codex/device`; const DEVICE_CODE_URL = `${ISSUER}/api/accounts/deviceauth/usercode`; const DEVICE_TOKEN_URL = `${ISSUER}/api/accounts/deviceauth/token`; const TOKEN_URL = `${ISSUER}/oauth/token`; const POLLING_SAFETY_MARGIN_MS = 3000; const REFRESH_SAFETY_MARGIN_MS = 30_000; const AUTH_DIR = resolve(homedir(), '.config/flynn'); const AUTH_FILE = resolve(AUTH_DIR, 'auth.json'); export interface OpenAIOAuthInfo { access_token: string; refresh_token: string; /** Epoch millis. */ expires_at: number; /** Optional account/org id used for subscription routing. */ account_id?: string; created_at: string; } export interface OpenAIApiKeyInfo { api_key: string; created_at: string; } interface OpenAIStoreEntry { oauth?: OpenAIOAuthInfo; api_key?: OpenAIApiKeyInfo; } interface AuthStore { // Leave github entry untyped here so this module does not depend on github.ts. github?: unknown; /** OpenAI credentials. Backward compatible with legacy OAuth-only entries. */ openai?: OpenAIStoreEntry | OpenAIOAuthInfo; } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } function isOpenAIOAuthInfo(value: unknown): value is OpenAIOAuthInfo { if (!isRecord(value)) { return false; } return typeof value.access_token === 'string' && typeof value.refresh_token === 'string' && typeof value.expires_at === 'number' && typeof value.created_at === 'string'; } function isOpenAIApiKeyInfo(value: unknown): value is OpenAIApiKeyInfo { if (!isRecord(value)) { return false; } return typeof value.api_key === 'string' && typeof value.created_at === 'string'; } function readOpenAIEntry(store: AuthStore): OpenAIStoreEntry | null { const raw = store.openai as unknown; if (!raw) { return null; } // Legacy format: auth.json.openai stored the OAuth info directly. if (isOpenAIOAuthInfo(raw)) { return { oauth: raw }; } if (!isRecord(raw)) { return null; } const oauth = isOpenAIOAuthInfo(raw.oauth) ? raw.oauth : undefined; const apiKey = isOpenAIApiKeyInfo(raw.api_key) ? raw.api_key : undefined; return { oauth, api_key: apiKey }; } function writeOpenAIEntry(store: AuthStore, entry: OpenAIStoreEntry | null): void { if (!entry || (!entry.oauth && !entry.api_key)) { delete store.openai; return; } store.openai = entry; } interface DeviceAuthResponse { device_auth_id: string; user_code: string; interval: string; } interface DeviceTokenResponse { authorization_code: string; code_verifier: string; } interface TokenResponse { id_token?: string; access_token: string; refresh_token: string; expires_in?: number; } export interface IdTokenClaims { chatgpt_account_id?: string; organizations?: Array<{ id: string }>; 'https://api.openai.com/auth'?: { chatgpt_account_id?: string; }; } function safeJsonParse(raw: string): T | null { try { return JSON.parse(raw) as T; } catch { return null; } } function readAuthStore(): AuthStore { try { const raw = readFileSync(AUTH_FILE, 'utf-8'); const parsed = safeJsonParse(raw); return parsed ?? {}; } catch { return {}; } } function writeAuthStore(store: AuthStore): void { mkdirSync(AUTH_DIR, { recursive: true }); writeFileSync(AUTH_FILE, JSON.stringify(store, null, 2) + '\n', 'utf-8'); chmodSync(AUTH_FILE, 0o600); } export function loadStoredOpenAIAuth(): OpenAIOAuthInfo | null { const store = readAuthStore(); const entry = readOpenAIEntry(store); return entry?.oauth ?? null; } export function storeOpenAIAuth(info: OpenAIOAuthInfo): void { const store = readAuthStore(); const entry = readOpenAIEntry(store) ?? {}; entry.oauth = info; writeOpenAIEntry(store, entry); writeAuthStore(store); } export function clearOpenAIAuth(): void { const store = readAuthStore(); const entry = readOpenAIEntry(store); if (entry) { delete entry.oauth; writeOpenAIEntry(store, entry); } else { delete store.openai; } writeAuthStore(store); } export function loadStoredOpenAIApiKey(): string | null { const store = readAuthStore(); const entry = readOpenAIEntry(store); return entry?.api_key?.api_key ?? null; } export function storeOpenAIApiKey(key: string): void { const trimmed = key.trim(); if (!trimmed) { throw new Error('OpenAI API key is empty'); } const store = readAuthStore(); const entry = readOpenAIEntry(store) ?? {}; entry.api_key = { api_key: trimmed, created_at: new Date().toISOString() }; writeOpenAIEntry(store, entry); writeAuthStore(store); } export function clearOpenAIApiKey(): void { const store = readAuthStore(); const entry = readOpenAIEntry(store); if (!entry) { return; } delete entry.api_key; writeOpenAIEntry(store, entry); writeAuthStore(store); } /** * Get an OpenAI API key from any available source. * Priority: OPENAI_API_KEY → stored auth.json. */ export function getOpenAIApiKey(): string | null { return process.env.OPENAI_API_KEY ?? loadStoredOpenAIApiKey() ?? null; } export function parseJwtClaims(token: string): IdTokenClaims | undefined { const parts = token.split('.'); if (parts.length !== 3) {return undefined;} try { return JSON.parse(Buffer.from(parts[1], 'base64url').toString()) as IdTokenClaims; } catch { return undefined; } } function extractAccountIdFromClaims(claims: IdTokenClaims): string | undefined { return claims.chatgpt_account_id ?? claims['https://api.openai.com/auth']?.chatgpt_account_id ?? claims.organizations?.[0]?.id; } export function extractAccountId(tokens: TokenResponse): string | undefined { const idToken = tokens.id_token; if (idToken) { const claims = parseJwtClaims(idToken); const id = claims && extractAccountIdFromClaims(claims); if (id) {return id;} } const accessToken = tokens.access_token; if (accessToken) { const claims = parseJwtClaims(accessToken); return claims ? extractAccountIdFromClaims(claims) : undefined; } return undefined; } async function requestDeviceAuth(): Promise { const response = await fetch(DEVICE_CODE_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'User-Agent': 'flynn', }, body: JSON.stringify({ client_id: CLIENT_ID }), }); if (!response.ok) { const body = await response.text(); throw new Error(`OpenAI device auth start failed (${response.status}): ${body}`); } return response.json() as Promise; } async function pollDeviceToken(deviceAuthId: string, userCode: string, intervalMs: number): Promise { while (true) { await new Promise(r => setTimeout(r, intervalMs + POLLING_SAFETY_MARGIN_MS)); const response = await fetch(DEVICE_TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'User-Agent': 'flynn', }, body: JSON.stringify({ device_auth_id: deviceAuthId, user_code: userCode }), }); if (response.ok) { return response.json() as Promise; } // OpenCode treats 403/404 as "pending". if (response.status === 403 || response.status === 404) { continue; } const body = await response.text(); throw new Error(`OpenAI device auth token failed (${response.status}): ${body}`); } } async function exchangeAuthorizationCode(authCode: string, codeVerifier: string): Promise { const response = await fetch(TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': 'flynn', }, body: new URLSearchParams({ grant_type: 'authorization_code', code: authCode, redirect_uri: `${ISSUER}/deviceauth/callback`, client_id: CLIENT_ID, code_verifier: codeVerifier, }).toString(), }); if (!response.ok) { const body = await response.text(); throw new Error(`OpenAI token exchange failed (${response.status}): ${body}`); } return response.json() as Promise; } export async function refreshOpenAIAuth(refreshToken: string): Promise { const response = await fetch(TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': 'flynn', }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: CLIENT_ID, }).toString(), }); if (!response.ok) { const body = await response.text(); throw new Error(`OpenAI token refresh failed (${response.status}): ${body}`); } return response.json() as Promise; } /** * Ensure we have a valid (non-expired) OpenAI OAuth access token. * Refreshes and persists the token if needed. */ export async function ensureValidOpenAIAuth(): Promise { const current = loadStoredOpenAIAuth(); if (!current) { throw new Error('OpenAI OAuth is not configured. Run `flynn openai-auth` to authenticate.'); } if (current.expires_at > Date.now() + REFRESH_SAFETY_MARGIN_MS) { return current; } const refreshed = await refreshOpenAIAuth(current.refresh_token); const expiresAt = Date.now() + (refreshed.expires_in ?? 3600) * 1000; const accountId = extractAccountId(refreshed) ?? current.account_id; const updated: OpenAIOAuthInfo = { access_token: refreshed.access_token, refresh_token: refreshed.refresh_token, expires_at: expiresAt, account_id: accountId, created_at: current.created_at, }; storeOpenAIAuth(updated); return updated; } /** * Run the OpenAI Codex device flow interactively. * @param onPrompt Callback to display the user code and verification URL to the user. */ export async function loginOpenAI( onPrompt: (userCode: string, verificationUri: string) => void, ): Promise { const device = await requestDeviceAuth(); const intervalMs = Math.max(parseInt(device.interval) || 5, 1) * 1000; onPrompt(device.user_code, DEVICE_URL); const deviceToken = await pollDeviceToken(device.device_auth_id, device.user_code, intervalMs); const tokens = await exchangeAuthorizationCode(deviceToken.authorization_code, deviceToken.code_verifier); const expiresAt = Date.now() + (tokens.expires_in ?? 3600) * 1000; const accountId = extractAccountId(tokens); const info: OpenAIOAuthInfo = { access_token: tokens.access_token, refresh_token: tokens.refresh_token, expires_at: expiresAt, ...(accountId ? { account_id: accountId } : {}), created_at: new Date().toISOString(), }; storeOpenAIAuth(info); return info; }