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