fix(xai-oauth): keep loopback listener open on stale-tab callbacks

The one-shot listener marked itself consumed at the top of every
/callback request, then closed itself in the finally block whether
or not the state actually matched. A stray browser tab replaying an
old /callback?state=… (real-world scenario: user re-clicked Sign in
before closing the previous tab) would therefore close the singleton
:56121 listener with a state-mismatch error before the real xAI
redirect could arrive.

Now we only tear the listener down on outcomes that actually
terminate the dance:
  - ok callback (matched state, code present)
  - explicit ?error= from xAI (auth provider terminated; we should
    propagate, not wait for the 30 min timeout). xAI's error
    redirects may or may not echo state, but a stale tab can't
    fabricate ?error= without colluding with the auth server, so
    this branch is safe to consume.

Stale tabs / browser prefetches / malformed redirects still get the
HTTP 400 / "Sign-in failed" page, but the listener stays open and
the matching xAI redirect that arrives next is what closes it.

Tests: replaces the previous "rejects state mismatch with kind=error"
test with the recovery scenario (stale-then-real callbacks both hit
the listener; only the real one fires onCallback). Adds a sibling
case for missing-code / missing-state callbacks. xai-oauth-server
suite is now 10/10; full xai sweep 39/39.
This commit is contained in:
Joey-nexu
2026-05-19 09:30:11 +08:00
parent a6ddd0aa75
commit c00252c948
2 changed files with 86 additions and 10 deletions
+26 -1
View File
@@ -118,7 +118,6 @@ export async function startCallbackListener(
return;
}
consumed = true;
const code = parsed.searchParams.get('code') ?? '';
const state = parsed.searchParams.get('state') ?? '';
const errorParam = parsed.searchParams.get('error') ?? '';
@@ -136,10 +135,36 @@ export async function startCallbackListener(
outcome = { kind: 'ok', code, state };
}
// Decide whether this hit *consumes* the listener. A stray browser
// tab replaying an old `/callback?state=…` would otherwise close the
// listener with a state-mismatch error before the real xAI redirect
// can arrive — we share a fixed singleton port, so killing it on a
// stale request strands the in-flight authorization. Keep the
// listener open on stale/malformed requests; the real callback will
// still find it. Consume on:
// - ok callback (matched state, code present)
// - explicit ?error= from xAI (the auth provider terminated the
// dance; we should propagate, not wait until the 30 min
// timeout). xAI may or may not echo state with the error;
// a stale tab can't fabricate `?error=` without colluding
// with the auth server, so this branch is safe to consume on.
const consumesListener =
outcome.kind === 'ok' || Boolean(errorParam);
if (consumesListener) {
consumed = true;
}
res.statusCode = outcome.kind === 'ok' ? 200 : 400;
res.setHeader('content-type', 'text/html; charset=utf-8');
res.end(renderResultPage(outcome));
if (!consumesListener) {
// Stale-tab replay or malformed request — don't surface to the
// caller and don't tear down the listener. The browser sees the
// 400 page; the real flow can still complete on a later hit.
return;
}
try {
await input.onCallback(outcome);
} catch (err: unknown) {
+60 -9
View File
@@ -70,26 +70,77 @@ describe('startCallbackListener', () => {
});
});
it('rejects state mismatch with kind=error', async () => {
it('rejects state mismatch with HTTP 400 but keeps the listener open for the real callback', async () => {
// A stale browser tab replaying an old /callback?state=… would
// close the singleton :56121 listener if the mismatch tore it down,
// stranding the real xAI redirect. The listener must reject with
// 400 but stay alive until the matching state arrives.
let onCallbackCount = 0;
const outcomeRef: { current: CallbackOutcome | null } = { current: null };
listener = await startCallbackListener({
expectedState: 'state-abc',
onCallback: async (o) => {
onCallbackCount += 1;
outcomeRef.current = o;
},
port: TEST_PORT,
});
const res = await fetchCallback(listener.address, {
code: 'whatever',
// 1. Stale tab fires a mismatched state.
const stale = await fetchCallback(listener.address, {
code: 'old',
state: 'state-mismatch',
});
expect(res.status).toBe(400);
expect(res.body).toContain('Sign-in failed');
expect(stale.status).toBe(400);
expect(stale.body).toContain('Sign-in failed');
await new Promise((r) => setTimeout(r, 30));
expect(outcomeRef.current?.kind).toBe('error');
if (outcomeRef.current?.kind === 'error') {
expect(outcomeRef.current.error).toMatch(/state mismatch/);
}
// Listener should NOT have surfaced anything to the caller.
expect(onCallbackCount).toBe(0);
expect(outcomeRef.current).toBeNull();
// 2. Real callback arrives next; listener must still be live.
const real = await fetchCallback(listener.address, {
code: 'real-code',
state: 'state-abc',
});
expect(real.status).toBe(200);
await new Promise((r) => setTimeout(r, 30));
expect(onCallbackCount).toBe(1);
expect(outcomeRef.current).toEqual({
kind: 'ok',
code: 'real-code',
state: 'state-abc',
});
});
it('keeps the listener open on missing-code/missing-state requests too', async () => {
let onCallbackCount = 0;
const outcomeRef: { current: CallbackOutcome | null } = { current: null };
listener = await startCallbackListener({
expectedState: 'state-real',
onCallback: async (o) => {
onCallbackCount += 1;
outcomeRef.current = o;
},
port: TEST_PORT,
});
// /callback with neither code nor state — e.g. browser prefetch or
// a malformed redirect. Must not consume the listener.
const empty = await fetchCallback(listener.address, {});
expect(empty.status).toBe(400);
await new Promise((r) => setTimeout(r, 30));
expect(onCallbackCount).toBe(0);
// Real callback completes normally afterwards.
const real = await fetchCallback(listener.address, {
code: 'c',
state: 'state-real',
});
expect(real.status).toBe(200);
await new Promise((r) => setTimeout(r, 30));
expect(onCallbackCount).toBe(1);
expect(outcomeRef.current?.kind).toBe('ok');
});
it('surfaces an explicit ?error= param', async () => {