Henning 7 days ago
committed by GitHub
parent
commit
024eaf79b6
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 11
      .env.template
  2. 1
      migrations/mysql/2025-12-26-143000_create_xoauth2/down.sql
  3. 4
      migrations/mysql/2025-12-26-143000_create_xoauth2/up.sql
  4. 1
      migrations/postgresql/2025-12-26-143000_create_xoauth2/down.sql
  5. 4
      migrations/postgresql/2025-12-26-143000_create_xoauth2/up.sql
  6. 1
      migrations/sqlite/2025-12-26-143000_create_xoauth2/down.sql
  7. 4
      migrations/sqlite/2025-12-26-143000_create_xoauth2/up.sql
  8. 146
      src/api/admin.rs
  9. 62
      src/config.rs
  10. 8
      src/db/mod.rs
  11. 2
      src/db/models/mod.rs
  12. 51
      src/db/models/xoauth2.rs
  13. 7
      src/db/schema.rs
  14. 6
      src/error.rs
  15. 142
      src/mail.rs
  16. 1
      src/main.rs
  17. 35
      src/static/scripts/admin_settings.js
  18. 10
      src/static/templates/admin/oauth2_success.hbs
  19. 30
      src/static/templates/admin/settings.hbs

11
.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

1
migrations/mysql/2025-12-26-143000_create_xoauth2/down.sql

@ -0,0 +1 @@
DROP TABLE xoauth2;

4
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
);

1
migrations/postgresql/2025-12-26-143000_create_xoauth2/down.sql

@ -0,0 +1 @@
DROP TABLE xoauth2;

4
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
);

1
migrations/sqlite/2025-12-26-143000_create_xoauth2/down.sql

@ -0,0 +1 @@
DROP TABLE xoauth2;

4
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
);

146
src/api/admin.rs

@ -1,4 +1,9 @@
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;
use reqwest::Method;
use rocket::{
@ -23,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,
},
@ -63,6 +68,9 @@ pub fn routes() -> Vec<Route> {
delete_config,
backup_db,
test_smtp,
refresh_oauth2_token_endpoint,
oauth2_authorize,
oauth2_callback,
users_overview,
organizations_overview,
delete_organization,
@ -97,6 +105,9 @@ static CAN_BACKUP: LazyLock<bool> =
#[cfg(not(sqlite))]
static CAN_BACKUP: LazyLock<bool> = LazyLock::new(|| false);
// OAuth2 state storage for CSRF protection (state -> expiration timestamp)
static OAUTH2_STATES: LazyLock<RwLock<HashMap<String, u64>>> = LazyLock::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"
@ -341,6 +352,139 @@ async fn test_smtp(data: Json<InviteData>, _token: AdminToken) -> EmptyResult {
}
}
#[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")
}
mail::refresh_oauth2_token().await.map(|_| ())
}
#[get("/oauth2/authorize")]
fn oauth2_authorize(_token: AdminToken) -> Result<Redirect, Error> {
// 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 = 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);
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<String>,
state: Option<String>,
error: Option<String>,
error_description: Option<String>,
}
#[get("/oauth2/callback?<params..>")]
async fn oauth2_callback(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());
err!("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 {
err!("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 = 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}")),
};
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_else(|_| String::from("Unable to read response body"));
err!("OAuth2 Token Exchange Failed", format!("HTTP {status}: {body}"));
}
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 database
XOAuth2::new("smtp".to_string(), refresh_token.to_string()).save(&conn).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()));

62
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 = {
@ -887,6 +890,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, 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
@ -1151,8 +1166,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")
}
}
}
@ -1244,6 +1263,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") => {
@ -1533,7 +1590,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();
@ -1793,6 +1850,7 @@ where
reg!("admin/users");
reg!("admin/organizations");
reg!("admin/diagnostics");
reg!("admin/oauth2_success");
reg!("404");

8
src/db/mod.rs

@ -121,6 +121,14 @@ pub enum DbConnType {
}
pub static ACTIVE_DB_TYPE: OnceLock<DbConnType> = OnceLock::new();
pub static DB_POOL: OnceLock<DbPool> = OnceLock::new();
pub async fn get_conn() -> Result<DbConn, Error> {
match DB_POOL.get() {
Some(p) => p.get().await,
None => err!("Database pool not initialized"),
}
}
pub struct DbConn {
conn: Arc<Mutex<Option<PooledConnection<DbConnManager>>>>,

2
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;

51
src/db/models/xoauth2.rs

@ -0,0 +1,51 @@
use crate::api::EmptyResult;
use crate::db::schema::xoauth2;
use crate::db::DbConn;
use crate::error::MapResult;
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<Self> {
db_run! { conn: {
xoauth2::table
.filter(xoauth2::id.eq(id))
.first::<Self>(conn)
.ok()
}}
}
}

