From afdd53ec4c9f6e42aa62f1115659c1806dace4bf Mon Sep 17 00:00:00 2001 From: Aneurin Barker Snook Date: Fri, 4 Sep 2026 19:34:53 +0100 Subject: [PATCH] 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 --- .env.example | 6 + README.md | 56 +++- composer.json | 1 + composer.lock | 47 ++- docker-compose.yml | 4 + migrations/010_add_passkeys.sql | 30 ++ src/Auth/SessionPayload.php | 8 +- src/Http/Controllers/PasskeyController.php | 274 ++++++++++++++++++ src/Repository/PasskeyRepository.php | 120 ++++++++ .../WebAuthnChallengeRepository.php | 84 ++++++ src/Support/Config.php | 22 +- src/bootstrap.php | 26 +- tests/PasskeyTest.php | 198 +++++++++++++ web/README.md | 59 +++- web/src/App.vue | 3 + web/src/components/PasskeyNotice.vue | 46 +++ web/src/lib/webauthn.ts | 119 ++++++++ web/src/stores/auth.ts | 7 + web/src/style.css | 103 +++++++ web/src/types.ts | 9 + web/src/views/LoginView.vue | 37 +++ web/src/views/ProfileView.vue | 129 ++++++++- 22 files changed, 1366 insertions(+), 22 deletions(-) create mode 100644 migrations/010_add_passkeys.sql create mode 100644 src/Http/Controllers/PasskeyController.php create mode 100644 src/Repository/PasskeyRepository.php create mode 100644 src/Repository/WebAuthnChallengeRepository.php create mode 100644 tests/PasskeyTest.php create mode 100644 web/src/components/PasskeyNotice.vue create mode 100644 web/src/lib/webauthn.ts diff --git a/.env.example b/.env.example index 82c9e54..e7104fb 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,12 @@ APP_ALLOW_REGISTRATION=true # together on http://localhost:8080; a host `npm run dev` serves it on :5173. 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. # mail — PHP's built-in mail() function (default) # smtp — the SMTP server configured below diff --git a/README.md b/README.md index a8818d2..237d4c6 100644 --- a/README.md +++ b/README.md @@ -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": "", @@ -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 `. 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 diff --git a/composer.json b/composer.json index c203de8..ed9b376 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,7 @@ "ext-pdo": "*", "ext-pdo_sqlite": "*", "firebase/php-jwt": "^7.0", + "lbuchs/webauthn": "^2.2", "phpmailer/phpmailer": "^7.1", "slim/psr7": "^1.6", "slim/slim": "^4.12", diff --git a/composer.lock b/composer.lock index 8d9e934..d3dc144 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0ee5526493b6ed24fc6d7f3a68af11cc", + "content-hash": "7e40179f4f6fad3fb02a8f8d7438ab9b", "packages": [ { "name": "fig/http-message-util", @@ -190,6 +190,51 @@ ], "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", "version": "1.3.1", diff --git a/docker-compose.yml b/docker-compose.yml index 7225547..74fe0b5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,10 @@ services: APP_ALLOW_REGISTRATION: "${APP_ALLOW_REGISTRATION:-true}" # The SPA and the API are both served from this container. 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. MAIL_TRANSPORT: "${MAIL_TRANSPORT:-smtp}" MAIL_FROM: "${MAIL_FROM:-no-reply@todo.test}" diff --git a/migrations/010_add_passkeys.sql b/migrations/010_add_passkeys.sql new file mode 100644 index 0000000..3b23081 --- /dev/null +++ b/migrations/010_add_passkeys.sql @@ -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); diff --git a/src/Auth/SessionPayload.php b/src/Auth/SessionPayload.php index be58e86..746502b 100644 --- a/src/Auth/SessionPayload.php +++ b/src/Auth/SessionPayload.php @@ -5,18 +5,19 @@ declare(strict_types=1); namespace App\Auth; use App\Repository\EmailVerificationRepository; +use App\Repository\PasskeyRepository; /** * 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 - * all go through the same verify-email endpoint). Shared so every entry point - * agrees on the shape. + * when a magic link (or a passkey) signs someone in. Shared so every entry + * point agrees on the shape. */ final class SessionPayload { public function __construct( private readonly JwtService $jwt, private readonly EmailVerificationRepository $tokens, + private readonly PasskeyRepository $passkeys, ) { } @@ -49,6 +50,7 @@ final class SessionPayload 'email_verified' => $verifiedAt !== null, 'email_verified_at' => $verifiedAt, 'pending_email' => $this->tokens->pendingEmailFor((int) $user['id']), + 'has_passkey' => $this->passkeys->countForUser((int) $user['id']) > 0, 'created_at' => $user['created_at'] ?? null, ]; } diff --git a/src/Http/Controllers/PasskeyController.php b/src/Http/Controllers/PasskeyController.php new file mode 100644 index 0000000..9d1d686 --- /dev/null +++ b/src/Http/Controllers/PasskeyController.php @@ -0,0 +1,274 @@ +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 $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 $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 + */ + 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'], + ]; + } +} diff --git a/src/Repository/PasskeyRepository.php b/src/Repository/PasskeyRepository.php new file mode 100644 index 0000000..dff4b11 --- /dev/null +++ b/src/Repository/PasskeyRepository.php @@ -0,0 +1,120 @@ +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 $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; + } +} diff --git a/src/Repository/WebAuthnChallengeRepository.php b/src/Repository/WebAuthnChallengeRepository.php new file mode 100644 index 0000000..eac9595 --- /dev/null +++ b/src/Repository/WebAuthnChallengeRepository.php @@ -0,0 +1,84 @@ +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; + } +} diff --git a/src/Support/Config.php b/src/Support/Config.php index c5e986a..263357f 100644 --- a/src/Support/Config.php +++ b/src/Support/Config.php @@ -19,6 +19,10 @@ final class Config public readonly bool $allowRegistration, /** Base URL of the frontend, used to build magic links. */ 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, ) { } @@ -46,6 +50,12 @@ final class Config $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'); if (!self::isAbsolutePath($mailLogPath)) { $mailLogPath = $basePath . '/' . ltrim($mailLogPath, '/'); @@ -63,7 +73,17 @@ final class Config 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 diff --git a/src/bootstrap.php b/src/bootstrap.php index ad0ffd3..382da26 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -9,6 +9,7 @@ use App\Http\Controllers\AuthController; use App\Http\Controllers\CardController; use App\Http\Controllers\CardStatusController; use App\Http\Controllers\EmailVerificationController; +use App\Http\Controllers\PasskeyController; use App\Http\Controllers\ProjectController; use App\Http\JsonErrorHandler; use App\Mail\EmailVerifier; @@ -18,10 +19,13 @@ use App\Mail\PhpMailerMailer; use App\Repository\CardRepository; use App\Repository\CardStatusRepository; use App\Repository\EmailVerificationRepository; +use App\Repository\PasskeyRepository; use App\Repository\ProjectRepository; use App\Repository\UserRepository; +use App\Repository\WebAuthnChallengeRepository; use App\Support\Config; use App\Support\Database; +use lbuchs\WebAuthn\WebAuthn; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Slim\Factory\AppFactory; @@ -47,8 +51,10 @@ $projects = new ProjectRepository($database->pdo()); $cards = new CardRepository($database->pdo()); $cardStatuses = new CardStatusRepository($database->pdo()); $verificationTokens = new EmailVerificationRepository($database->pdo()); +$passkeys = new PasskeyRepository($database->pdo()); +$webauthnChallenges = new WebAuthnChallengeRepository($database->pdo()); $jwt = new JwtService($config->jwtSecret, $config->jwtTtl); -$session = new SessionPayload($jwt, $verificationTokens); +$session = new SessionPayload($jwt, $verificationTokens, $passkeys); /** @var Mailer $mailer */ $mailer = $config->mail->transport === 'log' @@ -56,11 +62,19 @@ $mailer = $config->mail->transport === 'log' : new PhpMailerMailer($config->mail); $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); $emailController = new EmailVerificationController($users, $verificationTokens, $verifier, $session); $projectController = new ProjectController($projects, $cardStatuses); $cardController = new CardController($projects, $cards, $cardStatuses); $cardStatusController = new CardStatusController($projects, $cardStatuses); +$passkeyController = new PasskeyController($webAuthn, $passkeys, $webauthnChallenges, $users, $session); $authMiddleware = new AuthMiddleware($jwt, $users); // --- Routes --------------------------------------------------------------- @@ -71,6 +85,7 @@ $app->group('/api', function (RouteCollectorProxy $group) use ( $projectController, $cardController, $cardStatusController, + $passkeyController, $authMiddleware, ) { $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/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->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 ( $projectController, $cardController, diff --git a/tests/PasskeyTest.php b/tests/PasskeyTest.php new file mode 100644 index 0000000..7117cdf --- /dev/null +++ b/tests/PasskeyTest.php @@ -0,0 +1,198 @@ +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(); + } +} diff --git a/web/README.md b/web/README.md index ec90221..b68bbb7 100644 --- a/web/README.md +++ b/web/README.md @@ -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/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/webauthn.ts base64url <-> ArrayBuffer + the register/login passkey ceremonies 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/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, VerifyEmailView ``` @@ -111,10 +113,13 @@ side of the move. ## Auth flow -There is no password and no separate sign-up — `LoginView` is just an email -field and a "Send sign-in link" button (`POST /api/auth/magic-link`), for a new +There is no password and no separate sign-up — `LoginView` is an email field +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" -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 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. - Routes with `meta.requiresAuth` redirect to `/login` (preserving the intended path) when there is no authenticated user. -- Because the only way to get a session is opening a link, `user.email_verified` - is always `true` for a signed-in user — the frontend doesn't show any - verification nagging or resend UI. +- Because the only way to get a session is opening a link or using a passkey + (which itself requires a prior link-based sign-in to register), `user. + 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` (`ProfileView`) shows the current address and a **Change email** -form (new address only, no password). On success the API has emailed a -confirmation link to the *new* address and set `user.pending_email` (shown as a -notice until it's opened); the change only lands once that link is opened. The -button shows a live countdown driven by `retry_after` and by `429` responses. +`/profile` (`ProfileView`) shows the current address, the **Passkeys** section +described above, and a **Change email** form (new address only, no password). +On success the API has emailed a confirmation link to the *new* address and +set `user.pending_email` (shown as a notice until it's opened); the change +only lands once that link is opened. The button shows a live countdown driven +by `retry_after` and by `429` responses. diff --git a/web/src/App.vue b/web/src/App.vue index 569f81e..5322d62 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -2,6 +2,7 @@ import { computed } from 'vue' import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router' import AppSidebar from './components/AppSidebar.vue' +import PasskeyNotice from './components/PasskeyNotice.vue' import { useAuthStore } from './stores/auth' import { useCardsStore } from './stores/cards' import { useInboxStore } from './stores/inbox' @@ -38,6 +39,8 @@ async function onLogout() { + +
diff --git a/web/src/components/PasskeyNotice.vue b/web/src/components/PasskeyNotice.vue new file mode 100644 index 0000000..842dcdc --- /dev/null +++ b/web/src/components/PasskeyNotice.vue @@ -0,0 +1,46 @@ + + + diff --git a/web/src/lib/webauthn.ts b/web/src/lib/webauthn.ts new file mode 100644 index 0000000..908ed69 --- /dev/null +++ b/web/src/lib/webauthn.ts @@ -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 { + 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 { + 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('/auth/passkey/verify', { + method: 'POST', + body: { challenge_id, credential: serializeAssertion(credential) }, + }) +} diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts index f5614f2..33bdd5e 100644 --- a/web/src/stores/auth.ts +++ b/web/src/stores/auth.ts @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { computed, ref } from 'vue' import { apiRequest, setAuthToken } from '../lib/api' +import { loginWithPasskey as loginWithPasskeyCeremony } from '../lib/webauthn' import type { AuthResponse, User } from '../types' 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 { + adopt(await loginWithPasskeyCeremony()) + } + /** Request a deferred email change. Returns the pending address and cooldown. */ async function requestEmailChange( email: string, @@ -99,6 +105,7 @@ export const useAuthStore = defineStore('auth', () => { fetchMe, requestLoginLink, verifyEmail, + loginWithPasskey, requestEmailChange, } }) diff --git a/web/src/style.css b/web/src/style.css index ab56ce3..36833f3 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -221,6 +221,109 @@ h1 { 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 { diff --git a/web/src/types.ts b/web/src/types.ts index cedb2db..6ec9448 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -5,6 +5,8 @@ export interface User { email_verified_at: string | null /** A confirmed-but-not-yet-applied email change is waiting on this address. */ pending_email: string | null + /** Whether this user has at least one registered passkey. */ + has_passkey: boolean created_at: string | null } @@ -14,6 +16,13 @@ export interface AuthResponse { expires_at: string } +export interface Passkey { + id: number + label: string + created_at: string + last_used_at: string | null +} + export interface Project { id: number title: string diff --git a/web/src/views/LoginView.vue b/web/src/views/LoginView.vue index b44efec..7aba731 100644 --- a/web/src/views/LoginView.vue +++ b/web/src/views/LoginView.vue @@ -1,9 +1,13 @@