Plan: Introspection-Mode Token Validation
On this page
- Status
- Context
- Phase 0 Findings (durable; source-confirmed 2026-05-12)
- Scope
- Design
- Steps
- Step 1: Plan body + ADR-029 (Proposed)
- Step 2: Introspection primitives (no service wiring)
- Step 3: AuthLayer trait refactor + Settings + bootstrap wiring + ADR-029 Accepted
- Step 4: Devstack Kanidm profile +
xtask identity render --backend kanidmadapter - Step 5: Integration tests + ZITADEL-opaque-mode coverage
- Step 6: Docs reframe + ADR amendments + plan completion audit + archive
- Files Touched
- Verification
- Risks
- Migration / rollout / rollback
- Documentation Updates
- Errata
- References
Status
| Step | Description | Status |
|---|---|---|
1 |
Plan body + ADR-029 (Proposed) + nav entry move (Planned → Active) + CHANGELOG. Docs-only; zero code changes. |
Done (2026-05-12) — MR !269 |
2 |
Introspection primitives. |
Done (2026-05-13) — MR !270 |
3 |
AuthLayer refactor + Settings + bootstrap. |
Done (2026-05-13) — MR !271 |
4 |
Devstack Kanidm profile + |
Done (2026-05-13) — MR !272 |
5 |
Integration tests. |
Done (2026-05-13) — MR !273 |
6 |
Docs reframe + ADR amendments + plan completion audit + archive. |
Done (pre-ADR-030) — this MR |
Epic: none (single-plan initiative under Security & Compliance)
Issues: filed per step at implementation start; Step 1 = #404
Branch: docs/introspection-validation-step1-plan-body (Step 1); successors follow feat/introspection-validation-step<N>-<slug>
Predecessor: Built on Plan E (Service Identity, archived 2026-05-11). Direct follow-up to the Dex strip (!267) + ZITADEL JWT-mode addition (!268) under idp-multibackend-tests.adoc.
Context
CRAIG’s craig-auth::middleware::auth_middleware validates inbound bearer JWTs locally. The middleware calls layer.provider.validate_token(token).await (crates/craig-auth/src/middleware.rs:78), which dispatches into JwksProvider::validate_token (crates/craig-auth/src/jwks.rs:163-221); that path calls jsonwebtoken::decode::<Claims>(token, &key, &validation) at crates/craig-auth/src/jwks.rs:197. The path is fast (~10µs decode + cached JWKS lookup), resilient to short IdP outages (JWKS refresh is on a 1h TTL background task), and the natural fit for self-contained signed JWTs (RFC 9068).
It is one of two mature halves of the OAuth design space. The other half — opaque or encrypted tokens validated via introspection (RFC 7662) — is the model that ZITADEL defaults to, that Kanidm uses unconditionally for client_credentials access tokens (JWE-encrypted, validated only via introspection), and that Keycloak ("lightweight access tokens"), authentik (encrypted-token mode), and several other backends offer as configurable alternatives.
Today CRAIG’s "IdP-neutral over OIDC backends with client_credentials`" claim (ADR-026 + ADR-028) is materially narrower than the OAuth ecosystem: it excludes any backend whose `client_credentials path defaults to or requires introspection. Backends affected:
-
Kanidm —
client_credentialsaccess tokens are unconditionally JWE-encrypted viajwe_a128gcm_encryptatkanidmd/lib/src/idm/oauth2.rs:1861. Validation requires the introspection endpoint. Confirmed in Phase 0 sandbox (2026-05-12; see Phase 0 Findings below). -
ZITADEL — defaults to opaque access tokens (introspection-mandated). Operators can pin
access_token_type = ACCESS_TOKEN_TYPE_JWTper machine user to force JWS (which is what !268 does), but that’s working around the IdP’s default rather than supporting it natively. -
Keycloak — supports "lightweight access tokens" (smaller, opaque format); deployers in regulated environments may pin this for log-volume / data-minimization reasons.
-
authentik — encrypted-token mode available for high-confidentiality deployments.
This plan adds an introspection-mode validation strategy to craig-auth so deployers can choose either path (or have it auto-detected) per their backend configuration. The architectural goal: CRAIG’s IdP-neutral claim becomes "any OIDC backend with client_credentials, validated locally (JWS) or via introspection (RFC 7662)."
The work was deferred from !268 (ZITADEL JWT-mode adapter) precisely because it deserves its own architectural review surface — it changes the per-request runtime cost model, introduces cache-correctness work, and lands ADR-029. Doing it standalone keeps each MR independently reviewable.
Phase 0 Findings (durable; source-confirmed 2026-05-12)
Kanidm v1.10.1 (Rust-aligned candidate)
-
client_credentialsgrant works. Verified by sandbox: created an OAuth2 "resource server" viaPOST /v1/oauth2/_basicwithidm_admintoken; added a scope-map bindingidm_all_accounts→openid(the RS is itself a member ofidm_all_accountsbecause it carriesEntryClass::Account); HTTP basic auth +grant_type=client_credentials&scope=openidat/oauth2/tokenreturned HTTP 200 + an access token. -
Discovery URL pattern is per-resource-server:
https://<host>/oauth2/openid/<rs_name>/.well-known/openid-configuration. Token endpoint is issuer-level:<host>/oauth2/token. Introspection endpoint:<host>/oauth2/token/introspect. JWKS exposes one ES256 (P-256) sig key per RS. -
Introspection works and returns expected RFC 7662 claims (
sub,aud,client_id,username,scope,exp,iat,nbf,jti,active,token_type). -
Dealbreaker for JWS path: access tokens are JWE-encrypted (
alg=A128KW, enc=A128GCM, 5-segment compact serialization). Source-confirmed atkanidmd/lib/src/idm/oauth2.rs:1861—jwe_a128gcm_encrypt(&access_token_data, ct)is called unconditionally during access-token issuance. No configuration knob to switch to JWS forclient_credentials. Same on master. The intended validation path is the introspection endpoint — which is exactly what Plan F adds.
ZITADEL v4.15.0 (Go-based; shipped in !268 with JWT-mode pin)
-
Standard tenant-level discovery URL at
<host>/.well-known/openid-configuration. Token endpoint:/oauth/v2/token. Introspection endpoint:/oauth/v2/introspect. JWKS:/oauth/v2/keys— two RS256 sig keys (no enc keys). -
client_credentialsis advertised ingrant_types_supported. Bad-creds probe returnsinvalid_client(grant parsed + dispatched, only auth lookup failed) — same shape Authentik shows; opposite of what Dex returned. -
!268 pins
access_token_type = ACCESS_TOKEN_TYPE_JWTper machine user so issued tokens validate locally. ZITADEL’s default is opaque tokens that require introspection. Plan F Step 5 adds azitadel_opaque_mode_introspectionintegration test covering the default. -
Bootstrap chicken-and-egg: Terraform provider needs an authenticated PAT. Initial-instance human admin is created via
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_*env vars (already wired indocker-compose.yml). Step 5 pre-bakes viaZITADEL_FIRSTINSTANCE_MACHINEKEYPATHfor sandbox reproducibility.
Scope
In scope:
-
trait ClaimsExtractorabstraction incraig-authwith three impls —JwsExtractor(today’s path, behavior unchanged),IntrospectionExtractor(new),AutoExtractor(dispatches on token shape). -
RFC 7662 introspection client + per-process
DashMap-backed token claims cache (SHA-256 hashed keys,std::time::Instant-clocked TTL). -
Claimsnormalization layer mapping per-IdP introspection response shapes into the canonicalClaimsstruct, enforcing audience / token-type / expiry / nbf parity with the JWS path (per ADR-021). -
New
ServiceSettingsfieldtoken_validation_mode(Jws/Introspect/Auto; defaultAuto) + 5 introspection knobs (cache TTL, cache max entries, serve-on-outage tunable, endpoint override, auth method). -
Devstack
identity-multibackendprofile gains a Kanidm service. -
cargo xtask identity render --backend kanidmadapter (emits CLI bootstrap script — no admin API client per ADR-026). -
cargo xtask identity verifygains an introspection-endpoint probe arm (soft-check atvalidate [6c/14]). -
Integration tests: Kanidm full round-trip + ZITADEL opaque-mode introspection.
-
ADR-029 Proposed → Accepted lifecycle.
-
idp-integration.adocreframe; ADR-026 + ADR-028 revision notes. -
Metrics for introspection requests, cache hits, request duration, cache size.
Out of scope:
-
Token-exchange grant (RFC 8693) — separate work if a deployer asks.
-
Switching CRAIG’s default validation mode away from JWS-first —
Automode dispatches to JWS for 3-segment tokens (today’s behavior); introspection only fires for JWE/opaque. -
Replacing Plan E’s per-service
OidcServiceToken— outbound + inbound are decoupled; service-identity tokens stay how Plan E wired them. -
X-Craig-Actor JWT verification path — actor JWTs are CRAIG’s own ES256-signed tokens (peer-JWKS); unaffected by IdP-side validation mode.
-
Cross-replica cache sharing (Redis or similar) — per-process cache only. Each replica builds its own independently.
-
Push-based revocation (CAEP / SSE token-revocation streams) — captured in ADR-029’s Open Questions for forward-compat consideration only.
-
DPoP (RFC 9449) / mTLS-bound (RFC 8705) extractors — the trait abstraction leaves room but Plan F doesn’t ship these.
Design
Architectural decisions (locked)
| Decision | Choice | Rationale |
|---|---|---|
Dispatch shape |
|
Future-extensible to DPoP (RFC 9449), mTLS-bound (RFC 8705), public-key JWE, CAEP push-revocation. Rust 2024 async-fn-in-trait (already used in Plan E Step 7 for |
Default mode |
|
Zero-config compatibility across the candidate IdP matrix. Operators can pin for predictable cost. |
Outage posture |
Fail closed by default; opt-in |
Symmetric with JWS path’s fail-closed JWKS-refresh failure mode; safer default. |
Cache layer |
In-process |
|
Outbound auth for introspection call |
Reuse |
Already cached, refresh-aware, the right principal. CRAIG presents itself to the IdP as the service introspecting on a worker’s behalf. |
Token hash algorithm |
SHA-256 (32 bytes) |
HMAC-SHA-256 evaluated in ADR-029 Alternatives Considered; cache is per-process in-memory so cross-node correlation isn’t a threat that warrants the extra key-rotation surface. |
Cache clock |
|
Wall-clock skew during NTP corrections would otherwise expire valid cache entries or retain expired ones. Convert claim |
Logging discipline |
Raw bearer never appears in logs; the first 8 bytes of the SHA-256 hex digest are used as a diagnostic correlator |
Standard practice. Codified in ADR-029. |
Actor JWT lifting ( |
Unchanged |
Actor JWTs are CRAIG’s own inter-service ES256-signed tokens validated against the peer-JWKS map; not affected by IdP-side validation mode. |
Trait sketch
pub trait ClaimsExtractor: Send + Sync {
async fn extract(&self, token: &str) -> Result<Claims, AuthError>;
}
// 1) JwksProvider (existing) implements the trait — zero behavior change.
impl ClaimsExtractor for JwksProvider { /* delegates to validate_token */ }
// 2) Introspection path.
pub struct IntrospectionExtractor {
client: Arc<IntrospectionClient>,
}
impl ClaimsExtractor for IntrospectionExtractor { /* delegates to client.extract */ }
// 3) Auto path — dispatches on token shape.
pub struct AutoExtractor {
jws: Arc<JwksProvider>,
introspection: Arc<IntrospectionClient>,
}
impl ClaimsExtractor for AutoExtractor {
async fn extract(&self, token: &str) -> Result<Claims, AuthError> {
match token_shape(token) {
TokenShape::Jws => self.jws.extract(token).await, // 3 segments
TokenShape::Jwe | TokenShape::Opaque => { // 5 segments or no dots
self.introspection.extract(token).await
}
}
}
}
// AuthLayer becomes mode-agnostic.
pub struct AuthLayer {
extractor: Arc<dyn ClaimsExtractor>,
actor_registry: Option<Arc<dyn ActorJwksRegistry>>,
}
Introspection client flow
impl IntrospectionClient {
pub async fn extract(&self, token: &str) -> Result<Claims, AuthError> {
let hash = sha256(token); // never log/store the raw bearer
if let Some(claims) = self.cache.get(&hash) { // lazy TTL check inside get()
return Ok(claims);
}
let endpoint = self.resolve_endpoint().await?; // discovery + override + split-DNS
let svc_token = self.service_token.current().await?;
let resp = self.http
.post(&endpoint)
.bearer_auth(&svc_token) // or basic_auth(client_id, client_secret) per config
.form(&[("token", token)])
.send()
.await
.map_err(AuthError::IntrospectionUnreachable)?;
let body: IntrospectionResponse = resp.json().await.map_err(AuthError::IntrospectionFailed)?;
if !body.active {
return Err(AuthError::TokenInactive);
}
let claims = self.normalize(body)?; // RFC 7662 → Claims; per-backend variations live here
// Per ADR-021 — JWS and introspection enforce identical token semantics.
validate_audience(&claims, &self.expected_audience)?;
validate_token_type(&claims)?;
validate_exp_nbf(&claims, SystemTime::now())?;
let ttl = compute_ttl(&claims, self.config.cache_ttl_max);
self.cache.insert(hash, claims.clone(), ttl);
Ok(claims)
}
}
Cache semantics
-
Key:
[u8; 32]SHA-256 of the raw bearer. Hashing happens before any introspection call so the raw token never sits in the cache as a key. -
Value:
CachedClaims { claims: Claims, expires_at: Instant }. -
TTL:
min(claims.exp - now, config.cache_ttl_max_seconds). Defaultcache_ttl_max_seconds = 60. -
Eviction: lazy on
get(compareInstant::now()toexpires_at) + a background task that sweeps when entries exceedmax_entries(default 10_000). Both are required — the background sweep prevents the cache from holding unbounded expired entries between gets; the lazy check prevents returning expired entries between sweeps. -
Outage behavior: with
serve_on_outage: false(default), a failed introspection call (any error from the IdP) returnsAuthError::IntrospectionUnreachableand the auth middleware emits 401. Withserve_on_outage: true, cache hits continue to serve until their TTL expires even when the IdP is unreachable. -
Concurrent introspection of the same bearer: optional singleflight deduplication (
Arc<DashMap<TokenHash, Arc<Notify>>>keyed on in-flight hashes) is captured as a Step 2 nice-to-have. Without it, N simultaneous requests for the same uncached token fan out to N introspection calls; with it, only the first hits the IdP and the rest wait on the result.
Boot-time wiring
bootstrap::init_auth in crates/craig-api/src/bootstrap.rs switches on ServiceSettings::token_validation_mode:
-
Jws→ buildJwksProvideronly; wrap asArc<dyn ClaimsExtractor>. Behavior identical to pre-Plan-F. -
Introspect→ buildIntrospectionClient(requiresOidcServiceTokenfromBootstrapResult); wrap asArc<dyn ClaimsExtractor>. -
Auto(default) → build both, wrap anAutoExtractor.
Hard-fail at boot in these cases with operator-actionable error messages:
-
IntrospectorAutomode butOidcServiceTokenisNone— points operators atCRAIG_<SVC>__CLIENT_ID/SECRET(Plan E env vars). -
Introspectmode but neither discovery advertisesintrospection_endpointnorCRAIG_<SVC>__INTROSPECTION_ENDPOINTis set — names the override env var.
Cross-cutting invariants
These must hold regardless of validation mode. Codify in inline tests across Steps 2 + 3.
-
Claim semantics parity — JWS and Introspect paths surface identical
Claimsfor the same logical bearer. Audience, type, expiry, nbf, sub, preferred_username, email, realm_access.roles, aud, azp all populate consistently. -
Actor JWT lifting symmetry —
claims.actoris set viaverify_actor_token()post-claims-extraction, regardless of how the outer bearer was extracted. Actor JWTs are always JWS (CRAIG signs with its own ES256 keypair). -
is_service()/service_id()predicate parity — both paths populaterealm_access.roles(via the optionalCRAIG_IDENTITY_ROLES_CLAIM_PATHmapping for non-Keycloak shapes) so service-caller predicates yield identical answers. The deferred follow-up tracked inidp-integration.adocrolls into Step 2’s normalization layer. -
Audit log column population —
actor_service+actor_user_subcolumns (Plan E Step 11) populate fromEventEnvelope.source_service+event_parsing::extract_actor_user_sub. Both fields derive fromClaims, so introspection mode doesn’t alter audit attribution. -
Service-to-service flow symmetry — when craig-web makes an outbound
client_credentials+ X-Craig-Actor call to craig-cases, and craig-cases is in Introspect mode, craig-cases introspects the craig-web service token via its ownOidcServiceToken(acting as craig-cases). The introspection response carriesazp/ role information identifying craig-web; normalization populatesClaims.azpsoclaims.is_service()returns true. -
Outbound + inbound decoupling —
OidcServiceToken(outbound) andIntrospectionExtractor(inbound) are independent. A service inIntrospectinbound mode still emits outbound tokens via the sameclient_credentialspath Plan E established.
Steps
Step 1: Plan body + ADR-029 (Proposed)
Files: docs/modules/ROOT/pages/plans/introspection-validation-mode.adoc, docs/modules/ROOT/pages/adrs/adr-029-introspection-validation.adoc (new), docs/modules/ROOT/nav.adoc, CHANGELOG.adoc
Docs-only MR. Rewrites the stub plan body into the full content (this file). Drafts ADR-029 in Proposed status with sections per writing-adrs.adoc: Status, Context, Decision, Consequences, Open questions, Alternatives considered. Moves Plan F from nav Planned → Active. Adds ADR-029 to nav ADRs index. CHANGELOG entry under == Unreleased.
Step 2: Introspection primitives (no service wiring)
Files: crates/craig-auth/src/oidc_discovery.rs, crates/craig-auth/src/introspection.rs (new), xtask/src/cmd/identity/verify.rs, crates/craig-auth/Cargo.toml (wiremock dev-dep)
-
OidcDiscoveryDoc: addintrospection_endpoint: Option<String>with#[serde(default)]. Extend the split-DNS rewriter to include this endpoint alongsidejwks_uri+token_endpoint. -
New module
introspection.rs:IntrospectionClient+TokenClaimsCache+ RFC 7662 response normalization. Per-claim validation parity with the JWS path. SHA-256 token hashing.Instant-clocked TTL. Background eviction task. -
xtask identity verifyintrospection probe arm: when the discovery doc advertisesintrospection_endpoint, sentinel-probe withtoken=00000000and assert RFC 7662 response shape (active: falseexpected); reportsInconclusivewhen the endpoint isn’t advertised. -
Inline
#[cfg(test)]tests viawiremock = "0.6"(workspace dep): happy-path mapping,active: false, expired exp, cache hit avoids second HTTP call, eviction onmax_entriesoverflow. -
No changes to
auth_middleware,AuthLayer, or service bootstraps.
Step 3: AuthLayer trait refactor + Settings + bootstrap wiring + ADR-029 Accepted
Files: crates/craig-auth/src/middleware.rs, crates/craig-auth/src/jwks.rs, crates/craig-auth/src/lib.rs, crates/craig-common/src/settings.rs, crates/craig-api/src/bootstrap.rs, crates/craig-common/src/metrics.rs, docs/modules/ROOT/pages/adrs/adr-029-introspection-validation.adoc
-
Define
trait ClaimsExtractorinmiddleware.rs. RefactorAuthLayerfrom{ provider: JwksProvider, … }to{ extractor: Arc<dyn ClaimsExtractor>, actor_registry: … }. -
Implement
ClaimsExtractorforJwksProvider(delegate to existingvalidate_token). -
Add
IntrospectionExtractorimpl wrappingIntrospectionClient. -
Add
AutoExtractor { jws, introspection }dispatching on token-shape (3 segments = JWS; 5 = JWE; no dots = opaque). -
ServiceSettingsgainstoken_validation_mode: TokenValidationMode { Jws | Introspect | Auto }(defaultAuto) + 5 introspection knobs:introspection_cache_ttl_seconds(default 60),introspection_cache_max_entries(default 10000),introspection_serve_on_outage(default false),introspection_endpoint: Option<String>(operator override),introspection_auth_method: IntrospectionAuthMethod { Basic | Bearer }(defaultBasic). -
init_authdispatches on mode; hard-fails the two operator-error cases described above. -
craig_common::metricsgains 4 counters/histograms:introspection_requests_total{result},introspection_cache_hits_total,introspection_request_duration_seconds,introspection_cache_size. -
ADR-029 status: Proposed → Accepted.
-
Inline tests cover AutoExtractor dispatch matrix, Settings SerDe, bootstrap mode selection, hard-fail messages.
Step 4: Devstack Kanidm profile + xtask identity render --backend kanidm adapter
Files: xtask/src/cmd/identity/render/kanidm.rs (new), xtask/src/cmd/identity/render.rs, xtask/src/cmd/dev.rs, xtask/src/docker.rs, docker-compose.yml, devstack/kanidm/server.toml (new), devstack/kanidm/README.md (new), devstack/kanidm/bootstrap.sh (new)
-
New
render::kanidmmodule mirrors thezitadel.rs/authentik.rsshape. Kanidm provisions via CLI (kanidm system oauth2 create …); the rendered output is a shell script that operators run against a Kanidm instance authenticated with an admin token. No admin API client — per ADR-026, CRAIG renders the operator’s bootstrap recipe but never integrates with Kanidm’s native admin API at runtime. -
RenderBackend::Kanidmvariant + dispatch. -
docker-compose.yml:kanidmservice underidentity-multibackendprofile (single-container, embedded sqlite, no Postgres dep). Cert auto-gen viakanidmd cert-generateat compose-up. -
PORT_MAPPINGS: add("kanidm", 8443, 8443)+("kanidm", 3636, 3636). -
multibackend_up/down: includekanidm. Newbootstrap_kanidm()helper recovers admin password idempotently viadocker exec kanidmd recover-account admin.
Step 5: Integration tests + ZITADEL-opaque-mode coverage
Files: crates/craig-test-lib/src/lib.rs, crates/craig-test-lib/tests/identity_multibackend.rs
-
identity_multibackend_available(): extend probe to include Kanidm discovery URL; deadline raised to 120s. -
New tests:
-
kanidm_full_introspection_round_trip— discovery →client_credentials→ introspection-validate → assert active=true + claims shape. End-to-end proof the introspection path works against Kanidm v1.10.1. -
zitadel_opaque_mode_introspection— provisions a Machine User withACCESS_TOKEN_TYPE_BEARER(opaque), mints, introspects, validates. Confirms the architecture covers ZITADEL’s default token type, not just !268’s JWT-pinned path.
-
-
Coverage of cache hits, fail-closed on injected 5xx, serve-on-outage preserves cached claims.
Step 6: Docs reframe + ADR amendments + plan completion audit + archive
Files: docs/modules/ROOT/pages/idp-integration.adoc, docs/modules/ROOT/pages/adrs/adr-026-idp-neutral-identity.adoc, docs/modules/ROOT/pages/adrs/adr-028-service-identity.adoc, docs/modules/ROOT/pages/plans/introspection-validation-mode.adoc, docs/modules/ROOT/pages/plans/archive.adoc, docs/modules/ROOT/pages/test-coverage-scorecard.adoc, docs/modules/ROOT/nav.adoc, .claude/CLAUDE.md, CHANGELOG.adoc
-
idp-integration.adoc: Tested-backends list reframed as a JWS/introspection matrix. Kanidm row added (verified). ZITADEL-opaque-mode row added alongside JWT-mode. New env vars documented. -
ADR-026 + ADR-028: revision notes extending the IdP-neutral claim to introspection-validating backends.
-
Plan body Status table: all rows Complete. Plan moves
Active→Archive. -
test-coverage-scorecard.adoc: Plan F row added. -
.claude/CLAUDE.mdPhase Status update (final test count delta + MR list). -
Plan-completion-audit subagent per
delivery-protocol.md.
Files Touched
| File | Step | Action |
|---|---|---|
|
1, 6 |
Rewrite stub → full body; status table Complete at end |
|
1, 3 |
New; Proposed → Accepted |
|
1, 6 |
Plan F Planned→Active→Archive; ADR-029 entry |
|
2 |
|
|
2 |
New — client + cache + RFC 7662 → Claims normalization |
|
2 |
Introspection probe arm |
|
3 |
|
|
3 |
|
|
3 |
|
|
3 |
|
|
3 |
4 introspection metrics |
|
4 |
New — render adapter |
|
4 |
|
|
4 |
Kanidm in multibackend up/down + ports |
|
4 |
Kanidm service under |
|
4 |
New — server.toml + README + bootstrap.sh |
|
5 |
Kanidm + ZITADEL-opaque integration tests |
|
6 |
Tested-backends matrix reframe |
|
6 |
Revision notes |
|
6 |
Plan F row added |
|
6 |
Plan F row added |
|
6 |
Phase Status update |
|
every step |
One entry per MR under |
Verification
After Step 5 lands, the end-to-end verification is:
# Local battery
cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
cargo build --workspace --all-features
cargo nextest run -p craig-auth # introspection unit tests (wiremock)
cargo nextest run -p xtask # render dispatcher + Kanidm adapter inline tests
# End-to-end across all four backend modes
cargo xtask dev multibackend-up # Authentik + ZITADEL + Kanidm + Redis dep
# wait ~120s for warmups (Authentik ~90s, ZITADEL ~30s, Kanidm ~10s)
cargo nextest run -p craig-test-lib --test identity_multibackend
cargo xtask dev multibackend-down
# JWS mode unchanged (byte-identical to today)
CRAIG_CASES__TOKEN_VALIDATION_MODE=jws cargo xtask dev start
# Introspection mode against Kanidm
CRAIG_CASES__TOKEN_VALIDATION_MODE=introspect \
CRAIG_CASES__OIDC_ISSUER=https://kanidm.local:8443/oauth2/openid/craig-cases \
CRAIG_CASES__CLIENT_ID=craig-cases \
CRAIG_CASES__CLIENT_SECRET=<from-kanidm-show-basic-secret> \
cargo run -p craig-cases
Step-level verification is in each step’s section above.
Expected workspace test growth: ~40-50 new tests (15 in Step 2 wiremock + xtask verify probe, 10 in Step 3 dispatch + Settings + bootstrap, 10 in Step 4 Kanidm render inline, 3-5 in Step 5 integration). Today’s count is ~1962 (post-!268). Plan F should land at ~2000-2010. The Step 6 CHANGELOG entry reconciles the actual delta with arithmetic per Reconcile test-count deltas.
Risks
| Risk | Likelihood | Mitigation |
|---|---|---|
Trait refactor (Step 3) breaks downstream callers of |
Low |
|
RFC 7662 response shape divergence across IdPs causes silent claims-mapping bugs |
Medium |
Step 2 normalization layer has explicit per-claim mapping with tests for each backend’s response shape. Step 5 integration tests catch live regressions. |
Auto-mode misdetects token shape and dispatches wrong path |
Low |
JWS = 3 segments, JWE = 5, opaque = no dots; collision space is empty. Step 3 inline test matrix covers all three shapes plus malformed inputs (4 dots, empty, non-ASCII). |
Performance regression when operators enable Introspect mode |
High by construction |
Cache amortizes (~95% hit rate at default TTL). Metrics surface the cost. ADR-029 Consequences documents the perf budget. Opt-in only — default |
Cache TTL stale-revocation window leaks access |
Medium |
Document the window in ADR-029. Operators in compliance-strict deployments can lower TTL (1-5s) at the cost of cache hit rate. Future CAEP push-revocation closes this. |
IdP outage in Introspect mode → CRAIG-wide auth failure |
Medium |
Default |
Auto-mode operator confusion ("why is my IdP slow some requests?") |
Medium |
At boot, log the detected token shape from the first 10 inbound requests so operators can correlate. Document detection rules in |
CI infra failure on the 6-MR sequence (recurring 2xlarge disk-OOM) |
High by historical pattern |
Per-step force-merge fallback documented in |
Migration / rollout / rollback
-
Migration impact on existing deployers: zero. Default mode is
Auto.Autodispatches to JWS for 3-segment tokens (what Keycloak/authentik/ZITADEL-JWT-mode issue today). Existing deployments observe no behavioral change. -
Opt-in path for adding Kanidm: set
CRAIG_<SVC>OIDC_ISSUERto the Kanidm RS issuer URL + ensureCRAIG_<SVC>CLIENT_ID/SECRETare set (Plan E env vars). No code-side change. -
Rollback path if a deployer hits an issue with
Automode: setCRAIG_<SVC>__TOKEN_VALIDATION_MODE=jwsto revert per service. Per-service granularity supports gradual rollback. -
Forward path to opaque/encrypted-token IdPs:
CRAIG_<SVC>__TOKEN_VALIDATION_MODE=introspectpins the path explicitly.
Documentation Updates
-
docs/modules/ROOT/pages/adrs/adr-029-introspection-validation.adoc— NEW (Step 1 Proposed; Step 3 Accepted) -
docs/modules/ROOT/pages/idp-integration.adoc— Tested-backends list reframed; env vars added (Step 6) -
docs/modules/ROOT/pages/adrs/adr-026-idp-neutral-identity.adoc— revision note (Step 6) -
docs/modules/ROOT/pages/adrs/adr-028-service-identity.adoc— revision note (Step 6) -
docs/modules/ROOT/pages/test-coverage-scorecard.adoc— Plan F row (Step 6) -
docs/modules/ROOT/nav.adoc— Plan F nav move + ADR-029 entry (Steps 1, 6) -
.claude/CLAUDE.md— Phase Status update (Step 6) -
CHANGELOG.adoc— entry per MR under== Unreleased(every step) -
.claude/docs/services.md— N/A (Plan F changes no endpoints)
Errata
Plan deviations documented per delivery-protocol.md:
-
E-01 (Step 3, 2026-05-13): Prometheus metrics deferred from Step 3. The plan body originally listed "Metrics counters + histograms emitted" as a Step 3 deliverable (
introspection_requests_total{result},introspection_cache_hits_total,introspection_request_duration_seconds,introspection_cache_size). Step 3 deferred these because: (a) they require separatecraig-common::metricsplumbing on a different axis than the validation refactor, and (b) Step 5’s integration tests usewiremock::expect(N)to assert request-count behavior, giving us the same regression guarantee without the metrics pipeline. Captured for a follow-up MR after Plan F archives. Step 3 description in the Status table was updated accordingly.Resolved 2026-05-15 (closes #410). All 4 instruments shipped via the new
craig_common::metrics::introspectionmodule:introspection_requests_total{result=ok\|unreachable\|bad_status\|parse_error\|token_inactive\|expired\|not_yet_valid\|audience_mismatch\|wrong_token_type},introspection_cache_hits_total,introspection_request_duration_seconds, and the observableintrospection_cache_sizegauge sampled fromTokenClaimsCache::len()via a closure registered inIntrospectionClient::new. Feature-gated oncraig-common’s `otelflag with no-op stubs for builds that drop OTel. Instrumented at every terminal outcome inIntrospectionClient::extract+ duration recorded around the IdP round-trip (cache hits skip the histogram). -
E-02 (Step 5, 2026-05-13): full Kanidm + ZITADEL-opaque mint round-trips deferred. The plan body originally listed two integration tests asserting end- to-end
client_credentials→ introspect against Kanidm and ZITADEL- with-opaque-tokens. Both backends require operator-side bootstrap before a Machine User / OAuth2 RS exists to mint against — Kanidm needsidm_adminpassword recovery + interactive login + thebootstrap.shrendered by Step 4; ZITADEL needs Terraform-or-Console Machine User provisioning. Same operator-bootstrap gap !268’s original ZITADEL test has. Step 5 ships the structural assertion (kanidm_status_and_token_endpoint_reachable):/statushealthy, token endpoint dispatches the grant (notunsupported_grant_typelike Dex),/oauth2/token/introspectendpoint exists. Future MR can add a sidecar init container that runsbootstrap.shautomatically after compose-up, unlocking the full mint test for both backends.Partially resolved 2026-05-15 (closes #411). Two new xtask commands wrap each backend’s first-boot operator workflow into a non-interactive provisioning step:
-
cargo xtask dev kanidm-bootstrap— runskanidmd recover-account idm_adminagainst the kanidm server container, parses the rotated password from stdout, then logs in + provisions the 9 CRAIG OAuth2 resource servers in akanidm/tools:1.10.1container. Captures the basic secrets todevstack/kanidm-secrets.env. Idempotent — re-runs skip already-existing RSes. -
cargo xtask dev zitadel-bootstrap— reads the firstinstance Machine User PAT (provisioned viaZITADEL_FIRSTINSTANCE_ORG_MACHINE_*
ZITADEL_FIRSTINSTANCE_PATPATHenv vars at compose-up) from a bind- mounted host dir, then uses it to provision acraig-casesMachine User in opaque-token mode + acraigProject + acraig-cases- introspecterAPI application. Writes secrets todevstack/zitadel-secrets.env. Idempotent.
-
Integration tests:
-
kanidm_full_introspection_round_trip(NEW) — full mint → JWE shape assertion → introspect →active=true+ RFC 7662 claims subset (sub/client_id/aud/token_type/exp). Asserts the complete Plan F invariant for Kanidm. -
zitadel_opaque_mode_client_credentials_mint(NEW) — mints a client_credentials token + asserts JWE shape (proves opaque-mode round-trip is reachable from a coldmultibackend-up). Introspection-returns-active=trueis not yet asserted — ZITADEL’s introspect-by-API-app model requires additional Project-level role
user-grant chain configuration; with the current bootstrap the API app authenticates against the introspection endpoint but the token’s audience is not yet routed, so introspect returnsactive=false. Tracked as a sub-issue follow-up to fully close E-02.
Plus a small docker-compose change (zitadel service gains
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_* + ZITADEL_FIRSTINSTANCE_PATPATH
env vars + a bind-mount on ./devstack/zitadel-bootstrap) and a kanidm
renderer fix (group create --description → group create # comment
to match Kanidm 1.10.1 CLI syntax — drops the inline --description
flag that was rejected as unexpected argument).
+
Resolved 2026-08-12 (closes #415). The 2026-05-15 hypothesis
("additional Project-level role + user-grant chain configuration")
turned out WRONG — no provisioning step was missing at all. Settled
live against ZITADEL v4.15.0: introspection by the API app answers
active=true exactly when the token’s aud contains the app’s
project, and that routing is requested at mint via the
urn:zitadel:iam:org:project:id:<projectId>:aud scope. The pre-#415
test minted with bare scope=openid, so the audience was never
routed; no user grant, projectRoleAssertion, or JWT access-token
type is involved. As-built: cargo xtask dev zitadel-bootstrap now
writes CRAIG_ZITADEL_PROJECT_ID + CRAIG_ZITADEL_MINT_SCOPE into
devstack/zitadel-secrets.env, and
zitadel_full_introspection_round_trip (renamed from
zitadel_opaque_mode_client_credentials_mint) mints with that scope,
keeps the NEGATIVE control in-test (a bare-openid mint must
introspect active=false),
then asserts the full round-trip — active=true + the RFC 7662
canonical claims subset (sub / client_id / aud-contains-project
/ token_type / exp) — the complete Plan F invariant for ZITADEL,
mirroring kanidm_full_introspection_round_trip. E-02 is fully
closed for both backends.
References
-
RFC 7662 — OAuth 2.0 Token Introspection
-
RFC 9068 — JWT Profile for OAuth 2.0 Access Tokens (CRAIG’s current model)
-
RFC 9449 — DPoP (forward-compat consideration; out of scope)
-
RFC 8705 — mTLS for OAuth (forward-compat consideration; out of scope)
-
ADR-021 — JWT Validation Policy (audience / type / expiry parity rules that Step 2 inherits)
-
ADR-026 — IdP-Neutral Identity
-
ADR-028 — Service Identity + On-Behalf-Of
-
ADR-029 — Introspection-Mode Token Validation (filed in Step 1)
-
Multi-Backend Identity Integration Tests — predecessor plan; Dex strip (!267) + ZITADEL JWT-mode (!268)
-
Plan E — Service Identity + On-Behalf-Of — JWS-only baseline this plan extends