Feat #6: Anmeldung per Passkey (WebAuthn) #12

Merged
florian.wilde merged 1 commits from feat/6-passkey-anmeldung into main 2026-09-02 06:59:28 +00:00
Owner

Anmelden per Windows Hello, Face ID, Touch ID oder Sicherheitsschlüssel — als dritter Weg in denselben Trichter. finishLogin() prüft die Antwort und gibt die Kennung zurück, der Endpunkt legt damit über createSession() dieselbe Sitzung an wie bei Passwort und OIDC. Die Sitzungsschicht ist unangetastet geblieben.

Was dazugekommen ist

prisma/schema.prisma Modell Passkey samt Migration
src/lib/passkeys.ts WebAuthn-Prüfung, Challenge-Handhabung, Verwaltung
/api/auth/passkey/… vier Endpunkte: Optionen und Abschluss, je für Registrierung und Anmeldung
/api/passkeys/[id] umbenennen und entfernen
Einstellungen → Passkeys anlegen, umbenennen, entfernen, „zuletzt benutzt"
/anmelden „Mit Passkey anmelden" ohne vorherige E-Mail-Eingabe
ENABLE_PASSKEYS abschaltbar wie OIDC

Drei Entscheidungen, die Erklärung verdienen

Prüfung und Sitzung sind getrennt. In passkeys.ts steht nur WebAuthn; Cookie und Sitzungszeile setzt der Endpunkt. Das hält die Domänenschicht frei von HTTP — und nebenbei importierbar in Tests, was sie über lib/auth nicht wäre.

Die Challenge liegt in einem signierten Cookie, fünf Minuten gültig, Einmalgebrauch. Eine Tabelle bräuchte einen Aufräumjob für alles, was liegen bleibt, wenn jemand den Dialog abbricht.

Die RP ID kommt aus APP_URL, nicht aus dem Host-Header — hinter einem Reverse Proxy hinge sie sonst am falschen Namen, und ein Passkey ist fest an diesen Namen gebunden.

Niemand sperrt sich aus

Das Passwort bleibt. Wer weder Passwort noch OIDC hat, kann seinen letzten Passkey nicht entfernen — mit klarer Meldung statt einer stillen Sackgasse. Ein Passkey, der nur auf einem Gerät liegt, wird in der Übersicht als solcher ausgewiesen.

Eine Feststellung zum Signaturzähler

Meine eigene Zählerprüfung lief ins Leere: die Bibliothek prüft den Zähler bereits selbst und wirft dabei einen gewöhnlichen Error — der landete als „Das hat nicht geklappt." beim Nutzer und als unerwarteter Serverfehler im Log. Das ist jetzt übersetzt. Die eigene Prüfung bleibt als zusätzliche Sicherung für den Fall, den die Bibliothek durchlässt (Rückfall von einem gezählten auf einen ungezählten Stand).

Geprüft — mit virtuellem Authenticator gegen einen Produktionsbau

Playwright über CDP (WebAuthn.addVirtualAuthenticator), also ein echter WebAuthn-Durchlauf ohne Hardware:

Schritt Ergebnis
Passkey in den Einstellungen anlegen erscheint in der Liste, 1 Credential im Authenticator
Abmelden landet auf /anmelden
„Mit Passkey anmelden" landet auf /, Bibliothek erreichbar
Sitzung flimmer_session gesetzt, Challenge-Cookie verbraucht
Zähler künstlich auf 9999 gesetzt abgelehnt mit „der Zähler passt nicht", Zähler bleibt stehen
Passwortanmeldung danach unverändert
Instanz mit unsicherer APP_URL Knopf verschwindet, Verwaltung zeigt den Hinweis auf HTTPS

Dazu sieben Tests für Verwaltung, Fremdzugriff, die Aussperrsperre und die RP-ID-Herleitung. 66 Tests grün, Typecheck sauber.

Nebenbefund, der nichts mit Passkeys zu tun hat

Beim Prüfen über http://10.1.5.238:3013 scheiterte schon die Passwortanmeldung: das Sitzungscookie wird in der Produktion mit Secure gesetzt und über nacktes HTTP nicht gesendet. Eine Instanz, die im LAN ohne HTTPS läuft, ist damit gar nicht benutzbar — unabhängig von diesem PR. Details im Issue-Kommentar.

Closes #6

