UNPKG

auth-vir

Version:

Auth made easy and secure via JWT cookies, CSRF tokens, and password hashing helpers.

467 lines (466 loc) 19.1 kB
import { ensureArray, } from '@augment-vir/common'; import { calculateRelativeDate, createUtcFullDate, getNowInUtcTimezone, isDateAfter, } from 'date-vir'; import { extractUserIdFromRequestHeaders, generateLogoutHeaders, insecureExtractUserIdFromCookieAlone, } from '../auth.js'; import { AuthCookie, clearCsrfCookie, generateAuthCookie, generateCsrfCookie, resolveCookieName, } from '../cookie.js'; import { generateCsrfToken } from '../csrf-token.js'; import { AuthHeaderName, mergeHeaderValues } from '../headers.js'; import { parseJwtKeys } from '../jwt/jwt-keys.js'; import { defaultAllowedClockSkew } from '../jwt/jwt.js'; import { isSessionRefreshReady } from './is-session-refresh-ready.js'; const defaultSessionIdleTimeout = { minutes: 20, }; const defaultSessionRefreshStartTime = { minutes: 2, }; const defaultMaxSessionDuration = { days: 1.5, }; /** * An auth client for creating and validating JWTs embedded in cookies. This should only be used in * a backend environment as it accesses native Node packages. * * @category Auth : Host * @category Clients */ export class BackendAuthClient { config; cachedParsedJwtKeys = {}; constructor(config) { this.config = config; } /** * Resolves the origin to use for CSRF cookie generation. Returns `csrfCookieOrigin` if * configured, otherwise falls back to the auth cookie origin. */ resolveCsrfCookieOrigin(authCookieOrigin) { return this.config.csrfCookieOrigin || authCookieOrigin; } /** Conditionally logs a message if logging is enabled for the given user context. */ logForUser(params, message, extra) { if (this.config.enableLogging?.(params)) { const extraData = { userId: params.userId, ...extra, }; if (this.config.log) { this.config.log(message, extraData); } else { console.info(`[auth-vir] ${message}`, extraData); } } } /** Get all the parameters used for cookie generation. */ async getCookieParams({ isSignUpCookie, requestHeaders, }) { const serviceOrigin = requestHeaders ? await this.config.generateServiceOrigin?.({ requestHeaders, }) : undefined; return { cookieDuration: this.config.userSessionIdleTimeout || defaultSessionIdleTimeout, hostOrigin: serviceOrigin || this.config.serviceOrigin, jwtParams: await this.getJwtParams(), isDev: this.config.isDev, authCookie: isSignUpCookie ? AuthCookie.SignUp : AuthCookie.Auth, cookieNameSuffix: this.config.cookieNameSuffix, }; } /** Calls the provided `getUserFromDatabase` config. */ async getDatabaseUser({ isSignUpCookie, userId, assumingUser, requestHeaders, }) { if (!userId) { return undefined; } const authenticatedUser = await this.config.getUserFromDatabase({ assumingUser, userId, isSignUpCookie, requestHeaders, }); if (!authenticatedUser) { this.logForUser({ user: undefined, userId, assumedUserParams: assumingUser, }, 'getUserFromDatabase returned no user', { isSignUpCookie, }); return undefined; } return authenticatedUser; } /** Creates a `'cookie-set'` header to refresh the user's session cookie. */ async createCookieRefreshHeaders({ userIdResult, requestHeaders, }) { const now = getNowInUtcTimezone(); const clockSkew = this.config.allowedClockSkew || defaultAllowedClockSkew; /** Double check that the JWT hasn't already expired (with clock skew tolerance). */ const isExpiredAlready = isDateAfter({ fullDate: now, relativeTo: calculateRelativeDate(userIdResult.jwtExpiration, clockSkew), }); if (isExpiredAlready) { this.logForUser({ user: undefined, userId: userIdResult.userId, assumedUserParams: undefined, }, 'Session refresh denied: JWT already expired (even with clock skew tolerance)', { jwtExpiration: userIdResult.jwtExpiration, now: JSON.stringify(now), }); return undefined; } /** * Check if the session has exceeded the max session duration. If so, don't refresh the * session and let it expire naturally. */ const maxSessionDuration = this.config.maxSessionDuration || defaultMaxSessionDuration; if (userIdResult.sessionStartedAt) { const sessionStartDate = createUtcFullDate(userIdResult.sessionStartedAt); const maxSessionEndDate = calculateRelativeDate(sessionStartDate, maxSessionDuration); const isSessionExpired = isDateAfter({ fullDate: now, relativeTo: maxSessionEndDate, }); if (isSessionExpired) { this.logForUser({ user: undefined, userId: userIdResult.userId, assumedUserParams: undefined, }, 'Session refresh denied: max session duration exceeded', { sessionStartedAt: userIdResult.sessionStartedAt, maxSessionEndDate: JSON.stringify(maxSessionEndDate), now: JSON.stringify(now), }); return undefined; } } const sessionRefreshStartTime = this.config.sessionRefreshStartTime || defaultSessionRefreshStartTime; const isRefreshReady = isSessionRefreshReady({ now, jwtIssuedAt: userIdResult.jwtIssuedAt, sessionRefreshStartTime, }); if (isRefreshReady) { const isSignUpCookie = userIdResult.cookieName === AuthCookie.SignUp; const cookieParams = await this.getCookieParams({ isSignUpCookie, requestHeaders, }); const authCookie = await generateAuthCookie({ csrfToken: userIdResult.csrfToken, userId: userIdResult.userId, sessionStartedAt: userIdResult.sessionStartedAt || Date.now(), }, cookieParams); const csrfCookie = generateCsrfCookie(userIdResult.csrfToken, { ...cookieParams, hostOrigin: this.resolveCsrfCookieOrigin(cookieParams.hostOrigin), cookieNameSuffix: this.config.cookieNameSuffix, }); return { 'set-cookie': [ authCookie, csrfCookie, ], }; } else { this.logForUser({ user: undefined, userId: userIdResult.userId, assumedUserParams: undefined, }, 'Session refresh skipped: not yet ready for refresh', { jwtIssuedAt: userIdResult.jwtIssuedAt, sessionRefreshStartTime, }); return undefined; } } /** Reads the user's assumed user headers and, if configured, gets the assumed user. */ async getAssumedUser({ requestHeaders, user, }) { if (!this.config.assumeUser || !(await this.config.assumeUser.canAssumeUser(user))) { return undefined; } const assumedUserHeader = ensureArray(requestHeaders[this.config.assumedUserHeaderName || AuthHeaderName.AssumedUser])[0]; if (!assumedUserHeader) { return undefined; } const parsedAssumedUserData = await this.config.assumeUser.parseAssumedUserHeaderValue(assumedUserHeader); if (!parsedAssumedUserData || !parsedAssumedUserData.userId) { return undefined; } const assumedUser = await this.getDatabaseUser({ isSignUpCookie: false, userId: parsedAssumedUserData.userId, assumingUser: parsedAssumedUserData.assumedUserParams, requestHeaders, }); return assumedUser; } /** Securely extract a user from their request headers. */ async getSecureUser({ requestHeaders, isSignUpCookie, allowUserAuthRefresh, }) { const userIdResult = await extractUserIdFromRequestHeaders({ headers: requestHeaders, jwtParams: await this.getJwtParams(), csrfHeaderNameOption: this.config.csrf, cookieName: isSignUpCookie ? AuthCookie.SignUp : AuthCookie.Auth, cookieNameSuffix: this.config.cookieNameSuffix, }); if (!userIdResult) { this.logForUser({ user: undefined, userId: undefined, assumedUserParams: undefined, }, 'getSecureUser: failed to extract user ID from request headers (invalid JWT, missing cookie, or CSRF mismatch)', { isSignUpCookie, }); return undefined; } const user = await this.getDatabaseUser({ userId: userIdResult.userId, assumingUser: undefined, isSignUpCookie, requestHeaders, }); if (!user) { this.logForUser({ user: undefined, userId: userIdResult.userId, assumedUserParams: undefined, }, 'getSecureUser: user not found in database', { isSignUpCookie, }); return undefined; } const assumedUser = await this.getAssumedUser({ requestHeaders, user, }); const cookieRefreshHeaders = allowUserAuthRefresh ? await this.createCookieRefreshHeaders({ userIdResult, requestHeaders, }) : undefined; /** * Always include the CSRF cookie so it gets re-established if the browser clears it. When * session refresh fires, its headers already include a CSRF cookie. */ const authCookieOrigin = (await this.config.generateServiceOrigin?.({ requestHeaders, })) || this.config.serviceOrigin; const csrfCookie = generateCsrfCookie(userIdResult.csrfToken, { hostOrigin: this.resolveCsrfCookieOrigin(authCookieOrigin), isDev: this.config.isDev, cookieNameSuffix: this.config.cookieNameSuffix, }); return { user: assumedUser || user, isAssumed: !!assumedUser, responseHeaders: { 'set-cookie': mergeHeaderValues(cookieRefreshHeaders?.['set-cookie'], csrfCookie), }, }; } /** * Get all the JWT params used when creating the auth cookie, in case you need them for * something else too. */ async getJwtParams() { const rawJwtKeys = await this.config.getJwtKeys(); const cacheKey = JSON.stringify(rawJwtKeys); const cachedParsedKeys = this.cachedParsedJwtKeys[cacheKey]; const parsedKeys = cachedParsedKeys || (await parseJwtKeys(rawJwtKeys)); if (!cachedParsedKeys) { this.cachedParsedJwtKeys = { [cacheKey]: parsedKeys, }; } return { jwtKeys: parsedKeys, audience: 'server-context', issuer: 'server-auth', jwtDuration: this.config.userSessionIdleTimeout || defaultSessionIdleTimeout, allowedClockSkew: this.config.allowedClockSkew || defaultAllowedClockSkew, }; } /** Use these headers to log out the user. */ async createLogoutHeaders(params) { const clearingAllCookies = !!params.allCookies; const signUpCookieHeaders = params.allCookies || params.isSignUpCookie ? generateLogoutHeaders(await this.getCookieParams({ isSignUpCookie: true, requestHeaders: undefined, }), { preserveCsrf: !clearingAllCookies, }) : undefined; const authCookieHeaders = params.allCookies || !params.isSignUpCookie ? generateLogoutHeaders(await this.getCookieParams({ isSignUpCookie: false, requestHeaders: undefined, }), { preserveCsrf: !clearingAllCookies, }) : undefined; /** * When `csrfCookieOrigin` is configured, the CSRF cookie lives on a broader domain than the * auth cookie. Clear it on that broader domain too so stale tokens don't linger. */ const broadCsrfClearCookie = clearingAllCookies && this.config.csrfCookieOrigin ? clearCsrfCookie({ hostOrigin: this.config.csrfCookieOrigin, isDev: this.config.isDev, cookieNameSuffix: this.config.cookieNameSuffix, }) : undefined; return { 'set-cookie': mergeHeaderValues(signUpCookieHeaders?.['set-cookie'], authCookieHeaders?.['set-cookie'], broadCsrfClearCookie), }; } /** * Refreshes a login session by reissuing the auth cookie with the same CSRF token instead of * generating a new one. */ async refreshLoginHeaders({ userId, cookieParams, existingUserIdResult, }) { const authCookie = await generateAuthCookie({ csrfToken: existingUserIdResult.csrfToken, userId, sessionStartedAt: existingUserIdResult.sessionStartedAt, }, cookieParams); const csrfCookie = generateCsrfCookie(existingUserIdResult.csrfToken, { ...cookieParams, hostOrigin: this.resolveCsrfCookieOrigin(cookieParams.hostOrigin), cookieNameSuffix: this.config.cookieNameSuffix, }); return { 'set-cookie': [ authCookie, csrfCookie, ], }; } /** Generates login headers for a brand-new session (no existing JWT to reuse). */ async generateFreshLoginHeaders(userId, cookieParams) { const csrfToken = generateCsrfToken(); const authCookie = await generateAuthCookie({ csrfToken, userId, sessionStartedAt: Date.now(), }, cookieParams); const csrfCookie = generateCsrfCookie(csrfToken, { ...cookieParams, hostOrigin: this.resolveCsrfCookieOrigin(cookieParams.hostOrigin), cookieNameSuffix: this.config.cookieNameSuffix, }); return { 'set-cookie': [ authCookie, csrfCookie, ], }; } /** Use these headers to log a user in. */ async createLoginHeaders({ userId, requestHeaders, isSignUpCookie, }) { const oppositeCookieName = isSignUpCookie ? AuthCookie.Auth : AuthCookie.SignUp; const resolvedOppositeCookieName = resolveCookieName(oppositeCookieName, this.config.cookieNameSuffix); const hasExistingOppositeCookie = requestHeaders.cookie?.includes(`${resolvedOppositeCookieName}=`); const discardOppositeCookieHeaders = hasExistingOppositeCookie ? generateLogoutHeaders(await this.getCookieParams({ isSignUpCookie: !isSignUpCookie, requestHeaders, }), { preserveCsrf: true, }) : undefined; const existingUserIdResult = await extractUserIdFromRequestHeaders({ headers: requestHeaders, jwtParams: await this.getJwtParams(), csrfHeaderNameOption: this.config.csrf, cookieName: isSignUpCookie ? AuthCookie.SignUp : AuthCookie.Auth, cookieNameSuffix: this.config.cookieNameSuffix, }); const cookieParams = await this.getCookieParams({ isSignUpCookie, requestHeaders, }); const newCookieHeaders = existingUserIdResult ? await this.refreshLoginHeaders({ userId, cookieParams, existingUserIdResult, }) : await this.generateFreshLoginHeaders(userId, cookieParams); return { ...newCookieHeaders, 'set-cookie': mergeHeaderValues(newCookieHeaders['set-cookie'], discardOppositeCookieHeaders?.['set-cookie']), ...(isSignUpCookie ? { [AuthHeaderName.IsSignUpAuth]: 'true', } : {}), }; } /** Combines `.getInsecureUser()` and `.getSecureUser()` into one method. */ async getInsecureOrSecureUser(params) { const secureUser = await this.getSecureUser(params); if (secureUser) { return { secureUser, }; } // eslint-disable-next-line @typescript-eslint/no-deprecated const insecureUser = await this.getInsecureUser(params); return insecureUser ? { insecureUser, } : {}; } /** * @deprecated This only half authenticates the user. It should only be used in circumstances * where JavaScript cannot be used to attach the CSRF token header to the request (like when * opening a PDF file). Use `.getSecureUser()` instead, whenever possible. */ async getInsecureUser({ requestHeaders, allowUserAuthRefresh, }) { // eslint-disable-next-line @typescript-eslint/no-deprecated const userIdResult = await insecureExtractUserIdFromCookieAlone({ headers: requestHeaders, jwtParams: await this.getJwtParams(), cookieName: AuthCookie.Auth, cookieNameSuffix: this.config.cookieNameSuffix, }); if (!userIdResult) { this.logForUser({ user: undefined, userId: undefined, assumedUserParams: undefined, }, 'getInsecureUser: failed to extract user ID from cookie (invalid JWT or missing cookie)'); return undefined; } const user = await this.getDatabaseUser({ isSignUpCookie: false, userId: userIdResult.userId, assumingUser: undefined, requestHeaders, }); if (!user) { this.logForUser({ user: undefined, userId: userIdResult.userId, assumedUserParams: undefined, }, 'getInsecureUser: user not found in database'); return undefined; } const refreshHeaders = allowUserAuthRefresh && (await this.createCookieRefreshHeaders({ userIdResult, requestHeaders, })); return { user, isAssumed: false, responseHeaders: refreshHeaders || {}, }; } }