Skip to content

fix: skip the empty account picker during an active login ceremony - #101

Merged
yahyafakhroji merged 5 commits into
mainfrom
fix/skip-empty-account-picker
Jul 21, 2026
Merged

fix: skip the empty account picker during an active login ceremony#101
yahyafakhroji merged 5 commits into
mainfrom
fix/skip-empty-account-picker

Conversation

@yahyafakhroji

Copy link
Copy Markdown
Collaborator

Closes #99

Problem

A first-time datumctl login opened the browser on "Choose an account" with an empty list — "No signed-in accounts" and a single "Add an account" link. Users had to click a picker with nothing in it before reaching the login form.

datumctl sends prompt=select_account unconditionally, and decideAuthorize honored it without consulting session state — the only branch that didn't. The SAML entry path was hardened against this exact failure mode already (authorize.service.ts:391-396); OIDC was never brought into line.

Fixed in auth-ui rather than the CLI: staff-portal and datum.net also send select_account, so a CLI-side fix would leave them exposed to the same dead end.

Approach

The deciding question is not "is the list empty" but "is there a ceremony to resume." An empty picker holding a requestId is a dead end. An empty picker without one is a valid answer to a valid question — "am I signed out?"

decideAuthorize now routes a session-less select_account to /login. The /accounts loader is the backstop for the eight entry points that bypass it — /accounts is unguarded and reachable by direct navigation, bookmark, and the legacy /ui/v2/login/accounts 301.

URL Accounts Result
?requestId=oidc_… empty /login?requestId=oidc_… (+ organization)
?authRequest=… / ?samlRequest=… empty /login?requestId=oidc_… / saml_…
?user_code=WXYZ empty /login?requestId=device_WXYZ
?user_code=<malformed> empty treated as absent → empty state
?requestId=evil_payload empty empty state (allowlist nulls it)
bare /accounts empty empty state — logout probe, bookmark, legacy URL
any non-empty unchanged

Scoping to ceremony-present rather than any-empty-list keeps the bare empty state intact. acceptance/logout.acceptance.cy.ts visits bare /accounts after logout and asserts it stays put — a security assertion proving a logged-out session isn't silently resurrected. It passes unmodified, as do the four pre-existing loader tests.

Note hasSessions counts raw cookie entries including expired ones, while the loader filters via listAccounts. The loader redirect is therefore load-bearing on the primary OIDC path too, for any user with a stale cookie — the decideAuthorize change saves a round trip and restores OIDC/SAML symmetry.

Post-login resume is unchanged and already worked: a fresh login carrying a requestId goes straight to /authorizerunCallback → the RP callback (login.service.ts:478-487).

Supporting changes

  • userCode was bounded in switchSchema/removeSchema but read raw in the loader. Fine when it fed a link a human clicks; it now feeds an automatic 302, so the bound is shared across all three consumers.
  • addAccountHref is now one exported function, so the component's link and the loader's redirect can't drift apart.
  • The loader uses normalizeRequestId, so a ceremony arriving in Zitadel's raw form is recognized. Previously /ui/v2/login/accounts?authRequest=abc reached the picker with an undetected ceremony and "Add an account" dropped it.

Testing

540 component tests pass; lint:ci, typecheck, typecheck:cypress, lint:cycles, lint:boundaries clean.

New coverage: decideAuthorize's select_account branch (previously untested entirely, despite three source comments warning about select_account loops); loader redirects for oidc_/raw/device_ ceremonies with organization preserved; malformed user_code rejection; bare-URL 200 as a regression guard; userCodeSchema boundaries.

prompt=select_account returned /accounts without consulting hasSessions,
so a first-time datumctl login landed on an empty account picker. Every
other decideAuthorize branch already considers session state; the SAML
entry path was hardened the same way in authorize.service.ts:391-396.

Adds the first tests for this branch — it had none, despite three source
comments warning about select_account loops.

Refs #99
switchSchema and removeSchema each carried an inline copy of the same
max(64) + charset bound. The /accounts loader is about to become a third
consumer, so hoist it to one source with boundary tests.

No behavior change.

Refs #99
The loader is about to need the exact target the component links to.
Sharing one function keeps the link and the redirect from drifting.

No behavior change.

Refs #99
An empty account picker holding a requestId is a dead end — nothing to
select, and the only control is "Add an account". Redirect straight to
the identifier screen, carrying the ceremony so login resumes the
OIDC/SAML/device callback.

Scoped to ceremony-present: a bare empty /accounts still renders the
empty state, which is the correct answer for a logout probe, a bookmark,
or the legacy /ui/v2/login/accounts 301.

Also validates user_code in the loader — it now feeds an automatic 302
rather than a link a human clicks.

Closes #99
…est form

The loader's ceremony test read only ?requestId= and ?user_code=, but a
ceremony legitimately arrives in Zitadel's raw shape too — normalizeRequestId
folds ?authRequest= into oidc_<id> and ?samlRequest= into saml_<id>.

The legacy /ui/v2/login/accounts 301 preserves the query verbatim and
'accounts' is in KNOWN_SEGMENTS, so /ui/v2/login/accounts?authRequest=abc
reached the picker with an undetected ceremony: it rendered the empty state
and "Add an account" resolved to a bare /login, dropping the ceremony.

Reuses normalizeRequestId rather than re-deriving the mapping, so the raw
shapes stay in sync with /authorize. A threaded requestId still wins.

Refs #99
@github-actions

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

@yahyafakhroji
yahyafakhroji merged commit bb5e3c5 into main Jul 21, 2026
33 checks passed
@yahyafakhroji
yahyafakhroji deleted the fix/skip-empty-account-picker branch July 21, 2026 15:13
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.

Improve datumctl login experience on first time

2 participants