From e5934c13e9a13083923479342eac2eb346af5f44 Mon Sep 17 00:00:00 2001 From: hnolde Date: Wed, 22 Oct 2025 10:20:59 +0200 Subject: [PATCH 01/13] Add XoAuth2 support to fetch the token from the SMTP Provider and refresh used by Google or Microsoft --- src/api/admin.rs | 163 ++++++++++++++++++++++++ src/config.rs | 61 ++++++++- src/error.rs | 6 + src/mail.rs | 132 ++++++++++++++++++- src/static/scripts/admin_settings.js | 35 +++++ src/static/templates/admin/settings.hbs | 30 +++++ 6 files changed, 421 insertions(+), 6 deletions(-) diff --git a/src/api/admin.rs b/src/api/admin.rs index d52e24ef..c7325965 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -1,8 +1,13 @@ use once_cell::sync::Lazy; +use percent_encoding::{percent_encode, NON_ALPHANUMERIC}; use reqwest::Method; use serde::de::DeserializeOwned; use serde_json::Value; use std::env; +use std::collections::HashMap; +use std::sync::RwLock; +use std::time::{SystemTime, UNIX_EPOCH}; +use data_encoding::BASE64URL_NOPAD; use rocket::serde::json::Json; use rocket::{ @@ -57,6 +62,9 @@ pub fn routes() -> Vec { delete_config, backup_db, test_smtp, + refresh_oauth2_token_endpoint, + oauth2_authorize, + oauth2_callback, users_overview, organizations_overview, delete_organization, @@ -88,6 +96,9 @@ static DB_TYPE: Lazy<&str> = Lazy::new(|| { static CAN_BACKUP: Lazy = Lazy::new(|| DbConnType::from_url(&CONFIG.database_url()).map(|t| t == DbConnType::sqlite).unwrap_or(false)); +// OAuth2 state storage for CSRF protection (state -> expiration timestamp) +static OAUTH2_STATES: Lazy>> = Lazy::new(|| RwLock::new(HashMap::new())); + #[get("/")] fn admin_disabled() -> &'static str { "The admin panel is disabled, please configure the 'ADMIN_TOKEN' variable to enable it" @@ -329,6 +340,158 @@ async fn test_smtp(data: Json, _token: AdminToken) -> EmptyResult { } } +#[post("/test/oauth2")] +async fn refresh_oauth2_token_endpoint(_token: AdminToken) -> EmptyResult { + if CONFIG.smtp_oauth2_client_id().is_none() { + err!("OAuth2 is not configured") + } + + mail::refresh_oauth2_token().await.map(|_| ()) +} + +#[get("/oauth2/authorize")] +fn oauth2_authorize(_token: AdminToken) -> Result { + // Check if OAuth2 is configured + let client_id = CONFIG.smtp_oauth2_client_id().ok_or("OAuth2 Client ID not configured")?; + let auth_url = CONFIG.smtp_oauth2_auth_url().ok_or("OAuth2 Authorization URL not configured")?; + let scopes = CONFIG.smtp_oauth2_scopes(); + + // Generate a random state token for CSRF protection + let state = crate::crypto::encode_random_bytes::<32>(BASE64URL_NOPAD); + + // Store state with expiration (10 minutes from now) + let expiration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + 600; + + OAUTH2_STATES.write().unwrap().insert(state.clone(), expiration); + + // Clean up expired states + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); + OAUTH2_STATES.write().unwrap().retain(|_, &mut exp| exp > now); + + // Construct redirect URI + let redirect_uri = format!("{}/admin/oauth2/callback", CONFIG.domain()); + + // Build authorization URL + let auth_url = format!( + "{}?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}&access_type=offline&prompt=consent", + auth_url, + percent_encode(client_id.as_bytes(), NON_ALPHANUMERIC), + percent_encode(redirect_uri.as_bytes(), NON_ALPHANUMERIC), + percent_encode(scopes.as_bytes(), NON_ALPHANUMERIC), + percent_encode(state.as_bytes(), NON_ALPHANUMERIC) + ); + + Ok(Redirect::to(auth_url)) +} + +#[derive(FromForm)] +struct OAuth2CallbackParams { + code: Option, + state: Option, + error: Option, + error_description: Option, +} + +#[get("/oauth2/callback?")] +async fn oauth2_callback(params: OAuth2CallbackParams) -> Result, Error> { + // Check for errors from OAuth2 provider + if let Some(error) = params.error { + let description = params.error_description.unwrap_or_else(|| "Unknown error".to_string()); + return Err(Error::new("OAuth2 Authorization Failed", format!("{}: {}", error, description))); + } + + // Validate required parameters + let code = params.code.ok_or("Authorization code not provided")?; + let state = params.state.ok_or("State parameter not provided")?; + + // Validate state token + let valid_state = { + let states = OAUTH2_STATES.read().unwrap(); + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); + states.get(&state).map_or(false, |&exp| exp > now) + }; + + if !valid_state { + return Err(Error::new("OAuth2 State Validation Failed", "Invalid or expired state token")); + } + + // Remove used state + OAUTH2_STATES.write().unwrap().remove(&state); + + // Exchange authorization code for tokens + let client_id = CONFIG.smtp_oauth2_client_id().ok_or("OAuth2 Client ID not configured")?; + let client_secret = CONFIG.smtp_oauth2_client_secret().ok_or("OAuth2 Client Secret not configured")?; + let token_url = CONFIG.smtp_oauth2_token_url().ok_or("OAuth2 Token URL not configured")?; + let redirect_uri = format!("{}/admin/oauth2/callback", CONFIG.domain()); + + let form_params = [ + ("grant_type", "authorization_code"), + ("code", &code), + ("redirect_uri", &redirect_uri), + ("client_id", &client_id), + ("client_secret", &client_secret), + ]; + + let client = reqwest::Client::new(); + let response = client + .post(&token_url) + .form(&form_params) + .send() + .await + .map_err(|e| Error::new("OAuth2 Token Exchange Error", e.to_string()))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_else(|_| String::from("Unable to read response body")); + return Err(Error::new("OAuth2 Token Exchange Failed", format!("HTTP {}: {}", status, body))); + } + + let token_response: Value = response + .json() + .await + .map_err(|e| Error::new("OAuth2 Token Parse Error", e.to_string()))?; + + // Extract refresh_token from response + let refresh_token = token_response + .get("refresh_token") + .and_then(|v| v.as_str()) + .ok_or("No refresh_token in response")?; + + // Save refresh_token to configuration + let config_builder: ConfigBuilder = serde_json::from_value(json!({ + "smtp_oauth2_refresh_token": refresh_token + })) + .map_err(|e| Error::new("ConfigBuilder serialization error", e.to_string()))?; + CONFIG.update_config_partial(config_builder).await?; + + // Return success page + let success_html = format!( + r#" + + + OAuth2 Authorization Successful + + + +

✓ OAuth2 Authorization Successful!

+

The refresh token has been saved to your configuration.

+

You can now close this window and return to the admin panel.

+

Return to Admin Settings

+ +"#, + admin_url() + ); + + Ok(Html(success_html)) +} + #[get("/logout")] fn logout(cookies: &CookieJar<'_>) -> Redirect { cookies.remove(Cookie::build(COOKIE_NAME).path(admin_path())); diff --git a/src/config.rs b/src/config.rs index d7f24866..377912d9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -780,6 +780,18 @@ make_config! { smtp_password: Pass, true, option; /// SMTP Auth mechanism |> Defaults for SSL is "Plain" and "Login" and nothing for Non-SSL connections. Possible values: ["Plain", "Login", "Xoauth2"]. Multiple options need to be separated by a comma ','. smtp_auth_mechanism: String, true, option; + /// SMTP OAuth2 Client ID |> OAuth2 Client ID for XOAUTH2 authentication + smtp_oauth2_client_id: String, true, option; + /// SMTP OAuth2 Client Secret |> OAuth2 Client Secret for XOAUTH2 authentication + smtp_oauth2_client_secret: Pass, true, option; + /// SMTP OAuth2 Authorization URL |> OAuth2 Authorization Server URL + smtp_oauth2_auth_url: String, true, option; + /// SMTP OAuth2 Token URL |> OAuth2 Token Server URL for refreshing access tokens + smtp_oauth2_token_url: String, true, option; + /// SMTP OAuth2 Refresh Token |> OAuth2 Refresh Token for obtaining new access tokens + smtp_oauth2_refresh_token: Pass, true, option; + /// SMTP OAuth2 Scopes |> Comma-separated list of OAuth2 scopes + smtp_oauth2_scopes: String, true, def, "https://mail.google.com/".to_string(); /// SMTP connection timeout |> Number of seconds when to stop trying to connect to the SMTP server smtp_timeout: u64, true, def, 15; /// Server name sent during HELO |> By default this value should be the machine's hostname, but might need to be changed in case it trips some anti-spam filters @@ -1036,8 +1048,12 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> { err!("Both `SMTP_HOST` and `SMTP_FROM` need to be set for email support without `USE_SENDMAIL`") } + // Require both username and password for traditional auth, unless OAuth2 is configured if cfg.smtp_username.is_some() != cfg.smtp_password.is_some() { - err!("Both `SMTP_USERNAME` and `SMTP_PASSWORD` need to be set to enable email authentication without `USE_SENDMAIL`") + // Allow username without password if OAuth2 is configured + if cfg.smtp_oauth2_client_id.is_none() { + err!("Both `SMTP_USERNAME` and `SMTP_PASSWORD` need to be set to enable email authentication without `USE_SENDMAIL`, unless using OAuth2") + } } } @@ -1129,6 +1145,47 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> { err!("`AUTH_REQUEST_PURGE_SCHEDULE` is not a valid cron expression") } + // OAuth2 validation - triggered when SMTP Auth mechanism includes xoauth2 + let uses_xoauth2 = cfg.smtp_auth_mechanism + .as_ref() + .map(|m| m.to_lowercase().contains("xoauth2")) + .unwrap_or(false); + + if uses_xoauth2 { + if cfg.smtp_oauth2_client_id.is_none() { + err!("`SMTP_OAUTH2_CLIENT_ID` must be set when SMTP_AUTH_MECHANISM includes xoauth2"); + } + if cfg.smtp_oauth2_client_secret.is_none() { + err!("`SMTP_OAUTH2_CLIENT_SECRET` must be set when SMTP_AUTH_MECHANISM includes xoauth2"); + } + if cfg.smtp_oauth2_auth_url.is_none() { + err!("`SMTP_OAUTH2_AUTH_URL` must be set when SMTP_AUTH_MECHANISM includes xoauth2"); + } + if cfg.smtp_oauth2_token_url.is_none() { + err!("`SMTP_OAUTH2_TOKEN_URL` must be set when SMTP_AUTH_MECHANISM includes xoauth2"); + } + if cfg.smtp_oauth2_scopes.is_empty() { + err!("`SMTP_OAUTH2_SCOPES` must be set when SMTP_AUTH_MECHANISM includes xoauth2"); + } + if cfg.smtp_username.is_none() { + err!("`SMTP_USERNAME` must be set for OAuth2 authentication"); + } + + // Validate that auth URL is a valid URL + if let Some(ref auth_url) = cfg.smtp_oauth2_auth_url { + if !auth_url.starts_with("http://") && !auth_url.starts_with("https://") { + err!("`SMTP_OAUTH2_AUTH_URL` must be a valid URL starting with http:// or https://"); + } + } + + // Validate that token URL is a valid URL + if let Some(ref token_url) = cfg.smtp_oauth2_token_url { + if !token_url.starts_with("http://") && !token_url.starts_with("https://") { + err!("`SMTP_OAUTH2_TOKEN_URL` must be a valid URL starting with http:// or https://"); + } + } + } + if !cfg.disable_admin_token { match cfg.admin_token.as_ref() { Some(t) if t.starts_with("$argon2") => { @@ -1418,7 +1475,7 @@ impl Config { Ok(()) } - async fn update_config_partial(&self, other: ConfigBuilder) -> Result<(), Error> { + pub async fn update_config_partial(&self, other: ConfigBuilder) -> Result<(), Error> { let builder = { let usr = &self.inner.read().unwrap()._usr; let mut _overrides = Vec::new(); diff --git a/src/error.rs b/src/error.rs index 06ebf3aa..5274db1d 100644 --- a/src/error.rs +++ b/src/error.rs @@ -123,6 +123,12 @@ impl std::fmt::Debug for Error { } } +impl From<&str> for Error { + fn from(err: &str) -> Self { + Error::new(err, "") + } +} + impl Error { pub fn new, N: Into>(usr_msg: M, log_msg: N) -> Self { (usr_msg, log_msg.into()).into() diff --git a/src/mail.rs b/src/mail.rs index b6b8337c..31e6a810 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -1,6 +1,9 @@ use chrono::NaiveDateTime; use percent_encoding::{percent_encode, NON_ALPHANUMERIC}; use std::{env::consts::EXE_SUFFIX, str::FromStr}; +use std::sync::{LazyLock, RwLock}; +use std::time::{SystemTime, UNIX_EPOCH}; +use serde::{Deserialize, Serialize}; use lettre::{ message::{Attachment, Body, Mailbox, Message, MultiPart, SinglePart}, @@ -21,6 +24,92 @@ use crate::{ CONFIG, }; +// OAuth2 Token structures +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OAuth2Token { + access_token: String, + refresh_token: Option, + expires_at: Option, + token_type: String, +} + +#[derive(Debug, Deserialize)] +struct TokenRefreshResponse { + access_token: String, + refresh_token: Option, + expires_in: Option, + token_type: String, +} + +pub async fn refresh_oauth2_token() -> Result { + let client_id = CONFIG.smtp_oauth2_client_id().ok_or("OAuth2 Client ID not configured")?; + let client_secret = CONFIG.smtp_oauth2_client_secret().ok_or("OAuth2 Client Secret not configured")?; + let refresh_token = CONFIG.smtp_oauth2_refresh_token().ok_or("OAuth2 Refresh Token not configured")?; + let token_url = CONFIG.smtp_oauth2_token_url().ok_or("OAuth2 Token URL not configured")?; + + let form_params = [ + ("grant_type", "refresh_token"), + ("refresh_token", &refresh_token), + ("client_id", &client_id), + ("client_secret", &client_secret), + ]; + + let client = reqwest::Client::new(); + let response = client + .post(&token_url) + .form(&form_params) + .send() + .await + .map_err(|e| Error::new("OAuth2 Token Refresh Error", e.to_string()))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_else(|_| String::from("Unable to read response body")); + return Err(Error::new("OAuth2 Token Refresh Failed", format!("HTTP {status}: {body}"))); + } + + let token_response: TokenRefreshResponse = response + .json() + .await + .map_err(|e| Error::new("OAuth2 Token Parse Error", e.to_string()))?; + + let expires_at = token_response.expires_in.map(|expires_in| { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + expires_in + }); + + Ok(OAuth2Token { + access_token: token_response.access_token, + refresh_token: token_response.refresh_token.or(Some(refresh_token)), + expires_at, + token_type: token_response.token_type, + }) +} + +async fn get_valid_oauth2_token() -> Result { + static TOKEN_CACHE: LazyLock>> = LazyLock::new(|| RwLock::new(None)); + + let cached_token = TOKEN_CACHE.read().unwrap().clone(); + + if let Some(token) = cached_token { + // Check if token is still valid (with 5 min buffer) + if let Some(expires_at) = token.expires_at { + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); + if now + 300 < expires_at { + return Ok(token); + } + } + } + + // Refresh token + let new_token = refresh_oauth2_token().await?; + *TOKEN_CACHE.write().unwrap() = Some(new_token.clone()); + + Ok(new_token) +} + fn sendmail_transport() -> AsyncSendmailTransport { if let Some(command) = CONFIG.sendmail_command() { AsyncSendmailTransport::new_with_command(command) @@ -29,7 +118,7 @@ fn sendmail_transport() -> AsyncSendmailTransport { } } -fn smtp_transport() -> AsyncSmtpTransport { +async fn smtp_transport() -> AsyncSmtpTransport { use std::time::Duration; let host = CONFIG.smtp_host().unwrap(); @@ -57,8 +146,43 @@ fn smtp_transport() -> AsyncSmtpTransport { smtp_client }; - let smtp_client = match (CONFIG.smtp_username(), CONFIG.smtp_password()) { - (Some(user), Some(pass)) => smtp_client.credentials(Credentials::new(user, pass)), + // Handle authentication - OAuth2 or traditional + let smtp_client = match (CONFIG.smtp_username(), CONFIG.smtp_password(), CONFIG.smtp_oauth2_client_id()) { + (Some(user), Some(pass), None) => { + // Traditional authentication with username and password + smtp_client.credentials(Credentials::new(user, pass)) + } + (Some(user), None, Some(_)) => { + // OAuth2 authentication + match get_valid_oauth2_token().await { + Ok(token) => { + // Pass the access token directly as password - lettre's Xoauth2 mechanism + // will format it correctly as: user={user}\x01auth=Bearer {token}\x01\x01 + smtp_client.credentials(Credentials::new(user, token.access_token)) + } + Err(e) => { + error!("Error fetching OAuth2 token: {}", e); + warn!("Failed to get OAuth2 token, SMTP transport may not work properly"); + smtp_client + } + } + } + (Some(user), Some(pass), Some(_)) => { + // Both password and OAuth2 configured - prefer OAuth2 + warn!("Both SMTP_PASSWORD and SMTP_OAUTH2_CLIENT_ID are set. Using OAuth2 authentication."); + match get_valid_oauth2_token().await { + Ok(token) => { + // Pass the access token directly as password - lettre's Xoauth2 mechanism + // will format it correctly as: user={user}\x01auth=Bearer {token}\x01\x01 + smtp_client.credentials(Credentials::new(user, token.access_token)) + } + Err(e) => { + error!("Error fetching OAuth2 token: {}", e); + warn!("Falling back to password authentication"); + smtp_client.credentials(Credentials::new(user, pass)) + } + } + } _ => smtp_client, }; @@ -671,7 +795,7 @@ async fn send_with_selected_transport(email: Message) -> EmptyResult { } } } else { - match smtp_transport().send(email).await { + match smtp_transport().await.send(email).await { Ok(_) => Ok(()), // Match some common errors and make them more user friendly Err(e) => { diff --git a/src/static/scripts/admin_settings.js b/src/static/scripts/admin_settings.js index 3d61a508..8ed1f13c 100644 --- a/src/static/scripts/admin_settings.js +++ b/src/static/scripts/admin_settings.js @@ -26,6 +26,33 @@ function smtpTest(event) { ); } +function oauth2RefreshToken(event) { + event.preventDefault(); + event.stopPropagation(); + if (formHasChanges(config_form)) { + alert("Config has been changed but not yet saved.\nPlease save the changes first before refreshing the OAuth2 token."); + return false; + } + + _post(`${BASE_URL}/admin/test/oauth2`, + "OAuth2 token refreshed successfully", + "Error refreshing OAuth2 token", + null, false + ); +} + +function oauth2Authorize(event) { + event.preventDefault(); + event.stopPropagation(); + if (formHasChanges(config_form)) { + alert("Config has been changed but not yet saved.\nPlease save the changes first before starting OAuth2 authorization."); + return false; + } + + // Redirect to the OAuth2 authorization endpoint + window.location.href = `${BASE_URL}/admin/oauth2/authorize`; +} + function getFormData() { let data = {}; @@ -225,6 +252,14 @@ document.addEventListener("DOMContentLoaded", (/*event*/) => { if (btnSmtpTest) { btnSmtpTest.addEventListener("click", smtpTest); } + const btnOAuth2Refresh = document.getElementById("oauth2RefreshTest"); + if (btnOAuth2Refresh) { + btnOAuth2Refresh.addEventListener("click", oauth2RefreshToken); + } + const btnOAuth2Authorize = document.getElementById("oauth2Authorize"); + if (btnOAuth2Authorize) { + btnOAuth2Authorize.addEventListener("click", oauth2Authorize); + } config_form.addEventListener("submit", saveConfig); diff --git a/src/static/templates/admin/settings.hbs b/src/static/templates/admin/settings.hbs index fb066cb4..c3437012 100644 --- a/src/static/templates/admin/settings.hbs +++ b/src/static/templates/admin/settings.hbs @@ -50,6 +50,36 @@ {{/if}} {{/each}} {{#case group "smtp"}} +
+
+ +
+
+
+ +
+ + + Click to authenticate with your email provider and obtain a refresh token. + +
+
+
+ +
+ +
+
From bfbedb05d67770ed3b5445f36325f18d695d0cf3 Mon Sep 17 00:00:00 2001 From: hnolde Date: Wed, 22 Oct 2025 11:15:05 +0200 Subject: [PATCH 02/13] Fix: is_some_and and formatting issues --- src/api/admin.rs | 19 ++++++------------- src/config.rs | 5 +---- src/mail.rs | 21 ++++++++------------- 3 files changed, 15 insertions(+), 30 deletions(-) diff --git a/src/api/admin.rs b/src/api/admin.rs index c7325965..4f03a9c4 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -360,10 +360,7 @@ fn oauth2_authorize(_token: AdminToken) -> Result { let state = crate::crypto::encode_random_bytes::<32>(BASE64URL_NOPAD); // Store state with expiration (10 minutes from now) - let expiration = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() + 600; + let expiration = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + 600; OAUTH2_STATES.write().unwrap().insert(state.clone(), expiration); @@ -411,7 +408,7 @@ async fn oauth2_callback(params: OAuth2CallbackParams) -> Result, E let valid_state = { let states = OAUTH2_STATES.read().unwrap(); let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); - states.get(&state).map_or(false, |&exp| exp > now) + states.get(&state).is_some_and(|&exp| exp > now) }; if !valid_state { @@ -449,16 +446,12 @@ async fn oauth2_callback(params: OAuth2CallbackParams) -> Result, E return Err(Error::new("OAuth2 Token Exchange Failed", format!("HTTP {}: {}", status, body))); } - let token_response: Value = response - .json() - .await - .map_err(|e| Error::new("OAuth2 Token Parse Error", e.to_string()))?; + let token_response: Value = + response.json().await.map_err(|e| Error::new("OAuth2 Token Parse Error", e.to_string()))?; // Extract refresh_token from response - let refresh_token = token_response - .get("refresh_token") - .and_then(|v| v.as_str()) - .ok_or("No refresh_token in response")?; + let refresh_token = + token_response.get("refresh_token").and_then(|v| v.as_str()).ok_or("No refresh_token in response")?; // Save refresh_token to configuration let config_builder: ConfigBuilder = serde_json::from_value(json!({ diff --git a/src/config.rs b/src/config.rs index 377912d9..b7b89bad 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1146,10 +1146,7 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> { } // OAuth2 validation - triggered when SMTP Auth mechanism includes xoauth2 - let uses_xoauth2 = cfg.smtp_auth_mechanism - .as_ref() - .map(|m| m.to_lowercase().contains("xoauth2")) - .unwrap_or(false); + let uses_xoauth2 = cfg.smtp_auth_mechanism.as_ref().map(|m| m.to_lowercase().contains("xoauth2")).unwrap_or(false); if uses_xoauth2 { if cfg.smtp_oauth2_client_id.is_none() { diff --git a/src/mail.rs b/src/mail.rs index 31e6a810..422b61d9 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -1,9 +1,9 @@ use chrono::NaiveDateTime; use percent_encoding::{percent_encode, NON_ALPHANUMERIC}; -use std::{env::consts::EXE_SUFFIX, str::FromStr}; +use serde::{Deserialize, Serialize}; use std::sync::{LazyLock, RwLock}; use std::time::{SystemTime, UNIX_EPOCH}; -use serde::{Deserialize, Serialize}; +use std::{env::consts::EXE_SUFFIX, str::FromStr}; use lettre::{ message::{Attachment, Body, Mailbox, Message, MultiPart, SinglePart}, @@ -68,17 +68,12 @@ pub async fn refresh_oauth2_token() -> Result { return Err(Error::new("OAuth2 Token Refresh Failed", format!("HTTP {status}: {body}"))); } - let token_response: TokenRefreshResponse = response - .json() - .await - .map_err(|e| Error::new("OAuth2 Token Parse Error", e.to_string()))?; - - let expires_at = token_response.expires_in.map(|expires_in| { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() + expires_in - }); + let token_response: TokenRefreshResponse = + response.json().await.map_err(|e| Error::new("OAuth2 Token Parse Error", e.to_string()))?; + + let expires_at = token_response + .expires_in + .map(|expires_in| SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + expires_in); Ok(OAuth2Token { access_token: token_response.access_token, From 1269330f46c807875e3493216d73a37dca9e39af Mon Sep 17 00:00:00 2001 From: hnolde Date: Wed, 22 Oct 2025 11:50:22 +0200 Subject: [PATCH 03/13] Fix: formatting issues --- src/api/admin.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/admin.rs b/src/api/admin.rs index 4f03a9c4..7546dd7d 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -1,13 +1,13 @@ +use data_encoding::BASE64URL_NOPAD; use once_cell::sync::Lazy; use percent_encoding::{percent_encode, NON_ALPHANUMERIC}; use reqwest::Method; use serde::de::DeserializeOwned; use serde_json::Value; -use std::env; use std::collections::HashMap; +use std::env; use std::sync::RwLock; use std::time::{SystemTime, UNIX_EPOCH}; -use data_encoding::BASE64URL_NOPAD; use rocket::serde::json::Json; use rocket::{ From f6a5e53e43e946ca64d4821dac70c5363049ef2e Mon Sep 17 00:00:00 2001 From: hnolde Date: Wed, 22 Oct 2025 15:23:42 +0200 Subject: [PATCH 04/13] Fix: handlebars oauth2_success, http_client, url --- src/api/admin.rs | 60 ++++++++----------- src/config.rs | 1 + src/mail.rs | 6 +- src/static/templates/admin/oauth2_success.hbs | 10 ++++ 4 files changed, 38 insertions(+), 39 deletions(-) create mode 100644 src/static/templates/admin/oauth2_success.hbs diff --git a/src/api/admin.rs b/src/api/admin.rs index 7546dd7d..5ca075c2 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -1,6 +1,6 @@ use data_encoding::BASE64URL_NOPAD; use once_cell::sync::Lazy; -use percent_encoding::{percent_encode, NON_ALPHANUMERIC}; +use url::Url; use reqwest::Method; use serde::de::DeserializeOwned; use serde_json::Value; @@ -371,15 +371,20 @@ fn oauth2_authorize(_token: AdminToken) -> Result { // Construct redirect URI let redirect_uri = format!("{}/admin/oauth2/callback", CONFIG.domain()); - // Build authorization URL - let auth_url = format!( - "{}?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}&access_type=offline&prompt=consent", - auth_url, - percent_encode(client_id.as_bytes(), NON_ALPHANUMERIC), - percent_encode(redirect_uri.as_bytes(), NON_ALPHANUMERIC), - percent_encode(scopes.as_bytes(), NON_ALPHANUMERIC), - percent_encode(state.as_bytes(), NON_ALPHANUMERIC) - ); + // Build authorization URL using url crate to ensure proper encoding + let mut url = Url::parse(&auth_url).map_err(|e| Error::new("Invalid OAuth2 Authorization URL", e.to_string()))?; + { + let mut qp = url.query_pairs_mut(); + qp.append_pair("client_id", &client_id); + qp.append_pair("redirect_uri", &redirect_uri); + qp.append_pair("response_type", "code"); + qp.append_pair("scope", &scopes); + qp.append_pair("state", &state); + qp.append_pair("access_type", "offline"); + qp.append_pair("prompt", "consent"); + } + + let auth_url = url.to_string(); Ok(Redirect::to(auth_url)) } @@ -432,9 +437,7 @@ async fn oauth2_callback(params: OAuth2CallbackParams) -> Result, E ("client_secret", &client_secret), ]; - let client = reqwest::Client::new(); - let response = client - .post(&token_url) + let response = make_http_request(Method::POST, &token_url)? .form(&form_params) .send() .await @@ -460,29 +463,14 @@ async fn oauth2_callback(params: OAuth2CallbackParams) -> Result, E .map_err(|e| Error::new("ConfigBuilder serialization error", e.to_string()))?; CONFIG.update_config_partial(config_builder).await?; - // Return success page - let success_html = format!( - r#" - - - OAuth2 Authorization Successful - - - -

✓ OAuth2 Authorization Successful!

-

The refresh token has been saved to your configuration.

-

You can now close this window and return to the admin panel.

-

Return to Admin Settings

- -"#, - admin_url() - ); - - Ok(Html(success_html)) + // Return success page via template + let json = json!({ + "page_content": "admin/oauth2_success", + "admin_url": admin_url(), + "urlpath": CONFIG.domain_path(), + }); + let text = CONFIG.render_template(BASE_TEMPLATE, &json)?; + Ok(Html(text)) } #[get("/logout")] diff --git a/src/config.rs b/src/config.rs index b7b89bad..34497f18 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1732,6 +1732,7 @@ where reg!("admin/users"); reg!("admin/organizations"); reg!("admin/diagnostics"); + reg!("admin/oauth2_success"); reg!("404"); diff --git a/src/mail.rs b/src/mail.rs index 422b61d9..a4ffecf8 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -24,6 +24,8 @@ use crate::{ CONFIG, }; +use crate::http_client::make_http_request; + // OAuth2 Token structures #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OAuth2Token { @@ -54,9 +56,7 @@ pub async fn refresh_oauth2_token() -> Result { ("client_secret", &client_secret), ]; - let client = reqwest::Client::new(); - let response = client - .post(&token_url) + let response = make_http_request(reqwest::Method::POST, &token_url)? .form(&form_params) .send() .await diff --git a/src/static/templates/admin/oauth2_success.hbs b/src/static/templates/admin/oauth2_success.hbs new file mode 100644 index 00000000..e3ef6750 --- /dev/null +++ b/src/static/templates/admin/oauth2_success.hbs @@ -0,0 +1,10 @@ +
+ +
From e5c0655c74b025d673b36646d3ef48b9401d4888 Mon Sep 17 00:00:00 2001 From: hnolde Date: Fri, 31 Oct 2025 12:09:27 +0100 Subject: [PATCH 05/13] Fix: formatting --- src/api/admin.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/admin.rs b/src/api/admin.rs index 5ca075c2..7d9ffc89 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -1,6 +1,5 @@ use data_encoding::BASE64URL_NOPAD; use once_cell::sync::Lazy; -use url::Url; use reqwest::Method; use serde::de::DeserializeOwned; use serde_json::Value; @@ -8,6 +7,7 @@ use std::collections::HashMap; use std::env; use std::sync::RwLock; use std::time::{SystemTime, UNIX_EPOCH}; +use url::Url; use rocket::serde::json::Json; use rocket::{ From 7e3acf26b4b6f3f1087beebea2ce300e5f89c283 Mon Sep 17 00:00:00 2001 From: hnolde Date: Tue, 4 Nov 2025 16:11:51 +0100 Subject: [PATCH 06/13] Fix: update OAuth2 state storage initialization and state token encoding --- src/api/admin.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/admin.rs b/src/api/admin.rs index a9d9a5b5..14c0aaee 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -106,7 +106,7 @@ static CAN_BACKUP: LazyLock = static CAN_BACKUP: LazyLock = LazyLock::new(|| false); // OAuth2 state storage for CSRF protection (state -> expiration timestamp) -static OAUTH2_STATES: LazyLock>> = +static OAUTH2_STATES: LazyLock>> = LazyLock::new(|| RwLock::new(HashMap::new())); #[get("/")] @@ -370,7 +370,7 @@ fn oauth2_authorize(_token: AdminToken) -> Result { let scopes = CONFIG.smtp_oauth2_scopes(); // Generate a random state token for CSRF protection - let state = crate::crypto::encode_random_bytes::<32>(BASE64URL_NOPAD); + let state = crate::crypto::encode_random_bytes::<32>(&BASE64URL_NOPAD); // Store state with expiration (10 minutes from now) let expiration = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + 600; From 2be8e7e6283dfeef6f1acff15fa635202de898ce Mon Sep 17 00:00:00 2001 From: hnolde Date: Tue, 4 Nov 2025 16:54:06 +0100 Subject: [PATCH 07/13] Fix: reorder imports and clean up whitespace in admin.rs --- src/api/admin.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/api/admin.rs b/src/api/admin.rs index 14c0aaee..0602984a 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -1,8 +1,8 @@ use data_encoding::BASE64URL_NOPAD; use std::collections::HashMap; -use std::{env, sync::LazyLock}; use std::sync::RwLock; use std::time::{SystemTime, UNIX_EPOCH}; +use std::{env, sync::LazyLock}; use url::Url; use reqwest::Method; @@ -106,8 +106,7 @@ static CAN_BACKUP: LazyLock = static CAN_BACKUP: LazyLock = LazyLock::new(|| false); // OAuth2 state storage for CSRF protection (state -> expiration timestamp) -static OAUTH2_STATES: LazyLock>> = - LazyLock::new(|| RwLock::new(HashMap::new())); +static OAUTH2_STATES: LazyLock>> = LazyLock::new(|| RwLock::new(HashMap::new())); #[get("/")] fn admin_disabled() -> &'static str { From c17837fa2ab40b0aa9bc223b28ab27a02ede52af Mon Sep 17 00:00:00 2001 From: Henning Date: Sun, 21 Dec 2025 18:00:48 +0100 Subject: [PATCH 08/13] Add SMTP OAuth2 configuration options and improve error handling --- .env.template | 11 +++++++++++ src/api/admin.rs | 16 ++++++++-------- src/config.rs | 2 +- src/mail.rs | 6 +++--- src/static/scripts/admin_settings.js | 2 +- 5 files changed, 24 insertions(+), 13 deletions(-) diff --git a/.env.template b/.env.template index 67f531fc..b1fc5fd8 100644 --- a/.env.template +++ b/.env.template @@ -624,6 +624,17 @@ ## Multiple options need to be separated by a comma ','. # SMTP_AUTH_MECHANISM= +## SMTP OAuth2 settings +## These are required if SMTP_AUTH_MECHANISM includes "Xoauth2". +## After configuring these, you'll need to use the admin panel to complete the authorization flow. +# SMTP_OAUTH2_CLIENT_ID= +# SMTP_OAUTH2_CLIENT_SECRET= +# SMTP_OAUTH2_AUTH_URL= +# SMTP_OAUTH2_TOKEN_URL= +# SMTP_OAUTH2_SCOPES= +## The refresh token is typically obtained automatically during the authorization flow in the admin panel. +# SMTP_OAUTH2_REFRESH_TOKEN= + ## Server name sent during the SMTP HELO ## By default this value should be the machine's hostname, ## but might need to be changed in case it trips some anti-spam filters diff --git a/src/api/admin.rs b/src/api/admin.rs index 5b1dc7c7..4c905eed 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -352,7 +352,7 @@ async fn test_smtp(data: Json, _token: AdminToken) -> EmptyResult { } } -#[post("/test/oauth2")] +#[post("/oauth2/test")] async fn refresh_oauth2_token_endpoint(_token: AdminToken) -> EmptyResult { if CONFIG.smtp_oauth2_client_id().is_none() { err!("OAuth2 is not configured") @@ -384,7 +384,7 @@ fn oauth2_authorize(_token: AdminToken) -> Result { let redirect_uri = format!("{}/admin/oauth2/callback", CONFIG.domain()); // Build authorization URL using url crate to ensure proper encoding - let mut url = Url::parse(&auth_url).map_err(|e| Error::new("Invalid OAuth2 Authorization URL", e.to_string()))?; + let mut url = Url::parse(&auth_url).map_err(|e| err!(format!("Invalid OAuth2 Authorization URL: {e}")))?; { let mut qp = url.query_pairs_mut(); qp.append_pair("client_id", &client_id); @@ -414,7 +414,7 @@ async fn oauth2_callback(params: OAuth2CallbackParams) -> Result, E // Check for errors from OAuth2 provider if let Some(error) = params.error { let description = params.error_description.unwrap_or_else(|| "Unknown error".to_string()); - return Err(Error::new("OAuth2 Authorization Failed", format!("{}: {}", error, description))); + err!("OAuth2 Authorization Failed", format!("{error}: {description}")); } // Validate required parameters @@ -429,7 +429,7 @@ async fn oauth2_callback(params: OAuth2CallbackParams) -> Result, E }; if !valid_state { - return Err(Error::new("OAuth2 State Validation Failed", "Invalid or expired state token")); + err!("OAuth2 State Validation Failed", "Invalid or expired state token"); } // Remove used state @@ -453,16 +453,16 @@ async fn oauth2_callback(params: OAuth2CallbackParams) -> Result, E .form(&form_params) .send() .await - .map_err(|e| Error::new("OAuth2 Token Exchange Error", e.to_string()))?; + .map_err(|e| err!(format!("OAuth2 Token Exchange Error: {e}")))?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_else(|_| String::from("Unable to read response body")); - return Err(Error::new("OAuth2 Token Exchange Failed", format!("HTTP {}: {}", status, body))); + err!("OAuth2 Token Exchange Failed", format!("HTTP {status}: {body}")); } let token_response: Value = - response.json().await.map_err(|e| Error::new("OAuth2 Token Parse Error", e.to_string()))?; + response.json().await.map_err(|e| err!(format!("OAuth2 Token Parse Error: {e}")))?; // Extract refresh_token from response let refresh_token = @@ -472,7 +472,7 @@ async fn oauth2_callback(params: OAuth2CallbackParams) -> Result, E let config_builder: ConfigBuilder = serde_json::from_value(json!({ "smtp_oauth2_refresh_token": refresh_token })) - .map_err(|e| Error::new("ConfigBuilder serialization error", e.to_string()))?; + .map_err(|e| err!(format!("ConfigBuilder serialization error: {e}")))?; CONFIG.update_config_partial(config_builder).await?; // Return success page via template diff --git a/src/config.rs b/src/config.rs index b98f0249..bdf304c2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -898,7 +898,7 @@ make_config! { /// SMTP OAuth2 Refresh Token |> OAuth2 Refresh Token for obtaining new access tokens smtp_oauth2_refresh_token: Pass, true, option; /// SMTP OAuth2 Scopes |> Comma-separated list of OAuth2 scopes - smtp_oauth2_scopes: String, true, def, "https://mail.google.com/".to_string(); + smtp_oauth2_scopes: String, true, def, "".to_string(); /// SMTP connection timeout |> Number of seconds when to stop trying to connect to the SMTP server smtp_timeout: u64, true, def, 15; /// Server name sent during HELO |> By default this value should be the machine's hostname, but might need to be changed in case it trips some anti-spam filters diff --git a/src/mail.rs b/src/mail.rs index deadc45a..01092684 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -60,16 +60,16 @@ pub async fn refresh_oauth2_token() -> Result { .form(&form_params) .send() .await - .map_err(|e| Error::new("OAuth2 Token Refresh Error", e.to_string()))?; + .map_err(|e| err!(format!("OAuth2 Token Refresh Error: {e}")))?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_else(|_| String::from("Unable to read response body")); - return Err(Error::new("OAuth2 Token Refresh Failed", format!("HTTP {status}: {body}"))); + err!("OAuth2 Token Refresh Failed", format!("HTTP {status}: {body}")); } let token_response: TokenRefreshResponse = - response.json().await.map_err(|e| Error::new("OAuth2 Token Parse Error", e.to_string()))?; + response.json().await.map_err(|e| err!(format!("OAuth2 Token Parse Error: {e}")))?; let expires_at = token_response .expires_in diff --git a/src/static/scripts/admin_settings.js b/src/static/scripts/admin_settings.js index 8ed1f13c..49ad44d5 100644 --- a/src/static/scripts/admin_settings.js +++ b/src/static/scripts/admin_settings.js @@ -34,7 +34,7 @@ function oauth2RefreshToken(event) { return false; } - _post(`${BASE_URL}/admin/test/oauth2`, + _post(`${BASE_URL}/admin/oauth2/test`, "OAuth2 token refreshed successfully", "Error refreshing OAuth2 token", null, false From 2a360ff3de5f051831a546a6ea5ea9dbbe5fe33e Mon Sep 17 00:00:00 2001 From: Henning Date: Sun, 21 Dec 2025 18:52:13 +0100 Subject: [PATCH 09/13] Improve error handling for OAuth2 token exchange and parsing --- src/api/admin.rs | 26 ++++++++++++++++++-------- src/mail.rs | 13 +++++++++---- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/api/admin.rs b/src/api/admin.rs index 4c905eed..4b359eaf 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -384,7 +384,10 @@ fn oauth2_authorize(_token: AdminToken) -> Result { let redirect_uri = format!("{}/admin/oauth2/callback", CONFIG.domain()); // Build authorization URL using url crate to ensure proper encoding - let mut url = Url::parse(&auth_url).map_err(|e| err!(format!("Invalid OAuth2 Authorization URL: {e}")))?; + let mut url = match Url::parse(&auth_url) { + Ok(u) => u, + Err(e) => err!(format!("Invalid OAuth2 Authorization URL: {e}")), + }; { let mut qp = url.query_pairs_mut(); qp.append_pair("client_id", &client_id); @@ -449,11 +452,14 @@ async fn oauth2_callback(params: OAuth2CallbackParams) -> Result, E ("client_secret", &client_secret), ]; - let response = make_http_request(Method::POST, &token_url)? + let response = match make_http_request(Method::POST, &token_url)? .form(&form_params) .send() .await - .map_err(|e| err!(format!("OAuth2 Token Exchange Error: {e}")))?; + { + Ok(res) => res, + Err(e) => err!(format!("OAuth2 Token Exchange Error: {e}")), + }; if !response.status().is_success() { let status = response.status(); @@ -461,18 +467,22 @@ async fn oauth2_callback(params: OAuth2CallbackParams) -> Result, E err!("OAuth2 Token Exchange Failed", format!("HTTP {status}: {body}")); } - let token_response: Value = - response.json().await.map_err(|e| err!(format!("OAuth2 Token Parse Error: {e}")))?; + let token_response: Value = match response.json().await { + Ok(res) => res, + Err(e) => err!(format!("OAuth2 Token Parse Error: {e}")), + }; // Extract refresh_token from response let refresh_token = token_response.get("refresh_token").and_then(|v| v.as_str()).ok_or("No refresh_token in response")?; // Save refresh_token to configuration - let config_builder: ConfigBuilder = serde_json::from_value(json!({ + let config_builder: ConfigBuilder = match serde_json::from_value(json!({ "smtp_oauth2_refresh_token": refresh_token - })) - .map_err(|e| err!(format!("ConfigBuilder serialization error: {e}")))?; + })) { + Ok(builder) => builder, + Err(e) => err!(format!("ConfigBuilder serialization error: {e}")), + }; CONFIG.update_config_partial(config_builder).await?; // Return success page via template diff --git a/src/mail.rs b/src/mail.rs index 01092684..1696923d 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -56,11 +56,14 @@ pub async fn refresh_oauth2_token() -> Result { ("client_secret", &client_secret), ]; - let response = make_http_request(reqwest::Method::POST, &token_url)? + let response = match make_http_request(reqwest::Method::POST, &token_url)? .form(&form_params) .send() .await - .map_err(|e| err!(format!("OAuth2 Token Refresh Error: {e}")))?; + { + Ok(res) => res, + Err(e) => err!(format!("OAuth2 Token Refresh Error: {e}")), + }; if !response.status().is_success() { let status = response.status(); @@ -68,8 +71,10 @@ pub async fn refresh_oauth2_token() -> Result { err!("OAuth2 Token Refresh Failed", format!("HTTP {status}: {body}")); } - let token_response: TokenRefreshResponse = - response.json().await.map_err(|e| err!(format!("OAuth2 Token Parse Error: {e}")))?; + let token_response: TokenRefreshResponse = match response.json().await { + Ok(res) => res, + Err(e) => err!(format!("OAuth2 Token Parse Error: {e}")), + }; let expires_at = token_response .expires_in From 8927a1fea669003ef651aa7307c5d37d0689320d Mon Sep 17 00:00:00 2001 From: Henning Date: Sun, 21 Dec 2025 19:15:42 +0100 Subject: [PATCH 10/13] Refactor OAuth2 token exchange and refresh error handling --- src/api/admin.rs | 6 +----- src/config.rs | 2 +- src/mail.rs | 6 +----- 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/src/api/admin.rs b/src/api/admin.rs index 4b359eaf..807c224f 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -452,11 +452,7 @@ async fn oauth2_callback(params: OAuth2CallbackParams) -> Result, E ("client_secret", &client_secret), ]; - let response = match make_http_request(Method::POST, &token_url)? - .form(&form_params) - .send() - .await - { + let response = match make_http_request(Method::POST, &token_url)?.form(&form_params).send().await { Ok(res) => res, Err(e) => err!(format!("OAuth2 Token Exchange Error: {e}")), }; diff --git a/src/config.rs b/src/config.rs index bdf304c2..82db9c67 100644 --- a/src/config.rs +++ b/src/config.rs @@ -898,7 +898,7 @@ make_config! { /// SMTP OAuth2 Refresh Token |> OAuth2 Refresh Token for obtaining new access tokens smtp_oauth2_refresh_token: Pass, true, option; /// SMTP OAuth2 Scopes |> Comma-separated list of OAuth2 scopes - smtp_oauth2_scopes: String, true, def, "".to_string(); + smtp_oauth2_scopes: String, true, def, String::new(); /// SMTP connection timeout |> Number of seconds when to stop trying to connect to the SMTP server smtp_timeout: u64, true, def, 15; /// Server name sent during HELO |> By default this value should be the machine's hostname, but might need to be changed in case it trips some anti-spam filters diff --git a/src/mail.rs b/src/mail.rs index 1696923d..87a252eb 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -56,11 +56,7 @@ pub async fn refresh_oauth2_token() -> Result { ("client_secret", &client_secret), ]; - let response = match make_http_request(reqwest::Method::POST, &token_url)? - .form(&form_params) - .send() - .await - { + let response = match make_http_request(reqwest::Method::POST, &token_url)?.form(&form_params).send().await { Ok(res) => res, Err(e) => err!(format!("OAuth2 Token Refresh Error: {e}")), }; From 405b50d0444f6ec634c7060157326cc1a64cd562 Mon Sep 17 00:00:00 2001 From: Henning Date: Fri, 26 Dec 2025 15:00:23 +0100 Subject: [PATCH 11/13] Implement XOAuth2 model and database integration for SMTP OAuth2 tokens --- .../2025-12-26-143000_create_xoauth2/down.sql | 1 + .../2025-12-26-143000_create_xoauth2/up.sql | 4 ++ .../2025-12-26-143000_create_xoauth2/down.sql | 1 + .../2025-12-26-143000_create_xoauth2/up.sql | 4 ++ .../2025-12-26-143000_create_xoauth2/down.sql | 1 + .../2025-12-26-143000_create_xoauth2/up.sql | 4 ++ src/api/admin.rs | 14 ++---- src/config.rs | 3 ++ src/db/mod.rs | 8 ++++ src/db/models/mod.rs | 2 + src/db/models/xoauth2.rs | 48 +++++++++++++++++++ src/db/schema.rs | 7 +++ src/mail.rs | 22 +++++++-- src/main.rs | 1 + 14 files changed, 106 insertions(+), 14 deletions(-) create mode 100644 migrations/mysql/2025-12-26-143000_create_xoauth2/down.sql create mode 100644 migrations/mysql/2025-12-26-143000_create_xoauth2/up.sql create mode 100644 migrations/postgresql/2025-12-26-143000_create_xoauth2/down.sql create mode 100644 migrations/postgresql/2025-12-26-143000_create_xoauth2/up.sql create mode 100644 migrations/sqlite/2025-12-26-143000_create_xoauth2/down.sql create mode 100644 migrations/sqlite/2025-12-26-143000_create_xoauth2/up.sql create mode 100644 src/db/models/xoauth2.rs diff --git a/migrations/mysql/2025-12-26-143000_create_xoauth2/down.sql b/migrations/mysql/2025-12-26-143000_create_xoauth2/down.sql new file mode 100644 index 00000000..ea761dc9 --- /dev/null +++ b/migrations/mysql/2025-12-26-143000_create_xoauth2/down.sql @@ -0,0 +1 @@ +DROP TABLE xoauth2; diff --git a/migrations/mysql/2025-12-26-143000_create_xoauth2/up.sql b/migrations/mysql/2025-12-26-143000_create_xoauth2/up.sql new file mode 100644 index 00000000..1a57b055 --- /dev/null +++ b/migrations/mysql/2025-12-26-143000_create_xoauth2/up.sql @@ -0,0 +1,4 @@ +CREATE TABLE xoauth2 ( + id VARCHAR(255) NOT NULL PRIMARY KEY, + refresh_token TEXT NOT NULL +); diff --git a/migrations/postgresql/2025-12-26-143000_create_xoauth2/down.sql b/migrations/postgresql/2025-12-26-143000_create_xoauth2/down.sql new file mode 100644 index 00000000..ea761dc9 --- /dev/null +++ b/migrations/postgresql/2025-12-26-143000_create_xoauth2/down.sql @@ -0,0 +1 @@ +DROP TABLE xoauth2; diff --git a/migrations/postgresql/2025-12-26-143000_create_xoauth2/up.sql b/migrations/postgresql/2025-12-26-143000_create_xoauth2/up.sql new file mode 100644 index 00000000..4ac6a36f --- /dev/null +++ b/migrations/postgresql/2025-12-26-143000_create_xoauth2/up.sql @@ -0,0 +1,4 @@ +CREATE TABLE xoauth2 ( + id TEXT NOT NULL PRIMARY KEY, + refresh_token TEXT NOT NULL +); diff --git a/migrations/sqlite/2025-12-26-143000_create_xoauth2/down.sql b/migrations/sqlite/2025-12-26-143000_create_xoauth2/down.sql new file mode 100644 index 00000000..ea761dc9 --- /dev/null +++ b/migrations/sqlite/2025-12-26-143000_create_xoauth2/down.sql @@ -0,0 +1 @@ +DROP TABLE xoauth2; diff --git a/migrations/sqlite/2025-12-26-143000_create_xoauth2/up.sql b/migrations/sqlite/2025-12-26-143000_create_xoauth2/up.sql new file mode 100644 index 00000000..4ac6a36f --- /dev/null +++ b/migrations/sqlite/2025-12-26-143000_create_xoauth2/up.sql @@ -0,0 +1,4 @@ +CREATE TABLE xoauth2 ( + id TEXT NOT NULL PRIMARY KEY, + refresh_token TEXT NOT NULL +); diff --git a/src/api/admin.rs b/src/api/admin.rs index 807c224f..7d3e2551 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -28,7 +28,7 @@ use crate::{ backup_sqlite, get_sql_server_version, models::{ Attachment, Cipher, Collection, Device, Event, EventType, Group, Invitation, Membership, MembershipId, - MembershipType, OrgPolicy, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId, + MembershipType, OrgPolicy, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId, XOAuth2, }, DbConn, DbConnType, ACTIVE_DB_TYPE, }, @@ -413,7 +413,7 @@ struct OAuth2CallbackParams { } #[get("/oauth2/callback?")] -async fn oauth2_callback(params: OAuth2CallbackParams) -> Result, Error> { +async fn oauth2_callback(params: OAuth2CallbackParams, conn: DbConn) -> Result, Error> { // Check for errors from OAuth2 provider if let Some(error) = params.error { let description = params.error_description.unwrap_or_else(|| "Unknown error".to_string()); @@ -472,14 +472,8 @@ async fn oauth2_callback(params: OAuth2CallbackParams) -> Result, E let refresh_token = token_response.get("refresh_token").and_then(|v| v.as_str()).ok_or("No refresh_token in response")?; - // Save refresh_token to configuration - let config_builder: ConfigBuilder = match serde_json::from_value(json!({ - "smtp_oauth2_refresh_token": refresh_token - })) { - Ok(builder) => builder, - Err(e) => err!(format!("ConfigBuilder serialization error: {e}")), - }; - CONFIG.update_config_partial(config_builder).await?; + // Save refresh_token to database + XOAuth2::new("smtp".to_string(), refresh_token.to_string()).save(&conn).await?; // Return success page via template let json = json!({ diff --git a/src/config.rs b/src/config.rs index 82db9c67..297824cf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -429,6 +429,9 @@ macro_rules! make_config { "sso_authority", "sso_callback_path", "sso_client_id", + "smtp_oauth2_client_id", + "smtp_oauth2_auth_url", + "smtp_oauth2_token_url", ]; let cfg = { diff --git a/src/db/mod.rs b/src/db/mod.rs index ae2b1221..c5ce993e 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -121,6 +121,14 @@ pub enum DbConnType { } pub static ACTIVE_DB_TYPE: OnceLock = OnceLock::new(); +pub static DB_POOL: OnceLock = OnceLock::new(); + +pub async fn get_conn() -> Result { + match DB_POOL.get() { + Some(p) => p.get().await, + None => err!("Database pool not initialized"), + } +} pub struct DbConn { conn: Arc>>>, diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index b4fcf658..5af29c97 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -16,6 +16,7 @@ mod two_factor; mod two_factor_duo_context; mod two_factor_incomplete; mod user; +mod xoauth2; pub use self::attachment::{Attachment, AttachmentId}; pub use self::auth_request::{AuthRequest, AuthRequestId}; @@ -41,3 +42,4 @@ pub use self::two_factor::{TwoFactor, TwoFactorType}; pub use self::two_factor_duo_context::TwoFactorDuoContext; pub use self::two_factor_incomplete::TwoFactorIncomplete; pub use self::user::{Invitation, SsoUser, User, UserId, UserKdfType, UserStampException}; +pub use self::xoauth2::XOAuth2; diff --git a/src/db/models/xoauth2.rs b/src/db/models/xoauth2.rs new file mode 100644 index 00000000..b3131c80 --- /dev/null +++ b/src/db/models/xoauth2.rs @@ -0,0 +1,48 @@ +use crate::db::DbConn; +use crate::api::EmptyResult; +use crate::error::MapResult; +use crate::db::schema::xoauth2; +use diesel::prelude::*; + +#[derive(Debug, Identifiable, Queryable, Insertable, AsChangeset)] +#[diesel(table_name = xoauth2)] +#[diesel(primary_key(id))] +pub struct XOAuth2 { + pub id: String, + pub refresh_token: String, +} + +impl XOAuth2 { + pub fn new(id: String, refresh_token: String) -> Self { + Self { id, refresh_token } + } + + pub async fn save(&self, conn: &DbConn) -> EmptyResult { + db_run! { conn: + sqlite, mysql { + diesel::replace_into(xoauth2::table) + .values(self) + .execute(conn) + .map_res("Error saving xoauth2") + } + postgresql { + diesel::insert_into(xoauth2::table) + .values(self) + .on_conflict(xoauth2::id) + .do_update() + .set(self) + .execute(conn) + .map_res("Error saving xoauth2") + } + } + } + + pub async fn find_by_id(id: String, conn: &DbConn) -> Option { + db_run! { conn: { + xoauth2::table + .filter(xoauth2::id.eq(id)) + .first::(conn) + .ok() + }} + } +} diff --git a/src/db/schema.rs b/src/db/schema.rs index 914b4fe9..dacf50c9 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -341,6 +341,13 @@ table! { } } +table! { + xoauth2 (id) { + id -> Text, + refresh_token -> Text, + } +} + joinable!(attachments -> ciphers (cipher_uuid)); joinable!(ciphers -> organizations (organization_uuid)); joinable!(ciphers -> users (user_uuid)); diff --git a/src/mail.rs b/src/mail.rs index 87a252eb..f0f9e1ed 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -19,7 +19,9 @@ use crate::{ encode_jwt, generate_delete_claims, generate_emergency_access_invite_claims, generate_invite_claims, generate_verify_email_claims, }, - db::models::{Device, DeviceType, EmergencyAccessId, MembershipId, OrganizationId, User, UserId}, + db::models::{ + Device, DeviceType, EmergencyAccessId, MembershipId, OrganizationId, User, UserId, XOAuth2, + }, error::Error, CONFIG, }; @@ -44,9 +46,15 @@ struct TokenRefreshResponse { } pub async fn refresh_oauth2_token() -> Result { + let conn = crate::db::get_conn().await?; + let refresh_token = if let Some(x) = XOAuth2::find_by_id("smtp".to_string(), &conn).await { + x.refresh_token + } else { + CONFIG.smtp_oauth2_refresh_token().ok_or("OAuth2 Refresh Token not configured")? + }; + let client_id = CONFIG.smtp_oauth2_client_id().ok_or("OAuth2 Client ID not configured")?; let client_secret = CONFIG.smtp_oauth2_client_secret().ok_or("OAuth2 Client Secret not configured")?; - let refresh_token = CONFIG.smtp_oauth2_refresh_token().ok_or("OAuth2 Refresh Token not configured")?; let token_url = CONFIG.smtp_oauth2_token_url().ok_or("OAuth2 Token URL not configured")?; let form_params = [ @@ -76,12 +84,18 @@ pub async fn refresh_oauth2_token() -> Result { .expires_in .map(|expires_in| SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + expires_in); - Ok(OAuth2Token { + let new_token = OAuth2Token { access_token: token_response.access_token, refresh_token: token_response.refresh_token.or(Some(refresh_token)), expires_at, token_type: token_response.token_type, - }) + }; + + if let Some(ref new_refresh) = new_token.refresh_token { + XOAuth2::new("smtp".to_string(), new_refresh.clone()).save(&conn).await?; + } + + Ok(new_token) } async fn get_valid_oauth2_token() -> Result { diff --git a/src/main.rs b/src/main.rs index b5ff93ae..fbe1eea2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -85,6 +85,7 @@ async fn main() -> Result<(), Error> { create_dir(&CONFIG.tmp_folder(), "tmp folder"); let pool = create_db_pool().await; + db::DB_POOL.set(pool.clone()).ok(); schedule_jobs(pool.clone()); db::models::TwoFactor::migrate_u2f_to_webauthn(&pool.get().await.unwrap()).await.unwrap(); db::models::TwoFactor::migrate_credential_to_passkey(&pool.get().await.unwrap()).await.unwrap(); From d545de4b54e9bea12bf3d8ce1583b23f6c58336a Mon Sep 17 00:00:00 2001 From: Henning Date: Fri, 26 Dec 2025 15:18:04 +0100 Subject: [PATCH 12/13] Refactor imports and formatting in mail.rs and xoauth2.rs --- src/db/models/xoauth2.rs | 9 ++++++--- src/mail.rs | 4 +--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/db/models/xoauth2.rs b/src/db/models/xoauth2.rs index b3131c80..263db9bd 100644 --- a/src/db/models/xoauth2.rs +++ b/src/db/models/xoauth2.rs @@ -1,7 +1,7 @@ -use crate::db::DbConn; use crate::api::EmptyResult; -use crate::error::MapResult; use crate::db::schema::xoauth2; +use crate::db::DbConn; +use crate::error::MapResult; use diesel::prelude::*; #[derive(Debug, Identifiable, Queryable, Insertable, AsChangeset)] @@ -14,7 +14,10 @@ pub struct XOAuth2 { impl XOAuth2 { pub fn new(id: String, refresh_token: String) -> Self { - Self { id, refresh_token } + Self { + id, + refresh_token + } } pub async fn save(&self, conn: &DbConn) -> EmptyResult { diff --git a/src/mail.rs b/src/mail.rs index f0f9e1ed..bc9ec25a 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -19,9 +19,7 @@ use crate::{ encode_jwt, generate_delete_claims, generate_emergency_access_invite_claims, generate_invite_claims, generate_verify_email_claims, }, - db::models::{ - Device, DeviceType, EmergencyAccessId, MembershipId, OrganizationId, User, UserId, XOAuth2, - }, + db::models::{Device, DeviceType, EmergencyAccessId, MembershipId, OrganizationId, User, UserId, XOAuth2}, error::Error, CONFIG, }; From a826a5a0f3f36d574871175a7e3eef89e0a09069 Mon Sep 17 00:00:00 2001 From: Henning Date: Fri, 26 Dec 2025 15:56:25 +0100 Subject: [PATCH 13/13] Fix formatting issue in XOAuth2 struct constructor --- src/db/models/xoauth2.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/db/models/xoauth2.rs b/src/db/models/xoauth2.rs index 263db9bd..c4207b28 100644 --- a/src/db/models/xoauth2.rs +++ b/src/db/models/xoauth2.rs @@ -16,7 +16,7 @@ impl XOAuth2 { pub fn new(id: String, refresh_token: String) -> Self { Self { id, - refresh_token + refresh_token, } }