OAuth: registration required by default; first-party clients registered on every start (contract C-5, C-6)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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(), ¤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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 @@
|
||||
zUWyYdvOBMVeP1H7DNb7OqUThdpIaKjph4RyqBSKZAA
|
||||
4TLHJS-z8pSW23rxOTvraWfpsgMu_97Y5mqtbyUnDlo
|
||||
Reference in New Issue
Block a user