import { SignJWT, jwtVerify } from "jose"; /** * Das signierte Sitzungscookie (#004). * * Absichtlich framework-frei: kein `next/headers`, kein Prisma, kein React. Das Setzen und * Loeschen des Cookies gehoert in die Next-Schicht, das Nachladen von Rolle und Aktiv-Status * in die Datenbankschicht. Hier steht nur, wie ein Token entsteht und wie es geprueft wird -- * so laesst sich beides ohne Next und ohne Datenbank testen. * * Im Token stehen deshalb nur Angaben, die sich nicht hinter dem Ruecken der Anwendung * aendern: wer angemeldet ist und woher die Anmeldung kam. Rolle und Aktiv-Status stehen * bewusst *nicht* darin, sonst wirkte ein Rechteentzug erst nach Ablauf der Sitzung. */ export const SITZUNG_COOKIE = "sshkv_sitzung"; /** * Acht Stunden statt eines vollen Tages: sshkv hat root auf allen Hosts, eine offen * gelassene Sitzung ist entsprechend mehr wert als in anderen Anwendungen. Kritische * Aktionen verlangen ohnehin eine frische Bestaetigung (5 Minuten). */ export const SITZUNG_LAUFZEIT_STUNDEN = 8; export type SitzungsHerkunft = "notfall" | "oidc"; export type Sitzung = { benutzerId: string; email: string; name: string; herkunft: SitzungsHerkunft; /** Sekunden seit Epoche, aus dem iat-Anspruch. */ ausgestelltAm: number; }; /** * `issuer` und `audience` werden beim Lesen geprueft, damit ein mit demselben Geheimnis * signiertes Token aus einem anderen Zweck (etwa ein Einladungslink) nicht als Sitzung * durchrutscht. */ const AUSSTELLER = "sshkv"; const ZIELGRUPPE = "sshkv"; /** HS256 genuegt: derselbe Dienst signiert und prueft, ein Schluesselpaar braucht es nicht. */ const ALGORITHMUS = "HS256"; const MINDESTLAENGE = 32; const ERLAUBTE_HERKUNFT: readonly SitzungsHerkunft[] = ["notfall", "oidc"]; export class SitzungsFehler extends Error { constructor(nachricht: string, optionen?: ErrorOptions) { super(nachricht, optionen); this.name = "SitzungsFehler"; } } /** * Der Schluessel wird erst beim ersten Aufruf abgeleitet, nicht beim Import: `next build` * laeuft ohne gesetztes `SSHKV_APP_SECRET` und wuerde sonst abbrechen. Gemerkt wird die Quelle mit, * damit ein geaendertes `SSHKV_APP_SECRET` (in Tests, nach einer Rotation) nicht am Zwischenspeicher * vorbeilaeuft -- genau wie in `src/lib/geheimnisse.ts`. */ let abgeleitet: { quelle: string; schluessel: Uint8Array } | undefined; function schluesselHolen(): Uint8Array { const quelle = process.env["SSHKV_APP_SECRET"] ?? ""; if (quelle.length < MINDESTLAENGE) { // Die tatsaechliche Laenge steht bewusst nicht in der Meldung: sie koennte in einem Log // landen und verriete etwas ueber das Geheimnis. throw new SitzungsFehler( `SSHKV_APP_SECRET fehlt oder ist zu kurz (mindestens ${MINDESTLAENGE} Zeichen). ` + "Erzeugen mit: openssl rand -base64 48", ); } if (abgeleitet?.quelle !== quelle) { abgeleitet = { quelle, schluessel: new TextEncoder().encode(quelle) }; } return abgeleitet.schluessel; } export async function sitzungErzeugen(daten: Omit): Promise { const schluessel = schluesselHolen(); return await new SignJWT({ email: daten.email, name: daten.name, herkunft: daten.herkunft, }) .setProtectedHeader({ alg: ALGORITHMUS }) // Die Benutzerkennung steht im `sub`-Anspruch, dem dafuer vorgesehenen Feld -- so ist das // Token auch mit fremdem Werkzeug lesbar. .setSubject(daten.benutzerId) .setIssuer(AUSSTELLER) .setAudience(ZIELGRUPPE) .setIssuedAt() .setExpirationTime(`${SITZUNG_LAUFZEIT_STUNDEN}h`) .sign(schluessel); } /** Gibt undefined zurueck, wenn das Token fehlt, abgelaufen, verfaelscht oder fremd ist -- wirft nicht. */ export async function sitzungLesen(jwt: string | undefined): Promise { // Absichtlich vor dem `try`: ein zu kurzes `SSHKV_APP_SECRET` ist ein Konfigurationsfehler und // darf nicht als "nicht angemeldet" durchgehen, sonst sucht niemand die Ursache. const schluessel = schluesselHolen(); if (jwt === undefined || jwt.length === 0) { return undefined; } try { const { payload } = await jwtVerify(jwt, schluessel, { // Ohne diese Einschraenkung waere ein Token mit `alg: none` oder einem anderen // Verfahren denkbar. `issuer` und `audience` haelt jose selbst gegen. algorithms: [ALGORITHMUS], issuer: AUSSTELLER, audience: ZIELGRUPPE, }); const { sub, iat, email, name, herkunft } = payload; // Signatur und Ablauf sind geprueft, der Inhalt aber noch nicht: ein aelteres oder von // Hand gebautes Token kann Felder vermissen. Ein halb gefuelltes Sitzungsobjekt waere // schlimmer als keines. if ( typeof sub !== "string" || sub.length === 0 || typeof iat !== "number" || typeof email !== "string" || typeof name !== "string" || typeof herkunft !== "string" || !ERLAUBTE_HERKUNFT.includes(herkunft as SitzungsHerkunft) ) { return undefined; } return { benutzerId: sub, email, name, herkunft: herkunft as SitzungsHerkunft, ausgestelltAm: iat, }; } catch { // Fehlende Signatur, falscher Schluessel, Ablauf, fremder Aussteller, kaputtes base64 -- // fuer den Aufrufer ist das alles dasselbe: nicht angemeldet. return undefined; } }