Skip to content

Commit 9228345

Browse files
feat: support MFA step-up authentication via popup in iframe-based silent auth flow (#1540)
### Summary Extend `interactiveErrorHandler: 'popup'` to handle MFA step-up errors from the iframe path. Previously, step-up only worked with refresh tokens. The iframe path returned `login_required` (not `mfa_required`), which caused a premature `logout()` and was not detected as an interactive error. Now both token acquisition paths trigger the popup automatically. Extends step-up functionality added in #1531 ### Changes - Detect iframe MFA step-up errors by matching `error='login_required'` + `error_description='Multifactor authentication required'` - Widen interactive error detection to match both `MfaRequiredError` (refresh tokens) and the iframe `GenericError` variant - Skip `logout()` when the `login_required` error is an MFA step-up and the handler is configured, preserving the session for the popup - Add two tests: popup opens on iframe MFA error, and `logout()` is not called - Remove internal jargon ("iframe flow", "refresh token flow") from user-facing step-up docs ### Example ```js // Works with or without useRefreshTokens — no change needed const auth0 = await createAuth0Client({ domain: '<AUTH0_DOMAIN>', clientId: '<AUTH0_CLIENT_ID>', interactiveErrorHandler: 'popup' }); // If MFA is required, popup opens automatically const token = await auth0.getTokenSilently({ authorizationParams: { audience: 'https://api.example.com', scope: 'read:sensitive-data' } }); ``` ### Testing - Added two new tests to cover the recent changes - All tests are passing - `npm run build` completes successfully - Performed manual testing using `static/step-up.html`: reproduced the iframe-based silent token flow with MFA step-up and confirmed that the popup appears and successfully returns a token
1 parent ddbadd8 commit 9228345

File tree

4 files changed

+153
-15
lines changed

4 files changed

+153
-15
lines changed

EXAMPLES.md

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1208,22 +1208,19 @@ try {
12081208

12091209
Step-up authentication lets you request elevated access for sensitive operations (e.g. a specific audience or scope) and automatically handle MFA challenges via a popup, without manually catching errors or managing the MFA API.
12101210

1211-
When `getTokenSilently()` encounters an `mfa_required` error and `interactiveErrorHandler` is configured, the SDK automatically opens a Universal Login popup to complete MFA, then returns the token.
1212-
1213-
> [!NOTE]
1214-
> The interactive error handler currently only handles `mfa_required` errors. Other interactive errors (e.g. `login_required`, `consent_required`) are not intercepted and will be thrown to the caller as usual.
1211+
When `getTokenSilently()` encounters an MFA step-up error and `interactiveErrorHandler` is configured, the SDK automatically opens a Universal Login popup to complete MFA, then returns the token. This works regardless of whether you use refresh tokens (`useRefreshTokens: true`) or the default configuration.
12151212

12161213
### Setup
12171214

1218-
Enable the interactive error handler when creating the client. Refresh tokens must also be enabled, since `mfa_required` errors originate from the refresh token exchange. This feature is most suitable when combined with [Multi-Resource Refresh Tokens (MRRT)](#using-multi-resource-refresh-tokens), which allow a single refresh token to obtain access tokens for multiple APIs — making step-up requests across different audiences seamless.
1215+
Enable the interactive error handler when creating the client. Step-up authentication works with or without refresh tokens — no additional configuration is needed. When using refresh tokens, consider combining with [Multi-Resource Refresh Tokens (MRRT)](#using-multi-resource-refresh-tokens), which allow a single refresh token to obtain access tokens for multiple APIs — making step-up requests across different audiences seamless.
12191216

12201217
```js
12211218
const auth0 = await createAuth0Client({
12221219
domain: '<AUTH0_DOMAIN>',
12231220
clientId: '<AUTH0_CLIENT_ID>',
1224-
useRefreshTokens: true,
1225-
useMrrt: true, //optional
12261221
interactiveErrorHandler: 'popup',
1222+
useRefreshTokens: true, // optional — works with or without refresh tokens
1223+
useMrrt: true, // optional — useful when stepping up across multiple APIs
12271224
authorizationParams: {
12281225
redirect_uri: '<MY_CALLBACK_URL>'
12291226
}
@@ -1279,7 +1276,7 @@ try {
12791276
```
12801277

12811278
> [!NOTE]
1282-
> If `interactiveErrorHandler` is not configured, `MfaRequiredError` is thrown as usual, and you can handle it manually using the [MFA API](#multi-factor-authentication-mfa).
1279+
> If `interactiveErrorHandler` is not configured, MFA errors are thrown to the caller as usual. When using refresh tokens, you can handle `MfaRequiredError` manually using the [MFA API](#multi-factor-authentication-mfa).
12831280
12841281
> [!IMPORTANT]
12851282
> `interactiveErrorHandler` only affects `getTokenSilently()`. Other methods like `loginWithPopup()` and `loginWithRedirect()` are not affected.

__tests__/Auth0Client/interactiveErrorHandler.test.ts

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ import {
2323
TEST_TOKEN_TYPE
2424
} from '../constants';
2525

26-
import { MfaRequiredError } from '../../src/errors';
26+
import { GenericError, MfaRequiredError } from '../../src/errors';
27+
import { MFA_STEP_UP_ERROR_DESCRIPTION } from '../../src/constants';
2728

2829
jest.mock('es-cookie');
2930
jest.mock('../../src/jwt');
@@ -325,5 +326,108 @@ describe('Auth0Client', () => {
325326
expect(utils.runPopup).not.toHaveBeenCalled();
326327
});
327328
});
329+
330+
describe('iframe flow', () => {
331+
it('should open popup when iframe returns MFA step-up error', async () => {
332+
const auth0 = setup({
333+
interactiveErrorHandler: 'popup',
334+
});
335+
336+
await loginWithRedirect(auth0, {
337+
authorizationParams: {
338+
audience: TEST_AUDIENCE,
339+
scope: 'read:data'
340+
}
341+
});
342+
343+
mockFetch.mockReset();
344+
345+
// Mock runIframe to reject with MFA step-up error
346+
jest.spyOn(<any>utils, 'runIframe').mockRejectedValue(
347+
GenericError.fromPayload({
348+
error: 'login_required',
349+
error_description: MFA_STEP_UP_ERROR_DESCRIPTION
350+
})
351+
);
352+
353+
setupPopupMock(mockWindow, {
354+
code: 'my_code',
355+
state: TEST_STATE
356+
});
357+
358+
// Token response from popup's code exchange
359+
mockFetch.mockResolvedValueOnce(
360+
fetchResponse(true, {
361+
id_token: TEST_ID_TOKEN,
362+
refresh_token: TEST_REFRESH_TOKEN,
363+
access_token: TEST_ACCESS_TOKEN,
364+
token_type: TEST_TOKEN_TYPE,
365+
expires_in: 86400
366+
})
367+
);
368+
369+
const token = await auth0.getTokenSilently({
370+
authorizationParams: {
371+
audience: TEST_AUDIENCE,
372+
scope: 'read:data'
373+
},
374+
cacheMode: 'off'
375+
});
376+
377+
expect(token).toBeTruthy();
378+
expect(utils.runPopup).toHaveBeenCalled();
379+
});
380+
381+
it('should not call logout when handler is configured and error is iframe MFA step-up', async () => {
382+
const auth0 = setup({
383+
interactiveErrorHandler: 'popup'
384+
});
385+
386+
await loginWithRedirect(auth0, {
387+
authorizationParams: {
388+
audience: TEST_AUDIENCE,
389+
scope: 'read:data'
390+
}
391+
});
392+
393+
mockFetch.mockReset();
394+
395+
jest.spyOn(auth0, 'logout');
396+
397+
// Mock runIframe to reject with MFA step-up error
398+
jest.spyOn(<any>utils, 'runIframe').mockRejectedValue(
399+
GenericError.fromPayload({
400+
error: 'login_required',
401+
error_description: MFA_STEP_UP_ERROR_DESCRIPTION
402+
})
403+
);
404+
405+
setupPopupMock(mockWindow, {
406+
code: 'my_code',
407+
state: TEST_STATE
408+
});
409+
410+
// Token response from popup's code exchange
411+
mockFetch.mockResolvedValueOnce(
412+
fetchResponse(true, {
413+
id_token: TEST_ID_TOKEN,
414+
refresh_token: TEST_REFRESH_TOKEN,
415+
access_token: TEST_ACCESS_TOKEN,
416+
token_type: TEST_TOKEN_TYPE,
417+
expires_in: 86400
418+
})
419+
);
420+
421+
await auth0.getTokenSilently({
422+
authorizationParams: {
423+
audience: TEST_AUDIENCE,
424+
scope: 'read:data'
425+
},
426+
cacheMode: 'off'
427+
});
428+
429+
expect(auth0.logout).not.toHaveBeenCalled();
430+
});
431+
});
328432
});
329433
});

