From 0fbea0f33557b17d2e4fd3ef79d1a6230672d295 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 5 Apr 2026 01:30:06 -0400 Subject: [PATCH] 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();