From b6e70a5cddaced65d2464eb724660b47d7358a98 Mon Sep 17 00:00:00 2001 From: Liren Pan Date: Wed, 25 Mar 2026 00:54:50 +0800 Subject: [PATCH] auth: derive codex oauth profile ids from jwt claims --- extensions/openai/openai-codex-provider.ts | 70 ++++++++++- src/plugins/contracts/auth.contract.test.ts | 130 ++++++++++++++++++++ 2 files changed, 199 insertions(+), 1 deletion(-) diff --git a/extensions/openai/openai-codex-provider.ts b/extensions/openai/openai-codex-provider.ts index 3da270b88c2..50456031603 100644 --- a/extensions/openai/openai-codex-provider.ts +++ b/extensions/openai/openai-codex-provider.ts @@ -54,6 +54,62 @@ const OPENAI_CODEX_MODERN_MODEL_IDS = [ OPENAI_CODEX_GPT_53_SPARK_MODEL_ID, ] as const; +type CodexJwtPayload = { + iss?: unknown; + sub?: unknown; + "https://api.openai.com/profile"?: { + email?: unknown; + }; + "https://api.openai.com/auth"?: { + chatgpt_account_user_id?: unknown; + chatgpt_user_id?: unknown; + user_id?: unknown; + }; +}; + +function normalizeNonEmptyString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + return value.trim() || undefined; +} + +function decodeCodexJwtPayload(accessToken: string): CodexJwtPayload | null { + const parts = accessToken.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" ? (parsed as CodexJwtPayload) : null; + } catch { + return null; + } +} + +function resolveCodexStableSubject(payload: CodexJwtPayload | null): string | undefined { + const auth = payload?.["https://api.openai.com/auth"]; + const accountUserId = normalizeNonEmptyString(auth?.chatgpt_account_user_id); + if (accountUserId) { + return accountUserId; + } + + const userId = + normalizeNonEmptyString(auth?.chatgpt_user_id) ?? normalizeNonEmptyString(auth?.user_id); + if (userId) { + return userId; + } + + const iss = normalizeNonEmptyString(payload?.iss); + const sub = normalizeNonEmptyString(payload?.sub); + if (iss && sub) { + return `${iss}|${sub}`; + } + return sub; +} + function isOpenAICodexBaseUrl(baseUrl?: string): boolean { const trimmed = baseUrl?.trim(); if (!trimmed) { @@ -182,13 +238,25 @@ async function runOpenAICodexOAuth(ctx: ProviderAuthContext) { return { profiles: [] }; } + const payload = decodeCodexJwtPayload(creds.access); + const resolvedEmail = + normalizeNonEmptyString(payload?.["https://api.openai.com/profile"]?.email) ?? + normalizeNonEmptyString(creds.email); + return buildOauthProviderAuthResult({ providerId: PROVIDER_ID, defaultModel: OPENAI_CODEX_DEFAULT_MODEL, access: creds.access, refresh: creds.refresh, expires: creds.expires, - email: typeof creds.email === "string" ? creds.email : undefined, + email: + resolvedEmail ?? + (() => { + const stableSubject = resolveCodexStableSubject(payload); + return stableSubject + ? `id-${Buffer.from(stableSubject).toString("base64url")}` + : undefined; + })(), }); } diff --git a/src/plugins/contracts/auth.contract.test.ts b/src/plugins/contracts/auth.contract.test.ts index 1d12d71165a..431de71edf5 100644 --- a/src/plugins/contracts/auth.contract.test.ts +++ b/src/plugins/contracts/auth.contract.test.ts @@ -91,6 +91,12 @@ function buildAuthContext() { }; } +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("provider auth contract", () => { let authStore: AuthProfileStore; @@ -154,6 +160,130 @@ describe("provider auth contract", () => { }); }); + it("backfills OpenAI Codex OAuth email from the JWT profile claim", async () => { + const provider = requireProvider(registerProviders(openAIPlugin), "openai-codex"); + const access = createJwt({ + "https://api.openai.com/profile": { + email: "jwt-user@example.com", + }, + }); + loginOpenAICodexOAuthMock.mockResolvedValueOnce({ + refresh: "refresh-token", + access, + expires: 1_700_000_000_000, + }); + + const result = await provider.auth[0]?.run(buildAuthContext() as never); + + expect(result).toEqual({ + profiles: [ + { + profileId: "openai-codex:jwt-user@example.com", + credential: { + type: "oauth", + provider: "openai-codex", + access, + refresh: "refresh-token", + expires: 1_700_000_000_000, + email: "jwt-user@example.com", + }, + }, + ], + configPatch: { + agents: { + defaults: { + models: { + "openai-codex/gpt-5.4": {}, + }, + }, + }, + }, + defaultModel: "openai-codex/gpt-5.4", + notes: undefined, + }); + }); + + it("uses a stable fallback id when OpenAI Codex JWT email is missing", async () => { + const provider = requireProvider(registerProviders(openAIPlugin), "openai-codex"); + const access = createJwt({ + "https://api.openai.com/auth": { + chatgpt_account_user_id: "user-123__acct-456", + }, + }); + const expectedStableId = Buffer.from("user-123__acct-456", "utf8").toString("base64url"); + loginOpenAICodexOAuthMock.mockResolvedValueOnce({ + refresh: "refresh-token", + access, + expires: 1_700_000_000_000, + }); + + const result = await provider.auth[0]?.run(buildAuthContext() as never); + + expect(result).toEqual({ + profiles: [ + { + profileId: `openai-codex:id-${expectedStableId}`, + credential: { + type: "oauth", + provider: "openai-codex", + access, + refresh: "refresh-token", + expires: 1_700_000_000_000, + email: `id-${expectedStableId}`, + }, + }, + ], + configPatch: { + agents: { + defaults: { + models: { + "openai-codex/gpt-5.4": {}, + }, + }, + }, + }, + defaultModel: "openai-codex/gpt-5.4", + notes: undefined, + }); + }); + + it("falls back to the default OpenAI Codex profile when JWT parsing yields no identity", async () => { + const provider = requireProvider(registerProviders(openAIPlugin), "openai-codex"); + loginOpenAICodexOAuthMock.mockResolvedValueOnce({ + refresh: "refresh-token", + access: "not-a-jwt-token", + expires: 1_700_000_000_000, + }); + + const result = await provider.auth[0]?.run(buildAuthContext() as never); + + expect(result).toEqual({ + profiles: [ + { + profileId: "openai-codex:default", + credential: { + type: "oauth", + provider: "openai-codex", + access: "not-a-jwt-token", + refresh: "refresh-token", + expires: 1_700_000_000_000, + }, + }, + ], + configPatch: { + agents: { + defaults: { + models: { + "openai-codex/gpt-5.4": {}, + }, + }, + }, + }, + defaultModel: "openai-codex/gpt-5.4", + notes: undefined, + }); + }); + it("keeps OpenAI Codex OAuth failures non-fatal at the provider layer", async () => { const provider = requireProvider(registerProviders(openAIPlugin), "openai-codex"); loginOpenAICodexOAuthMock.mockRejectedValueOnce(new Error("oauth failed"));