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 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_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 ARG CARGO_BUILD_JOBS=4
FROM ${RUST_IMAGE} AS rust-build 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/public public
COPY web/src src COPY web/src src
RUN pnpm build RUN pnpm build
RUN CI=true pnpm prune --prod
FROM ${NODE_IMAGE} AS web FROM ${NODE_RUNTIME_IMAGE} AS web
WORKDIR /app WORKDIR /app
COPY --from=web-build /workspace/dist dist 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.mjs server.mjs
COPY web/server server
USER 65532:65532 USER 65532:65532
ENTRYPOINT ["node", "/app/server.mjs"] ENTRYPOINT ["node", "/app/server.mjs"]

View file

@ -3,6 +3,7 @@ import { readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path"; import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { parse, stringify } from "yaml"; import { parse, stringify } from "yaml";
import { format } from "prettier";
const root = dirname(fileURLToPath(import.meta.url)); const root = dirname(fileURLToPath(import.meta.url));
const repository = resolve(root, "../.."); const repository = resolve(root, "../..");
@ -62,12 +63,31 @@ for (const schema of Object.values(bundle.$defs)) {
delete schema.$schema; delete schema.$schema;
delete schema.$id; delete schema.$id;
} }
assertWebSchemasSupported(bundle.$defs);
const events = { const events = {
...bundle, ...bundle,
$id: "https://remora.invalid/api/events.schema.json", $id: "https://remora.invalid/api/events.schema.json",
title: "Remora WebSocket frame", title: "Remora WebSocket frame",
$ref: "#/$defs/EventFrame", $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 = [ const outputs = [
[resolve(repository, "docs/api/openapi.yaml"), openapi], [resolve(repository, "docs/api/openapi.yaml"), openapi],
[resolve(repository, "docs/api/events.schema.json"), `${JSON.stringify(events, null, 2)}\n`], [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"), resolve(repository, "crates/remora-contracts/src/generated_wire.rs"),
readFileSync(generatedRust, "utf8"), 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; let stale = false;
@ -105,3 +128,179 @@ function rewriteBundleReferences(value) {
} }
Object.values(value).forEach(rewriteBundleReferences); 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/json-schema": "1.14.0",
"@typespec/openapi": "1.14.0", "@typespec/openapi": "1.14.0",
"@typespec/openapi3": "1.14.0", "@typespec/openapi3": "1.14.0",
"prettier": "3.9.5",
"yaml": "2.9.0" "yaml": "2.9.0"
} }
}, },

View file

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

View file

@ -6,6 +6,9 @@
{{- if ge (int .Values.runtime.workerRenewSeconds) (int .Values.coordination.leaseTtlSeconds) -}} {{- if ge (int .Values.runtime.workerRenewSeconds) (int .Values.coordination.leaseTtlSeconds) -}}
{{- fail "runtime.workerRenewSeconds must be less than coordination.leaseTtlSeconds" -}} {{- fail "runtime.workerRenewSeconds must be less than coordination.leaseTtlSeconds" -}}
{{- end -}} {{- 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 -}} {{- range $entry := splitList "," .Values.outbound.allowedSchemePorts -}}
{{- $parts := splitList ":" $entry -}} {{- $parts := splitList ":" $entry -}}
{{- $port := int (index $parts 1) -}} {{- $port := int (index $parts 1) -}}

View file

@ -200,4 +200,22 @@ spec:
- {port: 53, protocol: UDP} - {port: 53, protocol: UDP}
- {port: 53, protocol: TCP} - {port: 53, protocol: TCP}
{{- end }} {{- 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 }} {{- end }}

View file

