mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
"Sign out everywhere" revoked every other refresh token and nothing else. A native login is two rows, and the Bearer check consults only the paired access token, so the other phones kept working until that token expired, up to a day on the default policy. The route also handed destroyOtherSessions a session-kind credential for a Bearer caller, whose session.id is the ApiToken row id; the exception matched nothing and the calling phone revoked its own refresh token. destroyOtherSessions now finds the paired access tokens through the refresh rows and revokes them inside the same transaction, reporting the count, and the route names a Bearer caller by its access-token hash. Programmatic tokens are untouched. rotateRefreshToken marked the consumed row under usedAt: null alone, so a revocation landing between the read and the write was overtaken and a pair minted after the family ended stayed live. The guard now includes revokedAt: null; on a lost race the minted pair is retired and the reason distinguishes a revoked family from a double consume. withIdempotency consulted the grant only at replay time, and only for its existence; a narrow-scoped token was checked only on the delegated arm. Both move into the cell key: a delegated cell carries the grant's id, level and scope fingerprint, a narrow token's cells carry the token, and a request with no live grant neither reads nor claims a cell. Cookie and wildcard own-record cells are keyed byte-for-byte as before. Tests: integration for the paired access tokens (cookie and Bearer caller), a deterministic revoke-mid-rotation race against Postgres, unit coverage for the new cell keys, and the structural guard moved to the new invariant.
276 lines
8.8 KiB
TypeScript
276 lines
8.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);
|
|
});
|
|
});
|
|
|
|
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,
|
|
});
|
|
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",
|
|
});
|
|
// 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);
|
|
});
|
|
});
|