mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
* feat(codex-oauth): manual callback paste for SSH / remote sessions Codex OAuth required the browser to reach the openclaude host's localhost:1455 callback. On SSH / containerized installs that callback resolves to the user's workstation instead of the openclaude host, so the redirect lands on a dead URL and the CLI hangs. Add a manual-paste fallback (mirrors the xAI OAuth recovery path): after authorizing in the browser, the user copies the full redirected URL from the address bar and pastes it into the CLI. CodexOAuthService validates the state parameter against the in-flight flow, races the manual code against the loopback listener, and reuses the same authorization-code → token exchange. SSH_CONNECTION / SSH_CLIENT triggers a warning banner explaining why the loopback redirect failed; non-SSH sessions get a dim hint covering containerized / remote setups. Closes #1288 * test(codex-oauth): type manual-paste fetch mock via asMockFetch The raw `mock(...) as typeof fetch` cast no longer typechecks against the base branch's stricter `fetch` type (now requires `preconnect`). Route the manual-callback-paste test's mock through the shared `asMockFetch` helper, matching the other fetch mocks in this file. * fix(codex-oauth): mask the pasted manual callback URL input The manual-recovery field echoed the redirected callback URL verbatim, which carries the OAuth code and state query params — enough to complete the in-flight exchange. Mask it with mask="*", matching the adjacent xAI manual-code field, so it stays out of terminal scrollback, recordings, and shared sessions. * test(codex-oauth): bound state wait and cover hook manual-callback contract - Bound the while (!capturedState) wait in the manual-callback test with a 5s deadline so a regression fails with a clear assertion instead of hanging the suite. - Add a useCodexOAuthFlow test asserting the waiting status exposes submitManualCallback and delegates both success and failure results from the service back to the caller. * test(codex-oauth): stabilize onAuthenticated in the manual-callback test The new hook test passed a fresh inline `onAuthenticated: async () => {}` on every render, so the hook effect re-ran each render, restarting the flow and looping setStatus → render ("Maximum update depth exceeded" when run alongside ProviderManager.test.tsx). Hoist the callback to a stable reference, matching the other tests in the file. * test(codex-oauth): cover the ProviderManager manual-callback UI Add focused coverage for the waiting-state paste surface: the masked callback input renders, a good callback delegates to status.submitManualCallback with no inline error, the SSH banner appears when SSH_CONNECTION is set, and a rejected callback surfaces the hook's inline error.
This commit is contained in:
@@ -342,6 +342,10 @@ function mockProviderManagerDependencies(
|
||||
authUrl?: string
|
||||
browserOpened?: boolean | null
|
||||
message?: string
|
||||
submitManualCallback?: (input: string) => {
|
||||
ok: boolean
|
||||
error?: string
|
||||
}
|
||||
}
|
||||
},
|
||||
): void {
|
||||
@@ -1586,6 +1590,143 @@ test('ProviderManager first-run Codex OAuth switches the current session after l
|
||||
await mounted.dispose()
|
||||
})
|
||||
|
||||
test('ProviderManager Codex OAuth waiting state masks the paste field and delegates a good callback', async () => {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||
delete process.env.GITHUB_TOKEN
|
||||
delete process.env.GH_TOKEN
|
||||
delete process.env.SSH_CONNECTION
|
||||
delete process.env.SSH_CLIENT
|
||||
|
||||
const onDone = mock(() => {})
|
||||
const submitManualCallback = mock((_input: string) => ({ ok: true }))
|
||||
|
||||
mockProviderManagerDependencies(
|
||||
() => undefined,
|
||||
async () => undefined,
|
||||
{
|
||||
// Stay in `waiting` (never call onAuthenticated) so the manual-paste UI
|
||||
// renders. The hook returns a spy submitManualCallback.
|
||||
useCodexOAuthFlow: () => ({
|
||||
state: 'waiting',
|
||||
authUrl: 'https://chatgpt.com/codex',
|
||||
browserOpened: true,
|
||||
submitManualCallback,
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
const nonce = `${Date.now()}-${Math.random()}`
|
||||
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
|
||||
const mounted = await mountProviderManager(ProviderManager, {
|
||||
mode: 'first-run',
|
||||
onDone,
|
||||
})
|
||||
|
||||
await waitForFrameOutput(
|
||||
mounted.getOutput,
|
||||
frame => frame.includes('Set up provider') && frame.includes('Codex OAuth'),
|
||||
)
|
||||
|
||||
await navigateToPreset(mounted.stdin, 'Codex OAuth')
|
||||
mounted.stdin.write('\r')
|
||||
|
||||
// Non-SSH session shows the generic "paste the callback URL" hint and the input.
|
||||
await waitForFrameOutput(
|
||||
mounted.getOutput,
|
||||
frame =>
|
||||
frame.includes('Callback URL') &&
|
||||
frame.includes('paste the full callback URL'),
|
||||
)
|
||||
|
||||
const callbackUrl =
|
||||
'http://localhost:41100/auth/callback?code=goodsecret&state=s'
|
||||
mounted.stdin.write(callbackUrl)
|
||||
// The pasted secret must be masked — the raw code must never reach the frame.
|
||||
await waitForFrameOutput(
|
||||
mounted.getOutput,
|
||||
frame => !frame.includes('goodsecret') && frame.includes('Callback URL'),
|
||||
)
|
||||
|
||||
mounted.stdin.write('\r')
|
||||
await waitForCondition(() => submitManualCallback.mock.calls.length > 0)
|
||||
expect(submitManualCallback).toHaveBeenCalledWith(callbackUrl)
|
||||
// A successful submit leaves no inline error on screen.
|
||||
expect(
|
||||
stripAnsi(extractLastFrame(mounted.getOutput())),
|
||||
).not.toContain('State mismatch')
|
||||
|
||||
await mounted.dispose()
|
||||
})
|
||||
|
||||
test('ProviderManager Codex OAuth waiting state shows the SSH banner and surfaces a bad-callback error', async () => {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||
delete process.env.GITHUB_TOKEN
|
||||
delete process.env.GH_TOKEN
|
||||
process.env.SSH_CONNECTION = '10.0.0.1 22 10.0.0.2 22'
|
||||
delete process.env.SSH_CLIENT
|
||||
|
||||
const onDone = mock(() => {})
|
||||
const submitManualCallback = mock((_input: string) => ({
|
||||
ok: false,
|
||||
error: 'State mismatch',
|
||||
}))
|
||||
|
||||
try {
|
||||
mockProviderManagerDependencies(
|
||||
() => undefined,
|
||||
async () => undefined,
|
||||
{
|
||||
useCodexOAuthFlow: () => ({
|
||||
state: 'waiting',
|
||||
authUrl: 'https://chatgpt.com/codex',
|
||||
browserOpened: true,
|
||||
submitManualCallback,
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
const nonce = `${Date.now()}-${Math.random()}`
|
||||
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
|
||||
const mounted = await mountProviderManager(ProviderManager, {
|
||||
mode: 'first-run',
|
||||
onDone,
|
||||
})
|
||||
|
||||
await waitForFrameOutput(
|
||||
mounted.getOutput,
|
||||
frame =>
|
||||
frame.includes('Set up provider') && frame.includes('Codex OAuth'),
|
||||
)
|
||||
|
||||
await navigateToPreset(mounted.stdin, 'Codex OAuth')
|
||||
mounted.stdin.write('\r')
|
||||
|
||||
// SSH session shows the dedicated banner instead of the generic hint.
|
||||
await waitForFrameOutput(
|
||||
mounted.getOutput,
|
||||
frame =>
|
||||
frame.includes('SSH session detected') &&
|
||||
frame.includes('Callback URL'),
|
||||
)
|
||||
|
||||
mounted.stdin.write('http://localhost:41100/auth/callback?code=x&state=s')
|
||||
mounted.stdin.write('\r')
|
||||
|
||||
// A rejected callback renders the inline error returned by the hook.
|
||||
await waitForFrameOutput(
|
||||
mounted.getOutput,
|
||||
frame => frame.includes('State mismatch'),
|
||||
)
|
||||
expect(submitManualCallback).toHaveBeenCalledTimes(1)
|
||||
|
||||
await mounted.dispose()
|
||||
} finally {
|
||||
delete process.env.SSH_CONNECTION
|
||||
}
|
||||
})
|
||||
|
||||
test('ProviderManager first-run Codex OAuth surfaces credential storage warnings', async () => {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||
|
||||
@@ -602,6 +602,40 @@ function XaiManualCodeInput({
|
||||
)
|
||||
}
|
||||
|
||||
function CodexManualCallbackInput({
|
||||
onSubmit,
|
||||
}: {
|
||||
onSubmit: (input: string) => void
|
||||
}): React.ReactNode {
|
||||
const [value, setValue] = React.useState('')
|
||||
const [cursorOffset, setCursorOffset] = React.useState(0)
|
||||
const { columns: terminalColumns } = useTerminalSize()
|
||||
const inputColumns = Math.max(20, Math.min(120, terminalColumns - 12))
|
||||
return (
|
||||
<Box>
|
||||
<Text>Callback URL › </Text>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
cursorOffset={cursorOffset}
|
||||
onChangeCursorOffset={setCursorOffset}
|
||||
columns={inputColumns}
|
||||
onSubmit={submitted => {
|
||||
const trimmed = submitted.trim()
|
||||
if (trimmed) onSubmit(trimmed)
|
||||
}}
|
||||
// The pasted callback URL carries the OAuth `code` and `state` query
|
||||
// params — enough to complete the in-flight exchange — so mask it the
|
||||
// same way the xAI manual-code field above does, to keep it out of
|
||||
// terminal scrollback, recordings, and shared sessions.
|
||||
mask="*"
|
||||
// The parent `CodexOAuthSetup` owns Esc via `useKeybinding('confirm:no')`.
|
||||
disableEscapeDoublePress
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function CodexOAuthSetup({
|
||||
onBack,
|
||||
onConfigured,
|
||||
@@ -638,6 +672,10 @@ function CodexOAuthSetup({
|
||||
const status = useCodexOAuthFlow({
|
||||
onAuthenticated: handleAuthenticated,
|
||||
})
|
||||
const [pasteError, setPasteError] = React.useState<string | undefined>()
|
||||
const isRemoteSession = Boolean(
|
||||
process.env['SSH_CONNECTION'] || process.env['SSH_CLIENT'],
|
||||
)
|
||||
|
||||
if (status.state === 'error') {
|
||||
return (
|
||||
@@ -693,6 +731,34 @@ function CodexOAuthSetup({
|
||||
) : (
|
||||
<Text dimColor>Opening your browser...</Text>
|
||||
)}
|
||||
{status.state === 'waiting' ? (
|
||||
<>
|
||||
{isRemoteSession ? (
|
||||
<Text color="warning">
|
||||
SSH session detected — the browser cannot reach this host's
|
||||
localhost callback. After signing in, copy the full URL your
|
||||
browser was redirected to (it starts with http://localhost:) and
|
||||
paste it below.
|
||||
</Text>
|
||||
) : (
|
||||
<Text dimColor>
|
||||
If the browser cannot reach localhost (remote / containerized
|
||||
session), paste the full callback URL it was redirected to:
|
||||
</Text>
|
||||
)}
|
||||
<CodexManualCallbackInput
|
||||
onSubmit={input => {
|
||||
const result = status.submitManualCallback(input)
|
||||
if (!result.ok) {
|
||||
setPasteError(result.error)
|
||||
} else {
|
||||
setPasteError(undefined)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{pasteError ? <Text color="error">{pasteError}</Text> : null}
|
||||
</>
|
||||
) : null}
|
||||
<Text dimColor>Press Esc to cancel and go back.</Text>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -122,6 +122,7 @@ test('does not persist credentials when downstream setup rejects', async () => {
|
||||
return TOKENS
|
||||
},
|
||||
cleanup,
|
||||
submitManualCallback: () => ({ ok: true as const }),
|
||||
}),
|
||||
openBrowser: async () => true,
|
||||
saveCodexCredentials,
|
||||
@@ -192,6 +193,7 @@ test('persists credentials with profile linkage after downstream setup succeeds'
|
||||
return TOKENS
|
||||
},
|
||||
cleanup,
|
||||
submitManualCallback: () => ({ ok: true as const }),
|
||||
}),
|
||||
openBrowser: async () => true,
|
||||
saveCodexCredentials,
|
||||
@@ -271,6 +273,7 @@ test('returns a successful storage warning without entering the error state', as
|
||||
return TOKENS
|
||||
},
|
||||
cleanup,
|
||||
submitManualCallback: () => ({ ok: true as const }),
|
||||
}),
|
||||
openBrowser: async () => true,
|
||||
saveCodexCredentials,
|
||||
@@ -343,6 +346,7 @@ test('reports credential persistence failures without token values', async () =>
|
||||
return TOKENS
|
||||
},
|
||||
cleanup,
|
||||
submitManualCallback: () => ({ ok: true as const }),
|
||||
}),
|
||||
openBrowser: async () => true,
|
||||
saveCodexCredentials,
|
||||
@@ -389,3 +393,94 @@ test('reports credential persistence failures without token values', async () =>
|
||||
await Bun.sleep(0)
|
||||
}
|
||||
})
|
||||
|
||||
test('exposes submitManualCallback in the waiting state and delegates success and failure results', async () => {
|
||||
// The service's manual-callback result is what the ProviderManager UI relies
|
||||
// on; verify the hook surfaces it in the waiting status and passes both the
|
||||
// raw input and the service's result straight back to the caller.
|
||||
const submitManualCallback = mock((input: string) =>
|
||||
input.includes('code=good')
|
||||
? ({ ok: true as const })
|
||||
: ({ ok: false as const, error: 'State mismatch' }),
|
||||
)
|
||||
const cleanup = mock(() => {})
|
||||
const deps = {
|
||||
createOAuthService: () => ({
|
||||
async startOAuthFlow(
|
||||
onAuthorizationUrl: (authUrl: string) => void | Promise<void>,
|
||||
) {
|
||||
await onAuthorizationUrl('https://chatgpt.com/codex')
|
||||
// Stay in the waiting state so the manual-callback path is reachable.
|
||||
return new Promise<typeof TOKENS>(() => {})
|
||||
},
|
||||
cleanup,
|
||||
submitManualCallback,
|
||||
}),
|
||||
openBrowser: async () => false,
|
||||
saveCodexCredentials: mock(() => ({ success: true })),
|
||||
isBareMode: () => false,
|
||||
}
|
||||
|
||||
const { useCodexOAuthFlow } = await import(
|
||||
`./useCodexOAuthFlow.js?waiting-manual-${Date.now()}-${Math.random()}`
|
||||
)
|
||||
|
||||
let latestStatus:
|
||||
| {
|
||||
state: string
|
||||
submitManualCallback?: (
|
||||
input: string,
|
||||
) => { ok: boolean; error?: string }
|
||||
}
|
||||
| undefined
|
||||
|
||||
// Stable reference: a fresh inline `onAuthenticated` on every render would
|
||||
// re-run the hook's effect each render, restarting the OAuth flow and
|
||||
// looping setStatus → render → setStatus ("Maximum update depth exceeded").
|
||||
// The other tests in this file memoize the callback for the same reason.
|
||||
const onAuthenticated = async (): Promise<void> => {}
|
||||
|
||||
function Harness(): React.ReactNode {
|
||||
const status = useCodexOAuthFlow({
|
||||
onAuthenticated,
|
||||
deps,
|
||||
})
|
||||
latestStatus = status as typeof latestStatus
|
||||
return <Text>{status.state}</Text>
|
||||
}
|
||||
|
||||
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(<Harness />)
|
||||
|
||||
try {
|
||||
await waitForCondition(() => latestStatus?.state === 'waiting')
|
||||
expect(latestStatus?.state).toBe('waiting')
|
||||
expect(typeof latestStatus?.submitManualCallback).toBe('function')
|
||||
|
||||
const okResult = latestStatus?.submitManualCallback?.(
|
||||
'http://localhost:41100/auth/callback?code=good&state=s',
|
||||
)
|
||||
expect(okResult).toEqual({ ok: true })
|
||||
|
||||
const failResult = latestStatus?.submitManualCallback?.(
|
||||
'http://localhost:41100/auth/callback?code=bad&state=s',
|
||||
)
|
||||
expect(failResult?.ok).toBe(false)
|
||||
expect(failResult?.error).toBe('State mismatch')
|
||||
|
||||
expect(submitManualCallback).toHaveBeenCalledTimes(2)
|
||||
expect(submitManualCallback).toHaveBeenCalledWith(
|
||||
'http://localhost:41100/auth/callback?code=good&state=s',
|
||||
)
|
||||
} finally {
|
||||
root.unmount()
|
||||
streams.stdin.end()
|
||||
streams.stdout.end()
|
||||
await Bun.sleep(0)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as React from 'react'
|
||||
|
||||
import {
|
||||
CodexOAuthService,
|
||||
type CodexManualCallbackResult,
|
||||
type CodexOAuthTokens,
|
||||
} from '../services/api/codexOAuth.js'
|
||||
import { openBrowser } from '../utils/browser.js'
|
||||
@@ -14,6 +15,7 @@ export type CodexOAuthFlowStatus =
|
||||
state: 'waiting'
|
||||
authUrl: string
|
||||
browserOpened: boolean | null
|
||||
submitManualCallback: (input: string) => CodexManualCallbackResult
|
||||
}
|
||||
| {
|
||||
state: 'error'
|
||||
@@ -24,20 +26,19 @@ type PersistCodexOAuthCredentials = (options?: {
|
||||
profileId?: string
|
||||
}) => { warning?: string }
|
||||
|
||||
type CodexOAuthServiceLike = Pick<
|
||||
CodexOAuthService,
|
||||
'startOAuthFlow' | 'cleanup' | 'submitManualCallback'
|
||||
>
|
||||
|
||||
type CodexOAuthFlowDependencies = {
|
||||
createOAuthService?: () => Pick<
|
||||
CodexOAuthService,
|
||||
'startOAuthFlow' | 'cleanup'
|
||||
>
|
||||
createOAuthService?: () => CodexOAuthServiceLike
|
||||
openBrowser?: typeof openBrowser
|
||||
saveCodexCredentials?: typeof saveCodexCredentials
|
||||
isBareMode?: typeof isBareMode
|
||||
}
|
||||
|
||||
function createDefaultOAuthService(): Pick<
|
||||
CodexOAuthService,
|
||||
'startOAuthFlow' | 'cleanup'
|
||||
> {
|
||||
function createDefaultOAuthService(): CodexOAuthServiceLike {
|
||||
return new CodexOAuthService()
|
||||
}
|
||||
|
||||
@@ -71,6 +72,9 @@ export function useCodexOAuthFlow(options: {
|
||||
|
||||
let cancelled = false
|
||||
const oauthService = createOAuthService()
|
||||
const submitManualCallback = (
|
||||
input: string,
|
||||
): CodexManualCallbackResult => oauthService.submitManualCallback(input)
|
||||
|
||||
void oauthService
|
||||
.startOAuthFlow(async authUrl => {
|
||||
@@ -79,6 +83,7 @@ export function useCodexOAuthFlow(options: {
|
||||
state: 'waiting',
|
||||
authUrl,
|
||||
browserOpened: null,
|
||||
submitManualCallback,
|
||||
})
|
||||
const browserOpened = await openBrowserFn(authUrl)
|
||||
if (cancelled) return
|
||||
@@ -86,6 +91,7 @@ export function useCodexOAuthFlow(options: {
|
||||
state: 'waiting',
|
||||
authUrl,
|
||||
browserOpened,
|
||||
submitManualCallback,
|
||||
})
|
||||
})
|
||||
.then(async tokens => {
|
||||
|
||||
@@ -257,6 +257,121 @@ test('serves updated success copy after a successful Codex OAuth flow', async ()
|
||||
}
|
||||
})
|
||||
|
||||
test('manual callback paste completes the flow when the loopback is unreachable', async () => {
|
||||
await acquireCodexOAuthTestIsolation()
|
||||
|
||||
try {
|
||||
process.env.CODEX_OAUTH_CLIENT_ID = 'test-client-id'
|
||||
|
||||
globalThis.fetch = asMockFetch(
|
||||
mock(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: 'manual-access-token',
|
||||
refresh_token: 'manual-refresh-token',
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
// Hanging listener — never resolves on its own. The manual paste path
|
||||
// must be what completes the flow.
|
||||
let capturedState = ''
|
||||
let pending = false
|
||||
const hangingListenerFactory = ((callbackPath: string) => ({
|
||||
callbackPath,
|
||||
async start(): Promise<number> {
|
||||
return 41100
|
||||
},
|
||||
hasPendingResponse(): boolean {
|
||||
return pending
|
||||
},
|
||||
async waitForAuthorization(
|
||||
state: string,
|
||||
onReady: () => Promise<void>,
|
||||
): Promise<string> {
|
||||
capturedState = state
|
||||
pending = true
|
||||
await onReady()
|
||||
return new Promise<string>(() => {
|
||||
/* never resolves */
|
||||
})
|
||||
},
|
||||
handleSuccessRedirect(): void {
|
||||
pending = false
|
||||
},
|
||||
handleErrorRedirect(): void {
|
||||
pending = false
|
||||
},
|
||||
cancelPendingAuthorization(): void {
|
||||
pending = false
|
||||
},
|
||||
})) as unknown as NonNullable<
|
||||
ConstructorParameters<typeof CodexOAuthService>[0]
|
||||
>['createAuthCodeListener']
|
||||
|
||||
const service = new CodexOAuthService({
|
||||
callbackPort: 0,
|
||||
callbackHost: '127.0.0.1',
|
||||
createAuthCodeListener: hangingListenerFactory,
|
||||
})
|
||||
|
||||
const flowPromise = service.startOAuthFlow(async () => {})
|
||||
|
||||
// Wait until startOAuthFlow has populated expectedState via the listener.
|
||||
// Bound the wait so a regression that never captures the state fails with a
|
||||
// clear assertion instead of hanging the suite indefinitely.
|
||||
const stateDeadline = Date.now() + 5_000
|
||||
while (!capturedState) {
|
||||
if (Date.now() > stateDeadline) {
|
||||
throw new Error(
|
||||
'startOAuthFlow did not capture the OAuth state within 5s',
|
||||
)
|
||||
}
|
||||
await Bun.sleep(0)
|
||||
}
|
||||
|
||||
const stateMismatch = service.submitManualCallback(
|
||||
'http://localhost:41100/auth/callback?code=foo&state=wrong',
|
||||
)
|
||||
expect(stateMismatch.ok).toBe(false)
|
||||
if (!stateMismatch.ok) {
|
||||
expect(stateMismatch.error).toContain('State mismatch')
|
||||
}
|
||||
|
||||
const missingCode = service.submitManualCallback(
|
||||
`http://localhost:41100/auth/callback?state=${capturedState}`,
|
||||
)
|
||||
expect(missingCode.ok).toBe(false)
|
||||
if (!missingCode.ok) {
|
||||
expect(missingCode.error).toContain('`code`')
|
||||
}
|
||||
|
||||
const errorRedirect = service.submitManualCallback(
|
||||
`http://localhost:41100/auth/callback?error=access_denied&state=${capturedState}`,
|
||||
)
|
||||
expect(errorRedirect.ok).toBe(false)
|
||||
if (!errorRedirect.ok) {
|
||||
expect(errorRedirect.error).toContain('access_denied')
|
||||
}
|
||||
|
||||
const success = service.submitManualCallback(
|
||||
`http://localhost:41100/auth/callback?code=manual-auth-code&state=${capturedState}`,
|
||||
)
|
||||
expect(success.ok).toBe(true)
|
||||
|
||||
const tokens = await flowPromise
|
||||
expect(tokens.accessToken).toBe('manual-access-token')
|
||||
expect(tokens.refreshToken).toBe('manual-refresh-token')
|
||||
} finally {
|
||||
restoreCodexOAuthTestIsolation()
|
||||
}
|
||||
})
|
||||
|
||||
test('cancellation during token exchange returns a cancelled page and rejects the flow', async () => {
|
||||
await acquireCodexOAuthTestIsolation()
|
||||
|
||||
|
||||
@@ -203,10 +203,17 @@ type CodexOAuthListener = Pick<
|
||||
| 'cancelPendingAuthorization'
|
||||
>
|
||||
|
||||
export type CodexManualCallbackResult =
|
||||
| { ok: true }
|
||||
| { ok: false; error: string }
|
||||
|
||||
export class CodexOAuthService {
|
||||
private authCodeListener: CodexOAuthListener | null = null
|
||||
private port: number | null = null
|
||||
private tokenExchangeAbortController: AbortController | null = null
|
||||
private manualResolver: ((authorizationCode: string) => void) | null = null
|
||||
private manualRejecter: ((error: Error) => void) | null = null
|
||||
private expectedState: string | null = null
|
||||
|
||||
constructor(private readonly options: CodexOAuthServiceOptions = {}) {}
|
||||
|
||||
@@ -214,6 +221,83 @@ export class CodexOAuthService {
|
||||
return new Error('Codex OAuth flow was cancelled.')
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover the flow when the loopback callback is unreachable — typically a
|
||||
* remote SSH session where the user's browser redirects to a localhost URL
|
||||
* that resolves to their workstation, not the openclaude host. The user
|
||||
* pastes the full redirected URL (or just its query string), we validate
|
||||
* the state parameter against the in-flight flow, and resolve the same
|
||||
* authorization code the loopback path would have produced.
|
||||
*
|
||||
* Returns a structured outcome instead of throwing so the UI layer can
|
||||
* surface parse / state errors inline without unmounting the flow.
|
||||
*/
|
||||
submitManualCallback(input: string): CodexManualCallbackResult {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) {
|
||||
return { ok: false, error: 'Paste the callback URL or its query string.' }
|
||||
}
|
||||
if (!this.manualResolver || !this.expectedState) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'No Codex OAuth flow is waiting for a manual callback.',
|
||||
}
|
||||
}
|
||||
|
||||
let searchParams: URLSearchParams
|
||||
try {
|
||||
if (trimmed.includes('://')) {
|
||||
searchParams = new URL(trimmed).searchParams
|
||||
} else {
|
||||
searchParams = new URLSearchParams(
|
||||
trimmed.startsWith('?') ? trimmed.slice(1) : trimmed,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Could not parse the callback URL — paste the full address.',
|
||||
}
|
||||
}
|
||||
|
||||
const code = asTrimmedString(searchParams.get('code') ?? undefined)
|
||||
const state = asTrimmedString(searchParams.get('state') ?? undefined)
|
||||
const errorParam = asTrimmedString(searchParams.get('error') ?? undefined)
|
||||
if (errorParam) {
|
||||
const description = asTrimmedString(
|
||||
searchParams.get('error_description') ?? undefined,
|
||||
)
|
||||
return {
|
||||
ok: false,
|
||||
error: description
|
||||
? `Authorization failed: ${errorParam} — ${description}`
|
||||
: `Authorization failed: ${errorParam}`,
|
||||
}
|
||||
}
|
||||
if (!code) {
|
||||
return { ok: false, error: 'Callback URL is missing the `code` parameter.' }
|
||||
}
|
||||
if (!state) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Callback URL is missing the `state` parameter.',
|
||||
}
|
||||
}
|
||||
if (state !== this.expectedState) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
'State mismatch — the URL is from a different login attempt. Start over.',
|
||||
}
|
||||
}
|
||||
|
||||
const resolver = this.manualResolver
|
||||
this.manualResolver = null
|
||||
this.manualRejecter = null
|
||||
resolver(code)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async startOAuthFlow(
|
||||
authURLHandler: (authUrl: string) => Promise<void>,
|
||||
): Promise<CodexOAuthTokens> {
|
||||
@@ -242,13 +326,29 @@ export class CodexOAuthService {
|
||||
state,
|
||||
})
|
||||
|
||||
this.expectedState = state
|
||||
const manualPromise = new Promise<string>((resolve, reject) => {
|
||||
this.manualResolver = resolve
|
||||
this.manualRejecter = reject
|
||||
})
|
||||
// Manual path may never be taken; swallow its rejection (raised from
|
||||
// cleanup()) so it doesn't bubble as an unhandledRejection when the
|
||||
// loopback wins the race.
|
||||
manualPromise.catch(() => undefined)
|
||||
|
||||
try {
|
||||
const authorizationCode = await authCodeListener.waitForAuthorization(
|
||||
const loopbackPromise = authCodeListener.waitForAuthorization(
|
||||
state,
|
||||
async () => {
|
||||
await authURLHandler(authUrl)
|
||||
},
|
||||
)
|
||||
loopbackPromise.catch(() => undefined)
|
||||
|
||||
const authorizationCode = await Promise.race([
|
||||
loopbackPromise,
|
||||
manualPromise,
|
||||
])
|
||||
|
||||
const tokenExchangeAbortController = new AbortController()
|
||||
this.tokenExchangeAbortController = tokenExchangeAbortController
|
||||
@@ -344,5 +444,13 @@ export class CodexOAuthService {
|
||||
this.authCodeListener?.cancelPendingAuthorization(cancellationError)
|
||||
this.authCodeListener = null
|
||||
this.port = null
|
||||
|
||||
// Unblock any caller awaiting Promise.race against the manual path so the
|
||||
// race resolves to the listener's rejection (or to this cancellation)
|
||||
// instead of hanging forever.
|
||||
this.manualRejecter?.(cancellationError)
|
||||
this.manualResolver = null
|
||||
this.manualRejecter = null
|
||||
this.expectedState = null
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user