Prevent OpenAI auth from rejecting Codex OAuth URLs

OpenAI rejects the authorize URL before callback when non-Codex query
parameters are sent. The ChatGPT OAuth flow now matches the sub2api
Codex-compatible shape by keeping originator out of the authorize URL
and using OpenAI-specific hex state and PKCE verifier values.

Constraint: The Codex OAuth client expects the standard authorize query shape used by sub2api.
Rejected: Keep originator=opencode in the OAuth URL | originator is a later ChatGPT API request header concern, not an OAuth authorize parameter.
Confidence: high
Scope-risk: narrow
Tested: bun test src/services/openaiAuth/client.test.ts 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: git diff --check
Tested: bun run check:server
Not-tested: Live browser OAuth authorization against a real OpenAI account.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-20 00:11:05 +08:00
parent 10d75852bb
commit bc998eab3a
6 changed files with 62 additions and 15 deletions

View File

@ -83,7 +83,8 @@ describe('POST /api/haha-openai-oauth/start', () => {
expect(data.authorizeUrl).toContain(
encodeURIComponent('http://localhost:54321/auth/callback'),
)
expect(data.state).toMatch(/^[A-Za-z0-9_-]+$/)
expect(data.authorizeUrl).not.toContain('originator=')
expect(data.state).toMatch(/^[a-f0-9]{64}$/)
})
test('400 if serverPort missing', async () => {

View File

@ -113,8 +113,8 @@ describe('HahaOpenAIOAuthService — session management', () => {
test('startSession creates session with PKCE + state', () => {
const session = service.startSession({ serverPort: 54321 })
expect(session.state).toMatch(/^[A-Za-z0-9_-]{43}$/)
expect(session.codeVerifier).toMatch(/^[A-Za-z0-9_-]{43}$/)
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)}`,
@ -125,6 +125,7 @@ describe('HahaOpenAIOAuthService — session management', () => {
expect(session.authorizeUrl).toContain(
encodeURIComponent('http://localhost:54321/auth/callback'),
)
expect(session.authorizeUrl).not.toContain('originator=')
})
test('getSession returns stored session by state', () => {

View File

@ -12,19 +12,15 @@
import * as fs from 'fs/promises'
import * as os from 'os'
import * as path from 'path'
import {
generateCodeVerifier,
generateCodeChallenge,
generateState,
} from '../../services/oauth/crypto.js'
import {
buildOpenAIAuthorizeUrl,
exchangeOpenAICodeForTokens,
generateOpenAICodeVerifier,
generateOpenAIState,
refreshOpenAITokens,
isOpenAITokenExpired,
normalizeOpenAITokens,
withRefreshedAccessToken,
OPENAI_CODEX_OAUTH_PORT,
OPENAI_CODEX_REDIRECT_PATH,
} from '../../services/openaiAuth/client.js'
import type { OpenAIOAuthTokenResponse } from '../../services/openaiAuth/types.js'
@ -108,8 +104,8 @@ export class HahaOpenAIOAuthService {
startSession({ serverPort }: { serverPort: number }): OpenAIOAuthSession {
this.pruneExpiredSessions()
const codeVerifier = generateCodeVerifier()
const state = generateState()
const codeVerifier = generateOpenAICodeVerifier()
const state = generateOpenAIState()
const redirectUri = `http://localhost:${serverPort}${OPENAI_CODEX_REDIRECT_PATH}`
const authorizeUrl = buildOpenAIAuthorizeUrl({

View File

@ -0,0 +1,40 @@
import { describe, expect, test } from 'bun:test'
import {
buildOpenAIAuthorizeUrl,
generateOpenAICodeVerifier,
generateOpenAIState,
OPENAI_CODEX_CLIENT_ID,
} from './client.js'
describe('OpenAI Codex OAuth client', () => {
test('generates hex PKCE verifier and state like the Codex-compatible flow', () => {
const verifier = generateOpenAICodeVerifier()
const state = generateOpenAIState()
expect(verifier).toMatch(/^[a-f0-9]{128}$/)
expect(state).toMatch(/^[a-f0-9]{64}$/)
})
test('builds authorize URL without non-Codex originator parameter', () => {
const authorizeUrl = buildOpenAIAuthorizeUrl({
redirectUri: 'http://localhost:1455/auth/callback',
codeVerifier: 'a'.repeat(128),
state: 'b'.repeat(64),
})
const parsed = new URL(authorizeUrl)
const params = parsed.searchParams
expect(parsed.origin + parsed.pathname).toBe(
'https://auth.openai.com/oauth/authorize',
)
expect(params.get('client_id')).toBe(OPENAI_CODEX_CLIENT_ID)
expect(params.get('redirect_uri')).toBe(
'http://localhost:1455/auth/callback',
)
expect(params.get('scope')).toBe('openid profile email offline_access')
expect(params.get('code_challenge_method')).toBe('S256')
expect(params.get('id_token_add_organizations')).toBe('true')
expect(params.get('codex_cli_simplified_flow')).toBe('true')
expect(params.has('originator')).toBe(false)
})
})

View File

@ -1,3 +1,4 @@
import { randomBytes } from 'crypto'
import { generateCodeChallenge } from '../oauth/crypto.js'
import type {
OpenAIJwtClaims,
@ -14,6 +15,14 @@ export const OPENAI_CODEX_REDIRECT_PATH = '/auth/callback'
const DEFAULT_TOKEN_LIFETIME_MS = 3600 * 1000
export function generateOpenAIState(): string {
return randomBytes(32).toString('hex')
}
export function generateOpenAICodeVerifier(): string {
return randomBytes(64).toString('hex')
}
export function buildOpenAIAuthorizeUrl(input: {
redirectUri: string
codeVerifier: string
@ -29,7 +38,6 @@ export function buildOpenAIAuthorizeUrl(input: {
id_token_add_organizations: 'true',
codex_cli_simplified_flow: 'true',
state: input.state,
originator: 'opencode',
})
return `${OPENAI_AUTH_ISSUER}/oauth/authorize?${params.toString()}`

View File

@ -1,9 +1,10 @@
import { openBrowser } from '../../utils/browser.js'
import { AuthCodeListener } from '../oauth/auth-code-listener.js'
import { generateCodeVerifier, generateState } from '../oauth/crypto.js'
import {
buildOpenAIAuthorizeUrl,
exchangeOpenAICodeForTokens,
generateOpenAICodeVerifier,
generateOpenAIState,
isOpenAITokenExpired,
OPENAI_CODEX_OAUTH_PORT,
OPENAI_CODEX_REDIRECT_PATH,
@ -59,7 +60,7 @@ export class OpenAIOAuthService {
null
constructor() {
this.codeVerifier = generateCodeVerifier()
this.codeVerifier = generateOpenAICodeVerifier()
}
async startOAuthFlow(
@ -71,7 +72,7 @@ export class OpenAIOAuthService {
this.authCodeListener = new AuthCodeListener(OPENAI_CODEX_REDIRECT_PATH)
this.port = await this.authCodeListener.start(OPENAI_CODEX_OAUTH_PORT)
const state = generateState()
const state = generateOpenAIState()
const redirectUri = `http://localhost:${this.port}${OPENAI_CODEX_REDIRECT_PATH}`
const authorizeUrl = buildOpenAIAuthorizeUrl({
redirectUri,