Browse Source

Enhance OAuth2 handling by ensuring HTTPS scheme for URLs and improving timestamp management

pull/6388/head
Henning 1 week ago
parent
commit
88d6453cfd
  1. 1
      .env.template
  2. 12
      src/api/admin.rs
  3. 29
      src/config.rs
  4. 45
      src/mail.rs

1
.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=

12
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<Redirect, Error> {
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?<params..>")]
async fn oauth2_callback(params: OAuth2CallbackParams, conn: DbConn) -> Result<Html<String>, Error> {
async fn oauth2_callback(_token: AdminToken, params: OAuth2CallbackParams, conn: DbConn) -> Result<Html<String>, 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<H
// Validate state token
let valid_state = {
let states = OAUTH2_STATES.read().unwrap();
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
let now = u64::try_from(Utc::now().timestamp()).unwrap_or_default();
states.get(&state).is_some_and(|&exp| exp > now)
};

29
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}'"
));
}
}
}
}

45
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<OAuth2Token, Error> {
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<OAuth2Token, Error> {
async fn get_valid_oauth2_token() -> Result<OAuth2Token, Error> {
static TOKEN_CACHE: LazyLock<RwLock<Option<OAuth2Token>>> = 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<Tokio1Executor> {
// 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) => {

Loading…
Cancel
Save