cc-haha/src/server/__tests__/haha-openai-oauth-service.test.ts
程序员阿江(Relakkes) 055a07be1a fix: Prevent ChatGPT OAuth authorization from failing before callback
OpenAI rejects Codex OAuth authorize URLs when the redirect URI uses the desktop API server's dynamic port. The desktop ChatGPT Official flow now starts a temporary Codex callback listener and generates the authorize URL with that callback port, while keeping token exchange and storage in the existing desktop OAuth service.

Constraint: OpenAI Codex OAuth client accepts the Codex-compatible localhost callback shape, not arbitrary desktop API ports.
Rejected: Keep routing the callback through the desktop server port | that fails before the browser can return an authorization code.
Confidence: high
Scope-risk: narrow
Directive: Do not put non-Codex query parameters or dynamic desktop ports into the OpenAI authorize URL without live authorization-page verification.
Tested: bun test src/server/__tests__/haha-openai-oauth-service.test.ts src/server/__tests__/haha-openai-oauth-api.test.ts
Tested: bun test src/services/openaiAuth/client.test.ts src/services/openaiAuth/fetch.test.ts src/services/openaiAuth/storage.test.ts
Tested: bun run check:server
Tested: bun run check:coverage
Tested: bun run verify
Not-tested: Completing a real account OAuth approval in the browser after the fix.
2026-05-20 17:04:38 +08:00

388 lines
12 KiB
TypeScript

