mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
Merge branch 'fix/step-up-and-session-revocation' into release/v1.39.3
# Conflicts: # messages/de.json # messages/en.json # messages/es.json # messages/fr.json # messages/it.json # messages/ko.json # messages/pl.json
This commit is contained in:
commit
03ccb21227
96 files changed
+3238
-495
No files matched your search
@@ -159,10 +159,13 @@ describe("DELETE /api/admin/data leaves nothing it promised to delete", () => {
|
||||
).toBeGreaterThan(100);
|
||||
|
||||
// Created after the generic seed so the cookie carries this session's id.
|
||||
// The seed gives the admin a second factor, and the wipe asks for a fresh
|
||||
// proof, so the session is one that has just completed it.
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
userId: admin.id,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
mfaVerifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
|
||||
@@ -248,14 +248,36 @@ describe("B2/B3 — the medication-ingest surface is unchanged", () => {
|
||||
});
|
||||
|
||||
describe("B4 — the native client is not broken", () => {
|
||||
it("admits a ['*'] token on GET /api/export/full-backup", async () => {
|
||||
it("admits a ['*'] token on POST /api/export/encrypted", async () => {
|
||||
// Login, passkey login-verify and refresh rotation all mint `["*"]`, so
|
||||
// this is exactly the credential the iOS app holds. If this case ever
|
||||
// goes red, the native client is down.
|
||||
// this is exactly the credential the iOS app holds, on the whole-record
|
||||
// export the app calls. If this case ever goes red, the app's backup is
|
||||
// down.
|
||||
await armToken(["*"], "wildcard");
|
||||
const { POST } = await import("@/app/api/export/encrypted/route");
|
||||
const res = await POST(
|
||||
new NextRequest("https://health.example/api/export/encrypted", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: headerJar.get("authorization")!,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ passphrase: "a long enough passphrase" }),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
await res.arrayBuffer();
|
||||
});
|
||||
|
||||
it("passes a ['*'] token through the scope check on GET /api/export/full-backup, to the elevation it needs", async () => {
|
||||
// The plaintext full backup asks a Bearer caller for a step-up
|
||||
// elevation. No shipped client calls it on Bearer. What this pins is that
|
||||
// the refusal is the proof gate's 401, not the scope layer's 403.
|
||||
await armToken(["*"], "wildcard");
|
||||
const { GET } = await import("@/app/api/export/full-backup/route");
|
||||
const res = await GET(req("/api/export/full-backup"));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.status).toBe(401);
|
||||
expect((await res.json()).meta.errorCode).toBe("auth.stepup.required");
|
||||
});
|
||||
|
||||
it("admits a ['*'] token on a batch ingest route", async () => {
|
||||
|
||||
@@ -136,6 +136,12 @@ describe("GET /api/export/measurements", () => {
|
||||
const audits = await prisma.auditLog.findMany({
|
||||
where: { userId: me.id, action: "user.export.measurements" },
|
||||
});
|
||||
console.log(
|
||||
"DBG",
|
||||
JSON.stringify(
|
||||
await prisma.auditLog.findMany({ where: { userId: me.id } }),
|
||||
),
|
||||
);
|
||||
expect(audits.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -258,6 +264,12 @@ describe("GET /api/export/mood", () => {
|
||||
const audits = await prisma.auditLog.findMany({
|
||||
where: { userId: me.id, action: "user.export.mood" },
|
||||
});
|
||||
console.log(
|
||||
"DBG",
|
||||
JSON.stringify(
|
||||
await prisma.auditLog.findMany({ where: { userId: me.id } }),
|
||||
),
|
||||
);
|
||||
expect(audits.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -317,9 +329,13 @@ describe("GET /api/export/full-backup", () => {
|
||||
expect(parsed.measurements.length).toBe(1);
|
||||
expect(parsed.moodEntries.length).toBe(1);
|
||||
|
||||
const audits = await prisma.auditLog.findMany({
|
||||
where: { userId: me.id, action: "user.export.full-backup" },
|
||||
// The audit row is written once the last byte has gone out, which is
|
||||
// after the body resolves here; wait for it rather than race it.
|
||||
await vi.waitFor(async () => {
|
||||
const audits = await prisma.auditLog.findMany({
|
||||
where: { userId: me.id, action: "user.export.full-backup" },
|
||||
});
|
||||
expect(audits.length).toBe(1);
|
||||
});
|
||||
expect(audits.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* The fresh-proof gate in front of whole-record exports, share links, token
|
||||
* minting and the admin data actions, against real Postgres.
|
||||
*
|
||||
* A live browser session is not enough for these: it needs a sign-in or a
|
||||
* re-proof inside five minutes (a second factor, on an account that has one).
|
||||
* `POST /api/auth/reproof` is how a session re-proves in place.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
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-recent-proof-32-bytes-minimum-1234567890ab";
|
||||
|
||||
const { hashPassword } = await import("@/lib/auth/password");
|
||||
|
||||
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),
|
||||
}));
|
||||
|
||||
const USER_ID = "user-recent-proof";
|
||||
const PASSWORD = "Correct horse battery staple!42";
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
type RouteFn = (request: NextRequest) => Promise<Response>;
|
||||
|
||||
function request(method: string, path: string, body?: unknown): NextRequest {
|
||||
return new NextRequest(`http://localhost${path}`, {
|
||||
method,
|
||||
headers: body === undefined ? {} : { "content-type": "application/json" },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
}
|
||||
|
||||
async function fullBackup() {
|
||||
const { GET } = await import("@/app/api/export/full-backup/route");
|
||||
return (GET as unknown as RouteFn)(request("GET", "/api/export/full-backup"));
|
||||
}
|
||||
|
||||
async function reproof(body: unknown) {
|
||||
const { POST } = await import("@/app/api/auth/reproof/route");
|
||||
return (POST as unknown as RouteFn)(
|
||||
request("POST", "/api/auth/reproof", body),
|
||||
);
|
||||
}
|
||||
|
||||
async function startSession(createdAgoMs: number): Promise<string> {
|
||||
const now = Date.now();
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
createdAt: new Date(now - createdAgoMs),
|
||||
expiresAt: new Date(now + HOUR),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return session.id;
|
||||
}
|
||||
|
||||
async function body(res: Response) {
|
||||
return (await res.json()) as {
|
||||
meta?: { errorCode?: string; methods?: string[] };
|
||||
};
|
||||
}
|
||||
|
||||
async function enrollTotp(): 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 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() });
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: USER_ID,
|
||||
username: "recent-proof-user",
|
||||
email: "recent-proof@example.test",
|
||||
role: "USER",
|
||||
passwordHash: await hashPassword(PASSWORD),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("account without a second factor", () => {
|
||||
it("exports right after signing in", async () => {
|
||||
await startSession(30_000);
|
||||
const res = await fullBackup();
|
||||
expect(res.status).toBe(200);
|
||||
await res.text();
|
||||
});
|
||||
|
||||
it("refuses an old session and names the password as the way through", async () => {
|
||||
await startSession(HOUR);
|
||||
const res = await fullBackup();
|
||||
expect(res.status).toBe(401);
|
||||
const b = await body(res);
|
||||
expect(b.meta?.errorCode).toBe("auth.reproof.required");
|
||||
expect(b.meta?.methods).toEqual(["password"]);
|
||||
});
|
||||
|
||||
it("exports after a password re-proof, which stamps only reproofAt", async () => {
|
||||
const sessionId = await startSession(HOUR);
|
||||
const proved = await reproof({ method: "password", password: PASSWORD });
|
||||
expect(proved.status).toBe(200);
|
||||
const row = await getPrismaClient().session.findUniqueOrThrow({
|
||||
where: { id: sessionId },
|
||||
});
|
||||
expect(row.reproofAt).not.toBeNull();
|
||||
expect(row.mfaVerifiedAt).toBeNull();
|
||||
|
||||
const res = await fullBackup();
|
||||
expect(res.status).toBe(200);
|
||||
await res.text();
|
||||
});
|
||||
|
||||
it("refuses and audits a wrong password without stamping", async () => {
|
||||
const sessionId = await startSession(HOUR);
|
||||
const res = await reproof({ method: "password", password: "nope" });
|
||||
expect(res.status).toBe(401);
|
||||
expect((await body(res)).meta?.errorCode).toBe("auth.reproof.failed");
|
||||
const row = await getPrismaClient().session.findUniqueOrThrow({
|
||||
where: { id: sessionId },
|
||||
});
|
||||
expect(row.reproofAt).toBeNull();
|
||||
expect(
|
||||
await getPrismaClient().auditLog.count({
|
||||
where: { userId: USER_ID, action: "auth.mfa.failed" },
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("account with a second factor", () => {
|
||||
it("refuses the password as a proof before spending a guess", async () => {
|
||||
await enrollTotp();
|
||||
await startSession(HOUR);
|
||||
const res = await reproof({ method: "password", password: PASSWORD });
|
||||
expect(res.status).toBe(422);
|
||||
const b = await body(res);
|
||||
expect(b.meta?.errorCode).toBe("auth.reproof.too_weak");
|
||||
expect(b.meta?.methods).toEqual(["totp"]);
|
||||
expect(
|
||||
await getPrismaClient().rateLimit.count({
|
||||
where: { key: `auth:step-up:${USER_ID}` },
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("a fresh sign-in alone is not enough; a TOTP re-proof is", async () => {
|
||||
const secret = await enrollTotp();
|
||||
const sessionId = await startSession(30_000);
|
||||
const refused = await fullBackup();
|
||||
expect(refused.status).toBe(401);
|
||||
expect((await body(refused)).meta?.methods).toEqual(["totp"]);
|
||||
|
||||
const proved = await reproof({ method: "totp", code: codeFor(secret) });
|
||||
expect(proved.status).toBe(200);
|
||||
const row = await getPrismaClient().session.findUniqueOrThrow({
|
||||
where: { id: sessionId },
|
||||
});
|
||||
expect(row.mfaVerifiedAt).not.toBeNull();
|
||||
|
||||
const res = await fullBackup();
|
||||
expect(res.status).toBe(200);
|
||||
await res.text();
|
||||
});
|
||||
});
|
||||
@@ -81,10 +81,11 @@ describe("refresh rotation — revocation landing between read and consume", ()
|
||||
.mockImplementation((async (args: unknown) => {
|
||||
if (!revokedInsideTheWindow) {
|
||||
revokedInsideTheWindow = true;
|
||||
await destroyOtherSessions(user.id, {
|
||||
kind: "session",
|
||||
sessionId: browser.id,
|
||||
});
|
||||
await destroyOtherSessions(
|
||||
user.id,
|
||||
{ kind: "session", sessionId: browser.id },
|
||||
{ reach: "everything" },
|
||||
);
|
||||
}
|
||||
return original(args as never);
|
||||
}) as never);
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Turning on the first second factor ends every other sign-in, as a password
|
||||
* change does. A session opened before the factor existed, perhaps by whoever
|
||||
* the owner is locking out, must not keep going without it. Adding a further
|
||||
* factor to an account that already has one leaves other sign-ins alone.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
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-second-factor-32-bytes-minimum-1234567890";
|
||||
|
||||
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),
|
||||
}));
|
||||
|
||||
const USER_ID = "user-second-factor";
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
type RouteFn = (request: NextRequest) => Promise<Response>;
|
||||
|
||||
async function confirm(code: string) {
|
||||
const { POST } = await import("@/app/api/auth/me/mfa/totp/confirm/route");
|
||||
return (POST as unknown as RouteFn)(
|
||||
new NextRequest("http://localhost/api/auth/me/mfa/totp/confirm", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ code }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function session(): Promise<string> {
|
||||
const row = await getPrismaClient().session.create({
|
||||
data: { userId: USER_ID, expiresAt: new Date(Date.now() + HOUR) },
|
||||
});
|
||||
return row.id;
|
||||
}
|
||||
|
||||
async function pendingSecret(): 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: null },
|
||||
});
|
||||
return secret;
|
||||
}
|
||||
|
||||
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() });
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: USER_ID,
|
||||
username: "second-factor-user",
|
||||
email: "second-factor@example.test",
|
||||
role: "USER",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("TOTP confirmation", () => {
|
||||
it("ends every other sign-in when it is the account's first second factor", async () => {
|
||||
const secret = await pendingSecret();
|
||||
const other = await session();
|
||||
const current = await session();
|
||||
cookieJar.set("healthlog_session", current);
|
||||
|
||||
const res = await confirm(codeFor(secret));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const left = await getPrismaClient().session.findMany({
|
||||
where: { userId: USER_ID },
|
||||
select: { id: true },
|
||||
});
|
||||
expect(left.map((s) => s.id)).toEqual([current]);
|
||||
expect(left.map((s) => s.id)).not.toContain(other);
|
||||
});
|
||||
|
||||
it("leaves other sign-ins alone when a security key was already there", async () => {
|
||||
const secret = await pendingSecret();
|
||||
await getPrismaClient().webauthnMfaCredential.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
name: "Key",
|
||||
credentialId: "cred-1",
|
||||
credentialPublicKey: Buffer.from("pk"),
|
||||
counter: BigInt(0),
|
||||
transports: [],
|
||||
},
|
||||
});
|
||||
await session();
|
||||
const current = await session();
|
||||
cookieJar.set("healthlog_session", current);
|
||||
|
||||
const res = await confirm(codeFor(secret));
|
||||
expect(res.status).toBe(200);
|
||||
expect(
|
||||
await getPrismaClient().session.count({ where: { userId: USER_ID } }),
|
||||
).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,9 @@
|
||||
*
|
||||
* 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.
|
||||
* native refresh tokens; with `reach: "sign-ins"` it leaves API tokens,
|
||||
* connections and share links untouched, with `reach: "everything"` it
|
||||
* ends them too (share links unless kept).
|
||||
* - 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.
|
||||
@@ -29,7 +31,7 @@ async function makeUser(username: string) {
|
||||
}
|
||||
|
||||
describe("destroyOtherSessions", () => {
|
||||
it("keeps the current session, removes the others, revokes refresh tokens, keeps API tokens", async () => {
|
||||
it("sign-ins: keeps the current session, removes the others, revokes refresh tokens, keeps API tokens", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await makeUser("sess-owner");
|
||||
|
||||
@@ -58,10 +60,11 @@ describe("destroyOtherSessions", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const result = await destroyOtherSessions(user.id, {
|
||||
kind: "session",
|
||||
sessionId: current.id,
|
||||
});
|
||||
const result = await destroyOtherSessions(
|
||||
user.id,
|
||||
{ kind: "session", sessionId: current.id },
|
||||
{ reach: "sign-ins" },
|
||||
);
|
||||
expect(result.sessionsRevoked).toBe(2);
|
||||
|
||||
const remaining = await prisma.session.findMany({
|
||||
@@ -204,10 +207,11 @@ describe("destroyOtherSessions — native device logins", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const result = await destroyOtherSessions(user.id, {
|
||||
kind: "session",
|
||||
sessionId: current.id,
|
||||
});
|
||||
const result = await destroyOtherSessions(
|
||||
user.id,
|
||||
{ kind: "session", sessionId: current.id },
|
||||
{ reach: "sign-ins" },
|
||||
);
|
||||
expect(result.accessTokensRevoked).toBe(2);
|
||||
|
||||
const revoked = await prisma.apiToken.findMany({
|
||||
@@ -251,10 +255,11 @@ describe("destroyOtherSessions — native device logins", () => {
|
||||
data: { userId: user.id, expiresAt: new Date(Date.now() + 1e6) },
|
||||
});
|
||||
|
||||
const result = await destroyOtherSessions(user.id, {
|
||||
kind: "accessToken",
|
||||
accessTokenHash: "access-caller",
|
||||
});
|
||||
const result = await destroyOtherSessions(
|
||||
user.id,
|
||||
{ kind: "accessToken", accessTokenHash: "access-caller" },
|
||||
{ reach: "sign-ins" },
|
||||
);
|
||||
// A Bearer caller has no session row, so every browser session goes.
|
||||
expect(result.sessionsRevoked).toBe(1);
|
||||
expect(result.accessTokensRevoked).toBe(1);
|
||||
@@ -273,3 +278,183 @@ describe("destroyOtherSessions — native device logins", () => {
|
||||
expect(otherAccess?.revoked).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("destroyOtherSessions — everything", () => {
|
||||
async function seedCredentials(userId: string) {
|
||||
const prisma = getPrismaClient();
|
||||
const token = await prisma.apiToken.create({
|
||||
data: {
|
||||
userId,
|
||||
name: "ingest",
|
||||
tokenHash: "narrow-hash",
|
||||
permissions: ["measurements:write"],
|
||||
},
|
||||
});
|
||||
const own = await prisma.apiToken.create({
|
||||
data: {
|
||||
userId,
|
||||
name: "caller",
|
||||
tokenHash: "access-caller",
|
||||
permissions: ["*"],
|
||||
},
|
||||
});
|
||||
const connection = await prisma.mcpOAuthConnection.create({
|
||||
data: {
|
||||
userId,
|
||||
clientId: "client",
|
||||
clientName: "Assistant",
|
||||
scope: "health:read",
|
||||
resource: "https://health.example/mcp",
|
||||
currentJti: "jti-1",
|
||||
},
|
||||
});
|
||||
const link = await prisma.clinicianShareLink.create({
|
||||
data: {
|
||||
userId,
|
||||
tokenHash: "share-hash",
|
||||
label: "Clinic",
|
||||
rangeStart: new Date("2026-01-01"),
|
||||
sectionsJson: { v: 2, leaves: [] },
|
||||
expiresAt: new Date(Date.now() + 1e9),
|
||||
},
|
||||
});
|
||||
return { token, own, connection, link };
|
||||
}
|
||||
|
||||
it("revokes every token but the caller's, every connection and every share link", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await makeUser("sess-everything");
|
||||
const seeded = await seedCredentials(user.id);
|
||||
|
||||
const result = await destroyOtherSessions(
|
||||
user.id,
|
||||
{ kind: "accessToken", accessTokenHash: "access-caller" },
|
||||
{ reach: "everything" },
|
||||
);
|
||||
|
||||
expect(result.connectorsRevoked).toBe(1);
|
||||
expect(result.shareLinksRevoked).toBe(1);
|
||||
expect(
|
||||
(
|
||||
await prisma.apiToken.findUniqueOrThrow({
|
||||
where: { id: seeded.token.id },
|
||||
})
|
||||
).revoked,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(
|
||||
await prisma.apiToken.findUniqueOrThrow({
|
||||
where: { id: seeded.own.id },
|
||||
})
|
||||
).revoked,
|
||||
).toBe(false);
|
||||
expect(
|
||||
(
|
||||
await prisma.mcpOAuthConnection.findUniqueOrThrow({
|
||||
where: { id: seeded.connection.id },
|
||||
})
|
||||
).revokedAt,
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
(
|
||||
await prisma.clinicianShareLink.findUniqueOrThrow({
|
||||
where: { id: seeded.link.id },
|
||||
})
|
||||
).revokedAt,
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the share links when asked to, and still ends the rest", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await makeUser("sess-keep-links");
|
||||
const seeded = await seedCredentials(user.id);
|
||||
|
||||
const result = await destroyOtherSessions(
|
||||
user.id,
|
||||
{ kind: "accessToken", accessTokenHash: "access-caller" },
|
||||
{ reach: "everything", keepShareLinks: true },
|
||||
);
|
||||
|
||||
expect(result.shareLinksRevoked).toBe(0);
|
||||
expect(result.connectorsRevoked).toBe(1);
|
||||
expect(
|
||||
(
|
||||
await prisma.clinicianShareLink.findUniqueOrThrow({
|
||||
where: { id: seeded.link.id },
|
||||
})
|
||||
).revokedAt,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("sign-ins leaves connections, tokens and share links alone", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await makeUser("sess-signins-only");
|
||||
const seeded = await seedCredentials(user.id);
|
||||
|
||||
await destroyOtherSessions(
|
||||
user.id,
|
||||
{ kind: "accessToken", accessTokenHash: "access-caller" },
|
||||
{ reach: "sign-ins" },
|
||||
);
|
||||
|
||||
expect(
|
||||
(
|
||||
await prisma.apiToken.findUniqueOrThrow({
|
||||
where: { id: seeded.token.id },
|
||||
})
|
||||
).revoked,
|
||||
).toBe(false);
|
||||
expect(
|
||||
(
|
||||
await prisma.mcpOAuthConnection.findUniqueOrThrow({
|
||||
where: { id: seeded.connection.id },
|
||||
})
|
||||
).revokedAt,
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("destroyAllSessions", () => {
|
||||
it("also ends AI-assistant connections and share links", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const { destroyAllSessions } = await import("@/lib/auth/session");
|
||||
const user = await makeUser("sess-all");
|
||||
const connection = await prisma.mcpOAuthConnection.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
clientId: "client",
|
||||
clientName: "Assistant",
|
||||
scope: "health:read",
|
||||
resource: "https://health.example/mcp",
|
||||
currentJti: "jti-1",
|
||||
},
|
||||
});
|
||||
const link = await prisma.clinicianShareLink.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
tokenHash: "share-hash-all",
|
||||
label: "Clinic",
|
||||
rangeStart: new Date("2026-01-01"),
|
||||
sectionsJson: { v: 2, leaves: [] },
|
||||
expiresAt: new Date(Date.now() + 1e9),
|
||||
},
|
||||
});
|
||||
|
||||
await destroyAllSessions(user.id);
|
||||
|
||||
expect(
|
||||
(
|
||||
await prisma.mcpOAuthConnection.findUniqueOrThrow({
|
||||
where: { id: connection.id },
|
||||
})
|
||||
).revokedAt,
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
(
|
||||
await prisma.clinicianShareLink.findUniqueOrThrow({
|
||||
where: { id: link.id },
|
||||
})
|
||||
).revokedAt,
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -658,10 +658,11 @@ describe("E8 — an elevation does not outlive its anchor", () => {
|
||||
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,
|
||||
});
|
||||
await destroyOtherSessions(
|
||||
USER_ID,
|
||||
{ kind: "session", sessionId: session.id },
|
||||
{ reach: "everything" },
|
||||
);
|
||||
|
||||
expect(await getPrismaClient().stepUpElevation.count()).toBe(0);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* A WebAuthn challenge is good for exactly one verification.
|
||||
*
|
||||
* The ceremony verifiers used to read the challenge, verify, and delete it
|
||||
* afterwards, so two requests carrying the same assertion could both read it
|
||||
* and both pass. `claimAuthChallenge` deletes and returns it in one guarded
|
||||
* statement; this pins that against real Postgres.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const { claimAuthChallenge } = await import("@/lib/auth/passkey");
|
||||
|
||||
const USER_ID = "user-challenge-claim";
|
||||
|
||||
async function challenge(
|
||||
type: string,
|
||||
opts: { userId?: string | null; expiresInMs?: number } = {},
|
||||
): Promise<string> {
|
||||
const row = await getPrismaClient().authChallenge.create({
|
||||
data: {
|
||||
userId: opts.userId === undefined ? USER_ID : opts.userId,
|
||||
challenge: `material-${type}`,
|
||||
type,
|
||||
expiresAt: new Date(Date.now() + (opts.expiresInMs ?? 60_000)),
|
||||
},
|
||||
});
|
||||
return row.id;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: USER_ID,
|
||||
username: "claim-user",
|
||||
email: "claim@example.test",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("claimAuthChallenge", () => {
|
||||
it("hands the challenge to exactly one of two concurrent claims", async () => {
|
||||
const id = await challenge("authentication");
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 8 }, () =>
|
||||
claimAuthChallenge({ challengeId: id, type: "authentication" }),
|
||||
),
|
||||
);
|
||||
expect(results.filter((r) => r !== null)).toHaveLength(1);
|
||||
expect(await getPrismaClient().authChallenge.count({ where: { id } })).toBe(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the material and the account it was begun for", async () => {
|
||||
const id = await challenge("mfa_authentication");
|
||||
await expect(
|
||||
claimAuthChallenge({
|
||||
challengeId: id,
|
||||
type: "mfa_authentication",
|
||||
userId: USER_ID,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
challenge: "material-mfa_authentication",
|
||||
userId: USER_ID,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses another ceremony's challenge and leaves it in place", async () => {
|
||||
const id = await challenge("mfa_authentication");
|
||||
await expect(
|
||||
claimAuthChallenge({ challengeId: id, type: "authentication" }),
|
||||
).resolves.toBeNull();
|
||||
expect(await getPrismaClient().authChallenge.count({ where: { id } })).toBe(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a challenge begun for another account", async () => {
|
||||
const id = await challenge("mfa_registration");
|
||||
await expect(
|
||||
claimAuthChallenge({
|
||||
challengeId: id,
|
||||
type: "mfa_registration",
|
||||
userId: "someone-else",
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("refuses an expired challenge", async () => {
|
||||
const id = await challenge("authentication", { expiresInMs: -1_000 });
|
||||
await expect(
|
||||
claimAuthChallenge({ challengeId: id, type: "authentication" }),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user