feat: Worker portal Stage 4 — generic IDP loader from idp.toml
Description
Land the generic IDP loader: parse idp.toml files declaring N OIDC identity providers per jurisdiction, with email-domain-based discovery routing the user to the matching provider. The N-OIDC genericization aligns with CRAIG's pattern (Keycloak / authentik / ZITADEL / Kanidm / generic OIDC). SAML federation stays upstream of the OIDC IdP (Keycloak/Authentik broker SAML downstream); canopy speaks only OIDC.
v1 ProviderType enum tightened to keycloak | oidc-generic. Full multi-shape claim support (authentik / zitadel / kanidm) requires roles_claim_path and lands as follow-up #515.
Acceptance Criteria
-
rulesets/{jurisdiction}/idp.tomlschema:[[idp]]table array:slug+label+provider_type(keycloak / oidc-generic) +issuer_url+ optionalinternal_issuer_url+client_id+audience+chip_color(enum) +chip_icon(enum) +domain_match(list of email domain patterns for discovery)[local_accounts]table withenabledtoggle- Zero-IDP graceful state: empty
[[idp]]array +local_accounts.enabled = false→ /login renders the empty-state pointer at Studio → Identity
-
crates/canopy-composition/src/idp.rsextension:IdpDocument::discover(email) -> Option<&IdpEntry>(case-insensitiveends_withemail-suffix match; first match wins)IdpDocument::by_slug(slug) -> Option<&IdpEntry>IdpDocument::all() -> &[IdpEntry]IdpDocument::local_accounts_enabled() -> bool- Parse-time validation: slug regex
^[a-z0-9-]+$, slug uniqueness, reserved slug "synthetic-fallback",domain_matchpatterns start with@,audiencerequired and non-empty,default_rolepresent inroles
-
services/canopy-web/src/auth/idp_runtime.rs(new) —IdpRuntimecarriesVec<IdpRuntimeEntry { meta: IdpEntry, external: Arc<OidcDiscovery>, internal: Arc<OidcDiscovery>, jwks: Arc<JwksProvider> }>. JwksProvider built via new canopy-authfrom_split_discovery(&external, &internal, http.clone())constructor (uses external issuer for iss-claim binding, internal jwks_uri for fetches). Warm-up viarefresh().await + start_refresh_task()per IdP. - Startup fallback ladder (Decision 9 of plan):
- N≥1
[[idp]]entries → multi-IdP runtime - 0 entries + ALL legacy
CANOPY_WEB__OIDC_*fields Some → synthetic single-IdP runtime (audience hardcoded "canopy"; slug "synthetic-fallback"); WARN-log - 0 entries + legacy fields None or partial → empty runtime (NOT a startup error); /login renders zero-IdP empty state; WARN-log
- Parse failure → fatal startup abort
- N≥1
- Local-account flow: GET
/auth/local-loginreturns 501 + JSON envelope stub; real impl deferred to #513 - Both v1 provider types verified against test IDPs (devstack Keycloak; stub generic OIDC in tests)
- Unit + integration tests for: discovery hits/misses/adversarial, parse-validation rejections (slug regex/uniqueness/reserved/domain-pattern/missing-audience), startup ladder rungs, per-IdP JwksProvider isolation, refresh-path lookup variations
- CHANGELOG entry under
=== Changed
Out of Scope (filed as follow-ups)
- SAML federation at app layer → #512
- Real local-account password auth → #513
- Introspection-mode token validation → #514
- Multi-IdP claim-shape support (authentik / zitadel / kanidm) → #515
- Multi-jurisdiction sign-in → #516
cargo xtask identity verify --issuer URL→ #517 (closed)- Retire
CANOPY_WEB__OIDC_*env vars + synthetic fallback → #518
Context & References
- Tracking issue: #460
- Epic: &51
- Plan:
docs/modules/ROOT/pages/plans/worker-portal-redesign-stage4-idp-loader-and-sign-in.adoc - CRAIG IdP-neutral pattern:
~/code/craig/docs/modules/ROOT/pages/idp-integration.adoc - Related:
docs/modules/ROOT/pages/idp-integration.adoc
Labels
type::feature, priority::medium, program::infrastructure, service::web, service::shared-crates, workflow::ready
Edited by Ghost User