Skip to content

fix: normalize cancellation / no-credential errors on Android & iOS (#106, #107)#108

Open
plgrazon wants to merge 1 commit into
f-23:stablefrom
plgrazon:fix/cancel-nocredential-errors
Open

fix: normalize cancellation / no-credential errors on Android & iOS (#106, #107)#108
plgrazon wants to merge 1 commit into
f-23:stablefrom
plgrazon:fix/cancel-nocredential-errors

Conversation

@plgrazon

@plgrazon plgrazon commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes two error-mapping regressions introduced in e8eb940 (released as 3.5.0, the getImmediate feature).

#106 — Android: Back button during get() returns RequestFailed instead of UserCancelled

e8eb940 added mapDomError, which mapped NotAllowedError -> "RequestFailed". On Android, the Credential Manager / FIDO2 provider surfaces a Back-button ceremony cancel as GetPublicKeyCredentialDomException(NotAllowedError) (WebAuthn deliberately uses NotAllowedError as the ambiguous user-abort/timeout signal) — not as GetCredentialCancellationException. So the cancel was deterministically collapsed to RequestFailed ("The request failed. No Credentials were returned.").

Fix: map NotAllowedError -> "UserCancelled", consistent with iOS .canceled and the web navigator.credentials convention. Genuine misconfiguration still surfaces as SecurityError / ConstraintError / DataError and keeps its distinct code.

#107 — iOS: getImmediate() returns UserCancelled instead of NoCredentials when no passkey exists

e8eb940 added case 1005 -> .noCredentials, assuming an immediate-mediation "no credentials" outcome returns ASAuthorizationError.notInteractive (1005). It doesn't — performRequests(options: .preferImmediatelyAvailableCredentials) with no on-device credential fails with ASAuthorizationError.canceled (1001), the same code as a genuine user cancel. The delegate forwarded the raw error with no knowledge the request was immediate.

Fix: thread the preferImmediatelyAvailable flag into handleErrorCode and map a 1001 to NoCredentials when the request was immediate (gated on iOS 16+, where the option actually engages). A normal get() cancel still maps to UserCancelled. Also removes a dead duplicate case 1006.

After both fixes, getImmediate with no passkey returns NoCredentials on both platforms (Android already did via NoCredentialException), matching the documented contract.

Design notes

  • Errors are disambiguated by request type/intent, never by parsing localized message strings (the fragile pre-3.5.0 behavior).
  • The iOS immediate tradeoff: if a credential is available, the immediate sheet shows, and the user cancels it, that 1001 is also reported as NoCredentials. Acceptable for a silent-probe API — consumers fall back either way.

Tests

The specs previously covered only success/arg-plumbing (no rejection path — which is how both regressions shipped). Added:

  • src/__tests__/PasskeyError.spec.ts — unit tests for handleNativeError code mapping.
  • Rejection-path tests in the Android/iOS specs (get() -> UserCancelled, getImmediate() -> NoCredentials).

yarn test (24 passing), yarn typescript, and yarn lint all green.

Verification

JS tests cannot exercise the Kotlin/Swift changes. Native behavior verified on real devices:

  • Android (16+): get() + hardware Back on the Credential Manager prompt -> UserCancelled.
  • iOS (16+): getImmediate() with no passkey -> NoCredentials; normal get() cancel still -> UserCancelled.

Closes #106
Closes #107

🤖 Generated with Claude Code

Both behaviors regressed in e8eb940 (3.5.0, getImmediate feature).

Android (f-23#106): a Back-button cancel during get() surfaces as
GetPublicKeyCredentialDomException(NotAllowedError), which mapDomError
collapsed to RequestFailed. WebAuthn uses NotAllowedError as the
(deliberately ambiguous) user-abort signal, so map it to UserCancelled
— consistent with iOS .canceled and the web navigator.credentials
convention. Genuine config failures still surface as SecurityError /
ConstraintError / DataError and keep their distinct codes.

iOS (f-23#107): immediate mediation reports "no credential available" as
ASAuthorizationError.canceled (1001), not notInteractive (1005) as the
original mapping assumed, so getImmediate() returned UserCancelled when
no passkey existed. Thread the immediate flag into handleErrorCode and
map a 1001 to NoCredentials when the request was immediate (iOS 16+);
a normal get() cancel still maps to UserCancelled. Also removes a dead
duplicate case 1006.

Adds JS rejection-path tests (handleNativeError unit test plus get /
getImmediate reject cases) which the repo previously lacked.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant