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 { 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 { 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 { 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; }