Add the OAuth2 client & tenant IDs to the public config, and the client
secret to the private config.

The refresh token is more dynamic and will be updated on the fly by
proxmox-notify. In order to avoid the config file changing without user
interaction, it is passed as its own parameter and will be managed
separately in per-endpoint state files.

Signed-off-by: Arthur Bied-Charreton <[email protected]>
---
 proxmox-notify/src/api/smtp.rs       | 94 +++++++++++++++++++++-------
 proxmox-notify/src/endpoints/smtp.rs | 45 ++++++++++++-
 2 files changed, 114 insertions(+), 25 deletions(-)

diff --git a/proxmox-notify/src/api/smtp.rs b/proxmox-notify/src/api/smtp.rs
index 41a94581..8fe26c48 100644
--- a/proxmox-notify/src/api/smtp.rs
+++ b/proxmox-notify/src/api/smtp.rs
@@ -30,6 +30,31 @@ pub fn get_endpoint(config: &Config, name: &str) -> 
Result<SmtpConfig, HttpError
         .map_err(|_| http_err!(NOT_FOUND, "endpoint '{name}' not found"))
 }
 
+/// Apply `updater` to the private config identified by `name`, and set
+/// the private config entry afterwards.
+fn update_private_config(
+    config: &mut Config,
+    name: &str,
+    updater: impl FnOnce(&mut SmtpPrivateConfig),
+) -> Result<(), HttpError> {
+    let mut private_config: SmtpPrivateConfig = get_private_config(config, 
name)?;
+    updater(&mut private_config);
+
+    super::set_private_config_entry(config, private_config, SMTP_TYPENAME, 
name)
+}
+
+fn get_private_config(config: &Config, name: &str) -> 
Result<SmtpPrivateConfig, HttpError> {
+    config
+        .private_config
+        .lookup(SMTP_TYPENAME, name)
+        .map_err(|e| {
+            http_err!(
+                NOT_FOUND,
+                "no private config found for SMTP endpoint : '{e}'"
+            )
+        })
+}
+
 /// Add a new smtp endpoint.
 ///
 /// The caller is responsible for any needed permission checks.
@@ -38,10 +63,16 @@ pub fn get_endpoint(config: &Config, name: &str) -> 
Result<SmtpConfig, HttpError
 ///   - an entity with the same name already exists (`400 Bad request`)
 ///   - the configuration could not be saved (`500 Internal server error`)
 ///   - mailto *and* mailto_user are both set to `None`
