mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
"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.
139 lines
4.2 KiB
TypeScript
139 lines
4.2 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|