ADR-023: OIDC Validation at Service Boundaries, Token Exchange, and Citizen-Upload Isolation

On this page

Status

Accepted

Amends

ADRs are immutable once accepted, so this ADR amends ADR-019 rather than editing it. Read both together: ADR-019 defines how canopy services authenticate as themselves via service-class tokens with X-Canopy-Actor for audit; ADR-023 amends that model by requiring program services to validate the actor’s identity directly (not delegate trust to the calling BFF) once citizen ingress paths exist, and by isolating citizen-upload processing under narrowly scoped credentials.

The bulk of ADR-019 stands. Service-class identity for background and scheduled work (outbox drainers, scheduled batch jobs, system-initiated events without user context) is unchanged. What changes is requests with user context — any request chain whose origin is a user action — must carry an OIDC token validated by the receiving service, not a service-class token plus an unverified actor header.

Context

ADR-019 chose service-class tokens for canopy-web → program-service calls, with the worker’s identity passed in X-Canopy-Actor for audit. That choice solved three real problems: per-endpoint role gates piling up at internal boundaries (#429), worker-token-lifetime bounding long-running flows, and missing caller-service identity in audit logs. The rejected alternatives (forwarding worker JWTs end-to-end, self-signed canopy-signed tokens, a canopy-identity proxy service) each had their own architectural defects.

ADR-019 was correct under one implicit assumption: program services are only reachable by trusted internal callers. canopy-web is the worker portal, canopy-eligibility is the orchestrator — both are operated by the jurisdiction. The trust boundary was "front door at canopy-web; everything behind is operator code calling operator code."

What changes that assumption

Two ingress paths break the assumption:

  • Citizen document uploads. Canopy is legally required (7 CFR 273.2(c), 42 CFR 435.907, 45 CFR 260, equivalent state statutes) to accept supporting documents from program applicants — pay stubs, lease agreements, immigration documentation, medical records, school enrollment. The applicant portal (ADR-008, canopy-portal, currently at "session middleware + i18n stub" per .claude/CLAUDE.md) is the path. Any vulnerability in the upload-processing pipeline (malicious file content, parser exploitation, SSRF against an EXIF lookup, path traversal in a temp-file write) executes under the BFF’s service-class token. The token authorizes — at minimum — reads against canopy-tanf::fti_audit_log (Pub 1075 §4 data), writes against canopy-applications (application records), reads against canopy-persons (SSN, addresses, dates of birth), and reads against canopy-eligibility (orchestrator). That’s the literal blast radius of an upload-handler exploit.

  • Cross-jurisdiction federation (deferred but on the roadmap). Stage 4 #494 ships IdP federation; multi-jurisdiction deployments will eventually expose program services to traffic originating from less-trusted partner jurisdictions. Same problem shape: a service-class token from a partner BFF authorizes our backend, but the receiving service has no signal about whether the request chain came from a worker action or partner-side citizen ingress.

The current model has no mechanism to distinguish "request originated from a caseworker action" from "request originated from citizen-submitted content" or "request originated from a deferred outbox publish triggered by citizen content." X-Canopy-Actor carries the worker identity for audit, but workers can be impersonated by anything inside the service-class trust boundary — and there is no "actor: untrusted_citizen_content" affordance. This is a confused-deputy problem: the deputy (canopy-web or canopy-portal) holds broad authority, but does not — and cannot — communicate to downstream services that it is currently acting on behalf of untrusted content rather than a worker.

Threat model

Adversary classes this ADR addresses:

  • Class A (preemptive — primary motivation): A future citizen upload pipeline executes parser/handler code against attacker-controlled bytes. A successful exploit (RCE, SSRF, deserialization, path traversal) inherits the BFF’s service-class token. Today: hypothetical. Post-ADR-008 implementation: real.

  • Class B: A compromised partner service in a federated deployment uses its own service-class token to call our backends. Today: not yet possible (single-jurisdiction). Post-federation: real.

  • Class C: Insider with elevated BFF cookies. Service-class delegation means any BFF compromise is full-service compromise. Mitigated by limiting service-class scope to background work only.

Adversary classes not addressed (out of scope for this ADR):

  • IdP-side compromise (Keycloak supply-chain, leaked admin credentials). Defended by the deployer’s operational practices.

  • Side channels (timing, log exfiltration). Defended by general hardening, not this ADR.

Preemptive timing

This is not a response to a known live exploit. canopy-portal has no domain routes today; the citizen-upload pipeline does not yet exist. The right time to harden is before the door opens, not after. Implementing OIDC-at-services + RFC 8693 exchange + citizen-upload isolation incrementally over the next quarter aligns the security posture with the post-UAT (September 2026 target) reality where citizen ingress is a real feature.

Counterarguments to OIDC-at-services (and why they don’t hold)

ADR-019 chose service-class tokens partly to avoid per-service OIDC validation cost. That choice was correct given the threat model at the time, but the counterarguments do not survive the citizen-ingress threat:

  • "JWT validation latency." Not material. JWT verify is a local cryptographic operation (RSA / ECDSA verify) against a JWKS that’s cached for the discovery refresh interval. Sub-millisecond at canopy’s load (thousands of caseworkers, not millions of API calls/sec). canopy-auth’s JwksProvider already does this in canopy-web’s middleware; extending the same crate to program services is incremental code, not a new performance class.

  • "Duplicated OIDC config per service." The original concern was every service growing operator-IdP-specific config. A shared canopy-auth Axum middleware crate (already exists in canopy-web) means the config is one import + one env-var set per service. The marginal cost per service is small.

  • "Adding hard dependency on Keycloak at every service." Keycloak is already a hard dependency at login time (canopy-web validates worker tokens). Program services becoming dependent on the same IdP is not a new dependency class — just broader application of the existing one.

  • "Role/claims schema not yet specified." It is. ADR-019 §"Required token shape" + canopy-auth’s Claims struct define the schema. Program-service middleware reuses both.

IdP portability (load-bearing requirement)

Canopy supports any RFC 6749 + OIDC-discovery compliant IdP (Keycloak by default in dev; Authentik, Kanidm, Zitadel, Okta, Entra ID, ForgeRock, custom in production per ADR-019). The OIDC-at-services migration must not bake in Keycloak-specific behavior beyond deployment configuration. Concretely:

  • Discovery-based. Middleware accepts CANOPY_IDENTITY_ISSUER (the OIDC issuer URL) and resolves JWKS, token endpoint, and revocation endpoint via the standard .well-known/openid-configuration document. No hardcoded /auth/realms/{realm}/ Keycloak paths.

  • Normalized internal claims struct. A canopy_auth::NormalizedClaims struct represents the claim set canopy services act on (sub, roles: Vec<String>, azp, aud, iss, exp, iat, optional act for on-behalf-of, optional scope for token-exchange-derived scopes). IdP-specific claim layouts (Keycloak’s realm_access.roles, Authentik’s groups, Kanidm’s claim_authgroups) are mapped to this struct at the boundary in a thin per-IdP adapter. canopy-auth already does this for Keycloak; the adapter pattern lets a new IdP plug in with one new adapter, no changes to any service’s business logic.

  • RFC 8693 token exchange configured via deployment. The exchange endpoint is whatever the OIDC issuer’s discovery document declares (token_endpoint). The exchange request is RFC 8693 form-encoded (grant_type=urn:ietf:params:oauth:grant-type:token-exchange, subject_token, requested_token_type, optional audience, optional scope). Keycloak requires the token-exchange feature flag enabled at the realm level (a deployment quirk, documented in deploy notes — not application logic). Other IdPs have their own quirks; the canopy-side code is RFC-compliant regardless.

  • Citizen upload isolation applies regardless of IdP. Scoped credentials for upload processing are RFC 8693 exchange products. Any compliant IdP that supports token exchange can produce them. IdPs without token exchange require operator workaround (e.g., dedicated upload-pipeline service account at the IdP); flagged in deploy notes per IdP.

Decision

Three concurrent decisions, applied incrementally across services per the remediation plan (Plan: OIDC Validation at Service Boundaries + Citizen-Upload Isolation):

Decision 1 — OIDC validation at every program service

Every program service (canopy-rules, canopy-persons, canopy-applications, canopy-eligibility, canopy-verification, canopy-enrollment, canopy-renewals, canopy-notices, canopy-exchange, canopy-appeals, canopy-reporting, canopy-security, canopy-snap, canopy-tanf, canopy-medicaid, canopy-caps, canopy-wic) gains OIDC token validation middleware (shared via canopy-auth, discovery-based, normalized claims) on all routes that handle user-scoped data.

The middleware rejects requests with no valid token (401) or with a token whose aud does not include the service’s identity (403). Existing role gates (require_caseworker_or_above, etc.) continue to work but now run against the user’s validated claims, not a service-class token. The require_service_or_caseworker_or_above transitional guard (introduced for ADR-019 cutover) is removed once migration completes per the remediation plan.

Decision 2 — RFC 8693 token exchange for user-context requests

canopy-web (and canopy-portal post-implementation) exchange the user’s bearer JWT for a downstream token via RFC 8693 before fanning out to program services. The exchanged token carries:

  • sub of the original user (preserved through act or equivalent on-behalf-of claim)

  • aud narrowed to the target service (one exchange per audience; ~30 exchanges per worker action is acceptable given local-crypto validation cost)

  • scope narrowed to what the calling BFF requires for this specific request

  • exp shorter than the original (default: 5 minutes; configurable per deployment)

The exchange happens once per BFF request and is cached for the request’s lifetime (so an orchestrator dispatch reuses one exchanged token across its fan-out instead of N exchanges).

X-Canopy-Actor retains its audit role for legacy / migration purposes during cutover but is dropped from the receiving-side trust path once Decision 1 is complete at every service.

Decision 3 — Citizen upload isolation under scoped credentials

Citizen-content processing (file parsing, virus scanning, content-type sniffing, EXIF/metadata extraction, OCR pipelines, anything that executes against bytes that traverse a citizen-controlled boundary) runs under a credential scoped along four dimensions:

  • aud narrowed to only the services upload processing legitimately needs (typically: canopy-applications for attachment metadata persistence + canopy-notices for downstream notice triggers; explicitly not canopy-tanf, canopy-medicaid, FTI-touching services, or canopy-eligibility orchestrator).

  • scope narrowed to operation-level OAuth scopes (e.g., attachment:write but not application:read).

  • exp short (default: 60 seconds; the upload-processing job either completes or fails closed within that window).

  • Optional cnf (RFC 8705 / 8471 confirmation) binding the token to the upload-processing job’s container identity, if the deployer’s infrastructure supports it. Defense-in-depth.

The scoped credential is produced via RFC 8693 token exchange at the moment the citizen-content boundary is crossed (the upload arrives at canopy-portal’s intake handler). It is not derived from a worker session — citizen upload processing has no worker actor.

Decision 4 — Service-class credential scope narrowing

The current broad service-class credential (per ADR-019) is retained only for:

  • Background and scheduled work without user context (outbox drainer, ABAWD month-counter cron, scheduled batch reports, system-initiated events).

  • Internal service-bootstrap concerns (canopy services reading their own config or shared crates initializing).

It is removed from the user-context request path entirely once Decision 1 + Decision 2 are complete at all services. Per-service migration sequence in the remediation plan.

Decision 5 — Token rotation and revocation

The new model introduces revocation as a meaningful primitive (a leaked exchanged token has narrower scope, but still needs a response):

  • JWKS rotation continues per existing canopy-auth practice (cached, refreshed on kid miss, 5-minute discovery refresh interval).

  • Per-token revocation uses the OIDC issuer’s revocation endpoint (RFC 7009) when leaks are detected. Canopy ships a cargo xtask identity revoke <jti> helper that calls the revocation endpoint via discovery.

  • Service-class credential rotation (the narrowed credentials from Decision 4) follows existing operational rotation cadence (per deployer policy); the narrower scope means the blast radius of a leak is bounded but rotation is still required.

Decision 6 — Audit chain for token exchange

Every RFC 8693 token exchange — especially exchanges producing citizen-upload-scoped credentials — emits an auth.token_exchange audit event into the canopy hash-chain (ADR-014). Event payload includes original sub, target aud, granted scope, exchange purpose code (worker_request | citizen_upload | background_job), and exp. This makes the credential-derivation step itself auditable for Pub 1075 §9 compliance and HIPAA access-tracking requirements.

Consequences

Positive

  • Confused-deputy class A defended. Citizen-upload exploits inherit only the narrow scoped credential; FTI services, eligibility orchestrator, and program services not in aud are unreachable.

  • Per-service auth is uniform. Every program service runs canopy-auth’s shared middleware; no per-service auth divergence.

  • Audit gains actor-with-context. actor_user reflects the validated sub; target_aud and scope are also auditable. The "worker invoked X directly vs. orchestrator fanned out to X" distinction becomes machine-readable via the act chain.

  • Compliance posture improves. Pub 1075 §9.4 (audit completeness), HIPAA 45 CFR §164.312(b) (audit controls), and IRS Pub 4812 §3.5 (access logging) gain new evidence: per-credential-derivation audit events, narrower service-class scope, and clean separation of citizen-content actors from worker actors.

  • Migration is incremental. Each program service can adopt the canopy-auth middleware independently; require_service_or_caseworker_or_above transitional guard accepts both old and new tokens during cutover.

  • IdP portability preserved. Discovery-based config + normalized claims + adapter pattern means a future Authentik / Kanidm / Zitadel deployment requires only an adapter, not a rewrite.

Negative

  • Cost of migration is real. ~17 program services × OIDC middleware integration × test coverage. The remediation plan estimates effort honestly (months, not weeks).

  • Token exchange adds operational complexity. Deployers must enable token-exchange in their IdP (Keycloak: realm feature flag; other IdPs: per-IdP setup). Documented in deploy notes per IdP.

  • Citizen-upload pipeline must be designed against this constraint from day one. ADR-008’s canopy-portal implementation cannot punt the auth boundary to "we’ll figure it out."

  • Per-call exchange cost. RFC 8693 exchange is one HTTP roundtrip per (BFF-request, target-service-audience) pair. Mitigated by per-request caching; not free.

  • Existing audit logs lose continuity at cutover. Pre-migration logs say actor=worker:X with no validation guarantee at the receiving service; post-migration logs say actor=worker:X with cryptographic validation. The remediation plan documents the cutover window in audit-log narrative for Pub 1075 evidence.

Mitigations

  • Phased rollout. Migration plan sequences services by ADR-004 sensitivity (FTI-touching services first: canopy-tanf, canopy-medicaid, canopy-security). canopy-rules and other low-sensitivity services can adopt last.

  • Conformance test. cargo xtask identity verify (per ADR-019) extended to verify the receiving-side middleware accepts exchanged tokens AND rejects unscoped service-class tokens on user-context routes.

  • Backward-compat during cutover. Transitional require_service_or_caseworker_or_above (existing ADR-019 helper) continues to work; services migrate one at a time with no downtime.

  • Off-ramps for non-Axum services. Canopy is fully Axum today, but if a future service uses a different HTTP layer or is third-party, the remediation plan documents fallbacks (network segmentation, proxy wrapping).

Out of scope

  • CSRF token rotation on login (separately filed; not load-bearing for this ADR).

  • Mutual TLS at service boundaries. Defense-in-depth that’s worth doing, but orthogonal — token-based auth is the load-bearing primitive; mTLS layered on top is a future decision.

  • IdP supply-chain compromise. Defender-side; outside canopy’s auth model.

  • Side-channel attacks (timing, log exfiltration). Hardened separately.

References

Edit this page · default