mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
"Sign out everywhere" ended web sessions and device logins but left every
credential that works without signing in: a connected AI assistant kept its
OAuth connection and minted the next access token from it, a connector or
ingest token kept working, and a clinician share link kept opening the
record. A password change revoked the tokens but not the assistant's
connection or the share links. Anything a stolen session had minted
outlived the remedy.
- Sign out everywhere now ends every other sign-in, every AI-assistant
connection, every programmatic token except the caller's own, and every
clinician share link. Share links are revoked by default; the web offers
a checkbox to keep them (keepShareLinks=1), and a client that sends
nothing, such as the shipped app, gets the safe default.
- A password change and an admin password reset also end AI-assistant
connections, share links and step-up elevations.
- Turning on the first second factor (TOTP or a security key) ends every
other sign-in, as a password change does. Adding a further factor does
not.
destroyOtherSessions takes an explicit reach ("sign-ins" or "everything")
so each caller says how far it goes.
1334 lines
48 KiB
TypeScript
1334 lines
48 KiB
TypeScript
/**
|
|
* Behavioural suite for token-bound step-up elevations, against a real Postgres
|
|
* and the real gate — no mocked Prisma, no mocked auth.
|
|
*
|
|
* This mechanism lets a Bearer token reach a surface that has been cookie-only
|
|
* since v1.23, so the cases below are written as attacks rather than as happy
|
|
* paths. Each one must fail closed if its protection is removed; a green suite
|
|
* over a broken gate would be worse than no suite at all.
|
|
*
|
|
* Two of them need a real database to mean anything. Single-use atomicity is a
|
|
* claim about what Postgres does when two writers race the same row, and the
|
|
* unit mocks cannot produce that race — E3b runs the two redemptions genuinely
|
|
* concurrently against one connection pool. The token binding is a claim about a
|
|
* WHERE clause, and only a real query proves the clause is in it.
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { NextRequest } from "next/server";
|
|
import * as OTPAuth from "otpauth";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
process.env.API_TOKEN_HMAC_KEY ??=
|
|
"test-hmac-key-step-up-elevation-32-bytes-minimum-1234567890";
|
|
|
|
const { hashToken } = await import("@/lib/auth/hmac");
|
|
const { hashPassword } = await import("@/lib/auth/password");
|
|
|
|
const USER_ID = "user-step-up-test";
|
|
const OTHER_USER_ID = "user-step-up-other";
|
|
const PASSWORD = "correct horse battery staple 42";
|
|
|
|
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),
|
|
}));
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
const passwordHash = await hashPassword(PASSWORD);
|
|
await getPrismaClient().user.create({
|
|
data: {
|
|
id: USER_ID,
|
|
username: "step-up",
|
|
email: "step-up@example.test",
|
|
timezone: "UTC",
|
|
passwordHash,
|
|
},
|
|
});
|
|
await getPrismaClient().user.create({
|
|
data: {
|
|
id: OTHER_USER_ID,
|
|
username: "step-up-other",
|
|
email: "step-up-other@example.test",
|
|
timezone: "UTC",
|
|
passwordHash,
|
|
},
|
|
});
|
|
});
|
|
|
|
/** Mint a real wildcard `ApiToken` row. Does NOT arm the header. */
|
|
async function mintToken(
|
|
label: string,
|
|
userId = USER_ID,
|
|
): Promise<{ raw: string; id: string }> {
|
|
const raw = `hlk_${label}${"0".repeat(64 - label.length)}`;
|
|
const row = await getPrismaClient().apiToken.create({
|
|
data: {
|
|
userId,
|
|
name: label,
|
|
tokenHash: hashToken(raw),
|
|
permissions: ["*"],
|
|
},
|
|
select: { id: true },
|
|
});
|
|
return { raw, id: row.id };
|
|
}
|
|
|
|
function useToken(raw: string): void {
|
|
headerJar.set("authorization", `Bearer ${raw}`);
|
|
}
|
|
|
|
function useElevation(raw: string | null): void {
|
|
if (raw === null) headerJar.delete("x-step-up");
|
|
else headerJar.set("x-step-up", raw);
|
|
}
|
|
|
|
function req(path: string, method = "GET", body?: unknown): NextRequest {
|
|
const headers: Record<string, string> = {};
|
|
const auth = headerJar.get("authorization");
|
|
if (auth) headers.authorization = auth;
|
|
const init: RequestInit = { method, headers };
|
|
if (body !== undefined) {
|
|
headers["content-type"] = "application/json";
|
|
init.body = JSON.stringify(body);
|
|
}
|
|
return new NextRequest(`https://health.example${path}`, init as never);
|
|
}
|
|
|
|
/** Drive the mint endpoint with an arbitrary proof body. */
|
|
async function mintWith(
|
|
body: Record<string, unknown>,
|
|
): Promise<{ status: number; body: Record<string, unknown> }> {
|
|
const { POST } = await import("@/app/api/auth/step-up/route");
|
|
const res = await POST(req("/api/auth/step-up", "POST", body));
|
|
return { status: res.status, body: (await res.json()) as never };
|
|
}
|
|
|
|
/** Drive the mint endpoint with a password proof. */
|
|
async function mintElevation(
|
|
password: string,
|
|
): Promise<{ status: number; body: Record<string, unknown> }> {
|
|
return mintWith({ method: "password", password });
|
|
}
|
|
|
|
/**
|
|
* Mint a TOTP-proved elevation the way a real client would: enrol a secret,
|
|
* then present a live code for it. This is the only fresh-factor arm reachable
|
|
* without a WebAuthn authenticator, so it carries the fresh-factor coverage.
|
|
*/
|
|
async function enrolTotp(): Promise<string> {
|
|
const { generateTotpSecret } = await import("@/lib/auth/mfa/totp");
|
|
const { encrypt } = await import("@/lib/crypto");
|
|
const secret = generateTotpSecret();
|
|
await getPrismaClient().user.update({
|
|
where: { id: USER_ID },
|
|
data: {
|
|
totpSecretEncrypted: encrypt(secret),
|
|
totpConfirmedAt: new Date(),
|
|
totpLastStep: null,
|
|
},
|
|
});
|
|
return secret;
|
|
}
|
|
|
|
/** Mirrors the server's TOTP parameters — there is no code generator to import. */
|
|
function currentTotpCode(secretBase32: string): string {
|
|
const totp = new OTPAuth.TOTP({
|
|
issuer: "HealthLog",
|
|
label: "HealthLog",
|
|
algorithm: "SHA1",
|
|
digits: 6,
|
|
period: 30,
|
|
secret: OTPAuth.Secret.fromBase32(secretBase32),
|
|
});
|
|
return totp.generate({ timestamp: Date.now() });
|
|
}
|
|
|
|
async function mintTotpElevation(
|
|
secret: string,
|
|
): Promise<{ status: number; body: Record<string, unknown> }> {
|
|
return mintWith({ method: "totp", code: currentTotpCode(secret) });
|
|
}
|
|
|
|
/**
|
|
* Seed a known recovery-code batch and return the plaintext.
|
|
*
|
|
* The disable route wants a live factor in its BODY on top of the step-up. A
|
|
* TOTP code spent minting the elevation cannot be reused there — the shared
|
|
* verifier burns its time-step, by design — so within one 30-second window the
|
|
* body factor has to be something else. That is exactly how the web behaves
|
|
* too.
|
|
*/
|
|
async function seedRecoveryCodes(): Promise<string[]> {
|
|
const { regenerateRecoveryCodes } =
|
|
await import("@/lib/auth/mfa/recovery-codes");
|
|
return regenerateRecoveryCodes(USER_ID);
|
|
}
|
|
|
|
/** Pull the raw elevation out of a successful mint. */
|
|
function elevationOf(result: { body: Record<string, unknown> }): string {
|
|
return (result.body.data as { elevation: string }).elevation;
|
|
}
|
|
|
|
/**
|
|
* Some handlers in the set declare no request parameter (`apiHandler(async () =>
|
|
* …)`), so their wrapped type takes none. The runtime always passes one; this
|
|
* keeps the call sites honest without loosening the handlers themselves.
|
|
*/
|
|
type RouteFn = (request: NextRequest) => Promise<Response>;
|
|
|
|
/**
|
|
* POST /api/auth/me/mfa/totp/setup — the cheapest MUTATION in the
|
|
* elevation-accepting set, and the probe every redemption case drives.
|
|
*
|
|
* NOT `GET /api/auth/me/mfa`: that read is plain `requireAuth()` now, so using
|
|
* it here would have quietly stopped exercising the elevation path at all.
|
|
*/
|
|
async function callMfaSetup(): Promise<Response> {
|
|
const { POST } = await import("@/app/api/auth/me/mfa/totp/setup/route");
|
|
return await POST(req("/api/auth/me/mfa/totp/setup", "POST"));
|
|
}
|
|
|
|
/** The status read — plain Bearer, no elevation. */
|
|
async function callMfaStatus(): Promise<Response> {
|
|
const { GET } = await import("@/app/api/auth/me/mfa/route");
|
|
return await (GET as unknown as RouteFn)(req("/api/auth/me/mfa"));
|
|
}
|
|
|
|
async function auditReasons(action: string): Promise<string[]> {
|
|
const rows = await getPrismaClient().auditLog.findMany({
|
|
where: { action },
|
|
orderBy: { createdAt: "asc" },
|
|
});
|
|
return rows.map(
|
|
(r) => (JSON.parse(r.details ?? "{}") as { reason?: string }).reason ?? "",
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Poll until an audit reason lands, instead of sleeping a fixed span and hoping.
|
|
*
|
|
* The audit write is fire-and-forget, so the old `setTimeout(r, 100)` before
|
|
* reading the ledger was a race the assertion loses whenever the runner is
|
|
* busy — which is exactly when CI is slowest and least willing to explain
|
|
* itself. The failure looked like a broken refusal ("expected [] to include
|
|
* 'consumed'") when the refusal had in fact worked and only its breadcrumb was
|
|
* still in flight. On timeout this asserts against the real list, so a genuine
|
|
* regression still reports what WAS written rather than a bare timeout.
|
|
*/
|
|
async function expectAuditReason(
|
|
action: string,
|
|
reason: string,
|
|
timeoutMs = 5_000,
|
|
): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
let seen: string[] = [];
|
|
while (Date.now() < deadline) {
|
|
seen = await auditReasons(action);
|
|
if (seen.includes(reason)) return;
|
|
await new Promise((r) => setTimeout(r, 25));
|
|
}
|
|
expect(seen).toContain(reason);
|
|
}
|
|
|
|
// ── E1 — a stolen token, on its own, gains nothing ───────────────────
|
|
|
|
describe("E1 — the token alone is not a step-up proof", () => {
|
|
it("refuses an MFA-management call with no elevation header", async () => {
|
|
const { raw } = await mintToken("e1a");
|
|
useToken(raw);
|
|
useElevation(null);
|
|
|
|
const res = await callMfaSetup();
|
|
|
|
// 401 with the machine code the client branches on to launch a re-proof —
|
|
// not a 200, and not the 403 an unauthorised token would get.
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("mints nothing when the body carries no factor proof", async () => {
|
|
const { raw } = await mintToken("e1b");
|
|
useToken(raw);
|
|
|
|
const { POST } = await import("@/app/api/auth/step-up/route");
|
|
const res = await POST(req("/api/auth/step-up", "POST", {}));
|
|
|
|
expect(res.status).toBe(422);
|
|
expect(await getPrismaClient().stepUpElevation.count()).toBe(0);
|
|
});
|
|
|
|
it("refuses a fabricated elevation value", async () => {
|
|
const { raw } = await mintToken("e1c");
|
|
useToken(raw);
|
|
useElevation(`hle_${"f".repeat(64)}`);
|
|
|
|
expect((await callMfaSetup()).status).toBe(401);
|
|
await expectAuditReason("auth.stepup.elevation.rejected", "unknown");
|
|
});
|
|
});
|
|
|
|
// ── E2 — the elevation is bound to one token ─────────────────────────
|
|
|
|
describe("E2 — an elevation is not portable between tokens", () => {
|
|
it("refuses redemption by a second token of the SAME user", async () => {
|
|
const a = await mintToken("e2a");
|
|
const b = await mintToken("e2b");
|
|
|
|
useToken(a.raw);
|
|
const minted = await mintElevation(PASSWORD);
|
|
expect(minted.status).toBe(200);
|
|
const elevation = (minted.body.data as { elevation: string }).elevation;
|
|
|
|
// Same account, same scopes, different token row. The binding is the only
|
|
// thing standing between these two, which is exactly the point.
|
|
useToken(b.raw);
|
|
useElevation(elevation);
|
|
const res = await callMfaSetup();
|
|
|
|
expect(res.status).toBe(401);
|
|
await expectAuditReason("auth.stepup.elevation.rejected", "wrong_token");
|
|
|
|
// And the legitimate holder's elevation survived the attempt.
|
|
const row = await getPrismaClient().stepUpElevation.findFirst();
|
|
expect(row?.consumedAt).toBeNull();
|
|
|
|
useToken(a.raw);
|
|
useElevation(elevation);
|
|
expect((await callMfaSetup()).status).toBe(200);
|
|
});
|
|
|
|
it("refuses redemption by another user's token", async () => {
|
|
const mine = await mintToken("e2c");
|
|
const theirs = await mintToken("e2d", OTHER_USER_ID);
|
|
|
|
useToken(mine.raw);
|
|
const elevation = (
|
|
(await mintElevation(PASSWORD)).body.data as { elevation: string }
|
|
).elevation;
|
|
|
|
useToken(theirs.raw);
|
|
useElevation(elevation);
|
|
|
|
expect((await callMfaSetup()).status).toBe(401);
|
|
});
|
|
});
|
|
|
|
// ── E3 — single use, including under concurrency ─────────────────────
|
|
|
|
describe("E3 — an elevation is consumed exactly once", () => {
|
|
it("refuses a second sequential redemption", async () => {
|
|
const { raw } = await mintToken("e3a");
|
|
useToken(raw);
|
|
const elevation = (
|
|
(await mintElevation(PASSWORD)).body.data as { elevation: string }
|
|
).elevation;
|
|
|
|
useElevation(elevation);
|
|
expect((await callMfaSetup()).status).toBe(200);
|
|
expect((await callMfaSetup()).status).toBe(401);
|
|
|
|
await expectAuditReason("auth.stepup.elevation.rejected", "consumed");
|
|
});
|
|
|
|
it("lets exactly one of two CONCURRENT redemptions win", async () => {
|
|
const { raw, id } = await mintToken("e3b");
|
|
useToken(raw);
|
|
const elevation = (
|
|
(await mintElevation(PASSWORD)).body.data as { elevation: string }
|
|
).elevation;
|
|
|
|
// Straight at the redemption primitive, so the race is between two real
|
|
// conditional UPDATEs on one row rather than between two HTTP handlers that
|
|
// happen to interleave. A check-then-update implementation passes the
|
|
// sequential case above and fails here.
|
|
const { claimStepUpElevation } = await import("@/lib/auth/step-up");
|
|
const attempt = () =>
|
|
claimStepUpElevation({
|
|
rawToken: elevation,
|
|
userId: USER_ID,
|
|
apiTokenId: id,
|
|
requireFreshFactor: false,
|
|
});
|
|
|
|
const results: Awaited<ReturnType<typeof attempt>>[] = await Promise.all([
|
|
attempt(),
|
|
attempt(),
|
|
attempt(),
|
|
attempt(),
|
|
]);
|
|
|
|
expect(results.filter((r) => r.ok)).toHaveLength(1);
|
|
expect(results.filter((r) => !r.ok)).toHaveLength(3);
|
|
});
|
|
});
|
|
|
|
// ── E4 — expiry ──────────────────────────────────────────────────────
|
|
|
|
describe("E4 — an expired elevation is refused", () => {
|
|
it("refuses one whose window has passed", async () => {
|
|
const { raw, id } = await mintToken("e4a");
|
|
useToken(raw);
|
|
const elevation = (
|
|
(await mintElevation(PASSWORD)).body.data as { elevation: string }
|
|
).elevation;
|
|
|
|
// Age the row rather than the clock: the expiry lives in the redemption's
|
|
// WHERE clause, so this is the honest way to exercise it.
|
|
await getPrismaClient().stepUpElevation.updateMany({
|
|
where: { apiTokenId: id },
|
|
data: { expiresAt: new Date(Date.now() - 1_000) },
|
|
});
|
|
|
|
useElevation(elevation);
|
|
expect((await callMfaSetup()).status).toBe(401);
|
|
|
|
await expectAuditReason("auth.stepup.elevation.rejected", "expired");
|
|
});
|
|
|
|
it("mints a five-minute window, matching the cookie step-up", async () => {
|
|
const { raw } = await mintToken("e4b");
|
|
useToken(raw);
|
|
const { body } = await mintElevation(PASSWORD);
|
|
const data = body.data as { expiresInSeconds: number };
|
|
|
|
const { MFA_STEP_UP_MAX_AGE_SECONDS } = await import("@/lib/api-handler");
|
|
expect(data.expiresInSeconds).toBe(MFA_STEP_UP_MAX_AGE_SECONDS);
|
|
});
|
|
});
|
|
|
|
// ── E5 — the elevation cannot reach admin ────────────────────────────
|
|
|
|
describe("E5 — an elevation does not satisfy requireAdmin", () => {
|
|
it("refuses an admin route even for an ADMIN user holding a valid elevation", async () => {
|
|
await getPrismaClient().user.update({
|
|
where: { id: USER_ID },
|
|
data: { role: "ADMIN" },
|
|
});
|
|
|
|
const { raw } = await mintToken("e5a");
|
|
useToken(raw);
|
|
const elevation = (
|
|
(await mintElevation(PASSWORD)).body.data as { elevation: string }
|
|
).elevation;
|
|
useElevation(elevation);
|
|
|
|
const { GET } = await import("@/app/api/admin/users/route");
|
|
const res = await (GET as unknown as RouteFn)(req("/api/admin/users"));
|
|
|
|
// Cookie-only, and the elevation is not a cookie. The account being a
|
|
// genuine admin is what makes this test worth having: the refusal is the
|
|
// transport, not the role.
|
|
expect(res.status).toBe(401);
|
|
|
|
// Unconsumed — the admin gate never even looked at it.
|
|
const row = await getPrismaClient().stepUpElevation.findFirst();
|
|
expect(row?.consumedAt).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── E6 — a wrong password mints nothing, is throttled, is audited ────
|
|
|
|
describe("E6 — the mint refuses, throttles, and records a bad password", () => {
|
|
it("returns a generic 401 and writes no row", async () => {
|
|
const { raw } = await mintToken("e6a");
|
|
useToken(raw);
|
|
|
|
const res = await mintElevation("not the password");
|
|
|
|
expect(res.status).toBe(401);
|
|
// Same prose for every cause — the response must not say whether the
|
|
// password was wrong or the account has none.
|
|
expect(res.body.error).toBe("Verification failed");
|
|
expect(await getPrismaClient().stepUpElevation.count()).toBe(0);
|
|
});
|
|
|
|
it("is indistinguishable from an account with no password set", async () => {
|
|
await getPrismaClient().user.update({
|
|
where: { id: USER_ID },
|
|
data: { passwordHash: null },
|
|
});
|
|
const { raw } = await mintToken("e6b");
|
|
useToken(raw);
|
|
|
|
const res = await mintElevation("anything at all");
|
|
|
|
expect(res.status).toBe(401);
|
|
expect(res.body.error).toBe("Verification failed");
|
|
});
|
|
|
|
it("audits the failure with the real reason", async () => {
|
|
const { raw } = await mintToken("e6c");
|
|
useToken(raw);
|
|
await mintElevation("wrong");
|
|
|
|
const rows = await getPrismaClient().auditLog.findMany({
|
|
where: { action: "auth.stepup.mint.failed" },
|
|
});
|
|
expect(rows).toHaveLength(1);
|
|
expect(JSON.parse(rows[0].details ?? "{}")).toMatchObject({
|
|
method: "password",
|
|
reason: "bad_password",
|
|
});
|
|
});
|
|
|
|
it("throttles a guessing run", async () => {
|
|
const { raw } = await mintToken("e6d");
|
|
useToken(raw);
|
|
|
|
const statuses: number[] = [];
|
|
for (let i = 0; i < 7; i++) {
|
|
statuses.push((await mintElevation(`guess-${i}`)).status);
|
|
}
|
|
|
|
// Five attempts per fifteen minutes, then the bucket closes — the same
|
|
// ceiling the password-change route uses, for the same Argon2id reason.
|
|
expect(statuses.filter((s) => s === 401)).toHaveLength(5);
|
|
expect(statuses.filter((s) => s === 429)).toHaveLength(2);
|
|
|
|
// And the throttle holds even once the caller finds the right password.
|
|
expect((await mintElevation(PASSWORD)).status).toBe(429);
|
|
expect(await getPrismaClient().stepUpElevation.count()).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ── E7 — the web path is untouched ───────────────────────────────────
|
|
|
|
describe("E7 — the cookie flow is unchanged", () => {
|
|
async function useCookieSession(userId = USER_ID): Promise<string> {
|
|
const session = await getPrismaClient().session.create({
|
|
data: { userId, expiresAt: new Date(Date.now() + 3_600_000) },
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
return session.id;
|
|
}
|
|
|
|
it("serves an MFA-management mutation on a plain cookie session", async () => {
|
|
await useCookieSession();
|
|
expect((await callMfaSetup()).status).toBe(200);
|
|
});
|
|
|
|
it("still refuses a step-up-gated route on a cookie with no fresh factor", async () => {
|
|
await useCookieSession();
|
|
const { POST } =
|
|
await import("@/app/api/auth/me/mfa/recovery-codes/regenerate/route");
|
|
const res = await POST(
|
|
req("/api/auth/me/mfa/recovery-codes/regenerate", "POST"),
|
|
);
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("admits a step-up-gated route on a freshly verified cookie session", async () => {
|
|
const sessionId = await useCookieSession();
|
|
await getPrismaClient().user.update({
|
|
where: { id: USER_ID },
|
|
data: { totpConfirmedAt: new Date() },
|
|
});
|
|
await getPrismaClient().session.update({
|
|
where: { id: sessionId },
|
|
data: { mfaVerifiedAt: new Date() },
|
|
});
|
|
|
|
const { POST } =
|
|
await import("@/app/api/auth/me/mfa/recovery-codes/regenerate/route");
|
|
const res = await POST(
|
|
req("/api/auth/me/mfa/recovery-codes/regenerate", "POST"),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it("ignores an elevation header when a cookie session is present", async () => {
|
|
// The cookie branch returns before the header is ever read. Proven by the
|
|
// elevation surviving unconsumed.
|
|
const { raw, id } = await mintToken("e7a");
|
|
useToken(raw);
|
|
const elevation = (
|
|
(await mintElevation(PASSWORD)).body.data as { elevation: string }
|
|
).elevation;
|
|
|
|
await useCookieSession();
|
|
useElevation(elevation);
|
|
expect((await callMfaSetup()).status).toBe(200);
|
|
|
|
const row = await getPrismaClient().stepUpElevation.findFirst({
|
|
where: { apiTokenId: id },
|
|
});
|
|
expect(row?.consumedAt).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── E8 — invalidation on credential rotation ─────────────────────────
|
|
|
|
describe("E8 — an elevation does not outlive its anchor", () => {
|
|
it("is dropped by a real password change, through the route", async () => {
|
|
// Driven through POST /api/auth/password rather than by calling the
|
|
// revocation helper. Calling the helper directly proved only that the helper
|
|
// deletes rows — deleting the call from BOTH password routes left the old
|
|
// version of this test green, so the invalidation story was uncovered.
|
|
const { raw } = await mintToken("e8a");
|
|
useToken(raw);
|
|
const elevation = elevationOf(await mintElevation(PASSWORD));
|
|
expect(await getPrismaClient().stepUpElevation.count()).toBe(1);
|
|
|
|
const NEW_PASSWORD = "Zt7#qvbLm2xR!e9Wd4Kp";
|
|
const { POST } = await import("@/app/api/auth/password/route");
|
|
const res = await POST(
|
|
req("/api/auth/password", "POST", {
|
|
currentPassword: PASSWORD,
|
|
newPassword: NEW_PASSWORD,
|
|
confirmPassword: NEW_PASSWORD,
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
|
|
expect(await getPrismaClient().stepUpElevation.count()).toBe(0);
|
|
|
|
// And the value the client still holds is dead. The password route mints a
|
|
// replacement cookie session, which the shared jar would otherwise pick up
|
|
// and satisfy the gate on the cookie arm — clear it so this probes the
|
|
// Bearer arm, which is the one under test.
|
|
cookieJar.clear();
|
|
useElevation(elevation);
|
|
expect((await callMfaSetup()).status).toBe(401);
|
|
});
|
|
|
|
it("is dropped by an operator-forced reset, through the admin route", async () => {
|
|
const { raw } = await mintToken("e8a2");
|
|
useToken(raw);
|
|
await mintElevation(PASSWORD);
|
|
expect(await getPrismaClient().stepUpElevation.count()).toBe(1);
|
|
|
|
// The admin route is cookie-only, so the reset arrives on an ADMIN session
|
|
// while the elevation belongs to the target's token.
|
|
await getPrismaClient().user.update({
|
|
where: { id: OTHER_USER_ID },
|
|
data: { role: "ADMIN" },
|
|
});
|
|
const adminSession = await getPrismaClient().session.create({
|
|
data: {
|
|
userId: OTHER_USER_ID,
|
|
expiresAt: new Date(Date.now() + 3_600_000),
|
|
},
|
|
});
|
|
cookieJar.set("healthlog_session", adminSession.id);
|
|
headerJar.delete("authorization");
|
|
|
|
const { POST } =
|
|
await import("@/app/api/admin/users/[id]/reset-password/route");
|
|
const res = await POST(
|
|
req(`/api/admin/users/${USER_ID}/reset-password`, "POST", {
|
|
password: "operator chosen long passphrase 77",
|
|
}),
|
|
{ params: Promise.resolve({ id: USER_ID }) },
|
|
);
|
|
expect(res.status).toBe(200);
|
|
|
|
expect(await getPrismaClient().stepUpElevation.count()).toBe(0);
|
|
});
|
|
|
|
it("is dropped by sign-out-everywhere", async () => {
|
|
const { raw } = await mintToken("e8a3");
|
|
useToken(raw);
|
|
await mintElevation(PASSWORD);
|
|
|
|
const session = await getPrismaClient().session.create({
|
|
data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) },
|
|
});
|
|
const { destroyOtherSessions } = await import("@/lib/auth/session");
|
|
await destroyOtherSessions(
|
|
USER_ID,
|
|
{ kind: "session", sessionId: session.id },
|
|
{ reach: "everything" },
|
|
);
|
|
|
|
expect(await getPrismaClient().stepUpElevation.count()).toBe(0);
|
|
});
|
|
|
|
it("is unredeemable once the bound token is revoked", async () => {
|
|
const { raw, id } = await mintToken("e8b");
|
|
useToken(raw);
|
|
const elevation = (
|
|
(await mintElevation(PASSWORD)).body.data as { elevation: string }
|
|
).elevation;
|
|
|
|
await getPrismaClient().apiToken.update({
|
|
where: { id },
|
|
data: { revoked: true },
|
|
});
|
|
|
|
useElevation(elevation);
|
|
// Refused at Bearer resolution, before the elevation is even looked at —
|
|
// which is why no code in the revoke path needs to know about elevations.
|
|
expect((await callMfaSetup()).status).toBe(401);
|
|
|
|
const row = await getPrismaClient().stepUpElevation.findFirst();
|
|
expect(row?.consumedAt).toBeNull();
|
|
});
|
|
|
|
it("is cascade-deleted with the token row", async () => {
|
|
const { raw, id } = await mintToken("e8c");
|
|
useToken(raw);
|
|
await mintElevation(PASSWORD);
|
|
|
|
await getPrismaClient().apiToken.delete({ where: { id } });
|
|
|
|
expect(await getPrismaClient().stepUpElevation.count()).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ── E9 — the happy path actually works end to end ────────────────────
|
|
|
|
describe("E9 — a native client can manage its second factor", () => {
|
|
it("mints, redeems, and enrols TOTP", async () => {
|
|
const { raw } = await mintToken("e9a");
|
|
useToken(raw);
|
|
const elevation = (
|
|
(await mintElevation(PASSWORD)).body.data as { elevation: string }
|
|
).elevation;
|
|
|
|
useElevation(elevation);
|
|
const { POST } = await import("@/app/api/auth/me/mfa/totp/setup/route");
|
|
const res = await POST(req("/api/auth/me/mfa/totp/setup", "POST"));
|
|
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as {
|
|
data: { totpSecret: string; otpauthUri: string };
|
|
};
|
|
expect(body.data.totpSecret).toBeTruthy();
|
|
expect(body.data.otpauthUri).toMatch(/^otpauth:\/\//);
|
|
|
|
// The pending secret landed, and the elevation is spent — a second action
|
|
// needs a second proof.
|
|
const user = await getPrismaClient().user.findUnique({
|
|
where: { id: USER_ID },
|
|
});
|
|
expect(user?.totpSecretEncrypted).toBeTruthy();
|
|
expect(user?.totpConfirmedAt).toBeNull();
|
|
expect((await callMfaSetup()).status).toBe(401);
|
|
});
|
|
|
|
it("refuses the mint surface to a cookie session", async () => {
|
|
const session = await getPrismaClient().session.create({
|
|
data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) },
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
headerJar.delete("authorization");
|
|
|
|
const { POST } = await import("@/app/api/auth/step-up/route");
|
|
const res = await POST(
|
|
req("/api/auth/step-up", "POST", {
|
|
method: "password",
|
|
password: PASSWORD,
|
|
}),
|
|
);
|
|
|
|
expect(res.status).toBe(401);
|
|
expect(await getPrismaClient().stepUpElevation.count()).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ── E10 — the fresh-factor rule (B1) ─────────────────────────────────
|
|
|
|
describe("E10 — a password proof cannot reach the destructive routes", () => {
|
|
/**
|
|
* The attack this closes, in the order it ran before the fix:
|
|
* 1. mint an elevation with the account password (a stolen token plus a
|
|
* known password is the whole prerequisite);
|
|
* 2. POST recovery-codes/regenerate — which takes NO factor in its body —
|
|
* and receive ten plaintext recovery codes;
|
|
* 3. POST disable with one of them, satisfying the body check.
|
|
* The second factor is gone. On the web that chain is impossible because a
|
|
* password login never stamps `mfaVerifiedAt`.
|
|
*/
|
|
async function regenerate(): Promise<Response> {
|
|
const { POST } =
|
|
await import("@/app/api/auth/me/mfa/recovery-codes/regenerate/route");
|
|
return POST(req("/api/auth/me/mfa/recovery-codes/regenerate", "POST"));
|
|
}
|
|
|
|
async function disable(code: string, method = "recovery"): Promise<Response> {
|
|
const { POST } = await import("@/app/api/auth/me/mfa/disable/route");
|
|
return POST(req("/api/auth/me/mfa/disable", "POST", { code, method }));
|
|
}
|
|
|
|
async function removeKey(id: string): Promise<Response> {
|
|
const { DELETE } =
|
|
await import("@/app/api/auth/me/mfa/webauthn/[id]/route");
|
|
return DELETE(req(`/api/auth/me/mfa/webauthn/${id}`, "DELETE"), {
|
|
params: Promise.resolve({ id }),
|
|
});
|
|
}
|
|
|
|
it("refuses recovery-code regeneration on a password-proved elevation", async () => {
|
|
await enrolTotp();
|
|
const { raw } = await mintToken("e10a");
|
|
useToken(raw);
|
|
useElevation(elevationOf(await mintElevation(PASSWORD)));
|
|
|
|
const res = await regenerate();
|
|
|
|
expect(res.status).toBe(401);
|
|
// No codes were minted, so step 2 of the chain yields nothing.
|
|
expect(await getPrismaClient().mfaRecoveryCode.count()).toBe(0);
|
|
await expectAuditReason(
|
|
"auth.stepup.elevation.rejected",
|
|
"insufficient_factor",
|
|
);
|
|
});
|
|
|
|
it("refuses disable on a password-proved elevation", async () => {
|
|
const secret = await enrolTotp();
|
|
const { raw } = await mintToken("e10b");
|
|
useToken(raw);
|
|
useElevation(elevationOf(await mintElevation(PASSWORD)));
|
|
|
|
const res = await disable(currentTotpCode(secret), "totp");
|
|
|
|
expect(res.status).toBe(401);
|
|
const user = await getPrismaClient().user.findUnique({
|
|
where: { id: USER_ID },
|
|
});
|
|
expect(user?.totpConfirmedAt).not.toBeNull();
|
|
});
|
|
|
|
it("refuses security-key removal on a password-proved elevation", async () => {
|
|
const key = await getPrismaClient().webauthnMfaCredential.create({
|
|
data: {
|
|
userId: USER_ID,
|
|
name: "key",
|
|
credentialId: "cred-e10c",
|
|
credentialPublicKey: Buffer.from([1, 2, 3]),
|
|
transports: ["internal"],
|
|
},
|
|
});
|
|
const { raw } = await mintToken("e10c");
|
|
useToken(raw);
|
|
useElevation(elevationOf(await mintElevation(PASSWORD)));
|
|
|
|
expect((await removeKey(key.id)).status).toBe(401);
|
|
expect(await getPrismaClient().webauthnMfaCredential.count()).toBe(1);
|
|
});
|
|
|
|
it("refuses at the CLAIM too, not only at the validation", async () => {
|
|
// Defence in depth, and it needs its own test: the gate validates before it
|
|
// claims, so removing the check inside `claimStepUpElevation` leaves every
|
|
// route-level case above green. Driven straight at the primitive.
|
|
const { raw, id } = await mintToken("e10i");
|
|
useToken(raw);
|
|
const elevation = elevationOf(await mintElevation(PASSWORD));
|
|
|
|
const { claimStepUpElevation } = await import("@/lib/auth/step-up");
|
|
const claimed = await claimStepUpElevation({
|
|
rawToken: elevation,
|
|
userId: USER_ID,
|
|
apiTokenId: id,
|
|
requireFreshFactor: true,
|
|
});
|
|
|
|
expect(claimed).toEqual({ ok: false, reason: "insufficient_factor" });
|
|
});
|
|
|
|
it("does NOT burn the elevation when the factor is too weak", async () => {
|
|
// The refusal is about reach, not validity. The same elevation must still
|
|
// work on the routes it was always entitled to.
|
|
await enrolTotp();
|
|
const { raw } = await mintToken("e10d");
|
|
useToken(raw);
|
|
const elevation = elevationOf(await mintElevation(PASSWORD));
|
|
|
|
useElevation(elevation);
|
|
expect((await regenerate()).status).toBe(401);
|
|
|
|
const row = await getPrismaClient().stepUpElevation.findFirst();
|
|
expect(row?.consumedAt).toBeNull();
|
|
});
|
|
|
|
it("admits the SAME routes a plain cookie session reaches", async () => {
|
|
// The password arm is not useless — it is exactly cookie-equivalent.
|
|
const { raw } = await mintToken("e10e");
|
|
useToken(raw);
|
|
useElevation(elevationOf(await mintElevation(PASSWORD)));
|
|
|
|
expect((await callMfaSetup()).status).toBe(200);
|
|
});
|
|
|
|
it("admits the destructive routes on a TOTP-proved elevation", async () => {
|
|
const secret = await enrolTotp();
|
|
const { raw } = await mintToken("e10f");
|
|
useToken(raw);
|
|
|
|
const minted = await mintTotpElevation(secret);
|
|
expect(minted.status).toBe(200);
|
|
expect(
|
|
(minted.body.data as { satisfiesFreshFactor: boolean })
|
|
.satisfiesFreshFactor,
|
|
).toBe(true);
|
|
|
|
useElevation(elevationOf(minted));
|
|
const res = await regenerate();
|
|
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as {
|
|
data: { recoveryCodes: string[] };
|
|
};
|
|
expect(body.data.recoveryCodes.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("marks a password-proved elevation as not fresh-factor in the mint response", async () => {
|
|
const { raw } = await mintToken("e10g");
|
|
useToken(raw);
|
|
const minted = await mintElevation(PASSWORD);
|
|
const data = minted.body.data as {
|
|
method: string;
|
|
satisfiesFreshFactor: boolean;
|
|
};
|
|
expect(data.method).toBe("password");
|
|
expect(data.satisfiesFreshFactor).toBe(false);
|
|
});
|
|
|
|
it("refuses a stale TOTP code and does not mint", async () => {
|
|
const secret = await enrolTotp();
|
|
const { raw } = await mintToken("e10h");
|
|
useToken(raw);
|
|
|
|
// Spend the code through the shared verifier first; the replay guard must
|
|
// then refuse the same code here, exactly as it does at login.
|
|
const code = currentTotpCode(secret);
|
|
const { verifyMfaFactor } = await import("@/lib/auth/mfa/verify-factor");
|
|
const user = await getPrismaClient().user.findUnique({
|
|
where: { id: USER_ID },
|
|
});
|
|
await verifyMfaFactor(user!, "totp", code);
|
|
|
|
const res = await mintWith({ method: "totp", code });
|
|
|
|
expect(res.status).toBe(401);
|
|
expect(await getPrismaClient().stepUpElevation.count()).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ── E11 — the disable route keeps the caller signed in (B2) ──────────
|
|
|
|
describe("E11 — disabling MFA over Bearer spares the calling device", () => {
|
|
it("revokes every OTHER refresh token but not the caller's own", async () => {
|
|
const secret = await enrolTotp();
|
|
const { raw, id } = await mintToken("e11a");
|
|
|
|
const { hashToken } = await import("@/lib/auth/hmac");
|
|
// The caller's own device login: the RefreshToken row cross-referencing the
|
|
// access token being presented.
|
|
const mine = await getPrismaClient().refreshToken.create({
|
|
data: {
|
|
userId: USER_ID,
|
|
tokenHash: hashToken(`hlr_mine${"0".repeat(56)}`),
|
|
accessTokenHash: hashToken(raw),
|
|
expiresAt: new Date(Date.now() + 30 * 24 * 3_600_000),
|
|
},
|
|
});
|
|
// Another device.
|
|
const theirs = await getPrismaClient().refreshToken.create({
|
|
data: {
|
|
userId: USER_ID,
|
|
tokenHash: hashToken(`hlr_theirs${"0".repeat(54)}`),
|
|
accessTokenHash: hashToken(`hlk_other${"0".repeat(55)}`),
|
|
expiresAt: new Date(Date.now() + 30 * 24 * 3_600_000),
|
|
},
|
|
});
|
|
// And a browser session, which has no "current" on the Bearer path.
|
|
const webSession = await getPrismaClient().session.create({
|
|
data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) },
|
|
});
|
|
|
|
const codes = await seedRecoveryCodes();
|
|
useToken(raw);
|
|
useElevation(elevationOf(await mintTotpElevation(secret)));
|
|
|
|
const { POST } = await import("@/app/api/auth/me/mfa/disable/route");
|
|
const res = await POST(
|
|
req("/api/auth/me/mfa/disable", "POST", {
|
|
code: codes[0],
|
|
method: "recovery",
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
|
|
// The defect: passing the ApiToken id where a Session id was expected made
|
|
// the "keep the current one" exclusion match nothing, so the caller's own
|
|
// refresh token was revoked and the app logged itself out at next rotation.
|
|
const mineAfter = await getPrismaClient().refreshToken.findUnique({
|
|
where: { id: mine.id },
|
|
});
|
|
const theirsAfter = await getPrismaClient().refreshToken.findUnique({
|
|
where: { id: theirs.id },
|
|
});
|
|
expect(mineAfter?.revokedAt).toBeNull();
|
|
expect(theirsAfter?.revokedAt).not.toBeNull();
|
|
|
|
// Web sessions all go — none of them is the caller.
|
|
expect(
|
|
await getPrismaClient().session.findUnique({
|
|
where: { id: webSession.id },
|
|
}),
|
|
).toBeNull();
|
|
|
|
// And the calling token itself is untouched, so the next request works.
|
|
const token = await getPrismaClient().apiToken.findUnique({
|
|
where: { id },
|
|
});
|
|
expect(token?.revoked).toBe(false);
|
|
});
|
|
|
|
it("keeps the caller's own session on the cookie path, as before", async () => {
|
|
const secret = await enrolTotp();
|
|
const current = await getPrismaClient().session.create({
|
|
data: {
|
|
userId: USER_ID,
|
|
expiresAt: new Date(Date.now() + 3_600_000),
|
|
mfaVerifiedAt: new Date(),
|
|
},
|
|
});
|
|
const other = await getPrismaClient().session.create({
|
|
data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) },
|
|
});
|
|
cookieJar.set("healthlog_session", current.id);
|
|
headerJar.delete("authorization");
|
|
|
|
const { POST } = await import("@/app/api/auth/me/mfa/disable/route");
|
|
const res = await POST(
|
|
req("/api/auth/me/mfa/disable", "POST", {
|
|
code: currentTotpCode(secret),
|
|
method: "totp",
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
|
|
expect(
|
|
await getPrismaClient().session.findUnique({ where: { id: current.id } }),
|
|
).not.toBeNull();
|
|
expect(
|
|
await getPrismaClient().session.findUnique({ where: { id: other.id } }),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── E12 — validation failures do not burn the proof ──────────────────
|
|
|
|
describe("E12 — a rejected request keeps the elevation spendable", () => {
|
|
it("survives a wrong TOTP code on disable", async () => {
|
|
const secret = await enrolTotp();
|
|
const codes = await seedRecoveryCodes();
|
|
const { raw } = await mintToken("e12a");
|
|
useToken(raw);
|
|
const elevation = elevationOf(await mintTotpElevation(secret));
|
|
|
|
useElevation(elevation);
|
|
const { POST } = await import("@/app/api/auth/me/mfa/disable/route");
|
|
const bad = await POST(
|
|
req("/api/auth/me/mfa/disable", "POST", {
|
|
code: "000000",
|
|
method: "totp",
|
|
}),
|
|
);
|
|
expect(bad.status).toBe(401);
|
|
|
|
// Unspent — otherwise five fat-fingered codes would exhaust the 5-per-15-min
|
|
// mint ceiling and lock the user out of their own security settings.
|
|
const row = await getPrismaClient().stepUpElevation.findFirst();
|
|
expect(row?.consumedAt).toBeNull();
|
|
|
|
// And the retry goes through on the SAME elevation.
|
|
const good = await POST(
|
|
req("/api/auth/me/mfa/disable", "POST", {
|
|
code: codes[0],
|
|
method: "recovery",
|
|
}),
|
|
);
|
|
expect(good.status).toBe(200);
|
|
});
|
|
|
|
it("IS spent once the route actually acts", async () => {
|
|
// The other half of the two-phase split. Deferring the claim buys nothing if
|
|
// the route then forgets to make it — the elevation would stay redeemable
|
|
// for the rest of its five minutes.
|
|
//
|
|
// Driven through recovery-code regeneration rather than disable: disable
|
|
// calls `destroyOtherSessions`, which deletes the account's elevations
|
|
// outright, so the row is gone afterwards either way and the assertion
|
|
// would pass whether or not the route committed. Regeneration leaves the
|
|
// row in place, so `consumedAt` is a real observation.
|
|
const secret = await enrolTotp();
|
|
const { raw } = await mintToken("e12c");
|
|
useToken(raw);
|
|
const elevation = elevationOf(await mintTotpElevation(secret));
|
|
|
|
useElevation(elevation);
|
|
const { POST } =
|
|
await import("@/app/api/auth/me/mfa/recovery-codes/regenerate/route");
|
|
const ok = await POST(
|
|
req("/api/auth/me/mfa/recovery-codes/regenerate", "POST"),
|
|
);
|
|
expect(ok.status).toBe(200);
|
|
|
|
const row = await getPrismaClient().stepUpElevation.findFirst();
|
|
expect(row).not.toBeNull();
|
|
expect(row?.consumedAt).not.toBeNull();
|
|
|
|
// And the spent value opens nothing else.
|
|
expect((await callMfaSetup()).status).toBe(401);
|
|
});
|
|
|
|
it("survives a malformed body", async () => {
|
|
const { raw } = await mintToken("e12b");
|
|
useToken(raw);
|
|
useElevation(elevationOf(await mintElevation(PASSWORD)));
|
|
|
|
const { POST } =
|
|
await import("@/app/api/auth/me/mfa/webauthn/register/verify/route");
|
|
const res = await POST(
|
|
req("/api/auth/me/mfa/webauthn/register/verify", "POST", { nope: 1 }),
|
|
);
|
|
expect(res.status).toBe(422);
|
|
|
|
const row = await getPrismaClient().stepUpElevation.findFirst();
|
|
expect(row?.consumedAt).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── E13 — the status read is plain Bearer ────────────────────────────
|
|
|
|
describe("E13 — the second-factor status read needs no elevation", () => {
|
|
it("serves a token with no elevation header at all", async () => {
|
|
const { raw } = await mintToken("e13a");
|
|
useToken(raw);
|
|
useElevation(null);
|
|
|
|
const res = await callMfaStatus();
|
|
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as {
|
|
data: { totp: { enabled: boolean }; webauthn: unknown[] };
|
|
};
|
|
expect(body.data.totp).toBeDefined();
|
|
expect(Array.isArray(body.data.webauthn)).toBe(true);
|
|
});
|
|
|
|
it("carries no credential material", async () => {
|
|
await getPrismaClient().webauthnMfaCredential.create({
|
|
data: {
|
|
userId: USER_ID,
|
|
name: "key",
|
|
credentialId: "cred-e13b-secret",
|
|
credentialPublicKey: Buffer.from([9, 9, 9]),
|
|
transports: ["internal"],
|
|
},
|
|
});
|
|
const { raw } = await mintToken("e13b");
|
|
useToken(raw);
|
|
|
|
const text = await (await callMfaStatus()).text();
|
|
|
|
// The justification for relaxing this route is that the payload is inert.
|
|
// Pin it: no credential id, no public key, no codes.
|
|
expect(text).not.toContain("cred-e13b-secret");
|
|
expect(text).not.toContain("credentialPublicKey");
|
|
expect(text).not.toContain("credentialId");
|
|
});
|
|
});
|
|
|
|
// ── E14 — passkey removal is gated, and a passkey satisfies the gate ──
|
|
|
|
/**
|
|
* `DELETE /api/auth/passkeys/{id}` used to take a plain cookie session or a
|
|
* wildcard Bearer, while the second-factor key beside it demanded a fresh
|
|
* proof. That was the softer gate on the PRIMARY sign-in credential, so it now
|
|
* goes through `requireMfaManagementAuth({ freshFactor: true, proofSource:
|
|
* "any-possession" })`.
|
|
*
|
|
* THE PROOF SOURCE IS THE PART THAT NEEDS PROVING, and the cases split on the
|
|
* difference between the two refusals rather than on the status, because both
|
|
* are 401 and only one of them is survivable:
|
|
*
|
|
* `auth.stepup.required` — re-prove and retry. A gate.
|
|
* `auth.stepup.mfa_not_enrolled` — the account holds nothing that could ever
|
|
* produce the proof. A dead end, and the reachability pre-check is what
|
|
* decides which one a caller gets.
|
|
*
|
|
* A passkey-only account holds no SECOND factor, so the default pre-check would
|
|
* hand it the dead end over a proof it was already carrying — its passkey stamps
|
|
* `mfaVerifiedAt` on login and is one of the mint's arms. `"any-possession"` is
|
|
* what makes its refusal the survivable one, and E14b then shows it actually
|
|
* surviving: a session stamped the way `/api/auth/passkey/login-verify` stamps
|
|
* it goes straight through.
|
|
*
|
|
* The Bearer happy path is driven with TOTP rather than a passkey assertion for
|
|
* the reason `enrolTotp` above already gives — it is the only fresh-factor arm
|
|
* reachable without a real authenticator. What it proves is the redemption and
|
|
* the spend; the passkey arm's admission is proved by E14a/E14b on the pre-check
|
|
* that governs both transports.
|
|
*
|
|
* Mutation check: drop `proofSource` from the route and E14a's errorCode
|
|
* assertion plus E14b go red; drop `freshFactor` and E14a, E14c and E14d's spend
|
|
* go red; move `commitElevation()` above the 404 branch and E14f goes red.
|
|
*/
|
|
describe("E14 — removing a passkey needs a fresh possession proof", () => {
|
|
async function seedPasskey(label: string): Promise<string> {
|
|
const row = await getPrismaClient().passkey.create({
|
|
data: {
|
|
userId: USER_ID,
|
|
name: label,
|
|
credentialId: `cred-${label}`,
|
|
credentialPublicKey: Buffer.from([1, 2, 3]),
|
|
credentialDeviceType: "multiDevice",
|
|
transports: ["internal"],
|
|
},
|
|
select: { id: true },
|
|
});
|
|
return row.id;
|
|
}
|
|
|
|
async function callDelete(id: string): Promise<Response> {
|
|
const { DELETE } = await import("@/app/api/auth/passkeys/[id]/route");
|
|
return await DELETE(req(`/api/auth/passkeys/${id}`, "DELETE"), {
|
|
params: Promise.resolve({ id }),
|
|
});
|
|
}
|
|
|
|
async function useCookieSession(): Promise<string> {
|
|
const session = await getPrismaClient().session.create({
|
|
data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) },
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
return session.id;
|
|
}
|
|
|
|
async function errorCodeOf(res: Response): Promise<string | undefined> {
|
|
const body = (await res.json()) as { meta?: { errorCode?: string } };
|
|
return body.meta?.errorCode;
|
|
}
|
|
|
|
it("refuses a passkey-only cookie session, and the refusal is re-provable", async () => {
|
|
const passkeyId = await seedPasskey("e14a");
|
|
// No TOTP, no security key. Only the passkey it is trying to remove.
|
|
await useCookieSession();
|
|
|
|
const res = await callDelete(passkeyId);
|
|
|
|
expect(res.status).toBe(401);
|
|
// The whole point: NOT `mfa_not_enrolled`. This account can re-prove.
|
|
expect(await errorCodeOf(res)).toBe("auth.stepup.required");
|
|
expect(await getPrismaClient().passkey.count()).toBe(1);
|
|
});
|
|
|
|
it("admits that same account once its session carries a passkey login", async () => {
|
|
const passkeyId = await seedPasskey("e14b");
|
|
const sessionId = await useCookieSession();
|
|
// Exactly what `/api/auth/passkey/login-verify` writes on a passkey login
|
|
// (v1.23, M-review M1). Still no second factor anywhere on the account.
|
|
await getPrismaClient().session.update({
|
|
where: { id: sessionId },
|
|
data: { mfaVerifiedAt: new Date() },
|
|
});
|
|
|
|
expect((await callDelete(passkeyId)).status).toBe(200);
|
|
expect(await getPrismaClient().passkey.count()).toBe(0);
|
|
});
|
|
|
|
it("refuses a stale passkey login, so the window is real", async () => {
|
|
const passkeyId = await seedPasskey("e14c");
|
|
const sessionId = await useCookieSession();
|
|
await getPrismaClient().session.update({
|
|
where: { id: sessionId },
|
|
data: {
|
|
mfaVerifiedAt: new Date(Date.now() - 6 * 60 * 1000),
|
|
},
|
|
});
|
|
|
|
const res = await callDelete(passkeyId);
|
|
|
|
expect(res.status).toBe(401);
|
|
expect(await errorCodeOf(res)).toBe("auth.stepup.required");
|
|
expect(await getPrismaClient().passkey.count()).toBe(1);
|
|
});
|
|
|
|
it("refuses a Bearer client presenting no elevation", async () => {
|
|
const passkeyId = await seedPasskey("e14d");
|
|
const { raw } = await mintToken("e14d");
|
|
useToken(raw);
|
|
useElevation(null);
|
|
|
|
const res = await callDelete(passkeyId);
|
|
|
|
expect(res.status).toBe(401);
|
|
expect(await getPrismaClient().passkey.count()).toBe(1);
|
|
});
|
|
|
|
it("admits the native client on a factor-proved elevation, and spends it", async () => {
|
|
const passkeyId = await seedPasskey("e14e");
|
|
const secret = await enrolTotp();
|
|
const { raw, id: tokenId } = await mintToken("e14e");
|
|
useToken(raw);
|
|
const elevation = elevationOf(await mintTotpElevation(secret));
|
|
useElevation(elevation);
|
|
|
|
expect((await callDelete(passkeyId)).status).toBe(200);
|
|
expect(await getPrismaClient().passkey.count()).toBe(0);
|
|
|
|
const row = await getPrismaClient().stepUpElevation.findFirst({
|
|
where: { apiTokenId: tokenId },
|
|
});
|
|
expect(row?.consumedAt).not.toBeNull();
|
|
});
|
|
|
|
it("does not burn the elevation on a foreign passkey id", async () => {
|
|
await seedPasskey("e14f");
|
|
const secret = await enrolTotp();
|
|
const { raw, id: tokenId } = await mintToken("e14f");
|
|
useToken(raw);
|
|
const elevation = elevationOf(await mintTotpElevation(secret));
|
|
useElevation(elevation);
|
|
|
|
expect((await callDelete("pk-does-not-exist")).status).toBe(404);
|
|
|
|
const row = await getPrismaClient().stepUpElevation.findFirst({
|
|
where: { apiTokenId: tokenId },
|
|
});
|
|
expect(row?.consumedAt).toBeNull();
|
|
});
|
|
|
|
it("tells an account holding nothing that there is nothing to prove with", async () => {
|
|
// No passkey, no TOTP, no security key. There is no credential this gate
|
|
// could ever accept, and the refusal says so rather than inviting a
|
|
// re-verification that cannot happen. Reachable only with a stale id — an
|
|
// account with no passkey has nothing here to delete.
|
|
await useCookieSession();
|
|
|
|
const res = await callDelete("pk-does-not-exist");
|
|
|
|
expect(res.status).toBe(401);
|
|
expect(await errorCodeOf(res)).toBe("auth.stepup.mfa_not_enrolled");
|
|
});
|
|
});
|