mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
055a07be1a
commit
005bfbca8c
@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user