From e19a561333b86d9c50daab6179d1f23a95fe7830 Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Fri, 6 Nov 2026 17:35:41 +0530 Subject: [PATCH] fix: forward mfa_token when a native token request requires MFA --- EXAMPLES.md | 11 ++++- .../java/com/auth0/react/A0Auth0Module.kt | 4 ++ .../java/com/auth0/react/MfaRequiredError.kt | 15 +++++++ .../main/java/com/auth0/react/Passwordless.kt | 1 + ios/NativeBridge.swift | 22 ++++++++++ ios/Passwordless.swift | 1 + .../NativeCredentialsManager.errors.spec.ts | 23 +++++++++- .../native/bridge/NativeBridgeManager.ts | 13 +++++- .../__tests__/NativeBridgeManager.spec.ts | 43 +++++++++++++++++++ 9 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 android/src/main/java/com/auth0/react/MfaRequiredError.kt diff --git a/EXAMPLES.md b/EXAMPLES.md index 07a9afb4..f69e9583 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -394,6 +394,8 @@ Both challenge methods accept an optional `allowSignup` parameter (defaults to ` The `challenge` object returned from a challenge call is opaque — pass it as-is to `loginWithOTP`. You can optionally provide `audience` and `scope` to `loginWithOTP`. +If the user must complete MFA, `loginWithOTP` rejects with an `AuthError` whose `code` is `mfa_required`. Pass `error.json.mfa_token` to the [`mfa` client](#mfa-flexible-factors-grant) to finish signing in, as with [`passwordRealm`](#login-using-mfa-with-one-time-password-code). Any other failure rejects with `PASSWORDLESS_LOGIN_FAILED`. + ### Create user in database connection ```js @@ -661,6 +663,8 @@ function MyComponent() { } ``` +If a post-login Action requires MFA when the refresh token is exchanged, `getCredentials` (and `getApiCredentials`) rejects with `code: 'mfa_required'` instead. Pass `error.json.mfa_token` to the [`mfa` client](#mfa-flexible-factors-grant) to finish the renewal rather than starting a new login. + **Best Practices:** 1. **Use moderate retry counts**: Recommended maximum of 2 retries to balance reliability with performance @@ -1803,7 +1807,7 @@ Passkey operations throw `PasskeyError` (extends `AuthError`) with a normalized | `PASSKEY_INVALID_PARAMETER` | `authResponse` passed to `getTokenByPasskey` has the wrong type for the platform (a JSON string on web, a non-string on native), or a code in `verification` was empty or only whitespace | | `PASSKEY_INVALID_CREDENTIAL` | **Web only.** The credential passed to `getTokenByPasskey` is neither a valid attestation (signup) nor assertion (login) response | | `PASSKEY_VERIFICATION_RETRYABLE` | A one-time code for [identifier verification](#signup-with-passkey-identifier-verification) was wrong while its session is still alive, so the signup can be retried. `error.getVerificationRetryPayload()` returns `{ authSession, verificationRequired }`: the session to retry with and the identifiers to ask for again | -| `PASSKEY_MFA_REQUIRED` | **Web only.** MFA is required to complete the exchange — use `error.getMfaRequiredPayload()` to extract `mfaToken` and `mfaRequirements`, then continue with `mfa.challenge()`/`mfa.verify()` | +| `PASSKEY_MFA_REQUIRED` | MFA is required to complete the exchange — use `error.getMfaRequiredPayload()` to extract `mfaToken` and `mfaRequirements`, then continue with `mfa.challenge()`/`mfa.verify()` | | `PASSKEY_UNKNOWN_ERROR` | Unknown or uncategorized passkey error — check `error.message` for the underlying description | ```typescript @@ -2071,7 +2075,10 @@ try { }); console.log(`Updated: ${updated.givenName}`); } catch (e) { - if (e instanceof MyAccountError && e.type === MyAccountErrorCodes.INVALID_REQUEST) { + if ( + e instanceof MyAccountError && + e.type === MyAccountErrorCodes.INVALID_REQUEST + ) { // On a 400, each rejected field is described in `validationErrors`. for (const error of e.validationErrors ?? []) { console.log(`${error.pointer ?? error.field}: ${error.detail}`); diff --git a/android/src/main/java/com/auth0/react/A0Auth0Module.kt b/android/src/main/java/com/auth0/react/A0Auth0Module.kt index 33932679..86879612 100644 --- a/android/src/main/java/com/auth0/react/A0Auth0Module.kt +++ b/android/src/main/java/com/auth0/react/A0Auth0Module.kt @@ -471,6 +471,7 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0 } override fun onFailure(e: CredentialsManagerException) { + if (promise.rejectIfMultifactorRequired(e.mfaRequiredErrorPayload, e)) return val errorCode = deduceErrorCode(e) promise.reject(errorCode, credentialsMessage(e), e) } @@ -546,6 +547,7 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0 } override fun onFailure(e: CredentialsManagerException) { + if (promise.rejectIfMultifactorRequired(e.mfaRequiredErrorPayload, e)) return val errorCode = deduceErrorCode(e) promise.reject(errorCode, credentialsMessage(e), e) } @@ -766,6 +768,7 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0 } override fun onFailure(error: AuthenticationException) { + if (promise.rejectIfMultifactorRequired(error.mfaRequiredErrorPayload, error)) return handleError(error, promise) } }) @@ -936,6 +939,7 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0 } override fun onFailure(error: AuthenticationException) { + if (promise.rejectIfMultifactorRequired(error.mfaRequiredErrorPayload, error)) return if (error.isPasskeyVerificationRetryable) { // A code was wrong and the session is still usable: hand JS what a retry needs. val userInfo = WritableNativeMap().apply { diff --git a/android/src/main/java/com/auth0/react/MfaRequiredError.kt b/android/src/main/java/com/auth0/react/MfaRequiredError.kt new file mode 100644 index 00000000..1b78da25 --- /dev/null +++ b/android/src/main/java/com/auth0/react/MfaRequiredError.kt @@ -0,0 +1,15 @@ +package com.auth0.react + +import com.auth0.android.result.MfaRequiredErrorPayload +import com.facebook.react.bridge.Promise +import com.google.gson.Gson + +/** + * Rejects with `mfa_required` and the response body, which holds the `mfa_token` the app needs to continue. + * Returns false, without rejecting, when MFA isn't what's required. + */ +internal fun Promise.rejectIfMultifactorRequired(payload: MfaRequiredErrorPayload?, cause: Throwable): Boolean { + payload ?: return false + reject(payload.error, payload.errorDescription, cause, JsonUtils.jsonToWritableMap(Gson().toJson(payload))) + return true +} diff --git a/android/src/main/java/com/auth0/react/Passwordless.kt b/android/src/main/java/com/auth0/react/Passwordless.kt index dd501320..7e8f6f97 100644 --- a/android/src/main/java/com/auth0/react/Passwordless.kt +++ b/android/src/main/java/com/auth0/react/Passwordless.kt @@ -90,6 +90,7 @@ class Passwordless( } override fun onFailure(error: AuthenticationException) { + if (promise.rejectIfMultifactorRequired(error.mfaRequiredErrorPayload, error)) return promise.reject("PASSWORDLESS_LOGIN_FAILED", error.getDescription(), error) } }) diff --git a/ios/NativeBridge.swift b/ios/NativeBridge.swift index e3b33a11..b25c04ff 100644 --- a/ios/NativeBridge.swift +++ b/ios/NativeBridge.swift @@ -243,6 +243,7 @@ public class NativeBridge: NSObject { case .success(let credentials): resolve(credentials.asDictionary()) case .failure(let error): + if error.rejectIfMultifactorRequired(reject) { return } reject(error.reactNativeErrorCode(), error.errorDescription, error) } } @@ -252,6 +253,7 @@ public class NativeBridge: NSObject { case .success(let credentials): resolve(credentials.asDictionary()) case .failure(let error): + if error.rejectIfMultifactorRequired(reject) { return } reject(error.reactNativeErrorCode(), error.errorDescription, error) } } @@ -411,6 +413,7 @@ public class NativeBridge: NSObject { case .success(let credentials): resolve(credentials.asDictionary()) case .failure(let error): + if error.rejectIfMultifactorRequired(reject) { return } reject(error.reactNativeErrorCode(), error.errorDescription, error) } } @@ -452,6 +455,7 @@ public class NativeBridge: NSObject { case .success(let credentials): resolve(credentials.asDictionary()) case .failure(let error): + if error.rejectIfMultifactorRequired(reject) { return } reject(error.code, error.localizedDescription, error) } } @@ -657,6 +661,7 @@ public class NativeBridge: NSObject { case .success(let credentials): resolve(credentials.asDictionary()) case .failure(let error): + if error.rejectIfMultifactorRequired(reject) { return } if error.isPasskeyVerificationRetryable { reject(NativeBridge.passkeyVerificationRetryableErrorCode, error.localizedDescription, @@ -706,6 +711,7 @@ public class NativeBridge: NSObject { case .success(let credentials): resolve(credentials.asDictionary()) case .failure(let error): + if error.rejectIfMultifactorRequired(reject) { return } reject("PASSKEY_EXCHANGE_FAILED", error.localizedDescription, error) } } @@ -847,7 +853,23 @@ extension DPoPError { } } +extension AuthenticationError { + /// Rejects with `mfa_required` and the response body, which holds the `mfa_token` the app needs to continue. + /// React Native only forwards `userInfo` from an `NSError`, and `AuthenticationError` doesn't carry it. + func rejectIfMultifactorRequired(_ reject: RCTPromiseRejectBlock) -> Bool { + guard isMultifactorRequired else { return false } + // `statusCode` is added by Auth0.swift, not part of the body + let body = info.filter { $0.key != "statusCode" } + reject(code, localizedDescription, NSError(domain: code, code: statusCode, userInfo: body)) + return true + } +} + extension CredentialsManagerError { + func rejectIfMultifactorRequired(_ reject: RCTPromiseRejectBlock) -> Bool { + return (cause as? AuthenticationError)?.rejectIfMultifactorRequired(reject) ?? false + } + func reactNativeErrorCode() -> String { var code: String switch self { diff --git a/ios/Passwordless.swift b/ios/Passwordless.swift index 60cf7f79..99eb100f 100644 --- a/ios/Passwordless.swift +++ b/ios/Passwordless.swift @@ -74,6 +74,7 @@ public class A0Passwordless: NSObject { case .success(let credentials): resolve(credentials.asDictionary()) case .failure(let error): + if error.rejectIfMultifactorRequired(reject) { return } reject("PASSWORDLESS_LOGIN_FAILED", error.localizedDescription, error) } } diff --git a/src/platforms/native/adapters/__tests__/NativeCredentialsManager.errors.spec.ts b/src/platforms/native/adapters/__tests__/NativeCredentialsManager.errors.spec.ts index eae2182e..76a57581 100644 --- a/src/platforms/native/adapters/__tests__/NativeCredentialsManager.errors.spec.ts +++ b/src/platforms/native/adapters/__tests__/NativeCredentialsManager.errors.spec.ts @@ -1,6 +1,6 @@ import { NativeCredentialsManager } from '../NativeCredentialsManager'; import { NativeBridge } from '../../bridge'; -import { CredentialsManagerError } from '../../../../core/models'; +import { AuthError, CredentialsManagerError } from '../../../../core/models'; // Mock the native bridge const mockBridge: jest.Mocked = { @@ -344,4 +344,25 @@ describe('Common Error Handling - NativeCredentialsManager', () => { }); }); }); + + it('keeps the mfa_required body so the MFA client can continue', async () => { + const body = { error: 'mfa_required', mfa_token: 'mfa_token_value' }; + mockBridge.getCredentials.mockRejectedValueOnce( + new AuthError('mfa_required', 'Multifactor authentication required', { + status: 403, + code: 'mfa_required', + json: body, + }) + ); + + const error = await manager.getCredentials().catch((e) => e); + + expect(error).toBeInstanceOf(CredentialsManagerError); + expect(error).toMatchObject({ + type: 'RENEW_FAILED', + code: 'mfa_required', + status: 403, + json: body, + }); + }); }); diff --git a/src/platforms/native/bridge/NativeBridgeManager.ts b/src/platforms/native/bridge/NativeBridgeManager.ts index 87d778e5..ec1caf65 100644 --- a/src/platforms/native/bridge/NativeBridgeManager.ts +++ b/src/platforms/native/bridge/NativeBridgeManager.ts @@ -40,7 +40,18 @@ export class NativeBridgeManager implements NativeBridge { try { return await nativeMethod(...args); } catch (e) { - const { code, message } = e as NativeModuleError; + const { code, message, userInfo } = e as NativeModuleError & { + userInfo?: { mfa_token?: unknown } | null; + }; + // The bridges reject mfa_required with the response body, so match the + // error the HTTP-based logins throw and `json.mfa_token` can feed the MFA client. + if (code === 'mfa_required' && userInfo?.mfa_token) { + throw new AuthError(code, message, { + status: 403, + code, + json: userInfo, + }); + } throw new AuthError(code, message, { code, json: e }); } } diff --git a/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts b/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts index 5c12284d..6ffe586f 100644 --- a/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts +++ b/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts @@ -545,6 +545,49 @@ describe('NativeBridgeManager', () => { expect(authError.code).toBe('a0.session.user_cancelled'); } }); + + it('should expose the mfa_required body like the HTTP-based logins', async () => { + const body = { + error: 'mfa_required', + error_description: 'Multifactor authentication required', + mfa_token: 'mfa_token_value', + mfa_requirements: { challenge: [{ type: 'otp' }] }, + }; + MockedAuth0NativeModule.getCredentials.mockRejectedValueOnce({ + code: 'mfa_required', + message: body.error_description, + userInfo: body, + }); + + const error = await bridge + .getCredentials(undefined, 0, {}, false) + .catch((e) => e); + + expect(error).toBeInstanceOf(AuthError); + expect(error).toMatchObject({ + name: 'mfa_required', + code: 'mfa_required', + status: 403, + message: body.error_description, + json: body, + }); + }); + + it('should keep the native error as json when mfa_required has no body', async () => { + const nativeError = { + code: 'mfa_required', + message: 'Multifactor authentication required', + userInfo: null, + }; + MockedAuth0NativeModule.getCredentials.mockRejectedValueOnce(nativeError); + + const error = await bridge + .getCredentials(undefined, 0, {}, false) + .catch((e) => e); + + expect(error).toMatchObject({ code: 'mfa_required', status: 0 }); + expect(error.json).toBe(nativeError); + }); }); describe('customTokenExchange', () => {