diff --git a/EXAMPLES.md b/EXAMPLES.md index 5855892b8..635abbeac 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -45,6 +45,15 @@ - [Handling `MfaRequiredError`](#handling-mfarequirederror) - [MFA Tenant Configuration](#mfa-tenant-configuration) - [Critical Warning](#critical-warning) +- [Reactive MFA Step-Up (Popup)](#reactive-mfa-step-up-popup) + - [Overview](#overview-1) + - [Basic Usage](#basic-usage) + - [Handling MfaRequiredError from Client Components](#handling-mfarequirederror-from-client-components) + - [Configuration Options](#configuration-options) + - [CSP Nonce Support](#csp-nonce-support) + - [Error Handling](#error-handling-2) + - [Security Considerations](#security-considerations-1) + - [Known Limitations](#known-limitations) - [Silent authentication](#silent-authentication) - [DPoP (Demonstrating Proof-of-Possession)](#dpop-demonstrating-proof-of-possession) - [What is DPoP?](#what-is-dpop) @@ -1342,8 +1351,9 @@ export async function GET() { ``` **Client Side:** -When the client receives the 403 with `mfa_required`, you should redirect the user to complete the step-up challenge. +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. +**Option 1: Full-page redirect** ```javascript const response = await fetch("/api/protected"); if (response.status === 403) { @@ -1356,6 +1366,10 @@ if (response.status === 403) { } ``` +**Option 2: Popup (no redirect)** + +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. + ### MFA Tenant Configuration 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: | `MfaTokenExpiredError` | `mfa_token_expired` | Token TTL exceeded | Context expired | | `MfaTokenInvalidError` | `mfa_token_invalid` | Token tampered or wrong secret | Decryption failed | -## Multiple Custom Domains (MCD) +## Reactive MFA Step-Up (Popup) ### Overview +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. + +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. + +**Flow summary:** +1. App calls an API that requires MFA → receives `MfaRequiredError` +2. App calls `mfa.challengeWithPopup({ audience })` → popup opens +3. User completes MFA in the popup via Auth0 Universal Login +4. Popup sends result back via `postMessage` → popup auto-closes +5. SDK retrieves the cached token from the server session +6. `challengeWithPopup()` resolves with the access token + +### Basic Usage + +```tsx +'use client'; + +import { mfa, getAccessToken } from '@auth0/nextjs-auth0/client'; +import { MfaRequiredError } from '@auth0/nextjs-auth0/errors'; +import { useState } from 'react'; + +export function ProtectedAction() { + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + + async function handleAction() { + try { + // 1. Try to get an access token for the protected API + const token = await getAccessToken({ + audience: 'https://api.example.com', + scope: 'read:sensitive' + }); + + // 2. Use the token to call your API + const res = await fetch('https://api.example.com/sensitive', { + headers: { Authorization: `Bearer ${token}` } + }); + setResult(await res.json()); + } catch (err) { + if (err instanceof MfaRequiredError) { + try { + // 3. MFA required — trigger popup step-up + const { token } = await mfa.challengeWithPopup({ + audience: 'https://api.example.com', + scope: 'read:sensitive' + }); + + // 4. Retry with the step-up token + const res = await fetch('https://api.example.com/sensitive', { + headers: { Authorization: `Bearer ${token}` } + }); + setResult(await res.json()); + } catch (popupErr) { + setError(popupErr.message); + } + } else { + setError(err.message); + } + } + } + + return ( +
{error}
} + {result &&{JSON.stringify(result, null, 2)}}
+ ${escapeHtml(statusText)}
+ + +`; + + return new NextResponse(html, { + status: 200, + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store" + } + }); +} diff --git a/src/utils/popup-helpers.test.ts b/src/utils/popup-helpers.test.ts new file mode 100644 index 000000000..0d79eb3fc --- /dev/null +++ b/src/utils/popup-helpers.test.ts @@ -0,0 +1,301 @@ +/** + * @vitest-environment jsdom + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + PopupCancelledError, + PopupTimeoutError +} from "../errors/popup-errors.js"; +import { + AUTO_CLOSE_DELAY, + DEFAULT_POPUP_HEIGHT, + DEFAULT_POPUP_TIMEOUT, + DEFAULT_POPUP_WIDTH, + openCenteredPopup, + POLL_INTERVAL, + waitForPopupCompletion +} from "./popup-helpers.js"; + +describe("popup-helpers", () => { + describe("constants", () => { + it("should export correct default values", () => { + expect(DEFAULT_POPUP_WIDTH).toBe(400); + expect(DEFAULT_POPUP_HEIGHT).toBe(600); + expect(DEFAULT_POPUP_TIMEOUT).toBe(60000); + expect(AUTO_CLOSE_DELAY).toBe(2000); + expect(POLL_INTERVAL).toBe(500); + }); + }); + + describe("openCenteredPopup", () => { + let openSpy: any; + + beforeEach(() => { + openSpy = vi.spyOn(window, "open"); + }); + + afterEach(() => { + openSpy.mockRestore(); + }); + + it("should call window.open with centered coordinates", () => { + // Mock window dimensions + Object.defineProperty(window, "screenX", { value: 100, writable: true }); + Object.defineProperty(window, "screenY", { value: 50, writable: true }); + Object.defineProperty(window, "outerWidth", { + value: 1200, + writable: true + }); + Object.defineProperty(window, "outerHeight", { + value: 800, + writable: true + }); + + const mockPopup = { closed: false } as Window; + openSpy.mockReturnValue(mockPopup); + + const result = openCenteredPopup("https://example.com", 400, 600); + + expect(result).toBe(mockPopup); + expect(openSpy).toHaveBeenCalledWith( + "https://example.com", + "_blank", + expect.stringContaining("width=400") + ); + expect(openSpy).toHaveBeenCalledWith( + "https://example.com", + "_blank", + expect.stringContaining("height=600") + ); + + // Check centered positioning: left = 100 + (1200 - 400) / 2 = 500 + // top = 50 + (800 - 600) / 2 = 150 + const features = openSpy.mock.calls[0][2] as string; + expect(features).toContain("left=500"); + expect(features).toContain("top=150"); + expect(features).toContain("scrollbars=yes"); + }); + + it("should return null when popup is blocked", () => { + openSpy.mockReturnValue(null); + + const result = openCenteredPopup("https://example.com", 400, 600); + + expect(result).toBeNull(); + }); + + it("should use custom dimensions", () => { + const mockPopup = { closed: false } as Window; + openSpy.mockReturnValue(mockPopup); + + openCenteredPopup("https://example.com", 800, 1000); + + const features = openSpy.mock.calls[0][2] as string; + expect(features).toContain("width=800"); + expect(features).toContain("height=1000"); + }); + }); + + describe("waitForPopupCompletion", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should resolve on valid auth_complete postMessage from same origin", async () => { + const mockPopup = { closed: false } as Window; + + const promise = waitForPopupCompletion(mockPopup, 60000); + + // Simulate postMessage from same origin + const messageEvent = new MessageEvent("message", { + data: { + type: "auth_complete", + success: true, + user: { sub: "auth0|123", email: "test@example.com" } + }, + origin: window.location.origin + }); + window.dispatchEvent(messageEvent); + + const result = await promise; + expect(result).toEqual({ + type: "auth_complete", + success: true, + user: { sub: "auth0|123", email: "test@example.com" } + }); + }); + + it("should resolve on error auth_complete postMessage", async () => { + const mockPopup = { closed: false } as Window; + + const promise = waitForPopupCompletion(mockPopup, 60000); + + const messageEvent = new MessageEvent("message", { + data: { + type: "auth_complete", + success: false, + error: { code: "access_denied", message: "User denied" } + }, + origin: window.location.origin + }); + window.dispatchEvent(messageEvent); + + const result = await promise; + expect(result).toEqual({ + type: "auth_complete", + success: false, + error: { code: "access_denied", message: "User denied" } + }); + }); + + it("should ignore messages from different origin", async () => { + const mockPopup = { closed: false } as Window; + + const promise = waitForPopupCompletion(mockPopup, 1000); + // Attach catch handler immediately to avoid unhandled rejection + const rejection = + expect(promise).rejects.toBeInstanceOf(PopupTimeoutError); + + // Simulate cross-origin message + const crossOriginEvent = new MessageEvent("message", { + data: { type: "auth_complete", success: true }, + origin: "https://evil.com" + }); + window.dispatchEvent(crossOriginEvent); + + // Should not resolve — advance timers to timeout + await vi.advanceTimersByTimeAsync(1000); + + await rejection; + }); + + it("should ignore messages without auth_complete type", async () => { + const mockPopup = { closed: false } as Window; + + const promise = waitForPopupCompletion(mockPopup, 1000); + const rejection = + expect(promise).rejects.toBeInstanceOf(PopupTimeoutError); + + // Dispatch unrelated message from same origin + const unrelatedEvent = new MessageEvent("message", { + data: { type: "other_event", payload: "test" }, + origin: window.location.origin + }); + window.dispatchEvent(unrelatedEvent); + + await vi.advanceTimersByTimeAsync(1000); + + await rejection; + }); + + it("should reject with PopupTimeoutError after timeout", async () => { + const mockPopup = { closed: false } as Window; + + const promise = waitForPopupCompletion(mockPopup, 5000); + const rejection = promise.catch((e) => e); + + await vi.advanceTimersByTimeAsync(5000); + + const error = await rejection; + expect(error).toBeInstanceOf(PopupTimeoutError); + expect(error.message).toBe("Popup did not complete within 5000ms"); + }); + + it("should reject with PopupCancelledError when popup is closed", async () => { + const mockPopup = { closed: false } as Window; + + const promise = waitForPopupCompletion(mockPopup, 60000); + const rejection = promise.catch((e) => e); + + // Simulate popup closing + Object.defineProperty(mockPopup, "closed", { value: true }); + + // Advance past poll interval + await vi.advanceTimersByTimeAsync(POLL_INTERVAL); + + const error = await rejection; + expect(error).toBeInstanceOf(PopupCancelledError); + expect(error.message).toBe("Popup was closed by user"); + }); + + it("should clean up event listener and timers on success", async () => { + const mockPopup = { closed: false } as Window; + const removeListenerSpy = vi.spyOn(window, "removeEventListener"); + + const promise = waitForPopupCompletion(mockPopup, 60000); + + const messageEvent = new MessageEvent("message", { + data: { type: "auth_complete", success: true }, + origin: window.location.origin + }); + window.dispatchEvent(messageEvent); + + await promise; + + expect(removeListenerSpy).toHaveBeenCalledWith( + "message", + expect.any(Function) + ); + + removeListenerSpy.mockRestore(); + }); + + it("should clean up event listener and timers on timeout", async () => { + const mockPopup = { closed: false } as Window; + const removeListenerSpy = vi.spyOn(window, "removeEventListener"); + + const promise = waitForPopupCompletion(mockPopup, 1000); + const rejection = promise.catch(() => {}); + + await vi.advanceTimersByTimeAsync(1000); + await rejection; + + expect(removeListenerSpy).toHaveBeenCalledWith( + "message", + expect.any(Function) + ); + + removeListenerSpy.mockRestore(); + }); + + it("should clean up event listener and timers on cancel", async () => { + const mockPopup = { closed: false } as Window; + const removeListenerSpy = vi.spyOn(window, "removeEventListener"); + + const promise = waitForPopupCompletion(mockPopup, 60000); + const rejection = promise.catch(() => {}); + + Object.defineProperty(mockPopup, "closed", { value: true }); + await vi.advanceTimersByTimeAsync(POLL_INTERVAL); + await rejection; + + expect(removeListenerSpy).toHaveBeenCalledWith( + "message", + expect.any(Function) + ); + + removeListenerSpy.mockRestore(); + }); + + it("should accept message with undefined user on success", async () => { + const mockPopup = { closed: false } as Window; + + const promise = waitForPopupCompletion(mockPopup, 60000); + + const messageEvent = new MessageEvent("message", { + data: { type: "auth_complete", success: true }, + origin: window.location.origin + }); + window.dispatchEvent(messageEvent); + + const result = await promise; + expect(result.success).toBe(true); + }); + }); +}); diff --git a/src/utils/popup-helpers.ts b/src/utils/popup-helpers.ts new file mode 100644 index 000000000..7eef226d9 --- /dev/null +++ b/src/utils/popup-helpers.ts @@ -0,0 +1,116 @@ +import { + PopupCancelledError, + PopupTimeoutError +} from "../errors/popup-errors.js"; +import { POLL_INTERVAL } from "./constants.js"; + +export { + AUTO_CLOSE_DELAY, + DEFAULT_POPUP_HEIGHT, + DEFAULT_POPUP_TIMEOUT, + DEFAULT_POPUP_WIDTH, + POLL_INTERVAL +} from "./constants.js"; + +/** + * postMessage payload sent from the popup callback page to the parent window. + * + * Uses a discriminated union on `success` for type-safe handling: + * - `success: true` — MFA completed, optional user metadata attached + * - `success: false` — error occurred, error code and message attached + * + * Security: Never contains raw access tokens. Only user metadata (`sub`, `email`) + * is sent via postMessage. Tokens remain server-side in the encrypted session. + */ +export type AuthCompleteMessage = + | { + type: "auth_complete"; + success: true; + /** User metadata from the authenticated session (sub and email only). */ + user?: { sub: string; email: string }; + } + | { + type: "auth_complete"; + success: false; + /** Error details from the callback (OAuth error code + description). */ + error: { code: string; message: string }; + }; + +/** + * Opens a centered popup window. + * @param url - URL to open in popup + * @param width - Popup width (pixels) + * @param height - Popup height (pixels) + * @returns Window reference or null if blocked + */ +export function openCenteredPopup( + url: string, + width: number, + height: number +): Window | null { + const left = window.screenX + (window.outerWidth - width) / 2; + const top = window.screenY + (window.outerHeight - height) / 2; + + return window.open( + url, + "_blank", + `width=${width},height=${height},left=${left},top=${top},scrollbars=yes` + ); +} + +/** + * Waits for popup to complete authentication via postMessage. + * + * Monitors three conditions concurrently: + * 1. **postMessage** with `type: 'auth_complete'` from same origin (resolves) + * 2. **popup.closed** polling every {@link POLL_INTERVAL}ms (rejects) + * 3. **Timeout** expiry (rejects) + * + * Only accepts messages where `event.origin === window.location.origin` + * (same-origin validation, Design Decision DD-1). + * + * @param popup - Popup window reference from `window.open()` + * @param timeout - Timeout in milliseconds before rejecting + * @returns Promise resolving to {@link AuthCompleteMessage} + * @throws {PopupTimeoutError} If timeout expires before completion + * @throws {PopupCancelledError} If user closes popup before completion + */ +export function waitForPopupCompletion( + popup: Window, + timeout: number +): Promise