Include OAuth public endpoint in PACC responses

This commit is contained in:
Maurus Decimus
2026-04-19 14:35:06 +02:00
parent 47d21dd03d
commit eaf0601742
22 changed files with 791 additions and 2630 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -238,6 +238,10 @@ impl Server {
linked_object.id().document_id(),
));
}
// SPDX-SnippetBegin
// SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
// SPDX-License-Identifier: LicenseRef-SEL
#[cfg(feature = "enterprise")]
ObjectType::Tenant => {
// Invalidate all accounts of the tenant
let tenant_id = linked_object.id().document_id();
@@ -253,6 +257,7 @@ impl Server {
changes.insert(CacheInvalidation::AccessToken(account_id));
}
}
// SPDX-SnippetEnd
ObjectType::Role => {
role_ids.push(linked_object.id().document_id());
}

View File

@@ -43,11 +43,17 @@ pub struct Network {
#[derive(Clone)]
pub struct NetworkInfo {
pub pacc: String,
pub pacc: Pacc,
pub mxs: Vec<MailExchanger>,
pub services: VecMap<ServiceProtocol, Service>,
}
#[derive(Clone)]
pub struct Pacc {
pub prefix: String,
pub suffix: String,
}
#[derive(Clone)]
pub struct Http {
pub rate_authenticated: Option<Rate>,
@@ -172,10 +178,13 @@ impl Network {
has_acme_tls_challenge = true;
}
const SPLIT_HERE: &str = "$$__SPLIT_HERE__$$";
let mut pacc = Configuration {
protocols: Protocols::default(),
authentication: Some(Authentication {
oauth_public: None,
oauth_public: Some(OAuthPublic {
issuer: SPLIT_HERE.to_string(),
}),
password: true,
}),
info: Info {
@@ -307,6 +316,11 @@ impl Network {
}
}
let (prefix, suffix) = serde_json::to_string(&pacc)
.unwrap_or_default()
.rsplit_once(SPLIT_HERE)
.map(|(prefix, suffix)| (prefix.to_string(), suffix.to_string()))
.unwrap();
let mut network = Network {
node_id: bp.node_id() as u64,
server_name: system.default_hostname,
@@ -321,7 +335,7 @@ impl Network {
info: NetworkInfo {
mxs: system.mail_exchangers.into_iter().collect(),
services: system.services,
pacc: serde_json::to_string(&pacc).unwrap_or_default(),
pacc: Pacc { prefix, suffix },
},
};

View File

@@ -19,6 +19,7 @@ use registry::schema::{
use reqwest::Url;
use sha2::{Digest, Sha256};
use store::registry::RegistryQuery;
use trc::AddContext;
use types::id::Id;
use x509_parser::parse_x509_certificate;
@@ -132,7 +133,7 @@ impl Server {
}
}
DnsRecordType::AutoConfig => {
let pacc_digest = Sha256::digest(&network.info.pacc);
let pacc_digest = Sha256::digest(&self.get_pacc_for_fomain(domain_name).await?);
let pacc_digest_encoded = general_purpose::STANDARD.encode(pacc_digest);
records.push(NamedDnsRecord {
@@ -377,6 +378,21 @@ impl Server {
.await
.map(|records| BindSerializer::serialize(&records))
}
pub async fn get_pacc_for_fomain(&self, domain_name: &str) -> trc::Result<String> {
self.get_directory_for_domain(domain_name)
.await
.caused_by(trc::location!())
.map(|directory| {
directory
.and_then(|directory| {
directory
.oidc_discovery_document()
.map(|doc| doc.url.to_string())
})
.unwrap_or_else(|| self.core.network.http.url_https.clone())
})
}
}
#[inline(always)]

View File

@@ -102,6 +102,7 @@ impl DnsUpdater {
}
};
#[allow(deprecated)]
Ok(DnsUpdater {
polling_interval: server.polling_interval.into_inner(),
propagation_timeout: server.propagation_timeout.into_inner(),
@@ -266,13 +267,8 @@ impl DnsUpdater {
.map_err(|err| format!("Failed to build DNS updater: {}", err))?,
}),
DnsServer::Route53(server) => {
let secret_access_key =
server.secret_access_key.secret().await?.into_owned();
let session_token = server
.session_token
.secret()
.await?
.map(|c| c.into_owned());
let secret_access_key = server.secret_access_key.secret().await?.into_owned();
let session_token = server.session_token.secret().await?.map(|c| c.into_owned());
let config = dns_update::providers::route53::Route53Config {
access_key_id: server.access_key_id,
secret_access_key,
@@ -292,8 +288,7 @@ impl DnsUpdater {
})
}
DnsServer::GoogleCloudDns(server) => {
let service_account_json =
server.service_account_json.secret().await?.into_owned();
let service_account_json = server.service_account_json.secret().await?.into_owned();
let config = dns_update::providers::google_cloud_dns::GoogleCloudDnsConfig {
service_account_json,
project_id: server.project_id,

View File

@@ -30,7 +30,10 @@ impl Server {
.await
.add_context(|err| err.caused_by(trc::location!()).account_id(account_id))
}
// SPDX-SnippetBegin
// SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
// SPDX-License-Identifier: LicenseRef-SEL
#[cfg(feature = "enterprise")]
pub async fn get_used_quota_tenant(&self, tenant_id: u32) -> trc::Result<i64> {
self.core
.storage
@@ -39,6 +42,12 @@ impl Server {
.await
.add_context(|err| err.caused_by(trc::location!()))
}
// SPDX-SnippetEnd
#[cfg(not(feature = "enterprise"))]
pub async fn get_used_quota_tenant(&self, _tenant_id: u32) -> trc::Result<i64> {
Ok(0)
}
pub async fn has_available_quota(
&self,

View File

@@ -7,7 +7,7 @@
use crate::Directory;
use crate::backend::oidc::lookup::fetch_jwks_keys;
use crate::backend::oidc::{
DiscoveryDocument, JwksCache, OidcError, OpenIdConfig, OpenIdDirectory,
DiscoveryDocument, JwksCache, OidcConfig, OidcDiscovery, OidcError, OpenIdDirectory,
};
use registry::schema::structs;
use reqwest::Client;
@@ -17,7 +17,7 @@ use trc::AuthEvent;
impl OpenIdDirectory {
pub async fn open(config: structs::OidcDirectory) -> Result<Directory, String> {
Self::new(OpenIdConfig {
Self::new(OidcConfig {
issue_url: config.issuer_url,
require_aud: config.require_audience,
require_scopes: config.require_scopes.into_inner(),
@@ -31,7 +31,7 @@ impl OpenIdDirectory {
.map_err(|err| err.to_string())
}
pub async fn new(config: OpenIdConfig) -> Result<Self, OidcError> {
pub async fn new(config: OidcConfig) -> Result<Self, OidcError> {
let http = Client::builder()
.user_agent("Stalwart/1.0")
.timeout(Duration::from_secs(30))
@@ -133,8 +133,11 @@ impl OpenIdDirectory {
});
Ok(Self {
discovery: OidcDiscovery {
url: config.issue_url.clone(),
document: discovery,
},
config,
discovery,
http,
cache,
})

View File

@@ -63,7 +63,7 @@ impl OpenIdDirectory {
validation.validate_aud = false;
}
validation.set_issuer(&[&self.discovery.issuer]);
validation.set_issuer(&[&self.discovery.document.issuer]);
validation.leeway = 60;
match decode::<serde_json::Value>(token, dk, &validation) {
@@ -115,7 +115,7 @@ impl OpenIdDirectory {
}
}
let new_keys = fetch_jwks_keys(&self.http, &self.discovery.jwks_uri).await?;
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;
@@ -146,7 +146,7 @@ impl OpenIdDirectory {
async fn fetch_userinfo(&self, token: &str) -> Result<serde_json::Value, OidcError> {
let resp = self
.http
.get(&self.discovery.userinfo_endpoint)
.get(&self.discovery.document.userinfo_endpoint)
.bearer_auth(token)
.send()
.await

View File

@@ -14,7 +14,7 @@ use utils::Client;
pub mod config;
pub mod lookup;
pub struct OpenIdConfig {
pub struct OidcConfig {
pub issue_url: String,
pub require_aud: Option<String>,
pub require_scopes: Vec<String>,
@@ -24,6 +24,11 @@ pub struct OpenIdConfig {
pub default_domain: Option<String>,
}
pub struct OidcDiscovery {
pub url: String,
pub document: DiscoveryDocument,
}
#[derive(Deserialize, Serialize)]
pub struct DiscoveryDocument {
pub issuer: String,
@@ -52,8 +57,8 @@ struct JwksCache {
}
pub struct OpenIdDirectory {
config: OpenIdConfig,
pub discovery: DiscoveryDocument,
config: OidcConfig,
pub discovery: OidcDiscovery,
http: Client,
cache: RwLock<JwksCache>,
}

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{Account, Credentials, Directory, Recipient, backend::oidc::DiscoveryDocument};
use crate::{Account, Credentials, Directory, Recipient, backend::oidc::OidcDiscovery};
use trc::AddContext;
impl Directory {
@@ -34,7 +34,7 @@ impl Directory {
!matches!(self, Directory::OpenId(_))
}
pub fn oidc_discovery_document(&self) -> Option<&DiscoveryDocument> {
pub fn oidc_discovery_document(&self) -> Option<&OidcDiscovery> {
match &self {
Directory::OpenId(directory) => Some(&directory.discovery),
_ => None,

View File

@@ -28,6 +28,7 @@ pub enum Credentials {
},
}
#[allow(clippy::large_enum_variant)]
pub enum Directory {
Ldap(LdapDirectory),
Sql(SqlDirectory),

View File

@@ -133,7 +133,9 @@ impl OAuthApiHandler for Server {
.await?
.and_then(|directory| directory.oidc_discovery_document())
{
Ok(JsonResponse::new(endpoint).no_cache().into_http_response())
Ok(JsonResponse::new(&endpoint.document)
.no_cache()
.into_http_response())
} else {
self.handle_oidc_metadata(req, session).await
}

View File

@@ -311,7 +311,15 @@ impl ParseHttp for Server {
.await?;
return Ok(Resource::new(
"application/json",
self.core.network.info.pacc.clone().into_bytes(),
self.get_pacc_for_fomain(
req.headers()
.get(header::HOST)
.and_then(|h| h.to_str().ok())
.map(|h| h.rsplit_once(':').map_or(h, |(h, _)| h))
.unwrap_or_default(),
)
.await?
.into_bytes(),
)
.into_http_response());
}

View File

@@ -32,6 +32,7 @@ pub mod principal;
pub mod public_key;
pub mod queued_message;
pub mod report;
pub mod sieve;
pub mod spam_sample;
pub mod task;
pub mod tls;

View File

@@ -350,6 +350,10 @@ pub(crate) async fn validate_role(
}
}
// SPDX-SnippetBegin
// SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
// SPDX-License-Identifier: LicenseRef-SEL
#[cfg(feature = "enterprise")]
pub(crate) async fn validate_tenant_quota(
set: &RegistrySetResponse<'_>,
quota: TenantStorageQuota,
@@ -411,6 +415,15 @@ pub(crate) async fn validate_tenant_quota(
Ok(Ok(ObjectResponse::default()))
}
// SPDX-SnippetEnd
#[cfg(not(feature = "enterprise"))]
pub(crate) async fn validate_tenant_quota(
_set: &RegistrySetResponse<'_>,
_quota: TenantStorageQuota,
) -> ValidationResult {
ValidationResult::Ok(Ok(ObjectResponse::default()))
}
pub(crate) async fn schedule_account_destruction(
server: &Server,

View File

@@ -537,6 +537,10 @@ pub(crate) async fn queued_message_query(
}
}
// SPDX-SnippetBegin
// SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
// SPDX-License-Identifier: LicenseRef-SEL
#[cfg(feature = "enterprise")]
async fn tenant_domains(server: &Server, tenant_id: u32) -> trc::Result<AHashSet<String>> {
let domain_ids = server
.registry()
@@ -553,6 +557,12 @@ async fn tenant_domains(server: &Server, tenant_id: u32) -> trc::Result<AHashSet
Ok(domains)
}
// SPDX-SnippetEnd
#[cfg(not(feature = "enterprise"))]
async fn tenant_domains(_server: &Server, _tenant_id: u32) -> trc::Result<AHashSet<String>> {
Ok(AHashSet::new())
}
fn map_message(message_in: &ArchivedMessage) -> QueuedMessage {
let mut message_out = QueuedMessage {

View File

@@ -0,0 +1,49 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::registry::mapping::{ObjectResponse, ValidationResult};
use common::Server;
use jmap_proto::error::set::SetError;
use registry::schema::prelude::Property;
pub(crate) async fn validate_sieve_script(
server: &Server,
script: &str,
old_script: Option<&str>,
is_system_script: bool,
) -> ValidationResult {
if old_script.is_none_or(|old_script| old_script != script) {
if is_system_script {
if let Err(err) = server
.core
.sieve
.untrusted_compiler
.compile(script.as_bytes())
{
return Ok(Err(SetError::invalid_properties()
.with_property(Property::Contents)
.with_description(format!(
"Failed to compile system Sieve script: {err}"
))));
}
} else {
if let Err(err) = server
.core
.sieve
.untrusted_compiler
.compile(script.as_bytes())
{
return Ok(Err(SetError::invalid_properties()
.with_property(Property::Contents)
.with_description(format!(
"Failed to compile user Sieve script: {err}"
))));
}
}
}
Ok(Ok(ObjectResponse::default()))
}

View File

@@ -403,7 +403,7 @@ impl RegistryQueryFilters for QueryRequest<Registry> {
.unwrap_or_default()
.into_iter()
.next()
.unwrap_or_else(|| Comparator::ascending(RegistryComparator::Property(Property::Id)));
.unwrap_or_else(|| Comparator::descending(RegistryComparator::Property(Property::Id)));
match comparator.property {
RegistryComparator::Property(property) => {

View File

@@ -21,6 +21,7 @@ use crate::registry::{
public_key::validate_public_key,
queued_message::queued_message_set,
report::report_set,
sieve::validate_sieve_script,
spam_sample::spam_sample_set,
task::task_set,
tls::{validate_acme_provider, validate_certificate},
@@ -47,7 +48,10 @@ use registry::{
OBJ_FILTER_ACCOUNT, OBJ_FILTER_TENANT, OBJ_SINGLETON, Object, ObjectInner, ObjectType,
Property,
},
structs::{Certificate, DkimSignature, DnsServer, Domain, PublicKey, Role, Task},
structs::{
Certificate, DkimSignature, DnsServer, Domain, PublicKey, Role, SieveSystemScript,
SieveUserScript, Task,
},
},
types::id::ObjectId,
};
@@ -467,6 +471,24 @@ impl RegistrySet for Server {
ObjectInner::Certificate(cert) => {
validate_certificate(cert, modification.as_certificate()).await?
}
ObjectInner::SieveUserScript(SieveUserScript { contents, .. }) => {
validate_sieve_script(
set.server,
contents,
modification.as_sieve_script(),
false,
)
.await?
}
ObjectInner::SieveSystemScript(SieveSystemScript { contents, .. }) => {
validate_sieve_script(
set.server,
contents,
modification.as_sieve_script(),
true,
)
.await?
}
_ => Ok(ObjectResponse::default()),
};
@@ -815,6 +837,19 @@ impl Modification {
},
}
}
fn as_sieve_script(&self) -> Option<&str> {
match self {
Modification::Create { .. } => None,
Modification::Update { object, .. } => match &object.inner {
ObjectInner::SieveUserScript(SieveUserScript { contents, .. })
| ObjectInner::SieveSystemScript(SieveSystemScript { contents, .. }) => {
Some(contents.as_str())
}
_ => None,
},
}
}
}
pub(crate) fn map_write_error(err: RegistryWriteResult) -> SetError<Property> {

View File

@@ -359,38 +359,45 @@ async fn store_maintenance(
}
}
TaskStoreMaintenanceType::ResetTenantQuotas => {
let mut batch = BatchBuilder::new();
let now = now() as i64;
for tenant_id in server
.registry()
.query::<RoaringBitmap>(RegistryQuery::new(ObjectType::Tenant))
.await?
// SPDX-SnippetBegin
// SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
// SPDX-License-Identifier: LicenseRef-SEL
#[cfg(feature = "enterprise")]
{
#[cfg(feature = "test_mode")]
let status = TaskStatus::at(now);
let mut batch = BatchBuilder::new();
let now = now() as i64;
#[cfg(not(feature = "test_mode"))]
let status =
TaskStatus::at(now + rand::Rng::random_range(&mut rand::rng(), 0..=300));
for tenant_id in server
.registry()
.query::<RoaringBitmap>(RegistryQuery::new(ObjectType::Tenant))
.await?
{
#[cfg(feature = "test_mode")]
let status = TaskStatus::at(now);
batch.schedule_task(Task::TenantMaintenance(TaskTenantMaintenance {
tenant_id: tenant_id.into(),
maintenance_type: TaskTenantMaintenanceType::RecalculateQuota,
status,
}));
#[cfg(not(feature = "test_mode"))]
let status =
TaskStatus::at(now + rand::Rng::random_range(&mut rand::rng(), 0..=300));
if batch.is_large_batch() {
batch.schedule_task(Task::TenantMaintenance(TaskTenantMaintenance {
tenant_id: tenant_id.into(),
maintenance_type: TaskTenantMaintenanceType::RecalculateQuota,
status,
}));
if batch.is_large_batch() {
server.core.storage.data.write(batch.build_all()).await?;
server.notify_task_queue();
batch = BatchBuilder::new();
}
}
if !batch.is_empty() {
server.core.storage.data.write(batch.build_all()).await?;
server.notify_task_queue();
batch = BatchBuilder::new();
}
}
if !batch.is_empty() {
server.core.storage.data.write(batch.build_all()).await?;
server.notify_task_queue();
}
// SPDX-SnippetEnd
}
}
@@ -489,6 +496,10 @@ async fn recalculate_quota(server: &Server, account_id: u32) -> trc::Result<()>
.map(|_| ())
}
// SPDX-SnippetBegin
// SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
// SPDX-License-Identifier: LicenseRef-SEL
#[cfg(feature = "enterprise")]
async fn recalculate_tenant_quota(server: &Server, tenant_id: u32) -> trc::Result<()> {
let mut quota = 0;
for account_id in server
@@ -515,6 +526,12 @@ async fn recalculate_tenant_quota(server: &Server, tenant_id: u32) -> trc::Resul
.caused_by(trc::location!())
.map(|_| ())
}
// SPDX-SnippetEnd
#[cfg(not(feature = "enterprise"))]
async fn recalculate_tenant_quota(_server: &Server, _tenant_id: u32) -> trc::Result<()> {
Ok(())
}
async fn reset_imap_uids(server: &Server, account_id: u32) -> trc::Result<(u32, u32)> {
let mut mailbox_count = 0;

View File

@@ -28,6 +28,8 @@ use std::{borrow::Cow, fmt::Display};
use trc::AddContext;
use types::id::Id;
const MAX_OBJECT_PAYLOAD_SIZE: usize = 200_000;
#[derive(Debug, PartialEq, Eq)]
pub enum RegistryWriteResult {
Success(Id),
@@ -307,6 +309,18 @@ impl RegistryStore {
// It's pickle time!
let out = object.inner.to_pickled_vec();
if out.len() > MAX_OBJECT_PAYLOAD_SIZE {
return Ok(RegistryWriteResult::ValidationError {
errors: vec![ValidationError::Invalid {
property: Property::Id,
value: format!(
"Object size {} exceeds maximum of {}",
out.len(),
MAX_OBJECT_PAYLOAD_SIZE
),
}],
});
}
// Build batch
if write_id {

View File

@@ -49,7 +49,7 @@ pub async fn test() {
// Make sure the userinfo endpoint is not being used
if let Directory::OpenId(directory) = &mut oidc {
directory.discovery.userinfo_endpoint = "http://invalid".to_string();
directory.discovery.document.userinfo_endpoint = "http://invalid".to_string();
}
// JWT authentication should still work without the userinfo endpoint
@@ -108,7 +108,7 @@ pub async fn test() {
assert_eq!(
oidc.oidc_discovery_document()
.as_ref()
.map(|oidc| oidc.authorization_endpoint.as_str()),
.map(|oidc| oidc.document.authorization_endpoint.as_str()),
Some("http://localhost:9080/realms/stalwart/protocol/openid-connect/auth")
);
}