diff --git a/src/server/__tests__/haha-openai-oauth-api.test.ts b/src/server/__tests__/haha-openai-oauth-api.test.ts index 3a605561..e7b19950 100644 --- a/src/server/__tests__/haha-openai-oauth-api.test.ts +++ b/src/server/__tests__/haha-openai-oauth-api.test.ts @@ -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 () => { diff --git a/src/server/__tests__/haha-openai-oauth-service.test.ts b/src/server/__tests__/haha-openai-oauth-service.test.ts index 8bd331b7..db5de249 100644 --- a/src/server/__tests__/haha-openai-oauth-service.test.ts +++ b/src/server/__tests__/haha-openai-oauth-service.test.ts @@ -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', () => { diff --git a/src/server/services/hahaOpenAIOAuthService.ts b/src/server/services/hahaOpenAIOAuthService.ts index 74738b9c..4f71b252 100644 --- a/src/server/services/hahaOpenAIOAuthService.ts +++ b/src/server/services/hahaOpenAIOAuthService.ts @@ -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({ diff --git a/src/services/openaiAuth/client.test.ts b/src/services/openaiAuth/client.test.ts new file mode 100644 index 00000000..2ea7bd61 --- /dev/null +++ b/src/services/openaiAuth/client.test.ts @@ -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) + }) +}) diff --git a/src/services/openaiAuth/client.ts b/src/services/openaiAuth/client.ts index e83a148c..459800e8 100644 --- a/src/services/openaiAuth/client.ts +++ b/src/services/openaiAuth/client.ts @@ -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()}` diff --git a/src/services/openaiAuth/index.ts b/src/services/openaiAuth/index.ts index 95be8ec5..8837f99f 100644 --- a/src/services/openaiAuth/index.ts +++ b/src/services/openaiAuth/index.ts @@ -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,