From c64ac905e19cd881e4d3c8af6449f228941a2674 Mon Sep 17 00:00:00 2001 From: cooper-oai Date: Wed, 20 May 2026 21:34:31 -0700 Subject: [PATCH] fix(opencode): dedupe concurrent Codex OAuth refreshes (#28236) Co-authored-by: Aiden Cline --- packages/opencode/src/plugin/codex.ts | 66 ++++++++---- packages/opencode/test/plugin/codex.test.ts | 106 ++++++++++++++++++++ 2 files changed, 153 insertions(+), 19 deletions(-) diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index d520750035..d501e33652 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -118,6 +118,11 @@ interface TokenResponse { expires_in?: number } +interface CodexAuthPluginOptions { + issuer?: string + codexApiEndpoint?: string +} + async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: PkceCodes): Promise { const response = await fetch(`${ISSUER}/oauth/token`, { method: "POST", @@ -136,8 +141,8 @@ async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: Pk return response.json() } -async function refreshAccessToken(refreshToken: string): Promise { - const response = await fetch(`${ISSUER}/oauth/token`, { +async function refreshAccessToken(refreshToken: string, issuer = ISSUER): Promise { + const response = await fetch(`${issuer}/oauth/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ @@ -364,7 +369,10 @@ function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise { +export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPluginOptions = {}): Promise { + const issuer = options.issuer ?? ISSUER + const codexApiEndpoint = options.codexApiEndpoint ?? CODEX_API_ENDPOINT + return { provider: { id: "openai", @@ -405,6 +413,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 +444,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, issuer) + .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 @@ -477,7 +505,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { : new URL(typeof requestInput === "string" ? requestInput : requestInput.url) const url = parsed.pathname.includes("/v1/responses") || parsed.pathname.includes("/chat/completions") - ? new URL(CODEX_API_ENDPOINT) + ? new URL(codexApiEndpoint) : parsed return fetch(url, { diff --git a/packages/opencode/test/plugin/codex.test.ts b/packages/opencode/test/plugin/codex.test.ts index 74d28ac9dc..271bcde99b 100644 --- a/packages/opencode/test/plugin/codex.test.ts +++ b/packages/opencode/test/plugin/codex.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import { + CodexAuthPlugin, parseJwtClaims, extractAccountIdFromClaims, extractAccountId, @@ -120,4 +121,109 @@ 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: Array<{ + body: { refresh: string; access: string; expires: number; accountId?: string } + }> = [] + let resolveRefresh: (() => void) | undefined + const refreshReady = new Promise((resolve) => { + resolveRefresh = resolve + }) + let refreshRequests = 0 + const apiRequests: { authorization: string | null; accountId: string | null }[] = [] + + using server = Bun.serve({ + port: 0, + async fetch(request) { + const url = new URL(request.url) + if (url.pathname === "/oauth/token") { + expect(await request.text()).toContain("refresh_token=refresh-old") + refreshRequests += 1 + await refreshReady + return Response.json({ + id_token: createTestJwt({ chatgpt_account_id: "acc-123" }), + access_token: "access-new", + refresh_token: "refresh-new", + expires_in: 3600, + }) + } + + if (url.pathname === "/backend-api/codex/responses") { + apiRequests.push({ + authorization: request.headers.get("authorization"), + accountId: request.headers.get("ChatGPT-Account-Id"), + }) + return new Response("{}", { status: 200 }) + } + + return new Response("unexpected request", { status: 500 }) + }, + }) + + const hooks = await CodexAuthPlugin( + { + client: { + auth: { + async set(input: { body: { refresh: string; access: string; expires: number; accountId?: string } }) { + authUpdates.push(input) + auth = { + type: "oauth", + refresh: input.body.refresh, + access: input.body.access, + expires: input.body.expires, + ...(input.body.accountId && { accountId: input.body.accountId }), + } + }, + }, + } as never, + project: {} as never, + directory: "", + worktree: "", + experimental_workspace: { + register() {}, + }, + serverUrl: new URL("https://example.com"), + $: {} as never, + }, + { + issuer: server.url.origin, + codexApiEndpoint: new URL("/backend-api/codex/responses", server.url).toString(), + }, + ) + 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 waitFor(() => refreshRequests === 1) + expect(apiRequests).toHaveLength(0) + + resolveRefresh!() + await Promise.all([first, second]) + + expect(refreshRequests).toBe(1) + expect(authUpdates).toHaveLength(1) + expect(authUpdates[0]?.body.refresh).toBe("refresh-new") + expect(authUpdates[0]?.body.access).toBe("access-new") + expect(authUpdates[0]?.body.accountId).toBe("acc-123") + expect(apiRequests).toEqual([ + { authorization: "Bearer access-new", accountId: "acc-123" }, + { authorization: "Bearer access-new", accountId: "acc-123" }, + ]) + }) }) + +async function waitFor(predicate: () => boolean) { + const started = Date.now() + while (!predicate()) { + if (Date.now() - started > 1_000) throw new Error("timed out waiting for condition") + await new Promise((resolve) => setTimeout(resolve, 1)) + } +}