mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
A step-up elevation minted from the account password satisfied the fresh-factor routes. That is a second-factor bypass: a stolen token plus a known password could rotate the recovery codes — which take no factor in the body — and then spend one of them to disable the second factor outright. The web has never allowed that chain, because a password login does not stamp mfaVerifiedAt. The method is now an authorisation input. Only totp, webauthn, and passkey satisfy a fresh-factor gate, which is exactly the set of ceremonies the web stamps a session for; password reaches what a plain cookie session reaches and stops. The mint gains TOTP and security-key arms so a TOTP-only account can still manage its factor natively, and the response says which reach it bought. The gate also returns a discriminated context. session.id meant a session row on one arm and a token row on the other, and the disable route passed the token id to a session-scoped query — excluding nothing, so it deleted every browser session and revoked the caller's own device login. The transport is now in the type and the compiler refuses the confusion. Redemption splits into validate-then-commit so a 429, a 422, or a wrong code no longer burns a proof against a five-per-fifteen-minute mint ceiling. The claim is UPDATE ... RETURNING, so the statement that decides is the one that reports. The mint is transactional behind a partial unique index. Sign-out-everywhere and both password-reset paths clear elevations. The status read drops to plain requireAuth — it carries no credential material — and every mutation stays gated.
152 lines
4.8 KiB
TypeScript
152 lines
4.8 KiB
TypeScript
/**
|
|
* Integration test — v1.23 active-session management + known-device ledger
|
|
* against a real Postgres (migration 0203 applied by the container harness).
|
|
*
|
|
* Covers the DB-layer contracts the unit mocks can't pin:
|
|
* - destroyOtherSessions keeps the current session, deletes the rest, revokes
|
|
* native refresh tokens, and leaves API tokens untouched.
|
|
* - destroySessionById is scoped to the owning user (no cross-user delete)
|
|
* and takes the public handle, never the row id.
|
|
* - the (userId, deviceHash) unique index enforces the login-alert dedupe.
|
|
*/
|
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
destroyOtherSessions,
|
|
destroySessionById,
|
|
sessionHandle,
|
|
} from "@/lib/auth/session";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
});
|
|
|
|
async function makeUser(username: string) {
|
|
return getPrismaClient().user.create({
|
|
data: { username, email: `${username}@example.test` },
|
|
});
|
|
}
|
|
|
|
describe("destroyOtherSessions", () => {
|
|
it("keeps the current session, removes the others, revokes refresh tokens, keeps API tokens", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await makeUser("sess-owner");
|
|
|
|
const current = await prisma.session.create({
|
|
data: { userId: user.id, expiresAt: new Date(Date.now() + 1e6) },
|
|
});
|
|
await prisma.session.create({
|
|
data: { userId: user.id, expiresAt: new Date(Date.now() + 1e6) },
|
|
});
|
|
await prisma.session.create({
|
|
data: { userId: user.id, expiresAt: new Date(Date.now() + 1e6) },
|
|
});
|
|
const refresh = await prisma.refreshToken.create({
|
|
data: {
|
|
userId: user.id,
|
|
tokenHash: "rt-hash-1",
|
|
expiresAt: new Date(Date.now() + 1e6),
|
|
},
|
|
});
|
|
const apiToken = await prisma.apiToken.create({
|
|
data: {
|
|
userId: user.id,
|
|
name: "automation",
|
|
tokenHash: "at-hash-1",
|
|
permissions: ["*"],
|
|
},
|
|
});
|
|
|
|
const result = await destroyOtherSessions(user.id, {
|
|
kind: "session",
|
|
sessionId: current.id,
|
|
});
|
|
expect(result.sessionsRevoked).toBe(2);
|
|
|
|
const remaining = await prisma.session.findMany({
|
|
where: { userId: user.id },
|
|
});
|
|
expect(remaining).toHaveLength(1);
|
|
expect(remaining[0].id).toBe(current.id);
|
|
|
|
const rt = await prisma.refreshToken.findUnique({
|
|
where: { id: refresh.id },
|
|
});
|
|
expect(rt?.revokedAt).not.toBeNull();
|
|
|
|
const at = await prisma.apiToken.findUnique({ where: { id: apiToken.id } });
|
|
expect(at?.revoked).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("destroySessionById", () => {
|
|
it("revokes an owned session and refuses a cross-user id", async () => {
|
|
const prisma = getPrismaClient();
|
|
const owner = await makeUser("owner");
|
|
const other = await makeUser("intruder");
|
|
|
|
const ownerSession = await prisma.session.create({
|
|
data: { userId: owner.id, expiresAt: new Date(Date.now() + 1e6) },
|
|
});
|
|
const otherSession = await prisma.session.create({
|
|
data: { userId: other.id, expiresAt: new Date(Date.now() + 1e6) },
|
|
});
|
|
|
|
// Cross-user attempt: owner presents the intruder's handle.
|
|
expect(
|
|
await destroySessionById(owner.id, sessionHandle(otherSession.id)),
|
|
).toBe(false);
|
|
expect(
|
|
await prisma.session.findUnique({ where: { id: otherSession.id } }),
|
|
).not.toBeNull();
|
|
|
|
// The row id is not an accepted key: a caller who somehow learned it
|
|
// still cannot revoke with it, which is what keeps the id out of the
|
|
// client's hands from being load-bearing in both directions.
|
|
expect(await destroySessionById(owner.id, ownerSession.id)).toBe(false);
|
|
expect(
|
|
await prisma.session.findUnique({ where: { id: ownerSession.id } }),
|
|
).not.toBeNull();
|
|
|
|
// Owned delete succeeds on the handle.
|
|
expect(
|
|
await destroySessionById(owner.id, sessionHandle(ownerSession.id)),
|
|
).toBe(true);
|
|
expect(
|
|
await prisma.session.findUnique({ where: { id: ownerSession.id } }),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("UserKnownDevice unique index", () => {
|
|
it("dedupes on (userId, deviceHash) — the same fingerprint cannot insert twice", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await makeUser("device-owner");
|
|
|
|
await prisma.userKnownDevice.create({
|
|
data: {
|
|
userId: user.id,
|
|
deviceHash: "hash-abc",
|
|
label: "Firefox on macOS",
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
prisma.userKnownDevice.create({
|
|
data: { userId: user.id, deviceHash: "hash-abc" },
|
|
}),
|
|
).rejects.toThrow();
|
|
|
|
// A different hash for the same user inserts fine.
|
|
await prisma.userKnownDevice.create({
|
|
data: { userId: user.id, deviceHash: "hash-def" },
|
|
});
|
|
|
|
const rows = await prisma.userKnownDevice.findMany({
|
|
where: { userId: user.id },
|
|
});
|
|
expect(rows).toHaveLength(2);
|
|
});
|
|
});
|