From 48d30d11d2be8c9a1530638647d4c1316426b56f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?deniz=20g=C3=B6k=C3=A7in?= <33603535+dgokcin@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:11:05 +0200 Subject: [PATCH] fix(oauth): report ignored callbacks in sign-in timeout - track ignored state mismatches in callback server - include count in timeout error message - helps users understand they completed a stale browser tab - add test for stale callback timeout scenario --- src/oauth/callback-server.ts | 9 ++++++++- tests/oauth-openai.test.ts | 21 ++++++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/oauth/callback-server.ts b/src/oauth/callback-server.ts index fc635895..3bccea7d 100644 --- a/src/oauth/callback-server.ts +++ b/src/oauth/callback-server.ts @@ -60,6 +60,7 @@ export async function startCallbackServer(options: CallbackServerOptions): Promi let codeReject: ((e: Error) => void) | undefined; // The browser can hit the callback before waitForCallback arms the promise. let buffered: CallbackParams | undefined; + let ignoredStateMismatchCount = 0; const { path, redirectHost } = options; const server = http.createServer((req, res) => { @@ -71,6 +72,7 @@ export async function startCallbackServer(options: CallbackServerOptions): Promi const state = u.searchParams.get('state') ?? ''; const error = u.searchParams.get('error') ?? ''; if (options.expectedState !== undefined && state !== options.expectedState) { + ignoredStateMismatchCount++; res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('Invalid OAuth state'); return; @@ -116,7 +118,12 @@ export async function startCallbackServer(options: CallbackServerOptions): Promi return; } const timer = setTimeout( - () => reject(new Error('OAuth timeout — browser closed without completing sign-in')), + () => reject(new Error( + ignoredStateMismatchCount > 0 + ? `OAuth timeout — ignored ${ignoredStateMismatchCount} callback(s) carrying a different sign-in state; ` + + 'you probably completed an older browser tab. Run the command again and use the newest tab.' + : 'OAuth timeout — browser closed without completing sign-in', + )), timeoutMs, ); codeResolve = params => { clearTimeout(timer); resolve(params); }; diff --git a/tests/oauth-openai.test.ts b/tests/oauth-openai.test.ts index 7d0d15f0..02cc94f7 100644 --- a/tests/oauth-openai.test.ts +++ b/tests/oauth-openai.test.ts @@ -168,6 +168,25 @@ describe('oauth/openai', () => { expect(body.get('code')).toBe('auth_code_1'); }); + it('reports ignored stale callbacks when sign-in times out', async () => { + let resolveCallback!: (status: number) => void; + let rejectCallback!: (error: unknown) => void; + const callbackResponse = new Promise((resolve, reject) => { + resolveCallback = resolve; + rejectCallback = reject; + }); + const flow = runOpenAiBrowserFlow(({ url }) => { + void hitCallback(url, () => 'code=stale&state=stale').then(resolveCallback, rejectCallback); + }, { ports: [0], timeoutMs: 50 }); + + expect(await callbackResponse).toBe(400); + await expect(flow).rejects.toThrow( + 'OAuth timeout — ignored 1 callback(s) carrying a different sign-in state; ' + + 'you probably completed an older browser tab. Run the command again and use the newest tab.', + ); + expect(global.fetch).not.toHaveBeenCalled(); + }); + it('rejects when the provider returns an error instead of a code', async () => { await expect(runOpenAiBrowserFlow(({ url }) => { void hitCallback(url, state => `error=access_denied&state=${encodeURIComponent(state)}`); @@ -266,7 +285,7 @@ describe('oauth/openai', () => { it('times out when the browser never completes sign-in', async () => { await expect(runOpenAiBrowserFlow(vi.fn(), { ports: [0], timeoutMs: 50 })) - .rejects.toThrow(/OAuth timeout/); + .rejects.toThrow('OAuth timeout — browser closed without completing sign-in'); expect(global.fetch).not.toHaveBeenCalled(); });