UNPKG

@wristband/nextjs-auth

Version:

SDK for integrating your Next.js application with Wristband. Handles user authentication, session management, and token management.

277 lines (276 loc) 16.8 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.AppRouterAuthHandler = void 0; const server_1 = require("next/server"); const error_1 = require("../../error"); const session_1 = require("../../session"); const app_router_utils_1 = require("../../utils/auth/app-router-utils"); const common_utils_1 = require("../../utils/auth/common-utils"); const crypto_1 = require("../../utils/crypto"); const constants_1 = require("../../utils/constants"); class AppRouterAuthHandler { constructor(configResolver, wristbandService) { this.configResolver = configResolver; this.wristbandService = wristbandService; } async login(request, loginConfig = {}) { // Fetch our SDK configs const clientId = this.configResolver.getClientId(); const customApplicationLoginPageUrl = await this.configResolver.getCustomApplicationLoginPageUrl(); const dangerouslyDisableSecureCookies = this.configResolver.getDangerouslyDisableSecureCookies(); const isApplicationCustomDomainActive = await this.configResolver.getIsApplicationCustomDomainActive(); const loginStateSecret = this.configResolver.getLoginStateSecret(); const parseTenantFromRootDomain = await this.configResolver.getParseTenantFromRootDomain(); const redirectUri = await this.configResolver.getRedirectUri(); const scopes = this.configResolver.getScopes(); const wristbandApplicationVanityDomain = this.configResolver.getWristbandApplicationVanityDomain(); // 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)(request); const tenantName = (0, app_router_utils_1.resolveTenantName)(request, parseTenantFromRootDomain); const defaultTenantCustomDomain = loginConfig.defaultTenantCustomDomain || ''; const defaultTenantName = loginConfig.defaultTenantName || ''; // In the event we cannot determine either a tenant custom domain or subdomain, send the user to app-level login. if (!tenantCustomDomain && !tenantName && !defaultTenantCustomDomain && !defaultTenantName) { const apploginUrl = customApplicationLoginPageUrl || `https://${wristbandApplicationVanityDomain}/login`; return server_1.NextResponse.redirect(`${apploginUrl}?client_id=${clientId}`, constants_1.REDIRECT_RESPONSE_INIT); } // 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)(request, redirectUri, { customState, returnUrl: loginConfig.returnUrl, }); // Create the Wristband Authorize Endpoint URL which the user will get redirectd to. const authorizeUrl = await (0, app_router_utils_1.getAuthorizeUrl)(request, { wristbandApplicationVanityDomain, isApplicationCustomDomainActive, clientId, redirectUri, state: loginState.state, codeVerifier: loginState.codeVerifier, scopes, tenantCustomDomain, tenantName, defaultTenantName, defaultTenantCustomDomain, }); // Prepare a response object for cookies and redirect const response = server_1.NextResponse.redirect(authorizeUrl, constants_1.REDIRECT_RESPONSE_INIT); // Clear any stale login state cookies and add a new one for the current request. const encryptedLoginState = await (0, crypto_1.encryptLoginState)(loginState, loginStateSecret); (0, app_router_utils_1.createLoginStateCookie)(request, response, loginState.state, encryptedLoginState, dangerouslyDisableSecureCookies); // Perform the redirect to Wristband's Authorize Endpoint. return response; } async callback(request) { // Fetch our SDK configs const loginStateSecret = this.configResolver.getLoginStateSecret(); const loginUrl = await this.configResolver.getLoginUrl(); const parseTenantFromRootDomain = await this.configResolver.getParseTenantFromRootDomain(); const tokenExpirationBuffer = this.configResolver.getTokenExpirationBuffer(); const codeArray = request.nextUrl.searchParams.getAll('code'); const paramStateArray = request.nextUrl.searchParams.getAll('state'); const errorArray = request.nextUrl.searchParams.getAll('error'); const errorDescriptionArray = request.nextUrl.searchParams.getAll('error_description'); const tenantCustomDomainParamArray = request.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 name const resolvedTenantName = (0, app_router_utils_1.resolveTenantName)(request, parseTenantFromRootDomain); if (!resolvedTenantName) { throw new error_1.WristbandError(parseTenantFromRootDomain ? 'missing_tenant_subdomain' : 'missing_tenant_name', parseTenantFromRootDomain ? 'Callback request URL is missing a tenant subdomain' : 'Callback request is missing the [tenant_name] query parameter from Wristband'); } // Construct the tenant login URL in the event we have to redirect to the login endpoint let tenantLoginUrl = parseTenantFromRootDomain ? loginUrl.replace(constants_1.TENANT_PLACEHOLDER_REGEX, resolvedTenantName) : `${loginUrl}?tenant_name=${resolvedTenantName}`; if (tenantCustomDomainParam) { tenantLoginUrl = `${tenantLoginUrl}${parseTenantFromRootDomain ? '?' : '&'}tenant_custom_domain=${tenantCustomDomainParam}`; } // 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)(request); if (!loginStateCookie) { return { type: 'redirect_required', redirectUrl: tenantLoginUrl, reason: 'missing_login_state' }; } const loginState = await (0, crypto_1.decryptLoginState)(loginStateCookie.value, loginStateSecret); const { codeVerifier, customState, redirectUri, returnUrl, state: cookieState } = loginState; // Check for any potential error conditions if (paramState !== cookieState) { return { type: 'redirect_required', redirectUrl: tenantLoginUrl, reason: 'invalid_login_state' }; } if (error) { if (error.toLowerCase() === constants_1.LOGIN_REQUIRED_ERROR) { return { type: 'redirect_required', redirectUrl: tenantLoginUrl, reason: 'login_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'); } let tokenResponse; try { tokenResponse = await this.wristbandService.getTokens(code, redirectUri, codeVerifier); } catch (err) { if (err instanceof error_1.InvalidGrantError) { return { type: 'redirect_required', redirectUrl: tenantLoginUrl, reason: 'invalid_grant' }; } throw new error_1.WristbandError('unexpected_error', 'Unexpected error', err instanceof Error ? err : undefined); } 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 resolvedExpiresIn = expiresIn - (tokenExpirationBuffer || 0); const resolvedExpiresAt = Date.now() + resolvedExpiresIn * 1000; const callbackData = { accessToken, ...(!!customState && { customState }), expiresAt: resolvedExpiresAt, expiresIn: resolvedExpiresIn, idToken, ...(!!refreshToken && { refreshToken }), ...(!!returnUrl && { returnUrl }), ...(!!tenantCustomDomainParam && { tenantCustomDomain: tenantCustomDomainParam }), tenantName: resolvedTenantName, userinfo, }; return { type: 'completed', callbackData }; } async logout(request, logoutConfig = {}) { // Fetch our SDK configs const clientId = this.configResolver.getClientId(); const customApplicationLoginPageUrl = await this.configResolver.getCustomApplicationLoginPageUrl(); const isApplicationCustomDomainActive = await this.configResolver.getIsApplicationCustomDomainActive(); const parseTenantFromRootDomain = await this.configResolver.getParseTenantFromRootDomain(); const wristbandApplicationVanityDomain = this.configResolver.getWristbandApplicationVanityDomain(); // 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`); } } if (logoutConfig.state && logoutConfig.state.length > 512) { throw new TypeError('The [state] logout config cannot exceed 512 characters.'); } // The client ID is always required by the Wristband Logout Endpoint. const logoutRedirectUrl = logoutConfig.redirectUrl ? `&redirect_url=${logoutConfig.redirectUrl}` : ''; const state = logoutConfig.state ? `&state=${logoutConfig.state}` : ''; const logoutPath = `/api/v1/logout?client_id=${clientId}${logoutRedirectUrl}${state}`; const separator = isApplicationCustomDomainActive ? '.' : '-'; const tenantCustomDomainParam = (0, app_router_utils_1.resolveTenantCustomDomainParam)(request); const tenantName = (0, app_router_utils_1.resolveTenantName)(request, parseTenantFromRootDomain); // Domain priority order resolution: // 1) If the LogoutConfig has a tenant custom domain explicitly defined, use that. if (logoutConfig.tenantCustomDomain) { return server_1.NextResponse.redirect(`https://${logoutConfig.tenantCustomDomain}${logoutPath}`, constants_1.REDIRECT_RESPONSE_INIT); } // 2) If the LogoutConfig has a tenant name defined, then use that. if (logoutConfig.tenantName) { return server_1.NextResponse.redirect(`https://${logoutConfig.tenantName}${separator}${wristbandApplicationVanityDomain}${logoutPath}`, constants_1.REDIRECT_RESPONSE_INIT); } // 3) If the tenant_custom_domain query param exists, then use that. if (tenantCustomDomainParam) { return server_1.NextResponse.redirect(`https://${tenantCustomDomainParam}${logoutPath}`, constants_1.REDIRECT_RESPONSE_INIT); } // 4a) If tenant subdomains are enabled, get the tenant domain from the host. // 4b) Otherwise, if tenant subdomains are not enabled, then look for it in the tenant_name query param. if (tenantName) { return server_1.NextResponse.redirect(`https://${tenantName}${separator}${wristbandApplicationVanityDomain}${logoutPath}`, constants_1.REDIRECT_RESPONSE_INIT); } // Fallback to the appropriate Application-Level Login or Redirect URL if tenant cannot be resolved. const appLoginUrl = customApplicationLoginPageUrl || `https://${wristbandApplicationVanityDomain}/login`; return server_1.NextResponse.redirect(logoutConfig.redirectUrl || `${appLoginUrl}?client_id=${clientId}`, constants_1.REDIRECT_RESPONSE_INIT); } async createCallbackResponse(request, redirectUrl) { // Fetch our SDK configs const dangerouslyDisableSecureCookies = this.configResolver.getDangerouslyDisableSecureCookies(); if (!redirectUrl) { throw new TypeError('redirectUrl cannot be null or empty'); } const redirectResponse = server_1.NextResponse.redirect(redirectUrl, constants_1.REDIRECT_RESPONSE_INIT); const loginStateCookie = (0, app_router_utils_1.getLoginStateCookie)(request); if (loginStateCookie) { await (0, app_router_utils_1.clearLoginStateCookie)(redirectResponse, loginStateCookie.name, dangerouslyDisableSecureCookies); } return redirectResponse; } /** * Validates authentication for Server Actions by checking session validity and refreshing tokens if needed. * * This method: * - Retrieves the session from cookies * - Checks if the user is authenticated * - Automatically refreshes expired access tokens * - Saves updated tokens back to cookies * * Note: CSRF validation is not performed as Next.js Server Actions have built-in * CSRF protection via Origin/Host header comparison. * * @template T - Session data type extending SessionData * @param cookieStore - Next.js cookie store from await cookies() * @param sessionOptions - Session configuration options * @returns Promise resolving to authentication result with session data or failure reason */ async createServerActionAuth(cookieStore, sessionOptions) { try { // Get mutable session from cookies const session = await (0, session_1.getMutableSessionFromCookies)(cookieStore, sessionOptions); const { expiresAt, isAuthenticated, refreshToken } = session; // Check if user is authenticated if (!isAuthenticated) { return { authenticated: false, reason: 'not_authenticated' }; } // Refresh token if expired if (refreshToken && expiresAt !== undefined) { try { const tokenExpirationBuffer = this.configResolver.getTokenExpirationBuffer(); const newTokenData = await (0, common_utils_1.refreshExpiredToken)(refreshToken, expiresAt, this.wristbandService, tokenExpirationBuffer); if (newTokenData) { // Update session with new tokens session.accessToken = newTokenData.accessToken; session.refreshToken = newTokenData.refreshToken; session.expiresAt = newTokenData.expiresAt; } } catch (error) { return { authenticated: false, reason: 'token_refresh_failed' }; } } // Always save session with or without token refresh ("touch" for rolling session expiration) await (0, session_1.saveSessionWithCookies)(cookieStore, session); // Authentication successful return { authenticated: true, session }; } catch (error) { return { authenticated: false, reason: 'unexpected_error' }; } } } exports.AppRouterAuthHandler = AppRouterAuthHandler;