mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-25 14:53:32 +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(
|
expect(data.authorizeUrl).toContain(
|
||||||
encodeURIComponent('http://localhost:54321/auth/callback'),
|
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 () => {
|
test('400 if serverPort missing', async () => {
|
||||||
|
|||||||
@ -113,8 +113,8 @@ describe('HahaOpenAIOAuthService — session management', () => {
|
|||||||
|
|
||||||
test('startSession creates session with PKCE + state', () => {
|
test('startSession creates session with PKCE + state', () => {
|
||||||
const session = service.startSession({ serverPort: 54321 })
|
const session = service.startSession({ serverPort: 54321 })
|
||||||
expect(session.state).toMatch(/^[A-Za-z0-9_-]{43}$/)
|
expect(session.state).toMatch(/^[a-f0-9]{64}$/)
|
||||||
expect(session.codeVerifier).toMatch(/^[A-Za-z0-9_-]{43}$/)
|
expect(session.codeVerifier).toMatch(/^[a-f0-9]{128}$/)
|
||||||
expect(session.authorizeUrl).toContain('code_challenge_method=S256')
|
expect(session.authorizeUrl).toContain('code_challenge_method=S256')
|
||||||
expect(session.authorizeUrl).toContain(
|
expect(session.authorizeUrl).toContain(
|
||||||
`state=${encodeURIComponent(session.state)}`,
|
`state=${encodeURIComponent(session.state)}`,
|
||||||
@ -125,6 +125,7 @@ describe('HahaOpenAIOAuthService — session management', () => {
|
|||||||
expect(session.authorizeUrl).toContain(
|
expect(session.authorizeUrl).toContain(
|
||||||
encodeURIComponent('http://localhost:54321/auth/callback'),
|
encodeURIComponent('http://localhost:54321/auth/callback'),
|
||||||
)
|
)
|
||||||
|
expect(session.authorizeUrl).not.toContain('originator=')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('getSession returns stored session by state', () => {
|
test('getSession returns stored session by state', () => {
|
||||||
|
|||||||
@ -12,19 +12,15 @@
|
|||||||
import * as fs from 'fs/promises'
|
import * as fs from 'fs/promises'
|
||||||
import * as os from 'os'
|
import * as os from 'os'
|
||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
import {
|
|
||||||
generateCodeVerifier,
|
|
||||||
generateCodeChallenge,
|
|
||||||
generateState,
|
|
||||||
} from '../../services/oauth/crypto.js'
|
|
||||||
import {
|
import {
|
||||||
buildOpenAIAuthorizeUrl,
|
buildOpenAIAuthorizeUrl,
|
||||||
exchangeOpenAICodeForTokens,
|
exchangeOpenAICodeForTokens,
|
||||||
|
generateOpenAICodeVerifier,
|
||||||
|
generateOpenAIState,
|
||||||
refreshOpenAITokens,
|
refreshOpenAITokens,
|
||||||
isOpenAITokenExpired,
|
isOpenAITokenExpired,
|
||||||
normalizeOpenAITokens,
|
normalizeOpenAITokens,
|
||||||
withRefreshedAccessToken,
|
withRefreshedAccessToken,
|
||||||
OPENAI_CODEX_OAUTH_PORT,
|
|
||||||
OPENAI_CODEX_REDIRECT_PATH,
|
OPENAI_CODEX_REDIRECT_PATH,
|
||||||
} from '../../services/openaiAuth/client.js'
|
} from '../../services/openaiAuth/client.js'
|
||||||
import type { OpenAIOAuthTokenResponse } from '../../services/openaiAuth/types.js'
|
import type { OpenAIOAuthTokenResponse } from '../../services/openaiAuth/types.js'
|
||||||
@ -108,8 +104,8 @@ export class HahaOpenAIOAuthService {
|
|||||||
startSession({ serverPort }: { serverPort: number }): OpenAIOAuthSession {
|
startSession({ serverPort }: { serverPort: number }): OpenAIOAuthSession {
|
||||||
this.pruneExpiredSessions()
|
this.pruneExpiredSessions()
|
||||||
|
|
||||||
const codeVerifier = generateCodeVerifier()
|
const codeVerifier = generateOpenAICodeVerifier()
|
||||||
const state = generateState()
|
const state = generateOpenAIState()
|
||||||
|
|
||||||
const redirectUri = `http://localhost:${serverPort}${OPENAI_CODEX_REDIRECT_PATH}`
|
const redirectUri = `http://localhost:${serverPort}${OPENAI_CODEX_REDIRECT_PATH}`
|
||||||
const authorizeUrl = buildOpenAIAuthorizeUrl({
|
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 { generateCodeChallenge } from '../oauth/crypto.js'
|
||||||
import type {
|
import type {
|
||||||
OpenAIJwtClaims,
|
OpenAIJwtClaims,
|
||||||
@ -14,6 +15,14 @@ export const OPENAI_CODEX_REDIRECT_PATH = '/auth/callback'
|
|||||||
|
|
||||||
const DEFAULT_TOKEN_LIFETIME_MS = 3600 * 1000
|
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: {
|
export function buildOpenAIAuthorizeUrl(input: {
|
||||||
redirectUri: string
|
redirectUri: string
|
||||||
codeVerifier: string
|
codeVerifier: string
|
||||||
@ -29,7 +38,6 @@ export function buildOpenAIAuthorizeUrl(input: {
|
|||||||
id_token_add_organizations: 'true',
|
id_token_add_organizations: 'true',
|
||||||
codex_cli_simplified_flow: 'true',
|
codex_cli_simplified_flow: 'true',
|
||||||
state: input.state,
|
state: input.state,
|
||||||
originator: 'opencode',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return `${OPENAI_AUTH_ISSUER}/oauth/authorize?${params.toString()}`
|
return `${OPENAI_AUTH_ISSUER}/oauth/authorize?${params.toString()}`
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { openBrowser } from '../../utils/browser.js'
|
import { openBrowser } from '../../utils/browser.js'
|
||||||
import { AuthCodeListener } from '../oauth/auth-code-listener.js'
|
import { AuthCodeListener } from '../oauth/auth-code-listener.js'
|
||||||
import { generateCodeVerifier, generateState } from '../oauth/crypto.js'
|
|
||||||
import {
|
import {
|
||||||
buildOpenAIAuthorizeUrl,
|
buildOpenAIAuthorizeUrl,
|
||||||
exchangeOpenAICodeForTokens,
|
exchangeOpenAICodeForTokens,
|
||||||
|
generateOpenAICodeVerifier,
|
||||||
|
generateOpenAIState,
|
||||||
isOpenAITokenExpired,
|
isOpenAITokenExpired,
|
||||||
OPENAI_CODEX_OAUTH_PORT,
|
OPENAI_CODEX_OAUTH_PORT,
|
||||||
OPENAI_CODEX_REDIRECT_PATH,
|
OPENAI_CODEX_REDIRECT_PATH,
|
||||||
@ -59,7 +60,7 @@ export class OpenAIOAuthService {
|
|||||||
null
|
null
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.codeVerifier = generateCodeVerifier()
|
this.codeVerifier = generateOpenAICodeVerifier()
|
||||||
}
|
}
|
||||||
|
|
||||||
async startOAuthFlow(
|
async startOAuthFlow(
|
||||||
@ -71,7 +72,7 @@ export class OpenAIOAuthService {
|
|||||||
this.authCodeListener = new AuthCodeListener(OPENAI_CODEX_REDIRECT_PATH)
|
this.authCodeListener = new AuthCodeListener(OPENAI_CODEX_REDIRECT_PATH)
|
||||||
this.port = await this.authCodeListener.start(OPENAI_CODEX_OAUTH_PORT)
|
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 redirectUri = `http://localhost:${this.port}${OPENAI_CODEX_REDIRECT_PATH}`
|
||||||
const authorizeUrl = buildOpenAIAuthorizeUrl({
|
const authorizeUrl = buildOpenAIAuthorizeUrl({
|
||||||
redirectUri,
|
redirectUri,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user