Skip to content

fix(login): cascade passkey arming so a declined hint falls through to discovery - #113

Merged
yahyafakhroji merged 1 commit into
mainfrom
fix/passkey-arming-cascade
Jul 31, 2026
Merged

fix(login): cascade passkey arming so a declined hint falls through to discovery#113
yahyafakhroji merged 1 commit into
mainfrom
fix/passkey-arming-cascade

Conversation

@yahyafakhroji

@yahyafakhroji yahyafakhroji commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

The /login passkey button silently fell through to the email field for anyone
who already had a session. ?add=1 reproduced it every time, because that param
always 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 hint was still truthy. 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
identity discovery. That is safe 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.

Tapping a passkey you're already signed into

Arming discovery on the add-account screen makes that newly reachable.
/login/passkey-discover 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 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_discover failure rate will step-change at deploy. Staging currently
shows 14 successes and zero failures, and no alerting rule keys on
auth_events_total today.

Test plan

  • Cypress component suite 750/750; typecheck, lint:ci, lint:boundaries clean
  • Loader cascade verified against the running production build: with a live
    session + hint, both /id/login?add=1 and /id/login serialize
    conditionalPasskey: null and an armed identityDiscovery
  • The 409 path verified running, not by code trace: in Chrome, a control probe
    with the same cookie state issues POST /id/login/passkey-discover and the
    server answers 409
  • cypress/e2e/passkey-conditional.cy.ts — 2 ?add=1 tests still fail. Cause
    is in the harness, not the product: under ?add=1 the page issues two
    document GETs, and the reload discards the injected __CYPRESS_HYDRATE__
    flag, so the page never hydrates and the button has no onClick to fire.
    Note CI does not run this spec — test:e2e:fast pins core-signin.cy.ts.
  • Staging: signed in → /login?add=1 → Passkey → dialog opens instead of
    dropping to the email field
  • Staging: on that screen, tap the passkey of the already-signed-in account →
    lands on /accounts

Follow-ups, out of scope here

  • Extract a shared hasLiveSessionFor(sessions, loginName) — the predicate
    enforcing armUserBoundChallenge's caller contract is duplicated across both
    callers and can drift.
  • The hint arm keys that check on hint, but the contract is stated in terms of
    user.loginName, and sso-callback.ts:295 writes the hint from idpUserName.
    Pre-existing; blast radius is bounded (can only force re-authentication).
  • The ?add=1 double-load above, plus a call on e2e CI coverage: 27 specs exist
    and exactly one is a merge gate.

@yahyafakhroji yahyafakhroji added the bug Something isn't working label Jul 31, 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

…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
yahyafakhroji force-pushed the fix/passkey-arming-cascade branch from 74f94cf to a87d3d8 Compare July 31, 2026 13:05
@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 628653d into main Jul 31, 2026
18 checks passed
@yahyafakhroji
yahyafakhroji deleted the fix/passkey-arming-cascade branch July 31, 2026 13:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants