Move browser authentication behind a generated-contract BFF

This commit is contained in:
ms2k 2026-07-19 18:21:17 +09:00
commit fa7220ee33
55 changed files with 7259 additions and 2825 deletions

View file

@ -2,6 +2,7 @@
ARG RUST_IMAGE=docker.io/library/rust:1.97.0-bookworm@sha256:8fa55b2f3ddf97471ab6a767bfa3f37e6bad0986ba823e75fea57e2a2a5c3073
ARG NODE_IMAGE=docker.io/library/node:24.18.0-bookworm@sha256:5711a0d445a1af54af9589066c646df387d1831a608226f4cd694fc59e745059
ARG NODE_RUNTIME_IMAGE=docker.io/library/node:24.18.0-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d
ARG CARGO_BUILD_JOBS=4
FROM ${RUST_IMAGE} AS rust-build
@ -35,10 +36,13 @@ COPY web/index.html web/tsconfig*.json web/vite.config.ts ./
COPY web/public public
COPY web/src src
RUN pnpm build
RUN CI=true pnpm prune --prod
FROM ${NODE_IMAGE} AS web
FROM ${NODE_RUNTIME_IMAGE} AS web
WORKDIR /app
COPY --from=web-build /workspace/dist dist
COPY --from=web-build /workspace/node_modules node_modules
COPY web/server.mjs server.mjs
COPY web/server server
USER 65532:65532
ENTRYPOINT ["node", "/app/server.mjs"]

View file

@ -3,6 +3,7 @@ import { readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { parse, stringify } from "yaml";
import { format } from "prettier";
const root = dirname(fileURLToPath(import.meta.url));
const repository = resolve(root, "../..");
@ -62,12 +63,31 @@ for (const schema of Object.values(bundle.$defs)) {
delete schema.$schema;
delete schema.$id;
}
assertWebSchemasSupported(bundle.$defs);
const events = {
...bundle,
$id: "https://remora.invalid/api/events.schema.json",
title: "Remora WebSocket frame",
$ref: "#/$defs/EventFrame",
};
const webContracts = await format(emitTypeScriptContracts(bundle.$defs), {
parser: "typescript",
semi: false,
singleQuote: true,
});
const webSchemas = await format(
`export const schemas = ${JSON.stringify(bundle.$defs, null, 2)} as const\n`,
{
parser: "typescript",
semi: false,
singleQuote: true,
},
);
const webDecoders = await format(emitTypeScriptDecoders(), {
parser: "typescript",
semi: false,
singleQuote: true,
});
const outputs = [
[resolve(repository, "docs/api/openapi.yaml"), openapi],
[resolve(repository, "docs/api/events.schema.json"), `${JSON.stringify(events, null, 2)}\n`],
@ -75,6 +95,9 @@ const outputs = [
resolve(repository, "crates/remora-contracts/src/generated_wire.rs"),
readFileSync(generatedRust, "utf8"),
],
[resolve(repository, "web/src/api/contracts.ts"), webContracts],
[resolve(repository, "web/src/api/generated-schema.ts"), webSchemas],
[resolve(repository, "web/src/api/decode.ts"), webDecoders],
];
let stale = false;
@ -105,3 +128,179 @@ function rewriteBundleReferences(value) {
}
Object.values(value).forEach(rewriteBundleReferences);
}
function emitTypeScriptContracts(definitions) {
const lines = [
"// Generated from contracts/typespec/main.tsp. Do not edit.",
"",
];
for (const [name, schema] of Object.entries(definitions)) {
lines.push(`export type ${name}Dto = ${typeScriptType(schema)}`, "");
}
return `${lines.join("\n")}\n`;
}
function typeScriptType(schema) {
if (schema.$ref) {
return `${schema.$ref.replace(/^#\/\$defs\//, "").replace(/\.json$/, "")}Dto`;
}
if (schema.const !== undefined) {
return JSON.stringify(schema.const);
}
if (schema.enum) {
return schema.enum.map((value) => JSON.stringify(value)).join(" | ");
}
const alternatives = schema.oneOf ?? schema.anyOf;
if (alternatives) {
return alternatives.map(typeScriptType).join(" | ");
}
if (schema.allOf || schema.type === "object" || schema.properties) {
const members = [];
if (schema.allOf) {
members.push(...schema.allOf.map(typeScriptType));
}
if (schema.properties || schema.type === "object") {
const required = new Set(schema.required ?? []);
const properties = Object.entries(schema.properties ?? {}).map(
([name, property]) =>
`${propertyName(name)}${required.has(name) ? "" : "?"}: ${typeScriptType(property)}`,
);
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
properties.push(`[key: string]: ${typeScriptType(schema.additionalProperties)}`);
}
members.push(`{ ${properties.join("; ")} }`);
}
return members.length === 0 ? "Record<string, never>" : members.join(" & ");
}
if (schema.type === "array") {
return `Array<${typeScriptType(schema.items)}>`;
}
if (schema.type === "string") return "string";
if (schema.type === "integer" || schema.type === "number") return "number";
if (schema.type === "boolean") return "boolean";
if (schema.type === "null") return "null";
throw new Error(`TypeScript schema emission does not support ${JSON.stringify(schema)}`);
}
function propertyName(name) {
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name);
}
function assertWebSchemasSupported(definitions) {
for (const [name, schema] of Object.entries(definitions)) {
assertWebSchemaSupported(schema, name);
}
}
function assertWebSchemaSupported(schema, location) {
const allowed = new Set([
"$ref",
"type",
"properties",
"required",
"items",
"oneOf",
"anyOf",
"allOf",
"const",
"enum",
"additionalProperties",
"unevaluatedProperties",
"minItems",
"maxItems",
"minLength",
"maxLength",
"pattern",
"format",
"minimum",
"maximum",
"description",
"default",
"not",
]);
for (const key of Object.keys(schema)) {
if (!allowed.has(key)) {
throw new Error(`Web schema emission does not support ${location}.${key}`);
}
}
if (
schema.format !== undefined &&
schema.format !== "uri" &&
schema.format !== "date-time"
) {
throw new Error(`Web schema emission does not support ${location} format ${schema.format}`);
}
for (const [name, property] of Object.entries(schema.properties ?? {})) {
assertWebSchemaSupported(property, `${location}.${name}`);
}
if (schema.items) assertWebSchemaSupported(schema.items, `${location}[]`);
for (const [kind, alternatives] of [
["oneOf", schema.oneOf],
["anyOf", schema.anyOf],
["allOf", schema.allOf],
]) {
alternatives?.forEach((member, index) =>
assertWebSchemaSupported(member, `${location}.${kind}[${index}]`),
);
}
if (schema.additionalProperties) {
assertWebSchemaSupported(schema.additionalProperties, `${location}.*`);
}
if (schema.unevaluatedProperties) {
const keys = Object.keys(schema.unevaluatedProperties);
if (
keys.length === 1 &&
keys[0] === "not" &&
Object.keys(schema.unevaluatedProperties.not).length === 0
) {
return;
}
assertWebSchemaSupported(schema.unevaluatedProperties, `${location}.*`);
}
if (schema.not !== undefined) {
throw new Error(`Web schema emission does not support general negation at ${location}`);
}
}
function emitTypeScriptDecoders() {
const roots = [
["JobFailure", "JobFailure"],
["JobSubmission", "JobSubmission"],
["EventFrame", "EventFrame"],
["EventTicket", "EventTicket"],
["JobPage", "JobPage"],
["JobResource", "JobResource"],
["DeletionOperation", "DeletionOperation"],
["AttemptPage", "AttemptPage"],
["ResultDescriptor", "ResultDescriptor"],
["ResultMemberPage", "ResultMemberPage"],
["ResultMemberAccess", "ResultMemberAccess"],
["SourceCredential", "SourceCredential"],
["SourceCredentialPage", "SourceCredentialPage"],
["ApiError", "ApiError"],
["AttemptOutcome", "AttemptOutcomeRequest"],
];
const imports = roots
.map(([, type]) => ` ${type}Dto,`)
.join("\n");
const functions = roots
.map(
([functionName, type]) =>
`function assert${functionName}(value: unknown): asserts value is ${type}Dto {\n` +
` validateSchema('${type}', value)\n` +
`}\n\n` +
`export function decode${functionName}(value: unknown): ${type}Dto {\n` +
` assert${functionName}(value)\n` +
` return value\n` +
`}`,
)
.join("\n\n");
return `// Generated from contracts/typespec/main.tsp. Do not edit.
import type {
${imports}
} from './contracts'
import { validateSchema } from './validate'
${functions}
`;
}

View file

@ -12,6 +12,7 @@
"@typespec/json-schema": "1.14.0",
"@typespec/openapi": "1.14.0",
"@typespec/openapi3": "1.14.0",
"prettier": "3.9.5",
"yaml": "2.9.0"
}
},

View file

@ -14,6 +14,7 @@
"@typespec/json-schema": "1.14.0",
"@typespec/openapi": "1.14.0",
"@typespec/openapi3": "1.14.0",
"prettier": "3.9.5",
"yaml": "2.9.0"
}
}

