mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 12:15:57 +00:00
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
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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<string | null> {
|
||||
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<string | null> {
|
||||
const readline = await import('readline');
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
@@ -124,53 +204,71 @@ async function promptManualCallbackUrl(displayName: string): Promise<string | nu
|
||||
|
||||
return new Promise<string | null>((resolve) => {
|
||||
let settled = false;
|
||||
let timeout: ReturnType<typeof setTimeout> | 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<boolean> {
|
||||
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<Accou
|
||||
userCode: null,
|
||||
kiroMethodSelectionHandled: false,
|
||||
manualCallbackPrompted: false,
|
||||
cancelManualCallbackPrompt: null,
|
||||
};
|
||||
|
||||
// Register session for cancellation support
|
||||
@@ -570,13 +676,27 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
|
||||
await handleStdout(data.toString(), state, options, authProcess, log);
|
||||
});
|
||||
|
||||
authProcess.stderr?.on('data', (data: Buffer) => {
|
||||
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<Accou
|
||||
|
||||
// Timeout handling
|
||||
// Device code flows need longer timeout to match CLIProxy binary's polling window (60 attempts × 5s = 300s)
|
||||
const timeoutMs = headless || isDeviceCodeFlow ? 300000 : 120000;
|
||||
const timeoutMs = options.manualCallback
|
||||
? 10 * 60 * 1000
|
||||
: headless || isDeviceCodeFlow
|
||||
? 300000
|
||||
: 120000;
|
||||
const timeout = setTimeout(() => {
|
||||
// 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<Accou
|
||||
clearTimeout(timeout);
|
||||
// H7: Clear stdin keepalive interval
|
||||
if (stdinKeepalive) clearInterval(stdinKeepalive);
|
||||
state.cancelManualCallbackPrompt?.();
|
||||
// H5: Remove signal handlers to prevent memory leaks
|
||||
process.removeListener('SIGINT', cleanup);
|
||||
process.removeListener('SIGTERM', cleanup);
|
||||
@@ -696,6 +822,7 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
|
||||
clearTimeout(timeout);
|
||||
// H7: Clear stdin keepalive interval
|
||||
if (stdinKeepalive) clearInterval(stdinKeepalive);
|
||||
state.cancelManualCallbackPrompt?.();
|
||||
// H5: Remove signal handlers to prevent memory leaks
|
||||
process.removeListener('SIGINT', cleanup);
|
||||
process.removeListener('SIGTERM', cleanup);
|
||||
|
||||
@@ -117,6 +117,34 @@ const DEFAULT_CONFIG: ExecutorConfig = {
|
||||
pollInterval: 100,
|
||||
};
|
||||
|
||||
export function readOptionValue(
|
||||
args: string[],
|
||||
flag: string
|
||||
): { present: boolean; value?: string; missingValue: boolean } {
|
||||
const inlinePrefix = `${flag}=`;
|
||||
const inlineArg = args.find((arg) => 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;
|
||||
|
||||
@@ -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<voi
|
||||
const noIncognitoBody =
|
||||
typeof requestBody.noIncognito === 'boolean' ? requestBody.noIncognito : undefined;
|
||||
const kiroMethodRaw = requestBody.kiroMethod;
|
||||
const kiroIDCStartUrl =
|
||||
typeof requestBody.kiroIDCStartUrl === 'string'
|
||||
? requestBody.kiroIDCStartUrl.trim()
|
||||
: undefined;
|
||||
const kiroIDCRegion =
|
||||
typeof requestBody.kiroIDCRegion === 'string' ? requestBody.kiroIDCRegion.trim() : undefined;
|
||||
const kiroIDCFlowRaw = requestBody.kiroIDCFlow;
|
||||
const riskAcknowledgement = requestBody.riskAcknowledgement;
|
||||
const target = getProxyTarget();
|
||||
if (target.isRemote) {
|
||||
@@ -609,6 +656,7 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
|
||||
// Trim nickname for consistency with CLI (oauth-handler.ts trims input)
|
||||
const nickname = nicknameRaw?.trim();
|
||||
const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw);
|
||||
const { flow: kiroIDCFlow, invalid: invalidKiroIDCFlow } = parseKiroIDCFlow(kiroIDCFlowRaw);
|
||||
|
||||
// Validate provider
|
||||
if (!validProviders.includes(provider as CLIProxyProvider)) {
|
||||
@@ -624,6 +672,18 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider === 'kiro') {
|
||||
const kiroIDCValidationError = getKiroStartIDCValidationError({
|
||||
kiroMethod,
|
||||
kiroIDCStartUrl,
|
||||
invalidKiroIDCFlow,
|
||||
});
|
||||
if (kiroIDCValidationError) {
|
||||
res.status(400).json(kiroIDCValidationError);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === 'agy' && !isAntigravityResponsibilityBypassEnabled()) {
|
||||
const validation = validateAntigravityRiskAcknowledgement(riskAcknowledgement);
|
||||
if (!validation.valid) {
|
||||
@@ -662,6 +722,9 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
|
||||
nickname: nickname || undefined,
|
||||
acceptAgyRisk: provider === 'agy',
|
||||
kiroMethod: provider === 'kiro' ? kiroMethod : undefined,
|
||||
kiroIDCStartUrl: provider === 'kiro' ? kiroIDCStartUrl : undefined,
|
||||
kiroIDCRegion: provider === 'kiro' ? kiroIDCRegion : undefined,
|
||||
kiroIDCFlow: provider === 'kiro' && kiroMethod === 'idc' ? kiroIDCFlow : undefined,
|
||||
fromUI: true, // Enable project selection prompt in UI
|
||||
noIncognito, // Kiro: use normal browser if enabled
|
||||
});
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { readOptionValue } from '../../../src/cliproxy/executor/index';
|
||||
|
||||
describe('readOptionValue', () => {
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user