Worker Portal Redesign — Stage 4: IDP loader + IDP-aware sign-in

On this page

Tracking: epic &51 (#460) → Stage 4 (#493 IDP loader + #494 sign-in template).

Branch root: feat/worker-portal-redesign-stage4-idp-loader (MR1), feat/worker-portal-redesign-stage4-sign-in (MR2).

GitLab MR labels: type::feature, priority::medium, program::infrastructure, service::web, service::shared-crates, workflow::ready.

Status

Surface Status Notes

Prerequisite — file FU-1..FU-7 + update parent plan + #493/#494 ACs

Done (2026-05-22)

FU-1..FU-7 = #512–#518; parent plan + issue ACs updated in the same commit

MR1 #493 IDP loader + schema + WebConfig fallback wiring

Done (2026-05-22) — !353 merged to main as 0124ce4

canopy-composition idp.rs extension + Idp→IdpDocument rename; canopy-auth JwksProvider constructors; IdpRuntime + auth refactor + session.rs slow path + main.rs reorder. 142/142 canopy-web tests + 95/95 canopy-composition tests + 142/142 E2E pass.

MR2 #494 sign-in template + route reorg

Done (2026-05-22) — !354

sign_in.html + _chip_list.html + idp_icons.html + .idp-chip CSS + auth_sign_in.rs handlers (sign_in_page / discover / select / local_login_stub) + /login delegation + routes wired. ChipView pre-resolved view model (askama 0.15 + CSP discipline). 142/142 canopy-web tests pass.

Context

Today canopy-web speaks to a single OIDC IdP via 4 CANOPY_WEB__OIDC_* env vars in services/canopy-web/src/config.rs:14-21. GET /login does an immediate browser redirect to that IdP’s authorization_endpoint. There is no sign-in landing page.

Stage 4 generalizes this to N OIDC IdPs per jurisdiction, declared in rulesets/{juris}/idp.toml, with email-domain-based discovery routing the worker to the right IdP. The sign-in surface becomes a real page with per-IdP chips + an email-first discovery affordance. Zero-IdP and local-accounts states render gracefully.

SAML is out of scope per CRAIG’s pattern (~/code/craig/docs/modules/ROOT/pages/idp-integration.adoc): SAML federation happens upstream of the OIDC IdP (Keycloak / Authentik / ZITADEL broker SAML downstream). Canopy speaks only OIDC.

v1 ProviderType enum tightened to keycloak \| oidc-generic. authentik / zitadel / kanidm need roles_claim_path and (for kanidm) introspection-mode validation. Both deferred — see #515 + #514.

Design

Decisions (locked)

1. Schema location

Extend the existing Idp struct in crates/canopy-composition/src/idp.rs (rename to IdpDocument). No new crate. The composition loader already reads idp.toml per jurisdiction (crates/canopy-composition/src/loader.rs:75-82). Backward-compat preserved via #[serde(default)] on new fields under deny_unknown_fields.

2. idp.toml schema additions

# Existing — unchanged
default_role = "eligibility_worker"
[roles.eligibility_worker] ...

# NEW in Stage 4
[[idp]]
slug = "georgia-keycloak"             # ^[a-z0-9-]+$, unique; "synthetic-fallback" reserved
label = "Georgia DHS"
provider_type = "keycloak"            # keycloak | oidc-generic
issuer_url = "http://localhost:8088/realms/canopy"
internal_issuer_url = "http://keycloak:8080/realms/canopy"  # optional
client_id = "canopy-ui"
audience = "canopy"                   # REQUIRED, explicit; matches canopy-api/bootstrap.rs:135
chip_color = "primary"                # primary | accent | sage | gold | info | neutral
chip_icon = "keycloak"                # keycloak | shield | oidc-generic
domain_match = ["@georgia.gov", "@dhs.ga.gov"]

[local_accounts]
enabled = false

3. ProviderType enum — keycloak \| oidc-generic only

authentik, zitadel, kanidm deferred to 515 because WorkerRole::from_keycloak_roles (services/canopy-web/src/session.rs:28-40) hardcodes Keycloak’s realm_access.roles. oidc-generic is documented to require Keycloak-shape claims in v1; operators configure their IdP-side claim mappers accordingly. Unknown provider_type rejects at parse via [serde(rename_all = "kebab-case", deny_unknown_fields)].

Claim-shape misconfig signal: in /auth/callback, after validate_token returns claims, if claims.realm_access.roles is empty AND provider_type == OidcGeneric, emit tracing::warn!(idp = %slug, "oidc-generic IdP returned empty realm_access.roles — check claim-mapper config"). Surfaces silent "everyone is Caseworker" misconfigs at first sign-in.

4. chip_color via data-color attribute selectors

6-value named enum — primary \| accent \| sage \| gold \| info \| neutral. CSS uses attribute selectors (idiomatic — see canopy-web.css:763-768 for .panel-frame__count[data-accent] precedent + .status-pill[data-kind]). Strict CSP (csp.rs:27-35) forbids inline style=, so freeform hex isn’t possible without a <style nonce> block. Each value binds to one --orchard-* token:

  • primaryvar(--orchard-primary) (brand greenish)

  • accentvar(--orchard-accent) (gold; reserved-for-brand)

  • sagevar(--orchard-sage)

  • goldvar(--orchard-gold) (DHS variant)

  • infovar(--orchard-info)

  • neutralvar(--orchard-surface-sunken) + var(--orchard-text)

5. chip_icon — 3-value enum, one Askama macro per ChipIcon variant

keycloak \| shield \| oidc-generic. Renders inline SVG defined in services/canopy-web/templates/_primitives/idp_icons.html (one macro per enum variant). No external SVG fetch (CSP img-src 'self' data:); no sprite file. Same inline-SVG pattern as the nav-logo at templates/base.html:21-26.

6. Discovery is server-side; chips are anchors

GET /v1/auth/discover?email=…​ returns an HTML fragment (htmx target with hx-target="#idp-chip-list" hx-swap="outerHTML"). The fragment is the full chip-list with one chip carrying data-matched="true". Chips ARE anchor tags (<a href="/auth/select?slug=X">) — clicking a chip IS the continue action. No separate Continue button.

7. domain_match is lowercase ends_with (not substring)

Discovery logic: email.to_lowercase().ends_with(&pattern.to_lowercase()). Patterns are exact @suffix.tld strings (no glob, no regex; must start with @ — validated at parse, returns IdpError::DomainPatternInvalid otherwise). First match wins (top-down through Vec<IdpEntry>, TOML declaration order preserved). Overlapping domain_match logs WARN at startup.

Adversarial cases (tests):

  • discover("worker@georgia.gov.evil.com") against "@georgia.gov" returns None (trailing .evil.com breaks suffix).

  • discover("worker@georgia.gov") against "@GEORGIA.GOV" matches (case-insensitive).

  • discover("evilworkgeorgia.gov") against "@georgia.gov" returns None (no @-anchor in local part).

8. WebConfig field changes

Three IdP-identity fields → Option<String>: oidc_client_id, oidc_external_issuer, oidc_internal_issuer. They’re per-IdP identity; idp.toml entries supply them when N≥1.

redirect_url stays required String — it’s the BFF’s callback URL, a deployment-wide value, not per-IdP. Every OAuth flow routes back to this single canopy-web callback. IdpRuntime carries redirect_url: String cloned from WebConfig.redirect_url, used by every /auth/select PKCE redirect.

9. Startup fallback ladder

At startup, after IdpDocument::parse succeeds:

  1. N≥1 entries → multi-IdP runtime; legacy oidc_* ignored. Log INFO …​ N-IdP runtime built from idp.toml ({N} entries).

  2. idps empty AND ALL THREE legacy IdP-identity fields are Some → synthetic single-IdP runtime. Log WARN …​ synthetic single-IdP fallback active.

  3. idps empty AND any legacy field is None (or partial)empty runtime (entries = vec![], fallback_active = false). NOT a startup error. /login renders zero-IdP empty state. Partial-config logs WARN …​ partial legacy oidc_* config ignored.

  4. Parse failure of idp.toml → fatal startup abort.

config/canopy-web/default.yaml removes the three IdP-identity default values so the Option<String> fields are None unless explicitly set. redirect_url: stays.

Synthetic entry fields: slug = "synthetic-fallback" (reserved; parse rejects user-defined slug == "synthetic-fallback" with IdpError::SlugReserved), label = "Identity provider", provider_type = ProviderType::OidcGeneric, chip_color = ChipColor::Neutral, chip_icon = ChipIcon::Shield, audience = "canopy", domain_match = vec![].

10. Multi-jurisdiction sign-in deferred

canopy-web is single-jurisdiction by WebConfig::jurisdiction. Stage 4 reads <rulesets_dir>/<jurisdiction>/idp.toml. Multi-jurisdiction = #516.

11. idp_slug persists in SessionData, not in OAuth flow state

SessionData gains pub idp_slug: Option<String> with #[serde(default)]. Optional because pre-Stage-4 sessions deserialized post-upgrade don’t have it.

Lifecycle:

  • /auth/select: write SESSION_IDP_SLUG_KEY = "idp_slug" as flat session key (transient).

  • /auth/callback success: read flat key, COPY into SessionData.idp_slug before store_session, then remove flat key (add to cleanup at auth/mod.rs:272-277).

  • /auth/callback early-return paths (state-mismatch / missing-PKCE): also remove SESSION_IDP_SLUG_KEY + the other three flat keys (SESSION_PKCE_VERIFIER_KEY + SESSION_STATE_KEY + SESSION_RETURN_TO_KEY) so failed flows don’t leak state into next attempt.

  • Single-IdP /login immediate-redirect path: writes SESSION_IDP_SLUG_KEY = single_entry.slug to session before redirecting (so /auth/callback works identically).

  • /logout decision tree (4 branches):

    • idp_slug = Some(known) → end_session_endpoint of that entry.

    • idp_slug = Some(unknown) (config drift) → clear session + redirect to /login.

    • idp_slug = None + runtime.single_idp() Some → that entry’s endpoint.

    • idp_slug = None + empty runtime → clear session + redirect to /login.

12. Per-IdP JwksProvider; new canopy-auth constructor

IdpRuntime carries one Arc<canopy_auth::jwks::JwksProvider> per entry (full path; JwksProvider isn’t re-exported from canopy_auth root today). The callback handler picks the matching provider by idp_slug. AuthLayer continues to exist for inbound-API-token validation but is NOT used for the OAuth callback flow.

canopy-auth requires two additive constructors this MR:

  1. pub fn from_discovery_with_client(discovery: &OidcDiscovery, client: reqwest::Client) → Result<Self, reqwest::Error> — sibling of the existing from_discovery that takes a shared reqwest::Client.

  2. pub fn from_split_discovery(external: &OidcDiscovery, internal: &OidcDiscovery, client: reqwest::Client) → Result<Self, reqwest::Error> — explicit issuer-from-external + jwks_uri-from-internal. This is the constructor IdpRuntime::build calls. Required because tokens carry the external issuer in their iss claim (per config.rs:15) but JWKS must be fetched via the internal URL.

IdpRuntime::build per-entry sequence:

  1. let ext = cached_or_fetch(&entry.issuer_url, http).await?; + let int = cached_or_fetch(entry.internal_issuer_url.as_deref().unwrap_or(&entry.issuer_url), http).await?;

  2. JwksProvider::from_split_discovery(&ext, &int, http.clone())?

  3. .with_audience(entry.audience.clone()) — explicit, no client_id fallback.

  4. await provider.refresh() — fail closed if any IdP’s JWKS endpoint is unreachable.

  5. provider.start_refresh_task() — spawn the 1-hour refresher.

  6. Arc::new(provider) → store as IdpRuntimeEntry.jwks.

13. refresh path uses Arc<IdpRuntime>, not OidcConfig

refresh.rs::refresh_token(discovery, client_id, refresh_token, http) signature stays unchanged — it’s a provider-neutral primitive. The lookup happens in the caller (session.rs slow path). session.rs:147-181 extractor switches from Extension::<OidcConfig> to Extension::<Arc<IdpRuntime>> and selects the entry via 4-branch logic:

  1. worker.idp_slug = Some(known) AND lookup found → refresh via that entry’s discovery + client_id.

  2. Some(unknown) (config drift) → AuthFailure::Internal("idp_slug references removed IdP").

  3. None + runtime.single_idp() Some → that entry.

  4. None + multi-IdP or empty runtime → AuthFailure::Internal("idp_slug missing").

Internal returns force re-login.

OidcConfig struct + OidcConfig::from_web_config DELETED — once WebConfig.oidc_* become Option<String>, the existing &cfg.oidc_external_issuer call site won’t compile against cached_or_fetch(&str, …​). Every Extension::<OidcConfig> extractor (/login, /auth/callback, /logout, session.rs slow path) switches to Extension::<Arc<IdpRuntime>>. No deprecation alias.

14. /auth/landing unchanged

auth/mod.rs:301-310 SameSite=Strict double-redirect + is_safe_redirect open-redirect protection (lines 343-363) stay byte-for-byte. The same-site landing intermediate is per-IdP-agnostic.

15. /auth/local-login is a GET stub

Returns 501 + JSON envelope {"error":{"code":"not_implemented","message":"Local accounts coming soon"}}. Sign-in template renders the link as an anchor <a href="/auth/local-login"> only when local_accounts.enabled = true. Real impl = #513.

16. return_to query param preserved

/login accepts ?return_to=<safe-path> (existing today). When the sign-in template renders, return_to flows as:

  • a baked-in query param on the email input’s hx-get URL (/v1/auth/discover?return_to={{ rt|urlencode }})

  • a query param on each chip’s href (/auth/select?slug=X&return_to={{ rt|urlencode }})

  • a query param on the local-account link (/auth/local-login?return_to={{ rt|urlencode }})

/auth/select stashes return_to in session at the same point it stashes pkce_verifier + oauth_state + idp_slug.

17. Function signatures (no vague verbs)

crates/canopy-composition/src/idp.rs (extending existing Idp → renamed to IdpDocument):

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IdpDocument {
    pub default_role: String,
    pub roles: HashMap<String, RoleDef>,
    #[serde(default, rename = "idp")]
    pub idps: Vec<IdpEntry>,
    #[serde(default)]
    pub local_accounts: LocalAccountsConfig,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IdpEntry {
    pub slug: String,
    pub label: String,
    pub provider_type: ProviderType,
    pub issuer_url: String,
    #[serde(default)]
    pub internal_issuer_url: Option<String>,
    pub client_id: String,
    /// Required (no `#[serde(default)]`). Must match the IdP's emitted JWT `aud` claim.
    /// No default to client_id; canopy-web uses client_id="canopy-ui" + audience="canopy".
    pub audience: String,
    pub chip_color: ChipColor,
    pub chip_icon: ChipIcon,
    #[serde(default)]
    pub domain_match: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub enum ProviderType { Keycloak, OidcGeneric }

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub enum ChipColor { Primary, Accent, Sage, Gold, Info, Neutral }

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub enum ChipIcon { Keycloak, Shield, OidcGeneric }

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct LocalAccountsConfig {
    #[serde(default)]
    pub enabled: bool,
}

impl std::fmt::Display for ChipColor { /* snake-case wire format */ }
impl std::fmt::Display for ChipIcon  { /* snake-case wire format */ }

impl IdpDocument {
    /// Parse + validate. Post-deserialize checks:
    ///   - every slug matches ^[a-z0-9-]+$ and is non-empty
    ///   - no slug == "synthetic-fallback" (reserved per Decision 9)
    ///   - slugs are unique across `idps`
    ///   - every `domain_match` pattern starts with `@`
    ///   - every `[[idp]]` audience is non-empty
    ///   - `default_role` is present in `roles` (existing invariant)
    /// Violations: IdpError::{SlugInvalid, SlugReserved, SlugDuplicate,
    ///                       DomainPatternInvalid, AudienceEmpty, DefaultRoleUnknown}.
    pub fn parse(toml_str: &str) -> Result<Self, IdpError>;
    pub fn discover(&self, email: &str) -> Option<&IdpEntry>;
    pub fn by_slug(&self, slug: &str) -> Option<&IdpEntry>;
    pub fn local_accounts_enabled(&self) -> bool;
    pub fn has_role(&self, role: &RoleSlug) -> bool; // existing
}

services/canopy-web/src/auth/idp_runtime.rs (NEW):

pub struct IdpRuntime {
    pub entries: Vec<IdpRuntimeEntry>,
    pub local_accounts_enabled: bool,
    pub redirect_url: String,
    pub fallback_active: bool,
}

pub struct IdpRuntimeEntry {
    pub meta: IdpEntry,
    pub external: Arc<OidcDiscovery>,
    pub internal: Arc<OidcDiscovery>,
    pub jwks: Arc<canopy_auth::jwks::JwksProvider>,
}

impl IdpRuntime {
    pub async fn build(
        idp_doc: &IdpDocument,
        web_config: &WebConfig,
        http: &reqwest::Client,
    ) -> Result<Self, IdpRuntimeError>;
    pub fn by_slug(&self, slug: &str) -> Option<&IdpRuntimeEntry>;
    pub fn all(&self) -> &[IdpRuntimeEntry];
    /// Proxies to IdpDocument::discover for lookup ordering parity.
    pub fn discover(&self, email: &str) -> Option<&IdpRuntimeEntry>;
    pub fn single_idp(&self) -> Option<&IdpRuntimeEntry>;  // Some iff entries.len() == 1
}

#[derive(Debug, thiserror::Error)]
pub enum IdpRuntimeError {
    #[error("OIDC discovery failed for issuer {issuer}: {source}")]
    Discovery { issuer: String, #[source] source: canopy_auth::AuthDiscoveryError },
    #[error("JWKS provider build failed for issuer {issuer}: {source}")]
    Jwks { issuer: String, #[source] source: reqwest::Error },
    #[error("JWKS warm-up refresh failed for issuer {issuer}: {source}")]
    JwksRefresh { issuer: String, #[source] source: anyhow::Error },
}

services/canopy-web/src/api/auth_sign_in.rs (NEW, MR2):

#[derive(askama::Template)]
#[template(path = "auth/sign_in.html")]
pub struct SignInPage {
    pub branding: BrandingConfig,
    pub is_sidebar: bool,    // ALWAYS `false` for sign-in
    pub active_nav: String,  // pass ""
    pub runtime: Arc<IdpRuntime>,
    pub matched_slug: Option<String>,
    pub return_to: Option<String>,
}

#[derive(askama::Template)]
#[template(path = "auth/_chip_list.html")]
pub struct ChipListFragment {
    pub runtime: Arc<IdpRuntime>,
    pub matched_slug: Option<String>,
    pub return_to: Option<String>,
}

#[derive(serde::Deserialize)]
pub struct SignInQuery { #[serde(default)] pub return_to: Option<String> }

#[derive(serde::Deserialize)]
pub struct DiscoverQuery {
    pub email: String,
    #[serde(default)] pub return_to: Option<String>,
}

#[derive(serde::Deserialize)]
pub struct SelectQuery {
    pub slug: String,
    #[serde(default)] pub return_to: Option<String>,
}

pub async fn sign_in_page(
    Extension(runtime): Extension<Arc<IdpRuntime>>,
    Extension(theme): Extension<Arc<ThemeConfig>>,
    Query(q): Query<SignInQuery>,
) -> Result<axum::response::Html<String>, axum::http::StatusCode>;

pub async fn discover(
    Extension(runtime): Extension<Arc<IdpRuntime>>,
    Query(q): Query<DiscoverQuery>,
) -> Result<axum::response::Html<String>, axum::http::StatusCode>;

pub async fn select(
    Extension(runtime): Extension<Arc<IdpRuntime>>,
    session: Session,
    Query(q): Query<SelectQuery>,
) -> axum::response::Response;  // Redirect on known slug; 404 with body on unknown.

pub async fn local_login_stub() -> (axum::http::StatusCode, axum::Json<serde_json::Value>);
// Wired as GET in main.rs: .route("/auth/local-login", get(local_login_stub))

Files Touched

MR1 (#493) — IDP loader + WebConfig fallback wiring (NO sign-in template)

NEW:

  • crates/canopy-composition/src/idp.rs — extend per Decision 17.

  • services/canopy-web/src/auth/idp_runtime.rsIdpRuntime per Decision 17.

  • services/canopy-web/tests/idp_runtime_test.rs — integration tests (EphemeralSchema + TempDir for idp.toml + direct handler calls, pattern from services/canopy-web/tests/composition_api_test.rs).

MODIFIED:

  • crates/canopy-auth/src/jwks.rs — add from_discovery_with_client AND from_split_discovery (Decision 12).

  • crates/canopy-composition/src/idp.rs — full rename IdpIdpDocument, no alias.

  • crates/canopy-composition/src/lib.rs:47 — re-exports updated.

  • crates/canopy-composition/src/loader.rs:75idp_for(…​) return type → Result<IdpDocument, _>.

  • crates/canopy-composition/src/types.rs:194 — error variant type ref update.

  • services/canopy-web/src/api/composition.rs:25 — import line: IdpIdpDocument.

  • services/canopy-web/src/api/composition.rs:97validate_role(role_str: &str, idp: &IdpDocument).

  • rulesets/georgia/idp.toml — add devstack-keycloak entry + empty [local_accounts] table. Update header comment.

  • services/canopy-web/src/config.rs — change oidc_* IdP-identity fields to Option<String> (keep redirect_url: String).

  • services/canopy-web/src/main.rs — new 4-step flow:

    1. Construct composition_loader EARLIER in main (currently main.rs:154; move before any OIDC wiring).

    2. let idp_doc = composition_loader.idp_for(&svc_config.jurisdiction).await?;

    3. let idp_runtime = IdpRuntime::build(&idp_doc, &svc_config, &http_client).await?;

    4. Pass same Arc<CompositionLoader> into composition_state (single instantiation, double consumer).

  • services/canopy-web/src/auth/mod.rs — DELETE OidcConfig struct + OidcConfig::from_web_config. Refactor /login, /auth/callback, /logout to use Extension::<Arc<IdpRuntime>>. /login preserves single-IdP immediate-redirect when entries.len() == 1 && !local_accounts_enabled; else stubs 501 (MR2 makes it real). /auth/landing byte-for-byte preserved.

  • services/canopy-web/src/auth/refresh.rs — UNCHANGED signature. Lookup happens in session.rs caller.

  • services/canopy-web/src/session.rs — add pub idp_slug: Option<String> to SessionData. Slow-path token-refresh extractor switches from Extension::<OidcConfig> to Extension::<Arc<IdpRuntime>>; 4-branch lookup per Decision 13.

  • config/canopy-web/default.yaml — REMOVE the three IdP-identity defaults (oidc_client_id, oidc_external_issuer, oidc_internal_issuer). KEEP redirect_url.

  • CHANGELOG.adoc — one === Changed entry.

  • docs/modules/ROOT/pages/idp-integration.adoc — new "Multi-IdP configuration via idp.toml" section.

  • docs/modules/ROOT/pages/plans/worker-portal-redesign.adoc — Stage 4 row → In progress (YYYY-MM-DD) — MR1 #493 merged.

  • docs/modules/ROOT/nav.adoc — link to this Stage 4 plan.

MR2 (#494) — Sign-in template + route reorg

NEW:

  • services/canopy-web/templates/auth/sign_in.html — Askama template extending base.html; uses {% block topbar_content %} (because is_sidebar = false); overrides topbar_nav_items/topbar_worker_name/topbar_page_title/topbar_breadcrumb to empty so the public sign-in doesn’t leak protected-shell affordances.

  • services/canopy-web/templates/auth/_chip_list.html — htmx-target fragment; iterates runtime.all(); uses {% match entry.meta.chip_icon %} with fully-qualified canopy_composition::ChipIcon::* variants.

  • services/canopy-web/templates/_primitives/idp_icons.html — 3 Askama macros: icon_keycloak, icon_shield, icon_oidc_generic.

  • services/canopy-web/static/css/canopy-web.css — add .idp-chip block (~60 lines). Attribute selectors .idp-chip[data-color="primary"] etc., binding to --orchard-* tokens.

  • services/canopy-web/src/api/auth_sign_in.rs — 4 handlers + Askama structs per Decision 17.

  • services/canopy-web/tests/sign_in_test.rs — handler-level integration tests.

  • tests/e2e/specs/sign-in.spec.ts — 4 Playwright specs (discover hit / miss / zero-IDP / local-account fallback) + axe-core checkA11y.

MODIFIED:

  • services/canopy-web/src/auth/mod.rs/login branching: immediate-redirect ONLY when entries.len() == 1 && !local_accounts_enabled; ALL other cases (0 IdPs / ≥2 / any-with-local-accounts) render auth_sign_in::sign_in_page.

  • services/canopy-web/src/main.rs — wire /v1/auth/discover (GET), /auth/select (GET), /auth/local-login (GET) routes.

  • services/canopy-web/src/api/mod.rs — add pub mod auth_sign_in;.

  • tests/e2e/playwright.config.ts — verify @axe-core/playwright is in dev-deps; add if missing.

  • CHANGELOG.adoc — one === Changed entry.

  • docs/modules/ROOT/pages/plans/worker-portal-redesign.adoc — Stage 4 row → Done (YYYY-MM-DD) — MR1 + MR2 merged.

  • .claude/docs/coding-conventions.md — add "Sign-in template patterns" subsection.

Verification

Per-MR

  • cargo xtask validate clean (fmt + clippy + nextest + check-docs).

  • cargo xtask docs plan-lint clean. Status table flip happens AFTER plan-lint passes.

  • Pre-push validate + Playwright E2E pass.

  • cargo xtask coverage ≥ 41% line.

  • All new .rs files carry // SPDX-License-Identifier: AGPL-3.0-or-later.

  • No unwrap outside [cfg(test)], no unsafe, no [allow(…​)] workarounds.

  • Only cargo xtask dev refresh after image rebuild (never raw docker compose).

MR1 acceptance (unit + integration tests)

crates/canopy-composition unit tests (≥ 17):

  1. Parse roles-only idp.toml (backward compat).

  2. Parse roles + 2 idps + local_accounts.

  3. discover("worker@georgia.gov") returns matching IdpEntry.

  4. discover("worker@unknown.tld") returns None.

  5. discover lowercases both sides (matches @GEORGIA.GOV pattern).

  6. discover first-match-wins on @suffix overlap.

  7. Unknown provider_type rejects at parse.

  8. Unknown chip_color rejects at parse.

  9. local_accounts_enabled = false by default.

  10. default_role validation still works with present.

  11. discover("worker@georgia.gov.evil.com") against "@georgia.gov" returns None.

  12. discover("evilworkgeorgia.gov") against "@georgia.gov" returns None.

  13. Slug "Georgia Keycloak" rejects with SlugInvalid.

  14. Duplicate slug rejects with SlugDuplicate.

  15. Missing audience rejects at parse.

  16. domain_match = ["georgia.gov"] (no @) rejects with DomainPatternInvalid.

  17. User-defined slug = "synthetic-fallback" rejects with SlugReserved.

services/canopy-web integration tests (≥ 15):

  1. IdpRuntime::build from single-entry idp.toml — entries.len() == 1, OIDC discovery fetched, JwksProvider built AND keys populated (refresh awaited).

  2. IdpRuntime::build from N=3 idp.toml — distinct discovery + jwks Arcs per entry.

  3. Shared reqwest::Client across all per-IdP JwksProviders.

  4. JWKS warm-up on startup — fixture-signed JWT validates without separate refresh call.

  5. Synthetic-single-IdP fallback when idps empty + ALL THREE legacy fields Some — fallback_active = true, entries.len() == 1, synthetic entry’s audience == "canopy", synthetic slug == "synthetic-fallback".

  6. Empty runtime when idps empty + legacy fields None or partial — runtime.all().is_empty() == true, fallback_active == false. NO startup error.

  7. Partial-legacy-config branch — one Some + two None → empty runtime + WARN log "partial legacy oidc_* config ignored".

  8. Parse error on idp.toml is fatal (startup abort), NOT fallback.

  9. IdpRuntime::by_slug("unknown") returns None.

  10. /login immediate-redirect path activates when entries.len() == 1 && !local_accounts_enabled — HTTP 303 to authorization_endpoint AND session carries SESSION_IDP_SLUG_KEY.

  11. /login sign-in-page path activates in 3 sub-cases (0+false, 2+false, 1+true); MR1 returns 501 placeholder.

  12. /auth/callback reads idp_slug from session + uses matching JwksProvider (cross-IdP token rejected); after success, SessionData.idp_slug populated AND flat SESSION_IDP_SLUG_KEY removed.

  13. /logout 4-branch decision tree (known slug / unknown slug / None+single_idp / None+empty runtime).

  14. session.rs slow-path refresh — 4-branch logic (Some-known / Some-unknown / None+single_idp / None+multi-or-empty).

  15. JwksProvider built via from_split_discovery(&external, &internal, …​) — verify iss validation passes for tokens with external issuer AND JWKS keys fetched via internal URL (hand-rolled axum::serve + TcpListener::bind("127.0.0.1:0") per auth/refresh.rs:175-191 pattern).

MR2 acceptance (integration + E2E)

services/canopy-web integration tests (≥ 10):

  1. sign_in_page renders chip list for N IdPs — body contains N <a class="idp-chip" elements.

  2. sign_in_page renders empty_state when 0 IdPs + local_accounts disabled — body contains "No identity providers configured" + Studio→Identity pointer.

  3. sign_in_page renders local-accounts link when enabled — body contains href="/auth/local-login" AND text "Use email + password".

  4. discover("worker@georgia.gov") returns chip-list fragment with data-matched="true" on matched chip.

  5. sign_in_page(return_to=Some("/cases/123")) renders the email input’s hx-get attribute with ?return_to=%2Fcases%2F123 baked in.

  6. discover(email=…​, return_to=Some("/cases/123")) returns chip fragment where each chip href contains &return_to=%2Fcases%2F123.

  7. discover("worker@unknown.tld") returns chip-list fragment with no data-matched="true".

  8. select(slug=known) — 303 redirect to authorization_endpoint + writes ALL four flow-state keys to session (SESSION_IDP_SLUG_KEY, SESSION_PKCE_VERIFIER_KEY, SESSION_STATE_KEY, SESSION_RETURN_TO_KEY when return_to present).

  9. select(slug=unknown) — 404 with body "Unknown identity provider".

  10. local_login_stub returns 501 + JSON envelope.

tests/e2e/specs/sign-in.spec.ts (≥ 4 specs):

  1. Discovery hit: type worker@georgia.gov, expect matching chip to receive data-matched="true" within 1s.

  2. Discovery miss: type worker@unknown.tld, expect no chip carries data-matched="true".

  3. Zero-IdP: load /login against fixture with empty idp.toml + local_accounts off, expect empty_state visible.

  4. Local-account fallback: load /login with local_accounts.enabled = true, expect "Use email + password" link visible.

axe-core WCAG 2.1 AA via injectAxe + checkA11y (@axe-core/playwright).

Stage acceptance (per parent plan, post-scope-reframe)

  • IDP loader unit-tested per v1 provider type (keycloak + oidc-generic).

  • Sign-in template axe-core WCAG 2.1 AA clean.

  • Zero-IDP graceful state renders.

Consequences

Positive

  • N-OIDC genericization: canopy is now jurisdiction-portable in the sign-in surface. New jurisdictions ship a rulesets/{juris}/idp.toml with their N IdP entries and no source change.

  • CRAIG-pattern alignment: future ADRs covering validation_mode (#514) + roles_claim_path (#515) + cargo xtask identity verify (#517) land additively, not as restructures.

  • No SAML protocol code at the app layer: deferral to IdP-side SAML brokering keeps canopy’s surface area small + CRAIG-aligned.

  • CSP discipline preserved: chip styling via data-attribute selectors matches existing .status-pill[data-kind] precedent; no inline-style regression.

  • Backward-compat: synthetic-single-IdP fallback means cargo xtask dev start keeps working without idp.toml changes.

Negative

  • Sharper scope-trim than the original #493 AC (5 backends → 2). Mitigated by #515.

  • oidc_ env vars in WebConfig linger as Option<String>* — only used by synthetic fallback. #518 retires them.

  • WorkerRole shape lock-in — Stage 4 v1 requires Keycloak-shape realm_access.roles claims even for oidc-generic IdPs.

  • SessionData.idp_slug = None on legacy sessions — first-load-after-upgrade workers see /logout fall through to the synthetic single-IdP end_session_endpoint. Mitigated by Option<String> + 4-branch /logout logic.

  • No introspection-mode (JWE token validation) — kanidm + authentik-encrypted + ZITADEL-opaque blocked until #514.

Risk + Rollback

  • Risk — Decision 12 (per-IdP JwksProvider) changes the canopy_auth integration shape in canopy-web’s callback path. Mitigation: AuthLayer (inbound API token validation) untouched; change is scoped to /auth/callback.

  • Risk — Decision 9 (env-var fallback) masks misconfigured idp.toml at startup. Mitigation: parse failure is fatal; WARN-log on fallback activation; INFO-log on multi-IdP path.

  • Risk — Decision 11 (idp_slug in SessionData) breaks logout for in-flight sessions during MR1 deploy. Mitigation: idp_slug: Option<String> + 4-branch decision tree in /logout covering known slug / unknown slug / None+single_idp / None+empty runtime.

  • Rollback is forward-only per ADR-016. Neither MR ships a Postgres migration; idp.toml is config, not schema. A failed deploy reverts by re-deploying the prior commit.

Pre-commit Q1-Q8 expectations (per MR)

  • Q1 — every MR adds tests for new code (IdpDocument parsing + discover, sign-in handlers, E2E, JwksProvider per-IdP coverage).

  • Q2 — no unwrap outside [cfg(test)], no unsafe, no [allow(…​)] workarounds.

  • Q3 — no test deletions or weakened assertions.

  • Q4 — design deviations update this plan’s Design section + file separate design-iteration issues.

  • Q5 — neither MR closes #460 (#494 closes Stage 4 only; epic closes at Stage 7).

  • Q6 — out-of-scope items stay deferred via #512–#518 (filed during Prerequisite Actions).

  • Q7 — per-MR CHANGELOG + this plan’s Status row update + idp-integration.adoc (MR1) + coding-conventions.md (MR2) + nav.adoc.

  • Q8 — zero new TODO/FIXME tokens.

References

Follow-up issues (filed before MR1 code work)

Issue Title Why this MR doesn’t include it

#512

spike: ADR + plan for SAML federation at canopy app layer

CRAIG pattern: SAML brokering happens upstream of OIDC IdP. Adding samael protocol code is multi-week scope and may never be needed.

#513

feat: local accounts password auth (argon2 + lockout + reset)

ATO/IRS-1075 implications for canopy-internal credential storage. Separate plan.

#514

feat: introspection-mode token validation (CRAIG Plan F)

JWE/opaque-token support unblocks kanidm + authentik-encrypted + ZITADEL-opaque. CRAIG took 6 step MRs.

#515

feat: multi-IdP claim-shape support (roles_claim_path + validation-mode)

WorkerRole::from_keycloak_roles is hardcoded. Stage 4 narrows enum to keycloak | oidc-generic until this lands.

#516

feat: multi-jurisdiction sign-in

canopy-web is single-jurisdiction by deployment today (WebConfig::jurisdiction).

#517

feat: cargo xtask identity verify --issuer URL

Borrowed from CRAIG; production deploy gate. Additive; post-Stage 4.

#518

chore: retire CANOPY_WEB__OIDC_* env vars + synthetic-fallback

Tracking deprecation; lands once all jurisdictions have N≥1 entries.

Edit this page · default