@auth0/nextjs-auth0
Version:
Auth0 Next.js SDK
250 lines (249 loc) • 9.77 kB
JavaScript
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);
}
}