auth-vir
Version:
Auth made easy and secure via JWT cookies, CSRF tokens, and password hashing helpers.
244 lines (223 loc) • 8.09 kB
text/typescript
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;
}
}