UNPKG

@trifrost/core

Version:

Blazingly fast, runtime-agnostic server framework for modern edge and node environments

248 lines (247 loc) 10.1 kB
import { isDate, addUTC, diff } from '@valkyriestudios/utils/date'; import { isNeString } from '@valkyriestudios/utils/string'; import { importKey, utf8Encode } from '../utils/Crypto'; /** * The below regexes validate name and values comply with cookie standards * eg: No control/illegal characters like semicolons */ const RGX_NAME = /^[a-zA-Z0-9!#$%&'*+\-.^_`|~]+$/; const RGX_VALUE = /^[\x20-\x7E]*$/; const HMACAlgos = { 'SHA-256': true, 'SHA-384': true, 'SHA-512': true, }; export class Cookies { #ctx; /* Global cookie defaults */ #config; /* Incoming cookies from request */ #incoming; /* Outgoing cookies */ #outgoing = {}; /* Incoming/Outgoing values (for usage in eg .all or .get) */ #combined = {}; constructor(ctx, config = {}) { this.#ctx = ctx; this.#config = config || {}; /* Process cookie header into map */ const cookies = typeof ctx.headers.cookie === 'string' ? ctx.headers.cookie.split(';') : []; const map = {}; for (let i = 0; i < cookies.length; i++) { const raw = cookies[i]; /* We don't use split here as the following could be a valid cookie x=1=2=3 which would be {x: '1=2=3'} */ const idx = raw.indexOf('='); if (idx <= 0) continue; const n_key = raw.slice(0, idx).trim(); const val = raw.slice(idx + 1); const n_val = val.length ? decodeURIComponent(val).trim() : null; if (n_key && n_val) map[n_key] = n_val; } this.#incoming = map; this.#combined = { ...this.#incoming }; } get outgoing() { return Object.values(this.#outgoing); } /** * Returns all cookies. Both the ones provided by the client as a KV-Map AND the cookies that are going to be passed to the client */ all() { return { ...this.#combined }; } /** * Get a cookie value by name * * @param {string} name - Cookie name * @returns Cookie value or null if not found */ get(name) { return this.#combined[name] ?? null; } /** * Set a cookie. * * Take Note: * - If both maxage and expires are passed we will ignore maxage and set both based on the expires bit * - If either maxage OR expires are passed we will set the other based on the passed value (maxage -> expires, expires -> maxage) * - We will always set secure UNLESS explicitly turned off * - We will always set secure if SameSite=None regardless of passed configuration * * @param {string} name - The cookie name. * @param {string|number} value - The cookie value. * @param {TriFrostCookieOptions} options - Cookie options (e.g., max-age, path, etc.). */ set(name, value, options = {}) { const normalized = Number.isFinite(value) ? String(value) : value; const config = { domain: this.#ctx.domain, ...this.#config, ...options, }; /* Validate */ if (typeof normalized !== 'string' || !RGX_NAME.test(name) || !RGX_VALUE.test(normalized)) return this.#ctx.logger.error('TriFrostCookies@set: Invalid name or value', { name, value, options }); /* Start cookie construction */ let new_cookie = name + '=' + encodeURIComponent(normalized); const maxage = Number.isInteger(config.maxage) ? config.maxage : null; const expires = isDate(config.expires) ? config.expires : null; /* Max Age */ if (expires === null && maxage !== null) { /* Set expires based on max-age if not provided */ new_cookie += '; Expires=' + addUTC(new Date(), maxage, 'seconds').toUTCString() + '; Max-Age=' + maxage; } /* Expires */ if (expires !== null) { new_cookie += '; Expires=' + expires.toUTCString(); /* Set maxage based on expires if not provided */ if (maxage === null) new_cookie += '; Max-Age=' + Math.ceil(diff(expires, new Date(), 'seconds')); } /* Path */ if (typeof config.path === 'string') new_cookie += '; Path=' + config.path; /* Domain */ if (typeof config.domain === 'string') new_cookie += '; Domain=' + config.domain; /* Secure */ if (config.secure !== false) new_cookie += '; Secure'; /* HttpOnly */ if (config.httponly === true) new_cookie += '; HttpOnly'; /* SameSite */ if (typeof config.samesite === 'string') { new_cookie += '; SameSite=' + config.samesite; /* If samesite 'None', ensure ALWAYS secure */ if (config.samesite === 'None' && config.secure === false) { this.#ctx.logger.warn('TriFrostCookies@set: SameSite=None requires Secure=true; overriding to ensure security'); new_cookie += '; Secure'; } } /* Push into new cookies */ this.#outgoing[name] = new_cookie; /* Set on combined */ this.#combined[name] = normalized; } /** * Sign a value with an HMAC signature * * @param {string|number} val - Value to sign * @param {string} secret - Signing secret * @param {TriFrostCookieSigningOptions} options - Options for signing (defaults to {algorithm: 'SHA-256'}) */ async sign(val, secret, options = { algorithm: 'SHA-256' }) { if (!isNeString(secret) || (typeof val !== 'string' && !Number.isFinite(val))) return ''; const sig = await this.generateHMAC(String(val), secret, options); return val + '.' + sig; } /** * Verifies a signed cookie has not been tampered with, returns the value if untampered * * @param {string} signed - Signed cookie value * @param {string|(string|(TriFrostCookieSigningOptions & {val:string}))[]} secrets - Secret or Secrets to check * @param {TriFrostCookieSigningOptions} options - Options for verifying (defaults to {algorithm: 'SHA-256'}) */ async verify(signed, secrets, options = { algorithm: 'SHA-256' }) { const idx = typeof signed === 'string' ? signed.lastIndexOf('.') : -1; if (idx === -1) return null; const val = signed.slice(0, idx); const sig = signed.slice(idx + 1); for (const secret of Array.isArray(secrets) ? secrets : [secrets]) { if (isNeString(secret)) { const expected_sig = await this.generateHMAC(val, secret, options); if (expected_sig === sig) return val; } else if (isNeString(secret?.val)) { const expected_sig = await this.generateHMAC(val, secret.val, isNeString(secret?.algorithm) ? { algorithm: secret.algorithm } : options); if (expected_sig === sig) return val; } } this.#ctx.logger.warn('TriFrostCookies@verify: Signature mismatch'); return null; } /** * Delete a cookie by name. Take note that the path/domain for a cookie need to be correct for it to be deleted * * @param {string|{prefix:string}} val - Name of the cookie to delete or the prefix of the cookies to delete * @param {Partial<TriFrostCookieDeleteOptions>} options - Cookie Delete options (path, domain) */ del(val, options = {}) { if (isNeString(val)) { return this.internalDel(val, { ...options, maxage: 0 }); } else if (isNeString(val?.prefix)) { const normalized_options = { ...options, maxage: 0 }; /* 1. Remove any newly-set cookies */ for (const key in this.#outgoing) { if (!(key in this.#incoming) && key.startsWith(val.prefix)) { delete this.#outgoing[key]; delete this.#combined[key]; } } /* 2. Expire any client-passed cookies */ for (const key in this.#incoming) { if (key.startsWith(val.prefix)) this.internalDel(key, normalized_options); } } } /** * Delete all cookies (both outgoing AND ones that were passed by the client) * * @param {Partial<TriFrostCookieDeleteOptions>} options - Cookie Delete options (path, domain) */ delAll(options = {}) { const normalized_options = { ...options, maxage: 0 }; /* 1. Remove any newly-set cookies */ for (const key in this.#outgoing) { delete this.#outgoing[key]; delete this.#combined[key]; } /* 2. Expire any client-passed cookies */ for (const key in this.#incoming) { this.internalDel(key, normalized_options); } } /** * MARK: Private */ internalDel(name, options = {}) { if (name in this.#outgoing) delete this.#outgoing[name]; if (name in this.#incoming) this.set(name, '', options); if (name in this.#combined) delete this.#combined[name]; } /** * Generates HMAC for a specific value and a secret * @param {string|number} data - Value to generate HMAC for * @param {string} secret - Signing secret * @param {TriFrostCookieSigningOptions} options - HMAC Options * @returns */ async generateHMAC(data, secret, options) { try { const algo = options?.algorithm in HMACAlgos ? options.algorithm : 'SHA-256'; const key = await importKey(secret, { name: 'HMAC', hash: { name: algo } }, ['sign']); const sig_buf = await crypto.subtle.sign('HMAC', key, utf8Encode(String(data))); const sig_arr = new Uint8Array(sig_buf); let hex = ''; for (let i = 0; i < sig_arr.length; i++) { hex += sig_arr[i].toString(16).padStart(2, '0'); } return hex; } catch { return null; } } }