UNPKG

auth-vir

Version:

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

244 lines (223 loc) 8.09 kB
import { type createBlockingInterval, HttpStatus, type JsonCompatibleObject, type MaybePromise, type PartialWithUndefined, type SelectFrom, } from '@augment-vir/common'; import {type AnyDuration} from 'date-vir'; import {listenToActivity} from 'detect-activity'; import {type EmptyObject} from 'type-fest'; import { type CsrfHeaderNameOption, getCurrentCsrfToken, resolveCsrfHeaderName, } from '../csrf-token.js'; import {AuthHeaderName} from '../headers.js'; /** * Config for {@link FrontendAuthClient}. * * @category Internal */ export type FrontendAuthClientConfig = Readonly<{ csrf: Readonly<CsrfHeaderNameOption>; }> & PartialWithUndefined<{ /** * Optional suffix appended to cookie names (e.g., `'staging'` produces * `auth-vir-csrf-staging`). When `undefined`, cookie names are unchanged. */ cookieNameSuffix: string; /** * Determine if the current user can assume the identity of another user. If this is not * defined, all users will be blocked from assuming other user identities. */ canAssumeUser: () => MaybePromise<boolean>; /** Called whenever the current user becomes unauthorized and their CSRF token is wiped. */ authClearedCallback: () => MaybePromise<void>; /** * Performs automatic checks on an interval to see if the user is still authenticated. Omit * this to turn off automatic checks. */ checkUser: { /** * Get a response from the backend to see if the user is still authenticated. If the * response returns a non-authorized status, the user is wiped. Any other status is * ignored. * * If the user is not currently authorized, this should return `undefined` to prevent * unnecessary network traffic. * * This will be called any time the user interacts with the page, debounced by the * adjacent `debounce` property. */ performCheck: () => MaybePromise< | SelectFrom< Response, { status: true; } > | undefined >; /** * Debounce for firing `performCheck`. * * @default {minutes: 1} */ debounce?: AnyDuration | undefined; }; /** * Overwrite the header name used for tracking is an admin is assuming the identity of * another user. */ assumedUserHeaderName: string; overrides: PartialWithUndefined<{ localStorage: SelectFrom<Storage, {setItem: true; removeItem: true; getItem: true}>; }>; }>; /** * An auth client for sending and validating client requests to a backend. This should only be used * in a frontend environment as it accesses native browser APIs. * * @category Auth : Client * @category Clients */ export class FrontendAuthClient<AssumedUserParams extends JsonCompatibleObject = EmptyObject> { protected userCheckInterval: undefined | ReturnType<typeof createBlockingInterval>; /** Used to clean up the activity listener on `.destroy()`. */ protected removeActivityListener: VoidFunction | undefined; constructor(protected readonly config: FrontendAuthClientConfig) { if (config.checkUser) { this.removeActivityListener = listenToActivity({ listener: async () => { const response = await config.checkUser?.performCheck(); if (response) { await this.verifyResponseAuth({ status: response.status, }); } }, debounce: config.checkUser.debounce || { minutes: 1, }, fireImmediately: false, }); } } /** * Destroys the client and performs all necessary cleanup (like clearing the user check * interval). */ public destroy() { this.userCheckInterval?.clearInterval(); this.removeActivityListener?.(); } /** * Assume the given user. Pass `undefined` to wipe the currently assumed user. * * @returns Whether the assumed user setting or clearing succeeded or not. */ public async assumeUser( assumedUserParams: Readonly<AssumedUserParams> | undefined, ): Promise<boolean> { const localStorage = this.config.overrides?.localStorage || globalThis.localStorage; const storageKey = this.config.assumedUserHeaderName || AuthHeaderName.AssumedUser; if (!assumedUserParams) { localStorage.removeItem(storageKey); return true; } else if (!(await this.config.canAssumeUser?.())) { return false; } localStorage.setItem(storageKey, JSON.stringify(assumedUserParams)); return true; } /** Gets the assumed user params stored in local storage, if any. */ public getAssumedUser(): AssumedUserParams | undefined { const rawValue = (this.config.overrides?.localStorage || globalThis.localStorage).getItem( this.config.assumedUserHeaderName || AuthHeaderName.AssumedUser, ); if (!rawValue) { return undefined; } try { return JSON.parse(rawValue); } catch { return undefined; } } /** * Creates a `RequestInit` object for the `fetch` API. If you have other request init options, * use [`mergeDeep` from * `@augment-vir/common`](https://electrovir.github.io/augment-vir/functions/mergeDeep.html) to * combine them with these. */ public createAuthenticatedRequestInit(): RequestInit { const csrfToken = getCurrentCsrfToken(this.config.cookieNameSuffix); const assumedUser = this.getAssumedUser(); const headers: HeadersInit = { ...(csrfToken ? { [resolveCsrfHeaderName(this.config.csrf)]: csrfToken, } : {}), ...(assumedUser ? { [this.config.assumedUserHeaderName || AuthHeaderName.AssumedUser]: JSON.stringify(assumedUser), } : {}), }; return { headers, credentials: 'include', }; } /** Wipes the current user auth. */ public async logout() { await this.config.authClearedCallback?.(); } /** * Use to handle a login response. The CSRF token cookie is automatically stored by the browser * from the `Set-Cookie` response header. * * @throws Error if the login response failed. */ public async handleLoginResponse( response: Readonly<SelectFrom<Response, {ok: true}>>, ): Promise<void> { if (!response.ok) { await this.logout(); throw new Error('Login response failed.'); } } /** * Use to verify _all_ responses received from the backend. Immediately logs the user out once * an unauthorized response is detected. * * @returns `true` if the auth is okay, `false` otherwise. */ public async verifyResponseAuth( response: Readonly< PartialWithUndefined< SelectFrom< Response, { status: true; headers: true; } > > >, ): Promise<boolean> { if ( response.status === HttpStatus.Unauthorized && !response.headers?.get(AuthHeaderName.IsSignUpAuth) ) { await this.logout(); return false; } return true; } }