@wristband/nextjs-auth
Version:
SDK for integrating your NextJS application with Wristband. Handles user authentication and token management.
206 lines (205 loc) • 12.7 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.AppRouterAuthHandler = void 0;
const server_1 = require("next/server");
const types_1 = require("../../types");
const common_utils_1 = require("../../utils/auth/common-utils");
const app_router_utils_1 = require("../../utils/auth/app-router-utils");
const constants_1 = require("../../utils/constants");
const error_1 = require("../../error");
class AppRouterAuthHandler {
constructor(authConfig, wristbandService) {
this.wristbandService = wristbandService;
this.clientId = authConfig.clientId;
this.customApplicationLoginPageUrl = authConfig.customApplicationLoginPageUrl || '';
this.dangerouslyDisableSecureCookies =
typeof authConfig.dangerouslyDisableSecureCookies !== 'undefined'
? authConfig.dangerouslyDisableSecureCookies
: false;
this.loginStateSecret = authConfig.loginStateSecret;
this.loginUrl = authConfig.loginUrl;
this.redirectUri = authConfig.redirectUri;
this.rootDomain = authConfig.rootDomain || '';
this.scopes =
!!authConfig.scopes && !!authConfig.scopes.length ? authConfig.scopes : ['openid', 'offline_access', 'email'];
this.useCustomDomains = typeof authConfig.useCustomDomains !== 'undefined' ? authConfig.useCustomDomains : false;
this.useTenantSubdomains =
typeof authConfig.useTenantSubdomains !== 'undefined' ? authConfig.useTenantSubdomains : false;
this.wristbandApplicationDomain = authConfig.wristbandApplicationDomain;
}
async login(req, loginConfig = {}) {
// Determine if a tenant custom domain is present as it will be needed for the authorize URL, if provided.
const tenantCustomDomain = (0, app_router_utils_1.resolveTenantCustomDomainParam)(req);
const tenantDomainName = (0, app_router_utils_1.resolveTenantDomainName)(req, this.useTenantSubdomains, this.rootDomain);
const defaultTenantCustomDomain = loginConfig.defaultTenantCustomDomain || '';
const defaultTenantDomainName = loginConfig.defaultTenantDomainName || '';
// In the event we cannot determine either a tenant custom domain or subdomain, send the user to app-level login.
if (!tenantCustomDomain && !tenantDomainName && !defaultTenantCustomDomain && !defaultTenantDomainName) {
const apploginUrl = this.customApplicationLoginPageUrl || `https://${this.wristbandApplicationDomain}/login`;
return server_1.NextResponse.redirect(`${apploginUrl}?client_id=${this.clientId}`, {
status: 302,
headers: constants_1.NO_CACHE_HEADERS,
});
}
// Create the login state which will be cached in a cookie so that it can be accessed in the callback.
const customState = !!loginConfig.customState && !!Object.keys(loginConfig.customState).length ? loginConfig.customState : undefined;
const loginState = (0, app_router_utils_1.createLoginState)(req, this.redirectUri, { customState });
// Create the Wristband Authorize Endpoint URL which the user will get redirectd to.
const authorizeUrl = await (0, app_router_utils_1.getAuthorizeUrl)(req, {
wristbandApplicationDomain: this.wristbandApplicationDomain,
useCustomDomains: this.useCustomDomains,
clientId: this.clientId,
redirectUri: this.redirectUri,
state: loginState.state,
codeVerifier: loginState.codeVerifier,
scopes: this.scopes,
tenantCustomDomain,
tenantDomainName,
defaultTenantDomainName,
defaultTenantCustomDomain,
});
// Prepare a response object for cookies and redirect
const res = server_1.NextResponse.redirect(authorizeUrl, { status: 302, headers: constants_1.NO_CACHE_HEADERS });
// Clear any stale login state cookies and add a new one for the current request.
const encryptedLoginState = await (0, common_utils_1.encryptLoginState)(loginState, this.loginStateSecret);
(0, app_router_utils_1.createLoginStateCookie)(req, res, loginState.state, encryptedLoginState, this.dangerouslyDisableSecureCookies);
// Perform the redirect to Wristband's Authorize Endpoint.
return res;
}
async callback(req) {
const codeArray = req.nextUrl.searchParams.getAll('code');
const paramStateArray = req.nextUrl.searchParams.getAll('state');
const errorArray = req.nextUrl.searchParams.getAll('error');
const errorDescriptionArray = req.nextUrl.searchParams.getAll('error_description');
const tenantCustomDomainParamArray = req.nextUrl.searchParams.getAll('tenant_custom_domain');
// Safety checks -- Wristband backend should never send bad query params
if (paramStateArray.length !== 1) {
throw new TypeError('Invalid query parameter [state] passed from Wristband during callback');
}
if (codeArray.length > 1) {
throw new TypeError('Invalid query parameter [code] passed from Wristband during callback');
}
if (errorArray.length > 1) {
throw new TypeError('Invalid query parameter [error] passed from Wristband during callback');
}
if (errorDescriptionArray.length > 1) {
throw new TypeError('Invalid query parameter [error_description] passed from Wristband during callback');
}
if (tenantCustomDomainParamArray.length > 1) {
throw new TypeError('Invalid query parameter [tenant_custom_domain] passed from Wristband during callback');
}
const code = codeArray[0] || '';
const paramState = paramStateArray[0] || '';
const error = errorArray[0] || '';
const errorDescription = errorDescriptionArray[0] || '';
const tenantCustomDomainParam = tenantCustomDomainParamArray[0] || '';
// Resolve and validate the tenant domain name
const resolvedTenantDomainName = (0, app_router_utils_1.resolveTenantDomainName)(req, this.useTenantSubdomains, this.rootDomain);
if (!resolvedTenantDomainName) {
throw new error_1.WristbandError(this.useTenantSubdomains ? 'missing_tenant_subdomain' : 'missing_tenant_domain', this.useTenantSubdomains
? 'Callback request URL is missing a tenant subdomain'
: 'Callback request is missing the [tenant_domain] query parameter from Wristband');
}
// Construct the tenant login URL in the event we have to redirect to the login endpoint
let tenantLoginUrl = this.useTenantSubdomains
? this.loginUrl.replace(constants_1.TENANT_DOMAIN_TOKEN, resolvedTenantDomainName)
: `${this.loginUrl}?tenant_domain=${resolvedTenantDomainName}`;
if (tenantCustomDomainParam) {
tenantLoginUrl = `${tenantLoginUrl}${this.useTenantSubdomains ? '?' : '&'}tenant_custom_domain=${tenantCustomDomainParam}`;
}
const loginRedirectResponse = server_1.NextResponse.redirect(tenantLoginUrl, { status: 302, headers: constants_1.NO_CACHE_HEADERS });
// Make sure the login state cookie exists, extract it, and set it to be cleared by the server.
const loginStateCookie = (0, app_router_utils_1.getLoginStateCookie)(req);
if (!loginStateCookie) {
return { redirectResponse: loginRedirectResponse, result: types_1.CallbackResultType.REDIRECT_REQUIRED };
}
const loginState = await (0, common_utils_1.decryptLoginState)(loginStateCookie.value, this.loginStateSecret);
const { codeVerifier, customState, redirectUri, returnUrl, state: cookieState } = loginState;
// Check for any potential error conditions
if (paramState !== cookieState) {
await (0, app_router_utils_1.clearLoginStateCookie)(loginRedirectResponse, loginStateCookie.name, this.dangerouslyDisableSecureCookies);
return { redirectResponse: loginRedirectResponse, result: types_1.CallbackResultType.REDIRECT_REQUIRED };
}
if (error) {
if (error.toLowerCase() === constants_1.LOGIN_REQUIRED_ERROR) {
await (0, app_router_utils_1.clearLoginStateCookie)(loginRedirectResponse, loginStateCookie.name, this.dangerouslyDisableSecureCookies);
return { redirectResponse: loginRedirectResponse, result: types_1.CallbackResultType.REDIRECT_REQUIRED };
}
throw new error_1.WristbandError(error, errorDescription || '');
}
// Exchange the authorization code for tokens
if (!code) {
throw new TypeError('Invalid query parameter [code] passed from Wristband during callback');
}
const tokenResponse = await this.wristbandService.getTokens(code, redirectUri, codeVerifier);
const { access_token: accessToken, id_token: idToken, refresh_token: refreshToken, expires_in: expiresIn, } = tokenResponse;
// Get a minimal set of the user's data to store in their session data.
// Fetch the userinfo for the user logging in.
const userinfo = await this.wristbandService.getUserinfo(accessToken);
const callbackData = {
accessToken,
...(!!customState && { customState }),
expiresIn,
idToken,
...(!!refreshToken && { refreshToken }),
...(!!returnUrl && { returnUrl }),
...(!!tenantCustomDomainParam && { tenantCustomDomain: tenantCustomDomainParam }),
tenantDomainName: resolvedTenantDomainName,
userinfo,
};
return { result: types_1.CallbackResultType.COMPLETED, callbackData };
}
async logout(req, logoutConfig = {}) {
const host = req.headers.get('host');
// Revoke the refresh token only if present.
if (logoutConfig.refreshToken) {
try {
await this.wristbandService.revokeRefreshToken(logoutConfig.refreshToken);
}
catch (error) {
// No need to block logout execution if revoking fails
console.debug(`Revoking the refresh token failed during logout`);
}
}
const appLoginUrl = this.customApplicationLoginPageUrl || `https://${this.wristbandApplicationDomain}/login`;
if (!logoutConfig.tenantCustomDomain) {
if (this.useTenantSubdomains && host.substring(host.indexOf('.') + 1) !== this.rootDomain) {
return server_1.NextResponse.redirect(logoutConfig.redirectUrl || `${appLoginUrl}?client_id=${this.clientId}`, {
status: 302,
headers: constants_1.NO_CACHE_HEADERS,
});
}
if (!this.useTenantSubdomains && !logoutConfig.tenantDomainName) {
return server_1.NextResponse.redirect(logoutConfig.redirectUrl || `${appLoginUrl}?client_id=${this.clientId}`, {
status: 302,
headers: constants_1.NO_CACHE_HEADERS,
});
}
}
// The client ID is always required by the Wristband Logout Endpoint.
const logoutRedirectUrl = logoutConfig.redirectUrl ? `&redirect_url=${logoutConfig.redirectUrl}` : '';
const query = `client_id=${this.clientId}${logoutRedirectUrl}`;
// Always perform logout redirect to the Wristband logout endpoint.
const tenantDomainName = this.useTenantSubdomains
? host.substring(0, host.indexOf('.'))
: logoutConfig.tenantDomainName;
const separator = this.useCustomDomains ? '.' : '-';
const tenantDomainToUse = logoutConfig.tenantCustomDomain || `${tenantDomainName}${separator}${this.wristbandApplicationDomain}`;
return server_1.NextResponse.redirect(`https://${tenantDomainToUse}/api/v1/logout?${query}`, {
status: 302,
headers: constants_1.NO_CACHE_HEADERS,
});
}
async createCallbackResponse(req, redirectUrl) {
if (!redirectUrl) {
throw new TypeError('redirectUrl cannot be null or empty');
}
const redirectResponse = server_1.NextResponse.redirect(redirectUrl, { status: 302, headers: constants_1.NO_CACHE_HEADERS });
const loginStateCookie = (0, app_router_utils_1.getLoginStateCookie)(req);
if (loginStateCookie) {
await (0, app_router_utils_1.clearLoginStateCookie)(redirectResponse, loginStateCookie.name, this.dangerouslyDisableSecureCookies);
}
return redirectResponse;
}
}
exports.AppRouterAuthHandler = AppRouterAuthHandler;