From 0fbea0f33557b17d2e4fd3ef79d1a6230672d295 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 5 Apr 2026 01:30:06 -0400 Subject: [PATCH 1/7] fix(kiro): align auth flows with CLIProxyAPIPlus - auto-select Builder ID for the default Kiro AWS auth flow - support IDC auth flags and callback-based Kiro paste replay - update regression coverage for Kiro auth routing --- src/ccs.ts | 3 + src/cliproxy/auth/auth-types.ts | 93 ++++++++-- src/cliproxy/auth/oauth-handler.ts | 160 +++++++++++++----- src/cliproxy/auth/oauth-process.ts | 129 +++++++++++++- src/cliproxy/executor/index.ts | 117 ++++++++++++- src/commands/completion-backend.ts | 10 +- src/web-server/routes/cliproxy-auth-routes.ts | 12 +- .../auth-types-management-path.test.ts | 14 +- .../oauth-handler-paste-callback.test.ts | 27 ++- .../cliproxy/provider-capabilities.test.ts | 12 ++ .../web-server/cliproxy-auth-routes.test.ts | 6 + 11 files changed, 501 insertions(+), 82 deletions(-) diff --git a/src/ccs.ts b/src/ccs.ts index 2806235f..f2ff905a 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -757,6 +757,9 @@ async function main(): Promise { '--port-forward', '--nickname', '--kiro-auth-method', + '--kiro-idc-start-url', + '--kiro-idc-region', + '--kiro-idc-flow', '--backend', '--proxy-host', '--proxy-port', diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts index dd8ad3e6..95e44980 100644 --- a/src/cliproxy/auth/auth-types.ts +++ b/src/cliproxy/auth/auth-types.ts @@ -22,14 +22,19 @@ import { * - aws-authcode: AWS Builder ID via Authorization Code flow (CLI flag only) * - google: Social OAuth via Google * - github: Social OAuth via GitHub (management API only) + * - idc: IAM Identity Center (IDC) via CLI flags with start URL + region */ -export const KIRO_AUTH_METHODS = ['aws', 'aws-authcode', 'google', 'github'] as const; +export const KIRO_AUTH_METHODS = ['aws', 'aws-authcode', 'google', 'github', 'idc'] as const; export type KiroAuthMethod = (typeof KIRO_AUTH_METHODS)[number]; /** CLI binary supports these Kiro methods directly via flags. */ -export const KIRO_CLI_AUTH_METHODS = ['aws', 'aws-authcode', 'google'] as const; +export const KIRO_CLI_AUTH_METHODS = ['aws', 'aws-authcode', 'google', 'idc'] as const; export type KiroCLIAuthMethod = (typeof KIRO_CLI_AUTH_METHODS)[number]; +export const KIRO_IDC_FLOWS = ['authcode', 'device'] as const; +export type KiroIDCFlow = (typeof KIRO_IDC_FLOWS)[number]; +export const DEFAULT_KIRO_IDC_FLOW: KiroIDCFlow = 'authcode'; + /** Default Kiro method for CCS UX and AWS Organization support. */ export const DEFAULT_KIRO_AUTH_METHOD: KiroAuthMethod = 'aws'; @@ -41,18 +46,40 @@ export function isKiroCLIAuthMethod(value: string): value is KiroCLIAuthMethod { return KIRO_CLI_AUTH_METHODS.includes(value as KiroCLIAuthMethod); } +export function isKiroIDCFlow(value: string): value is KiroIDCFlow { + return KIRO_IDC_FLOWS.includes(value as KiroIDCFlow); +} + export function normalizeKiroAuthMethod(value?: string): KiroAuthMethod { if (!value) return DEFAULT_KIRO_AUTH_METHOD; const normalized = value.trim().toLowerCase(); return isKiroAuthMethod(normalized) ? normalized : DEFAULT_KIRO_AUTH_METHOD; } -export function isKiroDeviceCodeMethod(method: KiroAuthMethod): boolean { - return method === 'aws'; +export function normalizeKiroIDCFlow(value?: string): KiroIDCFlow { + if (!value) return DEFAULT_KIRO_IDC_FLOW; + const normalized = value.trim().toLowerCase(); + return isKiroIDCFlow(normalized) ? normalized : DEFAULT_KIRO_IDC_FLOW; } -export function getKiroCallbackPort(method: KiroAuthMethod): number | null { - return isKiroDeviceCodeMethod(method) ? null : 9876; +export function isKiroDeviceCodeMethod( + method: KiroAuthMethod, + options?: { idcFlow?: KiroIDCFlow } +): boolean { + if (method === 'aws') { + return true; + } + if (method === 'idc') { + return normalizeKiroIDCFlow(options?.idcFlow) === 'device'; + } + return false; +} + +export function getKiroCallbackPort( + method: KiroAuthMethod, + options?: { idcFlow?: KiroIDCFlow } +): number | null { + return isKiroDeviceCodeMethod(method, options) ? null : 9876; } export function getKiroCLIAuthFlag(method: KiroCLIAuthMethod): string { @@ -63,19 +90,49 @@ export function getKiroCLIAuthFlag(method: KiroCLIAuthMethod): string { return '--kiro-aws-authcode'; case 'google': return '--kiro-google-login'; + case 'idc': + return '--kiro-idc-login'; } } +export function getKiroCLIAuthArgs( + method: KiroCLIAuthMethod, + options?: { + idcStartUrl?: string; + idcRegion?: string; + idcFlow?: KiroIDCFlow; + } +): string[] { + if (method !== 'idc') { + return [getKiroCLIAuthFlag(method)]; + } + + const startUrl = options?.idcStartUrl?.trim(); + if (!startUrl) { + throw new Error('Kiro IDC login requires --kiro-idc-start-url'); + } + + const args = [getKiroCLIAuthFlag('idc'), '--kiro-idc-start-url', startUrl]; + const region = options?.idcRegion?.trim(); + if (region) { + args.push('--kiro-idc-region', region); + } + args.push('--kiro-idc-flow', normalizeKiroIDCFlow(options?.idcFlow)); + return args; +} + /** * Kiro method for CLIProxyAPI management endpoint: * GET /v0/management/kiro-auth-url?method= */ -export function toKiroManagementMethod(method: KiroAuthMethod): 'aws' | 'google' | 'github' { +export function toKiroManagementMethod(method: KiroAuthMethod): 'aws' | 'google' | 'github' | null { switch (method) { case 'google': return 'google'; case 'github': return 'github'; + case 'idc': + return null; case 'aws-authcode': return 'aws'; case 'aws': @@ -258,10 +315,20 @@ export function getManagementAuthUrlPath(provider: CLIProxyProvider): string { return `/v0/management/${authUrlProvider}-auth-url?is_webui=true`; } -export function getPasteCallbackStartPath(provider: CLIProxyProvider): string { - // Kiro CLI auth methods still use the legacy start route. +export function getPasteCallbackStartPath( + provider: CLIProxyProvider, + options?: { kiroMethod?: KiroAuthMethod } +): string | null { if (provider === 'kiro') { - return `/oauth/${provider}/start`; + const kiroMethod = options?.kiroMethod ?? normalizeKiroAuthMethod(); + if (kiroMethod === 'aws-authcode' || kiroMethod === 'idc') { + return null; + } + const managementMethod = toKiroManagementMethod(kiroMethod); + if (!managementMethod) { + return null; + } + return `${getManagementAuthUrlPath(provider)}&method=${encodeURIComponent(managementMethod)}`; } return getManagementAuthUrlPath(provider); } @@ -294,6 +361,12 @@ export interface OAuthOptions { acceptAgyRisk?: boolean; /** Kiro auth method override (CLI + Dashboard parity). */ kiroMethod?: KiroAuthMethod; + /** Kiro IDC start URL (required when kiroMethod=idc). */ + kiroIDCStartUrl?: string; + /** Kiro IDC region override. */ + kiroIDCRegion?: string; + /** Kiro IDC flow override (authcode or device). */ + kiroIDCFlow?: KiroIDCFlow; /** If true, triggered from Web UI (enables project selection prompt) */ fromUI?: boolean; /** If true, use --no-incognito flag (Kiro only - use normal browser instead of incognito) */ diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index b30baf09..94051711 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -32,8 +32,9 @@ import { import { OAuthOptions, DEFAULT_KIRO_AUTH_METHOD, + DEFAULT_KIRO_IDC_FLOW, getKiroCallbackPort, - getKiroCLIAuthFlag, + getKiroCLIAuthArgs, isKiroCLIAuthMethod, isKiroDeviceCodeMethod, getOAuthConfig, @@ -42,6 +43,7 @@ import { getPasteCallbackStartPath, getManagementOAuthCallbackPath, normalizeKiroAuthMethod, + normalizeKiroIDCFlow, } from './auth-types'; import { isHeadlessEnvironment, killProcessOnPort, showStep } from './environment-detector'; import { getProviderTokenDir, isAuthenticated, registerAccountFromToken } from './token-manager'; @@ -72,15 +74,19 @@ const PASTE_CALLBACK_AUTH_URL_POLL_INTERVAL_MS = 3000; export async function requestPasteCallbackStart( provider: CLIProxyProvider, - target: ProxyTarget + target: ProxyTarget, + options?: { kiroMethod?: OAuthOptions['kiroMethod'] } ): Promise { - const startPath = getPasteCallbackStartPath(provider); + const startPath = getPasteCallbackStartPath(provider, { + kiroMethod: options?.kiroMethod, + }); + if (!startPath) { + throw new Error( + `Paste-callback start is not available for ${provider} with the selected method` + ); + } const response = await fetch(buildProxyUrl(target, startPath), { - ...(provider === 'kiro' ? { method: 'POST' } : {}), - headers: - provider === 'kiro' - ? buildManagementHeaders(target, { 'Content-Type': 'application/json' }) - : buildManagementHeaders(target), + headers: buildManagementHeaders(target), }); if (!response.ok) { @@ -297,6 +303,46 @@ async function prepareBinary( } } +function buildOAuthArgs( + provider: CLIProxyProvider, + configPath: string, + headless: boolean, + noIncognito: boolean, + options: { + kiroMethod?: OAuthOptions['kiroMethod']; + kiroIDCStartUrl?: string; + kiroIDCRegion?: string; + kiroIDCFlow?: OAuthOptions['kiroIDCFlow']; + } = {} +): string[] { + const args = ['--config', configPath]; + + if (provider === 'kiro') { + const method = normalizeKiroAuthMethod(options.kiroMethod); + if (!isKiroCLIAuthMethod(method)) { + throw new Error(`Kiro auth method '${method}' is not supported by CLI flow.`); + } + args.push( + ...getKiroCLIAuthArgs(method, { + idcStartUrl: options.kiroIDCStartUrl, + idcRegion: options.kiroIDCRegion, + idcFlow: options.kiroIDCFlow, + }) + ); + } else { + args.push(getOAuthConfig(provider).authFlag); + } + + if (headless) { + args.push('--no-browser'); + } + if (provider === 'kiro' && noIncognito) { + args.push('--no-incognito'); + } + + return args; +} + /** * Handle paste-callback mode: show auth URL, prompt for callback paste * Uses proxy target resolver to connect to correct CLIProxyAPI instance (local or remote) @@ -307,7 +353,8 @@ async function handlePasteCallbackMode( verbose: boolean, tokenDir: string, nickname?: string, - expectedAccountId?: string + expectedAccountId?: string, + options?: { kiroMethod?: OAuthOptions['kiroMethod'] } ): Promise { // Resolve CLIProxyAPI target (local or remote based on config) const target = getProxyTarget(); @@ -318,12 +365,13 @@ async function handlePasteCallbackMode( console.log(info(`Starting ${oauthConfig.displayName} OAuth (paste-callback mode)...`)); try { - // Request auth URL from CLIProxyAPI. - // Kiro keeps its legacy start route because CLI auth methods do not share the generic - // management auth-url contract used by providers like Claude. + // Request auth URL from CLIProxyAPI management endpoints when the selected + // provider/method supports the manual start-url contract. let startData: PasteCallbackStartData; try { - startData = await requestPasteCallbackStart(provider, target); + startData = await requestPasteCallbackStart(provider, target, { + kiroMethod: options?.kiroMethod, + }); } catch (error) { const startError = (error as Error).message; console.log(fail('Failed to start OAuth flow')); @@ -475,6 +523,8 @@ export async function triggerOAuth( const { nickname } = options; const resolvedKiroMethod = provider === 'kiro' ? normalizeKiroAuthMethod(options.kiroMethod) : DEFAULT_KIRO_AUTH_METHOD; + const resolvedKiroIDCFlow = + provider === 'kiro' ? normalizeKiroIDCFlow(options.kiroIDCFlow) : DEFAULT_KIRO_IDC_FLOW; if (provider === 'agy') { if (fromUI && !acceptAgyRisk) { @@ -505,19 +555,6 @@ export async function triggerOAuth( return null; } - // Handle paste-callback mode - if (options.pasteCallback) { - const tokenDir = getProviderTokenDir(provider); - return handlePasteCallbackMode( - provider, - oauthConfig, - verbose, - tokenDir, - nickname, - existingNameMatch?.id - ); - } - // Handle --import flag: skip OAuth and import from Kiro IDE directly if (options.import && provider === 'kiro') { const tokenDir = getProviderTokenDir(provider); @@ -535,20 +572,24 @@ export async function triggerOAuth( } const callbackPort = - provider === 'kiro' ? getKiroCallbackPort(resolvedKiroMethod) : OAUTH_PORTS[provider]; + provider === 'kiro' + ? getKiroCallbackPort(resolvedKiroMethod, { idcFlow: resolvedKiroIDCFlow }) + : OAUTH_PORTS[provider]; const isCLI = !fromUI; const headless = options.headless ?? isHeadlessEnvironment(); const isDeviceCodeFlow = - provider === 'kiro' ? isKiroDeviceCodeMethod(resolvedKiroMethod) : callbackPort === null; + provider === 'kiro' + ? isKiroDeviceCodeMethod(resolvedKiroMethod, { idcFlow: resolvedKiroIDCFlow }) + : callbackPort === null; + const useKiroLocalPasteCallback = + options.pasteCallback === true && provider === 'kiro' && !isDeviceCodeFlow; + const useKiroDirectCliFlow = + provider === 'kiro' && (isDeviceCodeFlow || useKiroLocalPasteCallback); - let authFlag = oauthConfig.authFlag; - if (provider === 'kiro') { - if (!isKiroCLIAuthMethod(resolvedKiroMethod)) { - console.log(fail(`Kiro auth method '${resolvedKiroMethod}' is not supported by CLI flow.`)); - console.log(' Use Dashboard management OAuth for this method.'); - return null; - } - authFlag = getKiroCLIAuthFlag(resolvedKiroMethod); + if (provider === 'kiro' && !isKiroCLIAuthMethod(resolvedKiroMethod)) { + console.log(fail(`Kiro auth method '${resolvedKiroMethod}' is not supported by CLI flow.`)); + console.log(' Use Dashboard management OAuth for this method.'); + return null; } // Interactive mode selection for headless environments @@ -595,6 +636,19 @@ export async function triggerOAuth( } } + if (options.pasteCallback && !useKiroDirectCliFlow) { + const tokenDir = getProviderTokenDir(provider); + return handlePasteCallbackMode( + provider, + oauthConfig, + verbose, + tokenDir, + nickname, + existingNameMatch?.id, + { kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined } + ); + } + // Pre-flight checks (skip for device code flows which don't need callback ports) if (!isDeviceCodeFlow && !(await runPreflightChecks(provider, oauthConfig))) { return null; @@ -617,14 +671,18 @@ export async function triggerOAuth( } } - // Build args - const args = ['--config', configPath, authFlag]; - if (headless) { - args.push('--no-browser'); - } - // Kiro-specific: --no-incognito to use normal browser (saves login credentials) - if (provider === 'kiro' && noIncognito) { - args.push('--no-incognito'); + const processHeadless = options.pasteCallback && provider === 'kiro' ? true : headless; + let args: string[]; + try { + args = buildOAuthArgs(provider, configPath, processHeadless, noIncognito, { + kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined, + kiroIDCStartUrl: options.kiroIDCStartUrl, + kiroIDCRegion: options.kiroIDCRegion, + kiroIDCFlow: provider === 'kiro' ? resolvedKiroIDCFlow : undefined, + }); + } catch (error) { + console.log(fail((error as Error).message)); + return null; } // Show step based on flow type @@ -636,7 +694,14 @@ export async function triggerOAuth( showStep(2, 4, 'progress', `Starting callback server on port ${callbackPort}...`); // Show headless instructions (only for authorization code flows) - if (headless) { + if (useKiroLocalPasteCallback) { + console.log(''); + console.log(info('Paste-callback mode enabled for Kiro CLI auth.')); + console.log( + ' CCS will print the authorization URL and wait for you to paste the final callback URL.' + ); + console.log(''); + } else if (headless) { console.log(''); console.log(warn('PORT FORWARDING REQUIRED')); console.log(` OAuth callback uses localhost:${callbackPort} which must be reachable.`); @@ -656,11 +721,14 @@ export async function triggerOAuth( tokenDir, oauthConfig, callbackPort, - headless, + headless: processHeadless, verbose, isCLI, nickname, expectedAccountId: existingNameMatch?.id, + authFlowType: isDeviceCodeFlow ? 'device_code' : 'authorization_code', + kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined, + manualCallback: useKiroLocalPasteCallback, }); // 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 8fcdfa60..082dc8d7 100644 --- a/src/cliproxy/auth/oauth-process.ts +++ b/src/cliproxy/auth/oauth-process.ts @@ -22,7 +22,7 @@ import { type GCloudProject, type ProjectSelectionPrompt, } from '../project-selection-handler'; -import { ProviderOAuthConfig } from './auth-types'; +import { KiroAuthMethod, ProviderOAuthConfig } from './auth-types'; import { getTimeoutTroubleshooting, showStep } from './environment-detector'; import { isAuthenticated, registerAccountFromToken } from './token-manager'; import { @@ -51,6 +51,9 @@ export interface OAuthProcessOptions { isCLI: boolean; nickname?: string; expectedAccountId?: string; + authFlowType?: 'device_code' | 'authorization_code'; + kiroMethod?: KiroAuthMethod; + manualCallback?: boolean; } /** Internal state for OAuth process */ @@ -66,6 +69,8 @@ interface ProcessState { deviceCodeDisplayed: boolean; /** The user code to enter at verification URL */ userCode: string | null; + kiroMethodSelectionHandled: boolean; + manualCallbackPrompted: boolean; } /** @@ -106,6 +111,92 @@ async function handleProjectSelection( } } +function resolveAuthFlowType(options: OAuthProcessOptions): 'device_code' | 'authorization_code' { + return options.authFlowType || OAUTH_FLOW_TYPES[options.provider] || 'authorization_code'; +} + +async function promptManualCallbackUrl(displayName: string): Promise { + const readline = await import('readline'); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + let settled = false; + + rl.on('close', () => { + if (!settled) { + settled = true; + resolve(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); + }); + }); +} + +async function replayManualCallback( + oauthConfig: ProviderOAuthConfig, + authProcess: ChildProcess, + output: string, + verbose: boolean +): Promise { + if (!output.includes('http://') && !output.includes('https://')) { + return false; + } + + const callbackUrl = await promptManualCallbackUrl(oauthConfig.displayName); + 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')); + killWithEscalation(authProcess); + return true; + } + + console.log(info('Replaying callback to the local auth server...')); + + try { + const response = await fetch(callbackUrl); + if (!response.ok && response.status >= 400) { + console.log(fail(`OAuth callback failed with status ${response.status}`)); + killWithEscalation(authProcess); + return true; + } + console.log(ok('Callback submitted. Waiting for token exchange...')); + } catch (error) { + if (verbose) { + console.log(fail(`Failed to replay callback: ${(error as Error).message}`)); + } else { + console.log(fail('Failed to replay callback to the local auth server')); + } + killWithEscalation(authProcess); + } + + return true; +} + /** * Handle stdout data from OAuth process */ @@ -119,10 +210,20 @@ async function handleStdout( log(`stdout: ${output.trim()}`); state.accumulatedOutput += output; - // H4: Use explicit flow type from OAUTH_FLOW_TYPES instead of null port check - const flowType = OAUTH_FLOW_TYPES[options.provider] || 'authorization_code'; + const flowType = resolveAuthFlowType(options); const isDeviceCodeFlow = flowType === 'device_code'; + if ( + options.provider === 'kiro' && + options.kiroMethod === 'aws' && + !state.kiroMethodSelectionHandled && + state.accumulatedOutput.includes('Select login method') + ) { + state.kiroMethodSelectionHandled = true; + authProcess.stdin?.write('1\n'); + log('Auto-selected Kiro Builder ID flow'); + } + // Parse project list when available if (isProjectList(state.accumulatedOutput) && state.parsedProjects.length === 0) { state.parsedProjects = parseProjectList(state.accumulatedOutput); @@ -198,6 +299,11 @@ async function handleStdout( console.log(` ${urlMatch[0]}`); console.log(''); state.urlDisplayed = true; + + if (options.manualCallback && !state.manualCallbackPrompted) { + state.manualCallbackPrompted = true; + await replayManualCallback(options.oauthConfig, authProcess, urlMatch[0], options.verbose); + } } } } @@ -386,14 +492,17 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise((resolve) => { - // H4: Use explicit flow type from OAUTH_FLOW_TYPES instead of null port check - const flowType = OAUTH_FLOW_TYPES[provider] || 'authorization_code'; + const flowType = resolveAuthFlowType(options); const isDeviceCodeFlow = flowType === 'device_code'; - // H6: TTY detection - only inherit stdin if TTY available (prevents issues in CI/piped scripts) - // Device Code flows may need interactive stdin for email/prompts - // Authorization Code flows need piped stdin for project selection - const stdinMode = isDeviceCodeFlow && process.stdin.isTTY ? 'inherit' : 'pipe'; + // Device-code flows can usually inherit stdin, but Kiro's default AWS flow now + // prints an intermediate Builder ID vs IDC selector that CCS auto-answers. + const stdinMode = + isDeviceCodeFlow && + process.stdin.isTTY && + !(provider === 'kiro' && options.kiroMethod === 'aws') + ? 'inherit' + : 'pipe'; const authProcess = spawn(binaryPath, args, { stdio: [stdinMode, 'pipe', 'pipe'], @@ -424,6 +533,8 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise { expect(getPasteCallbackStartPath('ghcp')).toBe('/v0/management/github-auth-url?is_webui=true'); }); - it('keeps Kiro on the legacy start route for paste-callback mode', () => { - expect(getPasteCallbackStartPath('kiro')).toBe('/oauth/kiro/start'); + it('maps Kiro management-supported methods to the management auth-url route', () => { + expect(getPasteCallbackStartPath('kiro')).toBe( + '/v0/management/kiro-auth-url?is_webui=true&method=aws' + ); + expect(getPasteCallbackStartPath('kiro', { kiroMethod: 'google' })).toBe( + '/v0/management/kiro-auth-url?is_webui=true&method=google' + ); + }); + + it('returns null for Kiro CLI-only paste-callback modes', () => { + expect(getPasteCallbackStartPath('kiro', { kiroMethod: 'aws-authcode' })).toBeNull(); + expect(getPasteCallbackStartPath('kiro', { kiroMethod: 'idc' })).toBeNull(); }); it('still exposes the generic management auth-url helper', () => { diff --git a/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts b/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts index 9ad75161..2e786038 100644 --- a/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts +++ b/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts @@ -39,11 +39,10 @@ describe('requestPasteCallbackStart', () => { expect(request.headers['Content-Type']).toBeUndefined(); }); - it('keeps kiro on the legacy start route with POST', async () => { + it('uses the Kiro management auth-url route for paste-callback compatible methods', async () => { mockFetch([ { - url: /\/oauth\/kiro\/start$/, - method: 'POST', + url: /\/v0\/management\/kiro-auth-url\?is_webui=true&method=aws$/, response: { auth_url: 'https://auth.example.com/kiro' }, }, ]); @@ -51,15 +50,29 @@ describe('requestPasteCallbackStart', () => { const { requestPasteCallbackStart } = await import( `../../../src/cliproxy/auth/oauth-handler?request-kiro-start=${Date.now()}` ); - const startData = await requestPasteCallbackStart('kiro', remoteTarget); + const startData = await requestPasteCallbackStart('kiro', remoteTarget, { + kiroMethod: 'aws', + }); expect(startData.auth_url).toBe('https://auth.example.com/kiro'); const [request] = getCapturedFetchRequests(); - expect(request.url).toBe('https://proxy.example.com:8317/oauth/kiro/start'); - expect(request.method).toBe('POST'); + expect(request.url).toBe( + 'https://proxy.example.com:8317/v0/management/kiro-auth-url?is_webui=true&method=aws' + ); + expect(request.method).toBe('GET'); expect(request.headers['Authorization']).toBe('Bearer test-mgmt-key'); - expect(request.headers['Content-Type']).toBe('application/json'); + expect(request.headers['Content-Type']).toBeUndefined(); + }); + + it('throws for Kiro methods that require the local callback server flow', async () => { + const { requestPasteCallbackStart } = await import( + `../../../src/cliproxy/auth/oauth-handler?request-kiro-authcode-start=${Date.now()}` + ); + + await expect( + requestPasteCallbackStart('kiro', remoteTarget, { kiroMethod: 'aws-authcode' }) + ).rejects.toThrow(/paste-callback start is not available/i); }); }); diff --git a/tests/unit/cliproxy/provider-capabilities.test.ts b/tests/unit/cliproxy/provider-capabilities.test.ts index e77cfc63..d6f1d565 100644 --- a/tests/unit/cliproxy/provider-capabilities.test.ts +++ b/tests/unit/cliproxy/provider-capabilities.test.ts @@ -21,7 +21,9 @@ import { import { DEFAULT_KIRO_AUTH_METHOD, getKiroCallbackPort, + getKiroCLIAuthArgs, getKiroCLIAuthFlag, + normalizeKiroIDCFlow, normalizeKiroAuthMethod, OAUTH_CALLBACK_PORTS as AUTH_CALLBACK_PORTS, toKiroManagementMethod, @@ -136,20 +138,30 @@ describe('provider-capabilities', () => { expect(DEFAULT_KIRO_AUTH_METHOD).toBe('aws'); expect(normalizeKiroAuthMethod()).toBe('aws'); expect(normalizeKiroAuthMethod('GOOGLE')).toBe('google'); + expect(normalizeKiroAuthMethod('IDC')).toBe('idc'); expect(normalizeKiroAuthMethod('not-valid')).toBe('aws'); + expect(normalizeKiroIDCFlow()).toBe('authcode'); + expect(normalizeKiroIDCFlow('DEVICE')).toBe('device'); expect(getKiroCLIAuthFlag('aws')).toBe('--kiro-aws-login'); expect(getKiroCLIAuthFlag('aws-authcode')).toBe('--kiro-aws-authcode'); expect(getKiroCLIAuthFlag('google')).toBe('--kiro-google-login'); + expect(getKiroCLIAuthFlag('idc')).toBe('--kiro-idc-login'); + expect(getKiroCLIAuthArgs('idc', { idcStartUrl: 'https://d-123.awsapps.com/start' })).toEqual( + ['--kiro-idc-login', '--kiro-idc-start-url', 'https://d-123.awsapps.com/start', '--kiro-idc-flow', 'authcode'] + ); expect(getKiroCallbackPort('aws')).toBeNull(); expect(getKiroCallbackPort('google')).toBe(9876); expect(getKiroCallbackPort('github')).toBe(9876); expect(getKiroCallbackPort('aws-authcode')).toBe(9876); + expect(getKiroCallbackPort('idc')).toBe(9876); + expect(getKiroCallbackPort('idc', { idcFlow: 'device' })).toBeNull(); expect(toKiroManagementMethod('aws')).toBe('aws'); expect(toKiroManagementMethod('aws-authcode')).toBe('aws'); expect(toKiroManagementMethod('google')).toBe('google'); expect(toKiroManagementMethod('github')).toBe('github'); + expect(toKiroManagementMethod('idc')).toBeNull(); }); }); diff --git a/tests/unit/web-server/cliproxy-auth-routes.test.ts b/tests/unit/web-server/cliproxy-auth-routes.test.ts index ec2ce8d3..16cba29a 100644 --- a/tests/unit/web-server/cliproxy-auth-routes.test.ts +++ b/tests/unit/web-server/cliproxy-auth-routes.test.ts @@ -25,6 +25,12 @@ describe('cliproxy-auth-routes start-url guard', () => { ); }); + it('rejects Kiro idc method on start-url', () => { + expect(getStartUrlUnsupportedReason('kiro', { kiroMethod: 'idc' })).toContain( + "Kiro method 'idc' uses CLI auth flow" + ); + }); + it('allows authorization code providers', () => { expect(getStartUrlUnsupportedReason('gemini')).toBeNull(); expect(getStartUrlUnsupportedReason('codex')).toBeNull(); From bb7dbd108d63cbd762e2df626ca59519f3645ec7 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 5 Apr 2026 01:30:29 -0400 Subject: [PATCH 2/7] docs(kiro): document idc and callback auth flows - add README coverage for IDC flags and Kiro paste-callback behavior - record the Kiro auth fixes in the project roadmap --- docs/project-roadmap.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 0594ca32..50be434f 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -1,6 +1,6 @@ # CCS Project Roadmap -Last Updated: 2026-04-04 +Last Updated: 2026-04-05 Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. @@ -42,6 +42,7 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes - **2026-04-04**: The GitHub README was reduced from a wall-of-text reference dump into a shorter conversion surface that keeps the hero, proof screenshots, and fast-start commands while delegating deeper installation, provider, feature, and CLI-reference content to `docs.ccs.kaitran.ca`. The docs site now includes a dedicated `Product Tour` page for the screenshot-led walkthrough. +- **2026-04-05**: **#912 #913 #914** Kiro auth is now aligned with the current CLIProxyAPIPlus contract. CCS auto-selects the Builder ID path for the default `ccs kiro --auth` flow instead of stalling on the upstream Builder ID vs IDC chooser, callback-based Kiro auth methods can use `--paste-callback` by replaying the pasted redirect URL back into the local callback server, and the CLI now supports IDC auth via `--kiro-auth-method idc` plus `--kiro-idc-start-url`, `--kiro-idc-region`, and `--kiro-idc-flow`. - **2026-04-03**: CCS CLI help and completion UX was refreshed. Root help is now shorter and task-oriented, `ccs help ` routes to topic-aware help, and shell completions now delegate to the hidden `ccs __complete` backend. - **2026-04-02**: Third-party image and PDF analysis now follows the same first-class local-tool model as WebSearch. CCS provisions `ccs-image-analysis` as a managed MCP tool, routes requests directly to provider-scoped CCS endpoints such as `/api/provider/agy/v1/messages`, keeps editable prompt templates under `~/.ccs/prompts/image-analysis/`, and demotes the old `Read` hook to a best-effort compatibility fallback. Launches now stay non-fatal and fall back to native `Read` when the managed runtime cannot be prepared. - **2026-04-01**: The `Compatible -> Codex CLI` dashboard now exposes manual long-context controls for `model_context_window` and `model_auto_compact_token_limit`. CCS reads and patches those upstream Codex config keys directly, adds official guidance that GPT-5.4 long context is experimental and opt-in, and keeps the behavior manual-only so the dashboard never auto-fills or auto-saves long-context values for the user. From f40d435a9291bb18c4df2692a0fdfaf55d7087dc Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 5 Apr 2026 02:03:45 -0400 Subject: [PATCH 3/7] fix(kiro): harden idc and callback auth paths - keep headless paste routing aligned with the selected Kiro auth method - validate local callback replay targets and add prompt cancellation safeguards - wire IDC params through the dashboard start route and support equals-form CLI flags --- src/cliproxy/auth/oauth-handler.ts | 61 +++--- src/cliproxy/auth/oauth-process.ts | 177 +++++++++++++++--- src/cliproxy/executor/index.ts | 72 ++++--- src/web-server/routes/cliproxy-auth-routes.ts | 63 +++++++ .../cliproxy/executor-option-value.test.ts | 33 ++++ .../oauth-handler-paste-callback.test.ts | 14 ++ .../oauth-process-error-parser.test.ts | 53 +++++- .../web-server/cliproxy-auth-routes.test.ts | 39 ++++ 8 files changed, 431 insertions(+), 81 deletions(-) create mode 100644 tests/unit/cliproxy/executor-option-value.test.ts 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( From bf5fcfc034e3e58d6dace28521adc223965520a3 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 5 Apr 2026 02:17:50 -0400 Subject: [PATCH 4/7] docs(help): add kiro idc flag guidance - add a dedicated 'ccs help kiro' topic with IDC and paste-callback examples - link the providers help topic to the new Kiro-specific help surface --- src/commands/command-catalog.ts | 3 +- src/commands/help-command.ts | 73 +++++++++++++++++++ .../unit/commands/help-command-parity.test.ts | 12 +++ 3 files changed, 87 insertions(+), 1 deletion(-) diff --git a/src/commands/command-catalog.ts b/src/commands/command-catalog.ts index 28bf315d..11f3a984 100644 --- a/src/commands/command-catalog.ts +++ b/src/commands/command-catalog.ts @@ -2,7 +2,7 @@ import { COPILOT_SUBCOMMANDS } from '../copilot/constants'; import { CURSOR_SUBCOMMANDS } from '../cursor/constants'; import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities'; -export type HelpTopicName = 'profiles' | 'providers' | 'completion' | 'targets'; +export type HelpTopicName = 'profiles' | 'providers' | 'kiro' | 'completion' | 'targets'; export interface HelpTopicEntry { name: HelpTopicName; @@ -25,6 +25,7 @@ export interface ShortcutEntry { export const ROOT_HELP_TOPICS: readonly HelpTopicEntry[] = [ { name: 'profiles', summary: 'Account profiles, API profiles, and CLIProxy variants' }, { name: 'providers', summary: 'Built-in OAuth providers and runtime shortcuts' }, + { name: 'kiro', summary: 'Kiro auth methods, IDC flags, and callback guidance' }, { name: 'completion', summary: 'Shell completion install, refresh, and testing' }, { name: 'targets', summary: 'Claude, Droid, and Codex target routing' }, ] as const; diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 88efc512..641a2d2c 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -78,6 +78,7 @@ async function showProvidersHelp(writeLine: HelpWriter): Promise { }, { name: 'ccs api create --preset ', summary: 'Create an API-backed provider profile' }, { name: 'ccs config', summary: 'Use the dashboard for provider and model setup' }, + { name: 'ccs help kiro', summary: 'Kiro-specific auth methods and IDC flags' }, ], writeLine ); @@ -85,6 +86,74 @@ async function showProvidersHelp(writeLine: HelpWriter): Promise { writeLine(''); } +async function showKiroHelp(writeLine: HelpWriter): Promise { + await initUI(); + writeLine(header('CCS Kiro Help')); + writeLine(''); + writeLine(' Kiro supports Builder ID, IDC, and management-only social OAuth flows.'); + writeLine(''); + writeCommandTable( + 'Authentication Methods', + [ + { name: 'ccs kiro --auth', summary: 'Default AWS Builder ID device-code flow' }, + { + name: 'ccs kiro --auth --kiro-auth-method aws-authcode', + summary: 'AWS Builder ID auth-code flow via local callback server', + }, + { + name: 'ccs kiro --auth --kiro-auth-method idc', + summary: 'IAM Identity Center flow; requires IDC start URL', + }, + { + name: 'ccs config', + summary: 'Dashboard flow for GitHub OAuth and account management', + }, + ], + writeLine + ); + writeCommandTable( + 'Kiro Flags', + [ + { + name: '--kiro-auth-method ', + summary: 'Select the Kiro auth method', + }, + { name: '--kiro-idc-start-url ', summary: 'Required IDC start URL when using `idc`' }, + { name: '--kiro-idc-region ', summary: 'Optional IDC region override' }, + { name: '--kiro-idc-flow ', summary: 'IDC flow type; defaults to authcode' }, + { + name: '--paste-callback', + summary: 'Paste the final callback URL for callback-based CLI auth flows', + }, + { name: '--import', summary: 'Import an existing Kiro IDE token instead of starting OAuth' }, + ], + writeLine + ); + writeCommandTable( + 'Examples', + [ + { name: 'ccs kiro --auth', summary: 'Start the default Builder ID device flow' }, + { + name: 'ccs kiro --auth --kiro-auth-method aws-authcode --paste-callback', + summary: 'Use auth-code flow and paste the callback URL manually', + }, + { + name: 'ccs kiro --auth --kiro-auth-method idc --kiro-idc-start-url https://d-xxx.awsapps.com/start', + summary: 'Start IDC auth with the default authcode flow', + }, + { + name: 'ccs kiro --auth --kiro-auth-method idc --kiro-idc-start-url https://d-xxx.awsapps.com/start --kiro-idc-flow device', + summary: 'Use IDC device-code flow instead of authcode', + }, + ], + writeLine + ); + writeLine( + ` ${dim('GitHub OAuth is dashboard-only: ccs config -> Accounts -> Add Kiro account')}` + ); + writeLine(''); +} + async function showTargetsHelp(writeLine: HelpWriter): Promise { await initUI(); writeLine(header('CCS Targets Help')); @@ -176,6 +245,10 @@ export async function handleHelpRoute( await showProvidersHelp(writeLine); return; } + if (topic === 'kiro') { + await showKiroHelp(writeLine); + return; + } if (topic === 'targets') { await showTargetsHelp(writeLine); return; diff --git a/tests/unit/commands/help-command-parity.test.ts b/tests/unit/commands/help-command-parity.test.ts index bf830826..017ec18e 100644 --- a/tests/unit/commands/help-command-parity.test.ts +++ b/tests/unit/commands/help-command-parity.test.ts @@ -46,11 +46,23 @@ describe('help command parity', () => { expect(rendered.includes('Built-in OAuth Providers')).toBe(true); expect(rendered.includes('ccs cliproxy --help')).toBe(true); + expect(rendered.includes('ccs help kiro')).toBe(true); expect(rendered.includes('gemini')).toBe(true); expect(rendered.includes('codex')).toBe(true); expect(rendered.includes('ghcp')).toBe(true); }); + test('kiro topic documents IDC and callback flags', async () => { + const rendered = await renderLines((writeLine) => handleHelpRoute(['kiro'], writeLine)); + + expect(rendered.includes('CCS Kiro Help')).toBe(true); + expect(rendered.includes('--kiro-idc-start-url ')).toBe(true); + expect(rendered.includes('--kiro-idc-region ')).toBe(true); + expect(rendered.includes('--kiro-idc-flow ')).toBe(true); + expect(rendered.includes('--paste-callback')).toBe(true); + expect(rendered.includes('GitHub OAuth is dashboard-only')).toBe(true); + }); + test('completion topic documents install and verification paths', async () => { const rendered = await renderLines((writeLine) => handleHelpRoute(['completion'], writeLine)); From 325d8d861d96ddc3527751e98f7541868a2d46fb Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 5 Apr 2026 02:27:17 -0400 Subject: [PATCH 5/7] fix(kiro): parse Builder ID selector dynamically --- src/cliproxy/auth/oauth-process.ts | 27 +++++++++++++-- .../oauth-process-error-parser.test.ts | 33 +++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/cliproxy/auth/oauth-process.ts b/src/cliproxy/auth/oauth-process.ts index ac566af2..74b78d8d 100644 --- a/src/cliproxy/auth/oauth-process.ts +++ b/src/cliproxy/auth/oauth-process.ts @@ -191,6 +191,21 @@ export function validateManualCallbackUrl(callbackUrl: string, authUrl: string): return null; } +export function getKiroBuilderIdSelectionInput(output: string): string | null { + const promptMatch = /Select login method/i.exec(output); + if (!promptMatch || promptMatch.index === undefined) { + return null; + } + + const promptWindow = output.slice(promptMatch.index, promptMatch.index + 600); + const optionMatch = /(?:^|\n)\s*(\d+)\s*[\).:-]?\s*(?:AWS\s+)?Builder ID\b/im.exec(promptWindow); + if (!optionMatch) { + return null; + } + + return `${optionMatch[1]}\n`; +} + async function promptManualCallbackUrl( displayName: string, state: ProcessState, @@ -318,8 +333,16 @@ async function handleStdout( state.accumulatedOutput.includes('Select login method') ) { state.kiroMethodSelectionHandled = true; - authProcess.stdin?.write('1\n'); - log('Auto-selected Kiro Builder ID flow'); + const builderIdSelection = getKiroBuilderIdSelectionInput(state.accumulatedOutput); + if (!builderIdSelection) { + console.log(fail('Unable to auto-select Kiro Builder ID from the upstream login menu.')); + console.log(' The upstream Kiro prompt format may have changed.'); + killWithEscalation(authProcess); + return; + } + + authProcess.stdin?.write(builderIdSelection); + log(`Auto-selected Kiro Builder ID flow (${builderIdSelection.trim()})`); } // Parse project list when available diff --git a/tests/unit/cliproxy/oauth-process-error-parser.test.ts b/tests/unit/cliproxy/oauth-process-error-parser.test.ts index 6bb84821..322f0a32 100644 --- a/tests/unit/cliproxy/oauth-process-error-parser.test.ts +++ b/tests/unit/cliproxy/oauth-process-error-parser.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'bun:test'; import { extractLikelyAuthFailureFromStderr, getExpectedLocalCallback, + getKiroBuilderIdSelectionInput, validateManualCallbackUrl, } from '../../../src/cliproxy/auth/oauth-process'; @@ -84,3 +85,35 @@ describe('oauth-process manual callback validation', () => { ).toContain('state does not match'); }); }); + +describe('oauth-process Kiro Builder ID menu parsing', () => { + it('selects Builder ID when it is the first option', () => { + const output = ` +Select login method +1. Builder ID +2. IAM Identity Center +`; + + expect(getKiroBuilderIdSelectionInput(output)).toBe('1\n'); + }); + + it('selects the Builder ID option even when upstream reorders the menu', () => { + const output = ` +Select login method +1. IAM Identity Center +2. AWS Builder ID +`; + + expect(getKiroBuilderIdSelectionInput(output)).toBe('2\n'); + }); + + it('returns null when the Builder ID option is not present in the prompt window', () => { + const output = ` +Select login method +1. IAM Identity Center +2. Google +`; + + expect(getKiroBuilderIdSelectionInput(output)).toBeNull(); + }); +}); From 1434c3d1e074c70b551ffb8c99c60eb800c4222f Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 5 Apr 2026 18:41:23 -0400 Subject: [PATCH 6/7] fix(kiro): harden callback replay and auth detection --- src/cliproxy/auth/oauth-handler.ts | 183 +++++++++++++++++- src/cliproxy/auth/oauth-process.ts | 56 ++++-- .../oauth-handler-paste-callback.test.ts | 53 +++++ .../oauth-process-error-parser.test.ts | 32 ++- 4 files changed, 301 insertions(+), 23 deletions(-) diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index f2aece66..fb50989e 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -11,6 +11,7 @@ */ import * as fs from 'fs'; +import * as path from 'path'; import { fail, info, warn, color, ok } from '../../utils/ui'; import { ensureCLIProxyBinary } from '../binary-manager'; import { generateConfig } from '../config-generator'; @@ -46,7 +47,12 @@ import { normalizeKiroIDCFlow, } from './auth-types'; import { isHeadlessEnvironment, killProcessOnPort, showStep } from './environment-detector'; -import { getProviderTokenDir, isAuthenticated, registerAccountFromToken } from './token-manager'; +import { + getProviderTokenDir, + isAuthenticated, + isTokenFileForProvider, + registerAccountFromToken, +} from './token-manager'; import { executeOAuthProcess } from './oauth-process'; import { importKiroToken } from './kiro-import'; import { @@ -71,6 +77,12 @@ interface PasteCallbackStartData { } const PASTE_CALLBACK_AUTH_URL_POLL_INTERVAL_MS = 3000; +const POLLED_AUTH_LOCAL_TOKEN_GRACE_MS = 15 * 1000; + +type ProviderTokenSnapshot = { + file: string; + mtimeMs: number; +}; export async function requestPasteCallbackStart( provider: CLIProxyProvider, @@ -122,6 +134,134 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +function parseAuthUrlState(url: string | null | undefined): string | null { + if (!url) { + return null; + } + + try { + return new URL(url).searchParams.get('state'); + } catch { + return null; + } +} + +function listProviderTokenSnapshots( + provider: CLIProxyProvider, + tokenDir: string +): ProviderTokenSnapshot[] { + if (!fs.existsSync(tokenDir)) { + return []; + } + + return fs + .readdirSync(tokenDir) + .filter((file) => file.endsWith('.json')) + .map((file): ProviderTokenSnapshot | null => { + const filePath = path.join(tokenDir, file); + if (!isTokenFileForProvider(filePath, provider)) { + return null; + } + + return { + file, + mtimeMs: fs.statSync(filePath).mtimeMs, + }; + }) + .filter((snapshot): snapshot is ProviderTokenSnapshot => snapshot !== null) + .sort((left, right) => right.mtimeMs - left.mtimeMs); +} + +export function findNewTokenSnapshotForManualAuth( + provider: CLIProxyProvider, + tokenDir: string, + knownTokenFiles: ProviderTokenSnapshot[], + expectedAccountId?: string +): ProviderTokenSnapshot | null { + const knownTokenMtimes = new Map( + knownTokenFiles.map((snapshot) => [snapshot.file, snapshot.mtimeMs]) + ); + + return ( + listProviderTokenSnapshots(provider, tokenDir).find((snapshot) => { + const knownMtime = knownTokenMtimes.get(snapshot.file); + if (knownMtime === undefined) { + return true; + } + + if (!expectedAccountId) { + return false; + } + + return snapshot.mtimeMs > knownMtime + 1; + }) || null + ); +} + +async function waitForManualCallbackToken( + provider: CLIProxyProvider, + target: ProxyTarget, + tokenDir: string, + oauthState: string | null, + knownTokenFiles: ProviderTokenSnapshot[], + expectedAccountId: string | undefined, + timeoutMs: number, + pollIntervalMs: number = PASTE_CALLBACK_AUTH_URL_POLL_INTERVAL_MS +): Promise<{ tokenSnapshot: ProviderTokenSnapshot | null; error?: string }> { + const deadline = Date.now() + timeoutMs; + let upstreamCompletedAt: number | null = null; + + while (Date.now() < deadline) { + const tokenSnapshot = findNewTokenSnapshotForManualAuth( + provider, + tokenDir, + knownTokenFiles, + expectedAccountId + ); + if (tokenSnapshot) { + return { tokenSnapshot }; + } + + if (oauthState) { + const response = await fetch( + buildProxyUrl( + target, + `/v0/management/get-auth-status?state=${encodeURIComponent(oauthState)}` + ), + { headers: buildManagementHeaders(target) } + ); + + if (response.ok) { + const data = (await response.json()) as { status?: string; error?: string }; + if (data.status === 'error') { + return { + tokenSnapshot: null, + error: data.error || 'Authentication failed while waiting for local token persistence', + }; + } + if (data.status === 'ok' && upstreamCompletedAt === null) { + upstreamCompletedAt = Date.now(); + } + } + } + + if ( + upstreamCompletedAt !== null && + Date.now() - upstreamCompletedAt >= POLLED_AUTH_LOCAL_TOKEN_GRACE_MS + ) { + break; + } + + if (Date.now() + pollIntervalMs >= deadline) { + break; + } + + await sleep(pollIntervalMs); + } + + return { tokenSnapshot: null }; +} + export async function resolvePasteCallbackAuthUrl( target: ProxyTarget, startData: PasteCallbackStartData, @@ -397,6 +537,9 @@ async function handlePasteCallbackMode( return null; } + const oauthState = startData.state || parseAuthUrlState(authUrl); + const knownTokenFiles = listProviderTokenSnapshots(provider, tokenDir); + // Display auth URL in box console.log(''); console.log(' ╔══════════════════════════════════════════════════════════════╗'); @@ -489,15 +632,49 @@ async function handlePasteCallbackMode( return null; } - console.log(ok('Authentication successful!')); + console.log(info('Callback submitted. Waiting for token exchange...')); + const { tokenSnapshot, error: tokenWaitError } = await waitForManualCallbackToken( + provider, + target, + tokenDir, + oauthState, + knownTokenFiles, + expectedAccountId, + OAUTH_STATE_TIMEOUT_MS + ); + + if (tokenWaitError) { + console.log(fail(tokenWaitError)); + warnPossible403Ban(provider, tokenWaitError); + return null; + } + + if (!tokenSnapshot) { + console.log( + fail( + 'Authentication completed upstream, but no new local token was saved for this account. Update CCS/CLIProxy and retry.' + ) + ); + return null; + } + const account = registerAccountFromToken( provider, tokenDir, nickname, verbose, - expectedAccountId + tokenSnapshot.file ); + if (!account) { + console.log( + fail('Authenticated token could not be matched to the requested account. Retry the flow.') + ); + return null; + } + + console.log(ok('Authentication successful!')); + // Account safety: check for cross-provider conflicts if (account?.email) { const conflicts = checkNewAccountConflict(provider, account.email); diff --git a/src/cliproxy/auth/oauth-process.ts b/src/cliproxy/auth/oauth-process.ts index 74b78d8d..bce9491c 100644 --- a/src/cliproxy/auth/oauth-process.ts +++ b/src/cliproxy/auth/oauth-process.ts @@ -198,7 +198,7 @@ export function getKiroBuilderIdSelectionInput(output: string): string | null { } const promptWindow = output.slice(promptMatch.index, promptMatch.index + 600); - const optionMatch = /(?:^|\n)\s*(\d+)\s*[\).:-]?\s*(?:AWS\s+)?Builder ID\b/im.exec(promptWindow); + const optionMatch = /(?:^|\n)\s*(\d+)\s*[\).:-]?\s*.*\bBuilder ID\b/im.exec(promptWindow); if (!optionMatch) { return null; } @@ -206,6 +206,33 @@ export function getKiroBuilderIdSelectionInput(output: string): string | null { return `${optionMatch[1]}\n`; } +export function extractLikelyOAuthAuthorizationUrl(output: string): string | null { + const urls = Array.from(output.matchAll(/https?:\/\/[^\s]+/g), (match) => match[0]); + let selectedUrl: string | null = null; + let selectedScore = 0; + + for (const url of urls) { + try { + const parsed = new URL(url); + let score = 0; + if (parsed.searchParams.has('redirect_uri')) score += 4; + if (parsed.searchParams.has('state')) score += 2; + if (parsed.searchParams.has('code_challenge')) score += 1; + if (parsed.pathname.includes('/authorize')) score += 1; + if (isLoopbackHost(parsed.hostname)) score -= 3; + + if (score >= selectedScore && score > 0) { + selectedUrl = url; + selectedScore = score; + } + } catch { + continue; + } + } + + return selectedUrl; +} + async function promptManualCallbackUrl( displayName: string, state: ProcessState, @@ -332,17 +359,12 @@ async function handleStdout( !state.kiroMethodSelectionHandled && state.accumulatedOutput.includes('Select login method') ) { - state.kiroMethodSelectionHandled = true; const builderIdSelection = getKiroBuilderIdSelectionInput(state.accumulatedOutput); - if (!builderIdSelection) { - console.log(fail('Unable to auto-select Kiro Builder ID from the upstream login menu.')); - console.log(' The upstream Kiro prompt format may have changed.'); - killWithEscalation(authProcess); - return; + if (builderIdSelection) { + state.kiroMethodSelectionHandled = true; + authProcess.stdin?.write(builderIdSelection); + log(`Auto-selected Kiro Builder ID flow (${builderIdSelection.trim()})`); } - - authProcess.stdin?.write(builderIdSelection); - log(`Auto-selected Kiro Builder ID flow (${builderIdSelection.trim()})`); } // Parse project list when available @@ -413,11 +435,11 @@ async function handleStdout( // Display OAuth URL for all modes (enables VS Code terminal URL detection popup) if (!isDeviceCodeFlow && !state.urlDisplayed) { - const urlMatch = output.match(/https?:\/\/[^\s]+/); - if (urlMatch) { + const authUrl = extractLikelyOAuthAuthorizationUrl(state.accumulatedOutput); + if (authUrl) { console.log(''); console.log(info(`${options.oauthConfig.displayName} OAuth URL:`)); - console.log(` ${urlMatch[0]}`); + console.log(` ${authUrl}`); console.log(''); state.urlDisplayed = true; @@ -426,7 +448,7 @@ async function handleStdout( await replayManualCallback( options.oauthConfig, authProcess, - urlMatch[0], + authUrl, options.verbose, state, 10 * 60 * 1000 @@ -442,11 +464,11 @@ function displayUrlFromStderr( state: ProcessState, oauthConfig: ProviderOAuthConfig ): void { - const urlMatch = output.match(/https?:\/\/[^\s]+/); - if (urlMatch) { + const authUrl = extractLikelyOAuthAuthorizationUrl(output); + if (authUrl) { console.log(''); console.log(info(`${oauthConfig.displayName} OAuth URL:`)); - console.log(` ${urlMatch[0]}`); + console.log(` ${authUrl}`); console.log(''); state.urlDisplayed = true; } diff --git a/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts b/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts index 983e94d1..287b95f8 100644 --- a/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts +++ b/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts @@ -1,4 +1,7 @@ import { afterEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import type { ProxyTarget } from '../../../src/cliproxy/proxy-target-resolver'; import { getCapturedFetchRequests, mockFetch, restoreFetch } from '../../mocks'; @@ -130,6 +133,56 @@ describe('resolvePasteCallbackAuthUrl', () => { }); }); +describe('findNewTokenSnapshotForManualAuth', () => { + it('detects newly created provider token files', async () => { + const tokenDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-kiro-manual-auth-')); + const existingFile = path.join(tokenDir, 'kiro-existing.json'); + fs.writeFileSync(existingFile, JSON.stringify({ type: 'kiro', email: 'existing@example.com' })); + const existingMtimeMs = fs.statSync(existingFile).mtimeMs; + + const { findNewTokenSnapshotForManualAuth } = await import( + `../../../src/cliproxy/auth/oauth-handler?manual-auth-new-token=${Date.now()}` + ); + + const newFile = path.join(tokenDir, 'kiro-new.json'); + fs.writeFileSync(newFile, JSON.stringify({ type: 'kiro', email: 'new@example.com' })); + + const snapshot = findNewTokenSnapshotForManualAuth( + 'kiro', + tokenDir, + [{ file: 'kiro-existing.json', mtimeMs: existingMtimeMs }] + ); + + expect(snapshot?.file).toBe('kiro-new.json'); + fs.rmSync(tokenDir, { recursive: true, force: true }); + }); + + it('treats a modified existing token as the new token during reauth', async () => { + const tokenDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-kiro-reauth-')); + const tokenFile = path.join(tokenDir, 'kiro-existing.json'); + fs.writeFileSync(tokenFile, JSON.stringify({ type: 'kiro', email: 'existing@example.com' })); + const existingMtimeMs = fs.statSync(tokenFile).mtimeMs; + + const { findNewTokenSnapshotForManualAuth } = await import( + `../../../src/cliproxy/auth/oauth-handler?manual-auth-updated-token=${Date.now()}` + ); + + fs.writeFileSync(tokenFile, JSON.stringify({ type: 'kiro', email: 'existing@example.com', refreshed: true })); + const bumpedTime = new Date(existingMtimeMs + 10_000); + fs.utimesSync(tokenFile, bumpedTime, bumpedTime); + + const snapshot = findNewTokenSnapshotForManualAuth( + 'kiro', + tokenDir, + [{ file: 'kiro-existing.json', mtimeMs: existingMtimeMs }], + 'kiro-existing.json' + ); + + expect(snapshot?.file).toBe('kiro-existing.json'); + fs.rmSync(tokenDir, { recursive: true, force: true }); + }); +}); + describe('getCliAuthNicknameError', () => { it('allows omitted nicknames for no-email providers', async () => { const { getCliAuthNicknameError } = 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 322f0a32..a9d7f806 100644 --- a/tests/unit/cliproxy/oauth-process-error-parser.test.ts +++ b/tests/unit/cliproxy/oauth-process-error-parser.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'bun:test'; import { extractLikelyAuthFailureFromStderr, + extractLikelyOAuthAuthorizationUrl, getExpectedLocalCallback, getKiroBuilderIdSelectionInput, validateManualCallbackUrl, @@ -89,9 +90,9 @@ describe('oauth-process manual callback validation', () => { describe('oauth-process Kiro Builder ID menu parsing', () => { it('selects Builder ID when it is the first option', () => { const output = ` -Select login method -1. Builder ID -2. IAM Identity Center +? Select login method: + 1) Use with Builder ID (personal AWS account) + 2) Use with IDC Account (organization SSO) `; expect(getKiroBuilderIdSelectionInput(output)).toBe('1\n'); @@ -117,3 +118,28 @@ Select login method expect(getKiroBuilderIdSelectionInput(output)).toBeNull(); }); }); + +describe('oauth-process OAuth URL extraction', () => { + it('prefers the real auth URL over the IDC start URL banner', () => { + const authUrl = + 'https://oidc.us-east-1.amazonaws.com/authorize?response_type=code&client_id=test-client&redirect_uri=http%3A%2F%2F127.0.0.1%3A9876%2Foauth%2Fcallback&state=test-state&code_challenge=test-challenge&code_challenge_method=S256'; + const output = ` +Using IDC with Start URL: https://d-123.awsapps.com/start +Region: us-east-1 +URL: ${authUrl} +`; + + expect(extractLikelyOAuthAuthorizationUrl(output)).toBe(authUrl); + }); + + it('ignores local callback server URLs when the auth URL is also present', () => { + const authUrl = + 'https://device.sso.us-east-1.amazonaws.com/authorize?response_type=code&client_id=test-client&redirect_uri=http%3A%2F%2F127.0.0.1%3A9876%2Foauth%2Fcallback&state=test-state&code_challenge=test-challenge&code_challenge_method=S256'; + const output = ` +Callback server started, redirect URI: http://127.0.0.1:9876/oauth/callback +URL: ${authUrl} +`; + + expect(extractLikelyOAuthAuthorizationUrl(output)).toBe(authUrl); + }); +}); From 699b19213720d65d76a28f5a907751210255f956 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 6 Apr 2026 15:44:34 -0400 Subject: [PATCH 7/7] fix(ui): improve Kiro dashboard auth flow --- .../components/account/add-account-dialog.tsx | 124 ++++++++++++++++-- ui/src/hooks/use-cliproxy-auth-flow.ts | 21 +++ ui/src/lib/provider-config.ts | 45 ++++++- .../hooks/use-cliproxy-auth-flow.test.tsx | 90 +++++++++++++ 4 files changed, 271 insertions(+), 9 deletions(-) diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx index 49bb0448..646a862a 100644 --- a/ui/src/components/account/add-account-dialog.tsx +++ b/ui/src/components/account/add-account-dialog.tsx @@ -39,11 +39,15 @@ import { } from '@/components/account/antigravity-responsibility-constants'; import { DEFAULT_KIRO_AUTH_METHOD, + DEFAULT_KIRO_IDC_FLOW, + getKiroEffectiveFlowType, + getKiroEffectiveStartEndpoint, getKiroAuthMethodOption, + isKiroSocialAuthMethod, isDeviceCodeProvider, KIRO_AUTH_METHOD_OPTIONS, } from '@/lib/provider-config'; -import type { KiroAuthMethod } from '@/lib/provider-config'; +import type { KiroAuthMethod, KiroIDCFlow } from '@/lib/provider-config'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; @@ -81,6 +85,9 @@ export function AddAccountDialog({ const [powerUserModeEnabled, setPowerUserModeEnabled] = useState(false); const [powerUserModeLoading, setPowerUserModeLoading] = useState(false); const [kiroAuthMethod, setKiroAuthMethod] = useState(DEFAULT_KIRO_AUTH_METHOD); + const [kiroIDCStartUrl, setKiroIDCStartUrl] = useState(''); + const [kiroIDCRegion, setKiroIDCRegion] = useState(''); + const [kiroIDCFlow, setKiroIDCFlow] = useState(DEFAULT_KIRO_IDC_FLOW); const { t } = useTranslation(); const wasAuthenticatingRef = useRef(false); const powerUserModeRequestIdRef = useRef(0); @@ -97,9 +104,19 @@ export function AddAccountDialog({ const isGeminiRiskAcknowledged = normalizeRiskPhrase(riskAcknowledgementText) === RISK_ACK_PHRASE; const defaultDeviceCode = isDeviceCodeProvider(provider); const kiroMethodOption = getKiroAuthMethodOption(kiroAuthMethod); - const isDeviceCode = isKiro ? kiroMethodOption.flowType === 'device_code' : defaultDeviceCode; + const isKiroIdc = isKiro && kiroAuthMethod === 'idc'; + const isKiroSocial = isKiro && isKiroSocialAuthMethod(kiroAuthMethod); + const selectedKiroFlowType = isKiro + ? getKiroEffectiveFlowType(kiroAuthMethod, kiroIDCFlow) + : undefined; + const selectedKiroStartEndpoint = isKiro + ? getKiroEffectiveStartEndpoint(kiroAuthMethod) + : undefined; + const isDeviceCode = isKiro ? selectedKiroFlowType === 'device_code' : defaultDeviceCode; const isPending = authFlow.isAuthenticating || kiroImportMutation.isPending; const nicknameTrimmed = nickname.trim(); + const kiroIDCStartUrlTrimmed = kiroIDCStartUrl.trim(); + const kiroIDCRegionTrimmed = kiroIDCRegion.trim(); const errorMessage = localError || authFlow.error; const fetchPowerUserModeState = useCallback(async (): Promise => { @@ -168,6 +185,9 @@ export function AddAccountDialog({ setPowerUserModeEnabled(false); setPowerUserModeLoading(false); setKiroAuthMethod(DEFAULT_KIRO_AUTH_METHOD); + setKiroIDCStartUrl(''); + setKiroIDCRegion(''); + setKiroIDCFlow(DEFAULT_KIRO_IDC_FLOW); powerUserModeRequestIdRef.current += 1; powerUserModeLoadErrorShownRef.current = false; wasAuthenticatingRef.current = false; @@ -282,12 +302,19 @@ export function AddAccountDialog({ return; } setLocalError(null); + if (isKiroIdc && !kiroIDCStartUrlTrimmed) { + setLocalError('IDC Start URL is required for Kiro IAM Identity Center login.'); + return; + } wasAuthenticatingRef.current = true; authFlow.startAuth(provider, { nickname: nicknameTrimmed || undefined, kiroMethod: isKiro ? kiroAuthMethod : undefined, - flowType: isKiro ? kiroMethodOption.flowType : undefined, - startEndpoint: isKiro ? kiroMethodOption.startEndpoint : undefined, + kiroIDCStartUrl: isKiroIdc ? kiroIDCStartUrlTrimmed : undefined, + kiroIDCRegion: isKiroIdc && kiroIDCRegionTrimmed ? kiroIDCRegionTrimmed : undefined, + kiroIDCFlow: isKiroIdc ? kiroIDCFlow : undefined, + flowType: isKiro ? selectedKiroFlowType : undefined, + startEndpoint: isKiro ? selectedKiroStartEndpoint : undefined, riskAcknowledgement: requiresAgyResponsibilityFlow ? { version: ANTIGRAVITY_ACK_VERSION, @@ -399,6 +426,77 @@ export function AddAccountDialog({

{kiroMethodOption.description}

+ {isKiroSocial && ( +

+ If your browser does not return automatically after login, CCS can accept the + final + + kiro://... + + callback URL in the next step. +

+ )} + + )} + + {isKiroIdc && !showAuthUI && ( +
+
+ + { + setKiroIDCStartUrl(e.target.value); + setLocalError(null); + }} + placeholder="https://d-xxx.awsapps.com/start" + disabled={isPending} + /> +

+ Required for organization IAM Identity Center login. +

+
+ +
+ + { + setKiroIDCRegion(e.target.value); + setLocalError(null); + }} + placeholder="us-east-1" + disabled={isPending} + /> +

+ Optional. Leave blank to use the upstream default region. +

+
+ +
+ + +

+ Auth Code opens a browser and may need the final callback URL pasted back. Device + Code shows a verification code instead. +

+
)} @@ -438,7 +536,9 @@ export function AddAccountDialog({

{authFlow.isDeviceCodeFlow ? t('addAccountDialog.deviceCodeHint') - : t('addAccountDialog.browserHint')} + : isKiroSocial + ? 'Complete sign-in in your browser. If it does not return automatically, paste the final kiro:// callback URL below.' + : t('addAccountDialog.browserHint')}

@@ -486,13 +586,19 @@ export function AddAccountDialog({ {/* Callback paste field */}
setCallbackUrl(e.target.value)} - placeholder={t('addAccountDialog.callbackPlaceholder')} + placeholder={ + isKiroSocial + ? 'kiro://kiro.kiroAgent/authenticate-success?code=...&state=...' + : t('addAccountDialog.callbackPlaceholder') + } className="font-mono text-xs" />
diff --git a/ui/src/hooks/use-cliproxy-auth-flow.ts b/ui/src/hooks/use-cliproxy-auth-flow.ts index dcc57e09..1ac8199f 100644 --- a/ui/src/hooks/use-cliproxy-auth-flow.ts +++ b/ui/src/hooks/use-cliproxy-auth-flow.ts @@ -26,6 +26,9 @@ interface AuthFlowState { interface StartAuthOptions { nickname?: string; kiroMethod?: string; + kiroIDCStartUrl?: string; + kiroIDCRegion?: string; + kiroIDCFlow?: 'authcode' | 'device'; flowType?: 'authorization_code' | 'device_code'; startEndpoint?: 'start' | 'start-url'; riskAcknowledgement?: { @@ -70,6 +73,7 @@ const INITIAL_STATE: AuthFlowState = { export function useCliproxyAuthFlow() { const [state, setState] = useState(INITIAL_STATE); + const stateRef = useRef(INITIAL_STATE); const attemptIdRef = useRef(0); const abortControllerRef = useRef(null); @@ -102,6 +106,10 @@ export function useCliproxyAuthFlow() { }; }, [stopPolling]); + useEffect(() => { + stateRef.current = state; + }, [state]); + // Poll OAuth status const pollStatus = useCallback( async (provider: string, oauthState: string, attemptId: number) => { @@ -253,6 +261,9 @@ export function useCliproxyAuthFlow() { const payload = { nickname: options?.nickname, kiroMethod: options?.kiroMethod, + kiroIDCStartUrl: options?.kiroIDCStartUrl, + kiroIDCRegion: options?.kiroIDCRegion, + kiroIDCFlow: options?.kiroIDCFlow, riskAcknowledgement: options?.riskAcknowledgement, }; @@ -368,6 +379,16 @@ export function useCliproxyAuthFlow() { // Start polling for completion if (oauthState) { pollStartRef.current = Date.now(); + if (!authUrl) { + await pollStatus(provider, oauthState, attemptId); + if (!isActiveAttempt(attemptId)) { + return; + } + const currentState = stateRef.current; + if (!currentState.isAuthenticating || currentState.provider !== provider) { + return; + } + } pollIntervalRef.current = setInterval(() => { void pollStatus(provider, oauthState, attemptId); }, POLL_INTERVAL); diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts index b1cbf0d2..516b85e8 100644 --- a/ui/src/lib/provider-config.ts +++ b/ui/src/lib/provider-config.ts @@ -234,8 +234,11 @@ export function getDeviceCodeProviderInstruction(provider: unknown): string { } /** Kiro auth methods exposed in CCS UI (aligned with CLIProxyAPIPlus support). */ -export const KIRO_AUTH_METHODS = ['aws', 'aws-authcode', 'google', 'github'] as const; +export const KIRO_AUTH_METHODS = ['aws', 'aws-authcode', 'google', 'github', 'idc'] as const; export type KiroAuthMethod = (typeof KIRO_AUTH_METHODS)[number]; +export const KIRO_IDC_FLOWS = ['authcode', 'device'] as const; +export type KiroIDCFlow = (typeof KIRO_IDC_FLOWS)[number]; +export const DEFAULT_KIRO_IDC_FLOW: KiroIDCFlow = 'authcode'; export type KiroFlowType = 'authorization_code' | 'device_code'; export type KiroStartEndpoint = 'start' | 'start-url'; @@ -280,6 +283,13 @@ export const KIRO_AUTH_METHOD_OPTIONS: readonly KiroAuthMethodOption[] = [ flowType: 'authorization_code', startEndpoint: 'start-url', }, + { + id: 'idc', + label: 'AWS Identity Center (IDC)', + description: 'Use your organization start URL with auth code or device flow.', + flowType: 'authorization_code', + startEndpoint: 'start', + }, ]; export function isKiroAuthMethod(value: string): value is KiroAuthMethod { @@ -292,7 +302,40 @@ export function normalizeKiroAuthMethod(value?: string): KiroAuthMethod { return isKiroAuthMethod(normalized) ? normalized : DEFAULT_KIRO_AUTH_METHOD; } +export function isKiroIDCFlow(value: string): value is KiroIDCFlow { + return KIRO_IDC_FLOWS.includes(value as KiroIDCFlow); +} + +export function normalizeKiroIDCFlow(value?: string): KiroIDCFlow { + if (!value) return DEFAULT_KIRO_IDC_FLOW; + const normalized = value.trim().toLowerCase(); + return isKiroIDCFlow(normalized) ? normalized : DEFAULT_KIRO_IDC_FLOW; +} + export function getKiroAuthMethodOption(method: KiroAuthMethod): KiroAuthMethodOption { const option = KIRO_AUTH_METHOD_OPTIONS.find((candidate) => candidate.id === method); return option || KIRO_AUTH_METHOD_OPTIONS[0]; } + +export function getKiroEffectiveFlowType( + method: KiroAuthMethod, + idcFlow: KiroIDCFlow = DEFAULT_KIRO_IDC_FLOW +): KiroFlowType { + if (method === 'aws') { + return 'device_code'; + } + + if (method === 'idc') { + return normalizeKiroIDCFlow(idcFlow) === 'device' ? 'device_code' : 'authorization_code'; + } + + return 'authorization_code'; +} + +export function getKiroEffectiveStartEndpoint(method: KiroAuthMethod): KiroStartEndpoint { + return method === 'google' || method === 'github' ? 'start-url' : 'start'; +} + +export function isKiroSocialAuthMethod(method: KiroAuthMethod): boolean { + return method === 'google' || method === 'github'; +} diff --git a/ui/tests/unit/hooks/use-cliproxy-auth-flow.test.tsx b/ui/tests/unit/hooks/use-cliproxy-auth-flow.test.tsx index d90c4593..b37b8533 100644 --- a/ui/tests/unit/hooks/use-cliproxy-auth-flow.test.tsx +++ b/ui/tests/unit/hooks/use-cliproxy-auth-flow.test.tsx @@ -221,6 +221,96 @@ describe('useCliproxyAuthFlow', () => { expect(toast.success).toHaveBeenCalledWith('codex authentication successful'); }); + it('promotes a state-first auth bootstrap into an immediate auth URL without waiting for the first interval', async () => { + let pollCount = 0; + + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + + if (url.includes('/start-url')) { + return Promise.resolve( + createJsonResponse({ + success: true, + authUrl: null, + state: 'state-kiro-social', + }) + ); + } + + if (url.includes('/status?state=state-kiro-social')) { + pollCount += 1; + return Promise.resolve( + createJsonResponse({ + status: 'auth_url', + url: 'https://auth.example/kiro-social', + }) + ); + } + + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }) + ); + + const { result } = renderHook(() => useCliproxyAuthFlow(), { wrapper }); + + await act(async () => { + await result.current.startAuth('kiro', { startEndpoint: 'start-url', kiroMethod: 'google' }); + }); + + expect(result.current.authUrl).toBe('https://auth.example/kiro-social'); + expect(result.current.oauthState).toBe('state-kiro-social'); + expect(result.current.isAuthenticating).toBe(true); + expect(pollCount).toBe(1); + }); + + it('forwards Kiro IDC options to the backend start endpoint payload', async () => { + let requestBody: Record | null = null; + + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + + if (url.includes('/start')) { + requestBody = JSON.parse(String(init?.body)) as Record; + return createJsonResponse({ + success: true, + account: { + id: 'kiro-idc-account', + provider: 'kiro', + }, + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }) + ); + + const { result } = renderHook(() => useCliproxyAuthFlow(), { wrapper }); + + await act(async () => { + await result.current.startAuth('kiro', { + startEndpoint: 'start', + flowType: 'authorization_code', + kiroMethod: 'idc', + kiroIDCStartUrl: 'https://d-123.awsapps.com/start', + kiroIDCRegion: 'ca-central-1', + kiroIDCFlow: 'authcode', + }); + }); + + expect(requestBody).toEqual({ + nickname: undefined, + kiroMethod: 'idc', + kiroIDCStartUrl: 'https://d-123.awsapps.com/start', + kiroIDCRegion: 'ca-central-1', + kiroIDCFlow: 'authcode', + riskAcknowledgement: undefined, + }); + }); + it('treats callback responses without an account as failures', async () => { vi.stubGlobal( 'fetch',