fix(auth): verify polled oauth account persistence

This commit is contained in:
Tam Nhu Tran
2026-03-24 09:15:05 -04:00
parent d62b2b9602
commit 09eb01f16e
2 changed files with 258 additions and 4 deletions
+138 -4
View File
@@ -1,3 +1,5 @@
import * as fs from 'fs';
import * as path from 'path';
import { Router, Request, Response } from 'express';
import {
getAllAuthStatus,
@@ -22,6 +24,7 @@ import {
pauseAccount as pauseAccountFn,
resumeAccount as resumeAccountFn,
touchAccount,
extractAccountIdFromTokenFile,
hasAccountNameConflict,
PROVIDERS_WITHOUT_EMAIL,
validateNickname,
@@ -34,7 +37,11 @@ import {
import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import { tryKiroImport } from '../../cliproxy/auth/kiro-import';
import { getProviderTokenDir, registerAccountFromToken } from '../../cliproxy/auth/token-manager';
import {
getProviderTokenDir,
isTokenFileForProvider,
registerAccountFromToken,
} from '../../cliproxy/auth/token-manager';
import {
CLIPROXY_CALLBACK_PROVIDER_MAP,
CLIPROXY_AUTH_URL_PROVIDER_MAP,
@@ -56,9 +63,20 @@ import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middlewar
const router = Router();
const MANUAL_AUTH_STATE_TTL_MS = 10 * 60 * 1000;
type ProviderTokenSnapshot = {
file: string;
mtimeMs: number;
email?: string;
};
const pendingManualAuthState = new Map<
string,
{ nickname?: string; expectedAccountId?: string; createdAt: number }
{
nickname?: string;
expectedAccountId?: string;
createdAt: number;
knownTokenFiles: ProviderTokenSnapshot[];
}
>();
// Valid providers list - derived from canonical CLIPROXY_PROFILES
@@ -88,7 +106,11 @@ function pruneExpiredManualAuthState(now = Date.now()): void {
function rememberManualAuthState(
state: string,
pending: { nickname?: string; expectedAccountId?: string }
pending: {
nickname?: string;
expectedAccountId?: string;
knownTokenFiles: ProviderTokenSnapshot[];
}
): void {
pruneExpiredManualAuthState();
pendingManualAuthState.set(state, {
@@ -99,7 +121,12 @@ function rememberManualAuthState(
function getManualAuthState(
state: string | undefined
): { nickname?: string; expectedAccountId?: string } | null {
): {
nickname?: string;
expectedAccountId?: string;
createdAt: number;
knownTokenFiles: ProviderTokenSnapshot[];
} | null {
if (!state) {
return null;
}
@@ -113,9 +140,62 @@ function getManualAuthState(
return {
nickname: pending.nickname,
expectedAccountId: pending.expectedAccountId,
createdAt: pending.createdAt,
knownTokenFiles: pending.knownTokenFiles,
};
}
function listProviderTokenSnapshots(provider: CLIProxyProvider): ProviderTokenSnapshot[] {
const tokenDir = getProviderTokenDir(provider);
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;
}
let email: string | undefined;
try {
const content = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(content) as { email?: string };
email = typeof parsed.email === 'string' ? parsed.email : undefined;
} catch {
email = undefined;
}
const stats = fs.statSync(filePath);
return {
file,
mtimeMs: stats.mtimeMs,
email,
};
})
.filter((snapshot): snapshot is ProviderTokenSnapshot => snapshot !== null)
.sort((left, right) => right.mtimeMs - left.mtimeMs);
}
function findNewTokenSnapshotForPendingAuth(
provider: CLIProxyProvider,
pending: { knownTokenFiles: ProviderTokenSnapshot[] }
): ProviderTokenSnapshot | null {
const knownTokenMtimes = new Map(
pending.knownTokenFiles.map((snapshot) => [snapshot.file, snapshot.mtimeMs])
);
return (
listProviderTokenSnapshots(provider).find((snapshot) => {
const knownMtime = knownTokenMtimes.get(snapshot.file);
return knownMtime === undefined || snapshot.mtimeMs > knownMtime + 1;
}) || null
);
}
function parseKiroMethod(raw: unknown): { method: KiroAuthMethod; invalid: boolean } {
if (raw === undefined || raw === null) {
return { method: normalizeKiroAuthMethod(), invalid: false };
@@ -795,6 +875,7 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
if (oauthState) {
rememberManualAuthState(oauthState, {
nickname: nickname || undefined,
knownTokenFiles: listProviderTokenSnapshots(provider as CLIProxyProvider),
});
}
@@ -838,6 +919,59 @@ router.get('/:provider/status', async (req: Request, res: Response): Promise<voi
);
const data = (await response.json()) as { status?: string; error?: string };
if (data.status === 'ok') {
const localProvider = provider as CLIProxyProvider;
const pendingAuth = getManualAuthState(state);
if (!pendingAuth) {
res.status(409).json({
status: 'error',
error:
'Authentication completed upstream, but CCS could not match it to the active add-account session. Retry the flow from the dashboard.',
});
return;
}
const tokenSnapshot = findNewTokenSnapshotForPendingAuth(localProvider, pendingAuth);
if (!tokenSnapshot) {
res.status(409).json({
status: 'error',
error:
'Authentication completed upstream, but no new local token was saved for this account. Update CCS/CLIProxy and retry.',
});
return;
}
const account = registerAccountFromToken(
localProvider,
getProviderTokenDir(localProvider),
pendingAuth.nickname,
false,
extractAccountIdFromTokenFile(tokenSnapshot.file, tokenSnapshot.email)
);
if (!account) {
res.status(409).json({
status: 'error',
error: getManualCallbackRegistrationError(localProvider),
});
return;
}
pendingManualAuthState.delete(state);
res.json({
status: 'ok',
account: {
id: account.id,
email: account.email,
nickname: account.nickname,
provider: account.provider,
isDefault: account.isDefault,
},
});
return;
}
res.json(data);
} catch {
res.status(503).json({ status: 'error', error: 'CLIProxyAPI not reachable' });
@@ -95,6 +95,36 @@ describe('cliproxy-auth-routes manual callback nickname persistence', () => {
});
}
async function getJson(route: string) {
return await new Promise<{ status: number; body: unknown }>((resolve, reject) => {
const url = new URL(`${baseUrl}${route}`);
const request = http.request(
{
method: 'GET',
hostname: url.hostname,
port: url.port,
path: `${url.pathname}${url.search}`,
},
(response) => {
let responseBody = '';
response.setEncoding('utf8');
response.on('data', (chunk) => {
responseBody += chunk;
});
response.on('end', () => {
resolve({
status: response.statusCode || 0,
body: responseBody ? JSON.parse(responseBody) : null,
});
});
}
);
request.on('error', reject);
request.end();
});
}
it('persists the supplied nickname for Kiro social start-url flows after callback submission', async () => {
mockFetch([
{
@@ -175,4 +205,94 @@ describe('cliproxy-auth-routes manual callback nickname persistence', () => {
'Authenticated token could not be matched to a new account. Retry the flow and choose a different nickname if needed.',
});
});
it('returns 409 when status polling completes upstream but no new local token was written', async () => {
const tokenDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth');
fs.mkdirSync(tokenDir, { recursive: true });
fs.writeFileSync(
path.join(tokenDir, 'codex-existing@example.com.json'),
JSON.stringify({ type: 'codex', email: 'existing@example.com' }),
'utf8'
);
mockFetch([
{
url: /\/v0\/management\/codex-auth-url\?is_webui=true$/,
response: {
auth_url: 'https://auth.example.com/authorize?state=state-status-missing',
state: 'state-status-missing',
},
},
{
url: /\/v0\/management\/get-auth-status\?state=state-status-missing$/,
response: { status: 'ok' },
},
]);
const startResponse = await postJson('/api/cliproxy/auth/codex/start-url', {});
expect(startResponse.status).toBe(200);
const statusResponse = await getJson(
'/api/cliproxy/auth/codex/status?state=state-status-missing'
);
expect(statusResponse.status).toBe(409);
expect(statusResponse.body).toEqual({
status: 'error',
error:
'Authentication completed upstream, but no new local token was saved for this account. Update CCS/CLIProxy and retry.',
});
});
it('registers the new account before reporting polled auth success', async () => {
mockFetch([
{
url: /\/v0\/management\/codex-auth-url\?is_webui=true$/,
response: {
auth_url: 'https://auth.example.com/authorize?state=state-status-ok',
state: 'state-status-ok',
},
},
{
url: /\/v0\/management\/get-auth-status\?state=state-status-ok$/,
response: { status: 'ok' },
},
]);
const startResponse = await postJson('/api/cliproxy/auth/codex/start-url', {});
expect(startResponse.status).toBe(200);
const tokenDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth');
fs.mkdirSync(tokenDir, { recursive: true });
fs.writeFileSync(
path.join(tokenDir, 'codex-new@example.com.json'),
JSON.stringify({ type: 'codex', email: 'new@example.com' }),
'utf8'
);
const statusResponse = await getJson('/api/cliproxy/auth/codex/status?state=state-status-ok');
expect(statusResponse.status).toBe(200);
expect(statusResponse.body).toEqual({
status: 'ok',
account: {
id: 'new@example.com',
email: 'new@example.com',
nickname: 'new',
provider: 'codex',
isDefault: true,
},
});
const registryPath = path.join(tempHome, '.ccs', 'cliproxy', 'accounts.json');
const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as {
providers: {
codex: {
accounts: Record<string, { email?: string }>;
};
};
};
expect(registry.providers.codex.accounts['new@example.com']?.email).toBe('new@example.com');
});
});