mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-11 02:21:58 +00:00
fix(kiro): require fresh token before auth success
This commit is contained in:
@@ -11,7 +11,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
|
||||||
import { fail, info, warn, color, ok } from '../../utils/ui';
|
import { fail, info, warn, color, ok } from '../../utils/ui';
|
||||||
import { ensureCLIProxyBinary } from '../binary-manager';
|
import { ensureCLIProxyBinary } from '../binary-manager';
|
||||||
import { generateConfig } from '../config-generator';
|
import { generateConfig } from '../config-generator';
|
||||||
@@ -48,9 +47,11 @@ import {
|
|||||||
} from './auth-types';
|
} from './auth-types';
|
||||||
import { isHeadlessEnvironment, killProcessOnPort, showStep } from './environment-detector';
|
import { isHeadlessEnvironment, killProcessOnPort, showStep } from './environment-detector';
|
||||||
import {
|
import {
|
||||||
|
ProviderTokenSnapshot,
|
||||||
|
findNewTokenSnapshotForAuthAttempt,
|
||||||
getProviderTokenDir,
|
getProviderTokenDir,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
isTokenFileForProvider,
|
listProviderTokenSnapshots,
|
||||||
registerAccountFromToken,
|
registerAccountFromToken,
|
||||||
} from './token-manager';
|
} from './token-manager';
|
||||||
import { executeOAuthProcess } from './oauth-process';
|
import { executeOAuthProcess } from './oauth-process';
|
||||||
@@ -79,11 +80,6 @@ interface PasteCallbackStartData {
|
|||||||
const PASTE_CALLBACK_AUTH_URL_POLL_INTERVAL_MS = 3000;
|
const PASTE_CALLBACK_AUTH_URL_POLL_INTERVAL_MS = 3000;
|
||||||
const POLLED_AUTH_LOCAL_TOKEN_GRACE_MS = 15 * 1000;
|
const POLLED_AUTH_LOCAL_TOKEN_GRACE_MS = 15 * 1000;
|
||||||
|
|
||||||
type ProviderTokenSnapshot = {
|
|
||||||
file: string;
|
|
||||||
mtimeMs: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function requestPasteCallbackStart(
|
export async function requestPasteCallbackStart(
|
||||||
provider: CLIProxyProvider,
|
provider: CLIProxyProvider,
|
||||||
target: ProxyTarget,
|
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(
|
export function findNewTokenSnapshotForManualAuth(
|
||||||
provider: CLIProxyProvider,
|
provider: CLIProxyProvider,
|
||||||
tokenDir: string,
|
tokenDir: string,
|
||||||
knownTokenFiles: ProviderTokenSnapshot[],
|
knownTokenFiles: ProviderTokenSnapshot[],
|
||||||
expectedAccountId?: string
|
expectedAccountId?: string
|
||||||
): ProviderTokenSnapshot | null {
|
): ProviderTokenSnapshot | null {
|
||||||
const knownTokenMtimes = new Map(
|
return findNewTokenSnapshotForAuthAttempt(provider, tokenDir, knownTokenFiles, expectedAccountId);
|
||||||
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(
|
async function waitForManualCallbackToken(
|
||||||
|
|||||||
@@ -24,7 +24,12 @@ import {
|
|||||||
} from '../project-selection-handler';
|
} from '../project-selection-handler';
|
||||||
import { KiroAuthMethod, ProviderOAuthConfig } from './auth-types';
|
import { KiroAuthMethod, ProviderOAuthConfig } from './auth-types';
|
||||||
import { getTimeoutTroubleshooting, showStep } from './environment-detector';
|
import { getTimeoutTroubleshooting, showStep } from './environment-detector';
|
||||||
import { isAuthenticated, registerAccountFromToken } from './token-manager';
|
import {
|
||||||
|
type ProviderTokenSnapshot,
|
||||||
|
findNewTokenSnapshot,
|
||||||
|
listProviderTokenSnapshots,
|
||||||
|
registerAccountFromToken,
|
||||||
|
} from './token-manager';
|
||||||
import {
|
import {
|
||||||
deviceCodeEvents,
|
deviceCodeEvents,
|
||||||
DEVICE_CODE_TIMEOUT_MS,
|
DEVICE_CODE_TIMEOUT_MS,
|
||||||
@@ -476,20 +481,15 @@ function displayUrlFromStderr(
|
|||||||
|
|
||||||
const ANSI_ESCAPE_REGEX = /\x1b\[[0-9;]*m/g;
|
const ANSI_ESCAPE_REGEX = /\x1b\[[0-9;]*m/g;
|
||||||
|
|
||||||
export function extractLikelyAuthFailureFromStderr(
|
export function extractLikelyAuthFailureFromLogs(
|
||||||
provider: CLIProxyProvider,
|
provider: CLIProxyProvider,
|
||||||
stderrData: string
|
logData: string
|
||||||
): string | null {
|
): string | null {
|
||||||
// Keep this scoped to ghcp to avoid over-classifying other providers.
|
if (!logData.trim()) {
|
||||||
if (provider !== 'ghcp') {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!stderrData.trim()) {
|
const normalizedLines = logData
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedLines = stderrData
|
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map((line) => line.replace(ANSI_ESCAPE_REGEX, '').trim())
|
.map((line) => line.replace(ANSI_ESCAPE_REGEX, '').trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -507,11 +507,23 @@ export function extractLikelyAuthFailureFromStderr(
|
|||||||
return line;
|
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 = [
|
const prioritizedPatterns = [
|
||||||
/github copilot authentication failed:\s*(.+)/i,
|
...(providerPatterns[provider] || []),
|
||||||
/authentication failed:\s*(.+)/i,
|
/^authentication failed:\s*(.+)/i,
|
||||||
/failed to verify copilot access[^:]*:\s*(.+)/i,
|
/^failed to save auth:\s*(.+)/i,
|
||||||
/failed to save auth:\s*(.+)/i,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (let i = normalizedLines.length - 1; i >= 0; i--) {
|
for (let i = normalizedLines.length - 1; i >= 0; i--) {
|
||||||
@@ -527,6 +539,34 @@ export function extractLikelyAuthFailureFromStderr(
|
|||||||
return null;
|
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 */
|
/** Handle token not found after successful process exit */
|
||||||
async function handleTokenNotFound(
|
async function handleTokenNotFound(
|
||||||
provider: CLIProxyProvider,
|
provider: CLIProxyProvider,
|
||||||
@@ -537,9 +577,22 @@ async function handleTokenNotFound(
|
|||||||
verbose: boolean,
|
verbose: boolean,
|
||||||
failureReason?: string
|
failureReason?: string
|
||||||
): Promise<AccountInfo | null> {
|
): 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
|
// Kiro-specific: Try auto-import from Kiro IDE
|
||||||
if (provider === 'kiro') {
|
if (provider === 'kiro') {
|
||||||
console.log('');
|
|
||||||
console.log(warn('Callback redirected to Kiro IDE. Attempting to import token...'));
|
console.log(warn('Callback redirected to Kiro IDE. Attempting to import token...'));
|
||||||
|
|
||||||
const result = await tryKiroImport(tokenDir, verbose);
|
const result = await tryKiroImport(tokenDir, verbose);
|
||||||
@@ -558,24 +611,6 @@ async function handleTokenNotFound(
|
|||||||
return null;
|
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(fail('Token not found after authentication'));
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('The browser showed success but callback was not received.');
|
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) => {
|
return new Promise<AccountInfo | null>((resolve) => {
|
||||||
const flowType = resolveAuthFlowType(options);
|
const flowType = resolveAuthFlowType(options);
|
||||||
const isDeviceCodeFlow = flowType === 'device_code';
|
const isDeviceCodeFlow = flowType === 'device_code';
|
||||||
|
const knownTokenFiles = listProviderTokenSnapshots(provider, tokenDir);
|
||||||
|
|
||||||
// Device-code flows can usually inherit stdin, but Kiro's default AWS flow now
|
// 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.
|
// 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);
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||||
|
|
||||||
if (code === 0) {
|
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('');
|
||||||
console.log(ok(`Authentication successful (${elapsed}s)`));
|
console.log(ok(`Authentication successful (${elapsed}s)`));
|
||||||
|
|
||||||
@@ -827,13 +872,11 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
|
|||||||
registerAccountFromToken(provider, tokenDir, nickname, verbose, expectedAccountId)
|
registerAccountFromToken(provider, tokenDir, nickname, verbose, expectedAccountId)
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const failureReason = extractLikelyAuthFailureFromStderr(provider, state.stderrData);
|
|
||||||
|
|
||||||
// Emit device code failure event for UI
|
// Emit device code failure event for UI
|
||||||
if (isDeviceCodeFlow && state.deviceCodeDisplayed) {
|
if (isDeviceCodeFlow && state.deviceCodeDisplayed) {
|
||||||
deviceCodeEvents.emit('deviceCode:failed', {
|
deviceCodeEvents.emit('deviceCode:failed', {
|
||||||
sessionId: state.sessionId,
|
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,
|
nickname,
|
||||||
expectedAccountId,
|
expectedAccountId,
|
||||||
verbose,
|
verbose,
|
||||||
failureReason || undefined
|
exitAnalysis.failureReason || undefined
|
||||||
);
|
);
|
||||||
resolve(account);
|
resolve(account);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
import { CLIProxyProvider } from '../types';
|
import { CLIProxyProvider } from '../types';
|
||||||
import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
|
import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
|
||||||
import { getProviderAuthDir } from '../config-generator';
|
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
|
* Check if provider has valid authentication
|
||||||
* CLIProxyAPI stores OAuth tokens as JSON files in the auth directory.
|
* CLIProxyAPI stores OAuth tokens as JSON files in the auth directory.
|
||||||
@@ -207,83 +367,11 @@ export function registerAccountFromToken(
|
|||||||
verbose = false,
|
verbose = false,
|
||||||
expectedAccountId?: string
|
expectedAccountId?: string
|
||||||
): import('../account-manager').AccountInfo | null {
|
): 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');
|
const { registerAccount } = require('../account-manager');
|
||||||
let selectedCandidate: Omit<TokenCandidate, 'mtimeMs'> | null = null;
|
let selectedCandidate: Omit<TokenCandidate, 'mtimeMs'> | null = null;
|
||||||
try {
|
try {
|
||||||
const files = fs.readdirSync(tokenDir);
|
const candidates = listTokenCandidates(provider, tokenDir);
|
||||||
const jsonFiles = files.filter((f: string) => f.endsWith('.json'));
|
|
||||||
const existingAccounts = getProviderAccounts(provider);
|
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) {
|
if (expectedAccountId) {
|
||||||
selectedCandidate =
|
selectedCandidate =
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { describe, expect, it } from 'bun:test';
|
import { describe, expect, it } from 'bun:test';
|
||||||
import {
|
import {
|
||||||
|
analyzeSuccessfulAuthExit,
|
||||||
|
extractLikelyAuthFailureFromLogs,
|
||||||
extractLikelyAuthFailureFromStderr,
|
extractLikelyAuthFailureFromStderr,
|
||||||
extractLikelyOAuthAuthorizationUrl,
|
extractLikelyOAuthAuthorizationUrl,
|
||||||
getExpectedLocalCallback,
|
getExpectedLocalCallback,
|
||||||
@@ -38,6 +40,123 @@ describe('oauth-process stderr parsing', () => {
|
|||||||
expect(parsed).not.toBeNull();
|
expect(parsed).not.toBeNull();
|
||||||
expect((parsed as string).length).toBe(240);
|
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', () => {
|
describe('oauth-process manual callback validation', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user