fix(mcp): let the consent screen return to the connecting client

After Allow on the MCP OAuth consent screen, Chromium-based browsers
stayed on the page although the grant was issued. The page carried the
app-wide CSP form-action 'self', and Chromium applies form-action to the
redirect that follows the form post to the client's redirect_uri.

The authorize route now owns the CSP of its responses and the proxy
leaves that exact path alone. The consent page allows form-action for
its own origin plus the origin of the redirect URI the route has just
validated against the resolved client, and only https or http origins
qualify. Every other response from the route keeps form-action 'self'
with default-src 'none'. The redirect after the decision is now 303,
which tells every browser to follow a form POST with a GET. Refs #983
This commit is contained in:
Marc-André Bombeck committed 2026-09-15 14:06:27 +02:00
1 parent 7b7a77b292
commit 864566d882
6 files changed
+309 -10

No files matched your search

@@ -270,7 +270,7 @@ describe("POST /authorize — decision", () => {
expect(res.status).toBe(401);
});
it("mints a code and 302s back on allow", async () => {
it("mints a code and 303s back on allow (a form POST is followed with GET)", async () => {
signedIn();
const res = await POST(
postReq({
@@ -285,7 +285,7 @@ describe("POST /authorize — decision", () => {
decision: "allow",
}) as never,
);
expect(res.status).toBe(302);
expect(res.status).toBe(303);
const loc = res.headers.get("location") ?? "";
expect(loc.startsWith(REDIRECT)).toBe(true);
const code = new URL(loc).searchParams.get("code");
@@ -295,7 +295,7 @@ describe("POST /authorize — decision", () => {
expect(new URL(loc).searchParams.get("iss")).toBe("https://health.example");
});
it("302s with access_denied on deny", async () => {
it("303s with access_denied on deny", async () => {
signedIn();
const res = await POST(
postReq({
@@ -309,10 +309,85 @@ describe("POST /authorize — decision", () => {
decision: "deny",
}) as never,
);
expect(res.status).toBe(302);
expect(res.status).toBe(303);
const loc = res.headers.get("location") ?? "";
expect(new URL(loc).searchParams.get("error")).toBe("access_denied");
// RFC 9207 — the issuer is echoed on the error redirect too.
expect(new URL(loc).searchParams.get("iss")).toBe("https://health.example");
});
});
describe("authorize CSP — the consent form may lead only to the validated redirect", () => {
function csp(res: Response): string {
return res.headers.get("content-security-policy") ?? "";
}
it("allows the registered client's redirect origin on the consent page", async () => {
signedIn();
const res = await GET(getReq(authorizeUrl()) as never);
expect(res.status).toBe(200);
expect(csp(res)).toContain("form-action 'self' https://claude.ai;");
expect(csp(res)).toContain("default-src 'none'");
expect(csp(res)).toContain("frame-ancestors 'none'");
});
it("uses the requested loopback port the client is allowed to vary", async () => {
const loopback = registerDcrClient({
clientName: "Desktop client",
redirectUris: ["http://127.0.0.1:4000/callback"],
});
signedIn();
const res = await GET(
getReq(
authorizeUrl({
client_id: loopback.clientId,
redirect_uri: "http://127.0.0.1:53682/callback",
}),
) as never,
);
expect(res.status).toBe(200);
expect(csp(res)).toContain("form-action 'self' http://127.0.0.1:53682;");
});
it("never names a redirect origin that failed validation", async () => {
signedIn();
const res = await GET(
getReq(
authorizeUrl({ redirect_uri: "https://evil.example/cb" }),
) as never,
);
expect(res.status).toBe(400);
expect(csp(res)).toContain("form-action 'self';");
expect(csp(res)).not.toContain("evil.example");
});
it("keeps form-action to 'self' on the sign-in prompt", async () => {
const res = await GET(getReq(authorizeUrl()) as never);
expect(res.status).toBe(200);
expect(csp(res)).toContain("form-action 'self';");
expect(csp(res)).not.toContain("claude.ai");
});
it("sets the policy on the decision redirect and on refusals", async () => {
signedIn();
const allow = await POST(
postReq({
response_type: "code",
client_id: CLIENT.clientId,
redirect_uri: REDIRECT,
code_challenge: CHALLENGE,
code_challenge_method: "S256",
scope: "health:read",
resource: RESOURCE,
decision: "allow",
}) as never,
);
expect(allow.status).toBe(303);
expect(csp(allow)).toContain("form-action 'self';");
vi.mocked(isApiGloballyEnabled).mockResolvedValue(false);
const unavailable = await GET(getReq(authorizeUrl()) as never);
expect(unavailable.status).toBe(503);
expect(csp(unavailable)).toContain("default-src 'none'");
});
});
+38 -5
View File
@@ -13,7 +13,7 @@
* POST — the consent decision. Requires a session, re-validates every
* parameter, and on "Allow" mints a single-use, short-lived,
* self-describing authorization code bound to this user + client +
* redirect URI + PKCE challenge + scope + audience, then 302s back.
* redirect URI + PKCE challenge + scope + audience, then 303s back.
*
* Mandatory gates, every request: PKCE `S256` (a missing / non-S256 challenge is
* rejected), audience binding (`resource` MUST equal the canonical `/mcp` URI —
@@ -43,6 +43,7 @@ import {
import { redirectUriAllowed, resolveClient } from "@/lib/mcp/oauth/clients";
import { isValidChallenge } from "@/lib/mcp/oauth/pkce";
import { signArtifact } from "@/lib/mcp/oauth/artifacts";
import { withAuthorizeCsp } from "@/lib/mcp/oauth/consent-csp";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -269,13 +270,21 @@ function consentOriginAllowed(request: NextRequest): boolean {
return true;
}
/**
* Send the browser back to the validated redirect URI. 303, not 302: the
* decision arrives as a form POST, and 303 is the status that tells every user
* agent to follow with a GET (RFC 9700, section 4.12).
*/
function redirectBack(
redirectUri: string,
query: Record<string, string>,
): Response {
const url = new URL(redirectUri);
for (const [k, v] of Object.entries(query)) url.searchParams.set(k, v);
return Response.redirect(url.toString(), 302);
return new Response(null, {
status: 303,
headers: { Location: url.toString() },
});
}
/** Whether the surface is available (M1 origin pinned + M4 kill-switch off). */
@@ -283,7 +292,26 @@ async function surfaceAvailable(): Promise<boolean> {
return isMcpOriginConfigured() && (await isApiGloballyEnabled());
}
/**
* Every response from this route carries the route's own CSP; the proxy leaves
* this path alone (see `consent-csp.ts`). A response that already set one, the
* consent page with its validated redirect origin, keeps it.
*/
function ensureAuthorizeCsp(response: Response): Response {
return response.headers.has("content-security-policy")
? response
: withAuthorizeCsp(response);
}
export async function GET(request: NextRequest): Promise<Response> {
return ensureAuthorizeCsp(await authorizeGet(request));
}
export async function POST(request: NextRequest): Promise<Response> {
return ensureAuthorizeCsp(await authorizePost(request));
}
async function authorizeGet(request: NextRequest): Promise<Response> {
return withBackgroundEvent("mcp.oauth.authorize", async () => {
if (!(await surfaceAvailable())) {
return Response.json(
@@ -347,8 +375,11 @@ export async function GET(request: NextRequest): Promise<Response> {
const accessSummary = writeGranted
? `<p><strong>This grants read AND write access:</strong> the application will be able to read your own health records <strong>and log new measurements and mood entries</strong> to your account on your behalf. It cannot delete or change existing entries, edit medications, or reach admin functions.</p>`
: `<p>Scope: <code>${htmlEscape(v.scope)}</code> — read-only access to your own health records.</p>`;
return html(
`<main>
// The one response whose form may lead off-origin: allow exactly the
// redirect URI `validate()` just matched against the resolved client.
return withAuthorizeCsp(
html(
`<main>
<h1>Authorize access</h1>
<p><strong>${htmlEscape(v.clientName)}</strong> is requesting access to your HealthLog data.</p>
${provenance}
@@ -367,11 +398,13 @@ export async function GET(request: NextRequest): Promise<Response> {
<button type="submit" name="decision" value="deny">Deny</button>
</form>
</main>`,
),
v.redirectUri,
);
});
}
export async function POST(request: NextRequest): Promise<Response> {
async function authorizePost(request: NextRequest): Promise<Response> {
return withBackgroundEvent("mcp.oauth.authorize", async () => {
if (!(await surfaceAvailable())) {
return Response.json(