mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
feat: 添加 ChatGPT OAuth 订阅登录流程
基于 OpenAI Codex CLI 官方实现,支持 PKCE 流程和手动 code 输入。 API key 交换为非致命步骤,兼容无 organization 的个人账户。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
238
src/services/oauth/__tests__/openai-codex.test.ts
Normal file
238
src/services/oauth/__tests__/openai-codex.test.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
373
src/services/oauth/openai-codex.ts
Normal file
373
src/services/oauth/openai-codex.ts
Normal file
@@ -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<TokenResponse> {
|
||||
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<string> {
|
||||
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 = `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>Login Successful</title>
|
||||
<style>body{font-family:system-ui,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e;color:#eee}
|
||||
.card{text-align:center;padding:2rem;border-radius:12px;background:#16213e;box-shadow:0 4px 24px rgba(0,0,0,.3)}
|
||||
h1{color:#4ade80;font-size:1.5rem}p{color:#94a3b8;margin-top:.5rem}</style></head>
|
||||
<body><div class="card"><h1>Authentication Complete</h1><p>You can close this window.</p></div></body></html>`
|
||||
|
||||
const ERROR_HTML = (msg: string) => `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>Login Error</title>
|
||||
<style>body{font-family:system-ui,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e;color:#eee}
|
||||
.card{text-align:center;padding:2rem;border-radius:12px;background:#16213e;box-shadow:0 4px 24px rgba(0,0,0,.3)}
|
||||
h1{color:#f87171;font-size:1.5rem}p{color:#94a3b8;margin-top:.5rem}</style></head>
|
||||
<body><div class="card"><h1>Authentication Failed</h1><p>${msg}</p></div></body></html>`
|
||||
|
||||
// ─── Local callback server ──────────────────────────────────────────────────
|
||||
|
||||
function startCallbackServer(
|
||||
state: string,
|
||||
port: number,
|
||||
): Promise<{
|
||||
waitForCode: () => Promise<string>
|
||||
close: () => void
|
||||
}> {
|
||||
let settlePromise: ((code: string) => void) | ((error: Error) => void) | null = null
|
||||
|
||||
const codePromise = new Promise<string>((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<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<CodexOAuthResult> {
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user