From 1d9ac04ecee027d09c1aacc16a5b0f93c24a066e Mon Sep 17 00:00:00 2001 From: mdecimus Date: Wed, 2 Oct 2024 11:13:10 +0200 Subject: [PATCH] OIDC diretory --- Cargo.lock | 30 +- crates/cli/Cargo.toml | 2 +- crates/common/Cargo.toml | 2 +- crates/common/src/auth/mod.rs | 38 ++- crates/common/src/config/mod.rs | 11 +- crates/common/src/listener/blocked.rs | 14 +- crates/directory/Cargo.toml | 5 +- .../directory/src/backend/internal/manage.rs | 87 +++--- crates/directory/src/backend/ldap/lookup.rs | 2 +- crates/directory/src/backend/mod.rs | 2 + crates/directory/src/backend/oidc/config.rs | 80 ++++++ crates/directory/src/backend/oidc/lookup.rs | 219 +++++++++++++++ crates/directory/src/backend/oidc/mod.rs | 43 +++ crates/directory/src/backend/sql/lookup.rs | 2 +- crates/directory/src/core/config.rs | 29 +- crates/directory/src/core/dispatch.rs | 40 +++ crates/directory/src/lib.rs | 2 + crates/imap/Cargo.toml | 2 +- crates/jmap/Cargo.toml | 2 +- crates/jmap/src/api/http.rs | 29 +- crates/jmap/src/api/management/principal.rs | 2 + crates/jmap/src/api/mod.rs | 1 + crates/jmap/src/auth/oauth/auth.rs | 6 +- crates/jmap/src/auth/oauth/openid.rs | 1 + crates/jmap/src/auth/oauth/registration.rs | 1 + crates/jmap/src/auth/oauth/token.rs | 2 +- crates/main/Cargo.toml | 2 +- crates/managesieve/Cargo.toml | 2 +- crates/nlp/Cargo.toml | 2 +- crates/pop3/Cargo.toml | 2 +- crates/smtp/Cargo.toml | 4 +- crates/store/Cargo.toml | 2 +- crates/trc/Cargo.toml | 2 +- crates/utils/Cargo.toml | 2 +- tests/Cargo.toml | 1 + tests/src/directory/internal.rs | 13 +- tests/src/directory/mod.rs | 66 +++++ tests/src/directory/oidc.rs | 263 ++++++++++++++++++ tests/src/directory/sql.rs | 1 + tests/src/jmap/mod.rs | 8 +- 40 files changed, 915 insertions(+), 109 deletions(-) create mode 100644 crates/directory/src/backend/oidc/config.rs create mode 100644 crates/directory/src/backend/oidc/lookup.rs create mode 100644 crates/directory/src/backend/oidc/mod.rs create mode 100644 tests/src/directory/oidc.rs diff --git a/Cargo.lock b/Cargo.lock index 3d84cb1bd..8d3b5b76e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1052,7 +1052,7 @@ dependencies = [ [[package]] name = "common" -version = "0.10.1" +version = "0.10.2" dependencies = [ "aes-gcm-siv", "ahash 0.8.11", @@ -1668,11 +1668,12 @@ dependencies = [ [[package]] name = "directory" -version = "0.10.1" +version = "0.10.2" dependencies = [ "ahash 0.8.11", "argon2", "async-trait", + "base64 0.22.1", "deadpool 0.10.0", "futures", "jmap_proto", @@ -1688,10 +1689,12 @@ dependencies = [ "proc_macros", "pwhash", "regex", + "reqwest 0.12.7", "rustls 0.23.13", "rustls-pki-types", "scrypt", "serde", + "serde_json", "sha1", "sha2 0.10.8", "smtp-proto", @@ -3001,7 +3004,7 @@ checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" [[package]] name = "imap" -version = "0.10.1" +version = "0.10.2" dependencies = [ "ahash 0.8.11", "common", @@ -3213,7 +3216,7 @@ dependencies = [ [[package]] name = "jmap" -version = "0.10.1" +version = "0.10.2" dependencies = [ "aes", "aes-gcm", @@ -3651,7 +3654,7 @@ dependencies = [ [[package]] name = "mail-server" -version = "0.10.1" +version = "0.10.2" dependencies = [ "common", "directory", @@ -3670,7 +3673,7 @@ dependencies = [ [[package]] name = "managesieve" -version = "0.10.1" +version = "0.10.2" dependencies = [ "ahash 0.8.11", "bincode", @@ -3948,7 +3951,7 @@ dependencies = [ [[package]] name = "nlp" -version = "0.10.1" +version = "0.10.2" dependencies = [ "ahash 0.8.11", "bincode", @@ -4499,7 +4502,7 @@ dependencies = [ [[package]] name = "pop3" -version = "0.10.1" +version = "0.10.2" dependencies = [ "common", "directory", @@ -6070,7 +6073,7 @@ checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] name = "smtp" -version = "0.10.1" +version = "0.10.2" dependencies = [ "ahash 0.8.11", "bincode", @@ -6186,7 +6189,7 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" [[package]] name = "stalwart-cli" -version = "0.10.1" +version = "0.10.2" dependencies = [ "clap", "console", @@ -6217,7 +6220,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "store" -version = "0.10.1" +version = "0.10.2" dependencies = [ "ahash 0.8.11", "arc-swap", @@ -6448,6 +6451,7 @@ dependencies = [ "directory", "ece", "flate2", + "form_urlencoded", "futures", "http-body-util", "hyper 1.4.1", @@ -6860,7 +6864,7 @@ dependencies = [ [[package]] name = "trc" -version = "0.10.1" +version = "0.10.2" dependencies = [ "ahash 0.8.11", "base64 0.22.1", @@ -7103,7 +7107,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "utils" -version = "0.10.1" +version = "0.10.2" dependencies = [ "ahash 0.8.11", "base64 0.22.1", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 97b66b8d4..58264fd20 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Stalwart Labs Ltd. "] license = "AGPL-3.0-only OR LicenseRef-SEL" repository = "https://github.com/stalwartlabs/cli" homepage = "https://github.com/stalwartlabs/cli" -version = "0.10.1" +version = "0.10.2" edition = "2021" readme = "README.md" resolver = "2" diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index ed0f3d21a..e25a57c8e 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "common" -version = "0.10.1" +version = "0.10.2" edition = "2021" resolver = "2" diff --git a/crates/common/src/auth/mod.rs b/crates/common/src/auth/mod.rs index 7b74cff84..7de1b8845 100644 --- a/crates/common/src/auth/mod.rs +++ b/crates/common/src/auth/mod.rs @@ -57,9 +57,12 @@ pub struct AuthRequest<'x> { impl Server { pub async fn authenticate(&self, req: &AuthRequest<'_>) -> trc::Result> { + // Resolve directory + let directory = req.directory.unwrap_or(&self.core.storage.directory); + // Validate credentials match &req.credentials { - Credentials::OAuthBearer { token } => { + Credentials::OAuthBearer { token } if !directory.has_bearer_token_support() => { match self .validate_access_token(GrantType::AccessToken.into(), token) .await @@ -68,7 +71,7 @@ impl Server { Err(err) => Err(err), } } - _ => match self.authenticate_plain(req).await { + _ => match self.authenticate_credentials(req, directory).await { Ok(principal) => { if let Some(access_token) = self.inner.data.access_tokens.get_with_ttl(&principal.id()) @@ -94,9 +97,11 @@ impl Server { }) } - async fn authenticate_plain(&self, req: &AuthRequest<'_>) -> trc::Result { - let directory = req.directory.unwrap_or(&self.core.storage.directory); - + async fn authenticate_credentials( + &self, + req: &AuthRequest<'_>, + directory: &Directory, + ) -> trc::Result { // First try to authenticate the user against the default directory let result = match directory .query(QueryBy::Credentials(&req.credentials), req.return_member_of) @@ -105,10 +110,9 @@ impl Server { Ok(Some(principal)) => { trc::event!( Auth(trc::AuthEvent::Success), - AccountName = req.credentials.login().to_string(), + AccountName = principal.name().to_string(), AccountId = principal.id(), SpanId = req.session_id, - Type = principal.typ().as_str(), ); return Ok(principal); @@ -176,16 +180,19 @@ impl Server { Err(trc::SecurityEvent::AuthenticationBan .into_err() .ctx(trc::Key::RemoteIp, req.remote_ip) - .ctx(trc::Key::AccountName, login.to_string())) + .ctx_opt(trc::Key::AccountName, login.map(|s| s.to_string()))) } else { Err(trc::AuthEvent::Failed .ctx(trc::Key::RemoteIp, req.remote_ip) - .ctx(trc::Key::AccountName, login.to_string())) + .ctx_opt(trc::Key::AccountName, login.map(|s| s.to_string()))) } } else { Err(trc::AuthEvent::Failed .ctx(trc::Key::RemoteIp, req.remote_ip) - .ctx(trc::Key::AccountName, req.credentials.login().to_string())) + .ctx_opt( + trc::Key::AccountName, + req.credentials.login().map(|s| s.to_string()), + )) } } @@ -241,15 +248,16 @@ impl<'x> AuthRequest<'x> { } pub(crate) trait CredentialsUsername { - fn login(&self) -> &str; + fn login(&self) -> Option<&str>; } impl CredentialsUsername for Credentials { - fn login(&self) -> &str { + fn login(&self) -> Option<&str> { match self { - Credentials::Plain { username, .. } - | Credentials::XOauth2 { username, .. } - | Credentials::OAuthBearer { token: username } => username, + Credentials::Plain { username, .. } | Credentials::XOauth2 { username, .. } => { + username.as_str().into() + } + Credentials::OAuthBearer { .. } => None, } } } diff --git a/crates/common/src/config/mod.rs b/crates/common/src/config/mod.rs index 443f5253c..1bea639aa 100644 --- a/crates/common/src/config/mod.rs +++ b/crates/common/src/config/mod.rs @@ -62,6 +62,9 @@ impl Core { }) .unwrap_or_default(); + #[cfg(not(feature = "enterprise"))] + let is_enterprise = false; + // SPDX-SnippetBegin // SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd // SPDX-License-Identifier: LicenseRef-SEL @@ -69,7 +72,10 @@ impl Core { let enterprise = crate::enterprise::Enterprise::parse(config, &stores, &data).await; #[cfg(feature = "enterprise")] - if enterprise.is_none() { + let is_enterprise = enterprise.is_some(); + + #[cfg(feature = "enterprise")] + if is_enterprise { if data.is_enterprise_store() { config .new_build_error("storage.data", "SQL read replicas is an Enterprise feature"); @@ -121,7 +127,8 @@ impl Core { } }) .unwrap_or_default(); - let mut directories = Directories::parse(config, &stores, data.clone()).await; + let mut directories = + Directories::parse(config, &stores, data.clone(), is_enterprise).await; let directory = config .value_require("storage.directory") .map(|id| id.to_string()) diff --git a/crates/common/src/listener/blocked.rs b/crates/common/src/listener/blocked.rs index 20de19511..ffb57b3b6 100644 --- a/crates/common/src/listener/blocked.rs +++ b/crates/common/src/listener/blocked.rs @@ -125,19 +125,21 @@ impl Server { Ok(false) } - pub async fn is_auth_fail2banned(&self, ip: IpAddr, login: &str) -> trc::Result { + pub async fn is_auth_fail2banned(&self, ip: IpAddr, login: Option<&str>) -> trc::Result { if let Some(rate) = &self.core.network.security.auth_fail_rate { + let login = login.unwrap_or_default(); let is_allowed = self.is_ip_allowed(&ip) || (self .lookup_store() .is_rate_allowed(format!("b:{ip}").as_bytes(), rate, false) .await? .is_none() - && self - .lookup_store() - .is_rate_allowed(format!("b:{login}").as_bytes(), rate, false) - .await? - .is_none()); + && (login.is_empty() + || self + .lookup_store() + .is_rate_allowed(format!("b:{login}").as_bytes(), rate, false) + .await? + .is_none())); if !is_allowed { return self.block_ip(ip).await.map(|_| true); } diff --git a/crates/directory/Cargo.toml b/crates/directory/Cargo.toml index 1b3e26ba5..27b36e33f 100644 --- a/crates/directory/Cargo.toml +++ b/crates/directory/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "directory" -version = "0.10.1" +version = "0.10.2" edition = "2021" resolver = "2" @@ -36,6 +36,9 @@ 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"] } +serde_json = "1.0" +base64 = "0.22" [dev-dependencies] tokio = { version = "1.23", features = ["full"] } diff --git a/crates/directory/src/backend/internal/manage.rs b/crates/directory/src/backend/internal/manage.rs index eecbef8b3..2fe3adb28 100644 --- a/crates/directory/src/backend/internal/manage.rs +++ b/crates/directory/src/backend/internal/manage.rs @@ -39,7 +39,7 @@ pub struct UpdatePrincipal<'x> { query: QueryBy<'x>, changes: Vec, tenant_id: Option, - validate: bool, + create_domains: bool, } #[allow(async_fn_in_trait)] @@ -79,6 +79,16 @@ pub trait ManageDirectory: Sized { ) -> trc::Result<()>; } +#[allow(async_fn_in_trait)] +trait ValidateDirectory: Sized { + async fn validate_email( + &self, + email: &str, + tenant_id: Option, + create_if_missing: bool, + ) -> trc::Result<()>; +} + impl ManageDirectory for Store { async fn get_principal(&self, principal_id: u32) -> trc::Result> { self.get_value::(ValueKey::from(ValueClass::Directory( @@ -690,7 +700,7 @@ impl ManageDirectory for Store { .caused_by(trc::location!())? .ok_or_else(|| not_found(principal_id))?; principal.inner.id = principal_id; - let validate_emails = params.validate && principal.inner.typ != Type::OauthClient; + let validate_emails = principal.inner.typ != Type::OauthClient; // Obtain members and memberOf let mut member_of = self @@ -989,21 +999,8 @@ impl ManageDirectory for Store { for email in &emails { if !principal.inner.has_str_value(PrincipalField::Emails, email) { if validate_emails { - if self.rcpt(email).await.caused_by(trc::location!())? { - return Err(err_exists( - PrincipalField::Emails, - email.to_string(), - )); - } - if let Some(domain) = email.split('@').nth(1) { - if !self - .is_local_domain(domain) - .await - .caused_by(trc::location!())? - { - return Err(not_found(domain.to_string())); - } - } + self.validate_email(email, tenant_id, params.create_domains) + .await?; } batch.set( ValueClass::Directory(DirectoryClass::EmailToId( @@ -1035,18 +1032,8 @@ impl ManageDirectory for Store { .has_str_value(PrincipalField::Emails, &email) { if validate_emails { - if self.rcpt(&email).await.caused_by(trc::location!())? { - return Err(err_exists(PrincipalField::Emails, email)); - } - if let Some(domain) = email.split('@').nth(1) { - if !self - .is_local_domain(domain) - .await - .caused_by(trc::location!())? - { - return Err(not_found(domain.to_string())); - } - } + self.validate_email(&email, tenant_id, params.create_domains) + .await?; } batch.set( ValueClass::Directory(DirectoryClass::EmailToId( @@ -1801,6 +1788,40 @@ impl ManageDirectory for Store { } } +impl ValidateDirectory for Store { + async fn validate_email( + &self, + email: &str, + tenant_id: Option, + create_if_missing: bool, + ) -> trc::Result<()> { + if self.rcpt(email).await.caused_by(trc::location!())? { + Err(err_exists(PrincipalField::Emails, email.to_string())) + } else if let Some(domain) = email.split('@').nth(1) { + match self + .get_principal_info(domain) + .await + .caused_by(trc::location!())? + { + Some(v) if v.typ == Type::Domain && v.has_tenant_access(tenant_id) => Ok(()), + None if create_if_missing => self + .create_principal( + Principal::new(0, Type::Domain) + .with_field(PrincipalField::Name, domain.to_string()) + .with_field(PrincipalField::Description, domain.to_string()), + tenant_id, + ) + .await + .caused_by(trc::location!()) + .map(|_| ()), + _ => Err(not_found(domain.to_string())), + } + } else { + Err(error("Invalid email", "Email address is invalid".into())) + } + } +} + impl PrincipalField { pub fn map_internal_role_name(&self, name: &str) -> Option { match (self, name) { @@ -1836,7 +1857,7 @@ impl<'x> UpdatePrincipal<'x> { Self { query: QueryBy::Id(id), changes: Vec::new(), - validate: true, + create_domains: false, tenant_id: None, } } @@ -1845,7 +1866,7 @@ impl<'x> UpdatePrincipal<'x> { Self { query: QueryBy::Name(name), changes: Vec::new(), - validate: true, + create_domains: false, tenant_id: None, } } @@ -1860,8 +1881,8 @@ impl<'x> UpdatePrincipal<'x> { self } - pub fn no_validate(mut self) -> Self { - self.validate = false; + pub fn create_domains(mut self) -> Self { + self.create_domains = true; self } } diff --git a/crates/directory/src/backend/ldap/lookup.rs b/crates/directory/src/backend/ldap/lookup.rs index f238c7b20..09813b747 100644 --- a/crates/directory/src/backend/ldap/lookup.rs +++ b/crates/directory/src/backend/ldap/lookup.rs @@ -200,7 +200,7 @@ impl LdapDirectory { .update_principal( UpdatePrincipal::by_id(principal.id) .with_updates(changes) - .no_validate(), + .create_domains(), ) .await .caused_by(trc::location!())?; diff --git a/crates/directory/src/backend/mod.rs b/crates/directory/src/backend/mod.rs index 7355355f8..d94eb44c2 100644 --- a/crates/directory/src/backend/mod.rs +++ b/crates/directory/src/backend/mod.rs @@ -8,5 +8,7 @@ pub mod imap; pub mod internal; pub mod ldap; pub mod memory; +#[cfg(feature = "enterprise")] +pub mod oidc; pub mod smtp; pub mod sql; diff --git a/crates/directory/src/backend/oidc/config.rs b/crates/directory/src/backend/oidc/config.rs new file mode 100644 index 000000000..28262cdae --- /dev/null +++ b/crates/directory/src/backend/oidc/config.rs @@ -0,0 +1,80 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: LicenseRef-SEL + * + * This file is subject to the Stalwart Enterprise License Agreement (SEL) and + * is NOT open source software. + * + */ + +use std::time::Duration; + +use base64::{engine::general_purpose, Engine}; +use store::Store; +use utils::config::{utils::AsKey, Config}; + +use super::{Authentication, EndpointType, OpenIdConfig, OpenIdDirectory}; + +impl OpenIdDirectory { + pub fn from_config(config: &mut Config, prefix: impl AsKey, data_store: Store) -> Option { + let prefix = prefix.as_key(); + let endpoint_type = match config.value_require((&prefix, "endpoint.method"))? { + "introspect" => match config.value_require((&prefix, "auth.method"))? { + #[allow(clippy::to_string_in_format_args)] + "basic" => EndpointType::Introspect(Authentication::Header(format!( + "Basic {}", + general_purpose::STANDARD.encode( + format!( + "{}:{}", + config + .value_require((&prefix, "auth.username"))? + .to_string(), + config.value_require((&prefix, "auth.secret"))? + ) + .as_bytes() + ) + ))), + "token" => EndpointType::Introspect(Authentication::Header(format!( + "Bearer {}", + config.value_require((&prefix, "auth.token"))? + ))), + "user-token" => EndpointType::Introspect(Authentication::Bearer), + "none" => EndpointType::Introspect(Authentication::None), + _ => { + config.new_build_error( + (&prefix, "auth.method"), + "Invalid authentication method, must be 'header', 'bearer' or 'none'", + ); + return None; + } + }, + "userinfo" => EndpointType::UserInfo, + _ => { + config.new_build_error( + (&prefix, "endpoint.method"), + "Invalid endpoint method, must be 'introspect' or 'userinfo'", + ); + return None; + } + }; + + Some(OpenIdDirectory { + config: OpenIdConfig { + endpoint: config.value_require((&prefix, "endpoint.url"))?.to_string(), + endpoint_type, + endpoint_timeout: config + .property_or_default::((&prefix, "timeout"), "30s") + .unwrap_or_else(|| Duration::from_secs(30)), + email_field: config.value_require((&prefix, "fields.email"))?.to_string(), + username_field: config + .value((&prefix, "fields.username")) + .map(|v| v.to_string()), + full_name_field: config + .value((&prefix, "fields.full-name")) + .map(|v| v.to_string()), + }, + data_store, + }) + } +} diff --git a/crates/directory/src/backend/oidc/lookup.rs b/crates/directory/src/backend/oidc/lookup.rs new file mode 100644 index 000000000..de3119cd9 --- /dev/null +++ b/crates/directory/src/backend/oidc/lookup.rs @@ -0,0 +1,219 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: LicenseRef-SEL + * + * This file is subject to the Stalwart Enterprise License Agreement (SEL) and + * is NOT open source software. + * + */ + +use ahash::HashMap; +use mail_send::Credentials; +use reqwest::{header::AUTHORIZATION, StatusCode}; +use trc::{AddContext, AuthEvent}; + +use crate::{ + backend::{ + internal::{ + lookup::DirectoryStore, + manage::{self, ManageDirectory, UpdatePrincipal}, + PrincipalField, + }, + oidc::{Authentication, EndpointType}, + }, + Principal, QueryBy, Type, ROLE_USER, +}; + +use super::{OpenIdConfig, OpenIdDirectory}; + +type OpenIdResponse = HashMap; + +impl OpenIdDirectory { + pub async fn query( + &self, + by: QueryBy<'_>, + return_member_of: bool, + ) -> trc::Result> { + match &by { + QueryBy::Credentials(Credentials::OAuthBearer { token }) => { + // Send request + #[cfg(feature = "test_mode")] + let client = reqwest::Client::builder().danger_accept_invalid_certs(true); + + #[cfg(not(feature = "test_mode"))] + let client = reqwest::Client::builder(); + + let client = client + .timeout(self.config.endpoint_timeout) + .build() + .map_err(|err| { + AuthEvent::Error + .into_err() + .reason(err) + .details("Failed to build client") + })?; + + let client = match &self.config.endpoint_type { + EndpointType::UserInfo => client.get(&self.config.endpoint).bearer_auth(token), + EndpointType::Introspect(authentication) => { + let client = client.post(&self.config.endpoint).form(&[ + ("token", token.as_str()), + ("token_type_hint", "access_token"), + ]); + match authentication { + Authentication::Header(header) => client.header(AUTHORIZATION, header), + Authentication::Bearer => client.bearer_auth(token), + Authentication::None => client, + } + } + }; + + let response = client.send().await.map_err(|err| { + AuthEvent::Error + .into_err() + .reason(err) + .details("HTTP request failed") + })?; + + 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") + })?; + + // Deserialize response + let external_principal = + serde_json::from_slice::(&response) + .map_err(|err| { + AuthEvent::Error + .into_err() + .reason(err) + .details("Failed to deserialize OIDC response") + })? + .build_principal(&self.config)?; + + // Fetch principal + let id = self + .data_store + .get_or_create_principal_id(external_principal.name(), Type::Individual) + .await + .caused_by(trc::location!())?; + let mut principal = self + .data_store + .query(QueryBy::Id(id), return_member_of) + .await + .caused_by(trc::location!())? + .ok_or_else(|| manage::not_found(id).caused_by(trc::location!()))?; + + // Keep the internal store up to date with the OIDC server + let changes = principal.update_external(external_principal); + if !changes.is_empty() { + self.data_store + .update_principal( + UpdatePrincipal::by_id(principal.id) + .with_updates(changes) + .create_domains(), + ) + .await + .caused_by(trc::location!())?; + } + + Ok(Some(principal)) + } + 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")), + } + } + _ => self.data_store.query(by, return_member_of).await, + } + } + + pub async fn email_to_ids(&self, address: &str) -> trc::Result> { + self.data_store.email_to_ids(address).await + } + + pub async fn rcpt(&self, address: &str) -> trc::Result { + self.data_store.rcpt(address).await + } + + pub async fn vrfy(&self, address: &str) -> trc::Result> { + self.data_store.vrfy(address).await + } + + pub async fn expn(&self, address: &str) -> trc::Result> { + self.data_store.expn(address).await + } + + pub async fn is_local_domain(&self, domain: &str) -> trc::Result { + self.data_store.is_local_domain(domain).await + } +} + +trait BuildPrincipal { + fn build_principal(&mut self, config: &OpenIdConfig) -> trc::Result; + fn take_required_field(&mut self, field: &str) -> trc::Result; + fn take_field(&mut self, field: &str) -> Option; +} + +impl BuildPrincipal for OpenIdResponse { + fn build_principal(&mut self, config: &OpenIdConfig) -> trc::Result { + let email = self + .take_required_field(&config.email_field)? + .to_lowercase(); + let username = if let Some(username_field) = &config.username_field { + self.take_required_field(username_field)?.to_lowercase() + } else { + email.clone() + }; + if !email.contains('@') && !email.contains('.') { + return Err(AuthEvent::Error + .into_err() + .details("Email field is not valid") + .ctx(trc::Key::Key, email)); + } + let full_name = config + .full_name_field + .as_ref() + .and_then(|field| self.take_field(field)); + + Ok(Principal::new(u32::MAX, Type::Individual) + .with_field(PrincipalField::Name, username) + .with_field(PrincipalField::Emails, email) + .with_field(PrincipalField::Roles, ROLE_USER) + .with_opt_field(PrincipalField::Description, full_name)) + } + + fn take_required_field(&mut self, field: &str) -> trc::Result { + match self.remove(field) { + Some(serde_json::Value::String(value)) if !value.is_empty() => Ok(value), + other => Err(trc::AuthEvent::Error + .into_err() + .details("Unexpected field type in OIDC response") + .ctx(trc::Key::Key, field.to_string()) + .ctx( + trc::Key::Value, + serde_json::to_string(&other.unwrap_or(serde_json::Value::Null)) + .unwrap_or_default(), + )), + } + } + + fn take_field(&mut self, field: &str) -> Option { + match self.remove(field) { + Some(serde_json::Value::String(value)) if !value.is_empty() => Some(value), + _ => None, + } + } +} diff --git a/crates/directory/src/backend/oidc/mod.rs b/crates/directory/src/backend/oidc/mod.rs new file mode 100644 index 000000000..e519815cd --- /dev/null +++ b/crates/directory/src/backend/oidc/mod.rs @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: LicenseRef-SEL + * + * This file is subject to the Stalwart Enterprise License Agreement (SEL) and + * is NOT open source software. + * + */ + +pub mod config; +pub mod lookup; + +use std::time::Duration; + +use store::Store; + +pub struct OpenIdDirectory { + config: OpenIdConfig, + pub(crate) data_store: Store, +} + +struct OpenIdConfig { + pub endpoint: String, + pub endpoint_type: EndpointType, + pub endpoint_timeout: Duration, + pub email_field: String, + pub username_field: Option, + pub full_name_field: Option, +} + +#[derive(Debug)] +pub enum EndpointType { + Introspect(Authentication), + UserInfo, +} + +#[derive(Debug)] +pub enum Authentication { + Header(String), + Bearer, + None, +} diff --git a/crates/directory/src/backend/sql/lookup.rs b/crates/directory/src/backend/sql/lookup.rs index 79ab78b82..71aaa054f 100644 --- a/crates/directory/src/backend/sql/lookup.rs +++ b/crates/directory/src/backend/sql/lookup.rs @@ -167,7 +167,7 @@ impl SqlDirectory { .update_principal( UpdatePrincipal::by_id(principal.id) .with_updates(changes) - .no_validate(), + .create_domains(), ) .await .caused_by(trc::location!())?; diff --git a/crates/directory/src/core/config.rs b/crates/directory/src/core/config.rs index fb0dcee16..2751dee3f 100644 --- a/crates/directory/src/core/config.rs +++ b/crates/directory/src/core/config.rs @@ -25,7 +25,12 @@ use crate::{ use super::cache::CachedDirectory; impl Directories { - pub async fn parse(config: &mut Config, stores: &Stores, data_store: Store) -> Self { + pub async fn parse( + config: &mut Config, + stores: &Stores, + data_store: Store, + is_enterprise: bool, + ) -> Self { let mut directories = AHashMap::new(); for id in config @@ -44,9 +49,12 @@ impl Directories { continue; } } - let protocol = config.value_require(("directory", id, "type")).unwrap(); + let protocol = config + .value_require(("directory", id, "type")) + .unwrap() + .to_string(); let prefix = ("directory", id); - let store = match protocol { + let store = match protocol.as_str() { "internal" => Some(DirectoryInner::Internal( if let Some(store_id) = config.value_require(("directory", id, "store")) { if let Some(data) = stores.stores.get(store_id) { @@ -76,6 +84,13 @@ impl Directories { "memory" => MemoryDirectory::from_config(config, prefix, data_store.clone()) .await .map(DirectoryInner::Memory), + #[cfg(feature = "enterprise")] + "oidc" => crate::backend::oidc::OpenIdDirectory::from_config( + config, + prefix, + data_store.clone(), + ) + .map(DirectoryInner::OpenId), unknown => { let err = format!("Unknown directory type: {unknown:?}"); config.new_parse_error(("directory", id, "type"), err); @@ -85,6 +100,14 @@ impl Directories { // Build directory if let Some(store) = store { + #[cfg(feature = "enterprise")] + if store.is_enterprise_directory() && !is_enterprise { + let message = + format!("Directory {protocol:?} is an Enterprise Edition feature"); + config.new_parse_error(("directory", id, "type"), message); + continue; + } + let directory = Arc::new(Directory { store, cache: CachedDirectory::try_from_config(config, ("directory", id)), diff --git a/crates/directory/src/core/dispatch.rs b/crates/directory/src/core/dispatch.rs index 77108e3eb..02a638997 100644 --- a/crates/directory/src/core/dispatch.rs +++ b/crates/directory/src/core/dispatch.rs @@ -23,6 +23,8 @@ impl Directory { DirectoryInner::Imap(store) => store.query(by).await, DirectoryInner::Smtp(store) => store.query(by).await, DirectoryInner::Memory(store) => store.query(by).await, + #[cfg(feature = "enterprise")] + DirectoryInner::OpenId(store) => store.query(by, return_member_of).await, } .caused_by(trc::location!()) } @@ -35,6 +37,8 @@ impl Directory { DirectoryInner::Imap(store) => store.email_to_ids(email).await, DirectoryInner::Smtp(store) => store.email_to_ids(email).await, DirectoryInner::Memory(store) => store.email_to_ids(email).await, + #[cfg(feature = "enterprise")] + DirectoryInner::OpenId(store) => store.email_to_ids(email).await, } .caused_by(trc::location!()) } @@ -54,6 +58,8 @@ impl Directory { DirectoryInner::Imap(store) => store.is_local_domain(domain).await, DirectoryInner::Smtp(store) => store.is_local_domain(domain).await, DirectoryInner::Memory(store) => store.is_local_domain(domain).await, + #[cfg(feature = "enterprise")] + DirectoryInner::OpenId(store) => store.is_local_domain(domain).await, } .caused_by(trc::location!())?; @@ -80,6 +86,8 @@ impl Directory { DirectoryInner::Imap(store) => store.rcpt(email).await, DirectoryInner::Smtp(store) => store.rcpt(email).await, DirectoryInner::Memory(store) => store.rcpt(email).await, + #[cfg(feature = "enterprise")] + DirectoryInner::OpenId(store) => store.rcpt(email).await, } .caused_by(trc::location!())?; @@ -99,6 +107,8 @@ impl Directory { DirectoryInner::Imap(store) => store.vrfy(address).await, DirectoryInner::Smtp(store) => store.vrfy(address).await, DirectoryInner::Memory(store) => store.vrfy(address).await, + #[cfg(feature = "enterprise")] + DirectoryInner::OpenId(store) => store.vrfy(address).await, } .caused_by(trc::location!()) } @@ -111,7 +121,37 @@ impl Directory { DirectoryInner::Imap(store) => store.expn(address).await, DirectoryInner::Smtp(store) => store.expn(address).await, DirectoryInner::Memory(store) => store.expn(address).await, + #[cfg(feature = "enterprise")] + DirectoryInner::OpenId(store) => store.expn(address).await, } .caused_by(trc::location!()) } + + pub fn has_bearer_token_support(&self) -> bool { + match &self.store { + DirectoryInner::Internal(_) + | DirectoryInner::Ldap(_) + | DirectoryInner::Sql(_) + | DirectoryInner::Imap(_) + | DirectoryInner::Smtp(_) + | DirectoryInner::Memory(_) => false, + #[cfg(feature = "enterprise")] + DirectoryInner::OpenId(_) => true, + } + } +} + +impl DirectoryInner { + pub fn is_enterprise_directory(&self) -> bool { + match self { + DirectoryInner::Internal(_) + | DirectoryInner::Ldap(_) + | DirectoryInner::Sql(_) + | DirectoryInner::Imap(_) + | DirectoryInner::Smtp(_) + | DirectoryInner::Memory(_) => false, + #[cfg(feature = "enterprise")] + DirectoryInner::OpenId(_) => true, + } + } } diff --git a/crates/directory/src/lib.rs b/crates/directory/src/lib.rs index 2f5ae2176..48880ac03 100644 --- a/crates/directory/src/lib.rs +++ b/crates/directory/src/lib.rs @@ -277,6 +277,8 @@ pub enum DirectoryInner { Internal(Store), Ldap(LdapDirectory), Sql(SqlDirectory), + #[cfg(feature = "enterprise")] + OpenId(backend::oidc::OpenIdDirectory), Imap(ImapDirectory), Smtp(SmtpDirectory), Memory(MemoryDirectory), diff --git a/crates/imap/Cargo.toml b/crates/imap/Cargo.toml index 0fcb93a72..aebab704d 100644 --- a/crates/imap/Cargo.toml +++ b/crates/imap/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "imap" -version = "0.10.1" +version = "0.10.2" edition = "2021" resolver = "2" diff --git a/crates/jmap/Cargo.toml b/crates/jmap/Cargo.toml index 7e6e4516c..9cc162d76 100644 --- a/crates/jmap/Cargo.toml +++ b/crates/jmap/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jmap" -version = "0.10.1" +version = "0.10.2" edition = "2021" resolver = "2" diff --git a/crates/jmap/src/api/http.rs b/crates/jmap/src/api/http.rs index 6be2bb2db..86d8984a9 100644 --- a/crates/jmap/src/api/http.rs +++ b/crates/jmap/src/api/http.rs @@ -891,11 +891,18 @@ impl HttpResponse { impl ToHttpResponse for JsonResponse { fn into_http_response(self) -> HttpResponse { - HttpResponse::new_text( - self.status, - "application/json; charset=utf-8", - serde_json::to_string(&self.inner).unwrap_or_default(), - ) + HttpResponse { + status: self.status, + content_type: "application/json; charset=utf-8".into(), + content_disposition: "".into(), + cache_control: if !self.no_cache { + "" + } else { + "no-store, no-cache, must-revalidate" + } + .into(), + body: HttpResponseBody::Text(serde_json::to_string(&self.inner).unwrap_or_default()), + } } } @@ -1012,11 +1019,21 @@ impl JsonResponse { JsonResponse { inner, status: StatusCode::OK, + no_cache: false, } } pub fn with_status(status: StatusCode, inner: T) -> Self { - JsonResponse { inner, status } + JsonResponse { + inner, + status, + no_cache: false, + } + } + + pub fn no_cache(mut self) -> Self { + self.no_cache = true; + self } } diff --git a/crates/jmap/src/api/management/principal.rs b/crates/jmap/src/api/management/principal.rs index 464589549..14f43c648 100644 --- a/crates/jmap/src/api/management/principal.rs +++ b/crates/jmap/src/api/management/principal.rs @@ -640,6 +640,8 @@ impl PrincipalManager for Server { DirectoryInner::Imap(_) => "IMAP", DirectoryInner::Smtp(_) => "SMTP", DirectoryInner::Memory(_) => "In-Memory", + #[cfg(feature = "enterprise")] + DirectoryInner::OpenId(_) => "OpenID", }; Err(manage::unsupported(format!( diff --git a/crates/jmap/src/api/mod.rs b/crates/jmap/src/api/mod.rs index 6a56a471d..041a451f9 100644 --- a/crates/jmap/src/api/mod.rs +++ b/crates/jmap/src/api/mod.rs @@ -34,6 +34,7 @@ impl JmapSessionManager { pub struct JsonResponse { status: StatusCode, inner: T, + no_cache: bool, } pub struct HtmlResponse { diff --git a/crates/jmap/src/auth/oauth/auth.rs b/crates/jmap/src/auth/oauth/auth.rs index eba15ccc2..0b4ce7260 100644 --- a/crates/jmap/src/auth/oauth/auth.rs +++ b/crates/jmap/src/auth/oauth/auth.rs @@ -90,7 +90,7 @@ impl OAuthApiHandler for Server { .details("Client ID is invalid.")); } else if redirect_uri .as_ref() - .map_or(false, |uri| !uri.starts_with("https://")) + .map_or(false, |uri| uri.starts_with("http://")) { return Err(trc::ManageEvent::Error .into_err() @@ -180,7 +180,7 @@ impl OAuthApiHandler for Server { } }; - Ok(JsonResponse::new(response).into_http_response()) + Ok(JsonResponse::new(response).no_cache().into_http_response()) } async fn handle_device_auth( @@ -262,7 +262,7 @@ impl OAuthApiHandler for Server { user_code, expires_in: self.core.oauth.oauth_expiry_user_code, interval: 5, - }) + }).no_cache() .into_http_response()) } diff --git a/crates/jmap/src/auth/oauth/openid.rs b/crates/jmap/src/auth/oauth/openid.rs index 47825c6cf..aa4141975 100644 --- a/crates/jmap/src/auth/oauth/openid.rs +++ b/crates/jmap/src/auth/oauth/openid.rs @@ -59,6 +59,7 @@ impl OpenIdHandler for Server { email_verified: !access_token.emails.is_empty(), ..Default::default() }) + .no_cache() .into_http_response()) } diff --git a/crates/jmap/src/auth/oauth/registration.rs b/crates/jmap/src/auth/oauth/registration.rs index 7c151fcf3..5cdb7c06d 100644 --- a/crates/jmap/src/auth/oauth/registration.rs +++ b/crates/jmap/src/auth/oauth/registration.rs @@ -96,6 +96,7 @@ impl ClientRegistrationHandler for Server { request, ..Default::default() }) + .no_cache() .into_http_response()) } diff --git a/crates/jmap/src/auth/oauth/token.rs b/crates/jmap/src/auth/oauth/token.rs index 73e158296..026916a30 100644 --- a/crates/jmap/src/auth/oauth/token.rs +++ b/crates/jmap/src/auth/oauth/token.rs @@ -243,7 +243,7 @@ impl TokenHandler for Server { self.introspect_access_token(&token, access_token) .await - .map(|response| JsonResponse::new(response).into_http_response()) + .map(|response| JsonResponse::new(response).no_cache().into_http_response()) } async fn issue_token( diff --git a/crates/main/Cargo.toml b/crates/main/Cargo.toml index da7435a5f..b416f240f 100644 --- a/crates/main/Cargo.toml +++ b/crates/main/Cargo.toml @@ -7,7 +7,7 @@ homepage = "https://stalw.art" keywords = ["imap", "jmap", "smtp", "email", "mail", "server"] categories = ["email"] license = "AGPL-3.0-only OR LicenseRef-SEL" -version = "0.10.1" +version = "0.10.2" edition = "2021" resolver = "2" diff --git a/crates/managesieve/Cargo.toml b/crates/managesieve/Cargo.toml index a067066fe..cd77fed83 100644 --- a/crates/managesieve/Cargo.toml +++ b/crates/managesieve/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "managesieve" -version = "0.10.1" +version = "0.10.2" edition = "2021" resolver = "2" diff --git a/crates/nlp/Cargo.toml b/crates/nlp/Cargo.toml index a46b6e7a9..837de4ddd 100644 --- a/crates/nlp/Cargo.toml +++ b/crates/nlp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nlp" -version = "0.10.1" +version = "0.10.2" edition = "2021" resolver = "2" diff --git a/crates/pop3/Cargo.toml b/crates/pop3/Cargo.toml index d7a606bca..2cfca02bc 100644 --- a/crates/pop3/Cargo.toml +++ b/crates/pop3/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pop3" -version = "0.10.1" +version = "0.10.2" edition = "2021" resolver = "2" diff --git a/crates/smtp/Cargo.toml b/crates/smtp/Cargo.toml index 12bdfdeaf..e98711805 100644 --- a/crates/smtp/Cargo.toml +++ b/crates/smtp/Cargo.toml @@ -7,7 +7,7 @@ homepage = "https://stalw.art/smtp" keywords = ["smtp", "email", "mail", "server"] categories = ["email"] license = "AGPL-3.0-only OR LicenseRef-SEL" -version = "0.10.1" +version = "0.10.2" edition = "2021" resolver = "2" @@ -46,7 +46,7 @@ blake3 = "1.3" lru-cache = "0.1.2" rand = "0.8.5" x509-parser = "0.16.0" -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-webpki-roots", "blocking", "http2"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-webpki-roots", "http2"] } serde = { version = "1.0", features = ["derive", "rc"] } serde_json = "1.0" num_cpus = "1.15.0" diff --git a/crates/store/Cargo.toml b/crates/store/Cargo.toml index eab2503ea..11cf39714 100644 --- a/crates/store/Cargo.toml +++ b/crates/store/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "store" -version = "0.10.1" +version = "0.10.2" edition = "2021" resolver = "2" diff --git a/crates/trc/Cargo.toml b/crates/trc/Cargo.toml index 2621ce50e..09e268d3f 100644 --- a/crates/trc/Cargo.toml +++ b/crates/trc/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "trc" -version = "0.10.1" +version = "0.10.2" edition = "2021" resolver = "2" diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml index 1c8fe0375..407a99eae 100644 --- a/crates/utils/Cargo.toml +++ b/crates/utils/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "utils" -version = "0.10.1" +version = "0.10.2" edition = "2021" resolver = "2" diff --git a/tests/Cargo.toml b/tests/Cargo.toml index 72a86abe2..a66d9cedd 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -62,6 +62,7 @@ async-trait = "0.1.68" chrono = "0.4" ring = { version = "0.17" } biscuit = "0.7.0" +form_urlencoded = "1.1.0" [target.'cfg(not(target_env = "msvc"))'.dependencies] jemallocator = "0.5.0" diff --git a/tests/src/directory/internal.rs b/tests/src/directory/internal.rs index fed8a2cb4..7fbf01959 100644 --- a/tests/src/directory/internal.rs +++ b/tests/src/directory/internal.rs @@ -331,11 +331,8 @@ async fn internal_directory() { description: Some("John Doe".to_string()), secrets: vec!["secret".to_string(), "secret2".to_string()], emails: vec!["john@example.org".to_string()], - member_of: vec![ - "list".to_string(), - "sales".to_string(), - "support".to_string() - ], + member_of: vec!["sales".to_string(), "support".to_string()], + lists: vec!["list".to_string()], ..Default::default() } ); @@ -379,7 +376,8 @@ async fn internal_directory() { description: Some("John Doe".to_string()), secrets: vec!["secret".to_string(), "secret2".to_string()], emails: vec!["john@example.org".to_string()], - member_of: vec!["list".to_string(), "sales".to_string()], + member_of: vec!["sales".to_string()], + lists: vec!["list".to_string()], ..Default::default() } ); @@ -430,7 +428,8 @@ async fn internal_directory() { emails: vec!["john.doe@example.org".to_string()], quota: 1024, typ: Type::Individual, - member_of: vec!["list".to_string(), "sales".to_string()], + member_of: vec!["sales".to_string()], + lists: vec!["list".to_string()], ..Default::default() } ); diff --git a/tests/src/directory/mod.rs b/tests/src/directory/mod.rs index ea37ca79f..4f4dca793 100644 --- a/tests/src/directory/mod.rs +++ b/tests/src/directory/mod.rs @@ -7,6 +7,7 @@ pub mod imap; pub mod internal; pub mod ldap; +pub mod oidc; pub mod smtp; pub mod sql; @@ -244,6 +245,65 @@ 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 { @@ -267,6 +327,7 @@ pub struct TestPrincipal { pub emails: Vec, pub member_of: Vec, pub roles: Vec, + pub lists: Vec, pub description: Option, } @@ -298,6 +359,7 @@ impl DirectoryTest { id_store .map(|id| stores.stores.get(id).unwrap().clone()) .unwrap_or_default(), + true, ) .await; config.assert_no_errors(); @@ -464,6 +526,9 @@ impl From for TestPrincipal { 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), } } @@ -477,6 +542,7 @@ impl From for Principal { .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) } } diff --git a/tests/src/directory/oidc.rs b/tests/src/directory/oidc.rs new file mode 100644 index 000000000..08ffe016f --- /dev/null +++ b/tests/src/directory/oidc.rs @@ -0,0 +1,263 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: LicenseRef-SEL + * + * This file is subject to the Stalwart Enterprise License Agreement (SEL) and + * is NOT open source software. + * + */ + +use std::sync::Arc; + +use ahash::AHashMap; +use base64::{engine::general_purpose, Engine}; +use common::{config::server::Listeners, listener::SessionData, Core, Data, Inner}; +use directory::{backend::internal::PrincipalField, QueryBy}; +use hyper::{body, server::conn::http1, service::service_fn, Method, StatusCode, Uri}; +use hyper_util::rt::TokioIo; +use jmap::api::{ + http::{fetch_body, ToHttpResponse}, + HttpResponse, JsonResponse, +}; +use mail_send::Credentials; +use serde_json::json; +use tokio::sync::watch; +use trc::{AuthEvent, EventType}; +use utils::config::Config; + +use crate::{add_test_certs, directory::DirectoryTest, AssertConfig}; + +static TEST_TOKEN: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ"; + +#[tokio::test] +async fn oidc_directory() { + // Obtain directory handle + let mut config = DirectoryTest::new("rocksdb".into()).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(_) => 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 { + 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(_) => 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(_) => 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(_) => StatusCode::UNAUTHORIZED.into_http_response(), + None => panic!("Missing Authorization header: {req:#?}"), + }, + _ => panic!("Unexpected request: {:?}", req), + } + })) + .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(); + + // Test an invalid token + let err = directory + .query( + QueryBy::Credentials(&Credentials::OAuthBearer { + token: "invalid_or_expired_token".to_string(), + }), + false, + ) + .await + .unwrap_err(); + assert!( + err.matches(EventType::Auth(AuthEvent::Failed)), + "Unexpected error: {:?}", + err + ); + + // Test a valid token + let principal = directory + .query( + QueryBy::Credentials(&Credentials::OAuthBearer { + token: TEST_TOKEN.to_string(), + }), + false, + ) + .await + .unwrap() + .unwrap(); + assert_eq!(principal.name(), "jdoe"); + assert_eq!( + principal.get_str(PrincipalField::Emails), + Some("john@example.org") + ); + assert_eq!(principal.description(), Some("John Doe")); + } +} + +const MOCK_HTTP_SERVER: &str = r#" +[server] +hostname = "'oidc.example.org'" +http.url = "'https://127.0.0.1:9090'" + +[server.listener.jmap] +bind = ['127.0.0.1:9090'] +protocol = 'http' +tls.implicit = true + +[server.socket] +reuse-addr = true + +[certificate.default] +cert = '%{file:{CERT}}%' +private-key = '%{file:{PK}}%' +default = true +"#; + +#[derive(Clone)] +pub struct HttpSessionManager { + inner: HttpRequestHandler, +} + +pub type HttpRequestHandler = Arc HttpResponse + Sync + Send>; + +#[derive(Debug)] +pub struct HttpMessage { + method: Method, + headers: AHashMap, + uri: Uri, + body: Option>, +} + +impl HttpMessage { + pub fn get_url_encoded(&self, key: &str) -> Option { + form_urlencoded::parse(self.body.as_ref()?.as_slice()) + .find(|(k, _)| k == key) + .map(|(_, v)| v.into_owned()) + } +} + +pub async fn spawn_mock_http_server( + handler: HttpRequestHandler, +) -> (watch::Sender, watch::Receiver) { + // Start mock push server + let mut settings = Config::new(add_test_certs(MOCK_HTTP_SERVER)).unwrap(); + settings.resolve_all_macros().await; + let mock_inner = Arc::new(Inner { + shared_core: Core::parse(&mut settings, Default::default(), Default::default()) + .await + .into_shared(), + data: Data::parse(&mut settings), + ..Default::default() + }); + settings.errors.clear(); + settings.warnings.clear(); + let mut servers = Listeners::parse(&mut settings); + servers.parse_tcp_acceptors(&mut settings, mock_inner.clone()); + + // Start JMAP server + servers.bind_and_drop_priv(&mut settings); + settings.assert_no_errors(); + servers.spawn(|server, acceptor, shutdown_rx| { + server.spawn( + HttpSessionManager { + inner: handler.clone(), + }, + mock_inner.clone(), + acceptor, + shutdown_rx, + ); + }) +} + +impl common::listener::SessionManager for HttpSessionManager { + #[allow(clippy::manual_async_fn)] + fn handle( + self, + session: SessionData, + ) -> impl std::future::Future + Send { + async move { + let sender = self.inner; + let _ = http1::Builder::new() + .keep_alive(false) + .serve_connection( + TokioIo::new(session.stream), + service_fn(|mut req: hyper::Request| { + let sender = sender.clone(); + + async move { + let response = sender(HttpMessage { + method: req.method().clone(), + uri: req.uri().clone(), + headers: req + .headers() + .iter() + .map(|(k, v)| { + (k.as_str().to_lowercase(), v.to_str().unwrap().to_string()) + }) + .collect(), + body: fetch_body(&mut req, 1024 * 1024, 0).await, + }); + + Ok::<_, hyper::Error>(response.build()) + } + }), + ) + .await; + } + } + + #[allow(clippy::manual_async_fn)] + fn shutdown(&self) -> impl std::future::Future + Send { + async {} + } +} diff --git a/tests/src/directory/sql.rs b/tests/src/directory/sql.rs index 12da895ae..994885825 100644 --- a/tests/src/directory/sql.rs +++ b/tests/src/directory/sql.rs @@ -36,6 +36,7 @@ async fn sql_directory() { let core = config.server; // Create tables + base_store.destroy().await; store.create_test_directory().await; // Create test users diff --git a/tests/src/jmap/mod.rs b/tests/src/jmap/mod.rs index 58f4ba525..93a282c3b 100644 --- a/tests/src/jmap/mod.rs +++ b/tests/src/jmap/mod.rs @@ -370,7 +370,7 @@ pub async fn jmap_tests() { ) .await; - /*webhooks::test(&mut params).await; + webhooks::test(&mut params).await; email_query::test(&mut params, delete).await; email_get::test(&mut params).await; email_set::test(&mut params).await; @@ -384,9 +384,9 @@ pub async fn jmap_tests() { mailbox::test(&mut params).await; delivery::test(&mut params).await; auth_acl::test(&mut params).await; - auth_limits::test(&mut params).await;*/ + auth_limits::test(&mut params).await; auth_oauth::test(&mut params).await; - /*event_source::test(&mut params).await; + event_source::test(&mut params).await; push_subscription::test(&mut params).await; sieve_script::test(&mut params).await; vacation_response::test(&mut params).await; @@ -397,7 +397,7 @@ pub async fn jmap_tests() { blob::test(&mut params).await; permissions::test(¶ms).await; purge::test(&mut params).await; - enterprise::test(&mut params).await;*/ + enterprise::test(&mut params).await; if delete { params.temp_dir.delete();