Files
HealthLog/tests/integration/sessions-and-known-devices.test.ts
T
Marc-André Bombeck a294d7f684 fix(sharing): invitations need a fresh proof and end with sign out everywhere
Offering access to the record took a live session alone, and an
invitation outlives the session that sent it: whoever held a stolen
session could invite their own account and keep a way into the record
after the owner signed everything out. POST /api/account/grants now takes
the recent-proof gate at every level (a sign-in or re-proof within five
minutes on the browser, with the second factor on an account that has one;
a step-up elevation on a token, which no shipped client uses here), and
the invite form opens the re-proof dialog and sends again.

Sign out everywhere now withdraws every invitation the account offered
that nobody has accepted. Accepted grants are left standing on purpose:
each is a person the owner chose, often a carer who still needs the
record, and a sign-out button must not quietly cut them off. The response
lists them as grantsKept and the sessions card shows each with a one-click
End access.
2026-09-26 22:34:21 +02:00

520 lines
16 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; 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.
*/
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("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");
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 },
{ reach: "sign-ins" },
);
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);
});
});
describe("destroyOtherSessions — native device logins", () => {
it("revokes the access token paired with every other refresh token, and nothing else", async () => {
const prisma = getPrismaClient();
const user = await makeUser("sess-native-owner");
const current = await prisma.session.create({
data: { userId: user.id, expiresAt: new Date(Date.now() + 1e6) },
});
// Two phones: each is a RefreshToken pointing at its login ApiToken.
for (const n of ["a", "b"]) {
await prisma.apiToken.create({
data: {
userId: user.id,
name: `native-${n}`,
tokenHash: `access-${n}`,
permissions: ["*"],
},
});
await prisma.refreshToken.create({
data: {
userId: user.id,
tokenHash: `refresh-${n}`,
accessTokenHash: `access-${n}`,
expiresAt: new Date(Date.now() + 1e6),
},
});
}
// A programmatic token: no refresh row, managed under /settings/api-tokens.
const automation = await prisma.apiToken.create({
data: {
userId: user.id,
name: "automation",
tokenHash: "hlk-automation",
permissions: ["measurements:write"],
},
});
// Another account's phone must be untouched.
const other = await makeUser("sess-native-other");
await prisma.apiToken.create({
data: {
userId: other.id,
name: "native-other",
tokenHash: "access-other",
permissions: ["*"],
},
});
await prisma.refreshToken.create({
data: {
userId: other.id,
tokenHash: "refresh-other",
accessTokenHash: "access-other",
expiresAt: new Date(Date.now() + 1e6),
},
});
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({
where: { tokenHash: { in: ["access-a", "access-b"] } },
select: { revoked: true },
});
expect(revoked.map((t) => t.revoked)).toEqual([true, true]);
const kept = await prisma.apiToken.findMany({
where: { tokenHash: { in: [automation.tokenHash, "access-other"] } },
select: { tokenHash: true, revoked: true },
});
expect(kept.every((t) => t.revoked === false)).toBe(true);
const otherRefresh = await prisma.refreshToken.findUnique({
where: { tokenHash: "refresh-other" },
});
expect(otherRefresh?.revokedAt).toBeNull();
});
it("spares the calling phone when the caller is a Bearer device login", async () => {
const prisma = getPrismaClient();
const user = await makeUser("sess-native-caller");
for (const n of ["caller", "other"]) {
await prisma.apiToken.create({
data: {
userId: user.id,
name: `native-${n}`,
tokenHash: `access-${n}`,
permissions: ["*"],
},
});
await prisma.refreshToken.create({
data: {
userId: user.id,
tokenHash: `refresh-${n}`,
accessTokenHash: `access-${n}`,
expiresAt: new Date(Date.now() + 1e6),
},
});
}
await prisma.session.create({
data: { userId: user.id, expiresAt: new Date(Date.now() + 1e6) },
});
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);
const caller = await prisma.refreshToken.findUnique({
where: { tokenHash: "refresh-caller" },
});
expect(caller?.revokedAt).toBeNull();
const callerAccess = await prisma.apiToken.findUnique({
where: { tokenHash: "access-caller" },
});
expect(callerAccess?.revoked).toBe(false);
const otherAccess = await prisma.apiToken.findUnique({
where: { tokenHash: "access-other" },
});
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("withdraws invitations nobody accepted and leaves accepted grants standing", async () => {
const prisma = getPrismaClient();
const owner = await makeUser("sess-invites-owner");
const carer = await makeUser("sess-invites-carer");
const stranger = await makeUser("sess-invites-stranger");
const other = await makeUser("sess-invites-other");
const accepted = await prisma.accountGrant.create({
data: {
grantorId: owner.id,
granteeId: carer.id,
access: "READ",
acceptedAt: new Date(),
},
});
const pending = await prisma.accountGrant.create({
data: { grantorId: owner.id, granteeId: stranger.id, access: "WRITE" },
});
// Somebody else's invitation to the owner is not the owner's to withdraw.
const received = await prisma.accountGrant.create({
data: { grantorId: other.id, granteeId: owner.id, access: "READ" },
});
const result = await destroyOtherSessions(
owner.id,
{ kind: "accessToken", accessTokenHash: "none" },
{ reach: "everything" },
);
expect(result.pendingInvitesRevoked).toBe(1);
const after = async (id: string) =>
prisma.accountGrant.findUniqueOrThrow({ where: { id } });
expect((await after(pending.id)).revokedAt).not.toBeNull();
expect((await after(pending.id)).revokedBy).toBe("GRANTOR");
expect((await after(accepted.id)).revokedAt).toBeNull();
expect((await after(received.id)).revokedAt).toBeNull();
});
it("sign-ins leaves pending invitations alone", async () => {
const prisma = getPrismaClient();
const owner = await makeUser("sess-invites-signins");
const other = await makeUser("sess-invites-signins-2");
const pending = await prisma.accountGrant.create({
data: { grantorId: owner.id, granteeId: other.id, access: "READ" },
});
const result = await destroyOtherSessions(
owner.id,
{ kind: "accessToken", accessTokenHash: "none" },
{ reach: "sign-ins" },
);
expect(result.pendingInvitesRevoked).toBe(0);
expect(
(
await prisma.accountGrant.findUniqueOrThrow({
where: { id: pending.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();
});
});