diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 9d9935d5..ba60d6c4 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added OpenAI Codex subscription device-code login as a selectable headless alternative while keeping browser login as the default. + ## [0.76.0] - 2026-05-27 ### Fixed diff --git a/packages/ai/src/utils/oauth/device-code.ts b/packages/ai/src/utils/oauth/device-code.ts index 95dba2e7..1121136f 100644 --- a/packages/ai/src/utils/oauth/device-code.ts +++ b/packages/ai/src/utils/oauth/device-code.ts @@ -8,16 +8,17 @@ const DEFAULT_POLL_INTERVAL_SECONDS = 5; // RFC 8628 section 3.5: `slow_down` means the polling interval must increase by 5 seconds. const SLOW_DOWN_INTERVAL_INCREMENT_MS = 5000; -export type OAuthDeviceCodePollResult = +type OAuthDeviceCodeIncompletePollResult = | { status: "pending" } | { status: "slow_down" } - | { status: "complete"; accessToken: string } | { status: "failed"; message: string }; -export type OAuthDeviceCodePollOptions = { +export type OAuthDeviceCodePollResult = OAuthDeviceCodeIncompletePollResult | { status: "complete"; value: T }; + +export type OAuthDeviceCodePollOptions = { intervalSeconds?: number; expiresInSeconds?: number; - poll: () => Promise; + poll: () => Promise>; signal?: AbortSignal; }; @@ -41,7 +42,7 @@ function abortableSleep(ms: number, signal: AbortSignal | undefined, cancelMessa }); } -export async function pollOAuthDeviceCodeFlow(options: OAuthDeviceCodePollOptions): Promise { +export async function pollOAuthDeviceCodeFlow(options: OAuthDeviceCodePollOptions): Promise { const deadline = typeof options.expiresInSeconds === "number" ? Date.now() + options.expiresInSeconds * 1000 @@ -57,23 +58,25 @@ export async function pollOAuthDeviceCodeFlow(options: OAuthDeviceCodePollOption throw new Error(CANCEL_MESSAGE); } - const remainingMs = deadline - Date.now(); - await abortableSleep(Math.min(intervalMs, remainingMs), options.signal, CANCEL_MESSAGE); - const result = await options.poll(); if (result.status === "complete") { - return result.accessToken; + return result.value; } - if (result.status === "pending") { - continue; + if (result.status === "failed") { + throw new Error(result.message); } if (result.status === "slow_down") { slowDownResponses += 1; // RFC 8628 section 3.5: apply this increase to this and all subsequent requests. intervalMs = Math.max(MINIMUM_INTERVAL_MS, intervalMs + SLOW_DOWN_INTERVAL_INCREMENT_MS); - continue; } - throw new Error(result.message); + + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + break; + } + + await abortableSleep(Math.min(intervalMs, remainingMs), options.signal, CANCEL_MESSAGE); } throw new Error(slowDownResponses > 0 ? SLOW_DOWN_TIMEOUT_MESSAGE : TIMEOUT_MESSAGE); diff --git a/packages/ai/src/utils/oauth/github-copilot.ts b/packages/ai/src/utils/oauth/github-copilot.ts index d5adb58a..ba371b8f 100644 --- a/packages/ai/src/utils/oauth/github-copilot.ts +++ b/packages/ai/src/utils/oauth/github-copilot.ts @@ -141,9 +141,13 @@ async function startDeviceFlow(domain: string): Promise { }; } -async function pollForGitHubAccessToken(domain: string, device: DeviceCodeResponse, signal?: AbortSignal) { +async function pollForGitHubAccessToken( + domain: string, + device: DeviceCodeResponse, + signal?: AbortSignal, +): Promise { const urls = getUrls(domain); - return pollOAuthDeviceCodeFlow({ + return pollOAuthDeviceCodeFlow({ intervalSeconds: device.interval, expiresInSeconds: device.expires_in, signal, @@ -163,7 +167,7 @@ async function pollForGitHubAccessToken(domain: string, device: DeviceCodeRespon }); if (raw && typeof raw === "object" && typeof (raw as DeviceTokenSuccessResponse).access_token === "string") { - return { status: "complete", accessToken: (raw as DeviceTokenSuccessResponse).access_token }; + return { status: "complete", value: (raw as DeviceTokenSuccessResponse).access_token }; } if (raw && typeof raw === "object" && typeof (raw as DeviceTokenErrorResponse).error === "string") { diff --git a/packages/ai/src/utils/oauth/index.ts b/packages/ai/src/utils/oauth/index.ts index 55910322..a57badda 100644 --- a/packages/ai/src/utils/oauth/index.ts +++ b/packages/ai/src/utils/oauth/index.ts @@ -19,7 +19,14 @@ export { refreshGitHubCopilotToken, } from "./github-copilot.ts"; // OpenAI Codex (ChatGPT OAuth) -export { loginOpenAICodex, openaiCodexOAuthProvider, refreshOpenAICodexToken } from "./openai-codex.ts"; +export { + loginOpenAICodex, + loginOpenAICodexDeviceCode, + OPENAI_CODEX_BROWSER_LOGIN_METHOD, + OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD, + openaiCodexOAuthProvider, + refreshOpenAICodexToken, +} from "./openai-codex.ts"; export * from "./types.ts"; diff --git a/packages/ai/src/utils/oauth/openai-codex.ts b/packages/ai/src/utils/oauth/openai-codex.ts index 7dd1fbb8..51ea832b 100644 --- a/packages/ai/src/utils/oauth/openai-codex.ts +++ b/packages/ai/src/utils/oauth/openai-codex.ts @@ -17,21 +17,46 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version }); } +import { pollOAuthDeviceCodeFlow } from "./device-code.ts"; import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts"; import { generatePKCE } from "./pkce.ts"; -import type { OAuthCredentials, OAuthLoginCallbacks, OAuthPrompt, OAuthProviderInterface } from "./types.ts"; +import type { + OAuthCredentials, + OAuthDeviceCodeInfo, + OAuthLoginCallbacks, + OAuthPrompt, + OAuthProviderInterface, +} from "./types.ts"; const CALLBACK_HOST = process.env.PI_OAUTH_CALLBACK_HOST || "127.0.0.1"; const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; -const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"; -const TOKEN_URL = "https://auth.openai.com/oauth/token"; +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; +export const OPENAI_CODEX_BROWSER_LOGIN_METHOD = "browser"; +export const OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD = "device_code"; const SCOPE = "openid profile email offline_access"; const JWT_CLAIM_PATH = "https://api.openai.com/auth"; -type TokenSuccess = { type: "success"; access: string; refresh: string; expires: number }; -type TokenFailure = { type: "failed"; message: string; status?: number }; -type TokenResult = TokenSuccess | TokenFailure; +type OAuthToken = { access: string; refresh: string; expires: number }; +type TokenOperation = "exchange" | "refresh"; + +type DeviceAuthInfo = { + deviceAuthId: string; + userCode: string; + intervalSeconds: number; +}; + +type DeviceTokenSuccess = { + authorizationCode: string; + codeVerifier: string; +}; type JwtPayload = { [JWT_CLAIM_PATH]?: { @@ -89,12 +114,47 @@ function decodeJwt(token: string): JwtPayload | null { } } +async function fetchWithLoginCancellation(input: string, init: RequestInit): Promise { + try { + return await fetch(input, init); + } catch (error) { + if (init.signal?.aborted) { + throw new Error("Login cancelled"); + } + throw error; + } +} + +async function readTokenResponse(response: Response, operation: TokenOperation): Promise { + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`OpenAI Codex token ${operation} failed (${response.status}): ${text || response.statusText}`); + } + + const rawJson = await response.json(); + const json = rawJson as { + access_token?: string; + refresh_token?: string; + expires_in?: number; + } | null; + 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)}`); + } + + return { + access: json.access_token, + refresh: json.refresh_token, + expires: Date.now() + json.expires_in * 1000, + }; +} + async function exchangeAuthorizationCode( code: string, verifier: string, redirectUri: string = REDIRECT_URI, -): Promise { - const response = await fetch(TOKEN_URL, { + signal?: AbortSignal, +): Promise { + const response = await fetchWithLoginCancellation(TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ @@ -104,41 +164,16 @@ async function exchangeAuthorizationCode( code_verifier: verifier, redirect_uri: redirectUri, }), + signal, }); - if (!response.ok) { - const text = await response.text().catch(() => ""); - return { - type: "failed", - status: response.status, - message: `OpenAI Codex token exchange failed (${response.status}): ${text || 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") { - return { - type: "failed", - message: `OpenAI Codex token exchange response missing fields: ${JSON.stringify(json)}`, - }; - } - - return { - type: "success", - access: json.access_token, - refresh: json.refresh_token, - expires: Date.now() + json.expires_in * 1000, - }; + return readTokenResponse(response, "exchange"); } -async function refreshAccessToken(refreshToken: string): Promise { +async function refreshAccessToken(refreshToken: string): Promise { + let response: Response; try { - const response = await fetch(TOKEN_URL, { + response = await fetch(TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ @@ -147,41 +182,113 @@ async function refreshAccessToken(refreshToken: string): Promise { client_id: CLIENT_ID, }), }); - - if (!response.ok) { - const text = await response.text().catch(() => ""); - return { - type: "failed", - status: response.status, - message: `OpenAI Codex token refresh failed (${response.status}): ${text || 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") { - return { - type: "failed", - message: `OpenAI Codex token refresh response missing fields: ${JSON.stringify(json)}`, - }; - } - - return { - type: "success", - access: json.access_token, - refresh: json.refresh_token, - expires: Date.now() + json.expires_in * 1000, - }; } catch (error) { - return { - type: "failed", - message: `OpenAI Codex token refresh error: ${error instanceof Error ? error.message : String(error)}`, - }; + throw new Error(`OpenAI Codex token refresh error: ${error instanceof Error ? error.message : String(error)}`); } + + return readTokenResponse(response, "refresh"); +} + +async function startOpenAICodexDeviceAuth(signal?: AbortSignal): Promise { + const response = await fetchWithLoginCancellation(DEVICE_USER_CODE_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ client_id: CLIENT_ID }), + signal, + }); + + if (!response.ok) { + if (response.status === 404) { + throw new Error( + "OpenAI Codex device code login is not enabled for this server. Use browser login or verify the server URL.", + ); + } + const responseBody = await response.text().catch(() => ""); + throw new Error( + `OpenAI Codex device code request failed with status ${response.status}${responseBody ? `: ${responseBody}` : ""}`, + ); + } + + const rawJson = await response.json(); + const json = rawJson as { + device_auth_id?: string; + user_code?: string; + interval?: number | string; + } | null; + 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) || + intervalSeconds < 0 + ) { + throw new Error(`Invalid OpenAI Codex device code response: ${JSON.stringify(json)}`); + } + + return { + deviceAuthId: json.device_auth_id, + userCode: json.user_code, + intervalSeconds, + }; +} + +async function pollOpenAICodexDeviceAuth(device: DeviceAuthInfo, signal?: AbortSignal): Promise { + return pollOAuthDeviceCodeFlow({ + intervalSeconds: device.intervalSeconds, + expiresInSeconds: DEVICE_CODE_TIMEOUT_SECONDS, + signal, + poll: async () => { + const response = await fetchWithLoginCancellation(DEVICE_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + device_auth_id: device.deviceAuthId, + user_code: device.userCode, + }), + signal, + }); + + if (response.ok) { + const rawJson = await response.json(); + const json = rawJson as { authorization_code?: string; code_verifier?: string } | null; + if (!json?.authorization_code || !json.code_verifier) { + return { + status: "failed", + message: `Invalid OpenAI Codex device auth token response: ${JSON.stringify(json)}`, + }; + } + return { + status: "complete", + value: { authorizationCode: json.authorization_code, codeVerifier: json.code_verifier }, + }; + } + + 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 createAuthorizationFlow( @@ -294,6 +401,52 @@ function getAccountId(accessToken: string): string | null { return typeof accountId === "string" && accountId.length > 0 ? accountId : null; } +function credentialsFromToken(token: OAuthToken): OAuthCredentials { + const accountId = getAccountId(token.access); + if (!accountId) { + throw new Error("Failed to extract accountId from token"); + } + + return { + access: token.access, + refresh: token.refresh, + expires: token.expires, + accountId, + }; +} + +async function exchangeAuthorizationCodeForCredentials( + code: string, + verifier: string, + redirectUri: string, + signal?: AbortSignal, +): Promise { + return credentialsFromToken(await exchangeAuthorizationCode(code, verifier, redirectUri, signal)); +} + +/** + * Login with OpenAI Codex OAuth using the Codex device-code flow. + */ +export async function loginOpenAICodexDeviceCode(options: { + onDeviceCode: (info: OAuthDeviceCodeInfo) => void; + signal?: AbortSignal; +}): Promise { + const device = await startOpenAICodexDeviceAuth(options.signal); + options.onDeviceCode({ + userCode: device.userCode, + verificationUri: DEVICE_VERIFICATION_URI, + intervalSeconds: device.intervalSeconds, + expiresInSeconds: DEVICE_CODE_TIMEOUT_SECONDS, + }); + const code = await pollOpenAICodexDeviceAuth(device, options.signal); + return exchangeAuthorizationCodeForCredentials( + code.authorizationCode, + code.codeVerifier, + DEVICE_REDIRECT_URI, + options.signal, + ); +} + /** * Login with OpenAI Codex OAuth * @@ -391,22 +544,7 @@ export async function loginOpenAICodex(options: { throw new Error("Missing authorization code"); } - const tokenResult = await exchangeAuthorizationCode(code, verifier); - if (tokenResult.type !== "success") { - throw new Error(tokenResult.message); - } - - const accountId = getAccountId(tokenResult.access); - if (!accountId) { - throw new Error("Failed to extract accountId from token"); - } - - return { - access: tokenResult.access, - refresh: tokenResult.refresh, - expires: tokenResult.expires, - accountId, - }; + return exchangeAuthorizationCodeForCredentials(code, verifier, REDIRECT_URI); } finally { server.close(); } @@ -416,22 +554,7 @@ export async function loginOpenAICodex(options: { * Refresh OpenAI Codex OAuth token */ export async function refreshOpenAICodexToken(refreshToken: string): Promise { - const result = await refreshAccessToken(refreshToken); - if (result.type !== "success") { - throw new Error(result.message); - } - - const accountId = getAccountId(result.access); - if (!accountId) { - throw new Error("Failed to extract accountId from token"); - } - - return { - access: result.access, - refresh: result.refresh, - expires: result.expires, - accountId, - }; + return credentialsFromToken(await refreshAccessToken(refreshToken)); } export const openaiCodexOAuthProvider: OAuthProviderInterface = { @@ -440,6 +563,28 @@ export const openaiCodexOAuthProvider: OAuthProviderInterface = { usesCallbackServer: true, async login(callbacks: OAuthLoginCallbacks): Promise { + const loginMethod = await callbacks.onSelect({ + message: "Select OpenAI Codex login method:", + options: [ + { id: OPENAI_CODEX_BROWSER_LOGIN_METHOD, label: "Browser login (default)" }, + { id: OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD, label: "Device code login (headless)" }, + ], + }); + if (!loginMethod) { + throw new Error("Login cancelled"); + } + + if (loginMethod === OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD) { + return loginOpenAICodexDeviceCode({ + onDeviceCode: callbacks.onDeviceCode, + signal: callbacks.signal, + }); + } + + if (loginMethod !== OPENAI_CODEX_BROWSER_LOGIN_METHOD) { + throw new Error(`Unknown OpenAI Codex login method: ${loginMethod}`); + } + return loginOpenAICodex({ onAuth: callbacks.onAuth, onPrompt: callbacks.onPrompt, diff --git a/packages/ai/test/github-copilot-oauth.test.ts b/packages/ai/test/github-copilot-oauth.test.ts index 1fb92b1e..324883ec 100644 --- a/packages/ai/test/github-copilot-oauth.test.ts +++ b/packages/ai/test/github-copilot-oauth.test.ts @@ -84,7 +84,7 @@ describe("GitHub Copilot OAuth device flow", () => { await loginPromise; }); - it("waits before the first poll and increases the interval after slow_down", async () => { + it("polls immediately and increases the interval after slow_down", async () => { vi.useFakeTimers(); const startTime = new Date("2026-03-09T00:00:00Z"); vi.setSystemTime(startTime); @@ -156,12 +156,6 @@ describe("GitHub Copilot OAuth device flow", () => { }); await vi.advanceTimersByTimeAsync(0); - expect(accessTokenPollTimes).toHaveLength(0); - - await vi.advanceTimersByTimeAsync(4999); - expect(accessTokenPollTimes).toHaveLength(0); - - await vi.advanceTimersByTimeAsync(1); expect(accessTokenPollTimes).toHaveLength(1); await vi.advanceTimersByTimeAsync(4999); @@ -177,13 +171,13 @@ describe("GitHub Copilot OAuth device flow", () => { await loginPromise; expect(accessTokenPollTimes).toEqual([ + startTime.getTime(), startTime.getTime() + 5000, - startTime.getTime() + 10000, - startTime.getTime() + 20000, + startTime.getTime() + 15000, ]); }); - it("uses the remaining lifetime for a final poll before timing out after repeated slow_down responses", async () => { + it("times out after repeated slow_down responses", async () => { vi.useFakeTimers(); const startTime = new Date("2026-03-09T00:00:00Z"); vi.setSystemTime(startTime); @@ -231,21 +225,17 @@ describe("GitHub Copilot OAuth device flow", () => { ); await vi.advanceTimersByTimeAsync(5000); - expect(accessTokenPollTimes).toEqual([startTime.getTime() + 5000]); + expect(accessTokenPollTimes).toEqual([startTime.getTime()]); - await vi.advanceTimersByTimeAsync(10000); - expect(accessTokenPollTimes).toEqual([startTime.getTime() + 5000, startTime.getTime() + 15000]); + await vi.advanceTimersByTimeAsync(5000); + expect(accessTokenPollTimes).toEqual([startTime.getTime(), startTime.getTime() + 10000]); - await vi.advanceTimersByTimeAsync(9999); - expect(accessTokenPollTimes).toEqual([startTime.getTime() + 5000, startTime.getTime() + 15000]); + await vi.advanceTimersByTimeAsync(14999); + expect(accessTokenPollTimes).toEqual([startTime.getTime(), startTime.getTime() + 10000]); await vi.advanceTimersByTimeAsync(1); await rejection; - expect(accessTokenPollTimes).toEqual([ - startTime.getTime() + 5000, - startTime.getTime() + 15000, - startTime.getTime() + 25000, - ]); + expect(accessTokenPollTimes).toEqual([startTime.getTime(), startTime.getTime() + 10000]); }); }); diff --git a/packages/ai/test/oauth-device-code.test.ts b/packages/ai/test/oauth-device-code.test.ts index 40ef4fd8..f32decb6 100644 --- a/packages/ai/test/oauth-device-code.test.ts +++ b/packages/ai/test/oauth-device-code.test.ts @@ -6,7 +6,7 @@ describe("OAuth device-code polling", () => { vi.useRealTimers(); }); - it("waits before the first poll and returns the completed value", async () => { + it("polls immediately and returns the completed value", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-09T00:00:00Z")); @@ -15,7 +15,7 @@ describe("OAuth device-code polling", () => { pollTimes.push(Date.now()); return pollTimes.length === 1 ? { status: "pending" as const } - : { status: "complete" as const, accessToken: "token" }; + : { status: "complete" as const, value: "token" }; }); const resultPromise = pollOAuthDeviceCodeFlow({ @@ -24,17 +24,17 @@ describe("OAuth device-code polling", () => { poll, }); + await vi.advanceTimersByTimeAsync(0); + expect(pollTimes).toEqual([new Date("2026-03-09T00:00:00Z").getTime()]); + await vi.advanceTimersByTimeAsync(1999); - expect(pollTimes).toEqual([]); + expect(pollTimes).toEqual([new Date("2026-03-09T00:00:00Z").getTime()]); await vi.advanceTimersByTimeAsync(1); - expect(pollTimes).toEqual([new Date("2026-03-09T00:00:02Z").getTime()]); - - await vi.advanceTimersByTimeAsync(2000); await expect(resultPromise).resolves.toBe("token"); expect(pollTimes).toEqual([ + new Date("2026-03-09T00:00:00Z").getTime(), new Date("2026-03-09T00:00:02Z").getTime(), - new Date("2026-03-09T00:00:04Z").getTime(), ]); }); diff --git a/packages/ai/test/openai-codex-oauth.test.ts b/packages/ai/test/openai-codex-oauth.test.ts index 0157f6f6..820fbe6b 100644 --- a/packages/ai/test/openai-codex-oauth.test.ts +++ b/packages/ai/test/openai-codex-oauth.test.ts @@ -1,10 +1,429 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { refreshOpenAICodexToken } from "../src/utils/oauth/openai-codex.ts"; +import { + loginOpenAICodexDeviceCode, + openaiCodexOAuthProvider, + refreshOpenAICodexToken, +} from "../src/utils/oauth/openai-codex.ts"; + +function jsonResponse(body: unknown, status: number = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function getUrl(input: unknown): string { + if (typeof input === "string") return input; + if (input instanceof URL) return input.toString(); + if (input instanceof Request) return input.url; + throw new Error(`Unsupported fetch input: ${String(input)}`); +} + +function createAccessToken(accountId: string): string { + const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64"); + const payload = Buffer.from( + JSON.stringify({ + "https://api.openai.com/auth": { + chatgpt_account_id: accountId, + }, + }), + ).toString("base64"); + return `${header}.${payload}.signature`; +} + +function deviceAuthPendingResponse(): Response { + return jsonResponse( + { + error: { + message: "Device authorization is pending. Please try again.", + type: "invalid_request_error", + param: null, + code: "deviceauth_authorization_pending", + }, + }, + 403, + ); +} describe("OpenAI Codex OAuth", () => { afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it("logs in with the OpenAI Codex device code flow", async () => { + vi.useFakeTimers(); + const startTime = new Date("2026-05-20T00:00:00Z"); + vi.setSystemTime(startTime); + + const accessToken = createAccessToken("account-123"); + const deviceInfos: Array<{ + userCode: string; + verificationUri: string; + instructions?: string; + intervalSeconds?: number; + expiresInSeconds?: number; + }> = []; + const pollTimes: number[] = []; + const pollResponses = [ + deviceAuthPendingResponse(), + jsonResponse({ + authorization_code: "oauth-code", + code_challenge: "device-code-challenge", + code_verifier: "device-code-verifier", + }), + ]; + + const fetchMock = vi.fn(async (input: unknown, init?: RequestInit): Promise => { + const url = getUrl(input); + + if (url === "https://auth.openai.com/api/accounts/deviceauth/usercode") { + expect(init?.method).toBe("POST"); + expect(init?.headers).toMatchObject({ "Content-Type": "application/json" }); + expect(JSON.parse(String(init?.body))).toEqual({ client_id: "app_EMoamEEZ73f0CkXaXp7hrann" }); + return jsonResponse({ + device_auth_id: "device-auth-id", + user_code: "ABCD-1234", + interval: "5", + }); + } + + if (url === "https://auth.openai.com/api/accounts/deviceauth/token") { + pollTimes.push(Date.now()); + expect(init?.method).toBe("POST"); + expect(init?.headers).toMatchObject({ "Content-Type": "application/json" }); + expect(JSON.parse(String(init?.body))).toEqual({ + device_auth_id: "device-auth-id", + user_code: "ABCD-1234", + }); + const response = pollResponses.shift(); + if (!response) { + throw new Error("Unexpected extra device auth poll"); + } + return response; + } + + if (url === "https://auth.openai.com/oauth/token") { + expect(init?.method).toBe("POST"); + expect(init?.headers).toMatchObject({ "Content-Type": "application/x-www-form-urlencoded" }); + const params = new URLSearchParams(String(init?.body)); + expect(params.get("grant_type")).toBe("authorization_code"); + expect(params.get("client_id")).toBe("app_EMoamEEZ73f0CkXaXp7hrann"); + expect(params.get("code")).toBe("oauth-code"); + expect(params.get("redirect_uri")).toBe("https://auth.openai.com/deviceauth/callback"); + expect(params.get("code_verifier")).toBe("device-code-verifier"); + return jsonResponse({ + access_token: accessToken, + refresh_token: "refresh-token", + expires_in: 3600, + }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + vi.stubGlobal("fetch", fetchMock); + + const credentialsPromise = loginOpenAICodexDeviceCode({ + onDeviceCode: (info) => deviceInfos.push(info), + }); + + for (let i = 0; i < 5 && pollTimes.length === 0; i++) { + await vi.advanceTimersByTimeAsync(0); + } + expect(deviceInfos).toEqual([ + { + userCode: "ABCD-1234", + verificationUri: "https://auth.openai.com/codex/device", + intervalSeconds: 5, + expiresInSeconds: 900, + }, + ]); + expect(pollTimes).toEqual([startTime.getTime()]); + + await vi.advanceTimersByTimeAsync(4999); + expect(pollTimes).toEqual([startTime.getTime()]); + + await vi.advanceTimersByTimeAsync(1); + await expect(credentialsPromise).resolves.toMatchObject({ + access: accessToken, + refresh: "refresh-token", + expires: startTime.getTime() + 5000 + 3600 * 1000, + accountId: "account-123", + }); + expect(pollTimes).toEqual([startTime.getTime(), startTime.getTime() + 5000]); + }); + + it("offers browser login first and uses the selected OpenAI Codex device code flow", async () => { + const accessToken = createAccessToken("account-456"); + const selectPrompts: Array<{ + message: string; + options: Array<{ id: string; label: string }>; + }> = []; + const deviceInfos: Array<{ + userCode: string; + verificationUri: string; + intervalSeconds?: number; + expiresInSeconds?: number; + }> = []; + + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown, init?: RequestInit): Promise => { + const url = getUrl(input); + if (url === "https://auth.openai.com/api/accounts/deviceauth/usercode") { + expect(JSON.parse(String(init?.body))).toEqual({ client_id: "app_EMoamEEZ73f0CkXaXp7hrann" }); + return jsonResponse({ + device_auth_id: "device-auth-id", + user_code: "WXYZ-7890", + interval: "5", + }); + } + if (url === "https://auth.openai.com/api/accounts/deviceauth/token") { + return jsonResponse({ + authorization_code: "oauth-code", + code_challenge: "device-code-challenge", + code_verifier: "device-code-verifier", + }); + } + if (url === "https://auth.openai.com/oauth/token") { + return jsonResponse({ + access_token: accessToken, + refresh_token: "refresh-token", + expires_in: 3600, + }); + } + throw new Error(`Unexpected fetch URL: ${url}`); + }), + ); + + await expect( + openaiCodexOAuthProvider.login({ + onAuth: () => { + throw new Error("Browser login should not start"); + }, + onDeviceCode: (info) => deviceInfos.push(info), + onPrompt: async () => { + throw new Error("Prompt should not be used"); + }, + onSelect: async (prompt) => { + selectPrompts.push(prompt); + return "device_code"; + }, + }), + ).resolves.toMatchObject({ + access: accessToken, + refresh: "refresh-token", + accountId: "account-456", + }); + + expect(selectPrompts).toEqual([ + { + message: "Select OpenAI Codex login method:", + options: [ + { id: "browser", label: "Browser login (default)" }, + { id: "device_code", label: "Device code login (headless)" }, + ], + }, + ]); + expect(deviceInfos).toEqual([ + { + userCode: "WXYZ-7890", + verificationUri: "https://auth.openai.com/codex/device", + intervalSeconds: 5, + expiresInSeconds: 900, + }, + ]); + }); + + it("cancels when OpenAI Codex login method selection is cancelled", async () => { + await expect( + openaiCodexOAuthProvider.login({ + onAuth: () => {}, + onDeviceCode: () => {}, + onPrompt: async () => "", + onSelect: async () => undefined, + }), + ).rejects.toThrow("Login cancelled"); + }); + + it("cancels the OpenAI Codex device code flow while waiting", async () => { + vi.useFakeTimers(); + const controller = new AbortController(); + const pollTimes: number[] = []; + + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown, init?: RequestInit): Promise => { + const url = getUrl(input); + if (url === "https://auth.openai.com/api/accounts/deviceauth/usercode") { + expect(JSON.parse(String(init?.body))).toEqual({ client_id: "app_EMoamEEZ73f0CkXaXp7hrann" }); + return jsonResponse({ + device_auth_id: "device-auth-id", + user_code: "ABCD-1234", + interval: "5", + }); + } + if (url === "https://auth.openai.com/api/accounts/deviceauth/token") { + pollTimes.push(Date.now()); + return deviceAuthPendingResponse(); + } + throw new Error(`Unexpected fetch URL: ${url}`); + }), + ); + + const credentialsPromise = loginOpenAICodexDeviceCode({ + onDeviceCode: () => {}, + signal: controller.signal, + }); + const rejectionPromise = credentialsPromise.then( + () => new Error("Expected login to fail"), + (error: unknown) => error, + ); + + for (let i = 0; i < 5 && pollTimes.length === 0; i++) { + await vi.advanceTimersByTimeAsync(0); + } + expect(pollTimes).toHaveLength(1); + + controller.abort(); + const rejection = await rejectionPromise; + expect(rejection).toBeInstanceOf(Error); + expect((rejection as Error).message).toBe("Login cancelled"); + }); + + it("times out the OpenAI Codex device code flow after 15 minutes", async () => { + vi.useFakeTimers(); + const pollTimes: number[] = []; + + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown, init?: RequestInit): Promise => { + const url = getUrl(input); + if (url === "https://auth.openai.com/api/accounts/deviceauth/usercode") { + expect(JSON.parse(String(init?.body))).toEqual({ client_id: "app_EMoamEEZ73f0CkXaXp7hrann" }); + return jsonResponse({ + device_auth_id: "device-auth-id", + user_code: "ABCD-1234", + interval: "60", + }); + } + if (url === "https://auth.openai.com/api/accounts/deviceauth/token") { + pollTimes.push(Date.now()); + return deviceAuthPendingResponse(); + } + throw new Error(`Unexpected fetch URL: ${url}`); + }), + ); + + const credentialsPromise = loginOpenAICodexDeviceCode({ + onDeviceCode: () => {}, + }); + const rejectionPromise = credentialsPromise.then( + () => new Error("Expected login to fail"), + (error: unknown) => error, + ); + + for (let i = 0; i < 5 && pollTimes.length === 0; i++) { + await vi.advanceTimersByTimeAsync(0); + } + expect(pollTimes).toHaveLength(1); + + await vi.advanceTimersByTimeAsync(15 * 60 * 1000); + const rejection = await rejectionPromise; + expect(rejection).toBeInstanceOf(Error); + expect((rejection as Error).message).toBe("Device flow timed out"); + }); + + it("treats OpenAI Codex device auth 403 and 404 responses as pending", async () => { + vi.useFakeTimers(); + const accessToken = createAccessToken("account-403-404"); + const pollTimes: number[] = []; + const pollResponses = [ + jsonResponse({ error: "access_denied", error_description: "denied" }, 403), + new Response("not ready", { status: 404, headers: { "Content-Type": "text/plain" } }), + jsonResponse({ + authorization_code: "oauth-code", + code_challenge: "device-code-challenge", + code_verifier: "device-code-verifier", + }), + ]; + + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown): Promise => { + const url = getUrl(input); + if (url === "https://auth.openai.com/api/accounts/deviceauth/usercode") { + return jsonResponse({ + device_auth_id: "device-auth-id", + user_code: "ABCD-1234", + interval: "1", + }); + } + if (url === "https://auth.openai.com/api/accounts/deviceauth/token") { + pollTimes.push(Date.now()); + const response = pollResponses.shift(); + if (!response) { + throw new Error("Unexpected extra device auth poll"); + } + return response; + } + if (url === "https://auth.openai.com/oauth/token") { + return jsonResponse({ + access_token: accessToken, + refresh_token: "refresh-token", + expires_in: 3600, + }); + } + throw new Error(`Unexpected fetch URL: ${url}`); + }), + ); + + const credentialsPromise = loginOpenAICodexDeviceCode({ + onDeviceCode: () => {}, + }); + + for (let i = 0; i < 5 && pollTimes.length === 0; i++) { + await vi.advanceTimersByTimeAsync(0); + } + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(1000); + + await expect(credentialsPromise).resolves.toMatchObject({ + access: accessToken, + refresh: "refresh-token", + accountId: "account-403-404", + }); + expect(pollTimes).toHaveLength(3); + }); + + it("includes the response body in OpenAI Codex device auth poll failures", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown): Promise => { + const url = getUrl(input); + if (url === "https://auth.openai.com/api/accounts/deviceauth/usercode") { + return jsonResponse({ + device_auth_id: "device-auth-id", + user_code: "ABCD-1234", + interval: "5", + }); + } + if (url === "https://auth.openai.com/api/accounts/deviceauth/token") { + return jsonResponse({ error: "server_error", error_description: "try again later" }, 500); + } + throw new Error(`Unexpected fetch URL: ${url}`); + }), + ); + + await expect( + loginOpenAICodexDeviceCode({ + onDeviceCode: () => {}, + }), + ).rejects.toThrow( + 'OpenAI Codex device auth failed with status 500: {"error":"server_error","error_description":"try again later"}', + ); }); it("does not write token refresh failures to stderr", async () => { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0c4aba0b..35521fef 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -55,6 +55,7 @@ - Added `compat.forceAdaptiveThinking` support to custom Anthropic-compatible model configuration docs and validation ([#4797](https://github.com/earendil-works/pi-mono/pull/4797) by [@mbazso](https://github.com/mbazso)). - Added a standard unified patch to edit tool result details for SDK consumers ([#4821](https://github.com/earendil-works/pi/issues/4821)). +- Added a Codex subscription login method selector with device-code auth for headless environments. ### Changed diff --git a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts index ee3f45cb..3ae9c92d 100644 --- a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts @@ -86,7 +86,7 @@ export class LoginDialogComponent extends Container implements Focusable { /** * Called by onAuth callback - show URL and optional instructions */ - showAuth(url: string, instructions?: string, options: { autoOpenBrowser?: boolean } = {}): void { + showAuth(url: string, instructions?: string): void { this.contentContainer.clear(); this.contentContainer.addChild(new Spacer(1)); const linkedUrl = `\x1b]8;;${url}\x07${url}\x1b]8;;\x07`; @@ -101,9 +101,7 @@ export class LoginDialogComponent extends Container implements Focusable { this.contentContainer.addChild(new Text(theme.fg("warning", instructions), 1, 0)); } - if (options.autoOpenBrowser ?? true) { - this.openUrl(url); - } + this.openUrl(url); this.tui.requestRender(); }