View file

@ -6,6 +6,9 @@
{{- if ge (int .Values.runtime.workerRenewSeconds) (int .Values.coordination.leaseTtlSeconds) -}}
{{- fail "runtime.workerRenewSeconds must be less than coordination.leaseTtlSeconds" -}}
{{- end -}}
{{- if eq .Values.web.sessionUrlSecret.name .Values.coordination.urlSecret.name -}}
{{- fail "web.sessionUrlSecret must not share the coordination Secret" -}}
{{- end -}}
{{- range $entry := splitList "," .Values.outbound.allowedSchemePorts -}}
{{- $parts := splitList ":" $entry -}}
{{- $port := int (index $parts 1) -}}

View file

@ -200,4 +200,22 @@ spec:
- {port: 53, protocol: UDP}
- {port: 53, protocol: TCP}
{{- end }}
- to:
- podSelector:
matchLabels:
{{- include "remora.selectorLabels" . | nindent 14 }}
app.kubernetes.io/component: api
ports:
- {port: 8080, protocol: TCP}
{{- range .Values.networkPolicy.webDependencyPodLabels }}
- to:
- podSelector:
matchLabels:
{{- toYaml . | nindent 14 }}
{{- end }}
{{- range .Values.networkPolicy.webExternalEgressCidrs }}
- to:
- ipBlock:
cidr: {{ . | quote }}
{{- end }}
{{- end }}

View file

@ -55,22 +55,39 @@ spec:
imagePullPolicy: {{ .Values.web.image.pullPolicy }}
env:
- {name: REMORA_WEB_PUBLIC_ORIGIN, value: {{ required "web.publicOrigin is required" .Values.web.publicOrigin | quote }}}
- {name: REMORA_WEB_API_URL, value: {{ .Values.web.apiUrl | quote }}}
- {name: REMORA_WEB_EVENTS_URL, value: {{ .Values.web.eventsUrl | quote }}}
- {name: REMORA_WEB_API_UPSTREAM, value: {{ printf "http://%s.%s.svc.cluster.local/v1/" (include "remora.fullname" .) .Release.Namespace | quote }}}
- {name: REMORA_WEB_EVENTS_UPSTREAM, value: {{ printf "ws://%s.%s.svc.cluster.local/v1/events" (include "remora.fullname" .) .Release.Namespace | quote }}}
- {name: REMORA_WEB_OIDC_AUTHORITY, value: {{ required "oidc.issuer is required" .Values.oidc.issuer | quote }}}
- {name: REMORA_WEB_OIDC_CLIENT_ID, value: {{ .Values.web.oidcClientId | quote }}}
- {name: REMORA_WEB_OIDC_REDIRECT_URI, value: {{ .Values.web.publicOrigin | quote }}}
- {name: REMORA_WEB_OIDC_POST_LOGOUT_REDIRECT_URI, value: {{ .Values.web.publicOrigin | quote }}}
- name: REMORA_WEB_OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: {{ .Values.web.oidcClientSecretSecret.name }}
key: {{ .Values.web.oidcClientSecretSecret.key }}
- {name: REMORA_WEB_OIDC_SCOPE, value: {{ .Values.web.oidcScope | quote }}}
- {name: REMORA_WEB_CSP_CONNECT_SRC, value: {{ .Values.web.cspConnectSrc | quote }}}
- name: REMORA_WEB_SESSION_URL
valueFrom:
secretKeyRef:
name: {{ .Values.web.sessionUrlSecret.name }}
key: {{ .Values.web.sessionUrlSecret.key }}
- {name: REMORA_WEB_SESSION_NAMESPACE, value: {{ printf "%sweb-session:" .Values.coordination.namespace | quote }}}
- {name: REMORA_WEB_SESSION_TTL_SECONDS, value: {{ .Values.web.sessionTtlSeconds | quote }}}
ports:
- {name: http, containerPort: 8080}
readinessProbe:
httpGet: {path: /health/ready, port: http}
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 6
livenessProbe:
httpGet: {path: /health/live, port: http}
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
startupProbe:
httpGet: {path: /health/live, port: http}
periodSeconds: 2
failureThreshold: 30
resources:
{{- toYaml .Values.web.resources | nindent 12 }}
securityContext:

View file

@ -218,6 +218,8 @@
"dnsPodLabels",
"dependencyPodLabels",
"externalEgressCidrs",
"webDependencyPodLabels",
"webExternalEgressCidrs",
"allowPublicSourceEgress"
],
"properties": {
@ -233,6 +235,12 @@
"uniqueItems": true,
"items": {"$ref": "#/$defs/cidr"}
},
"webDependencyPodLabels": {"$ref": "#/$defs/labelMapList"},
"webExternalEgressCidrs": {
"type": "array",
"uniqueItems": true,
"items": {"$ref": "#/$defs/cidr"}
},
"allowPublicSourceEgress": {"type": "boolean"}
}
},
@ -392,22 +400,22 @@
"replicas",
"image",
"publicOrigin",
"apiUrl",
"eventsUrl",
"oidcClientId",
"oidcClientSecretSecret",
"oidcScope",
"cspConnectSrc",
"sessionUrlSecret",
"sessionTtlSeconds",
"resources"
],
"properties": {
"replicas": {"$ref": "#/$defs/positiveInteger"},
"image": {"$ref": "#/$defs/image"},
"publicOrigin": {"$ref": "#/$defs/httpUrl"},
"apiUrl": {"type": "string", "pattern": "^(https?://|/).+"},
"eventsUrl": {"type": "string", "pattern": "^(|https?://.+|wss?://.+|/.+)$"},
"oidcClientId": {"$ref": "#/$defs/nonEmpty"},
"oidcClientSecretSecret": {"$ref": "#/$defs/secretRef"},
"oidcScope": {"$ref": "#/$defs/nonEmpty"},
"cspConnectSrc": {"$ref": "#/$defs/nonEmpty"},
"sessionUrlSecret": {"$ref": "#/$defs/secretRef"},
"sessionTtlSeconds": {"$ref": "#/$defs/positiveInteger"},
"resources": {"$ref": "#/$defs/resources"}
}
},

View file

@ -43,11 +43,15 @@ web:
digest: ""
pullPolicy: IfNotPresent
publicOrigin: https://remora.example.com
apiUrl: /v1/
eventsUrl: ""
oidcClientId: remora-web
oidcClientSecretSecret:
name: remora-web-oidc
key: client-secret
oidcScope: openid profile
cspConnectSrc: "'self' https://identity.example.com"
sessionUrlSecret:
name: remora-web-session
key: url
sessionTtlSeconds: 28800
resources:
requests:
cpu: 25m
@ -143,6 +147,10 @@ networkPolicy:
- fixture.remora.dev/component: zitadel
- fixture.remora.dev/component: versitygw
externalEgressCidrs: []
webDependencyPodLabels:
- fixture.remora.dev/component: dragonfly
- fixture.remora.dev/component: zitadel
webExternalEgressCidrs: []
allowPublicSourceEgress: true
observability:

View file

