Move browser authentication behind a generated-contract BFF
This commit is contained in:
parent
6f0f20f5cc
commit
fa7220ee33
55 changed files with 7259 additions and 2825 deletions
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
|
||||||
1
contracts/typespec/package-lock.json
generated
1
contracts/typespec/package-lock.json
generated
|
|
@ -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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) -}}
|
||||||
|
|
|
||||||
|
|
@ -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 }}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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" \
|
||||||
|
|
|
||||||
|
|
@ -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
175
web/pnpm-lock.yaml
generated
|
|
@ -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]: {}
|
||||||
|
|
||||||
|
|
|
||||||
312
web/server.mjs
312
web/server.mjs
|
|
@ -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
105
web/server.test.mjs
Normal 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
61
web/server/config.mjs
Normal 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
395
web/server/oauth.mjs
Normal 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
244
web/server/oauth.test.mjs
Normal 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
Loading…
Reference in a new issue