feat(anmeldung): OIDC, Passkeys und Passwort mit Auth.js v5

Drei Wege hinein, je Instanz zuschaltbar: generischer OIDC-Anbieter
(Entra ID beziehungsweise authentik), Passkeys und wahlweise E-Mail mit
Passwort.

Entscheidungen:
- Passkeys laufen direkt über SimpleWebAuthn 14, nicht über den
  experimentellen WebAuthn-Provider von Auth.js, der noch auf Fassung 9
  festgelegt ist.
- Eigener Prisma-Adapter, weil der mitgelieferte fest auf prisma.user und
  prisma.account zugreift, unsere Modelle aber Benutzer und Konto heißen.
- Sitzungen als JWT, weil Credentials-Provider das verlangen. Damit eine
  Sperre trotzdem sofort wirkt, liest der session-Callback Rolle und
  Status bei jeder Anfrage frisch aus der Datenbank. Die Tabellen sitzung
  und verifizierungs_token entfallen deshalb wieder.
- allowDangerousEmailAccountLinking ist bewusst an: je Instanz gibt es
  genau einen vertrauenswürdigen Anbieter, der die Adresse selbst prüft.

Anmeldung, Passwort und Passkey antworten bei Fehlschlag immer gleich,
damit sich nicht abfragen lässt, wer hier ein Konto hat. Die
WebAuthn-Herausforderung wird nach einem Versuch verworfen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-10 13:02:34 +02:00
co-authored by Claude Opus 5
parent 4ff67c15c2
commit cc013fd6a1
16 changed files with 1161 additions and 54 deletions
+74
View File
@@ -0,0 +1,74 @@
import 'server-only'
import { redirect } from 'next/navigation'
import type { Session } from 'next-auth'
import type { Rolle } from '@/generated/prisma/enums'
import { rolleReichtAus } from '@/lib/rollen'
import { auth } from '@/server/auth'
/**
* Rollenpruefung.
*
* Jede schreibende Aktion prueft serverseitig – in der Seite und noch einmal
* in der Server Action. Die Oberflaeche blendet Knoepfe nur zusaetzlich aus;
* verlassen wird sich darauf nie.
*/
export type Angemeldet = Session['user']
/** Fehler, den Server Actions dem Aufrufer zurueckgeben. */
export class NichtErlaubt extends Error {
constructor(nachricht = 'Dazu fehlt dir die Berechtigung.') {
super(nachricht)
this.name = 'NichtErlaubt'
}
}
/** Die angemeldete Person, oder null. */
export async function angemeldet(): Promise<Angemeldet | null> {
const sitzung = await auth()
return sitzung?.user?.id ? sitzung.user : null
}
/**
* Riegel fuer Seiten: ohne Anmeldung geht es zur Anmeldeseite zurueck,
* mit Ziel, damit es danach dort weitergeht, wo man hinwollte.
*/
export async function verlangeAnmeldung(zielPfad?: string): Promise<Angemeldet> {
const benutzer = await angemeldet()
if (!benutzer) {
const ziel = zielPfad ? `?weiter=${encodeURIComponent(zielPfad)}` : ''
redirect(`/anmelden${ziel}`)
}
return benutzer
}
/** Riegel fuer Seiten, die eine bestimmte Rolle verlangen. */
export async function verlangeRolle(
verlangt: Rolle,
zielPfad?: string,
): Promise<Angemeldet> {
const benutzer = await verlangeAnmeldung(zielPfad)
if (!rolleReichtAus(benutzer.rolle, verlangt)) {
redirect('/keine-berechtigung')
}
return benutzer
}
/**
* Riegel fuer Server Actions. Hier wird nicht umgeleitet, sondern geworfen –
* der Aufrufer wandelt das in eine Meldung fuer die Oberflaeche.
*/
export async function pruefeRolle(verlangt: Rolle): Promise<Angemeldet> {
const benutzer = await angemeldet()
if (!benutzer) throw new NichtErlaubt('Bitte zuerst anmelden.')
if (!rolleReichtAus(benutzer.rolle, verlangt)) throw new NichtErlaubt()
return benutzer
}
/**
* Riegel fuer Route Handler: gibt null zurueck, statt umzuleiten. Der
* Aufrufer antwortet dann mit 401.
*/
export async function verlangeAnmeldungFuerApi(): Promise<Angemeldet | null> {
return angemeldet()
}