mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
test(01-03): add passkey enrollment security contracts
- require fresh existing-factor proof on both cookie-only legs - bind registration challenges to user, purpose, session, expiry and one use - cover bearer, ambient-cookie, stale, foreign and replay attacks
This commit is contained in:
1 parent
6c0f47acad
commit
9f6cd86c0d
2 files changed
+457
-173
No files matched your search
@@ -11,6 +11,8 @@
|
||||
* The write half (the stamp on a verified assertion) is pinned in
|
||||
* `src/lib/auth/__tests__/passkey.test.ts`.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
@@ -80,3 +82,32 @@ describe("GET /api/auth/passkeys", () => {
|
||||
expect(body.data[0].lastUsedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("primary-passkey enrollment route guards", () => {
|
||||
const optionsSource = readFileSync(
|
||||
resolve(__dirname, "../../passkey/register-options/route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const verifySource = readFileSync(
|
||||
resolve(__dirname, "../../passkey/register-verify/route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
it("keeps both registration legs cookie-only", () => {
|
||||
for (const source of [optionsSource, verifySource]) {
|
||||
expect(source).toContain("requireCookieAuth");
|
||||
expect(source).not.toMatch(/\brequireAuth\b/);
|
||||
expect(source).not.toContain("requireMfaManagementAuth");
|
||||
expect(source).not.toContain("requireBearerAuth");
|
||||
}
|
||||
});
|
||||
|
||||
it("requires existing-factor proof before options and repeats freshness on verify", () => {
|
||||
expect(optionsSource).toMatch(
|
||||
/password|totp|webauthn|passkey-registration/,
|
||||
);
|
||||
expect(optionsSource).toMatch(/session\.id/);
|
||||
expect(verifySource).toMatch(/session\.id/);
|
||||
expect(verifySource).toMatch(/fresh|reauth|proof/i);
|
||||
});
|
||||
});
|
||||
@@ -1,29 +1,30 @@
|
||||
/**
|
||||
* v1.4.35 — integration coverage for the passkey registration ceremony.
|
||||
* SEC-05 / SEC-08 — primary-passkey enrollment security boundary.
|
||||
*
|
||||
* F-2 in the test coverage audit flagged
|
||||
* `/api/auth/passkey/register-options` and `register-verify` as having
|
||||
* zero route tests. The ceremony is purely server-side — a regression
|
||||
* in the challenge-store roundtrip ships silently. This file pins:
|
||||
* These are deliberately RED contracts for v1.34.1. A registration ceremony
|
||||
* is not authorized by possession of an ambient cookie or any Bearer token.
|
||||
* The caller must re-prove an existing account factor in the same cookie
|
||||
* session, and the resulting WebAuthn challenge must remain bound to that
|
||||
* user, purpose, session, expiry, and one redemption.
|
||||
*
|
||||
* - register-options issues a challenge, persists it in
|
||||
* `auth_challenges`, and returns the RP-ID derived from APP_URL
|
||||
* - register-verify with a valid (mocked) attestation persists a
|
||||
* Passkey row and consumes the challenge
|
||||
* - register-verify with a tampered attestation responds 400 and
|
||||
* does NOT persist a Passkey
|
||||
*
|
||||
* `@simplewebauthn/server` runs cryptographic verification we can't
|
||||
* fake against real WebAuthn output in a unit-test setting; the helper
|
||||
* module that wraps it (`src/lib/auth/passkey.ts`) is mocked so the
|
||||
* test exercises the route handler + DB write path end-to-end.
|
||||
* WebAuthn cryptography is mocked because a test process cannot manufacture a
|
||||
* hardware attestation. Authentication policy, challenge persistence, route
|
||||
* handling, and Passkey insertion all run against the real PostgreSQL
|
||||
* Testcontainer.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as OTPAuth from "otpauth";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
process.env.API_TOKEN_HMAC_KEY ??=
|
||||
"test-hmac-key-passkey-enrollment-32-bytes-minimum-123456789";
|
||||
|
||||
const { hashToken } = await import("@/lib/auth/hmac");
|
||||
const { hashPassword } = await import("@/lib/auth/password");
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
@@ -35,12 +36,8 @@ vi.mock("next/headers", async () => {
|
||||
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);
|
||||
},
|
||||
set: (name: string, value: string) => cookieJar.set(name, value),
|
||||
delete: (name: string) => cookieJar.delete(name),
|
||||
})),
|
||||
};
|
||||
});
|
||||
@@ -49,10 +46,6 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// `verifyRegistration` runs the upstream attestation verification that
|
||||
// requires real WebAuthn credentials. Stub the helper so the test owns
|
||||
// the success / failure branch shape; the route handler's
|
||||
// challenge-lookup + Passkey insertion still runs against real Postgres.
|
||||
vi.mock("@/lib/auth/passkey", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/auth/passkey")>(
|
||||
@@ -60,190 +53,450 @@ vi.mock("@/lib/auth/passkey", async () => {
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
verifyAuthentication: vi.fn(),
|
||||
verifyRegistration: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const TEST_USER_ID = "user-passkey-register";
|
||||
vi.mock("@/lib/auth/mfa/webauthn", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/lib/auth/mfa/webauthn")
|
||||
>("@/lib/auth/mfa/webauthn");
|
||||
return {
|
||||
...actual,
|
||||
verifyMfaAuthentication: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const USER_ID = "user-passkey-register";
|
||||
const OTHER_USER_ID = "user-passkey-register-other";
|
||||
const PASSWORD = "Correct horse battery staple!42";
|
||||
|
||||
type RouteFn = (request: NextRequest) => Promise<Response>;
|
||||
|
||||
function request(path: string, body?: unknown): NextRequest {
|
||||
const headers: Record<string, string> = {};
|
||||
const authorization = headerJar.get("authorization");
|
||||
if (authorization) headers.authorization = authorization;
|
||||
const init: RequestInit = { method: "POST", headers };
|
||||
if (body !== undefined) {
|
||||
headers["content-type"] = "application/json";
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
return new NextRequest(`http://localhost${path}`, init);
|
||||
}
|
||||
|
||||
async function registerOptions(proof?: Record<string, unknown>) {
|
||||
const { POST } =
|
||||
await import("@/app/api/auth/passkey/register-options/route");
|
||||
return (POST as unknown as RouteFn)(
|
||||
request("/api/auth/passkey/register-options", proof),
|
||||
);
|
||||
}
|
||||
|
||||
async function registerVerify(challengeId: string, credentialId: string) {
|
||||
const { POST } = await import("@/app/api/auth/passkey/register-verify/route");
|
||||
return POST(
|
||||
request("/api/auth/passkey/register-verify", {
|
||||
challengeId,
|
||||
credential: {
|
||||
id: credentialId,
|
||||
rawId: credentialId,
|
||||
type: "public-key",
|
||||
response: {
|
||||
clientDataJSON: "client-data",
|
||||
attestationObject: "attestation",
|
||||
transports: ["internal"],
|
||||
},
|
||||
clientExtensionResults: {},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function assertionCredential(id: string) {
|
||||
return {
|
||||
id,
|
||||
rawId: id,
|
||||
type: "public-key",
|
||||
response: {
|
||||
clientDataJSON: "client-data",
|
||||
authenticatorData: "authenticator-data",
|
||||
signature: "signature",
|
||||
},
|
||||
clientExtensionResults: {},
|
||||
};
|
||||
}
|
||||
|
||||
async function currentSessionId(): Promise<string> {
|
||||
const session = await getPrismaClient().session.findFirstOrThrow({
|
||||
where: { userId: USER_ID },
|
||||
select: { id: true },
|
||||
});
|
||||
return session.id;
|
||||
}
|
||||
|
||||
async function issueOptions(proof: Record<string, unknown>) {
|
||||
const response = await registerOptions(proof);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as {
|
||||
data: { challengeId: string; options: { challenge: string } };
|
||||
};
|
||||
return body.data;
|
||||
}
|
||||
|
||||
async function expectNoCredential(): Promise<void> {
|
||||
expect(await getPrismaClient().passkey.count()).toBe(0);
|
||||
}
|
||||
|
||||
async function mintBearer(
|
||||
permissions: string[],
|
||||
label: string,
|
||||
): Promise<string> {
|
||||
const raw = `hlk_${label}${"0".repeat(64 - label.length)}`;
|
||||
await getPrismaClient().apiToken.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
name: label,
|
||||
tokenHash: hashToken(raw),
|
||||
permissions,
|
||||
},
|
||||
});
|
||||
return raw;
|
||||
}
|
||||
|
||||
async function seedTotp(): 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;
|
||||
}
|
||||
|
||||
function currentTotpCode(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() });
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
|
||||
// The RP-ID derives from APP_URL / NEXT_PUBLIC_APP_URL; pin both so
|
||||
// the assertion below can compare literal hostnames.
|
||||
process.env.APP_URL = "http://localhost:3000";
|
||||
process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: TEST_USER_ID,
|
||||
username: "passkey-user",
|
||||
email: "passkey@example.test",
|
||||
role: "USER",
|
||||
},
|
||||
await prisma.user.createMany({
|
||||
data: [
|
||||
{
|
||||
id: USER_ID,
|
||||
username: "passkey-user",
|
||||
email: "passkey@example.test",
|
||||
role: "USER",
|
||||
passwordHash: await hashPassword(PASSWORD),
|
||||
},
|
||||
{
|
||||
id: OTHER_USER_ID,
|
||||
username: "passkey-other",
|
||||
email: "passkey-other@example.test",
|
||||
role: "USER",
|
||||
passwordHash: await hashPassword(PASSWORD),
|
||||
},
|
||||
],
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
userId: USER_ID,
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Passkey registration (real Postgres)", () => {
|
||||
it("register-options persists a challenge and returns the RP-ID from APP_URL", async () => {
|
||||
const { POST } =
|
||||
await import("@/app/api/auth/passkey/register-options/route");
|
||||
// The route handler takes no arguments at the TS level (the wrapper
|
||||
// ignores the request) — cast to a no-arg callable for clarity.
|
||||
const res = await (POST as unknown as () => Promise<Response>)();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
options: {
|
||||
challenge: string;
|
||||
rp: { name: string; id: string };
|
||||
};
|
||||
challengeId: string;
|
||||
};
|
||||
};
|
||||
|
||||
expect(body.data.challengeId).toBeTruthy();
|
||||
expect(body.data.options.challenge).toBeTruthy();
|
||||
expect(body.data.options.rp.id).toBe("localhost");
|
||||
expect(body.data.options.rp.name).toBe("HealthLog");
|
||||
|
||||
// The challenge row landed in `auth_challenges` and is tied to the
|
||||
// calling user with type=registration and a future expiry.
|
||||
const prisma = getPrismaClient();
|
||||
const stored = await prisma.authChallenge.findUnique({
|
||||
where: { id: body.data.challengeId },
|
||||
});
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored?.userId).toBe(TEST_USER_ID);
|
||||
expect(stored?.type).toBe("registration");
|
||||
expect(stored!.expiresAt.getTime()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it("register-verify persists a Passkey row on a verified attestation", async () => {
|
||||
const { verifyRegistration } = await import("@/lib/auth/passkey");
|
||||
const credentialIdRaw = "valid-credential-id";
|
||||
vi.mocked(verifyRegistration).mockResolvedValue({
|
||||
verified: true,
|
||||
registrationInfo: {
|
||||
credential: {
|
||||
id: credentialIdRaw,
|
||||
publicKey: new Uint8Array([1, 2, 3, 4, 5]),
|
||||
counter: 0,
|
||||
},
|
||||
credentialDeviceType: "singleDevice",
|
||||
credentialBackedUp: false,
|
||||
},
|
||||
} as never);
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
// Seed a challenge row so the verify path can resolve `challengeId`.
|
||||
const challenge = await prisma.authChallenge.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
challenge: "fake-server-challenge",
|
||||
type: "registration",
|
||||
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
const { POST } =
|
||||
await import("@/app/api/auth/passkey/register-verify/route");
|
||||
const res = await POST(
|
||||
new NextRequest("http://localhost/api/auth/passkey/register-verify", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
challengeId: challenge.id,
|
||||
const { verifyAuthentication, verifyRegistration } =
|
||||
await import("@/lib/auth/passkey");
|
||||
vi.mocked(verifyAuthentication).mockResolvedValue({
|
||||
verification: { verified: true },
|
||||
passkey: { userId: USER_ID },
|
||||
} as never);
|
||||
vi.mocked(verifyRegistration).mockImplementation(
|
||||
async (_challengeId, credential) => {
|
||||
const id = (credential as { id: string }).id;
|
||||
return {
|
||||
verified: true,
|
||||
registrationInfo: {
|
||||
credential: {
|
||||
id: credentialIdRaw,
|
||||
response: { transports: ["internal"] },
|
||||
id,
|
||||
publicKey: new Uint8Array([1, 2, 3, 4]),
|
||||
counter: 0,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
credentialDeviceType: "multiDevice",
|
||||
credentialBackedUp: true,
|
||||
},
|
||||
} as never;
|
||||
},
|
||||
);
|
||||
const { verifyMfaAuthentication } = await import("@/lib/auth/mfa/webauthn");
|
||||
vi.mocked(verifyMfaAuthentication).mockResolvedValue(true);
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { verified: boolean } };
|
||||
expect(body.data.verified).toBe(true);
|
||||
|
||||
// Passkey row exists and is tied to this user.
|
||||
const passkeys = await prisma.passkey.findMany({
|
||||
where: { userId: TEST_USER_ID },
|
||||
describe("passkey register-options existing-factor proof (real Postgres)", () => {
|
||||
it("accepts a fresh password proof and binds the new challenge to the cookie session", async () => {
|
||||
const data = await issueOptions({ method: "password", password: PASSWORD });
|
||||
const challenge = await getPrismaClient().authChallenge.findUniqueOrThrow({
|
||||
where: { id: data.challengeId },
|
||||
});
|
||||
expect(passkeys).toHaveLength(1);
|
||||
expect(passkeys[0].credentialId).toBe(credentialIdRaw);
|
||||
expect(passkeys[0].transports).toEqual(["internal"]);
|
||||
|
||||
// Audit row for the register action.
|
||||
const audit = await prisma.auditLog.findFirst({
|
||||
where: { userId: TEST_USER_ID, action: "auth.passkey.register" },
|
||||
});
|
||||
expect(audit).not.toBeNull();
|
||||
expect(challenge.userId).toBe(USER_ID);
|
||||
expect(challenge.type).toMatch(/^passkey-registration:v1:/);
|
||||
expect(challenge.type).not.toContain(await currentSessionId());
|
||||
await expectNoCredential();
|
||||
});
|
||||
|
||||
it("register-verify returns 400 on a tampered attestation and writes no Passkey", async () => {
|
||||
const { verifyRegistration } = await import("@/lib/auth/passkey");
|
||||
vi.mocked(verifyRegistration).mockResolvedValue({
|
||||
verified: false,
|
||||
} as never);
|
||||
it("accepts a fresh TOTP proof", async () => {
|
||||
const secret = await seedTotp();
|
||||
const data = await issueOptions({
|
||||
method: "totp",
|
||||
code: currentTotpCode(secret),
|
||||
});
|
||||
const challenge = await getPrismaClient().authChallenge.findUniqueOrThrow({
|
||||
where: { id: data.challengeId },
|
||||
});
|
||||
|
||||
expect(challenge.type).toMatch(/^passkey-registration:v1:/);
|
||||
await expectNoCredential();
|
||||
});
|
||||
|
||||
it("accepts an existing primary passkey proof", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const challenge = await prisma.authChallenge.create({
|
||||
await prisma.passkey.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
challenge: "fake-server-challenge",
|
||||
type: "registration",
|
||||
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
||||
userId: USER_ID,
|
||||
credentialId: "existing-primary-passkey",
|
||||
credentialPublicKey: Buffer.from([4, 3, 2, 1]),
|
||||
counter: 0,
|
||||
credentialDeviceType: "multiDevice",
|
||||
credentialBackedUp: true,
|
||||
transports: ["internal"],
|
||||
},
|
||||
});
|
||||
const proofChallenge = await prisma.authChallenge.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
challenge: "primary-proof",
|
||||
type: "authentication",
|
||||
expiresAt: new Date(Date.now() + 5 * 60_000),
|
||||
},
|
||||
});
|
||||
|
||||
const { POST } =
|
||||
await import("@/app/api/auth/passkey/register-verify/route");
|
||||
const res = await POST(
|
||||
new NextRequest("http://localhost/api/auth/passkey/register-verify", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
challengeId: challenge.id,
|
||||
credential: { id: "tampered-credential" },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
const passkeys = await prisma.passkey.findMany({
|
||||
where: { userId: TEST_USER_ID },
|
||||
const data = await issueOptions({
|
||||
method: "passkey",
|
||||
challengeId: proofChallenge.id,
|
||||
credential: assertionCredential("existing-primary-passkey"),
|
||||
});
|
||||
expect(passkeys).toHaveLength(0);
|
||||
const challenge = await prisma.authChallenge.findUniqueOrThrow({
|
||||
where: { id: data.challengeId },
|
||||
});
|
||||
|
||||
expect(challenge.type).toMatch(/^passkey-registration:v1:/);
|
||||
expect(await prisma.passkey.count({ where: { userId: USER_ID } })).toBe(1);
|
||||
});
|
||||
|
||||
it("register-verify returns 422 when challengeId is missing", async () => {
|
||||
const { POST } =
|
||||
await import("@/app/api/auth/passkey/register-verify/route");
|
||||
const res = await POST(
|
||||
new NextRequest("http://localhost/api/auth/passkey/register-verify", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ credential: { id: "x" } }),
|
||||
}),
|
||||
);
|
||||
it("accepts an existing MFA WebAuthn proof", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.webauthnMfaCredential.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
credentialId: "existing-mfa-key",
|
||||
credentialPublicKey: Buffer.from([9, 8, 7, 6]),
|
||||
counter: 0,
|
||||
transports: ["usb"],
|
||||
},
|
||||
});
|
||||
const proofChallenge = await prisma.authChallenge.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
challenge: "mfa-proof",
|
||||
type: "mfa_authentication",
|
||||
expiresAt: new Date(Date.now() + 5 * 60_000),
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
const data = await issueOptions({
|
||||
method: "webauthn",
|
||||
challengeId: proofChallenge.id,
|
||||
credential: assertionCredential("existing-mfa-key"),
|
||||
});
|
||||
const challenge = await prisma.authChallenge.findUniqueOrThrow({
|
||||
where: { id: data.challengeId },
|
||||
});
|
||||
|
||||
expect(challenge.type).toMatch(/^passkey-registration:v1:/);
|
||||
await expectNoCredential();
|
||||
});
|
||||
|
||||
it("rejects an ambient cookie that presents no fresh existing-factor proof", async () => {
|
||||
const response = await registerOptions();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(await getPrismaClient().authChallenge.count()).toBe(0);
|
||||
await expectNoCredential();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["wildcard", ["*"]],
|
||||
["narrow", ["medication:ingest"]],
|
||||
])(
|
||||
"rejects a %s Bearer token even with the account password",
|
||||
async (_, permissions) => {
|
||||
cookieJar.clear();
|
||||
const raw = await mintBearer(permissions, `bearer-${permissions[0]}`);
|
||||
headerJar.set("authorization", `Bearer ${raw}`);
|
||||
|
||||
const response = await registerOptions({
|
||||
method: "password",
|
||||
password: PASSWORD,
|
||||
});
|
||||
|
||||
expect([401, 403]).toContain(response.status);
|
||||
expect(await getPrismaClient().authChallenge.count()).toBe(0);
|
||||
await expectNoCredential();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("register-verify repeats cookie/fresh-factor and challenge binding", () => {
|
||||
it("persists one credential for a fresh same-session password ceremony", async () => {
|
||||
const { challengeId } = await issueOptions({
|
||||
method: "password",
|
||||
password: PASSWORD,
|
||||
});
|
||||
|
||||
const response = await registerVerify(challengeId, "new-credential");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(
|
||||
await getPrismaClient().passkey.count({ where: { userId: USER_ID } }),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects verify after the proof becomes stale", async () => {
|
||||
const { challengeId } = await issueOptions({
|
||||
method: "password",
|
||||
password: PASSWORD,
|
||||
});
|
||||
await getPrismaClient().session.update({
|
||||
where: { id: await currentSessionId() },
|
||||
data: { mfaVerifiedAt: new Date(Date.now() - 6 * 60_000) },
|
||||
});
|
||||
|
||||
const response = await registerVerify(challengeId, "stale-proof");
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
await expectNoCredential();
|
||||
});
|
||||
|
||||
it("rejects a challenge from another cookie session", async () => {
|
||||
const { challengeId } = await issueOptions({
|
||||
method: "password",
|
||||
password: PASSWORD,
|
||||
});
|
||||
const foreignSession = await getPrismaClient().session.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
expiresAt: new Date(Date.now() + 60 * 60_000),
|
||||
mfaVerifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", foreignSession.id);
|
||||
|
||||
const response = await registerVerify(challengeId, "foreign-session");
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
await expectNoCredential();
|
||||
});
|
||||
|
||||
it("rejects a challenge from another user", async () => {
|
||||
const { challengeId } = await issueOptions({
|
||||
method: "password",
|
||||
password: PASSWORD,
|
||||
});
|
||||
const foreignSession = await getPrismaClient().session.create({
|
||||
data: {
|
||||
userId: OTHER_USER_ID,
|
||||
expiresAt: new Date(Date.now() + 60 * 60_000),
|
||||
mfaVerifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", foreignSession.id);
|
||||
|
||||
const response = await registerVerify(challengeId, "foreign-user");
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
await expectNoCredential();
|
||||
});
|
||||
|
||||
it("rejects a challenge with the wrong purpose", async () => {
|
||||
const { challengeId } = await issueOptions({
|
||||
method: "password",
|
||||
password: PASSWORD,
|
||||
});
|
||||
await getPrismaClient().authChallenge.update({
|
||||
where: { id: challengeId },
|
||||
data: { type: "authentication" },
|
||||
});
|
||||
|
||||
const response = await registerVerify(challengeId, "wrong-purpose");
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
await expectNoCredential();
|
||||
});
|
||||
|
||||
it("rejects an expired enrollment challenge", async () => {
|
||||
const { challengeId } = await issueOptions({
|
||||
method: "password",
|
||||
password: PASSWORD,
|
||||
});
|
||||
await getPrismaClient().authChallenge.update({
|
||||
where: { id: challengeId },
|
||||
data: { expiresAt: new Date(Date.now() - 1_000) },
|
||||
});
|
||||
|
||||
const response = await registerVerify(challengeId, "expired");
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
await expectNoCredential();
|
||||
});
|
||||
|
||||
it("consumes the challenge once so replay cannot create a second credential", async () => {
|
||||
const { challengeId } = await issueOptions({
|
||||
method: "password",
|
||||
password: PASSWORD,
|
||||
});
|
||||
|
||||
expect((await registerVerify(challengeId, "first")).status).toBe(200);
|
||||
const replay = await registerVerify(challengeId, "second");
|
||||
|
||||
expect([400, 401]).toContain(replay.status);
|
||||
expect(await getPrismaClient().passkey.count()).toBe(1);
|
||||
expect(
|
||||
await getPrismaClient().authChallenge.findUnique({
|
||||
where: { id: challengeId },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user