Files
Laufzettel/src/lib/kurzzeit-token.ts
T
florian.wildeandClaude Opus 5 40e95dbe5f
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(auth): Anmeldung, Rollen und Benutzerverwaltung (#21)
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>
2026-09-25 01:35:02 +00:00

92 lines
2.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}