ADR-011: IAM Abstraction — Published JWT Contract
On this page
Rollout Status (2026-04-20)
This ADR is Accepted but the implementation is deferred.
As of 2026-04-20, CRAIG still uses Keycloak-specific env vars (CRAIG_*OIDC_ISSUER, CRAIG_*OIDC_INTERNAL_URL), the craig-api client still uses ROPC, and the web UI hardcodes Keycloak endpoint paths.
No separate issue or plan currently tracks the ADR-005 supersession described here.
Before onboarding any non-Keycloak IAM, a follow-up MR must:
-
Rename env vars from
KEYCLOAK_toOIDC_and update the deployment-guide / configuration-reference. -
Add the
claims_pathandroles_claimconfiguration documented below. -
Switch the CLI from ROPC to Device Authorization Grant (RFC 8628).
Until then, ADR-005 is the operational reality; this ADR documents the intended direction.
Context
CRAIG currently assumes Keycloak as its identity provider.
The coupling is deep: realm_access.roles is hardcoded in the Claims struct (craig-auth/src/claims.rs), JWKS fetching uses the Keycloak-specific path /protocol/openid-connect/certs (craig-auth/src/jwks.rs), the web UI hardcodes three Keycloak endpoints for login, token exchange, and logout (craig-web/src/auth.rs), and the CLI uses Resource Owner Password Credentials (ROPC) with Keycloak’s realm-based token URL (craig-cli/src/auth.rs).
State agencies commonly operate Azure AD/Entra ID, Okta, PingFederate, ForgeRock, or custom LDAP/SAML stacks. Requiring Keycloak forces agencies to either run a parallel identity store or reject CRAIG entirely. Neither is acceptable for an open-source CCWIS meant to lower barriers to adoption.
Three approaches were evaluated:
| Approach | Complexity | Strengths | Weaknesses |
|---|---|---|---|
Published JWT contract + configurable claim path |
Low |
Zero code change for most IAMs; operators configure claim mappings in their existing IAM; standards-based (RFC 7519, RFC 8628) |
Requires IAM to support custom claim mappings (most enterprise IAMs do) |
Pluggable auth backend in craig-auth |
Medium |
CRAIG handles IAM quirks internally; operator config only |
Over-engineers for alpha stage; every new IAM quirk adds code; hard to test exhaustively |
Dex as mandatory middleware |
High |
Normalizes everything upstream; CRAIG sees only clean JWTs |
Forces every deployment to run Dex; operational overhead for simple cases; violates "no mandatory middleware" |
Decision
Adopt the published JWT contract approach with OIDC discovery and a clean-break rename of all Keycloak-specific identifiers.
CRAIG JWT Contract
Any identity provider used with CRAIG MUST issue JWTs satisfying:
| Claim | Type | Description |
|---|---|---|
|
string |
Stable, unique user identifier. Used as worker identity in audit records and API responses. |
|
string (URL) |
Token issuer.
Validated against |
|
number |
Standard JWT expiry and issued-at. |
|
string |
Human-readable display name. Used by craig-web for UI display only; never for authorization. |
roles claim (configurable path) |
array of strings |
CRAIG role names assigned to the user.
Location configurable via |
CRAIG Role Values
| Role | Permissions |
|---|---|
|
Full access to all endpoints and admin operations |
|
Case management, placement, reporting, approval workflows |
|
Case management, placement, data entry |
|
Financial, eligibility evaluation, claims |
|
Data exchange, ICPC case management |
|
GET endpoints only across all services |
OIDC Discovery
All OIDC endpoint URLs are discovered at startup from {OIDC_ISSUER}/.well-known/openid-configuration.
This replaces all hardcoded /protocol/openid-connect/* paths.
The discovery document provides jwks_uri, authorization_endpoint, token_endpoint, end_session_endpoint, and device_authorization_endpoint.
Configuration Rename
Since CRAIG is v0.1.0-alpha with no production deployments, this is a clean break with no backward-compatibility aliases.
| Current | Replacement |
|---|---|
|
|
|
|
|
|
|
|
(new) |
|
CLI profile fields rename similarly: oidc_internal_url + keycloak_realm collapse into oidc_issuer.
CLI: ROPC → Device Authorization Flow
The CLI replaces Resource Owner Password Credentials (deprecated in OAuth 2.1, unsupported by most enterprise IAMs) with Device Authorization Flow (RFC 8628):
-
CLI requests a device code from the IdP
-
IdP returns
device_code,user_code, andverification_uri -
CLI displays the URI and code for the operator to open in a browser
-
CLI polls the token endpoint until authorization completes
-
Tokens stored in the profile
ROPC is retained as an opt-in (auth_flow = "ropc" in profile) for devstack testing where Keycloak supports it.
Rationale
-
Standards-based: OIDC discovery is an established standard. Every major enterprise IAM supports it.
-
Minimal code change: The coupling points are config fields and a handful of hardcoded paths — not architectural patterns.
-
Clean break is free: No production users exist at v0.1.0-alpha. Aliases add complexity for zero benefit.
-
Device Auth Flow is universal: Supported by Azure AD, Okta, Keycloak, Auth0, PingFederate. ROPC is not.
-
Configurable claim path is sufficient: Azure AD uses
roles, Auth0 uses custom namespaces, Keycloak usesrealm_access.roles. A dot-separated path config covers all of these.
Consequences
-
State agencies can use their existing IAM without running a parallel identity store.
-
OIDC_ROLES_CLAIMmisconfiguration silently produces empty role sets (all requests unauthorized). Startup validation and clear error messages are required. -
Device flow adds a browser step to CLI authentication. Service accounts should use client credentials flow instead.
-
The JWT contract is a public API surface — changes to role names or required claims are breaking changes requiring a major version bump.
-
Operators are responsible for mapping their IAM’s groups/roles to CRAIG role names. This is standard practice for enterprise IAM integration.