Skip to content

feat: passkey management, login polish, and two-step enrollment (Phase A) - #102

Merged
yahyafakhroji merged 9 commits into
mainfrom
feat/passkey-phase-a
Jul 27, 2026
Merged

feat: passkey management, login polish, and two-step enrollment (Phase A)#102
yahyafakhroji merged 9 commits into
mainfrom
feat/passkey-phase-a

Conversation

@yahyafakhroji

@yahyafakhroji yahyafakhroji commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

What

Passkey support for the identity UI: sign-in with a passkey plus a full self-service management surface.

  • /id/passkeys management page: list, add, and remove passkeys, with a confirm dialog, designed empty state, and a one-time "Passkey added." notice
  • Two-step enrollment: the WebAuthn ceremony runs first, then a naming step pre-filled from the authenticator's AAGUID (editable, with honest helptext about password-manager labels)
  • Sensitive actions (add, remove, sign out other sessions) require a recent authentication factor; stale sessions bounce through the /id/reauth interstitial
  • Removal safety: the last remaining sign-in method cannot be removed, and single-method accounts get a backup-method banner
  • Post-removal "sign out other sessions" signs the user out on all devices (provider-side session search + delete), not just this browser's cookie
  • Each passkey shows its creation date, stored as Zitadel user metadata at enrollment; metadata is best-effort and can never fail enrollment, listing, or removal
  • Generic "Sign in with your passkey" wording at sign-in; browser-specific WebAuthn failure messages

Testing

Cypress component suite (fake provider) plus request-driven e2e. Manual verification steps against a real Zitadel live in acceptance/PASSKEY-TESTING.md.

@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

@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

@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) ❌ failure
Unit Tests (component) (2) ❌ failure

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

@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

@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

@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

@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

@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

@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

@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 force-pushed the feat/passkey-phase-a branch from f855224 to 362836e Compare July 24, 2026 23: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

@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

@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 25, 2026 01:09
@yahyafakhroji
yahyafakhroji force-pushed the feat/passkey-phase-a branch from 3e2b5b0 to 7876927 Compare July 27, 2026 05:12
@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

@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

@mattdjenkinson

Copy link
Copy Markdown
Collaborator

@yahyafakhroji couple things from LLM but looks good!

High

1. performReauth drops absolute default returnTo that loadReauth just set

Where: app/resources/reauth/reauth.service.ts (loadReauth vs performReauth)

loadReauth falls back to resolveDefaultReturnTo() (admin console / Zitadel defaultRedirectUri / DEFAULT_APP_URL) without running it through validateReturnTo, and puts that value in the form. On success, performReauth does validateReturnTo(input.returnTo) ?? paths.passkeys(). Absolute destinations not on POST_LOGOUT_ALLOWLIST are rejected → user lands on /passkeys instead of the configured post-login destination.

Breaks the documented “same as /signed-in” fallback. Component tests assert the loader’s returnTo but never the perform round-trip with that absolute URL.

Fix: Either validate the default when resolving it (and only use allowlisted absolutes), or have performReauth accept an already-trusted target (e.g. resolve default again server-side on success instead of trusting/re-validating the posted absolute URL inconsistently). Add a test: load with returnTo: null + configured default → perform → target equals that default when allowlisted / falls back predictably when not.

2. /login/method IdP rows still navigate to SSO management, not IdP sign-in

Where: app/routes/login/method.tsx; test: cypress/component/routes/login/method.cy.tsx

Each “Continue with {Google}” LinkButton uses href={paths.sso.index(query)} (/sso), the signed-in linked-accounts page. Correct pattern is already on /login: IdpButtonList POSTs intent=idp + idpId to start OAuth. Destination was already wrong on main; this PR’s logo/name fix makes the mis-navigation look intentional. Tests only assert the link exists, not the href.

Multi-method users choosing IdP from the method chooser do not start provider login; they hit account-linking UI mid-ceremony.

Fix: Per-IdP POST forms to /login (reuse IdpButtonList or the same hidden fields). Assert href/action + idpId in the component test.


Medium

3. Passkey enroll sudo path can still 500 on stale provider sessions

Where: app/resources/webauthn/webauthn-enroll.ts; app/resources/webauthn/webauthn.service.ts (verifyEnrollment sudo getSession)

Passkeys/reauth wrap getSession with isStaleSessionError and recover. Enroll loader/action sudo checks call getSession bare; Zitadel PERMISSION_DENIED on a revoked token becomes an unhandled error instead of reauth/login.

