diff --git a/.changeset/fido-davinci-client-error.md b/.changeset/fido-davinci-client-error.md new file mode 100644 index 000000000..de72bc36d --- /dev/null +++ b/.changeset/fido-davinci-client-error.md @@ -0,0 +1,6 @@ +--- +'@ping-identity/rn-fido': minor +'@ping-identity/rn-davinci': patch +--- + +feat(rn-fido): add DaVinci FIDO2 client-side error handling via `FidoError.clientError` on ceremony rejections (SDKS-5383) diff --git a/PingSampleApp/ios/Podfile.lock b/PingSampleApp/ios/Podfile.lock index cb84ea8d0..5c88b64ad 100644 --- a/PingSampleApp/ios/Podfile.lock +++ b/PingSampleApp/ios/Podfile.lock @@ -2513,7 +2513,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - RNPingBinding (1.1.0-beta.0): + - RNPingBinding (1.1.0): - boost - DoubleConversion - fast_float @@ -2544,7 +2544,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingBrowser (1.1.0-beta.0): + - RNPingBrowser (1.1.0): - boost - DoubleConversion - fast_float @@ -2577,7 +2577,7 @@ PODS: - RNPingLogger - SocketRocket - Yoga - - RNPingCore (1.1.0-beta.0): + - RNPingCore (1.1.0): - boost - DoubleConversion - fast_float @@ -2606,7 +2606,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - RNPingDavinci (1.1.0-beta.0): + - RNPingDavinci (1.1.0): - boost - DoubleConversion - fast_float @@ -2642,7 +2642,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingDeviceClient (1.1.0-beta.0): + - RNPingDeviceClient (1.1.0): - boost - DoubleConversion - fast_float @@ -2673,7 +2673,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingDeviceId (1.1.0-beta.0): + - RNPingDeviceId (1.1.0): - boost - DoubleConversion - fast_float @@ -2704,7 +2704,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingDeviceProfile (1.1.0-beta.0): + - RNPingDeviceProfile (1.1.0): - boost - DoubleConversion - fast_float @@ -2735,7 +2735,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingExternalIdp (1.1.0-beta.0): + - RNPingExternalIdp (1.1.0): - boost - DoubleConversion - fast_float @@ -2766,7 +2766,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingFido (1.1.0-beta.0): + - RNPingFido (1.1.0): - boost - DoubleConversion - fast_float @@ -2798,7 +2798,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingJourney (1.1.0-beta.0): + - RNPingJourney (1.1.0): - boost - DoubleConversion - fast_float @@ -2835,7 +2835,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingLogger (1.1.0-beta.0): + - RNPingLogger (1.1.0): - boost - DoubleConversion - fast_float @@ -2866,7 +2866,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingOath (1.1.0-beta.0): + - RNPingOath (1.1.0): - boost - DoubleConversion - fast_float @@ -2897,7 +2897,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingOidc (1.1.0-beta.0): + - RNPingOidc (1.1.0): - boost - DoubleConversion - fast_float @@ -2932,7 +2932,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingProtect (1.1.0-beta.0): + - RNPingProtect (1.1.0): - boost - DoubleConversion - fast_float @@ -2965,7 +2965,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingPush (1.1.0-beta.0): + - RNPingPush (1.1.0): - boost - DoubleConversion - fast_float @@ -2998,7 +2998,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingStorage (1.1.0-beta.0): + - RNPingStorage (1.1.0): - boost - DoubleConversion - fast_float @@ -3808,98 +3808,98 @@ SPEC CHECKSUMS: PingTamperDetector: 99159fd12d0ffc40db6096fcff99daf554f248c8 PromisesObjC: 752c3227f599e3467650e47ea36f433eeb10c273 PromisesSwift: 217dea0fd5d2ad65222a109c48698add13cc1c5b - RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 + RCT-Folly: 59ec0ac1f2f39672a0c6e6cecdd39383b764646f RCTDeprecation: efa5010912100e944a7ac9a93a157e1def1988fe RCTRequired: bbc4cf999ddc4a4b076e076c74dd1d39d0254630 RCTTypeSafety: d877728097547d0a37786cc9130c43ad71739ac3 React: 4b0b9cb962e694611e5e8a697c1b0300a2510c21 React-callinvoker: 70f125c17c7132811a6b473946ac5e7ae93b5e57 - React-Core: bab40f5b1f46fe0c5896895a6f333e861a821a81 - React-CoreModules: 05647d952e521113c128360633896ba7ba652e82 - React-cxxreact: 2b4bac1ec6eecc6288ac8a6caea6afb42585740e + React-Core: 7cbc3118df2334b2ef597d9a515938b02c82109f + React-CoreModules: 7d8c14ecb889e7786a04637583b55b7d8f246baf + React-cxxreact: f32be07cba236c2f20f4e05ca200577ba5358e78 React-debug: deb3a146ef717fa3e8f4c23e0288369fe53199b7 - React-defaultsnativemodule: 11e2948787a15d3cf1b66d7f29f13770a177bff7 - React-domnativemodule: 2f4b279acdb2963736fb5de2f585811dd90070b5 - React-Fabric: 6f8d1a303c96f1d078c14d74c4005bf457e5b782 - React-FabricComponents: b106410970e9a0c4e592da656c7a7e0947306c23 - React-FabricImage: 1abaf230dfce9b58fdf53c4128f3f40c6e64af6a - React-featureflags: f7ef58d91079efde3ad223bcca6d197e845d5bcf - React-featureflagsnativemodule: ae5abc9849d1696f4f8f11ee3744bf5715e032cf - React-graphics: b306856c6ed9aac32f717a229550406a53b28a6d - React-hermes: b6edce8fa19388654b1aea30844497cbeade83bc - React-idlecallbacksnativemodule: cb386712842cb9e479c89311edb234d529b64db4 - React-ImageManager: 8ce94417853eaa22faaad1f4cc1952dd3f8e2275 - React-jserrorhandler: ab827d67dc270a9c8703eef524230baeafaf6876 - React-jsi: 545342ec5c78ab1277af5f0dbe8d489e7e73db14 - React-jsiexecutor: 20210891c7c77255c16dec6762faf68b373f9f74 - React-jsinspector: 4e73460e488132d70d2b4894e5578cc856f2cb74 - React-jsinspectorcdp: 8b2bcb5779289cb2b9ca517f2965ed23eb2fd3e0 - React-jsinspectornetwork: b5e0cb9e488d294eed2d8209dc3dc0f9587210c1 - React-jsinspectortracing: f3c4036e7b984405ac910f878576d325dd9f2834 - React-jsitooling: 75bbfd221b6173a5e848ca5a6680506bac064a56 - React-jsitracing: 11ed7d821864dd988c159d4943e0a1e0937c11b1 - React-logger: 984ebd897afad067555d081deaf03f57c4315723 - React-Mapbuffer: 0c045c844ce6d85cde53e85ab163294c6adad349 - React-microtasksnativemodule: d9499269ad1f484ae71319bac1d9231447f2094e - react-native-config: adc7d6e70302363192ed8f2eae027864d218ff71 - react-native-safe-area-context: 01f6d357d42395422eed7c3bfe715614728f2989 - React-NativeModulesApple: 983f3483ef0a3446b56d490f09d579fba2442e17 + React-defaultsnativemodule: 2c13a4240c5f96c42d069d1ba2392de6b4145bbd + React-domnativemodule: 91349b0b1cb20310cec1341b87cdd461aaa85e57 + React-Fabric: bdfc7ec2481f26d7a9b8f59461f29ba4d903c549 + React-FabricComponents: 47898469543d1bfb4528a9846419ec5568be89b1 + React-FabricImage: ac8fc85ef452e5e9ae935c41118814651bd9e7f3 + React-featureflags: 793b911e4c53e680db4a7d9965d0d6dc87b2fa88 + React-featureflagsnativemodule: 25c9516d0dd004493c9bbafeb97da20bf9bde7dc + React-graphics: e07281690425dd9eeba3875d1faad28bc1f6da3b + React-hermes: bc1440d0e0662cc813bbf1c5ffbf9e0db2993a0f + React-idlecallbacksnativemodule: a2a3bb4a1793280b34d06d00169153b094be8c16 + React-ImageManager: c9fa7461f3cab08e7bc98cbf55455b499e71c8b3 + React-jserrorhandler: 15e591702040afed99cfcd088cf2337a8d09d807 + React-jsi: 512ab3a1a628bc8824c41de8bcbbb81b2ac6fa8d + React-jsiexecutor: 653ccd2dee1e5ea558eecaf2f27b8bba0f09add8 + React-jsinspector: 9121ccd2676a3f7c079ac01c9f90183422e3190e + React-jsinspectorcdp: 5c723ff2a09d73f2fdc496a545fb7003e7fdc079 + React-jsinspectornetwork: 9cb0173f69e8405cef33fc79030fad26bbc3c073 + React-jsinspectortracing: 65dc04125dc2392d85a82b6916f8cb088ea77566 + React-jsitooling: 21af93cc98f760dd88d65b06b9317e0d4849fbbc + React-jsitracing: 4cc1b7de8087ae41c61a0eeee2593bc3362908b6 + React-logger: 2f0d40bc8e648fbb1ff3b6580ad54189a8753290 + React-Mapbuffer: 9a7c65078c6851397c1999068989e4fc239d0c80 + React-microtasksnativemodule: 4f1ef719ba6c7ebbd2d75346ffa2916f9b4771c9 + react-native-config: 85858ab5e598762e783a43ac592561ad110c35a9 + react-native-safe-area-context: d446989793f96dc2f44c33c42dbfb316d983f24e + React-NativeModulesApple: f6f696e510b9d89c3c06b7764f56947dc13ae922 React-oscompat: 114036cd8f064558c9c1a0c04fc9ae5e1453706a - React-perflogger: e7287fee27c16e3c8bd4d470f2361572b63be16b - React-performancetimeline: 8ebbaa31d2d0cea680b0a2a567500d3cab8954fc + React-perflogger: 4b2f88ae059b600daf268528a4a83366338eef05 + React-performancetimeline: e15fd9798123436f99e46898422fe921fecf506b React-RCTActionSheet: 68c68b0a7a5d2b0cfc255c64889b6e485974e988 - React-RCTAnimation: d6c5c728b888a967ce9aff1ff71a8ed71a68d069 - React-RCTAppDelegate: 0fc048666bda159cd469a6fb9befb04b3fa62be4 - React-RCTBlob: 12d8c699a1f906840113ee8d8bb575e69a05509f - React-RCTFabric: 01e815845ebc185f44205dcbf50eeb712fec23fe - React-RCTFBReactNativeSpec: f57927fb0af6ce2f25c19f8b894e2986138aa89f - React-RCTImage: a82518168f4ee407913b23ca749ca79ef51959f3 - React-RCTLinking: 7f343b584c36f024f390fea563483568fe763ef6 - React-RCTNetwork: 3165eb757ceb62a7cde4cdad043d63314122e8a3 - React-RCTRuntime: feee590c459c4cb6aaa7a00f3abc8c04709b536f - React-RCTSettings: 6bad0ae45d8d872c873059f332f586f99875621f - React-RCTText: 657d60f35983062de8f0cea67c279aa7a3ea9858 - React-RCTVibration: 78f4770515141efb7f55f9b27c49dda95319c3a8 + React-RCTAnimation: 6bf502c89c53076f92cd1a254f5ec8d63ee263de + React-RCTAppDelegate: c90f5732784684c3dd226d812eccb578cd954ad7 + React-RCTBlob: d2905f01749b80efd6d3b86fb15e30ed26d5450b + React-RCTFabric: 435b3ffaad113fb1f274c2f2a677c9fcc9b5cf55 + React-RCTFBReactNativeSpec: a3178b419f42af196e90ca4bf07710dce5d68301 + React-RCTImage: 8f5ffa03461339180a68820ea452af6e20ace2c7 + React-RCTLinking: 1151646834d31f97580d8a75d768a84b2533b7f9 + React-RCTNetwork: 52008724d0db90a540f4058ed0de0e41c4b7943c + React-RCTRuntime: 10ce9a7cb27ba307544d29a2a04e6202dc7b3e9a + React-RCTSettings: f724cacbd892ee18f985e1aebdd97386e49c76f5 + React-RCTText: 6e1b95d9126d808410dfa96e09bc4441ec6f36f7 + React-RCTVibration: 862a4e5b36d49e6299c8cbfb86486fc31f86f6fa React-rendererconsistency: f7baab26c6d0cd5b2eb7afcecfd2d8b957017b18 - React-renderercss: bdd2f83a4a054c3e4321fd61305c202b848e471b - React-rendererdebug: 9f8865ee038127a9d99d4b034c9da4935d204993 + React-renderercss: 62acb8f010a062309e3bd0e203aa14636162e3b3 + React-rendererdebug: 3a89ac44f15c7160735264d585a29525655238d2 React-rncore: f7438473c4c71ee1963fb06a8635bb96013c9e1c - React-RuntimeApple: 4d2ab9f72b9193da86eceded128a67254fc18aeb - React-RuntimeCore: 5fd73030438d094975ca0f549d162dd97746ae38 + React-RuntimeApple: 81f0a9ba81ce7eb203529b0471dc69bf18f5f637 + React-RuntimeCore: 6356e89b2518ba66a989c39a2adb18122a5e3b7b React-runtimeexecutor: 17c70842d5e611130cb66f91e247bc4a609c3508 - React-RuntimeHermes: 3c88e6e1ea7ea0899dcffc77c10d61ea46688cfd - React-runtimescheduler: 024500621c7c93d65371498abb4ee26d34f5d47d + React-RuntimeHermes: 0a1d7ce2fe08cf182235de1a9330b51aa6b935cd + React-runtimescheduler: 10ae98e1417eff159be5df8fdc8fcdaac557aba6 React-timing: c3c923df2b86194e1682e01167717481232f1dc7 - React-utils: 9154a037543147e1c24098f1a48fc8472602c092 - ReactAppDependencyProvider: afd905e84ee36e1678016ae04d7370c75ed539be - ReactCodegen: 62690fdeb185860673cb78e3d9b049c7e1d40531 - ReactCommon: 17fd88849a174bf9ce45461912291aca711410fc + React-utils: 7791a96e194eec85cb41dc98a2045b5f07839598 + ReactAppDependencyProvider: ba631a31783569c13056dd57ff39e19764abdd6f + ReactCodegen: 1e9f3e8a3f56fa25fbf39ecd37b708a4838d9032 + ReactCommon: 96684b90b235d6ae340d126141edd4563b7a446a RecaptchaInterop: 11e0b637842dfb48308d242afc3f448062325aba RNCAsyncStorage: 767abb068db6ad28b5f59a129fbc9fab18b377e2 - RNPingBinding: 697000dc3c2d6f3b029f793393498b830b6928bb - RNPingBrowser: e604b03d3bcf87e8fcc87586263d5482682ffd88 - RNPingCore: e7d88b03a4d4c8f9233ee8e9c2522c90e6e8a26b - RNPingDavinci: 6d761386abc3ec8b18355b89a9c50a2173eb2387 - RNPingDeviceClient: 63b56aab18aaa55e4d9e78059298a2aa3e02600e - RNPingDeviceId: 13da4c088675b9f2b2558999ce91a0694217d705 - RNPingDeviceProfile: dbc5a7cf28296ee9b2717470f1c12a169ca0ade6 - RNPingExternalIdp: 53ce3b2e6c8e1f0f131a4532563c2828787f63d8 - RNPingFido: 3fbfeaad2fc00aa0a24e9750cd1321f6cb2acf51 - RNPingJourney: 3eb20cc0387229496405f72dc6e7386ccf407359 - RNPingLogger: bb9a68a084b5be5337bdb543460d17a9e2e21319 - RNPingOath: 6ebd1aa93bc935f25db9be594b0398ee5b8fcaa3 - RNPingOidc: c4f91fe3ebe59cf6128eb639d14e941a96e4603c - RNPingProtect: c2bc798ead435ad5e236c86a0dabc90c676581fb - RNPingPush: 4b4b0b67e23acf8dcec306cdc40faf15a64070c3 - RNPingStorage: 9ac4947abd71d06496584a78b73f3a169d622cb5 + RNPingBinding: 2d36e1a69ee11ee53f2bf98edb4968240429f335 + RNPingBrowser: abea36612516b9187c1841c3485e0b5eabe6bcce + RNPingCore: f4a461e75fbe3fccf3263e2b91938d9887324b32 + RNPingDavinci: f58fdf67db1830a7b7c41b583ab26a6b780cf794 + RNPingDeviceClient: 018ce1c81f671b37d943f464f799852dbacd4d9a + RNPingDeviceId: 2537972a09ab70d4a03a7aa644d07770c59cc79f + RNPingDeviceProfile: 44fde49a965fb86123fca5db6cfc3075d29a28db + RNPingExternalIdp: ba514170a01d4ba51d141dcfe41d332bd092e300 + RNPingFido: 6a17bfc93afcc20d5172c37ce39997a91e4576c6 + RNPingJourney: f2907549187da7e4120e814c8704d4e96dcbdaa0 + RNPingLogger: 493cca89cf636799f9e580115d46f37426afcbcb + RNPingOath: a84840889256fb6d360d6b0303b858fe6e5e1cd2 + RNPingOidc: 3b1217530a24d2ab011be52c187bb7a7d3ba1bd4 + RNPingProtect: db22b6fce139d3c9026797f10fb7a4366f468c69 + RNPingPush: e276582ce2ee815d39b40f4f91c9399c4238728c + RNPingStorage: 75308e520dd9e0f8faf0802b73aa89e403f7ea3a RNScreens: 846d53087db560ed5fbc34feb0643adb5f9602c5 RNSVG: e8fb86f41fccd7b67c4480bb9e179e0ad5785b80 RNVectorIcons: 54df27a2e90ddeb674c7237d76060ec9762d0bc5 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 - VisionCamera: e82024ee7ce0fb6168a4c468ef43c711f4486baa - VisionCameraBarcodeScanner: a65d708fd3672e6d43e94cce39b548465851752b + VisionCamera: 3914b29387fc40c64f7022830cfdc604f94eb966 + VisionCameraBarcodeScanner: 0b6fe2e3c52ca68a0dbd5d25719c7523c68d807f Yoga: daa1e4de4b971b977b23bc842aaa3e135324f1f3 PODFILE CHECKSUM: 5182311ed80522ff433a9df006b29c172d5b0a2d -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/PingSampleApp/ui/davinci/README.md b/PingSampleApp/ui/davinci/README.md index 5424b415c..b2e1bd4ef 100644 --- a/PingSampleApp/ui/davinci/README.md +++ b/PingSampleApp/ui/davinci/README.md @@ -117,6 +117,15 @@ Auto-starts the DaVinci flow once when no node and no active session are present 1. `DaVinciContinueNodePanel` checks whether the collector list contains at least one `SUBMIT_BUTTON`, `ACTION`, or `FLOW_BUTTON`. 2. If none are present, a fallback "Next" button is rendered to allow progression. +### FIDO Ceremony Failure Propagation + +1. `DaVinciFidoField` invokes `onFidoCeremony`, which runs the native passkey ceremony and then advances the flow. +2. On success and on collector-classified failures (rejections carrying `FidoError.clientError`), the flow is advanced with `next({ collectors: [] })` so the server can branch on the outcome. +3. A classified failure is shown via `fidoError`; once the server's next node arrives, the failure card is cleared because that node is authoritative. +4. Failures without `clientError` (for example `FIDO_COLLECTOR_NOT_FOUND` and state errors) are shown via `fidoError` but are not propagated, and the node stays rendered for retry. +5. Propagation (`next`) failures are logged and surfaced via the hook-level `error`, kept distinct from the ceremony failure shown via `fidoError`. +6. Submitting the node's collectors instead of an empty array would shadow the FIDO error with a `SUBMIT_BUTTON` action, so the empty-array form is the only propagation path. + ## Security Notes - Collector values containing passwords or secrets are never logged by sample components. diff --git a/PingSampleApp/ui/davinci/hooks/useDaVinciClientPanelController.ts b/PingSampleApp/ui/davinci/hooks/useDaVinciClientPanelController.ts index eba0365c5..90a5dca93 100644 --- a/PingSampleApp/ui/davinci/hooks/useDaVinciClientPanelController.ts +++ b/PingSampleApp/ui/davinci/hooks/useDaVinciClientPanelController.ts @@ -26,6 +26,7 @@ import { import { createFidoClient, fidoCollectorType, + FidoError, type FidoCollector, } from '@ping-identity/rn-fido'; import { @@ -97,6 +98,14 @@ export type UseDaVinciClientPanelControllerResult = { * Runs the native passkey ceremony for a FIDO collector, then advances the * DaVinci flow. * + * @remarks + * On success and on collector-classified failures (rejections carrying + * `clientError`), the flow is advanced with `next({ collectors: [] })` so + * the server can branch on the outcome; the failure is also shown via + * `fidoError`. Failures without `clientError` (for example + * `FIDO_COLLECTOR_NOT_FOUND`) are shown via `fidoError` but are not + * propagated, and the node stays rendered for retry. + * * @param collector - The FIDO collector to process. */ onFidoCeremony: (collector: FidoCollector) => Promise; @@ -136,17 +145,16 @@ export type UseDaVinciClientPanelControllerOptions = { }; /** - * Returns true when the supplied error represents a user-cancelled FIDO - * authentication prompt. + * Returns the display message for a failed FIDO ceremony. * - * @param error Error returned by the FIDO client. - * @returns Whether the error is the native authentication cancellation code. + * @param error Error thrown by the FIDO client. + * @returns Message naming the client-side failure outcome when the collector + * classified it, otherwise the raw error message. */ -function isFidoAuthenticationCancelled(error: unknown): boolean { - if (!error || typeof error !== 'object') { - return false; - } - return (error as { code?: unknown }).code === 'FIDO_AUTHENTICATE_CANCELLED'; +function fidoCeremonyErrorMessage(error: FidoError): string { + return error.clientError !== undefined + ? `Passkey ceremony failed: ${error.clientError}.` + : error.message; } /** @@ -324,17 +332,32 @@ export function useDaVinciClientPanelController( }); } await next({ collectors: [] }); - } catch (fidoErrorValue) { - if (isFidoAuthenticationCancelled(fidoErrorValue)) { - setFidoError('Passkey authentication was canceled.'); + } catch (ceremonyError) { + const fidoErrorValue = FidoError.from(ceremonyError); + if (fidoErrorValue.clientError !== undefined) { + // The collector classified the failure; submit it so the flow can + // branch. Submitting the node's collectors instead would shadow the + // FIDO error with a SUBMIT_BUTTON action. + setFidoError(fidoCeremonyErrorMessage(fidoErrorValue)); + try { + await next({ collectors: [] }); + // The server's node is now authoritative; drop the failure card + // so it does not linger over the branch the server returned. + setFidoError(null); + } catch (propagationError) { + // Surfaces via the hook-level `error`, kept distinct from the + // ceremony failure already shown. + console.warn( + '[DaVinci] FIDO error propagation failed:', + propagationError, + ); + } return; } + // Resolution failures (collector not found, state errors) carry no + // clientError and must not be propagated. console.warn('[DaVinci] FIDO ceremony failed:', fidoErrorValue); - const msg = - fidoErrorValue instanceof Error - ? fidoErrorValue.message - : String(fidoErrorValue); - setFidoError(msg); + setFidoError(fidoCeremonyErrorMessage(fidoErrorValue)); } }, [davinciContext?.client, fido, form.fields, loading, next], diff --git a/PingTestRunner/__tests__/integration/fido.test.ts b/PingTestRunner/__tests__/integration/fido.test.ts index f486d76f7..5838063c0 100644 --- a/PingTestRunner/__tests__/integration/fido.test.ts +++ b/PingTestRunner/__tests__/integration/fido.test.ts @@ -13,6 +13,7 @@ * - Forwards options to native registerCredential / authenticateCredential * - Returns the native result unchanged * - Propagates native rejections to the caller + * - Surfaces clientError on DaVinci ceremony rejections */ export {}; @@ -706,5 +707,137 @@ describe('@ping-identity/rn-fido — integration', () => { 1, ); }); + + it('exposes clientError from authenticateForDaVinci rejection', async () => { + const nativeDaVinciMock = makeDaVinciMock(); + const nativeFidoMock: Required = { + ...makeMock(), + registerCredentialForDaVinci: jest.fn(async () => ({ + attestationValue: { id: 'cred-1' }, + })), + authenticateCredentialForDaVinci: jest.fn(async () => { + throw { + error: 'FIDO_AUTHENTICATE_CANCELLED', + message: 'Authentication failed.', + type: 'fido_error', + userInfo: { clientError: 'NotAllowedError' }, + }; + }), + }; + const { fidoClient, daVinciClient } = await loadDaVinciAndFido( + nativeDaVinciMock, + nativeFidoMock, + ); + + await expect( + fidoClient.authenticateForDaVinci(daVinciClient, { index: 0 }), + ).rejects.toMatchObject({ + name: 'FidoError', + code: 'FIDO_AUTHENTICATE_CANCELLED', + clientError: 'NotAllowedError', + }); + expect( + nativeFidoMock.authenticateCredentialForDaVinci, + ).toHaveBeenCalledTimes(1); + }); + + it('exposes clientError from registerForDaVinci rejection', async () => { + const nativeDaVinciMock = makeDaVinciMock(); + const nativeFidoMock: Required = { + ...makeMock(), + registerCredentialForDaVinci: jest.fn(async () => { + throw { + error: 'FIDO_REGISTER_ERROR', + message: 'Registration failed.', + type: 'fido_error', + userInfo: { clientError: 'InvalidStateError' }, + }; + }), + authenticateCredentialForDaVinci: jest.fn(async () => ({ + assertionValue: { id: 'cred-1' }, + })), + }; + const { fidoClient, daVinciClient } = await loadDaVinciAndFido( + nativeDaVinciMock, + nativeFidoMock, + ); + + await expect( + fidoClient.registerForDaVinci(daVinciClient, { index: 0 }), + ).rejects.toMatchObject({ + name: 'FidoError', + code: 'FIDO_REGISTER_ERROR', + clientError: 'InvalidStateError', + }); + expect(nativeFidoMock.registerCredentialForDaVinci).toHaveBeenCalledTimes( + 1, + ); + }); + + it('error then daVinci.next({collectors: []}) forwards an empty collectors array to native next', async () => { + const nativeDaVinciMock = makeDaVinciMock(); + const nativeFidoMock: Required = { + ...makeMock(), + registerCredentialForDaVinci: jest.fn(async () => ({ + attestationValue: { id: 'cred-1' }, + })), + authenticateCredentialForDaVinci: jest.fn(async () => { + throw { + error: 'FIDO_AUTHENTICATE_ERROR', + message: 'Authentication failed.', + type: 'fido_error', + userInfo: { clientError: 'TimeoutError' }, + }; + }), + }; + const { fidoClient, daVinciClient } = await loadDaVinciAndFido( + nativeDaVinciMock, + nativeFidoMock, + ); + + const err = await fidoClient + .authenticateForDaVinci(daVinciClient, { index: 0 }) + .catch((e) => e); + expect(err.clientError).toBe('TimeoutError'); + + await daVinciClient.next({ collectors: [] }); + + expect(nativeDaVinciMock.next).toHaveBeenCalledWith( + 'davinci-id-fido-mock', + { collectors: [] }, + ); + }); + + it('collector-not-found rejection has no clientError', async () => { + const nativeDaVinciMock = makeDaVinciMock(); + const nativeFidoMock: Required = { + ...makeMock(), + registerCredentialForDaVinci: jest.fn(async () => ({ + attestationValue: { id: 'cred-1' }, + })), + authenticateCredentialForDaVinci: jest.fn(async () => { + throw { + error: 'FIDO_COLLECTOR_NOT_FOUND', + message: 'No active FIDO authentication collector found.', + type: 'state_error', + }; + }), + }; + const { fidoClient, daVinciClient } = await loadDaVinciAndFido( + nativeDaVinciMock, + nativeFidoMock, + ); + + const err = await fidoClient + .authenticateForDaVinci(daVinciClient, { index: 0 }) + .catch((e) => e); + + expect(err).toMatchObject({ + name: 'FidoError', + code: 'FIDO_COLLECTOR_NOT_FOUND', + type: 'state_error', + }); + expect(err.clientError).toBeUndefined(); + }); }); }); diff --git a/PingTestRunner/ios/PingTestRunner/PrivacyInfo.xcprivacy b/PingTestRunner/ios/PingTestRunner/PrivacyInfo.xcprivacy index 2a2ee6bb2..09e882fa1 100644 --- a/PingTestRunner/ios/PingTestRunner/PrivacyInfo.xcprivacy +++ b/PingTestRunner/ios/PingTestRunner/PrivacyInfo.xcprivacy @@ -17,24 +17,8 @@ NSPrivacyAccessedAPICategoryUserDefaults NSPrivacyAccessedAPITypeReasons - CA92.1 C56D.1 - - - - NSPrivacyAccessedAPIType - NSPrivacyAccessedAPICategoryFileTimestamp - NSPrivacyAccessedAPITypeReasons - - C617.1 - - - - NSPrivacyAccessedAPIType - NSPrivacyAccessedAPICategoryFileTimestamp - NSPrivacyAccessedAPITypeReasons - - C617.1 + CA92.1 diff --git a/PingTestRunner/ios/Podfile.lock b/PingTestRunner/ios/Podfile.lock index 710be52e4..268403e7b 100644 --- a/PingTestRunner/ios/Podfile.lock +++ b/PingTestRunner/ios/Podfile.lock @@ -5,6 +5,46 @@ PODS: - FBLazyVector (0.80.1) - fmt (11.0.2) - glog (0.3.5) + - GoogleUtilities (8.1.5): + - GoogleUtilities/AppDelegateSwizzler (= 8.1.5) + - GoogleUtilities/Environment (= 8.1.5) + - GoogleUtilities/Logger (= 8.1.5) + - GoogleUtilities/MethodSwizzler (= 8.1.5) + - GoogleUtilities/Network (= 8.1.5) + - "GoogleUtilities/NSData+zlib (= 8.1.5)" + - GoogleUtilities/Privacy (= 8.1.5) + - GoogleUtilities/Reachability (= 8.1.5) + - GoogleUtilities/SwizzlerTestHelpers (= 8.1.5) + - GoogleUtilities/UserDefaults (= 8.1.5) + - GoogleUtilities/AppDelegateSwizzler (8.1.5): + - GoogleUtilities/Environment + - GoogleUtilities/Logger + - GoogleUtilities/Network + - GoogleUtilities/Privacy + - GoogleUtilities/Environment (8.1.5): + - GoogleUtilities/Privacy + - GoogleUtilities/Logger (8.1.5): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/MethodSwizzler (8.1.5): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GoogleUtilities/Network (8.1.5): + - GoogleUtilities/Logger + - "GoogleUtilities/NSData+zlib" + - GoogleUtilities/Privacy + - GoogleUtilities/Reachability + - "GoogleUtilities/NSData+zlib (8.1.5)": + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (8.1.5) + - GoogleUtilities/Reachability (8.1.5): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GoogleUtilities/SwizzlerTestHelpers (8.1.5): + - GoogleUtilities/MethodSwizzler + - GoogleUtilities/UserDefaults (8.1.5): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy - hermes-engine (0.80.1): - hermes-engine/Pre-built (= 0.80.1) - hermes-engine/Pre-built (0.80.1) @@ -2188,6 +2228,7 @@ PODS: - React-perflogger (= 0.80.1) - React-utils (= 0.80.1) - SocketRocket + - RecaptchaInterop (101.0.0) - RNCAsyncStorage (2.2.0): - boost - DoubleConversion @@ -2217,7 +2258,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - RNPingBinding (1.1.0-beta.0): + - RNPingBinding (1.1.0): - boost - DoubleConversion - fast_float @@ -2248,7 +2289,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingBinding/Tests (1.1.0-beta.0): + - RNPingBinding/Tests (1.1.0): - boost - DoubleConversion - fast_float @@ -2279,7 +2320,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingBrowser (1.1.0-beta.0): + - RNPingBrowser (1.1.0): - boost - DoubleConversion - fast_float @@ -2312,7 +2353,7 @@ PODS: - RNPingLogger - SocketRocket - Yoga - - RNPingBrowser/Tests (1.1.0-beta.0): + - RNPingBrowser/Tests (1.1.0): - boost - DoubleConversion - fast_float @@ -2345,7 +2386,7 @@ PODS: - RNPingLogger - SocketRocket - Yoga - - RNPingCore (1.1.0-beta.0): + - RNPingCore (1.1.0): - boost - DoubleConversion - fast_float @@ -2374,7 +2415,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - RNPingCore/Tests (1.1.0-beta.0): + - RNPingCore/Tests (1.1.0): - boost - DoubleConversion - fast_float @@ -2403,7 +2444,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - RNPingDavinci (1.1.0-beta.0): + - RNPingDavinci (1.1.0): - boost - DoubleConversion - fast_float @@ -2439,7 +2480,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingDavinci/Tests (1.1.0-beta.0): + - RNPingDavinci/Tests (1.1.0): - boost - DoubleConversion - fast_float @@ -2475,7 +2516,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingDeviceClient (1.1.0-beta.0): + - RNPingDeviceClient (1.1.0): - boost - DoubleConversion - fast_float @@ -2506,7 +2547,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingDeviceClient/Tests (1.1.0-beta.0): + - RNPingDeviceClient/Tests (1.1.0): - boost - DoubleConversion - fast_float @@ -2537,7 +2578,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingDeviceId (1.1.0-beta.0): + - RNPingDeviceId (1.1.0): - boost - DoubleConversion - fast_float @@ -2568,7 +2609,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingDeviceId/Tests (1.1.0-beta.0): + - RNPingDeviceId/Tests (1.1.0): - boost - DoubleConversion - fast_float @@ -2599,7 +2640,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingDeviceProfile (1.1.0-beta.0): + - RNPingDeviceProfile (1.1.0): - boost - DoubleConversion - fast_float @@ -2630,7 +2671,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingDeviceProfile/Tests (1.1.0-beta.0): + - RNPingDeviceProfile/Tests (1.1.0): - boost - DoubleConversion - fast_float @@ -2661,7 +2702,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingExternalIdp (1.1.0-beta.0): + - RNPingExternalIdp (1.1.0): - boost - DoubleConversion - fast_float @@ -2692,7 +2733,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingExternalIdp/Tests (1.1.0-beta.0): + - RNPingExternalIdp/Tests (1.1.0): - boost - DoubleConversion - fast_float @@ -2723,7 +2764,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingFido (1.1.0-beta.0): + - RNPingFido (1.1.0): - boost - DoubleConversion - fast_float @@ -2755,7 +2796,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingFido/Tests (1.1.0-beta.0): + - RNPingFido/Tests (1.1.0): - boost - DoubleConversion - fast_float @@ -2787,7 +2828,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingJourney (1.1.0-beta.0): + - RNPingJourney (1.1.0): - boost - DoubleConversion - fast_float @@ -2824,7 +2865,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingJourney/Tests (1.1.0-beta.0): + - RNPingJourney/Tests (1.1.0): - boost - DoubleConversion - fast_float @@ -2862,7 +2903,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingLogger (1.1.0-beta.0): + - RNPingLogger (1.1.0): - boost - DoubleConversion - fast_float @@ -2893,7 +2934,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingLogger/Tests (1.1.0-beta.0): + - RNPingLogger/Tests (1.1.0): - boost - DoubleConversion - fast_float @@ -2924,7 +2965,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingOath (1.1.0-beta.0): + - RNPingOath (1.1.0): - boost - DoubleConversion - fast_float @@ -2955,7 +2996,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingOath/Tests (1.1.0-beta.0): + - RNPingOath/Tests (1.1.0): - boost - DoubleConversion - fast_float @@ -2986,7 +3027,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingOidc (1.1.0-beta.0): + - RNPingOidc (1.1.0): - boost - DoubleConversion - fast_float @@ -3021,7 +3062,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingOidc/Tests (1.1.0-beta.0): + - RNPingOidc/Tests (1.1.0): - boost - DoubleConversion - fast_float @@ -3056,7 +3097,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingProtect (1.1.0-beta.0): + - RNPingProtect (1.1.0): - boost - DoubleConversion - fast_float @@ -3089,7 +3130,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingProtect/Tests (1.1.0-beta.0): + - RNPingProtect/Tests (1.1.0): - boost - DoubleConversion - fast_float @@ -3122,7 +3163,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingPush (1.1.0-beta.0): + - RNPingPush (1.1.0): - boost - DoubleConversion - fast_float @@ -3155,7 +3196,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingPush/Tests (1.1.0-beta.0): + - RNPingPush/Tests (1.1.0): - boost - DoubleConversion - fast_float @@ -3188,7 +3229,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingStorage (1.1.0-beta.0): + - RNPingStorage (1.1.0): - boost - DoubleConversion - fast_float @@ -3219,7 +3260,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingStorage/Tests (1.1.0-beta.0): + - RNPingStorage/Tests (1.1.0): - boost - DoubleConversion - fast_float @@ -3260,6 +3301,7 @@ DEPENDENCIES: - FBLazyVector (from `../../node_modules/react-native/Libraries/FBLazyVector`) - fmt (from `../../node_modules/react-native/third-party-podspecs/fmt.podspec`) - glog (from `../../node_modules/react-native/third-party-podspecs/glog.podspec`) + - GoogleUtilities - hermes-engine (from `../../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) - PingAuthMigration (from `https://github.com/ForgeRock/ping-ios-sdk.git`, commit `df0acc60c0d4d699aa3fab78c43934f3b1344bdb`) - PingBinding (from `https://github.com/ForgeRock/ping-ios-sdk.git`, commit `df0acc60c0d4d699aa3fab78c43934f3b1344bdb`) @@ -3349,6 +3391,7 @@ DEPENDENCIES: - ReactAppDependencyProvider (from `build/generated/ios`) - ReactCodegen (from `build/generated/ios`) - ReactCommon/turbomodule/core (from `../../node_modules/react-native/ReactCommon`) + - RecaptchaInterop - "RNCAsyncStorage (from `../../node_modules/@react-native-async-storage/async-storage`)" - "RNPingBinding (from `../../node_modules/@ping-identity/rn-binding`)" - "RNPingBinding/Tests (from `../../node_modules/@ping-identity/rn-binding`)" @@ -3387,7 +3430,9 @@ DEPENDENCIES: SPEC REPOS: trunk: + - GoogleUtilities - PingOneSignals + - RecaptchaInterop - SocketRocket EXTERNAL SOURCES: @@ -3714,6 +3759,7 @@ SPEC CHECKSUMS: FBLazyVector: 09f03e4b6f42f955734b64a118f86509cc719427 fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 + GoogleUtilities: 7dc09b3fa9ecb5c2298b952313d14628b61b9da3 hermes-engine: 4f07404533b808de66cf48ac4200463068d0e95a PingAuthMigration: 73ce3670f4c4fa04236960f091278bf362ee2ff3 PingBinding: e6c18b25ca49463faa790a8d7ab25d81197f67e0 @@ -3744,85 +3790,86 @@ SPEC CHECKSUMS: RCTTypeSafety: d877728097547d0a37786cc9130c43ad71739ac3 React: 4b0b9cb962e694611e5e8a697c1b0300a2510c21 React-callinvoker: 70f125c17c7132811a6b473946ac5e7ae93b5e57 - React-Core: bab40f5b1f46fe0c5896895a6f333e861a821a81 - React-CoreModules: 05647d952e521113c128360633896ba7ba652e82 - React-cxxreact: 2b4bac1ec6eecc6288ac8a6caea6afb42585740e + React-Core: 7cbc3118df2334b2ef597d9a515938b02c82109f + React-CoreModules: 7d8c14ecb889e7786a04637583b55b7d8f246baf + React-cxxreact: f32be07cba236c2f20f4e05ca200577ba5358e78 React-debug: deb3a146ef717fa3e8f4c23e0288369fe53199b7 - React-defaultsnativemodule: 11e2948787a15d3cf1b66d7f29f13770a177bff7 - React-domnativemodule: 2f4b279acdb2963736fb5de2f585811dd90070b5 - React-Fabric: 6f8d1a303c96f1d078c14d74c4005bf457e5b782 - React-FabricComponents: b106410970e9a0c4e592da656c7a7e0947306c23 - React-FabricImage: 1abaf230dfce9b58fdf53c4128f3f40c6e64af6a - React-featureflags: f7ef58d91079efde3ad223bcca6d197e845d5bcf - React-featureflagsnativemodule: ae5abc9849d1696f4f8f11ee3744bf5715e032cf - React-graphics: b306856c6ed9aac32f717a229550406a53b28a6d - React-hermes: b6edce8fa19388654b1aea30844497cbeade83bc - React-idlecallbacksnativemodule: cb386712842cb9e479c89311edb234d529b64db4 - React-ImageManager: 8ce94417853eaa22faaad1f4cc1952dd3f8e2275 - React-jserrorhandler: ab827d67dc270a9c8703eef524230baeafaf6876 - React-jsi: 545342ec5c78ab1277af5f0dbe8d489e7e73db14 - React-jsiexecutor: 20210891c7c77255c16dec6762faf68b373f9f74 - React-jsinspector: 4e73460e488132d70d2b4894e5578cc856f2cb74 - React-jsinspectorcdp: 8b2bcb5779289cb2b9ca517f2965ed23eb2fd3e0 - React-jsinspectornetwork: b5e0cb9e488d294eed2d8209dc3dc0f9587210c1 - React-jsinspectortracing: f3c4036e7b984405ac910f878576d325dd9f2834 - React-jsitooling: 75bbfd221b6173a5e848ca5a6680506bac064a56 - React-jsitracing: 11ed7d821864dd988c159d4943e0a1e0937c11b1 - React-logger: 984ebd897afad067555d081deaf03f57c4315723 - React-Mapbuffer: 0c045c844ce6d85cde53e85ab163294c6adad349 - React-microtasksnativemodule: d9499269ad1f484ae71319bac1d9231447f2094e - react-native-launch-arguments: eab68037b01ea7c78a0b3b55da3a5e967e9aaf92 - React-NativeModulesApple: 983f3483ef0a3446b56d490f09d579fba2442e17 + React-defaultsnativemodule: 2c13a4240c5f96c42d069d1ba2392de6b4145bbd + React-domnativemodule: 91349b0b1cb20310cec1341b87cdd461aaa85e57 + React-Fabric: bdfc7ec2481f26d7a9b8f59461f29ba4d903c549 + React-FabricComponents: 47898469543d1bfb4528a9846419ec5568be89b1 + React-FabricImage: ac8fc85ef452e5e9ae935c41118814651bd9e7f3 + React-featureflags: 793b911e4c53e680db4a7d9965d0d6dc87b2fa88 + React-featureflagsnativemodule: 25c9516d0dd004493c9bbafeb97da20bf9bde7dc + React-graphics: e07281690425dd9eeba3875d1faad28bc1f6da3b + React-hermes: bc1440d0e0662cc813bbf1c5ffbf9e0db2993a0f + React-idlecallbacksnativemodule: a2a3bb4a1793280b34d06d00169153b094be8c16 + React-ImageManager: c9fa7461f3cab08e7bc98cbf55455b499e71c8b3 + React-jserrorhandler: 15e591702040afed99cfcd088cf2337a8d09d807 + React-jsi: 512ab3a1a628bc8824c41de8bcbbb81b2ac6fa8d + React-jsiexecutor: 653ccd2dee1e5ea558eecaf2f27b8bba0f09add8 + React-jsinspector: 9121ccd2676a3f7c079ac01c9f90183422e3190e + React-jsinspectorcdp: 5c723ff2a09d73f2fdc496a545fb7003e7fdc079 + React-jsinspectornetwork: 9cb0173f69e8405cef33fc79030fad26bbc3c073 + React-jsinspectortracing: 65dc04125dc2392d85a82b6916f8cb088ea77566 + React-jsitooling: 21af93cc98f760dd88d65b06b9317e0d4849fbbc + React-jsitracing: 4cc1b7de8087ae41c61a0eeee2593bc3362908b6 + React-logger: 2f0d40bc8e648fbb1ff3b6580ad54189a8753290 + React-Mapbuffer: 9a7c65078c6851397c1999068989e4fc239d0c80 + React-microtasksnativemodule: 4f1ef719ba6c7ebbd2d75346ffa2916f9b4771c9 + react-native-launch-arguments: c02ee750ca094c92d2f9a5830c2c085efd10ec56 + React-NativeModulesApple: f6f696e510b9d89c3c06b7764f56947dc13ae922 React-oscompat: 114036cd8f064558c9c1a0c04fc9ae5e1453706a - React-perflogger: e7287fee27c16e3c8bd4d470f2361572b63be16b - React-performancetimeline: 8ebbaa31d2d0cea680b0a2a567500d3cab8954fc + React-perflogger: 4b2f88ae059b600daf268528a4a83366338eef05 + React-performancetimeline: e15fd9798123436f99e46898422fe921fecf506b React-RCTActionSheet: 68c68b0a7a5d2b0cfc255c64889b6e485974e988 - React-RCTAnimation: d6c5c728b888a967ce9aff1ff71a8ed71a68d069 - React-RCTAppDelegate: 0fc048666bda159cd469a6fb9befb04b3fa62be4 - React-RCTBlob: 12d8c699a1f906840113ee8d8bb575e69a05509f - React-RCTFabric: 01e815845ebc185f44205dcbf50eeb712fec23fe - React-RCTFBReactNativeSpec: f57927fb0af6ce2f25c19f8b894e2986138aa89f - React-RCTImage: a82518168f4ee407913b23ca749ca79ef51959f3 - React-RCTLinking: 7f343b584c36f024f390fea563483568fe763ef6 - React-RCTNetwork: 3165eb757ceb62a7cde4cdad043d63314122e8a3 - React-RCTRuntime: feee590c459c4cb6aaa7a00f3abc8c04709b536f - React-RCTSettings: 6bad0ae45d8d872c873059f332f586f99875621f - React-RCTText: 657d60f35983062de8f0cea67c279aa7a3ea9858 - React-RCTVibration: 78f4770515141efb7f55f9b27c49dda95319c3a8 + React-RCTAnimation: 6bf502c89c53076f92cd1a254f5ec8d63ee263de + React-RCTAppDelegate: c90f5732784684c3dd226d812eccb578cd954ad7 + React-RCTBlob: d2905f01749b80efd6d3b86fb15e30ed26d5450b + React-RCTFabric: 435b3ffaad113fb1f274c2f2a677c9fcc9b5cf55 + React-RCTFBReactNativeSpec: a3178b419f42af196e90ca4bf07710dce5d68301 + React-RCTImage: 8f5ffa03461339180a68820ea452af6e20ace2c7 + React-RCTLinking: 1151646834d31f97580d8a75d768a84b2533b7f9 + React-RCTNetwork: 52008724d0db90a540f4058ed0de0e41c4b7943c + React-RCTRuntime: 10ce9a7cb27ba307544d29a2a04e6202dc7b3e9a + React-RCTSettings: f724cacbd892ee18f985e1aebdd97386e49c76f5 + React-RCTText: 6e1b95d9126d808410dfa96e09bc4441ec6f36f7 + React-RCTVibration: 862a4e5b36d49e6299c8cbfb86486fc31f86f6fa React-rendererconsistency: f7baab26c6d0cd5b2eb7afcecfd2d8b957017b18 - React-renderercss: bdd2f83a4a054c3e4321fd61305c202b848e471b - React-rendererdebug: 9f8865ee038127a9d99d4b034c9da4935d204993 + React-renderercss: 62acb8f010a062309e3bd0e203aa14636162e3b3 + React-rendererdebug: 3a89ac44f15c7160735264d585a29525655238d2 React-rncore: f7438473c4c71ee1963fb06a8635bb96013c9e1c - React-RuntimeApple: 4d2ab9f72b9193da86eceded128a67254fc18aeb - React-RuntimeCore: 5fd73030438d094975ca0f549d162dd97746ae38 + React-RuntimeApple: 81f0a9ba81ce7eb203529b0471dc69bf18f5f637 + React-RuntimeCore: 6356e89b2518ba66a989c39a2adb18122a5e3b7b React-runtimeexecutor: 17c70842d5e611130cb66f91e247bc4a609c3508 - React-RuntimeHermes: 3c88e6e1ea7ea0899dcffc77c10d61ea46688cfd - React-runtimescheduler: 024500621c7c93d65371498abb4ee26d34f5d47d + React-RuntimeHermes: 0a1d7ce2fe08cf182235de1a9330b51aa6b935cd + React-runtimescheduler: 10ae98e1417eff159be5df8fdc8fcdaac557aba6 React-timing: c3c923df2b86194e1682e01167717481232f1dc7 React-utils: 7791a96e194eec85cb41dc98a2045b5f07839598 ReactAppDependencyProvider: ba631a31783569c13056dd57ff39e19764abdd6f ReactCodegen: 1e9f3e8a3f56fa25fbf39ecd37b708a4838d9032 ReactCommon: 96684b90b235d6ae340d126141edd4563b7a446a + RecaptchaInterop: 11e0b637842dfb48308d242afc3f448062325aba RNCAsyncStorage: 767abb068db6ad28b5f59a129fbc9fab18b377e2 - RNPingBinding: 697000dc3c2d6f3b029f793393498b830b6928bb - RNPingBrowser: e604b03d3bcf87e8fcc87586263d5482682ffd88 - RNPingCore: e7d88b03a4d4c8f9233ee8e9c2522c90e6e8a26b - RNPingDavinci: 6d761386abc3ec8b18355b89a9c50a2173eb2387 - RNPingDeviceClient: 63b56aab18aaa55e4d9e78059298a2aa3e02600e - RNPingDeviceId: 13da4c088675b9f2b2558999ce91a0694217d705 - RNPingDeviceProfile: dbc5a7cf28296ee9b2717470f1c12a169ca0ade6 - RNPingExternalIdp: 53ce3b2e6c8e1f0f131a4532563c2828787f63d8 - RNPingFido: 3fbfeaad2fc00aa0a24e9750cd1321f6cb2acf51 - RNPingJourney: 3eb20cc0387229496405f72dc6e7386ccf407359 - RNPingLogger: bb9a68a084b5be5337bdb543460d17a9e2e21319 - RNPingOath: 6ebd1aa93bc935f25db9be594b0398ee5b8fcaa3 - RNPingOidc: c4f91fe3ebe59cf6128eb639d14e941a96e4603c - RNPingProtect: c2bc798ead435ad5e236c86a0dabc90c676581fb - RNPingPush: 4b4b0b67e23acf8dcec306cdc40faf15a64070c3 - RNPingStorage: 9ac4947abd71d06496584a78b73f3a169d622cb5 + RNPingBinding: 2d36e1a69ee11ee53f2bf98edb4968240429f335 + RNPingBrowser: abea36612516b9187c1841c3485e0b5eabe6bcce + RNPingCore: f4a461e75fbe3fccf3263e2b91938d9887324b32 + RNPingDavinci: f58fdf67db1830a7b7c41b583ab26a6b780cf794 + RNPingDeviceClient: 018ce1c81f671b37d943f464f799852dbacd4d9a + RNPingDeviceId: 2537972a09ab70d4a03a7aa644d07770c59cc79f + RNPingDeviceProfile: 44fde49a965fb86123fca5db6cfc3075d29a28db + RNPingExternalIdp: ba514170a01d4ba51d141dcfe41d332bd092e300 + RNPingFido: 6a17bfc93afcc20d5172c37ce39997a91e4576c6 + RNPingJourney: f2907549187da7e4120e814c8704d4e96dcbdaa0 + RNPingLogger: 493cca89cf636799f9e580115d46f37426afcbcb + RNPingOath: a84840889256fb6d360d6b0303b858fe6e5e1cd2 + RNPingOidc: 3b1217530a24d2ab011be52c187bb7a7d3ba1bd4 + RNPingProtect: db22b6fce139d3c9026797f10fb7a4366f468c69 + RNPingPush: e276582ce2ee815d39b40f4f91c9399c4238728c + RNPingStorage: 75308e520dd9e0f8faf0802b73aa89e403f7ea3a SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: daa1e4de4b971b977b23bc842aaa3e135324f1f3 -PODFILE CHECKSUM: e82b73fd0b3522b3daf9923083f34f443f2fbb78 +PODFILE CHECKSUM: 77b562ffbc1cd52fbd04b59a77f5f91ce5968332 -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/packages/fido/README.md b/packages/fido/README.md index 310a23522..aa42e5445 100644 --- a/packages/fido/README.md +++ b/packages/fido/README.md @@ -197,6 +197,68 @@ The optional `index` selects among multiple FIDO2 collectors with the same action. The methods return the native attestation or assertion payload for informational use; the native collector submits it when the flow advances. +### DaVinci ceremony failure propagation + +When a DaVinci ceremony fails, the native collector classifies the failure and +the rejection carries `FidoError.clientError`, a WebAuthn DOMException name +(for example `'NotAllowedError'` after a user cancellation). When +`clientError` is defined, report the failure to the server by advancing the +flow with `daVinci.next({ collectors: [] })` so the DaVinci flow can branch on +it; without propagation the failure stays client-side and the server never +observes it. Resolution failures such as `FIDO_COLLECTOR_NOT_FOUND` carry no +`clientError` and must not be propagated; keep the node rendered for retry. + +Always advance with an empty collector input. Never submit the node's +collectors after a failed ceremony: on a node that also contains a +`SUBMIT_BUTTON`, the submit action shadows the FIDO error and the server +receives a normal submit instead of the failure (the native event builder +picks a submit or flow collector's action first, before any FIDO error +action). + +```ts +import { FidoError } from '@ping-identity/rn-fido'; + +try { + if (collector.action === 'REGISTER') { + await fido.registerForDaVinci(daVinci, { index: 0 }); + } else { + await fido.authenticateForDaVinci(daVinci, { index: 0 }); + } + await daVinci.next({ collectors: [] }); +} catch (error) { + const err = FidoError.from(error); + if (err.clientError !== undefined) { + // The collector classified the failure; submit it so the flow can branch. + try { + await daVinci.next({ collectors: [] }); + } catch (propagationError) { + // Advancing the flow also failed; surface it without losing the + // original ceremony failure. + } + } + // Handle the failure in place, for example by showing err.message. A + // failure without clientError is not propagatable; keep the node rendered + // for retry. Do not propagate twice: once submitted, the failure state is + // consumed. +} +``` + +`FidoError.clientError` values and their typical outcomes: + +| `clientError` | Typical outcome | +| ------------------- | ---------------------------------------------------------------------- | +| `NotAllowedError` | User cancellation or dismissal of the passkey UI. | +| `TimeoutError` | The ceremony timed out. | +| `NotSupportedError` | Passkeys are unsupported on the device or blocked by policy. | +| `InvalidStateError` | The credential is invalid or already registered (excluded credential). | +| `UnknownError` | Any other failure, including an unrecognized native error. | + +`NotAllowedError`, `TimeoutError`, `NotSupportedError`, `InvalidStateError`, +and `UnknownError` are the only values iOS reports. Android reports an open +set: credential exceptions map to their WebAuthn DOMException names, so other +valid names (for example `SecurityError`) can appear. Unknown names pass +through to the server unmodified. + ## `useJourneyForm` integration When using `useJourneyForm`, pass `handledCallbackTypes` so FIDO fields are excluded from @@ -294,6 +356,21 @@ Stable error codes: - `FIDO_CALLBACK_NOT_FOUND` - `FIDO_COLLECTOR_NOT_FOUND` +DaVinci ceremony failures additionally carry `FidoError.clientError`, the +WebAuthn DOMException name reported by the native collector, typed as +`FidoClientErrorName`. The two fields classify the same failure at different +layers and can intentionally disagree: `code` is the bridge-local +classification (for example `FIDO_AUTHENTICATE_CANCELLED`), while +`clientError` is the server-parity value the DaVinci flow branches on (for +example, on Android a missing credential reports `clientError: 'UnknownError'` +next to `code: 'FIDO_AUTHENTICATE_CANCELLED'`). + +`clientError` is defined only on DaVinci ceremony failures that the collector +classified. Standalone and Journey rejections never carry it, and neither do +resolution failures such as `FIDO_COLLECTOR_NOT_FOUND`. See +[DaVinci ceremony failure propagation](#davinci-ceremony-failure-propagation) +for the propagation contract and the full value table. + ## Platform notes - `android.useFido2Client` is an Android-only override for the standalone `register` / `authenticate` operations. diff --git a/packages/fido/android/build.gradle b/packages/fido/android/build.gradle index 7be504ab3..daea84fc0 100644 --- a/packages/fido/android/build.gradle +++ b/packages/fido/android/build.gradle @@ -88,15 +88,15 @@ android { dependencies { implementation "com.facebook.react:react-android" - implementation("com.pingidentity.sdks:journey:2.1.0") - implementation("com.pingidentity.sdks:fido:2.1.0") - implementation("com.pingidentity.sdks:android:2.1.0") - implementation("com.pingidentity.sdks:logger:2.1.0") + implementation("com.pingidentity.sdks:journey:2.2.0") + implementation("com.pingidentity.sdks:fido:2.2.0") + implementation("com.pingidentity.sdks:android:2.2.0") + implementation("com.pingidentity.sdks:logger:2.2.0") implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0" implementation(project(":ping-identity_rn-core")) - compileOnly("com.pingidentity.sdks:davinci:2.1.0") // FIDO DaVinci collector types; compileOnly so apps without rn-davinci don't bundle it + compileOnly("com.pingidentity.sdks:davinci:2.2.0") // FIDO DaVinci collector types; compileOnly so apps without rn-davinci don't bundle it testImplementation "junit:junit:4.13.2" testImplementation "io.mockk:mockk:1.13.12" testImplementation "org.robolectric:robolectric:4.11.1" - testImplementation("com.pingidentity.sdks:davinci:2.1.0") + testImplementation("com.pingidentity.sdks:davinci:2.2.0") } diff --git a/packages/fido/android/src/main/java/com/pingidentity/rnfido/FidoClientErrorMapper.kt b/packages/fido/android/src/main/java/com/pingidentity/rnfido/FidoClientErrorMapper.kt new file mode 100644 index 000000000..b4334f11d --- /dev/null +++ b/packages/fido/android/src/main/java/com/pingidentity/rnfido/FidoClientErrorMapper.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +package com.pingidentity.rnfido + +/** + * Builds the JS-facing extras carried by a DaVinci FIDO ceremony rejection. + * + * The native DaVinci FIDO collectors record a WebAuthn DOMException name on + * `errorCode` when a ceremony fails (introduced in native SDK 2.2.0). The bridge forwards + * that name to JS as the `clientError` key of the rejection userInfo so apps can + * branch on the client-side outcome without changing the existing rejection + * codes. DaVinci-scoped only: standalone and Journey FIDO rejections carry no + * extras. + */ +internal object FidoClientErrorMapper { + + /** + * Key under which the client error name is placed in the rejection userInfo. + */ + const val EXTRA_CLIENT_ERROR = "clientError" + + /** + * Builds the rejection extras from the collector's recorded error code. + * + * @param errorCode WebAuthn DOMException name recorded by the native + * collector (`AbstractFidoCollector.errorCode`), or null when the ceremony + * failed without recording one. + * @param warn Called with a diagnostic message when the failure carries no + * client error code, so the dropped extra is never silent. + * @return A single-entry map with [EXTRA_CLIENT_ERROR] when `errorCode` is + * non-blank, otherwise an empty map. + */ + fun extras(errorCode: String?, warn: (String) -> Unit): Map { + if (errorCode.isNullOrBlank()) { + warn("FIDO DaVinci ceremony failed but the collector recorded no client error code") + return emptyMap() + } + return mapOf(EXTRA_CLIENT_ERROR to errorCode) + } +} diff --git a/packages/fido/android/src/main/java/com/pingidentity/rnfido/RNPingFidoCommon.kt b/packages/fido/android/src/main/java/com/pingidentity/rnfido/RNPingFidoCommon.kt index 4117f5e0c..aa6a06eae 100644 --- a/packages/fido/android/src/main/java/com/pingidentity/rnfido/RNPingFidoCommon.kt +++ b/packages/fido/android/src/main/java/com/pingidentity/rnfido/RNPingFidoCommon.kt @@ -6,6 +6,7 @@ */ package com.pingidentity.rnfido +import android.util.Log import androidx.annotation.VisibleForTesting import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReadableMap @@ -41,6 +42,7 @@ import kotlinx.serialization.json.put * Shared implementation for FIDO operations on Android. */ object RNPingFidoCommon { + private const val TAG = "RNPingFidoCommon" private const val LOGGER_ID_KEY = "loggerId" private const val USE_FIDO2_CLIENT_KEY = "useFido2Client" private const val FIDO2_COLLECTOR_TYPE = "FIDO2" @@ -196,7 +198,9 @@ object RNPingFidoCommon { errorCode = FidoErrorCodes.FIDO_REGISTER_ERROR, invalidOptionsMessage = "Invalid FIDO registration options payload.", defaultFailureMessage = "FIDO registration failed.", - operation = { client, input -> client.register(input) } + // Registration has no API selection in either SDK version; callConfig is + // unused and register always runs through Credential Manager. + operation = { client, input, _ -> client.register(input) } ) } @@ -229,7 +233,13 @@ object RNPingFidoCommon { errorCode = FidoErrorCodes.FIDO_AUTHENTICATE_ERROR, invalidOptionsMessage = "Invalid FIDO authentication options payload.", defaultFailureMessage = "FIDO authentication failed.", - operation = { client, input -> client.authenticate(input) } + // NOTE: SDK 2.2.0 moved API selection from FidoClientConfig.useFido2Client + // to the per-call FidoAuthenticateCustomizer. Applying it here keeps the JS + // useFido2Client contract unchanged (true GMS FIDO2 API, false Credential + // Manager, undefined auto-detect via the customizer's GMS default). + operation = { client, input, callConfig -> + client.authenticate(input) { callConfig.useFido2Client?.let { useFido2ApiClient = it } } + } ) } @@ -243,7 +253,7 @@ object RNPingFidoCommon { errorCode: String, invalidOptionsMessage: String, defaultFailureMessage: String, - operation: suspend (client: FidoClient, input: JsonObject) -> Result + operation: suspend (client: FidoClient, input: JsonObject, callConfig: CallConfig) -> Result ) { scope.launchBridge(promise, errorCode) { val input = try { @@ -257,8 +267,12 @@ object RNPingFidoCommon { ) return@launchBridge } - val client = createFidoClient(parseCallConfig(config)) - val result = operation(client, input) + // Each call builds a fresh client, so thread the parsed config through to + // the operation as well; per-call options (useFido2Client) are applied by + // the operation itself, not at client construction. + val callConfig = parseCallConfig(config) + val client = createFidoClient(callConfig) + val result = operation(client, input, callConfig) result.fold( onSuccess = { payload -> promise.resolve(JsonBridgeMapper.encodeJsonObject(payload)) @@ -471,12 +485,19 @@ object RNPingFidoCommon { promise.resolve(JsonBridgeMapper.encodeJsonObject(payload)) }, onFailure = { error -> + // The collector's errorCode is a WebAuthn DOMException name (set by + // handleError inside register()'s own onFailure); forwarded as the + // clientError extra so apps can propagate it via next({collectors: []}). + val extras = FidoClientErrorMapper.extras(collector.errorCode) { + collector.logger.w(it, null) + } rejectWithError( promise = promise, code = FidoErrorCodes.FIDO_REGISTER_ERROR, message = error.localizedMessage ?: "DaVinci FIDO registration ceremony failed.", - throwable = error + throwable = error, + extras = extras ) } ) @@ -537,12 +558,19 @@ object RNPingFidoCommon { promise.resolve(JsonBridgeMapper.encodeJsonObject(payload)) }, onFailure = { error -> + // The collector's errorCode is a WebAuthn DOMException name (set by + // handleError inside authenticate()'s own onFailure); forwarded as the + // clientError extra so apps can propagate it via next({collectors: []}). + val extras = FidoClientErrorMapper.extras(collector.errorCode) { + collector.logger.w(it, null) + } if (isRecoverableFidoAuthenticationFailure(error)) { rejectWithError( promise = promise, code = FidoErrorCodes.FIDO_AUTHENTICATE_CANCELLED, message = "FIDO authentication cancelled: ${error.localizedMessage ?: error}", - throwable = error + throwable = error, + extras = extras ) return@fold } @@ -551,7 +579,8 @@ object RNPingFidoCommon { code = FidoErrorCodes.FIDO_AUTHENTICATE_ERROR, message = error.localizedMessage ?: "DaVinci FIDO authentication ceremony failed.", - throwable = error + throwable = error, + extras = extras ) } ) @@ -582,13 +611,17 @@ object RNPingFidoCommon { /** * Rejects a promise with the shared FIDO error contract. + * + * @param extras Module-specific key/value pairs placed at the top level of the + * rejection userInfo (used for the DaVinci `clientError` extra). */ private fun rejectWithError( promise: Promise, code: String, message: String, type: ErrorType = ErrorType.FIDO_ERROR, - throwable: Throwable? = null + throwable: Throwable? = null, + extras: Map = emptyMap() ) { val mapped = throwable?.let { mapThrowableToGenericError(it, code) } val resolvedType = if (type == ErrorType.FIDO_ERROR) { @@ -600,11 +633,22 @@ object RNPingFidoCommon { val error = GenericError( type = resolvedType, error = code, - message = resolvedMessage + message = resolvedMessage, + extras = extras ) try { promise.reject(error, throwable) } catch (_: Throwable) { + // The registry itself may be the failure cause here; no logger is in scope. + Log.w( + TAG, + buildString { + append("FIDO rejection failed; falling back to plain reject") + if (extras.isNotEmpty()) { + append(", dropping userInfo extras (${extras.keys.joinToString()})") + } + } + ) promise.reject(code, resolvedMessage, throwable) } } @@ -621,11 +665,14 @@ object RNPingFidoCommon { /** * Builds a FIDO client using the currently configured runtime state. + * + * Only the logger is applied at construction: since SDK 2.2.0, + * `FidoClientConfig` has no `useFido2Client` property and API selection is a + * per-call authenticate customizer decision (see [authenticate]). */ private fun createFidoClient(callConfig: CallConfig): FidoClient { val clientConfig = FidoClientConfig().apply { resolveLoggerFromCore(callConfig.loggerId)?.let { logger = it } - callConfig.useFido2Client?.let { useFido2Client = it } } return FidoClient(clientConfig) } diff --git a/packages/fido/android/src/test/java/com/pingidentity/rnfido/FidoClientErrorMapperTest.kt b/packages/fido/android/src/test/java/com/pingidentity/rnfido/FidoClientErrorMapperTest.kt new file mode 100644 index 000000000..838591656 --- /dev/null +++ b/packages/fido/android/src/test/java/com/pingidentity/rnfido/FidoClientErrorMapperTest.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +package com.pingidentity.rnfido + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for the rejection extras built from a DaVinci FIDO collector's + * recorded client error code. + */ +class FidoClientErrorMapperTest { + + /** + * Ensures each known WebAuthn DOMException name is forwarded as the clientError + * extra without triggering the warning path. + */ + @Test + fun extrasContainsClientErrorForEachKnownName() { + listOf( + "NotAllowedError", + "TimeoutError", + "NotSupportedError", + "InvalidStateError", + "UnknownError", + ).forEach { name -> + val warnings = mutableListOf() + + val extras = FidoClientErrorMapper.extras(name) { warnings.add(it) } + + assertEquals(mapOf(FidoClientErrorMapper.EXTRA_CLIENT_ERROR to name), extras) + assertTrue(warnings.isEmpty()) + } + } + + /** + * Ensures unknown DOMException names pass through unchanged: the Android error + * set is open, so the bridge must not drop unrecognized names. + */ + @Test + fun extrasPassesThroughUnknownDomName() { + val warnings = mutableListOf() + + val extras = FidoClientErrorMapper.extras("SecurityError") { warnings.add(it) } + + assertEquals(mapOf(FidoClientErrorMapper.EXTRA_CLIENT_ERROR to "SecurityError"), extras) + assertTrue(warnings.isEmpty()) + } + + /** + * Ensures a null error code produces no extras and emits exactly one warning + * naming the dropped extra. + */ + @Test + fun extrasWarnsAndReturnsEmptyWhenErrorCodeNull() { + val warnings = mutableListOf() + + val extras = FidoClientErrorMapper.extras(null) { warnings.add(it) } + + assertTrue(extras.isEmpty()) + assertEquals(1, warnings.size) + } + + /** + * Ensures a blank error code behaves like a null one: no extras, one warning. + */ + @Test + fun extrasWarnsAndReturnsEmptyWhenErrorCodeBlank() { + val warnings = mutableListOf() + + val extras = FidoClientErrorMapper.extras(" ") { warnings.add(it) } + + assertTrue(extras.isEmpty()) + assertEquals(1, warnings.size) + } +} diff --git a/packages/fido/android/src/test/java/com/pingidentity/rnfido/FidoDaVinciErrorContractTest.kt b/packages/fido/android/src/test/java/com/pingidentity/rnfido/FidoDaVinciErrorContractTest.kt new file mode 100644 index 000000000..d767d9883 --- /dev/null +++ b/packages/fido/android/src/test/java/com/pingidentity/rnfido/FidoDaVinciErrorContractTest.kt @@ -0,0 +1,226 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +package com.pingidentity.rnfido + +import androidx.credentials.exceptions.CreateCredentialCancellationException +import androidx.credentials.exceptions.CreateCredentialUnsupportedException +import androidx.credentials.exceptions.GetCredentialCancellationException +import androidx.credentials.exceptions.NoCredentialException +import androidx.credentials.exceptions.domerrors.InvalidStateError +import androidx.credentials.exceptions.domerrors.TimeoutError +import androidx.credentials.exceptions.publickeycredential.CreatePublicKeyCredentialDomException +import androidx.credentials.exceptions.publickeycredential.GetPublicKeyCredentialDomException +import com.pingidentity.davinci.plugin.DaVinci +import com.pingidentity.fido.Constants +import com.pingidentity.fido.davinci.FidoAuthenticationCollector +import com.pingidentity.fido.davinci.FidoRegistrationCollector +import com.pingidentity.orchestrate.WorkflowConfig +import io.mockk.every +import io.mockk.mockk +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Drift guards over the native FIDO collectors' client-error contract (SDK 2.2.0): + * the WebAuthn DOMException name `handleError` records on `errorCode`, the + * `ActionKeyProvider` surface (event type, action key, empty payload sentinel), and + * the state reset on `close()`. + * + * These tests consume the real native collectors, so any upstream change to the + * error contract surfaces here first instead of silently changing what JS + * receives as `clientError`. Robolectric is required only because the collectors + * touch Android framework classes through the Credential Manager types. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [29]) +class FidoDaVinciErrorContractTest { + + private lateinit var registrationCollector: FidoRegistrationCollector + private lateinit var authenticationCollector: FidoAuthenticationCollector + + @Before + fun setUp() { + // WorkflowConfig's default logger is the no-op Logger, so the collectors' + // internal logging needs no stubbing beyond the config read. + val daVinci = mockk() + every { daVinci.config } returns WorkflowConfig() + + registrationCollector = FidoRegistrationCollector().apply { + davinci = daVinci + init(registrationInput()) + } + authenticationCollector = FidoAuthenticationCollector().apply { + davinci = daVinci + init(authenticationInput()) + } + } + + // MARK: - handleError -> errorCode mapping + + /** + * Ensures a create-side cancellation maps to NotAllowedError, the same name + * the bridge forwards as clientError for register cancellations. + */ + @Test + fun registerHandleErrorMapsCreateCancellationToNotAllowed() { + registrationCollector.handleError(CreateCredentialCancellationException()) + + assertEquals("NotAllowedError", registrationCollector.errorCode) + } + + /** + * Ensures a create-side unsupported result maps to NotSupportedError. + */ + @Test + fun registerHandleErrorMapsCreateUnsupportedToNotSupported() { + registrationCollector.handleError(CreateCredentialUnsupportedException()) + + assertEquals("NotSupportedError", registrationCollector.errorCode) + } + + /** + * Ensures a get-side DomException's domError simple name becomes the errorCode, + * exercising the open set: TimeoutError comes straight from the DomError name. + */ + @Test + fun authenticateHandleErrorMapsGetDomTimeoutToTimeoutError() { + authenticationCollector.handleError( + GetPublicKeyCredentialDomException(TimeoutError()) + ) + + assertEquals("TimeoutError", authenticationCollector.errorCode) + } + + /** + * Ensures a create-side DomException's domError name is recorded, exercising + * InvalidStateError through the create path. + */ + @Test + fun registerHandleErrorMapsCreateDomInvalidStateToInvalidState() { + registrationCollector.handleError( + CreatePublicKeyCredentialDomException(InvalidStateError()) + ) + + assertEquals("InvalidStateError", registrationCollector.errorCode) + } + + /** + * Ensures an unexpected non-Credential-Manager failure still produces a + * errorCode: the open-set fallback UnknownError. + */ + @Test + fun handleErrorMapsUnexpectedThrowableToUnknown() { + registrationCollector.handleError(IllegalStateException("boom")) + + assertEquals("UnknownError", registrationCollector.errorCode) + } + + /** + * Ensures NoCredentialException has no dedicated handleError branch and falls + * through to UnknownError: the bridge keeps its own + * FIDO_AUTHENTICATE_CANCELLED classification while clientError reports the + * server-parity UnknownError, and the two intentionally disagree. + */ + @Test + fun authenticateHandleErrorMapsNoCredentialToUnknown() { + authenticationCollector.handleError(NoCredentialException()) + + assertEquals("UnknownError", authenticationCollector.errorCode) + } + + // MARK: - ActionKeyProvider surface while errorCode is set + + /** + * Ensures a recorded error code flips the collector's event type to "action", + * exposes the code as the action key, and switches payload() to the non-null + * empty-object sentinel that makes the collector win Collectors.eventType. + */ + @Test + fun erroredCollectorExposesActionKeySurface() { + authenticationCollector.handleError(GetCredentialCancellationException()) + + assertEquals("action", authenticationCollector.eventType()) + assertEquals("NotAllowedError", authenticationCollector.actionKey) + assertEquals(buildJsonObject { }, authenticationCollector.payload()) + } + + /** + * Ensures the event type matches the SDK's own constant, so a constant + * rename surfaces as a drift failure rather than a silent contract change. + */ + @Test + fun erroredAuthenticationCollectorEventTypeMatchesConstant() { + authenticationCollector.handleError(GetCredentialCancellationException()) + + assertEquals(Constants.EVENT_TYPE_ACTION, authenticationCollector.eventType()) + } + + // MARK: - State reset + + /** + * Ensures close() clears the recorded error code: the next submit reports the + * plain submit surface with no action key and a null payload, so a consumed + * error cannot leak into a later event. + */ + @Test + fun closeResetsErrorState() { + authenticationCollector.handleError(GetCredentialCancellationException()) + assertEquals("NotAllowedError", authenticationCollector.errorCode) + + authenticationCollector.close() + + assertNull(authenticationCollector.errorCode) + assertEquals("submit", authenticationCollector.eventType()) + assertNull(authenticationCollector.actionKey) + assertNull(authenticationCollector.payload()) + } + + /** + * Builds a registration collector payload with server-style int-array binary fields. + */ + private fun registrationInput() = buildJsonObject { + put("type", JsonPrimitive("FIDO2")) + put("key", JsonPrimitive("fido-register-key")) + put("label", JsonPrimitive("Set up passkeys")) + put("trigger", JsonPrimitive("submit")) + put("required", true) + put( + Constants.FIELD_PUBLIC_KEY_CREDENTIAL_CREATION_OPTIONS, + buildJsonObject { + put("rp", buildJsonObject { put("id", JsonPrimitive("example.com")) }) + put("challenge", JsonArray(listOf(JsonPrimitive(72), JsonPrimitive(101)))) + } + ) + } + + /** + * Builds an authentication collector payload in the same server style. + */ + private fun authenticationInput() = buildJsonObject { + put("type", JsonPrimitive("FIDO2")) + put("key", JsonPrimitive("fido-authenticate-key")) + put("label", JsonPrimitive("Sign in with passkey")) + put("trigger", JsonPrimitive("submit")) + put("required", true) + put( + Constants.FIELD_PUBLIC_KEY_CREDENTIAL_REQUEST_OPTIONS, + buildJsonObject { + put("rp", buildJsonObject { put("id", JsonPrimitive("example.com")) }) + put("challenge", JsonArray(listOf(JsonPrimitive(72), JsonPrimitive(101)))) + } + ) + } +} diff --git a/packages/fido/android/src/test/java/com/pingidentity/rnfido/RNPingFidoTest.kt b/packages/fido/android/src/test/java/com/pingidentity/rnfido/RNPingFidoTest.kt index e145b5d98..d4e1624af 100644 --- a/packages/fido/android/src/test/java/com/pingidentity/rnfido/RNPingFidoTest.kt +++ b/packages/fido/android/src/test/java/com/pingidentity/rnfido/RNPingFidoTest.kt @@ -7,6 +7,11 @@ package com.pingidentity.rnfido import androidx.credentials.exceptions.GetCredentialCancellationException +import androidx.credentials.exceptions.NoCredentialException +import androidx.credentials.exceptions.publickeycredential.GetPublicKeyCredentialDomException +import androidx.credentials.exceptions.domerrors.TimeoutError +import androidx.credentials.exceptions.domerrors.InvalidStateError +import androidx.credentials.exceptions.CreateCredentialCancellationException import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.JavaOnlyArray import com.facebook.react.bridge.JavaOnlyMap @@ -16,6 +21,7 @@ import com.facebook.soloader.SoLoader import com.facebook.soloader.nativeloader.NativeLoader import com.facebook.soloader.nativeloader.SystemDelegate import com.pingidentity.fido.davinci.FidoAuthenticationCollector +import com.pingidentity.fido.davinci.FidoRegistrationCollector import com.pingidentity.rncore.CoreRuntime import com.pingidentity.rncore.DaVinciCollectorResolver import com.pingidentity.rncore.utils.JsonBridgeMapper @@ -26,9 +32,14 @@ import io.mockk.mockkObject import io.mockk.mockkStatic import io.mockk.unmockkObject import io.mockk.unmockkStatic +import io.mockk.verify import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.After import org.junit.Before @@ -37,6 +48,7 @@ import org.junit.runner.RunWith import org.robolectric.RuntimeEnvironment import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowLog /** * Unit tests for FIDO module metadata and bridge behavior. @@ -329,23 +341,309 @@ class RNPingFidoTest { assertEquals(FidoErrorCodes.FIDO_ACTIVITY_UNAVAILABLE, promise.rejectedCode) } + // MARK: - DaVinci clientError extras + /** - * Ensures DaVinci authentication maps a user cancellation to the stable - * cancelled code rather than the generic authentication error. + * Ensures a failed DaVinci registration rejection carries the collector's + * recorded client error code in the userInfo, with the stable register code unchanged. */ @Test - fun authenticateForDaVinciRejectsWithCancelledWhenUserCancels() { + fun registerForDaVinciRejectionCarriesClientError() { + val collector = mockk() + coEvery { collector.register(any()) } returns Result.failure( + CreateCredentialCancellationException("Cancelled by user") + ) + every { collector.errorCode } returns "NotAllowedError" + every { collector.logger } returns mockk(relaxed = true) + CoreRuntime.davinciCollectorResolver = { listOf(collector) } + val promise = TestPromise() + + RNPingFidoCommon.registerForDaVinci("dv-1", JavaOnlyMap(), JavaOnlyMap(), promise) + + assertTrue(promise.await()) + assertEquals(FidoErrorCodes.FIDO_REGISTER_ERROR, promise.rejectedCode) + assertEquals("NotAllowedError", promise.rejectedUserInfo?.getString("clientError")) + } + + /** + * Ensures a cancelled DaVinci authentication keeps the stable cancelled code + * while carrying the collector's clientError extra. + */ + @Test + fun authenticateForDaVinciCancelledKeepsCodeAndCarriesClientError() { val collector = mockk() coEvery { collector.authenticate(any()) } returns Result.failure( GetCredentialCancellationException("Cancelled by user") ) + every { collector.errorCode } returns "NotAllowedError" + every { collector.logger } returns mockk(relaxed = true) + CoreRuntime.davinciCollectorResolver = { listOf(collector) } + val promise = TestPromise() + + RNPingFidoCommon.authenticateForDaVinci("dv-1", JavaOnlyMap(), JavaOnlyMap(), promise) + + assertTrue(promise.await()) + assertEquals(FidoErrorCodes.FIDO_AUTHENTICATE_CANCELLED, promise.rejectedCode) + assertEquals("NotAllowedError", promise.rejectedUserInfo?.getString("clientError")) + } + + /** + * Ensures the NoCredentialException case keeps the bridge-local + * cancelled code while clientError reports the server-parity UnknownError that + * the native handleError fallback records; the two intentionally disagree. + */ + @Test + fun authenticateForDaVinciNoCredentialMapsToCancelledWithUnknownError() { + val collector = mockk() + coEvery { collector.authenticate(any()) } returns Result.failure( + NoCredentialException("No eligible passkey credential") + ) + every { collector.errorCode } returns "UnknownError" + every { collector.logger } returns mockk(relaxed = true) + CoreRuntime.davinciCollectorResolver = { listOf(collector) } + val promise = TestPromise() + + RNPingFidoCommon.authenticateForDaVinci("dv-1", JavaOnlyMap(), JavaOnlyMap(), promise) + + assertTrue(promise.await()) + assertEquals(FidoErrorCodes.FIDO_AUTHENTICATE_CANCELLED, promise.rejectedCode) + assertEquals("UnknownError", promise.rejectedUserInfo?.getString("clientError")) + } + + /** + * Ensures a timed-out DaVinci authentication carries the OS-reported + * TimeoutError name (open Android set) as clientError under the stable + * authenticate error code. + */ + @Test + fun authenticateForDaVinciTimeoutCarriesTimeoutError() { + val collector = mockk() + coEvery { collector.authenticate(any()) } returns Result.failure( + GetPublicKeyCredentialDomException(TimeoutError()) + ) + every { collector.errorCode } returns "TimeoutError" + every { collector.logger } returns mockk(relaxed = true) + CoreRuntime.davinciCollectorResolver = { listOf(collector) } + val promise = TestPromise() + + RNPingFidoCommon.authenticateForDaVinci("dv-1", JavaOnlyMap(), JavaOnlyMap(), promise) + + assertTrue(promise.await()) + assertEquals(FidoErrorCodes.FIDO_AUTHENTICATE_ERROR, promise.rejectedCode) + assertEquals("TimeoutError", promise.rejectedUserInfo?.getString("clientError")) + } + + /** + * Ensures an invalid-state DaVinci authentication carries InvalidStateError as + * clientError with the generic authenticate error code unchanged. + */ + @Test + fun authenticateForDaVinciInvalidStateCarriesInvalidStateError() { + val collector = mockk() + coEvery { collector.authenticate(any()) } returns Result.failure( + GetPublicKeyCredentialDomException(InvalidStateError()) + ) + every { collector.errorCode } returns "InvalidStateError" + every { collector.logger } returns mockk(relaxed = true) + CoreRuntime.davinciCollectorResolver = { listOf(collector) } + val promise = TestPromise() + + RNPingFidoCommon.authenticateForDaVinci("dv-1", JavaOnlyMap(), JavaOnlyMap(), promise) + + assertTrue(promise.await()) + assertEquals(FidoErrorCodes.FIDO_AUTHENTICATE_ERROR, promise.rejectedCode) + assertEquals("InvalidStateError", promise.rejectedUserInfo?.getString("clientError")) + } + + /** + * Ensures a failure without a recorded client error code yields no clientError + * extra and logs a warning on the collector's logger (no-silent-failures rule). + */ + @Test + fun daVinciRejectionWithoutErrorCodeHasNoClientErrorAndLogsWarning() { + val collector = mockk() + val logger = mockk() + coEvery { collector.authenticate(any()) } returns Result.failure( + IllegalStateException("unexpected failure") + ) + every { collector.errorCode } returns null + every { collector.logger } returns logger + CoreRuntime.davinciCollectorResolver = { listOf(collector) } + val promise = TestPromise() + + RNPingFidoCommon.authenticateForDaVinci("dv-1", JavaOnlyMap(), JavaOnlyMap(), promise) + + assertTrue(promise.await()) + assertEquals(FidoErrorCodes.FIDO_AUTHENTICATE_ERROR, promise.rejectedCode) + assertNull(promise.rejectedUserInfo?.getString("clientError")) + verify(exactly = 1) { + logger.w( + "FIDO DaVinci ceremony failed but the collector recorded no client error code", + null + ) + } + } + + /** + * Ensures the bridge keeps no error state between calls: a failure followed by + * a success on the same mocked collector resolves the second call with the + * collector payload and no clientError leakage from the first call. + */ + @Test + fun daVinciFailureThenSuccessOnSameCollectorResolvesWithoutClientError() { + val collector = mockk() + coEvery { collector.authenticate(any()) } returnsMany listOf( + Result.failure(GetCredentialCancellationException("Cancelled by user")), + Result.success( + buildJsonObject { put("assertion", JsonPrimitive("assertion-value")) } + ) + ) + every { collector.errorCode } returnsMany listOf("NotAllowedError", null) + every { collector.logger } returns mockk(relaxed = true) + CoreRuntime.davinciCollectorResolver = { listOf(collector) } + + val failurePromise = TestPromise() + RNPingFidoCommon.authenticateForDaVinci("dv-1", JavaOnlyMap(), JavaOnlyMap(), failurePromise) + assertTrue(failurePromise.await()) + assertEquals(FidoErrorCodes.FIDO_AUTHENTICATE_CANCELLED, failurePromise.rejectedCode) + assertEquals("NotAllowedError", failurePromise.rejectedUserInfo?.getString("clientError")) + + val secondPromise = TestPromise() + RNPingFidoCommon.authenticateForDaVinci("dv-1", JavaOnlyMap(), JavaOnlyMap(), secondPromise) + assertTrue(secondPromise.await()) + assertNull(secondPromise.rejectedCode) + val resolved = secondPromise.resolvedValue as WritableMap + assertEquals("assertion-value", resolved.getString("assertion")) + } + + /** + * Ensures a successful DaVinci authentication resolves with the collector + * payload and no clientError, pinning the happy path unchanged. + */ + @Test + fun daVinciSuccessResolvesPayloadUnchanged() { + val collector = mockk() + coEvery { collector.authenticate(any()) } returns Result.success( + buildJsonObject { put("assertion", JsonPrimitive("assertion-value")) } + ) + every { collector.errorCode } returns null + every { collector.logger } returns mockk(relaxed = true) CoreRuntime.davinciCollectorResolver = { listOf(collector) } val promise = TestPromise() RNPingFidoCommon.authenticateForDaVinci("dv-1", JavaOnlyMap(), JavaOnlyMap(), promise) + assertTrue(promise.await()) + assertNull(promise.rejectedCode) + assertNull(promise.rejectedUserInfo) + val resolved = promise.resolvedValue as WritableMap + assertEquals("assertion-value", resolved.getString("assertion")) + } + + /** + * Ensures the fallback path inside rejectWithError is not silent: when the + * GenericError-based reject itself throws, the bridge logs a warning naming the + * dropped userInfo extras before falling back to the plain reject, which loses + * clientError. + */ + @Test + fun daVinciRejectionFallbackLogsWarningWhenRejectFails() { + val collector = mockk() + coEvery { collector.authenticate(any()) } returns Result.failure( + GetCredentialCancellationException("Cancelled by user") + ) + every { collector.errorCode } returns "NotAllowedError" + every { collector.logger } returns mockk(relaxed = true) + CoreRuntime.davinciCollectorResolver = { listOf(collector) } + ShadowLog.setupLogging() + ShadowLog.clear() + val promise = ThrowingRejectPromise() + + RNPingFidoCommon.authenticateForDaVinci("dv-1", JavaOnlyMap(), JavaOnlyMap(), promise) + assertTrue(promise.await()) assertEquals(FidoErrorCodes.FIDO_AUTHENTICATE_CANCELLED, promise.rejectedCode) + val warnings = ShadowLog.getLogsForTag("RNPingFidoCommon") + .filter { it.msg.contains("dropping userInfo extras (clientError)") } + assertTrue(warnings.isNotEmpty()) + } + + /** + * Promise test helper whose GenericError-based reject throws, forcing the + * rejectWithError fallback onto the plain 3-arg reject. + */ + private class ThrowingRejectPromise : Promise { + private val latch = CountDownLatch(1) + + var rejectedCode: String? = null + private set + var rejectedMessage: String? = null + private set + var rejectedThrowable: Throwable? = null + private set + + fun await(timeoutMs: Long = 2_000): Boolean { + return latch.await(timeoutMs, TimeUnit.MILLISECONDS) + } + + override fun resolve(value: Any?) = Unit + + override fun reject(code: String, message: String?) { + rejectedCode = code + rejectedMessage = message + latch.countDown() + } + + override fun reject(code: String, throwable: Throwable?) { + rejectedCode = code + rejectedThrowable = throwable + latch.countDown() + } + + override fun reject(code: String, message: String?, throwable: Throwable?) { + rejectedCode = code + rejectedMessage = message + rejectedThrowable = throwable + latch.countDown() + } + + override fun reject(throwable: Throwable) { + rejectedCode = "EUNSPECIFIED" + rejectedThrowable = throwable + latch.countDown() + } + + override fun reject(throwable: Throwable, userInfo: WritableMap) { + throw IllegalStateException("Simulated registry failure") + } + + override fun reject(code: String, userInfo: WritableMap) { + throw IllegalStateException("Simulated registry failure") + } + + override fun reject(code: String, throwable: Throwable?, userInfo: WritableMap) { + throw IllegalStateException("Simulated registry failure") + } + + override fun reject(code: String, message: String?, userInfo: WritableMap) { + throw IllegalStateException("Simulated registry failure") + } + + override fun reject( + code: String?, + message: String?, + throwable: Throwable?, + userInfo: WritableMap? + ) { + throw IllegalStateException("Simulated registry failure") + } + + @Suppress("DEPRECATION") + override fun reject(message: String) { + rejectedCode = "EUNSPECIFIED" + rejectedMessage = message + latch.countDown() + } } /** @@ -362,6 +660,8 @@ class RNPingFidoTest { private set var rejectedThrowable: Throwable? = null private set + var rejectedUserInfo: WritableMap? = null + private set fun await(timeoutMs: Long = 2_000): Boolean { return latch.await(timeoutMs, TimeUnit.MILLISECONDS) @@ -398,23 +698,27 @@ class RNPingFidoTest { override fun reject(throwable: Throwable, userInfo: WritableMap) { rejectedThrowable = throwable + rejectedUserInfo = userInfo latch.countDown() } override fun reject(code: String, userInfo: WritableMap) { rejectedCode = code + rejectedUserInfo = userInfo latch.countDown() } override fun reject(code: String, throwable: Throwable?, userInfo: WritableMap) { rejectedCode = code rejectedThrowable = throwable + rejectedUserInfo = userInfo latch.countDown() } override fun reject(code: String, message: String?, userInfo: WritableMap) { rejectedCode = code rejectedMessage = message + rejectedUserInfo = userInfo latch.countDown() } @@ -427,6 +731,7 @@ class RNPingFidoTest { rejectedCode = code rejectedMessage = message rejectedThrowable = throwable + rejectedUserInfo = userInfo latch.countDown() } diff --git a/packages/fido/ios/FidoClientErrorMapper.swift b/packages/fido/ios/FidoClientErrorMapper.swift new file mode 100644 index 000000000..d06edfcf2 --- /dev/null +++ b/packages/fido/ios/FidoClientErrorMapper.swift @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +import Foundation + +/// Builds the JS-facing `clientError` payload carried by a DaVinci FIDO ceremony rejection. +/// +/// The native DaVinci FIDO collectors record a WebAuthn DOMException name on `errorCode` +/// when a ceremony fails (`AbstractFidoCollector.handleError`). The bridge forwards that +/// name to JS as the `clientError` key of the rejection userInfo so apps can branch on +/// the client-side outcome without changing the existing rejection codes. +/// DaVinci-scoped only: standalone and Journey FIDO rejections carry no extras. +/// +/// - Note: Stateless and pure; `errorCode` is read on the main actor where the ceremony +/// completes. The iOS collector's error name set is closed at five WebAuthn +/// DOMException names while Android's is open; both are forwarded verbatim. +enum FidoClientErrorMapper { + + /// Key under which the client error name is placed in the rejection userInfo. + static let extraKey = "clientError" + + /// Builds the rejection extras from the collector's recorded error code. + /// + /// - Parameters: + /// - errorCode: WebAuthn DOMException name recorded by the native collector + /// (`AbstractFidoCollector.errorCode`), or `nil` when the ceremony failed + /// without recording one. + /// - warn: Called with a diagnostic message when the failure carries no client + /// error code, so the dropped extra is never silent. + /// - Returns: A single-entry dictionary with `extraKey` when `errorCode` is + /// non-blank, otherwise `nil`. + static func extras(errorCode: String?, warn: (String) -> Void) -> [String: String]? { + guard let errorCode, !errorCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + warn("FIDO DaVinci ceremony failed but the collector recorded no client error code") + return nil + } + return [extraKey: errorCode] + } + + /// Merges the rejection extras into the underlying native error's userInfo. + /// + /// `PromiseBridge.reject` forwards the underlying NSError to JS in preference to the + /// `GenericError` payload, so the extras must ride inside its userInfo to reach the + /// rejection's userInfo map. The original domain and code are preserved unchanged. + /// + /// - Parameters: + /// - error: Native error thrown by the ceremony. + /// - extras: Rejection extras to merge; `nil` or empty leaves the error unchanged. + /// - Returns: An `NSError` with the original domain and code and the extras merged + /// into its userInfo. + static func underlying(_ error: Error, extras: [String: String]?) -> NSError { + let nsError = error as NSError + guard let extras, !extras.isEmpty else { + return nsError + } + var userInfo = nsError.userInfo + extras.forEach { userInfo[$0.key] = $0.value } + return NSError(domain: nsError.domain, code: nsError.code, userInfo: userInfo) + } +} diff --git a/packages/fido/ios/RNPingFidoCommon.swift b/packages/fido/ios/RNPingFidoCommon.swift index 66034b63b..8bbc76ad7 100644 --- a/packages/fido/ios/RNPingFidoCommon.swift +++ b/packages/fido/ios/RNPingFidoCommon.swift @@ -470,18 +470,26 @@ public class RNPingFidoCommon: NSObject { return } - let result = await matching[collectorIndex].register(window: window) + let collector = matching[collectorIndex] + let result = await collector.register(window: window) switch result { case .success(let payload): handlers.resolve(JsonBridgeMapper.encodeJsonObject(payload)) case .failure(let error): + // The collector's errorCode is a WebAuthn DOMException name (set by + // handleError inside register()'s own failure path); forwarded as the + // clientError extra so apps can propagate it via next({collectors: []}). + let extras = FidoClientErrorMapper.extras(errorCode: collector.errorCode) { + collector.logger.w($0, error: nil) + } handlers.reject( GenericError( type: .fidoError, error: FidoErrorCode.registerError.rawValue, - message: error.localizedDescription + message: error.localizedDescription, + extras: extras ), - underlying: error as NSError + underlying: FidoClientErrorMapper.underlying(error, extras: extras) ) } } @@ -557,11 +565,18 @@ public class RNPingFidoCommon: NSObject { return } - let result = await matching[collectorIndex].authenticate(window: window) + let collector = matching[collectorIndex] + let result = await collector.authenticate(window: window) switch result { case .success(let payload): handlers.resolve(JsonBridgeMapper.encodeJsonObject(payload)) case .failure(let error): + // The collector's errorCode is a WebAuthn DOMException name (set by + // handleError inside authenticate()'s own failure path); forwarded as the + // clientError extra so apps can propagate it via next({collectors: []}). + let extras = FidoClientErrorMapper.extras(errorCode: collector.errorCode) { + collector.logger.w($0, error: nil) + } let code = isRecoverableFidoAuthenticationFailure(error) ? FidoErrorCode.authenticateCancelled : FidoErrorCode.authenticateError @@ -569,9 +584,10 @@ public class RNPingFidoCommon: NSObject { GenericError( type: .fidoError, error: code.rawValue, - message: error.localizedDescription + message: error.localizedDescription, + extras: extras ), - underlying: error as NSError + underlying: FidoClientErrorMapper.underlying(error, extras: extras) ) } } diff --git a/packages/fido/ios/Tests/FidoClientErrorMapperTests.swift b/packages/fido/ios/Tests/FidoClientErrorMapperTests.swift new file mode 100644 index 000000000..47c48d457 --- /dev/null +++ b/packages/fido/ios/Tests/FidoClientErrorMapperTests.swift @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import AuthenticationServices +import XCTest +@testable import RNPingFido + +/// Unit tests for the rejection extras built from a DaVinci FIDO collector's +/// recorded client error code, and for the underlying-error merge that +/// carries the extras to JS through `PromiseBridge.reject`. +final class FidoClientErrorMapperTests: XCTestCase { + + // MARK: - Extras + + /// Ensures each canonical WebAuthn DOMException name is forwarded as the + /// clientError extra without triggering the warning path. + func testExtrasForEachKnownName() { + for name in [ + "NotAllowedError", "TimeoutError", "NotSupportedError", "InvalidStateError", "UnknownError", + ] { + var warnings: [String] = [] + + let extras = FidoClientErrorMapper.extras(errorCode: name) { warnings.append($0) } + + XCTAssertEqual(extras, [FidoClientErrorMapper.extraKey: name]) + XCTAssertTrue(warnings.isEmpty) + } + } + + /// Ensures unrecognized DOMException names pass through unchanged: the + /// mapper is a verbatim pass-through, so names the SDK starts emitting in + /// the future are not dropped at the bridge. + func testExtrasPassesThroughUnknownName() { + var warnings: [String] = [] + + let extras = FidoClientErrorMapper.extras(errorCode: "SecurityError") { warnings.append($0) } + + XCTAssertEqual(extras, [FidoClientErrorMapper.extraKey: "SecurityError"]) + XCTAssertTrue(warnings.isEmpty) + } + + /// Ensures a nil, empty, or whitespace-only error code produces no extras + /// and emits exactly one warning naming the dropped extra. + func testExtrasWarnsAndReturnsNilWhenErrorCodeNilOrBlank() { + for errorCode in [nil as String?, "", " "] { + var warnings: [String] = [] + + let extras = FidoClientErrorMapper.extras(errorCode: errorCode) { warnings.append($0) } + + XCTAssertNil(extras) + XCTAssertEqual(warnings.count, 1) + } + } + + // MARK: - Underlying error merge + + /// Ensures the merge preserves the original domain, code, and userInfo + /// while adding the clientError extra. + func testUnderlyingMergesExtrasAndPreservesDomainCodeAndUserInfo() { + let original = NSError(domain: "TestDomain", code: 42, userInfo: ["existing": "value"]) + + let merged = FidoClientErrorMapper.underlying( + original, + extras: [FidoClientErrorMapper.extraKey: "TimeoutError"] + ) + + XCTAssertEqual(merged.domain, "TestDomain") + XCTAssertEqual(merged.code, 42) + XCTAssertEqual(merged.userInfo["existing"] as? String, "value") + XCTAssertEqual(merged.userInfo[FidoClientErrorMapper.extraKey] as? String, "TimeoutError") + } + + /// Ensures nil or empty extras leave the error unchanged instead of + /// rebuilding it with an empty userInfo merge. + func testUnderlyingWithoutExtrasLeavesErrorUnchanged() { + let original = NSError(domain: "TestDomain", code: 42, userInfo: ["existing": "value"]) + + for merged in [ + FidoClientErrorMapper.underlying(original, extras: nil), + FidoClientErrorMapper.underlying(original, extras: [:]), + ] { + XCTAssertEqual(merged.domain, "TestDomain") + XCTAssertEqual(merged.code, 42) + XCTAssertEqual(merged.userInfo["existing"] as? String, "value") + XCTAssertNil(merged.userInfo[FidoClientErrorMapper.extraKey]) + } + } + + /// Ensures the production input shape keeps its authorization identity: + /// DaVinci rejections carry the bridged ASAuthorization error's domain and + /// code, with the extras riding inside its userInfo. + func testUnderlyingPreservesAuthorizationErrorIdentity() { + let canceled = ASAuthorizationError(.canceled) + + let merged = FidoClientErrorMapper.underlying( + canceled, + extras: [FidoClientErrorMapper.extraKey: "NotAllowedError"] + ) + + XCTAssertEqual(merged.domain, ASAuthorizationError.errorDomain) + XCTAssertEqual(merged.code, ASAuthorizationError.canceled.rawValue) + XCTAssertEqual(merged.userInfo[FidoClientErrorMapper.extraKey] as? String, "NotAllowedError") + } +} diff --git a/packages/fido/ios/Tests/FidoDaVinciErrorContractTests.swift b/packages/fido/ios/Tests/FidoDaVinciErrorContractTests.swift new file mode 100644 index 000000000..ba12ebb73 --- /dev/null +++ b/packages/fido/ios/Tests/FidoDaVinciErrorContractTests.swift @@ -0,0 +1,222 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import AuthenticationServices +import XCTest +import PingFido + +/// Drift guards over the native FIDO collectors' client-error contract at the +/// pinned SDK commit: the WebAuthn DOMException name `handleError(error:)` +/// records on `errorCode`, the `ActionKeyProvider` surface (event type, action +/// key, empty payload sentinel), and the state reset on `close()`. +/// +/// These tests consume the real native collectors, so any upstream change to +/// the error contract surfaces here first instead of silently changing what +/// JS receives as `clientError`. Mirrors the Android +/// `FidoDaVinciErrorContractTest` for cross-platform parity. +final class FidoDaVinciErrorContractTests: XCTestCase { + + private var registrationCollector: FidoRegistrationCollector! + private var authenticationCollector: FidoAuthenticationCollector! + + override func setUp() { + super.setUp() + // The collectors' default logger falls back to LogManager's NoneLogger + // when no DaVinci instance is attached, so no mocking is needed. + registrationCollector = FidoRegistrationCollector(with: makeRegistrationJson()) + authenticationCollector = FidoAuthenticationCollector(with: makeAuthenticationJson()) + } + + // MARK: - handleError -> errorCode mapping (FidoError inputs) + + /// Ensures a timeout maps to TimeoutError. + func testHandleErrorMapsTimeoutToTimeoutError() { + let transformed = registrationCollector.handleError(error: FidoError.timeout) + + XCTAssertEqual(FidoError.timeout, transformed) + XCTAssertEqual("TimeoutError", registrationCollector.errorCode) + } + + /// Ensures the unsupported-action family maps to NotSupportedError. + func testHandleErrorMapsUnsupportedFamilyToNotSupportedError() { + let cases: [(FidoError, String)] = [ + (FidoError.unsupportedAction("no"), "unsupportedAction"), + (FidoError.invalidAction, "invalidAction"), + (FidoError.missingParameters("none"), "missingParameters"), + ] + for (error, label) in cases { + let collector = FidoAuthenticationCollector(with: makeAuthenticationJson()) + + let transformed = collector.handleError(error: error) + + XCTAssertEqual(error, transformed, label) + XCTAssertEqual("NotSupportedError", collector.errorCode, label) + } + } + + /// Ensures invalid response and challenge map to InvalidStateError. + func testHandleErrorMapsInvalidResponseAndChallengeToInvalidStateError() { + let challengeCollector = FidoAuthenticationCollector(with: makeAuthenticationJson()) + + _ = registrationCollector.handleError(error: FidoError.invalidResponse) + _ = challengeCollector.handleError(error: FidoError.invalidChallenge) + + XCTAssertEqual("InvalidStateError", registrationCollector.errorCode) + XCTAssertEqual("InvalidStateError", challengeCollector.errorCode) + } + + /// Ensures an invalid window maps to the open-set fallback UnknownError. + func testHandleErrorMapsInvalidWindowToUnknownError() { + let transformed = registrationCollector.handleError(error: FidoError.invalidWindow) + + XCTAssertEqual(FidoError.invalidWindow, transformed) + XCTAssertEqual("UnknownError", registrationCollector.errorCode) + } + + // MARK: - handleError -> errorCode mapping (ASAuthorizationError inputs) + + /// Ensures OS-reported cancellation and generic failure map to + /// NotAllowedError. The canceled case returns + /// `FidoError.unsupportedAction(FidoConstants.ERROR_NOT_ALLOWED_MESSAGE)`, + /// the exact shape `isRecoverableFidoAuthenticationFailure` matches for the + /// FIDO_AUTHENTICATE_CANCELLED classification. + func testHandleErrorMapsCanceledAndFailedToNotAllowedError() throws { + let canceledCollector = FidoAuthenticationCollector(with: makeAuthenticationJson()) + let failedCollector = FidoAuthenticationCollector(with: makeAuthenticationJson()) + + let canceled = canceledCollector.handleError(error: ASAuthorizationError(.canceled)) + _ = failedCollector.handleError(error: ASAuthorizationError(.failed)) + + XCTAssertEqual( + FidoError.unsupportedAction(FidoConstants.ERROR_NOT_ALLOWED_MESSAGE), + canceled + ) + XCTAssertEqual("NotAllowedError", canceledCollector.errorCode) + XCTAssertEqual("NotAllowedError", failedCollector.errorCode) + } + + /// Ensures an invalid OS response maps to InvalidStateError. + func testHandleErrorMapsASInvalidResponseToInvalidStateError() { + let transformed = authenticationCollector.handleError(error: ASAuthorizationError(.invalidResponse)) + + XCTAssertEqual(FidoError.invalidResponse, transformed) + XCTAssertEqual("InvalidStateError", authenticationCollector.errorCode) + } + + /// Ensures a not-handled OS outcome maps to NotSupportedError, including + /// the transformed unsupported-action shape with the SDK's message. + func testHandleErrorMapsASNotHandledToNotSupportedError() { + let transformed = authenticationCollector.handleError(error: ASAuthorizationError(.notHandled)) + + XCTAssertEqual(FidoError.unsupportedAction("Operation not supported"), transformed) + XCTAssertEqual("NotSupportedError", authenticationCollector.errorCode) + } + + /// Ensures an unknown OS outcome and a foreign-domain error both fall + /// through to UnknownError. + func testHandleErrorMapsUnknownAndForeignDomainToUnknownError() { + let unknownCollector = FidoAuthenticationCollector(with: makeAuthenticationJson()) + let foreignCollector = FidoAuthenticationCollector(with: makeAuthenticationJson()) + + _ = unknownCollector.handleError(error: ASAuthorizationError(.unknown)) + _ = foreignCollector.handleError(error: NSError(domain: "SomeOtherDomain", code: -1)) + + XCTAssertEqual("UnknownError", unknownCollector.errorCode) + XCTAssertEqual("UnknownError", foreignCollector.errorCode) + } + + // MARK: - ActionKeyProvider surface while errorCode is set + + /// Ensures a recorded error code flips the collector's event type to + /// "action", exposes the code as the action key, and switches payload() to + /// the non-nil empty-dictionary sentinel that makes the collector win + /// `Collectors.eventType()`. + func testErroredCollectorExposesActionKeySurface() throws { + _ = authenticationCollector.handleError(error: ASAuthorizationError(.canceled)) + + XCTAssertEqual("action", authenticationCollector.eventType()) + XCTAssertEqual("NotAllowedError", authenticationCollector.actionKey) + XCTAssertEqual(authenticationCollector.actionKey, authenticationCollector.errorCode) + let payload = try XCTUnwrap(authenticationCollector.payload()) + XCTAssertTrue(payload.isEmpty) + } + + /// Ensures the event type matches the SDK's own constant, so a constant + /// rename surfaces as a drift failure rather than a silent contract change. + func testErroredCollectorEventTypeMatchesConstant() { + _ = authenticationCollector.handleError(error: ASAuthorizationError(.canceled)) + + XCTAssertEqual(FidoConstants.EVENT_TYPE_ACTION, authenticationCollector.eventType()) + } + + /// Ensures the empty-dictionary sentinel is payload-visible on the + /// registration collector too, where a successful ceremony would otherwise + /// return the attestation envelope. + func testErroredRegistrationCollectorPayloadIsEmptySentinel() throws { + _ = registrationCollector.handleError(error: FidoError.timeout) + + let payload = try XCTUnwrap(registrationCollector.payload()) + XCTAssertTrue(payload.isEmpty) + XCTAssertEqual("action", registrationCollector.eventType()) + } + + // MARK: - State reset + + /// Ensures close() clears the recorded error code on each collector type: + /// the next submit reports the plain submit surface with no action key and + /// a nil payload, so a consumed error cannot leak into a later event. + func testCloseResetsErrorStateOnBothCollectors() { + _ = registrationCollector.handleError(error: FidoError.timeout) + _ = authenticationCollector.handleError(error: ASAuthorizationError(.canceled)) + + registrationCollector.close() + authenticationCollector.close() + + XCTAssertNil(registrationCollector.errorCode) + XCTAssertNil(authenticationCollector.errorCode) + XCTAssertEqual("submit", registrationCollector.eventType()) + XCTAssertEqual("submit", authenticationCollector.eventType()) + XCTAssertNil(registrationCollector.actionKey) + XCTAssertNil(authenticationCollector.actionKey) + XCTAssertNil(registrationCollector.payload()) + XCTAssertNil(authenticationCollector.payload()) + } + + // MARK: - Fixtures + + /// Server-style registration collector JSON matching the serializer test + /// fixture: int-array binary fields that the native `transform()` converts + /// to standard base64. + private func makeRegistrationJson() -> [String: Any] { + return [ + "type": "FIDO2", + "action": "REGISTER", + "key": "fido-register-key", + "label": "Set up passkeys", + "required": true, + "publicKeyCredentialCreationOptions": [ + "rp": ["id": "example.com", "name": "Example"], + "challenge": [72, 101], + ], + ] + } + + /// Server-style authentication collector JSON in the same shape. + private func makeAuthenticationJson() -> [String: Any] { + return [ + "type": "FIDO2", + "action": "AUTHENTICATE", + "key": "fido-auth-key", + "label": "Sign in with passkey", + "required": false, + "publicKeyCredentialRequestOptions": [ + "rpId": "example.com", + "challenge": [1, 2], + ], + ] + } +} diff --git a/packages/fido/src/NativeRNPingFido.ts b/packages/fido/src/NativeRNPingFido.ts index d419ebe7b..f72de33e1 100644 --- a/packages/fido/src/NativeRNPingFido.ts +++ b/packages/fido/src/NativeRNPingFido.ts @@ -87,6 +87,9 @@ export interface Spec extends TurboModule { * @param options Registration ceremony options. * @param config Per-client FIDO runtime configuration payload. * @returns A promise that resolves to the WebAuthn attestation payload. + * @remarks On ceremony failure the rejection carries an optional + * `userInfo.clientError` string with the WebAuthn DOMException name reported + * by the native collector. Absent for resolution failures. */ registerCredentialForDaVinci( davinciId: string, @@ -102,6 +105,9 @@ export interface Spec extends TurboModule { * @param options Authentication ceremony options. * @param config Per-client FIDO runtime configuration payload. * @returns A promise that resolves to the WebAuthn assertion payload. + * @remarks On ceremony failure the rejection carries an optional + * `userInfo.clientError` string with the WebAuthn DOMException name reported + * by the native collector. Absent for resolution failures. */ authenticateCredentialForDaVinci( davinciId: string, diff --git a/packages/fido/src/__tests__/fidoError.test.ts b/packages/fido/src/__tests__/fidoError.test.ts new file mode 100644 index 000000000..9e7288c35 --- /dev/null +++ b/packages/fido/src/__tests__/fidoError.test.ts @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +import { PingError } from '@ping-identity/rn-types'; +import { FidoError } from '../types'; + +describe('FidoError.from', () => { + it('reads clientError from the native rejection userInfo', () => { + const err = FidoError.from({ + error: 'FIDO_AUTHENTICATE_ERROR', + type: 'fido_error', + message: 'm', + userInfo: { clientError: 'TimeoutError' }, + }); + + expect(err).toBeInstanceOf(FidoError); + expect(err).toBeInstanceOf(PingError); + expect(err.name).toBe('FidoError'); + expect(err.code).toBe('FIDO_AUTHENTICATE_ERROR'); + expect(err.type).toBe('fido_error'); + expect(err.message).toBe('m'); + expect(err.clientError).toBe('TimeoutError'); + }); + + it.each([ + 'NotAllowedError', + 'TimeoutError', + 'NotSupportedError', + 'InvalidStateError', + 'UnknownError', + ])('exposes the known client error name %s', (clientError) => { + const err = FidoError.from({ + error: 'FIDO_REGISTER_ERROR', + type: 'fido_error', + message: 'register failed', + userInfo: { clientError }, + }); + + expect(err.clientError).toBe(clientError); + }); + + it('passes through unknown WebAuthn DOMException names from the open Android set', () => { + const err = FidoError.from({ + error: 'FIDO_AUTHENTICATE_ERROR', + type: 'fido_error', + message: 'm', + userInfo: { clientError: 'SecurityError' }, + }); + + expect(err.clientError).toBe('SecurityError'); + }); + + it.each([ + [ + 'no userInfo', + { error: 'FIDO_REGISTER_ERROR', type: 'fido_error', message: 'm' }, + ], + [ + 'null userInfo', + { + error: 'FIDO_REGISTER_ERROR', + type: 'fido_error', + message: 'm', + userInfo: null, + }, + ], + [ + 'a non-string clientError', + { + error: 'FIDO_REGISTER_ERROR', + type: 'fido_error', + message: 'm', + userInfo: { clientError: 42 }, + }, + ], + [ + 'an empty clientError', + { + error: 'FIDO_REGISTER_ERROR', + type: 'fido_error', + message: 'm', + userInfo: { clientError: '' }, + }, + ], + [ + 'a whitespace-only clientError', + { + error: 'FIDO_REGISTER_ERROR', + type: 'fido_error', + message: 'm', + userInfo: { clientError: ' ' }, + }, + ], + ])( + 'leaves clientError undefined for a rejection carrying %s', + (_label, nativeError) => { + const err = FidoError.from(nativeError); + + expect(err.clientError).toBeUndefined(); + }, + ); + + it('returns the same instance when the value is already a FidoError', () => { + const existing = new FidoError( + 'm', + 'FIDO_REGISTER_ERROR', + 'fido_error', + undefined, + 'NotAllowedError', + ); + + expect(FidoError.from(existing)).toBe(existing); + }); +}); diff --git a/packages/fido/src/__tests__/index.test.tsx b/packages/fido/src/__tests__/index.test.tsx index db094392b..c32295360 100644 --- a/packages/fido/src/__tests__/index.test.tsx +++ b/packages/fido/src/__tests__/index.test.tsx @@ -526,6 +526,102 @@ describe('FIDO API', () => { expect(err.type).toBe('state_error'); }); + it('registerForDaVinci throws FidoError exposing clientError NotAllowedError', async () => { + const nativeError = { + error: 'FIDO_REGISTER_ERROR', + type: 'fido_error', + message: 'register failed', + userInfo: { clientError: 'NotAllowedError' }, + }; + const registerDaVinciNative = jest.fn().mockRejectedValue(nativeError); + (getNativeModule as jest.Mock).mockReturnValue({ + registerDaVinciSerializer: jest.fn(), + registerCredentialForDaVinci: registerDaVinciNative, + }); + const daVinci = { getId: jest.fn().mockResolvedValue('davinci-123') }; + const logger = { + nativeHandle: { id: 'logger-1' }, + changeLevel: jest.fn(), + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + const client = createFidoClient({ logger }); + + const err = await client + .registerForDaVinci(daVinci, { index: 0 }) + .catch((e) => e); + + expect(err).toBeInstanceOf(FidoError); + expect(err.code).toBe('FIDO_REGISTER_ERROR'); + expect(err.clientError).toBe('NotAllowedError'); + expect(logger.error).toHaveBeenCalledWith('FIDO registerForDaVinci failed'); + }); + + it('authenticateForDaVinci keeps FIDO_AUTHENTICATE_CANCELLED and exposes clientError', async () => { + const nativeError = { + error: 'FIDO_AUTHENTICATE_CANCELLED', + type: 'fido_error', + message: 'authentication cancelled', + userInfo: { clientError: 'NotAllowedError' }, + }; + const authenticateDaVinciNative = jest.fn().mockRejectedValue(nativeError); + (getNativeModule as jest.Mock).mockReturnValue({ + registerDaVinciSerializer: jest.fn(), + authenticateCredentialForDaVinci: authenticateDaVinciNative, + }); + const daVinci = { getId: jest.fn().mockResolvedValue('davinci-xyz') }; + const client = createFidoClient(); + + const err = await client + .authenticateForDaVinci(daVinci, { index: 0 }) + .catch((e) => e); + + expect(err).toBeInstanceOf(FidoError); + expect(err.code).toBe('FIDO_AUTHENTICATE_CANCELLED'); + expect(err.clientError).toBe('NotAllowedError'); + }); + + it('DaVinci rejection without userInfo leaves clientError undefined', async () => { + const nativeError = { + error: 'FIDO_COLLECTOR_NOT_FOUND', + type: 'state_error', + message: 'No active FIDO authentication collector found', + }; + const authenticateDaVinciNative = jest.fn().mockRejectedValue(nativeError); + (getNativeModule as jest.Mock).mockReturnValue({ + registerDaVinciSerializer: jest.fn(), + authenticateCredentialForDaVinci: authenticateDaVinciNative, + }); + const daVinci = { getId: jest.fn().mockResolvedValue('davinci-xyz') }; + const client = createFidoClient(); + + const err = await client + .authenticateForDaVinci(daVinci, { index: 0 }) + .catch((e) => e); + + expect(err).toBeInstanceOf(FidoError); + expect(err.code).toBe('FIDO_COLLECTOR_NOT_FOUND'); + expect(err.clientError).toBeUndefined(); + }); + + it('successful DaVinci ceremony resolves unchanged', async () => { + const authenticateDaVinciNative = jest.fn().mockResolvedValue({ + assertionValue: { authenticatorData: 'AAECAw' }, + }); + (getNativeModule as jest.Mock).mockReturnValue({ + registerDaVinciSerializer: jest.fn(), + authenticateCredentialForDaVinci: authenticateDaVinciNative, + }); + const daVinci = { getId: jest.fn().mockResolvedValue('davinci-xyz') }; + const client = createFidoClient(); + + await expect(client.authenticateForDaVinci(daVinci)).resolves.toEqual({ + assertionValue: { authenticatorData: 'AAECAw' }, + }); + }); + it('authenticateForDaVinci forwards a default empty options object', async () => { const authenticateDaVinciNative = jest.fn().mockResolvedValue({}); (getNativeModule as jest.Mock).mockReturnValue({ diff --git a/packages/fido/src/index.tsx b/packages/fido/src/index.tsx index 7a5ea9903..5223ef844 100644 --- a/packages/fido/src/index.tsx +++ b/packages/fido/src/index.tsx @@ -194,6 +194,14 @@ export function createFidoClient(config: FidoConfig = {}): FidoClient { * @remarks Unlike the standalone `register`, no per-call logger override applies * here: the client-level `config` is threaded to native but both platforms discard * it for DaVinci ceremonies, which keep the workflow-configured native logger. + * On ceremony failure the rejection carries `clientError`, the WebAuthn + * DOMException name reported by the native collector (see `FidoClientErrorName`). + * When `clientError` is defined, report the failure to the server by advancing + * the flow with `daVinci.next({ collectors: [] })` so the flow can branch on it; + * do not submit the node's collectors, a `SUBMIT_BUTTON` shadows the FIDO error. + * Without propagation the failure stays client-side and the server never observes + * it. Resolution failures such as `FIDO_COLLECTOR_NOT_FOUND` carry no `clientError` + * and must not be propagated. */ async registerForDaVinci( daVinci: DaVinciInstance, @@ -225,6 +233,14 @@ export function createFidoClient(config: FidoConfig = {}): FidoClient { * @remarks Unlike the standalone `authenticate`, no per-call logger override applies * here: the client-level `config` is threaded to native but both platforms discard * it for DaVinci ceremonies, which keep the workflow-configured native logger. + * On ceremony failure the rejection carries `clientError`, the WebAuthn + * DOMException name reported by the native collector (see `FidoClientErrorName`). + * When `clientError` is defined, report the failure to the server by advancing + * the flow with `daVinci.next({ collectors: [] })` so the flow can branch on it; + * do not submit the node's collectors, a `SUBMIT_BUTTON` shadows the FIDO error. + * Without propagation the failure stays client-side and the server never observes + * it. Resolution failures such as `FIDO_COLLECTOR_NOT_FOUND` carry no `clientError` + * and must not be propagated. */ async authenticateForDaVinci( daVinci: DaVinciInstance, @@ -262,6 +278,7 @@ export type { FidoDaVinciRegistrationOptions, FidoDaVinciResult, FidoErrorCode, + FidoClientErrorName, FidoJourneyAuthenticationOptions, FidoJourneyRegistrationOptions, FidoJourneyResult, diff --git a/packages/fido/src/types/fido.types.ts b/packages/fido/src/types/fido.types.ts index a0732cf82..5eec3eab9 100644 --- a/packages/fido/src/types/fido.types.ts +++ b/packages/fido/src/types/fido.types.ts @@ -327,6 +327,34 @@ export interface FidoClient { * @returns A promise that resolves to the WebAuthn attestation payload. Informational * only — submit natively by advancing the flow with `daVinci.next({ collectors: [] })`. * @throws FidoError when the ceremony fails or the collector cannot be resolved. + * @remarks + * On ceremony failure the rejection carries `clientError`, the WebAuthn + * DOMException name reported by the native collector (see + * {@link FidoClientErrorName}). When `clientError` is defined, report the + * failure to the server by advancing the flow with + * `daVinci.next({ collectors: [] })` so the flow can branch on it; do not + * submit the node's collectors, a `SUBMIT_BUTTON` shadows the FIDO error. + * Without propagation the failure stays client-side and the server never + * observes it. Resolution failures such as `FIDO_COLLECTOR_NOT_FOUND` carry + * no `clientError` and must not be propagated. + * @example + * ```ts + * try { + * await fido.registerForDaVinci(daVinci); + * } catch (error) { + * const err = FidoError.from(error); + * if (err.clientError !== undefined) { + * // The collector classified the failure; submit it so the flow can branch. + * try { + * await daVinci.next({ collectors: [] }); + * } catch (propagationError) { + * // Advancing the flow also failed; surface propagationError without + * // losing the original ceremony failure. + * } + * } + * throw err; + * } + * ``` */ registerForDaVinci( daVinci: DaVinciInstance, @@ -340,6 +368,34 @@ export interface FidoClient { * @returns A promise that resolves to the WebAuthn assertion payload. Informational * only — submit natively by advancing the flow with `daVinci.next({ collectors: [] })`. * @throws FidoError when the ceremony fails or the collector cannot be resolved. + * @remarks + * On ceremony failure the rejection carries `clientError`, the WebAuthn + * DOMException name reported by the native collector (see + * {@link FidoClientErrorName}). When `clientError` is defined, report the + * failure to the server by advancing the flow with + * `daVinci.next({ collectors: [] })` so the flow can branch on it; do not + * submit the node's collectors, a `SUBMIT_BUTTON` shadows the FIDO error. + * Without propagation the failure stays client-side and the server never + * observes it. Resolution failures such as `FIDO_COLLECTOR_NOT_FOUND` carry + * no `clientError` and must not be propagated. + * @example + * ```ts + * try { + * await fido.authenticateForDaVinci(daVinci); + * } catch (error) { + * const err = FidoError.from(error); + * if (err.clientError !== undefined) { + * // The collector classified the failure; submit it so the flow can branch. + * try { + * await daVinci.next({ collectors: [] }); + * } catch (propagationError) { + * // Advancing the flow also failed; surface propagationError without + * // losing the original ceremony failure. + * } + * } + * throw err; + * } + * ``` */ authenticateForDaVinci( daVinci: DaVinciInstance, @@ -347,20 +403,92 @@ export interface FidoClient { ): Promise; } +/** + * WebAuthn DOMException name reported by a failed DaVinci FIDO2 ceremony. + * + * @remarks + * Surfaced on DaVinci FIDO rejections only, as {@link FidoError.clientError}. + * Standalone and Journey FIDO rejections never carry it. + * + * Names are WebAuthn DOMException names as reported by the native collector. + * Android reports an open set: androidx credential exceptions map to their + * DOMException names, so any valid name can appear. iOS reports a closed set + * of exactly the five names listed. For example, Android classifies a missing + * credential as `UnknownError` while the bridge keeps the rejection `code` as + * `FIDO_AUTHENTICATE_CANCELLED`: `clientError` is the server-parity value and + * the two fields can intentionally disagree. + * + * @public + */ +export type FidoClientErrorName = + | 'NotAllowedError' + | 'TimeoutError' + | 'NotSupportedError' + | 'InvalidStateError' + | 'UnknownError' + | (string & {}); + /** * Error thrown when FIDO operations fail. * * Extends {@link PingError} to allow per-package `instanceof` narrowing. + * + * On DaVinci ceremony failures, `clientError` carries the WebAuthn + * DOMException name reported by the native collector; see + * {@link FidoClientErrorName}. */ export class FidoError extends PingError { - constructor(message: string, code: string, type: string, status?: number) { + /** + * WebAuthn DOMException name reported by the native DaVinci FIDO collector + * when the ceremony failed, for example `'NotAllowedError'` after a user + * cancellation. + * + * @remarks + * Defined only on DaVinci ceremony failures that the native collector + * classified; `undefined` for standalone and Journey rejections and for + * resolution failures such as `FIDO_COLLECTOR_NOT_FOUND`. When defined, it + * marks the failure as propagatable to the DaVinci server with + * `daVinci.next({ collectors: [] })`. It is the server-parity value and can + * intentionally disagree with `code`, which is the bridge-local + * classification. + */ + readonly clientError?: FidoClientErrorName; + + constructor( + message: string, + code: string, + type: string, + status?: number, + clientError?: FidoClientErrorName, + ) { super(message, code, type, status); this.name = 'FidoError'; + if (clientError !== undefined) this.clientError = clientError; Object.setPrototypeOf(this, new.target.prototype); } static from(raw: unknown): FidoError { - return PingError.fromAs(raw, FidoError); + if (raw instanceof FidoError) return raw; + const normalizedError = PingError.from(raw); + const rawRecord = raw as Record; + const userInfo = + rawRecord?.userInfo && typeof rawRecord.userInfo === 'object' + ? (rawRecord.userInfo as Record) + : null; + const clientError = + typeof userInfo?.clientError === 'string' && + userInfo.clientError.trim().length > 0 + ? userInfo.clientError + : undefined; + const fidoError = new FidoError( + normalizedError.message, + normalizedError.code, + normalizedError.type, + normalizedError.status, + clientError, + ); + if (raw instanceof Error && raw.stack) fidoError.stack = raw.stack; + return fidoError; } } diff --git a/packages/fido/src/types/index.ts b/packages/fido/src/types/index.ts index c49e80a74..f079a166f 100644 --- a/packages/fido/src/types/index.ts +++ b/packages/fido/src/types/index.ts @@ -10,6 +10,7 @@ export type { FidoAndroidConfig, FidoClient, FidoClientConfig, + FidoClientErrorName, FidoConfig, FidoAuthenticationCollector, FidoAuthenticationOptions, diff --git a/packages/recaptcha/package.json b/packages/recaptcha/package.json index 3aea06846..e8d264518 100644 --- a/packages/recaptcha/package.json +++ b/packages/recaptcha/package.json @@ -1,6 +1,6 @@ { "name": "@ping-identity/rn-recaptcha", - "version": "1.1.0-beta.0", + "version": "1.1.0", "description": "The reCAPTCHA Module provides native-backed reCAPTCHA Enterprise verification for React Native.", "homepage": "https://www.pingidentity.com/", "author": "Ping Identity",