UNPKG

auth-vir

Version:

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

290 lines (289 loc) 13.4 kB
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>; }