fix(login): cascade passkey arming so a declined hint falls through to discovery - #113
Merged
Merged
Conversation
Contributor
🧪 Test Summary
|
…o discovery The /login passkey button silently fell through to the email field whenever a session was already live; ?add=1 reproduced it every time, because that param always implies a live session. The loader's two passkey arms were written as alternatives (`if (hint) … else if (!hint) …`), so any decline in the hint-bound arm — deleted user, no passkey, a provider hiccup during session creation — dead-ended on the email field instead of reaching identity discovery. Discovery was independently gated on there being zero live sessions, which removed it for the entire add-another-account population. Both arms now live in app/resources/webauthn/arm-login-passkey.ts and run in order: the hint-bound arm is tried first and every decline falls through to discovery. That is safe to do unconditionally because mintIdentityChallenge is self-issued — no provider round-trip, nothing persisted. Zitadel enters only at /login/passkey-discover, after a credential has actually been tapped: the loader decides what to offer, the action enforces what is allowed. Arming discovery on the add-account screen makes a new state reachable — tapping the passkey of an account this browser already holds. That returns 409 ALREADY_SIGNED_IN, which previously surfaced as an opaque discovery failure. The client now routes to the accounts picker with requestId/organization threaded through, so the user lands on the account they just proved they hold. A malformed 409 body still surfaces error copy rather than dead-ending silently.
yahyafakhroji
force-pushed
the
fix/passkey-arming-cascade
branch
from
July 31, 2026 13:05
74f94cf to
a87d3d8
Compare
Contributor
🧪 Test Summary
|
mattdjenkinson
approved these changes
Jul 31, 2026
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.
The
/loginpasskey button silently fell through to the email field for anyonewho already had a session.
?add=1reproduced it every time, because that paramalways implies a live session.
The two arms were alternatives, not a cascade
The loader had two ways to arm a passkey ceremony, written as
if (hint) … else if (!hint) …. A hint that resolved to a deleted user, a user without a passkey,or a provider hiccup during session creation dead-ended on the email field — the
discovery arm was unreachable, because
hintwas still truthy. Discovery wasindependently gated on there being zero live sessions, which removed it for the
entire add-another-account population.
Both arms now live in
app/resources/webauthn/arm-login-passkey.tsand run inorder: the hint-bound arm is tried first, and every decline falls through to
identity discovery. That is safe unconditionally because
mintIdentityChallengeis self-issued — no provider round-trip, nothing persisted. Zitadel enters only
at
/login/passkey-discover, after a credential has actually been tapped. Theloader decides what to offer; the action enforces what is allowed.
Tapping a passkey you're already signed into
Arming discovery on the add-account screen makes that newly reachable.
/login/passkey-discoverreturns 409ALREADY_SIGNED_IN, which previouslysurfaced as an opaque discovery failure. The client now routes to the accounts
picker with
requestId/organizationthreaded through, so the user lands onthe account they just proved they hold. A malformed 409 body still surfaces error
copy rather than failing silently.
Deploy note
logAuthEvent('passkey_discover', 'failure', { reason: 'already_signed_in' })now fires routinely on the add-account screen by design, so the
passkey_discoverfailure rate will step-change at deploy. Staging currentlyshows 14 successes and zero failures, and no alerting rule keys on
auth_events_totaltoday.Test plan
typecheck,lint:ci,lint:boundariescleansession + hint, both
/id/login?add=1and/id/loginserializeconditionalPasskey: nulland an armedidentityDiscoverywith the same cookie state issues
POST /id/login/passkey-discoverand theserver answers 409
cypress/e2e/passkey-conditional.cy.ts— 2?add=1tests still fail. Causeis in the harness, not the product: under
?add=1the page issues twodocument GETs, and the reload discards the injected
__CYPRESS_HYDRATE__flag, so the page never hydrates and the button has no
onClickto fire.Note CI does not run this spec —
test:e2e:fastpinscore-signin.cy.ts./login?add=1→ Passkey → dialog opens instead ofdropping to the email field
lands on
/accountsFollow-ups, out of scope here
hasLiveSessionFor(sessions, loginName)— the predicateenforcing
armUserBoundChallenge's caller contract is duplicated across bothcallers and can drift.
hint, but the contract is stated in terms ofuser.loginName, andsso-callback.ts:295writes the hint fromidpUserName.Pre-existing; blast radius is bounded (can only force re-authentication).
?add=1double-load above, plus a call on e2e CI coverage: 27 specs existand exactly one is a merge gate.