Same cross-browser stale-token case this PR fixed elsewhere; add/remove flows are inconsistent.

Fix: Reuse the isStaleSessionError pattern; map to redirect /login or SUDO_REQUIRED/reauth.

4. IdP reauth start does not verify the IdP is linked to the session user

Where: app/routes/reauth.tsx action intent=idp-reauth

Server checks active + non-LDAP only, not listIdpLinks. Chooser only shows linked IdPs; a crafted POST can start OAuth for any org IdP.

Relies entirely on Zitadel rejecting mismatched intents (FAILED_PRECONDITION). Defense-in-depth should match the chooser.

Fix: Require listIdpLinks(userId) to contain idpId (and optionally enrolled idp) before startReauthIdpIntent.

5. Last-method guard is check-then-act (TOCTOU)

Where: app/resources/passkeys/passkeys.service.ts (removeUserPasskey)

Two concurrent removes of different passkeys can both pass passkeys.length <= 1 && !other methods and remove the last method.

Edge case, but lockout-adjacent for passkey-only accounts.

Fix: Re-read methods after delete / use conditional delete / serialize removes per user if the API allows.


Low

  • Test gaps that would have caught the highs — method IdP href/action not asserted; no performReauth test with absolute default returnTo.
  • Reauth IdP error copyapp/routes/reauth/provider/error.tsx: button says “Back to sign in” but links to /reauth.
  • Fake provider fidelityremovePasskey clears inventory/enrolled but not seeded authMethods, so listAuthMethods can still report passkey with zero passkeys (test-only footgun).

@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

@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

@gaghan430

Copy link
Copy Markdown
Contributor

Nice work on this — the sudo model is coherent and the server-side enforcement points are in the right place. Another pass over the latest state; everything below is still open, and a few are defects that came in with the most recent fixes.

High

1. A null WebAuthn challenge deadlocks the whole chooser

Where: app/hooks/use-passkey-login-ceremony.ts:53, app/hooks/use-passkey-reauth-ceremony.ts:48

const consumedChallenge = useRef<unknown>(null);   // initial value: null
...
if (consumedChallenge.current === publicKeyCredentialRequestOptions) return;

requestWebAuthnChallenge (webauthn.service.ts:122-139) and loadReauth both return publicKeyCredentialRequestOptions: null when the challenge mint fails — documented as "non-fatal, the button surfaces an error on click."

With a null challenge, the ref's initial null matches, so runCeremony returns before setPhase('ceremony'). Phase stays 'loading-challenge' forever, and since passkeyBusy = phase !== 'idle' gates the rest of the screen:

  • /login/method — Passkey spins; Password, Email and every IdP row are disabled / pointer-events-none. The user can't sign in by any method until they reload.
  • /reauth — same, and this screen gates all of passkey management.
  • /login sole-passkey inline — auto-fire silently no-ops, then the manual button hits the same stuck path.

WebAuthnButton already handles this (if (!publicKey) { setError(...); return; }) — the hooks regressed it.

Fix: sentinel the ref so null isn't the "already consumed" value, and reject a null challenge explicitly:

const UNSET = Symbol('unset');
const consumedChallenge = useRef<unknown>(UNSET);
...
if (consumedChallenge.current === options) return;
consumedChallenge.current = options;
if (!options) { setPhase('idle'); setReason('unknown'); return; }   // renders WebAuthnReasonCopy

2. /reauth, /passkeys and /login/method have no rate limiting

Where: app/server/middleware/rate-limit.ts + app/server.ts — both untouched by this PR

Current matcher coverage: /id/login/password, /id/login/verify/, /id/login/passkey, /id/setup/, /id/signup, /id/signup/password, /id/sso/ldap, /id/accounts, /id/password/reset, /id/verify.

POST /id/reauth accepts factor=password and factor=otp_email unthrottled — a credential-guessing endpoint sitting beside ten throttled siblings. Zitadel lockout partly covers the password case; the emailed OTP code has no equivalent backstop. POST /id/passkeys (signout-others is destructive and cross-device) and the new POST /id/login/method are uncovered too.

Fix: two new limiters in the existing style, mounted on '*' in server.ts like the rest:

match: (c, pathname) => c.req.method === 'POST' && pathname === '/id/reauth',
match: (c, pathname) => c.req.method === 'POST' && pathname === '/id/passkeys',

