From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id 830401FF141 for ; Tue, 05 May 2026 10:33:45 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id EAA9C1B164; Tue, 5 May 2026 10:33:34 +0200 (CEST) From: Arthur Bied-Charreton To: pve-devel@lists.proxmox.com, pbs-devel@lists.proxmox.com Subject: [PATCH proxmox v5 02/27] notify: smtp: introduce xoauth2 module Date: Tue, 5 May 2026 10:32:23 +0200 Message-ID: <20260505083248.36450-3-a.bied-charreton@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260505083248.36450-1-a.bied-charreton@proxmox.com> References: <20260505083248.36450-1-a.bied-charreton@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL -0.117 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 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 URIBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to URIBL was blocked. See http://wiki.apache.org/spamassassin/DnsBlocklists#dnsbl-block for more information. [smtp.rs,oauth2.googleapis.com,xoauth2.rs] Message-ID-Hash: MQO6NIDWIQKD7OQ7BNYFQYJ37PTJDEXI X-Message-ID-Hash: MQO6NIDWIQKD7OQ7BNYFQYJ37PTJDEXI 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: The xoauth2 module handles some of the implementation details related to supporting XOAUTH2 for SMTP notification targets. The get_{google,microsoft}_token functions handle provider-specific token exchange. A newtype wrapper over proxmox_http's Client implementing the SyncHttpClient trait is added to allow using proxmox-http as a backend for oauth2 and respecting the proxy configs. Signed-off-by: Arthur Bied-Charreton --- proxmox-notify/Cargo.toml | 4 +- proxmox-notify/debian/control | 35 ++--- proxmox-notify/src/endpoints/smtp.rs | 2 + proxmox-notify/src/endpoints/smtp/xoauth2.rs | 133 +++++++++++++++++++ 4 files changed, 158 insertions(+), 16 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..72d3b322 100644 --- a/proxmox-notify/Cargo.toml +++ b/proxmox-notify/Cargo.toml @@ -19,6 +19,8 @@ http = { workspace = true, optional = true } lettre = { workspace = true, optional = true } tracing.workspace = true mail-parser = { workspace = true, optional = true } +oauth2 = { workspace = true, optional = true } + openssl.workspace = true percent-encoding = { workspace = true, optional = true } regex.workspace = true @@ -44,5 +46,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:proxmox-http", "dep:http"] 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..6f30cdd1 100644 --- a/proxmox-notify/debian/control +++ b/proxmox-notify/debian/control @@ -4,13 +4,14 @@ Priority: optional Build-Depends: debhelper-compat (= 13), dh-sequence-cargo Build-Depends-Arch: cargo:native , - rustc:native (>= 1.82) , + rustc:native (>= 1.85) , libstd-rust-dev , librust-anyhow-1+default-dev , librust-const-format-0.2+default-dev , librust-handlebars-5+default-dev , librust-http-1+default-dev , librust-lettre-0.11+default-dev (>= 0.11.1-~~) , + librust-oauth2-5-dev , librust-openssl-0.10+default-dev , librust-percent-encoding-2+default-dev (>= 2.1-~~) , librust-proxmox-base64-1+default-dev , @@ -18,14 +19,14 @@ Build-Depends-Arch: cargo:native , librust-proxmox-http-1+default-dev (>= 1.0.5-~~) , librust-proxmox-http-error-1+default-dev , librust-proxmox-human-byte-1+default-dev , - librust-proxmox-schema-5+api-macro-dev (>= 5.0.1-~~) , - librust-proxmox-schema-5+api-types-dev (>= 5.0.1-~~) , - librust-proxmox-schema-5+default-dev (>= 5.0.1-~~) , + librust-proxmox-schema-5+api-macro-dev (>= 5.1.1-~~) , + librust-proxmox-schema-5+api-types-dev (>= 5.1.1-~~) , + librust-proxmox-schema-5+default-dev (>= 5.1.1-~~) , librust-proxmox-section-config-3+default-dev (>= 3.1.0-~~) , - librust-proxmox-sendmail-1+default-dev (>= 1.0.1-~~) , + librust-proxmox-sendmail-1+default-dev (>= 1.0.2-~~) , librust-proxmox-serde-1+default-dev , librust-proxmox-serde-1+serde-json-dev , - librust-proxmox-sys-1+default-dev , + librust-proxmox-sys-1+default-dev (>= 1.0.1-~~) , librust-proxmox-time-2+default-dev (>= 2.1.0-~~) , librust-proxmox-uuid-1+default-dev (>= 1.1.0-~~) , librust-proxmox-uuid-1+serde-dev (>= 1.1.0-~~) , @@ -52,9 +53,9 @@ Depends: librust-openssl-0.10+default-dev, librust-proxmox-http-error-1+default-dev, librust-proxmox-human-byte-1+default-dev, - librust-proxmox-schema-5+api-macro-dev (>= 5.0.1-~~), - librust-proxmox-schema-5+api-types-dev (>= 5.0.1-~~), - librust-proxmox-schema-5+default-dev (>= 5.0.1-~~), + librust-proxmox-schema-5+api-macro-dev (>= 5.1.1-~~), + librust-proxmox-schema-5+api-types-dev (>= 5.1.1-~~), + librust-proxmox-schema-5+default-dev (>= 5.1.1-~~), librust-proxmox-section-config-3+default-dev (>= 3.1.0-~~), librust-proxmox-serde-1+default-dev, librust-proxmox-serde-1+serde-json-dev, @@ -124,8 +125,8 @@ Depends: ${misc:Depends}, librust-proxmox-notify-dev (= ${binary:Version}), librust-mail-parser-0.11+default-dev, - librust-proxmox-sendmail-1+mail-forwarder-dev (>= 1.0.1-~~), - librust-proxmox-sys-1+default-dev + librust-proxmox-sendmail-1+mail-forwarder-dev (>= 1.0.2-~~), + librust-proxmox-sys-1+default-dev (>= 1.0.1-~~) Provides: librust-proxmox-notify-1+mail-forwarder-dev (= ${binary:Version}), librust-proxmox-notify-1.0+mail-forwarder-dev (= ${binary:Version}), @@ -140,7 +141,7 @@ Multi-Arch: same Depends: ${misc:Depends}, librust-proxmox-notify-dev (= ${binary:Version}), - librust-proxmox-sys-1+default-dev + librust-proxmox-sys-1+default-dev (>= 1.0.1-~~) Provides: librust-proxmox-notify+pve-context-dev (= ${binary:Version}), librust-proxmox-notify-1+pbs-context-dev (= ${binary:Version}), @@ -161,8 +162,8 @@ Multi-Arch: same Depends: ${misc:Depends}, librust-proxmox-notify-dev (= ${binary:Version}), - librust-proxmox-sendmail-1+default-dev (>= 1.0.1-~~), - librust-proxmox-sys-1+default-dev + librust-proxmox-sendmail-1+default-dev (>= 1.0.2-~~), + librust-proxmox-sys-1+default-dev (>= 1.0.1-~~) Provides: librust-proxmox-notify-1+sendmail-dev (= ${binary:Version}), librust-proxmox-notify-1.0+sendmail-dev (= ${binary:Version}), @@ -177,7 +178,11 @@ 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-http-1+client-sync-dev (>= 1.0.5-~~), + librust-proxmox-http-1+default-dev (>= 1.0.5-~~) 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 6785932f..d1cdb540 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..7f4e8e06 --- /dev/null +++ b/proxmox-notify/src/endpoints/smtp/xoauth2.rs @@ -0,0 +1,133 @@ +use oauth2::{ + basic::BasicClient, AccessToken, AuthUrl, ClientId, ClientSecret, RefreshToken, TokenResponse, + TokenUrl, +}; +use proxmox_http::{HttpOptions, ProxyConfig}; + +use crate::{context::context, Error}; + +/// Implements `oauth2`'s `SyncHttpClient` trait. +/// +/// This allows `oauth2` to use `proxmox-http` as a backend for OAuth2 requests. +struct SyncHttpClient(proxmox_http::client::sync::Client); + +impl SyncHttpClient { + fn new() -> Result { + let proxy_config = context() + .http_proxy_config() + .map(|p| ProxyConfig::parse_proxy_url(&p)) + .transpose() + .map_err(|e| Error::Generic(format!("invalid HTTP proxy config: {e}")))?; + + let client = proxmox_http::client::sync::Client::new(HttpOptions { + proxy_config, + ..Default::default() + }); + + Ok(Self(client)) + } +} + +impl oauth2::SyncHttpClient for SyncHttpClient { + type Error = oauth2::HttpClientError; + + fn call(&self, request: oauth2::HttpRequest) -> Result { + let (parts, body) = request.into_parts(); + let request = http::Request::from_parts(parts, body.as_slice()); + + proxmox_http::HttpClient::<&[u8], Vec>::request(&self.0, request) + .map_err(|e| oauth2::HttpClientError::Other(e.to_string())) + } +} + +/// 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. +/// +/// 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. +/// +/// Therefore, while the saved token should be rotated eventually, in practice it is safe +/// to persist the new refresh token only once every 24 hours for example. +/// +/// 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(&SyncHttpClient::new()?) + .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(), + }) +} + +/// Perform a Google OAuth2 token exchange. +/// +/// 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(&SyncHttpClient::new()?) + .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