Enterprise identity providers
FerroEHR does not manage users. There is no user table, no user API, and no plan to add one: identity administration is delegated to your identity provider (IdP), and the CDR consumes standard OIDC bearer tokens. This page records that posture and walks through connecting the two enterprise IdPs we are asked about most, Microsoft Entra ID (Azure AD) and AD FS, plus the answer for plain-LDAP directories.
- The posture: users live in the IdP
- How the CDR consumes an IdP
- Microsoft Entra ID (Azure AD)
- AD FS (on-premises Active Directory)
- “We only have LDAP”
- Serving SMART apps
The posture: users live in the IdP
A clinical data repository is the wrong place to store credentials. A user store would make the CDR an authentication product (password lifecycle, lockout policy, MFA, recovery flows, and the largest new attack surface the product could grow) duplicating what a dedicated IdP already does under your existing governance. So the split is deliberate and permanent:
- The IdP owns identities: accounts, passwords, MFA, group/role membership, lifecycle (joiners/movers/leavers), and session policy.
- The CDR owns authorization: it validates the token, mines roles from its claims, and enforces RBAC/ABAC and per-EHR access control on every request.
The HTTP Basic user list in ferroehr.toml is a bootstrap/dev convenience,
not a user store; production deployments authenticate with OIDC bearer
tokens.
Note
The viewer follows the same rule: it authenticates against the same credentials the CDR accepts (the same OIDC issuer, or Basic) and has no user-management screens. To create, disable, or re-role a user, use your IdP’s own administration surface.
How the CDR consumes an IdP
Two configuration groups do all the work:
- Token validation (
[auth.oidc]): the server discovers the JWKS from the issuer’s.well-known/openid-configurationand validates each bearer token’s signature,iss,exp/nbf, andaud; the audience list is mandatory, so a server with none refuses to boot rather than accepting another service’s token. See the OIDC settings table. - Role mining (
[authz.rbac]):FERROEHR__AUTHZ__RBAC__ROLE_CLAIMS(default["roles","groups","entitlements","realm_access.roles"], the RFC 9068 §2.2.3.1 carriers, then the Keycloak shape) names the JWT claim paths whose values become the caller’s roles for the role layer. A path may be dotted to walk nested claims, and a claim holding a single string is accepted as readily as an array.
Everything below is just those two groups pointed at a different issuer.
Microsoft Entra ID (Azure AD)
Entra ID exposes a standards-compliant OIDC issuer per tenant.
-
Register an application (Entra admin center → App registrations). Note the Directory (tenant) ID and the Application (client) ID.
-
Define app roles (App registration → App roles): create roles named after the CDR roles you use (for example
USER,CLINICAL,ADMIN) and assign users/groups to them (Enterprise applications → your app → Users and groups). Entra puts assigned app roles in the token’srolesclaim. -
Expose an audience: either use the client ID as the audience or add an Application ID URI (for example
api://ferroehr). -
Point the CDR at the tenant issuer and mine the
rolesclaim:export FERROEHR__AUTH__OIDC__ISSUER=https://login.microsoftonline.com/<tenant-id>/v2.0 export FERROEHR__AUTH__OIDC__AUDIENCES=api://ferroehr export FERROEHR__AUTHZ__RBAC__ROLE_CLAIMS='["roles"]' -
Verify: request a token for the app (any OAuth2 client credential or auth-code flow) and call the API. Read the status codes as a diagnostic:
Status What it tells you 200-familyroles arrived and the operation was permitted 401no credential, or one the server rejected (the body deliberately never says which) 403the token is valid but carries no role the operation needs: a clinical call needs at least one role, an admin call needs the admin role 400the Authorizationheader itself is malformed; no credential was ever read503the CDR could not reach your issuer’s JWKS, so the token was never judged: check network egress and the discovery document, not the token
Important
A role must arrive in a role claim, not in
scope. The OAuth2scopeclaim grants a client delegated authority (RFC 6749 §3.3) and says nothing about the subject’s roles, so it is not a role source. If your IdP is configured to put role names inscope, map them intoroles(or another entry ofROLE_CLAIMS) instead.
Tip
Group-based deployments can emit the
groupsclaim instead and list it inROLE_CLAIMS, but group claims arrive as object IDs unless you configure group names, so app roles usually read better in policy.
Warning
Two configuration mistakes are boot errors rather than runtime surprises, so you will find them the first time you start the server: an issuer that is not an
httpsURL with no query or fragment, and an empty audience list. Both are deliberate: the second is what stops this server accepting a token minted for a different service. Do not reach forALLOW_INSECURE_ISSUERor a sharedHMAC_SECRETto get past them; both are development-only postures.
AD FS (on-premises Active Directory)
AD FS 2016+ speaks OIDC. This is also the supported path for on-premises Active Directory in general: front AD with AD FS (or another OIDC-capable broker) rather than pointing anything at LDAP.
-
Create an Application Group (AD FS Management → Application Groups → Web API template, or Server application + Web API for interactive clients). The Web API’s identifier becomes the token audience.
-
Issue role claims: on the Web API’s Issuance Transform Rules, add a rule mapping AD group membership to the
roleclaim (template: Send Group Membership as a Claim), one rule per CDR role. -
Point the CDR at the AD FS issuer and mine the
roleclaim:export FERROEHR__AUTH__OIDC__ISSUER=https://adfs.example.com/adfs export FERROEHR__AUTH__OIDC__AUDIENCES=ferroehr-api export FERROEHR__AUTHZ__RBAC__ROLE_CLAIMS='["role"]'Discovery works out of the box (
https://adfs.example.com/adfs/.well-known/openid-configuration). -
Verify with the same status table as above:
401without a token,403with a token that lacks the required role.
Note
AD FS emits a single string for one role and an array for several; the role-mining layer accepts both shapes on any configured claim path.
“We only have LDAP”
The CDR does not speak LDAP, by design: LDAP bind would put password handling back inside the CDR. Front the directory with an OIDC-capable broker and connect that instead:
- Active Directory → AD FS (above) or Entra ID (if synced).
- Generic LDAP → Keycloak with LDAP user federation (the Keycloak example then applies verbatim), or any other OIDC provider that can federate LDAP.
The broker owns the LDAP bind; the CDR sees only signed tokens.
Serving SMART apps
If your IdP is also the authorization server for SMART App Launch apps, the same
[auth.oidc] block does double duty: the CDR must be able to validate the tokens
those apps come back with, so SMART cannot be enabled without it, and the issuer
the CDR advertises to apps must be the same one it accepts tokens from; a
mismatch is refused at boot. See SMART App Launch.