Compare commits

...

2 Commits

Author SHA1 Message Date
Aiden Cline 7d43db8772 test(opencode): use local server for Codex refresh dedupe 2026-05-20 23:21:59 -05:00
Cooper Gamble fd2c3f3f13 fix(opencode): dedupe concurrent Codex OAuth refreshes 2026-05-18 20:26:42 +00:00
2 changed files with 153 additions and 19 deletions
+47 -19
View File
@@ -118,6 +118,11 @@ interface TokenResponse {
expires_in?: number expires_in?: number
} }
interface CodexAuthPluginOptions {
issuer?: string
codexApiEndpoint?: string
}
async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: PkceCodes): Promise<TokenResponse> { async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: PkceCodes): Promise<TokenResponse> {
const response = await fetch(`${ISSUER}/oauth/token`, { const response = await fetch(`${ISSUER}/oauth/token`, {
method: "POST", method: "POST",
@@ -136,8 +141,8 @@ async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: Pk
return response.json() return response.json()
} }
async function refreshAccessToken(refreshToken: string): Promise<TokenResponse> { async function refreshAccessToken(refreshToken: string, issuer = ISSUER): Promise<TokenResponse> {
const response = await fetch(`${ISSUER}/oauth/token`, { const response = await fetch(`${issuer}/oauth/token`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" }, headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ body: new URLSearchParams({
@@ -364,7 +369,10 @@ function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResp
}) })
} }
export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> { export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPluginOptions = {}): Promise<Hooks> {
const issuer = options.issuer ?? ISSUER
const codexApiEndpoint = options.codexApiEndpoint ?? CODEX_API_ENDPOINT
return { return {
provider: { provider: {
id: "openai", id: "openai",
@@ -405,6 +413,13 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
const auth = await getAuth() const auth = await getAuth()
if (auth.type !== "oauth") return {} if (auth.type !== "oauth") return {}
let refreshPromise:
| Promise<{
access: string
accountId: string | undefined
}>
| undefined
return { return {
apiKey: OAUTH_DUMMY_KEY, apiKey: OAUTH_DUMMY_KEY,
async fetch(requestInput: RequestInfo | URL, init?: RequestInit) { async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
@@ -429,21 +444,34 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
// Check if token needs refresh // Check if token needs refresh
if (!currentAuth.access || currentAuth.expires < Date.now()) { if (!currentAuth.access || currentAuth.expires < Date.now()) {
log.info("refreshing codex access token") if (!refreshPromise) {
const tokens = await refreshAccessToken(currentAuth.refresh) log.info("refreshing codex access token")
const newAccountId = extractAccountId(tokens) || authWithAccount.accountId refreshPromise = refreshAccessToken(currentAuth.refresh, issuer)
await input.client.auth.set({ .then(async (tokens) => {
path: { id: "openai" }, const accountId = extractAccountId(tokens) || authWithAccount.accountId
body: { await input.client.auth.set({
type: "oauth", path: { id: "openai" },
refresh: tokens.refresh_token, body: {
access: tokens.access_token, type: "oauth",
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, refresh: tokens.refresh_token,
...(newAccountId && { accountId: newAccountId }), access: tokens.access_token,
}, expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
}) ...(accountId && { accountId }),
currentAuth.access = tokens.access_token },
authWithAccount.accountId = newAccountId })
return {
access: tokens.access_token,
accountId,
}
})
.finally(() => {
refreshPromise = undefined
})
}
const refreshed = await refreshPromise
currentAuth.access = refreshed.access
authWithAccount.accountId = refreshed.accountId
} }
// Build headers // Build headers
@@ -477,7 +505,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
: new URL(typeof requestInput === "string" ? requestInput : requestInput.url) : new URL(typeof requestInput === "string" ? requestInput : requestInput.url)
const url = const url =
parsed.pathname.includes("/v1/responses") || parsed.pathname.includes("/chat/completions") parsed.pathname.includes("/v1/responses") || parsed.pathname.includes("/chat/completions")
? new URL(CODEX_API_ENDPOINT) ? new URL(codexApiEndpoint)
: parsed : parsed
return fetch(url, { return fetch(url, {
+106
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { import {
CodexAuthPlugin,
parseJwtClaims, parseJwtClaims,
extractAccountIdFromClaims, extractAccountIdFromClaims,
extractAccountId, extractAccountId,
@@ -120,4 +121,109 @@ describe("plugin.codex", () => {
).toBe("acc-123") ).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<void>((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))
}
}