Skip to content

fix(login): route every single-method account through the method chooser - #114

Open
yahyafakhroji wants to merge 1 commit into
mainfrom
fix/single-method-routing
Open

fix(login): route every single-method account through the method chooser#114
yahyafakhroji wants to merge 1 commit into
mainfrom
fix/single-method-routing

Conversation

@yahyafakhroji

@yahyafakhroji yahyafakhroji commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Problem

The identifier step picked a per-method screen from a static map, so an account whose only sign-in method was a linked IdP was sent to /sso — a session-gated management page that cannot start an intent. A Google-only user landed on a dead end instead of at Google.

Change

Every account with at least one usable method now routes to /login/method, which renders exactly what that account has and auto-starts what needs no form:

  • Sole linked IdP starts its intent in the loader, so the chooser never flashes. A one-shot signed marker cookie stops the Back-from-provider arrival re-minting an intent; it is retired on each fresh identifier submit.
  • Sole passkey begins its ceremony on mount.
  • Sole password takes one extra click — deliberate, since collapsing every branch onto one destination is what keeps the unknown-identifier path indistinguishable.

The identifier step picked a per-method screen from a static map, so an account
whose only method was a linked IdP was sent to /sso — a session-gated management
page that cannot start an intent. A Google-only user landed on a dead end instead
of at Google.

Route every account with at least one usable method to /login/method instead, which
renders exactly what that account has and auto-starts what needs no form. The
decision is pure over method KINDS, so it has no idpId and could only ever name a
static path; resolving the actual provider has to happen where the links are known.

  - A sole linked IdP starts its intent in the LOADER, so the chooser never flashes.
    Being in a loader is also what makes it dangerous: a loader re-runs when the user
    presses Back at the provider, which would mint a new intent and bounce them
    forward forever. A one-shot marker cookie (signed, httpOnly, 10 min) makes the
    second arrival render the chooser instead, whose button restarts the ceremony
    deliberately as a POST. It is retired on each fresh identifier submit so the next
    sign-in auto-starts again.
  - A sole passkey begins its ceremony on mount. The auto-begun attempt has no
    transient user activation behind it, which WebKit requires, so its failure copy
    is suppressed for that attempt only rather than accusing a user who has done
    nothing but arrive.
  - Sole-password accounts take one extra click. Deliberate: collapsing every branch
    onto one destination is what keeps the unknown-identifier path indistinguishable.

The loader is state-changing and its outcomes are distinguishable from outside, so it
is gated on a live ceremony session for that loginName and throttled by two tiers — a
tight ip|loginName budget bounding intents minted against one address, and a loose ip
ceiling bounding enumeration breadth. The identifier POST that hands out that session
is throttled too; it was the one unthrottled member of the auth surface.

Anti-enumeration holds across the move. `ignoreUnknownUsernames` routes an unknown
identifier through the same decision a password-only account gets, and the chooser
serves it the same screen — same status, target, payload, cookies and audit line. Its
policy org is resolved from the identifier's domain, so an unknown address is judged
by the org that claims it rather than by the default org, whose policy can differ.

`?policyOrg=` is now HMAC-authenticated. It rides an IdP return URL, so it is fully
attacker-writable, and it is the one param there coupled to nothing else: it feeds the
callback's allowRegister gate and auto-create org alone, while the same-email lookup
stays instance-wide. A hand-written value would borrow a registration-open org's policy
across that gap. Length validation cannot help, since decoupling those two orgs is the
param's whole purpose — only provenance can.

Also fixes the 429 page's back-link, which hardcoded /id instead of deriving it from
APP_BASENAME and would 404 the user at the moment they are trying to recover.
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

🧪 Test Summary

Job Status
E2E (fast, fake provider) ✅ success
Unit Tests (component) (0) ✅ success
Unit Tests (component) (1) ✅ success
Unit Tests (component) (2) ✅ success

View workflow run

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