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.toml schema:
    • [[idp]] table array: slug + label + provider_type (keycloak / oidc-generic) + issuer_url + optional internal_issuer_url + client_id + audience + chip_color (enum) + chip_icon (enum) + domain_match (list of email domain patterns for discovery)
    • [local_accounts] table with enabled toggle
    • 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.rs extension:
    • IdpDocument::discover(email) -> Option<&IdpEntry> (case-insensitive ends_with email-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_match patterns start with @, audience required and non-empty, default_role present in roles
  • services/canopy-web/src/auth/idp_runtime.rs (new) — IdpRuntime carries Vec<IdpRuntimeEntry { meta: IdpEntry, external: Arc<OidcDiscovery>, internal: Arc<OidcDiscovery>, jwks: Arc<JwksProvider> }>. JwksProvider built via new canopy-auth from_split_discovery(&external, &internal, http.clone()) constructor (uses external issuer for iss-claim binding, internal jwks_uri for fetches). Warm-up via refresh().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
  • Local-account flow: GET /auth/local-login returns 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