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
+6
View File
@@ -29,6 +29,12 @@ APP_ALLOW_REGISTRATION=true
# together on http://localhost:8080; a host `npm run dev` serves it on :5173. # together on http://localhost:8080; a host `npm run dev` serves it on :5173.
APP_URL=http://localhost:8080 APP_URL=http://localhost:8080
# WebAuthn (passkeys). The relying party ID is the domain a passkey is bound
# to -- defaults to APP_URL's host. Browsers only allow `localhost` or a real
# domain served over HTTPS, so passkeys won't work when APP_URL is a LAN IP.
WEBAUTHN_RP_ID=
WEBAUTHN_RP_NAME=Projects
# Email delivery. # Email delivery.
# mail — PHP's built-in mail() function (default) # mail — PHP's built-in mail() function (default)
# smtp — the SMTP server configured below # smtp — the SMTP server configured below
+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 | | 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 | | 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 | | 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 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 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 | | `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_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`) | | `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_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_FROM` / `MAIL_FROM_NAME` | `no-reply@todo.test` / `Projects` | Envelope sender |
| `MAIL_LOG_PATH` | `storage/mail.log` | Where `log` transport writes | | `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 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 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 | | 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/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/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 | | `GET` | `/api/me` | ✔ | the current user |
| `POST` | `/api/email/change` | ✔ | request a **deferred** email change | | `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": true,
"email_verified_at": "2026-09-03T12:00:00Z", "email_verified_at": "2026-09-03T12:00:00Z",
"pending_email": null, "pending_email": null,
"has_passkey": false,
"created_at": "2026-09-03T12:00:00Z" "created_at": "2026-09-03T12:00:00Z"
}, },
"token": "<jwt>", "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 `pending_email` set. Opening that link both changes the address and re-verifies
it, via the same `/api/auth/verify-email`. 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 ### Projects
All routes below require `Authorization: Bearer <jwt>`. A project belongs to one 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/Auth/SessionPayload.php Shared user + session JSON shape
src/Mail/ Mailer interface, SMTP/mail()/log transports, EmailVerifier src/Mail/ Mailer interface, SMTP/mail()/log transports, EmailVerifier
src/Http/JsonErrorHandler.php Uniform JSON error envelope src/Http/JsonErrorHandler.php Uniform JSON error envelope
src/Http/Controllers/ Request handlers (Auth, EmailVerification, Project, Card, CardStatus) src/Http/Controllers/ Request handlers (Auth, EmailVerification, Passkey, Project, Card, CardStatus)
src/Repository/ Database access (User, EmailVerification, Project, Card, CardStatus) src/Repository/ Database access (User, EmailVerification, Passkey, WebAuthnChallenge, Project, Card, CardStatus)
src/Support/Validator.php Request-body validation helper src/Support/Validator.php Request-body validation helper
migrations/*.sql Schema, applied by bin/migrate.php migrations/*.sql Schema, applied by bin/migrate.php
Dockerfile Multi-stage: Node frontend build + PHP 8.3/Apache runtime Dockerfile Multi-stage: Node frontend build + PHP 8.3/Apache runtime
+1
View File
@@ -10,6 +10,7 @@
"ext-pdo": "*", "ext-pdo": "*",
"ext-pdo_sqlite": "*", "ext-pdo_sqlite": "*",
"firebase/php-jwt": "^7.0", "firebase/php-jwt": "^7.0",
"lbuchs/webauthn": "^2.2",
"phpmailer/phpmailer": "^7.1", "phpmailer/phpmailer": "^7.1",
"slim/psr7": "^1.6", "slim/psr7": "^1.6",
"slim/slim": "^4.12", "slim/slim": "^4.12",
Generated
+46 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "0ee5526493b6ed24fc6d7f3a68af11cc", "content-hash": "7e40179f4f6fad3fb02a8f8d7438ab9b",
"packages": [ "packages": [
{ {
"name": "fig/http-message-util", "name": "fig/http-message-util",
@@ -190,6 +190,51 @@
], ],
"time": "2026-08-24T09:06:52+00:00" "time": "2026-08-24T09:06:52+00:00"
}, },
{
"name": "lbuchs/webauthn",
"version": "v2.2.0",
"source": {
"type": "git",
"url": "https://github.com/lbuchs/WebAuthn.git",
"reference": "20adb4a240c3997bd8cac7dc4dde38ab0bea0ed1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/lbuchs/WebAuthn/zipball/20adb4a240c3997bd8cac7dc4dde38ab0bea0ed1",
"reference": "20adb4a240c3997bd8cac7dc4dde38ab0bea0ed1",
"shasum": ""
},
"require": {
"php": ">=8.0.0"
},
"type": "library",
"autoload": {
"psr-4": {
"lbuchs\\WebAuthn\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Lukas Buchs",
"role": "Developer"
}
],
"description": "A simple PHP WebAuthn (FIDO2) server library",
"homepage": "https://github.com/lbuchs/webauthn",
"keywords": [
"Authentication",
"webauthn"
],
"support": {
"issues": "https://github.com/lbuchs/WebAuthn/issues",
"source": "https://github.com/lbuchs/WebAuthn/tree/v2.2.0"
},
"time": "2024-07-04T07:17:40+00:00"
},
{ {
"name": "nikic/fast-route", "name": "nikic/fast-route",
"version": "1.3.1", "version": "1.3.1",
+4
View File
@@ -19,6 +19,10 @@ services:
APP_ALLOW_REGISTRATION: "${APP_ALLOW_REGISTRATION:-true}" APP_ALLOW_REGISTRATION: "${APP_ALLOW_REGISTRATION:-true}"
# The SPA and the API are both served from this container. # The SPA and the API are both served from this container.
APP_URL: "${APP_URL:-http://localhost:8080}" APP_URL: "${APP_URL:-http://localhost:8080}"
# Passkeys: defaults to APP_URL's host (localhost). Browsers require
# `localhost` or a real domain over HTTPS -- a LAN IP won't work.
WEBAUTHN_RP_ID: "${WEBAUTHN_RP_ID:-}"
WEBAUTHN_RP_NAME: "${WEBAUTHN_RP_NAME:-Projects}"
# Deliver to the Mailpit catcher below; read mail at http://localhost:8025. # Deliver to the Mailpit catcher below; read mail at http://localhost:8025.
MAIL_TRANSPORT: "${MAIL_TRANSPORT:-smtp}" MAIL_TRANSPORT: "${MAIL_TRANSPORT:-smtp}"
MAIL_FROM: "${MAIL_FROM:-no-reply@todo.test}" MAIL_FROM: "${MAIL_FROM:-no-reply@todo.test}"
+30
View File
@@ -0,0 +1,30 @@
-- Passkeys (WebAuthn discoverable credentials): an alternative to the email
-- magic link. A user may register several (one per device/authenticator).
CREATE TABLE IF NOT EXISTS passkeys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
credential_id TEXT NOT NULL UNIQUE,
public_key TEXT NOT NULL,
sign_count INTEGER NOT NULL DEFAULT 0,
label TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
last_used_at TEXT NULL
);
CREATE INDEX IF NOT EXISTS idx_passkeys_user ON passkeys (user_id);
-- Short-lived, single-use WebAuthn challenges bridging the "options" and
-- "verify" calls of both the registration and login ceremonies. user_id is
-- set for a registration (tied to the signed-in caller) and NULL for a login
-- attempt, since who's logging in isn't known until the credential comes back.
CREATE TABLE IF NOT EXISTS webauthn_challenges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NULL REFERENCES users (id) ON DELETE CASCADE,
purpose TEXT NOT NULL CHECK (purpose IN ('register', 'login')),
challenge TEXT NOT NULL,
expires_at TEXT NOT NULL,
consumed_at TEXT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expiry ON webauthn_challenges (expires_at);
+5 -3
View File
@@ -5,18 +5,19 @@ declare(strict_types=1);
namespace App\Auth; namespace App\Auth;
use App\Repository\EmailVerificationRepository; use App\Repository\EmailVerificationRepository;
use App\Repository\PasskeyRepository;
/** /**
* Builds the JSON representation of a user and the session envelope returned * Builds the JSON representation of a user and the session envelope returned
* when a magic link is opened (sign-up, sign-in, and email-change confirmation * when a magic link (or a passkey) signs someone in. Shared so every entry
* all go through the same verify-email endpoint). Shared so every entry point * point agrees on the shape.
* agrees on the shape.
*/ */
final class SessionPayload final class SessionPayload
{ {
public function __construct( public function __construct(
private readonly JwtService $jwt, private readonly JwtService $jwt,
private readonly EmailVerificationRepository $tokens, private readonly EmailVerificationRepository $tokens,
private readonly PasskeyRepository $passkeys,
) { ) {
} }
@@ -49,6 +50,7 @@ final class SessionPayload
'email_verified' => $verifiedAt !== null, 'email_verified' => $verifiedAt !== null,
'email_verified_at' => $verifiedAt, 'email_verified_at' => $verifiedAt,
'pending_email' => $this->tokens->pendingEmailFor((int) $user['id']), 'pending_email' => $this->tokens->pendingEmailFor((int) $user['id']),
'has_passkey' => $this->passkeys->countForUser((int) $user['id']) > 0,
'created_at' => $user['created_at'] ?? null, 'created_at' => $user['created_at'] ?? null,
]; ];
} }
+274
View File
@@ -0,0 +1,274 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Auth\SessionPayload;
use App\Exception\ApiException;
use App\Repository\PasskeyRepository;
use App\Repository\UserRepository;
use App\Repository\WebAuthnChallengeRepository;
use lbuchs\WebAuthn\Binary\ByteBuffer;
use lbuchs\WebAuthn\WebAuthn;
use lbuchs\WebAuthn\WebAuthnException;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
/**
* Passkeys (WebAuthn): registering one or more per-device credentials while
* signed in, and using one to sign in instead of a magic link. Registration
* always asks for a discoverable ("resident key") credential with required
* user verification -- that combination is what makes it a passkey rather
* than a bare security key, and it's what lets login be usernameless: the
* browser prompts the user to pick from whatever passkeys it holds for this
* site, with no email typed first.
*/
final class PasskeyController extends Controller
{
private const LABEL_MAX = 100;
public function __construct(
private readonly WebAuthn $webAuthn,
private readonly PasskeyRepository $passkeys,
private readonly WebAuthnChallengeRepository $challenges,
private readonly UserRepository $users,
private readonly SessionPayload $session,
) {
}
/**
* GET /api/passkeys (auth)
*/
public function index(Request $request, Response $response): Response
{
$passkeys = $this->passkeys->allForUser($this->user($request)['id']);
return $this->json($response, ['passkeys' => array_map($this->present(...), $passkeys)]);
}
/**
* POST /api/passkeys/options (auth) -- a registration challenge.
*/
public function registerOptions(Request $request, Response $response): Response
{
$user = $this->user($request);
$excludeIds = array_map(
static fn (array $p): ByteBuffer => ByteBuffer::fromHex($p['credential_id']),
$this->passkeys->allForUser($user['id']),
);
$args = $this->webAuthn->getCreateArgs(
(string) $user['id'],
$user['email'],
$user['email'],
WebAuthnChallengeRepository::TTL_SECONDS,
true, // requireResidentKey: must be discoverable for usernameless login
'required', // requireUserVerification: what makes this a passkey
null,
$excludeIds,
);
$challengeId = $this->challenges->create($user['id'], 'register', $this->webAuthn->getChallenge()->getHex());
return $this->json($response, ['challenge_id' => $challengeId, 'options' => $args]);
}
/**
* POST /api/passkeys (auth) -- verify the browser's response and store the credential.
*/
public function store(Request $request, Response $response): Response
{
$userId = $this->user($request)['id'];
$body = $this->body($request);
$challengeId = $body['challenge_id'] ?? null;
if (!is_int($challengeId)) {
throw new ApiException('challenge_id is required.', 422);
}
$credential = $body['credential'] ?? null;
if (!is_array($credential)) {
throw new ApiException('credential is required.', 422);
}
$challenge = $this->challenges->consume($challengeId, 'register', $userId);
if ($challenge === null) {
throw new ApiException('This registration request has expired. Please try again.', 400);
}
try {
$data = $this->webAuthn->processCreate(
$this->decodeField($credential, 'clientDataJSON'),
$this->decodeField($credential, 'attestationObject'),
ByteBuffer::fromHex($challenge['challenge']),
true, // requireUserVerification
true, // requireUserPresent
false, // failIfRootMismatch -- we don't check attestation provenance
false, // requireCtsProfileMatch
);
} catch (WebAuthnException $e) {
throw new ApiException('Could not add that passkey: ' . $e->getMessage(), 400);
}
// AuthenticatorData::getCredentialId() returns a raw binary string, not
// a ByteBuffer (unlike most other binary fields in this library).
$credentialId = bin2hex($data->credentialId);
if ($this->passkeys->findByCredentialId($credentialId) !== null) {
throw new ApiException('That passkey is already registered.', 409);
}
$passkey = $this->passkeys->create(
$userId,
$credentialId,
$data->credentialPublicKey,
$data->signatureCounter ?? 0,
$this->labelFrom($body),
);
return $this->json($response, ['passkey' => $this->present($passkey)], 201);
}
/**
* DELETE /api/passkeys/{passkeyId} (auth)
*/
public function destroy(Request $request, Response $response, array $args): Response
{
$passkey = $this->passkeys->findOwnedBy((int) $args['passkeyId'], $this->user($request)['id']);
if ($passkey === null) {
throw new ApiException('Passkey not found.', 404);
}
$this->passkeys->delete($passkey['id']);
return $response->withStatus(204);
}
/**
* POST /api/auth/passkey/options (public) -- a discoverable login challenge.
* No email needed: allowCredentials is left empty, so the browser prompts
* the user to choose from any passkey it has stored for this site.
*/
public function loginOptions(Request $request, Response $response): Response
{
$args = $this->webAuthn->getGetArgs(
[],
WebAuthnChallengeRepository::TTL_SECONDS,
true,
true,
true,
true,
true,
'required',
);
$challengeId = $this->challenges->create(null, 'login', $this->webAuthn->getChallenge()->getHex());
return $this->json($response, ['challenge_id' => $challengeId, 'options' => $args]);
}
/**
* POST /api/auth/passkey/verify (public)
*/
public function loginVerify(Request $request, Response $response): Response
{
$body = $this->body($request);
$challengeId = $body['challenge_id'] ?? null;
if (!is_int($challengeId)) {
throw new ApiException('challenge_id is required.', 422);
}
$credential = $body['credential'] ?? null;
if (!is_array($credential) || !is_string($credential['id'] ?? null) || $credential['id'] === '') {
throw new ApiException('credential is required.', 422);
}
$challenge = $this->challenges->consume($challengeId, 'login');
if ($challenge === null) {
throw new ApiException('This sign-in request has expired. Please try again.', 400);
}
$credentialId = ByteBuffer::fromBase64Url($credential['id'])->getHex();
$passkey = $this->passkeys->findByCredentialId($credentialId);
if ($passkey === null) {
throw new ApiException('This passkey is not recognised.', 401);
}
$userHandle = $this->decodeField($credential, 'userHandle', required: false);
if ($userHandle !== null && $userHandle !== (string) $passkey['user_id']) {
throw new ApiException('This passkey is not recognised.', 401);
}
try {
$verified = $this->webAuthn->processGet(
$this->decodeField($credential, 'clientDataJSON'),
$this->decodeField($credential, 'authenticatorData'),
$this->decodeField($credential, 'signature'),
$passkey['public_key'],
ByteBuffer::fromHex($challenge['challenge']),
$passkey['sign_count'],
true, // requireUserVerification
);
} catch (WebAuthnException $e) {
throw new ApiException('Could not verify that passkey: ' . $e->getMessage(), 401);
}
if (!$verified) {
throw new ApiException('Could not verify that passkey.', 401);
}
$this->passkeys->markUsed($passkey['id'], $this->webAuthn->getSignatureCounter() ?? $passkey['sign_count']);
$user = $this->users->findById($passkey['user_id']);
if ($user === null) {
throw new ApiException('This account no longer exists.', 404);
}
return $this->json($response, $this->session->forUser($user));
}
/**
* @param array<string, mixed> $credential
*/
private function decodeField(array $credential, string $field, bool $required = true): ?string
{
$value = $credential['response'][$field] ?? null;
if (!is_string($value) || $value === '') {
if ($required) {
throw new ApiException("Malformed passkey response ({$field}).", 422);
}
return null;
}
return ByteBuffer::fromBase64Url($value)->getBinaryString();
}
/**
* @param array<string, mixed> $body
*/
private function labelFrom(array $body): string
{
$label = $body['label'] ?? null;
if (is_string($label) && trim($label) !== '') {
return mb_substr(trim($label), 0, self::LABEL_MAX);
}
return 'Passkey added ' . gmdate('j M Y');
}
/**
* @param array{id: int, label: string, created_at: string, last_used_at: string|null} $passkey
* @return array<string, mixed>
*/
private function present(array $passkey): array
{
return [
'id' => $passkey['id'],
'label' => $passkey['label'],
'created_at' => $passkey['created_at'],
'last_used_at' => $passkey['last_used_at'],
];
}
}
+120
View File
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use PDO;
/**
* Data access for the `passkeys` table (registered WebAuthn credentials).
*
* @phpstan-type PasskeyRow array{
* id: int, user_id: int, credential_id: string, public_key: string,
* sign_count: int, label: string, created_at: string, last_used_at: string|null
* }
*/
final class PasskeyRepository
{
public function __construct(private readonly PDO $pdo)
{
}
/**
* @return PasskeyRow[]
*/
public function allForUser(int $userId): array
{
$stmt = $this->pdo->prepare(
'SELECT * FROM passkeys WHERE user_id = :user ORDER BY created_at ASC, id ASC'
);
$stmt->execute(['user' => $userId]);
return array_map($this->cast(...), $stmt->fetchAll());
}
public function countForUser(int $userId): int
{
$stmt = $this->pdo->prepare('SELECT COUNT(*) FROM passkeys WHERE user_id = :user');
$stmt->execute(['user' => $userId]);
return (int) $stmt->fetchColumn();
}
/**
* @return PasskeyRow|null
*/
public function findByCredentialId(string $credentialId): ?array
{
$stmt = $this->pdo->prepare('SELECT * FROM passkeys WHERE credential_id = :id');
$stmt->execute(['id' => $credentialId]);
$row = $stmt->fetch();
return $row === false ? null : $this->cast($row);
}
/**
* @return PasskeyRow|null
*/
public function findOwnedBy(int $id, int $userId): ?array
{
$stmt = $this->pdo->prepare('SELECT * FROM passkeys WHERE id = :id AND user_id = :user');
$stmt->execute(['id' => $id, 'user' => $userId]);
$row = $stmt->fetch();
return $row === false ? null : $this->cast($row);
}
/**
* @return PasskeyRow
*/
public function create(int $userId, string $credentialId, string $publicKey, int $signCount, string $label): array
{
$stmt = $this->pdo->prepare(
'INSERT INTO passkeys (user_id, credential_id, public_key, sign_count, label)
VALUES (:user, :credential_id, :public_key, :sign_count, :label)'
);
$stmt->execute([
'user' => $userId,
'credential_id' => $credentialId,
'public_key' => $publicKey,
'sign_count' => $signCount,
'label' => $label,
]);
/** @var PasskeyRow $passkey */
$passkey = $this->findByCredentialId($credentialId);
return $passkey;
}
/** Record a successful login: bump the signature counter and last-used timestamp. */
public function markUsed(int $id, int $signCount): void
{
$this->pdo->prepare(
"UPDATE passkeys SET sign_count = :count, last_used_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = :id"
)->execute(['count' => $signCount, 'id' => $id]);
}
public function delete(int $id): void
{
$this->pdo->prepare('DELETE FROM passkeys WHERE id = :id')->execute(['id' => $id]);
}
/**
* @param array<string, mixed> $row
* @return PasskeyRow
*/
private function cast(array $row): array
{
$row['id'] = (int) $row['id'];
$row['user_id'] = (int) $row['user_id'];
$row['sign_count'] = (int) $row['sign_count'];
/** @var PasskeyRow $row */
return $row;
}
}
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use PDO;
/**
* Short-lived, single-use challenges bridging a WebAuthn ceremony's "options"
* and "verify" steps (see PasskeyController). A registration challenge is
* tied to the signed-in caller; a login challenge has no user_id, since who's
* logging in isn't known until the credential comes back.
*
* @phpstan-type ChallengeRow array{
* id: int, user_id: int|null, purpose: string, challenge: string,
* expires_at: string, consumed_at: string|null
* }
*/
final class WebAuthnChallengeRepository
{
public const TTL_SECONDS = 300; // 5 minutes
public function __construct(private readonly PDO $pdo)
{
}
public function create(?int $userId, string $purpose, string $challengeHex): int
{
$expiresAt = gmdate('Y-m-d\TH:i:s\Z', time() + self::TTL_SECONDS);
$stmt = $this->pdo->prepare(
'INSERT INTO webauthn_challenges (user_id, purpose, challenge, expires_at)
VALUES (:user, :purpose, :challenge, :expires_at)'
);
$stmt->execute([
'user' => $userId,
'purpose' => $purpose,
'challenge' => $challengeHex,
'expires_at' => $expiresAt,
]);
return (int) $this->pdo->lastInsertId();
}
/**
* Atomically consume a challenge: valid, right purpose, right owner (when
* given), not expired, not already used. Returns null if any of that
* fails -- the caller should treat that as an invalid/expired request.
*
* @return ChallengeRow|null
*/
public function consume(int $id, string $purpose, ?int $expectedUserId = null): ?array
{
$stmt = $this->pdo->prepare('SELECT * FROM webauthn_challenges WHERE id = :id');
$stmt->execute(['id' => $id]);
$row = $stmt->fetch();
if ($row === false
|| $row['purpose'] !== $purpose
|| $row['consumed_at'] !== null
|| strtotime($row['expires_at']) < time()
|| ($expectedUserId !== null && (int) $row['user_id'] !== $expectedUserId)
) {
return null;
}
$update = $this->pdo->prepare(
"UPDATE webauthn_challenges SET consumed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = :id AND consumed_at IS NULL"
);
$update->execute(['id' => $id]);
if ($update->rowCount() === 0) {
return null; // lost a race with a concurrent consume
}
$row['id'] = (int) $row['id'];
$row['user_id'] = $row['user_id'] === null ? null : (int) $row['user_id'];
/** @var ChallengeRow $row */
return $row;
}
}
+21 -1
View File
@@ -19,6 +19,10 @@ final class Config
public readonly bool $allowRegistration, public readonly bool $allowRegistration,
/** Base URL of the frontend, used to build magic links. */ /** Base URL of the frontend, used to build magic links. */
public readonly string $appUrl, public readonly string $appUrl,
/** WebAuthn relying party ID -- the domain a passkey is bound to. */
public readonly string $webauthnRpId,
/** WebAuthn relying party display name, shown by the browser/OS passkey prompt. */
public readonly string $webauthnRpName,
public readonly MailConfig $mail, public readonly MailConfig $mail,
) { ) {
} }
@@ -46,6 +50,12 @@ final class Config
$appUrl = rtrim(self::env('APP_URL', 'http://localhost:5173'), '/'); $appUrl = rtrim(self::env('APP_URL', 'http://localhost:5173'), '/');
// A passkey is bound to a domain (the "relying party ID"), never a full
// origin -- defaults to the frontend's host. WebAuthn requires this to
// be `localhost` or a real domain served over HTTPS; a LAN IP won't work.
$webauthnRpId = self::env('WEBAUTHN_RP_ID') ?? (parse_url($appUrl, PHP_URL_HOST) ?: 'localhost');
$webauthnRpName = self::env('WEBAUTHN_RP_NAME', 'Projects');
$mailLogPath = self::env('MAIL_LOG_PATH', $storagePath . '/mail.log'); $mailLogPath = self::env('MAIL_LOG_PATH', $storagePath . '/mail.log');
if (!self::isAbsolutePath($mailLogPath)) { if (!self::isAbsolutePath($mailLogPath)) {
$mailLogPath = $basePath . '/' . ltrim($mailLogPath, '/'); $mailLogPath = $basePath . '/' . ltrim($mailLogPath, '/');
@@ -63,7 +73,17 @@ final class Config
smtpEncryption: strtolower(self::env('MAIL_SMTP_ENCRYPTION', 'tls')), smtpEncryption: strtolower(self::env('MAIL_SMTP_ENCRYPTION', 'tls')),
); );
return new self($databasePath, $jwtSecret, $jwtTtl, $displayErrors, $allowRegistration, $appUrl, $mail); return new self(
$databasePath,
$jwtSecret,
$jwtTtl,
$displayErrors,
$allowRegistration,
$appUrl,
$webauthnRpId,
$webauthnRpName,
$mail,
);
} }
private static function env(string $key, ?string $default = null): ?string private static function env(string $key, ?string $default = null): ?string
+25 -1
View File
@@ -9,6 +9,7 @@ use App\Http\Controllers\AuthController;
use App\Http\Controllers\CardController; use App\Http\Controllers\CardController;
use App\Http\Controllers\CardStatusController; use App\Http\Controllers\CardStatusController;
use App\Http\Controllers\EmailVerificationController; use App\Http\Controllers\EmailVerificationController;
use App\Http\Controllers\PasskeyController;
use App\Http\Controllers\ProjectController; use App\Http\Controllers\ProjectController;
use App\Http\JsonErrorHandler; use App\Http\JsonErrorHandler;
use App\Mail\EmailVerifier; use App\Mail\EmailVerifier;
@@ -18,10 +19,13 @@ use App\Mail\PhpMailerMailer;
use App\Repository\CardRepository; use App\Repository\CardRepository;
use App\Repository\CardStatusRepository; use App\Repository\CardStatusRepository;
use App\Repository\EmailVerificationRepository; use App\Repository\EmailVerificationRepository;
use App\Repository\PasskeyRepository;
use App\Repository\ProjectRepository; use App\Repository\ProjectRepository;
use App\Repository\UserRepository; use App\Repository\UserRepository;
use App\Repository\WebAuthnChallengeRepository;
use App\Support\Config; use App\Support\Config;
use App\Support\Database; use App\Support\Database;
use lbuchs\WebAuthn\WebAuthn;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Factory\AppFactory; use Slim\Factory\AppFactory;
@@ -47,8 +51,10 @@ $projects = new ProjectRepository($database->pdo());
$cards = new CardRepository($database->pdo()); $cards = new CardRepository($database->pdo());
$cardStatuses = new CardStatusRepository($database->pdo()); $cardStatuses = new CardStatusRepository($database->pdo());
$verificationTokens = new EmailVerificationRepository($database->pdo()); $verificationTokens = new EmailVerificationRepository($database->pdo());
$passkeys = new PasskeyRepository($database->pdo());
$webauthnChallenges = new WebAuthnChallengeRepository($database->pdo());
$jwt = new JwtService($config->jwtSecret, $config->jwtTtl); $jwt = new JwtService($config->jwtSecret, $config->jwtTtl);
$session = new SessionPayload($jwt, $verificationTokens); $session = new SessionPayload($jwt, $verificationTokens, $passkeys);
/** @var Mailer $mailer */ /** @var Mailer $mailer */
$mailer = $config->mail->transport === 'log' $mailer = $config->mail->transport === 'log'
@@ -56,11 +62,19 @@ $mailer = $config->mail->transport === 'log'
: new PhpMailerMailer($config->mail); : new PhpMailerMailer($config->mail);
$verifier = new EmailVerifier($verificationTokens, $users, $mailer, $config->appUrl); $verifier = new EmailVerifier($verificationTokens, $users, $mailer, $config->appUrl);
// 'none' attestation: verify the credential is a legitimate WebAuthn response
// without checking authenticator provenance against a root CA -- the usual
// choice for "log in with the same device you registered", not a fleet of
// company-issued security keys. useBase64UrlEncoding=true so the challenge/
// ids in getCreateArgs()/getGetArgs() JSON straight to the frontend.
$webAuthn = new WebAuthn($config->webauthnRpName, $config->webauthnRpId, ['none'], true);
$authController = new AuthController($users, $session, $verifier, $config->allowRegistration); $authController = new AuthController($users, $session, $verifier, $config->allowRegistration);
$emailController = new EmailVerificationController($users, $verificationTokens, $verifier, $session); $emailController = new EmailVerificationController($users, $verificationTokens, $verifier, $session);
$projectController = new ProjectController($projects, $cardStatuses); $projectController = new ProjectController($projects, $cardStatuses);
$cardController = new CardController($projects, $cards, $cardStatuses); $cardController = new CardController($projects, $cards, $cardStatuses);
$cardStatusController = new CardStatusController($projects, $cardStatuses); $cardStatusController = new CardStatusController($projects, $cardStatuses);
$passkeyController = new PasskeyController($webAuthn, $passkeys, $webauthnChallenges, $users, $session);
$authMiddleware = new AuthMiddleware($jwt, $users); $authMiddleware = new AuthMiddleware($jwt, $users);
// --- Routes --------------------------------------------------------------- // --- Routes ---------------------------------------------------------------
@@ -71,6 +85,7 @@ $app->group('/api', function (RouteCollectorProxy $group) use (
$projectController, $projectController,
$cardController, $cardController,
$cardStatusController, $cardStatusController,
$passkeyController,
$authMiddleware, $authMiddleware,
) { ) {
$group->get('/health', function (Request $request, Response $response): Response { $group->get('/health', function (Request $request, Response $response): Response {
@@ -80,10 +95,19 @@ $app->group('/api', function (RouteCollectorProxy $group) use (
$group->post('/auth/magic-link', [$authController, 'requestLoginLink']); $group->post('/auth/magic-link', [$authController, 'requestLoginLink']);
$group->post('/auth/verify-email', [$emailController, 'verify']); $group->post('/auth/verify-email', [$emailController, 'verify']);
$group->post('/auth/passkey/options', [$passkeyController, 'loginOptions']);
$group->post('/auth/passkey/verify', [$passkeyController, 'loginVerify']);
$group->get('/me', [$authController, 'me'])->add($authMiddleware); $group->get('/me', [$authController, 'me'])->add($authMiddleware);
$group->post('/email/change', [$emailController, 'requestChange'])->add($authMiddleware); $group->post('/email/change', [$emailController, 'requestChange'])->add($authMiddleware);
$group->group('/passkeys', function (RouteCollectorProxy $passkeys) use ($passkeyController) {
$passkeys->get('', [$passkeyController, 'index']);
$passkeys->post('', [$passkeyController, 'store']);
$passkeys->post('/options', [$passkeyController, 'registerOptions']);
$passkeys->delete('/{passkeyId:[0-9]+}', [$passkeyController, 'destroy']);
})->add($authMiddleware);
$group->group('/projects', function (RouteCollectorProxy $projects) use ( $group->group('/projects', function (RouteCollectorProxy $projects) use (
$projectController, $projectController,
$cardController, $cardController,
+198
View File
@@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
namespace Tests;
/**
* The full register/verify and login/verify ceremonies need a real (or
* virtual) authenticator doing actual signing, which isn't practical from
* PHPUnit -- that round trip is exercised in the browser instead (a Chrome
* DevTools Protocol virtual authenticator). These tests cover everything
* that doesn't require a working credential: auth guards, the shape of the
* options responses, the challenge's single-use/expiry/purpose rules, input
* validation, and passkey list/remove CRUD.
*/
final class PasskeyTest extends ApiTestCase
{
public function test_passkey_routes_require_authentication(): void
{
self::assertSame(401, $this->request('GET', '/api/passkeys')->getStatusCode());
self::assertSame(401, $this->request('POST', '/api/passkeys/options')->getStatusCode());
self::assertSame(401, $this->request('POST', '/api/passkeys', ['challenge_id' => 1])->getStatusCode());
self::assertSame(401, $this->request('DELETE', '/api/passkeys/1')->getStatusCode());
}
public function test_a_new_user_has_no_passkeys(): void
{
$auth = $this->authHeader();
self::assertSame([], $this->decode($this->request('GET', '/api/passkeys', null, $auth))['passkeys']);
self::assertFalse($this->decode($this->request('GET', '/api/me', null, $auth))['user']['has_passkey']);
}
public function test_register_options_returns_a_discoverable_resident_key_challenge(): void
{
$auth = $this->authHeader('ada@example.com');
$response = $this->request('POST', '/api/passkeys/options', [], $auth);
self::assertSame(200, $response->getStatusCode());
$body = $this->decode($response);
self::assertIsInt($body['challenge_id']);
$publicKey = $body['options']['publicKey'];
self::assertTrue($publicKey['authenticatorSelection']['requireResidentKey']);
self::assertSame('required', $publicKey['authenticatorSelection']['residentKey']);
self::assertSame('required', $publicKey['authenticatorSelection']['userVerification']);
self::assertSame('ada@example.com', $publicKey['user']['name']);
self::assertNotEmpty($publicKey['challenge']);
}
public function test_login_options_is_public_and_usernameless(): void
{
$response = $this->request('POST', '/api/auth/passkey/options');
self::assertSame(200, $response->getStatusCode());
$publicKey = $this->decode($response)['options']['publicKey'];
self::assertArrayNotHasKey('allowCredentials', $publicKey);
self::assertSame('required', $publicKey['userVerification']);
self::assertNotEmpty($publicKey['challenge']);
}
public function test_register_requires_a_challenge_id_and_credential(): void
{
$auth = $this->authHeader();
self::assertSame(422, $this->request('POST', '/api/passkeys', ['credential' => []], $auth)->getStatusCode());
self::assertSame(422, $this->request('POST', '/api/passkeys', ['challenge_id' => 1], $auth)->getStatusCode());
}
public function test_register_rejects_a_bogus_credential_response(): void
{
$auth = $this->authHeader();
$challengeId = $this->decode($this->request('POST', '/api/passkeys/options', [], $auth))['challenge_id'];
$response = $this->request('POST', '/api/passkeys', [
'challenge_id' => $challengeId,
'credential' => ['response' => ['clientDataJSON' => 'bm90LXJlYWw', 'attestationObject' => 'bm90LXJlYWw']],
], $auth);
self::assertSame(400, $response->getStatusCode());
}
public function test_register_rejects_an_unknown_challenge(): void
{
$auth = $this->authHeader();
$response = $this->request('POST', '/api/passkeys', [
'challenge_id' => 999999,
'credential' => ['response' => ['clientDataJSON' => 'x', 'attestationObject' => 'x']],
], $auth);
self::assertSame(400, $response->getStatusCode());
}
public function test_a_registration_challenge_cannot_be_reused(): void
{
$auth = $this->authHeader();
$challengeId = $this->decode($this->request('POST', '/api/passkeys/options', [], $auth))['challenge_id'];
$body = [
'challenge_id' => $challengeId,
'credential' => ['response' => ['clientDataJSON' => 'bm90LXJlYWw', 'attestationObject' => 'bm90LXJlYWw']],
];
// First attempt fails on the bogus credential (400), but consumes the challenge either way.
$this->request('POST', '/api/passkeys', $body, $auth);
$second = $this->request('POST', '/api/passkeys', $body, $auth);
self::assertSame(400, $second->getStatusCode());
}
public function test_a_registration_challenge_cannot_be_used_for_login(): void
{
$auth = $this->authHeader();
$challengeId = $this->decode($this->request('POST', '/api/passkeys/options', [], $auth))['challenge_id'];
$response = $this->request('POST', '/api/auth/passkey/verify', [
'challenge_id' => $challengeId,
'credential' => ['id' => 'x', 'response' => []],
]);
self::assertSame(400, $response->getStatusCode());
}
public function test_an_expired_registration_challenge_is_rejected(): void
{
$auth = $this->authHeader();
$challengeId = $this->decode($this->request('POST', '/api/passkeys/options', [], $auth))['challenge_id'];
$this->db()->prepare('UPDATE webauthn_challenges SET expires_at = :past WHERE id = :id')->execute([
'past' => gmdate('Y-m-d\TH:i:s\Z', time() - 60),
'id' => $challengeId,
]);
$response = $this->request('POST', '/api/passkeys', [
'challenge_id' => $challengeId,
'credential' => ['response' => ['clientDataJSON' => 'x', 'attestationObject' => 'x']],
], $auth);
self::assertSame(400, $response->getStatusCode());
}
public function test_login_verify_rejects_an_unrecognised_credential(): void
{
$challengeId = $this->decode($this->request('POST', '/api/auth/passkey/options'))['challenge_id'];
$response = $this->request('POST', '/api/auth/passkey/verify', [
'challenge_id' => $challengeId,
'credential' => ['id' => 'bm9uZXhpc3RlbnQ', 'response' => []],
]);
self::assertSame(401, $response->getStatusCode());
}
public function test_login_verify_requires_a_challenge_id_and_credential(): void
{
self::assertSame(422, $this->request('POST', '/api/auth/passkey/verify', ['credential' => ['id' => 'x']])->getStatusCode());
self::assertSame(422, $this->request('POST', '/api/auth/passkey/verify', ['challenge_id' => 1])->getStatusCode());
}
public function test_listing_and_removing_a_passkey(): void
{
$auth = $this->authHeader('holder@example.com');
$userId = $this->decode($this->request('GET', '/api/me', null, $auth))['user']['id'];
$this->seedPasskey($userId, 'cred-1', 'My laptop');
$listed = $this->decode($this->request('GET', '/api/passkeys', null, $auth))['passkeys'];
self::assertCount(1, $listed);
self::assertSame('My laptop', $listed[0]['label']);
self::assertArrayNotHasKey('public_key', $listed[0]);
self::assertArrayNotHasKey('credential_id', $listed[0]);
self::assertTrue($this->decode($this->request('GET', '/api/me', null, $auth))['user']['has_passkey']);
self::assertSame(204, $this->request('DELETE', "/api/passkeys/{$listed[0]['id']}", null, $auth)->getStatusCode());
self::assertSame([], $this->decode($this->request('GET', '/api/passkeys', null, $auth))['passkeys']);
self::assertFalse($this->decode($this->request('GET', '/api/me', null, $auth))['user']['has_passkey']);
}
public function test_a_passkey_can_only_be_removed_by_its_owner(): void
{
$owner = $this->authHeader('owner@example.com');
$other = $this->authHeader('other@example.com');
$ownerId = $this->decode($this->request('GET', '/api/me', null, $owner))['user']['id'];
$passkeyId = $this->seedPasskey($ownerId, 'cred-2', 'Phone');
self::assertSame(404, $this->request('DELETE', "/api/passkeys/{$passkeyId}", null, $other)->getStatusCode());
self::assertCount(1, $this->decode($this->request('GET', '/api/passkeys', null, $owner))['passkeys']);
}
private function seedPasskey(int $userId, string $credentialId, string $label): int
{
$this->db()->prepare(
'INSERT INTO passkeys (user_id, credential_id, public_key, sign_count, label) VALUES (?, ?, ?, 0, ?)'
)->execute([$userId, $credentialId, '-----BEGIN PUBLIC KEY-----test-----END PUBLIC KEY-----', $label]);
return (int) $this->db()->lastInsertId();
}
}
+48 -11
View File
@@ -37,9 +37,11 @@ src/stores/cards.ts Pinia store: one project's cards (CRUD; no reordering --
src/stores/inbox.ts Pinia store: the caller's global inbox (fetch + create) src/stores/inbox.ts Pinia store: the caller's global inbox (fetch + create)
src/lib/api.ts fetch wrapper, bearer token, typed ApiError src/lib/api.ts fetch wrapper, bearer token, typed ApiError
src/lib/cardOrder.ts reorderColumn() -- PUT /api/cards/order, shared by the sidebar and kanban board src/lib/cardOrder.ts reorderColumn() -- PUT /api/cards/order, shared by the sidebar and kanban board
src/lib/webauthn.ts base64url <-> ArrayBuffer + the register/login passkey ceremonies
src/components/AppSidebar.vue left nav: Dashboard link, project dropdown, Inbox + form src/components/AppSidebar.vue left nav: Dashboard link, project dropdown, Inbox + form
src/components/CardRow.vue editable text + status chip + delete, one card src/components/CardRow.vue editable text + status chip + delete, one card
src/components/KanbanCard.vue small draggable card for the board columns and the inbox src/components/KanbanCard.vue small draggable card for the board columns and the inbox
src/components/PasskeyNotice.vue dismissible "add a passkey" banner across the top of the page
src/views/ DashboardView, ProjectView, LoginView, ProfileView, src/views/ DashboardView, ProjectView, LoginView, ProfileView,
VerifyEmailView VerifyEmailView
``` ```
@@ -111,10 +113,13 @@ side of the move.
## Auth flow ## Auth flow
There is no password and no separate sign-up — `LoginView` is just an email There is no password and no separate sign-up — `LoginView` is an email field
field and a "Send sign-in link" button (`POST /api/auth/magic-link`), for a new and a "Send sign-in link" button (`POST /api/auth/magic-link`), for a new
address or a returning one alike. On success it shows a "check your email" address or a returning one alike. On success it shows a "check your email"
message; it does not sign the caller in itself. message; it does not sign the caller in itself. If the browser supports
WebAuthn, a **"Log in with a passkey"** button sits above the form (see
[Passkeys](#passkeys)) — that one *does* sign the caller in directly, no email
round trip.
- `/verify-email?token=…` is the target for every magic link (sign-in and - `/verify-email?token=…` is the target for every magic link (sign-in and
email-change confirmation both). `VerifyEmailView` POSTs the token via email-change confirmation both). `VerifyEmailView` POSTs the token via
@@ -124,14 +129,46 @@ message; it does not sign the caller in itself.
On load, `fetchMe()` validates it via `GET /api/me`; a failure clears it. On load, `fetchMe()` validates it via `GET /api/me`; a failure clears it.
- Routes with `meta.requiresAuth` redirect to `/login` (preserving the intended - Routes with `meta.requiresAuth` redirect to `/login` (preserving the intended
path) when there is no authenticated user. path) when there is no authenticated user.
- Because the only way to get a session is opening a link, `user.email_verified` - Because the only way to get a session is opening a link or using a passkey
is always `true` for a signed-in user — the frontend doesn't show any (which itself requires a prior link-based sign-in to register), `user.
verification nagging or resend UI. email_verified` is always `true` for a signed-in user — the frontend doesn't
show any verification nagging or resend UI.
## Passkeys
`src/lib/webauthn.ts` wraps the two ceremonies. Both fetch a `{ challenge_id,
options }` pair from the API, decode `options.publicKey`'s base64url fields
(`challenge`, `user.id`, `*Credentials[].id`) into `ArrayBuffer`s, call
`navigator.credentials.create()` / `.get()`, then base64url-encode the
resulting `PublicKeyCredential`'s response back into JSON for the API
(`{ id, response: { clientDataJSON, ... } }`). `passkeysSupported()` is a
one-line `window.PublicKeyCredential` check gating the UI everywhere below.
- **Register** (`ProfileView`, "Passkeys" section) — lists the caller's
passkeys (`GET /api/passkeys`) with a **Remove** button each
(`DELETE /api/passkeys/{id}`), and an "Add a passkey" form: a label input
(pre-filled with a guess from `navigator.userAgent`, e.g. "Mac") and a
button calling `registerPasskey(label)`. On success it appends to the local
list and calls `auth.fetchMe()` so `user.has_passkey` (and the notice below)
updates immediately.
- **Login** (`LoginView`) — the passkey button calls
`auth.loginWithPasskey()`, which adopts the returned session exactly like
`verifyEmail()`, then redirects to `route.query.redirect` or `/`. A
cancelled prompt (`DOMException` named `NotAllowedError`) shows "Cancelled."
rather than a generic error.
- **`PasskeyNotice.vue`** (mounted in `App.vue`, between the header and the
sidebar/main body — spans the full page width) shows when signed in with
`user.has_passkey === false`. Dismissing it writes
`localStorage['passkeyNoticeDismissedUntil'] = Date.now() + 7 days`; the
banner stays hidden until that passes, and reappears immediately (no reload
needed, since `has_passkey` is reactive on the shared `auth.user`) if every
passkey is later removed.
## Profile ## Profile
`/profile` (`ProfileView`) shows the current address and a **Change email** `/profile` (`ProfileView`) shows the current address, the **Passkeys** section
form (new address only, no password). On success the API has emailed a described above, and a **Change email** form (new address only, no password).
confirmation link to the *new* address and set `user.pending_email` (shown as a On success the API has emailed a confirmation link to the *new* address and
notice until it's opened); the change only lands once that link is opened. The set `user.pending_email` (shown as a notice until it's opened); the change
button shows a live countdown driven by `retry_after` and by `429` responses. only lands once that link is opened. The button shows a live countdown driven
by `retry_after` and by `429` responses.
+3
View File
@@ -2,6 +2,7 @@
import { computed } from 'vue' import { computed } from 'vue'
import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router' import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router'
import AppSidebar from './components/AppSidebar.vue' import AppSidebar from './components/AppSidebar.vue'
import PasskeyNotice from './components/PasskeyNotice.vue'
import { useAuthStore } from './stores/auth' import { useAuthStore } from './stores/auth'
import { useCardsStore } from './stores/cards' import { useCardsStore } from './stores/cards'
import { useInboxStore } from './stores/inbox' import { useInboxStore } from './stores/inbox'
@@ -38,6 +39,8 @@ async function onLogout() {
</div> </div>
</header> </header>
<PasskeyNotice />
<div class="app__body"> <div class="app__body">
<AppSidebar v-if="showSidebar" /> <AppSidebar v-if="showSidebar" />
+46
View File
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
const DISMISS_KEY = 'passkeyNoticeDismissedUntil'
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
function readDismissedUntil(): number {
try {
return Number(localStorage.getItem(DISMISS_KEY) ?? 0)
} catch {
return 0
}
}
const dismissedUntil = ref(readDismissedUntil())
const visible = computed(
() => auth.isAuthenticated && auth.user?.has_passkey === false && Date.now() > dismissedUntil.value,
)
function dismiss() {
const until = Date.now() + WEEK_MS
dismissedUntil.value = until
try {
localStorage.setItem(DISMISS_KEY, String(until))
} catch {
/* storage unavailable -- the notice just won't stay dismissed across reloads */
}
}
</script>
<template>
<div v-if="visible" class="passkey-notice">
<p>
You don't have a passkey yet add one on your
<RouterLink to="/profile">profile</RouterLink> to sign in faster, without
waiting on an email.
</p>
<button type="button" class="passkey-notice__dismiss" aria-label="Dismiss" @click="dismiss">
&#x2715;
</button>
</div>
</template>
+119
View File
@@ -0,0 +1,119 @@
import { apiRequest } from './api'
import type { AuthResponse, Passkey } from '../types'
/** Loosely-typed shape of the `publicKey` options the API sends -- binary
* fields (challenge, ids) travel as base64url strings over JSON. */
interface RawPublicKey {
[key: string]: unknown
challenge: string
user?: { id: string; [key: string]: unknown }
excludeCredentials?: Array<{ id: string; [key: string]: unknown }>
allowCredentials?: Array<{ id: string; [key: string]: unknown }>
}
export function passkeysSupported(): boolean {
return typeof window !== 'undefined' && typeof window.PublicKeyCredential !== 'undefined'
}
function base64urlToBuffer(base64url: string): ArrayBuffer {
const padded = base64url.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(base64url.length / 4) * 4, '=')
const binary = atob(padded)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
return bytes.buffer
}
function bufferToBase64url(buffer: ArrayBuffer): string {
let binary = ''
for (const byte of new Uint8Array(buffer)) binary += String.fromCharCode(byte)
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
function decodeCreationOptions(publicKey: RawPublicKey): PublicKeyCredentialCreationOptions {
return {
...publicKey,
challenge: base64urlToBuffer(publicKey.challenge),
user: {
...publicKey.user,
id: base64urlToBuffer(publicKey.user!.id),
},
excludeCredentials: (publicKey.excludeCredentials ?? []).map((c) => ({
...c,
id: base64urlToBuffer(c.id),
})),
} as PublicKeyCredentialCreationOptions
}
function decodeRequestOptions(publicKey: RawPublicKey): PublicKeyCredentialRequestOptions {
return {
...publicKey,
challenge: base64urlToBuffer(publicKey.challenge),
allowCredentials: (publicKey.allowCredentials ?? []).map((c) => ({
...c,
id: base64urlToBuffer(c.id),
})),
} as PublicKeyCredentialRequestOptions
}
function serializeCreatedCredential(credential: PublicKeyCredential): unknown {
const response = credential.response as AuthenticatorAttestationResponse
return {
id: credential.id,
response: {
clientDataJSON: bufferToBase64url(response.clientDataJSON),
attestationObject: bufferToBase64url(response.attestationObject),
},
}
}
function serializeAssertion(credential: PublicKeyCredential): unknown {
const response = credential.response as AuthenticatorAssertionResponse
return {
id: credential.id,
response: {
clientDataJSON: bufferToBase64url(response.clientDataJSON),
authenticatorData: bufferToBase64url(response.authenticatorData),
signature: bufferToBase64url(response.signature),
userHandle: response.userHandle ? bufferToBase64url(response.userHandle) : null,
},
}
}
/** Register a new passkey for the signed-in caller. */
export async function registerPasskey(label: string): Promise<Passkey> {
const { challenge_id, options } = await apiRequest<{
challenge_id: number
options: { publicKey: RawPublicKey }
}>('/passkeys/options', { method: 'POST', auth: true })
const credential = await navigator.credentials.create({ publicKey: decodeCreationOptions(options.publicKey) })
if (!(credential instanceof PublicKeyCredential)) {
throw new Error('Could not create a passkey.')
}
const { passkey } = await apiRequest<{ passkey: Passkey }>('/passkeys', {
method: 'POST',
auth: true,
body: { challenge_id, credential: serializeCreatedCredential(credential), label },
})
return passkey
}
/** Sign in with a passkey. No email needed -- the browser offers whatever it has stored for this site. */
export async function loginWithPasskey(): Promise<AuthResponse> {
const { challenge_id, options } = await apiRequest<{
challenge_id: number
options: { publicKey: RawPublicKey }
}>('/auth/passkey/options', { method: 'POST' })
const credential = await navigator.credentials.get({ publicKey: decodeRequestOptions(options.publicKey) })
if (!(credential instanceof PublicKeyCredential)) {
throw new Error('Could not sign in with that passkey.')
}
return apiRequest<AuthResponse>('/auth/passkey/verify', {
method: 'POST',
body: { challenge_id, credential: serializeAssertion(credential) },
})
}
+7
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { apiRequest, setAuthToken } from '../lib/api' import { apiRequest, setAuthToken } from '../lib/api'
import { loginWithPasskey as loginWithPasskeyCeremony } from '../lib/webauthn'
import type { AuthResponse, User } from '../types' import type { AuthResponse, User } from '../types'
const TOKEN_KEY = 'todo.token' const TOKEN_KEY = 'todo.token'
@@ -62,6 +63,11 @@ export const useAuthStore = defineStore('auth', () => {
) )
} }
/** Sign in with a passkey instead of a magic link. */
async function loginWithPasskey(): Promise<void> {
adopt(await loginWithPasskeyCeremony())
}
/** Request a deferred email change. Returns the pending address and cooldown. */ /** Request a deferred email change. Returns the pending address and cooldown. */
async function requestEmailChange( async function requestEmailChange(
email: string, email: string,
@@ -99,6 +105,7 @@ export const useAuthStore = defineStore('auth', () => {
fetchMe, fetchMe,
requestLoginLink, requestLoginLink,
verifyEmail, verifyEmail,
loginWithPasskey,
requestEmailChange, requestEmailChange,
} }
}) })
+103
View File
@@ -221,6 +221,109 @@ h1 {
font-size: 0.9rem; font-size: 0.9rem;
} }
.divider {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 1.25rem 0;
color: var(--muted);
font-size: 0.85rem;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
/* --- top-of-page "add a passkey" notice, dismissible for a week -------- */
.passkey-notice {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 0.6rem 1.25rem;
background: var(--warn-bg);
border-bottom: 1px solid var(--warn-border);
font-size: 0.9rem;
text-align: center;
}
.passkey-notice p {
margin: 0;
}
.passkey-notice__dismiss {
flex: none;
border: none;
background: none;
color: var(--muted);
cursor: pointer;
font-size: 0.9rem;
padding: 0.2rem 0.4rem;
border-radius: 6px;
}
.passkey-notice__dismiss:hover {
background: var(--bg);
color: var(--text);
}
/* --- passkey list (profile) -------------------------------------------- */
.passkeys {
list-style: none;
margin: 1rem 0;
padding: 0;
display: grid;
gap: 0.5rem;
}
.passkeys__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.6rem 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
}
.passkeys__info {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.passkeys__label {
font-weight: 600;
}
.passkeys__meta {
font-size: 0.8rem;
}
.passkeys__remove {
flex: none;
border: none;
background: none;
color: var(--muted);
cursor: pointer;
font-size: 0.85rem;
padding: 0.3rem 0.5rem;
border-radius: 6px;
}
.passkeys__remove:hover {
color: var(--error);
background: var(--surface);
}
/* --- dashboard: grid of projects --------------------------------------- */ /* --- dashboard: grid of projects --------------------------------------- */
.dashboard__grid { .dashboard__grid {
+9
View File
@@ -5,6 +5,8 @@ export interface User {
email_verified_at: string | null email_verified_at: string | null
/** A confirmed-but-not-yet-applied email change is waiting on this address. */ /** A confirmed-but-not-yet-applied email change is waiting on this address. */
pending_email: string | null pending_email: string | null
/** Whether this user has at least one registered passkey. */
has_passkey: boolean
created_at: string | null created_at: string | null
} }
@@ -14,6 +16,13 @@ export interface AuthResponse {
expires_at: string expires_at: string
} }
export interface Passkey {
id: number
label: string
created_at: string
last_used_at: string | null
}
export interface Project { export interface Project {
id: number id: number
title: string title: string
+37
View File
@@ -1,9 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { passkeysSupported } from '../lib/webauthn'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
const auth = useAuthStore() const auth = useAuthStore()
const router = useRouter()
const route = useRoute()
const email = ref('') const email = ref('')
const error = ref<ApiError | null>(null) const error = ref<ApiError | null>(null)
@@ -22,11 +26,44 @@ async function onSubmit() {
submitting.value = false submitting.value = false
} }
} }
// --- passkey login -------------------------------------------------------
const passkeySupported = passkeysSupported()
const passkeySubmitting = ref(false)
const passkeyError = ref('')
async function onPasskeyLogin() {
passkeySubmitting.value = true
passkeyError.value = ''
try {
await auth.loginWithPasskey()
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'
await router.push(redirect)
} catch (e) {
if (e instanceof DOMException && e.name === 'NotAllowedError') {
passkeyError.value = 'Cancelled.'
} else {
passkeyError.value = e instanceof ApiError ? e.message : 'Could not sign in with a passkey.'
}
} finally {
passkeySubmitting.value = false
}
}
</script> </script>
<template> <template>
<section class="card"> <section class="card">
<h1>Log in</h1> <h1>Log in</h1>
<template v-if="passkeySupported">
<button type="button" :disabled="passkeySubmitting" @click="onPasskeyLogin">
{{ passkeySubmitting ? 'Waiting for your passkey…' : 'Log in with a passkey' }}
</button>
<p v-if="passkeyError" class="form-error">{{ passkeyError }}</p>
<div class="divider"><span>or</span></div>
</template>
<p class="muted"> <p class="muted">
Enter your email and we'll send you a link to sign in no password Enter your email and we'll send you a link to sign in no password
needed. New here? The same link creates your account. needed. New here? The same link creates your account.
+127 -2
View File
@@ -1,7 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue' import { onBeforeUnmount, onMounted, ref } from 'vue'
import { ApiError } from '../lib/api' import { ApiError, apiRequest } from '../lib/api'
import { passkeysSupported, registerPasskey } from '../lib/webauthn'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import type { Passkey } from '../types'
const auth = useAuthStore() const auth = useAuthStore()
@@ -41,6 +43,80 @@ async function onChangeEmail() {
changing.value = false changing.value = false
} }
} }
// --- passkeys ------------------------------------------------------------
const supported = passkeysSupported()
const passkeys = ref<Passkey[]>([])
const loadingPasskeys = ref(true)
const passkeysError = ref('')
const newLabel = ref(guessDeviceLabel())
const adding = ref(false)
const addError = ref('')
const removingId = ref<number | null>(null)
function guessDeviceLabel(): string {
const ua = typeof navigator === 'undefined' ? '' : navigator.userAgent
if (/iPhone/.test(ua)) return 'iPhone'
if (/iPad/.test(ua)) return 'iPad'
if (/Android/.test(ua)) return 'Android device'
if (/Macintosh/.test(ua)) return 'Mac'
if (/Windows/.test(ua)) return 'Windows PC'
if (/Linux/.test(ua)) return 'Linux PC'
return 'This device'
}
onMounted(loadPasskeys)
async function loadPasskeys() {
if (!supported) {
loadingPasskeys.value = false
return
}
loadingPasskeys.value = true
passkeysError.value = ''
try {
const { passkeys: fetched } = await apiRequest<{ passkeys: Passkey[] }>('/passkeys', { auth: true })
passkeys.value = fetched
} catch (e) {
passkeysError.value = e instanceof ApiError ? e.message : 'Could not load your passkeys.'
} finally {
loadingPasskeys.value = false
}
}
async function onAddPasskey() {
adding.value = true
addError.value = ''
try {
const passkey = await registerPasskey(newLabel.value.trim() || guessDeviceLabel())
passkeys.value.push(passkey)
newLabel.value = guessDeviceLabel()
await auth.fetchMe() // clears the "add a passkey" notice once there's one
} catch (e) {
if (e instanceof DOMException && e.name === 'NotAllowedError') {
addError.value = 'Cancelled.'
} else {
addError.value = e instanceof ApiError ? e.message : 'Could not add that passkey.'
}
} finally {
adding.value = false
}
}
async function onRemovePasskey(passkey: Passkey) {
removingId.value = passkey.id
passkeysError.value = ''
try {
await apiRequest(`/passkeys/${passkey.id}`, { method: 'DELETE', auth: true })
passkeys.value = passkeys.value.filter((p) => p.id !== passkey.id)
await auth.fetchMe() // the notice comes back if that was the last one
} catch (e) {
passkeysError.value = e instanceof ApiError ? e.message : 'Could not remove that passkey.'
} finally {
removingId.value = null
}
}
</script> </script>
<template> <template>
@@ -81,5 +157,54 @@ async function onChangeEmail() {
</button> </button>
</form> </form>
</section> </section>
<section>
<h2>Passkeys</h2>
<template v-if="!supported">
<p class="muted">Passkeys aren't supported in this browser.</p>
</template>
<template v-else>
<p class="muted">
Sign in with your device's fingerprint, face, or PIN instead of an
email link. You can add more than one, e.g. for a phone and a laptop.
</p>
<p v-if="passkeysError" class="form-error">{{ passkeysError }}</p>
<p v-else-if="loadingPasskeys" class="muted">Loading</p>
<p v-else-if="passkeys.length === 0" class="muted">No passkeys yet.</p>
<ul v-else class="passkeys">
<li v-for="passkey in passkeys" :key="passkey.id" class="passkeys__item">
<div class="passkeys__info">
<span class="passkeys__label">{{ passkey.label }}</span>
<span class="passkeys__meta muted">
{{ passkey.last_used_at ? `Last used ${new Date(passkey.last_used_at).toLocaleDateString()}` : 'Never used' }}
</span>
</div>
<button
type="button"
class="passkeys__remove"
:disabled="removingId === passkey.id"
@click="onRemovePasskey(passkey)"
>
{{ removingId === passkey.id ? 'Removing…' : 'Remove' }}
</button>
</li>
</ul>
<form class="form form--new-card" @submit.prevent="onAddPasskey">
<label>
<span>Label</span>
<input v-model="newLabel" type="text" maxlength="100" />
</label>
<p v-if="addError" class="form-error">{{ addError }}</p>
<button type="submit" :disabled="adding">
{{ adding ? 'Waiting for your passkey…' : 'Add a passkey' }}
</button>
</form>
</template>
</section>
</section> </section>
</template> </template>