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:
committed by
GitHub
parent
252808bbd0
commit
fc7dc9ca0d
123
src/hooks/useApiKeyVerification.test.tsx
Normal file
123
src/hooks/useApiKeyVerification.test.tsx
Normal 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')
|
||||
})
|
||||
@@ -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()) {
|
||||
|
||||
Reference in New Issue
Block a user