From 72bc8c05ab772a9cd757032d8e1b78e3800ea9a7 Mon Sep 17 00:00:00 2001 From: Maurus Decimus <11444311+mdecimus@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:10:06 +0100 Subject: [PATCH] Registry testing - part 16 --- Cargo.lock | 36 + crates/directory/Cargo.toml | 3 +- crates/directory/src/backend/ldap/config.rs | 72 +- crates/directory/src/backend/ldap/lookup.rs | 277 ++--- crates/directory/src/backend/ldap/mod.rs | 12 +- crates/directory/src/backend/oidc/config.rs | 162 ++- crates/directory/src/backend/oidc/lookup.rs | 546 ++++++--- crates/directory/src/backend/oidc/mod.rs | 89 +- crates/directory/src/core/dispatch.rs | 8 +- crates/http-proto/src/context.rs | 2 +- .../src/task_manager/spam_classifier.rs | 2 + crates/spam-filter/src/modules/expression.rs | 2 +- tests/docker/docker-compose.yml | 6 +- tests/docker/keycloak/stalwart-realm.json | 41 +- .../docker/ldap/{seed.ldif => 50-users.ldif} | 31 +- tests/docker/ldap/60-groups.ldif | 20 + .../smtp/antispam/classifier_features.test | 12 - tests/src/directory/internal.rs | 1070 ----------------- tests/src/directory/ldap.rs | 375 ++---- tests/src/directory/mod.rs | 777 +----------- tests/src/directory/oidc.rs | 234 ++-- tests/src/lib.rs | 3 +- tests/src/smtp/inbound/antispam.rs | 139 +-- 23 files changed, 1100 insertions(+), 2819 deletions(-) rename tests/docker/ldap/{seed.ldif => 50-users.ldif} (58%) create mode 100644 tests/docker/ldap/60-groups.ldif delete mode 100644 tests/src/directory/internal.rs diff --git a/Cargo.lock b/Cargo.lock index 7c2b5e1e..ac534f9d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1799,6 +1799,7 @@ dependencies = [ "compact_str", "deadpool 0.10.0", "futures", + "jsonwebtoken", "ldap3", "mail-builder", "mail-parser", @@ -3772,6 +3773,29 @@ dependencies = [ "serde", ] +[[package]] +name = "jsonwebtoken" +version = "10.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" +dependencies = [ + "base64 0.22.1", + "ed25519-dalek", + "getrandom 0.2.17", + "hmac 0.12.1", + "js-sys", + "p256", + "p384", + "pem", + "rand 0.8.5", + "rsa", + "serde", + "serde_json", + "sha2 0.10.9", + "signature", + "simple_asn1", +] + [[package]] name = "keccak" version = "0.1.6" @@ -7180,6 +7204,18 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + [[package]] name = "siphasher" version = "1.0.2" diff --git a/crates/directory/Cargo.toml b/crates/directory/Cargo.toml index 364e7fd3..da959319 100644 --- a/crates/directory/Cargo.toml +++ b/crates/directory/Cargo.toml @@ -33,12 +33,13 @@ futures = "0.3" regex = "1.7.0" serde = { version = "1.0", features = ["derive"]} totp-rs = { version = "5.5.1", features = ["otpauth"] } -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-webpki-roots", "http2"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-webpki-roots", "http2"] } # Use aws serde_json = "1.0" base64 = "0.22" rkyv = { version = "0.8.10", features = ["little_endian"] } compact_str = { version = "0.9.0", features = ["rkyv", "serde"] } nohash-hasher = "0.2.0" +jsonwebtoken = { version = "10.3.0", features = ["rust_crypto"] } [dev-dependencies] tokio = { version = "1.47", features = ["full"] } diff --git a/crates/directory/src/backend/ldap/config.rs b/crates/directory/src/backend/ldap/config.rs index e6c54341..be3d1cfd 100644 --- a/crates/directory/src/backend/ldap/config.rs +++ b/crates/directory/src/backend/ldap/config.rs @@ -5,7 +5,7 @@ */ use super::{Bind, LdapConnectionManager, LdapDirectory, LdapFilter, LdapFilterItem, LdapMappings}; -use crate::{Directory, backend::ldap::AuthBind}; +use crate::Directory; use deadpool::{Runtime, managed::Pool}; use ldap3::LdapConnSettings; use registry::schema::structs; @@ -42,13 +42,53 @@ impl LdapDirectory { base_dn: config.base_dn, filter_login: LdapFilter::new(&config.filter_login)?, filter_mailbox: LdapFilter::new(&config.filter_mailbox)?, - attr_class: config.attr_class.into_inner(), - attr_groups: config.attr_groups.into_inner(), - attr_description: config.attr_description.into_inner(), - attr_secret: config.attr_secret.into_inner(), - attr_secret_changed: config.attr_secret_changed.into_inner(), - attr_email: config.attr_email.into_inner(), - attr_email_alias: config.attr_email_alias.into_inner(), + filter_member_of: if let Some(filter) = config.filter_member_of { + Some(LdapFilter::new(&filter)?) + } else { + None + }, + attr_class: config + .attr_class + .into_inner() + .into_iter() + .map(|a| a.to_lowercase()) + .collect(), + attr_groups: config + .attr_member_of + .into_inner() + .into_iter() + .map(|a| a.to_lowercase()) + .collect(), + attr_description: config + .attr_description + .into_inner() + .into_iter() + .map(|a| a.to_lowercase()) + .collect(), + attr_secret: config + .attr_secret + .into_inner() + .into_iter() + .map(|a| a.to_lowercase()) + .collect(), + attr_secret_changed: config + .attr_secret_changed + .into_inner() + .into_iter() + .map(|a| a.to_lowercase()) + .collect(), + attr_email: config + .attr_email + .into_inner() + .into_iter() + .map(|a| a.to_lowercase()) + .collect(), + attr_email_alias: config + .attr_email_alias + .into_inner() + .into_iter() + .map(|a| a.to_lowercase()) + .collect(), group_class: config.group_class, attrs_principal: vec![], }; @@ -67,20 +107,6 @@ impl LdapDirectory { .extend(attr.iter().filter(|a| !a.is_empty()).cloned()); } - let auth_bind = match config.password_verification { - structs::LdapPasswordVerification::Local => AuthBind::None, - structs::LdapPasswordVerification::Bind(bind) => { - if let Some(template) = bind.bind_auth_template { - AuthBind::BindTemplate { - template: LdapFilter::new(&template)?, - can_search: bind.bind_auth_search, - } - } else { - AuthBind::Bind - } - } - }; - let pool = Pool::builder(manager) .runtime(Runtime::Tokio1) .max_size(config.pool_max_connections as usize) @@ -93,7 +119,7 @@ impl LdapDirectory { Ok(Directory::Ldap(LdapDirectory { mappings, pool, - auth_bind, + auth_bind: config.bind_authentication, })) } } diff --git a/crates/directory/src/backend/ldap/lookup.rs b/crates/directory/src/backend/ldap/lookup.rs index 1219fe0d..0e9bccb5 100644 --- a/crates/directory/src/backend/ldap/lookup.rs +++ b/crates/directory/src/backend/ldap/lookup.rs @@ -5,10 +5,7 @@ */ use super::{LdapDirectory, LdapMappings}; -use crate::{ - Account, Credentials, Group, IntoError, Recipient, backend::ldap::AuthBind, - core::secret::verify_secret_hash, -}; +use crate::{Account, Credentials, Group, IntoError, Recipient, core::secret::verify_secret_hash}; use ldap3::{Ldap, LdapConnAsync, ResultEntry, Scope, SearchEntry}; use store::xxhash_rust; use utils::sanitize_email; @@ -23,11 +20,10 @@ impl LdapDirectory { }; let mut conn = self.pool.get().await.map_err(|err| err.into_error())?; - let mut account = match &self.auth_bind { - AuthBind::BindTemplate { - template, - can_search, - } => { + let mut result = if self.auth_bind { + let filter = self.mappings.filter_login.build(username); + if let Some(mut result) = self.find_object(&mut conn, &filter).await? { + // Perform bind auth using the found dn let (auth_bind_conn, mut ldap) = LdapConnAsync::with_settings( self.pool.manager().settings.clone(), &self.pool.manager().address, @@ -37,127 +33,95 @@ impl LdapDirectory { ldap3::drive!(auth_bind_conn); - let dn = template.build(username); - if ldap - .simple_bind(&dn, secret) + .simple_bind(&result.dn, secret) .await .map_err(|err| err.into_error().caused_by(trc::location!()))? .success() - .is_err() + .is_ok() { - return Err(trc::AuthEvent::Failed - .into_err() - .details("Invalid credentials for auth bind using template") - .details(dn)); - } - - let filter = self.mappings.filter_login.build(username); - let result = if *can_search { - self.find_object(&mut ldap, &filter).await - } else { - self.find_object(&mut conn, &filter).await - }; - - match result { - Ok(Some(mut result)) => { - if result.account.email.is_empty() { - result.account.email = - sanitize_email(username).unwrap_or_else(|| username.to_lowercase()); - } - result.account - } - Err(err) - if err.matches(trc::EventType::Store(trc::StoreEvent::LdapError)) - && err - .value(trc::Key::Code) - .and_then(|v| v.to_uint()) - .is_some_and(|rc| [49, 50].contains(&rc)) => - { - return Err(trc::AuthEvent::Failed - .into_err() - .details("Error codes 49 or 50 returned by LDAP server") - .details(vec![dn, filter])); - } - Ok(None) => { - return Err(trc::AuthEvent::Failed - .into_err() - .details("Auth bind successful but filter yielded no results") - .details(vec![dn, filter])); - } - Err(err) => return Err(err), - } - } - AuthBind::Bind => { - let filter = self.mappings.filter_login.build(username); - if let Some(mut result) = self.find_object(&mut conn, &filter).await? { - // Perform bind auth using the found dn - let (auth_bind_conn, mut ldap) = LdapConnAsync::with_settings( - self.pool.manager().settings.clone(), - &self.pool.manager().address, - ) - .await - .map_err(|err| err.into_error().caused_by(trc::location!()))?; - - ldap3::drive!(auth_bind_conn); - - if ldap - .simple_bind(&result.dn, secret) - .await - .map_err(|err| err.into_error().caused_by(trc::location!()))? - .success() - .is_ok() - { - if result.account.email.is_empty() { - result.account.email = - sanitize_email(username).unwrap_or_else(|| username.to_lowercase()); - } - result.account - } else { - return Err(trc::AuthEvent::Failed - .into_err() - .details("Secret rejected during auth bind using lookup filter") - .details(vec![result.dn, filter])); - } - } else { - return Err(trc::AuthEvent::Failed - .into_err() - .details("Auth bind lookup filter yielded no results") - .details(vec![filter])); - } - } - AuthBind::None => { - let filter = self.mappings.filter_login.build(username); - if let Some(mut result) = self.find_object(&mut conn, &filter).await? { - if let Some(account_secret) = &result.account.secret { - if !verify_secret_hash(account_secret, secret.as_bytes()).await? { - return Err(trc::AuthEvent::Failed - .into_err() - .details("Invalid credentials") - .details(vec![filter])); - } - } else { - return Err(trc::AuthEvent::Error - .into_err() - .details("Account does not have a secret") - .details(vec![filter])); - } if result.account.email.is_empty() { result.account.email = sanitize_email(username).unwrap_or_else(|| username.to_lowercase()); } - result.account + result } else { return Err(trc::AuthEvent::Failed .into_err() - .details("Authentication filter yielded no results") + .details("Secret rejected during auth bind using lookup filter") + .details(vec![result.dn, filter])); + } + } else { + return Err(trc::AuthEvent::Failed + .into_err() + .details("Auth bind lookup filter yielded no results") + .details(vec![filter])); + } + } else { + let filter = self.mappings.filter_login.build(username); + if let Some(mut result) = self.find_object(&mut conn, &filter).await? { + if let Some(account_secret) = &result.account.secret { + if !verify_secret_hash(account_secret, secret.as_bytes()).await? { + return Err(trc::AuthEvent::Failed + .into_err() + .details("Invalid credentials") + .details(vec![filter])); + } + } else { + return Err(trc::AuthEvent::Error + .into_err() + .details("Account does not have a secret") .details(vec![filter])); } + if result.account.email.is_empty() { + result.account.email = + sanitize_email(username).unwrap_or_else(|| username.to_lowercase()); + } + result + } else { + return Err(trc::AuthEvent::Failed + .into_err() + .details("Authentication filter yielded no results") + .details(vec![filter])); } }; - if !account.groups.is_empty() { - for name in std::mem::take(&mut account.groups) + self.add_group_membership(&mut conn, &mut result).await?; + + Ok(result.account) + } + + pub async fn recipient(&self, address: &str) -> trc::Result { + let mut conn = self.pool.get().await.map_err(|err| err.into_error())?; + let filter = self.mappings.filter_mailbox.build(address); + if let Some(mut result) = self.find_object(&mut conn, &filter).await? { + if !result.is_group { + self.add_group_membership(&mut conn, &mut result).await?; + Ok(Recipient::Account(result.account)) + } else { + Ok(Recipient::Group(Group { + email: result.account.email, + email_aliases: result.account.email_aliases, + description: result.account.description, + })) + } + } else { + trc::event!( + Store(trc::StoreEvent::LdapWarning), + Reason = "Mailbox filter yielded no results", + Details = filter + ); + Ok(Recipient::Invalid) + } + } + + async fn add_group_membership( + &self, + conn: &mut Ldap, + result: &mut LdapResult, + ) -> trc::Result<()> { + if !result.account.groups.is_empty() { + for name in std::mem::take(&mut result.account.groups) .into_iter() .filter(|name| name.contains('=')) { @@ -178,69 +142,48 @@ impl LdapDirectory { && let Some(email) = value.first().map(|s| s.as_str()).and_then(sanitize_email) { - account.groups.push(email); + result.account.groups.push(email); break 'outer; } } } } - } - - Ok(account) - } - - pub async fn recipient(&self, address: &str) -> trc::Result { - let mut conn = self.pool.get().await.map_err(|err| err.into_error())?; - let filter = self.mappings.filter_mailbox.build(address); - if let Some(result) = self.find_object(&mut conn, &filter).await? { - let mut account = result.account; - - if !account.groups.is_empty() { - for name in std::mem::take(&mut account.groups) - .into_iter() - .filter(|name| name.contains('=')) - { - let (rs, _res) = conn - .search( - &name, - Scope::Base, - "objectClass=*", - &self.mappings.attr_email, - ) - .await - .map_err(|err| err.into_error().caused_by(trc::location!()))? - .success() - .map_err(|err| err.into_error().caused_by(trc::location!()))?; - for entry in rs { - 'outer: for (attr, value) in SearchEntry::construct(entry).attrs { - if self.mappings.attr_email.contains(&attr.to_lowercase()) - && let Some(email) = - value.first().map(|s| s.as_str()).and_then(sanitize_email) - { - account.groups.push(email); - break 'outer; - } - } + } else if let Some(filter) = &self.mappings.filter_member_of { + let filter = filter.build(&result.dn); + let rs = conn + .search( + &self.mappings.base_dn, + Scope::Subtree, + &filter, + &self.mappings.attr_email, + ) + .await + .map_err(|err| err.into_error().caused_by(trc::location!()))? + .success() + .map_err(|err| err.into_error().caused_by(trc::location!()))? + .0; + for entry in rs { + for (attr, value) in SearchEntry::construct(entry).attrs { + if self.mappings.attr_email.contains(&attr.to_lowercase()) { + result + .account + .groups + .extend(value.into_iter().filter_map(|v| { + sanitize_email(&v).or_else(|| { + trc::event!( + Store(trc::StoreEvent::LdapWarning), + Reason = "Group entry missing valid email attribute", + Details = v + ); + None + }) + })); } } } - if result.is_group { - Ok(Recipient::Group(Group { - email: account.email, - email_aliases: account.email_aliases, - description: account.description, - })) - } else { - Ok(Recipient::Account(account)) - } - } else { - trc::event!( - Store(trc::StoreEvent::LdapWarning), - Reason = "Mailbox filter yielded no results", - Details = filter - ); - Ok(Recipient::Invalid) } + + Ok(()) } } diff --git a/crates/directory/src/backend/ldap/mod.rs b/crates/directory/src/backend/ldap/mod.rs index 74f2eacc..7fedeec0 100644 --- a/crates/directory/src/backend/ldap/mod.rs +++ b/crates/directory/src/backend/ldap/mod.rs @@ -11,19 +11,10 @@ pub mod config; pub mod lookup; pub mod pool; -pub(crate) enum AuthBind { - Bind, - BindTemplate { - template: LdapFilter, - can_search: bool, - }, - None, -} - pub struct LdapDirectory { pool: Pool, mappings: LdapMappings, - auth_bind: AuthBind, + auth_bind: bool, } #[derive(Debug, Default)] @@ -31,6 +22,7 @@ pub struct LdapMappings { base_dn: String, filter_login: LdapFilter, filter_mailbox: LdapFilter, + filter_member_of: Option, attr_class: Vec, attr_groups: Vec, attr_description: Vec, diff --git a/crates/directory/src/backend/oidc/config.rs b/crates/directory/src/backend/oidc/config.rs index 142dca81..b0d6cec2 100644 --- a/crates/directory/src/backend/oidc/config.rs +++ b/crates/directory/src/backend/oidc/config.rs @@ -4,47 +4,139 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use super::OpenIdDirectory; use crate::Directory; +use crate::backend::oidc::lookup::fetch_jwks_keys; +use crate::backend::oidc::{ + DiscoveryDocument, JwksCache, OidcError, OpenIdConfig, OpenIdDirectory, +}; use registry::schema::structs; +use reqwest::Client; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; +use trc::AuthEvent; impl OpenIdDirectory { pub async fn open(config: structs::OidcDirectory) -> Result { - Ok(Directory::OpenId(match config { - structs::OidcDirectory::UserInfo(config) => OpenIdDirectory::UserInfo { - endpoint: config.endpoint, - timeout: config.timeout.into_inner(), - allow_invalid_certs: config.allow_invalid_certs, - claim_email: config.claim_email, - claim_name: config.claim_name, - }, - structs::OidcDirectory::Introspect(config) => { - let client = config - .http_auth - .build_http_client( - config.http_headers, - None, - config.timeout, - config.allow_invalid_certs, - ) - .await?; - OpenIdDirectory::Introspect { - client, - endpoint: config.endpoint, - claim_email: config.claim_email, - claim_name: config.claim_name, - require_aud: config.require_audience, - require_scopes: config.require_scopes.into_inner(), + Self::new(OpenIdConfig { + issue_url: config.issuer_url, + require_aud: config.require_audience, + require_scopes: config.require_scopes.into_inner(), + claim_email: config.claim_username, + claim_name: config.claim_name, + claim_groups: config.claim_groups, + default_domain: config.username_domain, + }) + .await + .map(Directory::OpenId) + .map_err(|err| err.to_string()) + } + + pub async fn new(config: OpenIdConfig) -> Result { + let http = Client::builder() + .user_agent("Stalwart/1.0") + .timeout(Duration::from_secs(30)) + .build() + .map_err(|e| OidcError::Network(format!("HTTP client build failed: {e}")))?; + let discovery_url = format!( + "{}/.well-known/openid-configuration", + config.issue_url.trim_end_matches('/') + ); + let discovery_bytes = http + .get(&discovery_url) + .send() + .await + .map_err(|e| OidcError::Network(format!("Discovery fetch failed: {e}")))? + .error_for_status() + .map_err(|e| OidcError::Provider(format!("Discovery HTTP error: {e}")))? + .bytes() + .await + .map_err(|e| OidcError::Provider(format!("Discovery HTTP error: {e}")))?; + let discovery: DiscoveryDocument = serde_json::from_slice(&discovery_bytes) + .map_err(|e| OidcError::Provider(format!("Discovery JSON parse error: {e}")))?; + + let normalised_issue = config.issue_url.trim_end_matches('/'); + let normalised_issuer = discovery.issuer.trim_end_matches('/'); + if normalised_issuer != normalised_issue { + return Err(OidcError::Provider(format!( + "Issuer mismatch: discovery document says '{}' but configured issue_url is '{}'", + discovery.issuer, config.issue_url, + ))); + } + + if let Some(supported) = &discovery.scopes_supported { + for scope in &config.require_scopes { + if !supported.contains(scope) { + trc::event!( + Auth(AuthEvent::Warning), + Url = config.issue_url.to_string(), + Reason = format!( + "Required scope '{}' is not in scopes_supported from the IdP", + scope + ) + ); } } - structs::OidcDirectory::Jwt(config) => OpenIdDirectory::Jwt { - jwks_url: config.jwks_url, - jwks_cache: config.jwks_cache_duration.into_inner(), - claim_email: config.claim_email, - claim_name: config.claim_name, - require_aud: config.require_audience, - require_iss: config.require_issuer, - }, - })) + } + + if let Some(supported) = &discovery.claims_supported { + let check = |name: &str, label: &str| { + if !supported.iter().any(|c| c == name) { + trc::event!( + Auth(AuthEvent::Warning), + Url = config.issue_url.to_string(), + Reason = format!( + "Configured {} claim '{}' is not in claims_supported from the IdP", + label, name + ) + ); + } + }; + check(&config.claim_email, "claim_email"); + if let Some(n) = &config.claim_name { + check(n, "claim_name"); + } + if let Some(g) = &config.claim_groups { + check(g, "claim_groups"); + } + } + + /*{ + let cache = Arc::clone(&cache); + let http = http.clone(); + let jwks_uri = discovery.jwks_uri.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(24 * 3600)); + interval.tick().await; + loop { + interval.tick().await; + match fetch_jwks_keys(&http, &jwks_uri).await { + Ok(new_keys) => { + let mut guard = cache.write().await; + guard.keys = new_keys; + guard.last_updated = Instant::now(); + } + Err(e) => { + trc::event!( + Auth(AuthEvent::Warning), + Url = jwks_uri.to_string(), + Reason = format!("Background JWKS refresh failed: {e}") + ); + } + } + } + }); + }*/ + + let cache = RwLock::new(JwksCache { + keys: fetch_jwks_keys(&http, &discovery.jwks_uri).await?, + last_updated: Instant::now(), + }); + + Ok(Self { + config, + discovery, + http, + cache, + }) } } diff --git a/crates/directory/src/backend/oidc/lookup.rs b/crates/directory/src/backend/oidc/lookup.rs index 2c082f3e..91563d4b 100644 --- a/crates/directory/src/backend/oidc/lookup.rs +++ b/crates/directory/src/backend/oidc/lookup.rs @@ -4,203 +4,393 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use super::OpenIdDirectory; -use crate::{Account, Credentials}; -use ahash::HashMap; -use reqwest::{RequestBuilder, StatusCode}; +use crate::{ + Account, Credentials, + backend::oidc::{CachedKey, OidcError, OpenIdDirectory}, +}; +use ahash::AHashMap; +use jsonwebtoken::{ + Algorithm, DecodingKey, Validation, decode, decode_header, + jwk::{self, JwkSet}, +}; +use reqwest::Client; +use std::time::Instant; +use std::{sync::Arc, time::Duration}; use trc::AuthEvent; -use utils::sanitize_email; - -type OpenIdResponse = HashMap; impl OpenIdDirectory { pub async fn authenticate(&self, credentials: &Credentials) -> trc::Result { - let token = match credentials { - Credentials::Bearer { token, .. } => token, - _ => { - return Err(AuthEvent::Error - .into_err() - .details("Unsupported credentials type for OIDC backend")); - } - }; - let email; - let name; - let aud; - let iss; - let scopes; - - let response = match self { - OpenIdDirectory::Introspect { - client, - endpoint, - claim_email, - claim_name, - require_aud, - require_scopes, - } => { - email = claim_email; - name = claim_name; - aud = require_aud; - scopes = require_scopes.as_slice(); - iss = &None; - - send_request(client.post(endpoint).form(&[ - ("token", token.as_str()), - ("token_type_hint", "access_token"), - ])) - .await? - } - OpenIdDirectory::UserInfo { - endpoint, - timeout, - allow_invalid_certs, - claim_email, - claim_name, - } => { - let client = reqwest::Client::builder() - .danger_accept_invalid_certs(*allow_invalid_certs) - .timeout(*timeout) - .build() - .map_err(|err| { - AuthEvent::Error - .into_err() - .reason(err) - .details("Failed to build client") - })?; - email = claim_email; - name = claim_name; - aud = &None; - iss = &None; - scopes = &[]; - send_request(client.get(endpoint).bearer_auth(token)).await? - } - OpenIdDirectory::Jwt { - jwks_url, - jwks_cache, - claim_email, - claim_name, - require_aud, - require_iss, - } => { - email = claim_email; - name = claim_name; - aud = require_aud; - iss = require_iss; - scopes = &[]; - todo!() - } - }; - - let mut account = Account::default(); - let mut aud_matched = aud.is_none(); - let mut iss_matched = iss.is_none(); - let mut scopes_unmatched = scopes.len(); - - for (field, value) in response { - let serde_json::Value::String(value) = value else { - continue; - }; - - if email == &field { - if let Some(sanitized_email) = sanitize_email(&value) { - account.email = sanitized_email; - } - } else if let Some(name_field) = name - && name_field == &field - { - account.description = Some(value); - } else if !aud_matched - && let Some(required_aud) = aud - && field == "aud" - { - if value == *required_aud { - aud_matched = true; + match credentials { + Credentials::Bearer { token, .. } => { + if token.chars().filter(|&c| c == '.').count() == 2 { + self.authenticate_jwt(token).await } else { - return Err(AuthEvent::Failed - .into_err() - .details("Audience claim does not match")); + #[cfg(feature = "test_mode")] + let token = token.strip_prefix(".").unwrap_or(token); + self.authenticate_opaque(token).await } - } else if !iss_matched - && let Some(required_iss) = iss - && field == "iss" - { - if value == *required_iss { - iss_matched = true; - } else { - return Err(AuthEvent::Failed - .into_err() - .details("Issuer claim does not match")); - } - } else if scopes_unmatched > 0 && field == "scope" { - for scope in value.split_whitespace() { - if scopes.iter().any(|required_scope| required_scope == &scope) { - scopes_unmatched -= 1; - if scopes_unmatched == 0 { - break; - } + .map_err(|err| match err { + OidcError::AuthorizationFailed(reason) => { + AuthEvent::Failed.into_err().reason(reason) } + err => AuthEvent::Error.into_err().reason(err), + }) + } + _ => Err(AuthEvent::Error + .into_err() + .reason("Unsupported credentials type for OIDC backend")), + } + } + + async fn authenticate_jwt(&self, token: &str) -> Result { + let header = decode_header(token) + .map_err(|e| OidcError::TokenValidation(format!("Failed to decode JWT header: {e}")))?; + + match header.alg { + Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512 => { + return Err(OidcError::TokenValidation( + "Unsupported algorithm".to_string(), + )); + } + _ => {} + } + + let candidates = self.get_key(header.kid.as_deref()).await?; + let mut last_err = None; + for cached in &candidates { + let dk = &cached.decoding_key; + let alg = cached.algorithm; + let mut validation = Validation::new(alg); + + if let Some(aud) = &self.config.require_aud { + validation.set_audience(&[aud]); + } else { + validation.validate_aud = false; + } + + validation.set_issuer(&[&self.discovery.issuer]); + validation.leeway = 60; + + match decode::(token, dk, &validation) { + Ok(token_data) => { + if self.config.require_aud.is_some() && token_data.claims.get("aud").is_none() { + last_err = Some(jsonwebtoken::errors::Error::from( + jsonwebtoken::errors::ErrorKind::InvalidAudience, + )); + continue; + } + + self.validate_scopes(&token_data.claims)?; + return self.build_account(&token_data.claims); + } + Err(e) => { + last_err = Some(e); } } } - if !aud_matched { - Err(AuthEvent::Error - .into_err() - .details("Audience claim not found in OIDC response")) - } else if !iss_matched { - Err(AuthEvent::Error - .into_err() - .details("Issuer claim not found in OIDC response")) - } else if scopes_unmatched > 0 { - Err(AuthEvent::Error - .into_err() - .details("One or more required scopes not found in OIDC response")) - } else if !account.email.is_empty() { - Ok(account) + Err(OidcError::TokenValidation(format!( + "JWT validation failed: {}", + last_err.map(|e| e.to_string()).unwrap_or_default() + ))) + } + + async fn authenticate_opaque(&self, token: &str) -> Result { + let claims = self.fetch_userinfo(token).await?; + self.build_account(&claims) + } + + async fn get_key(&self, kid: Option<&str>) -> Result>, OidcError> { + { + let guard = self.cache.read().await; + + if let Some(kid) = kid { + if let Some(cached) = guard.keys.get(kid) { + return Ok(vec![cached.clone()]); + } + + if guard.last_updated.elapsed() < Duration::from_secs(300) { + return Err(OidcError::TokenValidation("Unknown key id".to_string())); + } + } else { + let all: Vec<_> = guard.keys.values().cloned().collect(); + if !all.is_empty() { + return Ok(all); + } + } + } + + let new_keys = fetch_jwks_keys(&self.http, &self.discovery.jwks_uri).await?; + { + let mut guard = self.cache.write().await; + guard.keys = new_keys; + guard.last_updated = Instant::now(); + } + + let guard = self.cache.read().await; + if let Some(kid) = kid { + if let Some(cached) = guard.keys.get(kid) { + Ok(vec![cached.clone()]) + } else { + Err(OidcError::TokenValidation( + "Unknown key id after refresh".to_string(), + )) + } } else { - Err(trc::AuthEvent::Error - .into_err() - .details("Email claim not found in OIDC response")) + let all: Vec<_> = guard.keys.values().cloned().collect(); + if all.is_empty() { + Err(OidcError::Provider( + "JWKS contains no usable keys".to_string(), + )) + } else { + Ok(all) + } } } + + async fn fetch_userinfo(&self, token: &str) -> Result { + let resp = self + .http + .get(&self.discovery.userinfo_endpoint) + .bearer_auth(token) + .send() + .await + .map_err(|e| OidcError::Network(format!("UserInfo request failed: {e}")))?; + + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + let reason = resp.text().await.unwrap_or_default(); + return Err(OidcError::AuthorizationFailed(format!( + "Token rejected by UserInfo endpoint with status {status}: {reason}" + ))); + } + if !status.is_success() { + return Err(OidcError::Provider(format!( + "UserInfo returned HTTP {status}" + ))); + } + + let bytes = resp + .bytes() + .await + .map_err(|e| OidcError::Provider(format!("UserInfo HTTP error: {e}")))?; + + serde_json::from_slice::(&bytes) + .map_err(|e| OidcError::Provider(format!("UserInfo JSON parse error: {e}"))) + } + + fn validate_scopes(&self, claims: &serde_json::Value) -> Result<(), OidcError> { + if !self.config.require_scopes.is_empty() { + let token_scopes = extract_scopes(claims); + + for required in &self.config.require_scopes { + if !token_scopes.iter().any(|s| s == required) { + return Err(OidcError::AuthorizationFailed(format!( + "Missing required scope '{required}', present scopes: {token_scopes:?}" + ))); + } + } + } + + Ok(()) + } + + fn build_account(&self, claims: &serde_json::Value) -> Result { + let email = self.resolve_email(claims)?; + let description = self + .config + .claim_name + .as_ref() + .and_then(|name_claim| claims.get(name_claim)) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let groups = self + .config + .claim_groups + .as_ref() + .and_then(|groups_claim| claims.get(groups_claim)) + .map(extract_string_list) + .unwrap_or_default(); + + Ok(Account { + email, + email_aliases: Vec::new(), + secret: None, + groups, + description, + }) + } + + fn resolve_email(&self, claims: &serde_json::Value) -> Result { + if let Some(val) = claims + .get(&self.config.claim_email) + .and_then(|v| v.as_str()) + { + if val.contains('@') { + return Ok(val.to_string()); + } + if let Some(domain) = &self.config.default_domain { + return Ok(format!("{val}@{domain}")); + } + } + + if self.config.claim_email != "email" + && let Some(val) = claims.get("email").and_then(|v| v.as_str()) + && val.contains('@') + { + return Ok(val.to_string()); + } + + Err(OidcError::AuthorizationFailed( + "Could not determine a valid email address for account".to_string(), + )) + } } -async fn send_request(request: RequestBuilder) -> trc::Result { - let response = request.send().await.map_err(|err| { - AuthEvent::Error - .into_err() - .reason(err) - .details("OIDC HTTP request failed") - })?; +pub(super) async fn fetch_jwks_keys( + http: &Client, + jwks_uri: &str, +) -> Result>, OidcError> { + let jwks_bytes = http + .get(jwks_uri) + .send() + .await + .map_err(|e| OidcError::Network(format!("JWKS fetch failed: {e}")))? + .error_for_status() + .map_err(|e| OidcError::Provider(format!("JWKS HTTP error: {e}")))? + .bytes() + .await + .map_err(|e| OidcError::Provider(format!("JWKS HTTP error: {e}")))?; + let jwks: JwkSet = serde_json::from_slice(&jwks_bytes) + .map_err(|e| OidcError::Provider(format!("JWKS JSON parse error: {e}")))?; - match response.status() { - StatusCode::OK => { - // Fetch response - let response = response.bytes().await.map_err(|err| { - AuthEvent::Error - .into_err() - .reason(err) - .details("Failed to read OIDC response") - })?; + let mut map = AHashMap::new(); + let mut synthetic_id: u64 = 0; - let todo = "deserialize directly into string, not serde_json::Value"; + for key in &jwks.keys { + if let Some(pk_use) = &key.common.public_key_use + && pk_use != &jwk::PublicKeyUse::Signature + { + continue; + } - // Deserialize response - serde_json::from_slice::(&response).map_err(|err| { - AuthEvent::Error - .into_err() - .reason(err) - .details("Failed to deserialize OIDC response") + let algorithm = match &key.algorithm { + jwk::AlgorithmParameters::RSA(_) => match key.common.key_algorithm { + Some(jwk::KeyAlgorithm::RS256) => Algorithm::RS256, + Some(jwk::KeyAlgorithm::RS384) => Algorithm::RS384, + Some(jwk::KeyAlgorithm::RS512) => Algorithm::RS512, + Some(jwk::KeyAlgorithm::PS256) => Algorithm::PS256, + Some(jwk::KeyAlgorithm::PS384) => Algorithm::PS384, + Some(jwk::KeyAlgorithm::PS512) => Algorithm::PS512, + None => Algorithm::RS256, + Some(other) => { + trc::event!( + Auth(AuthEvent::Warning), + Url = jwks_uri.to_string(), + Reason = format!("Unsupported RSA key algorithm {:?}", other) + ); + continue; + } + }, + jwk::AlgorithmParameters::EllipticCurve(ec) => match ec.curve { + jwk::EllipticCurve::P256 => Algorithm::ES256, + jwk::EllipticCurve::P384 => Algorithm::ES384, + _ => { + trc::event!( + Auth(AuthEvent::Warning), + Url = jwks_uri.to_string(), + Reason = format!("Unsupported EC curve {:?}", ec.curve) + ); + continue; + } + }, + jwk::AlgorithmParameters::OctetKeyPair(_) => Algorithm::EdDSA, + jwk::AlgorithmParameters::OctetKey(_) => { + trc::event!( + Auth(AuthEvent::Warning), + Url = jwks_uri.to_string(), + Reason = format!( + "Symmetric (HMAC) key found in JWKS (kid={:?}), skipping — HMAC is not accepted", + key.common.key_id + ) + ); + continue; + } + }; + + if matches!( + algorithm, + Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512 + ) { + trc::event!( + Auth(AuthEvent::Warning), + Url = jwks_uri.to_string(), + Reason = format!( + "HMAC algorithm {:?} in JWKS (kid={:?}) is not accepted, skipping", + algorithm, key.common.key_id + ) + ); + continue; + } + + let decoding_key = DecodingKey::from_jwk(key) + .map_err(|e| { + trc::event!( + Auth(AuthEvent::Warning), + Url = jwks_uri.to_string(), + Reason = format!( + "Failed to build DecodingKey from JWK (kid={:?}): {e}", + key.common.key_id + ) + ); }) - } - StatusCode::UNAUTHORIZED => Err(trc::AuthEvent::Failed - .into_err() - .code(401) - .details("Unauthorized")), - other => Err(trc::AuthEvent::Error - .into_err() - .code(other.as_u16()) - .ctx(trc::Key::Reason, response.text().await.unwrap_or_default()) - .details("Unexpected status code")), + .ok(); + + let decoding_key = match decoding_key { + Some(dk) => dk, + None => continue, + }; + + let kid = match &key.common.key_id { + Some(id) => id.clone(), + None => { + let id = format!("_synthetic_{synthetic_id}"); + synthetic_id += 1; + id + } + }; + + map.insert( + kid, + CachedKey { + decoding_key, + algorithm, + } + .into(), + ); + } + + Ok(map) +} + +fn extract_scopes(claims: &serde_json::Value) -> Vec { + match claims.get("scope") { + Some(serde_json::Value::String(s)) => s.split_whitespace().map(|s| s.to_string()).collect(), + Some(serde_json::Value::Array(arr)) => arr + .iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect(), + _ => Vec::new(), + } +} + +fn extract_string_list(value: &serde_json::Value) -> Vec { + match value { + serde_json::Value::Array(arr) => arr + .iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect(), + serde_json::Value::String(s) => s.split_whitespace().map(|s| s.to_string()).collect(), + _ => Vec::new(), } } diff --git a/crates/directory/src/backend/oidc/mod.rs b/crates/directory/src/backend/oidc/mod.rs index 27531bfc..4fcf8779 100644 --- a/crates/directory/src/backend/oidc/mod.rs +++ b/crates/directory/src/backend/oidc/mod.rs @@ -4,33 +4,70 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use ahash::AHashMap; +use jsonwebtoken::{Algorithm, DecodingKey}; +use serde::Deserialize; +use std::{fmt, sync::Arc, time::Instant}; +use tokio::sync::RwLock; +use utils::Client; + pub mod config; pub mod lookup; -use std::time::Duration; - -pub enum OpenIdDirectory { - Introspect { - client: reqwest::Client, - endpoint: String, - claim_email: String, - claim_name: Option, - require_aud: Option, - require_scopes: Vec, - }, - UserInfo { - endpoint: String, - timeout: Duration, - allow_invalid_certs: bool, - claim_email: String, - claim_name: Option, - }, - Jwt { - jwks_url: String, - jwks_cache: Duration, - claim_email: String, - claim_name: Option, - require_aud: Option, - require_iss: Option, - }, +pub struct OpenIdConfig { + pub issue_url: String, + pub require_aud: Option, + pub require_scopes: Vec, + pub claim_email: String, + pub claim_name: Option, + pub claim_groups: Option, + pub default_domain: Option, } + +#[derive(Deserialize)] +pub struct DiscoveryDocument { + issuer: String, + jwks_uri: String, + pub userinfo_endpoint: String, + pub authorization_endpoint: String, + scopes_supported: Option>, + claims_supported: Option>, +} + +struct CachedKey { + decoding_key: DecodingKey, + algorithm: Algorithm, +} + +struct JwksCache { + keys: AHashMap>, + last_updated: Instant, +} + +pub struct OpenIdDirectory { + config: OpenIdConfig, + pub discovery: DiscoveryDocument, + http: Client, + cache: RwLock, +} + +#[derive(Debug)] +pub enum OidcError { + TokenValidation(String), + AuthorizationFailed(String), + Network(String), + Provider(String), +} + +impl fmt::Display for OidcError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + OidcError::TokenValidation(msg) => write!(f, "Token validation error: {msg}"), + OidcError::AuthorizationFailed(msg) => write!(f, "Authorization failed: {msg}"), + OidcError::Network(msg) => write!(f, "Network error: {msg}"), + OidcError::Provider(msg) => write!(f, "Provider error: {msg}"), + } + } +} + +impl std::error::Error for OidcError {} diff --git a/crates/directory/src/core/dispatch.rs b/crates/directory/src/core/dispatch.rs index b0fc8117..59823c53 100644 --- a/crates/directory/src/core/dispatch.rs +++ b/crates/directory/src/core/dispatch.rs @@ -35,7 +35,11 @@ impl Directory { } pub fn oidc_authorization_endpoint(&self) -> Option { - let todo = "implement"; - None + match &self { + Directory::OpenId(directory) => { + Some(directory.discovery.authorization_endpoint.clone()) + } + _ => None, + } } } diff --git a/crates/http-proto/src/context.rs b/crates/http-proto/src/context.rs index 65cc2044..97bc30d8 100644 --- a/crates/http-proto/src/context.rs +++ b/crates/http-proto/src/context.rs @@ -62,7 +62,7 @@ impl ResolveVariable for HttpContext<'_> { } ExpressionVariable::Listener => self.session.instance.id.as_str().into(), ExpressionVariable::Url => self.req.uri().to_compact_string().into(), - ExpressionVariable::UrlPath => self.req.uri().path().into(), + ExpressionVariable::Path => self.req.uri().path().into(), ExpressionVariable::Method => self.req.method().as_str().into(), ExpressionVariable::Headers => self .req diff --git a/crates/services/src/task_manager/spam_classifier.rs b/crates/services/src/task_manager/spam_classifier.rs index 8be8936a..c6c71499 100644 --- a/crates/services/src/task_manager/spam_classifier.rs +++ b/crates/services/src/task_manager/spam_classifier.rs @@ -275,6 +275,8 @@ async fn fetch_spam_rules(server: &Server) -> Result { }) })?; + let todo = "trigger task to reload settings and lookup stores"; + let mut rules = Rules::default(); for (object_type, values) in rules_json { let Some(object_type) = ObjectType::parse(&object_type) else { diff --git a/crates/spam-filter/src/modules/expression.rs b/crates/spam-filter/src/modules/expression.rs index 2a738fb9..f1ffa9b3 100644 --- a/crates/spam-filter/src/modules/expression.rs +++ b/crates/spam-filter/src/modules/expression.rs @@ -427,7 +427,7 @@ impl ResolveVariable for UrlParts<'_> { .and_then(|p| p.parts.path_and_query().map(|p| p.as_str())) .unwrap_or_default(), ), - ExpressionVariable::UrlPath => Variable::from( + ExpressionVariable::Path => Variable::from( self.url_parsed .as_ref() .map(|p| p.parts.path()) diff --git a/tests/docker/docker-compose.yml b/tests/docker/docker-compose.yml index 12c65f77..ddcd31ac 100644 --- a/tests/docker/docker-compose.yml +++ b/tests/docker/docker-compose.yml @@ -177,14 +177,14 @@ services: LDAP_TLS_KEY_FILENAME: "key.pem" LDAP_TLS_CA_CRT_FILENAME: "cert.pem" LDAP_TLS_VERIFY_CLIENT: "never" - LDAP_SEED_INTERNAL_LDIF_PATH: "/seed" ports: - "127.0.0.1:389:389" - "127.0.0.1:636:636" volumes: - - ./ldap/seed.ldif:/seed/50-stalwart.ldif:ro + - ./ldap/50-users.ldif:/seed/50-users.ldif:ro + - ./ldap/60-groups.ldif:/seed/60-groups.ldif:ro - certs:/certs-shared:ro - entrypoint: [ "/bin/bash", "-c", "cp /certs-shared/* /container/service/slapd/assets/certs/ 2>/dev/null; exec /container/tool/run" ] + entrypoint: [ "/bin/bash", "-c", "mkdir -p /container/service/slapd/assets/config/bootstrap/ldif/custom && cp /seed/*.ldif /container/service/slapd/assets/config/bootstrap/ldif/custom/ && cp /certs-shared/* /container/service/slapd/assets/certs/ 2>/dev/null; exec /container/tool/run" ] # --------------------------------------------------------------------------- # Pebble (ACME server) – ports 14000 (directory) + 15000 (management) diff --git a/tests/docker/keycloak/stalwart-realm.json b/tests/docker/keycloak/stalwart-realm.json index e387a0cc..d56d712a 100644 --- a/tests/docker/keycloak/stalwart-realm.json +++ b/tests/docker/keycloak/stalwart-realm.json @@ -11,14 +11,23 @@ "enabled": true, "clientAuthenticatorType": "client-secret", "secret": "stalwart-secret", - "redirectUris": ["*"], - "webOrigins": ["*"], + "redirectUris": [ + "*" + ], + "webOrigins": [ + "*" + ], "publicClient": false, "protocol": "openid-connect", "directAccessGrantsEnabled": true, "standardFlowEnabled": true, "serviceAccountsEnabled": true, - "defaultClientScopes": ["openid", "email", "profile", "roles"], + "defaultClientScopes": [ + "openid", + "email", + "profile", + "roles" + ], "protocolMappers": [ { "name": "groups", @@ -46,6 +55,17 @@ "userinfo.token.claim": "true", "jsonType.label": "String" } + }, + { + "name": "audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "stalwart", + "id.token.claim": "false", + "access.token.claim": "true" + } } ] } @@ -65,7 +85,9 @@ "temporary": false } ], - "groups": ["/sales@example.org"] + "groups": [ + "/sales@example.org" + ] }, { "username": "jane.smith@example.org", @@ -81,7 +103,10 @@ "temporary": false } ], - "groups": ["/sales@example.org", "/corporate@example.org"] + "groups": [ + "/sales@example.org", + "/corporate@example.org" + ] }, { "username": "bill.foobar@example.org", @@ -97,7 +122,9 @@ "temporary": false } ], - "groups": ["/corporate@example.org"] + "groups": [ + "/corporate@example.org" + ] } ], "groups": [ @@ -110,4 +137,4 @@ "path": "/corporate@example.org" } ] -} +} \ No newline at end of file diff --git a/tests/docker/ldap/seed.ldif b/tests/docker/ldap/50-users.ldif similarity index 58% rename from tests/docker/ldap/seed.ldif rename to tests/docker/ldap/50-users.ldif index b65aa9a7..ed0ce30b 100644 --- a/tests/docker/ldap/seed.ldif +++ b/tests/docker/ldap/50-users.ldif @@ -1,23 +1,20 @@ -# Organizational Units dn: ou=users,dc=stalwart,dc=test objectClass: organizationalUnit ou: users -dn: ou=groups,dc=stalwart,dc=test -objectClass: organizationalUnit -ou: groups - -# Users dn: uid=john.doe,ou=users,dc=stalwart,dc=test objectClass: inetOrgPerson objectClass: posixAccount objectClass: shadowAccount +objectClass: extensibleObject uid: john.doe cn: John Doe sn: Doe givenName: John mail: john.doe@example.org -userPassword: this is an LDAP password +mailAlias: john@example.org +userPassword: this is John's LDAP password +shadowLastChange: 19723 uidNumber: 10001 gidNumber: 10001 homeDirectory: /home/john.doe @@ -32,7 +29,8 @@ cn: Jane Smith sn: Smith givenName: Jane mail: jane.smith@example.org -userPassword: this is an LDAP password +userPassword: this is Jane's LDAP password +shadowLastChange: 19724 uidNumber: 10002 gidNumber: 10002 homeDirectory: /home/jane.smith @@ -47,23 +45,8 @@ cn: Bill Foobar sn: Foobar givenName: Bill mail: bill.foobar@example.org -userPassword: this is an LDAP password +userPassword: this is Bill's LDAP password uidNumber: 10003 gidNumber: 10003 homeDirectory: /home/bill.foobar loginShell: /bin/bash - -# Groups with email addresses -dn: cn=sales,ou=groups,dc=stalwart,dc=test -objectClass: groupOfNames -cn: sales -mail: sales@example.org -member: uid=john.doe,ou=users,dc=stalwart,dc=test -member: uid=jane.smith,ou=users,dc=stalwart,dc=test - -dn: cn=corporate,ou=groups,dc=stalwart,dc=test -objectClass: groupOfNames -cn: corporate -mail: corporate@example.org -member: uid=bill.foobar,ou=users,dc=stalwart,dc=test -member: uid=jane.smith,ou=users,dc=stalwart,dc=test diff --git a/tests/docker/ldap/60-groups.ldif b/tests/docker/ldap/60-groups.ldif new file mode 100644 index 00000000..64f29229 --- /dev/null +++ b/tests/docker/ldap/60-groups.ldif @@ -0,0 +1,20 @@ +dn: ou=groups,dc=stalwart,dc=test +objectClass: organizationalUnit +ou: groups + +dn: cn=sales,ou=groups,dc=stalwart,dc=test +objectClass: groupOfNames +objectClass: extensibleObject +cn: sales +mail: sales@example.org +member: uid=john.doe,ou=users,dc=stalwart,dc=test +member: uid=jane.smith,ou=users,dc=stalwart,dc=test + +dn: cn=corporate,ou=groups,dc=stalwart,dc=test +objectClass: groupOfNames +objectClass: extensibleObject +cn: corporate +mail: corporate@example.org +mailAlias: everyone@example.org +member: uid=bill.foobar,ou=users,dc=stalwart,dc=test +member: uid=jane.smith,ou=users,dc=stalwart,dc=test diff --git a/tests/resources/smtp/antispam/classifier_features.test b/tests/resources/smtp/antispam/classifier_features.test index 1f86b820..28ebc733 100644 --- a/tests/resources/smtp/antispam/classifier_features.test +++ b/tests/resources/smtp/antispam/classifier_features.test @@ -2318,18 +2318,6 @@ important;"> "type": "hostname", "value": "gmail.com" }, - { - "type": "hostname", - "value": "gmailapi.google.com" - }, - { - "type": "hostname", - "value": "google.com" - }, - { - "type": "hostname", - "value": "mail-wm1-x32d.google.com" - }, { "type": "hostname", "value": "mail.gmail.com" diff --git a/tests/src/directory/internal.rs b/tests/src/directory/internal.rs deleted file mode 100644 index 701cea73..00000000 --- a/tests/src/directory/internal.rs +++ /dev/null @@ -1,1070 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - */ - -use std::sync::Arc; - -use crate::{ - directory::{DirectoryTest, IntoTestPrincipal, TestPrincipal}, - store::cleanup::{store_assert_is_empty, store_destroy}, -}; -use ahash::AHashSet; -use common::{Core, Inner, Server, config::storage::Storage}; -use mail_send::Credentials; -use store::{ - IterateParams, Store, ValueKey, - write::{BatchBuilder, ValueClass}, -}; -use types::collection::Collection; - -#[tokio::test] -async fn internal_directory() { - let config = DirectoryTest::new(None).await; - - for (store_id, store) in config.stores.stores { - println!("Testing internal directory with store {:?}", store_id); - store_destroy(&store).await; - - // A principal without name should fail - assert_eq!( - store - .create_principal(PrincipalSet::default(), None, None) - .await, - Err(manage::err_missing(PrincipalField::Name)) - ); - - // Basic account creation - let john_id = store - .create_principal( - TestPrincipal { - name: "john".into(), - description: Some("John Doe".into()), - secrets: vec!["secret".into(), "$app$secret2".into()], - ..Default::default() - } - .into(), - None, - None, - ) - .await - .unwrap() - .id; - - // Two accounts with the same name should fail - assert_eq!( - store - .create_principal( - TestPrincipal { - name: "john".into(), - ..Default::default() - } - .into(), - None, - None - ) - .await, - Err(manage::err_exists(PrincipalField::Name, "john")) - ); - - // An account using a non-existent domain should fail - assert_eq!( - store - .create_principal( - TestPrincipal { - name: "jane".into(), - emails: vec!["jane@example.org".into()], - ..Default::default() - } - .into(), - None, - None - ) - .await, - Err(manage::not_found("example.org")) - ); - - // Create a domain name - store - .create_principal( - TestPrincipal { - name: "example.org".into(), - typ: Type::Domain, - ..Default::default() - } - .into(), - None, - None, - ) - .await - .unwrap(); - assert!(store.is_local_domain("example.org").await.unwrap()); - assert!(!store.is_local_domain("otherdomain.org").await.unwrap()); - - // Add an email address - assert!( - store - .update_principal(UpdatePrincipal::by_name("john").with_updates(vec![ - PrincipalUpdate::add_item( - PrincipalField::Emails, - PrincipalValue::String("john@example.org".into()), - ) - ])) - .await - .is_ok() - ); - assert_eq!( - store.rcpt("john@example.org").await.unwrap(), - RcptType::Mailbox - ); - assert_eq!( - store.email_to_id("john@example.org").await.unwrap(), - Some(john_id) - ); - - // Using non-existent domain should fail - assert_eq!( - store - .update_principal(UpdatePrincipal::by_name("john").with_updates(vec![ - PrincipalUpdate::add_item( - PrincipalField::Emails, - PrincipalValue::String("john@otherdomain.org".into()), - ) - ])) - .await, - Err(manage::not_found("otherdomain.org")) - ); - - // Create an account with an email address - let jane_id = store - .create_principal( - TestPrincipal { - name: "jane".into(), - description: Some("Jane Doe".into()), - secrets: vec!["my_secret".into(), "$app$my_secret2".into()], - emails: vec!["jane@example.org".into()], - quota: 123, - ..Default::default() - } - .into(), - None, - None, - ) - .await - .unwrap() - .id; - - assert_eq!( - store.rcpt("jane@example.org").await.unwrap(), - RcptType::Mailbox - ); - assert_eq!( - store.rcpt("jane@otherdomain.org").await.unwrap(), - RcptType::Invalid - ); - assert_eq!( - store.email_to_id("jane@example.org").await.unwrap(), - Some(jane_id) - ); - assert_eq!(store.vrfy("jane").await.unwrap(), vec!["jane@example.org"]); - assert_eq!( - store - .query( - QueryParams::credentials(&Credentials::new("jane".into(), "my_secret".into())) - .with_return_member_of(true) - ) - .await - .unwrap() - .map(|p| p.into_test()), - Some(TestPrincipal { - id: jane_id, - name: "jane".into(), - description: Some("Jane Doe".into()), - emails: vec!["jane@example.org".into()], - secrets: vec!["my_secret".into(), "$app$my_secret2".into()], - quota: 123, - ..Default::default() - }) - ); - assert_eq!( - store - .query( - QueryParams::credentials(&Credentials::new( - "jane".into(), - "wrong_password".into() - )) - .with_return_member_of(true) - ) - .await - .unwrap(), - None - ); - - // Duplicate email address should fail - assert_eq!( - store - .create_principal( - TestPrincipal { - name: "janeth".into(), - description: Some("Janeth Doe".into()), - emails: vec!["jane@example.org".into()], - ..Default::default() - } - .into(), - None, - None - ) - .await, - Err(manage::err_exists( - PrincipalField::Emails, - "jane@example.org" - )) - ); - - // Create a mailing list - let list_id = store - .create_principal( - TestPrincipal { - name: "list".into(), - typ: Type::List, - emails: vec!["list@example.org".into()], - ..Default::default() - } - .into(), - None, - None, - ) - .await - .unwrap() - .id; - assert!( - store - .update_principal(UpdatePrincipal::by_name("list").with_updates(vec![ - PrincipalUpdate::set( - PrincipalField::Members, - PrincipalValue::StringList(vec!["john".into(), "jane".into()]), - ), - PrincipalUpdate::set( - PrincipalField::ExternalMembers, - PrincipalValue::StringList(vec![ - "mike@other.org".into(), - "lucy@foobar.net".into() - ]), - ) - ])) - .await - .is_ok() - ); - - assert_list_members( - &store, - "list@example.org", - [ - "john@example.org", - "mike@other.org", - "lucy@foobar.net", - "jane@example.org", - ], - ) - .await; - - assert_eq!( - store - .query(QueryParams::name("list").with_return_member_of(true)) - .await - .unwrap() - .unwrap() - .into_test(), - TestPrincipal { - name: "list".into(), - id: list_id, - typ: Type::List, - emails: vec!["list@example.org".into()], - ..Default::default() - } - ); - assert_eq!( - store - .expn("list@example.org") - .await - .unwrap() - .into_iter() - .collect::>(), - [ - "john@example.org", - "mike@other.org", - "lucy@foobar.net", - "jane@example.org" - ] - .into_iter() - .map(|s| s.into()) - .collect::>() - ); - - // Create groups - store - .create_principal( - TestPrincipal { - name: "sales".into(), - description: Some("Sales Team".into()), - typ: Type::Group, - ..Default::default() - } - .into(), - None, - None, - ) - .await - .unwrap(); - store - .create_principal( - TestPrincipal { - name: "support".into(), - description: Some("Support Team".into()), - typ: Type::Group, - ..Default::default() - } - .into(), - None, - None, - ) - .await - .unwrap(); - - // Add John to the Sales and Support groups - assert!( - store - .update_principal(UpdatePrincipal::by_name("john").with_updates(vec![ - PrincipalUpdate::add_item( - PrincipalField::MemberOf, - PrincipalValue::String("sales".into()), - ), - PrincipalUpdate::add_item( - PrincipalField::MemberOf, - PrincipalValue::String("support".into()), - ) - ])) - .await - .is_ok() - ); - let principal = store - .query(QueryParams::name("john").with_return_member_of(true)) - .await - .unwrap() - .unwrap(); - let principal = store.map_principal(principal, &[]).await.unwrap(); - assert_eq!( - principal.into_test().into_sorted(), - TestPrincipal { - id: john_id, - name: "john".into(), - description: Some("John Doe".into()), - secrets: vec!["secret".into(), "$app$secret2".into()], - emails: vec!["john@example.org".into()], - member_of: vec!["sales".into(), "support".into()], - lists: vec!["list".into()], - ..Default::default() - } - ); - - // Adding a non-existent user should fail - assert_eq!( - store - .update_principal(UpdatePrincipal::by_name("john").with_updates(vec![ - PrincipalUpdate::add_item( - PrincipalField::MemberOf, - PrincipalValue::String("accounting".into()), - ) - ])) - .await, - Err(manage::not_found("accounting")) - ); - - // Remove a member from a group - assert!( - store - .update_principal(UpdatePrincipal::by_name("john").with_updates(vec![ - PrincipalUpdate::remove_item( - PrincipalField::MemberOf, - PrincipalValue::String("support".into()), - ) - ])) - .await - .is_ok() - ); - let principal = store - .query(QueryParams::name("john").with_return_member_of(true)) - .await - .unwrap() - .unwrap(); - let principal = store.map_principal(principal, &[]).await.unwrap(); - assert_eq!( - principal.into_test().into_sorted(), - TestPrincipal { - id: john_id, - name: "john".into(), - description: Some("John Doe".into()), - secrets: vec!["secret".into(), "$app$secret2".into()], - emails: vec!["john@example.org".into()], - member_of: vec!["sales".into()], - lists: vec!["list".into()], - ..Default::default() - } - ); - - // Update multiple fields - assert!( - store - .update_principal(UpdatePrincipal::by_name("john").with_updates(vec![ - PrincipalUpdate::set( - PrincipalField::Name, - PrincipalValue::String("john.doe".into()) - ), - PrincipalUpdate::set( - PrincipalField::Description, - PrincipalValue::String("Johnny Doe".into()) - ), - PrincipalUpdate::set( - PrincipalField::Secrets, - PrincipalValue::StringList(vec!["12345".into()]) - ), - PrincipalUpdate::set(PrincipalField::Quota, PrincipalValue::Integer(1024)), - PrincipalUpdate::remove_item( - PrincipalField::Emails, - PrincipalValue::String("john@example.org".into()), - ), - PrincipalUpdate::add_item( - PrincipalField::Emails, - PrincipalValue::String("john.doe@example.org".into()), - ) - ])) - .await - .is_ok() - ); - - let principal = store - .query(QueryParams::name("john.doe").with_return_member_of(true)) - .await - .unwrap() - .unwrap(); - let principal = store.map_principal(principal, &[]).await.unwrap(); - assert_eq!( - principal.into_test().into_sorted(), - TestPrincipal { - id: john_id, - name: "john.doe".into(), - description: Some("Johnny Doe".into()), - secrets: vec!["12345".into()], - emails: vec!["john.doe@example.org".into()], - quota: 1024, - typ: Type::Individual, - member_of: vec!["sales".into()], - lists: vec!["list".into()], - ..Default::default() - } - ); - assert_eq!(store.get_principal_id("john").await.unwrap(), None); - assert_eq!( - store.rcpt("john@example.org").await.unwrap(), - RcptType::Invalid - ); - assert_eq!( - store.rcpt("john.doe@example.org").await.unwrap(), - RcptType::Mailbox - ); - - // Remove a member from a mailing list and then add it back - assert!( - store - .update_principal(UpdatePrincipal::by_name("list").with_updates(vec![ - PrincipalUpdate::remove_item( - PrincipalField::Members, - PrincipalValue::String("john.doe".into()), - ) - ])) - .await - .is_ok() - ); - assert_list_members( - &store, - "list@example.org", - ["jane@example.org", "mike@other.org", "lucy@foobar.net"], - ) - .await; - assert!( - store - .update_principal(UpdatePrincipal::by_name("list").with_updates(vec![ - PrincipalUpdate::add_item( - PrincipalField::Members, - PrincipalValue::String("john.doe".into()), - ) - ])) - .await - .is_ok() - ); - assert_list_members( - &store, - "list@example.org", - [ - "john.doe@example.org", - "jane@example.org", - "mike@other.org", - "lucy@foobar.net", - ], - ) - .await; - - // Field validation - assert_eq!( - store - .update_principal(UpdatePrincipal::by_name("john.doe").with_updates(vec![ - PrincipalUpdate::set( - PrincipalField::Name, - PrincipalValue::String("jane".into()) - ), - ])) - .await, - Err(manage::err_exists(PrincipalField::Name, "jane")) - ); - assert_eq!( - store - .update_principal(UpdatePrincipal::by_name("john.doe").with_updates(vec![ - PrincipalUpdate::add_item( - PrincipalField::Emails, - PrincipalValue::String("jane@example.org".into()) - ), - ])) - .await, - Err(manage::err_exists( - PrincipalField::Emails, - "jane@example.org" - )) - ); - - // List accounts - assert_eq!( - store - .list_principals( - None, - None, - &[Type::Individual, Type::Group, Type::List], - true, - 0, - 0 - ) - .await - .unwrap() - .items - .into_iter() - .map(|p| p.name) - .collect::>(), - ["jane", "john.doe", "list", "sales", "support"] - .into_iter() - .map(|s| s.into()) - .collect::>() - ); - assert_eq!( - store - .list_principals("john".into(), None, &[], true, 0, 0) - .await - .unwrap() - .items - .into_iter() - .map(|p| p.name) - .collect::>(), - vec!["john.doe"] - ); - assert_eq!( - store - .list_principals(None, None, &[Type::Individual], true, 0, 0) - .await - .unwrap() - .items - .into_iter() - .map(|p| p.name) - .collect::>(), - ["jane", "john.doe"] - .into_iter() - .map(|s| s.into()) - .collect::>() - ); - assert_eq!( - store - .list_principals(None, None, &[Type::Group], true, 0, 0) - .await - .unwrap() - .items - .into_iter() - .map(|p| p.name) - .collect::>(), - ["sales", "support"] - .into_iter() - .map(|s| s.into()) - .collect::>() - ); - assert_eq!( - store - .list_principals(None, None, &[Type::List], true, 0, 0) - .await - .unwrap() - .items - .into_iter() - .map(|p| p.name) - .collect::>(), - vec!["list"] - ); - assert_eq!( - store - .list_principals("example.org".into(), None, &[], true, 0, 0) - .await - .unwrap() - .items - .into_iter() - .map(|p| p.name) - .collect::>(), - vec!["example.org", "jane", "john.doe", "list"] - ); - assert_eq!( - store - .list_principals("johnny doe".into(), None, &[], true, 0, 0) - .await - .unwrap() - .items - .into_iter() - .map(|p| p.name) - .collect::>(), - vec!["john.doe"] - ); - - // Write records on John's and Jane's accounts - let mut document_id = u32::MAX; - for account_id in [john_id, jane_id] { - document_id = store - .assign_document_ids(u32::MAX, Collection::Principal, 1) - .await - .unwrap(); - store - .write( - BatchBuilder::new() - .with_account_id(account_id) - .with_collection(Collection::Email) - .with_document(document_id) - .set(ValueClass::Property(0), "hello".as_bytes()) - .build_all(), - ) - .await - .unwrap(); - assert_eq!( - store - .get_value::(ValueKey { - account_id, - collection: Collection::Email.into(), - document_id, - class: ValueClass::Property(0) - }) - .await - .unwrap(), - Some("hello".into()) - ); - } - - // Delete John's account and make sure his records are gone - let server = Server { - inner: Arc::new(Inner::default()), - core: Arc::new(Core { - storage: Storage { - data: store.clone(), - blob: store.clone().into(), - fts: store.clone().into(), - ..Default::default() - }, - ..Default::default() - }), - }; - store.delete_principal(QueryBy::Id(john_id)).await.unwrap(); - destroy_account_data(&server, john_id, true).await.unwrap(); - assert_eq!(store.get_principal_id("john.doe").await.unwrap(), None); - assert_eq!( - store.email_to_id("john.doe@example.org").await.unwrap(), - None - ); - assert_eq!( - store.rcpt("john.doe@example.org").await.unwrap(), - RcptType::Invalid - ); - assert_eq!( - store - .list_principals( - None, - None, - &[Type::Individual, Type::Group, Type::List], - true, - 0, - 0 - ) - .await - .unwrap() - .items - .into_iter() - .map(|p| p.name) - .collect::>(), - ["jane", "list", "sales", "support"] - .into_iter() - .map(|s| s.into()) - .collect::>() - ); - assert!(!account_has_emails(&store, john_id).await); - assert_eq!( - store - .get_value::(ValueKey { - account_id: john_id, - collection: Collection::Email.into(), - document_id: 0, - class: ValueClass::Property(0) - }) - .await - .unwrap(), - None - ); - - // Make sure Jane's records are still there - assert_eq!(store.get_principal_id("jane").await.unwrap(), Some(jane_id)); - assert_eq!( - store.email_to_id("jane@example.org").await.unwrap(), - Some(jane_id) - ); - assert_eq!( - store.rcpt("jane@example.org").await.unwrap(), - RcptType::Mailbox - ); - assert!(account_has_emails(&store, jane_id).await); - assert_eq!( - store - .get_value::(ValueKey { - account_id: jane_id, - collection: Collection::Email.into(), - document_id, - class: ValueClass::Property(0) - }) - .await - .unwrap(), - Some("hello".into()) - ); - - // Clean up - destroy_account_data(&server, jane_id, true).await.unwrap(); - for principal_name in ["jane", "list", "sales", "support", "example.org"] { - store - .delete_principal(QueryBy::Name(principal_name)) - .await - .unwrap(); - } - store_assert_is_empty(&store, store.clone().into(), true).await; - } -} - -#[allow(async_fn_in_trait)] -pub trait TestInternalDirectory { - async fn create_test_user(&self, login: &str, secret: &str, name: &str, emails: &[&str]) - -> u32; - async fn create_test_group(&self, login: &str, name: &str, emails: &[&str]) -> u32; - async fn create_test_list(&self, login: &str, name: &str, emails: &[&str]) -> u32; - async fn set_test_quota(&self, login: &str, quota: u32); - async fn add_permissions(&self, login: &str, permissions: impl IntoIterator); - async fn remove_permissions( - &self, - login: &str, - permissions: impl IntoIterator, - ); - async fn add_to_group(&self, login: &str, group: &str) -> ChangedPrincipals; - async fn remove_from_group(&self, login: &str, group: &str) -> ChangedPrincipals; - async fn remove_test_alias(&self, login: &str, alias: &str); - async fn create_test_domains(&self, domains: &[&str]); -} - -impl TestInternalDirectory for Store { - async fn create_test_user( - &self, - login: &str, - secret: &str, - name: &str, - emails: &[&str], - ) -> u32 { - let role = if login == "admin" { "admin" } else { "user" }; - self.create_test_domains(emails).await; - if let Some(principal) = self - .query(QueryParams::name(login).with_return_member_of(false)) - .await - .unwrap() - { - self.update_principal(UpdatePrincipal::by_id(principal.id()).with_updates(vec![ - PrincipalUpdate::set( - PrincipalField::Secrets, - PrincipalValue::StringList(vec![secret.into()]), - ), - PrincipalUpdate::set( - PrincipalField::Description, - PrincipalValue::String(name.into()), - ), - PrincipalUpdate::set( - PrincipalField::Emails, - PrincipalValue::StringList(emails.iter().map(|s| (*s).into()).collect()), - ), - PrincipalUpdate::add_item( - PrincipalField::Roles, - PrincipalValue::String(role.into()), - ), - PrincipalUpdate::add_item( - PrincipalField::EnabledPermissions, - PrincipalValue::String(Permission::UnlimitedRequests.name().into()), - ), - ])) - .await - .unwrap(); - principal.id() - } else { - self.create_principal( - PrincipalSet::new(0, Type::Individual) - .with_field(PrincipalField::Name, login) - .with_field(PrincipalField::Description, name) - .with_field( - PrincipalField::Secrets, - PrincipalValue::StringList(vec![secret.into()]), - ) - .with_field( - PrincipalField::Emails, - PrincipalValue::StringList(emails.iter().map(|s| (*s).into()).collect()), - ) - .with_field( - PrincipalField::Roles, - PrincipalValue::StringList(vec![role.into()]), - ) - .with_field( - PrincipalField::EnabledPermissions, - PrincipalValue::StringList(vec![ - Permission::UnlimitedRequests.name().into(), - ]), - ), - None, - None, - ) - .await - .unwrap() - .id - } - } - - async fn create_test_group(&self, login: &str, name: &str, emails: &[&str]) -> u32 { - self.create_test_domains(emails).await; - if let Some(principal) = self - .query(QueryParams::name(login).with_return_member_of(false)) - .await - .unwrap() - { - principal.id() - } else { - self.create_principal( - PrincipalSet::new(0, Type::Group) - .with_field(PrincipalField::Name, login) - .with_field(PrincipalField::Description, name) - .with_field( - PrincipalField::Emails, - PrincipalValue::StringList(emails.iter().map(|s| (*s).into()).collect()), - ) - .with_field( - PrincipalField::Roles, - PrincipalValue::StringList(vec!["user".into()]), - ), - None, - None, - ) - .await - .unwrap() - .id - } - } - - async fn create_test_list(&self, login: &str, name: &str, members: &[&str]) -> u32 { - if let Some(principal) = self - .query(QueryParams::name(login).with_return_member_of(false)) - .await - .unwrap() - { - principal.id() - } else { - self.create_test_domains(&[login]).await; - self.create_principal( - PrincipalSet::new(0, Type::List) - .with_field(PrincipalField::Name, login) - .with_field(PrincipalField::Description, name) - .with_field( - PrincipalField::Members, - PrincipalValue::StringList(members.iter().map(|s| (*s).into()).collect()), - ) - .with_field( - PrincipalField::Emails, - PrincipalValue::StringList(vec![login.into()]), - ), - None, - None, - ) - .await - .unwrap() - .id - } - } - - async fn set_test_quota(&self, login: &str, quota: u32) { - self.update_principal(UpdatePrincipal::by_name(login).with_updates(vec![ - PrincipalUpdate::set(PrincipalField::Quota, PrincipalValue::Integer(quota as u64)), - ])) - .await - .unwrap(); - } - - async fn add_permissions( - &self, - login: &str, - permissions: impl IntoIterator, - ) { - self.update_principal( - UpdatePrincipal::by_name(login).with_updates( - permissions - .into_iter() - .map(|p| { - PrincipalUpdate::add_item( - PrincipalField::EnabledPermissions, - PrincipalValue::String(p.name().to_string()), - ) - }) - .collect(), - ), - ) - .await - .unwrap(); - } - - async fn remove_permissions( - &self, - login: &str, - permissions: impl IntoIterator, - ) { - self.update_principal( - UpdatePrincipal::by_name(login).with_updates( - permissions - .into_iter() - .map(|p| { - PrincipalUpdate::remove_item( - PrincipalField::EnabledPermissions, - PrincipalValue::String(p.name().to_string()), - ) - }) - .collect(), - ), - ) - .await - .unwrap(); - } - - async fn add_to_group(&self, login: &str, group: &str) -> ChangedPrincipals { - self.update_principal(UpdatePrincipal::by_name(login).with_updates(vec![ - PrincipalUpdate::add_item( - PrincipalField::MemberOf, - PrincipalValue::String(group.into()), - ), - ])) - .await - .unwrap() - } - - async fn remove_from_group(&self, login: &str, group: &str) -> ChangedPrincipals { - self.update_principal(UpdatePrincipal::by_name(login).with_updates(vec![ - PrincipalUpdate::remove_item( - PrincipalField::MemberOf, - PrincipalValue::String(group.into()), - ), - ])) - .await - .unwrap() - } - - async fn remove_test_alias(&self, login: &str, alias: &str) { - self.update_principal(UpdatePrincipal::by_name(login).with_updates(vec![ - PrincipalUpdate::remove_item( - PrincipalField::Emails, - PrincipalValue::String(alias.into()), - ), - ])) - .await - .unwrap(); - } - - async fn create_test_domains(&self, domains: &[&str]) { - for domain in domains { - let domain = domain.rsplit_once('@').map_or(*domain, |(_, d)| d); - if self - .query(QueryParams::name(domain).with_return_member_of(false)) - .await - .unwrap() - .is_none() - { - self.create_principal( - PrincipalSet::new(0, Type::Domain).with_field(PrincipalField::Name, domain), - None, - None, - ) - .await - .unwrap(); - } - } - } -} - -async fn account_has_emails(store: &Store, account_id: u32) -> bool { - let mut has_emails = false; - store - .iterate( - IterateParams::new( - ValueKey { - account_id, - collection: Collection::Email.into(), - document_id: 0, - class: ValueClass::Property(0), - }, - ValueKey { - account_id, - collection: Collection::Email.into(), - document_id: u32::MAX, - class: ValueClass::Property(u8::MAX), - }, - ) - .no_values(), - |_, _| { - has_emails = true; - Ok(false) - }, - ) - .await - .unwrap(); - has_emails -} - -async fn assert_list_members( - store: &Store, - list_addr: &str, - members: impl IntoIterator, -) { - match store.rcpt(list_addr).await.unwrap() { - RcptType::List(items) => { - assert_eq!( - items.into_iter().collect::>(), - members - .into_iter() - .map(|s| s.into()) - .collect::>() - ); - } - other => panic!("invalid {other:?}"), - } -} diff --git a/tests/src/directory/ldap.rs b/tests/src/directory/ldap.rs index e0ba577e..e72bafcc 100644 --- a/tests/src/directory/ldap.rs +++ b/tests/src/directory/ldap.rs @@ -4,270 +4,155 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::directory::{ - DirectoryTest, IntoTestPrincipal, TestPrincipal, map_account_id, map_account_ids, +use directory::{Account, Credentials, Group, Recipient, backend::ldap::LdapDirectory}; +use registry::{ + schema::structs::{self, SecretKeyOptional, SecretKeyValue}, + types::map::Map, }; -use mail_send::Credentials; -use std::fmt::Debug; #[tokio::test] async fn ldap_directory() { - // Enable logging - crate::enable_logging(); + let mut config = structs::LdapDirectory { + url: "ldap://localhost".into(), + use_tls: false, + attr_class: Map::new(vec!["objectClass".to_string()]), + attr_description: Map::new(vec!["cn".to_string()]), + attr_email: Map::new(vec!["mail".to_string()]), + attr_email_alias: Map::new(vec!["mailAlias".to_string()]), + attr_member_of: Map::new(vec!["memberOf".to_string()]), + attr_secret: Map::new(vec![]), + attr_secret_changed: Map::new(vec!["shadowLastChange".to_string()]), + base_dn: "dc=stalwart,dc=test".into(), + bind_dn: "cn=admin,dc=stalwart,dc=test".to_string().into(), + bind_secret: SecretKeyOptional::Value(SecretKeyValue { + secret: "admin".into(), + }), + filter_member_of: "(&(objectClass=groupOfNames)(member=?))".to_string().into(), + filter_login: "(&(objectClass=inetOrgPerson)(mail=?))".into(), + filter_mailbox: concat!( + "(|(&(objectClass=inetOrgPerson)(|(mail=?)(mailAlias=?)))", + "(&(objectClass=groupOfNames)(|(mail=?)(mailAlias=?))))" + ) + .into(), + group_class: "groupOfNames".into(), + bind_authentication: true, + ..Default::default() + }; - // Obtain directory handle - let mut config = DirectoryTest::new("sqlite".into()).await; - let handle = config.directories.directories.remove("ldap").unwrap(); - let base_store = config.stores.stores.get("sqlite").unwrap(); - let core = config.server; - - // Test authentication - for (auth_type, handle) in [ - ("Default", handle.clone()), - ( - "Bind template", - config - .directories - .directories - .remove("ldap-bind-template") - .unwrap(), - ), - ( - "Bind lookup", - config - .directories - .directories - .remove("ldap-bind-lookup") - .unwrap(), - ), - ] { - println!("Testing {auth_type} LDAP authentication..."); - assert_eq!( - handle - .query( - QueryParams::credentials(&Credentials::Plain { - username: "john".into(), - secret: "12345".into() - }) - .with_return_member_of(true) - ) - .await - .unwrap() - .unwrap() - .into_test() - .into_sorted(), - TestPrincipal { - id: base_store.get_principal_id("john").await.unwrap().unwrap(), - name: "john".into(), - description: Some("John Doe".into()), - secrets: vec!["12345".into()], - typ: Type::Individual, - member_of: map_account_ids(base_store, vec!["sales"]) - .await - .into_iter() - .map(|v| v.to_string()) - .collect(), - emails: vec!["john@example.org".into(), "john.doe@example.org".into()], - roles: vec![ROLE_USER.to_string()], - ..Default::default() - } - .into_sorted() - ); - assert_eq!( - handle - .query( - QueryParams::credentials(&Credentials::Plain { - username: "bill".into(), - secret: "password".into() - }) - .with_return_member_of(true) - ) - .await - .unwrap() - .unwrap() - .into_test() - .into_sorted(), - TestPrincipal { - id: base_store.get_principal_id("bill").await.unwrap().unwrap(), - name: "bill".into(), - description: Some("Bill Foobar".into()), - secrets: vec![ - "$2y$05$bvIG6Nmid91Mu9RcmmWZfO5HJIMCT8riNW0hEp8f6/FuA2/mHZFpe".into() - ], - typ: Type::Individual, - quota: 500000, - emails: vec!["bill@example.org".into(),], - roles: vec![ROLE_USER.to_string()], - ..Default::default() - } - .into_sorted() - ); - assert!( - handle - .query( - QueryParams::credentials(&Credentials::Plain { - username: "bill".into(), - secret: "invalid".into() - }) - .with_return_member_of(true) - ) - .await - .unwrap() - .is_none() - ); - } - - // Get user by name + // Test bind authentication + let ldap = LdapDirectory::open(config.clone()).await.unwrap(); assert_eq!( - handle - .query(QueryParams::name("jane").with_return_member_of(true)) - .await - .unwrap() - .unwrap() - .into_test() - .into_sorted(), - TestPrincipal { - id: base_store.get_principal_id("jane").await.unwrap().unwrap(), - name: "jane".into(), - description: Some("Jane Doe".into()), - typ: Type::Individual, - secrets: vec!["abcde".into()], - member_of: map_account_ids(base_store, vec!["sales", "support"]) - .await - .into_iter() - .map(|v| v.to_string()) - .collect(), - emails: vec!["jane@example.org".into(),], - roles: vec![ROLE_USER.to_string()], - ..Default::default() - } - .into_sorted() - ); - - // Get group by name - assert_eq!( - handle - .query(QueryParams::name("sales").with_return_member_of(true)) - .await - .unwrap() - .unwrap() - .into_test(), - TestPrincipal { - id: base_store.get_principal_id("sales").await.unwrap().unwrap(), - name: "sales".into(), - description: Some("sales".into()), - typ: Type::Group, - roles: vec![ROLE_USER.to_string()], - ..Default::default() + ldap.authenticate(&Credentials::Basic { + username: "john.doe@example.org".into(), + secret: "this is John's LDAP password".into(), + mfa_token: None, + }) + .await + .unwrap(), + Account { + email: "john.doe@example.org".into(), + email_aliases: vec!["john@example.org".into()], + secret: Some("$app$8958830913002348890$".into()), + groups: vec!["sales@example.org".into()], + description: Some("John Doe".into()), } ); - - // Ids by email assert_eq!( - core.email_to_id(&handle, "jane@example.org", 0) - .await - .unwrap(), - Some(map_account_id(base_store, "jane").await), + ldap.authenticate(&Credentials::Basic { + username: "jane.smith@example.org".into(), + secret: "this is Jane's LDAP password".into(), + mfa_token: None, + }) + .await + .unwrap(), + Account { + email: "jane.smith@example.org".into(), + email_aliases: vec![], + secret: Some("$app$4096614298472586996$".into()), + groups: vec!["sales@example.org".into(), "corporate@example.org".into()], + description: Some("Jane Smith".into()), + } ); - assert_eq!( - core.email_to_id(&handle, "jane+alias@example.org", 0) - .await - .unwrap(), - Some(map_account_id(base_store, "jane").await), - ); - assert_eq!( - core.email_to_id(&handle, "unknown@example.org", 0) - .await - .unwrap(), - None, - ); - assert_eq!( - core.email_to_id(&handle, "anything@catchall.org", 0) - .await - .unwrap(), - Some(map_account_id(base_store, "robert").await) + assert!( + ldap.authenticate(&Credentials::Basic { + username: "jane.smith@example.org".into(), + secret: "this is a wrong LDAP password".into(), + mfa_token: None, + }) + .await + .is_err() ); - // Domain validation - assert!(handle.is_local_domain("example.org").await.unwrap()); - assert!(!handle.is_local_domain("other.org").await.unwrap()); - - // RCPT TO + // Test direct authentication (without bind) + config.attr_secret = Map::new(vec!["userPassword".to_string()]); + config.attr_secret_changed = Map::new(vec![]); + config.bind_authentication = false; + let ldap = LdapDirectory::open(config.clone()).await.unwrap(); assert_eq!( - core.rcpt(&handle, "jane@example.org", 0).await.unwrap(), - RcptType::Mailbox + ldap.authenticate(&Credentials::Basic { + username: "john.doe@example.org".into(), + secret: "this is John's LDAP password".into(), + mfa_token: None, + }) + .await + .unwrap(), + Account { + email: "john.doe@example.org".into(), + email_aliases: vec!["john@example.org".into()], + secret: Some("this is John's LDAP password".into()), + groups: vec!["sales@example.org".into()], + description: Some("John Doe".into()), + } ); - assert_eq!( - core.rcpt(&handle, "info@example.org", 0).await.unwrap(), - RcptType::Mailbox - ); - assert_eq!( - core.rcpt(&handle, "jane+alias@example.org", 0) - .await - .unwrap(), - RcptType::Mailbox - ); - assert_eq!( - core.rcpt(&handle, "info+alias@example.org", 0) - .await - .unwrap(), - RcptType::Mailbox - ); - assert_eq!( - core.rcpt(&handle, "random_user@catchall.org", 0) - .await - .unwrap(), - RcptType::Mailbox - ); - assert_eq!( - core.rcpt(&handle, "invalid@example.org", 0).await.unwrap(), - RcptType::Invalid + assert!( + ldap.authenticate(&Credentials::Basic { + username: "john.doe@example.org".into(), + secret: "this is a wrong LDAP password".into(), + mfa_token: None, + }) + .await + .is_err() ); - // VRFY - compare_sorted( - core.vrfy(&handle, "jane", 0).await.unwrap(), - vec!["jane@example.org".into()], + // Test recipient lookup + assert_eq!( + ldap.recipient("john.doe@example.org").await.unwrap(), + Recipient::Account(Account { + email: "john.doe@example.org".into(), + email_aliases: vec!["john@example.org".into()], + secret: Some("this is John's LDAP password".into()), + groups: vec!["sales@example.org".into()], + description: Some("John Doe".into()) + }) ); - compare_sorted( - core.vrfy(&handle, "john", 0).await.unwrap(), - vec!["john@example.org".into(), "john.doe@example.org".into()], + assert_eq!( + ldap.recipient("jane.smith@example.org").await.unwrap(), + Recipient::Account(Account { + email: "jane.smith@example.org".into(), + email_aliases: vec![], + secret: Some("this is Jane's LDAP password".into()), + groups: vec!["sales@example.org".into(), "corporate@example.org".into()], + description: Some("Jane Smith".into()) + }) ); - compare_sorted( - core.vrfy(&handle, "jane+alias@example", 0).await.unwrap(), - vec!["jane@example.org".into()], + assert_eq!( + ldap.recipient("sales@example.org").await.unwrap(), + Recipient::Group(Group { + email: "sales@example.org".into(), + email_aliases: vec![], + description: Some("sales".into()) + }) ); - compare_sorted( - core.vrfy(&handle, "info", 0).await.unwrap(), - Vec::::new(), + assert_eq!( + ldap.recipient("corporate@example.org").await.unwrap(), + Recipient::Group(Group { + email: "corporate@example.org".into(), + email_aliases: vec!["everyone@example.org".into()], + description: Some("corporate".into()) + }) ); - compare_sorted( - core.vrfy(&handle, "invalid", 0).await.unwrap(), - Vec::::new(), + assert_eq!( + ldap.recipient("nonexistent@example.org").await.unwrap(), + Recipient::Invalid ); - - // EXPN - // Now handled by the internal directory - /*compare_sorted( - core.expn(&handle, "info@example.org", 0).await.unwrap(), - vec![ - "bill@example.org".into(), - "jane@example.org".into(), - "john@example.org".into(), - ], - ); - compare_sorted( - core.expn(&handle, "john@example.org", 0).await.unwrap(), - Vec::::new(), - );*/ -} - -fn compare_sorted(v1: Vec, v2: Vec) { - for val in v1.iter() { - assert!(v2.contains(val), "{v1:?} != {v2:?}"); - } - - for val in v2.iter() { - assert!(v1.contains(val), "{v1:?} != {v2:?}"); - } } diff --git a/tests/src/directory/mod.rs b/tests/src/directory/mod.rs index 60ede0f5..c4b0c26f 100644 --- a/tests/src/directory/mod.rs +++ b/tests/src/directory/mod.rs @@ -4,781 +4,6 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -pub mod internal; pub mod ldap; pub mod oidc; -pub mod sql; - -use crate::{AssertConfig, store::TempDir}; -use common::{Core, Server, config::smtp::session::AddressMapping}; -use mail_send::Credentials; -use rustls::ServerConfig; -use rustls_pemfile::{certs, pkcs8_private_keys}; -use rustls_pki_types::PrivateKeyDer; -use std::{borrow::Cow, io::BufReader, sync::Arc}; -use tokio_rustls::TlsAcceptor; - -const CONFIG: &str = r#" -[directory."rocksdb"] -type = "internal" -store = "rocksdb" - -[directory."foundationdb"] -type = "internal" -store = "foundationdb" - -[directory."sqlite"] -type = "sql" -store = "sqlite" - -[directory."sqlite".columns] -name = "name" -description = "description" -secret = "secret" -email = "address" -quota = "quota" -class = "type" - -[store."rocksdb"] -type = "rocksdb" -path = "{TMP}/rocksdb" - -[store."foundationdb"] -type = "foundationdb" - -[store."sqlite"] -type = "sqlite" -path = "{TMP}/auth.db" - -[store."sqlite".query] -name = "SELECT name, type, secret, description, quota FROM accounts WHERE name = ? AND active = true" -members = "SELECT member_of FROM group_members WHERE name = ?" -recipients = "SELECT name FROM emails WHERE address = ? ORDER BY name ASC" -emails = "SELECT address FROM emails WHERE name = ? AND type != 'list' ORDER BY type DESC, address ASC" -verify = "SELECT address FROM emails WHERE address LIKE '%' || ? || '%' AND type = 'primary' ORDER BY address LIMIT 5" -expand = "SELECT p.address FROM emails AS p JOIN emails AS l ON p.name = l.name WHERE p.type = 'primary' AND l.address = ? AND l.type = 'list' ORDER BY p.address LIMIT 50" -domains = "SELECT 1 FROM emails WHERE address LIKE '%@' || ? LIMIT 1" - -[storage] -lookup = "sqlite" - -############################################################################## - -[directory."postgresql"] -type = "sql" -store = "postgresql" - -[directory."postgresql".columns] -name = "name" -description = "description" -secret = "secret" -email = "address" -quota = "quota" -class = "type" - -[store."postgresql"] -type = "postgresql" -host = "localhost" -port = 5432 -database = "stalwart" -user = "postgres" -password = "mysecretpassword" - -[store."postgresql".query] -name = "SELECT name, type, secret, description, quota FROM accounts WHERE name = $1 AND active = true" -members = "SELECT member_of FROM group_members WHERE name = $1" -recipients = "SELECT name FROM emails WHERE address = $1 ORDER BY name ASC" -emails = "SELECT address FROM emails WHERE name = $1 AND type != 'list' ORDER BY type DESC, address ASC" -verify = "SELECT address FROM emails WHERE address LIKE '%' || $1 || '%' AND type = 'primary' ORDER BY address LIMIT 5" -expand = "SELECT p.address FROM emails AS p JOIN emails AS l ON p.name = l.name WHERE p.type = 'primary' AND l.address = $1 AND l.type = 'list' ORDER BY p.address LIMIT 50" -domains = "SELECT 1 FROM emails WHERE address LIKE '%@' || $1 LIMIT 1" - -############################################################################## - -[directory."mysql"] -type = "sql" -store = "mysql" - -[directory."mysql".columns] -name = "name" -description = "description" -secret = "secret" -email = "address" -quota = "quota" -class = "type" - -[store."mysql"] -type = "mysql" -host = "localhost" -port = 3307 -database = "stalwart" -user = "root" -password = "password" - -[store."mysql".query] -name = "SELECT name, type, secret, description, quota FROM accounts WHERE name = ? AND active = true" -members = "SELECT member_of FROM group_members WHERE name = ?" -recipients = "SELECT name FROM emails WHERE address = ? ORDER BY name ASC" -emails = "SELECT address FROM emails WHERE name = ? AND type != 'list' ORDER BY type DESC, address ASC" -verify = "SELECT address FROM emails WHERE address LIKE CONCAT('%', ?, '%') AND type = 'primary' ORDER BY address LIMIT 5" -expand = "SELECT p.address FROM emails AS p JOIN emails AS l ON p.name = l.name WHERE p.type = 'primary' AND l.address = ? AND l.type = 'list' ORDER BY p.address LIMIT 50" -domains = "SELECT 1 FROM emails WHERE address LIKE CONCAT('%@', ?) LIMIT 1" - -############################################################################## - -[directory."ldap"] -type = "ldap" -url = "ldap://localhost:3893" -base-dn = "dc=example,dc=org" - -[directory."ldap".bind] -dn = "cn=serviceuser,ou=svcaccts,dc=example,dc=org" -secret = "mysecret" - -[directory."ldap".filter] -name = "(&(|(objectClass=posixAccount)(objectClass=posixGroup))(uid=?))" -email = "(&(|(objectClass=posixAccount)(objectClass=posixGroup))(|(mail=?)(givenName=?)(sn=?)))" - -[directory."ldap".attributes] -name = "uid" -description = ["principalName", "description"] -secret = "userPassword" -groups = ["memberOf", "otherGroups"] -email = "mail" -email-alias = "givenName" -quota = "diskQuota" -class = "objectClass" - -[directory."ldap-bind-template"] -type = "ldap" -url = "ldap://localhost:3893" -base-dn = "dc=example,dc=org" - -[directory."ldap-bind-template".bind] -dn = "cn=serviceuser,ou=svcaccts,dc=example,dc=org" -secret = "mysecret" - -[directory."ldap-bind-template".bind.auth] -method = "template" -template = "cn={username},ou=,dc=example,dc=org" -search = false - -[directory."ldap-bind-template".filter] -name = "(&(|(objectClass=posixAccount)(objectClass=posixGroup))(uid=?))" -email = "(&(|(objectClass=posixAccount)(objectClass=posixGroup))(|(mail=?)(givenName=?)(sn=?)))" - -[directory."ldap-bind-template".attributes] -name = "uid" -description = ["principalName", "description"] -secret = "userPassword" -groups = ["memberOf", "otherGroups"] -email = "mail" -email-alias = "givenName" -quota = "diskQuota" -class = "objectClass" - -[directory."ldap-bind-lookup"] -type = "ldap" -url = "ldap://localhost:3893" -base-dn = "dc=example,dc=org" - -[directory."ldap-bind-lookup".bind] -dn = "cn=serviceuser,ou=svcaccts,dc=example,dc=org" -secret = "mysecret" - -[directory."ldap-bind-lookup".bind.auth] -method = "lookup" - -[directory."ldap-bind-lookup".filter] -name = "(&(|(objectClass=posixAccount)(objectClass=posixGroup))(uid=?))" -email = "(&(|(objectClass=posixAccount)(objectClass=posixGroup))(|(mail=?)(givenName=?)(sn=?)))" - -[directory."ldap-bind-lookup".attributes] -name = "uid" -description = ["principalName", "description"] -secret = "userPassword" -groups = ["memberOf", "otherGroups"] -email = "mail" -email-alias = "givenName" -quota = "diskQuota" -class = "objectClass" - -############################################################################## - -[directory."imap"] -type = "imap" -host = "127.0.0.1" -port = 9198 - -[directory."imap".pool] -max-connections = 5 - -[directory."imap".tls] -enable = true -allow-invalid-certs = true - -############################################################################## - -[directory."smtp"] -type = "lmtp" -host = "127.0.0.1" -port = 9199 - -[directory."smtp".limits] -auth-errors = 3 -rcpt = 5 - -[directory."smtp".pool] -max-connections = 5 - -[directory."smtp".tls] -enable = true -allow-invalid-certs = true - -[directory."smtp".cache] -entries = 500 -ttl = {positive = '10s', negative = '5s'} - -############################################################################## - -[directory."local"] -type = "memory" - -[[directory."local".principals]] -name = "john" -class = "individual" -description = "John Doe" -secret = "12345" -email = ["john@example.org", "jdoe@example.org", "john.doe@example.org"] -email-list = ["info@example.org"] -member-of = ["sales"] - -[[directory."local".principals]] -name = "jane" -class = "individual" -description = "Jane Doe" -secret = "abcde" -email = "jane@example.org" -email-list = ["info@example.org"] -member-of = ["sales", "support"] - -[[directory."local".principals]] -name = "bill" -class = "individual" -description = "Bill Foobar" -secret = "$2y$05$bvIG6Nmid91Mu9RcmmWZfO5HJIMCT8riNW0hEp8f6/FuA2/mHZFpe" -quota = 500000 -email = "bill@example.org" -email-list = ["info@example.org"] - -[[directory."local".principals]] -name = "sales" -class = "group" -description = "Sales Team" - -[[directory."local".principals]] -name = "support" -class = "group" -description = "Support Team" - -############################################################################## - -[directory."oidc-userinfo"] -type = "oidc" -store = "rocksdb" -timeout = "1s" -endpoint.url = "https://127.0.0.1:9090/userinfo" -endpoint.method = "userinfo" -fields.email = "email" -fields.username = "preferred_username" -fields.full-name = "name" - -[directory."oidc-introspect-none"] -type = "oidc" -store = "rocksdb" -timeout = "1s" -endpoint.url = "https://127.0.0.1:9090/introspect-none" -endpoint.method = "introspect" -auth.method = "none" -fields.email = "email" -fields.username = "preferred_username" -fields.full-name = "name" - -[directory."oidc-introspect-user-token"] -type = "oidc" -store = "rocksdb" -timeout = "1s" -endpoint.url = "https://127.0.0.1:9090/introspect-user-token" -endpoint.method = "introspect" -auth.method = "user-token" -fields.email = "email" -fields.username = "preferred_username" -fields.full-name = "name" - -[directory."oidc-introspect-token"] -type = "oidc" -store = "rocksdb" -timeout = "1s" -endpoint.url = "https://127.0.0.1:9090/introspect-token" -endpoint.method = "introspect" -auth.method = "token" -auth.token = "token_of_gratitude" -fields.email = "email" -fields.username = "preferred_username" -fields.full-name = "name" - -[directory."oidc-introspect-basic"] -type = "oidc" -store = "rocksdb" -timeout = "1s" -endpoint.url = "https://127.0.0.1:9090/introspect-basic" -endpoint.method = "introspect" -auth.method = "basic" -auth.username = "myuser" -auth.secret = "mypass" -fields.email = "email" -fields.username = "preferred_username" -fields.full-name = "name" - -"#; - -pub struct DirectoryStore { - pub store: Store, -} - -pub struct DirectoryTest { - pub directories: Directories, - pub stores: Stores, - pub temp_dir: TempDir, - pub server: Server, -} - -#[derive(Debug, Default, Clone, PartialEq, Eq)] -pub struct TestPrincipal { - pub id: u32, - pub typ: Type, - pub quota: u64, - pub name: String, - pub secrets: Vec, - pub emails: Vec, - pub member_of: Vec, - pub roles: Vec, - pub lists: Vec, - pub description: Option, -} - -impl DirectoryTest { - pub async fn new(id_store: Option<&str>) -> DirectoryTest { - let temp_dir = TempDir::new("directory_tests", true); - let mut config_file = CONFIG.replace("{TMP}", &temp_dir.path.to_string_lossy()); - if id_store.is_some() { - // Disable foundationdb store for SQL tests (the fdb select api version can only be run once per process) - config_file = config_file - .replace( - "type = \"foundationdb\"", - "type = \"foundationdb\"\ndisable = true", - ) - .replace( - "store = \"foundationdb\"", - "store = \"foundationdb\"\ndisable = true", - ) - } else { - // Disable internal store - config_file = - config_file.replace("type = \"memory\"", "type = \"memory\"\ndisable = true") - } - let mut config = utils::config::Config::new(&config_file).unwrap(); - let stores = Stores::parse_all(&mut config, false).await; - let directories = Directories::parse( - &mut config, - &stores, - id_store - .map(|id| stores.stores.get(id).unwrap().clone()) - .unwrap_or_default(), - true, - ) - .await; - - config.assert_no_errors(); - - // Enable catch-all and subaddressing - let mut core = Core::default(); - core.smtp.session.rcpt.catch_all = AddressMapping::Enable; - core.smtp.session.rcpt.subaddressing = AddressMapping::Enable; - - DirectoryTest { - directories, - stores, - temp_dir, - server: Server { - inner: Default::default(), - core: core.into(), - }, - } - } -} - -const CERT: &str = "-----BEGIN CERTIFICATE----- -MIIFCTCCAvGgAwIBAgIUCgHGQYUqtelbHGVSzCVwBL3fyEUwDQYJKoZIhvcNAQEL -BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTIyMDUxNjExNDAzNFoXDTIzMDUx -NjExNDAzNFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIICIjANBgkqhkiG9w0BAQEF -AAOCAg8AMIICCgKCAgEAtwS0Fzl3SjaCuKEXgZ/fdWbDoj/qDphyNCAKNevQ0+D0 -STNkWCO04aFSH0zcL8zoD9gokNos0i7OU9//ZhZQmex4V6EFdZn8bFwUWN/scUvW -HEFXVjtHldO2isZgIxH9LuwRv7KAgkISuWahqerOVDhe7SeQUV0AJGNEh3cT9PZr -gSY931BxB7n+5k8eoSk8Z1gtBzQzL62kVGpHDKfw8yX8m65owF9eLUBrNzgxmXfC -xpuHwj7hmVhS09PPKeN/RsFS8PsYO7bo0u8jEKalteumjRT7RyUEbioqfo6ZFOGj -FHPIq/uKXS9zN1fpoyNh3ur5hMznQhrqlwBM9KlM7GdBJ0pZ3ad0YjT8IL/GnGKR -85J2WZdLqaQdUZo7nV67FhqdDlNE4MdwiykTMjfmLRXGAVhAzJHKyRKNwmkI2aqe -S7aqeNgvuDBwY80Q9a2rb5py1Aw+L8yCkUBuHboToDpxSVRDNN8DrWNmmsXnxsOG -wRDODy4GICKyxlP+RFSM8xWSQ6y9ktS2OfDBm+Eqcw+3pZKhdz2wgxLkUBJ8X1eh -kJrCA/6LTuhy6m6mMjAfoSOFU7fu88jxaWPgvP7GKyH+LM/t9eucobz2ks5rtSjz -V4Dc5DCS94/OpVRHwHdaFSPbJKBN9Ev8gnNrAyx/aBPGoHBPG/QUiU7dcUNIPt0C -AwEAAaNTMFEwHQYDVR0OBBYEFI167IxBmErB11EqiPPqFLa31ZaMMB8GA1UdIwQY -MBaAFI167IxBmErB11EqiPPqFLa31ZaMMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI -hvcNAQELBQADggIBALU00IOiH5ubEauVCmakms5ermNTZfculnhnDfWTLMeh2+a7 -G4cqADErfMhm/mmLbrw33t9s6tCAhQltvewKR40ST9uMPSyiQbYaCXd5DXnuI6Ox -JtNW+UOWIaMf8abnkdLvREOvb8dVQS1i3xq14tAjY5XgpGwCPP8m54b7N3Q7soLn -e5PDhPNTnhRIn2RLuYoZmQmMA5fcqEUDYff4epUww7PhrM1QckZligI3566NlGOf -j1G9JrivBtY0eaJtamIFnGMBT0ThDudxVja2Nv0C2Elry0p4T/o4nc4M67BJ/y1R -vjNLAgFhbxssemU3lZqSd+pykpJBwDBjFSPrZZmQcbk7H6Uz8V1xr/xuzfw6fA13 -NWZ5vLgP/DQ13sM+XFlxThKfbPMPVe/UCTvfGtNW+3XyBgPntEkR+fNEawQmzbYl -R+X1ymT9MZnEZqRMf7/UD/SYek1aUJefoew3upjMgxYVvh4F8dqJ+39F+xoFzIA2 -1dDAEMzXtjA3zKhZ2cycZbEzpJvYA3eGLuR16Suqfi4kPvfwK0mOhCxQmpayt7/X -vuEzW6dPCH8Hgbb0WvsSppGOvhdbDaZFNfFc5eNSxhyKzu3H3ACNImZRtZE+yixx -0fR8+xz9kDLf8xupV+X9heyFGHSyYU2Lveaevtr2Ij3weLRgJ6LbNALoeKXk ------END CERTIFICATE----- -"; -const PK: &str = "-----BEGIN PRIVATE KEY----- -MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQC3BLQXOXdKNoK4 -oReBn991ZsOiP+oOmHI0IAo169DT4PRJM2RYI7ThoVIfTNwvzOgP2CiQ2izSLs5T -3/9mFlCZ7HhXoQV1mfxsXBRY3+xxS9YcQVdWO0eV07aKxmAjEf0u7BG/soCCQhK5 -ZqGp6s5UOF7tJ5BRXQAkY0SHdxP09muBJj3fUHEHuf7mTx6hKTxnWC0HNDMvraRU -akcMp/DzJfybrmjAX14tQGs3ODGZd8LGm4fCPuGZWFLT088p439GwVLw+xg7tujS -7yMQpqW166aNFPtHJQRuKip+jpkU4aMUc8ir+4pdL3M3V+mjI2He6vmEzOdCGuqX -AEz0qUzsZ0EnSlndp3RiNPwgv8acYpHzknZZl0uppB1RmjudXrsWGp0OU0Tgx3CL -KRMyN+YtFcYBWEDMkcrJEo3CaQjZqp5Ltqp42C+4MHBjzRD1ratvmnLUDD4vzIKR -QG4duhOgOnFJVEM03wOtY2aaxefGw4bBEM4PLgYgIrLGU/5EVIzzFZJDrL2S1LY5 -8MGb4SpzD7elkqF3PbCDEuRQEnxfV6GQmsID/otO6HLqbqYyMB+hI4VTt+7zyPFp -Y+C8/sYrIf4sz+3165yhvPaSzmu1KPNXgNzkMJL3j86lVEfAd1oVI9skoE30S/yC -c2sDLH9oE8agcE8b9BSJTt1xQ0g+3QIDAQABAoICABq5oxqpF5RMtXYEgAw7rkPU -h8jPkHwlIrgd3Z/WGZ53APUXfhWo0ScJiZZsgNKyF0kJBZNxaI4gq5xv3zmnFIoF -j+Ur7EIqBERGheoceMhqjI9/syMycNeeHM/S/ALjA5ewfT8C7+UVhOpx5DWNxidi -O+phlp9q9zRZEo69grqIqVYooWxUsMyyCljTQOPDw8BLjfe5VagmsRJqmolslLDM -4UBSjZVZ18S/3Wgo2oVQia660244BHWCAkZQbbXuNI2+eUAbSoSdxw3WQcaSrywL -hzyezbqr2yPDIIVuiUgVUt0Ps0P57VCCN07jlYhvCEGnClysFzD+ATefoZ0wg7za -dQu2E+d166rAjnssyhzcHMn3pxgSdtXD+dQR/xfIGbPABucCupEFqKmhLdMm9+ud -lHay87qzMpIa8cITJwEQROfXqWAhNUU98pKCOx1SVXBqQC7QVqGQ5solDf0eMSVh -ngQ6Dz2WUI2ty75LteiFwlyTgnU9nyPN0NXsrMEET2BHWre7ufTQqiULtQ7+9BwH -AMxEKvrQHjMUjdfbXuzdyc5w5mPYJZfFVSQ1HMslx66h9yCpRIsBZvUGvoaP8Tpe -nQ66FTYRbiOkkdJ7k8DtrnhsJI1oOGjnvj/rvZ8D2pvrlJcIH2AyN3MOL8Jp5Oj1 -nCFt77TwpF92pgl0g9gBAoIBAQDcarmP54QboaIQ9S2gE/4gSVC5i44iDJuSRdI8 -K081RQcWiNzqQXTRc5nqJ7KzLyPiGlg+6rWsBKLos5l4t+MdhhH+KUvk/OtT/g8V -0NZBNXLIbSb8j8ix4v3/f2qKHN3Co6QOlxb3gFvobKDdoKqUNiSH1zTZ8/Y/BzkM -jqWKhTdaLz6eyzhKfOTA4LO8kJ3VF8HUM1N9/e8Gjorl+gZpJUXUQS0+AIi8W76C -OwDrVb3BPGVnApQJfWF78h4g20RwXrx/GYUW2vOMcLjXXDV5U7+nobPUoJnLxoZC -16o88y0Ivan8dBNXsc1epyPvvEqp6MJbAyyVuNeuRJcgYA0BAoIBAQDUkGRV7fLG -wCr5rNysUO+FKzVtTJnf9KEsqAqUmmVnG4oubxAJJtiB5n2+DT+CtO8Nrtz05BbR -uxfWm+lbEw6lVMj63bywtp0NdULg7/2t+oq2Svv16KrZIRJttXMkdEiFFmkVAEhX -l8Fyl6PJPfSMwbPdXEUPUAaNrXweVFffXczHc4W2G212ZzDB0z7QQSgEntbTDFB/ -2Cg5dvuojlM9zw0fuEyLwItZs7n16j/ONZLgBHyroMU9ZPxbnLrVyoZlqtob+RWm -Ju2fSIL9QqG6O4td1TqcUBGvFQYjGvKA+q5fsG26NBJ0Ac48cNK6PS4lMkN3Av2J -ccloYaMEHAXdAoIBAE8WMCy1Ok6byUXiYxOL+OPmyoM40q/e7DcovE2AkLQhZ3Cr -fPDEucCphPFiexkV8f8fysgQeU0WgMmUH54UBPbD81LJyISKR3nkr875Ftdg8SV/ -HL0EblN9ifuR4U1bHCrJgoUFq2T09oVH7NR44Ju7bZIcIseNZK6qzcp2qGkycXD3 -gLWDX1hCxeV6+qLPFQKvuomEPRH4+jnVDXuFIaW6jPqixDP6BxXmqU2bFDJcmnBq -VkwGvc1F4qORdUP+yOi05VeJdZqEx1x92aTUXg+BgEQKnjbNxUE7o1L6hQfHjUIU -o5iEoagWkQTEXf2YBwY+EPaNBgNWxnSuAbfJHwECggEBALOF95ezTVWauzD/U6ic -+o3n/kl/Zn4FJ5KFodn7xCSe18d7uXlhO34KYqx+l+MWWMefpbGWacdcUjfImf93 -SulLgCqP12sP7/iLzp4XUpL7hOeM0NvRU2nqSpwpoUNqik0Mrlc0U+TWoGTduVCf -aMjwV65e3VyfY8mIeclLxqM5n1fcM1OoOnzDjiRE+0n7nYa5eAnq3pn6v4449TZY -belH03e0ucFWLtrltesBmj3YdWGJqJlzQOInRhNBfXJOh8+ZynfRmP0o54udPDQV -cG3PGFd5XPTjkuvhv7sqaSGRlm/um92lWOhtFfdp+i+cuDpmByCef+7zEP19aKZx -3GkCggEAFTs7KNMfvIEaLH0yQUFeq2gLmtcMofmOmeoIECycN1rG7iJo07lJLIs0 -bVODH8Z0kX8llu3cjGMAH/6R2uugJSxkmFiZKrngTzKmxDPvTCKWR4RFwXH9j8IO -cPq7FtKN4SgrPy9ciAPdkcGmu3zz/sBKOaoPwvU2PdBRT+v/aoz+GCLXAvzFlKVe -9/7zdg87ilo8+AtV+71EJeR3kyBPKS9JrWYUKfiams12+uuH4/53rMFZfNCAaZ3Z -1sdXEO4o3Loc5TX4DbO9FVdBSBe6klEXx4T0QJboO6uBvTBnnRL2SQriJQQFwYT6 -XzVV5pwOxkIDBWDIqMUfwJDChBKfpw== ------END PRIVATE KEY----- -"; - -pub fn dummy_tls_acceptor() -> Arc { - // Init server config builder with safe defaults - let config = ServerConfig::builder().with_no_client_auth(); - - // load TLS key/cert files - let cert_file = &mut BufReader::new(CERT.as_bytes()); - let key_file = &mut BufReader::new(PK.as_bytes()); - - // convert files to key/cert objects - let cert_chain = certs(cert_file).map(|r| r.unwrap()).collect(); - let mut keys: Vec = pkcs8_private_keys(key_file) - .map(|v| PrivateKeyDer::Pkcs8(v.unwrap())) - .collect(); - - // exit if no keys could be parsed - if keys.is_empty() { - panic!("Could not locate PKCS 8 private keys."); - } - - Arc::new(TlsAcceptor::from(Arc::new( - config.with_single_cert(cert_chain, keys.remove(0)).unwrap(), - ))) -} - -trait IntoTestPrincipal { - fn into_test(self) -> TestPrincipal; -} - -impl IntoTestPrincipal for PrincipalSet { - fn into_test(self) -> TestPrincipal { - TestPrincipal::from(self) - } -} - -impl IntoTestPrincipal for Principal { - fn into_test(self) -> TestPrincipal { - TestPrincipal::from(self) - } -} - -impl TestPrincipal { - pub fn into_sorted(mut self) -> Self { - self.member_of.sort_unstable(); - self.emails.sort_unstable(); - self - } -} - -impl From for TestPrincipal { - fn from(mut value: PrincipalSet) -> Self { - Self { - id: value.id(), - typ: value.typ(), - quota: value.quota(), - name: value.take_str(PrincipalField::Name).unwrap_or_default(), - secrets: value - .take_str_array(PrincipalField::Secrets) - .unwrap_or_default(), - emails: value - .take_str_array(PrincipalField::Emails) - .unwrap_or_default(), - member_of: value - .take_str_array(PrincipalField::MemberOf) - .unwrap_or_default(), - roles: value - .take_str_array(PrincipalField::Roles) - .unwrap_or_default(), - lists: value - .take_str_array(PrincipalField::Lists) - .unwrap_or_default(), - description: value.take_str(PrincipalField::Description), - } - } -} - -impl From for TestPrincipal { - fn from(value: Principal) -> Self { - Self { - id: value.id(), - typ: value.typ(), - quota: value.quota().unwrap_or_default(), - member_of: value.member_of().map(|v| v.to_string()).collect(), - roles: value.roles().map(|v| v.to_string()).collect(), - lists: value.lists().map(|v| v.to_string()).collect(), - secrets: value - .data - .iter() - .filter_map(|v| match v { - PrincipalData::Password(s) - | PrincipalData::AppPassword(s) - | PrincipalData::OtpAuth(s) => Some(s.to_string()), - _ => None, - }) - .collect(), - emails: value.email_addresses().map(|v| v.to_string()).collect(), - description: value.description().map(|v| v.to_string()), - name: value.name, - } - } -} - -impl From for PrincipalSet { - fn from(value: TestPrincipal) -> Self { - PrincipalSet::new(value.id, value.typ) - .with_field(PrincipalField::Name, value.name) - .with_field(PrincipalField::Quota, value.quota) - .with_field(PrincipalField::Secrets, value.secrets) - .with_field(PrincipalField::Emails, value.emails) - .with_field(PrincipalField::MemberOf, value.member_of) - .with_field(PrincipalField::Lists, value.lists) - .with_opt_field(PrincipalField::Description, value.description) - } -} - -#[derive(Clone, PartialEq, Eq, Hash)] -pub enum Item { - IsAccount(String), - Authenticate(Credentials), - Verify(String), - Expand(String), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum LookupResult { - True, - False, - Values(Vec), -} - -impl Item { - pub fn append(&self, append: usize) -> Self { - match self { - Item::IsAccount(str) => Item::IsAccount(format!("{append}{str}")), - Item::Authenticate(str) => Item::Authenticate(match str { - Credentials::Plain { username, secret } => Credentials::Plain { - username: username.to_string(), - secret: format!("{append}{secret}"), - }, - Credentials::OAuthBearer { token } => Credentials::OAuthBearer { - token: format!("{append}{token}"), - }, - Credentials::XOauth2 { username, secret } => Credentials::XOauth2 { - username: username.to_string(), - secret: format!("{append}{secret}"), - }, - }), - Item::Verify(str) => Item::Verify(format!("{append}{str}")), - Item::Expand(str) => Item::Expand(format!("{append}{str}")), - } - } - - pub fn as_credentials(&self) -> &Credentials { - match self { - Item::Authenticate(c) => c, - _ => panic!("Item is not a Credentials"), - } - } -} - -impl LookupResult { - fn append(&self, append: usize) -> Self { - match self { - LookupResult::True => LookupResult::True, - LookupResult::False => LookupResult::False, - LookupResult::Values(v) => { - let mut r = Vec::with_capacity(v.len()); - for (pos, val) in v.iter().enumerate() { - r.push(if pos == 0 { - format!("{append}{val}") - } else { - val.to_string() - }); - } - LookupResult::Values(r) - } - } - } -} - -impl From for LookupResult { - fn from(b: bool) -> Self { - if b { - LookupResult::True - } else { - LookupResult::False - } - } -} - -impl From> for LookupResult { - fn from(v: Vec) -> Self { - LookupResult::Values(v) - } -} - -impl core::fmt::Debug for Item { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::IsAccount(arg0) => f.debug_tuple("Rcpt").field(arg0).finish(), - Self::Authenticate(_) => f.debug_tuple("Auth").finish(), - Self::Expand(arg0) => f.debug_tuple("Expn").field(arg0).finish(), - Self::Verify(arg0) => f.debug_tuple("Vrfy").field(arg0).finish(), - } - } -} - -#[tokio::test] -async fn address_mappings() { - const MAPPINGS: &str = r#" - [enable] - catch-all = true - subaddressing = true - expected-sub = "john.doe@example.org" - expected-sub-nomatch = "jane@example.org" - expected-catch = "@example.org" - - [disable] - catch-all = false - subaddressing = false - expected-sub = "john.doe+alias@example.org" - expected-sub-nomatch = "jane@example.org" - expected-catch = false - - [custom] - catch-all = [{if = "matches('(.+)@(.+)$', address)", then = "'info@' + $2"}, {else = false}] - subaddressing = [{ if = "matches('^([^.]+)\\.([^.]+)@(.+)$', address)", then = "$2 + '@' + $3" }, {else = false}] - expected-sub = "doe+alias@example.org" - expected-sub-nomatch = "jane@example.org" - expected-catch = "info@example.org" - "#; - - let mut config = utils::config::Config::new(MAPPINGS).unwrap(); - const ADDR: &str = "john.doe+alias@example.org"; - const ADDR_NO_MATCH: &str = "jane@example.org"; - let core = Server::default(); - - for test in ["enable", "disable", "custom"] { - let catch_all = AddressMapping::parse(&mut config, (test, "catch-all")); - let subaddressing = AddressMapping::parse(&mut config, (test, "subaddressing")); - - assert_eq!( - subaddressing.to_subaddress(&core, ADDR, 0).await, - config.value_require((test, "expected-sub")).unwrap(), - "failed subaddress for {test:?}" - ); - - assert_eq!( - subaddressing.to_subaddress(&core, ADDR_NO_MATCH, 0).await, - config - .value_require((test, "expected-sub-nomatch")) - .unwrap(), - "failed subaddress no match for {test:?}" - ); - - assert_eq!( - catch_all.to_catch_all(&core, ADDR, 0).await, - config - .property_require::>((test, "expected-catch")) - .unwrap() - .map(Cow::Owned), - "failed catch-all for {test:?}" - ); - } -} - -async fn map_account_ids(store: &Store, names: Vec>) -> Vec { - let mut ids = Vec::with_capacity(names.len()); - for name in names { - ids.push(map_account_id(store, name).await); - } - ids -} - -async fn map_account_id(store: &Store, name: impl AsRef) -> u32 { - store - .get_principal_id(name.as_ref()) - .await - .unwrap() - .unwrap() -} +//pub mod sql; diff --git a/tests/src/directory/oidc.rs b/tests/src/directory/oidc.rs index 979700da..016046bd 100644 --- a/tests/src/directory/oidc.rs +++ b/tests/src/directory/oidc.rs @@ -8,127 +8,135 @@ * */ -use crate::{ - directory::DirectoryTest, - http_server::{HttpMessage, spawn_mock_http_server}, -}; -use base64::{Engine, engine::general_purpose}; -use http_proto::{JsonProblemResponse, JsonResponse, ToHttpResponse}; -use hyper::{Method, StatusCode}; -use mail_send::Credentials; -use serde_json::json; -use std::sync::Arc; -use trc::{AuthEvent, EventType}; - -static TEST_TOKEN: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ"; +use directory::{Account, Credentials, Directory, backend::oidc::OpenIdDirectory}; +use registry::{schema::structs, types::map::Map}; #[tokio::test] async fn oidc_directory() { - // Obtain directory handle - let mut config = DirectoryTest::new("rocksdb".into()).await; + let config = structs::OidcDirectory { + issuer_url: "http://localhost:9080/realms/stalwart".to_string(), + claim_username: "preferred_username".to_string(), + claim_name: Some("name".to_string()), + claim_groups: Some("groups".to_string()), + username_domain: None, + require_audience: Some("stalwart".to_string()), + require_scopes: Map::new(vec![ + "email".to_string(), + "profile".to_string(), + "openid".to_string(), + ]), + }; + let mut oidc = OpenIdDirectory::open(config.clone()).await.unwrap(); + let token = get_token("john.doe@example.org", "this is an OIDC password").await; - // Spawn mock OIDC server - let _tx = spawn_mock_http_server(Arc::new(|req: HttpMessage| { - let success_response = JsonResponse::new(json!({ - "email": "john@example.org", - "preferred_username": "jdoe", - "name": "John Doe", - })) - .into_http_response(); - - match (req.method.clone(), req.uri.path().split('/').nth(1)) { - (Method::GET, Some("userinfo")) => match req.headers.get("authorization") { - Some(auth) if auth == &format!("Bearer {TEST_TOKEN}") => success_response, - Some(_) => JsonProblemResponse(StatusCode::UNAUTHORIZED).into_http_response(), - None => panic!("Missing Authorization header: {req:#?}"), - }, - (Method::POST, Some("introspect-none")) => { - assert!(req.headers.get("authorization").is_none()); - if req.get_url_encoded("token").as_deref() == Some(TEST_TOKEN) { - success_response - } else { - JsonProblemResponse(StatusCode::UNAUTHORIZED).into_http_response() - } - } - (Method::POST, Some("introspect-user-token")) => match req.headers.get("authorization") - { - Some(auth) - if auth == &format!("Bearer {TEST_TOKEN}") - && req.get_url_encoded("token").as_deref() == Some(TEST_TOKEN) => - { - success_response - } - Some(_) => JsonProblemResponse(StatusCode::UNAUTHORIZED).into_http_response(), - None => panic!("Missing Authorization header: {req:#?}"), - }, - (Method::POST, Some("introspect-token")) => match req.headers.get("authorization") { - Some(auth) - if auth == "Bearer token_of_gratitude" - && req.get_url_encoded("token").as_deref() == Some(TEST_TOKEN) => - { - success_response - } - Some(_) => JsonProblemResponse(StatusCode::UNAUTHORIZED).into_http_response(), - None => panic!("Missing Authorization header: {req:#?}"), - }, - (Method::POST, Some("introspect-basic")) => match req.headers.get("authorization") { - Some(auth) - if auth - == &format!( - "Basic {}", - general_purpose::STANDARD.encode("myuser:mypass".as_bytes()) - ) - && req.get_url_encoded("token").as_deref() == Some(TEST_TOKEN) => - { - success_response - } - Some(_) => JsonProblemResponse(StatusCode::UNAUTHORIZED).into_http_response(), - None => panic!("Missing Authorization header: {req:#?}"), - }, - _ => panic!("Unexpected request: {:?}", req), + // Test the userinfo endpoint + assert_eq!( + oidc.authenticate(&Credentials::Bearer { + username: None, + token: format!(".{token}"), // Prefix with '.' to force userinfo in test mode + }) + .await + .unwrap(), + Account { + email: "john.doe@example.org".to_string(), + email_aliases: vec![], + secret: None, + groups: vec!["sales@example.org".to_string()], + description: Some("John Doe".to_string()) } - })) - .await; + ); - for test in [ - "oidc-userinfo", - "oidc-introspect-none", - "oidc-introspect-user-token", - "oidc-introspect-token", - "oidc-introspect-basic", - ] { - println!("Running OIDC test {test:?}..."); - let directory = config.directories.directories.remove(test).unwrap(); + // Make sure the userinfo endpoint is not being used + if let Directory::OpenId(directory) = &mut oidc { + directory.discovery.userinfo_endpoint = "http://invalid".to_string(); + } - // Test an invalid token - let err = directory - .query( - QueryParams::credentials(&Credentials::OAuthBearer { - token: "invalid_or_expired_token".to_string(), - }) - .with_return_member_of(false), - ) - .await - .unwrap_err(); - assert!( - err.matches(EventType::Auth(AuthEvent::Failed)), - "Unexpected error: {:?}", - err - ); + // JWT authentication should still work without the userinfo endpoint + assert_eq!( + oidc.authenticate(&Credentials::Bearer { + username: None, + token: token.clone(), + }) + .await + .unwrap(), + Account { + email: "john.doe@example.org".to_string(), + email_aliases: vec![], + secret: None, + groups: vec!["sales@example.org".to_string()], + description: Some("John Doe".to_string()) + } + ); - // Test a valid token - let principal = directory - .query( - QueryParams::credentials(&Credentials::OAuthBearer { - token: TEST_TOKEN.to_string(), - }) - .with_return_member_of(false), - ) + // Not matching the required audience should fail + let mut config_wrong_audience = config.clone(); + config_wrong_audience.require_audience = Some("wrong_audience".to_string()); + assert!( + OpenIdDirectory::open(config_wrong_audience) .await .unwrap() - .unwrap(); - assert_eq!(principal.name(), "jdoe"); - assert_eq!(principal.email_addresses().next(), Some("john@example.org")); - assert_eq!(principal.description(), Some("John Doe")); - } + .authenticate(&Credentials::Bearer { + username: None, + token: token.clone(), + }) + .await + .is_err() + ); + + // Not having the required scopes should fail + let mut config_wrong_scopes = config.clone(); + config_wrong_scopes.require_scopes = Map::new(vec![ + "email".to_string(), + "profile".to_string(), + "openid".to_string(), + "missing_scope".to_string(), + ]); + assert!( + OpenIdDirectory::open(config_wrong_scopes) + .await + .unwrap() + .authenticate(&Credentials::Bearer { + username: None, + token, + }) + .await + .is_err() + ); + + // Test authorization endpoint retrieval + assert_eq!( + oidc.oidc_authorization_endpoint(), + Some("http://localhost:9080/realms/stalwart/protocol/openid-connect/auth".to_string()) + ); +} + +async fn get_token(username: &str, password: &str) -> String { + let client = reqwest::Client::new(); + + let response = client + .post("http://localhost:9080/realms/stalwart/protocol/openid-connect/token") + .form(&[ + ("grant_type", "password"), + ("client_id", "stalwart"), + ("client_secret", "stalwart-secret"), + ("username", username), + ("password", password), + ("scope", "openid email profile"), + ]) + .send() + .await + .expect("Failed to send token request"); + + let body = response + .text() + .await + .expect("Failed to read token response"); + + let json: serde_json::Value = + serde_json::from_str(&body).expect("Failed to parse token response"); + + json["access_token"] + .as_str() + .expect("No access_token in response") + .to_string() } diff --git a/tests/src/lib.rs b/tests/src/lib.rs index 130460f7..974d2f4a 100644 --- a/tests/src/lib.rs +++ b/tests/src/lib.rs @@ -16,9 +16,9 @@ static GLOBAL: Jemalloc = Jemalloc; /* #[cfg(test)] pub mod cluster; +*/ #[cfg(test)] pub mod directory; -*/ #[cfg(test)] pub mod imap; #[cfg(test)] @@ -45,6 +45,7 @@ pub trait AssertConfig { #[cfg(test)] impl AssertConfig for Bootstrap { fn assert_no_errors(self) -> Self { + let todo = "cluster tests"; if !self.errors.is_empty() { panic!("Errors: {:#?}", self.errors); } diff --git a/tests/src/smtp/inbound/antispam.rs b/tests/src/smtp/inbound/antispam.rs index ae9443ef..c12fe6c7 100644 --- a/tests/src/smtp/inbound/antispam.rs +++ b/tests/src/smtp/inbound/antispam.rs @@ -28,13 +28,15 @@ use mail_parser::MessageParser; use registry::{ schema::{ enums::{AiModelType, TaskSpamFilterMaintenanceType}, + prelude::{ObjectType, Property}, structs::{ - self, AiModel, MemoryLookupKey, SpamLlm, SpamLlmProperties, SpamSettings, - SpamTrainingSample, Task, TaskSpamFilterMaintenance, TaskStatus, + self, AiModel, MemoryLookupKey, SpamLlm, SpamLlmProperties, SpamSettings, Task, + TaskSpamFilterMaintenance, TaskStatus, }, }, types::{float::Float, map::Map}, }; +use serde_json::json; use smtp::core::SessionAddress; use smtp_proto::{MAIL_BODY_8BITMIME, MAIL_SMTPUTF8}; use spam_filter::{ @@ -63,118 +65,6 @@ use std::{ time::{Duration, Instant}, }; -const CONFIG: &str = r#" -[spam-filter.score] -spam = "5.0" - -[spam-filter.llm] -enable = true -model = "dummy" -prompt = "You are an AI assistant specialized in analyzing email content to detect unsolicited, commercial, or harmful messages. Format your response as follows, separated by commas: Category,Confidence,Explanation -Here's the email to analyze, please provide your analysis based on the above instructions, ensuring your response is in the specified comma-separated format." -separator = "," -categories = ["Unsolicited", "Commercial", "Harmful", "Legitimate"] -confidence = ["High", "Medium", "Low"] - -[spam-filter.llm.index] -category = 0 -confidence = 1 -explanation = 2 - -[spam-filter.classifier.samples] -min-ham = 10 -min-spam = 10 - -[session.rcpt] -relay = true - -[storage] -data = "spamdb" -lookup = "spamdb" -blob = "spamdb" -fts = "spamdb" -directory = "spamdb" - -[directory."spamdb"] -type = "internal" -store = "spamdb" - -[store."spamdb"] -type = "rocksdb" -path = "{PATH}/test_antispam.db" - -#[store."redis"] -#type = "redis" -#url = "redis://127.0.0.1" - -[http-lookup.STWT_OPENPHISH] -enable = true -url = "https://openphish.com/feed.txt" -format = "list" -retry = "1h" -refresh = "12h" -timeout = "30s" -limits.size = 104857600 -limits.entries = 900000 -limits.entry-size = 512 - -[http-lookup.STWT_PHISHTANK] -enable = true -url = "http://data.phishtank.com/data/online-valid.csv.gz" -format = "csv" -separator = "," -index.key = 1 -skip-first = true -gzipped = true -retry = "1h" -refresh = "6h" -timeout = "30s" -limits.size = 104857600 -limits.entries = 900000 -limits.entry-size = 512 - -[http-lookup.STWT_DISPOSABLE_DOMAINS] -enable = true -url = "https://disposable.github.io/disposable-email-domains/domains_mx.txt" -format = "list" -retry = "1h" -refresh = "24h" -timeout = "30s" -limits.size = 104857600 -limits.entries = 900000 -limits.entry-size = 512 - -[http-lookup.STWT_FREE_DOMAINS] -enable = true -url = "https://gist.githubusercontent.com/okutbay/5b4974b70673dfdcc21c517632c1f984/raw/993a35930a8d24a1faab1b988d19d38d92afbba4/free_email_provider_domains.txt" -format = "list" -retry = "1h" -refresh = "720h" -timeout = "30s" -limits.size = 104857600 -limits.entries = 900000 -limits.entry-size = 512 - -[enterprise.ai.dummy] -url = "https://127.0.0.1:9090/v1/chat/completions" -type = "chat" -model = "gpt-dummy" -allow-invalid-certs = true - -[spam-filter.list] -"file-extensions" = { "html" = "text/html|BAD", - "pdf" = "application/pdf|NZ", - "txt" = "text/plain|message/disposition-notification|text/rfc822-headers", - "zip" = "AR", - "js" = "BAD|NZ", - "hta" = "BAD|NZ" } -[lookup] -"url-redirectors" = {"bit.ly", "redirect.io", "redirect.me", "redirect.org", "redirect.com", "redirect.net", "t.ly", "tinyurl.com"} -"spam-traps" = {"spamtrap@*"} -"trusted-domains" = {"stalw.art"} -"surbl-hashbl" = {"bit.ly", "drive.google.com", "lnkiy.in"} -"#; - #[tokio::test(flavor = "multi_thread")] async fn antispam() { let mut test = TestServerBuilder::new("smtp_antispam_test") @@ -264,6 +154,7 @@ async fn antispam() { .await; test.wait_for_tasks().await; admin.reload_settings().await; + admin.reload_lookup_stores().await; test.reload_core(); let admin = test.account("admin"); @@ -296,7 +187,7 @@ async fn antispam() { "127.0.0.64", ), ( - "637d6717761b5de0c84108c894bb68f2.hashbl.surbl.org", + "ef6f530a68b77d782983e8712ff31fe5.hashbl.surbl.org", "127.0.0.8", ), ] { @@ -425,16 +316,16 @@ async fn antispam() { .put_jmap_blob(u32::MAX, sample.as_bytes()) .await .unwrap(); - admin - .registry_create_object(SpamTrainingSample { - blob_id, - from: "unknown".to_string(), - is_spam: class == "spam", - subject: "unknown".to_string(), - ..Default::default() - }) - .await; + .registry_create_many( + ObjectType::SpamTrainingSample, + [json!({ + Property::BlobId: blob_id, + Property::IsSpam: class == "spam", + })], + ) + .await + .created_id(0); } } admin