7
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));

6
src/error.rs

@ -126,6 +126,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<M: Into<String>, N: Into<String>>(usr_msg: M, log_msg: N) -> Self {
(usr_msg, log_msg.into()).into()

142
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::{
@ -16,11 +19,105 @@ 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,
};
use crate::http_client::make_http_request;
// OAuth2 Token structures
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuth2Token {
access_token: String,
refresh_token: Option<String>,
expires_at: Option<u64>,
token_type: String,
}
#[derive(Debug, Deserialize)]
struct TokenRefreshResponse {
access_token: String,
refresh_token: Option<String>,
expires_in: Option<u64>,
token_type: String,
}
pub async fn refresh_oauth2_token() -> Result<OAuth2Token, Error> {
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 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 = 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}")),
};
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_else(|_| String::from("Unable to read response body"));
err!("OAuth2 Token Refresh Failed", format!("HTTP {status}: {body}"));
}
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
.map(|expires_in| SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + expires_in);
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<OAuth2Token, Error> {
static TOKEN_CACHE: LazyLock<RwLock<Option<OAuth2Token>>> = 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<Tokio1Executor> {
if let Some(command) = CONFIG.sendmail_command() {
AsyncSendmailTransport::new_with_command(command)
@ -29,7 +126,7 @@ fn sendmail_transport() -> AsyncSendmailTransport<Tokio1Executor> {
}
}
fn smtp_transport() -> AsyncSmtpTransport<Tokio1Executor> {
async fn smtp_transport() -> AsyncSmtpTransport<Tokio1Executor> {
use std::time::Duration;
let host = CONFIG.smtp_host().unwrap();
@ -57,8 +154,43 @@ fn smtp_transport() -> AsyncSmtpTransport<Tokio1Executor> {
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 +803,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) => {

1
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();

35
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/oauth2/test`,
"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);

10
src/static/templates/admin/oauth2_success.hbs

@ -0,0 +1,10 @@
<main class="container-xl">
<div class="alert alert-success mt-5" role="alert">
<h4 class="alert-heading">OAuth2 Authorization Successful</h4>
<p>The refresh token has been saved to your configuration.</p>
<hr>
<p class="mb-0">
<a href="{{admin_url}}" class="btn btn-primary">Return to Admin Settings</a>
</p>
</div>
</main>

30
src/static/templates/admin/settings.hbs

@ -50,6 +50,36 @@
{{/if}}
{{/each}}
{{#case group "smtp"}}
<div class="row my-2 pt-3 border-top">
<div class="col-sm-11">
<div class="alert alert-info mb-0" role="alert">
<h6 class="alert-heading"><i class="bi bi-info-circle"></i> OAuth2/XOAUTH2 Authentication</h6>
<p class="mb-2 small">
OAuth2 authentication allows secure email sending without storing your email password.
Configure the OAuth2 fields below to use token-based authentication (recommended for Gmail and Microsoft 365).
</p>
<p class="mb-0 small">
<strong>Note:</strong> If both password and OAuth2 credentials are configured, OAuth2 will be preferred with automatic fallback to password authentication.
See the <a href="https://github.com/dani-garcia/vaultwarden/wiki/SMTP-Configuration#oauth2-authentication" target="_blank" rel="noopener noreferrer" class="alert-link">documentation</a> for detailed setup instructions.
</p>
</div>
</div>
</div>
<div class="row my-2 align-items-center pt-3 border-top" title="Start OAuth2 authorization flow to obtain refresh token">
<label class="col-sm-3 col-form-label">Obtain OAuth2 Refresh Token</label>
<div class="col-sm-8">
<button type="button" class="btn btn-primary" id="oauth2Authorize">Start Authorization</button>
<small class="form-text text-muted d-block mt-1">
Click to authenticate with your email provider and obtain a refresh token.
</small>
</div>
</div>
<div class="row my-2 align-items-center pt-3 border-top" title="Manually refresh OAuth2 token">
<label class="col-sm-3 col-form-label">Refresh OAuth2 Token</label>
<div class="col-sm-8">
<button type="button" class="btn btn-outline-primary" id="oauth2RefreshTest">Refresh Token</button>
</div>
</div>
<div class="row my-2 align-items-center pt-3 border-top" title="Send a test email to given email address">
<label for="smtp-test-email" class="col-sm-3 col-form-label">Test SMTP</label>
<div class="col-sm-8 input-group">

Loading…
Cancel
Save