Skip to content

feat(login): usernameless passkey sign-in - #108

Merged
yahyafakhroji merged 4 commits into
mainfrom
feat/usernameless-passkey-login
Jul 31, 2026
Merged

feat(login): usernameless passkey sign-in#108
yahyafakhroji merged 4 commits into
mainfrom
feat/usernameless-passkey-login

Conversation

@yahyafakhroji

@yahyafakhroji yahyafakhroji commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

What

Passkey sign-in without typing an email. Zitadel only issues WebAuthn challenges for an already-identified user, so both entry points resolve identity first, then run the standard ceremony:

  • Returning browsers — a signed passkey-hint cookie pre-arms a conditional ceremony: one tap, zero typing.
  • Fresh browsers — the Passkey button runs a modal discovery ceremony; the assertion's userHandle resolves the user via the new POST /login/passkey-discover, then the normal /login/passkey verify completes sign-in.

Notes

  • Discover failures are one opaque 400 (enumeration parity); real reasons go to the passkey_discover audit event.
  • AUTH_PASSKEY_DISCOVERY_ENABLED kill switch (default ON).
  • No auto-prompt on page load — discovery is button-initiated only.
  • Sole-passkey identifiers keep redirecting to /login/passkey (supersedes fix(login): make passkey sign-in reachable when password is disabled #107's inline ceremony; its view logic survives).
  • Chooser buttons rebranded to short labels (Email).

Test plan

  • 739/739 component specs; e2e journeys green on a cold server; manual staging pass
  • Staging validation after merge, then flip production passwordless (infra PR 3732)

@yahyafakhroji yahyafakhroji left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed

@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 added a commit that referenced this pull request Jul 31, 2026
… hardening

MEDIUM:
- passkey-discover: reorder getUser → live-session guard → listAuthMethods
  (the local cookie guard now trips BEFORE the second provider round-trip)
- passkey_discover audit events: success (hashed actor) + per-reason failures
  (disabled/invalid_input/no_user_handle/unresolved_user/live_session/
  no_passkey_method/mint_failed/challenge_failed) — enumeration parity stays
  caller-facing only
- AUTH_PASSKEY_DISCOVERY_ENABLED kill switch (env, default ON; 'false'/'0'
  disables) gating the loader arm + the discover action; documented in
  .env.example

LOW:
- PasskeyDiscoverData moved to a shared types-only module (route + hook)
- decodeUserHandle: strict base64url charset gate before Buffer's lenient decode
- identity-challenge: web-safe base64url (no Buffer) — portable if ever client-bundled
- use-conditional-passkey: capacity watermark comment (next mode ⇒ split)
- specs: kill-switch cases (action + loader), audit assertions, beginDiscovery
  single-flight (delayed-reply harness knob)
@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

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).
- 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
… hardening

MEDIUM:
- passkey-discover: reorder getUser → live-session guard → listAuthMethods
  (the local cookie guard now trips BEFORE the second provider round-trip)
- passkey_discover audit events: success (hashed actor) + per-reason failures
  (disabled/invalid_input/no_user_handle/unresolved_user/live_session/
  no_passkey_method/mint_failed/challenge_failed) — enumeration parity stays
  caller-facing only
- AUTH_PASSKEY_DISCOVERY_ENABLED kill switch (env, default ON; 'false'/'0'
  disables) gating the loader arm + the discover action; documented in
  .env.example

LOW:
- PasskeyDiscoverData moved to a shared types-only module (route + hook)
- decodeUserHandle: strict base64url charset gate before Buffer's lenient decode
- identity-challenge: web-safe base64url (no Buffer) — portable if ever client-bundled
- use-conditional-passkey: capacity watermark comment (next mode ⇒ split)
- specs: kill-switch cases (action + loader), audit assertions, beginDiscovery
  single-flight (delayed-reply harness knob)
@yahyafakhroji
yahyafakhroji force-pushed the feat/usernameless-passkey-login branch from 129385b to a7930f5 Compare July 31, 2026 04:05
- Comments across the branch now describe behavior in plain terms — internal
  process references (spec sections, decision numbers, task/phase tags,
  review pointers, upstream issue links) removed
- .claude/reviews/pr-108-review.md untracked (local review artifact,
  committed by accident)
@github-actions

Copy link
Copy Markdown
Contributor

🧪 Test Summary

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

View workflow run

@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 7bc44d8 into main Jul 31, 2026
18 checks passed
@yahyafakhroji
yahyafakhroji deleted the feat/usernameless-passkey-login branch July 31, 2026 04:49
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