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
@@ -36,23 +36,6 @@ CREATE TABLE "konto" (
CONSTRAINT "konto_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sitzung" (
"id" TEXT NOT NULL,
"sessionToken" TEXT NOT NULL,
"benutzerId" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "sitzung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "verifizierungs_token" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "passkey" (
"id" TEXT NOT NULL,
@@ -130,18 +113,6 @@ CREATE INDEX "konto_benutzerId_idx" ON "konto"("benutzerId");
-- CreateIndex
CREATE UNIQUE INDEX "konto_provider_providerAccountId_key" ON "konto"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "sitzung_sessionToken_key" ON "sitzung"("sessionToken");
-- CreateIndex
CREATE INDEX "sitzung_benutzerId_idx" ON "sitzung"("benutzerId");
-- CreateIndex
CREATE UNIQUE INDEX "verifizierungs_token_token_key" ON "verifizierungs_token"("token");
-- CreateIndex
CREATE UNIQUE INDEX "verifizierungs_token_identifier_token_key" ON "verifizierungs_token"("identifier", "token");
-- CreateIndex
CREATE UNIQUE INDEX "passkey_credentialId_key" ON "passkey"("credentialId");
@@ -169,9 +140,6 @@ CREATE INDEX "audit_eintrag_benutzerId_idx" ON "audit_eintrag"("benutzerId");
-- AddForeignKey
ALTER TABLE "konto" ADD CONSTRAINT "konto_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sitzung" ADD CONSTRAINT "sitzung_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "passkey" ADD CONSTRAINT "passkey_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-22
View File
@@ -32,7 +32,6 @@ model Benutzer {
aktualisiertAm DateTime @updatedAt
konten Konto[]
sitzungen Sitzung[]
passkeys Passkey[]
audit AuditEintrag[]
@@ -62,27 +61,6 @@ model Konto {
@@map("konto")
}
model Sitzung {
id String @id @default(cuid())
sessionToken String @unique
benutzerId String
expires DateTime
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
@@index([benutzerId])
@@map("sitzung")
}
model VerifizierungsToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
@@map("verifizierungs_token")
}
/// Ein registrierter Passkey (WebAuthn) eines Benutzers.
model Passkey {
id String @id @default(cuid())