Skip to content

feat(auth): replace invite temp passwords with emailed set-password links - #335

Open
prajjwalkumar17 wants to merge 1 commit into
feat/forgot-password-resetfrom
feat/invite-set-password-link
Open

feat(auth): replace invite temp passwords with emailed set-password links#335
prajjwalkumar17 wants to merge 1 commit into
feat/forgot-password-resetfrom
feat/invite-set-password-link

Conversation

@prajjwalkumar17

Copy link
Copy Markdown
Member

Summary

invite_member previously generated a temporary password for new users and both returned it in the HTTP response body (InviteMemberResponse.password) and emailed it in plaintext. This was flagged during the forgot-password security review as a pre-existing weakness to fix once a reset flow existed — which #333 provides. This PR removes passwords from the invite flow entirely: invitees now receive a single-use set-password link.

Stacked on #333 (feat/forgot-password-reset) — it reuses the reset-code machinery and redemption endpoint introduced there. Merge #333 first; GitHub will retarget this PR to main automatically.

How the new flow works

  • New users are created with an unusable random bcrypt hash (a discarded 244-bit secret) and email_verified = false — the account cannot be logged into until the invitee sets a password.
  • The invite email carries a single-use set-password link built on the reset machinery: same SHA-256-keyed Redis entry and the same POST /auth/reset-password redemption (which also marks the mailbox verified), but with a 7-day TTL since invitees may not open the email same-day.
  • Ordering / rollback: the email is sent before any DB write — a delivery failure fails the invite with 500 and persists nothing (admin just retries). A membership-insert failure compensates by deleting both the user row and the code. A lost invite email is recoverable via the normal forgot-password flow.
  • InviteMemberResponse.password is gone; the Members page shows an "invitation sent" banner instead of a credentials card. InviteUserTemplate (credentials email) is replaced by InviteSetPasswordTemplate (CTA link + 7-day/single-use footer). Existing-user invites (membership add + notification email) are unchanged.

Hardenings from the review pass

This PR was reviewed by a multi-agent adversarial pass (3 reviewer dimensions, every finding independently verified); all confirmed findings are fixed here:

  • 7-day codes vs revocation: user_pwreset_at's TTL now covers the invite-code lifetime, so a password change/reset still invalidates every previously emailed link — without this, a stale invite link could regain takeover power after the revocation key expired.
  • No silent dead accounts: release deployments without an active email client (no_email_client) now fail the invite instead of "succeeding" while delivering no link (debug builds keep the dev flow, where the stub logs the URL).
  • Log-leak hardening: the no_email stub's release-logged branch now matches starts_with("confirm your email"), closing a path where an admin-chosen merchant name could steer a live set-password link into release logs.
  • Oracle removal: login now verifies the password before disclosing EmailNotVerified, so probing an invited-but-unredeemed account returns the same generic 401 as a nonexistent one.
  • The unusable placeholder hash is computed before the Redis write so every post-write failure path has a compensating delete.

Testing

  • cargo check clean on both mysql and postgres feature paths; tsc clean.
  • Live end-to-end on an isolated instance: admin signup → merchant → invite → response contains no password field → login before redemption fails → set-password link from the (debug) log redeems with 200 → invitee logs in with their chosen password, email_verified = true, membership present → link replay 400 → re-invite 409 AlreadyMember → wrong-password probes against an unverified invited account and a nonexistent account return byte-identical responses.

Docs

docs/api-refs/auth-and-onboarding.mdx — invite section rewritten: new response shape, link semantics, and the active-email-client requirement for release deployments.

🤖 Generated with Claude Code

…inks

Invites previously generated a temporary password and both returned it in
the HTTP response (InviteMemberResponse.password) and emailed it in
plaintext — flagged in the forgot-password security review as the invite
flow's biggest weakness once a reset flow existed.

- New users are now created with an unusable random bcrypt hash and
  email_verified=false; the invite email carries a single-use
  set-password link (reset-code machinery: SHA-256-keyed Redis entry,
  7-day TTL) redeemed on the existing /reset-password page, which also
  marks the mailbox verified
- Invite email is sent before any DB write: delivery failure fails the
  invite cleanly (500, nothing persisted); membership-insert failure
  compensates by deleting the user row and the code
- Release deployments without an active email client now fail the invite
  instead of silently creating an unreachable account (debug builds keep
  the no_email dev flow, which logs the link)
- user_pwreset_at TTL raised to cover the 7-day invite codes so a
  password change still kills every previously emailed link
- no_email release logging keys on starts_with, closing a
  merchant-name-controlled subject collision that could route a live
  set-password link into release logs
- login now checks the password before disclosing EmailNotVerified,
  removing a verification-status/existence oracle
- InviteMemberResponse.password removed; MembersPage shows an
  "invitation sent" banner instead of credentials; InviteUserTemplate
  replaced by InviteSetPasswordTemplate; docs updated

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@prajjwalkumar17 prajjwalkumar17 self-assigned this Jul 27, 2026
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.

1 participant