From 73fd52a9f70e708c50d86be4571ee2d03a417ac5 Mon Sep 17 00:00:00 2001 From: charming_wicket <235307685+charming-wicket-5502@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:37:57 +0200 Subject: [PATCH 01/19] data layer and request store --- admin-ui/src/types/api.ts | 35 ++- client/src/lib.rs | 2 +- client/src/models/base.rs | 3 + client/src/params/mod.rs | 3 + client/src/params/realm_auth_params.rs | 4 +- client/src/params/saml_params.rs | 85 ++++++++ server/documentation/openapi.yaml | 70 +++++- server/src/database/impls/mysql.rs | 3 + server/src/database/impls/postgres.rs | 3 + server/src/database/impls/sqlite.rs | 3 + server/src/lib.rs | 5 + server/src/saml/factory.rs | 83 +++++++ server/src/saml/impls/mod.rs | 11 + server/src/saml/impls/mysql.rs | 183 ++++++++++++++++ server/src/saml/impls/postgres.rs | 172 +++++++++++++++ server/src/saml/impls/redis.rs | 108 ++++++++++ server/src/saml/impls/sqlite.rs | 288 +++++++++++++++++++++++++ server/src/saml/mod.rs | 8 + server/src/saml/request_store.rs | 50 +++++ 19 files changed, 1114 insertions(+), 5 deletions(-) create mode 100644 client/src/params/saml_params.rs create mode 100644 server/src/saml/factory.rs create mode 100644 server/src/saml/impls/mod.rs create mode 100644 server/src/saml/impls/mysql.rs create mode 100644 server/src/saml/impls/postgres.rs create mode 100644 server/src/saml/impls/redis.rs create mode 100644 server/src/saml/impls/sqlite.rs create mode 100644 server/src/saml/mod.rs create mode 100644 server/src/saml/request_store.rs diff --git a/admin-ui/src/types/api.ts b/admin-ui/src/types/api.ts index a6547f75..6b29641f 100644 --- a/admin-ui/src/types/api.ts +++ b/admin-ui/src/types/api.ts @@ -24,11 +24,44 @@ export interface TotpRealmParams { step?: number; } +/** SAML 2.0 Service Provider parameters for a realm (SP-initiated Web Browser SSO) */ +export interface SamlParams { + /** IdP entityID; server-derived from metadata_xml. */ + idp_entity_id: string; + /** IdP HTTP-Redirect Single Sign-On Service URL; server-derived from metadata_xml. */ + idp_sso_url: string; + /** PEM-encoded IdP signing certificate(s); server-derived from metadata_xml. */ + idp_signing_certificates: string[]; + /** Expected/requested Format URI. */ + idp_nameid_format?: string; + /** Raw IdP metadata XML supplied by the admin; the IdP fields above are derived from it. */ + metadata_xml?: string; + /** This SP's entityID, advertised in published SP metadata. */ + sp_entity_id: string; + /** This SP's Assertion Consumer Service URL (/saml/{realm_id}/acs). */ + sp_acs_url: string; + /** Reference to the server-configured AuthnRequest signing key; unset = server default. */ + sp_signing_key?: string; + /** SAML attribute whose single value becomes the subject; when unset the is used. */ + subject_attribute?: string; + /** Lowercase the resolved subject before use. */ + normalize_subject_case: boolean; + /** SAML attribute whose values populate the JWT roles claim (pass-through). */ + role_attribute?: string; + /** Allowlist: SAML attribute name → session JWT extra-claim name. */ + attribute_claim_map: Record; + /** Allowed return-URL origins (scheme://host[:port]); any path under an approved origin. */ + allowed_return_origins: string[]; + /** Default post-login redirect URL, used when no return_to is supplied. */ + default_return_url: string; +} + /** Authentication parameters for a realm */ export interface RealmAuthParams { jwt_params: JwtParams | null; username_password_params: UsernamePasswordParams | null; totp_params: TotpRealmParams | null; + saml_params: SamlParams | null; } /** Realm configuration */ @@ -40,7 +73,7 @@ export interface Realm { } /** Authentication scheme identifiers */ -export type AuthScheme = "up" | "jwt" | "cc" | "f2" | "dc"; +export type AuthScheme = "up" | "jwt" | "cc" | "f2" | "dc" | "sa"; /** Admin account */ export interface Admin { diff --git a/client/src/lib.rs b/client/src/lib.rs index c8281e26..8b2b66b7 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -56,4 +56,4 @@ pub use models::{ }; mod params; -pub use params::{IdpParams, JwtParams, RealmAuthParams, TotpRealmParams, UsernamePasswordParams}; +pub use params::{IdpParams, JwtParams, RealmAuthParams, SamlParams, TotpRealmParams, UsernamePasswordParams}; diff --git a/client/src/models/base.rs b/client/src/models/base.rs index 8f7a0f73..d3bbe9ad 100644 --- a/client/src/models/base.rs +++ b/client/src/models/base.rs @@ -134,6 +134,9 @@ pub enum AuthScheme { #[serde(rename = "cc")] ClientCertificate, + + #[serde(rename = "sa")] + Saml, } /// The Data of a Session stored in the session store. diff --git a/client/src/params/mod.rs b/client/src/params/mod.rs index 060269b4..00ce8519 100644 --- a/client/src/params/mod.rs +++ b/client/src/params/mod.rs @@ -9,3 +9,6 @@ pub use totp_params::TotpRealmParams; mod realm_auth_params; pub use realm_auth_params::RealmAuthParams; + +mod saml_params; +pub use saml_params::SamlParams; diff --git a/client/src/params/realm_auth_params.rs b/client/src/params/realm_auth_params.rs index 5f86191e..6d126d55 100644 --- a/client/src/params/realm_auth_params.rs +++ b/client/src/params/realm_auth_params.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use crate::{JwtParams, TotpRealmParams, UsernamePasswordParams}; +use crate::{JwtParams, SamlParams, TotpRealmParams, UsernamePasswordParams}; #[derive(Clone, Debug, Serialize, Deserialize, Default)] pub struct RealmAuthParams { @@ -8,4 +8,6 @@ pub struct RealmAuthParams { pub username_password_params: Option, #[serde(skip_serializing_if = "Option::is_none")] pub totp_params: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub saml_params: Option, } diff --git a/client/src/params/saml_params.rs b/client/src/params/saml_params.rs new file mode 100644 index 00000000..46a81370 --- /dev/null +++ b/client/src/params/saml_params.rs @@ -0,0 +1,85 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +/// Per-realm SAML 2.0 Service Provider configuration for SP-initiated Web Browser SSO. +/// +/// Stored inside [`crate::RealmAuthParams`] and serialized into the realm's `auth_params` +/// column. The IdP-side fields (`idp_entity_id`, `idp_sso_url`, `idp_signing_certificates`, +/// `idp_nameid_format`) are **server-derived**: they are parsed and populated from +/// `metadata_xml` when the realm is created or updated, not supplied directly by the caller. +/// Callers provide `metadata_xml`, the SP-side fields, the identity mapping, and the +/// return-URL policy. +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct SamlParams { + // ── IdP side — derived from `metadata_xml` at ingestion ─────────────────────────── + /// IdP entityID; the `` value required on incoming responses. Parsed from + /// `metadata_xml`. + #[serde(default)] + pub idp_entity_id: String, + + /// IdP HTTP-Redirect Single Sign-On Service URL that ``s are sent to. + /// Parsed from `metadata_xml`. + #[serde(default)] + pub idp_sso_url: String, + + /// PEM-encoded IdP signing certificate(s) used to verify assertion/response signatures. + /// Parsed from `metadata_xml`. + #[serde(default)] + pub idp_signing_certificates: Vec, + + /// Expected/requested `` Format URI (e.g. persistent, emailAddress). Parsed + /// from `metadata_xml` when the IdP advertises one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub idp_nameid_format: Option, + + /// Raw IdP metadata XML as supplied by the admin, retained for reference and re-parsing. + /// Required on input; the structured IdP fields above are derived from it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata_xml: Option, + + // ── SP side — this server's identity toward the IdP ─────────────────────────────── + /// This SP's entityID, advertised in our published SP metadata. + pub sp_entity_id: String, + + /// This SP's Assertion Consumer Service URL (`/saml/{realm_id}/acs`), advertised in + /// our published SP metadata. + pub sp_acs_url: String, + + /// Reference to the server-configured signing key used to sign our `` and + /// published in our SP metadata. `None` uses the server's default SP signing key. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sp_signing_key: Option, + + // ── Identity mapping — assertion → session claims ───────────────────────────────── + /// SAML attribute whose single value becomes the session subject. When unset, the + /// `` is used instead; a `transient` NameID with no subject attribute is + /// rejected (it is not a stable subject). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subject_attribute: Option, + + /// Lowercase the resolved subject before use, for IdPs that are inconsistent about + /// casing. Off by default. + #[serde(default)] + pub normalize_subject_case: bool, + + /// SAML attribute whose values populate the JWT `roles` claim, passed through as-is + /// (no server-side RBAC filtering). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub role_attribute: Option, + + /// Allowlist mapping SAML attribute name → session JWT extra-claim name. Only listed + /// attributes are copied into the session; each is validated against the reserved claim + /// names and the extra-claims size budget. + #[serde(default)] + pub attribute_claim_map: HashMap, + + // ── Post-login redirect — open-redirect defense ─────────────────────────────────── + /// Allowed return-URL origins (`scheme://host[:port]`); any path under an approved + /// origin is permitted. A `return_to` not matching one of these is rejected. + #[serde(default)] + pub allowed_return_origins: Vec, + + /// Default post-login redirect URL, used when no `return_to` is supplied. + pub default_return_url: String, +} diff --git a/server/documentation/openapi.yaml b/server/documentation/openapi.yaml index ce39939a..4119def7 100644 --- a/server/documentation/openapi.yaml +++ b/server/documentation/openapi.yaml @@ -92,7 +92,7 @@ components: AuthScheme: type: string description: Wire representation of the authentication scheme. - enum: [up, jwt, cc, f2, dc] + enum: [up, jwt, cc, f2, dc, sa] AuthenticatedClientScheme: type: object @@ -103,7 +103,7 @@ components: example: alice auth_scheme: type: string - enum: [UsernamePassword, Jwt, ClientCertificate, Fido2, DigitalCredentials] + enum: [UsernamePassword, Jwt, ClientCertificate, Fido2, DigitalCredentials, Saml] example: username: alice auth_scheme: UsernamePassword @@ -171,9 +171,75 @@ components: default: 30 example: 30 + SamlParams: + type: object + required: [sp_entity_id, sp_acs_url, default_return_url] + properties: + idp_entity_id: + type: string + description: IdP entityID (the required on responses); server-derived from metadata_xml. + idp_sso_url: + type: string + format: uri + description: IdP HTTP-Redirect Single Sign-On Service URL; server-derived from metadata_xml. + idp_signing_certificates: + type: array + items: + type: string + description: PEM-encoded IdP signing certificate(s); server-derived from metadata_xml. + idp_nameid_format: + type: string + nullable: true + description: Expected/requested Format URI. + metadata_xml: + type: string + nullable: true + description: Raw IdP metadata XML supplied by the admin; the IdP fields above are derived from it. + sp_entity_id: + type: string + description: This SP's entityID, advertised in published SP metadata. + sp_acs_url: + type: string + format: uri + description: This SP's Assertion Consumer Service URL (/saml/{realm_id}/acs). + sp_signing_key: + type: string + nullable: true + description: Reference to the server-configured AuthnRequest signing key; unset uses the server default. + subject_attribute: + type: string + nullable: true + description: SAML attribute whose single value becomes the session subject; when unset the is used. + normalize_subject_case: + type: boolean + default: false + description: Lowercase the resolved subject before use. + role_attribute: + type: string + nullable: true + description: SAML attribute whose values populate the JWT roles claim (pass-through). + attribute_claim_map: + type: object + additionalProperties: + type: string + description: Allowlist mapping SAML attribute name to session JWT extra-claim name. + allowed_return_origins: + type: array + items: + type: string + description: Allowed return-URL origins (scheme://host[:port]); any path under an approved origin is permitted. + default_return_url: + type: string + format: uri + description: Default post-login redirect URL, used when no return_to is supplied. + RealmAuthParams: type: object properties: + saml_params: + allOf: + - $ref: '#/components/schemas/SamlParams' + nullable: true jwt_params: allOf: - $ref: '#/components/schemas/JwtParams' diff --git a/server/src/database/impls/mysql.rs b/server/src/database/impls/mysql.rs index aae25e2b..0c0df96a 100644 --- a/server/src/database/impls/mysql.rs +++ b/server/src/database/impls/mysql.rs @@ -943,6 +943,9 @@ impl Database for MySqlDatabase { "SELECT id FROM admin WHERE certificate = ?", value.to_string(), ), + // SAML is a user-facing realm auth scheme, not an admin credential: no admin + // authenticates via SAML, so there is never a matching admin row. + AuthScheme::Saml => return Ok(Vec::new()), }; let rows = sqlx::query(query) diff --git a/server/src/database/impls/postgres.rs b/server/src/database/impls/postgres.rs index def3ed0a..cbb39fef 100644 --- a/server/src/database/impls/postgres.rs +++ b/server/src/database/impls/postgres.rs @@ -907,6 +907,9 @@ impl Database for PostgresDatabase { "SELECT id FROM admin WHERE certificate = $1", value.to_string(), ), + // SAML is a user-facing realm auth scheme, not an admin credential: no admin + // authenticates via SAML, so there is never a matching admin row. + AuthScheme::Saml => return Ok(Vec::new()), }; let rows = sqlx::query(query) diff --git a/server/src/database/impls/sqlite.rs b/server/src/database/impls/sqlite.rs index f58a7601..47262e63 100644 --- a/server/src/database/impls/sqlite.rs +++ b/server/src/database/impls/sqlite.rs @@ -950,6 +950,9 @@ impl Database for SqliteDatabase { AuthScheme::Fido2 => "SELECT id FROM admin WHERE fido2 = ?", AuthScheme::DigitalCredentials => "SELECT id FROM admin WHERE digital_credentials =?", AuthScheme::ClientCertificate => "SELECT id FROM admin WHERE certificate = ?", + // SAML is a user-facing realm auth scheme, not an admin credential: no admin + // authenticates via SAML, so there is never a matching admin row. + AuthScheme::Saml => return Ok(Vec::new()), }; let rows = sqlx::query(query).bind(value).fetch_all(&self.pool).await?; diff --git a/server/src/lib.rs b/server/src/lib.rs index 5a017570..d2460fc8 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -30,6 +30,11 @@ pub use session::{ }; pub use session::{build_cookie, delete_cookie}; +mod saml; +pub use saml::{ + PendingSamlRequest, SamlRequestStore, SqliteSamlRequestStore, create_saml_request_store, +}; + pub mod tls; pub mod totp; diff --git a/server/src/saml/factory.rs b/server/src/saml/factory.rs new file mode 100644 index 00000000..8bda8181 --- /dev/null +++ b/server/src/saml/factory.rs @@ -0,0 +1,83 @@ +use std::{sync::Arc, time::Duration}; + +use crate::{ + AuthError, AuthResult, + saml::{ + SamlRequestStore, + impls::{ + MySqlSamlRequestStore, PostgresSamlRequestStore, RedisSamlRequestStore, + SqliteSamlRequestStore, + }, + }, + server::parameters::{DatabaseBackend, DatabaseParams}, +}; + +/// Create a [`SamlRequestStore`] for the configured database backend. +/// +/// Mirrors the session-store factory: it opens its own pool/connection, initializing the +/// SAML tables when `auto_init_schema` is set. SQL backends accumulate expired rows until +/// [`SamlRequestStore::delete_expired`] is called; Redis expires them automatically via TTL. +pub async fn create_saml_request_store( + params: &DatabaseParams, +) -> AuthResult> { + match params.backend { + DatabaseBackend::PostgreSQL => { + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(params.max_connections) + .min_connections(params.min_connections) + .acquire_timeout(Duration::from_secs(params.connect_timeout_secs)) + .idle_timeout(Duration::from_secs(params.idle_timeout_secs)) + .connect(¶ms.connection_url) + .await + .map_err(|e| AuthError::Init(format!("Failed to connect to PostgreSQL: {e}")))?; + let store = PostgresSamlRequestStore::new(pool); + if params.auto_init_schema { + store.init().await?; + } + Ok(Arc::new(store)) + } + DatabaseBackend::SQLite => { + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(params.max_connections) + .min_connections(params.min_connections) + .acquire_timeout(Duration::from_secs(params.connect_timeout_secs)) + .idle_timeout(Duration::from_secs(params.idle_timeout_secs)) + .connect(¶ms.connection_url) + .await + .map_err(|e| AuthError::Init(format!("Failed to connect to SQLite: {e}")))?; + let store = SqliteSamlRequestStore::new(pool); + if params.auto_init_schema { + store.init().await?; + } + Ok(Arc::new(store)) + } + DatabaseBackend::MySQL => { + let pool = sqlx::mysql::MySqlPoolOptions::new() + .max_connections(params.max_connections) + .min_connections(params.min_connections) + .acquire_timeout(Duration::from_secs(params.connect_timeout_secs)) + .idle_timeout(Duration::from_secs(params.idle_timeout_secs)) + .connect(¶ms.connection_url) + .await + .map_err(|e| AuthError::Init(format!("Failed to connect to MySQL: {e}")))?; + let store = MySqlSamlRequestStore::new(pool); + if params.auto_init_schema { + store.init().await?; + } + Ok(Arc::new(store)) + } + DatabaseBackend::Redis => { + let client = redis::Client::open(params.connection_url.as_str()) + .map_err(|e| AuthError::Init(format!("Failed to create Redis client: {e}")))?; + let mut conn = client + .get_multiplexed_async_connection() + .await + .map_err(|e| AuthError::Init(format!("Failed to connect to Redis: {e}")))?; + let _: String = redis::cmd("PING") + .query_async(&mut conn) + .await + .map_err(|e| AuthError::Init(format!("Failed to ping Redis: {e}")))?; + Ok(Arc::new(RedisSamlRequestStore::new(client))) + } + } +} diff --git a/server/src/saml/impls/mod.rs b/server/src/saml/impls/mod.rs new file mode 100644 index 00000000..44930312 --- /dev/null +++ b/server/src/saml/impls/mod.rs @@ -0,0 +1,11 @@ +mod mysql; +pub use mysql::MySqlSamlRequestStore; + +mod postgres; +pub use postgres::PostgresSamlRequestStore; + +mod redis; +pub use redis::RedisSamlRequestStore; + +mod sqlite; +pub use sqlite::SqliteSamlRequestStore; diff --git a/server/src/saml/impls/mysql.rs b/server/src/saml/impls/mysql.rs new file mode 100644 index 00000000..c9fd959d --- /dev/null +++ b/server/src/saml/impls/mysql.rs @@ -0,0 +1,183 @@ +use async_trait::async_trait; +use chrono::Utc; +use sqlx::{MySqlPool, Row, mysql::MySqlRow}; + +use crate::{ + AuthError, AuthResult, + saml::request_store::{PendingSamlRequest, SamlRequestStore}, +}; + +/// MySQL-backed store for pending SAML requests and the assertion replay cache. +pub struct MySqlSamlRequestStore { + pool: MySqlPool, +} + +impl MySqlSamlRequestStore { + pub fn new(pool: MySqlPool) -> Self { + Self { pool } + } + + /// Create the pending-request and replay-cache tables (indexes declared inline, MySQL-style). + pub async fn init(&self) -> AuthResult<()> { + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS saml_pending_request ( + request_id VARCHAR(255) PRIMARY KEY, + realm_id VARCHAR(255) NOT NULL, + return_url TEXT NOT NULL, + created_at BIGINT NOT NULL, + expires_at BIGINT NOT NULL, + INDEX idx_saml_pending_expires_at (expires_at) + ) + "#, + ) + .execute(&self.pool) + .await + .map_err(|e| { + AuthError::Generic(format!("Failed to create saml_pending_request table: {e}")) + })?; + + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS saml_seen_assertion ( + assertion_id VARCHAR(255) PRIMARY KEY, + expires_at BIGINT NOT NULL, + INDEX idx_saml_seen_expires_at (expires_at) + ) + "#, + ) + .execute(&self.pool) + .await + .map_err(|e| { + AuthError::Generic(format!("Failed to create saml_seen_assertion table: {e}")) + })?; + + Ok(()) + } +} + +fn row_to_pending(row: MySqlRow) -> AuthResult { + Ok(PendingSamlRequest { + request_id: row + .try_get("request_id") + .map_err(|e| AuthError::Generic(format!("bad request_id column: {e}")))?, + realm_id: row + .try_get("realm_id") + .map_err(|e| AuthError::Generic(format!("bad realm_id column: {e}")))?, + return_url: row + .try_get("return_url") + .map_err(|e| AuthError::Generic(format!("bad return_url column: {e}")))?, + created_at: row + .try_get("created_at") + .map_err(|e| AuthError::Generic(format!("bad created_at column: {e}")))?, + expires_at: row + .try_get("expires_at") + .map_err(|e| AuthError::Generic(format!("bad expires_at column: {e}")))?, + }) +} + +#[async_trait] +impl SamlRequestStore for MySqlSamlRequestStore { + async fn store_pending_request(&self, request: &PendingSamlRequest) -> AuthResult<()> { + sqlx::query( + r#" + INSERT INTO saml_pending_request (request_id, realm_id, return_url, created_at, expires_at) + VALUES (?, ?, ?, ?, ?) + "#, + ) + .bind(&request.request_id) + .bind(&request.realm_id) + .bind(&request.return_url) + .bind(request.created_at) + .bind(request.expires_at) + .execute(&self.pool) + .await + .map_err(|e| AuthError::Generic(format!("Failed to store SAML pending request: {e}")))?; + Ok(()) + } + + async fn take_pending_request( + &self, + request_id: &str, + realm_id: &str, + ) -> AuthResult> { + let now = Utc::now().timestamp(); + + // MySQL has no `DELETE ... RETURNING`, so `SELECT ... FOR UPDATE` then `DELETE` inside a + // single transaction gives the same atomic single-use guarantee: the row lock prevents a + // concurrent ACS post from also reading it before the delete commits. + let mut tx = self + .pool + .begin() + .await + .map_err(|e| AuthError::Generic(format!("Failed to begin transaction: {e}")))?; + + let row = sqlx::query( + r#" + SELECT request_id, realm_id, return_url, created_at, expires_at + FROM saml_pending_request + WHERE request_id = ? AND realm_id = ? AND expires_at > ? + FOR UPDATE + "#, + ) + .bind(request_id) + .bind(realm_id) + .bind(now) + .fetch_optional(&mut *tx) + .await + .map_err(|e| AuthError::Generic(format!("Failed to select SAML pending request: {e}")))?; + + let result = match row { + None => None, + Some(row) => { + sqlx::query("DELETE FROM saml_pending_request WHERE request_id = ?") + .bind(request_id) + .execute(&mut *tx) + .await + .map_err(|e| { + AuthError::Generic(format!("Failed to delete SAML pending request: {e}")) + })?; + Some(row_to_pending(row)?) + } + }; + + tx.commit() + .await + .map_err(|e| AuthError::Generic(format!("Failed to commit transaction: {e}")))?; + + Ok(result) + } + + async fn record_assertion_id(&self, assertion_id: &str, expires_at: i64) -> AuthResult { + // INSERT IGNORE: the first insert for an assertion id wins (1 row affected); a replay + // collides on the primary key and affects 0 rows. + let result = sqlx::query( + "INSERT IGNORE INTO saml_seen_assertion (assertion_id, expires_at) VALUES (?, ?)", + ) + .bind(assertion_id) + .bind(expires_at) + .execute(&self.pool) + .await + .map_err(|e| AuthError::Generic(format!("Failed to record SAML assertion id: {e}")))?; + Ok(result.rows_affected() > 0) + } + + async fn delete_expired(&self) -> AuthResult<()> { + let now = Utc::now().timestamp(); + sqlx::query("DELETE FROM saml_pending_request WHERE expires_at <= ?") + .bind(now) + .execute(&self.pool) + .await + .map_err(|e| { + AuthError::Generic(format!("Failed to delete expired SAML pending requests: {e}")) + })?; + sqlx::query("DELETE FROM saml_seen_assertion WHERE expires_at <= ?") + .bind(now) + .execute(&self.pool) + .await + .map_err(|e| { + AuthError::Generic(format!("Failed to delete expired SAML assertion ids: {e}")) + })?; + Ok(()) + } +} diff --git a/server/src/saml/impls/postgres.rs b/server/src/saml/impls/postgres.rs new file mode 100644 index 00000000..e4a07d78 --- /dev/null +++ b/server/src/saml/impls/postgres.rs @@ -0,0 +1,172 @@ +use async_trait::async_trait; +use chrono::Utc; +use sqlx::{PgPool, Row, postgres::PgRow}; + +use crate::{ + AuthError, AuthResult, + saml::request_store::{PendingSamlRequest, SamlRequestStore}, +}; + +/// PostgreSQL-backed store for pending SAML requests and the assertion replay cache. +pub struct PostgresSamlRequestStore { + pool: PgPool, +} + +impl PostgresSamlRequestStore { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } + + /// Create the pending-request and replay-cache tables and their cleanup indexes. + pub async fn init(&self) -> AuthResult<()> { + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS saml_pending_request ( + request_id TEXT PRIMARY KEY, + realm_id TEXT NOT NULL, + return_url TEXT NOT NULL, + created_at BIGINT NOT NULL, + expires_at BIGINT NOT NULL + ) + "#, + ) + .execute(&self.pool) + .await + .map_err(|e| { + AuthError::Generic(format!("Failed to create saml_pending_request table: {e}")) + })?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_saml_pending_expires_at ON saml_pending_request(expires_at)", + ) + .execute(&self.pool) + .await + .map_err(|e| { + AuthError::Generic(format!("Failed to create saml_pending_request index: {e}")) + })?; + + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS saml_seen_assertion ( + assertion_id TEXT PRIMARY KEY, + expires_at BIGINT NOT NULL + ) + "#, + ) + .execute(&self.pool) + .await + .map_err(|e| { + AuthError::Generic(format!("Failed to create saml_seen_assertion table: {e}")) + })?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_saml_seen_expires_at ON saml_seen_assertion(expires_at)", + ) + .execute(&self.pool) + .await + .map_err(|e| { + AuthError::Generic(format!("Failed to create saml_seen_assertion index: {e}")) + })?; + + Ok(()) + } +} + +fn row_to_pending(row: PgRow) -> AuthResult { + Ok(PendingSamlRequest { + request_id: row + .try_get("request_id") + .map_err(|e| AuthError::Generic(format!("bad request_id column: {e}")))?, + realm_id: row + .try_get("realm_id") + .map_err(|e| AuthError::Generic(format!("bad realm_id column: {e}")))?, + return_url: row + .try_get("return_url") + .map_err(|e| AuthError::Generic(format!("bad return_url column: {e}")))?, + created_at: row + .try_get("created_at") + .map_err(|e| AuthError::Generic(format!("bad created_at column: {e}")))?, + expires_at: row + .try_get("expires_at") + .map_err(|e| AuthError::Generic(format!("bad expires_at column: {e}")))?, + }) +} + +#[async_trait] +impl SamlRequestStore for PostgresSamlRequestStore { + async fn store_pending_request(&self, request: &PendingSamlRequest) -> AuthResult<()> { + sqlx::query( + r#" + INSERT INTO saml_pending_request (request_id, realm_id, return_url, created_at, expires_at) + VALUES ($1, $2, $3, $4, $5) + "#, + ) + .bind(&request.request_id) + .bind(&request.realm_id) + .bind(&request.return_url) + .bind(request.created_at) + .bind(request.expires_at) + .execute(&self.pool) + .await + .map_err(|e| AuthError::Generic(format!("Failed to store SAML pending request: {e}")))?; + Ok(()) + } + + async fn take_pending_request( + &self, + request_id: &str, + realm_id: &str, + ) -> AuthResult> { + let now = Utc::now().timestamp(); + // DELETE ... RETURNING is atomic fetch-and-delete: single-use, so a replayed + // `InResponseTo` can't correlate twice. + let row = sqlx::query( + r#" + DELETE FROM saml_pending_request + WHERE request_id = $1 AND realm_id = $2 AND expires_at > $3 + RETURNING request_id, realm_id, return_url, created_at, expires_at + "#, + ) + .bind(request_id) + .bind(realm_id) + .bind(now) + .fetch_optional(&self.pool) + .await + .map_err(|e| AuthError::Generic(format!("Failed to take SAML pending request: {e}")))?; + + row.map(row_to_pending).transpose() + } + + async fn record_assertion_id(&self, assertion_id: &str, expires_at: i64) -> AuthResult { + // ON CONFLICT DO NOTHING: the first insert for an assertion id wins (1 row affected); + // a replay conflicts and affects 0 rows. + let result = sqlx::query( + "INSERT INTO saml_seen_assertion (assertion_id, expires_at) VALUES ($1, $2) ON CONFLICT (assertion_id) DO NOTHING", + ) + .bind(assertion_id) + .bind(expires_at) + .execute(&self.pool) + .await + .map_err(|e| AuthError::Generic(format!("Failed to record SAML assertion id: {e}")))?; + Ok(result.rows_affected() > 0) + } + + async fn delete_expired(&self) -> AuthResult<()> { + let now = Utc::now().timestamp(); + sqlx::query("DELETE FROM saml_pending_request WHERE expires_at <= $1") + .bind(now) + .execute(&self.pool) + .await + .map_err(|e| { + AuthError::Generic(format!("Failed to delete expired SAML pending requests: {e}")) + })?; + sqlx::query("DELETE FROM saml_seen_assertion WHERE expires_at <= $1") + .bind(now) + .execute(&self.pool) + .await + .map_err(|e| { + AuthError::Generic(format!("Failed to delete expired SAML assertion ids: {e}")) + })?; + Ok(()) + } +} diff --git a/server/src/saml/impls/redis.rs b/server/src/saml/impls/redis.rs new file mode 100644 index 00000000..24ad120f --- /dev/null +++ b/server/src/saml/impls/redis.rs @@ -0,0 +1,108 @@ +use async_trait::async_trait; +use chrono::Utc; +use redis::{AsyncCommands, Client}; +use std::sync::Arc; + +use crate::{ + AuthError, AuthResult, + saml::request_store::{PendingSamlRequest, SamlRequestStore}, +}; + +/// Redis/ValKey-backed store for pending SAML requests and the assertion replay cache. +/// +/// Both kinds of state carry a TTL, so Redis expires them automatically: +/// - `saml:pending:{realm_id}:{request_id}` -> JSON `PendingSamlRequest` +/// - `saml:seen:{assertion_id}` -> a marker set only while the id must be remembered +pub struct RedisSamlRequestStore { + client: Arc, +} + +impl RedisSamlRequestStore { + pub fn new(client: Client) -> Self { + Self { + client: Arc::new(client), + } + } + + fn pending_key(realm_id: &str, request_id: &str) -> String { + format!("saml:pending:{realm_id}:{request_id}") + } + + fn seen_key(assertion_id: &str) -> String { + format!("saml:seen:{assertion_id}") + } + + async fn conn(&self) -> AuthResult { + self.client + .get_multiplexed_async_connection() + .await + .map_err(|e| AuthError::Generic(format!("Failed to get Redis connection: {e}"))) + } +} + +#[async_trait] +impl SamlRequestStore for RedisSamlRequestStore { + async fn store_pending_request(&self, request: &PendingSamlRequest) -> AuthResult<()> { + let mut conn = self.conn().await?; + let key = Self::pending_key(&request.realm_id, &request.request_id); + let json = serde_json::to_string(request).map_err(|e| { + AuthError::Generic(format!("Failed to serialize SAML pending request: {e}")) + })?; + // TTL bounds the request lifetime; Redis purges it automatically once it lapses. + let ttl = (request.expires_at - Utc::now().timestamp()).max(1); + let _: () = conn + .set_ex(&key, json, ttl as u64) + .await + .map_err(|e| AuthError::Generic(format!("Failed to store SAML pending request: {e}")))?; + Ok(()) + } + + async fn take_pending_request( + &self, + request_id: &str, + realm_id: &str, + ) -> AuthResult> { + let mut conn = self.conn().await?; + let key = Self::pending_key(realm_id, request_id); + // GETDEL is atomic fetch-and-delete (Redis 6.2+), enforcing single use; the realm is + // part of the key, and expired requests are already gone via TTL. + let json: Option = redis::cmd("GETDEL") + .arg(&key) + .query_async(&mut conn) + .await + .map_err(|e| AuthError::Generic(format!("Failed to take SAML pending request: {e}")))?; + + match json { + None => Ok(None), + Some(json) => { + let request = serde_json::from_str(&json).map_err(|e| { + AuthError::Generic(format!("Failed to deserialize SAML pending request: {e}")) + })?; + Ok(Some(request)) + } + } + } + + async fn record_assertion_id(&self, assertion_id: &str, expires_at: i64) -> AuthResult { + let mut conn = self.conn().await?; + let key = Self::seen_key(assertion_id); + let ttl = (expires_at - Utc::now().timestamp()).max(1); + // `SET key 1 NX EX ttl` sets only if the key is absent: a reply of OK means newly + // recorded, a nil reply means the id was already seen (a replay). One atomic command. + let set: Option = redis::cmd("SET") + .arg(&key) + .arg(1) + .arg("NX") + .arg("EX") + .arg(ttl) + .query_async(&mut conn) + .await + .map_err(|e| AuthError::Generic(format!("Failed to record SAML assertion id: {e}")))?; + Ok(set.is_some()) + } + + async fn delete_expired(&self) -> AuthResult<()> { + // Redis expires both key families automatically via their TTLs; nothing to purge. + Ok(()) + } +} diff --git a/server/src/saml/impls/sqlite.rs b/server/src/saml/impls/sqlite.rs new file mode 100644 index 00000000..a0f172fb --- /dev/null +++ b/server/src/saml/impls/sqlite.rs @@ -0,0 +1,288 @@ +use async_trait::async_trait; +use chrono::Utc; +use sqlx::{Row, SqlitePool}; + +use crate::{ + AuthError, AuthResult, + saml::request_store::{PendingSamlRequest, SamlRequestStore}, +}; + +/// SQLite-backed store for pending SAML `AuthnRequest`s and the assertion replay cache. +pub struct SqliteSamlRequestStore { + pool: SqlitePool, +} + +impl SqliteSamlRequestStore { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } + + /// Create the pending-request and replay-cache tables and their cleanup indexes. + pub async fn init(&self) -> AuthResult<()> { + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS saml_pending_request ( + request_id TEXT PRIMARY KEY, + realm_id TEXT NOT NULL, + return_url TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL + ) + "#, + ) + .execute(&self.pool) + .await + .map_err(|e| { + AuthError::Generic(format!("Failed to create saml_pending_request table: {e}")) + })?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_saml_pending_expires_at ON saml_pending_request(expires_at)", + ) + .execute(&self.pool) + .await + .map_err(|e| { + AuthError::Generic(format!("Failed to create saml_pending_request index: {e}")) + })?; + + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS saml_seen_assertion ( + assertion_id TEXT PRIMARY KEY, + expires_at INTEGER NOT NULL + ) + "#, + ) + .execute(&self.pool) + .await + .map_err(|e| { + AuthError::Generic(format!("Failed to create saml_seen_assertion table: {e}")) + })?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_saml_seen_expires_at ON saml_seen_assertion(expires_at)", + ) + .execute(&self.pool) + .await + .map_err(|e| { + AuthError::Generic(format!("Failed to create saml_seen_assertion index: {e}")) + })?; + + Ok(()) + } +} + +#[async_trait] +impl SamlRequestStore for SqliteSamlRequestStore { + async fn store_pending_request(&self, request: &PendingSamlRequest) -> AuthResult<()> { + sqlx::query( + r#" + INSERT INTO saml_pending_request (request_id, realm_id, return_url, created_at, expires_at) + VALUES (?, ?, ?, ?, ?) + "#, + ) + .bind(&request.request_id) + .bind(&request.realm_id) + .bind(&request.return_url) + .bind(request.created_at) + .bind(request.expires_at) + .execute(&self.pool) + .await + .map_err(|e| AuthError::Generic(format!("Failed to store SAML pending request: {e}")))?; + Ok(()) + } + + async fn take_pending_request( + &self, + request_id: &str, + realm_id: &str, + ) -> AuthResult> { + let now = Utc::now().timestamp(); + + // DELETE ... RETURNING makes fetch-and-delete a single atomic statement, so a request + // is consumed at most once even under concurrent ACS posts — this single-use property + // is what makes a replayed `InResponseTo` fail to correlate a second time. + let row = sqlx::query( + r#" + DELETE FROM saml_pending_request + WHERE request_id = ? AND realm_id = ? AND expires_at > ? + RETURNING request_id, realm_id, return_url, created_at, expires_at + "#, + ) + .bind(request_id) + .bind(realm_id) + .bind(now) + .fetch_optional(&self.pool) + .await + .map_err(|e| AuthError::Generic(format!("Failed to take SAML pending request: {e}")))?; + + row.map(|row| { + Ok(PendingSamlRequest { + request_id: row + .try_get("request_id") + .map_err(|e| AuthError::Generic(format!("bad request_id column: {e}")))?, + realm_id: row + .try_get("realm_id") + .map_err(|e| AuthError::Generic(format!("bad realm_id column: {e}")))?, + return_url: row + .try_get("return_url") + .map_err(|e| AuthError::Generic(format!("bad return_url column: {e}")))?, + created_at: row + .try_get("created_at") + .map_err(|e| AuthError::Generic(format!("bad created_at column: {e}")))?, + expires_at: row + .try_get("expires_at") + .map_err(|e| AuthError::Generic(format!("bad expires_at column: {e}")))?, + }) + }) + .transpose() + } + + async fn record_assertion_id(&self, assertion_id: &str, expires_at: i64) -> AuthResult { + // INSERT OR IGNORE relies on the PRIMARY KEY: the first insert for an assertion id wins + // (1 row affected); a replay collides and affects 0 rows. `rows_affected` therefore + // distinguishes a newly-recorded id from a replay without a separate read. + let result = sqlx::query( + "INSERT OR IGNORE INTO saml_seen_assertion (assertion_id, expires_at) VALUES (?, ?)", + ) + .bind(assertion_id) + .bind(expires_at) + .execute(&self.pool) + .await + .map_err(|e| AuthError::Generic(format!("Failed to record SAML assertion id: {e}")))?; + + Ok(result.rows_affected() > 0) + } + + async fn delete_expired(&self) -> AuthResult<()> { + let now = Utc::now().timestamp(); + sqlx::query("DELETE FROM saml_pending_request WHERE expires_at <= ?") + .bind(now) + .execute(&self.pool) + .await + .map_err(|e| { + AuthError::Generic(format!("Failed to delete expired SAML pending requests: {e}")) + })?; + sqlx::query("DELETE FROM saml_seen_assertion WHERE expires_at <= ?") + .bind(now) + .execute(&self.pool) + .await + .map_err(|e| { + AuthError::Generic(format!("Failed to delete expired SAML assertion ids: {e}")) + })?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::sqlite::SqlitePoolOptions; + + async fn store() -> SqliteSamlRequestStore { + // max_connections(1) keeps the single in-memory database alive across queries — each + // `sqlite::memory:` connection is otherwise its own separate database. + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("in-memory SQLite pool"); + let store = SqliteSamlRequestStore::new(pool); + store.init().await.expect("init schema"); + store + } + + fn sample(request_id: &str, realm_id: &str, expires_at: i64) -> PendingSamlRequest { + PendingSamlRequest { + request_id: request_id.to_string(), + realm_id: realm_id.to_string(), + return_url: "https://app.example.com/home".to_string(), + created_at: 1_000, + expires_at, + } + } + + #[tokio::test] + async fn take_returns_the_stored_request_then_consumes_it() { + let store = store().await; + let future = Utc::now().timestamp() + 300; + store + .store_pending_request(&sample("id-1", "realm-a", future)) + .await + .unwrap(); + + let taken = store.take_pending_request("id-1", "realm-a").await.unwrap(); + assert_eq!(taken, Some(sample("id-1", "realm-a", future))); + + // Single-use: a second take of the same request id finds nothing. + let again = store.take_pending_request("id-1", "realm-a").await.unwrap(); + assert_eq!(again, None); + } + + #[tokio::test] + async fn take_rejects_a_wrong_realm() { + let store = store().await; + let future = Utc::now().timestamp() + 300; + store + .store_pending_request(&sample("id-1", "realm-a", future)) + .await + .unwrap(); + + // A request initiated by realm-a must not be consumable by realm-b's ACS. + let taken = store.take_pending_request("id-1", "realm-b").await.unwrap(); + assert_eq!(taken, None); + // And it remains available to its own realm (was not consumed by the wrong-realm attempt). + assert!(store.take_pending_request("id-1", "realm-a").await.unwrap().is_some()); + } + + #[tokio::test] + async fn take_rejects_an_expired_request() { + let store = store().await; + let past = Utc::now().timestamp() - 1; + store + .store_pending_request(&sample("id-old", "realm-a", past)) + .await + .unwrap(); + + let taken = store.take_pending_request("id-old", "realm-a").await.unwrap(); + assert_eq!(taken, None); + } + + #[tokio::test] + async fn record_assertion_id_detects_replay() { + let store = store().await; + let future = Utc::now().timestamp() + 300; + + assert!(store.record_assertion_id("assertion-1", future).await.unwrap()); + // Second time the same id is seen → replay. + assert!(!store.record_assertion_id("assertion-1", future).await.unwrap()); + // A different id is still accepted. + assert!(store.record_assertion_id("assertion-2", future).await.unwrap()); + } + + #[tokio::test] + async fn delete_expired_purges_only_expired_entries() { + let store = store().await; + let now = Utc::now().timestamp(); + store + .store_pending_request(&sample("fresh", "realm-a", now + 300)) + .await + .unwrap(); + store + .store_pending_request(&sample("stale", "realm-a", now - 1)) + .await + .unwrap(); + store.record_assertion_id("assertion-fresh", now + 300).await.unwrap(); + store.record_assertion_id("assertion-stale", now - 1).await.unwrap(); + + store.delete_expired().await.unwrap(); + + // The fresh pending request survives; the stale one is gone. + assert!(store.take_pending_request("fresh", "realm-a").await.unwrap().is_some()); + assert!(store.take_pending_request("stale", "realm-a").await.unwrap().is_none()); + // The stale assertion id was purged, so it is accepted again (no longer a known replay); + // the fresh one is still remembered. + assert!(store.record_assertion_id("assertion-stale", now + 300).await.unwrap()); + assert!(!store.record_assertion_id("assertion-fresh", now + 300).await.unwrap()); + } +} diff --git a/server/src/saml/mod.rs b/server/src/saml/mod.rs new file mode 100644 index 00000000..579d0a59 --- /dev/null +++ b/server/src/saml/mod.rs @@ -0,0 +1,8 @@ +mod request_store; +pub use request_store::{PendingSamlRequest, SamlRequestStore}; + +mod impls; +pub use impls::SqliteSamlRequestStore; + +mod factory; +pub use factory::create_saml_request_store; diff --git a/server/src/saml/request_store.rs b/server/src/saml/request_store.rs new file mode 100644 index 00000000..4df0bd83 --- /dev/null +++ b/server/src/saml/request_store.rs @@ -0,0 +1,50 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use crate::AuthResult; + +/// A stored SP-initiated `` awaiting its matching ``. +/// +/// Persisted when `/saml/{realm_id}/login` issues an `AuthnRequest`, and consumed +/// (single-use) at the ACS to correlate the response's `InResponseTo` and recover the +/// server-side return URL — so neither the correlation nor the return URL can be tampered +/// with in transit through the browser or IdP. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PendingSamlRequest { + /// The `` ID, matched against the response's `InResponseTo`. + pub request_id: String, + /// Realm that initiated the request; the ACS must belong to the same realm. + pub realm_id: String, + /// Validated post-login redirect URL, bound to this request server-side. + pub return_url: String, + /// Unix seconds when the request was created. + pub created_at: i64, + /// Unix seconds after which the pending request is invalid and may be purged. + pub expires_at: i64, +} + +/// Storage for the two pieces of short-lived state SP-initiated SAML SSO needs: outstanding +/// `AuthnRequest`s (for `InResponseTo` correlation and return-URL binding) and consumed +/// assertion IDs (for replay defense). +#[async_trait] +pub trait SamlRequestStore: Send + Sync { + /// Persist a pending SP-initiated `AuthnRequest`, keyed by its `request_id`. + async fn store_pending_request(&self, request: &PendingSamlRequest) -> AuthResult<()>; + + /// Atomically fetch and delete the pending request for `request_id` in `realm_id`, + /// enforcing single use. Returns `None` if it is absent, expired, or belongs to a + /// different realm. + async fn take_pending_request( + &self, + request_id: &str, + realm_id: &str, + ) -> AuthResult>; + + /// Record a consumed assertion ID for replay defense. Returns `true` if it was newly + /// recorded, `false` if it had already been seen (a replay). `expires_at` bounds how + /// long the entry must be retained (typically the assertion's `NotOnOrAfter` + skew). + async fn record_assertion_id(&self, assertion_id: &str, expires_at: i64) -> AuthResult; + + /// Delete expired pending requests and replay-cache entries. Called periodically. + async fn delete_expired(&self) -> AuthResult<()>; +} From 58294a72859c32d43ff0c287b7cfc7ad30536076 Mon Sep 17 00:00:00 2001 From: charming_wicket <235307685+charming-wicket-5502@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:02:55 +0200 Subject: [PATCH 02/19] build(saml): add samael with statically bundled xmlsec --- Cargo.lock | 286 +++++++++++++++++++++++++++++- Cargo.toml | 1 + client/src/lib.rs | 4 +- nix/xmlsec-static.nix | 71 ++++++++ server/Cargo.toml | 1 + server/src/saml/impls/mysql.rs | 4 +- server/src/saml/impls/postgres.rs | 4 +- server/src/saml/impls/redis.rs | 7 +- server/src/saml/impls/sqlite.rs | 78 ++++++-- server/src/saml/mod.rs | 14 ++ shell.nix | 21 +++ 11 files changed, 469 insertions(+), 22 deletions(-) create mode 100644 nix/xmlsec-static.nix diff --git a/Cargo.lock b/Cargo.lock index 9cbb7dfe..688b796d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -237,6 +237,12 @@ dependencies = [ "syn", ] +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -342,6 +348,7 @@ dependencies = [ "redis", "reqwest", "rustls", + "samael", "serde", "serde_json", "sha2", @@ -416,6 +423,26 @@ dependencies = [ "tokio", ] +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + [[package]] name = "bitflags" version = "2.11.0" @@ -488,6 +515,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -525,6 +561,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "cmake" version = "0.1.58" @@ -696,6 +743,15 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -730,6 +786,41 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "dashmap" version = "6.2.1" @@ -744,6 +835,12 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "der" version = "0.7.10" @@ -777,6 +874,37 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -918,6 +1046,17 @@ version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "flume" version = "0.11.1" @@ -1135,6 +1274,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "governor" version = "0.10.4" @@ -1546,6 +1691,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1601,6 +1752,15 @@ dependencies = [ "serde", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -1726,6 +1886,16 @@ version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libm" version = "0.2.16" @@ -1755,6 +1925,17 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libxml" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fe73cdec2bcb36d25a9fe3f607ffcd44bb8907ca0100c4098d1aa342d1e7bec" +dependencies = [ + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1841,6 +2022,22 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.0" @@ -1862,7 +2059,7 @@ dependencies = [ "libc", "log", "openssl", - "openssl-probe", + "openssl-probe 0.2.1", "openssl-sys", "schannel", "security-framework", @@ -1870,6 +2067,16 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nonzero_ext" version = "0.3.0" @@ -1978,6 +2185,12 @@ dependencies = [ "syn", ] +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + [[package]] name = "openssl-probe" version = "0.2.1" @@ -2291,7 +2504,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", "syn", @@ -2337,6 +2550,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quinn" version = "0.11.9" @@ -2550,6 +2773,18 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -2703,7 +2938,7 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe", + "openssl-probe 0.2.1", "rustls-pki-types", "schannel", "security-framework", @@ -2770,6 +3005,33 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "samael" +version = "0.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3507857cc308877abafd0db18e30b2852623db961006ba38b402e0eff048ee7f" +dependencies = [ + "base64", + "bindgen", + "chrono", + "data-encoding", + "derive_builder", + "flate2", + "lazy_static", + "libc", + "libxml", + "openssl", + "openssl-probe 0.1.6", + "openssl-sys", + "pkg-config", + "quick-xml", + "rand 0.9.3", + "serde", + "thiserror 2.0.18", + "url", + "uuid", +] + [[package]] name = "same-file" version = "1.0.6" @@ -2950,6 +3212,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "simple_asn1" version = "0.6.4" @@ -3232,6 +3500,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -4638,6 +4912,12 @@ dependencies = [ "syn", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 26246764..8821b570 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ redis = { version = "0.32.6", features = ["aio", "tokio-comp", "bb8"] } rand = { version = "0.9" } reqwest = { version = "0.13.2" } rustls = "0.23.35" +samael = { version = "0.0.22", features = ["xmlsec"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sha2 = "0.10.9" diff --git a/client/src/lib.rs b/client/src/lib.rs index 8b2b66b7..c6088912 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -56,4 +56,6 @@ pub use models::{ }; mod params; -pub use params::{IdpParams, JwtParams, RealmAuthParams, SamlParams, TotpRealmParams, UsernamePasswordParams}; +pub use params::{ + IdpParams, JwtParams, RealmAuthParams, SamlParams, TotpRealmParams, UsernamePasswordParams, +}; diff --git a/nix/xmlsec-static.nix b/nix/xmlsec-static.nix new file mode 100644 index 00000000..da3b9fca --- /dev/null +++ b/nix/xmlsec-static.nix @@ -0,0 +1,71 @@ +# Static, OpenSSL-only xmlsec (+ libxml2) for samael's `xmlsec` feature. +# Built with the glibc-2.34 toolchain (pkgs234) from current upstream sources, so the +# bundled objects stay Rocky Linux 9 compatible without inheriting nixpkgs 22.05's +# outdated xmlsec 1.2 / libxml2 2.9. +{ pkgs, pkgs234 }: +let + stdenv = pkgs234.stdenv; + + # Compile-time headers only: the final binary resolves OpenSSL symbols against the + # server's vendored OpenSSL, so no second OpenSSL ends up in the process. + openssl = pkgs234.openssl_3_0; + + libxml2 = stdenv.mkDerivation { + pname = "libxml2-static"; + inherit (pkgs.libxml2) version src; + nativeBuildInputs = [ pkgs234.pkg-config ]; + configureFlags = [ + "--enable-static" + "--disable-shared" + "--with-pic" + "--without-python" + "--without-icu" + "--without-lzma" + "--without-zlib" + "--without-readline" + ]; + }; +in +stdenv.mkDerivation { + pname = "xmlsec-static"; + inherit (pkgs.xmlsec) version src; + + nativeBuildInputs = [ pkgs234.pkg-config ]; + buildInputs = [ + libxml2 + openssl + ]; + + configureFlags = [ + "--enable-static" + "--disable-shared" + "--with-pic" + # Link the OpenSSL backend directly instead of loading it at runtime (drops libltdl). + "--disable-crypto-dl" + "--disable-apps-crypto-dl" + "--with-default-crypto=openssl" + "--without-gnutls" + "--without-gcrypt" + "--without-nss" + "--without-nspr" + # SAML never uses XSLT transforms; they are an attack surface in signed XML. + "--without-libxslt" + "--disable-apps" + "--disable-docs" + ]; + + # xmlsec1-config shells out to xml2-config at build time; wrap it so the static libxml2 + # is found without relying on the caller's PATH. + propagatedBuildInputs = [ libxml2 ]; + + # Drop the shared OpenSSL library dir from every link-time descriptor: `-lssl -lcrypto` + # must resolve against the server's vendored OpenSSL, not a second shared copy. + postInstall = '' + for f in $out/bin/xmlsec1-config $out/lib/pkgconfig/*.pc $out/lib/xmlsec1Conf.sh $out/lib/*.la; do + substituteInPlace "$f" --replace "-L${openssl.out}/lib" "" + done + substituteInPlace $out/bin/xmlsec1-config --replace "xml2-config" "${libxml2}/bin/xml2-config" + ''; + + passthru = { inherit libxml2; }; +} diff --git a/server/Cargo.toml b/server/Cargo.toml index 4ca60449..62251cb9 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -46,6 +46,7 @@ reqwest = { workspace = true, features = [ "cookies", ] } rustls = { workspace = true, optional = true } +samael = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } diff --git a/server/src/saml/impls/mysql.rs b/server/src/saml/impls/mysql.rs index c9fd959d..cfba6ba2 100644 --- a/server/src/saml/impls/mysql.rs +++ b/server/src/saml/impls/mysql.rs @@ -169,7 +169,9 @@ impl SamlRequestStore for MySqlSamlRequestStore { .execute(&self.pool) .await .map_err(|e| { - AuthError::Generic(format!("Failed to delete expired SAML pending requests: {e}")) + AuthError::Generic(format!( + "Failed to delete expired SAML pending requests: {e}" + )) })?; sqlx::query("DELETE FROM saml_seen_assertion WHERE expires_at <= ?") .bind(now) diff --git a/server/src/saml/impls/postgres.rs b/server/src/saml/impls/postgres.rs index e4a07d78..7b27825f 100644 --- a/server/src/saml/impls/postgres.rs +++ b/server/src/saml/impls/postgres.rs @@ -158,7 +158,9 @@ impl SamlRequestStore for PostgresSamlRequestStore { .execute(&self.pool) .await .map_err(|e| { - AuthError::Generic(format!("Failed to delete expired SAML pending requests: {e}")) + AuthError::Generic(format!( + "Failed to delete expired SAML pending requests: {e}" + )) })?; sqlx::query("DELETE FROM saml_seen_assertion WHERE expires_at <= $1") .bind(now) diff --git a/server/src/saml/impls/redis.rs b/server/src/saml/impls/redis.rs index 24ad120f..3564d9e5 100644 --- a/server/src/saml/impls/redis.rs +++ b/server/src/saml/impls/redis.rs @@ -50,10 +50,9 @@ impl SamlRequestStore for RedisSamlRequestStore { })?; // TTL bounds the request lifetime; Redis purges it automatically once it lapses. let ttl = (request.expires_at - Utc::now().timestamp()).max(1); - let _: () = conn - .set_ex(&key, json, ttl as u64) - .await - .map_err(|e| AuthError::Generic(format!("Failed to store SAML pending request: {e}")))?; + let _: () = conn.set_ex(&key, json, ttl as u64).await.map_err(|e| { + AuthError::Generic(format!("Failed to store SAML pending request: {e}")) + })?; Ok(()) } diff --git a/server/src/saml/impls/sqlite.rs b/server/src/saml/impls/sqlite.rs index a0f172fb..3c884a97 100644 --- a/server/src/saml/impls/sqlite.rs +++ b/server/src/saml/impls/sqlite.rs @@ -161,7 +161,9 @@ impl SamlRequestStore for SqliteSamlRequestStore { .execute(&self.pool) .await .map_err(|e| { - AuthError::Generic(format!("Failed to delete expired SAML pending requests: {e}")) + AuthError::Generic(format!( + "Failed to delete expired SAML pending requests: {e}" + )) })?; sqlx::query("DELETE FROM saml_seen_assertion WHERE expires_at <= ?") .bind(now) @@ -232,7 +234,13 @@ mod tests { let taken = store.take_pending_request("id-1", "realm-b").await.unwrap(); assert_eq!(taken, None); // And it remains available to its own realm (was not consumed by the wrong-realm attempt). - assert!(store.take_pending_request("id-1", "realm-a").await.unwrap().is_some()); + assert!( + store + .take_pending_request("id-1", "realm-a") + .await + .unwrap() + .is_some() + ); } #[tokio::test] @@ -244,7 +252,10 @@ mod tests { .await .unwrap(); - let taken = store.take_pending_request("id-old", "realm-a").await.unwrap(); + let taken = store + .take_pending_request("id-old", "realm-a") + .await + .unwrap(); assert_eq!(taken, None); } @@ -253,11 +264,26 @@ mod tests { let store = store().await; let future = Utc::now().timestamp() + 300; - assert!(store.record_assertion_id("assertion-1", future).await.unwrap()); + assert!( + store + .record_assertion_id("assertion-1", future) + .await + .unwrap() + ); // Second time the same id is seen → replay. - assert!(!store.record_assertion_id("assertion-1", future).await.unwrap()); + assert!( + !store + .record_assertion_id("assertion-1", future) + .await + .unwrap() + ); // A different id is still accepted. - assert!(store.record_assertion_id("assertion-2", future).await.unwrap()); + assert!( + store + .record_assertion_id("assertion-2", future) + .await + .unwrap() + ); } #[tokio::test] @@ -272,17 +298,45 @@ mod tests { .store_pending_request(&sample("stale", "realm-a", now - 1)) .await .unwrap(); - store.record_assertion_id("assertion-fresh", now + 300).await.unwrap(); - store.record_assertion_id("assertion-stale", now - 1).await.unwrap(); + store + .record_assertion_id("assertion-fresh", now + 300) + .await + .unwrap(); + store + .record_assertion_id("assertion-stale", now - 1) + .await + .unwrap(); store.delete_expired().await.unwrap(); // The fresh pending request survives; the stale one is gone. - assert!(store.take_pending_request("fresh", "realm-a").await.unwrap().is_some()); - assert!(store.take_pending_request("stale", "realm-a").await.unwrap().is_none()); + assert!( + store + .take_pending_request("fresh", "realm-a") + .await + .unwrap() + .is_some() + ); + assert!( + store + .take_pending_request("stale", "realm-a") + .await + .unwrap() + .is_none() + ); // The stale assertion id was purged, so it is accepted again (no longer a known replay); // the fresh one is still remembered. - assert!(store.record_assertion_id("assertion-stale", now + 300).await.unwrap()); - assert!(!store.record_assertion_id("assertion-fresh", now + 300).await.unwrap()); + assert!( + store + .record_assertion_id("assertion-stale", now + 300) + .await + .unwrap() + ); + assert!( + !store + .record_assertion_id("assertion-fresh", now + 300) + .await + .unwrap() + ); } } diff --git a/server/src/saml/mod.rs b/server/src/saml/mod.rs index 579d0a59..75277f14 100644 --- a/server/src/saml/mod.rs +++ b/server/src/saml/mod.rs @@ -6,3 +6,17 @@ pub use impls::SqliteSamlRequestStore; mod factory; pub use factory::create_saml_request_store; + +#[cfg(test)] +mod tests { + use samael::crypto::{CertificateDer, Crypto, CryptoProvider}; + + // Runs libxml2 + xmlsec + its OpenSSL backend end to end, so a broken or mis-linked + // xmlsec fails here rather than at the first real SAML login. + #[test] + fn xmlsec_backend_rejects_an_invalid_certificate() { + let not_a_certificate = CertificateDer::from(vec![0u8; 16]); + let result = Crypto::verify_signed_xml("", ¬_a_certificate, None); + assert!(result.is_err()); + } +} diff --git a/shell.nix b/shell.nix index 2d0b09fa..91f7accd 100644 --- a/shell.nix +++ b/shell.nix @@ -26,6 +26,18 @@ let rustToolchain = pkgs.rust-bin.stable.latest.default; + # glibc-2.34 package set (same pin as default.nix), used to build the bundled xmlsec. + pkgs234 = + if pkgs.stdenv.isLinux then + import (builtins.fetchTarball { + url = "https://package.cosmian.com/nixpkgs/380be19fbd2d9079f677978361792cb25e8a3635.tar.gz"; + sha256 = "sha256-Zffu01pONhs/pqH07cjlF10NnMDLok8ix5Uk4rhOnZQ="; + }) { } + else + pkgs; + + xmlsecStatic = import ./nix/xmlsec-static.nix { inherit pkgs pkgs234; }; + in pkgs.mkShell { name = "auth-verifier-dev"; @@ -39,6 +51,9 @@ pkgs.mkShell { pkgs.openssl pkgs.cargo-deny pkgs.cargo-edit + # SAML: samael's `xmlsec` feature links the bundled xmlsec + libxml2 (nix/xmlsec-static.nix). + xmlsecStatic + pkgs.llvmPackages.libclang # bindgen ] ++ pkgs.lib.optionals pkgs.stdenv.isDarwin [ pkgs.libiconv @@ -49,6 +64,12 @@ pkgs.mkShell { ++ pkgs.lib.optionals withCurl [ pkgs.curl ]; shellHook = '' + # bindgen (via samael's xmlsec feature) parses libxml2 headers with libclang, which needs + # both the stdenv C headers (stdio.h) and clang's own builtin headers (stddef.h). Pull the + # former from the cc-wrapper flags and the latter from libclang's resource include dir (its + # version subdir is major-only on clang >= 16, so glob it). + export LIBCLANG_PATH="${pkgs.llvmPackages.libclang.lib}/lib" + export BINDGEN_EXTRA_CLANG_ARGS="$(cat ${pkgs.stdenv.cc}/nix-support/libc-cflags 2>/dev/null) $(cat ${pkgs.stdenv.cc}/nix-support/cc-cflags 2>/dev/null) -idirafter $(echo ${pkgs.llvmPackages.libclang.lib}/lib/clang/*/include)" echo "Authentication Verifier dev shell (Rust $(rustc --version))" ''; } From 0bb33d8b61dea65e91701bea48b31524df915671 Mon Sep 17 00:00:00 2001 From: charming_wicket <235307685+charming-wicket-5502@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:27:08 +0200 Subject: [PATCH 03/19] feat(saml): add optional saml feature and harden the request store --- .github/workflows/main_base.yml | 28 ++ CHANGELOG/feat_saml-integration.md | 16 + report/ISSUES_CHECKLIST.md | 302 ++++++++++++++++ server/Cargo.toml | 5 +- server/src/lib.rs | 2 + server/src/saml/impls/mysql.rs | 37 +- server/src/saml/impls/postgres.rs | 4 +- server/src/saml/impls/redis.rs | 16 +- server/src/saml/impls/sqlite.rs | 169 +-------- server/src/saml/request_store.rs | 24 +- .../endpoints/super_admins_endpoints.rs | 18 + server/src/tests/mod.rs | 3 + server/src/tests/saml_request_store.rs | 340 ++++++++++++++++++ server/src/tests/super_admin_api.rs | 51 +++ 14 files changed, 824 insertions(+), 191 deletions(-) create mode 100644 CHANGELOG/feat_saml-integration.md create mode 100644 report/ISSUES_CHECKLIST.md create mode 100644 server/src/tests/saml_request_store.rs diff --git a/.github/workflows/main_base.yml b/.github/workflows/main_base.yml index fe1f3025..91a52c65 100644 --- a/.github/workflows/main_base.yml +++ b/.github/workflows/main_base.yml @@ -87,3 +87,31 @@ jobs: - name: Run unit tests run: | cargo test --workspace --lib -- --nocapture + + cargo-saml: + name: Cargo SAML (Nix) - ${{ matrix.runner }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + # Bundled xmlsec (nix/xmlsec-static.nix) is only verified on Linux x86_64 so far. + runner: [ubuntu-24.04] + + steps: + - name: Nix installation + uses: cachix/install-nix-action@v31 + with: + extra_nix_config: | + connect-timeout = 15 + stalled-download-timeout = 15 + narinfo-cache-negative-ttl = 0 + + - uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Static analysis (saml feature) + run: nix-shell --run "cargo clippy --workspace --all-targets --features auth_verifier/saml -- -D warnings" + + - name: Run unit tests (saml feature) + run: nix-shell --run "cargo test --workspace --lib --features auth_verifier/saml -- --nocapture" diff --git a/CHANGELOG/feat_saml-integration.md b/CHANGELOG/feat_saml-integration.md new file mode 100644 index 00000000..e4297faa --- /dev/null +++ b/CHANGELOG/feat_saml-integration.md @@ -0,0 +1,16 @@ +## Features + +- Add the SAML 2.0 contract types to the client crate — `SamlParams`, `RealmAuthParams.saml_params` and `AuthScheme::Saml` (`"sa"`) — mirrored in the admin-ui TypeScript types and `openapi.yaml`, so realms can carry per-realm SAML Service Provider configuration for the upcoming SP-initiated Web Browser SSO login. +- Make `find_admins_by_auth_scheme` return no admin for `AuthScheme::Saml` on SQLite, PostgreSQL and MySQL, because SAML is a user-facing realm login method and never an administrator credential. +- Add a `SamlRequestStore` (pending `AuthnRequest`s for `InResponseTo` correlation plus a consumed-assertion replay cache) with SQLite, PostgreSQL, MySQL and Redis backends and a `create_saml_request_store` factory, each enforcing single-use atomically in its native primitive, refusing already-expired entries so no backend can return an expired request or forget a consumed assertion early, and keeping IDs exact-match on MySQL (binary collation, SHA-256 replay keys) where the default collation ignores case and `INSERT IGNORE` truncates long IDs. +- Add an optional `saml` Cargo feature (off by default) that pulls in `samael` and gates the server-side SAML module, so default builds, plain `cargo` and existing CI stay free of the new C dependencies. +- Refuse realm create/update requests that carry `saml_params` on servers built without the `saml` feature (HTTP 400), so unusable and unvalidated SAML configuration is never stored. +- Bundle a static, OpenSSL-only xmlsec 1.3.5 and libxml2 2.13.4 (`nix/xmlsec-static.nix`, built with the glibc-2.34 toolchain, XSLT and runtime crypto loading disabled) and wire it plus the libclang/bindgen setup into `shell.nix`, so `--features saml` links xmlsec statically against the server's vendored OpenSSL with no new runtime library dependency. + +## Tests + +- Add a SAML request store test suite that runs against in-memory SQLite by default and against PostgreSQL, MySQL or Redis through the same `TEST_SESSIONS_STORE` switch as the session-store tests (single-use and concurrent takes, realm scoping, expiry on write and on read, replay detection, case-sensitive and long IDs, expiry purge), an xmlsec smoke test that exercises libxml2, xmlsec and its OpenSSL backend end to end, and an API test checking that SAML settings are refused without the `saml` feature. + +## CI + +- Add a Nix-based `cargo-saml` job that runs clippy and the unit tests with `--features saml` inside `nix-shell`, since that feature needs the bundled xmlsec and libclang that the plain-cargo jobs don't have. diff --git a/report/ISSUES_CHECKLIST.md b/report/ISSUES_CHECKLIST.md new file mode 100644 index 00000000..4b27ff6b --- /dev/null +++ b/report/ISSUES_CHECKLIST.md @@ -0,0 +1,302 @@ +# SAML PR Review — Issue Checklist + +**PR:** feat/saml-integration → remotes/origin/develop +**Issue:** GitHub #889 +**Date:** 2026-09-23 + +--- + +## BLOCKING ISSUES — Must Fix Before Merge + +### ✋ Issue 1: SAML Store Factory Never Instantiated +- **Severity:** P1 (Runtime non-functional) +- **File:** `server/src/saml/factory.rs:20–22` +- **Problem:** `create_saml_request_store()` is never called. SAML realms have no login path. +- **Fix:** Wire factory into `start_auth_verifier()`, pass store to endpoint handlers +- **Complexity:** Medium (requires server startup refactor) +- **Blocks:** Issues 2, 3 (cleanup, expiry validation only matter once factory is used) +- **Dependency:** Follow-up PR (SAML endpoints) depends on this + +- [ ] **Owner:** Fix store instantiation +- [ ] **Verification:** Confirm store is Arc in server state +- [ ] **Testing:** Ensure factory is called during server init + +--- + +### ✋ Issue 2: SQL Cleanup Never Scheduled +- **Severity:** P1 (Resource leak — unbounded table growth) +- **Files:** + - `server/src/saml/impls/sqlite.rs:157–160` (delete_expired method) + - `server/src/saml/factory.rs:17–20` (factory, no cleanup task) +- **Problem:** Expired rows never purged. Tables grow indefinitely. +- **Affected:** PostgreSQL, MySQL, SQLite +- **Unaffected:** Redis (TTL automatic) +- **Fix:** Implement background cleanup task (weekly or hourly) +- **Complexity:** Medium (similar to `start_stale_session_collector`) +- **Performance Impact:** Without fix, table scans degrade after weeks + +- [ ] **Owner:** Implement cleanup collector +- [ ] **Reference Pattern:** `server/src/session/factory.rs` (start_stale_session_collector) +- [ ] **Testing:** Verify delete_expired is called; rows actually purged +- [ ] **Config:** Document cleanup interval (default: 1 hour) + +--- + +### ✋ Issue 3: Redis Returns Expired Pending Requests +- **Severity:** P1 (Logic violation — accepts invalid requests) +- **File:** `server/src/saml/impls/redis.rs:52` +- **Problem:** `.max(1)` clamps negative TTL to 1 second. Expired requests stored + fetchable. +- **Impact:** Already-expired requests accepted within ≤1 second grace period +- **Fix:** Reject expired writes OR validate after GETDEL +- **Complexity:** Low + +**Option A (Recommended):** +```rust +// In store_pending_request +if request.expires_at <= Utc::now().timestamp() { + return Err(AuthError::Generic("Request already expired".into())); +} +``` + +**Option B (Alternative):** +```rust +// In take_pending_request, after GETDEL +let now = Utc::now().timestamp(); +if request.expires_at <= now { + return Ok(None); +} +``` + +- [ ] **Owner:** Implement fix (choose A or B) +- [ ] **Testing:** Unit test with expired request, verify rejection +- [ ] **Edge case:** Test clock skew (now() slightly advances during set_ex) + +--- + +### ✋ Issue 4: Build Broken — samael+xmlsec Unconditional +- **Severity:** P1 (Build breaks — blocks all CI) +- **Files:** + - `Cargo.toml` line 39 (samael = "0.0.22" with features = ["xmlsec"]) + - `server/Cargo.toml` line 49 (samael = { workspace = true }) + - `.github/workflows/main_base.yml` (no xmlsec1 installed) + - `nix/auth-verifier.nix` (missing xmlsecStatic, libclang) +- **Problem:** + - samael's build.rs calls xmlsec1-config, which fails without system xmlsec1 + libclang + - Direct Cargo CI (main_base.yml) doesn't have them + - Nix derivation doesn't provide them + - Breaks `cargo build`, `cargo test`, `nix build` +- **Impact:** ❌ Blocks all downstream development +- **Fix:** Gate behind feature flag OR update build infra + +**Option A (Recommended — Feature Flag):** +```toml +[dependencies] +samael = { version = "0.0.22", features = ["xmlsec"], optional = true } + +[features] +saml = ["samael"] +``` +- Default: no samael, no xmlsec needed +- Dev: `cargo build --features saml` +- Unblocks all CI until SAML endpoints ready + +**Option B (Alternative — Update Build Infra):** +1. Add `xmlsec1-dev` to Ubuntu runners in main_base.yml +2. Add `libxml2-devel`, `xmlsec1-devel` to macOS via Homebrew +3. Update nix/auth-verifier.nix (see Issue 5) + +- [ ] **Owner:** Implement Option A or B +- [ ] **Testing:** `cargo build`, `cargo test`, `nix build .#auth-verifier` all succeed +- [ ] **CI Validation:** Merge to develop; verify all workflows pass + +--- + +### ✋ Issue 5: Nix Package Build Missing xmlsec Setup +- **Severity:** P1 (Build breaks — nix build fails) +- **File:** `nix/auth-verifier.nix` (not modified in PR; needs changes) +- **Problem:** + - New file `nix/xmlsec-static.nix` + shell.nix updates are present + - Package derivation `nix/auth-verifier.nix` NOT updated + - Missing: `xmlsecStatic`, `libclang`, `LIBCLANG_PATH`, `BINDGEN_EXTRA_CLANG_ARGS` + - `nix build .#auth-verifier` fails +- **Impact:** ❌ Package CI jobs fail; users can't `nix build` auth-verifier +- **Fix:** Update auth-verifier.nix OR use feature flag (Issue 4 Option A) + +**If continuing with unconditional samael:** +```nix +{ + buildInputs = [ + # ... existing ... + xmlsecStatic # Import from xmlsec-static.nix + pkgs.llvmPackages.libclang + ]; + + preBuild = '' + export LIBCLANG_PATH="${pkgs.llvmPackages.libclang.lib}/lib" + export BINDGEN_EXTRA_CLANG_ARGS="..." + ''; +} +``` + +- [ ] **Owner:** Update nix/auth-verifier.nix +- [ ] **Testing:** `nix build .#auth-verifier` succeeds +- [ ] **CI:** Verify packaging workflow passes + +--- + +### ✋ Issue 6: Pre-Release Dependency Without Audit — samael 0.0.22 +- **Severity:** P1 (Dependency risk) +- **File:** `Cargo.toml` line 39 (workspace root) +- **Problem:** + - samael 0.0.22 is pre-1.0, labeled "work-in-progress" + - No security audit evidence + - No documented justification for this crate + - OpenSSL transitive deps not pinned (semver ranges allow drift) +- **Impact:** Potential upstream breaking changes; security gaps in pre-release code +- **Fix:** Document decision, add security review before shipping + +- [ ] **Owner:** Create ADR (Architectural Decision Record) + - Why samael? (criteria: SAML 2.0 compliance, active maintenance, Rust ecosystem fit) + - Alternatives considered? (xmldsig, xml-crypto bindings, etc.) + - Timeline for moving to stable version? +- [ ] **Owner:** Add pre-merge security review task + - Run `/security-review` on samael crate + - Document samael's pre-release status in SECURITY.md + - Track timeline for upgrade to stable +- [ ] **Owner:** Pin samael and OpenSSL transitive deps + ```toml + samael = "=0.0.22" # Exact version until stable + openssl = "=0.10.81" # Lock transitive + ``` +- [ ] **Reviewer:** Approve security audit before merge + +--- + +## HIGH-PRIORITY ISSUES — Strongly Recommended Before Merge + +### ⚠️ Issue 7: MySQL Breaks SAML ID Case-Sensitivity +- **Severity:** P2 (Data integrity — ID mismatches) +- **File:** `server/src/saml/impls/mysql.rs:24–26` +- **Problem:** Default collation `utf8mb4_general_ci` is case-insensitive. SAML IDs are case-sensitive. +- **Impact:** + - `take_pending_request("_abc", ...)` can match `_ABC` + - Assertion ID replay cache collisions on case difference + - False replay positives → valid logins rejected +- **Fix:** Use binary collation + ```sql + request_id TEXT COLLATE utf8mb4_bin PRIMARY KEY, + assertion_id TEXT COLLATE utf8mb4_bin PRIMARY KEY, + ``` +- **Complexity:** Low (schema change only) + +- [ ] **Owner:** Update MySQL schema +- [ ] **Testing:** Unit test case-sensitivity (id='abc' vs 'ABC' treated as different) + +--- + +### ⚠️ Issue 8: MySQL Assertion IDs Silently Truncated to 255 Chars +- **Severity:** P2 (Data integrity — false replay positives) +- **File:** `server/src/saml/impls/mysql.rs:42–45` +- **Problem:** `VARCHAR(255)` + `INSERT IGNORE` silently truncates long IDs. SAML has no length limit. +- **Impact:** + - Distinct assertions with same 255-char prefix treated as replay + - Valid logins denied +- **Fix:** Use TEXT or digest-based key + ```sql + -- Option 1: Full ID as TEXT + assertion_id TEXT NOT NULL PRIMARY KEY, + + -- Option 2: Digest-based key (safer indexing) + assertion_id_hash BLOB NOT NULL PRIMARY KEY, + full_assertion_id TEXT NOT NULL, + ``` +- **Complexity:** Medium (schema + insert logic update) + +- [ ] **Owner:** Update MySQL schema +- [ ] **Owner:** Update record_assertion_id logic +- [ ] **Testing:** Unit test with long IDs (>255 chars), verify no truncation + +--- + +## DEFERRED WORK — Covered by Follow-up PRs + +- [ ] HTTP endpoints (`/saml/{realm_id}/login`, `/saml/{realm_id}/acs`) +- [ ] SAML request handler (AuthnRequest creation, response validation, session creation) +- [ ] Server configuration for SAML parameters +- [ ] Realm admin UI for SAML setup +- [ ] E2E tests +- [ ] Documentation + +--- + +## VALIDATION CHECKLIST + +- [ ] Issue 1: Store factory wired into startup +- [ ] Issue 2: SQL cleanup scheduler implemented and running +- [ ] Issue 3: Redis expiry validation fixed +- [ ] Issue 4: samael gated OR build infra updated; `cargo build` passes on all platforms +- [ ] Issue 5: `nix build .#auth-verifier` passes +- [ ] Issue 6: ADR + security review task documented +- [ ] Issue 7: MySQL collation fixed +- [ ] Issue 8: MySQL ID truncation fixed +- [ ] Add PostgreSQL + MySQL + Redis integration tests +- [ ] All CI jobs pass (clippy, test, packaging, nix) +- [ ] Code review approved by maintainer + +--- + +## SIGN-OFF + +- [ ] **Author:** All issues acknowledged and scheduled +- [ ] **Reviewer:** All blockers verified fixed +- [ ] **CI:** Green on all platforms (Linux, macOS, Nix) +- [ ] **Ready to Merge:** Yes ✅ + +--- + +**Questions/Comments:** +(Space for reviewer notes) + +--- + +## AUTHOR RESPONSE — Triage (2026-09-24) + +Each finding was checked against the code at `58294a7` and the git history. + +### Verdict per issue + +| Issue | Verdict | Planned action | When | +|-------|---------|----------------|------| +| 1 — Store never instantiated | Correct, by design at this stage | Wire the store together with the endpoints (a store with no caller would only add startup state) | First endpoint (Chunk 6) | +| 2 — SQL cleanup never scheduled | **Correct, gap in the plan** | Add a periodic `delete_expired()` task, modeled on `start_stale_session_collector` | With the store wiring (Chunk 6) | +| 3 — Redis returns expired requests | **Correct** | Fix in **all four backends**: reject already-expired writes, and re-check expiry on `take` | Chunk 2c (next) | +| 4 — samael unconditional | Correct | Adopt Option A: optional `saml` Cargo feature, off by default | Step 0b (next) | +| 5 — `auth-verifier.nix` not wired | Correct | Add `xmlsecStatic` + libclang/bindgen setup, build with `--features saml`, run `nix-build -A auth-verifier-static` | Step 0b (next) | +| 6 — samael pre-1.0 | Correct as a risk; it was a deliberate choice | Write an ADR; security review of the signature-verification path before release | ADR now; review before release | +| 7 — MySQL case-insensitive IDs | **Correct** (effect: valid logins refused, not a bypass) | Binary collation for `request_id` | Chunk 2c | +| 8 — MySQL truncation | **Correct** (`INSERT IGNORE` truncates even in strict mode) | Key the replay cache by `SHA-256(assertion_id)` as `BINARY(32)` | Chunk 2c | +| Coverage — PG/MySQL/Redis untested | **Correct**; this also missed an existing convention | Run the SAML store tests against all backends through the same env-var switch as `server/src/tests/sessions_store.rs`, plus case and long-ID tests | Chunk 2c | +| SEC-01 — Redis replay-cache 1 s TTL | **Correct**, and it applies to every backend once rows are purged | `record_assertion_id` fails closed when `expires_at <= now`; the ACS must pass `NotOnOrAfter` + the same clock skew the validator accepts | Chunk 2c (store) / Chunk 5a (caller) | +| SEC-02 — `SamlParams` not validated | Correct; this is planned work | Validate metadata, origins, ACS URL and claim map at realm create/update | Chunk 3 | +| PANIC-01 — `claim_policy.rs` | Out of scope: not in this branch's diff (introduced in `d92a3f0`, release 0.4.0) | Separate ticket if wanted | — | +| ERR-01 — `AuthError::Generic` | Incorrect premise: existing session stores use `Generic` throughout, not `Db` | Consistent with the codebase; any change should be a repo-wide refactor | — | + +### Proposed fixes not adopted + +- **Issue 7/8, `TEXT ... PRIMARY KEY`:** MySQL rejects a `TEXT` primary key without a prefix length (error 1170). +- **Issue 8, `ON DUPLICATE KEY UPDATE expires_at = ?`:** do not use. A replay would report affected rows > 0 and be treated as a new assertion, which is a replay bypass. It would also extend the retention window. +- **Issue 6, pin `samael = "=0.0.22"`:** no effect. For `0.0.x` versions Cargo's default requirement already matches exactly `0.0.22`, and `Cargo.lock` pins the transitive OpenSSL crates. +- **Issue 6, SECURITY.md entry:** that file is reserved for vulnerabilities that shipped in a tagged release (AGENTS.md §11). The dependency rationale goes in the ADR instead. + +### Proposed order + +1. **Step 0b — build:** `saml` feature gate; wire and run the Nix release build; CHANGELOG entry. +2. **Chunk 2c — store hardening:** expiry contract in all backends, MySQL keys, tests on every backend. +3. **ADR** for the samael decision. +4. Continue the feature: Chunk 3 (metadata ingestion + `SamlParams` validation) → signature validation → endpoints and store wiring (with cleanup task) → admin UI → docs. + +### Status update (2026-09-24) + +- **Done in Step 0b — Issue 4:** optional `saml` feature (off by default); plain `cargo` builds and the existing CI jobs no longer compile samael. Servers built without it refuse `saml_params` with HTTP 400. Draft Nix-based `cargo-saml` CI job added for review. +- **Issue 5, deferred with reason:** the release build no longer compiles samael, because the feature is off by default. The Nix release derivation will be wired when SAML code first calls samael (Chunk 3/5a), since only then can the static-linkage check be meaningful. +- **Done in Chunk 2c — Issues 3, 7, 8, SEC-01 and backend coverage:** every backend refuses already-expired writes; Redis re-checks expiry on `take`; MySQL uses binary collation for IDs and `SHA-256(assertion_id)` as `BINARY(32)` replay keys. A new suite (`server/src/tests/saml_request_store.rs`) runs through `TEST_SESSIONS_STORE` and passes 10/10 against SQLite, PostgreSQL 16, MySQL 8.4 and Redis 7. diff --git a/server/Cargo.toml b/server/Cargo.toml index 62251cb9..6165a18a 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -21,6 +21,9 @@ database = ["dep:sqlx", "sqlx?/postgres", "sqlx?/sqlite", "sqlx?/mysql"] swagger-ui = [] # Serve the pre-built admin UI static files at /admin-ui admin-ui = ["dep:actix-files"] +# SAML 2.0 Service Provider (SP-initiated Web Browser SSO). Needs the bundled xmlsec C +# library provided by the Nix shell (nix/xmlsec-static.nix). +saml = ["dep:samael"] [dependencies] actix-cors = { workspace = true } @@ -46,7 +49,7 @@ reqwest = { workspace = true, features = [ "cookies", ] } rustls = { workspace = true, optional = true } -samael = { workspace = true } +samael = { workspace = true, optional = true } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } diff --git a/server/src/lib.rs b/server/src/lib.rs index d2460fc8..a7b94c46 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -30,7 +30,9 @@ pub use session::{ }; pub use session::{build_cookie, delete_cookie}; +#[cfg(feature = "saml")] mod saml; +#[cfg(feature = "saml")] pub use saml::{ PendingSamlRequest, SamlRequestStore, SqliteSamlRequestStore, create_saml_request_store, }; diff --git a/server/src/saml/impls/mysql.rs b/server/src/saml/impls/mysql.rs index cfba6ba2..e09cee52 100644 --- a/server/src/saml/impls/mysql.rs +++ b/server/src/saml/impls/mysql.rs @@ -1,10 +1,11 @@ use async_trait::async_trait; use chrono::Utc; +use sha2::{Digest, Sha256}; use sqlx::{MySqlPool, Row, mysql::MySqlRow}; use crate::{ AuthError, AuthResult, - saml::request_store::{PendingSamlRequest, SamlRequestStore}, + saml::request_store::{PendingSamlRequest, SamlRequestStore, ensure_unexpired}, }; /// MySQL-backed store for pending SAML requests and the assertion replay cache. @@ -19,11 +20,12 @@ impl MySqlSamlRequestStore { /// Create the pending-request and replay-cache tables (indexes declared inline, MySQL-style). pub async fn init(&self) -> AuthResult<()> { + // Binary collation: SAML IDs are case-sensitive, MySQL's default collation is not. sqlx::query( r#" CREATE TABLE IF NOT EXISTS saml_pending_request ( - request_id VARCHAR(255) PRIMARY KEY, - realm_id VARCHAR(255) NOT NULL, + request_id VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin PRIMARY KEY, + realm_id VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, return_url TEXT NOT NULL, created_at BIGINT NOT NULL, expires_at BIGINT NOT NULL, @@ -37,11 +39,13 @@ impl MySqlSamlRequestStore { AuthError::Generic(format!("Failed to create saml_pending_request table: {e}")) })?; + // Keyed by SHA-256 of the assertion ID: fixed length, so a long ID is never truncated + // into a collision, and compared byte for byte. sqlx::query( r#" CREATE TABLE IF NOT EXISTS saml_seen_assertion ( - assertion_id VARCHAR(255) PRIMARY KEY, - expires_at BIGINT NOT NULL, + assertion_id_sha256 BINARY(32) PRIMARY KEY, + expires_at BIGINT NOT NULL, INDEX idx_saml_seen_expires_at (expires_at) ) "#, @@ -56,14 +60,19 @@ impl MySqlSamlRequestStore { } } +/// Read a `utf8mb4_bin` column, which sqlx reports as `VARBINARY` and won't decode as `String`. +fn binary_text_column(row: &MySqlRow, column: &str) -> AuthResult { + let bytes: Vec = row + .try_get(column) + .map_err(|e| AuthError::Generic(format!("bad {column} column: {e}")))?; + String::from_utf8(bytes) + .map_err(|e| AuthError::Generic(format!("bad {column} column: not UTF-8: {e}"))) +} + fn row_to_pending(row: MySqlRow) -> AuthResult { Ok(PendingSamlRequest { - request_id: row - .try_get("request_id") - .map_err(|e| AuthError::Generic(format!("bad request_id column: {e}")))?, - realm_id: row - .try_get("realm_id") - .map_err(|e| AuthError::Generic(format!("bad realm_id column: {e}")))?, + request_id: binary_text_column(&row, "request_id")?, + realm_id: binary_text_column(&row, "realm_id")?, return_url: row .try_get("return_url") .map_err(|e| AuthError::Generic(format!("bad return_url column: {e}")))?, @@ -79,6 +88,7 @@ fn row_to_pending(row: MySqlRow) -> AuthResult { #[async_trait] impl SamlRequestStore for MySqlSamlRequestStore { async fn store_pending_request(&self, request: &PendingSamlRequest) -> AuthResult<()> { + ensure_unexpired(request.expires_at, "SAML pending request")?; sqlx::query( r#" INSERT INTO saml_pending_request (request_id, realm_id, return_url, created_at, expires_at) @@ -149,12 +159,13 @@ impl SamlRequestStore for MySqlSamlRequestStore { } async fn record_assertion_id(&self, assertion_id: &str, expires_at: i64) -> AuthResult { + ensure_unexpired(expires_at, "SAML assertion id")?; // INSERT IGNORE: the first insert for an assertion id wins (1 row affected); a replay // collides on the primary key and affects 0 rows. let result = sqlx::query( - "INSERT IGNORE INTO saml_seen_assertion (assertion_id, expires_at) VALUES (?, ?)", + "INSERT IGNORE INTO saml_seen_assertion (assertion_id_sha256, expires_at) VALUES (?, ?)", ) - .bind(assertion_id) + .bind(Sha256::digest(assertion_id.as_bytes()).to_vec()) .bind(expires_at) .execute(&self.pool) .await diff --git a/server/src/saml/impls/postgres.rs b/server/src/saml/impls/postgres.rs index 7b27825f..8d0853b2 100644 --- a/server/src/saml/impls/postgres.rs +++ b/server/src/saml/impls/postgres.rs @@ -4,7 +4,7 @@ use sqlx::{PgPool, Row, postgres::PgRow}; use crate::{ AuthError, AuthResult, - saml::request_store::{PendingSamlRequest, SamlRequestStore}, + saml::request_store::{PendingSamlRequest, SamlRequestStore, ensure_unexpired}, }; /// PostgreSQL-backed store for pending SAML requests and the assertion replay cache. @@ -95,6 +95,7 @@ fn row_to_pending(row: PgRow) -> AuthResult { #[async_trait] impl SamlRequestStore for PostgresSamlRequestStore { async fn store_pending_request(&self, request: &PendingSamlRequest) -> AuthResult<()> { + ensure_unexpired(request.expires_at, "SAML pending request")?; sqlx::query( r#" INSERT INTO saml_pending_request (request_id, realm_id, return_url, created_at, expires_at) @@ -138,6 +139,7 @@ impl SamlRequestStore for PostgresSamlRequestStore { } async fn record_assertion_id(&self, assertion_id: &str, expires_at: i64) -> AuthResult { + ensure_unexpired(expires_at, "SAML assertion id")?; // ON CONFLICT DO NOTHING: the first insert for an assertion id wins (1 row affected); // a replay conflicts and affects 0 rows. let result = sqlx::query( diff --git a/server/src/saml/impls/redis.rs b/server/src/saml/impls/redis.rs index 3564d9e5..9ed73b58 100644 --- a/server/src/saml/impls/redis.rs +++ b/server/src/saml/impls/redis.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use crate::{ AuthError, AuthResult, - saml::request_store::{PendingSamlRequest, SamlRequestStore}, + saml::request_store::{PendingSamlRequest, SamlRequestStore, ensure_unexpired}, }; /// Redis/ValKey-backed store for pending SAML requests and the assertion replay cache. @@ -49,8 +49,8 @@ impl SamlRequestStore for RedisSamlRequestStore { AuthError::Generic(format!("Failed to serialize SAML pending request: {e}")) })?; // TTL bounds the request lifetime; Redis purges it automatically once it lapses. - let ttl = (request.expires_at - Utc::now().timestamp()).max(1); - let _: () = conn.set_ex(&key, json, ttl as u64).await.map_err(|e| { + let ttl = ensure_unexpired(request.expires_at, "SAML pending request")?; + let _: () = conn.set_ex(&key, json, ttl).await.map_err(|e| { AuthError::Generic(format!("Failed to store SAML pending request: {e}")) })?; Ok(()) @@ -64,7 +64,8 @@ impl SamlRequestStore for RedisSamlRequestStore { let mut conn = self.conn().await?; let key = Self::pending_key(realm_id, request_id); // GETDEL is atomic fetch-and-delete (Redis 6.2+), enforcing single use; the realm is - // part of the key, and expired requests are already gone via TTL. + // part of the key. TTLs are whole seconds, so expiry is re-checked like the SQL + // backends do rather than trusting the TTL alone. let json: Option = redis::cmd("GETDEL") .arg(&key) .query_async(&mut conn) @@ -74,18 +75,21 @@ impl SamlRequestStore for RedisSamlRequestStore { match json { None => Ok(None), Some(json) => { - let request = serde_json::from_str(&json).map_err(|e| { + let request: PendingSamlRequest = serde_json::from_str(&json).map_err(|e| { AuthError::Generic(format!("Failed to deserialize SAML pending request: {e}")) })?; + if request.expires_at <= Utc::now().timestamp() { + return Ok(None); + } Ok(Some(request)) } } } async fn record_assertion_id(&self, assertion_id: &str, expires_at: i64) -> AuthResult { + let ttl = ensure_unexpired(expires_at, "SAML assertion id")?; let mut conn = self.conn().await?; let key = Self::seen_key(assertion_id); - let ttl = (expires_at - Utc::now().timestamp()).max(1); // `SET key 1 NX EX ttl` sets only if the key is absent: a reply of OK means newly // recorded, a nil reply means the id was already seen (a replay). One atomic command. let set: Option = redis::cmd("SET") diff --git a/server/src/saml/impls/sqlite.rs b/server/src/saml/impls/sqlite.rs index 3c884a97..3b6d3de3 100644 --- a/server/src/saml/impls/sqlite.rs +++ b/server/src/saml/impls/sqlite.rs @@ -4,7 +4,7 @@ use sqlx::{Row, SqlitePool}; use crate::{ AuthError, AuthResult, - saml::request_store::{PendingSamlRequest, SamlRequestStore}, + saml::request_store::{PendingSamlRequest, SamlRequestStore, ensure_unexpired}, }; /// SQLite-backed store for pending SAML `AuthnRequest`s and the assertion replay cache. @@ -75,6 +75,7 @@ impl SqliteSamlRequestStore { #[async_trait] impl SamlRequestStore for SqliteSamlRequestStore { async fn store_pending_request(&self, request: &PendingSamlRequest) -> AuthResult<()> { + ensure_unexpired(request.expires_at, "SAML pending request")?; sqlx::query( r#" INSERT INTO saml_pending_request (request_id, realm_id, return_url, created_at, expires_at) @@ -139,6 +140,7 @@ impl SamlRequestStore for SqliteSamlRequestStore { } async fn record_assertion_id(&self, assertion_id: &str, expires_at: i64) -> AuthResult { + ensure_unexpired(expires_at, "SAML assertion id")?; // INSERT OR IGNORE relies on the PRIMARY KEY: the first insert for an assertion id wins // (1 row affected); a replay collides and affects 0 rows. `rows_affected` therefore // distinguishes a newly-recorded id from a replay without a separate read. @@ -175,168 +177,3 @@ impl SamlRequestStore for SqliteSamlRequestStore { Ok(()) } } - -#[cfg(test)] -mod tests { - use super::*; - use sqlx::sqlite::SqlitePoolOptions; - - async fn store() -> SqliteSamlRequestStore { - // max_connections(1) keeps the single in-memory database alive across queries — each - // `sqlite::memory:` connection is otherwise its own separate database. - let pool = SqlitePoolOptions::new() - .max_connections(1) - .connect("sqlite::memory:") - .await - .expect("in-memory SQLite pool"); - let store = SqliteSamlRequestStore::new(pool); - store.init().await.expect("init schema"); - store - } - - fn sample(request_id: &str, realm_id: &str, expires_at: i64) -> PendingSamlRequest { - PendingSamlRequest { - request_id: request_id.to_string(), - realm_id: realm_id.to_string(), - return_url: "https://app.example.com/home".to_string(), - created_at: 1_000, - expires_at, - } - } - - #[tokio::test] - async fn take_returns_the_stored_request_then_consumes_it() { - let store = store().await; - let future = Utc::now().timestamp() + 300; - store - .store_pending_request(&sample("id-1", "realm-a", future)) - .await - .unwrap(); - - let taken = store.take_pending_request("id-1", "realm-a").await.unwrap(); - assert_eq!(taken, Some(sample("id-1", "realm-a", future))); - - // Single-use: a second take of the same request id finds nothing. - let again = store.take_pending_request("id-1", "realm-a").await.unwrap(); - assert_eq!(again, None); - } - - #[tokio::test] - async fn take_rejects_a_wrong_realm() { - let store = store().await; - let future = Utc::now().timestamp() + 300; - store - .store_pending_request(&sample("id-1", "realm-a", future)) - .await - .unwrap(); - - // A request initiated by realm-a must not be consumable by realm-b's ACS. - let taken = store.take_pending_request("id-1", "realm-b").await.unwrap(); - assert_eq!(taken, None); - // And it remains available to its own realm (was not consumed by the wrong-realm attempt). - assert!( - store - .take_pending_request("id-1", "realm-a") - .await - .unwrap() - .is_some() - ); - } - - #[tokio::test] - async fn take_rejects_an_expired_request() { - let store = store().await; - let past = Utc::now().timestamp() - 1; - store - .store_pending_request(&sample("id-old", "realm-a", past)) - .await - .unwrap(); - - let taken = store - .take_pending_request("id-old", "realm-a") - .await - .unwrap(); - assert_eq!(taken, None); - } - - #[tokio::test] - async fn record_assertion_id_detects_replay() { - let store = store().await; - let future = Utc::now().timestamp() + 300; - - assert!( - store - .record_assertion_id("assertion-1", future) - .await - .unwrap() - ); - // Second time the same id is seen → replay. - assert!( - !store - .record_assertion_id("assertion-1", future) - .await - .unwrap() - ); - // A different id is still accepted. - assert!( - store - .record_assertion_id("assertion-2", future) - .await - .unwrap() - ); - } - - #[tokio::test] - async fn delete_expired_purges_only_expired_entries() { - let store = store().await; - let now = Utc::now().timestamp(); - store - .store_pending_request(&sample("fresh", "realm-a", now + 300)) - .await - .unwrap(); - store - .store_pending_request(&sample("stale", "realm-a", now - 1)) - .await - .unwrap(); - store - .record_assertion_id("assertion-fresh", now + 300) - .await - .unwrap(); - store - .record_assertion_id("assertion-stale", now - 1) - .await - .unwrap(); - - store.delete_expired().await.unwrap(); - - // The fresh pending request survives; the stale one is gone. - assert!( - store - .take_pending_request("fresh", "realm-a") - .await - .unwrap() - .is_some() - ); - assert!( - store - .take_pending_request("stale", "realm-a") - .await - .unwrap() - .is_none() - ); - // The stale assertion id was purged, so it is accepted again (no longer a known replay); - // the fresh one is still remembered. - assert!( - store - .record_assertion_id("assertion-stale", now + 300) - .await - .unwrap() - ); - assert!( - !store - .record_assertion_id("assertion-fresh", now + 300) - .await - .unwrap() - ); - } -} diff --git a/server/src/saml/request_store.rs b/server/src/saml/request_store.rs index 4df0bd83..63fd82ed 100644 --- a/server/src/saml/request_store.rs +++ b/server/src/saml/request_store.rs @@ -1,7 +1,8 @@ use async_trait::async_trait; +use chrono::Utc; use serde::{Deserialize, Serialize}; -use crate::AuthResult; +use crate::{AuthError, AuthResult}; /// A stored SP-initiated `` awaiting its matching ``. /// @@ -28,7 +29,8 @@ pub struct PendingSamlRequest { /// assertion IDs (for replay defense). #[async_trait] pub trait SamlRequestStore: Send + Sync { - /// Persist a pending SP-initiated `AuthnRequest`, keyed by its `request_id`. + /// Persist a pending SP-initiated `AuthnRequest`, keyed by its `request_id`. Fails if it + /// has already expired. async fn store_pending_request(&self, request: &PendingSamlRequest) -> AuthResult<()>; /// Atomically fetch and delete the pending request for `request_id` in `realm_id`, @@ -41,10 +43,24 @@ pub trait SamlRequestStore: Send + Sync { ) -> AuthResult>; /// Record a consumed assertion ID for replay defense. Returns `true` if it was newly - /// recorded, `false` if it had already been seen (a replay). `expires_at` bounds how - /// long the entry must be retained (typically the assertion's `NotOnOrAfter` + skew). + /// recorded, `false` if it had already been seen (a replay). Fails if `expires_at` has + /// already passed. `expires_at` must cover every instant at which the assertion could still + /// be accepted (its `NotOnOrAfter` plus the validator's clock-skew allowance), because the + /// entry may be forgotten after that. async fn record_assertion_id(&self, assertion_id: &str, expires_at: i64) -> AuthResult; /// Delete expired pending requests and replay-cache entries. Called periodically. async fn delete_expired(&self) -> AuthResult<()>; } + +/// Returns the remaining lifetime in seconds (always >= 1), or an error if `expires_at` has +/// passed: expired state is never stored, so no backend can hand it back or drop it early. +pub(super) fn ensure_unexpired(expires_at: i64, what: &str) -> AuthResult { + let remaining = expires_at - Utc::now().timestamp(); + if remaining <= 0 { + return Err(AuthError::Generic(format!( + "refusing to store an already-expired {what}" + ))); + } + Ok(remaining as u64) +} diff --git a/server/src/server/endpoints/super_admins_endpoints.rs b/server/src/server/endpoints/super_admins_endpoints.rs index 9508b83c..e64c075e 100644 --- a/server/src/server/endpoints/super_admins_endpoints.rs +++ b/server/src/server/endpoints/super_admins_endpoints.rs @@ -7,6 +7,18 @@ use actix_web::{ use cosmian_logger::info; use std::sync::Arc; +/// Without the `saml` feature there are no SAML endpoints and no way to validate SAML +/// settings, so they are refused instead of being stored unvalidated. +#[cfg(not(feature = "saml"))] +fn reject_saml_params(realm: &Realm) -> Result<(), AuthError> { + if realm.auth_params.saml_params.is_some() { + return Err(AuthError::BadRequest( + "SAML is not enabled on this server (built without the `saml` feature)".to_string(), + )); + } + Ok(()) +} + /// Create a new realm /// /// # Arguments @@ -30,6 +42,9 @@ pub async fn create_realm( )); } + #[cfg(not(feature = "saml"))] + reject_saml_params(&realm)?; + info!( "create_realm: authenticated admin '{}' is creating realm '{}'", requester.id, realm.id @@ -106,6 +121,9 @@ pub async fn update_realm( // Ensure the ID in the path matches the ID in the payload realm.id = realm_id; + #[cfg(not(feature = "saml"))] + reject_saml_params(&realm)?; + info!( "update_realm: '{}' is updating realm '{}'", requester.id, realm.id diff --git a/server/src/tests/mod.rs b/server/src/tests/mod.rs index 69fa8a55..b7dbe73e 100644 --- a/server/src/tests/mod.rs +++ b/server/src/tests/mod.rs @@ -32,6 +32,9 @@ mod sessions_api; mod sessions_store; +#[cfg(feature = "saml")] +mod saml_request_store; + mod super_admin_api; mod test_server; diff --git a/server/src/tests/saml_request_store.rs b/server/src/tests/saml_request_store.rs new file mode 100644 index 00000000..22a4cf25 --- /dev/null +++ b/server/src/tests/saml_request_store.rs @@ -0,0 +1,340 @@ +// SAML request store tests, run against the backend selected by the same variables as +// `sessions_store.rs` (in-memory SQLite by default): +// TEST_SESSIONS_STORE="postgresql" TEST_POSTGRES_URL="postgresql://auth:auth@localhost/auth" +// TEST_SESSIONS_STORE="mysql" TEST_MYSQL_URL="mysql://auth:auth@localhost/auth" +// TEST_SESSIONS_STORE="redis" TEST_REDIS_URL="redis://localhost:6379" +// Requires `--features auth_verifier/saml`. Every test uses unique IDs so runs can share a +// persistent database and execute in parallel. + +use crate::{ + DatabaseBackend, DatabaseParams, PendingSamlRequest, SamlRequestStore, + create_saml_request_store, +}; +use chrono::Utc; +use std::sync::Arc; +use tokio::{ + sync::Mutex, + time::{Duration, sleep}, +}; +use uuid::Uuid; + +// Concurrent `CREATE TABLE IF NOT EXISTS` can fail on PostgreSQL, so schema init is serialized. +static INIT_LOCK: Mutex<()> = Mutex::const_new(()); + +async fn store() -> Arc { + let selected = std::env::var("TEST_SESSIONS_STORE").unwrap_or_else(|_| "sqlite".to_string()); + let (backend, connection_url, max_connections) = match selected.as_str() { + // One connection: each `sqlite::memory:` connection is its own separate database. + "sqlite" => ( + DatabaseBackend::SQLite, + std::env::var("TEST_SQLITE_URL").unwrap_or_else(|_| "sqlite::memory:".to_string()), + 1, + ), + "postgresql" | "postgres" => ( + DatabaseBackend::PostgreSQL, + std::env::var("TEST_POSTGRES_URL") + .expect("TEST_POSTGRES_URL must be set when using postgresql store"), + 5, + ), + "mysql" => ( + DatabaseBackend::MySQL, + std::env::var("TEST_MYSQL_URL") + .expect("TEST_MYSQL_URL must be set when using mysql store"), + 5, + ), + "redis" => ( + DatabaseBackend::Redis, + std::env::var("TEST_REDIS_URL") + .expect("TEST_REDIS_URL must be set when using redis store"), + 5, + ), + other => panic!("Unknown TEST_SESSIONS_STORE value: {other}"), + }; + + let _guard = INIT_LOCK.lock().await; + create_saml_request_store(&DatabaseParams { + backend, + connection_url, + max_connections, + min_connections: 1, + connect_timeout_secs: 30, + idle_timeout_secs: 300, + auto_init_schema: true, + }) + .await + .expect("Failed to create SAML request store") +} + +fn unique(prefix: &str) -> String { + format!("{prefix}-{}", Uuid::new_v4()) +} + +fn now() -> i64 { + Utc::now().timestamp() +} + +fn pending(request_id: &str, realm_id: &str, expires_at: i64) -> PendingSamlRequest { + PendingSamlRequest { + request_id: request_id.to_string(), + realm_id: realm_id.to_string(), + return_url: "https://app.example.com/home".to_string(), + created_at: now(), + expires_at, + } +} + +#[tokio::test] +async fn test_take_returns_the_stored_request_then_consumes_it() { + let store = store().await; + let (id, realm) = (unique("req"), unique("realm")); + let request = pending(&id, &realm, now() + 300); + store + .store_pending_request(&request) + .await + .expect("store request"); + + let taken = store.take_pending_request(&id, &realm).await.expect("take"); + assert_eq!(taken, Some(request)); + + let again = store.take_pending_request(&id, &realm).await.expect("take"); + assert_eq!(again, None, "a pending request must be single-use"); +} + +#[tokio::test] +async fn test_take_rejects_a_wrong_realm() { + let store = store().await; + let (id, realm, other_realm) = (unique("req"), unique("realm"), unique("realm")); + store + .store_pending_request(&pending(&id, &realm, now() + 300)) + .await + .expect("store request"); + + let taken = store + .take_pending_request(&id, &other_realm) + .await + .expect("take"); + assert_eq!( + taken, None, + "another realm's ACS must not consume the request" + ); + assert!( + store + .take_pending_request(&id, &realm) + .await + .expect("take") + .is_some(), + "the wrong-realm attempt must not have consumed it" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_concurrent_takes_consume_a_request_exactly_once() { + let store = store().await; + let (id, realm) = (unique("req"), unique("realm")); + store + .store_pending_request(&pending(&id, &realm, now() + 300)) + .await + .expect("store request"); + + let takers: Vec<_> = (0..8) + .map(|_| { + let (store, id, realm) = (store.clone(), id.clone(), realm.clone()); + tokio::spawn(async move { store.take_pending_request(&id, &realm).await }) + }) + .collect(); + let mut consumed = 0; + for taker in takers { + if taker.await.expect("join").expect("take").is_some() { + consumed += 1; + } + } + assert_eq!(consumed, 1, "exactly one concurrent take must succeed"); +} + +#[tokio::test] +async fn test_store_refuses_an_already_expired_request() { + let store = store().await; + let (id, realm) = (unique("req"), unique("realm")); + + let result = store + .store_pending_request(&pending(&id, &realm, now() - 1)) + .await; + assert!(result.is_err(), "an expired request must not be stored"); + assert_eq!( + store.take_pending_request(&id, &realm).await.expect("take"), + None + ); +} + +#[tokio::test] +async fn test_take_rejects_a_request_that_expired_after_being_stored() { + let store = store().await; + let (id, realm) = (unique("req"), unique("realm")); + // Two seconds, not one: a second boundary between building and storing must not make the + // initial store see the request as already expired. + store + .store_pending_request(&pending(&id, &realm, now() + 2)) + .await + .expect("store request"); + + sleep(Duration::from_secs(3)).await; + + assert_eq!( + store.take_pending_request(&id, &realm).await.expect("take"), + None, + "an expired request must not be returned" + ); +} + +#[tokio::test] +async fn test_record_assertion_id_detects_replay() { + let store = store().await; + let (id, other_id) = (unique("assertion"), unique("assertion")); + + assert!( + store + .record_assertion_id(&id, now() + 300) + .await + .expect("record") + ); + assert!( + !store + .record_assertion_id(&id, now() + 300) + .await + .expect("record"), + "the same assertion id seen twice is a replay" + ); + assert!( + store + .record_assertion_id(&other_id, now() + 300) + .await + .expect("record") + ); +} + +#[tokio::test] +async fn test_record_assertion_id_refuses_an_already_expired_entry() { + let store = store().await; + + let result = store + .record_assertion_id(&unique("assertion"), now() - 1) + .await; + assert!( + result.is_err(), + "an entry that would be forgotten immediately must be refused" + ); +} + +#[tokio::test] +async fn test_ids_are_case_sensitive() { + let store = store().await; + let (id, realm) = (unique("Req-Mixed"), unique("realm")); + store + .store_pending_request(&pending(&id, &realm, now() + 300)) + .await + .expect("store request"); + + assert_eq!( + store + .take_pending_request(&id.to_lowercase(), &realm) + .await + .expect("take"), + None, + "request ids differing only in case are different requests" + ); + assert!( + store + .take_pending_request(&id, &realm) + .await + .expect("take") + .is_some() + ); + + let assertion_id = unique("Assertion-Mixed"); + assert!( + store + .record_assertion_id(&assertion_id, now() + 300) + .await + .expect("record") + ); + assert!( + store + .record_assertion_id(&assertion_id.to_lowercase(), now() + 300) + .await + .expect("record"), + "assertion ids differing only in case are different assertions" + ); +} + +#[tokio::test] +async fn test_long_assertion_ids_sharing_a_prefix_are_distinct() { + let store = store().await; + let prefix = format!("{}{}", unique("assertion"), "x".repeat(300)); + let (first, second) = (format!("{prefix}-first"), format!("{prefix}-second")); + + assert!( + store + .record_assertion_id(&first, now() + 300) + .await + .expect("record") + ); + assert!( + store + .record_assertion_id(&second, now() + 300) + .await + .expect("record"), + "ids that differ only after 255 characters are different assertions" + ); + assert!( + !store + .record_assertion_id(&first, now() + 300) + .await + .expect("record"), + "a long id is still recognized as a replay" + ); +} + +#[tokio::test] +async fn test_delete_expired_purges_only_expired_entries() { + let store = store().await; + let (fresh_request, realm) = (unique("req"), unique("realm")); + let (stale_assertion, fresh_assertion) = (unique("assertion"), unique("assertion")); + store + .store_pending_request(&pending(&fresh_request, &realm, now() + 300)) + .await + .expect("store request"); + store + .record_assertion_id(&stale_assertion, now() + 2) + .await + .expect("record"); + store + .record_assertion_id(&fresh_assertion, now() + 300) + .await + .expect("record"); + + sleep(Duration::from_secs(3)).await; + store.delete_expired().await.expect("delete expired"); + + assert!( + store + .record_assertion_id(&stale_assertion, now() + 300) + .await + .expect("record"), + "the expired entry must have been purged" + ); + assert!( + !store + .record_assertion_id(&fresh_assertion, now() + 300) + .await + .expect("record"), + "an unexpired entry must survive the purge" + ); + assert!( + store + .take_pending_request(&fresh_request, &realm) + .await + .expect("take") + .is_some(), + "an unexpired pending request must survive the purge" + ); +} diff --git a/server/src/tests/super_admin_api.rs b/server/src/tests/super_admin_api.rs index 471a55e9..c6f32763 100644 --- a/server/src/tests/super_admin_api.rs +++ b/server/src/tests/super_admin_api.rs @@ -204,6 +204,57 @@ async fn test_create_duplicate_realm_fails() -> AuthResult<()> { ctx.stop_server().await } +/// Without the `saml` feature, create and update must refuse SAML settings with a 400 +/// rather than store configuration that nothing can validate or use. +#[cfg(not(feature = "saml"))] +#[actix_web::test] +async fn test_saml_params_rejected_without_saml_feature() -> AuthResult<()> { + init_test_logging(None); + let ctx = start_default_test_server().await?; + let client = authenticate_as_admin(&ctx).await?; + + let mut new_realm = test_realm("realm_with_saml"); + new_realm.auth_params.saml_params = Some(crate::SamlParams::default()); + let err = client + .create_realm_as_super_admin(&new_realm) + .await + .expect_err("Expected create_realm to refuse SAML settings"); + assert!( + matches!(err, AuthError::FailedHttpStatus(ref m) if m.contains("400")), + "Expected a 400 Bad Request on create, got: {err:?}" + ); + + let mut existing = client.get_realm_as_super_admin(ADMIN_REALM).await?; + existing.auth_params.saml_params = Some(crate::SamlParams::default()); + let err = client + .update_realm_as_super_admin(ADMIN_REALM, &existing) + .await + .expect_err("Expected update_realm to refuse SAML settings"); + assert!( + matches!(err, AuthError::FailedHttpStatus(ref m) if m.contains("400")), + "Expected a 400 Bad Request on update, got: {err:?}" + ); + + assert!( + client + .get_realm_as_super_admin("realm_with_saml") + .await + .is_err(), + "The refused realm must not have been created" + ); + assert!( + client + .get_realm_as_super_admin(ADMIN_REALM) + .await? + .auth_params + .saml_params + .is_none(), + "The refused update must not have been stored" + ); + + ctx.stop_server().await +} + // ── Authorization enforcement ──────────────────────────────────────────────── /// A realm admin (non-super-admin) must not be able to update any realm (HTTP 403). From f6adb034241bd78b594fbd3915b48fadad8cb843 Mon Sep 17 00:00:00 2001 From: charming_wicket <235307685+charming-wicket-5502@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:36:52 +0200 Subject: [PATCH 04/19] feat(saml): validate SAML realm settings and add a server SP signing key --- CHANGELOG/feat_saml-integration.md | 8 +- admin-ui/src/types/api.ts | 2 - client/src/params/saml_params.rs | 9 +- report/ISSUES_CHECKLIST.md | 22 +- server/Cargo.toml | 4 +- server/documentation/docs/SUMMARY.md | 1 + .../adr/2026-09-25-saml-service-provider.md | 184 +++++++ server/documentation/nav.yml | 1 + server/documentation/openapi.yaml | 20 +- server/src/saml/mod.rs | 6 + server/src/saml/params_validation.rs | 470 ++++++++++++++++++ server/src/saml/sp_signing_key.rs | 133 +++++ server/src/server/auth_verifier.rs | 15 + .../endpoints/super_admins_endpoints.rs | 29 +- server/src/server/parameters/auth_params.rs | 12 + server/src/server/parameters/mod.rs | 2 +- server/src/server/parameters/server_params.rs | 7 +- server/src/tests/helpers.rs | 52 ++ server/src/tests/params.rs | 1 + server/src/tests/super_admin_api.rs | 128 +++++ 20 files changed, 1080 insertions(+), 26 deletions(-) create mode 100644 server/documentation/docs/adr/2026-09-25-saml-service-provider.md create mode 100644 server/src/saml/params_validation.rs create mode 100644 server/src/saml/sp_signing_key.rs diff --git a/CHANGELOG/feat_saml-integration.md b/CHANGELOG/feat_saml-integration.md index e4297faa..6d4392e8 100644 --- a/CHANGELOG/feat_saml-integration.md +++ b/CHANGELOG/feat_saml-integration.md @@ -5,12 +5,18 @@ - Add a `SamlRequestStore` (pending `AuthnRequest`s for `InResponseTo` correlation plus a consumed-assertion replay cache) with SQLite, PostgreSQL, MySQL and Redis backends and a `create_saml_request_store` factory, each enforcing single-use atomically in its native primitive, refusing already-expired entries so no backend can return an expired request or forget a consumed assertion early, and keeping IDs exact-match on MySQL (binary collation, SHA-256 replay keys) where the default collation ignores case and `INSERT IGNORE` truncates long IDs. - Add an optional `saml` Cargo feature (off by default) that pulls in `samael` and gates the server-side SAML module, so default builds, plain `cargo` and existing CI stay free of the new C dependencies. - Refuse realm create/update requests that carry `saml_params` on servers built without the `saml` feature (HTTP 400), so unusable and unvalidated SAML configuration is never stored. +- Validate SAML settings on realm create/update when the `saml` feature is enabled: parse the pasted IdP metadata and always derive the IdP entityID, HTTPS HTTP-Redirect SSO URL, signing certificates and NameID format from it (overwriting any values sent), and refuse expired, oversized or multi-entity metadata, an ACS URL that isn't `https://…/saml/{realm_id}/acs`, return URLs outside the https origin allowlist, empty/duplicate/reserved claim names, and transient NameIDs without a subject attribute, each with an HTTP 400 naming the field. +- Add a server-wide SAML SP signing key (`[saml_sp_params]`: RSA private key and certificate PEM paths), checked at startup (RSA of at least 2048 bits, certificate matching the key) and required before any realm can enable SAML, and drop `SamlParams.sp_signing_key` from the client, admin-ui and OpenAPI types, because the key is server configuration and must never travel through the database or the admin API. - Bundle a static, OpenSSL-only xmlsec 1.3.5 and libxml2 2.13.4 (`nix/xmlsec-static.nix`, built with the glibc-2.34 toolchain, XSLT and runtime crypto loading disabled) and wire it plus the libclang/bindgen setup into `shell.nix`, so `--features saml` links xmlsec statically against the server's vendored OpenSSL with no new runtime library dependency. ## Tests -- Add a SAML request store test suite that runs against in-memory SQLite by default and against PostgreSQL, MySQL or Redis through the same `TEST_SESSIONS_STORE` switch as the session-store tests (single-use and concurrent takes, realm scoping, expiry on write and on read, replay detection, case-sensitive and long IDs, expiry purge), an xmlsec smoke test that exercises libxml2, xmlsec and its OpenSSL backend end to end, and an API test checking that SAML settings are refused without the `saml` feature. +- Add a SAML request store test suite that runs against in-memory SQLite by default and against PostgreSQL, MySQL or Redis through the same `TEST_SESSIONS_STORE` switch as the session-store tests (single-use and concurrent takes, realm scoping, expiry on write and on read, replay detection, case-sensitive and long IDs, expiry purge), an xmlsec smoke test that exercises libxml2, xmlsec and its OpenSSL backend end to end, an API test checking that SAML settings are refused without the `saml` feature, unit tests for every SAML settings validation rule (prefixed and unprefixed metadata), and an API test checking that valid settings are stored with metadata-derived IdP fields while invalid ones get a field-specific 400, plus unit tests for the SAML signing-key checks and API tests checking that SAML realms are refused without a signing key and that a mismatched key, or the key section on a build without `saml`, stops the server from starting. ## CI - Add a Nix-based `cargo-saml` job that runs clippy and the unit tests with `--features saml` inside `nix-shell`, since that feature needs the bundled xmlsec and libclang that the plain-cargo jobs don't have. + +## Docs + +- Add ADR-0003 recording why SAML uses `samael` with a statically bundled, OpenSSL-only xmlsec, why the server-side feature is behind an optional `saml` Cargo feature while the API types are not, the v1 protocol scope, the single server-wide RSA SP signing key, and the rejected alternatives and accepted risks (including that xmlsec/libxml2 security updates now ship with our releases). diff --git a/admin-ui/src/types/api.ts b/admin-ui/src/types/api.ts index 6b29641f..4c481943 100644 --- a/admin-ui/src/types/api.ts +++ b/admin-ui/src/types/api.ts @@ -40,8 +40,6 @@ export interface SamlParams { sp_entity_id: string; /** This SP's Assertion Consumer Service URL (/saml/{realm_id}/acs). */ sp_acs_url: string; - /** Reference to the server-configured AuthnRequest signing key; unset = server default. */ - sp_signing_key?: string; /** SAML attribute whose single value becomes the subject; when unset the is used. */ subject_attribute?: string; /** Lowercase the resolved subject before use. */ diff --git a/client/src/params/saml_params.rs b/client/src/params/saml_params.rs index 46a81370..f81deb5c 100644 --- a/client/src/params/saml_params.rs +++ b/client/src/params/saml_params.rs @@ -6,8 +6,8 @@ use serde::{Deserialize, Serialize}; /// /// Stored inside [`crate::RealmAuthParams`] and serialized into the realm's `auth_params` /// column. The IdP-side fields (`idp_entity_id`, `idp_sso_url`, `idp_signing_certificates`, -/// `idp_nameid_format`) are **server-derived**: they are parsed and populated from -/// `metadata_xml` when the realm is created or updated, not supplied directly by the caller. +/// `idp_nameid_format`) are **server-derived**: the server parses `metadata_xml` when the +/// realm is created or updated and overwrites them, ignoring any values the caller sent. /// Callers provide `metadata_xml`, the SP-side fields, the identity mapping, and the /// return-URL policy. #[derive(Clone, Debug, Serialize, Deserialize, Default)] @@ -46,11 +46,6 @@ pub struct SamlParams { /// our published SP metadata. pub sp_acs_url: String, - /// Reference to the server-configured signing key used to sign our `` and - /// published in our SP metadata. `None` uses the server's default SP signing key. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sp_signing_key: Option, - // ── Identity mapping — assertion → session claims ───────────────────────────────── /// SAML attribute whose single value becomes the session subject. When unset, the /// `` is used instead; a `transient` NameID with no subject attribute is diff --git a/report/ISSUES_CHECKLIST.md b/report/ISSUES_CHECKLIST.md index 4b27ff6b..3d4e246b 100644 --- a/report/ISSUES_CHECKLIST.md +++ b/report/ISSUES_CHECKLIST.md @@ -9,6 +9,7 @@ ## BLOCKING ISSUES — Must Fix Before Merge ### ✋ Issue 1: SAML Store Factory Never Instantiated + - **Severity:** P1 (Runtime non-functional) - **File:** `server/src/saml/factory.rs:20–22` - **Problem:** `create_saml_request_store()` is never called. SAML realms have no login path. @@ -24,8 +25,9 @@ --- ### ✋ Issue 2: SQL Cleanup Never Scheduled + - **Severity:** P1 (Resource leak — unbounded table growth) -- **Files:** +- **Files:** - `server/src/saml/impls/sqlite.rs:157–160` (delete_expired method) - `server/src/saml/factory.rs:17–20` (factory, no cleanup task) - **Problem:** Expired rows never purged. Tables grow indefinitely. @@ -43,6 +45,7 @@ --- ### ✋ Issue 3: Redis Returns Expired Pending Requests + - **Severity:** P1 (Logic violation — accepts invalid requests) - **File:** `server/src/saml/impls/redis.rs:52` - **Problem:** `.max(1)` clamps negative TTL to 1 second. Expired requests stored + fetchable. @@ -51,6 +54,7 @@ - **Complexity:** Low **Option A (Recommended):** + ```rust // In store_pending_request if request.expires_at <= Utc::now().timestamp() { @@ -59,6 +63,7 @@ if request.expires_at <= Utc::now().timestamp() { ``` **Option B (Alternative):** + ```rust // In take_pending_request, after GETDEL let now = Utc::now().timestamp(); @@ -74,6 +79,7 @@ if request.expires_at <= now { --- ### ✋ Issue 4: Build Broken — samael+xmlsec Unconditional + - **Severity:** P1 (Build breaks — blocks all CI) - **Files:** - `Cargo.toml` line 39 (samael = "0.0.22" with features = ["xmlsec"]) @@ -89,6 +95,7 @@ if request.expires_at <= now { - **Fix:** Gate behind feature flag OR update build infra **Option A (Recommended — Feature Flag):** + ```toml [dependencies] samael = { version = "0.0.22", features = ["xmlsec"], optional = true } @@ -96,11 +103,13 @@ samael = { version = "0.0.22", features = ["xmlsec"], optional = true } [features] saml = ["samael"] ``` + - Default: no samael, no xmlsec needed - Dev: `cargo build --features saml` - Unblocks all CI until SAML endpoints ready **Option B (Alternative — Update Build Infra):** + 1. Add `xmlsec1-dev` to Ubuntu runners in main_base.yml 2. Add `libxml2-devel`, `xmlsec1-devel` to macOS via Homebrew 3. Update nix/auth-verifier.nix (see Issue 5) @@ -112,6 +121,7 @@ saml = ["samael"] --- ### ✋ Issue 5: Nix Package Build Missing xmlsec Setup + - **Severity:** P1 (Build breaks — nix build fails) - **File:** `nix/auth-verifier.nix` (not modified in PR; needs changes) - **Problem:** @@ -123,6 +133,7 @@ saml = ["samael"] - **Fix:** Update auth-verifier.nix OR use feature flag (Issue 4 Option A) **If continuing with unconditional samael:** + ```nix { buildInputs = [ @@ -145,6 +156,7 @@ saml = ["samael"] --- ### ✋ Issue 6: Pre-Release Dependency Without Audit — samael 0.0.22 + - **Severity:** P1 (Dependency risk) - **File:** `Cargo.toml` line 39 (workspace root) - **Problem:** @@ -164,10 +176,12 @@ saml = ["samael"] - Document samael's pre-release status in SECURITY.md - Track timeline for upgrade to stable - [ ] **Owner:** Pin samael and OpenSSL transitive deps + ```toml samael = "=0.0.22" # Exact version until stable openssl = "=0.10.81" # Lock transitive ``` + - [ ] **Reviewer:** Approve security audit before merge --- @@ -175,6 +189,7 @@ saml = ["samael"] ## HIGH-PRIORITY ISSUES — Strongly Recommended Before Merge ### ⚠️ Issue 7: MySQL Breaks SAML ID Case-Sensitivity + - **Severity:** P2 (Data integrity — ID mismatches) - **File:** `server/src/saml/impls/mysql.rs:24–26` - **Problem:** Default collation `utf8mb4_general_ci` is case-insensitive. SAML IDs are case-sensitive. @@ -183,10 +198,12 @@ saml = ["samael"] - Assertion ID replay cache collisions on case difference - False replay positives → valid logins rejected - **Fix:** Use binary collation + ```sql request_id TEXT COLLATE utf8mb4_bin PRIMARY KEY, assertion_id TEXT COLLATE utf8mb4_bin PRIMARY KEY, ``` + - **Complexity:** Low (schema change only) - [ ] **Owner:** Update MySQL schema @@ -195,6 +212,7 @@ saml = ["samael"] --- ### ⚠️ Issue 8: MySQL Assertion IDs Silently Truncated to 255 Chars + - **Severity:** P2 (Data integrity — false replay positives) - **File:** `server/src/saml/impls/mysql.rs:42–45` - **Problem:** `VARCHAR(255)` + `INSERT IGNORE` silently truncates long IDs. SAML has no length limit. @@ -202,6 +220,7 @@ saml = ["samael"] - Distinct assertions with same 255-char prefix treated as replay - Valid logins denied - **Fix:** Use TEXT or digest-based key + ```sql -- Option 1: Full ID as TEXT assertion_id TEXT NOT NULL PRIMARY KEY, @@ -210,6 +229,7 @@ saml = ["samael"] assertion_id_hash BLOB NOT NULL PRIMARY KEY, full_assertion_id TEXT NOT NULL, ``` + - **Complexity:** Medium (schema + insert logic update) - [ ] **Owner:** Update MySQL schema diff --git a/server/Cargo.toml b/server/Cargo.toml index 6165a18a..5f257ce9 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -22,8 +22,8 @@ swagger-ui = [] # Serve the pre-built admin UI static files at /admin-ui admin-ui = ["dep:actix-files"] # SAML 2.0 Service Provider (SP-initiated Web Browser SSO). Needs the bundled xmlsec C -# library provided by the Nix shell (nix/xmlsec-static.nix). -saml = ["dep:samael"] +# library provided by the Nix shell (nix/xmlsec-static.nix). openssl: samael's key type. +saml = ["dep:samael", "dep:openssl"] [dependencies] actix-cors = { workspace = true } diff --git a/server/documentation/docs/SUMMARY.md b/server/documentation/docs/SUMMARY.md index b4060dce..1a090dec 100644 --- a/server/documentation/docs/SUMMARY.md +++ b/server/documentation/docs/SUMMARY.md @@ -15,3 +15,4 @@ - [Architecture Decision Records]() - [Role / JWT / OPA integration](adr/2026-06-24-role-jwt-opa-integration.md) - [App auth API for SPIRE](adr/2026-07-26-app-auth-api-for-spire.md) + - [SAML 2.0 Service Provider](adr/2026-09-25-saml-service-provider.md) diff --git a/server/documentation/docs/adr/2026-09-25-saml-service-provider.md b/server/documentation/docs/adr/2026-09-25-saml-service-provider.md new file mode 100644 index 00000000..862ddab8 --- /dev/null +++ b/server/documentation/docs/adr/2026-09-25-saml-service-provider.md @@ -0,0 +1,184 @@ +# ADR-0003 — SAML 2.0 Service Provider: samael, statically bundled xmlsec, and the `saml` feature + +| Field | Value | +|------------|------------------------------------------------| +| **Status** | Accepted | +| **Date** | 2026-09-25 | +| **Branch** | `feat/saml-integration` | +| **PR** | *(to be filled when opened)* | +| **Scope** | Authentication Server (build, packaging, SAML) | + +--- + +## Context + +Authentication Verifier already authenticates humans with username/password (optionally +with TOTP), accepts JWT/OIDC bearer tokens, and authenticates machines through mTLS, +AppRole and Kubernetes. The users none of these reach are **people whose identity lives +in a corporate identity provider (IdP) that only speaks SAML 2.0**, where the organization +requires federation (no local passwords, MFA and offboarding handled by the IdP). Serving +them requires Authentication Verifier to act as a SAML **Service Provider (SP)**: redirect +the browser to the IdP, receive the IdP's signed response, verify it, and issue the usual +session JWT (`_ea_` cookie). + +The security of an SP rests on XML Signature verification (XML-DSig), XML +canonicalization and XML parsing — historically the source of SAML's worst vulnerabilities +(XML Signature Wrapping, canonicalization bugs, XXE). Normative references: OASIS SAML 2.0 +Core, Bindings, Profiles, Metadata and Security Considerations (`saml-*-2.0-os`). + +### Problem + +The Rust ecosystem has no stable, audited SAML SP library. Whatever is chosen, the +signature-verification path must be trustworthy, and the result must still ship the way +this server ships today: one binary that runs on any Linux with glibc ≥ 2.34 (Rocky +Linux 9), with statically linked dependencies (only the base system — glibc and +`libgcc_s` — is dynamic) and packages that declare no runtime dependencies. + +## Decisions + +### Decision 1 — Use `samael` with its `xmlsec` feature + +`samael` (0.0.22, MIT, the de-facto Rust SAML 2.0 crate) covers exactly the flow we need: +SP-initiated SSO with the HTTP-Redirect/HTTP-POST bindings, response and assertion +validation helpers, and SAML metadata. Its `xmlsec` feature performs XML-DSig through +**xmlsec1**, the long-established C library used by most SAML implementations, via bindgen. + +The deciding criterion is the signature-verification path: reusing a battle-tested +XML-DSig implementation is safer than writing one or adopting a young one. `samael` +itself is pre-1.0; its version is pinned by `Cargo.lock` (and a `0.0.x` requirement +already matches a single version), and its verification path gets a dedicated security +review before SAML is released. + +### Decision 2 — Bundle xmlsec and libxml2 statically, built for glibc 2.34 + +`nix/xmlsec-static.nix` builds **xmlsec 1.3.5** and **libxml2 2.13.4** from current +upstream sources (taken from the pinned nixpkgs) with the glibc-2.34 toolchain used for +release builds, as static archives: + +- **OpenSSL backend only** (`--without-gnutls --without-gcrypt --without-nss`), linked + directly (`--disable-crypto-dl`, so no `libltdl`). At link time its OpenSSL symbols + resolve against the server's **vendored OpenSSL**; OpenSSL 3.0 headers are used only to + compile. This keeps a single OpenSSL in the process. +- **No XSLT** (`--without-libxslt`): SAML never needs XSLT transforms, and they are an + attack surface in signed XML. The resulting build also has HTTP/FTP fetching and MD5 + compiled out. +- The library descriptors (`.pc`, `xmlsec1-config`) are patched so that no shared OpenSSL + library directory reaches the linker. + +`shell.nix` provides this library plus libclang and the bindgen include setup needed to +compile `samael`. + +Verified on Linux x86_64 (a test binary that calls into xmlsec): its only shared +dependencies are `libc`, `libm`, `libgcc_s` and the dynamic loader — the same list as the +current release binary; it embeds exactly one OpenSSL (3.6.2, the vendored copy); the +bundled archives reference no glibc symbols newer than 2.34; the size cost is under +3 MB on a 35 MB binary. + +Wiring the Nix release derivation (`nix/auth-verifier.nix`) is deferred until SAML code +first calls `samael` from the server binary; before that, the linker drops the library +and a release-build linkage check would prove nothing. + +### Decision 3 — Put the whole server-side SAML feature behind an optional `saml` feature + +`saml = ["dep:samael"]`, **off by default**, modelled on `swagger-ui` and `admin-ui`: +the server-side SAML module (request store, factory, and later metadata, verification and +the `/saml/...` routes) compiles only with the feature. Default builds, plain `cargo` and +the existing CI jobs therefore need no C toolchain; a dedicated Nix-based CI job builds and +tests with the feature on. + +The API contract types in the `auth_client` crate (`SamlParams`, +`RealmAuthParams.saml_params`, `AuthScheme::Saml`) are **not** gated. The client crate +never gates wire types (the scheme enum already lists methods the server does not +implement), and a client compiled without them would silently drop `saml_params` when +reading and re-saving a realm, because unknown fields are ignored. Instead, a server built +without `saml` refuses realm create/update requests carrying `saml_params` (HTTP 400), so +unusable configuration is never stored. + +### Decision 4 — v1 protocol scope + +- **SP-initiated Web Browser SSO only.** Every response must answer an `AuthnRequest` this + server issued (`InResponseTo` correlation); unsolicited IdP-initiated responses are + rejected, which removes a class of login-CSRF and injection attacks. +- **HTTP-Redirect** for the `AuthnRequest`, which is **signed**; **HTTP-POST** for the + response. No Artifact or URI binding. +- **Signed assertions over TLS**; encrypted assertions are not supported. +- **No Single Logout**: logging out ends the local session only. +- **IdP metadata is pasted by an administrator** and validated when the realm is saved; + no metadata URL fetching. + +Anything outside this scope is added only for a concrete client need. + +### Decision 5 — One server-wide RSA signing key, configured in the server file + +The SP signing key (it signs our `AuthnRequest`s, and its certificate is published in +our SP metadata) is a single key shared by all SAML realms. It is set as two PEM file +paths in a new `[saml_sp_params]` section, the same way as the session and certificate +JWT keys, and never goes through the database or the admin API. `SamlParams` therefore +has no signing-key field. + +- **RSA only, at least 2048 bits.** RSA is what IdPs universally accept. `samael` also + signs with EC keys, but it emits DER-encoded ECDSA signatures where XML-DSig expects + the raw `r‖s` form, which strict IdPs reject. +- **Checked at startup.** An unreadable file, a non-RSA or too-small key, or a + certificate that doesn't match the key stops the server. A server built without `saml` + refuses to start with the section set rather than ignoring it. +- **Required for SAML realms.** Realm create/update refuses `saml_params` (HTTP 400) + while no key is configured. + +Per-realm keys would only matter if one server fronted tenants that must not share an SP +identity; they can be added then without changing the realm API for existing realms. + +## Consequences + +### Positive + +- No new runtime dependency for customers; packages stay dependency-free. +- One OpenSSL in the process, and a smaller attack surface (no XSLT, no network fetching, + no runtime plugin loading). +- Builds without SAML are unaffected, and unused SAML configuration cannot be stored. + +### Negative / risks + +- **We own security updates for xmlsec and libxml2.** Because they are compiled in, + operating-system updates no longer patch them. An advisory in either library requires + bumping their sources (via the pinned nixpkgs) and shipping a new release. +- **Pre-1.0 dependency**: `samael` may change its API between releases and has no public + audit; mitigated by version pinning and the pre-release security review. +- **Building with `saml` requires the Nix shell** (bundled xmlsec, libclang); plain `cargo` + can only build without it. +- **Platform coverage**: the bundled build is verified on Linux x86_64 only so far; + ARM64 and macOS must be verified before they ship SAML. + +## Alternatives considered + +### A — Pure-Rust XML-DSig (`bergshamra` crates, `uppsala`) + +**Rejected for now**: no C dependency, but the crates are about seven months old +(0.9 / 0.10), unaudited, and not integrated with `samael`. Revisit once one reaches +maturity or an audit and is usable with a SAML library. + +### B — Implement XML-DSig and canonicalization ourselves + +**Rejected**: this is precisely the code where SAML vulnerabilities historically occur; +re-implementing it would trade a dependency risk for a larger, untested one. + +### C — Link the distribution's shared xmlsec1 + +**Rejected**: it would be the first runtime package dependency; the distribution's +version varies per target (the glibc-2.34 package set only offers xmlsec 1.2.33 and +libxml2 2.9.14); and xmlsec would load the system's shared OpenSSL next to the server's +vendored one. + +### D — Accept SAML assertions from a third party instead of being the SP (RFC 7522-style) + +**Rejected**: assertions are bound to a specific audience and recipient and are single-use, +so someone must still act as the SP to obtain them, and the XML-DSig work remains. The +browser SP flow serves the target users directly. + +## Related documents + +- `nix/xmlsec-static.nix` — the bundled xmlsec/libxml2 build. +- `shell.nix` — development shell providing the bundled library and bindgen setup. +- `server/Cargo.toml` — the `saml` feature definition. +- OASIS SAML 2.0 standard: +- `samael`: diff --git a/server/documentation/nav.yml b/server/documentation/nav.yml index f08879da..a3a95169 100644 --- a/server/documentation/nav.yml +++ b/server/documentation/nav.yml @@ -14,3 +14,4 @@ nav: - Architecture Decision Records: - Role / JWT / OPA integration: adr/2026-06-24-role-jwt-opa-integration.md - App auth API for SPIRE: adr/2026-07-26-app-auth-api-for-spire.md + - SAML 2.0 Service Provider: adr/2026-09-25-saml-service-provider.md diff --git a/server/documentation/openapi.yaml b/server/documentation/openapi.yaml index 4119def7..7dea6111 100644 --- a/server/documentation/openapi.yaml +++ b/server/documentation/openapi.yaml @@ -173,7 +173,11 @@ components: SamlParams: type: object - required: [sp_entity_id, sp_acs_url, default_return_url] + description: | + Validated when the realm is created or updated; servers built without the `saml` + feature, or without a configured SAML signing key, refuse it. The IdP fields are + always derived from `metadata_xml`, overwriting any values sent. + required: [metadata_xml, sp_entity_id, sp_acs_url, default_return_url] properties: idp_entity_id: type: string @@ -202,10 +206,6 @@ components: type: string format: uri description: This SP's Assertion Consumer Service URL (/saml/{realm_id}/acs). - sp_signing_key: - type: string - nullable: true - description: Reference to the server-configured AuthnRequest signing key; unset uses the server default. subject_attribute: type: string nullable: true @@ -1038,6 +1038,12 @@ components: responses: NoContent: description: Success — empty body. + BadRequest: + description: Invalid request body; the message names the offending field. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' Unauthorized: description: Missing or invalid credentials. content: @@ -1554,6 +1560,8 @@ paths: application/json: schema: $ref: '#/components/schemas/Realm' + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': @@ -1645,6 +1653,8 @@ paths: application/json: schema: $ref: '#/components/schemas/Realm' + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': diff --git a/server/src/saml/mod.rs b/server/src/saml/mod.rs index 75277f14..5879a42d 100644 --- a/server/src/saml/mod.rs +++ b/server/src/saml/mod.rs @@ -7,6 +7,12 @@ pub use impls::SqliteSamlRequestStore; mod factory; pub use factory::create_saml_request_store; +mod params_validation; +pub(crate) use params_validation::validate_saml_params; + +mod sp_signing_key; +pub(crate) use sp_signing_key::load_sp_signing_key; + #[cfg(test)] mod tests { use samael::crypto::{CertificateDer, Crypto, CryptoProvider}; diff --git a/server/src/saml/params_validation.rs b/server/src/saml/params_validation.rs new file mode 100644 index 00000000..68c0b89e --- /dev/null +++ b/server/src/saml/params_validation.rs @@ -0,0 +1,470 @@ +//! Validates a realm's SAML settings when the realm is created or updated. +//! +//! The IdP fields of [`SamlParams`] are always derived from the pasted IdP metadata, so a +//! caller cannot supply an SSO URL or signing certificate that the metadata does not state. + +use std::collections::HashSet; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use chrono::Utc; +use samael::metadata::{ + EntityDescriptor, EntityDescriptorType, HTTP_REDIRECT_BINDING, IdpSsoDescriptor, de, +}; +use url::Url; +use x509_cert::{ + Certificate, + der::{Decode, EncodePem, pem::LineEnding}, +}; + +use crate::{AuthError, AuthResult, SamlParams, reject_reserved_claim_names}; + +const SAML2_PROTOCOL: &str = "urn:oasis:names:tc:SAML:2.0:protocol"; +const NAMEID_TRANSIENT: &str = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient"; +/// Largest IdP metadata accepted. Real single-IdP metadata is well under this; federation +/// aggregates (thousands of IdPs) are not supported. +const MAX_METADATA_BYTES: usize = 256 * 1024; + +fn invalid(field: &str, reason: impl std::fmt::Display) -> AuthError { + AuthError::BadRequest(format!("saml_params.{field}: {reason}")) +} + +/// Validate `params` for `realm_id` and fill in its IdP fields from `metadata_xml`. +pub(crate) fn validate_saml_params(params: &mut SamlParams, realm_id: &str) -> AuthResult<()> { + let xml = params + .metadata_xml + .as_deref() + .map(str::trim) + .filter(|xml| !xml.is_empty()) + .ok_or_else(|| invalid("metadata_xml", "the IdP metadata XML is required"))?; + let idp = parse_idp_metadata(xml)?; + + params.idp_entity_id = idp.entity_id; + params.idp_sso_url = idp.sso_url; + params.idp_signing_certificates = idp.signing_certificates; + params.idp_nameid_format = idp.nameid_format; + + validate_sp_fields(params, realm_id)?; + validate_identity_mapping(params)?; + validate_return_urls(params) +} + +struct IdpMetadata { + entity_id: String, + sso_url: String, + signing_certificates: Vec, + nameid_format: Option, +} + +fn parse_idp_metadata(xml: &str) -> AuthResult { + if xml.len() > MAX_METADATA_BYTES { + return Err(invalid( + "metadata_xml", + format!("larger than the {MAX_METADATA_BYTES}-byte limit"), + )); + } + let document: EntityDescriptorType = de::from_str(xml).map_err(|e| { + invalid( + "metadata_xml", + format!("not a SAML 2.0 document: {e}"), + ) + })?; + let entity: EntityDescriptor = match document { + EntityDescriptorType::EntityDescriptor(entity) => entity, + EntityDescriptorType::EntitiesDescriptor(_) => { + return Err(invalid( + "metadata_xml", + "an bundle of several entities; paste the metadata of the single IdP", + )); + } + }; + + let entity_id = entity + .entity_id + .filter(|id| !id.trim().is_empty()) + .ok_or_else(|| invalid("metadata_xml", "missing the IdP entityID"))?; + if entity + .valid_until + .is_some_and(|valid_until| valid_until <= Utc::now()) + { + return Err(invalid( + "metadata_xml", + "the metadata has expired (validUntil is in the past)", + )); + } + + let idp = entity + .idp_sso_descriptors + .unwrap_or_default() + .into_iter() + .find(|d| { + d.protocol_support_enumeration + .as_deref() + .is_some_and(|p| p.split_whitespace().any(|p| p == SAML2_PROTOCOL)) + }) + .ok_or_else(|| { + invalid( + "metadata_xml", + "no IDPSSODescriptor supporting the SAML 2.0 protocol", + ) + })?; + + Ok(IdpMetadata { + entity_id, + sso_url: redirect_sso_url(&idp)?, + signing_certificates: signing_certificates(&idp)?, + nameid_format: idp.name_id_formats.first().map(|f| f.trim().to_string()), + }) +} + +fn redirect_sso_url(idp: &IdpSsoDescriptor) -> AuthResult { + let service = idp + .single_sign_on_services + .iter() + .find(|s| s.binding == HTTP_REDIRECT_BINDING) + .ok_or_else(|| { + invalid( + "metadata_xml", + "no SingleSignOnService with the HTTP-Redirect binding", + ) + })?; + let url = Url::parse(service.location.trim()).map_err(|e| { + invalid( + "metadata_xml", + format!("invalid SingleSignOnService URL: {e}"), + ) + })?; + if url.scheme() != "https" { + return Err(invalid( + "metadata_xml", + "the SingleSignOnService URL must use https", + )); + } + Ok(url.to_string()) +} + +/// Signing certificates, as PEM. A `KeyDescriptor` without `use` applies to both signing +/// and encryption (SAML Metadata §2.4.1.1), so it counts as a signing key. +fn signing_certificates(idp: &IdpSsoDescriptor) -> AuthResult> { + let mut certificates = Vec::new(); + for key in &idp.key_descriptors { + if key.key_use.as_deref().is_some_and(|u| u != "signing") { + continue; + } + for base64_der in key + .key_info + .x509_data + .iter() + .flat_map(|data| &data.certificates) + { + certificates.push(der_certificate_to_pem(base64_der)?); + } + } + if certificates.is_empty() { + return Err(invalid( + "metadata_xml", + "no signing certificate in the IDPSSODescriptor", + )); + } + Ok(certificates) +} + +fn der_certificate_to_pem(base64_der: &str) -> AuthResult { + let compact: String = base64_der.chars().filter(|c| !c.is_whitespace()).collect(); + let der = STANDARD.decode(compact).map_err(|e| { + invalid( + "metadata_xml", + format!("an X509Certificate is not valid base64: {e}"), + ) + })?; + let certificate = Certificate::from_der(&der).map_err(|e| { + invalid( + "metadata_xml", + format!("an X509Certificate is not a valid certificate: {e}"), + ) + })?; + certificate + .to_pem(LineEnding::LF) + .map_err(|e| AuthError::Unexpected(format!("failed to PEM-encode a certificate: {e}"))) +} + +fn validate_sp_fields(params: &SamlParams, realm_id: &str) -> AuthResult<()> { + if params.sp_entity_id.trim().is_empty() { + return Err(invalid("sp_entity_id", "must not be empty")); + } + let acs = Url::parse(¶ms.sp_acs_url) + .map_err(|e| invalid("sp_acs_url", format!("not a valid URL: {e}")))?; + let expected_path = format!("/saml/{realm_id}/acs"); + if acs.scheme() != "https" || !acs.path().ends_with(&expected_path) { + return Err(invalid( + "sp_acs_url", + format!("must be an https URL ending with {expected_path}"), + )); + } + Ok(()) +} + +fn validate_identity_mapping(params: &SamlParams) -> AuthResult<()> { + let subject_attribute_set = params + .subject_attribute + .as_deref() + .is_some_and(|a| !a.trim().is_empty()); + if !subject_attribute_set && params.idp_nameid_format.as_deref() == Some(NAMEID_TRANSIENT) { + return Err(invalid( + "subject_attribute", + "required because the IdP issues transient NameIDs, which change on every login", + )); + } + + if params + .attribute_claim_map + .keys() + .any(|attribute| attribute.trim().is_empty()) + { + return Err(invalid( + "attribute_claim_map", + "SAML attribute names must not be empty", + )); + } + let mut claim_names = HashSet::new(); + for claim in params.attribute_claim_map.values() { + if claim.trim().is_empty() { + return Err(invalid( + "attribute_claim_map", + "claim names must not be empty", + )); + } + if !claim_names.insert(claim) { + return Err(invalid( + "attribute_claim_map", + format!("two SAML attributes map to the same claim '{claim}'"), + )); + } + } + reject_reserved_claim_names(params.attribute_claim_map.values()).map_err(|e| match e { + AuthError::BadRequest(reason) => invalid("attribute_claim_map", reason), + other => other, + }) +} + +fn validate_return_urls(params: &SamlParams) -> AuthResult<()> { + let mut allowed = HashSet::new(); + for origin in ¶ms.allowed_return_origins { + let url = Url::parse(origin).map_err(|e| { + invalid( + "allowed_return_origins", + format!("'{origin}' is not a valid URL: {e}"), + ) + })?; + if url.scheme() != "https" + || url.host_str().is_none() + || url.path() != "/" + || url.query().is_some() + || url.fragment().is_some() + || !url.username().is_empty() + { + return Err(invalid( + "allowed_return_origins", + format!("'{origin}' must be an https origin such as https://app.example.com"), + )); + } + allowed.insert(url.origin().ascii_serialization()); + } + + let default = Url::parse(¶ms.default_return_url) + .map_err(|e| invalid("default_return_url", format!("not a valid URL: {e}")))?; + if default.scheme() != "https" { + return Err(invalid("default_return_url", "must use https")); + } + if !allowed.contains(&default.origin().ascii_serialization()) { + return Err(invalid( + "default_return_url", + "its origin must be listed in allowed_return_origins", + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tests::helpers::{test_idp_certificate_base64, test_idp_metadata, test_saml_params}; + use std::collections::HashMap; + use x509_cert::der::DecodePem; + + const REALM: &str = "acme"; + const TEST_CERT_PEM: &str = include_str!("../tests/certificates/ec/auth.server.cert.pem"); + + fn valid_metadata() -> String { + test_idp_metadata(HTTP_REDIRECT_BINDING, "https://idp.example.com/sso", "") + } + + fn params(metadata_xml: String) -> SamlParams { + SamlParams { + metadata_xml: Some(metadata_xml), + attribute_claim_map: HashMap::from([("department".to_string(), "dept".to_string())]), + ..test_saml_params(REALM) + } + } + + fn rejection(mut params: SamlParams) -> String { + match validate_saml_params(&mut params, REALM) { + Err(AuthError::BadRequest(message)) => message, + other => panic!("expected a BadRequest rejection, got {other:?}"), + } + } + + #[test] + fn valid_settings_derive_the_idp_fields_from_the_metadata() { + let mut params = params(valid_metadata()); + params.idp_entity_id = "https://attacker.example.com".to_string(); + params.idp_signing_certificates = vec!["-----BEGIN CERTIFICATE-----\nforged".to_string()]; + + validate_saml_params(&mut params, REALM).expect("valid settings"); + + assert_eq!(params.idp_entity_id, "https://idp.example.com/metadata"); + assert_eq!(params.idp_sso_url, "https://idp.example.com/sso"); + assert_eq!(params.idp_signing_certificates.len(), 1); + let reparsed = Certificate::from_pem(params.idp_signing_certificates[0].as_bytes()) + .expect("stored certificate is valid PEM"); + let original = Certificate::from_pem(TEST_CERT_PEM.as_bytes()).expect("test certificate"); + assert_eq!(reparsed, original); + } + + #[test] + fn unprefixed_metadata_is_accepted() { + let xml = valid_metadata().replace("".to_string())).contains("saml_params.metadata_xml")); + } + + #[test] + fn a_multi_entity_bundle_is_rejected_with_a_clear_message() { + let bundle = format!( + r#"{}"#, + valid_metadata().replace(r#" xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata""#, "") + ); + assert!(rejection(params(bundle)).contains("single IdP")); + } + + #[test] + fn oversized_metadata_is_rejected() { + let padding = format!("", "x".repeat(MAX_METADATA_BYTES)); + let xml = valid_metadata().replace( + "{NAMEID_TRANSIENT}"); + let xml = test_idp_metadata( + HTTP_REDIRECT_BINDING, + "https://idp.example.com/sso", + &transient, + ); + assert!(rejection(params(xml.clone())).contains("saml_params.subject_attribute")); + + let mut with_subject = params(xml); + with_subject.subject_attribute = Some("uid".to_string()); + validate_saml_params(&mut with_subject, REALM).expect("subject attribute set"); + } + + #[test] + fn a_reserved_claim_name_is_rejected() { + let mut params = params(valid_metadata()); + params.attribute_claim_map = HashMap::from([("groups".to_string(), "roles".to_string())]); + assert!(rejection(params).contains("saml_params.attribute_claim_map")); + } + + #[test] + fn two_attributes_mapped_to_one_claim_are_rejected() { + let mut params = params(valid_metadata()); + params.attribute_claim_map = HashMap::from([ + ("mail".to_string(), "email".to_string()), + ("email".to_string(), "email".to_string()), + ]); + assert!(rejection(params).contains("same claim")); + } + + #[test] + fn return_origins_must_be_bare_https_origins() { + for origin in [ + "http://app.example.com", + "https://app.example.com/some/path", + "https://user@app.example.com", + "not a url", + ] { + let mut params = params(valid_metadata()); + params.allowed_return_origins = vec![origin.to_string()]; + assert!( + rejection(params).contains("saml_params.allowed_return_origins"), + "{origin} must be rejected" + ); + } + } + + #[test] + fn the_default_return_url_must_be_under_an_allowed_origin() { + let mut params = params(valid_metadata()); + params.default_return_url = "https://elsewhere.example.com/home".to_string(); + assert!(rejection(params).contains("saml_params.default_return_url")); + } +} diff --git a/server/src/saml/sp_signing_key.rs b/server/src/saml/sp_signing_key.rs new file mode 100644 index 00000000..91f9cf3e --- /dev/null +++ b/server/src/saml/sp_signing_key.rs @@ -0,0 +1,133 @@ +use openssl::{ + pkey::{PKey, Private}, + x509::X509, +}; + +use crate::{AuthError, AuthResult, server::parameters::SamlSpParams}; + +/// Smallest RSA key accepted (NIST SP 800-131A); IdPs commonly refuse anything shorter. +const MIN_RSA_BITS: u32 = 2048; + +/// This server's SAML SP signing key and the certificate published for it, checked to +/// belong together. +#[expect( + dead_code, + reason = "the SAML endpoints that sign with it are not implemented yet" +)] +pub(crate) struct SamlSpSigningKey { + pub(crate) private_key: PKey, + pub(crate) certificate: X509, +} + +/// Load and check the files named in `[saml_sp_params]`. Called at startup so that a bad +/// key stops the server instead of failing the first SAML login. +pub(crate) fn load_sp_signing_key(params: &SamlSpParams) -> AuthResult { + let key_pem = read_pem_file(¶ms.saml_rsa_private_key)?; + let certificate_pem = read_pem_file(¶ms.saml_certificate)?; + parse_sp_signing_key(&key_pem, &certificate_pem) +} + +fn read_pem_file(path: &str) -> AuthResult> { + std::fs::read(path) + .map_err(|e| AuthError::Init(format!("saml_sp_params: cannot read {path}: {e}"))) +} + +fn parse_sp_signing_key(key_pem: &[u8], certificate_pem: &[u8]) -> AuthResult { + let private_key = PKey::private_key_from_pem(key_pem).map_err(|e| { + init_error(&format!( + "saml_rsa_private_key is not a PEM private key: {e}" + )) + })?; + if private_key.rsa().is_err() { + return Err(init_error("saml_rsa_private_key must be an RSA key")); + } + if private_key.bits() < MIN_RSA_BITS { + return Err(init_error(&format!( + "saml_rsa_private_key must be at least {MIN_RSA_BITS} bits, got {}", + private_key.bits() + ))); + } + let certificate = X509::from_pem(certificate_pem).map_err(|e| { + init_error(&format!( + "saml_certificate is not a PEM X.509 certificate: {e}" + )) + })?; + let certificate_key = certificate + .public_key() + .map_err(|e| init_error(&format!("saml_certificate has no usable public key: {e}")))?; + if !certificate_key.public_eq(&private_key) { + return Err(init_error( + "saml_certificate does not match saml_rsa_private_key", + )); + } + Ok(SamlSpSigningKey { + private_key, + certificate, + }) +} + +fn init_error(message: &str) -> AuthError { + AuthError::Init(format!("saml_sp_params: {message}")) +} + +#[cfg(test)] +mod tests { + use openssl::{pkey::PKey, rsa::Rsa}; + + use super::{load_sp_signing_key, parse_sp_signing_key}; + use crate::{server::parameters::SamlSpParams, tests::helpers::test_saml_sp_params}; + + fn fixture(relative_path: &str) -> String { + format!( + "{}/src/tests/certificates/{relative_path}", + env!("CARGO_MANIFEST_DIR") + ) + } + + fn load_error(key: &str, certificate: &str) -> String { + let params = SamlSpParams { + saml_rsa_private_key: fixture(key), + saml_certificate: fixture(certificate), + }; + match load_sp_signing_key(¶ms) { + Ok(_) => panic!("expected {key} + {certificate} to be refused"), + Err(e) => e.to_string(), + } + } + + #[test] + fn loads_a_matching_rsa_key_and_certificate() { + assert!(load_sp_signing_key(&test_saml_sp_params()).is_ok()); + } + + #[test] + fn refuses_a_certificate_of_another_key() { + let error = load_error("rsa/auth.server.key.pem", "rsa/auth.user1.cert.pem"); + assert!(error.contains("does not match"), "{error}"); + } + + #[test] + fn refuses_a_non_rsa_key() { + let error = load_error("ec/auth.server.key.pem", "ec/auth.server.cert.pem"); + assert!(error.contains("must be an RSA key"), "{error}"); + } + + #[test] + fn refuses_an_rsa_key_under_2048_bits() { + let small_key = PKey::from_rsa(Rsa::generate(1024).expect("generate RSA key")) + .expect("wrap RSA key") + .private_key_to_pem_pkcs8() + .expect("encode RSA key"); + let certificate = std::fs::read(fixture("rsa/auth.server.cert.pem")).expect("read"); + let Err(error) = parse_sp_signing_key(&small_key, &certificate) else { + panic!("expected a 1024-bit key to be refused"); + }; + assert!(error.to_string().contains("at least 2048 bits"), "{error}"); + } + + #[test] + fn names_the_file_it_cannot_read() { + let error = load_error("rsa/missing.key.pem", "rsa/auth.server.cert.pem"); + assert!(error.contains("rsa/missing.key.pem"), "{error}"); + } +} diff --git a/server/src/server/auth_verifier.rs b/server/src/server/auth_verifier.rs index 65335765..57816a24 100644 --- a/server/src/server/auth_verifier.rs +++ b/server/src/server/auth_verifier.rs @@ -168,6 +168,21 @@ async fn prepare_auth_verifier( // Determine the address to bind the server to. let address = format!("{}:{}", ¶ms.host_name, params.host_port); + // Check the SAML signing key before anything else, so a bad key stops the server + // instead of failing the first SAML login. + #[cfg(feature = "saml")] + let _saml_sp_signing_key = params + .saml_sp_params + .as_ref() + .map(crate::saml::load_sp_signing_key) + .transpose()?; + #[cfg(not(feature = "saml"))] + if params.saml_sp_params.is_some() { + return Err(crate::AuthError::Init( + "saml_sp_params is set but this server was built without the `saml` feature".to_owned(), + )); + } + let database_params = if let Some(ref db_params) = params.database_params { db_params.clone() } else { diff --git a/server/src/server/endpoints/super_admins_endpoints.rs b/server/src/server/endpoints/super_admins_endpoints.rs index e64c075e..0883b56c 100644 --- a/server/src/server/endpoints/super_admins_endpoints.rs +++ b/server/src/server/endpoints/super_admins_endpoints.rs @@ -1,4 +1,5 @@ use crate::server::endpoints::{admin_from_request, can_manage_realm}; +use crate::server::parameters::ServerParams; use crate::{AuthError, database::Database, models::Realm}; use actix_web::{ HttpRequest, HttpResponse, delete, get, post, put, @@ -7,10 +8,26 @@ use actix_web::{ use cosmian_logger::info; use std::sync::Arc; +/// Validate a realm's SAML settings and fill in the IdP fields from its metadata. SAML +/// realms need the server's SP signing key, so they are refused while none is configured. +#[cfg(feature = "saml")] +fn check_saml_params(realm: &mut Realm, server_params: &ServerParams) -> Result<(), AuthError> { + let Some(params) = realm.auth_params.saml_params.as_mut() else { + return Ok(()); + }; + if server_params.saml_sp_params.is_none() { + return Err(AuthError::BadRequest( + "SAML needs a server signing key: set `saml_sp_params` in the server configuration" + .to_string(), + )); + } + crate::saml::validate_saml_params(params, &realm.id) +} + /// Without the `saml` feature there are no SAML endpoints and no way to validate SAML /// settings, so they are refused instead of being stored unvalidated. #[cfg(not(feature = "saml"))] -fn reject_saml_params(realm: &Realm) -> Result<(), AuthError> { +fn check_saml_params(realm: &mut Realm, _server_params: &ServerParams) -> Result<(), AuthError> { if realm.auth_params.saml_params.is_some() { return Err(AuthError::BadRequest( "SAML is not enabled on this server (built without the `saml` feature)".to_string(), @@ -32,8 +49,9 @@ pub async fn create_realm( req: HttpRequest, realm: Json, database: Data>, + server_params: Data>, ) -> Result { - let realm = realm.into_inner(); + let mut realm = realm.into_inner(); let requester = admin_from_request(&req)?; if !requester.is_super_admin() { @@ -42,8 +60,7 @@ pub async fn create_realm( )); } - #[cfg(not(feature = "saml"))] - reject_saml_params(&realm)?; + check_saml_params(&mut realm, &server_params)?; info!( "create_realm: authenticated admin '{}' is creating realm '{}'", @@ -106,6 +123,7 @@ pub async fn update_realm( id: Path, realm: Json, database: Data>, + server_params: Data>, ) -> Result { let realm_id = id.into_inner(); let requester = admin_from_request(&req)?; @@ -121,8 +139,7 @@ pub async fn update_realm( // Ensure the ID in the path matches the ID in the payload realm.id = realm_id; - #[cfg(not(feature = "saml"))] - reject_saml_params(&realm)?; + check_saml_params(&mut realm, &server_params)?; info!( "update_realm: '{}' is updating realm '{}'", diff --git a/server/src/server/parameters/auth_params.rs b/server/src/server/parameters/auth_params.rs index 59d8d8c2..47b11029 100644 --- a/server/src/server/parameters/auth_params.rs +++ b/server/src/server/parameters/auth_params.rs @@ -18,3 +18,15 @@ pub struct CertificateJwtParams { /// The path to the certificate EC public key PEM file used for verifying certificates pub cert_ec_public_key: String, } + +/// This server's SAML Service Provider signing key, shared by all SAML realms. It signs our +/// ``s, and its certificate is published in our SP metadata. RSA only (2048 +/// bits or more): it is what IdPs universally accept. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct SamlSpParams { + /// The path to the RSA private key PEM file + pub saml_rsa_private_key: String, + + /// The path to the X.509 certificate PEM file matching `saml_rsa_private_key` + pub saml_certificate: String, +} diff --git a/server/src/server/parameters/mod.rs b/server/src/server/parameters/mod.rs index 9105d52b..bf831193 100644 --- a/server/src/server/parameters/mod.rs +++ b/server/src/server/parameters/mod.rs @@ -1,5 +1,5 @@ mod auth_params; -pub use auth_params::{CertificateJwtParams, SessionJwtParams}; +pub use auth_params::{CertificateJwtParams, SamlSpParams, SessionJwtParams}; mod database_params; pub use database_params::{DatabaseBackend, DatabaseParams}; diff --git a/server/src/server/parameters/server_params.rs b/server/src/server/parameters/server_params.rs index 0bdd57ba..659b6e38 100644 --- a/server/src/server/parameters/server_params.rs +++ b/server/src/server/parameters/server_params.rs @@ -3,7 +3,8 @@ use jsonwebtoken::{DecodingKey, EncodingKey}; use crate::{ AuthError, server::parameters::{ - CertificateJwtParams, DatabaseParams, ProxyParams, SessionJwtParams, TlsParams, + CertificateJwtParams, DatabaseParams, ProxyParams, SamlSpParams, SessionJwtParams, + TlsParams, }, session::StaleSessionCollectorConfig, }; @@ -24,6 +25,10 @@ pub struct ServerParams { /// are unavailable (500 / 404 respectively) but the rest of the server is unaffected. pub certificate_jwt_params: Option, + /// Optional SAML Service Provider signing key, required before any realm can use SAML. + /// Checked at startup; a server built without the `saml` feature refuses to start with it. + pub saml_sp_params: Option, + /// Optional database parameters for all objects but the sessions - /// if not provided an in-memory SQLite database will be used /// Only PostgreSQL, SQLite and MySQL are supported. Redis is not supported. diff --git a/server/src/tests/helpers.rs b/server/src/tests/helpers.rs index 3f61c841..8564fdac 100644 --- a/server/src/tests/helpers.rs +++ b/server/src/tests/helpers.rs @@ -28,6 +28,58 @@ pub fn test_realm(id: &str) -> Realm { } } +/// Base64 DER of the EC test server certificate, as it appears in an ``. +#[cfg(feature = "saml")] +pub fn test_idp_certificate_base64() -> String { + include_str!("certificates/ec/auth.server.cert.pem") + .lines() + .filter(|line| !line.starts_with("-----")) + .collect() +} + +/// SAML 2.0 IdP metadata signing with the EC test certificate; `extra` is inserted into the +/// `IDPSSODescriptor` (e.g. a `NameIDFormat`). +#[cfg(feature = "saml")] +pub fn test_idp_metadata(sso_binding: &str, sso_url: &str, extra: &str) -> String { + format!( + r#" + + {cert} + {extra} + + +"#, + cert = test_idp_certificate_base64() + ) +} + +/// SAML settings for `realm_id` that pass validation. +#[cfg(feature = "saml")] +pub fn test_saml_params(realm_id: &str) -> crate::SamlParams { + crate::SamlParams { + metadata_xml: Some(test_idp_metadata( + samael::metadata::HTTP_REDIRECT_BINDING, + "https://idp.example.com/sso", + "", + )), + sp_entity_id: format!("https://auth.example.com/saml/{realm_id}"), + sp_acs_url: format!("https://auth.example.com/saml/{realm_id}/acs"), + allowed_return_origins: vec!["https://app.example.com".to_string()], + default_return_url: "https://app.example.com/home".to_string(), + ..Default::default() + } +} + +/// SAML SP signing key configuration using the RSA-4096 test server key and certificate. +#[cfg(feature = "saml")] +pub fn test_saml_sp_params() -> crate::server::parameters::SamlSpParams { + let rsa_dir = format!("{}/src/tests/certificates/rsa", env!("CARGO_MANIFEST_DIR")); + crate::server::parameters::SamlSpParams { + saml_rsa_private_key: format!("{rsa_dir}/auth.server.key.pem"), + saml_certificate: format!("{rsa_dir}/auth.server.cert.pem"), + } +} + pub fn test_admin(id: &str) -> Admin { Admin { id: id.to_string(), diff --git a/server/src/tests/params.rs b/server/src/tests/params.rs index 2cdcb1fb..fd7e824f 100644 --- a/server/src/tests/params.rs +++ b/server/src/tests/params.rs @@ -78,6 +78,7 @@ pub fn get_default_server_params() -> AuthResult { // Use TLS keys for JWT by default in tests session_jwt_params: None, certificate_jwt_params: None, + saml_sp_params: None, sessions_store_params: None, stale_session_collector_config: None, dev_seed: None, diff --git a/server/src/tests/super_admin_api.rs b/server/src/tests/super_admin_api.rs index c6f32763..3a238106 100644 --- a/server/src/tests/super_admin_api.rs +++ b/server/src/tests/super_admin_api.rs @@ -7,6 +7,8 @@ //! 3. Makes subsequent requests with the **same** [`AuthClient`] instance so the cookie is //! automatically sent on every call. +#[cfg(feature = "saml")] +use crate::tests::helpers::test_saml_sp_params; use crate::{ AuthError, AuthResult, models::ADMIN_REALM, @@ -18,6 +20,10 @@ use crate::{ init_test_logging, start_default_test_server, }, }; +use crate::{ + server::parameters::SamlSpParams, + tests::{get_default_server_params, start_test_server}, +}; use cosmian_logger::info; // Alias for backwards-compatibility with local call sites. @@ -255,6 +261,128 @@ async fn test_saml_params_rejected_without_saml_feature() -> AuthResult<()> { ctx.stop_server().await } +/// Without the `saml` feature, a SAML signing key in the configuration stops the server +/// from starting instead of being silently ignored. +#[cfg(not(feature = "saml"))] +#[actix_web::test] +async fn test_server_refuses_saml_sp_params_without_saml_feature() -> AuthResult<()> { + init_test_logging(None); + let mut params = get_default_server_params()?; + params.saml_sp_params = Some(SamlSpParams { + saml_rsa_private_key: "unused.key.pem".to_string(), + saml_certificate: "unused.cert.pem".to_string(), + }); + assert!( + start_test_server(params).await.is_err(), + "The server must refuse to start with saml_sp_params and no saml feature" + ); + Ok(()) +} + +/// A SAML signing key whose certificate belongs to another key stops the server from +/// starting. +#[cfg(feature = "saml")] +#[actix_web::test] +async fn test_server_refuses_a_bad_saml_signing_key() -> AuthResult<()> { + init_test_logging(None); + let mut params = get_default_server_params()?; + let good = test_saml_sp_params(); + params.saml_sp_params = Some(SamlSpParams { + saml_certificate: good + .saml_certificate + .replace("auth.server.cert", "auth.user1.cert"), + ..good + }); + assert!( + start_test_server(params).await.is_err(), + "The server must refuse to start with a mismatched SAML certificate" + ); + Ok(()) +} + +/// SAML realms need the server's SP signing key: without `saml_sp_params` they are refused +/// with a 400 that says what to configure. +#[cfg(feature = "saml")] +#[actix_web::test] +async fn test_saml_params_refused_without_sp_signing_key() -> AuthResult<()> { + init_test_logging(None); + let ctx = start_default_test_server().await?; + let client = authenticate_as_admin(&ctx).await?; + + let mut realm = test_realm("realm_saml_no_key"); + realm.auth_params.saml_params = + Some(crate::tests::helpers::test_saml_params("realm_saml_no_key")); + let err = client + .create_realm_as_super_admin(&realm) + .await + .expect_err("Expected create_realm to refuse SAML without a signing key"); + assert!( + matches!(err, AuthError::FailedHttpStatus(ref m) if m.contains("400") && m.contains("saml_sp_params")), + "Expected a 400 naming saml_sp_params, got: {err:?}" + ); + + ctx.stop_server().await +} + +/// With the `saml` feature, valid SAML settings are stored with their IdP fields derived +/// from the metadata, and invalid ones are refused with a 400 naming the field. +#[cfg(feature = "saml")] +#[actix_web::test] +async fn test_saml_params_validated_on_realm_create_and_update() -> AuthResult<()> { + init_test_logging(None); + let mut server_params = get_default_server_params()?; + server_params.saml_sp_params = Some(test_saml_sp_params()); + let ctx = start_test_server(server_params).await?; + let client = authenticate_as_admin(&ctx).await?; + + let mut realm = test_realm("realm_saml_valid"); + let mut params = crate::tests::helpers::test_saml_params("realm_saml_valid"); + params.idp_sso_url = "https://attacker.example.com/sso".to_string(); + realm.auth_params.saml_params = Some(params); + client.create_realm_as_super_admin(&realm).await?; + + let stored = client + .get_realm_as_super_admin("realm_saml_valid") + .await? + .auth_params + .saml_params + .expect("SAML settings must be stored"); + assert_eq!(stored.idp_entity_id, "https://idp.example.com/metadata"); + assert_eq!( + stored.idp_sso_url, "https://idp.example.com/sso", + "the SSO URL must come from the metadata, not the request" + ); + assert_eq!(stored.idp_signing_certificates.len(), 1); + + let mut invalid = test_realm("realm_saml_invalid"); + let mut params = crate::tests::helpers::test_saml_params("realm_saml_invalid"); + params.sp_acs_url = "https://auth.example.com/saml/another_realm/acs".to_string(); + invalid.auth_params.saml_params = Some(params); + let err = client + .create_realm_as_super_admin(&invalid) + .await + .expect_err("Expected create_realm to refuse invalid SAML settings"); + assert!( + matches!(err, AuthError::FailedHttpStatus(ref m) if m.contains("400") && m.contains("saml_params.sp_acs_url")), + "Expected a 400 naming sp_acs_url, got: {err:?}" + ); + + let mut update = client.get_realm_as_super_admin("realm_saml_valid").await?; + if let Some(params) = update.auth_params.saml_params.as_mut() { + params.metadata_xml = Some("".to_string()); + } + let err = client + .update_realm_as_super_admin("realm_saml_valid", &update) + .await + .expect_err("Expected update_realm to refuse invalid metadata"); + assert!( + matches!(err, AuthError::FailedHttpStatus(ref m) if m.contains("400") && m.contains("saml_params.metadata_xml")), + "Expected a 400 naming metadata_xml, got: {err:?}" + ); + + ctx.stop_server().await +} + // ── Authorization enforcement ──────────────────────────────────────────────── /// A realm admin (non-super-admin) must not be able to update any realm (HTTP 403). From 1be763d0d726cf278f578fc247721478285ef860 Mon Sep 17 00:00:00 2001 From: charming_wicket <235307685+charming-wicket-5502@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:52:20 +0200 Subject: [PATCH 05/19] feat(saml): add SP-initiated SAML login, ACS and SP metadata endpoints --- CHANGELOG/feat_saml-integration.md | 7 + client/src/client/auth_client.rs | 23 ++ client/src/error/auth_error.rs | 5 +- server/documentation/openapi.yaml | 128 ++++++++ server/src/saml/authn_request.rs | 51 ++++ server/src/saml/endpoints.rs | 285 +++++++++++++++++ server/src/saml/factory.rs | 19 ++ server/src/saml/identity_mapping.rs | 239 +++++++++++++++ server/src/saml/mod.rs | 11 + server/src/saml/params_validation.rs | 10 +- server/src/saml/request_store.rs | 2 +- server/src/saml/response_validation.rs | 404 +++++++++++++++++++++++++ server/src/saml/sp_metadata.rs | 51 ++++ server/src/saml/sp_signing_key.rs | 4 - server/src/server/auth_verifier.rs | 53 +++- server/src/tests/helpers.rs | 18 +- server/src/tests/mod.rs | 4 + server/src/tests/saml_flow.rs | 317 +++++++++++++++++++ server/src/tests/saml_idp.rs | 333 ++++++++++++++++++++ 19 files changed, 1948 insertions(+), 16 deletions(-) create mode 100644 server/src/saml/authn_request.rs create mode 100644 server/src/saml/endpoints.rs create mode 100644 server/src/saml/identity_mapping.rs create mode 100644 server/src/saml/response_validation.rs create mode 100644 server/src/saml/sp_metadata.rs create mode 100644 server/src/tests/saml_flow.rs create mode 100644 server/src/tests/saml_idp.rs diff --git a/CHANGELOG/feat_saml-integration.md b/CHANGELOG/feat_saml-integration.md index 6d4392e8..a14b9f2b 100644 --- a/CHANGELOG/feat_saml-integration.md +++ b/CHANGELOG/feat_saml-integration.md @@ -7,11 +7,17 @@ - Refuse realm create/update requests that carry `saml_params` on servers built without the `saml` feature (HTTP 400), so unusable and unvalidated SAML configuration is never stored. - Validate SAML settings on realm create/update when the `saml` feature is enabled: parse the pasted IdP metadata and always derive the IdP entityID, HTTPS HTTP-Redirect SSO URL, signing certificates and NameID format from it (overwriting any values sent), and refuse expired, oversized or multi-entity metadata, an ACS URL that isn't `https://…/saml/{realm_id}/acs`, return URLs outside the https origin allowlist, empty/duplicate/reserved claim names, and transient NameIDs without a subject attribute, each with an HTTP 400 naming the field. - Add a server-wide SAML SP signing key (`[saml_sp_params]`: RSA private key and certificate PEM paths), checked at startup (RSA of at least 2048 bits, certificate matching the key) and required before any realm can enable SAML, and drop `SamlParams.sp_signing_key` from the client, admin-ui and OpenAPI types, because the key is server configuration and must never travel through the database or the admin API. +- Add the SAML response validator for the upcoming ACS endpoint: it relies on samael for the signature, `InResponseTo`, issuer, validity window, recipient, destination and status checks, and adds what samael leaves out — refusing realms without an IdP certificate (samael would skip signature verification), requiring an `AudienceRestriction` where every restriction names our entity ID and an `AuthnStatement` (SAMLProf §4.1.4.2), allowing only RSA/ECDSA with SHA-256/384/512, and rejecting replayed assertions for as long as they could be accepted (5 minutes after `IssueInstant`, 3 minutes clock skew), with rejections reported as a new HTTP 401 `AuthError::Saml`. +- Add SAML identity mapping for the upcoming ACS endpoint: the session subject is the configured subject attribute (exactly one non-empty value) or else the NameID, rejected at login when the IdP actually sends a transient NameID, optionally lowercased; roles are the deduplicated values of the role attribute (none when it is unset or absent); and only attributes listed in `attribute_claim_map` become extra claims, within the existing 4 KiB extra-claims budget. +- Add the SAML browser endpoints, registered under `/saml` only when `saml_sp_params` is set and rate limited like `/login`: `GET /saml/{realm_id}/login` checks `return_to` against the realm's allowed origins, stores the pending request (10 minutes) next to the sessions and redirects to the IdP with an RSA-SHA256-signed `AuthnRequest` carrying a 128-bit request ID; `POST /saml/{realm_id}/acs` requires the `_ea_saml` cookie set at login to match `RelayState` (login-CSRF protection), validates and maps the response, issues the `_ea_` session with `AuthScheme::Saml` and continues to the return URL through a same-site page so the `SameSite=Strict` session cookie is kept; `GET /saml/{realm_id}/metadata` publishes our SP metadata (entity ID, ACS, NameID format, signing certificate, signed requests), with a matching `AuthClient::get_saml_metadata`. +- Purge expired SAML pending requests and replay-cache entries periodically, on the stale-session collector's interval. - Bundle a static, OpenSSL-only xmlsec 1.3.5 and libxml2 2.13.4 (`nix/xmlsec-static.nix`, built with the glibc-2.34 toolchain, XSLT and runtime crypto loading disabled) and wire it plus the libclang/bindgen setup into `shell.nix`, so `--features saml` links xmlsec statically against the server's vendored OpenSSL with no new runtime library dependency. ## Tests - Add a SAML request store test suite that runs against in-memory SQLite by default and against PostgreSQL, MySQL or Redis through the same `TEST_SESSIONS_STORE` switch as the session-store tests (single-use and concurrent takes, realm scoping, expiry on write and on read, replay detection, case-sensitive and long IDs, expiry purge), an xmlsec smoke test that exercises libxml2, xmlsec and its OpenSSL backend end to end, an API test checking that SAML settings are refused without the `saml` feature, unit tests for every SAML settings validation rule (prefixed and unprefixed metadata), and an API test checking that valid settings are stored with metadata-derived IdP fields while invalid ones get a field-specific 400, plus unit tests for the SAML signing-key checks and API tests checking that SAML realms are refused without a signing key and that a mismatched key, or the key section on a build without `saml`, stops the server from starting. +- Add a fake SAML IdP test helper (`tests::saml_idp`) that renders ``s answering our `AuthnRequest`s with every validated field adjustable and signs the assertion and/or the response through xmlsec with RSA-SHA256 and SHA-256 digests, and switch the test IdP metadata to that IdP's RSA certificate, so the upcoming ACS validation can be tested against real signatures, untrusted keys and post-signing tampering. +- Add SAML response validation tests that break one property at a time (signature missing, untrusted or broken by tampering, injected unsigned assertion, wrong request, issuer, recipient or audience, missing audience restriction or `AuthnStatement`, expired, not yet valid or issued too long ago, unsuccessful status, replay, realm without IdP certificate), and SAML identity mapping tests (NameID or single-valued subject attribute, transient NameID, lowercasing, roles, allowlisted extra claims, size limit), and end-to-end SAML tests over HTTPS (signed redirect verified with the SP certificate, fake-IdP login producing a session with the mapped subject, scheme and roles, replay, login-CSRF browser binding, return-URL allowlist, SP metadata, realms without SAML, no `/saml` routes without a signing key). ## CI @@ -19,4 +25,5 @@ ## Docs +- Document the `/saml/{realm_id}/login`, `/acs` and `/metadata` endpoints and a `SAML` tag in `openapi.yaml`. - Add ADR-0003 recording why SAML uses `samael` with a statically bundled, OpenSSL-only xmlsec, why the server-side feature is behind an optional `saml` Cargo feature while the API types are not, the v1 protocol scope, the single server-wide RSA SP signing key, and the rejected alternatives and accepted risks (including that xmlsec/libxml2 security updates now ship with our releases). diff --git a/client/src/client/auth_client.rs b/client/src/client/auth_client.rs index 8ec05780..e41995d3 100644 --- a/client/src/client/auth_client.rs +++ b/client/src/client/auth_client.rs @@ -361,6 +361,29 @@ impl AuthClient { let v: Version = self.get("/public/version").await?; Ok(v.version) } + + /// Fetch this server's SAML SP metadata XML for `realm_id` (`GET /saml/{realm_id}/metadata`), + /// to import into the realm's IdP. The login and ACS endpoints are browser-only. + pub async fn get_saml_metadata(&self, realm_id: &str) -> AuthResult { + let url = format!("{}/saml/{}/metadata", self.base_url, realm_id); + let response = self + .client + .get(&url) + .send() + .await + .map_err(|e| AuthError::Generic(format!("GET request failed: {e}")))?; + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| AuthError::Generic(format!("failed to read the response body: {e}")))?; + if !status.is_success() { + return Err(AuthError::FailedHttpStatus(format!( + "Request failed with status {status}: {body}" + ))); + } + Ok(body) + } } // Realm Management API diff --git a/client/src/error/auth_error.rs b/client/src/error/auth_error.rs index 520ccd48..382b3f74 100644 --- a/client/src/error/auth_error.rs +++ b/client/src/error/auth_error.rs @@ -41,6 +41,9 @@ pub enum AuthError { #[error("Session not found")] SessionNotFound, + #[error("SAML error: {0}")] + Saml(String), + #[error("TOTP/2FA error: {0}")] Totp(String), @@ -58,7 +61,7 @@ impl actix_web::ResponseError for AuthError { match self { Self::BadRequest(_) => actix_web::HttpResponse::BadRequest().json(format!("{self}")), Self::Conflict(_) => actix_web::HttpResponse::Conflict().json(format!("{self}")), - Self::JWT(_) | Self::Session(_) | Self::Cookie(_) => { + Self::JWT(_) | Self::Session(_) | Self::Cookie(_) | Self::Saml(_) => { actix_web::HttpResponse::Unauthorized().json(format!("{self}")) } Self::Forbidden(_) => actix_web::HttpResponse::Forbidden().json(format!("{self}")), diff --git a/server/documentation/openapi.yaml b/server/documentation/openapi.yaml index 7dea6111..e04919e1 100644 --- a/server/documentation/openapi.yaml +++ b/server/documentation/openapi.yaml @@ -63,6 +63,12 @@ tags: Kubernetes service-account JWT authentication (`/auth/kubernetes/...`). The `login` endpoint validates the JWT against the role's JWKS URL. Role management requires admin session cookie. + - name: SAML + description: | + SAML 2.0 SP-initiated Web Browser SSO (`/saml/{realm_id}/...`). Present only on + servers built with the `saml` feature and configured with `saml_sp_params`; + otherwise these routes return 404. `login` and `acs` are browser navigations, + not API calls. components: securitySchemes: @@ -1233,6 +1239,128 @@ paths: '401': $ref: '#/components/responses/Unauthorized' + /saml/{realm_id}/login: + get: + tags: [SAML] + summary: Start a SAML login at the realm's IdP + operationId: samlLogin + description: | + Redirects the browser to the realm's IdP with a signed `AuthnRequest` + (HTTP-Redirect binding, `RelayState` = request ID) and sets the short-lived + `_ea_saml` cookie (`Secure; HttpOnly; SameSite=None; Path=/saml/`) that the ACS + requires, so a login can only be completed by the browser that started it. + parameters: + - $ref: '#/components/parameters/realmId' + - name: return_to + in: query + required: false + description: | + Where to send the browser after login. Must be an https URL under one of the + realm's `allowed_return_origins`; defaults to `default_return_url`. + schema: + type: string + format: uri + example: https://app.example.com/dashboard + security: [] + responses: + '302': + description: Redirect to the IdP Single Sign-On Service. + headers: + Location: + schema: + type: string + format: uri + Set-Cookie: + schema: + type: string + example: _ea_saml=_4f1c...; Secure; HttpOnly; SameSite=None; Path=/saml/; Max-Age=600 + '400': + description: Unknown realm, realm without SAML, or `return_to` not allowed. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /saml/{realm_id}/acs: + post: + tags: [SAML] + summary: SAML Assertion Consumer Service + operationId: samlAcs + description: | + Receives the IdP's `` (HTTP-POST binding). Requires the `_ea_saml` + cookie to match `RelayState`, validates the response (signature, issuer, + audience, recipient, `InResponseTo`, validity window, single use), maps the + identity and sets the `_ea_` session cookie. The HTML page returned sends the + browser on to the login's return URL. + parameters: + - $ref: '#/components/parameters/realmId' + security: [] + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + required: [SAMLResponse, RelayState] + properties: + SAMLResponse: + type: string + description: Base64-encoded SAML `` XML. + RelayState: + type: string + description: The request ID sent in the login redirect. + responses: + '200': + description: Signed in; the page redirects to the return URL. + headers: + Set-Cookie: + schema: + type: string + example: _ea_=; HttpOnly; Secure; SameSite=Strict; Path=/ + content: + text/html: + schema: + type: string + '400': + description: Unknown realm or realm without SAML. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: | + The response was rejected (browser mismatch, unknown or expired login, + invalid or replayed response, unmappable identity). + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /saml/{realm_id}/metadata: + get: + tags: [SAML] + summary: This server's SAML SP metadata for the realm + operationId: samlMetadata + description: | + SAML 2.0 metadata to import into the realm's IdP: entity ID, ACS URL, NameID + format and the SP signing certificate (`AuthnRequestsSigned="true"`). + parameters: + - $ref: '#/components/parameters/realmId' + security: [] + responses: + '200': + description: SP metadata. + content: + application/samlmetadata+xml: + schema: + type: string + '400': + description: Unknown realm or realm without SAML. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /whoami: get: tags: [Auth] diff --git a/server/src/saml/authn_request.rs b/server/src/saml/authn_request.rs new file mode 100644 index 00000000..9fab0aaa --- /dev/null +++ b/server/src/saml/authn_request.rs @@ -0,0 +1,51 @@ +use chrono::Utc; +use samael::{ + metadata::HTTP_POST_BINDING, + schema::{AuthnRequest, Issuer, NameIdPolicy}, +}; +use url::Url; + +use crate::{AuthError, AuthResult, SamlParams, saml::sp_signing_key::SamlSpSigningKey}; + +const ENTITY_NAMEID_FORMAT: &str = "urn:oasis:names:tc:SAML:2.0:nameid-format:entity"; + +/// A fresh `AuthnRequest` ID: 128 random bits, prefixed because `xs:ID` can't start with a digit. +pub(super) fn new_request_id() -> String { + format!("_{}", uuid::Uuid::new_v4().simple()) +} + +/// The IdP SSO URL carrying our signed `AuthnRequest` (HTTP-Redirect binding, SAMLBind §3.4), +/// with `request_id` as both the request ID and the `RelayState`. +pub(super) fn signed_authn_request_url( + params: &SamlParams, + request_id: &str, + signing_key: &SamlSpSigningKey, +) -> AuthResult { + // Built here rather than with samael's helper, whose request IDs are only 32 random bits. + let request = AuthnRequest { + id: request_id.to_string(), + version: "2.0".to_string(), + issue_instant: Utc::now(), + destination: Some(params.idp_sso_url.clone()), + issuer: Some(Issuer { + format: Some(ENTITY_NAMEID_FORMAT.to_string()), + value: Some(params.sp_entity_id.clone()), + ..Issuer::default() + }), + assertion_consumer_service_url: Some(params.sp_acs_url.clone()), + protocol_binding: Some(HTTP_POST_BINDING.to_string()), + name_id_policy: params + .idp_nameid_format + .as_ref() + .map(|format| NameIdPolicy { + format: Some(format.clone()), + allow_create: Some(true), + ..NameIdPolicy::default() + }), + ..AuthnRequest::default() + }; + request + .signed_redirect(request_id, &signing_key.private_key) + .map_err(|e| AuthError::Unexpected(format!("failed to sign the SAML AuthnRequest: {e}")))? + .ok_or_else(|| AuthError::Unexpected("the SAML AuthnRequest has no destination".into())) +} diff --git a/server/src/saml/endpoints.rs b/server/src/saml/endpoints.rs new file mode 100644 index 00000000..cd16be5b --- /dev/null +++ b/server/src/saml/endpoints.rs @@ -0,0 +1,285 @@ +use std::sync::Arc; + +use actix_web::{ + HttpRequest, HttpResponse, + cookie::{Cookie, SameSite, time::Duration}, + get, + http::header, + post, + web::{Data, Form, Path, Query}, +}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use chrono::Utc; +use cosmian_logger::{info, warn}; +use serde::Deserialize; +use url::Url; + +use crate::{ + AuthError, AuthResult, AuthScheme, AuthenticatedClientScheme, Realm, SamlParams, build_cookie, + database::Database, + saml::{ + PendingSamlRequest, SamlRequestStore, + authn_request::{new_request_id, signed_authn_request_url}, + create_saml_request_store, + factory::start_saml_request_store_cleanup, + identity_mapping::{SamlIdentity, map_identity}, + response_validation::validate_saml_response, + sp_metadata::{escape_markup, render_sp_metadata}, + sp_signing_key::SamlSpSigningKey, + }, + server::parameters::{DatabaseParams, ServerParams}, + session::{JwtTokenConfig, SessionStore, issue_token, session_id_from_cookie_value}, +}; + +/// How long a user has to sign in at the IdP. +const PENDING_REQUEST_SECONDS: i64 = 600; + +/// Binds a login to the browser that started it: the ACS requires it to match `RelayState`, +/// so a response obtained elsewhere can't be posted into a victim's browser (login CSRF). +const REQUEST_COOKIE: &str = "_ea_saml"; + +/// Upper bound for the ACS form; signed responses with certificates are typically 5–20 KiB. +pub(crate) const MAX_SAML_FORM_BYTES: usize = 256 * 1024; + +/// SAML state shared by the `/saml` endpoints; exists only when `saml_sp_params` is set. +pub(crate) struct SamlState { + pub(crate) signing_key: SamlSpSigningKey, + pub(crate) store: Arc, +} + +impl SamlState { + /// Open the request store on `store_params` (the session store's database) and start + /// purging it every `cleanup_interval_seconds`. + pub(crate) async fn start( + signing_key: SamlSpSigningKey, + store_params: &DatabaseParams, + cleanup_interval_seconds: u64, + ) -> AuthResult { + let store = create_saml_request_store(store_params).await?; + start_saml_request_store_cleanup(store.clone(), cleanup_interval_seconds); + Ok(Self { signing_key, store }) + } +} + +#[derive(Deserialize)] +pub(crate) struct LoginQuery { + return_to: Option, +} + +#[derive(Deserialize)] +pub(crate) struct AcsForm { + #[serde(rename = "SAMLResponse")] + saml_response: String, + #[serde(rename = "RelayState")] + relay_state: String, +} + +/// Start an SP-initiated login: remember the request and send the browser to the IdP. +#[get("/{realm_id}/login")] +pub(crate) async fn saml_login( + realm_id: Path, + query: Query, + database: Data>, + state: Data, +) -> AuthResult { + let (realm, params) = saml_realm(database.as_ref().as_ref(), &realm_id).await?; + let return_url = resolve_return_url(¶ms, query.return_to.as_deref())?; + let request_id = new_request_id(); + let now = Utc::now().timestamp(); + state + .store + .store_pending_request(&PendingSamlRequest { + request_id: request_id.clone(), + realm_id: realm.id.clone(), + return_url, + created_at: now, + expires_at: now + PENDING_REQUEST_SECONDS, + }) + .await?; + let idp_url = signed_authn_request_url(¶ms, &request_id, &state.signing_key)?; + Ok(HttpResponse::Found() + .insert_header((header::LOCATION, idp_url.as_str())) + .insert_header((header::CACHE_CONTROL, "no-store")) + .cookie(request_cookie(&request_id)) + .finish()) +} + +/// Assertion Consumer Service: the IdP posts its response here (HTTP-POST binding). On success +/// the session cookie is set and the browser continues to the return URL of the login. +#[post("/{realm_id}/acs")] +#[allow(clippy::too_many_arguments)] +pub(crate) async fn saml_acs( + req: HttpRequest, + realm_id: Path, + form: Form, + database: Data>, + session_store: Data>, + jwt_token_config: Data>, + server_params: Data>, + state: Data, +) -> AuthResult { + let result = consume_response(&req, &realm_id, &form, database.as_ref().as_ref(), &state).await; + let (realm, pending, identity) = match result { + Ok(accepted) => accepted, + Err(e) => { + warn!(event = "auth.login.failure", realm = %realm_id, auth_scheme = "saml", "SAML login rejected: {e}"); + return Err(e); + } + }; + + let token = issue_token( + &identity.subject, + AuthScheme::Saml, + &realm.id, + identity.roles, + identity.extra_claims, + &jwt_token_config, + realm.session_max_age_seconds, + )?; + let session_cookie = build_cookie( + &token, + realm.session_max_age_seconds, + server_params.tls_params.is_some(), + )?; + let session_id = session_id_from_cookie_value(session_cookie.value().as_bytes())?; + let client = AuthenticatedClientScheme { + username: identity.subject, + auth_scheme: AuthScheme::Saml, + }; + session_store + .upsert_session(&session_id, &realm, &client, &session_cookie.to_string()) + .await?; + info!( + event = "auth.login.success", + realm = %realm.id, + username = %client.username, + auth_scheme = ?client.auth_scheme, + session_id = %session_id, + "login successful" + ); + + let mut used_request_cookie = request_cookie(""); + used_request_cookie.make_removal(); + Ok(HttpResponse::Ok() + .content_type("text/html; charset=utf-8") + .insert_header((header::CACHE_CONTROL, "no-store")) + .insert_header((header::CONTENT_SECURITY_POLICY, "default-src 'none'")) + .cookie(session_cookie) + .cookie(used_request_cookie) + .body(redirect_page(&pending.return_url))) +} + +/// Our SP metadata for the realm, to import into the IdP. +#[get("/{realm_id}/metadata")] +pub(crate) async fn saml_metadata( + realm_id: Path, + database: Data>, + state: Data, +) -> AuthResult { + let (_, params) = saml_realm(database.as_ref().as_ref(), &realm_id).await?; + Ok(HttpResponse::Ok() + .content_type("application/samlmetadata+xml") + .body(render_sp_metadata(¶ms, &state.signing_key)?)) +} + +/// Check the browser binding, consume the pending request and validate the response. +async fn consume_response( + req: &HttpRequest, + realm_id: &str, + form: &AcsForm, + database: &dyn Database, + state: &SamlState, +) -> AuthResult<(Realm, PendingSamlRequest, SamlIdentity)> { + if req + .cookie(REQUEST_COOKIE) + .map(|c| c.value().to_string()) + .as_deref() + != Some(form.relay_state.as_str()) + { + return Err(AuthError::Saml( + "this sign-in was not started from this browser".to_string(), + )); + } + let (realm, params) = saml_realm(database, realm_id).await?; + let pending = state + .store + .take_pending_request(&form.relay_state, &realm.id) + .await? + .ok_or_else(|| AuthError::Saml("unknown or expired sign-in request".to_string()))?; + // Some IdPs wrap the base64 in lines. + let encoded: String = form + .saml_response + .chars() + .filter(|c| !c.is_ascii_whitespace()) + .collect(); + let xml = STANDARD + .decode(encoded) + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + .ok_or_else(|| AuthError::Saml("SAMLResponse is not base64-encoded UTF-8".to_string()))?; + let assertion = + validate_saml_response(&xml, ¶ms, &pending.request_id, state.store.as_ref()).await?; + let identity = map_identity(&assertion, ¶ms)?; + Ok((realm, pending, identity)) +} + +/// The realm and its SAML settings, or a 400 when it doesn't exist or doesn't use SAML. +async fn saml_realm(database: &dyn Database, realm_id: &str) -> AuthResult<(Realm, SamlParams)> { + let realm = database.get_realm(realm_id).await?; + let params = realm + .as_ref() + .and_then(|realm| realm.auth_params.saml_params.clone()); + match (realm, params) { + (Some(realm), Some(params)) => Ok((realm, params)), + _ => Err(AuthError::BadRequest(format!( + "realm '{realm_id}' does not exist or does not use SAML" + ))), + } +} + +/// `return_to` when it is an https URL under one of the realm's allowed origins, else the +/// realm's default; anything else is refused rather than silently replaced. +fn resolve_return_url(params: &SamlParams, return_to: Option<&str>) -> AuthResult { + let Some(raw) = return_to else { + return Ok(params.default_return_url.clone()); + }; + let url = Url::parse(raw) + .map_err(|_| AuthError::BadRequest("return_to is not a valid URL".to_string()))?; + let allowed = url.scheme() == "https" + && url.username().is_empty() + && url.password().is_none() + && params + .allowed_return_origins + .iter() + .filter_map(|origin| Url::parse(origin).ok()) + .any(|origin| origin.origin() == url.origin()); + if !allowed { + return Err(AuthError::BadRequest( + "return_to is not under an allowed return origin of this realm".to_string(), + )); + } + Ok(url.to_string()) +} + +fn request_cookie(request_id: &str) -> Cookie<'static> { + // SameSite=None: the IdP's form POST back to the ACS is a cross-site request. + Cookie::build(REQUEST_COOKIE, request_id.to_string()) + .path("/saml/") + .secure(true) + .http_only(true) + .same_site(SameSite::None) + .max_age(Duration::seconds(PENDING_REQUEST_SECONDS)) + .finish() +} + +/// Browsers may withhold the `SameSite=Strict` session cookie on the navigation that follows a +/// cross-site POST; continuing from a page of ours makes it a same-site navigation. +fn redirect_page(return_url: &str) -> String { + let url = escape_markup(return_url); + format!( + r#" +Signing in +

Continue

+"# + ) +} diff --git a/server/src/saml/factory.rs b/server/src/saml/factory.rs index 8bda8181..86675728 100644 --- a/server/src/saml/factory.rs +++ b/server/src/saml/factory.rs @@ -1,5 +1,7 @@ use std::{sync::Arc, time::Duration}; +use cosmian_logger::error; + use crate::{ AuthError, AuthResult, saml::{ @@ -81,3 +83,20 @@ pub async fn create_saml_request_store( } } } + +/// Periodically purge expired pending requests and replay-cache entries (a no-op on Redis, +/// which expires them itself). +pub(crate) fn start_saml_request_store_cleanup( + store: Arc, + interval_seconds: u64, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let mut timer = tokio::time::interval(Duration::from_secs(interval_seconds.max(1))); + loop { + timer.tick().await; + if let Err(e) = store.delete_expired().await { + error!("Failed to purge expired SAML requests: {e}"); + } + } + }) +} diff --git a/server/src/saml/identity_mapping.rs b/server/src/saml/identity_mapping.rs new file mode 100644 index 00000000..ed344ea5 --- /dev/null +++ b/server/src/saml/identity_mapping.rs @@ -0,0 +1,239 @@ +use std::collections::HashMap; + +use samael::schema::Assertion; +use serde_json::Value; + +use crate::{ + AuthError, AuthResult, SamlParams, models::validate_extra_claims_size, + saml::params_validation::NAMEID_TRANSIENT, +}; + +/// The session identity taken from a verified assertion. +pub(crate) struct SamlIdentity { + pub(crate) subject: String, + pub(crate) roles: Vec, + pub(crate) extra_claims: HashMap, +} + +/// Map a verified assertion to the session identity, following the realm's identity settings. +pub(crate) fn map_identity(assertion: &Assertion, params: &SamlParams) -> AuthResult { + let mut subject = match ¶ms.subject_attribute { + Some(name) => match attribute_values(assertion, name).as_slice() { + [value] => (*value).to_string(), + [] => { + return Err(saml_error(&format!( + "subject attribute '{name}' is missing" + ))); + } + _ => { + return Err(saml_error(&format!( + "subject attribute '{name}' has several values" + ))); + } + }, + None => name_id_subject(assertion)?, + }; + if params.normalize_subject_case { + subject = subject.to_lowercase(); + } + + let mut roles = Vec::new(); + if let Some(name) = ¶ms.role_attribute { + for role in attribute_values(assertion, name) { + if !roles.iter().any(|r| r == role) { + roles.push(role.to_string()); + } + } + } + + let mut extra_claims = HashMap::new(); + for (attribute, claim) in ¶ms.attribute_claim_map { + let value = match attribute_values(assertion, attribute).as_slice() { + [] => continue, + [single] => Value::String((*single).to_string()), + several => Value::from(several.to_vec()), + }; + extra_claims.insert(claim.clone(), value); + } + validate_extra_claims_size(&extra_claims) + .map_err(|_| saml_error("the mapped attributes exceed the extra-claims size limit"))?; + + Ok(SamlIdentity { + subject, + roles, + extra_claims, + }) +} + +fn name_id_subject(assertion: &Assertion) -> AuthResult { + let name_id = assertion + .subject + .as_ref() + .and_then(|subject| subject.name_id.as_ref()) + .filter(|name_id| !name_id.value.is_empty()) + .ok_or_else(|| saml_error("assertion has no NameID"))?; + // The realm settings only see the format the IdP advertises, not the one it sends. + if name_id.format.as_deref() == Some(NAMEID_TRANSIENT) { + return Err(saml_error( + "the NameID is transient and no subject attribute is configured", + )); + } + Ok(name_id.value.clone()) +} + +/// Non-empty values of every attribute named `name` (matched on `Name`, not `FriendlyName`). +fn attribute_values<'a>(assertion: &'a Assertion, name: &str) -> Vec<&'a str> { + assertion + .attribute_statements + .iter() + .flatten() + .flat_map(|statement| &statement.attributes) + .filter(|attribute| attribute.name.as_deref() == Some(name)) + .flat_map(|attribute| &attribute.values) + .filter_map(|value| value.value.as_deref()) + .filter(|value| !value.is_empty()) + .collect() +} + +fn saml_error(message: &str) -> AuthError { + AuthError::Saml(message.to_string()) +} + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, str::FromStr}; + + use samael::schema::{Assertion, Response}; + use serde_json::{Value, json}; + + use super::{SamlIdentity, map_identity}; + use crate::{ + AuthError, SamlParams, + saml::params_validation::NAMEID_TRANSIENT, + tests::{ + helpers::test_saml_params, + saml_idp::{TestIdp, TestResponse}, + }, + }; + + fn params() -> SamlParams { + test_saml_params("acme") + } + + /// The assertion of a default fake-IdP response after `change`, parsed without validation. + fn assertion(change: impl FnOnce(&mut TestResponse)) -> Assertion { + let mut response = TestResponse::answering("_request-1", ¶ms()); + change(&mut response); + Response::from_str(&TestIdp::new().sign(&response)) + .expect("parse test response") + .assertion + .expect("test response has an assertion") + } + + fn mapped(assertion: &Assertion, params: &SamlParams) -> SamlIdentity { + map_identity(assertion, params).expect("identity maps") + } + + fn rejection(assertion: &Assertion, params: &SamlParams) -> String { + match map_identity(assertion, params) { + Err(AuthError::Saml(message)) => message, + Err(other) => panic!("expected a SAML rejection, got {other:?}"), + Ok(identity) => panic!("expected a rejection, mapped {}", identity.subject), + } + } + + #[test] + fn the_nameid_is_the_default_subject() { + assert_eq!(mapped(&assertion(|_| {}), ¶ms()).subject, "alice"); + } + + #[test] + fn a_missing_nameid_is_rejected() { + let mut without = assertion(|_| {}); + if let Some(subject) = without.subject.as_mut() { + subject.name_id = None; + } + assert!(rejection(&without, ¶ms()).contains("no NameID")); + } + + #[test] + fn a_transient_nameid_is_rejected_unless_a_subject_attribute_is_set() { + let transient = assertion(|r| r.name_id_format = NAMEID_TRANSIENT.to_string()); + assert!(rejection(&transient, ¶ms()).contains("transient")); + + let params = SamlParams { + subject_attribute: Some("email".to_string()), + ..params() + }; + assert_eq!(mapped(&transient, ¶ms).subject, "alice@example.com"); + } + + #[test] + fn the_subject_attribute_must_have_exactly_one_value() { + let assertion = assertion(|_| {}); + let with = |name: &str| SamlParams { + subject_attribute: Some(name.to_string()), + ..params() + }; + assert!(rejection(&assertion, &with("groups")).contains("several values")); + assert!(rejection(&assertion, &with("department")).contains("missing")); + } + + #[test] + fn the_subject_can_be_lowercased() { + let assertion = assertion(|r| r.name_id = "Alice@Example.COM".to_string()); + let params = SamlParams { + normalize_subject_case: true, + ..params() + }; + assert_eq!(mapped(&assertion, ¶ms).subject, "alice@example.com"); + } + + #[test] + fn roles_come_from_the_role_attribute_only() { + let assertion = assertion(|r| { + r.attributes.push(( + "groups".to_string(), + vec!["users".to_string(), String::new()], + )); + }); + assert!(mapped(&assertion, ¶ms()).roles.is_empty()); + + let params = SamlParams { + role_attribute: Some("groups".to_string()), + ..params() + }; + assert_eq!(mapped(&assertion, ¶ms).roles, ["admins", "users"]); + } + + #[test] + fn only_mapped_attributes_become_extra_claims() { + let params = SamlParams { + attribute_claim_map: HashMap::from([ + ("email".to_string(), "mail".to_string()), + ("groups".to_string(), "teams".to_string()), + ("department".to_string(), "dept".to_string()), + ]), + ..params() + }; + let claims = mapped(&assertion(|_| {}), ¶ms).extra_claims; + assert_eq!( + claims, + HashMap::from([ + ("mail".to_string(), Value::from("alice@example.com")), + ("teams".to_string(), json!(["admins", "users"])), + ]) + ); + } + + #[test] + fn oversized_extra_claims_are_rejected() { + let assertion = + assertion(|r| r.attributes = vec![("bio".to_string(), vec!["x".repeat(5000)])]); + let params = SamlParams { + attribute_claim_map: HashMap::from([("bio".to_string(), "bio".to_string())]), + ..params() + }; + assert!(rejection(&assertion, ¶ms).contains("size limit")); + } +} diff --git a/server/src/saml/mod.rs b/server/src/saml/mod.rs index 5879a42d..a6c1f623 100644 --- a/server/src/saml/mod.rs +++ b/server/src/saml/mod.rs @@ -13,6 +13,17 @@ pub(crate) use params_validation::validate_saml_params; mod sp_signing_key; pub(crate) use sp_signing_key::load_sp_signing_key; +mod response_validation; + +mod identity_mapping; + +mod authn_request; + +mod sp_metadata; + +mod endpoints; +pub(crate) use endpoints::{MAX_SAML_FORM_BYTES, SamlState, saml_acs, saml_login, saml_metadata}; + #[cfg(test)] mod tests { use samael::crypto::{CertificateDer, Crypto, CryptoProvider}; diff --git a/server/src/saml/params_validation.rs b/server/src/saml/params_validation.rs index 68c0b89e..a77a0b10 100644 --- a/server/src/saml/params_validation.rs +++ b/server/src/saml/params_validation.rs @@ -19,7 +19,7 @@ use x509_cert::{ use crate::{AuthError, AuthResult, SamlParams, reject_reserved_claim_names}; const SAML2_PROTOCOL: &str = "urn:oasis:names:tc:SAML:2.0:protocol"; -const NAMEID_TRANSIENT: &str = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient"; +pub(super) const NAMEID_TRANSIENT: &str = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient"; /// Largest IdP metadata accepted. Real single-IdP metadata is well under this; federation /// aggregates (thousands of IdPs) are not supported. const MAX_METADATA_BYTES: usize = 256 * 1024; @@ -287,12 +287,13 @@ fn validate_return_urls(params: &SamlParams) -> AuthResult<()> { #[cfg(test)] mod tests { use super::*; - use crate::tests::helpers::{test_idp_certificate_base64, test_idp_metadata, test_saml_params}; + use crate::tests::helpers::{ + TEST_IDP_CERTIFICATE_PEM, test_idp_certificate_base64, test_idp_metadata, test_saml_params, + }; use std::collections::HashMap; use x509_cert::der::DecodePem; const REALM: &str = "acme"; - const TEST_CERT_PEM: &str = include_str!("../tests/certificates/ec/auth.server.cert.pem"); fn valid_metadata() -> String { test_idp_metadata(HTTP_REDIRECT_BINDING, "https://idp.example.com/sso", "") @@ -326,7 +327,8 @@ mod tests { assert_eq!(params.idp_signing_certificates.len(), 1); let reparsed = Certificate::from_pem(params.idp_signing_certificates[0].as_bytes()) .expect("stored certificate is valid PEM"); - let original = Certificate::from_pem(TEST_CERT_PEM.as_bytes()).expect("test certificate"); + let original = + Certificate::from_pem(TEST_IDP_CERTIFICATE_PEM.as_bytes()).expect("test certificate"); assert_eq!(reparsed, original); } diff --git a/server/src/saml/request_store.rs b/server/src/saml/request_store.rs index 63fd82ed..14cb89a5 100644 --- a/server/src/saml/request_store.rs +++ b/server/src/saml/request_store.rs @@ -45,7 +45,7 @@ pub trait SamlRequestStore: Send + Sync { /// Record a consumed assertion ID for replay defense. Returns `true` if it was newly /// recorded, `false` if it had already been seen (a replay). Fails if `expires_at` has /// already passed. `expires_at` must cover every instant at which the assertion could still - /// be accepted (its `NotOnOrAfter` plus the validator's clock-skew allowance), because the + /// be accepted (for the ACS: its `IssueInstant` plus the accepted issue delay), because the /// entry may be forgotten after that. async fn record_assertion_id(&self, assertion_id: &str, expires_at: i64) -> AuthResult; diff --git a/server/src/saml/response_validation.rs b/server/src/saml/response_validation.rs new file mode 100644 index 00000000..3fa1b025 --- /dev/null +++ b/server/src/saml/response_validation.rs @@ -0,0 +1,404 @@ +use chrono::{Duration, Utc}; +use samael::{ + crypto::AllowedSignatureAlgorithm, + key_info::{KeyInfo, X509Data}, + metadata::{EntityDescriptor, IdpSsoDescriptor, KeyDescriptor}, + schema::Assertion, + service_provider::ServiceProvider, +}; + +use crate::{AuthError, AuthResult, SamlParams, saml::SamlRequestStore}; + +/// Accepted difference between the IdP's clock and ours (samael's and Shibboleth's default). +const MAX_CLOCK_SKEW_SECONDS: i64 = 180; + +/// How long after its `IssueInstant` an assertion is still accepted. This also bounds how +/// long a consumed assertion ID must be remembered to detect a replay. +const MAX_ISSUE_DELAY_SECONDS: i64 = 300; + +/// Check a SAML `` answering our `AuthnRequest` `request_id`, against the realm +/// settings `params`, and return its verified assertion. Each assertion is accepted once. +/// +/// samael checks the signature (of the response or the assertion), `InResponseTo`, issuer, +/// validity window, bearer `Recipient`, `Destination` and status; this adds what it leaves +/// out: a mandatory signature, the audience and `AuthnStatement` required by the Web Browser +/// SSO profile (SAMLProf §4.1.4.2), and replay detection. +pub(crate) async fn validate_saml_response( + response_xml: &str, + params: &SamlParams, + request_id: &str, + store: &dyn SamlRequestStore, +) -> AuthResult { + let assertion = service_provider(params)? + .parse_xml_response(response_xml, Some(&[request_id])) + .map_err(|e| saml_error(&format!("rejected SAML response: {e}")))?; + require_audience(&assertion, ¶ms.sp_entity_id)?; + if assertion + .authn_statements + .as_ref() + .is_none_or(Vec::is_empty) + { + return Err(saml_error("assertion has no AuthnStatement")); + } + let accepted_until = assertion.issue_instant + Duration::seconds(MAX_ISSUE_DELAY_SECONDS); + // samael enforces this too; checked here because the replay retention below relies on it. + if accepted_until <= Utc::now() { + return Err(saml_error("assertion was issued too long ago")); + } + // Rounded up: the store needs a whole second that is still in the future. + if !store + .record_assertion_id(&assertion.id, accepted_until.timestamp() + 1) + .await? + { + return Err(saml_error("assertion has already been used")); + } + Ok(assertion) +} + +fn service_provider(params: &SamlParams) -> AuthResult { + // Without a certificate samael accepts responses without verifying any signature. + if params.idp_signing_certificates.is_empty() { + return Err(saml_error("the realm has no IdP signing certificate")); + } + Ok(ServiceProvider { + entity_id: Some(params.sp_entity_id.clone()), + acs_url: Some(params.sp_acs_url.clone()), + // samael falls back to the metadata URL as audience; ours is always the entity ID. + metadata_url: None, + slo_url: None, + idp_metadata: trusted_idp(params), + allow_idp_initiated: false, + max_issue_delay: Duration::seconds(MAX_ISSUE_DELAY_SECONDS), + max_clock_skew: Duration::seconds(MAX_CLOCK_SKEW_SECONDS), + // Also restricts the digest algorithm, which keeps SHA-1 out entirely. + allowed_signature_algorithms: Some(vec![ + AllowedSignatureAlgorithm::RsaSha256, + AllowedSignatureAlgorithm::RsaSha384, + AllowedSignatureAlgorithm::RsaSha512, + AllowedSignatureAlgorithm::EcdsaSha256, + AllowedSignatureAlgorithm::EcdsaSha384, + AllowedSignatureAlgorithm::EcdsaSha512, + ]), + ..ServiceProvider::default() + }) +} + +/// The IdP as samael expects it, built from the settings validated when the realm was saved. +fn trusted_idp(params: &SamlParams) -> EntityDescriptor { + let certificates = params + .idp_signing_certificates + .iter() + .map(|pem| { + pem.lines() + .filter(|line| !line.starts_with("-----")) + .collect() + }) + .collect(); + let signing_key = KeyDescriptor { + key_use: Some("signing".to_string()), + key_info: KeyInfo { + id: None, + x509_data: Some(X509Data { certificates }), + }, + encryption_methods: None, + }; + EntityDescriptor { + entity_id: Some(params.idp_entity_id.clone()), + idp_sso_descriptors: Some(vec![IdpSsoDescriptor { + id: None, + valid_until: None, + cache_duration: None, + protocol_support_enumeration: None, + error_url: None, + signature: None, + key_descriptors: vec![signing_key], + organization: None, + contact_people: Vec::new(), + artifact_resolution_service: Vec::new(), + single_logout_services: Vec::new(), + manage_name_id_services: Vec::new(), + name_id_formats: Vec::new(), + want_authn_requests_signed: None, + single_sign_on_services: Vec::new(), + name_id_mapping_services: Vec::new(), + assertion_id_request_services: Vec::new(), + attribute_profiles: Vec::new(), + attributes: Vec::new(), + }]), + ..EntityDescriptor::default() + } +} + +/// SAMLCore §2.5.1.4: every `` must name us. samael accepts an +/// assertion with none, or when any single one does. +fn require_audience(assertion: &Assertion, sp_entity_id: &str) -> AuthResult<()> { + let restrictions = assertion + .conditions + .as_ref() + .and_then(|conditions| conditions.audience_restrictions.as_ref()) + .filter(|restrictions| !restrictions.is_empty()) + .ok_or_else(|| saml_error("assertion has no AudienceRestriction"))?; + if restrictions + .iter() + .all(|restriction| restriction.audience.iter().any(|a| a == sp_entity_id)) + { + Ok(()) + } else { + Err(saml_error( + "assertion is not addressed to this service provider", + )) + } +} + +fn saml_error(message: &str) -> AuthError { + AuthError::Saml(message.to_string()) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use chrono::{Duration, Utc}; + use samael::schema::Assertion; + + use super::validate_saml_response; + use crate::{ + AuthError, AuthResult, DatabaseBackend, DatabaseParams, SamlParams, SamlRequestStore, + create_saml_request_store, + saml::validate_saml_params, + tests::{ + helpers::test_saml_params, + saml_idp::{TestIdp, TestResponse}, + }, + }; + + const REALM: &str = "acme"; + const REQUEST_ID: &str = "_request-1"; + + fn realm_params() -> SamlParams { + let mut params = test_saml_params(REALM); + validate_saml_params(&mut params, REALM).expect("valid test settings"); + params + } + + async fn memory_store() -> Arc { + create_saml_request_store(&DatabaseParams { + backend: DatabaseBackend::SQLite, + connection_url: "sqlite::memory:".to_string(), + max_connections: 1, + ..DatabaseParams::default() + }) + .await + .expect("in-memory SAML request store") + } + + fn response() -> TestResponse { + TestResponse::answering(REQUEST_ID, &realm_params()) + } + + async fn validate(xml: &str) -> AuthResult { + validate_saml_response( + xml, + &realm_params(), + REQUEST_ID, + memory_store().await.as_ref(), + ) + .await + } + + async fn rejection(response: TestResponse) -> String { + match validate(&TestIdp::new().sign(&response)).await { + Err(AuthError::Saml(message)) => message, + Err(other) => panic!("expected a SAML rejection, got {other:?}"), + Ok(assertion) => panic!("expected a rejection, accepted {}", assertion.id), + } + } + + fn name_id(assertion: &Assertion) -> Option<&str> { + assertion + .subject + .as_ref() + .and_then(|subject| subject.name_id.as_ref()) + .map(|name_id| name_id.value.as_str()) + } + + fn assert_mentions(message: &str, expected: &str) { + assert!( + message.contains(expected), + "expected {expected:?} in: {message}" + ); + } + + #[actix_web::test] + async fn a_valid_response_is_accepted() { + let assertion = validate(&TestIdp::new().sign(&response())) + .await + .expect("valid response"); + assert_eq!(name_id(&assertion), Some("alice")); + } + + #[actix_web::test] + async fn a_response_signed_only_at_the_response_level_is_accepted() { + let mut response = response(); + response.sign_assertion = false; + response.sign_response = true; + validate(&TestIdp::new().sign(&response)) + .await + .expect("a signed response covers its assertion"); + } + + #[actix_web::test] + async fn a_replayed_assertion_is_rejected() { + let (params, store) = (realm_params(), memory_store().await); + let xml = TestIdp::new().sign(&response()); + validate_saml_response(&xml, ¶ms, REQUEST_ID, store.as_ref()) + .await + .expect("first use"); + let Err(AuthError::Saml(message)) = + validate_saml_response(&xml, ¶ms, REQUEST_ID, store.as_ref()).await + else { + panic!("the second use must be rejected"); + }; + assert_mentions(&message, "already been used"); + } + + #[actix_web::test] + async fn an_unsigned_response_is_rejected() { + let mut response = response(); + response.sign_assertion = false; + assert_mentions(&rejection(response).await, "must be signed"); + } + + #[actix_web::test] + async fn a_signature_by_another_key_is_rejected() { + let xml = TestIdp::untrusted().sign(&response()); + assert!(matches!(validate(&xml).await, Err(AuthError::Saml(_)))); + } + + #[actix_web::test] + async fn a_response_modified_after_signing_is_rejected() { + let xml = TestIdp::new() + .sign(&response()) + .replace(">alice<", ">mallory<"); + assert!(matches!(validate(&xml).await, Err(AuthError::Saml(_)))); + } + + /// Signature wrapping: an unsigned assertion placed next to a validly signed one must never + /// be the one returned. + #[actix_web::test] + async fn an_injected_unsigned_assertion_is_never_returned() { + let legitimate = TestIdp::new().sign(&response()); + let mut forged = response(); + forged.name_id = "mallory".to_string(); + forged.sign_assertion = false; + let forged = TestIdp::new().sign(&forged); + let start = forged.find("").expect("assertion end"); + let forged_assertion = &forged[start..end + "".len()]; + let insert_at = legitimate.find(" AuthResult { + let certificate = signing_key.certificate.to_der().map_err(|e| { + AuthError::Unexpected(format!("failed to encode the SAML certificate: {e}")) + })?; + let name_id_format = params + .idp_nameid_format + .as_ref() + .map(|format| { + format!( + "\n {}", + escape_markup(format) + ) + }) + .unwrap_or_default(); + Ok(format!( + r#" + + + + {certificate} + {name_id_format} + + + +"#, + entity_id = escape_markup(¶ms.sp_entity_id), + certificate = STANDARD.encode(certificate), + acs_url = escape_markup(¶ms.sp_acs_url), + )) +} + +/// Escape text for an XML or HTML attribute value or element content. +pub(super) fn escape_markup(text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} diff --git a/server/src/saml/sp_signing_key.rs b/server/src/saml/sp_signing_key.rs index 91f9cf3e..1531d46f 100644 --- a/server/src/saml/sp_signing_key.rs +++ b/server/src/saml/sp_signing_key.rs @@ -10,10 +10,6 @@ const MIN_RSA_BITS: u32 = 2048; /// This server's SAML SP signing key and the certificate published for it, checked to /// belong together. -#[expect( - dead_code, - reason = "the SAML endpoints that sign with it are not implemented yet" -)] pub(crate) struct SamlSpSigningKey { pub(crate) private_key: PKey, pub(crate) certificate: X509, diff --git a/server/src/server/auth_verifier.rs b/server/src/server/auth_verifier.rs index 57816a24..e42bfe8e 100644 --- a/server/src/server/auth_verifier.rs +++ b/server/src/server/auth_verifier.rs @@ -75,6 +75,17 @@ use std::{ sync::{Arc, mpsc}, }; +/// State for the `/saml` scope, present only when built with `saml` and `saml_sp_params` is set. +#[cfg(feature = "saml")] +type SamlScopeState = Option>; +#[cfg(not(feature = "saml"))] +type SamlScopeState = NoSaml; + +/// Placeholder for the SAML scope state in builds without the `saml` feature. +#[cfg(not(feature = "saml"))] +#[derive(Clone)] +struct NoSaml; + /// Build a `Cors` middleware for *admin* scopes. /// /// When `allowed_origins` is non-empty, only those origins are allowed, and @@ -171,7 +182,7 @@ async fn prepare_auth_verifier( // Check the SAML signing key before anything else, so a bad key stops the server // instead of failing the first SAML login. #[cfg(feature = "saml")] - let _saml_sp_signing_key = params + let saml_sp_signing_key = params .saml_sp_params .as_ref() .map(crate::saml::load_sp_signing_key) @@ -212,12 +223,30 @@ async fn prepare_auth_verifier( .stale_session_collector_config .clone() .unwrap_or_default(); + #[cfg(feature = "saml")] + let saml_cleanup_interval = collector_config.cleanup_interval_seconds; let (session_store, collector_handle) = crate::session::create_session_store_with_collector( &session_store_params, collector_config, ) .await?; + // SAML's pending requests and replay cache live next to the sessions. + #[cfg(feature = "saml")] + let saml_state: SamlScopeState = match saml_sp_signing_key { + Some(signing_key) => Some(Arc::new( + crate::saml::SamlState::start( + signing_key, + &session_store_params, + saml_cleanup_interval, + ) + .await?, + )), + None => None, + }; + #[cfg(not(feature = "saml"))] + let saml_state: SamlScopeState = NoSaml; + let jwks_manager = JwksManager::new(params.proxy_params.as_ref()).await; // let auth_public_url = params.params.auth_public_url.clone().unwrap_or_else(|| { @@ -311,6 +340,7 @@ async fn prepare_auth_verifier( jwks_data.clone(), cert_jwt_config.clone(), cert_jwks_data.clone(), + saml_state.clone(), ) }); let http_server = http_server @@ -373,6 +403,7 @@ fn build_app( jwks_data: Arc, cert_jwt_config: Option>, cert_jwks_data: Option>, + saml_state: SamlScopeState, ) -> App< impl ServiceFactory< ServiceRequest, @@ -393,6 +424,8 @@ fn build_app( server_params.login_rate_limit_per_second, server_params.login_rate_limit_burst, ); + #[cfg(feature = "saml")] + let saml_rate_limit = login_rate_limit.clone(); // Create an `App` instance and configure the passed data and the various scopes let app = App::new() @@ -620,6 +653,24 @@ fn build_app( // same `{"errors": [...]}` envelope every other endpoint uses. .default_service(web::route().to(unsupported_route)); + // SAML browser endpoints: no CORS (top-level navigations and form posts only), and + // the same per-IP rate limit as `/login`. + #[cfg(feature = "saml")] + let app = match saml_state { + Some(state) => app.service( + web::scope("/saml") + .app_data(Data::from(state)) + .app_data(web::FormConfig::default().limit(crate::saml::MAX_SAML_FORM_BYTES)) + .wrap(saml_rate_limit) + .service(crate::saml::saml_login) + .service(crate::saml::saml_acs) + .service(crate::saml::saml_metadata), + ), + None => app, + }; + #[cfg(not(feature = "saml"))] + let NoSaml = saml_state; + #[cfg(feature = "admin-ui")] let app = { if let Some(ref ui_path) = server_params.admin_ui_path { diff --git a/server/src/tests/helpers.rs b/server/src/tests/helpers.rs index 8564fdac..cd2b67dc 100644 --- a/server/src/tests/helpers.rs +++ b/server/src/tests/helpers.rs @@ -28,21 +28,29 @@ pub fn test_realm(id: &str) -> Realm { } } -/// Base64 DER of the EC test server certificate, as it appears in an ``. +/// entityID of the test IdP (see `tests::saml_idp::TestIdp`). +#[cfg(feature = "saml")] +pub const TEST_IDP_ENTITY_ID: &str = "https://idp.example.com/metadata"; + +/// Signing certificate of the test IdP (see `tests::saml_idp::TestIdp`). +#[cfg(feature = "saml")] +pub const TEST_IDP_CERTIFICATE_PEM: &str = include_str!("certificates/rsa/auth.user2.cert.pem"); + +/// Base64 DER of the test IdP certificate, as it appears in an ``. #[cfg(feature = "saml")] pub fn test_idp_certificate_base64() -> String { - include_str!("certificates/ec/auth.server.cert.pem") + TEST_IDP_CERTIFICATE_PEM .lines() .filter(|line| !line.starts_with("-----")) .collect() } -/// SAML 2.0 IdP metadata signing with the EC test certificate; `extra` is inserted into the -/// `IDPSSODescriptor` (e.g. a `NameIDFormat`). +/// SAML 2.0 metadata of the test IdP; `extra` is inserted into the `IDPSSODescriptor` +/// (e.g. a `NameIDFormat`). #[cfg(feature = "saml")] pub fn test_idp_metadata(sso_binding: &str, sso_url: &str, extra: &str) -> String { format!( - r#" + r#" {cert} {extra} diff --git a/server/src/tests/mod.rs b/server/src/tests/mod.rs index b7dbe73e..91c81986 100644 --- a/server/src/tests/mod.rs +++ b/server/src/tests/mod.rs @@ -32,6 +32,10 @@ mod sessions_api; mod sessions_store; +#[cfg(feature = "saml")] +mod saml_flow; +#[cfg(feature = "saml")] +pub mod saml_idp; #[cfg(feature = "saml")] mod saml_request_store; diff --git a/server/src/tests/saml_flow.rs b/server/src/tests/saml_flow.rs new file mode 100644 index 00000000..d371d685 --- /dev/null +++ b/server/src/tests/saml_flow.rs @@ -0,0 +1,317 @@ +//! SP-initiated SAML login end to end over HTTPS: `/saml/{realm}/login` → fake IdP → +//! `/saml/{realm}/acs` → session, plus `/saml/{realm}/metadata`. A browser is simulated +//! with redirects disabled and cookies passed by hand. + +use base64::{Engine, engine::general_purpose::STANDARD}; +use reqwest::{ + Client, Response, StatusCode, + header::{CONTENT_TYPE, COOKIE, LOCATION, SET_COOKIE}, + redirect::Policy, +}; +use samael::{ + crypto::UrlVerifier, + metadata::{EntityDescriptorType, de}, +}; +use serde_json::Value; +use url::Url; + +use crate::{ + AuthError, AuthResult, SamlParams, + tests::{ + TestsContext, get_default_server_params, + helpers::{ + admin_scheme, authenticate_as_admin, test_realm, test_saml_params, test_saml_sp_params, + }, + init_test_logging, + saml_idp::{TestIdp, TestResponse}, + start_default_test_server, start_test_server, + }, +}; + +const REALM: &str = "saml_flow"; +const SP_CERTIFICATE_PEM: &str = include_str!("certificates/rsa/auth.server.cert.pem"); + +fn realm_params() -> SamlParams { + SamlParams { + role_attribute: Some("groups".to_string()), + ..test_saml_params(REALM) + } +} + +async fn start_saml_server() -> AuthResult { + let mut params = get_default_server_params()?; + params.saml_sp_params = Some(test_saml_sp_params()); + let ctx = start_test_server(params).await?; + let mut realm = test_realm(REALM); + realm.auth_params.saml_params = Some(realm_params()); + authenticate_as_admin(&ctx) + .await? + .create_realm_as_super_admin(&realm) + .await?; + Ok(ctx) +} + +fn browser() -> Client { + Client::builder() + // Self-signed test certificate. + .danger_accept_invalid_certs(true) + .redirect(Policy::none()) + .build() + .expect("test HTTP client") +} + +fn http_error(e: reqwest::Error) -> AuthError { + AuthError::Unexpected(format!("HTTP request failed: {e}")) +} + +/// The `Set-Cookie` header setting `name`, as (`name=value`, whole header). +fn set_cookie(response: &Response, name: &str) -> Option<(String, String)> { + response + .headers() + .get_all(SET_COOKIE) + .iter() + .filter_map(|value| value.to_str().ok()) + .find(|header| header.starts_with(&format!("{name}="))) + .map(|header| { + let pair = header.split(';').next().unwrap_or_default().to_string(); + (pair, header.to_string()) + }) +} + +fn query_value(url: &Url, key: &str) -> Option { + url.query_pairs() + .find(|(k, _)| k == key) + .map(|(_, v)| v.into_owned()) +} + +fn login_url(ctx: &TestsContext, return_to: Option<&str>) -> Url { + let mut url = Url::parse(&format!("{}/saml/{REALM}/login", ctx.get_client_url())) + .expect("test server URL"); + if let Some(return_to) = return_to { + url.query_pairs_mut().append_pair("return_to", return_to); + } + url +} + +/// Start a login and return the IdP redirect and the login's request cookie (`name=value`). +async fn start_login(ctx: &TestsContext, return_to: Option<&str>) -> AuthResult<(Url, String)> { + let response = browser() + .get(login_url(ctx, return_to)) + .send() + .await + .map_err(http_error)?; + assert_eq!(response.status(), StatusCode::FOUND); + let (cookie, header) = set_cookie(&response, "_ea_saml").expect("request cookie"); + for attribute in ["Secure", "HttpOnly", "SameSite=None", "Path=/saml/"] { + assert!( + header.contains(attribute), + "{attribute} missing from: {header}" + ); + } + let location = response.headers()[LOCATION] + .to_str() + .expect("ASCII Location"); + let location = Url::parse(location).expect("absolute Location"); + Ok((location, cookie)) +} + +/// The fake IdP's valid answer to the login redirected to `idp_url`. +fn idp_answer(idp_url: &Url) -> (String, String) { + let relay_state = query_value(idp_url, "RelayState").expect("RelayState"); + let xml = TestIdp::new().sign(&TestResponse::answering(&relay_state, &realm_params())); + (xml, relay_state) +} + +async fn post_to_acs( + ctx: &TestsContext, + xml: &str, + relay_state: &str, + cookie: Option<&str>, +) -> AuthResult { + let body = url::form_urlencoded::Serializer::new(String::new()) + .append_pair("SAMLResponse", &STANDARD.encode(xml)) + .append_pair("RelayState", relay_state) + .finish(); + let mut request = browser() + .post(format!("{}/saml/{REALM}/acs", ctx.get_client_url())) + .header(CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(body); + if let Some(cookie) = cookie { + request = request.header(COOKIE, cookie); + } + request.send().await.map_err(http_error) +} + +#[actix_web::test] +async fn a_saml_login_signs_the_user_in() -> AuthResult<()> { + init_test_logging(None); + let ctx = start_saml_server().await?; + + let (idp_url, request_cookie) = + start_login(&ctx, Some("https://app.example.com/dashboard")).await?; + assert!(idp_url.as_str().starts_with("https://idp.example.com/sso?")); + assert_eq!( + query_value(&idp_url, "SigAlg").as_deref(), + Some("http://www.w3.org/2001/04/xmldsig-more#rsa-sha256") + ); + let verifier = UrlVerifier::from_x509_cert_pem(SP_CERTIFICATE_PEM).expect("SP certificate"); + assert!( + verifier + .verify_signed_request_url(&idp_url) + .expect("verifiable redirect"), + "the AuthnRequest must be signed with the SP key" + ); + + let (xml, relay_state) = idp_answer(&idp_url); + let response = post_to_acs(&ctx, &xml, &relay_state, Some(&request_cookie)).await?; + assert_eq!(response.status(), StatusCode::OK); + let (session_cookie, _) = set_cookie(&response, "_ea_").expect("session cookie"); + let (_, cleared) = set_cookie(&response, "_ea_saml").expect("request cookie removal"); + assert!(cleared.contains("Max-Age=0"), "{cleared}"); + let page = response.text().await.map_err(http_error)?; + assert!( + page.contains("url=https://app.example.com/dashboard"), + "{page}" + ); + + let claims: Value = browser() + .get(format!("{}/whoami?realm={REALM}", ctx.get_client_url())) + .header(COOKIE, &session_cookie) + .send() + .await + .map_err(http_error)? + .json() + .await + .map_err(http_error)?; + assert_eq!(claims["sub"], "alice"); + assert_eq!(claims["as_as"], "sa"); + assert_eq!(claims["as_rid"], REALM); + assert_eq!(claims["roles"], serde_json::json!(["admins", "users"])); + + let replay = post_to_acs(&ctx, &xml, &relay_state, Some(&request_cookie)).await?; + assert_eq!( + replay.status(), + StatusCode::UNAUTHORIZED, + "a response is usable once" + ); + + ctx.stop_server().await +} + +/// Login CSRF: a response posted without the cookie of the browser that started the login is +/// refused, and the refusal doesn't burn the pending login. +#[actix_web::test] +async fn the_acs_only_accepts_the_browser_that_started_the_login() -> AuthResult<()> { + init_test_logging(None); + let ctx = start_saml_server().await?; + let (idp_url, request_cookie) = start_login(&ctx, None).await?; + let (xml, relay_state) = idp_answer(&idp_url); + + for cookie in [None, Some("_ea_saml=_another-login")] { + let response = post_to_acs(&ctx, &xml, &relay_state, cookie).await?; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert!(set_cookie(&response, "_ea_").is_none()); + } + + let response = post_to_acs(&ctx, &xml, &relay_state, Some(&request_cookie)).await?; + assert_eq!(response.status(), StatusCode::OK); + let page = response.text().await.map_err(http_error)?; + assert!( + page.contains("url=https://app.example.com/home"), + "the default return URL applies: {page}" + ); + + ctx.stop_server().await +} + +#[actix_web::test] +async fn a_return_url_outside_the_allowed_origins_is_refused() -> AuthResult<()> { + init_test_logging(None); + let ctx = start_saml_server().await?; + for return_to in [ + "https://evil.example.com/", + "http://app.example.com/", + "not a url", + ] { + let response = browser() + .get(login_url(&ctx, Some(return_to))) + .send() + .await + .map_err(http_error)?; + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{return_to}"); + } + ctx.stop_server().await +} + +#[actix_web::test] +async fn the_sp_metadata_describes_this_server() -> AuthResult<()> { + init_test_logging(None); + let ctx = start_saml_server().await?; + + let response = browser() + .get(format!("{}/saml/{REALM}/metadata", ctx.get_client_url())) + .send() + .await + .map_err(http_error)?; + assert_eq!( + response.headers()[CONTENT_TYPE], + "application/samlmetadata+xml" + ); + + let xml = ctx + .get_test_client(admin_scheme()) + .get_saml_metadata(REALM) + .await?; + let EntityDescriptorType::EntityDescriptor(metadata) = + de::from_str::(&xml).expect("valid SAML metadata") + else { + panic!("expected a single EntityDescriptor: {xml}"); + }; + let params = realm_params(); + assert_eq!( + metadata.entity_id.as_deref(), + Some(params.sp_entity_id.as_str()) + ); + let sp = &metadata.sp_sso_descriptors.expect("SPSSODescriptor")[0]; + assert_eq!(sp.authn_requests_signed, Some(true)); + assert_eq!( + sp.assertion_consumer_services[0].location, + params.sp_acs_url + ); + let certificate: String = SP_CERTIFICATE_PEM + .lines() + .filter(|line| !line.starts_with("-----")) + .collect(); + assert!( + xml.contains(&certificate), + "the SP signing certificate is published" + ); + + ctx.stop_server().await +} + +#[actix_web::test] +async fn a_realm_without_saml_is_refused() -> AuthResult<()> { + init_test_logging(None); + let ctx = start_saml_server().await?; + let response = browser() + .get(format!("{}/saml/_/login", ctx.get_client_url())) + .send() + .await + .map_err(http_error)?; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + ctx.stop_server().await +} + +#[actix_web::test] +async fn there_are_no_saml_routes_without_a_signing_key() -> AuthResult<()> { + init_test_logging(None); + let ctx = start_default_test_server().await?; + let response = browser() + .get(format!("{}/saml/{REALM}/metadata", ctx.get_client_url())) + .send() + .await + .map_err(http_error)?; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + ctx.stop_server().await +} diff --git a/server/src/tests/saml_idp.rs b/server/src/tests/saml_idp.rs new file mode 100644 index 00000000..b7d4af4a --- /dev/null +++ b/server/src/tests/saml_idp.rs @@ -0,0 +1,333 @@ +//! A fake SAML 2.0 IdP: builds signed ``s answering our `AuthnRequest`s, with +//! every field the ACS validation checks exposed so tests can break one at a time. + +use base64::{Engine, engine::general_purpose::STANDARD}; +use chrono::{DateTime, Duration, SecondsFormat, Utc}; +use openssl::pkey::PKey; +use samael::crypto::{Crypto, CryptoProvider}; + +use crate::{SamlParams, tests::helpers::TEST_IDP_ENTITY_ID}; + +const PERSISTENT_NAMEID: &str = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"; +const SUCCESS_STATUS: &str = "urn:oasis:names:tc:SAML:2.0:status:Success"; + +/// One response, before signing. Built by [`TestResponse::answering`] with valid values; +/// tests change fields to produce the invalid variants. +pub struct TestResponse { + pub response_id: String, + pub assertion_id: String, + pub in_response_to: String, + /// `` of both the response and the assertion. + pub issuer: String, + /// Response `Destination` and bearer `SubjectConfirmationData/@Recipient`. + pub destination: String, + /// One `` per entry, each naming that single audience. + pub audience_restrictions: Vec, + pub status: String, + pub name_id: String, + pub name_id_format: String, + pub attributes: Vec<(String, Vec)>, + pub issue_instant: DateTime, + pub not_before: DateTime, + /// `Conditions/@NotOnOrAfter` and the bearer confirmation's `NotOnOrAfter`. + pub not_on_or_after: DateTime, + pub authn_statement: bool, + pub sign_assertion: bool, + pub sign_response: bool, +} + +impl TestResponse { + /// A valid response to `request_id` for a realm configured with `sp`: assertion signed, + /// valid for five minutes, NameID `alice`, with an `email` and a two-valued `groups` + /// attribute. + pub fn answering(request_id: &str, sp: &SamlParams) -> Self { + let now = Utc::now(); + Self { + response_id: new_id(), + assertion_id: new_id(), + in_response_to: request_id.to_string(), + issuer: TEST_IDP_ENTITY_ID.to_string(), + destination: sp.sp_acs_url.clone(), + audience_restrictions: vec![sp.sp_entity_id.clone()], + status: SUCCESS_STATUS.to_string(), + name_id: "alice".to_string(), + name_id_format: PERSISTENT_NAMEID.to_string(), + attributes: vec![ + ("email".to_string(), vec!["alice@example.com".to_string()]), + ( + "groups".to_string(), + vec!["admins".to_string(), "users".to_string()], + ), + ], + issue_instant: now, + not_before: now - Duration::minutes(1), + not_on_or_after: now + Duration::minutes(5), + authn_statement: true, + sign_assertion: true, + sign_response: false, + } + } +} + +pub struct TestIdp { + /// PKCS#1 DER, the form samael's xmlsec signer loads. + private_key_der: Vec, + certificate_base64: String, +} + +impl TestIdp { + /// The IdP that `test_idp_metadata` describes, so realms built from it trust its signatures. + pub fn new() -> Self { + Self::from_pem( + include_str!("certificates/rsa/auth.user2.key.pem"), + include_str!("certificates/rsa/auth.user2.cert.pem"), + ) + } + + /// An IdP with another key, whose signatures the test realms must reject. + pub fn untrusted() -> Self { + Self::from_pem( + include_str!("certificates/rsa/auth.user1.key.pem"), + include_str!("certificates/rsa/auth.user1.cert.pem"), + ) + } + + fn from_pem(key_pem: &str, certificate_pem: &str) -> Self { + let private_key_der = PKey::private_key_from_pem(key_pem.as_bytes()) + .and_then(|key| key.rsa()) + .and_then(|rsa| rsa.private_key_to_der()) + .expect("test IdP RSA key"); + let certificate_base64 = certificate_pem + .lines() + .filter(|line| !line.starts_with("-----")) + .collect(); + Self { + private_key_der, + certificate_base64, + } + } + + /// Render `response` as XML, signing the assertion and/or the response as requested. + pub fn sign(&self, response: &TestResponse) -> String { + let assertion_signature = response + .sign_assertion + .then(|| self.signature_template(&response.assertion_id)); + let mut assertion = render_assertion(response, assertion_signature.as_deref()); + if response.sign_assertion { + assertion = strip_xml_declaration(&self.sign_xml(&assertion)).to_string(); + } + let response_signature = response + .sign_response + .then(|| self.signature_template(&response.response_id)); + let xml = render_response(response, response_signature.as_deref(), &assertion); + if response.sign_response { + self.sign_xml(&xml) + } else { + xml + } + } + + /// samael signs only the first `` template, hence one call per signed element. + fn sign_xml(&self, xml: &str) -> String { + Crypto::sign_xml(xml, &self.private_key_der).expect("xmlsec signs the test response") + } + + /// RSA-SHA256 with a SHA-256 digest; samael's own template digests with SHA-1. + fn signature_template(&self, referenced_id: &str) -> String { + format!( + r##" + + + + + + + + + + + + + + + {cert} + "##, + cert = self.certificate_base64 + ) + } +} + +fn render_assertion(r: &TestResponse, signature: Option<&str>) -> String { + let attributes: String = r + .attributes + .iter() + .map(|(name, values)| { + let values: String = values + .iter() + .map(|v| format!("{}", escape(v))) + .collect(); + format!( + "\n {values}", + escape(name) + ) + }) + .collect(); + let audience_restrictions: String = r + .audience_restrictions + .iter() + .map(|audience| { + format!( + "\n {}", + escape(audience) + ) + }) + .collect(); + let authn_statement = if r.authn_statement { + format!( + r#" + + urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport + "#, + issued = timestamp(r.issue_instant), + id = r.assertion_id, + ) + } else { + String::new() + }; + format!( + r#" + {issuer}{signature} + + {name_id} + + + + + {audience_restrictions} + {authn_statement} + {attributes} + +"#, + id = r.assertion_id, + issued = timestamp(r.issue_instant), + issuer = escape(&r.issuer), + signature = signature.unwrap_or_default(), + format = escape(&r.name_id_format), + name_id = escape(&r.name_id), + in_response_to = escape(&r.in_response_to), + recipient = escape(&r.destination), + not_before = timestamp(r.not_before), + not_on_or_after = timestamp(r.not_on_or_after), + ) +} + +fn render_response(r: &TestResponse, signature: Option<&str>, assertion: &str) -> String { + format!( + r#" + {issuer}{signature} + + {assertion} +"#, + id = r.response_id, + issued = timestamp(r.issue_instant), + destination = escape(&r.destination), + in_response_to = escape(&r.in_response_to), + issuer = escape(&r.issuer), + signature = signature.unwrap_or_default(), + status = escape(&r.status), + ) +} + +/// SAML IDs are `xs:ID`, so they must not start with a digit. +fn new_id() -> String { + format!("_{}", uuid::Uuid::new_v4().simple()) +} + +fn timestamp(instant: DateTime) -> String { + instant.to_rfc3339_opts(SecondsFormat::Secs, true) +} + +fn strip_xml_declaration(xml: &str) -> &str { + match xml.trim_start().strip_prefix(" rest + .split_once("?>") + .map_or(xml, |(_, body)| body.trim_start()), + None => xml, + } +} + +fn escape(text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +/// The certificate the test realms trust, in the form samael's verifier takes. +pub fn test_idp_certificate_der() -> samael::crypto::CertificateDer { + STANDARD + .decode(crate::tests::helpers::test_idp_certificate_base64()) + .expect("test IdP certificate is base64") + .into() +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use samael::{ + crypto::{Crypto, CryptoProvider, ReduceMode}, + schema::Response, + }; + + use super::{TestIdp, TestResponse, test_idp_certificate_der}; + use crate::tests::helpers::test_saml_params; + + fn verify(xml: &str) -> Result { + Crypto::reduce_xml_to_signed( + xml, + &[test_idp_certificate_der()], + ReduceMode::ValidateAndMarkNoAncestors, + ) + } + + fn default_response() -> TestResponse { + TestResponse::answering("_request-1", &test_saml_params("acme")) + } + + #[test] + fn signed_assertion_verifies_and_parses() { + let xml = TestIdp::new().sign(&default_response()); + + let verified = verify(&xml).expect("assertion signature verifies"); + assert!(verified.contains(">alice"), "{verified}"); + let parsed = Response::from_str(&xml).expect("samael parses the response"); + assert_eq!(parsed.in_response_to.as_deref(), Some("_request-1")); + assert!(parsed.assertion.is_some()); + } + + #[test] + fn response_and_assertion_can_both_be_signed() { + let mut response = default_response(); + response.sign_response = true; + let xml = TestIdp::new().sign(&response); + + assert_eq!(xml.matches("").count(), 2, "{xml}"); + verify(&xml).expect("both signatures verify"); + } + + #[test] + fn untrusted_signature_is_rejected() { + let xml = TestIdp::untrusted().sign(&default_response()); + assert!(verify(&xml).is_err()); + } + + #[test] + fn tampering_after_signing_breaks_the_signature() { + let xml = TestIdp::new() + .sign(&default_response()) + .replace(">alice<", ">mallory<"); + assert!(verify(&xml).is_err()); + } +} From 5dc441d6dc37fb38cf0c471df65ca9aa1fc93ebd Mon Sep 17 00:00:00 2001 From: charming_wicket <235307685+charming-wicket-5502@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:49:53 +0200 Subject: [PATCH 06/19] feat(admin-ui): configure SAML realms with metadata preview and field-level validation --- CHANGELOG/feat_saml-integration.md | 5 +- admin-ui/CLAUDE.md | 2 +- .../realms/CertificateExpiryTag.tsx | 26 +++ .../src/components/realms/CopyableInput.tsx | 31 ++++ .../src/components/realms/RealmFormDrawer.tsx | 87 +++++++++- .../components/realms/SamlIdentityMapping.tsx | 72 ++++++++ .../components/realms/SamlMetadataField.tsx | 42 +++++ .../components/realms/SamlMetadataPreview.tsx | 43 +++++ .../components/realms/SamlSettingsSection.tsx | 93 +++++++++++ admin-ui/src/pages/RealmsPage.tsx | 12 ++ admin-ui/src/types/api.ts | 3 +- admin-ui/src/utils/samlForm.ts | 64 +++++++ admin-ui/src/utils/samlMetadata.ts | 82 +++++++++ admin-ui/src/utils/samlValidation.ts | 113 +++++++++++++ admin-ui/src/utils/x509.ts | 121 ++++++++++++++ admin-ui/tests/e2e/realms.spec.ts | 34 ++++ .../realms/SamlSettingsSection.test.tsx | 156 ++++++++++++++++++ admin-ui/tests/unit/fixtures/saml.ts | 71 ++++++++ admin-ui/tests/unit/pages/RealmsPage.test.tsx | 42 +++++ .../tests/unit/utils/samlMetadata.test.ts | 58 +++++++ .../tests/unit/utils/samlValidation.test.ts | 109 ++++++++++++ admin-ui/vite.config.ts | 1 + 22 files changed, 1257 insertions(+), 10 deletions(-) create mode 100644 admin-ui/src/components/realms/CertificateExpiryTag.tsx create mode 100644 admin-ui/src/components/realms/CopyableInput.tsx create mode 100644 admin-ui/src/components/realms/SamlIdentityMapping.tsx create mode 100644 admin-ui/src/components/realms/SamlMetadataField.tsx create mode 100644 admin-ui/src/components/realms/SamlMetadataPreview.tsx create mode 100644 admin-ui/src/components/realms/SamlSettingsSection.tsx create mode 100644 admin-ui/src/utils/samlForm.ts create mode 100644 admin-ui/src/utils/samlMetadata.ts create mode 100644 admin-ui/src/utils/samlValidation.ts create mode 100644 admin-ui/src/utils/x509.ts create mode 100644 admin-ui/tests/unit/components/realms/SamlSettingsSection.test.tsx create mode 100644 admin-ui/tests/unit/fixtures/saml.ts create mode 100644 admin-ui/tests/unit/utils/samlMetadata.test.ts create mode 100644 admin-ui/tests/unit/utils/samlValidation.test.ts diff --git a/CHANGELOG/feat_saml-integration.md b/CHANGELOG/feat_saml-integration.md index a14b9f2b..bbb3cbfd 100644 --- a/CHANGELOG/feat_saml-integration.md +++ b/CHANGELOG/feat_saml-integration.md @@ -11,6 +11,8 @@ - Add SAML identity mapping for the upcoming ACS endpoint: the session subject is the configured subject attribute (exactly one non-empty value) or else the NameID, rejected at login when the IdP actually sends a transient NameID, optionally lowercased; roles are the deduplicated values of the role attribute (none when it is unset or absent); and only attributes listed in `attribute_claim_map` become extra claims, within the existing 4 KiB extra-claims budget. - Add the SAML browser endpoints, registered under `/saml` only when `saml_sp_params` is set and rate limited like `/login`: `GET /saml/{realm_id}/login` checks `return_to` against the realm's allowed origins, stores the pending request (10 minutes) next to the sessions and redirects to the IdP with an RSA-SHA256-signed `AuthnRequest` carrying a 128-bit request ID; `POST /saml/{realm_id}/acs` requires the `_ea_saml` cookie set at login to match `RelayState` (login-CSRF protection), validates and maps the response, issues the `_ea_` session with `AuthScheme::Saml` and continues to the return URL through a same-site page so the `SameSite=Strict` session cookie is kept; `GET /saml/{realm_id}/metadata` publishes our SP metadata (entity ID, ACS, NameID format, signing certificate, signed requests), with a matching `AuthClient::get_saml_metadata`. - Purge expired SAML pending requests and replay-cache entries periodically, on the stale-session collector's interval. +- Add SAML 2.0 as a realm authentication method in the admin UI: pasted IdP metadata is parsed in the browser with the server's acceptance rules and previewed (IdP entity ID, sign-in URL, signing-certificate subject and expiry, NameID formats); the SP entity ID and ACS URL are prefilled, copyable and paired with a download of this server's SP metadata; subject, role and allowlisted claim mappings, return origins and default return URL are checked per field (reserved claim names, duplicates, non-origin or non-https URLs, ACS URL of another realm); server rejections naming a SAML setting appear on that field and other refusals inline; and realm cards show a SAML tag and flag IdP signing certificates within 30 days of expiry. +- Make `saml_params` optional in the admin UI's `RealmAuthParams` type, as the server omits it for realms without SAML, which also restores the admin UI's type-check. - Bundle a static, OpenSSL-only xmlsec 1.3.5 and libxml2 2.13.4 (`nix/xmlsec-static.nix`, built with the glibc-2.34 toolchain, XSLT and runtime crypto loading disabled) and wire it plus the libclang/bindgen setup into `shell.nix`, so `--features saml` links xmlsec statically against the server's vendored OpenSSL with no new runtime library dependency. ## Tests @@ -18,6 +20,7 @@ - Add a SAML request store test suite that runs against in-memory SQLite by default and against PostgreSQL, MySQL or Redis through the same `TEST_SESSIONS_STORE` switch as the session-store tests (single-use and concurrent takes, realm scoping, expiry on write and on read, replay detection, case-sensitive and long IDs, expiry purge), an xmlsec smoke test that exercises libxml2, xmlsec and its OpenSSL backend end to end, an API test checking that SAML settings are refused without the `saml` feature, unit tests for every SAML settings validation rule (prefixed and unprefixed metadata), and an API test checking that valid settings are stored with metadata-derived IdP fields while invalid ones get a field-specific 400, plus unit tests for the SAML signing-key checks and API tests checking that SAML realms are refused without a signing key and that a mismatched key, or the key section on a build without `saml`, stops the server from starting. - Add a fake SAML IdP test helper (`tests::saml_idp`) that renders ``s answering our `AuthnRequest`s with every validated field adjustable and signs the assertion and/or the response through xmlsec with RSA-SHA256 and SHA-256 digests, and switch the test IdP metadata to that IdP's RSA certificate, so the upcoming ACS validation can be tested against real signatures, untrusted keys and post-signing tampering. - Add SAML response validation tests that break one property at a time (signature missing, untrusted or broken by tampering, injected unsigned assertion, wrong request, issuer, recipient or audience, missing audience restriction or `AuthnStatement`, expired, not yet valid or issued too long ago, unsuccessful status, replay, realm without IdP certificate), and SAML identity mapping tests (NameID or single-valued subject attribute, transient NameID, lowercasing, roles, allowlisted extra claims, size limit), and end-to-end SAML tests over HTTPS (signed redirect verified with the SP certificate, fake-IdP login producing a session with the mapped subject, scheme and roles, replay, login-CSRF browser binding, return-URL allowlist, SP metadata, realms without SAML, no `/saml` routes without a signing key). +- Add admin UI tests for the SAML settings: certificate and metadata parsing and every client-side check, the realm drawer (preview, prefilled and copyable SP URLs, field-scoped client and server errors, turning SAML off), the SAML tag and expiry badge on realm cards, and a Playwright scenario creating a SAML realm from pasted metadata. ## CI @@ -25,5 +28,5 @@ ## Docs -- Document the `/saml/{realm_id}/login`, `/acs` and `/metadata` endpoints and a `SAML` tag in `openapi.yaml`. +- Document the `/saml/{realm_id}/login`, `/acs` and `/metadata` endpoints and a `SAML` tag in `openapi.yaml`, and proxy `/saml` from the admin UI dev and preview servers. - Add ADR-0003 recording why SAML uses `samael` with a statically bundled, OpenSSL-only xmlsec, why the server-side feature is behind an optional `saml` Cargo feature while the API types are not, the v1 protocol scope, the single server-wide RSA SP signing key, and the rejected alternatives and accepted risks (including that xmlsec/libxml2 security updates now ship with our releases). diff --git a/admin-ui/CLAUDE.md b/admin-ui/CLAUDE.md index e2ea41c9..6986adb1 100644 --- a/admin-ui/CLAUDE.md +++ b/admin-ui/CLAUDE.md @@ -69,7 +69,7 @@ tests/ ## Auth Server Proxy (dev) Target: `https://localhost:8443` -Proxied paths: `/login`, `/whoami`, `/sessions`, `/realms`, `/admins`, `/public` +Proxied paths: `/login`, `/whoami`, `/sessions`, `/realms`, `/admins`, `/auth`, `/public`, `/saml` Options: `secure: false`, `changeOrigin: true` ## Test Regression Policy diff --git a/admin-ui/src/components/realms/CertificateExpiryTag.tsx b/admin-ui/src/components/realms/CertificateExpiryTag.tsx new file mode 100644 index 00000000..d639d8ab --- /dev/null +++ b/admin-ui/src/components/realms/CertificateExpiryTag.tsx @@ -0,0 +1,26 @@ +import { Tag } from "antd"; +import React, { useState } from "react"; +import { CERTIFICATE_WARNING_DAYS, expiryLevel } from "../../utils/samlValidation"; + +export interface CertificateExpiryTagProps { + notAfter: Date; +} + +const DAY_MS = 86_400_000; + +/** Validity of an IdP signing certificate: red once expired, orange within the warning window. */ +export const CertificateExpiryTag: React.FC = ({ notAfter }) => { + const [now] = useState(() => new Date()); + const level = expiryLevel(notAfter, now); + const date = notAfter.toISOString().slice(0, 10); + if (level === "expired") return Expired on {date}; + if (level === "soon") { + const days = Math.ceil((notAfter.getTime() - now.getTime()) / DAY_MS); + return ( + + Expires in {days} day{days === 1 ? "" : "s"} ({date}) + + ); + } + return Valid until {date}; +}; diff --git a/admin-ui/src/components/realms/CopyableInput.tsx b/admin-ui/src/components/realms/CopyableInput.tsx new file mode 100644 index 00000000..a59d3be5 --- /dev/null +++ b/admin-ui/src/components/realms/CopyableInput.tsx @@ -0,0 +1,31 @@ +import { CopyOutlined } from "@ant-design/icons"; +import { Button, Input, message, Space } from "antd"; +import React from "react"; + +export interface CopyableInputProps { + /** Set by Form.Item so its label points at the input. */ + id?: string; + value?: string; + onChange?: (event: React.ChangeEvent) => void; + placeholder?: string; + /** Accessible name of the copy button. */ + copyLabel: string; +} + +/** A text input with a button copying its value, for values the admin pastes into the IdP. */ +export const CopyableInput: React.FC = ({ id, value, onChange, placeholder, copyLabel }) => { + const copy = async (): Promise => { + try { + await navigator.clipboard.writeText(value ?? ""); + message.success("Copied to clipboard"); + } catch { + message.error("Could not copy to the clipboard"); + } + }; + return ( + + + + + + )} + + +); diff --git a/admin-ui/src/components/realms/SamlMetadataField.tsx b/admin-ui/src/components/realms/SamlMetadataField.tsx new file mode 100644 index 00000000..397fc372 --- /dev/null +++ b/admin-ui/src/components/realms/SamlMetadataField.tsx @@ -0,0 +1,42 @@ +import { Form, Input } from "antd"; +import React from "react"; +import { parseIdpMetadata } from "../../utils/samlMetadata"; +import { SamlMetadataPreview } from "./SamlMetadataPreview"; + +const FIELD = ["saml", "metadata_xml"]; + +function tryParse(xml: unknown): ReturnType | null { + if (typeof xml !== "string" || !xml.trim()) return null; + try { + return parseIdpMetadata(xml); + } catch { + return null; + } +} + +/** The IdP metadata textarea: parse errors show under it, the parsed summary once it's valid. */ +export const SamlMetadataField: React.FC = () => { + const xml: unknown = Form.useWatch(FIELD); + const summary = tryParse(xml); + return ( + <> + { + if (typeof value !== "string" || !value.trim()) return; + parseIdpMetadata(value); + }, + }, + ]} + > + + + {summary && } + + ); +}; diff --git a/admin-ui/src/components/realms/SamlMetadataPreview.tsx b/admin-ui/src/components/realms/SamlMetadataPreview.tsx new file mode 100644 index 00000000..702b46b7 --- /dev/null +++ b/admin-ui/src/components/realms/SamlMetadataPreview.tsx @@ -0,0 +1,43 @@ +import { Descriptions, Space, Typography } from "antd"; +import React from "react"; +import type { IdpMetadataSummary } from "../../utils/samlMetadata"; +import { CertificateExpiryTag } from "./CertificateExpiryTag"; + +export interface SamlMetadataPreviewProps { + summary: IdpMetadataSummary; +} + +/** What the server will take from the pasted metadata, for the admin to confirm before saving. */ +export const SamlMetadataPreview: React.FC = ({ summary }) => ( + + + {summary.entityId} + + + {summary.ssoUrl} + + + + {summary.certificates.map((certificate) => + certificate.info ? ( + + {certificate.info.subject || "(no common name)"} + + + ) : ( + + Unreadable certificate: {certificate.error} + + ), + )} + + + + {summary.nameIdFormats.length > 0 ? ( + summary.nameIdFormats.join(", ") + ) : ( + none advertised + )} + + +); diff --git a/admin-ui/src/components/realms/SamlSettingsSection.tsx b/admin-ui/src/components/realms/SamlSettingsSection.tsx new file mode 100644 index 00000000..2a80a1da --- /dev/null +++ b/admin-ui/src/components/realms/SamlSettingsSection.tsx @@ -0,0 +1,93 @@ +import { DownloadOutlined } from "@ant-design/icons"; +import { Alert, Button, Divider, Form, Input, Select } from "antd"; +import React from "react"; +import { acsUrlError, defaultReturnUrlError, isBareHttpsOrigin } from "../../utils/samlValidation"; +import { CopyableInput } from "./CopyableInput"; +import { SamlIdentityMapping } from "./SamlIdentityMapping"; +import { SamlMetadataField } from "./SamlMetadataField"; + +export interface SamlSettingsSectionProps { + /** The realm ID being edited or created, which the ACS URL must end with. */ + realmId: string; + /** Our SP metadata URL, once SAML settings have been saved for this realm. */ + metadataUrl: string | null; +} + +const ORIGINS = ["saml", "allowed_return_origins"]; + +/** SAML settings of a realm: the IdP, this server's SP identity, identity mapping, redirects. */ +export const SamlSettingsSection: React.FC = ({ realmId, metadataUrl }) => ( + <> + + + This server (give these to the IdP) + + + + { + const error = typeof value === "string" && value ? acsUrlError(value, realmId) : null; + if (error) throw new Error(error); + }, + }, + ]} + > + + + {metadataUrl ? ( + + ) : ( + + )} + + Identity mapping + + + After sign-in + { + const invalid = (origins ?? []).find((origin) => !isBareHttpsOrigin(origin)); + if (invalid) throw new Error(`'${invalid}' must be an https origin such as https://app.example.com`); + }, + }, + ]} + > + + + +); diff --git a/admin-ui/src/pages/RealmsPage.tsx b/admin-ui/src/pages/RealmsPage.tsx index 3d6d42f1..23a58358 100644 --- a/admin-ui/src/pages/RealmsPage.tsx +++ b/admin-ui/src/pages/RealmsPage.tsx @@ -10,7 +10,10 @@ import { LoadingState } from "../components/common/LoadingState"; import { EmptyState } from "../components/common/EmptyState"; import { ConfirmDeleteModal } from "../components/common/ConfirmDeleteModal"; import { RealmFormDrawer } from "../components/realms/RealmFormDrawer"; +import { CertificateExpiryTag } from "../components/realms/CertificateExpiryTag"; import { formatDuration } from "../utils/formatDuration"; +import { expiryLevel } from "../utils/samlValidation"; +import { earliestExpiry } from "../utils/x509"; const RealmsPage: React.FC = () => { const { serverUrl } = useAuth(); @@ -112,6 +115,9 @@ const RealmsPage: React.FC = () => { if (realm.auth_params.username_password_params) methods.push("Password"); if (realm.auth_params.jwt_params) methods.push("JWT"); if (realm.auth_params.totp_params) methods.push("TOTP"); + if (realm.auth_params.saml_params) methods.push("SAML"); + const samlExpiry = earliestExpiry(realm.auth_params.saml_params?.idp_signing_certificates ?? []); + const flagSamlExpiry = samlExpiry !== null && expiryLevel(samlExpiry) !== "ok"; return ( @@ -145,6 +151,12 @@ const RealmsPage: React.FC = () => { ))} {methods.length === 0 && No auth methods} + {flagSamlExpiry && samlExpiry && ( +
+ IdP certificate: + +
+ )} diff --git a/admin-ui/src/types/api.ts b/admin-ui/src/types/api.ts index 4c481943..be9f7e24 100644 --- a/admin-ui/src/types/api.ts +++ b/admin-ui/src/types/api.ts @@ -59,7 +59,8 @@ export interface RealmAuthParams { jwt_params: JwtParams | null; username_password_params: UsernamePasswordParams | null; totp_params: TotpRealmParams | null; - saml_params: SamlParams | null; + /** Omitted by the server when the realm doesn't use SAML. */ + saml_params?: SamlParams | null; } /** Realm configuration */ diff --git a/admin-ui/src/utils/samlForm.ts b/admin-ui/src/utils/samlForm.ts new file mode 100644 index 00000000..41d1135a --- /dev/null +++ b/admin-ui/src/utils/samlForm.ts @@ -0,0 +1,64 @@ +import type { SamlParams } from "../types/api"; +import type { ClaimMapping } from "./samlValidation"; + +/** The SAML section's form values; the IdP fields are left out since the server derives them. */ +export interface SamlFormValues { + metadata_xml: string; + sp_entity_id: string; + sp_acs_url: string; + subject_attribute?: string; + normalize_subject_case: boolean; + role_attribute?: string; + claim_map: ClaimMapping[]; + allowed_return_origins: string[]; + default_return_url: string; +} + +/** Where this server's SAML endpoints for `realmId` live, as seen from `baseUrl`. */ +export function defaultSpUrls(baseUrl: string, realmId: string): { entityId: string; acsUrl: string } { + const realm = encodeURIComponent(realmId); + return { entityId: `${baseUrl}/saml/${realm}`, acsUrl: `${baseUrl}/saml/${realm}/acs` }; +} + +export function toSamlFormValues(params: SamlParams | null | undefined): SamlFormValues { + return { + metadata_xml: params?.metadata_xml ?? "", + sp_entity_id: params?.sp_entity_id ?? "", + sp_acs_url: params?.sp_acs_url ?? "", + subject_attribute: params?.subject_attribute, + normalize_subject_case: params?.normalize_subject_case ?? false, + role_attribute: params?.role_attribute, + claim_map: Object.entries(params?.attribute_claim_map ?? {}).map(([attribute, claim]) => ({ attribute, claim })), + allowed_return_origins: params?.allowed_return_origins ?? [], + default_return_url: params?.default_return_url ?? "", + }; +} + +function optional(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +export function toSamlParams(values: Partial | undefined): SamlParams { + return { + idp_entity_id: "", + idp_sso_url: "", + idp_signing_certificates: [], + metadata_xml: values?.metadata_xml?.trim() ?? "", + sp_entity_id: values?.sp_entity_id?.trim() ?? "", + sp_acs_url: values?.sp_acs_url?.trim() ?? "", + subject_attribute: optional(values?.subject_attribute), + normalize_subject_case: values?.normalize_subject_case ?? false, + role_attribute: optional(values?.role_attribute), + attribute_claim_map: Object.fromEntries( + (values?.claim_map ?? []).map(({ attribute = "", claim = "" }) => [attribute.trim(), claim.trim()]), + ), + allowed_return_origins: (values?.allowed_return_origins ?? []).map((origin) => origin.trim()), + default_return_url: values?.default_return_url?.trim() ?? "", + }; +} + +/** Whether the SAML form values differ from the saved settings. */ +export function isSamlDirty(current: Partial | undefined, saved: SamlParams | null | undefined): boolean { + return JSON.stringify(toSamlParams(current)) !== JSON.stringify(toSamlParams(toSamlFormValues(saved))); +} diff --git a/admin-ui/src/utils/samlMetadata.ts b/admin-ui/src/utils/samlMetadata.ts new file mode 100644 index 00000000..602e6fc0 --- /dev/null +++ b/admin-ui/src/utils/samlMetadata.ts @@ -0,0 +1,82 @@ +import { type CertificateInfo, readCertificateInfo } from "./x509"; + +const SAML2_PROTOCOL = "urn:oasis:names:tc:SAML:2.0:protocol"; +const HTTP_REDIRECT_BINDING = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"; +/** Same limit as the server. */ +const MAX_METADATA_BYTES = 256 * 1024; + +export interface MetadataCertificate { + base64: string; + info: CertificateInfo | null; + error: string | null; +} + +/** What the server will derive from pasted IdP metadata, shown to the admin before saving. */ +export interface IdpMetadataSummary { + entityId: string; + ssoUrl: string; + certificates: MetadataCertificate[]; + nameIdFormats: string[]; +} + +function elements(root: Element, localName: string): Element[] { + return Array.from(root.getElementsByTagNameNS("*", localName)); +} + +function childElements(parent: Element, localName: string): Element[] { + return Array.from(parent.children).filter((child) => child.localName === localName); +} + +function parseRoot(xml: string): Element { + if (new TextEncoder().encode(xml).length > MAX_METADATA_BYTES) { + throw new Error("the metadata is larger than 256 KiB"); + } + const document = new DOMParser().parseFromString(xml, "application/xml"); + const root = document.documentElement; + if (!root || document.getElementsByTagName("parsererror").length > 0) { + throw new Error("the metadata is not well-formed XML"); + } + if (root.localName === "EntitiesDescriptor") { + throw new Error("this is a federation bundle with several entities; paste the metadata of the single IdP"); + } + if (root.localName !== "EntityDescriptor") throw new Error("the metadata has no EntityDescriptor"); + return root; +} + +function signingCertificates(idp: Element): MetadataCertificate[] { + // A KeyDescriptor without `use` serves both signing and encryption (SAMLMeta §2.4.1.1). + const descriptors = childElements(idp, "KeyDescriptor").filter((key) => (key.getAttribute("use") ?? "signing") === "signing"); + return descriptors + .flatMap((key) => elements(key, "X509Certificate")) + .map((element) => { + const base64 = (element.textContent ?? "").replace(/\s+/g, ""); + try { + return { base64, info: readCertificateInfo(base64), error: null }; + } catch (e) { + return { base64, info: null, error: e instanceof Error ? e.message : "unreadable certificate" }; + } + }); +} + +/** Parse pasted IdP metadata with the same acceptance rules as the server; throws a readable Error. */ +export function parseIdpMetadata(xml: string): IdpMetadataSummary { + const root = parseRoot(xml.trim()); + const entityId = root.getAttribute("entityID") ?? ""; + if (!entityId) throw new Error("the EntityDescriptor has no entityID"); + const validUntil = root.getAttribute("validUntil"); + if (validUntil && new Date(validUntil).getTime() <= Date.now()) { + throw new Error(`the metadata expired on ${validUntil}`); + } + const idp = childElements(root, "IDPSSODescriptor").find((descriptor) => + (descriptor.getAttribute("protocolSupportEnumeration") ?? "").split(/\s+/).includes(SAML2_PROTOCOL), + ); + if (!idp) throw new Error("the metadata has no SAML 2.0 IDPSSODescriptor"); + const sso = childElements(idp, "SingleSignOnService").find((service) => service.getAttribute("Binding") === HTTP_REDIRECT_BINDING); + const ssoUrl = sso?.getAttribute("Location") ?? ""; + if (!ssoUrl) throw new Error("the IdP has no HTTP-Redirect SingleSignOnService"); + if (!ssoUrl.startsWith("https://")) throw new Error("the IdP SingleSignOnService must use https"); + const certificates = signingCertificates(idp); + if (certificates.length === 0) throw new Error("the IdP has no signing certificate"); + const nameIdFormats = childElements(idp, "NameIDFormat").map((format) => (format.textContent ?? "").trim()); + return { entityId, ssoUrl, certificates, nameIdFormats }; +} diff --git a/admin-ui/src/utils/samlValidation.ts b/admin-ui/src/utils/samlValidation.ts new file mode 100644 index 00000000..99650e05 --- /dev/null +++ b/admin-ui/src/utils/samlValidation.ts @@ -0,0 +1,113 @@ +import { ApiError } from "../services/api"; + +/** + * Claim names this server sets itself in session JWTs and certificates (see + * `reserved_claim_names` in client/src/models/claim_policy.rs); mapped attributes can't use them. + */ +export const RESERVED_CLAIM_NAMES: readonly string[] = [ + "iss", + "sub", + "aud", + "exp", + "nbf", + "iat", + "jti", + "roles", + "as_as", + "as_rid", + "realm_id", + "auth_scheme", + "verification_key", +]; + +/** Signing certificates within this many days of expiry are flagged. */ +export const CERTIFICATE_WARNING_DAYS = 30; + +export type ExpiryLevel = "expired" | "soon" | "ok"; + +export function expiryLevel(notAfter: Date, now: Date = new Date()): ExpiryLevel { + const days = (notAfter.getTime() - now.getTime()) / 86_400_000; + if (days <= 0) return "expired"; + return days <= CERTIFICATE_WARNING_DAYS ? "soon" : "ok"; +} + +/** `https://host[:port]` with nothing after it, as the server requires for return origins. */ +export function isBareHttpsOrigin(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === "https:" && url.pathname === "/" && !url.search && !url.hash && !url.username; + } catch { + return false; + } +} + +export function acsUrlError(value: string, realmId: string): string | null { + if (!value.startsWith("https://")) return "must be an https URL"; + const expected = `/saml/${realmId}/acs`; + if (realmId && !value.endsWith(expected)) return `must end with ${expected}`; + return null; +} + +export function defaultReturnUrlError(value: string, origins: string[]): string | null { + let url: URL; + try { + url = new URL(value); + } catch { + return "must be a valid URL"; + } + if (url.protocol !== "https:") return "must use https"; + const allowed = origins.some((origin) => { + try { + return new URL(origin).origin === url.origin; + } catch { + return false; + } + }); + return allowed ? null : "its origin must be listed in the allowed return origins"; +} + +export interface ClaimMapping { + attribute?: string; + claim?: string; +} + +export function reservedClaimError(claim: string): string | null { + return RESERVED_CLAIM_NAMES.includes(claim.trim()) ? `'${claim.trim()}' is a reserved claim name` : null; +} + +/** An attribute or claim name used by more than one mapping, or null. */ +export function duplicateMappingError(mappings: ClaimMapping[]): string | null { + const attributes = new Set(); + const claims = new Set(); + for (const { attribute = "", claim = "" } of mappings) { + if (attribute && attributes.has(attribute)) return `attribute '${attribute}' is mapped twice`; + if (claim && claims.has(claim)) return `claim '${claim}' is mapped twice`; + attributes.add(attribute); + claims.add(claim); + } + return null; +} + +export interface SamlFieldError { + field: string; + message: string; +} + +/** A server 400 naming a SAML setting (`saml_params.: `), or null. */ +export function samlFieldError(error: unknown): SamlFieldError | null { + if (!(error instanceof ApiError) || error.status !== 400) return null; + const match = /saml_params\.([a-z_]+): ([\s\S]*)$/.exec(serverMessage(error)); + return match ? { field: match[1], message: match[2] } : null; +} + +/** The server's error text: its JSON string body without the variant prefix. */ +export function serverMessage(error: ApiError): string { + let body: unknown = error.message; + try { + body = JSON.parse(error.message); + } catch { + // Not JSON: use the raw text. + } + const text = typeof body === "string" ? body : error.message; + return text.replace(/^Bad Request: /, ""); +} diff --git a/admin-ui/src/utils/x509.ts b/admin-ui/src/utils/x509.ts new file mode 100644 index 00000000..f3e546cf --- /dev/null +++ b/admin-ui/src/utils/x509.ts @@ -0,0 +1,121 @@ +/** What the admin needs to recognise an X.509 certificate: its subject and validity. */ +export interface CertificateInfo { + subject: string; + notBefore: Date; + notAfter: Date; +} + +/** A DER tag-length-value: `start`/`end` bound its content. */ +interface Tlv { + tag: number; + start: number; + end: number; +} + +const COMMON_NAME_OID = [0x55, 0x04, 0x03]; +const UTC_TIME = 0x17; +const EXPLICIT_VERSION = 0xa0; + +function readTlv(bytes: Uint8Array, offset: number): Tlv { + const tag = bytes[offset]; + let length = bytes[offset + 1]; + let start = offset + 2; + if (tag === undefined || length === undefined) throw new Error("truncated certificate"); + if (length & 0x80) { + const count = length & 0x7f; + if (count === 0 || count > 4) throw new Error("unsupported certificate encoding"); + length = 0; + for (let i = 0; i < count; i++) length = length * 256 + (bytes[start + i] ?? 0); + start += count; + } + const end = start + length; + if (end > bytes.length) throw new Error("truncated certificate"); + return { tag, start, end }; +} + +function children(bytes: Uint8Array, parent: Tlv): Tlv[] { + const result: Tlv[] = []; + for (let offset = parent.start; offset < parent.end; ) { + const child = readTlv(bytes, offset); + result.push(child); + offset = child.end; + } + return result; +} + +function text(bytes: Uint8Array, tlv: Tlv): string { + return new TextDecoder().decode(bytes.subarray(tlv.start, tlv.end)); +} + +/** UTCTime (`YYMMDDhhmmssZ`) or GeneralizedTime (`YYYYMMDDhhmmssZ`), RFC 5280 §4.1.2.5. */ +function readTime(bytes: Uint8Array, tlv: Tlv): Date { + const digits = text(bytes, tlv).replace(/Z$/, ""); + const utc = tlv.tag === UTC_TIME; + let year = Number(digits.slice(0, utc ? 2 : 4)); + if (utc) year += year < 50 ? 2000 : 1900; + const rest = digits.slice(utc ? 2 : 4); + const part = (from: number): number => Number(rest.slice(from, from + 2) || 0); + const date = new Date(Date.UTC(year, part(0) - 1, part(2), part(4), part(6), part(8))); + if (Number.isNaN(date.getTime())) throw new Error("invalid certificate validity date"); + return date; +} + +function commonName(bytes: Uint8Array, name: Tlv): string { + for (const rdn of children(bytes, name)) { + for (const attribute of children(bytes, rdn)) { + const [oid, value] = children(bytes, attribute); + const isCommonName = + oid !== undefined && oid.end - oid.start === 3 && COMMON_NAME_OID.every((b, i) => bytes[oid.start + i] === b); + if (isCommonName && value) return text(bytes, value); + } + } + return ""; +} + +/** Read the subject common name and validity of a base64 DER certificate (as in ``). */ +export function readCertificateInfo(base64: string): CertificateInfo { + let bytes: Uint8Array; + try { + bytes = Uint8Array.from(atob(base64.replace(/\s+/g, "")), (c) => c.charCodeAt(0)); + } catch { + throw new Error("certificate is not valid base64"); + } + const [tbs] = children(bytes, readTlv(bytes, 0)); + if (!tbs) throw new Error("not an X.509 certificate"); + const fields = children(bytes, tbs); + // TBSCertificate: [0] version?, serial, signature, issuer, validity, subject, … (RFC 5280 §4.1) + const first = fields[0]?.tag === EXPLICIT_VERSION ? 1 : 0; + const validity = fields[first + 3]; + const subject = fields[first + 4]; + if (!validity || !subject) throw new Error("not an X.509 certificate"); + const [notBefore, notAfter] = children(bytes, validity); + if (!notBefore || !notAfter) throw new Error("certificate has no validity period"); + return { + subject: commonName(bytes, subject), + notBefore: readTime(bytes, notBefore), + notAfter: readTime(bytes, notAfter), + }; +} + +/** The base64 body of a PEM certificate. */ +export function pemBody(pem: string): string { + return pem + .split("\n") + .filter((line) => !line.startsWith("-----")) + .join("") + .trim(); +} + +/** The soonest expiry among PEM certificates, ignoring unreadable ones; null if none is readable. */ +export function earliestExpiry(pems: string[]): Date | null { + let earliest: Date | null = null; + for (const pem of pems) { + try { + const { notAfter } = readCertificateInfo(pemBody(pem)); + if (!earliest || notAfter < earliest) earliest = notAfter; + } catch { + // The server validated these; an unreadable one is simply not reported here. + } + } + return earliest; +} diff --git a/admin-ui/tests/e2e/realms.spec.ts b/admin-ui/tests/e2e/realms.spec.ts index 1595f17d..6129d5d0 100644 --- a/admin-ui/tests/e2e/realms.spec.ts +++ b/admin-ui/tests/e2e/realms.spec.ts @@ -1,5 +1,6 @@ import { test, expect, type Page } from "@playwright/test"; import type { Realm } from "../../src/types/api"; +import { idpMetadata } from "../unit/fixtures/saml"; // ── Fixture data ────────────────────────────────────────────────────────────── @@ -204,4 +205,37 @@ test.describe("Realms page", () => { // The realm must no longer appear in the cards. await expect(page.getByText("my-service")).not.toBeVisible(); }); + + test("should create a SAML realm from pasted IdP metadata", async ({ page }) => { + await mockRealmsApi(page, [baseRealm]); + await page.goto("/admin-ui/realms"); + await page.getByRole("button", { name: "Create Realm" }).click(); + + await page.getByLabel("Realm ID").fill("acme"); + await page.getByRole("checkbox", { name: "SAML 2.0 (single sign-on)" }).check(); + await page.getByLabel("IdP metadata XML").fill(idpMetadata()); + const preview = page.getByLabel("Parsed IdP metadata"); + await expect(preview.getByText("https://idp.example.com/metadata")).toBeVisible(); + await expect(preview.getByText("user2.acme.com")).toBeVisible(); + + // The prefilled URLs point at the preview server (plain http); use the public ones. + await page.getByLabel("SP entity ID", { exact: true }).fill("https://auth.example.com/saml/acme"); + await page.getByLabel("Assertion Consumer Service URL", { exact: true }).fill("https://auth.example.com/saml/acme/acs"); + const origins = page.getByLabel("Allowed return origins"); + await origins.fill("https://app.example.com"); + await origins.press("Enter"); + await page.getByLabel("Default return URL").fill("https://app.example.com/home"); + + const request = page.waitForRequest((r) => r.method() === "POST" && new URL(r.url()).pathname === "/admins/realms"); + await page.getByRole("button", { name: "Create" }).last().click(); + const body = (await request).postDataJSON() as Realm; + expect(body.auth_params.saml_params).toMatchObject({ + sp_acs_url: "https://auth.example.com/saml/acme/acs", + allowed_return_origins: ["https://app.example.com"], + default_return_url: "https://app.example.com/home", + }); + + const card = page.locator(".ant-card").filter({ hasText: "acme" }); + await expect(card.getByText("SAML", { exact: true })).toBeVisible(); + }); }); diff --git a/admin-ui/tests/unit/components/realms/SamlSettingsSection.test.tsx b/admin-ui/tests/unit/components/realms/SamlSettingsSection.test.tsx new file mode 100644 index 00000000..43f512e6 --- /dev/null +++ b/admin-ui/tests/unit/components/realms/SamlSettingsSection.test.tsx @@ -0,0 +1,156 @@ +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { RealmFormDrawer } from "../../../../src/components/realms/RealmFormDrawer"; +import type { Realm } from "../../../../src/types/api"; +import { IDP_CERT_PEM, idpMetadata } from "../../fixtures/saml"; + +vi.mock("../../../../src/contexts/AuthContext", () => ({ + useAuth: () => ({ + isAuthenticated: true, + username: "admin", + serverUrl: "", + loading: false, + sessionId: null, + exp: null, + login: vi.fn(), + logout: vi.fn(), + }), +})); + +const samlRealm: Realm = { + id: "acme", + auth_params: { + username_password_params: null, + jwt_params: null, + totp_params: null, + saml_params: { + idp_entity_id: "https://idp.example.com/metadata", + idp_sso_url: "https://idp.example.com/sso", + idp_signing_certificates: [IDP_CERT_PEM], + metadata_xml: idpMetadata(), + sp_entity_id: "https://auth.example.com/saml/acme", + sp_acs_url: "https://auth.example.com/saml/acme/acs", + normalize_subject_case: false, + attribute_claim_map: {}, + allowed_return_origins: ["https://app.example.com"], + default_return_url: "https://app.example.com/home", + }, + }, + session_max_age_seconds: 3600, + session_max_stale_age_seconds: 1800, +}; + +async function openDrawer(realm: Realm | null): Promise { + await act(async () => { + render(); + }); +} + +function saveButton(): HTMLElement { + const buttons = screen.getAllByRole("button", { name: /^(Save|Create)$/ }); + return buttons[buttons.length - 1]; +} + +/** Whole-form validation re-parses the metadata on every change, which is slow under jsdom. */ +const SLOW = { timeout: 5000 }; + +/** Make the saved realm dirty, save, and answer with `status` / `body`. */ +async function saveWithResponse(status: number, body: string): Promise { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response(JSON.stringify(body), { status })); + fireEvent.change(screen.getByLabelText("Role attribute"), { target: { value: "groups" } }); + await waitFor(() => expect(saveButton()).not.toBeDisabled(), SLOW); + fireEvent.click(saveButton()); +} + +describe("RealmFormDrawer — SAML", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("shows the saved settings with a preview of the IdP metadata", async () => { + await openDrawer(samlRealm); + + expect(screen.getByRole("checkbox", { name: "SAML 2.0 (single sign-on)" })).toBeChecked(); + const preview = screen.getByLabelText("Parsed IdP metadata"); + expect(within(preview).getByText("https://idp.example.com/metadata")).toBeInTheDocument(); + expect(within(preview).getByText("user2.acme.com")).toBeInTheDocument(); + expect(within(preview).getByText(/Valid until 2027-04-11/)).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /Download SP metadata/ })).toHaveAttribute("href", "/saml/acme/metadata"); + }); + + it("prefills this server's SP URLs when SAML is turned on", async () => { + await openDrawer(null); + fireEvent.change(screen.getByLabelText("Realm ID"), { target: { value: "acme" } }); + fireEvent.click(screen.getByRole("checkbox", { name: "SAML 2.0 (single sign-on)" })); + + const origin = window.location.origin; + await waitFor(() => expect(screen.getByLabelText("Assertion Consumer Service URL")).toHaveValue(`${origin}/saml/acme/acs`), SLOW); + expect(screen.getByLabelText("SP entity ID")).toHaveValue(`${origin}/saml/acme`); + expect(screen.getByText(/Save the realm to download/)).toBeInTheDocument(); + }); + + it("copies the ACS URL", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); + await openDrawer(samlRealm); + + fireEvent.click(screen.getByRole("button", { name: "Copy ACS URL" })); + await waitFor(() => expect(writeText).toHaveBeenCalledWith("https://auth.example.com/saml/acme/acs")); + }); + + it("reports invalid metadata under the field and hides the preview", async () => { + await openDrawer(samlRealm); + fireEvent.change(screen.getByLabelText("IdP metadata XML"), { target: { value: " { + await openDrawer(samlRealm); + fireEvent.click(screen.getByRole("button", { name: /Add claim mapping/ })); + fireEvent.change(screen.getByLabelText("SAML attribute"), { target: { value: "uid" } }); + fireEvent.change(screen.getByLabelText("Claim name"), { target: { value: "sub" } }); + + expect(await screen.findByText("'sub' is a reserved claim name", {}, SLOW)).toBeInTheDocument(); + await waitFor(() => expect(saveButton()).toBeDisabled(), SLOW); + }); + + it("rejects return origins that are not bare https origins", async () => { + await openDrawer(samlRealm); + const origins = screen.getByLabelText("Allowed return origins"); + fireEvent.change(origins, { target: { value: "http://app.example.com/path" } }); + fireEvent.keyDown(origins, { key: "Enter", code: "Enter" }); + + expect(await screen.findByText(/must be an https origin/, {}, SLOW)).toBeInTheDocument(); + }); + + it("shows a server validation error on the field it names", async () => { + await openDrawer(samlRealm); + await saveWithResponse(400, "Bad Request: saml_params.sp_acs_url: must end with /saml/acme/acs"); + + expect(await screen.findByText("must end with /saml/acme/acs", {}, SLOW)).toBeInTheDocument(); + expect(screen.queryByText("The server refused these settings")).not.toBeInTheDocument(); + }); + + it("shows other refusals inline, e.g. a server without SAML", async () => { + await openDrawer(samlRealm); + await saveWithResponse(400, "Bad Request: SAML is not enabled on this server (built without the `saml` feature)"); + + expect(await screen.findByText("The server refused these settings", {}, SLOW)).toBeInTheDocument(); + expect(screen.getByText(/SAML is not enabled on this server/)).toBeInTheDocument(); + }); + + it("sends the SAML settings, and null once SAML is turned off", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify(samlRealm), { status: 200 })); + await openDrawer(samlRealm); + fireEvent.click(screen.getByRole("checkbox", { name: "SAML 2.0 (single sign-on)" })); + await waitFor(() => expect(saveButton()).not.toBeDisabled(), SLOW); + fireEvent.click(saveButton()); + + await waitFor(() => expect(fetchSpy).toHaveBeenCalled(), SLOW); + const body = JSON.parse(String(fetchSpy.mock.calls[0][1]?.body)) as Realm; + expect(body.auth_params.saml_params).toBeNull(); + }); +}); diff --git a/admin-ui/tests/unit/fixtures/saml.ts b/admin-ui/tests/unit/fixtures/saml.ts new file mode 100644 index 00000000..479c259c --- /dev/null +++ b/admin-ui/tests/unit/fixtures/saml.ts @@ -0,0 +1,71 @@ +/** Test IdP signing certificate (server/src/tests/certificates/rsa/auth.user2.cert.pem). */ +export const IDP_CERT_BASE64 = [ + "MIIFXDCCA0SgAwIBAgIUMql22toWMwSAQXMzpi5B+/232E8wDQYJKoZIhvcNAQELBQAwEzERMA8GA1UE", + "AwwIYWNtZS5jb20wHhcN", + "MjYwNDExMTYwNDA2WhcNMjcwNDExMTYwNDA2WjAZMRcwFQYDVQQDDA51c2VyMi5hY21lLmNvbTCCAiIw", + "DQYJKoZIhvcNAQEBBQAD", + "ggIPADCCAgoCggIBAMOB0DaQ1L/SuU9+d+u1jfOmxUmH0pwdhbBJloBtzB6A10/uh+nexkqg56ftKRqq", + "Lt0HKoH0ujNDjCkqzSo1", + "BIcXbxWjiX05jeRvlnDc8SLNzDeaN/g/0XAqyOhSshwwBu38Mvt2Z5gNKOOK0ZkoevFfZLNk4XLHTJTD", + "eDromInoSUfRUuLdkFpZ", + "m9qqCYGdtUtoDa8X+BY8+k8b8e10VGXgIs+J9T/61d8vrAbpdI/4eS48YFuuDS+a9z4ffkNruNHK8Lkd", + "o0wt/v2Ibu1M75kA4gjR", + "Q6XxSa+FFZbWRQ64ZDcDS9ETKH9wU49nImXD17DKeS/M8BrNFIUybdc1HY+s13HIZfwTzdIx8ZWa0KbW", + "vzC93FUyrMBM9ueXKhxx", + "gPh8DInQH4W/q83N+9LAMt+uqY3GahewpcVOvZZGt+MVhr8+n+zZX6GzEIJ1BJ0Eoola9z6GHQC9B3s1", + "b475ZdP/UEgSf2ap1MUN", + "ZN8DzKEPzrrZCWfVRsOnfS1WrxekqeqnJiGEuMjI9xr+IR3tqRZITDP65cAXLdXfJ+djkcle9711t1DL", + "tTQwzjykTT9s2Q+fTsKW", + "UzjUE6QpyZhiQg7QSfA3sHbYoml2c+h1f085MiBIqB2KfwtAlK4dmaRXbu1bgQI12Nwsy7ORqQtm/6vs", + "IXnPbM8r7Kb9q1M1cquP", + "AgMBAAGjgaEwgZ4wCwYDVR0PBAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMCMCkGA1UdHwQiMCAwHqAc", + "oBqGGGh0dHBzOi8vYWNt", + "ZS5jb20vY3JsLnBlbTAPBgNVHREECDAGhwR/AAABMB0GA1UdDgQWBBQ7W4chRF/iv5swh7ra0DhM10s8", + "JzAfBgNVHSMEGDAWgBRc", + "mjxht2F86Fqo+TAA5GkBnubWDTANBgkqhkiG9w0BAQsFAAOCAgEAPFrcguAn6mWOpspVIO/F4JYDr0KK", + "PboCOXlSOgScIpw9p7lL", + "5tnDkf2yQ3D1M/NJVN4qq1hudXrbEzlyBjSz/72E4VZSP6doWxDuYG55DXmRIf9pqXAsrRbGq5AMhYhS", + "uJdYnsdeHgszUUp9BnX7", + "fEJ/rUk+WGe9LlM+SS5BcFcDUVuzAOL6vI78Y1l8O6wCEHOXU7pKXfsQmmDJOsn8hvCkerp6MBt0RxCj", + "WdrEB/80De8aF4vwF75n", + "Lfq9l0kiW8yjaa43oAF9/HDbIHV8BWco7ZTjIiSGC7jjVV5u0zE6mCtNgiDUzzfujAvtb6oA+Daowsyu", + "Bxnhv/IydlZ68GZOGnsB", + "KVPITw+J4dUHpRNtK+OrHtX3YcOcsIbYUXtqbfM7/qeGPI3EVNpcs80zvQFUNYBShicuDdzzA626eh9h", + "0Qq1njj7eW0CQdRgyYsD", + "TQZKQ0CnTEt6kP1z+Hbp+i4BJ+qBtogFUvDYg/7AQwjIEcQuJJyd/rMxufIrYVYzlfBqBdU/5RCjW7H2", + "i5Ui58Gbd3VXefoOqG7v", + "Z/7edXo88jCWkfFroPsqZUlLXUcK+W52CtDKSE24gR4nceSc0PSDk2yrpihwlPhw14X4hOHCIRLctZ+1", + "+n4Twr7xzQmx+j2JScsd", + "SxkiC2b0NAA65Ey79tkvylW73mbVcY19Jt8=", +].join(""); + +export const IDP_CERT_SUBJECT = "user2.acme.com"; +export const IDP_CERT_NOT_AFTER = new Date("2027-04-11T16:04:06Z"); + +export const IDP_CERT_PEM = `-----BEGIN CERTIFICATE-----\n${IDP_CERT_BASE64.replace(/(.{64})/g, "$1\n")}\n-----END CERTIFICATE-----\n`; + +const REDIRECT = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"; +const POST = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"; + +export interface MetadataOptions { + keyUse?: string | null; + ssoBinding?: "redirect" | "post"; + ssoUrl?: string; + extra?: string; + entityId?: string; + validUntil?: string; +} + +/** IdP metadata signing with the test certificate, in the shape IdPs export. */ +export function idpMetadata(options: MetadataOptions = {}): string { + const use = options.keyUse === undefined ? ' use="signing"' : options.keyUse === null ? "" : ` use="${options.keyUse}"`; + const binding = options.ssoBinding === "post" ? POST : REDIRECT; + const validUntil = options.validUntil ? ` validUntil="${options.validUntil}"` : ""; + return ` + + ${IDP_CERT_BASE64} + urn:oasis:names:tc:SAML:2.0:nameid-format:persistent${options.extra ?? ""} + + +`; +} diff --git a/admin-ui/tests/unit/pages/RealmsPage.test.tsx b/admin-ui/tests/unit/pages/RealmsPage.test.tsx index a6eecb3f..38f49465 100644 --- a/admin-ui/tests/unit/pages/RealmsPage.test.tsx +++ b/admin-ui/tests/unit/pages/RealmsPage.test.tsx @@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { MemoryRouter } from "react-router"; import RealmsPage from "../../../src/pages/RealmsPage"; import type { Realm } from "../../../src/types/api"; +import { IDP_CERT_PEM } from "../fixtures/saml"; const mockRealms: Realm[] = [ { @@ -95,6 +96,47 @@ describe("RealmsPage", () => { expect(screen.getByText("TOTP")).toBeInTheDocument(); }); + it("should tag SAML realms and flag an IdP certificate close to expiry", async () => { + const samlRealm: Realm = { + id: "acme", + auth_params: { + username_password_params: null, + jwt_params: null, + totp_params: null, + saml_params: { + idp_entity_id: "https://idp.example.com/metadata", + idp_sso_url: "https://idp.example.com/sso", + idp_signing_certificates: [IDP_CERT_PEM], + sp_entity_id: "https://auth.example.com/saml/acme", + sp_acs_url: "https://auth.example.com/saml/acme/acs", + normalize_subject_case: false, + attribute_claim_map: {}, + allowed_return_origins: ["https://app.example.com"], + default_return_url: "https://app.example.com/home", + }, + }, + session_max_age_seconds: 3600, + session_max_stale_age_seconds: 1800, + }; + vi.useFakeTimers({ toFake: ["Date"], now: new Date("2027-04-01T00:00:00Z") }); + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response(JSON.stringify([samlRealm]), { status: 200 })); + + try { + await act(async () => { + render( + + + , + ); + }); + + expect(screen.getByText("SAML")).toBeInTheDocument(); + expect(screen.getByText(/Expires in 11 days/)).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + it("should show empty state when no realms exist", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response(JSON.stringify([]), { status: 200 })); diff --git a/admin-ui/tests/unit/utils/samlMetadata.test.ts b/admin-ui/tests/unit/utils/samlMetadata.test.ts new file mode 100644 index 00000000..3f036e78 --- /dev/null +++ b/admin-ui/tests/unit/utils/samlMetadata.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { earliestExpiry, readCertificateInfo } from "../../../src/utils/x509"; +import { parseIdpMetadata } from "../../../src/utils/samlMetadata"; +import { IDP_CERT_BASE64, IDP_CERT_NOT_AFTER, IDP_CERT_PEM, IDP_CERT_SUBJECT, idpMetadata } from "../fixtures/saml"; + +describe("readCertificateInfo", () => { + it("reads the subject common name and validity of a DER certificate", () => { + const info = readCertificateInfo(IDP_CERT_BASE64); + expect(info.subject).toBe(IDP_CERT_SUBJECT); + expect(info.notAfter).toEqual(IDP_CERT_NOT_AFTER); + expect(info.notBefore).toEqual(new Date("2026-04-11T16:04:06Z")); + }); + + it("rejects input that is not a certificate", () => { + expect(() => readCertificateInfo("not base64!")).toThrow("not valid base64"); + expect(() => readCertificateInfo(btoa("garbage"))).toThrow(); + expect(() => readCertificateInfo(IDP_CERT_BASE64.slice(0, 200))).toThrow(); + }); + + it("finds the earliest expiry among PEM certificates and skips unreadable ones", () => { + expect(earliestExpiry([IDP_CERT_PEM, "-----BEGIN CERTIFICATE-----\nforged\n-----END CERTIFICATE-----"])).toEqual( + IDP_CERT_NOT_AFTER, + ); + expect(earliestExpiry([])).toBeNull(); + }); +}); + +describe("parseIdpMetadata", () => { + it("extracts what the server will derive", () => { + const summary = parseIdpMetadata(idpMetadata()); + expect(summary.entityId).toBe("https://idp.example.com/metadata"); + expect(summary.ssoUrl).toBe("https://idp.example.com/sso"); + expect(summary.nameIdFormats).toEqual(["urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"]); + expect(summary.certificates).toHaveLength(1); + expect(summary.certificates[0].info?.subject).toBe(IDP_CERT_SUBJECT); + }); + + it("accepts unprefixed metadata and keys without a use", () => { + const xml = idpMetadata({ keyUse: null }) + .replace(/', "federation bundle"], + [idpMetadata({ entityId: "" }), "no entityID"], + [idpMetadata({ validUntil: "2000-01-01T00:00:00Z" }), "expired"], + [idpMetadata({ ssoBinding: "post" }), "HTTP-Redirect"], + [idpMetadata({ ssoUrl: "http://idp.example.com/sso" }), "https"], + [idpMetadata({ keyUse: "encryption" }), "no signing certificate"], + ["x".repeat(256 * 1024 + 1), "256 KiB"], + ])("rejects invalid metadata (%#)", (xml, message) => { + expect(() => parseIdpMetadata(xml)).toThrow(message); + }); +}); diff --git a/admin-ui/tests/unit/utils/samlValidation.test.ts b/admin-ui/tests/unit/utils/samlValidation.test.ts new file mode 100644 index 00000000..771d488d --- /dev/null +++ b/admin-ui/tests/unit/utils/samlValidation.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import { ApiError } from "../../../src/services/api"; +import type { SamlParams } from "../../../src/types/api"; +import { isSamlDirty, toSamlFormValues, toSamlParams } from "../../../src/utils/samlForm"; +import { + acsUrlError, + defaultReturnUrlError, + duplicateMappingError, + expiryLevel, + isBareHttpsOrigin, + reservedClaimError, + samlFieldError, +} from "../../../src/utils/samlValidation"; + +const saved: SamlParams = { + idp_entity_id: "https://idp.example.com/metadata", + idp_sso_url: "https://idp.example.com/sso", + idp_signing_certificates: ["-----BEGIN CERTIFICATE-----"], + metadata_xml: "", + sp_entity_id: "https://auth.example.com/saml/acme", + sp_acs_url: "https://auth.example.com/saml/acme/acs", + subject_attribute: "email", + normalize_subject_case: true, + attribute_claim_map: { department: "dept" }, + allowed_return_origins: ["https://app.example.com"], + default_return_url: "https://app.example.com/home", +}; + +describe("SAML field checks", () => { + it("accepts only bare https origins", () => { + expect(isBareHttpsOrigin("https://app.example.com")).toBe(true); + expect(isBareHttpsOrigin("https://app.example.com:8443/")).toBe(true); + for (const bad of [ + "http://app.example.com", + "https://app.example.com/path", + "https://u@app.example.com", + "https://a.com/?q", + "app.example.com", + ]) { + expect(isBareHttpsOrigin(bad)).toBe(false); + } + }); + + it("requires the ACS URL of this realm over https", () => { + expect(acsUrlError("https://auth.example.com/saml/acme/acs", "acme")).toBeNull(); + expect(acsUrlError("http://auth.example.com/saml/acme/acs", "acme")).toContain("https"); + expect(acsUrlError("https://auth.example.com/saml/other/acs", "acme")).toContain("/saml/acme/acs"); + }); + + it("requires the default return URL under an allowed origin", () => { + expect(defaultReturnUrlError("https://app.example.com/home", ["https://app.example.com"])).toBeNull(); + expect(defaultReturnUrlError("https://evil.example.com/", ["https://app.example.com"])).toContain("allowed return origins"); + expect(defaultReturnUrlError("http://app.example.com/", ["https://app.example.com"])).toContain("https"); + expect(defaultReturnUrlError("nope", [])).toContain("valid URL"); + }); + + it("rejects reserved claim names and duplicate mappings", () => { + expect(reservedClaimError("dept")).toBeNull(); + expect(reservedClaimError("sub")).toContain("reserved"); + expect(reservedClaimError(" roles ")).toContain("reserved"); + expect(duplicateMappingError([{ attribute: "department", claim: "dept" }])).toBeNull(); + expect( + duplicateMappingError([ + { attribute: "a", claim: "x" }, + { attribute: "b", claim: "x" }, + ]), + ).toContain("mapped twice"); + expect( + duplicateMappingError([ + { attribute: "a", claim: "x" }, + { attribute: "a", claim: "y" }, + ]), + ).toContain("mapped twice"); + }); + + it("classifies certificate expiry", () => { + const now = new Date("2027-01-01T00:00:00Z"); + expect(expiryLevel(new Date("2026-12-31T00:00:00Z"), now)).toBe("expired"); + expect(expiryLevel(new Date("2027-01-20T00:00:00Z"), now)).toBe("soon"); + expect(expiryLevel(new Date("2027-06-01T00:00:00Z"), now)).toBe("ok"); + }); +}); + +describe("samlFieldError", () => { + it("reads the field and reason from a server 400", () => { + const error = new ApiError(400, JSON.stringify("Bad Request: saml_params.sp_acs_url: must end with /saml/acme/acs")); + expect(samlFieldError(error)).toEqual({ field: "sp_acs_url", message: "must end with /saml/acme/acs" }); + }); + + it("ignores other errors", () => { + expect(samlFieldError(new ApiError(400, JSON.stringify("Bad Request: SAML is not enabled on this server")))).toBeNull(); + expect(samlFieldError(new ApiError(500, JSON.stringify("saml_params.x: y")))).toBeNull(); + expect(samlFieldError(new Error("network"))).toBeNull(); + }); +}); + +describe("SAML form values", () => { + it("round-trips the settings the admin edits and leaves IdP fields to the server", () => { + const params = toSamlParams(toSamlFormValues(saved)); + expect(params).toEqual({ ...saved, idp_entity_id: "", idp_sso_url: "", idp_signing_certificates: [], role_attribute: undefined }); + }); + + it("detects edits", () => { + const values = toSamlFormValues(saved); + expect(isSamlDirty(values, saved)).toBe(false); + expect(isSamlDirty({ ...values, role_attribute: "groups" }, saved)).toBe(true); + expect(isSamlDirty({ ...values, claim_map: [] }, saved)).toBe(true); + }); +}); diff --git a/admin-ui/vite.config.ts b/admin-ui/vite.config.ts index be83fb3b..74362543 100644 --- a/admin-ui/vite.config.ts +++ b/admin-ui/vite.config.ts @@ -13,6 +13,7 @@ const apiProxy: Record ({ From fd4e79024cfec742a80bde3e631319b9cf271fac Mon Sep 17 00:00:00 2001 From: charming_wicket <235307685+charming-wicket-5502@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:11:25 +0200 Subject: [PATCH 07/19] docs(saml): add the SAML single sign-on guide and document the SAML endpoints and configuration --- CHANGELOG/feat_saml-integration.md | 1 + README.md | 2 +- pkg/auth_verifier.toml | 12 + server/auth_verifier.dev.toml | 8 + server/documentation/docs/SUMMARY.md | 1 + server/documentation/docs/api_reference.md | 72 ++++- .../docs/authentication_flows.md | 41 ++- server/documentation/docs/index.md | 3 +- server/documentation/docs/saml.md | 288 ++++++++++++++++++ .../docs/server_configuration.md | 35 +++ server/documentation/nav.yml | 1 + 11 files changed, 455 insertions(+), 9 deletions(-) create mode 100644 server/documentation/docs/saml.md diff --git a/CHANGELOG/feat_saml-integration.md b/CHANGELOG/feat_saml-integration.md index bbb3cbfd..e19dbbab 100644 --- a/CHANGELOG/feat_saml-integration.md +++ b/CHANGELOG/feat_saml-integration.md @@ -28,5 +28,6 @@ ## Docs +- Add a SAML 2.0 single sign-on guide (`docs/saml.md`: supported scope, build and `[saml_sp_params]` prerequisites, realm settings, IdP onboarding with the SP metadata, the login flow, identity mapping, every check applied to a response, certificate and key rotation, troubleshooting), document SAML as flow 8, the `[saml_sp_params]` section, the `/saml` endpoints and the `sa` auth scheme across the configuration, flows and API references, list SAML as implemented in the overview and README, and add a commented `[saml_sp_params]` block to the packaged and development configurations. - Document the `/saml/{realm_id}/login`, `/acs` and `/metadata` endpoints and a `SAML` tag in `openapi.yaml`, and proxy `/saml` from the admin UI dev and preview servers. - Add ADR-0003 recording why SAML uses `samael` with a statically bundled, OpenSSL-only xmlsec, why the server-side feature is behind an optional `saml` Cargo feature while the API types are not, the v1 protocol scope, the single server-wide RSA SP signing key, and the rejected alternatives and accepted risks (including that xmlsec/libxml2 security updates now ship with our releases). diff --git a/README.md b/README.md index 72614d83..14315421 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ `authentication` is a Rust workspace that provides: -- `auth_verifier`: a multi-realm authentication server supporting username/password, JWT/OIDC, mTLS, and TOTP +- `auth_verifier`: a multi-realm authentication server supporting username/password, JWT/OIDC, mTLS, TOTP and SAML 2.0 single sign-on - `auth_client`: a client library and shared types used to integrate with the server ## Documentation diff --git a/pkg/auth_verifier.toml b/pkg/auth_verifier.toml index 8ce24402..27cdcc5b 100644 --- a/pkg/auth_verifier.toml +++ b/pkg/auth_verifier.toml @@ -109,6 +109,18 @@ server_ca_chain = "/etc/cosmian/ca.pem" # # # Path to the EC public key PEM file used to verify certificates. # cert_ec_public_key = "/etc/cosmian/certify.pub.pem" +# ----------------------------------------------------------------------------- +# SAML 2.0 Service Provider signing key (optional) +# ----------------------------------------------------------------------------- +# Enables SAML single sign-on (/saml/{realm_id}/...) for realms configured with +# SAML settings. One RSA key (>= 2048 bits) signs the AuthnRequests of every +# realm; its certificate is published in the SP metadata. Only accepted by +# servers built with the `saml` feature: others refuse to start with it. + +# [saml_sp_params] +# saml_rsa_private_key = "/etc/cosmian/saml-sp.key.pem" +# saml_certificate = "/etc/cosmian/saml-sp.cert.pem" + # ----------------------------------------------------------------------------- # Primary database (users, realms, credentials, …) # ----------------------------------------------------------------------------- diff --git a/server/auth_verifier.dev.toml b/server/auth_verifier.dev.toml index 7581691e..60f63e1a 100644 --- a/server/auth_verifier.dev.toml +++ b/server/auth_verifier.dev.toml @@ -52,6 +52,14 @@ jwt_ec_public_key = "server/src/tests/certificates/ec/auth.server.cert.pem" cert_ec_private_key = "server/src/tests/certificates/ec/auth.user1.key.pem" cert_ec_public_key = "server/src/tests/certificates/ec/auth.user1.cert.pem" +# --------------------------------------------------------------------------- +# SAML SP signing key — uncomment only when running a build with the `saml` +# feature (nix-shell); other builds refuse to start with this section. +# --------------------------------------------------------------------------- +# [saml_sp_params] +# saml_rsa_private_key = "server/src/tests/certificates/rsa/auth.server.key.pem" +# saml_certificate = "server/src/tests/certificates/rsa/auth.server.cert.pem" + # --------------------------------------------------------------------------- # Database # --------------------------------------------------------------------------- diff --git a/server/documentation/docs/SUMMARY.md b/server/documentation/docs/SUMMARY.md index 1a090dec..56584a3d 100644 --- a/server/documentation/docs/SUMMARY.md +++ b/server/documentation/docs/SUMMARY.md @@ -7,6 +7,7 @@ - [Authentication]() - [Authentication flows](authentication_flows.md) - [Two-factor authentication](two_factor_authentication.md) + - [SAML 2.0 single sign-on](saml.md) - [AppRole, Kubernetes & Tokens](app_auth_api.md) - [Session management](session_management.md) - [Authorization and administration](authorization_and_administration.md) diff --git a/server/documentation/docs/api_reference.md b/server/documentation/docs/api_reference.md index 6864e5d3..0a150724 100644 --- a/server/documentation/docs/api_reference.md +++ b/server/documentation/docs/api_reference.md @@ -11,6 +11,7 @@ All endpoints are served over **HTTPS only**. The base URL is `https://{host}:{p - [Public Endpoints](#public-endpoints) - [Authentication — Login and Session Claims](#authentication--login-and-session-claims) - [`POST /certify`](#post-certifyrealmrealm_id) +- [SAML 2.0 Single Sign-On](#saml-20-single-sign-on) - [Session Management](#session-management) - [Machine Authentication (AppRole / Kubernetes / Token)](#machine-authentication-approle--kubernetes--token) - [Realm Administration](#realm-administration) @@ -198,6 +199,61 @@ server (no `certificate_jwt_params`). --- +## SAML 2.0 Single Sign-On + +Browser endpoints of the SAML Service Provider. They exist only on servers built with the +`saml` feature and configured with `[saml_sp_params]`; otherwise they return `404`. See +[SAML 2.0 single sign-on](saml.md) for setup and the validation rules. + +### `GET /saml/{realm_id}/login` + +Start a login at the realm's IdP. Meant to be opened by the browser, not called by API clients. + +| Query parameter | Required | Description | +|-----------------|----------|-------------| +| `return_to` | No | Where the browser goes after login: an `https` URL under one of the realm's `allowed_return_origins`. Defaults to `default_return_url`. | + +**Response — `302 Found`** — `Location` is the IdP sign-in URL carrying a signed `AuthnRequest` +(HTTP-Redirect binding) whose `RelayState` is the request ID. Also sets +`_ea_saml=; Secure; HttpOnly; SameSite=None; Path=/saml/; Max-Age=600`. + +**Response — `400 Bad Request`** — unknown realm, realm without SAML, or `return_to` not allowed. + +--- + +### `POST /saml/{realm_id}/acs` + +Assertion Consumer Service: the IdP's response arrives here through the browser (HTTP-POST +binding, `application/x-www-form-urlencoded`). + +| Form field | Description | +|------------|-------------| +| `SAMLResponse` | Base64-encoded SAML `` | +| `RelayState` | The request ID from the login redirect; must equal the `_ea_saml` cookie | + +**Response — `200 OK`** — an HTML page sending the browser on to the login's return URL, with +`Set-Cookie: _ea_=; HttpOnly; Secure; SameSite=Strict` (auth scheme `sa`) and +the `_ea_saml` cookie cleared. + +**Response — `400 Bad Request`** — unknown realm or realm without SAML. + +**Response — `401 Unauthorized`** — `"SAML error: "`: the login wasn't started from +this browser, the request is unknown, expired or already used, or the response fails +validation. + +--- + +### `GET /saml/{realm_id}/metadata` + +This server's SAML 2.0 SP metadata for the realm (entity ID, ACS URL, NameID format, signing +certificate, `AuthnRequestsSigned="true"`), to import into the IdP. + +**Response — `200 OK`** — `Content-Type: application/samlmetadata+xml`. + +**Response — `400 Bad Request`** — unknown realm or realm without SAML. + +--- + ## Session Management ### `GET /sessions/{session_id}` @@ -995,6 +1051,9 @@ See [authorization_and_administration.md](authorization_and_administration.md) f } ``` +`auth_params.saml_params` (omitted when unset) enables SAML single sign-on; its fields are +described in [SAML 2.0 single sign-on](saml.md#step-2--configure-the-realm). + ### `Admin` ```json @@ -1047,7 +1106,7 @@ See [authorization_and_administration.md](authorization_and_administration.md) f | Field | Description | | ------------- | ------------------------------------------------------------------------------------------------------- | -| `auth_scheme` | `"up"` username/password · `"jwt"` JWT · `"cc"` client cert · `"f2"` FIDO2 · `"dc"` digital credentials | +| `auth_scheme` | `"up"` username/password · `"jwt"` JWT · `"cc"` client cert · `"f2"` FIDO2 · `"dc"` digital credentials · `"sa"` SAML | ### `AuthenticatedClientScheme` @@ -1055,7 +1114,7 @@ See [authorization_and_administration.md](authorization_and_administration.md) f { "username": "alice", "auth_scheme": "UsernamePassword" } ``` -`auth_scheme` values: `"UsernamePassword"`, `"Jwt"`, `"ClientCertificate"`, `"Fido2"`, `"DigitalCredentials"` +`auth_scheme` values: `"UsernamePassword"`, `"Jwt"`, `"ClientCertificate"`, `"Fido2"`, `"DigitalCredentials"`, `"Saml"` ### `ClientClaims` @@ -1070,12 +1129,13 @@ The JWT payload returned by `GET /whoami`: | `nbf` | `i64` | Not-before (Unix seconds) | | `iat` | `i64` | Issued-at (Unix seconds) | | `jti` | `String` | JWT ID | -| `roles` | `String[]` | RBAC roles (e.g. `["CryptoOfficer"]`), sourced from `UserPass.roles` for username/password sessions. Absent/empty means no roles (fail-closed in OPA). Empty for all other auth schemes. | -| `as_as` | `String` | Auth scheme used (`up`/`jwt`/`cc`/`f2`/`dc`) | +| `roles` | `String[]` | RBAC roles (e.g. `["CryptoOfficer"]`), sourced from `UserPass.roles` for username/password sessions and from the realm's `role_attribute` for SAML sessions. Absent/empty means no roles (fail-closed in OPA). Empty for all other auth schemes. | +| `as_as` | `String` | Auth scheme used (`up`/`jwt`/`cc`/`f2`/`dc`/`sa`) | | `as_rid` | `String` | Realm ID | Any keys from `UserPass.extra_claims` are also present as top-level claims for -username/password sessions. +username/password sessions, and the attributes mapped by the realm's `attribute_claim_map` +for SAML sessions. ### `CertificateClaims` @@ -1088,7 +1148,7 @@ substituted for, a session token. | -------------------- | -------- | ---------------------------------------- | | `realm_id` | `String` | Realm the caller authenticated to | | `sub` | `String` \| absent | Subject (authenticated username). Absent when the request set `exclude_sub: true`. | -| `auth_scheme` | `String` | Auth scheme used to establish the session (`up`/`jwt`/`cc`/`f2`/`dc`) | +| `auth_scheme` | `String` | Auth scheme used to establish the session (`up`/`jwt`/`cc`/`f2`/`dc`/`sa`) | | `verification_key` | `String` | The certified PEM public key | | `iat` | `i64` | Issued-at (Unix seconds) | | `exp` | `i64` | Expiration (Unix seconds) — realm's `certificate_max_age_seconds` | diff --git a/server/documentation/docs/authentication_flows.md b/server/documentation/docs/authentication_flows.md index c8c13498..1d77830c 100644 --- a/server/documentation/docs/authentication_flows.md +++ b/server/documentation/docs/authentication_flows.md @@ -11,8 +11,9 @@ This document describes every authentication flow supported by the Authenticatio | 5 | [AppRole](#flow-5--approle-machine-credentials) | `role_id` + `secret_id` | `X-Vault-Token` app token | | 6 | [Kubernetes service-account](#flow-6--kubernetes-service-account) | Kubernetes SA JWT | `X-Vault-Token` app token | | 7 | [Token self-service](#flow-7--token-self-service) | Existing app token | Metadata / renewed token | +| 8 | [SAML 2.0 single sign-on](#flow-8--saml-20-single-sign-on) | Signed SAML assertion from the realm's IdP | `_ea_` session cookie | -Flows 1–4 produce a **session cookie** (`_ea_`) used by human and administrator clients. Flows 5–6 produce an opaque **app token** (`X-Vault-Token`) for machine-to-machine workloads. Flow 7 is the self-service lifecycle for app tokens produced by flows 5 and 6. +Flows 1–4 and 8 produce a **session cookie** (`_ea_`) used by human and administrator clients. Flows 5–6 produce an opaque **app token** (`X-Vault-Token`) for machine-to-machine workloads. Flow 7 is the self-service lifecycle for app tokens produced by flows 5 and 6. --- @@ -322,6 +323,36 @@ For the full endpoint reference, see [app_auth_api.md](app_auth_api.md#token-sel --- +## Flow 8 — SAML 2.0 Single Sign-On + +Browser users of a SAML realm sign in at the organisation's identity provider. The server is +the SAML Service Provider: it sends a signed `AuthnRequest`, validates the IdP's signed +response and issues the usual `_ea_` session, with `as_as` set to `"sa"`. Requires a server +built with the `saml` feature and a `[saml_sp_params]` signing key. + +### SAML sequence + +```mermaid +sequenceDiagram + autonumber + participant B as Browser + participant EA as Authentication Verifier + participant IdP as Identity Provider + + B->>EA: GET /saml/{realm}/login?return_to=https://app.example.com/… + EA-->>B: 302 to IdP (signed AuthnRequest)
Set-Cookie: _ea_saml= + B->>IdP: sign in + IdP-->>B: form posting the signed SAML response + B->>EA: POST /saml/{realm}/acs (SAMLResponse, RelayState)
Cookie: _ea_saml + note over EA: browser binding, single-use request,
signature, audience, recipient, time, replay + EA-->>B: Set-Cookie: _ea_=…; continue to return_to +``` + +Setup, identity mapping, the full list of checks and troubleshooting are in +[SAML 2.0 single sign-on](saml.md). + +--- + ## Session Lifecycle ```mermaid @@ -373,6 +404,14 @@ DELETE /sessions/realms/{realm_id} | `GET` | `/public/version` | Server version string | No | | `GET` | `/.well-known/jwks.json` | JSON Web Key Set for JWT verification | No | +### SAML endpoints (browser, `saml` feature with `[saml_sp_params]`) + +| Method | Path | Description | Auth required | +| ------ | ------------------------ | ------------------------------------------------------------- | ------------------------------ | +| `GET` | `/saml/{realm}/login` | Start a login: redirect to the realm's IdP | No | +| `POST` | `/saml/{realm}/acs` | Assertion Consumer Service: validate the response, set `_ea_` | `_ea_saml` cookie from `login` | +| `GET` | `/saml/{realm}/metadata` | This server's SP metadata for the realm, for the IdP | No | + ### Machine authentication endpoints These endpoints produce or manage **app tokens** (`X-Vault-Token`). See [app_auth_api.md](app_auth_api.md) for full request/response detail. diff --git a/server/documentation/docs/index.md b/server/documentation/docs/index.md index 001eed0d..c3f1f1f9 100644 --- a/server/documentation/docs/index.md +++ b/server/documentation/docs/index.md @@ -13,13 +13,13 @@ The server manages client authentication in isolated realms. The following authe - OAuth2, OpenID Connect: JWT bearer token (RS256 / ES256, JWKS-based) - Client certificate (mTLS, EC P-256) - Two-factor authentication using TOTP (RFC 6238) +- **SAML 2.0 single sign-on** — browser login at the realm's enterprise identity provider (optional `saml` build feature) - **AppRole** — machine-to-machine credentials; `role_id` + `secret_id` exchange for an opaque app token - **Kubernetes service-account** — Kubernetes SA JWTs for workloads running inside a cluster - **Token self-service** — lookup, renew, and revoke app tokens (all machine auth methods) The following methods are **planned for future implementation**: -- Enterprise SSO using SAML - Authentication using OpenID4 Verifiable Proofs (Decentralized Identity) - Passwordless authentication (WebAuthn, Hardware Tokens) - Two-factor authentication using WebAuthn, Hardware Tokens, or OOB (SMS/Email) @@ -142,5 +142,6 @@ Administrator clients authenticate against the special `_` realm. The API endpoi | [Client Library](client_library.md) | How to use the `auth_client` crate in an API server: session validation, realm management, admin and credential management, TOTP management. | | [Session Management](session_management.md) | Session lifecycle, validation strategies (cookie decryption, session endpoint, direct store query), session actions, stale-session cleanup. | | [Two-Factor Authentication](two_factor_authentication.md) | TOTP implementation: module architecture, data model, enrollment flow (`POST /realms/{realm}/totp/generate` + `POST /realms/{realm}/totp/verify`), login-flow integration (`TotpRequired` step), disable endpoint (`DELETE /realms/{realm}/totp/{username}`), per-realm configuration (algorithm and time step), code path walkthrough, and security considerations. | +| [SAML 2.0 Single Sign-On](saml.md) | Enterprise single sign-on through the realm's SAML identity provider: build and SP signing key prerequisites, realm settings, IdP onboarding (SP metadata), the login flow, identity mapping, the security checks applied to every response, certificate rotation and troubleshooting. | | [Authorization and Administration](authorization_and_administration.md) | The two-tier super admin / realm admin model, the exclusive-ownership rule, endpoint authorization matrix, how to bootstrap the first admin, how to create realm admins, and known caveats. | | [AppRole, Kubernetes & Token Authentication](app_auth_api.md) | AppRole and Kubernetes service-account authentication, token self-service, database schema, and integration with the Cosmian KMS SPIRE crypto engines. | diff --git a/server/documentation/docs/saml.md b/server/documentation/docs/saml.md new file mode 100644 index 00000000..3f898dfb --- /dev/null +++ b/server/documentation/docs/saml.md @@ -0,0 +1,288 @@ +# SAML 2.0 Single Sign-On + +The Authentication Verifier can act as a SAML 2.0 **Service Provider (SP)**: users of a realm +sign in at the organisation's identity provider (IdP) — Microsoft Entra ID, Okta, ADFS, +Keycloak, Shibboleth, … — and come back with the usual `_ea_` session cookie. Nothing changes +for the API servers that validate sessions: a SAML session is a session like any other, with +`as_as` (auth scheme) set to `"sa"`. + +The design and its trade-offs are recorded in +[ADR-0003](adr/2026-09-25-saml-service-provider.md). + +--- + +## Table of Contents + +- [What is supported](#what-is-supported) +- [Prerequisites](#prerequisites) +- [Step 1 — Create the SP signing key](#step-1--create-the-sp-signing-key) +- [Step 2 — Configure the realm](#step-2--configure-the-realm) +- [Step 3 — Register the realm at the IdP](#step-3--register-the-realm-at-the-idp) +- [Step 4 — Sign users in from your application](#step-4--sign-users-in-from-your-application) +- [Login flow](#login-flow) +- [Identity mapping](#identity-mapping) +- [Security checks](#security-checks) +- [Certificate and key rotation](#certificate-and-key-rotation) +- [Troubleshooting](#troubleshooting) + +--- + +## What is supported + +| Supported | Not supported | +|-----------|---------------| +| SP-initiated Web Browser SSO (SAML Profiles §4.1) | IdP-initiated (unsolicited) responses | +| `AuthnRequest` over **HTTP-Redirect**, signed with RSA-SHA256 | HTTP-POST or Artifact binding for requests | +| `Response` over **HTTP-POST** | Artifact binding for responses | +| Signed assertion, signed response, or both | Encrypted assertions | +| RSA or ECDSA signatures with SHA-256/384/512 | SHA-1 signatures or digests | +| IdP metadata pasted by an administrator | Fetching metadata from a URL | +| One IdP per realm | Federations / multi-IdP discovery | +| Local logout (`DELETE /sessions`) | SAML Single Logout | + +SAML is a login method for the users of a realm. It is **never** an administrator credential: +admins keep signing in with the methods of the `_` realm. + +--- + +## Prerequisites + +1. **A server built with the `saml` feature.** SAML statically links the xmlsec and libxml2 + C libraries (see the ADR), so it is off by default. Build it inside the Nix shell: + + ```bash + nix-shell --run "cargo build --release --features auth_verifier/saml" + ``` + + A server built without the feature refuses realms carrying SAML settings (HTTP 400) and + refuses to start if `[saml_sp_params]` is configured. + +2. **HTTPS.** The ACS URL must be `https://`, and the login cookie that protects against login + CSRF is `Secure`. + +3. **An SP signing key** in the server configuration — [Step 1](#step-1--create-the-sp-signing-key). + Without it the `/saml` routes don't exist and realms can't enable SAML. + +--- + +## Step 1 — Create the SP signing key + +One RSA key signs the `AuthnRequest`s of every SAML realm, and its certificate is published in +the SP metadata that IdPs import. It is server configuration: it never goes through the +database or the admin API. + +```bash +openssl req -x509 -newkey rsa:3072 -sha256 -days 1095 -nodes \ + -subj "/CN=auth.example.com SAML SP" \ + -keyout saml-sp.key.pem -out saml-sp.cert.pem +chmod 600 saml-sp.key.pem +``` + +```toml +[saml_sp_params] +saml_rsa_private_key = "/etc/cosmian/saml-sp.key.pem" +saml_certificate = "/etc/cosmian/saml-sp.cert.pem" +``` + +The server checks the pair at startup and stops if a file is unreadable, the key isn't RSA or +is shorter than 2048 bits, or the certificate doesn't match the key. A self-signed certificate +is fine: IdPs pin the certificate from the metadata rather than validating a chain. See +[Server configuration](server_configuration.md#saml-service-provider-key). + +--- + +## Step 2 — Configure the realm + +In the admin UI, open the realm (**Realms → Edit**) and tick **SAML 2.0 (single sign-on)**. +The same settings can be sent through the API as `auth_params.saml_params` on +`POST /admins/realms` or `PUT /admins/realms/{realm_id}`: + +```json +{ + "saml_params": { + "metadata_xml": "…", + "sp_entity_id": "https://auth.example.com/saml/my-service", + "sp_acs_url": "https://auth.example.com/saml/my-service/acs", + "subject_attribute": "email", + "normalize_subject_case": true, + "role_attribute": "groups", + "attribute_claim_map": { "department": "dept" }, + "allowed_return_origins": ["https://app.example.com"], + "default_return_url": "https://app.example.com/" + } +} +``` + +| Field | Required | Description | +|-------|----------|-------------| +| `metadata_xml` | Yes | The IdP's SAML 2.0 metadata (one `EntityDescriptor`, at most 256 KiB). | +| `sp_entity_id` | Yes | This server's entity ID for the realm, given to the IdP. Conventionally `https:///saml/`. | +| `sp_acs_url` | Yes | `https:///saml//acs` — the public URL browsers post the IdP's response to. | +| `subject_attribute` | No | Attribute whose single value becomes the user name. Unset: the NameID is used. Required when the IdP issues transient NameIDs. | +| `normalize_subject_case` | No | Lowercase the user name (default `false`). | +| `role_attribute` | No | Attribute whose values become the session `roles`. | +| `attribute_claim_map` | No | Attribute name → extra-claim name. Only listed attributes reach the session. | +| `allowed_return_origins` | Yes | `https://host[:port]` origins a login may return to. | +| `default_return_url` | Yes | Where a login returns when no `return_to` is given; must be under an allowed origin. | + +The IdP fields (`idp_entity_id`, `idp_sso_url`, `idp_signing_certificates`, +`idp_nameid_format`) are **derived by the server from `metadata_xml`** on every save, and any +values sent for them are ignored. The metadata is refused if it isn't a single SAML 2.0 +`EntityDescriptor`, has expired (`validUntil`), has no HTTPS HTTP-Redirect +`SingleSignOnService`, or has no signing certificate. Every refusal is an HTTP 400 naming the +field (`saml_params.: `); the admin UI shows it next to that field. + +The admin UI previews the parsed metadata — IdP entity ID, sign-in URL, signing certificates +with their expiry — before you save. + +--- + +## Step 3 — Register the realm at the IdP + +The IdP administrator needs this server's SP details. Either give them the SP metadata: + +```text +https://auth.example.com/saml/my-service/metadata +``` + +(also available from the realm drawer's **Download SP metadata** button, or +`AuthClient::get_saml_metadata`), or enter by hand: + +| IdP setting | Value | +|-------------|-------| +| Entity ID / Identifier / Audience | `sp_entity_id` | +| ACS URL / Reply URL (HTTP-POST) | `sp_acs_url` | +| Request signing certificate | The `[saml_sp_params]` certificate | +| Sign | Assertion, response, or both | +| Attributes to release | Those named in `subject_attribute`, `role_attribute` and `attribute_claim_map` | + +Then paste the IdP's metadata into the realm ([Step 2](#step-2--configure-the-realm)). + +--- + +## Step 4 — Sign users in from your application + +Send the browser to the realm's login endpoint, with the page to come back to: + +```text +https://auth.example.com/saml/my-service/login?return_to=https://app.example.com/dashboard +``` + +After signing in at the IdP, the browser returns to `return_to` (or `default_return_url`) +holding the `_ea_` session cookie. `return_to` must be an `https` URL under one of +`allowed_return_origins`; anything else is refused with HTTP 400 rather than silently +replaced, so a misconfigured link is noticed. + +Your API validates the session exactly as for any other method — see +[Session management](session_management.md). + +--- + +## Login flow + +```mermaid +sequenceDiagram + autonumber + participant B as Browser + participant EA as Authentication Verifier + participant S as SAML request store + participant IdP as Identity Provider + + B->>EA: GET /saml/{realm}/login?return_to=… + note over EA: return_to checked against allowed_return_origins + EA->>S: store pending request (ID, realm, return URL; 10 min) + EA-->>B: 302 to IdP SSO URL (signed AuthnRequest, RelayState = request ID)
Set-Cookie: _ea_saml=; Secure; HttpOnly; SameSite=None + B->>IdP: GET SSO URL + IdP-->>B: sign-in page (MFA, …) + B->>IdP: credentials + IdP-->>B: auto-submitting form + B->>EA: POST /saml/{realm}/acs (SAMLResponse, RelayState)
Cookie: _ea_saml= + note over EA: _ea_saml must equal RelayState + EA->>S: take pending request (single use, same realm) + note over EA: verify signature and conditions,
record assertion ID (replay cache),
map identity + EA-->>B: 200 page continuing to the return URL
Set-Cookie: _ea_=…; SameSite=Strict (session) + B->>B: navigate to return URL +``` + +The pending requests and the replay cache live in the session store's database (SQLite, +PostgreSQL, MySQL or Redis) and are purged on the stale-session collector's interval. The +`/saml` routes share the per-IP rate limit of `/login`. + +The ACS answers with a small page instead of a redirect: the `SameSite=Strict` session cookie +may be withheld on a navigation that directly follows the IdP's cross-site POST, and +continuing from a page of this server makes it a same-site navigation. + +SAML sessions don't go through this server's TOTP step: multi-factor authentication is the +IdP's responsibility. + +--- + +## Identity mapping + +| Session field | Source | +|---------------|--------| +| `sub` (user name) | `subject_attribute`'s value — exactly one non-empty value is required — or else the ``. A transient NameID is refused unless `subject_attribute` is set, as it changes on every login. Lowercased when `normalize_subject_case` is set. | +| `roles` | The deduplicated values of `role_attribute`. None when it is unset or not sent, which policies treat as no access. | +| extra claims | For each `attribute_claim_map` entry the IdP sent: a string, or an array for several values. At most 4 KiB in total; claim names this server sets itself (`sub`, `roles`, `as_rid`, …) are refused when saving. | +| `as_as` | `"sa"` | +| `as_rid` | The realm ID | + +Roles come straight from the IdP: this server does not filter them against its role list. + +--- + +## Security checks + +A response is accepted only if **all** of these hold; otherwise the ACS answers +`401 SAML error: ` and logs an `auth.login.failure` event. + +| Check | Rule | +|-------|------| +| Browser binding | The `_ea_saml` cookie set by `/login` equals `RelayState` (prevents login CSRF: a response obtained elsewhere can't be posted into someone else's browser). | +| Solicited | `InResponseTo` names a pending request of this realm, which is consumed (single use, 10-minute lifetime). | +| Signature | The assertion, or the response containing it, is signed by one of the IdP certificates from the metadata. Unsigned content is ignored (XML signature-wrapping defence). | +| Algorithms | RSA or ECDSA with SHA-256/384/512 only, for both signature and digest. | +| Issuer | Equals the IdP entity ID. | +| Audience | An `AudienceRestriction` is present and every one of them names `sp_entity_id`. | +| Recipient / Destination | Equal `sp_acs_url`. | +| Time | `NotBefore` / `NotOnOrAfter` hold with 3 minutes of clock skew, and the assertion was issued at most 5 minutes ago. | +| Authentication | The assertion has an `AuthnStatement` and a bearer `SubjectConfirmation`. | +| Status | The response status is `Success`. | +| Replay | The assertion ID hasn't been used before (remembered for as long as it could be accepted). | + +--- + +## Certificate and key rotation + +**IdP signing certificate.** When the IdP rolls its certificate, paste its new metadata into +the realm. During a planned rollover, metadata listing both certificates keeps logins working +across the switch. The admin UI shows each certificate's expiry in the preview and flags a +realm card when a certificate expires within 30 days. + +**SP signing key.** Replace the files named in `[saml_sp_params]` and restart the server, then +have every IdP import the new metadata. IdPs that verify request signatures reject requests +signed with the new key until they do. + +--- + +## Troubleshooting + +Rejections are logged with the reason: + +```text +WARN event="auth.login.failure" realm=my-service auth_scheme="saml" SAML login rejected: … +``` + +| Symptom | Likely cause | +|---------|--------------| +| `404` on `/saml/…` | The server was built without `saml`, or `[saml_sp_params]` isn't set. | +| `400 realm '…' does not exist or does not use SAML` | Wrong realm in the URL, or SAML isn't enabled for it. | +| `400 return_to is not under an allowed return origin` | Add the origin to `allowed_return_origins`. | +| `401 this sign-in was not started from this browser` | The browser didn't send back the `_ea_saml` cookie: the login was started in another browser or profile, or the server isn't reached over HTTPS. Start again from `/saml/{realm}/login`. | +| `401 unknown or expired sign-in request` | More than 10 minutes at the IdP, the response was already used, or it answers another realm. | +| `401 … must be signed` / signature errors | The IdP signs with a certificate missing from the pasted metadata, or signs neither the assertion nor the response. Refresh the metadata. | +| `401 … AudienceRequirement` / `not addressed to this service provider` | The IdP's entity ID / audience setting differs from `sp_entity_id`. | +| `401 … Recipient` / `destination` | The IdP's ACS / reply URL differs from `sp_acs_url` (watch for `http` vs `https` and trailing slashes). | +| `401 … expired` / `not valid until` | Clock drift above 3 minutes between the IdP and this server — check NTP. | +| `401 the NameID is transient …` | Set `subject_attribute`, or configure a persistent NameID at the IdP. | +| `401 subject attribute '…' is missing` | The IdP doesn't release that attribute to this SP. | diff --git a/server/documentation/docs/server_configuration.md b/server/documentation/docs/server_configuration.md index 2448a632..b2727290 100644 --- a/server/documentation/docs/server_configuration.md +++ b/server/documentation/docs/server_configuration.md @@ -25,6 +25,7 @@ A fully commented sample configuration is provided in `auth_verifier.toml` at th - [TLS Configuration](#tls-configuration) - [Generating certificates](#generating-certificates) - [Session JWT Keys](#session-jwt-keys) + - [SAML Service Provider Key](#saml-service-provider-key) - [Primary Database](#primary-database) - [Redis](#redis) - [Session Store](#session-store) @@ -142,6 +143,39 @@ openssl ec -in jwt.key.pem -pubout -out jwt.pub.pem --- +## SAML Service Provider Key + +Required for [SAML 2.0 single sign-on](saml.md), and only accepted by servers built with the +`saml` feature (a server without it refuses to start when this section is present). One RSA +key signs the `AuthnRequest`s of every SAML realm; its certificate is published in the SP +metadata that IdPs import. When the section is absent, the `/saml` routes don't exist and +realms can't enable SAML. + +```toml +[saml_sp_params] +# Path to the RSA private key PEM file (at least 2048 bits). +saml_rsa_private_key = "/etc/cosmian/saml-sp.key.pem" + +# Path to the X.509 certificate PEM file matching the key; self-signed is fine. +saml_certificate = "/etc/cosmian/saml-sp.cert.pem" +``` + +| Key | Type | Required | Default | +|-----|------|----------|---------| +| `saml_rsa_private_key` | `String` (path) | Yes (if section present) | — | +| `saml_certificate` | `String` (path) | Yes (if section present) | — | + +The server stops at startup if a file is unreadable, the key isn't RSA or is shorter than 2048 +bits, or the certificate doesn't match the key. Generate a pair: + +```bash +openssl req -x509 -newkey rsa:3072 -sha256 -days 1095 -nodes \ + -subj "/CN=auth.example.com SAML SP" \ + -keyout saml-sp.key.pem -out saml-sp.cert.pem +``` + +--- + ## Primary Database The `[database_params]` section configures the store for users, realms, and credentials. @@ -429,3 +463,4 @@ For each realm you can configure: | `auth_params.totp_params` | Object | TOTP algorithm settings for per-realm config | | `auth_params.totp_params.algorithm` | `"SHA1"` \| `"SHA256"` \| `"SHA512"` | HMAC algorithm for TOTP codes | | `auth_params.totp_params.step` | `u64` | Time step in seconds (default: `30`) | +| `auth_params.saml_params` | Object | Enable SAML 2.0 single sign-on; see [SAML 2.0 single sign-on](saml.md#step-2--configure-the-realm) for its fields | diff --git a/server/documentation/nav.yml b/server/documentation/nav.yml index a3a95169..871e3e12 100644 --- a/server/documentation/nav.yml +++ b/server/documentation/nav.yml @@ -6,6 +6,7 @@ nav: - Authentication: - Authentication flows: authentication_flows.md - Two-factor authentication: two_factor_authentication.md + - SAML 2.0 single sign-on: saml.md - AppRole, Kubernetes & Tokens: app_auth_api.md - Session management: session_management.md - Authorization and administration: authorization_and_administration.md From bf98aebff6603f553f4d88737d6475b850060530 Mon Sep 17 00:00:00 2001 From: charming_wicket <235307685+charming-wicket-5502@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:35:52 +0200 Subject: [PATCH 08/19] feat(nix): add a separate SAML build variant next to the default build --- .github/scripts/nix.sh | 61 ++++- .github/scripts/package/package_common.sh | 51 +++- .github/scripts/package/package_dmg.sh | 41 ++- .github/scripts/test/test_docker_image.sh | 60 ++++- .github/workflows/main_base.yml | 4 +- .github/workflows/packaging-docker.yml | 16 +- .github/workflows/packaging-tests.yml | 53 ++-- .github/workflows/packaging.yml | 34 ++- .mise/lib/common.sh | 12 + .mise/tasks/docker/load | 8 +- .mise/tasks/docker/test | 8 +- .mise/tasks/hashes/update | 12 +- .mise/tasks/package | 15 +- AGENTS.md | 22 +- CHANGELOG/feat_saml-integration.md | 11 + .../src/components/realms/RealmFormDrawer.tsx | 4 +- admin-ui/src/types/api.ts | 3 +- .../realms/RealmFormDrawer.test.tsx | 15 ++ default.nix | 22 ++ nix/auth-verifier.nix | 57 +++- nix/docker.nix | 4 +- nix/expected-hashes/README.md | 5 +- server/documentation/docs/saml.md | 10 +- server/saml_testing.md | 255 ++++++++++++++++++ 24 files changed, 675 insertions(+), 108 deletions(-) create mode 100644 server/saml_testing.md diff --git a/.github/scripts/nix.sh b/.github/scripts/nix.sh index 7866b2b6..cdbe46d2 100755 --- a/.github/scripts/nix.sh +++ b/.github/scripts/nix.sh @@ -32,6 +32,10 @@ usage() { -l, --link OpenSSL linkage (default: static) static: statically link OpenSSL (vendored in Rust crate) dynamic: dynamically link system OpenSSL + --variant Build variant (default: default) + default: the standard server + saml: server compiled with the saml feature (static only; + packages get a -saml file suffix, Docker tag a -saml suffix) Examples: $0 test # all tests @@ -41,6 +45,8 @@ usage() { $0 --link static package deb $0 --link dynamic package rpm $0 --link static package dmg # macOS only + $0 --variant saml package deb + $0 --variant saml docker --load --test $0 docker --load $0 docker --load --test $0 update-hashes @@ -111,6 +117,7 @@ ensure_nix_path() { parse_global_options() { LINK="static" + VARIANT="default" COMMAND="" while [ $# -gt 0 ]; do @@ -119,6 +126,10 @@ parse_global_options() { LINK="${2:-}" shift 2 || true ;; + --variant) + VARIANT="${2:-}" + shift 2 || true + ;; docker | test | package | update-hashes) COMMAND="$1" shift @@ -139,6 +150,21 @@ parse_global_options() { [ -z "${COMMAND:-}" ] && usage + case "$VARIANT" in + default) BUILD_TAG="$LINK" ;; + saml) + if [ "$LINK" != "static" ]; then + echo "Error: --variant saml is only available with --link static" >&2 + exit 1 + fi + BUILD_TAG="static-saml" + ;; + *) + echo "Error: --variant must be 'default' or 'saml'" >&2 + exit 1 + ;; + esac + if [ "$COMMAND" = "package" ]; then RELEASE_FLAG="--release" BUILD_PROFILE="release" @@ -147,7 +173,7 @@ parse_global_options() { BUILD_PROFILE="debug" fi - export LINK RELEASE_FLAG BUILD_PROFILE + export LINK VARIANT BUILD_TAG RELEASE_FLAG BUILD_PROFILE REMAINING_ARGS=("$@") } @@ -240,8 +266,13 @@ docker_command() { fi ATTR="docker-image" + IMAGE_TAG_SUFFIX="" + if [ "$VARIANT" = "saml" ]; then + ATTR="docker-image-saml" + IMAGE_TAG_SUFFIX="-saml" + fi VERSION=$(bash "$REPO_ROOT/.github/scripts/release/get_version.sh") - OUT_LINK="$REPO_ROOT/result-docker-static" + OUT_LINK="$REPO_ROOT/result-docker-${LINK}${IMAGE_TAG_SUFFIX}" if [ -n "${FORCE_REBUILD:-}" ]; then DOCKER_FORCE=true @@ -263,8 +294,9 @@ docker_command() { if [ "$DOCKER_TEST" = true ]; then echo "Running Docker image tests..." - DOCKER_IMAGE_NAME="cosmian-auth-verifier:${VERSION}" + DOCKER_IMAGE_NAME="cosmian-auth-verifier:${VERSION}${IMAGE_TAG_SUFFIX}" export DOCKER_IMAGE_NAME + if [ "$VARIANT" = "saml" ]; then export EXPECT_SAML=1; fi bash "$REPO_ROOT/.github/scripts/test/test_docker_image.sh" fi else @@ -329,8 +361,8 @@ package_command() { if [ "$pkg_type" = "dmg" ]; then SCRIPT="$REPO_ROOT/.github/scripts/package/package_dmg.sh" nix-shell -I "nixpkgs=${PIN_URL}" --argstr variant "default" "$REPO_ROOT/shell.nix" \ - --run "bash '$SCRIPT' --link '$LINK'" - OUT_DIR="$REPO_ROOT/result-dmg-$LINK" + --run "bash '$SCRIPT' --link '$LINK' --variant '$VARIANT'" + OUT_DIR="$REPO_ROOT/result-dmg-$BUILD_TAG" dmg_file=$(find "$OUT_DIR" -maxdepth 1 -type f -name '*.dmg' 2>/dev/null | head -n1 || true) if [ -n "${dmg_file:-}" ] && [ -f "$dmg_file" ]; then sum=$(compute_sha256 "$dmg_file") @@ -362,9 +394,9 @@ package_command() { deb) if [ "$(uname)" = "Linux" ]; then SCRIPT_LINUX="$REPO_ROOT/.github/scripts/package/package_deb.sh" - nix-shell -I "nixpkgs=${NIXPKGS_ARG}" -p curl --run "bash '$SCRIPT_LINUX' --link '$LINK'" - REAL_OUT="$REPO_ROOT/result-deb-$LINK" - echo "Built deb ($LINK): $REAL_OUT" + nix-shell -I "nixpkgs=${NIXPKGS_ARG}" -p curl --run "bash '$SCRIPT_LINUX' --link '$LINK' --variant '$VARIANT'" + REAL_OUT="$REPO_ROOT/result-deb-$BUILD_TAG" + echo "Built deb ($BUILD_TAG): $REAL_OUT" # Smoke test SMOKE_TEST="$REPO_ROOT/.github/scripts/package/smoke_test_deb.sh" @@ -383,9 +415,9 @@ package_command() { rpm) if [ "$(uname)" = "Linux" ]; then SCRIPT_LINUX="$REPO_ROOT/.github/scripts/package/package_rpm.sh" - nix-shell -I "nixpkgs=${NIXPKGS_ARG}" -p curl --run "bash '$SCRIPT_LINUX' --link '$LINK'" - REAL_OUT="$REPO_ROOT/result-rpm-$LINK" - echo "Built rpm ($LINK): $REAL_OUT" + nix-shell -I "nixpkgs=${NIXPKGS_ARG}" -p curl --run "bash '$SCRIPT_LINUX' --link '$LINK' --variant '$VARIANT'" + REAL_OUT="$REPO_ROOT/result-rpm-$BUILD_TAG" + echo "Built rpm ($BUILD_TAG): $REAL_OUT" SMOKE_TEST="$REPO_ROOT/.github/scripts/package/smoke_test_rpm.sh" RPM_FILE=$(find "$REAL_OUT" -maxdepth 1 -type f -name '*.rpm' 2>/dev/null | head -n1 || true) @@ -404,8 +436,8 @@ package_command() { if [ "$(uname)" = "Darwin" ]; then SCRIPT_DARWIN="$REPO_ROOT/.github/scripts/package/package_dmg.sh" nix-shell -I "nixpkgs=${NIXPKGS_ARG}" --argstr variant "default" "$REPO_ROOT/shell.nix" \ - --run "bash '$SCRIPT_DARWIN' --link '$LINK'" - echo "Built dmg ($LINK): $REPO_ROOT/result-dmg-$LINK" + --run "bash '$SCRIPT_DARWIN' --link '$LINK' --variant '$VARIANT'" + echo "Built dmg ($BUILD_TAG): $REPO_ROOT/result-dmg-$BUILD_TAG" else echo "DMG packaging is only supported on macOS." >&2 exit 1 @@ -424,7 +456,8 @@ update_hashes_command() { else echo "Building auth-verifier (static) to capture hashes..." ATTR="auth-verifier-static" - OUT_LINK="$REPO_ROOT/result-server-static" + [ "$VARIANT" = "saml" ] && ATTR="auth-verifier-static-saml" + OUT_LINK="$REPO_ROOT/result-server-${BUILD_TAG}" nix-build -I "nixpkgs=${PIN_URL}" "$REPO_ROOT/default.nix" -A "$ATTR" -o "$OUT_LINK" REAL_OUT=$(readlink -f "$OUT_LINK") diff --git a/.github/scripts/package/package_common.sh b/.github/scripts/package/package_common.sh index 130be92f..87efca6c 100755 --- a/.github/scripts/package/package_common.sh +++ b/.github/scripts/package/package_common.sh @@ -10,9 +10,10 @@ cd "$REPO_ROOT" FORMAT="" LINK="static" +VARIANT="default" usage() { - echo "Usage: $0 --format deb|rpm [--link static|dynamic]" >&2 + echo "Usage: $0 --format deb|rpm [--link static|dynamic] [--variant default|saml]" >&2 exit 2 } @@ -26,6 +27,10 @@ while [ $# -gt 0 ]; do LINK="${2:-}" shift 2 || true ;; + --variant) + VARIANT="${2:-}" + shift 2 || true + ;; -h | --help) usage ;; *) shift ;; esac @@ -45,6 +50,34 @@ static | dynamic) : ;; exit 1 ;; esac +case "$VARIANT" in +default) BUILD_TAG="$LINK" ;; +saml) + if [ "$LINK" != "static" ]; then + echo "Error: --variant saml is only available with --link static" >&2 + exit 1 + fi + BUILD_TAG="static-saml" + ;; +*) + echo "Error: --variant must be 'default' or 'saml'" >&2 + exit 1 + ;; +esac + +# The SAML package keeps the same package name; only the file name changes. +# Renames before the checksum and signature are written so they name the final file. +apply_variant_suffix() { + local file="$1" ext renamed + if [ "$VARIANT" != "saml" ]; then + echo "$file" + return 0 + fi + ext="${file##*.}" + renamed="${file%."$ext"}-saml.$ext" + mv -f "$file" "$renamed" + echo "$renamed" +} # Persistent Cargo cache for offline runs OFFLINE_CARGO_HOME="$REPO_ROOT/target/cargo-offline-home" @@ -64,13 +97,15 @@ prewarm_cargo_registry() { build_or_reuse_server() { local attr - if [ "$LINK" = "dynamic" ]; then + if [ "$VARIANT" = "saml" ]; then + attr="auth-verifier-static-saml" + elif [ "$LINK" = "dynamic" ]; then attr="auth-verifier-dynamic-openssl" else attr="auth-verifier-static-openssl" fi - OUT_LINK="$REPO_ROOT/result-server-${LINK}" + OUT_LINK="$REPO_ROOT/result-server-${BUILD_TAG}" nix-build -I "nixpkgs=${PIN_URL}" "$REPO_ROOT/default.nix" -A "$attr" -o "$OUT_LINK" REAL_SERVER=$(readlink -f "$OUT_LINK" || echo "$OUT_LINK") @@ -168,10 +203,10 @@ build_deb() { fi VERSION_STR=$(bash "$REPO_ROOT/.github/scripts/release/get_version.sh") - OUT_DIR="$REPO_ROOT/result-deb-${LINK}" + OUT_DIR="$REPO_ROOT/result-deb-${BUILD_TAG}" mkdir -p "$OUT_DIR" - echo "Building DEB for auth_verifier v${VERSION_STR} (link=$LINK)…" + echo "Building DEB for auth_verifier v${VERSION_STR} (${BUILD_TAG})…" pushd "$REPO_ROOT/server" >/dev/null # shellcheck disable=SC2086 @@ -188,6 +223,7 @@ build_deb() { echo "ERROR: DEB file not found in $OUT_DIR" >&2 exit 1 fi + DEB_FILE=$(apply_variant_suffix "$DEB_FILE") # Compute SHA256 sum=$(sha256sum "$DEB_FILE" | awk '{print $1}') @@ -205,10 +241,10 @@ build_rpm() { mkdir -p "$CARGO_HOME" VERSION_STR=$(bash "$REPO_ROOT/.github/scripts/release/get_version.sh") - OUT_DIR="$REPO_ROOT/result-rpm-${LINK}" + OUT_DIR="$REPO_ROOT/result-rpm-${BUILD_TAG}" mkdir -p "$OUT_DIR" - echo "Building RPM for auth_verifier v${VERSION_STR} (link=$LINK)…" + echo "Building RPM for auth_verifier v${VERSION_STR} (${BUILD_TAG})…" pushd "$REPO_ROOT" >/dev/null # Use cargo-generate-rpm from Nix derivation @@ -237,6 +273,7 @@ build_rpm() { echo "ERROR: RPM file not found in $OUT_DIR" >&2 exit 1 fi + RPM_FILE=$(apply_variant_suffix "$RPM_FILE") sum=$(sha256sum "$RPM_FILE" | awk '{print $1}') echo "$sum $(basename "$RPM_FILE")" >"$RPM_FILE.sha256" diff --git a/.github/scripts/package/package_dmg.sh b/.github/scripts/package/package_dmg.sh index dcf05e93..f09e4719 100755 --- a/.github/scripts/package/package_dmg.sh +++ b/.github/scripts/package/package_dmg.sh @@ -8,16 +8,36 @@ cd "$REPO_ROOT" source "$REPO_ROOT/.github/scripts/common.sh" LINK="static" +VARIANT="default" while [ $# -gt 0 ]; do case "$1" in -l | --link) LINK="${2:-}" shift 2 || true ;; + --variant) + VARIANT="${2:-}" + shift 2 || true + ;; *) shift ;; esac done +case "$VARIANT" in +default) BUILD_TAG="$LINK" ;; +saml) + if [ "$LINK" != "static" ]; then + echo "Error: --variant saml is only available with --link static" >&2 + exit 1 + fi + BUILD_TAG="static-saml" + ;; +*) + echo "Error: --variant must be 'default' or 'saml'" >&2 + exit 1 + ;; +esac + # Only supported on macOS if [ "$(uname)" != "Darwin" ]; then echo "Error: DMG packaging is only supported on macOS." >&2 @@ -30,12 +50,14 @@ ensure_macos_frameworks_ldflags VERSION_STR=$(bash "$REPO_ROOT/.github/scripts/release/get_version.sh") # Build or reuse server binary via Nix -if [ "$LINK" = "dynamic" ]; then +if [ "$VARIANT" = "saml" ]; then + ATTR="auth-verifier-static-saml" +elif [ "$LINK" = "dynamic" ]; then ATTR="auth-verifier-dynamic-openssl" else ATTR="auth-verifier-static-openssl" fi -OUT_LINK="$REPO_ROOT/result-server-${LINK}" +OUT_LINK="$REPO_ROOT/result-server-${BUILD_TAG}" nix-build -I "nixpkgs=${PIN_URL}" -A "$ATTR" -o "$OUT_LINK" REAL_OUT=$(readlink -f "$OUT_LINK" || echo "$OUT_LINK") BIN_OUT="$REAL_OUT/bin/auth_verifier" @@ -52,7 +74,7 @@ export CARGO_HOME="$HOME/cargo-home" mkdir -p "$CARGO_HOME" export PATH="/usr/bin:/bin:/usr/sbin:/sbin:$PATH" -echo "Building DMG for auth_verifier v${VERSION_STR} (link=${LINK})…" +echo "Building DMG for auth_verifier v${VERSION_STR} (${BUILD_TAG})…" # Use cargo-packager if available if command -v cargo-packager >/dev/null 2>&1; then @@ -67,13 +89,16 @@ $PACKAGER \ --release \ --formats dmg -OUT_DIR="$REPO_ROOT/result-dmg-${LINK}" +OUT_DIR="$REPO_ROOT/result-dmg-${BUILD_TAG}" mkdir -p "$OUT_DIR" -find "$REPO_ROOT" -maxdepth 4 -name '*.dmg' -newer "$REPO_ROOT/Cargo.toml" 2>/dev/null | while IFS= read -r dmg; do - cp -f "$dmg" "$OUT_DIR/" +# Skip result-* dirs so a DMG collected by another build tag is not picked up again. +find "$REPO_ROOT" -maxdepth 4 -name '*.dmg' -not -path "$REPO_ROOT/result-*" -newer "$REPO_ROOT/Cargo.toml" 2>/dev/null | while IFS= read -r dmg; do + name=$(basename "$dmg") + if [ "$VARIANT" = "saml" ]; then name="${name%.dmg}-saml.dmg"; fi + cp -f "$dmg" "$OUT_DIR/$name" sum=$(shasum -a 256 "$dmg" | awk '{print $1}') - echo "$sum $(basename "$dmg")" >"$OUT_DIR/$(basename "$dmg").sha256" - echo "Built DMG: $dmg (sha256: $sum)" + echo "$sum $name" >"$OUT_DIR/$name.sha256" + echo "Built DMG: $OUT_DIR/$name (sha256: $sum)" done # GPG sign diff --git a/.github/scripts/test/test_docker_image.sh b/.github/scripts/test/test_docker_image.sh index 6df5bf52..07626e0a 100755 --- a/.github/scripts/test/test_docker_image.sh +++ b/.github/scripts/test/test_docker_image.sh @@ -184,7 +184,65 @@ HTTP_CODE=$(ca_curl -o /dev/null -w '%{http_code}' \ "$BASE_URL/whoami?realm=_") assert_eq "HTTP 401" "401" "$HTTP_CODE" -# ── Summary ──────────────────────────────────────────────────────────────── +# ── SAML build check (EXPECT_SAML=1) ─────────────────────────────────────── +# A server built without the `saml` feature refuses to start when [saml_sp_params] +# is configured, so a successful start proves the feature is compiled in. + +if [ "${EXPECT_SAML:-}" = "1" ]; then + echo "" + echo "── SAML build ────────────────────────────────────────────────────────" + SAML_PORT="${AUTH_SERVER_SAML_PORT:-8081}" + SAML_DIR=$(mktemp -d) + # The container runs as uid 1000 and must be able to read the (throw-away) keys. + chmod 755 "$SAML_DIR" + openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:prime256v1 -out "$SAML_DIR/tls.key.pem" + openssl req -new -x509 -key "$SAML_DIR/tls.key.pem" -out "$SAML_DIR/tls.cert.pem" -days 1 \ + -subj "/CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" + openssl req -x509 -newkey rsa:3072 -sha256 -days 1 -nodes -subj "/CN=saml-sp-test" \ + -keyout "$SAML_DIR/saml-sp.key.pem" -out "$SAML_DIR/saml-sp.cert.pem" 2>/dev/null + cat >"$SAML_DIR/auth_verifier.toml" <<'TOML' +host_name = "0.0.0.0" +host_port = 8080 +roles = ["Admin", "User"] + +[tls_params] +server_private_key = "/conf/tls.key.pem" +server_certificate = "/conf/tls.cert.pem" +server_ca_chain = "/conf/tls.cert.pem" + +[database_params] +backend = "sqlite" +connection_url = "sqlite::memory:" + +[saml_sp_params] +saml_rsa_private_key = "/conf/saml-sp.key.pem" +saml_certificate = "/conf/saml-sp.cert.pem" +TOML + chmod 644 "$SAML_DIR"/* + + SAML_CID=$(docker run -d -p "${SAML_PORT}:8080" -v "$SAML_DIR:/conf:ro" \ + -e AUTH_SERVER_CONF=/conf/auth_verifier.toml "$IMAGE_NAME" 2>/dev/null) + trap 'echo "Stopping containers…"; docker rm -f "$CID" "$SAML_CID" >/dev/null 2>&1 || true; rm -rf "$SAML_DIR"' EXIT + + echo "Test: server starts with [saml_sp_params] (saml feature compiled in)" + SAML_UP=false + for _ in $(seq 1 30); do + if ca_curl -o /dev/null "https://127.0.0.1:${SAML_PORT}/public/version" 2>/dev/null; then + SAML_UP=true + break + fi + docker inspect -f '{{.State.Running}}' "$SAML_CID" 2>/dev/null | grep -q true || break + sleep 1 + done + if [ "$SAML_UP" = true ]; then + echo " PASS server started with [saml_sp_params]" + PASS=$((PASS + 1)) + else + echo " FAIL server did not start with [saml_sp_params]" + docker logs "$SAML_CID" 2>&1 || true + FAIL=$((FAIL + 1)) + fi +fi echo "" echo "==========================================" diff --git a/.github/workflows/main_base.yml b/.github/workflows/main_base.yml index 91a52c65..4698ece7 100644 --- a/.github/workflows/main_base.yml +++ b/.github/workflows/main_base.yml @@ -94,8 +94,8 @@ jobs: strategy: fail-fast: false matrix: - # Bundled xmlsec (nix/xmlsec-static.nix) is only verified on Linux x86_64 so far. - runner: [ubuntu-24.04] + # Bundled xmlsec (nix/xmlsec-static.nix) is built per platform, matching the packaging matrix. + runner: [ubuntu-24.04, ubuntu-24.04-arm, macos-15] steps: - name: Nix installation diff --git a/.github/workflows/packaging-docker.yml b/.github/workflows/packaging-docker.yml index d32aef1b..760637d4 100644 --- a/.github/workflows/packaging-docker.yml +++ b/.github/workflows/packaging-docker.yml @@ -18,7 +18,7 @@ on: jobs: nix-docker-image: - name: docker-${{ matrix.runner }} + name: docker-${{ matrix.runner }}-${{ matrix.variant }} runs-on: ${{ matrix.runner }} permissions: contents: read @@ -28,6 +28,7 @@ jobs: fail-fast: false matrix: runner: [ubuntu-24.04, ubuntu-24.04-arm] + variant: [default, saml] steps: - name: Derive architecture from runner @@ -90,6 +91,8 @@ jobs: uses: docker/metadata-action@v6 with: images: ghcr.io/cosmian/auth-verifier + flavor: | + suffix=${{ matrix.variant == 'saml' && '-saml' || '' }} tags: | type=ref,event=branch type=ref,event=pr @@ -109,7 +112,7 @@ jobs: - name: Build and load Docker image id: build run: | - mise run docker:load + mise run docker:load -- --variant ${{ matrix.variant }} env: NIX_CURL_FLAGS: --user-agent "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36" @@ -165,6 +168,7 @@ jobs: - name: Test Docker image env: DOCKER_IMAGE_NAME: ${{ steps.pick_test_tag.outputs.test_tag }} + EXPECT_SAML: ${{ matrix.variant == 'saml' && '1' || '' }} run: | mise run test:docker-image @@ -179,9 +183,13 @@ jobs: arch: ${{ steps.arch.outputs.arch }} nix-docker-manifest: - name: docker-manifest + name: docker-manifest-${{ matrix.variant }} needs: nix-docker-image runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + variant: [default, saml] permissions: contents: read packages: write @@ -208,6 +216,8 @@ jobs: uses: docker/metadata-action@v6 with: images: ${{ env.REGISTRY_IMAGE }} + flavor: | + suffix=${{ matrix.variant == 'saml' && '-saml' || '' }} tags: | type=ref,event=branch type=ref,event=pr diff --git a/.github/workflows/packaging-tests.yml b/.github/workflows/packaging-tests.yml index 82f35207..50150fde 100644 --- a/.github/workflows/packaging-tests.yml +++ b/.github/workflows/packaging-tests.yml @@ -18,7 +18,7 @@ on: jobs: packages-test: - name: In Docker ${{ matrix.container }}-${{ matrix.link }}${{ matrix.runner == 'ubuntu-24.04-arm' && '-ARM' || '-AMD64' }} + name: In Docker ${{ matrix.container }}-${{ matrix.build-tag }}${{ matrix.runner == 'ubuntu-24.04-arm' && '-ARM' || '-AMD64' }} runs-on: ${{ matrix.runner }} container: ${{ matrix.container }} strategy: @@ -35,14 +35,14 @@ jobs: # Rocky Linux releases - rockylinux/rockylinux:10 - rockylinux/rockylinux:9 - link: [static, dynamic] + build-tag: [static, dynamic, static-saml] runner: [ubuntu-24.04, ubuntu-24.04-arm] steps: - name: Download package artifacts uses: actions/download-artifact@v8 with: - name: ${{ matrix.link }}_${{ matrix.runner }}-release + name: ${{ matrix.build-tag }}_${{ matrix.runner }}-release - name: List downloaded artifacts run: find . @@ -52,19 +52,19 @@ jobs: run: | set -ex if [[ "${{ matrix.container }}" == rockylinux* ]]; then - for pkg in ./result-rpm-${{ matrix.link }}/*.rpm; do + for pkg in ./result-rpm-${{ matrix.build-tag }}/*.rpm; do rpm -qpl "$pkg" done - rpm -i ./result-rpm-${{ matrix.link }}/*.rpm --nodeps + rpm -i ./result-rpm-${{ matrix.build-tag }}/*.rpm --nodeps else - for pkg in ./result-deb-${{ matrix.link }}/*.deb; do + for pkg in ./result-deb-${{ matrix.build-tag }}/*.deb; do dpkg --contents "$pkg" done - if [ "${{ matrix.link }}" = "dynamic" ]; then - dpkg -i ./result-deb-${{ matrix.link }}/*.deb || true + if [ "${{ matrix.build-tag }}" = "dynamic" ]; then + dpkg -i ./result-deb-${{ matrix.build-tag }}/*.deb || true apt-get update -qq && apt-get install -f -y else - dpkg -i ./result-deb-${{ matrix.link }}/*.deb + dpkg -i ./result-deb-${{ matrix.build-tag }}/*.deb fi fi @@ -181,6 +181,16 @@ jobs: backend = "sqlite" connection_url = "sqlite::memory:" TOML + # A build without the saml feature refuses to start when [saml_sp_params] is set. + if [ "${{ matrix.build-tag }}" = "static-saml" ]; then + openssl req -x509 -newkey rsa:3072 -sha256 -days 1 -nodes -subj "/CN=saml-sp-test" \ + -keyout "$WORK/saml-sp.key.pem" -out "$WORK/saml-sp.cert.pem" + cat >> "$CONFIG" </dev/null | head -1) @@ -209,12 +219,12 @@ jobs: case "$HTTP" in 2*) echo "All UI checks PASSED";; *) echo "ERROR: /public/version returned $HTTP"; exit 1;; esac systemd-packages-test: - name: Use GH runners ${{ matrix.link }}${{ matrix.runner == 'ubuntu-24.04-arm' && '-ARM' || '-AMD64' }} + name: Use GH runners ${{ matrix.build-tag }}${{ matrix.runner == 'ubuntu-24.04-arm' && '-ARM' || '-AMD64' }} runs-on: ${{ matrix.runner }} strategy: fail-fast: false matrix: - link: [static, dynamic] + build-tag: [static, dynamic, static-saml] runner: [ubuntu-24.04, ubuntu-24.04-arm] steps: @@ -230,7 +240,7 @@ jobs: - name: Download package artifacts uses: actions/download-artifact@v8 with: - name: ${{ matrix.link }}_${{ matrix.runner }}-release + name: ${{ matrix.build-tag }}_${{ matrix.runner }}-release - name: List downloaded artifacts run: find . @@ -240,15 +250,15 @@ jobs: run: | set -ex # DEB-based (Ubuntu/Debian); systemd tests run on Ubuntu GH runners only - for pkg in ./result-deb-${{ matrix.link }}/*.deb; do + for pkg in ./result-deb-${{ matrix.build-tag }}/*.deb; do dpkg --contents "$pkg" done - if [ "${{ matrix.link }}" = "dynamic" ]; then + if [ "${{ matrix.build-tag }}" = "dynamic" ]; then # Dynamic builds depend on system libs; fix with apt after attempt - sudo dpkg -i ./result-deb-${{ matrix.link }}/*.deb || true + sudo dpkg -i ./result-deb-${{ matrix.build-tag }}/*.deb || true sudo apt-get update -qq && sudo apt-get install -f -y else - sudo dpkg -i ./result-deb-${{ matrix.link }}/*.deb + sudo dpkg -i ./result-deb-${{ matrix.build-tag }}/*.deb fi - name: Pre-check server binary @@ -302,6 +312,17 @@ jobs: backend = "sqlite" connection_url = "sqlite::memory:" TOML + # A build without the saml feature refuses to start when [saml_sp_params] is set. + if [ "${{ matrix.build-tag }}" = "static-saml" ]; then + sudo openssl req -x509 -newkey rsa:3072 -sha256 -days 1 -nodes -subj "/CN=saml-sp-test" \ + -keyout /etc/cosmian/saml-sp.key.pem -out /etc/cosmian/saml-sp.cert.pem + sudo tee -a /etc/cosmian/auth_verifier.toml > /dev/null <<'TOML' + [saml_sp_params] + saml_rsa_private_key = "/etc/cosmian/saml-sp.key.pem" + saml_certificate = "/etc/cosmian/saml-sp.cert.pem" + TOML + sudo chmod 644 /etc/cosmian/saml-sp.key.pem /etc/cosmian/saml-sp.cert.pem + fi sudo chown root:root /etc/cosmian/auth_verifier.toml /etc/cosmian/dev.key.pem /etc/cosmian/dev.cert.pem /etc/cosmian/dev.ca.pem sudo chmod 440 /etc/cosmian/auth_verifier.toml sudo chmod 644 /etc/cosmian/dev.key.pem /etc/cosmian/dev.cert.pem /etc/cosmian/dev.ca.pem diff --git a/.github/workflows/packaging.yml b/.github/workflows/packaging.yml index 47cd6508..1fc676e3 100644 --- a/.github/workflows/packaging.yml +++ b/.github/workflows/packaging.yml @@ -31,13 +31,23 @@ jobs: || github.event_name == 'workflow_dispatch' packages: - name: ${{ matrix.runner }}-${{ matrix.link }} + name: ${{ matrix.runner }}-${{ matrix.build-tag }} runs-on: ${{ matrix.runner }} strategy: fail-fast: false matrix: - link: [static, dynamic] + build-tag: [static, dynamic, static-saml] runner: [ubuntu-24.04, ubuntu-24.04-arm, macos-15] + include: + - build-tag: static + link: static + variant: default + - build-tag: dynamic + link: dynamic + variant: default + - build-tag: static-saml + link: static + variant: saml steps: - name: Nix installation @@ -86,7 +96,7 @@ jobs: - name: Package with GPG signature run: | - mise run package -- --link ${{ matrix.link }} + mise run package -- --link ${{ matrix.link }} --variant ${{ matrix.variant }} env: GPG_SIGNING_KEY: ${{ secrets.GPG_SIGNING_KEY }} GPG_SIGNING_KEY_PASSPHRASE: ${{ secrets.GPG_SIGNING_KEY_PASSPHRASE }} @@ -95,8 +105,8 @@ jobs: - name: Upload package uses: actions/upload-artifact@v4 with: - name: ${{ matrix.link }}_${{ matrix.runner }}-release - path: result-*-${{ matrix.link }}/* + name: ${{ matrix.build-tag }}_${{ matrix.runner }}-release + path: result-*-${{ matrix.build-tag }}/* retention-days: 1 if-no-files-found: error @@ -106,7 +116,7 @@ jobs: || startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' - name: Publish ${{ matrix.runner }}-${{ matrix.link }} + name: Publish ${{ matrix.runner }}-${{ matrix.build-tag }} needs: packages concurrency: group: publish-release @@ -121,7 +131,7 @@ jobs: fail-fast: false max-parallel: 1 matrix: - link: [static, dynamic] + build-tag: [static, dynamic, static-saml] runner: [ubuntu-24.04, ubuntu-24.04-arm, macos-15] package-type: [deb, rpm, dmg] cpu-arch: [amd64, arm64] @@ -148,7 +158,7 @@ jobs: - name: Download artifacts uses: actions/download-artifact@v8 with: - name: ${{ matrix.link }}_${{ matrix.runner }}-release + name: ${{ matrix.build-tag }}_${{ matrix.runner }}-release - name: Display structure run: find . -type f | head -40 @@ -163,7 +173,7 @@ jobs: BRANCH="last_build/${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" fi DESTINATION_DIR="/mnt/package/authentication/${BRANCH}" - RESULT_DIR="result-${{ matrix.package-type }}-${{ matrix.link }}" + RESULT_DIR="result-${{ matrix.package-type }}-${{ matrix.build-tag }}" # cargo-packager outputs aarch64 for Apple Silicon; # rpm uses x86_64/aarch64; deb uses the matrix cpu-arch @@ -180,7 +190,7 @@ jobs: fi PKG_EXT=".${{ matrix.package-type }}" - remote_dir="${DESTINATION_DIR}/${{ matrix.package-type }}/${{ matrix.cpu-arch }}/${{ matrix.link }}" + remote_dir="${DESTINATION_DIR}/${{ matrix.package-type }}/${{ matrix.cpu-arch }}/${{ matrix.build-tag }}" ssh -o 'StrictHostKeyChecking no' -i /root/.ssh/id_rsa \ cosmian@package.cosmian.com mkdir -p "$remote_dir" scp -o 'StrictHostKeyChecking no' -i /root/.ssh/id_rsa \ @@ -188,11 +198,11 @@ jobs: cosmian@package.cosmian.com:"${remote_dir}/" - name: Publish GitHub Release assets - if: startsWith(github.ref, 'refs/tags/') && matrix.link == 'static' + if: startsWith(github.ref, 'refs/tags/') && matrix.build-tag != 'dynamic' uses: softprops/action-gh-release@v3 with: files: | - result-${{ matrix.package-type }}-${{ matrix.link }}/* + result-${{ matrix.package-type }}-${{ matrix.build-tag }}/* cargo-publish: uses: ./.github/workflows/cargo-publish.yml diff --git a/.mise/lib/common.sh b/.mise/lib/common.sh index 4e80f3b6..a90db3d5 100644 --- a/.mise/lib/common.sh +++ b/.mise/lib/common.sh @@ -12,3 +12,15 @@ NC='\033[0m' print_status() { echo -e "${GREEN}[mise]${NC} $*"; } print_error() { echo -e "${RED}[mise][error]${NC} $*" >&2; } + +# Split leading --link/--variant pairs of "$@" into NIX_GLOBAL_ARGS (they must precede the +# nix.sh command word); the remaining arguments go to NIX_REST_ARGS. +split_nix_global_args() { + NIX_GLOBAL_ARGS=() + while [ "${1:-}" = "--link" ] || [ "${1:-}" = "-l" ] || [ "${1:-}" = "--variant" ]; do + NIX_GLOBAL_ARGS+=("$1" "${2:?missing value for $1}") + shift 2 + done + # shellcheck disable=SC2034 # read by the sourcing task + NIX_REST_ARGS=("$@") +} diff --git a/.mise/tasks/docker/load b/.mise/tasks/docker/load index ec8d9f71..0bdbcc29 100755 --- a/.mise/tasks/docker/load +++ b/.mise/tasks/docker/load @@ -1,7 +1,9 @@ #!/usr/bin/env bash -#MISE description="Build and load the Docker image into the local Docker daemon (delegates to .github/scripts/nix.sh docker --load)" +#MISE description="Build and load the Docker image into the local Docker daemon. Usage: mise run docker:load -- [--variant default|saml]" set -euo pipefail source "${MISE_CONFIG_ROOT}/.mise/lib/common.sh" -print_status "Building and loading Docker image" -exec bash "${MISE_CONFIG_ROOT}/.github/scripts/nix.sh" docker --load "$@" +split_nix_global_args "$@" + +print_status "Building and loading Docker image (${NIX_GLOBAL_ARGS[*]:-defaults})" +exec bash "${MISE_CONFIG_ROOT}/.github/scripts/nix.sh" ${NIX_GLOBAL_ARGS[@]+"${NIX_GLOBAL_ARGS[@]}"} docker --load ${NIX_REST_ARGS[@]+"${NIX_REST_ARGS[@]}"} diff --git a/.mise/tasks/docker/test b/.mise/tasks/docker/test index 7f5642f8..c6c911cc 100755 --- a/.mise/tasks/docker/test +++ b/.mise/tasks/docker/test @@ -1,7 +1,9 @@ #!/usr/bin/env bash -#MISE description="Build, load, and smoke-test the Docker image (delegates to .github/scripts/nix.sh docker --load --test)" +#MISE description="Build, load, and smoke-test the Docker image. Usage: mise run docker:test -- [--variant default|saml]" set -euo pipefail source "${MISE_CONFIG_ROOT}/.mise/lib/common.sh" -print_status "Building, loading, and testing Docker image" -exec bash "${MISE_CONFIG_ROOT}/.github/scripts/nix.sh" docker --load --test "$@" +split_nix_global_args "$@" + +print_status "Building, loading, and testing Docker image (${NIX_GLOBAL_ARGS[*]:-defaults})" +exec bash "${MISE_CONFIG_ROOT}/.github/scripts/nix.sh" ${NIX_GLOBAL_ARGS[@]+"${NIX_GLOBAL_ARGS[@]}"} docker --load --test ${NIX_REST_ARGS[@]+"${NIX_REST_ARGS[@]}"} diff --git a/.mise/tasks/hashes/update b/.mise/tasks/hashes/update index 0c30118a..e0f71589 100755 --- a/.mise/tasks/hashes/update +++ b/.mise/tasks/hashes/update @@ -1,13 +1,9 @@ #!/usr/bin/env bash -#MISE description="Regenerate expected binary hashes. Usage: mise run hashes:update -- [--link static|dynamic]" +#MISE description="Regenerate expected binary hashes. Usage: mise run hashes:update -- [--link static|dynamic] [--variant default|saml]" set -euo pipefail source "${MISE_CONFIG_ROOT}/.mise/lib/common.sh" -LINK_ARGS=() -if [ "${1:-}" = "--link" ] || [ "${1:-}" = "-l" ]; then - LINK_ARGS=(--link "${2:?missing value for --link}") - shift 2 -fi +split_nix_global_args "$@" -print_status "Updating expected hashes (${LINK_ARGS[1]:-static} link)" -exec bash "${MISE_CONFIG_ROOT}/.github/scripts/nix.sh" "${LINK_ARGS[@]}" update-hashes "$@" +print_status "Updating expected hashes (${NIX_GLOBAL_ARGS[*]:-defaults})" +exec bash "${MISE_CONFIG_ROOT}/.github/scripts/nix.sh" ${NIX_GLOBAL_ARGS[@]+"${NIX_GLOBAL_ARGS[@]}"} update-hashes ${NIX_REST_ARGS[@]+"${NIX_REST_ARGS[@]}"} diff --git a/.mise/tasks/package b/.mise/tasks/package index 163672f2..b758b4d6 100755 --- a/.mise/tasks/package +++ b/.mise/tasks/package @@ -1,15 +1,10 @@ #!/usr/bin/env bash -#MISE description="Build packages via Nix. Usage: mise run package -- [--link static|dynamic] [deb|rpm|dmg]" +#MISE description="Build packages via Nix. Usage: mise run package -- [--link static|dynamic] [--variant default|saml] [deb|rpm|dmg]" set -euo pipefail source "${MISE_CONFIG_ROOT}/.mise/lib/common.sh" -# nix.sh expects: [--link static|dynamic] package [deb|rpm|dmg] -# Splice the literal "package" command word in after an optional leading --link pair. -LINK_ARGS=() -if [ "${1:-}" = "--link" ] || [ "${1:-}" = "-l" ]; then - LINK_ARGS=(--link "${2:?missing value for --link}") - shift 2 -fi +# nix.sh expects: [--link X] [--variant Y] package [deb|rpm|dmg] +split_nix_global_args "$@" -print_status "Building packages (${LINK_ARGS[1]:-static} link)" -exec bash "${MISE_CONFIG_ROOT}/.github/scripts/nix.sh" "${LINK_ARGS[@]}" package "$@" +print_status "Building packages (${NIX_GLOBAL_ARGS[*]:-defaults})" +exec bash "${MISE_CONFIG_ROOT}/.github/scripts/nix.sh" ${NIX_GLOBAL_ARGS[@]+"${NIX_GLOBAL_ARGS[@]}"} package ${NIX_REST_ARGS[@]+"${NIX_REST_ARGS[@]}"} diff --git a/AGENTS.md b/AGENTS.md index c8b294c7..537ff6ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,8 +46,10 @@ full list. mise run test:sqlite # == bash .github/scripts/nix.sh test sqlite mise run test:psql mise run package -- --link static deb # == bash .github/scripts/nix.sh --link static package deb +mise run package -- --variant saml deb # SAML build: result-deb-static-saml/*-saml.deb mise run docker:load mise run docker:test +mise run docker:test -- --variant saml # SAML image (tag -saml), checks the saml feature mise run ui:lint # admin-ui: eslint + prettier + tsc mise run ui:test # admin-ui: unit tests mise run ui:build @@ -122,6 +124,7 @@ mise.toml — mise task runner config ([tools], [task_config]) | ------------------ | ------- | ----------------------------------------------------------- | | `openssl` | **on** | Use OpenSSL (vendored) for TLS; required for most deploys | | `rustls` | off | Use rustls instead of OpenSSL | +| `saml` | off | SAML 2.0 SP; needs bundled xmlsec (`nix/xmlsec-static.nix`) — Nix build `auth-verifier-static-saml` | | `database` | **on** | Compile all database backends (SQLite, PostgreSQL, MySQL) | | `no_jwt_validation`| off | Skip JWT expiry/issuer checks — **dev/test only** | @@ -230,7 +233,12 @@ grep -r '#\[get\|#\[post\|#\[put\|#\[delete' server/src/server/endpoints/ - **Vendored OpenSSL** (compiled during cargo build, no external OpenSSL needed) - `cmake` and `perl` as native build inputs (required by `aws-lc-sys` and openssl crate) -No FIPS / non-FIPS variants — the auth server has a single build variant. +No FIPS / non-FIPS variants. The only build variant besides the default one is +**SAML**: the same server compiled with the `saml` Cargo feature and a statically +bundled xmlsec + libxml2 (`nix/xmlsec-static.nix`). It is static-only +(`--variant saml` with `--link dynamic` is rejected) and is tagged `static-saml` +in result directories, CI artifacts, hash files and publish folders. +Packages keep the package name; only the file name gets a `-saml` suffix. ```bash # Build static binary @@ -239,8 +247,12 @@ nix-build -A auth-verifier-static # Build dynamic binary nix-build -A auth-verifier-dynamic -# Build Docker image (Linux only) +# Build the SAML binary (static only) +nix-build -A auth-verifier-static-saml + +# Build Docker image (Linux only); the SAML image is tagged -saml nix-build -A docker-image +nix-build -A docker-image-saml ``` --- @@ -253,9 +265,11 @@ bash .github/scripts/nix.sh --link static package # DEB + RPM on Linux, DM bash .github/scripts/nix.sh --link static package deb # DEB only bash .github/scripts/nix.sh --link static package rpm # RPM only bash .github/scripts/nix.sh --link static package dmg # DMG only (macOS) +bash .github/scripts/nix.sh --variant saml package deb # SAML build (static only) # Docker (Linux only): bash .github/scripts/nix.sh docker --load +bash .github/scripts/nix.sh --variant saml docker --load --test ``` ### Expected hashes (`nix/expected-hashes/`) @@ -264,7 +278,7 @@ bash .github/scripts/nix.sh docker --load | ------------------------------------------- | ------------------------------------------ | | `server.vendor.static.sha256` | Cargo vendor hash for static builds | | `server.vendor.dynamic.sha256` | Cargo vendor hash for dynamic builds | -| `auth-verifier....sha256` | Expected binary hash for determinism check | +| `auth-verifier....sha256` | Expected binary hash for determinism check | When `Cargo.lock` changes, the vendor hashes become stale. Regenerate: @@ -281,7 +295,7 @@ nix-build -A auth-verifier-static 2>&1 | grep "got:" All CI runs go through `.github/scripts/nix.sh`: ```bash -bash .github/scripts/nix.sh [--link static|dynamic] COMMAND [args] +bash .github/scripts/nix.sh [--link static|dynamic] [--variant default|saml] COMMAND [args] ``` | Command | Description | diff --git a/CHANGELOG/feat_saml-integration.md b/CHANGELOG/feat_saml-integration.md index e19dbbab..be2a59be 100644 --- a/CHANGELOG/feat_saml-integration.md +++ b/CHANGELOG/feat_saml-integration.md @@ -14,6 +14,13 @@ - Add SAML 2.0 as a realm authentication method in the admin UI: pasted IdP metadata is parsed in the browser with the server's acceptance rules and previewed (IdP entity ID, sign-in URL, signing-certificate subject and expiry, NameID formats); the SP entity ID and ACS URL are prefilled, copyable and paired with a download of this server's SP metadata; subject, role and allowlisted claim mappings, return origins and default return URL are checked per field (reserved claim names, duplicates, non-origin or non-https URLs, ACS URL of another realm); server rejections naming a SAML setting appear on that field and other refusals inline; and realm cards show a SAML tag and flag IdP signing certificates within 30 days of expiry. - Make `saml_params` optional in the admin UI's `RealmAuthParams` type, as the server omits it for realms without SAML, which also restores the admin UI's type-check. - Bundle a static, OpenSSL-only xmlsec 1.3.5 and libxml2 2.13.4 (`nix/xmlsec-static.nix`, built with the glibc-2.34 toolchain, XSLT and runtime crypto loading disabled) and wire it plus the libclang/bindgen setup into `shell.nix`, so `--features saml` links xmlsec statically against the server's vendored OpenSSL with no new runtime library dependency. +- Add a separate SAML Nix build next to the unchanged default one — `auth-verifier-static-saml` (static only, compiled with `--features saml` against the bundled xmlsec, libclang taken from the glibc-2.34 package set, build failing if xmlsec/libxml2 end up dynamically linked) and `docker-image-saml` (tag `-saml`) — so SAML can be shipped as prebuilt packages and images without changing the standard artifacts; its binary hash is tracked as `static-saml`. +- Add a `--variant default|saml` option to `.github/scripts/nix.sh`, the packaging scripts and the `package`, `hashes:update`, `docker:load` and `docker:test` mise tasks: SAML packages are written to `result--static-saml/` with a `-saml` file-name suffix (checksum and signature name the final file) under the same package name, and `--variant saml --link dynamic` is rejected. +- Make the DMG packaging script skip `result-*` directories when collecting the built DMG, so a DMG from another build tag is not picked up again. + +## Bug Fixes + +- Stop the admin UI's realm drawer from ticking TOTP when editing a realm without it: the server omits `totp_params` when unset, but the drawer only treated `null` as disabled, so saving such a realm silently enabled TOTP. ## Tests @@ -25,9 +32,13 @@ ## CI - Add a Nix-based `cargo-saml` job that runs clippy and the unit tests with `--features saml` inside `nix-shell`, since that feature needs the bundled xmlsec and libclang that the plain-cargo jobs don't have. +- Run the `cargo-saml` job on Linux x86_64, Linux aarch64 and macOS, and package the SAML variant (`static-saml`) for deb, rpm and dmg in the packaging and publish matrices (own `static-saml` publish folder, attached to GitHub releases), so the bundled xmlsec is verified on every platform we ship. +- Test the SAML packages in the container and systemd package tests by starting the server with a `[saml_sp_params]` section, which a build without the `saml` feature refuses, and build, test and push a `-saml` Docker image and manifest next to the default one; `test_docker_image.sh` does the same check when `EXPECT_SAML=1`. ## Docs +- Add `server/saml_testing.md`, a hands-on guide to test the SAML branch: automated tests, building the server and admin UI, starting the server, running a local Keycloak identity provider from a one-file realm import, creating a SAML realm in the admin UI, signing in, and a checklist of UI and server checks including the IdP-must-sign and bad-signing-key cases. - Add a SAML 2.0 single sign-on guide (`docs/saml.md`: supported scope, build and `[saml_sp_params]` prerequisites, realm settings, IdP onboarding with the SP metadata, the login flow, identity mapping, every check applied to a response, certificate and key rotation, troubleshooting), document SAML as flow 8, the `[saml_sp_params]` section, the `/saml` endpoints and the `sa` auth scheme across the configuration, flows and API references, list SAML as implemented in the overview and README, and add a commented `[saml_sp_params]` block to the packaged and development configurations. - Document the `/saml/{realm_id}/login`, `/acs` and `/metadata` endpoints and a `SAML` tag in `openapi.yaml`, and proxy `/saml` from the admin UI dev and preview servers. - Add ADR-0003 recording why SAML uses `samael` with a statically bundled, OpenSSL-only xmlsec, why the server-side feature is behind an optional `saml` Cargo feature while the API types are not, the v1 protocol scope, the single server-wide RSA SP signing key, and the rejected alternatives and accepted risks (including that xmlsec/libxml2 security updates now ship with our releases). +- Document the prebuilt SAML packages, Docker tag and `nix-build -A auth-verifier-static-saml` as ways to get a SAML-enabled server in `docs/saml.md`, and the `saml` feature, SAML Nix attributes, `--variant` option and `static-saml` hash files in `AGENTS.md` and the expected-hashes README. diff --git a/admin-ui/src/components/realms/RealmFormDrawer.tsx b/admin-ui/src/components/realms/RealmFormDrawer.tsx index d6473f92..5e796e4d 100644 --- a/admin-ui/src/components/realms/RealmFormDrawer.tsx +++ b/admin-ui/src/components/realms/RealmFormDrawer.tsx @@ -93,7 +93,7 @@ export const RealmFormDrawer: React.FC = ({ open, realm, o const toggleDirty = upEnabled !== (orig.auth_params.username_password_params !== null) || jwtEnabled !== (orig.auth_params.jwt_params !== null) || - totpEnabled !== (orig.auth_params.totp_params !== null) || + totpEnabled !== Boolean(orig.auth_params.totp_params) || samlEnabled !== Boolean(orig.auth_params.saml_params); const formDirty = cur.session_max_age_seconds !== orig.session_max_age_seconds || @@ -139,7 +139,7 @@ export const RealmFormDrawer: React.FC = ({ open, realm, o form.setFieldsValue(values); const up = realm.auth_params.username_password_params !== null; const jwt = realm.auth_params.jwt_params !== null; - const totp = realm.auth_params.totp_params !== null; + const totp = Boolean(realm.auth_params.totp_params); setUpEnabled(up); setJwtEnabled(jwt); setTotpEnabled(totp); diff --git a/admin-ui/src/types/api.ts b/admin-ui/src/types/api.ts index be9f7e24..dd807bd7 100644 --- a/admin-ui/src/types/api.ts +++ b/admin-ui/src/types/api.ts @@ -58,7 +58,8 @@ export interface SamlParams { export interface RealmAuthParams { jwt_params: JwtParams | null; username_password_params: UsernamePasswordParams | null; - totp_params: TotpRealmParams | null; + /** Omitted by the server when the realm doesn't use TOTP. */ + totp_params?: TotpRealmParams | null; /** Omitted by the server when the realm doesn't use SAML. */ saml_params?: SamlParams | null; } diff --git a/admin-ui/tests/unit/components/realms/RealmFormDrawer.test.tsx b/admin-ui/tests/unit/components/realms/RealmFormDrawer.test.tsx index 24ba5fbc..533598a3 100644 --- a/admin-ui/tests/unit/components/realms/RealmFormDrawer.test.tsx +++ b/admin-ui/tests/unit/components/realms/RealmFormDrawer.test.tsx @@ -93,6 +93,21 @@ describe("RealmFormDrawer", () => { await waitFor(() => expect(onSuccess).toHaveBeenCalled()); }); + it("should treat a realm whose totp_params the server omitted as not using TOTP", async () => { + // The server drops `totp_params` from its JSON when unset, rather than sending null. + const withoutTotp: Realm = { + ...existingRealm, + auth_params: { username_password_params: { allow_expired_passwords: false }, jwt_params: null }, + }; + await act(async () => { + render(); + }); + + expect(screen.getByRole("checkbox", { name: "TOTP (Two-Factor)" })).not.toBeChecked(); + const buttons = screen.getAllByRole("button", { name: "Save" }); + expect(buttons[buttons.length - 1]).toBeDisabled(); + }); + it("should not render when open is false", () => { render(); diff --git a/default.nix b/default.nix index 771f72b1..70a754b9 100644 --- a/default.nix +++ b/default.nix @@ -184,6 +184,9 @@ let version = authVersion; }; + # Static, OpenSSL-only xmlsec + libxml2 for the SAML build (glibc 2.34 toolchain on Linux) + xmlsecStatic = import ./nix/xmlsec-static.nix { inherit pkgs pkgs234; }; + # Build auth-verifier for static linkage auth-verifier-static = pkgs.callPackage ./nix/auth-verifier.nix { inherit pkgs pkgs234 rustPlatform; @@ -191,6 +194,14 @@ let static = true; }; + # Same as auth-verifier-static, compiled with the `saml` Cargo feature + auth-verifier-static-saml = pkgs.callPackage ./nix/auth-verifier.nix { + inherit pkgs pkgs234 rustPlatform xmlsecStatic; + version = authVersion; + static = true; + saml = true; + }; + # Build auth-verifier for dynamic linkage auth-verifier-dynamic = pkgs.callPackage ./nix/auth-verifier.nix { inherit pkgs pkgs234 rustPlatform; @@ -206,14 +217,25 @@ let version = authVersion; }; + # Docker image with the SAML-enabled server (tag suffix -saml) + docker-image-saml = pkgs.callPackage ./nix/docker.nix { + inherit pkgs; + authServer = auth-verifier-static-saml; + adminUi = admin-ui; + version = authVersion; + tagSuffix = "-saml"; + }; + in { # Build attributes accessible via -A inherit admin-ui auth-verifier-static + auth-verifier-static-saml auth-verifier-dynamic docker-image + docker-image-saml cargoGenerateRpmTool rustToolchain ; diff --git a/nix/auth-verifier.nix b/nix/auth-verifier.nix index 2b3a3d84..89c91c44 100644 --- a/nix/auth-verifier.nix +++ b/nix/auth-verifier.nix @@ -8,8 +8,14 @@ version, # Linkage mode: true for static OpenSSL, false for dynamic OpenSSL static ? true, + # Build with the `saml` Cargo feature, linking the bundled static xmlsec/libxml2 + saml ? false, + xmlsecStatic ? null, }: +assert saml -> xmlsecStatic != null; +assert saml -> static; + let # On Linux, use pkgs234 stdenv (glibc 2.34) to broaden runtime compatibility. # On macOS, use the default stdenv. @@ -18,9 +24,12 @@ let # Name tag for output symlinks linkTag = if static then "static" else "dynamic"; + # Distinguishes the SAML binary from the default one (different bytes, different hash) + buildTag = linkTag + lib.optionalString saml "-saml"; + # Expected deterministic sha256 of the final installed binary (auth_verifier) # Naming convention (matches repository files): - # auth-verifier....sha256 + # auth-verifier....sha256 expectedHashDir = ./expected-hashes; # Helper: read & trim a hash file, returning null when absent or placeholder. @@ -40,10 +49,10 @@ let null; # Pre-read expected hashes for every arch+os combination this derivation supports. - expectedHash_x86_64_linux = readHashFile "auth-verifier.${linkTag}.x86_64.linux.sha256"; - expectedHash_aarch64_linux = readHashFile "auth-verifier.${linkTag}.aarch64.linux.sha256"; - expectedHash_x86_64_darwin = readHashFile "auth-verifier.${linkTag}.x86_64.darwin.sha256"; - expectedHash_arm64_darwin = readHashFile "auth-verifier.${linkTag}.arm64.darwin.sha256"; + expectedHash_x86_64_linux = readHashFile "auth-verifier.${buildTag}.x86_64.linux.sha256"; + expectedHash_aarch64_linux = readHashFile "auth-verifier.${buildTag}.aarch64.linux.sha256"; + expectedHash_x86_64_darwin = readHashFile "auth-verifier.${buildTag}.x86_64.darwin.sha256"; + expectedHash_arm64_darwin = readHashFile "auth-verifier.${buildTag}.arm64.darwin.sha256"; srcRoot = ../.; @@ -87,7 +96,9 @@ let fw.CoreFoundation pkgs.libiconv ] - ); + ) + # Setup hooks put xmlsec on PKG_CONFIG_PATH and xmlsec1-config on PATH, as in shell.nix + ++ lib.optionals saml [ xmlsecStatic ]; # Native build inputs needed by vendored OpenSSL and aws-lc-sys nativeBuildInputs = @@ -102,7 +113,7 @@ let in rustPlatform.buildRustPackage { - pname = "auth_verifier"; + pname = "auth_verifier" + lib.optionalString saml "-saml"; inherit version; src = filteredSrc; @@ -151,10 +162,21 @@ rustPlatform.buildRustPackage { export CC_${rustTriple}=${ccBin} export CXX_${rustTriple}=${cxxBin} ''; + # samael's bindgen needs libclang plus the target libc headers; same recipe as shell.nix. + # Must come from the same glibc-2.34 set: build scripts run against pkgs234's libc, + # which cannot load a libclang built for a newer glibc. + libclang = platform.llvmPackages.libclang; + samlExports = lib.optionalString saml '' + # samael's build script runs xmlsec1-config; buildInputs bin dirs are not on PATH here. + export PATH="${xmlsecStatic}/bin:$PATH" + export LIBCLANG_PATH="${libclang.lib}/lib" + export BINDGEN_EXTRA_CLANG_ARGS="$(cat ${effectiveCc}/nix-support/libc-cflags 2>/dev/null) $(cat ${effectiveCc}/nix-support/cc-cflags 2>/dev/null) -idirafter $(echo ${libclang.lib}/lib/clang/*/include)" + ''; + features = lib.optionalString saml " --features saml"; in '' - echo "== cargo build auth_verifier (release) ==" - ${ccExports}cargo build --release -p auth_verifier --bin auth_verifier + echo "== cargo build auth_verifier (release, ${buildTag}) ==" + ${ccExports}${samlExports}cargo build --release -p auth_verifier --bin auth_verifier${features} ''; # Custom install phase: copy the binary and immediately patch its ELF @@ -186,6 +208,19 @@ rustPlatform.buildRustPackage { BIN="$out/bin/auth_verifier" [ -f "$BIN" ] || { echo "ERROR: Binary not found at $BIN"; exit 1; } echo "Binary exists at: $BIN" + ${lib.optionalString saml '' + # xmlsec/libxml2 must be linked statically; a hit means a system library slipped in. + if [ "$(uname)" = "Linux" ]; then + LIBS=$(readelf -d "$BIN" | grep NEEDED || true) + else + LIBS=$(otool -L "$BIN" || true) + fi + if echo "$LIBS" | grep -qiE 'xmlsec|libxml2|libltdl'; then + echo "ERROR: SAML binary links xmlsec/libxml2 dynamically:" + echo "$LIBS" + exit 1 + fi + ''} file "$BIN" || true if [ "$(uname)" = "Linux" ]; then @@ -214,7 +249,7 @@ rustPlatform.buildRustPackage { aarch64|arm64) ARCH_TAG="aarch64" ;; *) ARCH_TAG="$ARCH_LINUX" ;; esac - HASH_FILENAME="auth-verifier.${linkTag}.$ARCH_TAG.linux.sha256" + HASH_FILENAME="auth-verifier.${buildTag}.$ARCH_TAG.linux.sha256" EXPECTED="" case "$ARCH_LINUX" in @@ -248,7 +283,7 @@ rustPlatform.buildRustPackage { arm64) ARCH_TAG="arm64" ;; *) ARCH_TAG="$ARCH_DARWIN" ;; esac - HASH_FILENAME="auth-verifier.${linkTag}.$ARCH_TAG.darwin.sha256" + HASH_FILENAME="auth-verifier.${buildTag}.$ARCH_TAG.darwin.sha256" EXPECTED="" case "$ARCH_DARWIN" in diff --git a/nix/docker.nix b/nix/docker.nix index 5f8db550..f4c89a10 100644 --- a/nix/docker.nix +++ b/nix/docker.nix @@ -6,6 +6,8 @@ adminUi ? null, # Version (from Cargo.toml) version, + # Appended to the image tag, e.g. "-saml" for the SAML variant + tagSuffix ? "", }: let @@ -28,7 +30,7 @@ let null; imageName = "cosmian-auth-verifier"; - imageTag = "${version}"; + imageTag = "${version}${tagSuffix}"; # ── Entrypoint script ───────────────────────────────────────────────────── # Resolves the configuration file at runtime: diff --git a/nix/expected-hashes/README.md b/nix/expected-hashes/README.md index 4e707772..fc650073 100644 --- a/nix/expected-hashes/README.md +++ b/nix/expected-hashes/README.md @@ -15,7 +15,10 @@ Used by `nix/auth-verifier.nix` to verify reproducible Cargo vendoring: Generated after a successful build and used for cross-run determinism checks: -- `auth-verifier....sha256` +- `auth-verifier....sha256` + +`static-saml` is the SAML build (`auth-verifier-static-saml`); it shares the Cargo vendor +hash of the static build because `Cargo.lock` is the same. ## How to update diff --git a/server/documentation/docs/saml.md b/server/documentation/docs/saml.md index 3f898dfb..fa742488 100644 --- a/server/documentation/docs/saml.md +++ b/server/documentation/docs/saml.md @@ -48,7 +48,15 @@ admins keep signing in with the methods of the `_` realm. ## Prerequisites 1. **A server built with the `saml` feature.** SAML statically links the xmlsec and libxml2 - C libraries (see the ADR), so it is off by default. Build it inside the Nix shell: + C libraries (see the ADR), so it is off by default. Either use a prebuilt SAML build or + build it yourself: + + - **Prebuilt:** the release publishes a SAML variant next to the standard one — packages + whose file name ends in `-saml` (for example `auth-verifier_-1_amd64-saml.deb`) + and the Docker image tag `-saml`. It is a static build only and installs under + the same package name as the standard server, so install one or the other. + - **From source with Nix:** `nix-build -A auth-verifier-static-saml` + - **From source with cargo**, inside the Nix shell: ```bash nix-shell --run "cargo build --release --features auth_verifier/saml" diff --git a/server/saml_testing.md b/server/saml_testing.md new file mode 100644 index 00000000..4d0b2df3 --- /dev/null +++ b/server/saml_testing.md @@ -0,0 +1,255 @@ +# Testing the SAML branch + +A hands-on guide to check SAML single sign-on (`feat/saml-integration`) on your machine: +build the server and the admin UI, start a local identity provider, and sign in through it. +Every command below was run as written. + +You will test two things: **the server** (SAML login, metadata, security checks) and **the +admin UI** (configuring a realm for SAML). The product documentation is in +[`documentation/docs/saml.md`](documentation/docs/saml.md); this guide only covers testing. + +--- + +## What you need + +| Tool | Why | +|------|-----| +| [Nix](https://nixos.org/download) | The SAML build links the C libraries xmlsec/libxml2, which the repo's `nix-shell` provides. Only needed to **build**; the built server runs anywhere. | +| Docker | Runs a local identity provider (Keycloak). | +| Node 22.12+ and pnpm | Builds the admin UI. | +| `curl`, `openssl` | Used in the checks. | + +Use **two terminals**: one for the server (it stays running), one for everything else. Run +every command from the repository root. + +--- + +## 1. Get the code and run the automated tests + +```bash +git checkout feat/saml-integration +nix-shell --run "cargo test -p auth_verifier --features saml --lib -- saml --test-threads=4" +(cd admin-ui && pnpm install && pnpm test:unit) +``` + +The first `nix-shell` builds xmlsec and takes a while; later runs are quick. All tests should +pass. (The complete server suite is `cargo test --workspace --features auth_verifier/saml +--lib -- --test-threads=4` and takes about ten minutes; it is not required for this guide.) + +## 2. Build the server and the admin UI + +```bash +nix-shell --run "cargo build -p auth_verifier --features saml" # -> target/debug/auth_verifier +(cd admin-ui && pnpm build) # -> admin-ui/dist +``` + +Release-style build, if you want it: `nix-build -A auth-verifier-static-saml`. + +## 3. Start the server (terminal 1) + +The development config already has a commented SAML signing-key block. Enable it in a copy, +with a fresh database: + +```bash +rm -f /tmp/saml-guide.db +sed -e 's/^# \[saml_sp_params\]/[saml_sp_params]/' \ + -e 's/^# saml_rsa_private_key/saml_rsa_private_key/' \ + -e 's/^# saml_certificate/saml_certificate/' \ + -e 's#sqlite:///tmp/path.db#sqlite:///tmp/saml-guide.db#' \ + server/auth_verifier.dev.toml > /tmp/saml-guide.toml + +./target/debug/auth_verifier /tmp/saml-guide.toml +``` + +Wait until this answers (a debug build takes a while to start): + +```bash +curl -sk https://127.0.0.1:8443/public/version +``` + +Notes: + +- The server uses **HTTPS on port 8443** with a self-signed test certificate. Ignore the + comments at the top of the dev config that mention plain HTTP on 8080. +- Always use **`127.0.0.1`**, not `localhost`: the test certificate is issued for that address. +- In your browser, open once and accept the certificate + warning. +- Admin UI login: `admin` / `change_me`. + +## 4. Start the identity provider (terminal 2) + +Keycloak plays the company IdP. This creates a `demo` realm with a user `alice`, her groups, +and a SAML client for your server, which trusts your server's signing certificate, so +Keycloak really verifies the signature on your server's requests: + +```bash +SP_CERT=$(grep -v -- '-----' server/src/tests/certificates/rsa/auth.server.cert.pem | tr -d '\n') +mkdir -p /tmp/kc && cat > /tmp/kc/demo-realm.json < in your browser once and accept its certificate warning too +(the IdP has its own address, so it needs its own exception). The Keycloak admin console is at +`https://127.0.0.1:9443/admin` (`admin` / `admin`), the demo user is `alice` / `alice-pw`. + +## 5. Create a SAML realm in the admin UI + +1. Sign in at as `admin` / `change_me`. +2. **Realms → Create Realm**, Realm ID **`sso-demo`** (it must be exactly this: the IdP was set + up for it). +3. Tick **SAML 2.0 (single sign-on)** and fill in: + + | Field | Value | + |-------|-------| + | IdP metadata XML | The content of `/tmp/idp-metadata.xml` | + | SP entity ID / ACS URL | Already prefilled: `https://127.0.0.1:8443/saml/sso-demo` and `…/acs` | + | Subject attribute | `username` | + | Role attribute | `groups` | + | Extra claims | Add mapping: attribute `email`, claim `mail` | + | Allowed return origins | `https://127.0.0.1:8443` (press Enter) | + | Default return URL | `https://127.0.0.1:8443/whoami?realm=sso-demo` | + +4. As soon as the metadata is pasted, a **summary panel** appears: IdP entity ID + `https://127.0.0.1:9443/realms/demo`, the sign-in URL and the signing certificate with a + green validity tag. **Create**. + +The realm card now shows a **SAML** tag. Reopen it with **Edit**: the **Download SP metadata** +button gives the file an IdP administrator would import. + +## 6. Sign in through the IdP + +In the browser, open: + +```text +https://127.0.0.1:8443/saml/sso-demo/login +``` + +You are sent to Keycloak; sign in as `alice` / `alice-pw`. You land on +`/whoami?realm=sso-demo`, which shows the session your server issued: + +```json +{"sub":"alice", "roles":["admins","users"], "as_as":"sa", "as_rid":"sso-demo", "mail":"alice@example.com", …} +``` + +`as_as: "sa"` means the session came from SAML; `roles` and `mail` came from the IdP's +attributes through your mapping. The browser now holds the `_ea_` session cookie, exactly as +after any other login. + +--- + +## 7. Things worth trying + +### In the admin UI (edit the `sso-demo` realm) + +| Do | Expect | +|----|--------| +| Replace the metadata with `/dev/null | grep -v -- '-----' | tr -d '\n') +sed -E "s|()[^<]*|\1$EXP|" /tmp/idp-metadata.xml > /tmp/idp-metadata-expiring.xml +``` + +The summary shows an orange **"Expires in 10 days"** tag and, once saved, so does the realm +card. Logins fail while this certificate is saved (it isn't Keycloak's), so paste +`/tmp/idp-metadata.xml` back afterwards. Dark mode is stored in the browser: run +`localStorage.setItem("admin-ui-darkMode","true")` in the browser console and reload. + +### On the server + +| Do | Expect | +|----|--------| +| `curl -sk https://127.0.0.1:8443/saml/sso-demo/metadata` | XML with `AuthnRequestsSigned="true"` and your signing certificate. | +| `curl -sk -o /dev/null -w "%{http_code}\n" -G --data-urlencode "return_to=https://evil.example.com/" https://127.0.0.1:8443/saml/sso-demo/login` | `400`: only the allowed origin is accepted (`https://127.0.0.1:8443/…` gives `302`). | +| `curl -sk -X POST -d 'SAMLResponse=eA==&RelayState=x' https://127.0.0.1:8443/saml/sso-demo/acs` | `401 … not started from this browser`: an ACS call without the login cookie is refused. | +| `curl -sk -o /dev/null -w "%{http_code}\n" https://127.0.0.1:8443/saml/dev-realm/login` | `400`: that realm doesn't use SAML. | + +**The IdP must sign.** Ask Keycloak to stop signing, then sign in again: + +```bash +K="docker exec kc-saml-demo /opt/keycloak/bin/kcadm.sh" +$K config credentials --server http://localhost:8080 --realm master --user admin --password admin +CID=$($K get clients -r demo -q clientId=https://127.0.0.1:8443/saml/sso-demo --fields id --format csv --noquotes | tr -d '\r') +$K update clients/$CID -r demo -s 'attributes."saml.assertion.signature"=false' +``` + +The login now ends with `401 … SAML Response and all assertions must be signed`. Restore it +with the same command and `=true`. + +**Bad signing key.** Stop the server, point `saml_certificate` in `/tmp/saml-guide.toml` at +`server/src/tests/certificates/rsa/auth.user1.cert.pem`, and start it: it refuses to start +with `saml_certificate does not match saml_rsa_private_key`. Undo the change afterwards. + +--- + +## If something goes wrong + +| Symptom | Likely cause | +|---------|--------------| +| Browser shows a certificate warning page | Expected: accept it for both `https://127.0.0.1:8443` and `https://127.0.0.1:9443`. | +| Keycloak shows "Invalid requester" or a signature error | The realm ID or URLs differ from `sso-demo` / `https://127.0.0.1:8443`, or you used `localhost`. | +| `401 unknown or expired sign-in request` | The login took over 10 minutes, or was already used. Start again from `/saml/sso-demo/login`. | +| `404` on `/saml/...` | The server was started with the original dev config (no `[saml_sp_params]`) or is not the SAML build. | +| Server exits at start with `built without the saml feature` | Rebuild with `--features saml` inside `nix-shell` (step 2). | +| Keycloak still shows old settings | It only imports the realm file on a fresh container: `docker rm -f kc-saml-demo` and rerun step 4. | + +## Clean up + +```bash +docker rm -f kc-saml-demo # stops Keycloak +rm -rf /tmp/kc /tmp/saml-guide.* # test files and database +# stop the server with Ctrl-C in terminal 1 +``` From 1449958f9cb6d84fc241bc48f307be447af10dd5 Mon Sep 17 00:00:00 2001 From: charming_wicket <235307685+charming-wicket-5502@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:19:51 +0200 Subject: [PATCH 09/19] Pipeline fix attempt --- .github/workflows/main_base.yml | 8 ++++++-- CHANGELOG/feat_saml-integration.md | 1 + 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main_base.yml b/.github/workflows/main_base.yml index 4698ece7..674a3742 100644 --- a/.github/workflows/main_base.yml +++ b/.github/workflows/main_base.yml @@ -97,6 +97,10 @@ jobs: # Bundled xmlsec (nix/xmlsec-static.nix) is built per platform, matching the packaging matrix. runner: [ubuntu-24.04, ubuntu-24.04-arm, macos-15] + env: + # Same pin as shell.nix/common.sh; nix-shell takes its bash from , and without it falls back to macOS /bin/bash 3.2. + PIN_URL: https://package.cosmian.com/nixpkgs/8b27c1239e5c421a2bbc2c65d52e4a6fbf2ff296.tar.gz + steps: - name: Nix installation uses: cachix/install-nix-action@v31 @@ -111,7 +115,7 @@ jobs: submodules: recursive - name: Static analysis (saml feature) - run: nix-shell --run "cargo clippy --workspace --all-targets --features auth_verifier/saml -- -D warnings" + run: nix-shell -I "nixpkgs=$PIN_URL" --run "cargo clippy --workspace --all-targets --features auth_verifier/saml -- -D warnings" - name: Run unit tests (saml feature) - run: nix-shell --run "cargo test --workspace --lib --features auth_verifier/saml -- --nocapture" + run: nix-shell -I "nixpkgs=$PIN_URL" --run "cargo test --workspace --lib --features auth_verifier/saml -- --nocapture" diff --git a/CHANGELOG/feat_saml-integration.md b/CHANGELOG/feat_saml-integration.md index be2a59be..e192cc4b 100644 --- a/CHANGELOG/feat_saml-integration.md +++ b/CHANGELOG/feat_saml-integration.md @@ -33,6 +33,7 @@ - Add a Nix-based `cargo-saml` job that runs clippy and the unit tests with `--features saml` inside `nix-shell`, since that feature needs the bundled xmlsec and libclang that the plain-cargo jobs don't have. - Run the `cargo-saml` job on Linux x86_64, Linux aarch64 and macOS, and package the SAML variant (`static-saml`) for deb, rpm and dmg in the packaging and publish matrices (own `static-saml` publish folder, attached to GitHub releases), so the bundled xmlsec is verified on every platform we ship. +- Pin `` (`-I nixpkgs=$PIN_URL`) in the `cargo-saml` job's `nix-shell` calls so the shell uses a Nix-provided bash; without it the macOS runner fell back to `/bin/bash` 3.2, which nixpkgs' stdenv rejects. - Test the SAML packages in the container and systemd package tests by starting the server with a `[saml_sp_params]` section, which a build without the `saml` feature refuses, and build, test and push a `-saml` Docker image and manifest next to the default one; `test_docker_image.sh` does the same check when `EXPECT_SAML=1`. ## Docs From c4279d2844709d45e2e151de36f514fd2febbf35 Mon Sep 17 00:00:00 2001 From: charming_wicket <235307685+charming-wicket-5502@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:53:28 +0200 Subject: [PATCH 10/19] fix for unit tests running in pipeline --- CHANGELOG/feat_saml-integration.md | 1 + admin-ui/tests/unit/setup.ts | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG/feat_saml-integration.md b/CHANGELOG/feat_saml-integration.md index e192cc4b..c8b59c74 100644 --- a/CHANGELOG/feat_saml-integration.md +++ b/CHANGELOG/feat_saml-integration.md @@ -24,6 +24,7 @@ ## Tests +- Make the admin UI unit-test teardown wait at least 50 ms (not just 16 event-loop turns) before jsdom is destroyed, so slow CI runners no longer fail the run with an unhandled `window is not defined` raised by React's scheduler after `RealmFormDrawer.test.tsx`. - Add a SAML request store test suite that runs against in-memory SQLite by default and against PostgreSQL, MySQL or Redis through the same `TEST_SESSIONS_STORE` switch as the session-store tests (single-use and concurrent takes, realm scoping, expiry on write and on read, replay detection, case-sensitive and long IDs, expiry purge), an xmlsec smoke test that exercises libxml2, xmlsec and its OpenSSL backend end to end, an API test checking that SAML settings are refused without the `saml` feature, unit tests for every SAML settings validation rule (prefixed and unprefixed metadata), and an API test checking that valid settings are stored with metadata-derived IdP fields while invalid ones get a field-specific 400, plus unit tests for the SAML signing-key checks and API tests checking that SAML realms are refused without a signing key and that a mismatched key, or the key section on a build without `saml`, stops the server from starting. - Add a fake SAML IdP test helper (`tests::saml_idp`) that renders ``s answering our `AuthnRequest`s with every validated field adjustable and signs the assertion and/or the response through xmlsec with RSA-SHA256 and SHA-256 digests, and switch the test IdP metadata to that IdP's RSA certificate, so the upcoming ACS validation can be tested against real signatures, untrusted keys and post-signing tampering. - Add SAML response validation tests that break one property at a time (signature missing, untrusted or broken by tampering, injected unsigned assertion, wrong request, issuer, recipient or audience, missing audience restriction or `AuthnStatement`, expired, not yet valid or issued too long ago, unsuccessful status, replay, realm without IdP certificate), and SAML identity mapping tests (NameID or single-valued subject attribute, transient NameID, lowercasing, roles, allowlisted extra claims, size limit), and end-to-end SAML tests over HTTPS (signed redirect verified with the SP certificate, fake-IdP login producing a session with the mapped subject, scheme and roles, replay, login-CSRF browser binding, return-URL allowlist, SP metadata, realms without SAML, no `/saml` routes without a signing key). diff --git a/admin-ui/tests/unit/setup.ts b/admin-ui/tests/unit/setup.ts index 53f9b7d7..67e1a9de 100644 --- a/admin-ui/tests/unit/setup.ts +++ b/admin-ui/tests/unit/setup.ts @@ -10,8 +10,11 @@ afterEach(async () => { // async work; draining rounds covers all known cases (bumped from five to // eight after a recurrence in RealmContext.test.tsx, then to sixteen after // a recurrence in CredentialModal.test.tsx's Form.List-driven claim editor). - // If "window is not defined" returns, increase the loop count. - for (let i = 0; i < 16; i++) { + // A fixed round count is load-dependent (it recurred on slow CI runners), so also keep + // yielding for a minimum wall-clock time, which lets short timers (antd/rc-motion, + // form validation) fire and the scheduler go idle before jsdom is torn down. + const settleUntil = Date.now() + 50; + for (let i = 0; i < 16 || Date.now() < settleUntil; i++) { await new Promise((resolve) => setImmediate(() => resolve())); } vi.restoreAllMocks(); From ab5cdf780211f2556a00abf8bc79090eb85c1e6a Mon Sep 17 00:00:00 2001 From: charming_wicket <235307685+charming-wicket-5502@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:46:29 +0200 Subject: [PATCH 11/19] Pipeline unit fix test attempt and updated and executed manual testing plan --- CHANGELOG/feat_saml-integration.md | 2 +- admin-ui/tests/unit/setup.ts | 12 +++++ server/saml_testing.md | 74 +++++++++++++++++++++--------- 3 files changed, 66 insertions(+), 22 deletions(-) diff --git a/CHANGELOG/feat_saml-integration.md b/CHANGELOG/feat_saml-integration.md index c8b59c74..1ea798d2 100644 --- a/CHANGELOG/feat_saml-integration.md +++ b/CHANGELOG/feat_saml-integration.md @@ -24,7 +24,7 @@ ## Tests -- Make the admin UI unit-test teardown wait at least 50 ms (not just 16 event-loop turns) before jsdom is destroyed, so slow CI runners no longer fail the run with an unhandled `window is not defined` raised by React's scheduler after `RealmFormDrawer.test.tsx`. +- Stop the admin UI unit tests from failing CI with an unhandled `window is not defined` by making antd's static `message.*` toasts no-ops in the test setup (they mount a React root outside Testing Library's cleanup that flushed after jsdom teardown), and keep a 50 ms minimum settle time in the teardown. - Add a SAML request store test suite that runs against in-memory SQLite by default and against PostgreSQL, MySQL or Redis through the same `TEST_SESSIONS_STORE` switch as the session-store tests (single-use and concurrent takes, realm scoping, expiry on write and on read, replay detection, case-sensitive and long IDs, expiry purge), an xmlsec smoke test that exercises libxml2, xmlsec and its OpenSSL backend end to end, an API test checking that SAML settings are refused without the `saml` feature, unit tests for every SAML settings validation rule (prefixed and unprefixed metadata), and an API test checking that valid settings are stored with metadata-derived IdP fields while invalid ones get a field-specific 400, plus unit tests for the SAML signing-key checks and API tests checking that SAML realms are refused without a signing key and that a mismatched key, or the key section on a build without `saml`, stops the server from starting. - Add a fake SAML IdP test helper (`tests::saml_idp`) that renders ``s answering our `AuthnRequest`s with every validated field adjustable and signs the assertion and/or the response through xmlsec with RSA-SHA256 and SHA-256 digests, and switch the test IdP metadata to that IdP's RSA certificate, so the upcoming ACS validation can be tested against real signatures, untrusted keys and post-signing tampering. - Add SAML response validation tests that break one property at a time (signature missing, untrusted or broken by tampering, injected unsigned assertion, wrong request, issuer, recipient or audience, missing audience restriction or `AuthnStatement`, expired, not yet valid or issued too long ago, unsuccessful status, replay, realm without IdP certificate), and SAML identity mapping tests (NameID or single-valued subject attribute, transient NameID, lowercasing, roles, allowlisted extra claims, size limit), and end-to-end SAML tests over HTTPS (signed redirect verified with the SP certificate, fake-IdP login producing a session with the mapped subject, scheme and roles, replay, login-CSRF browser binding, return-URL allowlist, SP metadata, realms without SAML, no `/saml` routes without a signing key). diff --git a/admin-ui/tests/unit/setup.ts b/admin-ui/tests/unit/setup.ts index 67e1a9de..4e678ddf 100644 --- a/admin-ui/tests/unit/setup.ts +++ b/admin-ui/tests/unit/setup.ts @@ -1,7 +1,19 @@ import "@testing-library/jest-dom/vitest"; import { cleanup } from "@testing-library/react"; +import { message } from "antd"; import { afterEach, vi } from "vitest"; +// antd's static `message.*` mounts its own React root outside Testing Library's cleanup, so its +// commits and passive-effect flushes can fire after jsdom is torn down ("window is not defined"). +// No test asserts on these toasts, so make them no-ops. Assigned directly because the tests' +// vi.restoreAllMocks() would undo a vi.spyOn. +{ + const noop = () => Object.assign(() => {}, { then: (resolve?: () => void) => Promise.resolve().then(resolve) }); + for (const method of ["success", "error", "warning", "info", "loading", "open"] as const) { + (message as unknown as Record)[method] = noop; + } +} + afterEach(async () => { cleanup(); // React 19 + scheduler@0.27 chains setImmediate callbacks: diff --git a/server/saml_testing.md b/server/saml_testing.md index 4d0b2df3..a737bcf5 100644 --- a/server/saml_testing.md +++ b/server/saml_testing.md @@ -14,7 +14,7 @@ admin UI** (configuring a realm for SAML). The product documentation is in | Tool | Why | |------|-----| -| [Nix](https://nixos.org/download) | The SAML build links the C libraries xmlsec/libxml2, which the repo's `nix-shell` provides. Only needed to **build**; the built server runs anywhere. | +| [Nix](https://nixos.org/download) | The SAML build links the C libraries xmlsec/libxml2, which the repo's `nix-shell` provides. Only needed to **build**; the built server runs anywhere. If `nix-shell` is "command not found" after installing, see [If something goes wrong](#if-something-goes-wrong). | | Docker | Runs a local identity provider (Keycloak). | | Node 22.12+ and pnpm | Builds the admin UI. | | `curl`, `openssl` | Used in the checks. | @@ -48,7 +48,7 @@ Release-style build, if you want it: `nix-build -A auth-verifier-static-saml`. ## 3. Start the server (terminal 1) The development config already has a commented SAML signing-key block. Enable it in a copy, -with a fresh database: +with a fresh database. Do this only while no server is running: ```bash rm -f /tmp/saml-guide.db @@ -57,11 +57,20 @@ sed -e 's/^# \[saml_sp_params\]/[saml_sp_params]/' \ -e 's/^# saml_certificate/saml_certificate/' \ -e 's#sqlite:///tmp/path.db#sqlite:///tmp/saml-guide.db#' \ server/auth_verifier.dev.toml > /tmp/saml-guide.toml +``` + +Start the server: +```bash ./target/debug/auth_verifier /tmp/saml-guide.toml ``` -Wait until this answers (a debug build takes a while to start): +**This command does not return.** The server runs in this terminal, printing log lines, until +you press Ctrl-C. That is expected: leave it running and use a second terminal for everything +else. To stop or restart it, press Ctrl-C here first (never delete `/tmp/saml-guide.db` while +it runs). + +From the second terminal, check that it answers (a debug build needs a little while to start): ```bash curl -sk https://127.0.0.1:8443/public/version @@ -126,13 +135,23 @@ docker run -d --rm --name kc-saml-demo -p 9443:9443 \ quay.io/keycloak/keycloak:26.4 start-dev --import-realm ``` -The first run downloads the image. Wait until this saves the IdP's metadata (this is what -you'll paste into the admin UI): +The first run downloads the image and Keycloak needs a few seconds to start, so the command +below fails (`curl: (35)` or `(7)`) until it is ready; just rerun it. It saves the IdP's +metadata, which you will paste into the admin UI: ```bash curl -sk https://127.0.0.1:9443/realms/demo/protocol/saml/descriptor -o /tmp/idp-metadata.xml && wc -c /tmp/idp-metadata.xml ``` +When you paste it in step 5, copy it **from this file** (`cat /tmp/idp-metadata.xml`, or open it +in an editor), not from the descriptor URL in a browser. A browser shows the XML as a tree, and +copying from that view loses the `xmlns:` declarations on the first line, which makes the +admin UI reject it as "not well-formed XML". + +Use the **`demo`** realm created by the import, exactly as written above. Do not create a +Keycloak realm by hand: it would have no SAML client for your server and no user, and sign-in +would fail with "Invalid requester". + Open in your browser once and accept its certificate warning too (the IdP has its own address, so it needs its own exception). The Keycloak admin console is at `https://127.0.0.1:9443/admin` (`admin` / `admin`), the demo user is `alice` / `alice-pw`. @@ -141,22 +160,30 @@ Open in your browser once and accept its certificate w 1. Sign in at as `admin` / `change_me`. 2. **Realms → Create Realm**, Realm ID **`sso-demo`** (it must be exactly this: the IdP was set - up for it). -3. Tick **SAML 2.0 (single sign-on)** and fill in: - - | Field | Value | - |-------|-------| - | IdP metadata XML | The content of `/tmp/idp-metadata.xml` | - | SP entity ID / ACS URL | Already prefilled: `https://127.0.0.1:8443/saml/sso-demo` and `…/acs` | - | Subject attribute | `username` | - | Role attribute | `groups` | - | Extra claims | Add mapping: attribute `email`, claim `mail` | - | Allowed return origins | `https://127.0.0.1:8443` (press Enter) | - | Default return URL | `https://127.0.0.1:8443/whoami?realm=sso-demo` | - -4. As soon as the metadata is pasted, a **summary panel** appears: IdP entity ID - `https://127.0.0.1:9443/realms/demo`, the sign-in URL and the signing certificate with a - green validity tag. **Create**. + up for it). Two different realms are involved: `sso-demo` is the realm on **your server**, + `demo` is the realm inside **Keycloak** (the IdP). +3. Tick **SAML 2.0 (single sign-on)**. The SAML settings appear in the drawer, in the order of + the table below. **Scroll down inside the drawer**: the last section, "After sign-in", + holds two **required** fields that are easy to miss. +4. Fill in the fields, then click **Create** at the bottom of the drawer. + +| Drawer section | Field | Value | Required | +|----------------|-------|-------|----------| +| (top) | Realm ID | `sso-demo` | Yes | +| IdP metadata | IdP metadata XML | The content of `/tmp/idp-metadata.xml` | Yes | +| This server | SP entity ID | Prefilled: `https://127.0.0.1:8443/saml/sso-demo` | Yes | +| This server | Assertion Consumer Service URL | Prefilled: `https://127.0.0.1:8443/saml/sso-demo/acs` | Yes | +| Identity mapping | Subject attribute | `username` | No, but needed here (see below) | +| Identity mapping | Role attribute | `groups` | No | +| Identity mapping | Extra claims | Click **Add claim mapping**: attribute `email`, claim `mail` | No | +| After sign-in | **Allowed return origins** | `https://127.0.0.1:8443`, then press **Enter** so it becomes a tag | **Yes** | +| After sign-in | **Default return URL** | `https://127.0.0.1:8443/whoami?realm=sso-demo` | **Yes** | + +Notes: + +- As soon as the metadata is pasted, a **summary panel** appears: IdP entity ID `https://127.0.0.1:9443/realms/demo`, the sign-in URL and the signing certificate with a green validity tag. +- **The Create button stays greyed out, with no message, until every required field is filled.** If it won't enable, check the two "After sign-in" fields at the bottom first. The default return URL must be under one of the allowed origins. +- Without a subject attribute, the user name would be Keycloak's opaque persistent NameID (something like `G-d56e48b8-…`) instead of `alice`. The realm card now shows a **SAML** tag. Reopen it with **Edit**: the **Download SP metadata** button gives the file an IdP administrator would import. @@ -239,6 +266,11 @@ with `saml_certificate does not match saml_rsa_private_key`. Undo the change aft | Symptom | Likely cause | |---------|--------------| +| The admin UI says the metadata is "not well-formed XML" | The XML was copied from a browser page, which drops the `xmlns:` declarations on the first line (the real document starts with ` Date: Fri, 2 Oct 2026 16:11:35 +0200 Subject: [PATCH 12/19] Automated pipeline e2e tests attempt --- .../test/saml_e2e/keycloak-realm.json.tmpl | 56 +++++ .github/scripts/test/saml_e2e/stack.sh | 206 ++++++++++++++++++ .github/scripts/test/test_saml_e2e.sh | 116 ++++++++++ .github/workflows/saml-e2e.yml | 123 +++++++++++ .mise/tasks/test/saml-e2e | 7 + AGENTS.md | 3 + CHANGELOG/feat_saml-integration.md | 2 + e2e/.gitignore | 3 + e2e/.prettierignore | 4 + e2e/.prettierrc.json | 6 + e2e/package.json | 18 ++ e2e/playwright.config.ts | 22 ++ e2e/pnpm-lock.yaml | 77 +++++++ e2e/tests/admin-ui.spec.ts | 58 +++++ e2e/tests/saml-login.spec.ts | 65 ++++++ e2e/tests/support.ts | 68 ++++++ e2e/tsconfig.json | 13 ++ server/saml_testing.md | 6 + 18 files changed, 853 insertions(+) create mode 100644 .github/scripts/test/saml_e2e/keycloak-realm.json.tmpl create mode 100755 .github/scripts/test/saml_e2e/stack.sh create mode 100755 .github/scripts/test/test_saml_e2e.sh create mode 100644 .github/workflows/saml-e2e.yml create mode 100755 .mise/tasks/test/saml-e2e create mode 100644 e2e/.gitignore create mode 100644 e2e/.prettierignore create mode 100644 e2e/.prettierrc.json create mode 100644 e2e/package.json create mode 100644 e2e/playwright.config.ts create mode 100644 e2e/pnpm-lock.yaml create mode 100644 e2e/tests/admin-ui.spec.ts create mode 100644 e2e/tests/saml-login.spec.ts create mode 100644 e2e/tests/support.ts create mode 100644 e2e/tsconfig.json diff --git a/.github/scripts/test/saml_e2e/keycloak-realm.json.tmpl b/.github/scripts/test/saml_e2e/keycloak-realm.json.tmpl new file mode 100644 index 00000000..7600b7d0 --- /dev/null +++ b/.github/scripts/test/saml_e2e/keycloak-realm.json.tmpl @@ -0,0 +1,56 @@ +{ + "realm": "demo", + "enabled": true, + "sslRequired": "none", + "groups": [{ "name": "admins" }, { "name": "users" }], + "users": [ + { + "username": "alice", + "email": "alice@example.com", + "emailVerified": true, + "firstName": "Alice", + "lastName": "Tester", + "enabled": true, + "groups": ["/admins", "/users"], + "credentials": [{ "type": "password", "value": "alice-pw", "temporary": false }] + } + ], + "clients": [ + { + "clientId": "${SP_ORIGIN}/saml/${SP_REALM}", + "protocol": "saml", + "enabled": true, + "redirectUris": ["${SP_ORIGIN}/saml/${SP_REALM}/acs"], + "attributes": { + "saml_assertion_consumer_url_post": "${SP_ORIGIN}/saml/${SP_REALM}/acs", + "saml.assertion.signature": "true", + "saml.server.signature": "false", + "saml.client.signature": "true", + "saml.signature.algorithm": "RSA_SHA256", + "saml.signing.certificate": "${SP_CERT}", + "saml.authnstatement": "true", + "saml_name_id_format": "username" + }, + "protocolMappers": [ + { + "name": "username", + "protocol": "saml", + "protocolMapper": "saml-user-property-mapper", + "config": { "user.attribute": "username", "attribute.name": "username", "attribute.nameformat": "Basic" } + }, + { + "name": "email", + "protocol": "saml", + "protocolMapper": "saml-user-property-mapper", + "config": { "user.attribute": "email", "attribute.name": "email", "attribute.nameformat": "Basic" } + }, + { + "name": "groups", + "protocol": "saml", + "protocolMapper": "saml-group-membership-mapper", + "config": { "attribute.name": "groups", "attribute.nameformat": "Basic", "full.path": "false", "single": "false" } + } + ] + } + ] +} diff --git a/.github/scripts/test/saml_e2e/stack.sh b/.github/scripts/test/saml_e2e/stack.sh new file mode 100755 index 00000000..54a01823 --- /dev/null +++ b/.github/scripts/test/saml_e2e/stack.sh @@ -0,0 +1,206 @@ +#!/usr/bin/env bash +# Start/stop the SAML end-to-end stack: the SAML auth_verifier image (SP) next to a +# Keycloak IdP, both published on 127.0.0.1 so a browser on the host can reach them. +# +# Usage: stack.sh up|down|logs +# +# Env: +# DOCKER_IMAGE_NAME SAML auth_verifier image (default: newest local cosmian-auth-verifier:*-saml) +# KEYCLOAK_IMAGE Keycloak image (default: pinned digest below) +# SAML_E2E_DIR work dir for keys, configs and metadata (default: /tmp/saml-e2e) +# SP_PORT, IDP_PORT host ports (default: 8443, 9443) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Multi-arch (amd64 + arm64) index digest of quay.io/keycloak/keycloak:26.4 (26.4.7). +KEYCLOAK_IMAGE="${KEYCLOAK_IMAGE:-quay.io/keycloak/keycloak:26.4@sha256:9409c59bdfb65dbffa20b11e6f18b8abb9281d480c7ca402f51ed3d5977e6007}" +WORK_DIR="${SAML_E2E_DIR:-/tmp/saml-e2e}" +SP_PORT="${SP_PORT:-8443}" +IDP_PORT="${IDP_PORT:-9443}" +SP_CONTAINER=saml-e2e-sp +IDP_CONTAINER=saml-e2e-idp + +export SP_ORIGIN="https://127.0.0.1:${SP_PORT}" +export SP_REALM="${SP_REALM:-sso-demo}" +# Realm created through the admin UI by the e2e tests; Keycloak only needs its client. +SP_UI_REALM="${SP_UI_REALM:-sso-ui}" +IDP_ORIGIN="https://127.0.0.1:${IDP_PORT}" +IDP_METADATA_URL="${IDP_ORIGIN}/realms/demo/protocol/saml/descriptor" + +resolve_sp_image() { + if [ -n "${DOCKER_IMAGE_NAME:-}" ]; then + echo "$DOCKER_IMAGE_NAME" + return + fi + local image + image=$(docker images --format '{{.Repository}}:{{.Tag}}' cosmian-auth-verifier | grep -- '-saml$' | head -n1 || true) + if [ -z "$image" ]; then + echo "ERROR: no cosmian-auth-verifier:*-saml image found; run 'mise run docker:load -- --variant saml' or set DOCKER_IMAGE_NAME" >&2 + exit 1 + fi + echo "$image" +} + +generate_keys() { + if [ -d "$WORK_DIR" ] && [ -n "$(ls -A "$WORK_DIR")" ] && [ ! -f "$WORK_DIR/.saml-e2e" ]; then + echo "ERROR: $WORK_DIR is not empty and was not created by this script" >&2 + exit 1 + fi + rm -rf "$WORK_DIR" + mkdir -p "$WORK_DIR" + touch "$WORK_DIR/.saml-e2e" + # Both containers run as uid 1000 and must read these throw-away files. + chmod 755 "$WORK_DIR" + openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:prime256v1 -out "$WORK_DIR/tls.key.pem" + openssl req -new -x509 -key "$WORK_DIR/tls.key.pem" -out "$WORK_DIR/tls.cert.pem" -days 2 \ + -subj "/CN=127.0.0.1" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" + openssl req -x509 -newkey rsa:3072 -sha256 -days 2 -nodes -subj "/CN=saml-e2e-sp" \ + -keyout "$WORK_DIR/saml-sp.key.pem" -out "$WORK_DIR/saml-sp.cert.pem" 2>/dev/null +} + +write_sp_config() { + cat >"$WORK_DIR/auth_verifier.toml" <"$WORK_DIR/kc-$realm.json" + rendered+=("$WORK_DIR/kc-$realm.json") + done + jq -s '.[0] + {clients: [.[].clients[]]}' "${rendered[@]}" >"$WORK_DIR/demo-realm.json" + rm -f "${rendered[@]}" +} + +wait_for() { + local name="$1" url="$2" container="$3" tries="${4:-90}" + echo "Waiting for $name ($url)…" + for i in $(seq 1 "$tries"); do + if curl -ksf -o /dev/null "$url"; then + echo "$name is ready (attempt $i)" + return 0 + fi + if ! docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null | grep -q true; then + echo "ERROR: $container exited prematurely" >&2 + docker logs "$container" 2>&1 | tail -n 100 >&2 || true + return 1 + fi + sleep 2 + done + echo "ERROR: $name not ready after $((tries * 2)) s" >&2 + docker logs "$container" 2>&1 | tail -n 100 >&2 || true + return 1 +} + +seed_sp_realm() { + local jar="$WORK_DIR/admin.cookies" code + code=$(curl -ks -o /dev/null -w '%{http_code}' -c "$jar" -X POST \ + -H "Authorization: Basic $(printf 'admin:change_me' | base64)" \ + -H "Content-Type: application/json" -d '{}' "$SP_ORIGIN/login?realm=_") + [ "$code" = 200 ] || { echo "ERROR: admin login returned $code" >&2; return 1; } + + jq -n --rawfile metadata "$WORK_DIR/idp-metadata.xml" \ + --arg realm "$SP_REALM" --arg origin "$SP_ORIGIN" '{ + id: $realm, + auth_params: { + saml_params: { + metadata_xml: $metadata, + sp_entity_id: "\($origin)/saml/\($realm)", + sp_acs_url: "\($origin)/saml/\($realm)/acs", + subject_attribute: "username", + role_attribute: "groups", + attribute_claim_map: { email: "mail" }, + allowed_return_origins: [$origin], + default_return_url: "\($origin)/whoami?realm=\($realm)" + } + }, + session_max_age_seconds: 3600, + session_max_stale_age_seconds: 3600 + }' >"$WORK_DIR/sp-realm.json" + + code=$(curl -ks -o "$WORK_DIR/sp-realm.response" -w '%{http_code}' -b "$jar" -X POST \ + -H "Content-Type: application/json" --data-binary "@$WORK_DIR/sp-realm.json" \ + "$SP_ORIGIN/admins/realms") + case "$code" in + 2*) echo "Created SP realm $SP_REALM" ;; + *) + echo "ERROR: creating realm $SP_REALM returned $code: $(cat "$WORK_DIR/sp-realm.response")" >&2 + return 1 + ;; + esac +} + +up() { + local sp_image + sp_image=$(resolve_sp_image) + down + generate_keys + write_sp_config + write_idp_realm + chmod 644 "$WORK_DIR"/* + + echo "Starting IdP: $KEYCLOAK_IMAGE" + docker run -d --name "$IDP_CONTAINER" -p "127.0.0.1:${IDP_PORT}:${IDP_PORT}" \ + -e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \ + -e KC_HTTPS_PORT="$IDP_PORT" -e KC_HOSTNAME="$IDP_ORIGIN" \ + -e KC_HTTPS_CERTIFICATE_FILE=/conf/tls.cert.pem \ + -e KC_HTTPS_CERTIFICATE_KEY_FILE=/conf/tls.key.pem \ + -v "$WORK_DIR:/conf:ro" \ + -v "$WORK_DIR/demo-realm.json:/opt/keycloak/data/import/demo-realm.json:ro" \ + "$KEYCLOAK_IMAGE" start-dev --import-realm >/dev/null + + echo "Starting SP: $sp_image" + docker run -d --name "$SP_CONTAINER" -p "127.0.0.1:${SP_PORT}:${SP_PORT}" \ + -v "$WORK_DIR:/conf:ro" -e AUTH_SERVER_CONF=/conf/auth_verifier.toml \ + "$sp_image" >/dev/null + + wait_for "SP" "$SP_ORIGIN/public/version" "$SP_CONTAINER" 30 + wait_for "IdP" "$IDP_METADATA_URL" "$IDP_CONTAINER" 90 + curl -ksSf -o "$WORK_DIR/idp-metadata.xml" "$IDP_METADATA_URL" + seed_sp_realm + echo "SAML e2e stack is up: SP $SP_ORIGIN, IdP $IDP_ORIGIN (work dir $WORK_DIR)" +} + +down() { + docker rm -f "$SP_CONTAINER" "$IDP_CONTAINER" >/dev/null 2>&1 || true +} + +logs() { + for c in "$SP_CONTAINER" "$IDP_CONTAINER"; do + echo "──── docker logs $c ────" + docker logs "$c" 2>&1 | tail -n 200 || true + done +} + +case "${1:-}" in + up) up ;; + down) down ;; + logs) logs ;; + *) + echo "Usage: $0 up|down|logs" >&2 + exit 2 + ;; +esac diff --git a/.github/scripts/test/test_saml_e2e.sh b/.github/scripts/test/test_saml_e2e.sh new file mode 100755 index 00000000..d6df5568 --- /dev/null +++ b/.github/scripts/test/test_saml_e2e.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# SAML end-to-end tests: the SAML auth_verifier Docker image as SP against a real Keycloak IdP. +# Brings the stack up (saml_e2e/stack.sh), runs HTTP-level checks and the Playwright browser +# tests in e2e/, then tears it down. +# +# Env: see saml_e2e/stack.sh; SAML_E2E_KEEP=1 leaves the stack running afterwards; +# SAML_E2E_SKIP_BROWSER=1 skips the Playwright tests (needs pnpm and a Playwright Chromium). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +STACK="$SCRIPT_DIR/saml_e2e/stack.sh" + +SP_PORT="${SP_PORT:-8443}" +IDP_PORT="${IDP_PORT:-9443}" +SP_REALM="${SP_REALM:-sso-demo}" +SP="https://127.0.0.1:${SP_PORT}" +IDP_SSO_URL="https://127.0.0.1:${IDP_PORT}/realms/demo/protocol/saml" + +PASS=0 +FAIL=0 + +cleanup() { + local status=$? + if [ "$status" -ne 0 ] || [ "$FAIL" -gt 0 ]; then + bash "$STACK" logs + fi + if [ "${SAML_E2E_KEEP:-}" = 1 ]; then + echo "SAML_E2E_KEEP=1: stack left running; stop it with: bash $STACK down" + else + bash "$STACK" down + fi +} +trap cleanup EXIT + +assert_eq() { + local label="$1" expected="$2" actual="$3" + if [ "$actual" = "$expected" ]; then + echo " PASS $label (got: $actual)" + PASS=$((PASS + 1)) + else + echo " FAIL $label (expected: $expected, got: $actual)" + FAIL=$((FAIL + 1)) + fi +} + +assert_contains() { + local label="$1" needle="$2" haystack="$3" + if grep -qF -- "$needle" <<<"$haystack"; then + echo " PASS $label" + PASS=$((PASS + 1)) + else + echo " FAIL $label (expected to contain '$needle', got: ${haystack:0:300})" + FAIL=$((FAIL + 1)) + fi +} + +# Status code and Location header of a request, without following redirects: " ". +status_and_location() { + curl -ks -o /dev/null -w '%{http_code} %{redirect_url}\n' "$@" +} + +bash "$STACK" up + +echo "" +echo "── SP metadata ───────────────────────────────────────────────────────" +METADATA=$(curl -ks "$SP/saml/$SP_REALM/metadata") +assert_contains "AuthnRequestsSigned=\"true\"" 'AuthnRequestsSigned="true"' "$METADATA" +assert_contains "entityID is the SP entity ID" "entityID=\"$SP/saml/$SP_REALM\"" "$METADATA" +assert_contains "ACS URL advertised" "Location=\"$SP/saml/$SP_REALM/acs\"" "$METADATA" + +echo "" +echo "── Login redirect ────────────────────────────────────────────────────" +read -r CODE LOCATION < <(status_and_location "$SP/saml/$SP_REALM/login") +assert_eq "GET /saml/$SP_REALM/login → 302" "302" "$CODE" +assert_contains "redirects to the Keycloak SSO URL" "$IDP_SSO_URL?" "$LOCATION" +assert_contains "AuthnRequest is signed (Signature)" "Signature=" "$LOCATION" +assert_contains "AuthnRequest is signed (SigAlg)" "SigAlg=" "$LOCATION" + +# Keycloak verifies the AuthnRequest signature against the SP certificate before showing +# its login form; an unsigned or badly signed request gets an error page instead. +IDP_PAGE=$(curl -ks "$LOCATION") +assert_contains "Keycloak accepts the signed AuthnRequest (login form shown)" 'id="kc-form-login"' "$IDP_PAGE" + +echo "" +echo "── Return URL allowlist ──────────────────────────────────────────────" +read -r CODE _ < <(status_and_location -G --data-urlencode "return_to=$SP/whoami?realm=$SP_REALM" "$SP/saml/$SP_REALM/login") +assert_eq "allowed return_to → 302" "302" "$CODE" +read -r CODE _ < <(status_and_location -G --data-urlencode "return_to=https://evil.example.com/" "$SP/saml/$SP_REALM/login") +assert_eq "foreign return_to → 400" "400" "$CODE" + +echo "" +echo "── ACS refusals ──────────────────────────────────────────────────────" +CODE=$(curl -ks -o /dev/null -w '%{http_code}' -X POST -d 'SAMLResponse=eA==&RelayState=x' "$SP/saml/$SP_REALM/acs") +assert_eq "ACS without the login cookie → 401" "401" "$CODE" + +echo "" +echo "── Realm without SAML ────────────────────────────────────────────────" +CODE=$(curl -ks -o /dev/null -w '%{http_code}' "$SP/saml/_/login") +assert_eq "GET /saml/_/login → 400" "400" "$CODE" + +echo "" +echo "==========================================" +echo "HTTP checks: $PASS passed, $FAIL failed" +echo "==========================================" + +BROWSER_STATUS=0 +if [ "${SAML_E2E_SKIP_BROWSER:-}" != 1 ]; then + echo "" + echo "── Browser tests (Playwright) ────────────────────────────────────────" + pnpm --dir "$REPO_ROOT/e2e" install --frozen-lockfile + SP_PORT="$SP_PORT" IDP_PORT="$IDP_PORT" SP_REALM="$SP_REALM" \ + pnpm --dir "$REPO_ROOT/e2e" exec playwright test || BROWSER_STATUS=$? +fi + +[ "$FAIL" -eq 0 ] && [ "$BROWSER_STATUS" -eq 0 ] diff --git a/.github/workflows/saml-e2e.yml b/.github/workflows/saml-e2e.yml new file mode 100644 index 00000000..66857b4a --- /dev/null +++ b/.github/workflows/saml-e2e.yml @@ -0,0 +1,123 @@ +--- +name: SAML e2e + +on: + schedule: + - cron: "0 3 * * 1" # Mondays 03:00 UTC + workflow_dispatch: + push: + branches: + - main + - develop + paths: + - "server/src/saml/**" + - "server/Cargo.toml" + - "client/src/params/saml_params.rs" + - "client/src/params/realm_auth_params.rs" + - "admin-ui/src/components/realms/Saml*" + - "admin-ui/src/utils/saml*" + - "default.nix" + - "nix/auth-verifier.nix" + - "nix/docker.nix" + - "nix/xmlsec-static.nix" + - "e2e/**" + - ".github/scripts/test/saml_e2e/**" + - ".github/scripts/test/test_saml_e2e.sh" + - ".mise/tasks/test/saml-e2e" + - ".github/workflows/saml-e2e.yml" + pull_request: + paths: + - "server/src/saml/**" + - "server/Cargo.toml" + - "client/src/params/saml_params.rs" + - "client/src/params/realm_auth_params.rs" + - "admin-ui/src/components/realms/Saml*" + - "admin-ui/src/utils/saml*" + - "default.nix" + - "nix/auth-verifier.nix" + - "nix/docker.nix" + - "nix/xmlsec-static.nix" + - "e2e/**" + - ".github/scripts/test/saml_e2e/**" + - ".github/scripts/test/test_saml_e2e.sh" + - ".mise/tasks/test/saml-e2e" + - ".github/workflows/saml-e2e.yml" + +permissions: + contents: read + +concurrency: + group: saml-e2e-${{ github.ref }} + cancel-in-progress: true + +jobs: + saml-e2e: + name: SAML e2e (Keycloak) - ${{ matrix.runner }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + runner: [ubuntu-24.04, ubuntu-24.04-arm] + + steps: + - name: Nix installation + uses: cachix/install-nix-action@v31 + with: + extra_nix_config: | + connect-timeout = 15 + stalled-download-timeout = 15 + narinfo-cache-negative-ttl = 0 + + - uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Install mise + uses: jdx/mise-action@v2 + with: + version: 2026.9.2 + + - name: Warm Nix store + run: | + sed -n 's/.*url *= *"\(https:\/\/[^"]*\.tar\.gz\)".*/\1/p' default.nix | sort -u | while read -r url; do + dest="/tmp/$(basename "$url")" + fallback=$(echo "$url" | sed 's|https://package.cosmian.com/nixpkgs/|https://github.com/NixOS/nixpkgs/archive/|') + echo "Downloading $(basename "$url") …" + if ! curl --retry 3 --retry-delay 5 --retry-all-errors -fsSL -o "$dest" "$url"; then + echo "Mirror failed, falling back to GitHub …" + curl --retry 5 --retry-delay 10 --retry-max-time 300 --retry-all-errors -fsSL -o "$dest" "$fallback" + fi + nix-prefetch-url --unpack --name source "file://$dest" + rm -f "$dest" + done + + - name: Build and load the SAML Docker image + run: mise run docker:load -- --variant saml + env: + NIX_CURL_FLAGS: --user-agent "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36" + + - name: Install e2e dependencies and Chromium + run: | + pnpm --dir e2e install --frozen-lockfile + pnpm --dir e2e exec playwright install --with-deps chromium + + - name: Lint e2e + run: | + pnpm --dir e2e format:check + pnpm --dir e2e typecheck + + - name: Run SAML e2e tests + run: mise run test:saml-e2e + env: + CI: true + + - name: Upload Playwright results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: saml-e2e-${{ matrix.runner }} + path: | + e2e/test-results/ + e2e/playwright-report/ + retention-days: 7 diff --git a/.mise/tasks/test/saml-e2e b/.mise/tasks/test/saml-e2e new file mode 100755 index 00000000..c967d0a2 --- /dev/null +++ b/.mise/tasks/test/saml-e2e @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +#MISE description="SAML end-to-end tests: SAML Docker image against a Keycloak IdP (set DOCKER_IMAGE_NAME; SAML_E2E_KEEP=1 keeps the stack up)" +set -euo pipefail +source "${MISE_CONFIG_ROOT}/.mise/lib/common.sh" + +print_status "SAML e2e against ${DOCKER_IMAGE_NAME:-newest local cosmian-auth-verifier:*-saml}" +exec bash "${MISE_CONFIG_ROOT}/.github/scripts/test/test_saml_e2e.sh" "$@" diff --git a/AGENTS.md b/AGENTS.md index 537ff6ad..176caf8d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,7 @@ mise run package -- --variant saml deb # SAML build: result-deb-static-saml mise run docker:load mise run docker:test mise run docker:test -- --variant saml # SAML image (tag -saml), checks the saml feature +mise run test:saml-e2e # SAML image vs a real Keycloak: curl checks + Playwright (e2e/) mise run ui:lint # admin-ui: eslint + prettier + tsc mise run ui:test # admin-ui: unit tests mise run ui:build @@ -91,6 +92,8 @@ server/ auth_verifier — server binary + lib tests/ — integration tests tls/ — TLS helpers +e2e/ Playwright SAML end-to-end tests (run by `mise run test:saml-e2e`) + nix/ Nix build expressions and expected vendor hashes auth-verifier.nix — Nix derivation for auth_verifier binary docker.nix — Docker image derivation diff --git a/CHANGELOG/feat_saml-integration.md b/CHANGELOG/feat_saml-integration.md index 1ea798d2..b8f9a00a 100644 --- a/CHANGELOG/feat_saml-integration.md +++ b/CHANGELOG/feat_saml-integration.md @@ -29,6 +29,7 @@ - Add a fake SAML IdP test helper (`tests::saml_idp`) that renders ``s answering our `AuthnRequest`s with every validated field adjustable and signs the assertion and/or the response through xmlsec with RSA-SHA256 and SHA-256 digests, and switch the test IdP metadata to that IdP's RSA certificate, so the upcoming ACS validation can be tested against real signatures, untrusted keys and post-signing tampering. - Add SAML response validation tests that break one property at a time (signature missing, untrusted or broken by tampering, injected unsigned assertion, wrong request, issuer, recipient or audience, missing audience restriction or `AuthnStatement`, expired, not yet valid or issued too long ago, unsuccessful status, replay, realm without IdP certificate), and SAML identity mapping tests (NameID or single-valued subject attribute, transient NameID, lowercasing, roles, allowlisted extra claims, size limit), and end-to-end SAML tests over HTTPS (signed redirect verified with the SP certificate, fake-IdP login producing a session with the mapped subject, scheme and roles, replay, login-CSRF browser binding, return-URL allowlist, SP metadata, realms without SAML, no `/saml` routes without a signing key). - Add admin UI tests for the SAML settings: certificate and metadata parsing and every client-side check, the realm drawer (preview, prefilled and copyable SP URLs, field-scoped client and server errors, turning SAML off), the SAML tag and expiry badge on realm cards, and a Playwright scenario creating a SAML realm from pasted metadata. +- Add SAML end-to-end tests against a real Keycloak (`mise run test:saml-e2e`, `.github/scripts/test/test_saml_e2e.sh`): `saml_e2e/stack.sh` starts the SAML Docker image next to a digest-pinned Keycloak 26.4 with throw-away TLS and SP signing keys and a templated realm import, seeds a SAML realm through the admin API from Keycloak's live metadata, and the script checks the SP metadata, that Keycloak accepts our signed `AuthnRequest`, the return-URL allowlist, the ACS login-cookie check and realms without SAML, then runs the Playwright tests of the new top-level `e2e/` package — a real login with the mapped claims and a secure `_ea_` cookie, `return_to`, a wrong Keycloak password, a replayed SAML response, Keycloak no longer signing assertions, and a SAML realm created in the admin UI from the live metadata that alice then signs in to — so the shipped image is tested against a real IdP rather than only the fake one. ## CI @@ -36,6 +37,7 @@ - Run the `cargo-saml` job on Linux x86_64, Linux aarch64 and macOS, and package the SAML variant (`static-saml`) for deb, rpm and dmg in the packaging and publish matrices (own `static-saml` publish folder, attached to GitHub releases), so the bundled xmlsec is verified on every platform we ship. - Pin `` (`-I nixpkgs=$PIN_URL`) in the `cargo-saml` job's `nix-shell` calls so the shell uses a Nix-provided bash; without it the macOS runner fell back to `/bin/bash` 3.2, which nixpkgs' stdenv rejects. - Test the SAML packages in the container and systemd package tests by starting the server with a `[saml_sp_params]` section, which a build without the `saml` feature refuses, and build, test and push a `-saml` Docker image and manifest next to the default one; `test_docker_image.sh` does the same check when `EXPECT_SAML=1`. +- Add a `SAML e2e` workflow that builds the SAML Docker image with Nix and runs `mise run test:saml-e2e` (plus the `e2e/` format and type checks) on Linux x86_64 and aarch64, weekly and on pushes and pull requests touching SAML code, uploading Playwright traces on failure. ## Docs diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 00000000..dbd64df8 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +test-results/ +playwright-report/ diff --git a/e2e/.prettierignore b/e2e/.prettierignore new file mode 100644 index 00000000..55ee1b85 --- /dev/null +++ b/e2e/.prettierignore @@ -0,0 +1,4 @@ +node_modules/ +test-results/ +playwright-report/ +pnpm-lock.yaml diff --git a/e2e/.prettierrc.json b/e2e/.prettierrc.json new file mode 100644 index 00000000..6d51a330 --- /dev/null +++ b/e2e/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "semi": true, + "singleQuote": false, + "printWidth": 140, + "tabWidth": 4 +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 00000000..a41dd3a9 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,18 @@ +{ + "name": "auth-e2e", + "private": true, + "version": "0.4.0", + "type": "module", + "scripts": { + "test": "playwright test", + "typecheck": "tsc --noEmit", + "format": "prettier . --write", + "format:check": "prettier . --check" + }, + "devDependencies": { + "@playwright/test": "1.63.0", + "@types/node": "^26.5.1", + "prettier": "^3.8.1", + "typescript": "~6.0.3" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 00000000..e4e09341 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, devices } from "@playwright/test"; +import { SP_ORIGIN } from "./tests/support"; + +// Runs against the stack started by .github/scripts/test/saml_e2e/stack.sh; no web server here. +export default defineConfig({ + testDir: "./tests", + timeout: 60_000, + retries: process.env.CI ? 1 : 0, + // Tests share one server and one Keycloak and some change their settings. + workers: 1, + reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : "list", + use: { + baseURL: SP_ORIGIN, + ignoreHTTPSErrors: true, + headless: true, + screenshot: "only-on-failure", + trace: "retain-on-failure", + actionTimeout: 15_000, + navigationTimeout: 30_000, + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], +}); diff --git a/e2e/pnpm-lock.yaml b/e2e/pnpm-lock.yaml new file mode 100644 index 00000000..f52d1f01 --- /dev/null +++ b/e2e/pnpm-lock.yaml @@ -0,0 +1,77 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@playwright/test': + specifier: 1.63.0 + version: 1.63.0 + '@types/node': + specifier: ^26.5.1 + version: 26.6.4 + prettier: + specifier: ^3.8.1 + version: 3.9.9 + typescript: + specifier: ~6.0.3 + version: 6.0.3 + +packages: + + '@playwright/test@1.63.0': + resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==} + engines: {node: '>=20'} + hasBin: true + + '@types/node@26.6.4': + resolution: {integrity: sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==} + + playwright-core@1.63.0: + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.63.0: + resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==} + engines: {node: '>=20'} + hasBin: true + + prettier@3.9.9: + resolution: {integrity: sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==} + engines: {node: '>=14'} + hasBin: true + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + +snapshots: + + '@playwright/test@1.63.0': + dependencies: + playwright: 1.63.0 + + '@types/node@26.6.4': + dependencies: + undici-types: 8.9.0 + + playwright-core@1.63.0: {} + + playwright@1.63.0: + dependencies: + playwright-core: 1.63.0 + + prettier@3.9.9: {} + + typescript@6.0.3: {} + + undici-types@8.9.0: {} diff --git a/e2e/tests/admin-ui.spec.ts b/e2e/tests/admin-ui.spec.ts new file mode 100644 index 00000000..02f54438 --- /dev/null +++ b/e2e/tests/admin-ui.spec.ts @@ -0,0 +1,58 @@ +import { expect, test } from "@playwright/test"; +import { ADMIN, IDP_ENTITY_ID, IDP_METADATA_URL, SP_ORIGIN, UI_REALM, signInAsAlice } from "./support"; + +test.describe("Admin UI against the real server and IdP", () => { + let idpMetadata = ""; + + test.beforeAll(async ({ playwright }) => { + const api = await playwright.request.newContext({ baseURL: SP_ORIGIN, ignoreHTTPSErrors: true }); + idpMetadata = await (await api.get(IDP_METADATA_URL)).text(); + // Make reruns against a kept-up stack (SAML_E2E_KEEP=1) start clean. + const login = await api.post("/login?realm=_", { + headers: { Authorization: `Basic ${Buffer.from(`${ADMIN.username}:${ADMIN.password}`).toString("base64")}` }, + data: {}, + }); + expect(login.status()).toBe(200); + await api.delete(`/admins/realms/${UI_REALM}`); + await api.dispose(); + }); + + test("an admin configures a SAML realm from the IdP metadata and alice signs in through it", async ({ page, browser }) => { + await page.goto("/admin-ui/"); + await page.getByPlaceholder("Username").fill(ADMIN.username); + await page.getByPlaceholder("Password").fill(ADMIN.password); + await page.getByRole("button", { name: "Login" }).click(); + await page.goto("/admin-ui/realms"); + + await page.getByRole("button", { name: "Create Realm" }).click(); + await page.getByLabel("Realm ID").fill(UI_REALM); + await page.getByRole("checkbox", { name: "SAML 2.0 (single sign-on)" }).check(); + await page.getByLabel("IdP metadata XML").fill(idpMetadata); + await expect(page.getByLabel("Parsed IdP metadata").getByText(IDP_ENTITY_ID, { exact: true })).toBeVisible(); + await expect(page.getByLabel("SP entity ID", { exact: true })).toHaveValue(`${SP_ORIGIN}/saml/${UI_REALM}`); + await expect(page.getByLabel("Assertion Consumer Service URL", { exact: true })).toHaveValue(`${SP_ORIGIN}/saml/${UI_REALM}/acs`); + + await page.getByLabel("Subject attribute").fill("username"); + await page.getByLabel("Role attribute").fill("groups"); + await page.getByRole("button", { name: "Add claim mapping" }).click(); + await page.getByLabel("SAML attribute").fill("email"); + await page.getByLabel("Claim name").fill("mail"); + const origins = page.getByLabel("Allowed return origins"); + await origins.fill(SP_ORIGIN); + await origins.press("Enter"); + await page.getByLabel("Default return URL").fill(`${SP_ORIGIN}/whoami?realm=${UI_REALM}`); + await page.getByRole("button", { name: "Create" }).last().click(); + + const card = page.locator(".ant-card").filter({ hasText: UI_REALM }); + await expect(card.getByText("SAML", { exact: true })).toBeVisible(); + + const context = await browser.newContext({ baseURL: SP_ORIGIN, ignoreHTTPSErrors: true }); + try { + const claims = await signInAsAlice(await context.newPage(), UI_REALM); + expect(claims).toMatchObject({ sub: "alice", as_as: "sa", as_rid: UI_REALM, mail: "alice@example.com" }); + expect(claims.roles).toEqual(expect.arrayContaining(["admins", "users"])); + } finally { + await context.close(); + } + }); +}); diff --git a/e2e/tests/saml-login.spec.ts b/e2e/tests/saml-login.spec.ts new file mode 100644 index 00000000..337d7162 --- /dev/null +++ b/e2e/tests/saml-login.spec.ts @@ -0,0 +1,65 @@ +import { expect, test } from "@playwright/test"; +import { ALICE, SEEDED_REALM, SP_ORIGIN, acsPath, isAcsPost, setAssertionSigning, signInAsAlice, submitKeycloakLogin } from "./support"; + +test.describe("SAML login through Keycloak", () => { + test("alice signs in and gets a SAML session with her mapped claims", async ({ page }) => { + const claims = await signInAsAlice(page, SEEDED_REALM); + + expect(page.url()).toBe(`${SP_ORIGIN}/whoami?realm=${SEEDED_REALM}`); + expect(claims).toMatchObject({ sub: "alice", as_as: "sa", as_rid: SEEDED_REALM, mail: "alice@example.com" }); + expect(claims.roles).toEqual(expect.arrayContaining(["admins", "users"])); + + const session = (await page.context().cookies(SP_ORIGIN)).find((c) => c.name === "_ea_"); + expect(session).toMatchObject({ httpOnly: true, secure: true }); + }); + + test("the browser lands on the requested return URL", async ({ page }) => { + const returnTo = `${SP_ORIGIN}/public/version`; + await page.goto(`/saml/${SEEDED_REALM}/login?return_to=${encodeURIComponent(returnTo)}`); + await submitKeycloakLogin(page, ALICE.username, ALICE.password); + + await expect(page).toHaveURL(returnTo); + }); + + test("a wrong password at Keycloak creates no session", async ({ page }) => { + await page.goto(`/saml/${SEEDED_REALM}/login`); + await submitKeycloakLogin(page, ALICE.username, "wrong-password"); + + await expect(page.getByText("Invalid username or password.")).toBeVisible(); + expect((await page.context().cookies(SP_ORIGIN)).some((c) => c.name === "_ea_")).toBe(false); + }); + + test("a captured SAML response cannot be used again", async ({ page, request }) => { + const acsRequest = page.waitForRequest((r) => isAcsPost(r, SEEDED_REALM)); + await signInAsAlice(page, SEEDED_REALM); + const form = new URLSearchParams((await acsRequest).postData() ?? ""); + const relayState = form.get("RelayState") ?? ""; + expect(form.get("SAMLResponse")).toBeTruthy(); + + // Replay it with the login cookie the browser had, as an attacker who copied both would. + const replay = await request.post(acsPath(SEEDED_REALM), { + headers: { Cookie: `_ea_saml=${relayState}` }, + form: { SAMLResponse: form.get("SAMLResponse") ?? "", RelayState: relayState }, + }); + + expect(replay.status()).toBe(401); + expect(await replay.text()).toContain("unknown or expired sign-in request"); + expect(replay.headers()["set-cookie"] ?? "").not.toContain("_ea_="); + }); + + test.describe("when Keycloak stops signing assertions", () => { + test.afterEach(() => setAssertionSigning(SEEDED_REALM, true)); + + test("the login is refused", async ({ page }) => { + setAssertionSigning(SEEDED_REALM, false); + await page.goto(`/saml/${SEEDED_REALM}/login`); + const acs = page.waitForResponse((r) => isAcsPost(r.request(), SEEDED_REALM)); + await submitKeycloakLogin(page, ALICE.username, ALICE.password); + + const response = await acs; + expect(response.status()).toBe(401); + expect(await response.text()).toContain("must be signed"); + expect((await page.context().cookies(SP_ORIGIN)).some((c) => c.name === "_ea_")).toBe(false); + }); + }); +}); diff --git a/e2e/tests/support.ts b/e2e/tests/support.ts new file mode 100644 index 00000000..2c0e19ba --- /dev/null +++ b/e2e/tests/support.ts @@ -0,0 +1,68 @@ +import { execFileSync } from "node:child_process"; +import { expect, type Page, type Request } from "@playwright/test"; + +export const SP_ORIGIN = `https://127.0.0.1:${process.env.SP_PORT ?? "8443"}`; +export const IDP_ORIGIN = `https://127.0.0.1:${process.env.IDP_PORT ?? "9443"}`; +export const IDP_ENTITY_ID = `${IDP_ORIGIN}/realms/demo`; +export const IDP_METADATA_URL = `${IDP_ENTITY_ID}/protocol/saml/descriptor`; +const IDP_CONTAINER = process.env.SAML_E2E_IDP_CONTAINER ?? "saml-e2e-idp"; + +/** SAML realm created by stack.sh through the admin API. */ +export const SEEDED_REALM = process.env.SP_REALM ?? "sso-demo"; +/** SAML realm the admin UI test creates; Keycloak already has its client. */ +export const UI_REALM = process.env.SP_UI_REALM ?? "sso-ui"; + +export const ALICE = { username: "alice", password: "alice-pw" }; +export const ADMIN = { username: "admin", password: "change_me" }; + +export type Claims = Record & { sub: string; as_as: string; as_rid: string; roles?: string[] }; + +export function acsPath(realm: string): string { + return `/saml/${realm}/acs`; +} + +export function isAcsPost(request: Request, realm: string): boolean { + return request.method() === "POST" && new URL(request.url()).pathname === acsPath(realm); +} + +export async function submitKeycloakLogin(page: Page, username: string, password: string): Promise { + await expect(page).toHaveURL((url) => url.href.startsWith(`${IDP_ENTITY_ID}/`)); + await page.locator("#username").fill(username); + await page.locator("#password").fill(password); + await page.locator("#kc-login").click(); +} + +/** Start an SP-initiated login on `realm`, sign in at Keycloak as alice, and return the `/whoami` claims. */ +export async function signInAsAlice(page: Page, realm: string): Promise { + await page.goto(`/saml/${realm}/login`); + const whoami = page.waitForResponse((r) => new URL(r.url()).pathname === "/whoami"); + await submitKeycloakLogin(page, ALICE.username, ALICE.password); + const response = await whoami; + expect(response.status()).toBe(200); + return (await response.json()) as Claims; +} + +function kcadm(...args: string[]): string { + return execFileSync("docker", ["exec", IDP_CONTAINER, "/opt/keycloak/bin/kcadm.sh", ...args], { encoding: "utf8" }); +} + +/** Turn Keycloak's assertion signing on or off for the SAML client of `realm`. */ +export function setAssertionSigning(realm: string, enabled: boolean): void { + kcadm("config", "credentials", "--server", "http://localhost:8080", "--realm", "master", "--user", "admin", "--password", "admin"); + const clientId = `${SP_ORIGIN}/saml/${realm}`; + const id = kcadm( + "get", + "clients", + "-r", + "demo", + "-q", + `clientId=${clientId}`, + "--fields", + "id", + "--format", + "csv", + "--noquotes", + ).trim(); + if (!id) throw new Error(`Keycloak has no client ${clientId}`); + kcadm("update", `clients/${id}`, "-r", "demo", "-s", `attributes."saml.assertion.signature"=${enabled}`); +} diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 00000000..5c968073 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023", "DOM"], + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["node"], + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["playwright.config.ts", "tests"] +} diff --git a/server/saml_testing.md b/server/saml_testing.md index a737bcf5..ec5a04a7 100644 --- a/server/saml_testing.md +++ b/server/saml_testing.md @@ -36,6 +36,12 @@ The first `nix-shell` builds xmlsec and takes a while; later runs are quick. All pass. (The complete server suite is `cargo test --workspace --features auth_verifier/saml --lib -- --test-threads=4` and takes about ten minutes; it is not required for this guide.) +Steps 3 to 7 are also automated against the SAML Docker image and the same Keycloak setup: +`mise run docker:load -- --variant saml`, then `mise run test:saml-e2e` (Playwright tests in +`e2e/`; `SAML_E2E_KEEP=1` leaves both containers running). CI runs it weekly and on SAML changes +(`.github/workflows/saml-e2e.yml`). The manual walk-through below remains the way to look at +the UI yourself. + ## 2. Build the server and the admin UI ```bash From 1bb6987c83d3ffa70acfc483f523aa489220376d Mon Sep 17 00:00:00 2001 From: charming_wicket <235307685+charming-wicket-5502@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:28:52 +0200 Subject: [PATCH 13/19] Added helm chart for default and saml confifurations --- .pre-commit-config.yaml | 2 + CHANGELOG/feat_saml-integration.md | 1 + charts/cosmian-auth-verifier/.helmignore | 20 ++ charts/cosmian-auth-verifier/Chart.yaml | 31 ++ charts/cosmian-auth-verifier/README.md | 84 +++++ .../cosmian-auth-verifier/templates/NOTES.txt | 41 +++ .../templates/_helpers.tpl | 102 +++++++ .../templates/configmap.yaml | 83 +++++ .../templates/deployment.yaml | 271 ++++++++++++++++ .../cosmian-auth-verifier/templates/hpa.yaml | 36 +++ .../templates/ingress.yaml | 35 +++ .../templates/networkpolicy.yaml | 35 +++ .../templates/poddisruptionbudget.yaml | 24 ++ .../cosmian-auth-verifier/templates/pvc.yaml | 21 ++ .../templates/secret.yaml | 20 ++ .../templates/service.yaml | 19 ++ .../templates/serviceaccount.yaml | 13 + .../templates/tests/test-connection.yaml | 41 +++ charts/cosmian-auth-verifier/values.yaml | 289 ++++++++++++++++++ 19 files changed, 1168 insertions(+) create mode 100644 charts/cosmian-auth-verifier/.helmignore create mode 100644 charts/cosmian-auth-verifier/Chart.yaml create mode 100644 charts/cosmian-auth-verifier/README.md create mode 100644 charts/cosmian-auth-verifier/templates/NOTES.txt create mode 100644 charts/cosmian-auth-verifier/templates/_helpers.tpl create mode 100644 charts/cosmian-auth-verifier/templates/configmap.yaml create mode 100644 charts/cosmian-auth-verifier/templates/deployment.yaml create mode 100644 charts/cosmian-auth-verifier/templates/hpa.yaml create mode 100644 charts/cosmian-auth-verifier/templates/ingress.yaml create mode 100644 charts/cosmian-auth-verifier/templates/networkpolicy.yaml create mode 100644 charts/cosmian-auth-verifier/templates/poddisruptionbudget.yaml create mode 100644 charts/cosmian-auth-verifier/templates/pvc.yaml create mode 100644 charts/cosmian-auth-verifier/templates/secret.yaml create mode 100644 charts/cosmian-auth-verifier/templates/service.yaml create mode 100644 charts/cosmian-auth-verifier/templates/serviceaccount.yaml create mode 100644 charts/cosmian-auth-verifier/templates/tests/test-connection.yaml create mode 100644 charts/cosmian-auth-verifier/values.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0e88ef11..ce63aedc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -36,6 +36,7 @@ repos: hooks: - id: yamlfmt args: [--mapping, "2", --sequence, "4", --offset, "2"] + exclude: ^charts/[^/]+/templates/ - repo: https://github.com/crate-ci/typos rev: v1.31.1 @@ -83,6 +84,7 @@ repos: - id: check-vcs-permalinks - id: check-xml - id: check-yaml + exclude: ^charts/[^/]+/templates/ - id: debug-statements - id: destroyed-symlinks - id: detect-private-key diff --git a/CHANGELOG/feat_saml-integration.md b/CHANGELOG/feat_saml-integration.md index b8f9a00a..ba0e7965 100644 --- a/CHANGELOG/feat_saml-integration.md +++ b/CHANGELOG/feat_saml-integration.md @@ -17,6 +17,7 @@ - Add a separate SAML Nix build next to the unchanged default one — `auth-verifier-static-saml` (static only, compiled with `--features saml` against the bundled xmlsec, libclang taken from the glibc-2.34 package set, build failing if xmlsec/libxml2 end up dynamically linked) and `docker-image-saml` (tag `-saml`) — so SAML can be shipped as prebuilt packages and images without changing the standard artifacts; its binary hash is tracked as `static-saml`. - Add a `--variant default|saml` option to `.github/scripts/nix.sh`, the packaging scripts and the `package`, `hashes:update`, `docker:load` and `docker:test` mise tasks: SAML packages are written to `result--static-saml/` with a `-saml` file-name suffix (checksum and signature name the final file) under the same package name, and `--variant saml --link dynamic` is rejected. - Make the DMG packaging script skip `result-*` directories when collecting the built DMG, so a DMG from another build tag is not picked up again. +- Add the `charts/cosmian-auth-verifier` Helm chart, adapted from the KMS chart, which renders `auth_verifier.toml` from values, injects database and session-store URLs from Secrets at container start (so credentials never land in the ConfigMap), mounts the TLS, JWT, certificate and SAML SP key Secrets, refuses settings the server would reject at startup, and switches to the `-saml` image with `[saml_sp_params]` when `saml.enabled` is set, so both variants can be deployed on Kubernetes; Helm templates are excluded from the `check-yaml` and `yamlfmt` pre-commit hooks. ## Bug Fixes diff --git a/charts/cosmian-auth-verifier/.helmignore b/charts/cosmian-auth-verifier/.helmignore new file mode 100644 index 00000000..a6eb2fee --- /dev/null +++ b/charts/cosmian-auth-verifier/.helmignore @@ -0,0 +1,20 @@ +# Patterns to ignore when building packages. +.DS_Store +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ +ci/ diff --git a/charts/cosmian-auth-verifier/Chart.yaml b/charts/cosmian-auth-verifier/Chart.yaml new file mode 100644 index 00000000..a6d8ca8e --- /dev/null +++ b/charts/cosmian-auth-verifier/Chart.yaml @@ -0,0 +1,31 @@ +--- +apiVersion: v2 +name: cosmian-auth-verifier +description: A Helm chart for deploying the Cosmian Authentication Server (auth_verifier), with optional SAML 2.0 single sign-on, on Kubernetes. +type: application +version: 0.4.0 +appVersion: 0.4.0 +kubeVersion: '>=1.24.0-0' +home: https://github.com/Cosmian/authentication +sources: + - https://github.com/Cosmian/authentication +keywords: + - authentication + - session + - totp + - jwt + - saml + - cosmian +maintainers: + - name: Cosmian + url: https://cosmian.com +annotations: + artifacthub.io/license: BUSL-1.1 + artifacthub.io/links: | + - name: Source + url: https://github.com/Cosmian/authentication + artifacthub.io/images: | + - name: auth-verifier + image: ghcr.io/cosmian/auth-verifier:0.4.0 + - name: auth-verifier-saml + image: ghcr.io/cosmian/auth-verifier:0.4.0-saml diff --git a/charts/cosmian-auth-verifier/README.md b/charts/cosmian-auth-verifier/README.md new file mode 100644 index 00000000..51e3c71e --- /dev/null +++ b/charts/cosmian-auth-verifier/README.md @@ -0,0 +1,84 @@ +# cosmian-auth-verifier + +Helm chart for deploying the [Cosmian Authentication Server](https://github.com/Cosmian/authentication) +(`auth_verifier`) — realms, username/password, TOTP, JWT/OIDC, mTLS and +optional SAML 2.0 single sign-on — on Kubernetes. + +The chart renders `auth_verifier.toml` from `authVerifier.*` values. Database +URLs are read from a Secret and substituted when the container starts, so no +credentials end up in the ConfigMap. + +## Prerequisites + +The server terminates TLS itself. Create the TLS Secret first (certificate, +PKCS#8 private key and CA chain). Unless `authVerifier.sessionJwt` is set, the +same key signs session JWTs and must be EC P-256. With cert-manager: + +```yaml +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: auth-verifier-tls +spec: + secretName: auth-verifier-tls # contains tls.crt, tls.key, ca.crt + dnsNames: [auth.example.com] + privateKey: + algorithm: ECDSA + size: 256 + encoding: PKCS8 + issuerRef: + name: my-issuer + kind: ClusterIssuer +``` + +## Quick start + +```bash +# Default image, sqlite backend on a PVC +helm install auth ./charts/cosmian-auth-verifier \ + --set authVerifier.tls.existingSecret=auth-verifier-tls + +# Production: PostgreSQL (Secret with key "database-url") + Redis sessions +helm install auth ./charts/cosmian-auth-verifier \ + --set authVerifier.tls.existingSecret=auth-verifier-tls \ + --set authVerifier.database.backend=postgresql \ + --set authVerifier.database.existingSecret=auth-db \ + --set authVerifier.sessionStore.enabled=true \ + --set authVerifier.sessionStore.existingSecret=auth-sessions \ + --set replicaCount=2 + +# SAML: switches to the -saml image and configures the SP signing key +kubectl create secret tls auth-saml-sp --cert=saml-sp.cert.pem --key=saml-sp.key.pem +helm install auth ./charts/cosmian-auth-verifier \ + --set authVerifier.tls.existingSecret=auth-verifier-tls \ + --set saml.enabled=true \ + --set saml.existingSecret=auth-saml-sp +``` + +The SAML SP key must be RSA (>= 2048 bits), for example: + +```bash +openssl req -x509 -newkey rsa:3072 -sha256 -days 730 -nodes -subj "/CN=auth-saml-sp" \ + -keyout saml-sp.key.pem -out saml-sp.cert.pem +``` + +Per-realm IdP settings (entity ID, SSO URL, ACS URL, …) are then managed +through the API or the admin UI; see `server/documentation/docs/saml.md`. + +## Secrets reference + +| Value | Keys | +| -------------------------------------- | ----------------------------- | +| `authVerifier.tls.existingSecret` | `tls.crt`, `tls.key`, `ca.crt` (`caKey`) | +| `authVerifier.tls.clientCaSecret` | `ca.crt` (`clientCaKey`) | +| `authVerifier.sessionJwt.existingSecret` | `tls.crt`, `tls.key` (EC P-256) | +| `authVerifier.certificateJwt.existingSecret` | `tls.crt`, `tls.key` (EC P-256) | +| `authVerifier.database.existingSecret` | `database-url` | +| `authVerifier.sessionStore.existingSecret` | `session-store-url` | +| `saml.existingSecret` | `tls.crt`, `tls.key` (RSA) | + +## Source files + +- [`values.yaml`](values.yaml) — all configurable parameters and defaults +- [`templates/`](templates/) — Kubernetes resource templates +- [`Chart.yaml`](Chart.yaml) — chart metadata diff --git a/charts/cosmian-auth-verifier/templates/NOTES.txt b/charts/cosmian-auth-verifier/templates/NOTES.txt new file mode 100644 index 00000000..14417e97 --- /dev/null +++ b/charts/cosmian-auth-verifier/templates/NOTES.txt @@ -0,0 +1,41 @@ +Cosmian Authentication Server has been deployed as {{ include "cosmian-auth-verifier.fullname" . }} in namespace {{ .Release.Namespace }}. +Image: {{ .Values.image.repository }}:{{ include "cosmian-auth-verifier.imageTag" . }} + +{{- $scheme := include "cosmian-auth-verifier.scheme" . }} + +{{- if not .Values.authVerifier.tls.enabled }} + +*** WARNING: TLS is disabled — the server serves plain HTTP. Development only! *** +{{- end }} + +{{- if .Values.ingress.enabled }} +{{- $ingressScheme := ternary "https" "http" (or (not (empty .Values.ingress.tls)) (hasKey .Values.ingress.annotations "nginx.ingress.kubernetes.io/ssl-passthrough")) }} +{{- range .Values.ingress.hosts }} +The server is reachable at: {{ $ingressScheme }}://{{ .host }} +{{- end }} +{{- else if contains "NodePort" .Values.service.type }} +Get the application URL by running: + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "cosmian-auth-verifier.fullname" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo {{ $scheme }}://$NODE_IP:$NODE_PORT +{{- else if contains "ClusterIP" .Values.service.type }} +Get the application URL by running these commands: + kubectl --namespace {{ .Release.Namespace }} port-forward svc/{{ include "cosmian-auth-verifier.fullname" . }} {{ .Values.service.port }}:{{ .Values.service.port }} + echo "Visit {{ $scheme }}://127.0.0.1:{{ .Values.service.port }}{{ .Values.probes.path }}" +{{- end }} +{{- if .Values.authVerifier.adminUi.enabled }} +The admin UI is served under /admin-ui. +{{- end }} + +Database backend: {{ .Values.authVerifier.database.backend }} +{{- if and (eq .Values.authVerifier.database.backend "sqlite") (not .Values.persistence.enabled) }} +WARNING: persistence is disabled for the sqlite backend — data will be lost on pod restart. +{{- end }} +{{- if .Values.authVerifier.sessionStore.enabled }} +Session store: {{ .Values.authVerifier.sessionStore.backend }} +{{- end }} +{{- if .Values.saml.enabled }} + +SAML 2.0 single sign-on is enabled. Configure the IdP of each realm through the +API or the admin UI; each realm publishes its SP metadata at /saml//metadata. +{{- end }} diff --git a/charts/cosmian-auth-verifier/templates/_helpers.tpl b/charts/cosmian-auth-verifier/templates/_helpers.tpl new file mode 100644 index 00000000..9e7d2b84 --- /dev/null +++ b/charts/cosmian-auth-verifier/templates/_helpers.tpl @@ -0,0 +1,102 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "cosmian-auth-verifier.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to +this (by the DNS naming spec). +*/}} +{{- define "cosmian-auth-verifier.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "cosmian-auth-verifier.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "cosmian-auth-verifier.labels" -}} +helm.sh/chart: {{ include "cosmian-auth-verifier.chart" . }} +{{ include "cosmian-auth-verifier.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "cosmian-auth-verifier.selectorLabels" -}} +app.kubernetes.io/name: {{ include "cosmian-auth-verifier.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use. +*/}} +{{- define "cosmian-auth-verifier.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "cosmian-auth-verifier.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Resolve the image tag: the chart appVersion by default, with the "-saml" suffix +of the SAML image variant when saml.enabled is true. An explicit image.tag is +used verbatim. +*/}} +{{- define "cosmian-auth-verifier.imageTag" -}} +{{- if .Values.image.tag }} +{{- .Values.image.tag }} +{{- else if .Values.saml.enabled }} +{{- printf "%s-saml" .Chart.AppVersion }} +{{- else }} +{{- .Chart.AppVersion }} +{{- end }} +{{- end }} + +{{/* +URL scheme served by the container. +*/}} +{{- define "cosmian-auth-verifier.scheme" -}} +{{- if .Values.authVerifier.tls.enabled }}https{{ else }}http{{ end }} +{{- end }} + +{{/* +Resolve the PVC name used by the sqlite backend. +*/}} +{{- define "cosmian-auth-verifier.pvcName" -}} +{{- if .Values.persistence.existingClaim }} +{{- .Values.persistence.existingClaim }} +{{- else }} +{{- include "cosmian-auth-verifier.fullname" . }} +{{- end }} +{{- end }} + +{{/* +Name of the chart-managed Secret holding database connection URLs. +*/}} +{{- define "cosmian-auth-verifier.dbSecretName" -}} +{{- printf "%s-db" (include "cosmian-auth-verifier.fullname" .) }} +{{- end }} diff --git a/charts/cosmian-auth-verifier/templates/configmap.yaml b/charts/cosmian-auth-verifier/templates/configmap.yaml new file mode 100644 index 00000000..fb01d8ab --- /dev/null +++ b/charts/cosmian-auth-verifier/templates/configmap.yaml @@ -0,0 +1,83 @@ +{{- $av := .Values.authVerifier -}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "cosmian-auth-verifier.fullname" . }} + labels: + {{- include "cosmian-auth-verifier.labels" . | nindent 4 }} +data: + # @DATABASE_URL@ and @SESSION_STORE_URL@ are replaced at container start with + # values read from Secrets, so no credentials are stored in this ConfigMap. + auth_verifier.toml.tpl: | + host_name = {{ $av.hostName | quote }} + host_port = {{ $av.port | int }} + {{- if $av.adminUi.enabled }} + admin_ui_path = "/srv/admin-ui" + {{- end }} + roles = {{ toJson $av.roles }} + allowed_origins = {{ toJson $av.allowedOrigins }} + login_rate_limit_per_second = {{ $av.loginRateLimit.perSecond | int }} + login_rate_limit_burst = {{ $av.loginRateLimit.burst | int }} + + [log] + level = {{ $av.log.level | quote }} + {{- if $av.tls.enabled }} + + [tls_params] + server_private_key = "/etc/cosmian/tls/tls.key" + server_certificate = "/etc/cosmian/tls/tls.crt" + server_ca_chain = {{ printf "/etc/cosmian/tls/%s" $av.tls.caKey | quote }} + {{- if $av.tls.clientCaSecret }} + client_ca_cert_chain = {{ printf "/etc/cosmian/client-ca/%s" $av.tls.clientCaKey | quote }} + {{- end }} + {{- with $av.tls.cipherSuites }} + tls_cipher_suites = {{ . | quote }} + {{- end }} + {{- end }} + {{- if $av.sessionJwt.existingSecret }} + + [session_jwt_params] + jwt_ec_private_key = "/etc/cosmian/session-jwt/tls.key" + jwt_ec_public_key = "/etc/cosmian/session-jwt/tls.crt" + {{- end }} + {{- if $av.certificateJwt.existingSecret }} + + [certificate_jwt_params] + cert_ec_private_key = "/etc/cosmian/certificate-jwt/tls.key" + cert_ec_public_key = "/etc/cosmian/certificate-jwt/tls.crt" + {{- end }} + {{- if .Values.saml.enabled }} + + [saml_sp_params] + saml_rsa_private_key = "/etc/cosmian/saml-sp/tls.key" + saml_certificate = "/etc/cosmian/saml-sp/tls.crt" + {{- end }} + + [database_params] + backend = {{ $av.database.backend | quote }} + {{- if eq $av.database.backend "sqlite" }} + connection_url = {{ printf "sqlite://%s/auth_verifier.db" $av.database.sqlitePath | quote }} + {{- else }} + connection_url = "@DATABASE_URL@" + {{- end }} + max_connections = {{ $av.database.maxConnections | int }} + min_connections = {{ $av.database.minConnections | int }} + connect_timeout_secs = {{ $av.database.connectTimeoutSecs | int }} + idle_timeout_secs = {{ $av.database.idleTimeoutSecs | int }} + auto_init_schema = {{ $av.database.autoInitSchema }} + {{- if $av.sessionStore.enabled }} + + [sessions_store_params] + backend = {{ $av.sessionStore.backend | quote }} + connection_url = "@SESSION_STORE_URL@" + max_connections = {{ $av.sessionStore.maxConnections | int }} + {{- end }} + {{- with $av.staleSessionCleanupIntervalSeconds }} + + [stale_session_collector_config] + cleanup_interval_seconds = {{ . | int }} + {{- end }} + {{- with $av.extraConfig }} + {{- "\n" }} + {{- . | nindent 4 }} + {{- end }} diff --git a/charts/cosmian-auth-verifier/templates/deployment.yaml b/charts/cosmian-auth-verifier/templates/deployment.yaml new file mode 100644 index 00000000..48b788c9 --- /dev/null +++ b/charts/cosmian-auth-verifier/templates/deployment.yaml @@ -0,0 +1,271 @@ +{{- $av := .Values.authVerifier -}} +{{- $db := $av.database -}} +{{- $ss := $av.sessionStore -}} +{{- $scheme := include "cosmian-auth-verifier.scheme" . -}} +{{- /* Fail early on configurations the server would reject at startup. */ -}} +{{- if not (has $db.backend (list "sqlite" "postgresql" "mysql")) }} + {{- fail "authVerifier.database.backend must be one of: sqlite, postgresql, mysql" }} +{{- end }} +{{- if and (ne $db.backend "sqlite") (not $db.url) (not $db.existingSecret) }} + {{- fail "authVerifier.database.url or authVerifier.database.existingSecret is required for postgresql/mysql" }} +{{- end }} +{{- if and $ss.enabled (not (has $ss.backend (list "redis" "postgresql" "mysql"))) }} + {{- fail "authVerifier.sessionStore.backend must be one of: redis, postgresql, mysql" }} +{{- end }} +{{- if and $ss.enabled (not $ss.url) (not $ss.existingSecret) }} + {{- fail "authVerifier.sessionStore.url or authVerifier.sessionStore.existingSecret is required when the session store is enabled" }} +{{- end }} +{{- if and (eq $db.backend "sqlite") (gt (int .Values.replicaCount) 1) (not .Values.autoscaling.enabled) }} + {{- fail "replicaCount > 1 requires a postgresql/mysql database backend; sqlite cannot be shared across pods" }} +{{- end }} +{{- if and (eq $db.backend "sqlite") .Values.autoscaling.enabled }} + {{- fail "autoscaling requires a postgresql/mysql database backend; sqlite cannot be shared across pods" }} +{{- end }} +{{- if and (eq $db.backend "sqlite") .Values.podDisruptionBudget.enabled }} + {{- fail "podDisruptionBudget requires a postgresql/mysql database backend; on the single sqlite pod it would block node drains" }} +{{- end }} +{{- if and $av.tls.enabled (not $av.tls.existingSecret) }} + {{- fail "authVerifier.tls.existingSecret is required when authVerifier.tls.enabled is true" }} +{{- end }} +{{- if and (not $av.tls.enabled) (not $av.sessionJwt.existingSecret) }} + {{- fail "authVerifier.sessionJwt.existingSecret is required when TLS is disabled (session JWTs are otherwise signed with the TLS key)" }} +{{- end }} +{{- if and .Values.saml.enabled (not .Values.saml.existingSecret) }} + {{- fail "saml.existingSecret is required when saml.enabled is true" }} +{{- end }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "cosmian-auth-verifier.fullname" . }} + labels: + {{- include "cosmian-auth-verifier.labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + {{- if eq $db.backend "sqlite" }} + # A single pod owns the sqlite file: never run two side by side. + strategy: + type: Recreate + {{- end }} + selector: + matchLabels: + {{- include "cosmian-auth-verifier.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "cosmian-auth-verifier.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + automountServiceAccountToken: {{ .Values.serviceAccount.automount }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "cosmian-auth-verifier.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + {{- if .Values.terminationGracePeriodSeconds }} + terminationGracePeriodSeconds: {{ .Values.terminationGracePeriodSeconds }} + {{- end }} + {{- if .Values.priorityClassName }} + priorityClassName: {{ .Values.priorityClassName }} + {{- end }} + {{- with .Values.initContainers }} + initContainers: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: auth-verifier + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ include "cosmian-auth-verifier.imageTag" . }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + # Render the configuration template with the Secret-provided URLs + # (TOML-escaped), then start the server on it. + command: + - /bin/bash + - -c + args: + - | + set -euo pipefail + bs='\' dq='"' + escape() { local v=${1//"$bs"/"$bs$bs"}; printf '%s' "${v//"$dq"/"$bs$dq"}"; } + conf=$( /run/auth-verifier/auth_verifier.toml + exec /bin/auth_verifier /run/auth-verifier/auth_verifier.toml + env: + {{- if ne $db.backend "sqlite" }} + - name: AUTH_VERIFIER_DATABASE_URL + valueFrom: + secretKeyRef: + name: {{ $db.existingSecret | default (include "cosmian-auth-verifier.dbSecretName" .) }} + key: database-url + {{- end }} + {{- if $ss.enabled }} + - name: AUTH_VERIFIER_SESSION_STORE_URL + valueFrom: + secretKeyRef: + name: {{ $ss.existingSecret | default (include "cosmian-auth-verifier.dbSecretName" .) }} + key: session-store-url + {{- end }} + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.extraEnvFrom }} + envFrom: + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + - name: {{ $scheme }} + containerPort: {{ $av.port }} + protocol: TCP + startupProbe: + httpGet: + path: {{ .Values.probes.path }} + port: {{ $scheme }} + scheme: {{ upper $scheme }} + {{- toYaml .Values.probes.startup | nindent 12 }} + livenessProbe: + httpGet: + path: {{ .Values.probes.path }} + port: {{ $scheme }} + scheme: {{ upper $scheme }} + {{- toYaml .Values.probes.liveness | nindent 12 }} + readinessProbe: + httpGet: + path: {{ .Values.probes.path }} + port: {{ $scheme }} + scheme: {{ upper $scheme }} + {{- toYaml .Values.probes.readiness | nindent 12 }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumeMounts: + - name: config-template + mountPath: /etc/cosmian/config + readOnly: true + - name: config + mountPath: /run/auth-verifier + - name: tmp + mountPath: /tmp + {{- if eq $db.backend "sqlite" }} + - name: sqlite-data + mountPath: {{ $db.sqlitePath }} + {{- end }} + {{- if $av.tls.enabled }} + - name: tls + mountPath: /etc/cosmian/tls + readOnly: true + {{- if $av.tls.clientCaSecret }} + - name: client-ca + mountPath: /etc/cosmian/client-ca + readOnly: true + {{- end }} + {{- end }} + {{- if $av.sessionJwt.existingSecret }} + - name: session-jwt + mountPath: /etc/cosmian/session-jwt + readOnly: true + {{- end }} + {{- if $av.certificateJwt.existingSecret }} + - name: certificate-jwt + mountPath: /etc/cosmian/certificate-jwt + readOnly: true + {{- end }} + {{- if .Values.saml.enabled }} + - name: saml-sp + mountPath: /etc/cosmian/saml-sp + readOnly: true + {{- end }} + {{- with .Values.extraVolumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.extraContainers }} + {{- toYaml . | nindent 8 }} + {{- end }} + volumes: + - name: config-template + configMap: + name: {{ include "cosmian-auth-verifier.fullname" . }} + # The rendered configuration holds database credentials: keep it in RAM. + - name: config + emptyDir: + medium: Memory + sizeLimit: 1Mi + - name: tmp + emptyDir: {} + {{- if eq $db.backend "sqlite" }} + - name: sqlite-data + {{- if .Values.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ include "cosmian-auth-verifier.pvcName" . }} + {{- else }} + emptyDir: {} + {{- end }} + {{- end }} + {{- if $av.tls.enabled }} + - name: tls + secret: + secretName: {{ $av.tls.existingSecret }} + defaultMode: 0440 + {{- if $av.tls.clientCaSecret }} + - name: client-ca + secret: + secretName: {{ $av.tls.clientCaSecret }} + defaultMode: 0440 + {{- end }} + {{- end }} + {{- with $av.sessionJwt.existingSecret }} + - name: session-jwt + secret: + secretName: {{ . }} + defaultMode: 0440 + {{- end }} + {{- with $av.certificateJwt.existingSecret }} + - name: certificate-jwt + secret: + secretName: {{ . }} + defaultMode: 0440 + {{- end }} + {{- if .Values.saml.enabled }} + - name: saml-sp + secret: + secretName: {{ .Values.saml.existingSecret }} + defaultMode: 0440 + {{- end }} + {{- with .Values.extraVolumes }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.topologySpreadConstraints }} + topologySpreadConstraints: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/charts/cosmian-auth-verifier/templates/hpa.yaml b/charts/cosmian-auth-verifier/templates/hpa.yaml new file mode 100644 index 00000000..15bea778 --- /dev/null +++ b/charts/cosmian-auth-verifier/templates/hpa.yaml @@ -0,0 +1,36 @@ +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "cosmian-auth-verifier.fullname" . }} + labels: + {{- include "cosmian-auth-verifier.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "cosmian-auth-verifier.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} + {{- with .Values.autoscaling.behavior }} + behavior: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/charts/cosmian-auth-verifier/templates/ingress.yaml b/charts/cosmian-auth-verifier/templates/ingress.yaml new file mode 100644 index 00000000..5f3e96a4 --- /dev/null +++ b/charts/cosmian-auth-verifier/templates/ingress.yaml @@ -0,0 +1,35 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "cosmian-auth-verifier.fullname" . }} + labels: + {{- include "cosmian-auth-verifier.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "cosmian-auth-verifier.fullname" $ }} + port: + number: {{ $.Values.service.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/charts/cosmian-auth-verifier/templates/networkpolicy.yaml b/charts/cosmian-auth-verifier/templates/networkpolicy.yaml new file mode 100644 index 00000000..843e3a42 --- /dev/null +++ b/charts/cosmian-auth-verifier/templates/networkpolicy.yaml @@ -0,0 +1,35 @@ +{{- if .Values.networkPolicy.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "cosmian-auth-verifier.fullname" . }} + labels: + {{- include "cosmian-auth-verifier.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + {{- include "cosmian-auth-verifier.selectorLabels" . | nindent 6 }} + policyTypes: + - Ingress + {{- if .Values.networkPolicy.egress }} + - Egress + {{- end }} + ingress: + - from: + {{- if .Values.networkPolicy.ingressFrom }} + {{- toYaml .Values.networkPolicy.ingressFrom | nindent 8 }} + {{- else }} + # Default: allow ingress only from within the same namespace. + # Override with networkPolicy.ingressFrom for cross-namespace access. + - podSelector: {} + {{- end }} + ports: + - protocol: TCP + port: {{ .Values.authVerifier.port }} + {{- if .Values.networkPolicy.egress }} + # The server needs egress to DNS, its database/session store and the JWKS + # endpoints of JWT/OIDC and Kubernetes logins. + egress: + {{- toYaml .Values.networkPolicy.egress | nindent 4 }} + {{- end }} +{{- end }} diff --git a/charts/cosmian-auth-verifier/templates/poddisruptionbudget.yaml b/charts/cosmian-auth-verifier/templates/poddisruptionbudget.yaml new file mode 100644 index 00000000..31734216 --- /dev/null +++ b/charts/cosmian-auth-verifier/templates/poddisruptionbudget.yaml @@ -0,0 +1,24 @@ +{{- if .Values.podDisruptionBudget.enabled }} +{{- if and .Values.podDisruptionBudget.minAvailable .Values.podDisruptionBudget.maxUnavailable }} + {{- fail "podDisruptionBudget: only one of minAvailable or maxUnavailable may be set, not both" }} +{{- end }} +{{- if and (not .Values.podDisruptionBudget.minAvailable) (not .Values.podDisruptionBudget.maxUnavailable) }} + {{- fail "podDisruptionBudget: one of minAvailable or maxUnavailable must be set" }} +{{- end }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "cosmian-auth-verifier.fullname" . }} + labels: + {{- include "cosmian-auth-verifier.labels" . | nindent 4 }} +spec: + {{- if .Values.podDisruptionBudget.minAvailable }} + minAvailable: {{ .Values.podDisruptionBudget.minAvailable }} + {{- end }} + {{- if .Values.podDisruptionBudget.maxUnavailable }} + maxUnavailable: {{ .Values.podDisruptionBudget.maxUnavailable }} + {{- end }} + selector: + matchLabels: + {{- include "cosmian-auth-verifier.selectorLabels" . | nindent 6 }} +{{- end }} diff --git a/charts/cosmian-auth-verifier/templates/pvc.yaml b/charts/cosmian-auth-verifier/templates/pvc.yaml new file mode 100644 index 00000000..95d3d584 --- /dev/null +++ b/charts/cosmian-auth-verifier/templates/pvc.yaml @@ -0,0 +1,21 @@ +{{- if and (eq .Values.authVerifier.database.backend "sqlite") .Values.persistence.enabled (not .Values.persistence.existingClaim) }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "cosmian-auth-verifier.pvcName" . }} + labels: + {{- include "cosmian-auth-verifier.labels" . | nindent 4 }} + {{- with .Values.persistence.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + accessModes: + {{- toYaml .Values.persistence.accessModes | nindent 4 }} + {{- with .Values.persistence.storageClassName }} + storageClassName: {{ . }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.size }} +{{- end }} diff --git a/charts/cosmian-auth-verifier/templates/secret.yaml b/charts/cosmian-auth-verifier/templates/secret.yaml new file mode 100644 index 00000000..fdf72489 --- /dev/null +++ b/charts/cosmian-auth-verifier/templates/secret.yaml @@ -0,0 +1,20 @@ +{{- $db := .Values.authVerifier.database -}} +{{- $ss := .Values.authVerifier.sessionStore -}} +{{- $dbUrl := and (ne $db.backend "sqlite") (not $db.existingSecret) $db.url -}} +{{- $ssUrl := and $ss.enabled (not $ss.existingSecret) $ss.url -}} +{{- if or $dbUrl $ssUrl }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "cosmian-auth-verifier.dbSecretName" . }} + labels: + {{- include "cosmian-auth-verifier.labels" . | nindent 4 }} +type: Opaque +stringData: + {{- if $dbUrl }} + database-url: {{ $dbUrl | quote }} + {{- end }} + {{- if $ssUrl }} + session-store-url: {{ $ssUrl | quote }} + {{- end }} +{{- end }} diff --git a/charts/cosmian-auth-verifier/templates/service.yaml b/charts/cosmian-auth-verifier/templates/service.yaml new file mode 100644 index 00000000..6447875a --- /dev/null +++ b/charts/cosmian-auth-verifier/templates/service.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "cosmian-auth-verifier.fullname" . }} + labels: + {{- include "cosmian-auth-verifier.labels" . | nindent 4 }} + {{- with .Values.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: {{ include "cosmian-auth-verifier.scheme" . }} + protocol: TCP + name: {{ include "cosmian-auth-verifier.scheme" . }} + selector: + {{- include "cosmian-auth-verifier.selectorLabels" . | nindent 4 }} diff --git a/charts/cosmian-auth-verifier/templates/serviceaccount.yaml b/charts/cosmian-auth-verifier/templates/serviceaccount.yaml new file mode 100644 index 00000000..dc9a1a9e --- /dev/null +++ b/charts/cosmian-auth-verifier/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "cosmian-auth-verifier.serviceAccountName" . }} + labels: + {{- include "cosmian-auth-verifier.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automount }} +{{- end }} diff --git a/charts/cosmian-auth-verifier/templates/tests/test-connection.yaml b/charts/cosmian-auth-verifier/templates/tests/test-connection.yaml new file mode 100644 index 00000000..c8628070 --- /dev/null +++ b/charts/cosmian-auth-verifier/templates/tests/test-connection.yaml @@ -0,0 +1,41 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{ include "cosmian-auth-verifier.fullname" . }}-test-connection + labels: + {{- include "cosmian-auth-verifier.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": test + "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded +spec: + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 65534 + runAsGroup: 65534 + seccompProfile: + type: RuntimeDefault + containers: + - name: curl + image: "{{ .Values.test.image.repository }}:{{ .Values.test.image.tag }}" + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + command: + - curl + args: + - --fail + - --silent + - --show-error + - --retry + - '5' + - --retry-all-errors + {{- if .Values.authVerifier.tls.enabled }} + # The pod does not hold the server CA; this only checks reachability. + - --insecure + {{- end }} + - {{ printf "%s://%s:%v%s" (include "cosmian-auth-verifier.scheme" .) (include "cosmian-auth-verifier.fullname" .) .Values.service.port .Values.probes.path }} + restartPolicy: Never diff --git a/charts/cosmian-auth-verifier/values.yaml b/charts/cosmian-auth-verifier/values.yaml new file mode 100644 index 00000000..3aa57e7b --- /dev/null +++ b/charts/cosmian-auth-verifier/values.yaml @@ -0,0 +1,289 @@ +--- +## Default values for the cosmian-auth-verifier Helm chart. +## This is a YAML-formatted file. +## Declare variables to be passed into your templates. + +replicaCount: 1 + +image: + repository: ghcr.io/cosmian/auth-verifier + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion, suffixed + # with "-saml" when saml.enabled is true. + tag: '' + +imagePullSecrets: [] +nameOverride: '' +fullnameOverride: '' + +serviceAccount: + # Specifies whether a service account should be created. + create: true + # Automatically mount a ServiceAccount's API credentials. + automount: false + # Annotations to add to the service account. + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template. + name: '' + +podAnnotations: {} +podLabels: {} + +# The image runs as the baked-in "auth" user (uid/gid 1000). +podSecurityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault + +securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + # The rendered configuration, /tmp and the sqlite PVC are writable volumes; + # the root filesystem can safely be read-only. + readOnlyRootFilesystem: true + runAsNonRoot: true + +service: + type: ClusterIP + port: 8443 + annotations: {} + +# The server terminates TLS itself (authVerifier.tls) and mTLS client +# certificates are verified during the TLS handshake. Use TLS passthrough +# (e.g. nginx.ingress.kubernetes.io/ssl-passthrough: "true") to keep mTLS, or +# re-encrypt to the HTTPS backend +# (nginx.ingress.kubernetes.io/backend-protocol: "HTTPS") otherwise. +# Either way the server sees the controller's IP, so the per-IP login rate +# limit (authVerifier.loginRateLimit) is shared by all clients. +ingress: + enabled: false + className: '' + annotations: {} + hosts: + - host: auth.example.com + paths: + - path: / + pathType: Prefix + tls: [] + # - secretName: cosmian-auth-verifier-ingress-tls + # hosts: + # - auth.example.com + +resources: + # Each Argon2id password hash (/login, credential updates) takes 64 MiB and + # 4 lanes: size the memory limit for the expected concurrent logins. + limits: + cpu: '2' + memory: 1Gi + requests: + cpu: 500m + memory: 256Mi + +# -- Optional PriorityClass name. +priorityClassName: '' + +# -- Seconds the pod is allowed to terminate gracefully. +terminationGracePeriodSeconds: 30 + +# Probes target the unauthenticated GET /public/version endpoint; the scheme +# (HTTP/HTTPS) follows authVerifier.tls.enabled. Only timing fields go here. +probes: + path: /public/version + # Covers the database connect timeout (30 s) plus schema initialisation. + startup: + failureThreshold: 12 + periodSeconds: 5 + liveness: + periodSeconds: 10 + readiness: + periodSeconds: 10 + +autoscaling: + enabled: false + minReplicas: 2 + maxReplicas: 6 + targetCPUUtilizationPercentage: 75 + # targetMemoryUtilizationPercentage: 80 + # -- Optional HPA scaling behavior; pods are stateless (sessions live in the + # session store), so the Kubernetes defaults are usually fine. + behavior: {} + +# Requires a postgresql/mysql backend: the sqlite deployment runs a single pod. +podDisruptionBudget: + enabled: false + # Exactly one of minAvailable / maxUnavailable must be set. + minAvailable: 1 + # maxUnavailable: 1 + +networkPolicy: + # When enabled, only traffic on the server port is allowed in, from the + # namespaces/pods matched by ingressFrom (defaults to same-namespace only). + enabled: false + ingressFrom: [] + # - namespaceSelector: {} + # - podSelector: + # matchLabels: + # role: frontend + egress: [] + +# -- Topology spread constraints for distributing replicas across zones/nodes. +topologySpreadConstraints: [] +# - maxSkew: 1 +# topologyKey: topology.kubernetes.io/zone +# whenUnsatisfiable: DoNotSchedule +# labelSelector: +# matchLabels: +# app.kubernetes.io/name: cosmian-auth-verifier + +nodeSelector: {} +tolerations: [] +affinity: {} + +# -- Extra environment variables appended to the container (e.g. RUST_LOG, +# which overrides authVerifier.log.level). +extraEnv: [] +# -- Extra envFrom sources (ConfigMap/Secret) merged into the container env. +extraEnvFrom: [] +# -- Extra volumes mounted on the pod. +extraVolumes: [] +# -- Extra volumeMounts mounted on the container. +extraVolumeMounts: [] +# -- Extra sidecar containers added to the pod. +extraContainers: [] +# -- Init containers run before the main container starts. +initContainers: [] + +## Cosmian Authentication Server configuration. +## Rendered into auth_verifier.toml (see server/documentation/docs/server_configuration.md). +## Credentials (database URLs) are kept in a Secret and substituted at startup. +authVerifier: + # -- Address to bind to inside the container. + hostName: 0.0.0.0 + # -- Listening port inside the container. + port: 8443 + + log: + # -- error, warn, info, debug, trace, or a RUST_LOG-style directive. + level: info + + # -- Serve the admin UI bundled in the image (/srv/admin-ui) at /admin-ui. + adminUi: + enabled: true + + # -- RBAC role names advertised via GET /public/roles (must match your OPA policy). + roles: [] + # -- Origins allowed to call the admin scopes cross-origin (same-origin when empty). + allowedOrigins: [] + + # Keyed on the TCP peer IP (forwarded headers are ignored); each replica + # keeps its own counters. + loginRateLimit: + # -- Sustained per-IP requests/second allowed on POST /login. + perSecond: 5 + # -- Burst capacity above the sustained rate. + burst: 10 + + tls: + # -- Serve HTTPS. Disabling it is for development only and then requires + # sessionJwt.existingSecret, since session JWTs are otherwise signed with + # the TLS key. + enabled: true + # -- Secret with the server certificate (tls.crt), its PKCS#8 private key + # (tls.key) and the CA chain (caKey). When sessionJwt is unset, the key + # also signs session JWTs and must be EC P-256. With cert-manager, use + # privateKey.algorithm: ECDSA, privateKey.size: 256 and + # privateKey.encoding: PKCS8. + existingSecret: '' + # -- Key of existingSecret holding the CA chain (server_ca_chain). + caKey: ca.crt + # -- Optional Secret with the CA chain verifying mTLS client certificates. + # When empty, the server CA chain is used. + clientCaSecret: '' + # -- Key of clientCaSecret holding the client CA chain. + clientCaKey: ca.crt + # -- Optional colon-separated cipher suite list. + cipherSuites: '' + + sessionJwt: + # -- Optional Secret with a dedicated EC P-256 key pair signing session JWTs: + # tls.key (PKCS#8 private key) and tls.crt (public key or certificate PEM). + existingSecret: '' + + certificateJwt: + # -- Optional Secret with the EC P-256 key pair signing POST /certify + # certificates (same tls.key / tls.crt layout). /certify is disabled when empty. + existingSecret: '' + + database: + # -- Primary database: sqlite, postgresql or mysql. + backend: sqlite + # -- Connection URL for postgresql/mysql, stored in a chart-managed Secret. + # Prefer existingSecret to avoid storing credentials in values. + url: '' + # -- Existing Secret holding the connection URL under the key "database-url". + existingSecret: '' + # -- Directory holding auth_verifier.db for the sqlite backend. + sqlitePath: /var/lib/cosmian-auth + maxConnections: 10 + minConnections: 0 + connectTimeoutSecs: 30 + idleTimeoutSecs: 600 + # -- Create/migrate the schema on startup. + autoInitSchema: true + + # Dedicated session store. When disabled, sessions are kept in the primary + # database. + sessionStore: + enabled: false + # -- redis, postgresql or mysql. + backend: redis + # -- Connection URL, stored in a chart-managed Secret. + url: '' + # -- Existing Secret holding the connection URL under the key "session-store-url". + existingSecret: '' + maxConnections: 20 + + # -- Interval of the expired-session cleanup task (SQL session stores only). + # Leave empty for the server default (60 s). + staleSessionCleanupIntervalSeconds: '' + + # -- Raw TOML appended to the generated configuration, e.g. a [proxy_params] + # table. It must only contain [table] sections and ends up in a ConfigMap: + # do not put credentials here. + extraConfig: '' + +## SAML 2.0 single sign-on. +## Enabling it switches to the "-saml" image variant (built with the `saml` +## feature) and configures the SP signing key. Per-realm IdP settings are then +## managed through the API / admin UI. +saml: + enabled: false + # -- Secret with the SP signing key pair: tls.key (RSA >= 2048 bits private + # key) and tls.crt (its certificate, published in the SP metadata). + existingSecret: '' + +# Persistence for the sqlite database backend. Ignored for postgresql/mysql. +# Prefer postgresql or mysql for HA deployments. +persistence: + enabled: true + # -- Use an existing PVC instead of creating one. + existingClaim: '' + storageClassName: '' + accessModes: + - ReadWriteOnce + size: 1Gi + annotations: {} + +# -- Test connection pod settings. +test: + # -- Image used by the helm test connection pod (needs curl; busybox wget + # lacks TLS 1.3). Override for air-gapped environments or a private registry. + image: + repository: curlimages/curl + tag: 8.11.1 From 697d3a27f2d0b8ec2a6549a9c453b391aba40c11 Mon Sep 17 00:00:00 2001 From: charming_wicket <235307685+charming-wicket-5502@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:22:21 +0200 Subject: [PATCH 14/19] Package creation for saml version --- .github/workflows/packaging-docker.yml | 8 ++------ AGENTS.md | 3 ++- CHANGELOG/feat_saml-integration.md | 6 +++--- charts/cosmian-auth-verifier/Chart.yaml | 2 +- charts/cosmian-auth-verifier/README.md | 2 +- charts/cosmian-auth-verifier/templates/NOTES.txt | 2 +- .../cosmian-auth-verifier/templates/_helpers.tpl | 16 +++++----------- .../templates/deployment.yaml | 2 +- charts/cosmian-auth-verifier/values.yaml | 7 ++++--- server/documentation/docs/saml.md | 3 ++- 10 files changed, 22 insertions(+), 29 deletions(-) diff --git a/.github/workflows/packaging-docker.yml b/.github/workflows/packaging-docker.yml index 760637d4..b58856ae 100644 --- a/.github/workflows/packaging-docker.yml +++ b/.github/workflows/packaging-docker.yml @@ -90,9 +90,7 @@ jobs: id: meta uses: docker/metadata-action@v6 with: - images: ghcr.io/cosmian/auth-verifier - flavor: | - suffix=${{ matrix.variant == 'saml' && '-saml' || '' }} + images: ghcr.io/cosmian/${{ matrix.variant == 'saml' && 'auth-verifier-saml' || 'auth-verifier' }} tags: | type=ref,event=branch type=ref,event=pr @@ -195,7 +193,7 @@ jobs: packages: write id-token: write # Required for cosign keyless signing env: - REGISTRY_IMAGE: ghcr.io/cosmian/auth-verifier + REGISTRY_IMAGE: ghcr.io/cosmian/${{ matrix.variant == 'saml' && 'auth-verifier-saml' || 'auth-verifier' }} steps: - name: Login to GHCR @@ -216,8 +214,6 @@ jobs: uses: docker/metadata-action@v6 with: images: ${{ env.REGISTRY_IMAGE }} - flavor: | - suffix=${{ matrix.variant == 'saml' && '-saml' || '' }} tags: | type=ref,event=branch type=ref,event=pr diff --git a/AGENTS.md b/AGENTS.md index 176caf8d..7962c3ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -253,7 +253,8 @@ nix-build -A auth-verifier-dynamic # Build the SAML binary (static only) nix-build -A auth-verifier-static-saml -# Build Docker image (Linux only); the SAML image is tagged -saml +# Build Docker image (Linux only); the SAML image is tagged -saml locally and +# published as the separate GHCR package ghcr.io/cosmian/auth-verifier-saml: nix-build -A docker-image nix-build -A docker-image-saml ``` diff --git a/CHANGELOG/feat_saml-integration.md b/CHANGELOG/feat_saml-integration.md index ba0e7965..291e7f27 100644 --- a/CHANGELOG/feat_saml-integration.md +++ b/CHANGELOG/feat_saml-integration.md @@ -17,7 +17,7 @@ - Add a separate SAML Nix build next to the unchanged default one — `auth-verifier-static-saml` (static only, compiled with `--features saml` against the bundled xmlsec, libclang taken from the glibc-2.34 package set, build failing if xmlsec/libxml2 end up dynamically linked) and `docker-image-saml` (tag `-saml`) — so SAML can be shipped as prebuilt packages and images without changing the standard artifacts; its binary hash is tracked as `static-saml`. - Add a `--variant default|saml` option to `.github/scripts/nix.sh`, the packaging scripts and the `package`, `hashes:update`, `docker:load` and `docker:test` mise tasks: SAML packages are written to `result--static-saml/` with a `-saml` file-name suffix (checksum and signature name the final file) under the same package name, and `--variant saml --link dynamic` is rejected. - Make the DMG packaging script skip `result-*` directories when collecting the built DMG, so a DMG from another build tag is not picked up again. -- Add the `charts/cosmian-auth-verifier` Helm chart, adapted from the KMS chart, which renders `auth_verifier.toml` from values, injects database and session-store URLs from Secrets at container start (so credentials never land in the ConfigMap), mounts the TLS, JWT, certificate and SAML SP key Secrets, refuses settings the server would reject at startup, and switches to the `-saml` image with `[saml_sp_params]` when `saml.enabled` is set, so both variants can be deployed on Kubernetes; Helm templates are excluded from the `check-yaml` and `yamlfmt` pre-commit hooks. +- Add the `charts/cosmian-auth-verifier` Helm chart, adapted from the KMS chart, which renders `auth_verifier.toml` from values, injects database and session-store URLs from Secrets at container start (so credentials never land in the ConfigMap), mounts the TLS, JWT, certificate and SAML SP key Secrets, refuses settings the server would reject at startup, and switches to the `ghcr.io/cosmian/auth-verifier-saml` image (`image.samlRepository`) with `[saml_sp_params]` when `saml.enabled` is set, so both variants can be deployed on Kubernetes; Helm templates are excluded from the `check-yaml` and `yamlfmt` pre-commit hooks. ## Bug Fixes @@ -37,7 +37,7 @@ - Add a Nix-based `cargo-saml` job that runs clippy and the unit tests with `--features saml` inside `nix-shell`, since that feature needs the bundled xmlsec and libclang that the plain-cargo jobs don't have. - Run the `cargo-saml` job on Linux x86_64, Linux aarch64 and macOS, and package the SAML variant (`static-saml`) for deb, rpm and dmg in the packaging and publish matrices (own `static-saml` publish folder, attached to GitHub releases), so the bundled xmlsec is verified on every platform we ship. - Pin `` (`-I nixpkgs=$PIN_URL`) in the `cargo-saml` job's `nix-shell` calls so the shell uses a Nix-provided bash; without it the macOS runner fell back to `/bin/bash` 3.2, which nixpkgs' stdenv rejects. -- Test the SAML packages in the container and systemd package tests by starting the server with a `[saml_sp_params]` section, which a build without the `saml` feature refuses, and build, test and push a `-saml` Docker image and manifest next to the default one; `test_docker_image.sh` does the same check when `EXPECT_SAML=1`. +- Test the SAML packages in the container and systemd package tests by starting the server with a `[saml_sp_params]` section, which a build without the `saml` feature refuses, and build, test and push the SAML Docker image and manifest as a separate GitHub package, `ghcr.io/cosmian/auth-verifier-saml` (same tags as the default image), so users pull SAML from its own package instead of a tag suffix on the default one; `test_docker_image.sh` does the same check when `EXPECT_SAML=1`. - Add a `SAML e2e` workflow that builds the SAML Docker image with Nix and runs `mise run test:saml-e2e` (plus the `e2e/` format and type checks) on Linux x86_64 and aarch64, weekly and on pushes and pull requests touching SAML code, uploading Playwright traces on failure. ## Docs @@ -46,4 +46,4 @@ - Add a SAML 2.0 single sign-on guide (`docs/saml.md`: supported scope, build and `[saml_sp_params]` prerequisites, realm settings, IdP onboarding with the SP metadata, the login flow, identity mapping, every check applied to a response, certificate and key rotation, troubleshooting), document SAML as flow 8, the `[saml_sp_params]` section, the `/saml` endpoints and the `sa` auth scheme across the configuration, flows and API references, list SAML as implemented in the overview and README, and add a commented `[saml_sp_params]` block to the packaged and development configurations. - Document the `/saml/{realm_id}/login`, `/acs` and `/metadata` endpoints and a `SAML` tag in `openapi.yaml`, and proxy `/saml` from the admin UI dev and preview servers. - Add ADR-0003 recording why SAML uses `samael` with a statically bundled, OpenSSL-only xmlsec, why the server-side feature is behind an optional `saml` Cargo feature while the API types are not, the v1 protocol scope, the single server-wide RSA SP signing key, and the rejected alternatives and accepted risks (including that xmlsec/libxml2 security updates now ship with our releases). -- Document the prebuilt SAML packages, Docker tag and `nix-build -A auth-verifier-static-saml` as ways to get a SAML-enabled server in `docs/saml.md`, and the `saml` feature, SAML Nix attributes, `--variant` option and `static-saml` hash files in `AGENTS.md` and the expected-hashes README. +- Document the prebuilt SAML packages, the `ghcr.io/cosmian/auth-verifier-saml` Docker image and `nix-build -A auth-verifier-static-saml` as ways to get a SAML-enabled server in `docs/saml.md`, and the `saml` feature, SAML Nix attributes, `--variant` option and `static-saml` hash files in `AGENTS.md` and the expected-hashes README. diff --git a/charts/cosmian-auth-verifier/Chart.yaml b/charts/cosmian-auth-verifier/Chart.yaml index a6d8ca8e..78f6e0ca 100644 --- a/charts/cosmian-auth-verifier/Chart.yaml +++ b/charts/cosmian-auth-verifier/Chart.yaml @@ -28,4 +28,4 @@ annotations: - name: auth-verifier image: ghcr.io/cosmian/auth-verifier:0.4.0 - name: auth-verifier-saml - image: ghcr.io/cosmian/auth-verifier:0.4.0-saml + image: ghcr.io/cosmian/auth-verifier-saml:0.4.0 diff --git a/charts/cosmian-auth-verifier/README.md b/charts/cosmian-auth-verifier/README.md index 51e3c71e..9ac8ede0 100644 --- a/charts/cosmian-auth-verifier/README.md +++ b/charts/cosmian-auth-verifier/README.md @@ -47,7 +47,7 @@ helm install auth ./charts/cosmian-auth-verifier \ --set authVerifier.sessionStore.existingSecret=auth-sessions \ --set replicaCount=2 -# SAML: switches to the -saml image and configures the SP signing key +# SAML: switches to the ghcr.io/cosmian/auth-verifier-saml image and configures the SP signing key kubectl create secret tls auth-saml-sp --cert=saml-sp.cert.pem --key=saml-sp.key.pem helm install auth ./charts/cosmian-auth-verifier \ --set authVerifier.tls.existingSecret=auth-verifier-tls \ diff --git a/charts/cosmian-auth-verifier/templates/NOTES.txt b/charts/cosmian-auth-verifier/templates/NOTES.txt index 14417e97..edcc7230 100644 --- a/charts/cosmian-auth-verifier/templates/NOTES.txt +++ b/charts/cosmian-auth-verifier/templates/NOTES.txt @@ -1,5 +1,5 @@ Cosmian Authentication Server has been deployed as {{ include "cosmian-auth-verifier.fullname" . }} in namespace {{ .Release.Namespace }}. -Image: {{ .Values.image.repository }}:{{ include "cosmian-auth-verifier.imageTag" . }} +Image: {{ include "cosmian-auth-verifier.image" . }} {{- $scheme := include "cosmian-auth-verifier.scheme" . }} diff --git a/charts/cosmian-auth-verifier/templates/_helpers.tpl b/charts/cosmian-auth-verifier/templates/_helpers.tpl index 9e7d2b84..2dad9840 100644 --- a/charts/cosmian-auth-verifier/templates/_helpers.tpl +++ b/charts/cosmian-auth-verifier/templates/_helpers.tpl @@ -62,18 +62,12 @@ Create the name of the service account to use. {{- end }} {{/* -Resolve the image tag: the chart appVersion by default, with the "-saml" suffix -of the SAML image variant when saml.enabled is true. An explicit image.tag is -used verbatim. +Resolve the image: the SAML image repository when saml.enabled is true, tagged with +the chart appVersion unless image.tag is set. */}} -{{- define "cosmian-auth-verifier.imageTag" -}} -{{- if .Values.image.tag }} -{{- .Values.image.tag }} -{{- else if .Values.saml.enabled }} -{{- printf "%s-saml" .Chart.AppVersion }} -{{- else }} -{{- .Chart.AppVersion }} -{{- end }} +{{- define "cosmian-auth-verifier.image" -}} +{{- $repository := ternary .Values.image.samlRepository .Values.image.repository .Values.saml.enabled }} +{{- printf "%s:%s" $repository (.Values.image.tag | default .Chart.AppVersion) }} {{- end }} {{/* diff --git a/charts/cosmian-auth-verifier/templates/deployment.yaml b/charts/cosmian-auth-verifier/templates/deployment.yaml index 48b788c9..27927380 100644 --- a/charts/cosmian-auth-verifier/templates/deployment.yaml +++ b/charts/cosmian-auth-verifier/templates/deployment.yaml @@ -87,7 +87,7 @@ spec: - name: auth-verifier securityContext: {{- toYaml .Values.securityContext | nindent 12 }} - image: "{{ .Values.image.repository }}:{{ include "cosmian-auth-verifier.imageTag" . }}" + image: {{ include "cosmian-auth-verifier.image" . | quote }} imagePullPolicy: {{ .Values.image.pullPolicy }} # Render the configuration template with the Secret-provided URLs # (TOML-escaped), then start the server on it. diff --git a/charts/cosmian-auth-verifier/values.yaml b/charts/cosmian-auth-verifier/values.yaml index 3aa57e7b..b5bdec0f 100644 --- a/charts/cosmian-auth-verifier/values.yaml +++ b/charts/cosmian-auth-verifier/values.yaml @@ -7,9 +7,10 @@ replicaCount: 1 image: repository: ghcr.io/cosmian/auth-verifier + # Used instead of `repository` when saml.enabled is true (server built with the `saml` feature). + samlRepository: ghcr.io/cosmian/auth-verifier-saml pullPolicy: IfNotPresent - # Overrides the image tag whose default is the chart appVersion, suffixed - # with "-saml" when saml.enabled is true. + # Overrides the image tag whose default is the chart appVersion. tag: '' imagePullSecrets: [] @@ -259,7 +260,7 @@ authVerifier: extraConfig: '' ## SAML 2.0 single sign-on. -## Enabling it switches to the "-saml" image variant (built with the `saml` +## Enabling it switches to the SAML image (image.samlRepository, built with the `saml` ## feature) and configures the SP signing key. Per-realm IdP settings are then ## managed through the API / admin UI. saml: diff --git a/server/documentation/docs/saml.md b/server/documentation/docs/saml.md index fa742488..1c71fc65 100644 --- a/server/documentation/docs/saml.md +++ b/server/documentation/docs/saml.md @@ -53,7 +53,8 @@ admins keep signing in with the methods of the `_` realm. - **Prebuilt:** the release publishes a SAML variant next to the standard one — packages whose file name ends in `-saml` (for example `auth-verifier_-1_amd64-saml.deb`) - and the Docker image tag `-saml`. It is a static build only and installs under + and the Docker image `ghcr.io/cosmian/auth-verifier-saml:`, a separate package + from the standard `ghcr.io/cosmian/auth-verifier`. It is a static build only and installs under the same package name as the standard server, so install one or the other. - **From source with Nix:** `nix-build -A auth-verifier-static-saml` - **From source with cargo**, inside the Nix shell: From 15fb8d05fce603dd777969bb1d316d8580521bcd Mon Sep 17 00:00:00 2001 From: charming_wicket <235307685+charming-wicket-5502@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:03:57 +0200 Subject: [PATCH 15/19] Fixed and created mermaid diagrams for SAML --- CHANGELOG/feat_saml-integration.md | 1 + server/documentation/docs/saml.md | 103 ++++++++++++++++++++++++----- 2 files changed, 88 insertions(+), 16 deletions(-) diff --git a/CHANGELOG/feat_saml-integration.md b/CHANGELOG/feat_saml-integration.md index 291e7f27..8959269d 100644 --- a/CHANGELOG/feat_saml-integration.md +++ b/CHANGELOG/feat_saml-integration.md @@ -47,3 +47,4 @@ - Document the `/saml/{realm_id}/login`, `/acs` and `/metadata` endpoints and a `SAML` tag in `openapi.yaml`, and proxy `/saml` from the admin UI dev and preview servers. - Add ADR-0003 recording why SAML uses `samael` with a statically bundled, OpenSSL-only xmlsec, why the server-side feature is behind an optional `saml` Cargo feature while the API types are not, the v1 protocol scope, the single server-wide RSA SP signing key, and the rejected alternatives and accepted risks (including that xmlsec/libxml2 security updates now ship with our releases). - Document the prebuilt SAML packages, the `ghcr.io/cosmian/auth-verifier-saml` Docker image and `nix-build -A auth-verifier-static-saml` as ways to get a SAML-enabled server in `docs/saml.md`, and the `saml` feature, SAML Nix attributes, `--variant` option and `static-saml` hash files in `AGENTS.md` and the expected-hashes README. +- Fix the broken Mermaid "Login flow" diagram in `docs/saml.md` (`;` and `` in messages broke parsing) by rewriting it as a detailed sequence diagram of what the Authentication Verifier does at `/login` and `/acs`, and add an architecture flowchart of every component involved in a SAML sign-in. diff --git a/server/documentation/docs/saml.md b/server/documentation/docs/saml.md index 1c71fc65..25224109 100644 --- a/server/documentation/docs/saml.md +++ b/server/documentation/docs/saml.md @@ -14,6 +14,7 @@ The design and its trade-offs are recorded in ## Table of Contents - [What is supported](#what-is-supported) +- [Architecture](#architecture) - [Prerequisites](#prerequisites) - [Step 1 — Create the SP signing key](#step-1--create-the-sp-signing-key) - [Step 2 — Configure the realm](#step-2--configure-the-realm) @@ -45,6 +46,56 @@ admins keep signing in with the methods of the `_` realm. --- +## Architecture + +```mermaid +flowchart LR + subgraph user["End user"] + B["Web browser"] + end + + subgraph app["Your application"] + APP["Web application
(origin listed in allowed_return_origins)"] + API["API server"] + end + + subgraph idp["Identity provider"] + IDP["SAML 2.0 IdP
Entra ID, Okta, ADFS, Keycloak, Shibboleth"] + DIR[("User directory
AD, LDAP")] + MFA["MFA factors"] + end + + subgraph verifier["Authentication Verifier deployment"] + EA["Authentication Verifier
SAML 2.0 Service Provider
/saml/{realm_id}/login, /acs, /metadata"] + KEY["SP signing key and certificate
(PEM files of saml_sp_params)"] + DB[("Auth database
SQLite, PostgreSQL, MySQL
realms and saml_params")] + SS[("Session store
SQLite, PostgreSQL, MySQL, Redis
sessions, pending SAML requests, replay cache")] + end + + ADM["Realm administrator
admin UI or admin API"] + IDPADM["IdP administrator"] + + B -- "1. opens the application" --> APP + B -- "2. login redirect and ACS form post" --> EA + B -- "3. signs in" --> IDP + IDP --> DIR + IDP --> MFA + B -- "4. API calls with the _ea_ cookie" --> API + API -- "5. validates the session (/sessions)" --> EA + EA --> DB + EA --> SS + KEY -.-> EA + ADM -. "pastes IdP metadata, sets SAML realm settings" .-> EA + EA -. "SP metadata" .-> IDPADM + IDPADM -. "registers the SP" .-> IDP +``` + +The Authentication Verifier and the IdP never talk to each other directly: every SAML message +travels through the browser (front channel), and trust is set up once, out of band, by +exchanging metadata. The application and the API server only ever see the `_ea_` session. + +--- + ## Prerequisites 1. **A server built with the `saml` feature.** SAML statically links the xmlsec and libxml2 @@ -194,23 +245,43 @@ sequenceDiagram autonumber participant B as Browser participant EA as Authentication Verifier - participant S as SAML request store + participant DB as Auth database + participant RS as SAML request store + participant SS as Session store participant IdP as Identity Provider - - B->>EA: GET /saml/{realm}/login?return_to=… - note over EA: return_to checked against allowed_return_origins - EA->>S: store pending request (ID, realm, return URL; 10 min) - EA-->>B: 302 to IdP SSO URL (signed AuthnRequest, RelayState = request ID)
Set-Cookie: _ea_saml=; Secure; HttpOnly; SameSite=None - B->>IdP: GET SSO URL - IdP-->>B: sign-in page (MFA, …) - B->>IdP: credentials - IdP-->>B: auto-submitting form - B->>EA: POST /saml/{realm}/acs (SAMLResponse, RelayState)
Cookie: _ea_saml= - note over EA: _ea_saml must equal RelayState - EA->>S: take pending request (single use, same realm) - note over EA: verify signature and conditions,
record assertion ID (replay cache),
map identity - EA-->>B: 200 page continuing to the return URL
Set-Cookie: _ea_=…; SameSite=Strict (session) - B->>B: navigate to return URL + participant APP as Application and API + + B->>EA: GET /saml/{realm_id}/login?return_to=URL + note over EA: per-IP rate limit (same as /login) + EA->>DB: get realm + DB-->>EA: realm and saml_params + note over EA: 400 if the realm does not use SAML
return_to must be https under allowed_return_origins (else 400)
absent return_to uses default_return_url + EA->>RS: store pending request (request ID, realm, return URL, expires in 10 min) + note over EA: AuthnRequest ID = request ID
signed RSA-SHA256 with the SP key (HTTP-Redirect) + EA-->>B: 302 Location: IdP SSO URL with SAMLRequest, RelayState = request ID, SigAlg, Signature
Set-Cookie: _ea_saml = request ID (Secure, HttpOnly, SameSite=None, Path=/saml/) + + rect rgba(128, 128, 128, 0.1) + note over B,IdP: at the IdP, outside the Authentication Verifier + B->>IdP: GET SSO URL + note over IdP: checks the AuthnRequest signature
authenticates the user (password, MFA) + IdP-->>B: auto-submitting HTML form + end + + B->>EA: POST /saml/{realm_id}/acs with SAMLResponse and RelayState
Cookie: _ea_saml = request ID + note over EA: _ea_saml cookie must equal RelayState (else 401) + EA->>DB: get realm + DB-->>EA: realm and saml_params + EA->>RS: take pending request (RelayState, realm), single use + RS-->>EA: pending request with return URL (none: 401) + note over EA: base64-decode SAMLResponse
verify signature against the IdP certificates (xmlsec)
check algorithms, Issuer, InResponseTo, Audience,
Recipient, Destination, validity window, AuthnStatement, Status + EA->>RS: record assertion ID (replay cache) + RS-->>EA: new (already seen: 401) + note over EA: map identity: subject, roles, extra claims
issue session token (as_as = sa, as_rid = realm) + EA->>SS: upsert session + EA-->>B: 200 HTML page continuing to the return URL
Set-Cookie: _ea_ session (SameSite=Strict), _ea_saml cleared + B->>APP: GET return URL with the _ea_ cookie + APP->>EA: validate the session (/sessions) + EA-->>APP: session data ``` The pending requests and the replay cache live in the session store's database (SQLite, From 98ba405564cbdd4e3b869f188e625415c7bec0f2 Mon Sep 17 00:00:00 2001 From: charming_wicket <235307685+charming-wicket-5502@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:07:52 +0200 Subject: [PATCH 16/19] updated another broken mermaid diagram --- CHANGELOG/feat_saml-integration.md | 2 +- server/documentation/docs/authentication_flows.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG/feat_saml-integration.md b/CHANGELOG/feat_saml-integration.md index 8959269d..ac43b1f5 100644 --- a/CHANGELOG/feat_saml-integration.md +++ b/CHANGELOG/feat_saml-integration.md @@ -47,4 +47,4 @@ - Document the `/saml/{realm_id}/login`, `/acs` and `/metadata` endpoints and a `SAML` tag in `openapi.yaml`, and proxy `/saml` from the admin UI dev and preview servers. - Add ADR-0003 recording why SAML uses `samael` with a statically bundled, OpenSSL-only xmlsec, why the server-side feature is behind an optional `saml` Cargo feature while the API types are not, the v1 protocol scope, the single server-wide RSA SP signing key, and the rejected alternatives and accepted risks (including that xmlsec/libxml2 security updates now ship with our releases). - Document the prebuilt SAML packages, the `ghcr.io/cosmian/auth-verifier-saml` Docker image and `nix-build -A auth-verifier-static-saml` as ways to get a SAML-enabled server in `docs/saml.md`, and the `saml` feature, SAML Nix attributes, `--variant` option and `static-saml` hash files in `AGENTS.md` and the expected-hashes README. -- Fix the broken Mermaid "Login flow" diagram in `docs/saml.md` (`;` and `` in messages broke parsing) by rewriting it as a detailed sequence diagram of what the Authentication Verifier does at `/login` and `/acs`, and add an architecture flowchart of every component involved in a SAML sign-in. +- Fix the broken Mermaid "Login flow" diagram in `docs/saml.md` and the Flow 8 SAML sequence in `docs/authentication_flows.md` (`;` and `` in messages broke parsing) by rewriting it as a detailed sequence diagram of what the Authentication Verifier does at `/login` and `/acs`, and add an architecture flowchart of every component involved in a SAML sign-in. diff --git a/server/documentation/docs/authentication_flows.md b/server/documentation/docs/authentication_flows.md index 1d77830c..1a200e89 100644 --- a/server/documentation/docs/authentication_flows.md +++ b/server/documentation/docs/authentication_flows.md @@ -340,12 +340,12 @@ sequenceDiagram participant IdP as Identity Provider B->>EA: GET /saml/{realm}/login?return_to=https://app.example.com/… - EA-->>B: 302 to IdP (signed AuthnRequest)
Set-Cookie: _ea_saml= + EA-->>B: 302 to IdP (signed AuthnRequest)
Set-Cookie: _ea_saml = request ID B->>IdP: sign in IdP-->>B: form posting the signed SAML response B->>EA: POST /saml/{realm}/acs (SAMLResponse, RelayState)
Cookie: _ea_saml note over EA: browser binding, single-use request,
signature, audience, recipient, time, replay - EA-->>B: Set-Cookie: _ea_=…; continue to return_to + EA-->>B: 200 page continuing to return_to
Set-Cookie: _ea_ session ``` Setup, identity mapping, the full list of checks and troubleshooting are in From 151eea4cd8d7eed27869cce9c9f9ed9089ffc2f8 Mon Sep 17 00:00:00 2001 From: charming_wicket <235307685+charming-wicket-5502@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:43:47 +0200 Subject: [PATCH 17/19] docs: move ADR-0003 to server/documentation/adr and drop public ADR links --- CHANGELOG/feat_saml-integration.md | 2 +- .../{docs => }/adr/2026-09-25-saml-service-provider.md | 0 server/documentation/docs/SUMMARY.md | 4 ---- server/documentation/docs/saml.md | 5 +---- server/documentation/nav.yml | 4 ---- 5 files changed, 2 insertions(+), 13 deletions(-) rename server/documentation/{docs => }/adr/2026-09-25-saml-service-provider.md (100%) diff --git a/CHANGELOG/feat_saml-integration.md b/CHANGELOG/feat_saml-integration.md index ac43b1f5..add13562 100644 --- a/CHANGELOG/feat_saml-integration.md +++ b/CHANGELOG/feat_saml-integration.md @@ -45,6 +45,6 @@ - Add `server/saml_testing.md`, a hands-on guide to test the SAML branch: automated tests, building the server and admin UI, starting the server, running a local Keycloak identity provider from a one-file realm import, creating a SAML realm in the admin UI, signing in, and a checklist of UI and server checks including the IdP-must-sign and bad-signing-key cases. - Add a SAML 2.0 single sign-on guide (`docs/saml.md`: supported scope, build and `[saml_sp_params]` prerequisites, realm settings, IdP onboarding with the SP metadata, the login flow, identity mapping, every check applied to a response, certificate and key rotation, troubleshooting), document SAML as flow 8, the `[saml_sp_params]` section, the `/saml` endpoints and the `sa` auth scheme across the configuration, flows and API references, list SAML as implemented in the overview and README, and add a commented `[saml_sp_params]` block to the packaged and development configurations. - Document the `/saml/{realm_id}/login`, `/acs` and `/metadata` endpoints and a `SAML` tag in `openapi.yaml`, and proxy `/saml` from the admin UI dev and preview servers. -- Add ADR-0003 recording why SAML uses `samael` with a statically bundled, OpenSSL-only xmlsec, why the server-side feature is behind an optional `saml` Cargo feature while the API types are not, the v1 protocol scope, the single server-wide RSA SP signing key, and the rejected alternatives and accepted risks (including that xmlsec/libxml2 security updates now ship with our releases). +- Add ADR-0003 (in `server/documentation/adr/`, outside the public mdBook and not linked from it, per the ADR relocation on `develop`) recording why SAML uses `samael` with a statically bundled, OpenSSL-only xmlsec, why the server-side feature is behind an optional `saml` Cargo feature while the API types are not, the v1 protocol scope, the single server-wide RSA SP signing key, and the rejected alternatives and accepted risks (including that xmlsec/libxml2 security updates now ship with our releases). - Document the prebuilt SAML packages, the `ghcr.io/cosmian/auth-verifier-saml` Docker image and `nix-build -A auth-verifier-static-saml` as ways to get a SAML-enabled server in `docs/saml.md`, and the `saml` feature, SAML Nix attributes, `--variant` option and `static-saml` hash files in `AGENTS.md` and the expected-hashes README. - Fix the broken Mermaid "Login flow" diagram in `docs/saml.md` and the Flow 8 SAML sequence in `docs/authentication_flows.md` (`;` and `` in messages broke parsing) by rewriting it as a detailed sequence diagram of what the Authentication Verifier does at `/login` and `/acs`, and add an architecture flowchart of every component involved in a SAML sign-in. diff --git a/server/documentation/docs/adr/2026-09-25-saml-service-provider.md b/server/documentation/adr/2026-09-25-saml-service-provider.md similarity index 100% rename from server/documentation/docs/adr/2026-09-25-saml-service-provider.md rename to server/documentation/adr/2026-09-25-saml-service-provider.md diff --git a/server/documentation/docs/SUMMARY.md b/server/documentation/docs/SUMMARY.md index 56584a3d..c329d13b 100644 --- a/server/documentation/docs/SUMMARY.md +++ b/server/documentation/docs/SUMMARY.md @@ -13,7 +13,3 @@ - [Authorization and administration](authorization_and_administration.md) - [Client library](client_library.md) - [API reference](api_reference.md) -- [Architecture Decision Records]() - - [Role / JWT / OPA integration](adr/2026-06-24-role-jwt-opa-integration.md) - - [App auth API for SPIRE](adr/2026-07-26-app-auth-api-for-spire.md) - - [SAML 2.0 Service Provider](adr/2026-09-25-saml-service-provider.md) diff --git a/server/documentation/docs/saml.md b/server/documentation/docs/saml.md index 25224109..d3421e17 100644 --- a/server/documentation/docs/saml.md +++ b/server/documentation/docs/saml.md @@ -6,9 +6,6 @@ Keycloak, Shibboleth, … — and come back with the usual `_ea_` session cookie for the API servers that validate sessions: a SAML session is a session like any other, with `as_as` (auth scheme) set to `"sa"`. -The design and its trade-offs are recorded in -[ADR-0003](adr/2026-09-25-saml-service-provider.md). - --- ## Table of Contents @@ -99,7 +96,7 @@ exchanging metadata. The application and the API server only ever see the `_ea_` ## Prerequisites 1. **A server built with the `saml` feature.** SAML statically links the xmlsec and libxml2 - C libraries (see the ADR), so it is off by default. Either use a prebuilt SAML build or + C libraries, so it is off by default. Either use a prebuilt SAML build or build it yourself: - **Prebuilt:** the release publishes a SAML variant next to the standard one — packages diff --git a/server/documentation/nav.yml b/server/documentation/nav.yml index 871e3e12..9ae65d1e 100644 --- a/server/documentation/nav.yml +++ b/server/documentation/nav.yml @@ -12,7 +12,3 @@ nav: - Authorization and administration: authorization_and_administration.md - Client library: client_library.md - API reference: api_reference.md - - Architecture Decision Records: - - Role / JWT / OPA integration: adr/2026-06-24-role-jwt-opa-integration.md - - App auth API for SPIRE: adr/2026-07-26-app-auth-api-for-spire.md - - SAML 2.0 Service Provider: adr/2026-09-25-saml-service-provider.md From 9d1f67be0f72e32ac28eb003f2b37782bd82f844 Mon Sep 17 00:00:00 2001 From: charming_wicket <235307685+charming-wicket-5502@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:31:27 +0200 Subject: [PATCH 18/19] fix(saml): address PR #111 review findings - Scope the SAML assertion replay cache per realm (trait, SQLite, PostgreSQL, MySQL and Redis keys), so one realm's IdP cannot use up assertion IDs that another realm will receive. - Refuse SAML settings on the admin realm `_`: SAML is never an administrator credential. - Issue the `_ea_saml` request cookie with `Path=/`, so an ACS behind a path-prefixing reverse proxy still receives it. - Remove the stale `report/ISSUES_CHECKLIST.md` review artifact. - Tests: ACS-level assertion replay and cross-realm tests, a per-realm replay store test and an admin-realm API test. - Docs: SAML tables and extra pool when `auto_init_schema = false`, the multi-tab cause of the "not started from this browser" 401, the `sa` scheme in the scheme lists, keying identities on realm + scheme + subject; fix a pre-existing markdownlint heading and yamlfmt drift in openapi.yaml. --- CHANGELOG/feat_saml-integration.md | 8 +- report/ISSUES_CHECKLIST.md | 322 ------------------ server/documentation/docs/api_reference.md | 4 +- .../docs/authentication_flows.md | 2 +- server/documentation/docs/client_library.md | 7 +- server/documentation/docs/saml.md | 21 +- .../docs/server_configuration.md | 3 +- server/documentation/openapi.yaml | 30 +- server/src/saml/endpoints.rs | 13 +- server/src/saml/impls/mysql.rs | 14 +- server/src/saml/impls/postgres.rs | 16 +- server/src/saml/impls/redis.rs | 17 +- server/src/saml/impls/sqlite.rs | 16 +- server/src/saml/request_store.rs | 18 +- server/src/saml/response_validation.rs | 23 +- .../endpoints/super_admins_endpoints.rs | 6 + server/src/tests/saml_flow.rs | 72 +++- server/src/tests/saml_request_store.rs | 55 ++- server/src/tests/super_admin_api.rs | 35 ++ 19 files changed, 284 insertions(+), 398 deletions(-) delete mode 100644 report/ISSUES_CHECKLIST.md diff --git a/CHANGELOG/feat_saml-integration.md b/CHANGELOG/feat_saml-integration.md index add13562..31beafc7 100644 --- a/CHANGELOG/feat_saml-integration.md +++ b/CHANGELOG/feat_saml-integration.md @@ -2,14 +2,14 @@ - Add the SAML 2.0 contract types to the client crate — `SamlParams`, `RealmAuthParams.saml_params` and `AuthScheme::Saml` (`"sa"`) — mirrored in the admin-ui TypeScript types and `openapi.yaml`, so realms can carry per-realm SAML Service Provider configuration for the upcoming SP-initiated Web Browser SSO login. - Make `find_admins_by_auth_scheme` return no admin for `AuthScheme::Saml` on SQLite, PostgreSQL and MySQL, because SAML is a user-facing realm login method and never an administrator credential. -- Add a `SamlRequestStore` (pending `AuthnRequest`s for `InResponseTo` correlation plus a consumed-assertion replay cache) with SQLite, PostgreSQL, MySQL and Redis backends and a `create_saml_request_store` factory, each enforcing single-use atomically in its native primitive, refusing already-expired entries so no backend can return an expired request or forget a consumed assertion early, and keeping IDs exact-match on MySQL (binary collation, SHA-256 replay keys) where the default collation ignores case and `INSERT IGNORE` truncates long IDs. +- Add a `SamlRequestStore` (pending `AuthnRequest`s for `InResponseTo` correlation plus a consumed-assertion replay cache kept per realm, so one realm's IdP cannot use up assertion IDs another realm will receive) with SQLite, PostgreSQL, MySQL and Redis backends and a `create_saml_request_store` factory, each enforcing single-use atomically in its native primitive, refusing already-expired entries so no backend can return an expired request or forget a consumed assertion early, and keeping IDs exact-match on MySQL (binary collation, SHA-256 replay keys) where the default collation ignores case and `INSERT IGNORE` truncates long IDs. - Add an optional `saml` Cargo feature (off by default) that pulls in `samael` and gates the server-side SAML module, so default builds, plain `cargo` and existing CI stay free of the new C dependencies. - Refuse realm create/update requests that carry `saml_params` on servers built without the `saml` feature (HTTP 400), so unusable and unvalidated SAML configuration is never stored. -- Validate SAML settings on realm create/update when the `saml` feature is enabled: parse the pasted IdP metadata and always derive the IdP entityID, HTTPS HTTP-Redirect SSO URL, signing certificates and NameID format from it (overwriting any values sent), and refuse expired, oversized or multi-entity metadata, an ACS URL that isn't `https://…/saml/{realm_id}/acs`, return URLs outside the https origin allowlist, empty/duplicate/reserved claim names, and transient NameIDs without a subject attribute, each with an HTTP 400 naming the field. +- Validate SAML settings on realm create/update when the `saml` feature is enabled: parse the pasted IdP metadata and always derive the IdP entityID, HTTPS HTTP-Redirect SSO URL, signing certificates and NameID format from it (overwriting any values sent), and refuse expired, oversized or multi-entity metadata, an ACS URL that isn't `https://…/saml/{realm_id}/acs`, return URLs outside the https origin allowlist, empty/duplicate/reserved claim names, and transient NameIDs without a subject attribute, each with an HTTP 400 naming the field; SAML settings on the admin realm `_` are refused too, since SAML is never an administrator credential. - Add a server-wide SAML SP signing key (`[saml_sp_params]`: RSA private key and certificate PEM paths), checked at startup (RSA of at least 2048 bits, certificate matching the key) and required before any realm can enable SAML, and drop `SamlParams.sp_signing_key` from the client, admin-ui and OpenAPI types, because the key is server configuration and must never travel through the database or the admin API. - Add the SAML response validator for the upcoming ACS endpoint: it relies on samael for the signature, `InResponseTo`, issuer, validity window, recipient, destination and status checks, and adds what samael leaves out — refusing realms without an IdP certificate (samael would skip signature verification), requiring an `AudienceRestriction` where every restriction names our entity ID and an `AuthnStatement` (SAMLProf §4.1.4.2), allowing only RSA/ECDSA with SHA-256/384/512, and rejecting replayed assertions for as long as they could be accepted (5 minutes after `IssueInstant`, 3 minutes clock skew), with rejections reported as a new HTTP 401 `AuthError::Saml`. - Add SAML identity mapping for the upcoming ACS endpoint: the session subject is the configured subject attribute (exactly one non-empty value) or else the NameID, rejected at login when the IdP actually sends a transient NameID, optionally lowercased; roles are the deduplicated values of the role attribute (none when it is unset or absent); and only attributes listed in `attribute_claim_map` become extra claims, within the existing 4 KiB extra-claims budget. -- Add the SAML browser endpoints, registered under `/saml` only when `saml_sp_params` is set and rate limited like `/login`: `GET /saml/{realm_id}/login` checks `return_to` against the realm's allowed origins, stores the pending request (10 minutes) next to the sessions and redirects to the IdP with an RSA-SHA256-signed `AuthnRequest` carrying a 128-bit request ID; `POST /saml/{realm_id}/acs` requires the `_ea_saml` cookie set at login to match `RelayState` (login-CSRF protection), validates and maps the response, issues the `_ea_` session with `AuthScheme::Saml` and continues to the return URL through a same-site page so the `SameSite=Strict` session cookie is kept; `GET /saml/{realm_id}/metadata` publishes our SP metadata (entity ID, ACS, NameID format, signing certificate, signed requests), with a matching `AuthClient::get_saml_metadata`. +- Add the SAML browser endpoints, registered under `/saml` only when `saml_sp_params` is set and rate limited like `/login`: `GET /saml/{realm_id}/login` checks `return_to` against the realm's allowed origins, stores the pending request (10 minutes) next to the sessions and redirects to the IdP with an RSA-SHA256-signed `AuthnRequest` carrying a 128-bit request ID; `POST /saml/{realm_id}/acs` requires the `_ea_saml` cookie set at login (`Path=/`, so an ACS behind a path-prefixing reverse proxy still receives it) to match `RelayState` (login-CSRF protection), validates and maps the response, issues the `_ea_` session with `AuthScheme::Saml` and continues to the return URL through a same-site page so the `SameSite=Strict` session cookie is kept; `GET /saml/{realm_id}/metadata` publishes our SP metadata (entity ID, ACS, NameID format, signing certificate, signed requests), with a matching `AuthClient::get_saml_metadata`. - Purge expired SAML pending requests and replay-cache entries periodically, on the stale-session collector's interval. - Add SAML 2.0 as a realm authentication method in the admin UI: pasted IdP metadata is parsed in the browser with the server's acceptance rules and previewed (IdP entity ID, sign-in URL, signing-certificate subject and expiry, NameID formats); the SP entity ID and ACS URL are prefilled, copyable and paired with a download of this server's SP metadata; subject, role and allowlisted claim mappings, return origins and default return URL are checked per field (reserved claim names, duplicates, non-origin or non-https URLs, ACS URL of another realm); server rejections naming a SAML setting appear on that field and other refusals inline; and realm cards show a SAML tag and flag IdP signing certificates within 30 days of expiry. - Make `saml_params` optional in the admin UI's `RealmAuthParams` type, as the server omits it for realms without SAML, which also restores the admin UI's type-check. @@ -29,6 +29,7 @@ - Add a SAML request store test suite that runs against in-memory SQLite by default and against PostgreSQL, MySQL or Redis through the same `TEST_SESSIONS_STORE` switch as the session-store tests (single-use and concurrent takes, realm scoping, expiry on write and on read, replay detection, case-sensitive and long IDs, expiry purge), an xmlsec smoke test that exercises libxml2, xmlsec and its OpenSSL backend end to end, an API test checking that SAML settings are refused without the `saml` feature, unit tests for every SAML settings validation rule (prefixed and unprefixed metadata), and an API test checking that valid settings are stored with metadata-derived IdP fields while invalid ones get a field-specific 400, plus unit tests for the SAML signing-key checks and API tests checking that SAML realms are refused without a signing key and that a mismatched key, or the key section on a build without `saml`, stops the server from starting. - Add a fake SAML IdP test helper (`tests::saml_idp`) that renders ``s answering our `AuthnRequest`s with every validated field adjustable and signs the assertion and/or the response through xmlsec with RSA-SHA256 and SHA-256 digests, and switch the test IdP metadata to that IdP's RSA certificate, so the upcoming ACS validation can be tested against real signatures, untrusted keys and post-signing tampering. - Add SAML response validation tests that break one property at a time (signature missing, untrusted or broken by tampering, injected unsigned assertion, wrong request, issuer, recipient or audience, missing audience restriction or `AuthnStatement`, expired, not yet valid or issued too long ago, unsuccessful status, replay, realm without IdP certificate), and SAML identity mapping tests (NameID or single-valued subject attribute, transient NameID, lowercasing, roles, allowlisted extra claims, size limit), and end-to-end SAML tests over HTTPS (signed redirect verified with the SP certificate, fake-IdP login producing a session with the mapped subject, scheme and roles, replay, login-CSRF browser binding, return-URL allowlist, SP metadata, realms without SAML, no `/saml` routes without a signing key). +- Add two ACS-level SAML tests: a second login reusing an already consumed assertion ID is refused by the assertion replay cache (not by the spent pending request), and a login started in one realm is refused at another realm's ACS without being consumed; plus a store test that the same assertion ID is accepted once in each realm, and an API test that SAML settings are refused on the admin realm `_`. - Add admin UI tests for the SAML settings: certificate and metadata parsing and every client-side check, the realm drawer (preview, prefilled and copyable SP URLs, field-scoped client and server errors, turning SAML off), the SAML tag and expiry badge on realm cards, and a Playwright scenario creating a SAML realm from pasted metadata. - Add SAML end-to-end tests against a real Keycloak (`mise run test:saml-e2e`, `.github/scripts/test/test_saml_e2e.sh`): `saml_e2e/stack.sh` starts the SAML Docker image next to a digest-pinned Keycloak 26.4 with throw-away TLS and SP signing keys and a templated realm import, seeds a SAML realm through the admin API from Keycloak's live metadata, and the script checks the SP metadata, that Keycloak accepts our signed `AuthnRequest`, the return-URL allowlist, the ACS login-cookie check and realms without SAML, then runs the Playwright tests of the new top-level `e2e/` package — a real login with the mapped claims and a secure `_ea_` cookie, `return_to`, a wrong Keycloak password, a replayed SAML response, Keycloak no longer signing assertions, and a SAML realm created in the admin UI from the live metadata that alice then signs in to — so the shipped image is tested against a real IdP rather than only the fake one. @@ -47,4 +48,5 @@ - Document the `/saml/{realm_id}/login`, `/acs` and `/metadata` endpoints and a `SAML` tag in `openapi.yaml`, and proxy `/saml` from the admin UI dev and preview servers. - Add ADR-0003 (in `server/documentation/adr/`, outside the public mdBook and not linked from it, per the ADR relocation on `develop`) recording why SAML uses `samael` with a statically bundled, OpenSSL-only xmlsec, why the server-side feature is behind an optional `saml` Cargo feature while the API types are not, the v1 protocol scope, the single server-wide RSA SP signing key, and the rejected alternatives and accepted risks (including that xmlsec/libxml2 security updates now ship with our releases). - Document the prebuilt SAML packages, the `ghcr.io/cosmian/auth-verifier-saml` Docker image and `nix-build -A auth-verifier-static-saml` as ways to get a SAML-enabled server in `docs/saml.md`, and the `saml` feature, SAML Nix attributes, `--variant` option and `static-saml` hash files in `AGENTS.md` and the expected-hashes README. +- Document in `docs/saml.md` the extra SAML connection pool and the SAML tables to create when `auto_init_schema = false`, a newer sign-in in another tab as a cause of the "not started from this browser" 401, and that applications must key users on realm, scheme and subject together; and add the `sa` scheme to the `as_as` / `auth_scheme` lists in the flows and client-library references. - Fix the broken Mermaid "Login flow" diagram in `docs/saml.md` and the Flow 8 SAML sequence in `docs/authentication_flows.md` (`;` and `` in messages broke parsing) by rewriting it as a detailed sequence diagram of what the Authentication Verifier does at `/login` and `/acs`, and add an architecture flowchart of every component involved in a SAML sign-in. diff --git a/report/ISSUES_CHECKLIST.md b/report/ISSUES_CHECKLIST.md deleted file mode 100644 index 3d4e246b..00000000 --- a/report/ISSUES_CHECKLIST.md +++ /dev/null @@ -1,322 +0,0 @@ -# SAML PR Review — Issue Checklist - -**PR:** feat/saml-integration → remotes/origin/develop -**Issue:** GitHub #889 -**Date:** 2026-09-23 - ---- - -## BLOCKING ISSUES — Must Fix Before Merge - -### ✋ Issue 1: SAML Store Factory Never Instantiated - -- **Severity:** P1 (Runtime non-functional) -- **File:** `server/src/saml/factory.rs:20–22` -- **Problem:** `create_saml_request_store()` is never called. SAML realms have no login path. -- **Fix:** Wire factory into `start_auth_verifier()`, pass store to endpoint handlers -- **Complexity:** Medium (requires server startup refactor) -- **Blocks:** Issues 2, 3 (cleanup, expiry validation only matter once factory is used) -- **Dependency:** Follow-up PR (SAML endpoints) depends on this - -- [ ] **Owner:** Fix store instantiation -- [ ] **Verification:** Confirm store is Arc in server state -- [ ] **Testing:** Ensure factory is called during server init - ---- - -### ✋ Issue 2: SQL Cleanup Never Scheduled - -- **Severity:** P1 (Resource leak — unbounded table growth) -- **Files:** - - `server/src/saml/impls/sqlite.rs:157–160` (delete_expired method) - - `server/src/saml/factory.rs:17–20` (factory, no cleanup task) -- **Problem:** Expired rows never purged. Tables grow indefinitely. -- **Affected:** PostgreSQL, MySQL, SQLite -- **Unaffected:** Redis (TTL automatic) -- **Fix:** Implement background cleanup task (weekly or hourly) -- **Complexity:** Medium (similar to `start_stale_session_collector`) -- **Performance Impact:** Without fix, table scans degrade after weeks - -- [ ] **Owner:** Implement cleanup collector -- [ ] **Reference Pattern:** `server/src/session/factory.rs` (start_stale_session_collector) -- [ ] **Testing:** Verify delete_expired is called; rows actually purged -- [ ] **Config:** Document cleanup interval (default: 1 hour) - ---- - -### ✋ Issue 3: Redis Returns Expired Pending Requests - -- **Severity:** P1 (Logic violation — accepts invalid requests) -- **File:** `server/src/saml/impls/redis.rs:52` -- **Problem:** `.max(1)` clamps negative TTL to 1 second. Expired requests stored + fetchable. -- **Impact:** Already-expired requests accepted within ≤1 second grace period -- **Fix:** Reject expired writes OR validate after GETDEL -- **Complexity:** Low - -**Option A (Recommended):** - -```rust -// In store_pending_request -if request.expires_at <= Utc::now().timestamp() { - return Err(AuthError::Generic("Request already expired".into())); -} -``` - -**Option B (Alternative):** - -```rust -// In take_pending_request, after GETDEL -let now = Utc::now().timestamp(); -if request.expires_at <= now { - return Ok(None); -} -``` - -- [ ] **Owner:** Implement fix (choose A or B) -- [ ] **Testing:** Unit test with expired request, verify rejection -- [ ] **Edge case:** Test clock skew (now() slightly advances during set_ex) - ---- - -### ✋ Issue 4: Build Broken — samael+xmlsec Unconditional - -- **Severity:** P1 (Build breaks — blocks all CI) -- **Files:** - - `Cargo.toml` line 39 (samael = "0.0.22" with features = ["xmlsec"]) - - `server/Cargo.toml` line 49 (samael = { workspace = true }) - - `.github/workflows/main_base.yml` (no xmlsec1 installed) - - `nix/auth-verifier.nix` (missing xmlsecStatic, libclang) -- **Problem:** - - samael's build.rs calls xmlsec1-config, which fails without system xmlsec1 + libclang - - Direct Cargo CI (main_base.yml) doesn't have them - - Nix derivation doesn't provide them - - Breaks `cargo build`, `cargo test`, `nix build` -- **Impact:** ❌ Blocks all downstream development -- **Fix:** Gate behind feature flag OR update build infra - -**Option A (Recommended — Feature Flag):** - -```toml -[dependencies] -samael = { version = "0.0.22", features = ["xmlsec"], optional = true } - -[features] -saml = ["samael"] -``` - -- Default: no samael, no xmlsec needed -- Dev: `cargo build --features saml` -- Unblocks all CI until SAML endpoints ready - -**Option B (Alternative — Update Build Infra):** - -1. Add `xmlsec1-dev` to Ubuntu runners in main_base.yml -2. Add `libxml2-devel`, `xmlsec1-devel` to macOS via Homebrew -3. Update nix/auth-verifier.nix (see Issue 5) - -- [ ] **Owner:** Implement Option A or B -- [ ] **Testing:** `cargo build`, `cargo test`, `nix build .#auth-verifier` all succeed -- [ ] **CI Validation:** Merge to develop; verify all workflows pass - ---- - -### ✋ Issue 5: Nix Package Build Missing xmlsec Setup - -- **Severity:** P1 (Build breaks — nix build fails) -- **File:** `nix/auth-verifier.nix` (not modified in PR; needs changes) -- **Problem:** - - New file `nix/xmlsec-static.nix` + shell.nix updates are present - - Package derivation `nix/auth-verifier.nix` NOT updated - - Missing: `xmlsecStatic`, `libclang`, `LIBCLANG_PATH`, `BINDGEN_EXTRA_CLANG_ARGS` - - `nix build .#auth-verifier` fails -- **Impact:** ❌ Package CI jobs fail; users can't `nix build` auth-verifier -- **Fix:** Update auth-verifier.nix OR use feature flag (Issue 4 Option A) - -**If continuing with unconditional samael:** - -```nix -{ - buildInputs = [ - # ... existing ... - xmlsecStatic # Import from xmlsec-static.nix - pkgs.llvmPackages.libclang - ]; - - preBuild = '' - export LIBCLANG_PATH="${pkgs.llvmPackages.libclang.lib}/lib" - export BINDGEN_EXTRA_CLANG_ARGS="..." - ''; -} -``` - -- [ ] **Owner:** Update nix/auth-verifier.nix -- [ ] **Testing:** `nix build .#auth-verifier` succeeds -- [ ] **CI:** Verify packaging workflow passes - ---- - -### ✋ Issue 6: Pre-Release Dependency Without Audit — samael 0.0.22 - -- **Severity:** P1 (Dependency risk) -- **File:** `Cargo.toml` line 39 (workspace root) -- **Problem:** - - samael 0.0.22 is pre-1.0, labeled "work-in-progress" - - No security audit evidence - - No documented justification for this crate - - OpenSSL transitive deps not pinned (semver ranges allow drift) -- **Impact:** Potential upstream breaking changes; security gaps in pre-release code -- **Fix:** Document decision, add security review before shipping - -- [ ] **Owner:** Create ADR (Architectural Decision Record) - - Why samael? (criteria: SAML 2.0 compliance, active maintenance, Rust ecosystem fit) - - Alternatives considered? (xmldsig, xml-crypto bindings, etc.) - - Timeline for moving to stable version? -- [ ] **Owner:** Add pre-merge security review task - - Run `/security-review` on samael crate - - Document samael's pre-release status in SECURITY.md - - Track timeline for upgrade to stable -- [ ] **Owner:** Pin samael and OpenSSL transitive deps - - ```toml - samael = "=0.0.22" # Exact version until stable - openssl = "=0.10.81" # Lock transitive - ``` - -- [ ] **Reviewer:** Approve security audit before merge - ---- - -## HIGH-PRIORITY ISSUES — Strongly Recommended Before Merge - -### ⚠️ Issue 7: MySQL Breaks SAML ID Case-Sensitivity - -- **Severity:** P2 (Data integrity — ID mismatches) -- **File:** `server/src/saml/impls/mysql.rs:24–26` -- **Problem:** Default collation `utf8mb4_general_ci` is case-insensitive. SAML IDs are case-sensitive. -- **Impact:** - - `take_pending_request("_abc", ...)` can match `_ABC` - - Assertion ID replay cache collisions on case difference - - False replay positives → valid logins rejected -- **Fix:** Use binary collation - - ```sql - request_id TEXT COLLATE utf8mb4_bin PRIMARY KEY, - assertion_id TEXT COLLATE utf8mb4_bin PRIMARY KEY, - ``` - -- **Complexity:** Low (schema change only) - -- [ ] **Owner:** Update MySQL schema -- [ ] **Testing:** Unit test case-sensitivity (id='abc' vs 'ABC' treated as different) - ---- - -### ⚠️ Issue 8: MySQL Assertion IDs Silently Truncated to 255 Chars - -- **Severity:** P2 (Data integrity — false replay positives) -- **File:** `server/src/saml/impls/mysql.rs:42–45` -- **Problem:** `VARCHAR(255)` + `INSERT IGNORE` silently truncates long IDs. SAML has no length limit. -- **Impact:** - - Distinct assertions with same 255-char prefix treated as replay - - Valid logins denied -- **Fix:** Use TEXT or digest-based key - - ```sql - -- Option 1: Full ID as TEXT - assertion_id TEXT NOT NULL PRIMARY KEY, - - -- Option 2: Digest-based key (safer indexing) - assertion_id_hash BLOB NOT NULL PRIMARY KEY, - full_assertion_id TEXT NOT NULL, - ``` - -- **Complexity:** Medium (schema + insert logic update) - -- [ ] **Owner:** Update MySQL schema -- [ ] **Owner:** Update record_assertion_id logic -- [ ] **Testing:** Unit test with long IDs (>255 chars), verify no truncation - ---- - -## DEFERRED WORK — Covered by Follow-up PRs - -- [ ] HTTP endpoints (`/saml/{realm_id}/login`, `/saml/{realm_id}/acs`) -- [ ] SAML request handler (AuthnRequest creation, response validation, session creation) -- [ ] Server configuration for SAML parameters -- [ ] Realm admin UI for SAML setup -- [ ] E2E tests -- [ ] Documentation - ---- - -## VALIDATION CHECKLIST - -- [ ] Issue 1: Store factory wired into startup -- [ ] Issue 2: SQL cleanup scheduler implemented and running -- [ ] Issue 3: Redis expiry validation fixed -- [ ] Issue 4: samael gated OR build infra updated; `cargo build` passes on all platforms -- [ ] Issue 5: `nix build .#auth-verifier` passes -- [ ] Issue 6: ADR + security review task documented -- [ ] Issue 7: MySQL collation fixed -- [ ] Issue 8: MySQL ID truncation fixed -- [ ] Add PostgreSQL + MySQL + Redis integration tests -- [ ] All CI jobs pass (clippy, test, packaging, nix) -- [ ] Code review approved by maintainer - ---- - -## SIGN-OFF - -- [ ] **Author:** All issues acknowledged and scheduled -- [ ] **Reviewer:** All blockers verified fixed -- [ ] **CI:** Green on all platforms (Linux, macOS, Nix) -- [ ] **Ready to Merge:** Yes ✅ - ---- - -**Questions/Comments:** -(Space for reviewer notes) - ---- - -## AUTHOR RESPONSE — Triage (2026-09-24) - -Each finding was checked against the code at `58294a7` and the git history. - -### Verdict per issue - -| Issue | Verdict | Planned action | When | -|-------|---------|----------------|------| -| 1 — Store never instantiated | Correct, by design at this stage | Wire the store together with the endpoints (a store with no caller would only add startup state) | First endpoint (Chunk 6) | -| 2 — SQL cleanup never scheduled | **Correct, gap in the plan** | Add a periodic `delete_expired()` task, modeled on `start_stale_session_collector` | With the store wiring (Chunk 6) | -| 3 — Redis returns expired requests | **Correct** | Fix in **all four backends**: reject already-expired writes, and re-check expiry on `take` | Chunk 2c (next) | -| 4 — samael unconditional | Correct | Adopt Option A: optional `saml` Cargo feature, off by default | Step 0b (next) | -| 5 — `auth-verifier.nix` not wired | Correct | Add `xmlsecStatic` + libclang/bindgen setup, build with `--features saml`, run `nix-build -A auth-verifier-static` | Step 0b (next) | -| 6 — samael pre-1.0 | Correct as a risk; it was a deliberate choice | Write an ADR; security review of the signature-verification path before release | ADR now; review before release | -| 7 — MySQL case-insensitive IDs | **Correct** (effect: valid logins refused, not a bypass) | Binary collation for `request_id` | Chunk 2c | -| 8 — MySQL truncation | **Correct** (`INSERT IGNORE` truncates even in strict mode) | Key the replay cache by `SHA-256(assertion_id)` as `BINARY(32)` | Chunk 2c | -| Coverage — PG/MySQL/Redis untested | **Correct**; this also missed an existing convention | Run the SAML store tests against all backends through the same env-var switch as `server/src/tests/sessions_store.rs`, plus case and long-ID tests | Chunk 2c | -| SEC-01 — Redis replay-cache 1 s TTL | **Correct**, and it applies to every backend once rows are purged | `record_assertion_id` fails closed when `expires_at <= now`; the ACS must pass `NotOnOrAfter` + the same clock skew the validator accepts | Chunk 2c (store) / Chunk 5a (caller) | -| SEC-02 — `SamlParams` not validated | Correct; this is planned work | Validate metadata, origins, ACS URL and claim map at realm create/update | Chunk 3 | -| PANIC-01 — `claim_policy.rs` | Out of scope: not in this branch's diff (introduced in `d92a3f0`, release 0.4.0) | Separate ticket if wanted | — | -| ERR-01 — `AuthError::Generic` | Incorrect premise: existing session stores use `Generic` throughout, not `Db` | Consistent with the codebase; any change should be a repo-wide refactor | — | - -### Proposed fixes not adopted - -- **Issue 7/8, `TEXT ... PRIMARY KEY`:** MySQL rejects a `TEXT` primary key without a prefix length (error 1170). -- **Issue 8, `ON DUPLICATE KEY UPDATE expires_at = ?`:** do not use. A replay would report affected rows > 0 and be treated as a new assertion, which is a replay bypass. It would also extend the retention window. -- **Issue 6, pin `samael = "=0.0.22"`:** no effect. For `0.0.x` versions Cargo's default requirement already matches exactly `0.0.22`, and `Cargo.lock` pins the transitive OpenSSL crates. -- **Issue 6, SECURITY.md entry:** that file is reserved for vulnerabilities that shipped in a tagged release (AGENTS.md §11). The dependency rationale goes in the ADR instead. - -### Proposed order - -1. **Step 0b — build:** `saml` feature gate; wire and run the Nix release build; CHANGELOG entry. -2. **Chunk 2c — store hardening:** expiry contract in all backends, MySQL keys, tests on every backend. -3. **ADR** for the samael decision. -4. Continue the feature: Chunk 3 (metadata ingestion + `SamlParams` validation) → signature validation → endpoints and store wiring (with cleanup task) → admin UI → docs. - -### Status update (2026-09-24) - -- **Done in Step 0b — Issue 4:** optional `saml` feature (off by default); plain `cargo` builds and the existing CI jobs no longer compile samael. Servers built without it refuse `saml_params` with HTTP 400. Draft Nix-based `cargo-saml` CI job added for review. -- **Issue 5, deferred with reason:** the release build no longer compiles samael, because the feature is off by default. The Nix release derivation will be wired when SAML code first calls samael (Chunk 3/5a), since only then can the static-linkage check be meaningful. -- **Done in Chunk 2c — Issues 3, 7, 8, SEC-01 and backend coverage:** every backend refuses already-expired writes; Redis re-checks expiry on `take`; MySQL uses binary collation for IDs and `SHA-256(assertion_id)` as `BINARY(32)` replay keys. A new suite (`server/src/tests/saml_request_store.rs`) runs through `TEST_SESSIONS_STORE` and passes 10/10 against SQLite, PostgreSQL 16, MySQL 8.4 and Redis 7. diff --git a/server/documentation/docs/api_reference.md b/server/documentation/docs/api_reference.md index 0a150724..52e0cd94 100644 --- a/server/documentation/docs/api_reference.md +++ b/server/documentation/docs/api_reference.md @@ -161,7 +161,7 @@ back as a session cookie/token. **Authentication:** Session cookie (`_ea_` cookie). -**Request body** +**Request body:** ```json { @@ -215,7 +215,7 @@ Start a login at the realm's IdP. Meant to be opened by the browser, not called **Response — `302 Found`** — `Location` is the IdP sign-in URL carrying a signed `AuthnRequest` (HTTP-Redirect binding) whose `RelayState` is the request ID. Also sets -`_ea_saml=; Secure; HttpOnly; SameSite=None; Path=/saml/; Max-Age=600`. +`_ea_saml=; Secure; HttpOnly; SameSite=None; Path=/; Max-Age=600`. **Response — `400 Bad Request`** — unknown realm, realm without SAML, or `return_to` not allowed. diff --git a/server/documentation/docs/authentication_flows.md b/server/documentation/docs/authentication_flows.md index 1a200e89..cb3b45e2 100644 --- a/server/documentation/docs/authentication_flows.md +++ b/server/documentation/docs/authentication_flows.md @@ -465,7 +465,7 @@ These endpoints produce or manage **app tokens** (`X-Vault-Token`). See [app_aut | `exp` | Registered | Expiry time (Unix seconds) | | `iat` | Registered | Issued-at time (Unix seconds) | | `roles` | Authorization (RFC 9068 §2.2.3.1) | RBAC roles assigned to the user — **omitted when empty** | -| `as_as` | Private | Auth scheme: `"up"` / `"jwt"` / `"cc"` / `"f2"` / `"dc"` | +| `as_as` | Private | Auth scheme: `"up"` / `"jwt"` / `"cc"` / `"f2"` / `"dc"` / `"sa"` | | `as_rid` | Private | Realm ID (also used as the OPA domain scope) | | `as_pk` | Private | Client public key PEM — only present for mTLS sessions | diff --git a/server/documentation/docs/client_library.md b/server/documentation/docs/client_library.md index 77a27531..cce764d9 100644 --- a/server/documentation/docs/client_library.md +++ b/server/documentation/docs/client_library.md @@ -122,7 +122,7 @@ match session { } Some(data) => { println!("Authenticated: {} in realm {}", data.username, data.realm_id); - // data.auth_scheme — "up" | "jwt" | "cc" | "f2" | "dc" + // data.auth_scheme — "up" | "jwt" | "cc" | "f2" | "dc" | "sa" // data.created_at — Unix timestamp } } @@ -137,7 +137,7 @@ match session { | `session_id` | `String` | Unique session identifier (UUID) | | `realm_id` | `String` | Realm the session belongs to | | `username` | `String` | Authenticated client identity | -| `auth_scheme` | `String` | `"up"` / `"jwt"` / `"cc"` / `"f2"` / `"dc"` | +| `auth_scheme` | `String` | `"up"` / `"jwt"` / `"cc"` / `"f2"` / `"dc"` / `"sa"` | | `cookie_string` | `String` | Opaque value from the `_ea_` cookie | | `max_age_seconds` | `i64` | Maximum absolute session lifetime | | `max_stale_age_seconds` | `i64` | Idle timeout; any access resets this timer | @@ -652,6 +652,7 @@ pub enum AuthClientScheme { | `ClientCertificate` | `"cc"` | | `Fido2` | `"f2"` | | `DigitalCredentials` | `"dc"` | +| `Saml` | `"sa"` | ### `AuthenticationNextStep` @@ -692,7 +693,7 @@ pub struct AuthorizationClaims { } pub struct AuthPrivateClaims { - // "as_as" — auth scheme: "up" | "jwt" | "cc" | "f2" | "dc" + // "as_as" — auth scheme: "up" | "jwt" | "cc" | "f2" | "dc" | "sa" pub auth_scheme: Option, // "as_rid" — realm ID (also used as the OPA domain scope) pub realm_id: Option, diff --git a/server/documentation/docs/saml.md b/server/documentation/docs/saml.md index d3421e17..0123d623 100644 --- a/server/documentation/docs/saml.md +++ b/server/documentation/docs/saml.md @@ -39,7 +39,7 @@ for the API servers that validate sessions: a SAML session is a session like any | Local logout (`DELETE /sessions`) | SAML Single Logout | SAML is a login method for the users of a realm. It is **never** an administrator credential: -admins keep signing in with the methods of the `_` realm. +admins keep signing in with the methods of the `_` realm, which refuses SAML settings. --- @@ -255,7 +255,7 @@ sequenceDiagram note over EA: 400 if the realm does not use SAML
return_to must be https under allowed_return_origins (else 400)
absent return_to uses default_return_url EA->>RS: store pending request (request ID, realm, return URL, expires in 10 min) note over EA: AuthnRequest ID = request ID
signed RSA-SHA256 with the SP key (HTTP-Redirect) - EA-->>B: 302 Location: IdP SSO URL with SAMLRequest, RelayState = request ID, SigAlg, Signature
Set-Cookie: _ea_saml = request ID (Secure, HttpOnly, SameSite=None, Path=/saml/) + EA-->>B: 302 Location: IdP SSO URL with SAMLRequest, RelayState = request ID, SigAlg, Signature
Set-Cookie: _ea_saml = request ID (Secure, HttpOnly, SameSite=None, Path=/) rect rgba(128, 128, 128, 0.1) note over B,IdP: at the IdP, outside the Authentication Verifier @@ -271,7 +271,7 @@ sequenceDiagram EA->>RS: take pending request (RelayState, realm), single use RS-->>EA: pending request with return URL (none: 401) note over EA: base64-decode SAMLResponse
verify signature against the IdP certificates (xmlsec)
check algorithms, Issuer, InResponseTo, Audience,
Recipient, Destination, validity window, AuthnStatement, Status - EA->>RS: record assertion ID (replay cache) + EA->>RS: record assertion ID in this realm (replay cache) RS-->>EA: new (already seen: 401) note over EA: map identity: subject, roles, extra claims
issue session token (as_as = sa, as_rid = realm) EA->>SS: upsert session @@ -285,6 +285,15 @@ The pending requests and the replay cache live in the session store's database ( PostgreSQL, MySQL or Redis) and are purged on the stale-session collector's interval. The `/saml` routes share the per-IP rate limit of `/login`. +On the SQL backends SAML opens its own connection pool with the session store's +`max_connections` / `min_connections`, so plan for twice that many connections to that +database. With `auto_init_schema = false`, create the two SAML tables yourself: +`saml_pending_request` (`request_id` primary key, `realm_id`, `return_url`, `created_at`, +`expires_at`) and `saml_seen_assertion` (primary key `realm_id` + `assertion_id`, or +`assertion_id_sha256` `BINARY(32)` on MySQL, and `expires_at`), each indexed on +`expires_at`. The `CREATE TABLE` statements in `server/src/saml/impls/` are the reference. +Until they exist, SAML logins fail and the periodic cleanup logs errors. + The ACS answers with a small page instead of a redirect: the `SameSite=Strict` session cookie may be withheld on a navigation that directly follows the IdP's cross-site POST, and continuing from a page of this server makes it a same-site navigation. @@ -306,6 +315,10 @@ IdP's responsibility. Roles come straight from the IdP: this server does not filter them against its role list. +A SAML user is a different identity from a user of another scheme with the same name: a +session's identity is the realm, the scheme (`as_as`) and `sub` together. Applications must +key users on all three, never on `sub` alone. + --- ## Security checks @@ -355,7 +368,7 @@ WARN event="auth.login.failure" realm=my-service auth_scheme="saml" SAML login r | `404` on `/saml/…` | The server was built without `saml`, or `[saml_sp_params]` isn't set. | | `400 realm '…' does not exist or does not use SAML` | Wrong realm in the URL, or SAML isn't enabled for it. | | `400 return_to is not under an allowed return origin` | Add the origin to `allowed_return_origins`. | -| `401 this sign-in was not started from this browser` | The browser didn't send back the `_ea_saml` cookie: the login was started in another browser or profile, or the server isn't reached over HTTPS. Start again from `/saml/{realm}/login`. | +| `401 this sign-in was not started from this browser` | The browser didn't send back the `_ea_saml` cookie: the login was started in another browser or profile, a newer sign-in was started in another tab of the same browser (only the latest one can complete), or the server isn't reached over HTTPS. Start again from `/saml/{realm}/login`. | | `401 unknown or expired sign-in request` | More than 10 minutes at the IdP, the response was already used, or it answers another realm. | | `401 … must be signed` / signature errors | The IdP signs with a certificate missing from the pasted metadata, or signs neither the assertion nor the response. Refresh the metadata. | | `401 … AudienceRequirement` / `not addressed to this service provider` | The IdP's entity ID / audience setting differs from `sp_entity_id`. | diff --git a/server/documentation/docs/server_configuration.md b/server/documentation/docs/server_configuration.md index b2727290..84f03003 100644 --- a/server/documentation/docs/server_configuration.md +++ b/server/documentation/docs/server_configuration.md @@ -209,7 +209,8 @@ connect_timeout_secs = 10 idle_timeout_secs = 300 # When true (default), the database schema is created / migrated at startup. -# Set to false when migrations are managed externally (e.g., Flyway, Liquibase). +# Set to false when migrations are managed externally (e.g., Flyway, Liquibase); +# SAML builds then also need the SAML tables (see the SAML guide). auto_init_schema = true ``` diff --git a/server/documentation/openapi.yaml b/server/documentation/openapi.yaml index e04919e1..c655b233 100644 --- a/server/documentation/openapi.yaml +++ b/server/documentation/openapi.yaml @@ -357,7 +357,7 @@ components: plaintext: type: string description: Plaintext UTF-8 password, hashed with Argon2id by the server before storage. - example: { plaintext: "correct horse battery staple" } + example: {plaintext: correct horse battery staple} - type: object required: [hashed] properties: @@ -373,7 +373,7 @@ components: match exactly (the PHC format doesn't validate parameter *choice*, only syntax, and an unbounded `m` would let an unauthenticated login attempt allocate arbitrary memory before the password comparison even runs). - example: { hashed: "$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$aGFzaGVkdmFsdWU" } + example: {hashed: '$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$aGFzaGVkdmFsdWU'} UserPass: type: object @@ -392,10 +392,10 @@ components: ignored on input (use `password_input` to set/change the password) and always returned as an empty string on read — the hash is never returned to callers. - example: "" + example: '' password_input: allOf: - - $ref: "#/components/schemas/PasswordInput" + - $ref: '#/components/schemas/PasswordInput' nullable: true description: | How to set the password on create/update. Required on create; omit on @@ -422,7 +422,7 @@ components: schemes never source claims from a UserPass row). Selectively exposed via POST /certify when explicitly named in that request's `claims` field. Rejected with 400 if its serialized size exceeds 4 KiB. - example: { as_registrant: "acme-corp" } + example: {as_registrant: acme-corp} SessionData: type: object description: | @@ -576,7 +576,7 @@ components: sub: type: string nullable: true - description: "Absent when the request set `exclude_sub: true`." + description: 'Absent when the request set `exclude_sub: true`.' example: alice auth_scheme: $ref: '#/components/schemas/AuthScheme' @@ -1247,7 +1247,7 @@ paths: description: | Redirects the browser to the realm's IdP with a signed `AuthnRequest` (HTTP-Redirect binding, `RelayState` = request ID) and sets the short-lived - `_ea_saml` cookie (`Secure; HttpOnly; SameSite=None; Path=/saml/`) that the ACS + `_ea_saml` cookie (`Secure; HttpOnly; SameSite=None; Path=/`) that the ACS requires, so a login can only be completed by the browser that started it. parameters: - $ref: '#/components/parameters/realmId' @@ -1273,7 +1273,7 @@ paths: Set-Cookie: schema: type: string - example: _ea_saml=_4f1c...; Secure; HttpOnly; SameSite=None; Path=/saml/; Max-Age=600 + example: _ea_saml=_4f1c...; Secure; HttpOnly; SameSite=None; Path=/; Max-Age=600 '400': description: Unknown realm, realm without SAML, or `return_to` not allowed. content: @@ -1836,7 +1836,7 @@ paths: example: - realm: my-service username: alice - password_hash: "" + password_hash: '' change_password: false '401': $ref: '#/components/responses/Unauthorized' @@ -2075,8 +2075,8 @@ paths: example: realm: my-service username: alice - password_hash: "" - password_input: { plaintext: secret } + password_hash: '' + password_input: {plaintext: secret} change_password: false responses: '201': @@ -2112,7 +2112,7 @@ paths: example: - realm: my-service username: alice - password_hash: "" + password_hash: '' change_password: false '401': $ref: '#/components/responses/Unauthorized' @@ -2139,7 +2139,7 @@ paths: example: realm: my-service username: alice - password_hash: "" + password_hash: '' change_password: false '401': $ref: '#/components/responses/Unauthorized' @@ -2167,8 +2167,8 @@ paths: example: realm: my-service username: alice - password_hash: "" - password_input: { plaintext: newsecret } + password_hash: '' + password_input: {plaintext: newsecret} change_password: false responses: '200': diff --git a/server/src/saml/endpoints.rs b/server/src/saml/endpoints.rs index cd16be5b..2811bad6 100644 --- a/server/src/saml/endpoints.rs +++ b/server/src/saml/endpoints.rs @@ -217,8 +217,14 @@ async fn consume_response( .ok() .and_then(|bytes| String::from_utf8(bytes).ok()) .ok_or_else(|| AuthError::Saml("SAMLResponse is not base64-encoded UTF-8".to_string()))?; - let assertion = - validate_saml_response(&xml, ¶ms, &pending.request_id, state.store.as_ref()).await?; + let assertion = validate_saml_response( + &xml, + ¶ms, + &realm.id, + &pending.request_id, + state.store.as_ref(), + ) + .await?; let identity = map_identity(&assertion, ¶ms)?; Ok((realm, pending, identity)) } @@ -263,8 +269,9 @@ fn resolve_return_url(params: &SamlParams, return_to: Option<&str>) -> AuthResul fn request_cookie(request_id: &str) -> Cookie<'static> { // SameSite=None: the IdP's form POST back to the ACS is a cross-site request. + // Path=/: the ACS URL may sit behind a reverse-proxy path prefix (`sp_acs_url`). Cookie::build(REQUEST_COOKIE, request_id.to_string()) - .path("/saml/") + .path("/") .secure(true) .http_only(true) .same_site(SameSite::None) diff --git a/server/src/saml/impls/mysql.rs b/server/src/saml/impls/mysql.rs index e09cee52..dffaa090 100644 --- a/server/src/saml/impls/mysql.rs +++ b/server/src/saml/impls/mysql.rs @@ -44,8 +44,10 @@ impl MySqlSamlRequestStore { sqlx::query( r#" CREATE TABLE IF NOT EXISTS saml_seen_assertion ( - assertion_id_sha256 BINARY(32) PRIMARY KEY, + realm_id VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, + assertion_id_sha256 BINARY(32) NOT NULL, expires_at BIGINT NOT NULL, + PRIMARY KEY (realm_id, assertion_id_sha256), INDEX idx_saml_seen_expires_at (expires_at) ) "#, @@ -158,13 +160,19 @@ impl SamlRequestStore for MySqlSamlRequestStore { Ok(result) } - async fn record_assertion_id(&self, assertion_id: &str, expires_at: i64) -> AuthResult { + async fn record_assertion_id( + &self, + realm_id: &str, + assertion_id: &str, + expires_at: i64, + ) -> AuthResult { ensure_unexpired(expires_at, "SAML assertion id")?; // INSERT IGNORE: the first insert for an assertion id wins (1 row affected); a replay // collides on the primary key and affects 0 rows. let result = sqlx::query( - "INSERT IGNORE INTO saml_seen_assertion (assertion_id_sha256, expires_at) VALUES (?, ?)", + "INSERT IGNORE INTO saml_seen_assertion (realm_id, assertion_id_sha256, expires_at) VALUES (?, ?, ?)", ) + .bind(realm_id) .bind(Sha256::digest(assertion_id.as_bytes()).to_vec()) .bind(expires_at) .execute(&self.pool) diff --git a/server/src/saml/impls/postgres.rs b/server/src/saml/impls/postgres.rs index 8d0853b2..571f219e 100644 --- a/server/src/saml/impls/postgres.rs +++ b/server/src/saml/impls/postgres.rs @@ -48,8 +48,10 @@ impl PostgresSamlRequestStore { sqlx::query( r#" CREATE TABLE IF NOT EXISTS saml_seen_assertion ( - assertion_id TEXT PRIMARY KEY, - expires_at BIGINT NOT NULL + realm_id TEXT NOT NULL, + assertion_id TEXT NOT NULL, + expires_at BIGINT NOT NULL, + PRIMARY KEY (realm_id, assertion_id) ) "#, ) @@ -138,13 +140,19 @@ impl SamlRequestStore for PostgresSamlRequestStore { row.map(row_to_pending).transpose() } - async fn record_assertion_id(&self, assertion_id: &str, expires_at: i64) -> AuthResult { + async fn record_assertion_id( + &self, + realm_id: &str, + assertion_id: &str, + expires_at: i64, + ) -> AuthResult { ensure_unexpired(expires_at, "SAML assertion id")?; // ON CONFLICT DO NOTHING: the first insert for an assertion id wins (1 row affected); // a replay conflicts and affects 0 rows. let result = sqlx::query( - "INSERT INTO saml_seen_assertion (assertion_id, expires_at) VALUES ($1, $2) ON CONFLICT (assertion_id) DO NOTHING", + "INSERT INTO saml_seen_assertion (realm_id, assertion_id, expires_at) VALUES ($1, $2, $3) ON CONFLICT (realm_id, assertion_id) DO NOTHING", ) + .bind(realm_id) .bind(assertion_id) .bind(expires_at) .execute(&self.pool) diff --git a/server/src/saml/impls/redis.rs b/server/src/saml/impls/redis.rs index 9ed73b58..1e52d206 100644 --- a/server/src/saml/impls/redis.rs +++ b/server/src/saml/impls/redis.rs @@ -12,7 +12,8 @@ use crate::{ /// /// Both kinds of state carry a TTL, so Redis expires them automatically: /// - `saml:pending:{realm_id}:{request_id}` -> JSON `PendingSamlRequest` -/// - `saml:seen:{assertion_id}` -> a marker set only while the id must be remembered +/// - `saml:seen:{realm_id length}:{realm_id}:{assertion_id}` -> a marker set only while the id +/// must be remembered pub struct RedisSamlRequestStore { client: Arc, } @@ -28,8 +29,9 @@ impl RedisSamlRequestStore { format!("saml:pending:{realm_id}:{request_id}") } - fn seen_key(assertion_id: &str) -> String { - format!("saml:seen:{assertion_id}") + fn seen_key(realm_id: &str, assertion_id: &str) -> String { + // The length prefix keeps `a:b` + `c` and `a` + `b:c` apart: both IDs may contain ':'. + format!("saml:seen:{}:{realm_id}:{assertion_id}", realm_id.len()) } async fn conn(&self) -> AuthResult { @@ -86,10 +88,15 @@ impl SamlRequestStore for RedisSamlRequestStore { } } - async fn record_assertion_id(&self, assertion_id: &str, expires_at: i64) -> AuthResult { + async fn record_assertion_id( + &self, + realm_id: &str, + assertion_id: &str, + expires_at: i64, + ) -> AuthResult { let ttl = ensure_unexpired(expires_at, "SAML assertion id")?; let mut conn = self.conn().await?; - let key = Self::seen_key(assertion_id); + let key = Self::seen_key(realm_id, assertion_id); // `SET key 1 NX EX ttl` sets only if the key is absent: a reply of OK means newly // recorded, a nil reply means the id was already seen (a replay). One atomic command. let set: Option = redis::cmd("SET") diff --git a/server/src/saml/impls/sqlite.rs b/server/src/saml/impls/sqlite.rs index 3b6d3de3..0ca869a2 100644 --- a/server/src/saml/impls/sqlite.rs +++ b/server/src/saml/impls/sqlite.rs @@ -48,8 +48,10 @@ impl SqliteSamlRequestStore { sqlx::query( r#" CREATE TABLE IF NOT EXISTS saml_seen_assertion ( - assertion_id TEXT PRIMARY KEY, - expires_at INTEGER NOT NULL + realm_id TEXT NOT NULL, + assertion_id TEXT NOT NULL, + expires_at INTEGER NOT NULL, + PRIMARY KEY (realm_id, assertion_id) ) "#, ) @@ -139,14 +141,20 @@ impl SamlRequestStore for SqliteSamlRequestStore { .transpose() } - async fn record_assertion_id(&self, assertion_id: &str, expires_at: i64) -> AuthResult { + async fn record_assertion_id( + &self, + realm_id: &str, + assertion_id: &str, + expires_at: i64, + ) -> AuthResult { ensure_unexpired(expires_at, "SAML assertion id")?; // INSERT OR IGNORE relies on the PRIMARY KEY: the first insert for an assertion id wins // (1 row affected); a replay collides and affects 0 rows. `rows_affected` therefore // distinguishes a newly-recorded id from a replay without a separate read. let result = sqlx::query( - "INSERT OR IGNORE INTO saml_seen_assertion (assertion_id, expires_at) VALUES (?, ?)", + "INSERT OR IGNORE INTO saml_seen_assertion (realm_id, assertion_id, expires_at) VALUES (?, ?, ?)", ) + .bind(realm_id) .bind(assertion_id) .bind(expires_at) .execute(&self.pool) diff --git a/server/src/saml/request_store.rs b/server/src/saml/request_store.rs index 14cb89a5..d22dea55 100644 --- a/server/src/saml/request_store.rs +++ b/server/src/saml/request_store.rs @@ -42,12 +42,18 @@ pub trait SamlRequestStore: Send + Sync { realm_id: &str, ) -> AuthResult>; - /// Record a consumed assertion ID for replay defense. Returns `true` if it was newly - /// recorded, `false` if it had already been seen (a replay). Fails if `expires_at` has - /// already passed. `expires_at` must cover every instant at which the assertion could still - /// be accepted (for the ACS: its `IssueInstant` plus the accepted issue delay), because the - /// entry may be forgotten after that. - async fn record_assertion_id(&self, assertion_id: &str, expires_at: i64) -> AuthResult; + /// Record an assertion ID consumed in `realm_id` for replay defense. Returns `true` if it was + /// newly recorded, `false` if it had already been seen in that realm (a replay). Realms keep + /// separate caches, so one realm's IdP cannot use up IDs another realm will receive. Fails + /// if `expires_at` has already passed. `expires_at` must cover every instant at which the + /// assertion could still be accepted (for the ACS: its `IssueInstant` plus the accepted issue + /// delay), because the entry may be forgotten after that. + async fn record_assertion_id( + &self, + realm_id: &str, + assertion_id: &str, + expires_at: i64, + ) -> AuthResult; /// Delete expired pending requests and replay-cache entries. Called periodically. async fn delete_expired(&self) -> AuthResult<()>; diff --git a/server/src/saml/response_validation.rs b/server/src/saml/response_validation.rs index 3fa1b025..27561c1b 100644 --- a/server/src/saml/response_validation.rs +++ b/server/src/saml/response_validation.rs @@ -16,8 +16,9 @@ const MAX_CLOCK_SKEW_SECONDS: i64 = 180; /// long a consumed assertion ID must be remembered to detect a replay. const MAX_ISSUE_DELAY_SECONDS: i64 = 300; -/// Check a SAML `` answering our `AuthnRequest` `request_id`, against the realm -/// settings `params`, and return its verified assertion. Each assertion is accepted once. +/// Check a SAML `` answering our `AuthnRequest` `request_id`, against the settings +/// `params` of realm `realm_id`, and return its verified assertion. Each assertion is accepted +/// once per realm. /// /// samael checks the signature (of the response or the assertion), `InResponseTo`, issuer, /// validity window, bearer `Recipient`, `Destination` and status; this adds what it leaves @@ -26,6 +27,7 @@ const MAX_ISSUE_DELAY_SECONDS: i64 = 300; pub(crate) async fn validate_saml_response( response_xml: &str, params: &SamlParams, + realm_id: &str, request_id: &str, store: &dyn SamlRequestStore, ) -> AuthResult { @@ -47,7 +49,7 @@ pub(crate) async fn validate_saml_response( } // Rounded up: the store needs a whole second that is still in the future. if !store - .record_assertion_id(&assertion.id, accepted_until.timestamp() + 1) + .record_assertion_id(realm_id, &assertion.id, accepted_until.timestamp() + 1) .await? { return Err(saml_error("assertion has already been used")); @@ -200,6 +202,7 @@ mod tests { validate_saml_response( xml, &realm_params(), + REALM, REQUEST_ID, memory_store().await.as_ref(), ) @@ -251,11 +254,11 @@ mod tests { async fn a_replayed_assertion_is_rejected() { let (params, store) = (realm_params(), memory_store().await); let xml = TestIdp::new().sign(&response()); - validate_saml_response(&xml, ¶ms, REQUEST_ID, store.as_ref()) + validate_saml_response(&xml, ¶ms, REALM, REQUEST_ID, store.as_ref()) .await .expect("first use"); let Err(AuthError::Saml(message)) = - validate_saml_response(&xml, ¶ms, REQUEST_ID, store.as_ref()).await + validate_saml_response(&xml, ¶ms, REALM, REQUEST_ID, store.as_ref()).await else { panic!("the second use must be rejected"); }; @@ -395,8 +398,14 @@ mod tests { let mut params = realm_params(); params.idp_signing_certificates.clear(); let xml = TestIdp::new().sign(&response()); - let result = - validate_saml_response(&xml, ¶ms, REQUEST_ID, memory_store().await.as_ref()).await; + let result = validate_saml_response( + &xml, + ¶ms, + REALM, + REQUEST_ID, + memory_store().await.as_ref(), + ) + .await; assert!( matches!(result, Err(AuthError::Saml(ref m)) if m.contains("no IdP signing certificate")) ); diff --git a/server/src/server/endpoints/super_admins_endpoints.rs b/server/src/server/endpoints/super_admins_endpoints.rs index 0883b56c..6e8b4d24 100644 --- a/server/src/server/endpoints/super_admins_endpoints.rs +++ b/server/src/server/endpoints/super_admins_endpoints.rs @@ -15,6 +15,12 @@ fn check_saml_params(realm: &mut Realm, server_params: &ServerParams) -> Result< let Some(params) = realm.auth_params.saml_params.as_mut() else { return Ok(()); }; + if realm.id == crate::models::ADMIN_REALM { + return Err(AuthError::BadRequest( + "SAML can't be enabled on the admin realm `_`: SAML is never an administrator credential" + .to_string(), + )); + } if server_params.saml_sp_params.is_none() { return Err(AuthError::BadRequest( "SAML needs a server signing key: set `saml_sp_params` in the server configuration" diff --git a/server/src/tests/saml_flow.rs b/server/src/tests/saml_flow.rs index d371d685..2bf15bc1 100644 --- a/server/src/tests/saml_flow.rs +++ b/server/src/tests/saml_flow.rs @@ -29,6 +29,7 @@ use crate::{ }; const REALM: &str = "saml_flow"; +const OTHER_REALM: &str = "saml_flow_other"; const SP_CERTIFICATE_PEM: &str = include_str!("certificates/rsa/auth.server.cert.pem"); fn realm_params() -> SamlParams { @@ -102,7 +103,7 @@ async fn start_login(ctx: &TestsContext, return_to: Option<&str>) -> AuthResult< .map_err(http_error)?; assert_eq!(response.status(), StatusCode::FOUND); let (cookie, header) = set_cookie(&response, "_ea_saml").expect("request cookie"); - for attribute in ["Secure", "HttpOnly", "SameSite=None", "Path=/saml/"] { + for attribute in ["Secure", "HttpOnly", "SameSite=None", "Path=/;"] { assert!( header.contains(attribute), "{attribute} missing from: {header}" @@ -127,13 +128,23 @@ async fn post_to_acs( xml: &str, relay_state: &str, cookie: Option<&str>, +) -> AuthResult { + post_to_realm_acs(ctx, REALM, xml, relay_state, cookie).await +} + +async fn post_to_realm_acs( + ctx: &TestsContext, + realm: &str, + xml: &str, + relay_state: &str, + cookie: Option<&str>, ) -> AuthResult { let body = url::form_urlencoded::Serializer::new(String::new()) .append_pair("SAMLResponse", &STANDARD.encode(xml)) .append_pair("RelayState", relay_state) .finish(); let mut request = browser() - .post(format!("{}/saml/{REALM}/acs", ctx.get_client_url())) + .post(format!("{}/saml/{realm}/acs", ctx.get_client_url())) .header(CONTENT_TYPE, "application/x-www-form-urlencoded") .body(body); if let Some(cookie) = cookie { @@ -224,6 +235,63 @@ async fn the_acs_only_accepts_the_browser_that_started_the_login() -> AuthResult ctx.stop_server().await } +/// The second login has its own pending request, so only the assertion-ID cache can refuse it. +#[actix_web::test] +async fn a_replayed_assertion_is_refused_at_the_acs() -> AuthResult<()> { + init_test_logging(None); + let ctx = start_saml_server().await?; + let idp = TestIdp::new(); + + let mut statuses = Vec::new(); + let mut last_body = String::new(); + for _ in 0..2 { + let (idp_url, cookie) = start_login(&ctx, None).await?; + let relay_state = query_value(&idp_url, "RelayState").expect("RelayState"); + let mut answer = TestResponse::answering(&relay_state, &realm_params()); + answer.assertion_id = "_replayed-assertion".to_string(); + let response = post_to_acs(&ctx, &idp.sign(&answer), &relay_state, Some(&cookie)).await?; + statuses.push(response.status()); + last_body = response.text().await.map_err(http_error)?; + } + + assert_eq!(statuses, [StatusCode::OK, StatusCode::UNAUTHORIZED]); + assert!(last_body.contains("already been used"), "{last_body}"); + + ctx.stop_server().await +} + +/// A login started in one realm can't be completed at another realm's ACS, and the attempt +/// doesn't consume it. +#[actix_web::test] +async fn another_realms_acs_refuses_the_login() -> AuthResult<()> { + init_test_logging(None); + let ctx = start_saml_server().await?; + let mut other = test_realm(OTHER_REALM); + other.auth_params.saml_params = Some(test_saml_params(OTHER_REALM)); + authenticate_as_admin(&ctx) + .await? + .create_realm_as_super_admin(&other) + .await?; + + let (idp_url, request_cookie) = start_login(&ctx, None).await?; + let (xml, relay_state) = idp_answer(&idp_url); + + let wrong = + post_to_realm_acs(&ctx, OTHER_REALM, &xml, &relay_state, Some(&request_cookie)).await?; + assert_eq!(wrong.status(), StatusCode::UNAUTHORIZED); + assert!(set_cookie(&wrong, "_ea_").is_none()); + let body = wrong.text().await.map_err(http_error)?; + assert!( + body.contains("unknown or expired sign-in request"), + "{body}" + ); + + let right = post_to_acs(&ctx, &xml, &relay_state, Some(&request_cookie)).await?; + assert_eq!(right.status(), StatusCode::OK); + + ctx.stop_server().await +} + #[actix_web::test] async fn a_return_url_outside_the_allowed_origins_is_refused() -> AuthResult<()> { init_test_logging(None); diff --git a/server/src/tests/saml_request_store.rs b/server/src/tests/saml_request_store.rs index 22a4cf25..4c756449 100644 --- a/server/src/tests/saml_request_store.rs +++ b/server/src/tests/saml_request_store.rs @@ -69,6 +69,8 @@ fn unique(prefix: &str) -> String { format!("{prefix}-{}", Uuid::new_v4()) } +const ASSERTION_REALM: &str = "assertion-realm"; + fn now() -> i64 { Utc::now().timestamp() } @@ -193,23 +195,50 @@ async fn test_record_assertion_id_detects_replay() { assert!( store - .record_assertion_id(&id, now() + 300) + .record_assertion_id(ASSERTION_REALM, &id, now() + 300) .await .expect("record") ); assert!( !store - .record_assertion_id(&id, now() + 300) + .record_assertion_id(ASSERTION_REALM, &id, now() + 300) .await .expect("record"), "the same assertion id seen twice is a replay" ); assert!( store - .record_assertion_id(&other_id, now() + 300) + .record_assertion_id(ASSERTION_REALM, &other_id, now() + 300) + .await + .expect("record") + ); +} + +#[tokio::test] +async fn test_assertion_ids_are_scoped_per_realm() { + let store = store().await; + let (id, realm, other_realm) = (unique("assertion"), unique("realm"), unique("realm")); + + assert!( + store + .record_assertion_id(&realm, &id, now() + 300) .await .expect("record") ); + assert!( + store + .record_assertion_id(&other_realm, &id, now() + 300) + .await + .expect("record"), + "one realm's assertion ids must not block another realm's" + ); + assert!( + !store + .record_assertion_id(&realm, &id, now() + 300) + .await + .expect("record"), + "a replay within the realm is still detected" + ); } #[tokio::test] @@ -217,7 +246,7 @@ async fn test_record_assertion_id_refuses_an_already_expired_entry() { let store = store().await; let result = store - .record_assertion_id(&unique("assertion"), now() - 1) + .record_assertion_id(ASSERTION_REALM, &unique("assertion"), now() - 1) .await; assert!( result.is_err(), @@ -253,13 +282,13 @@ async fn test_ids_are_case_sensitive() { let assertion_id = unique("Assertion-Mixed"); assert!( store - .record_assertion_id(&assertion_id, now() + 300) + .record_assertion_id(&realm, &assertion_id, now() + 300) .await .expect("record") ); assert!( store - .record_assertion_id(&assertion_id.to_lowercase(), now() + 300) + .record_assertion_id(&realm, &assertion_id.to_lowercase(), now() + 300) .await .expect("record"), "assertion ids differing only in case are different assertions" @@ -274,20 +303,20 @@ async fn test_long_assertion_ids_sharing_a_prefix_are_distinct() { assert!( store - .record_assertion_id(&first, now() + 300) + .record_assertion_id(ASSERTION_REALM, &first, now() + 300) .await .expect("record") ); assert!( store - .record_assertion_id(&second, now() + 300) + .record_assertion_id(ASSERTION_REALM, &second, now() + 300) .await .expect("record"), "ids that differ only after 255 characters are different assertions" ); assert!( !store - .record_assertion_id(&first, now() + 300) + .record_assertion_id(ASSERTION_REALM, &first, now() + 300) .await .expect("record"), "a long id is still recognized as a replay" @@ -304,11 +333,11 @@ async fn test_delete_expired_purges_only_expired_entries() { .await .expect("store request"); store - .record_assertion_id(&stale_assertion, now() + 2) + .record_assertion_id(&realm, &stale_assertion, now() + 2) .await .expect("record"); store - .record_assertion_id(&fresh_assertion, now() + 300) + .record_assertion_id(&realm, &fresh_assertion, now() + 300) .await .expect("record"); @@ -317,14 +346,14 @@ async fn test_delete_expired_purges_only_expired_entries() { assert!( store - .record_assertion_id(&stale_assertion, now() + 300) + .record_assertion_id(&realm, &stale_assertion, now() + 300) .await .expect("record"), "the expired entry must have been purged" ); assert!( !store - .record_assertion_id(&fresh_assertion, now() + 300) + .record_assertion_id(&realm, &fresh_assertion, now() + 300) .await .expect("record"), "an unexpired entry must survive the purge" diff --git a/server/src/tests/super_admin_api.rs b/server/src/tests/super_admin_api.rs index 3a238106..a3ab7417 100644 --- a/server/src/tests/super_admin_api.rs +++ b/server/src/tests/super_admin_api.rs @@ -383,6 +383,41 @@ async fn test_saml_params_validated_on_realm_create_and_update() -> AuthResult<( ctx.stop_server().await } +/// SAML is never an administrator credential, so the admin realm `_` refuses SAML settings +/// even when they are otherwise valid. +#[cfg(feature = "saml")] +#[actix_web::test] +async fn test_saml_params_refused_on_admin_realm() -> AuthResult<()> { + init_test_logging(None); + let mut server_params = get_default_server_params()?; + server_params.saml_sp_params = Some(test_saml_sp_params()); + let ctx = start_test_server(server_params).await?; + let client = authenticate_as_admin(&ctx).await?; + + let mut admin_realm = client.get_realm_as_super_admin(ADMIN_REALM).await?; + admin_realm.auth_params.saml_params = + Some(crate::tests::helpers::test_saml_params(ADMIN_REALM)); + let err = client + .update_realm_as_super_admin(ADMIN_REALM, &admin_realm) + .await + .expect_err("Expected update_realm to refuse SAML on the admin realm"); + assert!( + matches!(err, AuthError::FailedHttpStatus(ref m) if m.contains("400") && m.contains("admin realm")), + "Expected a 400 naming the admin realm, got: {err:?}" + ); + assert!( + client + .get_realm_as_super_admin(ADMIN_REALM) + .await? + .auth_params + .saml_params + .is_none(), + "The refused update must not have been stored" + ); + + ctx.stop_server().await +} + // ── Authorization enforcement ──────────────────────────────────────────────── /// A realm admin (non-super-admin) must not be able to update any realm (HTTP 403). From 445f13e3b4de0273eaa2fc0ca6f3647325a478eb Mon Sep 17 00:00:00 2001 From: charming_wicket <235307685+charming-wicket-5502@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:12:15 +0200 Subject: [PATCH 19/19] fix(deps): bump rustls to 0.23.46 for RUSTSEC-2026-0348 and RUSTSEC-2026-0349 Unblocks the cargo-deny advisories check. Also moves spin off the yanked 0.9.8 release. --- CHANGELOG/feat_saml-integration.md | 4 ++++ Cargo.lock | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG/feat_saml-integration.md b/CHANGELOG/feat_saml-integration.md index 31beafc7..a2871cdc 100644 --- a/CHANGELOG/feat_saml-integration.md +++ b/CHANGELOG/feat_saml-integration.md @@ -23,6 +23,10 @@ - Stop the admin UI's realm drawer from ticking TOTP when editing a realm without it: the server omits `totp_params` when unset, but the drawer only treated `null` as disabled, so saving such a realm silently enabled TOTP. +## Security + +- Bump `rustls` 0.23.45 → 0.23.46 for RUSTSEC-2026-0349 (an unauthenticated ServerHello could make outbound client connections reuse a classical-only key share, losing post-quantum confidentiality) and RUSTSEC-2026-0348 (Brotli certificate decompression; not reachable here since the `brotli` feature is off), which failed the cargo-deny advisories check; also move `spin` off the yanked 0.9.8. + ## Tests - Stop the admin UI unit tests from failing CI with an unhandled `window is not defined` by making antd's static `message.*` toasts no-ops in the test setup (they mount a React root outside Testing Library's cleanup that flushed after jsdom teardown), and keep a 50 ms minimum settle time in the teardown. diff --git a/Cargo.lock b/Cargo.lock index 688b796d..3fcc6ee2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2919,9 +2919,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", "log", @@ -3267,9 +3267,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ]