Plan: OIDC Pluggability Refactor (Issue #422)
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
New module |
Not started |
2 |
|
Not started |
3 |
Rename |
Not started |
4 |
Rewrite |
Not started |
5 |
Refactor |
Not started |
6 |
Rename |
Not started |
7 |
Update |
Not started |
8 |
Update |
Not started |
9 |
Update |
Not started |
10 |
Tests: keep |
Not started |
11 |
JWKS-rotation interaction with discovery cache. The existing |
Not started |
12 |
Docs sync. New |
Not started |
Issue: #422
Branch: refactor/oidc-pluggability
Labels: type::refactor, priority::high, program::infrastructure, service::shared-crates, service::web, workflow::ready
Context
Pre-existing tech debt: canopy-auth + canopy-web hardcode Keycloak-specific OIDC paths (/protocol/openid-connect/{auth,token,logout,certs}) and use keycloak_* config keys throughout. This bakes Keycloak as the only supported IdP — operators using Okta, Auth0, or Azure AD would have to fork the codebase to retarget endpoint paths.
The standard fix is OIDC .well-known/openid-configuration discovery: every compliant OIDC provider exposes a metadata document at the issuer URL with the actual endpoint URLs inside (RFC 8414). Use that, drop the hardcoded paths, and the codebase becomes provider-neutral.
This refactor is the foundation for the BFF token-refresh fix (#411). Without provider-neutral discovery in place first, the #411 fix would extend Keycloak coupling rather than repair it. Per user direction during Tier A planning (2026-05-05): "we’re already touching the code in that area, do the strategic refactor now."
Code references for the existing coupling
-
crates/canopy-common/src/settings.rs:23,30—ServiceSettings.keycloak_issuer+keycloak_urlfield names. -
crates/canopy-api/src/bootstrap.rs:67-69— three uses of those fields. -
crates/canopy-auth/src/jwks.rs:65— Keycloak-path JWKS fallback (format!("{}/protocol/openid-connect/certs", …)). -
services/canopy-web/src/config.rs:13-15—keycloak_client_id,keycloak_external_url,keycloak_internal_urlconfig keys. -
services/canopy-web/src/auth.rs:110, 150, 277— three hardcoded path strings (/protocol/openid-connect/{auth,token,logout}). -
docker-compose.yml— 17 services × 2 env vars (CANOPY_<SERVICE>KEYCLOAK_ISSUER,CANOPY_<SERVICE>KEYCLOAK_URL) ≈ 34 lines. -
config/canopy-web/default.yaml,config/canopy-applications/default.yaml— YAML config keys. -
.env,.env.local,.env.example,.envrc— env-var references. -
Tests:
crates/canopy-auth/src/jwks.rs:198-243,crates/canopy-auth/src/middleware.rs:91.
Net edit volume: ~25 file edits, mostly mechanical (rename + 1 new helper module).
Scope
In scope:
-
OidcDiscovery::fetchhelper that GETs the standard discovery doc, parses, caches per-URL. -
JwksProvider::from_discoveryasync constructor. -
ServiceSettings.keycloak_*→oidc_*rename across the codebase (with serde aliases for one MR cycle of back-compat). -
Rewrite
services/canopy-web/src/auth.rsto use discovery-driven endpoint URLs (fetched separately for browser-visible vs server-side usage). -
docker-compose, config YAML,
.env*rename toOIDC_*. -
New
idp-integration.adocdocumenting which providers are supported.
Out of scope:
-
Removing the back-compat
#[serde(alias)]and the deprecatedJwksProvider::new— those drop in the dependent MR (#411 BFF token refresh) once both have shipped together. -
Azure AD smoke test — documented as untested. File a follow-up if a tenant becomes available.
-
Service-account / client-credentials flow — separate concern, file when needed.
-
Token refresh handling — that’s the dependent MR (#411).
Dependencies
-
crates/canopy-auth/Cargo.toml—reqwest+serde_jsonalready present (no new deps). -
crates/canopy-common/src/settings.rs— field rename source. -
crates/canopy-api/src/bootstrap.rs— wires discovery + stashesOidcDiscoveryinBootstrapResult. -
services/canopy-web/src/config.rs,services/canopy-web/src/auth.rs— local consumers. -
docker-compose.yml,config/*/default.yaml,.env— devstack glue.
No schema migrations. No new workspace dependencies.
Design
OidcDiscovery shape
// crates/canopy-auth/src/discovery.rs
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields = false)]
pub struct OidcDiscovery {
pub issuer: String,
pub authorization_endpoint: String,
pub token_endpoint: String,
pub jwks_uri: String,
pub end_session_endpoint: Option<String>,
pub userinfo_endpoint: Option<String>,
}
impl OidcDiscovery {
pub async fn fetch(
issuer_url: &str,
http: &reqwest::Client,
) -> Result<Arc<Self>, AuthError> {
// 1. Cache lookup (per-URL, single-flight via mutex map).
// 2. GET {issuer_url}/.well-known/openid-configuration
// 3. Parse Cache-Control: max-age into expires_at; default 300 s.
// 4. Insert into cache, return Arc.
}
pub async fn cached_or_fetch(
issuer_url: &str,
http: &reqwest::Client,
) -> Result<Arc<Self>, AuthError> {
// Returns cached doc if not expired; otherwise calls fetch().
}
}
Internal vs external discovery split
The internal/external URL split is canopy-specific (docker network has a different hostname than the host). Each issuer URL has its OWN discovery doc — Keycloak reports endpoints relative to the URL discovery was fetched from. So canopy-web fetches discovery twice at startup:
-
external.authorization_endpoint(e.g.http://localhost:8180/realms/canopy/protocol/openid-connect/auth) — browser-visible, used for the OIDC login redirect. -
internal.token_endpoint(e.g.http://keycloak:8080/realms/canopy/protocol/openid-connect/token) — server-side, used for the BFF token-exchange POST.
Each cached independently in `OidcDiscovery’s per-URL cache.
JWKS rotation + discovery cache interaction
The existing JwksProvider::start_refresh_task() periodically re-fetches JWKS. With from_discovery, the JWKS URL is sourced from the cached discovery doc. If the IdP rotates AND moves the JWKS URL (rare), the JwksProvider would still hit the old URL.
Resolution: the JwksProvider’s refresh task calls OidcDiscovery::cached_or_fetch on each refresh (cheap because of the 300 s cache), then refreshes JWKS from the current jwks_uri. This way a JWKS-URL change is picked up within one cache TTL.
Files Touched
| File | Change |
|---|---|
|
New module — |
|
Add |
|
Re-export |
|
Rename |
|
Use discovery, stash |
|
Config-key rename + serde aliases. |
|
Replace 5 hardcoded paths with |
|
YAML key rename. |
|
Env-var rename across 17 services (~34 lines). |
|
Env-var rename. |
|
New (~6 tests covering Keycloak/Okta/Auth0 + cache + URL-change). |
|
New page documenting supported providers + config. |
|
|
|
Tech Stack line annotation: "Identity: Keycloak (default; any RFC 6749 + OIDC discovery provider works via config)". |
|
This plan; moves to |
No schema migrations. No new workspace dependencies (reqwest already present in canopy-auth).
Verification
Per-step
-
cargo nextest run -p canopy-auth— discovery + JWKS tests pass against stubbed shapes. -
cargo xtask dev start— devstack still boots; every service successfully fetches discovery from Keycloak at startup. -
cargo xtask validate— full battery green; no service silently regresses on JWT validation.
End-to-end
-
With devstack up, every existing endpoint that requires auth still accepts a worker JWT (regression:
cargo xtask e2ebaseline still passes). -
Boot a fresh devstack with
KEYCLOAK_ISSUERenv var unset andOIDC_ISSUERset instead — services come up clean. Old name still accepted via#[serde(alias)]. -
Optional smoke: stand up a test Okta tenant + flip
CANOPY_WEB__OIDC_INTERNAL_ISSUERto point at it. Login flow works. (Out-of-band; documented inidp-integration.adoc.)
Risk + Rollback
Risk: discovery fetch at service startup is a new external dep on the IdP being reachable when each service boots. If Keycloak is slow to come up in devstack, services block.
Mitigation: OidcDiscovery::fetch retries with exponential backoff (matches the existing JWKS fetch retry pattern in crates/canopy-auth/src/jwks.rs). 30-second wait is the upper bound — same envelope as today’s bootstrap.
Risk 2: a misconfigured discovery URL causes 17 services to fail-fast at boot.
Mitigation: cargo xtask dev start already health-checks every service before declaring devstack up; the health-check timeout surfaces the misconfiguration loudly. Bonus: cargo xtask validate adds a discovery-fetch smoke test in Step 10.
Rollback: this MR is mostly mechanical rename + 1 new helper. Revert the MR; no schema or wire-format changes to unwind.
Potential Improvements
(Out of scope; file separately if/when relevant.)
-
Azure AD smoke test — confirm Microsoft’s discovery + JWKS handling matches the implementation. Documented in
idp-integration.adocas untested. File when a tenant becomes available. -
Per-IdP documentation —
idp-integration.adoccovers Keycloak/Okta/Auth0; add Azure AD, Cognito, Ory Hydra etc. as operators need them. -
Service-account / client-credentials flow — separate concern, file when the first background-job consumer needs it.