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>
There is now one way in: POST /api/auth/magic-link with an email address. It
creates the account (unverified) if the address is new -- that's the only
"sign up" -- and emails a sign-in link either way, subject to the existing
60s-per-user resend throttle. Opening the link (POST /api/auth/verify-email,
unchanged) is what actually creates the session, and marks the address
verified the first time. Since a session can now only ever come from an
opened link, "authenticated" implies "verified" -- there's no more
authenticated-but-unverified state, so the resend-verification endpoint and
all the "verify your email" nagging UI are gone too.
Backend
- migrations/008: ALTER TABLE users DROP COLUMN password_hash.
- UserRepository: create() takes only an email; new findOrCreateByEmail()
(race-safe) backs the magic-link endpoint.
- AuthController: register()/login() removed; requestLoginLink() now
find-or-creates before sending.
- EmailVerificationController: resend() removed (dead -- you can't be
authenticated and unverified); requestChange() drops the password check,
now just { email }.
- EmailVerifier: sendVerification() removed (unused once register() and
resend() are gone); sendLoginLink() is the one email people get.
- Routes: POST /auth/register, POST /auth/login, POST /email/verification
all gone.
Frontend
- LoginView: email field + "Send sign-in link" button, nothing else.
RegisterView and the /register route are gone.
- auth store: register()/login()/resendVerification() removed;
requestEmailChange() drops the password param.
- ProfileView: password field and the "verify your email" section removed,
leaving just the change-email form.
- App.vue: the "verify email" header badge is gone; DashboardView's
unverified-address notice is gone.
- Now-dead .badge/.badge--warn/a.badge CSS removed.
Tests: AuthTest and EmailVerificationTest rewritten for the new flow (52
tests total, down from 58 -- consolidated, not reduced coverage).
ApiTestCase::authHeader() signs in via the real magic-link -> verify flow.
Verified end-to-end against the rebuilt container and the dev server: a brand
new address gets an account + session from one link; /auth/register,
/auth/login and /email/verification all 404; the UI shows no password field
anywhere and no verification nagging. Also fixed the README's "Try it" curl
snippets, which had been silently broken since JSON_PRETTY_PRINT was added
(grep patterns didn't tolerate the space after ':').
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Backend
- New Mail namespace: a Mailer interface with SMTP (phpmailer), PHP mail()
(the default fallback), and log-to-file transports, selected by
MAIL_TRANSPORT. EmailVerifier issues a hashed, 15-minute magic-link token
and sends the link (APP_URL/verify-email?token=...).
- Migration 005: email_verifications table + users.verification_email_sent_at.
- Registration now emails a verification link (best effort — a send failure
doesn't fail registration).
- POST /api/auth/verify-email consumes a token and returns a session, so
opening the link verifies the address (or applies a pending email change)
and logs the user in. Single-use; distinct 400s for invalid/used/expired.
- POST /api/email/verification resends; POST /api/email/change requests a
deferred change (current password required; link goes to the new address;
users.email only updates when that link is opened). Both throttled to once
per 60s, returning 429 + retry_after.
- GET /api/me and every session payload now include pending_email. Shared
SessionPayload builds the user/session JSON for all entry points.
Frontend
- /verify-email view: posts the token, adopts the returned session, redirects.
- /profile view: shows address + status, a resend button with a live cooldown
(driven by retry_after / 429), and a change-email form (new address +
current password) that surfaces the pending change.
- Header shows a "verify email" badge linking to the profile.
Tests: 9 new (EmailVerificationTest) covering the link lifecycle, throttle,
and deferred change; AuthTest folded into ApiTestCase, which now routes mail
to a per-test log. Suite: 32 passing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Slim 4 + SQLite todo-list API providing email/password registration,
login, and an authenticated GET /me endpoint. Stateless HS256 JWTs,
bcrypt password hashing, uniform JSON error envelope, and a SQL
migration runner. Includes PHPUnit feature tests and stage-1 docs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>