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:
parent
ffa65018ed
commit
414b380516
18 changed files with 980 additions and 136 deletions
|
|
@ -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 }}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -124,13 +124,6 @@ outbound:
|
|||
allowedCidrs: ""
|
||||
maxRedirects: 5
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
className: ""
|
||||
annotations: {}
|
||||
host: remora.example.com
|
||||
tlsSecretName: ""
|
||||
|
||||
networkPolicy:
|
||||
enabled: true
|
||||
ingressNamespaceSelector: {}
|
||||
|
|
|
|||
20
docs/protocol/WebSessionRenewal.cfg
Normal file
20
docs/protocol/WebSessionRenewal.cfg
Normal 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
|
||||
276
docs/protocol/WebSessionRenewal.tla
Normal file
276
docs/protocol/WebSessionRenewal.tla
Normal 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})
|
||||
|
||||
=============================================================================
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
120
web/server.mjs
120
web/server.mjs
|
|
@ -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,19 +325,30 @@ 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', () => {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: 'info',
|
||||
component: 'remora-web',
|
||||
message: 'web application ready',
|
||||
port: config.port,
|
||||
})}\n`,
|
||||
)
|
||||
})
|
||||
await listen(server, config.port)
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: 'info',
|
||||
component: 'remora-web',
|
||||
message: 'web application ready',
|
||||
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'))
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
186
web/server/sessions.test.mjs
Normal file
186
web/server/sessions.test.mjs
Normal 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))
|
||||
}
|
||||
Loading…
Reference in a new issue