import * as jose from 'jose' import { JWTExpired } from 'jose/errors' /** * JWE Key Management Algorithm * * @see {@link https://github.com/panva/jose/issues/210#jwe-alg} */ const ALG = 'dir' // Direct Encryption Mode with a shared secret /** * JWE Content Encryption Algorithm * * @see {@link https://github.com/panva/jose/issues/210#jwe-enc} */ const ENC = 'A256GCM' // Requires a 256 bit (32 byte) secret const BYTE_LENGTH = 32 /** Digest algorithm */ const HKDF_ALGORITHM: HkdfParams = { hash: 'SHA-256', /** Additional information to derive the encryption key */ info: new TextEncoder().encode('session jwe cek'), name: 'HKDF', salt: new Uint8Array(0), } async function hdkf(secret: string) { const ikm = new TextEncoder().encode(secret) const length = BYTE_LENGTH << 3 const key = await crypto.subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits']) return new Uint8Array(await crypto.subtle.deriveBits(HKDF_ALGORITHM, key, length)) } export type EncryptionKey = jose.CryptoKey | jose.KeyObject | jose.JWK | Uint8Array /** * Create an encryption key from a shared secret or return the existing encryption key. */ export async function createEncryptionKey(secret: EncryptionKey | string): Promise { if (typeof secret === 'string') { return hdkf(secret) } return secret } export interface CookiePayload extends jose.JWTPayload {} export interface DecryptResult extends Partial> { /** * Indicates that the JWT has expired. */ expired: jose.errors.JWTExpired | undefined } /** * Decrypt and validate the JWE string */ export async function jweDecrypt( jwt: string, key: EncryptionKey, options?: jose.JWTDecryptOptions ): Promise> { let expired let result try { result = await jose.jwtDecrypt(jwt, key, options) } catch (error) { if (error instanceof JWTExpired) { expired = error } else { // Ignore other errors when decrypting the cookie, eg; // when the cookie is invalid. console.error(error) } } return { expired, ...result } } /** * Encrypt the cookie payload as a JWE string * * @returns the JWE string and the max age of the session cookie. */ export async function jweEncrypt( payload: CookiePayload, key: EncryptionKey, duration?: MaxAgeDuration ): Promise<{ jwe: string; maxAge?: number }> { const now = epoch() const iat = payload.iat ?? now const jwt = new jose.EncryptJWT(payload) .setIssuedAt(iat) .setProtectedHeader({ enc: ENC, alg: ALG }) let maxAge if (duration) { const exp = calculateExpiration(iat, now, duration) maxAge = Math.max(0, exp - now) jwt.setExpirationTime(exp) } const jwe = await jwt.encrypt(key) return { jwe, maxAge } } /** * Generates a random byte hex string, encoded with base64. * * See [Generating random values](https://thecopenhagenbook.com/random-values) */ export function generateId(length = 20): string { const bytes = new Uint8Array(length) crypto.getRandomValues(bytes) // TODO: return bytes.toBase64() return btoa(Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')) } /** * Time since unix epoch in seconds. */ export function epoch(date: Date = new Date()): number { return Math.floor(date.getTime() / 1000) } export interface MaxAgeDuration { /** * Duration a session will be valid for, * after which it will have to be re-authenticated. */ absolute: number /** * Duration a session will be considered active, * during which the session max age can be extended. */ inactivity?: number } /** * Calculate the expiration of the session cookie. * * Either the: * - last updated time + inactivity duration * - created time + absolute duration * * whichever is sooner */ function calculateExpiration(createdAt: number, updatedAt: number, duration: MaxAgeDuration) { if (duration.inactivity === undefined) { return createdAt + duration.absolute } return Math.min(updatedAt + duration.inactivity, createdAt + duration.absolute) }