3. performReauth still 500s on a stale session

Where: app/resources/reauth/reauth.service.ts:257-275

54da085 swept the two bare getSession calls in enrollment, but the updateSession catch here is a third site with the same problem:

session = await provider.updateSession(entry.id, entry.token, checks);
} catch (err) {
  if (err instanceof ProviderError && (INVALID_CREDENTIALS || idp FAILED_PRECONDITION)) return ...;
  throw err;   // PERMISSION_DENIED from a stale cross-browser token lands here
}

ReauthPerformResult already has a SESSION_EXPIRED variant, currently only returned when the cookie has no entry at all. This also hits /reauth/:provider/callback, so a stale token there produces an unhandled 500 mid-OAuth-return.

Fix: add if (isStaleSessionError(err)) return { ok: false, error: 'SESSION_EXPIRED' }; before the rethrow.


Medium

4. Post-reauth default destination uses the rotated-away token

Where: app/resources/reauth/reauth.service.ts:294 — introduced by b2fd0c6

session = await provider.updateSession(...)                    // Zitadel MAY rotate the token here
const next = addSession(sessions, { ...entry, token: session.token })
...
(await resolveDefaultReturnTo(provider, entry, {...}))         // entry still holds the OLD token

resolveDefaultReturnTo calls isInstanceAdmin({ id: entry.id, token: entry.token }), which .catch(() => false)s. So whenever rotation happens an instance admin reauthenticating without an explicit returnTo silently lands on the non-admin default — re-breaking the "same as /signed-in" fallback this fix was written to restore.

Fix: resolveDefaultReturnTo(provider, { ...entry, token: session.token }, {...}).

5. Last-method guard counts inactive passkeys

Where: app/resources/passkeys/passkeys.service.ts:164

if (passkeys.length <= 1 && !methods.some((m) => m !== 'passkey')) { ...LAST_METHOD }

listPasskeys returns both states (adapter maps AuthFactorState.READY → 'active', everything else → 'inactive', and the UI renders an "Inactive" badge — so non-READY rows do occur). A passkey-only account with 1 active + 1 inactive passes the guard (length === 2), removes the active one, and is locked out. The mutex in 58f0500 closed the concurrency window; the arithmetic inside the lock is still wrong.

Fix: const usable = passkeys.filter((p) => p.state === 'active'); and guard on usable.length <= 1.

6. POST /id/login/method is an unauthenticated linked-IdP oracle

Where: app/routes/login/method.tsx action — introduced by bdb5e1b

loginName comes from the URL, there's no session check, and the responses are distinguishable: redirect to /login (unknown user) vs 400 INVALID_INPUT (known user, that IdP not linked) vs 302 to the provider (known user, linked). Combined with #2 that's an unthrottled probe for which specific IdP a given address has linked.

The loader already leaked available method types pre-PR, so the shape isn't new — but per-provider identity is a step further, and this codebase takes enumeration seriously enough to ship ignoreUnknownUsernames.

Fix: collapse the unlinked and unknown-user branches into one identical response.

7. A failed verify orphans a real credential

Where: app/routes/setup/passkey.tsx:66-69

useEffect(() => { if (actionData?.error) setHeld(null); }, [actionData]);

By the time step 2 exists, navigator.credentials.create() has already run — the credential is in the user's iCloud Keychain / 1Password. Dropping to step 1 makes the retry create a second one, orphaning the first. The step-2 comment reasons about exactly this ("there is no cancel — it would orphan the entry in the user's password manager") and then the error path does it anyway.

Fix: keep the held credential and re-submit it against the fresh passkeyId from revalidation, or tell the user a duplicate is about to be created.


