UNPKG

@auth0/nextjs-auth0

Version:
373 lines (372 loc) 14.1 kB
import { MfaChallengeError, MfaEnrollmentError, MfaGetAuthenticatorsError, MfaNoAvailableFactorsError, MfaRequiredError, MfaTokenExpiredError, MfaTokenInvalidError, MfaVerifyError } from "../../errors/index.js"; import { normalizeWithBasePath } from "../../utils/pathUtils.js"; /** * Client-side MFA API (singleton). * * All operations are thin wrappers that fetch() to SDK routes. * Business logic executes server-side for security. * * @example React Component * ```typescript * 'use client'; * * import { mfa } from '@auth0/nextjs-auth0/client'; * import { useState } from 'react'; * * export function MfaVerification({ mfaToken }) { * const [otp, setOtp] = useState(''); * const [error, setError] = useState(null); * * async function handleVerify() { * try { * await mfa.verify({ mfaToken, otp }); * window.location.href = '/dashboard'; // Redirect after success * } catch (err) { * setError(err.message); * } * } * * return ( * <form onSubmit={e => { e.preventDefault(); handleVerify(); }}> * <input value={otp} onChange={e => setOtp(e.target.value)} /> * <button type="submit">Verify</button> * {error && <p>{error}</p>} * </form> * ); * } * ``` */ class ClientMfaClient { /** * List enrolled MFA authenticators. * * Server-side logic: * - Decrypts mfaToken (validates TTL and integrity) * - Calls Auth0 API with raw mfa_token * - Filters by allowed challenge types * - Returns array of authenticators * * @param options - Options containing encrypted mfaToken * @returns Array of available authenticators * @throws {MfaTokenExpiredError} Token TTL exceeded * @throws {MfaTokenInvalidError} Token tampered or malformed * @throws {MfaGetAuthenticatorsError} Auth0 API error * * @example * ```typescript * 'use client'; * import { mfa } from '@auth0/nextjs-auth0/client'; * import { useState, useEffect } from 'react'; * * export function AuthenticatorList({ mfaToken }) { * const [authenticators, setAuthenticators] = useState([]); * * useEffect(() => { * mfa.getAuthenticators({ mfaToken }) * .then(setAuthenticators) * .catch(console.error); * }, [mfaToken]); * * return ( * <ul> * {authenticators.map(auth => ( * <li key={auth.id}>{auth.authenticatorType}</li> * ))} * </ul> * ); * } * ``` */ async getAuthenticators(options) { try { const urlParams = new URLSearchParams(); urlParams.append("mfa_token", options.mfaToken); const url = `${normalizeWithBasePath(process.env.NEXT_PUBLIC_MFA_AUTHENTICATORS_ROUTE || "/auth/mfa/authenticators")}?${urlParams.toString()}`; const response = await fetch(url, { method: "GET", credentials: "omit" // Stateless operation, no session needed }); if (!response.ok) { const error = await response.json(); throw this.parseError(error, "getAuthenticators", response.url); } return await response.json(); } catch (e) { // Re-throw typed errors if (e instanceof MfaTokenExpiredError || e instanceof MfaTokenInvalidError || e instanceof MfaGetAuthenticatorsError) { throw e; } // Network/parse errors → MfaGetAuthenticatorsError with client_error code throw new MfaGetAuthenticatorsError("client_error", e instanceof Error ? e.message : "Network or parsing error", undefined); } } /** * Initiate an MFA challenge. * * Server-side logic: * - Decrypts mfaToken (validates TTL and integrity) * - Calls Auth0 challenge API * - Returns challenge response (oobCode, bindingMethod) * * @param options - Challenge options * @returns Challenge response with oobCode and bindingMethod * @throws {MfaTokenExpiredError} Token TTL exceeded * @throws {MfaTokenInvalidError} Token tampered or malformed * @throws {MfaChallengeError} Auth0 API error * * @example * ```typescript * 'use client'; * import { mfa } from '@auth0/nextjs-auth0/client'; * * async function sendSmsCode(mfaToken, authenticatorId) { * const challenge = await mfa.challenge({ * mfaToken, * challengeType: 'oob', * authenticatorId * }); * // SMS sent, now collect binding code from user * return challenge.oobCode; * } * ``` */ async challenge(options) { try { const body = { mfaToken: options.mfaToken, challengeType: options.challengeType }; if (options.authenticatorId) { body.authenticatorId = options.authenticatorId; } const url = normalizeWithBasePath(process.env.NEXT_PUBLIC_MFA_CHALLENGE_ROUTE || "/auth/mfa/challenge"); const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "omit", // Stateless operation body: JSON.stringify(body) }); if (!response.ok) { const error = await response.json(); throw this.parseError(error, "challenge", response.url); } return await response.json(); } catch (e) { // Re-throw typed errors if (e instanceof MfaTokenExpiredError || e instanceof MfaTokenInvalidError || e instanceof MfaChallengeError) { throw e; } // Network/parse errors throw new MfaChallengeError("client_error", e instanceof Error ? e.message : "Network or parsing error", undefined); } } /** * Verify MFA code and complete authentication. * * Server-side logic: * - Decrypts mfaToken (validates TTL and integrity) * - Calls Auth0 verify API * - Caches resulting access token in session * - Returns token response * * Chained MFA: If Auth0 returns mfa_required, throws MfaRequiredError with * a new encrypted mfa_token for the next factor. * * @param options - Verification options (otp, oobCode+bindingCode, or recoveryCode) * @returns Token response with access_token, refresh_token, etc. * @throws {MfaTokenExpiredError} Token TTL exceeded * @throws {MfaTokenInvalidError} Token tampered or malformed * @throws {MfaRequiredError} Additional MFA factor required (chained MFA) * @throws {MfaVerifyError} Auth0 API error (wrong code, rate limit, etc.) */ async verify(options) { try { const body = { mfaToken: options.mfaToken }; // Type-based field mapping (matches VerifyMfaOptions union type) if ("otp" in options) { body.otp = options.otp; } else if ("oobCode" in options) { body.oobCode = options.oobCode; body.bindingCode = options.bindingCode; } else if ("recoveryCode" in options) { body.recoveryCode = options.recoveryCode; } const url = normalizeWithBasePath(process.env.NEXT_PUBLIC_MFA_VERIFY_ROUTE || "/auth/mfa/verify"); const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", // Session caching (verify stores token in session) body: JSON.stringify(body) }); if (!response.ok) { const error = await response.json(); throw this.parseError(error, "verify", response.url); } return await response.json(); } catch (e) { // Re-throw typed errors if (e instanceof MfaTokenExpiredError || e instanceof MfaTokenInvalidError || e instanceof MfaRequiredError || e instanceof MfaVerifyError) { throw e; } // Network/parse errors throw new MfaVerifyError("client_error", e instanceof Error ? e.message : "Network or parsing error", undefined); } } /** * Enroll a new MFA authenticator. * * Server-side logic: * - Decrypts mfaToken (validates TTL and integrity) * - Calls Auth0 enrollment API * - Returns enrollment response with authenticator details and optional recovery codes * * @param options - Enrollment options (otp | oob | email) * @returns Enrollment response with authenticator ID, secret (for OTP), and optional recovery codes * @throws {MfaTokenExpiredError} Token TTL exceeded * @throws {MfaTokenInvalidError} Token tampered or malformed * @throws {MfaEnrollmentError} Auth0 API error * * @example * ```typescript * 'use client'; * import { mfa } from '@auth0/nextjs-auth0/client'; * import QRCode from 'qrcode.react'; * * export function EnrollOtp({ mfaToken }) { * const [enrollment, setEnrollment] = useState(null); * * async function handleEnroll() { * const result = await mfa.enroll({ * mfaToken, * authenticatorTypes: ['otp'] * }); * setEnrollment(result); * } * * return enrollment ? ( * <QRCode value={enrollment.barcodeUri} /> * ) : ( * <button onClick={handleEnroll}>Enroll</button> * ); * } * ``` */ async enroll(options) { try { const url = normalizeWithBasePath(process.env.NEXT_PUBLIC_MFA_ENROLL_ROUTE || "/auth/mfa/enroll"); const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(options), credentials: "omit" // Stateless operation }); if (!response.ok) { const error = await response.json(); throw this.parseError(error, "enroll", response.url); } return await response.json(); } catch (e) { // Re-throw typed errors if (e instanceof MfaTokenExpiredError || e instanceof MfaTokenInvalidError || e instanceof MfaEnrollmentError) { throw e; } // Network/parse errors throw new MfaEnrollmentError("client_error", e instanceof Error ? e.message : "Network or parsing error", undefined); } } /** * Parse server error response into typed error classes. * * Server returns JSON: { error, error_description, mfa_token? } * Maps to SDK error types based on error code and route context. * * Chained MFA: error === 'mfa_required' → MfaRequiredError (not MfaVerifyError) * * @param error - Parsed JSON error from server * @param route - Route name for fallback error detection * @param url - Full URL for route extraction * @returns Typed error instance */ parseError(error, route, url) { const code = error.error || "unknown_error"; const description = error.error_description || "Unknown error occurred"; // SDK errors (fixed codes) if (code === "mfa_token_expired") { return new MfaTokenExpiredError(); } if (code === "mfa_token_invalid") { return new MfaTokenInvalidError(); } if (code === "mfa_no_available_factors") { return new MfaNoAvailableFactorsError(description); } // Chained MFA: mfa_required means "success, continue to next factor" // NOT a verification failure, so use MfaRequiredError (not MfaVerifyError) if (code === "mfa_required") { return new MfaRequiredError(description, error.mfa_token, // Server returns encrypted token for next factor error.mfa_requirements, undefined); } // Auth0 API errors (dynamic codes) - route-based fallback // Route detection from URL (fallback if route param is unreliable) const isAuthenticators = route === "getAuthenticators" || url.includes("/authenticators"); const isChallenge = route === "challenge" || url.includes("/challenge"); const isVerify = route === "verify" || url.includes("/verify"); const isEnroll = route === "enroll" || url.includes("/enroll"); if (isAuthenticators) { return new MfaGetAuthenticatorsError(code, description, undefined); } if (isChallenge) { return new MfaChallengeError(code, description, undefined); } if (isVerify) { return new MfaVerifyError(code, description, undefined); } if (isEnroll) { return new MfaEnrollmentError(code, description, undefined); } // Fallback: unknown route (shouldn't happen) return new MfaVerifyError(code, description, undefined); } } /** * Client-side MFA API singleton. * * @example * ```typescript * import { mfa } from '@auth0/nextjs-auth0/client'; * * // List authenticators * const authenticators = await mfa.getAuthenticators({ mfaToken }); * * // Initiate challenge * const challenge = await mfa.challenge({ mfaToken, challengeType: 'oob' }); * * // Verify and complete * const tokens = await mfa.verify({ mfaToken, otp: '123456' }); * ``` */ export const mfa = new ClientMfaClient();