Skip to content

fix(login): make passkey sign-in reachable when password is disabled - #107

Merged
yahyafakhroji merged 3 commits into
mainfrom
fix/login-identifier-gate
Jul 31, 2026
Merged

fix(login): make passkey sign-in reachable when password is disabled#107
yahyafakhroji merged 3 commits into
mainfrom
fix/login-identifier-gate

Conversation

@yahyafakhroji

@yahyafakhroji yahyafakhroji commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Problem

The /login identifier field was gated on allowPassword. But passkey needs a known user too — usernameless is unsupported upstrea— so with password disabled there was no way to enter an identifier, and no error either.

Production already runs allowPassword: false, so enabling passkeys there would leave passkey sign-in unreachable: users would see only Google/GitHub.

Changes

  • Gate the identifier form on allowPassword || passkey allowed || email-link instead of allowPassword alone.
  • Add showContinue: hide Continue when nothing sits behind it, so it can't dead-end on NO_SUPPORTED_METHOD.
  • Guard /login/password (loader + action) when policy disables password — Zitadel's Session API doesn't enforce LoginPolicy, so the route was reachable by direct URL.
  • Label the reveal button from resolveIdentifierField — a username-only org showed an "Email" button opening a "Username" field.
  • Loading state on the passkey Remove / Sign-out-others dialog buttons.

No behaviour change in production or staging as currently configured.

Testing

686 component tests + e2e pass. Adds gate/unavailable coverage, passkey-only and email-link-only render cases, and the password policy guard.

Paired with datum-cloud/infra#3732 (draft), which enables passkeys in production.

