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>
92 lines
2.9 KiB
TypeScript
92 lines
2.9 KiB
TypeScript
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;
|
||
}
|
||
}
|