Registry testing - part 16

This commit is contained in:
Maurus Decimus
2026-03-26 19:10:06 +01:00
parent 0fde3e04fd
commit 72bc8c05ab
23 changed files with 1100 additions and 2819 deletions

View File

@@ -33,12 +33,13 @@ futures = "0.3"
regex = "1.7.0"
serde = { version = "1.0", features = ["derive"]}
totp-rs = { version = "5.5.1", features = ["otpauth"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-webpki-roots", "http2"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-webpki-roots", "http2"] } # Use aws
serde_json = "1.0"
base64 = "0.22"
rkyv = { version = "0.8.10", features = ["little_endian"] }
compact_str = { version = "0.9.0", features = ["rkyv", "serde"] }
nohash-hasher = "0.2.0"
jsonwebtoken = { version = "10.3.0", features = ["rust_crypto"] }
[dev-dependencies]
tokio = { version = "1.47", features = ["full"] }

View File

@@ -5,7 +5,7 @@
*/
use super::{Bind, LdapConnectionManager, LdapDirectory, LdapFilter, LdapFilterItem, LdapMappings};
use crate::{Directory, backend::ldap::AuthBind};
use crate::Directory;
use deadpool::{Runtime, managed::Pool};
use ldap3::LdapConnSettings;
use registry::schema::structs;
@@ -42,13 +42,53 @@ impl LdapDirectory {
base_dn: config.base_dn,
filter_login: LdapFilter::new(&config.filter_login)?,
filter_mailbox: LdapFilter::new(&config.filter_mailbox)?,
attr_class: config.attr_class.into_inner(),
attr_groups: config.attr_groups.into_inner(),
attr_description: config.attr_description.into_inner(),
attr_secret: config.attr_secret.into_inner(),
attr_secret_changed: config.attr_secret_changed.into_inner(),
attr_email: config.attr_email.into_inner(),
attr_email_alias: config.attr_email_alias.into_inner(),
filter_member_of: if let Some(filter) = config.filter_member_of {
Some(LdapFilter::new(&filter)?)
} else {
None
},
attr_class: config
.attr_class
.into_inner()
.into_iter()
.map(|a| a.to_lowercase())
.collect(),
attr_groups: config
.attr_member_of
.into_inner()
.into_iter()
.map(|a| a.to_lowercase())
.collect(),
attr_description: config
.attr_description
.into_inner()
.into_iter()
.map(|a| a.to_lowercase())
.collect(),
attr_secret: config
.attr_secret
.into_inner()
.into_iter()
.map(|a| a.to_lowercase())
.collect(),
attr_secret_changed: config
.attr_secret_changed
.into_inner()
.into_iter()
.map(|a| a.to_lowercase())
.collect(),
attr_email: config
.attr_email
.into_inner()
.into_iter()
.map(|a| a.to_lowercase())
.collect(),
attr_email_alias: config
.attr_email_alias
.into_inner()
.into_iter()
.map(|a| a.to_lowercase())
.collect(),
group_class: config.group_class,
attrs_principal: vec![],
};
@@ -67,20 +107,6 @@ impl LdapDirectory {
.extend(attr.iter().filter(|a| !a.is_empty()).cloned());
}
let auth_bind = match config.password_verification {
structs::LdapPasswordVerification::Local => AuthBind::None,
structs::LdapPasswordVerification::Bind(bind) => {
if let Some(template) = bind.bind_auth_template {
AuthBind::BindTemplate {
template: LdapFilter::new(&template)?,
can_search: bind.bind_auth_search,
}
} else {
AuthBind::Bind
}
}
};
let pool = Pool::builder(manager)
.runtime(Runtime::Tokio1)
.max_size(config.pool_max_connections as usize)
@@ -93,7 +119,7 @@ impl LdapDirectory {
Ok(Directory::Ldap(LdapDirectory {
mappings,
pool,
auth_bind,
auth_bind: config.bind_authentication,
}))
}
}

View File

