feat(auth): Anmeldung mit Passwort und OIDC, Schutz aller Seiten (#013)
Signierte Sitzungs-Cookies mit jose, OIDC ueber openid-client (authentik), Schutz in proxy.ts, Anmeldeseite, Audit-Log-Ansicht, Sicherheits-Kopfzeilen und Rate-Limit. Kein Auth.js, weil Version 5 Beta ist. 147 Tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
import * as client from "openid-client";
|
||||
|
||||
/**
|
||||
* Anmeldung über OIDC (getestet mit authentik). Die Einrichtung erfolgt über Umgebungsvariablen,
|
||||
* damit Zugangsdaten nicht in der Datenbank liegen.
|
||||
*/
|
||||
|
||||
export interface OidcEinstellungen {
|
||||
ausstellerUrl: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
/** Anzeigename auf dem Anmeldeknopf. */
|
||||
bezeichnung: string;
|
||||
}
|
||||
|
||||
export function oidcEinstellungen(): OidcEinstellungen | null {
|
||||
const ausstellerUrl = process.env.OIDC_ISSUER;
|
||||
const clientId = process.env.OIDC_CLIENT_ID;
|
||||
const clientSecret = process.env.OIDC_CLIENT_SECRET;
|
||||
|
||||
if (!ausstellerUrl || !clientId || !clientSecret) return null;
|
||||
|
||||
return {
|
||||
ausstellerUrl,
|
||||
clientId,
|
||||
clientSecret,
|
||||
bezeichnung: process.env.OIDC_NAME ?? "authentik",
|
||||
};
|
||||
}
|
||||
|
||||
export function rueckkehrAdresse(): string {
|
||||
const basis = (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
|
||||
return `${basis}/api/auth/oidc/callback`;
|
||||
}
|
||||
|
||||
async function konfiguration(einstellungen: OidcEinstellungen) {
|
||||
return client.discovery(
|
||||
new URL(einstellungen.ausstellerUrl),
|
||||
einstellungen.clientId,
|
||||
einstellungen.clientSecret,
|
||||
);
|
||||
}
|
||||
|
||||
export interface AnmeldeStart {
|
||||
url: string;
|
||||
pruefwert: string;
|
||||
zustand: string;
|
||||
}
|
||||
|
||||
/** Baut die Adresse beim Anbieter samt PKCE-Prüfwert. */
|
||||
export async function anmeldungStarten(einstellungen: OidcEinstellungen): Promise<AnmeldeStart> {
|
||||
const konfig = await konfiguration(einstellungen);
|
||||
const pruefwert = client.randomPKCECodeVerifier();
|
||||
const herausforderung = await client.calculatePKCECodeChallenge(pruefwert);
|
||||
const zustand = client.randomState();
|
||||
|
||||
const url = client.buildAuthorizationUrl(konfig, {
|
||||
redirect_uri: rueckkehrAdresse(),
|
||||
scope: "openid email profile",
|
||||
code_challenge: herausforderung,
|
||||
code_challenge_method: "S256",
|
||||
state: zustand,
|
||||
});
|
||||
|
||||
return { url: url.href, pruefwert, zustand };
|
||||
}
|
||||
|
||||
export interface OidcBenutzer {
|
||||
subject: string;
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** Löst den Rückkehr-Code beim Anbieter ein und liest die Benutzerangaben. */
|
||||
export async function anmeldungAbschliessen(
|
||||
einstellungen: OidcEinstellungen,
|
||||
aktuelleUrl: URL,
|
||||
pruefwert: string,
|
||||
zustand: string,
|
||||
): Promise<OidcBenutzer> {
|
||||
const konfig = await konfiguration(einstellungen);
|
||||
|
||||
const tokens = await client.authorizationCodeGrant(konfig, aktuelleUrl, {
|
||||
pkceCodeVerifier: pruefwert,
|
||||
expectedState: zustand,
|
||||
});
|
||||
|
||||
const angaben = tokens.claims();
|
||||
if (!angaben?.sub) throw new Error("Der Anbieter hat keine Benutzerkennung geliefert.");
|
||||
|
||||
const email = typeof angaben.email === "string" ? angaben.email : "";
|
||||
if (!email) throw new Error("Der Anbieter hat keine E-Mail-Adresse geliefert.");
|
||||
|
||||
const name =
|
||||
(typeof angaben.name === "string" && angaben.name) ||
|
||||
(typeof angaben.preferred_username === "string" && angaben.preferred_username) ||
|
||||
email;
|
||||
|
||||
return { subject: angaben.sub, email, name };
|
||||
}
|
||||
Reference in New Issue
Block a user