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 1BBBE1FF138 for ; Wed, 04 Feb 2026 17:14:12 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 7419A1A04C; Wed, 4 Feb 2026 17:14:31 +0100 (CET) From: Arthur Bied-Charreton To: pve-devel@lists.proxmox.com Subject: [PATCH proxmox 1/5] notify: Introduce xoauth2 module Date: Wed, 4 Feb 2026 17:13:40 +0100 Message-ID: <20260204161354.458814-2-a.bied-charreton@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260204161354.458814-1-a.bied-charreton@proxmox.com> References: <20260204161354.458814-1-a.bied-charreton@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL -0.151 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: 5GEKUWAT7CFFKZZJ7SN3IL3CFYNUJZ2F X-Message-ID-Hash: 5GEKUWAT7CFFKZZJ7SN3IL3CFYNUJZ2F 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 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 [1] 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 Signed-off-by: Arthur Bied-Charreton --- proxmox-notify/Cargo.toml | 4 + proxmox-notify/debian/control | 10 ++- proxmox-notify/src/lib.rs | 1 + proxmox-notify/src/xoauth2.rs | 146 ++++++++++++++++++++++++++++++++++ 4 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 proxmox-notify/src/xoauth2.rs diff --git a/proxmox-notify/Cargo.toml b/proxmox-notify/Cargo.toml index bc63e19d..52493ef7 100644 --- a/proxmox-notify/Cargo.toml +++ b/proxmox-notify/Cargo.toml @@ -19,6 +19,10 @@ http = { workspace = true, optional = true } lettre = { workspace = true, optional = true } tracing.workspace = true mail-parser = { workspace = true, optional = true } +oauth2 = { version = "5.0.0" } +ureq = { version = "3.0.11", features = ["platform-verifier"] } + + openssl.workspace = true percent-encoding = { workspace = true, optional = true } regex.workspace = true diff --git a/proxmox-notify/debian/control b/proxmox-notify/debian/control index e588e485..7770f5ee 100644 --- a/proxmox-notify/debian/control +++ b/proxmox-notify/debian/control @@ -11,6 +11,7 @@ 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-oauth2-5+default-dev , librust-openssl-0.10+default-dev , librust-percent-encoding-2+default-dev (>= 2.1-~~) , librust-proxmox-base64-1+default-dev , @@ -33,7 +34,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 +52,7 @@ Depends: librust-anyhow-1+default-dev, librust-const-format-0.2+default-dev, librust-handlebars-5+default-dev, + librust-oauth2-5+default-dev, librust-openssl-0.10+default-dev, librust-proxmox-http-error-1+default-dev, librust-proxmox-human-byte-1+default-dev, @@ -65,7 +69,9 @@ Depends: 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-~~) Recommends: librust-proxmox-notify+default-dev (= ${binary:Version}) Suggests: diff --git a/proxmox-notify/src/lib.rs b/proxmox-notify/src/lib.rs index 879f8326..1134027c 100644 --- a/proxmox-notify/src/lib.rs +++ b/proxmox-notify/src/lib.rs @@ -24,6 +24,7 @@ pub mod context; pub mod endpoints; pub mod renderer; pub mod schema; +pub mod xoauth2; #[derive(Debug)] pub enum Error { diff --git a/proxmox-notify/src/xoauth2.rs b/proxmox-notify/src/xoauth2.rs new file mode 100644 index 00000000..66faabfa --- /dev/null +++ b/proxmox-notify/src/xoauth2.rs @@ -0,0 +1,146 @@ +use oauth2::{ + basic::BasicClient, AccessToken, AuthUrl, ClientId, ClientSecret, RefreshToken, TokenResponse, + TokenUrl, +}; + +use crate::Error; + +/// `oauth2` dropped support for the `ureq` backend, this newtype circumvents this +/// by implementing the `SyncHttpClient` trait. This allows us to avoid pulling in +/// a different backend like `reqwest`. +pub 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) + } +} + +pub struct TokenExchangeResult { + pub access_token: AccessToken, + pub refresh_token: Option, +} + +/// Microsoft Identity Platform refresh tokens replace themselves +/// upon every use, however the old one does *not* get revoked. +/// +/// This means that every access token request yields both an access +/// token AND a new refresh token, which should replace the old one. +/// The old one should then be discarded. +/// +/// https://learn.microsoft.com/en-us/entra/identity-platform/refresh-tokens#token-lifetime +pub 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("https://login.microsoftonline.com/common/oauth2/v2.0/authorize".into()) + .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, we can keep the same token. +/// +/// To make sure the token does not expire, it should be enough to periodically +/// make an access token request. If the token becomes invalid for whatever +/// other reason, we need user intervention to get a new one. +/// +/// https://developers.google.com/identity/protocols/oauth2#expiration +pub 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