Skip to content

feat(users): signed temporary invite URLs for adding local users (#272) - #273

Merged
benders merged 4 commits into
mainfrom
feature/272-user-invites
Sep 12, 2026
Merged

benders merged 4 commits into
mainfrom
feature/272-user-invites

Conversation

@benders

@benders benders commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Closes #272.

Adding a local user meant an admin typing a password on someone else's behalf and relaying it out-of-band. Now the admin issues a signed, expiring, single-use link; the invitee opens it, picks their own username and password, and lands signed in.

Shape, and where it departs from peer invites

Peer invite User invite
Signed with Ed25519 federation key HMAC-SHA-256, settings.user_invite_key (new)
Verified by Remote hub, no prior trust Issuing hub only
Redeemed via POST /federation/handshake POST /api/invites/redeem (unauthenticated)
Default TTL 7 d 48 h
Federation version bump yes no — nothing crosses /federation/*

The asymmetric signature exists so a different hub can verify a peer invitation. A user invite never leaves the hub that minted it, so it signs under its own HMAC key — the Ed25519 key is cluster trust (admission and eviction) and shouldn't be spent on account creation. Same blast-radius argument that keeps castSecret separate from internalAuthSecret.

The signature is not the authority; the DB row is. It only proves the token was minted here, which lets the public route reject junk before touching SQLite. Single-use, expiry, and revocation are enforced by one atomic UPDATE … WHERE consumed_at IS NULL … RETURNING — no SELECT-then-UPDATE (the #156 TOCTOU trap).

Endpoints

Method Path Auth
POST / GET /api/admin/hub/user-invites requireOwner
DELETE /api/admin/hub/user-invites/:id requireOwner (revoke)
POST /api/invites/preview none
POST /api/invites/redeem none

Redeem returns the POST /admin/login body shape, so the SPA reuses one code path.

Details worth a reviewer's eye

  • Token in the fragment. …/invite#<token>, both public routes are POST — the token never reaches an access log, a Referer header, or a proxy. The page clears the hash after reading it.
  • Hash at rest. Only sha256(token) is stored. Matters more here than usual: users.password_enc is reversible, so the DB is already the prize.
  • Insert user, then claim. consumed_by_id is a FK onto users, so claiming for an account that doesn't exist yet fails the constraint; an empty claim throws to roll back the account with it. Username is checked before the claim so a typo doesn't burn the invite.
  • One uniform 400 for forged / expired / consumed / revoked / unknown. Username-taken (409) is the one distinguishable case, and has to be.
  • Per-IP rate limit (20 / 10 min, in-process, dependency-free) on the only unauthenticated write surface on the hub.
  • /invite fires no authenticated fetch on mount — the /login self-redirect trap. Both App's checkAuth guard and apiFetch's 401 redirect now cover it.
  • formatTimeUntil added: formatTimeAgo clamps future timestamps to "just now", which reads as expired on an invite with two days left.

Testing

pnpm verify green: 32 new hub tests (signing, tamper, expiry, atomic single-use, admin API, redemption, rate limit) and 10 new frontend tests (admin section, redeem page). pnpm lint clean. No /rest/* or /federation/* contract change, so the compat suite is unaffected — CI runs it regardless.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Q6d5j3DE1sqf6Gm34gEAAN

Admins can issue a signed, expiring, single-use invite URL instead of
typing a password on someone else's behalf. Mirrors the peer-admission
flow, with two deliberate departures:

- HMAC-SHA-256 under a new `settings.user_invite_key`, not the Ed25519
  federation key. A user invite is issued and redeemed by the same hub,
  so asymmetric signing buys nothing — and that key is cluster trust
  (admission/eviction), an entirely different blast radius. Same
  reasoning that keeps castSecret separate from internalAuthSecret.
- The DB row stays authoritative. The signature only proves the token
  was minted here; single-use, expiry and revocation are enforced by the
  atomic consume in consumeUserInvite (no SELECT-then-UPDATE, per #156).

Only sha256(token) is persisted, so a stolen DB yields no redeemable
invites. The token rides in the URL fragment, keeping it out of access
logs and Referer headers.

New: `user_invitations` table, services/user-invites.ts, three owner-only
endpoints under /api/admin/hub/user-invites, and a public /api/invites/*
pair (preview + redeem) with a per-IP rate limit and one uniform
rejection for every bad token. Redeem creates the account, honors the
invite's admin grant, and returns the /admin/login body shape so the
invitee lands signed in.

The user row is inserted before the invite is claimed —
consumed_by_id is a FK onto users — and a lost race throws to roll back
the account along with the claim.

Refs #272

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6d5j3DE1sqf6Gm34gEAAN
Hub admin gains an Invitations section beside Users: issue a link with an
optional suggested username, note, admin grant, and TTL; watch its state
(pending / consumed / expired / revoked); revoke while pending. The link
is shown once and never again — the hub stores sha256(token), so it
cannot re-display it.

The redeem page at /invite is unauthenticated and lives in pages/, not in
either bounded admin dir. It reads the token from the URL fragment, then
immediately rewrites the address bar so the token doesn't linger in
history, and it talks only to the two public /api/invites/* POSTs via a
plain fetch — an apiFetch there would 401 → redirect → mount → 401. The
same guard now covers /invite in apiFetch's redirect and in App's
checkAuth.

Adds formatTimeUntil: formatTimeAgo clamps future timestamps to
"just now", which reads as expired on an invite with two days left.

Refs #272

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6d5j3DE1sqf6Gm34gEAAN
…ME (#272)

authentication.md gains a "User invitations" section: key separation
rationale, canonical payload, endpoint table, the insert-user-then-claim
ordering, and the hardening rules. system-architecture.md picks up the
/api/invites/* mount, the user_invitations table, and a Design-decisions
row for the separate signing key. Four new traps in pitfalls.md (borrowed
signing keys, storing tokens instead of hashes, ISO-vs-datetime('now')
comparison, new unauthenticated routes). README gets an operator-facing
"Adding a user".

Closes #272

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6d5j3DE1sqf6Gm34gEAAN
@benders
benders marked this pull request as ready for review September 12, 2026 02:31
@benders
benders merged commit 0627ad5 into main Sep 12, 2026
2 checks passed
@benders
benders deleted the feature/272-user-invites branch September 12, 2026 02:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(users): signed temporary invite URLs for adding local users

2 participants