@auth0/nextjs-auth0
Version:
Auth0 Next.js SDK
455 lines (454 loc) • 21.3 kB
JavaScript
import { cookies } from "next/headers.js";
import { NextRequest, NextResponse } from "next/server.js";
import { AccessTokenError, AccessTokenErrorCode, AccessTokenForConnectionError, AccessTokenForConnectionErrorCode } from "../errors/index.js";
import { isRequest } from "../utils/request.js";
import { AuthClient } from "./auth-client.js";
import { RequestCookies, ResponseCookies } from "./cookies.js";
import * as withApiAuthRequired from "./helpers/with-api-auth-required.js";
import { appRouteHandlerFactory, pageRouteHandlerFactory } from "./helpers/with-page-auth-required.js";
import { StatefulSessionStore } from "./session/stateful-session-store.js";
import { StatelessSessionStore } from "./session/stateless-session-store.js";
import { TransactionStore } from "./transaction-store.js";
export class Auth0Client {
constructor(options = {}) {
// Extract and validate required options
const { domain, clientId, clientSecret, appBaseUrl, secret, clientAssertionSigningKey } = this.validateAndExtractRequiredOptions(options);
const clientAssertionSigningAlg = options.clientAssertionSigningAlg ||
process.env.AUTH0_CLIENT_ASSERTION_SIGNING_ALG;
// Auto-detect base path for cookie configuration
const basePath = process.env.NEXT_PUBLIC_BASE_PATH;
const sessionCookieOptions = {
name: options.session?.cookie?.name ?? "__session",
secure: options.session?.cookie?.secure ??
process.env.AUTH0_COOKIE_SECURE === "true",
sameSite: options.session?.cookie?.sameSite ??
process.env.AUTH0_COOKIE_SAME_SITE ??
"lax",
path: options.session?.cookie?.path ??
process.env.AUTH0_COOKIE_PATH ??
basePath ??
"/",
transient: options.session?.cookie?.transient ??
process.env.AUTH0_COOKIE_TRANSIENT === "true",
domain: options.session?.cookie?.domain ?? process.env.AUTH0_COOKIE_DOMAIN
};
const transactionCookieOptions = {
prefix: options.transactionCookie?.prefix ?? "__txn_",
secure: options.transactionCookie?.secure ?? false,
sameSite: options.transactionCookie?.sameSite ?? "lax",
path: options.transactionCookie?.path ?? basePath ?? "/",
maxAge: options.transactionCookie?.maxAge ?? 3600
};
if (appBaseUrl) {
const { protocol } = new URL(appBaseUrl);
if (protocol === "https:") {
sessionCookieOptions.secure = true;
transactionCookieOptions.secure = true;
}
}
this.routes = {
login: process.env.NEXT_PUBLIC_LOGIN_ROUTE || "/auth/login",
logout: "/auth/logout",
callback: "/auth/callback",
backChannelLogout: "/auth/backchannel-logout",
profile: process.env.NEXT_PUBLIC_PROFILE_ROUTE || "/auth/profile",
accessToken: process.env.NEXT_PUBLIC_ACCESS_TOKEN_ROUTE || "/auth/access-token",
...options.routes
};
this.transactionStore = new TransactionStore({
secret,
cookieOptions: transactionCookieOptions,
enableParallelTransactions: options.enableParallelTransactions ?? true
});
this.sessionStore = options.sessionStore
? new StatefulSessionStore({
...options.session,
secret,
store: options.sessionStore,
cookieOptions: sessionCookieOptions
})
: new StatelessSessionStore({
...options.session,
secret,
cookieOptions: sessionCookieOptions
});
this.authClient = new AuthClient({
transactionStore: this.transactionStore,
sessionStore: this.sessionStore,
domain,
clientId,
clientSecret,
clientAssertionSigningKey,
clientAssertionSigningAlg,
authorizationParameters: options.authorizationParameters,
pushedAuthorizationRequests: options.pushedAuthorizationRequests,
appBaseUrl,
secret,
signInReturnToPath: options.signInReturnToPath,
logoutStrategy: options.logoutStrategy,
includeIdTokenHintInOIDCLogoutUrl: options.includeIdTokenHintInOIDCLogoutUrl,
beforeSessionSaved: options.beforeSessionSaved,
onCallback: options.onCallback,
routes: this.routes,
allowInsecureRequests: options.allowInsecureRequests,
httpTimeout: options.httpTimeout,
enableTelemetry: options.enableTelemetry,
enableAccessTokenEndpoint: options.enableAccessTokenEndpoint,
noContentProfileResponseWhenUnauthenticated: options.noContentProfileResponseWhenUnauthenticated
});
}
/**
* middleware mounts the SDK routes to run as a middleware function.
*/
middleware(req) {
return this.authClient.handler.bind(this.authClient)(req);
}
/**
* getSession returns the session data for the current request.
*/
async getSession(req) {
if (req) {
// middleware usage
if (req instanceof NextRequest) {
return this.sessionStore.get(req.cookies);
}
// pages router usage
return this.sessionStore.get(this.createRequestCookies(req));
}
// app router usage: Server Components, Server Actions, Route Handlers
return this.sessionStore.get(await cookies());
}
/**
* getAccessToken returns the access token.
*
* NOTE: Server Components cannot set cookies. Calling `getAccessToken()` in a Server Component will cause the access token to be refreshed, if it is expired, and the updated token set will not to be persisted.
* It is recommended to call `getAccessToken(req, res)` in the middleware if you need to retrieve the access token in a Server Component to ensure the updated token set is persisted.
*/
async getAccessToken(arg1, arg2, arg3) {
const defaultOptions = {
refresh: false
};
let req = undefined;
let res = undefined;
let options = {};
// Determine which overload was called based on arguments
if (arg1 &&
(arg1 instanceof Request || typeof arg1.headers === "object")) {
// Case: getAccessToken(req, res, options?)
req = arg1;
res = arg2; // arg2 must be Response if arg1 is Request
// Merge provided options (arg3) with defaults
options = { ...defaultOptions, ...(arg3 ?? {}) };
if (!res) {
throw new TypeError("getAccessToken(req, res): The 'res' argument is missing. Both 'req' and 'res' must be provided together for Pages Router or middleware usage.");
}
}
else {
// Case: getAccessToken(options?) or getAccessToken()
// arg1 (if present) must be options, arg2 and arg3 must be undefined.
if (arg2 !== undefined || arg3 !== undefined) {
throw new TypeError("getAccessToken: Invalid arguments. Valid signatures are getAccessToken(), getAccessToken(options), or getAccessToken(req, res, options).");
}
// Merge provided options (arg1) with defaults
options = {
...defaultOptions,
...(arg1 ?? {})
};
}
const session = req
? await this.getSession(req)
: await this.getSession();
if (!session) {
throw new AccessTokenError(AccessTokenErrorCode.MISSING_SESSION, "The user does not have an active session.");
}
const [error, getTokenSetResponse] = await this.authClient.getTokenSet(session.tokenSet, options.refresh);
if (error) {
throw error;
}
const { tokenSet, idTokenClaims } = getTokenSetResponse;
// update the session with the new token set, if necessary
if (tokenSet.accessToken !== session.tokenSet.accessToken ||
tokenSet.expiresAt !== session.tokenSet.expiresAt ||
tokenSet.refreshToken !== session.tokenSet.refreshToken) {
if (idTokenClaims) {
session.user = idTokenClaims;
}
// call beforeSessionSaved callback if present
// if not then filter id_token claims with default rules
const finalSession = await this.authClient.finalizeSession(session, tokenSet.idToken);
await this.saveToSession({
...finalSession,
tokenSet
}, req, res);
}
return {
token: tokenSet.accessToken,
scope: tokenSet.scope,
expiresAt: tokenSet.expiresAt
};
}
/**
* Retrieves an access token for a connection.
*
* This method attempts to obtain an access token for a specified connection.
* It first checks if a session exists, either from the provided request or from cookies.
* If no session is found, it throws a `AccessTokenForConnectionError` indicating
* that the user does not have an active session.
*
* @param {AccessTokenForConnectionOptions} options - Options for retrieving an access token for a connection.
* @param {PagesRouterRequest | NextRequest} [req] - An optional request object from which to extract session information.
* @param {PagesRouterResponse | NextResponse} [res] - An optional response object from which to extract session information.
*
* @throws {AccessTokenForConnectionError} If the user does not have an active session.
* @throws {Error} If there is an error during the token exchange process.
*
* @returns {Promise<{ token: string; expiresAt: number; scope?: string }} An object containing the access token and its expiration time.
*/
async getAccessTokenForConnection(options, req, res) {
const session = req
? await this.getSession(req)
: await this.getSession();
if (!session) {
throw new AccessTokenForConnectionError(AccessTokenForConnectionErrorCode.MISSING_SESSION, "The user does not have an active session.");
}
// Find the connection token set in the session
const existingTokenSet = session.connectionTokenSets?.find((tokenSet) => tokenSet.connection === options.connection);
const [error, retrievedTokenSet] = await this.authClient.getConnectionTokenSet(session.tokenSet, existingTokenSet, options);
if (error !== null) {
throw error;
}
// If we didnt have a corresponding connection token set in the session
// or if the one we have in the session does not match the one we received
// We want to update the store incase we retrieved a token set.
if (retrievedTokenSet &&
(!existingTokenSet ||
retrievedTokenSet.accessToken !== existingTokenSet.accessToken ||
retrievedTokenSet.expiresAt !== existingTokenSet.expiresAt ||
retrievedTokenSet.scope !== existingTokenSet.scope)) {
let tokenSets;
// If we already had the connection token set in the session
// we need to update the item in the array
// If not, we need to add it.
if (existingTokenSet) {
tokenSets = session.connectionTokenSets?.map((tokenSet) => tokenSet.connection === options.connection
? retrievedTokenSet
: tokenSet);
}
else {
tokenSets = [...(session.connectionTokenSets || []), retrievedTokenSet];
}
await this.saveToSession({
...session,
connectionTokenSets: tokenSets
}, req, res);
}
return {
token: retrievedTokenSet.accessToken,
scope: retrievedTokenSet.scope,
expiresAt: retrievedTokenSet.expiresAt
};
}
/**
* updateSession updates the session of the currently authenticated user. If the user does not have a session, an error is thrown.
*/
async updateSession(reqOrSession, res, sessionData) {
if (!res) {
// app router: Server Actions, Route Handlers
const existingSession = await this.getSession();
if (!existingSession) {
throw new Error("The user is not authenticated.");
}
const updatedSession = reqOrSession;
if (!updatedSession) {
throw new Error("The session data is missing.");
}
await this.sessionStore.set(await cookies(), await cookies(), {
...updatedSession,
internal: {
...existingSession.internal
}
});
}
else {
const req = reqOrSession;
if (!sessionData) {
throw new Error("The session data is missing.");
}
if (req instanceof NextRequest && res instanceof NextResponse) {
// middleware usage
const existingSession = await this.getSession(req);
if (!existingSession) {
throw new Error("The user is not authenticated.");
}
await this.sessionStore.set(req.cookies, res.cookies, {
...sessionData,
internal: {
...existingSession.internal
}
});
}
else {
// pages router usage
const existingSession = await this.getSession(req);
if (!existingSession) {
throw new Error("The user is not authenticated.");
}
const resHeaders = new Headers();
const resCookies = new ResponseCookies(resHeaders);
const updatedSession = sessionData;
const reqCookies = this.createRequestCookies(req);
const pagesRouterRes = res;
await this.sessionStore.set(reqCookies, resCookies, {
...updatedSession,
internal: {
...existingSession.internal
}
});
for (const [key, value] of resHeaders.entries()) {
pagesRouterRes.setHeader(key, value);
}
}
}
}
createRequestCookies(req) {
const headers = new Headers();
for (const key in req.headers) {
if (Array.isArray(req.headers[key])) {
for (const value of req.headers[key]) {
headers.append(key, value);
}
}
else {
headers.append(key, req.headers[key] ?? "");
}
}
return new RequestCookies(headers);
}
async startInteractiveLogin(options = {}) {
return this.authClient.startInteractiveLogin(options);
}
/**
* Authenticates using Client-Initiated Backchannel Authentication and returns the token set and optionally the ID token claims and authorization details.
*
* This method will initialize the backchannel authentication process with Auth0, and poll the token endpoint until the authentication is complete.
*
* Using Client-Initiated Backchannel Authentication requires the feature to be enabled in the Auth0 dashboard.
* @see https://auth0.com/docs/get-started/authentication-and-authorization-flow/client-initiated-backchannel-authentication-flow
*/
async getTokenByBackchannelAuth(options) {
const [error, response] = await this.authClient.backchannelAuthentication(options);
if (error) {
throw error;
}
return response;
}
withPageAuthRequired(fnOrOpts, opts) {
const config = {
loginUrl: this.routes.login
};
const appRouteHandler = appRouteHandlerFactory(this, config);
const pageRouteHandler = pageRouteHandlerFactory(this, config);
if (typeof fnOrOpts === "function") {
return appRouteHandler(fnOrOpts, opts);
}
return pageRouteHandler(fnOrOpts);
}
withApiAuthRequired(apiRoute) {
const pageRouteHandler = withApiAuthRequired.pageRouteHandlerFactory(this);
const appRouteHandler = withApiAuthRequired.appRouteHandlerFactory(this);
return (req, resOrParams) => {
if (isRequest(req)) {
return appRouteHandler(apiRoute)(req, resOrParams);
}
return pageRouteHandler(apiRoute)(req, resOrParams);
};
}
async saveToSession(data, req, res) {
if (req && res) {
if (req instanceof NextRequest && res instanceof NextResponse) {
// middleware usage
await this.sessionStore.set(req.cookies, res.cookies, data);
}
else {
// pages router usage
const resHeaders = new Headers();
const resCookies = new ResponseCookies(resHeaders);
const pagesRouterRes = res;
await this.sessionStore.set(this.createRequestCookies(req), resCookies, data);
for (const [key, value] of resHeaders.entries()) {
pagesRouterRes.setHeader(key, value);
}
}
}
else {
// app router usage: Server Components, Server Actions, Route Handlers
try {
await this.sessionStore.set(await cookies(), await cookies(), data);
}
catch (e) {
if (process.env.NODE_ENV === "development") {
console.warn("Failed to persist the updated token set. `getAccessToken()` was likely called from a Server Component which cannot set cookies.");
}
}
}
}
/**
* Validates and extracts required configuration options.
* @param options The client options
* @returns The validated required options
* @throws ConfigurationError if any required option is missing
*/
validateAndExtractRequiredOptions(options) {
// Base required options that are always needed
const requiredOptions = {
domain: options.domain ?? process.env.AUTH0_DOMAIN,
clientId: options.clientId ?? process.env.AUTH0_CLIENT_ID,
appBaseUrl: options.appBaseUrl ?? process.env.APP_BASE_URL,
secret: options.secret ?? process.env.AUTH0_SECRET
};
// Check client authentication options - either clientSecret OR clientAssertionSigningKey must be provided
const clientSecret = options.clientSecret ?? process.env.AUTH0_CLIENT_SECRET;
const clientAssertionSigningKey = options.clientAssertionSigningKey ??
process.env.AUTH0_CLIENT_ASSERTION_SIGNING_KEY;
const hasClientAuthentication = !!(clientSecret || clientAssertionSigningKey);
const missing = Object.entries(requiredOptions)
.filter(([, value]) => !value)
.map(([key]) => key);
// Add client authentication error if neither option is provided
if (!hasClientAuthentication) {
missing.push("clientAuthentication");
}
if (missing.length) {
// Map of option keys to their exact environment variable names
const envVarNames = {
domain: "AUTH0_DOMAIN",
clientId: "AUTH0_CLIENT_ID",
appBaseUrl: "APP_BASE_URL",
secret: "AUTH0_SECRET"
};
// Standard intro message explaining the issue
let errorMessage = "WARNING: Not all required options were provided when creating an instance of Auth0Client. Ensure to provide all missing options, either by passing it to the Auth0Client constructor, or by setting the corresponding environment variable.\n";
// Add specific details for each missing option
missing.forEach((key) => {
if (key === "clientAuthentication") {
errorMessage += `Missing: clientAuthentication: Set either AUTH0_CLIENT_SECRET env var or AUTH0_CLIENT_ASSERTION_SIGNING_KEY env var, or pass clientSecret or clientAssertionSigningKey in options\n`;
}
else if (envVarNames[key]) {
errorMessage += `Missing: ${key}: Set ${envVarNames[key]} env var or pass ${key} in options\n`;
}
else {
errorMessage += `Missing: ${key}\n`;
}
});
console.error(errorMessage.trim());
}
// Prepare the result object with all validated options
const result = {
...requiredOptions,
clientSecret,
clientAssertionSigningKey
};
// Type-safe assignment after validation
return result;
}
}