From 2cdb37b1b1b07965d29e5534a58fc352fe44b4d5 Mon Sep 17 00:00:00 2001 From: Gyanesh Gouraw Date: Fri, 6 Feb 2026 11:43:30 +0530 Subject: [PATCH 1/2] feat: add Multi-Factor Authentication (MFA) support and update context interfaces --- EXAMPLES.md | 221 ++++++++++++++++++++++++++++++++++++++++- src/auth0-context.tsx | 64 +++++++++++- src/auth0-provider.tsx | 1 + src/index.tsx | 22 ++++ 4 files changed, 306 insertions(+), 2 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 9e336669..3b474820 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -766,4 +766,223 @@ const ConfigInfo = () => { export default ConfigInfo; ``` -This is useful for debugging, logging, or building custom Auth0-related URLs without duplicating configuration values. \ No newline at end of file +This is useful for debugging, logging, or building custom Auth0-related URLs without duplicating configuration values. + +## Multi-Factor Authentication (MFA) + +Access MFA operations through the `mfa` property from `useAuth0()`. All operations require an `mfa_token` from the MFA required error. + +### Setup + +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). + +#### Understanding the MFA Response + +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). + +**Challenge Flow Response** (user has existing authenticators): + +```json +{ + "error": "mfa_required", + "error_description": "Multifactor authentication required", + "mfa_token": "Fe26.2*...", + "mfa_requirements": { + "challenge": [ + { "type": "otp" }, + { "type": "email" } + ... + ] + } +} +``` + +**Enroll Flow Response** (user needs to enroll an authenticator): + +```json +{ + "error": "mfa_required", + "error_description": "Multifactor authentication required", + "mfa_token": "Fe26.2*...", + "mfa_requirements": { + "enroll": [ + { "type": "otp" }, + { "type": "phone" }, + { "type": "push-notification" } + ... + ] + } +} +``` + +Based on the response: +- **`mfa_requirements.challenge`**: User has enrolled authenticators → proceed with **List Authenticators → Challenge → Verify** flow +- **`mfa_requirements.enroll`**: User needs to set up MFA → proceed with **Enroll → Verify** flow + +> [!NOTE] +> The SDK handles this logic automatically. When you call `getEnrollmentFactors()` or `getAuthenticators()`, the SDK uses the stored context to return the appropriate data. + + +### Handling MFA Required Error +When MFA is required, the SDK automatically stores the context. You can then call MFA methods with just the token: + +```jsx +import { useAuth0, MfaRequiredError } from '@auth0/auth0-react'; + +try { + await getAccessTokenSilently(); +} catch (error) { + if (error instanceof MfaRequiredError) { + const mfaToken = error.mfa_token; + + // Check if user needs to enroll + const factors = await mfa.getEnrollmentFactors(mfaToken); + if (factors.length > 0) { + // Show enrollment UI + } else { + // User has enrolled authenticators - get the list of enrolled authenticator + const authenticators = await mfa.getAuthenticators(error.mfa_token); + + // proceed with challenge + } + } +} +``` + +### Enrolling Authenticators + +```jsx +const { mfa } = useAuth0(); + +// Enroll any factor type +const enrollment = await mfa.enroll({ + mfaToken, + factorType: 'otp' // 'otp' | 'sms' | 'email' | 'voice' | 'push' +}); + +// For OTP: Display QR code +console.log('Scan:', enrollment.barcodeUri); +console.log('Recovery codes:', enrollment.recoveryCodes); + +// For SMS: Include phone number +await mfa.enroll({ + mfaToken, + factorType: 'sms', + phoneNumber: '+12025551234' // E.164 format +}); + +// For Voice: Include phone number +await mfa.enroll({ + mfaToken, + factorType: 'voice', + phoneNumber: '+12025551234' // E.164 format +}); + +// For Email: Include email address +await mfa.enroll({ + mfaToken, + factorType: 'email', + email: 'user@example.com' +}); + +// For Push: Returns authenticator for mobile app +const pushEnrollment = await mfa.enroll({ + mfaToken, + factorType: 'push' +}); +console.log('Authenticator ID:', pushEnrollment.id); // Use with Guardian app +``` + +### Challenging Authenticators + +```jsx +const { mfa } = useAuth0(); + +// Get enrolled authenticators +const authenticators = await mfa.getAuthenticators(mfaToken); + +// For OTP: Challenge is OPTIONAL - code already available in authenticator app +// Skip directly to verify() with the 6-digit code + +// For SMS: Challenge REQUIRED to send code +const smsResponse = await mfa.challenge({ + mfaToken, + challengeType: 'sms', + authenticatorId: authenticators[0].id +}); +console.log('OOB Code:', smsResponse.oobCode); // SMS sent to phone + +// For Voice: Challenge REQUIRED to initiate call +const voiceResponse = await mfa.challenge({ + mfaToken, + challengeType: 'voice', + authenticatorId: authenticators[0].id +}); +console.log('OOB Code:', voiceResponse.oobCode); // Voice call initiated + +// For Email: Challenge REQUIRED to send email +const emailResponse = await mfa.challenge({ + mfaToken, + challengeType: 'email', + authenticatorId: authenticators[0].id +}); +console.log('OOB Code:', emailResponse.oobCode); // Email sent + +// For Push: Challenge REQUIRED to send notification +await mfa.challenge({ + mfaToken, + challengeType: 'push', + authenticatorId: authenticators[0].id +}); +// Push notification sent to Guardian app +``` + +### Verifying Challenges + +```jsx +const { mfa } = useAuth0(); + +// Verify with OTP code (for OTP authenticators) +const tokens = await mfa.verify({ + mfaToken, + otp: '123456' // 6-digit code from authenticator app +}); + +// Verify with OOB code (for SMS/Voice/Email authenticators) +const tokens = await mfa.verify({ + mfaToken, + oobCode: smsResponse.oobCode, + bindingCode: '123456' // Optional: code shown in challenge +}); + +// Verify with recovery code (works for any authenticator) +const tokens = await mfa.verify({ + mfaToken, + recoveryCode: 'recovery-code-here' +}); + +// Tokens are now cached - user is authenticated +console.log('Access token:', tokens.access_token); +``` + +### Error Handling + +```jsx +import { + MfaEnrollmentError, + MfaChallengeError, + MfaVerifyError +} from '@auth0/auth0-react'; + +try { + await mfa.verify({ mfaToken, otp }); +} catch (error) { + if (error instanceof MfaVerifyError) { + console.error('Invalid code:', error.error_description); + } else if (error instanceof MfaChallengeError) { + console.error('Challenge failed:', error.error_description); + } else if (error instanceof MfaEnrollmentError) { + console.error('Enrollment failed:', error.error_description); + } +} +``` diff --git a/src/auth0-context.tsx b/src/auth0-context.tsx index 07ed729f..7d3483dc 100644 --- a/src/auth0-context.tsx +++ b/src/auth0-context.tsx @@ -13,7 +13,8 @@ import { RedirectConnectAccountOptions, ConnectAccountRedirectResult, CustomTokenExchangeOptions, - TokenEndpointResponse + TokenEndpointResponse, + type MfaApiClient } from '@auth0/auth0-spa-js'; import { createContext } from 'react'; import { AuthState, initialAuthState } from './auth-state'; @@ -308,6 +309,60 @@ export interface Auth0ContextInterface * containing the domain and clientId. */ getConfiguration: Auth0Client['getConfiguration']; + + /** + * ```js + * const { mfa } = useAuth0(); + * const authenticators = await mfa.getAuthenticators(mfaToken); + * ``` + * + * MFA API client for Multi-Factor Authentication operations. + * + * Provides access to all MFA-related methods: + * - `getAuthenticators(mfaToken)` - List enrolled authenticators + * - `enroll(params)` - Enroll new authenticators (OTP, SMS, Voice, Email, Push) + * - `challenge(params)` - Initiate MFA challenges + * - `verify(params)` - Verify MFA challenges and complete authentication + * - `getEnrollmentFactors(mfaToken)` - Get available enrollment factors + * + * @example + * ```js + * const { mfa, getAccessTokenSilently } = useAuth0(); + * + * try { + * await getAccessTokenSilently(); + * } catch (error) { + * if (error.error === 'mfa_required') { + * // Check if enrollment is needed + * const factors = await mfa.getEnrollmentFactors(error.mfa_token); + * + * if (factors.length > 0) { + * // Enroll in OTP + * const enrollment = await mfa.enroll({ + * mfaToken: error.mfa_token, + * factorType: 'otp' + * }); + * console.log('QR Code:', enrollment.barcodeUri); + * } + * + * // Get authenticators and challenge + * const authenticators = await mfa.getAuthenticators(error.mfa_token); + * await mfa.challenge({ + * mfaToken: error.mfa_token, + * challengeType: 'otp', + * authenticatorId: authenticators[0].id + * }); + * + * // Verify with user's code + * const tokens = await mfa.verify({ + * mfaToken: error.mfa_token, + * otp: userCode + * }); + * } + * } + * ``` + */ + mfa: MfaApiClient; } /** @@ -339,6 +394,13 @@ export const initialContext = { generateDpopProof: stub, createFetcher: stub, getConfiguration: stub, + mfa: { + getAuthenticators: stub, + enroll: stub, + challenge: stub, + verify: stub, + getEnrollmentFactors: stub, + } as MfaApiClient, }; /** diff --git a/src/auth0-provider.tsx b/src/auth0-provider.tsx index b10a20a6..0e931064 100644 --- a/src/auth0-provider.tsx +++ b/src/auth0-provider.tsx @@ -373,6 +373,7 @@ const Auth0Provider = (opts: Auth0ProviderOptions Date: Fri, 6 Feb 2026 13:43:58 +0530 Subject: [PATCH 2/2] feat: implement MFA methods related tests --- EXAMPLES.md | 35 +++-------- __mocks__/@auth0/auth0-spa-js.tsx | 21 ++++++- __tests__/mfa.test.tsx | 96 +++++++++++++++++++++++++++++++ src/auth0-context.tsx | 2 +- 4 files changed, 125 insertions(+), 29 deletions(-) create mode 100644 __tests__/mfa.test.tsx diff --git a/EXAMPLES.md b/EXAMPLES.md index 3b474820..f7368dac 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -902,39 +902,20 @@ const { mfa } = useAuth0(); const authenticators = await mfa.getAuthenticators(mfaToken); // For OTP: Challenge is OPTIONAL - code already available in authenticator app -// Skip directly to verify() with the 6-digit code - -// For SMS: Challenge REQUIRED to send code -const smsResponse = await mfa.challenge({ - mfaToken, - challengeType: 'sms', - authenticatorId: authenticators[0].id -}); -console.log('OOB Code:', smsResponse.oobCode); // SMS sent to phone - -// For Voice: Challenge REQUIRED to initiate call -const voiceResponse = await mfa.challenge({ +// Skip directly to verify() with the 6-digit code, or optionally challenge with: +const otpResponse = await mfa.challenge({ mfaToken, - challengeType: 'voice', + challengeType: 'otp', authenticatorId: authenticators[0].id }); -console.log('OOB Code:', voiceResponse.oobCode); // Voice call initiated -// For Email: Challenge REQUIRED to send email -const emailResponse = await mfa.challenge({ +// For SMS/Voice/Email/Push: Challenge REQUIRED to send code (use 'oob' type) +const oobResponse = await mfa.challenge({ mfaToken, - challengeType: 'email', - authenticatorId: authenticators[0].id -}); -console.log('OOB Code:', emailResponse.oobCode); // Email sent - -// For Push: Challenge REQUIRED to send notification -await mfa.challenge({ - mfaToken, - challengeType: 'push', - authenticatorId: authenticators[0].id + challengeType: 'oob', // Use 'oob' for all out-of-band authenticators + authenticatorId: authenticators[0].id // ID of SMS/Voice/Email/Push authenticator }); -// Push notification sent to Guardian app +console.log('OOB Code:', oobResponse.oobCode); // Code sent via SMS/Voice/Email/Push ``` ### Verifying Challenges diff --git a/__mocks__/@auth0/auth0-spa-js.tsx b/__mocks__/@auth0/auth0-spa-js.tsx index 0e101a2c..d3fa5ac6 100644 --- a/__mocks__/@auth0/auth0-spa-js.tsx +++ b/__mocks__/@auth0/auth0-spa-js.tsx @@ -20,6 +20,11 @@ const setDpopNonce = jest.fn(); const generateDpopProof = jest.fn(); const createFetcher = jest.fn(); const getConfiguration = jest.fn(); +const mfaGetAuthenticators = jest.fn(() => Promise.resolve([])); +const mfaEnroll = jest.fn(() => Promise.resolve({ id: 'test-id', barcodeUri: 'test-uri', recoveryCodes: [] })); +const mfaChallenge = jest.fn(() => Promise.resolve({ challengeType: 'otp', oobCode: null })); +const mfaVerify = jest.fn(() => Promise.resolve({ access_token: 'test-token', id_token: 'test-id-token' })); +const mfaGetEnrollmentFactors = jest.fn(() => Promise.resolve([])); export const Auth0Client = jest.fn(() => { return { @@ -43,7 +48,21 @@ export const Auth0Client = jest.fn(() => { generateDpopProof, createFetcher, getConfiguration, + mfa: { + getAuthenticators: mfaGetAuthenticators, + enroll: mfaEnroll, + challenge: mfaChallenge, + verify: mfaVerify, + getEnrollmentFactors: mfaGetEnrollmentFactors, + }, }; }); -export const ResponseType = actual.ResponseType; \ No newline at end of file +export const ResponseType = actual.ResponseType; + +export const MfaError = actual.MfaError; +export const MfaListAuthenticatorsError = actual.MfaListAuthenticatorsError; +export const MfaEnrollmentError = actual.MfaEnrollmentError; +export const MfaChallengeError = actual.MfaChallengeError; +export const MfaVerifyError = actual.MfaVerifyError; +export const MfaEnrollmentFactorsError = actual.MfaEnrollmentFactorsError; \ No newline at end of file diff --git a/__tests__/mfa.test.tsx b/__tests__/mfa.test.tsx new file mode 100644 index 00000000..0d1f49b8 --- /dev/null +++ b/__tests__/mfa.test.tsx @@ -0,0 +1,96 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import useAuth0 from '../src/use-auth0'; +import { createWrapper } from './helpers'; + +describe('MFA API', () => { + describe('Basic Availability', () => { + it('should provide mfa client through useAuth0', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => { + expect(result.current.mfa).toBeDefined(); + }); + }); + + it('should provide all five MFA methods', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => { + expect(result.current.mfa.getAuthenticators).toBeDefined(); + expect(result.current.mfa.enroll).toBeDefined(); + expect(result.current.mfa.challenge).toBeDefined(); + expect(result.current.mfa.verify).toBeDefined(); + expect(result.current.mfa.getEnrollmentFactors).toBeDefined(); + }); + }); + }); + + describe('Method Success Tests', () => { + it('should call mfa.getAuthenticators', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(async () => { + const authenticators = await result.current.mfa.getAuthenticators('test-mfa-token'); + expect(authenticators).toBeDefined(); + expect(Array.isArray(authenticators)).toBe(true); + }); + }); + + it('should call mfa.enroll', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(async () => { + const enrollment = await result.current.mfa.enroll({ + mfaToken: 'test-mfa-token', + factorType: 'otp', + }); + expect(enrollment).toBeDefined(); + expect(enrollment.id).toBe('test-id'); + }); + }); + + it('should call mfa.challenge', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(async () => { + const response = await result.current.mfa.challenge({ + mfaToken: 'test-mfa-token', + challengeType: 'otp', + authenticatorId: 'test-auth-id', + }); + expect(response).toBeDefined(); + expect(response.challengeType).toBe('otp'); + }); + }); + + it('should call mfa.verify', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(async () => { + const tokens = await result.current.mfa.verify({ + mfaToken: 'test-mfa-token', + otp: '123456', + }); + expect(tokens).toBeDefined(); + expect(tokens.access_token).toBe('test-token'); + }); + }); + + it('should call mfa.getEnrollmentFactors', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(async () => { + const factors = await result.current.mfa.getEnrollmentFactors('test-mfa-token'); + expect(factors).toBeDefined(); + expect(Array.isArray(factors)).toBe(true); + }); + }); + }); +}); diff --git a/src/auth0-context.tsx b/src/auth0-context.tsx index 7d3483dc..53e994c4 100644 --- a/src/auth0-context.tsx +++ b/src/auth0-context.tsx @@ -400,7 +400,7 @@ export const initialContext = { challenge: stub, verify: stub, getEnrollmentFactors: stub, - } as MfaApiClient, + } as unknown as MfaApiClient, }; /**