diff --git a/src/commands/provider/provider.tsx b/src/commands/provider/provider.tsx index 3aa9ac646..db53f5335 100644 --- a/src/commands/provider/provider.tsx +++ b/src/commands/provider/provider.tsx @@ -13,6 +13,7 @@ import { } from '../../components/CustomSelect/index.js' import { Dialog } from '../../components/design-system/Dialog.js' import { LoadingState } from '../../components/design-system/LoadingState.js' +import { useCodexDeviceCodeFlow } from '../../components/useCodexDeviceCodeFlow.js' import { useCodexOAuthFlow } from '../../components/useCodexOAuthFlow.js' import { useTerminalSize } from '../../hooks/useTerminalSize.js' import { Box, Text } from '../../ink.js' @@ -135,7 +136,7 @@ function describeOllamaReadinessIssue( return '' } -type ProviderChoice = 'auto' | ProviderProfile | 'codex-oauth' | 'clear' +type ProviderChoice = 'auto' | ProviderProfile | 'codex-oauth' | 'codex-device-code' | 'clear' type Step = | { name: 'choose' } @@ -167,6 +168,7 @@ type Step = authMode: 'api-key' | 'access-token' | 'adc' } | { name: 'codex-oauth' } + | { name: 'codex-device-code' } | { name: 'codex-check' } type CurrentProviderSummary = { @@ -713,6 +715,12 @@ function ProviderChooser({ description: 'Sign in with ChatGPT in your browser and store Codex tokens securely', }, + { + label: 'Codex Device Code', + value: 'codex-device-code' as const, + description: + 'Sign in with a device code and store Codex tokens securely', + }, ] : []), ] @@ -1098,6 +1106,84 @@ function OllamaModelStep({ ) } + +function CodexDeviceCodeStep({ + onSave, + onBack, + onCancel, +}: { + onSave: (profile: ProviderProfile, env: ProfileEnv) => void + onBack: () => void + onCancel: () => void +}): React.ReactNode { + const handleAuthenticated = React.useCallback(async ( + tokens: CodexOAuthTokens, + persistCredentials: (options?: { profileId?: string }) => void, + ) => { + const env = buildCodexOAuthProfileEnv(tokens) + if (!env) { + throw new Error( + 'Codex device-code sign-in succeeded, but OpenClaude could not build a Codex profile from the stored credentials.', + ) + } + + persistCredentials() + onSave('codex', env) + }, [onSave]) + + const status = useCodexDeviceCodeFlow({ + onAuthenticated: handleAuthenticated, + }) + + if (status.state === 'error') { + return ( + + + {status.message} + + + ) + } + + return ( + + + Codex Device Code + + + Sign in with the device code shown below. OpenClaude will poll until the + login completes and then store the resulting Codex credentials securely. + + {status.state === 'starting' ? ( + Requesting your device code... + ) : ( + <> + + Visit {status.verificationUriComplete ?? status.verificationUri} and enter code{' '} + {status.userCode}. + + + Expires in {Math.max(1, Math.ceil(status.expiresIn / 60))} minutes. + + {status.browserOpened === false ? ( + Could not open the verification URL automatically. + ) : status.browserOpened === true ? ( + Verification page opened in your browser. + ) : ( + Opening the verification page... + )} + {status.verificationUriComplete ?? status.verificationUri} + + )} + Press Esc to cancel and go back. + + ) +} + + export function ProviderManager({ mode, onDone }: Props): React.ReactNode { const setAppState = useSetAppState() const initialGithubCredentialSource = getGithubCredentialSourceFromEnv() @@ -1456,17 +1542,28 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { }) if (canUseCodexOAuth) { - options.splice(6, 0, { - value: 'codex-oauth', - label: ( - - Codex OAuth - ★ Recommended - - ), - description: - 'Sign in with ChatGPT in your browser and store Codex credentials securely', - }) + const codexInsertIndex = options.findIndex(option => option.value === 'gemini') + options.splice( + codexInsertIndex === -1 ? 6 : codexInsertIndex, + 0, + { + value: 'codex-oauth', + label: ( + + Codex OAuth + ★ Recommended + + ), + description: + 'Sign in with ChatGPT in your browser and store Codex credentials securely', + }, + { + value: 'codex-device-code', + label: 'Codex Device Code', + description: + 'Sign in with a device code and store Codex credentials securely', + }, + ) } if (mode === 'first-run') { @@ -1496,6 +1593,10 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { setScreen('codex-oauth') return } + if (value === 'codex-device-code') { + setScreen('codex-device-code') + return + } startCreateFromPreset(value as ProviderPreset) }} onCancel={() => { @@ -1783,6 +1884,73 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { case 'select-atomic-chat-model': content = renderAtomicChatSelection() break + case 'codex-device-code': + content = ( + setScreen('select-preset')} + onConfigured={async (tokens, persistCredentials) => { + const payload: ProviderProfileInput = { + provider: 'openai', + name: CODEX_DEVICE_CODE_PROVIDER_NAME, + baseUrl: DEFAULT_CODEX_BASE_URL, + model: CODEX_DEVICE_CODE_PROVIDER_MODEL, + apiKey: '', + } + + const saved = addProviderProfile(payload, { makeActive: false }) + if (!saved) { + setErrorMessage( + 'Codex device-code login finished, but the provider profile could not be saved.', + ) + returnToMenu() + return + } + + const active = setActiveProviderProfile(saved.id) + if (!active) { + setErrorMessage( + 'Codex device-code login finished, but the provider could not be set as the startup provider.', + ) + returnToMenu() + return + } + + persistCredentials({ profileId: saved.id }) + const settingsOverrideError = + clearStartupProviderOverrideFromUserSettings() + const activationWarning = await activateCodexOAuthSession(tokens) + setHasStoredCodexOAuthCredentials(true) + setStoredCodexOAuthProfileId(saved.id) + refreshProfiles() + const warnings = [ + activationWarning, + settingsOverrideError + ? `could not clear startup provider override (${settingsOverrideError})` + : null, + ].filter((warning): warning is string => Boolean(warning)) + const message = buildCodexOAuthActivationMessage({ + prefix: 'Codex Device Code configured', + activationWarning, + warnings, + }) + + if (mode === 'first-run') { + onDone({ + action: 'saved', + activeProfileId: active.id, + message, + }) + return + } + + setStatusMessage(message) + setErrorMessage(undefined) + returnToMenu() + }} + /> + ) + break + case 'codex-oauth': content = ( void + ref: () => void + unref: () => void + } + getOutput: () => string +} { + let output = '' + 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 + stdout.on('data', chunk => { + output += chunk.toString() + }) + + return { + stdout, + stdin, + getOutput: () => output, + } +} + +async function waitForCondition( + predicate: () => boolean, + options?: { timeoutMs?: number; intervalMs?: number }, +): Promise { + const timeoutMs = options?.timeoutMs ?? 5000 + const intervalMs = options?.intervalMs ?? 10 + const startedAt = Date.now() + + while (Date.now() - startedAt < timeoutMs) { + if (predicate()) { + return + } + await Bun.sleep(intervalMs) + } + + throw new Error('Timed out waiting for useCodexDeviceCodeFlow test condition') +} + +afterEach(() => { + mock.restore() +}) + +test('reports bare-mode error without starting device flow', async () => { + const requestCodexDeviceCode = mock(async () => DEVICE_CODE) + const pollCodexDeviceToken = mock(async () => TOKENS) + const openVerificationUri = mock(async () => true) + const saveCodexCredentials = mock(() => ({ success: true })) + const onAuthenticated = mock(async () => {}) + const deps = { + requestCodexDeviceCode, + pollCodexDeviceToken, + openVerificationUri, + saveCodexCredentials, + isBareMode: () => true, + } + + const { useCodexDeviceCodeFlow } = await import( + `./useCodexDeviceCodeFlow.js?bare-${Date.now()}-${Math.random()}` + ) + + function Harness(): React.ReactNode { + const handleAuthenticated = React.useCallback(onAuthenticated, [onAuthenticated]) + const status = useCodexDeviceCodeFlow({ + onAuthenticated: handleAuthenticated, + deps, + }) + + return {status.state === 'error' ? status.message : status.state} + } + + const streams = createTestStreams() + const root = await createRoot({ + stdout: streams.stdout as unknown as NodeJS.WriteStream, + stdin: streams.stdin as unknown as NodeJS.ReadStream, + patchConsole: false, + }) + root.render() + + try { + await waitForCondition(() => streams.getOutput().includes('unavailable in --bare')) + expect(requestCodexDeviceCode).not.toHaveBeenCalled() + expect(openVerificationUri).not.toHaveBeenCalled() + expect(pollCodexDeviceToken).not.toHaveBeenCalled() + expect(saveCodexCredentials).not.toHaveBeenCalled() + expect(onAuthenticated).not.toHaveBeenCalled() + } finally { + root.unmount() + streams.stdin.end() + streams.stdout.end() + await Bun.sleep(0) + } +}) + +test('opens complete verification uri and polls with device code timing', async () => { + const requestCodexDeviceCode = mock(async () => DEVICE_CODE) + const pollCodexDeviceToken = mock(async () => TOKENS) + const openVerificationUri = mock(async () => true) + const saveCodexCredentials = mock(() => ({ success: true })) + const onAuthenticated = mock(async () => {}) + const deps = { + requestCodexDeviceCode, + pollCodexDeviceToken, + openVerificationUri, + saveCodexCredentials, + isBareMode: () => false, + } + + const { useCodexDeviceCodeFlow } = await import( + `./useCodexDeviceCodeFlow.js?poll-${Date.now()}-${Math.random()}` + ) + + function Harness(): React.ReactNode { + const handleAuthenticated = React.useCallback(onAuthenticated, [onAuthenticated]) + const status = useCodexDeviceCodeFlow({ + onAuthenticated: handleAuthenticated, + deps, + }) + + if (status.state !== 'waiting') return {status.state} + return {`${status.userCode} ${status.verificationUri}`} + } + + const streams = createTestStreams() + const root = await createRoot({ + stdout: streams.stdout as unknown as NodeJS.WriteStream, + stdin: streams.stdin as unknown as NodeJS.ReadStream, + patchConsole: false, + }) + root.render() + + try { + await waitForCondition(() => pollCodexDeviceToken.mock.calls.length === 1) + expect(openVerificationUri).toHaveBeenCalledWith( + DEVICE_CODE.verificationUriComplete, + ) + expect(pollCodexDeviceToken).toHaveBeenCalledWith(DEVICE_CODE.deviceCode, { + initialInterval: DEVICE_CODE.interval, + timeoutSeconds: DEVICE_CODE.expiresIn, + signal: expect.any(AbortSignal), + }) + } finally { + root.unmount() + streams.stdin.end() + streams.stdout.end() + await Bun.sleep(0) + } +}) + +test('does not persist credentials when downstream setup rejects', async () => { + const requestCodexDeviceCode = mock(async () => DEVICE_CODE) + const pollCodexDeviceToken = mock(async () => TOKENS) + const openVerificationUri = mock(async () => true) + const saveCodexCredentials = mock(() => ({ success: true })) + const onAuthenticated = mock(async () => { + throw new Error('profile save failed') + }) + const deps = { + requestCodexDeviceCode, + pollCodexDeviceToken, + openVerificationUri, + saveCodexCredentials, + isBareMode: () => false, + } + + const { useCodexDeviceCodeFlow } = await import( + `./useCodexDeviceCodeFlow.js?reject-${Date.now()}-${Math.random()}` + ) + + function Harness(): React.ReactNode { + const handleAuthenticated = React.useCallback(onAuthenticated, [onAuthenticated]) + const status = useCodexDeviceCodeFlow({ + onAuthenticated: handleAuthenticated, + deps, + }) + + return {status.state === 'error' ? status.message : status.state} + } + + const streams = createTestStreams() + const root = await createRoot({ + stdout: streams.stdout as unknown as NodeJS.WriteStream, + stdin: streams.stdin as unknown as NodeJS.ReadStream, + patchConsole: false, + }) + root.render() + + try { + await waitForCondition(() => onAuthenticated.mock.calls.length === 1) + await Bun.sleep(0) + await Bun.sleep(0) + expect(onAuthenticated).toHaveBeenCalled() + expect(saveCodexCredentials).not.toHaveBeenCalled() + } finally { + root.unmount() + streams.stdin.end() + streams.stdout.end() + await Bun.sleep(0) + } +}) + +test('persists credentials with profile linkage after downstream setup succeeds', async () => { + const requestCodexDeviceCode = mock(async () => DEVICE_CODE) + const pollCodexDeviceToken = mock(async () => TOKENS) + const openVerificationUri = mock(async () => true) + const saveCodexCredentials = mock(() => ({ success: true })) + const onAuthenticated = mock( + async ( + _tokens: typeof TOKENS, + persistCredentials: (options?: { profileId?: string }) => void, + ) => { + persistCredentials({ profileId: 'profile_codex_device' }) + }, + ) + const deps = { + requestCodexDeviceCode, + pollCodexDeviceToken, + openVerificationUri, + saveCodexCredentials, + isBareMode: () => false, + } + + const { useCodexDeviceCodeFlow } = await import( + `./useCodexDeviceCodeFlow.js?persist-${Date.now()}-${Math.random()}` + ) + + function Harness(): React.ReactNode { + const handleAuthenticated = React.useCallback(onAuthenticated, [onAuthenticated]) + useCodexDeviceCodeFlow({ + onAuthenticated: handleAuthenticated, + deps, + }) + return waiting + } + + const streams = createTestStreams() + const root = await createRoot({ + stdout: streams.stdout as unknown as NodeJS.WriteStream, + stdin: streams.stdin as unknown as NodeJS.ReadStream, + patchConsole: false, + }) + root.render() + + try { + await waitForCondition(() => onAuthenticated.mock.calls.length === 1) + await waitForCondition(() => saveCodexCredentials.mock.calls.length === 1) + expect(onAuthenticated).toHaveBeenCalled() + expect(saveCodexCredentials).toHaveBeenCalledWith({ + apiKey: TOKENS.apiKey, + accessToken: TOKENS.accessToken, + refreshToken: TOKENS.refreshToken, + idToken: TOKENS.idToken, + accountId: TOKENS.accountId, + profileId: 'profile_codex_device', + }) + } finally { + root.unmount() + streams.stdin.end() + streams.stdout.end() + await Bun.sleep(0) + } +}) + +test('aborts polling on unmount', async () => { + let pollingSignal: AbortSignal | undefined + const requestCodexDeviceCode = mock(async () => DEVICE_CODE) + const pollCodexDeviceToken = mock( + async ( + _deviceCode: string, + options?: { signal?: AbortSignal }, + ): Promise => { + pollingSignal = options?.signal + await new Promise(() => {}) + return TOKENS + }, + ) + const openVerificationUri = mock(async () => true) + const saveCodexCredentials = mock(() => ({ success: true })) + const onAuthenticated = mock(async () => {}) + const deps = { + requestCodexDeviceCode, + pollCodexDeviceToken, + openVerificationUri, + saveCodexCredentials, + isBareMode: () => false, + } + + const { useCodexDeviceCodeFlow } = await import( + `./useCodexDeviceCodeFlow.js?abort-${Date.now()}-${Math.random()}` + ) + + function Harness(): React.ReactNode { + const handleAuthenticated = React.useCallback(onAuthenticated, [onAuthenticated]) + useCodexDeviceCodeFlow({ + onAuthenticated: handleAuthenticated, + deps, + }) + return waiting + } + + const streams = createTestStreams() + const root = await createRoot({ + stdout: streams.stdout as unknown as NodeJS.WriteStream, + stdin: streams.stdin as unknown as NodeJS.ReadStream, + patchConsole: false, + }) + root.render() + + try { + await waitForCondition(() => pollingSignal !== undefined) + expect(pollingSignal?.aborted).toBe(false) + root.unmount() + await Bun.sleep(0) + expect(pollingSignal?.aborted).toBe(true) + } finally { + streams.stdin.end() + streams.stdout.end() + await Bun.sleep(0) + } +}) diff --git a/src/components/useCodexDeviceCodeFlow.ts b/src/components/useCodexDeviceCodeFlow.ts new file mode 100644 index 000000000..3a7fcfb01 --- /dev/null +++ b/src/components/useCodexDeviceCodeFlow.ts @@ -0,0 +1,152 @@ +import * as React from 'react' + +import { + openVerificationUri, + pollCodexDeviceToken, + requestCodexDeviceCode, +} from '../services/api/codexDeviceFlow.js' +import type { CodexOAuthTokens } from '../services/api/codexOAuth.js' +import { saveCodexCredentials } from '../utils/codexCredentials.js' +import { isBareMode } from '../utils/envUtils.js' + +export type CodexDeviceCodeFlowStatus = + | { state: 'starting' } + | { + state: 'waiting' + userCode: string + verificationUri: string + verificationUriComplete?: string + expiresIn: number + interval: number + browserOpened: boolean | null + } + | { + state: 'error' + message: string + } + +type PersistCodexCredentials = (options?: { + profileId?: string +}) => void + +type CodexDeviceCodeFlowDependencies = { + requestCodexDeviceCode?: typeof requestCodexDeviceCode + pollCodexDeviceToken?: typeof pollCodexDeviceToken + openVerificationUri?: typeof openVerificationUri + saveCodexCredentials?: typeof saveCodexCredentials + isBareMode?: typeof isBareMode +} + +export function useCodexDeviceCodeFlow(options: { + onAuthenticated: ( + tokens: CodexOAuthTokens, + persistCredentials: PersistCodexCredentials, + ) => void | Promise + deps?: CodexDeviceCodeFlowDependencies +}): CodexDeviceCodeFlowStatus { + const { onAuthenticated } = options + const requestDeviceCode = + options.deps?.requestCodexDeviceCode ?? requestCodexDeviceCode + const pollDeviceToken = + options.deps?.pollCodexDeviceToken ?? pollCodexDeviceToken + const openVerificationUriFn = + options.deps?.openVerificationUri ?? openVerificationUri + const saveCredentials = + options.deps?.saveCodexCredentials ?? saveCodexCredentials + const isBareModeFn = options.deps?.isBareMode ?? isBareMode + const [status, setStatus] = React.useState({ + state: 'starting', + }) + + React.useEffect(() => { + if (isBareModeFn()) { + setStatus({ + state: 'error', + message: + 'Codex device-code sign-in is unavailable in --bare because secure storage is disabled.', + }) + return + } + + let cancelled = false + const controller = new AbortController() + + void (async () => { + try { + const deviceCode = await requestDeviceCode() + if (cancelled) return + + setStatus({ + state: 'waiting', + userCode: deviceCode.userCode, + verificationUri: deviceCode.verificationUri, + verificationUriComplete: deviceCode.verificationUriComplete, + expiresIn: deviceCode.expiresIn, + interval: deviceCode.interval, + browserOpened: null, + }) + + const browserOpened = await openVerificationUriFn( + deviceCode.verificationUriComplete ?? deviceCode.verificationUri, + ) + if (cancelled) return + + setStatus({ + state: 'waiting', + userCode: deviceCode.userCode, + verificationUri: deviceCode.verificationUri, + verificationUriComplete: deviceCode.verificationUriComplete, + expiresIn: deviceCode.expiresIn, + interval: deviceCode.interval, + browserOpened, + }) + + const tokens = await pollDeviceToken(deviceCode.deviceCode, { + initialInterval: deviceCode.interval, + timeoutSeconds: deviceCode.expiresIn, + signal: controller.signal, + }) + if (cancelled) return + + const persistCredentials: PersistCodexCredentials = options => { + const saved = saveCredentials({ + apiKey: tokens.apiKey, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + idToken: tokens.idToken, + accountId: tokens.accountId, + profileId: options?.profileId, + }) + if (!saved.success) { + throw new Error( + saved.warning ?? + 'Codex device-code sign-in succeeded, but credentials could not be saved securely.', + ) + } + } + + await onAuthenticated(tokens, persistCredentials) + } catch (error) { + if (cancelled) return + setStatus({ + state: 'error', + message: error instanceof Error ? error.message : String(error), + }) + } + })() + + return () => { + cancelled = true + controller.abort() + } + }, [ + isBareModeFn, + onAuthenticated, + openVerificationUriFn, + pollDeviceToken, + requestDeviceCode, + saveCredentials, + ]) + + return status +} diff --git a/src/services/api/codexDeviceFlow.test.ts b/src/services/api/codexDeviceFlow.test.ts new file mode 100644 index 000000000..81d36337a --- /dev/null +++ b/src/services/api/codexDeviceFlow.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, mock, test } from 'bun:test' + +import { + CODEX_DEVICE_CODE_URL, + CODEX_DEVICE_REFRESH_URL, + CODEX_DEVICE_TOKEN_GRANT, + pollCodexDeviceToken, + requestCodexDeviceCode, +} from './codexDeviceFlow.js' + +function jsonResponse(payload: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(payload), { + headers: { 'Content-Type': 'application/json' }, + ...init, + }) +} + +describe('requestCodexDeviceCode', () => { + test('parses successful device-code response and sends expected form fields', async () => { + let requestInit: RequestInit | undefined + const fetchImpl = mock(async (_input: RequestInfo | URL, init?: RequestInit) => { + requestInit = init + return jsonResponse({ + device_code: 'device-code', + user_code: 'USER-CODE', + verification_uri: 'https://auth.openai.com/activate', + verification_uri_complete: + 'https://auth.openai.com/activate?user_code=USER-CODE', + expires_in: 900, + interval: 5, + }) + }) + + const result = await requestCodexDeviceCode({ + clientId: 'client-id', + scope: 'scope-a scope-b', + fetchImpl, + }) + + expect(fetchImpl).toHaveBeenCalledWith(CODEX_DEVICE_CODE_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: expect.any(URLSearchParams), + signal: expect.any(AbortSignal), + }) + expect((requestInit?.body as URLSearchParams).get('client_id')).toBe( + 'client-id', + ) + expect((requestInit?.body as URLSearchParams).get('scope')).toBe( + 'scope-a scope-b', + ) + expect((requestInit?.body as URLSearchParams).get('originator')).toBe( + 'codex_cli_rs', + ) + expect( + (requestInit?.body as URLSearchParams).get('id_token_add_organizations'), + ).toBe('true') + expect(result).toEqual({ + deviceCode: 'device-code', + userCode: 'USER-CODE', + verificationUri: 'https://auth.openai.com/activate', + verificationUriComplete: + 'https://auth.openai.com/activate?user_code=USER-CODE', + expiresIn: 900, + interval: 5, + }) + }) + + test('aborts a hanging request after the per-request timeout', async () => { + const fetchImpl = mock( + async (_input: RequestInfo | URL, init?: RequestInit): Promise => { + await new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { + once: true, + }) + }) + throw new Error('unreachable') + }, + ) + + await expect( + requestCodexDeviceCode({ + fetchImpl, + requestTimeoutMs: 1, + }), + ).rejects.toThrow() + }) + + test('honors caller abort signal', async () => { + let requestSignal: AbortSignal | undefined + const controller = new AbortController() + const fetchImpl = mock( + async (_input: RequestInfo | URL, init?: RequestInit): Promise => { + requestSignal = init?.signal ?? undefined + await new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { + once: true, + }) + }) + throw new Error('unreachable') + }, + ) + + const result = requestCodexDeviceCode({ + fetchImpl, + signal: controller.signal, + requestTimeoutMs: 60_000, + }) + await Bun.sleep(0) + controller.abort(new Error('cancelled')) + + await expect(result).rejects.toThrow('cancelled') + expect(requestSignal?.aborted).toBe(true) + }) +}) + +describe('pollCodexDeviceToken', () => { + test('polls with request signal and returns tokens', async () => { + let requestInit: RequestInit | undefined + const fetchImpl = mock(async (_input: RequestInfo | URL, init?: RequestInit) => { + requestInit = init + return jsonResponse({ + access_token: 'access-token', + refresh_token: 'refresh-token', + chatgpt_account_id: 'account-id', + }) + }) + + const result = await pollCodexDeviceToken('device-code', { + clientId: 'client-id', + fetchImpl, + requestTimeoutMs: 1_000, + }) + + expect(fetchImpl).toHaveBeenCalledWith(CODEX_DEVICE_REFRESH_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: expect.any(URLSearchParams), + signal: expect.any(AbortSignal), + }) + expect((requestInit?.body as URLSearchParams).get('client_id')).toBe( + 'client-id', + ) + expect((requestInit?.body as URLSearchParams).get('grant_type')).toBe( + CODEX_DEVICE_TOKEN_GRANT, + ) + expect((requestInit?.body as URLSearchParams).get('device_code')).toBe( + 'device-code', + ) + expect(result).toEqual({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + idToken: undefined, + apiKey: undefined, + accountId: 'account-id', + }) + }) + + test('aborts a hanging polling request after the per-request timeout', async () => { + const fetchImpl = mock( + async (_input: RequestInfo | URL, init?: RequestInit): Promise => { + await new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { + once: true, + }) + }) + throw new Error('unreachable') + }, + ) + + await expect( + pollCodexDeviceToken('device-code', { + fetchImpl, + requestTimeoutMs: 1, + }), + ).rejects.toThrow() + }) + + test('honors caller abort signal while polling', async () => { + let requestSignal: AbortSignal | undefined + const controller = new AbortController() + const fetchImpl = mock( + async (_input: RequestInfo | URL, init?: RequestInit): Promise => { + requestSignal = init?.signal ?? undefined + await new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { + once: true, + }) + }) + throw new Error('unreachable') + }, + ) + + const result = pollCodexDeviceToken('device-code', { + fetchImpl, + signal: controller.signal, + requestTimeoutMs: 60_000, + }) + await Bun.sleep(0) + controller.abort(new Error('cancelled')) + + await expect(result).rejects.toThrow('cancelled') + expect(requestSignal?.aborted).toBe(true) + }) +}) diff --git a/src/services/api/codexDeviceFlow.ts b/src/services/api/codexDeviceFlow.ts new file mode 100644 index 000000000..8b6c885b9 --- /dev/null +++ b/src/services/api/codexDeviceFlow.ts @@ -0,0 +1,307 @@ +import { openBrowser } from '../../utils/browser.js' +import { + asTrimmedString, + CODEX_OAUTH_ISSUER, + CODEX_OAUTH_ORIGINATOR, + CODEX_OAUTH_SCOPE, + CODEX_REFRESH_URL as SHARED_CODEX_REFRESH_URL, + exchangeCodexIdTokenForApiKey, + getCodexOAuthClientId, + parseChatgptAccountId, +} from './codexOAuthShared.js' +import type { CodexOAuthTokens } from './codexOAuth.js' + +export const CODEX_DEVICE_REFRESH_URL = SHARED_CODEX_REFRESH_URL +export const CODEX_DEVICE_CODE_URL = `${CODEX_OAUTH_ISSUER}/oauth/device/code` +export const CODEX_DEVICE_TOKEN_GRANT = + 'urn:ietf:params:oauth:grant-type:device_code' +const CODEX_DEVICE_FLOW_REQUEST_TIMEOUT_MS = 15_000 + +export class CodexDeviceFlowError extends Error { + constructor(message: string) { + super(message) + this.name = 'CodexDeviceFlowError' + } +} + +export type CodexDeviceCodeResult = { + deviceCode: string + userCode: string + verificationUri: string + verificationUriComplete?: string + expiresIn: number + interval: number +} + +type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise + +function createRequestSignal( + signal?: AbortSignal, + timeoutMs = CODEX_DEVICE_FLOW_REQUEST_TIMEOUT_MS, +): AbortSignal { + const timeoutSignal = AbortSignal.timeout(Math.max(1, Math.floor(timeoutMs))) + return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup() + resolve() + }, ms) + + const onAbort = (): void => { + cleanup() + reject(new DOMException('The operation was aborted.', 'AbortError')) + } + + const cleanup = (): void => { + clearTimeout(timeout) + signal?.removeEventListener('abort', onAbort) + } + + if (signal) { + if (signal.aborted) { + cleanup() + reject(new DOMException('The operation was aborted.', 'AbortError')) + return + } + signal.addEventListener('abort', onAbort, { once: true }) + } + }) +} + +function readString(value: unknown): string | undefined { + return asTrimmedString(value) +} + +function parseDeviceCodeResponse(payload: Record): CodexDeviceCodeResult { + const deviceCode = readString(payload.device_code) + const userCode = readString(payload.user_code) + const verificationUri = + readString(payload.verification_uri) ?? readString(payload.verification_url) + const verificationUriComplete = + readString(payload.verification_uri_complete) ?? + readString(payload.verification_url_complete) + const expiresIn = Number(payload.expires_in) + const interval = Number(payload.interval) + + if ( + !deviceCode || + !userCode || + !verificationUri || + !Number.isFinite(expiresIn) || + !Number.isFinite(interval) + ) { + throw new CodexDeviceFlowError( + 'Codex device-code response was missing required fields.', + ) + } + + return { + deviceCode, + userCode, + verificationUri, + verificationUriComplete, + expiresIn, + interval: interval > 0 ? interval : 5, + } +} + +async function readJsonResponse(response: Response): Promise> { + try { + const payload = (await response.json()) as unknown + return payload && typeof payload === 'object' + ? (payload as Record) + : {} + } catch { + return {} + } +} + +function getErrorMessage(response: Response, payload: Record): string { + const error = readString(payload.error) + const description = readString(payload.error_description) + const detail = description ?? error + if (detail) { + return `Codex device-code request failed (${response.status}): ${detail}` + } + return `Codex device-code request failed with status ${response.status}.` +} + +export async function requestCodexDeviceCode(options?: { + clientId?: string + scope?: string + fetchImpl?: FetchLike + signal?: AbortSignal + requestTimeoutMs?: number +}): Promise { + const fetchFn = options?.fetchImpl ?? fetch + const body = new URLSearchParams({ + client_id: options?.clientId ?? getCodexOAuthClientId(), + scope: options?.scope ?? CODEX_OAUTH_SCOPE, + originator: CODEX_OAUTH_ORIGINATOR, + id_token_add_organizations: 'true', + }) + + const response = await fetchFn(CODEX_DEVICE_CODE_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + signal: createRequestSignal(options?.signal, options?.requestTimeoutMs), + }) + + const payload = await readJsonResponse(response) + if (!response.ok) { + throw new CodexDeviceFlowError(getErrorMessage(response, payload)) + } + + return parseDeviceCodeResponse(payload) +} + +async function pollTokenOnce(options: { + deviceCode: string + clientId: string + fetchImpl: FetchLike + signal?: AbortSignal + requestTimeoutMs?: number +}): Promise< + | { state: 'pending'; interval?: number } + | { state: 'denied' } + | { state: 'expired' } + | { state: 'slow_down'; interval?: number } + | { state: 'success'; tokens: CodexOAuthTokens } + | { state: 'error'; message: string } +> { + const body = new URLSearchParams({ + client_id: options.clientId, + grant_type: CODEX_DEVICE_TOKEN_GRANT, + device_code: options.deviceCode, + }) + + const response = await options.fetchImpl(CODEX_DEVICE_REFRESH_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + signal: createRequestSignal(options.signal, options.requestTimeoutMs), + }) + + const payload = await readJsonResponse(response) + if (response.ok) { + const accessToken = readString(payload.access_token) + const refreshToken = readString(payload.refresh_token) + const idToken = readString(payload.id_token) + const accountId = + readString(payload.chatgpt_account_id) ?? + parseChatgptAccountId(idToken) ?? + parseChatgptAccountId(accessToken) + + if (!accessToken || !refreshToken) { + return { + state: 'error', + message: + 'Codex device-code sign-in completed, but no usable tokens were returned.', + } + } + + const apiKey = idToken + ? await exchangeCodexIdTokenForApiKey(idToken).catch(() => undefined) + : undefined + + return { + state: 'success', + tokens: { + apiKey, + accessToken, + refreshToken, + idToken, + accountId, + }, + } + } + + const error = readString(payload.error) + const interval = Number(payload.interval) + + switch (error) { + case 'authorization_pending': + return { + state: 'pending', + interval: Number.isFinite(interval) && interval > 0 ? interval : undefined, + } + case 'slow_down': + return { + state: 'slow_down', + interval: Number.isFinite(interval) && interval > 0 ? interval : undefined, + } + case 'access_denied': + case 'authorization_declined': + return { state: 'denied' } + case 'expired_token': + return { state: 'expired' } + default: + return { + state: 'error', + message: getErrorMessage(response, payload), + } + } +} + +export async function pollCodexDeviceToken( + deviceCode: string, + options?: { + clientId?: string + initialInterval?: number + timeoutSeconds?: number + fetchImpl?: FetchLike + signal?: AbortSignal + requestTimeoutMs?: number + }, +): Promise { + const fetchFn = options?.fetchImpl ?? fetch + const clientId = options?.clientId ?? getCodexOAuthClientId() + let interval = Math.max(1, Math.floor(options?.initialInterval ?? 5)) + const timeoutMs = Math.max(1, Math.floor(options?.timeoutSeconds ?? 15 * 60)) * 1000 + const startedAt = Date.now() + + while (Date.now() - startedAt < timeoutMs) { + const result = await pollTokenOnce({ + deviceCode, + clientId, + fetchImpl: fetchFn, + signal: options?.signal, + requestTimeoutMs: options?.requestTimeoutMs, + }) + + if (result.state === 'success') { + return result.tokens + } + if (result.state === 'error') { + throw new CodexDeviceFlowError(result.message) + } + if (result.state === 'denied') { + throw new CodexDeviceFlowError('Authorization was denied or cancelled.') + } + if (result.state === 'expired') { + throw new CodexDeviceFlowError('Device code expired. Start the login flow again.') + } + + if (result.state === 'slow_down' && result.interval) { + interval = Math.max(interval + 5, result.interval) + } else if (result.interval) { + interval = Math.max(1, result.interval) + } + + await sleep(interval * 1000, options?.signal) + } + + throw new CodexDeviceFlowError('Device code expired. Start the login flow again.') +} + +export async function openVerificationUri(uri: string): Promise { + return openBrowser(uri) +} diff --git a/src/utils/providerValidation.ts b/src/utils/providerValidation.ts index 831cfda83..925a133ba 100644 --- a/src/utils/providerValidation.ts +++ b/src/utils/providerValidation.ts @@ -413,7 +413,7 @@ export async function getProviderValidationError( if (hasExplicitCodexIntent) { const credentials = resolveCodexApiCredentials(env) if (!credentials.apiKey) { - const oauthHint = isBareMode() ? '' : ', choose Codex OAuth in /provider' + const oauthHint = isBareMode() ? '' : ', choose Codex OAuth or device code in /provider' const authHint = credentials.authPath ? `${oauthHint} or put auth.json at ${credentials.authPath}` : oauthHint @@ -424,7 +424,7 @@ export async function getProviderValidationError( return `Codex auth is required for ${safeModel}. Set CODEX_API_KEY${authHint}.` } if (!credentials.accountId) { - return 'Codex auth is missing chatgpt_account_id. Re-login with Codex OAuth, Codex CLI, or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.' + return 'Codex auth is missing chatgpt_account_id. Re-login with Codex OAuth, device code, Codex CLI, or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.' } return null }