Skip to content

fix(auth): clear error when adding a passkey on a device that already has one - #1014

Merged
2witstudios merged 3 commits into
masterfrom
pu/passkey-register-existing-credential-ux
Apr 14, 2026
Merged

2witstudios merged 3 commits into
masterfrom
pu/passkey-register-existing-credential-ux

Conversation

@2witstudios

Copy link
Copy Markdown
Owner

Summary

A desktop user on desktop-v1.0.22 (shipped via #1012) reported:

"it doesn't work when I have one already"

They have a Touch ID passkey registered on their Mac and tried to add another from Settings → Security. The external register page showed an unhelpful error like "The operation either timed out or was not allowed" with no actionable context, leading them to conclude the feature was broken.

Root cause is WebAuthn behaving correctly: the server in packages/lib/src/auth/passkey-service.ts populates excludeCredentials with the user's existing credential IDs (the spec-recommended pattern), Touch ID sees its own credential in the exclude list, and refuses with InvalidStateError. The existing web flow (PasskeyManager.tsx line 195) maps this to a friendly toast — but the NEW external register ceremony shipped in #1012 forgot to copy that branch and bubbled the raw message.

This PR fixes the messaging, not the WebAuthn rule. To actually register a second passkey, users still need a different authenticator (another Mac, an iPhone, a hardware security key, or iCloud Keychain sync). The message now spells that out.

What changed

  • runPasskeyRegisterExternalCeremony now returns { ok: false, error, code? } where code is 'ALREADY_REGISTERED' for InvalidStateError and 'CANCELLED' for NotAllowedError. Generic errors surface err.message with no code, matching prior behaviour.
  • passkey-register-external/page.tsx extracts a pure PasskeyRegisterExternalView that branches on code to render distinct headers with softer icons:
    • ALREADY_REGISTERED → "This device is already set up" + CheckCircle2 (muted)
    • CANCELLED → "Registration cancelled" + Info (muted)
    • everything else → "Registration failed" + ShieldAlert (destructive)

NOT touched

  • packages/lib/src/auth/passkey-service.ts — server excludeCredentials is correct and stays.
  • apps/web/src/app/api/auth/passkey/register/options/route.ts, apps/web/src/app/api/auth/passkey/register/route.ts — untouched; the parallel rate-limit hot fix (pu/passkey-register-ratelimit-fix) owns these.
  • runPasskeyExternalCeremony.ts (sign-in, not register) — .get does not throw InvalidStateError.
  • PasskeyManager.tsx — its web-path InvalidStateError toast already works.

Parallel hot fix

This PR is one of two independent hot fixes flowing from #1012. The rate-limit fix is being handled in a separate worktree / PR.

Commits

  • RED 4dfe5dbd — test(auth): RED passkey register external existing-credential UX
  • GREEN 987bec64 — fix(auth): map InvalidStateError to clear already-registered UX on register-external

Test plan

Automated (all green locally):

  • pnpm --filter web typecheck
  • pnpm --filter web lint (no new warnings)
  • pnpm --filter web exec vitest run src/components/auth/__tests__/runPasskeyRegisterExternalCeremony.test.ts — 7/7 passing (adds InvalidStateError, CANCELLED-code, and generic-error cases)
  • pnpm --filter web exec vitest run src/app/auth/__tests__/passkey-register-external-page.test.tsx — 5/5 passing (asserts each status variant renders its own header)

Manual smoke test (to run against desktop-v1.0.22+ once shipped):

  • Sign in as a user who already has one Touch ID passkey registered.
  • Settings → Security → Add Passkey.
  • System browser opens, Touch ID prompt appears. Approve it.
  • macOS refuses (credential already exists on this authenticator).
  • Expected: external page shows "This device is already set up" with "A passkey is already registered on this device. To add another, use a different device, a security key, or iCloud Keychain from another Mac or iPhone." — NOT a generic "Registration failed" header.
  • Cancel Touch ID in a separate run. Expected: page shows "Registration cancelled" — distinct from the "already set up" case, proves code discrimination works.

🤖 Generated with Claude Code

2witstudios and others added 2 commits April 14, 2026 14:19
Extend runPasskeyRegisterExternalCeremony tests to assert a new
`code` discriminator on the { ok: false } branch — 'ALREADY_REGISTERED'
for InvalidStateError and 'CANCELLED' for NotAllowedError — and add a
page-level test for a PasskeyRegisterExternalView that renders distinct
headers per code ("This device is already set up" / "Registration
cancelled" / "Registration failed"). Both fail against the current code.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…gister-external

The external passkey register ceremony shipped in #1012 bubbled WebAuthn's
InvalidStateError as a raw message, so users trying to add a second passkey
on an authenticator they already have one registered with saw gibberish
like "The operation either timed out or was not allowed" with no
actionable context. The server correctly populates excludeCredentials
(that is spec-recommended behaviour); the bug was purely the client-side
error mapping.

- runPasskeyRegisterExternalCeremony now returns a structured
  { ok: false, error, code } shape, emitting code='ALREADY_REGISTERED'
  with a friendly message for InvalidStateError and code='CANCELLED'
  for NotAllowedError. Generic errors still surface err.message with no
  code, matching prior behaviour.
- passkey-register-external/page.tsx now exports a pure
  PasskeyRegisterExternalView that branches on the error code to render
  distinct headers ("This device is already set up" / "Registration
  cancelled" / "Registration failed") with softer icons for the first
  two cases. The page shell still owns the ceremony effect and just
  passes the status down.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Apr 14, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
pagespace-master-plan Ready Ready Preview, Comment Apr 14, 2026 7:37pm

@coderabbitai

coderabbitai Bot commented Apr 14, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@2witstudios has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 29 minutes and 46 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 29 minutes and 46 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 33eab811-83cf-4388-a7ad-06319f95e939

📥 Commits

Reviewing files that changed from the base of the PR and between f00547a and 689d5af.

📒 Files selected for processing (5)
  • apps/web/src/app/auth/__tests__/passkey-register-external-page.test.tsx
  • apps/web/src/app/auth/passkey-register-external/page.tsx
  • apps/web/src/components/auth/PasskeyRegisterExternalView.tsx
  • apps/web/src/components/auth/__tests__/runPasskeyRegisterExternalCeremony.test.ts
  • apps/web/src/components/auth/runPasskeyRegisterExternalCeremony.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pu/passkey-register-existing-credential-ux

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 987bec6405

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/web/src/app/auth/passkey-register-external/page.tsx Outdated
….js build

Next.js 15 page.tsx files only allow default + specific metadata
exports. Exporting PasskeyRegisterExternalView and its Status type from
the page broke production build with 'not a valid Page export field'.

Extract the pure view component and its Status type into
apps/web/src/components/auth/PasskeyRegisterExternalView.tsx. The page
now only exports the default and imports the view from its new home.
Tests updated to import from the component module.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@2witstudios
2witstudios merged commit b28af9c into master Apr 14, 2026
5 checks passed
2witstudios added a commit that referenced this pull request Apr 14, 2026
Resolves conflict in apps/web/src/app/auth/passkey-register-external/page.tsx
by combining both branches:

- Keeps master's PasskeyRegisterExternalView extraction with the
  ALREADY_REGISTERED / CANCELLED error-code rendering paths from
  #1013 / #1014.
- Keeps this branch's `complete` state machine, post-deep-link
  settle timer, best-effort window.close(), and HandoffCompleteCard
  terminal-state rendering.

PasskeyRegisterExternalView is extended with a `complete` kind that
renders <HandoffCompleteCard variant="passkey-added" />, so the page
component can rely on the View for all rendering while still owning
the timer-driven state transitions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@2witstudios
2witstudios deleted the pu/passkey-register-existing-credential-ux branch April 14, 2026 21:23

This branch was previously deployed

1 inactive deployment
Preview — 689d5af6 Deployed Apr 14, 2026 by vercel[bot]
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.

1 participant