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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user