Import upstream v0.16.22, stripped

Upstream commit: 474dd0229cb20cf513036619781ed97bd8073c3f
Enterprise-only files removed or emptied: 63
Enterprise-only snippets removed: 117 in 50 files
Dangling module declarations removed: 5
Cargo edits turning enterprise off: 14
Verification: clean
Enterprise feature gates left for rebuilt features: 19 in 18 files

Produced by tools/fork/strip.py. The full report is in docs/fork/strip-reports/ on main.
This commit is contained in:
2026-09-18 10:21:56 -07:00
commit 7dae9b29fd
1650 changed files with 485521 additions and 0 deletions
+186
View File
@@ -0,0 +1,186 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::{Bind, LdapConnectionManager, LdapDirectory, LdapFilter, LdapFilterItem, LdapMappings};
use crate::Directory;
use deadpool::{Runtime, managed::Pool};
use ldap3::LdapConnSettings;
use registry::schema::structs;
impl LdapDirectory {
pub async fn open(config: structs::LdapDirectory) -> Result<Directory, String> {
let bind_dn = if let Some(dn) = config.bind_dn {
Bind::new(
dn,
config
.bind_secret
.secret()
.await?
.map(|v| v.into_owned())
.ok_or_else(|| {
"LDAP bind password is required when bind DN is set".to_string()
})?,
)
.into()
} else {
None
};
let manager = LdapConnectionManager::new(
config.url,
LdapConnSettings::new()
.set_conn_timeout(config.timeout.into_inner())
.set_starttls(config.use_tls)
.set_no_tls_verify(config.allow_invalid_certs),
bind_dn,
);
let mut mappings = LdapMappings {
base_dn: config.base_dn,
filter_login: LdapFilter::new(&config.filter_login)?,
filter_mailbox: LdapFilter::new(&config.filter_mailbox)?,
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![],
};
let mut attrs_principal: Vec<String> = Vec::new();
for attrs in [
&mappings.attr_description,
&mappings.attr_secret,
&mappings.attr_secret_changed,
&mappings.attr_groups,
&mappings.attr_email_alias,
&mappings.attr_email,
&mappings.attr_class,
] {
for attr in attrs.iter().filter(|a| !a.is_empty()) {
if !attrs_principal.contains(attr) {
attrs_principal.push(attr.clone());
}
}
}
mappings.attrs_principal = attrs_principal;
let pool = Pool::builder(manager)
.runtime(Runtime::Tokio1)
.max_size(config.pool_max_connections as usize)
.create_timeout(config.pool_timeout_create.into_inner().into())
.wait_timeout(config.pool_timeout_wait.into_inner().into())
.recycle_timeout(config.pool_timeout_recycle.into_inner().into())
.build()
.map_err(|err| format!("Failed to build LDAP pool: {err}"))?;
Ok(Directory::Ldap(LdapDirectory {
mappings,
pool,
auth_bind: config.bind_authentication,
}))
}
}
impl LdapFilter {
pub(super) fn new(value: &str) -> Result<Self, String> {
let mut filter = Vec::new();
let mut token = String::new();
let mut value = value.chars();
while let Some(ch) = value.next() {
match ch {
'?' => {
// For backwards compatibility, we treat '?' as a placeholder for the full value.
if !token.is_empty() {
filter.push(LdapFilterItem::Static(token));
token = String::new();
}
filter.push(LdapFilterItem::Full);
}
'{' => {
if !token.is_empty() {
filter.push(LdapFilterItem::Static(token));
token = String::new();
}
for ch in value.by_ref() {
if ch == '}' {
break;
} else {
token.push(ch);
}
}
match token.as_str() {
"user" | "username" | "email" => filter.push(LdapFilterItem::Full),
"local" => filter.push(LdapFilterItem::LocalPart),
"domain" => filter.push(LdapFilterItem::DomainPart),
_ => {
return Err(format!("Unknown LDAP filter placeholder: {}", token));
}
}
token.clear();
}
_ => token.push(ch),
}
}
if !token.is_empty() {
filter.push(LdapFilterItem::Static(token));
}
if filter.len() >= 2 {
Ok(LdapFilter { filter })
} else {
Err(format!(
"Missing parameter placeholders in value {:?}",
value
))
}
}
}
+307
View File
@@ -0,0 +1,307 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::{LdapDirectory, LdapMappings};
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;
impl LdapDirectory {
pub async fn authenticate(&self, credentials: &Credentials) -> trc::Result<Account> {
let (username, secret) = match credentials {
Credentials::Basic {
username, secret, ..
} => (username, secret),
Credentials::Bearer { token, .. } => (token, token),
};
if secret.is_empty() {
return Err(trc::AuthEvent::Failed
.into_err()
.details("Empty secret rejected"));
}
let mut conn = self.pool.get().await.map_err(|err| err.into_error())?;
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,
)
.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
} 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]));
}
} 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]));
}
};
self.add_group_membership(&mut conn, &mut result).await?;
Ok(result.account)
}
pub async fn recipient(&self, address: &str) -> trc::Result<Recipient> {
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 let Some(group_dns) = result.account.groups.take() {
let mut groups = Vec::new();
for name in group_dns.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)
{
groups.push(email);
break 'outer;
}
}
}
}
result.account.groups = if groups.is_empty() {
None
} else {
Some(groups)
};
} 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;
let had_entries = !rs.is_empty();
let mut groups = Vec::new();
for entry in rs {
for (attr, value) in SearchEntry::construct(entry).attrs {
if self.mappings.attr_email.contains(&attr.to_lowercase()) {
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
})
}));
}
}
}
result.account.groups = if had_entries && groups.is_empty() {
None
} else {
Some(groups)
};
} else {
result.account.groups = (!self.mappings.attr_groups.is_empty()).then(Vec::new);
}
Ok(())
}
}
impl LdapDirectory {
async fn find_object(&self, conn: &mut Ldap, filter: &str) -> trc::Result<Option<LdapResult>> {
conn.search(
&self.mappings.base_dn,
Scope::Subtree,
filter,
&self.mappings.attrs_principal,
)
.await
.map_err(|err| err.into_error().caused_by(trc::location!()))?
.success()
.map(|(rs, _)| {
trc::event!(
Store(trc::StoreEvent::LdapQuery),
Details = filter.to_string(),
Result = rs.first().map(result_to_trace).unwrap_or_default()
);
rs.into_iter()
.next()
.map(|entry| self.mappings.map_entry(SearchEntry::construct(entry)))
})
.map_err(|err| err.into_error().caused_by(trc::location!()))
}
}
struct LdapResult {
dn: String,
account: Account,
is_group: bool,
}
impl LdapMappings {
fn map_entry(&self, entry: SearchEntry) -> LdapResult {
let mut account = Account::default();
let mut is_group = false;
for (attr, value) in entry.attrs {
let attr = attr.to_lowercase();
let is_email = self.attr_email.contains(&attr);
let is_email_alias = self.attr_email_alias.contains(&attr);
if is_email || is_email_alias {
let mut values = value.into_iter().filter_map(|v| sanitize_email(&v));
if is_email
&& account.email.is_empty()
&& let Some(email) = values.next()
{
account.email = email;
}
if is_email_alias {
account.email_aliases.extend(values);
}
} else if self.attr_secret.contains(&attr) {
account.secret = value.into_iter().find(|secret| !secret.is_empty());
} else if self.attr_secret_changed.contains(&attr) {
// Create a disabled AppPassword, used to indicate that the password has been changed
// but cannot be used for authentication.
if account.secret.is_none() {
account.secret = value.into_iter().find(|item| !item.is_empty()).map(|item| {
format!("$app${}$", xxhash_rust::xxh3::xxh3_64(item.as_bytes()))
});
}
} else if let Some(idx) = self.attr_description.iter().position(|a| a == &attr) {
if (account.description.is_none() || idx == 0)
&& let Some(desc) = value.into_iter().find(|desc| !desc.is_empty())
{
account.description = Some(desc);
}
} else if self.attr_groups.contains(&attr) {
account.groups.get_or_insert_default().extend(value);
} else if self.attr_class.contains(&attr) {
for value in value {
is_group |= value.eq_ignore_ascii_case(&self.group_class);
}
}
}
if !account.email_aliases.is_empty() {
let mut aliases = Vec::with_capacity(account.email_aliases.len());
for alias in std::mem::take(&mut account.email_aliases) {
if alias != account.email && !aliases.contains(&alias) {
aliases.push(alias);
}
}
account.email_aliases = aliases;
}
LdapResult {
dn: entry.dn,
account,
is_group,
}
}
}
fn result_to_trace(rs: &ResultEntry) -> trc::Value {
let se = SearchEntry::construct(rs.clone());
se.attrs
.into_iter()
.map(|(k, v)| trc::Value::Array(vec![trc::Value::from(k), trc::Value::from(v.join(", "))]))
.chain([trc::Value::from(se.dn)])
.collect::<Vec<_>>()
.into()
}
+125
View File
@@ -0,0 +1,125 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use deadpool::managed::Pool;
use ldap3::{LdapConnSettings, ldap_escape};
pub mod config;
pub mod lookup;
pub mod pool;
pub struct LdapDirectory {
pool: Pool<LdapConnectionManager>,
mappings: LdapMappings,
auth_bind: bool,
}
#[derive(Debug, Default)]
pub struct LdapMappings {
base_dn: String,
filter_login: LdapFilter,
filter_mailbox: LdapFilter,
filter_member_of: Option<LdapFilter>,
attr_class: Vec<String>,
attr_groups: Vec<String>,
attr_description: Vec<String>,
attr_secret: Vec<String>,
attr_secret_changed: Vec<String>,
attr_email: Vec<String>,
attr_email_alias: Vec<String>,
attrs_principal: Vec<String>,
group_class: String,
}
#[derive(Debug, Default)]
pub(crate) struct LdapFilter {
filter: Vec<LdapFilterItem>,
}
#[derive(Debug)]
enum LdapFilterItem {
Static(String),
Full,
LocalPart,
DomainPart,
}
impl LdapFilter {
pub fn build(&self, value: &str) -> String {
let mut result = String::with_capacity(value.len() + 16);
for item in &self.filter {
match item {
LdapFilterItem::Static(s) => result.push_str(s),
LdapFilterItem::Full => result.push_str(ldap_escape(value).as_ref()),
LdapFilterItem::LocalPart => {
result.push_str(
ldap_escape(
value
.rsplit_once('@')
.map(|(local, _)| local)
.unwrap_or(value),
)
.as_ref(),
);
}
LdapFilterItem::DomainPart => {
if let Some((_, domain)) = value.rsplit_once('@') {
result.push_str(ldap_escape(domain).as_ref());
}
}
}
}
result
}
}
pub(crate) struct LdapConnectionManager {
address: String,
settings: LdapConnSettings,
bind_dn: Option<Bind>,
}
pub(crate) struct Bind {
dn: String,
password: String,
}
impl LdapConnectionManager {
pub fn new(address: String, settings: LdapConnSettings, bind_dn: Option<Bind>) -> Self {
Self {
address,
settings,
bind_dn,
}
}
}
impl Bind {
pub fn new(dn: String, password: String) -> Self {
Self { dn, password }
}
}
#[cfg(test)]
mod tests {
use super::LdapFilter;
#[test]
fn filter_placeholders_are_escaped() {
let filter = LdapFilter::new("(&(uid={local})(dc={domain})(mail=?))").unwrap();
assert_eq!(
filter.build("*)(uid=*@ex)(dc=*"),
"(&(uid=\\2a\\29\\28uid=\\2a)(dc=ex\\29\\28dc=\\2a)(mail=\\2a\\29\\28uid=\\2a@ex\\29\\28dc=\\2a))"
);
assert_eq!(
filter.build("[email protected]"),
"(&(uid=john)(dc=example.com)([email protected]))"
);
}
}
+40
View File
@@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::LdapConnectionManager;
use deadpool::managed;
use ldap3::{Ldap, LdapConnAsync, LdapError, exop::WhoAmI};
impl managed::Manager for LdapConnectionManager {
type Type = Ldap;
type Error = LdapError;
async fn create(&self) -> Result<Ldap, LdapError> {
let (conn, mut ldap) =
LdapConnAsync::with_settings(self.settings.clone(), &self.address).await?;
ldap3::drive!(conn);
if let Some(bind) = &self.bind_dn {
ldap.simple_bind(&bind.dn, &bind.password)
.await?
.success()?;
}
Ok(ldap)
}
async fn recycle(
&self,
conn: &mut Ldap,
_: &managed::Metrics,
) -> managed::RecycleResult<LdapError> {
conn.extended(WhoAmI)
.await
.map(|_| ())
.map_err(managed::RecycleError::Backend)
}
}
+9
View File
@@ -0,0 +1,9 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
pub mod ldap;
pub mod oidc;
pub mod sql;
+177
View File
@@ -0,0 +1,177 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::Directory;
use crate::backend::oidc::lookup::fetch_jwks_keys;
use crate::backend::oidc::{
CachedKey, DiscoveryDocument, JwksCache, OidcConfig, OidcDiscovery, OidcError, OpenIdDirectory,
};
use ahash::AHashMap;
use registry::schema::structs;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use trc::AuthEvent;
use utils::Client;
const DISCOVERY_RETRY_FOR: Duration = Duration::from_secs(30);
const DISCOVERY_RETRY_INTERVAL: Duration = Duration::from_secs(3);
impl OpenIdDirectory {
pub async fn open(config: structs::OidcDirectory) -> Result<Directory, String> {
Self::new(OidcConfig {
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: OidcConfig) -> Result<Self, OidcError> {
let http = utils::http::http_client_builder(false)
.user_agent("Stalwart/1.0")
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| OidcError::Network(format!("HTTP client build failed: {e}")))?;
let started_at = Instant::now();
let (document, keys) = loop {
match Self::discover(&http, &config).await {
Ok(discovery) => break discovery,
Err(err) if err.is_transient() && started_at.elapsed() < DISCOVERY_RETRY_FOR => {
trc::event!(
Auth(AuthEvent::Warning),
Url = config.issue_url.to_string(),
Reason = format!(
"{err}, retrying in {} seconds",
DISCOVERY_RETRY_INTERVAL.as_secs()
)
);
tokio::time::sleep(DISCOVERY_RETRY_INTERVAL).await;
}
Err(err) => return Err(err),
}
};
Ok(Self {
discovery: OidcDiscovery {
url: config.issue_url.clone(),
document,
},
config,
http,
cache: RwLock::new(JwksCache {
keys,
last_updated: Instant::now(),
}),
})
}
async fn discover(
http: &Client,
config: &OidcConfig,
) -> Result<(DiscoveryDocument, AHashMap<String, Arc<CachedKey>>), OidcError> {
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::Config(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
)
);
}
}
}
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 keys = fetch_jwks_keys(http, &discovery.jwks_uri).await?;
Ok((discovery, keys))
}
}
+464
View File
@@ -0,0 +1,464 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
Account, Credentials,
backend::oidc::{CachedKey, OidcError, OpenIdDirectory},
};
use ahash::AHashMap;
use jsonwebtoken::{
Algorithm, DecodingKey, Header, Validation, decode, decode_header,
jwk::{self, JwkSet},
};
use reqwest::Client;
use serde_json::Value;
use std::time::Instant;
use std::{sync::Arc, time::Duration};
use trc::AuthEvent;
impl OpenIdDirectory {
pub async fn authenticate(&self, credentials: &Credentials) -> trc::Result<Account> {
match credentials {
Credentials::Bearer { token, .. } if token.is_empty() => {
Err(AuthEvent::Failed.into_err().reason("Empty token rejected"))
}
Credentials::Bearer { token, .. } => if let Ok(header) = decode_header(token) {
self.authenticate_jwt(token, header).await
} else {
#[cfg(feature = "test_mode")]
let token = token.strip_prefix(".").unwrap_or(token);
self.authenticate_opaque(token).await
}
.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, header: Header) -> Result<Account, OidcError> {
if matches!(
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.document.issuer]);
validation.leeway = 60;
match decode::<serde_json::Value>(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)?;
let mut claims = token_data.claims;
let jwt_email = self.resolve_email(&claims).ok();
let missing_profile =
is_claim_missing(&claims, self.config.claim_name.as_ref())
|| is_claim_missing(&claims, self.config.claim_groups.as_ref());
if jwt_email.is_none() || missing_profile {
match self.fetch_userinfo(token).await {
Ok(userinfo) => {
if let (Some(base), Value::Object(extra)) =
(claims.as_object_mut(), userinfo)
{
for (key, value) in extra {
if base.get(&key).is_none_or(Value::is_null) {
base.insert(key, value);
}
}
}
}
Err(err) if jwt_email.is_none() => return Err(err),
Err(_) => {}
}
}
let email = match jwt_email {
Some(email) => email,
None => self.resolve_email(&claims)?,
};
return self.build_account(email, &claims);
}
Err(e) => {
last_err = Some(e);
}
}
}
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<Account, OidcError> {
let claims = self.fetch_userinfo(token).await?;
self.build_account(self.resolve_email(&claims)?, &claims)
}
async fn get_key(&self, kid: Option<&str>) -> Result<Vec<Arc<CachedKey>>, 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.document.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 {
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<serde_json::Value, OidcError> {
let resp = self
.http
.get(&self.discovery.document.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::<serde_json::Value>(&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,
email: String,
claims: &serde_json::Value,
) -> Result<Account, OidcError> {
Ok(Account {
email,
email_aliases: Vec::new(),
secret: None,
groups: self
.config
.claim_groups
.as_ref()
.and_then(|groups_claim| claims.get(groups_claim))
.and_then(extract_string_list)
.map(|groups| {
groups
.into_iter()
.map(|group| match &self.config.default_domain {
Some(domain) if !group.contains('@') => format!("{group}@{domain}"),
_ => group,
})
.collect()
}),
description: self
.config
.claim_name
.as_ref()
.and_then(|name_claim| claims.get(name_claim))
.and_then(|v| v.as_str())
.filter(|name| !name.is_empty())
.map(|s| s.to_string()),
})
}
fn resolve_email(&self, claims: &serde_json::Value) -> Result<String, OidcError> {
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(),
))
}
}
pub(super) async fn fetch_jwks_keys(
http: &Client,
jwks_uri: &str,
) -> Result<AHashMap<String, Arc<CachedKey>>, 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}")))?;
let mut map = AHashMap::new();
let mut synthetic_id: u64 = 0;
for key in &jwks.keys {
if let Some(pk_use) = &key.common.public_key_use
&& pk_use != &jwk::PublicKeyUse::Signature
{
continue;
}
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;
}
_ => {
trc::event!(
Auth(AuthEvent::Warning),
Url = jwks_uri.to_string(),
Reason = format!(
"Unrecognised key type in JWKS (kid={:?}), skipping",
key.common.key_id
)
);
continue;
}
};
let decoding_key = match DecodingKey::from_jwk(key) {
Ok(decoding_key) => decoding_key,
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
)
);
continue;
}
};
map.insert(
match &key.common.key_id {
Some(id) => id.clone(),
None => {
let id = format!("_synthetic_{synthetic_id}");
synthetic_id += 1;
id
}
},
CachedKey {
decoding_key,
algorithm,
}
.into(),
);
}
Ok(map)
}
fn extract_scopes(claims: &serde_json::Value) -> Vec<String> {
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) -> Option<Vec<String>> {
match value {
serde_json::Value::Array(arr) => Some(
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect(),
),
serde_json::Value::String(s) => Some(s.split_whitespace().map(|s| s.to_string()).collect()),
_ => None,
}
}
#[inline(always)]
fn is_claim_missing(claims: &serde_json::Value, claim: Option<&String>) -> bool {
claim.is_some_and(|claim| claims.get(claim).is_none_or(serde_json::Value::is_null))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn extract_string_list_distinguishes_absent_from_empty() {
assert_eq!(
extract_string_list(&json!(["sales", "support"])),
Some(vec!["sales".to_string(), "support".to_string()])
);
assert_eq!(
extract_string_list(&json!("sales support")),
Some(vec!["sales".to_string(), "support".to_string()])
);
assert_eq!(extract_string_list(&json!([])), Some(vec![]));
assert_eq!(extract_string_list(&json!("")), Some(vec![]));
assert_eq!(extract_string_list(&json!(null)), None);
assert_eq!(extract_string_list(&json!({"groups": []})), None);
assert_eq!(extract_string_list(&json!(42)), None);
}
#[test]
fn is_claim_missing_treats_null_as_absent() {
let claims = json!({"groups": null, "name": "John Doe", "roles": []});
assert!(is_claim_missing(&claims, Some(&"groups".to_string())));
assert!(is_claim_missing(&claims, Some(&"unknown".to_string())));
assert!(!is_claim_missing(&claims, Some(&"name".to_string())));
assert!(!is_claim_missing(&claims, Some(&"roles".to_string())));
assert!(!is_claim_missing(&claims, None));
}
}
+93
View File
@@ -0,0 +1,93 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use ahash::AHashMap;
use jsonwebtoken::{Algorithm, DecodingKey};
use serde::{Deserialize, Serialize};
use std::{fmt, sync::Arc, time::Instant};
use tokio::sync::RwLock;
use utils::Client;
pub mod config;
pub mod lookup;
pub struct OidcConfig {
pub issue_url: String,
pub require_aud: Option<String>,
pub require_scopes: Vec<String>,
pub claim_email: String,
pub claim_name: Option<String>,
pub claim_groups: Option<String>,
pub default_domain: Option<String>,
}
pub struct OidcDiscovery {
pub url: String,
pub document: DiscoveryDocument,
}
#[derive(Deserialize, Serialize)]
pub struct DiscoveryDocument {
pub issuer: String,
pub jwks_uri: String,
pub userinfo_endpoint: String,
pub token_endpoint: String,
pub authorization_endpoint: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub end_session_endpoint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scopes_supported: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub claims_supported: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub code_challenge_methods_supported: Option<Vec<String>>,
}
struct CachedKey {
decoding_key: DecodingKey,
algorithm: Algorithm,
}
struct JwksCache {
keys: AHashMap<String, Arc<CachedKey>>,
last_updated: Instant,
}
pub struct OpenIdDirectory {
config: OidcConfig,
pub discovery: OidcDiscovery,
http: Client,
cache: RwLock<JwksCache>,
}
#[derive(Debug)]
pub enum OidcError {
TokenValidation(String),
AuthorizationFailed(String),
Network(String),
Provider(String),
Config(String),
}
impl OidcError {
pub fn is_transient(&self) -> bool {
matches!(self, OidcError::Network(_) | OidcError::Provider(_))
}
}
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}"),
OidcError::Config(msg) => write!(f, "Configuration error: {msg}"),
}
}
}
impl std::error::Error for OidcError {}
@@ -0,0 +1,68 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::{SqlDirectory, SqlMappings};
use crate::Directory;
use registry::schema::structs;
use store::Store;
#[allow(unreachable_patterns)]
impl SqlDirectory {
pub async fn open(
config: structs::SqlDirectory,
data_store: &Store,
) -> Result<Directory, String> {
let sql_store = match config.store {
#[cfg(feature = "postgres")]
structs::SqlAuthStore::PostgreSql(store) => {
store::backend::postgres::PostgresStore::open(store).await?
}
#[cfg(feature = "mysql")]
structs::SqlAuthStore::MySql(store) => {
store::backend::mysql::MysqlStore::open(store).await?
}
#[cfg(feature = "sqlite")]
structs::SqlAuthStore::Sqlite(store) => {
store::backend::sqlite::SqliteStore::open(store)?
}
structs::SqlAuthStore::Default => {
if data_store.is_sql() {
data_store.clone()
} else {
return Err(concat!(
"This directory is set to store accounts in the main data store, ",
"but the configured data store is not an SQL database. ",
"Either select an SQL data store or configure a separate SQL ",
"database for this directory."
)
.to_string());
}
}
_ => {
return Err(
"Binary not compiled with support for the selected SQL directory backend."
.to_string(),
);
}
};
let mappings = SqlMappings {
query_login: config.query_login,
query_recipient: config.query_recipient,
query_member_of: config.query_member_of,
query_email_aliases: config.query_email_aliases,
column_email: config.column_email,
column_secret: config.column_secret,
column_type: config.column_class,
column_description: config.column_description,
};
Ok(Directory::Sql(SqlDirectory {
sql_store,
mappings,
}))
}
}
+201
View File
@@ -0,0 +1,201 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::{SqlDirectory, SqlMappings};
use crate::{Account, Credentials, Recipient, core::secret::verify_secret_hash};
use store::{NamedRows, Rows, Value};
use trc::AddContext;
use utils::sanitize_email;
impl SqlDirectory {
pub async fn authenticate(&self, credentials: &Credentials) -> trc::Result<Account> {
let (username, secret) = match credentials {
Credentials::Basic {
username, secret, ..
} => (username, secret),
Credentials::Bearer { .. } => {
return Err(trc::AuthEvent::Error
.into_err()
.details("Unsupported credentials type for SQL authentication"));
}
};
if secret.is_empty() {
return Err(trc::AuthEvent::Failed
.into_err()
.details("Empty secret rejected")
.ctx(trc::Key::AccountName, username.to_string()));
}
let Recipient::Account(mut account) = self.mappings.row_to_account(
self.sql_store
.sql_query::<NamedRows>(&self.mappings.query_login, vec![username.into()])
.await
.caused_by(trc::location!())?,
) else {
return Err(trc::AuthEvent::Failed
.into_err()
.details("SQL login query did not return an account")
.ctx(trc::Key::AccountName, username.to_string()));
};
// Validate secret
if let Some(account_secret) = &account.secret {
if !verify_secret_hash(account_secret, secret.as_bytes()).await? {
return Err(trc::AuthEvent::Failed
.into_err()
.details("Invalid credentials")
.ctx(trc::Key::AccountName, username.to_string()));
}
} else {
return Err(trc::AuthEvent::Error
.into_err()
.details("Account does not have a secret")
.ctx(trc::Key::AccountName, username.to_string()));
}
// Obtain members
if let Some(query) = &self.mappings.query_member_of {
let members = account.groups.get_or_insert_default();
for row in self
.sql_store
.sql_query::<Rows>(query, vec![username.into()])
.await
.caused_by(trc::location!())?
.rows
{
if let Some(Value::Text(address)) = row.values.first()
&& let Some(email) = sanitize_email(address)
{
members.push(email);
}
}
}
// Obtain emails
if let Some(query) = &self.mappings.query_email_aliases {
account.email_aliases.extend(
self.sql_store
.sql_query::<Rows>(query, vec![username.into()])
.await
.caused_by(trc::location!())?
.rows
.into_iter()
.flat_map(|v| {
v.values
.into_iter()
.filter_map(|v| sanitize_email(v.to_str().as_ref()))
}),
);
}
if account.email.is_empty() {
account.email = sanitize_email(username).unwrap_or_else(|| username.to_lowercase());
}
Ok(account)
}
pub async fn recipient(&self, address: &str) -> trc::Result<Recipient> {
let recipient = self.mappings.row_to_account(
self.sql_store
.sql_query::<NamedRows>(&self.mappings.query_recipient, vec![address.into()])
.await
.caused_by(trc::location!())?,
);
match recipient {
Recipient::Account(mut account) => {
// Obtain members
if let Some(query) = &self.mappings.query_member_of {
let members = account.groups.get_or_insert_default();
for row in self
.sql_store
.sql_query::<Rows>(query, vec![account.email.as_str().into()])
.await
.caused_by(trc::location!())?
.rows
{
if let Some(Value::Text(address)) = row.values.first()
&& let Some(email) = sanitize_email(address)
{
members.push(email);
}
}
}
// Obtain emails
if let Some(query) = &self.mappings.query_email_aliases {
account.email_aliases.extend(
self.sql_store
.sql_query::<Rows>(query, vec![account.email.as_str().into()])
.await
.caused_by(trc::location!())?
.rows
.into_iter()
.flat_map(|v| {
v.values
.into_iter()
.filter_map(|v| sanitize_email(v.to_str().as_ref()))
}),
);
}
Ok(Recipient::Account(account))
}
Recipient::Group(group) => Ok(Recipient::Group(group)),
Recipient::Invalid => Ok(Recipient::Invalid),
}
}
}
impl SqlMappings {
pub fn row_to_account(&self, rows: NamedRows) -> Recipient {
if rows.rows.is_empty() {
return Recipient::Invalid;
}
let mut account = Account::default();
let mut is_group = false;
if let Some(row) = rows.rows.into_iter().next() {
for (name, value) in rows.names.into_iter().zip(row.values) {
if name.eq_ignore_ascii_case(&self.column_email) {
if let Value::Text(text) = value
&& let Some(email) = sanitize_email(&text)
{
account.email = email;
}
} else if name.eq_ignore_ascii_case(&self.column_secret) {
if let Value::Text(text) = value
&& !text.is_empty()
{
account.secret = Some(text.into_owned());
}
} else if let Some(column_type) = &self.column_type
&& name.eq_ignore_ascii_case(column_type)
{
is_group = value.to_str().eq_ignore_ascii_case("group");
} else if let Some(column_description) = &self.column_description
&& name.eq_ignore_ascii_case(column_description)
&& let Value::Text(text) = value
&& !text.is_empty()
{
account.description = Some(text.into_owned());
}
}
}
if !is_group {
Recipient::Account(account)
} else {
Recipient::Group(crate::Group {
email: account.email,
email_aliases: account.email_aliases,
description: account.description,
})
}
}
}
+27
View File
@@ -0,0 +1,27 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use store::Store;
pub mod config;
pub mod lookup;
pub struct SqlDirectory {
sql_store: Store,
mappings: SqlMappings,
}
#[derive(Debug, Default)]
pub(crate) struct SqlMappings {
query_login: String,
query_recipient: String,
query_member_of: Option<String>,
query_email_aliases: Option<String>,
column_email: String,
column_secret: String,
column_type: Option<String>,
column_description: Option<String>,
}