IdP Integration
On this page
- Supported providers
- Configuration
- Standing up a production realm (checklist)
- Keycloak without token exchange (the off-ramp)
- Token exchange (RFC 8693) — the OIDC program’s user-context path
- The applicant portal’s narrow service account (OIDC P1 #1440 + P2 #1441)
- Switching to a non-Keycloak provider
- Troubleshooting
- Implementation references
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 |
|---|---|---|
Default |
Bundled with the dev |
|
Confirmed via test fixtures |
Discovery-doc shape covered by |
|
Confirmed via test fixtures |
Discovery-doc shape covered by |
|
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 ( |
|
Other compliant providers |
Should work |
Any IdP that publishes |
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 |
|---|---|---|
|
|
Public OIDC issuer URL that JWT |
|
|
Optional. Internal URL for reaching the IdP’s discovery + JWKS endpoints (e.g. inside a docker network). Defaults to |
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 |
|---|---|---|
|
|
Public OIDC client ID. For Keycloak: |
|
|
Browser-visible OIDC issuer URL. Used for the login redirect. The |
|
|
Server-side OIDC issuer URL. Used for token-exchange POSTs from canopy-web’s BFF. Often differs from |
Legacy keycloak_client_id / keycloak_external_url / keycloak_internal_url field names are accepted via #[serde(alias)] for one MR cycle.
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).
-
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.
-
Realm roles: worker tiers
caseworker/eligibility_specialist/supervisor/admin, the specialist rolesfti_auditor/data_steward/auditor/analyst/studio_admin, the citizen roleapplicant, and oneservice:canopy-<name>role per service account (the ADR-019 service-class marker). -
Worker clients:
canopy-ui(public, Authorization Code + PKCE S256) andcanopy-api, each carrying (a) an audience mapper forcanopy(the broad worker audience), (b) a requester-audience mapper namingcanopy-web-exchanger(V2 requires the subject token to name its exchanger inaud), and (c) the ADR-044primary_programsuser-attribute mapper — a worker token without a recognized non-emptyprimary_programsclaim is refused at admission, with no canopy-side override. -
Service accounts: one confidential
client_credentialsclient per service, holding itsservice: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). -
Exchanger clients:
canopy-web-exchanger(lifespan 300s) andcanopy-eligibility-exchanger(240s — the hop-2 margin), each with the PER-CLIENTstandard.token.exchange.enabled=truetoggle, noservice:*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-onlycanopy-conformance-exchanger. -
Client scopes: the per-target
aud-canopy-<target>audience scopes (optional, on the exchangers + the portal client) and the twelveportal:*operation scopes (mapperless,include.in.token.scope = true, portal-only). Keep the realm’s builtin default scopes EXPLICIT once any top-levelclientScopessection exists. -
Retired — do not provision: the ADR-019
X-Canopy-Actormachinery (theweb-actorkeypair 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-Actorchannel 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 clients —
canopy-web-exchanger(canopy-web’s BFF,access.token.lifespan=300) andcanopy-eligibility-exchanger(the orchestrator fan-out,access.token.lifespan=240— the hop-2 chaining margin, #1563 below). Each carriesstandard.token.exchange.enabled=true(exchanged tokens are ≤ 300s per ADR-043) and holds noservice:*role — it mints on behalf of a human. -
Per-target audience scopes (
aud-canopy-<target>) as OPTIONAL client scopes on the exchangers, each anoidc-audience-mapperwithincluded.client.audience=canopy-<target>. The exchange request passesscope=aud-canopy-<target>so the exchanged token’saudis EXACTLY the one target (the receiver contract’s exact-audience rule; the F3 broker rejects anything broader). The broadcanopy/canopy-internal-serviceaudiences are deliberately not emittable by any exchanger scope — requesting them is denied at the IdP. -
Requester-audience mappers on the worker clients —
canopy-api(andcanopy-ui) carry anoidc-audience-mapperwithincluded.client.audience=canopy-web-exchanger, because V2 requires the subject token to name the requesting exchanger inaud. -
canopy-conformance-exchangeris 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 inAUTHORIZED_EXCHANGER_AZPS. An operator replicating the realm shape provisions ONLY the two real exchangers above. -
Chained exchange (hop-2) works: an exchanged token whose
audincludescanopy-eligibility-exchangercan 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 twoaudienceparams (canopy-eligibility+canopy-eligibility-exchanger) alongside bothaud-*scopes — a singleaudienceparam 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 viaReceiverContract::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’sexp ≤ subject.expbound 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 mintscanopy-eligibility-exchangertokens at 240s vs the 300s hop-1 lifespan (60s margin — the real gap is bounded by the web dispatch window, seconds);cargo xtask identity renderemits 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
clientScopessection suppresses Keycloak’s auto-creation of the builtin default scopes, soroles/basic/profile/email/web-origins/acrare defined explicitly and named indefaultDefaultClientScopes; without this,realm_access.rolesvanishes from every token fleet-wide. Exchanger clients declaredefaultClientScopesexplicitly (they don’t inherit the realm defaults once they declareoptionalClientScopes) — and their list is the MINIMAL["roles", "basic", "web-origins", "acr"], deliberately withoutprofile/email(#1424): those two land in the exchanged token’sscopeclaim, 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 withscope_exceeds_requested— trim the client, don’t widen the request. -
Every exchange audits (#1424, OIDC A1): the broker’s production
ChainAuditSinkposts each outcome — grants fail-closed (audit-commit-before-release), denials best-effort — as anauth.token_exchangeevent to canopy-security’s ingest under the exchanging service’s own ADR-019 identity. The exchanging services therefore needCANOPY_<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 withinclude.in.token.scope = true, so the granted names land in thescopeclaim). Receivers enforce them per route family — and refuse the portal credential outright on every route outside its classified surface (403portal_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-portalclient carries NOcanopy-internal-serviceaudience mapper (the CONTRACT shape). A scope-less mint by this client yields a token no receiver accepts. -
Short exp:
access.token.lifespan = 600on 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-servicevalidation 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 switch → drain (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.
-
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. -
Set
OIDC_ISSUERin your shell env to point at the IdP’s issuer URL (e.g.https://example.okta.com/oauth2/default). -
Update
config/canopy-web/default.yaml(or override via env) sooidc_external_issuer,oidc_internal_issuer, andoidc_client_idmatch. -
cargo xtask dev start— every service fetches.well-known/openid-configurationat boot. If the IdP is unreachable services fail-fast at startup.
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" |
|
Worker login redirect lands at a 404 |
|
Tokens validate locally but upstream services 401 |
Either the JWT |
Logout redirects to |
|
A long-running service suddenly gets 401s on outbound calls after the IdP rotated + deleted a signing key |
Its cached |
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.rs—OidcDiscovery::fetch+ per-issuer-URL cache (300s default TTL; honoursCache-Control: max-age). -
crates/canopy-auth/src/jwks.rs::JwksProvider::from_discovery— JWKS provider constructor that usesdiscovery.jwks_uridirectly. -
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).