UNPKG

@auth0/nextjs-auth0

Version:
815 lines (814 loc) 38.7 kB
import { NextResponse } from "next/server.js"; import * as jose from "jose"; import * as oauth from "oauth4webapi"; import packageJson from "../../package.json" with { type: "json" }; import { AccessTokenError, AccessTokenErrorCode, AccessTokenForConnectionError, AccessTokenForConnectionErrorCode, AuthorizationCodeGrantError, AuthorizationCodeGrantRequestError, AuthorizationError, BackchannelLogoutError, DiscoveryError, InvalidStateError, MissingStateError, OAuth2Error } from "../errors/index.js"; import { ensureNoLeadingSlash, ensureTrailingSlash, normalizeWithBasePath, removeTrailingSlash } from "../utils/pathUtils.js"; import { toSafeRedirect } from "../utils/url-helpers.js"; import { addCacheControlHeadersForSession } from "./cookies.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" ]; const DEFAULT_SCOPES = ["openid", "profile", "email", "offline_access"].join(" "); /** * 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"; /** * Constant representing the subject type for a refresh token. * This is used in OAuth 2.0 token exchange to specify that the token being exchanged is a refresh token. * * @see {@link https://tools.ietf.org/html/rfc8693#section-3.1 RFC 8693 Section 3.1} */ const SUBJECT_TYPE_REFRESH_TOKEN = "urn:ietf:params:oauth:token-type:refresh_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) { // dependencies this.fetch = options.fetch || fetch; this.jwksCache = options.jwksCache || {}; this.allowInsecureRequests = options.allowInsecureRequests ?? false; this.httpOptions = () => { const headers = new Headers(); const enableTelemetry = options.enableTelemetry ?? true; const timeout = options.httpTimeout ?? 5000; 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(timeout), 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 }; 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"; if (!this.authorizationParameters.scope) { this.authorizationParameters.scope = DEFAULT_SCOPES; } const scope = this.authorizationParameters.scope .split(" ") .map((s) => s.trim()); if (!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; // 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; } 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 { // 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 registed 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 const authorizationParams = new URLSearchParams(); authorizationParams.set("client_id", this.clientMetadata.client_id); authorizationParams.set("redirect_uri", redirectUri.toString()); authorizationParams.set("response_type", "code"); authorizationParams.set("code_challenge", codeChallenge); authorizationParams.set("code_challenge_method", codeChallengeMethod); authorizationParams.set("state", state); authorizationParams.set("nonce", nonce); const mergedAuthorizationParams = { // any custom params to forward to /authorize defined as configuration ...this.authorizationParameters, // custom parameters passed in via the query params to ensure only the confidential client can set them ...options.authorizationParameters }; Object.entries(mergedAuthorizationParams).forEach(([key, val]) => { if (!INTERNAL_AUTHORIZE_PARAMS.includes(key) && val != null) { authorizationParams.set(key, String(val)); } }); // Prepare transaction state const transactionState = { nonce, maxAge: this.authorizationParameters.max_age, codeVerifier, responseType: "code", state, returnTo }; // Generate authorization URL with PAR handling const [error, authorizationUrl] = await this.authorizationUrl(authorizationParams); if (error) { return new NextResponse("An error occured 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()); const options = { // SECURITY CRITICAL: Only forward query params when PAR is disabled authorizationParameters: !this.pushedAuthorizationRequests ? searchParams : {}, returnTo: searchParams.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 occured 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 createV2LogoutResponse = () => { const url = new URL("/v2/logout", this.issuer); url.searchParams.set("returnTo", returnTo); url.searchParams.set("client_id", this.clientMetadata.client_id); 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 (session?.tokenSet.idToken) { url.searchParams.set("id_token_hint", session.tokenSet.idToken); } 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 = { returnTo: transactionState.returnTo }; 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; try { const redirectUri = createRouteUrl(this.routes.callback, this.appBaseUrl); // must be registed with the authorization server codeGrantResponse = await oauth.authorizationCodeGrantRequest(authorizationServerMetadata, this.clientMetadata, await this.getClientAuth(), codeGrantParams, redirectUri.toString(), transactionState.codeVerifier, { ...this.httpOptions(), [oauth.customFetch]: this.fetch, [oauth.allowInsecureRequests]: this.allowInsecureRequests }); } catch (e) { return this.handleCallbackError(new AuthorizationCodeGrantRequestError(e.message), onCallbackCtx, req, state); } let oidcRes; try { 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, 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); 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.tokenSet); 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 }); if (updatedTokenSet.accessToken !== session.tokenSet.accessToken || updatedTokenSet.expiresAt !== session.tokenSet.expiresAt || updatedTokenSet.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.finalizeSession(session, updatedTokenSet.idToken); await this.sessionStore.set(req.cookies, res.cookies, { ...finalSession, tokenSet: updatedTokenSet }); 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 }); } /** * 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(tokenSet, forceRefresh) { // the access token has expired but we do not have a refresh token if (!tokenSet.refreshToken && 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 (forceRefresh || tokenSet.expiresAt <= Date.now() / 1000) { const [discoveryError, authorizationServerMetadata] = await this.discoverAuthorizationServerMetadata(); if (discoveryError) { return [discoveryError, null]; } const refreshTokenRes = await oauth.refreshTokenGrantRequest(authorizationServerMetadata, this.clientMetadata, await this.getClientAuth(), tokenSet.refreshToken, { ...this.httpOptions(), [oauth.customFetch]: this.fetch, [oauth.allowInsecureRequests]: this.allowInsecureRequests }); let oauthRes; try { oauthRes = await oauth.processRefreshTokenResponse(authorizationServerMetadata, this.clientMetadata, refreshTokenRes); } 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, expiresAt: accessTokenExpiresAt }; 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, idTokenClaims: undefined }]; } 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 occured 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 occured 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.clientSecret); } get issuer() { return this.domain.startsWith("http://") || this.domain.startsWith("https://") ? this.domain : `https://${this.domain}`; } /** * Exchanges a refresh token for an access token for a connection. * * This method performs a token exchange using the provided refresh token and connection details. * It first checks if the refresh token is present in the `tokenSet`. If not, it returns an error. * Then, it constructs the necessary parameters for the token exchange request and performs * the request to the authorization server's token endpoint. * * @returns {Promise<[AccessTokenForConnectionError, null] | [null, ConnectionTokenSet]>} A promise that resolves to a tuple. * The first element is either an `AccessTokenForConnectionError` if an error occurred, or `null` if the request was successful. * The second element is either `null` if an error occurred, or a `ConnectionTokenSet` object * containing the access token, expiration time, and scope if the request was successful. * * @throws {AccessTokenForConnectionError} If the refresh token is missing or if there is an error during the token exchange process. */ async getConnectionTokenSet(tokenSet, connectionTokenSet, options) { // If we do not have a refresh token // and we do not have a connection token set in the cache or the one we have is expired, // there is noting to retrieve and we return an error. if (!tokenSet.refreshToken && (!connectionTokenSet || connectionTokenSet.expiresAt <= Date.now() / 1000)) { return [ new AccessTokenForConnectionError(AccessTokenForConnectionErrorCode.MISSING_REFRESH_TOKEN, "A refresh token was not present, Connection Access Token requires a refresh token. The user needs to re-authenticate."), null ]; } // If we do have a refresh token, // and we do not have a connection token set in the cache or the one we have is expired, // we need to exchange the refresh token for a connection access token. if (tokenSet.refreshToken && (!connectionTokenSet || connectionTokenSet.expiresAt <= Date.now() / 1000)) { const params = new URLSearchParams(); params.append("connection", options.connection); params.append("subject_token_type", SUBJECT_TYPE_REFRESH_TOKEN); params.append("subject_token", tokenSet.refreshToken); params.append("requested_token_type", REQUESTED_TOKEN_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN); if (options.login_hint) { params.append("login_hint", options.login_hint); } const [discoveryError, authorizationServerMetadata] = await this.discoverAuthorizationServerMetadata(); if (discoveryError) { return [discoveryError, null]; } const httpResponse = await oauth.genericTokenEndpointRequest(authorizationServerMetadata, this.clientMetadata, await this.getClientAuth(), GRANT_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN, params, { [oauth.customFetch]: this.fetch, [oauth.allowInsecureRequests]: this.allowInsecureRequests }); let tokenEndpointResponse; try { tokenEndpointResponse = await oauth.processGenericTokenEndpointResponse(authorizationServerMetadata, this.clientMetadata, httpResponse); } catch (err) { return [ new AccessTokenForConnectionError(AccessTokenForConnectionErrorCode.FAILED_TO_EXCHANGE, "There was an error trying to exchange the refresh token for a connection access token.", new OAuth2Error({ code: err.error, message: err.error_description })), null ]; } return [ null, { accessToken: tokenEndpointResponse.access_token, expiresAt: Math.floor(Date.now() / 1000) + Number(tokenEndpointResponse.expires_in), scope: tokenEndpointResponse.scope, connection: options.connection } ]; } return [null, connectionTokenSet]; } /** * Filters and processes ID token claims for a session. * * If a `beforeSessionSaved` callback is configured, it will be invoked to allow * custom processing of the session and ID token. Otherwise, default filtering * will be applied to remove standard ID token claims from the user object. */ async finalizeSession(session, idToken) { if (this.beforeSessionSaved) { const updatedSession = await this.beforeSessionSaved(session, idToken ?? null); session = { ...updatedSession, internal: session.internal }; } else { session.user = filterDefaultIdTokenClaims(session.user); } return session; } } const encodeBase64 = (input) => { const unencoded = new TextEncoder().encode(input); const CHUNK_SIZE = 0x8000; const arr = []; for (let i = 0; i < unencoded.length; i += CHUNK_SIZE) { arr.push( // @ts-expect-error Argument of type 'Uint8Array' is not assignable to parameter of type 'number[]'. String.fromCharCode.apply(null, unencoded.subarray(i, i + CHUNK_SIZE))); } return btoa(arr.join("")); };