UNPKG

@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
"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;