Skip to content

Add passkey (WebAuthn) authentication#487

Merged
dahlia merged 25 commits into
fedify-dev:mainfrom
dahlia:feature/passkey
May 15, 2026
Merged

Add passkey (WebAuthn) authentication#487
dahlia merged 25 commits into
fedify-dev:mainfrom
dahlia:feature/passkey

Conversation

@dahlia
Copy link
Copy Markdown
Member

@dahlia dahlia commented May 15, 2026

Hollo's sign-in story so far has been email and password plus an optional single-instance TOTP. There was no way to enroll a phishing-resistant credential, and there was no way to skip the TOTP prompt for users who already authenticate the same gesture into their authenticator. This branch lands passkeys on both the admin Auth page and the public login page, using @simplewebauthn/server for verification and the matching browser helper as a vendored static asset.

Configuration

There is no new environment variable. The relying-party ID and origin are derived from the incoming request URL at runtime, so single-origin and split-domain (HANDLE_HOST + WEB_ORIGIN) deployments both work without operator intervention: the browser is always on the web origin during a ceremony, and that hostname is the rpID.

Data model

Two new tables in src/schema.ts, both generated through pnpm migrate:generate.

passkeys (migration 0088_passkeys.sql) stores one row per enrolled credential: the base64url credential id as the primary key, the COSE public key, the signature counter, transport hints, the credentialDeviceType / credentialBackedUp flags returned by SimpleWebAuthn, a user-supplied nickname, and the timestamps the management UI renders. Rows cascade-delete with the parent credentials.email so a credential reset clears its passkeys instead of leaving orphans bound to an obsolete WebAuthn user handle.

passkey_login_challenges (migration 0089_passkey_login_challenges.sql) holds one row per outstanding login ceremony, keyed by a random UUID, with the challenge and an expiry. The signed cookie issued by /login/passkey/begin carries only the row's id; the challenge itself never leaves the server. /finish consumes the row with an atomic DELETE … WHERE id RETURNING challenge, expires_at, so a captured cookie plus assertion pair can be redeemed at most once even if the underlying authenticator reports signCount == 0.

Model helpers

src/passkey.ts is the seam between Hollo and @simplewebauthn/server. It exposes getRpInfo, buildRegistrationOptions / verifyRegistration, buildAuthenticationOptions / verifyAuthentication, encodePublicKey / decodePublicKey, sanitizeTransports, and nicknameFromUserAgent. The wrappers enforce residentKey: required and userVerification: required so every enrolled credential is discoverable and tied to a biometric or PIN gesture, and they swallow the library's “invalid response” throws into a plain null return so route handlers can answer with a flat 400 Bad Request. sanitizeTransports keeps only the WebAuthn-defined values (ble, cable, hybrid, internal, nfc, smart-card, usb) and drops anything else, so a malicious browser cannot poison a future excludeCredentials payload.

The companion src/passkey.test.ts was written first. It stubs the library's verify functions with vi.mock, which keeps the suite self-contained without a real authenticator response fixture.

Login middleware

loginRequired in src/login.ts previously demanded a matching otp cookie when a TOTP was enrolled. It now accepts either an otp cookie or a passkey cookie. A passkey is itself multi-factor (the user has the authenticator and confirms with a biometric or PIN gesture), so stacking TOTP on top would not add real security and would hurt the experience. Both cookies are bound to the current login value so they cannot be reused across sessions. src/login.test.ts covers the four-way matrix plus mismatched-cookie cases.

Admin Auth page

src/pages/auth.tsx gains a Passkeys section below the existing TOTP section. It lists enrolled passkeys with their nickname, creation date, and last-used date, with a per-row delete form. An enrollment panel takes an optional nickname (defaulting to a UA-derived label such as “macOS device”) and submits to three new routes:

  • POST /auth/passkeys/registration/begin returns the WebAuthn creation options and stashes the challenge in a signed cookie. The cookie body is ${challenge}|${expiresAt}|${login}, which binds the ceremony to the current login session and lets the server enforce a 5-minute TTL even though Max-Age is only a browser hint.
  • POST /auth/passkeys/registration/finish reads the cookie back, verifies expiry, session binding, and the WebAuthn assertion, then inserts a row. The cookie is deleted unconditionally so a captured value cannot be replayed within its TTL; duplicate credential ids return 409 Conflict instead of a false-positive 204 No Content.
  • POST /auth/passkeys/:id/delete drops the row and redirects back to ​/auth.

