Skip to main content

Authentication & Authorization

Applies to​

Purpose​

FUME Enterprise can require a Keycloak-issued bearer token on incoming requests and apply a per-route authorization policy. Both behaviors are driven by a single auth.yaml configuration file:

  • Authentication — verify the Authorization: Bearer <token> JWT against a Keycloak realm (issuer, signature via JWKS, audience, clock skew).
  • Authorization — match each request path and method against a policy and allow it for everyone, for any authenticated caller, or only for callers in specific roles.

Authentication is off unless an auth.yaml is present. Upgrading an existing single-server deployment changes nothing until you add the file.

Modes​

The effective mode is derived from configuration:

ModeWhenBehavior
no-authNo auth.yaml resolvedAuth subsystem not installed. All routes are public. No Keycloak dependency.
auth-availableauth.yaml present, but every policy rule is access: publicAuth subsystem wired (metadata endpoint, token mediator) but never blocks a request.
auth-requiredauth.yaml present with at least one non-public ruleBearer verification and policy enforcement are active.

There are two client topologies, selected by whether the token mediator is enabled — they are mutually exclusive in practice:

  • Token-mediator (BFF) — FUME is configured with a confidential Keycloak client. The browser performs the authorization-code redirect against Keycloak, then posts the code to FUME's /auth/exchange; FUME exchanges it for tokens using the confidential client secret. The secret never reaches the browser.
  • Direct PKCE — FUME advertises a public Keycloak client. The browser runs Authorization Code + PKCE (S256) directly against Keycloak; FUME only verifies the resulting bearer tokens.

File location and precedence​

By default FUME looks for auth.yaml in the server's working directory. To use a different path, set AUTH_CONFIG_PATH:

AUTH_CONFIG_PATH=/etc/fume/auth.yaml

Resolution order:

  1. If AUTH_CONFIG_PATH is set and points to an existing file, that file is used.
  2. If AUTH_CONFIG_PATH is set but does not resolve to an existing file, FUME treats the value as an inline base64-encoded auth.yaml document and decodes it.
  3. If AUTH_CONFIG_PATH is unset, FUME reads ./auth.yaml if it exists.
  4. Otherwise the mode is no-auth.
AUTH_CONFIG_PATH=dmVyc2lvbjogMQprZXljbG9hazoKICBpc3N1ZXI6IGh0dHBzOi8va2MuZXhhbXBsZS5jb20vcmVhbG1zL2Z1bWUK...

If both an explicit AUTH_CONFIG_PATH file and a default ./auth.yaml exist, or if the configured value is neither a file nor valid base64, FUME fails to start with a clear error.

auth.yaml is loaded once at startup. Changing it (file or base64) requires a server restart.

Environment-variable substitution​

After the YAML is parsed, fields containing ${VARIABLE_NAME} are expanded using the value of that environment variable. ${VARIABLE_NAME:-default} supplies a fallback. \${...} escapes to a literal. A ${VARIABLE_NAME} reference with no value and no default makes the server fail to start.

Security best practice

Keep all secrets — Keycloak client secrets in particular — in environment variables or your secret manager, and reference them as ${VAR} in auth.yaml. Never commit secrets to version control.

auth.yaml schema​

version: 1
keycloak:
issuer: https://kc.example.com/realms/fume
audience: fume
clockToleranceSeconds: 10
jwksCacheMaxAgeMs: 600000
discoveryTtlSeconds: 3600
discoveryCooldownSeconds: 5
client:
id: fume
secret: ${KC_CLIENT_SECRET}
jwt:
valueSource:
type: memberOf
tokenMediator:
enabled: true
scope: openid profile email memberOf
corsAllowedOrigins:
- https://designer.example.com
- http://localhost:5173
designerClient:
clientId: fume-designer
scope: openid profile email memberOf
resource:
url: https://fume.example.com
name: FUME Production

policy:
defaultRule:
access: authenticated
routes:
- path: /
methods: { GET: { access: public } }
- path: /$recache
methods: { POST: { roles: [admin] } }
- path: /Mapping/:mappingId
methods:
GET: { access: authenticated }
PUT: { roles: [admin, power-user] }
DELETE: { roles: [admin] }
"*": { access: authenticated }

Top-level fields​

FieldTypeRequiredNotes
versionliteral 1yesSchema version pin.
keycloakobjectyesIdentity-provider settings. See below.
jwtobjectnoToken claim handling. Today jwt.valueSource.type only accepts memberOf; the block can be omitted.
tokenMediatorobjectnoEnables the /auth/exchange and /auth/refresh endpoints (BFF topology).
designerClientobjectconditionalRequired when the token mediator is not enabled (direct-PKCE topology).
resourceobjectyesIdentifies this FUME deployment in protected-resource metadata.
policyobjectyesAuthorization policy. See below.

