From 3fd6bd6081c088f3d1ebb312bafd440269b40c25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 23:18:24 +0200 Subject: [PATCH] test(claim): drive the single-sign-on claim against a mock identity provider Check the link before the code is redeemed and refuse a live session first, so a refused claim spends nothing at the provider. --- src/app/api/auth/oidc/callback/route.ts | 27 +- src/lib/queries/use-profile-claim.ts | 36 + src/lib/query-keys/sharing.ts | 2 + .../managed-profile-handover-sso.test.ts | 906 ++++++++++++++++++ 4 files changed, 959 insertions(+), 12 deletions(-) create mode 100644 tests/integration/managed-profile-handover-sso.test.ts diff --git a/src/app/api/auth/oidc/callback/route.ts b/src/app/api/auth/oidc/callback/route.ts index f5d155a55..8f39e6457 100644 --- a/src/app/api/auth/oidc/callback/route.ts +++ b/src/app/api/auth/oidc/callback/route.ts @@ -49,6 +49,7 @@ import { completeSsoClaim, SSO_CLAIM_LANDING, } from "@/lib/managed-profiles/claim-sso"; +import { isHandoverUsable } from "@/lib/managed-profiles/handover"; const LOGIN_ERROR_URL = "/auth/login"; @@ -283,22 +284,16 @@ export const GET = apiHandler(async (req: NextRequest) => { return isClaim ? failRedirect("oidc_state") : errorRedirect("oidc_state"); } - // v1.43 (#959) — a claim is admitted only with its own sealed cookie, still - // bound to this sign-in, and never over a live session (the claim routes' - // rule: a Guardian finishing their own link would otherwise be signed out - // of their account and into the person's). Both before the code is - // redeemed, so a refused claim spends nothing at the provider. + // v1.43 (#959) — a claim is never finished over a live session (the claim + // routes' rule: a Guardian finishing their own link would otherwise be + // signed out of their account and into the person's), and is admitted only + // with its own sealed cookie, still bound to this sign-in, for a link that + // is still usable. All of it before the code is redeemed, so a refused claim + // spends nothing at the provider. const pendingClaim = isClaim ? readClaimCookie(req.cookies.get(CLAIM_COOKIE)?.value) : null; if (isClaim) { - if ( - !pendingClaim || - !claimMatchesBinding(pendingClaim, stored.claimBinding as string) - ) { - annotate({ meta: { reason: "claim_cookie" } }); - return failRedirect("claim_expired"); - } const liveSession = await validateSessionFromCookieValue( req.cookies.get(SESSION_COOKIE_NAME)?.value, ); @@ -306,6 +301,14 @@ export const GET = apiHandler(async (req: NextRequest) => { annotate({ meta: { reason: "claim_signed_in" } }); return failRedirect("claim_signed_in"); } + if ( + !pendingClaim || + !claimMatchesBinding(pendingClaim, stored.claimBinding as string) || + !(await isHandoverUsable(pendingClaim.handoverId)) + ) { + annotate({ meta: { reason: "claim_expired" } }); + return failRedirect("claim_expired"); + } } try { diff --git a/src/lib/queries/use-profile-claim.ts b/src/lib/queries/use-profile-claim.ts index 9c4b23910..160a3ff69 100644 --- a/src/lib/queries/use-profile-claim.ts +++ b/src/lib/queries/use-profile-claim.ts @@ -12,6 +12,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { apiPost } from "@/lib/api/api-fetch"; import type { HandoverAccess } from "@/lib/managed-profiles/handover-access"; +import type { HandoverMethod } from "@/lib/validations/claim-sso"; import { queryKeys } from "@/lib/query-keys"; export interface ProfileClaimPreview { @@ -21,6 +22,13 @@ export interface ProfileClaimPreview { displayName: string | null; proposal: HandoverAccess; }[]; + /** + * v1.43 — the ways in this server offers. Absent from an older server, + * which only ever offered the password. + */ + methods?: HandoverMethod[]; + /** v1.43 — the provider's name for the button, or null. */ + ssoProviderName?: string | null; } export function useProfileClaimPreview(token: string | null, enabled: boolean) { @@ -64,3 +72,31 @@ export function useClaimProfile() { ), }); } + +/** Exactly the body `POST /api/auth/claim/sso` accepts (strict). */ +export interface ProfileClaimSsoInput { + token: string; + username: string; +} + +export function claimSsoBody( + input: ProfileClaimSsoInput, +): ProfileClaimSsoInput { + return { token: input.token, username: input.username }; +} + +/** + * v1.43 (#959) — start claiming through the identity provider. The answer + * sets the sealed claim cookie and names where the browser goes next; the + * caller navigates there as a full page load. + */ +export function useClaimProfileSso() { + return useMutation({ + mutationKey: queryKeys.profileClaimSso(), + mutationFn: (input: ProfileClaimSsoInput) => + apiPost<{ redirectTo: string }>( + "/api/auth/claim/sso", + claimSsoBody(input), + ), + }); +} diff --git a/src/lib/query-keys/sharing.ts b/src/lib/query-keys/sharing.ts index 48755f2d4..38a071974 100644 --- a/src/lib/query-keys/sharing.ts +++ b/src/lib/query-keys/sharing.ts @@ -99,4 +99,6 @@ export const sharingKeys = { ["managed-profiles", "handover", "revoke"] as const, handoverDecide: () => ["account", "handover-decision", "decide"] as const, profileClaim: () => ["profile-claim", "submit"] as const, + /** v1.43 (#959) — starting the claim through single sign-on. */ + profileClaimSso: () => ["profile-claim", "sso"] as const, }; diff --git a/tests/integration/managed-profile-handover-sso.test.ts b/tests/integration/managed-profile-handover-sso.test.ts new file mode 100644 index 000000000..842e9e8c7 --- /dev/null +++ b/tests/integration/managed-profile-handover-sso.test.ts @@ -0,0 +1,906 @@ +/** + * v1.43 (#959) — handing a managed profile over on a server that signs in + * through an identity provider, end to end: the real start route, the real + * OIDC login and callback, a real Postgres, and a mock identity provider that + * speaks the wire (discovery, JWKS, a token endpoint that checks PKCE, and + * ES256-signed ID tokens), so state, nonce, PKCE and the signature are the + * production code's own checks rather than stubs. + * + * Every refusal is asserted twice: where the browser lands, and that nothing + * about any account changed (the profile is still managed, the subject is not + * bound, nobody got a session, the link is still as it was). + */ +import { createServer, type Server } from "node:http"; +import { createHash } from "node:crypto"; +import type { AddressInfo } from "node:net"; + +import { exportJWK, generateKeyPair, SignJWT, type CryptoKey } from "jose"; +import { NextRequest } from "next/server"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; +import { createManagedProfile } from "@/lib/managed-profiles/create"; +import { acceptGrant, inviteGrant } from "@/lib/sharing/grants"; +import { readPendingHandoverDecision } from "@/lib/managed-profiles/handover"; +import { _resetOidcCacheForTests } from "@/lib/auth/oidc"; +import { handoverCreateBody } from "@/lib/queries/use-managed-profiles"; +import { claimBody, claimSsoBody } from "@/lib/queries/use-profile-claim"; +import type { HandoverAccess } from "@/lib/managed-profiles/handover-access"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/password-breach-check", () => ({ + checkPasswordBreachIfEnabled: vi.fn().mockResolvedValue(null), +})); + +const sent: { userId: string; messageKey: string }[] = []; +vi.mock("@/lib/notifications/dispatch-localised", () => ({ + dispatchLocalisedNotification: vi.fn( + async (opts: { userId: string; messageKey: string }) => { + sent.push({ userId: opts.userId, messageKey: opts.messageKey }); + }, + ), +})); + +const APP = "http://localhost:3000"; +const CLIENT_ID = "healthlog-test"; +const CLIENT_SECRET = "client-secret-for-the-mock-idp"; + +// ── The mock identity provider ────────────────────────────────────────────── + +interface Grant { + sub: string; + email?: string; + emailVerified?: boolean; + nonce: string; + codeChallenge: string; + /** The provider would redeem the code more than once (a replay test). */ + reusable?: boolean; + /** Sign the ID token with this nonce instead of the requested one. */ + nonceOverride?: string; +} + +let idp: Server; +let ISSUER = ""; +let privateKey: CryptoKey; +let publicJwk: Record; +const grants = new Map(); +const redeemed: string[] = []; + +function s256(verifier: string): string { + return createHash("sha256").update(verifier).digest("base64url"); +} + +async function readBody(req: import("node:http").IncomingMessage) { + let body = ""; + for await (const chunk of req) body += chunk; + return new URLSearchParams(body); +} + +beforeAll(async () => { + const pair = await generateKeyPair("ES256"); + privateKey = pair.privateKey; + publicJwk = { ...(await exportJWK(pair.publicKey)), kid: "k1", alg: "ES256" }; + + idp = createServer(async (req, res) => { + const json = (status: number, body: unknown) => { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); + }; + const path = new URL(req.url ?? "/", ISSUER).pathname; + if (path === "/.well-known/openid-configuration") { + return json(200, { + issuer: ISSUER, + authorization_endpoint: `${ISSUER}/authorize`, + token_endpoint: `${ISSUER}/token`, + jwks_uri: `${ISSUER}/jwks`, + }); + } + if (path === "/jwks") return json(200, { keys: [publicJwk] }); + if (path === "/token" && req.method === "POST") { + const form = await readBody(req); + const code = form.get("code") ?? ""; + const grant = grants.get(code); + if ( + !grant || + form.get("client_id") !== CLIENT_ID || + form.get("client_secret") !== CLIENT_SECRET || + form.get("redirect_uri") !== `${APP}/api/auth/oidc/callback` || + // PKCE, as a provider enforces it. + s256(form.get("code_verifier") ?? "") !== grant.codeChallenge + ) { + return json(400, { error: "invalid_grant" }); + } + if (!grant.reusable) grants.delete(code); + redeemed.push(code); + const idToken = await new SignJWT({ + nonce: grant.nonceOverride ?? grant.nonce, + ...(grant.email !== undefined ? { email: grant.email } : {}), + ...(grant.emailVerified !== undefined + ? { email_verified: grant.emailVerified } + : {}), + }) + .setProtectedHeader({ alg: "ES256", kid: "k1" }) + .setIssuer(ISSUER) + .setAudience(CLIENT_ID) + .setSubject(grant.sub) + .setIssuedAt() + .setExpirationTime("5m") + .sign(privateKey); + return json(200, { id_token: idToken, token_type: "Bearer" }); + } + json(404, { error: "not_found" }); + }); + await new Promise((resolve) => idp.listen(0, "127.0.0.1", resolve)); + ISSUER = `http://127.0.0.1:${(idp.address() as AddressInfo).port}`; +}); + +afterAll(async () => { + await new Promise((resolve) => idp.close(() => resolve())); +}); + +// ── The household ─────────────────────────────────────────────────────────── + +let sequence = 0; + +async function person(label: string) { + const suffix = sequence++; + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username: `${label}-${suffix}`, + email: `${label}-${suffix}@example.test`, + totpConfirmedAt: new Date(), + }, + }); + const session = await prisma.session.create({ + data: { + userId: user.id, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + mfaVerifiedAt: new Date(), + }, + }); + return { id: user.id, username: user.username, sessionId: session.id }; +} + +/** A profile with two Guardians: its creator and one more. */ +async function household() { + const creator = await person("creator"); + const second = await person("second"); + const { profile, creatorGrant } = await createManagedProfile({ + creatorId: creator.id, + displayName: "Robin", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + const invitation = await inviteGrant({ + grantorId: profile.id, + granteeId: second.id, + access: "MANAGE", + scope: null, + }); + const secondGrant = await acceptGrant({ + grantId: invitation.id, + granteeId: second.id, + }); + return { creator, second, profile, creatorGrant, secondGrant }; +} + +const json = (body: unknown) => ({ + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), +}); + +/** Mint a link as the creator (the Guardian side), then sign out. */ +async function mintToken( + creator: { sessionId: string }, + profileId: string, + proposals: { grantId: string; proposal: HandoverAccess }[] = [], +): Promise { + cookieJar.set("healthlog_session", creator.sessionId); + const { POST } = + await import("@/app/api/managed-profiles/[id]/handover/route"); + const response = await POST( + new NextRequest( + `${APP}/api/managed-profiles/${profileId}/handover`, + json(handoverCreateBody({ profileId, expiresInDays: 7, proposals })), + ), + { params: Promise.resolve({ id: profileId }) }, + ); + expect(response.status).toBe(201); + cookieJar.clear(); + return (await response.json()).data.token as string; +} + +async function handoverStatus(creator: { sessionId: string }, id: string) { + cookieJar.set("healthlog_session", creator.sessionId); + const { GET } = + await import("@/app/api/managed-profiles/[id]/handover/route"); + const response = await GET( + new NextRequest(`${APP}/api/managed-profiles/${id}/handover`), + { params: Promise.resolve({ id }) }, + ); + cookieJar.clear(); + return response; +} + +async function preview(token: string) { + const { POST } = await import("@/app/api/auth/claim/preview/route"); + return POST( + new NextRequest(`${APP}/api/auth/claim/preview`, json({ token })), + ); +} + +async function passwordClaim(token: string) { + const suffix = sequence++; + const { POST } = await import("@/app/api/auth/claim/route"); + return POST( + new NextRequest( + `${APP}/api/auth/claim`, + json( + claimBody({ + token, + username: `robinpw${suffix}`, + email: `robin-pw-${suffix}@example.test`, + password: "correct horse battery staple 1959", + }), + ), + ), + ); +} + +async function startSso(token: string, username: string) { + const { POST } = await import("@/app/api/auth/claim/sso/route"); + return POST( + new NextRequest( + `${APP}/api/auth/claim/sso`, + json(claimSsoBody({ token, username })), + ), + ); +} + +function cookieHeader(cookies: Record): string { + return Object.entries(cookies) + .filter((entry): entry is [string, string] => Boolean(entry[1])) + .map(([k, v]) => `${k}=${v}`) + .join("; "); +} + +/** The value a response sets for a cookie, or undefined. */ +function setCookie(response: Response, name: string): string | undefined { + const cookie = (response as NextResponseLike).cookies?.get(name); + return cookie?.value || undefined; +} +type NextResponseLike = Response & { + cookies?: { get(name: string): { value: string } | undefined }; +}; + +async function login(claimCookie: string | undefined, query = "intent=claim") { + const { GET } = await import("@/app/api/auth/oidc/login/route"); + return GET( + new NextRequest(`${APP}/api/auth/oidc/login?${query}`, { + headers: { cookie: cookieHeader({ hl_claim: claimCookie }) }, + }), + ); +} + +interface Identity { + sub: string; + email?: string; + emailVerified?: boolean; + reusable?: boolean; + nonceOverride?: string; +} + +/** Where the provider sends the browser back, and with which cookies. */ +interface Callback { + url: string; + stateCookie: string; + claimCookie: string | undefined; +} + +/** The provider authenticates `identity` for an authorize redirect. */ +function authorize(loginResponse: Response, identity: Identity, code: string) { + const location = new URL(loginResponse.headers.get("location") ?? ""); + expect(location.origin + location.pathname).toBe(`${ISSUER}/authorize`); + expect(location.searchParams.get("code_challenge_method")).toBe("S256"); + grants.set(code, { + ...identity, + nonce: location.searchParams.get("nonce") ?? "", + codeChallenge: location.searchParams.get("code_challenge") ?? "", + }); + return location.searchParams.get("state") ?? ""; +} + +async function callback( + cb: Callback, + extra: Record = {}, +) { + const { GET } = await import("@/app/api/auth/oidc/callback/route"); + return GET( + new NextRequest(cb.url, { + headers: { + cookie: cookieHeader({ + oidc_auth_state: cb.stateCookie, + hl_claim: cb.claimCookie, + ...extra, + }), + }, + }), + ); +} + +/** Start, log in and come back from the provider; the callback not yet run. */ +async function throughProvider( + token: string, + identity: Identity, + username = `robin${sequence++}`, +): Promise { + const started = await startSso(token, username); + expect(started.status).toBe(200); + expect((await started.json()).data.redirectTo).toBe( + "/api/auth/oidc/login?intent=claim", + ); + const claimCookie = setCookie(started, "hl_claim"); + expect(claimCookie).toBeDefined(); + const loginResponse = await login(claimCookie); + const stateCookie = setCookie(loginResponse, "oidc_auth_state"); + expect(stateCookie).toBeDefined(); + const code = `code-${sequence++}`; + const state = authorize(loginResponse, identity, code); + return { + url: `${APP}/api/auth/oidc/callback?code=${code}&state=${encodeURIComponent(state)}`, + stateCookie: stateCookie!, + claimCookie, + }; +} + +function landedOn(response: Response): string { + expect([302, 303, 307]).toContain(response.status); + const location = new URL(response.headers.get("location") ?? ""); + expect(location.origin).toBe(APP); + return location.pathname + location.search; +} + +async function profileRow(id: string) { + return getPrismaClient().user.findUniqueOrThrow({ + where: { id }, + select: { + username: true, + email: true, + passwordHash: true, + oidcIssuer: true, + oidcSub: true, + managedProfileAt: true, + onboardingCompletedAt: true, + }, + }); +} + +/** The account did not change at all, and nobody signed in to it. */ +async function expectUntouched(profileId: string) { + const row = await profileRow(profileId); + expect(row.managedProfileAt).not.toBeNull(); + expect(row.oidcSub).toBeNull(); + expect(row.email).toBeNull(); + expect(row.username.startsWith("managed-")).toBe(true); + expect( + await getPrismaClient().session.count({ where: { userId: profileId } }), + ).toBe(0); + expect( + await getPrismaClient().auditLog.count({ + where: { userId: profileId, action: "auth.claim" }, + }), + ).toBe(0); +} + +function oidcEnv(only: boolean) { + process.env.OIDC_ISSUER_URL = ISSUER; + process.env.OIDC_CLIENT_ID = CLIENT_ID; + process.env.OIDC_CLIENT_SECRET = CLIENT_SECRET; + process.env.OIDC_BUTTON_LABEL = "Example ID"; + if (only) process.env.OIDC_ONLY = "true"; + else delete process.env.OIDC_ONLY; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + grants.clear(); + redeemed.length = 0; + sent.length = 0; + _resetOidcCacheForTests(); + process.env.APP_URL = APP; + process.env.NEXT_PUBLIC_APP_URL = APP; + oidcEnv(true); +}); + +afterEach(() => { + for (const key of [ + "OIDC_ISSUER_URL", + "OIDC_CLIENT_ID", + "OIDC_CLIENT_SECRET", + "OIDC_BUTTON_LABEL", + "OIDC_ONLY", + ]) { + delete process.env[key]; + } +}); + +// ── The tests ─────────────────────────────────────────────────────────────── + +describe("claiming through single sign-on on an SSO-only server", () => { + it("hands the profile over and binds the provider's subject", async () => { + const { creator, second, profile, secondGrant } = await household(); + + // The Guardian side: available, through single sign-on only. + const status = (await (await handoverStatus(creator, profile.id)).json()) + .data; + expect(status.available).toBe(true); + expect(status.methods).toEqual(["sso"]); + + const token = await mintToken(creator, profile.id, [ + { grantId: secondGrant.id, proposal: "read" }, + ]); + + // The person's side: the preview names the provider, the password claim + // stays closed. + const shown = (await (await preview(token)).json()).data; + expect(shown.methods).toEqual(["sso"]); + expect(shown.ssoProviderName).toBe("Example ID"); + const refused = await passwordClaim(token); + expect(refused.status).toBe(403); + expect((await refused.json()).meta.errorCode).toBe( + "profile_claim.oidc_only_unsupported", + ); + + const cb = await throughProvider( + token, + { + sub: "subject-robin", + email: "Robin@Example.test", + emailVerified: true, + }, + "robin", + ); + const response = await callback(cb); + expect(landedOn(response)).toBe("/onboarding/handover"); + // Both single-use cookies go with the answer. + expect(response.headers.get("set-cookie")).toMatch(/hl_claim=;/); + expect(response.headers.get("set-cookie")).toMatch(/oidc_auth_state=;/); + // The provider redeemed the code with the matching PKCE verifier. + expect(redeemed).toHaveLength(1); + + const row = await profileRow(profile.id); + expect(row).toMatchObject({ + username: "robin", + email: "robin@example.test", + passwordHash: null, + oidcIssuer: ISSUER, + oidcSub: "subject-robin", + managedProfileAt: null, + onboardingCompletedAt: null, + }); + + // The link is spent; a session exists for the new owner, and only one. + const link = + await getPrismaClient().managedProfileHandover.findFirstOrThrow({ + where: { profileId: profile.id }, + }); + expect(link.usedAt).not.toBeNull(); + expect( + await getPrismaClient().session.count({ where: { userId: profile.id } }), + ).toBe(1); + expect(cookieJar.get("healthlog_session")).toBeDefined(); + + // The Guardians' proposals hold and wait for the owner's decision. + const decision = await readPendingHandoverDecision(profile.id); + expect( + decision?.guardians.map((g) => [g.displayName, g.proposal, g.current]), + ).toEqual( + expect.arrayContaining([ + [expect.stringContaining("second"), "read", "read"], + ]), + ); + const secondNow = await getPrismaClient().accountGrant.findFirst({ + where: { grantorId: profile.id, granteeId: second.id, revokedAt: null }, + }); + expect(secondNow?.access).toBe("READ"); + + // Audited as an SSO claim, and the Guardians were told. + const audits = await getPrismaClient().auditLog.findMany({ + where: { userId: profile.id }, + select: { action: true, details: true }, + }); + const actions = audits.map((a) => a.action); + expect(actions).toEqual( + expect.arrayContaining([ + "managed_profile.claim_sso.started", + "managed_profile.claimed", + "auth.claim", + ]), + ); + const claimAudit = audits.find((a) => a.action === "auth.claim"); + expect(JSON.parse(claimAudit!.details!)).toEqual({ method: "sso" }); + // The token never reached an audit row. + expect(JSON.stringify(audits)).not.toContain(token); + expect(sent.map((s) => s.userId).sort()).toEqual( + [creator.id, second.id].sort(), + ); + }); + + it("creates the account without an address the provider did not verify", async () => { + const { creator, profile } = await household(); + const token = await mintToken(creator, profile.id); + const cb = await throughProvider(token, { + sub: "subject-unverified", + email: "robin@example.test", + emailVerified: false, + }); + expect(landedOn(await callback(cb))).toBe("/onboarding/handover"); + const row = await profileRow(profile.id); + expect(row.email).toBeNull(); + expect(row.oidcSub).toBe("subject-unverified"); + }); + + it("ignores a caller-supplied next and lands on the fixed page", async () => { + const { creator, profile } = await household(); + const token = await mintToken(creator, profile.id); + const started = await startSso(token, "robin"); + const claimCookie = setCookie(started, "hl_claim"); + const loginResponse = await login( + claimCookie, + "intent=claim&next=https%3A%2F%2Fevil.example%2Fphish", + ); + const state = authorize(loginResponse, { sub: "s-next" }, "code-next"); + const response = await callback({ + url: `${APP}/api/auth/oidc/callback?code=code-next&state=${encodeURIComponent(state)}`, + stateCookie: setCookie(loginResponse, "oidc_auth_state")!, + claimCookie, + }); + expect(response.headers.get("location")).toBe(`${APP}/onboarding/handover`); + }); +}); + +describe("refusals", () => { + it("refuses a subject already bound to another account (no double binding)", async () => { + const { creator, profile } = await household(); + const holder = await getPrismaClient().user.create({ + data: { + username: "holder", + email: "holder@example.test", + oidcIssuer: ISSUER, + oidcSub: "subject-taken", + }, + }); + const token = await mintToken(creator, profile.id); + const cb = await throughProvider(token, { sub: "subject-taken" }); + + expect(landedOn(await callback(cb))).toBe( + "/auth/claim?error=claim_sso_subject_bound", + ); + await expectUntouched(profile.id); + // The holder keeps their sign-in and is told somebody tried. + expect((await profileRow(holder.id)).oidcSub).toBe("subject-taken"); + expect( + await getPrismaClient().auditLog.count({ + where: { userId: holder.id, action: "auth.oidc.claim_conflict" }, + }), + ).toBe(1); + // The link survives for the right person. + expect((await preview(token)).status).toBe(200); + }); + + it("refuses a second profile for a subject that already claimed one", async () => { + const first = await household(); + const second = await household(); + const tokenA = await mintToken(first.creator, first.profile.id); + const tokenB = await mintToken(second.creator, second.profile.id); + + const a = await throughProvider(tokenA, { sub: "one-person" }); + expect(landedOn(await callback(a))).toBe("/onboarding/handover"); + cookieJar.clear(); + + const b = await throughProvider(tokenB, { sub: "one-person" }); + expect(landedOn(await callback(b))).toBe( + "/auth/claim?error=claim_sso_subject_bound", + ); + await expectUntouched(second.profile.id); + }); + + it("refuses a verified address another account holds", async () => { + const { creator, profile } = await household(); + const token = await mintToken(creator, profile.id); + const cb = await throughProvider(token, { + sub: "subject-email", + email: creator.username + "@EXAMPLE.test", + emailVerified: true, + }); + expect(landedOn(await callback(cb))).toBe( + "/auth/claim?error=claim_email_taken", + ); + await expectUntouched(profile.id); + }); + + it("refuses a username taken during the round trip", async () => { + const { creator, profile } = await household(); + const token = await mintToken(creator, profile.id); + const cb = await throughProvider(token, { sub: "subject-name" }, "robin"); + await getPrismaClient().user.create({ + data: { username: "robin", email: "other@example.test" }, + }); + expect(landedOn(await callback(cb))).toBe( + "/auth/claim?error=claim_username_taken", + ); + await expectUntouched(profile.id); + }); + + it.each([ + [ + "expired", + async (profileId: string) => { + await getPrismaClient().managedProfileHandover.updateMany({ + where: { profileId }, + data: { expiresAt: new Date(Date.now() - 1000) }, + }); + }, + ], + [ + "withdrawn", + async (profileId: string) => { + await getPrismaClient().managedProfileHandover.updateMany({ + where: { profileId }, + data: { revokedAt: new Date() }, + }); + }, + ], + ])( + "refuses a link %s while the person was at the provider", + async (_n, spoil) => { + const { creator, profile } = await household(); + const token = await mintToken(creator, profile.id); + const cb = await throughProvider(token, { sub: "subject-late" }); + await spoil(profile.id); + expect(landedOn(await callback(cb))).toBe( + "/auth/claim?error=claim_expired", + ); + await expectUntouched(profile.id); + // Refused before the code was redeemed: nothing spent at the provider. + expect(redeemed).toHaveLength(0); + }, + ); + + it("refuses a link used by a password claim in the meantime", async () => { + oidcEnv(false); + const { creator, profile } = await household(); + const token = await mintToken(creator, profile.id); + const cb = await throughProvider(token, { sub: "subject-slow" }); + expect((await passwordClaim(token)).status).toBe(201); + cookieJar.clear(); + expect(landedOn(await callback(cb))).toBe( + "/auth/claim?error=claim_expired", + ); + const row = await profileRow(profile.id); + expect(row.oidcSub).toBeNull(); + expect(row.passwordHash).not.toBeNull(); + }); + + it("stops an expired link at the login, before the provider", async () => { + const { creator, profile } = await household(); + const token = await mintToken(creator, profile.id); + const started = await startSso(token, "robin"); + await getPrismaClient().managedProfileHandover.updateMany({ + where: { profileId: profile.id }, + data: { expiresAt: new Date(Date.now() - 1000) }, + }); + const response = await login(setCookie(started, "hl_claim")); + expect(landedOn(response)).toBe("/auth/claim?error=claim_expired"); + expect(setCookie(response, "oidc_auth_state")).toBeUndefined(); + }); + + it("answers the start with 404 for a used or unknown link", async () => { + const { creator, profile } = await household(); + const token = await mintToken(creator, profile.id); + const cb = await throughProvider(token, { sub: "subject-once" }); + expect(landedOn(await callback(cb))).toBe("/onboarding/handover"); + cookieJar.clear(); + for (const t of [token, `hlp_${"0".repeat(64)}`]) { + const response = await startSso(t, "someone"); + expect(response.status).toBe(404); + expect((await response.json()).meta.errorCode).toBe( + "profile_claim.invalid", + ); + } + }); +}); + +describe("replay, state, nonce and the claim cookie", () => { + it("does nothing on a replayed callback, even if the provider redeemed the code twice", async () => { + const { creator, profile } = await household(); + const token = await mintToken(creator, profile.id); + const cb = await throughProvider(token, { + sub: "subject-replay", + reusable: true, + }); + expect(landedOn(await callback(cb))).toBe("/onboarding/handover"); + const sessionsAfterFirst = await getPrismaClient().session.count({ + where: { userId: profile.id }, + }); + const sessionCookie = cookieJar.get("healthlog_session"); + expect(sessionCookie).toMatch(/^hls_/); + cookieJar.clear(); + + // The same request again, cookies and all, from a browser without the + // session (a captured request): the link is spent. + expect(landedOn(await callback(cb))).toBe( + "/auth/claim?error=claim_expired", + ); + // From the browser that now holds the session: refused as signed in, + // before the link is even looked at. + expect( + landedOn(await callback(cb, { healthlog_session: sessionCookie })), + ).toBe("/auth/claim?error=claim_signed_in"); + + expect( + await getPrismaClient().session.count({ where: { userId: profile.id } }), + ).toBe(sessionsAfterFirst); + expect( + await getPrismaClient().auditLog.count({ + where: { userId: profile.id, action: "auth.claim" }, + }), + ).toBe(1); + }); + + it("refuses a callback whose state does not match", async () => { + const { creator, profile } = await household(); + const token = await mintToken(creator, profile.id); + const cb = await throughProvider(token, { sub: "subject-state" }); + const forged = cb.url.replace(/state=[^&]+/, "state=forged-state-value"); + expect(landedOn(await callback({ ...cb, url: forged }))).toBe( + "/auth/claim?error=oidc_state", + ); + await expectUntouched(profile.id); + expect(redeemed).toHaveLength(0); + }); + + it("refuses an ID token minted for another nonce", async () => { + const { creator, profile } = await household(); + const token = await mintToken(creator, profile.id); + const cb = await throughProvider(token, { + sub: "subject-nonce", + nonceOverride: "some-other-sign-in", + }); + expect(landedOn(await callback(cb))).toBe("/auth/claim?error=oidc_failed"); + await expectUntouched(profile.id); + }); + + it("refuses a callback that comes back without the claim cookie", async () => { + const { creator, profile } = await household(); + const token = await mintToken(creator, profile.id); + const cb = await throughProvider(token, { sub: "subject-nocookie" }); + expect(landedOn(await callback({ ...cb, claimCookie: undefined }))).toBe( + "/auth/claim?error=claim_expired", + ); + await expectUntouched(profile.id); + expect(redeemed).toHaveLength(0); + }); + + it("refuses a claim cookie from a different start", async () => { + const { creator, profile } = await household(); + const token = await mintToken(creator, profile.id); + const cb = await throughProvider(token, { sub: "subject-swap" }); + // A second start in the same browser seals a new binding; the sign-in + // that began with the first one cannot finish with it. + const other = setCookie(await startSso(token, "robin2"), "hl_claim"); + expect(landedOn(await callback({ ...cb, claimCookie: other }))).toBe( + "/auth/claim?error=claim_expired", + ); + await expectUntouched(profile.id); + }); + + it("refuses a forged claim cookie", async () => { + const { creator, profile } = await household(); + const token = await mintToken(creator, profile.id); + const cb = await throughProvider(token, { sub: "subject-forged" }); + const link = + await getPrismaClient().managedProfileHandover.findFirstOrThrow({ + where: { profileId: profile.id }, + }); + const forged = Buffer.from( + JSON.stringify({ h: link.id, u: "x", b: "y", e: Date.now() + 1e6 }), + ).toString("base64"); + expect(landedOn(await callback({ ...cb, claimCookie: forged }))).toBe( + "/auth/claim?error=claim_expired", + ); + await expectUntouched(profile.id); + }); + + it("does not adopt a session id the browser brought along", async () => { + const { creator, profile } = await household(); + const token = await mintToken(creator, profile.id); + const cb = await throughProvider(token, { sub: "subject-fixation" }); + const planted = "planted-session-id-chosen-by-somebody-else"; + expect(landedOn(await callback(cb, { healthlog_session: planted }))).toBe( + "/onboarding/handover", + ); + const sessions = await getPrismaClient().session.findMany({ + where: { userId: profile.id }, + select: { id: true }, + }); + expect(sessions).toHaveLength(1); + expect(sessions[0]!.id).not.toBe(planted); + expect(cookieJar.get("healthlog_session")).not.toBe(planted); + }); +}); + +describe("which ways in a server offers", () => { + it("offers both with a provider and passwords allowed, and both work", async () => { + oidcEnv(false); + const { creator, profile } = await household(); + const status = (await (await handoverStatus(creator, profile.id)).json()) + .data; + expect(status.methods).toEqual(["password", "sso"]); + const token = await mintToken(creator, profile.id); + expect((await (await preview(token)).json()).data.methods).toEqual([ + "password", + "sso", + ]); + const cb = await throughProvider(token, { sub: "subject-mixed" }); + expect(landedOn(await callback(cb))).toBe("/onboarding/handover"); + + const other = await household(); + const token2 = await mintToken(other.creator, other.profile.id); + expect((await passwordClaim(token2)).status).toBe(201); + }); + + it("offers only the password without a provider, and the SSO start says so", async () => { + for (const key of ["OIDC_ISSUER_URL", "OIDC_CLIENT_ID", "OIDC_ONLY"]) { + delete process.env[key]; + } + const { creator, profile } = await household(); + const status = (await (await handoverStatus(creator, profile.id)).json()) + .data; + expect(status.methods).toEqual(["password"]); + const token = await mintToken(creator, profile.id); + const shown = (await (await preview(token)).json()).data; + expect(shown.methods).toEqual(["password"]); + expect(shown.ssoProviderName).toBeNull(); + const response = await startSso(token, "robin"); + expect(response.status).toBe(404); + expect((await response.json()).meta.errorCode).toBe( + "profile_claim.sso_unavailable", + ); + }); +});