The /login identifier field was gated on settings.allowPassword, but the
identifier is a prerequisite for nearly every method — password, passkey
(usernameless is unsupported upstream, zitadel/zitadel#8899), and email-link.
Only IdP buttons bypass it. The flag name `showPasswordForm` encoded the wrong
mental model and the markup inherited it.

Worst case was a silent dead end: on a passkey-only org (allowPassword=false,
passkeysType=allowed, no IdP) a visitor with no resolvable loginName got an
EMPTY card. The form was hidden, and signInUnavailable was suppressed by
showPasskeyPrompt — claiming a passkey path that cannot start without an
identifier. No sign-in path, and no error explaining why.

- login-view: showPasswordForm → showIdentifierForm, gated on
  allowPassword || passkeysType === 'allowed' || showEmailLink.
- login-view: new showContinue (allowPassword || passkey allowed). "Continue"
  hands off to decideAfterIdentifier, which returns NO_SUPPORTED_METHOD when
  neither exists — so on an email-link-only org the button is hidden rather
  than left to dead-end. Dropping it also makes email-link the first submit
  button, so Enter in the field still does the right thing.
- login-view: signInUnavailable drops from three terms to two —
  !showIdentifierForm && !showIdpButtons. Passkey no longer clears the state on
  its own. This reverses the 2026-07-06 assumption that email-link is not a
  sign-in path; its action mints the OTP session directly, without any enrolled
  method, so it is one.
- login/index: the reveal button hardcoded "Email" while the field label adapts
  via identifierLabel — a username-only org showed an "Email" button that opened
  a "Username" field. Now derived from the same resolveIdentifierField result.
- Tests: login-view gains an exhaustive gate/unavailable table incl. the
  empty-card regression; the route spec gains passkey-only and email-link-only
  render cases. The mount helper now threads settings through hydrationData as
  well as the loader — with hydrationData present the loaders do not run, so
  patching only the loader was a no-op.

Spec: docs/superpowers/specs/2026-07-29-login-identifier-gate-design.md
@github-actions

Copy link
Copy Markdown
Contributor

🧪 Test Summary

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

View workflow run

settings.allowPassword=false (Zitadel LoginPolicy.userLogin=false — production's
configuration today) kept password out of the post-identifier routing, but nothing
stopped a direct visit or a crafted POST. LoginPolicy governs Zitadel's own hosted
UI; auth-ui drives the headless Session API, whose password check does not consult
it — so /login/password still rendered a working form and still authenticated.

- password loader: redirect back to /login when !allowPassword, carrying requestId
  and organization through redirectToLogin.
- password action: return 403 PASSWORD_NOT_ALLOWED before verifyLoginPassword. The
  loader guard alone is not enough — a POST never runs the loader.
- test harness: register loginPasswordLoader and add allowPassword to
  mockLoginSettings so the policy can be driven from a scenario.
- passkeys: restyle the Back button (theme="link" type="quaternary").

Prerequisite for enabling passkeys in production, where password is policy-disabled
but the route was still reachable.
@github-actions

Copy link
Copy Markdown
Contributor

🧪 Test Summary

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

View workflow run

…e e2e labels

E2E: four /login specs still asserted the old hardcoded "Email" button and broke on
the relabel to "Continue with email" (core-signin, verify-otp, passkeys-manage,
login-hydrated-submit). The earlier verification ran `cypress run --component`,
which never loads cypress/e2e — hence CI caught it and the local run did not.
The /signup acceptance specs keep asserting "Email"; that button is unchanged.

Passkeys dialogs: both destructive submits were bare RRForm buttons with no pending
state, so clicking "Remove passkey" looked inert until the dialog abruptly swapped
to the sign-out-others offer. Both now show a spinner and disable their cancel:

- Remove: scoped to the row via navigation.formData.passkeyId — useNavigation is
  global and every row renders its own dialog, so an unscoped check would spin all
  of them at once.
- Sign out other sessions: same treatment, matched on intent.
@yahyafakhroji yahyafakhroji self-assigned this Jul 30, 2026
@yahyafakhroji yahyafakhroji changed the title fix(login): decouple identifier entry from allowPassword fix(login): make passkey sign-in reachable when password is disabled Jul 30, 2026
@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 enabled auto-merge July 30, 2026 15:15
@yahyafakhroji
yahyafakhroji merged commit 3f2b930 into main Jul 31, 2026
18 checks passed
@yahyafakhroji
yahyafakhroji deleted the fix/login-identifier-gate branch July 31, 2026 02:15
yahyafakhroji added a commit that referenced this pull request Jul 31, 2026
Works around the Zitadel constraint that a WebAuthn challenge requires an
already-identified user with two complementary entry points:

- Returning browsers: a signed passkey-hint cookie (loginName of the last
  authenticated user, written on every login success, cleared on logout)
  lets the /login loader mint a user-bound challenge and arm a conditional
  ceremony — one tap, zero typing.
- Fresh browsers: the Passkey button runs a modal discovery ceremony over a
  self-minted challenge; the assertion's userHandle (== Zitadel userId)
  resolves the user via POST /login/passkey-discover, which mints the real
  challenge for the standard /login/passkey verify. Every user-dependent
  discover failure is one opaque 400 (enumeration parity); the endpoint
  shares the webauthn verify rate limit.
- Ambient arming is hinted-only — no auto-prompt on fresh loads (password
  managers escalate conditional requests into full pickers); discovery is
  button-initiated, with failures surfaced through WebAuthnReasonCopy and
  the identifier field as fallback.
- Sole-passkey identifiers keep REDIRECTING to /login/passkey (Task-12
  product ruling) — supersedes the inline ceremony #107 shipped; its
  reachability + view logic (showIdentifierForm/showContinue) survive.
- Chooser buttons rebranded to short labels (Email / Phone / Username).
- v0.1.0 release prep (CONTRIBUTING/SECURITY, untrack .claude/settings).
yahyafakhroji added a commit that referenced this pull request Jul 31, 2026
- Hint lifecycle (write on every success path, clear on logout, add=1
  suppression), loader arming + suppression lists (hinted and discovery),
  discover action matrix (userHandle resolution, opaque-400 parity,
  crafted-POST live-session guard), hook modes incl. beginDiscovery,
  button binding, #107 view-logic grafts
- E2E: hinted one-tap return journey; fresh-browser button discovery with
  hint self-upgrade; harness passkeyDiscoverAction dispatch + JSON body
  capture
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.

2 participants