Skip to content

Commit 37bd431

Browse files
feat: add Multi-Factor Authentication (MFA) support
1 parent df74d88 commit 37bd431

5 files changed

Lines changed: 716 additions & 1 deletion

File tree

EXAMPLES.md

Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
- [Standalone Components and a more functional approach](#standalone-components-and-a-more-functional-approach)
1515
- [Connect Accounts for using Token Vault](#connect-accounts-for-using-token-vault)
1616
- [Native to Web SSO](#native-to-web-sso)
17+
- [Multi-Factor Authentication (MFA)](#multi-factor-authentication-mfa)
18+
- [Step-Up Authentication](#step-up-authentication)
1719

1820
## Add login to your application
1921

@@ -1008,3 +1010,363 @@ this.auth.loginWithRedirect({
10081010
},
10091011
});
10101012
```
1013+
1014+
## Multi-Factor Authentication (MFA)
1015+
1016+
Access MFA operations through the `mfa` property on `AuthService`. All operations require an `mfa_token` from the `MfaRequiredError` thrown by `getAccessTokenSilently`.
1017+
1018+
> [!NOTE]
1019+
> Multi Factor Authentication support via SDKs is currently in Early Access. To request access to this feature, contact your Auth0 representative.
1020+
1021+
- [Setup](#setup)
1022+
- [Handling MFA Required Error](#handling-mfa-required-error)
1023+
- [Enrolling Authenticators](#enrolling-authenticators)
1024+
- [Challenging Authenticators](#challenging-authenticators)
1025+
- [Verifying Challenges](#verifying-challenges)
1026+
- [Error Handling](#mfa-error-handling)
1027+
1028+
### Setup
1029+
1030+
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).
1031+
1032+
#### Understanding the MFA Response
1033+
1034+
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).
1035+
1036+
**Challenge Flow Response** (user has existing authenticators):
1037+
1038+
```json
1039+
{
1040+
"error": "mfa_required",
1041+
"error_description": "Multifactor authentication required",
1042+
"mfa_token": "Fe26.2*...",
1043+
"mfa_requirements": {
1044+
"challenge": [{ "type": "otp" }, { "type": "email" }]
1045+
}
1046+
}
1047+
```
1048+
1049+
**Enroll Flow Response** (user needs to enroll an authenticator):
1050+
1051+
```json
1052+
{
1053+
"error": "mfa_required",
1054+
"error_description": "Multifactor authentication required",
1055+
"mfa_token": "Fe26.2*...",
1056+
"mfa_requirements": {
1057+
"enroll": [{ "type": "otp" }, { "type": "phone" }, { "type": "push-notification" }]
1058+
}
1059+
}
1060+
```
1061+
1062+
These two keys are mutually exclusive — a single response will contain either `challenge` or `enroll`, never both:
1063+
1064+
- **`mfa_requirements.challenge`**: User has enrolled authenticators → proceed with **List Authenticators → Challenge → Verify** flow
1065+
- **`mfa_requirements.enroll`**: User needs to set up MFA → proceed with **Enroll → Verify** flow
1066+
1067+
### Handling MFA Required Error
1068+
1069+
Catch the `MfaRequiredError` from `getAccessTokenSilently` and use `mfa_requirements` to determine which flow to follow:
1070+
1071+
```ts
1072+
import { Component } from '@angular/core';
1073+
import { AuthService, MfaRequiredError } from '@auth0/auth0-angular';
1074+
import { catchError, EMPTY, switchMap } from 'rxjs';
1075+
1076+
@Component({ selector: 'app-mfa', template: '' })
1077+
export class MfaComponent {
1078+
constructor(private auth: AuthService) {}
1079+
1080+
requestToken() {
1081+
this.auth
1082+
.getAccessTokenSilently()
1083+
.pipe(
1084+
catchError((error) => {
1085+
if (error instanceof MfaRequiredError) {
1086+
const mfaToken = error.mfa_token;
1087+
1088+
if (error.mfa_requirements?.enroll?.length) {
1089+
// New user — needs to enroll a factor first
1090+
this.auth.mfa.getEnrollmentFactors(mfaToken).subscribe((factors) => {
1091+
// Show enrollment UI with available factors
1092+
});
1093+
} else {
1094+
// Existing user — list enrolled authenticators and challenge
1095+
this.auth.mfa.getAuthenticators(mfaToken).subscribe((authenticators) => {
1096+
// Show challenge UI
1097+
});
1098+
}
1099+
}
1100+
return EMPTY;
1101+
})
1102+
)
1103+
.subscribe();
1104+
}
1105+
}
1106+
```
1107+
1108+
### Enrolling Authenticators
1109+
1110+
```ts
1111+
import { Component } from '@angular/core';
1112+
import { AuthService } from '@auth0/auth0-angular';
1113+
1114+
@Component({ selector: 'app-enroll', template: '' })
1115+
export class EnrollComponent {
1116+
constructor(private auth: AuthService) {}
1117+
1118+
// Enroll TOTP — returns a QR code to display to the user
1119+
enrollOtp(mfaToken: string) {
1120+
this.auth.mfa.enroll({ mfaToken, factorType: 'otp' }).subscribe((enrollment) => {
1121+
console.log('Scan QR:', enrollment.barcodeUri);
1122+
console.log('Recovery codes:', enrollment.recoveryCodes);
1123+
});
1124+
}
1125+
1126+
// Enroll SMS — include phone number in E.164 format
1127+
enrollSms(mfaToken: string) {
1128+
this.auth.mfa
1129+
.enroll({
1130+
mfaToken,
1131+
factorType: 'sms',
1132+
phoneNumber: '+12025551234',
1133+
})
1134+
.subscribe();
1135+
}
1136+
1137+
// Enroll Voice — include phone number in E.164 format
1138+
enrollVoice(mfaToken: string) {
1139+
this.auth.mfa
1140+
.enroll({
1141+
mfaToken,
1142+
factorType: 'voice',
1143+
phoneNumber: '+12025551234',
1144+
})
1145+
.subscribe();
1146+
}
1147+
1148+
// Enroll Email
1149+
enrollEmail(mfaToken: string) {
1150+
this.auth.mfa
1151+
.enroll({
1152+
mfaToken,
1153+
factorType: 'email',
1154+
email: 'user@example.com',
1155+
})
1156+
.subscribe();
1157+
}
1158+
1159+
// Enroll Push — returns authenticator ID for use with the Guardian app
1160+
enrollPush(mfaToken: string) {
1161+
this.auth.mfa.enroll({ mfaToken, factorType: 'push' }).subscribe((enrollment) => {
1162+
console.log('Authenticator ID:', enrollment.id);
1163+
});
1164+
}
1165+
}
1166+
```
1167+
1168+
### Challenging Authenticators
1169+
1170+
```ts
1171+
import { Component } from '@angular/core';
1172+
import { AuthService } from '@auth0/auth0-angular';
1173+
import { switchMap } from 'rxjs';
1174+
1175+
@Component({ selector: 'app-challenge', template: '' })
1176+
export class ChallengeComponent {
1177+
constructor(private auth: AuthService) {}
1178+
1179+
// For OTP: challenge is optional — user can go straight to verify()
1180+
// with the 6-digit code from their authenticator app
1181+
challengeOtp(mfaToken: string, authenticatorId: string) {
1182+
this.auth.mfa
1183+
.challenge({
1184+
mfaToken,
1185+
challengeType: 'otp',
1186+
authenticatorId,
1187+
})
1188+
.subscribe();
1189+
}
1190+
1191+
// For SMS / Voice / Email / Push: challenge is required to send the code
1192+
challengeOob(mfaToken: string, authenticatorId: string) {
1193+
this.auth.mfa
1194+
.challenge({
1195+
mfaToken,
1196+
challengeType: 'oob',
1197+
authenticatorId,
1198+
})
1199+
.subscribe((response) => {
1200+
console.log('OOB Code:', response.oobCode); // use this in verify()
1201+
});
1202+
}
1203+
1204+
// Typical flow: list authenticators then challenge
1205+
listAndChallenge(mfaToken: string) {
1206+
this.auth.mfa
1207+
.getAuthenticators(mfaToken)
1208+
.pipe(
1209+
switchMap((authenticators) =>
1210+
this.auth.mfa.challenge({
1211+
mfaToken,
1212+
challengeType: 'oob',
1213+
authenticatorId: authenticators[0].id,
1214+
})
1215+
)
1216+
)
1217+
.subscribe((response) => {
1218+
// Code has been sent — show input to user
1219+
});
1220+
}
1221+
}
1222+
```
1223+
1224+
### Verifying Challenges
1225+
1226+
```ts
1227+
import { Component } from '@angular/core';
1228+
import { AuthService } from '@auth0/auth0-angular';
1229+
1230+
@Component({ selector: 'app-verify', template: '' })
1231+
export class VerifyComponent {
1232+
constructor(private auth: AuthService) {}
1233+
1234+
// Verify with OTP code (TOTP authenticator app)
1235+
verifyOtp(mfaToken: string, otp: string) {
1236+
this.auth.mfa.verify({ mfaToken, otp }).subscribe((tokens) => {
1237+
console.log('Access token:', tokens.access_token);
1238+
});
1239+
}
1240+
1241+
// Verify with OOB code (SMS / Voice / Email / Push)
1242+
verifyOob(mfaToken: string, oobCode: string, bindingCode?: string) {
1243+
this.auth.mfa.verify({ mfaToken, oobCode, bindingCode }).subscribe((tokens) => {
1244+
console.log('Access token:', tokens.access_token);
1245+
});
1246+
}
1247+
1248+
// Verify with recovery code (fallback for any authenticator)
1249+
verifyRecoveryCode(mfaToken: string, recoveryCode: string) {
1250+
this.auth.mfa.verify({ mfaToken, recoveryCode }).subscribe((tokens) => {
1251+
console.log('Access token:', tokens.access_token);
1252+
});
1253+
}
1254+
}
1255+
```
1256+
1257+
### MFA Error Handling
1258+
1259+
Each MFA operation throws a specific error class you can import from `@auth0/auth0-angular`:
1260+
1261+
```ts
1262+
import { MfaVerifyError, MfaChallengeError, MfaEnrollmentError, MfaListAuthenticatorsError, MfaEnrollmentFactorsError } from '@auth0/auth0-angular';
1263+
import { catchError, EMPTY } from 'rxjs';
1264+
1265+
this.auth.mfa
1266+
.verify({ mfaToken, otp })
1267+
.pipe(
1268+
catchError((error) => {
1269+
if (error instanceof MfaVerifyError) {
1270+
console.error('Invalid code:', error.error_description);
1271+
} else if (error instanceof MfaChallengeError) {
1272+
console.error('Challenge failed:', error.error_description);
1273+
} else if (error instanceof MfaEnrollmentError) {
1274+
console.error('Enrollment failed:', error.error_description);
1275+
}
1276+
return EMPTY;
1277+
})
1278+
)
1279+
.subscribe();
1280+
```
1281+
1282+
## Step-Up Authentication
1283+
1284+
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.
1285+
1286+
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.
1287+
1288+
> [!WARNING]
1289+
> This feature only works with the refresh token flow (`useRefreshTokens: true`) and only handles `mfa_required` errors.
1290+
1291+
### Setup
1292+
1293+
Configure `provideAuth0` (or `AuthModule.forRoot`) with `interactiveErrorHandler` set to `"popup"` and refresh tokens enabled:
1294+
1295+
```ts
1296+
// app.config.ts — standalone / functional approach
1297+
import { provideAuth0 } from '@auth0/auth0-angular';
1298+
1299+
export const appConfig = {
1300+
providers: [
1301+
provideAuth0({
1302+
domain: 'YOUR_AUTH0_DOMAIN',
1303+
clientId: 'YOUR_AUTH0_CLIENT_ID',
1304+
authorizationParams: {
1305+
redirect_uri: window.location.origin,
1306+
audience: 'https://api.example.com/',
1307+
},
1308+
useRefreshTokens: true,
1309+
interactiveErrorHandler: 'popup',
1310+
}),
1311+
],
1312+
};
1313+
```
1314+
1315+
```ts
1316+
// app.module.ts — NgModule approach
1317+
import { AuthModule } from '@auth0/auth0-angular';
1318+
1319+
@NgModule({
1320+
imports: [
1321+
AuthModule.forRoot({
1322+
domain: 'YOUR_AUTH0_DOMAIN',
1323+
clientId: 'YOUR_AUTH0_CLIENT_ID',
1324+
authorizationParams: {
1325+
redirect_uri: window.location.origin,
1326+
audience: 'https://api.example.com/',
1327+
},
1328+
useRefreshTokens: true,
1329+
interactiveErrorHandler: 'popup',
1330+
}),
1331+
],
1332+
})
1333+
export class AppModule {}
1334+
```
1335+
1336+
### Usage
1337+
1338+
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:
1339+
1340+
```ts
1341+
import { Component } from '@angular/core';
1342+
import { AuthService } from '@auth0/auth0-angular';
1343+
1344+
@Component({ selector: 'app-protected', template: '' })
1345+
export class ProtectedComponent {
1346+
constructor(private auth: AuthService) {}
1347+
1348+
fetchSensitiveData() {
1349+
this.auth
1350+
.getAccessTokenSilently({
1351+
authorizationParams: {
1352+
audience: 'https://api.example.com/',
1353+
scope: 'read:sensitive',
1354+
},
1355+
})
1356+
.subscribe({
1357+
next: (token) => {
1358+
// If MFA was required, the popup opened and closed automatically.
1359+
// token is ready to use.
1360+
fetch('https://api.example.com/sensitive', {
1361+
headers: { Authorization: `Bearer ${token}` },
1362+
});
1363+
},
1364+
error: (e) => console.error(e),
1365+
});
1366+
}
1367+
}
1368+
```
1369+
1370+
### Error Handling
1371+
1372+
If the popup is blocked, cancelled, or times out, `getAccessTokenSilently` throws `PopupOpenError`, `PopupCancelledError`, or `PopupTimeoutError` respectively. These can be imported from `@auth0/auth0-angular`.

0 commit comments

Comments
 (0)