+///
+/// The OAuth2 refresh token lives in a per-endpoint state file rather than
+/// the notifications config, so it is passed through as a separate parameter.
+/// Callers pass `Some(_)` only when seeding the state at create time or after
+/// a re-authorization.
 pub fn add_endpoint(
     config: &mut Config,
     endpoint_config: SmtpConfig,
     private_endpoint_config: SmtpPrivateConfig,
+    oauth2_refresh_token: Option<String>,
 ) -> Result<(), HttpError> {
     if endpoint_config.name != private_endpoint_config.name {
         // Programming error by the user of the crate, thus we panic
@@ -83,11 +114,17 @@ pub fn add_endpoint(
 /// Returns a `HttpError` if:
 ///   - the configuration could not be saved (`500 Internal server error`)
 ///   - mailto *and* mailto_user are both set to `None`
+///
+/// The OAuth2 refresh token lives in a per-endpoint state file rather than
+/// the notifications config, so it is passed through as a separate parameter.
+/// Callers pass `Some(_)` only when seeding the state at create time or after
+/// a re-authorization.
 pub fn update_endpoint(
     config: &mut Config,
     name: &str,
     updater: SmtpConfigUpdater,
     private_endpoint_config_updater: SmtpPrivateConfigUpdater,
+    oauth2_refresh_token: Option<String>,
     delete: Option<&[DeleteableSmtpProperty]>,
     digest: Option<&[u8]>,
 ) -> Result<(), HttpError> {
@@ -103,15 +140,15 @@ pub fn update_endpoint(
                 DeleteableSmtpProperty::Disable => endpoint.disable = None,
                 DeleteableSmtpProperty::Mailto => endpoint.mailto.clear(),
                 DeleteableSmtpProperty::MailtoUser => 
endpoint.mailto_user.clear(),
-                DeleteableSmtpProperty::Password => 
super::set_private_config_entry(
-                    config,
-                    SmtpPrivateConfig {
-                        name: name.to_string(),
-                        password: None,
-                    },
-                    SMTP_TYPENAME,
-                    name,
-                )?,
+                DeleteableSmtpProperty::Password => {
+                    update_private_config(config, name, |c| c.password = None)?
+                }
+                DeleteableSmtpProperty::AuthMethod => endpoint.auth_method = 
None,
+                DeleteableSmtpProperty::OAuth2ClientId => 
endpoint.oauth2_client_id = None,
+                DeleteableSmtpProperty::OAuth2ClientSecret => {
+                    update_private_config(config, name, |c| 
c.oauth2_client_secret = None)?
+                }
+                DeleteableSmtpProperty::OAuth2TenantId => 
endpoint.oauth2_tenant_id = None,
                 DeleteableSmtpProperty::Port => endpoint.port = None,
                 DeleteableSmtpProperty::Username => endpoint.username = None,
             }
@@ -139,29 +176,24 @@ pub fn update_endpoint(
     if let Some(mode) = updater.mode {
         endpoint.mode = Some(mode);
     }
-    if let Some(password) = private_endpoint_config_updater.password {
-        super::set_private_config_entry(
-            config,
-            SmtpPrivateConfig {
-                name: name.into(),
-                password: Some(password),
-            },
-            SMTP_TYPENAME,
-            name,
-        )?;
+    if let Some(auth_method) = updater.auth_method {
+        endpoint.auth_method = Some(auth_method);
     }
-
     if let Some(author) = updater.author {
         endpoint.author = Some(author);
     }
-
     if let Some(comment) = updater.comment {
         endpoint.comment = Some(comment);
     }
-
     if let Some(disable) = updater.disable {
         endpoint.disable = Some(disable);
     }
+    if let Some(oauth2_client_id) = updater.oauth2_client_id {
+        endpoint.oauth2_client_id = Some(oauth2_client_id);
+    }
+    if let Some(oauth2_tenant_id) = updater.oauth2_tenant_id {
+        endpoint.oauth2_tenant_id = Some(oauth2_tenant_id);
+    }
 
     if endpoint.mailto.is_empty() && endpoint.mailto_user.is_empty() {
         http_bail!(
@@ -169,6 +201,14 @@ pub fn update_endpoint(
             "must at least provide one recipient, either in mailto or in 
mailto-user"
         );
     }
+    update_private_config(config, name, |c| {
+        if let Some(password) = private_endpoint_config_updater.password {
+            c.password = Some(password);
+        }
+        if let Some(oauth2_client_secret) = 
private_endpoint_config_updater.oauth2_client_secret {
+            c.oauth2_client_secret = Some(oauth2_client_secret);
+        }
+    })?;
 
     config
         .config
@@ -195,6 +235,7 @@ pub fn delete_endpoint(config: &mut Config, name: &str) -> 
Result<(), HttpError>
     super::ensure_safe_to_delete(config, name)?;
 
     super::remove_private_config_entry(config, name)?;
+
     config.config.sections.remove(name);
 
     Ok(())
@@ -204,7 +245,7 @@ pub fn delete_endpoint(config: &mut Config, name: &str) -> 
Result<(), HttpError>
 pub mod tests {
     use super::*;
     use crate::api::test_helpers::*;
-    use crate::endpoints::smtp::SmtpMode;
+    use crate::endpoints::smtp::{SmtpAuthMethod, SmtpMode};
 
     pub fn add_smtp_endpoint_for_test(config: &mut Config, name: &str) -> 
Result<(), HttpError> {
         add_endpoint(
@@ -217,6 +258,7 @@ pub mod tests {
                 author: Some("root".into()),
                 comment: Some("Comment".into()),
                 mode: Some(SmtpMode::StartTls),
+                auth_method: Some(SmtpAuthMethod::Plain),
                 server: "localhost".into(),
                 port: Some(555),
                 username: Some("username".into()),
@@ -225,7 +267,9 @@ pub mod tests {
             SmtpPrivateConfig {
                 name: name.into(),
                 password: Some("password".into()),
+                oauth2_client_secret: None,
             },
+            None,
         )?;
 
         assert!(get_endpoint(config, name).is_ok());
@@ -257,6 +301,7 @@ pub mod tests {
                 Default::default(),
                 None,
                 None,
+                None,
             )
             .is_err()
         );
@@ -276,6 +321,7 @@ pub mod tests {
                 Default::default(),
                 Default::default(),
                 None,
+                None,
                 Some(&[0; 32]),
             )
             .is_err()
@@ -308,6 +354,7 @@ pub mod tests {
             },
             Default::default(),
             None,
+            None,
             Some(&digest),
         )?;
 
@@ -331,6 +378,7 @@ pub mod tests {
             "smtp-endpoint",
             Default::default(),
             Default::default(),
+            None,
             Some(&[
                 DeleteableSmtpProperty::Author,
                 DeleteableSmtpProperty::MailtoUser,
diff --git a/proxmox-notify/src/endpoints/smtp.rs 
b/proxmox-notify/src/endpoints/smtp.rs
index cf02a750..3bb0eec6 100644
--- a/proxmox-notify/src/endpoints/smtp.rs
+++ b/proxmox-notify/src/endpoints/smtp.rs
@@ -83,11 +83,20 @@ pub struct SmtpConfig {
     pub port: Option<u16>,
     #[serde(skip_serializing_if = "Option::is_none")]
     pub mode: Option<SmtpMode>,
+    /// Method to be used for authentication.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub auth_method: Option<SmtpAuthMethod>,
     /// Username to use during authentication.
-    /// If no username is set, no authentication will be performed.
-    /// The PLAIN and LOGIN authentication methods are supported
     #[serde(skip_serializing_if = "Option::is_none")]
     pub username: Option<String>,
+    /// Client ID for XOAUTH2 authentication method.
+    /// If set to `None`, no authentication will be performed.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub oauth2_client_id: Option<String>,
+    /// Tenant ID for XOAUTH2 authentication method. Only required for
+    /// Microsoft Exchange Online OAuth2.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub oauth2_tenant_id: Option<String>,
     /// Mail address to send a mail to.
     #[serde(default, skip_serializing_if = "Vec::is_empty")]
     #[updater(serde(skip_serializing_if = "Option::is_none"))]
@@ -134,12 +143,39 @@ pub enum DeleteableSmtpProperty {
     MailtoUser,
     /// Delete `password`
     Password,
+    /// Delete `auth_method`
+    AuthMethod,
+    /// Delete `oauth2_client_id`
+    #[serde(rename = "oauth2-client-id")]
+    OAuth2ClientId,
+    /// Delete `oauth2_client_secret`
+    #[serde(rename = "oauth2-client-secret")]
+    OAuth2ClientSecret,
+    /// Delete `oauth2_tenant_id`
+    #[serde(rename = "oauth2-tenant-id")]
+    OAuth2TenantId,
     /// Delete `port`
     Port,
     /// Delete `username`
     Username,
 }
 
+/// Authentication mode to use for SMTP.
+#[api]
+#[derive(Serialize, Deserialize, Clone, Debug, Default, Copy)]
+#[serde(rename_all = "kebab-case")]
+pub enum SmtpAuthMethod {
+    /// Username + password
+    #[default]
+    Plain,
+    /// Google OAuth2
+    #[serde(rename = "google-oauth2")]
+    GoogleOAuth2,
+    /// Microsoft OAuth2
+    #[serde(rename = "microsoft-oauth2")]
+    MicrosoftOAuth2,
+}
+
 #[api]
 #[derive(Serialize, Deserialize, Clone, Updater, Debug)]
 #[serde(rename_all = "kebab-case")]
@@ -150,9 +186,14 @@ pub struct SmtpPrivateConfig {
     /// Name of the endpoint
     #[updater(skip)]
     pub name: String,
+
     /// The password to use during authentication.
     #[serde(skip_serializing_if = "Option::is_none")]
     pub password: Option<String>,
+
+    /// OAuth2 client secret
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub oauth2_client_secret: Option<String>,
 }
 
 /// A sendmail notification endpoint.
-- 
2.47.3



Reply via email to