@ -55,22 +55,39 @@ spec:
imagePullPolicy: {{ .Values.web.image.pullPolicy }} imagePullPolicy: {{ .Values.web.image.pullPolicy }}
env: env:
- {name: REMORA_WEB_PUBLIC_ORIGIN, value: {{ required "web.publicOrigin is required" .Values.web.publicOrigin | quote }}} - {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_API_UPSTREAM, value: {{ printf "http://%s.%s.svc.cluster.local/v1/" (include "remora.fullname" .) .Release.Namespace | quote }}}
- {name: REMORA_WEB_EVENTS_URL, value: {{ .Values.web.eventsUrl | 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_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_CLIENT_ID, value: {{ .Values.web.oidcClientId | quote }}}
- {name: REMORA_WEB_OIDC_REDIRECT_URI, value: {{ .Values.web.publicOrigin | quote }}} - name: REMORA_WEB_OIDC_CLIENT_SECRET
- {name: REMORA_WEB_OIDC_POST_LOGOUT_REDIRECT_URI, value: {{ .Values.web.publicOrigin | quote }}} 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_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: ports:
- {name: http, containerPort: 8080} - {name: http, containerPort: 8080}
readinessProbe: readinessProbe:
httpGet: {path: /health/ready, port: http} httpGet: {path: /health/ready, port: http}
periodSeconds: 5 periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 6
livenessProbe: livenessProbe:
httpGet: {path: /health/live, port: http} httpGet: {path: /health/live, port: http}
periodSeconds: 10 periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
startupProbe:
httpGet: {path: /health/live, port: http}
periodSeconds: 2
failureThreshold: 30
resources: resources:
{{- toYaml .Values.web.resources | nindent 12 }} {{- toYaml .Values.web.resources | nindent 12 }}
securityContext: securityContext:

View file

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

View file

@ -43,11 +43,15 @@ web:
digest: "" digest: ""
pullPolicy: IfNotPresent pullPolicy: IfNotPresent
publicOrigin: https://remora.example.com publicOrigin: https://remora.example.com
apiUrl: /v1/
eventsUrl: ""
oidcClientId: remora-web oidcClientId: remora-web
oidcClientSecretSecret:
name: remora-web-oidc
key: client-secret
oidcScope: openid profile oidcScope: openid profile
cspConnectSrc: "'self' https://identity.example.com" sessionUrlSecret:
name: remora-web-session
key: url
sessionTtlSeconds: 28800
resources: resources:
requests: requests:
cpu: 25m cpu: 25m
@ -143,6 +147,10 @@ networkPolicy:
- fixture.remora.dev/component: zitadel - fixture.remora.dev/component: zitadel
- fixture.remora.dev/component: versitygw - fixture.remora.dev/component: versitygw
externalEgressCidrs: [] externalEgressCidrs: []
webDependencyPodLabels:
- fixture.remora.dev/component: dragonfly
- fixture.remora.dev/component: zitadel
webExternalEgressCidrs: []
allowPublicSourceEgress: true allowPublicSourceEgress: true
observability: 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. | | `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`. | | `CommittedResult` | The concrete durable descriptor corresponding to the attempt committed by `JobProtocol`. |
| `WebClient` | The browser-based user interface for submitting and managing jobs. | | `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. | | `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. | | `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. | | `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 ## 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. `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 end
subgraph Service subgraph Service
WebBFF[WebBFF]
API[API] API[API]
Worker[Worker] Worker[Worker]
end end
@ -88,8 +91,9 @@ flowchart TD
TargetStore[TargetStore] TargetStore[TargetStore]
end end
WebClient -- OIDC --> IdentityProvider WebClient -- Session + CSRF --> WebBFF
WebClient -- API + live updates --> API WebBFF -- OAuth + OIDC --> IdentityProvider
WebBFF -- Bearer API + live updates --> API
IdentityProvider -- Identity and roles --> API IdentityProvider -- Identity and roles --> API
API -- Submit and coordinate --> CoordinationStore API -- Submit and coordinate --> CoordinationStore
API -- Record and query --> DurableStore API -- Record and query --> DurableStore
@ -100,6 +104,19 @@ flowchart TD
StorageAdapter --> TargetStore 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 ## 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. - 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 | | Observability | Expose job status, progress, and errors through the UI and API |
| Scalability | Support multiple stateless API and worker replicas using shared state services | | Scalability | Support multiple stateless API and worker replicas using shared state services |
| Performance | Stream multi-gigabyte objects without buffering complete objects in worker memory | | 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 | | Deployment | Deploy as a standard Kubernetes service using externally managed identity, state, and object storage |
## Responsibility Boundaries ## Responsibility Boundaries

View file

@ -1,6 +1,6 @@
# Kubernetes Deployment # 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 CloudNativePG, Dragonfly, Vault, ZITADEL, and VersityGW remain externally
managed. managed.
@ -19,8 +19,12 @@ managed.
- Dragonfly uses a deployment-unique namespace. - Dragonfly uses a deployment-unique namespace.
`REMORA_GLOBAL_ATTEMPT_LIMIT` and `REMORA_WORKER_LOCAL_ATTEMPT_LIMIT` remain `REMORA_GLOBAL_ATTEMPT_LIMIT` and `REMORA_WORKER_LOCAL_ATTEMPT_LIMIT` remain
independent. independent.
- ZITADEL supplies the issuer, project audience, client identifier, and exact - The WebBFF stores ephemeral WebSessions through a WebBFF-only Dragonfly
project-role claim. 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 - VersityGW uses dedicated credentials and a bucket whose `_remora` namespace
excludes other writers and lifecycle expiry. Deployments unable to establish excludes other writers and lifecycle expiry. Deployments unable to establish
both properties are unsupported. both properties are unsupported.
@ -28,13 +32,18 @@ managed.
## Security ## Security
NetworkPolicies default-deny each workload, isolate the Worker and management 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 non-global and metadata-capable IPv4 ranges. Private sources require matching
application and NetworkPolicy CIDR allowances. application and NetworkPolicy CIDR allowances.
Containers run non-root with read-only roots, dropped capabilities, resource Containers run non-root with read-only roots, dropped capabilities, resource
limits, and no service links. Worker temporary storage is bounded. 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 ## Operations
API and Worker expose liveness, dependency-aware readiness, and Prometheus API and Worker expose liveness, dependency-aware readiness, and Prometheus

View file

@ -30,14 +30,33 @@ spec:
args: args:
- | - |
retry="--retry 30 --retry-all-errors --retry-delay 1" 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 expect_status() {
test "$(curl $retry --silent --output /dev/null --write-out '%{http_code}' http://remora-remora-web/health/ready)" = 200 name="$1"
curl $retry --fail --silent http://remora-remora-api-metrics:9090/metrics | grep -q remora_background_runs_total expected="$2"
curl $retry --fail --silent http://remora-remora-web/runtime-config.js | grep -q '"oidcClientId":"remora-web"' shift 2
test "$(curl $retry --silent --output /dev/null --write-out '%{http_code}' http://remora-remora/v1/jobs)" = 401 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 if test -n "@@INGRESS_URL@@"; then
curl $retry --fail --silent --header 'Host: qualification-ingress' @@INGRESS_URL@@/ | grep -q '<title>Remora</title>' expect_body "Ingress WebClient" '<title>Remora</title>' --header 'Host: qualification-ingress' --header 'Accept: text/html' @@INGRESS_URL@@/
test "$(curl $retry --silent --header 'Host: qualification-ingress' --output /dev/null --write-out '%{http_code}' @@INGRESS_URL@@/v1/jobs)" = 401 expect_status "Ingress API authentication" 401 --header 'Host: qualification-ingress' @@INGRESS_URL@@/v1/jobs
fi 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 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 exit 1

View file

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

View file

@ -10,27 +10,30 @@
}, },
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build && node tools/compress-assets.mjs && node tools/check-bundle.mjs",
"preview": "vite preview", "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", "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": "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", "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": "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": "vitest run --config vitest.browser.config.ts",
"test:component:full": "vitest run --config vitest.browser-full.config.ts", "test:component:full": "vitest run --config vitest.browser-full.config.ts",
"test:e2e": "playwright test --project=chromium", "test:e2e": "playwright test --project=chromium",
"test:e2e:full": "playwright test" "test:e2e:full": "playwright test"
}, },
"dependencies": { "dependencies": {
"oidc-client-ts": "3.5.0", "redis": "6.1.0",
"svelte": "5.56.5" "svelte": "5.56.5",
"ws": "8.21.1"
}, },
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "4.12.1",
"@playwright/test": "1.61.1", "@playwright/test": "1.61.1",
"@redocly/cli": "2.33.1", "@redocly/cli": "2.33.1",
"@sveltejs/vite-plugin-svelte": "7.2.0", "@sveltejs/vite-plugin-svelte": "7.2.0",
"@types/node": "26.1.1", "@types/node": "26.1.1",
"@types/ws": "8.18.1",
"@vitest/browser-playwright": "4.1.10", "@vitest/browser-playwright": "4.1.10",
"oxlint": "1.74.0", "oxlint": "1.74.0",
"oxlint-tsgolint": "0.24.0", "oxlint-tsgolint": "0.24.0",

