resend: accept an email address as well as a username #127

Stängd
öppnade 2026-07-31 19:54:03 +00:00 av supernaut · 1 kommentar
Ägare

The resend form (#116) accepts only a username. A user who never received their set-up email is far more likely to remember their email address than the username they picked minutes earlier — so the recovery path asks for the one thing they may not have.

Raised by the operator after walking the journey.

Why it was built username-only

Not arbitrary: the link is sent to the address on file, never to one the caller types. Accepting an address as the destination would let anyone have a stranger's credential-reset link delivered to themselves — an account-takeover primitive.

Accepting an address purely as a lookup key, while still sending to the registered address, is safe from that angle. The enumeration risk is also already handled: the endpoint returns an identical response whether or not the account exists.

So the objection is not security. It is that we have no way to resolve an email to a person.

What I verified, so nobody re-derives it

Kanidm has no server-side email filter on /v1/person. A filter= query parameter is silently ignored:

GET /v1/person?filter={"eq":["mail","test2@gitborg.se"]}  → 200, 5 persons
GET /v1/person                                            → 200, 5 persons

Same count both times — the parameter does nothing.

The portal's own database cannot resolve it either. signups (src/db/schema.ts) is keyed by username and deliberately stores no email, to avoid duplicating PII that Kanidm already holds.

Options

  1. List all persons from Kanidm and match client-side. Works with today's API. But it fetches every person on every anonymous resend request, needs read-all-persons permission the portal's write-scoped provisioning token does not have, and pulls the full user list into the process in response to unauthenticated input. Acceptable at five accounts, wrong by construction.
  2. Store a salted hash of the email at sign-up and look up by hash on resend. O(1), adds no plaintext PII, no Kanidm privileges. Costs a migration, and does not help any account created before the column exists — including every current one.
  3. Find the real search API first. Kanidm may expose a proper search (/v1/raw/search or similar) with different privileges. Worth ruling in or out before building a workaround, since it would make this trivial.
  4. Keep username-only and make the username easier to hold on to. The set-up email already greets the user by username, so anyone who still has any of our email has it. The form could say "your username — it is in the email we sent you". Nearly free, but useless to someone with no email at all, which is precisely the population this serves.

Recommendation

Investigate option 3 first. If Kanidm can search by attribute with a scoped token, this becomes a small change and the right one. If it cannot, option 2 is the honest fallback, with option 4's copy improvement shipped alongside either.

Do not implement option 1.

Done when

Someone who did not receive their set-up email can request a new one using either their username or their email address, with the link still going only to the address on file, and with the response revealing nothing about which accounts exist.

The resend form (#116) accepts only a username. A user who never received their set-up email is far more likely to remember their **email address** than the username they picked minutes earlier — so the recovery path asks for the one thing they may not have. Raised by the operator after walking the journey. ## Why it was built username-only Not arbitrary: the link is sent to the address **on file**, never to one the caller types. Accepting an address as the *destination* would let anyone have a stranger's credential-reset link delivered to themselves — an account-takeover primitive. Accepting an address purely as a **lookup key**, while still sending to the registered address, is safe from that angle. The enumeration risk is also already handled: the endpoint returns an identical response whether or not the account exists. So the objection is not security. It is that we have no way to resolve an email to a person. ## What I verified, so nobody re-derives it **Kanidm has no server-side email filter on `/v1/person`.** A `filter=` query parameter is silently ignored: ``` GET /v1/person?filter={"eq":["mail","test2@gitborg.se"]} → 200, 5 persons GET /v1/person → 200, 5 persons ``` Same count both times — the parameter does nothing. **The portal's own database cannot resolve it either.** `signups` (`src/db/schema.ts`) is keyed by `username` and deliberately stores **no email**, to avoid duplicating PII that Kanidm already holds. ## Options 1. **List all persons from Kanidm and match client-side.** Works with today's API. But it fetches every person on every anonymous resend request, needs read-all-persons permission the portal's write-scoped provisioning token does not have, and pulls the full user list into the process in response to unauthenticated input. Acceptable at five accounts, wrong by construction. 2. **Store a salted hash of the email at sign-up** and look up by hash on resend. O(1), adds no plaintext PII, no Kanidm privileges. Costs a migration, and does **not** help any account created before the column exists — including every current one. 3. **Find the real search API first.** Kanidm may expose a proper search (`/v1/raw/search` or similar) with different privileges. Worth ruling in or out before building a workaround, since it would make this trivial. 4. **Keep username-only and make the username easier to hold on to.** The set-up email already greets the user by username, so anyone who still has *any* of our email has it. The form could say "your username — it is in the email we sent you". Nearly free, but useless to someone with no email at all, which is precisely the population this serves. ## Recommendation Investigate option 3 first. If Kanidm can search by attribute with a scoped token, this becomes a small change and the right one. If it cannot, option 2 is the honest fallback, with option 4's copy improvement shipped alongside either. Do not implement option 1. ## Done when Someone who did not receive their set-up email can request a new one using either their username or their email address, with the link still going only to the address on file, and with the response revealing nothing about which accounts exist.
supernaut lade till detta till projektet Bitborg Web 2026-07-31 19:54:04 +00:00
Upphovsperson
Ägare

Unblocking the investigation this issue asks for

The API question is settled. Checked directly against the running server's own OpenAPI document
(https://auth.gitborg.se/docs/v1/openapi.json, served unauthenticated), so this is the surface of the
version we actually run rather than the surface of whatever version the docs describe.

Kanidm 1.10.4 exposes two search endpoints, so no schema change and no stored derived data are
needed:

Endpoint Notes
GET /v1/person/_search/{id} Person-scoped search. The spec carries no description, so what it matches — name/spn only, or mail too — still has to be established empirically.
POST /v1/raw/search Takes a SearchRequest body, i.e. an arbitrary filter such as {"filter":{"eq":["mail","<address>"]}}. Its own summary says "Raw request to the system, be warned this can be dangerous!"

There is also GET /v1/person, which lists persons — a match could be done client-side without any
search endpoint. That works at present scale and stops working quietly later, so it is a poor default.

What remains, and it is an authorisation question, not an API one

Whether the portal's scoped service account may run either call. It is a member of
idm_people_on_boarding, idm_people_admins and idm_bitborg_ent_managers
(bitborg-infra/ansible/roles/kanidm/defaults/main.yml), and idm_people_admins manages person
entries — so read access to mail is likely, but "likely" is what this issue's sibling #291 was closed
on once already and should not be repeated. Establish it by calling the endpoint with the portal's own
token.

Prefer GET /v1/person/_search/{id} over /v1/raw/search if it turns out to match mail: a scoped
endpoint is a smaller grant than an arbitrary-filter one, and the raw endpoint's own summary warns
against casual use.

Why the previous attempt stopped here

The implementation pass had no network access, so it could not run any of this and deliberately did not
build on a guessed endpoint. The copy half of the fix — telling people where their username is written
down — shipped in #136; that stands on its own and is what the issue said should ship alongside either
approach.

The stored-keyed-digest alternative remains the fallback if the lookup turns out to be denied. It should
stay the fallback: it costs a migration plus a new pepper secret that only bitborg-infra can provision,
helps zero existing accounts, and stores derived personal data to work around a lookup that the running
server appears to support.

## Unblocking the investigation this issue asks for The API question is settled. Checked directly against the **running** server's own OpenAPI document (`https://auth.gitborg.se/docs/v1/openapi.json`, served unauthenticated), so this is the surface of the version we actually run rather than the surface of whatever version the docs describe. **Kanidm 1.10.4 exposes two search endpoints**, so no schema change and no stored derived data are needed: | Endpoint | Notes | | --- | --- | | `GET /v1/person/_search/{id}` | Person-scoped search. The spec carries no description, so what it matches — name/spn only, or `mail` too — still has to be established empirically. | | `POST /v1/raw/search` | Takes a `SearchRequest` body, i.e. an arbitrary filter such as `{"filter":{"eq":["mail","<address>"]}}`. Its own summary says *"Raw request to the system, be warned this can be dangerous!"* | There is also `GET /v1/person`, which lists persons — a match could be done client-side without any search endpoint. That works at present scale and stops working quietly later, so it is a poor default. ## What remains, and it is an authorisation question, not an API one Whether the **portal's** scoped service account may run either call. It is a member of `idm_people_on_boarding`, `idm_people_admins` and `idm_bitborg_ent_managers` (`bitborg-infra/ansible/roles/kanidm/defaults/main.yml`), and `idm_people_admins` manages person entries — so read access to `mail` is likely, but "likely" is what this issue's sibling #291 was closed on once already and should not be repeated. Establish it by calling the endpoint with the portal's own token. Prefer `GET /v1/person/_search/{id}` over `/v1/raw/search` if it turns out to match `mail`: a scoped endpoint is a smaller grant than an arbitrary-filter one, and the raw endpoint's own summary warns against casual use. ## Why the previous attempt stopped here The implementation pass had no network access, so it could not run any of this and deliberately did not build on a guessed endpoint. The copy half of the fix — telling people where their username is written down — shipped in #136; that stands on its own and is what the issue said should ship alongside either approach. The stored-keyed-digest alternative remains the fallback if the lookup turns out to be denied. It should stay the fallback: it costs a migration plus a new pepper secret that only bitborg-infra can provision, helps zero existing accounts, and stores derived personal data to work around a lookup that the running server appears to support.
Logga in för att delta i denna konversation.
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#127
Ingen beskrivning angiven.