Model fail-closed cross-replica session renewal and implement owner-fenced refresh, transient observer handling, logout fencing, and graceful shutdown. Remove chart-owned Ingress while retaining qualification-owned external routing.
148 lines
4.3 KiB
JavaScript
148 lines
4.3 KiB
JavaScript
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)
|
|
}
|