@wristband/express-auth
Version:
SDK for integrating your ExpressJS application with Wristband. Handles user authentication, session management, and token management.
491 lines (490 loc) • 26.9 kB
JavaScript
import { createWristbandJwtValidator, } from '@wristband/typescript-jwt';
import { LOGIN_REQUIRED_ERROR, MAX_REFRESH_ATTEMPT_DELAY_MS, MAX_REFRESH_ATTEMPTS, TENANT_PLACEHOLDER_REGEX, } from './utils/constants';
import { clearOldestLoginStateCookie, createLoginState, createLoginStateCookie, decryptLoginState, encryptLoginState, getAndClearLoginStateCookie, getOAuthAuthorizeUrl, isExpired, resolveTenantCustomDomainParam, resolveTenantName, } from './utils';
import { WristbandService } from './wristband-service';
import { FetchError, InvalidGrantError, WristbandError } from './error';
import { ConfigResolver } from './config-resolver';
import { isValidCsrf, normalizeAuthMiddlewareConfig, sendAuthFailureResponse } from './utils/middleware';
/**
* Core service class that handles Wristband authentication operations.
* Manages login flows, token exchanges, session validation, and logout functionality.
*/
export class AuthService {
/**
* Creates an instance of AuthService.
*
* @param {AuthConfig} authConfig - Configuration for Wristband authentication.
*/
constructor(authConfig) {
this.configResolver = new ConfigResolver(authConfig);
this.wristbandService = new WristbandService(this.configResolver.getWristbandApplicationVanityDomain(), this.configResolver.getClientId(), this.configResolver.getClientSecret());
}
/**
* Force load all auto-configurable fields to cache them. This will trigger the API call
* and cache the results. Any validation errors will be thrown here (fail-fast).
*
* @returns {Promise<void>} A Promise that resolves when configuration is preloaded.
* @throws {WristbandError} When autoConfigureEnabled is false or auto-configuration fails.
*/
async preloadConfig() {
if (!this.configResolver.getAutoConfigureEnabled()) {
throw new WristbandError('Cannot preload configs when autoConfigureEnabled is false. Use createWristbandAuth() instead.');
}
await this.configResolver.preloadSdkConfig();
}
/**
* Initiates a login request by constructing a redirect URL to Wristband's authorization endpoint.
*
* @param {Request} req - The Express request object.
* @param {Response} res - The Express response object.
* @param {LoginConfig} [config={}] - Optional configuration for the login flow.
* @returns {Promise<string>} A Promise containing the redirect URL to Wristband's Authorize Endpoint.
*/
async login(req, res, config = {}) {
res.header('Cache-Control', 'no-store');
res.header('Pragma', 'no-cache');
// 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 which domain-related values are present as it will be needed for the authorize URL.
const tenantCustomDomain = resolveTenantCustomDomainParam(req);
const tenantName = resolveTenantName(req, parseTenantFromRootDomain);
const defaultTenantCustomDomain = config.defaultTenantCustomDomain || '';
const defaultTenantName = config.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 `${apploginUrl}?client_id=${clientId}`;
}
// Create the login state which will be cached in a cookie so that it can be accessed in the callback.
const customState = !!config.customState && !!Object.keys(config.customState).length ? config.customState : undefined;
const loginState = createLoginState(req, redirectUri, { customState, returnUrl: config.returnUrl });
// Clear any stale login state cookies and add a new one fo rthe current request.
clearOldestLoginStateCookie(req, res, dangerouslyDisableSecureCookies);
const encryptedLoginState = await encryptLoginState(loginState, loginStateSecret);
createLoginStateCookie(res, loginState.state, encryptedLoginState, dangerouslyDisableSecureCookies);
// Return the Wristband Authorize Endpoint URL which the user will get redirectd to.
return getOAuthAuthorizeUrl(req, {
wristbandApplicationVanityDomain,
isApplicationCustomDomainActive,
clientId,
redirectUri,
state: loginState.state,
codeVerifier: loginState.codeVerifier,
scopes,
tenantCustomDomain,
tenantName,
defaultTenantCustomDomain,
defaultTenantName,
});
}
/**
* Handles the OAuth callback from Wristband, exchanging the authorization code for tokens
* and retrieving user information.
*
* @param {Request} req - The Express request object containing query parameters from Wristband.
* @param {Response} res - The Express response object.
* @returns {Promise<CallbackResult>} A Promise containing the callback result with token data and userinfo,
* or a redirect URL if re-authentication is required.
* @throws {TypeError} When required query parameters are invalid or missing.
* @throws {WristbandError} When an error occurs during the OAuth flow.
*/
async callback(req, res) {
res.header('Cache-Control', 'no-store');
res.header('Pragma', 'no-cache');
// Fetch our SDK configs
const dangerouslyDisableSecureCookies = this.configResolver.getDangerouslyDisableSecureCookies();
const loginStateSecret = this.configResolver.getLoginStateSecret();
const loginUrl = await this.configResolver.getLoginUrl();
const parseTenantFromRootDomain = await this.configResolver.getParseTenantFromRootDomain();
const tokenExpirationBuffer = this.configResolver.getTokenExpirationBuffer();
// Safety checks -- Wristband backend should never send bad query params
const { code, state: paramState, error, error_description: errorDescription, tenant_custom_domain: tenantCustomDomainParam, } = req.query;
if (!paramState || typeof paramState !== 'string') {
throw new TypeError('Invalid query parameter [state] passed from Wristband during callback');
}
if (!!code && typeof code !== 'string') {
throw new TypeError('Invalid query parameter [code] passed from Wristband during callback');
}
if (!!error && typeof error !== 'string') {
throw new TypeError('Invalid query parameter [error] passed from Wristband during callback');
}
if (!!errorDescription && typeof errorDescription !== 'string') {
throw new TypeError('Invalid query parameter [error_description] passed from Wristband during callback');
}
if (!!tenantCustomDomainParam && typeof tenantCustomDomainParam !== 'string') {
throw new TypeError('Invalid query parameter [tenant_custom_domain] passed from Wristband during callback');
}
// Resolve and validate the tenant name
const resolvedTenantName = resolveTenantName(req, parseTenantFromRootDomain);
if (!resolvedTenantName) {
throw new 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(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 = getAndClearLoginStateCookie(req, res, dangerouslyDisableSecureCookies);
if (!loginStateCookie) {
return { type: 'redirect_required', redirectUrl: tenantLoginUrl, reason: 'missing_login_state' };
}
const loginState = await decryptLoginState(loginStateCookie, 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() === LOGIN_REQUIRED_ERROR) {
return { type: 'redirect_required', redirectUrl: tenantLoginUrl, reason: 'login_required' };
}
throw new 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 InvalidGrantError) {
return { type: 'redirect_required', redirectUrl: tenantLoginUrl, reason: 'invalid_grant' };
}
throw new WristbandError('unexpected_error', 'Unexpected error', err instanceof Error ? err : undefined);
}
const { access_token: accessToken, id_token: idToken, refresh_token: refreshToken, expires_in: expiresIn, } = tokenResponse;
// Fetch the userinfo for the user logging in.
let userinfo;
try {
userinfo = await this.wristbandService.getUserInfo(accessToken);
}
catch (err) {
throw new WristbandError('unexpected_error', 'Unexpected error', err instanceof Error ? err : undefined);
}
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 };
}
/**
* Initiates logout by revoking the refresh token and constructing a redirect URL
* to Wristband's logout endpoint.
*
* @param {Request} req - The Express request object.
* @param {Response} res - The Express response object.
* @param {LogoutConfig} [config={ tenantCustomDomain: '' }] - Optional configuration for logout.
* @returns {Promise<string>} A Promise containing the redirect URL to Wristband's Logout Endpoint.
* @throws {TypeError} When query parameters are invalid or state exceeds 512 characters.
*/
async logout(req, res, config = { tenantCustomDomain: '' }) {
res.header('Cache-Control', 'no-store');
res.header('Pragma', 'no-cache');
// 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 (config.refreshToken) {
try {
await this.wristbandService.revokeRefreshToken(config.refreshToken);
}
catch (error) {
// No need to block logout execution if revoking fails
// Silently continue - the refresh token will eventually expire and can be revoked by admin
}
}
if (config.state && config.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 = config.redirectUrl ? `&redirect_url=${config.redirectUrl}` : '';
const state = config.state ? `&state=${config.state}` : '';
const logoutPath = `/api/v1/logout?client_id=${clientId}${logoutRedirectUrl}${state}`;
const separator = isApplicationCustomDomainActive ? '.' : '-';
const tenantCustomDomainParam = resolveTenantCustomDomainParam(req);
const tenantName = resolveTenantName(req, parseTenantFromRootDomain);
// 4a) If tenant subdomains are enabled, get the tenant name from the host.
// 4b) Otherwise, if tenant subdomains are not enabled, then look for it in the tenant_name query param.
// Domain priority order resolution:
// 1) If the LogoutConfig has a tenant custom domain explicitly defined, use that.
if (config.tenantCustomDomain) {
return `https://${config.tenantCustomDomain}${logoutPath}`;
}
// 2) If the LogoutConfig has a tenant name defined, then use that.
if (config.tenantName) {
return `https://${config.tenantName}${separator}${wristbandApplicationVanityDomain}${logoutPath}`;
}
// 3) If the tenant_custom_domain query param exists, then use that.
if (tenantCustomDomainParam) {
return `https://${tenantCustomDomainParam}${logoutPath}`;
}
// 4a) If tenant subdomains are enabled, get the tenant name from the host.
// 4b) Otherwise, if tenant subdomains are not enabled, then look for it in the tenant_name query param.
if (tenantName) {
return `https://${tenantName}${separator}${wristbandApplicationVanityDomain}${logoutPath}`;
}
// Fallback to the appropriate Application-Level Login or Redirect URL if tenant cannot be resolved.
const appLoginUrl = customApplicationLoginPageUrl || `https://${wristbandApplicationVanityDomain}/login`;
return config.redirectUrl || `${appLoginUrl}?client_id=${clientId}`;
}
/**
* Checks if the access token is expired and refreshes it if necessary.
* Implements retry logic for transient failures.
*
* @param {string} refreshToken - The refresh token to use for obtaining a new access token.
* @param {number} expiresAt - Unix timestamp in milliseconds when the current token expires.
* @returns {Promise<TokenData | null>} A Promise with new token data if refresh occurred, or null if token is still valid.
* @throws {TypeError} When refreshToken is invalid or expiresAt is not a positive integer.
* @throws {WristbandError} When token refresh fails due to invalid credentials or unexpected errors.
*/
async refreshTokenIfExpired(refreshToken, expiresAt) {
// Fetch our SDK configs
const tokenExpirationBuffer = this.configResolver.getTokenExpirationBuffer();
// Safety checks
if (!refreshToken) {
throw new TypeError('Refresh token must be a valid string');
}
if (!expiresAt || expiresAt < 0) {
throw new TypeError('The expiresAt field must be an integer greater than 0');
}
// Nothing to do here if the access token is still valid.
if (!isExpired(expiresAt)) {
return null;
}
// Try up to 3 times to perform a token refresh.
for (let attempt = 1; attempt <= MAX_REFRESH_ATTEMPTS; attempt += 1) {
try {
// eslint-disable-next-line no-await-in-loop
const tokenResponse = await this.wristbandService.refreshToken(refreshToken);
const { access_token: accessToken, id_token: idToken, expires_in: expiresIn, refresh_token: responseRefreshToken, } = tokenResponse;
const resolvedExpiresIn = expiresIn - (tokenExpirationBuffer || 0);
const resolvedExpiresAt = Date.now() + resolvedExpiresIn * 1000;
return {
accessToken,
idToken,
refreshToken: responseRefreshToken,
expiresAt: resolvedExpiresAt,
expiresIn: resolvedExpiresIn,
};
}
catch (error) {
// Specifically handle invalid_grant errors
if (error instanceof InvalidGrantError) {
throw new WristbandError('invalid_refresh_token', error.errorDescription, error);
}
// Only 4xx errors should short-circuit the retry loop early.
if (error instanceof FetchError &&
error.response &&
error.response.status >= 400 &&
error.response.status < 500) {
// Only 4xx errors should short-circuit the retry loop early.
const errorDescription = error.body && error.body.error_description ? error.body.error_description : 'Invalid Refresh Token';
throw new WristbandError('invalid_refresh_token', errorDescription, error);
}
// Last attempt failed
if (attempt === MAX_REFRESH_ATTEMPTS) {
throw new WristbandError('unexpected_error', 'Unexpected Error', error instanceof Error ? error : undefined);
}
// Wait before next retry (only for 5xx errors or network failures)
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => {
setTimeout(resolve, MAX_REFRESH_ATTEMPT_DELAY_MS);
});
}
}
// This is merely a safety check, but this should never happen.
throw new WristbandError('unexpected_error', 'Unexpected Error');
}
/**
* Create middleware that ensures authenticated session using multiple strategies.
* Tries strategies in order until one succeeds. Supports both SESSION and JWT auth.
*
* @param {AuthMiddlewareConfig} config - Configuration for the auth middleware.
* @returns {Function} Express middleware function that validates authentication.
*
* @example
* ```typescript
* // SESSION only
* const requireAuth = authService.createMiddlewareAuth({
* authStrategies: ['SESSION'],
* sessionConfig: {
* sessionOptions: {
* secrets: process.env.SESSION_SECRET!,
* cookieName: 'my-session',
* maxAge: 24 * 60 * 60 * 1000, // 24 hours
* enableCsrfProtection: true,
* }
* }
* });
* app.use('/api/protected', requireAuth);
*
* // JWT only
* const requireJwtAuth = authService.createMiddlewareAuth({
* authStrategies: ['JWT']
* });
* app.use('/api/protected', requireJwtAuth);
*
* // Try SESSION first, fallback to JWT
* const requireAuth = authService.createMiddlewareAuth({
* authStrategies: ['SESSION', 'JWT'],
* sessionConfig: {
* sessionOptions: {
* secrets: process.env.SESSION_SECRET!,
* enableCsrfProtection: true,
* }
* }
* });
* app.use('/api/protected', requireAuth);
*
* // Apply at router level
* const protectedRouter = express.Router();
* protectedRouter.use(requireAuth);
* protectedRouter.get('/orders', (req, res) => { //... });
* ```
*/
createAuthMiddleware(config) {
const normalizedConfig = normalizeAuthMiddlewareConfig(config);
return async (req, res, next) => {
// Initialize req.auth at the very beginning regardless of strategy
req.auth = {};
let result = { authenticated: false, reason: 'not_authenticated' };
// Try all auth strategies in sequential order
for (let i = 0; i < normalizedConfig.authStrategies.length; i += 1) {
const strategy = normalizedConfig.authStrategies[i];
// Check if session middleware is configured
// In Express, the session is attached to req.session by the session middleware
if (strategy === 'SESSION' && (!req.session || typeof req.session.save !== 'function')) {
const error = new WristbandError('session_not_configured', 'The Wristband session middleware must be used before any auth middleware.');
next(error);
return;
}
// eslint-disable-next-line no-await-in-loop
result = await this.tryAuthStrategy(req, strategy, normalizedConfig);
if (result.authenticated) {
break;
}
}
// If no strategy succeeded, return appropriate error
if (!result.authenticated) {
sendAuthFailureResponse(res, result.reason);
return;
}
// Authentication succeeded - continue
next();
};
}
/**
* Attempts to authenticate a request using a single configured auth strategy.
*
* This evaluates the provided strategy in isolation and reports whether it
* succeeded or failed with a specific reason. Normal authentication failures
* are returned as structured results rather than thrown.
*
* @param req - The incoming Express request to authenticate.
* @param strategy - The auth strategy to apply for this attempt.
* @param config - The fully normalized middleware configuration.
* @returns A structured result describing authentication outcome, session (if successful), strategy used, and failure reason (if failed).
*/
async tryAuthStrategy(req, strategy, config) {
if (strategy === 'SESSION') {
const { sessionOptions, csrfTokenHeaderName } = config.sessionConfig;
try {
const { csrfToken, expiresAt, isAuthenticated, refreshToken } = req.session;
// Check if user has an authenticated session
if (!isAuthenticated) {
return { authenticated: false, reason: 'not_authenticated' };
}
// Validate CSRF token if protection is enabled
if (sessionOptions?.enableCsrfProtection && !isValidCsrf(req, csrfToken, csrfTokenHeaderName)) {
return { authenticated: false, reason: 'csrf_failed' };
}
// Try to refresh token if expired
if (refreshToken && expiresAt !== undefined) {
try {
const tokenData = await this.refreshTokenIfExpired(refreshToken, expiresAt);
if (tokenData) {
req.session.accessToken = tokenData.accessToken;
req.session.expiresAt = tokenData.expiresAt;
req.session.refreshToken = tokenData.refreshToken;
}
}
catch (error) {
return { authenticated: false, reason: 'token_refresh_failed' };
}
}
// Save session (for rolling expiration)
await req.session.save();
return { authenticated: true, usedStrategy: 'SESSION' };
}
catch (error) {
return { authenticated: false, reason: 'unexpected_error' };
}
}
if (strategy === 'JWT') {
try {
const jwtValidator = this.getJwtValidator(config.jwtConfig);
const bearerToken = jwtValidator.extractBearerToken(req.headers.authorization);
if (!bearerToken) {
return { authenticated: false, reason: 'not_authenticated' };
}
const validationResult = await jwtValidator.validate(bearerToken);
const { isValid, payload } = validationResult;
if (!isValid) {
return { authenticated: false, reason: 'not_authenticated' };
}
// Attach JWT and decoded payload to req.auth
req.auth = payload;
req.auth.jwt = bearerToken;
return { authenticated: true, usedStrategy: 'JWT' };
}
catch (error) {
return { authenticated: false, reason: 'unexpected_error' };
}
}
// Should never reach here
return { authenticated: false, reason: 'unexpected_error' };
}
/**
* Lazily initializes and returns the JWT validator instance.
* Only creates the validator on first use if JWT strategy is configured.
*/
getJwtValidator(jwtConfig) {
if (!this.jwtValidator) {
const wristbandApplicationVanityDomain = this.configResolver.getWristbandApplicationVanityDomain();
this.jwtValidator = createWristbandJwtValidator({
wristbandApplicationVanityDomain,
jwksCacheMaxSize: jwtConfig?.jwksCacheMaxSize,
jwksCacheTtl: jwtConfig?.jwksCacheTtl,
});
}
return this.jwtValidator;
}
}