import { createServer, type Server } from "node:http"; import { randomBytes, createHash } from "node:crypto"; import type { OAuthDeviceCodeInfo } from "@earendil-works/pi-ai/oauth"; import type { Api, Model } from "@earendil-works/pi-ai"; import type { ProviderConfig } from "@earendil-works/pi-coding-agent"; import { type OAuthDeviceCodePollResult, pollOAuthDeviceCodeFlow, } from "./device-code.ts"; import { supportsResponsesLiteModel } from "./responses-lite-model.ts"; const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; const AUTH_BASE_URL = "https://auth.openai.com"; const AUTHORIZE_URL = `${AUTH_BASE_URL}/oauth/authorize`; const TOKEN_URL = `${AUTH_BASE_URL}/oauth/token`; const REDIRECT_URI = "http://localhost:1455/auth/callback"; const DEVICE_USER_CODE_URL = `${AUTH_BASE_URL}/api/accounts/deviceauth/usercode`; const DEVICE_TOKEN_URL = `${AUTH_BASE_URL}/api/accounts/deviceauth/token`; const DEVICE_VERIFICATION_URI = `${AUTH_BASE_URL}/codex/device`; const DEVICE_REDIRECT_URI = `${AUTH_BASE_URL}/deviceauth/callback`; const DEVICE_CODE_TIMEOUT_SECONDS = 15 * 60; const JWT_CLAIM_PATH = "https://api.openai.com/auth"; const GPT_56_PRODUCTION_CONTEXT_WINDOW = 272_000; function oauthSuccessHtml(message: string): string { return `Login complete${message}`; } function oauthErrorHtml(message: string): string { return `Login error${message}`; } export const OPENAI_CODEX_NATIVE_SCOPE = "openid profile email offline_access api.connectors.read api.connectors.invoke"; type OAuthCredentials = { access: string; refresh: string; expires: number; accountId: string }; type OAuthCallbacks = Parameters["login"]>[0]; type DeviceAuthToken = { authorization_code: string; code_verifier: string }; function getCallbackHost(): string { return process.env["PI_OAUTH_CALLBACK_HOST"] || "127.0.0.1"; } function base64Url(bytes: Buffer): string { return bytes.toString("base64url"); } function createState(): string { return randomBytes(16).toString("hex"); } async function createPkce(): Promise<{ verifier: string; challenge: string }> { const verifier = base64Url(randomBytes(32)); const challenge = createHash("sha256").update(verifier).digest("base64url"); return { verifier, challenge }; } function decodeJwt(token: string): Record | null { try { return JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8")); } catch { return null; } } export function getOpenAICodexAccountId(accessToken: string): string | null { const auth = decodeJwt(accessToken)?.[JWT_CLAIM_PATH] as { chatgpt_account_id?: unknown } | undefined; return typeof auth?.chatgpt_account_id === "string" && auth.chatgpt_account_id ? auth.chatgpt_account_id : null; } export function clampOpenAICodexModelWindows(models: Model[]): Model[] { return models.map((model) => // Temporary: remove this clamp as soon as OpenAI confirms 372k production // context caching is fixed. Overestimating currently delays Pi compaction. supportsResponsesLiteModel(model) && model.contextWindow > GPT_56_PRODUCTION_CONTEXT_WINDOW ? { ...model, contextWindow: GPT_56_PRODUCTION_CONTEXT_WINDOW } : model, ); } function compactCodeState(code: string | null | undefined, state?: string | null | undefined): { code?: string; state?: string } { return { ...(code ? { code } : {}), ...(state ? { state } : {}) }; } function parseAuthorizationInput(input: string): { code?: string; state?: string } { const value = input.trim(); if (!value) return {}; try { const url = new URL(value); return compactCodeState(url.searchParams.get("code"), url.searchParams.get("state")); } catch {} if (value.includes("#")) { const [code, state] = value.split("#", 2); return compactCodeState(code, state); } if (value.includes("code=")) { const params = new URLSearchParams(value); return compactCodeState(params.get("code"), params.get("state")); } return { code: value }; } export async function createOpenAICodexNativeAuthorizationFlow(originator = "pi"): Promise<{ verifier: string; state: string; url: string }> { const { verifier, challenge } = await createPkce(); const state = createState(); const url = new URL(AUTHORIZE_URL); url.searchParams.set("response_type", "code"); url.searchParams.set("client_id", CLIENT_ID); url.searchParams.set("redirect_uri", REDIRECT_URI); url.searchParams.set("scope", OPENAI_CODEX_NATIVE_SCOPE); url.searchParams.set("code_challenge", challenge); url.searchParams.set("code_challenge_method", "S256"); url.searchParams.set("state", state); url.searchParams.set("id_token_add_organizations", "true"); url.searchParams.set("codex_cli_simplified_flow", "true"); url.searchParams.set("originator", originator); return { verifier, state, url: url.toString() }; } async function tokenRequest(body: URLSearchParams, operation: string, signal?: AbortSignal): Promise { const response = await fetch(TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body, signal: signal ?? null }); if (!response.ok) throw new Error(`OpenAI Codex token ${operation} failed (${response.status}): ${await response.text().catch(() => response.statusText)}`); const json = await response.json() as { access_token?: string; refresh_token?: string; expires_in?: number }; if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") throw new Error(`OpenAI Codex token ${operation} response missing fields: ${JSON.stringify(json)}`); const accountId = getOpenAICodexAccountId(json.access_token); if (!accountId) throw new Error("Failed to extract accountId from OpenAI Codex token"); return { access: json.access_token, refresh: json.refresh_token, expires: Date.now() + json.expires_in * 1000, accountId }; } async function exchangeAuthorizationCode(code: string, verifier: string, redirectUri: string, signal?: AbortSignal): Promise { return tokenRequest(new URLSearchParams({ grant_type: "authorization_code", client_id: CLIENT_ID, code, code_verifier: verifier, redirect_uri: redirectUri }), "exchange", signal); } function startLocalOAuthServer(state: string): Promise<{ close: () => void; cancelWait: () => void; waitForCode: () => Promise<{ code: string } | null> }> { let server: Server; let settleWait: ((value: { code: string } | null) => void) | undefined; const waitForCodePromise = new Promise<{ code: string } | null>((resolve) => { settleWait = resolve; }); server = createServer((req, res) => { try { const url = new URL(req.url || "", "http://localhost"); if (url.pathname !== "/auth/callback") { res.statusCode = 404; res.end(oauthErrorHtml("Callback route not found.")); return; } if (url.searchParams.get("state") !== state) { res.statusCode = 400; res.end(oauthErrorHtml("State mismatch.")); return; } const code = url.searchParams.get("code"); if (!code) { res.statusCode = 400; res.end(oauthErrorHtml("Missing authorization code.")); return; } res.statusCode = 200; res.setHeader("Content-Type", "text/html; charset=utf-8"); res.end(oauthSuccessHtml("OpenAI authentication completed. You can close this window.")); settleWait?.({ code }); } catch { res.statusCode = 500; res.end(oauthErrorHtml("Internal error while processing OAuth callback.")); } }); return new Promise((resolve) => { server.listen(1455, getCallbackHost(), () => resolve({ close: () => server.close(), cancelWait: () => settleWait?.(null), waitForCode: () => waitForCodePromise })) .on("error", () => resolve({ close: () => {}, cancelWait: () => {}, waitForCode: async () => null })); }); } async function loginBrowser(callbacks: OAuthCallbacks): Promise { const { verifier, state, url } = await createOpenAICodexNativeAuthorizationFlow("pi"); const server = await startLocalOAuthServer(state); callbacks.onAuth({ url, instructions: "A browser window should open. Complete login to finish." }); try { let manualInput: string | undefined; let manualError: Error | undefined; if (callbacks.onManualCodeInput) { void callbacks.onManualCodeInput().then((value) => { manualInput = value; server.cancelWait(); }).catch((error) => { manualError = error instanceof Error ? error : new Error(String(error)); server.cancelWait(); }); } let code = (await server.waitForCode())?.code; if (manualError) throw manualError; if (!code && manualInput) { const parsed = parseAuthorizationInput(manualInput); if (parsed.state && parsed.state !== state) throw new Error("State mismatch"); code = parsed.code; } if (!code) { const input = await callbacks.onPrompt({ message: "Paste the authorization code (or full redirect URL):" }); const parsed = parseAuthorizationInput(input); if (parsed.state && parsed.state !== state) throw new Error("State mismatch"); code = parsed.code; } if (!code) throw new Error("Missing authorization code"); return exchangeAuthorizationCode(code, verifier, REDIRECT_URI, callbacks.signal); } finally { server.close(); } } export async function parseOpenAICodexDeviceAuthPollResponse( response: Response, ): Promise> { if (response.ok) { const json = await response.json() as Partial | null; return json?.authorization_code && json.code_verifier ? { status: "complete", value: { authorization_code: json.authorization_code, code_verifier: json.code_verifier } } : { status: "failed", message: `Invalid OpenAI Codex device auth token response: ${JSON.stringify(json)}` }; } if (response.status === 403 || response.status === 404) return { status: "pending" }; const responseBody = await response.text().catch(() => ""); let errorCode: unknown; try { const json = JSON.parse(responseBody) as { error?: string | { code?: string } } | null; const error = json?.error; errorCode = typeof error === "object" ? error?.code : error; } catch {} if (errorCode === "deviceauth_authorization_pending") return { status: "pending" }; if (errorCode === "slow_down") return { status: "slow_down" }; return { status: "failed", message: `OpenAI Codex device auth failed with status ${response.status}${responseBody ? `: ${responseBody}` : ""}`, }; } async function loginDeviceCode(callbacks: OAuthCallbacks): Promise { const response = await fetch(DEVICE_USER_CODE_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ client_id: CLIENT_ID }), signal: callbacks.signal ?? null }); if (!response.ok) throw new Error(`OpenAI Codex device code request failed with status ${response.status}: ${await response.text().catch(() => response.statusText)}`); const json = await response.json() as { device_auth_id?: string; user_code?: string; interval?: number | string }; const intervalSeconds = typeof json.interval === "string" ? Number(json.interval.trim()) : json.interval; if (!json.device_auth_id || !json.user_code || typeof intervalSeconds !== "number" || !Number.isFinite(intervalSeconds)) throw new Error(`Invalid OpenAI Codex device code response: ${JSON.stringify(json)}`); callbacks.onDeviceCode({ userCode: json.user_code, verificationUri: DEVICE_VERIFICATION_URI, intervalSeconds, expiresInSeconds: DEVICE_CODE_TIMEOUT_SECONDS } satisfies OAuthDeviceCodeInfo); const code = await pollOAuthDeviceCodeFlow({ intervalSeconds, expiresInSeconds: DEVICE_CODE_TIMEOUT_SECONDS, signal: callbacks.signal ?? new AbortController().signal, poll: async () => { const pollResponse = await fetch(DEVICE_TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ device_auth_id: json.device_auth_id, user_code: json.user_code }), signal: callbacks.signal ?? null }); return parseOpenAICodexDeviceAuthPollResponse(pollResponse); }, }); if (!code.authorization_code || !code.code_verifier) throw new Error("Invalid OpenAI Codex device auth token response"); return exchangeAuthorizationCode(code.authorization_code, code.code_verifier, DEVICE_REDIRECT_URI, callbacks.signal); } export const openaiCodexNativeOAuthProvider: NonNullable & { usesCallbackServer: true } = { name: "ChatGPT Plus/Pro (Codex Subscription)", usesCallbackServer: true, async login(callbacks) { const method = await callbacks.onSelect({ message: "Select OpenAI Codex login method:", options: [{ id: "browser", label: "Browser login (default)" }, { id: "device_code", label: "Device code login (headless)" }] }); if (method === "device_code") return loginDeviceCode(callbacks); if (method && method !== "browser") throw new Error(`Unknown OpenAI Codex login method: ${method}`); return loginBrowser(callbacks); }, refreshToken(credentials) { return tokenRequest(new URLSearchParams({ grant_type: "refresh_token", refresh_token: credentials.refresh, client_id: CLIENT_ID }), "refresh"); }, getApiKey(credentials) { return credentials.access; }, modifyModels(models) { return clampOpenAICodexModelWindows(models); }, };