Files
HealthLog/tests/integration/refresh-rotation-revoke-race.test.ts
T
Marc-André Bombeck 92c5b0a60d fix(auth): end connected assistants, tokens and share links on sign-out everywhere
"Sign out everywhere" ended web sessions and device logins but left every
credential that works without signing in: a connected AI assistant kept its
OAuth connection and minted the next access token from it, a connector or
ingest token kept working, and a clinician share link kept opening the
record. A password change revoked the tokens but not the assistant's
connection or the share links. Anything a stolen session had minted
outlived the remedy.

- Sign out everywhere now ends every other sign-in, every AI-assistant
  connection, every programmatic token except the caller's own, and every
  clinician share link. Share links are revoked by default; the web offers
  a checkbox to keep them (keepShareLinks=1), and a client that sends
  nothing, such as the shipped app, gets the safe default.
- A password change and an admin password reset also end AI-assistant
  connections, share links and step-up elevations.
- Turning on the first second factor (TOTP or a security key) ends every
  other sign-in, as a password change does. Adding a further factor does
  not.

destroyOtherSessions takes an explicit reach ("sign-ins" or "everything")
so each caller says how far it goes.
2026-09-26 20:06:36 +02:00

156 lines
5.5 KiB
TypeScript

/**
* Refresh rotation versus a revocation that lands mid-flight.
*
* `rotateRefreshToken` reads the presented row as live, mints a new pair, and
* only then marks the old row consumed with a conditional update. Until
* v1.38.11 that condition checked `usedAt: null` alone, so a revocation
* landing between the read and the write — "sign out everywhere" from another
* device, a credential rotation — was overtaken: the update still matched, the
* caller walked away with a fresh pair minted a moment AFTER the family had
* been ended, and it was the one live login left.
*
* The race is made deterministic by hooking the lookup the rotation performs
* between minting and marking (the `findUnique` that fetches the new row's id)
* and revoking the family inside it. Real Postgres; the conditional update is
* the thing under test.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
const { issueAccessAndRefresh, rotateRefreshToken } =
await import("@/lib/auth/refresh-token");
const { destroyOtherSessions } = await import("@/lib/auth/session");
const { resolveBearerToken, BearerAuthError } =
await import("@/lib/auth/bearer");
const { prisma } = await import("@/lib/db");
const NATIVE_POLICY = {
policy: "native" as const,
accessTokenDays: 1,
refreshTokenDays: 30,
tokenLabel: "native",
};
const DEVICE_ID = "race-device-1";
async function seedUser(username: string) {
return getPrismaClient().user.create({
data: { username, email: `${username}@example.test` },
});
}
async function bearerVerdict(rawToken: string): Promise<string> {
try {
await resolveBearerToken(rawToken, { kind: "wildcard-only" });
} catch (error) {
if (error instanceof BearerAuthError) return error.reason;
throw error;
}
return "accepted";
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
vi.restoreAllMocks();
});
describe("refresh rotation — revocation landing between read and consume", () => {
it("does not hand out a live pair on a family revoked mid-rotation", async () => {
const user = await seedUser("race-owner");
const phone = await issueAccessAndRefresh({
userId: user.id,
policy: NATIVE_POLICY,
deviceId: DEVICE_ID,
source: "test",
});
// A second, web session is the one pressing "sign out everywhere".
const browser = await getPrismaClient().session.create({
data: { userId: user.id, expiresAt: new Date(Date.now() + 1e6) },
});
// Hook the lookup between "mint" and "mark consumed", and end the family
// from the browser session inside it — after the rotation has read the
// old row as live and after the new pair exists, before the old row is
// marked. Exactly the window the old `usedAt: null` guard did not cover.
const original = prisma.refreshToken.findUnique.bind(prisma.refreshToken);
let revokedInsideTheWindow = false;
// Prisma's delegate returns a lazy thenable rather than a Promise; the
// spy's implementation type is widened for that alone.
const spy = vi
.spyOn(prisma.refreshToken, "findUnique")
.mockImplementation((async (args: unknown) => {
if (!revokedInsideTheWindow) {
revokedInsideTheWindow = true;
await destroyOtherSessions(
user.id,
{ kind: "session", sessionId: browser.id },
{ reach: "everything" },
);
}
return original(args as never);
}) as never);
const result = await rotateRefreshToken({
refreshToken: phone.refreshToken,
policy: NATIVE_POLICY,
deviceId: DEVICE_ID,
});
spy.mockRestore();
expect(revokedInsideTheWindow).toBe(true);
// The rotation must lose, and must say why in the word the client acts on.
expect(result).toEqual({ ok: false, reason: "revoked" });
// No live refresh token on the family, and nothing minted survives.
const live = await getPrismaClient().refreshToken.findMany({
where: { userId: user.id, revokedAt: null },
});
expect(live).toHaveLength(0);
const liveAccess = await getPrismaClient().apiToken.findMany({
where: { userId: user.id, revoked: false },
});
expect(liveAccess).toHaveLength(0);
expect(await bearerVerdict(phone.accessToken)).toBe("revoked");
});
it("POSITIVE CONTROL: the same rotation without a revocation succeeds", async () => {
const user = await seedUser("race-control");
const phone = await issueAccessAndRefresh({
userId: user.id,
policy: NATIVE_POLICY,
deviceId: DEVICE_ID,
source: "test",
});
const result = await rotateRefreshToken({
refreshToken: phone.refreshToken,
policy: NATIVE_POLICY,
deviceId: DEVICE_ID,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(await bearerVerdict(result.bundle.accessToken)).toBe("accepted");
});
it("still reports a plain double-consume as already_used", async () => {
const user = await seedUser("race-double");
const phone = await issueAccessAndRefresh({
userId: user.id,
policy: NATIVE_POLICY,
deviceId: DEVICE_ID,
source: "test",
});
const first = await rotateRefreshToken({
refreshToken: phone.refreshToken,
policy: NATIVE_POLICY,
deviceId: DEVICE_ID,
});
expect(first.ok).toBe(true);
const replay = await rotateRefreshToken({
refreshToken: phone.refreshToken,
policy: NATIVE_POLICY,
deviceId: DEVICE_ID,
});
expect(replay).toEqual({ ok: false, reason: "already_used" });
});
});