Files
zitadel/apps
10a66b786b fix(login): honour login_hint and skip the auto-submit loop in OIDC flow (#12431)
<!--
Please inform yourself about the contribution guidelines on submitting a
PR here:

https://github.com/zitadel/zitadel/blob/main/CONTRIBUTING.md#submit-a-pull-request-pr.
Take note of how PR/commit titles should
be written and replace the template texts in the sections below. Don't
remove any of the sections. It is important that the
  commit history clearly shows what is changed and why.
Important: By submitting a contribution you agree to the terms from our
Licensing Policy as described here:

https://github.com/zitadel/zitadel/blob/main/LICENSING.md#community-contributions.
  -->

  # Which Problems Are Solved

An OIDC authorize request that carries a `login_hint` (and no `prompt`)
is meant to land
the user on a **prefilled** login screen and let them proceed straight
to authentication.
  Two problems prevented that:

**1. The hint was dropped when an unrelated session existed.** When the
browser held a
  `sessions` cookie for a *different* account:

- The user was redirected to the **account picker** (`/accounts`)
instead of the
    prefilled login-name screen.
- The picker listed the already-logged-in, unrelated account(s) — never
the user
    identified by `login_hint`.
- The `login_hint` was **silently dropped** (the `/accounts` URL carries
no hint), so the
    typed email was lost and the flow dead-ended on the wrong accounts.

In a clean session (no `sessions` cookie) the same request prefilled
correctly, which
  made the bug depend on unrelated pre-existing sessions.

**2. The prefilled path was jarring and looped on "back".** Even when
the hint *was*
honoured, the flow redirected to `/loginname?loginName=…&submit=true`.
That page renders
the username screen and then **client-side auto-submits** it, so the
user sees the
username flash on screen, watches "Continue" click itself, and only then
lands on the
password step. Worse, `/loginname?submit=true` stays in browser history,
so pressing
**Back** from the password screen re-triggers the auto-submit and
bounces the user
straight forward again — an inescapable loop with no way to edit the
username.

  # How the Problems Are Solved

**Honour the hint (problem 1).** In `handleOIDCFlowInitiation`, the
default branch (no
`prompt`) already calls `findValidSession`, which filters the browser's
sessions by
`login_hint` and treats the hint as *selecting the user*. When the hint
matches none of
the sessions it returns `undefined`, and the remaining
`eligibleSessions` (filtered by
organization only) are therefore never the hinted user. The fallback
previously chose
  between the loginname screen and the account picker based solely on
`eligibleSessions.length === 0`, so a present-but-unrelated session sent
the user to
`gotoAccounts`, discarding the email. The loginname branch is now also
gated on
`authRequest.loginHint`, mirroring the existing empty-eligible-sessions
branch.

**Skip the flash and the loop (problem 2).** Instead of bouncing through
`/loginname?submit=true` and relying on client-side auto-submit, the
hint is now resolved
**server-side** via `sendLoginname` (the same approach the
`Prompt.LOGIN` branch already
used) and the user is redirected **straight to the next step** (e.g.
`/password`),
skipping the `/loginname` screen entirely. This is factored into a
shared
`resolveLoginHint` helper and applied to all `login_hint` branches
(default-prompt,
no-session, and the existing `Prompt.LOGIN` path, now deduplicated).
When the hint can't
be resolved (unknown/ambiguous user, transient error), the flow falls
back to a prefilled
`/loginname` **without** `submit=true` — a clean screen where the user
clicks once — so
  there is no auto-submit and no back-button loop in any path.

  ```ts
  if (!selectedSession || !selectedSession.id) {
// login_hint matches no session: resolve it straight to the next step.
    if (authRequest.loginHint) {
const hintResponse = await resolveLoginHint({ request, requestId,
loginHint: authRequest.loginHint, organization });
      if (hintResponse) {
        return hintResponse;
      }
    }
// Prefill loginname (not the account picker) for an unresolved hint or
when no session is eligible.
    if (authRequest.loginHint || eligibleSessions.length === 0) {
return gotoLoginname({ request, requestId, loginHint:
authRequest.loginHint, organization, orgDomain });
    }
return gotoAccounts({ request, requestId, organization, orgDomain });
  }
  ```

  Resulting behaviour:

- `login_hint` set, a session **matches** it → silent auth
(**unchanged**).
- `login_hint` set, **no** session matches, hint **resolves** → straight
to the next step
(e.g. `/password`), no `/loginname` flash (**was**: account picker with
the hint
    dropped / a jarring auto-submit screen).
- `login_hint` set, hint **can't** be resolved → prefilled `/loginname`,
no auto-submit,
    Back stays editable.
- **No** `login_hint` → account picker / loginname exactly as before
(**unchanged**).

  # Additional Changes

- `resolveLoginHint` helper extracted in `flow-initiation.ts`; the
`Prompt.LOGIN` branch
    now reuses it instead of duplicating the `sendLoginname` call.
- `submit=true` is no longer emitted for the login-name flow, so the
client-side
auto-submit in `username-form.tsx` is no longer reached from OIDC
initiation.
- Tests in `flow-initiation.test.ts`: each `login_hint` branch
(org-scope-filtered,
unrelated-session, no-session) is covered twice — hint resolves →
straight to
`/password`; hint unresolved → prefilled `/loginname` with **no**
`submit=true`.

  # Additional Context

- The org-only `eligibleSessions` check was introduced in #12346, which
correctly handled
the *empty* case but did not account for a present-but-unmatched
`login_hint`; this PR
    extends that gate.
- Per OIDC, `login_hint` is advisory prefill; a client that wants the
chooser would send
`prompt=select_account`. Resolving the hint server-side also matches how
the
    `Prompt.LOGIN` branch already behaved.

---------

Co-authored-by: Max Peintner <[email protected]>
Co-authored-by: Max Peintner <[email protected]>
2026-07-29 12:06:36 +00:00
..