diff --git a/README.md b/README.md index b4861825..b6a62ffe 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ Want to run the dashboard in Docker? See `docker/README.md`. The dashboard provides visual management for all account types: - **Claude Accounts**: Create isolated instances (work, personal, client) -- **OAuth Providers**: One-click auth for Gemini, Codex, Antigravity +- **OAuth Providers**: One-click auth for Gemini, Codex, Antigravity, Kiro, Copilot - **API Profiles**: Configure GLM, Kimi with your keys - **Health Monitor**: Real-time status across all profiles @@ -93,7 +93,7 @@ The dashboard provides visual management for all account types: | **Codex** | OAuth | `ccs codex` | Code generation | | **Copilot** | OAuth | `ccs copilot` or `ccs ghcp` | GitHub Copilot models | | **Cursor IDE** | Local Token | `ccs cursor` | Cursor subscription models via local daemon | -| **Kiro** | OAuth | `ccs kiro` | AWS CodeWhisperer (Claude-powered) | +| **Kiro** | OAuth (AWS default) | `ccs kiro` | AWS CodeWhisperer (Claude-powered) | | **Antigravity** | OAuth | `ccs agy` | Alternative routing | | **OpenRouter** | API Key | `ccs openrouter` | 300+ models, unified API | | **Ollama** | Local | `ccs ollama` | Local open-source models, privacy | @@ -141,6 +141,19 @@ ccs ollama # Local Ollama (no API key needed) ccs glm # GLM (API key) ``` +### Kiro Auth Methods + +`ccs kiro --auth` defaults to AWS Builder ID Device OAuth (best support for AWS org accounts). + +```bash +ccs kiro --auth --kiro-auth-method aws # AWS Builder ID device code (default) +ccs kiro --auth --kiro-auth-method aws-authcode # AWS Builder ID auth code +ccs kiro --auth --kiro-auth-method google # Google OAuth +ccs kiro --auth --kiro-auth-method github # Dashboard management OAuth flow +``` + +Dashboard parity: `ccs config` -> Accounts -> Add Kiro account -> choose `Auth Method`. + ### Cursor IDE Quick Start ```bash diff --git a/docs/system-architecture.md b/docs/system-architecture.md index 10b55a2a..96726327 100644 --- a/docs/system-architecture.md +++ b/docs/system-architecture.md @@ -86,7 +86,7 @@ CCS v7.34 adds Image Analysis Hook for vision model proxying through CLIProxy wi v 1. CLIProxy Hardcoded ----+---> gemini, codex, agy, kiro, ghcp (OAuth-based) | Zero-config OAuth providers - | (kiro: Auth Code, ghcp: Device Code) + | (kiro: method-aware, ghcp: Device Code) | 2. CLIProxy Variants -----+---> config.cliproxy section (User-defined) | Custom provider configurations @@ -570,10 +570,10 @@ CCS v7.34 adds Image Analysis Hook for vision model proxying through CLIProxy wi | Authentication Flow | +===========================================================================+ - OAuth Providers - Authorization Code Flow (Gemini, Codex, AGY, Kiro) - -------------------------------------------------------------------- + OAuth Providers - Authorization Code Flow (Gemini, Codex, AGY) + -------------------------------------------------------------- - 1. User runs: ccs gemini (or ccs kiro) + 1. User runs: ccs gemini | v 2. Check token cache (~/.ccs/cliproxy/auth/) @@ -584,7 +584,6 @@ CCS v7.34 adds Image Analysis Hook for vision model proxying through CLIProxy wi | v 3. Open browser for OAuth (localhost:PORT callback) - | - Kiro uses port 9876 v 4. Callback with auth code | @@ -595,6 +594,21 @@ CCS v7.34 adds Image Analysis Hook for vision model proxying through CLIProxy wi 6. Cache token locally + Kiro OAuth - Method-Aware Flow (CLI + Dashboard parity) + ------------------------------------------------------- + + Supported methods: + - aws: Device Code (default, AWS org friendly) + - aws-authcode: Authorization Code via CLI flow + - google: Social OAuth via management API + - github: Social OAuth via management API (Dashboard flow) + + Key behavior: + - Device Code method uses /start route (no callback port) + - Callback/social methods use /start-url + status polling + - Some management flows return state first, auth_url later + + OAuth Providers - Device Code Flow (GitHub Copilot/ghcp) -------------------------------------------------------- diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts index 234a00ba..91feaa70 100644 --- a/src/cliproxy/auth/auth-types.ts +++ b/src/cliproxy/auth/auth-types.ts @@ -7,6 +7,74 @@ import { CLIProxyProvider } from '../types'; import { AccountInfo } from '../account-manager'; +/** + * Kiro authentication methods supported by CLIProxyAPIPlus. + * - aws: AWS Builder ID via Device Code flow + * - 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) + */ +export const KIRO_AUTH_METHODS = ['aws', 'aws-authcode', 'google', 'github'] 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 type KiroCLIAuthMethod = (typeof KIRO_CLI_AUTH_METHODS)[number]; + +/** Default Kiro method for CCS UX and AWS Organization support. */ +export const DEFAULT_KIRO_AUTH_METHOD: KiroAuthMethod = 'aws'; + +export function isKiroAuthMethod(value: string): value is KiroAuthMethod { + return KIRO_AUTH_METHODS.includes(value as KiroAuthMethod); +} + +export function isKiroCLIAuthMethod(value: string): value is KiroCLIAuthMethod { + return KIRO_CLI_AUTH_METHODS.includes(value as KiroCLIAuthMethod); +} + +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 getKiroCallbackPort(method: KiroAuthMethod): number | null { + return isKiroDeviceCodeMethod(method) ? null : 9876; +} + +export function getKiroCLIAuthFlag(method: KiroCLIAuthMethod): string { + switch (method) { + case 'aws': + return '--kiro-aws-login'; + case 'aws-authcode': + return '--kiro-aws-authcode'; + case 'google': + return '--kiro-google-login'; + } +} + +/** + * Kiro method for CLIProxyAPI management endpoint: + * GET /v0/management/kiro-auth-url?method= + */ +export function toKiroManagementMethod(method: KiroAuthMethod): 'aws' | 'google' | 'github' { + switch (method) { + case 'google': + return 'google'; + case 'github': + return 'github'; + case 'aws-authcode': + return 'aws'; + case 'aws': + default: + return 'aws'; + } +} + /** * OAuth callback ports used by CLIProxyAPI (hardcoded in binary) * See: https://github.com/router-for-me/CLIProxyAPI/tree/main/internal/auth @@ -15,19 +83,19 @@ import { AccountInfo } from '../account-manager'; * - Gemini: Authorization Code Flow with local callback server on port 8085 * - Codex: Authorization Code Flow with local callback server on port 1455 * - Agy: Authorization Code Flow with local callback server on port 51121 - * - Kiro: Authorization Code Flow with local callback server on port 9876 * - iFlow: Authorization Code Flow with local callback server on port 11451 * - Claude: Authorization Code Flow with local callback server on port 54545 (Anthropic OAuth) + * - Kiro: Device Code Flow (polling-based, NO callback port needed) * - Qwen: Device Code Flow (polling-based, NO callback port needed) * - GHCP: Device Code Flow (polling-based, NO callback port needed) */ export const OAUTH_CALLBACK_PORTS: Partial> = { gemini: 8085, - kiro: 9876, codex: 1455, agy: 51121, iflow: 11451, claude: 54545, + // kiro: Device Code Flow - no callback port // qwen: Device Code Flow - no callback port // ghcp: Device Code Flow - no callback port }; @@ -113,7 +181,9 @@ export const OAUTH_CONFIGS: Record = { displayName: 'Kiro (AWS)', authUrl: 'https://oidc.us-east-1.amazonaws.com', scopes: ['codewhisperer:completions', 'codewhisperer:conversations'], - authFlag: '--kiro-login', + // Default to AWS Builder ID device code flow for better compatibility. + // Other Kiro methods are selected at runtime via OAuthOptions.kiroMethod. + authFlag: '--kiro-aws-login', }, ghcp: { provider: 'ghcp', @@ -213,6 +283,8 @@ export interface OAuthOptions { account?: string; add?: boolean; nickname?: string; + /** Kiro auth method override (CLI + Dashboard parity). */ + kiroMethod?: KiroAuthMethod; /** 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 29e47e98..dc651216 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -29,10 +29,15 @@ import { } from '../../management/oauth-port-diagnostics'; import { OAuthOptions, - OAUTH_CALLBACK_PORTS, + DEFAULT_KIRO_AUTH_METHOD, + getKiroCallbackPort, + getKiroCLIAuthFlag, + isKiroCLIAuthMethod, + isKiroDeviceCodeMethod, getOAuthConfig, ProviderOAuthConfig, CLIPROXY_CALLBACK_PROVIDER_MAP, + normalizeKiroAuthMethod, } from './auth-types'; import { isHeadlessEnvironment, killProcessOnPort, showStep } from './environment-detector'; import { getProviderTokenDir, isAuthenticated, registerAccountFromToken } from './token-manager'; @@ -414,6 +419,8 @@ export async function triggerOAuth( const oauthConfig = getOAuthConfig(provider); const { verbose = false, add = false, fromUI = false, noIncognito = true } = options; let { nickname } = options; + const resolvedKiroMethod = + provider === 'kiro' ? normalizeKiroAuthMethod(options.kiroMethod) : DEFAULT_KIRO_AUTH_METHOD; // Check for existing accounts const existingAccounts = getProviderAccounts(provider); @@ -444,10 +451,28 @@ export async function triggerOAuth( return null; } - const callbackPort = OAUTH_PORTS[provider]; + if (provider === 'kiro' && resolvedKiroMethod === 'github') { + console.log(fail('Kiro GitHub login is only available in Dashboard management OAuth flow.')); + console.log(' Use: ccs config -> Accounts -> Add Kiro account -> Method: GitHub OAuth'); + return null; + } + + const callbackPort = + provider === 'kiro' ? getKiroCallbackPort(resolvedKiroMethod) : OAUTH_PORTS[provider]; const isCLI = !fromUI; const headless = options.headless ?? isHeadlessEnvironment(); - const isDeviceCodeFlow = callbackPort === null; + const isDeviceCodeFlow = + provider === 'kiro' ? isKiroDeviceCodeMethod(resolvedKiroMethod) : callbackPort === null; + + 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); + } // Interactive mode selection for headless environments // Skip if explicit mode flag provided or device code flow (no callback needed) @@ -493,7 +518,7 @@ export async function triggerOAuth( const { binaryPath, tokenDir, configPath } = prepared; // Free callback port if needed (only for authorization code flows) - const localCallbackPort = OAUTH_CALLBACK_PORTS[provider]; + const localCallbackPort = callbackPort; if (localCallbackPort) { const killed = killProcessOnPort(localCallbackPort, verbose); if (killed && verbose) { @@ -502,7 +527,7 @@ export async function triggerOAuth( } // Build args - const args = ['--config', configPath, oauthConfig.authFlag]; + const args = ['--config', configPath, authFlag]; if (headless) { args.push('--no-browser'); } diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index c084dc68..3ea5b1c7 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -52,6 +52,7 @@ import { import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import { installImageAnalyzerHook } from '../../utils/hooks'; import { HttpsTunnelProxy } from '../https-tunnel-proxy'; +import { isKiroAuthMethod, KiroAuthMethod, normalizeKiroAuthMethod } from '../auth/auth-types'; // Import modular components import { waitForProxyReadyWithSpinner, spawnProxy } from './lifecycle-manager'; @@ -307,6 +308,33 @@ export async function execClaudeWithCLIProxy( setNickname = argsWithoutProxy[nicknameIdx + 1]; } + // 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('-')) { + console.error(fail('--kiro-auth-method requires a value')); + console.error(' Supported values: aws, aws-authcode, google, github'); + process.exitCode = 1; + return; + } + const normalized = rawMethod.trim().toLowerCase(); + if (!isKiroAuthMethod(normalized)) { + console.error(fail(`Invalid --kiro-auth-method value: ${rawMethod}`)); + console.error(' Supported values: aws, aws-authcode, google, github'); + process.exitCode = 1; + return; + } + kiroAuthMethod = normalizeKiroAuthMethod(normalized); + } + + if (kiroAuthMethod && provider !== 'kiro' && !compositeProviders.includes('kiro')) { + console.error(fail('--kiro-auth-method is only valid for ccs kiro')); + process.exitCode = 1; + return; + } + // Parse --thinking / --effort flags (aliases; first occurrence wins) const thinkingParse = parseThinkingOverride(argsWithoutProxy); if (thinkingParse.error) { @@ -465,6 +493,7 @@ export async function execClaudeWithCLIProxy( const authSuccess = await triggerOAuth(provider, { verbose, import: true, + ...(kiroAuthMethod ? { kiroMethod: kiroAuthMethod } : {}), ...(setNickname ? { nickname: setNickname } : {}), }); if (!authSuccess) { @@ -495,6 +524,7 @@ export async function execClaudeWithCLIProxy( const authSuccess = await triggerOAuth(p, { verbose, add: addAccount, + ...(kiroAuthMethod && p === 'kiro' ? { kiroMethod: kiroAuthMethod } : {}), ...(forceHeadless ? { headless: true } : {}), ...(setNickname ? { nickname: setNickname } : {}), ...(noIncognito ? { noIncognito: true } : {}), @@ -535,6 +565,7 @@ export async function execClaudeWithCLIProxy( const authSuccess = await triggerOAuth(provider, { verbose, add: addAccount, + ...(kiroAuthMethod ? { kiroMethod: kiroAuthMethod } : {}), ...(forceHeadless ? { headless: true } : {}), ...(setNickname ? { nickname: setNickname } : {}), ...(noIncognito ? { noIncognito: true } : {}), @@ -854,6 +885,7 @@ export async function execClaudeWithCLIProxy( '--accounts', '--use', '--nickname', + '--kiro-auth-method', '--thinking', '--effort', '--1m', @@ -872,6 +904,7 @@ export async function execClaudeWithCLIProxy( if ( argsWithoutProxy[idx - 1] === '--use' || argsWithoutProxy[idx - 1] === '--nickname' || + argsWithoutProxy[idx - 1] === '--kiro-auth-method' || argsWithoutProxy[idx - 1] === '--thinking' || argsWithoutProxy[idx - 1] === '--effort' ) diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 830aa902..ecfdbd0a 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -193,6 +193,10 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs --logout', 'Clear authentication'], ['ccs --headless', 'Headless auth (for SSH)'], ['ccs --port-forward', 'Force port-forwarding mode (skip prompt)'], + ['ccs kiro --auth --kiro-auth-method aws', 'Kiro via AWS Builder ID (device code)'], + ['ccs kiro --auth --kiro-auth-method aws-authcode', 'Kiro via AWS auth code flow'], + ['ccs kiro --auth --kiro-auth-method google', 'Kiro via Google OAuth'], + ['ccs kiro --auth --kiro-auth-method github', 'Kiro via GitHub OAuth (Dashboard flow)'], ['ccs kiro --import', 'Import token from Kiro IDE'], ['ccs kiro --incognito', 'Use incognito browser (default: normal)'], ['ccs codex "explain code"', 'Use with prompt'], diff --git a/src/management/oauth-port-diagnostics.ts b/src/management/oauth-port-diagnostics.ts index d50c16cf..8a13628d 100644 --- a/src/management/oauth-port-diagnostics.ts +++ b/src/management/oauth-port-diagnostics.ts @@ -9,7 +9,7 @@ * - Codex: 1455 * - Agy: 51121 * - iFlow: 11451 - * - Kiro: 9876 + * - Kiro: Device Code Flow (no port needed) * - Claude: 54545 * - Qwen: Device Code Flow (no port needed) * - GHCP: Device Code Flow (no port needed) @@ -26,40 +26,44 @@ import { } from '../utils/port-utils'; import { CLIProxyProvider } from '../cliproxy/types'; import { CLIPROXY_PROFILES } from '../auth/profile-detector'; +import { + CLIPROXY_PROVIDER_IDS, + getOAuthCallbackPort, + getOAuthFlowType, + type OAuthFlowType as ProviderOAuthFlowType, +} from '../cliproxy/provider-capabilities'; /** - * OAuth callback ports for each provider - * Extracted from CLIProxyAPI source + * Build provider-indexed records from canonical provider capabilities. + * Keeps diagnostics in sync with runtime OAuth flow metadata. */ -export const OAUTH_CALLBACK_PORTS: Record = { - gemini: 8085, - codex: 1455, - agy: 51121, - qwen: null, // Device Code Flow - no callback port - iflow: 11451, // Authorization Code Flow - kiro: 9876, // Authorization Code Flow - ghcp: null, // Device Code Flow - no callback port - claude: 54545, // Authorization Code Flow (Anthropic OAuth) -}; +function buildProviderMap( + valueFor: (provider: CLIProxyProvider) => T +): Record { + return CLIPROXY_PROVIDER_IDS.reduce( + (acc, provider) => { + acc[provider] = valueFor(provider); + return acc; + }, + {} as Record + ); +} + +export const OAUTH_CALLBACK_PORTS: Record = buildProviderMap( + (provider) => getOAuthCallbackPort(provider) +); /** * OAuth flow types */ -export type OAuthFlowType = 'authorization_code' | 'device_code'; +export type OAuthFlowType = ProviderOAuthFlowType; /** * OAuth flow type per provider */ -export const OAUTH_FLOW_TYPES: Record = { - gemini: 'authorization_code', - codex: 'authorization_code', - agy: 'authorization_code', - qwen: 'device_code', - iflow: 'authorization_code', - kiro: 'authorization_code', - ghcp: 'device_code', - claude: 'authorization_code', -}; +export const OAUTH_FLOW_TYPES: Record = buildProviderMap( + (provider) => getOAuthFlowType(provider) +); /** * Port diagnostic result diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 302323ba..a940a117 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -37,7 +37,13 @@ import { getProviderTokenDir } from '../../cliproxy/auth/token-manager'; import { CLIPROXY_CALLBACK_PROVIDER_MAP, CLIPROXY_AUTH_URL_PROVIDER_MAP, + isKiroAuthMethod, + isKiroDeviceCodeMethod, + KiroAuthMethod, + normalizeKiroAuthMethod, + toKiroManagementMethod, } from '../../cliproxy/auth/auth-types'; +import { getOAuthFlowType } from '../../cliproxy/provider-capabilities'; import type { CLIProxyProvider } from '../../cliproxy/types'; import { CLIPROXY_PROFILES } from '../../auth/profile-detector'; @@ -46,6 +52,44 @@ const router = Router(); // Valid providers list - derived from canonical CLIPROXY_PROFILES const validProviders: CLIProxyProvider[] = [...CLIPROXY_PROFILES]; +function parseKiroMethod(raw: unknown): { method: KiroAuthMethod; invalid: boolean } { + if (raw === undefined || raw === null) { + return { method: normalizeKiroAuthMethod(), invalid: false }; + } + if (typeof raw !== 'string') { + return { method: normalizeKiroAuthMethod(), invalid: true }; + } + if (raw.trim() === '') { + return { method: normalizeKiroAuthMethod(), invalid: false }; + } + const normalized = raw.trim().toLowerCase(); + if (!isKiroAuthMethod(normalized)) { + return { method: normalizeKiroAuthMethod(), invalid: true }; + } + return { method: normalizeKiroAuthMethod(normalized), invalid: false }; +} + +export function getStartUrlUnsupportedReason( + provider: CLIProxyProvider, + options?: { kiroMethod?: KiroAuthMethod } +): string | null { + if (provider === 'kiro') { + const kiroMethod = options?.kiroMethod ?? normalizeKiroAuthMethod(); + if (kiroMethod === 'aws-authcode') { + return "Kiro method 'aws-authcode' uses CLI auth flow. Use /api/cliproxy/auth/kiro/start instead."; + } + if (isKiroDeviceCodeMethod(kiroMethod)) { + return "Kiro method 'aws' uses Device Code flow. Use /api/cliproxy/auth/kiro/start instead."; + } + return null; + } + + if (getOAuthFlowType(provider) === 'device_code') { + return `Provider '${provider}' uses Device Code flow. Use /api/cliproxy/auth/${provider}/start instead.`; + } + return null; +} + /** * GET /api/cliproxy/auth - Get auth status for built-in CLIProxy profiles * Also fetches CLIProxyAPI stats to update lastUsedAt for active providers @@ -343,9 +387,14 @@ router.post('/accounts/:provider/:accountId/resume', (req: Request, res: Respons */ router.post('/:provider/start', async (req: Request, res: Response): Promise => { const { provider } = req.params; - const { nickname: nicknameRaw, noIncognito: noIncognitoBody } = req.body; + const { + nickname: nicknameRaw, + noIncognito: noIncognitoBody, + kiroMethod: kiroMethodRaw, + } = req.body; // Trim nickname for consistency with CLI (oauth-handler.ts trims input) const nickname = typeof nicknameRaw === 'string' ? nicknameRaw.trim() : nicknameRaw; + const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw); // Validate provider if (!validProviders.includes(provider as CLIProxyProvider)) { @@ -353,6 +402,14 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise */ router.post('/:provider/start-url', async (req: Request, res: Response): Promise => { const { provider } = req.params; + const { kiroMethod: kiroMethodRaw } = req.body ?? {}; + const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw); // Check remote mode const target = getProxyTarget(); @@ -558,14 +618,34 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise return; } + if (provider === 'kiro' && invalidKiroMethod) { + res.status(400).json({ + error: 'Invalid kiroMethod. Supported: aws, aws-authcode, google, github', + code: 'INVALID_KIRO_METHOD', + }); + return; + } + + const unsupportedReason = getStartUrlUnsupportedReason(provider as CLIProxyProvider, { + kiroMethod: provider === 'kiro' ? kiroMethod : undefined, + }); + if (unsupportedReason) { + res.status(400).json({ error: unsupportedReason }); + return; + } + try { const authUrlProvider = CLIPROXY_AUTH_URL_PROVIDER_MAP[provider as CLIProxyProvider] || provider; + const kiroQuery = + provider === 'kiro' + ? `&method=${encodeURIComponent(toKiroManagementMethod(kiroMethod))}` + : ''; // Call CLIProxyAPI to start OAuth and get auth URL // CLIProxyAPI management routes are under /v0/management prefix const response = await fetch( - buildProxyUrl(target, `/v0/management/${authUrlProvider}-auth-url?is_webui=true`), + buildProxyUrl(target, `/v0/management/${authUrlProvider}-auth-url?is_webui=true${kiroQuery}`), { headers: buildManagementHeaders(target) } ); @@ -575,18 +655,27 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise return; } - const data = (await response.json()) as { url?: string; auth_url?: string; state?: string }; + const data = (await response.json()) as { + url?: string; + auth_url?: string; + state?: string; + method?: string; + }; const authUrl = data.url || data.auth_url; - if (!authUrl) { - res.status(500).json({ error: 'No authorization URL received from CLIProxyAPI' }); + // Some upstream flows return state first and provide auth_url in subsequent status polling. + if (!authUrl && !data.state) { + res + .status(500) + .json({ error: 'No OAuth state or authorization URL received from CLIProxyAPI' }); return; } res.json({ success: true, - authUrl, - state: data.state, + authUrl: authUrl || null, + state: data.state || null, + method: data.method || null, }); } catch (error) { const message = error instanceof Error ? error.message : 'Failed to start OAuth'; diff --git a/tests/unit/cliproxy/provider-capabilities.test.ts b/tests/unit/cliproxy/provider-capabilities.test.ts index 7aa13e7e..c92c702f 100644 --- a/tests/unit/cliproxy/provider-capabilities.test.ts +++ b/tests/unit/cliproxy/provider-capabilities.test.ts @@ -2,11 +2,24 @@ import { describe, expect, it } from 'bun:test'; import { CLIPROXY_PROVIDER_IDS, getOAuthCallbackPort, + getOAuthFlowType, getProviderDisplayName, getProvidersByOAuthFlow, isCLIProxyProvider, mapExternalProviderName, } from '../../../src/cliproxy/provider-capabilities'; +import { + OAUTH_CALLBACK_PORTS as DIAGNOSTIC_CALLBACK_PORTS, + OAUTH_FLOW_TYPES, +} from '../../../src/management/oauth-port-diagnostics'; +import { + DEFAULT_KIRO_AUTH_METHOD, + getKiroCallbackPort, + getKiroCLIAuthFlag, + normalizeKiroAuthMethod, + OAUTH_CALLBACK_PORTS as AUTH_CALLBACK_PORTS, + toKiroManagementMethod, +} from '../../../src/cliproxy/auth/auth-types'; describe('provider-capabilities', () => { it('keeps canonical provider IDs backward-compatible', () => { @@ -56,4 +69,38 @@ describe('provider-capabilities', () => { expect(getOAuthCallbackPort('gemini')).toBe(8085); expect(getProviderDisplayName('agy')).toBe('AntiGravity'); }); + + it('keeps diagnostics flow metadata in sync with provider capabilities', () => { + for (const provider of CLIPROXY_PROVIDER_IDS) { + expect(OAUTH_FLOW_TYPES[provider]).toBe(getOAuthFlowType(provider)); + expect(DIAGNOSTIC_CALLBACK_PORTS[provider]).toBe(getOAuthCallbackPort(provider)); + } + }); + + it('does not define callback ports for device code providers in auth constants', () => { + for (const provider of getProvidersByOAuthFlow('device_code')) { + expect(AUTH_CALLBACK_PORTS[provider]).toBeUndefined(); + } + }); + + it('maps Kiro auth methods to upstream CLI/management contracts', () => { + expect(DEFAULT_KIRO_AUTH_METHOD).toBe('aws'); + expect(normalizeKiroAuthMethod()).toBe('aws'); + expect(normalizeKiroAuthMethod('GOOGLE')).toBe('google'); + expect(normalizeKiroAuthMethod('not-valid')).toBe('aws'); + + expect(getKiroCLIAuthFlag('aws')).toBe('--kiro-aws-login'); + expect(getKiroCLIAuthFlag('aws-authcode')).toBe('--kiro-aws-authcode'); + expect(getKiroCLIAuthFlag('google')).toBe('--kiro-google-login'); + + expect(getKiroCallbackPort('aws')).toBeNull(); + expect(getKiroCallbackPort('google')).toBe(9876); + expect(getKiroCallbackPort('github')).toBe(9876); + expect(getKiroCallbackPort('aws-authcode')).toBe(9876); + + expect(toKiroManagementMethod('aws')).toBe('aws'); + expect(toKiroManagementMethod('aws-authcode')).toBe('aws'); + expect(toKiroManagementMethod('google')).toBe('google'); + expect(toKiroManagementMethod('github')).toBe('github'); + }); }); diff --git a/tests/unit/web-server/cliproxy-auth-routes.test.ts b/tests/unit/web-server/cliproxy-auth-routes.test.ts new file mode 100644 index 00000000..1ad73d09 --- /dev/null +++ b/tests/unit/web-server/cliproxy-auth-routes.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'bun:test'; +import { getStartUrlUnsupportedReason } from '../../../src/web-server/routes/cliproxy-auth-routes'; + +describe('cliproxy-auth-routes start-url guard', () => { + it('rejects device code providers', () => { + expect(getStartUrlUnsupportedReason('kiro')).toContain("Kiro method 'aws' uses Device Code flow"); + expect(getStartUrlUnsupportedReason('ghcp')).toContain("Provider 'ghcp' uses Device Code flow"); + expect(getStartUrlUnsupportedReason('qwen')).toContain("Provider 'qwen' uses Device Code flow"); + }); + + it('allows Kiro social methods on start-url', () => { + expect(getStartUrlUnsupportedReason('kiro', { kiroMethod: 'google' })).toBeNull(); + expect(getStartUrlUnsupportedReason('kiro', { kiroMethod: 'github' })).toBeNull(); + }); + + it('rejects Kiro aws-authcode method on start-url', () => { + expect(getStartUrlUnsupportedReason('kiro', { kiroMethod: 'aws-authcode' })).toContain( + "Kiro method 'aws-authcode' uses CLI auth flow" + ); + }); + + it('allows authorization code providers', () => { + expect(getStartUrlUnsupportedReason('gemini')).toBeNull(); + expect(getStartUrlUnsupportedReason('codex')).toBeNull(); + expect(getStartUrlUnsupportedReason('claude')).toBeNull(); + }); +}); diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx index 55ef8079..8a250680 100644 --- a/ui/src/components/account/add-account-dialog.tsx +++ b/ui/src/components/account/add-account-dialog.tsx @@ -1,7 +1,7 @@ /** * Add Account Dialog Component * Uses /start-url to get OAuth URL + polls for completion via management API. - * For Device Code flows (ghcp, qwen): Uses /start endpoint which spawns CLIProxy + * For Device Code flows (ghcp, qwen, kiro): Uses /start endpoint which spawns CLIProxy * binary and emits WebSocket events. DeviceCodeDialog handles user code display. * Shows auth URL + callback paste field. Polling auto-closes on success. * For Kiro: Also shows "Import from IDE" option. @@ -18,11 +18,25 @@ import { import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { Loader2, ExternalLink, User, Download, Copy, Check } from 'lucide-react'; import { useKiroImport } from '@/hooks/use-cliproxy'; import { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow'; import { applyDefaultPreset } from '@/lib/preset-utils'; -import { isDeviceCodeProvider } from '@/lib/provider-config'; +import { + DEFAULT_KIRO_AUTH_METHOD, + getKiroAuthMethodOption, + isDeviceCodeProvider, + isNicknameRequiredProvider, + KIRO_AUTH_METHOD_OPTIONS, +} from '@/lib/provider-config'; +import type { KiroAuthMethod } from '@/lib/provider-config'; import { toast } from 'sonner'; interface AddAccountDialogProps { @@ -44,18 +58,27 @@ export function AddAccountDialog({ const [nickname, setNickname] = useState(''); const [callbackUrl, setCallbackUrl] = useState(''); const [copied, setCopied] = useState(false); + const [localError, setLocalError] = useState(null); + const [kiroAuthMethod, setKiroAuthMethod] = useState(DEFAULT_KIRO_AUTH_METHOD); const wasAuthenticatingRef = useRef(false); const authFlow = useCliproxyAuthFlow(); const kiroImportMutation = useKiroImport(); const isKiro = provider === 'kiro'; - const isDeviceCode = isDeviceCodeProvider(provider); + const defaultDeviceCode = isDeviceCodeProvider(provider); + const requiresNickname = isNicknameRequiredProvider(provider); + const kiroMethodOption = getKiroAuthMethodOption(kiroAuthMethod); + const isDeviceCode = isKiro ? kiroMethodOption.flowType === 'device_code' : defaultDeviceCode; const isPending = authFlow.isAuthenticating || kiroImportMutation.isPending; + const nicknameTrimmed = nickname.trim(); + const errorMessage = localError || authFlow.error; const resetAndClose = () => { setNickname(''); setCallbackUrl(''); setCopied(false); + setLocalError(null); + setKiroAuthMethod(DEFAULT_KIRO_AUTH_METHOD); wasAuthenticatingRef.current = false; onClose(); }; @@ -103,13 +126,23 @@ export function AddAccountDialog({ }; /** - * Authenticate via /start-url + polling only. - * Does NOT call /start (which spawns a local CLIProxy binary that kills running instances). - * /start-url uses the management API to get auth URL, then polls for completion. + * Start auth flow using provider capabilities. + * - Device code providers use /start and rely on WebSocket events for code display. + * - Authorization code providers use /start-url and polling. */ const handleAuthenticate = () => { + if (requiresNickname && !nicknameTrimmed) { + setLocalError(`Nickname is required for ${displayName} accounts.`); + return; + } + setLocalError(null); wasAuthenticatingRef.current = true; - authFlow.startAuth(provider, { nickname: nickname.trim() || undefined }); + authFlow.startAuth(provider, { + nickname: nicknameTrimmed || undefined, + kiroMethod: isKiro ? kiroAuthMethod : undefined, + flowType: isKiro ? kiroMethodOption.flowType : undefined, + startEndpoint: isKiro ? kiroMethodOption.startEndpoint : undefined, + }); }; const handleKiroImport = () => { @@ -146,7 +179,7 @@ export function AddAccountDialog({ Add {displayName} Account {isKiro - ? 'Authenticate via browser or import an existing token from Kiro IDE.' + ? 'Choose a Kiro auth method, then authenticate via browser or import from Kiro IDE.' : isDeviceCode ? 'Click Authenticate. A verification code will appear for you to enter on the provider website.' : 'Click Authenticate to get an OAuth URL. Open it in any browser to sign in.'} @@ -154,23 +187,56 @@ export function AddAccountDialog({
+ {/* Kiro auth method */} + {isKiro && !showAuthUI && ( +
+ + +

{kiroMethodOption.description}

+
+ )} + {/* Nickname input - only show before auth starts */} {!showAuthUI && (
- +
setNickname(e.target.value)} + onChange={(e) => { + setNickname(e.target.value); + setLocalError(null); + }} placeholder="e.g., work, personal" disabled={isPending} className="flex-1" />

- A friendly name to identify this account. Auto-generated from email if left empty. + {requiresNickname + ? 'Required for this provider. Use a unique friendly name (e.g., work, personal).' + : 'A friendly name to identify this account. Auto-generated from email if left empty.'}

)} @@ -191,11 +257,6 @@ export function AddAccountDialog({

- {/* Error display */} - {authFlow.error && !authFlow.authUrl && ( -

{authFlow.error}

- )} - {/* Auth URL section - only for Authorization Code flows, NOT Device Code */} {authFlow.authUrl && !authFlow.isDeviceCodeFlow && (
@@ -270,9 +331,18 @@ export function AddAccountDialog({
)} + + {!authFlow.authUrl && !authFlow.isDeviceCodeFlow && ( +

+ Preparing sign-in URL... +

+ )} )} + {/* Persist error visibility outside auth-only UI states */} + {errorMessage &&

{errorMessage}

} + {/* Kiro import loading */} {kiroImportMutation.isPending && (

@@ -302,7 +372,10 @@ export function AddAccountDialog({ )} {!showAuthUI && ( - diff --git a/ui/src/hooks/use-cliproxy-auth-flow.ts b/ui/src/hooks/use-cliproxy-auth-flow.ts index 433af6e2..a1f8f954 100644 --- a/ui/src/hooks/use-cliproxy-auth-flow.ts +++ b/ui/src/hooks/use-cliproxy-auth-flow.ts @@ -19,12 +19,15 @@ interface AuthFlowState { oauthState: string | null; /** Whether callback is being submitted */ isSubmittingCallback: boolean; - /** Whether this is a device code flow (ghcp, qwen) - dialog handled separately via WebSocket */ + /** Whether this is a device code flow (ghcp, qwen, kiro) - dialog handled separately via WebSocket */ isDeviceCodeFlow: boolean; } interface StartAuthOptions { nickname?: string; + kiroMethod?: string; + flowType?: 'authorization_code' | 'device_code'; + startEndpoint?: 'start' | 'start-url'; } /** Polling interval for OAuth status check (3 seconds) */ @@ -49,6 +52,7 @@ export function useCliproxyAuthFlow() { const abortControllerRef = useRef(null); const pollIntervalRef = useRef | null>(null); const pollStartRef = useRef(0); + const openedAuthUrlRef = useRef(false); const queryClient = useQueryClient(); // Clear polling @@ -64,6 +68,7 @@ export function useCliproxyAuthFlow() { return () => { abortControllerRef.current?.abort(); stopPolling(); + openedAuthUrlRef.current = false; }; }, [stopPolling]); @@ -85,14 +90,46 @@ export function useCliproxyAuthFlow() { const response = await fetch( `/api/cliproxy/auth/${provider}/status?state=${encodeURIComponent(oauthState)}` ); - const data = await response.json(); + const data = (await response.json()) as { + status?: string; + error?: string; + url?: string; + auth_url?: string; + verification_url?: string; + user_code?: string; + }; if (data.status === 'ok') { stopPolling(); queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); queryClient.invalidateQueries({ queryKey: ['account-quota'] }); toast.success(`${provider} authentication successful`); + openedAuthUrlRef.current = false; setState(INITIAL_STATE); + } else if (data.status === 'auth_url') { + const authUrl = data.url || data.auth_url; + if (authUrl) { + setState((prev) => ({ + ...prev, + authUrl, + })); + if (!openedAuthUrlRef.current) { + openedAuthUrlRef.current = true; + window.open(authUrl, '_blank'); + } + } + } else if (data.status === 'device_code') { + stopPolling(); + const details = + data.user_code && data.verification_url + ? `Open ${data.verification_url} and enter code: ${data.user_code}` + : 'Switch to Device Code method and try again.'; + toast.error('Provider returned Device Code flow in callback mode'); + setState((prev) => ({ + ...prev, + isAuthenticating: false, + error: details, + })); } else if (data.status === 'error') { stopPolling(); const errorMsg = data.error || 'Authentication failed'; @@ -103,7 +140,7 @@ export function useCliproxyAuthFlow() { error: errorMsg, })); } - // status === 'pending' means continue polling + // status === 'wait' (or pending) means continue polling } catch { // Network error - continue polling } @@ -124,12 +161,21 @@ export function useCliproxyAuthFlow() { // Abort any in-progress auth abortControllerRef.current?.abort(); stopPolling(); + openedAuthUrlRef.current = false; // Create fresh controller and capture locally to avoid race with cancelAuth const controller = new AbortController(); abortControllerRef.current = controller; - const deviceCodeFlow = isDeviceCodeProvider(provider); + const flowType = + options?.flowType || + (isDeviceCodeProvider(provider) ? 'device_code' : 'authorization_code'); + const deviceCodeFlow = flowType === 'device_code'; + const startEndpoint = options?.startEndpoint || (deviceCodeFlow ? 'start' : 'start-url'); + const payload = { + nickname: options?.nickname, + kiroMethod: options?.kiroMethod, + }; setState({ provider, @@ -142,14 +188,13 @@ export function useCliproxyAuthFlow() { }); try { - if (deviceCodeFlow) { - // Device Code Flow: Call /start endpoint which spawns CLIProxyAPI binary. - // This emits WebSocket events with userCode that DeviceCodeDialog will display. - // The /start endpoint blocks until completion, so we don't await it here. + if (startEndpoint === 'start') { + // /start spawns CLIProxy binary and blocks until completion. + // For Device Code flows, userCode is delivered via WebSocket. fetch(`/api/cliproxy/auth/${provider}/start`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ nickname: options?.nickname }), + body: JSON.stringify(payload), signal: controller.signal, }) .then(async (response) => { @@ -159,6 +204,7 @@ export function useCliproxyAuthFlow() { queryClient.invalidateQueries({ queryKey: ['account-quota'] }); // Note: No toast here - DeviceCodeDialog's useDeviceCode hook handles success toast // via deviceCodeCompleted WebSocket event to avoid duplicate toasts + openedAuthUrlRef.current = false; setState(INITIAL_STATE); } else { const errorMsg = data.error || 'Authentication failed'; @@ -183,13 +229,13 @@ export function useCliproxyAuthFlow() { error: message, })); }); - // Don't await - let the request run in background while DeviceCodeDialog handles UI + // Don't await - keeps UI responsive while backend auth is in progress } else { - // Authorization Code Flow: Call /start-url to get auth URL immediately (non-blocking) + // /start-url uses management API to bootstrap callback/social flows. const response = await fetch(`/api/cliproxy/auth/${provider}/start-url`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ nickname: options?.nickname }), + body: JSON.stringify(payload), signal: controller.signal, }); @@ -202,12 +248,13 @@ export function useCliproxyAuthFlow() { // Update state with auth URL setState((prev) => ({ ...prev, - authUrl: data.authUrl, + authUrl: data.authUrl || null, oauthState: data.state, })); // Auto-open auth URL in new browser tab (fallback URL still shown in dialog) if (data.authUrl) { + openedAuthUrlRef.current = true; window.open(data.authUrl, '_blank'); } @@ -221,6 +268,7 @@ export function useCliproxyAuthFlow() { } } catch (error) { if (error instanceof Error && error.name === 'AbortError') { + openedAuthUrlRef.current = false; setState(INITIAL_STATE); return; } @@ -240,6 +288,7 @@ export function useCliproxyAuthFlow() { const currentProvider = state.provider; abortControllerRef.current?.abort(); stopPolling(); + openedAuthUrlRef.current = false; setState(INITIAL_STATE); // Also cancel on backend if (currentProvider) { diff --git a/ui/src/hooks/use-device-code.ts b/ui/src/hooks/use-device-code.ts index a8492068..8c69bd77 100644 --- a/ui/src/hooks/use-device-code.ts +++ b/ui/src/hooks/use-device-code.ts @@ -3,7 +3,7 @@ * * Listens for WebSocket device code events and manages dialog state. * Similar to useProjectSelection but for Device Code OAuth flows - * (GitHub Copilot, Qwen, etc.) + * (GitHub Copilot, Qwen, Kiro, etc.) */ import { useState, useEffect, useCallback, useMemo } from 'react'; @@ -26,6 +26,7 @@ interface DeviceCodeState { /** Provider display names for user-friendly messages */ const PROVIDER_DISPLAY_NAMES: Record = { ghcp: 'GitHub Copilot', + kiro: 'Kiro (AWS)', qwen: 'Qwen Code', }; diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts index bdcbd378..05e2a958 100644 --- a/ui/src/lib/provider-config.ts +++ b/ui/src/lib/provider-config.ts @@ -82,3 +82,75 @@ export const DEVICE_CODE_PROVIDERS: CLIProxyProvider[] = ['ghcp', 'kiro', 'qwen' export function isDeviceCodeProvider(provider: string): boolean { return DEVICE_CODE_PROVIDERS.includes(provider as CLIProxyProvider); } + +/** Providers that require nickname because token payload may not include email. */ +export const NICKNAME_REQUIRED_PROVIDERS: CLIProxyProvider[] = ['ghcp', 'kiro']; + +/** Check if provider requires user-supplied nickname in auth flow */ +export function isNicknameRequiredProvider(provider: string): boolean { + return NICKNAME_REQUIRED_PROVIDERS.includes(provider as CLIProxyProvider); +} + +/** Kiro auth methods exposed in CCS UI (aligned with CLIProxyAPIPlus support). */ +export const KIRO_AUTH_METHODS = ['aws', 'aws-authcode', 'google', 'github'] as const; +export type KiroAuthMethod = (typeof KIRO_AUTH_METHODS)[number]; + +export type KiroFlowType = 'authorization_code' | 'device_code'; +export type KiroStartEndpoint = 'start' | 'start-url'; + +export interface KiroAuthMethodOption { + id: KiroAuthMethod; + label: string; + description: string; + flowType: KiroFlowType; + startEndpoint: KiroStartEndpoint; +} + +/** UX-first default for issue #233: AWS Builder ID device flow. */ +export const DEFAULT_KIRO_AUTH_METHOD: KiroAuthMethod = 'aws'; + +export const KIRO_AUTH_METHOD_OPTIONS: readonly KiroAuthMethodOption[] = [ + { + id: 'aws', + label: 'AWS Builder ID (Recommended)', + description: 'Device code flow for AWS organizations and Builder ID accounts.', + flowType: 'device_code', + startEndpoint: 'start', + }, + { + id: 'aws-authcode', + label: 'AWS Builder ID (Auth Code)', + description: 'Authorization code flow via CLI binary.', + flowType: 'authorization_code', + startEndpoint: 'start', + }, + { + id: 'google', + label: 'Google OAuth', + description: 'Social OAuth flow with callback URL support.', + flowType: 'authorization_code', + startEndpoint: 'start-url', + }, + { + id: 'github', + label: 'GitHub OAuth', + description: 'Social OAuth flow via management API callback.', + flowType: 'authorization_code', + startEndpoint: 'start-url', + }, +]; + +export function isKiroAuthMethod(value: string): value is KiroAuthMethod { + return KIRO_AUTH_METHODS.includes(value as KiroAuthMethod); +} + +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 getKiroAuthMethodOption(method: KiroAuthMethod): KiroAuthMethodOption { + const option = KIRO_AUTH_METHOD_OPTIONS.find((candidate) => candidate.id === method); + return option || KIRO_AUTH_METHOD_OPTIONS[0]; +}