Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
613bb62
Add @simplewebauthn dependencies
dahlia May 14, 2026
2bf93ed
Add passkeys table
dahlia May 14, 2026
29205d9
Add passkey model helpers
dahlia May 14, 2026
77a82d4
Accept a passkey assertion as the second factor
dahlia May 14, 2026
83de0a8
Enroll and manage passkeys from the Auth page
dahlia May 14, 2026
2b2ddfb
Sign in with a passkey
dahlia May 14, 2026
e235a85
Wire passkey ceremonies up in the browser
dahlia May 14, 2026
491fb9d
Make passkey login challenges single-use
dahlia May 14, 2026
320e23f
Document the passkey work
dahlia May 14, 2026
7b98ac9
Harden /login/passkey/begin against unauthenticated abuse
dahlia May 15, 2026
6e6a35f
Add a PR link to the changelog
dahlia May 15, 2026
01d4b16
Pull SECRET_KEY from src/env.ts in the login page
dahlia May 15, 2026
f82232a
Use one timestamp for the begin transaction predicates
dahlia May 15, 2026
6280988
Handle auth redirects before parsing JSON in postJson
dahlia May 15, 2026
772797f
Refuse safeNext paths that start with two slashes
dahlia May 15, 2026
dc25d17
Burn the passkey_reg cookie before validating the body
dahlia May 15, 2026
9682924
Drop the redundant copy in decodePublicKey
dahlia May 15, 2026
c41a7a8
Evict the oldest /begin row instead of 429 at cap
dahlia May 15, 2026
6fc9c03
Simplify the transports cast in /passkeys/registration/begin
dahlia May 15, 2026
a7e367a
Modernise the passkey.js client script
dahlia May 15, 2026
051af9a
Render passkey dates as <time> with locale text
dahlia May 15, 2026
eecf069
Fold the expiry check into the /finish DELETE
dahlia May 15, 2026
c927d70
Require non-empty WebAuthn credential ids in schemas
dahlia May 15, 2026
49f0822
Drop the redundant nickname trim in /finish
dahlia May 15, 2026
3b1397e
Use bare new Date() in /finish, drive the test via timekeeper
dahlia May 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,26 @@ Version 0.9.0

To be released.

- Added passkey (WebAuthn) authentication. The admin *Auth* page now
has a "Passkeys" section for enrolling and managing passkeys, and
the public login page presents a "Sign in with passkey" button
(with the email/password form tucked behind a toggle) whenever at
least one passkey is enrolled. Both device-bound and synced
(multi-device) passkeys are accepted. A passkey on its own counts
as multi-factor authentication, so a successful passkey sign-in is
accepted in place of the TOTP step — the user is not asked for a
one-time code in the same session.

Hollo uses the *@simplewebauthn/server* library for verification
and ships the matching browser helper as a static asset linked
only from the auth and login pages. Registration uses
`residentKey: required` and `userVerification: required`, so every
enrolled passkey is discoverable and tied to a biometric or PIN
gesture. Registration challenges are bound to the current login
session with a server-enforced 5-minute TTL, and login challenges
are stored in a single-use `passkey_login_challenges` table so a
captured cookie + assertion pair can be redeemed at most once. [[#487]]

- Added optional split-domain WebFinger support. When the new
`HANDLE_HOST` and `WEB_ORIGIN` environment variables are set,
Hollo uses Fedify's `origin` configuration so that fediverse
Expand Down Expand Up @@ -221,6 +241,7 @@ To be released.
[#482]: https://github.com/fedify-dev/hollo/pull/482
[#483]: https://github.com/fedify-dev/hollo/pull/483
[#484]: https://github.com/fedify-dev/hollo/pull/484
[#487]: https://github.com/fedify-dev/hollo/pull/487


Version 0.8.4
Expand Down
31 changes: 31 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -352,3 +352,34 @@ shorthand at class extraction time, but the original string still ships
in the HTML `class` attribute, where the browser splits it on
whitespace and matches `ring-2` (etc.) as a standalone class. Always
write each variant out long-form (`focus:border-brand-500 focus:ring-2 …`).


### Page-scoped client scripts

The lightweight-SSR principle still stands: *Layout.tsx* and the
dashboard chrome stay JavaScript-free. A handful of existing pages
emit tiny inline scripts (e.g. `onsubmit="this.submit.ariaBusy='true'"`
on long-running forms, or `<script dangerouslySetInnerHTML>` blocks
for small DOM enhancements like dependent field reveal); those are
fine for one-liners that decorate an otherwise functional form, and
they're allowed to stay.

For features that genuinely *can't* work without JavaScript — currently
just the WebAuthn passkey ceremonies on the admin *Auth* page and the
public login page — prefer the passkey pattern over an inline blob:

- Drop a small, hand-written `.js` file into *src/public/* (e.g.
*passkey.js*). Keep it short, IIFE-wrapped, no framework, no build
step. If it depends on a vendored library, copy that library's
UMD bundle into *src/public/* as a separate file and add a comment
documenting how to re-vendor it after a dep bump.
- Emit `<script src="/public/your-script.js" defer>` at the very end
of the JSX tree of only the page(s) that need it — not in
*Layout.tsx*. A referenced static asset stays CSP-friendly and
lets the script live as a normal source file with real syntax
highlighting.
- The script must degrade gracefully: if it fails to load, the
underlying HTML form should still be operable (or the feature
should be hidden behind a button the user has to click).
- Wire to the DOM via stable `id`s on the form / button the page
already renders. The server-rendered markup is the contract.
14 changes: 14 additions & 0 deletions drizzle/0088_passkeys.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
CREATE TABLE "passkeys" (
"id" text PRIMARY KEY NOT NULL,
"credential_email" varchar(254) NOT NULL,
"public_key" text NOT NULL,
"counter" bigint NOT NULL,
"transports" text[] DEFAULT (ARRAY[]::text[]) NOT NULL,
"device_type" text NOT NULL,
"backed_up" boolean NOT NULL,
"nickname" text NOT NULL,
"last_used" timestamp with time zone,
"created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
ALTER TABLE "passkeys" ADD CONSTRAINT "passkeys_credential_email_credentials_email_fk" FOREIGN KEY ("credential_email") REFERENCES "public"."credentials"("email") ON DELETE cascade ON UPDATE no action;
8 changes: 8 additions & 0 deletions drizzle/0089_passkey_login_challenges.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
CREATE TABLE "passkey_login_challenges" (
"id" text PRIMARY KEY NOT NULL,
"challenge" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE INDEX "passkey_login_challenges_expires_at_index" ON "passkey_login_challenges" USING btree ("expires_at");
Loading
Loading