Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions book/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- [FHIRPath Expressions](ch05-fhirpath.md)
- [SQL-on-FHIR](ch06-sql-on-fhir.md)
- [Natural-Language Search](components/natural-language-search.md)
- [Web UI Self-Calls and Authentication](components/web-ui-self-calls.md)
- [Architecture Overview](ch07-architecture.md)
- [Multi-Version FHIR Support](ch08-versions.md)
- [Python Bindings (pysof)](ch09-pysof.md)
Expand Down
44 changes: 44 additions & 0 deletions book/src/components/web-ui-self-calls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Web UI Self-Calls and Authentication

The HFS web UI reads its conformance data — the SearchParameter registry and
the CompartmentDefinition set — from the server's **own FHIR API** over HTTP
(`GET /SearchParameter`, `GET /CompartmentDefinition` on the loopback
address). Primary storage is the source of truth, and the UI shows exactly
what the server serves; there is no side channel and **no auth carve-out**:
the self-call is authenticated exactly like any other client when
authentication is enabled.

## Supported modes

| Mode | Configuration | Self-call behavior |
|------|---------------|--------------------|
| **Off** (default) | `HFS_AUTH_ENABLED` unset or `false` | The self-call carries no credentials; everything works out of the box. |
| **Static bearer** | `HFS_AUTH_ENABLED=true` + `HFS_OUTBOUND_BEARER_TOKEN=<token>` | The self-call attaches the provisioned token. The token must be valid against the server's own validation config (`HFS_AUTH_JWKS_URL`, `HFS_AUTH_ISSUER`, `HFS_AUTH_AUDIENCE`) and carry the read scopes `system/SearchParameter.rs system/CompartmentDefinition.rs`. |
| **IdP-issued** | As above, with the token minted by your identity provider (e.g. a Keycloak service-account client) | Same as static bearer — the operator obtains a token via `client_credentials` and provisions it. Note the token's lifetime: when it expires the self-call starts failing and the pages degrade until a fresh token is provisioned. |

Leave the token's **tenant claim unset** for service tokens: the tenant claim
is authoritative when present, and the self-call scopes each request with
`X-Tenant-ID` so every tenant's conformance view stays reachable. A token
pinned to one tenant pins every conformance page to that tenant.

## Degraded state

With authentication enabled and **no valid outbound token**, the self-call is
rejected and the conformance pages **degrade to a warning** — they render the
page shell with a notice instead of data (never a 404 or a crash). The failed
fetch is **not cached**: the next request re-attempts it, so provisioning a
token (or fixing an expired one) heals the pages without a restart.