@ -20,6 +20,8 @@ Terms in this glossary are stable identifiers for use across Remora documentatio
| `ResultManifest` | The immutable exact inventory and root identity for one attempt's result aggregate. |
| `CommittedResult` | The concrete durable descriptor corresponding to the attempt committed by `JobProtocol`. |
| `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. |
| `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. |
@ -51,9 +53,9 @@ Identity, coordination, and durable state are external services. Multiple API an
## Architecture
`WebClient` communicates with `API`, which authorizes requests through `IdentityProvider`, records jobs in `DurableStore`, and submits work through `CoordinationStore`. `Worker` replicas consume jobs and stream data from `Source` through `StorageAdapter` into `TargetStore`.
`WebClient` communicates only with its same-origin `WebBFF`. `WebBFF` is the confidential OAuth and OpenID Connect client, mediates browser API and live-event access, and sends bearer credentials only to `API`. `API` validates the bearer identity and roles supplied by `IdentityProvider`, records jobs in `DurableStore`, and submits work through `CoordinationStore`. `Worker` replicas consume jobs and stream data from `Source` through `StorageAdapter` into `TargetStore`.
`API` and `Worker` replicas are stateless. `DurableStore` realizes `durableVars` and retains architecture data outside the model. `CoordinationStore` realizes `coordinationVars` and may be reconstructed from durable state as permitted by `JobProtocol`.
`API`, `Worker`, and `WebBFF` replicas are stateless. `WebSession` records are ephemeral external state: their loss signs users out and does not alter durable application state. `DurableStore` realizes `durableVars` and retains architecture data outside the model. `CoordinationStore` realizes `coordinationVars` and may be reconstructed from durable state as permitted by `JobProtocol`.
`TargetStore` contains immutable attempt-scoped objects. The model's staging identity abstracts each concrete aggregate, and its committed attempt maps to the manifest descriptor exposed as the `CommittedResult`. `JobProtocol` is the sole specification of attempt authority and publication behavior.
@ -70,6 +72,7 @@ flowchart TD
end
subgraph Service
WebBFF[WebBFF]
API[API]
Worker[Worker]
end
@ -88,8 +91,9 @@ flowchart TD
TargetStore[TargetStore]
end
WebClient -- OIDC --> IdentityProvider
WebClient -- API + live updates --> API
WebClient -- Session + CSRF --> WebBFF
WebBFF -- OAuth + OIDC --> IdentityProvider
WebBFF -- Bearer API + live updates --> API
IdentityProvider -- Identity and roles --> API
API -- Submit and coordinate --> CoordinationStore
API -- Record and query --> DurableStore
@ -100,6 +104,19 @@ flowchart TD
StorageAdapter --> TargetStore
```
## Browser Session Boundary
- `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.
- `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.
- Browser live-event connections use a same-origin BFF WebSocket route. The browser obtains the one-use event ticket through the API proxy; `WebBFF` validates the browser session and proxies the ticket-bearing connection without exposing a bearer token.
- Local logout invalidates `WebSession` before redirecting to the provider logout endpoint. Provider logout failure cannot restore the local session.
- The public `API` bearer-token interface remains available to non-browser OAuth clients. `WebBFF` does not weaken API authentication or authorization and does not reinterpret roles.
- `WebBFF` is presentation and identity mediation outside the state abstracted by `JobProtocol`. It preserves the authorization-snapshot, event-ticket, replay, and idempotency semantics defined at the API boundary.
## Worker Execution Envelope
- Every Worker process creates a fresh, non-reused `WorkerIncarnation`. A restarted process cannot reuse authority issued to an earlier lifetime, including when it occupies the same replica slot.
@ -244,7 +261,7 @@ flowchart TD
| Observability | Expose job status, progress, and errors through the UI and API |
| Scalability | Support multiple stateless API and worker replicas using shared state services |
| Performance | Stream multi-gigabyte objects without buffering complete objects in worker memory |
| Security | Authenticate and authorize all operations; do not expose long-lived source or target credentials to the UI |
| Security | Authenticate and authorize all operations; keep OAuth tokens and long-lived source or target credentials outside browser JavaScript |
| Deployment | Deploy as a standard Kubernetes service using externally managed identity, state, and object storage |
## Responsibility Boundaries

View file

@ -1,6 +1,6 @@
# Kubernetes Deployment
`deploy/charts/remora` runs API, Worker, and WebClient as separate Deployments.
`deploy/charts/remora` runs API, Worker, and WebClient with its `WebBFF` as separate Deployments.
CloudNativePG, Dragonfly, Vault, ZITADEL, and VersityGW remain externally
managed.
@ -19,8 +19,12 @@ managed.
- Dragonfly uses a deployment-unique namespace.
`REMORA_GLOBAL_ATTEMPT_LIMIT` and `REMORA_WORKER_LOCAL_ATTEMPT_LIMIT` remain
independent.
- ZITADEL supplies the issuer, project audience, client identifier, and exact
project-role claim.
- The WebBFF stores ephemeral WebSessions through a WebBFF-only Dragonfly
credential and namespace. Its credential Secret is not shared with API or
Worker coordination access. Loss of that namespace signs browser users out
without affecting durable application state.
- ZITADEL supplies the issuer, project audience, exact project-role claim, and
a confidential WebBFF client whose secret is mounted from a Secret.
- VersityGW uses dedicated credentials and a bucket whose `_remora` namespace
excludes other writers and lifecycle expiry. Deployments unable to establish
both properties are unsupported.
@ -28,13 +32,18 @@ managed.
## Security
NetworkPolicies default-deny each workload, isolate the Worker and management
ports, and allow dependencies by selector or CIDR. Public source egress excludes
ports, and allow dependencies by selector or CIDR. The WebBFF may reach only
the API, identity provider, and its session store. Public source egress excludes
non-global and metadata-capable IPv4 ranges. Private sources require matching
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
is always `Secure`; unencrypted origins are unsupported outside loopback test
environments.
## Operations
API and Worker expose liveness, dependency-aware readiness, and Prometheus

View file

@ -30,14 +30,33 @@ spec:
args:
- |
retry="--retry 30 --retry-all-errors --retry-delay 1"
test "$(curl $retry --silent --output /dev/null --write-out '%{http_code}' http://remora-remora-api-metrics:9090/health/ready)" = 200
test "$(curl $retry --silent --output /dev/null --write-out '%{http_code}' http://remora-remora-web/health/ready)" = 200
curl $retry --fail --silent http://remora-remora-api-metrics:9090/metrics | grep -q remora_background_runs_total
curl $retry --fail --silent http://remora-remora-web/runtime-config.js | grep -q '"oidcClientId":"remora-web"'
test "$(curl $retry --silent --output /dev/null --write-out '%{http_code}' http://remora-remora/v1/jobs)" = 401
expect_status() {
name="$1"
expected="$2"
shift 2
actual="$(curl $retry --silent --output /dev/null --write-out '%{http_code}' "$@")"
if test "$actual" != "$expected"; then
printf '%s returned HTTP %s, expected %s\n' "$name" "$actual" "$expected" >&2
return 1
fi
}
expect_body() {
name="$1"
pattern="$2"
shift 2
if ! curl $retry --fail --silent "$@" | grep -q "$pattern"; then
printf '%s did not contain the expected response marker\n' "$name" >&2
return 1
fi
}
expect_status "API readiness" 200 http://remora-remora-api-metrics:9090/health/ready
expect_status "Web readiness" 200 http://remora-remora-web/health/ready
expect_body "API metrics" remora_background_runs_total http://remora-remora-api-metrics:9090/metrics
expect_body "Web runtime configuration" '"apiUrl":"/bff/api/"' http://remora-remora-web/runtime-config.js
expect_status "API authentication" 401 http://remora-remora/v1/jobs
if test -n "@@INGRESS_URL@@"; then
curl $retry --fail --silent --header 'Host: qualification-ingress' @@INGRESS_URL@@/ | grep -q '<title>Remora</title>'
test "$(curl $retry --silent --header 'Host: qualification-ingress' --output /dev/null --write-out '%{http_code}' @@INGRESS_URL@@/v1/jobs)" = 401
expect_body "Ingress WebClient" '<title>Remora</title>' --header 'Host: qualification-ingress' --header 'Accept: text/html' @@INGRESS_URL@@/
expect_status "Ingress API authentication" 401 --header 'Host: qualification-ingress' @@INGRESS_URL@@/v1/jobs
fi
if curl --connect-timeout 2 --max-time 5 --insecure --silent --fail https://remora-remora-worker:8443/internal/v1/jobs/x/claim >/dev/null 2>&1; then
exit 1

View file

@ -138,6 +138,8 @@ create_external_secrets() {
kube --namespace "$FIXTURE_NAMESPACE" delete \
secret/remora-database \
secret/remora-coordination \
secret/remora-web-oidc \
secret/remora-web-session \
secret/remora-target \
--ignore-not-found \
--wait=true
@ -145,6 +147,10 @@ create_external_secrets() {
--from-literal="url=$database_url"
kube --namespace "$FIXTURE_NAMESPACE" create secret generic remora-coordination \
--from-literal="url=$coordination_url"
kube --namespace "$FIXTURE_NAMESPACE" create secret generic remora-web-oidc \
--from-literal="client-secret=synthetic-web-client-secret"
kube --namespace "$FIXTURE_NAMESPACE" create secret generic remora-web-session \
--from-literal="url=$coordination_url"
kube --namespace "$FIXTURE_NAMESPACE" create secret generic remora-target \
--from-literal="access-key=$target_access_key" \
--from-literal="secret-key=$target_secret_key"
@ -181,9 +187,7 @@ worker:
web:
image: {repository: remora-web, tag: local, digest: "", pullPolicy: Never}
publicOrigin: $public_origin
apiUrl: /v1/
oidcClientId: remora-web
cspConnectSrc: "'self' $oidc_issuer"
podDnsPolicy: None
podDnsConfig:
nameservers: [10.96.0.53]
@ -227,6 +231,9 @@ networkPolicy:
- fixture.remora.dev/component: vault
- fixture.remora.dev/component: zitadel
- fixture.remora.dev/component: versitygw
webDependencyPodLabels:
- fixture.remora.dev/component: dragonfly
- fixture.remora.dev/component: zitadel
observability:
serviceMonitor: {enabled: false, interval: 30s}
prometheusRule: {enabled: false}
@ -413,6 +420,8 @@ validate_deployment_assets() {
--set-string outbound.allowedSchemePorts=https:70000
reject_chart_values "renewal at lease expiry" \
--set runtime.workerRenewSeconds=30
reject_chart_values "a shared WebSession credential" \
--set web.sessionUrlSecret.name=remora-coordination
reject_chart_values "application-only CIDR reachability" \
--set-string outbound.allowedCidrs=10.0.0.0/8
reject_chart_values "an impossible disruption budget" \

View file

@ -10,27 +10,30 @@
},
"scripts": {
"dev": "vite",
"build": "vite build",
"build": "vite build && node tools/compress-assets.mjs && node tools/check-bundle.mjs",
"preview": "vite preview",
"check": "tsc --noEmit -p ./tsconfig.app.json && tsc --noEmit -p ./tsconfig.node.json && tsc --noEmit -p ./tsconfig.test.json && pnpm --filter @remora/svelte-check-compat check",
"lint": "oxlint --threads 1 --deny-warnings src tests *.config.ts && prettier --check . && pnpm run lint:api",
"lint:api": "NODE_OPTIONS=--max-old-space-size=2048 redocly lint ../docs/api/openapi.yaml --extends=spec",
"test": "vitest run --config vitest.config.ts",
"test:unit": "vitest run --config vitest.unit.config.ts",
"test:unit": "vitest run --config vitest.unit.config.ts && node --test server.test.mjs server/*.test.mjs",
"test:component": "vitest run --config vitest.browser.config.ts",
"test:component:full": "vitest run --config vitest.browser-full.config.ts",
"test:e2e": "playwright test --project=chromium",
"test:e2e:full": "playwright test"
},
"dependencies": {
"oidc-client-ts": "3.5.0",
"svelte": "5.56.5"
"redis": "6.1.0",
"svelte": "5.56.5",
"ws": "8.21.1"
},
"devDependencies": {
"@axe-core/playwright": "4.12.1",
"@playwright/test": "1.61.1",
"@redocly/cli": "2.33.1",
"@sveltejs/vite-plugin-svelte": "7.2.0",
"@types/node": "26.1.1",
"@types/ws": "8.18.1",
"@vitest/browser-playwright": "4.1.10",
"oxlint": "1.74.0",
"oxlint-tsgolint": "0.24.0",

175
web/pnpm-lock.yaml generated
View file

@ -7,13 +7,19 @@ settings:
importers:
.:
dependencies:
oidc-client-ts:
specifier: 3.5.0
version: 3.5.0
redis:
specifier: 6.1.0
version: 6.1.0(@opentelemetry/[email protected])
svelte:
specifier: 5.56.5
version: 5.56.5(@typescript-eslint/[email protected])
ws:
specifier: 8.21.1
version: 8.21.1
devDependencies:
'@axe-core/playwright':
specifier: 4.12.1
version: 4.12.1([email protected])
'@playwright/test':
specifier: 1.61.1
version: 1.61.1
@ -26,6 +32,9 @@ importers:
'@types/node':
specifier: 26.1.1
version: 26.1.1
'@types/ws':
specifier: 8.18.1
version: 8.18.1
'@vitest/browser-playwright':
specifier: 4.1.10
version: 4.1.10([email protected])([email protected](@types/[email protected]))([email protected])
@ -67,6 +76,14 @@ importers:
version: 6.0.3
packages:
'@axe-core/[email protected]':
resolution:
{
integrity: sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==,
}
peerDependencies:
playwright-core: '>= 1.0.0'
'@babel/[email protected]':
resolution:
{
@ -555,6 +572,57 @@ packages:
integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==,
}
'@redis/[email protected]':
resolution:
{
integrity: sha512-Rzascjd9J9bJsM45T/Z9CTg1QY/B63B6YO8QorLVMeXnbBDsKiSCVR/+GQ061hYPk8FpTzWmPY8tAv2sT+JEtQ==,
}
engines: { node: '>= 20.0.0' }
peerDependencies:
'@redis/client': ^6.1.0
'@redis/[email protected]':
resolution:
{
integrity: sha512-7u1LefkezJF0HESlhO7ZFLEPfyY+NejP3SGv+Z4pGaT3oM5GVVLa0u3f4rDLUrcw+SRo8IlX9Y8JAONeDdg1Ag==,
}
engines: { node: '>= 20.0.0' }
peerDependencies:
'@node-rs/xxhash': ^1.1.0
'@opentelemetry/api': '>=1 <2'
peerDependenciesMeta:
'@node-rs/xxhash':
optional: true
'@opentelemetry/api':
optional: true
'@redis/[email protected]':
resolution:
{
integrity: sha512-/GFjQA6bu5pG9ClCJAI5Xx4bNXe7UTpxBBlIupBNTrn1+nY860apGnYJuaSCDV2BmEbTidpa7O2qa28oxKx+rg==,
}
engines: { node: '>= 20.0.0' }
peerDependencies:
'@redis/client': ^6.1.0
'@redis/[email protected]':
resolution:
{
integrity: sha512-kS5agg+3yZbrdrt8omrew7FLCD8eOm7tarG1CROekPBRe+QGDR9aOpnHIQaYsYi6wPRTH70nQiF06AIjgURefQ==,
}
engines: { node: '>= 20.0.0' }
peerDependencies:
'@redis/client': ^6.1.0
'@redis/[email protected]':
resolution:
{
integrity: sha512-uIDBtV8MmG/xpJsRqbGSO4iX6ryj37MLMP82lRpFvI7ykAVe5GyqgxigEbU+uZNv9kDPNMKw3dvI/S/J1BNBzA==,
}
engines: { node: '>= 20.0.0' }
peerDependencies:
'@redis/client': ^6.1.0
'@redocly/[email protected]':
resolution:
{
@ -842,6 +910,12 @@ packages:
integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==,
}
'@types/[email protected]':
resolution:
{
integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==,
}
'@typescript-eslint/[email protected]':
resolution:
{
@ -1162,6 +1236,13 @@ packages:
}
engines: { node: '>=12' }
[email protected]:
resolution:
{
integrity: sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==,
}
engines: { node: '>=4' }
[email protected]:
resolution:
{
@ -1250,6 +1331,13 @@ packages:
}
engines: { node: '>=6' }
[email protected]:
resolution:
{
integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==,
}
engines: { node: '>=0.10.0' }
[email protected]:
resolution:
{
@ -1604,13 +1692,6 @@ packages:
}
engines: { node: '>=0.10.0' }
[email protected]:
resolution:
{
integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==,
}
engines: { node: '>=18' }
[email protected]:
resolution:
{
@ -1943,13 +2024,6 @@ packages:
}
engines: { node: '>=12.20.0' }
[email protected]:
resolution:
{
integrity: sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==,
}
engines: { node: '>=18' }
[email protected]:
resolution:
{
@ -2160,6 +2234,13 @@ packages:
}
engines: { node: '>= 14.18.0' }
[email protected]:
resolution:
{
integrity: sha512-0kvUPM8RHP/ZMa0xYaDTcG5e8tIGW6kz6MToVT0V8iOnk6bkXp2jncGRGe2bZEk41lZwiDspUqjZCSk5ohjcKw==,
}
engines: { node: '>= 20.0.0' }
[email protected]:
resolution:
{
@ -2698,10 +2779,10 @@ packages:
utf-8-validate:
optional: true
[email protected].0:
[email protected].1:
resolution:
{
integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==,
integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==,
}
engines: { node: '>=10.0.0' }
peerDependencies:
@ -2761,6 +2842,11 @@ packages:
}
snapshots:
'@axe-core/[email protected]([email protected])':
dependencies:
axe-core: 4.12.1
playwright-core: 1.61.1
'@babel/[email protected]':
dependencies:
'@babel/helper-validator-identifier': 7.29.7
@ -2990,6 +3076,28 @@ snapshots:
'@polka/[email protected]': {}
'@redis/[email protected](@redis/[email protected](@opentelemetry/[email protected]))':
dependencies:
'@redis/client': 6.1.0(@opentelemetry/[email protected])
'@redis/[email protected](@opentelemetry/[email protected])':
dependencies:
cluster-key-slot: 1.1.2
optionalDependencies:
'@opentelemetry/api': 1.9.1
'@redis/[email protected](@redis/[email protected](@opentelemetry/[email protected]))':
dependencies:
'@redis/client': 6.1.0(@opentelemetry/[email protected])
'@redis/[email protected](@redis/[email protected](@opentelemetry/[email protected]))':
dependencies:
'@redis/client': 6.1.0(@opentelemetry/[email protected])
'@redis/[email protected](@redis/[email protected](@opentelemetry/[email protected]))':
dependencies:
'@redis/client': 6.1.0(@opentelemetry/[email protected])
'@redocly/[email protected]':
dependencies:
fast-deep-equal: 3.1.3
@ -3190,6 +3298,10 @@ snapshots:
'@types/[email protected]': {}
'@types/[email protected]':
dependencies:
'@types/node': 26.1.1
'@typescript-eslint/[email protected]':
optional: true
@ -3276,7 +3388,7 @@ snapshots:
sirv: 3.0.2
tinyrainbow: 3.1.0
vitest: 4.1.10(@opentelemetry/[email protected])(@types/[email protected])(@vitest/[email protected])([email protected](@types/[email protected]))
ws: 8.21.0
ws: 8.21.1
transitivePeerDependencies:
- bufferutil
- msw
@ -3346,6 +3458,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
@ -3392,6 +3506,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
color-name: 1.1.4
@ -3550,8 +3666,6 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
@ -3708,10 +3822,6 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
jwt-decode: 4.0.0
[email protected]:
dependencies:
'@types/json-schema': 7.0.15
@ -3835,6 +3945,17 @@ snapshots:
[email protected]: {}
[email protected](@opentelemetry/[email protected]):
dependencies:
'@redis/bloom': 6.1.0(@redis/[email protected](@opentelemetry/[email protected]))
'@redis/client': 6.1.0(@opentelemetry/[email protected])
'@redis/json': 6.1.0(@redis/[email protected](@opentelemetry/[email protected]))
'@redis/search': 6.1.0(@redis/[email protected](@opentelemetry/[email protected]))
'@redis/time-series': 6.1.0(@redis/[email protected](@opentelemetry/[email protected]))
transitivePeerDependencies:
- '@node-rs/xxhash'
- '@opentelemetry/api'
[email protected]([email protected])([email protected])([email protected]([email protected]))([email protected])([email protected]([email protected]([email protected]))([email protected])):
dependencies:
'@redocly/openapi-core': 1.34.17
@ -4186,7 +4307,7 @@ snapshots:
[email protected]: {}
[email protected].0: {}
[email protected].1: {}
[email protected]: {}

View file

@ -1,68 +1,101 @@
import { createReadStream, statSync } from 'node:fs'
import { createServer } from 'node:http'
import { extname, resolve, sep } from 'node:path'
import { pathToFileURL } from 'node:url'
const root = resolve(process.env.REMORA_WEB_ROOT ?? '/app/dist')
const port = numberEnvironment('REMORA_WEB_PORT', 8080)
const runtimeConfig = {
apiUrl: environment('REMORA_WEB_API_URL', '/v1/'),
eventsUrl: environment('REMORA_WEB_EVENTS_URL', ''),
oidcAuthority: environment('REMORA_WEB_OIDC_AUTHORITY', '/oidc'),
oidcClientId: environment('REMORA_WEB_OIDC_CLIENT_ID', 'remora-web'),
oidcRedirectUri: environment('REMORA_WEB_OIDC_REDIRECT_URI', '/'),
oidcPostLogoutRedirectUri: environment(
'REMORA_WEB_OIDC_POST_LOGOUT_REDIRECT_URI',
'/',
),
oidcScope: environment('REMORA_WEB_OIDC_SCOPE', 'openid profile'),
}
const startedAt = Date.now()
let requests = 0
import { loadConfig } from './server/config.mjs'
import { OAuthClient } from './server/oauth.mjs'
import { ApiProxy } from './server/proxy.mjs'
import { SessionStore } from './server/sessions.mjs'
createServer((request, response) => {
requests += 1
const traceId = traceIdentifier(request.headers.traceparent)
response.setHeader('X-Content-Type-Options', 'nosniff')
response.setHeader('Referrer-Policy', 'no-referrer')
response.setHeader('X-Frame-Options', 'DENY')
response.setHeader('Cross-Origin-Opener-Policy', 'same-origin')
response.setHeader('Content-Security-Policy', contentSecurityPolicy())
response.setHeader('Traceparent', `00-${traceId}-0000000000000001-01`)
response.once('finish', () => {
process.stdout.write(
`${JSON.stringify({
timestamp: new Date().toISOString(),
level: 'info',
component: 'remora-web',
trace_id: traceId,
method: request.method,
path: requestPath(request.url),
status: response.statusCode,
})}\n`,
)
export function createWebServer(config, dependencies = {}) {
const store = dependencies.store ?? new SessionStore(config)
const oauth =
dependencies.oauth ??
new OAuthClient(config, store, dependencies.fetchImplementation)
const proxy =
dependencies.proxy ??
new ApiProxy(config, oauth, dependencies.fetchImplementation)
const startedAt = Date.now()
let requests = 0
const server = createServer(async (request, response) => {
requests += 1
const traceId = traceIdentifier(request.headers.traceparent)
secureHeaders(response, traceId)
response.once('finish', () => {
process.stdout.write(
`${JSON.stringify({
timestamp: new Date().toISOString(),
level: 'info',
component: 'remora-web',
trace_id: traceId,
method: request.method,
path: requestPath(request.url),
status: response.statusCode,
})}\n`,
)
})
try {
await route(
request,
response,
config,
store,
oauth,
proxy,
startedAt,
requests,
)
} catch {
if (!response.headersSent)
response.writeHead(502, { 'Cache-Control': 'no-store' })
response.end()
}
})
route(request, response)
}).listen(port, '0.0.0.0', () => {
process.stdout.write(
`${JSON.stringify({
timestamp: new Date().toISOString(),
level: 'info',
component: 'remora-web',
message: 'web application ready',
port,
})}\n`,
)
})
server.on('upgrade', async (request, socket, head) => {
try {
if (!(await proxy.upgrade(request, socket, head))) socket.destroy()
} catch {
socket.destroy()
}
})
return { server, store }
}
function route(request, response) {
async function route(
request,
response,
config,
store,
oauth,
proxy,
startedAt,
requests,
) {
const url = new URL(request.url ?? '/', config.publicOrigin)
if (request.method === 'POST' && url.pathname === '/client-errors') {
await recordClientError(request, response, config)
return
}
if (await oauth.route(request, response, url)) return
if (await proxy.route(request, response, url)) return
if (request.method !== 'GET' && request.method !== 'HEAD') {
response.writeHead(405, { Allow: 'GET, HEAD' })
response.end()
return
}
const url = new URL(request.url ?? '/', 'http://localhost')
if (url.pathname === '/health/live' || url.pathname === '/health/ready') {
json(response, 200, { status: 'ready' })
if (url.pathname === '/health/live') {
json(response, 200, { status: 'live' }, request.method)
return
}
if (url.pathname === '/health/ready') {
json(
response,
store.ready ? 200 : 503,
{ status: store.ready ? 'ready' : 'unavailable' },
request.method,
)
return
}
if (url.pathname === '/metrics') {
@ -81,9 +114,10 @@ function route(request, response) {
return
}
if (url.pathname === '/runtime-config.js') {
const body = `window.remoraRuntimeConfig=${JSON.stringify(
normalizedRuntimeConfig(),
)};\n`
const body = `window.remoraRuntimeConfig=${JSON.stringify({
apiUrl: '/bff/api/',
eventsUrl: `${config.publicOrigin.replace(/^http/, 'ws')}/bff/events`,
})};\n`
response.writeHead(200, {
'Cache-Control': 'no-store',
'Content-Type': 'text/javascript; charset=utf-8',
@ -92,27 +126,116 @@ function route(request, response) {
response.end(request.method === 'HEAD' ? undefined : body)
return
}
const candidate = safeFile(url.pathname)
const file = candidate ?? resolve(root, 'index.html')
const body = regularFile(file) ? file : resolve(root, 'index.html')
if (!regularFile(body)) {
serveStatic(request, response, url, config.root)
}
async function recordClientError(request, response, config) {
if (
request.headers.origin !== config.publicOrigin ||
request.headers['sec-fetch-site'] === 'cross-site'
) {
response.writeHead(403, { 'Cache-Control': 'no-store' })
response.end()
return
}
const chunks = []
let size = 0
for await (const chunk of request) {
size += chunk.length
if (size > 4096) {
response.writeHead(413, { 'Cache-Control': 'no-store' })
response.end()
return
}
chunks.push(chunk)
}
let body
try {
body = JSON.parse(Buffer.concat(chunks).toString())
} catch {
response.writeHead(400, { 'Cache-Control': 'no-store' })
response.end()
return
}
const validKind =
body?.kind === 'error' || body?.kind === 'unhandled_rejection'
if (
!validKind ||
typeof body.path !== 'string' ||
body.path.length > 1024 ||
!body.path.startsWith('/')
) {
response.writeHead(400, { 'Cache-Control': 'no-store' })
response.end()
return
}
process.stdout.write(
`${JSON.stringify({
timestamp: new Date().toISOString(),
level: 'error',
component: 'remora-web',
event: 'browser_error',
kind: body.kind,
path: body.path,
})}\n`,
)
response.writeHead(204, { 'Cache-Control': 'no-store' })
response.end()
}
function serveStatic(request, response, url, root) {
const candidate = safeFile(url.pathname, root)
let file = candidate && regularFile(candidate) ? candidate : null
if (
file === null &&
request.headers.accept?.includes('text/html') &&
!url.pathname.includes('.')
)
file = resolve(root, 'index.html')
if (file === null || !regularFile(file)) {
response.writeHead(404)
response.end()
return
}
const size = statSync(body).size
response.writeHead(200, {
'Cache-Control': body.endsWith('index.html')
const compressed = compressedFile(file, request.headers['accept-encoding'])
const served = compressed?.path ?? file
const size = statSync(served).size
const headers = {
'Cache-Control': file.endsWith('index.html')
? 'no-cache'
: 'public, max-age=31536000, immutable',
'Content-Type': contentType(body),
'Content-Type': contentType(file),
'Content-Length': size,
})
if (request.method === 'HEAD') {
response.end()
} else {
createReadStream(body).pipe(response)
}
if (compressed !== null) {
headers['Content-Encoding'] = compressed.encoding
headers.Vary = 'Accept-Encoding'
}
response.writeHead(200, headers)
if (request.method === 'HEAD') response.end()
else createReadStream(served).pipe(response)
}
function compressedFile(path, accepted = '') {
for (const [encoding, suffix] of [
['br', '.br'],
['gzip', '.gz'],
])
if (accepted.includes(encoding) && regularFile(`${path}${suffix}`))
return { encoding, path: `${path}${suffix}` }
return null
}
function secureHeaders(response, traceId) {
response.setHeader('X-Content-Type-Options', 'nosniff')
response.setHeader('Referrer-Policy', 'no-referrer')
response.setHeader('X-Frame-Options', 'DENY')
response.setHeader('Cross-Origin-Opener-Policy', 'same-origin')
response.setHeader(
'Content-Security-Policy',
"default-src 'self'; base-uri 'none'; frame-ancestors 'none'; object-src 'none'; connect-src 'self'",
)
response.setHeader('Traceparent', `00-${traceId}-0000000000000001-01`)
}
function requestPath(value) {
@ -123,32 +246,7 @@ function requestPath(value) {
}
}
function normalizedRuntimeConfig() {
const origin = requiredEnvironment('REMORA_WEB_PUBLIC_ORIGIN')
return {
...runtimeConfig,
eventsUrl:
runtimeConfig.eventsUrl ||
absolute(runtimeConfig.apiUrl.replace(/\/?$/, '/events'), origin, true),
oidcAuthority: absolute(runtimeConfig.oidcAuthority, origin, false),
oidcRedirectUri: absolute(runtimeConfig.oidcRedirectUri, origin, false),
oidcPostLogoutRedirectUri: absolute(
runtimeConfig.oidcPostLogoutRedirectUri,
origin,
false,
),
}
}
function absolute(value, origin, websocket) {
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return value
if (!origin) return value
const url = new URL(value, origin)
if (websocket) url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
return url.toString()
}
function safeFile(pathname) {
function safeFile(pathname, root) {
let decoded
try {
decoded = decodeURIComponent(pathname)
@ -169,13 +267,13 @@ function regularFile(path) {
}
}
function json(response, status, value) {
function json(response, status, value, method) {
const body = JSON.stringify(value)
response.writeHead(status, {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
})
response.end(body)
response.end(method === 'HEAD' ? undefined : body)
}
function contentType(path) {
@ -191,11 +289,6 @@ function contentType(path) {
)
}
function contentSecurityPolicy() {
const connect = environment('REMORA_WEB_CSP_CONNECT_SRC', "'self'")
return `default-src 'self'; base-uri 'none'; frame-ancestors 'none'; object-src 'none'; connect-src ${connect}`
}
function traceIdentifier(traceparent) {
const match =
typeof traceparent === 'string'
@ -204,23 +297,32 @@ function traceIdentifier(traceparent) {
return match?.[1] ?? crypto.randomUUID().replaceAll('-', '')
}
function environment(name, fallback) {
const value = process.env[name]
return value === undefined || value === '' ? fallback : value
async function main() {
const config = loadConfig()
const { server, store } = createWebServer(config)
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`,
)
})
}
function requiredEnvironment(name) {
const value = process.env[name]
if (value === undefined || value === '') {
throw new Error(`${name} is required`)
}
return value
}
function numberEnvironment(name, fallback) {
const value = Number(environment(name, String(fallback)))
if (!Number.isSafeInteger(value) || value < 1 || value > 65535) {
throw new Error(`${name} must be a valid port`)
}
return value
}
if (import.meta.url === pathToFileURL(process.argv[1] || '').href)
main().catch((error) => {
process.stderr.write(
`${JSON.stringify({
timestamp: new Date().toISOString(),
level: 'error',
component: 'remora-web',
message: error.message,
})}\n`,
)
process.exitCode = 1
})