/**
* Unit tests for HahaOpenAIOAuthService — haha 自管 OpenAI OAuth 的核心 service 层。
*/
import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test'
import * as fs from 'fs/promises'
import * as path from 'path'
import * as os from 'os'
import { createConnection, createServer } from 'net'
import {
HahaOpenAIOAuthService,
getHahaOpenAIOAuthFilePath,
type StoredOpenAIOAuthTokens,
} from '../services/hahaOpenAIOAuthService.js'
let tmpDir: string
let originalConfigDir: string | undefined
let service: HahaOpenAIOAuthService
let callbackPort: number
async function getFreePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = createServer()
server.on('error', reject)
server.listen(0, '127.0.0.1', () => {
const address = server.address()
if (!address || typeof address === 'string') {
server.close(() => reject(new Error('Failed to allocate test port')))
return
}
const port = address.port
server.close(() => resolve(port))
})
})
}
async function getLocalCallback(
callbackPath: string,
): Promise<{ status: number; body: string }> {
return await new Promise((resolve, reject) => {
const socket = createConnection(
{ host: 'localhost', port: callbackPort },
() => {
socket.write(
`GET ${callbackPath} HTTP/1.1\r\nHost: localhost:${callbackPort}\r\nConnection: close\r\n\r\n`,
)
},
)
let raw = ''
socket.setEncoding('utf8')
socket.on('data', (chunk) => {
raw += chunk
})
socket.on('end', () => {
const status = Number.parseInt(
raw.match(/^HTTP\/1\.[01] (\d{3})/)?.[1] ?? '0',
10,
)
const body = raw.split('\r\n\r\n').slice(1).join('\r\n\r\n')
resolve({ status, body })
})
socket.on('error', reject)
})
}
function mockJwt(payload: Record<string, unknown>): string {
const encode = (value: Record<string, unknown>) =>
Buffer.from(JSON.stringify(value)).toString('base64url')
return `${encode({ alg: 'none' })}.${encode(payload)}.signature`
}
async function setup() {
tmpDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'haha-openai-oauth-test-'),
)
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = tmpDir
callbackPort = await getFreePort()
service = new HahaOpenAIOAuthService({ callbackPort })
}
async function teardown() {
service.dispose()
if (originalConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
}
await fs.rm(tmpDir, { recursive: true, force: true })
}
describe('HahaOpenAIOAuthService — file storage', () => {
beforeEach(setup)
afterEach(teardown)
test('loadTokens returns null when file does not exist', async () => {
expect(await service.loadTokens()).toBeNull()
})
test('saveTokens writes file with 0600 permissions', async () => {
const tokens: StoredOpenAIOAuthTokens = {
accessToken: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.mock-access',
refreshToken: 'eyJhbGciOiJSUzI1NiJ9.mock-refresh',
expiresAt: Date.now() + 3600_000,
idToken: 'mock-id-token',
email: 'test@example.com',
accountId: 'acct_123',
clientId: 'app_EMoamEEZ73f0CkXaXp7hrann',
}
await service.saveTokens(tokens)
const oauthPath = getHahaOpenAIOAuthFilePath()
const stat = await fs.stat(oauthPath)
if (process.platform !== 'win32') {
expect(stat.mode & 0o777).toBe(0o600)
}
const loaded = await service.loadTokens()
expect(loaded).toEqual(tokens)
})
test('deleteTokens removes file', async () => {
await service.saveTokens({
accessToken: 'a',
refreshToken: null,
expiresAt: null,
email: null,
accountId: null,
})
await service.deleteTokens()
expect(await service.loadTokens()).toBeNull()
})
test('saveTokens cleans up tmp file when rename fails', async () => {
const renameSpy = spyOn(fs, 'rename').mockImplementation(async () => {
const error = new Error('rename failed') as NodeJS.ErrnoException
error.code = 'EXDEV'
throw error
})
try {
await expect(
service.saveTokens({
accessToken: 'sensitive-access',
refreshToken: 'sensitive-refresh',
expiresAt: Date.now() + 3600_000,
idToken: 'sensitive-id-token',
email: 'test@example.com',
accountId: 'acct_123',
}),
).rejects.toThrow('rename failed')
} finally {
renameSpy.mockRestore()
}
const oauthPath = getHahaOpenAIOAuthFilePath()
const files = await fs.readdir(path.dirname(oauthPath))
expect(
files.filter((name) => name.startsWith('openai-oauth.json.tmp.')),
).toEqual([])
expect(await service.loadTokens()).toBeNull()
})
})
describe('HahaOpenAIOAuthService — session management', () => {
beforeEach(setup)
afterEach(teardown)
test('startSession creates session with PKCE + fixed Codex callback port', async () => {
const session = await service.startSession({ serverPort: 54321 })
expect(session.state).toMatch(/^[a-f0-9]{64}$/)
expect(session.codeVerifier).toMatch(/^[a-f0-9]{128}$/)
expect(session.authorizeUrl).toContain('code_challenge_method=S256')
expect(session.authorizeUrl).toContain(
`state=${encodeURIComponent(session.state)}`,
)
expect(session.authorizeUrl).toContain(
'codex_cli_simplified_flow=true',
)
expect(session.authorizeUrl).toContain(
encodeURIComponent(`http://localhost:${callbackPort}/auth/callback`),
)
expect(session.authorizeUrl).not.toContain(
encodeURIComponent('http://localhost:54321/auth/callback'),
)
expect(session.authorizeUrl).not.toContain('originator=')
})
test('getSession returns stored session by state', async () => {
const session = await service.startSession({ serverPort: 54321 })
const found = service.getSession(session.state)
expect(found?.codeVerifier).toBe(session.codeVerifier)
})
test('getSession returns null for unknown state', () => {
expect(service.getSession('unknown-state')).toBeNull()
})
test('consumeSession removes session after fetch', async () => {
const session = await service.startSession({ serverPort: 54321 })
expect(service.consumeSession(session.state)).not.toBeNull()
expect(service.getSession(session.state)).toBeNull()
})
test('callback listener exchanges the authorization code and saves tokens', async () => {
const originalFetch = globalThis.fetch
const session = await service.startSession({ serverPort: 54321 })
let tokenRequestBody = ''
globalThis.fetch = (async (_url, init) => {
tokenRequestBody = String(init?.body ?? '')
return new Response(
JSON.stringify({
access_token: 'openai-access-token',
refresh_token: 'openai-refresh-token',
expires_in: 3600,
id_token: mockJwt({
email: 'test@example.com',
'https://api.openai.com/auth': {
chatgpt_account_id: 'acct_123',
},
}),
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
}) as typeof fetch
try {
const res = await getLocalCallback(
`/auth/callback?code=auth-code&state=${session.state}`,
)
expect(res.status).toBe(200)
expect(res.body).toContain('OpenAI Login Successful')
expect(tokenRequestBody).toContain('code=auth-code')
expect(tokenRequestBody).toContain(
`redirect_uri=${encodeURIComponent(`http://localhost:${callbackPort}/auth/callback`)}`,
)
expect(tokenRequestBody).toContain(
`code_verifier=${session.codeVerifier}`,
)
const tokens = await service.loadTokens()
expect(tokens?.accessToken).toBe('openai-access-token')
expect(tokens?.refreshToken).toBe('openai-refresh-token')
expect(tokens?.email).toBe('test@example.com')
expect(tokens?.accountId).toBe('acct_123')
} finally {
globalThis.fetch = originalFetch
}
})
test('callback listener renders an error page when token exchange fails', async () => {
const originalFetch = globalThis.fetch
const session = await service.startSession({ serverPort: 54321 })
globalThis.fetch = (async () => {
return new Response('bad request', { status: 400 })
}) as typeof fetch
try {
const res = await getLocalCallback(
`/auth/callback?code=bad-code&state=${session.state}`,
)
expect(res.status).toBe(200)
expect(res.body).toContain('OpenAI Login Failed')
expect(res.body).toContain('OpenAI token exchange failed: 400')
expect(await service.loadTokens()).toBeNull()
} finally {
globalThis.fetch = originalFetch
}
})
test('callback listener clears the session after an invalid callback', async () => {
const consoleSpy = spyOn(console, 'error').mockImplementation(() => {})
const session = await service.startSession({ serverPort: 54321 })
try {
const res = await getLocalCallback(`/auth/callback?state=${session.state}`)
expect(res.status).toBe(400)
expect(res.body).toContain('Authorization code not found')
await new Promise((resolve) => setTimeout(resolve, 0))
expect(service.getSession(session.state)).toBeNull()
} finally {
consoleSpy.mockRestore()
}
})
})
describe('HahaOpenAIOAuthService — ensureFreshAccessToken', () => {
beforeEach(setup)
afterEach(teardown)
test('returns null when no token file exists', async () => {
expect(await service.ensureFreshAccessToken()).toBeNull()
})
test('returns token unchanged if not expired', async () => {
const tokens: StoredOpenAIOAuthTokens = {
accessToken: 'still-valid',
refreshToken: 'refresh-xxx',
expiresAt: Date.now() + 30 * 60_000,
email: 'test@example.com',
accountId: 'acct_123',
}
await service.saveTokens(tokens)
expect(await service.ensureFreshAccessToken()).toBe('still-valid')
})
test('returns null when tokens expired and no refresh token', async () => {
await service.saveTokens({
accessToken: 'expired',
refreshToken: null,
expiresAt: Date.now() - 1_000,
email: null,
accountId: null,
})
expect(await service.ensureFreshAccessToken()).toBeNull()
})
test('refreshes token when expired (within 5-min buffer)', async () => {
await service.saveTokens({
accessToken: 'expired',
refreshToken: 'refresh-xxx',
expiresAt: Date.now() + 60_000,
email: 'test@example.com',
accountId: 'acct_123',
})
service.setRefreshFn(async () => ({
access_token: 'new-fresh-token',
refresh_token: 'new-refresh-xxx',
expires_in: 3600,
id_token: 'mock-id-token',
}))
const fresh = await service.ensureFreshAccessToken()
expect(fresh).toBe('new-fresh-token')
const loaded = await service.loadTokens()
expect(loaded?.accessToken).toBe('new-fresh-token')
})
test('preserves existing refresh token and id token when refresh omits them', async () => {
await service.saveTokens({
accessToken: 'expired',
refreshToken: 'refresh-to-preserve',
expiresAt: Date.now() + 60_000,
idToken: 'id-token-to-preserve',
email: 'test@example.com',
accountId: 'acct_123',
clientId: 'app_EMoamEEZ73f0CkXaXp7hrann',
})
service.setRefreshFn(async () => ({
access_token: 'new-access-token',
expires_in: 3600,
}))
const fresh = await service.ensureFreshAccessToken()
expect(fresh).toBe('new-access-token')
const loaded = await service.loadTokens()
expect(loaded?.refreshToken).toBe('refresh-to-preserve')
expect(loaded?.idToken).toBe('id-token-to-preserve')
expect(loaded?.clientId).toBe('app_EMoamEEZ73f0CkXaXp7hrann')
})
test('returns null when refresh fails', async () => {
await service.saveTokens({
accessToken: 'expired',
refreshToken: 'bad-refresh',
expiresAt: Date.now() + 60_000,
email: null,
accountId: null,
})
service.setRefreshFn(async () => {
throw new Error('401 Unauthorized')
})
expect(await service.ensureFreshAccessToken()).toBeNull()
})
})