Files
sshkeyvault/src/lib/sitzung.ts
T
florian.wildeandClaude Opus 5.5 5ef90310e1 feat(auth): Anmeldung per OIDC und Notfall-Admin mit TOTP (#004)
Sitzung, OIDC-Fluss und Rueckruf aus Laufzettel uebernommen und auf
sshkv angepasst. Abweichungen mit Absicht:

- Ohne passende sshkv-Gruppe gibt es keinen Zugang, auch keinen
  Lesezugriff: schon die Hostliste verraet, wer wohin darf.
- Notfall-Admin nur mit TOTP (Freigabe Frage 5). Ein richtiges
  Passwort fuehrt beim ersten Mal nur zur Einrichtung. Jeder Code gilt
  einmal, fuenf Fehlversuche sperren 15 Minuten.
- Kritische Rechte verlangen eine Bestaetigung, die hoechstens fuenf
  Minuten alt ist. Bei OIDC mit prompt=login, max_age=0 und Pruefung
  von auth_time, sonst zaehlte eine laufende SSO-Sitzung als Beweis.
- Sitzungen laufen acht statt zwoelf Stunden.

Die Seiten folgen, sobald das Design-System (#002) steht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:17:44 +02:00

146 lines
5.2 KiB
TypeScript

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<Sitzung, "ausgestelltAm">): Promise<string> {
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<Sitzung | undefined> {
// 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;
}
}