fix(login): make passkey sign-in reachable when password is disabled - #107
Merged
Conversation
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
Contributor
🧪 Test Summary
|
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.
Contributor
🧪 Test Summary
|
…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.
Contributor
🧪 Test Summary
|
yahyafakhroji
enabled auto-merge
July 30, 2026 15:15
gaghan430
approved these changes
Jul 31, 2026
2 tasks
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
15 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The
/loginidentifier field was gated onallowPassword. 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
allowPassword || passkey allowed || email-linkinstead ofallowPasswordalone.showContinue: hideContinuewhen nothing sits behind it, so it can't dead-end onNO_SUPPORTED_METHOD./login/password(loader + action) when policy disables password — Zitadel's Session API doesn't enforceLoginPolicy, so the route was reachable by direct URL.resolveIdentifierField— a username-only org showed an "Email" button opening a "Username" field.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.