mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +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 { describe, expect, test } from 'bun:test'
|
||||||
import {
|
import {
|
||||||
buildOpenAIAuthorizeUrl,
|
buildOpenAIAuthorizeUrl,
|
||||||
|
exchangeOpenAICodeForTokens,
|
||||||
generateOpenAICodeVerifier,
|
generateOpenAICodeVerifier,
|
||||||
generateOpenAIState,
|
generateOpenAIState,
|
||||||
OPENAI_CODEX_CLIENT_ID,
|
OPENAI_CODEX_CLIENT_ID,
|
||||||
|
OPENAI_CODEX_TOKEN_USER_AGENT,
|
||||||
|
refreshOpenAITokens,
|
||||||
} from './client.js'
|
} from './client.js'
|
||||||
|
|
||||||
describe('OpenAI Codex OAuth client', () => {
|
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.get('codex_cli_simplified_flow')).toBe('true')
|
||||||
expect(params.has('originator')).toBe(false)
|
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'
|
'https://chatgpt.com/backend-api/codex/responses'
|
||||||
export const OPENAI_CODEX_OAUTH_PORT = 1455
|
export const OPENAI_CODEX_OAUTH_PORT = 1455
|
||||||
export const OPENAI_CODEX_REDIRECT_PATH = '/auth/callback'
|
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 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 {
|
export function generateOpenAIState(): string {
|
||||||
return randomBytes(32).toString('hex')
|
return randomBytes(32).toString('hex')
|
||||||
@ -50,7 +58,7 @@ export async function exchangeOpenAICodeForTokens(input: {
|
|||||||
}): Promise<OpenAIOAuthTokenResponse> {
|
}): Promise<OpenAIOAuthTokenResponse> {
|
||||||
const response = await fetch(`${OPENAI_AUTH_ISSUER}/oauth/token`, {
|
const response = await fetch(`${OPENAI_AUTH_ISSUER}/oauth/token`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: OPENAI_TOKEN_REQUEST_HEADERS,
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
grant_type: 'authorization_code',
|
grant_type: 'authorization_code',
|
||||||
code: input.code,
|
code: input.code,
|
||||||
@ -61,7 +69,7 @@ export async function exchangeOpenAICodeForTokens(input: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`OpenAI token exchange failed: ${response.status}`)
|
throw await buildOpenAITokenHttpError('exchange', response)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (await response.json()) as OpenAIOAuthTokenResponse
|
return (await response.json()) as OpenAIOAuthTokenResponse
|
||||||
@ -72,7 +80,7 @@ export async function refreshOpenAITokens(
|
|||||||
): Promise<OpenAIOAuthTokenResponse> {
|
): Promise<OpenAIOAuthTokenResponse> {
|
||||||
const response = await fetch(`${OPENAI_AUTH_ISSUER}/oauth/token`, {
|
const response = await fetch(`${OPENAI_AUTH_ISSUER}/oauth/token`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: OPENAI_TOKEN_REQUEST_HEADERS,
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
grant_type: 'refresh_token',
|
grant_type: 'refresh_token',
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
@ -82,12 +90,37 @@ export async function refreshOpenAITokens(
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`OpenAI token refresh failed: ${response.status}`)
|
throw await buildOpenAITokenHttpError('refresh', response)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (await response.json()) as OpenAIOAuthTokenResponse
|
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(
|
export function parseOpenAIJwtClaims(
|
||||||
token?: string,
|
token?: string,
|
||||||
): OpenAIJwtClaims | undefined {
|
): OpenAIJwtClaims | undefined {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user