diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index d520750035..6679e5fe6b 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -405,6 +405,13 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { const auth = await getAuth() if (auth.type !== "oauth") return {} + let refreshPromise: + | Promise<{ + access: string + accountId: string | undefined + }> + | undefined + return { apiKey: OAUTH_DUMMY_KEY, async fetch(requestInput: RequestInfo | URL, init?: RequestInit) { @@ -429,21 +436,34 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { // Check if token needs refresh if (!currentAuth.access || currentAuth.expires < Date.now()) { - log.info("refreshing codex access token") - const tokens = await refreshAccessToken(currentAuth.refresh) - const newAccountId = extractAccountId(tokens) || authWithAccount.accountId - await input.client.auth.set({ - path: { id: "openai" }, - body: { - type: "oauth", - refresh: tokens.refresh_token, - access: tokens.access_token, - expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, - ...(newAccountId && { accountId: newAccountId }), - }, - }) - currentAuth.access = tokens.access_token - authWithAccount.accountId = newAccountId + if (!refreshPromise) { + log.info("refreshing codex access token") + refreshPromise = refreshAccessToken(currentAuth.refresh) + .then(async (tokens) => { + const accountId = extractAccountId(tokens) || authWithAccount.accountId + await input.client.auth.set({ + path: { id: "openai" }, + body: { + type: "oauth", + refresh: tokens.refresh_token, + access: tokens.access_token, + expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, + ...(accountId && { accountId }), + }, + }) + return { + access: tokens.access_token, + accountId, + } + }) + .finally(() => { + refreshPromise = undefined + }) + } + + const refreshed = await refreshPromise + currentAuth.access = refreshed.access + authWithAccount.accountId = refreshed.accountId } // Build headers diff --git a/packages/opencode/test/plugin/codex.test.ts b/packages/opencode/test/plugin/codex.test.ts index 74d28ac9dc..298539bb15 100644 --- a/packages/opencode/test/plugin/codex.test.ts +++ b/packages/opencode/test/plugin/codex.test.ts @@ -1,11 +1,18 @@ -import { describe, expect, test } from "bun:test" +import { afterEach, describe, expect, mock, test } from "bun:test" import { + CodexAuthPlugin, parseJwtClaims, extractAccountIdFromClaims, extractAccountId, type IdTokenClaims, } from "../../src/plugin/codex" +const originalFetch = globalThis.fetch + +afterEach(() => { + globalThis.fetch = originalFetch +}) + function createTestJwt(payload: object): string { const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url") const body = Buffer.from(JSON.stringify(payload)).toString("base64url") @@ -120,4 +127,76 @@ describe("plugin.codex", () => { ).toBe("acc-123") }) }) + + test("deduplicates concurrent Codex token refreshes", async () => { + let auth = { + type: "oauth" as const, + refresh: "refresh-old", + access: "", + expires: 0, + } + const authUpdates: unknown[] = [] + let resolveRefresh: (() => void) | undefined + const refreshReady = new Promise((resolve) => { + resolveRefresh = resolve + }) + let refreshRequests = 0 + + globalThis.fetch = mock(async (request: RequestInfo | URL) => { + const url = request instanceof URL ? request.href : typeof request === "string" ? request : request.url + if (url === "https://auth.openai.com/oauth/token") { + refreshRequests += 1 + await refreshReady + return new Response( + JSON.stringify({ + id_token: createTestJwt({ chatgpt_account_id: "acc-123" }), + access_token: "access-new", + refresh_token: "refresh-new", + expires_in: 3600, + }), + { status: 200 }, + ) + } + + return new Response("{}", { status: 200 }) + }) as unknown as typeof fetch + + const hooks = await CodexAuthPlugin({ + client: { + auth: { + async set(input: { body: { refresh: string; access: string; expires: number } }) { + authUpdates.push(input) + auth = { + type: "oauth", + refresh: input.body.refresh, + access: input.body.access, + expires: input.body.expires, + } + }, + }, + } as never, + project: {} as never, + directory: "", + worktree: "", + experimental_workspace: { + register() {}, + }, + serverUrl: new URL("https://example.com"), + $: {} as never, + }) + const loaded = await hooks.auth!.loader!(async () => auth as never, {} as never) + + const first = loaded.fetch!("https://api.openai.com/v1/responses") + const second = loaded.fetch!("https://api.openai.com/v1/responses") + + await Promise.resolve() + await Promise.resolve() + expect(refreshRequests).toBe(1) + + resolveRefresh!() + await Promise.all([first, second]) + + expect(refreshRequests).toBe(1) + expect(authUpdates).toHaveLength(1) + }) })