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).
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:
-
Audience (
audclaim, RFC 7519 §4.1.3) — every token must include the calling service’s identifier in itsaudarray. -
Authorized Party (
azpclaim, OIDC §2) — captured but not enforced in v1; useful for future analytics distinguishingcraig-ui(web) fromcraig-api(CLI/SDK). -
Scope (
scopeclaim, RFC 6749) — capability shipped; per-handler enforcement is incremental (Step 3 shipsclaims.has_scope()/claims.require_scope(); handlers adopt as they’re touched in subsequent steps). -
Token Type (
typclaim) — 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 hitrecord_decision; an admin token still requiresadminto hitdelete_partner. Audience does not change that. -
Audience ensures the token was issued by Keycloak’s
craigrealm 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 theiraudwon’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/nbfwith ZERO leeway while the JWS path inheritedjsonwebtoken’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 siat-in-future tolerance andexpected_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)typcomparison 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 byvalidate_token_accepts_lowercase_bearer_typ. (2)expabsence — DECIDED as an accepted, mode-inherent asymmetry rather than a parity defect: the LOCAL (JWS) verifier keeps requiringexp(a self-validated token without one is a never-expiring credential with no oracle to bound it), while introspection keeps accepting anactive: trueresponse withoutexp(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_endpointcollapsed an IdP discovery OUTAGE intoEndpointNotConfigured— sending on-call to config during an outage.DiscoveryFailed(OidcDiscoveryError)now carries the outage cause;EndpointNotConfiguredis 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::refreshnever consulted the HTTP status, so any body that parsed as aJwkSetreplaced 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::newgains theexpected_audience: &strparameter; every service’sbootstrapfunction passes it from settings. -
All existing tests that synthesize tokens (
crates/craig-auth/tests/jwks.rs) need audience andtypfields 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,admintest 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
audand will fail validation. This is fine pre-1.0 with no live deployment; a re-login cycle is acceptable.
Open questions
-
azp enforcement in v1? Decision: capture only, do not enforce. Reasoning:
azpis 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"). -
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.mdwhich scopes each handler requires; flag missing scope checks in the plan-completion-audit (Step 16). -
Refresh token handling. Today
craig-uiuses standard flow with refresh tokens managed in the encrypted-cookie pattern (ADR-013). Thetyp == "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).