mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
Merge remote-tracking branch 'origin/main' into fix/v1434-illness-symptoms
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user