From bc4a2f1281c055c539e97109f6c9bb0783de5304 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sun, 26 Apr 2026 21:49:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20ChatGPT=20OAuth=20?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E7=99=BB=E5=BD=95=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于 OpenAI Codex CLI 官方实现,支持 PKCE 流程和手动 code 输入。 API key 交换为非致命步骤,兼容无 organization 的个人账户。 Co-Authored-By: Claude Opus 4.7 --- .../oauth/__tests__/openai-codex.test.ts | 238 +++++++++++ src/services/oauth/openai-codex.ts | 373 ++++++++++++++++++ 2 files changed, 611 insertions(+) create mode 100644 src/services/oauth/__tests__/openai-codex.test.ts create mode 100644 src/services/oauth/openai-codex.ts diff --git a/src/services/oauth/__tests__/openai-codex.test.ts b/src/services/oauth/__tests__/openai-codex.test.ts new file mode 100644 index 000000000..19b22b40b --- /dev/null +++ b/src/services/oauth/__tests__/openai-codex.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, test, mock, beforeEach, afterEach } from 'bun:test' +import { + _internal, + performOpenAICodexLogin, +} from '../openai-codex.js' + +describe('openai-codex OAuth', () => { + describe('constants', () => { + test('has correct OAuth endpoints', () => { + expect(_internal.CLIENT_ID).toBe('app_EMoamEEZ73f0CkXaXp7hrann') + expect(_internal.AUTHORIZE_URL).toBe('https://auth.openai.com/oauth/authorize') + expect(_internal.TOKEN_URL).toBe('https://auth.openai.com/oauth/token') + expect(_internal.REDIRECT_URI).toBe('http://localhost:1455/auth/callback') + expect(_internal.SCOPE).toBe('openid profile email offline_access api.connectors.read api.connectors.invoke') + }) + }) + + describe('buildAuthorizeUrl', () => { + test('builds correct authorize URL with all parameters', () => { + const url = _internal.buildAuthorizeUrl('test-challenge', 'test-state') + const parsed = new URL(url) + + expect(parsed.origin + parsed.pathname).toBe('https://auth.openai.com/oauth/authorize') + expect(parsed.searchParams.get('response_type')).toBe('code') + expect(parsed.searchParams.get('client_id')).toBe(_internal.CLIENT_ID) + expect(parsed.searchParams.get('redirect_uri')).toBe(_internal.REDIRECT_URI) + expect(parsed.searchParams.get('scope')).toBe(_internal.SCOPE) + expect(parsed.searchParams.get('code_challenge')).toBe('test-challenge') + expect(parsed.searchParams.get('code_challenge_method')).toBe('S256') + expect(parsed.searchParams.get('state')).toBe('test-state') + expect(parsed.searchParams.get('id_token_add_organizations')).toBe('true') + expect(parsed.searchParams.get('codex_cli_simplified_flow')).toBe('true') + expect(parsed.searchParams.get('originator')).toBe('claude-code') + }) + + test('uses custom redirect URI when provided', () => { + const url = _internal.buildAuthorizeUrl('challenge', 'state', 'http://localhost:9999/custom') + const parsed = new URL(url) + expect(parsed.searchParams.get('redirect_uri')).toBe('http://localhost:9999/custom') + }) + }) + + describe('decodeJwt', () => { + test('decodes valid JWT payload', () => { + // Create a minimal JWT: header.payload.signature + const payload = Buffer.from( + JSON.stringify({ + 'https://api.openai.com/auth': { chatgpt_account_id: 'acc_12345' }, + sub: 'user_123', + }), + ).toString('base64url') + const token = `eyJhbGciOiJSUzI1NiJ9.${payload}.signature` + + const result = _internal.decodeJwt(token) + expect(result).not.toBeNull() + expect(result?.['https://api.openai.com/auth']?.chatgpt_account_id).toBe('acc_12345') + }) + + test('returns null for invalid JWT', () => { + expect(_internal.decodeJwt('not-a-jwt')).toBeNull() + expect(_internal.decodeJwt('a.b')).toBeNull() + expect(_internal.decodeJwt('')).toBeNull() + }) + }) + + describe('getAccountId', () => { + test('extracts account ID from valid token', () => { + const payload = Buffer.from( + JSON.stringify({ + 'https://api.openai.com/auth': { chatgpt_account_id: 'acc_test123' }, + }), + ).toString('base64url') + const token = `header.${payload}.sig` + + expect(_internal.getAccountId(token)).toBe('acc_test123') + }) + + test('returns null when account ID is missing', () => { + const payload = Buffer.from(JSON.stringify({ sub: 'user_123' })).toString('base64url') + const token = `header.${payload}.sig` + + expect(_internal.getAccountId(token)).toBeNull() + }) + + test('returns null for empty account ID', () => { + const payload = Buffer.from( + JSON.stringify({ + 'https://api.openai.com/auth': { chatgpt_account_id: '' }, + }), + ).toString('base64url') + const token = `header.${payload}.sig` + + expect(_internal.getAccountId(token)).toBeNull() + }) + + test('returns null for invalid token', () => { + expect(_internal.getAccountId('invalid')).toBeNull() + }) + }) + + describe('exchangeCodeForTokens', () => { + const originalFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test('exchanges code for tokens successfully', async () => { + globalThis.fetch = mock(() => + Promise.resolve( + new Response( + JSON.stringify({ + id_token: 'id_token_value', + access_token: 'access_value', + refresh_token: 'refresh_value', + expires_in: 3600, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ), + ) as any + + const result = await _internal.exchangeCodeForTokens('auth_code', 'verifier') + expect(result.access_token).toBe('access_value') + expect(result.refresh_token).toBe('refresh_value') + expect(result.id_token).toBe('id_token_value') + }) + + test('throws on non-200 response', async () => { + globalThis.fetch = mock(() => + Promise.resolve( + new Response('Unauthorized', { status: 401 }), + ), + ) as any + + await expect( + _internal.exchangeCodeForTokens('bad_code', 'verifier'), + ).rejects.toThrow('Token exchange failed (401)') + }) + + test('throws when response missing fields', async () => { + globalThis.fetch = mock(() => + Promise.resolve( + new Response(JSON.stringify({ access_token: 'only_access' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) as any + + await expect( + _internal.exchangeCodeForTokens('code', 'verifier'), + ).rejects.toThrow('missing required fields') + }) + + test('sends correct request body', async () => { + let capturedBody: string | null = null + globalThis.fetch = mock((url: string, opts: any) => { + capturedBody = opts.body + return Promise.resolve( + new Response( + JSON.stringify({ + id_token: 'id', + access_token: 'acc', + refresh_token: 'ref', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + }) as any + + await _internal.exchangeCodeForTokens('test_code', 'test_verifier', 'http://localhost:1455/auth/callback') + + const params = new URLSearchParams(capturedBody!) + expect(params.get('grant_type')).toBe('authorization_code') + expect(params.get('client_id')).toBe(_internal.CLIENT_ID) + expect(params.get('code')).toBe('test_code') + expect(params.get('code_verifier')).toBe('test_verifier') + expect(params.get('redirect_uri')).toBe('http://localhost:1455/auth/callback') + }) + }) + + describe('obtainApiKey', () => { + const originalFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test('exchanges id_token for API key', async () => { + globalThis.fetch = mock(() => + Promise.resolve( + new Response( + JSON.stringify({ access_token: 'sk-api-key-12345' }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ), + ) as any + + const apiKey = await _internal.obtainApiKey('id_token_value') + expect(apiKey).toBe('sk-api-key-12345') + }) + + test('throws on non-200 response', async () => { + globalThis.fetch = mock(() => + Promise.resolve( + new Response('Forbidden', { status: 403 }), + ), + ) as any + + await expect( + _internal.obtainApiKey('bad_token'), + ).rejects.toThrow('API key exchange failed (403)') + }) + + test('sends correct token exchange parameters', async () => { + let capturedBody: string | null = null + globalThis.fetch = mock((url: string, opts: any) => { + capturedBody = opts.body + return Promise.resolve( + new Response( + JSON.stringify({ access_token: 'key' }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + }) as any + + await _internal.obtainApiKey('test_id_token') + + const params = new URLSearchParams(capturedBody!) + expect(params.get('grant_type')).toBe('urn:ietf:params:oauth:grant-type:token-exchange') + expect(params.get('client_id')).toBe(_internal.CLIENT_ID) + expect(params.get('requested_token')).toBe('openai-api-key') + expect(params.get('subject_token')).toBe('test_id_token') + expect(params.get('subject_token_type')).toBe('urn:ietf:params:oauth:token-type:id_token') + }) + }) +}) diff --git a/src/services/oauth/openai-codex.ts b/src/services/oauth/openai-codex.ts new file mode 100644 index 000000000..39af8e8c4 --- /dev/null +++ b/src/services/oauth/openai-codex.ts @@ -0,0 +1,373 @@ +/** + * OpenAI Codex (ChatGPT) OAuth flow + * + * Implements the browser-based OAuth login for ChatGPT subscription access. + * Based on the official OpenAI Codex CLI implementation (codex-rs/login/src/server.rs). + * + * Flow: + * 1. Generate PKCE codes + state + * 2. Start local HTTP server on port 1455 + * 3. Open browser to OpenAI authorize URL + * 4. Handle callback → exchange code for tokens + * 5. Token exchange: id_token → API key + */ + +import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'http' +import { generateCodeVerifier, generateCodeChallenge, generateState } from './crypto.js' +import { openBrowser } from '../../utils/browser.js' + +// ─── Constants ─────────────────────────────────────────────────────────────── + +const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann' +const AUTHORIZE_URL = 'https://auth.openai.com/oauth/authorize' +const TOKEN_URL = 'https://auth.openai.com/oauth/token' +const DEFAULT_PORT = 1455 +const CALLBACK_PATH = '/auth/callback' +const REDIRECT_URI = `http://localhost:${DEFAULT_PORT}${CALLBACK_PATH}` +const SCOPE = 'openid profile email offline_access api.connectors.read api.connectors.invoke' +const JWT_CLAIM_PATH = 'https://api.openai.com/auth' + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export type CodexOAuthResult = { + apiKey: string | null + accessToken: string + refreshToken: string + accountId: string +} + +type TokenResponse = { + id_token: string + access_token: string + refresh_token: string + expires_in?: number +} + +type ExchangeResponse = { + access_token: string +} + +type JwtPayload = { + [JWT_CLAIM_PATH]?: { + chatgpt_account_id?: string + } + [key: string]: unknown +} + +// ─── JWT helpers ───────────────────────────────────────────────────────────── + +function decodeJwt(token: string): JwtPayload | null { + try { + const parts = token.split('.') + if (parts.length !== 3) return null + const payload = parts[1] ?? '' + const decoded = Buffer.from(payload, 'base64url').toString('utf-8') + return JSON.parse(decoded) as JwtPayload + } catch { + return null + } +} + +function getAccountId(token: string): string | null { + const payload = decodeJwt(token) + const accountId = payload?.[JWT_CLAIM_PATH]?.chatgpt_account_id + return typeof accountId === 'string' && accountId.length > 0 ? accountId : null +} + +// ─── URL building ──────────────────────────────────────────────────────────── + +function buildAuthorizeUrl( + codeChallenge: string, + state: string, + redirectUri: string = REDIRECT_URI, +): string { + const url = new URL(AUTHORIZE_URL) + url.searchParams.set('response_type', 'code') + url.searchParams.set('client_id', CLIENT_ID) + url.searchParams.set('redirect_uri', redirectUri) + url.searchParams.set('scope', SCOPE) + url.searchParams.set('code_challenge', codeChallenge) + url.searchParams.set('code_challenge_method', 'S256') + url.searchParams.set('state', state) + url.searchParams.set('id_token_add_organizations', 'true') + url.searchParams.set('codex_cli_simplified_flow', 'true') + url.searchParams.set('originator', 'claude-code') + return url.toString() +} + +// ─── Token exchange ────────────────────────────────────────────────────────── + +async function exchangeCodeForTokens( + code: string, + codeVerifier: string, + redirectUri: string = REDIRECT_URI, +): Promise { + const response = await fetch(TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: CLIENT_ID, + code, + code_verifier: codeVerifier, + redirect_uri: redirectUri, + }), + }) + + if (!response.ok) { + const text = await response.text().catch(() => '') + throw new Error(`Token exchange failed (${response.status}): ${text}`) + } + + const json = (await response.json()) as TokenResponse + if (!json.access_token || !json.refresh_token) { + throw new Error('Token response missing required fields') + } + return json +} + +async function obtainApiKey(idToken: string): Promise { + const response = await fetch(TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + client_id: CLIENT_ID, + requested_token: 'openai-api-key', + subject_token: idToken, + subject_token_type: 'urn:ietf:params:oauth:token-type:id_token', + }), + }) + + if (!response.ok) { + const text = await response.text().catch(() => '') + throw new Error(`API key exchange failed (${response.status}): ${text}`) + } + + const json = (await response.json()) as ExchangeResponse + if (!json.access_token) { + throw new Error('API key exchange response missing access_token') + } + return json.access_token +} + +// ─── HTML responses ────────────────────────────────────────────────────────── + +const SUCCESS_HTML = ` +Login Successful + +

Authentication Complete

You can close this window.

` + +const ERROR_HTML = (msg: string) => ` +Login Error + +

Authentication Failed

${msg}

` + +// ─── Local callback server ────────────────────────────────────────────────── + +function startCallbackServer( + state: string, + port: number, +): Promise<{ + waitForCode: () => Promise + close: () => void +}> { + let settlePromise: ((code: string) => void) | ((error: Error) => void) | null = null + + const codePromise = new Promise((resolve, reject) => { + settlePromise = resolve + // Also store reject for error cases + ;(settlePromise as any).__reject = reject + }) + + const server: Server = createServer((req: IncomingMessage, res: ServerResponse) => { + try { + const url = new URL(req.url || '', `http://localhost:${port}`) + + if (url.pathname !== CALLBACK_PATH) { + res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' }) + res.end(ERROR_HTML('Not found')) + return + } + + // Check for OAuth error + const error = url.searchParams.get('error') + if (error) { + const desc = url.searchParams.get('error_description') ?? error + res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }) + res.end(ERROR_HTML(desc)) + ;((settlePromise as any).__reject as (e: Error) => void)?.(new Error(`OAuth error: ${desc}`)) + return + } + + if (url.searchParams.get('state') !== state) { + res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }) + res.end(ERROR_HTML('State mismatch')) + ;((settlePromise as any).__reject as (e: Error) => void)?.(new Error('State mismatch')) + return + } + + const code = url.searchParams.get('code') + if (!code) { + res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }) + res.end(ERROR_HTML('Missing authorization code')) + ;((settlePromise as any).__reject as (e: Error) => void)?.(new Error('Missing authorization code')) + return + } + + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) + res.end(SUCCESS_HTML) + ;(settlePromise as (code: string) => void)?.(code) + } catch { + res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' }) + res.end(ERROR_HTML('Internal error')) + } + }) + + return new Promise((resolve, reject) => { + server.listen(port, '127.0.0.1', () => { + resolve({ + waitForCode: () => codePromise, + close: () => { + server.close() + server.removeAllListeners() + }, + }) + }) + server.on('error', (err: Error & { code?: string }) => { + reject(new Error(`Failed to start callback server on port ${port}: ${err.message}`)) + }) + }) +} + +// ─── Manual code parsing ──────────────────────────────────────────────────── + +/** + * Parse manual user input to extract an authorization code. + * Accepts: + * - A full redirect URL: http://localhost:1455/auth/callback?code=XXX&state=YYY + * - A raw authorization code: XXX + * - code#state format: XXX#YYY + */ +export function parseManualCodeInput(input: string): string | null { + const value = input.trim() + if (!value) return null + + // Try as URL + try { + const url = new URL(value) + const code = url.searchParams.get('code') + return code ?? null + } catch { + // Not a URL, continue + } + + // Try code#state format — return just the code part + if (value.includes('#')) { + const [code] = value.split('#', 2) + return code ?? null + } + + // Return as raw code + return value +} + +// ─── Public API ────────────────────────────────────────────────────────────── + +export type CodexLoginOptions = { + /** Called with the authorize URL when the flow starts */ + onUrl: (url: string) => void + /** Optional: provide a manual authorization code (headless fallback) */ + manualCode?: Promise +} + +/** + * Perform the complete OpenAI Codex OAuth login flow. + * + * 1. Starts local callback server on port 1455 + * 2. Opens browser to OpenAI authorize URL + * 3. Exchanges authorization code for tokens + * 4. Performs token exchange to obtain an API key + * 5. Returns the API key and token information + */ +export async function performOpenAICodexLogin( + options: CodexLoginOptions, +): Promise { + const { onUrl, manualCode } = options + + // Step 1: Generate PKCE + state + const codeVerifier = generateCodeVerifier() + const codeChallenge = generateCodeChallenge(codeVerifier) + const state = generateState() + + // Step 2: Build authorize URL + const authUrl = buildAuthorizeUrl(codeChallenge, state) + onUrl(authUrl) + + // Step 3: Start callback server + const server = await startCallbackServer(state, DEFAULT_PORT) + + try { + // Step 4: Open browser + await openBrowser(authUrl) + + // Step 5: Wait for code (from callback or manual input) + let code: string + + if (manualCode) { + // Race between browser callback and manual input + const result = await Promise.race([ + server.waitForCode().then(c => ({ source: 'callback' as const, code: c })), + manualCode.then(c => ({ source: 'manual' as const, code: c })), + ]) + code = result.code + } else { + code = await server.waitForCode() + } + + // Step 6: Exchange code for tokens + const tokens = await exchangeCodeForTokens(code, codeVerifier) + + // Step 7: Extract account ID + const accountId = getAccountId(tokens.id_token) + if (!accountId) { + throw new Error('Failed to extract ChatGPT account ID from token') + } + + // Step 8: Exchange id_token for API key (non-fatal: some accounts lack org, returning null) + let apiKey: string | null = null + try { + apiKey = await obtainApiKey(tokens.id_token) + } catch { + // API key exchange may fail if the ID token lacks organization_id. + // This is expected for some account types — login still succeeds. + } + + return { + apiKey, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + accountId, + } + } finally { + server.close() + } +} + +// Export helpers for testing +export const _internal = { + CLIENT_ID, + AUTHORIZE_URL, + TOKEN_URL, + REDIRECT_URI, + SCOPE, + buildAuthorizeUrl, + decodeJwt, + getAccountId, + exchangeCodeForTokens, + obtainApiKey, +}