Add passkeys (WebAuthn): register from the profile, log in without email

New library dependency: lbuchs/webauthn (^2.2, MIT, zero transitive deps
beyond PHP+OpenSSL+Mbstring, both already required). 'none' attestation --
this only confirms "the same device that registered", not hardware
provenance, the standard trust model for a public site's own passkey login.

Backend
- migrations/010: `passkeys` (one row per registered credential: owner,
  credential_id, public_key, sign_count, label) and `webauthn_challenges`
  (short-lived, single-use, bridging each ceremony's "options" and "verify"
  calls -- user_id set for a registration, null for a login since who's
  signing in isn't known until the credential comes back).
- Config: WEBAUTHN_RP_ID (defaults to APP_URL's host) and WEBAUTHN_RP_NAME.
- PasskeyRepository, WebAuthnChallengeRepository, PasskeyController:
  GET/POST /api/passkeys, POST /api/passkeys/options, DELETE
  /api/passkeys/{id} (all auth), plus the public POST /api/auth/passkey/
  options and /verify for login. Registration always asks for a
  discoverable, user-verified credential -- what makes login usernameless:
  the browser offers whatever passkeys it has for the site, no email first.
- SessionPayload now also exposes `has_passkey` on every user object
  (PasskeyRepository::countForUser() > 0), reused by both the profile page
  and the dismissible notice.
- PasskeyTest: auth guards, options response shape, challenge single-use/
  expiry/purpose/cross-user rules, malformed-input handling, list/remove
  CRUD (seeded rows) -- everything short of a real signature, which isn't
  practical from PHPUnit. 73 tests pass.

Frontend
- lib/webauthn.ts: base64url <-> ArrayBuffer conversion and the two
  ceremonies (registerPasskey, loginWithPasskey), matching the API's wire
  format exactly.
- ProfileView: a Passkeys section -- list with Remove buttons, an "Add a
  passkey" form (label pre-filled from a UA guess).
- LoginView: a "Log in with a passkey" button above the email form, shown
  only when the browser supports WebAuthn.
- PasskeyNotice.vue: dismissible banner across the top of the page
  (`user.has_passkey === false`); dismissal is a week-long localStorage
  timestamp.

Verified against the rebuilt container using a Chrome DevTools Protocol
*virtual authenticator* (real ECDSA signing, no human interaction) end to
end: notice shown -> register a passkey -> notice gone (same page and after
navigating) -> log out -> "Log in with a passkey" with no email typed ->
correct account, notice still gone -> remove the passkey -> notice back ->
dismiss -> stays hidden for ~7 days across pages. Along the way, caught and
fixed a real bug: AuthenticatorData::getCredentialId() returns a raw binary
string, not a ByteBuffer like most of this library's other binary fields --
bin2hex() it directly rather than calling ->getHex().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 19:34:53 +01:00
co-authored by Claude Sonnet 5
parent 7da881bb78
commit afdd53ec4c
22 changed files with 1366 additions and 22 deletions
+53 -3
View File
@@ -22,6 +22,7 @@ Each user owns **projects**, and each project holds ordered **cards**.
| 12 | Passwordless-only auth — registration and password login removed; a magic link is the sole way in, and creates the account if needed | ✅ done |
| 13 | Global inbox — cards can have no project; moved into the sidebar, drag in/out of any project's kanban columns | ✅ done |
| 14 | New-project form moved to the dashboard; sidebar project list is now a switcher dropdown; Kanban is a project's default tab | ✅ done |
| 15 | Passkeys (WebAuthn) — register from the profile page, sign in with one instead of a magic link; a dismissible notice nudges users with none | ✅ done |
There is no password. Signing in is entering an email address and opening the
magic link sent to it — the same step creates the account the first time. See
@@ -116,6 +117,8 @@ environment). See [.env.example](.env.example).
| `JWT_TTL` | `86400` | Token lifetime in seconds |
| `APP_ALLOW_REGISTRATION` | `true` | When `false`, a magic link is only ever sent to an existing address — an unknown one is silently ignored, so no new accounts get created |
| `APP_URL` | `http://localhost:8080` | Base URL used to build magic links (`http://localhost:5173` for a host `npm run dev`) |
| `WEBAUTHN_RP_ID` | `APP_URL`'s host | Passkey relying party ID (domain). Must be `localhost` or a real domain over HTTPS — a LAN IP won't work |
| `WEBAUTHN_RP_NAME` | `Projects` | Passkey relying party display name, shown in the browser/OS prompt |
| `MAIL_TRANSPORT` | `mail` | `mail` (PHP `mail()`), `smtp`, or `log` (append to a file) |
| `MAIL_FROM` / `MAIL_FROM_NAME` | `no-reply@todo.test` / `Projects` | Envelope sender |
| `MAIL_LOG_PATH` | `storage/mail.log` | Where `log` transport writes |
@@ -141,12 +144,15 @@ Base path: `/api`. All request and response bodies are JSON; send
There is no password and no separate registration endpoint. Entering an email
address and opening the link sent to it is the entire flow, for a brand-new
address and a returning one alike.
address and a returning one alike. A user can also register one or more
[passkeys](#passkeys) and use one instead, once signed in at least once.
| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| `POST` | `/api/auth/magic-link` | — | email a one-time sign-in link, creating the account first if the address is new |
| `POST` | `/api/auth/verify-email` | — | consume the token: sign in, and (the first time) mark the address verified, or apply a pending email change |
| `POST` | `/api/auth/passkey/options` | — | a challenge for signing in with a passkey (see [Passkeys](#passkeys)) |
| `POST` | `/api/auth/passkey/verify` | — | verify a passkey response and sign in |
| `GET` | `/api/me` | ✔ | the current user |
| `POST` | `/api/email/change` | ✔ | request a **deferred** email change |
@@ -180,6 +186,7 @@ Body: `{ "token": "..." }`. A missing/invalid, already-used, or expired token is
"email_verified": true,
"email_verified_at": "2026-09-03T12:00:00Z",
"pending_email": null,
"has_passkey": false,
"created_at": "2026-09-03T12:00:00Z"
},
"token": "<jwt>",
@@ -219,6 +226,49 @@ opened — until then `GET /api/me` still shows the old address, with
`pending_email` set. Opening that link both changes the address and re-verifies
it, via the same `/api/auth/verify-email`.
### Passkeys
WebAuthn, via [lbuchs/webauthn](https://github.com/lbuchs/WebAuthn). A passkey
is always registered as a **discoverable, user-verified** credential, which is
what makes login usernameless: the browser prompts the signed-in device for
whichever passkey it has for this site, with no email typed first. There's no
attestation/provenance check (`'none'` format) — this only confirms "the same
device that registered", the standard trust model for a public site's own
users, not a fleet of company-issued security keys.
| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| `GET` | `/api/passkeys` | ✔ | list the caller's passkeys |
| `POST` | `/api/passkeys/options` | ✔ | a registration challenge |
| `POST` | `/api/passkeys` | ✔ | verify the browser's response and store the credential |
| `DELETE` | `/api/passkeys/{id}` | ✔ | remove a passkey (`204`) |
| `POST` | `/api/auth/passkey/options` | — | a login challenge (no email — discoverable) |
| `POST` | `/api/auth/passkey/verify` | — | verify and sign in |
Both `.../options` endpoints return `{ "challenge_id": 1, "options": { "publicKey": {…} } }`
`options.publicKey` is passed more or less directly to
[`navigator.credentials.create()`](https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/create)
/ [`.get()`](https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/get)
(binary fields travel as base64url strings; the frontend converts them —
see [web/README.md](web/README.md)). `challenge_id` identifies a **single-use**
challenge, good for 5 minutes, and must be sent back with the browser's
response:
- `POST /api/passkeys` body: `{ "challenge_id": 1, "credential": {…}, "label": "My laptop" }`.
`credential` is `{ id, response: { clientDataJSON, attestationObject } }`
(all base64url). `201` with the stored passkey
(`{ id, label, created_at, last_used_at }` — never the credential id or
public key) on success; `400` if the response doesn't check out, `409` if
that credential is already registered.
- `POST /api/auth/passkey/verify` body: `{ "challenge_id": 1, "credential": {…} }`,
where `credential` also carries `authenticatorData`, `signature`, and
`userHandle`. Success returns the same `{ user, token, expires_at }` envelope
as `/api/auth/verify-email`. `401` if the credential isn't recognised or the
signature doesn't check out.
`user.has_passkey` (on every user object) is `true` once at least one is
registered — that's what the frontend's "add a passkey" notice keys off.
### Projects
All routes below require `Authorization: Bearer <jwt>`. A project belongs to one
@@ -416,8 +466,8 @@ src/Auth/AuthMiddleware.php Bearer-token authentication
src/Auth/SessionPayload.php Shared user + session JSON shape
src/Mail/ Mailer interface, SMTP/mail()/log transports, EmailVerifier
src/Http/JsonErrorHandler.php Uniform JSON error envelope
src/Http/Controllers/ Request handlers (Auth, EmailVerification, Project, Card, CardStatus)
src/Repository/ Database access (User, EmailVerification, Project, Card, CardStatus)
src/Http/Controllers/ Request handlers (Auth, EmailVerification, Passkey, Project, Card, CardStatus)
src/Repository/ Database access (User, EmailVerification, Passkey, WebAuthnChallenge, Project, Card, CardStatus)
src/Support/Validator.php Request-body validation helper
migrations/*.sql Schema, applied by bin/migrate.php
Dockerfile Multi-stage: Node frontend build + PHP 8.3/Apache runtime