IdP Integration

On this page

Canopy authenticates worker portal sessions and inter-service API calls against an OIDC identity provider. Per #422 / oidc-pluggability-refactor, the codebase is provider-neutral: endpoint URLs come from the IdP’s .well-known/openid-configuration discovery document at startup, not from hardcoded path conventions.

This page documents which providers are supported, what configuration each requires, and how to swap the default Keycloak instance for a different provider.

Supported providers

Provider Status Notes

Keycloak

Default

Bundled with the dev devstack/. Realm imported from devstack/keycloak/canopy-realm.json.

Okta

Confirmed via test fixtures

Discovery-doc shape covered by crates/canopy-auth/src/discovery.rs::tests::parses_okta_shape_with_extra_fields. End-session endpoint omitted on certain Okta plans — Canopy falls back to its local /login page for the post-logout redirect.

Auth0

Confirmed via test fixtures

Discovery-doc shape covered by crates/canopy-auth/src/discovery.rs::tests::parses_auth0_shape. Refresh-token rotation is on by default (#411 will exercise this).

Azure AD / Entra ID

Untested

Should work — Microsoft’s discovery doc follows OIDC 1.0. File a bug if a tenant trial surfaces real incompatibility. Tenant-aware issuer URLs (https://login.microsoftonline.com/{tenant}/v2.0) work as drop-in oidc_issuer values.

Other compliant providers

Should work

Any IdP that publishes .well-known/openid-configuration and supports the OAuth 2.0 authorization-code + PKCE flow with aud=canopy audience claims should work without code changes.

Configuration

Three categories of settings cover IdP integration. They follow the layered YAML / env-var pattern from ADR-012.

Service-side issuer (every program service + orchestrator + persons + …)

Read by the shared canopy-common::settings::ServiceSettings struct.

Field Env var Description

oidc_issuer

CANOPY_<SERVICE>__OIDC_ISSUER

Public OIDC issuer URL that JWT iss claims must match. Used as the canonical identity for token validation.

oidc_internal_url

CANOPY_<SERVICE>__OIDC_INTERNAL_URL

Optional. Internal URL for reaching the IdP’s discovery + JWKS endpoints (e.g. inside a docker network). Defaults to oidc_issuer when not set. Public-issuer tokens can be validated against keys fetched from the internal URL because the JWKS itself doesn’t change with the URL the request comes from.

The legacy keycloak_issuer / keycloak_url field names are accepted via #[serde(alias)] for one MR cycle (drops in #411 / bff-token-refresh) so docker-compose and .env files can migrate independently of Rust struct rename.

Worker portal — canopy-web

Read by services/canopy-web/src/config.rs::WebConfig.

Field YAML key Description

oidc_client_id

oidc_client_id

Public OIDC client ID. For Keycloak: canopy-ui (from the bundled realm). For Okta: the application’s Client ID. For Auth0: the application’s Client ID.

oidc_external_issuer

oidc_external_issuer

Browser-visible OIDC issuer URL. Used for the login redirect. The authorization_endpoint reachable from this discovery doc must be reachable from the user’s browser.

oidc_internal_issuer

oidc_internal_issuer

Server-side OIDC issuer URL. Used for token-exchange POSTs from canopy-web’s BFF. Often differs from oidc_external_issuer in docker deployments where the docker-internal hostname (e.g. http://keycloak:8080/realms/canopy) reaches the IdP without going through the host network.

Legacy keycloak_client_id / keycloak_external_url / keycloak_internal_url field names are accepted via #[serde(alias)] for one MR cycle.

docker-compose

Per-service env vars use CANOPY_<SERVICE>OIDC_ISSUER and CANOPY_<SERVICE>OIDC_INTERNAL_URL. The top-level docker-compose substitution variable is OIDC_ISSUER (defaults to the bundled Keycloak realm URL).

Standing up a production realm (checklist)

Everything canopy expects from the IdP, in one place — the sections below carry the detail; devstack/keycloak/canopy-realm.json is the executable reference shape and cargo xtask identity render --backend keycloak emits the importable fragments (verify a live issuer with cargo xtask identity verify --issuer <url> --check-exchange --check-portal).

  1. Provider floor: any RFC 6749 + OIDC-discovery IdP covers worker login and ADR-019 service identity — but the user-context path REQUIRES RFC 8693 standard token exchange. On Keycloak that means 26.2+ (V2 exchange GA; hop-2 chaining + the chained-lifespan rule verified live on 26.5). No exchange-capable IdP → see the off-ramp below, and read its constraints carefully.

  2. Realm roles: worker tiers caseworker / eligibility_specialist / supervisor / admin, the specialist roles fti_auditor / data_steward / auditor / analyst / studio_admin, the citizen role applicant, and one service:canopy-<name> role per service account (the ADR-019 service-class marker).

  3. Worker clients: canopy-ui (public, Authorization Code + PKCE S256) and canopy-api, each carrying (a) an audience mapper for canopy (the broad worker audience), (b) a requester-audience mapper naming canopy-web-exchanger (V2 requires the subject token to name its exchanger in aud), and (c) the ADR-044 primary_programs user-attribute mapper — a worker token without a recognized non-empty primary_programs claim is refused at admission, with no canopy-side override.

  4. Service accounts: one confidential client_credentials client per service, holding its service:canopy-<name> realm role and the fleet’s service audience mappers. The portal is the exception: a DEDICATED NARROW account with per-target audience scopes and NO broad audience mapper (see the portal section below, including the staged production rotation sequence).

  5. Exchanger clients: canopy-web-exchanger (lifespan 300s) and canopy-eligibility-exchanger (240s — the hop-2 margin), each with the PER-CLIENT standard.token.exchange.enabled=true toggle, no service:* role, and the MINIMAL default scope list. Exchanged tokens are ≤300s by policy: the realm lifespans set it, and the F3 broker independently refuses anything longer (belt and braces). Do NOT provision the devstack-only canopy-conformance-exchanger.

  6. Client scopes: the per-target aud-canopy-<target> audience scopes (optional, on the exchangers + the portal client) and the twelve portal:* operation scopes (mapperless, include.in.token.scope = true, portal-only). Keep the realm’s builtin default scopes EXPLICIT once any top-level clientScopes section exists.

  7. Retired — do not provision: the ADR-019 X-Canopy-Actor machinery (the web-actor keypair and its verifying-key distribution) is gone as of #1443 — the middleware rejects any request carrying the header, and worker identity rides exchanged bearers only.

Keycloak without token exchange (the off-ramp)

For an IdP (or Keycloak build) without RFC 8693 standard exchange, the CREDENTIAL-NARROWING half of the program generalizes from the portal pattern (P1, #1440): give every calling service dedicated per-target client_credentials accounts, each minting with one aud-canopy-<target> scope, so a stolen token works at exactly one service — receivers keep their exact-audience contracts unchanged.

What the off-ramp CANNOT replicate is the USER-CONTEXT path, and since the C1 drain (#1443) that constraint is hard, not advisory:

  • Worker-attributed writes fail closed without a configured exchanger (exchanger_not_configured — there is no service-token fallback), so the worker review surfaces (document accept/reject/scan-override), the user-only reporting surfaces, and every S-slice user-context arm are inoperable.

  • There is no header-based substitute: the transitional X-Canopy-Actor channel is retired and rejected outright.

An exchange-less deployment therefore runs the SERVICE half of canopy only (intake, pipelines, system flows); interactive worker casework requires an exchange-capable issuer. Treat the off-ramp as a bridge posture, not a destination.

Token exchange (RFC 8693) — the OIDC program’s user-context path

The OIDC boundary-auth program (epic &52) mints per-target user-context tokens via RFC 8693 standard token exchange (Keycloak 26.2+ V2, GA). R1 (#1423) wires the devstack realm; the shape any IdP must replicate:

  • Dedicated confidential exchanger clientscanopy-web-exchanger (canopy-web’s BFF, access.token.lifespan=300) and canopy-eligibility-exchanger (the orchestrator fan-out, access.token.lifespan=240 — the hop-2 chaining margin, #1563 below). Each carries standard.token.exchange.enabled=true (exchanged tokens are ≤ 300s per ADR-043) and holds no service:* role — it mints on behalf of a human.

  • Per-target audience scopes (aud-canopy-<target>) as OPTIONAL client scopes on the exchangers, each an oidc-audience-mapper with included.client.audience=canopy-<target>. The exchange request passes scope=aud-canopy-<target> so the exchanged token’s aud is EXACTLY the one target (the receiver contract’s exact-audience rule; the F3 broker rejects anything broader). The broad canopy / canopy-internal-service audiences are deliberately not emittable by any exchanger scope — requesting them is denied at the IdP.

  • Requester-audience mappers on the worker clientscanopy-api (and canopy-ui) carry an oidc-audience-mapper with included.client.audience=canopy-web-exchanger, because V2 requires the subject token to name the requesting exchanger in aud.

  • canopy-conformance-exchanger is devstack-only — do NOT provision it in production. It exists solely so the F4 conformance matrix (#1425) can mint adversarial shapes (rogue-azp, multi-audience) and prove receivers reject them; no service allowlists it in AUTHORIZED_EXCHANGER_AZPS. An operator replicating the realm shape provisions ONLY the two real exchangers above.

  • Chained exchange (hop-2) works: an exchanged token whose aud includes canopy-eligibility-exchanger can itself be the subject of a second exchange (the orchestrator fan-out). Proven in the devstack on R1 — see the #1423 hop-2 note. Wire mechanics (#1430, verified live on KC 26.5): the hop-1 mint must send two audience params (canopy-eligibility + canopy-eligibility-exchanger) alongside both aud-* scopes — a single audience param down-filters the exchanger entry away and the resulting token cannot hop; omitting the param yields the pair too, but the explicit two-param form is what the broker sends (deterministic against future granted-scope drift). Receivers accept the pair only via ReceiverContract::with_hop2_exchanger (canopy-eligibility only). Chained-lifespan requirement (#1563, verified live on KC 26.5): KC V2 mints the chained output with a FRESH client lifespan, never capped to the subject’s remaining life — so the F3 broker’s exp ≤ subject.exp bound trips (lifetime_exceeds_cap) the moment the chain takes >0s at equal lifespans. Sizing rule: hop2_lifespan ≤ hop1_lifespan − worst-case hop1-mint→hop2-exchange gap. The devstack realm mints canopy-eligibility-exchanger tokens at 240s vs the 300s hop-1 lifespan (60s margin — the real gap is bounded by the web dispatch window, seconds); cargo xtask identity render emits the same 300/240 split, and any production realm replicating the hop-2 topology must preserve the inequality.

  • Realm builtin scopes must stay explicit: adding a top-level clientScopes section suppresses Keycloak’s auto-creation of the builtin default scopes, so roles/basic/profile/email/ web-origins/acr are defined explicitly and named in defaultDefaultClientScopes; without this, realm_access.roles vanishes from every token fleet-wide. Exchanger clients declare defaultClientScopes explicitly (they don’t inherit the realm defaults once they declare optionalClientScopes) — and their list is the MINIMAL ["roles", "basic", "web-origins", "acr"], deliberately without profile/email (#1424): those two land in the exchanged token’s scope claim, which the F3 broker’s granted-scope ⊆ requested rule refuses, and a per-target user-context token has no business carrying name/email claims anyway. An IdP whose exchanger client adds any scope-string-visible default breaks every exchange loudly with scope_exceeds_requested — trim the client, don’t widen the request.

  • Every exchange audits (#1424, OIDC A1): the broker’s production ChainAuditSink posts each outcome — grants fail-closed (audit-commit-before-release), denials best-effort — as an auth.token_exchange event to canopy-security’s ingest under the exchanging service’s own ADR-019 identity. The exchanging services therefore need CANOPY_<SVC>OIDC_EXCHANGER_CLIENT_ID
    OIDC_EXCHANGER_CLIENT_SECRET (half-configured pairs are a boot error) and a reachable canopy-security URL; without the exchanger credentials the exchange path stays inert.

Verify the whole chain against a running issuer with cargo xtask identity verify --issuer <url> --check-exchange — it password-grants a seeded worker, exchanges, and validates the exchanged token’s RS256 signature + the ADR-043 A2 claim shape. The exchanger secret is read from CANOPY_IDENTITY_EXCHANGER_SECRET (non-argv; dev fallback to the deterministic devstack secret).

The applicant portal’s narrow service account (OIDC P1 #1440 + P2 #1441)

The citizen path has no user token to exchange (ADR-026 opaque sessions), so canopy-portal authenticates outbound with a DEDICATED NARROW client_credentials account (ADR-043 A1) instead of the fleet’s broad service audience:

  • Per-target acquisition: the portal holds one token source per backend target, each minting with the aud-canopy-<target> scope — the realm’s optional client scope stamps exactly that audience, so a stolen applications-scoped token is a 401 at every other service.

  • Operation scopes (P2, #1441): each source additionally mints its target’s portal:* operation scopes (12 mapperless optional client scopes with include.in.token.scope = true, so the granted names land in the scope claim). Receivers enforce them per route family — and refuse the portal credential outright on every route outside its classified surface (403 portal_on_non_portal_route), so inside an allowed target the token is good for exactly its classified operations and nothing else.

  • No broad audience: the canopy-portal client carries NO canopy-internal-service audience mapper (the CONTRACT shape). A scope-less mint by this client yields a token no receiver accepts.

  • Short exp: access.token.lifespan = 600 on the client (the ADR-043 A1 short-exp dimension; comfortably above the token source’s 300s refresh lead).

  • Exact self-validation: each source revalidates (ADR-037) against its OWN target audience — the process-wide canopy-internal-service validation pin is gone.

cargo xtask identity render --backend keycloak emits the narrow client shape (including the eight aud-canopy- and twelve portal: client-scope definitions, so the fragment imports self-contained); cargo xtask identity verify --check-portal probes a live mint (exact target aud present, broad aud absent, every requested operation scope granted in the scope claim, service: role, lifespan in (300, 600]s — the floor is the token source’s refresh lead). The probe reads the portal secret non-argv from CANOPY_IDENTITY_PORTAL_SECRET (devstack fallback baked in).

Production rotation (devstack contracts atomically — image rebake deploys realm + code together; a live deployment must stage it because tokens live up to 1800s in caches): expand (add the per-target scopes; keep the broad mapper — both postures valid) → deploy (the per-target portal build) → realm switchdrain (wait out cached broad tokens or push a not-before revocation) → contract (remove the broad mapper). Rollback at every gate = previous realm + previous deploy; the P2 receiver arms accept both rotation shapes throughout (the portal arm pins azp + scope, deliberately NOT exact audience, so the expand phase’s {broad, target} tokens still pass their classified surface). Per-gate criteria are recorded on issue #1440.

Switching to a non-Keycloak provider

The simplest case: bring up devstack with a different IdP issuer.

  1. Provision your IdP with an OIDC client (Authorization Code + PKCE, audience canopy, public client). For Okta this is a Single-Page App; for Auth0 a "Single Page Application" type.

  2. Set OIDC_ISSUER in your shell env to point at the IdP’s issuer URL (e.g. https://example.okta.com/oauth2/default).

  3. Update config/canopy-web/default.yaml (or override via env) so oidc_external_issuer, oidc_internal_issuer, and oidc_client_id match.

  4. cargo xtask dev start — every service fetches .well-known/openid-configuration at boot. If the IdP is unreachable services fail-fast at startup.

NOTE

The bundled devstack/keycloak/canopy-realm.json is the reference for which roles, audience mappers, and client config Canopy expects. When swapping providers, replicate that shape: aud=canopy on access tokens, the realm roles from the checklist above (worker tiers + specialist roles + applicant + the service:* markers), the ADR-044 primary_programs mapper, PKCE-S256 client.

Troubleshooting

Symptom Likely cause

Service fails at startup with "failed to fetch OIDC discovery document"

oidc_internal_url (or oidc_issuer if no internal override) is wrong, or the IdP is not yet ready. Validate via curl ${OIDC_ISSUER}/.well-known/openid-configuration from inside the docker network.

Worker login redirect lands at a 404

oidc_external_issuer doesn’t match the browser-reachable URL. The discovery doc reports endpoints relative to the URL discovery is fetched from; if you fetched discovery via http://localhost:8180 the redirect uses localhost-shaped URLs.

Tokens validate locally but upstream services 401

Either the JWT iss claim doesn’t match oidc_issuer (provider not configured for the right issuer URL), or the aud claim is missing/wrong. Confirm the IdP’s audience mapper is configured for aud=canopy.

Logout redirects to /login instead of the IdP’s logout page

discovery.end_session_endpoint is None. Some Okta plans don’t expose it. This is the documented graceful fallback, not a bug — Canopy clears its own session and returns to /login rather than failing.

A long-running service suddenly gets 401s on outbound calls after the IdP rotated + deleted a signing key

Its cached client_credentials token was signed by the now-deleted key. Services wired with signing-key-aware revalidation (ADR-037) self-heal within twice oidc_service_token_revalidate_max_age_secs (default 60s; the #1212 revalidation-verdict window plus the JWKS age behind it) — no action needed. See the JWKS-stale-recovery runbook’s sender-vs-receiver decision tree.

NOTE
No IdP-contract change is required for ADR-037 — the self-heal keys entirely off the IdP’s published JWKS (standard OIDC key rotation). A provider that publishes a rotated key’s kid for a grace period (Keycloak’s default) simply widens the window in which no re-mint is even needed.

Implementation references

  • crates/canopy-auth/src/discovery.rsOidcDiscovery::fetch + per-issuer-URL cache (300s default TTL; honours Cache-Control: max-age).

  • crates/canopy-auth/src/jwks.rs::JwksProvider::from_discovery — JWKS provider constructor that uses discovery.jwks_uri directly.

  • crates/canopy-api/src/bootstrap.rs — every service’s startup path fetches discovery before binding the auth middleware.

  • services/canopy-web/src/auth.rs::OidcConfig::from_web_config — fetches both external and internal discovery docs at startup.

  • oidc-pluggability-refactor.adoc — the plan that landed this design (#422).

Edit this page · default