[ECUK In-App 3DS] Add Passkeys/WebAuthn support for multifactor biometric authentication#84610
Conversation
Extract biometrics logic from Context/useNativeBiometrics into a dedicated biometrics/ module with platform-specific implementations (useBiometrics.ts for native, useBiometrics.web.ts for web). Add passkey stub implementation, WebAuthn error handling, and include PASSKEYS in allowed 3DS auth methods.
Implement WebAuthn-based passkey registration and authentication as a new MFA method alongside existing biometrics. Add passkey scenario configuration, translations, and SecureStore integration.
Strengthen source types in RegistrationChallenge and AuthenticationChallenge to use PublicKeyCredentialType and UserVerificationRequirement. Replace PublicKeyCredential casts with instanceof type guard. Derive SupportedTransport from CONST.PASSKEY_TRANSPORT with runtime guard for type narrowing.
Replace as casts for AuthenticatorAttestationResponse and AuthenticatorAssertionResponse with instanceof type guards. Use isSupportedTransport guard for getTransports() filtering.
Private keys should not leak beyond the registration function. For native biometrics the key is stored in SecureStore internally, for passkeys it never leaves the authenticator. No consumer of RegisterResult ever reads privateKey.
These debug logs were development artifacts that could leak sensitive authentication data (credential IDs, challenge content, backend responses) to the browser console in production.
Passkeys don't use SecureStore at all — the PASSKEY entry was misplaced. Define PASSKEY_AUTH_TYPE in WebAuthn.ts, make AuthTypeInfo.code optional (only relevant for native biometrics), and update derived types to include passkey via union.
|
Hey, I noticed you changed If you want to automatically generate translations for other locales, an Expensify employee will have to:
Alternatively, if you are an external contributor, you can run the translation script locally with your own OpenAI API key. To learn more, try running: npx ts-node ./scripts/generateTranslations.ts --helpTypically, you'd want to translate only what you changed by running |
Codecov Report✅ Changes either increased or maintained existing code coverage, great job!
|
Reconcile server-known credential IDs with local Onyx credentials and pass the result as excludeCredentials to navigator.credentials.create, preventing the same authenticator from being registered twice.
…eTypes.ts These types (SignedChallenge, RegistrationChallenge, AuthenticationChallenge, etc.) are algorithm-agnostic and shared by both ED25519 (native biometrics) and ES256 (passkeys) flows. Moving them to a neutral location removes the misleading coupling to the ED25519 module.
Separate platform-specific logic into dedicated modules: - NativeBiometrics: Expo SecureStore, ED25519, native key management - Passkeys: WebAuthn API, passkey credential helpers - shared: challenge types, observers, HTTP helpers, common types Update all imports across source and test files.
JakubKorytko
left a comment
There was a problem hiding this comment.
some NITs, overall LGTM
tests/unit/libs/MultifactorAuthentication/shared/helpers.test.ts
Outdated
Show resolved
Hide resolved
src/components/MultifactorAuthentication/biometrics/usePasskeys.ts
Outdated
Show resolved
Hide resolved
… tests Remove unnecessary KeyStore mock, add tests for processPasskeyRegistration and processScenarioAction to cover the full processing module.
- Rename biometrics/common/ to biometrics/shared/ for consistency - Use spread in VALUES.ts barrel file - Rename MultifactorAuthenticationKeyInfo to NativeBiometricsKeyInfo - Replace `as` with type guard in Passkeys/helpers.ts - Remove redundant credentials.length check in usePasskeys - Rename (p) to (param) in WebAuthn.ts - Split shared/helpers.test.ts into NativeBiometrics/ and shared/ dirs - Unify processRegistration and processPasskeyRegistration: hooks now build keyInfo internally, processing.ts has one shared function
…s/passkeys-mfa # Conflicts: # src/components/MultifactorAuthentication/Context/Main.tsx # src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts # src/libs/MultifactorAuthentication/shared/VALUES.ts
18da419 to
8171acc
Compare
…tion Expose a platform-specific constant (BIOMETRICS on native, PASSKEYS on web) so consumers can check whether the scenario allows the current device's verification type, replacing the redundant switch-case fallthrough.
…r wrapper React Compiler handles memoization automatically, so useMemo and the module-level selector function are unnecessary.
Replace magic number -8 with CONST.COSE_ALGORITHM.EDDSA in types and runtime code. Add ES256 and RS256 constants for passkey use.
|
@dariusz-biela TroubleshootMultifactorAuthentication Command is failing, it looks like it might be related to the authenticationMethod param. Screen.Recording.2026-03-17.at.17.26.14.mov |
This is because the backend team hasn't yet merged the changes for passkeys on their end. Unfortunately, we'll have to wait until this goes live in production before we can run manual tests in this PR.
|
|
@codex review |
| } | ||
|
|
||
| /** Builds WebAuthn credential creation options from a backend registration challenge. */ | ||
| function buildPublicKeyCredentialCreationOptions(challenge: RegistrationChallenge, excludeCredentials: PublicKeyCredentialDescriptor[]): PublicKeyCredentialCreationOptions { |
There was a problem hiding this comment.
Let’s use buildAllowedCredentialDescriptors in this function instead of passing excludeCredentials, and then export it.
| /** | ||
| * Helper utilities for passkey/WebAuthn error decoding. | ||
| */ | ||
| import type {MultifactorAuthenticationReason} from '@libs/MultifactorAuthentication/shared/types'; | ||
| import VALUES from '@libs/MultifactorAuthentication/VALUES'; |
There was a problem hiding this comment.
Why don't we put these functions in src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts? I don't see any reason to separate them into a new file
| try { | ||
| credential = await createPasskeyCredential(publicKeyOptions); | ||
| } catch (error) { | ||
| onResult({ |
There was a problem hiding this comment.
NAB: Should we add await before onResult?
|
Codex Review: Something went wrong. Try again later by commenting “@codex review”. ℹ️ About Codex in GitHubCodex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback". |
…s/passkeys-mfa # Conflicts: # src/selectors/Account.ts
|
The changes look fine to me. Waiting for the BE update to test |
|
I tested this and it worked well! A couple of quirks - I think both are expected, but LMK if you don't think so.
Screen.Recording.2026-03-18.at.3.30.20.PM.mov |
rafecolton
left a comment
There was a problem hiding this comment.
Really nice job! This is so slick and works really well, and I love the way you did the abstraction
src/components/MultifactorAuthentication/biometrics/useNativeBiometrics.ts
Show resolved
Hide resolved
|
Some test runs macOS Chrome: cancelling the system passkey prompt shows failure screen ✅passkey.rejected.mp4macOS Chrome: If the user cancels the system passkey prompt, then runs through the test scenario again, the magic code page dumps them straight to a failure screen instead of prompting them again 🔴Repro
passkey.does.not.prompt.after.rejected.registration.attempt.mp4macOS Chrome: happy path test scenario works ✅passkey.success.after.reload.mp4macOS Safari: biometrics test scenario simply does not work 🔴passkey.registration.fails.in.safari.mp4Error message |
I was not able to reproduce this - it worked just fine for me. I wonder if this is caused by poor handling of magic code rate limiting - can you check the API responses? Here is it working for me: Screen.Recording.2026-03-18.at.6.42.42.PM.mov
This I was able to reproduce. Asked here if we should fix now, as a follow-up, or not at all. Though if you can fix it quickly @dariusz-biela before the discussion concludes, feel free! |
Reviewer Checklist
Screenshots/VideosAndroid: HybridAppN/A Android: mWeb ChromeiOS: HybridAppN/A iOS: mWeb SafariMacOS: Chrome / SafariScreen.Recording.2026-03-18.at.6.42.42.PM.mov |
|
🚧 @rafecolton has triggered a test Expensify/App build. You can view the workflow run here. |
|
Final TODOs before this can be merged:
|
|
🧪🧪 Use the links below to test this adhoc build on Android, iOS, and Web. Happy testing! 🧪🧪
|
|
I can no longer reproduce the cancel-then-fail behavior I saw in chrome. Also, I can confirm this works flawlessly in Firefox |
|
Mobile web test results iOS Safari: failSame as desktop safaripasskey.failure.ios.safari.mp4Android Chrome: success ✅Turns out you need to make sure your emulator is "Google Play" (not just Google APIs) and sign in to a Google account to use passkeys at all passkeys.work.android.mp4 |
|
Thanks Chuck. If it's broken on mobile safari too, then I think we should hold off merging this until it's fixed. The Auth PR is merged and should go out tomorrow, so that should make testing easier |
…riptive messages to device checks
Explanation of Change
This PR adds Passkeys/WebAuthn as a new authentication method for in-app 3DS card transaction authorization on Web/mWeb, alongside the existing NativeBiometrics (iOS/Android). A platform-specific
useBiometrics()hook abstracts both methods behind a sharedUseBiometricsReturninterface so the MFA context is unaware of the underlying technology. TheBiometrics/module is refactored intoNativeBiometrics/,Passkeys/, andshared/directories. Passkeys are currently enabled only in the BiometricsTest scenario. Localization and tests are updated accordingly.Detailed breakdown
usePasskeyshook — Manages the full Passkeys lifecycle (registration and authorization) by bridging low-level WebAuthn browser APIs with theMultifactorAuthenticationContext. Handles frontend ↔ backend credential exchange including challenge request/response flows.Passkeys/WebAuthn.tsprovidesArrayBuffer↔base64urlconversions, buildsPublicKeyCredentialCreationOptions/RequestOptions, and wrapsnavigator.credentials.create/get. A separatePasskeys/helpers.tsmodule maps common WebAuthnDOMExceptionerrors to internal error codes.UseBiometricsReturn,RegisterResult,AuthorizeResult,AuthorizeParams) and auseServerCredentialshook intobiometrics/shared/so that bothuseNativeBiometricsandusePasskeysshare the same contract with the MFA context.useBiometricssplit —biometrics/useBiometrics/index.ts(web) re-exportsusePasskeys, whileindex.native.tsre-exportsuseNativeBiometrics, so theMultifactorAuthenticationContextcalls a singleuseBiometrics()hook and the platform decides which implementation runs.MultifactorAuthenticationContextextension — Wires in the newuseBiometrics()abstraction. Device eligibility is now checked in two steps: (2a)doesDeviceSupportAuthenticationMethod()verifies platform support, and (2b) the scenario'sallowedAuthenticationMethodsarray is checked againstdeviceVerificationType. Prompt navigation now uses aPROMPT_TYPE_MAPto pick the correct prompt type (biometrics vs passkeys).allowedAuthenticationMethodsfrom[BIOMETRICS]to[BIOMETRICS, PASSKEYS]. Other scenarios remain unchanged, so Passkeys are currently only available in the test flow.Biometrics/directory toNativeBiometrics/and extracts native-specific types, helpers (Expo error decoding), and VALUES into dedicated files. Shared types and challenge types are moved toshared/. A new barrel file (MultifactorAuthentication/VALUES.ts) merges shared + NativeBiometrics + Passkeys values. The unusedObserver.tsandMultifactorAuthenticationCallbacksare removed.keyInfoconstruction (previously inprocessing.tsviacreateKeyInfoObject) is now performed inside each hook (useNativeBiometrics/usePasskeys), andprocessRegistrationreceives the ready-madekeyInfoobject directly.PromptContent.tsxnow supports both Lottie animations and SVG illustrations (animationprop renamed toillustration). Passkeys use a new SVG asset (simple-illustration__encryption-passkeys.svg); native biometrics continue using the existing Lottie fingerprint animation.localPublicKey→localCredentialID,getLocalPublicKey→getLocalCredentialID,doesDeviceSupportBiometrics→doesDeviceSupportAuthenticationMethod,resetKeysForAccount→deleteLocalKeysForAccountacross hooks, pages, selectors, and tests.useNavigateTo3DSAuthorizationChallengereplaces the per-methodswitch-case with a singledoesDeviceSupportAuthenticationMethod() && allowedAuthenticationMethods.includes(deviceVerificationType)check, removing the previous TODO comment about passkey support.userVerificationnarrowed fromstringtoUserVerificationRequirement,pubKeyCredParams.typenarrowed fromstringtoPublicKeyCredentialType,AuthTypeInfo.codemade optional (passkeys don't use SecureStore codes). AddedCOSE_ALGORITHMconstants (EDDSA,ES256,RS256) toCONST.hasBiometricsRegisteredSelectorandisAccountLoadingSelectorfromselectors/Account.ts; addedmfaCredentialIDsSelectorused by the newuseServerCredentialshook.verifyYourself.passkeys,enableQuickVerification.passkeys,authType.passkey) across all supported languages.processRegistration/processScenarioAction(processing.test.ts), NativeBiometrics Expo error decoding (NativeBiometrics/helpers.test.ts), and sharedparseHttpRequest(shared/helpers.test.ts). Updates existing test suites for renamed fields and import paths. Removes obsoleteObserver.test.tsand oldBiometrics/helpers.test.ts.Fixed Issues
$ #79464
$ #79467
$ #79469
PROPOSAL:
Tests
Prerequisites: The backend is not yet ready — testing requires the Auth and Web-Expensify PRs: https://github.com/Expensify/Auth/pull/19657
https://github.com/Expensify/Web-Expensify/pull/51463
https://github.com/Expensify/Auth/pull/20520
Passkeys registration (Web / mWeb):
registeredlabel is now displayedPasskeys authorization (Web / mWeb): 10. Repeat steps 3 and 4 11. Verify that the validate code step is no longer required 12. Authenticate using the passkey prompt 13. Verify that the Authentication was successful
Failure scenarios (Web / mWeb): 14. Cancel the passkey prompt during registration (step 7) — verify the Authentication failed 15. Cancel the passkey prompt during authorization (step 12) — verify the Authentication failed 16. Exit the flow using the back button or by clicking the overlay — verify a confirmation modal is displayed 17. Enter a wrong validate code during registration — verify an error text is displayed and the magic code input is shown again to allow re-entry
NativeBiometrics regression (iOS / Android native): 18. Open Expensify App on a native mobile device 19. Navigate to Settings → Troubleshoot 20. Click on the "Test" button next to the "Biometrics (Not registered)" text 21. Click on the "Test" button in the RHP view 22. Fill the magic code input 23. Click on the "Got it" button 24. Authenticate using device credentials or biometry (Face ID / Touch ID / fingerprint) 25. Verify that the Authentication was successful 26. Verify that the
registeredlabel is now displayed 27. Repeat steps 20 and 21 28. Verify that validate code is no longer required 29. Authenticate using device biometrics 30. Verify that the Authentication was successfulOffline tests
N/A,
D - Full Page Blocking UI Patternfor this project.QA Steps
Same as tests
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectiontoggleReportand notonIconClick)src/languages/*files and using the translation methodSTYLE.md) were followedAvatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.ScrollViewcomponent to make it scrollable when more elements are added to the page.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari