Skip to content

Commit e11f788

Browse files
feat: add Multi-Factor Authentication (MFA) support (#1014)
## Summary Adds comprehensive MFA support by exposing the MFA client from `@auth0/auth0-spa-js` through the `useAuth0()` hook. Enables React applications to implement MFA enrollment, challenge, and verification flows for all authenticator types (OTP, SMS, Voice, Email, Push). ## Changes ### Core Implementation **`src/auth0-context.tsx` & `src/auth0-provider.tsx`** - Added `mfa` property to `Auth0ContextInterface` exposing `MfaApiClient` - Integrated MFA client into provider context - Added JSDoc documentation with usage examples **Available Methods:** - `getAuthenticators(mfaToken)` - List enrolled authenticators - `enroll(params)` - Enroll new authenticators (OTP, SMS, Voice, Email, Push) - `challenge(params)` - Initiate MFA challenges - `verify(params)` - Verify with OTP, OOB code, or recovery code - `getEnrollmentFactors(mfaToken)` - Get available enrollment factors ### Documentation (`EXAMPLES.md`) Added comprehensive MFA guide covering: - Setup and dashboard configuration - MFA response flow patterns (challenge vs enroll) - Error handling with `MfaRequiredError` - Code examples for all authenticator types - Challenge and verification patterns - Recovery code handling ### Testing (`__tests__/mfa.test.tsx`) - Availability tests: Verify MFA client and all 5 methods are accessible - Success tests: Test each method returns expected responses - Updated mocks in `__mocks__/@auth0/auth0-spa-js.tsx` ## Usage Example ```jsx import { useAuth0, MfaRequiredError } from '@auth0/auth0-react'; const { getAccessTokenSilently, mfa } = useAuth0(); try { await getAccessTokenSilently(); } catch (error) { if (error instanceof MfaRequiredError) { // Enroll new authenticator const enrollment = await mfa.enroll({ mfaToken: error.mfa_token, factorType: 'otp' }); // Challenge and verify const authenticators = await mfa.getAuthenticators(error.mfa_token); await mfa.challenge({ mfaToken: error.mfa_token, challengeType: 'otp', authenticatorId: authenticators[0].id }); const tokens = await mfa.verify({ mfaToken: error.mfa_token, otp: userCode }); } } ``` ## Breaking Changes None. Backward compatible, additive change only. ## Related Documentation - [Auth0 MFA Documentation](https://auth0.com/docs/secure/multi-factor-authentication) - [`@auth0/auth0-spa-js` MFA API](https://github.com/auth0/auth0-spa-js/blob/main/EXAMPLES.md#multi-factor-authentication-mfa)
1 parent 4b227e7 commit e11f788

File tree

6 files changed

+417
-3
lines changed

6 files changed

+417
-3
lines changed

EXAMPLES.md

Lines changed: 212 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
- [Using Multi Resource Refresh Tokens](#using-multi-resource-refresh-tokens)
1313
- [Connect Accounts for using Token Vault](#connect-accounts-for-using-token-vault)
1414
- [Access SDK Configuration](#access-sdk-configuration)
15+
- [Multi-Factor Authentication (MFA)](#multi-factor-authentication-mfa)
1516

1617
## Use with a Class Component
1718

@@ -778,4 +779,214 @@ const ConfigInfo = () => {
778779
export default ConfigInfo;
779780
```
780781
781-
This is useful for debugging, logging, or building custom Auth0-related URLs without duplicating configuration values.
782+
This is useful for debugging, logging, or building custom Auth0-related URLs without duplicating configuration values.
783+
784+
## Multi-Factor Authentication (MFA)
785+
786+
Access MFA operations through the `mfa` property from `useAuth0()`. All operations require an `mfa_token` from the MFA required error.
787+
788+
> [!NOTE]
789+
> Multi Factor Authentication support via SDKs is currently in Early Access. To request access to this feature, contact your Auth0 representative.
790+
791+
- [Setup](#setup)
792+
- [Handling MFA Required Error](#handling-mfa-required-error)
793+
- [Enrolling Authenticators](#enrolling-authenticators)
794+
- [Challenging Authenticators](#challenging-authenticators)
795+
- [Verifying Challenges](#verifying-challenges)
796+
- [Error Handling](#error-handling)
797+
798+
### Setup
799+
800+
Before using the MFA API, configure MFA in your [Auth0 Dashboard](https://manage.auth0.com) under **Security** > **Multi-factor Auth**. For detailed configuration, see the [Auth0 MFA documentation](https://auth0.com/docs/secure/multi-factor-authentication/customize-mfa/customize-mfa-enrollments-universal-login).
801+
802+
#### Understanding the MFA Response
803+
804+
When MFA is required, the error payload contains an `mfa_requirements` object that indicates either a **challenge** flow (user has enrolled authenticators) or an **enroll** flow (user needs to set up MFA).
805+
806+
**Challenge Flow Response** (user has existing authenticators):
807+
808+
```json
809+
{
810+
"error": "mfa_required",
811+
"error_description": "Multifactor authentication required",
812+
"mfa_token": "Fe26.2*...",
813+
"mfa_requirements": {
814+
"challenge": [
815+
{ "type": "otp" },
816+
{ "type": "email" }
817+
...
818+
]
819+
}
820+
}
821+
```
822+
823+
**Enroll Flow Response** (user needs to enroll an authenticator):
824+
825+
```json
826+
{
827+
"error": "mfa_required",
828+
"error_description": "Multifactor authentication required",
829+
"mfa_token": "Fe26.2*...",
830+
"mfa_requirements": {
831+
"enroll": [
832+
{ "type": "otp" },
833+
{ "type": "phone" },
834+
{ "type": "push-notification" }
835+
...
836+
]
837+
}
838+
}
839+
```
840+
841+
Based on the response:
842+
- **`mfa_requirements.challenge`**: User has enrolled authenticators → proceed with **List Authenticators → Challenge → Verify** flow
843+
- **`mfa_requirements.enroll`**: User needs to set up MFA → proceed with **Enroll → Verify** flow
844+
845+
> [!NOTE]
846+
> The SDK handles this logic automatically. When you call `getEnrollmentFactors()` or `getAuthenticators()`, the SDK uses the stored context to return the appropriate data.
847+
848+
849+
### Handling MFA Required Error
850+
When MFA is required, the SDK automatically stores the context. You can then call MFA methods with just the token:
851+
852+
```jsx
853+
import { useAuth0, MfaRequiredError } from '@auth0/auth0-react';
854+
855+
try {
856+
await getAccessTokenSilently();
857+
} catch (error) {
858+
if (error instanceof MfaRequiredError) {
859+
const mfaToken = error.mfa_token;
860+
861+
// Check if user needs to enroll
862+
const factors = await mfa.getEnrollmentFactors(mfaToken);
863+
if (factors.length > 0) {
864+
// Show enrollment UI
865+
} else {
866+
// User has enrolled authenticators - get the list of enrolled authenticator
867+
const authenticators = await mfa.getAuthenticators(error.mfa_token);
868+
869+
// proceed with challenge
870+
}
871+
}
872+
}
873+
```
874+
875+
### Enrolling Authenticators
876+
877+
```jsx
878+
const { mfa } = useAuth0();
879+
880+
// Enroll any factor type
881+
const enrollment = await mfa.enroll({
882+
mfaToken,
883+
factorType: 'otp' // 'otp' | 'sms' | 'email' | 'voice' | 'push'
884+
});
885+
886+
// For OTP: Display QR code
887+
console.log('Scan:', enrollment.barcodeUri);
888+
console.log('Recovery codes:', enrollment.recoveryCodes);
889+
890+
// For SMS: Include phone number
891+
await mfa.enroll({
892+
mfaToken,
893+
factorType: 'sms',
894+
phoneNumber: '+12025551234' // E.164 format
895+
});
896+
897+
// For Voice: Include phone number
898+
await mfa.enroll({
899+
mfaToken,
900+
factorType: 'voice',
901+
phoneNumber: '+12025551234' // E.164 format
902+
});
903+
904+
// For Email: Include email address
905+
await mfa.enroll({
906+
mfaToken,
907+
factorType: 'email',
908+
email: 'user@example.com'
909+
});
910+
911+
// For Push: Returns authenticator for mobile app
912+
const pushEnrollment = await mfa.enroll({
913+
mfaToken,
914+
factorType: 'push'
915+
});
916+
console.log('Authenticator ID:', pushEnrollment.id); // Use with Guardian app
917+
```
918+
919+
### Challenging Authenticators
920+
921+
```jsx
922+
const { mfa } = useAuth0();
923+
924+
// Get enrolled authenticators
925+
const authenticators = await mfa.getAuthenticators(mfaToken);
926+
927+
// For OTP: Challenge is OPTIONAL - code already available in authenticator app
928+
// Skip directly to verify() with the 6-digit code, or optionally challenge with:
929+
const otpResponse = await mfa.challenge({
930+
mfaToken,
931+
challengeType: 'otp',
932+
authenticatorId: authenticators[0].id
933+
});
934+
935+
// For SMS/Voice/Email/Push: Challenge REQUIRED to send code (use 'oob' type)
936+
const oobResponse = await mfa.challenge({
937+
mfaToken,
938+
challengeType: 'oob', // Use 'oob' for all out-of-band authenticators
939+
authenticatorId: authenticators[0].id // ID of SMS/Voice/Email/Push authenticator
940+
});
941+
console.log('OOB Code:', oobResponse.oobCode); // Code sent via SMS/Voice/Email/Push
942+
```
943+
944+
### Verifying Challenges
945+
946+
```jsx
947+
const { mfa } = useAuth0();
948+
949+
// Verify with OTP code (for OTP authenticators)
950+
const tokens = await mfa.verify({
951+
mfaToken,
952+
otp: '123456' // 6-digit code from authenticator app
953+
});
954+
955+
// Verify with OOB code (for SMS/Voice/Email authenticators)
956+
const tokens = await mfa.verify({
957+
mfaToken,
958+
oobCode: smsResponse.oobCode,
959+
bindingCode: '123456' // Optional: code shown in challenge
960+
});
961+
962+
// Verify with recovery code (works for any authenticator)
963+
const tokens = await mfa.verify({
964+
mfaToken,
965+
recoveryCode: 'recovery-code-here'
966+
});
967+
968+
// Tokens are now cached - user is authenticated
969+
console.log('Access token:', tokens.access_token);
970+
```
971+
972+
### Error Handling
973+
974+
```jsx
975+
import {
976+
MfaEnrollmentError,
977+
MfaChallengeError,
978+
MfaVerifyError
979+
} from '@auth0/auth0-react';
980+
981+
try {
982+
await mfa.verify({ mfaToken, otp });
983+
} catch (error) {
984+
if (error instanceof MfaVerifyError) {
985+
console.error('Invalid code:', error.error_description);
986+
} else if (error instanceof MfaChallengeError) {
987+
console.error('Challenge failed:', error.error_description);
988+
} else if (error instanceof MfaEnrollmentError) {
989+
console.error('Enrollment failed:', error.error_description);
990+
}
991+
}
992+
```

__mocks__/@auth0/auth0-spa-js.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ const setDpopNonce = jest.fn();
2020
const generateDpopProof = jest.fn();
2121
const createFetcher = jest.fn();
2222
const getConfiguration = jest.fn();
23+
const mfaGetAuthenticators = jest.fn(() => Promise.resolve([]));
24+
const mfaEnroll = jest.fn(() => Promise.resolve({ id: 'test-id', barcodeUri: 'test-uri', recoveryCodes: [] }));
25+
const mfaChallenge = jest.fn(() => Promise.resolve({ challengeType: 'otp', oobCode: null }));
26+
const mfaVerify = jest.fn(() => Promise.resolve({ access_token: 'test-token', id_token: 'test-id-token' }));
27+
const mfaGetEnrollmentFactors = jest.fn(() => Promise.resolve([]));
2328

2429
export const Auth0Client = jest.fn(() => {
2530
return {
@@ -43,7 +48,21 @@ export const Auth0Client = jest.fn(() => {
4348
generateDpopProof,
4449
createFetcher,
4550
getConfiguration,
51+
mfa: {
52+
getAuthenticators: mfaGetAuthenticators,
53+
enroll: mfaEnroll,
54+
challenge: mfaChallenge,
55+
verify: mfaVerify,
56+
getEnrollmentFactors: mfaGetEnrollmentFactors,
57+
},
4658
};
4759
});
4860

49-
export const ResponseType = actual.ResponseType;
61+
export const ResponseType = actual.ResponseType;
62+
63+
export const MfaError = actual.MfaError;
64+
export const MfaListAuthenticatorsError = actual.MfaListAuthenticatorsError;
65+
export const MfaEnrollmentError = actual.MfaEnrollmentError;
66+
export const MfaChallengeError = actual.MfaChallengeError;
67+
export const MfaVerifyError = actual.MfaVerifyError;
68+
export const MfaEnrollmentFactorsError = actual.MfaEnrollmentFactorsError;

__tests__/mfa.test.tsx

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { renderHook, waitFor } from '@testing-library/react';
2+
import useAuth0 from '../src/use-auth0';
3+
import { createWrapper } from './helpers';
4+
5+
describe('MFA API', () => {
6+
describe('Basic Availability', () => {
7+
it('should provide mfa client through useAuth0', async () => {
8+
const wrapper = createWrapper();
9+
const { result } = renderHook(() => useAuth0(), { wrapper });
10+
11+
await waitFor(() => {
12+
expect(result.current.mfa).toBeDefined();
13+
});
14+
});
15+
16+
it('should provide all five MFA methods', async () => {
17+
const wrapper = createWrapper();
18+
const { result } = renderHook(() => useAuth0(), { wrapper });
19+
20+
await waitFor(() => {
21+
expect(result.current.mfa.getAuthenticators).toBeDefined();
22+
expect(result.current.mfa.enroll).toBeDefined();
23+
expect(result.current.mfa.challenge).toBeDefined();
24+
expect(result.current.mfa.verify).toBeDefined();
25+
expect(result.current.mfa.getEnrollmentFactors).toBeDefined();
26+
});
27+
});
28+
});
29+
30+
describe('Method Success Tests', () => {
31+
it('should call mfa.getAuthenticators', async () => {
32+
const wrapper = createWrapper();
33+
const { result } = renderHook(() => useAuth0(), { wrapper });
34+
35+
await waitFor(async () => {
36+
const authenticators = await result.current.mfa.getAuthenticators('test-mfa-token');
37+
expect(authenticators).toBeDefined();
38+
expect(Array.isArray(authenticators)).toBe(true);
39+
});
40+
});
41+
42+
it('should call mfa.enroll', async () => {
43+
const wrapper = createWrapper();
44+
const { result } = renderHook(() => useAuth0(), { wrapper });
45+
46+
await waitFor(async () => {
47+
const enrollment = await result.current.mfa.enroll({
48+
mfaToken: 'test-mfa-token',
49+
factorType: 'otp',
50+
});
51+
expect(enrollment).toBeDefined();
52+
expect(enrollment.id).toBe('test-id');
53+
});
54+
});
55+
56+
it('should call mfa.challenge', async () => {
57+
const wrapper = createWrapper();
58+
const { result } = renderHook(() => useAuth0(), { wrapper });
59+
60+
await waitFor(async () => {
61+
const response = await result.current.mfa.challenge({
62+
mfaToken: 'test-mfa-token',
63+
challengeType: 'otp',
64+
authenticatorId: 'test-auth-id',
65+
});
66+
expect(response).toBeDefined();
67+
expect(response.challengeType).toBe('otp');
68+
});
69+
});
70+
71+
it('should call mfa.verify', async () => {
72+
const wrapper = createWrapper();
73+
const { result } = renderHook(() => useAuth0(), { wrapper });
74+
75+
await waitFor(async () => {
76+
const tokens = await result.current.mfa.verify({
77+
mfaToken: 'test-mfa-token',
78+
otp: '123456',
79+
});
80+
expect(tokens).toBeDefined();
81+
expect(tokens.access_token).toBe('test-token');
82+
});
83+
});
84+
85+
it('should call mfa.getEnrollmentFactors', async () => {
86+
const wrapper = createWrapper();
87+
const { result } = renderHook(() => useAuth0(), { wrapper });
88+
89+
await waitFor(async () => {
90+
const factors = await result.current.mfa.getEnrollmentFactors('test-mfa-token');
91+
expect(factors).toBeDefined();
92+
expect(Array.isArray(factors)).toBe(true);
93+
});
94+
});
95+
});
96+
});

0 commit comments

Comments
 (0)