Skip to content

Login: remember accounts on the device and offer them as a picker - #636

Merged
schmitch merged 2 commits into
mainfrom
claude/remembered-accounts-115
Oct 28, 2026
Merged

schmitch merged 2 commits into
mainfrom
claude/remembered-accounts-115

Conversation

@schmitch

Copy link
Copy Markdown
Member

Fixes #115

After someone has signed in, the login screen shows their account the next time instead of an empty username field, like Microsoft's "Pick an account". One click continues as that account. Use another account shows today's form, and each tile can be forgotten. Signing out keeps the account, and so does a session-expired redirect to /login.

What changes

Remembered accounts (client-side only) — web/src/lib/accounts.ts

  • Accounts are kept in localStorage under forge.accounts. Each entry holds the identifier, the IdP's display name, the method with its realm, and the last-used time. The list is newest first, at most 5, with one entry per account whatever the case (usernames are canonical lowercase/NFC).
  • No password, token or session is ever stored. Every storage access is wrapped in try/catch: with storage blocked, the picker is simply not shown, and a damaged value reads as empty.
  • A password sign-in is recorded when it succeeds.
  • An OIDC sign-in leaves a pending marker in sessionStorage before the browser leaves. The first /auth/me after the round trip (syncAuthSession) turns it into an entry. If the browser comes back to the login screen instead, the marker is dropped, and a marker older than the flow's 10 minutes is ignored.

Picker — web/src/routes/Login.tsx

  • Each tile shows the display name, the identifier and an "SSO" / "password" badge.
  • OIDC account whose realm /auth/login-options still lists: goes straight to /auth/oidc/login with redirect_to and login_hint.
  • OIDC account whose realm is gone or disabled: routed by /auth/identify, like a typed identifier, so it cannot loop.
  • Password account, identifier-first mode: goes to the "Signing in as … (Change)" password step, after /auth/identify confirms the account still signs in with a password. A domain an OIDC realm has taken over since goes to that IdP instead.
  • Password account, combined mode: fills the form's username and focuses the password field.
  • In both modes the picker is a step in front of the form, with a Back to your accounts link from the form. An invitation's ?username= skips the picker.

login_hint to the IdP

  • GET /auth/oidc/login accepts an optional login_hint, which is trimmed and must be ≤ 256 bytes with no control characters. begin_login (core) adds it to the authorize URL as an encoded parameter.
  • An invalid hint is dropped rather than refused, because the sign-in works without it. It is logged at debug level only, and only its size.
  • The identifier-first form now passes the email the user typed too.

remember_accounts setting (default true)

  • This is a stored auth setting, not a forge.toml key (see the note below).
  • It is exposed in GET /auth/login-options, set through /admin/settings/auth, and has a checkbox under Admin → Settings → Sign-in.
  • When it is off, the login screen neither stores nor shows accounts, and it forgets those a browser kept before.

/auth/me display name

  • /auth/me now returns display_name: the IdP's name for an OIDC session, null otherwise.
  • To make that possible, the OIDC callback now links the session it opens to its external_identity row. That column existed but was never filled.
  • This adds one new query, external_identity_dao::display_name, with its .sqlx file.

Deviation from the issue

The issue proposed auth.remember_accounts in config.rs and forge.toml.example. Since #294, though, every login-screen policy (login_mode included) is a database auth setting, and startup refuses those keys in forge.toml. So remember_accounts sits next to login_mode in AuthSettings. That also gives it the admin screen AGENTS.md asks for. A stored settings row from before this change reads it as true.

Tests

  • Vitest: the store (ordering, cap, case-insensitive upsert, forget/forget-all, damaged values, blocked storage, the pending OIDC marker and its expiry) and the picker (both entry kinds in both modes, login_hint, the fallback for a disabled or renamed realm, forget, use another account, expired session, invitation, setting off, options unavailable). Also the admin settings checkbox. The full web suite passes (498 tests), and tsc is clean for the app and e2e.
  • Rust:
    • sanitize_login_hint unit test.
    • build_authorize_url with a hint (encoded, cannot add parameters).
    • oidc_routes::login_passes_a_valid_login_hint_to_the_idp: valid, encoded, blank, control character, 256 vs 257 bytes.
    • auth_me_returns_identity_and_csrf_token now checks display_name.
    • auth_settings covers remember_accounts through the API and /auth/login-options.
    • forge-core OIDC flow tests, oidc_routes, auth_settings and me pass locally.
    • cargo clippy --workspace --all-targets -D warnings, cargo fmt --check, cargo sqlx prepare --check and cargo xtask changelog --check are clean.
  • Playwright: auth.spec.ts signs in, signs out, then signs in again from the tile. The whole e2e suite passes locally (22/22).

Changelog: docs/changes/2.2.0/115.feature.md. The setting and the behaviour are documented in docs/deployment.md under "Access and sign-in settings".


Generated by Claude Code

After someone has signed in, the login screen shows their account the
next time instead of an empty username field, like Microsoft's "Pick an
account". One click continues as that account; "Use another account"
shows today's form, and each tile can be forgotten.

- The SPA keeps the accounts in localStorage (forge.accounts, web/src/
  lib/accounts.ts): the identifier, the IdP's display name, the method
  with its realm and the last use, newest first, at most five, one entry
  per account whatever its case. Never a password, a token or the
  session. Every access is wrapped; with storage blocked the picker is
  simply not shown. The server lists no accounts, so /auth/identify
  still answers from public realm configuration alone.
- A password sign-in is remembered when it succeeds. An OIDC sign-in
  leaves a pending marker in sessionStorage before the browser leaves,
  and the first /auth/me after the round trip turns it into an entry;
  coming back to the login screen drops it.
- A tile continues at the password step (identifier-first: once
  /auth/identify confirms the identifier still signs in with a
  password; combined: the form with the username filled in), or at the
  IdP of an OIDC realm the login options still list. An OIDC account
  whose realm is gone is routed by /auth/identify, so it cannot loop.
- GET /auth/oidc/login takes an optional login_hint, trimmed, at most
  256 bytes and without control characters (anything else is dropped,
  logged at debug level with its size only), and passes it to the
  authorize request. Tiles and the identifier-first form send it, so
  the IdP does not ask for the account again.
- remember_accounts, a new auth setting (default true), turns the
  picker off for kiosks and shared computers: GET /auth/login-options
  reports it, and with it off the screen keeps none and forgets those
  kept before. It is an auth setting rather than a forge.toml key, like
  login_mode, with its checkbox under Admin -> Settings -> Sign-in.
- /auth/me reports the IdP's display name for an OIDC session; the
  callback now links the session to the identity that signed in.

Fixes #115

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012e6DJbsBcmhipReXByB1cF
Comment thread web/src/lib/accounts.ts Fixed
… as a password

CodeQL's js/clear-text-storage-of-sensitive-data took
passwordFallbackOptions for a password by its name and followed it
through options.oidc[].name into the pending OIDC sign-in that
beginOidcSignIn keeps in sessionStorage. What is stored there is a realm
name, which /auth/login-options publishes; the constant is the login
options while those cannot be read, and is now named so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012e6DJbsBcmhipReXByB1cF
@schmitch
schmitch enabled auto-merge October 28, 2026 21:51
@schmitch
schmitch added this pull request to the merge queue Oct 28, 2026
Merged via the queue into main with commit 3653519 Oct 28, 2026
19 checks passed
@schmitch
schmitch deleted the claude/remembered-accounts-115 branch October 28, 2026 22:04
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.

Login: remember accounts on the device and show an account picker (like Microsoft's "Pick an account")

3 participants