The name is inbuxa, lowercase, like the wordmark; INBUXA reads as an acronym. The admin and webmail already changed. Here that's everything the server shows people: the brand macro behind the protocol greetings, the HTTP and SCIM realms, the startup banner and the calendar and contact PRODID; the first-party OAuth client descriptions; the legacy-protocol refusals; the default calendar and address book names and the SMTP greeting default, in the code and the schema served to the admin (checksum regenerated); startup and shutdown events; the User-Agent; the sign-in and RSVP pages; the service units; the OpenAPI realm; the crate descriptions and the README, where it's set in bold. Identifiers that are uppercase for their own reasons stay: INBUXA_* settings, SUBSPACE_INBUXA. So do code comments and the AGPL 5(a) notice lines. Tests follow: the IMAP ID name, the default collection names, the PRODID in the iTIP fixtures and the CalDAV free-busy expectations, and the e2e legacy-protocol refusals. The webdav, imap and jmap suites pass, so do the unit tests of every crate touched, and 73 of 75 SMTP tests; of the other two, antispam fails on main too, and queue_retry is a timing flake that passes on its own.
180 lines
6.6 KiB
Rust
180 lines
6.6 KiB
Rust
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
|
*
|
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
|
*
|
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
|
*/
|
|
|
|
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("inbuxa/1.0") // types::brand!(); this crate does not depend on types
|
|
.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))
|
|
}
|
|
}
|