fix: Match Codex token exchange request shape

OpenAI returns token-exchange failures at the OAuth boundary, so the desktop ChatGPT login path should send the same Codex-compatible headers as the reference implementation and preserve enough sanitized response detail to diagnose upstream rejections.

Constraint: OpenAI authorization codes and token responses are sensitive and must not be logged verbatim.
Rejected: Retry the pasted authorization code during debugging | the code is single-use sensitive material and the next generated code gives cleaner evidence.
Confidence: medium
Scope-risk: narrow
Directive: Keep token exchange diagnostics sanitized; do not include raw OAuth codes, refresh tokens, access tokens, or id tokens in errors.
Tested: bun test src/services/openaiAuth/client.test.ts
Tested: bun test src/server/__tests__/haha-openai-oauth-service.test.ts src/server/__tests__/haha-openai-oauth-api.test.ts src/services/openaiAuth/fetch.test.ts src/services/openaiAuth/storage.test.ts
Tested: bun run check:server
Tested: bun run verify
Not-tested: Full successful live OAuth exchange with a fresh user authorization code.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-20 17:28:28 +08:00
parent 055a07be1a
commit 005bfbca8c
2 changed files with 145 additions and 4 deletions

View File

@ -1,9 +1,12 @@
import { describe, expect, test } from 'bun:test'
import {
buildOpenAIAuthorizeUrl,
exchangeOpenAICodeForTokens,
generateOpenAICodeVerifier,
generateOpenAIState,
OPENAI_CODEX_CLIENT_ID,
OPENAI_CODEX_TOKEN_USER_AGENT,
refreshOpenAITokens,
} from './client.js'
describe('OpenAI Codex OAuth client', () => {
@ -37,4 +40,109 @@ describe('OpenAI Codex OAuth client', () => {
expect(params.get('codex_cli_simplified_flow')).toBe('true')
expect(params.has('originator')).toBe(false)
})
test('exchanges authorization code with Codex-compatible token request headers', async () => {
const originalFetch = globalThis.fetch
let tokenRequestUrl = ''
let tokenRequestBody = ''
let tokenRequestHeaders = new Headers()
globalThis.fetch = (async (input, init) => {
tokenRequestUrl = String(input)
tokenRequestBody = String(init?.body ?? '')
tokenRequestHeaders = new Headers(init?.headers)
return new Response(
JSON.stringify({
access_token: 'access-token',
refresh_token: 'refresh-token',
expires_in: 3600,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
}) as typeof fetch
try {
await exchangeOpenAICodeForTokens({
code: 'auth-code',
redirectUri: 'http://localhost:1455/auth/callback',
codeVerifier: 'verifier',
})
expect(tokenRequestUrl).toBe('https://auth.openai.com/oauth/token')
expect(tokenRequestHeaders.get('Accept')).toBe('application/json')
expect(tokenRequestHeaders.get('Content-Type')).toBe(
'application/x-www-form-urlencoded',
)
expect(tokenRequestHeaders.get('User-Agent')).toBe(
OPENAI_CODEX_TOKEN_USER_AGENT,
)
expect(tokenRequestBody).toContain('grant_type=authorization_code')
expect(tokenRequestBody).toContain('client_id=app_EMoamEEZ73f0CkXaXp7hrann')
expect(tokenRequestBody).toContain('code=auth-code')
expect(tokenRequestBody).toContain(
`redirect_uri=${encodeURIComponent('http://localhost:1455/auth/callback')}`,
)
expect(tokenRequestBody).toContain('code_verifier=verifier')
} finally {
globalThis.fetch = originalFetch
}
})
test('refreshes tokens with Codex-compatible token request headers', async () => {
const originalFetch = globalThis.fetch
let tokenRequestBody = ''
let tokenRequestHeaders = new Headers()
globalThis.fetch = (async (_input, init) => {
tokenRequestBody = String(init?.body ?? '')
tokenRequestHeaders = new Headers(init?.headers)
return new Response(
JSON.stringify({
access_token: 'access-token',
expires_in: 3600,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
}) as typeof fetch
try {
await refreshOpenAITokens('refresh-token')
expect(tokenRequestHeaders.get('User-Agent')).toBe(
OPENAI_CODEX_TOKEN_USER_AGENT,
)
expect(tokenRequestBody).toContain('grant_type=refresh_token')
expect(tokenRequestBody).toContain('refresh_token=refresh-token')
expect(tokenRequestBody).toContain('scope=openid+profile+email')
} finally {
globalThis.fetch = originalFetch
}
})
test('includes sanitized token error response details for diagnostics', async () => {
const originalFetch = globalThis.fetch
globalThis.fetch = (async () => {
return new Response(
'{"error":"forbidden","code":"sensitive-code","refresh_token":"secret"}',
{ status: 403, headers: { 'Content-Type': 'application/json' } },
)
}) as typeof fetch
try {
await expect(
exchangeOpenAICodeForTokens({
code: 'auth-code',
redirectUri: 'http://localhost:1455/auth/callback',
codeVerifier: 'verifier',
}),
).rejects.toThrow(
'OpenAI token exchange failed: 403: {"error":"forbidden","code":"[redacted]","refresh_token":"[redacted]"}',
)
} finally {
globalThis.fetch = originalFetch
}
})
})

View File

@ -12,8 +12,16 @@ export const OPENAI_CODEX_API_ENDPOINT =
'https://chatgpt.com/backend-api/codex/responses'
export const OPENAI_CODEX_OAUTH_PORT = 1455
export const OPENAI_CODEX_REDIRECT_PATH = '/auth/callback'
export const OPENAI_CODEX_TOKEN_USER_AGENT = 'codex-cli/0.91.0'
const DEFAULT_TOKEN_LIFETIME_MS = 3600 * 1000
const OPENAI_TOKEN_ERROR_BODY_LIMIT = 500
const OPENAI_TOKEN_REQUEST_HEADERS = {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': OPENAI_CODEX_TOKEN_USER_AGENT,
} as const
export function generateOpenAIState(): string {
return randomBytes(32).toString('hex')
@ -50,7 +58,7 @@ export async function exchangeOpenAICodeForTokens(input: {
}): Promise<OpenAIOAuthTokenResponse> {
const response = await fetch(`${OPENAI_AUTH_ISSUER}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
headers: OPENAI_TOKEN_REQUEST_HEADERS,
body: new URLSearchParams({
grant_type: 'authorization_code',
code: input.code,
@ -61,7 +69,7 @@ export async function exchangeOpenAICodeForTokens(input: {
})
if (!response.ok) {
throw new Error(`OpenAI token exchange failed: ${response.status}`)
throw await buildOpenAITokenHttpError('exchange', response)
}
return (await response.json()) as OpenAIOAuthTokenResponse
@ -72,7 +80,7 @@ export async function refreshOpenAITokens(
): Promise<OpenAIOAuthTokenResponse> {
const response = await fetch(`${OPENAI_AUTH_ISSUER}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
headers: OPENAI_TOKEN_REQUEST_HEADERS,
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
@ -82,12 +90,37 @@ export async function refreshOpenAITokens(
})
if (!response.ok) {
throw new Error(`OpenAI token refresh failed: ${response.status}`)
throw await buildOpenAITokenHttpError('refresh', response)
}
return (await response.json()) as OpenAIOAuthTokenResponse
}
async function buildOpenAITokenHttpError(
operation: 'exchange' | 'refresh',
response: Response,
): Promise<Error> {
const body = await response.text().catch(() => '')
const sanitizedBody = sanitizeOpenAITokenErrorBody(body)
const bodySuffix = sanitizedBody ? `: ${sanitizedBody}` : ''
return new Error(
`OpenAI token ${operation} failed: ${response.status}${bodySuffix}`,
)
}
function sanitizeOpenAITokenErrorBody(body: string): string {
return body
.replace(
/"((?:access_token|refresh_token|id_token|code|code_verifier))"\s*:\s*"[^"]*"/gi,
'"$1":"[redacted]"',
)
.replace(
/\b(access_token|refresh_token|id_token|code|code_verifier)=([^&\s]+)/gi,
'$1=[redacted]',
)
.slice(0, OPENAI_TOKEN_ERROR_BODY_LIMIT)
}
export function parseOpenAIJwtClaims(
token?: string,
): OpenAIJwtClaims | undefined {