Merge pull request #561 from kaitranntt/fix/552-review-followups

fix(cliproxy): harden Kiro device-code auth flow consistency
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-02-14 13:38:00 +07:00
commit c9b4a9ccba
14 files changed
+600 -77

No files matched your search

+75 -3
View File
@@ -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=<value>
*/
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<Record<CLIProxyProvider, number>> = {
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<CLIProxyProvider, ProviderOAuthConfig> = {
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) */
+30 -5
View File
@@ -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');
}
+33
View File
@@ -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'
)
+4
View File
@@ -193,6 +193,10 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
['ccs <provider> --logout', 'Clear authentication'],
['ccs <provider> --headless', 'Headless auth (for SSH)'],
['ccs <provider> --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'],
+28 -24
View File
@@ -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<CLIProxyProvider, number | null> = {
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<T>(
valueFor: (provider: CLIProxyProvider) => T
): Record<CLIProxyProvider, T> {
return CLIPROXY_PROVIDER_IDS.reduce(
(acc, provider) => {
acc[provider] = valueFor(provider);
return acc;
},
{} as Record<CLIProxyProvider, T>
);
}
export const OAUTH_CALLBACK_PORTS: Record<CLIProxyProvider, number | null> = 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<CLIProxyProvider, OAuthFlowType> = {
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<CLIProxyProvider, OAuthFlowType> = buildProviderMap(
(provider) => getOAuthFlowType(provider)
);
/**
* Port diagnostic result
+96 -7
View File
@@ -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<void> => {
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<voi
return;
}
if (provider === 'kiro' && invalidKiroMethod) {
res.status(400).json({
error: 'Invalid kiroMethod. Supported: aws, aws-authcode, google, github',
code: 'INVALID_KIRO_METHOD',
});
return;
}
// For kiro/ghcp: nickname is required
if (PROVIDERS_WITHOUT_EMAIL.includes(provider as CLIProxyProvider)) {
if (!nickname) {
@@ -400,6 +457,7 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
add: true, // Always add mode from UI
headless: false, // Force interactive mode
nickname: nickname || undefined,
kiroMethod: provider === 'kiro' ? kiroMethod : undefined,
fromUI: true, // Enable project selection prompt in UI
noIncognito, // Kiro: use normal browser if enabled
});
@@ -544,6 +602,8 @@ router.post('/kiro/import', async (_req: Request, res: Response): Promise<void>
*/
router.post('/:provider/start-url', async (req: Request, res: Response): Promise<void> => {
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';