Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/oauth/callback-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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;
Expand Down Expand Up @@ -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); };
Expand Down
21 changes: 20 additions & 1 deletion tests/oauth-openai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>((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)}`);
Expand Down Expand Up @@ -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();
});

Expand Down
Loading