mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-11 02:21:58 +00:00
fix(auth): verify polled oauth account persistence
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import {
|
import {
|
||||||
getAllAuthStatus,
|
getAllAuthStatus,
|
||||||
@@ -22,6 +24,7 @@ import {
|
|||||||
pauseAccount as pauseAccountFn,
|
pauseAccount as pauseAccountFn,
|
||||||
resumeAccount as resumeAccountFn,
|
resumeAccount as resumeAccountFn,
|
||||||
touchAccount,
|
touchAccount,
|
||||||
|
extractAccountIdFromTokenFile,
|
||||||
hasAccountNameConflict,
|
hasAccountNameConflict,
|
||||||
PROVIDERS_WITHOUT_EMAIL,
|
PROVIDERS_WITHOUT_EMAIL,
|
||||||
validateNickname,
|
validateNickname,
|
||||||
@@ -34,7 +37,11 @@ import {
|
|||||||
import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher';
|
import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher';
|
||||||
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
||||||
import { tryKiroImport } from '../../cliproxy/auth/kiro-import';
|
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 {
|
import {
|
||||||
CLIPROXY_CALLBACK_PROVIDER_MAP,
|
CLIPROXY_CALLBACK_PROVIDER_MAP,
|
||||||
CLIPROXY_AUTH_URL_PROVIDER_MAP,
|
CLIPROXY_AUTH_URL_PROVIDER_MAP,
|
||||||
@@ -56,9 +63,20 @@ import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middlewar
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const MANUAL_AUTH_STATE_TTL_MS = 10 * 60 * 1000;
|
const MANUAL_AUTH_STATE_TTL_MS = 10 * 60 * 1000;
|
||||||
|
type ProviderTokenSnapshot = {
|
||||||
|
file: string;
|
||||||
|
mtimeMs: number;
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const pendingManualAuthState = new Map<
|
const pendingManualAuthState = new Map<
|
||||||
string,
|
string,
|
||||||
{ nickname?: string; expectedAccountId?: string; createdAt: number }
|
{
|
||||||
|
nickname?: string;
|
||||||
|
expectedAccountId?: string;
|
||||||
|
createdAt: number;
|
||||||
|
knownTokenFiles: ProviderTokenSnapshot[];
|
||||||
|
}
|
||||||
>();
|
>();
|
||||||
|
|
||||||
// Valid providers list - derived from canonical CLIPROXY_PROFILES
|
// Valid providers list - derived from canonical CLIPROXY_PROFILES
|
||||||
@@ -88,7 +106,11 @@ function pruneExpiredManualAuthState(now = Date.now()): void {
|
|||||||
|
|
||||||
function rememberManualAuthState(
|
function rememberManualAuthState(
|
||||||
state: string,
|
state: string,
|
||||||
pending: { nickname?: string; expectedAccountId?: string }
|
pending: {
|
||||||
|
nickname?: string;
|
||||||
|
expectedAccountId?: string;
|
||||||
|
knownTokenFiles: ProviderTokenSnapshot[];
|
||||||
|
}
|
||||||
): void {
|
): void {
|
||||||
pruneExpiredManualAuthState();
|
pruneExpiredManualAuthState();
|
||||||
pendingManualAuthState.set(state, {
|
pendingManualAuthState.set(state, {
|
||||||
@@ -99,7 +121,12 @@ function rememberManualAuthState(
|
|||||||
|
|
||||||
function getManualAuthState(
|
function getManualAuthState(
|
||||||
state: string | undefined
|
state: string | undefined
|
||||||
): { nickname?: string; expectedAccountId?: string } | null {
|
): {
|
||||||
|
nickname?: string;
|
||||||
|
expectedAccountId?: string;
|
||||||
|
createdAt: number;
|
||||||
|
knownTokenFiles: ProviderTokenSnapshot[];
|
||||||
|
} | null {
|
||||||
if (!state) {
|
if (!state) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -113,9 +140,62 @@ function getManualAuthState(
|
|||||||
return {
|
return {
|
||||||
nickname: pending.nickname,
|
nickname: pending.nickname,
|
||||||
expectedAccountId: pending.expectedAccountId,
|
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 } {
|
function parseKiroMethod(raw: unknown): { method: KiroAuthMethod; invalid: boolean } {
|
||||||
if (raw === undefined || raw === null) {
|
if (raw === undefined || raw === null) {
|
||||||
return { method: normalizeKiroAuthMethod(), invalid: false };
|
return { method: normalizeKiroAuthMethod(), invalid: false };
|
||||||
@@ -795,6 +875,7 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
|
|||||||
if (oauthState) {
|
if (oauthState) {
|
||||||
rememberManualAuthState(oauthState, {
|
rememberManualAuthState(oauthState, {
|
||||||
nickname: nickname || undefined,
|
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 };
|
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);
|
res.json(data);
|
||||||
} catch {
|
} catch {
|
||||||
res.status(503).json({ status: 'error', error: 'CLIProxyAPI not reachable' });
|
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 () => {
|
it('persists the supplied nickname for Kiro social start-url flows after callback submission', async () => {
|
||||||
mockFetch([
|
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.',
|
'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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user