Login page

src/pages/login.tsx becomes passkey-first when at least one passkey is enrolled: a "Sign in with passkey" button is the primary action and the email/password form is tucked behind a <details> toggle, which opens automatically when a previous password attempt failed so the error stays visible.

  • POST /login/passkey/begin returns the WebAuthn request options with an empty allowCredentials so the browser surfaces any resident credential it knows about, inserts a row into passkey_login_challenges, and returns the id in a signed cookie. The endpoint refuses with 404 Not Found before any database write when no passkeys are enrolled, so an unauthenticated caller cannot pump rows into the table on an instance with nothing to authenticate against. It also enforces a soft cap of 64 outstanding unexpired challenges and returns 429 Too Many Requests with Retry-After: 300 when the cap is reached. GC plus count plus insert run inside a single transaction guarded by pg_advisory_xact_lock, so concurrent /begin calls serialize on the lock rather than racing between the count and the insert.
  • POST /login/passkey/finish consumes the challenge row atomically (so a captured cookie is good for at most one request), parses the JSON body manually after the row is already burnt so schema failures cannot bypass the consumption, verifies the assertion against the stored credential, and compare-and-sets the counter to defeat concurrent ceremonies racing over an old value. On success it writes the login cookie and the passkey cookie the middleware accepts, and returns { redirect } for the client script to follow. The redirect target is clamped to the request's origin via safeNext, which parses the candidate as a URL against the request URL and demands a matching origin, neutralizing open-redirect tricks like /\evil.example/path that browsers normalize into //evil.example/path.

Client script

The dashboard stays JavaScript-free outside the two pages that need the WebAuthn ceremonies. Two files land in src/public/: simplewebauthn-browser.umd.js is a verbatim copy of @simplewebauthn/browser 13.3.0 exposing window.SimpleWebAuthnBrowser, and passkey.js is a small IIFE that hooks #passkey-enroll-form on the admin page and #passkey-signin-button on the login page. It surfaces status via an aria-live="polite" element, distinguishes a NotAllowedError cancellation from a real failure, disables the trigger while a ceremony is in flight, and tolerates the 204 No Content reply from registration/finish. Both files are loaded with <script defer> only on the pages that need them.

DESIGN.md gains a short “Page-scoped client scripts” subsection that codifies this pattern: layout stays zero-JS, tiny inline scripts in pages are fine where they are, but anything that genuinely needs JS (currently just the WebAuthn ceremonies) should live in src/public/ and be linked only from the pages that need it.

Verification

pnpm check is clean and the full Vitest suite passes (495 tests). The flow was also exercised end-to-end against a Cloudflare-tunnelled local dev server using Playwright plus a Chromium virtual WebAuthn authenticator: enrollment from ​/auth, passkey sign-in landing on the dashboard without a TOTP prompt, lastUsed updating on every assertion, removal, and the password fallback toggle still working when no passkey is enrolled.

dahlia added 11 commits May 15, 2026 09:18
Add @simplewebauthn/server and @simplewebauthn/browser at ^13.3.0
as the foundation for upcoming WebAuthn/passkey authentication
support.  This commit only updates package.json and the pnpm
lockfile; no source code changes yet.

Assisted-by: Claude Code:claude-opus-4-7
Introduce a `passkeys` table to back the upcoming WebAuthn /
passkey authentication flow.  Each row stores one credential
returned by the browser at registration: the base64url credential
id, the COSE public key, the signature counter, transport hints,
device-type / backup-state metadata reported by SimpleWebAuthn, a
user-supplied nickname, and the timestamps Hollo needs to render
the management UI.

Rows cascade-delete with the parent `credentials.email` so the
credential record stays authoritative — when a credential is
replaced (re-setup), its passkeys go with it instead of becoming
orphans bound to an obsolete WebAuthn user handle.

Assisted-by: Claude Code:claude-opus-4-7
Introduce `src/passkey.ts` as the seam between Hollo and
@simplewebauthn/server.  It exposes a small, intent-named API the
upcoming HTTP routes can consume:

- `getRpInfo(requestUrl)` derives the relying-party ID and origin
  from the incoming request, which is what makes the split-domain
  setup (HANDLE_HOST + WEB_ORIGIN) Just Work: the browser is always
  on the web origin during a ceremony, so its hostname is the rpID.
