UNPKG

auth-vir

Version:

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

268 lines (252 loc) 8.06 kB
import {check} from '@augment-vir/assert'; import { escapeStringForRegExp, safeMatch, type PartialWithUndefined, type SelectFrom, } from '@augment-vir/common'; import {convertDuration, type AnyDuration} from 'date-vir'; import {type Primitive} from 'type-fest'; import {parseUrl} from 'url-vir'; import {type CreateJwtParams, type ParseJwtParams, type ParsedJwt} from './jwt/jwt.js'; import {createUserJwt, parseUserJwt, type JwtUserData} from './jwt/user-jwt.js'; /** * Cookie header names supported by default. * * @category Internal */ export enum AuthCookie { /** Used for a full user login auth. */ Auth = 'auth', /** Use for a temporary "just signed up" auth. */ SignUp = 'sign-up', /** Used for storing the CSRF token. Not `HttpOnly` so that frontend JS can read it. */ Csrf = 'auth-vir-csrf', } /** * Resolves a cookie name by appending a suffix when provided. When `cookieNameSuffix` is * `undefined`, the base name is returned unchanged. * * @category Internal */ export function resolveCookieName( baseCookieName: AuthCookie, cookieNameSuffix?: string | undefined, ): string { return [ baseCookieName, cookieNameSuffix, ] .filter(check.isTruthy) .join('-'); } /** * Parameters for {@link generateAuthCookie}. * * @category Internal */ export type CookieParams = { /** * The origin of the host (backend) service that cookies will be included in all requests to. * This should be restricted to just your host (backend) origin for security purposes. * * @example 'https://www.example.com' */ hostOrigin: string; /** * The max duration of this cookie. Or, in other words, the max user session duration before * they're logged out. */ cookieDuration: AnyDuration; /** * All JWT parameters required for generating the encrypted JWT that will be embedded in the * Cookie. Note that all JWT keys contained herein should never shared with any frontend, * client, etc. */ jwtParams: Readonly<CreateJwtParams>; } & PartialWithUndefined<{ /** * Which auth cookie name to use. * * @default AuthCookie.Auth */ authCookie: AuthCookie; /** * Is set to `true` (which should only be done in development environments), the cookie will be * allowed in insecure requests (non HTTPS requests). * * @default false */ isDev: boolean; /** * Optional suffix appended to cookie names (e.g., `'staging'` produces `auth-staging`). When * `undefined`, cookie names are unchanged. Useful for running multiple environments on the same * domain without cookie collisions. */ cookieNameSuffix: string; }>; function generateSetCookie({ name, value, httpOnly, cookieConfig, }: { name: string; value: string; httpOnly: boolean; cookieConfig: Readonly<SelectFrom<CookieParams, {hostOrigin: true; isDev: true}>> & PartialWithUndefined<SelectFrom<CookieParams, {cookieDuration: true}>>; }): string { return generateCookie({ [name]: value, Domain: parseUrl(cookieConfig.hostOrigin).hostname, HttpOnly: httpOnly, Path: '/', SameSite: 'Strict', 'MAX-AGE': cookieConfig.cookieDuration ? convertDuration(cookieConfig.cookieDuration, { seconds: true, }).seconds : 0, Secure: !cookieConfig.isDev, }); } /** * Generate a secure cookie that stores the user JWT data. Used in host (backend) code. * * @category Internal */ export async function generateAuthCookie( userJwtData: Readonly<JwtUserData>, cookieConfig: Readonly<CookieParams>, ): Promise<string> { return generateSetCookie({ name: resolveCookieName( cookieConfig.authCookie || AuthCookie.Auth, cookieConfig.cookieNameSuffix, ), value: await createUserJwt(userJwtData, cookieConfig.jwtParams), httpOnly: true, cookieConfig, }); } /** * Generate a CSRF token cookie. This cookie is intentionally not `HttpOnly` so that frontend * JavaScript can read it and inject the value as a request header for double-submit verification. * * The CSRF cookie uses a fixed 400-day MAX-AGE rather than matching the auth cookie duration. 400 * days is the cross-browser safe maximum (Chrome caps cookie lifetimes at 400 days; other browsers * accept it as-is). The CSRF token is only meaningful when paired with a valid JWT, so it doesn't * need its own expiration management. It gets regenerated on every fresh login. * * @category Internal */ export function generateCsrfCookie( csrfToken: string, cookieConfig: Readonly<SelectFrom<CookieParams, {hostOrigin: true; isDev: true}>> & PartialWithUndefined<{cookieNameSuffix: string}>, ): string { return generateSetCookie({ name: resolveCookieName(AuthCookie.Csrf, cookieConfig.cookieNameSuffix), value: csrfToken, httpOnly: false, cookieConfig: { ...cookieConfig, cookieDuration: { days: 400, }, }, }); } /** * Generate a cookie value that will clear the previous auth cookie. Use this when signing out. * * @category Internal */ export function clearAuthCookie( cookieConfig: Readonly<SelectFrom<CookieParams, {hostOrigin: true; isDev: true}>> & PartialWithUndefined<{authCookie: AuthCookie; cookieNameSuffix: string}>, ) { return generateSetCookie({ name: resolveCookieName( cookieConfig.authCookie || AuthCookie.Auth, cookieConfig.cookieNameSuffix, ), value: 'redacted', httpOnly: true, cookieConfig, }); } /** * Generate a cookie value that will clear the CSRF token cookie. Use this when signing out. * * @category Internal */ export function clearCsrfCookie( cookieConfig: Readonly<SelectFrom<CookieParams, {hostOrigin: true; isDev: true}>> & PartialWithUndefined<{cookieNameSuffix: string}>, ) { return generateSetCookie({ name: resolveCookieName(AuthCookie.Csrf, cookieConfig.cookieNameSuffix), value: 'redacted', httpOnly: false, cookieConfig, }); } /** * Generate a cookie string from a raw set of parameters. * * @category Internal */ export function generateCookie( params: Readonly<Record<string, Exclude<Primitive, symbol>>>, ): string { return Object.entries(params) .map( ([ key, value, ]): string | undefined => { if (value == undefined || value === false) { return undefined; } else if (value === '' || value === true) { return key; } else { return [ key, value, ].join('='); } }, ) .filter(check.isTruthy) .join('; '); } /** * Extract an auth cookie from a cookie string. Used in host (backend) code. * * @category Internal * @returns The extracted auth Cookie JWT data or `undefined` if no valid auth JWT data was found. */ export async function extractCookieJwt({ rawCookie, jwtParams, cookieName, cookieNameSuffix, }: { rawCookie: string; jwtParams: Readonly<ParseJwtParams>; cookieName: AuthCookie; } & PartialWithUndefined<{ cookieNameSuffix: string; }>): Promise<undefined | ParsedJwt<JwtUserData>> { const resolvedName = resolveCookieName(cookieName, cookieNameSuffix); const cookieRegExp = new RegExp(`${escapeStringForRegExp(resolvedName)}=[^;]+(?:;|$)`); const [cookieValue] = safeMatch(rawCookie, cookieRegExp); if (!cookieValue) { return undefined; } const rawJwt = cookieValue.replace(`${resolvedName}=`, '').replace(';', ''); const jwt = await parseUserJwt(rawJwt, jwtParams); return jwt; }