feat(auth): Anmeldung, Rollen und Benutzerverwaltung (#21)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

OIDC mit Discovery, PKCE, state und nonce; lokaler Login mit argon2 als
Notfallzugang; Rechte in einer Tabelle, Prüfung ausschließlich serverseitig,
Rolle und Aktiv-Status pro Anfrage aus der Datenbank.

Ein unabhängiger Review fand eine offene Weiterleitung: die Präfix-Prüfung des
Rücksprungziels ließ /<TAB>/fremd.example durch, woraus der Browser
//fremd.example macht. Das Ziel wird jetzt aufgelöst und die Herkunft
verglichen. Dazu acht weitere Befunde, alle behoben.

274 Tests.

Closes #7

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #21.
This commit is contained in:
2026-09-25 01:35:02 +00:00
co-authored by Claude Opus 5
parent 7a0f875f55
commit 40e95dbe5f
43 changed files with 3426 additions and 56 deletions
+91
View File
@@ -0,0 +1,91 @@
import { scryptSync } from "node:crypto";
import { SignJWT, jwtVerify } from "jose";
/**
* Kurzlebige, signierte Nutzlast fuer Zustaende, die zwischen zwei Anfragen
* ueberleben muessen (#007) – heute der OIDC-Anmeldefluss mit `state`, `nonce`,
* PKCE-Pruefwert und dem urspruenglich angeforderten Ziel.
*
* Bewusst getrennt von der Sitzung in src/lib/sitzung.ts: das hier lebt Minuten
* statt Stunden, ist einmalig und traegt keine Identitaet. Beide sind ueber
* `audience` gegeneinander abgegrenzt, ein Token kann also nicht an der falschen
* Stelle durchrutschen.
*/
const ALGORITHMUS = "HS256";
const HERAUSGEBER = "laufzettel";
const SALZ = "laufzettel.kurzzeit.v1";
const MINDESTLAENGE = 32;
export class KurzzeitFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "KurzzeitFehler";
}
}
/**
* Der Schluessel wird erst beim ersten Aufruf abgeleitet, damit `next build` ohne
* gesetztes APP_SECRET laeuft. Die Quelle wird mitgemerkt, damit ein geaendertes
* Geheimnis nicht am Zwischenspeicher vorbeilaeuft.
*/
let abgeleitet: { quelle: string; schluessel: Uint8Array } | undefined;
function schluesselHolen(): Uint8Array {
const quelle = process.env["APP_SECRET"] ?? "";
if (quelle.length < MINDESTLAENGE) {
throw new KurzzeitFehler(
`APP_SECRET fehlt oder ist zu kurz (mindestens ${MINDESTLAENGE} Zeichen). ` +
"Erzeugen mit: openssl rand -base64 48",
);
}
if (abgeleitet?.quelle !== quelle) {
abgeleitet = { quelle, schluessel: new Uint8Array(scryptSync(quelle, SALZ, 32)) };
}
return abgeleitet.schluessel;
}
/** Signiert eine Nutzlast fuer den angegebenen Zweck und die angegebene Dauer. */
export async function kurzzeitTokenErzeugen(
zweck: string,
nutzlast: Record<string, string>,
gueltigkeitSekunden: number,
): Promise<string> {
return new SignJWT(nutzlast)
.setProtectedHeader({ alg: ALGORITHMUS })
.setIssuedAt()
.setIssuer(HERAUSGEBER)
.setAudience(zweck)
.setExpirationTime(`${gueltigkeitSekunden}s`)
.sign(schluesselHolen());
}
/**
* Liest eine Nutzlast zurueck. Gibt `undefined` zurueck, wenn das Token fehlt,
* abgelaufen, verfaelscht oder fuer einen anderen Zweck ausgestellt ist – der
* Aufrufer behandelt all das gleich.
*/
export async function kurzzeitTokenLesen(
zweck: string,
token: string | undefined,
): Promise<Record<string, string> | undefined> {
if (!token) return undefined;
try {
const { payload } = await jwtVerify(token, schluesselHolen(), {
algorithms: [ALGORITHMUS],
issuer: HERAUSGEBER,
audience: zweck,
});
const werte: Record<string, string> = {};
for (const [schluessel, wert] of Object.entries(payload)) {
if (typeof wert === "string") werte[schluessel] = wert;
}
return werte;
} catch (fehler) {
// Ein Konfigurationsfehler ist etwas anderes als ein ungueltiges Token.
if (fehler instanceof KurzzeitFehler) throw fehler;
return undefined;
}
}