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
issuerURLyesRealm issuer URL. Must match the iss claim and the issuer in the discovery document.
audiencestring | string[]noAccepted 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.idstringconditionalConfidential client id. Required when tokenMediator.enabled: true.
client.secretstringconditionalConfidential 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.
scopestringnoSpace-separated scopes advertised in protected-resource metadata.
corsAllowedOriginsURL[]no[]Browser origins allowed to call /auth/exchange and /auth/refresh.

designerClient

FieldTypeRequiredDefaultNotes
clientIdstringyesPublic, PKCE-enabled Keycloak client id. Advertised to the Mapping Designer in protected-resource metadata.
scopestringnoExact 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
defaultRuleruleyesApplied 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}.