- `buildRegistrationOptions` / `verifyRegistration` and the matching
  authentication pair wrap the library, enforce `residentKey:
  required` (so logins can be username-less) and
  `userVerification: required` (so a biometric / PIN gesture is
  always part of the ceremony), and swallow the library's "invalid
  response" throws into a plain `null` return so route handlers can
  respond with a 400 without a stack trace in the logs.
- `encodePublicKey` / `decodePublicKey` keep the COSE blob portable
  through a `text` column without reaching for a custom Drizzle
  type.
- `nicknameFromUserAgent` lights up sensible defaults
  ("macOS device", "iOS device", …) so the management UI doesn't
  open with an empty label.

The companion test file was written first; it stubs the library's
verify-* functions with `vi.mock`, so the round-trip of a real
authenticator response isn't required to lock in the wrapper
contract.

Assisted-by: Claude Code:claude-opus-4-7
Assisted-by: Codex:gpt-5.5
When a TOTP is enrolled, `loginRequired` previously required a
matching `otp` cookie before letting any request through.  This
commit teaches it to also honour a matching `passkey` cookie
written by the upcoming passkey login flow.

A passkey is itself multi-factor — the user has the authenticator
and confirms with a biometric or PIN gesture during the
WebAuthn ceremony — so stacking TOTP on top would add no real
security and just hurt the experience of signing in.  Either
cookie, bound to the current `login` value to prevent
session-mixing, now satisfies the gate.

The new `src/login.test.ts` mounts a throwaway Hono app behind
the middleware and walks the four-way matrix: no TOTP, TOTP +
otp, TOTP + passkey, TOTP + nothing, plus mismatched-cookie
cases.

Assisted-by: Claude Code:claude-opus-4-7
Assisted-by: Codex:gpt-5.5
Wire the admin Auth page into the WebAuthn helpers added earlier.
Three new routes sit alongside the existing TOTP forms:

- POST /auth/passkeys/registration/begin returns the WebAuthn
  creation options and stashes the challenge in a signed cookie.
  The cookie body is `${challenge}|${expiresAt}|${login}`, which
  binds the ceremony to the current login session and lets the
  server enforce a 5-minute TTL even though Max-Age is only a
  browser hint.

- POST /auth/passkeys/registration/finish reads the cookie back,
  verifies expiry / session binding / WebAuthn assertion, and
  inserts a row.  The cookie is deleted unconditionally so a
  captured value can't be replayed within its TTL; duplicate
  credential ids return 409 instead of a false-positive 204; and
  the row's nickname falls back to a UA-derived label
  ("macOS device", etc.) when the user leaves the field blank.

- POST /auth/passkeys/:id/delete drops the row and redirects back
  to /auth.

The rendered AuthPage gains a Passkeys section below the TOTP
section that lists enrolled passkeys with their nickname, the
date they were added, and the date they were last used, plus a
small form the upcoming client script will hook into for
enrollment.

CSRF is intentionally left as a project-wide concern: the
existing /auth/2fa/disable, /logout, and the new passkey delete
all share the same posture, so widening the scope here would
just be inconsistent.  A follow-up can apply a single token
mechanism across all auth-management POSTs in one pass.

Assisted-by: Claude Code:claude-opus-4-7
Assisted-by: Codex:gpt-5.5
Add a public-side passkey login flow alongside the existing
email/password path:

- POST /login/passkey/begin returns the WebAuthn request options
  (empty allowCredentials, so the browser surfaces any resident
  credential it knows about) and stashes the challenge in a signed
  cookie.  The cookie body is `${challenge}|${expiresAt}` so the
  server enforces the 5-minute TTL even when Max-Age is just a
  browser hint.

- POST /login/passkey/finish consumes the cookie unconditionally
  (so a captured value is good for at most one request), parses the
  JSON body manually after the cookie is already burnt so schema
  failures can't bypass the consumption, verifies the assertion
  against the stored credential, compare-and-sets the counter to
  defeat concurrent ceremonies racing over an old value, and on
  success writes both the `login` cookie and the `passkey` cookie
  that the middleware accepts as a TOTP stand-in.  Response is
  `{ redirect }` JSON for the client script to follow.

  The redirect target is clamped to the request's origin via
  `safeNext`, which parses the candidate as a URL against the
  request URL and demands a matching origin.  That neutralises
  open-redirect tricks like `/\evil.example/path` that browsers
  normalise into `//evil.example/path`.

