ADR-011: IAM Abstraction — Published JWT Contract

On this page

Status

Accepted — implementation outstanding

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_ to OIDC_ and update the deployment-guide / configuration-reference.

  • Add the claims_path and roles_claim configuration 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

sub

string

Stable, unique user identifier. Used as worker identity in audit records and API responses.

iss

string (URL)

Token issuer. Validated against OIDC_ISSUER env var.

exp, iat

number

Standard JWT expiry and issued-at.

preferred_username

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 OIDC_ROLES_CLAIM (default: realm_access.roles).

CRAIG Role Values

Role Permissions

admin

Full access to all endpoints and admin operations

supervisor

Case management, placement, reporting, approval workflows

caseworker

Case management, placement, data entry

eligibility_worker

Financial, eligibility evaluation, claims

icpc_coordinator

Data exchange, ICPC case management

readonly

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

CRAIG_*__OIDC_ISSUER

CRAIG_*__OIDC_ISSUER

CRAIG_*__OIDC_INTERNAL_URL

CRAIG_*__OIDC_INTERNAL_URL (optional, for split Docker networking)

CRAIG_WEB__KEYCLOAK_CLIENT_ID

CRAIG_WEB__OIDC_CLIENT_ID

CRAIG_WEB__KEYCLOAK_REDIRECT_URI

CRAIG_WEB__OIDC_REDIRECT_URI

(new)

OIDC_ROLES_CLAIM (default: realm_access.roles)

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):

  1. CLI requests a device code from the IdP

  2. IdP returns device_code, user_code, and verification_uri

  3. CLI displays the URI and code for the operator to open in a browser

  4. CLI polls the token endpoint until authorization completes

  5. Tokens stored in the profile

ROPC is retained as an opt-in (auth_flow = "ropc" in profile) for devstack testing where Keycloak supports it.

Dex as Optional Translation Proxy

For IAMs that cannot emit JWTs matching the CRAIG contract (SAML-only IdPs, LDAP directories, rigid claim formats), Dex (CNCF graduated) is the recommended translation proxy. Dex is not bundled with CRAIG and is not required for OIDC-compliant IAMs.

Devstack

Keycloak remains the devstack IAM. Test users retain their pinned UUIDs. The default OIDC_ROLES_CLAIM value (realm_access.roles) matches Keycloak’s claim format. Test infrastructure (TokenProvider) continues using ROPC against Keycloak — ROPC is acceptable for test tooling.

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 uses realm_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_CLAIM misconfiguration 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.

Edit this page · latest