diff --git a/messages/de.json b/messages/de.json index 8e7c9e885..7ccfb920c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index 5fb9c4cf2..eeedef816 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index 3caf7e3d1..5856435ad 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index f362b2bae..50abc764c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index 404551bb8..941033101 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/ko.json b/messages/ko.json index 85af2c610..f1349726d 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -471,6 +471,13 @@ "title": "2차 인증 수단 설정", "body": "관리자가 이 계정에 2차 인증 수단을 요구해요. 인증 앱이나 보안 키를 추가하고 계속해 주세요.", "signOut": "로그아웃" + }, + "confirmAccess": { + "title": "본인인지 확인해 주세요", + "body": "어시스턴트가 HealthLog 데이터에 연결하려고 해요. 본인인지 확인하면 허용할지 정하는 화면으로 돌아가요.", + "action": "확인", + "failed": "확인하지 못했어요. 다시 시도해 주세요.", + "nothingToConfirm": "여기에서 확인할 것이 없어요. 어시스턴트에서 연결을 다시 시작해 주세요." } }, "dashboard": { diff --git a/messages/pl.json b/messages/pl.json index 541ad9267..b71f77fd3 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/__tests__/recent-proof-surface-guard.test.ts b/src/__tests__/recent-proof-surface-guard.test.ts index cb6bb3878..bb334ff98 100644 --- a/src/__tests__/recent-proof-surface-guard.test.ts +++ b/src/__tests__/recent-proof-surface-guard.test.ts @@ -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(), ); }); diff --git a/src/app/api/mcp/oauth/__tests__/authorize.test.ts b/src/app/api/mcp/oauth/__tests__/authorize.test.ts index 68142b8c1..164da251e 100644 --- a/src/app/api/mcp/oauth/__tests__/authorize.test.ts +++ b/src/app/api/mcp/oauth/__tests__/authorize.test.ts @@ -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") ?? ""; diff --git a/src/app/api/mcp/oauth/authorize/route.ts b/src/app/api/mcp/oauth/authorize/route.ts index 783940496..b0ad1ef5e 100644 --- a/src/app/api/mcp/oauth/authorize/route.ts +++ b/src/app/api/mcp/oauth/authorize/route.ts @@ -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>>, +): Promise { + 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( + `

Confirm it is you

Before ${htmlEscape(clientName)} 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.

Confirm and continue

`, + 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 { ); } + 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 { }); } + // 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", { diff --git a/src/app/confirm-access/__tests__/return-to.test.ts b/src/app/confirm-access/__tests__/return-to.test.ts new file mode 100644 index 000000000..8f7f4555e --- /dev/null +++ b/src/app/confirm-access/__tests__/return-to.test.ts @@ -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(); + }); +}); diff --git a/src/app/confirm-access/confirm-access-client.tsx b/src/app/confirm-access/confirm-access-client.tsx new file mode 100644 index 000000000..ee369b423 --- /dev/null +++ b/src/app/confirm-access/confirm-access-client.tsx @@ -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(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 ( +
+
+
+ {returnTo ? ( + <> +

{t("auth.confirmAccess.body")}

+
+ +
+ + ) : ( +

{t("auth.confirmAccess.nothingToConfirm")}

+ )} + {error && ( +

+ {error} +

+ )} + {recentProof.dialog} +
+ ); +} diff --git a/src/app/confirm-access/page.tsx b/src/app/confirm-access/page.tsx new file mode 100644 index 000000000..b9885f7e7 --- /dev/null +++ b/src/app/confirm-access/page.tsx @@ -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 ; +} diff --git a/src/app/confirm-access/return-to.ts b/src/app/confirm-access/return-to.ts new file mode 100644 index 000000000..166e8a49a --- /dev/null +++ b/src/app/confirm-access/return-to.ts @@ -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; + } +}