Both paths are exercised in CI by the browser suite's `auth` and
`auth-degraded` legs (`crates/ui/e2e`, #320), which boot the server against a
throwaway JWKS and mint the service token locally.

## Planned: self-minted service tokens

The static token puts key rotation on the operator. The planned follow-up is
a `JwtAssertionOutboundAuthProvider` (see `crates/auth/src/outbound.rs` and
the note in `crates/hfs/src/main.rs`): SMART Backend Services
`client_credentials` with `private_key_jwt`, configured from `HFS_UI_*`
client credentials, minting short-lived, auto-refreshed tokens with exactly
the two conformance read scopes. Until then, provision
`HFS_OUTBOUND_BEARER_TOKEN` with a long-lived token from your IdP.
63 changes: 61 additions & 2 deletions crates/ui/e2e/boot.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
// --features ui`), points it at a throwaway SQLite DB, and serves /ui. Playwright's
// webServer waits for the port, then tears this down.
import { spawn } from "node:child_process";
import { generateKeyPairSync, sign } from "node:crypto";
import { existsSync, rmSync, statSync } from "node:fs";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
Expand Down Expand Up @@ -32,17 +34,73 @@ if (!bin) {
// seeding write ~1.4k resources one insert (and one fsync) at a time, which
// on the container's overlayfs turns tenant creation into a minutes-long
// request. /dev/shm makes those fsyncs free without changing what is tested.
const port = process.env.HFS_E2E_PORT || "8080";
const dbDir = existsSync("/dev/shm") ? "/dev/shm" : tmpdir();
const db = join(dbDir, ".hfs-e2e.db");
// Per-port DB: the auth legs (#320) run their own servers next to this one.
const db = join(dbDir, `.hfs-e2e-${port}.db`);
for (const suffix of ["", "-wal", "-shm"]) {
try { rmSync(db + suffix, { force: true }); } catch {}
}

/*
* Auth legs (#320). HFS_E2E_AUTH turns bearer authentication on, backed by a
* throwaway RSA key served as a JWKS from a local HTTP server — the whole IdP
* surface the server needs for validation, with no external dependency:
*
* - "token": HFS_OUTBOUND_BEARER_TOKEN carries a minted service token with
* the conformance read scopes, so the UI's self-fetch works.
* - "degraded": auth is on but no outbound token is provisioned — the
* self-fetch is rejected and the pages must degrade, not 404.
*
* The token carries no tenant claim on purpose: the claim is authoritative
* when present, and the self-fetch scopes each call with X-Tenant-ID instead.
*/
const AUTH_MODE = process.env.HFS_E2E_AUTH || "";
const ISSUER = "https://e2e.hfs.invalid/issuer";
const AUDIENCE = "hfs-e2e";
let authEnv = {};
if (AUTH_MODE) {
const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
const kid = "hfs-e2e-key";
const jwks = JSON.stringify({
keys: [{ ...publicKey.export({ format: "jwk" }), kid, alg: "RS256", use: "sig" }],
});
const jwksPort = Number(port) + 1;
createServer((req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(jwks);
}).listen(jwksPort, "127.0.0.1");

const b64url = (buf) => Buffer.from(buf).toString("base64url");
const mint = (claims) => {
const header = b64url(JSON.stringify({ alg: "RS256", typ: "JWT", kid }));
const now = Math.floor(Date.now() / 1000);
const payload = b64url(
JSON.stringify({ iss: ISSUER, aud: AUDIENCE, iat: now, exp: now + 86400, ...claims }),
);
const signature = b64url(sign("sha256", Buffer.from(`${header}.${payload}`), privateKey));
return `${header}.${payload}.${signature}`;
};

authEnv = {
HFS_AUTH_ENABLED: "true",
HFS_AUTH_JWKS_URL: `http://127.0.0.1:${jwksPort}/jwks.json`,
HFS_AUTH_ISSUER: ISSUER,
HFS_AUTH_AUDIENCE: AUDIENCE,
};
if (AUTH_MODE === "token") {
authEnv.HFS_OUTBOUND_BEARER_TOKEN = mint({
sub: "hfs-ui-self",
scope: "system/SearchParameter.rs system/CompartmentDefinition.rs",
});
}
}

const child = spawn(bin, [], {
stdio: "inherit",
env: {
...process.env,
HFS_SERVER_PORT: process.env.HFS_E2E_PORT || "8080",
HFS_SERVER_PORT: port,
HFS_STORAGE_BACKEND: "sqlite",
HFS_DATABASE_URL: db,
// Load the vendored SearchParameter specs (so search works like production),
Expand All @@ -52,6 +110,7 @@ const child = spawn(bin, [], {
// Natural-language search advertises itself as configured so the search
// area renders its working pane. The key is never used by the tests.
HFS_NL_SEARCH_API_KEY: "e2e-placeholder-not-a-real-key",
...authEnv,
},
});

Expand Down
60 changes: 50 additions & 10 deletions crates/ui/e2e/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export default defineConfig({
{
// The JS-enabled ring: theme behavior, axe-core a11y, no-CDN invariants.
name: "chromium",
testIgnore: "**/nojs/**",
testIgnore: ["**/nojs/**", "**/auth/**"],
use: { ...devices["Desktop Chrome"] },
},
{
Expand All @@ -36,21 +36,61 @@ export default defineConfig({
testMatch: "**/nojs/**/*.spec.ts",
use: { ...devices["Desktop Chrome"], javaScriptEnabled: false },
},
// The auth legs (#320) drive their own servers (see webServer below), so
// they only exist when this run boots its servers itself.
...(externalBase
? []
: [
{
// Auth enabled + outbound service token: conformance self-fetch works.
name: "auth",
testMatch: "**/auth/conformance.spec.ts",
use: { ...devices["Desktop Chrome"], baseURL: `http://127.0.0.1:${PORT + 10}` },
},
{
// Auth enabled, no outbound token: the pages degrade, never 404.
name: "auth-degraded",
testMatch: "**/auth/degraded.spec.ts",
use: { ...devices["Desktop Chrome"], baseURL: `http://127.0.0.1:${PORT + 20}` },
},
]),
],
// Boot our own hfs only when no external server was handed to us. The backend
// matrix launches hfs on the host and sets HFS_E2E_BASE_URL, so there we skip
// this entirely and drive the already-running server.
...(externalBase
? {}
: {
webServer: {
command: "node boot.mjs",
// Readiness probe: the FHIR root does not 200, but /ui does.
url: `${baseURL}/ui`,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
stdout: "pipe",
stderr: "pipe",
},
webServer: [
{
command: "node boot.mjs",
// Readiness probe: the FHIR root does not 200, but /ui does.
url: `${baseURL}/ui`,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
stdout: "pipe",
stderr: "pipe",
},
// The auth legs (#320): same binary, bearer auth enabled against a
// throwaway JWKS boot.mjs serves on the port next to the server's.
{
command: "node boot.mjs",
url: `http://127.0.0.1:${PORT + 10}/ui`,
env: { HFS_E2E_PORT: String(PORT + 10), HFS_E2E_AUTH: "token" },
reuseExistingServer: !process.env.CI,
timeout: 120_000,
stdout: "pipe",
stderr: "pipe",
},
{
command: "node boot.mjs",
url: `http://127.0.0.1:${PORT + 20}/ui`,
env: { HFS_E2E_PORT: String(PORT + 20), HFS_E2E_AUTH: "degraded" },
reuseExistingServer: !process.env.CI,
timeout: 120_000,
stdout: "pipe",
stderr: "pipe",
},
],
}),
});
37 changes: 37 additions & 0 deletions crates/ui/e2e/tests/auth/conformance.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { test, expect } from "../../pages/fixtures";

// #320, leg 1: auth enabled AND an outbound service token provisioned
// (HFS_OUTBOUND_BEARER_TOKEN, minted by boot.mjs against the throwaway JWKS).
// The UI's self-fetch of /SearchParameter and /CompartmentDefinition carries
// that token, so the conformance pages must render real data — no carve-outs
// for the loopback call.

test("search parameters render real registry data under auth", async ({
page,
searchParameters,
}) => {
await searchParameters.goto();
// No degraded-fetch warning...
await expect(page.locator(".notice--warn")).toHaveCount(0);
// ...and the full spec registry came through the authenticated self-call.
await expect(searchParameters.rows.first()).toBeVisible();
const total = await page.locator("#sp-rail-list .filter-rail__item .count").first().innerText();
expect(Number(total.replace(/\D/g, ""))).toBeGreaterThan(500);
});

test("compartments render the five spec definitions under auth", async ({ page }) => {
await page.goto("/ui/compartments", { waitUntil: "networkidle" });
await expect(page.locator(".notice--warn")).toHaveCount(0);
for (const code of ["Patient", "Encounter", "Practitioner", "RelatedPerson", "Device"]) {
await expect(page.locator(".filter-rail__item", { hasText: code })).toBeVisible();
}
});

test("a direct FHIR call without a token is still rejected", async ({ request }) => {
// The pages work because the self-call carries the service token — not
// because auth grew a loophole. An anonymous API call stays a 401.
const bare = await request.get("/SearchParameter?_count=1", {
headers: { Accept: "application/fhir+json" },
});
expect(bare.status()).toBe(401);
});
28 changes: 28 additions & 0 deletions crates/ui/e2e/tests/auth/degraded.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { test, expect } from "../../pages/fixtures";

// #320, leg 2: auth enabled but NO outbound token provisioned. The self-fetch
// is rejected (401), and the conformance pages must degrade to their warning
// state — no crash, no empty 404 — and re-attempt the fetch on the next
// request instead of caching the failure.

test("search parameters degrade to the warning state", async ({ page, searchParameters }) => {
await searchParameters.goto();
await expect(page.locator(".notice--warn")).toBeVisible();
await expect(searchParameters.rows).toHaveCount(0);
});

test("compartments degrade to a warning page, not a 404", async ({ page }) => {
const response = await page.goto("/ui/compartments", { waitUntil: "networkidle" });
expect(response?.status()).toBe(200);
await expect(page.locator(".notice--warn")).toBeVisible();
await expect(page.locator("h1.page-head__title")).toBeVisible();
});

test("the degraded fetch is retried, not cached", async ({ page, searchParameters }) => {
// Two consecutive loads both warn — and both actually hit the API again:
// the failed snapshot is served degraded for its request only.
await searchParameters.goto();
await expect(page.locator(".notice--warn")).toBeVisible();
await searchParameters.goto();
await expect(page.locator(".notice--warn")).toBeVisible();
});
19 changes: 18 additions & 1 deletion crates/ui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,16 @@ struct CompartmentsPage {
view: compartments::CmpView,
}

/// The compartments page when no definitions could be fetched (#320): the
/// shell with a warning, in place of the data-bearing view.
#[derive(Template)]
#[template(path = "pages/compartments-degraded.html")]
struct CompartmentsDegradedPage {
status: Status,
i18n: I18n,
active_page: &'static str,
}

#[derive(Template)]
#[template(path = "partials/status.html")]
struct StatusPartial {
Expand Down Expand Up @@ -1052,7 +1062,14 @@ async fn compartments_page(
active_page: "compartments",
view,
}),
None => StatusCode::NOT_FOUND.into_response(),
// No definitions means the self-fetch degraded (an outage, or auth
// without an outbound token, #320) — a warning, not a 404. The failed
// fetch is not cached, so the next request re-attempts it.
None => render(CompartmentsDegradedPage {
status: current_status(state.version, rv.0, &rt),
i18n: I18n::new(locale),
active_page: "compartments",
}),
}
}

Expand Down
15 changes: 15 additions & 0 deletions crates/ui/templates/pages/compartments-degraded.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{% extends "layouts/base.html" %}

{% block title %}{{ i18n.t("cmp-heading") }} — {{ i18n.t("app-title") }}{% endblock %}

{% block content %}
<section class="page-head">
<h1 class="page-head__title">{{ i18n.t("cmp-heading") }}</h1>
<p class="page-head__lede">{{ i18n.t("cmp-lede") }}</p>
</section>

<!-- The definitions self-fetch failed or returned nothing (an outage, or an
auth-enabled server whose outbound service token is missing, #320). The
failed fetch is not cached, so the next request tries again. -->
<p class="notice notice--warn">{{ i18n.t("cmp-degraded") }}</p>
{% endblock %}
30 changes: 30 additions & 0 deletions crates/ui/tests/router_http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,36 @@ async fn search_parameters_selection_renders_the_detail_panel() {
assert!(html.contains("Patient.name"));
}

/// #320: when the conformance self-fetch yields nothing (an outage, or auth
/// without an outbound service token), the compartments page degrades to a
/// warning — it must not 404.
#[tokio::test]
async fn compartments_degrade_to_a_warning_when_the_fetch_is_empty() {
let response = helios_ui::mount_with_conformance_source(
Router::new(),
"9.9.9",
Some(std::path::PathBuf::from("../../data")),
nl(true, true),
None,
None,
"default".to_string(),
std::sync::Arc::new(helios_ui::StaticConformanceSource::empty()),
helios_fhir::FhirVersion::R4,
)
.oneshot(
Request::get("/ui/compartments")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();

assert_eq!(response.status(), StatusCode::OK);
let html = body_text(response).await;
assert!(html.contains("notice--warn"));
assert!(html.contains("<!doctype html>"));
}

#[tokio::test]
async fn compartments_page_defaults_to_patient() {
let response = app()
Expand Down
1 change: 1 addition & 0 deletions locales/de/main.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ cmp-heading = Compartments
cmp-lede = Die Compartment-Definitionen, mit denen dieser Server /{"{"}compartment{"}"}/{"{"}id{"}"}/{"{"}type{"}"}-Anfragen routet, und ein Tester, der beantwortet: Ist dieser Typ in diesem Compartment, über welche Parameter, und welche Suche führt der Server aus?
cmp-rail-label = Compartment-Definitionen
cmp-rail-heading = Compartments
cmp-degraded = Die Compartment-Definitionen konnten gerade nicht von diesem Server geladen werden — der Selbstaufruf an /CompartmentDefinition schlug fehl (bei aktivierter Authentifizierung fehlt meist das ausgehende Service-Token oder es ist ungültig). Die Seite versucht es bei der nächsten Anfrage erneut.
cmp-rail-note = Die Basisdefinitionen werden mit dem Server ausgeliefert (aus der FHIR-Spezifikation generiert). Sie zu bearbeiten setzt eine tenant-spezifische Override-Schicht voraus — offene Frage im Issue.
cmp-tabs-label = Compartment-Bereiche
cmp-tab-definition = Definition
Expand Down
1 change: 1 addition & 0 deletions locales/en/main.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ cmp-heading = Compartments
cmp-lede = The compartment definitions this server routes /{"{"}compartment{"}"}/{"{"}id{"}"}/{"{"}type{"}"} requests with, and a tester that answers: is this type in this compartment, via which parameters, and what search does the server run?
cmp-rail-label = Compartment definitions
cmp-rail-heading = Compartments
cmp-degraded = Compartment definitions could not be loaded from this server right now — the self-call to /CompartmentDefinition failed (with authentication enabled this usually means the outbound service token is missing or invalid). The page retries on the next request.
cmp-rail-note = Base definitions ship with the server (codegen'd from the FHIR spec). Editing them implies a tenant-scoped override layer — open question on the issue.
cmp-tabs-label = Compartment sections
cmp-tab-definition = Definition
Expand Down
Loading
Loading