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