auth-vir
Version:
Auth made easy and secure via JWT cookies, CSRF tokens, and password hashing helpers.
467 lines (466 loc) • 19.1 kB
JavaScript
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 || {},
};
}
}