From 005bfbca8cf0bbcaa89e911222906822d6ff2ef9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 20 May 2026 17:28:28 +0800 Subject: [PATCH] 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. --- src/services/openaiAuth/client.test.ts | 108 +++++++++++++++++++++++++ src/services/openaiAuth/client.ts | 41 +++++++++- 2 files changed, 145 insertions(+), 4 deletions(-) diff --git a/src/services/openaiAuth/client.test.ts b/src/services/openaiAuth/client.test.ts index 2ea7bd61..edbafff4 100644 --- a/src/services/openaiAuth/client.test.ts +++ b/src/services/openaiAuth/client.test.ts @@ -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 + } + }) }) diff --git a/src/services/openaiAuth/client.ts b/src/services/openaiAuth/client.ts index 459800e8..11ef6e82 100644 --- a/src/services/openaiAuth/client.ts +++ b/src/services/openaiAuth/client.ts @@ -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 { 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 { 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 { + 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 {