diff --git a/protos/rustmailer.proto b/protos/rustmailer.proto index b43cbae..3deffa7 100644 --- a/protos/rustmailer.proto +++ b/protos/rustmailer.proto @@ -1381,6 +1381,14 @@ service MtaService { rpc SendTestEmail(SendTestEmailRequest) returns (Empty); } +// OAuth2GrantType defines the type of OAuth2 grant flow. +enum OAuth2GrantType { + // Authorization Code Flow with PKCE — requires user interaction. + AUTHORIZATION_CODE = 0; + // Client Credentials Flow — no user interaction, suitable for service-to-service auth. + CLIENT_CREDENTIALS = 1; +} + // OAuth2 represents an OAuth2 client configuration. message OAuth2 { // The unique identifier for the OAuth2 configuration. @@ -1409,6 +1417,8 @@ message OAuth2 { int64 updated_at = 12; // Optional: The ID of a proxy to use for OAuth2 related requests. optional uint64 use_proxy = 13; + // The grant type used for this OAuth2 configuration. + OAuth2GrantType grant_type = 14; } // GetOAuth2Request is used to retrieve a specific OAuth2 configuration by its ID. @@ -1445,6 +1455,8 @@ message OAuth2CreateRequest { bool enabled = 10; // Optional: The ID of a proxy to use for OAuth2 related requests. optional uint64 use_proxy = 11; + // The grant type to use for authentication. + OAuth2GrantType grant_type = 15; } // UpdateOAuth2Request defines the parameters for updating an existing OAuth2 configuration. @@ -1471,6 +1483,8 @@ message UpdateOAuth2Request { optional bool enabled = 10; // Optional: The ID of a proxy to use for OAuth2 related requests. optional uint64 use_proxy = 11; + // Optional: Update the grant type. + optional OAuth2GrantType grant_type = 16; } // ListOAuth2Request defines parameters for paginating lists of OAuth2 configurations. @@ -1511,6 +1525,14 @@ message AuthorizeUrlResponse { string url = 1; } +// ClientCredentialsRequest is used to exchange client credentials for an access token. +message ClientCredentialsRequest { + // The ID of the account to associate the token with. + uint64 account_id = 1; + // The ID of the OAuth2 configuration to use (must have grant_type = CLIENT_CREDENTIALS). + uint64 oauth2_id = 2; +} + // OAuth2AccessToken represents stored OAuth2 access and refresh tokens. message OAuth2AccessToken { // The ID of the account associated with these tokens. @@ -1572,6 +1594,9 @@ service OAuth2Service { rpc CreateAuthorizeUrl(AuthorizeUrlRequest) returns (AuthorizeUrlResponse); // Retrieves OAuth2 access and refresh tokens for an account. rpc GetOAuth2Tokens(GetOAuth2TokensRequest) returns (OAuth2AccessToken); + // Exchanges client credentials for an access token and stores it. + // Only applicable for OAuth2 configurations with grant_type = CLIENT_CREDENTIALS. + rpc ExchangeClientCredentials(ClientCredentialsRequest) returns (Empty); // Upserts an external OAuth2 token for a specified account. // // If only an access_token is provided, RustMailer stores it directly. diff --git a/src/modules/database/manager.rs b/src/modules/database/manager.rs index d850da9..f8d8221 100644 --- a/src/modules/database/manager.rs +++ b/src/modules/database/manager.rs @@ -7,6 +7,7 @@ use crate::modules::cache::imap::migration::EmailEnvelopeV3; use crate::modules::cache::imap::ENVELOPE_MODELS; use crate::modules::context::Initialize; use crate::modules::error::{code::ErrorCode, RustMailerError}; +use crate::modules::oauth2::entity::OAuth2Model; use crate::modules::scheduler::nativedb::TaskMetaEntity; use crate::modules::settings::cli::SETTINGS; use crate::modules::settings::dir::{DATA_DIR_MANAGER, META_FILE, TASK_FILE}; @@ -18,8 +19,6 @@ use native_db::{Builder, Database}; use std::sync::{Arc, LazyLock}; use tracing::{info, warn}; -pub static DB_MANAGER: LazyLock = LazyLock::new(DatabaseManager::new); - use crate::modules::{ account::status::AccountRunningState, autoconfig::CachedMailSettings, @@ -27,13 +26,15 @@ use crate::modules::{ database::{batch_insert_impl, list_all_impl}, hook::entity::EventHooks, license::License, - oauth2::{entity::OAuth2, pending::OAuth2PendingEntity, token::OAuth2AccessToken}, + oauth2::{pending::OAuth2PendingEntity, token::OAuth2AccessToken}, overview::metrics::DailyMetrics, settings::{proxy::Proxy, system::SystemSetting}, smtp::{mta::entity::Mta, template::entity::EmailTemplate}, token::AccessToken, }; +pub static DB_MANAGER: LazyLock = LazyLock::new(DatabaseManager::new); + /// Metadata database instance pub struct DatabaseManager { meta_db: Arc>, @@ -92,6 +93,8 @@ impl DatabaseManager { .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; rw.migrate::() .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; + rw.migrate::() + .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; rw.commit() .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; @@ -140,7 +143,7 @@ impl DatabaseManager { spawn_migration_task!(AccountModel); spawn_migration_task!(EmailTemplate); spawn_migration_task!(Mta); - spawn_migration_task!(OAuth2); + spawn_migration_task!(OAuth2Model); spawn_migration_task!(OAuth2PendingEntity); spawn_migration_task!(OAuth2AccessToken); spawn_migration_task!(EventHooks); diff --git a/src/modules/database/mod.rs b/src/modules/database/mod.rs index eb4ae8b..08afcf2 100644 --- a/src/modules/database/mod.rs +++ b/src/modules/database/mod.rs @@ -9,7 +9,7 @@ use crate::modules::cache::disk::CacheItem; use crate::modules::error::RustMailerResult; use crate::modules::hook::entity::EventHooks; use crate::modules::license::License; -use crate::modules::oauth2::entity::OAuth2; +use crate::modules::oauth2::entity::{OAuth2, OAuth2V2}; use crate::modules::oauth2::pending::OAuth2PendingEntity; use crate::modules::oauth2::token::OAuth2AccessToken; use crate::modules::settings::proxy::Proxy; @@ -65,6 +65,7 @@ impl ModelsAdapter { self.register_model::(); self.register_model::(); self.register_model::(); + self.register_model::(); self.register_model::(); self.register_model::(); self.register_model::(); diff --git a/src/modules/grpc/service/oauth2/from.rs b/src/modules/grpc/service/oauth2/from.rs index 98884c7..7ca24e2 100644 --- a/src/modules/grpc/service/oauth2/from.rs +++ b/src/modules/grpc/service/oauth2/from.rs @@ -3,14 +3,30 @@ // Unauthorized copying, modification, or distribution is prohibited. use crate::modules::{ - grpc::service::rustmailer_grpc::{self, PagedOAuth2}, + grpc::service::rustmailer_grpc::{ + self, ClientCredentialsRequest, OAuth2GrantType as GrpcOAuth2GrantType, PagedOAuth2, + }, oauth2::{ - entity::{OAuth2, OAuth2CreateRequest, OAuth2UpdateRequest}, + entity::{OAuth2CreateRequest, OAuth2GrantType, OAuth2Model, OAuth2UpdateRequest}, token::{ExternalOAuth2Request, OAuth2AccessToken}, }, rest::response::DataPage, }; +fn grpc_grant_type_to_domain(v: i32) -> OAuth2GrantType { + match GrpcOAuth2GrantType::try_from(v) { + Ok(GrpcOAuth2GrantType::ClientCredentials) => OAuth2GrantType::ClientCredentials, + _ => OAuth2GrantType::AuthorizationCode, + } +} + +fn grpc_grant_type_to_optional(v: Option) -> Option { + match v { + Some(code) => Some(grpc_grant_type_to_domain(code)), + None => None, + } +} + impl From for OAuth2CreateRequest { fn from(value: rustmailer_grpc::OAuth2CreateRequest) -> Self { Self { @@ -25,11 +41,11 @@ impl From for OAuth2CreateRequest { .then(|| value.extra_params.into_iter().collect()), enabled: value.enabled, use_proxy: value.use_proxy, + grant_type: grpc_grant_type_to_domain(value.grant_type), } } } -// 2. From RustMailerOAuth2 to OAuth2 (gRPC) impl From for OAuth2UpdateRequest { fn from(value: rustmailer_grpc::UpdateOAuth2Request) -> Self { Self { @@ -44,13 +60,13 @@ impl From for OAuth2UpdateRequest { .then(|| value.extra_params.into_iter().collect()), enabled: value.enabled, use_proxy: value.use_proxy, + grant_type: grpc_grant_type_to_optional(value.grant_type), } } } -// 3. From UpdateOAuth2Request (gRPC) to OAuth2UpdateRequestDomain -impl From for rustmailer_grpc::OAuth2 { - fn from(value: OAuth2) -> Self { +impl From for rustmailer_grpc::OAuth2 { + fn from(value: OAuth2Model) -> Self { Self { id: value.id, description: value.description, @@ -68,12 +84,16 @@ impl From for rustmailer_grpc::OAuth2 { use_proxy: value.use_proxy, created_at: value.created_at, updated_at: value.updated_at, + grant_type: match value.grant_type { + OAuth2GrantType::ClientCredentials => GrpcOAuth2GrantType::ClientCredentials as i32, + _ => GrpcOAuth2GrantType::AuthorizationCode as i32, + }, } } } -impl From> for PagedOAuth2 { - fn from(value: DataPage) -> Self { +impl From> for PagedOAuth2 { + fn from(value: DataPage) -> Self { Self { current_page: value.current_page, page_size: value.page_size, @@ -96,6 +116,7 @@ impl From for rustmailer_grpc::OAuth2AccessToken { } } } + impl From for ExternalOAuth2Request { fn from(value: rustmailer_grpc::ExternalOAuth2Request) -> Self { Self { @@ -105,3 +126,9 @@ impl From for ExternalOAuth2Request { } } } + +impl From for (u64, u64) { + fn from(value: ClientCredentialsRequest) -> Self { + (value.account_id, value.oauth2_id) + } +} diff --git a/src/modules/grpc/service/oauth2/mod.rs b/src/modules/grpc/service/oauth2/mod.rs index 22b1f82..e44e9b4 100644 --- a/src/modules/grpc/service/oauth2/mod.rs +++ b/src/modules/grpc/service/oauth2/mod.rs @@ -5,12 +5,12 @@ use crate::modules::error::code::ErrorCode; use crate::modules::grpc::auth::{require_account_access, require_root}; use crate::modules::grpc::service::rustmailer_grpc::{ - AuthorizeUrlRequest, AuthorizeUrlResponse, DeleteOAuth2Request, Empty, ExternalOAuth2Request, - GetOAuth2Request, GetOAuth2TokensRequest, ListOAuth2Request, OAuth2, OAuth2AccessToken, - OAuth2CreateRequest, OAuth2Service, PagedOAuth2, UpdateOAuth2Request, + ClientCredentialsRequest, DeleteOAuth2Request, Empty, ExternalOAuth2Request, GetOAuth2Request, + GetOAuth2TokensRequest, ListOAuth2Request, OAuth2, OAuth2AccessToken, OAuth2CreateRequest, + OAuth2Service, PagedOAuth2, UpdateOAuth2Request, }; use crate::modules::oauth2::{ - entity::OAuth2 as RustMailerOAuth2, flow::OAuth2Flow, + entity::OAuth2Model as RustMailerOAuth2, flow::OAuth2Flow, token::OAuth2AccessToken as RustMailerOAuth2AccessToken, }; use crate::raise_error; @@ -75,12 +75,14 @@ impl OAuth2Service for RustMailerOAuth2Service { async fn create_authorize_url( &self, - request: Request, - ) -> Result, Status> { + request: Request, + ) -> Result, Status> { let req = require_root(request)?; let flow = OAuth2Flow::new(req.oauth2_id); let url = flow.authorize_url(req.account_id).await?; - Ok(Response::new(AuthorizeUrlResponse { url })) + Ok(Response::new(crate::modules::grpc::service::rustmailer_grpc::AuthorizeUrlResponse { + url, + })) } async fn get_o_auth2_tokens( @@ -99,6 +101,17 @@ impl OAuth2Service for RustMailerOAuth2Service { Ok(Response::new(result.into())) } + async fn exchange_client_credentials( + &self, + request: Request, + ) -> Result, Status> { + let req = require_account_access(request, |r| r.account_id)?; + let (account_id, oauth2_id) = req.into(); + let flow = OAuth2Flow::new(oauth2_id); + flow.exchange_client_credentials(account_id).await?; + Ok(Response::new(Empty::default())) + } + async fn upsert_external_o_auth2_token( &self, request: Request, diff --git a/src/modules/oauth2/entity.rs b/src/modules/oauth2/entity.rs index 27dcfdd..b5cf992 100644 --- a/src/modules/oauth2/entity.rs +++ b/src/modules/oauth2/entity.rs @@ -16,10 +16,22 @@ use crate::{ }; use native_db::*; use native_model::{native_model, Model}; -use poem_openapi::Object; +use poem_openapi::{Enum, Object}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; +pub type OAuth2Model = OAuth2V2; + +/// The grant type used by this OAuth2 configuration. +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Enum)] +pub enum OAuth2GrantType { + /// Authorization Code Flow with PKCE — requires user interaction. + #[default] + AuthorizationCode, + /// Client Credentials Flow — no user interaction, suitable for service-to-service auth. + ClientCredentials, +} + /// Represents the OAuth2 configuration for a client, including initialization and runtime values. #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)] #[native_model(id = 8, version = 1)] @@ -57,15 +69,60 @@ pub struct OAuth2 { /// The timestamp when the configuration was last updated, in milliseconds since the Unix epoch. pub updated_at: i64, } - impl OAuth2 { fn pk(&self) -> String { format!("{}_{}", &self.created_at, &self.id) } +} + +/// Represents the OAuth2 configuration for a client, including initialization and runtime values. +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)] +#[native_model(id = 8, version = 2, from = OAuth2)] +#[native_db(primary_key(pk -> String))] +pub struct OAuth2V2 { + /// A unique identifier for the OAuth2 configuration. + #[secondary_key(unique)] + pub id: u64, + /// A description of what this configuration is used for. + pub description: Option, + /// The client ID used for authenticating the application with the OAuth2 provider. + pub client_id: String, + /// The client secret used in conjunction with the client ID. + /// + /// Users should provide a plaintext secret. + /// The server will encrypt it using AES-256-GCM and securely store it. + /// The plaintext secret is never stored, so users must ensure it is valid for OAuth2 authentication. + pub client_secret: String, + /// The URL to redirect users to for OAuth2 authorization. + pub auth_url: String, + /// The URL to exchange authorization codes for access tokens. + pub token_url: String, + /// The URI where the OAuth2 provider will redirect to after authorization. + pub redirect_uri: String, + /// The scopes of access that are being requested (e.g., email, profile). + pub scopes: Option>, + /// Any additional parameters to include in the OAuth2 requests (e.g., access_type, prompt). + pub extra_params: Option>, + /// Indicates whether this configuration is enabled or disabled. + pub enabled: bool, + /// route OAuth through proxy (when direct access is blocked) + pub use_proxy: Option, + /// The grant type used for this OAuth2 configuration. + pub grant_type: OAuth2GrantType, + /// The timestamp when the configuration was created, in milliseconds since the Unix epoch. + pub created_at: i64, + /// The timestamp when the configuration was last updated, in milliseconds since the Unix epoch. + pub updated_at: i64, +} + +impl OAuth2V2 { + fn pk(&self) -> String { + format!("{}_{}", &self.created_at, &self.id) + } pub fn new(request: OAuth2CreateRequest) -> RustMailerResult { let request = request.encrypt()?; - Ok(OAuth2 { + Ok(OAuth2Model { id: id!(64), description: request.description, client_id: request.client_id, @@ -79,6 +136,7 @@ impl OAuth2 { created_at: utc_now!(), updated_at: utc_now!(), use_proxy: request.use_proxy, + grant_type: request.grant_type, }) } @@ -91,20 +149,20 @@ impl OAuth2 { page: Option, page_size: Option, desc: Option, - ) -> RustMailerResult> { + ) -> RustMailerResult> { paginate_query_primary_scan_all_impl(DB_MANAGER.meta_db(), page, page_size, desc) .await .map(DataPage::from) } - pub async fn get(id: u64) -> RustMailerResult> { - secondary_find_impl(DB_MANAGER.meta_db(), OAuth2Key::id, id).await + pub async fn get(id: u64) -> RustMailerResult> { + secondary_find_impl(DB_MANAGER.meta_db(), OAuth2V2Key::id, id).await } pub async fn delete(id: u64) -> RustMailerResult<()> { delete_impl(DB_MANAGER.meta_db(), move |rw| { rw.get() - .secondary::(OAuth2Key::id, id) + .secondary::(OAuth2V2Key::id, id) .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? .ok_or_else(|| { raise_error!( @@ -123,7 +181,7 @@ impl OAuth2 { DB_MANAGER.meta_db(), move |rw| { rw.get() - .secondary::(OAuth2Key::id, id) + .secondary::(OAuth2V2Key::id, id) .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? .ok_or_else(|| { raise_error!( @@ -171,6 +229,9 @@ pub struct OAuth2CreateRequest { /// route OAuth through proxy (when direct access is blocked) pub use_proxy: Option, + + /// The grant type to use for authentication. + pub grant_type: OAuth2GrantType, } impl OAuth2CreateRequest { @@ -186,6 +247,7 @@ impl OAuth2CreateRequest { extra_params: self.extra_params, enabled: self.enabled, use_proxy: self.use_proxy, + grant_type: self.grant_type, }) } } @@ -221,9 +283,12 @@ pub struct OAuth2UpdateRequest { /// route OAuth through proxy (when direct access is blocked) pub use_proxy: Option, + + /// The grant type to use for authentication. + pub grant_type: Option, } -fn apply_update(old: &OAuth2, request: OAuth2UpdateRequest) -> RustMailerResult { +fn apply_update(old: &OAuth2Model, request: OAuth2UpdateRequest) -> RustMailerResult { let mut new = old.clone(); if request.description.is_some() { new.description = request.description; @@ -255,6 +320,50 @@ fn apply_update(old: &OAuth2, request: OAuth2UpdateRequest) -> RustMailerResult< if let Some(use_proxy) = request.use_proxy { new.use_proxy = Some(use_proxy); } + if let Some(grant_type) = request.grant_type { + new.grant_type = grant_type; + } new.updated_at = utc_now!(); Ok(new) } + +impl From for OAuth2V2 { + fn from(value: OAuth2) -> Self { + Self { + id: value.id, + description: value.description, + client_id: value.client_id, + client_secret: value.client_secret, + auth_url: value.auth_url, + token_url: value.token_url, + redirect_uri: value.redirect_uri, + scopes: value.scopes, + extra_params: value.extra_params, + enabled: value.enabled, + use_proxy: value.use_proxy, + grant_type: OAuth2GrantType::AuthorizationCode, + created_at: value.created_at, + updated_at: value.updated_at, + } + } +} + +impl From for OAuth2 { + fn from(value: OAuth2V2) -> Self { + Self { + id: value.id, + description: value.description, + client_id: value.client_id, + client_secret: value.client_secret, + auth_url: value.auth_url, + token_url: value.token_url, + redirect_uri: value.redirect_uri, + scopes: value.scopes, + extra_params: value.extra_params, + enabled: value.enabled, + use_proxy: value.use_proxy, + created_at: value.created_at, + updated_at: value.updated_at, + } + } +} diff --git a/src/modules/oauth2/flow.rs b/src/modules/oauth2/flow.rs index d46b233..b63c956 100644 --- a/src/modules/oauth2/flow.rs +++ b/src/modules/oauth2/flow.rs @@ -4,9 +4,8 @@ use crate::modules::error::code::ErrorCode; use crate::modules::error::RustMailerResult; -use crate::modules::oauth2::{ - entity::OAuth2, pending::OAuth2PendingEntity, token::OAuth2AccessToken, -}; +use crate::modules::oauth2::entity::{OAuth2GrantType, OAuth2Model}; +use crate::modules::oauth2::{pending::OAuth2PendingEntity, token::OAuth2AccessToken}; use crate::modules::settings::proxy::Proxy; use crate::{decrypt, encrypt, raise_error}; use oauth2::{ @@ -50,7 +49,6 @@ impl OAuth2Flow { } pub async fn authorize_url(&self, account_id: u64) -> RustMailerResult { - // Fetch OAuth2 entity or return a custom error if not found let entity = self.fetch_oauth2_entity().await?; if !entity.enabled { @@ -62,11 +60,9 @@ impl OAuth2Flow { ErrorCode::OAuth2ItemDisabled )); } - // Create and configure the OAuth2 client + let client = self.build_oauth2_client(&entity)?; - // Generate PKCE challenge and verifier let (pkce_code_challenge, pkce_code_verifier) = PkceCodeChallenge::new_random_sha256(); - // Build the authorization URL request let mut request = client .authorize_url(CsrfToken::new_random) @@ -78,22 +74,20 @@ impl OAuth2Flow { .into_iter() .map(Scope::new), ); - // Add extra parameters + if let Some(extra_params) = &entity.extra_params { for (name, value) in extra_params { request = request.add_extra_param(name.clone(), value.clone()); } } - // Extract authorization URL and CSRF state + let (authorize_url, csrf_state) = request.url(); - // Save the pending OAuth2 state self.save_pending_oauth2_state( account_id, csrf_state.secret(), pkce_code_verifier.secret(), ) .await?; - // Return the authorization URL Ok(authorize_url.to_string()) } @@ -132,6 +126,57 @@ impl OAuth2Flow { Ok(()) } + /// Exchanges client credentials for an access token and stores it. + /// Suitable for grant_type = ClientCredentials (e.g. Microsoft Graph v2). + pub async fn exchange_client_credentials(&self, account_id: u64) -> RustMailerResult<()> { + let entity = self.fetch_oauth2_entity().await?; + + if !matches!(entity.grant_type, OAuth2GrantType::ClientCredentials) { + return Err(raise_error!( + format!( + "OAuth2 configuration id={} does not use ClientCredentials grant type.", + self.oauth2_id + ), + ErrorCode::InvalidParameter + )); + } + + if !entity.enabled { + return Err(raise_error!( + format!( + "OAuth2 authentication is disabled for this client '{}'.", + self.oauth2_id + ), + ErrorCode::OAuth2ItemDisabled + )); + } + + let client = self.build_oauth2_client(&entity)?; + let http_client = build_http_client(entity.use_proxy).await?; + + let scopes: Vec = entity + .scopes + .clone() + .unwrap_or_default() + .into_iter() + .map(Scope::new) + .collect(); + + let token_response = client + .exchange_client_credentials() + .add_scopes(scopes) + .request_async(&http_client) + .await + .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::HttpResponseError))?; + + let access_token = token_response.access_token().secret().to_owned(); + + self.save_oauth2_entity_creds(account_id, access_token) + .await?; + + Ok(()) + } + async fn save_oauth2_entity( &self, account_id: u64, @@ -143,6 +188,15 @@ impl OAuth2Flow { token.save_or_update().await } + async fn save_oauth2_entity_creds( + &self, + account_id: u64, + access_token: String, + ) -> RustMailerResult<()> { + let token = OAuth2AccessToken::create_creds(account_id, self.oauth2_id, access_token)?; + token.save_or_update().await + } + async fn update_oauth2_entity( &self, account_id: u64, @@ -166,7 +220,23 @@ impl OAuth2Flow { ErrorCode::OAuth2ItemDisabled )); } - let client = self.build_oauth2_client(&entity)?; + + match &entity.grant_type { + crate::modules::oauth2::entity::OAuth2GrantType::ClientCredentials => { + self.exchange_client_credentials(token.account_id).await + } + crate::modules::oauth2::entity::OAuth2GrantType::AuthorizationCode => { + self.refresh_authorization_code_token(&entity, token).await + } + } + } + + async fn refresh_authorization_code_token( + &self, + entity: &OAuth2Model, + token: &OAuth2AccessToken, + ) -> RustMailerResult<()> { + let client = self.build_oauth2_client(entity)?; let http_client = build_http_client(entity.use_proxy).await?; let refresh_token = token.refresh_token.clone().ok_or_else(|| { @@ -181,7 +251,8 @@ impl OAuth2Flow { .add_scopes( entity .scopes - .unwrap_or(Vec::new()) + .clone() + .unwrap_or_default() .into_iter() .map(Scope::new), ) @@ -209,8 +280,8 @@ impl OAuth2Flow { } // Helper function to fetch the OAuth2 entity - async fn fetch_oauth2_entity(&self) -> RustMailerResult { - OAuth2::get(self.oauth2_id).await?.ok_or_else(|| { + async fn fetch_oauth2_entity(&self) -> RustMailerResult { + OAuth2Model::get(self.oauth2_id).await?.ok_or_else(|| { raise_error!( format!("OAuth2 entity with id '{}' not found", self.oauth2_id), ErrorCode::ResourceNotFound @@ -219,15 +290,22 @@ impl OAuth2Flow { } // Helper function to build the OAuth2 client - fn build_oauth2_client(&self, entity: &OAuth2) -> RustMailerResult { + fn build_oauth2_client(&self, entity: &OAuth2Model) -> RustMailerResult { let auth_url = AuthUrl::new(entity.auth_url.clone()) .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?; let token_url = TokenUrl::new(entity.token_url.clone()) .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?; - let redirect_uri = RedirectUrl::new(entity.redirect_uri.clone()) + + // Client credentials flow does not use a redirect URI; `oauth2` crate still + // requires one on the client builder, so use a placeholder that is never sent. + let redirect_uri_str = if entity.redirect_uri.trim().is_empty() { + "http://localhost/oauth2/callback" + } else { + entity.redirect_uri.as_str() + }; + let redirect_uri = RedirectUrl::new(redirect_uri_str.to_owned()) .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?; - // Create and return the OAuth2 client let client = BasicClient::new(ClientId::new(entity.client_id.clone())) .set_client_secret(ClientSecret::new(decrypt!(&entity.client_secret)?)) .set_auth_uri(auth_url) diff --git a/src/modules/oauth2/token.rs b/src/modules/oauth2/token.rs index 71f33ce..0ccd6f1 100644 --- a/src/modules/oauth2/token.rs +++ b/src/modules/oauth2/token.rs @@ -10,7 +10,7 @@ use crate::{ update_impl, upsert_impl, }, error::{code::ErrorCode, RustMailerResult}, - oauth2::entity::OAuth2, + oauth2::entity::OAuth2Model, }, raise_error, utc_now, }; @@ -58,6 +58,21 @@ impl OAuth2AccessToken { }) } + pub fn create_creds( + account_id: u64, + oauth2_id: u64, + access_token: String, + ) -> RustMailerResult { + Ok(Self { + account_id, + oauth2_id, + access_token: Some(encrypt!(&access_token)?), + refresh_token: None, + created_at: utc_now!(), + updated_at: utc_now!(), + }) + } + pub async fn upsert_external_oauth_token( account_id: u64, request: ExternalOAuth2Request, @@ -215,7 +230,7 @@ impl ExternalOAuth2Request { // Validate that oauth2_id exists in the database if provided if let Some(oauth2_id) = self.oauth2_id { - let oauth2 = OAuth2::get(oauth2_id).await?; + let oauth2 = OAuth2Model::get(oauth2_id).await?; if oauth2.is_none() { return Err(raise_error!( format!("OAuth2 configuration with id {} does not exist", oauth2_id), diff --git a/src/modules/rest/api/oauth2.rs b/src/modules/rest/api/oauth2.rs index d0b4a04..9adebbe 100644 --- a/src/modules/rest/api/oauth2.rs +++ b/src/modules/rest/api/oauth2.rs @@ -4,7 +4,7 @@ use crate::modules::common::auth::ClientContext; use crate::modules::error::code::ErrorCode; -use crate::modules::oauth2::entity::{OAuth2, OAuth2CreateRequest, OAuth2UpdateRequest}; +use crate::modules::oauth2::entity::{OAuth2CreateRequest, OAuth2Model, OAuth2UpdateRequest}; use crate::modules::oauth2::flow::{AuthorizeUrlRequest, OAuth2Flow}; use crate::modules::oauth2::token::{ExternalOAuth2Request, OAuth2AccessToken}; use crate::modules::rest::api::ApiTags; @@ -12,9 +12,12 @@ use crate::modules::rest::response::DataPage; use crate::modules::rest::ApiResult; use crate::raise_error; use poem::web::Path; -use poem_openapi::param::Query; -use poem_openapi::payload::{Json, PlainText}; -use poem_openapi::OpenApi; +use poem_openapi::{ + param::Query, + payload::{Json, PlainText}, + Object, OpenApi, +}; +use serde::{Deserialize, Serialize}; pub struct OAuth2Api; @@ -33,10 +36,10 @@ impl OAuth2Api { /// The name of the OAuth2 configuration to retrieve id: Path, context: ClientContext, - ) -> ApiResult> { + ) -> ApiResult> { context.require_root()?; let id = id.0; - Ok(Json(OAuth2::get(id).await?.ok_or_else(|| { + Ok(Json(OAuth2Model::get(id).await?.ok_or_else(|| { raise_error!( format!("OAuth2 configuration id='{id}' not found"), ErrorCode::ResourceNotFound @@ -59,7 +62,7 @@ impl OAuth2Api { context: ClientContext, ) -> ApiResult<()> { context.require_root()?; - Ok(OAuth2::delete(id.0).await?) + Ok(OAuth2Model::delete(id.0).await?) } /// Creates a new OAuth2 configuration. @@ -77,7 +80,7 @@ impl OAuth2Api { context: ClientContext, ) -> ApiResult<()> { context.require_root()?; - let entity = OAuth2::new(request.0)?; + let entity = OAuth2Model::new(request.0)?; Ok(entity.save().await?) } @@ -98,7 +101,7 @@ impl OAuth2Api { context: ClientContext, ) -> ApiResult<()> { context.require_root()?; - Ok(OAuth2::update(id.0, payload.0).await?) + Ok(OAuth2Model::update(id.0, payload.0).await?) } /// Lists OAuth2 configurations with pagination and sorting options. @@ -118,10 +121,10 @@ impl OAuth2Api { /// Optional. Whether to sort the list in descending order. desc: Query>, context: ClientContext, - ) -> ApiResult>> { + ) -> ApiResult>> { context.require_root()?; Ok(Json( - OAuth2::paginate_list(page.0, page_size.0, desc.0).await?, + OAuth2Model::paginate_list(page.0, page_size.0, desc.0).await?, )) } @@ -202,4 +205,54 @@ impl OAuth2Api { Ok(()) } + + /// Exchanges client credentials for an access token. + /// Only applicable for OAuth2 configurations with grant_type = CLIENT_CREDENTIALS. + #[oai( + path = "/oauth2-exchange-client-credentials", + method = "post", + operation_id = "exchange_client_credentials" + )] + async fn exchange_client_credentials( + &self, + request: Json, + context: ClientContext, + ) -> ApiResult<()> { + context.require_account_access(request.account_id)?; + let flow = OAuth2Flow::new(request.oauth2_id); + flow.exchange_client_credentials(request.account_id).await?; + Ok(()) + } + + /// Refreshes the OAuth2 access token for a specified account. + /// Uses the refresh token for AuthorizationCode configs, or re-exchanges + /// client credentials for ClientCredentials configs. + #[oai( + path = "/oauth2-tokens/:account_id/refresh", + method = "post", + operation_id = "refresh_oauth2_token" + )] + async fn refresh_oauth2_token( + &self, + account_id: Path, + context: ClientContext, + ) -> ApiResult<()> { + let account = account_id.0; + context.require_account_access(account)?; + let token = OAuth2AccessToken::get(account).await?.ok_or_else(|| { + raise_error!( + "OAuth2 access tokens not found".into(), + ErrorCode::ResourceNotFound + ) + })?; + let flow = OAuth2Flow::new(token.oauth2_id); + flow.refresh_access_token(&token).await?; + Ok(()) + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)] +pub struct ExchangeClientCredentialsRequest { + pub account_id: u64, + pub oauth2_id: u64, } diff --git a/src/modules/rest/mod.rs b/src/modules/rest/mod.rs index 375e1ff..136645a 100644 --- a/src/modules/rest/mod.rs +++ b/src/modules/rest/mod.rs @@ -38,7 +38,7 @@ pub mod response; pub type ApiResult = std::result::Result; const DESCRIPTION: &str = r#" - RustMailer is a self-hosted IMAP/SMTP middleware platform designed for developers and businesses seeking a robust, scalable, and secure email solution. + RustMailer is a self-hosted IMAP, SMTP, Gmail API, Graph API middleware platform designed for developers and businesses seeking a robust, scalable, and secure email solution. - Provides seamless IMAP synchronization and reliable SMTP sending via blazing-fast REST and gRPC APIs. - Supports programmable email workflows, customizable filters, and webhook notifications. @@ -67,7 +67,7 @@ pub async fn start_http_server() -> RustMailerResult<()> { .contact(ContactObject::new().email("rustmailer.git@gmail.com")) .license("https://rustmailer.com/license") .external_document("https://rustmailer.com/docs") - .summary("A self-hosted IMAP/SMTP middleware designed for developers"); + .summary("A self-hosted Email Middleware for IMAP, SMTP, Gmail API, Graph API — built for developers"); let swagger = api_service.swagger_ui(); let redoc = api_service.redoc(); diff --git a/web/src/api/oauth2/api.ts b/web/src/api/oauth2/api.ts index b1ee50d..b05a7b9 100644 --- a/web/src/api/oauth2/api.ts +++ b/web/src/api/oauth2/api.ts @@ -46,4 +46,17 @@ export interface OAuth2Tokens { export const get_oauth2_tokens = async (accountId: number) => { const response = await axiosInstance.get(`/api/v1/oauth2-tokens/${accountId}`); return response.data; +}; + +export const exchange_client_credentials = async (accountId: number, oauth2Id: number) => { + const response = await axiosInstance.post(`/api/v1/oauth2-exchange-client-credentials`, { + account_id: accountId, + oauth2_id: oauth2Id + }); + return response.data; +}; + +export const refresh_oauth2_token = async (accountId: number) => { + const response = await axiosInstance.post(`/api/v1/oauth2-tokens/${accountId}/refresh`); + return response.data; }; \ No newline at end of file diff --git a/web/src/features/accounts/components/oauth2-tokens.tsx b/web/src/features/accounts/components/oauth2-tokens.tsx index 490ae64..0f87411 100644 --- a/web/src/features/accounts/components/oauth2-tokens.tsx +++ b/web/src/features/accounts/components/oauth2-tokens.tsx @@ -15,12 +15,12 @@ import { } from '@/components/ui/dialog' import { AccountEntity } from '../data/schema' import { Button } from '@/components/ui/button' -import { get_oauth2_tokens } from '@/api/oauth2/api' -import { useQuery } from '@tanstack/react-query' +import { get_oauth2_tokens, refresh_oauth2_token } from '@/api/oauth2/api' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Card, CardContent } from '@/components/ui/card' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { TableSkeleton } from '@/components/table-skeleton' -import { FileIcon } from 'lucide-react' +import { FileIcon, RefreshCw } from 'lucide-react' import { format, formatDistanceToNow } from 'date-fns' import LongText from '@/components/long-text' import { useCallback } from 'react' @@ -28,6 +28,7 @@ import { IconCopy } from '@tabler/icons-react' import { toast } from '@/hooks/use-toast' import { ToastAction } from '@/components/ui/toast' import { useNavigate } from '@tanstack/react-router' +import { cn } from '@/lib/utils' interface Props { currentRow: AccountEntity @@ -37,6 +38,7 @@ interface Props { export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) { const navigate = useNavigate() + const queryClient = useQueryClient() const { data: oauth2Tokens, isLoading } = useQuery({ queryKey: ['oauth2-tokens', currentRow.id], queryFn: () => get_oauth2_tokens(currentRow.id), @@ -71,6 +73,24 @@ export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) { } }, []); + const refreshMutation = useMutation({ + mutationFn: () => refresh_oauth2_token(currentRow.id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['oauth2-tokens', currentRow.id] }); + toast({ + title: "Token refreshed", + description: "The OAuth2 token has been successfully refreshed.", + }); + }, + onError: () => { + toast({ + variant: "destructive", + title: "Refresh failed", + description: "Failed to refresh the token. Please check your OAuth2 configuration.", + }); + }, + }); + return ( - - OAuth2 Name - - {oauth2Tokens.oauth2_name} - - Access Token @@ -116,17 +130,19 @@ export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) { - - Refresh Token - - {oauth2Tokens.refresh_token} - - - - - + {oauth2Tokens.refresh_token && oauth2Tokens.refresh_token !== '' && ( + + Refresh Token + + {oauth2Tokens.refresh_token} + + + + + + )} Created At @@ -147,15 +163,33 @@ export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {

No OAuth2 Tokens

- The account has not completed the authorization process. Please - navigate({ to: '/oauth2' })} className="ml-1 text-blue-500 underline cursor-pointer">click here to authorize the account. + The account has not completed the authorization process.

+
+ {oauth2Tokens === null && ( + + )} +
)} + {oauth2Tokens && ( + + )} diff --git a/web/src/features/oauth2/components/action-dialog.tsx b/web/src/features/oauth2/components/action-dialog.tsx index 7e000bc..106f3c0 100644 --- a/web/src/features/oauth2/components/action-dialog.tsx +++ b/web/src/features/oauth2/components/action-dialog.tsx @@ -76,30 +76,28 @@ function convertToScopeSchema(authorizeScopes: z.infer void } -const defaultValues = { +const defaultValues: OAuth2Form = { description: undefined, client_id: '', client_secret: '', + grant_type: 'AuthorizationCode', auth_url: '', token_url: '', redirect_uri: '', @@ -138,6 +137,7 @@ export function ActionDialog({ currentRow, open, onOpenChange }: Props) { description: currentRow.description ?? undefined, client_id: currentRow.client_id, client_secret: undefined, + grant_type: currentRow.grant_type, auth_url: currentRow.auth_url, token_url: currentRow.token_url, redirect_uri: currentRow.redirect_uri, @@ -230,6 +230,7 @@ export function ActionDialog({ currentRow, open, onOpenChange }: Props) { description: values.description, client_id: values.client_id, client_secret: prepareClientSecret(values.client_secret), + grant_type: values.grant_type, auth_url: values.auth_url, token_url: values.token_url, redirect_uri: values.redirect_uri, @@ -243,6 +244,7 @@ export function ActionDialog({ currentRow, open, onOpenChange }: Props) { description: values.description, client_id: values.client_id, client_secret: values.client_secret!, + grant_type: values.grant_type, auth_url: values.auth_url, token_url: values.token_url, redirect_uri: values.redirect_uri, @@ -254,6 +256,8 @@ export function ActionDialog({ currentRow, open, onOpenChange }: Props) { } } + const isClientCredentials = form.watch('grant_type') === 'ClientCredentials' + return ( - Outlook + Outlook (Consumer) + + + @@ -309,6 +333,32 @@ export function ActionDialog({ currentRow, open, onOpenChange }: Props) { onSubmit={form.handleSubmit(onSubmit)} className='space-y-4 p-0.5' > + ( + + Grant Type: + + + {field.value === 'ClientCredentials' + ? 'Uses client_id + client_secret to obtain tokens automatically. Suitable for organizational accounts with Microsoft Graph.' + : 'Uses PKCE flow with user interaction. Suitable for personal accounts (Outlook.com, Hotmail).'} + + + + )} + /> )} /> - ( - - Auth Url: - - - - - The URL where users will be redirected to authorize your application. - - - - )} - /> + {!isClientCredentials && ( + ( + + Auth Url: + + + + + The URL where users will be redirected to authorize your application. + + + + )} + /> + )} - The URL used to exchange the authorization code for an access token. - - - - )} - /> - ( - - Redirect Url: - - - - - The redirect URL after authorization. It must match the one registered with the OAuth provider. - Use the format http://[host]:[port]/oauth2/callback (or https://), - where [host] and [port] match your RustMailer deployment. - The path /oauth2/callback is fixed. + The URL used to exchange credentials for an access token. + For Microsoft Graph client credentials, replace YOUR_TENANT_ID with your tenant ID or domain (e.g. yourtenant.onmicrosoft.com):{' '} + https://login.microsoftonline.com/YOUR_TENANT_ID/oauth2/v2.0/token + {' '}Multi-tenant apps may use common or organizations instead. )} /> + {!isClientCredentials && ( + ( + + Redirect Url: + + + + + The redirect URL after authorization. It must match the one registered with the OAuth provider. + Use the format http://[host]:[port]/oauth2/callback (or https://), + where [host] and [port] match your RustMailer deployment. + The path /oauth2/callback is fixed. + + + + )} + /> + )}
{scopes.map((field, index) => (
diff --git a/web/src/features/oauth2/components/authorize-dialog.tsx b/web/src/features/oauth2/components/authorize-dialog.tsx index d9d3357..cb302de 100644 --- a/web/src/features/oauth2/components/authorize-dialog.tsx +++ b/web/src/features/oauth2/components/authorize-dialog.tsx @@ -20,10 +20,11 @@ import { useState } from 'react' import { VirtualizedSelect } from '@/components/virtualized-select' import useMinimalAccountList from '@/hooks/use-minimal-account-list' import { useMutation } from '@tanstack/react-query' -import { get_authorize_url } from '@/api/oauth2/api' +import { exchange_client_credentials, get_authorize_url } from '@/api/oauth2/api' import { toast } from '@/hooks/use-toast' import { ToastAction } from '@/components/ui/toast' import { AxiosError } from 'axios' +import { Loader2 } from 'lucide-react' interface Props { currentRow: OAuth2Entity @@ -37,18 +38,28 @@ export function AuthorizeDialog({ currentRow, open, onOpenChange }: Props) { const [accountId, setAccountId] = useState(null) const { accountsOptions, minimalList, isLoading } = useMinimalAccountList(); + const isClientCredentials = currentRow.grant_type === 'ClientCredentials' const authorizeMutation = useMutation({ - mutationFn: () => get_authorize_url({ account_id: accountId, oauth2_id: currentRow.id }), - onSuccess: handleSuccess, + mutationFn: () => + isClientCredentials + ? exchange_client_credentials(accountId!, currentRow.id) + : get_authorize_url({ account_id: accountId, oauth2_id: currentRow.id }), + onSuccess: (url: any) => { + if (isClientCredentials) { + toast({ + title: 'Access Token Acquired', + description: 'Client credentials exchanged successfully. The access token has been stored.', + action: Close, + }); + } else if (typeof url === 'string' && url) { + window.open(url, '_blank'); + } + onOpenChange(false); + }, onError: handleError }); - - function handleSuccess(url: string) { - window.open(url, '_blank'); - onOpenChange(false); - } function handleError(error: AxiosError) { const errorMessage = (error.response?.data as { message?: string })?.message || error.message || @@ -56,7 +67,7 @@ export function AuthorizeDialog({ currentRow, open, onOpenChange }: Props) { toast({ variant: "destructive", - title: 'Get Authorize Url Failed', + title: isClientCredentials ? 'Client Credentials Exchange Failed' : 'Get Authorize Url Failed', description: errorMessage as string, action: Try again, }); @@ -77,9 +88,11 @@ export function AuthorizeDialog({ currentRow, open, onOpenChange }: Props) { > - Authorize Email Account + {isClientCredentials ? 'Exchange Client Credentials' : 'Authorize Email Account'} - Authorize an email account to start the OAuth2 authorization process. + {isClientCredentials + ? 'Exchange client credentials to obtain an access token for the selected account. No user interaction is required.' + : 'Authorize an email account to start the OAuth2 authorization process.'}
@@ -112,7 +125,12 @@ export function AuthorizeDialog({ currentRow, open, onOpenChange }: Props) { - {!isLoading && minimalList && minimalList.length > 0 && } + {!isLoading && minimalList && minimalList.length > 0 && ( + + )}
diff --git a/web/src/features/oauth2/components/columns.tsx b/web/src/features/oauth2/components/columns.tsx index b580029..efb4dec 100644 --- a/web/src/features/oauth2/components/columns.tsx +++ b/web/src/features/oauth2/components/columns.tsx @@ -65,6 +65,29 @@ export const columns: ColumnDef[] = [ cell: EnableAction, meta: { className: 'w-8 text-center' }, }, + { + accessorKey: 'grant_type', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const grantType = row.original.grant_type; + const label = grantType === 'ClientCredentials' ? 'Client Credentials' : 'Auth Code'; + return ( + + {label} + + ); + }, + meta: { className: 'w-32' }, + enableHiding: true, + enableSorting: false, + }, { accessorKey: 'use_proxy', header: ({ column }) => ( diff --git a/web/src/features/oauth2/data/schema.ts b/web/src/features/oauth2/data/schema.ts index 4420f6e..7cb1887 100644 --- a/web/src/features/oauth2/data/schema.ts +++ b/web/src/features/oauth2/data/schema.ts @@ -10,6 +10,7 @@ export interface OAuth2Entity { extra_params?: Record; enabled: boolean; use_proxy?: number; + grant_type: 'AuthorizationCode' | 'ClientCredentials'; created_at: number; updated_at: number; } \ No newline at end of file