Low

  • Hook duplicationuse-passkey-login-ceremony.ts and use-passkey-reauth-ceremony.ts are ~90% identical (unwrapPublicKey verbatim, Cypress gate verbatim, same phase machine, same two effects). They differ only in challenge path, submit path, extra fields, and where the options sit in the payload. One usePasskeyCeremony({ challengePath, submitPath, selectOptions, extraFields }) — and Eenhance Documentation, Docker, and CI for UI Development #1 gets fixed once instead of twice.
  • Untranslated stringspasskeys.tsx:125-126: aria-label={\Remove ${row.name}`}/title=are raw templates in a file where everything else is; confirmed absent from locales/en.po. Use ``tRemove ${row.name}` ``.
  • Dead payloadpasskeys.service.ts:100 returns userId in the view; the component never reads it. Leaks the Zitadel user id into the page HTML for nothing.
  • returnTo lost on addpasskeys.tsx:293 hardcodes returnTo: paths.passkeys(), discarding the external returnTo that produced the "Back" button, so a portal round-trip loses its way home after adding a key.
  • Hydration mismatch risk<Trans>Added {i18n.date(new Date(row.createdAt), { dateStyle: 'medium' })}</Trans> formats with the server's TZ on SSR and the browser's on hydration; dateStyle: 'medium' is date-only, so a TZ delta shifts the day. @epic-web/client-hints is already a dep.
  • Back-link trade-off — routing /login/verify/* to /login fixes the sole-factor loop but makes Back worse for genuine multi-factor users, who lose the picker they came from. Probably still the right call; worth a comment noting the trade, not just the win.
  • SignOutButton hardcodes action="/id/logout?index"APP_BASENAME exists in @/resources/shared/app-basename. The detailed comment explaining why ?index is required was dropped from signed-in.tsx during the extraction; only a one-liner survived.
  • startReauthIdpIntent doesn't thread login_hint though login.service.ts does. On reauth, picking the wrong Google account is more likely and fails with FAILED_PRECONDITION — cheap win.
  • reauth/provider/callback.tsx is a GET loader that verifies a factor, rotates the token and sets a cookie, with no state/nonce binding it to the request that started the round-trip; the identity-mismatch check is doing all the work. Same shape as /sso/:provider/callback so it's pre-existing — but reauth is a privilege-elevation step, which raises the stakes.
  • aaguid.ts is imported by a route component, so the CBOR reader + 2.9 KB catalog ship in the client bundle — worth a glance against the 300 KB budget. Minor: readArg for info 26 uses |, yielding a signed 32-bit int (harmless for attestation objects, wrong in principle).
  • Copy — in login/index.tsx's inline branch, "Not you?" doesn't switch identity, it just reveals the other sign-in methods for the same identity.

@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

…-device sign-out

Adds /id/passkeys (list, sudo-gated remove with a server-side last-method
guard, post-removal "sign out other sessions" offer), two-step passkey
enrollment (ceremony, then name the credential), created-at metadata on
enrolled passkeys, and true cross-device sign-out (deletes the user's other
provider-side sessions, not just the local cookie).
…gn-in ceremony

Introduces the shared IdentityBadge ("Logged in/Signing up as X — Not you?")
used consistently across login, signup, reauth, passkeys, and accounts;
consolidates Back-link and sign-out affordances that had drifted per-route;
and adds the in-place passkey sign-in ceremony (challenge → assertion →
submit without navigating away) reused by /login and /login/method.
…-session recovery

Adds the in-place passkey ceremony to /reauth (mirrors the login-side one)
with busy/disabled states across the chooser while it runs, and an
active-identity badge with a Back-link fix. Also recovers from stale
cross-browser session tokens (getSession/findUser throwing a non-transient
ProviderError instead of returning null) by bouncing to /login instead of
crashing, and standardizes "Not you?" wording across surfaces.
Adds a fourth reauth factor: re-verifying the active session via one of the
user's linked (non-LDAP) external IdPs, mirroring the login-side IdP flow
but pointed at dedicated /reauth/:provider/callback|error routes so it can
never create a session or sign in as a different identity — it only
verifies the idpIntent onto the EXISTING session.
/login/method rendered a generic UserCircle icon for every linked IdP
instead of the provider's real logo. Adds the missing google.dark.png IdP
icon asset and wires the method chooser to the same IdpIcon/logoUrl
resolution the rest of the app already uses.
…6 areas

- reauth: never trust a client-echoed absolute returnTo; re-resolve the
  configured default (admin console / Zitadel default / env default)
  server-side instead of silently collapsing to a hardcoded /passkeys.
- login/method: the IdP entry now POSTs through an action (CSRF, server-side
  linked-provider re-check) and starts sign-in in place, instead of
  navigating straight to /sso with no verification.
- webauthn: recover from stale cross-browser session tokens during
  enrollment (loader pre-check) the same way other surfaces do.
- reauth: the idp-reauth action verifies the target IdP is actually linked
  to the current user before starting the OAuth round-trip.
- passkeys: serialize concurrent removals (per-user in-process mutex) to
  close a TOCTOU race in the last-method guard — two simultaneous removes
  could otherwise both read "still have a backup" and both succeed.
- reauth/fake-provider: corrected error copy and removePasskey fidelity
  (static seed authMethods now cleared consistently with the dynamic set).
- reauth: the idp-reauth action also re-resolves the configured default
  returnTo (same fix as the main performReauth path) instead of hardcoding
  /passkeys when the caller's returnTo doesn't validate.
- passkeys: the last-method guard now gates by ACTUAL usable policy
  (passkeysType / allowExternalIdp / allowPassword / emailDeliveryEnabled),
  not just raw enrollment — an enrolled-but-policy-disabled method is not a
  real backup.
- auth: isStaleSessionError flipped from a fail-open denylist (assume stale
  unless the code is on a known-transient allowlist) to a fail-closed
  allowlist (assume the error propagates unless the code is a confirmed-stale
  one) — safer default when a new ProviderErrorCode is added later.
- Both passkey ceremony hooks (login + reauth) used `null` as the ref
  sentinel for "already consumed this challenge", but a challenge-mint
  failure also legitimately returns null — on first render this made the
  ref's initial value match, so the hook returned before setPhase('ceremony')
  ever ran and stayed stuck on 'loading-challenge' forever, disabling every
  other sign-in method on /login/method and /reauth. Fixed with a Symbol
  sentinel and an explicit null-challenge branch.
- /id/reauth, /id/passkeys, and /id/login/method had no rate limiting despite
  sitting beside ten throttled siblings — added three ip-keyed limiters.
- performReauth's updateSession catch rethrew a stale cross-browser session
  token uncaught (500), including mid-OAuth at /reauth/:provider/callback;
  now classified via isStaleSessionError → SESSION_EXPIRED. Its returnTo
  fallback also used the PRE-rotation session token for the isInstanceAdmin
  check, silently misclassifying admins after a token rotation — now uses
  the just-rotated token. Also threads a best-effort login_hint into the
  reauth IdP round-trip, matching the login-side behavior.
- passkeys: the last-method guard counted raw passkey rows including
  inactive ones, so a passkey-only account with 1 active + 1 inactive passkey
  slipped past the <= 1 check and could be locked out. Now filters to
  state === 'active' first.
- login/method: collapsed the unknown-user and idp-not-linked action
  responses into an identical one — the action is unauthenticated and the
  two previously-distinguishable outcomes made it a linked-IdP identity
  oracle.
- setup/passkey: a failed step-2 (Save) submit silently reset to step 1,
  but the WebAuthn credential was already created in step 1 — retrying
  silently created a second, orphaned credential. Now shows a persistent
  notice instead of silently duplicating.
- Misc: translated the Remove-passkey aria-label/title, dropped the unused
  userId field from the passkeys view payload, threaded the page's own
  external returnTo through the Add-passkey round-trip, SignOutButton now
  uses APP_BASENAME instead of a hardcoded literal, and aaguid.ts's CBOR
  length reader now forces an unsigned 32-bit result.
@yahyafakhroji
yahyafakhroji force-pushed the feat/passkey-phase-a branch from 4d59be7 to 721e71c Compare July 27, 2026 12:32
@yahyafakhroji

Copy link
Copy Markdown
Collaborator Author

@mattdjenkinson @gaghan430 — this is ready for another pass.

Since your last reviews:

  • All findings from both of your reviews have been addressed and pushed (rate limiting on /id/reauth, /id/passkeys, /id/login/method; the null-challenge deadlock in the passkey ceremony hooks; performReauth's stale-session handling and rotated-token returnTo resolution; the last-method guard now counts only active passkeys; /login/method's unknown-user/unlinked-idp responses are collapsed; the orphaned-credential warning on a failed passkey enrollment retry; plus the smaller items from each round).
  • The branch history has been squashed from 26 commits down to 9, grouped by feature area and by review round, to make this easier to re-review.

Let me know if anything still looks off or if you'd like any of the previously-deferred lower-priority items (e.g. merging the two passkey-ceremony hooks, the hydration/client-hints date formatting) picked up as follow-ups.

@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

@gaghan430 gaghan430 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

lgtm!

@yahyafakhroji
yahyafakhroji merged commit 1c20b11 into main Jul 27, 2026
18 checks passed
@yahyafakhroji
yahyafakhroji deleted the feat/passkey-phase-a branch July 27, 2026 12:42
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.

3 participants