diff --git a/src/api/admin.rs b/src/api/admin.rs index d52e24ef..5ca075c2 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -1,8 +1,13 @@ +use data_encoding::BASE64URL_NOPAD; use once_cell::sync::Lazy; +use url::Url; use reqwest::Method; use serde::de::DeserializeOwned; use serde_json::Value; +use std::collections::HashMap; use std::env; +use std::sync::RwLock; +use std::time::{SystemTime, UNIX_EPOCH}; 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,139 @@ 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 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)) +} + +#[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).is_some_and(|&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 response = make_http_request(Method::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 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")] 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..34497f18 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,44 @@ 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 +1472,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(); @@ -1678,6 +1732,7 @@ where reg!("admin/users"); reg!("admin/organizations"); reg!("admin/diagnostics"); + reg!("admin/oauth2_success"); reg!("404"); 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..a4ffecf8 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -1,5 +1,8 @@ use chrono::NaiveDateTime; use percent_encoding::{percent_encode, NON_ALPHANUMERIC}; +use serde::{Deserialize, Serialize}; +use std::sync::{LazyLock, RwLock}; +use std::time::{SystemTime, UNIX_EPOCH}; use std::{env::consts::EXE_SUFFIX, str::FromStr}; use lettre::{ @@ -21,6 +24,87 @@ use crate::{ CONFIG, }; +use crate::http_client::make_http_request; + +// 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 response = make_http_request(reqwest::Method::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 +113,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 +141,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 +790,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/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 @@ +
+ +
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. + +
+
+
+ +
+ +
+