keycloak​

FieldTypeRequiredDefaultNotes
issuerURLyes—Realm issuer URL. Must match the iss claim and the issuer in the discovery document.
audiencestring | string[]no—Accepted aud value(s). Omit to accept any audience.
clockToleranceSecondsint 1–60no5Allowed clock skew for exp/nbf.
jwksCacheMaxAgeMsint > 0no600000JWKS cache lifetime (10 minutes).
discoveryTtlSecondsint > 0no3600OIDC discovery cache lifetime (1 hour).
discoveryCooldownSecondsint ≥ 0no5How long a stale discovery result is served while a refresh is failing.
client.idstringconditional—Confidential client id. Required when tokenMediator.enabled: true.
client.secretstringconditional—Confidential client secret. Required when tokenMediator.enabled: true. Use ${KC_CLIENT_SECRET}.

jwt​

FieldTypeRequiredNotes
valueSource.typeliteral memberOfnoReads Keycloak's memberOf group-membership claim (the LDAP memberOf mapper, typically an array of group names) and surfaces those values as the caller's roles for policy evaluation.

tokenMediator​

FieldTypeRequiredDefaultNotes
enabledbooleanyes (within the block)—Turns on the token-mediating backend. Requires keycloak.client.
scopestringno—Space-separated scopes advertised in protected-resource metadata.
corsAllowedOriginsURL[]no[]Browser origins allowed to call /auth/exchange and /auth/refresh.

designerClient​

FieldTypeRequiredDefaultNotes
clientIdstringyes—Public, PKCE-enabled Keycloak client id. Advertised to the Mapping Designer in protected-resource metadata.
scopestringno—Exact scope string the Designer should request.

resource​

FieldTypeRequiredNotes
urlURLyesPublic base URL of this FUME server.
namestringyesHuman-readable name shown in protected-resource metadata.

policy​

FieldTypeRequiredDefaultNotes
defaultRuleruleyes—Applied when no route matches, or when a matched route has no entry for the request method and no * entry.
routesarrayno[]Per-path rules. Each entry is { path, methods }.

Each routes entry:

FieldTypeNotes
pathstringMust start with /. Wildcards (*) are not allowed — matching is exact, segment by segment, and case-insensitive. A :param segment matches any single non-empty segment. Duplicate paths are rejected.
methodsobjectKeyed by GET | POST | PUT | DELETE | PATCH | HEAD | OPTIONS | *, each mapping to a rule. At least one entry is required.

A rule is exactly one of:

RuleMeaning
{ access: public }No authentication required.
{ access: authenticated }Any valid bearer token is allowed.
{ roles: [<string>, ...] }Allowed only if the caller's roles include any one of the listed roles. At least one role must be listed.

Resolution order for a request:

  1. Among all routes whose path pattern matches, pick the most specific — literal segments beat :param segments, and a longer pattern wins a tie.
  2. From that route, use methods[<METHOD>], else methods['*'], else policy.defaultRule.
  3. If no route matched at all, use policy.defaultRule.
  4. public always allows. Otherwise an unauthenticated caller is rejected with 401; an authenticated rule allows any valid token; a roles rule allows the request if the caller's roles intersect the rule's roles.
note

GET /health always responds without authentication, regardless of the policy — it is reserved for readiness and diagnostics, so there is no need (and no effect) to add a rule for it.

Build your policy interactively

Don't hand-write the policy block — use the built-in Policy Builder (GET /auth/policy/builder). It's an offline form editor with a live YAML preview, presets, and a request-resolution tester, then you copy the result into auth.yaml.

