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 A55291FF141 for ; Tue, 05 May 2026 10:33:02 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 29B241A874; Tue, 5 May 2026 10:33:06 +0200 (CEST) From: Arthur Bied-Charreton To: pve-devel@lists.proxmox.com, pbs-devel@lists.proxmox.com Subject: [PATCH proxmox v5 06/27] notify: smtp: add API to exchange authorization code for refresh token Date: Tue, 5 May 2026 10:32:27 +0200 Message-ID: <20260505083248.36450-7-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: WG3QBCWHQ6P4D3LRSAVR5N3RLREJGFNJ X-Message-ID-Hash: WG3QBCWHQ6P4D3LRSAVR5N3RLREJGFNJ 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: Expose a function in SMTP API to exchange the initial authorization code for a refresh token, which can then be used by proxmox-notify to keep requesting access tokens without requiring the user to re-authorize. Azure AD's "Web" client type rejects browser-originated token requests, so this must run on the backend. Signed-off-by: Arthur Bied-Charreton --- proxmox-notify/src/api/smtp.rs | 48 +++++++++++++ proxmox-notify/src/endpoints/smtp.rs | 2 +- proxmox-notify/src/endpoints/smtp/xoauth2.rs | 73 +++++++++++++++++++- 3 files changed, 120 insertions(+), 3 deletions(-) diff --git a/proxmox-notify/src/api/smtp.rs b/proxmox-notify/src/api/smtp.rs index d1482047..1797b8f8 100644 --- a/proxmox-notify/src/api/smtp.rs +++ b/proxmox-notify/src/api/smtp.rs @@ -1,8 +1,10 @@ +use oauth2::{AuthorizationCode, ClientId, ClientSecret, RedirectUrl}; use proxmox_http_error::HttpError; use crate::api::{http_bail, http_err}; use crate::context::context; use crate::endpoints::smtp::{ + xoauth2::{exchange_google_code, exchange_microsoft_code}, DeleteableSmtpProperty, SmtpAuthMethod, SmtpConfig, SmtpConfigUpdater, SmtpPrivateConfig, SmtpPrivateConfigUpdater, State, SMTP_TYPENAME, }; @@ -223,6 +225,52 @@ pub fn update_endpoint( }) } +/// Exchange an OAuth2 authorization code for a refresh token. +/// +/// `auth_method` selects the provider; `tenant_id` is required iff +/// `auth_method == MicrosoftOAuth2`. +/// +/// The exchange runs server-side because Azure AD's "Web" client type rejects +/// browser-originated token requests. +pub fn exchange_oauth2_code( + auth_method: SmtpAuthMethod, + client_id: String, + client_secret: String, + tenant_id: Option, + authorization_code: String, + redirect_uri: String, +) -> Result { + let client_id = ClientId::new(client_id); + let client_secret = ClientSecret::new(client_secret); + let code = AuthorizationCode::new(authorization_code); + let redirect_uri = RedirectUrl::new(redirect_uri) + .map_err(|e| http_err!(BAD_REQUEST, "invalid redirect-uri: {e}"))?; + + match auth_method { + SmtpAuthMethod::GoogleOAuth2 => { + exchange_google_code(client_id, client_secret, code, redirect_uri) + .map_err(|e| http_err!(BAD_REQUEST, "{e}")) + } + SmtpAuthMethod::MicrosoftOAuth2 => { + let tenant = tenant_id.as_deref().ok_or_else(|| { + http_err!(BAD_REQUEST, "tenant-id is required for Microsoft OAuth2") + })?; + if tenant.is_empty() + || !tenant + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.') + { + http_bail!(BAD_REQUEST, "invalid tenant-id"); + } + exchange_microsoft_code(client_id, client_secret, tenant, code, redirect_uri) + .map_err(|e| http_err!(BAD_REQUEST, "{e}")) + } + SmtpAuthMethod::Plain => { + http_bail!(BAD_REQUEST, "auth-method must be an OAuth2 method"); + } + } +} + /// Delete existing smtp endpoint /// /// The caller is responsible for any needed permission checks. diff --git a/proxmox-notify/src/endpoints/smtp.rs b/proxmox-notify/src/endpoints/smtp.rs index 19b97113..74297969 100644 --- a/proxmox-notify/src/endpoints/smtp.rs +++ b/proxmox-notify/src/endpoints/smtp.rs @@ -23,7 +23,7 @@ const SMTP_SUBMISSION_STARTTLS_PORT: u16 = 587; const SMTP_SUBMISSION_TLS_PORT: u16 = 465; const SMTP_TIMEOUT: u16 = 5; -mod xoauth2; +pub(crate) mod xoauth2; pub use xoauth2::State; diff --git a/proxmox-notify/src/endpoints/smtp/xoauth2.rs b/proxmox-notify/src/endpoints/smtp/xoauth2.rs index 78d90d24..095f7aae 100644 --- a/proxmox-notify/src/endpoints/smtp/xoauth2.rs +++ b/proxmox-notify/src/endpoints/smtp/xoauth2.rs @@ -1,8 +1,8 @@ use std::path::Path; use oauth2::{ - basic::BasicClient, AccessToken, AuthUrl, ClientId, ClientSecret, RefreshToken, TokenResponse, - TokenUrl, + basic::BasicClient, AccessToken, AuthUrl, AuthorizationCode, ClientId, ClientSecret, + RedirectUrl, RefreshToken, TokenResponse, TokenUrl, }; use proxmox_http::{HttpOptions, ProxyConfig}; use serde::{Deserialize, Serialize}; @@ -206,6 +206,45 @@ pub(crate) fn get_microsoft_token( }) } +/// Exchange a Microsoft OAuth2 authorization code for a refresh token. +/// +/// Azure AD's "Web" client-type rejects browser-originated token requests +/// so this MUST run server-side regardless of where the auth popup was +/// opened from. +pub(crate) fn exchange_microsoft_code( + client_id: ClientId, + client_secret: ClientSecret, + tenant_id: &str, + code: AuthorizationCode, + redirect_uri: RedirectUrl, +) -> 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}")))?, + ) + .set_redirect_uri(redirect_uri); + + let token_result = client + .exchange_code(code) + .request(&SyncHttpClient::new()?) + .map_err(|e| Error::Generic(format!("OAuth2 token exchange failed: {e}")))?; + + token_result + .refresh_token() + .map(|t| t.secret().to_owned()) + .ok_or_else(|| Error::Generic("token endpoint did not return a refresh token".into())) +} + /// Perform a Google OAuth2 token exchange. /// /// Google refresh tokens' TTL is extended at every use. As long as @@ -239,3 +278,33 @@ pub(crate) fn get_google_token( refresh_token: token_result.refresh_token().cloned(), }) } + +/// Exchange a Google OAuth2 authorization code for a refresh token. +pub(crate) fn exchange_google_code( + client_id: ClientId, + client_secret: ClientSecret, + code: AuthorizationCode, + redirect_uri: RedirectUrl, +) -> 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}")))?, + ) + .set_redirect_uri(redirect_uri); + + let token_result = client + .exchange_code(code) + .request(&SyncHttpClient::new()?) + .map_err(|e| Error::Generic(format!("OAuth2 token exchange failed: {e}")))?; + + token_result + .refresh_token() + .map(|t| t.secret().to_owned()) + .ok_or_else(|| Error::Generic("token endpoint did not return a refresh token".into())) +} -- 2.47.3