mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-05 18:16:28 +00:00
fix(auth): harden delayed oauth token registration
This commit is contained in:
@@ -99,7 +99,12 @@ router.use((req: Request, res: Response, next) => {
|
|||||||
|
|
||||||
function pruneExpiredManualAuthState(now = Date.now()): void {
|
function pruneExpiredManualAuthState(now = Date.now()): void {
|
||||||
for (const [state, pending] of pendingManualAuthState.entries()) {
|
for (const [state, pending] of pendingManualAuthState.entries()) {
|
||||||
if (now - pending.createdAt > MANUAL_AUTH_STATE_TTL_MS) {
|
const authExpired = now - pending.createdAt > MANUAL_AUTH_STATE_TTL_MS;
|
||||||
|
const withinLocalTokenGrace =
|
||||||
|
pending.upstreamCompletedAt !== undefined &&
|
||||||
|
now - pending.upstreamCompletedAt < POLLED_AUTH_LOCAL_TOKEN_GRACE_MS;
|
||||||
|
|
||||||
|
if (authExpired && !withinLocalTokenGrace) {
|
||||||
pendingManualAuthState.delete(state);
|
pendingManualAuthState.delete(state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,7 +204,7 @@ function listProviderTokenSnapshots(provider: CLIProxyProvider): ProviderTokenSn
|
|||||||
|
|
||||||
function findNewTokenSnapshotForPendingAuth(
|
function findNewTokenSnapshotForPendingAuth(
|
||||||
provider: CLIProxyProvider,
|
provider: CLIProxyProvider,
|
||||||
pending: { knownTokenFiles: ProviderTokenSnapshot[] }
|
pending: { expectedAccountId?: string; knownTokenFiles: ProviderTokenSnapshot[] }
|
||||||
): ProviderTokenSnapshot | null {
|
): ProviderTokenSnapshot | null {
|
||||||
const knownTokenMtimes = new Map(
|
const knownTokenMtimes = new Map(
|
||||||
pending.knownTokenFiles.map((snapshot) => [snapshot.file, snapshot.mtimeMs])
|
pending.knownTokenFiles.map((snapshot) => [snapshot.file, snapshot.mtimeMs])
|
||||||
@@ -208,11 +213,32 @@ function findNewTokenSnapshotForPendingAuth(
|
|||||||
return (
|
return (
|
||||||
listProviderTokenSnapshots(provider).find((snapshot) => {
|
listProviderTokenSnapshots(provider).find((snapshot) => {
|
||||||
const knownMtime = knownTokenMtimes.get(snapshot.file);
|
const knownMtime = knownTokenMtimes.get(snapshot.file);
|
||||||
return knownMtime === undefined || snapshot.mtimeMs > knownMtime + 1;
|
if (knownMtime === undefined) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pending.expectedAccountId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot.mtimeMs > knownMtime + 1;
|
||||||
}) || null
|
}) || null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldKeepWaitingForLocalToken(
|
||||||
|
state: string,
|
||||||
|
pending: { upstreamCompletedAt?: number },
|
||||||
|
now = Date.now()
|
||||||
|
): boolean {
|
||||||
|
const upstreamCompletedAt =
|
||||||
|
pending.upstreamCompletedAt ?? markManualAuthUpstreamCompleted(state, now);
|
||||||
|
|
||||||
|
return (
|
||||||
|
upstreamCompletedAt !== null && now - upstreamCompletedAt < POLLED_AUTH_LOCAL_TOKEN_GRACE_MS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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 };
|
||||||
@@ -936,18 +962,15 @@ router.get('/:provider/status', async (req: Request, res: Response): Promise<voi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
const tokenSnapshot = findNewTokenSnapshotForPendingAuth(localProvider, pendingAuth);
|
const tokenSnapshot = findNewTokenSnapshotForPendingAuth(localProvider, pendingAuth);
|
||||||
if (!tokenSnapshot) {
|
if (!tokenSnapshot) {
|
||||||
const upstreamCompletedAt =
|
if (shouldKeepWaitingForLocalToken(state, pendingAuth, now)) {
|
||||||
pendingAuth.upstreamCompletedAt ?? markManualAuthUpstreamCompleted(state, Date.now());
|
|
||||||
if (
|
|
||||||
upstreamCompletedAt !== null &&
|
|
||||||
Date.now() - upstreamCompletedAt < POLLED_AUTH_LOCAL_TOKEN_GRACE_MS
|
|
||||||
) {
|
|
||||||
res.json({ status: 'wait' });
|
res.json({ status: 'wait' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pendingManualAuthState.delete(state);
|
||||||
res.status(409).json({
|
res.status(409).json({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
error:
|
error:
|
||||||
@@ -965,6 +988,7 @@ router.get('/:provider/status', async (req: Request, res: Response): Promise<voi
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
|
pendingManualAuthState.delete(state);
|
||||||
res.status(409).json({
|
res.status(409).json({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
error: getManualCallbackRegistrationError(localProvider),
|
error: getManualCallbackRegistrationError(localProvider),
|
||||||
@@ -1078,20 +1102,72 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const account = registerAccountFromToken(
|
const localProvider = provider as CLIProxyProvider;
|
||||||
provider as CLIProxyProvider,
|
const now = Date.now();
|
||||||
getProviderTokenDir(provider as CLIProxyProvider),
|
|
||||||
pendingAuth?.nickname,
|
if (pendingAuth) {
|
||||||
false,
|
const tokenSnapshot = findNewTokenSnapshotForPendingAuth(localProvider, pendingAuth);
|
||||||
pendingAuth?.expectedAccountId
|
if (!tokenSnapshot) {
|
||||||
);
|
if (parsed.state && shouldKeepWaitingForLocalToken(parsed.state, pendingAuth, now)) {
|
||||||
if (parsed.state) {
|
res.json({ status: 'wait' });
|
||||||
pendingManualAuthState.delete(parsed.state);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.state) {
|
||||||
|
pendingManualAuthState.delete(parsed.state);
|
||||||
|
}
|
||||||
|
res.status(409).json({
|
||||||
|
error: getManualCallbackRegistrationError(localProvider),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = registerAccountFromToken(
|
||||||
|
localProvider,
|
||||||
|
getProviderTokenDir(localProvider),
|
||||||
|
pendingAuth.nickname,
|
||||||
|
false,
|
||||||
|
tokenSnapshot.file
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
if (parsed.state) {
|
||||||
|
pendingManualAuthState.delete(parsed.state);
|
||||||
|
}
|
||||||
|
res.status(409).json({
|
||||||
|
error: getManualCallbackRegistrationError(localProvider),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.state) {
|
||||||
|
pendingManualAuthState.delete(parsed.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
account: {
|
||||||
|
id: account.id,
|
||||||
|
email: account.email,
|
||||||
|
nickname: account.nickname,
|
||||||
|
provider: account.provider,
|
||||||
|
isDefault: account.isDefault,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const account = registerAccountFromToken(
|
||||||
|
localProvider,
|
||||||
|
getProviderTokenDir(localProvider),
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
res.status(409).json({
|
res.status(409).json({
|
||||||
error: getManualCallbackRegistrationError(provider as CLIProxyProvider),
|
error: getManualCallbackRegistrationError(localProvider),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,13 +173,13 @@ describe('cliproxy-auth-routes manual callback nickname persistence', () => {
|
|||||||
expect(registry.providers.kiro.accounts['github-ABC123']?.nickname).toBe('work');
|
expect(registry.providers.kiro.accounts['github-ABC123']?.nickname).toBe('work');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 409 when callback completes upstream but no account can be registered locally', async () => {
|
it('returns wait after callback submission when the local token is not yet available', async () => {
|
||||||
mockFetch([
|
mockFetch([
|
||||||
{
|
{
|
||||||
url: /\/v0\/management\/kiro-auth-url\?is_webui=true&method=google$/,
|
url: /\/v0\/management\/codex-auth-url\?is_webui=true$/,
|
||||||
response: {
|
response: {
|
||||||
auth_url: 'https://auth.example.com/authorize?state=state-409',
|
auth_url: 'https://auth.example.com/authorize?state=state-callback-wait',
|
||||||
state: 'state-409',
|
state: 'state-callback-wait',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -187,23 +187,41 @@ describe('cliproxy-auth-routes manual callback nickname persistence', () => {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
response: { status: 'ok' },
|
response: { status: 'ok' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
url: /\/v0\/management\/get-auth-status\?state=state-callback-wait$/,
|
||||||
|
response: { status: 'ok' },
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const startResponse = await postJson('/api/cliproxy/auth/kiro/start-url', {
|
const startResponse = await postJson('/api/cliproxy/auth/codex/start-url', {});
|
||||||
nickname: 'work',
|
|
||||||
kiroMethod: 'google',
|
|
||||||
});
|
|
||||||
expect(startResponse.status).toBe(200);
|
expect(startResponse.status).toBe(200);
|
||||||
|
|
||||||
const callbackResponse = await postJson('/api/cliproxy/auth/kiro/submit-callback', {
|
const realDateNow = Date.now;
|
||||||
redirectUrl: 'http://localhost/callback?code=abc123&state=state-409',
|
let now = realDateNow();
|
||||||
});
|
Date.now = () => now;
|
||||||
|
try {
|
||||||
|
const callbackResponse = await postJson('/api/cliproxy/auth/codex/submit-callback', {
|
||||||
|
redirectUrl: 'http://localhost/callback?code=abc123&state=state-callback-wait',
|
||||||
|
});
|
||||||
|
|
||||||
expect(callbackResponse.status).toBe(409);
|
expect(callbackResponse.status).toBe(200);
|
||||||
expect(callbackResponse.body).toEqual({
|
expect(callbackResponse.body).toEqual({ status: 'wait' });
|
||||||
error:
|
|
||||||
'Authenticated token could not be matched to a new account. Retry the flow and choose a different nickname if needed.',
|
now += 16_000;
|
||||||
});
|
|
||||||
|
const statusResponse = await getJson(
|
||||||
|
'/api/cliproxy/auth/codex/status?state=state-callback-wait'
|
||||||
|
);
|
||||||
|
|
||||||
|
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.',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
Date.now = realDateNow;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps polling briefly after upstream completion before surfacing the missing-token error', async () => {
|
it('keeps polling briefly after upstream completion before surfacing the missing-token error', async () => {
|
||||||
@@ -310,6 +328,191 @@ describe('cliproxy-auth-routes manual callback nickname persistence', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lets polling finish successfully after callback submission once the token appears', async () => {
|
||||||
|
mockFetch([
|
||||||
|
{
|
||||||
|
url: /\/v0\/management\/codex-auth-url\?is_webui=true$/,
|
||||||
|
response: {
|
||||||
|
auth_url: 'https://auth.example.com/authorize?state=state-callback-delayed',
|
||||||
|
state: 'state-callback-delayed',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: /\/v0\/management\/oauth-callback$/,
|
||||||
|
method: 'POST',
|
||||||
|
response: { status: 'ok' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: /\/v0\/management\/get-auth-status\?state=state-callback-delayed$/,
|
||||||
|
response: { status: 'ok' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const startResponse = await postJson('/api/cliproxy/auth/codex/start-url', {});
|
||||||
|
expect(startResponse.status).toBe(200);
|
||||||
|
|
||||||
|
const callbackResponse = await postJson('/api/cliproxy/auth/codex/submit-callback', {
|
||||||
|
redirectUrl: 'http://localhost/callback?code=abc123&state=state-callback-delayed',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(callbackResponse.status).toBe(200);
|
||||||
|
expect(callbackResponse.body).toEqual({ status: 'wait' });
|
||||||
|
|
||||||
|
const tokenDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth');
|
||||||
|
fs.mkdirSync(tokenDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tokenDir, 'codex-callback@example.com.json'),
|
||||||
|
JSON.stringify({ type: 'codex', email: 'callback@example.com' }),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusResponse = await getJson(
|
||||||
|
'/api/cliproxy/auth/codex/status?state=state-callback-delayed'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(statusResponse.status).toBe(200);
|
||||||
|
expect(statusResponse.body).toEqual({
|
||||||
|
status: 'ok',
|
||||||
|
account: {
|
||||||
|
id: 'callback@example.com',
|
||||||
|
email: 'callback@example.com',
|
||||||
|
nickname: 'callback',
|
||||||
|
provider: 'codex',
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the pending auth state alive through the local-token grace window even near TTL expiry', async () => {
|
||||||
|
mockFetch([
|
||||||
|
{
|
||||||
|
url: /\/v0\/management\/codex-auth-url\?is_webui=true$/,
|
||||||
|
response: {
|
||||||
|
auth_url: 'https://auth.example.com/authorize?state=state-ttl-grace',
|
||||||
|
state: 'state-ttl-grace',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: /\/v0\/management\/get-auth-status\?state=state-ttl-grace$/,
|
||||||
|
response: { status: 'ok' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: /\/v0\/management\/get-auth-status\?state=state-ttl-grace$/,
|
||||||
|
response: { status: 'ok' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const realDateNow = Date.now;
|
||||||
|
let now = realDateNow();
|
||||||
|
Date.now = () => now;
|
||||||
|
try {
|
||||||
|
const startResponse = await postJson('/api/cliproxy/auth/codex/start-url', {});
|
||||||
|
expect(startResponse.status).toBe(200);
|
||||||
|
|
||||||
|
now += 10 * 60 * 1000 - 1_000;
|
||||||
|
|
||||||
|
const firstStatusResponse = await getJson(
|
||||||
|
'/api/cliproxy/auth/codex/status?state=state-ttl-grace'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(firstStatusResponse.status).toBe(200);
|
||||||
|
expect(firstStatusResponse.body).toEqual({ status: 'wait' });
|
||||||
|
|
||||||
|
const tokenDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth');
|
||||||
|
fs.mkdirSync(tokenDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tokenDir, 'codex-ttl@example.com.json'),
|
||||||
|
JSON.stringify({ type: 'codex', email: 'ttl@example.com' }),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
now += 2_000;
|
||||||
|
|
||||||
|
const secondStatusResponse = await getJson(
|
||||||
|
'/api/cliproxy/auth/codex/status?state=state-ttl-grace'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(secondStatusResponse.status).toBe(200);
|
||||||
|
expect(secondStatusResponse.body).toEqual({
|
||||||
|
status: 'ok',
|
||||||
|
account: {
|
||||||
|
id: 'ttl@example.com',
|
||||||
|
email: 'ttl@example.com',
|
||||||
|
nickname: 'ttl',
|
||||||
|
provider: 'codex',
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
Date.now = realDateNow;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat rewrites of pre-existing token files as a newly added account', async () => {
|
||||||
|
const tokenDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth');
|
||||||
|
fs.mkdirSync(tokenDir, { recursive: true });
|
||||||
|
const tokenPath = path.join(tokenDir, 'codex-existing@example.com.json');
|
||||||
|
fs.writeFileSync(
|
||||||
|
tokenPath,
|
||||||
|
JSON.stringify({ type: 'codex', email: 'existing@example.com', version: 1 }),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
mockFetch([
|
||||||
|
{
|
||||||
|
url: /\/v0\/management\/codex-auth-url\?is_webui=true$/,
|
||||||
|
response: {
|
||||||
|
auth_url: 'https://auth.example.com/authorize?state=state-existing-rewrite',
|
||||||
|
state: 'state-existing-rewrite',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: /\/v0\/management\/get-auth-status\?state=state-existing-rewrite$/,
|
||||||
|
response: { status: 'ok' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: /\/v0\/management\/get-auth-status\?state=state-existing-rewrite$/,
|
||||||
|
response: { status: 'ok' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const startResponse = await postJson('/api/cliproxy/auth/codex/start-url', {});
|
||||||
|
expect(startResponse.status).toBe(200);
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
tokenPath,
|
||||||
|
JSON.stringify({ type: 'codex', email: 'existing@example.com', version: 2 }),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const realDateNow = Date.now;
|
||||||
|
let now = realDateNow();
|
||||||
|
Date.now = () => now;
|
||||||
|
try {
|
||||||
|
const firstStatusResponse = await getJson(
|
||||||
|
'/api/cliproxy/auth/codex/status?state=state-existing-rewrite'
|
||||||
|
);
|
||||||
|
|
||||||
|
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-existing-rewrite'
|
||||||
|
);
|
||||||
|
|
||||||
|
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('registers the new account before reporting polled auth success', async () => {
|
it('registers the new account before reporting polled auth success', async () => {
|
||||||
mockFetch([
|
mockFetch([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -432,9 +432,16 @@ export function useCliproxyAuthFlow() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const success = data.success === true;
|
const success = data.success === true;
|
||||||
|
const waitingForLocalToken = data.status === 'wait';
|
||||||
const hasAccount = typeof data.account === 'object' && data.account !== null;
|
const hasAccount = typeof data.account === 'object' && data.account !== null;
|
||||||
|
|
||||||
if (response.ok && success && hasAccount) {
|
if (response.ok && waitingForLocalToken) {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
isSubmittingCallback: false,
|
||||||
|
error: null,
|
||||||
|
}));
|
||||||
|
} else if (response.ok && success && hasAccount) {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] });
|
queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] });
|
||||||
|
|||||||
@@ -151,6 +151,76 @@ describe('useCliproxyAuthFlow', () => {
|
|||||||
expect(toast.error).toHaveBeenCalledWith('poll failed');
|
expect(toast.error).toHaveBeenCalledWith('poll failed');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps polling through wait responses until a later ok response includes the account', async () => {
|
||||||
|
let pollCount = 0;
|
||||||
|
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn((input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
|
||||||
|
if (url.includes('/start-url')) {
|
||||||
|
return Promise.resolve(
|
||||||
|
createJsonResponse({
|
||||||
|
success: true,
|
||||||
|
authUrl: 'https://auth.example/wait-ok',
|
||||||
|
state: 'state-wait-ok',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes('/status?state=state-wait-ok')) {
|
||||||
|
pollCount += 1;
|
||||||
|
if (pollCount === 1) {
|
||||||
|
return Promise.resolve(createJsonResponse({ status: 'wait' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(
|
||||||
|
createJsonResponse({
|
||||||
|
status: 'ok',
|
||||||
|
account: {
|
||||||
|
id: 'delayed@example.com',
|
||||||
|
email: 'delayed@example.com',
|
||||||
|
nickname: 'delayed',
|
||||||
|
provider: 'codex',
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useCliproxyAuthFlow(), { wrapper });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.startAuth('codex', { startEndpoint: 'start-url' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(3000);
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isAuthenticating).toBe(true);
|
||||||
|
expect(result.current.oauthState).toBe('state-wait-ok');
|
||||||
|
expect(result.current.error).toBeNull();
|
||||||
|
expect(toast.success).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(3000);
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isAuthenticating).toBe(false);
|
||||||
|
expect(result.current.oauthState).toBeNull();
|
||||||
|
expect(toast.success).toHaveBeenCalledWith('codex authentication successful');
|
||||||
|
});
|
||||||
|
|
||||||
it('treats callback responses without an account as failures', async () => {
|
it('treats callback responses without an account as failures', async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
'fetch',
|
'fetch',
|
||||||
@@ -197,6 +267,71 @@ describe('useCliproxyAuthFlow', () => {
|
|||||||
expect(toast.success).not.toHaveBeenCalled();
|
expect(toast.success).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps auth active when callback submission returns wait and only errors on the terminal poll', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn((input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
|
||||||
|
if (url.includes('/start-url')) {
|
||||||
|
return Promise.resolve(
|
||||||
|
createJsonResponse({
|
||||||
|
success: true,
|
||||||
|
authUrl: 'https://auth.example/callback-wait',
|
||||||
|
state: 'state-callback-wait',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes('/submit-callback')) {
|
||||||
|
return Promise.resolve(createJsonResponse({ status: 'wait' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes('/status?state=state-callback-wait')) {
|
||||||
|
return Promise.resolve(
|
||||||
|
createJsonResponse({
|
||||||
|
status: 'error',
|
||||||
|
error:
|
||||||
|
'Authentication completed upstream, but no new local token was saved for this account. Update CCS/CLIProxy and retry.',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useCliproxyAuthFlow(), { wrapper });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.startAuth('codex', { startEndpoint: 'start-url' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.submitCallback(
|
||||||
|
'http://localhost/callback?code=abc123&state=state-callback-wait'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isSubmittingCallback).toBe(false);
|
||||||
|
expect(result.current.isAuthenticating).toBe(true);
|
||||||
|
expect(result.current.error).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(3000);
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isAuthenticating).toBe(false);
|
||||||
|
expect(result.current.error).toBe(
|
||||||
|
'Authentication completed upstream, but no new local token was saved for this account. Update CCS/CLIProxy and retry.'
|
||||||
|
);
|
||||||
|
expect(toast.error).toHaveBeenCalledWith(
|
||||||
|
'Authentication completed upstream, but no new local token was saved for this account. Update CCS/CLIProxy and retry.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('treats status ok responses without an account as failures', async () => {
|
it('treats status ok responses without an account as failures', async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
'fetch',
|
'fetch',
|
||||||
|
|||||||
Reference in New Issue
Block a user