@wristband/nextjs-auth
Version:
SDK for integrating your Next.js application with Wristband. Handles user authentication, session management, and token management.
345 lines (344 loc) • 14.2 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.normalizeMiddlewareConfig = normalizeMiddlewareConfig;
exports.resolveOnPageUnauthenticated = resolveOnPageUnauthenticated;
exports.createRouteMatcher = createRouteMatcher;
exports.isProtectedApi = isProtectedApi;
exports.isProtectedPage = isProtectedPage;
exports.isValidCsrf = isValidCsrf;
exports.copyResponseHeaders = copyResponseHeaders;
const server_1 = require("next/server");
const DEFAULT_CSRF_HEADER_NAME = 'X-CSRF-TOKEN';
const DEFAULT_PROTECTED_APIS = [];
const DEFAULT_PROTECTED_PAGES = [];
const DEFAULT_LOGIN_ENDPOINT = '/api/auth/login';
const DEFAULT_SESSION_ENDPOINT = '/api/auth/session';
const DEFAULT_TOKEN_ENDPOINT = '/api/auth/token';
const VALID_AUTH_STRATEGIES = new Set(['SESSION', 'JWT']);
// Skip x-middleware-next - internal Next.js routing signal
const X_MIDDLEWARE_NEXT_HEADER = 'x-middleware-next';
/**
* Validates auth middleware configuration.
*
* @param config - User-provided middleware configuration
* @throws {TypeError} If authStrategies is empty
* @throws {TypeError} If authStrategies contains invalid values
* @throws {TypeError} If authStrategies contains duplicates
* @throws {TypeError} If authStrategies contains too many strategies
* @throws {TypeError} If SESSION strategy is used but sessionConfig or sessionOptions are missing
*/
function validateMiddlewareConfig(config) {
// Validate authStrategies is not empty
if (!config.authStrategies || config.authStrategies.length === 0) {
throw new TypeError('authStrategies must contain at least one strategy');
}
// Validate in one pass: no invalid values, no duplicates
const seen = new Set();
const invalidStrategies = [];
config.authStrategies.forEach((strategy) => {
// Check for invalid strategy
if (!VALID_AUTH_STRATEGIES.has(strategy)) {
invalidStrategies.push(strategy);
return; // Skip to next iteration
}
// Check for duplicate
if (seen.has(strategy)) {
throw new TypeError(`authStrategies contains duplicate strategy: '${strategy}'`);
}
seen.add(strategy);
});
// Report all invalid strategies at once
if (invalidStrategies.length > 0) {
throw new TypeError(`Invalid auth strategies: '${invalidStrategies.join("', '")}'. Valid strategies are: 'SESSION', 'JWT'`);
}
// Validate sessionConfig is provided if SESSION strategy is used
if (config.authStrategies.includes('SESSION')) {
if (!config.sessionConfig) {
throw new TypeError('sessionConfig is required when using SESSION strategy');
}
if (!config.sessionConfig?.sessionOptions) {
throw new TypeError('sessionConfig.sessionOptions is required when using SESSION strategy');
}
}
}
/**
* Normalizes middleware configuration by applying default values for optional fields.
*
* @param config - User-provided middleware configuration with nested strategy configs
* @returns Normalized configuration with all strategy configs in nested objects and defaults applied
* @throws {TypeError} If configuration validation fails
*
* @example
* ```typescript
* const normalized = normalizeMiddlewareConfig({
* authStrategies: ['SESSION'],
* sessionConfig: {
* sessionOptions: { secrets: 'my-secret', enableCsrfProtection: true },
* },
* protectedPages: ['/dashboard'],
* });
* // Returns config with sessionConfig and jwtConfig objects, all defaults applied
* ```
*/
function normalizeMiddlewareConfig(config) {
// Validate config first
validateMiddlewareConfig(config);
return {
authStrategies: config.authStrategies,
protectedApis: config.protectedApis || DEFAULT_PROTECTED_APIS,
protectedPages: config.protectedPages || DEFAULT_PROTECTED_PAGES,
// The core middleware logic will handle applying a default "onPageUnauthenticated" value if none is provided.
onPageUnauthenticated: config.onPageUnauthenticated || undefined,
sessionConfig: {
sessionOptions: config.sessionConfig?.sessionOptions || undefined,
sessionEndpoint: config.sessionConfig?.sessionEndpoint || DEFAULT_SESSION_ENDPOINT,
tokenEndpoint: config.sessionConfig?.tokenEndpoint || DEFAULT_TOKEN_ENDPOINT,
csrfTokenHeaderName: config.sessionConfig?.csrfTokenHeaderName || DEFAULT_CSRF_HEADER_NAME,
},
jwtConfig: {
jwksCacheMaxSize: config.jwtConfig?.jwksCacheMaxSize || undefined,
jwksCacheTtl: config.jwtConfig?.jwksCacheTtl || undefined,
},
};
}
/**
* Extracts the pathname from a login URL, removing the protocol, host, and any tenant placeholders. This
* is used to create a default redirect URL for the default for onPageUnauthenticated() middleware config.
*
* This handles URLs with tenant subdomains (containing `{tenant_name}` or `{tenant_domain}` placeholders)
* by removing the placeholder and the dot separator that follows it.
*
* @param loginUrl - The full login URL from WristbandAuth config
* @returns The pathname portion of the URL (e.g., '/api/auth/login'). Returns '/api/auth/login' as
* a fallback if URL parsing fails.
*
* @example
* ```typescript
* extractLoginPath('http://localhost/login');
* // Returns: '/login'
*
* extractLoginPath('https://{tenant_domain}.app.com/api/auth/login');
* // Returns: '/api/auth/login'
*
* extractLoginPath('https://{tenant_name}.app.com/login');
* // Returns: '/login'
*
* extractLoginPath('https://acme.com/api/v1/login');
* // Returns: '/api/v1/login'
* ```
*/
function extractLoginPath(loginUrl) {
if (!loginUrl || !loginUrl.trim()) {
throw new TypeError('Must provide a valid login URL');
}
try {
// Remove tenant placeholders and the dot separator that follows them
const normalizedUrl = loginUrl.replace(/\{tenant_domain\}\./g, '').replace(/\{tenant_name\}\./g, '');
const url = new URL(normalizedUrl);
return url.pathname;
}
catch (error) {
// Fallback to relative path default in the event a parse error occurs.
return DEFAULT_LOGIN_ENDPOINT;
}
}
/**
* Resolves the onPageUnauthenticated() handler, using the provided config or creating a default handler
* that redirects to the login endpoint with a return_url query parameter.
*
* @param config - Normalized middleware configuration
* @param loginUrl - The full login URL from WristbandAuth config
* @returns The resolved onPageUnauthenticated handler function
*
* @example
* ```typescript
* // With custom handler
* const onPageUnauthenticated = resolveOnPageUnauthenticated(config, loginUrl);
* return await onPageUnauthenticated(req);
*
* // Default behavior (no custom handler provided)
* // Redirects to: /api/auth/login?return_url=<current_location>
* ```
*/
function resolveOnPageUnauthenticated(config, loginUrl) {
// If user provided a custom handler, use it
if (config.onPageUnauthenticated) {
return config.onPageUnauthenticated;
}
// Otherwise, create default handler that redirects to login with return_url
const loginPath = extractLoginPath(loginUrl);
return (request) => {
const redirectUrl = new URL(loginPath, request.url);
redirectUrl.searchParams.set('return_url', request.url);
return server_1.NextResponse.redirect(redirectUrl, { status: 302 });
};
}
/**
* Creates a route matcher function from an array of URL patterns.
*
* Supports:
* - Exact path matches: `/api/users`
* - Named parameters: `/api/users/:id` matches `/api/users/123`
* - Wildcards: `/dashboard(.*)` matches `/dashboard`, `/dashboard/settings`, etc.
* - Regex patterns: Any valid regex pattern
*
* @param patterns - Array of route patterns to match against
* @returns Matcher function that tests if a pathname matches any pattern
*
* @example
* ```typescript
* const matcher = createRouteMatcher([
* '/dashboard(.*)',
* '/api/users/:id',
* '/settings'
* ]);
*
* matcher('/dashboard/profile'); // true
* matcher('/api/users/123'); // true
* matcher('/settings'); // true
* matcher('/home'); // false
* ```
*/
function createRouteMatcher(patterns) {
const regexPatterns = patterns.map((pattern) => {
// Convert pattern to regex
// Handle named params like :id and wildcards like (.*)
const regexPattern = pattern
.replace(/:[^/]+/g, '[^/]+') // :id -> [^/]+
.replace(/\(\.\*\)/g, '.*'); // (.*) -> .*
return new RegExp(`^${regexPattern}$`);
});
return (pathname) => {
return regexPatterns.some((regex) => {
return regex.test(pathname);
});
};
}
/**
* Determines if a pathname is a protected API route that requires authentication.
*
* A route is considered protected if:
* 1. SESSION strategy is used and it matches the Session Endpoint (`/api/auth/session` by default)
* 2. SESSION strategy is used and it matches the Token Endpoint (`/api/auth/token` by default)
* 3. It matches any pattern in `config.protectedApis`
*
* Note: Session and token endpoints are only protected when using the SESSION authentication strategy.
* If using only JWT strategy, these endpoints will not be automatically protected.
*
* @param pathname - The URL pathname to check
* @param config - Normalized middleware configuration
* @returns True if the route requires authentication
*
* @example
* ```typescript
* // With SESSION strategy and no protectedApis configured
* isProtectedApi('/api/users', config); // false (not protected by default)
* isProtectedApi('/api/auth/login', config); // false
* isProtectedApi('/api/auth/session', config); // true (protected when using SESSION strategy)
* isProtectedApi('/api/auth/token', config); // true (protected when using SESSION strategy)
*
* // With JWT strategy only (no SESSION strategy)
* isProtectedApi('/api/auth/session', config); // false (not protected without SESSION strategy)
* isProtectedApi('/api/auth/token', config); // false (not protected without SESSION strategy)
*
* // With protectedApis: ['/api/v1/.*']
* isProtectedApi('/api/v1/users', config); // true
* ```
*/
function isProtectedApi(pathname, config) {
// Only protect session and token endpoints if SESSION strategy is being used
if (config.authStrategies.includes('SESSION')) {
if (pathname === config.sessionConfig.sessionEndpoint || pathname === config.sessionConfig.tokenEndpoint) {
return true;
}
}
// Check against protected API patterns
const matcher = createRouteMatcher(config.protectedApis);
return matcher(pathname);
}
/**
* Determines if a pathname is a protected page route that requires authentication.
*
* Checks if the pathname matches any pattern defined in `config.protectedPages`.
* Unlike API routes, pages typically redirect unauthenticated users rather than
* returning 401 status codes.
*
* @param pathname - The URL pathname to check
* @param config - Normalized middleware configuration
* @returns True if the page requires authentication
*
* @example
* ```typescript
* // With protectedPages: ['/dashboard(.*)', '/settings']
* isProtectedPage('/dashboard', config); // true
* isProtectedPage('/dashboard/profile', config); // true
* isProtectedPage('/settings', config); // true
* isProtectedPage('/home', config); // false
* ```
*/
function isProtectedPage(request, config) {
const matcher = createRouteMatcher(config.protectedPages);
const isRouteMatch = matcher(request.nextUrl.pathname);
if (!isRouteMatch) {
return false;
}
// Server Actions POST to the same route as their page but handle their own auth.
// Skip middleware for them to avoid blocking legitimate Server Action requests.
// Security: Requires BOTH POST method AND next-action header to prevent bypass attacks.
const isServerAction = request.method === 'POST' && request.headers.get('next-action');
if (isServerAction) {
return false;
}
return true;
}
/**
* Validates the CSRF token for API requests to prevent cross-site request forgery attacks.
*
* Compares the CSRF token stored in the session against the token provided in the
* request header. Both must exist and match exactly for validation to pass.
*
* This should only be used for API routes, as page navigations don't include CSRF headers.
*
* @param req - The NextRequest object containing headers
* @param csrfToken - The CSRF token stored in the session (from session.csrfToken)
* @param csrfHeaderName - The header name to check for the token (default: 'X-CSRF-TOKEN')
* @returns True if the CSRF token is valid, false otherwise
*
* @example
* ```typescript
* // In middleware for protected API routes
* const isValid = isValidCsrf(req, session.csrfToken, 'X-CSRF-TOKEN');
* if (!isValid) {
* return new NextResponse(null, { status: 403 });
* }
* ```
*/
function isValidCsrf(req, csrfToken, csrfHeaderName) {
if (!csrfToken) {
return false;
}
const headerValue = req.headers.get(csrfHeaderName);
return csrfToken === headerValue;
}
/**
* Copies all headers from a source Response to a target NextResponse.
* This is useful for preserving headers (including Set-Cookie) when creating
* new responses in middleware.
*
* IMPORTANT: Filters out the internal Next.js 'x-middleware-next' header to prevent
* routing conflicts when copying headers to error or redirect responses.
*
* @param source - The source Response to copy headers from
* @param target - The target NextResponse to copy headers to
* @returns The target NextResponse with headers copied
*/
function copyResponseHeaders(source, target) {
source.headers.forEach((value, key) => {
// Skip x-middleware-next - internal Next.js routing signal
if (key.toLowerCase() === X_MIDDLEWARE_NEXT_HEADER) {
return;
}
target.headers.append(key, value);
});
return target;
}