Repository navigation
feat(users): signed temporary invite URLs for adding local users (#272) - #273
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
settings.user_invite_key(new)POST /federation/handshakePOST /api/invites/redeem(unauthenticated)/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
castSecretseparate frominternalAuthSecret.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
POST/GET/api/admin/hub/user-invitesrequireOwnerDELETE/api/admin/hub/user-invites/:idrequireOwner(revoke)POST/api/invites/previewPOST/api/invites/redeemRedeem returns the
POST /admin/loginbody shape, so the SPA reuses one code path.Details worth a reviewer's eye
…/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.sha256(token)is stored. Matters more here than usual:users.password_encis reversible, so the DB is already the prize.consumed_by_idis a FK ontousers, 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./invitefires no authenticated fetch on mount — the/loginself-redirect trap. BothApp'scheckAuthguard andapiFetch's 401 redirect now cover it.formatTimeUntiladded:formatTimeAgoclamps future timestamps to "just now", which reads as expired on an invite with two days left.Testing
pnpm verifygreen: 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 lintclean. 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