From 60350f00415cc95c6175cdc4b02e26d67d73f90c Mon Sep 17 00:00:00 2001 From: ly-wang19 <94427531+ly-wang19@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:43:53 +0800 Subject: [PATCH] fix(openrouter): use the loopback's bound port in the OAuth callback_url (#3904) Co-authored-by: ly-wang19 --- .../utils/__tests__/openrouterOAuth.test.ts | 35 ++++++++++++++++++- app/src/utils/openrouterOAuth.ts | 7 +++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/app/src/utils/__tests__/openrouterOAuth.test.ts b/app/src/utils/__tests__/openrouterOAuth.test.ts index 47bf4b154..7624b18fb 100644 --- a/app/src/utils/__tests__/openrouterOAuth.test.ts +++ b/app/src/utils/__tests__/openrouterOAuth.test.ts @@ -31,8 +31,10 @@ describe('connectOpenRouterViaOAuth', () => { expect(openExternalUrl).toHaveBeenCalledTimes(1); const authUrl = new URL(openExternalUrl.mock.calls[0][0]); expect(authUrl.origin + authUrl.pathname).toBe('https://openrouter.ai/auth'); + // callback_url must use the port the listener actually bound to (carried in + // redirectUri), not the requested OPENROUTER_LOOPBACK_PORT constant. expect(authUrl.searchParams.get('callback_url')).toBe( - 'http://localhost:3000/auth?state=expected-state' + 'http://localhost:53824/auth?state=expected-state' ); expect(authUrl.searchParams.get('code_challenge_method')).toBe('S256'); expect(authUrl.searchParams.get('code_challenge')).toBeTruthy(); @@ -43,6 +45,37 @@ describe('connectOpenRouterViaOAuth', () => { expect(cancel).toHaveBeenCalledTimes(1); }); + it('uses the listener bound port for callback_url when the requested port was busy', async () => { + // Port 3000 busy -> the Tauri listener falls back to an OS-assigned port and + // returns it in redirectUri. callback_url must point at that bound port, or + // OpenRouter redirects to a port nothing is listening on and OAuth fails. + const openExternalUrl = vi.fn().mockResolvedValue(undefined); + const startLoopbackListener = vi + .fn() + .mockResolvedValue({ + redirectUri: 'http://127.0.0.1:54321/auth?state=expected-state', + state: 'expected-state', + awaitCallback: vi + .fn() + .mockResolvedValue('http://127.0.0.1:54321/auth?state=expected-state&code=abc123'), + cancel: vi.fn().mockResolvedValue(undefined), + }); + const fetchImpl = vi + .fn() + .mockResolvedValue({ ok: true, json: async () => ({ key: 'sk-or-via-oauth' }) }); + + await connectOpenRouterViaOAuth({ + startLoopbackListener, + openExternalUrl, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + const authUrl = new URL(openExternalUrl.mock.calls[0][0]); + expect(authUrl.searchParams.get('callback_url')).toBe( + 'http://localhost:54321/auth?state=expected-state' + ); + }); + it('rejects when the loopback listener is unavailable', async () => { await expect( connectOpenRouterViaOAuth({ startLoopbackListener: vi.fn().mockResolvedValue(null) }) diff --git a/app/src/utils/openrouterOAuth.ts b/app/src/utils/openrouterOAuth.ts index a82116de9..546a633c9 100644 --- a/app/src/utils/openrouterOAuth.ts +++ b/app/src/utils/openrouterOAuth.ts @@ -105,8 +105,13 @@ function toOpenRouterCallbackUrl(redirectUri: string): string { throw new Error('OpenRouter OAuth listener returned an invalid redirect URL.'); } + // Preserve the port the loopback listener actually bound to (carried in + // redirectUri): when the requested port is busy, the Tauri command falls back + // to an OS-assigned ephemeral port, so hardcoding OPENROUTER_LOOPBACK_PORT here + // sent OpenRouter a callback_url pointing at the wrong port. The PKCE + // callback_url is per-request, so the dynamic port is valid (this matches the + // sibling OAuthProviderButton flow, which trusts the bound port). parsed.hostname = 'localhost'; - parsed.port = String(OPENROUTER_LOOPBACK_PORT); return parsed.toString(); }