@auth0/nextjs-auth0
Version:
Auth0 Next.js SDK
996 lines • 71.3 kB
JavaScript
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
};
var _AuthClient_instances, _AuthClient_getTokenSetFromSession;
import { NextResponse } from "next/server.js";
import * as jose from "jose";
import * as oauth from "oauth4webapi";
import * as client from "openid-client";
import packageJson from "../../package.json" with { type: "json" };
import { AccessTokenError, AccessTokenErrorCode, AccessTokenForConnectionError, AccessTokenForConnectionErrorCode, AuthorizationCodeGrantError, AuthorizationCodeGrantRequestError, AuthorizationError, BackchannelAuthenticationError, BackchannelAuthenticationNotSupportedError, BackchannelLogoutError, ConnectAccountError, ConnectAccountErrorCodes, DiscoveryError, DPoPError, DPoPErrorCode, InvalidStateError, MissingStateError, MyAccountApiError, OAuth2Error } from "../errors/index.js";
import { RESPONSE_TYPES, SUBJECT_TOKEN_TYPES } from "../types/index.js";
import { mergeAuthorizationParamsIntoSearchParams } from "../utils/authorization-params-helpers.js";
import { DEFAULT_SCOPES } from "../utils/constants.js";
import { withDPoPNonceRetry } from "../utils/dpopUtils.js";
import { ensureNoLeadingSlash, ensureTrailingSlash, normalizeWithBasePath, removeTrailingSlash } from "../utils/pathUtils.js";
import { ensureDefaultScope, getScopeForAudience } from "../utils/scope-helpers.js";
import { getSessionChangesAfterGetAccessToken } from "../utils/session-changes-helpers.js";
import { compareScopes, findAccessTokenSet, mergeScopes, tokenSetFromAccessTokenSet } from "../utils/token-set-helpers.js";
import { toSafeRedirect } from "../utils/url-helpers.js";
import { addCacheControlHeadersForSession } from "./cookies.js";
import { Fetcher } from "./fetcher.js";
import { filterDefaultIdTokenClaims } from "./user.js";
// params passed to the /authorize endpoint that cannot be overwritten
const INTERNAL_AUTHORIZE_PARAMS = [
"client_id",
"redirect_uri",
"response_type",
"code_challenge",
"code_challenge_method",
"state",
"nonce"
];
/**
* A constant representing the grant type for federated connection access token exchange.
*
* This grant type is used in OAuth token exchange scenarios where a federated connection
* access token is required. It is specific to Auth0's implementation and follows the
* "urn:auth0:params:oauth:grant-type:token-exchange:federated-connection-access-token" format.
*/
const GRANT_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN = "urn:auth0:params:oauth:grant-type:token-exchange:federated-connection-access-token";
/**
* A constant representing the token type for federated connection access tokens.
* This is used to specify the type of token being requested from Auth0.
*
* @constant
* @type {string}
*/
const REQUESTED_TOKEN_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN = "http://auth0.com/oauth/token-type/federated-connection-access-token";
function createRouteUrl(path, baseUrl) {
return new URL(ensureNoLeadingSlash(normalizeWithBasePath(path)), ensureTrailingSlash(baseUrl));
}
export class AuthClient {
constructor(options) {
_AuthClient_instances.add(this);
// dependencies
this.fetch = options.fetch || fetch;
this.jwksCache = options.jwksCache || {};
this.allowInsecureRequests = options.allowInsecureRequests ?? false;
this.httpTimeout = options.httpTimeout ?? 5000;
this.httpOptions = () => {
const headers = new Headers();
const enableTelemetry = options.enableTelemetry ?? true;
if (enableTelemetry) {
const name = "nextjs-auth0";
const version = packageJson.version;
headers.set("User-Agent", `${name}/${version}`);
headers.set("Auth0-Client", encodeBase64(JSON.stringify({
name,
version
})));
}
return {
signal: AbortSignal.timeout(this.httpTimeout),
headers
};
};
if (this.allowInsecureRequests && process.env.NODE_ENV === "production") {
console.warn("allowInsecureRequests is enabled in a production environment. This is not recommended.");
}
// stores
this.transactionStore = options.transactionStore;
this.sessionStore = options.sessionStore;
// authorization server
this.domain = options.domain;
this.clientMetadata = { client_id: options.clientId };
// Apply DPoP timing validation options to client metadata if provided
if (options.dpopOptions) {
if (typeof options.dpopOptions.clockSkew === "number") {
this.clientMetadata[oauth.clockSkew] = options.dpopOptions.clockSkew;
}
if (typeof options.dpopOptions.clockTolerance === "number") {
this.clientMetadata[oauth.clockTolerance] =
options.dpopOptions.clockTolerance;
}
}
// Store dpopOptions for use in retry logic
this.dpopOptions = options.dpopOptions;
this.clientSecret = options.clientSecret;
this.authorizationParameters = options.authorizationParameters || {
scope: DEFAULT_SCOPES
};
this.pushedAuthorizationRequests =
options.pushedAuthorizationRequests ?? false;
this.clientAssertionSigningKey = options.clientAssertionSigningKey;
this.clientAssertionSigningAlg =
options.clientAssertionSigningAlg || "RS256";
this.authorizationParameters.scope = ensureDefaultScope(this.authorizationParameters);
const scope = getScopeForAudience(this.authorizationParameters.scope, this.authorizationParameters.audience)
?.split(" ")
.map((s) => s.trim());
if (!scope || !scope.includes("openid")) {
throw new Error("The 'openid' scope must be included in the set of scopes. See https://auth0.com/docs");
}
// application
this.appBaseUrl = options.appBaseUrl;
this.signInReturnToPath = options.signInReturnToPath || "/";
// validate logout strategy
const validStrategies = ["auto", "oidc", "v2"];
let logoutStrategy = options.logoutStrategy || "auto";
if (!validStrategies.includes(logoutStrategy)) {
console.error(`Invalid logoutStrategy: ${logoutStrategy}. Must be one of: ${validStrategies.join(", ")}. Defaulting to "auto"`);
logoutStrategy = "auto";
}
this.logoutStrategy = logoutStrategy;
this.includeIdTokenHintInOIDCLogoutUrl =
options.includeIdTokenHintInOIDCLogoutUrl ?? true;
// hooks
this.beforeSessionSaved = options.beforeSessionSaved;
this.onCallback = options.onCallback || this.defaultOnCallback;
// routes
this.routes = options.routes;
this.enableAccessTokenEndpoint = options.enableAccessTokenEndpoint ?? true;
this.noContentProfileResponseWhenUnauthenticated =
options.noContentProfileResponseWhenUnauthenticated ?? false;
this.enableConnectAccountEndpoint =
options.enableConnectAccountEndpoint ?? false;
this.useDPoP = options.useDPoP ?? false;
// Initialize DPoP if enabled. Check useDPoP flag first to avoid timing attacks.
if ((options.useDPoP ?? false) && options.dpopKeyPair) {
this.dpopKeyPair = options.dpopKeyPair;
}
}
async handler(req) {
const { pathname } = req.nextUrl;
const sanitizedPathname = removeTrailingSlash(pathname);
const method = req.method;
if (method === "GET" && sanitizedPathname === this.routes.login) {
return this.handleLogin(req);
}
else if (method === "GET" && sanitizedPathname === this.routes.logout) {
return this.handleLogout(req);
}
else if (method === "GET" && sanitizedPathname === this.routes.callback) {
return this.handleCallback(req);
}
else if (method === "GET" && sanitizedPathname === this.routes.profile) {
return this.handleProfile(req);
}
else if (method === "GET" &&
sanitizedPathname === this.routes.accessToken &&
this.enableAccessTokenEndpoint) {
return this.handleAccessToken(req);
}
else if (method === "POST" &&
sanitizedPathname === this.routes.backChannelLogout) {
return this.handleBackChannelLogout(req);
}
else if (method === "GET" &&
sanitizedPathname === this.routes.connectAccount &&
this.enableConnectAccountEndpoint) {
return this.handleConnectAccount(req);
}
else {
// no auth handler found, simply touch the sessions
// TODO: this should only happen if rolling sessions are enabled. Also, we should
// try to avoid reading from the DB (for stateful sessions) on every request if possible.
const res = NextResponse.next();
const session = await this.sessionStore.get(req.cookies);
if (session) {
// we pass the existing session (containing an `createdAt` timestamp) to the set method
// which will update the cookie's `maxAge` property based on the `createdAt` time
await this.sessionStore.set(req.cookies, res.cookies, {
...session
});
addCacheControlHeadersForSession(res);
}
return res;
}
}
async startInteractiveLogin(options = {}) {
const redirectUri = createRouteUrl(this.routes.callback, this.appBaseUrl); // must be registered with the authorization server
let returnTo = this.signInReturnToPath;
// Validate returnTo parameter
if (options.returnTo) {
const safeBaseUrl = new URL(this.authorizationParameters.redirect_uri ||
this.appBaseUrl);
const sanitizedReturnTo = toSafeRedirect(options.returnTo, safeBaseUrl);
if (sanitizedReturnTo) {
returnTo =
sanitizedReturnTo.pathname +
sanitizedReturnTo.search +
sanitizedReturnTo.hash;
}
}
// Generate PKCE challenges
const codeChallengeMethod = "S256";
const codeVerifier = oauth.generateRandomCodeVerifier();
const codeChallenge = await oauth.calculatePKCECodeChallenge(codeVerifier);
const state = oauth.generateRandomState();
const nonce = oauth.generateRandomNonce();
// Construct base authorization parameters
// If provided on both sides, this does not merge the scope property,
// instead, the scope from the right side (options) fully overrides the left side.
// This is done to avoid breaking existing behavior.
const authorizationParams = mergeAuthorizationParamsIntoSearchParams(this.authorizationParameters, options.authorizationParameters, INTERNAL_AUTHORIZE_PARAMS);
authorizationParams.set("client_id", this.clientMetadata.client_id);
authorizationParams.set("redirect_uri", redirectUri.toString());
authorizationParams.set("response_type", RESPONSE_TYPES.CODE);
authorizationParams.set("code_challenge", codeChallenge);
authorizationParams.set("code_challenge_method", codeChallengeMethod);
authorizationParams.set("state", state);
authorizationParams.set("nonce", nonce);
// Add dpop_jkt parameter if DPoP is enabled
if (this.dpopKeyPair) {
try {
const publicKeyJwk = await jose.exportJWK(this.dpopKeyPair.publicKey);
const dpopJkt = await jose.calculateJwkThumbprint(publicKeyJwk);
authorizationParams.set("dpop_jkt", dpopJkt);
}
catch (error) {
throw new DPoPError(DPoPErrorCode.DPOP_JKT_CALCULATION_FAILED, "DPoP is enabled but failed to calculate key thumbprint (dpop_jkt). " +
"This is required for secure DPoP binding. Please check your key configuration.", error instanceof Error ? error : undefined);
}
}
// Prepare transaction state
const transactionState = {
nonce,
maxAge: this.authorizationParameters.max_age,
codeVerifier,
responseType: RESPONSE_TYPES.CODE,
state,
returnTo,
scope: authorizationParams.get("scope") || undefined,
audience: authorizationParams.get("audience") || undefined
};
// Generate authorization URL with PAR handling
const [error, authorizationUrl] = await this.authorizationUrl(authorizationParams);
if (error) {
return new NextResponse("An error occurred while trying to initiate the login request.", {
status: 500
});
}
// Set response and save transaction
const res = NextResponse.redirect(authorizationUrl.toString());
// Save transaction state
await this.transactionStore.save(res.cookies, transactionState);
return res;
}
async handleLogin(req) {
const searchParams = Object.fromEntries(req.nextUrl.searchParams.entries());
// Always forward all parameters
// When PAR is disabled, parameters go to authorization URL as before
// When PAR is enabled, all parameters are sent securely in the PAR request body
// do not pass returnTo as part of authorizationParameters
// returnTo should only be used in txn state
const { returnTo, ...authorizationParameters } = searchParams;
const options = {
authorizationParameters,
returnTo: returnTo
};
return this.startInteractiveLogin(options);
}
async handleLogout(req) {
const session = await this.sessionStore.get(req.cookies);
const [discoveryError, authorizationServerMetadata] = await this.discoverAuthorizationServerMetadata();
if (discoveryError) {
// Clean up session on discovery error
const errorResponse = new NextResponse("An error occurred while trying to initiate the logout request.", {
status: 500
});
await this.sessionStore.delete(req.cookies, errorResponse.cookies);
await this.transactionStore.deleteAll(req.cookies, errorResponse.cookies);
return errorResponse;
}
const returnTo = req.nextUrl.searchParams.get("returnTo") || this.appBaseUrl;
const federated = req.nextUrl.searchParams.has("federated");
const createV2LogoutResponse = () => {
const url = new URL("/v2/logout", this.issuer);
url.searchParams.set("returnTo", returnTo);
url.searchParams.set("client_id", this.clientMetadata.client_id);
if (federated) {
url.searchParams.set("federated", "");
}
return NextResponse.redirect(url);
};
const createOIDCLogoutResponse = () => {
const url = new URL(authorizationServerMetadata.end_session_endpoint);
url.searchParams.set("client_id", this.clientMetadata.client_id);
url.searchParams.set("post_logout_redirect_uri", returnTo);
if (session?.internal.sid) {
url.searchParams.set("logout_hint", session.internal.sid);
}
if (this.includeIdTokenHintInOIDCLogoutUrl && session?.tokenSet.idToken) {
url.searchParams.set("id_token_hint", session.tokenSet.idToken);
}
if (federated) {
url.searchParams.set("federated", "");
}
return NextResponse.redirect(url);
};
// Determine logout strategy and create appropriate response
let logoutResponse;
if (this.logoutStrategy === "v2") {
// Always use v2 logout endpoint
logoutResponse = createV2LogoutResponse();
}
else if (this.logoutStrategy === "oidc") {
// Always use OIDC RP-Initiated Logout
if (!authorizationServerMetadata.end_session_endpoint) {
// Clean up session on OIDC error
const errorResponse = new NextResponse("OIDC RP-Initiated Logout is not supported by the authorization server. Enable it or use a different logout strategy.", {
status: 500
});
await this.sessionStore.delete(req.cookies, errorResponse.cookies);
await this.transactionStore.deleteAll(req.cookies, errorResponse.cookies);
return errorResponse;
}
logoutResponse = createOIDCLogoutResponse();
}
else {
// Auto strategy (default): Try OIDC first, fallback to v2 if not available
if (!authorizationServerMetadata.end_session_endpoint) {
console.warn("The Auth0 client does not have RP-initiated logout enabled, the user will be redirected to the `/v2/logout` endpoint instead. Learn how to enable it here: https://auth0.com/docs/authenticate/login/logout/log-users-out-of-auth0#enable-endpoint-discovery");
logoutResponse = createV2LogoutResponse();
}
else {
logoutResponse = createOIDCLogoutResponse();
}
}
// Clean up session and transaction cookies
await this.sessionStore.delete(req.cookies, logoutResponse.cookies);
addCacheControlHeadersForSession(logoutResponse);
// Clear any orphaned transaction cookies
await this.transactionStore.deleteAll(req.cookies, logoutResponse.cookies);
return logoutResponse;
}
async handleCallback(req) {
const state = req.nextUrl.searchParams.get("state");
if (!state) {
return this.handleCallbackError(new MissingStateError(), {}, req);
}
const transactionStateCookie = await this.transactionStore.get(req.cookies, state);
if (!transactionStateCookie) {
return this.onCallback(new InvalidStateError(), {}, null);
}
const transactionState = transactionStateCookie.payload;
const onCallbackCtx = {
responseType: transactionState.responseType,
returnTo: transactionState.returnTo
};
if (transactionState.responseType === RESPONSE_TYPES.CONNECT_CODE) {
const session = await this.sessionStore.get(req.cookies);
if (!session) {
return this.handleCallbackError(new ConnectAccountError({
code: ConnectAccountErrorCodes.MISSING_SESSION,
message: "The user does not have an active session."
}), onCallbackCtx, req, state);
}
// get an access token for connected accounts
const [tokenSetError, tokenSetResponse] = await this.getTokenSet(session, {
audience: `${this.issuer}/me/`,
scope: "create:me:connected_accounts"
});
if (tokenSetError) {
return this.handleCallbackError(tokenSetError, onCallbackCtx, req, state);
}
const [completeConnectAccountError, connectedAccount] = await this.completeConnectAccount({
tokenSet: tokenSetResponse.tokenSet,
authSession: transactionState.authSession,
connectCode: req.nextUrl.searchParams.get("connect_code"),
redirectUri: createRouteUrl(this.routes.callback, this.appBaseUrl).toString(),
codeVerifier: transactionState.codeVerifier
});
if (completeConnectAccountError) {
return this.handleCallbackError(completeConnectAccountError, onCallbackCtx, req, state);
}
const res = await this.onCallback(null, {
...onCallbackCtx,
connectedAccount
}, session);
await this.transactionStore.delete(res.cookies, state);
return res;
}
const [discoveryError, authorizationServerMetadata] = await this.discoverAuthorizationServerMetadata();
if (discoveryError) {
return this.handleCallbackError(discoveryError, onCallbackCtx, req, state);
}
let codeGrantParams;
try {
codeGrantParams = oauth.validateAuthResponse(authorizationServerMetadata, this.clientMetadata, req.nextUrl.searchParams, transactionState.state);
}
catch (e) {
return this.handleCallbackError(new AuthorizationError({
cause: new OAuth2Error({
code: e.error,
message: e.error_description
})
}), onCallbackCtx, req, state);
}
let codeGrantResponse;
let redirectUri;
let authorizationCodeGrantRequestCall;
try {
redirectUri = createRouteUrl(this.routes.callback, this.appBaseUrl); // must be registered with the authorization server
// Create DPoP handle ONCE outside the closure so it persists across retries.
// This is required by RFC 9449: the handle must learn and reuse the nonce from
// the DPoP-Nonce header across multiple attempts.
const dpopHandle = this.useDPoP && this.dpopKeyPair
? oauth.DPoP(this.clientMetadata, this.dpopKeyPair)
: undefined;
authorizationCodeGrantRequestCall = async () => oauth.authorizationCodeGrantRequest(authorizationServerMetadata, this.clientMetadata, await this.getClientAuth(), codeGrantParams, redirectUri.toString(), transactionState.codeVerifier, {
...this.httpOptions(),
[oauth.customFetch]: this.fetch,
[oauth.allowInsecureRequests]: this.allowInsecureRequests,
...(dpopHandle && {
DPoP: dpopHandle
})
});
// NOTE: Unlike refresh token and connection token flows, the auth code flow
// wraps only the HTTP request (not response processing) in withDPoPNonceRetry().
// This is intentional: withDPoPNonceRetry() expects a Response object to inspect
// for nonce retries. If response processing is included in the wrapper, it returns
// a processed object and retry logic breaks. Response processing happens after
// (see line 807) to maintain compatibility with the retry mechanism.
codeGrantResponse = await withDPoPNonceRetry(authorizationCodeGrantRequestCall, {
isDPoPEnabled: !!dpopHandle,
...this.dpopOptions?.retry
});
}
catch (e) {
return this.handleCallbackError(new AuthorizationCodeGrantRequestError(e.message), onCallbackCtx, req, state);
}
let oidcRes;
try {
// Process the authorization code response
// For authorization code flows, oauth4webapi handles DPoP nonce management internally
// No need for manual retry since authorization codes are single-use
oidcRes = await oauth.processAuthorizationCodeResponse(authorizationServerMetadata, this.clientMetadata, codeGrantResponse, {
expectedNonce: transactionState.nonce,
maxAge: transactionState.maxAge,
requireIdToken: true
});
}
catch (e) {
return this.handleCallbackError(new AuthorizationCodeGrantError({
cause: new OAuth2Error({
code: e.error,
message: e.error_description
})
}), onCallbackCtx, req, state);
}
const idTokenClaims = oauth.getValidatedIdTokenClaims(oidcRes);
let session = {
user: idTokenClaims,
tokenSet: {
accessToken: oidcRes.access_token,
idToken: oidcRes.id_token,
scope: oidcRes.scope,
requestedScope: transactionState.scope,
audience: transactionState.audience,
refreshToken: oidcRes.refresh_token,
expiresAt: Math.floor(Date.now() / 1000) + Number(oidcRes.expires_in)
},
internal: {
sid: idTokenClaims.sid,
createdAt: Math.floor(Date.now() / 1000)
}
};
const res = await this.onCallback(null, onCallbackCtx, session);
// call beforeSessionSaved callback if present
// if not then filter id_token claims with default rules
session = await this.finalizeSession(session, oidcRes.id_token);
await this.sessionStore.set(req.cookies, res.cookies, session, true);
addCacheControlHeadersForSession(res);
// Clean up the current transaction cookie after successful authentication
await this.transactionStore.delete(res.cookies, state);
return res;
}
async handleProfile(req) {
const session = await this.sessionStore.get(req.cookies);
if (!session) {
if (this.noContentProfileResponseWhenUnauthenticated) {
return new NextResponse(null, {
status: 204
});
}
return new NextResponse(null, {
status: 401
});
}
const res = NextResponse.json(session?.user);
addCacheControlHeadersForSession(res);
return res;
}
async handleAccessToken(req) {
const session = await this.sessionStore.get(req.cookies);
const audience = req.nextUrl.searchParams.get("audience");
const scope = req.nextUrl.searchParams.get("scope");
if (!session) {
return NextResponse.json({
error: {
message: "The user does not have an active session.",
code: AccessTokenErrorCode.MISSING_SESSION
}
}, {
status: 401
});
}
const [error, getTokenSetResponse] = await this.getTokenSet(session, {
scope,
audience
});
if (error) {
return NextResponse.json({
error: {
message: error.message,
code: error.code
}
}, {
status: 401
});
}
const { tokenSet: updatedTokenSet, idTokenClaims } = getTokenSetResponse;
const res = NextResponse.json({
token: updatedTokenSet.accessToken,
scope: updatedTokenSet.scope,
expires_at: updatedTokenSet.expiresAt,
...(updatedTokenSet.token_type && {
token_type: updatedTokenSet.token_type
})
});
const sessionChanges = getSessionChangesAfterGetAccessToken(session, updatedTokenSet, {
scope: this.authorizationParameters?.scope,
audience: this.authorizationParameters?.audience
});
if (sessionChanges) {
if (idTokenClaims) {
session.user = idTokenClaims;
}
// call beforeSessionSaved callback if present
// if not then filter id_token claims with default rules
const finalSession = await this.finalizeSession({ ...session, ...sessionChanges }, updatedTokenSet.idToken);
await this.sessionStore.set(req.cookies, res.cookies, finalSession);
addCacheControlHeadersForSession(res);
}
return res;
}
async handleBackChannelLogout(req) {
if (!this.sessionStore.store) {
return new NextResponse("A session data store is not configured.", {
status: 500
});
}
if (!this.sessionStore.store.deleteByLogoutToken) {
return new NextResponse("Back-channel logout is not supported by the session data store.", {
status: 500
});
}
const body = new URLSearchParams(await req.text());
const logoutToken = body.get("logout_token");
if (!logoutToken) {
return new NextResponse("Missing `logout_token` in the request body.", {
status: 400
});
}
const [error, logoutTokenClaims] = await this.verifyLogoutToken(logoutToken);
if (error) {
return new NextResponse(error.message, {
status: 400
});
}
await this.sessionStore.store.deleteByLogoutToken(logoutTokenClaims);
return new NextResponse(null, {
status: 204
});
}
async handleConnectAccount(req) {
const session = await this.sessionStore.get(req.cookies);
// pass all query params except `connection` and `returnTo` as authorization params
const connection = req.nextUrl.searchParams.get("connection");
const returnTo = req.nextUrl.searchParams.get("returnTo") ?? undefined;
const authorizationParams = Object.fromEntries([...req.nextUrl.searchParams.entries()].filter(([key]) => key !== "connection" && key !== "returnTo"));
if (!connection) {
return new NextResponse("A connection is required.", {
status: 400
});
}
if (!session) {
return new NextResponse("The user does not have an active session.", {
status: 401
});
}
const [getTokenSetError, getTokenSetResponse] = await this.getTokenSet(session, {
scope: "create:me:connected_accounts",
audience: `${this.issuer}/me/`
});
if (getTokenSetError) {
return new NextResponse("Failed to retrieve a connected account access token.", {
status: 401
});
}
const { tokenSet, idTokenClaims } = getTokenSetResponse;
const [connectAccountError, connectAccountResponse] = await this.connectAccount({
tokenSet: tokenSet,
connection,
authorizationParams,
returnTo
});
if (connectAccountError) {
return new NextResponse(connectAccountError.message, {
status: connectAccountError.cause?.status ?? 500
});
}
// update the session with the new token set, if necessary
const sessionChanges = getSessionChangesAfterGetAccessToken(session, tokenSet, {
scope: this.authorizationParameters?.scope ?? DEFAULT_SCOPES,
audience: this.authorizationParameters?.audience
});
if (sessionChanges) {
if (idTokenClaims) {
session.user = idTokenClaims;
}
// call beforeSessionSaved callback if present
// if not then filter id_token claims with default rules
const finalSession = await this.finalizeSession({ ...session, ...sessionChanges }, tokenSet.idToken);
await this.sessionStore.set(req.cookies, connectAccountResponse.cookies, finalSession);
addCacheControlHeadersForSession(connectAccountResponse);
}
return connectAccountResponse;
}
/**
* Retrieves OAuth token sets, handling token refresh when necessary or if forced.
*
* @returns A tuple containing either:
* - `[SdkError, null]` if an error occurred (missing refresh token, discovery failure, or refresh failure)
* - `[null, {tokenSet, idTokenClaims}]` if a new token was retrieved, containing the new token set ID token claims
* - `[null, {tokenSet, }]` if token refresh was not done and existing token was returned
*/
async getTokenSet(sessionData, options = {}) {
// This will merge the scopes from the authorization parameters and the options.
// The scope from the options will be added to the scopes from the authorization parameters.
// If there are duplicate scopes, they will be removed.
const scope = mergeScopes(getScopeForAudience(this.authorizationParameters.scope, options.audience ?? this.authorizationParameters.audience), options.scope);
const tokenSet = __classPrivateFieldGet(this, _AuthClient_instances, "m", _AuthClient_getTokenSetFromSession).call(this, sessionData, {
scope: scope,
audience: options.audience ?? this.authorizationParameters.audience
});
// no access token was found that matches the, optional, provided audience and scope
if (!tokenSet.refreshToken && !tokenSet.accessToken) {
return [
new AccessTokenError(AccessTokenErrorCode.MISSING_REFRESH_TOKEN, "No access token found and a refresh token was not provided. The user needs to re-authenticate."),
null
];
}
// the access token was found, but it has expired and we do not have a refresh token
if (!tokenSet.refreshToken &&
tokenSet.accessToken &&
tokenSet.expiresAt &&
tokenSet.expiresAt <= Date.now() / 1000) {
return [
new AccessTokenError(AccessTokenErrorCode.MISSING_REFRESH_TOKEN, "The access token has expired and a refresh token was not provided. The user needs to re-authenticate."),
null
];
}
if (tokenSet.refreshToken) {
// either the access token has expired or we are forcing a refresh
if (options.refresh ||
!tokenSet.expiresAt ||
tokenSet.expiresAt <= Date.now() / 1000) {
const [discoveryError, authorizationServerMetadata] = await this.discoverAuthorizationServerMetadata();
if (discoveryError) {
return [discoveryError, null];
}
const additionalParameters = new URLSearchParams();
if (options.scope) {
additionalParameters.append("scope", scope);
}
if (options.audience) {
additionalParameters.append("audience", options.audience);
}
// Create DPoP handle ONCE outside the closure so it persists across retries.
// This is required by RFC 9449: the handle must learn and reuse the nonce from
// the DPoP-Nonce header across multiple attempts.
const dpopHandle = this.useDPoP && this.dpopKeyPair
? oauth.DPoP(this.clientMetadata, this.dpopKeyPair)
: undefined;
const refreshTokenGrantRequestCall = async () => oauth.refreshTokenGrantRequest(authorizationServerMetadata, this.clientMetadata, await this.getClientAuth(), tokenSet.refreshToken, {
...this.httpOptions(),
[oauth.customFetch]: this.fetch,
[oauth.allowInsecureRequests]: this.allowInsecureRequests,
additionalParameters,
...(dpopHandle && {
DPoP: dpopHandle
})
});
const processRefreshTokenResponseCall = (response) => oauth.processRefreshTokenResponse(authorizationServerMetadata, this.clientMetadata, response);
let oauthRes;
try {
oauthRes = await withDPoPNonceRetry(async () => {
const refreshTokenRes = await refreshTokenGrantRequestCall();
return await processRefreshTokenResponseCall(refreshTokenRes);
}, {
isDPoPEnabled: !!(this.useDPoP && this.dpopKeyPair),
...this.dpopOptions?.retry
});
}
catch (e) {
return [
new AccessTokenError(AccessTokenErrorCode.FAILED_TO_REFRESH_TOKEN, "The access token has expired and there was an error while trying to refresh it.", new OAuth2Error({
code: e.error,
message: e.error_description
})),
null
];
}
const idTokenClaims = oauth.getValidatedIdTokenClaims(oauthRes);
const accessTokenExpiresAt = Math.floor(Date.now() / 1000) + Number(oauthRes.expires_in);
const updatedTokenSet = {
...tokenSet, // contains the existing `iat` claim to maintain the session lifetime
accessToken: oauthRes.access_token,
idToken: oauthRes.id_token,
// We store the both requested and granted scopes on the tokenSet, so we know what scopes were requested.
// The server may return less scopes than requested.
// This ensures we can return the same token again when a token for the same or less scopes is requested by using `requestedScope` during look-up.
//
// E.g. When requesting a token with scope `a b`, and we return one for scope `a` only,
// - If we only store the returned scopes, we cannot return this token when the user requests a token for scope `a b` again.
// - If we only store the requested scopes, we lose track of the actual scopes granted.
//
// Scopes actually granted by the server
scope: oauthRes.scope,
// Scopes requested by the client
requestedScope: scope,
expiresAt: accessTokenExpiresAt,
// Keep the audience if it exists, otherwise use the one from the options.
// If not provided, use `undefined`.
audience: tokenSet.audience || options.audience || undefined,
// Store the token type from the OAuth response (e.g., "Bearer", "DPoP")
...(oauthRes.token_type && { token_type: oauthRes.token_type })
};
if (oauthRes.refresh_token) {
// refresh token rotation is enabled, persist the new refresh token from the response
updatedTokenSet.refreshToken = oauthRes.refresh_token;
}
else {
// we did not get a refresh token back, keep the current long-lived refresh token around
updatedTokenSet.refreshToken = tokenSet.refreshToken;
}
return [
null,
{
tokenSet: updatedTokenSet,
idTokenClaims: idTokenClaims
}
];
}
}
return [null, { tokenSet: tokenSet, idTokenClaims: undefined }];
}
async backchannelAuthentication(options) {
const [discoveryError, authorizationServerMetadata] = await this.discoverAuthorizationServerMetadata();
if (discoveryError) {
return [discoveryError, null];
}
if (!authorizationServerMetadata.backchannel_authentication_endpoint) {
return [new BackchannelAuthenticationNotSupportedError(), null];
}
// If provided on both sides, this does not merge the scope property,
// instead, the scope from the right side (options) fully overrides the left side.
// This is done to avoid breaking existing behavior.
const authorizationParams = mergeAuthorizationParamsIntoSearchParams(this.authorizationParameters, options.authorizationParams, INTERNAL_AUTHORIZE_PARAMS);
if (!authorizationParams.get("scope")) {
authorizationParams.set("scope", DEFAULT_SCOPES);
}
authorizationParams.set("client_id", this.clientMetadata.client_id);
authorizationParams.set("binding_message", options.bindingMessage);
authorizationParams.set("login_hint", JSON.stringify({
format: "iss_sub",
iss: authorizationServerMetadata.issuer,
sub: options.loginHint.sub
}));
if (options.requestedExpiry) {
authorizationParams.append("requested_expiry", options.requestedExpiry.toString());
}
if (options.authorizationDetails) {
authorizationParams.append("authorization_details", JSON.stringify(options.authorizationDetails));
}
const [openIdClientConfigError, openidClientConfig] = await this.getOpenIdClientConfig();
if (openIdClientConfigError) {
return [openIdClientConfigError, null];
}
try {
const backchannelAuthenticationResponse = await client.initiateBackchannelAuthentication(openidClientConfig, authorizationParams);
const tokenEndpointResponse = await client.pollBackchannelAuthenticationGrant(openidClientConfig, backchannelAuthenticationResponse);
const accessTokenExpiresAt = Math.floor(Date.now() / 1000) +
Number(tokenEndpointResponse.expires_in);
return [
null,
{
tokenSet: {
accessToken: tokenEndpointResponse.access_token,
idToken: tokenEndpointResponse.id_token,
scope: tokenEndpointResponse.scope,
refreshToken: tokenEndpointResponse.refresh_token,
expiresAt: accessTokenExpiresAt
},
idTokenClaims: tokenEndpointResponse.claims(),
authorizationDetails: tokenEndpointResponse.authorization_details
}
];
}
catch (e) {
return [
new BackchannelAuthenticationError({
cause: new OAuth2Error({
code: e.error,
message: e.error_description
})
}),
null
];
}
}
async discoverAuthorizationServerMetadata() {
if (this.authorizationServerMetadata) {
return [null, this.authorizationServerMetadata];
}
const issuer = new URL(this.issuer);
try {
const authorizationServerMetadata = await oauth
.discoveryRequest(issuer, {
...this.httpOptions(),
[oauth.customFetch]: this.fetch,
[oauth.allowInsecureRequests]: this.allowInsecureRequests
})
.then((response) => oauth.processDiscoveryResponse(issuer, response));
this.authorizationServerMetadata = authorizationServerMetadata;
return [null, authorizationServerMetadata];
}
catch (e) {
console.error(`An error occurred while performing the discovery request. issuer=${issuer.toString()}, error:`, e);
return [
new DiscoveryError("Discovery failed for the OpenID Connect configuration."),
null
];
}
}
async defaultOnCallback(error, ctx) {
if (error) {
return new NextResponse(error.message, {
status: 500
});
}
const res = NextResponse.redirect(createRouteUrl(ctx.returnTo || "/", this.appBaseUrl));
return res;
}
/**
* Handle callback errors with transaction cleanup
*/
async handleCallbackError(error, ctx, req, state) {
const response = await this.onCallback(error, ctx, null);
// Clean up the transaction cookie on error to prevent accumulation
if (state) {
await this.transactionStore.delete(response.cookies, state);
}
return response;
}
async verifyLogoutToken(logoutToken) {
const [discoveryError, authorizationServerMetadata] = await this.discoverAuthorizationServerMetadata();
if (discoveryError) {
return [discoveryError, null];
}
// only `RS256` is supported for logout tokens
const ID_TOKEN_SIGNING_ALG = "RS256";
const keyInput = jose.createRemoteJWKSet(new URL(authorizationServerMetadata.jwks_uri), {
[jose.jwksCache]: this.jwksCache
});
const { payload } = await jose.jwtVerify(logoutToken, keyInput, {
issuer: authorizationServerMetadata.issuer,
audience: this.clientMetadata.client_id,
algorithms: [ID_TOKEN_SIGNING_ALG],
requiredClaims: ["iat"]
});
if (!("sid" in payload) && !("sub" in payload)) {
return [
new BackchannelLogoutError('either "sid" or "sub" (or both) claims must be present'),
null
];
}
if ("sid" in payload && typeof payload.sid !== "string") {
return [new BackchannelLogoutError('"sid" claim must be a string'), null];
}
if ("sub" in payload && typeof payload.sub !== "string") {
return [new BackchannelLogoutError('"sub" claim must be a string'), null];
}
if ("nonce" in payload) {
return [new BackchannelLogoutError('"nonce" claim is prohibited'), null];
}
if (!("events" in payload)) {
return [new BackchannelLogoutError('"events" claim is missing'), null];
}
if (typeof payload.events !== "object" || payload.events === null) {
return [
new BackchannelLogoutError('"events" claim must be an object'),
null
];
}
if (!("http://schemas.openid.net/event/backchannel-logout" in payload.events)) {
return [
new BackchannelLogoutError('"http://schemas.openid.net/event/backchannel-logout" member is missing in the "events" claim'),
null
];
}
if (typeof payload.events["http://schemas.openid.net/event/backchannel-logout"] !== "object") {
return [
new BackchannelLogoutError('"http://schemas.openid.net/event/backchannel-logout" member in the "events" claim must be an object'),
null
];
}
return [
null,
{
sid: payload.sid,
sub: payload.sub
}
];
}
async authorizationUrl(params) {
const [discoveryError, authorizationServerMetadata] = await this.discoverAuthorizationServerMetadata();
if (discoveryError) {
return [discoveryError, null];
}
if (this.pushedAuthorizationRequests &&
!authorizationServerMetadata.pushed_authorization_request_endpoint) {
console.error("The Auth0 tenant does not have pushed authorization requests enabled. Learn how to enable it here: https://auth0.com/docs/get-started/applications/configure-par");
return [
new Error("The authorization server does not support pushed authorization requests."),
null
];
}
const authorizationUrl = new URL(authorizationServerMetadata.authorization_endpoint);
if (this.pushedAuthorizationRequests) {
// push the request params to the authorization server
const response = await oauth.pushedAuthorizationRequest(authorizationServerMetadata, this.clientMetadata, await this.getClientAuth(), params, {
...this.httpOptions(),
[oauth.customFetch]: this.fetch,
[oauth.allowInsecureRequests]: this.allowInsecureRequests
});
let parRes;
try {
parRes = await oauth.processPushedAuthorizationResponse(authorizationServerMetadata, this.clientMetadata, response);
}
catch (e) {
return [
new AuthorizationError({
cause: new OAuth2Error({
code: e.error,
message: e.error_description
}),
message: "An error occurred while pushing the authorization request."
}),
null
];
}
authorizationUrl.searchParams.set("request_uri", parRes.request_uri);
authorizationUrl.searchParams.set("client_id", this.clientMetadata.client_id);
return [null, authorizationUrl];
}
// append the query parameters to the authorization URL for the normal flow
authorizationUrl.search = params.toString();
return [null, authorizationUrl];
}
async getClientAuth() {
if (!this.clientSecret && !this.clientAssertionSigningKey) {
throw new Error("The client secret or client assertion signing key must be provided.");
}
let clientPrivateKey = this
.clientAssertionSigningKey;
if (clientPrivateKey && typeof clientPrivateKey === "string") {
clientPrivateKey = await jose.importPKCS8(clientPrivateKey, this.clientAssertionSigningAlg);
}
return clientPrivateKey
? oauth.PrivateKeyJwt(clientPrivateKey)
: oauth.ClientSecretPost(this.clientSecr