Keycloak setup​

  • Direct-PKCE deployments — register the browser client (the Mapping Designer, Swagger UI, your own SPA) as a public client with Authorization Code + PKCE (S256) enabled and implicit flow disabled. Restrict its valid redirect URIs and web origins. Put its id (and the scope string you want callers to request) in designerClient.
  • Token-mediator (BFF) deployments — register FUME itself as a confidential client. Put its id and secret in keycloak.client and set tokenMediator.enabled: true. The browser still does the authorization-code redirect against Keycloak, but the code-to-token exchange happens server-side through FUME.
  • For role-based rules, configure a group-membership mapper that emits the memberOf claim (Keycloak's built-in LDAP memberOf mapper produces an array of group names). Those names are what you list under roles:.
  • Set keycloak.audience to the audience your tokens carry (or omit it to accept any audience).

HTTP surface​

When auth is installed, FUME exposes these additional endpoints. They are reachable without a bearer token so that clients can discover and complete the login flow.

GET /.well-known/oauth-protected-resource​

RFC 9728 Protected Resource Metadata. Lets a browser client learn how to authenticate before it calls a protected endpoint. Response body includes:

{
"resource": "https://fume.example.com",
"resource_name": "FUME Production",
"authorization_servers": ["https://kc.example.com/realms/fume"],
"scopes_supported": ["openid", "profile", "email", "memberOf"],
"bearer_methods_supported": ["header"],
"fume_designer_client": {
"client_id": "fume-designer",
"scope": "openid profile email memberOf",
"token_mediator_enabled": true
}
}

scopes_supported comes from tokenMediator.scope. The fume_designer_client object is present only when designerClient is configured; token_mediator_enabled tells clients which login flow to use.

POST /auth/exchange and POST /auth/refresh​

Available only when tokenMediator.enabled: true.

  • POST /auth/exchange — body { code, redirect_uri }. FUME exchanges the authorization code for tokens against Keycloak's token endpoint using the confidential client, and returns Keycloak's token response.
  • POST /auth/refresh — body { refresh_token }. FUME performs a refresh_token grant the same way.

Both are CORS-restricted to tokenMediator.corsAllowedOrigins; disallowed origins receive a 403. The endpoints are stateless — no server-side sessions or cookies.

GET /auth/policy/builder​

A self-contained offline HTML page for authoring the policy: block: a form editor for defaultRule and routes, a live YAML preview with a copy button, paste-import of an existing policy: block, example presets, role autocomplete, and an in-page request-resolution tester that mirrors the server's matching and specificity rules. It is copy-paste only — it never reads or writes auth.yaml. Mounted even when auth is off, so you can draft a policy before enabling it.

Error responses​

Authentication and authorization failures are returned as application/problem+json (RFC 9457) with appropriate 4xx/5xx status codes. A 401 response also carries a WWW-Authenticate challenge pointing at the protected-resource metadata, e.g.:

WWW-Authenticate: Bearer resource_metadata="https://fume.example.com/.well-known/oauth-protected-resource", error="invalid_token", error_description="..."

Mapping Designer integration​

The Mapping Designer runs in public mode by default. Set FUME_DESIGNER_AUTH_ENABLED to true to turn on authenticated mode.

In authenticated mode the Designer does not need any identity-provider configuration of its own — it reads the FUME server's /.well-known/oauth-protected-resource and uses whatever is advertised there. Based on token_mediator_enabled it either uses the server-assisted (token-mediator) flow or talks to Keycloak directly with PKCE.

User experience:

  • When you are not signed in, the Designer shows a Sign in pill (Sign in pill) in the navbar. You can browse the app, but server-touching actions are hidden until you sign in.
  • Signing in opens the identity provider in a separate browser tab; once it completes, the Designer tab updates in place (it does not reload).
  • When signed in, the navbar shows an account menu with your identity and a Sign out action.
  • If the server advertises an authentication mode the Designer can't use, it shows a non-blocking warning instead of a sign-in control.

When the Helm chart deploys FUME with backend auth enabled, it sets FUME_DESIGNER_AUTH_ENABLED=true on the Designer automatically. See Deployment.

Deployment​

For Docker deployments, mount auth.yaml into the server container and point AUTH_CONFIG_PATH at it (or pass the file's base64-encoded contents directly in AUTH_CONFIG_PATH), and set FUME_DESIGNER_AUTH_ENABLED=true on the Designer container.

For Kubernetes, the FUME Helm chart can render a Secret-backed auth.yaml, mount it into the backend, set AUTH_CONFIG_PATH, and set FUME_DESIGNER_AUTH_ENABLED=true on the Designer automatically. Keep secrets as ${ENV_VAR} placeholders and supply them through your existing secret management.

Examples​

Minimal — direct PKCE​

version: 1
keycloak:
issuer: https://kc.example.com/realms/fume
designerClient:
clientId: fume-designer
resource:
url: https://fume.example.com
name: FUME
policy:
defaultRule:
access: authenticated

Minimal — token mediator (BFF)​

version: 1
keycloak:
issuer: https://kc.example.com/realms/fume
client:
id: fume
secret: ${KC_CLIENT_SECRET}
tokenMediator:
enabled: true
corsAllowedOrigins:
- https://designer.example.com
resource:
url: https://fume.example.com
name: FUME
policy:
defaultRule:
access: authenticated

Role-based routes​

policy:
defaultRule:
access: authenticated
routes:
- path: /
methods: { GET: { access: public } }
- path: /$recache
methods: { POST: { roles: [admin] } }
- path: /Mapping/:mappingId
methods:
GET: { access: authenticated }
PUT: { roles: [admin, power-user] }
DELETE: { roles: [admin] }
"*": { access: authenticated }

Notes​

  • auth.yaml is read once at startup; changes require a restart.
  • Named FHIR connections and the terminology/conformance operations are independent of this feature.
  • For the analogous guidance on keeping connection credentials out of version control, see the security note on the Named FHIR Connections page.
  • Keep secrets in environment variables or a secret manager and reference them as ${VAR}.