mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-05 12:22:35 +00:00
fix(auth): wait for polled oauth token persistence
This commit is contained in:
@@ -62,6 +62,7 @@ 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;
|
||||||
|
const POLLED_AUTH_LOCAL_TOKEN_GRACE_MS = 15 * 1000;
|
||||||
type ProviderTokenSnapshot = {
|
type ProviderTokenSnapshot = {
|
||||||
file: string;
|
file: string;
|
||||||
mtimeMs: number;
|
mtimeMs: number;
|
||||||
@@ -74,6 +75,7 @@ const pendingManualAuthState = new Map<
|
|||||||
nickname?: string;
|
nickname?: string;
|
||||||
expectedAccountId?: string;
|
expectedAccountId?: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
upstreamCompletedAt?: number;
|
||||||
knownTokenFiles: ProviderTokenSnapshot[];
|
knownTokenFiles: ProviderTokenSnapshot[];
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
@@ -122,6 +124,7 @@ function getManualAuthState(state: string | undefined): {
|
|||||||
nickname?: string;
|
nickname?: string;
|
||||||
expectedAccountId?: string;
|
expectedAccountId?: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
upstreamCompletedAt?: number;
|
||||||
knownTokenFiles: ProviderTokenSnapshot[];
|
knownTokenFiles: ProviderTokenSnapshot[];
|
||||||
} | null {
|
} | null {
|
||||||
if (!state) {
|
if (!state) {
|
||||||
@@ -138,10 +141,27 @@ function getManualAuthState(state: string | undefined): {
|
|||||||
nickname: pending.nickname,
|
nickname: pending.nickname,
|
||||||
expectedAccountId: pending.expectedAccountId,
|
expectedAccountId: pending.expectedAccountId,
|
||||||
createdAt: pending.createdAt,
|
createdAt: pending.createdAt,
|
||||||
|
upstreamCompletedAt: pending.upstreamCompletedAt,
|
||||||
knownTokenFiles: pending.knownTokenFiles,
|
knownTokenFiles: pending.knownTokenFiles,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markManualAuthUpstreamCompleted(state: string, now = Date.now()): number | null {
|
||||||
|
pruneExpiredManualAuthState(now);
|
||||||
|
const pending = pendingManualAuthState.get(state);
|
||||||
|
if (!pending) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pending.upstreamCompletedAt !== undefined) {
|
||||||
|
return pending.upstreamCompletedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
pending.upstreamCompletedAt = now;
|
||||||
|
pendingManualAuthState.set(state, pending);
|
||||||
|
return now;
|
||||||
|
}
|
||||||
|
|
||||||
function listProviderTokenSnapshots(provider: CLIProxyProvider): ProviderTokenSnapshot[] {
|
function listProviderTokenSnapshots(provider: CLIProxyProvider): ProviderTokenSnapshot[] {
|
||||||
const tokenDir = getProviderTokenDir(provider);
|
const tokenDir = getProviderTokenDir(provider);
|
||||||
if (!fs.existsSync(tokenDir)) {
|
if (!fs.existsSync(tokenDir)) {
|
||||||
@@ -918,6 +938,16 @@ router.get('/:provider/status', async (req: Request, res: Response): Promise<voi
|
|||||||
|
|
||||||
const tokenSnapshot = findNewTokenSnapshotForPendingAuth(localProvider, pendingAuth);
|
const tokenSnapshot = findNewTokenSnapshotForPendingAuth(localProvider, pendingAuth);
|
||||||
if (!tokenSnapshot) {
|
if (!tokenSnapshot) {
|
||||||
|
const upstreamCompletedAt =
|
||||||
|
pendingAuth.upstreamCompletedAt ?? markManualAuthUpstreamCompleted(state, Date.now());
|
||||||
|
if (
|
||||||
|
upstreamCompletedAt !== null &&
|
||||||
|
Date.now() - upstreamCompletedAt < POLLED_AUTH_LOCAL_TOKEN_GRACE_MS
|
||||||
|
) {
|
||||||
|
res.json({ status: 'wait' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
res.status(409).json({
|
res.status(409).json({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
error:
|
error:
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ describe('cliproxy-auth-routes manual callback nickname persistence', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 409 when status polling completes upstream but no new local token was written', async () => {
|
it('keeps polling briefly after upstream completion before surfacing the missing-token error', async () => {
|
||||||
const tokenDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth');
|
const tokenDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth');
|
||||||
fs.mkdirSync(tokenDir, { recursive: true });
|
fs.mkdirSync(tokenDir, { recursive: true });
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
@@ -232,15 +232,81 @@ describe('cliproxy-auth-routes manual callback nickname persistence', () => {
|
|||||||
const startResponse = await postJson('/api/cliproxy/auth/codex/start-url', {});
|
const startResponse = await postJson('/api/cliproxy/auth/codex/start-url', {});
|
||||||
expect(startResponse.status).toBe(200);
|
expect(startResponse.status).toBe(200);
|
||||||
|
|
||||||
const statusResponse = await getJson(
|
const realDateNow = Date.now;
|
||||||
'/api/cliproxy/auth/codex/status?state=state-status-missing'
|
let now = realDateNow();
|
||||||
|
Date.now = () => now;
|
||||||
|
try {
|
||||||
|
const firstStatusResponse = await getJson(
|
||||||
|
'/api/cliproxy/auth/codex/status?state=state-status-missing'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(firstStatusResponse.status).toBe(200);
|
||||||
|
expect(firstStatusResponse.body).toEqual({ status: 'wait' });
|
||||||
|
|
||||||
|
now += 16_000;
|
||||||
|
|
||||||
|
const secondStatusResponse = await getJson(
|
||||||
|
'/api/cliproxy/auth/codex/status?state=state-status-missing'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(secondStatusResponse.status).toBe(409);
|
||||||
|
expect(secondStatusResponse.body).toEqual({
|
||||||
|
status: 'error',
|
||||||
|
error:
|
||||||
|
'Authentication completed upstream, but no new local token was saved for this account. Update CCS/CLIProxy and retry.',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
Date.now = realDateNow;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('continues polling until the local token appears after upstream completion', async () => {
|
||||||
|
mockFetch([
|
||||||
|
{
|
||||||
|
url: /\/v0\/management\/codex-auth-url\?is_webui=true$/,
|
||||||
|
response: {
|
||||||
|
auth_url: 'https://auth.example.com/authorize?state=state-status-delayed',
|
||||||
|
state: 'state-status-delayed',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: /\/v0\/management\/get-auth-status\?state=state-status-delayed$/,
|
||||||
|
response: { status: 'ok' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const startResponse = await postJson('/api/cliproxy/auth/codex/start-url', {});
|
||||||
|
expect(startResponse.status).toBe(200);
|
||||||
|
|
||||||
|
const firstStatusResponse = await getJson(
|
||||||
|
'/api/cliproxy/auth/codex/status?state=state-status-delayed'
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(statusResponse.status).toBe(409);
|
expect(firstStatusResponse.status).toBe(200);
|
||||||
expect(statusResponse.body).toEqual({
|
expect(firstStatusResponse.body).toEqual({ status: 'wait' });
|
||||||
status: 'error',
|
|
||||||
error:
|
const tokenDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth');
|
||||||
'Authentication completed upstream, but no new local token was saved for this account. Update CCS/CLIProxy and retry.',
|
fs.mkdirSync(tokenDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tokenDir, 'codex-delayed@example.com.json'),
|
||||||
|
JSON.stringify({ type: 'codex', email: 'delayed@example.com' }),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const secondStatusResponse = await getJson(
|
||||||
|
'/api/cliproxy/auth/codex/status?state=state-status-delayed'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(secondStatusResponse.status).toBe(200);
|
||||||
|
expect(secondStatusResponse.body).toEqual({
|
||||||
|
status: 'ok',
|
||||||
|
account: {
|
||||||
|
id: 'delayed@example.com',
|
||||||
|
email: 'delayed@example.com',
|
||||||
|
nickname: 'delayed',
|
||||||
|
provider: 'codex',
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user