175
web/pnpm-lock.yaml generated
View file

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

View file

@ -1,33 +1,28 @@
import { createReadStream, statSync } from 'node:fs' import { createReadStream, statSync } from 'node:fs'
import { createServer } from 'node:http' import { createServer } from 'node:http'
import { extname, resolve, sep } from 'node:path' import { extname, resolve, sep } from 'node:path'
import { pathToFileURL } from 'node:url'
const root = resolve(process.env.REMORA_WEB_ROOT ?? '/app/dist') import { loadConfig } from './server/config.mjs'
const port = numberEnvironment('REMORA_WEB_PORT', 8080) import { OAuthClient } from './server/oauth.mjs'
const runtimeConfig = { import { ApiProxy } from './server/proxy.mjs'
apiUrl: environment('REMORA_WEB_API_URL', '/v1/'), import { SessionStore } from './server/sessions.mjs'
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
createServer((request, response) => { 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 requests += 1
const traceId = traceIdentifier(request.headers.traceparent) const traceId = traceIdentifier(request.headers.traceparent)
response.setHeader('X-Content-Type-Options', 'nosniff') secureHeaders(response, traceId)
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', () => { response.once('finish', () => {
process.stdout.write( process.stdout.write(
`${JSON.stringify({ `${JSON.stringify({
@ -41,28 +36,66 @@ createServer((request, response) => {
})}\n`, })}\n`,
) )
}) })
route(request, response) try {
}).listen(port, '0.0.0.0', () => { await route(
process.stdout.write( request,
`${JSON.stringify({ response,
timestamp: new Date().toISOString(), config,
level: 'info', store,
component: 'remora-web', oauth,
message: 'web application ready', proxy,
port, startedAt,
})}\n`, requests,
) )
}) } catch {
if (!response.headersSent)
response.writeHead(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()
}
})
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') { if (request.method !== 'GET' && request.method !== 'HEAD') {
response.writeHead(405, { Allow: 'GET, HEAD' }) response.writeHead(405, { Allow: 'GET, HEAD' })
response.end() response.end()
return return
} }
const url = new URL(request.url ?? '/', 'http://localhost') if (url.pathname === '/health/live') {
if (url.pathname === '/health/live' || url.pathname === '/health/ready') { json(response, 200, { status: 'live' }, request.method)
json(response, 200, { status: 'ready' }) return
}
if (url.pathname === '/health/ready') {
json(
response,
store.ready ? 200 : 503,
{ status: store.ready ? 'ready' : 'unavailable' },
request.method,
)
return return
} }
if (url.pathname === '/metrics') { if (url.pathname === '/metrics') {
@ -81,9 +114,10 @@ function route(request, response) {
return return
} }
if (url.pathname === '/runtime-config.js') { if (url.pathname === '/runtime-config.js') {
const body = `window.remoraRuntimeConfig=${JSON.stringify( const body = `window.remoraRuntimeConfig=${JSON.stringify({
normalizedRuntimeConfig(), apiUrl: '/bff/api/',
)};\n` eventsUrl: `${config.publicOrigin.replace(/^http/, 'ws')}/bff/events`,
})};\n`
response.writeHead(200, { response.writeHead(200, {
'Cache-Control': 'no-store', 'Cache-Control': 'no-store',
'Content-Type': 'text/javascript; charset=utf-8', 'Content-Type': 'text/javascript; charset=utf-8',
@ -92,27 +126,116 @@ function route(request, response) {
response.end(request.method === 'HEAD' ? undefined : body) response.end(request.method === 'HEAD' ? undefined : body)
return return
} }
const candidate = safeFile(url.pathname) serveStatic(request, response, url, config.root)
const file = candidate ?? resolve(root, 'index.html') }
const body = regularFile(file) ? file : resolve(root, 'index.html')
if (!regularFile(body)) { 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.writeHead(404)
response.end() response.end()
return return
} }
const size = statSync(body).size const compressed = compressedFile(file, request.headers['accept-encoding'])
response.writeHead(200, { const served = compressed?.path ?? file
'Cache-Control': body.endsWith('index.html') const size = statSync(served).size
const headers = {
'Cache-Control': file.endsWith('index.html')
? 'no-cache' ? 'no-cache'
: 'public, max-age=31536000, immutable', : 'public, max-age=31536000, immutable',
'Content-Type': contentType(body), 'Content-Type': contentType(file),
'Content-Length': size, '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) { function requestPath(value) {
@ -123,32 +246,7 @@ function requestPath(value) {
} }
} }
function normalizedRuntimeConfig() { function safeFile(pathname, root) {
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) {
let decoded let decoded
try { try {
decoded = decodeURIComponent(pathname) 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) const body = JSON.stringify(value)
response.writeHead(status, { response.writeHead(status, {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body), 'Content-Length': Buffer.byteLength(body),
}) })
response.end(body) response.end(method === 'HEAD' ? undefined : body)
} }
function contentType(path) { 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) { function traceIdentifier(traceparent) {
const match = const match =
typeof traceparent === 'string' typeof traceparent === 'string'
@ -204,23 +297,32 @@ function traceIdentifier(traceparent) {
return match?.[1] ?? crypto.randomUUID().replaceAll('-', '') return match?.[1] ?? crypto.randomUUID().replaceAll('-', '')
} }
function environment(name, fallback) { async function main() {
const value = process.env[name] const config = loadConfig()
return value === undefined || value === '' ? fallback : value 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) { if (import.meta.url === pathToFileURL(process.argv[1] || '').href)
const value = process.env[name] main().catch((error) => {
if (value === undefined || value === '') { process.stderr.write(
throw new Error(`${name} is required`) `${JSON.stringify({
} timestamp: new Date().toISOString(),
return value level: 'error',
} component: 'remora-web',
message: error.message,
function numberEnvironment(name, fallback) { })}\n`,
const value = Number(environment(name, String(fallback))) )
if (!Number.isSafeInteger(value) || value < 1 || value > 65535) { process.exitCode = 1
throw new Error(`${name} must be a valid port`) })
}
return value
}

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