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:
-
primary→var(--orchard-primary)(brand greenish) -
accent→var(--orchard-accent)(gold; reserved-for-brand) -
sage→var(--orchard-sage) -
gold→var(--orchard-gold)(DHS variant) -
info→var(--orchard-info) -
neutral→var(--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"returnsNone(trailing.evil.combreaks suffix). -
discover("worker@georgia.gov")against"@GEORGIA.GOV"matches (case-insensitive). -
discover("evilworkgeorgia.gov")against"@georgia.gov"returnsNone(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:
-
N≥1
entries → multi-IdP runtime; legacyoidc_*ignored. LogINFO … N-IdP runtime built from idp.toml ({N} entries). -
idps empty AND ALL THREE legacy IdP-identity fields are
Some→ synthetic single-IdP runtime. LogWARN … synthetic single-IdP fallback active. -
idps empty AND any legacy field is
None(or partial) → empty runtime (entries = vec![],fallback_active = false). NOT a startup error./loginrenders zero-IdP empty state. Partial-config logsWARN … partial legacy oidc_* config ignored. -
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: writeSESSION_IDP_SLUG_KEY = "idp_slug"as flat session key (transient). -
/auth/callbacksuccess: read flat key, COPY intoSessionData.idp_slugbeforestore_session, then remove flat key (add to cleanup atauth/mod.rs:272-277). -
/auth/callbackearly-return paths (state-mismatch / missing-PKCE): also removeSESSION_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
/loginimmediate-redirect path: writesSESSION_IDP_SLUG_KEY = single_entry.slugto session before redirecting (so/auth/callbackworks identically). -
/logoutdecision 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:
-
pub fn from_discovery_with_client(discovery: &OidcDiscovery, client: reqwest::Client) → Result<Self, reqwest::Error>— sibling of the existingfrom_discoverythat takes a sharedreqwest::Client. -
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 constructorIdpRuntime::buildcalls. Required because tokens carry the external issuer in theirissclaim (perconfig.rs:15) but JWKS must be fetched via the internal URL.
IdpRuntime::build per-entry sequence:
-
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?; -
JwksProvider::from_split_discovery(&ext, &int, http.clone())? -
.with_audience(entry.audience.clone())— explicit, no client_id fallback. -
await provider.refresh()— fail closed if any IdP’s JWKS endpoint is unreachable. -
provider.start_refresh_task()— spawn the 1-hour refresher. -
Arc::new(provider)→ store asIdpRuntimeEntry.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:
-
worker.idp_slug = Some(known)AND lookup found → refresh via that entry’s discovery + client_id. -
Some(unknown)(config drift) →AuthFailure::Internal("idp_slug references removed IdP"). -
None+runtime.single_idp()Some → that entry. -
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-getURL (/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.rs—IdpRuntimeper Decision 17. -
services/canopy-web/tests/idp_runtime_test.rs— integration tests (EphemeralSchema + TempDir for idp.toml + direct handler calls, pattern fromservices/canopy-web/tests/composition_api_test.rs).
MODIFIED:
-
crates/canopy-auth/src/jwks.rs— addfrom_discovery_with_clientANDfrom_split_discovery(Decision 12). -
crates/canopy-composition/src/idp.rs— full renameIdp→IdpDocument, no alias. -
crates/canopy-composition/src/lib.rs:47— re-exports updated. -
crates/canopy-composition/src/loader.rs:75—idp_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:Idp→IdpDocument. -
services/canopy-web/src/api/composition.rs:97—validate_role(role_str: &str, idp: &IdpDocument). -
rulesets/georgia/idp.toml— add devstack-keycloakentry + empty[local_accounts]table. Update header comment. -
services/canopy-web/src/config.rs— changeoidc_*IdP-identity fields toOption<String>(keepredirect_url: String). -
services/canopy-web/src/main.rs— new 4-step flow:-
Construct
composition_loaderEARLIER in main (currently main.rs:154; move before any OIDC wiring). -
let idp_doc = composition_loader.idp_for(&svc_config.jurisdiction).await?; -
let idp_runtime = IdpRuntime::build(&idp_doc, &svc_config, &http_client).await?; -
Pass same
Arc<CompositionLoader>intocomposition_state(single instantiation, double consumer).
-
-
services/canopy-web/src/auth/mod.rs— DELETEOidcConfigstruct +OidcConfig::from_web_config. Refactor/login,/auth/callback,/logoutto useExtension::<Arc<IdpRuntime>>./loginpreserves single-IdP immediate-redirect whenentries.len() == 1 && !local_accounts_enabled; else stubs 501 (MR2 makes it real)./auth/landingbyte-for-byte preserved. -
services/canopy-web/src/auth/refresh.rs— UNCHANGED signature. Lookup happens insession.rscaller. -
services/canopy-web/src/session.rs— addpub idp_slug: Option<String>toSessionData. Slow-path token-refresh extractor switches fromExtension::<OidcConfig>toExtension::<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). KEEPredirect_url. -
CHANGELOG.adoc— one=== Changedentry. -
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 extendingbase.html; uses{% block topbar_content %}(becauseis_sidebar = false); overridestopbar_nav_items/topbar_worker_name/topbar_page_title/topbar_breadcrumbto empty so the public sign-in doesn’t leak protected-shell affordances. -
services/canopy-web/templates/auth/_chip_list.html— htmx-target fragment; iteratesruntime.all(); uses{% match entry.meta.chip_icon %}with fully-qualifiedcanopy_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-chipblock (~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—/loginbranching: immediate-redirect ONLY whenentries.len() == 1 && !local_accounts_enabled; ALL other cases (0 IdPs / ≥2 / any-with-local-accounts) renderauth_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— addpub mod auth_sign_in;. -
tests/e2e/playwright.config.ts— verify@axe-core/playwrightis in dev-deps; add if missing. -
CHANGELOG.adoc— one=== Changedentry. -
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 validateclean (fmt + clippy + nextest + check-docs). -
cargo xtask docs plan-lintclean. Status table flip happens AFTER plan-lint passes. -
Pre-push validate + Playwright E2E pass.
-
cargo xtask coverage≥ 41% line. -
All new
.rsfiles carry// SPDX-License-Identifier: AGPL-3.0-or-later. -
No
unwrapoutside[cfg(test)], nounsafe, no[allow(…)]workarounds. -
Only
cargo xtask dev refreshafter image rebuild (never rawdocker compose).
MR1 acceptance (unit + integration tests)
crates/canopy-composition unit tests (≥ 17):
-
Parse roles-only idp.toml (backward compat).
-
Parse roles + 2 idps + local_accounts.
-
discover("worker@georgia.gov")returns matchingIdpEntry. -
discover("worker@unknown.tld")returnsNone. -
discoverlowercases both sides (matches@GEORGIA.GOVpattern). -
discoverfirst-match-wins on@suffixoverlap. -
Unknown
provider_typerejects at parse. -
Unknown
chip_colorrejects at parse. -
local_accounts_enabled = falseby default. -
discover("worker@georgia.gov.evil.com")against"@georgia.gov"returnsNone. -
discover("evilworkgeorgia.gov")against"@georgia.gov"returnsNone. -
Slug
"Georgia Keycloak"rejects withSlugInvalid. -
Duplicate slug rejects with
SlugDuplicate. -
Missing
audiencerejects at parse. -
domain_match = ["georgia.gov"](no@) rejects withDomainPatternInvalid. -
User-defined
slug = "synthetic-fallback"rejects withSlugReserved.
services/canopy-web integration tests (≥ 15):
-
IdpRuntime::buildfrom single-entry idp.toml —entries.len() == 1, OIDC discovery fetched, JwksProvider built AND keys populated (refresh awaited). -
IdpRuntime::buildfrom N=3 idp.toml — distinct discovery + jwks Arcs per entry. -
Shared
reqwest::Clientacross all per-IdP JwksProviders. -
JWKS warm-up on startup — fixture-signed JWT validates without separate refresh call.
-
Synthetic-single-IdP fallback when idps empty + ALL THREE legacy fields Some —
fallback_active = true,entries.len() == 1, synthetic entry’saudience == "canopy", synthetic slug == "synthetic-fallback". -
Empty runtime when idps empty + legacy fields None or partial —
runtime.all().is_empty() == true,fallback_active == false. NO startup error. -
Partial-legacy-config branch — one Some + two None → empty runtime + WARN log "partial legacy oidc_* config ignored".
-
Parse error on idp.toml is fatal (startup abort), NOT fallback.
-
IdpRuntime::by_slug("unknown")returnsNone. -
/loginimmediate-redirect path activates whenentries.len() == 1 && !local_accounts_enabled— HTTP 303 to authorization_endpoint AND session carriesSESSION_IDP_SLUG_KEY. -
/loginsign-in-page path activates in 3 sub-cases (0+false, 2+false, 1+true); MR1 returns 501 placeholder. -
/auth/callbackreadsidp_slugfrom session + uses matchingJwksProvider(cross-IdP token rejected); after success,SessionData.idp_slugpopulated AND flatSESSION_IDP_SLUG_KEYremoved. -
/logout4-branch decision tree (known slug / unknown slug / None+single_idp / None+empty runtime). -
session.rs slow-path refresh — 4-branch logic (Some-known / Some-unknown / None+single_idp / None+multi-or-empty).
-
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-rolledaxum::serve+TcpListener::bind("127.0.0.1:0")perauth/refresh.rs:175-191pattern).
MR2 acceptance (integration + E2E)
services/canopy-web integration tests (≥ 10):
-
sign_in_pagerenders chip list for N IdPs — body contains N<a class="idp-chip"elements. -
sign_in_pagerenders empty_state when 0 IdPs + local_accounts disabled — body contains "No identity providers configured" + Studio→Identity pointer. -
sign_in_pagerenders local-accounts link when enabled — body containshref="/auth/local-login"AND text "Use email + password". -
discover("worker@georgia.gov")returns chip-list fragment withdata-matched="true"on matched chip. -
sign_in_page(return_to=Some("/cases/123"))renders the email input’shx-getattribute with?return_to=%2Fcases%2F123baked in. -
discover(email=…, return_to=Some("/cases/123"))returns chip fragment where each chiphrefcontains&return_to=%2Fcases%2F123. -
discover("worker@unknown.tld")returns chip-list fragment with nodata-matched="true". -
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_KEYwhen return_to present). -
select(slug=unknown)— 404 with body "Unknown identity provider". -
local_login_stubreturns 501 + JSON envelope.
tests/e2e/specs/sign-in.spec.ts (≥ 4 specs):
-
Discovery hit: type
worker@georgia.gov, expect matching chip to receivedata-matched="true"within 1s. -
Discovery miss: type
worker@unknown.tld, expect no chip carriesdata-matched="true". -
Zero-IdP: load
/loginagainst fixture with empty idp.toml + local_accounts off, expect empty_state visible. -
Local-account fallback: load
/loginwithlocal_accounts.enabled = true, expect "Use email + password" link visible.
axe-core WCAG 2.1 AA via injectAxe + checkA11y (@axe-core/playwright).
Consequences
Positive
-
N-OIDC genericization: canopy is now jurisdiction-portable in the sign-in surface. New jurisdictions ship a
rulesets/{juris}/idp.tomlwith 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 startkeeps working without idp.toml changes.
Negative
-
Sharper scope-trim than the original #493 AC (5 backends → 2). Mitigated by #515.
-
oidc_env vars inWebConfiglinger asOption<String>* — only used by synthetic fallback. #518 retires them. -
WorkerRole shape lock-in — Stage 4 v1 requires Keycloak-shape
realm_access.rolesclaims even foroidc-genericIdPs. -
SessionData.idp_slug = Noneon legacy sessions — first-load-after-upgrade workers see /logout fall through to the synthetic single-IdPend_session_endpoint. Mitigated byOption<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/logoutcovering 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
unwrapoutside[cfg(test)], nounsafe, 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
-
Parent plan: worker-portal-redesign.adoc
-
Stage 3 MR2 plan (same-shape precedent): Worker portal redesign — Stage 3 MR2 (HTTP live-override APIs)
-
CRAIG IdP-neutral pattern:
~/code/craig/docs/modules/ROOT/pages/idp-integration.adoc+~/code/craig/crates/craig-auth/src/* -
ADR-013 plan lifecycle
-
ADR-016 forward-only migrations
-
ADR-017 encrypted secrets at rest