UNPKG

@auth0/nextjs-auth0

Version:
399 lines (398 loc) 19.3 kB
import { createPrivateKey, createPublicKey, createSign, createVerify } from "crypto"; import { generateKeyPair, isDPoPNonceError } from "oauth4webapi"; import { DEFAULT_DPOP_CLOCK_SKEW, DEFAULT_DPOP_CLOCK_TOLERANCE, DEFAULT_RETRY_DELAY, DEFAULT_RETRY_JITTER, MAX_RECOMMENDED_DPOP_CLOCK_TOLERANCE } from "./constants.js"; /** * Detects if the current environment is Edge Runtime. * Edge Runtime environments have limited Node.js API support. */ function isEdgeRuntime() { return typeof globalThis.EdgeRuntime === "string"; } /** * Generates a new ES256 key pair for DPoP (Demonstrating Proof-of-Possession) operations. * * This function creates a cryptographically secure ES256 key pair suitable for DPoP proof * generation. The generated keys use the P-256 elliptic curve with SHA-256 hashing, * which is the required algorithm for DPoP as specified in RFC 9449. * * @returns Promise that resolves to a DpopKeyPair containing the private and public keys * * @example * ```typescript * import { generateDpopKeyPair } from "@auth0/nextjs-auth0/server"; * * const keyPair = await generateDpopKeyPair(); * * const auth0 = new Auth0Client({ * useDPoP: true, * dpopKeyPair: keyPair * }); * ``` * * @see {@link https://datatracker.ietf.org/doc/html/rfc9449 | RFC 9449: OAuth 2.0 Demonstrating Proof-of-Possession at the Application Layer (DPoP)} */ export async function generateDpopKeyPair() { return await generateKeyPair("ES256"); } const applyRetryDelay = async (config) => { const delay = config?.delay ?? 100; const jitter = config?.jitter ?? true; let actualDelay = delay; // Apply jitter if enabled (50-100% of original delay to prevent thundering herd) if (jitter) { actualDelay = delay * (0.5 + Math.random() * 0.5); } // Delay before retry to avoid rapid successive requests await new Promise((resolve) => setTimeout(resolve, actualDelay)); }; /** * Executes a function with retry logic for DPoP nonce errors. * * DPoP nonce errors occur when the authorization server requires a fresh nonce * for replay attack prevention. This function implements a single retry pattern * with configurable delay and jitter to handle these errors gracefully. * * The retry mechanism: * 1. If DPoP is not enabled, executes the function once without retry logic * 2. If DPoP is enabled: * - Executes the provided function * - If a DPoP nonce error occurs (400 with use_dpop_nonce error), waits with jitter * - Retries the function once * - The DPoP handle automatically learns and applies the new nonce on retry * 3. If the retry fails or any other error occurs, re-throws the error * * Note: The DPoP handle (oauth4webapi) is stateful and automatically learns nonces * from the DPoP-Nonce response header. No manual nonce injection is required. * * * ## Dual-Path Retry Logic * * The wrapper supports TWO different error paths, depending on how the caller * structures their token request: * * ### Path 1: HTTP Request Only (Recommended for Auth Code Flow) * **When to use:** Wrapping ONLY the HTTP request, not response processing* * **Error handling:** * - Nonce errors are detected via `response.status === 400` check (line 135) * - Non-nonce 400 errors pass through unchanged * - No exception is thrown; Response is returned for caller to process * * ### Path 2: HTTP Request + Response Processing (Used for Refresh/Connection Flows) * **When to use:** Wrapping both HTTP request AND response processing * * * **Error handling:** * - Nonce errors are detected via `isDPoPNonceError(error)` check (line 150) * - Non-nonce errors are re-thrown unchanged * - Caller receives either a successful response or an exception * * @template T - The return type of the function being executed * @param fn - The async function to execute with retry logic * @param config - Configuration object with retry behavior and DPoP enablement flag * @param config.isDPoPEnabled - Whether DPoP nonce retry logic should be applied (default: false) * @param config.delay - Retry delay in milliseconds (default: 100) * @param config.jitter - Whether to apply jitter to retry delay (default: true) * @returns The result of the function execution * @throws The original error if it's not a DPoP nonce error or if retry fails * * @example * ```typescript * import { withDPoPNonceRetry } from "@auth0/nextjs-auth0/server"; * import * as oauth from "oauth4webapi"; * * const dpopHandle = oauth.DPoP(client, keyPair); * * const result = await withDPoPNonceRetry( * async () => { * return await authorizationCodeGrantRequest( * metadata, * client, * clientAuth, * params, * redirectUri, * codeVerifier, * { DPoP: dpopHandle } * ); * }, * { isDPoPEnabled: true, delay: 100, jitter: true } * ); * * // The DPoP handle automatically learned the nonce from error response * // and injected it on retry * ``` * * @see {@link https://datatracker.ietf.org/doc/html/rfc9449#section-7.1 | RFC 9449 Section 7.1: DPoP Nonce} */ export async function withDPoPNonceRetry(fn, config) { // If DPoP is not enabled, execute without retry logic if (!config?.isDPoPEnabled) { return await fn(); } /** * PATH 1: Response Object Inspection (Auth Code Flow) * * When fn() returns a Response object (not thrown), we check its status. * If 400 with use_dpop_nonce error, extract nonce from error body and retry. * This path is used when response processing happens OUTSIDE the wrapper. * * PATH 2: Exception Handling (Refresh/Connection Flows) * When fn() includes response processing that throws, we catch exceptions above. * Both paths support automatic nonce retry per RFC 9449 Section 8. * * @see withDPoPNonceRetry JSDoc for detailed explanation of dual-path retry logic */ try { const response = await fn(); // Check if this is a 400 error response with use_dpop_nonce if (response instanceof Response && response.status === 400) { try { const errorBody = await response.clone().json(); if (errorBody.error === "use_dpop_nonce") { // This is a DPoP nonce error, retry with delay and jitter await applyRetryDelay(config); // Retry the request - the DPoP handle automatically learned the nonce return await fn(); } } catch { // If JSON parsing fails, it's not a DPoP nonce error - return original response } } return response; } catch (error) { if (isDPoPNonceError(error)) { // This is a DPoP nonce error, retry with delay and jitter await applyRetryDelay(config); // Retry the request - the DPoP handle automatically learned the nonce return await fn(); } else { throw error; } } } /** * Validates that a private and public key form a compatible key pair * by attempting to sign and verify a test message. * * This function ensures that the provided private and public keys are mathematically * compatible by performing a sign-and-verify operation with test data. This validation * helps catch mismatched key pairs, corrupted keys, or incorrect key formats early. * * @param privateKey - The private key as a Node.js KeyObject * @param publicKey - The public key as a Node.js KeyObject * @returns true if keys are compatible, false otherwise * * @example * ```typescript * import { createPrivateKey, createPublicKey } from "crypto"; * import { validateKeyPairCompatibility } from "@auth0/nextjs-auth0/server"; * * const privateKey = createPrivateKey(privateKeyPem); * const publicKey = createPublicKey(publicKeyPem); * * if (validateKeyPairCompatibility(privateKey, publicKey)) { * console.log("Keys are compatible"); * } else { * console.log("Keys are not compatible"); * } * ``` */ export function validateKeyPairCompatibility(privateKey, publicKey) { // Skip key pair validation in Edge Runtime environments // Edge Runtime doesn't have access to Node.js crypto APIs needed for validation if (isEdgeRuntime()) { return true; } try { // Create test data const testData = "test-data-for-key-pair-validation"; // Sign with private key const sign = createSign("sha256"); sign.update(testData); const signature = sign.sign(privateKey); // Verify with public key const verify = createVerify("sha256"); verify.update(testData); const isValid = verify.verify(publicKey, signature); if (!isValid) { console.warn("WARNING: Private and public keys do not form a valid key pair - signature verification failed. " + "Please ensure the keys are properly paired and in the correct format. " + "DPoP will be disabled and bearer authentication will be used instead."); return false; } return true; } catch (error) { console.warn("WARNING: Failed to validate key pair compatibility. " + "This may indicate invalid key format, mismatched algorithms, or corrupted key data. " + "DPoP will be disabled and bearer authentication will be used instead. " + `Error: ${error instanceof Error ? error.message : String(error)}`); return false; } } /** * Validates DPoP configuration and returns keypair and options if available. * Attempts to load from environment variables if not provided in options. * * **Validation Behavior:** * - **Success**: Returns both `dpopKeyPair` and `dpopOptions` * - **Validation Failure**: Returns `{ dpopKeyPair: undefined, dpopOptions: undefined }` * * When DPoP cannot be properly configured, the system falls back to bearer authentication. * * **Performance Characteristics - Synchronous Key Loading:** * * Key loading and validation are performed synchronously during Auth0Client constructor execution. * * **Optimization Strategies:** * - **Recommended**: Pre-generate keys and pass via `dpopKeyPair` option to avoid env var parsing * - **Module-Level**: Instantiate Auth0Client at module level (lib/auth0.ts) for one-time cost * - **High-Throughput**: Consider pre-loading keys outside constructor for serverless environments * * @example Performance-optimized initialization * ```typescript * // Optimal: Pre-generated keys (no env var parsing) * import { generateKeyPair } from "oauth4webapi"; * const dpopKeyPair = await generateKeyPair("ES256"); * export const auth0 = new Auth0Client({ useDPoP: true, dpopKeyPair }); * ``` * * **Security-sensitive configuration:** * - `clockSkew`: Difference between client and server clocks (default: {@link DEFAULT_DPOP_CLOCK_SKEW}) * - `clockTolerance`: Acceptable time drift for DPoP proof validation (default: {@link DEFAULT_DPOP_CLOCK_TOLERANCE}, max recommended: {@link MAX_RECOMMENDED_DPOP_CLOCK_TOLERANCE}) * * Values exceeding {@link MAX_RECOMMENDED_DPOP_CLOCK_TOLERANCE} will trigger a warning but are not enforced. * Excessively large clock tolerance values may weaken DPoP security by allowing replay attacks within a * wider time window. Prefer synchronizing server clocks using NTP instead of increasing tolerance. * * @param options The configuration options containing DPoP settings * @returns Object containing DpopKeyPair and DpopOptions if validation succeeds, or both undefined if validation fails */ export function validateDpopConfiguration(options) { const useDPoP = options.useDPoP || false; // If DPoP is not enabled, return early with undefined values if (!useDPoP) { return { dpopKeyPair: undefined, dpopOptions: undefined }; } // Build DPoP options with defaults from environment variables or provided options const clockTolerance = options.dpopOptions?.clockTolerance ?? (process.env.AUTH0_DPOP_CLOCK_TOLERANCE ? parseInt(process.env.AUTH0_DPOP_CLOCK_TOLERANCE, 10) : DEFAULT_DPOP_CLOCK_TOLERANCE); // Warn if clock tolerance exceeds recommended maximum (but don't enforce) if (clockTolerance > MAX_RECOMMENDED_DPOP_CLOCK_TOLERANCE) { const productionMaxTolerance = process.env .AUTH0_DPOP_CLOCK_TOLERANCE_MAX_PROD ? parseInt(process.env.AUTH0_DPOP_CLOCK_TOLERANCE_MAX_PROD, 10) : MAX_RECOMMENDED_DPOP_CLOCK_TOLERANCE; if (process.env.NODE_ENV === "production" && clockTolerance > productionMaxTolerance) { throw new Error(`clockTolerance of ${clockTolerance}s exceeds maximum allowed ${productionMaxTolerance}s in production. ` + "This could significantly weaken DPoP replay attack protection. " + "Set AUTH0_DPOP_CLOCK_TOLERANCE_MAX_PROD environment variable to override this limit in production."); } console.warn(`WARNING: clockTolerance of ${clockTolerance}s exceeds recommended maximum of ${MAX_RECOMMENDED_DPOP_CLOCK_TOLERANCE}s. ` + "This may weaken DPoP security by allowing replay attacks within a wider time window. " + "Consider synchronizing server clocks using NTP instead of increasing tolerance."); } const dpopOptions = { clockSkew: options.dpopOptions?.clockSkew ?? (process.env.AUTH0_DPOP_CLOCK_SKEW ? parseInt(process.env.AUTH0_DPOP_CLOCK_SKEW, 10) : DEFAULT_DPOP_CLOCK_SKEW), clockTolerance, retry: { delay: options.dpopOptions?.retry?.delay ?? (process.env.AUTH0_RETRY_DELAY ? parseInt(process.env.AUTH0_RETRY_DELAY, 10) : DEFAULT_RETRY_DELAY), jitter: options.dpopOptions?.retry?.jitter ?? (process.env.AUTH0_RETRY_JITTER ? process.env.AUTH0_RETRY_JITTER === "true" : DEFAULT_RETRY_JITTER) } }; // Validate retry configuration if (dpopOptions.retry && typeof dpopOptions.retry.delay === "number" && dpopOptions.retry.delay < 0) { throw new Error("Retry delay must be non-negative"); } // If we already have a keypair, return it with options if (options.dpopKeyPair) { return { dpopKeyPair: options.dpopKeyPair, dpopOptions }; } // If DPoP is enabled but no keypair provided, check if environment variables exist if (useDPoP) { const privateKeyPem = process.env.AUTH0_DPOP_PRIVATE_KEY; const publicKeyPem = process.env.AUTH0_DPOP_PUBLIC_KEY; // In Edge Runtime, we can't use Node.js crypto APIs to load keys from environment variables if (isEdgeRuntime() && (privateKeyPem || publicKeyPem)) { console.warn("WARNING: Running in Edge Runtime environment. DPoP keypair loading from environment variables " + "is not supported due to limited Node.js crypto API access. DPoP has been disabled. " + "To use DPoP in Edge Runtime, provide a pre-generated keypair via the dpopKeyPair option."); return { dpopKeyPair: undefined, dpopOptions: undefined }; } if (privateKeyPem && publicKeyPem) { try { // Note: Key loading is performed synchronously during initialization. // Ensure keys are pre-loaded or cached to avoid blocking the event loop. const privateKeyNodeJS = createPrivateKey(privateKeyPem); const publicKeyNodeJS = createPublicKey(publicKeyPem); // Validate algorithm - DPoP requires ES256 (ECDSA using P-256 and SHA-256) if (privateKeyNodeJS.asymmetricKeyType !== "ec") { throw new Error(`DPoP private key must be an Elliptic Curve key for ES256 algorithm, got: ${privateKeyNodeJS.asymmetricKeyType}`); } if (publicKeyNodeJS.asymmetricKeyType !== "ec") { throw new Error(`DPoP public key must be an Elliptic Curve key for ES256 algorithm, got: ${publicKeyNodeJS.asymmetricKeyType}`); } const privateKeyDetails = privateKeyNodeJS.asymmetricKeyDetails; const publicKeyDetails = publicKeyNodeJS.asymmetricKeyDetails; // Validate P-256 curve requirement for ES256 algorithm if (privateKeyDetails?.namedCurve !== "prime256v1") { throw new Error(`DPoP private key must use P-256 curve (prime256v1) for ES256 algorithm, got: ${privateKeyDetails?.namedCurve}`); } if (publicKeyDetails?.namedCurve !== "prime256v1") { throw new Error(`DPoP public key must use P-256 curve (prime256v1) for ES256 algorithm, got: ${publicKeyDetails?.namedCurve}`); } // Validate key pair compatibility const isKeyPairValid = validateKeyPairCompatibility(privateKeyNodeJS, publicKeyNodeJS); if (!isKeyPairValid) { // Key pair validation failed, completely disable DPoP to ensure consistent state // When validation fails, we should not use DPoP at all and fallback to bearer auth console.warn("WARNING: DPoP key pair validation failed. DPoP has been completely disabled. " + "Falling back to bearer authentication. Please verify your key pair configuration."); return { dpopKeyPair: undefined, dpopOptions: undefined }; } // Convert NodeJS KeyObjects to CryptoKeys synchronously const privateKey = privateKeyNodeJS.toCryptoKey("ES256", false, [ "sign" ]); const publicKey = publicKeyNodeJS.toCryptoKey("ES256", false, [ "verify" ]); return { dpopKeyPair: { privateKey, publicKey }, dpopOptions }; } catch (error) { console.warn("WARNING: Failed to load DPoP keypair from environment variables. " + "Please ensure AUTH0_DPOP_PUBLIC_KEY and AUTH0_DPOP_PRIVATE_KEY contain valid ES256 keys in PEM format. " + `Error: ${error instanceof Error ? error.message : String(error)}`); } } if (!privateKeyPem || !publicKeyPem) { // Issue warning if no keypair is available console.warn("WARNING: useDPoP is set to true but dpopKeyPair is not provided. " + "DPoP will not be used and protected requests will use bearer authentication instead. " + "To enable DPoP, provide a dpopKeyPair in the Auth0Client options or set " + "AUTH0_DPOP_PUBLIC_KEY and AUTH0_DPOP_PRIVATE_KEY environment variables."); } } // No DPoP keypair available, but DPoP is enabled, return options without keypair return { dpopKeyPair: undefined, dpopOptions }; }