import { randomBytes, timingSafeEqual } from 'node:crypto' import { createClient } from 'redis' export const SESSION_COOKIE = '__Host-remora-session' export const SESSION_RENEWAL_PENDING = Symbol('session renewal pending') export class SessionStore { constructor(config, client = createClient({ url: config.sessionUrl })) { this.config = config this.client = client } async connect() { this.client.on('error', () => undefined) await this.client.connect() } get ready() { return this.client.isReady } async create(value) { const identifier = randomValue(32) await this.client.set(this.key(identifier), JSON.stringify(value), { EX: this.config.sessionTtlSeconds, }) return identifier } async read(identifier) { if (!validIdentifier(identifier)) return null const encoded = await this.client.get(this.key(identifier)) if (encoded === null) return null try { return JSON.parse(encoded) } catch { await this.destroy(identifier) return null } } async replace(identifier, value) { const replacement = await this.create(value) await this.destroy(identifier) return replacement } async renew(identifier, shouldRenew, renewal) { if (!validIdentifier(identifier)) return null const lock = this.lockKey(identifier) const owner = randomValue() const acquired = await this.client.eval( 'local ttl = redis.call("pttl", KEYS[2]) if ttl <= 0 then return 0 end if redis.call("set", KEYS[1], ARGV[1], "NX", "PX", ttl) then return 1 else return 0 end', { keys: [lock, this.key(identifier)], arguments: [owner], }, ) if (acquired !== 1) { const current = await this.read(identifier) if (current === null || !shouldRenew(current)) return current return SESSION_RENEWAL_PENDING } let releaseOwnership = true try { const current = await this.read(identifier) if (current === null || !shouldRenew(current)) return current releaseOwnership = false const renewed = await renewal(current) if (renewed === null) { await this.destroy(identifier) releaseOwnership = true return null } const stored = await this.client.eval( 'if redis.call("get", KEYS[1]) == ARGV[1] and redis.call("exists", KEYS[2]) == 1 then redis.call("set", KEYS[2], ARGV[2], "EX", ARGV[3]) return 1 else return 0 end', { keys: [lock, this.key(identifier)], arguments: [ owner, JSON.stringify(renewed), String(this.config.sessionTtlSeconds), ], }, ) if (stored !== 1) return null releaseOwnership = true return renewed } finally { if (releaseOwnership) await this.client.eval( 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end', { keys: [lock], arguments: [owner] }, ) } } async destroy(identifier) { if (validIdentifier(identifier)) await this.client.del(this.key(identifier)) } key(identifier) { return `${this.config.sessionNamespace}${identifier}` } lockKey(identifier) { return `${this.key(identifier)}:renew` } async close() { if (this.client.isOpen) await this.client.quit() } } export function sessionIdentifier(request) { const cookies = String(request.headers.cookie || '').split(';') for (const cookie of cookies) { const separator = cookie.indexOf('=') if (separator === -1) continue if (cookie.slice(0, separator).trim() === SESSION_COOKIE) return cookie.slice(separator + 1).trim() } return null } export function sessionCookie(identifier, config) { const secure = config.secureCookie ? '; Secure' : '' return `${SESSION_COOKIE}=${identifier}; Path=/; HttpOnly${secure}; SameSite=Strict` } export function expiredSessionCookie(config) { return `${sessionCookie('', config)}; Max-Age=0` } export function randomValue(bytes = 24) { return randomBytes(bytes).toString('base64url') } export function equalSecret(left, right) { const a = Buffer.from(String(left)) const b = Buffer.from(String(right)) return a.length === b.length && timingSafeEqual(a, b) } function validIdentifier(value) { return typeof value === 'string' && /^[A-Za-z0-9_-]{43}$/.test(value) }