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 <[email protected]> --- proxmox-notify/src/api/smtp.rs | 52 +++++++++++++- proxmox-notify/src/endpoints/smtp.rs | 2 +- proxmox-notify/src/endpoints/smtp/xoauth2.rs | 73 +++++++++++++++++++- 3 files changed, 122 insertions(+), 5 deletions(-) diff --git a/proxmox-notify/src/api/smtp.rs b/proxmox-notify/src/api/smtp.rs index 8fe26c48..80f3ede6 100644 --- a/proxmox-notify/src/api/smtp.rs +++ b/proxmox-notify/src/api/smtp.rs @@ -1,10 +1,12 @@ +use oauth2::{AuthorizationCode, ClientId, ClientSecret, RedirectUrl}; use proxmox_http_error::HttpError; use crate::Config; use crate::api::{http_bail, http_err}; use crate::endpoints::smtp::{ - DeleteableSmtpProperty, SMTP_TYPENAME, SmtpConfig, SmtpConfigUpdater, SmtpPrivateConfig, - SmtpPrivateConfigUpdater, + DeleteableSmtpProperty, SMTP_TYPENAME, SmtpAuthMethod, SmtpConfig, SmtpConfigUpdater, + SmtpPrivateConfig, SmtpPrivateConfigUpdater, State, + xoauth2::{exchange_google_code, exchange_microsoft_code}, }; /// Get a list of all smtp endpoints. @@ -222,6 +224,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<String>, + authorization_code: String, + redirect_uri: String, +) -> Result<String, HttpError> { + 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 3bb0eec6..e03864d4 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 ba479f3f..38ec7493 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::{ - AccessToken, AuthUrl, ClientId, ClientSecret, RefreshToken, TokenResponse, TokenUrl, - basic::BasicClient, + AccessToken, AuthUrl, AuthorizationCode, ClientId, ClientSecret, RedirectUrl, RefreshToken, + TokenResponse, TokenUrl, basic::BasicClient, }; 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<String, Error> { + 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<String, Error> { + 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
