Files
craftvia/src/server/sessions.ts
T
msolarczekandClaude Opus 5 c8e6f30a27
CI / build-and-check (push) Canceled after 0s
CI / audit (push) Canceled after 0s
CI / sbom (push) Canceled after 0s
Basis: Certvia dev@a48c5fb als Fundament für Craftvia
Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation
und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 11:05:39 +02:00

107 lines
4.0 KiB
TypeScript

import { prisma } from "@/server/db";
/**
* SEC2 §5 — Session-Invalidierung als wiederverwendbarer Baustein.
*
* NextAuth v5 arbeitet hier mit **JWT (stateless)** — es gibt keine
* Session-Tabelle, die man leeren könnte. Die Invalidierung läuft deshalb über
* eine Versionsmarke am Konto:
*
* `sessionsValidAfter` (Zeitstempel) wird hochgesetzt. Jedes JWT trägt sein
* Ausstellungsdatum (`iat`); liegt es **vor** der Marke, gilt das Token als
* ungültig und der Nutzer wird abgemeldet.
*
* Geprüft wird an zwei Stellen:
* - im `jwt`-Callback beider Auth-Instanzen (greift bei jedem Token-Refresh),
* - in den Layout-Guards, die den Kontostatus ohnehin schon aus der DB lesen.
*
* **Sekundengenauigkeit:** `iat` ist ein Unix-Zeitstempel in Sekunden. Der
* Vergleich schneidet die Marke deshalb auf Sekunden ab und verlangt echtes
* „älter als" — sonst würde ein im selben Sekundenfenster neu ausgestelltes
* Token (Fall „aktuelle Session behalten") fälschlich mit-invalidiert.
*
* Auslöser: Passwort-Reset, Passwortwechsel, E-Mail-Änderung, Deaktivierung
* eines Kontos und — ab SEC3 — MFA-Änderungen.
*/
// Option C (WS4): der Mandanten-Kill-Switch lebt an der GLOBALEN Identity — eine
// Marke entwertet alle Sessions der Person über alle Mandanten hinweg. Plattform-
// Admins bleiben ein getrennter Store.
export type Principal = { type: "identity" | "platform_admin"; id: string };
/**
* Entwertet **alle** bestehenden Sessions eines Kontos.
* Gibt den gesetzten Zeitpunkt zurück, damit der Aufrufer bei Bedarf die
* aktuelle Session danach neu ausstellen kann.
*/
export async function invalidateSessions(principal: Principal): Promise<Date> {
const at = new Date();
if (principal.type === "identity") {
await prisma.identity.update({ where: { id: principal.id }, data: { sessionsValidAfter: at } });
} else {
await prisma.platformAdmin.update({
where: { id: principal.id },
data: { sessionsValidAfter: at },
});
}
return at;
}
/**
* Ist ein JWT nach der Invalidierungsmarke ausgestellt worden?
*
* @param issuedAtSeconds `iat` des Tokens (Sekunden seit Epoche)
* @param validAfter Marke am Konto, `null` = nie invalidiert
*/
export function isTokenStillValid(
issuedAtSeconds: number | undefined,
validAfter: Date | null | undefined,
): boolean {
if (!validAfter) return true;
// Ohne `iat` lässt sich das Alter nicht bestimmen → fail-closed.
if (typeof issuedAtSeconds !== "number") return false;
const markSeconds = Math.floor(validAfter.getTime() / 1000);
return issuedAtSeconds >= markSeconds;
}
/**
* Liest die Invalidierungsmarke eines Kontos. Getrennte Abfragen, weil die
* beiden Auth-Domänen unterschiedliche Stores haben.
*/
export async function getSessionsValidAfter(principal: Principal): Promise<Date | null> {
if (principal.type === "identity") {
const row = await prisma.identity.findUnique({
where: { id: principal.id },
select: { sessionsValidAfter: true },
});
return row?.sessionsValidAfter ?? null;
}
const row = await prisma.platformAdmin.findUnique({
where: { id: principal.id },
select: { sessionsValidAfter: true },
});
return row?.sessionsValidAfter ?? null;
}
/**
* „Andere Sitzungen abmelden, aktuelle behalten" (Selbständerung, SEC2 §3).
*
* Die Marke wird auf **eine Sekunde vor jetzt** gesetzt. Damit sind alle zuvor
* ausgestellten Tokens ungültig, während das unmittelbar danach erneuerte JWT
* der laufenden Sitzung (dessen `iat` in der aktuellen Sekunde liegt) die
* Prüfung besteht. Ohne diesen Versatz würde sich der Nutzer bei der eigenen
* Passwortänderung selbst aussperren.
*/
export async function invalidateOtherSessions(principal: Principal): Promise<Date> {
const at = new Date(Date.now() - 1000);
if (principal.type === "identity") {
await prisma.identity.update({ where: { id: principal.id }, data: { sessionsValidAfter: at } });
} else {
await prisma.platformAdmin.update({
where: { id: principal.id },
data: { sessionsValidAfter: at },
});
}
return at;
}