fix(kiro): require fresh token before auth success

This commit is contained in:
Tam Nhu Tran
2026-04-07 08:27:52 -04:00
parent 827c866286
commit 25bcee2fa1
4 changed files with 365 additions and 162 deletions
+4 -51
View File
@@ -11,7 +11,6 @@
*/
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';
@@ -48,9 +47,11 @@ import {
} from './auth-types';
import { isHeadlessEnvironment, killProcessOnPort, showStep } from './environment-detector';
import {
ProviderTokenSnapshot,
findNewTokenSnapshotForAuthAttempt,
getProviderTokenDir,
isAuthenticated,
isTokenFileForProvider,
listProviderTokenSnapshots,
registerAccountFromToken,
} from './token-manager';
import { executeOAuthProcess } from './oauth-process';
@@ -79,11 +80,6 @@ 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,
@@ -146,56 +142,13 @@ function parseAuthUrlState(url: string | null | undefined): string | 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
);
return findNewTokenSnapshotForAuthAttempt(provider, tokenDir, knownTokenFiles, expectedAccountId);
}
async function waitForManualCallbackToken(
+81 -38
View File
@@ -24,7 +24,12 @@ import {
} from '../project-selection-handler';
import { KiroAuthMethod, ProviderOAuthConfig } from './auth-types';
import { getTimeoutTroubleshooting, showStep } from './environment-detector';
import { isAuthenticated, registerAccountFromToken } from './token-manager';
import {
type ProviderTokenSnapshot,
findNewTokenSnapshot,
listProviderTokenSnapshots,
registerAccountFromToken,
} from './token-manager';
import {
deviceCodeEvents,
DEVICE_CODE_TIMEOUT_MS,
@@ -476,20 +481,15 @@ function displayUrlFromStderr(
const ANSI_ESCAPE_REGEX = /\x1b\[[0-9;]*m/g;
export function extractLikelyAuthFailureFromStderr(
export function extractLikelyAuthFailureFromLogs(
provider: CLIProxyProvider,
stderrData: string
logData: string
): string | null {
// Keep this scoped to ghcp to avoid over-classifying other providers.
if (provider !== 'ghcp') {
if (!logData.trim()) {
return null;
}
if (!stderrData.trim()) {
return null;
}
const normalizedLines = stderrData
const normalizedLines = logData
.split('\n')
.map((line) => line.replace(ANSI_ESCAPE_REGEX, '').trim())
.filter(Boolean)
@@ -507,11 +507,23 @@ export function extractLikelyAuthFailureFromStderr(
return line;
});
const providerPatterns: Partial<Record<CLIProxyProvider, RegExp[]>> = {
ghcp: [
/github copilot authentication failed:\s*(.+)/i,
/failed to verify copilot access[^:]*:\s*(.+)/i,
],
kiro: [
/kiro idc authentication failed:\s*(.+)/i,
/kiro authentication failed:\s*(.+)/i,
/login failed:\s*(.+)/i,
/failed to register client:\s*(.+)/i,
],
};
const prioritizedPatterns = [
/github copilot authentication failed:\s*(.+)/i,
/authentication failed:\s*(.+)/i,
/failed to verify copilot access[^:]*:\s*(.+)/i,
/failed to save auth:\s*(.+)/i,
...(providerPatterns[provider] || []),
/^authentication failed:\s*(.+)/i,
/^failed to save auth:\s*(.+)/i,
];
for (let i = normalizedLines.length - 1; i >= 0; i--) {
@@ -527,6 +539,34 @@ export function extractLikelyAuthFailureFromStderr(
return null;
}
export function extractLikelyAuthFailureFromStderr(
provider: CLIProxyProvider,
stderrData: string
): string | null {
return extractLikelyAuthFailureFromLogs(provider, stderrData);
}
export function analyzeSuccessfulAuthExit(options: {
provider: CLIProxyProvider;
knownTokenFiles: ProviderTokenSnapshot[];
currentTokenFiles: ProviderTokenSnapshot[];
expectedAccountId?: string;
stdoutData: string;
stderrData: string;
}): { tokenSnapshot: ProviderTokenSnapshot | null; failureReason: string | null } {
const tokenSnapshot = findNewTokenSnapshot(
options.currentTokenFiles,
options.knownTokenFiles,
options.expectedAccountId
);
const failureReason = extractLikelyAuthFailureFromLogs(
options.provider,
[options.stdoutData, options.stderrData].filter(Boolean).join('\n')
);
return { tokenSnapshot, failureReason };
}
/** Handle token not found after successful process exit */
async function handleTokenNotFound(
provider: CLIProxyProvider,
@@ -537,9 +577,22 @@ async function handleTokenNotFound(
verbose: boolean,
failureReason?: string
): Promise<AccountInfo | null> {
console.log('');
if (failureReason) {
// Sanitize internal URLs/paths from failure reason to avoid leaking infrastructure details
const sanitizedReason = failureReason
.replace(/https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)[^\s]*/gi, '[internal-url]')
.replace(/\/(?:root|home|opt|tmp|var)\/[^\s]*/g, '[path]');
console.log(fail('Authentication failed before a usable token was saved'));
console.log(` ${sanitizedReason}`);
console.log('');
console.log(`Try: ccs ${provider} --auth --verbose`);
return null;
}
// Kiro-specific: Try auto-import from Kiro IDE
if (provider === 'kiro') {
console.log('');
console.log(warn('Callback redirected to Kiro IDE. Attempting to import token...'));
const result = await tryKiroImport(tokenDir, verbose);
@@ -558,24 +611,6 @@ async function handleTokenNotFound(
return null;
}
// Default behavior for other providers
console.log('');
if (failureReason) {
// Sanitize internal URLs/paths from failure reason to avoid leaking infrastructure details
const sanitizedReason = failureReason
.replace(/https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)[^\s]*/gi, '[internal-url]')
.replace(/\/(?:root|home|opt|tmp|var)\/[^\s]*/g, '[path]');
console.log(fail('Authentication completed but token was not persisted'));
console.log(` ${sanitizedReason}`);
console.log('');
console.log('This usually means provider-side authorization was accepted,');
console.log('but CLIProxy failed a post-auth verification or token save step.');
console.log('');
console.log(`Try: ccs ${provider} --auth --verbose`);
return null;
}
console.log(fail('Token not found after authentication'));
console.log('');
console.log('The browser showed success but callback was not received.');
@@ -644,6 +679,7 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
return new Promise<AccountInfo | null>((resolve) => {
const flowType = resolveAuthFlowType(options);
const isDeviceCodeFlow = flowType === 'device_code';
const knownTokenFiles = listProviderTokenSnapshots(provider, tokenDir);
// 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.
@@ -814,7 +850,16 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
if (code === 0) {
if (isAuthenticated(provider)) {
const exitAnalysis = analyzeSuccessfulAuthExit({
provider,
knownTokenFiles,
currentTokenFiles: listProviderTokenSnapshots(provider, tokenDir),
expectedAccountId,
stdoutData: state.accumulatedOutput,
stderrData: state.stderrData,
});
if (exitAnalysis.tokenSnapshot) {
console.log('');
console.log(ok(`Authentication successful (${elapsed}s)`));
@@ -827,13 +872,11 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
registerAccountFromToken(provider, tokenDir, nickname, verbose, expectedAccountId)
);
} else {
const failureReason = extractLikelyAuthFailureFromStderr(provider, state.stderrData);
// Emit device code failure event for UI
if (isDeviceCodeFlow && state.deviceCodeDisplayed) {
deviceCodeEvents.emit('deviceCode:failed', {
sessionId: state.sessionId,
error: failureReason || 'Token not found after authentication',
error: exitAnalysis.failureReason || 'Token not found after authentication',
});
}
@@ -845,7 +888,7 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
nickname,
expectedAccountId,
verbose,
failureReason || undefined
exitAnalysis.failureReason || undefined
);
resolve(account);
}
+161 -73
View File
@@ -7,6 +7,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { createHash } from 'crypto';
import { CLIProxyProvider } from '../types';
import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
import { getProviderAuthDir } from '../config-generator';
@@ -43,6 +44,165 @@ export function isTokenFileForProvider(filePath: string, provider: CLIProxyProvi
}
}
export type ProviderTokenSnapshot = {
file: string;
mtimeMs: number;
accountId?: string;
fingerprint?: string;
};
type TokenCandidate = {
file: string;
filePath: string;
email?: string;
projectId?: string;
accountId: string;
mtimeMs: number;
alreadyRegistered: boolean;
fingerprint: string;
};
type RawTokenCandidate = Omit<TokenCandidate, 'accountId' | 'fingerprint'>;
function buildTokenFingerprint(content: string): string {
return createHash('sha256').update(content).digest('hex');
}
function listTokenCandidates(provider: CLIProxyProvider, tokenDir: string): TokenCandidate[] {
if (!fs.existsSync(tokenDir)) {
return [];
}
const files = fs.readdirSync(tokenDir);
const jsonFiles = files.filter((file) => file.endsWith('.json'));
const existingAccounts = getProviderAccounts(provider);
const rawCandidates: RawTokenCandidate[] = jsonFiles.flatMap((file) => {
const filePath = path.join(tokenDir, file);
if (!isTokenFileForProvider(filePath, provider)) {
return [];
}
const content = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(content) as { email?: string; project_id?: string };
const email = data.email || undefined;
const projectId = data.project_id || undefined;
const stats = fs.statSync(filePath);
return [
{
file,
filePath,
email,
projectId,
mtimeMs: stats.mtimeMs,
alreadyRegistered: existingAccounts.some((account) => account.tokenFile === file),
},
];
});
const duplicateEmailCounts = new Map<string, number>();
const duplicateEmailTokenSets = new Map<string, Set<string>>();
for (const account of existingAccounts) {
if (!account.email) continue;
const key = account.email.toLowerCase();
const tokenSet = duplicateEmailTokenSets.get(key) ?? new Set<string>();
tokenSet.add(account.tokenFile);
duplicateEmailTokenSets.set(key, tokenSet);
}
for (const candidate of rawCandidates) {
if (!candidate.email) continue;
const key = candidate.email.toLowerCase();
const tokenSet = duplicateEmailTokenSets.get(key) ?? new Set<string>();
tokenSet.add(candidate.file);
duplicateEmailTokenSets.set(key, tokenSet);
}
for (const [key, tokenSet] of duplicateEmailTokenSets) {
duplicateEmailCounts.set(key, tokenSet.size);
}
return rawCandidates
.map((rawCandidate) => {
const content = fs.readFileSync(rawCandidate.filePath, 'utf-8');
const duplicateEmailCount = rawCandidate.email
? (duplicateEmailCounts.get(rawCandidate.email.toLowerCase()) ?? 1)
: 1;
const accountId = rawCandidate.email
? buildEmailBackedAccountId(
provider,
rawCandidate.file,
rawCandidate.email,
duplicateEmailCount
)
: extractAccountIdFromTokenFile(rawCandidate.file, rawCandidate.email);
return {
...rawCandidate,
accountId,
fingerprint: buildTokenFingerprint(content),
};
})
.sort((left, right) => right.mtimeMs - left.mtimeMs);
}
export function listProviderTokenSnapshots(
provider: CLIProxyProvider,
tokenDir: string = getProviderTokenDir(provider)
): ProviderTokenSnapshot[] {
return listTokenCandidates(provider, tokenDir).map((candidate) => ({
file: candidate.file,
mtimeMs: candidate.mtimeMs,
accountId: candidate.accountId,
fingerprint: candidate.fingerprint,
}));
}
export function findNewTokenSnapshot(
currentTokenFiles: ProviderTokenSnapshot[],
knownTokenFiles: ProviderTokenSnapshot[],
expectedAccountId?: string
): ProviderTokenSnapshot | null {
const knownSnapshotsByFile = new Map(
knownTokenFiles.map((snapshot) => [snapshot.file, snapshot])
);
return (
currentTokenFiles.find((snapshot) => {
const knownSnapshot = knownSnapshotsByFile.get(snapshot.file);
if (!expectedAccountId) {
return !knownSnapshot;
}
const matchesExpectedAccount =
snapshot.file === expectedAccountId || snapshot.accountId === expectedAccountId;
if (!matchesExpectedAccount) {
return false;
}
if (!knownSnapshot) {
return true;
}
return (
snapshot.fingerprint !== knownSnapshot.fingerprint ||
snapshot.mtimeMs !== knownSnapshot.mtimeMs
);
}) || null
);
}
export function findNewTokenSnapshotForAuthAttempt(
provider: CLIProxyProvider,
tokenDir: string,
knownTokenFiles: ProviderTokenSnapshot[],
expectedAccountId?: string
): ProviderTokenSnapshot | null {
return findNewTokenSnapshot(
listProviderTokenSnapshots(provider, tokenDir),
knownTokenFiles,
expectedAccountId
);
}
/**
* Check if provider has valid authentication
* CLIProxyAPI stores OAuth tokens as JSON files in the auth directory.
@@ -207,83 +367,11 @@ export function registerAccountFromToken(
verbose = false,
expectedAccountId?: string
): import('../account-manager').AccountInfo | null {
type TokenCandidate = {
file: string;
filePath: string;
email?: string;
projectId?: string;
accountId: string;
mtimeMs: number;
alreadyRegistered: boolean;
};
type RawTokenCandidate = Omit<TokenCandidate, 'accountId'>;
const { registerAccount } = require('../account-manager');
let selectedCandidate: Omit<TokenCandidate, 'mtimeMs'> | null = null;
try {
const files = fs.readdirSync(tokenDir);
const jsonFiles = files.filter((f: string) => f.endsWith('.json'));
const candidates = listTokenCandidates(provider, tokenDir);
const existingAccounts = getProviderAccounts(provider);
const rawCandidates: RawTokenCandidate[] = jsonFiles.flatMap((file) => {
const filePath = path.join(tokenDir, file);
if (!isTokenFileForProvider(filePath, provider)) return [];
const content = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(content) as { email?: string; project_id?: string };
const email = data.email || undefined;
const projectId = data.project_id || undefined;
const stats = fs.statSync(filePath);
return [
{
file,
filePath,
email,
projectId,
mtimeMs: stats.mtimeMs,
alreadyRegistered: existingAccounts.some((account) => account.tokenFile === file),
},
];
});
const duplicateEmailCounts = new Map<string, number>();
const duplicateEmailTokenSets = new Map<string, Set<string>>();
for (const account of existingAccounts) {
if (!account.email) continue;
const key = account.email.toLowerCase();
const tokenSet = duplicateEmailTokenSets.get(key) ?? new Set<string>();
tokenSet.add(account.tokenFile);
duplicateEmailTokenSets.set(key, tokenSet);
}
for (const candidate of rawCandidates) {
if (!candidate.email) continue;
const key = candidate.email.toLowerCase();
const tokenSet = duplicateEmailTokenSets.get(key) ?? new Set<string>();
tokenSet.add(candidate.file);
duplicateEmailTokenSets.set(key, tokenSet);
}
for (const [key, tokenSet] of duplicateEmailTokenSets) {
duplicateEmailCounts.set(key, tokenSet.size);
}
const candidates: TokenCandidate[] = rawCandidates
.map((rawCandidate) => {
const duplicateEmailCount = rawCandidate.email
? (duplicateEmailCounts.get(rawCandidate.email.toLowerCase()) ?? 1)
: 1;
const accountId = rawCandidate.email
? buildEmailBackedAccountId(
provider,
rawCandidate.file,
rawCandidate.email,
duplicateEmailCount
)
: extractAccountIdFromTokenFile(rawCandidate.file, rawCandidate.email);
return {
...rawCandidate,
accountId,
};
})
.sort((a, b) => b.mtimeMs - a.mtimeMs);
if (expectedAccountId) {
selectedCandidate =
@@ -1,5 +1,7 @@
import { describe, expect, it } from 'bun:test';
import {
analyzeSuccessfulAuthExit,
extractLikelyAuthFailureFromLogs,
extractLikelyAuthFailureFromStderr,
extractLikelyOAuthAuthorizationUrl,
getExpectedLocalCallback,
@@ -38,6 +40,123 @@ describe('oauth-process stderr parsing', () => {
expect(parsed).not.toBeNull();
expect((parsed as string).length).toBe(240);
});
it('extracts kiro IDC failures from verbose stdout logs', () => {
const logData =
'[2026-04-07 11:01:21] [--------] [error] [kiro_login.go:236] Kiro IDC authentication failed: login failed: failed to register client: register client failed (status 400)';
expect(extractLikelyAuthFailureFromLogs('kiro', logData)).toBe(
'login failed: failed to register client: register client failed (status 400)'
);
});
});
describe('oauth-process successful exit analysis', () => {
it('treats unchanged existing kiro tokens as a failed add-account attempt', () => {
const result = analyzeSuccessfulAuthExit({
provider: 'kiro',
knownTokenFiles: [{ file: 'kiro-existing.json', mtimeMs: 100 }],
currentTokenFiles: [{ file: 'kiro-existing.json', mtimeMs: 100 }],
stdoutData:
'[error] Kiro IDC authentication failed: login failed: failed to register client: register client failed (status 400)',
stderrData: '',
});
expect(result.tokenSnapshot).toBeNull();
expect(result.failureReason).toBe(
'login failed: failed to register client: register client failed (status 400)'
);
});
it('treats a refreshed token file as success during reauth', () => {
const result = analyzeSuccessfulAuthExit({
provider: 'kiro',
knownTokenFiles: [
{
file: 'kiro-existing.json',
mtimeMs: 100,
accountId: 'kiro-existing',
fingerprint: 'before',
},
],
currentTokenFiles: [
{
file: 'kiro-existing.json',
mtimeMs: 250,
accountId: 'kiro-existing',
fingerprint: 'after',
},
],
expectedAccountId: 'kiro-existing.json',
stdoutData: '',
stderrData: '',
});
expect(result.tokenSnapshot?.file).toBe('kiro-existing.json');
expect(result.failureReason).toBeNull();
});
it('ignores unrelated new token files during reauth', () => {
const result = analyzeSuccessfulAuthExit({
provider: 'kiro',
knownTokenFiles: [
{
file: 'kiro-existing.json',
mtimeMs: 100,
accountId: 'kiro-existing',
fingerprint: 'before',
},
],
currentTokenFiles: [
{
file: 'kiro-existing.json',
mtimeMs: 100,
accountId: 'kiro-existing',
fingerprint: 'before',
},
{
file: 'kiro-other.json',
mtimeMs: 150,
accountId: 'kiro-other',
fingerprint: 'other-after',
},
],
expectedAccountId: 'kiro-existing',
stdoutData: '',
stderrData: '',
});
expect(result.tokenSnapshot).toBeNull();
expect(result.failureReason).toBeNull();
});
it('treats fingerprint changes as success even when mtime is unchanged', () => {
const result = analyzeSuccessfulAuthExit({
provider: 'kiro',
knownTokenFiles: [
{
file: 'kiro-existing.json',
mtimeMs: 100,
accountId: 'kiro-existing',
fingerprint: 'before',
},
],
currentTokenFiles: [
{
file: 'kiro-existing.json',
mtimeMs: 100,
accountId: 'kiro-existing',
fingerprint: 'after',
},
],
expectedAccountId: 'kiro-existing',
stdoutData: '',
stderrData: '',
});
expect(result.tokenSnapshot?.file).toBe('kiro-existing.json');
expect(result.failureReason).toBeNull();
});
});
describe('oauth-process manual callback validation', () => {