diff --git a/crates/defguard_common/src/db/models/settings.rs b/crates/defguard_common/src/db/models/settings.rs index 8daaf158a..ad132f544 100644 --- a/crates/defguard_common/src/db/models/settings.rs +++ b/crates/defguard_common/src/db/models/settings.rs @@ -10,7 +10,7 @@ use url::Url; use utoipa::ToSchema; use uuid::Uuid; -use crate::{db::Id, global_value, secret::SecretStringWrapper}; +use crate::{db::Id, global_value, secret::SecretStringWrapper, types::AuthFlowType}; global_value!(SETTINGS, Option, None, set_settings, get_settings); @@ -529,6 +529,19 @@ impl Settings { pub fn proxy_public_url(&self) -> Result { Url::parse(&self.public_proxy_url) } + + /// Returns configured Edge Component URL with the correct callback path appended depending on auth flow type. + pub fn edge_callback_url(&self, auth_flow_type: AuthFlowType) -> Result { + let mut url = self.proxy_public_url()?; + // Append callback segments to the URL. + if let Ok(mut path_segments) = url.path_segments_mut() { + match auth_flow_type { + AuthFlowType::Enrollment => path_segments.extend(&["openid", "callback"]), + AuthFlowType::Mfa => path_segments.extend(&["openid", "mfa", "callback"]), + }; + } + Ok(url) + } } #[derive(Serialize)] @@ -676,4 +689,35 @@ mod test { "https://defguard.example.com:8443/path/auth/callback" ); } + + #[test] + fn test_edge_callback_url() { + let mut s = Settings { + public_proxy_url: "https://edge.example.com".into(), + ..Default::default() + }; + + assert_eq!( + s.edge_callback_url(AuthFlowType::Enrollment) + .unwrap() + .as_str(), + "https://edge.example.com/openid/callback" + ); + assert_eq!( + s.edge_callback_url(AuthFlowType::Mfa).unwrap().as_str(), + "https://edge.example.com/openid/mfa/callback" + ); + + s.public_proxy_url = "https://edge.example.com:8443/path".into(); + assert_eq!( + s.edge_callback_url(AuthFlowType::Enrollment) + .unwrap() + .as_str(), + "https://edge.example.com:8443/path/openid/callback" + ); + assert_eq!( + s.edge_callback_url(AuthFlowType::Mfa).unwrap().as_str(), + "https://edge.example.com:8443/path/openid/mfa/callback" + ); + } } diff --git a/crates/defguard_common/src/types/mod.rs b/crates/defguard_common/src/types/mod.rs index 04247cadd..2b5d8ad81 100644 --- a/crates/defguard_common/src/types/mod.rs +++ b/crates/defguard_common/src/types/mod.rs @@ -3,3 +3,9 @@ pub mod proxy; pub mod user_info; pub type UrlParseError = url::ParseError; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AuthFlowType { + Enrollment, + Mfa, +} diff --git a/crates/defguard_core/src/enterprise/grpc/desktop_client_mfa.rs b/crates/defguard_core/src/enterprise/grpc/desktop_client_mfa.rs index e23c402c2..6b595cde4 100644 --- a/crates/defguard_core/src/enterprise/grpc/desktop_client_mfa.rs +++ b/crates/defguard_core/src/enterprise/grpc/desktop_client_mfa.rs @@ -1,6 +1,6 @@ +use defguard_common::{db::models::Settings, types::AuthFlowType}; use defguard_proto::proxy::{ClientMfaOidcAuthenticateRequest, DeviceInfo, MfaMethod}; use openidconnect::{AuthorizationCode, Nonce}; -use reqwest::Url; use tonic::Status; use crate::{ @@ -84,10 +84,12 @@ impl ClientMfaServer { ); let code = AuthorizationCode::new(request.code.clone()); - let url = match Url::parse(&request.callback_url).map_err(|err| { - error!("Invalid redirect URL provided: {err}"); - Status::invalid_argument("invalid redirect URL") - }) { + let url = match Settings::get_current_settings() + .edge_callback_url(AuthFlowType::Mfa) + .map_err(|err| { + error!("Invalid callback URL configuration: {err}"); + Status::invalid_argument("invalid callback URL") + }) { Ok(url) => url, Err(status) => { self.sessions @@ -101,7 +103,7 @@ impl ClientMfaServer { location: location.clone(), device: device.clone(), method, - message: "provided invalid redirect URL".to_string(), + message: "provided invalid callback URL".to_string(), }, )), })?; diff --git a/crates/defguard_proxy_manager/src/handler.rs b/crates/defguard_proxy_manager/src/handler.rs index 06b24aba0..f16fb182c 100644 --- a/crates/defguard_proxy_manager/src/handler.rs +++ b/crates/defguard_proxy_manager/src/handler.rs @@ -12,6 +12,7 @@ use defguard_common::{ Id, models::{Settings, proxy::Proxy}, }, + types::AuthFlowType, }; use defguard_core::{ db::models::enrollment::{ENROLLMENT_TOKEN_TYPE, Token}, @@ -34,8 +35,8 @@ use defguard_core::{ }; use defguard_grpc_tls::{certs as tls_certs, connector::HttpsSchemeConnector}; use defguard_proto::proxy::{ - AuthCallbackResponse, AuthInfoResponse, CoreError, CoreRequest, CoreResponse, InitialInfo, - core_request, core_response, proxy_client::ProxyClient, + AuthCallbackResponse, AuthFlowType as ProtoAuthFlowType, AuthInfoResponse, CoreError, + CoreRequest, CoreResponse, InitialInfo, core_request, core_response, proxy_client::ProxyClient, }; use defguard_version::{ ComponentInfo, DefguardComponent, client::ClientVersionInterceptor, get_tracing_variables, @@ -624,70 +625,90 @@ impl ProxyHandler { status_code: Code::FailedPrecondition as i32, message: "no valid license".into(), })) - } else if let Ok(redirect_url) = Url::parse(&request.redirect_url) { - if let Some(provider) = OpenIdProvider::get_current(&pool).await? { - match make_oidc_client(redirect_url, &provider).await { - Ok((_client_id, client)) => { - let mut authorize_url_builder = client - .authorize_url( - CoreAuthenticationFlow::AuthorizationCode, - || build_state(request.state), - Nonce::new_random, - ) - .add_scope(Scope::new("email".to_string())) - .add_scope(Scope::new("profile".to_string())); - - if SELECT_ACCOUNT_SUPPORTED_PROVIDERS - .iter() - .all(|p| p.eq_ignore_ascii_case(&provider.name)) - { - authorize_url_builder = authorize_url_builder - .add_prompt( - openidconnect::core::CoreAuthPrompt::SelectAccount, - ); + } else { + let redirect_url = match request.auth_flow_type() { + ProtoAuthFlowType::Enrollment => { + let settings = Settings::get_current_settings(); + settings.edge_callback_url(AuthFlowType::Enrollment) + } + ProtoAuthFlowType::Mfa => { + let settings = Settings::get_current_settings(); + settings.edge_callback_url(AuthFlowType::Mfa) + } + // fall back for legacy pre-2.0 clients + ProtoAuthFlowType::Unspecified => + { + #[allow(deprecated)] + Url::parse(&request.redirect_url) + } + }; + + if let Ok(redirect_url) = redirect_url { + if let Some(provider) = + OpenIdProvider::get_current(&pool).await? + { + match make_oidc_client(redirect_url, &provider).await { + Ok((_client_id, client)) => { + let mut authorize_url_builder = client + .authorize_url( + CoreAuthenticationFlow::AuthorizationCode, + || build_state(request.state), + Nonce::new_random, + ) + .add_scope(Scope::new("email".to_string())) + .add_scope(Scope::new("profile".to_string())); + + if SELECT_ACCOUNT_SUPPORTED_PROVIDERS + .iter() + .all(|p| p.eq_ignore_ascii_case(&provider.name)) + { + authorize_url_builder = authorize_url_builder + .add_prompt( + openidconnect::core::CoreAuthPrompt::SelectAccount, + ); + } + let (url, csrf_token, nonce) = + authorize_url_builder.url(); + + Some(core_response::Payload::AuthInfo( + AuthInfoResponse { + url: url.into(), + csrf_token: csrf_token.secret().to_owned(), + nonce: nonce.secret().to_owned(), + button_display_name: provider.display_name, + }, + )) + } + Err(err) => { + error!( + "Failed to setup external OIDC provider client: {err}" + ); + Some(core_response::Payload::CoreError(CoreError { + status_code: Code::Internal as i32, + message: "failed to build OIDC client".into(), + })) } - let (url, csrf_token, nonce) = - authorize_url_builder.url(); - - Some(core_response::Payload::AuthInfo( - AuthInfoResponse { - url: url.into(), - csrf_token: csrf_token.secret().to_owned(), - nonce: nonce.secret().to_owned(), - button_display_name: provider.display_name, - }, - )) - } - Err(err) => { - error!( - "Failed to setup external OIDC provider client: {err}" - ); - Some(core_response::Payload::CoreError(CoreError { - status_code: Code::Internal as i32, - message: "failed to build OIDC client".into(), - })) } + } else { + error!("Failed to get current OpenID provider"); + Some(core_response::Payload::CoreError(CoreError { + status_code: Code::NotFound as i32, + message: "failed to get current OpenID provider".into(), + })) } } else { - error!("Failed to get current OpenID provider"); + error!("Invalid redirect URL in authentication info request"); Some(core_response::Payload::CoreError(CoreError { - status_code: Code::NotFound as i32, - message: "failed to get current OpenID provider".into(), + status_code: Code::Internal as i32, + message: "invalid redirect URL".into(), })) } - } else { - error!( - "Invalid redirect URL in authentication info request: {}", - request.redirect_url - ); - Some(core_response::Payload::CoreError(CoreError { - status_code: Code::Internal as i32, - message: "invalid redirect URL".into(), - })) } } Some(core_request::Payload::AuthCallback(request)) => { - match Url::parse(&request.callback_url) { + match Settings::get_current_settings() + .edge_callback_url(AuthFlowType::Enrollment) + { Ok(callback_url) => { let code = AuthorizationCode::new(request.code); match user_from_claims( @@ -759,8 +780,7 @@ impl ProxyHandler { Err(err) => { error!( "Proxy requested an OpenID authentication info for a callback \ - URL ({}) that couldn't be parsed. Details: {err}", - request.callback_url + URL that couldn't be built. Details: {err}" ); Some(core_response::Payload::CoreError(CoreError { status_code: Code::Internal as i32, diff --git a/proto b/proto index faebcc544..b76289ef9 160000 --- a/proto +++ b/proto @@ -1 +1 @@ -Subproject commit faebcc5449ae803e15cf5faf838c0c508401caf1 +Subproject commit b76289ef976e03cdd6bbf336ebac7cc02c0011cc diff --git a/web/messages/en/edge.json b/web/messages/en/edge.json index 9eb606ef6..39d05a93f 100644 --- a/web/messages/en/edge.json +++ b/web/messages/en/edge.json @@ -1,15 +1,15 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", - "edge_title": "Edge components", - "edge_edit_title": "Edit edge component", + "edge_title": "Edge Components", + "edge_edit_title": "Edit Edge Component", "edge_edit_general_info": "General information", "edge_edit_name": "Name", "edge_edit_address": "IP or Domain", "edge_edit_port": "gRPC port", "edge_edit_public_address": "Public domain", "edge_edit_delete": "Delete", - "edge_edit_success": "Edge component updated", - "edge_edit_failed": "Failed to update edge component", + "edge_edit_success": "Edge Component updated", + "edge_edit_failed": "Failed to update Edge Component", "edges_header_title": "All components", "edges_col_name": "Name", "edges_col_address": "Address", @@ -19,11 +19,12 @@ "edges_col_modified_by": "Modified by", "edges_col_status": "Status", "edges_row_menu_edit": "Edit", - "edges_empty_title": "No edge components added yet.", - "edges_empty_subtitle": "Add edge components by clicking the button below.", + "edges_empty_title": "No Edge Components added yet.", + "edges_empty_subtitle": "Add Edge Components by clicking the button below.", "edges_search_placeholder": "Search", - "edge_delete_success": "Edge component deleted", - "edge_delete_failed": "Failed to delete edge component", + "edge_delete_success": "Edge Component deleted", + "edge_delete_failed": "Failed to delete Edge Component", "edge_connected": "Connected", - "edge_disconnected": "Disconnected" + "edge_disconnected": "Disconnected", + "edge_add": "Add Edge Component" } diff --git a/web/messages/en/settings.json b/web/messages/en/settings.json index ff8c4635a..d7f3e95e7 100644 --- a/web/messages/en/settings.json +++ b/web/messages/en/settings.json @@ -1,5 +1,20 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", + "settings_page_title": "Settings", + "settings_breadcrumb_general": "General", + "settings_breadcrumb_instance": "Instance settings", + "settings_instance_title": "Instance settings", + "settings_instance_subtitle": "Here you can configure general instance parameters.", + "settings_instance_label_name": "Instance name", + "settings_instance_label_public_proxy_url": "Public Edge Component URL", + "settings_instance_label_session_duration": "Session duration", + "settings_instance_session_duration_1": "1 day", + "settings_instance_session_duration_2": "2 days", + "settings_instance_session_duration_3": "3 days", + "settings_instance_session_duration_7": "7 days", + "settings_instance_session_duration_10": "10 days", + "settings_instance_session_duration_14": "14 days", + "settings_instance_session_duration_30": "30 days", "settings_activity_log_streaming_title": "Activity log streaming", "settings_activity_log_streaming_description": "Monitor and export real-time activity logs from your Defguard instance. Stream events to external systems for auditing, analytics, or security monitoring.", "settings_activity_log_streaming_no_upstreams": "You don't have any activity log upstreams.", @@ -9,5 +24,6 @@ "settings_activity_log_streaming_table_title": "All log streams", "settings_activity_log_streaming_table_header_name": "Name", "settings_activity_log_streaming_table_stream_type_name": "Destination", - "settings_msg_saved": "Settings saved" + "settings_msg_saved": "Settings saved", + "settings_msg_save_failed": "Failed to save settings" } diff --git a/web/src/pages/EdgesPage/EdgesTable.tsx b/web/src/pages/EdgesPage/EdgesTable.tsx index 2d9abe550..80b3d67da 100644 --- a/web/src/pages/EdgesPage/EdgesTable.tsx +++ b/web/src/pages/EdgesPage/EdgesTable.tsx @@ -62,7 +62,7 @@ export const EdgesTable = () => { const addButtonProps = useMemo( (): ButtonProps => ({ variant: 'primary', - text: 'Add Edge component', + text: m.edge_add(), iconLeft: 'globe', testId: 'add-edge', onClick: () => { diff --git a/web/src/pages/settings/SettingsInstancePage/SettingsInstancePage.tsx b/web/src/pages/settings/SettingsInstancePage/SettingsInstancePage.tsx index e026c78d7..be047c14b 100644 --- a/web/src/pages/settings/SettingsInstancePage/SettingsInstancePage.tsx +++ b/web/src/pages/settings/SettingsInstancePage/SettingsInstancePage.tsx @@ -12,6 +12,9 @@ import { SettingsCard } from '../../../shared/components/SettingsCard/SettingsCa import { SettingsHeader } from '../../../shared/components/SettingsHeader/SettingsHeader'; import { SettingsLayout } from '../../../shared/components/SettingsLayout/SettingsLayout'; import { Button } from '../../../shared/defguard-ui/components/Button/Button'; +import { SizedBox } from '../../../shared/defguard-ui/components/SizedBox/SizedBox'; +import { Snackbar } from '../../../shared/defguard-ui/providers/snackbar/snackbar'; +import { ThemeSpacing } from '../../../shared/defguard-ui/types'; import { isPresent } from '../../../shared/defguard-ui/utils/isPresent'; import { useAppForm } from '../../../shared/form'; import { formChangeLogic } from '../../../shared/formLogic'; @@ -25,23 +28,23 @@ const breadcrumbs = [ }} key={0} > - General + {m.settings_breadcrumb_general()} , - Instance settings + {m.settings_breadcrumb_instance()} , ]; export const SettingsInstancePage = () => { const { data: settings } = useQuery(getSettingsQueryOptions); return ( - + {isPresent(settings) && ( @@ -65,23 +68,61 @@ const formSchema = z.object({ }), ) .max(64, m.form_error_max_len({ length: 64 })), + public_proxy_url: z + .url(m.initial_setup_general_config_error_public_proxy_url_invalid()) + .min(1, m.initial_setup_general_config_error_public_proxy_url_required()), + authentication_period_days: z.number().min(1, m.form_error_invalid()), }); type FormFields = z.infer; +const sessionDurationOptions = [ + { key: 1, value: 1, label: m.settings_instance_session_duration_1() }, + { key: 2, value: 2, label: m.settings_instance_session_duration_2() }, + { key: 3, value: 3, label: m.settings_instance_session_duration_3() }, + { key: 7, value: 7, label: m.settings_instance_session_duration_7() }, + { + key: 10, + value: 10, + label: m.settings_instance_session_duration_10(), + }, + { + key: 14, + value: 14, + label: m.settings_instance_session_duration_14(), + }, + { + key: 30, + value: 30, + label: m.settings_instance_session_duration_30(), + }, +]; + const Content = ({ settings }: { settings: Settings }) => { const { mutateAsync } = useMutation({ mutationFn: api.settings.patchSettings, meta: { invalidate: ['settings'], }, + onSuccess: () => { + Snackbar.success(m.settings_msg_saved()); + }, + onError: () => { + Snackbar.error(m.settings_msg_save_failed()); + }, }); const defaultValues = useMemo( (): FormFields => ({ instance_name: settings.instance_name ?? '', + public_proxy_url: settings.public_proxy_url ?? '', + authentication_period_days: settings.authentication_period_days ?? 7, }), - [settings.instance_name], + [ + settings.instance_name, + settings.public_proxy_url, + settings.authentication_period_days, + ], ); const form = useAppForm({ @@ -93,6 +134,7 @@ const Content = ({ settings }: { settings: Settings }) => { }, onSubmit: async ({ value }) => { await mutateAsync(value); + form.reset(value); }, }); @@ -106,22 +148,44 @@ const Content = ({ settings }: { settings: Settings }) => { > - {(field) => } + {(field) => ( + + )} + + + + {(field) => ( + + )} + + + + {(field) => ( + + )} ({ isDefault: s.isDefaultValue || s.isPristine, isSubmitting: s.isSubmitting, + canSubmit: s.canSubmit, })} > - {({ isDefault, isSubmitting }) => ( + {({ isDefault, isSubmitting, canSubmit }) => (