Skip to content

feat(passkey): expose the policy code behind a refused passkey registration - #133

Merged
Bccorb merged 1 commit into
mainfrom
fix/passkey-policy-error-code
Aug 30, 2026
Merged

feat(passkey): expose the policy code behind a refused passkey registration#133
Bccorb merged 1 commit into
mainfrom
fix/passkey-policy-error-code

Conversation

@Bccorb

@Bccorb Bccorb commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Closes #132.

The problem

POST /webAuthn/register/finish can refuse a credential that passed
verification, on the deployment's authenticator policy, answering 403 with a
body whose error is a machine code rather than a sentence:

Code When
synced_passkey_not_allowed authenticator_policy.syncedPasskeys is block and the credential is backup eligible
authenticator_not_allowed the AAGUID is on aaguidDenyList, or absent from a non-empty aaguidAllowList
prf_required registration required PRF and the credential did not report support (pre-existing, same endpoint)

The adapter forwards that body verbatim (readPassthroughFailure returns
errorBody), and extractMessage in src/client/errors.ts reads { error: string } straight into the message. So registerPasskey() resolves with
error.message === 'synced_passkey_not_allowed' and an app rendering the
message puts that code in front of a user, with nothing to branch on but the
literal.

This is the default path, not an edge case: syncedPasskeys defaults to
block, and every iCloud Keychain or Google Password Manager passkey is backup
eligible.

The change

getPasskeyPolicyErrorCode(error) returns
'synced_passkey_not_allowed' | 'authenticator_not_allowed' | 'prf_required' | undefined, following the existing getOAuthErrorCode() shape:

  • an unrecognized value returns undefined, so a refusal from a newer API keeps
    the caller's generic messaging
  • a code nested under details is found too, for a proxy that derives its own
    human-readable top-level error and keeps the upstream body underneath. An
    unrecognized top-level value falls through to the nested one rather than
    ending the lookup, which is the difference from getOAuthErrorCode(): there
    the code lives under code, here it is the whole of error, so the top level
    is occupied by the derived string in exactly the reshaped case
  • exported from src/index.ts alongside the PasskeyPolicyErrorCode type

README documents the codes, the syncedPasskeys default, and that only the API
can relax it. AGENTS.md records the same under the backend endpoint notes.

Decision worth flagging

@seamless-auth/types publishes no union for these codes (checked against
0.15.0, the version the API itself is on; this repo is on 0.5.0). The union only
exists as AuthenticatorRefusal inside the API's authenticatorPolicyService,
and prf_required is a bare literal in its controller. So PasskeyPolicyErrorCode
is defined locally, and the Record<Code, true> check keeps the list and the
union in step with each other but cannot catch upstream drift the way the OAuth
one does. Drift degrades to generic messaging rather than breaking. The
alternative, adding the union to @seamless-auth/types, needs a version bump
and a coordinated release across the API and both SDKs, so it is not done here.
A note in AGENTS.md says to switch to the published union if one appears.

Checks

  • npm run typecheck: clean
  • npm run lint: clean
  • npm run format:check: all matched files use Prettier code style
  • npm test -- --runInBand: 32 suites, 303 tests passed. src/client/errors.ts
    at 100% statements and lines
  • npm run build: succeeded, and dist/index.d.ts carries both new exports

New tests in tests/errors.test.ts cover each known code, a code nested under
details, an unknown code at both levels, a generic verification failure, a
missing or non-object body and details, and a non-SeamlessAuthError input.

…ration

The auth API can refuse a verified credential on policy grounds, answering 403
with a body whose `error` is a machine code rather than a sentence. The adapter
forwards that body verbatim and `extractMessage` turns the code into
`error.message`, so an app rendering the message shows a user
`synced_passkey_not_allowed`, and a caller has nothing to branch on except the
literal.

`getPasskeyPolicyErrorCode()` reads that code, following the existing
`getOAuthErrorCode()` shape: unrecognized values return undefined so a refusal
from a newer API keeps generic messaging, and a code nested under `details` is
found too, for a proxy that derives its own top-level `error` string.

`prf_required` comes from the same endpoint and had the same problem, so it is
covered as well.

This is the default path rather than an edge case: `syncedPasskeys` defaults to
`block` and every iCloud Keychain or Google Password Manager passkey is backup
eligible, so README and AGENTS.md now name the codes and the default.

Refs #132
@Bccorb
Bccorb merged commit 4199d52 into main Aug 30, 2026
3 checks passed
@Bccorb
Bccorb deleted the fix/passkey-policy-error-code branch August 30, 2026 17:40
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.

Passkey policy refusals surface as a raw error code to the end user

1 participant