Skip to content

Commit 9d0b615

Browse files
feat: add mfa.stepUpWithPopup() for reactive MFA step-up via Universal Login (#2524)
1 parent 4d58651 commit 9d0b615

22 files changed

Lines changed: 3633 additions & 57 deletions

EXAMPLES.md

Lines changed: 229 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,15 @@
4545
- [Handling `MfaRequiredError`](#handling-mfarequirederror)
4646
- [MFA Tenant Configuration](#mfa-tenant-configuration)
4747
- [Critical Warning](#critical-warning)
48+
- [Reactive MFA Step-Up (Popup)](#reactive-mfa-step-up-popup)
49+
- [Overview](#overview-1)
50+
- [Basic Usage](#basic-usage)
51+
- [Handling MfaRequiredError from Client Components](#handling-mfarequirederror-from-client-components)
52+
- [Configuration Options](#configuration-options)
53+
- [CSP Nonce Support](#csp-nonce-support)
54+
- [Error Handling](#error-handling-2)
55+
- [Security Considerations](#security-considerations-1)
56+
- [Known Limitations](#known-limitations)
4857
- [Silent authentication](#silent-authentication)
4958
- [DPoP (Demonstrating Proof-of-Possession)](#dpop-demonstrating-proof-of-possession)
5059
- [What is DPoP?](#what-is-dpop)
@@ -1342,8 +1351,9 @@ export async function GET() {
13421351
```
13431352

13441353
**Client Side:**
1345-
When the client receives the 403 with `mfa_required`, you should redirect the user to complete the step-up challenge.
1354+
When the client receives the 403 with `mfa_required`, you can either redirect the user to a dedicated MFA page or use the popup-based approach to complete MFA without a full-page redirect.
13461355

1356+
**Option 1: Full-page redirect**
13471357
```javascript
13481358
const response = await fetch("/api/protected");
13491359
if (response.status === 403) {
@@ -1356,6 +1366,10 @@ if (response.status === 403) {
13561366
}
13571367
```
13581368

1369+
**Option 2: Popup (no redirect)**
1370+
1371+
Use `mfa.challengeWithPopup()` to complete MFA in a popup without leaving the current page. See [Reactive MFA Step-Up (Popup)](#reactive-mfa-step-up-popup) for full documentation.
1372+
13591373
### MFA Tenant Configuration
13601374

13611375
The SDK relies on background token refreshes to maintain user sessions. For these non-interactive requests to succeed, it is important to configure your MFA policies to allow `refresh_token` exchanges without immediate user challenge.
@@ -3705,10 +3719,194 @@ The SDK provides typed error classes for all MFA operations:
37053719
| `MfaTokenExpiredError` | `mfa_token_expired` | Token TTL exceeded | Context expired |
37063720
| `MfaTokenInvalidError` | `mfa_token_invalid` | Token tampered or wrong secret | Decryption failed |
37073721
3708-
## Multiple Custom Domains (MCD)
3722+
## Reactive MFA Step-Up (Popup)
37093723
37103724
### Overview
37113725
3726+
The SDK supports **reactive MFA step-up** via a browser popup using Auth0 Universal Login. When an API call fails with `mfa_required`, the client-side `mfa.challengeWithPopup()` method opens a popup window where the user completes MFA through Auth0's Universal Login. After completion, the token is cached in the server-side session and returned directly to the caller — no full-page redirect required.
3727+
3728+
This is useful for applications that need to protect specific actions (e.g., transferring funds, changing settings) with MFA without disrupting the user's current page state.
3729+
3730+
**Flow summary:**
3731+
1. App calls an API that requires MFA → receives `MfaRequiredError`
3732+
2. App calls `mfa.challengeWithPopup({ audience })` → popup opens
3733+
3. User completes MFA in the popup via Auth0 Universal Login
3734+
4. Popup sends result back via `postMessage` → popup auto-closes
3735+
5. SDK retrieves the cached token from the server session
3736+
6. `challengeWithPopup()` resolves with the access token
3737+
3738+
### Basic Usage
3739+
3740+
```tsx
3741+
'use client';
3742+
3743+
import { mfa, getAccessToken } from '@auth0/nextjs-auth0/client';
3744+
import { MfaRequiredError } from '@auth0/nextjs-auth0/errors';
3745+
import { useState } from 'react';
3746+
3747+
export function ProtectedAction() {
3748+
const [result, setResult] = useState(null);
3749+
const [error, setError] = useState(null);
3750+
3751+
async function handleAction() {
3752+
try {
3753+
// 1. Try to get an access token for the protected API
3754+
const token = await getAccessToken({
3755+
audience: 'https://api.example.com',
3756+
scope: 'read:sensitive'
3757+
});
3758+
3759+
// 2. Use the token to call your API
3760+
const res = await fetch('https://api.example.com/sensitive', {
3761+
headers: { Authorization: `Bearer ${token}` }
3762+
});
3763+
setResult(await res.json());
3764+
} catch (err) {
3765+
if (err instanceof MfaRequiredError) {
3766+
try {
3767+
// 3. MFA required — trigger popup step-up
3768+
const { token } = await mfa.challengeWithPopup({
3769+
audience: 'https://api.example.com',
3770+
scope: 'read:sensitive'
3771+
});
3772+
3773+
// 4. Retry with the step-up token
3774+
const res = await fetch('https://api.example.com/sensitive', {
3775+
headers: { Authorization: `Bearer ${token}` }
3776+
});
3777+
setResult(await res.json());
3778+
} catch (popupErr) {
3779+
setError(popupErr.message);
3780+
}
3781+
} else {
3782+
setError(err.message);
3783+
}
3784+
}
3785+
}
3786+
3787+
return (
3788+
<div>
3789+
<button onClick={handleAction}>Perform Sensitive Action</button>
3790+
{error && <p style={{ color: 'red' }}>{error}</p>}
3791+
{result && <pre>{JSON.stringify(result, null, 2)}</pre>}
3792+
</div>
3793+
);
3794+
}
3795+
```
3796+
3797+
### Handling MfaRequiredError from Client Components
3798+
3799+
The client-side `getAccessToken()` helper automatically detects 403 responses with `error: "mfa_required"` and throws `MfaRequiredError`. This allows you to use `instanceof` checks to trigger the popup flow:
3800+
3801+
```tsx
3802+
import { getAccessToken } from '@auth0/nextjs-auth0/client';
3803+
import { MfaRequiredError } from '@auth0/nextjs-auth0/errors';
3804+
3805+
try {
3806+
const token = await getAccessToken({ audience: 'https://api.example.com' });
3807+
} catch (err) {
3808+
if (err instanceof MfaRequiredError) {
3809+
// Trigger popup MFA step-up
3810+
const { token } = await mfa.challengeWithPopup({
3811+
audience: 'https://api.example.com'
3812+
});
3813+
}
3814+
}
3815+
```
3816+
3817+
> [!NOTE]
3818+
> The `MfaRequiredError` detection works for both server-side and client-side `getAccessToken()` calls. On the client, it is reconstructed from the 403 JSON response returned by the `/auth/access-token` endpoint.
3819+
3820+
### Configuration Options
3821+
3822+
`challengeWithPopup()` accepts the following options:
3823+
3824+
| Option | Type | Default | Description |
3825+
|--------|------|---------|-------------|
3826+
| `audience` | `string` | *(required)* | Target API audience identifier |
3827+
| `scope` | `string` | `'openid profile email'` | Space-separated scopes for the token |
3828+
| `acr_values` | `string` | `'http://schemas.openid.net/pape/policies/2007/06/multi-factor'` | ACR values sent to Auth0 for step-up policy |
3829+
| `returnTo` | `string` | `'/'` | Return URL (used internally by the OAuth flow) |
3830+
| `timeout` | `number` | `60000` | Popup timeout in milliseconds |
3831+
| `popupWidth` | `number` | `400` | Popup window width in pixels |
3832+
| `popupHeight` | `number` | `600` | Popup window height in pixels |
3833+
3834+
**Example with custom options:**
3835+
3836+
```tsx
3837+
const { token } = await mfa.challengeWithPopup({
3838+
audience: 'https://api.example.com',
3839+
scope: 'openid profile email transfer:funds',
3840+
timeout: 120000, // 2 minutes
3841+
popupWidth: 500,
3842+
popupHeight: 700
3843+
});
3844+
```
3845+
3846+
> [!NOTE]
3847+
> Popup timeout is configured per-call only. There is no server-side configuration option or environment variable for this — timeout is a client-side runtime concern. If you need a consistent default across your app, define an application-level constant and pass it to every call.
3848+
3849+
### CSP Nonce Support
3850+
3851+
If your application uses a strict Content Security Policy that blocks inline scripts, configure a CSP nonce on the server-side `Auth0Client`:
3852+
3853+
```typescript
3854+
// lib/auth0.ts
3855+
import { Auth0Client } from '@auth0/nextjs-auth0/server';
3856+
3857+
export const auth0 = new Auth0Client({
3858+
cspNonce: 'your-generated-nonce'
3859+
});
3860+
```
3861+
3862+
The nonce is injected into the `<script>` tag of the popup callback HTML response, making it compliant with `script-src 'nonce-...'` CSP policies.
3863+
3864+
> [!IMPORTANT]
3865+
> The nonce must contain only base64 characters (`A-Za-z0-9+/=-_`). Invalid characters will throw an `InvalidConfigurationError`.
3866+
3867+
> [!NOTE]
3868+
> The `cspNonce` is set at `Auth0Client` construction time and remains static for the lifetime of the instance. Since `Auth0Client` is typically a singleton, this means the same nonce is reused across requests. This still provides protection over `'unsafe-inline'` (the script must know the nonce), but is weaker than per-request nonce rotation. If your security policy requires per-request nonces, you would need to create the `Auth0Client` per-request or use middleware to inject a fresh nonce via a custom header.
3869+
3870+
If you do **not** configure a `cspNonce` and your CSP blocks inline scripts, the popup will complete the MFA flow but the parent window will never receive the `postMessage`. This manifests as a `PopupTimeoutError` after the configured timeout.
3871+
3872+
### Error Handling
3873+
3874+
`challengeWithPopup()` can throw several typed errors. Handle them to provide appropriate user feedback:
3875+
3876+
```tsx
3877+
import { mfa } from '@auth0/nextjs-auth0/client';
3878+
import {
3879+
PopupBlockedError,
3880+
PopupCancelledError,
3881+
PopupTimeoutError,
3882+
PopupInProgressError,
3883+
ExecutionContextError
3884+
} from '@auth0/nextjs-auth0/errors';
3885+
3886+
try {
3887+
const { token } = await mfa.challengeWithPopup({
3888+
audience: 'https://api.example.com'
3889+
});
3890+
} catch (err) {
3891+
if (err instanceof PopupBlockedError) {
3892+
// Browser blocked the popup — prompt user to allow popups
3893+
alert('Please allow popups for this site and try again.');
3894+
} else if (err instanceof PopupCancelledError) {
3895+
// User closed the popup before completing MFA
3896+
console.log('MFA cancelled by user.');
3897+
} else if (err instanceof PopupTimeoutError) {
3898+
// Popup did not complete within the timeout
3899+
console.log('MFA timed out. Please try again.');
3900+
} else if (err instanceof PopupInProgressError) {
3901+
// Another popup is already open
3902+
console.log('Please complete the current MFA prompt first.');
3903+
} else if (err instanceof ExecutionContextError) {
3904+
// Called from server-side code (SSR, middleware)
3905+
console.error('challengeWithPopup() can only be called in browser context.');
3906+
} else {
3907+
// AccessTokenError or other errors
3908+
console.error('MFA failed:', err.message);
3909+
37123910
Multiple Custom Domains (MCD) enables a single `@auth0/nextjs-auth0` instance to authenticate users against different Auth0 custom domains on the same tenant. This is useful for:
37133911
37143912
- **B2C Multi-Brand**: Multiple branded auth domains (`auth.brand1.com`, `auth.brand2.com`) on a single Auth0 tenant
@@ -3995,6 +4193,35 @@ export default async function Page() {
39954193
}
39964194
```
39974195
4196+
**Error reference:**
4197+
4198+
| Error Class | Code | When Thrown |
4199+
|-------------|------|------------|
4200+
| `PopupBlockedError` | `popup_blocked` | Browser blocked `window.open()` |
4201+
| `PopupCancelledError` | `popup_cancelled` | User closed the popup window |
4202+
| `PopupTimeoutError` | `popup_timeout` | Popup did not complete within timeout |
4203+
| `PopupInProgressError` | `popup_in_progress` | Another `challengeWithPopup()` call is active |
4204+
| `ExecutionContextError` | `invalid_execution_context` | Called outside browser context (SSR/middleware) |
4205+
| `AccessTokenError` | Various | Token retrieval failed after popup completed |
4206+
4207+
### Security Considerations
4208+
4209+
- **Same-origin postMessage:** The popup listener only accepts messages from `window.location.origin`. Cross-origin messages are silently ignored.
4210+
- **No tokens in postMessage:** The popup's `postMessage` payload contains only `{ sub, email }` metadata — never raw access tokens. Tokens remain server-side in the encrypted session cookie.
4211+
- **PKCE:** The popup flow uses the same PKCE-based authorization code exchange as standard login. No security downgrade.
4212+
- **State encryption:** The `returnStrategy` flag is stored in the encrypted transaction cookie alongside other OAuth state (AES-256-GCM).
4213+
- **XSS prevention:** The callback HTML uses `JSON.stringify()` with `<` escaping (`\u003c`) to prevent script injection via user-controlled values.
4214+
4215+
### Known Limitations
4216+
4217+
| Limitation | Details |
4218+
|------------|---------|
4219+
| **One popup at a time** | Only one `challengeWithPopup()` call is allowed concurrently. A second call throws `PopupInProgressError` regardless of audience. |
4220+
| **Same-origin only** | The postMessage validation requires same-origin. Cross-origin popup flows are not supported. |
4221+
| **Browser popup policies** | Most browsers block popups unless triggered by a direct user action (click handler). Ensure `challengeWithPopup()` is called within a user-initiated event handler. |
4222+
| **`beforeSessionSaved` idempotency** | The `beforeSessionSaved` hook runs again when the popup token is merged into the existing session. Ensure your hook is idempotent when using popup flows. |
4223+
| **Session cookie size** | Each cached MRRT token increases session cookie size. For applications with many audiences, consider using a [database session store](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#database-sessions). |
4224+
39984225
**Handling issuer validation errors:**
39994226
40004227
`IssuerValidationError` is thrown during the authentication callback when the ID token's issuer doesn't match the transaction's expected issuer. This indicates a potential cross-domain token confusion attack or misconfiguration.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ You can customize the client by using the options below:
224224
| useDPoP | `boolean` | Enable DPoP (Demonstration of Proof-of-Possession) for enhanced security. When enabled, the client will generate DPoP proofs for token requests and protected resource requests. Defaults to `false`. |
225225
| dpopKeyPair | `DpopKeyPair` | ES256 key pair for DPoP proof generation. If not provided, the SDK will attempt to load keys from `AUTH0_DPOP_PUBLIC_KEY` and `AUTH0_DPOP_PRIVATE_KEY` environment variables. Keys must be in PEM format. |
226226
| dpopOptions | `DpopOptions` | Configure DPoP timing validation. Supports `clockSkew` (adjust assumed current time) and `clockTolerance` (validation tolerance). Can also be configured via `AUTH0_DPOP_CLOCK_SKEW` and `AUTH0_DPOP_CLOCK_TOLERANCE` environment variables. See [DPoP Clock Validation](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#dpop-clock-validation) for details. |
227+
| cspNonce | `string` | CSP nonce for inline scripts in popup callback HTML responses. When provided, `<script>` tags in the MFA popup callback include a `nonce="..."` attribute for strict Content Security Policy compliance. Only required when using `mfa.challengeWithPopup()` with a CSP that blocks inline scripts. |
227228
| discoveryCache | `DiscoveryCacheOptions` | Configure the OIDC discovery metadata cache for [Multiple Custom Domains](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#discovery-cache-configuration). Controls TTL and maximum cached issuers. |
228229

229230
### Customizing Auth Handlers

0 commit comments

Comments
 (0)