105
web/server.test.mjs Normal file
View file

@ -0,0 +1,105 @@
import assert from 'node:assert/strict'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { after, before, test } from 'node:test'
import { createWebServer } from './server.mjs'
let base
let root
let server
before(async () => {
root = await mkdtemp(join(tmpdir(), 'remora-web-server-'))
await writeFile(join(root, 'index.html'), '<main>Remora</main>')
await writeFile(join(root, 'application.js'), 'application()')
await writeFile(join(root, 'application.js.br'), 'compressed')
const config = {
root,
publicOrigin: 'http://127.0.0.1',
}
const created = createWebServer(config, {
store: { ready: true },
oauth: { route: async () => false },
proxy: {
route: async () => false,
upgrade: async () => false,
},
})
server = created.server
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (typeof address !== 'object' || address === null)
throw new Error('server address unavailable')
base = `http://127.0.0.1:${address.port}`
})
after(async () => {
server.closeIdleConnections()
server.closeAllConnections()
await new Promise((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
)
await rm(root, { recursive: true })
})
test('serves health, metrics, runtime configuration, and security headers', async () => {
const ready = await fetch(`${base}/health/ready`)
assert.equal(ready.status, 200)
assert.deepEqual(await ready.json(), { status: 'ready' })
assert.equal(ready.headers.get('x-content-type-options'), 'nosniff')
assert.equal(ready.headers.get('x-frame-options'), 'DENY')
assert.match(
ready.headers.get('content-security-policy'),
/connect-src 'self'/,
)
const metrics = await fetch(`${base}/metrics`)
assert.match(await metrics.text(), /remora_web_requests_total/)
const runtime = await fetch(`${base}/runtime-config.js`)
const script = await runtime.text()
assert.match(script, /"apiUrl":"\/bff\/api\/"/)
assert.doesNotMatch(script, /oidc|client|secret/i)
assert.equal(runtime.headers.get('cache-control'), 'no-store')
})
test('serves immutable precompressed assets and correct HEAD responses', async () => {
const asset = await fetch(`${base}/application.js`, {
headers: { 'Accept-Encoding': 'br' },
})
assert.equal(asset.status, 200)
assert.equal(asset.headers.get('content-encoding'), 'br')
assert.equal(asset.headers.get('vary'), 'Accept-Encoding')
assert.match(asset.headers.get('cache-control'), /immutable/)
const head = await fetch(`${base}/application.js`, { method: 'HEAD' })
assert.equal(head.status, 200)
assert.equal(await head.text(), '')
assert.equal(
head.headers.get('content-type'),
'text/javascript; charset=utf-8',
)
})
test('limits the SPA fallback to extensionless HTML navigation', async () => {
const navigation = await fetch(`${base}/jobs/current`, {
headers: { Accept: 'text/html' },
})
assert.equal(navigation.status, 200)
assert.equal(await navigation.text(), '<main>Remora</main>')
const data = await fetch(`${base}/jobs/current`, {
headers: { Accept: 'application/json' },
})
assert.equal(data.status, 404)
const missingAsset = await fetch(`${base}/missing.js`, {
headers: { Accept: 'text/html' },
})
assert.equal(missingAsset.status, 404)
const traversal = await fetch(`${base}/%2e%2e/package.json`, {
headers: { Accept: 'text/html' },
})
assert.equal(traversal.status, 404)
})

61
web/server/config.mjs Normal file
View file

@ -0,0 +1,61 @@
export function loadConfig(environment = process.env) {
const publicOrigin = required(environment, 'REMORA_WEB_PUBLIC_ORIGIN')
const apiUpstream = required(environment, 'REMORA_WEB_API_UPSTREAM')
const oidcAuthority = required(environment, 'REMORA_WEB_OIDC_AUTHORITY')
return {
root: environment.REMORA_WEB_ROOT || '/app/dist',
port: port(environment.REMORA_WEB_PORT || '8080'),
publicOrigin: new URL(publicOrigin).origin,
apiUpstream: trailingSlash(new URL(apiUpstream)),
eventsUpstream: webSocketUrl(
environment.REMORA_WEB_EVENTS_UPSTREAM ||
new URL('events', trailingSlash(new URL(apiUpstream))).toString(),
),
oidcAuthority: oidcAuthority.replace(/\/$/, ''),
oidcClientId: required(environment, 'REMORA_WEB_OIDC_CLIENT_ID'),
oidcClientSecret: required(environment, 'REMORA_WEB_OIDC_CLIENT_SECRET'),
oidcScope: environment.REMORA_WEB_OIDC_SCOPE || 'openid profile',
sessionUrl: required(environment, 'REMORA_WEB_SESSION_URL'),
sessionNamespace:
environment.REMORA_WEB_SESSION_NAMESPACE || 'remora:web-session:',
sessionTtlSeconds: positiveInteger(
environment.REMORA_WEB_SESSION_TTL_SECONDS || '28800',
'REMORA_WEB_SESSION_TTL_SECONDS',
),
secureCookie: true,
}
}
function required(environment, name) {
const value = environment[name]
if (value === undefined || value === '')
throw new Error(`${name} is required`)
return value
}
function port(value) {
const parsed = positiveInteger(value, 'REMORA_WEB_PORT')
if (parsed > 65535) throw new Error('REMORA_WEB_PORT must be a valid port')
return parsed
}
function positiveInteger(value, name) {
const parsed = Number(value)
if (!Number.isSafeInteger(parsed) || parsed < 1)
throw new Error(`${name} must be a positive integer`)
return parsed
}
function trailingSlash(url) {
if (!url.pathname.endsWith('/')) url.pathname += '/'
return url
}
function webSocketUrl(value) {
const url = new URL(value)
if (url.protocol === 'http:') url.protocol = 'ws:'
else if (url.protocol === 'https:') url.protocol = 'wss:'
if (url.protocol !== 'ws:' && url.protocol !== 'wss:')
throw new Error('REMORA_WEB_EVENTS_UPSTREAM must use HTTP or WebSocket')
return url
}

395
web/server/oauth.mjs Normal file
View file

@ -0,0 +1,395 @@
import { createHash, createPublicKey, createVerify } from 'node:crypto'
import {
equalSecret,
expiredSessionCookie,
randomValue,
sessionCookie,
sessionIdentifier,
} from './sessions.mjs'
const CLOCK_SKEW_SECONDS = 30
const OIDC_TIMEOUT_MILLISECONDS = 15_000
export class OAuthClient {
constructor(config, store, fetchImplementation = fetch) {
this.config = config
this.store = store
this.fetch = fetchImplementation
this.discovery = null
}
async route(request, response, url) {
if (request.method === 'GET' && url.pathname === '/auth/login')
return this.login(request, response)
if (request.method === 'GET' && url.pathname === '/auth/callback')
return this.callback(request, response, url)
if (request.method === 'GET' && url.pathname === '/auth/session')
return this.session(request, response)
if (request.method === 'POST' && url.pathname === '/auth/logout')
return this.logout(request, response)
return false
}
async login(request, response) {
const previous = sessionIdentifier(request)
if (previous !== null) await this.store.destroy(previous)
const discovery = await this.metadata()
const verifier = randomValue(48)
const state = randomValue()
const nonce = randomValue()
const identifier = await this.store.create({
kind: 'authorization',
state,
nonce,
verifier,
})
const authorization = new URL(discovery.authorization_endpoint)
authorization.search = new URLSearchParams({
client_id: this.config.oidcClientId,
redirect_uri: `${this.config.publicOrigin}/auth/callback`,
response_type: 'code',
scope: this.config.oidcScope,
state,
nonce,
code_challenge: createHash('sha256').update(verifier).digest('base64url'),
code_challenge_method: 'S256',
}).toString()
response.writeHead(302, {
Location: authorization.toString(),
'Set-Cookie': sessionCookie(identifier, this.config),
'Cache-Control': 'no-store',
})
response.end()
return true
}
async callback(request, response, url) {
const identifier = sessionIdentifier(request)
const pending = identifier && (await this.store.read(identifier))
const state = url.searchParams.get('state')
const code = url.searchParams.get('code')
if (
pending?.kind !== 'authorization' ||
code === null ||
state === null ||
!equalSecret(state, pending.state)
)
return this.authenticationFailure(response, identifier)
try {
const discovery = await this.metadata()
const tokenResponse = await this.fetch(discovery.token_endpoint, {
method: 'POST',
headers: {
Authorization: clientAuthorization(this.config),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: `${this.config.publicOrigin}/auth/callback`,
code_verifier: pending.verifier,
}),
signal: AbortSignal.timeout(OIDC_TIMEOUT_MILLISECONDS),
})
if (!tokenResponse.ok) throw new Error('token exchange failed')
const tokens = await tokenResponse.json()
const identity = await this.validateIdToken(
tokens.id_token,
pending.nonce,
)
const csrfToken = randomValue()
const session = authenticatedSession(tokens, identity, csrfToken)
const replacement = await this.store.replace(identifier, session)
response.writeHead(302, {
Location: this.config.publicOrigin,
'Set-Cookie': sessionCookie(replacement, this.config),
'Cache-Control': 'no-store',
})
response.end()
} catch {
await this.authenticationFailure(response, identifier)
}
return true
}
async session(request, response) {
const loaded = await this.authenticated(request)
if (loaded === null) {
json(
response,
200,
{ kind: 'signed_out' },
{
'Set-Cookie': expiredSessionCookie(this.config),
},
)
return true
}
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)
if (
loaded === null ||
!validUnsafeRequest(request, this.config, loaded.session.csrfToken)
) {
json(response, 403, { error: 'invalid_csrf' })
return true
}
await this.store.destroy(loaded.identifier)
const discovery = await this.metadata().catch(() => null)
let location = this.config.publicOrigin
if (discovery?.end_session_endpoint) {
const logout = new URL(discovery.end_session_endpoint)
logout.searchParams.set(
'post_logout_redirect_uri',
this.config.publicOrigin,
)
logout.searchParams.set('id_token_hint', loaded.session.idToken)
location = logout.toString()
}
json(
response,
200,
{ redirect: location },
{ 'Set-Cookie': expiredSessionCookie(this.config) },
)
return true
}
async authenticated(request, refresh = true) {
const identifier = sessionIdentifier(request)
if (identifier === null) return null
let 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 }
}
async refresh(session) {
if (typeof session.refreshToken !== 'string') return null
const discovery = await this.metadata()
const response = await this.fetch(discovery.token_endpoint, {
method: 'POST',
headers: {
Authorization: clientAuthorization(this.config),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: session.refreshToken,
}),
signal: AbortSignal.timeout(OIDC_TIMEOUT_MILLISECONDS),
})
if (!response.ok) return null
const tokens = await response.json()
const idToken =
typeof tokens.id_token === 'string' ? tokens.id_token : session.idToken
const identity =
typeof tokens.id_token === 'string'
? await this.validateIdToken(tokens.id_token, undefined)
: session.identity
if (identity !== session.identity) return null
return authenticatedSession(
{
...tokens,
id_token: idToken,
refresh_token:
typeof tokens.refresh_token === 'string'
? tokens.refresh_token
: session.refreshToken,
},
identity,
session.csrfToken,
)
}
async metadata() {
if (this.discovery !== null) return this.discovery
const response = await this.fetch(
`${this.config.oidcAuthority}/.well-known/openid-configuration`,
{ signal: AbortSignal.timeout(OIDC_TIMEOUT_MILLISECONDS) },
)
if (!response.ok) throw new Error('OIDC discovery failed')
const metadata = await response.json()
for (const field of [
'issuer',
'authorization_endpoint',
'token_endpoint',
'jwks_uri',
])
if (typeof metadata[field] !== 'string')
throw new Error(`OIDC discovery omitted ${field}`)
if (metadata.issuer !== this.config.oidcAuthority)
throw new Error('OIDC discovery issuer mismatch')
this.discovery = metadata
return metadata
}
async validateIdToken(token, nonce) {
const [headerPart, payloadPart, signaturePart] = String(token).split('.')
if (!headerPart || !payloadPart || !signaturePart)
throw new Error('invalid ID token')
const header = JSON.parse(Buffer.from(headerPart, 'base64url').toString())
const claims = JSON.parse(Buffer.from(payloadPart, 'base64url').toString())
if (header.alg !== 'RS256' || typeof header.kid !== 'string')
throw new Error('unsupported ID token signature')
const discovery = await this.metadata()
const response = await this.fetch(discovery.jwks_uri, {
signal: AbortSignal.timeout(OIDC_TIMEOUT_MILLISECONDS),
})
if (!response.ok) throw new Error('JWKS fetch failed')
const jwks = await response.json()
const key = jwks.keys?.find((candidate) => candidate.kid === header.kid)
if (key === undefined) throw new Error('ID token key missing')
const verifier = createVerify('RSA-SHA256')
verifier.update(`${headerPart}.${payloadPart}`)
verifier.end()
if (
!verifier.verify(
createPublicKey({ key, format: 'jwk' }),
Buffer.from(signaturePart, 'base64url'),
)
)
throw new Error('invalid ID token signature')
const audience = Array.isArray(claims.aud) ? claims.aud : [claims.aud]
if (
claims.iss !== this.config.oidcAuthority ||
!audience.includes(this.config.oidcClientId) ||
(audience.length > 1 && claims.azp !== this.config.oidcClientId) ||
(nonce !== undefined && claims.nonce !== nonce) ||
typeof claims.sub !== 'string' ||
typeof claims.iat !== 'number' ||
claims.iat > now() + CLOCK_SKEW_SECONDS ||
typeof claims.exp !== 'number' ||
claims.exp <= now() - CLOCK_SKEW_SECONDS
)
throw new Error('invalid ID token claims')
return `${claims.iss}\u0000${claims.sub}`
}
async authenticationFailure(response, identifier) {
if (identifier) await this.store.destroy(identifier)
response.writeHead(302, {
Location: `${this.config.publicOrigin}/?authentication=failed`,
'Set-Cookie': expiredSessionCookie(this.config),
'Cache-Control': 'no-store',
})
response.end()
return true
}
}
export function validUnsafeRequest(request, config, csrfToken) {
return (
validBrowserContext(request, config) &&
equalSecret(request.headers['x-remora-csrf'], csrfToken)
)
}
export function validBrowserContext(request, config) {
const fetchSite = request.headers['sec-fetch-site']
return (
request.headers.origin === config.publicOrigin &&
(fetchSite === undefined ||
fetchSite === 'same-origin' ||
fetchSite === 'none')
)
}
function authenticatedSession(tokens, identity, csrfToken) {
if (
typeof tokens.access_token !== 'string' ||
typeof tokens.id_token !== 'string' ||
typeof tokens.expires_in !== 'number' ||
!Number.isFinite(tokens.expires_in) ||
tokens.expires_in <= 0
)
throw new Error('token response incomplete')
return {
kind: 'authenticated',
identity,
csrfToken,
accessToken: tokens.access_token,
refreshToken:
typeof tokens.refresh_token === 'string' ? tokens.refresh_token : null,
idToken: tokens.id_token,
accessExpiresAt: now() + tokens.expires_in,
}
}
function validAuthenticatedSession(session) {
return (
session?.kind === 'authenticated' &&
typeof session.identity === 'string' &&
session.identity !== '' &&
typeof session.csrfToken === 'string' &&
session.csrfToken !== '' &&
typeof session.accessToken === 'string' &&
session.accessToken !== '' &&
(session.refreshToken === null ||
(typeof session.refreshToken === 'string' &&
session.refreshToken !== '')) &&
typeof session.idToken === 'string' &&
session.idToken !== '' &&
Number.isFinite(session.accessExpiresAt) &&
session.accessExpiresAt > 0
)
}
function clientAuthorization(config) {
const identifier = formComponent(config.oidcClientId)
const secret = formComponent(config.oidcClientSecret)
return `Basic ${Buffer.from(`${identifier}:${secret}`).toString('base64')}`
}
function formComponent(value) {
return new URLSearchParams({ value }).toString().slice('value='.length)
}
function now() {
return Math.floor(Date.now() / 1000)
}
function json(response, status, value, headers = {}) {
const body = JSON.stringify(value)
response.writeHead(status, {
...headers,
'Cache-Control': 'no-store',
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
})
response.end(body)
}

