From 831f3dc677ee0b335cca5233d69635e8187dc557 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 29 Jul 2026 02:31:01 +0200 Subject: [PATCH] feat(auth): verify Basic credentials through OIDC provider --- Dockerfile.namailu | 26 +++++++ NAMAILU_FORK.md | 31 ++++++++ crates/directory/src/backend/oidc/config.rs | 32 +++++++- crates/directory/src/backend/oidc/lookup.rs | 86 ++++++++++++++++++++- crates/directory/src/backend/oidc/mod.rs | 6 ++ crates/http/src/auth/authenticate.rs | 62 ++++++++++++++- 6 files changed, 236 insertions(+), 7 deletions(-) create mode 100644 Dockerfile.namailu create mode 100644 NAMAILU_FORK.md diff --git a/Dockerfile.namailu b/Dockerfile.namailu new file mode 100644 index 00000000..11d51009 --- /dev/null +++ b/Dockerfile.namailu @@ -0,0 +1,26 @@ +# Namailu AGPL build of Stalwart 0.16.14. +# +# Only the PostgreSQL storage backend used by the deployment is enabled. Mail +# protocols, JMAP, OIDC directories and filtering are regular core features. +FROM rust:1.97.1-slim-trixie AS builder + +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential clang cmake libclang-dev pkg-config \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /src +COPY . . +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ + --mount=type=cache,target=/src/target \ + cargo build --locked --release -p stalwart \ + --no-default-features --features "postgres s3" \ + && cp /src/target/release/stalwart /tmp/stalwart + +FROM stalwartlabs/stalwart:v0.16.14 +LABEL org.opencontainers.image.source="https://git.facilitygo.com/filip/stalwart" \ + org.opencontainers.image.licenses="AGPL-3.0-only" \ + org.opencontainers.image.description="Stalwart 0.16.14 with Namailu unified HUMAN password verification" +COPY --from=builder --chmod=0755 /tmp/stalwart /usr/local/bin/stalwart diff --git a/NAMAILU_FORK.md b/NAMAILU_FORK.md new file mode 100644 index 00000000..55ff2ac0 --- /dev/null +++ b/NAMAILU_FORK.md @@ -0,0 +1,31 @@ +# Namailu fork: unified HUMAN password + +This fork keeps the regular Stalwart OIDC Bearer flow and AppPassword flow intact, +and adds an intentionally narrow Basic-auth bridge for HUMAN accounts. + +When the OIDC discovery document advertises +`password_verification_endpoint`, IMAP/SMTP/POP3 Basic credentials are POSTed +over HTTPS to that endpoint. The request uses the service bearer token from +`STALWART_OIDC_BASIC_AUTH_TOKEN`. A `204 No Content` response authenticates the +account; failure responses disclose no account data. + +Security properties: + +- the endpoint must be HTTPS, contain no userinfo, and use the issuer hostname; +- redirects and environment HTTP proxies are disabled for the OIDC client; +- password hashes, tokens, profiles, and sessions are never returned; +- the existing OIDC Bearer and scoped AppPassword paths are unchanged; +- the deployment additionally restricts the endpoint with an internal TLS + listener, source ACL, service bearer, rate limits, and bounded Argon2 work. + +The provided image enables the PostgreSQL metadata and S3-compatible blob +backends used by Namailu. Build it with: + +```sh +docker build -f Dockerfile.namailu \ + -t namailu/stalwart:v0.16.14-unified-password . +``` + +The fork remains licensed under the upstream AGPL-3.0-only option. Source for the +running modified version is published at +. diff --git a/crates/directory/src/backend/oidc/config.rs b/crates/directory/src/backend/oidc/config.rs index ebcd191e..453790f0 100644 --- a/crates/directory/src/backend/oidc/config.rs +++ b/crates/directory/src/backend/oidc/config.rs @@ -10,7 +10,7 @@ use crate::backend::oidc::{ DiscoveryDocument, JwksCache, OidcConfig, OidcDiscovery, OidcError, OpenIdDirectory, }; use registry::schema::structs; -use reqwest::Client; +use reqwest::{Client, redirect::Policy}; use std::time::{Duration, Instant}; use tokio::sync::RwLock; use trc::AuthEvent; @@ -34,7 +34,11 @@ impl OpenIdDirectory { pub async fn new(config: OidcConfig) -> Result { let http = Client::builder() .user_agent("Stalwart/1.0") - .timeout(Duration::from_secs(30)) + .timeout(Duration::from_secs(10)) + // Credentials must never be redirected or inherited by a proxy from the + // process environment. The endpoint is a fixed, same-host HTTPS target. + .redirect(Policy::none()) + .no_proxy() .build() .map_err(|e| OidcError::Network(format!("HTTP client build failed: {e}")))?; let discovery_url = format!( @@ -63,6 +67,25 @@ impl OpenIdDirectory { ))); } + if let Some(endpoint) = &discovery.password_verification_endpoint { + let endpoint_url = reqwest::Url::parse(endpoint).map_err(|err| { + OidcError::Provider(format!("Invalid password_verification_endpoint URL: {err}")) + })?; + let issuer_url = reqwest::Url::parse(&discovery.issuer) + .map_err(|err| OidcError::Provider(format!("Invalid issuer URL: {err}")))?; + if endpoint_url.scheme() != "https" + || endpoint_url.host_str() != issuer_url.host_str() + || endpoint_url.username() != "" + || endpoint_url.password().is_some() + { + return Err(OidcError::Provider( + "password_verification_endpoint must be HTTPS, contain no userinfo, \ + and use the same hostname as the issuer" + .to_string(), + )); + } + } + if let Some(supported) = &discovery.scopes_supported { for scope in &config.require_scopes { if !supported.contains(scope) { @@ -140,6 +163,11 @@ impl OpenIdDirectory { config, http, cache, + // Deployment-specific service identity. It is intentionally not part of + // the public registry object or discovery document and is never logged. + basic_auth_token: std::env::var("STALWART_OIDC_BASIC_AUTH_TOKEN") + .ok() + .filter(|token| !token.is_empty()), }) } } diff --git a/crates/directory/src/backend/oidc/lookup.rs b/crates/directory/src/backend/oidc/lookup.rs index 7fc5e78c..756152a6 100644 --- a/crates/directory/src/backend/oidc/lookup.rs +++ b/crates/directory/src/backend/oidc/lookup.rs @@ -35,9 +35,89 @@ impl OpenIdDirectory { } err => AuthEvent::Error.into_err().reason(err), }), - _ => Err(AuthEvent::Error - .into_err() - .reason("Unsupported credentials type for OIDC backend")), + Credentials::Basic { + username, secret, .. + } => self + .authenticate_basic(username, secret) + .await + .map_err(|err| match err { + OidcError::AuthorizationFailed(reason) => { + AuthEvent::Failed.into_err().reason(reason) + } + err => AuthEvent::Error.into_err().reason(err), + }), + } + } + + /// Verify a legacy mail-protocol password against the OIDC provider's private + /// password oracle. The provider remains the only password/hash authority. + /// + /// This is deliberately narrower than OAuth's removed password grant: no token, + /// profile, hash or session is returned. App passwords are still handled earlier + /// in `Server::route_auth_request` and never reach this endpoint. + async fn authenticate_basic(&self, username: &str, secret: &str) -> Result { + let endpoint = self + .discovery + .document + .password_verification_endpoint + .as_deref() + .ok_or_else(|| OidcError::AuthorizationFailed("Invalid credentials".to_string()))?; + let token = self.basic_auth_token.as_deref().ok_or_else(|| { + OidcError::Provider( + "OIDC password verification endpoint is advertised but \ + STALWART_OIDC_BASIC_AUTH_TOKEN is not configured" + .to_string(), + ) + })?; + let username = username.trim().to_ascii_lowercase(); + if username.is_empty() || username.len() > 320 || secret.is_empty() || secret.len() > 1024 { + return Err(OidcError::AuthorizationFailed( + "Invalid credentials".to_string(), + )); + } + + let response = self + .http + .post(endpoint) + .bearer_auth(token) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(serde_json::to_vec(&serde_json::json!({ + "username": username, + "password": secret, + })) + .map_err(|err| { + OidcError::Provider(format!( + "Password verification request serialization failed: {err}" + )) + })?) + .send() + .await + .map_err(|err| { + OidcError::Network(format!("Password verification request failed: {err}")) + })?; + + if response.status() == reqwest::StatusCode::NO_CONTENT { + Ok(Account { + email: username, + email_aliases: Vec::new(), + secret: None, + groups: None, + description: None, + }) + } else if matches!( + response.status(), + reqwest::StatusCode::UNAUTHORIZED + | reqwest::StatusCode::FORBIDDEN + | reqwest::StatusCode::TOO_MANY_REQUESTS + ) { + Err(OidcError::AuthorizationFailed( + "Invalid credentials".to_string(), + )) + } else { + Err(OidcError::Provider(format!( + "Password verification endpoint returned HTTP {}", + response.status() + ))) } } diff --git a/crates/directory/src/backend/oidc/mod.rs b/crates/directory/src/backend/oidc/mod.rs index 03ad48c0..4b92a692 100644 --- a/crates/directory/src/backend/oidc/mod.rs +++ b/crates/directory/src/backend/oidc/mod.rs @@ -36,6 +36,11 @@ pub struct DiscoveryDocument { pub userinfo_endpoint: String, pub token_endpoint: String, pub authorization_endpoint: String, + /// Optional private extension used by deployments that deliberately expose the + /// same primary password to legacy mail protocols. The endpoint returns no profile + /// or hash; it only verifies a Basic username/password over HTTPS. + #[serde(skip_serializing_if = "Option::is_none")] + pub password_verification_endpoint: Option, #[serde(skip_serializing_if = "Option::is_none")] pub end_session_endpoint: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -61,6 +66,7 @@ pub struct OpenIdDirectory { pub discovery: OidcDiscovery, http: Client, cache: RwLock, + basic_auth_token: Option, } #[derive(Debug)] diff --git a/crates/http/src/auth/authenticate.rs b/crates/http/src/auth/authenticate.rs index 5d9f2714..9d27b0e9 100644 --- a/crates/http/src/auth/authenticate.rs +++ b/crates/http/src/auth/authenticate.rs @@ -9,9 +9,10 @@ use common::{HttpAuthCache, Server, auth::AuthRequest, network::limiter::InFligh use directory::Credentials; use http_proto::{HttpRequest, HttpSessionData}; use hyper::header; +use base64::Engine; use mail_parser::decoders::base64::base64_decode; use std::future::Future; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; pub trait Authenticator: Sync + Send { fn authenticate_headers( @@ -91,14 +92,18 @@ impl Authenticator for Server { .await?; // Cache credentials + let max_cache_ttl = Duration::from_secs(self.core.oauth.oauth_expiry_token); self.inner.cache.http_auth.insert( token.into(), HttpAuthCache { account_id: access_token.account_id(), revision: access_token.revision(), credential_id: access_token.credential_id(), + // A verified JWT must never outlive its own exp just because + // the HTTP credential cache is configured for longer. The + // unverified peek only shortens an already authenticated token. expires: Instant::now() - + Duration::from_secs(self.core.oauth.oauth_expiry_token), + + auth_cache_ttl(mechanism, token, max_cache_ttl), }, ); @@ -119,6 +124,32 @@ impl Authenticator for Server { } } +fn auth_cache_ttl(mechanism: &str, token: &str, max_ttl: Duration) -> Duration { + if !mechanism.eq_ignore_ascii_case("bearer") { + return max_ttl; + } + + let Some(payload) = token.split('.').nth(1).and_then(|payload| { + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload) + .ok() + }) else { + // Opaque OAuth tokens and Stalwart API keys keep the configured cache TTL. + return max_ttl; + }; + let Some(exp) = serde_json::from_slice::(&payload) + .ok() + .and_then(|claims| claims.get("exp").and_then(|exp| exp.as_u64())) + else { + return max_ttl; + }; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + Duration::from_secs(exp.saturating_sub(now).min(max_ttl.as_secs())) +} + pub trait HttpHeaders { fn authorization(&self) -> Option<(&str, &str)>; fn authorization_basic(&self) -> Option<&str>; @@ -156,3 +187,30 @@ fn decode_plain_auth(token: &str) -> Option { }) }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bearer_cache_never_outlives_jwt_exp() { + let exp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + + 60; + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(format!(r#"{{"exp":{exp}}}"#)); + let token = format!("header.{payload}.signature"); + let ttl = auth_cache_ttl("Bearer", &token, Duration::from_secs(900)); + assert!(ttl <= Duration::from_secs(60)); + assert!(ttl >= Duration::from_secs(58)); + } + + #[test] + fn opaque_bearer_and_basic_keep_configured_cache_ttl() { + let configured = Duration::from_secs(900); + assert_eq!(auth_cache_ttl("Bearer", "API_opaque", configured), configured); + assert_eq!(auth_cache_ttl("Basic", "opaque", configured), configured); + } +}