From 09eb01f16e0a374198031079d22eeae2a3135810 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 24 Mar 2026 09:15:05 -0400 Subject: [PATCH] fix(auth): verify polled oauth account persistence --- src/web-server/routes/cliproxy-auth-routes.ts | 142 +++++++++++++++++- ...iproxy-auth-routes-manual-callback.test.ts | 120 +++++++++++++++ 2 files changed, 258 insertions(+), 4 deletions(-) diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index b6a96a88..86b1e706 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -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 { }); } + 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; + }; + }; + }; + + expect(registry.providers.codex.accounts['new@example.com']?.email).toBe('new@example.com'); + }); });