diff --git a/crates/common/src/manager/defaults.rs b/crates/common/src/manager/defaults.rs index e6e74da..b2d66ec 100644 --- a/crates/common/src/manager/defaults.rs +++ b/crates/common/src/manager/defaults.rs @@ -98,6 +98,10 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> { return Ok(()); } + // inbuxa: registration is required (contract C-5), so the first-party + // front ends are registered on every start (C-6) + super::first_party::ensure_first_party_clients(bp).await?; + if bp.registry.count_object(ObjectType::MtaQueueQuota).await? == 0 { bp.registry .write(RegistryWrite::insert( diff --git a/crates/common/src/manager/first_party.rs b/crates/common/src/manager/first_party.rs new file mode 100644 index 0000000..8c3aeda --- /dev/null +++ b/crates/common/src/manager/first_party.rs @@ -0,0 +1,323 @@ +/* + * SPDX-FileCopyrightText: 2026 John Coffey + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! First-party OAuth clients (docs/spec/contract.md C-6). +//! +//! INBUXA requires OAuth clients to be registered (C-5), so the front ends +//! that ship with it are registered for it, on every start: +//! +//! - the web interface the server serves itself (`Application`, `/admin` and +//! `/account`), as its OAuth client id, `stalwart-webui` unless the +//! application names another; +//! - INBUXA Admin hosted elsewhere, as `inbuxa-admin`, when `INBUXA_ADMIN_URL` +//! is set; +//! - ihasmail-inbuxa, as the confidential client `ihasmail-inbuxa`, when +//! `INBUXA_WEBMAIL_URL` and `INBUXA_WEBMAIL_CLIENT_SECRET` are set. +//! +//! inbuxa: the environment variables stand in for `x:FrontEnds` (C-4) until +//! that object exists; the installer and INBUXA Admin's setup wizard will set +//! it instead. +//! +//! A missing client is created. An existing one gains any redirect URI it +//! lacks and, for ihasmail-inbuxa, the configured secret; nothing an operator +//! added is removed. + +use directory::core::secret::{hash_secret, verify_secret_hash}; +use registry::{ + schema::{ + enums::{PasswordHashAlgorithm, ServiceProtocol}, + prelude::{ObjectType, Property, UTCDateTime}, + structs::{Application, OAuthClient, SystemSettings}, + }, + types::map::Map, +}; +use store::registry::{ + bootstrap::Bootstrap, + write::{RegistryWrite, RegistryWriteResult}, +}; + +/// The client id the upstream web interface uses when its application names none. +pub const WEB_INTERFACE_CLIENT_ID: &str = "stalwart-webui"; +pub const ADMIN_CLIENT_ID: &str = "inbuxa-admin"; +pub const WEBMAIL_CLIENT_ID: &str = "ihasmail-inbuxa"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FirstPartyClient { + pub client_id: String, + pub description: String, + pub redirect_uris: Vec, + pub secret: Option, +} + +/// The first-party clients this server should have, from its applications and +/// the front-end addresses it was given. +pub fn first_party_clients( + base_url: &str, + applications: &[Application], + admin_url: Option<&str>, + webmail: Option<(&str, &str)>, +) -> Vec { + let base_url = base_url.trim_end_matches('/'); + let mut clients: Vec = Vec::new(); + + for app in applications.iter().filter(|app| app.enabled) { + let client_id = app + .oauth_client_id + .as_deref() + .filter(|id| !id.is_empty()) + .unwrap_or(WEB_INTERFACE_CLIENT_ID); + let redirect_uris = app + .url_prefix + .iter() + .map(|prefix| { + format!( + "{base_url}/{}/oauth/callback", + prefix.trim_matches('/') + ) + }) + .collect::>(); + if redirect_uris.is_empty() { + continue; + } + + if let Some(client) = clients.iter_mut().find(|c| c.client_id == client_id) { + for uri in redirect_uris { + if !client.redirect_uris.contains(&uri) { + client.redirect_uris.push(uri); + } + } + } else { + clients.push(FirstPartyClient { + client_id: client_id.to_string(), + description: format!("{} (served by this server)", app.description), + redirect_uris, + secret: None, + }); + } + } + + if let Some(url) = admin_url.map(|url| url.trim().trim_end_matches('/')).filter(|url| !url.is_empty()) { + clients.push(FirstPartyClient { + client_id: ADMIN_CLIENT_ID.to_string(), + description: "INBUXA Admin".to_string(), + redirect_uris: vec![format!("{url}/oauth/callback")], + secret: None, + }); + } + + if let Some((url, secret)) = webmail { + let url = url.trim().trim_end_matches('/'); + if !url.is_empty() && !secret.is_empty() { + clients.push(FirstPartyClient { + client_id: WEBMAIL_CLIENT_ID.to_string(), + description: "ihasmail webmail".to_string(), + redirect_uris: vec![format!("{url}/api/auth/callback")], + secret: Some(secret.to_string()), + }); + } + } + + clients +} + +/// The address the server's own pages are served from, as `Http` works it out. +fn base_url(bp: &Bootstrap, system: &SystemSettings) -> String { + if let Some(url) = bp.registry.public_url() { + return url.to_string(); + } + let default_hostname = if !system.default_hostname.is_empty() { + system.default_hostname.as_str() + } else { + bp.registry.local_hostname() + }; + let host = system + .services + .iter() + .find(|(service, _)| matches!(service, ServiceProtocol::Jmap)) + .and_then(|(_, details)| details.hostname.as_deref()) + .unwrap_or(default_hostname); + format!("https://{host}") +} + +fn env(name: &str) -> Option { + types::branding::env_var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +pub(crate) async fn ensure_first_party_clients(bp: &mut Bootstrap) -> trc::Result<()> { + let system = bp.setting_infallible::().await; + let base_url = base_url(bp, &system); + let applications = bp + .list_infallible::() + .await + .into_iter() + .map(|app| app.object) + .collect::>(); + let admin_url = env("ADMIN_URL"); + let webmail_url = env("WEBMAIL_URL"); + let webmail_secret = env("WEBMAIL_CLIENT_SECRET"); + if webmail_url.is_some() && webmail_secret.is_none() { + trc::event!( + Auth(trc::AuthEvent::Error), + Details = "INBUXA_WEBMAIL_URL is set without INBUXA_WEBMAIL_CLIENT_SECRET; the webmail client was not registered." + ); + } + let webmail = webmail_url.as_deref().zip(webmail_secret.as_deref()); + + for client in first_party_clients(&base_url, &applications, admin_url.as_deref(), webmail) { + ensure_client(bp, client).await?; + } + + Ok(()) +} + +async fn ensure_client(bp: &mut Bootstrap, client: FirstPartyClient) -> trc::Result<()> { + let existing = match bp + .registry + .primary_key( + ObjectType::OAuthClient.into(), + Property::ClientId, + client.client_id.as_bytes().to_vec(), + ) + .await? + { + Some(object_id) => bp + .registry + .object::(object_id.id()) + .await? + .map(|object| (object_id.id(), object)), + None => None, + }; + + let result = if let Some((id, current)) = existing { + let mut updated = current.clone(); + for uri in &client.redirect_uris { + if !updated.redirect_uris.contains(uri) { + updated.redirect_uris.push(uri.clone()); + } + } + if let Some(secret) = &client.secret { + let matches = match updated.secret.as_deref() { + Some(hash) if !hash.is_empty() => { + verify_secret_hash(hash, secret.as_bytes()).await? + } + _ => false, + }; + if !matches { + updated.secret = Some( + hash_secret(PasswordHashAlgorithm::Argon2id, secret.as_bytes().to_vec()) + .await?, + ); + } + } + if updated == current { + return Ok(()); + } + bp.registry + .write(RegistryWrite::update(id, &updated.into(), ¤t.into())) + .await? + } else { + let secret = match &client.secret { + Some(secret) => Some( + hash_secret(PasswordHashAlgorithm::Argon2id, secret.as_bytes().to_vec()).await?, + ), + None => None, + }; + bp.registry + .write(RegistryWrite::insert( + &OAuthClient { + client_id: client.client_id.clone(), + description: Some(client.description), + redirect_uris: Map::new(client.redirect_uris), + secret, + created_at: UTCDateTime::now(), + ..Default::default() + } + .into(), + )) + .await? + }; + + if !matches!(result, RegistryWriteResult::Success(_)) { + return Err(trc::StoreEvent::UnexpectedError + .into_err() + .details("Failed to register a first-party OAuth client.") + .ctx(trc::Key::Id, client.client_id) + .reason(result.to_string()) + .caused_by(trc::location!())); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn web_interface() -> Application { + Application { + description: "Stalwart Web Interface".to_string(), + enabled: true, + url_prefix: Map::new(vec!["/admin".into(), "/account".into()]), + ..Default::default() + } + } + + #[test] + fn web_interface_gets_one_uri_per_prefix() { + let clients = first_party_clients("https://mail.example.org/", &[web_interface()], None, None); + assert_eq!( + clients, + vec![FirstPartyClient { + client_id: WEB_INTERFACE_CLIENT_ID.to_string(), + description: "Stalwart Web Interface (served by this server)".to_string(), + redirect_uris: vec![ + "https://mail.example.org/admin/oauth/callback".to_string(), + "https://mail.example.org/account/oauth/callback".to_string(), + ], + secret: None, + }] + ); + } + + #[test] + fn disabled_applications_and_named_clients() { + let mut disabled = web_interface(); + disabled.enabled = false; + let mut named = web_interface(); + named.oauth_client_id = Some("custom".to_string()); + named.url_prefix = Map::new(vec!["portal".into()]); + let clients = first_party_clients("https://h", &[disabled, named], None, None); + assert_eq!(clients.len(), 1); + assert_eq!(clients[0].client_id, "custom"); + assert_eq!(clients[0].redirect_uris, vec!["https://h/portal/oauth/callback"]); + } + + #[test] + fn front_ends_from_their_addresses() { + let clients = first_party_clients( + "https://h", + &[], + Some("https://admin.example.org/"), + Some(("https://webmail.example.org", "s3cret")), + ); + assert_eq!(clients.len(), 2); + assert_eq!(clients[0].client_id, ADMIN_CLIENT_ID); + assert_eq!(clients[0].redirect_uris, vec!["https://admin.example.org/oauth/callback"]); + assert_eq!(clients[0].secret, None); + assert_eq!(clients[1].client_id, WEBMAIL_CLIENT_ID); + assert_eq!(clients[1].redirect_uris, vec!["https://webmail.example.org/api/auth/callback"]); + assert_eq!(clients[1].secret.as_deref(), Some("s3cret")); + } + + #[test] + fn webmail_needs_a_secret() { + let clients = first_party_clients("https://h", &[], Some(" "), Some(("https://w", ""))); + assert!(clients.is_empty()); + } +} diff --git a/crates/common/src/manager/mod.rs b/crates/common/src/manager/mod.rs index a86bdb0..d7d47db 100644 --- a/crates/common/src/manager/mod.rs +++ b/crates/common/src/manager/mod.rs @@ -18,6 +18,7 @@ pub mod backup; pub mod boot; pub mod console; pub mod defaults; +pub mod first_party; pub mod restore; pub const SPAM_TRAINER_KEY: &[u8] = "STALWART_SPAM_TRAIN_DATA.lz4".as_bytes(); diff --git a/crates/registry/src/schema/structs_impl.rs b/crates/registry/src/schema/structs_impl.rs index 7708e0c..11b1158 100644 --- a/crates/registry/src/schema/structs_impl.rs +++ b/crates/registry/src/schema/structs_impl.rs @@ -30641,8 +30641,9 @@ impl Default for OidcProvider { fn default() -> Self { Self { auth_code_max_attempts: 3u64, - anonymous_client_registration: true, - require_client_registration: false, + // inbuxa: registration required, anonymous registration off (docs/spec/contract.md C-5) + anonymous_client_registration: false, + require_client_registration: true, auth_code_expiry: Duration::from_millis(600000), refresh_token_expiry: Duration::from_millis(2592000000), refresh_token_renewal: Duration::from_millis(345600000), diff --git a/docs/spec/SPEC.md b/docs/spec/SPEC.md index bd11d36..a14519a 100644 --- a/docs/spec/SPEC.md +++ b/docs/spec/SPEC.md @@ -468,7 +468,8 @@ Found at the fork's first boot, and to fix: Additions for the fork: -- Register ihasmail as the first-party OAuth client (§5.2). +- Register the first-party OAuth clients (§5.2). Done 2026-09-18: see + contract.md C-6. - Behavior fixes where upstream's first boot needs workarounds: an ACME order that fails isn't retried on restart, some network settings need a restart, and the default log path doesn't exist in the image. Each is a candidate for diff --git a/docs/spec/contract.md b/docs/spec/contract.md index 5195588..9e511a1 100644 --- a/docs/spec/contract.md +++ b/docs/spec/contract.md @@ -98,6 +98,31 @@ Each has an ID, and tests name the IDs they check. INBUXA Admin's `` is set to `inbuxa-admin`. Until then it keeps upstream's `stalwart-webui`, which only works while registration isn't required. + + **Built (interim), 2026-09-18.** C-5's defaults are in the server, and + `crates/common/src/manager/first_party.rs` registers the clients on every + start. Until `x:FrontEnds` exists, three environment variables stand in for + it: `INBUXA_ADMIN_URL`, `INBUXA_WEBMAIL_URL` and + `INBUXA_WEBMAIL_CLIENT_SECRET` (the webmail client is registered only when + both of its variables are set). The web interface the server still serves + itself (`/admin`, `/account`, until SPEC.md §5.3 removes it) is registered + too, as its application's OAuth client id or `stalwart-webui`, at the + server's public URL. A missing client is created. An existing one gains any + redirect URI it lacks, and the webmail client gets the configured secret. + Nothing an operator added is removed. Bootstrap and recovery mode skip this: + their recovery admin holds `oAuthClientOverride`. + + Checked on a local first boot of the debug build: setup still signed in; + after a restart with the three variables set, the server reported + registration required and anonymous registration off, and held the three + clients with the expected redirect URIs. A user signed in through each + public client. A foreign redirect, an unregistered client and anonymous + registration were refused, and so was the webmail client's code exchange + with a wrong or missing secret. A second restart left three clients. + + An existing server that saved its OAuth settings keeps them (INBUXA's + production server did). One upgraded from Stalwart that never saved them + moves to the new defaults, with its web interface registered first. - **C-7.** Third-party apps that want OAuth (Thunderbird, mobile apps) get a client in one of two ways: an administrator registers it (`x:OAuthClient`, in INBUXA Admin), or the operator turns anonymous dynamic registration back diff --git a/resources/schema/schema.json.gz b/resources/schema/schema.json.gz index 3c19a47..d3d7758 100644 Binary files a/resources/schema/schema.json.gz and b/resources/schema/schema.json.gz differ diff --git a/resources/schema/schema.json.sha256 b/resources/schema/schema.json.sha256 index a276e88..43e0093 100644 --- a/resources/schema/schema.json.sha256 +++ b/resources/schema/schema.json.sha256 @@ -1 +1 @@ -zUWyYdvOBMVeP1H7DNb7OqUThdpIaKjph4RyqBSKZAA \ No newline at end of file +4TLHJS-z8pSW23rxOTvraWfpsgMu_97Y5mqtbyUnDlo \ No newline at end of file