Merge remote-tracking branch 'origin/main' into fix/v1434-illness-symptoms

This commit is contained in:
Marc-André Bombeck committed 2026-10-17 11:35:55 +02:00
commit e8e3cf4cd3
47 files changed
+2646 -553

No files matched your search

@@ -0,0 +1,489 @@
/**
* #959 — a Guardian who signs in through an identity provider, with a second
* factor enrolled in the app, hands a managed profile over after the
* five-minute window has passed. The report: "I get the error message that I
* need to confirm my second factor, but there is nowhere to do so."
*
* Driven end to end against real Postgres and a mock identity provider that
* speaks the wire (discovery, JWKS, a PKCE-checking token endpoint, ES256 ID
* tokens), the same harness as `managed-profile-handover-sso.test.ts`:
*
* 1. the real OIDC login and callback, which puts an account with a second
* factor through the app's own MFA challenge;
* 2. the real `/api/auth/mfa/verify`, which stamps the session;
* 3. ten minutes later the handover refuses with `auth.stepup.required` AND
* names the proofs that clear it, which is what lets the browser open the
* re-proof dialog instead of printing a sentence;
* 4. `/api/auth/reproof` with the second factor, exactly what the dialog
* posts, and the handover goes through.
*
* And the other half of the question: a Guardian whose only factor is the one
* the identity provider asked for. The app cannot see that factor, so the gate
* asks for one of its own (`mfa_not_enrolled`), and the way through is
* reachable: enrolling right after a sign-in needs no further proof, and the
* confirmation stamps the session, so the handover follows.
*/
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 * as OTPAuth from "otpauth";
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 { _resetOidcCacheForTests } from "@/lib/auth/oidc";
import { handoverCreateBody } from "@/lib/queries/use-managed-profiles";
process.env.API_TOKEN_HMAC_KEY ??=
"test-hmac-key-handover-stepup-sso-32-bytes-minimum-123456";
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/notifications/dispatch-localised", () => ({
dispatchLocalisedNotification: vi.fn(async () => {}),
}));
const APP = "http://localhost:3000";
const CLIENT_ID = "healthlog-test";
const CLIENT_SECRET = "client-secret-for-the-mock-idp";
const MINUTE = 60 * 1000;
// ── The mock identity provider ──────────────────────────────────────────────
interface Grant {
sub: string;
email: string;
nonce: string;
codeChallenge: string;
}
let idp: Server;
let ISSUER = "";
let privateKey: CryptoKey;
let publicJwk: Record<string, unknown>;
const grants = new Map<string, Grant>();
function s256(verifier: string): string {
return createHash("sha256").update(verifier).digest("base64url");
}
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") {
let raw = "";
for await (const chunk of req) raw += chunk;
const form = new URLSearchParams(raw);
const grant = grants.get(form.get("code") ?? "");
if (
!grant ||
form.get("client_id") !== CLIENT_ID ||
form.get("client_secret") !== CLIENT_SECRET ||
s256(form.get("code_verifier") ?? "") !== grant.codeChallenge
) {
return json(400, { error: "invalid_grant" });
}
grants.delete(form.get("code") ?? "");
const idToken = await new SignJWT({
nonce: grant.nonce,
email: grant.email,
email_verified: true,
})
.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<void>((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<void>((resolve) => idp.close(() => resolve()));
});
// ── Signing in through the provider ─────────────────────────────────────────
type NextResponseLike = Response & {
cookies?: { get(name: string): { value: string } | undefined };
};
function setCookie(response: Response, name: string): string | undefined {
return (response as NextResponseLike).cookies?.get(name)?.value || undefined;
}
let sequence = 0;
/**
* The browser's trip: the login route, the provider, the callback. Returns the
* callback's answer; the session (when one is minted) lands in the cookie jar.
*/
async function signInThroughProvider(identity: {
sub: string;
email: string;
}): Promise<Response> {
const { GET: login } = await import("@/app/api/auth/oidc/login/route");
const started = await login(new NextRequest(`${APP}/api/auth/oidc/login`));
const location = new URL(started.headers.get("location") ?? "");
expect(location.origin + location.pathname).toBe(`${ISSUER}/authorize`);
const stateCookie = setCookie(started, "oidc_auth_state");
expect(stateCookie).toBeDefined();
const code = `code-${sequence++}`;
grants.set(code, {
...identity,
nonce: location.searchParams.get("nonce") ?? "",
codeChallenge: location.searchParams.get("code_challenge") ?? "",
});
const state = location.searchParams.get("state") ?? "";
const { GET: callback } = await import("@/app/api/auth/oidc/callback/route");
return callback(
new NextRequest(
`${APP}/api/auth/oidc/callback?code=${code}&state=${encodeURIComponent(state)}`,
{ headers: { cookie: `oidc_auth_state=${stateCookie}` } },
),
);
}
function post(path: string, body?: unknown): NextRequest {
return new NextRequest(`${APP}${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body ?? {}),
});
}
type RouteFn = (request: NextRequest) => Promise<Response>;
async function mfaVerify(mfaTicket: string, code: string) {
const { POST } = await import("@/app/api/auth/mfa/verify/route");
return (POST as unknown as RouteFn)(
post("/api/auth/mfa/verify", { mfaTicket, method: "totp", code }),
);
}
async function reproof(body: unknown) {
const { POST } = await import("@/app/api/auth/reproof/route");
return (POST as unknown as RouteFn)(post("/api/auth/reproof", body));
}
async function handover(profileId: string) {
const { POST } =
await import("@/app/api/managed-profiles/[id]/handover/route");
return POST(
post(
`/api/managed-profiles/${profileId}/handover`,
handoverCreateBody({ profileId, expiresInDays: 7, proposals: [] }),
),
{ params: Promise.resolve({ id: profileId }) },
);
}
async function totpSetup() {
const { POST } = await import("@/app/api/auth/me/mfa/totp/setup/route");
return (POST as unknown as RouteFn)(post("/api/auth/me/mfa/totp/setup"));
}
async function totpConfirm(code: string) {
const { POST } = await import("@/app/api/auth/me/mfa/totp/confirm/route");
return (POST as unknown as RouteFn)(
post("/api/auth/me/mfa/totp/confirm", { code }),
);
}
async function meta(res: Response) {
return (
(await res.json()) as {
meta?: { errorCode?: string; methods?: string[] };
}
).meta;
}
function codeFor(secret: string): string {
return new OTPAuth.TOTP({
issuer: "HealthLog",
label: "HealthLog",
algorithm: "SHA1",
digits: 6,
period: 30,
secret: OTPAuth.Secret.fromBase32(secret),
}).generate({ timestamp: Date.now() });
}
// ── The household ───────────────────────────────────────────────────────────
/** A Guardian bound to the provider's subject, and the profile they look after. */
async function guardianWithProfile(withTotp: boolean) {
const prisma = getPrismaClient();
const suffix = sequence++;
let secret: string | null = null;
let totp = {};
if (withTotp) {
const { generateTotpSecret } = await import("@/lib/auth/mfa/totp");
const { encrypt } = await import("@/lib/crypto");
secret = generateTotpSecret();
totp = {
totpSecretEncrypted: encrypt(secret),
totpConfirmedAt: new Date(),
totpLastStep: null,
};
}
const guardian = await prisma.user.create({
data: {
username: `guardian-${suffix}`,
email: `guardian-${suffix}@example.test`,
oidcIssuer: ISSUER,
oidcSub: `subject-guardian-${suffix}`,
onboardingCompletedAt: new Date(),
...totp,
},
});
const { profile } = await createManagedProfile({
creatorId: guardian.id,
displayName: "Robin",
dateOfBirth: null,
locale: "en",
timezone: "UTC",
});
return {
guardian,
profileId: profile.id,
secret,
identity: { sub: guardian.oidcSub!, email: guardian.email! },
};
}
// ── Lifecycle ───────────────────────────────────────────────────────────────
let clock = 0;
/** Move the wall clock the app reads, as time passing would. */
function advance(ms: number) {
clock += ms;
vi.setSystemTime(clock);
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
grants.clear();
_resetOidcCacheForTests();
process.env.APP_URL = APP;
process.env.NEXT_PUBLIC_APP_URL = APP;
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";
// Single sign-on only: the report came from such a server, and it is the
// strictest case (no password to fall back on anywhere).
process.env.OIDC_ONLY = "true";
// Only `Date` is faked: the provider round trip and Postgres keep real
// timers, and the codes generated below follow the moved clock.
vi.useFakeTimers({ toFake: ["Date"] });
clock = Date.now();
vi.setSystemTime(clock);
});
afterEach(() => {
vi.useRealTimers();
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("a Guardian signed in through SSO with a second factor in the app", () => {
it("confirms the factor when asked, then hands the profile over", async () => {
const { guardian, profileId, secret, identity } =
await guardianWithProfile(true);
// The callback puts the account through the app's own challenge.
const cb = await signInThroughProvider(identity);
expect([302, 303, 307]).toContain(cb.status);
const handoff = setCookie(cb, "oidc_mfa_handoff");
expect(handoff).toBeDefined();
const { ticket, methods } = JSON.parse(decodeURIComponent(handoff!)) as {
ticket: string;
methods: string[];
};
expect(methods).toContain("totp");
expect(cookieJar.get("healthlog_session")).toBeUndefined();
const verified = await mfaVerify(ticket, codeFor(secret!));
expect(verified.status).toBe(200);
const sessionId = cookieJar.get("healthlog_session");
expect(sessionId).toBeDefined();
const session = await getPrismaClient().session.findFirstOrThrow({
where: { userId: guardian.id },
select: { mfaVerifiedAt: true },
});
expect(session.mfaVerifiedAt).not.toBeNull();
// Ten minutes on, the factor is stale. The refusal is the gate, and it
// names what clears it: the TOTP factor this account holds.
advance(10 * MINUTE);
const refused = await handover(profileId);
expect(refused.status).toBe(401);
expect(await meta(refused)).toMatchObject({
errorCode: "auth.stepup.required",
methods: ["totp"],
});
expect(
await getPrismaClient().managedProfileHandover.count({
where: { profileId },
}),
).toBe(0);
// What the dialog posts.
const proved = await reproof({ method: "totp", code: codeFor(secret!) });
expect(proved.status).toBe(200);
// And the request is sent once more.
const minted = await handover(profileId);
expect(minted.status).toBe(201);
const link = (await minted.json()).data as { url: string };
expect(link.url).toContain("/claim");
expect(
await getPrismaClient().managedProfileHandover.count({
where: { profileId },
}),
).toBe(1);
});
it("does not let the provider's sign-in stand in for the factor", async () => {
// The re-proof dialog never offers "sign in again" to this account: it
// holds a factor, and the gate names it. A fresh SSO sign-in still goes
// through the factor, so it is no shortcut either.
const { profileId, secret, identity } = await guardianWithProfile(true);
const cb = await signInThroughProvider(identity);
const { ticket } = JSON.parse(
decodeURIComponent(setCookie(cb, "oidc_mfa_handoff")!),
) as { ticket: string };
expect((await mfaVerify(ticket, codeFor(secret!))).status).toBe(200);
advance(10 * MINUTE);
const wrong = await reproof({ method: "totp", code: "000000" });
expect(wrong.status).toBe(401);
expect((await handover(profileId)).status).toBe(401);
});
});
describe("a Guardian whose only second factor is the identity provider's", () => {
it("is asked for an app factor, can enrol one at once, and then hands over", async () => {
const { guardian, profileId, identity } = await guardianWithProfile(false);
const cb = await signInThroughProvider(identity);
expect([302, 303, 307]).toContain(cb.status);
expect(cookieJar.get("healthlog_session")).toBeDefined();
// The app cannot see the provider's factor, so the gate asks for one of
// its own — and says so with its own code, which the web turns into
// "set one up under Settings → Security" rather than a dialog.
const refused = await handover(profileId);
expect(refused.status).toBe(401);
expect((await meta(refused))?.errorCode).toBe(
"auth.stepup.mfa_not_enrolled",
);
// Right after signing in, enrolment needs no further proof.
const setup = await totpSetup();
expect(setup.status).toBe(200);
const { totpSecret } = (await setup.json()).data as { totpSecret: string };
const confirmed = await totpConfirm(codeFor(totpSecret));
expect(confirmed.status).toBe(200);
// The confirmation stamped the session, so the handover goes through.
const minted = await handover(profileId);
expect(minted.status).toBe(201);
const row = await getPrismaClient().user.findUniqueOrThrow({
where: { id: guardian.id },
select: { totpConfirmedAt: true },
});
expect(row.totpConfirmedAt).not.toBeNull();
});
it("is told to sign in again when the sign-in is too old to enrol from", async () => {
const { identity } = await guardianWithProfile(false);
await signInThroughProvider(identity);
advance(10 * MINUTE);
// Nothing to re-prove in place: no password, no passkey, no factor. The
// empty list is what makes the web say "sign in again" instead of
// offering a password this account does not have.
const refused = await totpSetup();
expect(refused.status).toBe(401);
expect(await meta(refused)).toMatchObject({
errorCode: "auth.reproof.required",
methods: [],
});
// And signing in again is a way through.
cookieJar.clear();
await signInThroughProvider(identity);
expect((await totpSetup()).status).toBe(200);
});
});
@@ -0,0 +1,235 @@
/**
* #959 — a passkey counts as the second factor on the managed-profile acts.
*
* A Guardian whose only strong credential is a primary passkey (a password
* too, but no TOTP and no security key) used to be refused
* `auth.stepup.mfa_not_enrolled` by `requireFreshMfa`, and so could not hand a
* profile over, create one or manage its guardians. Driven against real
* Postgres through the real routes, with only the WebAuthn signature check
* stubbed:
*
* 1. the password login is unchanged: no MFA challenge, a session straight
* away, and that session is not stamped as a fresh factor;
* 2. the handover refuses with `auth.stepup.required` and offers the passkey
* (not the password) as the way through;
* 3. a password re-proof does not open the gate;
* 4. a passkey re-proof, which is what the dialog posts, does, and the
* handover goes through.
*/
import { NextRequest } from "next/server";
import { 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 { handoverCreateBody } from "@/lib/queries/use-managed-profiles";
process.env.API_TOKEN_HMAC_KEY ??=
"test-hmac-key-managed-profile-passkey-step-up-32-bytes-min";
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/notifications/dispatch-localised", () => ({
dispatchLocalisedNotification: vi.fn(async () => {}),
}));
// The signature check is the one piece a test cannot produce honestly; the
// challenge lookup, the ownership check and the session stamp all run.
vi.mock("@/lib/auth/passkey", async () => {
const actual =
await vi.importActual<typeof import("@/lib/auth/passkey")>(
"@/lib/auth/passkey",
);
return { ...actual, verifyAuthentication: vi.fn() };
});
const { hashPassword } = await import("@/lib/auth/password");
const APP = "http://localhost:3000";
const PASSWORD = "Correct horse battery staple!42";
const EMAIL = "guardian@example.test";
const CREDENTIAL_ID = "guardian-primary-passkey";
type RouteFn = (request: NextRequest) => Promise<Response>;
function post(path: string, body?: unknown): NextRequest {
return new NextRequest(`${APP}${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body ?? {}),
});
}
async function login() {
const { POST } = await import("@/app/api/auth/login/route");
return (POST as unknown as RouteFn)(
post("/api/auth/login", { email: EMAIL, password: PASSWORD }),
);
}
async function reproof(body: unknown) {
const { POST } = await import("@/app/api/auth/reproof/route");
return (POST as unknown as RouteFn)(post("/api/auth/reproof", body));
}
async function handover(profileId: string) {
const { POST } =
await import("@/app/api/managed-profiles/[id]/handover/route");
return POST(
post(
`/api/managed-profiles/${profileId}/handover`,
handoverCreateBody({ profileId, expiresInDays: 7, proposals: [] }),
),
{ params: Promise.resolve({ id: profileId }) },
);
}
async function body(res: Response) {
return (await res.json()) as {
data?: unknown;
meta?: { errorCode?: string; methods?: string[]; mfaRequired?: boolean };
};
}
async function passkeyProof(userId: string) {
const challenge = await getPrismaClient().authChallenge.create({
data: {
userId,
challenge: `proof-${Date.now()}`,
type: "authentication",
expiresAt: new Date(Date.now() + 5 * 60_000),
},
});
return {
method: "passkey",
challengeId: challenge.id,
credential: {
id: CREDENTIAL_ID,
rawId: CREDENTIAL_ID,
type: "public-key",
response: {
clientDataJSON: "client-data",
authenticatorData: "authenticator-data",
signature: "signature",
},
clientExtensionResults: {},
},
};
}
let guardianId = "";
let profileId = "";
beforeEach(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
await prisma.authChallenge.deleteMany();
await prisma.rateLimit.deleteMany();
cookieJar.clear();
headerJar.clear();
process.env.APP_URL = APP;
process.env.NEXT_PUBLIC_APP_URL = APP;
const guardian = await prisma.user.create({
data: {
username: "guardian",
email: EMAIL,
passwordHash: await hashPassword(PASSWORD),
onboardingCompletedAt: new Date(),
},
});
guardianId = guardian.id;
await prisma.passkey.create({
data: {
userId: guardian.id,
credentialId: CREDENTIAL_ID,
credentialPublicKey: Buffer.from([4, 3, 2, 1]),
counter: 0,
credentialDeviceType: "multiDevice",
credentialBackedUp: true,
transports: ["internal"],
},
});
const { profile } = await createManagedProfile({
creatorId: guardian.id,
displayName: "Robin",
dateOfBirth: null,
locale: "en",
timezone: "UTC",
});
profileId = profile.id;
const { verifyAuthentication } = await import("@/lib/auth/passkey");
vi.mocked(verifyAuthentication).mockResolvedValue({
verification: { verified: true },
passkey: { userId: guardian.id },
} as never);
});
describe("a Guardian whose only strong credential is a passkey", () => {
it("signs in with the password exactly as before, without an MFA challenge", async () => {
const res = await login();
expect(res.status).toBe(200);
expect((await body(res)).meta?.mfaRequired).toBeUndefined();
expect(cookieJar.get("healthlog_session")).toBeDefined();
const session = await getPrismaClient().session.findFirstOrThrow({
where: { userId: guardianId },
select: { mfaVerifiedAt: true },
});
// The password login proves no factor, so it cannot open the gate itself.
expect(session.mfaVerifiedAt).toBeNull();
expect(await getPrismaClient().mfaChallenge.count()).toBe(0);
});
it("is offered the passkey, refused on a password, and hands over after the passkey", async () => {
expect((await login()).status).toBe(200);
const refused = await handover(profileId);
expect(refused.status).toBe(401);
expect((await body(refused)).meta).toMatchObject({
errorCode: "auth.stepup.required",
methods: ["passkey"],
});
// A password re-proof is accepted by the endpoint (the recent-proof gate
// still takes it on this account) but stamps no factor, so the handover
// stays shut.
const byPassword = await reproof({
method: "password",
password: PASSWORD,
});
expect(byPassword.status).toBe(200);
expect((await handover(profileId)).status).toBe(401);
// What the dialog posts after the passkey ceremony.
const byPasskey = await reproof(await passkeyProof(guardianId));
expect(byPasskey.status).toBe(200);
const minted = await handover(profileId);
expect(minted.status).toBe(201);
expect(
await getPrismaClient().managedProfileHandover.count({
where: { profileId },
}),
).toBe(1);
});
});