diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index 94051711..f2aece66 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -343,6 +343,17 @@ function buildOAuthArgs( return args; } +export function usesKiroLocalCallbackReplay( + method: OAuthOptions['kiroMethod'], + idcFlow: OAuthOptions['kiroIDCFlow'] +): boolean { + const normalizedMethod = normalizeKiroAuthMethod(method); + if (normalizedMethod === 'aws-authcode') { + return true; + } + return normalizedMethod === 'idc' && normalizeKiroIDCFlow(idcFlow) === 'authcode'; +} + /** * Handle paste-callback mode: show auth URL, prompt for callback paste * Uses proxy target resolver to connect to correct CLIProxyAPI instance (local or remote) @@ -581,10 +592,7 @@ export async function triggerOAuth( provider === 'kiro' ? isKiroDeviceCodeMethod(resolvedKiroMethod, { idcFlow: resolvedKiroIDCFlow }) : callbackPort === null; - const useKiroLocalPasteCallback = - options.pasteCallback === true && provider === 'kiro' && !isDeviceCodeFlow; - const useKiroDirectCliFlow = - provider === 'kiro' && (isDeviceCodeFlow || useKiroLocalPasteCallback); + let selectedPasteCallback = options.pasteCallback === true; if (provider === 'kiro' && !isKiroCLIAuthMethod(resolvedKiroMethod)) { console.log(fail(`Kiro auth method '${resolvedKiroMethod}' is not supported by CLI flow.`)); @@ -594,34 +602,25 @@ export async function triggerOAuth( // Interactive mode selection for headless environments // Skip if explicit mode flag provided or device code flow (no callback needed) - if (headless && !options.pasteCallback && !options.portForward && !isDeviceCodeFlow) { + if (headless && !selectedPasteCallback && !options.portForward && !isDeviceCodeFlow) { // Non-interactive environment (piped input) - default to paste mode if (!process.stdin.isTTY) { - const tokenDir = getProviderTokenDir(provider); - return handlePasteCallbackMode( - provider, - oauthConfig, - verbose, - tokenDir, - nickname, - existingNameMatch?.id - ); + selectedPasteCallback = true; + } else { + const mode = await promptOAuthModeChoice(callbackPort); + if (mode === 'paste') { + selectedPasteCallback = true; + } } - const mode = await promptOAuthModeChoice(callbackPort); - if (mode === 'paste') { - const tokenDir = getProviderTokenDir(provider); - return handlePasteCallbackMode( - provider, - oauthConfig, - verbose, - tokenDir, - nickname, - existingNameMatch?.id - ); - } - // mode === 'forward' continues to existing port-forwarding flow below } + const useSelectedKiroLocalPasteCallback = + selectedPasteCallback && + provider === 'kiro' && + usesKiroLocalCallbackReplay(resolvedKiroMethod, resolvedKiroIDCFlow); + const useSelectedKiroDirectCliFlow = + provider === 'kiro' && (isDeviceCodeFlow || useSelectedKiroLocalPasteCallback); + if (existingAccounts.length > 0 && !add) { console.log(''); console.log( @@ -636,7 +635,7 @@ export async function triggerOAuth( } } - if (options.pasteCallback && !useKiroDirectCliFlow) { + if (selectedPasteCallback && !useSelectedKiroDirectCliFlow) { const tokenDir = getProviderTokenDir(provider); return handlePasteCallbackMode( provider, @@ -671,7 +670,7 @@ export async function triggerOAuth( } } - const processHeadless = options.pasteCallback && provider === 'kiro' ? true : headless; + const processHeadless = selectedPasteCallback && provider === 'kiro' ? true : headless; let args: string[]; try { args = buildOAuthArgs(provider, configPath, processHeadless, noIncognito, { @@ -694,7 +693,7 @@ export async function triggerOAuth( showStep(2, 4, 'progress', `Starting callback server on port ${callbackPort}...`); // Show headless instructions (only for authorization code flows) - if (useKiroLocalPasteCallback) { + if (useSelectedKiroLocalPasteCallback) { console.log(''); console.log(info('Paste-callback mode enabled for Kiro CLI auth.')); console.log( @@ -728,7 +727,7 @@ export async function triggerOAuth( expectedAccountId: existingNameMatch?.id, authFlowType: isDeviceCodeFlow ? 'device_code' : 'authorization_code', kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined, - manualCallback: useKiroLocalPasteCallback, + manualCallback: useSelectedKiroLocalPasteCallback, }); // Show hint for Kiro users about --no-incognito option (first-time auth only) diff --git a/src/cliproxy/auth/oauth-process.ts b/src/cliproxy/auth/oauth-process.ts index 082dc8d7..ac566af2 100644 --- a/src/cliproxy/auth/oauth-process.ts +++ b/src/cliproxy/auth/oauth-process.ts @@ -71,6 +71,7 @@ interface ProcessState { userCode: string | null; kiroMethodSelectionHandled: boolean; manualCallbackPrompted: boolean; + cancelManualCallbackPrompt: (() => void) | null; } /** @@ -115,7 +116,86 @@ function resolveAuthFlowType(options: OAuthProcessOptions): 'device_code' | 'aut return options.authFlowType || OAUTH_FLOW_TYPES[options.provider] || 'authorization_code'; } -async function promptManualCallbackUrl(displayName: string): Promise { +export function isLoopbackHost(hostname: string): boolean { + const normalized = hostname.replace(/^\[|\]$/g, '').toLowerCase(); + return ( + normalized === '127.0.0.1' || + normalized === 'localhost' || + normalized === '::1' || + normalized === '0:0:0:0:0:0:0:1' + ); +} + +export function getExpectedLocalCallback(authUrl: string): { + origin: string; + pathname: string; + state: string | null; +} | null { + try { + const parsedAuthUrl = new URL(authUrl); + const redirectUriRaw = parsedAuthUrl.searchParams.get('redirect_uri'); + if (!redirectUriRaw) { + return null; + } + + const redirectUri = new URL(redirectUriRaw); + if (!isLoopbackHost(redirectUri.hostname)) { + return null; + } + + return { + origin: redirectUri.origin, + pathname: redirectUri.pathname, + state: parsedAuthUrl.searchParams.get('state'), + }; + } catch { + return null; + } +} + +export function validateManualCallbackUrl(callbackUrl: string, authUrl: string): string | null { + let parsedCallback: URL; + try { + parsedCallback = new URL(callbackUrl); + } catch { + return 'Invalid callback URL format'; + } + + if (!parsedCallback.searchParams.get('code')) { + return 'Invalid callback URL: missing code parameter'; + } + + const expectedCallback = getExpectedLocalCallback(authUrl); + if (!expectedCallback) { + return 'Unable to determine the expected local callback target'; + } + + if (!isLoopbackHost(parsedCallback.hostname)) { + return 'Callback URL must target the local OAuth callback server'; + } + + if ( + parsedCallback.origin !== expectedCallback.origin || + parsedCallback.pathname !== expectedCallback.pathname + ) { + return 'Callback URL does not match the expected local OAuth callback target'; + } + + if (expectedCallback.state) { + const callbackState = parsedCallback.searchParams.get('state'); + if (callbackState !== expectedCallback.state) { + return 'Callback URL state does not match the active OAuth session'; + } + } + + return null; +} + +async function promptManualCallbackUrl( + displayName: string, + state: ProcessState, + timeoutMs: number +): Promise { const readline = await import('readline'); const rl = readline.createInterface({ input: process.stdin, @@ -124,53 +204,71 @@ async function promptManualCallbackUrl(displayName: string): Promise((resolve) => { let settled = false; + let timeout: ReturnType | null = null; + + const finish = (value: string | null) => { + if (settled) { + return; + } + settled = true; + if (timeout) { + clearTimeout(timeout); + } + state.cancelManualCallbackPrompt = null; + resolve(value); + }; + + state.cancelManualCallbackPrompt = () => { + if (!settled) { + rl.close(); + finish(null); + } + }; rl.on('close', () => { - if (!settled) { - settled = true; - resolve(null); - } + finish(null); }); console.log(''); console.log(info(`${displayName} is waiting for the OAuth callback.`)); console.log('Paste the full callback URL after you finish the login in your browser.'); rl.question('> ', (answer) => { - settled = true; rl.close(); - resolve(answer.trim() || null); + finish(answer.trim() || null); }); + + timeout = setTimeout(() => { + if (!settled) { + console.log(''); + console.log(fail('Timed out waiting for callback URL')); + rl.close(); + } + }, timeoutMs); }); } async function replayManualCallback( oauthConfig: ProviderOAuthConfig, authProcess: ChildProcess, - output: string, - verbose: boolean + authUrl: string, + verbose: boolean, + state: ProcessState, + timeoutMs: number ): Promise { - if (!output.includes('http://') && !output.includes('https://')) { + if (!authUrl.includes('http://') && !authUrl.includes('https://')) { return false; } - const callbackUrl = await promptManualCallbackUrl(oauthConfig.displayName); + const callbackUrl = await promptManualCallbackUrl(oauthConfig.displayName, state, timeoutMs); if (!callbackUrl) { console.log(info('Cancelled')); killWithEscalation(authProcess); return true; } - let parsed: URL; - try { - parsed = new URL(callbackUrl); - } catch { - console.log(fail('Invalid callback URL format')); - killWithEscalation(authProcess); - return true; - } - - if (!parsed.searchParams.get('code')) { - console.log(fail('Invalid callback URL: missing code parameter')); + const validationError = validateManualCallbackUrl(callbackUrl, authUrl); + if (validationError) { + console.log(fail(validationError)); killWithEscalation(authProcess); return true; } @@ -302,7 +400,14 @@ async function handleStdout( if (options.manualCallback && !state.manualCallbackPrompted) { state.manualCallbackPrompted = true; - await replayManualCallback(options.oauthConfig, authProcess, urlMatch[0], options.verbose); + await replayManualCallback( + options.oauthConfig, + authProcess, + urlMatch[0], + options.verbose, + state, + 10 * 60 * 1000 + ); } } } @@ -535,6 +640,7 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise { + authProcess.stderr?.on('data', async (data: Buffer) => { const output = data.toString(); state.stderrData += output; log(`stderr: ${output.trim()}`); if (headless && !state.urlDisplayed) { displayUrlFromStderr(output, state, oauthConfig); } + if (options.manualCallback && !state.manualCallbackPrompted) { + const urlMatch = output.match(/https?:\/\/[^\s]+/); + if (urlMatch) { + state.manualCallbackPrompted = true; + await replayManualCallback( + options.oauthConfig, + authProcess, + urlMatch[0], + options.verbose, + state, + 10 * 60 * 1000 + ); + } + } }); // Show waiting message after delay @@ -611,10 +731,15 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise { // H7: Clear stdin keepalive interval if (stdinKeepalive) clearInterval(stdinKeepalive); + state.cancelManualCallbackPrompt?.(); // H5: Remove signal handlers before killing process process.removeListener('SIGINT', cleanup); process.removeListener('SIGTERM', cleanup); @@ -634,6 +759,7 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise arg.startsWith(inlinePrefix)); + if (inlineArg !== undefined) { + const value = inlineArg.slice(inlinePrefix.length).trim(); + return { + present: true, + value: value.length > 0 ? value : undefined, + missingValue: value.length === 0, + }; + } + + const index = args.indexOf(flag); + if (index === -1) { + return { present: false, missingValue: false }; + } + + const next = args[index + 1]; + if (!next || next.startsWith('-')) { + return { present: true, missingValue: true }; + } + + return { present: true, value: next.trim(), missingValue: false }; +} + /** * Execute Claude CLI with CLIProxy (main entry point) * @@ -346,10 +374,10 @@ export async function execClaudeWithCLIProxy( // Parse --kiro-auth-method flag let kiroAuthMethod: KiroAuthMethod | undefined; - const kiroMethodIdx = argsWithoutProxy.indexOf('--kiro-auth-method'); - if (kiroMethodIdx !== -1) { - const rawMethod = argsWithoutProxy[kiroMethodIdx + 1]; - if (!rawMethod || rawMethod.startsWith('-')) { + const kiroMethodValue = readOptionValue(argsWithoutProxy, '--kiro-auth-method'); + if (kiroMethodValue.present) { + const rawMethod = kiroMethodValue.value; + if (kiroMethodValue.missingValue || !rawMethod) { console.error(fail('--kiro-auth-method requires a value')); console.error(' Supported values: aws, aws-authcode, google, github, idc'); process.exitCode = 1; @@ -366,38 +394,30 @@ export async function execClaudeWithCLIProxy( } let kiroIDCStartUrl: string | undefined; - const kiroIDCStartUrlIdx = argsWithoutProxy.indexOf('--kiro-idc-start-url'); - if ( - kiroIDCStartUrlIdx !== -1 && - argsWithoutProxy[kiroIDCStartUrlIdx + 1] && - !argsWithoutProxy[kiroIDCStartUrlIdx + 1].startsWith('-') - ) { - kiroIDCStartUrl = argsWithoutProxy[kiroIDCStartUrlIdx + 1].trim(); - } else if (kiroIDCStartUrlIdx !== -1) { + const kiroIDCStartUrlValue = readOptionValue(argsWithoutProxy, '--kiro-idc-start-url'); + if (kiroIDCStartUrlValue.present && kiroIDCStartUrlValue.value) { + kiroIDCStartUrl = kiroIDCStartUrlValue.value; + } else if (kiroIDCStartUrlValue.present) { console.error(fail('--kiro-idc-start-url requires a value')); process.exitCode = 1; return; } let kiroIDCRegion: string | undefined; - const kiroIDCRegionIdx = argsWithoutProxy.indexOf('--kiro-idc-region'); - if ( - kiroIDCRegionIdx !== -1 && - argsWithoutProxy[kiroIDCRegionIdx + 1] && - !argsWithoutProxy[kiroIDCRegionIdx + 1].startsWith('-') - ) { - kiroIDCRegion = argsWithoutProxy[kiroIDCRegionIdx + 1].trim(); - } else if (kiroIDCRegionIdx !== -1) { + const kiroIDCRegionValue = readOptionValue(argsWithoutProxy, '--kiro-idc-region'); + if (kiroIDCRegionValue.present && kiroIDCRegionValue.value) { + kiroIDCRegion = kiroIDCRegionValue.value; + } else if (kiroIDCRegionValue.present) { console.error(fail('--kiro-idc-region requires a value')); process.exitCode = 1; return; } let kiroIDCFlow: KiroIDCFlow | undefined; - const kiroIDCFlowIdx = argsWithoutProxy.indexOf('--kiro-idc-flow'); - if (kiroIDCFlowIdx !== -1) { - const rawFlow = argsWithoutProxy[kiroIDCFlowIdx + 1]; - if (!rawFlow || rawFlow.startsWith('-')) { + const kiroIDCFlowValue = readOptionValue(argsWithoutProxy, '--kiro-idc-flow'); + if (kiroIDCFlowValue.present) { + const rawFlow = kiroIDCFlowValue.value; + if (kiroIDCFlowValue.missingValue || !rawFlow) { console.error(fail('--kiro-idc-flow requires a value')); console.error(' Supported values: authcode, device'); process.exitCode = 1; @@ -1174,6 +1194,10 @@ export async function execClaudeWithCLIProxy( ]; const claudeArgs = argsWithoutProxy.filter((arg, idx) => { if (ccsFlags.includes(arg)) return false; + if (arg.startsWith('--kiro-auth-method=')) return false; + if (arg.startsWith('--kiro-idc-start-url=')) return false; + if (arg.startsWith('--kiro-idc-region=')) return false; + if (arg.startsWith('--kiro-idc-flow=')) return false; if (arg.startsWith('--thinking=')) return false; if (arg.startsWith('--effort=')) return false; if (arg.startsWith('--1m=') || arg.startsWith('--no-1m=')) return false; diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index b06ee84b..09d542a2 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -45,8 +45,11 @@ import { CLIPROXY_CALLBACK_PROVIDER_MAP, CLIPROXY_AUTH_URL_PROVIDER_MAP, isKiroAuthMethod, + isKiroIDCFlow, isKiroDeviceCodeMethod, + KiroIDCFlow, KiroAuthMethod, + normalizeKiroIDCFlow, normalizeKiroAuthMethod, toKiroManagementMethod, } from '../../cliproxy/auth/auth-types'; @@ -256,6 +259,43 @@ function parseKiroMethod(raw: unknown): { method: KiroAuthMethod; invalid: boole return { method: normalizeKiroAuthMethod(normalized), invalid: false }; } +function parseKiroIDCFlow(raw: unknown): { flow: KiroIDCFlow; invalid: boolean } { + if (raw === undefined || raw === null || raw === '') { + return { flow: normalizeKiroIDCFlow(), invalid: false }; + } + if (typeof raw !== 'string') { + return { flow: normalizeKiroIDCFlow(), invalid: true }; + } + const normalized = raw.trim().toLowerCase(); + if (!isKiroIDCFlow(normalized)) { + return { flow: normalizeKiroIDCFlow(), invalid: true }; + } + return { flow: normalizeKiroIDCFlow(normalized), invalid: false }; +} + +export function getKiroStartIDCValidationError(options: { + kiroMethod: KiroAuthMethod; + kiroIDCStartUrl?: string; + invalidKiroIDCFlow?: boolean; +}): { error: string; code: string } | null { + if (options.kiroMethod !== 'idc') { + return null; + } + if (options.invalidKiroIDCFlow) { + return { + error: 'Invalid kiroIDCFlow. Supported: authcode, device', + code: 'INVALID_KIRO_IDC_FLOW', + }; + } + if (!options.kiroIDCStartUrl) { + return { + error: 'Kiro IDC login requires kiroIDCStartUrl', + code: 'MISSING_KIRO_IDC_START_URL', + }; + } + return null; +} + export function getStartUrlUnsupportedReason( provider: CLIProxyProvider, options?: { kiroMethod?: KiroAuthMethod } @@ -600,6 +640,13 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise { + it('parses split-token option values', () => { + expect(readOptionValue(['--kiro-idc-start-url', 'https://d-123.awsapps.com/start'], '--kiro-idc-start-url')).toEqual({ + present: true, + value: 'https://d-123.awsapps.com/start', + missingValue: false, + }); + }); + + it('parses equals-form option values', () => { + expect(readOptionValue(['--kiro-idc-flow=device'], '--kiro-idc-flow')).toEqual({ + present: true, + value: 'device', + missingValue: false, + }); + }); + + it('marks empty or missing values as invalid', () => { + expect(readOptionValue(['--kiro-idc-region'], '--kiro-idc-region')).toEqual({ + present: true, + value: undefined, + missingValue: true, + }); + expect(readOptionValue(['--kiro-idc-flow='], '--kiro-idc-flow')).toEqual({ + present: true, + value: undefined, + missingValue: true, + }); + }); +}); diff --git a/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts b/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts index 2e786038..983e94d1 100644 --- a/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts +++ b/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts @@ -76,6 +76,20 @@ describe('requestPasteCallbackStart', () => { }); }); +describe('usesKiroLocalCallbackReplay', () => { + it('limits local callback replay to CLI auth-code flows', async () => { + const { usesKiroLocalCallbackReplay } = await import( + `../../../src/cliproxy/auth/oauth-handler?kiro-local-callback-mode=${Date.now()}` + ); + + expect(usesKiroLocalCallbackReplay('aws-authcode', 'authcode')).toBe(true); + expect(usesKiroLocalCallbackReplay('idc', 'authcode')).toBe(true); + expect(usesKiroLocalCallbackReplay('idc', 'device')).toBe(false); + expect(usesKiroLocalCallbackReplay('google', 'authcode')).toBe(false); + expect(usesKiroLocalCallbackReplay('aws', 'authcode')).toBe(false); + }); +}); + describe('resolvePasteCallbackAuthUrl', () => { it('returns the immediate auth URL without polling', async () => { const { resolvePasteCallbackAuthUrl } = await import( diff --git a/tests/unit/cliproxy/oauth-process-error-parser.test.ts b/tests/unit/cliproxy/oauth-process-error-parser.test.ts index 84fe5331..6bb84821 100644 --- a/tests/unit/cliproxy/oauth-process-error-parser.test.ts +++ b/tests/unit/cliproxy/oauth-process-error-parser.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'bun:test'; -import { extractLikelyAuthFailureFromStderr } from '../../../src/cliproxy/auth/oauth-process'; +import { + extractLikelyAuthFailureFromStderr, + getExpectedLocalCallback, + validateManualCallbackUrl, +} from '../../../src/cliproxy/auth/oauth-process'; describe('oauth-process stderr parsing', () => { it('ignores non-ghcp providers', () => { @@ -33,3 +37,50 @@ describe('oauth-process stderr parsing', () => { expect((parsed as string).length).toBe(240); }); }); + +describe('oauth-process manual callback validation', () => { + const authUrl = + 'https://oidc.example.com/authorize?redirect_uri=http%3A%2F%2F127.0.0.1%3A9876%2Foauth%2Fcallback&state=test-state'; + + it('extracts the expected local callback target from the auth URL', () => { + expect(getExpectedLocalCallback(authUrl)).toEqual({ + origin: 'http://127.0.0.1:9876', + pathname: '/oauth/callback', + state: 'test-state', + }); + }); + + it('accepts matching loopback callback URLs', () => { + expect( + validateManualCallbackUrl( + 'http://127.0.0.1:9876/oauth/callback?code=abc123&state=test-state', + authUrl + ) + ).toBeNull(); + }); + + it('rejects non-loopback callback URLs', () => { + expect( + validateManualCallbackUrl( + 'https://evil.example.com/oauth/callback?code=abc123&state=test-state', + authUrl + ) + ).toContain('local OAuth callback server'); + }); + + it('rejects callback URLs with the wrong path or state', () => { + expect( + validateManualCallbackUrl( + 'http://127.0.0.1:9876/not-the-callback?code=abc123&state=test-state', + authUrl + ) + ).toContain('expected local OAuth callback target'); + + expect( + validateManualCallbackUrl( + 'http://127.0.0.1:9876/oauth/callback?code=abc123&state=wrong-state', + authUrl + ) + ).toContain('state does not match'); + }); +}); diff --git a/tests/unit/web-server/cliproxy-auth-routes.test.ts b/tests/unit/web-server/cliproxy-auth-routes.test.ts index 16cba29a..703c2870 100644 --- a/tests/unit/web-server/cliproxy-auth-routes.test.ts +++ b/tests/unit/web-server/cliproxy-auth-routes.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'bun:test'; import { + getKiroStartIDCValidationError, getStartAuthFailureMessage, getStartAuthNicknameError, getStartUrlUnsupportedReason, @@ -38,6 +39,44 @@ describe('cliproxy-auth-routes start-url guard', () => { }); }); +describe('cliproxy-auth-routes Kiro IDC start validation', () => { + it('requires an IDC start URL when idc auth is selected', () => { + expect( + getKiroStartIDCValidationError({ + kiroMethod: 'idc', + kiroIDCStartUrl: undefined, + invalidKiroIDCFlow: false, + }) + ).toEqual({ + error: 'Kiro IDC login requires kiroIDCStartUrl', + code: 'MISSING_KIRO_IDC_START_URL', + }); + }); + + it('rejects invalid IDC flow values before triggerOAuth is called', () => { + expect( + getKiroStartIDCValidationError({ + kiroMethod: 'idc', + kiroIDCStartUrl: 'https://d-123.awsapps.com/start', + invalidKiroIDCFlow: true, + }) + ).toEqual({ + error: 'Invalid kiroIDCFlow. Supported: authcode, device', + code: 'INVALID_KIRO_IDC_FLOW', + }); + }); + + it('allows valid IDC start payloads through', () => { + expect( + getKiroStartIDCValidationError({ + kiroMethod: 'idc', + kiroIDCStartUrl: 'https://d-123.awsapps.com/start', + invalidKiroIDCFlow: false, + }) + ).toBeNull(); + }); +}); + describe('cliproxy-auth-routes start failure messaging', () => { it('returns ghcp-specific guidance for Copilot verification failures', () => { expect(getStartAuthFailureMessage('ghcp')).toContain(