diff --git a/.env.template b/.env.template index b1fc5fd8..4f7acdd0 100644 --- a/.env.template +++ b/.env.template @@ -626,6 +626,7 @@ ## SMTP OAuth2 settings ## These are required if SMTP_AUTH_MECHANISM includes "Xoauth2". +## Both URLs must use the https scheme. ## After configuring these, you'll need to use the admin panel to complete the authorization flow. # SMTP_OAUTH2_CLIENT_ID= # SMTP_OAUTH2_CLIENT_SECRET= diff --git a/src/api/admin.rs b/src/api/admin.rs index 7d3e2551..cbd9f5b0 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -1,7 +1,7 @@ +use chrono::Utc; use data_encoding::BASE64URL_NOPAD; use std::collections::HashMap; use std::sync::RwLock; -use std::time::{SystemTime, UNIX_EPOCH}; use std::{env, sync::LazyLock}; use url::Url; @@ -372,12 +372,14 @@ 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; + // Multiple calls to Utc::now().timestamp() can return a negative value if the system time is set before the UNIX epoch. + // While extremely rare in practice, we handle this by using unwrap_or_default() to prevent huge values when casting to u64. + let now = u64::try_from(Utc::now().timestamp()).unwrap_or_default(); + let expiration = now + 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 @@ -413,7 +415,7 @@ struct OAuth2CallbackParams { } #[get("/oauth2/callback?")] -async fn oauth2_callback(params: OAuth2CallbackParams, conn: DbConn) -> Result, Error> { +async fn oauth2_callback(_token: AdminToken, 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()); @@ -427,7 +429,7 @@ async fn oauth2_callback(params: OAuth2CallbackParams, conn: DbConn) -> Result now) }; diff --git a/src/config.rs b/src/config.rs index b8899fe4..7d0eb6a6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1288,15 +1288,34 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> { // 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://"); + match Url::parse(auth_url) { + Ok(parsed) => { + let scheme = parsed.scheme(); + if scheme != "https" { + err!("`SMTP_OAUTH2_AUTH_URL` must be a valid URL with https scheme"); + } + } + Err(e) => { + err!(format!( + "`SMTP_OAUTH2_AUTH_URL` must be a valid URL: '{e}'" + )); + } } } - // 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://"); + match Url::parse(token_url) { + Ok(parsed) => { + let scheme = parsed.scheme(); + if scheme != "https" { + err!("`SMTP_OAUTH2_TOKEN_URL` must be a valid URL with https scheme"); + } + } + Err(e) => { + err!(format!( + "`SMTP_OAUTH2_TOKEN_URL` must be a valid URL: '{e}'" + )); + } } } } diff --git a/src/mail.rs b/src/mail.rs index bc9ec25a..f7d4e410 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -1,9 +1,9 @@ -use chrono::NaiveDateTime; +use chrono::{NaiveDateTime, Utc}; use percent_encoding::{percent_encode, NON_ALPHANUMERIC}; use serde::{Deserialize, Serialize}; -use std::sync::{LazyLock, RwLock}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::sync::LazyLock; use std::{env::consts::EXE_SUFFIX, str::FromStr}; +use tokio::sync::RwLock; use lettre::{ message::{Attachment, Body, Mailbox, Message, MultiPart, SinglePart}, @@ -78,9 +78,11 @@ pub async fn refresh_oauth2_token() -> Result { Err(e) => err!(format!("OAuth2 Token Parse Error: {e}")), }; - let expires_at = token_response - .expires_in - .map(|expires_in| SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + expires_in); + let expires_at = token_response.expires_in.map(|expires_in| { + // Multiple calls to Utc::now().timestamp() can return a negative value if the system time is set before the UNIX epoch. + // While extremely rare in practice, we handle this by using unwrap_or_default() to prevent huge values when casting to u64. + u64::try_from(Utc::now().timestamp()).unwrap_or_default() + expires_in + }); let new_token = OAuth2Token { access_token: token_response.access_token, @@ -99,21 +101,34 @@ pub async fn refresh_oauth2_token() -> Result { async fn get_valid_oauth2_token() -> Result { static TOKEN_CACHE: LazyLock>> = LazyLock::new(|| RwLock::new(None)); - let cached_token = TOKEN_CACHE.read().unwrap().clone(); + { + let token_cache = TOKEN_CACHE.read().await; + if let Some(token) = token_cache.as_ref() { + // Check if token is still valid (with 5 min buffer) + if let Some(expires_at) = token.expires_at { + let now = u64::try_from(Utc::now().timestamp()).unwrap_or_default(); + if now + 300 < expires_at { + return Ok(token.clone()); + } + } + } + } - if let Some(token) = cached_token { - // Check if token is still valid (with 5 min buffer) + // Refresh token + let mut token_cache = TOKEN_CACHE.write().await; + + // Double check + if let Some(token) = token_cache.as_ref() { if let Some(expires_at) = token.expires_at { - let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); + let now = u64::try_from(Utc::now().timestamp()).unwrap_or_default(); if now + 300 < expires_at { - return Ok(token); + return Ok(token.clone()); } } } - // Refresh token let new_token = refresh_oauth2_token().await?; - *TOKEN_CACHE.write().unwrap() = Some(new_token.clone()); + *token_cache = Some(new_token.clone()); Ok(new_token) } @@ -164,8 +179,8 @@ async fn smtp_transport() -> AsyncSmtpTransport { // 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 + // Pass the access token directly as the password. + // Note: This requires the Xoauth2 mechanism to be enabled for lettre to format it correctly. smtp_client.credentials(Credentials::new(user, token.access_token)) } Err(e) => {