-
Notifications
You must be signed in to change notification settings - Fork 71
feat: add Multi-Factor Authentication (MFA) support #909
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,6 +14,8 @@ | |
| - [Standalone Components and a more functional approach](#standalone-components-and-a-more-functional-approach) | ||
| - [Connect Accounts for using Token Vault](#connect-accounts-for-using-token-vault) | ||
| - [Native to Web SSO](#native-to-web-sso) | ||
| - [Multi-Factor Authentication (MFA)](#multi-factor-authentication-mfa) | ||
| - [Step-Up Authentication](#step-up-authentication) | ||
|
|
||
| ## Add login to your application | ||
|
|
||
|
|
@@ -1008,3 +1010,365 @@ this.auth.loginWithRedirect({ | |
| }, | ||
| }); | ||
| ``` | ||
|
|
||
| ## Multi-Factor Authentication (MFA) | ||
|
|
||
| Access MFA operations through the `mfa` property on `AuthService`. All operations require an `mfa_token` from the `MfaRequiredError` thrown by `getAccessTokenSilently`. | ||
|
|
||
| > [!NOTE] | ||
| > Multi Factor Authentication support via SDKs is currently in Early Access. To request access to this feature, contact your Auth0 representative. | ||
|
|
||
| - [Setup](#setup) | ||
| - [Handling MFA Required Error](#handling-mfa-required-error) | ||
| - [Enrolling Authenticators](#enrolling-authenticators) | ||
| - [Challenging Authenticators](#challenging-authenticators) | ||
| - [Verifying Challenges](#verifying-challenges) | ||
| - [Error Handling](#mfa-error-handling) | ||
|
|
||
| ### Setup | ||
|
arpit-jn marked this conversation as resolved.
Outdated
|
||
|
|
||
| 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" }] | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| These two keys are mutually exclusive — a single response will contain either `challenge` or `enroll`, never both: | ||
|
|
||
| - **`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 | ||
|
|
||
| ### Handling MFA Required Error | ||
|
|
||
| Catch the `MfaRequiredError` from `getAccessTokenSilently` and use `mfa_requirements` to determine which flow to follow: | ||
|
|
||
| ```ts | ||
| import { Component } from '@angular/core'; | ||
| import { AuthService, MfaRequiredError } from '@auth0/auth0-angular'; | ||
| import { catchError, EMPTY, switchMap } from 'rxjs'; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This imports Current: Replace with:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. removed switchMap and added tap alongside catchError and EMPTY in a single import from rxjs |
||
|
|
||
| @Component({ selector: 'app-mfa', template: '' }) | ||
| export class MfaComponent { | ||
| constructor(private auth: AuthService) {} | ||
|
|
||
| requestToken() { | ||
| this.auth | ||
| .getAccessTokenSilently() | ||
| .pipe( | ||
| catchError((error) => { | ||
| if (error instanceof MfaRequiredError) { | ||
| const mfaToken = error.mfa_token; | ||
|
|
||
| if (error.mfa_requirements?.enroll?.length) { | ||
| // New user — needs to enroll a factor first | ||
| this.auth.mfa.getEnrollmentFactors(mfaToken).subscribe((factors) => { | ||
|
arpit-jn marked this conversation as resolved.
Outdated
|
||
| // Show enrollment UI with available factors | ||
| }); | ||
| } else { | ||
| // Existing user — list enrolled authenticators and challenge | ||
| this.auth.mfa.getAuthenticators(mfaToken).subscribe((authenticators) => { | ||
| // Show challenge UI | ||
| }); | ||
| } | ||
| } | ||
| return EMPTY; | ||
| }) | ||
| ) | ||
| .subscribe(); | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ### Enrolling Authenticators | ||
|
|
||
| ```ts | ||
| import { Component } from '@angular/core'; | ||
| import { AuthService } from '@auth0/auth0-angular'; | ||
|
|
||
| @Component({ selector: 'app-enroll', template: '' }) | ||
| export class EnrollComponent { | ||
| constructor(private auth: AuthService) {} | ||
|
|
||
| // Enroll TOTP — returns a QR code to display to the user | ||
| enrollOtp(mfaToken: string) { | ||
| this.auth.mfa.enroll({ mfaToken, factorType: 'otp' }).subscribe((enrollment) => { | ||
| console.log('Scan QR:', enrollment.barcodeUri); | ||
| console.log('Recovery codes:', enrollment.recoveryCodes); | ||
| }); | ||
| } | ||
|
|
||
| // Enroll SMS — include phone number in E.164 format | ||
| enrollSms(mfaToken: string) { | ||
| this.auth.mfa | ||
| .enroll({ | ||
| mfaToken, | ||
| factorType: 'sms', | ||
| phoneNumber: '+12025551234', | ||
| }) | ||
| .subscribe(); | ||
| } | ||
|
|
||
| // Enroll Voice — include phone number in E.164 format | ||
| enrollVoice(mfaToken: string) { | ||
| this.auth.mfa | ||
| .enroll({ | ||
| mfaToken, | ||
| factorType: 'voice', | ||
| phoneNumber: '+12025551234', | ||
| }) | ||
| .subscribe(); | ||
| } | ||
|
|
||
| // Enroll Email | ||
| enrollEmail(mfaToken: string) { | ||
| this.auth.mfa | ||
| .enroll({ | ||
| mfaToken, | ||
| factorType: 'email', | ||
| email: 'user@example.com', | ||
| }) | ||
| .subscribe(); | ||
| } | ||
|
|
||
| // Enroll Push — returns authenticator ID for use with the Guardian app | ||
| enrollPush(mfaToken: string) { | ||
| this.auth.mfa.enroll({ mfaToken, factorType: 'push' }).subscribe((enrollment) => { | ||
| console.log('Authenticator ID:', enrollment.id); | ||
| }); | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ### Challenging Authenticators | ||
|
|
||
| ```ts | ||
| import { Component } from '@angular/core'; | ||
| import { AuthService } from '@auth0/auth0-angular'; | ||
| import { switchMap } from 'rxjs'; | ||
|
|
||
| @Component({ selector: 'app-challenge', template: '' }) | ||
| export class ChallengeComponent { | ||
| constructor(private auth: AuthService) {} | ||
|
|
||
| // For OTP: challenge is optional — user can go straight to verify() | ||
| // with the 6-digit code from their authenticator app | ||
| challengeOtp(mfaToken: string, authenticatorId: string) { | ||
| this.auth.mfa | ||
| .challenge({ | ||
| mfaToken, | ||
| challengeType: 'otp', | ||
| authenticatorId, | ||
| }) | ||
| .subscribe(); | ||
| } | ||
|
|
||
| // For SMS / Voice / Email / Push: challenge is required to send the code | ||
| challengeOob(mfaToken: string, authenticatorId: string) { | ||
| this.auth.mfa | ||
| .challenge({ | ||
| mfaToken, | ||
| challengeType: 'oob', | ||
| authenticatorId, | ||
| }) | ||
| .subscribe((response) => { | ||
| console.log('OOB Code:', response.oobCode); // use this in verify() | ||
| }); | ||
| } | ||
|
|
||
| // Typical flow: list authenticators then challenge | ||
| listAndChallenge(mfaToken: string) { | ||
| this.auth.mfa | ||
| .getAuthenticators(mfaToken) | ||
| .pipe( | ||
| switchMap((authenticators) => | ||
| this.auth.mfa.challenge({ | ||
| mfaToken, | ||
| challengeType: 'oob', | ||
| authenticatorId: authenticators[0].id, | ||
| }) | ||
| ) | ||
| ) | ||
| .subscribe((response) => { | ||
| // Code has been sent — show input to user | ||
| }); | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ### Verifying Challenges | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Every Vue PR handles this well — every verify call is followed by Something like: this.auth.mfa.verify({ mfaToken, otp }).pipe(
switchMap(() => this.auth.getAccessTokenSilently())
).subscribe();
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. done, updated all verify examples to chain |
||
|
|
||
| > [!IMPORTANT] > `verify()` does not update Angular auth state (`isAuthenticated$`, `user$`). Call `getAccessTokenSilently()` after a successful verification to reflect the new session in the UI. | ||
|
|
||
| ```ts | ||
| import { Component } from '@angular/core'; | ||
| import { AuthService } from '@auth0/auth0-angular'; | ||
|
|
||
| @Component({ selector: 'app-verify', template: '' }) | ||
| export class VerifyComponent { | ||
| constructor(private auth: AuthService) {} | ||
|
|
||
| // Verify with OTP code (TOTP authenticator app) | ||
| verifyOtp(mfaToken: string, otp: string) { | ||
| this.auth.mfa.verify({ mfaToken, otp }).subscribe((tokens) => { | ||
| console.log('Access token:', tokens.access_token); | ||
| }); | ||
| } | ||
|
|
||
| // Verify with OOB code (SMS / Voice / Email / Push) | ||
| verifyOob(mfaToken: string, oobCode: string, bindingCode?: string) { | ||
| this.auth.mfa.verify({ mfaToken, oobCode, bindingCode }).subscribe((tokens) => { | ||
| console.log('Access token:', tokens.access_token); | ||
| }); | ||
| } | ||
|
|
||
| // Verify with recovery code (fallback for any authenticator) | ||
| verifyRecoveryCode(mfaToken: string, recoveryCode: string) { | ||
| this.auth.mfa.verify({ mfaToken, recoveryCode }).subscribe((tokens) => { | ||
| console.log('Access token:', tokens.access_token); | ||
| }); | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ### MFA Error Handling | ||
|
|
||
| Each MFA operation throws a specific error class you can import from `@auth0/auth0-angular`: | ||
|
|
||
| ```ts | ||
| import { MfaVerifyError, MfaChallengeError, MfaEnrollmentError, MfaListAuthenticatorsError, MfaEnrollmentFactorsError } from '@auth0/auth0-angular'; | ||
| import { catchError, EMPTY } from 'rxjs'; | ||
|
|
||
| this.auth.mfa | ||
| .verify({ mfaToken, otp }) | ||
| .pipe( | ||
| catchError((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); | ||
| } | ||
| return EMPTY; | ||
| }) | ||
| ) | ||
| .subscribe(); | ||
| ``` | ||
|
|
||
| ## Step-Up Authentication | ||
|
|
||
| When a protected API requires MFA, `getAccessTokenSilently` receives an `mfa_required` error from Auth0. By configuring `interactiveErrorHandler`, the SDK automatically handles this by opening a Universal Login popup for the user to complete MFA, then returns the token transparently. No custom MFA UI is required. | ||
|
|
||
| If you need full control over the MFA experience (custom UI for enrollment, challenge, and verification), see the [Multi-Factor Authentication (MFA)](#multi-factor-authentication-mfa) section instead. | ||
|
|
||
| > [!WARNING] | ||
| > This feature only works with the refresh token flow (`useRefreshTokens: true`) and only handles `mfa_required` errors. | ||
|
|
||
| ### Setup | ||
|
|
||
| Configure `provideAuth0` (or `AuthModule.forRoot`) with `interactiveErrorHandler` set to `"popup"` and refresh tokens enabled: | ||
|
|
||
| ```ts | ||
| // app.config.ts — standalone / functional approach | ||
| import { provideAuth0 } from '@auth0/auth0-angular'; | ||
|
|
||
| export const appConfig = { | ||
| providers: [ | ||
| provideAuth0({ | ||
| domain: 'YOUR_AUTH0_DOMAIN', | ||
| clientId: 'YOUR_AUTH0_CLIENT_ID', | ||
| authorizationParams: { | ||
| redirect_uri: window.location.origin, | ||
| audience: 'https://api.example.com/', | ||
| }, | ||
| useRefreshTokens: true, | ||
| interactiveErrorHandler: 'popup', | ||
| }), | ||
| ], | ||
| }; | ||
| ``` | ||
|
|
||
| ```ts | ||
| // app.module.ts — NgModule approach | ||
| import { AuthModule } from '@auth0/auth0-angular'; | ||
|
|
||
| @NgModule({ | ||
| imports: [ | ||
| AuthModule.forRoot({ | ||
| domain: 'YOUR_AUTH0_DOMAIN', | ||
| clientId: 'YOUR_AUTH0_CLIENT_ID', | ||
| authorizationParams: { | ||
| redirect_uri: window.location.origin, | ||
| audience: 'https://api.example.com/', | ||
| }, | ||
| useRefreshTokens: true, | ||
| interactiveErrorHandler: 'popup', | ||
| }), | ||
| ], | ||
| }) | ||
| export class AppModule {} | ||
| ``` | ||
|
|
||
| ### Usage | ||
|
|
||
| With this configuration, `getAccessTokenSilently` automatically opens a popup when the token request triggers an `mfa_required` error. Once the user completes MFA in the popup, the token is returned as if the call succeeded normally: | ||
|
|
||
| ```ts | ||
| import { Component } from '@angular/core'; | ||
| import { AuthService } from '@auth0/auth0-angular'; | ||
|
|
||
| @Component({ selector: 'app-protected', template: '' }) | ||
| export class ProtectedComponent { | ||
| constructor(private auth: AuthService) {} | ||
|
|
||
| fetchSensitiveData() { | ||
| this.auth | ||
| .getAccessTokenSilently({ | ||
| authorizationParams: { | ||
| audience: 'https://api.example.com/', | ||
| scope: 'read:sensitive', | ||
| }, | ||
| }) | ||
| .subscribe({ | ||
| next: (token) => { | ||
| // If MFA was required, the popup opened and closed automatically. | ||
| // token is ready to use. | ||
| fetch('https://api.example.com/sensitive', { | ||
| headers: { Authorization: `Bearer ${token}` }, | ||
| }); | ||
| }, | ||
| error: (e) => console.error(e), | ||
| }); | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ### Error Handling | ||
|
|
||
| If the popup is blocked, cancelled, or times out, `getAccessTokenSilently` throws `PopupOpenError`, `PopupCancelledError`, or `PopupTimeoutError` respectively. These can be imported from `@auth0/auth0-angular`. | ||
|
arpit-jn marked this conversation as resolved.
Outdated
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a recovery code is consumed, Auth0 can return a replacement
recovery_codein the token response. Neither PR's examples mention this. A one-liner checkingtokens.recovery_codeand prompting the user to save it would go a long way, losing track of the new code locks the user out.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
good point, added a check for
tokens.recovery_codein the recovery code example