fix(mcp): connecting an assistant asks for a recent sign-in or re-proof

Allowing an AI assistant hands it a standing credential to the record
that outlives the session, the same class of act as minting a token, but
the consent took a live session alone. A stolen session could connect
its own assistant and keep reading after the owner signed everything out.

The consent page now shows the Allow form only when the session signed in
or re-proved within five minutes (with the second factor on an account
that has one). Otherwise it links to a new confirm page that runs the
same re-proof dialog as the rest of the app and returns to the exact
consent request. The allow decision checks again and mints nothing
without it; deny needs no proof. The confirm page only ever returns to
the consent endpoint.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 22:38:40 +02:00
1 parent a294d7f684
commit 1900792756
14 files changed
+358 -3

No files matched your search

+7
View File
@@ -471,6 +471,13 @@
"title": "Zweiten Faktor einrichten",
"body": "Dein Administrator verlangt einen zweiten Faktor für dieses Konto. Füge eine Authenticator-App oder einen Sicherheitsschlüssel hinzu, um fortzufahren.",
"signOut": "Abmelden"
},
"confirmAccess": {
"title": "Bestätige, dass du es bist",
"body": "Ein Assistent möchte sich mit deinen HealthLog-Daten verbinden. Bestätige, dass du es bist; danach entscheidest du, ob du es erlaubst.",
"action": "Bestätigen",
"failed": "Das hat nicht geklappt. Bitte versuche es noch einmal.",
"nothingToConfirm": "Hier gibt es nichts zu bestätigen. Starte die Verbindung noch einmal in deinem Assistenten."
}
},
"dashboard": {
+7
View File
@@ -471,6 +471,13 @@
"title": "Set up a second factor",
"body": "Your administrator requires a second factor on this account. Add an authenticator app or a security key to continue.",
"signOut": "Sign out"
},
"confirmAccess": {
"title": "Confirm it is you",
"body": "An assistant is asking to connect to your HealthLog data. Confirm it is you, then you will be taken back to decide whether to allow it.",
"action": "Confirm",
"failed": "That did not work. Please try again.",
"nothingToConfirm": "There is nothing to confirm here. Start the connection again from your assistant."
}
},
"dashboard": {
+7
View File
@@ -471,6 +471,13 @@
"title": "Configurar un segundo factor",
"body": "Tu administrador exige un segundo factor en esta cuenta. Añade una app de autenticación o una llave de seguridad para continuar.",
"signOut": "Cerrar sesión"
},
"confirmAccess": {
"title": "Confirma que eres tú",
"body": "Un asistente quiere conectarse a tus datos de HealthLog. Confirma que eres tú y después decidirás si lo permites.",
"action": "Confirmar",
"failed": "No ha funcionado. Inténtalo de nuevo.",
"nothingToConfirm": "Aquí no hay nada que confirmar. Vuelve a iniciar la conexión desde tu asistente."
}
},
"dashboard": {
+7
View File
@@ -471,6 +471,13 @@
"title": "Configurer un second facteur",
"body": "Votre administrateur exige un second facteur sur ce compte. Ajoutez une application d'authentification ou une clé de sécurité pour continuer.",
"signOut": "Se déconnecter"
},
"confirmAccess": {
"title": "Confirmez que c'est bien vous",
"body": "Un assistant demande à se connecter à vos données HealthLog. Confirmez que c'est bien vous, puis vous déciderez si vous l'autorisez.",
"action": "Confirmer",
"failed": "Cela n'a pas fonctionné. Veuillez réessayer.",
"nothingToConfirm": "Il n'y a rien à confirmer ici. Relancez la connexion depuis votre assistant."
}
},
"dashboard": {
+7
View File
@@ -471,6 +471,13 @@
"title": "Configura un secondo fattore",
"body": "Il tuo amministratore richiede un secondo fattore su questo account. Aggiungi un'app di autenticazione o una chiave di sicurezza per continuare.",
"signOut": "Esci"
},
"confirmAccess": {
"title": "Conferma che sei tu",
"body": "Un assistente chiede di collegarsi ai tuoi dati HealthLog. Conferma che sei tu, poi deciderai se consentirlo.",
"action": "Conferma",
"failed": "Non ha funzionato. Riprova.",
"nothingToConfirm": "Qui non c'è nulla da confermare. Avvia di nuovo la connessione dal tuo assistente."
}
},
"dashboard": {
+7
View File
@@ -471,6 +471,13 @@
"title": "2차 인증 수단 설정",
"body": "관리자가 이 계정에 2차 인증 수단을 요구해요. 인증 앱이나 보안 키를 추가하고 계속해 주세요.",
"signOut": "로그아웃"
},
"confirmAccess": {
"title": "본인인지 확인해 주세요",
"body": "어시스턴트가 HealthLog 데이터에 연결하려고 해요. 본인인지 확인하면 허용할지 정하는 화면으로 돌아가요.",
"action": "확인",
"failed": "확인하지 못했어요. 다시 시도해 주세요.",
"nothingToConfirm": "여기에서 확인할 것이 없어요. 어시스턴트에서 연결을 다시 시작해 주세요."
}
},
"dashboard": {
+7
View File
@@ -471,6 +471,13 @@
"title": "Skonfiguruj drugi składnik",
"body": "Administrator wymaga drugiego składnika na tym koncie. Dodaj aplikację uwierzytelniającą lub klucz bezpieczeństwa, aby kontynuować.",
"signOut": "Wyloguj się"
},
"confirmAccess": {
"title": "Potwierdź, że to Ty",
"body": "Asystent chce połączyć się z Twoimi danymi w HealthLog. Potwierdź, że to Ty, a potem zdecydujesz, czy na to pozwolić.",
"action": "Potwierdź",
"failed": "Nie udało się. Spróbuj ponownie.",
"nothingToConfirm": "Nie ma tu nic do potwierdzenia. Rozpocznij połączenie ponownie w swoim asystencie."
}
},
"dashboard": {
@@ -97,6 +97,13 @@ const TOKEN_ARM_COOKIE_PROOF_ROUTES = [
"app/api/fhir/Patient/$everything/route.ts",
].sort();
/**
* The connection consent. It resolves the session through `getSession`
* (OAuth answers in its own error format, not the envelope) and asks for the
* proof on the consent page and again on "allow".
*/
const CONSENT_PROOF_ROUTES = ["app/api/mcp/oauth/authorize/route.ts"];
/** Cookie-only routes that add the gate after their own resolver. */
const COOKIE_PROOF_ROUTES = [
"app/api/admin/backups/[id]/download/route.ts",
@@ -131,7 +138,11 @@ describe("the recent-proof gate", () => {
const found = callers("assertRecentCookieProof");
expect(found.length).toBeGreaterThan(0);
expect(found).toEqual(
[...COOKIE_PROOF_ROUTES, ...TOKEN_ARM_COOKIE_PROOF_ROUTES].sort(),
[
...COOKIE_PROOF_ROUTES,
...TOKEN_ARM_COOKIE_PROOF_ROUTES,
...CONSENT_PROOF_ROUTES,
].sort(),
);
});
@@ -1,7 +1,7 @@
/**
* Authorize endpoint — consent gate + audience + PKCE + redirect-URI binding.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
process.env.API_TOKEN_HMAC_KEY = "x".repeat(48);
process.env.APP_URL = "https://health.example";
@@ -21,6 +21,22 @@ vi.mock("@/lib/rate-limit", () => ({
})),
rateLimitHeaders: vi.fn(() => ({})),
}));
// The consent reads the session's own proof stamps. By default the session
// signed in a moment ago, which is a recent proof on an account without a
// second factor; the cases below age it.
vi.mock("@/lib/db", () => ({
prisma: {
session: {
findUnique: vi.fn(async () => ({
createdAt: new Date(),
mfaVerifiedAt: null,
reproofAt: null,
})),
},
webauthnMfaCredential: { count: vi.fn(async () => 0) },
passkey: { count: vi.fn(async () => 0) },
},
}));
vi.mock("@/lib/app-settings", () => ({
isApiGloballyEnabled: vi.fn(async () => true),
}));
@@ -30,6 +46,7 @@ import { getSession } from "@/lib/auth/session";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { registerDcrClient } from "@/lib/mcp/oauth/clients";
import { s256Challenge } from "@/lib/mcp/oauth/pkce";
import { prisma } from "@/lib/db";
const REDIRECT = "https://claude.ai/api/mcp/auth_callback";
const VERIFIER = "a".repeat(64);
@@ -327,6 +344,82 @@ describe("POST /authorize — decision", () => {
});
});
describe("authorize — allowing a connection needs a recent proof", () => {
// A queued stale stamp a case did not read must not leak into the next one.
afterEach(() => {
vi.mocked(prisma.session.findUnique).mockReset();
});
function staleSession() {
vi.mocked(prisma.session.findUnique).mockResolvedValueOnce({
createdAt: new Date(Date.now() - 60 * 60 * 1000),
mfaVerifiedAt: null,
reproofAt: null,
} as never);
}
const allow = {
response_type: "code",
client_id: CLIENT.clientId,
redirect_uri: REDIRECT,
code_challenge: CHALLENGE,
code_challenge_method: "S256",
scope: "health:read",
state: "xyz",
resource: RESOURCE,
decision: "allow",
};
it("GET shows a confirm link instead of the form when the session is an hour old", async () => {
signedIn();
staleSession();
const res = await GET(getReq(authorizeUrl()) as never);
expect(res.status).toBe(200);
const body = await res.text();
expect(body).toMatch(/Confirm it is you/);
expect(body).not.toMatch(/name="decision"/);
const href = body.match(/href="([^"]+)"/)?.[1] ?? "";
const next =
new URL(href.replace(/&/g, "&"), "https://x").searchParams.get(
"next",
) ?? "";
expect(href.startsWith("/confirm-access?next=")).toBe(true);
// Back to this exact consent request.
expect(next).toBe(
new URL(authorizeUrl()).pathname + new URL(authorizeUrl()).search,
);
});
it("POST allow on an hour-old session mints nothing", async () => {
signedIn();
staleSession();
const res = await POST(postReq(allow) as never);
expect(res.status).toBe(401);
expect(res.headers.get("location")).toBeNull();
expect(await res.text()).toMatch(/confirm-access\?next=/);
});
it("an account with a second factor needs the factor, not a young session", async () => {
vi.mocked(getSession).mockResolvedValue({
session: {
id: "s1",
expiresAt: new Date(Date.now() + 1e6),
actingAsUserId: null,
recordEpoch: 0,
},
user: { id: "user-1", totpConfirmedAt: new Date("2026-01-01") } as never,
});
const res = await POST(postReq(allow) as never);
expect(res.status).toBe(401);
});
it("deny needs no proof", async () => {
signedIn();
staleSession();
const res = await POST(postReq({ ...allow, decision: "deny" }) as never);
expect(res.status).toBe(303);
});
});
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") ?? "";
+63 -1
View File
@@ -11,7 +11,9 @@
* a GET (confused-deputy mitigation: a third party cannot silently
* obtain a grant — an authenticated human must click "Allow").
* POST — the consent decision. Requires a session, re-validates every
* parameter, and on "Allow" mints a single-use, short-lived,
* parameter, and on "Allow" asks for a recent sign-in or re-proof
* (the GET shows a confirm link instead of the form without one),
* then mints a single-use, short-lived,
* self-describing authorization code bound to this user + client +
* redirect URI + PKCE challenge + scope + audience, then 303s back.
*
@@ -30,6 +32,8 @@ import { annotate } from "@/lib/logging/context";
import { withBackgroundEvent } from "@/lib/logging/background";
import { auditLog } from "@/lib/auth/audit";
import { getSession } from "@/lib/auth/session";
import { assertRecentCookieProof } from "@/lib/api-handler";
import { StepUpRequiredError } from "@/lib/api-errors";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import {
@@ -88,6 +92,42 @@ function html(body: string, status = 200): Response {
);
}
/**
* Has this session signed in or re-proved recently enough to grant access?
*
* Allowing a connection hands a standing credential to the record to a third
* party, one that outlives the session: the same class of act as minting a
* token or making a share link, and it takes the same gate
* (`assertRecentCookieProof`: a sign-in or re-proof within five minutes, with
* the second factor on an account that has one). Without it, whoever held a
* stolen session could connect their own assistant and keep reading the
* record after the owner signed everything out.
*/
async function hasRecentProof(
session: NonNullable<Awaited<ReturnType<typeof getSession>>>,
): Promise<boolean> {
try {
await assertRecentCookieProof(session.user, session.session.id);
return true;
} catch (err) {
if (err instanceof StepUpRequiredError) return false;
throw err;
}
}
/**
* The page shown instead of the consent form while the session carries no
* recent proof. It links to the confirm page, which runs the same re-proof
* dialog as the rest of the app and then comes back to this exact request.
*/
function reproofPage(clientName: string, returnTo: string, status = 200) {
const confirmHref = `/confirm-access?next=${encodeURIComponent(returnTo)}`;
return html(
`<main><h1>Confirm it is you</h1><p>Before <strong>${htmlEscape(clientName)}</strong> can connect to your HealthLog data, confirm it is you. This is asked when you have not signed in or confirmed in the last five minutes.</p><p><a href="${htmlEscape(confirmHref)}">Confirm and continue</a></p></main>`,
status,
);
}
/**
* The scopes this surface grants for a request. `health:read` is always
* granted (the floor). `health:write` is granted ONLY when explicitly
@@ -359,6 +399,11 @@ async function authorizeGet(request: NextRequest): Promise<Response> {
);
}
if (!(await hasRecentProof(session))) {
annotate({ action: { name: "mcp.oauth.authorize.reproof_required" } });
return reproofPage(v.clientName, url.pathname + url.search);
}
annotate({ action: { name: "mcp.oauth.authorize.consent_shown" } });
// Consent screen — a plain POST form (no inline script/style, CSP-safe).
const hidden = (name: string, value: string) =>
@@ -499,6 +544,23 @@ async function authorizePost(request: NextRequest): Promise<Response> {
});
}
// The same gate the consent page asked for, checked again on the decision
// itself: the page may have been left open past the window, or the POST
// built by hand. Denying needs no proof, so only "allow" is gated.
if (!(await hasRecentProof(session))) {
annotate({ action: { name: "mcp.oauth.authorize.reproof_required" } });
const back = new URL("/api/mcp/oauth/authorize", request.url);
back.searchParams.set("response_type", "code");
back.searchParams.set("client_id", v.clientId);
back.searchParams.set("redirect_uri", v.redirectUri);
back.searchParams.set("code_challenge", v.codeChallenge);
back.searchParams.set("code_challenge_method", "S256");
back.searchParams.set("scope", v.scope);
back.searchParams.set("resource", v.resource);
if (v.state !== undefined) back.searchParams.set("state", v.state);
return reproofPage(v.clientName, back.pathname + back.search, 401);
}
const code = signArtifact(
"authCode",
{
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { confirmReturnTo } from "../return-to";
describe("confirmReturnTo", () => {
it("returns to the connection consent it came from", () => {
expect(
confirmReturnTo("/api/mcp/oauth/authorize?client_id=a&state=b"),
).toBe("/api/mcp/oauth/authorize?client_id=a&state=b");
});
it.each([
null,
"",
"/",
"/settings",
"https://evil.example/api/mcp/oauth/authorize?x=1",
"//evil.example/api/mcp/oauth/authorize?x=1",
"/api/mcp/oauth/authorize",
"/api/mcp/oauth/authorize/../token?x=1",
])("goes nowhere for %s", (next) => {
expect(confirmReturnTo(next)).toBeNull();
});
});
@@ -0,0 +1,69 @@
"use client";
import { useState } from "react";
import { Loader2, ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
recentProofErrorMessage,
useRecentProof,
} from "@/components/settings/security-section/use-recent-proof";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
export function ConfirmAccessClient({ returnTo }: { returnTo: string | null }) {
const { t } = useTranslations();
const recentProof = useRecentProof();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function confirm() {
if (!returnTo) return;
setBusy(true);
setError(null);
try {
// Answers 200 when the session's proof is recent, and otherwise the
// refusal the hook turns into the re-proof dialog.
await recentProof.run(() => apiGet("/api/auth/reproof"));
// A full navigation: the consent is a server page, not an app route.
window.location.assign(returnTo);
} catch (err) {
setError(recentProofErrorMessage(err, t("auth.confirmAccess.failed")));
setBusy(false);
}
}
return (
<div className="mx-auto flex w-full max-w-md flex-col gap-4 px-4 py-10">
<div className="text-foreground inline-flex items-center gap-2 text-lg font-semibold">
<ShieldCheck className="h-5 w-5" aria-hidden="true" />
{t("auth.confirmAccess.title")}
</div>
{returnTo ? (
<>
<p className="text-sm">{t("auth.confirmAccess.body")}</p>
<div>
<Button
onClick={() => void confirm()}
disabled={busy}
className="min-h-11 sm:min-h-9"
>
{busy && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("auth.confirmAccess.action")}
</Button>
</div>
</>
) : (
<p className="text-sm">{t("auth.confirmAccess.nothingToConfirm")}</p>
)}
{error && (
<p role="alert" className="text-destructive text-sm">
{error}
</p>
)}
{recentProof.dialog}
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { ConfirmAccessClient } from "./confirm-access-client";
import { confirmReturnTo } from "./return-to";
/**
* Confirm it is you before connecting an AI assistant.
*
* The connection consent (`/api/mcp/oauth/authorize`) is a plain server page
* with no script, so it cannot run the re-proof dialog itself. When the
* session has not signed in or re-proved within five minutes it links here;
* this page runs the same dialog as the rest of the app and then goes back to
* the consent request it came from.
*/
export default async function ConfirmAccessPage({
searchParams,
}: {
searchParams: Promise<{ next?: string | string[] }>;
}) {
const { next } = await searchParams;
const returnTo = confirmReturnTo(typeof next === "string" ? next : null);
return <ConfirmAccessClient returnTo={returnTo} />;
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Where the confirm page may send the browser once the person has confirmed
* it is them.
*
* Only back to the connection consent it came from, never anywhere else: the
* page takes `next` from the address bar, so any path it accepted would be a
* same-origin redirect somebody else could point. The consent request carries
* its own parameters and re-validates all of them when it is loaded again.
*/
export const CONSENT_PATH = "/api/mcp/oauth/authorize";
export function confirmReturnTo(
next: string | null | undefined,
): string | null {
if (!next || !next.startsWith(`${CONSENT_PATH}?`)) return null;
try {
const base = "http://confirm.invalid";
const resolved = new URL(next, base);
if (resolved.origin !== base || resolved.pathname !== CONSENT_PATH) {
return null;
}
return `${resolved.pathname}${resolved.search}`;
} catch {
return null;
}
}