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
18 changed files with 980 additions and 136 deletions

View file

@ -1,39 +0,0 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "remora.fullname" . }}
labels:
{{- include "remora.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tlsSecretName }}
tls:
- hosts: [{{ .Values.ingress.host | quote }}]
secretName: {{ .Values.ingress.tlsSecretName }}
{{- end }}
rules:
- host: {{ .Values.ingress.host | quote }}
http:
paths:
- path: /v1
pathType: Prefix
backend:
service:
name: {{ include "remora.fullname" . }}
port:
name: http
- path: /
pathType: Prefix
backend:
service:
name: {{ include "remora.fullname" . }}-web
port:
name: http
{{- end }}

View file

@ -18,7 +18,6 @@
"vault",
"runtime",
"outbound",
"ingress",
"networkPolicy",
"observability",
"podDisruptionBudget"
@ -191,21 +190,6 @@
"maxRedirects": {"type": "integer", "minimum": 0, "maximum": 100}
}
},
"ingress": {
"type": "object",
"additionalProperties": false,
"required": ["enabled", "className", "annotations", "host", "tlsSecretName"],
"properties": {
"enabled": {"type": "boolean"},
"className": {"type": "string"},
"annotations": {"$ref": "#/$defs/stringMap"},
"host": {
"type": "string",
"pattern": "^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$"
},
"tlsSecretName": {"type": "string"}
}
},
"networkPolicy": {
"type": "object",
"additionalProperties": false,

View file

@ -124,13 +124,6 @@ outbound:
allowedCidrs: ""
maxRedirects: 5
ingress:
enabled: false
className: ""
annotations: {}
host: remora.example.com
tlsSecretName: ""
networkPolicy:
enabled: true
ingressNamespaceSelector: {}

View file

@ -0,0 +1,20 @@
SPECIFICATION FairSpec
CHECK_DEADLOCK FALSE
CONSTANTS
Replicas = {r1, r2}
NoReplica = NoReplica
AllowTakeover = FALSE
AllowAmbiguousRelease = FALSE
ObserverDestroysOwned = FALSE
INVARIANTS
TypeOK
ProviderRefreshSerialized
CommitIsOwnerFenced
ObserverPreservesOwnedRenewal
PROPERTIES
TerminalSessionIsPermanent
EventuallyTerminal

View file

@ -0,0 +1,276 @@
------------------------ MODULE WebSessionRenewal ------------------------
EXTENDS Naturals, FiniteSets, TLC
\* This protocol covers one WebSession refresh-token generation. Provider refresh
\* is external and cannot observe or honor the store's ownership fence.
CONSTANTS
Replicas, NoReplica,
AllowTakeover, AllowAmbiguousRelease, ObserverDestroysOwned
ASSUME
/\ Replicas # {}
/\ NoReplica \notin Replicas
/\ AllowTakeover \in BOOLEAN
/\ AllowAmbiguousRelease \in BOOLEAN
/\ ObserverDestroysOwned \in BOOLEAN
Active == "Active"
Renewed == "Renewed"
Destroyed == "Destroyed"
SessionStates == {Active, Renewed, Destroyed}
Up == "Up"
Down == "Down"
ReplicaStates == {Up, Down}
Idle == "Idle"
Submitted == "Submitted"
ProviderUnknown == "ProviderUnknown"
Returned == "Returned"
CommitUnknown == "CommitUnknown"
Committed == "Committed"
RequestStates ==
{Idle, Submitted, ProviderUnknown, Returned, CommitUnknown, Committed}
AccessValid == "AccessValid"
AccessExpired == "AccessExpired"
AccessStates == {AccessValid, AccessExpired}
NoObservation == "NoObservation"
UsedCurrent == "UsedCurrent"
Unavailable == "Unavailable"
ObservedSignout == "ObservedSignout"
Observations ==
{NoObservation, UsedCurrent, Unavailable, ObservedSignout}
VARIABLES
sessionState, owner, replicaState, requestState, committedBy,
accessState, observation, observerDestroyedOwned
vars ==
<<sessionState, owner, replicaState, requestState, committedBy,
accessState, observation, observerDestroyedOwned>>
Init ==
/\ sessionState = Active
/\ owner = NoReplica
/\ replicaState = [r \in Replicas |-> Up]
/\ requestState = [r \in Replicas |-> Idle]
/\ committedBy = NoReplica
/\ accessState = AccessValid
/\ observation = NoObservation
/\ observerDestroyedOwned = FALSE
Acquire(r) ==
/\ sessionState = Active
/\ owner = NoReplica
/\ replicaState[r] = Up
/\ requestState[r] = Idle
/\ owner' = r
/\ UNCHANGED
<<sessionState, replicaState, requestState, committedBy,
accessState, observation, observerDestroyedOwned>>
Submit(r) ==
/\ sessionState = Active
/\ owner = r
/\ replicaState[r] = Up
/\ requestState[r] = Idle
/\ requestState' = [requestState EXCEPT ![r] = Submitted]
/\ UNCHANGED
<<sessionState, owner, replicaState, committedBy,
accessState, observation, observerDestroyedOwned>>
ProviderOutcomeUnknown(r) ==
/\ requestState[r] = Submitted
/\ requestState' = [requestState EXCEPT ![r] = ProviderUnknown]
/\ UNCHANGED
<<sessionState, owner, replicaState, committedBy,
accessState, observation, observerDestroyedOwned>>
Return(r) ==
/\ requestState[r] = Submitted
/\ replicaState[r] = Up
/\ requestState' = [requestState EXCEPT ![r] = Returned]
/\ UNCHANGED
<<sessionState, owner, replicaState, committedBy,
accessState, observation, observerDestroyedOwned>>
CommitOutcomeUnknown(r) ==
/\ sessionState = Active
/\ owner = r
/\ requestState[r] = Returned
/\ requestState' = [requestState EXCEPT ![r] = CommitUnknown]
/\ UNCHANGED
<<sessionState, owner, replicaState, committedBy,
accessState, observation, observerDestroyedOwned>>
Commit(r) ==
/\ sessionState = Active
/\ owner = r
/\ requestState[r] = Returned
/\ sessionState' = Renewed
/\ owner' = NoReplica
/\ requestState' = [requestState EXCEPT ![r] = Committed]
/\ committedBy' = r
/\ UNCHANGED
<<replicaState, accessState, observation, observerDestroyedOwned>>
ExpireOwnership(r) ==
/\ AllowTakeover
/\ sessionState = Active
/\ owner = r
/\ requestState[r] # Idle
/\ owner' = NoReplica
/\ UNCHANGED
<<sessionState, replicaState, requestState, committedBy,
accessState, observation, observerDestroyedOwned>>
ReleaseAfterAmbiguousOutcome(r) ==
/\ AllowAmbiguousRelease
/\ sessionState = Active
/\ owner = r
/\ requestState[r] \in {ProviderUnknown, CommitUnknown}
/\ owner' = NoReplica
/\ UNCHANGED
<<sessionState, replicaState, requestState, committedBy,
accessState, observation, observerDestroyedOwned>>
Crash(r) ==
/\ replicaState[r] = Up
/\ replicaState' = [replicaState EXCEPT ![r] = Down]
/\ UNCHANGED
<<sessionState, owner, requestState, committedBy,
accessState, observation, observerDestroyedOwned>>
DestroyAfterOwnerCrash(r) ==
/\ sessionState = Active
/\ owner = r
/\ replicaState[r] = Down
/\ sessionState' = Destroyed
/\ owner' = NoReplica
/\ UNCHANGED
<<replicaState, requestState, committedBy,
accessState, observation, observerDestroyedOwned>>
ConfirmRenewalFailure(r) ==
/\ sessionState = Active
/\ owner = r
/\ requestState[r] \in
{Submitted, ProviderUnknown, Returned, CommitUnknown}
/\ sessionState' = Destroyed
/\ owner' = NoReplica
/\ UNCHANGED
<<replicaState, requestState, committedBy,
accessState, observation, observerDestroyedOwned>>
ExpireAccessToken ==
/\ sessionState = Active
/\ accessState = AccessValid
/\ accessState' = AccessExpired
/\ UNCHANGED
<<sessionState, owner, replicaState, requestState, committedBy,
observation, observerDestroyedOwned>>
ObserveValidToken ==
/\ sessionState = Active
/\ accessState = AccessValid
/\ observation' = UsedCurrent
/\ UNCHANGED
<<sessionState, owner, replicaState, requestState, committedBy,
accessState, observerDestroyedOwned>>
ObserveExpiredOwnedRenewal ==
/\ sessionState = Active
/\ accessState = AccessExpired
/\ owner # NoReplica
/\ observation' = Unavailable
/\ UNCHANGED
<<sessionState, owner, replicaState, requestState, committedBy,
accessState, observerDestroyedOwned>>
ObserveExpiredAndDestroy ==
/\ sessionState = Active
/\ accessState = AccessExpired
/\ \/ owner = NoReplica
\/ ObserverDestroysOwned
/\ sessionState' = Destroyed
/\ owner' = NoReplica
/\ observation' = ObservedSignout
/\ observerDestroyedOwned' = (owner # NoReplica)
/\ UNCHANGED
<<replicaState, requestState, committedBy, accessState>>
Logout ==
/\ sessionState = Active
/\ sessionState' = Destroyed
/\ owner' = NoReplica
/\ UNCHANGED
<<replicaState, requestState, committedBy,
accessState, observation, observerDestroyedOwned>>
ExpireSession ==
/\ sessionState = Active
/\ sessionState' = Destroyed
/\ owner' = NoReplica
/\ UNCHANGED
<<replicaState, requestState, committedBy,
accessState, observation, observerDestroyedOwned>>
Next ==
\/ \E r \in Replicas : Acquire(r)
\/ \E r \in Replicas : Submit(r)
\/ \E r \in Replicas : ProviderOutcomeUnknown(r)
\/ \E r \in Replicas : Return(r)
\/ \E r \in Replicas : CommitOutcomeUnknown(r)
\/ \E r \in Replicas : Commit(r)
\/ \E r \in Replicas : ExpireOwnership(r)
\/ \E r \in Replicas : ReleaseAfterAmbiguousOutcome(r)
\/ \E r \in Replicas : Crash(r)
\/ \E r \in Replicas : DestroyAfterOwnerCrash(r)
\/ \E r \in Replicas : ConfirmRenewalFailure(r)
\/ ExpireAccessToken
\/ ObserveValidToken
\/ ObserveExpiredOwnedRenewal
\/ ObserveExpiredAndDestroy
\/ Logout
\/ ExpireSession
Spec == Init /\ [][Next]_vars
FairSpec == Spec /\ WF_vars(ExpireSession)
TypeOK ==
/\ sessionState \in SessionStates
/\ owner \in Replicas \cup {NoReplica}
/\ replicaState \in [Replicas -> ReplicaStates]
/\ requestState \in [Replicas -> RequestStates]
/\ committedBy \in Replicas \cup {NoReplica}
/\ accessState \in AccessStates
/\ observation \in Observations
/\ observerDestroyedOwned \in BOOLEAN
SubmittedRefreshes ==
{r \in Replicas : requestState[r] # Idle}
ProviderRefreshSerialized ==
Cardinality(SubmittedRefreshes) <= 1
CommitIsOwnerFenced ==
committedBy # NoReplica =>
/\ sessionState = Renewed
/\ requestState[committedBy] = Committed
ObserverPreservesOwnedRenewal ==
~observerDestroyedOwned
TerminalSessionAction ==
sessionState \in {Renewed, Destroyed} =>
sessionState' = sessionState
TerminalSessionIsPermanent == [][TerminalSessionAction]_vars
EventuallyTerminal ==
<>(sessionState \in {Renewed, Destroyed})
=============================================================================

View file

@ -22,6 +22,7 @@ Terms in this glossary are stable identifiers for use across Remora documentatio
| `WebClient` | The browser-based user interface for submitting and managing jobs. |
| `WebBFF` | The same-origin confidential OAuth client and token-mediating backend for `WebClient`. |
| `WebSession` | Ephemeral server-side OAuth session state addressed by an opaque browser cookie. |
| `WebSessionProtocol` | The TLA+ specification defining distributed browser-session renewal semantics and their safety and liveness properties. |
| `API` | The authenticated service interface for job submission, status, cancellation, history, and deletion. |
| `Worker` | A stateless process that executes attempts and reports progress and outcomes. |
| `IdentityProvider` | The external authority for user identity, administrative roles, and storage authorization scopes. |
@ -35,9 +36,14 @@ Terms in this glossary are stable identifiers for use across Remora documentatio
## Scope
This document is authoritative for Remora's product requirements, system boundaries, major components, result representation and access contract, and allocation of responsibilities below the abstraction of `JobProtocol`. It intentionally does not define implementation-level technology choices.
This document is authoritative for Remora's product requirements, system boundaries, major components, result representation and access contract, and allocation of responsibilities below the protocol models' abstractions. It intentionally does not define implementation-level technology choices.
[`Remora.tla`](../protocol/Remora.tla) is the authoritative `JobProtocol`. This document references its symbols without restating their definitions or actions; where they overlap, the model controls. Concrete frameworks, libraries, wire formats, mutable API descriptions, and deployment choices are outside the scope of this specification.
[`Remora.tla`](../protocol/Remora.tla) is the authoritative `JobProtocol`, and
[`WebSessionRenewal.tla`](../protocol/WebSessionRenewal.tla) is the authoritative
`WebSessionProtocol`. This document references their symbols without restating
their definitions or actions; where they overlap, the models control. Concrete
frameworks, libraries, wire formats, mutable API descriptions, and deployment
choices are outside the scope of this specification.
## Problem Statement
@ -108,7 +114,7 @@ flowchart TD
- `WebBFF` uses the OAuth authorization-code flow as a confidential client with Proof Key for Code Exchange and nonce validation. OAuth and OpenID Connect remain the only user-authentication path.
- Access tokens, refresh tokens, and ID tokens remain inside `WebBFF` and its protected `WebSession` store. Browser JavaScript receives only an authorization snapshot and a CSRF value.
- The browser carries a random session identifier in a host-only `__Host-` cookie with `Secure`, `HttpOnly`, `SameSite=Strict`, and `Path=/`. Authentication creates both the session identifier and CSRF value. Token renewal rotates the identifier while retaining the CSRF value for in-flight same-session requests; an identity change invalidates the session.
- The browser carries a random session identifier in a host-only `__Host-` cookie with `Secure`, `HttpOnly`, `SameSite=Strict`, and `Path=/`. Authentication creates both the session identifier and CSRF value. The authorization callback rotates the identifier before establishing the authenticated session. Renewal and concurrent-observer behavior refine `WebSessionProtocol`. An identity change invalidates the session.
- `WebSession` state is stored under a deployment-specific, WebBFF-only namespace in `CoordinationStore`. Its lifetime is bounded by the provider session and token lifetimes. Loss, expiry, unreadable state, or failed renewal destroys the local session and requires authentication again.
- Unsafe same-origin requests require the current session CSRF value in a request header. `WebBFF` also rejects unsafe requests with an absent or foreign `Origin` or a cross-site Fetch Metadata classification. SameSite cookies are defense in depth rather than the sole CSRF control.
- Browser API requests use a same-origin BFF route. `WebBFF` removes browser credentials and forwarding headers, injects the current access token, preserves idempotency keys and response status, and never retries an unsafe application request.
@ -267,9 +273,9 @@ flowchart TD
## Responsibility Boundaries
- [`docs/protocol`](../protocol/) is authoritative for distributed state and coordination semantics, including safety and liveness invariants.
- This architecture specification is authoritative for components, trust boundaries, externally visible behavior, and responsibility allocation below the model's abstraction, and must conform to `JobProtocol` where they overlap.
- Implementations may optimize storage layout but must refine `JobProtocol` behavior and preserve its linearization points.
- Mutable implementation contracts and documentation select technologies, wire shapes, and mappings from architectural components. They are not normative over `JobProtocol` or this specification.
- This architecture specification is authoritative for components, trust boundaries, externally visible behavior, and responsibility allocation below the models' abstractions, and must conform to the applicable protocol where they overlap.
- Implementations may optimize storage layout but must refine the applicable protocol behavior and preserve its linearization points.
- Mutable implementation contracts and documentation select technologies, wire shapes, and mappings from architectural components. They are not normative over the protocol models or this specification.
- Deployment configuration supplies service credentials and endpoints, configures allowed source reachability, and provides infrastructure-level egress enforcement.
- Deployment configuration reserves and protects Remora's internal target namespace, excludes it from independent lifecycle deletion, and establishes either exclusive write ownership or immutable version addressing.
- Deployment configuration selects secret-store topology and policies while preserving the application-level grant-bound credential-resolution contract.

View file

@ -40,7 +40,9 @@ application and NetworkPolicy CIDR allowances.
Containers run non-root with read-only roots, dropped capabilities, resource
limits, and no service links. Worker temporary storage is bounded.
TLS ingress redirects HTTP to HTTPS and emits HSTS. The WebBFF session cookie
The chart exposes ClusterIP Services and does not own north-south routing.
Deployments attach Gateway API `HTTPRoute` resources outside the chart and
enforce HTTPS redirection and HSTS at that boundary. The WebBFF session cookie
is always `Secure`; unencrypted origins are unsupported outside loopback test
environments.

View file

@ -34,4 +34,4 @@ Package runners may create additional namespaced fixtures and fault injectors ne
Automated backend tests use cluster-local service discovery. Tests of Keycloak or ZITADEL issuer behavior use the `dnsPolicy` and `dnsConfig` pattern in [`fixture-consumer-sample`](../../tests/kind/sample/job.yaml.in) so discovery URLs and token issuers have one identity from every participating pod.
Port forwarding is for interactive diagnostics only and is not an acceptance-test transport. Ingress is introduced by deployment and system qualification, where routing is itself under test; substrate test components do not install an ingress controller merely to reach fixture services.
Port forwarding is for interactive diagnostics only and is not an acceptance-test transport. System qualification owns an external routing fixture around the chart Services; substrate test components do not install a routing controller merely to reach fixture services.

View file

@ -167,3 +167,29 @@ spec:
runAsNonRoot: true
runAsUser: 101
seccompProfile: {type: RuntimeDefault}
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: qualification-remora-route
namespace: remora-testbed
spec:
ingressClassName: qualification-nginx
rules:
- host: qualification-ingress
http:
paths:
- path: /v1
pathType: Prefix
backend:
service:
name: remora-remora
port:
name: http
- path: /
pathType: Prefix
backend:
service:
name: remora-remora-web
port:
name: http

View file

@ -238,14 +238,6 @@ observability:
serviceMonitor: {enabled: false, interval: 30s}
prometheusRule: {enabled: false}
EOF
if [[ "${REMORA_SYSTEM_QUALIFICATION:-0}" = 1 ]]; then
cat >> "$RUNTIME/deployment-values.yaml" <<'EOF'
ingress:
enabled: true
className: qualification-nginx
host: qualification-ingress
EOF
fi
}
wait_for_queue_length() {
@ -410,6 +402,10 @@ validate_deployment_assets() {
grep -q 'absent(remora_ready{component="remora-worker"})' "$RUNTIME/deployment-policy-rendered.yaml"
grep -q 'automountServiceAccountToken: false' "$RUNTIME/deployment-policy-rendered.yaml"
grep -q 'readOnlyRootFilesystem: true' "$RUNTIME/deployment-policy-rendered.yaml"
if grep -E -q '^kind: (Gateway|HTTPRoute|Ingress)$' "$RUNTIME/deployment-policy-rendered.yaml"; then
printf 'chart must not own north-south routing\n' >&2
return 1
fi
reject_chart_values "an unprotected target namespace" --set target.exclusiveWriter=false
reject_chart_values "an unknown top-level value" --set unexpected=true
reject_chart_values "an unknown nested value" --set runtime.unexpected=true

View file

@ -4,10 +4,12 @@ import { extname, resolve, sep } from 'node:path'
import { pathToFileURL } from 'node:url'
import { loadConfig } from './server/config.mjs'
import { OAuthClient } from './server/oauth.mjs'
import { OAuthClient, SessionRenewalPendingError } from './server/oauth.mjs'
import { ApiProxy } from './server/proxy.mjs'
import { SessionStore } from './server/sessions.mjs'
const SHUTDOWN_TIMEOUT_MILLISECONDS = 25_000
export function createWebServer(config, dependencies = {}) {
const store = dependencies.store ?? new SessionStore(config)
const oauth =
@ -17,7 +19,9 @@ export function createWebServer(config, dependencies = {}) {
dependencies.proxy ??
new ApiProxy(config, oauth, dependencies.fetchImplementation)
const startedAt = Date.now()
const runtime = { accepting: true }
let requests = 0
let shutdownPromise
const server = createServer(async (request, response) => {
requests += 1
@ -35,6 +39,7 @@ export function createWebServer(config, dependencies = {}) {
status: response.statusCode,
})}\n`,
)
if (!runtime.accepting) server.closeIdleConnections()
})
try {
await route(
@ -46,21 +51,39 @@ export function createWebServer(config, dependencies = {}) {
proxy,
startedAt,
requests,
runtime,
)
} catch {
} catch (error) {
if (!response.headersSent)
response.writeHead(502, { 'Cache-Control': 'no-store' })
response.writeHead(
error instanceof SessionRenewalPendingError ? 503 : 502,
{ 'Cache-Control': 'no-store' },
)
response.end()
}
})
server.on('upgrade', async (request, socket, head) => {
try {
if (!(await proxy.upgrade(request, socket, head))) socket.destroy()
} catch {
socket.destroy()
} catch (error) {
if (error instanceof SessionRenewalPendingError)
socket.end(
'HTTP/1.1 503 Service Unavailable\r\nCache-Control: no-store\r\nConnection: close\r\nContent-Length: 0\r\n\r\n',
)
else socket.destroy()
}
})
return { server, store }
const shutdown = () => {
shutdownPromise ??= shutdownWebServer(
server,
store,
proxy,
runtime,
dependencies.shutdownTimeoutMilliseconds ?? SHUTDOWN_TIMEOUT_MILLISECONDS,
)
return shutdownPromise
}
return { server, store, shutdown }
}
async function route(
@ -72,6 +95,7 @@ async function route(
proxy,
startedAt,
requests,
runtime,
) {
const url = new URL(request.url ?? '/', config.publicOrigin)
if (request.method === 'POST' && url.pathname === '/client-errors') {
@ -92,8 +116,10 @@ async function route(
if (url.pathname === '/health/ready') {
json(
response,
store.ready ? 200 : 503,
{ status: store.ready ? 'ready' : 'unavailable' },
runtime.accepting && store.ready ? 200 : 503,
{
status: runtime.accepting && store.ready ? 'ready' : 'unavailable',
},
request.method,
)
return
@ -299,9 +325,10 @@ function traceIdentifier(traceparent) {
async function main() {
const config = loadConfig()
const { server, store } = createWebServer(config)
const { server, store, shutdown } = createWebServer(config)
const stopping = terminationSignal()
await store.connect()
server.listen(config.port, '0.0.0.0', () => {
await listen(server, config.port)
process.stdout.write(
`${JSON.stringify({
timestamp: new Date().toISOString(),
@ -311,7 +338,17 @@ async function main() {
port: config.port,
})}\n`,
)
})
const signal = await stopping
process.stdout.write(
`${JSON.stringify({
timestamp: new Date().toISOString(),
level: 'info',
component: 'remora-web',
message: 'web application stopping',
signal,
})}\n`,
)
await shutdown()
}
if (import.meta.url === pathToFileURL(process.argv[1] || '').href)
@ -326,3 +363,46 @@ if (import.meta.url === pathToFileURL(process.argv[1] || '').href)
)
process.exitCode = 1
})
async function shutdownWebServer(
server,
store,
proxy,
runtime,
timeoutMilliseconds,
) {
runtime.accepting = false
proxy.close?.()
server.closeIdleConnections()
await new Promise((resolve, reject) => {
const timeout = setTimeout(
() => server.closeAllConnections(),
timeoutMilliseconds,
)
timeout.unref()
server.close((error) => {
clearTimeout(timeout)
if (error && error.code !== 'ERR_SERVER_NOT_RUNNING') reject(error)
else resolve()
})
})
await store.close?.()
}
function listen(server, port) {
return new Promise((resolve, reject) => {
const failed = (error) => reject(error)
server.once('error', failed)
server.listen(port, '0.0.0.0', () => {
server.off('error', failed)
resolve()
})
})
}
function terminationSignal() {
return new Promise((resolve) => {
process.once('SIGINT', () => resolve('SIGINT'))
process.once('SIGTERM', () => resolve('SIGTERM'))
})
}

View file

@ -1,10 +1,12 @@
import assert from 'node:assert/strict'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { connect } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { after, before, test } from 'node:test'
import { createWebServer } from './server.mjs'
import { SessionRenewalPendingError } from './server/oauth.mjs'
let base
let root
@ -103,3 +105,133 @@ test('limits the SPA fallback to extensionless HTML navigation', async () => {
})
assert.equal(traversal.status, 404)
})
test('graceful shutdown drains active requests and closes dependencies', async () => {
const requestStarted = deferred()
const requestReleased = deferred()
let storeClosed = false
let proxyClosed = false
const created = createWebServer(
{
root,
publicOrigin: 'http://127.0.0.1',
},
{
store: {
ready: true,
close: async () => {
storeClosed = true
},
},
oauth: { route: async () => false },
proxy: {
route: async (_request, response, url) => {
if (url.pathname !== '/bff/api/blocked') return false
requestStarted.resolve()
await requestReleased.promise
response.writeHead(204)
response.end()
return true
},
upgrade: async () => false,
close: () => {
proxyClosed = true
},
},
shutdownTimeoutMilliseconds: 1_000,
},
)
await new Promise((resolve) => created.server.listen(0, '127.0.0.1', resolve))
const address = created.server.address()
const request = fetch(`http://127.0.0.1:${address.port}/bff/api/blocked`)
await requestStarted.promise
let stopped = false
const stopping = created.shutdown().then(() => {
stopped = true
})
await new Promise((resolve) => setTimeout(resolve, 10))
assert.equal(stopped, false)
assert.equal(proxyClosed, true)
requestReleased.resolve()
assert.equal((await request).status, 204)
await stopping
assert.equal(storeClosed, true)
})
test('preserves the session cookie while renewal is pending', async () => {
const created = createWebServer(
{
root,
publicOrigin: 'http://127.0.0.1',
},
{
store: { ready: true },
oauth: {
route: async () => {
throw new SessionRenewalPendingError()
},
},
proxy: {
route: async () => false,
upgrade: async () => false,
},
},
)
await new Promise((resolve) => created.server.listen(0, '127.0.0.1', resolve))
const address = created.server.address()
const response = await fetch(`http://127.0.0.1:${address.port}/auth/session`)
assert.equal(response.status, 503)
assert.equal(response.headers.get('set-cookie'), null)
await created.shutdown()
})
test('returns transient unavailability for a pending WebSocket renewal', async () => {
const created = createWebServer(
{
root,
publicOrigin: 'http://127.0.0.1',
},
{
store: { ready: true },
oauth: { route: async () => false },
proxy: {
route: async () => false,
upgrade: async () => {
throw new SessionRenewalPendingError()
},
},
},
)
await new Promise((resolve) => created.server.listen(0, '127.0.0.1', resolve))
const address = created.server.address()
const response = await new Promise((resolve, reject) => {
const socket = connect(address.port, '127.0.0.1')
let received = ''
socket.setEncoding('utf8')
socket.once('connect', () => {
socket.write(
'GET /bff/events HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n',
)
})
socket.on('data', (chunk) => {
received += chunk
})
socket.once('end', () => resolve(received))
socket.once('error', reject)
})
assert.match(response, /^HTTP\/1\.1 503 Service Unavailable\r\n/)
await created.shutdown()
})
function deferred() {
let resolve
const promise = new Promise((complete) => {
resolve = complete
})
return { promise, resolve }
}

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,
{
json(response, 200, {
kind: 'authenticated',
identity: loaded.session.identity,
csrfToken: loaded.session.csrfToken,
},
loaded.rotated
? { 'Set-Cookie': sessionCookie(loaded.identifier, this.config) }
: {},
)
})
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 identifier = sessionIdentifier(request)
if (identifier === null) return null
let session = await this.store.read(identifier)
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
}
let rotated = false
if (session.accessExpiresAt <= now() + CLOCK_SKEW_SECONDS) {
if (!refresh) {
}
}
return { identifier, session }
}
async storedSession(request) {
const identifier = sessionIdentifier(request)
if (identifier === null) return null
const session = await this.store.read(identifier)
if (!validAuthenticatedSession(session)) {
if (session?.kind === 'authenticated')
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))
}