diff --git a/extensions/openrouter/oauth.test.ts b/extensions/openrouter/oauth.test.ts index 829a042d2f86..ac5789e3cd15 100644 --- a/extensions/openrouter/oauth.test.ts +++ b/extensions/openrouter/oauth.test.ts @@ -138,6 +138,27 @@ function createOpenRouterOAuthContext(params: { return { ctx, progress, note, text, log, openUrl }; } +async function requestLocalOpenRouterOAuthCallback( + query: string, +): Promise<{ callback: Promise; response: Response; body: string }> { + let markReady = () => {}; + const ready = new Promise((resolve) => { + markReady = resolve; + }); + const callback = waitForOpenRouterOAuthCallback({ + expectedState: "state-1", + timeoutMs: 1000, + onProgress: markReady, + }); + callback.catch(() => undefined); + await ready; + + const response = await fetch(`${OPENROUTER_OAUTH_REDIRECT_URI}?${query}`, { + headers: { Connection: "close" }, + }); + return { callback, response, body: await response.text() }; +} + describe("OpenRouter OAuth", () => { it("builds the documented PKCE authorize URL", () => { const url = new URL( @@ -167,6 +188,30 @@ describe("OpenRouter OAuth", () => { code: "AUTHCODE", state: "state-1", }); + expect(() => + parseOpenRouterOAuthCallbackInput( + `${OPENROUTER_OAUTH_REDIRECT_URI}?state=state-1&error=access_denied&error_description=Denied`, + "state-1", + ), + ).toThrow("OpenRouter OAuth error: access_denied: Denied"); + expect(() => + parseOpenRouterOAuthCallbackInput( + "state=state-1&error=access_denied&error_description=Denied", + "state-1", + ), + ).toThrow("OpenRouter OAuth error: access_denied: Denied"); + expect(() => + parseOpenRouterOAuthCallbackInput( + `${OPENROUTER_OAUTH_REDIRECT_URI}?error=access_denied&error_description=Denied`, + "state-1", + ), + ).toThrow("Missing OpenRouter OAuth state"); + expect(() => + parseOpenRouterOAuthCallbackInput( + `${OPENROUTER_OAUTH_REDIRECT_URI}?state=wrong&error=access_denied&error_description=Denied`, + "state-1", + ), + ).toThrow("OpenRouter OAuth state mismatch"); expect(buildOpenRouterOAuthRedirectUri({ state: "state-1" })).toBe( `${OPENROUTER_OAUTH_REDIRECT_URI}?state=state-1`, ); @@ -348,6 +393,29 @@ describe("OpenRouter OAuth", () => { expect(text).not.toHaveBeenCalled(); }); + it("validates local callback state before surfacing OpenRouter OAuth errors", async () => { + const denied = await requestLocalOpenRouterOAuthCallback( + "state=state-1&error=access_denied&error_description=Denied", + ); + expect(denied.response.status).toBe(400); + expect(denied.body).toBe("OpenRouter authentication failed: access_denied: Denied"); + await expect(denied.callback).rejects.toThrow("OpenRouter OAuth error: access_denied: Denied"); + + const missingState = await requestLocalOpenRouterOAuthCallback( + "error=access_denied&error_description=Denied", + ); + expect(missingState.response.status).toBe(400); + expect(missingState.body).toBe("Invalid OAuth state"); + await expect(missingState.callback).rejects.toThrow("Missing OpenRouter OAuth state"); + + const wrongState = await requestLocalOpenRouterOAuthCallback( + "state=wrong&error=access_denied&error_description=Denied", + ); + expect(wrongState.response.status).toBe(400); + expect(wrongState.body).toBe("Invalid OAuth state"); + await expect(wrongState.callback).rejects.toThrow("OpenRouter OAuth state mismatch"); + }); + it("exposes stable auth choice metadata", () => { expect(OPENROUTER_OAUTH_CHOICE_ID).toBe("openrouter-oauth"); }); diff --git a/extensions/openrouter/oauth.ts b/extensions/openrouter/oauth.ts index 66462a543e34..2efcea06f2d8 100644 --- a/extensions/openrouter/oauth.ts +++ b/extensions/openrouter/oauth.ts @@ -143,11 +143,18 @@ export function parseOpenRouterOAuthCallbackInput( } const parseParams = (params: URLSearchParams): OpenRouterOAuthCallbackResult => { + const state = requireOpenRouterOAuthState(readString(params.get("state")), expectedState); + const error = readString(params.get("error")); + if (error) { + const description = readString(params.get("error_description")); + throw new Error( + `OpenRouter OAuth error: ${description ? `${error}: ${description}` : error}`, + ); + } const code = readString(params.get("code")); if (!code) { throw new Error("Missing 'code' parameter in redirect URL."); } - const state = requireOpenRouterOAuthState(readString(params.get("state")), expectedState); return { code, state }; }; @@ -156,7 +163,7 @@ export function parseOpenRouterOAuthCallbackInput( return parseParams(url.searchParams); } catch (err) { if (err instanceof TypeError) { - if (trimmed.includes("code=")) { + if (trimmed.includes("code=") || trimmed.includes("error=")) { return parseParams(new URLSearchParams(trimmed)); } throw new Error("Paste the full OpenRouter redirect URL, not just the code.", { @@ -231,12 +238,25 @@ export async function waitForOpenRouterOAuthCallback(params: { return; } - const error = readString(requestUrl.searchParams.get("error")); - if (error) { + const state = readString(requestUrl.searchParams.get("state")); + try { + requireOpenRouterOAuthState(state, params.expectedState); + } catch (err) { res.statusCode = 400; res.setHeader("Content-Type", "text/plain"); - res.end(`OpenRouter authentication failed: ${error}`); - finish(new Error(`OpenRouter OAuth error: ${error}`)); + res.end("Invalid OAuth state"); + finish(err instanceof Error ? err : new Error("OpenRouter OAuth state mismatch")); + return; + } + + const error = readString(requestUrl.searchParams.get("error")); + if (error) { + const description = readString(requestUrl.searchParams.get("error_description")); + const detail = description ? `${error}: ${description}` : error; + res.statusCode = 400; + res.setHeader("Content-Type", "text/plain"); + res.end(`OpenRouter authentication failed: ${detail}`); + finish(new Error(`OpenRouter OAuth error: ${detail}`)); return; } @@ -248,16 +268,6 @@ export async function waitForOpenRouterOAuthCallback(params: { finish(new Error("Missing OpenRouter OAuth code")); return; } - const state = readString(requestUrl.searchParams.get("state")); - try { - requireOpenRouterOAuthState(state, params.expectedState); - } catch (err) { - res.statusCode = 400; - res.setHeader("Content-Type", "text/plain"); - res.end("Invalid OAuth state"); - finish(err instanceof Error ? err : new Error("OpenRouter OAuth state mismatch")); - return; - } res.statusCode = 200; res.setHeader("Content-Type", "text/html; charset=utf-8");