Files
planer/src/app/api/passkey/registrierung/route.ts
T
Florian WildeandClaude Opus 5 cc013fd6a1 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>
2026-09-10 13:02:34 +02:00

72 lines
2.2 KiB
TypeScript

import { NextResponse } from 'next/server'
import { z } from 'zod'
import type { RegistrationResponseJSON } from '@simplewebauthn/server'
import { auditSchreiben } from '@/server/audit'
import { registrierungPruefen, registrierungsOptionen } from '@/server/auth/passkey'
import { verlangeAnmeldungFuerApi } from '@/server/berechtigung'
export const dynamic = 'force-dynamic'
const abschluss = z.object({
bezeichnung: z.string().trim().min(1).max(60),
antwort: z.looseObject({ id: z.string().min(1) }),
})
/** Schritt 1: Optionen fuer einen neuen Passkey des angemeldeten Benutzers. */
export async function GET() {
const benutzer = await verlangeAnmeldungFuerApi()
if (!benutzer) {
return NextResponse.json({ fehler: 'Nicht angemeldet.' }, { status: 401 })
}
const optionen = await registrierungsOptionen(benutzer.id)
return NextResponse.json(optionen, { headers: { 'Cache-Control': 'no-store' } })
}
/** Schritt 2: Antwort des Browsers pruefen und den Passkey speichern. */
export async function POST(anfrage: Request) {
const benutzer = await verlangeAnmeldungFuerApi()
if (!benutzer) {
return NextResponse.json({ fehler: 'Nicht angemeldet.' }, { status: 401 })
}
let inhalt: unknown
try {
inhalt = await anfrage.json()
} catch {
return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 })
}
const gelesen = abschluss.safeParse(inhalt)
if (!gelesen.success) {
return NextResponse.json(
{ fehler: 'Bitte einen Namen für das Gerät angeben.' },
{ status: 400 },
)
}
try {
const ergebnis = await registrierungPruefen(
benutzer.id,
gelesen.data.antwort as unknown as RegistrationResponseJSON,
gelesen.data.bezeichnung,
)
await auditSchreiben({
akteur: { id: benutzer.id, email: benutzer.email },
aktion: 'passkey.angelegt',
objektTyp: 'Passkey',
objektId: ergebnis.passkeyId,
details: { bezeichnung: ergebnis.bezeichnung },
})
return NextResponse.json({ ok: true, bezeichnung: ergebnis.bezeichnung })
} catch (fehler) {
const meldung =
fehler instanceof Error
? fehler.message
: 'Der Passkey konnte nicht gespeichert werden.'
return NextResponse.json({ fehler: meldung }, { status: 400 })
}
}