diff --git a/extensions/openai/openai-codex-auth-identity.test.ts b/extensions/openai/openai-codex-auth-identity.test.ts index c417caef9c3..0b9bdb3a86c 100644 --- a/extensions/openai/openai-codex-auth-identity.test.ts +++ b/extensions/openai/openai-codex-auth-identity.test.ts @@ -45,6 +45,19 @@ describe("resolveCodexAuthIdentity", () => { }); }); + it("decodes URL-safe base64 JWT payloads", () => { + const accessToken = createJwt({ + "https://api.openai.com/auth": { + chatgpt_account_id: "w_ébé_1fzcswWN6Pi5zL", + }, + }); + expect(accessToken.split(".")[1]).toContain("_"); + + expect(resolveCodexAuthIdentity({ accessToken })).toEqual({ + accountId: "w_ébé_1fzcswWN6Pi5zL", + }); + }); + it("falls back to credential email before synthetic ids", () => { const identity = resolveCodexAuthIdentity({ accessToken: createJwt({}), diff --git a/extensions/openai/openai-codex-oauth-flow.runtime.ts b/extensions/openai/openai-codex-oauth-flow.runtime.ts index b15e2aad2ef..290313a166c 100644 --- a/extensions/openai/openai-codex-oauth-flow.runtime.ts +++ b/extensions/openai/openai-codex-oauth-flow.runtime.ts @@ -17,6 +17,7 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version }); } +import { resolveCodexAuthIdentity } from "./openai-codex-auth-identity.js"; import { oauthErrorHtml, oauthSuccessHtml } from "./openai-codex-oauth-page.runtime.js"; import type { OAuthCredentials, @@ -32,7 +33,6 @@ 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 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 }; @@ -43,13 +43,6 @@ type TokenResponseJson = { expires_in?: number; }; -type JwtPayload = { - [JWT_CLAIM_PATH]?: { - chatgpt_account_id?: string; - }; - [key: string]: unknown; -}; - function createState(): string { if (!randomBytes) { throw new Error("OpenAI Codex OAuth is only available in Node.js environments"); @@ -89,20 +82,6 @@ function parseAuthorizationInput(input: string): { code?: string; state?: string return { code: value }; } -function decodeJwt(token: string): JwtPayload | null { - try { - const parts = token.split("."); - if (parts.length !== 3) { - return null; - } - const payload = parts[1] ?? ""; - const decoded = atob(payload); - return JSON.parse(decoded) as JwtPayload; - } catch { - return null; - } -} - function formatMissingTokenResponseFields(json: TokenResponseJson): string { const missing: string[] = []; if (!json.access_token) { @@ -310,9 +289,7 @@ function startLocalOAuthServer(state: string): Promise { } function getAccountId(accessToken: string): string | null { - const payload = decodeJwt(accessToken); - const auth = payload?.[JWT_CLAIM_PATH]; - const accountId = auth?.chatgpt_account_id; + const accountId = resolveCodexAuthIdentity({ accessToken }).accountId; return typeof accountId === "string" && accountId.length > 0 ? accountId : null; } diff --git a/src/llm/providers/openai-codex-responses.test.ts b/src/llm/providers/openai-codex-responses.test.ts new file mode 100644 index 00000000000..57ba4fa4329 --- /dev/null +++ b/src/llm/providers/openai-codex-responses.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { extractOpenAICodexAccountId } from "./openai-codex-responses.js"; + +function createJwt(payload: Record): string { + const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url"); + const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); + return `${header}.${body}.signature`; +} + +describe("extractOpenAICodexAccountId", () => { + it("decodes URL-safe base64 JWT payloads", () => { + const accessToken = createJwt({ + "https://api.openai.com/auth": { + chatgpt_account_id: "w_ébé_1fzcswWN6Pi5zL", + }, + }); + expect(accessToken.split(".")[1]).toContain("_"); + + expect(extractOpenAICodexAccountId(accessToken)).toBe("w_ébé_1fzcswWN6Pi5zL"); + }); + + it("rejects tokens without a Codex account id", () => { + expect(() => extractOpenAICodexAccountId(createJwt({}))).toThrow( + "Failed to extract accountId from token", + ); + }); +}); diff --git a/src/llm/providers/openai-codex-responses.ts b/src/llm/providers/openai-codex-responses.ts index b74fef76f18..1f5dd88d95c 100644 --- a/src/llm/providers/openai-codex-responses.ts +++ b/src/llm/providers/openai-codex-responses.ts @@ -106,6 +106,12 @@ interface RequestBody { [key: string]: unknown; } +type CodexJwtPayload = { + [JWT_CLAIM_PATH]?: { + chatgpt_account_id?: unknown; + }; +}; + // ============================================================================ // Retry Helpers // ============================================================================ @@ -172,7 +178,7 @@ export const streamOpenAICodexResponses: StreamFunction< throw new Error(`No API key for provider: ${model.provider}`); } - const accountId = extractAccountId(apiKey); + const accountId = extractOpenAICodexAccountId(apiKey); let body = buildRequestBody(model, context, options); const nextBody = await options?.onPayload?.(body, model); if (nextBody !== undefined) { @@ -1458,21 +1464,29 @@ async function parseErrorResponse( // Auth & Headers // ============================================================================ -function extractAccountId(token: string): string { - try { - const parts = token.split("."); - if (parts.length !== 3) { - throw new Error("Invalid token"); - } - const payload = JSON.parse(atob(parts[1])); - const accountId = payload?.[JWT_CLAIM_PATH]?.chatgpt_account_id; - if (!accountId) { - throw new Error("No account ID in token"); - } - return accountId; - } catch { - throw new Error("Failed to extract accountId from token"); +function decodeCodexJwtPayload(token: string): CodexJwtPayload | null { + const parts = token.split("."); + if (parts.length !== 3) { + return null; } + + try { + const decoded = Buffer.from(parts[1] ?? "", "base64url").toString("utf8"); + const parsed = JSON.parse(decoded); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as CodexJwtPayload) + : null; + } catch { + return null; + } +} + +export function extractOpenAICodexAccountId(token: string): string { + const accountId = decodeCodexJwtPayload(token)?.[JWT_CLAIM_PATH]?.chatgpt_account_id; + if (typeof accountId === "string" && accountId.length > 0) { + return accountId; + } + throw new Error("Failed to extract accountId from token"); } function createCodexRequestId(): string {