@@ -5,10 +5,7 @@
*/
use super::{LdapDirectory, LdapMappings};
use crate::{
Account, Credentials, Group, IntoError, Recipient, backend::ldap::AuthBind,
core::secret::verify_secret_hash,
};
use crate::{Account, Credentials, Group, IntoError, Recipient, core::secret::verify_secret_hash};
use ldap3::{Ldap, LdapConnAsync, ResultEntry, Scope, SearchEntry};
use store::xxhash_rust;
use utils::sanitize_email;
@@ -23,11 +20,10 @@ impl LdapDirectory {
};
let mut conn = self.pool.get().await.map_err(|err| err.into_error())?;
let mut account = match &self.auth_bind {
AuthBind::BindTemplate {
template,
can_search,
} => {
let mut result = if self.auth_bind {
let filter = self.mappings.filter_login.build(username);
if let Some(mut result) = self.find_object(&mut conn, &filter).await? {
// Perform bind auth using the found dn
let (auth_bind_conn, mut ldap) = LdapConnAsync::with_settings(
self.pool.manager().settings.clone(),
&self.pool.manager().address,
@@ -37,127 +33,95 @@ impl LdapDirectory {
ldap3::drive!(auth_bind_conn);
let dn = template.build(username);
if ldap
.simple_bind(&dn, secret)
.simple_bind(&result.dn, secret)
.await
.map_err(|err| err.into_error().caused_by(trc::location!()))?
.success()
.is_err()
.is_ok()
{
return Err(trc::AuthEvent::Failed
.into_err()
.details("Invalid credentials for auth bind using template")
.details(dn));
}
let filter = self.mappings.filter_login.build(username);
let result = if *can_search {
self.find_object(&mut ldap, &filter).await
} else {
self.find_object(&mut conn, &filter).await
};
match result {
Ok(Some(mut result)) => {
if result.account.email.is_empty() {
result.account.email =
sanitize_email(username).unwrap_or_else(|| username.to_lowercase());
}
result.account
}
Err(err)
if err.matches(trc::EventType::Store(trc::StoreEvent::LdapError))
&& err
.value(trc::Key::Code)
.and_then(|v| v.to_uint())
.is_some_and(|rc| [49, 50].contains(&rc)) =>
{
return Err(trc::AuthEvent::Failed
.into_err()
.details("Error codes 49 or 50 returned by LDAP server")
.details(vec![dn, filter]));
}
Ok(None) => {
return Err(trc::AuthEvent::Failed
.into_err()
.details("Auth bind successful but filter yielded no results")
.details(vec![dn, filter]));
}
Err(err) => return Err(err),
}
}
AuthBind::Bind => {
let filter = self.mappings.filter_login.build(username);
if let Some(mut result) = self.find_object(&mut conn, &filter).await? {
// Perform bind auth using the found dn
let (auth_bind_conn, mut ldap) = LdapConnAsync::with_settings(
self.pool.manager().settings.clone(),
&self.pool.manager().address,
)
.await
.map_err(|err| err.into_error().caused_by(trc::location!()))?;
ldap3::drive!(auth_bind_conn);
if ldap
.simple_bind(&result.dn, secret)
.await
.map_err(|err| err.into_error().caused_by(trc::location!()))?
.success()
.is_ok()
{
if result.account.email.is_empty() {
result.account.email =
sanitize_email(username).unwrap_or_else(|| username.to_lowercase());
}
result.account
} else {
return Err(trc::AuthEvent::Failed
.into_err()
.details("Secret rejected during auth bind using lookup filter")
.details(vec![result.dn, filter]));
}
} else {
return Err(trc::AuthEvent::Failed
.into_err()
.details("Auth bind lookup filter yielded no results")
.details(vec![filter]));
}
}
AuthBind::None => {
let filter = self.mappings.filter_login.build(username);
if let Some(mut result) = self.find_object(&mut conn, &filter).await? {
if let Some(account_secret) = &result.account.secret {
if !verify_secret_hash(account_secret, secret.as_bytes()).await? {
return Err(trc::AuthEvent::Failed
.into_err()
.details("Invalid credentials")
.details(vec![filter]));
}
} else {
return Err(trc::AuthEvent::Error
.into_err()
.details("Account does not have a secret")
.details(vec![filter]));
}
if result.account.email.is_empty() {
result.account.email =
sanitize_email(username).unwrap_or_else(|| username.to_lowercase());
}
result.account
result
} else {
return Err(trc::AuthEvent::Failed
.into_err()
.details("Authentication filter yielded no results")
.details("Secret rejected during auth bind using lookup filter")
.details(vec![result.dn, filter]));
}
} else {
return Err(trc::AuthEvent::Failed
.into_err()
.details("Auth bind lookup filter yielded no results")
.details(vec![filter]));
}
} else {
let filter = self.mappings.filter_login.build(username);
if let Some(mut result) = self.find_object(&mut conn, &filter).await? {
if let Some(account_secret) = &result.account.secret {
if !verify_secret_hash(account_secret, secret.as_bytes()).await? {
return Err(trc::AuthEvent::Failed
.into_err()
.details("Invalid credentials")
.details(vec![filter]));
}
} else {
return Err(trc::AuthEvent::Error
.into_err()
.details("Account does not have a secret")
.details(vec![filter]));
}
if result.account.email.is_empty() {
result.account.email =
sanitize_email(username).unwrap_or_else(|| username.to_lowercase());
}
result
} else {
return Err(trc::AuthEvent::Failed
.into_err()
.details("Authentication filter yielded no results")
.details(vec![filter]));
}
};
if !account.groups.is_empty() {
for name in std::mem::take(&mut account.groups)
self.add_group_membership(&mut conn, &mut result).await?;
Ok(result.account)
}
pub async fn recipient(&self, address: &str) -> trc::Result<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 !result.account.groups.is_empty() {
for name in std::mem::take(&mut result.account.groups)
.into_iter()
.filter(|name| name.contains('='))
{
@@ -178,69 +142,48 @@ impl LdapDirectory {
&& let Some(email) =
value.first().map(|s| s.as_str()).and_then(sanitize_email)
{
account.groups.push(email);
result.account.groups.push(email);
break 'outer;
}
}
}
}
}
Ok(account)
}
pub async fn recipient(&self, address: &str) -> trc::Result<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(result) = self.find_object(&mut conn, &filter).await? {
let mut account = result.account;
if !account.groups.is_empty() {
for name in std::mem::take(&mut account.groups)
.into_iter()
.filter(|name| name.contains('='))
{
let (rs, _res) = conn
.search(
&name,
Scope::Base,
"objectClass=*",
&self.mappings.attr_email,
)
.await
.map_err(|err| err.into_error().caused_by(trc::location!()))?
.success()
.map_err(|err| err.into_error().caused_by(trc::location!()))?;
for entry in rs {
'outer: for (attr, value) in SearchEntry::construct(entry).attrs {
if self.mappings.attr_email.contains(&attr.to_lowercase())
&& let Some(email) =
value.first().map(|s| s.as_str()).and_then(sanitize_email)
{
account.groups.push(email);
break 'outer;
}
}
} else if let Some(filter) = &self.mappings.filter_member_of {
let filter = filter.build(&result.dn);
let rs = conn
.search(
&self.mappings.base_dn,
Scope::Subtree,
&filter,
&self.mappings.attr_email,
)
.await
.map_err(|err| err.into_error().caused_by(trc::location!()))?
.success()
.map_err(|err| err.into_error().caused_by(trc::location!()))?
.0;
for entry in rs {
for (attr, value) in SearchEntry::construct(entry).attrs {
if self.mappings.attr_email.contains(&attr.to_lowercase()) {
result
.account
.groups
.extend(value.into_iter().filter_map(|v| {
sanitize_email(&v).or_else(|| {
trc::event!(
Store(trc::StoreEvent::LdapWarning),
Reason = "Group entry missing valid email attribute",
Details = v
);
None
})
}));
}
}
}
if result.is_group {
Ok(Recipient::Group(Group {
email: account.email,
email_aliases: account.email_aliases,
description: account.description,
}))
} else {
Ok(Recipient::Account(account))
}
} else {
trc::event!(
Store(trc::StoreEvent::LdapWarning),
Reason = "Mailbox filter yielded no results",
Details = filter
);
Ok(Recipient::Invalid)
}
Ok(())
}
}

View File

@@ -11,19 +11,10 @@ pub mod config;
pub mod lookup;
pub mod pool;
pub(crate) enum AuthBind {
Bind,
BindTemplate {
template: LdapFilter,
can_search: bool,
},
None,
}
pub struct LdapDirectory {
pool: Pool<LdapConnectionManager>,
mappings: LdapMappings,
auth_bind: AuthBind,
auth_bind: bool,
}
#[derive(Debug, Default)]
@@ -31,6 +22,7 @@ pub struct LdapMappings {
base_dn: String,
filter_login: LdapFilter,
filter_mailbox: LdapFilter,
filter_member_of: Option<LdapFilter>,
attr_class: Vec<String>,
attr_groups: Vec<String>,
attr_description: Vec<String>,

View File

@@ -4,47 +4,139 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::OpenIdDirectory;
use crate::Directory;
use crate::backend::oidc::lookup::fetch_jwks_keys;
use crate::backend::oidc::{
DiscoveryDocument, JwksCache, OidcError, OpenIdConfig, OpenIdDirectory,
};
use registry::schema::structs;
use reqwest::Client;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use trc::AuthEvent;
impl OpenIdDirectory {
pub async fn open(config: structs::OidcDirectory) -> Result<Directory, String> {
Ok(Directory::OpenId(match config {
structs::OidcDirectory::UserInfo(config) => OpenIdDirectory::UserInfo {
endpoint: config.endpoint,
timeout: config.timeout.into_inner(),
allow_invalid_certs: config.allow_invalid_certs,
claim_email: config.claim_email,
claim_name: config.claim_name,
},
structs::OidcDirectory::Introspect(config) => {
let client = config
.http_auth
.build_http_client(
config.http_headers,
None,
config.timeout,
config.allow_invalid_certs,
)
.await?;
OpenIdDirectory::Introspect {
client,
endpoint: config.endpoint,
claim_email: config.claim_email,
claim_name: config.claim_name,
require_aud: config.require_audience,
require_scopes: config.require_scopes.into_inner(),
Self::new(OpenIdConfig {
issue_url: config.issuer_url,
require_aud: config.require_audience,
require_scopes: config.require_scopes.into_inner(),
claim_email: config.claim_username,
claim_name: config.claim_name,
claim_groups: config.claim_groups,
default_domain: config.username_domain,
})
.await
.map(Directory::OpenId)
.map_err(|err| err.to_string())
}
pub async fn new(config: OpenIdConfig) -> Result<Self, OidcError> {
let http = Client::builder()
.user_agent("Stalwart/1.0")
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| OidcError::Network(format!("HTTP client build failed: {e}")))?;
let discovery_url = format!(
"{}/.well-known/openid-configuration",
config.issue_url.trim_end_matches('/')
);
let discovery_bytes = http
.get(&discovery_url)
.send()
.await
.map_err(|e| OidcError::Network(format!("Discovery fetch failed: {e}")))?
.error_for_status()
.map_err(|e| OidcError::Provider(format!("Discovery HTTP error: {e}")))?
.bytes()
.await
.map_err(|e| OidcError::Provider(format!("Discovery HTTP error: {e}")))?;
let discovery: DiscoveryDocument = serde_json::from_slice(&discovery_bytes)
.map_err(|e| OidcError::Provider(format!("Discovery JSON parse error: {e}")))?;
let normalised_issue = config.issue_url.trim_end_matches('/');
let normalised_issuer = discovery.issuer.trim_end_matches('/');
if normalised_issuer != normalised_issue {
return Err(OidcError::Provider(format!(
"Issuer mismatch: discovery document says '{}' but configured issue_url is '{}'",
discovery.issuer, config.issue_url,
)));
}
if let Some(supported) = &discovery.scopes_supported {
for scope in &config.require_scopes {
if !supported.contains(scope) {
trc::event!(
Auth(AuthEvent::Warning),
Url = config.issue_url.to_string(),
Reason = format!(
"Required scope '{}' is not in scopes_supported from the IdP",
scope
)
);
}
}
structs::OidcDirectory::Jwt(config) => OpenIdDirectory::Jwt {
jwks_url: config.jwks_url,
jwks_cache: config.jwks_cache_duration.into_inner(),
claim_email: config.claim_email,
claim_name: config.claim_name,
require_aud: config.require_audience,
require_iss: config.require_issuer,
},
}))
}
if let Some(supported) = &discovery.claims_supported {
let check = |name: &str, label: &str| {
if !supported.iter().any(|c| c == name) {
trc::event!(
Auth(AuthEvent::Warning),
Url = config.issue_url.to_string(),
Reason = format!(
"Configured {} claim '{}' is not in claims_supported from the IdP",
label, name
)
);
}
};
check(&config.claim_email, "claim_email");
if let Some(n) = &config.claim_name {
check(n, "claim_name");
}
if let Some(g) = &config.claim_groups {
check(g, "claim_groups");
}
}
/*{
let cache = Arc::clone(&cache);
let http = http.clone();
let jwks_uri = discovery.jwks_uri.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(24 * 3600));
interval.tick().await;
loop {
interval.tick().await;
match fetch_jwks_keys(&http, &jwks_uri).await {
Ok(new_keys) => {
let mut guard = cache.write().await;
guard.keys = new_keys;
guard.last_updated = Instant::now();
}
Err(e) => {
trc::event!(
Auth(AuthEvent::Warning),
Url = jwks_uri.to_string(),
Reason = format!("Background JWKS refresh failed: {e}")
);
}
}
}
});
}*/
let cache = RwLock::new(JwksCache {
keys: fetch_jwks_keys(&http, &discovery.jwks_uri).await?,
last_updated: Instant::now(),
});
Ok(Self {
config,
discovery,
http,
cache,
})
}
}

View File

@@ -4,203 +4,393 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::OpenIdDirectory;
use crate::{Account, Credentials};
use ahash::HashMap;
use reqwest::{RequestBuilder, StatusCode};
use crate::{
Account, Credentials,
backend::oidc::{CachedKey, OidcError, OpenIdDirectory},
};
use ahash::AHashMap;
use jsonwebtoken::{
Algorithm, DecodingKey, Validation, decode, decode_header,
jwk::{self, JwkSet},
};
use reqwest::Client;
use std::time::Instant;
use std::{sync::Arc, time::Duration};
use trc::AuthEvent;
use utils::sanitize_email;
type OpenIdResponse = HashMap<String, serde_json::Value>;
impl OpenIdDirectory {
pub async fn authenticate(&self, credentials: &Credentials) -> trc::Result<Account> {
let token = match credentials {
Credentials::Bearer { token, .. } => token,
_ => {
return Err(AuthEvent::Error
.into_err()
.details("Unsupported credentials type for OIDC backend"));
}
};
let email;
let name;
let aud;
let iss;
let scopes;
let response = match self {
OpenIdDirectory::Introspect {
client,
endpoint,
claim_email,
claim_name,
require_aud,
require_scopes,
} => {
email = claim_email;
name = claim_name;
aud = require_aud;
scopes = require_scopes.as_slice();
iss = &None;
send_request(client.post(endpoint).form(&[
("token", token.as_str()),
("token_type_hint", "access_token"),
]))
.await?
}
OpenIdDirectory::UserInfo {
endpoint,
timeout,
allow_invalid_certs,
claim_email,
claim_name,
} => {
let client = reqwest::Client::builder()
.danger_accept_invalid_certs(*allow_invalid_certs)
.timeout(*timeout)
.build()
.map_err(|err| {
AuthEvent::Error
.into_err()
.reason(err)
.details("Failed to build client")
})?;
email = claim_email;
name = claim_name;
aud = &None;
iss = &None;
scopes = &[];
send_request(client.get(endpoint).bearer_auth(token)).await?
}
OpenIdDirectory::Jwt {
jwks_url,
jwks_cache,
claim_email,
claim_name,
require_aud,
require_iss,
} => {
email = claim_email;
name = claim_name;
aud = require_aud;
iss = require_iss;
scopes = &[];
todo!()
}
};
let mut account = Account::default();
let mut aud_matched = aud.is_none();
let mut iss_matched = iss.is_none();
let mut scopes_unmatched = scopes.len();
for (field, value) in response {
let serde_json::Value::String(value) = value else {
continue;
};
if email == &field {
if let Some(sanitized_email) = sanitize_email(&value) {
account.email = sanitized_email;
}
} else if let Some(name_field) = name
&& name_field == &field
{
account.description = Some(value);
} else if !aud_matched
&& let Some(required_aud) = aud
&& field == "aud"
{
if value == *required_aud {
aud_matched = true;
match credentials {
Credentials::Bearer { token, .. } => {
if token.chars().filter(|&c| c == '.').count() == 2 {
self.authenticate_jwt(token).await
} else {
return Err(AuthEvent::Failed
.into_err()
.details("Audience claim does not match"));
#[cfg(feature = "test_mode")]
let token = token.strip_prefix(".").unwrap_or(token);
self.authenticate_opaque(token).await
}
} else if !iss_matched
&& let Some(required_iss) = iss
&& field == "iss"
{
if value == *required_iss {
iss_matched = true;
} else {
return Err(AuthEvent::Failed
.into_err()
.details("Issuer claim does not match"));
}
} else if scopes_unmatched > 0 && field == "scope" {
for scope in value.split_whitespace() {
if scopes.iter().any(|required_scope| required_scope == &scope) {
scopes_unmatched -= 1;
if scopes_unmatched == 0 {
break;
}
.map_err(|err| match err {
OidcError::AuthorizationFailed(reason) => {
AuthEvent::Failed.into_err().reason(reason)
}
err => AuthEvent::Error.into_err().reason(err),
})
}
_ => Err(AuthEvent::Error
.into_err()
.reason("Unsupported credentials type for OIDC backend")),
}
}
async fn authenticate_jwt(&self, token: &str) -> Result<Account, OidcError> {
let header = decode_header(token)
.map_err(|e| OidcError::TokenValidation(format!("Failed to decode JWT header: {e}")))?;
match header.alg {
Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512 => {
return Err(OidcError::TokenValidation(
"Unsupported algorithm".to_string(),
));
}
_ => {}
}
let candidates = self.get_key(header.kid.as_deref()).await?;
let mut last_err = None;
for cached in &candidates {
let dk = &cached.decoding_key;
let alg = cached.algorithm;
let mut validation = Validation::new(alg);
if let Some(aud) = &self.config.require_aud {
validation.set_audience(&[aud]);
} else {
validation.validate_aud = false;
}
validation.set_issuer(&[&self.discovery.issuer]);
validation.leeway = 60;
match decode::<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)?;
return self.build_account(&token_data.claims);
}
Err(e) => {
last_err = Some(e);
}
}
}
if !aud_matched {
Err(AuthEvent::Error
.into_err()
.details("Audience claim not found in OIDC response"))
} else if !iss_matched {
Err(AuthEvent::Error
.into_err()
.details("Issuer claim not found in OIDC response"))
} else if scopes_unmatched > 0 {
Err(AuthEvent::Error
.into_err()
.details("One or more required scopes not found in OIDC response"))
} else if !account.email.is_empty() {
Ok(account)
Err(OidcError::TokenValidation(format!(
"JWT validation failed: {}",
last_err.map(|e| e.to_string()).unwrap_or_default()
)))
}
async fn authenticate_opaque(&self, token: &str) -> Result<Account, OidcError> {
let claims = self.fetch_userinfo(token).await?;
self.build_account(&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.jwks_uri).await?;
{
let mut guard = self.cache.write().await;
guard.keys = new_keys;
guard.last_updated = Instant::now();
}
let guard = self.cache.read().await;
if let Some(kid) = kid {
if let Some(cached) = guard.keys.get(kid) {
Ok(vec![cached.clone()])
} else {
Err(OidcError::TokenValidation(
"Unknown key id after refresh".to_string(),
))
}
} else {
Err(trc::AuthEvent::Error
.into_err()
.details("Email claim not found in OIDC response"))
let all: Vec<_> = guard.keys.values().cloned().collect();
if all.is_empty() {
Err(OidcError::Provider(
"JWKS contains no usable keys".to_string(),
))
} else {
Ok(all)
}
}
}
async fn fetch_userinfo(&self, token: &str) -> Result<serde_json::Value, OidcError> {
let resp = self
.http
.get(&self.discovery.userinfo_endpoint)
.bearer_auth(token)
.send()
.await
.map_err(|e| OidcError::Network(format!("UserInfo request failed: {e}")))?;
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
let reason = resp.text().await.unwrap_or_default();
return Err(OidcError::AuthorizationFailed(format!(
"Token rejected by UserInfo endpoint with status {status}: {reason}"
)));
}
if !status.is_success() {
return Err(OidcError::Provider(format!(
"UserInfo returned HTTP {status}"
)));
}
let bytes = resp
.bytes()
.await
.map_err(|e| OidcError::Provider(format!("UserInfo HTTP error: {e}")))?;
serde_json::from_slice::<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, claims: &serde_json::Value) -> Result<Account, OidcError> {
let email = self.resolve_email(claims)?;
let description = self
.config
.claim_name
.as_ref()
.and_then(|name_claim| claims.get(name_claim))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let groups = self
.config
.claim_groups
.as_ref()
.and_then(|groups_claim| claims.get(groups_claim))
.map(extract_string_list)
.unwrap_or_default();
Ok(Account {
email,
email_aliases: Vec::new(),
secret: None,
groups,
description,
})
}
fn resolve_email(&self, claims: &serde_json::Value) -> Result<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(),
))
}
}
async fn send_request(request: RequestBuilder) -> trc::Result<OpenIdResponse> {
let response = request.send().await.map_err(|err| {
AuthEvent::Error
.into_err()
.reason(err)
.details("OIDC HTTP request failed")
})?;
pub(super) async fn fetch_jwks_keys(
http: &Client,
jwks_uri: &str,
) -> Result<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}")))?;
match response.status() {
StatusCode::OK => {
// Fetch response
let response = response.bytes().await.map_err(|err| {
AuthEvent::Error
.into_err()
.reason(err)
.details("Failed to read OIDC response")
})?;
let mut map = AHashMap::new();
let mut synthetic_id: u64 = 0;
let todo = "deserialize directly into string, not serde_json::Value";
for key in &jwks.keys {
if let Some(pk_use) = &key.common.public_key_use
&& pk_use != &jwk::PublicKeyUse::Signature
{
continue;
}
// Deserialize response
serde_json::from_slice::<OpenIdResponse>(&response).map_err(|err| {
AuthEvent::Error
.into_err()
.reason(err)
.details("Failed to deserialize OIDC response")
let algorithm = match &key.algorithm {
jwk::AlgorithmParameters::RSA(_) => match key.common.key_algorithm {
Some(jwk::KeyAlgorithm::RS256) => Algorithm::RS256,
Some(jwk::KeyAlgorithm::RS384) => Algorithm::RS384,
Some(jwk::KeyAlgorithm::RS512) => Algorithm::RS512,
Some(jwk::KeyAlgorithm::PS256) => Algorithm::PS256,
Some(jwk::KeyAlgorithm::PS384) => Algorithm::PS384,
Some(jwk::KeyAlgorithm::PS512) => Algorithm::PS512,
None => Algorithm::RS256,
Some(other) => {
trc::event!(
Auth(AuthEvent::Warning),
Url = jwks_uri.to_string(),
Reason = format!("Unsupported RSA key algorithm {:?}", other)
);
continue;
}
},
jwk::AlgorithmParameters::EllipticCurve(ec) => match ec.curve {
jwk::EllipticCurve::P256 => Algorithm::ES256,
jwk::EllipticCurve::P384 => Algorithm::ES384,
_ => {
trc::event!(
Auth(AuthEvent::Warning),
Url = jwks_uri.to_string(),
Reason = format!("Unsupported EC curve {:?}", ec.curve)
);
continue;
}
},
jwk::AlgorithmParameters::OctetKeyPair(_) => Algorithm::EdDSA,
jwk::AlgorithmParameters::OctetKey(_) => {
trc::event!(
Auth(AuthEvent::Warning),
Url = jwks_uri.to_string(),
Reason = format!(
"Symmetric (HMAC) key found in JWKS (kid={:?}), skipping — HMAC is not accepted",
key.common.key_id
)
);
continue;
}
};
if matches!(
algorithm,
Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512
) {
trc::event!(
Auth(AuthEvent::Warning),
Url = jwks_uri.to_string(),
Reason = format!(
"HMAC algorithm {:?} in JWKS (kid={:?}) is not accepted, skipping",
algorithm, key.common.key_id
)
);
continue;
}
let decoding_key = DecodingKey::from_jwk(key)
.map_err(|e| {
trc::event!(
Auth(AuthEvent::Warning),
Url = jwks_uri.to_string(),
Reason = format!(
"Failed to build DecodingKey from JWK (kid={:?}): {e}",
key.common.key_id
)
);
})
}
StatusCode::UNAUTHORIZED => Err(trc::AuthEvent::Failed
.into_err()
.code(401)
.details("Unauthorized")),
other => Err(trc::AuthEvent::Error
.into_err()
.code(other.as_u16())
.ctx(trc::Key::Reason, response.text().await.unwrap_or_default())
.details("Unexpected status code")),
.ok();
let decoding_key = match decoding_key {
Some(dk) => dk,
None => continue,
};
let kid = match &key.common.key_id {
Some(id) => id.clone(),
None => {
let id = format!("_synthetic_{synthetic_id}");
synthetic_id += 1;
id
}
};
map.insert(
kid,
CachedKey {
decoding_key,
algorithm,
}
.into(),
);
}
Ok(map)
}
fn extract_scopes(claims: &serde_json::Value) -> Vec<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) -> Vec<String> {
match value {
serde_json::Value::Array(arr) => arr
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect(),
serde_json::Value::String(s) => s.split_whitespace().map(|s| s.to_string()).collect(),
_ => Vec::new(),
}
}

View File

@@ -4,33 +4,70 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use ahash::AHashMap;
use jsonwebtoken::{Algorithm, DecodingKey};
use serde::Deserialize;
use std::{fmt, sync::Arc, time::Instant};
use tokio::sync::RwLock;
use utils::Client;
pub mod config;
pub mod lookup;
use std::time::Duration;
pub enum OpenIdDirectory {
Introspect {
client: reqwest::Client,
endpoint: String,
claim_email: String,
claim_name: Option<String>,
require_aud: Option<String>,
require_scopes: Vec<String>,
},
UserInfo {
endpoint: String,
timeout: Duration,
allow_invalid_certs: bool,
claim_email: String,
claim_name: Option<String>,
},
Jwt {
jwks_url: String,
jwks_cache: Duration,
claim_email: String,
claim_name: Option<String>,
require_aud: Option<String>,
require_iss: Option<String>,
},
pub struct OpenIdConfig {
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>,
}
#[derive(Deserialize)]
pub struct DiscoveryDocument {
issuer: String,
jwks_uri: String,
pub userinfo_endpoint: String,
pub authorization_endpoint: String,
scopes_supported: Option<Vec<String>>,
claims_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: OpenIdConfig,
pub discovery: DiscoveryDocument,
http: Client,
cache: RwLock<JwksCache>,
}
#[derive(Debug)]
pub enum OidcError {
TokenValidation(String),
AuthorizationFailed(String),
Network(String),
Provider(String),
}
impl fmt::Display for OidcError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
OidcError::TokenValidation(msg) => write!(f, "Token validation error: {msg}"),
OidcError::AuthorizationFailed(msg) => write!(f, "Authorization failed: {msg}"),
OidcError::Network(msg) => write!(f, "Network error: {msg}"),
OidcError::Provider(msg) => write!(f, "Provider error: {msg}"),
}
}
}
impl std::error::Error for OidcError {}

View File

@@ -35,7 +35,11 @@ impl Directory {
}
pub fn oidc_authorization_endpoint(&self) -> Option<String> {
let todo = "implement";
None
match &self {
Directory::OpenId(directory) => {
Some(directory.discovery.authorization_endpoint.clone())
}
_ => None,
}
}
}

View File

@@ -62,7 +62,7 @@ impl ResolveVariable for HttpContext<'_> {
}
ExpressionVariable::Listener => self.session.instance.id.as_str().into(),
ExpressionVariable::Url => self.req.uri().to_compact_string().into(),
ExpressionVariable::UrlPath => self.req.uri().path().into(),
ExpressionVariable::Path => self.req.uri().path().into(),
ExpressionVariable::Method => self.req.method().as_str().into(),
ExpressionVariable::Headers => self
.req

View File

@@ -275,6 +275,8 @@ async fn fetch_spam_rules(server: &Server) -> Result<Rules, RuleUpdateError> {
})
})?;
let todo = "trigger task to reload settings and lookup stores";
let mut rules = Rules::default();
for (object_type, values) in rules_json {
let Some(object_type) = ObjectType::parse(&object_type) else {

View File

@@ -427,7 +427,7 @@ impl ResolveVariable for UrlParts<'_> {
.and_then(|p| p.parts.path_and_query().map(|p| p.as_str()))
.unwrap_or_default(),
),
ExpressionVariable::UrlPath => Variable::from(
ExpressionVariable::Path => Variable::from(
self.url_parsed
.as_ref()
.map(|p| p.parts.path())