- LoginPage is now passkey-first: when at least one passkey is
  enrolled, a "Sign in with passkey" button is the primary action
  with the email/password form tucked behind a <details> toggle.
  The fallback is opened automatically when a previous password
  attempt failed so the error stays visible.

A follow-up commit will replace the stateless challenge cookie
with a `passkey_login_challenges` table so a captured assertion
+ cookie pair can't be replayed within the TTL even if a sync
passkey reports `signCount == 0`.  Tracked as the next task on
the branch.

Assisted-by: Claude Code:claude-opus-4-7
Assisted-by: Codex:gpt-5.5
Add the bit of client-side JavaScript the auth and login pages
need to actually drive `navigator.credentials.create/get`.  Two
files land in src/public/:

- simplewebauthn-browser.umd.js is a verbatim copy of the
  @simplewebauthn/browser v13.3.0 UMD bundle; it exposes
  `window.SimpleWebAuthnBrowser`.  A short note at the top of
  passkey.js documents how to re-vendor it after a dep bump.

- passkey.js is an IIFE glue script that hooks the existing form
  ids: `#passkey-enroll-form` on the admin Auth page (POSTs to
  /auth/passkeys/registration/{begin,finish} and reloads on
  success) and `#passkey-signin-button` on the login page (POSTs
  to /login/passkey/{begin,finish} and follows the server-supplied
  redirect).  It surfaces a friendly status line via an
  aria-live="polite" element, distinguishes a NotAllowedError
  cancellation from a real failure, disables the trigger while a
  ceremony is in flight, and tolerates the 204 No Content reply
  from the registration-finish endpoint instead of trying to parse
  an empty body as JSON.

Both files are loaded with `<script defer>` only on the pages
that need them: always on the dashboard Auth page, and on the
login page only when at least one passkey is enrolled, so the
rest of the dashboard stays zero-JS.

Assisted-by: Claude Code:claude-opus-4-7
Assisted-by: Codex:gpt-5.5
Move the WebAuthn login challenge off a stateless signed cookie
and onto a new `passkey_login_challenges` table.  Each /begin
inserts a row keyed by a random UUID; the signed cookie carries
only that id, so the challenge itself never leaves the server.
/finish atomically `DELETE … WHERE id RETURNING challenge,
expires_at` — if the row is gone the request is rejected, which
makes a captured cookie + assertion pair good for at most one
/finish call even within the TTL.  Expired rows are GC'd
opportunistically at the top of /begin so the table can't grow
unbounded without a separate cleanup job.

This closes the replay window the previous commit flagged as a
defense-in-depth follow-up: a sync passkey that reports
`signCount == 0` would otherwise allow a captured pair to be
replayed up to the 5-minute TTL.  Registration challenges stay
stateless — they're already bound to the logged-in session, so
session-binding plus the existing TTL suffice.

A new test exercises the single-use semantics by sending two
/finish requests with the same captured cookie and asserts the
second one is rejected without even calling `verifyAuthentication`.
The mock-clear in the test suite was switched from `mockClear()`
to `mockReset()` to stop one-shot mocks leaking between tests.

Assisted-by: Claude Code:claude-opus-4-7
Assisted-by: Codex:gpt-5.5
Add a CHANGES.md entry for the new WebAuthn / passkey support
under the unreleased v0.9.0 section: enrollment + management on
the admin Auth page, passkey-first login with a password
fallback, both device-bound and synced passkeys accepted, the
TOTP step skipped when the passkey ceremony succeeds.  The
security envelope (residentKey/userVerification both required,
session-bound registration challenge, single-use login-challenge
table) is called out so operators upgrading from 0.8 see the
guarantees explicitly.

Add a "Page-scoped client scripts" subsection to DESIGN.md that
codifies the pattern this branch introduced: Layout and the
dashboard chrome stay JavaScript-free, existing tiny inline
scripts in pages are fine where they are, but anything that
genuinely can't function without JS (currently just the WebAuthn
ceremonies) should live as a small hand-written file in
src/public/ linked via <script defer> from only the pages that
need it, with a real fallback when the script fails to load.

Assisted-by: Claude Code:claude-opus-4-7
Assisted-by: Codex:gpt-5.5
Three follow-ups from the branch-wide review:

