From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id ADC191FF141 for ; Fri, 13 Feb 2026 17:04:02 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id EE752967A; Fri, 13 Feb 2026 17:04:32 +0100 (CET) From: Arthur Bied-Charreton To: pve-devel@lists.proxmox.com Subject: [PATCH proxmox 1/7] notify (smtp): Introduce xoauth2 module Date: Fri, 13 Feb 2026 17:03:59 +0100 Message-ID: <20260213160415.609868-2-a.bied-charreton@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260213160415.609868-1-a.bied-charreton@proxmox.com> References: <20260213160415.609868-1-a.bied-charreton@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL -0.090 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment KAM_LAZY_DOMAIN_SECURITY 1 Sending domain does not have any anti-forgery methods KAM_SHORT 0.001 Use of a URL Shortener for very short URL RCVD_IN_VALIDITY_CERTIFIED_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. RCVD_IN_VALIDITY_RPBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. RCVD_IN_VALIDITY_SAFE_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. RDNS_NONE 0.793 Delivered to internal network by a host with no rDNS SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_NONE 0.001 SPF: sender does not publish an SPF Record Message-ID-Hash: JB4TSZRKTPZZU3K53R3UODJDRJKB32PY X-Message-ID-Hash: JB4TSZRKTPZZU3K53R3UODJDRJKB32PY X-MailFrom: abied-charreton@jett.proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: Prepare proxmox-notify to use the oauth2 crate for SMTP XOAUTH2 support. The xoauth2 module handles some of the implementation details related to supporting XOAUTH2 for SMTP notification targets. * Add a ureq::Agent newtype wrapper implementing the SyncHttpClient trait to allow using ureq as oauth2 backend, since OAuth2 dropped the ureq feature. Debian seems to have patched it out due to a ureq 2/3 version mismatch [1]. * Add get_{google,microsoft}_token functions Signed-off-by: Arthur Bied-Charreton --- proxmox-notify/Cargo.toml | 6 +- proxmox-notify/debian/control | 14 +- proxmox-notify/src/endpoints/smtp.rs | 2 + proxmox-notify/src/endpoints/smtp/xoauth2.rs | 167 +++++++++++++++++++ 4 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 proxmox-notify/src/endpoints/smtp/xoauth2.rs diff --git a/proxmox-notify/Cargo.toml b/proxmox-notify/Cargo.toml index bc63e19d..d816c695 100644 --- a/proxmox-notify/Cargo.toml +++ b/proxmox-notify/Cargo.toml @@ -19,6 +19,9 @@ http = { workspace = true, optional = true } lettre = { workspace = true, optional = true } tracing.workspace = true mail-parser = { workspace = true, optional = true } +oauth2 = { version = "5.0.0", default-features = false, optional = true } +ureq = { version = "3.0.11", features = ["platform-verifier"], optional = true } + openssl.workspace = true percent-encoding = { workspace = true, optional = true } regex.workspace = true @@ -36,6 +39,7 @@ proxmox-sendmail = { workspace = true, optional = true } proxmox-sys = { workspace = true, optional = true } proxmox-time.workspace = true proxmox-uuid = { workspace = true, features = ["serde"] } +nix.workspace = true [features] default = ["sendmail", "gotify", "smtp", "webhook"] @@ -44,5 +48,5 @@ sendmail = ["dep:proxmox-sys", "dep:proxmox-sendmail"] gotify = ["dep:proxmox-http", "dep:http"] pve-context = ["dep:proxmox-sys"] pbs-context = ["dep:proxmox-sys"] -smtp = ["dep:lettre"] +smtp = ["dep:lettre", "dep:oauth2", "dep:ureq", "dep:http", "dep:proxmox-sys"] webhook = ["dep:http", "dep:percent-encoding", "dep:proxmox-base64", "dep:proxmox-http"] diff --git a/proxmox-notify/debian/control b/proxmox-notify/debian/control index e588e485..a84af040 100644 --- a/proxmox-notify/debian/control +++ b/proxmox-notify/debian/control @@ -11,6 +11,8 @@ Build-Depends-Arch: cargo:native , librust-handlebars-5+default-dev , librust-http-1+default-dev , librust-lettre-0.11+default-dev (>= 0.11.1-~~) , + librust-nix-0.29+default-dev , + librust-oauth2-5-dev , librust-openssl-0.10+default-dev , librust-percent-encoding-2+default-dev (>= 2.1-~~) , librust-proxmox-base64-1+default-dev , @@ -33,7 +35,9 @@ Build-Depends-Arch: cargo:native , librust-serde-1+default-dev , librust-serde-1+derive-dev , librust-serde-json-1+default-dev , - librust-tracing-0.1+default-dev + librust-tracing-0.1+default-dev , + librust-ureq-3+default-dev (>= 3.0.11-~~) , + librust-ureq-3+platform-verifier-dev (>= 3.0.11-~~) Maintainer: Proxmox Support Team Standards-Version: 4.7.2 Vcs-Git: git://git.proxmox.com/git/proxmox.git @@ -49,6 +53,7 @@ Depends: librust-anyhow-1+default-dev, librust-const-format-0.2+default-dev, librust-handlebars-5+default-dev, + librust-nix-0.29+default-dev, librust-openssl-0.10+default-dev, librust-proxmox-http-error-1+default-dev, librust-proxmox-human-byte-1+default-dev, @@ -177,7 +182,12 @@ Multi-Arch: same Depends: ${misc:Depends}, librust-proxmox-notify-dev (= ${binary:Version}), - librust-lettre-0.11+default-dev (>= 0.11.1-~~) + librust-http-1+default-dev, + librust-lettre-0.11+default-dev (>= 0.11.1-~~), + librust-oauth2-5-dev, + librust-proxmox-sys-1+default-dev, + librust-ureq-3+default-dev (>= 3.0.11-~~), + librust-ureq-3+platform-verifier-dev (>= 3.0.11-~~) Provides: librust-proxmox-notify-1+smtp-dev (= ${binary:Version}), librust-proxmox-notify-1.0+smtp-dev (= ${binary:Version}), diff --git a/proxmox-notify/src/endpoints/smtp.rs b/proxmox-notify/src/endpoints/smtp.rs index c888dee7..277b70f4 100644 --- a/proxmox-notify/src/endpoints/smtp.rs +++ b/proxmox-notify/src/endpoints/smtp.rs @@ -23,6 +23,8 @@ const SMTP_SUBMISSION_STARTTLS_PORT: u16 = 587; const SMTP_SUBMISSION_TLS_PORT: u16 = 465; const SMTP_TIMEOUT: u16 = 5; +mod xoauth2; + #[api] #[derive(Debug, Serialize, Deserialize, Default, Clone, Copy)] #[serde(rename_all = "kebab-case")] diff --git a/proxmox-notify/src/endpoints/smtp/xoauth2.rs b/proxmox-notify/src/endpoints/smtp/xoauth2.rs new file mode 100644 index 00000000..90ee630f --- /dev/null +++ b/proxmox-notify/src/endpoints/smtp/xoauth2.rs @@ -0,0 +1,167 @@ +use oauth2::{ + basic::BasicClient, AccessToken, AuthUrl, ClientId, ClientSecret, RefreshToken, TokenResponse, + TokenUrl, +}; + +use crate::Error; + +/// This newtype implements the `SyncHttpClient` trait for [`ureq::Agent`]. This allows +/// us to avoid pulling in a different backend like `reqwest`. +/// +/// Debian patched out `[0]` the `ureq` backend due to a `ureq` 2-3 version +/// mismatch in the `oauth2` crate. +/// +/// There is an open PR `[1]` in `oauth2`, once/if this is merged, we can drop the +/// custom client implementation. +/// +/// `[0]` +/// https://git.proxmox.com/?p=debcargo-conf.git;a=blob;f=src/oauth2/debian/patches/disable-ureq.patch;h=828b883a83a86927c5cd32df055226a5e78e8bea;hb=refs/heads/proxmox/trixie +/// +/// `[1]` https://github.com/ramosbugs/oauth2-rs/pull/338 +pub(crate) struct UreqSyncHttpClient(ureq::Agent); + +impl Default for UreqSyncHttpClient { + /// Set `max_redirects` to 0 to prevent SSRF, see + /// https://docs.rs/oauth2/latest/oauth2/#security-warning + fn default() -> Self { + Self(ureq::Agent::new_with_config( + ureq::Agent::config_builder().max_redirects(0).build(), + )) + } +} + +impl oauth2::SyncHttpClient for UreqSyncHttpClient { + type Error = oauth2::HttpClientError; + + fn call(&self, request: oauth2::HttpRequest) -> Result { + let uri = request.uri().to_string(); + + let response = match request.method() { + &http::Method::POST => { + let req = request + .headers() + .iter() + .fold(self.0.post(&uri), |req, (name, value)| { + req.header(name, value) + }); + req.send(request.body()).map_err(Box::new)? + } + &http::Method::GET => { + let req = request + .headers() + .iter() + .fold(self.0.get(&uri), |req, (name, value)| { + req.header(name, value) + }); + req.call().map_err(Box::new)? + } + m => { + return Err(oauth2::HttpClientError::Other(format!( + "unexpected method: {m}" + ))); + } + }; + + let mut builder = http::Response::builder().status(response.status()); + + if let Some(content_type) = response.headers().get(http::header::CONTENT_TYPE) { + builder = builder.header(http::header::CONTENT_TYPE, content_type); + } + + let (_, mut body) = response.into_parts(); + + let body = body.read_to_vec().map_err(Box::new)?; + + builder.body(body).map_err(oauth2::HttpClientError::Http) + } +} + +/// The result yielded by an OAuth2 token exchange. +/// +/// A successful OAuth2 token exchange will always return an access token to be +/// used for authentication. +/// +/// Some providers additionally yield a new refresh token that should replace the +/// old one. +pub(crate) struct TokenExchangeResult { + pub access_token: AccessToken, + pub refresh_token: Option, +} + +/// Perform a Microsoft OAuth2 token exchange. +/// +/// This always yields a new refresh token, which should be persisted on a best-effort +/// basis, replacing the one that was passed to this function. +/// +/// Microsoft Identity Platform refresh tokens have static lifetimes of 90 days, with each +/// token exchange yielding a new refresh token. The new refresh token is assigned a new +/// static lifetime, starting from the moment the token exchange was performed. +/// +/// The old refresh token is not invalidated, rather it keeps the static lifetime it was +/// assigned at generation time. This means that at any given point in time, there can be +/// many different refresh tokens that are *all* valid. +/// +/// https://learn.microsoft.com/en-us/entra/identity-platform/refresh-tokens#token-lifetime +pub(crate) fn get_microsoft_token( + client_id: ClientId, + client_secret: ClientSecret, + tenant_id: &str, + refresh_token: RefreshToken, +) -> Result { + let client = BasicClient::new(client_id) + .set_client_secret(client_secret) + .set_auth_uri( + AuthUrl::new(format!( + "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/authorize" + )) + .map_err(|e| Error::Generic(format!("invalid auth URL: {e}")))?, + ) + .set_token_uri( + TokenUrl::new(format!( + "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" + )) + .map_err(|e| Error::Generic(format!("invalid token URL: {e}")))?, + ); + + let token_result = client + .exchange_refresh_token(&refresh_token) + .request(&UreqSyncHttpClient::default()) + .map_err(|e| Error::Generic(format!("could not get access token: {e}")))?; + + Ok(TokenExchangeResult { + access_token: token_result.access_token().clone(), + refresh_token: token_result.refresh_token().cloned(), + }) +} + +/// Google refresh tokens' TTL is extended at every use. As long as +/// a token has been used at least once in the past 6 months, and no +/// other expiration reason applies, the same token can be kept. +/// +/// https://developers.google.com/identity/protocols/oauth2#expiration +pub(crate) fn get_google_token( + client_id: ClientId, + client_secret: ClientSecret, + refresh_token: RefreshToken, +) -> Result { + let client = BasicClient::new(client_id) + .set_client_secret(client_secret) + .set_auth_uri( + AuthUrl::new("https://accounts.google.com/o/oauth2/v2/auth".into()) + .map_err(|e| Error::Generic(format!("invalid auth URL: {e}")))?, + ) + .set_token_uri( + TokenUrl::new("https://oauth2.googleapis.com/token".into()) + .map_err(|e| Error::Generic(format!("invalid token URL: {e}")))?, + ); + + let token_result = client + .exchange_refresh_token(&refresh_token) + .request(&UreqSyncHttpClient::default()) + .map_err(|e| Error::Generic(format!("could not get access token: {e}")))?; + + Ok(TokenExchangeResult { + access_token: token_result.access_token().clone(), + refresh_token: token_result.refresh_token().cloned(), + }) +} -- 2.47.3