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
+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 {}