Anmelden per Windows Hello, Face ID, Touch ID oder Sicherheitsschlüssel — als **dritter Weg in denselben Trichter**. `finishLogin()` prüft die Antwort und gibt die Kennung zurück, der Endpunkt legt damit über `createSession()` dieselbe Sitzung an wie bei Passwort und OIDC. Die Sitzungsschicht ist unangetastet geblieben. ## Was dazugekommen ist | | | |---|---| | `prisma/schema.prisma` | Modell `Passkey` samt Migration | | `src/lib/passkeys.ts` | WebAuthn-Prüfung, Challenge-Handhabung, Verwaltung | | `/api/auth/passkey/…` | vier Endpunkte: Optionen und Abschluss, je für Registrierung und Anmeldung | | `/api/passkeys/[id]` | umbenennen und entfernen | | Einstellungen → Passkeys | anlegen, umbenennen, entfernen, „zuletzt benutzt" | | `/anmelden` | „Mit Passkey anmelden" ohne vorherige E-Mail-Eingabe | | `ENABLE_PASSKEYS` | abschaltbar wie OIDC | ## Drei Entscheidungen, die Erklärung verdienen **Prüfung und Sitzung sind getrennt.** In `passkeys.ts` steht nur WebAuthn; Cookie und Sitzungszeile setzt der Endpunkt. Das hält die Domänenschicht frei von HTTP — und nebenbei importierbar in Tests, was sie über `lib/auth` nicht wäre. **Die Challenge liegt in einem signierten Cookie**, fünf Minuten gültig, Einmalgebrauch. Eine Tabelle bräuchte einen Aufräumjob für alles, was liegen bleibt, wenn jemand den Dialog abbricht. **Die RP ID kommt aus `APP_URL`**, nicht aus dem `Host`-Header — hinter einem Reverse Proxy hinge sie sonst am falschen Namen, und ein Passkey ist fest an diesen Namen gebunden. ## Niemand sperrt sich aus Das Passwort bleibt. Wer weder Passwort noch OIDC hat, kann seinen **letzten Passkey nicht entfernen** — mit klarer Meldung statt einer stillen Sackgasse. Ein Passkey, der nur auf einem Gerät liegt, wird in der Übersicht als solcher ausgewiesen. ## Eine Feststellung zum Signaturzähler Meine eigene Zählerprüfung lief ins Leere: **die Bibliothek prüft den Zähler bereits selbst** und wirft dabei einen gewöhnlichen `Error` — der landete als „Das hat nicht geklappt." beim Nutzer und als unerwarteter Serverfehler im Log. Das ist jetzt übersetzt. Die eigene Prüfung bleibt als zusätzliche Sicherung für den Fall, den die Bibliothek durchlässt (Rückfall von einem gezählten auf einen ungezählten Stand). ## Geprüft — mit virtuellem Authenticator gegen einen Produktionsbau Playwright über CDP (`WebAuthn.addVirtualAuthenticator`), also ein echter WebAuthn-Durchlauf ohne Hardware: | Schritt | Ergebnis | |---|---| | Passkey in den Einstellungen anlegen | erscheint in der Liste, 1 Credential im Authenticator | | Abmelden | landet auf `/anmelden` | | „Mit Passkey anmelden" | landet auf `/`, Bibliothek erreichbar | | Sitzung | `flimmer_session` gesetzt, Challenge-Cookie verbraucht | | Zähler künstlich auf 9999 gesetzt | **abgelehnt** mit „der Zähler passt nicht", Zähler bleibt stehen | | Passwortanmeldung danach | unverändert | | Instanz mit unsicherer `APP_URL` | Knopf verschwindet, Verwaltung zeigt den Hinweis auf HTTPS | Dazu sieben Tests für Verwaltung, Fremdzugriff, die Aussperrsperre und die RP-ID-Herleitung. **66 Tests grün**, Typecheck sauber. ## Nebenbefund, der nichts mit Passkeys zu tun hat Beim Prüfen über `http://10.1.5.238:3013` scheiterte schon die **Passwortanmeldung**: das Sitzungscookie wird in der Produktion mit `Secure` gesetzt und über nacktes HTTP nicht gesendet. Eine Instanz, die im LAN ohne HTTPS läuft, ist damit gar nicht benutzbar — unabhängig von diesem PR. Details im Issue-Kommentar. Closes #6
florian.wilde added 1 commit 2026-09-02 05:05:36 +00:00
Anmeldung per Passkey (WebAuthn)
CI / pruefen (pull_request) Canceled after 0s
c0579c6f61
Ein Passkey ist der dritte Weg in denselben Trichter: finishLogin() prueft die
Antwort und gibt die Kennung zurueck, der Endpunkt legt damit ueber
createSession() dieselbe Sitzung an wie bei Passwort und OIDC. Die
Sitzungsschicht bleibt unangetastet.

Die Trennung zwischen Pruefung und Sitzung ist Absicht: in passkeys.ts steht
nur WebAuthn, das Setzen von Cookie und Sitzungszeile gehoert zur HTTP-Schicht.

Die Challenge liegt in einem signierten, fuenf Minuten gueltigen httpOnly-
Cookie statt in einer Tabelle — das spart Schema und Aufraeumjob fuer alles,
was liegen bleibt, wenn jemand den Dialog abbricht.

Die RP ID kommt aus APP_URL, nicht aus dem Host-Header. Hinter einem Reverse
Proxy hinge sie sonst am falschen Namen, und ein Passkey ist fest an diesen
Namen gebunden. Ueber nacktes HTTP verschwindet die Verwaltung mit einem
Hinweis statt einen kryptischen Browserfehler zu zeigen.

Den letzten verbleibenden Anmeldeweg laesst sich nicht entfernen: wer kein
Passwort und keine OIDC-Verknuepfung hat, behaelt seinen letzten Passkey.

Die Bibliothek prueft den Signaturzaehler selbst und wirft dabei gewoehnliche
Error. Die werden jetzt uebersetzt, damit eine erwartete Ablehnung nicht als
unerwarteter Serverfehler im Log landet. Die eigene Zaehlerpruefung bleibt als
zusaetzliche Sicherung fuer den Fall, den die Bibliothek durchlaesst.

Geprueft mit virtuellem Authenticator gegen einen Produktionsbau: anlegen,
abmelden, Anmeldung allein per Passkey, Sitzungscookie gesetzt,
Challenge-Cookie verbraucht, Passwortweg unveraendert. Mit kuenstlich
hochgesetztem Zaehler wird die Anmeldung abgelehnt und der Zaehler bleibt
stehen. Dazu sieben Tests fuer Verwaltung, Rechte und die Aussperrsperre.

Refs #6
florian.wilde merged commit 824cc7764c into main 2026-09-02 06:59:28 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: florian.wilde/flimmer#12