Harden WebBFF and deployment release behavior

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.
This commit is contained in:
ms2k 2026-07-20 17:09:26 +09:00
commit 414b380516
6 changed files with 980 additions and 136 deletions

View file

@ -4,6 +4,7 @@ import {
equalSecret,
expiredSessionCookie,
randomValue,
SESSION_RENEWAL_PENDING,
sessionCookie,
sessionIdentifier,
} from './sessions.mjs'
@ -11,6 +12,8 @@ import {
const CLOCK_SKEW_SECONDS = 30
const OIDC_TIMEOUT_MILLISECONDS = 15_000
export class SessionRenewalPendingError extends Error {}
export class OAuthClient {
constructor(config, store, fetchImplementation = fetch) {
this.config = config
@ -127,23 +130,16 @@ export class OAuthClient {
)
return true
}
json(
response,
200,
{
kind: 'authenticated',
identity: loaded.session.identity,
csrfToken: loaded.session.csrfToken,
},
loaded.rotated
? { 'Set-Cookie': sessionCookie(loaded.identifier, this.config) }
: {},
)
json(response, 200, {
kind: 'authenticated',
identity: loaded.session.identity,
csrfToken: loaded.session.csrfToken,
})
return true
}
async logout(request, response) {
const loaded = await this.authenticated(request, false)
const loaded = await this.storedSession(request)
if (
loaded === null ||
!validUnsafeRequest(request, this.config, loaded.session.csrfToken)
@ -173,29 +169,58 @@ export class OAuthClient {
}
async authenticated(request, refresh = true) {
const loaded = await this.storedSession(request)
if (loaded === null) return null
const { identifier } = loaded
let { session } = loaded
if (session.accessExpiresAt <= now() + CLOCK_SKEW_SECONDS) {
if (!refresh) {
if (session.accessExpiresAt <= now()) {
session = await this.store.renew(
identifier,
(current) =>
validAuthenticatedSession(current) &&
current.accessExpiresAt <= now(),
async () => null,
)
if (session === SESSION_RENEWAL_PENDING)
throw new SessionRenewalPendingError()
if (!validAuthenticatedSession(session)) return null
}
} else {
const renewed = await this.store.renew(
identifier,
(current) =>
validAuthenticatedSession(current) &&
current.accessExpiresAt <= now() + CLOCK_SKEW_SECONDS,
(current) => this.refresh(current).catch(() => null),
)
if (renewed === SESSION_RENEWAL_PENDING) {
if (session.accessExpiresAt <= now())
throw new SessionRenewalPendingError()
} else {
session = renewed
}
if (!validAuthenticatedSession(session)) {
if (session?.kind === 'authenticated')
await this.store.destroy(identifier)
return null
}
}
}
return { identifier, session }
}
async storedSession(request) {
const identifier = sessionIdentifier(request)
if (identifier === null) return null
let session = await this.store.read(identifier)
const session = await this.store.read(identifier)
if (!validAuthenticatedSession(session)) {
if (session?.kind === 'authenticated')
await this.store.destroy(identifier)
return null
}
let rotated = false
if (session.accessExpiresAt <= now() + CLOCK_SKEW_SECONDS) {
if (!refresh) {
await this.store.destroy(identifier)
return null
}
session = await this.refresh(session).catch(() => null)
if (session === null) {
await this.store.destroy(identifier)
return null
}
identifier = await this.store.replace(identifier, session)
rotated = true
}
return { identifier, session, rotated }
return { identifier, session }
}
async refresh(session) {

View file

@ -2,8 +2,8 @@ import assert from 'node:assert/strict'
import { createSign, generateKeyPairSync } from 'node:crypto'
import { test } from 'node:test'
import { OAuthClient } from './oauth.mjs'
import { SESSION_COOKIE } from './sessions.mjs'
import { OAuthClient, SessionRenewalPendingError } from './oauth.mjs'
import { SESSION_COOKIE, SESSION_RENEWAL_PENDING } from './sessions.mjs'
const identifier = 'a'.repeat(43)
const replacement = 'b'.repeat(43)
@ -163,9 +163,88 @@ test('expired and malformed authenticated sessions are destroyed', async () => {
assert.deepEqual(store.destroyed, [identifier, identifier])
})
test('token renewal preserves the session identifier and CSRF value', async () => {
const store = new MemoryStore()
store.value = {
...validSession(),
refreshToken: 'refresh-token',
accessExpiresAt: now + 20,
}
const oauth = new OAuthClient(config, store)
oauth.refresh = async (session) => ({
...session,
accessToken: 'renewed-access-token',
accessExpiresAt: now + 300,
})
const loaded = await oauth.authenticated({
headers: { cookie: `${SESSION_COOKIE}=${identifier}` },
})
assert.equal(loaded.identifier, identifier)
assert.equal(loaded.session.accessToken, 'renewed-access-token')
assert.equal(loaded.session.csrfToken, 'csrf-value')
assert.equal(store.replaced, 0)
})
test('concurrent renewal uses only an unexpired access token', async () => {
const store = new MemoryStore()
store.pending = true
store.value = {
...validSession(),
refreshToken: 'refresh-token',
accessExpiresAt: now + 20,
}
const oauth = new OAuthClient(config, store)
const request = {
headers: { cookie: `${SESSION_COOKIE}=${identifier}` },
}
assert.equal(
(await oauth.authenticated(request)).session.accessToken,
'access-token',
)
store.value.accessExpiresAt = now - 1
await assert.rejects(
oauth.authenticated(request, false),
SessionRenewalPendingError,
)
assert.deepEqual(store.destroyed, [])
})
test('logout destroys an expired session during owned renewal', async () => {
const store = new MemoryStore()
store.pending = true
store.value = {
...validSession(),
accessExpiresAt: now - 1,
}
const oauth = new OAuthClient(config, store)
oauth.metadata = async () => ({})
const response = new TestResponse()
await oauth.logout(
{
headers: {
cookie: `${SESSION_COOKIE}=${identifier}`,
origin: config.publicOrigin,
'sec-fetch-site': 'same-origin',
'x-remora-csrf': 'csrf-value',
},
},
response,
)
assert.equal(response.status, 200)
assert.deepEqual(store.destroyed, [identifier])
assert.match(response.headers['Set-Cookie'], /Max-Age=0/)
})
class MemoryStore {
value = null
destroyed = []
replaced = 0
pending = false
async create(value) {
this.value = value
@ -177,10 +256,23 @@ class MemoryStore {
}
async replace(_identifier, value) {
this.replaced += 1
this.value = value
return replacement
}
async renew(identifier, shouldRenew, renewal) {
if (!shouldRenew(this.value)) return this.value
if (this.pending) return SESSION_RENEWAL_PENDING
const renewed = await renewal(this.value)
if (renewed === null) {
await this.destroy(identifier)
return null
}
this.value = renewed
return this.value
}
async destroy(identifier) {
this.destroyed.push(identifier)
this.value = null

View file

@ -1,7 +1,7 @@
import { WebSocket, WebSocketServer } from 'ws'
import { validBrowserContext, validUnsafeRequest } from './oauth.mjs'
import { equalSecret, sessionCookie } from './sessions.mjs'
import { equalSecret } from './sessions.mjs'
const MAX_REQUEST_BYTES = 1024 * 1024
const MAX_EVENT_BYTES = 1024 * 1024
@ -90,11 +90,6 @@ export class ApiProxy {
signal: AbortSignal.timeout(API_TIMEOUT_MILLISECONDS),
})
const responseHeaders = { 'Cache-Control': 'no-store' }
if (loaded.rotated)
responseHeaders['Set-Cookie'] = sessionCookie(
loaded.identifier,
this.config,
)
for (const name of ['content-type', 'traceparent'])
if (upstreamResponse.headers.has(name))
responseHeaders[name] = upstreamResponse.headers.get(name)
@ -136,6 +131,17 @@ export class ApiProxy {
})
return true
}
close() {
for (const browser of this.webSockets.clients) {
browser.close(1012, 'service restart')
const timeout = setTimeout(() => {
if (browser.readyState !== WebSocket.CLOSED) browser.terminate()
}, 1_000)
timeout.unref()
}
this.webSockets.close()
}
}
function copyHeader(request, headers, name) {
@ -169,6 +175,7 @@ function relay(browser, upstream) {
}
const closeUpstream = (code = 1011, reason = '') => {
if (upstream.readyState === WebSocket.OPEN) upstream.close(code, reason)
else if (upstream.readyState === WebSocket.CONNECTING) upstream.terminate()
}
upstream.on('close', closeBrowser)
browser.on('close', closeUpstream)

View file

@ -114,7 +114,6 @@ test('event ticket requests use the validated public origin upstream', async ()
},
{
authenticated: async () => ({
rotated: false,
session: {
accessToken: 'server-token',
csrfToken: 'csrf-value',

View file

@ -3,6 +3,7 @@ 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 })) {
@ -45,6 +46,56 @@ export class SessionStore {
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))
}
@ -52,6 +103,14 @@ export class SessionStore {
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) {

View file

@ -0,0 +1,186 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { SESSION_RENEWAL_PENDING, SessionStore } from './sessions.mjs'
const identifier = 'a'.repeat(43)
const config = {
sessionNamespace: 'remora:test:',
sessionTtlSeconds: 300,
}
test('serializes renewal across session store users', async () => {
const client = new FakeRedis()
const store = new SessionStore(config, client)
const original = { kind: 'authenticated', accessToken: 'old' }
await client.set(store.key(identifier), JSON.stringify(original))
const blocked = deferred()
let renewals = 0
const renewal = async () => {
renewals += 1
await blocked.promise
return { kind: 'authenticated', accessToken: 'new' }
}
const shouldRenew = (session) => session.accessToken === 'old'
const first = store.renew(identifier, shouldRenew, renewal)
await waitFor(() => renewals === 1)
const second = await store.renew(identifier, shouldRenew, renewal)
assert.equal(second, SESSION_RENEWAL_PENDING)
blocked.resolve()
const left = await first
assert.deepEqual(left, { kind: 'authenticated', accessToken: 'new' })
assert.equal(renewals, 1)
assert.deepEqual(await store.read(identifier), left)
assert.deepEqual(await store.renew(identifier, shouldRenew, renewal), left)
assert.equal(renewals, 1)
})
test('logout fences an in-flight renewal from restoring the session', async () => {
const client = new FakeRedis()
const store = new SessionStore(config, client)
await client.set(
store.key(identifier),
JSON.stringify({ kind: 'authenticated', accessToken: 'old' }),
)
const blocked = deferred()
let started = false
const renewing = store.renew(
identifier,
() => true,
async () => {
started = true
await blocked.promise
return { kind: 'authenticated', accessToken: 'new' }
},
)
await waitFor(() => started)
await store.destroy(identifier)
blocked.resolve()
assert.equal(await renewing, null)
assert.equal(await store.read(identifier), null)
})
test('ambiguous commit retains renewal ownership', async () => {
const client = new FakeRedis()
const store = new SessionStore(config, client)
await client.set(
store.key(identifier),
JSON.stringify({ accessToken: 'old' }),
)
client.failCommit = true
await assert.rejects(
store.renew(
identifier,
() => true,
async () => ({ accessToken: 'new' }),
),
/commit unavailable/,
)
assert.equal(client.values.has(store.lockKey(identifier)), true)
assert.equal(
await store.renew(
identifier,
() => true,
async () => {
throw new Error('duplicate provider refresh')
},
),
SESSION_RENEWAL_PENDING,
)
})
test('ambiguous destruction retains renewal ownership', async () => {
const client = new FakeRedis()
const store = new SessionStore(config, client)
await client.set(
store.key(identifier),
JSON.stringify({ accessToken: 'old' }),
)
client.failDeleteKey = store.key(identifier)
await assert.rejects(
store.renew(
identifier,
() => true,
async () => null,
),
/deletion unavailable/,
)
assert.equal(client.values.has(store.lockKey(identifier)), true)
assert.equal(
await store.renew(
identifier,
() => true,
async () => {
throw new Error('duplicate provider refresh')
},
),
SESSION_RENEWAL_PENDING,
)
})
class FakeRedis {
values = new Map()
isOpen = true
failCommit = false
failDeleteKey = null
async get(key) {
return this.values.get(key) ?? null
}
async set(key, value, options = {}) {
if (options.NX && this.values.has(key)) return null
this.values.set(key, value)
return 'OK'
}
async del(...keys) {
if (keys.includes(this.failDeleteKey))
throw new Error('deletion unavailable')
let deleted = 0
for (const key of keys) deleted += Number(this.values.delete(key))
return deleted
}
async eval(_script, { keys, arguments: values }) {
if (keys.length === 2 && values.length === 1) {
if (!this.values.has(keys[1]) || this.values.has(keys[0])) return 0
this.values.set(keys[0], values[0])
return 1
}
if (keys.length === 2) {
if (this.failCommit) throw new Error('commit unavailable')
if (this.values.get(keys[0]) !== values[0] || !this.values.has(keys[1]))
return 0
this.values.set(keys[1], values[1])
return 1
}
if (this.values.get(keys[0]) !== values[0]) return 0
this.values.delete(keys[0])
return 1
}
async quit() {
this.isOpen = false
}
}
function deferred() {
let resolve
const promise = new Promise((complete) => {
resolve = complete
})
return { promise, resolve }
}
async function waitFor(predicate) {
while (!predicate()) await new Promise((resolve) => setTimeout(resolve, 1))
}