/** * 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", ); }); });