- /login/passkey/begin now refuses with 404 before any DB write
  when no passkeys are enrolled.  Otherwise an unauthenticated
  caller could pump rows into passkey_login_challenges on an
  instance with nothing to actually authenticate against.

- The endpoint also enforces a soft cap of 64 outstanding
  unexpired challenges and returns 429 + Retry-After: 300 when
  the cap is reached.  Hollo is single-user, so realistic
  concurrent ceremonies are far below this number; the cap exists
  purely to keep the table bounded under abuse.  GC + count +
  insert are wrapped in a single transaction guarded by
  pg_advisory_xact_lock so concurrent /begin calls serialise on
  the lock rather than racing between the count and the insert.

- src/passkey.ts now exports `sanitizeTransports`, which keeps
  only the WebAuthn-defined values (ble, cable, hybrid, internal,
  nfc, smart-card, usb) and drops anything else.
  `verifyRegistration` runs the client-supplied
  `response.transports` through it before returning, so a bogus
  or hostile value from the browser never reaches the DB or a
  future excludeCredentials payload.

Each behaviour has a focused test in the appropriate suite.

Assisted-by: Claude Code:claude-opus-4-7
Assisted-by: Codex:gpt-5.5
@dahlia dahlia added this to the Hollo 0.9 milestone May 15, 2026
@dahlia dahlia self-assigned this May 15, 2026
@dahlia dahlia added the enhancement New feature or request label May 15, 2026
@dahlia
Copy link
Copy Markdown
Member Author

dahlia commented May 15, 2026

@codex review

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements passkey (WebAuthn) authentication, allowing users to enroll and manage passkeys on the admin Auth page and sign in using them on the public login page. The implementation introduces new database tables for passkeys and login challenges, updates the login middleware to accept passkeys as a second factor, and adds the necessary server-side and client-side logic for WebAuthn ceremonies. Feedback includes suggestions to use the central SECRET_KEY export in src/pages/login.tsx for consistency and to capture a single timestamp within the passkey login challenge transaction to ensure temporal consistency between data cleanup and counting.

Comment thread src/pages/login.tsx Outdated
Comment thread src/pages/login.tsx Outdated
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6e6a35f285

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/public/passkey.js
dahlia added 3 commits May 15, 2026 10:15
src/pages/login.tsx was reading SECRET_KEY straight off process.env
with a local null check.  src/login.ts and src/pages/auth.tsx both
import it from src/env.ts, which is also where the 44-char minimum
length is enforced; reading process.env directly bypassed that
check.  Switch to the same import so the validation runs and the
pattern is consistent across the auth-adjacent modules.

fedify-dev#487 (comment)

Assisted-by: Claude Code:claude-opus-4-7
/login/passkey/begin called new Date() twice inside the
transaction: once for the GC delete predicate and once for the
outstanding-count predicate.  If the wall clock ticked between the
two calls, the two predicates could disagree about which rows were
"expired", and a row sitting exactly on the boundary could be
deleted and then re-counted (or vice versa) inside the same lock.

Capture a single new Date() at the top of the transaction and
reuse it for both predicates so they see the same instant.

fedify-dev#487 (comment)

