mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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:
parent
10d75852bb
commit
bc998eab3a
@ -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 () => {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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({
|
||||
|
||||
40
src/services/openaiAuth/client.test.ts
Normal file
40
src/services/openaiAuth/client.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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()}`
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user