Add Codex OAuth provider flow for ChatGPT account sign-in (#503)

* feat: add Codex OAuth provider flow

* fix: harden Codex OAuth storage, session activation, and UI
This commit is contained in:
Henrique Fernandes
2026-04-13 11:34:16 -03:00
committed by GitHub
parent 252808bbd0
commit fc7dc9ca0d
34 changed files with 5187 additions and 508 deletions

View File

@@ -0,0 +1,123 @@
import { PassThrough } from 'node:stream'
import { afterEach, expect, mock, test } from 'bun:test'
import React from 'react'
import { createRoot, Text } from '../ink.js'
type AuthState = {
anthropicAuthEnabled: boolean
claudeSubscriber: boolean
key?: string
source?: string
}
function createTestStreams(): {
stdout: PassThrough
stdin: PassThrough & {
isTTY: boolean
setRawMode: (mode: boolean) => void
ref: () => void
unref: () => void
}
} {
const stdout = new PassThrough()
const stdin = new PassThrough() as PassThrough & {
isTTY: boolean
setRawMode: (mode: boolean) => void
ref: () => void
unref: () => void
}
stdin.isTTY = true
stdin.setRawMode = () => {}
stdin.ref = () => {}
stdin.unref = () => {}
;(stdout as unknown as { columns: number }).columns = 120
return { stdout, stdin }
}
async function waitForCondition(
predicate: () => boolean,
timeoutMs = 2000,
): Promise<void> {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
if (predicate()) {
return
}
await Bun.sleep(10)
}
throw new Error('Timed out waiting for useApiKeyVerification test state')
}
afterEach(() => {
mock.restore()
})
test('useApiKeyVerification resets stale missing status when the session switches to a third-party provider', async () => {
const authState: AuthState = {
anthropicAuthEnabled: true,
claudeSubscriber: false,
}
const seenStatuses: string[] = []
mock.module('../utils/auth.js', () => ({
getAnthropicApiKeyWithSource: () => ({
key: authState.key,
source: authState.source,
}),
getApiKeyFromApiKeyHelper: async () => undefined,
isAnthropicAuthEnabled: () => authState.anthropicAuthEnabled,
isClaudeAISubscriber: () => authState.claudeSubscriber,
}))
mock.module('../bootstrap/state.js', () => ({
getIsNonInteractiveSession: () => false,
}))
mock.module('../services/api/claude.js', () => ({
verifyApiKey: async () => true,
}))
// @ts-expect-error cache-busting query string for Bun module mocks
const { useApiKeyVerification } = await import(
'./useApiKeyVerification.ts?switch-to-third-party'
)
function Harness(): React.ReactNode {
const { status } = useApiKeyVerification()
React.useEffect(() => {
seenStatuses.push(status)
}, [status])
return <Text>{status}</Text>
}
const { stdout, stdin } = createTestStreams()
const root = await createRoot({
stdout: stdout as unknown as NodeJS.WriteStream,
stdin: stdin as unknown as NodeJS.ReadStream,
patchConsole: false,
})
root.render(<Harness />)
await waitForCondition(() => seenStatuses.includes('missing'))
authState.anthropicAuthEnabled = false
root.render(<Harness />)
await waitForCondition(() => seenStatuses.includes('valid'))
root.unmount()
stdin.end()
stdout.end()
await Bun.sleep(0)
expect(seenStatuses[0]).toBe('missing')
expect(seenStatuses).toContain('valid')
})

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { getIsNonInteractiveSession } from '../bootstrap/state.js'
import { verifyApiKey } from '../services/api/claude.js'
import {
@@ -21,24 +21,43 @@ export type ApiKeyVerificationResult = {
error: Error | null
}
export function useApiKeyVerification(): ApiKeyVerificationResult {
const [status, setStatus] = useState<VerificationStatus>(() => {
if (!isAnthropicAuthEnabled() || isClaudeAISubscriber()) {
return 'valid'
}
// Use skipRetrievingKeyFromApiKeyHelper to avoid executing apiKeyHelper
// before trust dialog is shown (security: prevents RCE via settings.json)
const { key, source } = getAnthropicApiKeyWithSource({
skipRetrievingKeyFromApiKeyHelper: true,
})
// If apiKeyHelper is configured, we have a key source even though we
// haven't executed it yet - return 'loading' to indicate we'll verify later
if (key || source === 'apiKeyHelper') {
return 'loading'
}
return 'missing'
function getInitialVerificationStatus(): VerificationStatus {
if (!isAnthropicAuthEnabled() || isClaudeAISubscriber()) {
return 'valid'
}
// Use skipRetrievingKeyFromApiKeyHelper to avoid executing apiKeyHelper
// before trust dialog is shown (security: prevents RCE via settings.json)
const { key, source } = getAnthropicApiKeyWithSource({
skipRetrievingKeyFromApiKeyHelper: true,
})
// If apiKeyHelper is configured, we have a key source even though we
// haven't executed it yet - return 'loading' to indicate we'll verify later
if (key || source === 'apiKeyHelper') {
return 'loading'
}
return 'missing'
}
export function useApiKeyVerification(): ApiKeyVerificationResult {
const [status, setStatus] = useState<VerificationStatus>(
getInitialVerificationStatus,
)
const [error, setError] = useState<Error | null>(null)
const anthropicVerificationEnabled =
isAnthropicAuthEnabled() && !isClaudeAISubscriber()
useEffect(() => {
const nextStatus = anthropicVerificationEnabled
? getInitialVerificationStatus()
: 'valid'
setStatus(currentStatus =>
currentStatus === nextStatus ? currentStatus : nextStatus,
)
if (nextStatus !== 'error') {
setError(null)
}
}, [anthropicVerificationEnabled])
const verify = useCallback(async (): Promise<void> => {
if (!isAnthropicAuthEnabled() || isClaudeAISubscriber()) {