244
web/server/oauth.test.mjs Normal file
View file

@ -0,0 +1,244 @@
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'
const identifier = 'a'.repeat(43)
const replacement = 'b'.repeat(43)
const now = Math.floor(Date.now() / 1000)
const { privateKey, publicKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
})
const publicJwk = publicKey.export({ format: 'jwk' })
publicJwk.kid = 'test-key'
publicJwk.alg = 'RS256'
publicJwk.use = 'sig'
const config = {
publicOrigin: 'https://remora.example.com',
oidcAuthority: 'https://identity.example.com',
oidcClientId: 'remora client',
oidcClientSecret: 'secret:value',
oidcScope: 'openid profile',
secureCookie: true,
}
test('completes authorization code flow without exposing tokens to the browser', async () => {
const store = new MemoryStore()
const requests = []
const oauth = new OAuthClient(config, store, async (url, init = {}) => {
requests.push({ url: String(url), init })
if (String(url).endsWith('/.well-known/openid-configuration'))
return Response.json(metadata())
if (String(url) === metadata().token_endpoint) {
assert.equal(
init.headers.Authorization,
`Basic ${Buffer.from('remora+client:secret%3Avalue').toString('base64')}`,
)
const parameters = new URLSearchParams(init.body)
assert.equal(parameters.get('code_verifier'), store.value.verifier)
return Response.json({
access_token: 'server-only-access-token',
refresh_token: 'server-only-refresh-token',
id_token: idToken(store.value.nonce),
expires_in: 300,
})
}
if (String(url) === metadata().jwks_uri)
return Response.json({ keys: [publicJwk] })
throw new Error(`unexpected request: ${url}`)
})
const login = new TestResponse()
await oauth.login({ headers: {} }, login)
const authorization = new URL(login.headers.Location)
assert.equal(authorization.searchParams.get('code_challenge_method'), 'S256')
assert.equal(authorization.searchParams.get('state'), store.value.state)
assert.equal(authorization.searchParams.get('nonce'), store.value.nonce)
assert.match(login.headers['Set-Cookie'], /HttpOnly; Secure; SameSite=Strict/)
const callback = new TestResponse()
await oauth.callback(
{ headers: { cookie: `${SESSION_COOKIE}=${identifier}` } },
callback,
new URL(
`/auth/callback?code=authorization-code&state=${store.value.state}`,
config.publicOrigin,
),
)
assert.equal(callback.status, 302)
assert.equal(callback.headers.Location, config.publicOrigin)
assert.match(callback.headers['Set-Cookie'], new RegExp(replacement))
assert.equal(store.value.kind, 'authenticated')
assert.equal(store.value.identity, `${config.oidcAuthority}\u0000subject-123`)
assert.equal(store.value.accessToken, 'server-only-access-token')
assert.equal(requests.length, 3)
const session = new TestResponse()
await oauth.session(
{ headers: { cookie: `${SESSION_COOKIE}=${replacement}` } },
session,
)
assert.deepEqual(JSON.parse(session.body), {
kind: 'authenticated',
identity: `${config.oidcAuthority}\u0000subject-123`,
csrfToken: store.value.csrfToken,
})
assert.doesNotMatch(session.body, /access|refresh|id_token/i)
})
test('destroys pending authorization when nonce validation fails', async () => {
const store = new MemoryStore()
const oauth = new OAuthClient(config, store, async (url) => {
if (String(url).endsWith('/.well-known/openid-configuration'))
return Response.json(metadata())
if (String(url) === metadata().token_endpoint)
return Response.json({
access_token: 'access',
id_token: idToken('wrong-nonce'),
expires_in: 300,
})
if (String(url) === metadata().jwks_uri)
return Response.json({ keys: [publicJwk] })
throw new Error(`unexpected request: ${url}`)
})
await oauth.login({ headers: {} }, new TestResponse())
const response = new TestResponse()
await oauth.callback(
{ headers: { cookie: `${SESSION_COOKIE}=${identifier}` } },
response,
new URL(
`/auth/callback?code=authorization-code&state=${store.value.state}`,
config.publicOrigin,
),
)
assert.equal(response.status, 302)
assert.equal(
response.headers.Location,
`${config.publicOrigin}/?authentication=failed`,
)
assert.equal(store.value, null)
})
test('reauthentication destroys the preceding browser session', async () => {
const store = new MemoryStore()
store.value = validSession()
const oauth = new OAuthClient(config, store, async () =>
Response.json(metadata()),
)
await oauth.login(
{ headers: { cookie: `${SESSION_COOKIE}=${replacement}` } },
new TestResponse(),
)
assert.deepEqual(store.destroyed, [replacement])
assert.equal(store.value.kind, 'authorization')
})
test('expired and malformed authenticated sessions are destroyed', async () => {
const store = new MemoryStore()
const oauth = new OAuthClient(config, store)
store.value = { kind: 'authenticated', accessToken: 'incomplete' }
assert.equal(
await oauth.authenticated(
{ headers: { cookie: `${SESSION_COOKIE}=${identifier}` } },
false,
),
null,
)
store.value = { ...validSession(), accessExpiresAt: now - 1 }
assert.equal(
await oauth.authenticated(
{ headers: { cookie: `${SESSION_COOKIE}=${identifier}` } },
false,
),
null,
)
assert.deepEqual(store.destroyed, [identifier, identifier])
})
class MemoryStore {
value = null
destroyed = []
async create(value) {
this.value = value
return identifier
}
async read() {
return this.value
}
async replace(_identifier, value) {
this.value = value
return replacement
}
async destroy(identifier) {
this.destroyed.push(identifier)
this.value = null
}
}
class TestResponse {
status = 0
headers = {}
body = ''
writeHead(status, headers) {
this.status = status
this.headers = headers
}
end(body = '') {
this.body = body
}
}
function metadata() {
return {
issuer: config.oidcAuthority,
authorization_endpoint: `${config.oidcAuthority}/oauth/authorize`,
token_endpoint: `${config.oidcAuthority}/oauth/token`,
jwks_uri: `${config.oidcAuthority}/oauth/keys`,
}
}
function validSession() {
return {
kind: 'authenticated',
identity: `${config.oidcAuthority}\u0000subject-123`,
csrfToken: 'csrf-value',
accessToken: 'access-token',
refreshToken: null,
idToken: 'id-token',
accessExpiresAt: now + 300,
}
}
function idToken(nonce) {
const header = encode({ alg: 'RS256', kid: publicJwk.kid })
const payload = encode({
iss: config.oidcAuthority,
aud: config.oidcClientId,
sub: 'subject-123',
nonce,
iat: now,
exp: now + 300,
})
const signer = createSign('RSA-SHA256')
signer.update(`${header}.${payload}`)
signer.end()
return `${header}.${payload}.${signer.sign(privateKey).toString('base64url')}`
}
function encode(value) {
return Buffer.from(JSON.stringify(value)).toString('base64url')
}

Some files were not shown because too many files have changed in this diff Show more