ADR-021: JWT Validation Policy (Audience, Scope, Authorized Party, Token Type)

On this page

Status

Accepted (2026-04-29). Implemented in Platform Stabilization plan § Step 3.

Claims extended with aud (custom deserializer for string-or-array shapes), azp, scope, and typ. JwksProvider gained with_audience(…​) builder and enforces audience + typ == "Bearer" during validate_token. init_auth propagates the service name as the expected audience. The Keycloak craig-realm.json adds a hardcoded multi-valued aud claim mapper to both craig-api and craig-ui clients listing all API-service names (9 as-built — see § Keycloak realm config); any token from the realm therefore satisfies any service. Claims::has_scope / require_scope ship as capability — per-handler scope adoption is incremental as those handlers are touched in later steps.

Amendment (2026-07-07, #797): the Validation::new(Algorithm::RS256) shown in the Context/Decision sketches below was later generalized — validate_token now derives the single accepted algorithm from the resolved JWK’s key type (RSA→RS256, EC P-256/P-384→ES256/ES384, OKP-Ed25519→EdDSA), never from the token header, so ES256/EdDSA issuers validate under the IdP-neutral default (ADR-026) while the one-algorithm-per-token anti-confusion property is preserved. A symmetric/oct key in a verification JWKS is refused (JwksError::UnsupportedKeyAlgorithm).

Related: ADR-005 (Keycloak OIDC choice), ADR-013 (web session cookie strategy).

Context

crates/craig-auth/src/jwks.rs:117-119 configures token validation as:

let mut validation = Validation::new(Algorithm::RS256);
validation.set_issuer(&[&self.issuer]);
validation.validate_exp = true;

Issuer + expiry. That’s it. No audience, no scope, no authorized party (azp), no token-type validation. The Claims struct at crates/craig-auth/src/claims.rs:5-19 deserializes only sub, preferred_username, email, and realm_access.roles.

Practical consequence: any token correctly signed by the craig realm with broad realm roles (e.g., caseworker) can hit every service. There is no per-service authority boundary at the token layer; only claims.require_role() checks at the handler. A caseworker token correctly issued by Keycloak for use against craig-cases works identically against craig-rules, craig-financial, etc., as long as the role check passes.

The external review (2026-04-28) called this out as the second-highest P0 finding: "Any correctly issued token for the realm with broad roles can potentially hit every service. Offline workers will make this worse unless you add audience, authorized party/client, token type, and service-specific scopes."

The Keycloak realm config at devstack/keycloak/craig-realm.json:18-35 confirms no audience mappers are configured for either craig-api (CLI/SDK ROPC client) or craig-ui (web standard-flow client). Tokens are issued without aud claims today, so even if validation were enabled it would have nothing to check against.

Decision

Extend the Claims struct, the JwksProvider validator, the per-service settings, and the Keycloak realm config to enforce four additional checks:

  1. Audience (aud claim, RFC 7519 §4.1.3) — every token must include the calling service’s identifier in its aud array.

  2. Authorized Party (azp claim, OIDC §2) — captured but not enforced in v1; useful for future analytics distinguishing craig-ui (web) from craig-api (CLI/SDK).

  3. Scope (scope claim, RFC 6749) — capability shipped; per-handler enforcement is incremental (Step 3 ships claims.has_scope() / claims.require_scope(); handlers adopt as they’re touched in subsequent steps).

  4. Token Type (typ claim) — must be present and equal to "Bearer". Keycloak emits this; rejecting anything else closes off accidentally-presented refresh tokens.

Claims struct extension

pub struct Claims {
    pub sub: String,
    pub preferred_username: String,
    pub email: Option<String>,
    pub realm_access: RealmAccess,
    // ── ADR-021 additions ──────────────────────────────────────────
    /// Audience claim per RFC 7519 §4.1.3. Keycloak emits as either a
    /// string or array; deserialized as Vec<String> either way via a
    /// custom deserializer that tolerates both shapes.
    #[serde(default, deserialize_with = "claims::aud_or_vec")]
    pub aud: Vec<String>,
    /// Authorized party (client_id) per OIDC §2.
    #[serde(default)]
    pub azp: Option<String>,
    /// Space-separated scope string per RFC 6749. Parsed lazily into
    /// Vec<String> via Claims::scopes().
    #[serde(default)]
    pub scope: Option<String>,
    /// Token type (Keycloak emits "Bearer"). Reject anything else.
    #[serde(default, rename = "typ")]
    pub typ: Option<String>,
}

impl Claims {
    pub fn scopes(&self) -> Vec<&str> { /* split on whitespace */ }
    pub fn has_scope(&self, scope: &str) -> bool { /* contains check */ }
    pub fn require_scope(&self, scope: &str) -> Result<(), ApiError> { /* 403 if absent */ }
}

Validator extension

pub struct JwksProvider {
    issuer: String,
    fetch_url: String,
    expected_audience: String,  // ← NEW
    keys: Arc<RwLock<Option<JwkSet>>>,
}

impl JwksProvider {
    pub fn new(issuer: &str, expected_audience: &str) -> Result<Self> { ... }

    pub async fn validate_token(&self, token: &str) -> Result<Claims, anyhow::Error> {
        // ... existing kid lookup ...
        let mut validation = Validation::new(Algorithm::RS256);
        validation.set_issuer(&[&self.issuer]);
        validation.set_audience(&[&self.expected_audience]);
        validation.validate_exp = true;
        let token_data = decode::<Claims>(token, &decoding_key, &validation)?;

        if token_data.claims.typ.as_deref() != Some("Bearer") {
            return Err(anyhow::anyhow!(
                "token typ is `{:?}`, expected `Bearer`",
                token_data.claims.typ
            ));
        }
        Ok(token_data.claims)
    }
}

Per-service config

crates/craig-common/src/settings.rs::ServiceSettings gains jwt_audience: String, loaded from CRAIG_<SERVICE>JWT_AUDIENCE env var (e.g., CRAIG_CASESJWT_AUDIENCE=craig-cases). Default to the service name when env var unset.

Keycloak realm config

Add Hardcoded Audience client mappers to both craig-api and craig-ui clients. Each mapper emits a multi-valued aud array containing all 9 API-service names (craig-composition joined the fleet after this ADR; the authoritative list is devstack/keycloak/craig-realm.json — the craig-web audience is emitted by the per-service client_credentials clients, not by these two):

craig-rules, craig-cases, craig-placement, craig-exchange,
craig-financial, craig-reporting, craig-security, craig-composition,
craig-intake

Plus per-client scope mappers emitting service-specific scope strings (defined in the plan §D2).

Multi-valued aud rationale (load-bearing)

A reasonable reader will ask: "if every token’s aud contains every service, what does audience enforcement actually buy us?" The answer:

Audience prevents cross-realm token reuse, not cross-service role escalation.

  • RBAC remains the per-service authorization boundary. A caseworker token still requires caseworker (or higher) role to hit record_decision; an admin token still requires admin to hit delete_partner. Audience does not change that.

  • Audience ensures the token was issued by Keycloak’s craig realm for use against CRAIG services. Tokens from a sibling Keycloak realm (other DHS systems on the same Keycloak host), tokens stolen from a non-CRAIG context, or tokens minted by a misconfigured downstream client are all rejected because their aud won’t match.

  • Per-service tokens (the alternative) would force every CLI invocation and every web page load to acquire N tokens (one per service). The friction is high, the security win is zero (RBAC still gates each service independently).

This rationale is required reading before audience enforcement is interpreted as service-isolation.

Scope strings

Defined per service in plan §D2. Scope enforcement is capability-only in Step 3: handlers gain access to claims.has_scope() / claims.require_scope(). Per-handler adoption is incremental in Steps 5/8/9/10/12 of the plan as those handlers are touched anyway. The full scope-enforcement audit is a follow-on cleanup tracked in the plan’s sibling-issues group, not blocking ADR-021 acceptance.

Amendment — #1534/#1533/#1532: introspection-path parity + diagnostics (2026-08-22)

The C17 contested audit (#1512) found the "identical token semantics" claim false at three margins; this amendment records the closures:

  • Clock-skew parity (#1534). The introspection path compared exp/nbf with ZERO leeway while the JWS path inherited jsonwebtoken’s 60 s default — in Auto mode the SAME token could pass as a JWS and fail via introspection inside the skew window, purely on token shape. The introspection path now applies the same 60 s leeway (`EXP_NBF_LEEWAY_SECONDS), and gains the two gates it was missing: the shared 30 s iat-in-future tolerance and expected_authorized_parties (client_id/azp) enforcement with the JWS provider’s exact semantics (absence-with-enforcement rejected; unconfigured = skipped). The parity claim is now true for exp/nbf/iat/aud/azp. Two residual margins CLOSED by #1562 (2026-08-23): (1) typ comparison case — the JWS path now compares case-insensitively too (RFC 7515 §4.1.9’s recommendation; loosening the strict side, never tightening the compliant one), pinned by validate_token_accepts_lowercase_bearer_typ. (2) exp absence — DECIDED as an accepted, mode-inherent asymmetry rather than a parity defect: the LOCAL (JWS) verifier keeps requiring exp (a self-validated token without one is a never-expiring credential with no oracle to bound it), while introspection keeps accepting an active: true response without exp (RFC 7662 makes it optional, the IdP’s live verdict IS the expiry judgment, and the cache-TTL cap bounds staleness). Tightening either side would have been RFC-hostile or a security downgrade; both boundaries are pinned by twin tests (validate_token_rejects_a_token_without_exp / active_response_without_exp_is_accepted).

  • Outage vs config diagnostics (#1533). resolve_endpoint collapsed an IdP discovery OUTAGE into EndpointNotConfigured — sending on-call to config during an outage. DiscoveryFailed(OidcDiscoveryError) now carries the outage cause; EndpointNotConfigured is reserved for a successful discovery doc lacking the endpoint with no override. Both still fold to 401 at the middleware (fail-closed unchanged).

  • JWKS status-classed refresh (#1532). JwksProvider::refresh never consulted the HTTP status, so any body that parsed as a JwkSet replaced the key cache regardless of status (a proxy answering 503 with {"keys":[]} would wipe the keys), and a 5xx with a JSON error body misread as a decode problem. The status is now checked before the body; a non-2xx can never touch the cache (pinned by the poison-shape test).

Consequences

  • JwksProvider::new gains the expected_audience: &str parameter; every service’s bootstrap function passes it from settings.

  • All existing tests that synthesize tokens (crates/craig-auth/tests/jwks.rs) need audience and typ fields added to the test-token payload.

  • New tests:

    • validate_token_rejects_wrong_audience

    • validate_token_rejects_missing_audience

    • validate_token_rejects_wrong_typ

    • validate_token_accepts_aud_as_string_or_array

    • has_scope_check

    • require_scope_returns_403_when_absent

  • Devstack reseed (cargo xtask dev reseed) is required after the realm config change.

  • Existing E2E suite must keep passing — jane.doe, bob.smith, admin test users acquire tokens with the right audience claims via the realm mapper.

  • No backwards-compat for in-flight tokens. Tokens issued before the realm config update are still valid until expiry but won’t carry aud and will fail validation. This is fine pre-1.0 with no live deployment; a re-login cycle is acceptable.

Open questions

  1. azp enforcement in v1? Decision: capture only, do not enforce. Reasoning: azp is most useful in pure OIDC flows; CRAIG is mostly RBAC-driven. Revisit if a use case surfaces (e.g., "this endpoint is callable from the web UI but not from the CLI").

  2. Per-handler scope adoption pace. Capability ships in Step 3; full adoption is incremental. Risk: a partially-enforced scope policy is confusing. Mitigation: document per-endpoint in services.md which scopes each handler requires; flag missing scope checks in the plan-completion-audit (Step 16).

  3. Refresh token handling. Today craig-ui uses standard flow with refresh tokens managed in the encrypted-cookie pattern (ADR-013). The typ == "Bearer" check applies only to access tokens; refresh-token flow is internal to the BFF and unaffected.

Alternatives considered

A. Per-service tokens (one token per service, single-audience)

Rejected. Forces every CLI invocation and every web page load to acquire N tokens. The friction is real; the security win is zero (RBAC still gates per-service authorization).

B. Scope enforcement upfront, blocking on full adoption

Rejected. Would require simultaneously updating ~150+ handler call sites in Step 3. Too large for a single MR. Capability-then-incremental adoption matches CRAIG’s other gradual-rollout patterns (e.g., the metadata extension in ADR-019).

C. Status quo (no audience, no scope)

Rejected. External review explicitly flagged this as a P0 federal-compliance gap and the offline-worker app can’t safely build on top of it.

Edit this page · latest