auth-vir
Version:
Auth made easy and secure via JWT cookies, CSRF tokens, and password hashing helpers.
290 lines (289 loc) • 13.4 kB
TypeScript
import { type AnyObject, type JsonCompatibleObject, type MaybePromise, type PartialWithUndefined } from '@augment-vir/common';
import { type AnyDuration } from 'date-vir';
import { type IncomingHttpHeaders, type OutgoingHttpHeaders } from 'node:http';
import { type EmptyObject, type RequireExactlyOne, type RequireOneOrNone } from 'type-fest';
import { type UserIdResult } from '../auth.js';
import { type CookieParams } from '../cookie.js';
import { type CsrfHeaderNameOption } from '../csrf-token.js';
import { type JwtKeys, type RawJwtKeys } from '../jwt/jwt-keys.js';
import { type CreateJwtParams, type ParseJwtParams } from '../jwt/jwt.js';
/**
* Output from `BackendAuthClient.getSecureUser()`.
*
* @category Internal
*/
export type GetUserResult<DatabaseUser extends AnyObject> = {
/** The retrieved user. */
user: DatabaseUser;
/**
* When `true`, indicates that the current `user` result is as assumed user. This can only be
* `true` if you've configured user assuming in `BackendAuthClient`.
*/
isAssumed: boolean;
/**
* This should be merged into your own response headers. It usually contains auth cookie
* duration refresh headers.
*/
responseHeaders: OutgoingHttpHeaders;
};
/**
* Config for {@link BackendAuthClient}.
*
* @category Internal
*/
export type BackendAuthClientConfig<DatabaseUser extends AnyObject, UserId extends string | number, AssumedUserParams extends JsonCompatibleObject = EmptyObject> = Readonly<{
csrf: Readonly<CsrfHeaderNameOption>;
/** The origin of your backend that is offering auth cookies. */
serviceOrigin: string;
/** Finds the relevant user from your own database. */
getUserFromDatabase: (userParams: {
/** The user id extracted from the request cookie. */
userId: UserId;
/** Indicates that we're loading the user from a sign up cookie. */
isSignUpCookie: boolean;
/**
* If this is set, we're attempting to load a database user for the purpose of assuming
* their user identity. Otherwise, this is `undefined`.
*/
assumingUser: AssumedUserParams | undefined;
requestHeaders: Readonly<IncomingHttpHeaders>;
}) => MaybePromise<DatabaseUser | undefined | null>;
/**
* Get JWT keys produced by {@link generateNewJwtKeys}. Make sure that each time this is
* called, the same JWT keys are returned (do not call {@link generateNewJwtKeys} each time
* this is called). Any time the JWT keys change, all current sessions will terminate.
*/
getJwtKeys: () => MaybePromise<Readonly<RawJwtKeys>>;
/**
* When `isDev` is set, cookies do not require HTTPS (so they can be used with
* http://localhost).
*/
isDev: boolean;
} & PartialWithUndefined<{
/** If this returns true, logging will be enabled while handling the relevant session. */
enableLogging(params: {
user: DatabaseUser | undefined;
userId: UserId | undefined;
assumedUserParams: AssumedUserParams | undefined;
}): boolean;
/**
* Overwrite the header name used for tracking is an admin is assuming the identity of
* another user.
*/
assumedUserHeaderName: string;
/**
* Optionally generate a service origin from request headers. The generated origin is used
* for set-cookie headers.
*/
generateServiceOrigin(params: {
requestHeaders: Readonly<IncomingHttpHeaders>;
}): MaybePromise<undefined | string>;
/** If provided, logs will be sent to this method. */
log?: (message: string, extraData: AnyObject) => void;
/**
* Set this to allow specific users (determined by `canAssumeUser`) to assume the identity
* of other users. This should only be used for admins so that they can troubleshoot user
* issues.
*
* @see {@link AuthHeaderName}
*/
assumeUser: {
/**
* Parse the assumed user header value.
*
* @see {@link AuthHeaderName}
*/
parseAssumedUserHeaderValue: (
/**
* The assumed user header value.
*
* @see {@link AuthHeaderName}
*/
data: string) => MaybePromise<{
assumedUserParams: AssumedUserParams;
userId: UserId;
} | undefined>;
/**
* Return `true` to allow the current/original user to assume identities of other users.
* Return `false` to block it. It is recommended to only return `true` for admin users.
*
* @see {@link AuthHeaderName}
*/
canAssumeUser: (originalUser: DatabaseUser) => MaybePromise<boolean>;
};
/**
* This determines how long a cookie will be valid until it needs to be refreshed.
*
* @default {minutes: 20}
*/
userSessionIdleTimeout: Readonly<AnyDuration>;
/**
* How long into a user's session when we should start trying to refresh their session.
*
* @default {minutes: 2}
*/
sessionRefreshStartTime: Readonly<AnyDuration>;
/**
* The maximum duration a session can last, regardless of activity. After this time, the
* user will be logged out even if they are actively using the application.
*
* @default {days: 1.5}
*/
maxSessionDuration: Readonly<AnyDuration>;
/**
* Allowed clock skew tolerance for JWT and CSRF token expiration checks. Accounts for
* differences between server and client clocks.
*
* @default {minutes: 5}
*/
allowedClockSkew: Readonly<AnyDuration>;
/**
* Optional separate origin for the CSRF cookie's `Domain` attribute. When set, the
* non-`HttpOnly` CSRF cookie will use this origin's hostname instead of `serviceOrigin`.
*
* This is useful when the backend and frontend live on different subdomains that don't
* share a common parent narrower than the top-level domain. The `HttpOnly` auth cookie
* stays scoped to `serviceOrigin` (protecting it from unrelated subdomains), while the CSRF
* cookie uses the broader domain so frontend JavaScript can read it via `document.cookie`.
*
* The CSRF token alone is not a security risk — it is only meaningful when paired with the
* JWT embedded in the `HttpOnly` auth cookie.
*/
csrfCookieOrigin: string;
/**
* Optional suffix appended to cookie names (e.g., `'staging'` produces `auth-staging`,
* `auth-vir-csrf-staging`). When `undefined`, cookie names are unchanged. Useful for
* running multiple environments on the same domain without cookie collisions.
*/
cookieNameSuffix: string;
}>>;
/**
* 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 declare class BackendAuthClient<DatabaseUser extends AnyObject, UserId extends string | number, AssumedUserParams extends AnyObject = EmptyObject> {
protected readonly config: BackendAuthClientConfig<DatabaseUser, UserId, AssumedUserParams>;
protected cachedParsedJwtKeys: Record<string, Readonly<JwtKeys>>;
constructor(config: BackendAuthClientConfig<DatabaseUser, UserId, AssumedUserParams>);
/**
* Resolves the origin to use for CSRF cookie generation. Returns `csrfCookieOrigin` if
* configured, otherwise falls back to the auth cookie origin.
*/
protected resolveCsrfCookieOrigin(authCookieOrigin: string): string;
/** Conditionally logs a message if logging is enabled for the given user context. */
protected logForUser(params: {
user: DatabaseUser | undefined;
userId: UserId | undefined;
assumedUserParams: AssumedUserParams | undefined;
}, message: string, extra?: Record<string, unknown>): void;
/** Get all the parameters used for cookie generation. */
protected getCookieParams({ isSignUpCookie, requestHeaders, }: {
/**
* Set this to `true` when we are setting the initial cookie right after a user signs up.
* This allows them to auto-authorize when they verify their email address.
*
* This should only be set to `true` when a new user is signing up.
*/
isSignUpCookie: boolean;
requestHeaders: Readonly<IncomingHttpHeaders> | undefined;
}): Promise<Readonly<CookieParams>>;
/** Calls the provided `getUserFromDatabase` config. */
protected getDatabaseUser({ isSignUpCookie, userId, assumingUser, requestHeaders, }: {
userId: UserId | undefined;
assumingUser: AssumedUserParams | undefined;
isSignUpCookie: boolean;
requestHeaders: IncomingHttpHeaders;
}): Promise<undefined | DatabaseUser>;
/** Creates a `'cookie-set'` header to refresh the user's session cookie. */
protected createCookieRefreshHeaders({ userIdResult, requestHeaders, }: {
userIdResult: Readonly<UserIdResult<UserId>>;
requestHeaders: IncomingHttpHeaders;
}): Promise<OutgoingHttpHeaders | undefined>;
/** Reads the user's assumed user headers and, if configured, gets the assumed user. */
protected getAssumedUser({ requestHeaders, user, }: {
user: DatabaseUser;
requestHeaders: IncomingHttpHeaders;
}): Promise<DatabaseUser | undefined>;
/** Securely extract a user from their request headers. */
getSecureUser({ requestHeaders, isSignUpCookie, allowUserAuthRefresh, }: {
requestHeaders: IncomingHttpHeaders;
isSignUpCookie: boolean;
/**
* If true, this method will generate headers to refresh the user's auth session. This
* should likely only be done with a specific endpoint, like whatever endpoint you trigger
* with the frontend auth client's `checkUser.performCheck` callback.
*/
allowUserAuthRefresh: boolean;
}): Promise<GetUserResult<DatabaseUser> | undefined>;
/**
* Get all the JWT params used when creating the auth cookie, in case you need them for
* something else too.
*/
getJwtParams(): Promise<Readonly<CreateJwtParams> & ParseJwtParams>;
/** Use these headers to log out the user. */
createLogoutHeaders(params: Readonly<RequireExactlyOne<{
allCookies: true;
isSignUpCookie: boolean;
}> & {
/** Overrides the client's already established `serviceOrigin`. */
serviceOrigin?: string | undefined;
}>): Promise<Record<string, string | string[]> & {
'set-cookie': string[];
}>;
/**
* Refreshes a login session by reissuing the auth cookie with the same CSRF token instead of
* generating a new one.
*/
protected refreshLoginHeaders({ userId, cookieParams, existingUserIdResult, }: {
userId: UserId;
cookieParams: Readonly<CookieParams>;
existingUserIdResult: Readonly<UserIdResult<UserId>>;
}): Promise<Record<string, string | string[]>>;
/** Generates login headers for a brand-new session (no existing JWT to reuse). */
protected generateFreshLoginHeaders(userId: UserId, cookieParams: Readonly<CookieParams>): Promise<Record<string, string[]>>;
/** Use these headers to log a user in. */
createLoginHeaders({ userId, requestHeaders, isSignUpCookie, }: {
userId: UserId;
requestHeaders: IncomingHttpHeaders;
isSignUpCookie: boolean;
}): Promise<OutgoingHttpHeaders>;
/** Combines `.getInsecureUser()` and `.getSecureUser()` into one method. */
getInsecureOrSecureUser(params: {
requestHeaders: IncomingHttpHeaders;
isSignUpCookie: boolean;
/**
* If true, this method will generate headers to refresh the user's auth session. This
* should likely only be done with a specific endpoint, like whatever endpoint you trigger
* with the frontend auth client's `checkUser.performCheck` callback.
*/
allowUserAuthRefresh: boolean;
/** Overrides the client's already established `serviceOrigin`. */
serviceOrigin?: string | undefined;
}): Promise<RequireOneOrNone<{
secureUser: GetUserResult<DatabaseUser>;
/**
* @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.
*/
insecureUser: GetUserResult<DatabaseUser>;
}>>;
/**
* @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.
*/
getInsecureUser({ requestHeaders, allowUserAuthRefresh, }: {
requestHeaders: IncomingHttpHeaders;
/**
* If true, this method will generate headers to refresh the user's auth session. This
* should likely only be done with a specific endpoint, like whatever endpoint you trigger
* with the frontend auth client's `checkUser.performCheck` callback.
*/
allowUserAuthRefresh: boolean;
}): Promise<GetUserResult<DatabaseUser> | undefined>;
}