From 5b838a9bf8c5d98f2d041fc6e4ed5b409906f38e Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Tue, 19 May 2026 19:47:13 +0200 Subject: [PATCH] feat(ai): add OpenAI Codex device login --- packages/ai/CHANGELOG.md | 2 +- packages/ai/src/cli.ts | 10 + packages/ai/src/utils/oauth/index.ts | 7 +- packages/ai/src/utils/oauth/openai-codex.ts | 217 +++++++++++++++++++ packages/ai/test/openai-codex-oauth.test.ts | 224 +++++++++++++++++++- 5 files changed, 457 insertions(+), 3 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 782adee11..62ad31257 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -9,7 +9,7 @@ ### Added -- Added first-class OAuth device-code callback metadata, shared polling support, and GitHub Copilot OAuth integration. +- Added first-class OAuth device-code callback metadata, shared polling support, GitHub Copilot OAuth integration, and an OpenAI Codex device-code login option. ### Fixed diff --git a/packages/ai/src/cli.ts b/packages/ai/src/cli.ts index ead8e4377..b09be1d4b 100644 --- a/packages/ai/src/cli.ts +++ b/packages/ai/src/cli.ts @@ -52,6 +52,16 @@ async function login(providerId: OAuthProviderId): Promise { onPrompt: async (p) => { return await promptFn(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`); }, + onSelect: async (p) => { + console.log(`\n${p.message}:`); + for (let i = 0; i < p.options.length; i++) { + console.log(` ${i + 1}. ${p.options[i].label}`); + } + const choice = await promptFn(`Enter number (1-${p.options.length}, default 1):`); + const trimmed = choice.trim(); + const index = trimmed ? parseInt(trimmed, 10) - 1 : 0; + return p.options[index]?.id; + }, onProgress: (msg) => console.log(msg), }); diff --git a/packages/ai/src/utils/oauth/index.ts b/packages/ai/src/utils/oauth/index.ts index 55910322b..75d5d97cf 100644 --- a/packages/ai/src/utils/oauth/index.ts +++ b/packages/ai/src/utils/oauth/index.ts @@ -19,7 +19,12 @@ export { refreshGitHubCopilotToken, } from "./github-copilot.ts"; // OpenAI Codex (ChatGPT OAuth) -export { loginOpenAICodex, openaiCodexOAuthProvider, refreshOpenAICodexToken } from "./openai-codex.ts"; +export { + loginOpenAICodex, + loginOpenAICodexDeviceCode, + 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 7dd1fbb82..ef86b3509 100644 --- a/packages/ai/src/utils/oauth/openai-codex.ts +++ b/packages/ai/src/utils/oauth/openai-codex.ts @@ -26,6 +26,10 @@ const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"; const TOKEN_URL = "https://auth.openai.com/oauth/token"; const REDIRECT_URI = "http://localhost:1455/auth/callback"; +const DEVICE_USER_CODE_URL = "https://auth.openai.com/api/accounts/deviceauth/usercode"; +const DEVICE_TOKEN_URL = "https://auth.openai.com/api/accounts/deviceauth/token"; +const DEVICE_VERIFICATION_URI = "https://auth.openai.com/codex/device"; +const DEVICE_REDIRECT_URI = "https://auth.openai.com/deviceauth/callback"; const SCOPE = "openid profile email offline_access"; const JWT_CLAIM_PATH = "https://api.openai.com/auth"; @@ -33,6 +37,32 @@ type TokenSuccess = { type: "success"; access: string; refresh: string; expires: type TokenFailure = { type: "failed"; message: string; status?: number }; type TokenResult = TokenSuccess | TokenFailure; +type DeviceAuthInfo = { + deviceAuthId: string; + userCode: string; + intervalSeconds: number; +}; + +type DeviceTokenResponse = { + code?: string; + authorization_code?: string; + oauth_code?: string; + code_verifier?: string; + codeVerifier?: string; + verifier?: string; + error?: + | string + | { + message?: string; + type?: string; + code?: string; + }; + error_description?: string; + interval?: number | string; +}; + +type OpenAICodexLoginMethod = "browser" | "device"; + type JwtPayload = { [JWT_CLAIM_PATH]?: { chatgpt_account_id?: string; @@ -184,6 +214,118 @@ async function refreshAccessToken(refreshToken: string): Promise { } } +function parsePositiveSeconds(value: unknown): number | null { + const seconds = typeof value === "string" ? Number(value) : value; + return typeof seconds === "number" && Number.isFinite(seconds) && seconds > 0 ? seconds : null; +} + +async function startOpenAICodexDeviceAuth(): Promise { + const response = await fetch(DEVICE_USER_CODE_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ client_id: CLIENT_ID }), + }); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`OpenAI Codex device auth failed (${response.status}): ${text || response.statusText}`); + } + + const json = (await response.json()) as Record; + const deviceAuthId = json.device_auth_id; + const userCode = json.user_code; + const intervalSeconds = parsePositiveSeconds(json.interval); + + if (typeof deviceAuthId !== "string" || typeof userCode !== "string" || intervalSeconds === null) { + throw new Error(`Invalid OpenAI Codex device auth response: ${JSON.stringify(json)}`); + } + + return { deviceAuthId, userCode, intervalSeconds }; +} + +async function pollOpenAICodexDeviceAuth( + deviceAuthId: string, + userCode: string, + intervalSeconds: number, + signal?: AbortSignal, +): Promise<{ code: string; verifier: string }> { + return pollOAuthDeviceCodeFlow<{ code: string; verifier: string }>({ + authorization: { + userCode, + verificationUri: DEVICE_VERIFICATION_URI, + intervalSeconds, + }, + signal, + poll: async () => { + const response = await fetch(DEVICE_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + device_auth_id: deviceAuthId, + user_code: userCode, + }), + }); + const text = await response.text(); + let parsed: DeviceTokenResponse | null = null; + if (text) { + try { + parsed = JSON.parse(text) as DeviceTokenResponse; + } catch { + parsed = null; + } + } + + const code = parsed?.code ?? parsed?.authorization_code ?? parsed?.oauth_code; + if (typeof code === "string" && code.length > 0) { + const verifier = parsed?.code_verifier ?? parsed?.codeVerifier ?? parsed?.verifier; + if (typeof verifier !== "string" || verifier.length === 0) { + return { + status: "failed", + message: `OpenAI Codex device auth response missing code verifier. Response keys: ${Object.keys(parsed ?? {}).join(", ")}`, + } as const; + } + return { status: "complete", value: { code, verifier } } as const; + } + + const rawError = parsed?.error; + const error = + typeof rawError === "string" + ? rawError + : rawError + ? (rawError.code ?? rawError.type ?? rawError.message) + : undefined; + const errorDescription = + parsed?.error_description ?? (typeof rawError === "object" && rawError ? rawError.message : undefined); + if (error === "authorization_pending" || error === "deviceauth_authorization_pending") { + return { status: "pending" } as const; + } + if (error === "slow_down" || error === "deviceauth_slow_down") { + return { + status: "slow_down", + intervalSeconds: parsePositiveSeconds(parsed?.interval) ?? undefined, + } as const; + } + + if (!response.ok) { + return { + status: "failed", + message: `OpenAI Codex device auth failed (${response.status}): ${text || response.statusText}`, + } as const; + } + + if (error) { + const description = errorDescription ? `: ${errorDescription}` : ""; + return { status: "failed", message: `OpenAI Codex device auth failed: ${error}${description}` } as const; + } + + return { + status: "failed", + message: `OpenAI Codex device auth response missing authorization code: ${text || response.statusText}`, + } as const; + }, + }); +} + async function createAuthorizationFlow( originator: string = "pi", ): Promise<{ verifier: string; state: string; url: string }> { @@ -294,6 +436,54 @@ function getAccountId(accessToken: string): string | null { return typeof accountId === "string" && accountId.length > 0 ? accountId : null; } +/** + * Login with OpenAI Codex OAuth using the device code flow. + */ +export async function loginOpenAICodexDeviceCode(options: { + onAuth: (info: OAuthAuthInfo) => void; + onDeviceCode?: (info: OAuthDeviceCodeInfo) => void; + onProgress?: (message: string) => void; + signal?: AbortSignal; +}): Promise { + const device = await startOpenAICodexDeviceAuth(); + + const deviceCodeInfo = { + userCode: device.userCode, + verificationUri: DEVICE_VERIFICATION_URI, + instructions: `Enter code: ${device.userCode}`, + intervalSeconds: device.intervalSeconds, + } satisfies OAuthDeviceCodeInfo; + if (options.onDeviceCode) { + options.onDeviceCode(deviceCodeInfo); + } else { + options.onAuth({ url: deviceCodeInfo.verificationUri, instructions: deviceCodeInfo.instructions }); + } + options.onProgress?.("Waiting for authentication..."); + + const { code, verifier } = await pollOpenAICodexDeviceAuth( + device.deviceAuthId, + device.userCode, + device.intervalSeconds, + options.signal, + ); + const tokenResult = await exchangeAuthorizationCode(code, verifier, DEVICE_REDIRECT_URI); + 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, + }; +} + /** * Login with OpenAI Codex OAuth * @@ -440,6 +630,33 @@ export const openaiCodexOAuthProvider: OAuthProviderInterface = { usesCallbackServer: true, async login(callbacks: OAuthLoginCallbacks): Promise { + let method: OpenAICodexLoginMethod = "browser"; + if (callbacks.onSelect) { + const selected = await callbacks.onSelect({ + message: "Choose OpenAI Codex login method", + options: [ + { id: "browser", label: "Browser login (default)" }, + { id: "device", label: "Device code login" }, + ], + }); + if (!selected) { + throw new Error("Login cancelled"); + } + if (selected !== "browser" && selected !== "device") { + throw new Error(`Unknown OpenAI Codex login method: ${selected}`); + } + method = selected; + } + + if (method === "device") { + return loginOpenAICodexDeviceCode({ + onAuth: callbacks.onAuth, + onDeviceCode: callbacks.onDeviceCode, + onProgress: callbacks.onProgress, + signal: callbacks.signal, + }); + } + return loginOpenAICodex({ onAuth: callbacks.onAuth, onPrompt: callbacks.onPrompt, diff --git a/packages/ai/test/openai-codex-oauth.test.ts b/packages/ai/test/openai-codex-oauth.test.ts index 276abe1bf..d8f20b6cb 100644 --- a/packages/ai/test/openai-codex-oauth.test.ts +++ b/packages/ai/test/openai-codex-oauth.test.ts @@ -1,10 +1,232 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { refreshOpenAICodexToken } from "../src/utils/oauth/openai-codex.js"; +import { + loginOpenAICodexDeviceCode, + openaiCodexOAuthProvider, + refreshOpenAICodexToken, +} from "../src/utils/oauth/openai-codex.js"; +import type { OAuthSelectPrompt } from "../src/utils/oauth/types.js"; + +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`; +} 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-19T00:00:00Z"); + vi.setSystemTime(startTime); + + const accessToken = createAccessToken("account-123"); + const authInfos: { userCode: string; verificationUri: string; instructions?: string }[] = []; + const progressMessages: string[] = []; + const pollTimes: number[] = []; + const pollResponses = [ + jsonResponse( + { + error: { + message: "Device authorization is pending. Please try again.", + type: "invalid_request_error", + code: "deviceauth_authorization_pending", + }, + }, + 403, + ), + jsonResponse({ code: "oauth-code", 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 onAuth = vi.fn(); + const credentialsPromise = loginOpenAICodexDeviceCode({ + onAuth, + onDeviceCode: (info) => authInfos.push(info), + onProgress: (message) => progressMessages.push(message), + }); + + await vi.advanceTimersByTimeAsync(0); + expect(authInfos).toEqual([ + { + userCode: "ABCD-1234", + verificationUri: "https://auth.openai.com/codex/device", + instructions: "Enter code: ABCD-1234", + intervalSeconds: 5, + }, + ]); + expect(onAuth).not.toHaveBeenCalled(); + expect(progressMessages).toEqual(["Waiting for authentication..."]); + + await vi.advanceTimersByTimeAsync(4999); + expect(pollTimes).toEqual([]); + + await vi.advanceTimersByTimeAsync(1); + expect(pollTimes).toEqual([startTime.getTime() + 5000]); + + await vi.advanceTimersByTimeAsync(5000); + await expect(credentialsPromise).resolves.toMatchObject({ + access: accessToken, + refresh: "refresh-token", + expires: startTime.getTime() + 10_000 + 3600 * 1000, + accountId: "account-123", + }); + expect(pollTimes).toEqual([startTime.getTime() + 5000, startTime.getTime() + 10_000]); + }); + + it("selects device code login as an OpenAI Codex login option", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-19T00:00:00Z")); + + const accessToken = createAccessToken("account-123"); + const selectPrompts: OAuthSelectPrompt[] = []; + 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") { + return jsonResponse({ code: "oauth-code", 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}`); + }), + ); + + const credentialsPromise = openaiCodexOAuthProvider.login({ + onAuth: () => {}, + onDeviceCode: () => {}, + onPrompt: async () => "", + onSelect: async (prompt) => { + selectPrompts.push(prompt); + return "device"; + }, + }); + + await vi.advanceTimersByTimeAsync(0); + expect(selectPrompts).toEqual([ + { + message: "Choose OpenAI Codex login method", + options: [ + { id: "browser", label: "Browser login (default)" }, + { id: "device", label: "Device code login" }, + ], + }, + ]); + + await vi.advanceTimersByTimeAsync(1000); + await expect(credentialsPromise).resolves.toMatchObject({ accountId: "account-123" }); + }); + + it("cancels the OpenAI Codex device code flow while waiting", async () => { + vi.useFakeTimers(); + const controller = new AbortController(); + const authInfos: { userCode: string; verificationUri: string }[] = []; + + 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", + }); + } + throw new Error(`Unexpected fetch URL: ${url}`); + }), + ); + + const credentialsPromise = loginOpenAICodexDeviceCode({ + onAuth: () => {}, + onDeviceCode: (info) => authInfos.push(info), + signal: controller.signal, + }); + + await vi.advanceTimersByTimeAsync(0); + expect(authInfos).toHaveLength(1); + + controller.abort(); + await expect(credentialsPromise).rejects.toThrow("Login cancelled"); }); it("does not write token refresh failures to stderr", async () => {