auth-vir
Version:
Auth made easy and secure via JWT cookies, CSRF tokens, and password hashing helpers.
219 lines (196 loc) • 6.44 kB
text/typescript
import {
clearAuthCookie,
type CookieParams,
extractCookieJwt,
generateAuthCookie,
} from './cookie.js';
import {csrfTokenHeaderName, generateCsrfToken} from './csrf-token.js';
import {type ParseJwtParams} from './jwt.js';
/**
* All possible headers container types supported by {@link extractUserIdFromRequestHeaders}.
*
* @category Internal
*/
export type HeaderContainer = Record<string, string[] | undefined | string | number> | Headers;
function readHeader(headers: HeaderContainer, headerName: string): string | undefined {
if (headers instanceof Headers) {
return headers.get(headerName) || undefined;
} else {
const value = headers[headerName];
if (value == undefined) {
return undefined;
} else if (Array.isArray(value)) {
return value[0];
} else {
return String(value);
}
}
}
/**
* Extract the user id from a request by checking both the request cookie and CSRF token. This is
* used by host (backend) code to help verify a request. After extracting the user id using this,
* you should compare it to users stored in your database.
*
* @category Auth : Host
* @returns The extracted user id or `undefined` if no valid auth headers exist.
*/
export async function extractUserIdFromRequestHeaders(
headers: HeaderContainer,
jwtParams: Readonly<ParseJwtParams>,
cookieName?: string | undefined,
): Promise<string | undefined> {
try {
const csrfToken = readHeader(headers, csrfTokenHeaderName);
const cookie = readHeader(headers, 'cookie');
if (!cookie || !csrfToken) {
return undefined;
}
const jwt = await extractCookieJwt(cookie, jwtParams, cookieName);
if (!jwt || jwt.csrfToken !== csrfToken) {
return undefined;
}
return jwt.userId;
} catch {
return undefined;
}
}
/**
* Extract a user id from just the cookie, without CSRF token validation. This is _less secure_ than
* {@link extractUserIdFromRequestHeaders} as a result. This should only be used in rare
* circumstances where you cannot rely on client-side JavaScript to insert the CSRF token.
*
* @deprecated Prefer {@link extractUserIdFromRequestHeaders} instead: it is more secure.
*/
export async function extractUserIdFromCookieAlone(
headers: HeaderContainer,
jwtParams: Readonly<ParseJwtParams>,
cookieName?: string | undefined,
): Promise<string | undefined> {
try {
const cookie = readHeader(headers, 'cookie');
if (!cookie) {
return undefined;
}
const jwt = await extractCookieJwt(cookie, jwtParams, cookieName);
if (!jwt) {
return undefined;
}
return jwt.userId;
} catch {
return undefined;
}
}
/**
* Used by host (backend) code to set headers on a response object.
*
* @category Auth : Host
*/
export async function generateSuccessfulLoginHeaders(
/** The id from your database of the user you're authenticating. */
userId: string,
cookieConfig: Readonly<CookieParams>,
) {
const csrfToken = generateCsrfToken();
return {
'set-cookie': await generateAuthCookie(
{
csrfToken,
userId,
},
cookieConfig,
),
[csrfTokenHeaderName]: csrfToken,
};
}
/**
* Used by host (backend) code to set headers on a response object when the user has logged out or
* failed to authorize.
*
* @category Auth : Host
*/
export function generateLogoutHeaders(...params: Parameters<typeof clearAuthCookie>) {
return {
'set-cookie': clearAuthCookie(...params),
[csrfTokenHeaderName]: 'redacted',
};
}
/**
* Store auth data on a client (frontend) after receiving an auth response from the host (backend).
* Specifically, this stores the CSRF token into local storage (which doesn't need to be a secret).
* Alternatively, if the given response failed, this will wipe the existing (if anyone) stored CSRF
* token.
*
* @category Auth : Client
* @throws Error if no CSRF token header is found.
*/
export function handleAuthResponse(
response: Readonly<Pick<Response, 'ok' | 'headers'>>,
overrides: {
/**
* Allows mocking or overriding the global `localStorage`.
*
* @default globalThis.localStorage
*/
localStorage?: Pick<Storage, 'setItem' | 'removeItem'>;
/** Override the default CSRF token header name. */
csrfHeaderName?: string;
} = {},
) {
if (!response.ok) {
wipeCurrentCsrfToken(overrides);
return;
}
const headerName = overrides.csrfHeaderName || csrfTokenHeaderName;
const csrfToken = response.headers.get(headerName);
if (!csrfToken) {
wipeCurrentCsrfToken(overrides);
throw new Error('Did not receive any CSRF token.');
}
(overrides.localStorage || globalThis.localStorage).setItem(headerName, csrfToken);
}
/**
* Used in client (frontend) code to retrieve the current CSRF token in order to send it with
* requests to the host (backend).
*
* @category Auth : Client
*/
export function getCurrentCsrfToken(
overrides: {
/**
* Allows mocking or overriding the global `localStorage`.
*
* @default globalThis.localStorage
*/
localStorage?: Pick<Storage, 'getItem'>;
/** Override the default CSRF token header name. */
csrfHeaderName?: string;
} = {},
): string | undefined {
return (
(overrides.localStorage || globalThis.localStorage).getItem(
overrides.csrfHeaderName || csrfTokenHeaderName,
) || undefined
);
}
/**
* Wipes the current stored CSRF token. This should be used by client (frontend) code to logout a
* user or react to a session timeout.
*
* @category Auth : Client
*/
export function wipeCurrentCsrfToken(
overrides: {
/**
* Allows mocking or overriding the global `localStorage`.
*
* @default globalThis.localStorage
*/
localStorage?: Pick<Storage, 'removeItem'>;
/** Override the default CSRF token header name. */
csrfHeaderName?: string;
} = {},
) {
return (overrides.localStorage || globalThis.localStorage).removeItem(
overrides.csrfHeaderName || csrfTokenHeaderName,
);
}