src/Auth0Client.ts

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import {
5757
DEFAULT_POPUP_CONFIG_OPTIONS,
5858
DEFAULT_AUTHORIZE_TIMEOUT_IN_SECONDS,
5959
MISSING_REFRESH_TOKEN_ERROR_MESSAGE,
60+
MFA_STEP_UP_ERROR_DESCRIPTION,
6061
DEFAULT_SCOPE,
6162
DEFAULT_SESSION_CHECK_EXPIRY_DAYS,
6263
DEFAULT_AUTH0_CLIENT,
@@ -926,10 +927,29 @@ export class Auth0Client {
926927

927928
/**
928929
* Checks if an error should be handled by the interactive error handler.
929-
* Currently only handles mfa_required; extensible for future error types.
930+
* Matches:
931+
* - MfaRequiredError (refresh token path, error='mfa_required')
932+
* - GenericError from iframe path (error='login_required',
933+
* error_description='Multifactor authentication required')
934+
* Extensible for future interactive error types.
930935
*/
931-
private _isInteractiveError(error: unknown): error is MfaRequiredError {
932-
return error instanceof MfaRequiredError;
936+
private _isInteractiveError(
937+
error: unknown
938+
): error is MfaRequiredError | GenericError {
939+
return error instanceof MfaRequiredError || (error instanceof GenericError && this._isIframeMfaError(error));
940+
}
941+
942+
/**
943+
* Checks if a login_required error from the iframe flow is actually
944+
* an MFA step-up requirement. The /authorize endpoint returns
945+
* error='login_required' with error_description='Multifactor authentication required'
946+
* when MFA is needed but prompt=none prevents interaction.
947+
*/
948+
private _isIframeMfaError(error: GenericError): boolean {
949+
return (
950+
error.error === 'login_required' &&
951+
error.error_description === MFA_STEP_UP_ERROR_DESCRIPTION
952+
);
933953
}
934954

935955
/**
@@ -1207,9 +1227,19 @@ export class Auth0Client {
12071227
);
12081228
} catch (e) {
12091229
if (e.error === 'login_required') {
1210-
this.logout({
1211-
openUrl: false
1212-
});
1230+
// When the login_required error is actually an MFA step-up requirement
1231+
// and the interactive error handler is configured, skip logout so the
1232+
// session is preserved for the popup flow.
1233+
const shouldSkipLogoutForMfaStepUp =
1234+
e instanceof GenericError &&
1235+
this._isIframeMfaError(e) &&
1236+
this.options.interactiveErrorHandler === 'popup';
1237+
1238+
if (!shouldSkipLogoutForMfaStepUp) {
1239+
this.logout({
1240+
openUrl: false
1241+
});
1242+
}
12131243
}
12141244
throw e;
12151245
}

src/constants.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ export const INVALID_REFRESH_TOKEN_ERROR_MESSAGE = 'invalid refresh token';
4646
*/
4747
export const USER_BLOCKED_ERROR_MESSAGE = 'user is blocked';
4848

49+
/**
50+
* @ignore
51+
* The error_description returned by the /authorize endpoint when MFA is required
52+
* but prompt=none prevents interaction (iframe silent auth flow).
53+
*/
54+
export const MFA_STEP_UP_ERROR_DESCRIPTION = 'Multifactor authentication required';
55+
4956
/**
5057
* @ignore
5158
*/

0 commit comments

Comments
 (0)