Authentication & Authorization
Applies to
- Enterprise v3.1.0+
- Designer v3.1.0+ (see Mapping Designer integration)
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:
| Mode | When | Behavior |
|---|---|---|
no-auth | No auth.yaml resolved | Auth subsystem not installed. All routes are public. No Keycloak dependency. |
auth-available | auth.yaml present, but every policy rule is access: public | Auth subsystem wired (metadata endpoint, token mediator) but never blocks a request. |
auth-required | auth.yaml present with at least one non-public rule | Bearer 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
codeto 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:
- If
AUTH_CONFIG_PATHis set and points to an existing file, that file is used. - If
AUTH_CONFIG_PATHis set but does not resolve to an existing file, FUME treats the value as an inline base64-encodedauth.yamldocument and decodes it. - If
AUTH_CONFIG_PATHis unset, FUME reads./auth.yamlif it exists. - 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.
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
| Field | Type | Required | Notes |
|---|---|---|---|
version | literal 1 | yes | Schema version pin. |
keycloak | object | yes | Identity-provider settings. See below. |
jwt | object | no | Token claim handling. Today jwt.valueSource.type only accepts memberOf; the block can be omitted. |
tokenMediator | object | no | Enables the /auth/exchange and /auth/refresh endpoints (BFF topology). |
designerClient | object | conditional | Required when the token mediator is not enabled (direct-PKCE topology). |
resource | object | yes | Identifies this FUME deployment in protected-resource metadata. |
policy | object | yes | Authorization policy. See below. |
keycloak
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
issuer | URL | yes | — | Realm issuer URL. Must match the iss claim and the issuer in the discovery document. |
audience | string | string[] | no | — | Accepted aud value(s). Omit to accept any audience. |
clockToleranceSeconds | int 1–60 | no | 5 | Allowed clock skew for exp/nbf. |
jwksCacheMaxAgeMs | int > 0 | no | 600000 | JWKS cache lifetime (10 minutes). |
discoveryTtlSeconds | int > 0 | no | 3600 | OIDC discovery cache lifetime (1 hour). |
discoveryCooldownSeconds | int ≥ 0 | no | 5 | How long a stale discovery result is served while a refresh is failing. |
client.id | string | conditional | — | Confidential client id. Required when tokenMediator.enabled: true. |
client.secret | string | conditional | — | Confidential client secret. Required when tokenMediator.enabled: true. Use ${KC_CLIENT_SECRET}. |
jwt
| Field | Type | Required | Notes |
|---|---|---|---|
valueSource.type | literal memberOf | no | Reads 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
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
enabled | boolean | yes (within the block) | — | Turns on the token-mediating backend. Requires keycloak.client. |
scope | string | no | — | Space-separated scopes advertised in protected-resource metadata. |
corsAllowedOrigins | URL[] | no | [] | Browser origins allowed to call /auth/exchange and /auth/refresh. |
designerClient
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
clientId | string | yes | — | Public, PKCE-enabled Keycloak client id. Advertised to the Mapping Designer in protected-resource metadata. |
scope | string | no | — | Exact scope string the Designer should request. |
resource
| Field | Type | Required | Notes |
|---|---|---|---|
url | URL | yes | Public base URL of this FUME server. |
name | string | yes | Human-readable name shown in protected-resource metadata. |
policy
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
defaultRule | rule | yes | — | Applied when no route matches, or when a matched route has no entry for the request method and no * entry. |
routes | array | no | [] | Per-path rules. Each entry is { path, methods }. |
Each routes entry:
| Field | Type | Notes |
|---|---|---|
path | string | Must 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. |
methods | object | Keyed 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:
| Rule | Meaning |
|---|---|
{ 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:
- Among all routes whose
pathpattern matches, pick the most specific — literal segments beat:paramsegments, and a longer pattern wins a tie. - From that route, use
methods[<METHOD>], elsemethods['*'], elsepolicy.defaultRule. - If no route matched at all, use
policy.defaultRule. publicalways allows. Otherwise an unauthenticated caller is rejected with401; anauthenticatedrule allows any valid token; arolesrule allows the request if the caller's roles intersect the rule's roles.
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.
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.clientand settokenMediator.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
memberOfclaim (Keycloak's built-in LDAPmemberOfmapper produces an array of group names). Those names are what you list underroles:. - Set
keycloak.audienceto 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 arefresh_tokengrant 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 (
) 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.yamlis 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}.