UNPKG

@auth0/nextjs-auth0

Version:
250 lines (249 loc) 9.77 kB
import { allowInsecureRequests, customFetch, isDPoPNonceError, protectedResourceRequest } from "oauth4webapi"; /** * Fetcher class for making authenticated HTTP requests with optional DPoP support. * * This class provides a high-level interface for making HTTP requests to protected resources * using OAuth 2.0 access tokens. It supports both standard Bearer token authentication and * DPoP (Demonstrating Proof-of-Possession) for enhanced security. * * Key Features: * - Automatic access token injection * - DPoP proof generation and nonce error retry * - Flexible URL handling (absolute and relative) * - Type-safe response handling * - Custom fetch implementation support * * @template TOutput - Response type that extends the standard Response interface * * @example * ```typescript * const fetcher = await auth0.createFetcher(req, { * baseUrl: 'https://api.example.com', * useDPoP: true * }); * * const response = await fetcher.fetchWithAuth('/protected-resource', { * method: 'POST', * body: JSON.stringify({ data: 'example' }) * }); * ``` */ export class Fetcher { constructor(config, hooks) { this.hooks = hooks; this.config = { ...config, fetch: config.fetch || // For easier testing and constructor compatibility with SSR. (typeof window === "undefined" ? fetch : window.fetch.bind(window)) }; } /** * Checks if a URL is absolute (includes protocol and domain). * * @param url - The URL string to test * @returns True if the URL is absolute, false otherwise * * @example * ```typescript * fetcher.isAbsoluteUrl('https://api.example.com/data') // true * fetcher.isAbsoluteUrl('/api/data') // false * fetcher.isAbsoluteUrl('//example.com/api') // true * ``` */ isAbsoluteUrl(url) { try { new URL(url); return true; } catch { return false; } } /** * Builds a complete URL from base URL and relative path. * * @param baseUrl - The base URL to resolve against (optional) * @param url - The URL or path to resolve * @returns The complete resolved URL * @throws TypeError if url is relative but baseUrl is not provided * * @example * ```typescript * fetcher.buildUrl('https://api.example.com', '/users') * // Returns: 'https://api.example.com/users' * ``` */ buildUrl(baseUrl, url) { if (url) { if (this.isAbsoluteUrl(url)) { return url; } if (baseUrl) { return `${baseUrl.replace(/\/?\/$/, "")}/${url.replace(/^\/+/, "")}`; } } throw new TypeError("`url` must be absolute or `baseUrl` non-empty."); } /** * Retrieves an access token for the current request. * Uses the configured access token factory or falls back to the hooks implementation. * * @param getAccessTokenOptions - Options for token retrieval (scope, audience, etc.) * @returns Promise that resolves to the access token string * * @example * ```typescript * const token = await fetcher.getAccessToken({ * scope: 'read:data', * audience: 'https://api.example.com' * }); * ``` */ getAccessToken(getAccessTokenOptions) { return this.config.getAccessToken ? this.config.getAccessToken(getAccessTokenOptions ?? {}) : this.hooks.getAccessToken(getAccessTokenOptions ?? {}); } buildBaseRequest(info, init) { // Handle URL resolution before creating Request object let resolvedUrl; if (info instanceof Request) { // If info is already a Request object, use its URL resolvedUrl = info.url; } else if (info instanceof URL) { // If info is a URL object, convert to string resolvedUrl = info.toString(); } else { // info is a string - check if we need to resolve it with baseUrl if (this.config.baseUrl && !this.isAbsoluteUrl(info)) { // Resolve relative URL with baseUrl resolvedUrl = this.buildUrl(this.config.baseUrl, info); } else { // Use as-is (either absolute URL or no baseUrl configured) resolvedUrl = info; } } // Create Request object with resolved URL return new Request(resolvedUrl, init); } getHeader(headers, name) { if (Array.isArray(headers)) { return new Headers(headers).get(name) || ""; } if (typeof headers.get === "function") { return headers.get(name) || ""; } return headers[name] || ""; } async internalFetchWithAuth(info, init, callbacks, getAccessTokenOptions) { const request = this.buildBaseRequest(info, init); const accessTokenResponse = await this.getAccessToken(getAccessTokenOptions); let useDpop; let accessToken; if (typeof accessTokenResponse === "string") { useDpop = this.config.dpopHandle ? true : false; accessToken = accessTokenResponse; } else { useDpop = this.config.dpopHandle ? accessTokenResponse.token_type?.toLowerCase() === "dpop" : false; accessToken = accessTokenResponse.accessToken; } try { // Make (DPoP)-authenticated request using oauth4webapi const response = await protectedResourceRequest(accessToken, request.method, new URL(request.url), request.headers, request.body, { ...this.config.httpOptions(), [customFetch]: (url, options) => { return this.config.fetch(url, options); }, [allowInsecureRequests]: this.config.allowInsecureRequests || false, ...(useDpop && { DPoP: this.config.dpopHandle }) }); return response; } catch (error) { // Use oauth4webapi's isDPoPNonceError to detect nonce errors if (isDPoPNonceError(error) && callbacks.onUseDpopNonceError) { // Retry once with the callback return callbacks.onUseDpopNonceError(); } // Re-throw non-DPoP nonce errors or if no callback available throw error; } } isRequestInit(obj) { if (!obj || typeof obj !== "object") return false; // Check for GetAccessTokenOptions-specific properties // Since RequestInit and GetAccessTokenOptions have no common properties, // if any GetAccessTokenOptions property is present, it's GetAccessTokenOptions const getAccessTokenOptionsProps = ["refresh", "scope", "audience"]; const hasGetAccessTokenOptionsProp = getAccessTokenOptionsProps.some((prop) => Object.prototype.hasOwnProperty.call(obj, prop)); // If it has GetAccessTokenOptions props, it's NOT RequestInit // Otherwise, default to RequestInit (backwards compatibility) return !hasGetAccessTokenOptionsProp; } // Implementation fetchWithAuth(info, initOrOptions, getAccessTokenOptions) { // Parameter disambiguation for 2-argument case let init; let accessTokenOptions; if (arguments.length === 2 && initOrOptions !== undefined) { // Determine if second argument is RequestInit or GetAccessTokenOptions if (this.isRequestInit(initOrOptions)) { init = initOrOptions; accessTokenOptions = undefined; } else { init = undefined; accessTokenOptions = initOrOptions; } } else { // 3-argument case or 1-argument case init = initOrOptions; accessTokenOptions = getAccessTokenOptions; } const callbacks = { onUseDpopNonceError: async () => { // Use configured retry values or defaults const retryConfig = this.config.retryConfig ?? { delay: 100, jitter: true }; let delay = retryConfig.delay ?? 100; // Apply jitter if enabled if (retryConfig.jitter) { delay = delay * (0.5 + Math.random() * 0.5); // 50-100% of original delay } await new Promise((resolve) => setTimeout(resolve, delay)); try { return await this.internalFetchWithAuth(info, init, { ...callbacks, // Retry on a `use_dpop_nonce` error, but just once. onUseDpopNonceError: undefined }, accessTokenOptions); } catch (retryError) { // If the retry also fails, enhance the error with context if (isDPoPNonceError(retryError)) { const enhancedError = new Error(`DPoP nonce error persisted after retry: ${retryError.message}`); enhancedError.code = retryError.code || "dpop_nonce_retry_failed"; throw enhancedError; } // For non-DPoP errors, just re-throw throw retryError; } } }; return this.internalFetchWithAuth(info, init, callbacks, accessTokenOptions); } }