Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d43db8772 | ||
|
|
fd2c3f3f13 |
@@ -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, {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user