mirror of
https://github.com/rustmailer/rustmailer.git
synced 2026-08-25 08:00:35 +00:00
feat: add OAuth2 client credentials flow for Microsoft Graph
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<DatabaseManager> = 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<DatabaseManager> = LazyLock::new(DatabaseManager::new);
|
||||
|
||||
/// Metadata database instance
|
||||
pub struct DatabaseManager {
|
||||
meta_db: Arc<Database<'static>>,
|
||||
@@ -92,6 +93,8 @@ impl DatabaseManager {
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
rw.migrate::<AccountModel>()
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
rw.migrate::<OAuth2Model>()
|
||||
.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);
|
||||
|
||||
@@ -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::<EmailTemplate>();
|
||||
self.register_model::<Mta>();
|
||||
self.register_model::<OAuth2>();
|
||||
self.register_model::<OAuth2V2>();
|
||||
self.register_model::<OAuth2PendingEntity>();
|
||||
self.register_model::<OAuth2AccessToken>();
|
||||
self.register_model::<EventHooks>();
|
||||
|
||||
@@ -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<i32>) -> Option<OAuth2GrantType> {
|
||||
match v {
|
||||
Some(code) => Some(grpc_grant_type_to_domain(code)),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rustmailer_grpc::OAuth2CreateRequest> for OAuth2CreateRequest {
|
||||
fn from(value: rustmailer_grpc::OAuth2CreateRequest) -> Self {
|
||||
Self {
|
||||
@@ -25,11 +41,11 @@ impl From<rustmailer_grpc::OAuth2CreateRequest> 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<rustmailer_grpc::UpdateOAuth2Request> for OAuth2UpdateRequest {
|
||||
fn from(value: rustmailer_grpc::UpdateOAuth2Request) -> Self {
|
||||
Self {
|
||||
@@ -44,13 +60,13 @@ impl From<rustmailer_grpc::UpdateOAuth2Request> 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<OAuth2> for rustmailer_grpc::OAuth2 {
|
||||
fn from(value: OAuth2) -> Self {
|
||||
impl From<OAuth2Model> for rustmailer_grpc::OAuth2 {
|
||||
fn from(value: OAuth2Model) -> Self {
|
||||
Self {
|
||||
id: value.id,
|
||||
description: value.description,
|
||||
@@ -68,12 +84,16 @@ impl From<OAuth2> 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<DataPage<OAuth2>> for PagedOAuth2 {
|
||||
fn from(value: DataPage<OAuth2>) -> Self {
|
||||
impl From<DataPage<OAuth2Model>> for PagedOAuth2 {
|
||||
fn from(value: DataPage<OAuth2Model>) -> Self {
|
||||
Self {
|
||||
current_page: value.current_page,
|
||||
page_size: value.page_size,
|
||||
@@ -96,6 +116,7 @@ impl From<OAuth2AccessToken> for rustmailer_grpc::OAuth2AccessToken {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rustmailer_grpc::ExternalOAuth2Request> for ExternalOAuth2Request {
|
||||
fn from(value: rustmailer_grpc::ExternalOAuth2Request) -> Self {
|
||||
Self {
|
||||
@@ -105,3 +126,9 @@ impl From<rustmailer_grpc::ExternalOAuth2Request> for ExternalOAuth2Request {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ClientCredentialsRequest> for (u64, u64) {
|
||||
fn from(value: ClientCredentialsRequest) -> Self {
|
||||
(value.account_id, value.oauth2_id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AuthorizeUrlRequest>,
|
||||
) -> Result<Response<AuthorizeUrlResponse>, Status> {
|
||||
request: Request<crate::modules::grpc::service::rustmailer_grpc::AuthorizeUrlRequest>,
|
||||
) -> Result<Response<crate::modules::grpc::service::rustmailer_grpc::AuthorizeUrlResponse>, 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<ClientCredentialsRequest>,
|
||||
) -> Result<Response<Empty>, 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<ExternalOAuth2Request>,
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<Vec<String>>,
|
||||
/// Any additional parameters to include in the OAuth2 requests (e.g., access_type, prompt).
|
||||
pub extra_params: Option<BTreeMap<String, String>>,
|
||||
/// Indicates whether this configuration is enabled or disabled.
|
||||
pub enabled: bool,
|
||||
/// route OAuth through proxy (when direct access is blocked)
|
||||
pub use_proxy: Option<u64>,
|
||||
/// 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<Self> {
|
||||
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<u64>,
|
||||
page_size: Option<u64>,
|
||||
desc: Option<bool>,
|
||||
) -> RustMailerResult<DataPage<OAuth2>> {
|
||||
) -> RustMailerResult<DataPage<OAuth2Model>> {
|
||||
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<Option<OAuth2>> {
|
||||
secondary_find_impl(DB_MANAGER.meta_db(), OAuth2Key::id, id).await
|
||||
pub async fn get(id: u64) -> RustMailerResult<Option<OAuth2Model>> {
|
||||
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::<OAuth2>(OAuth2Key::id, id)
|
||||
.secondary::<OAuth2Model>(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::<OAuth2>(OAuth2Key::id, id)
|
||||
.secondary::<OAuth2Model>(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<u64>,
|
||||
|
||||
/// 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<u64>,
|
||||
|
||||
/// The grant type to use for authentication.
|
||||
pub grant_type: Option<OAuth2GrantType>,
|
||||
}
|
||||
|
||||
fn apply_update(old: &OAuth2, request: OAuth2UpdateRequest) -> RustMailerResult<OAuth2> {
|
||||
fn apply_update(old: &OAuth2Model, request: OAuth2UpdateRequest) -> RustMailerResult<OAuth2Model> {
|
||||
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<OAuth2> 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<OAuth2V2> 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+96
-18
@@ -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<String> {
|
||||
// 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<Scope> = 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> {
|
||||
OAuth2::get(self.oauth2_id).await?.ok_or_else(|| {
|
||||
async fn fetch_oauth2_entity(&self) -> RustMailerResult<OAuth2Model> {
|
||||
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<OAuth2Client> {
|
||||
fn build_oauth2_client(&self, entity: &OAuth2Model) -> RustMailerResult<OAuth2Client> {
|
||||
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)
|
||||
|
||||
@@ -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<Self> {
|
||||
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),
|
||||
|
||||
@@ -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<u64>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<OAuth2>> {
|
||||
) -> ApiResult<Json<OAuth2Model>> {
|
||||
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<Option<bool>>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<DataPage<OAuth2>>> {
|
||||
) -> ApiResult<Json<DataPage<OAuth2Model>>> {
|
||||
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<ExchangeClientCredentialsRequest>,
|
||||
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<u64>,
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ pub mod response;
|
||||
pub type ApiResult<T, E = ApiErrorResponse> = std::result::Result<T, E>;
|
||||
|
||||
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();
|
||||
|
||||
@@ -46,4 +46,17 @@ export interface OAuth2Tokens {
|
||||
export const get_oauth2_tokens = async (accountId: number) => {
|
||||
const response = await axiosInstance.get<OAuth2Tokens>(`/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;
|
||||
};
|
||||
@@ -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 (
|
||||
<Dialog
|
||||
open={open}
|
||||
@@ -99,12 +119,6 @@ export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className='max-w-80'>OAuth2 Name</TableCell>
|
||||
<TableCell>
|
||||
<LongText className='max-w-[240px] sm:max-w-[430px]'>{oauth2Tokens.oauth2_name}</LongText>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className='max-w-80'>Access Token</TableCell>
|
||||
<TableCell>
|
||||
@@ -116,17 +130,19 @@ export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className='max-w-80'>Refresh Token</TableCell>
|
||||
<TableCell>
|
||||
<LongText className='max-w-[240px] sm:max-w-[430px]'>{oauth2Tokens.refresh_token}</LongText>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button className='text-xs px-1.5 py-0.5' onClick={() => onCopy(false, oauth2Tokens.refresh_token)}>
|
||||
<IconCopy className="h-5 w-5" aria-hidden="true" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{oauth2Tokens.refresh_token && oauth2Tokens.refresh_token !== '' && (
|
||||
<TableRow>
|
||||
<TableCell className='max-w-80'>Refresh Token</TableCell>
|
||||
<TableCell>
|
||||
<LongText className='max-w-[240px] sm:max-w-[430px]'>{oauth2Tokens.refresh_token}</LongText>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button className='text-xs px-1.5 py-0.5' onClick={() => onCopy(false, oauth2Tokens.refresh_token!)}>
|
||||
<IconCopy className="h-5 w-5" aria-hidden="true" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
<TableRow>
|
||||
<TableCell className='max-w-80'>Created At</TableCell>
|
||||
<TableCell>
|
||||
@@ -147,15 +163,33 @@ export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
<FileIcon className="h-10 w-10 text-muted-foreground" />
|
||||
<h3 className="mt-4 text-lg font-semibold">No OAuth2 Tokens</h3>
|
||||
<p className="mb-4 mt-2 text-sm text-muted-foreground">
|
||||
The account has not completed the authorization process. Please
|
||||
<a onClick={() => navigate({ to: '/oauth2' })} className="ml-1 text-blue-500 underline cursor-pointer">click here</a> to authorize the account.
|
||||
The account has not completed the authorization process.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{oauth2Tokens === null && (
|
||||
<Button onClick={() => navigate({ to: '/oauth2' })} variant="outline">
|
||||
Go to OAuth2 Configs
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DialogFooter>
|
||||
{oauth2Tokens && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refreshMutation.mutate()}
|
||||
disabled={refreshMutation.isPending}
|
||||
className="mr-auto"
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4 mr-1", refreshMutation.isPending && "animate-spin")} />
|
||||
Refresh Token
|
||||
</Button>
|
||||
)}
|
||||
<DialogClose asChild>
|
||||
<Button variant='outline' className="px-2 py-1 text-sm h-auto">Close</Button>
|
||||
</DialogClose>
|
||||
|
||||
@@ -76,30 +76,28 @@ function convertToScopeSchema(authorizeScopes: z.infer<typeof authorizescopeSche
|
||||
}));
|
||||
}
|
||||
|
||||
const grantTypeSchema = z.enum(['AuthorizationCode', 'ClientCredentials'], {
|
||||
required_error: 'Grant type is required',
|
||||
});
|
||||
|
||||
const oauth2Schema = z.object({
|
||||
description: z.string().max(255, { message: "Description must not exceed 255 characters." }).optional(),
|
||||
client_id: z.string({
|
||||
required_error: "Client ID is required",
|
||||
}).min(1, { message: "Client ID cannot be empty" }),
|
||||
client_secret: z.string().optional(),
|
||||
grant_type: grantTypeSchema,
|
||||
auth_url: z.string({
|
||||
required_error: "Authorization URL is required",
|
||||
})
|
||||
.min(1, { message: "Authorization URL cannot be empty" })
|
||||
.url({ message: "Invalid Authorization URL format" }),
|
||||
|
||||
.url({ message: "Invalid Authorization URL format" }).optional().or(z.literal('')),
|
||||
token_url: z.string({
|
||||
required_error: "Token URL is required",
|
||||
})
|
||||
.min(1, { message: "Token URL cannot be empty" })
|
||||
.url({ message: "Invalid Token URL format" }),
|
||||
|
||||
redirect_uri: z.string({
|
||||
required_error: "Redirect URI is required",
|
||||
})
|
||||
.min(1, { message: "Redirect URI cannot be empty" })
|
||||
.url({ message: "Invalid Redirect URI format" }),
|
||||
|
||||
redirect_uri: z.string().url({ message: "Invalid Redirect URI format" }).optional().or(z.literal('')),
|
||||
scopes: z.array(scopeSchema).optional(),
|
||||
extra_params: z.array(paramSchema).optional(),
|
||||
enabled: z.boolean(),
|
||||
@@ -115,10 +113,11 @@ interface Props {
|
||||
onOpenChange: (open: boolean) => 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 (
|
||||
<Dialog
|
||||
open={open}
|
||||
@@ -281,6 +285,8 @@ export function ActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
form.setValue("auth_url", "https://accounts.google.com/o/oauth2/v2/auth");
|
||||
form.setValue("token_url", "https://oauth2.googleapis.com/token");
|
||||
form.setValue("enabled", true);
|
||||
form.setValue("grant_type", "AuthorizationCode");
|
||||
form.setValue("redirect_uri", "http://localhost");
|
||||
form.setValue("scopes", [{ value: "https://mail.google.com/" }]);
|
||||
form.setValue("extra_params", [{ key: "access_type", value: "offline" }, { key: "prompt", value: "consent" }])
|
||||
}}
|
||||
@@ -295,11 +301,29 @@ export function ActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
form.setValue("auth_url", "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize");
|
||||
form.setValue("token_url", "https://login.microsoftonline.com/consumers/oauth2/v2.0/token");
|
||||
form.setValue("enabled", true);
|
||||
form.setValue("grant_type", "AuthorizationCode");
|
||||
form.setValue("redirect_uri", "http://localhost");
|
||||
form.setValue("scopes", [{ value: "https://graph.microsoft.com/Mail.ReadWrite" }, { value: "https://graph.microsoft.com/Mail.Send" }, { value: "offline_access" }]);
|
||||
form.setValue("extra_params", [{ key: "prompt", value: "consent" }])
|
||||
}}
|
||||
>
|
||||
Outlook
|
||||
Outlook (Consumer)
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
form.setValue("auth_url", "https://login.microsoftonline.com/common/oauth2/v2.0/authorize");
|
||||
form.setValue("token_url", "https://login.microsoftonline.com/common/oauth2/v2.0/token");
|
||||
form.setValue("enabled", true);
|
||||
form.setValue("grant_type", "ClientCredentials");
|
||||
form.setValue("redirect_uri", "http://localhost");
|
||||
form.setValue("scopes", [{ value: "https://graph.microsoft.com/.default" }]);
|
||||
form.setValue("extra_params", []);
|
||||
}}
|
||||
>
|
||||
Microsoft 365 (Service)
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea className='h-[40rem] w-full pr-4 -mr-4 py-1'>
|
||||
@@ -309,6 +333,32 @@ export function ActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className='space-y-4 p-0.5'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='grant_type'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Grant Type:</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select grant type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="AuthorizationCode">Authorization Code + PKCE (requires user login)</SelectItem>
|
||||
<SelectItem value="ClientCredentials">Client Credentials (no user interaction, service-to-service)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{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).'}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='enabled'
|
||||
@@ -372,25 +422,27 @@ export function ActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='auth_url'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-col gap-y-1 space-y-0'>
|
||||
<FormLabel className='mb-1'>Auth Url:</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='Enter the authorization URL'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The URL where users will be redirected to authorize your application.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{!isClientCredentials && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='auth_url'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-col gap-y-1 space-y-0'>
|
||||
<FormLabel className='mb-1'>Auth Url:</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='Enter the authorization URL'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The URL where users will be redirected to authorize your application.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='token_url'
|
||||
@@ -404,34 +456,39 @@ export function ActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The URL used to exchange the authorization code for an access token.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='redirect_uri'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-col gap-y-1 space-y-0'>
|
||||
<FormLabel className='mb-1'>Redirect Url:</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='Enter your redirect URL'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The redirect URL after authorization. It must match the one registered with the OAuth provider.
|
||||
Use the format <code>http://[host]:[port]/oauth2/callback</code> (or <code>https://</code>),
|
||||
where <code>[host]</code> and <code>[port]</code> match your RustMailer deployment.
|
||||
The path <code>/oauth2/callback</code> is fixed.
|
||||
The URL used to exchange credentials for an access token.
|
||||
For Microsoft Graph client credentials, replace <code>YOUR_TENANT_ID</code> with your tenant ID or domain (e.g. <code>yourtenant.onmicrosoft.com</code>):{' '}
|
||||
<code>https://login.microsoftonline.com/YOUR_TENANT_ID/oauth2/v2.0/token</code>
|
||||
{' '}Multi-tenant apps may use <code>common</code> or <code>organizations</code> instead.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{!isClientCredentials && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='redirect_uri'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-col gap-y-1 space-y-0'>
|
||||
<FormLabel className='mb-1'>Redirect Url:</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='Enter your redirect URL'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The redirect URL after authorization. It must match the one registered with the OAuth provider.
|
||||
Use the format <code>http://[host]:[port]/oauth2/callback</code> (or <code>https://</code>),
|
||||
where <code>[host]</code> and <code>[port]</code> match your RustMailer deployment.
|
||||
The path <code>/oauth2/callback</code> is fixed.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
{scopes.map((field, index) => (
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center" key={field.id + index}>
|
||||
|
||||
@@ -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<number | null>(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: <ToastAction altText="Close">Close</ToastAction>,
|
||||
});
|
||||
} 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: <ToastAction altText="Try again">Try again</ToastAction>,
|
||||
});
|
||||
@@ -77,9 +88,11 @@ export function AuthorizeDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
>
|
||||
<DialogContent className='sm:max-w-lg' autoFocus>
|
||||
<DialogHeader className='text-left'>
|
||||
<DialogTitle>Authorize Email Account</DialogTitle>
|
||||
<DialogTitle>{isClientCredentials ? 'Exchange Client Credentials' : 'Authorize Email Account'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
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.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='flex flex-col space-y-4 h-24'>
|
||||
@@ -112,7 +125,12 @@ export function AuthorizeDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
<DialogClose asChild>
|
||||
<Button variant='outline' className="px-2 py-1 text-sm h-auto">Close</Button>
|
||||
</DialogClose>
|
||||
{!isLoading && minimalList && minimalList.length > 0 && <Button disabled={!accountId} onClick={doAuthorize}>Authorize</Button>}
|
||||
{!isLoading && minimalList && minimalList.length > 0 && (
|
||||
<Button disabled={!accountId || authorizeMutation.isPending} onClick={doAuthorize}>
|
||||
{authorizeMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{isClientCredentials ? 'Exchange' : 'Authorize'}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -65,6 +65,29 @@ export const columns: ColumnDef<OAuth2Entity>[] = [
|
||||
cell: EnableAction,
|
||||
meta: { className: 'w-8 text-center' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'grant_type',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='Grant Type' />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const grantType = row.original.grant_type;
|
||||
const label = grantType === 'ClientCredentials' ? 'Client Credentials' : 'Auth Code';
|
||||
return (
|
||||
<span className={cn(
|
||||
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold',
|
||||
grantType === 'ClientCredentials'
|
||||
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
)}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
meta: { className: 'w-32' },
|
||||
enableHiding: true,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'use_proxy',
|
||||
header: ({ column }) => (
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface OAuth2Entity {
|
||||
extra_params?: Record<string, string>;
|
||||
enabled: boolean;
|
||||
use_proxy?: number;
|
||||
grant_type: 'AuthorizationCode' | 'ClientCredentials';
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
Reference in New Issue
Block a user