Files
HealthLog/tests/integration/managed-profile-handover-stepup-sso.test.ts
T
Marc-André Bombeck 99f0607a69 fix(auth): let every fresh-factor refusal open the re-proof dialog
A Guardian signed in through single sign-on with a second factor enrolled
could not hand a managed profile over once five minutes had passed since
sign-in: the handover answered 401 auth.stepup.required, and the panel
printed "Confirm your second factor, then try again" with no way to do so.

The same held for every action behind requireFreshMfa: creating, editing
and ending a managed profile, adding and removing a Guardian, disabling
the second factor, rotating recovery codes, removing a security key or a
passkey, changing the password, the data wipe, account deletion and the
key rotation. The recent-proof routes had the dialog; these had text.

- requireFreshMfa names the proofs that clear a stale stamp in
  meta.methods (the possession proofs the account holds, never a
  password, which stamps reproofAt only).
- The re-proof flow treats that refusal like auth.reproof.required:
  open the dialog, post the proof to /api/auth/reproof, send the request
  once more. The flow moves into a React-free module so it can be driven
  in tests; the hook binds it to the dialog.
- The managed-profile mutation hooks take a required proof guard, and
  every caller passes recentProof.run. The other surfaces wrap their
  requests in recentProof.run; disabling the second factor asks before
  its confirmation opens, so the code typed there is not spent twice.
- An account with nothing to re-prove in place (single sign-on only, no
  factor) is told to sign in again when adding a factor, instead of being
  offered a password it does not have.
- step-up-web-caller-guard freezes the gated route methods and fails when
  a web request to one of them runs outside the flow.

An account whose only second factor is the identity provider's is still
asked for an app factor on the managed-profile acts: the app cannot see
the provider's factor. Enrolling right after a sign-in needs no further
proof, and the confirmation stamps the session, so the act follows.

Refs #959
2026-10-17 09:17:03 +02:00

490 lines
17 KiB
TypeScript

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