mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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
490 lines
17 KiB
TypeScript
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);
|
|
});
|
|
});
|