Assisted-by: Claude Code:claude-opus-4-7
postJson() assumed every 2xx response had a JSON body.  The
/auth/passkeys/* endpoints sit behind auth middleware that
redirects to /login when the session is gone (e.g., the user
logged out in another tab and then clicked "Add passkey" here).
fetch() follows that redirect transparently, so response.ok stays
true and the final HTML page reaches response.json(), which
throws.  The enrollment then surfaced as a generic "could not
enroll a passkey" message and the user had no signal to go
re-authenticate.

Check response.redirected before any JSON parsing.  When it's
true, navigate the browser to response.url so the user lands on
the real /login page, and throw a short status message for the
inline aria-live region.

fedify-dev#487 (comment)

Assisted-by: Claude Code:claude-opus-4-7
@dahlia
Copy link
Copy Markdown
Member Author

dahlia commented May 15, 2026

@codex review

@dahlia
Copy link
Copy Markdown
Member Author

dahlia commented May 15, 2026

/gemini review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 62809887b5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/pages/login.tsx Outdated
Comment thread src/pages/auth.tsx Outdated
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces passkey (WebAuthn) authentication, enabling users to register and manage device-bound or synced passkeys as a multi-factor authentication method. The implementation includes new database tables for passkeys and login challenges, session-bound challenges with a 5-minute TTL, and integration with the @simplewebauthn library suite. Review feedback highlighted a security vulnerability regarding open redirects in the URL sanitization logic and suggested an optimization to avoid unnecessary memory allocations when decoding public keys.

Comment thread src/pages/login.tsx Outdated
Comment thread src/passkey.ts Outdated
dahlia added 4 commits May 15, 2026 10:32
safeNext parses the incoming `next` value against the request URL
and demands a matching origin.  That catches `/\evil.example/x`
style inputs (WHATWG URL parsing moves the origin away from the
request origin in those cases).  But `/.//evil.example/x` is
normalised by the same parser into pathname `//evil.example/x`
against the request origin, so the same-origin check passes and
safeNext returned the protocol-relative-looking string.  The
client passes the result straight to window.location.assign(),
which treats a leading `//` as a protocol-relative URL and
navigates cross-origin.

Reject any post-normalisation pathname that starts with `//` and
fall back to `/`.  Added a regression test asserting that
`/.//evil.example/phish` collapses to `/` in the /login/passkey/finish
response.

fedify-dev#487 (comment)

Assisted-by: Claude Code:claude-opus-4-7
The registration finish handler had zValidator("json", schema)
wired up as middleware in front of the handler.  zValidator runs
before any handler code, so a request with a malformed body
returned 400 from the validator and the deleteCookie() call that
sits at the top of the handler never executed.  A captured
passkey_reg signed cookie therefore stayed reusable for the rest
of its 5-minute TTL, contradicting the route's "consume on every
path" intent.

Drop the zValidator middleware on this route and instead read the
JSON body inside the handler with c.req.json(), then run
finishBodySchema.safeParse().  Both happen after the cookie has
already been deleted, so any path that leaves the handler burns
the cookie.  This mirrors what login.tsx /passkey/finish already
does for the same reason.

A new test posts a body that fails the schema and asserts the
response still sets `passkey_reg=` to clear the browser-side
copy.

fedify-dev#487 (comment)

Assisted-by: Claude Code:claude-opus-4-7
Buffer is a subclass of Uint8Array, so Buffer.from(encoded,
"base64url") already returns something that satisfies the
Uint8Array<ArrayBuffer> return type and the only downstream
caller (SimpleWebAuthn's verifyAuthenticationResponse) reads the
bytes either way.  The previous version allocated a fresh
ArrayBuffer and copied the buffer's contents into a plain
Uint8Array; nothing here benefits from that round trip.

The two unit tests that round-trip a key now compare via
Array.from() rather than toEqual() on the typed-array instance,
since toEqual checks the prototype as well and a Buffer doesn't
deep-equal a Uint8Array under that rule.

fedify-dev#487 (comment)

Assisted-by: Claude Code:claude-opus-4-7
/login/passkey/begin used to refuse with 429 and Retry-After: 300
once the table held 64 unexpired challenges.  Because the
endpoint is unauthenticated, any actor could send 64 POSTs in a
burst and park the table at the cap for the full 5-minute TTL,
which forced every legitimate passkey sign-in into 429 for that
window and turned the anti-bloat safeguard into a trivial
availability DoS.

Switch the at-cap branch from "return 429" to "evict the oldest
unexpired row, then insert."  The cap still holds (size never
exceeds 64) but no caller ever sees a 429, so an attacker can't
hold the door shut.  Eviction picks the row with the smallest
expires_at, which is the FIFO head since expires_at is just
insert time + TTL.

There's a residual displacement risk: a sustained burst could
race a legitimate user's row out of the table before they reach
/finish.  That's a much harder attack than the cap-park one this
fixes, and it lives at the same threat tier (network-bandwidth
abuse) where a reverse proxy is the right defense layer.

The 429 test in src/pages/login.test.ts is replaced with one that
asserts the eviction behaviour: pre-fill the table to 64 rows
with ascending expires_at, hit /begin, and check the response is
200, the table size is still 64, and the oldest seeded row is
gone.

fedify-dev#487 (comment)

Assisted-by: Claude Code:claude-opus-4-7
@dahlia
Copy link
Copy Markdown
Member Author

dahlia commented May 15, 2026

/gemini review

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces passkey (WebAuthn) authentication, allowing users to register, manage, and sign in with device-bound or synced passkeys. The implementation includes new database tables for passkeys and login challenges, server-side verification logic using the @simplewebauthn library, and a lightweight client-side script for handling WebAuthn ceremonies. Feedback from the reviewer suggests simplifying a complex type cast in the authentication page and adopting modern JavaScript conventions, such as using const/let and async callbacks, in the new client-side script.

Comment thread src/pages/auth.tsx Outdated
Comment thread src/public/passkey.js Outdated
Comment thread src/public/passkey.js Outdated
dahlia added 2 commits May 15, 2026 10:55
The cast on `p.transports` was a multi-line conditional-types
expression that fished the element type out of
buildRegistrationOptions' parameter shape via `Parameters<...>`
and `infer T`.  The element type is just
AuthenticatorTransportFuture from @simplewebauthn/server, which
the same file already imports transitively, so import it
directly and cast to AuthenticatorTransportFuture[].  Runtime is
identical; the call site fits on one line again.

fedify-dev#487 (comment)

Assisted-by: Claude Code:claude-opus-4-7
Two cleanups in one pass, both inside src/public/passkey.js:

- Replace every `var` with `const`, except for the two locals in
  postJson's !response.ok branch (`detail` and `error.message`
  fallback path) which are written conditionally and stay `let`.
  The rest of the script was already using async/await and arrow
  functions, so `const` is in keeping with the style.
- Replace the `(async function () { ... })()` IIFEs that wrapped
  the async work inside the submit and click handlers with
  `async (event) => { ... }` / `async () => { ... }` passed
  directly to addEventListener.  The extra indentation level
  goes away and the handler is a single function instead of a
  sync function that immediately fires off an async one.

No runtime behaviour change.

fedify-dev#487 (comment)
fedify-dev#487 (comment)

Assisted-by: Claude Code:claude-opus-4-7
@dahlia
Copy link
Copy Markdown
Member Author

dahlia commented May 15, 2026

@codex review

@dahlia
Copy link
Copy Markdown
Member Author

dahlia commented May 15, 2026

/gemini review

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements passkey (WebAuthn) authentication, allowing users to manage passkeys in the admin dashboard and use them as a second factor during login. The implementation includes new database tables for passkeys and login challenges, a passkey utility module, and client-side scripts for WebAuthn ceremonies. Feedback suggests that the safeNext origin check might be bypassed behind reverse proxies if proxy headers are not correctly trusted and recommends using local timezone formatting for dates in the UI instead of UTC.

Comment thread src/pages/login.tsx
Comment thread src/pages/auth.tsx Outdated
@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

The Passkeys section used `toISOString().slice(0, 10)` directly
in string concatenation, which renders a fixed YYYY-MM-DD in UTC
regardless of the operator's timezone.  Hollo's other date
listings (e.g. src/components/AccountList.tsx) already wrap dates
in `<time dateTime={iso}>{date.toLocaleDateString()}</time>`, so
the visible text follows the server's locale and the dateTime
attribute carries the canonical ISO instant for any browser-side
enhancement to re-render in the visitor's locale.

Bring the auth page in line with that pattern: each "Added" and
"last used" date is wrapped in a `<time>` and formatDate now
returns `value.toLocaleDateString()`.  The comment above
formatDate is updated to point at AccountList as the precedent.

fedify-dev#487 (comment)

Assisted-by: Claude Code:claude-opus-4-7
@dahlia
Copy link
Copy Markdown
Member Author

dahlia commented May 15, 2026

@codex review

@dahlia
Copy link
Copy Markdown
Member Author

dahlia commented May 15, 2026

/gemini review

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces passkey (WebAuthn) authentication, enabling users to enroll, manage, and sign in with device-bound or synced passkeys. The changes include new database tables for passkeys and login challenges, middleware updates to support passkeys as a second factor, and a lightweight client-side script for WebAuthn ceremonies. The reviewer suggested enhancing the atomicity of challenge consumption by moving the expiration check directly into the database deletion query.

Comment thread src/pages/login.tsx Outdated
@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Nice work!

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

/login/passkey/finish used to DELETE any row matching the cookie
id, return the row, and then check expires_at in JavaScript to
decide whether to accept the challenge.  That deleted expired
rows on every poke, which is fine but redundant: the row is
already going to be cleaned up on the next /begin's GC pass.

Fold the expiry check into the WHERE clause as
`id = ? AND expires_at >= now()`, drop the separate JS branch,
and trim expires_at off the RETURNING list.  Behaviour for a
legitimate /finish is identical; behaviour for an expired one
shifts from "delete the row, then return 400" to "leave the
expired row alone, return 400 mentioning expired-or-used."  Same
security envelope; one SQL statement does the consume-only-if-
still-valid step atomically.

The DATE expression uses `new Date(Date.now())` rather than bare
`new Date()` so the existing time-travel test, which stubs
Date.now to fast-forward past the TTL, still trips the SQL
expiry predicate.

fedify-dev#487 (comment)

Assisted-by: Claude Code:claude-opus-4-7
@dahlia
Copy link
Copy Markdown
Member Author

dahlia commented May 15, 2026

@codex review

@dahlia
Copy link
Copy Markdown
Member Author

dahlia commented May 15, 2026

/gemini review

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements passkey (WebAuthn) authentication, enabling users to enroll and manage passkeys in the admin dashboard and use them for multi-factor authentication during login. The changes include new database migrations for passkeys and login challenges, server-side logic using the @simplewebauthn library, and a lightweight client-side script for browser-based ceremonies. Review feedback suggests enhancing Zod schema validation for credential IDs, removing a redundant string trimming operation, and adopting more idiomatic date instantiation.

Comment thread src/pages/auth.tsx Outdated
Comment thread src/pages/auth.tsx Outdated
Comment thread src/pages/login.tsx Outdated
Comment thread src/pages/login.tsx Outdated
@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Chef's kiss.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

dahlia added 3 commits May 15, 2026 11:38
The Zod schemas accepting passkey responses on both routes had
`id: z.string()` (and matching `rawId`) without a length floor.
A real authenticator never returns an empty credential id, but
the validator would have let one through to the SimpleWebAuthn
verifier, which would reject it later with a less specific
error.  Add `.min(1)` on `id` and `rawId` in both schemas
(src/pages/auth.tsx finishBodySchema; src/pages/login.tsx
passkeyFinishSchema where the same constraint was already on
`id` but missing on `rawId`).

fedify-dev#487 (comment)
fedify-dev#487 (comment)

Assisted-by: Claude Code:claude-opus-4-7
finishBodySchema declares the nickname field as
z.string().trim().max(80).optional(), so by the time
body.nickname reaches the handler it has already been trimmed
(or is undefined).  The handler was calling .trim() on it again
and stashing the result in a `trimmedNickname` local that did
nothing useful.

Use body.nickname directly and leave a short comment recording
the schema-side guarantee so a future reader doesn't add the
trim back out of habit.

fedify-dev#487 (comment)

Assisted-by: Claude Code:claude-opus-4-7
The /passkey/finish DELETE WHERE expiry predicate read
`new Date(Date.now())` only because the existing time-travel
test stubbed Date.now with vi.spyOn(Date, "now"), and V8's Date
constructor doesn't go through that stub when called with no
arguments.  The rest of the file uses bare `new Date()` (e.g.,
the GC inside /begin), so this read like dead-weight defensive
code.

Migrate the test to timekeeper.travel(...), which mocks both
Date.now() and `new Date()`.  Other Hollo tests already use this
pattern (src/federation/account.test.ts, src/oauth.test.ts).
The finally block now calls timekeeper.reset() instead of
mockRestore.

With the test on timekeeper, the source-side workaround is no
longer needed; switch the WHERE clause to bare `new Date()` to
match the surrounding idiom.

fedify-dev#487 (comment)

Assisted-by: Claude Code:claude-opus-4-7
@dahlia
Copy link
Copy Markdown
Member Author

dahlia commented May 15, 2026

@codex review

@dahlia
Copy link
Copy Markdown
Member Author

dahlia commented May 15, 2026

/gemini review

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces passkey (WebAuthn) authentication, enabling users to register and manage passkeys through the admin interface and use them for multi-factor authentication during login. The changes encompass new database migrations, the integration of @simplewebauthn libraries, and updates to the authentication middleware. A review comment suggests explicitly importing webcrypto from node:crypto in src/passkey.ts to improve environment compatibility and maintain consistency with the project's existing import style.

Comment thread src/passkey.ts
@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@dahlia dahlia merged commit dbc208c into fedify-dev:main May 15, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant