feat(signup): re-issue a setup link when the email never arrived #118

Sammanfogat
supernaut sammanfogade 1 incheckning från feat/116-resend-setup-link in i main 2026-07-31 13:29:28 +00:00
Ägare

Closes #116. Implements both halves we agreed: the recovery path (A) and making the failure visible (B).

The constraint that shaped everything

Someone whose setup-link email failed has an account but no credential, so they cannot log in. That rules out an authenticated recovery page and forces an anonymous-reachable path — which is exactly where this feature could go wrong.

Send to the address on file, never one supplied

The form takes a username. The link goes to the address already registered on the account.

Accepting an email address would let anyone request a stranger's credential-reset link and have it delivered to themselves. That is not a recovery path, it is an account-takeover primitive. This is the single most important decision in the change.

Uniform response

An unknown username, a person with no address, and a provider failure all return the same sent status as a genuine success. The difference is logged for operators and never shown, so the endpoint cannot enumerate who has an account.

The success and not-found branches deliberately return the same thing; a comment says so, because "tidying" that into distinct statuses would silently reintroduce the oracle.

Other hardening

  • The lookup gates the intent. Minting first and discarding on failure would consume Kanidm work and record a credential-reset intent for an account nobody asked about. A test asserts no intent path is hit for an unknown user.
  • Username validated in the library as well as the route — it is interpolated into an API path, so ../admin or alice/../bob would address a different resource. Tested, with an assertion that Kanidm is not contacted at all.
  • GET-only Kanidm reader (kanidmResendDeps), so a resend cannot mutate a person even if the calling code is wrong.
  • 3/minute per IP, tighter than sign-up's 5, because each accepted attempt mints a reset intent and sends mail to a third party.
  • Captcha-gated with the same Cap proof-of-work as sign-up.
  • Not gated on signupsOpen() — closing registration must not strand people who already have an account.
  • Tokens are percent-encoded into the URL (tested with a b&c=d).

The B half: the failure is now visible

A sign-up whose email fails returns nomail instead of success. The user is told the account exists, the email did not arrive, and how to get it re-sent — instead of being told to check an inbox that will stay empty.

That false reassurance is precisely how a completely broken sender looked identical to a working sign-up from the outside on 2026-07-31.

UI shape

The re-issue view is a separate view on /signup rather than a second form on the default page, so each view carries exactly one captcha widget and an ordinary visitor is never asked to solve proof-of-work twice. /signup?status=resend is a durable URL for someone returning later, linked from the sign-up form.

New sv/en strings pass the content-style detector (Swedish terms tracking Forgejo sv-SE, British spelling, brand casing, sentence-case buttons).

Built test-first

15 tests in src/lib/resend.test.ts, each watched failing before the code existed. Beyond the happy path they cover malformed Kanidm responses (null, non-object, wrong types, blank address), the no-intent-for-unknown-user assertion, token encoding, and path-traversal usernames.

129 tests pass, astro check 0 errors, eslint + stylelint clean.

Known limitation

A person who never received the email and does not remember their username still needs operator help. Recovering by email address is what would fix that, and it is the thing that cannot be done safely without also building address verification — deliberately out of scope.

Closes #116. Implements both halves we agreed: the recovery path (A) and making the failure visible (B). ## The constraint that shaped everything Someone whose setup-link email failed has an account but **no credential**, so they cannot log in. That rules out an authenticated recovery page and forces an anonymous-reachable path — which is exactly where this feature could go wrong. ## Send to the address on file, never one supplied The form takes a **username**. The link goes to the address **already registered on the account**. Accepting an email address would let anyone request a stranger's credential-reset link and have it delivered to themselves. That is not a recovery path, it is an account-takeover primitive. This is the single most important decision in the change. ## Uniform response An unknown username, a person with no address, and a provider failure all return the same `sent` status as a genuine success. The difference is logged for operators and never shown, so the endpoint cannot enumerate who has an account. The success and not-found branches deliberately return the same thing; a comment says so, because "tidying" that into distinct statuses would silently reintroduce the oracle. ## Other hardening - **The lookup gates the intent.** Minting first and discarding on failure would consume Kanidm work and record a credential-reset intent for an account nobody asked about. A test asserts *no* intent path is hit for an unknown user. - **Username validated in the library as well as the route** — it is interpolated into an API path, so `../admin` or `alice/../bob` would address a different resource. Tested, with an assertion that Kanidm is not contacted at all. - **GET-only Kanidm reader** (`kanidmResendDeps`), so a resend cannot mutate a person even if the calling code is wrong. - **3/minute per IP**, tighter than sign-up's 5, because each accepted attempt mints a reset intent and sends mail to a third party. - **Captcha-gated** with the same Cap proof-of-work as sign-up. - **Not gated on `signupsOpen()`** — closing registration must not strand people who already have an account. - Tokens are percent-encoded into the URL (tested with `a b&c=d`). ## The B half: the failure is now visible A sign-up whose email fails returns `nomail` instead of `success`. The user is told the account exists, the email did not arrive, and how to get it re-sent — instead of being told to check an inbox that will stay empty. That false reassurance is precisely how a completely broken sender looked identical to a working sign-up from the outside on 2026-07-31. ## UI shape The re-issue view is a separate view on `/signup` rather than a second form on the default page, so **each view carries exactly one captcha widget** and an ordinary visitor is never asked to solve proof-of-work twice. `/signup?status=resend` is a durable URL for someone returning later, linked from the sign-up form. New sv/en strings pass the content-style detector (Swedish terms tracking Forgejo `sv-SE`, British spelling, brand casing, sentence-case buttons). ## Built test-first 15 tests in `src/lib/resend.test.ts`, each watched failing before the code existed. Beyond the happy path they cover malformed Kanidm responses (null, non-object, wrong types, blank address), the no-intent-for-unknown-user assertion, token encoding, and path-traversal usernames. **129 tests pass**, `astro check` 0 errors, eslint + stylelint clean. ## Known limitation A person who never received the email and does not remember their username still needs operator help. Recovering by email address is what would fix that, and it is the thing that cannot be done safely without also building address verification — deliberately out of scope.
supernaut lade till 1 incheckning 2026-07-31 13:06:05 +00:00
feat(signup): re-issue a setup link when the email never arrived
Alla kontroller lyckades
ci / ci (pull_request) Successful in 1m18s
f1e4f36c4f
Closes #116.

A person whose setup-link email failed has an account but no credential, so they
cannot log in — which rules out an authenticated recovery path and means recovery
must be reachable by an anonymous visitor. That shaped every decision here.

SEND TO THE ADDRESS ON FILE, NOT ONE SUPPLIED. The form takes a USERNAME and the
link goes to the address already registered on the account. Accepting an email
address would let anyone redirect a stranger's credential-reset link to themselves;
the feature would be an account-takeover primitive rather than a recovery path.

UNIFORM RESPONSE. An unknown username, a person with no address, and a provider
failure all return the same "sent" status as a real success. The difference is
logged for operators and never shown, so the endpoint cannot be used to enumerate
who has an account.

The person lookup gates the intent: minting first and discarding on failure would
consume Kanidm work and record a credential-reset intent for an account nobody
asked about. The username is validated against Kanidm's name grammar in the
library as well as the route, because it is interpolated into an API path — a value
containing `/` or `..` would address a different resource.

Rate limited to 3/minute per IP, tighter than sign-up's 5, because each accepted
attempt mints a reset intent and sends mail to a third party. Captcha-gated with
the same Cap proof-of-work as sign-up. NOT gated on signupsOpen(): closing
registration must not strand people who already have an account.

Kanidm access is exposed as a GET-only reader (kanidmResendDeps), so a resend
cannot mutate a person even if the calling code is wrong.

Also makes the failure visible (the B half). A sign-up whose email fails now
returns "nomail" instead of "success", so the user is told the account exists, the
email did not arrive, and how to get it re-sent — rather than being told to check an
inbox that will stay empty. That false reassurance is how a totally broken sender
looked identical to a working sign-up from the outside.

The re-issue view is a separate view on /signup rather than a second form on the
default page, so each view carries exactly one captcha widget and an ordinary
visitor is never asked to solve proof-of-work twice. /signup?status=resend is a
durable URL for someone returning later.

Built test-first: 15 tests in src/lib/resend.test.ts, each watched failing first.
They cover the address extraction against malformed Kanidm responses (null,
non-object, wrong types, blank), the assertion that NO intent is minted for an
unknown user or one without an address, token percent-encoding, and rejection of
path-traversal usernames without contacting Kanidm at all.

129 tests pass, astro check 0 errors, eslint and stylelint clean, and the
content-style detector reports no candidates in the new sv/en strings.
supernaut sammanfogade incheckning ced062cca5 till main 2026-07-31 13:29:28 +00:00
supernaut tog bort grenen feat/116-resend-setup-link 2026-07-31 13:29:29 +00:00
Logga in för att delta i denna konversation.
Inga granskare
Ingen milstolpe
Inget projekt
Inga tilldelade
1 deltagare
Notiser
Förfallodatum
Förfallodatumet är ogiltigt eller utanför gränserna. Använd formatet "åååå-mm-dd".

Inget förfallodatum satt.

Beroenden

Inga beroenden satta

Referens
bitborg/bitborg-web!118
Ingen beskrivning angiven.