mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 16:19:27 +00:00
Merge pull request #916 from kaitranntt/kai/fix/kiro-auth-912-913-914
fix(kiro): align auth flows with CLIProxyAPIPlus
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# CCS Project Roadmap
|
||||
|
||||
Last Updated: 2026-04-04
|
||||
Last Updated: 2026-04-05
|
||||
|
||||
Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans.
|
||||
|
||||
@@ -42,6 +42,7 @@ All major modularization work is complete. The codebase evolved from monolithic
|
||||
### Recent Fixes
|
||||
|
||||
- **2026-04-04**: The GitHub README was reduced from a wall-of-text reference dump into a shorter conversion surface that keeps the hero, proof screenshots, and fast-start commands while delegating deeper installation, provider, feature, and CLI-reference content to `docs.ccs.kaitran.ca`. The docs site now includes a dedicated `Product Tour` page for the screenshot-led walkthrough.
|
||||
- **2026-04-05**: **#912 #913 #914** Kiro auth is now aligned with the current CLIProxyAPIPlus contract. CCS auto-selects the Builder ID path for the default `ccs kiro --auth` flow instead of stalling on the upstream Builder ID vs IDC chooser, callback-based Kiro auth methods can use `--paste-callback` by replaying the pasted redirect URL back into the local callback server, and the CLI now supports IDC auth via `--kiro-auth-method idc` plus `--kiro-idc-start-url`, `--kiro-idc-region`, and `--kiro-idc-flow`.
|
||||
- **2026-04-03**: CCS CLI help and completion UX was refreshed. Root help is now shorter and task-oriented, `ccs help <topic|command>` routes to topic-aware help, and shell completions now delegate to the hidden `ccs __complete` backend.
|
||||
- **2026-04-02**: Third-party image and PDF analysis now follows the same first-class local-tool model as WebSearch. CCS provisions `ccs-image-analysis` as a managed MCP tool, routes requests directly to provider-scoped CCS endpoints such as `/api/provider/agy/v1/messages`, keeps editable prompt templates under `~/.ccs/prompts/image-analysis/`, and demotes the old `Read` hook to a best-effort compatibility fallback. Launches now stay non-fatal and fall back to native `Read` when the managed runtime cannot be prepared.
|
||||
- **2026-04-01**: The `Compatible -> Codex CLI` dashboard now exposes manual long-context controls for `model_context_window` and `model_auto_compact_token_limit`. CCS reads and patches those upstream Codex config keys directly, adds official guidance that GPT-5.4 long context is experimental and opt-in, and keeps the behavior manual-only so the dashboard never auto-fills or auto-saves long-context values for the user.
|
||||
|
||||
@@ -757,6 +757,9 @@ async function main(): Promise<void> {
|
||||
'--port-forward',
|
||||
'--nickname',
|
||||
'--kiro-auth-method',
|
||||
'--kiro-idc-start-url',
|
||||
'--kiro-idc-region',
|
||||
'--kiro-idc-flow',
|
||||
'--backend',
|
||||
'--proxy-host',
|
||||
'--proxy-port',
|
||||
|
||||
@@ -22,14 +22,19 @@ import {
|
||||
* - aws-authcode: AWS Builder ID via Authorization Code flow (CLI flag only)
|
||||
* - google: Social OAuth via Google
|
||||
* - github: Social OAuth via GitHub (management API only)
|
||||
* - idc: IAM Identity Center (IDC) via CLI flags with start URL + region
|
||||
*/
|
||||
export const KIRO_AUTH_METHODS = ['aws', 'aws-authcode', 'google', 'github'] as const;
|
||||
export const KIRO_AUTH_METHODS = ['aws', 'aws-authcode', 'google', 'github', 'idc'] as const;
|
||||
export type KiroAuthMethod = (typeof KIRO_AUTH_METHODS)[number];
|
||||
|
||||
/** CLI binary supports these Kiro methods directly via flags. */
|
||||
export const KIRO_CLI_AUTH_METHODS = ['aws', 'aws-authcode', 'google'] as const;
|
||||
export const KIRO_CLI_AUTH_METHODS = ['aws', 'aws-authcode', 'google', 'idc'] as const;
|
||||
export type KiroCLIAuthMethod = (typeof KIRO_CLI_AUTH_METHODS)[number];
|
||||
|
||||
export const KIRO_IDC_FLOWS = ['authcode', 'device'] as const;
|
||||
export type KiroIDCFlow = (typeof KIRO_IDC_FLOWS)[number];
|
||||
export const DEFAULT_KIRO_IDC_FLOW: KiroIDCFlow = 'authcode';
|
||||
|
||||
/** Default Kiro method for CCS UX and AWS Organization support. */
|
||||
export const DEFAULT_KIRO_AUTH_METHOD: KiroAuthMethod = 'aws';
|
||||
|
||||
@@ -41,18 +46,40 @@ export function isKiroCLIAuthMethod(value: string): value is KiroCLIAuthMethod {
|
||||
return KIRO_CLI_AUTH_METHODS.includes(value as KiroCLIAuthMethod);
|
||||
}
|
||||
|
||||
export function isKiroIDCFlow(value: string): value is KiroIDCFlow {
|
||||
return KIRO_IDC_FLOWS.includes(value as KiroIDCFlow);
|
||||
}
|
||||
|
||||
export function normalizeKiroAuthMethod(value?: string): KiroAuthMethod {
|
||||
if (!value) return DEFAULT_KIRO_AUTH_METHOD;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return isKiroAuthMethod(normalized) ? normalized : DEFAULT_KIRO_AUTH_METHOD;
|
||||
}
|
||||
|
||||
export function isKiroDeviceCodeMethod(method: KiroAuthMethod): boolean {
|
||||
return method === 'aws';
|
||||
export function normalizeKiroIDCFlow(value?: string): KiroIDCFlow {
|
||||
if (!value) return DEFAULT_KIRO_IDC_FLOW;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return isKiroIDCFlow(normalized) ? normalized : DEFAULT_KIRO_IDC_FLOW;
|
||||
}
|
||||
|
||||
export function getKiroCallbackPort(method: KiroAuthMethod): number | null {
|
||||
return isKiroDeviceCodeMethod(method) ? null : 9876;
|
||||
export function isKiroDeviceCodeMethod(
|
||||
method: KiroAuthMethod,
|
||||
options?: { idcFlow?: KiroIDCFlow }
|
||||
): boolean {
|
||||
if (method === 'aws') {
|
||||
return true;
|
||||
}
|
||||
if (method === 'idc') {
|
||||
return normalizeKiroIDCFlow(options?.idcFlow) === 'device';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getKiroCallbackPort(
|
||||
method: KiroAuthMethod,
|
||||
options?: { idcFlow?: KiroIDCFlow }
|
||||
): number | null {
|
||||
return isKiroDeviceCodeMethod(method, options) ? null : 9876;
|
||||
}
|
||||
|
||||
export function getKiroCLIAuthFlag(method: KiroCLIAuthMethod): string {
|
||||
@@ -63,19 +90,49 @@ export function getKiroCLIAuthFlag(method: KiroCLIAuthMethod): string {
|
||||
return '--kiro-aws-authcode';
|
||||
case 'google':
|
||||
return '--kiro-google-login';
|
||||
case 'idc':
|
||||
return '--kiro-idc-login';
|
||||
}
|
||||
}
|
||||
|
||||
export function getKiroCLIAuthArgs(
|
||||
method: KiroCLIAuthMethod,
|
||||
options?: {
|
||||
idcStartUrl?: string;
|
||||
idcRegion?: string;
|
||||
idcFlow?: KiroIDCFlow;
|
||||
}
|
||||
): string[] {
|
||||
if (method !== 'idc') {
|
||||
return [getKiroCLIAuthFlag(method)];
|
||||
}
|
||||
|
||||
const startUrl = options?.idcStartUrl?.trim();
|
||||
if (!startUrl) {
|
||||
throw new Error('Kiro IDC login requires --kiro-idc-start-url');
|
||||
}
|
||||
|
||||
const args = [getKiroCLIAuthFlag('idc'), '--kiro-idc-start-url', startUrl];
|
||||
const region = options?.idcRegion?.trim();
|
||||
if (region) {
|
||||
args.push('--kiro-idc-region', region);
|
||||
}
|
||||
args.push('--kiro-idc-flow', normalizeKiroIDCFlow(options?.idcFlow));
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kiro method for CLIProxyAPI management endpoint:
|
||||
* GET /v0/management/kiro-auth-url?method=<value>
|
||||
*/
|
||||
export function toKiroManagementMethod(method: KiroAuthMethod): 'aws' | 'google' | 'github' {
|
||||
export function toKiroManagementMethod(method: KiroAuthMethod): 'aws' | 'google' | 'github' | null {
|
||||
switch (method) {
|
||||
case 'google':
|
||||
return 'google';
|
||||
case 'github':
|
||||
return 'github';
|
||||
case 'idc':
|
||||
return null;
|
||||
case 'aws-authcode':
|
||||
return 'aws';
|
||||
case 'aws':
|
||||
@@ -258,10 +315,20 @@ export function getManagementAuthUrlPath(provider: CLIProxyProvider): string {
|
||||
return `/v0/management/${authUrlProvider}-auth-url?is_webui=true`;
|
||||
}
|
||||
|
||||
export function getPasteCallbackStartPath(provider: CLIProxyProvider): string {
|
||||
// Kiro CLI auth methods still use the legacy start route.
|
||||
export function getPasteCallbackStartPath(
|
||||
provider: CLIProxyProvider,
|
||||
options?: { kiroMethod?: KiroAuthMethod }
|
||||
): string | null {
|
||||
if (provider === 'kiro') {
|
||||
return `/oauth/${provider}/start`;
|
||||
const kiroMethod = options?.kiroMethod ?? normalizeKiroAuthMethod();
|
||||
if (kiroMethod === 'aws-authcode' || kiroMethod === 'idc') {
|
||||
return null;
|
||||
}
|
||||
const managementMethod = toKiroManagementMethod(kiroMethod);
|
||||
if (!managementMethod) {
|
||||
return null;
|
||||
}
|
||||
return `${getManagementAuthUrlPath(provider)}&method=${encodeURIComponent(managementMethod)}`;
|
||||
}
|
||||
return getManagementAuthUrlPath(provider);
|
||||
}
|
||||
@@ -294,6 +361,12 @@ export interface OAuthOptions {
|
||||
acceptAgyRisk?: boolean;
|
||||
/** Kiro auth method override (CLI + Dashboard parity). */
|
||||
kiroMethod?: KiroAuthMethod;
|
||||
/** Kiro IDC start URL (required when kiroMethod=idc). */
|
||||
kiroIDCStartUrl?: string;
|
||||
/** Kiro IDC region override. */
|
||||
kiroIDCRegion?: string;
|
||||
/** Kiro IDC flow override (authcode or device). */
|
||||
kiroIDCFlow?: KiroIDCFlow;
|
||||
/** If true, triggered from Web UI (enables project selection prompt) */
|
||||
fromUI?: boolean;
|
||||
/** If true, use --no-incognito flag (Kiro only - use normal browser instead of incognito) */
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { fail, info, warn, color, ok } from '../../utils/ui';
|
||||
import { ensureCLIProxyBinary } from '../binary-manager';
|
||||
import { generateConfig } from '../config-generator';
|
||||
@@ -32,8 +33,9 @@ import {
|
||||
import {
|
||||
OAuthOptions,
|
||||
DEFAULT_KIRO_AUTH_METHOD,
|
||||
DEFAULT_KIRO_IDC_FLOW,
|
||||
getKiroCallbackPort,
|
||||
getKiroCLIAuthFlag,
|
||||
getKiroCLIAuthArgs,
|
||||
isKiroCLIAuthMethod,
|
||||
isKiroDeviceCodeMethod,
|
||||
getOAuthConfig,
|
||||
@@ -42,9 +44,15 @@ import {
|
||||
getPasteCallbackStartPath,
|
||||
getManagementOAuthCallbackPath,
|
||||
normalizeKiroAuthMethod,
|
||||
normalizeKiroIDCFlow,
|
||||
} from './auth-types';
|
||||
import { isHeadlessEnvironment, killProcessOnPort, showStep } from './environment-detector';
|
||||
import { getProviderTokenDir, isAuthenticated, registerAccountFromToken } from './token-manager';
|
||||
import {
|
||||
getProviderTokenDir,
|
||||
isAuthenticated,
|
||||
isTokenFileForProvider,
|
||||
registerAccountFromToken,
|
||||
} from './token-manager';
|
||||
import { executeOAuthProcess } from './oauth-process';
|
||||
import { importKiroToken } from './kiro-import';
|
||||
import {
|
||||
@@ -69,18 +77,28 @@ interface PasteCallbackStartData {
|
||||
}
|
||||
|
||||
const PASTE_CALLBACK_AUTH_URL_POLL_INTERVAL_MS = 3000;
|
||||
const POLLED_AUTH_LOCAL_TOKEN_GRACE_MS = 15 * 1000;
|
||||
|
||||
type ProviderTokenSnapshot = {
|
||||
file: string;
|
||||
mtimeMs: number;
|
||||
};
|
||||
|
||||
export async function requestPasteCallbackStart(
|
||||
provider: CLIProxyProvider,
|
||||
target: ProxyTarget
|
||||
target: ProxyTarget,
|
||||
options?: { kiroMethod?: OAuthOptions['kiroMethod'] }
|
||||
): Promise<PasteCallbackStartData> {
|
||||
const startPath = getPasteCallbackStartPath(provider);
|
||||
const startPath = getPasteCallbackStartPath(provider, {
|
||||
kiroMethod: options?.kiroMethod,
|
||||
});
|
||||
if (!startPath) {
|
||||
throw new Error(
|
||||
`Paste-callback start is not available for ${provider} with the selected method`
|
||||
);
|
||||
}
|
||||
const response = await fetch(buildProxyUrl(target, startPath), {
|
||||
...(provider === 'kiro' ? { method: 'POST' } : {}),
|
||||
headers:
|
||||
provider === 'kiro'
|
||||
? buildManagementHeaders(target, { 'Content-Type': 'application/json' })
|
||||
: buildManagementHeaders(target),
|
||||
headers: buildManagementHeaders(target),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -116,6 +134,134 @@ function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function parseAuthUrlState(url: string | null | undefined): string | null {
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(url).searchParams.get('state');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function listProviderTokenSnapshots(
|
||||
provider: CLIProxyProvider,
|
||||
tokenDir: string
|
||||
): ProviderTokenSnapshot[] {
|
||||
if (!fs.existsSync(tokenDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs
|
||||
.readdirSync(tokenDir)
|
||||
.filter((file) => file.endsWith('.json'))
|
||||
.map((file): ProviderTokenSnapshot | null => {
|
||||
const filePath = path.join(tokenDir, file);
|
||||
if (!isTokenFileForProvider(filePath, provider)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
file,
|
||||
mtimeMs: fs.statSync(filePath).mtimeMs,
|
||||
};
|
||||
})
|
||||
.filter((snapshot): snapshot is ProviderTokenSnapshot => snapshot !== null)
|
||||
.sort((left, right) => right.mtimeMs - left.mtimeMs);
|
||||
}
|
||||
|
||||
export function findNewTokenSnapshotForManualAuth(
|
||||
provider: CLIProxyProvider,
|
||||
tokenDir: string,
|
||||
knownTokenFiles: ProviderTokenSnapshot[],
|
||||
expectedAccountId?: string
|
||||
): ProviderTokenSnapshot | null {
|
||||
const knownTokenMtimes = new Map(
|
||||
knownTokenFiles.map((snapshot) => [snapshot.file, snapshot.mtimeMs])
|
||||
);
|
||||
|
||||
return (
|
||||
listProviderTokenSnapshots(provider, tokenDir).find((snapshot) => {
|
||||
const knownMtime = knownTokenMtimes.get(snapshot.file);
|
||||
if (knownMtime === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!expectedAccountId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return snapshot.mtimeMs > knownMtime + 1;
|
||||
}) || null
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForManualCallbackToken(
|
||||
provider: CLIProxyProvider,
|
||||
target: ProxyTarget,
|
||||
tokenDir: string,
|
||||
oauthState: string | null,
|
||||
knownTokenFiles: ProviderTokenSnapshot[],
|
||||
expectedAccountId: string | undefined,
|
||||
timeoutMs: number,
|
||||
pollIntervalMs: number = PASTE_CALLBACK_AUTH_URL_POLL_INTERVAL_MS
|
||||
): Promise<{ tokenSnapshot: ProviderTokenSnapshot | null; error?: string }> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let upstreamCompletedAt: number | null = null;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const tokenSnapshot = findNewTokenSnapshotForManualAuth(
|
||||
provider,
|
||||
tokenDir,
|
||||
knownTokenFiles,
|
||||
expectedAccountId
|
||||
);
|
||||
if (tokenSnapshot) {
|
||||
return { tokenSnapshot };
|
||||
}
|
||||
|
||||
if (oauthState) {
|
||||
const response = await fetch(
|
||||
buildProxyUrl(
|
||||
target,
|
||||
`/v0/management/get-auth-status?state=${encodeURIComponent(oauthState)}`
|
||||
),
|
||||
{ headers: buildManagementHeaders(target) }
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as { status?: string; error?: string };
|
||||
if (data.status === 'error') {
|
||||
return {
|
||||
tokenSnapshot: null,
|
||||
error: data.error || 'Authentication failed while waiting for local token persistence',
|
||||
};
|
||||
}
|
||||
if (data.status === 'ok' && upstreamCompletedAt === null) {
|
||||
upstreamCompletedAt = Date.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
upstreamCompletedAt !== null &&
|
||||
Date.now() - upstreamCompletedAt >= POLLED_AUTH_LOCAL_TOKEN_GRACE_MS
|
||||
) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (Date.now() + pollIntervalMs >= deadline) {
|
||||
break;
|
||||
}
|
||||
|
||||
await sleep(pollIntervalMs);
|
||||
}
|
||||
|
||||
return { tokenSnapshot: null };
|
||||
}
|
||||
|
||||
export async function resolvePasteCallbackAuthUrl(
|
||||
target: ProxyTarget,
|
||||
startData: PasteCallbackStartData,
|
||||
@@ -297,6 +443,57 @@ async function prepareBinary(
|
||||
}
|
||||
}
|
||||
|
||||
function buildOAuthArgs(
|
||||
provider: CLIProxyProvider,
|
||||
configPath: string,
|
||||
headless: boolean,
|
||||
noIncognito: boolean,
|
||||
options: {
|
||||
kiroMethod?: OAuthOptions['kiroMethod'];
|
||||
kiroIDCStartUrl?: string;
|
||||
kiroIDCRegion?: string;
|
||||
kiroIDCFlow?: OAuthOptions['kiroIDCFlow'];
|
||||
} = {}
|
||||
): string[] {
|
||||
const args = ['--config', configPath];
|
||||
|
||||
if (provider === 'kiro') {
|
||||
const method = normalizeKiroAuthMethod(options.kiroMethod);
|
||||
if (!isKiroCLIAuthMethod(method)) {
|
||||
throw new Error(`Kiro auth method '${method}' is not supported by CLI flow.`);
|
||||
}
|
||||
args.push(
|
||||
...getKiroCLIAuthArgs(method, {
|
||||
idcStartUrl: options.kiroIDCStartUrl,
|
||||
idcRegion: options.kiroIDCRegion,
|
||||
idcFlow: options.kiroIDCFlow,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
args.push(getOAuthConfig(provider).authFlag);
|
||||
}
|
||||
|
||||
if (headless) {
|
||||
args.push('--no-browser');
|
||||
}
|
||||
if (provider === 'kiro' && noIncognito) {
|
||||
args.push('--no-incognito');
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -307,7 +504,8 @@ async function handlePasteCallbackMode(
|
||||
verbose: boolean,
|
||||
tokenDir: string,
|
||||
nickname?: string,
|
||||
expectedAccountId?: string
|
||||
expectedAccountId?: string,
|
||||
options?: { kiroMethod?: OAuthOptions['kiroMethod'] }
|
||||
): Promise<AccountInfo | null> {
|
||||
// Resolve CLIProxyAPI target (local or remote based on config)
|
||||
const target = getProxyTarget();
|
||||
@@ -318,12 +516,13 @@ async function handlePasteCallbackMode(
|
||||
console.log(info(`Starting ${oauthConfig.displayName} OAuth (paste-callback mode)...`));
|
||||
|
||||
try {
|
||||
// Request auth URL from CLIProxyAPI.
|
||||
// Kiro keeps its legacy start route because CLI auth methods do not share the generic
|
||||
// management auth-url contract used by providers like Claude.
|
||||
// Request auth URL from CLIProxyAPI management endpoints when the selected
|
||||
// provider/method supports the manual start-url contract.
|
||||
let startData: PasteCallbackStartData;
|
||||
try {
|
||||
startData = await requestPasteCallbackStart(provider, target);
|
||||
startData = await requestPasteCallbackStart(provider, target, {
|
||||
kiroMethod: options?.kiroMethod,
|
||||
});
|
||||
} catch (error) {
|
||||
const startError = (error as Error).message;
|
||||
console.log(fail('Failed to start OAuth flow'));
|
||||
@@ -338,6 +537,9 @@ async function handlePasteCallbackMode(
|
||||
return null;
|
||||
}
|
||||
|
||||
const oauthState = startData.state || parseAuthUrlState(authUrl);
|
||||
const knownTokenFiles = listProviderTokenSnapshots(provider, tokenDir);
|
||||
|
||||
// Display auth URL in box
|
||||
console.log('');
|
||||
console.log(' ╔══════════════════════════════════════════════════════════════╗');
|
||||
@@ -430,15 +632,49 @@ async function handlePasteCallbackMode(
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(ok('Authentication successful!'));
|
||||
console.log(info('Callback submitted. Waiting for token exchange...'));
|
||||
const { tokenSnapshot, error: tokenWaitError } = await waitForManualCallbackToken(
|
||||
provider,
|
||||
target,
|
||||
tokenDir,
|
||||
oauthState,
|
||||
knownTokenFiles,
|
||||
expectedAccountId,
|
||||
OAUTH_STATE_TIMEOUT_MS
|
||||
);
|
||||
|
||||
if (tokenWaitError) {
|
||||
console.log(fail(tokenWaitError));
|
||||
warnPossible403Ban(provider, tokenWaitError);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!tokenSnapshot) {
|
||||
console.log(
|
||||
fail(
|
||||
'Authentication completed upstream, but no new local token was saved for this account. Update CCS/CLIProxy and retry.'
|
||||
)
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const account = registerAccountFromToken(
|
||||
provider,
|
||||
tokenDir,
|
||||
nickname,
|
||||
verbose,
|
||||
expectedAccountId
|
||||
tokenSnapshot.file
|
||||
);
|
||||
|
||||
if (!account) {
|
||||
console.log(
|
||||
fail('Authenticated token could not be matched to the requested account. Retry the flow.')
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(ok('Authentication successful!'));
|
||||
|
||||
// Account safety: check for cross-provider conflicts
|
||||
if (account?.email) {
|
||||
const conflicts = checkNewAccountConflict(provider, account.email);
|
||||
@@ -475,6 +711,8 @@ export async function triggerOAuth(
|
||||
const { nickname } = options;
|
||||
const resolvedKiroMethod =
|
||||
provider === 'kiro' ? normalizeKiroAuthMethod(options.kiroMethod) : DEFAULT_KIRO_AUTH_METHOD;
|
||||
const resolvedKiroIDCFlow =
|
||||
provider === 'kiro' ? normalizeKiroIDCFlow(options.kiroIDCFlow) : DEFAULT_KIRO_IDC_FLOW;
|
||||
|
||||
if (provider === 'agy') {
|
||||
if (fromUI && !acceptAgyRisk) {
|
||||
@@ -505,19 +743,6 @@ export async function triggerOAuth(
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle paste-callback mode
|
||||
if (options.pasteCallback) {
|
||||
const tokenDir = getProviderTokenDir(provider);
|
||||
return handlePasteCallbackMode(
|
||||
provider,
|
||||
oauthConfig,
|
||||
verbose,
|
||||
tokenDir,
|
||||
nickname,
|
||||
existingNameMatch?.id
|
||||
);
|
||||
}
|
||||
|
||||
// Handle --import flag: skip OAuth and import from Kiro IDE directly
|
||||
if (options.import && provider === 'kiro') {
|
||||
const tokenDir = getProviderTokenDir(provider);
|
||||
@@ -535,52 +760,44 @@ export async function triggerOAuth(
|
||||
}
|
||||
|
||||
const callbackPort =
|
||||
provider === 'kiro' ? getKiroCallbackPort(resolvedKiroMethod) : OAUTH_PORTS[provider];
|
||||
provider === 'kiro'
|
||||
? getKiroCallbackPort(resolvedKiroMethod, { idcFlow: resolvedKiroIDCFlow })
|
||||
: OAUTH_PORTS[provider];
|
||||
const isCLI = !fromUI;
|
||||
const headless = options.headless ?? isHeadlessEnvironment();
|
||||
const isDeviceCodeFlow =
|
||||
provider === 'kiro' ? isKiroDeviceCodeMethod(resolvedKiroMethod) : callbackPort === null;
|
||||
provider === 'kiro'
|
||||
? isKiroDeviceCodeMethod(resolvedKiroMethod, { idcFlow: resolvedKiroIDCFlow })
|
||||
: callbackPort === null;
|
||||
let selectedPasteCallback = options.pasteCallback === true;
|
||||
|
||||
let authFlag = oauthConfig.authFlag;
|
||||
if (provider === 'kiro') {
|
||||
if (!isKiroCLIAuthMethod(resolvedKiroMethod)) {
|
||||
console.log(fail(`Kiro auth method '${resolvedKiroMethod}' is not supported by CLI flow.`));
|
||||
console.log(' Use Dashboard management OAuth for this method.');
|
||||
return null;
|
||||
}
|
||||
authFlag = getKiroCLIAuthFlag(resolvedKiroMethod);
|
||||
if (provider === 'kiro' && !isKiroCLIAuthMethod(resolvedKiroMethod)) {
|
||||
console.log(fail(`Kiro auth method '${resolvedKiroMethod}' is not supported by CLI flow.`));
|
||||
console.log(' Use Dashboard management OAuth for this method.');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Interactive mode selection for headless environments
|
||||
// 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(
|
||||
@@ -595,6 +812,19 @@ export async function triggerOAuth(
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedPasteCallback && !useSelectedKiroDirectCliFlow) {
|
||||
const tokenDir = getProviderTokenDir(provider);
|
||||
return handlePasteCallbackMode(
|
||||
provider,
|
||||
oauthConfig,
|
||||
verbose,
|
||||
tokenDir,
|
||||
nickname,
|
||||
existingNameMatch?.id,
|
||||
{ kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined }
|
||||
);
|
||||
}
|
||||
|
||||
// Pre-flight checks (skip for device code flows which don't need callback ports)
|
||||
if (!isDeviceCodeFlow && !(await runPreflightChecks(provider, oauthConfig))) {
|
||||
return null;
|
||||
@@ -617,14 +847,18 @@ export async function triggerOAuth(
|
||||
}
|
||||
}
|
||||
|
||||
// Build args
|
||||
const args = ['--config', configPath, authFlag];
|
||||
if (headless) {
|
||||
args.push('--no-browser');
|
||||
}
|
||||
// Kiro-specific: --no-incognito to use normal browser (saves login credentials)
|
||||
if (provider === 'kiro' && noIncognito) {
|
||||
args.push('--no-incognito');
|
||||
const processHeadless = selectedPasteCallback && provider === 'kiro' ? true : headless;
|
||||
let args: string[];
|
||||
try {
|
||||
args = buildOAuthArgs(provider, configPath, processHeadless, noIncognito, {
|
||||
kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined,
|
||||
kiroIDCStartUrl: options.kiroIDCStartUrl,
|
||||
kiroIDCRegion: options.kiroIDCRegion,
|
||||
kiroIDCFlow: provider === 'kiro' ? resolvedKiroIDCFlow : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(fail((error as Error).message));
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show step based on flow type
|
||||
@@ -636,7 +870,14 @@ export async function triggerOAuth(
|
||||
showStep(2, 4, 'progress', `Starting callback server on port ${callbackPort}...`);
|
||||
|
||||
// Show headless instructions (only for authorization code flows)
|
||||
if (headless) {
|
||||
if (useSelectedKiroLocalPasteCallback) {
|
||||
console.log('');
|
||||
console.log(info('Paste-callback mode enabled for Kiro CLI auth.'));
|
||||
console.log(
|
||||
' CCS will print the authorization URL and wait for you to paste the final callback URL.'
|
||||
);
|
||||
console.log('');
|
||||
} else if (headless) {
|
||||
console.log('');
|
||||
console.log(warn('PORT FORWARDING REQUIRED'));
|
||||
console.log(` OAuth callback uses localhost:${callbackPort} which must be reachable.`);
|
||||
@@ -656,11 +897,14 @@ export async function triggerOAuth(
|
||||
tokenDir,
|
||||
oauthConfig,
|
||||
callbackPort,
|
||||
headless,
|
||||
headless: processHeadless,
|
||||
verbose,
|
||||
isCLI,
|
||||
nickname,
|
||||
expectedAccountId: existingNameMatch?.id,
|
||||
authFlowType: isDeviceCodeFlow ? 'device_code' : 'authorization_code',
|
||||
kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined,
|
||||
manualCallback: useSelectedKiroLocalPasteCallback,
|
||||
});
|
||||
|
||||
// Show hint for Kiro users about --no-incognito option (first-time auth only)
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
type GCloudProject,
|
||||
type ProjectSelectionPrompt,
|
||||
} from '../project-selection-handler';
|
||||
import { ProviderOAuthConfig } from './auth-types';
|
||||
import { KiroAuthMethod, ProviderOAuthConfig } from './auth-types';
|
||||
import { getTimeoutTroubleshooting, showStep } from './environment-detector';
|
||||
import { isAuthenticated, registerAccountFromToken } from './token-manager';
|
||||
import {
|
||||
@@ -51,6 +51,9 @@ export interface OAuthProcessOptions {
|
||||
isCLI: boolean;
|
||||
nickname?: string;
|
||||
expectedAccountId?: string;
|
||||
authFlowType?: 'device_code' | 'authorization_code';
|
||||
kiroMethod?: KiroAuthMethod;
|
||||
manualCallback?: boolean;
|
||||
}
|
||||
|
||||
/** Internal state for OAuth process */
|
||||
@@ -66,6 +69,9 @@ interface ProcessState {
|
||||
deviceCodeDisplayed: boolean;
|
||||
/** The user code to enter at verification URL */
|
||||
userCode: string | null;
|
||||
kiroMethodSelectionHandled: boolean;
|
||||
manualCallbackPrompted: boolean;
|
||||
cancelManualCallbackPrompt: (() => void) | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,6 +112,231 @@ async function handleProjectSelection(
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAuthFlowType(options: OAuthProcessOptions): 'device_code' | 'authorization_code' {
|
||||
return options.authFlowType || OAUTH_FLOW_TYPES[options.provider] || 'authorization_code';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function getKiroBuilderIdSelectionInput(output: string): string | null {
|
||||
const promptMatch = /Select login method/i.exec(output);
|
||||
if (!promptMatch || promptMatch.index === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const promptWindow = output.slice(promptMatch.index, promptMatch.index + 600);
|
||||
const optionMatch = /(?:^|\n)\s*(\d+)\s*[\).:-]?\s*.*\bBuilder ID\b/im.exec(promptWindow);
|
||||
if (!optionMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${optionMatch[1]}\n`;
|
||||
}
|
||||
|
||||
export function extractLikelyOAuthAuthorizationUrl(output: string): string | null {
|
||||
const urls = Array.from(output.matchAll(/https?:\/\/[^\s]+/g), (match) => match[0]);
|
||||
let selectedUrl: string | null = null;
|
||||
let selectedScore = 0;
|
||||
|
||||
for (const url of urls) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
let score = 0;
|
||||
if (parsed.searchParams.has('redirect_uri')) score += 4;
|
||||
if (parsed.searchParams.has('state')) score += 2;
|
||||
if (parsed.searchParams.has('code_challenge')) score += 1;
|
||||
if (parsed.pathname.includes('/authorize')) score += 1;
|
||||
if (isLoopbackHost(parsed.hostname)) score -= 3;
|
||||
|
||||
if (score >= selectedScore && score > 0) {
|
||||
selectedUrl = url;
|
||||
selectedScore = score;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return selectedUrl;
|
||||
}
|
||||
|
||||
async function promptManualCallbackUrl(
|
||||
displayName: string,
|
||||
state: ProcessState,
|
||||
timeoutMs: number
|
||||
): Promise<string | null> {
|
||||
const readline = await import('readline');
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
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', () => {
|
||||
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) => {
|
||||
rl.close();
|
||||
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,
|
||||
authUrl: string,
|
||||
verbose: boolean,
|
||||
state: ProcessState,
|
||||
timeoutMs: number
|
||||
): Promise<boolean> {
|
||||
if (!authUrl.includes('http://') && !authUrl.includes('https://')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const callbackUrl = await promptManualCallbackUrl(oauthConfig.displayName, state, timeoutMs);
|
||||
if (!callbackUrl) {
|
||||
console.log(info('Cancelled'));
|
||||
killWithEscalation(authProcess);
|
||||
return true;
|
||||
}
|
||||
|
||||
const validationError = validateManualCallbackUrl(callbackUrl, authUrl);
|
||||
if (validationError) {
|
||||
console.log(fail(validationError));
|
||||
killWithEscalation(authProcess);
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log(info('Replaying callback to the local auth server...'));
|
||||
|
||||
try {
|
||||
const response = await fetch(callbackUrl);
|
||||
if (!response.ok && response.status >= 400) {
|
||||
console.log(fail(`OAuth callback failed with status ${response.status}`));
|
||||
killWithEscalation(authProcess);
|
||||
return true;
|
||||
}
|
||||
console.log(ok('Callback submitted. Waiting for token exchange...'));
|
||||
} catch (error) {
|
||||
if (verbose) {
|
||||
console.log(fail(`Failed to replay callback: ${(error as Error).message}`));
|
||||
} else {
|
||||
console.log(fail('Failed to replay callback to the local auth server'));
|
||||
}
|
||||
killWithEscalation(authProcess);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle stdout data from OAuth process
|
||||
*/
|
||||
@@ -119,10 +350,23 @@ async function handleStdout(
|
||||
log(`stdout: ${output.trim()}`);
|
||||
state.accumulatedOutput += output;
|
||||
|
||||
// H4: Use explicit flow type from OAUTH_FLOW_TYPES instead of null port check
|
||||
const flowType = OAUTH_FLOW_TYPES[options.provider] || 'authorization_code';
|
||||
const flowType = resolveAuthFlowType(options);
|
||||
const isDeviceCodeFlow = flowType === 'device_code';
|
||||
|
||||
if (
|
||||
options.provider === 'kiro' &&
|
||||
options.kiroMethod === 'aws' &&
|
||||
!state.kiroMethodSelectionHandled &&
|
||||
state.accumulatedOutput.includes('Select login method')
|
||||
) {
|
||||
const builderIdSelection = getKiroBuilderIdSelectionInput(state.accumulatedOutput);
|
||||
if (builderIdSelection) {
|
||||
state.kiroMethodSelectionHandled = true;
|
||||
authProcess.stdin?.write(builderIdSelection);
|
||||
log(`Auto-selected Kiro Builder ID flow (${builderIdSelection.trim()})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse project list when available
|
||||
if (isProjectList(state.accumulatedOutput) && state.parsedProjects.length === 0) {
|
||||
state.parsedProjects = parseProjectList(state.accumulatedOutput);
|
||||
@@ -191,13 +435,25 @@ async function handleStdout(
|
||||
|
||||
// Display OAuth URL for all modes (enables VS Code terminal URL detection popup)
|
||||
if (!isDeviceCodeFlow && !state.urlDisplayed) {
|
||||
const urlMatch = output.match(/https?:\/\/[^\s]+/);
|
||||
if (urlMatch) {
|
||||
const authUrl = extractLikelyOAuthAuthorizationUrl(state.accumulatedOutput);
|
||||
if (authUrl) {
|
||||
console.log('');
|
||||
console.log(info(`${options.oauthConfig.displayName} OAuth URL:`));
|
||||
console.log(` ${urlMatch[0]}`);
|
||||
console.log(` ${authUrl}`);
|
||||
console.log('');
|
||||
state.urlDisplayed = true;
|
||||
|
||||
if (options.manualCallback && !state.manualCallbackPrompted) {
|
||||
state.manualCallbackPrompted = true;
|
||||
await replayManualCallback(
|
||||
options.oauthConfig,
|
||||
authProcess,
|
||||
authUrl,
|
||||
options.verbose,
|
||||
state,
|
||||
10 * 60 * 1000
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,11 +464,11 @@ function displayUrlFromStderr(
|
||||
state: ProcessState,
|
||||
oauthConfig: ProviderOAuthConfig
|
||||
): void {
|
||||
const urlMatch = output.match(/https?:\/\/[^\s]+/);
|
||||
if (urlMatch) {
|
||||
const authUrl = extractLikelyOAuthAuthorizationUrl(output);
|
||||
if (authUrl) {
|
||||
console.log('');
|
||||
console.log(info(`${oauthConfig.displayName} OAuth URL:`));
|
||||
console.log(` ${urlMatch[0]}`);
|
||||
console.log(` ${authUrl}`);
|
||||
console.log('');
|
||||
state.urlDisplayed = true;
|
||||
}
|
||||
@@ -386,14 +642,17 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
|
||||
};
|
||||
|
||||
return new Promise<AccountInfo | null>((resolve) => {
|
||||
// H4: Use explicit flow type from OAUTH_FLOW_TYPES instead of null port check
|
||||
const flowType = OAUTH_FLOW_TYPES[provider] || 'authorization_code';
|
||||
const flowType = resolveAuthFlowType(options);
|
||||
const isDeviceCodeFlow = flowType === 'device_code';
|
||||
|
||||
// H6: TTY detection - only inherit stdin if TTY available (prevents issues in CI/piped scripts)
|
||||
// Device Code flows may need interactive stdin for email/prompts
|
||||
// Authorization Code flows need piped stdin for project selection
|
||||
const stdinMode = isDeviceCodeFlow && process.stdin.isTTY ? 'inherit' : 'pipe';
|
||||
// Device-code flows can usually inherit stdin, but Kiro's default AWS flow now
|
||||
// prints an intermediate Builder ID vs IDC selector that CCS auto-answers.
|
||||
const stdinMode =
|
||||
isDeviceCodeFlow &&
|
||||
process.stdin.isTTY &&
|
||||
!(provider === 'kiro' && options.kiroMethod === 'aws')
|
||||
? 'inherit'
|
||||
: 'pipe';
|
||||
|
||||
const authProcess = spawn(binaryPath, args, {
|
||||
stdio: [stdinMode, 'pipe', 'pipe'],
|
||||
@@ -424,6 +683,9 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
|
||||
sessionId: generateSessionId(),
|
||||
deviceCodeDisplayed: false,
|
||||
userCode: null,
|
||||
kiroMethodSelectionHandled: false,
|
||||
manualCallbackPrompted: false,
|
||||
cancelManualCallbackPrompt: null,
|
||||
};
|
||||
|
||||
// Register session for cancellation support
|
||||
@@ -459,13 +721,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
|
||||
@@ -500,10 +776,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);
|
||||
@@ -523,6 +804,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);
|
||||
@@ -585,6 +867,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);
|
||||
|
||||
@@ -65,7 +65,14 @@ import {
|
||||
} from '../../utils/image-analysis';
|
||||
import { loadOrCreateUnifiedConfig, getThinkingConfig } from '../../config/unified-config-loader';
|
||||
import { HttpsTunnelProxy } from '../https-tunnel-proxy';
|
||||
import { isKiroAuthMethod, KiroAuthMethod, normalizeKiroAuthMethod } from '../auth/auth-types';
|
||||
import {
|
||||
isKiroAuthMethod,
|
||||
isKiroIDCFlow,
|
||||
KiroAuthMethod,
|
||||
KiroIDCFlow,
|
||||
normalizeKiroAuthMethod,
|
||||
normalizeKiroIDCFlow,
|
||||
} from '../auth/auth-types';
|
||||
import { resolveProfileContinuityInheritance } from '../../auth/profile-continuity-inheritance';
|
||||
|
||||
// Import modular components
|
||||
@@ -110,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)
|
||||
*
|
||||
@@ -339,31 +374,112 @@ 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');
|
||||
console.error(' Supported values: aws, aws-authcode, google, github, idc');
|
||||
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');
|
||||
console.error(' Supported values: aws, aws-authcode, google, github, idc');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
kiroAuthMethod = normalizeKiroAuthMethod(normalized);
|
||||
}
|
||||
|
||||
let kiroIDCStartUrl: string | undefined;
|
||||
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 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 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;
|
||||
return;
|
||||
}
|
||||
const normalized = rawFlow.trim().toLowerCase();
|
||||
if (!isKiroIDCFlow(normalized)) {
|
||||
console.error(fail(`Invalid --kiro-idc-flow value: ${rawFlow}`));
|
||||
console.error(' Supported values: authcode, device');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
kiroIDCFlow = normalizeKiroIDCFlow(normalized);
|
||||
}
|
||||
|
||||
if (kiroAuthMethod && provider !== 'kiro' && !compositeProviders.includes('kiro')) {
|
||||
console.error(fail('--kiro-auth-method is only valid for ccs kiro'));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(kiroIDCStartUrl || kiroIDCRegion || kiroIDCFlow) &&
|
||||
provider !== 'kiro' &&
|
||||
!compositeProviders.includes('kiro')
|
||||
) {
|
||||
console.error(
|
||||
fail(
|
||||
'--kiro-idc-start-url, --kiro-idc-region, and --kiro-idc-flow are only valid for ccs kiro'
|
||||
)
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!kiroAuthMethod && (kiroIDCStartUrl || kiroIDCRegion || kiroIDCFlow)) {
|
||||
kiroAuthMethod = 'idc';
|
||||
}
|
||||
|
||||
if (kiroAuthMethod === 'idc' && !kiroIDCStartUrl) {
|
||||
console.error(fail('Kiro IDC login requires --kiro-idc-start-url'));
|
||||
console.error(
|
||||
' Example: ccs kiro --auth --kiro-auth-method idc --kiro-idc-start-url https://d-xxx.awsapps.com/start'
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
kiroAuthMethod &&
|
||||
kiroAuthMethod !== 'idc' &&
|
||||
(kiroIDCStartUrl || kiroIDCRegion || kiroIDCFlow)
|
||||
) {
|
||||
console.error(
|
||||
fail(
|
||||
'--kiro-idc-start-url, --kiro-idc-region, and --kiro-idc-flow require --kiro-auth-method idc'
|
||||
)
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse --thinking / --effort flags (aliases; first occurrence wins)
|
||||
const thinkingParse = parseThinkingOverride(argsWithoutProxy);
|
||||
if (thinkingParse.error) {
|
||||
@@ -533,6 +649,9 @@ export async function execClaudeWithCLIProxy(
|
||||
verbose,
|
||||
import: true,
|
||||
...(kiroAuthMethod ? { kiroMethod: kiroAuthMethod } : {}),
|
||||
...(kiroIDCStartUrl ? { kiroIDCStartUrl } : {}),
|
||||
...(kiroIDCRegion ? { kiroIDCRegion } : {}),
|
||||
...(kiroIDCFlow ? { kiroIDCFlow } : {}),
|
||||
...(setNickname ? { nickname: setNickname } : {}),
|
||||
});
|
||||
if (!authSuccess) {
|
||||
@@ -597,6 +716,9 @@ export async function execClaudeWithCLIProxy(
|
||||
add: addAccount,
|
||||
...(acceptAgyRisk ? { acceptAgyRisk: true } : {}),
|
||||
...(kiroAuthMethod && p === 'kiro' ? { kiroMethod: kiroAuthMethod } : {}),
|
||||
...(kiroIDCStartUrl && p === 'kiro' ? { kiroIDCStartUrl } : {}),
|
||||
...(kiroIDCRegion && p === 'kiro' ? { kiroIDCRegion } : {}),
|
||||
...(kiroIDCFlow && p === 'kiro' ? { kiroIDCFlow } : {}),
|
||||
...(forceHeadless ? { headless: true } : {}),
|
||||
...(setNickname ? { nickname: setNickname } : {}),
|
||||
...(noIncognito ? { noIncognito: true } : {}),
|
||||
@@ -639,6 +761,9 @@ export async function execClaudeWithCLIProxy(
|
||||
add: addAccount,
|
||||
...(acceptAgyRisk ? { acceptAgyRisk: true } : {}),
|
||||
...(kiroAuthMethod ? { kiroMethod: kiroAuthMethod } : {}),
|
||||
...(kiroIDCStartUrl ? { kiroIDCStartUrl } : {}),
|
||||
...(kiroIDCRegion ? { kiroIDCRegion } : {}),
|
||||
...(kiroIDCFlow ? { kiroIDCFlow } : {}),
|
||||
...(forceHeadless ? { headless: true } : {}),
|
||||
...(setNickname ? { nickname: setNickname } : {}),
|
||||
...(noIncognito ? { noIncognito: true } : {}),
|
||||
@@ -1052,6 +1177,9 @@ export async function execClaudeWithCLIProxy(
|
||||
'--use',
|
||||
'--nickname',
|
||||
'--kiro-auth-method',
|
||||
'--kiro-idc-start-url',
|
||||
'--kiro-idc-region',
|
||||
'--kiro-idc-flow',
|
||||
'--thinking',
|
||||
'--effort',
|
||||
'--1m',
|
||||
@@ -1066,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;
|
||||
@@ -1073,6 +1205,9 @@ export async function execClaudeWithCLIProxy(
|
||||
argsWithoutProxy[idx - 1] === '--use' ||
|
||||
argsWithoutProxy[idx - 1] === '--nickname' ||
|
||||
argsWithoutProxy[idx - 1] === '--kiro-auth-method' ||
|
||||
argsWithoutProxy[idx - 1] === '--kiro-idc-start-url' ||
|
||||
argsWithoutProxy[idx - 1] === '--kiro-idc-region' ||
|
||||
argsWithoutProxy[idx - 1] === '--kiro-idc-flow' ||
|
||||
argsWithoutProxy[idx - 1] === '--thinking' ||
|
||||
argsWithoutProxy[idx - 1] === '--effort'
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { COPILOT_SUBCOMMANDS } from '../copilot/constants';
|
||||
import { CURSOR_SUBCOMMANDS } from '../cursor/constants';
|
||||
import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities';
|
||||
|
||||
export type HelpTopicName = 'profiles' | 'providers' | 'completion' | 'targets';
|
||||
export type HelpTopicName = 'profiles' | 'providers' | 'kiro' | 'completion' | 'targets';
|
||||
|
||||
export interface HelpTopicEntry {
|
||||
name: HelpTopicName;
|
||||
@@ -25,6 +25,7 @@ export interface ShortcutEntry {
|
||||
export const ROOT_HELP_TOPICS: readonly HelpTopicEntry[] = [
|
||||
{ name: 'profiles', summary: 'Account profiles, API profiles, and CLIProxy variants' },
|
||||
{ name: 'providers', summary: 'Built-in OAuth providers and runtime shortcuts' },
|
||||
{ name: 'kiro', summary: 'Kiro auth methods, IDC flags, and callback guidance' },
|
||||
{ name: 'completion', summary: 'Shell completion install, refresh, and testing' },
|
||||
{ name: 'targets', summary: 'Claude, Droid, and Codex target routing' },
|
||||
] as const;
|
||||
|
||||
@@ -220,7 +220,15 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg
|
||||
if (command === 'kiro') {
|
||||
return completeSubcommands(
|
||||
[],
|
||||
[...PROVIDER_FLAGS, '--kiro-auth-method', '--import', '--incognito']
|
||||
[
|
||||
...PROVIDER_FLAGS,
|
||||
'--kiro-auth-method',
|
||||
'--kiro-idc-start-url',
|
||||
'--kiro-idc-region',
|
||||
'--kiro-idc-flow',
|
||||
'--import',
|
||||
'--incognito',
|
||||
]
|
||||
);
|
||||
}
|
||||
return completeSubcommands([], PROVIDER_FLAGS);
|
||||
|
||||
@@ -78,6 +78,7 @@ async function showProvidersHelp(writeLine: HelpWriter): Promise<void> {
|
||||
},
|
||||
{ name: 'ccs api create --preset <id>', summary: 'Create an API-backed provider profile' },
|
||||
{ name: 'ccs config', summary: 'Use the dashboard for provider and model setup' },
|
||||
{ name: 'ccs help kiro', summary: 'Kiro-specific auth methods and IDC flags' },
|
||||
],
|
||||
writeLine
|
||||
);
|
||||
@@ -85,6 +86,74 @@ async function showProvidersHelp(writeLine: HelpWriter): Promise<void> {
|
||||
writeLine('');
|
||||
}
|
||||
|
||||
async function showKiroHelp(writeLine: HelpWriter): Promise<void> {
|
||||
await initUI();
|
||||
writeLine(header('CCS Kiro Help'));
|
||||
writeLine('');
|
||||
writeLine(' Kiro supports Builder ID, IDC, and management-only social OAuth flows.');
|
||||
writeLine('');
|
||||
writeCommandTable(
|
||||
'Authentication Methods',
|
||||
[
|
||||
{ name: 'ccs kiro --auth', summary: 'Default AWS Builder ID device-code flow' },
|
||||
{
|
||||
name: 'ccs kiro --auth --kiro-auth-method aws-authcode',
|
||||
summary: 'AWS Builder ID auth-code flow via local callback server',
|
||||
},
|
||||
{
|
||||
name: 'ccs kiro --auth --kiro-auth-method idc',
|
||||
summary: 'IAM Identity Center flow; requires IDC start URL',
|
||||
},
|
||||
{
|
||||
name: 'ccs config',
|
||||
summary: 'Dashboard flow for GitHub OAuth and account management',
|
||||
},
|
||||
],
|
||||
writeLine
|
||||
);
|
||||
writeCommandTable(
|
||||
'Kiro Flags',
|
||||
[
|
||||
{
|
||||
name: '--kiro-auth-method <aws|aws-authcode|google|github|idc>',
|
||||
summary: 'Select the Kiro auth method',
|
||||
},
|
||||
{ name: '--kiro-idc-start-url <url>', summary: 'Required IDC start URL when using `idc`' },
|
||||
{ name: '--kiro-idc-region <region>', summary: 'Optional IDC region override' },
|
||||
{ name: '--kiro-idc-flow <authcode|device>', summary: 'IDC flow type; defaults to authcode' },
|
||||
{
|
||||
name: '--paste-callback',
|
||||
summary: 'Paste the final callback URL for callback-based CLI auth flows',
|
||||
},
|
||||
{ name: '--import', summary: 'Import an existing Kiro IDE token instead of starting OAuth' },
|
||||
],
|
||||
writeLine
|
||||
);
|
||||
writeCommandTable(
|
||||
'Examples',
|
||||
[
|
||||
{ name: 'ccs kiro --auth', summary: 'Start the default Builder ID device flow' },
|
||||
{
|
||||
name: 'ccs kiro --auth --kiro-auth-method aws-authcode --paste-callback',
|
||||
summary: 'Use auth-code flow and paste the callback URL manually',
|
||||
},
|
||||
{
|
||||
name: 'ccs kiro --auth --kiro-auth-method idc --kiro-idc-start-url https://d-xxx.awsapps.com/start',
|
||||
summary: 'Start IDC auth with the default authcode flow',
|
||||
},
|
||||
{
|
||||
name: 'ccs kiro --auth --kiro-auth-method idc --kiro-idc-start-url https://d-xxx.awsapps.com/start --kiro-idc-flow device',
|
||||
summary: 'Use IDC device-code flow instead of authcode',
|
||||
},
|
||||
],
|
||||
writeLine
|
||||
);
|
||||
writeLine(
|
||||
` ${dim('GitHub OAuth is dashboard-only: ccs config -> Accounts -> Add Kiro account')}`
|
||||
);
|
||||
writeLine('');
|
||||
}
|
||||
|
||||
async function showTargetsHelp(writeLine: HelpWriter): Promise<void> {
|
||||
await initUI();
|
||||
writeLine(header('CCS Targets Help'));
|
||||
@@ -176,6 +245,10 @@ export async function handleHelpRoute(
|
||||
await showProvidersHelp(writeLine);
|
||||
return;
|
||||
}
|
||||
if (topic === 'kiro') {
|
||||
await showKiroHelp(writeLine);
|
||||
return;
|
||||
}
|
||||
if (topic === 'targets') {
|
||||
await showTargetsHelp(writeLine);
|
||||
return;
|
||||
|
||||
@@ -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,12 +259,52 @@ 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 }
|
||||
): string | null {
|
||||
if (provider === 'kiro') {
|
||||
const kiroMethod = options?.kiroMethod ?? normalizeKiroAuthMethod();
|
||||
if (kiroMethod === 'idc') {
|
||||
return "Kiro method 'idc' uses CLI auth flow. Use /api/cliproxy/auth/kiro/start instead.";
|
||||
}
|
||||
if (kiroMethod === 'aws-authcode') {
|
||||
return "Kiro method 'aws-authcode' uses CLI auth flow. Use /api/cliproxy/auth/kiro/start instead.";
|
||||
}
|
||||
@@ -597,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) {
|
||||
@@ -606,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)) {
|
||||
@@ -615,12 +666,24 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
|
||||
|
||||
if (provider === 'kiro' && invalidKiroMethod) {
|
||||
res.status(400).json({
|
||||
error: 'Invalid kiroMethod. Supported: aws, aws-authcode, google, github',
|
||||
error: 'Invalid kiroMethod. Supported: aws, aws-authcode, google, github, idc',
|
||||
code: 'INVALID_KIRO_METHOD',
|
||||
});
|
||||
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) {
|
||||
@@ -659,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
|
||||
});
|
||||
@@ -828,7 +894,7 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
|
||||
|
||||
if (provider === 'kiro' && invalidKiroMethod) {
|
||||
res.status(400).json({
|
||||
error: 'Invalid kiroMethod. Supported: aws, aws-authcode, google, github',
|
||||
error: 'Invalid kiroMethod. Supported: aws, aws-authcode, google, github, idc',
|
||||
code: 'INVALID_KIRO_METHOD',
|
||||
});
|
||||
return;
|
||||
@@ -867,9 +933,10 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
|
||||
try {
|
||||
const authUrlProvider =
|
||||
CLIPROXY_AUTH_URL_PROVIDER_MAP[provider as CLIProxyProvider] || provider;
|
||||
const kiroManagementMethod = provider === 'kiro' ? toKiroManagementMethod(kiroMethod) : null;
|
||||
const kiroQuery =
|
||||
provider === 'kiro'
|
||||
? `&method=${encodeURIComponent(toKiroManagementMethod(kiroMethod))}`
|
||||
provider === 'kiro' && kiroManagementMethod
|
||||
? `&method=${encodeURIComponent(kiroManagementMethod)}`
|
||||
: '';
|
||||
|
||||
// Call CLIProxyAPI to start OAuth and get auth URL
|
||||
|
||||
@@ -20,8 +20,18 @@ describe('auth-types paste-callback start path', () => {
|
||||
expect(getPasteCallbackStartPath('ghcp')).toBe('/v0/management/github-auth-url?is_webui=true');
|
||||
});
|
||||
|
||||
it('keeps Kiro on the legacy start route for paste-callback mode', () => {
|
||||
expect(getPasteCallbackStartPath('kiro')).toBe('/oauth/kiro/start');
|
||||
it('maps Kiro management-supported methods to the management auth-url route', () => {
|
||||
expect(getPasteCallbackStartPath('kiro')).toBe(
|
||||
'/v0/management/kiro-auth-url?is_webui=true&method=aws'
|
||||
);
|
||||
expect(getPasteCallbackStartPath('kiro', { kiroMethod: 'google' })).toBe(
|
||||
'/v0/management/kiro-auth-url?is_webui=true&method=google'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null for Kiro CLI-only paste-callback modes', () => {
|
||||
expect(getPasteCallbackStartPath('kiro', { kiroMethod: 'aws-authcode' })).toBeNull();
|
||||
expect(getPasteCallbackStartPath('kiro', { kiroMethod: 'idc' })).toBeNull();
|
||||
});
|
||||
|
||||
it('still exposes the generic management auth-url helper', () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,7 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import type { ProxyTarget } from '../../../src/cliproxy/proxy-target-resolver';
|
||||
import { getCapturedFetchRequests, mockFetch, restoreFetch } from '../../mocks';
|
||||
|
||||
@@ -39,11 +42,10 @@ describe('requestPasteCallbackStart', () => {
|
||||
expect(request.headers['Content-Type']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps kiro on the legacy start route with POST', async () => {
|
||||
it('uses the Kiro management auth-url route for paste-callback compatible methods', async () => {
|
||||
mockFetch([
|
||||
{
|
||||
url: /\/oauth\/kiro\/start$/,
|
||||
method: 'POST',
|
||||
url: /\/v0\/management\/kiro-auth-url\?is_webui=true&method=aws$/,
|
||||
response: { auth_url: 'https://auth.example.com/kiro' },
|
||||
},
|
||||
]);
|
||||
@@ -51,15 +53,43 @@ describe('requestPasteCallbackStart', () => {
|
||||
const { requestPasteCallbackStart } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?request-kiro-start=${Date.now()}`
|
||||
);
|
||||
const startData = await requestPasteCallbackStart('kiro', remoteTarget);
|
||||
const startData = await requestPasteCallbackStart('kiro', remoteTarget, {
|
||||
kiroMethod: 'aws',
|
||||
});
|
||||
|
||||
expect(startData.auth_url).toBe('https://auth.example.com/kiro');
|
||||
|
||||
const [request] = getCapturedFetchRequests();
|
||||
expect(request.url).toBe('https://proxy.example.com:8317/oauth/kiro/start');
|
||||
expect(request.method).toBe('POST');
|
||||
expect(request.url).toBe(
|
||||
'https://proxy.example.com:8317/v0/management/kiro-auth-url?is_webui=true&method=aws'
|
||||
);
|
||||
expect(request.method).toBe('GET');
|
||||
expect(request.headers['Authorization']).toBe('Bearer test-mgmt-key');
|
||||
expect(request.headers['Content-Type']).toBe('application/json');
|
||||
expect(request.headers['Content-Type']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws for Kiro methods that require the local callback server flow', async () => {
|
||||
const { requestPasteCallbackStart } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?request-kiro-authcode-start=${Date.now()}`
|
||||
);
|
||||
|
||||
await expect(
|
||||
requestPasteCallbackStart('kiro', remoteTarget, { kiroMethod: 'aws-authcode' })
|
||||
).rejects.toThrow(/paste-callback start is not available/i);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,6 +133,56 @@ describe('resolvePasteCallbackAuthUrl', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('findNewTokenSnapshotForManualAuth', () => {
|
||||
it('detects newly created provider token files', async () => {
|
||||
const tokenDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-kiro-manual-auth-'));
|
||||
const existingFile = path.join(tokenDir, 'kiro-existing.json');
|
||||
fs.writeFileSync(existingFile, JSON.stringify({ type: 'kiro', email: 'existing@example.com' }));
|
||||
const existingMtimeMs = fs.statSync(existingFile).mtimeMs;
|
||||
|
||||
const { findNewTokenSnapshotForManualAuth } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?manual-auth-new-token=${Date.now()}`
|
||||
);
|
||||
|
||||
const newFile = path.join(tokenDir, 'kiro-new.json');
|
||||
fs.writeFileSync(newFile, JSON.stringify({ type: 'kiro', email: 'new@example.com' }));
|
||||
|
||||
const snapshot = findNewTokenSnapshotForManualAuth(
|
||||
'kiro',
|
||||
tokenDir,
|
||||
[{ file: 'kiro-existing.json', mtimeMs: existingMtimeMs }]
|
||||
);
|
||||
|
||||
expect(snapshot?.file).toBe('kiro-new.json');
|
||||
fs.rmSync(tokenDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('treats a modified existing token as the new token during reauth', async () => {
|
||||
const tokenDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-kiro-reauth-'));
|
||||
const tokenFile = path.join(tokenDir, 'kiro-existing.json');
|
||||
fs.writeFileSync(tokenFile, JSON.stringify({ type: 'kiro', email: 'existing@example.com' }));
|
||||
const existingMtimeMs = fs.statSync(tokenFile).mtimeMs;
|
||||
|
||||
const { findNewTokenSnapshotForManualAuth } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?manual-auth-updated-token=${Date.now()}`
|
||||
);
|
||||
|
||||
fs.writeFileSync(tokenFile, JSON.stringify({ type: 'kiro', email: 'existing@example.com', refreshed: true }));
|
||||
const bumpedTime = new Date(existingMtimeMs + 10_000);
|
||||
fs.utimesSync(tokenFile, bumpedTime, bumpedTime);
|
||||
|
||||
const snapshot = findNewTokenSnapshotForManualAuth(
|
||||
'kiro',
|
||||
tokenDir,
|
||||
[{ file: 'kiro-existing.json', mtimeMs: existingMtimeMs }],
|
||||
'kiro-existing.json'
|
||||
);
|
||||
|
||||
expect(snapshot?.file).toBe('kiro-existing.json');
|
||||
fs.rmSync(tokenDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCliAuthNicknameError', () => {
|
||||
it('allows omitted nicknames for no-email providers', async () => {
|
||||
const { getCliAuthNicknameError } = await import(
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { extractLikelyAuthFailureFromStderr } from '../../../src/cliproxy/auth/oauth-process';
|
||||
import {
|
||||
extractLikelyAuthFailureFromStderr,
|
||||
extractLikelyOAuthAuthorizationUrl,
|
||||
getExpectedLocalCallback,
|
||||
getKiroBuilderIdSelectionInput,
|
||||
validateManualCallbackUrl,
|
||||
} from '../../../src/cliproxy/auth/oauth-process';
|
||||
|
||||
describe('oauth-process stderr parsing', () => {
|
||||
it('ignores non-ghcp providers', () => {
|
||||
@@ -33,3 +39,107 @@ 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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('oauth-process Kiro Builder ID menu parsing', () => {
|
||||
it('selects Builder ID when it is the first option', () => {
|
||||
const output = `
|
||||
? Select login method:
|
||||
1) Use with Builder ID (personal AWS account)
|
||||
2) Use with IDC Account (organization SSO)
|
||||
`;
|
||||
|
||||
expect(getKiroBuilderIdSelectionInput(output)).toBe('1\n');
|
||||
});
|
||||
|
||||
it('selects the Builder ID option even when upstream reorders the menu', () => {
|
||||
const output = `
|
||||
Select login method
|
||||
1. IAM Identity Center
|
||||
2. AWS Builder ID
|
||||
`;
|
||||
|
||||
expect(getKiroBuilderIdSelectionInput(output)).toBe('2\n');
|
||||
});
|
||||
|
||||
it('returns null when the Builder ID option is not present in the prompt window', () => {
|
||||
const output = `
|
||||
Select login method
|
||||
1. IAM Identity Center
|
||||
2. Google
|
||||
`;
|
||||
|
||||
expect(getKiroBuilderIdSelectionInput(output)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('oauth-process OAuth URL extraction', () => {
|
||||
it('prefers the real auth URL over the IDC start URL banner', () => {
|
||||
const authUrl =
|
||||
'https://oidc.us-east-1.amazonaws.com/authorize?response_type=code&client_id=test-client&redirect_uri=http%3A%2F%2F127.0.0.1%3A9876%2Foauth%2Fcallback&state=test-state&code_challenge=test-challenge&code_challenge_method=S256';
|
||||
const output = `
|
||||
Using IDC with Start URL: https://d-123.awsapps.com/start
|
||||
Region: us-east-1
|
||||
URL: ${authUrl}
|
||||
`;
|
||||
|
||||
expect(extractLikelyOAuthAuthorizationUrl(output)).toBe(authUrl);
|
||||
});
|
||||
|
||||
it('ignores local callback server URLs when the auth URL is also present', () => {
|
||||
const authUrl =
|
||||
'https://device.sso.us-east-1.amazonaws.com/authorize?response_type=code&client_id=test-client&redirect_uri=http%3A%2F%2F127.0.0.1%3A9876%2Foauth%2Fcallback&state=test-state&code_challenge=test-challenge&code_challenge_method=S256';
|
||||
const output = `
|
||||
Callback server started, redirect URI: http://127.0.0.1:9876/oauth/callback
|
||||
URL: ${authUrl}
|
||||
`;
|
||||
|
||||
expect(extractLikelyOAuthAuthorizationUrl(output)).toBe(authUrl);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,9 @@ import {
|
||||
import {
|
||||
DEFAULT_KIRO_AUTH_METHOD,
|
||||
getKiroCallbackPort,
|
||||
getKiroCLIAuthArgs,
|
||||
getKiroCLIAuthFlag,
|
||||
normalizeKiroIDCFlow,
|
||||
normalizeKiroAuthMethod,
|
||||
OAUTH_CALLBACK_PORTS as AUTH_CALLBACK_PORTS,
|
||||
toKiroManagementMethod,
|
||||
@@ -136,20 +138,30 @@ describe('provider-capabilities', () => {
|
||||
expect(DEFAULT_KIRO_AUTH_METHOD).toBe('aws');
|
||||
expect(normalizeKiroAuthMethod()).toBe('aws');
|
||||
expect(normalizeKiroAuthMethod('GOOGLE')).toBe('google');
|
||||
expect(normalizeKiroAuthMethod('IDC')).toBe('idc');
|
||||
expect(normalizeKiroAuthMethod('not-valid')).toBe('aws');
|
||||
expect(normalizeKiroIDCFlow()).toBe('authcode');
|
||||
expect(normalizeKiroIDCFlow('DEVICE')).toBe('device');
|
||||
|
||||
expect(getKiroCLIAuthFlag('aws')).toBe('--kiro-aws-login');
|
||||
expect(getKiroCLIAuthFlag('aws-authcode')).toBe('--kiro-aws-authcode');
|
||||
expect(getKiroCLIAuthFlag('google')).toBe('--kiro-google-login');
|
||||
expect(getKiroCLIAuthFlag('idc')).toBe('--kiro-idc-login');
|
||||
expect(getKiroCLIAuthArgs('idc', { idcStartUrl: 'https://d-123.awsapps.com/start' })).toEqual(
|
||||
['--kiro-idc-login', '--kiro-idc-start-url', 'https://d-123.awsapps.com/start', '--kiro-idc-flow', 'authcode']
|
||||
);
|
||||
|
||||
expect(getKiroCallbackPort('aws')).toBeNull();
|
||||
expect(getKiroCallbackPort('google')).toBe(9876);
|
||||
expect(getKiroCallbackPort('github')).toBe(9876);
|
||||
expect(getKiroCallbackPort('aws-authcode')).toBe(9876);
|
||||
expect(getKiroCallbackPort('idc')).toBe(9876);
|
||||
expect(getKiroCallbackPort('idc', { idcFlow: 'device' })).toBeNull();
|
||||
|
||||
expect(toKiroManagementMethod('aws')).toBe('aws');
|
||||
expect(toKiroManagementMethod('aws-authcode')).toBe('aws');
|
||||
expect(toKiroManagementMethod('google')).toBe('google');
|
||||
expect(toKiroManagementMethod('github')).toBe('github');
|
||||
expect(toKiroManagementMethod('idc')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,11 +46,23 @@ describe('help command parity', () => {
|
||||
|
||||
expect(rendered.includes('Built-in OAuth Providers')).toBe(true);
|
||||
expect(rendered.includes('ccs cliproxy --help')).toBe(true);
|
||||
expect(rendered.includes('ccs help kiro')).toBe(true);
|
||||
expect(rendered.includes('gemini')).toBe(true);
|
||||
expect(rendered.includes('codex')).toBe(true);
|
||||
expect(rendered.includes('ghcp')).toBe(true);
|
||||
});
|
||||
|
||||
test('kiro topic documents IDC and callback flags', async () => {
|
||||
const rendered = await renderLines((writeLine) => handleHelpRoute(['kiro'], writeLine));
|
||||
|
||||
expect(rendered.includes('CCS Kiro Help')).toBe(true);
|
||||
expect(rendered.includes('--kiro-idc-start-url <url>')).toBe(true);
|
||||
expect(rendered.includes('--kiro-idc-region <region>')).toBe(true);
|
||||
expect(rendered.includes('--kiro-idc-flow <authcode|device>')).toBe(true);
|
||||
expect(rendered.includes('--paste-callback')).toBe(true);
|
||||
expect(rendered.includes('GitHub OAuth is dashboard-only')).toBe(true);
|
||||
});
|
||||
|
||||
test('completion topic documents install and verification paths', async () => {
|
||||
const rendered = await renderLines((writeLine) => handleHelpRoute(['completion'], writeLine));
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
getKiroStartIDCValidationError,
|
||||
getStartAuthFailureMessage,
|
||||
getStartAuthNicknameError,
|
||||
getStartUrlUnsupportedReason,
|
||||
@@ -25,6 +26,12 @@ describe('cliproxy-auth-routes start-url guard', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects Kiro idc method on start-url', () => {
|
||||
expect(getStartUrlUnsupportedReason('kiro', { kiroMethod: 'idc' })).toContain(
|
||||
"Kiro method 'idc' uses CLI auth flow"
|
||||
);
|
||||
});
|
||||
|
||||
it('allows authorization code providers', () => {
|
||||
expect(getStartUrlUnsupportedReason('gemini')).toBeNull();
|
||||
expect(getStartUrlUnsupportedReason('codex')).toBeNull();
|
||||
@@ -32,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(
|
||||
|
||||
@@ -39,11 +39,15 @@ import {
|
||||
} from '@/components/account/antigravity-responsibility-constants';
|
||||
import {
|
||||
DEFAULT_KIRO_AUTH_METHOD,
|
||||
DEFAULT_KIRO_IDC_FLOW,
|
||||
getKiroEffectiveFlowType,
|
||||
getKiroEffectiveStartEndpoint,
|
||||
getKiroAuthMethodOption,
|
||||
isKiroSocialAuthMethod,
|
||||
isDeviceCodeProvider,
|
||||
KIRO_AUTH_METHOD_OPTIONS,
|
||||
} from '@/lib/provider-config';
|
||||
import type { KiroAuthMethod } from '@/lib/provider-config';
|
||||
import type { KiroAuthMethod, KiroIDCFlow } from '@/lib/provider-config';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -81,6 +85,9 @@ export function AddAccountDialog({
|
||||
const [powerUserModeEnabled, setPowerUserModeEnabled] = useState(false);
|
||||
const [powerUserModeLoading, setPowerUserModeLoading] = useState(false);
|
||||
const [kiroAuthMethod, setKiroAuthMethod] = useState<KiroAuthMethod>(DEFAULT_KIRO_AUTH_METHOD);
|
||||
const [kiroIDCStartUrl, setKiroIDCStartUrl] = useState('');
|
||||
const [kiroIDCRegion, setKiroIDCRegion] = useState('');
|
||||
const [kiroIDCFlow, setKiroIDCFlow] = useState<KiroIDCFlow>(DEFAULT_KIRO_IDC_FLOW);
|
||||
const { t } = useTranslation();
|
||||
const wasAuthenticatingRef = useRef(false);
|
||||
const powerUserModeRequestIdRef = useRef(0);
|
||||
@@ -97,9 +104,19 @@ export function AddAccountDialog({
|
||||
const isGeminiRiskAcknowledged = normalizeRiskPhrase(riskAcknowledgementText) === RISK_ACK_PHRASE;
|
||||
const defaultDeviceCode = isDeviceCodeProvider(provider);
|
||||
const kiroMethodOption = getKiroAuthMethodOption(kiroAuthMethod);
|
||||
const isDeviceCode = isKiro ? kiroMethodOption.flowType === 'device_code' : defaultDeviceCode;
|
||||
const isKiroIdc = isKiro && kiroAuthMethod === 'idc';
|
||||
const isKiroSocial = isKiro && isKiroSocialAuthMethod(kiroAuthMethod);
|
||||
const selectedKiroFlowType = isKiro
|
||||
? getKiroEffectiveFlowType(kiroAuthMethod, kiroIDCFlow)
|
||||
: undefined;
|
||||
const selectedKiroStartEndpoint = isKiro
|
||||
? getKiroEffectiveStartEndpoint(kiroAuthMethod)
|
||||
: undefined;
|
||||
const isDeviceCode = isKiro ? selectedKiroFlowType === 'device_code' : defaultDeviceCode;
|
||||
const isPending = authFlow.isAuthenticating || kiroImportMutation.isPending;
|
||||
const nicknameTrimmed = nickname.trim();
|
||||
const kiroIDCStartUrlTrimmed = kiroIDCStartUrl.trim();
|
||||
const kiroIDCRegionTrimmed = kiroIDCRegion.trim();
|
||||
const errorMessage = localError || authFlow.error;
|
||||
|
||||
const fetchPowerUserModeState = useCallback(async (): Promise<boolean> => {
|
||||
@@ -168,6 +185,9 @@ export function AddAccountDialog({
|
||||
setPowerUserModeEnabled(false);
|
||||
setPowerUserModeLoading(false);
|
||||
setKiroAuthMethod(DEFAULT_KIRO_AUTH_METHOD);
|
||||
setKiroIDCStartUrl('');
|
||||
setKiroIDCRegion('');
|
||||
setKiroIDCFlow(DEFAULT_KIRO_IDC_FLOW);
|
||||
powerUserModeRequestIdRef.current += 1;
|
||||
powerUserModeLoadErrorShownRef.current = false;
|
||||
wasAuthenticatingRef.current = false;
|
||||
@@ -282,12 +302,19 @@ export function AddAccountDialog({
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
if (isKiroIdc && !kiroIDCStartUrlTrimmed) {
|
||||
setLocalError('IDC Start URL is required for Kiro IAM Identity Center login.');
|
||||
return;
|
||||
}
|
||||
wasAuthenticatingRef.current = true;
|
||||
authFlow.startAuth(provider, {
|
||||
nickname: nicknameTrimmed || undefined,
|
||||
kiroMethod: isKiro ? kiroAuthMethod : undefined,
|
||||
flowType: isKiro ? kiroMethodOption.flowType : undefined,
|
||||
startEndpoint: isKiro ? kiroMethodOption.startEndpoint : undefined,
|
||||
kiroIDCStartUrl: isKiroIdc ? kiroIDCStartUrlTrimmed : undefined,
|
||||
kiroIDCRegion: isKiroIdc && kiroIDCRegionTrimmed ? kiroIDCRegionTrimmed : undefined,
|
||||
kiroIDCFlow: isKiroIdc ? kiroIDCFlow : undefined,
|
||||
flowType: isKiro ? selectedKiroFlowType : undefined,
|
||||
startEndpoint: isKiro ? selectedKiroStartEndpoint : undefined,
|
||||
riskAcknowledgement: requiresAgyResponsibilityFlow
|
||||
? {
|
||||
version: ANTIGRAVITY_ACK_VERSION,
|
||||
@@ -399,6 +426,77 @@ export function AddAccountDialog({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{kiroMethodOption.description}</p>
|
||||
{isKiroSocial && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
If your browser does not return automatically after login, CCS can accept the
|
||||
final
|
||||
<span className="mx-1 rounded bg-muted px-1.5 py-0.5 font-mono text-[11px]">
|
||||
kiro://...
|
||||
</span>
|
||||
callback URL in the next step.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isKiroIdc && !showAuthUI && (
|
||||
<div className="space-y-4 rounded-lg border border-border/60 bg-muted/20 p-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="kiro-idc-start-url">IDC Start URL</Label>
|
||||
<Input
|
||||
id="kiro-idc-start-url"
|
||||
value={kiroIDCStartUrl}
|
||||
onChange={(e) => {
|
||||
setKiroIDCStartUrl(e.target.value);
|
||||
setLocalError(null);
|
||||
}}
|
||||
placeholder="https://d-xxx.awsapps.com/start"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Required for organization IAM Identity Center login.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="kiro-idc-region">IDC Region</Label>
|
||||
<Input
|
||||
id="kiro-idc-region"
|
||||
value={kiroIDCRegion}
|
||||
onChange={(e) => {
|
||||
setKiroIDCRegion(e.target.value);
|
||||
setLocalError(null);
|
||||
}}
|
||||
placeholder="us-east-1"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Optional. Leave blank to use the upstream default region.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="kiro-idc-flow">IDC Flow</Label>
|
||||
<Select
|
||||
value={kiroIDCFlow}
|
||||
onValueChange={(value) => {
|
||||
setKiroIDCFlow(value as KiroIDCFlow);
|
||||
setLocalError(null);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="kiro-idc-flow">
|
||||
<SelectValue placeholder="Select IDC flow" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="authcode">Authorization Code</SelectItem>
|
||||
<SelectItem value="device">Device Code</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Auth Code opens a browser and may need the final callback URL pasted back. Device
|
||||
Code shows a verification code instead.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -438,7 +536,9 @@ export function AddAccountDialog({
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{authFlow.isDeviceCodeFlow
|
||||
? t('addAccountDialog.deviceCodeHint')
|
||||
: t('addAccountDialog.browserHint')}
|
||||
: isKiroSocial
|
||||
? 'Complete sign-in in your browser. If it does not return automatically, paste the final kiro:// callback URL below.'
|
||||
: t('addAccountDialog.browserHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -486,13 +586,19 @@ export function AddAccountDialog({
|
||||
{/* Callback paste field */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="callback-url" className="text-xs">
|
||||
{t('addAccountDialog.redirectPasteLabel')}
|
||||
{isKiroSocial
|
||||
? 'Browser did not return? Paste the final kiro:// callback URL:'
|
||||
: t('addAccountDialog.redirectPasteLabel')}
|
||||
</Label>
|
||||
<Input
|
||||
id="callback-url"
|
||||
value={callbackUrl}
|
||||
onChange={(e) => setCallbackUrl(e.target.value)}
|
||||
placeholder={t('addAccountDialog.callbackPlaceholder')}
|
||||
placeholder={
|
||||
isKiroSocial
|
||||
? 'kiro://kiro.kiroAgent/authenticate-success?code=...&state=...'
|
||||
: t('addAccountDialog.callbackPlaceholder')
|
||||
}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
@@ -516,7 +622,9 @@ export function AddAccountDialog({
|
||||
|
||||
{!authFlow.authUrl && !authFlow.isDeviceCodeFlow && (
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
{t('addAccountDialog.preparingUrl')}
|
||||
{isKiroSocial
|
||||
? 'Preparing the Kiro sign-in URL. If it does not open automatically, it will appear here shortly.'
|
||||
: t('addAccountDialog.preparingUrl')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,9 @@ interface AuthFlowState {
|
||||
interface StartAuthOptions {
|
||||
nickname?: string;
|
||||
kiroMethod?: string;
|
||||
kiroIDCStartUrl?: string;
|
||||
kiroIDCRegion?: string;
|
||||
kiroIDCFlow?: 'authcode' | 'device';
|
||||
flowType?: 'authorization_code' | 'device_code';
|
||||
startEndpoint?: 'start' | 'start-url';
|
||||
riskAcknowledgement?: {
|
||||
@@ -70,6 +73,7 @@ const INITIAL_STATE: AuthFlowState = {
|
||||
|
||||
export function useCliproxyAuthFlow() {
|
||||
const [state, setState] = useState<AuthFlowState>(INITIAL_STATE);
|
||||
const stateRef = useRef<AuthFlowState>(INITIAL_STATE);
|
||||
|
||||
const attemptIdRef = useRef(0);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
@@ -102,6 +106,10 @@ export function useCliproxyAuthFlow() {
|
||||
};
|
||||
}, [stopPolling]);
|
||||
|
||||
useEffect(() => {
|
||||
stateRef.current = state;
|
||||
}, [state]);
|
||||
|
||||
// Poll OAuth status
|
||||
const pollStatus = useCallback(
|
||||
async (provider: string, oauthState: string, attemptId: number) => {
|
||||
@@ -253,6 +261,9 @@ export function useCliproxyAuthFlow() {
|
||||
const payload = {
|
||||
nickname: options?.nickname,
|
||||
kiroMethod: options?.kiroMethod,
|
||||
kiroIDCStartUrl: options?.kiroIDCStartUrl,
|
||||
kiroIDCRegion: options?.kiroIDCRegion,
|
||||
kiroIDCFlow: options?.kiroIDCFlow,
|
||||
riskAcknowledgement: options?.riskAcknowledgement,
|
||||
};
|
||||
|
||||
@@ -368,6 +379,16 @@ export function useCliproxyAuthFlow() {
|
||||
// Start polling for completion
|
||||
if (oauthState) {
|
||||
pollStartRef.current = Date.now();
|
||||
if (!authUrl) {
|
||||
await pollStatus(provider, oauthState, attemptId);
|
||||
if (!isActiveAttempt(attemptId)) {
|
||||
return;
|
||||
}
|
||||
const currentState = stateRef.current;
|
||||
if (!currentState.isAuthenticating || currentState.provider !== provider) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
pollIntervalRef.current = setInterval(() => {
|
||||
void pollStatus(provider, oauthState, attemptId);
|
||||
}, POLL_INTERVAL);
|
||||
|
||||
@@ -234,8 +234,11 @@ export function getDeviceCodeProviderInstruction(provider: unknown): string {
|
||||
}
|
||||
|
||||
/** Kiro auth methods exposed in CCS UI (aligned with CLIProxyAPIPlus support). */
|
||||
export const KIRO_AUTH_METHODS = ['aws', 'aws-authcode', 'google', 'github'] as const;
|
||||
export const KIRO_AUTH_METHODS = ['aws', 'aws-authcode', 'google', 'github', 'idc'] as const;
|
||||
export type KiroAuthMethod = (typeof KIRO_AUTH_METHODS)[number];
|
||||
export const KIRO_IDC_FLOWS = ['authcode', 'device'] as const;
|
||||
export type KiroIDCFlow = (typeof KIRO_IDC_FLOWS)[number];
|
||||
export const DEFAULT_KIRO_IDC_FLOW: KiroIDCFlow = 'authcode';
|
||||
|
||||
export type KiroFlowType = 'authorization_code' | 'device_code';
|
||||
export type KiroStartEndpoint = 'start' | 'start-url';
|
||||
@@ -280,6 +283,13 @@ export const KIRO_AUTH_METHOD_OPTIONS: readonly KiroAuthMethodOption[] = [
|
||||
flowType: 'authorization_code',
|
||||
startEndpoint: 'start-url',
|
||||
},
|
||||
{
|
||||
id: 'idc',
|
||||
label: 'AWS Identity Center (IDC)',
|
||||
description: 'Use your organization start URL with auth code or device flow.',
|
||||
flowType: 'authorization_code',
|
||||
startEndpoint: 'start',
|
||||
},
|
||||
];
|
||||
|
||||
export function isKiroAuthMethod(value: string): value is KiroAuthMethod {
|
||||
@@ -292,7 +302,40 @@ export function normalizeKiroAuthMethod(value?: string): KiroAuthMethod {
|
||||
return isKiroAuthMethod(normalized) ? normalized : DEFAULT_KIRO_AUTH_METHOD;
|
||||
}
|
||||
|
||||
export function isKiroIDCFlow(value: string): value is KiroIDCFlow {
|
||||
return KIRO_IDC_FLOWS.includes(value as KiroIDCFlow);
|
||||
}
|
||||
|
||||
export function normalizeKiroIDCFlow(value?: string): KiroIDCFlow {
|
||||
if (!value) return DEFAULT_KIRO_IDC_FLOW;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return isKiroIDCFlow(normalized) ? normalized : DEFAULT_KIRO_IDC_FLOW;
|
||||
}
|
||||
|
||||
export function getKiroAuthMethodOption(method: KiroAuthMethod): KiroAuthMethodOption {
|
||||
const option = KIRO_AUTH_METHOD_OPTIONS.find((candidate) => candidate.id === method);
|
||||
return option || KIRO_AUTH_METHOD_OPTIONS[0];
|
||||
}
|
||||
|
||||
export function getKiroEffectiveFlowType(
|
||||
method: KiroAuthMethod,
|
||||
idcFlow: KiroIDCFlow = DEFAULT_KIRO_IDC_FLOW
|
||||
): KiroFlowType {
|
||||
if (method === 'aws') {
|
||||
return 'device_code';
|
||||
}
|
||||
|
||||
if (method === 'idc') {
|
||||
return normalizeKiroIDCFlow(idcFlow) === 'device' ? 'device_code' : 'authorization_code';
|
||||
}
|
||||
|
||||
return 'authorization_code';
|
||||
}
|
||||
|
||||
export function getKiroEffectiveStartEndpoint(method: KiroAuthMethod): KiroStartEndpoint {
|
||||
return method === 'google' || method === 'github' ? 'start-url' : 'start';
|
||||
}
|
||||
|
||||
export function isKiroSocialAuthMethod(method: KiroAuthMethod): boolean {
|
||||
return method === 'google' || method === 'github';
|
||||
}
|
||||
|
||||
@@ -221,6 +221,96 @@ describe('useCliproxyAuthFlow', () => {
|
||||
expect(toast.success).toHaveBeenCalledWith('codex authentication successful');
|
||||
});
|
||||
|
||||
it('promotes a state-first auth bootstrap into an immediate auth URL without waiting for the first interval', async () => {
|
||||
let pollCount = 0;
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
|
||||
if (url.includes('/start-url')) {
|
||||
return Promise.resolve(
|
||||
createJsonResponse({
|
||||
success: true,
|
||||
authUrl: null,
|
||||
state: 'state-kiro-social',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (url.includes('/status?state=state-kiro-social')) {
|
||||
pollCount += 1;
|
||||
return Promise.resolve(
|
||||
createJsonResponse({
|
||||
status: 'auth_url',
|
||||
url: 'https://auth.example/kiro-social',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
|
||||
})
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useCliproxyAuthFlow(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.startAuth('kiro', { startEndpoint: 'start-url', kiroMethod: 'google' });
|
||||
});
|
||||
|
||||
expect(result.current.authUrl).toBe('https://auth.example/kiro-social');
|
||||
expect(result.current.oauthState).toBe('state-kiro-social');
|
||||
expect(result.current.isAuthenticating).toBe(true);
|
||||
expect(pollCount).toBe(1);
|
||||
});
|
||||
|
||||
it('forwards Kiro IDC options to the backend start endpoint payload', async () => {
|
||||
let requestBody: Record<string, unknown> | null = null;
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
|
||||
if (url.includes('/start')) {
|
||||
requestBody = JSON.parse(String(init?.body)) as Record<string, unknown>;
|
||||
return createJsonResponse({
|
||||
success: true,
|
||||
account: {
|
||||
id: 'kiro-idc-account',
|
||||
provider: 'kiro',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
|
||||
})
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useCliproxyAuthFlow(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.startAuth('kiro', {
|
||||
startEndpoint: 'start',
|
||||
flowType: 'authorization_code',
|
||||
kiroMethod: 'idc',
|
||||
kiroIDCStartUrl: 'https://d-123.awsapps.com/start',
|
||||
kiroIDCRegion: 'ca-central-1',
|
||||
kiroIDCFlow: 'authcode',
|
||||
});
|
||||
});
|
||||
|
||||
expect(requestBody).toEqual({
|
||||
nickname: undefined,
|
||||
kiroMethod: 'idc',
|
||||
kiroIDCStartUrl: 'https://d-123.awsapps.com/start',
|
||||
kiroIDCRegion: 'ca-central-1',
|
||||
kiroIDCFlow: 'authcode',
|
||||
riskAcknowledgement: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats callback responses without an account as failures', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
|
||||
Reference in New Issue
Block a user