OAuth: registration required by default; first-party clients registered on every start (contract C-5, C-6)

This commit is contained in:
2026-09-18 13:04:20 -07:00
parent f82f15d863
commit d6fc4600cd
8 changed files with 359 additions and 4 deletions
+4
View File
@@ -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(
+323
View File
@@ -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<String>,
pub secret: Option<String>,
}
/// 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<FirstPartyClient> {
let base_url = base_url.trim_end_matches('/');
let mut clients: Vec<FirstPartyClient> = 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::<Vec<_>>();
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<String> {
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::<SystemSettings>().await;
let base_url = base_url(bp, &system);
let applications = bp
.list_infallible::<Application>()
.await
.into_iter()
.map(|app| app.object)
.collect::<Vec<_>>();
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::<OAuthClient>(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(), &current.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());
}
}
+1
View File
@@ -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();
+3 -2
View File
@@ -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),
+2 -1
View File
@@ -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
+25
View File
@@ -98,6 +98,31 @@ Each has an ID, and tests name the IDs they check.
INBUXA Admin's `<meta name="oauth-client-id">` 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
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
zUWyYdvOBMVeP1H7DNb7OqUThdpIaKjph4RyqBSKZAA
4TLHJS-z8pSW23rxOTvraWfpsgMu_97Y5mqtbyUnDlo