mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
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:
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user