diff --git a/Cargo.lock b/Cargo.lock index b27c5d2b..cda03257 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4155,6 +4155,7 @@ dependencies = [ "harmony-fleet-auth", "harmony-reconciler-contracts", "harmony_zitadel_auth", + "http-body-util", "k8s-openapi", "kube", "maud", @@ -4166,6 +4167,7 @@ dependencies = [ "tokio", "tokio-stream", "toml", + "tower", "tracing", "tracing-subscriber", "url", diff --git a/fleet/harmony-fleet-operator/Cargo.toml b/fleet/harmony-fleet-operator/Cargo.toml index f272a067..00628a4b 100644 --- a/fleet/harmony-fleet-operator/Cargo.toml +++ b/fleet/harmony-fleet-operator/Cargo.toml @@ -43,3 +43,11 @@ axum-extra = { version = "0.10", features = ["cookie", "cookie-private"], option maud = { version = "0.27", features = ["axum"], optional = true } tokio-stream = { version = "0.1", optional = true } dotenvy = "0.15" + +[dev-dependencies] +# `oneshot` lets the middleware tests drive a `Router` end-to-end +# without binding a TCP port — the only way to exercise the +# require_auth/require_fleet_admin composition as the production +# stack actually layers them. +tower = { version = "0.5", features = ["util"] } +http-body-util = "0.1" diff --git a/fleet/harmony-fleet-operator/src/frontend/server.rs b/fleet/harmony-fleet-operator/src/frontend/server.rs index 07ec872c..2b181e41 100644 --- a/fleet/harmony-fleet-operator/src/frontend/server.rs +++ b/fleet/harmony-fleet-operator/src/frontend/server.rs @@ -28,7 +28,7 @@ use super::views::{ }; use crate::frontend::auth::{self, DASHBOARD_SESSION_COOKIE, DashboardSession, JwksCache}; use crate::service::FleetService; -use harmony_zitadel_auth::ZitadelAuthConfig; +use harmony_zitadel_auth::{Role, ZitadelAuthConfig}; pub const DEFAULT_PORT: u16 = 18080; @@ -90,6 +90,14 @@ pub fn router(state: AppState) -> Router { let public_routes = Router::new() .route("/login", get(auth::login_handler)) .route("/auth/callback", get(auth::callback_handler)) + // `/logout` lives here, not in `private_routes`, so a logged-in + // user without `fleet-admin` can still switch accounts. The + // handler is idempotent — it clears the session cookie and + // redirects to Zitadel's end-session endpoint regardless of + // whether a session existed — so making it public costs us + // nothing and avoids a 403 trap where the forbidden page + // links to a route the role gate would itself reject. + .route("/logout", get(auth::logout_handler)) .route("/static/tailwind.css", get(tailwind_css)) .route("/static/htmx.min.js", get(htmx_js)) .route("/static/htmx-ext-sse.js", get(htmx_sse_js)) @@ -115,9 +123,12 @@ pub fn router(state: AppState) -> Router { // Settings .route("/settings", get(settings_handler)) .route("/settings/toggle/{key}", post(settings_toggle_handler)) - // Logout - .route("/logout", get(auth::logout_handler)) .route_layer(middleware::from_fn_with_state(state.clone(), csrf_protect)) + // The role gate (v0.3 Ch.1) runs **after** authentication: it + // reads the `DashboardSession` extension that `require_auth` + // inserts. `route_layer`'s execution order is bottom-up, so + // `require_auth` is listed below to ensure it runs first. + .route_layer(middleware::from_fn(require_fleet_admin)) .route_layer(middleware::from_fn_with_state(state.clone(), require_auth)); let mut r = public_routes.merge(private_routes); @@ -156,6 +167,93 @@ async fn require_auth( } } +/// Require the dashboard's `fleet-admin` role on the verified session. +/// +/// v0.3 Chapter 1: before this gate, any authenticated Zitadel user +/// reached every dashboard handler — the JWT signature/issuer/audience +/// check was the only authorization. Operators routinely give non-admin +/// users an account in Zitadel for SSO into adjacent apps, so the JWT +/// being valid is **not** sufficient evidence the user should manage a +/// fleet. This middleware closes that gap. +/// +/// Composition: layered **above** `require_auth` so the +/// `DashboardSession` extension is guaranteed present. A missing +/// extension is a programming error (route mounted without auth) and +/// fails closed with the same forbidden response — never wildcard-grant. +async fn require_fleet_admin(req: Request, next: Next) -> Response { + require_role(Role::FleetAdmin, req, next).await +} + +/// Reusable role-gating middleware body. +/// +/// Takes a typed [`Role`] — not a string — so adding a future role +/// gate (e.g. a `fleet-viewer` read-only zone) is a new variant + +/// new wrapper fn, and the compiler refuses every string-based +/// "role" comparison that previously hid bugs (case mismatch, typo, +/// missing claim treated as wildcard). +async fn require_role(role: Role, req: Request, next: Next) -> Response { + let Some(session) = req.extensions().get::() else { + // No session = require_auth wasn't layered above us. Fail + // closed: serving the request would skip authentication + // entirely. The 401 path (login redirect) is correct because + // the user genuinely has no session. + tracing::error!( + "require_role invoked without an authenticated session — \ + middleware composition bug; failing closed" + ); + return unauthenticated_response(&req); + }; + + if session.roles.has(role) { + return next.run(req).await; + } + + tracing::warn!( + subject = %session.subject, + required_role = %role, + granted_roles = ?session.roles.iter().collect::>(), + "user lacks required role for dashboard", + ); + forbidden_response(role) +} + +/// Render a human-facing 403 page for a missing role. +/// +/// Not JSON: the dashboard is human-facing, so a logged-in user who +/// lands here gets a page they can read and act on (ask their admin +/// for the role) rather than a raw error code. +fn forbidden_response(required_role: Role) -> Response { + let role_name = required_role.name(); + let body = maud::html! { + (maud::DOCTYPE) + html lang="en" { + head { + meta charset="utf-8"; + meta name="viewport" content="width=device-width, initial-scale=1"; + title { "Access denied — Harmony Fleet" } + link rel="stylesheet" href="/static/tailwind.css"; + } + body class="min-h-screen flex items-center justify-center" + style="background:var(--bg); color:#e2e8f0; font-family:'Inter',sans-serif" { + main class="max-w-md w-full px-6 py-10 rounded-lg border" + style="border-color:var(--border); background:rgba(148,163,184,0.04)" { + h1 class="text-xl font-semibold text-slate-100 mb-2" { "Access denied" } + p class="text-sm text-slate-300 mb-4" { + "The " + code class="font-mono text-slate-200" { (role_name) } + " role is required to use this dashboard. Ask your administrator to grant it on your Zitadel account." + } + p class="text-sm" { + a href="/logout" class="text-cyan-400 hover:text-cyan-300" { "Sign out" } + } + } + } + } + }; + + (StatusCode::FORBIDDEN, body).into_response() +} + async fn csrf_protect(State(state): State, req: Request, next: Next) -> Response { if !is_mutating_method(req.method()) { return next.run(req).await; @@ -723,3 +821,172 @@ impl IntoResponse for AppError { (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", self.0)).into_response() } } + +#[cfg(test)] +mod role_middleware_tests { + //! Behaviour tests for the v0.3 Ch.1 dashboard role gate. + //! + //! These drive an in-memory `Router` through `tower::ServiceExt::oneshot` + //! so we exercise the *composition* of `require_auth` and + //! `require_fleet_admin` the same way it's mounted in production — + //! mocking just the `DashboardSession` extension rather than + //! standing up Zitadel. + + use super::*; + use axum::Router; + use axum::body::Body; + use axum::http::Request; + use axum::middleware; + use axum::routing::get; + use harmony_zitadel_auth::Roles; + use http_body_util::BodyExt; + use tower::ServiceExt; + + async fn ok_handler() -> &'static str { + "OK" + } + + /// Inject a pre-built session — production code only ever has + /// `require_auth` insert this, so tests do the same by hand. + async fn inject_session( + session: DashboardSession, + mut req: Request, + next: Next, + ) -> Response { + req.extensions_mut().insert(session); + next.run(req).await + } + + fn router_with_session(session: Option) -> Router { + let injector_layer = move |req: Request, next: Next| { + let session = session.clone(); + async move { + if let Some(s) = session { + inject_session(s, req, next).await + } else { + next.run(req).await + } + } + }; + + Router::new() + .route("/", get(ok_handler)) + .route_layer(middleware::from_fn(require_fleet_admin)) + .route_layer(middleware::from_fn(injector_layer)) + } + + /// Build a fixture session. Takes typed [`Role`] values — there is + /// no string-keyed path here, by design. A test that wants to + /// exercise an "unknown" role just passes an empty slice; the + /// only way to grant `fleet-admin` in a test is to mention + /// `Role::FleetAdmin` explicitly, which is exactly the + /// security-correct property we want production code to enforce. + fn session_with_roles(roles: I) -> DashboardSession + where + I: IntoIterator, + { + DashboardSession { + subject: "u-1".to_string(), + email: Some("u@example.com".to_string()), + name: Some("Tester".to_string()), + expires_at: 0, + nonce: None, + roles: roles.into_iter().collect::(), + } + } + + async fn read_body(resp: Response) -> String { + let bytes = resp + .into_body() + .collect() + .await + .expect("collect body") + .to_bytes(); + String::from_utf8(bytes.to_vec()).expect("utf-8 body") + } + + #[tokio::test] + async fn request_without_session_is_unauthenticated() { + // No DashboardSession in extensions → fail closed via the + // same path require_auth uses when the cookie is missing. + let app = router_with_session(None); + let resp = app + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .expect("middleware ran"); + // unauthenticated_response → 303 redirect to /login for HTML clients. + assert!( + matches!( + resp.status(), + StatusCode::SEE_OTHER + | StatusCode::TEMPORARY_REDIRECT + | StatusCode::UNAUTHORIZED + | StatusCode::FOUND + ), + "expected redirect or 401, got {:?}", + resp.status() + ); + } + + #[tokio::test] + async fn session_without_fleet_admin_is_forbidden() { + // The exact gap v0.3 Ch.1 closes: a valid Zitadel JWT with + // no fleet-admin role must NOT reach dashboard handlers. We + // can't even *spell* "device" as a Role variant from the + // test — the typed API forces "anything not Role::FleetAdmin" + // to round-trip through the empty roles set, which is the + // exact production behaviour for an unrecognized role name. + let app = router_with_session(Some(session_with_roles([]))); + let resp = app + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .expect("middleware ran"); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + + let body = read_body(resp).await; + // Body must name the missing role so the user can ask for + // it, and link to /logout so they can switch accounts. + assert!(body.contains(Role::FleetAdmin.name())); + assert!(body.contains("/logout")); + } + + #[tokio::test] + async fn session_with_fleet_admin_reaches_handler() { + let app = router_with_session(Some(session_with_roles([Role::FleetAdmin]))); + let resp = app + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .expect("middleware ran"); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(read_body(resp).await, "OK"); + } + + #[tokio::test] + async fn forbidden_response_is_html_not_json() { + // Dashboard is human-facing — a 403 must render as HTML the + // user can read, not a JSON `{error:"..."}` envelope. + let app = router_with_session(Some(session_with_roles([]))); + let resp = app + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .expect("middleware ran"); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + + let content_type = resp + .headers() + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_string(); + assert!( + content_type.contains("text/html"), + "expected HTML content-type, got {content_type:?}" + ); + + let body = read_body(resp).await; + assert!( + body.contains(", name: Option, nonce: Option, + #[serde(flatten)] + roles: RoleClaims, } let claims = decode::(token, &decoding_key, &validation) @@ -177,6 +186,7 @@ fn verify_with_jwk( name: claims.name, expires_at: claims.exp, nonce: claims.nonce, + roles: claims.roles.into_roles(), }) } diff --git a/harmony_zitadel_auth/src/lib.rs b/harmony_zitadel_auth/src/lib.rs index f5cd0e78..481de474 100644 --- a/harmony_zitadel_auth/src/lib.rs +++ b/harmony_zitadel_auth/src/lib.rs @@ -3,6 +3,7 @@ pub mod axum_login_flow; pub mod config; pub mod jwks; pub mod login; +pub mod roles; pub mod session; #[cfg(feature = "axum")] @@ -20,4 +21,6 @@ pub use login::{ validate_callback_state, validate_id_token, }; +pub use roles::{Role, RoleClaims, Roles}; + pub use session::{LoginAttemptCookie, VerifiedSession}; diff --git a/harmony_zitadel_auth/src/roles.rs b/harmony_zitadel_auth/src/roles.rs new file mode 100644 index 00000000..091470a9 --- /dev/null +++ b/harmony_zitadel_auth/src/roles.rs @@ -0,0 +1,325 @@ +//! Typed role extraction for Zitadel / OIDC JWT bodies. +//! +//! Why this module is paranoid: the role claim is the *only* thing +//! that separates a non-admin Zitadel user from full dashboard +//! control. Earlier drafts walked `serde_json::Value` with a +//! configurable string path (`lookup_claim(value, "a.b.c")`) and a +//! `urn:`-aware splitter — that's a heuristic over untrusted input +//! inside a security boundary, the exact shape that hides +//! injection-style bugs (a misconfigured env var or a Zitadel mapper +//! quirk shifts the lookup to a path the attacker controls). +//! +//! This module replaces all of that with **typed serde +//! deserialization**: +//! +//! - [`Role`] is a closed enum of every role the platform knows about. +//! Adding `fleet-viewer` later is a code change here, not a config +//! flip. Unknown role names emitted by the IdP are silently dropped +//! — they cannot grant access. +//! - [`RoleClaims`] is the wire-side struct that the JWT verifier +//! `flatten`s into its top-level `Claims`. The two well-known claim +//! locations are matched verbatim by `#[serde(rename = "...")]`, +//! not by string-path walking — there is no env-driven path to +//! poison. +//! - [`Roles`] is the domain value carried on [`crate::VerifiedSession`]. +//! Construction is restricted to deserialization. Middleware +//! compares typed [`Role`] values, never strings. +//! +//! No `lookup_claim`. No dotted paths. No `path.contains("urn:")` +//! heuristic. If a future issuer puts roles somewhere new, add a new +//! `#[serde(rename = ...)]` field on [`RoleClaims`] — that's an +//! additive, reviewable, typed code change inside the security +//! boundary. + +use std::collections::{HashMap, HashSet}; + +use serde::Deserialize; + +/// A role the platform recognizes. +/// +/// Closed enum: adding a variant is a deliberate code change to the +/// security path, not a runtime string. Unknown role names emitted by +/// the IdP deserialize to nothing — they cannot grant access through +/// this type. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Role { + /// Full read/write access to the fleet dashboard and the + /// operator's management endpoints. + FleetAdmin, +} + +impl Role { + /// Resolve a wire-format role name to its typed variant. + /// + /// Exhaustive match on purpose: a new [`Role`] variant forces this + /// function to grow a new arm at compile time. Returns `None` for + /// unknown names; those names never grant access. + fn parse(name: &str) -> Option { + match name { + "fleet-admin" => Some(Role::FleetAdmin), + _ => None, + } + } + + /// Wire-format name — what the IdP emits, what the 403 page + /// renders. The mapping is pinned by a test so a typo in the enum + /// can't drift from the wire spelling silently. + pub fn name(&self) -> &'static str { + match self { + Role::FleetAdmin => "fleet-admin", + } + } +} + +impl std::fmt::Display for Role { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.name()) + } +} + +/// The set of roles granted to a verified principal. +/// +/// **Construction is restricted to deserialization** via [`RoleClaims::into_roles`]. +/// There is no public constructor and no string-keyed mutator — +/// anything held here came from a verified JWT body decoded by serde. +/// The middleware checks a typed [`Role`], not a string. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Roles { + granted: HashSet, +} + +impl Roles { + /// Whether the principal holds `role`. + pub fn has(&self, role: Role) -> bool { + self.granted.contains(&role) + } + + /// Iterate granted roles for logging / diagnostics. Read-only — + /// there is no way to add roles outside of deserialization. + pub fn iter(&self) -> impl Iterator + '_ { + self.granted.iter().copied() + } + + pub fn is_empty(&self) -> bool { + self.granted.is_empty() + } + + pub fn len(&self) -> usize { + self.granted.len() + } +} + +/// Construct from a typed iterator of [`Role`] variants. +/// +/// Note: this accepts only [`Role`] values — there is no `From<&str>` +/// or `From>` path. Test fixtures and explicit +/// platform-internal constructions use this; user-supplied data still +/// goes through [`RoleClaims`] deserialization. +impl FromIterator for Roles { + fn from_iter>(iter: I) -> Self { + Roles { + granted: iter.into_iter().collect(), + } + } +} + +/// Role-bearing claims as serde decodes them out of a verified JWT +/// body via `#[serde(flatten)]`. +/// +/// Two well-known locations, both resolved by serde at decode time +/// from an **exact** key match — no path walking, no env-driven +/// string, no heuristic. +/// +/// 1. `urn:zitadel:iam:org:project:roles` — Zitadel's default role +/// claim. Emitted as an object whose **keys** are role names; the +/// values describe the granting organization (we ignore them with +/// [`serde::de::IgnoredAny`] so the decoder never allocates them). +/// 2. `roles` — OIDC-standard flat array used by custom mappers and +/// most non-Zitadel issuers. Used as a fallback when the Zitadel +/// URN is absent. +/// +/// If both are present, Zitadel's URN wins. If neither is present, +/// the resulting [`Roles`] is empty — closed door, never wildcard. +#[derive(Debug, Default, Deserialize)] +pub struct RoleClaims { + #[serde(default, rename = "urn:zitadel:iam:org:project:roles")] + zitadel_roles: Option>, + + #[serde(default, rename = "roles")] + oidc_roles: Option>, +} + +impl RoleClaims { + /// Promote wire claims into the typed domain value. + /// + /// Unknown role names — anything not matched by [`Role::parse`] — + /// are silently dropped. The expected production cardinality is + /// "a handful" of roles per token, so the cost of iterating both + /// shapes is irrelevant. + pub fn into_roles(self) -> Roles { + let granted: HashSet = if let Some(map) = self.zitadel_roles { + map.into_keys().filter_map(|k| Role::parse(&k)).collect() + } else if let Some(arr) = self.oidc_roles { + arr.into_iter().filter_map(|s| Role::parse(&s)).collect() + } else { + HashSet::new() + }; + Roles { granted } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// Round-trip a JSON body through serde — mirrors what the JWT + /// verifier does after signature checks succeed. Returns the + /// resulting typed [`Roles`] for assertions. + fn parse(body: serde_json::Value) -> Roles { + let claims: RoleClaims = + serde_json::from_value(body).expect("test inputs must be RoleClaims-shaped"); + claims.into_roles() + } + + #[test] + fn fleet_admin_from_zitadel_object_map_resolves() { + let roles = parse(json!({ + "urn:zitadel:iam:org:project:roles": { + "fleet-admin": { "org-a": "Org A" } + } + })); + assert!(roles.has(Role::FleetAdmin)); + assert_eq!(roles.len(), 1); + } + + #[test] + fn fleet_admin_from_oidc_array_resolves() { + let roles = parse(json!({ "roles": ["fleet-admin", "device"] })); + assert!(roles.has(Role::FleetAdmin)); + assert_eq!(roles.len(), 1); + } + + #[test] + fn missing_role_claim_yields_empty_roles() { + // Without this guarantee a parse failure could be confused + // with "no roles" and lock everyone out — or worse, be + // treated as a wildcard. Empty `Roles` is the closed-door + // default, asserted here. + let roles = parse(json!({ "sub": "u-1" })); + assert!(roles.is_empty()); + assert!(!roles.has(Role::FleetAdmin)); + } + + #[test] + fn unknown_role_names_are_silently_dropped() { + // An IdP that mints a `superuser` or `*` role cannot escalate + // — only names matched by `Role::parse` become `Role` variants + // in the typed set. + let roles = parse(json!({ + "roles": ["superuser", "*", "fleet-admin", "fleet-admin-typo"] + })); + assert!(roles.has(Role::FleetAdmin)); + assert_eq!(roles.len(), 1); + } + + #[test] + fn zitadel_urn_wins_over_oidc_array_when_both_present() { + // An unusual but legal token. Zitadel's URN is what the + // operator explicitly configured the IdP to emit; the array + // claim is a fallback for non-Zitadel issuers and should NOT + // override a present Zitadel claim. + let roles = parse(json!({ + "urn:zitadel:iam:org:project:roles": { + "fleet-admin": { "org-a": "Org A" } + }, + "roles": [] + })); + assert!(roles.has(Role::FleetAdmin)); + } + + #[test] + fn device_only_session_does_not_carry_fleet_admin() { + // The core authorization gap Chapter 1 closes: a logged-in + // Zitadel user with only the `device` role must NOT pass the + // dashboard's `fleet-admin` gate. + let roles = parse(json!({ + "urn:zitadel:iam:org:project:roles": { + "device": { "org-a": "Org A" } + } + })); + assert!(!roles.has(Role::FleetAdmin)); + assert!(roles.is_empty(), "device is not a Role variant"); + } + + #[test] + fn malformed_zitadel_shape_errors_at_decode() { + // If Zitadel emits a scalar at the URN path (mapper + // misconfigured, breaking IdP upgrade) serde errors here. The + // upstream JWT verifier surfaces that as a verification + // failure, which logs the user out for re-auth — preferable + // to silently treating them as roleless and proceeding. + let res: Result = serde_json::from_value(json!({ + "urn:zitadel:iam:org:project:roles": "fleet-admin" + })); + assert!(res.is_err(), "scalar at URN path must not parse"); + } + + #[test] + fn malformed_oidc_array_with_mixed_types_errors_at_decode() { + // The OIDC `roles` claim is typed `Vec`. A mixed-type + // array (`["fleet-admin", 42]`) errors at decode rather than + // silently dropping the bad entries — fail-loud is the + // security-correct default when the IdP misbehaves. + let res: Result = serde_json::from_value(json!({ + "roles": ["fleet-admin", 42, null] + })); + assert!(res.is_err(), "mixed-type array must not parse"); + } + + #[test] + fn empty_object_at_zitadel_path_yields_empty_roles() { + let roles = parse(json!({ + "urn:zitadel:iam:org:project:roles": {} + })); + assert!(roles.is_empty()); + } + + #[test] + fn empty_array_at_oidc_path_yields_empty_roles() { + let roles = parse(json!({ "roles": [] })); + assert!(roles.is_empty()); + } + + #[test] + fn role_display_matches_wire_name() { + // Pin the wire spelling. If anyone edits `Role::name()` to + // return a different string than what the IdP emits, the + // 403 page misleads the user about which role to ask for — + // and worse, the gate would silently never match. + assert_eq!(Role::FleetAdmin.to_string(), "fleet-admin"); + assert_eq!(Role::FleetAdmin.name(), "fleet-admin"); + } + + #[test] + fn extra_unrelated_claims_do_not_disturb_role_extraction() { + // Real JWT bodies have many fields (`iss`, `aud`, `exp`, + // `email`, ...). The decoder must ignore everything it + // doesn't rename to a `RoleClaims` field — serde's default + // behaviour, asserted here as a regression canary against a + // future `#[serde(deny_unknown_fields)]` slipping in and + // breaking JWT decoding on every real token. + let roles = parse(json!({ + "iss": "https://zitadel.example/", + "aud": ["dashboard"], + "exp": 9_999_999_999i64, + "sub": "u-1", + "email": "admin@example.com", + "name": "Admin", + "urn:zitadel:iam:org:project:roles": { + "fleet-admin": { "org-a": "Org A" } + } + })); + assert!(roles.has(Role::FleetAdmin)); + } +} diff --git a/harmony_zitadel_auth/src/session.rs b/harmony_zitadel_auth/src/session.rs index 8e5f73c3..545aa844 100644 --- a/harmony_zitadel_auth/src/session.rs +++ b/harmony_zitadel_auth/src/session.rs @@ -1,6 +1,15 @@ use serde::{Deserialize, Serialize}; +use crate::roles::Roles; + /// Claims extracted from a verified session cookie JWT on each request. +/// +/// `roles` is captured here (rather than re-extracted on demand) so the +/// role-enforcement middleware introduced in v0.3 Chapter 1 doesn't +/// have to re-decode the JWT body. It is a typed [`Roles`] value, not a +/// `Vec` — middleware checks named [`crate::Role`] variants +/// rather than comparing strings, eliminating the string-comparison +/// surface inside the security boundary. #[derive(Debug, Clone)] pub struct VerifiedSession { pub subject: String, @@ -9,6 +18,12 @@ pub struct VerifiedSession { pub expires_at: i64, /// OIDC nonce from the ID token, used to bind callback tokens to login attempts. pub nonce: Option, + /// Roles granted by the verified JWT. Constructed only via serde + /// deserialization of a [`crate::RoleClaims`] flatten on the JWT + /// body — no string-keyed mutation path exists. Empty when the IdP + /// emitted no recognized role; callers MUST treat empty as "no + /// roles", never as a wildcard. + pub roles: Roles, } /// PKCE state persisted in the encrypted login-attempt cookie during the