helios-auth
Authentication and authorization for the Helios FHIR Server.
Overview
This crate provides SMART Backend Services authentication via JWT/JWKS validation and SMART v2 scope-based authorization. It is designed around a key architectural principle: HFS does not act as an authorization server. Token issuance and client registration remain external (Keycloak, Okta, Auth0, Entra ID, etc.). This crate performs local token validation only.
- JWKS-Based JWT Validation: Fetches and caches public keys from IdP JWKS endpoints
- SMART v2 Scope Parsing: Parses
system/Patient.rs,system/*.crudsscope syntax - Scope-Based Authorization: Maps FHIR operations to SMART permissions (CRUDS)
- JTI Replay Prevention: In-memory and Redis-backed caches for JWT ID tracking
- Multi-Instance Coordination: Redis leader election for JWKS refresh across scaled deployments
- SMART Discovery: Builds
/.well-known/smart-configurationdocuments - Pluggable Audit: Trait-based audit event sink (noop default, extensible)
Quick Start
use Arc;
use ;
async
How Authentication Works
The authentication flow follows the SMART Backend Services protocol:
- Client registers with an external authorization server (Keycloak, Okta, etc.)
- Client obtains a token from the authorization server using its private key
- Client sends request to HFS with
Authorization: Bearer <token> - HFS validates the token locally:
- Decodes the JWT header to extract
kidandalg - Rejects tokens using algorithms not in the allowed list
- Fetches the public key from the cached JWKS keyset (refreshes on unknown
kid) - Validates signature, expiration, issuer, and audience claims
- Checks the
jticlaim against the replay prevention cache - Parses SMART v2 scopes from the
scopeorscpclaim - Extracts the tenant ID from the configured claim
- Decodes the JWT header to extract
- HFS enforces authorization by checking scopes against the requested FHIR operation
SMART v2 Scopes
Scopes follow the SMART v2 syntax: context/resourceType.permissions
| Scope | Meaning |
|---|---|
system/Patient.rs |
Read and search Patient resources |
system/*.cruds |
Full CRUD + search on all resource types |
system/Observation.r |
Read-only access to Observation |
system/Condition.crud |
Create, read, update, delete Condition (no search) |
user/Patient.rs |
User-level read/search on Patient |
Permission characters: c = create, r = read, u = update, d = delete, s = search.
Operation-to-Permission Mapping
| HTTP Request | FHIR Operation | Required Permission |
|---|---|---|
GET /Patient/123 |
read | r |
GET /Patient?name=Smith |
search | s |
POST /Patient |
create | c |
PUT /Patient/123 |
update | u |
PATCH /Patient/123 |
update | u |
DELETE /Patient/123 |
delete | d |
GET /Patient/_history |
history | r |
Configuration
All configuration is via environment variables. Auth is a runtime toggle — no feature flags needed to enable it.
Core Settings
| Variable | Default | Description |
|---|---|---|
HFS_AUTH_ENABLED |
false |
Master switch for authentication |
HFS_AUTH_JWKS_URL |
(required) | JWKS endpoint URL |
HFS_AUTH_ISSUER |
(none) | Expected JWT iss claim |
HFS_AUTH_AUDIENCE |
(none) | Expected JWT aud claim (recommended for production — prevents accepting tokens intended for other services) |
HFS_AUTH_TENANT_CLAIM |
tenant_id |
JWT claim name for tenant ID |
HFS_AUTH_ALGORITHMS |
RS256,RS384,ES256,ES384 |
Allowed signing algorithms |
Caching and Replay Prevention
| Variable | Default | Description |
|---|---|---|
HFS_AUTH_JTI_BACKEND |
memory |
JTI cache backend (memory, redis, or disabled) |
HFS_AUTH_REDIS_URL |
(none) | Redis URL (required for redis backend) |
HFS_AUTH_JWKS_MIN_REFRESH_INTERVAL |
10 |
Min seconds between JWKS refreshes |
SMART Discovery Endpoint
These populate the GET /.well-known/smart-configuration response:
| Variable | Description |
|---|---|
HFS_SMART_TOKEN_ENDPOINT |
Token endpoint URL |
HFS_SMART_AUTHORIZE_ENDPOINT |
Authorization endpoint URL |
HFS_SMART_JWKS_URL |
JWKS URL (for discovery doc; falls back to HFS_AUTH_JWKS_URL) |
HFS_SMART_INTROSPECTION_ENDPOINT |
Token introspection endpoint |
HFS_SMART_MANAGEMENT_ENDPOINT |
Token management endpoint |
HFS_SMART_REGISTRATION_ENDPOINT |
Dynamic client registration endpoint |
HFS_SMART_REVOCATION_ENDPOINT |
Token revocation endpoint |
Running with Authentication
# Enable auth with Keycloak
HFS_AUTH_ENABLED=true \
HFS_AUTH_JWKS_URL=http://keycloak:8080/realms/fhir/protocol/openid-connect/certs \
HFS_AUTH_ISSUER=http://keycloak:8080/realms/fhir \
HFS_AUTH_AUDIENCE=https://fhir.example.com \
HFS_SMART_TOKEN_ENDPOINT=http://keycloak:8080/realms/fhir/protocol/openid-connect/token \
# Verify SMART discovery
# Unauthenticated request (expect 401)
# Authenticated request
Exempt Paths
These endpoints are always accessible without a token:
/health,/_liveness,/_readiness/metadata/.well-known/smart-configuration/$versions
Identity Provider Integration
Keycloak
Docker Quickstart
A pre-configured Keycloak 26 instance with a fhir realm, two test clients, and SMART client scopes is provided in docker/keycloak/.
# Start Keycloak (port 8180 to avoid conflict with HFS on 8080)
# Wait for health check to pass (~30-40s on first run)
# Verify the realm is available
| |
Pre-configured Test Clients
| Client ID | Secret | Default Scopes | Use case |
|---|---|---|---|
hfs-backend-client |
hfs-backend-secret |
system/*.cruds |
Full access testing |
hfs-readonly-client |
hfs-readonly-secret |
system/Patient.rs |
Restricted access testing |
Run HFS Against Keycloak
HFS_AUTH_ENABLED=true \
HFS_AUTH_JWKS_URL=http://localhost:8180/realms/fhir/protocol/openid-connect/certs \
HFS_AUTH_ISSUER=http://localhost:8180/realms/fhir \
HFS_SMART_TOKEN_ENDPOINT=http://localhost:8180/realms/fhir/protocol/openid-connect/token \
HFS_SMART_AUTHORIZE_ENDPOINT=http://localhost:8180/realms/fhir/protocol/openid-connect/auth \
HFS_SMART_JWKS_URL=http://localhost:8180/realms/fhir/protocol/openid-connect/certs \
Verify SMART discovery is populated:
|
Get a Token
Use the provided helper script:
# Full-access token (system/*.cruds) — prints token to stdout
# Read-only token (system/Patient.rs)
Or call Keycloak directly:
Inspect the token claims (no library needed):
| | |
End-to-End Test
# Unauthenticated request → 401
|
# Authenticated request → 200 (or 404 if no data)
|
# Create a patient (requires 'c' permission — works with hfs-backend-client)
|
# Attempt write with read-only token → 403
READONLY_TOKEN=
|
Manual Realm Setup (Existing Keycloak)
If you have an existing Keycloak instance, configure it via the Admin Console or CLI:
-
Create a realm named
fhir(or use an existing one). -
Create client scopes for each SMART scope you need:
- Admin Console → Client Scopes → Create → Name:
system/*.cruds→ Protocol:openid-connect - Set
Include in token scope=Onunder Settings - Repeat for
system/Patient.rs,system/Observation.r, etc.
- Admin Console → Client Scopes → Create → Name:
-
Create a client:
- Admin Console → Clients → Create
- Client ID:
hfs-backend-client - Client authentication:
On - Standard flow:
Off, Service accounts roles:On - Save → Credentials tab → note the client secret
-
Assign scopes to the client:
- Client → Client Scopes tab → Add client scope → select your SMART scopes → Add as Default
-
HFS environment variables:
HFS_AUTH_JWKS_URL=https:///realms//protocol/openid-connect/certs
HFS_AUTH_ISSUER=https:///realms/
# Scope claim: "scope" (space-delimited string) — no additional config needed
Keycloak Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| 401 on all requests | Wrong issuer | Confirm HFS_AUTH_ISSUER matches the iss in your decoded token exactly |
| 401 — signature validation failed | JWKS not yet cached | HFS fetches JWKS on first request; retry after ~5s or check HFS_AUTH_JWKS_URL is reachable |
| 403 on valid token | Scope not in token | Check scope claim in decoded token; ensure client scopes are assigned as Default not Optional |
Token has no scope claim |
Mapper missing | In Keycloak Admin → Client Scopes → verify include.in.token.scope is true for each scope |
| Realm import didn't apply | File not found | Check the volume mount path; Keycloak imports from /opt/keycloak/data/import/ |
Okta
Okta uses a custom Authorization Server for client credentials. A free developer account is available at developer.okta.com.
Okta Setup (one-time)
-
Create a Custom Authorization Server
- Admin Console → Security → API → Add Authorization Server
- Name:
FHIR, Audience:https://fhir.example.com(or your FHIR base URL) - Note the Issuer URI shown on the server's Settings tab
-
Add SMART scopes to the Authorization Server
- Scopes tab → Add Scope for each scope you need:
- Name:
system/*.cruds, Description: Full SMART access, Default scope: off - Name:
system/Patient.rs, Description: Read/search Patient - Name:
system/Observation.r, Description: Read Observation
- Name:
- Leave Metadata published: on
- Scopes tab → Add Scope for each scope you need:
-
Create a Machine-to-Machine application
- Applications → Create App Integration → API Services (machine-to-machine)
- Note the Client ID and Client Secret from the app's General tab
-
Assign scopes to the application
- App → Okta API Scopes tab — this is for Okta management APIs, not your custom server
- Instead: go to Security → API → your Authorization Server → Access Policies
- Add a Policy → Add Rule → Grant type: Client Credentials, Client: your app, Scopes: the SMART scopes you created
Run HFS Against Okta
Replace {domain} with your Okta domain (e.g. dev-12345678.okta.com) and {auth-server-id} with the ID from the authorization server URL (e.g. aus1abc2defGHIJK):
HFS_AUTH_ENABLED=true \
HFS_AUTH_JWKS_URL=https:///oauth2//v1/keys \
HFS_AUTH_ISSUER=https:///oauth2/ \
HFS_AUTH_AUDIENCE=https://fhir.example.com \
HFS_SMART_TOKEN_ENDPOINT=https:///oauth2//v1/token \
Get a Token
Or use the helper script (set env vars first):
OKTA_DOMAIN=dev-12345678.okta.com \
OKTA_AUTH_SERVER_ID=aus1abc2defGHIJK \
OKTA_CLIENT_ID=0oa... \
OKTA_CLIENT_SECRET=... \
Decode the token to confirm the scp claim contains your SMART scopes and test:
| | | |
Okta Scope Claim Notes
Okta issues scopes as scp (a JSON array), not scope (a string). HFS auto-detects both formats — no extra configuration is needed.
Okta Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| 401 — issuer mismatch | Wrong auth server ID | The iss in the token must exactly match HFS_AUTH_ISSUER; check with the decode one-liner above |
| 401 — audience mismatch | HFS_AUTH_AUDIENCE set wrong |
The aud claim in Okta tokens is the Authorization Server's audience value (set in step 1) |
| 403 — scope not granted | Access Policy missing | Verify the Access Policy rule explicitly lists your client and the SMART scopes |
Empty scp in token |
Scopes not requested | Include scope=system/*.cruds in the token request; Okta only grants requested scopes |
Auth0
A free developer account is available at auth0.com.
Auth0 Setup (one-time)
-
Register an API (this is Auth0's term for the resource server HFS represents)
- Dashboard → Applications → APIs → Create API
- Name:
FHIR Server, Identifier:https://fhir.example.com(this becomes theaudclaim) - Signing Algorithm:
RS256 - Save — Auth0 automatically creates a test M2M application for the API
-
Add SMART scope permissions to the API
- Dashboard → Applications → APIs → your API → Permissions tab
- Add each scope you need:
system/*.cruds— Full SMART accesssystem/Patient.rs— Read and search Patientsystem/Observation.r— Read-only Observation
-
Create a Machine-to-Machine application (or use the auto-created test app)
- Dashboard → Applications → Applications → Create Application
- Choose Machine to Machine Applications
- Select your FHIR API from the dropdown and authorize it
- Select the scopes this client may request, then Authorize
-
Note your credentials
- Application → Settings tab: copy Domain, Client ID, Client Secret
Run HFS Against Auth0
HFS_AUTH_ENABLED=true \
HFS_AUTH_JWKS_URL=https:///.well-known/jwks.json \
HFS_AUTH_ISSUER=https:/// \
HFS_AUTH_AUDIENCE=https://fhir.example.com \
HFS_SMART_TOKEN_ENDPOINT=https:///oauth/token \
Get a Token
Or call Auth0 directly:
Decode the token to confirm claims and test:
| | | |
Auth0 Scope Claim Notes
Auth0 tokens include granted scopes in the scope claim as a space-delimited string. Scopes only appear in the token if they were both defined as API permissions and requested in the token request.
Auth0 Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| 401 — issuer mismatch | Trailing slash | Auth0 issuer is https://{domain}/ — the trailing slash is required |
| 401 — audience mismatch | Wrong identifier | HFS_AUTH_AUDIENCE must exactly match the API Identifier set in step 1 |
| 403 — scope not in token | Scope not authorized | Ensure the scope is added as an API Permission and the M2M app is authorized for it |
Empty scope in token |
Scope not requested | Include scope=system/*.cruds in the token request body |
Microsoft Entra ID
HFS_AUTH_JWKS_URL=https://login.microsoftonline.com//discovery/v2.0/keys
HFS_AUTH_ISSUER=https://login.microsoftonline.com//v2.0
# Permissions are typically in the "roles" claim
Setup summary: Register an application, define App Roles with SMART scope names, grant the client application the roles, and use the client credentials flow. Entra tokens include granted permissions in the roles array rather than scope.
Tenant Resolution
When authentication is enabled, the tenant ID is derived exclusively from the JWT claim configured by HFS_AUTH_TENANT_CLAIM (default: tenant_id). The X-Tenant-ID header and URL-based tenant routing are ignored for authenticated requests — this prevents tenant impersonation.
If the token does not contain the tenant claim, the server falls back to the standard tenant resolution (header, URL path, or default).
Multi-Instance Deployments
For HFS deployments with multiple instances behind a load balancer:
# Use Redis for JTI replay prevention (shared across instances)
HFS_AUTH_JTI_BACKEND=redis
HFS_AUTH_REDIS_URL=redis://redis:6379
# Build with Redis support
The Redis backend also coordinates JWKS refresh across instances using leader election, so only one instance fetches from the IdP's JWKS endpoint at a time.
Features
| Feature | Description |
|---|---|
redis |
Enables Redis-backed JTI cache and JWKS refresh coordination |
Testing
# Run all auth tests
# Run specific test module
Architecture
src/
├── lib.rs # Crate entry, re-exports
├── config.rs # AuthConfig (env var parsing)
├── error.rs # AuthError enum, FhirOperation
├── principal.rs # Principal (authenticated identity)
├── audit.rs # AuditEventSink trait + NoopAuditEventSink
├── discovery.rs # SmartConfiguration builder
├── scope/
│ ├── mod.rs # ScopeSet (collection of parsed scopes)
│ ├── smart_v2.rs # SmartScope parser (context/type.perms)
│ └── permissions.rs # SmartPermissions bitflags (CRUDS)
├── provider/
│ ├── mod.rs # AuthProvider trait
│ └── jwks_bearer.rs # JwksBearerAuthProvider (JWT validation)
├── jwks/
│ ├── mod.rs # Module exports
│ ├── fetcher.rs # HTTP JWKS fetcher with Cache-Control parsing
│ ├── cache.rs # JwksCache (background refresh, rate limiting)
│ └── coordinator.rs # Redis leader election (feature = "redis")
├── jti/
│ ├── mod.rs # JtiCache trait
│ ├── memory.rs # InMemoryJtiCache (moka)
│ └── redis.rs # RedisJtiCache (feature = "redis")
└── policy/
└── mod.rs # SmartScopePolicy (operation → permission check)
Key Types
| Type | Description |
|---|---|
Principal |
Authenticated identity from a validated JWT (subject, issuer, scopes, tenant) |
ScopeSet |
Parsed collection of SMART v2 scopes with permission checking |
SmartPermissions |
Bitflags for CRUDS permissions |
AuthProvider |
Trait for token validation (currently: JWKS Bearer) |
JwksCache |
JWKS key cache with Cache-Control awareness and background refresh |
JtiCache |
Trait for JWT ID replay prevention (in-memory or Redis) |
SmartScopePolicy |
Checks principal scopes against FHIR operations |
AuditEventSink |
Trait for recording auth events (noop default) |
AuthConfig |
Configuration from environment variables |
SmartConfiguration |
SMART discovery document builder |
License
MIT