From fcfda38934213d78e5f992d064fed10185b58909 Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Tue, 24 Jun 2025 14:09:08 +0200 Subject: [PATCH 1/3] oidc mfa for client --- src/enterprise/handlers/desktop_client_mfa.rs | 141 ++++++++++++++ src/enterprise/handlers/mod.rs | 1 + src/enterprise/handlers/openid_login.rs | 54 +++++- src/error.rs | 6 +- src/http.rs | 9 +- web/src/components/App/App.tsx | 10 + web/src/i18n/en/index.ts | 20 ++ web/src/i18n/i18n-types.ts | 76 ++++++++ web/src/pages/mfa/Icons.tsx | 177 ++++++++++++++++++ web/src/pages/mfa/OpenIDCallback.tsx | 97 ++++++++++ web/src/pages/mfa/OpenIDRedirect.tsx | 54 ++++++ web/src/pages/mfa/style.scss | 32 ++++ .../components/OpenIDCallbackCard.tsx | 1 + web/src/pages/token/components/TokenCard.tsx | 5 +- web/src/shared/hooks/api/types.ts | 12 +- web/src/shared/hooks/api/useApi.tsx | 7 +- web/src/shared/routes.ts | 2 + web/src/shared/scss/_spacing.scss | 7 + web/src/shared/scss/index.scss | 1 + 19 files changed, 700 insertions(+), 12 deletions(-) create mode 100644 src/enterprise/handlers/desktop_client_mfa.rs create mode 100644 web/src/pages/mfa/Icons.tsx create mode 100644 web/src/pages/mfa/OpenIDCallback.tsx create mode 100644 web/src/pages/mfa/OpenIDRedirect.tsx create mode 100644 web/src/pages/mfa/style.scss create mode 100644 web/src/shared/scss/_spacing.scss diff --git a/src/enterprise/handlers/desktop_client_mfa.rs b/src/enterprise/handlers/desktop_client_mfa.rs new file mode 100644 index 00000000..db2990f4 --- /dev/null +++ b/src/enterprise/handlers/desktop_client_mfa.rs @@ -0,0 +1,141 @@ +use axum::{ + extract::State, + routing::{get, post}, + Json, Router, +}; +use axum_extra::extract::{ + cookie::{Cookie, SameSite}, + PrivateCookieJar, +}; +use serde::{Deserialize, Serialize}; +use time::Duration; +use tracing::{debug, error, info, warn}; + +use crate::{ + enterprise::handlers::openid_login::FlowType, + error::ApiError, + handlers::get_core_response, + http::AppState, + proto::{ + core_request, core_response, AuthCallbackRequest, AuthCallbackResponse, AuthInfoRequest, + ClientMfaOidcAuthenticateRequest, ClientMfaStartRequest, ClientMfaStartResponse, + DeviceInfo, + }, +}; + +static CSRF_COOKIE_NAME: &str = "csrf_proxy"; +static NONCE_COOKIE_NAME: &str = "nonce_proxy"; + +#[derive(Serialize)] +pub struct AuthInfo { + url: String, +} + +impl AuthInfo { + #[must_use] + fn new(url: String) -> Self { + Self { url } + } +} + +#[derive(Debug, Deserialize)] +pub struct AuthenticationResponse { + code: String, + state: String, + #[serde(rename = "type")] + flow_type: String, +} + +#[derive(Serialize)] +struct CallbackResponseData { + url: String, + token: String, +} + +#[instrument(level = "debug", skip(state))] +pub(crate) async fn mfa_auth_callback( + State(state): State, + device_info: DeviceInfo, + mut private_cookies: PrivateCookieJar, + Json(payload): Json, +) -> Result { + info!("Processing MFA authentication callback"); + debug!( + "Received payload: state={}, flow_type={}", + payload.state, payload.flow_type + ); + + let flow_type = payload.flow_type.parse::().map_err(|err| { + warn!("Failed to parse flow type '{}': {err:?}", payload.flow_type); + ApiError::BadRequest("Invalid flow type".into()) + })?; + + if flow_type != FlowType::Mfa { + warn!("Invalid flow type for MFA callback: {flow_type:?}"); + return Err(ApiError::BadRequest( + "Invalid flow type for MFA callback".into(), + )); + } + + debug!("Flow type validation passed: {flow_type:?}"); + + let nonce = private_cookies + .get(NONCE_COOKIE_NAME) + .ok_or_else(|| { + warn!("Nonce cookie not found in request"); + ApiError::Unauthorized("Nonce cookie not found".into()) + })? + .value_trimmed() + .to_string(); + + let csrf = private_cookies + .get(CSRF_COOKIE_NAME) + .ok_or_else(|| { + warn!("CSRF cookie not found in request"); + ApiError::Unauthorized("CSRF cookie not found".into()) + })? + .value_trimmed() + .to_string(); + + debug!("Retrieved cookies successfully"); + + if payload.state != csrf { + warn!( + "CSRF token mismatch: expected={csrf}, received={}", + payload.state + ); + return Err(ApiError::Unauthorized("CSRF token mismatch".into())); + } + + debug!("CSRF token validation passed"); + + private_cookies = private_cookies + .remove(Cookie::from(NONCE_COOKIE_NAME)) + .remove(Cookie::from(CSRF_COOKIE_NAME)); + + debug!("Removed security cookies"); + + let request = ClientMfaOidcAuthenticateRequest { + code: payload.code, + nonce, + callback_url: state.callback_url(flow_type).to_string(), + state: payload.state, + }; + + debug!("Sending MFA OIDC authenticate request to core service"); + + let rx = state.grpc_server.send( + core_request::Payload::ClientMfaOidcAuthenticate(request), + device_info, + )?; + + let payload = get_core_response(rx).await?; + + if let core_response::Payload::Empty(()) = payload { + info!("MFA authentication callback completed successfully"); + Ok(private_cookies) + } else { + error!("Received invalid gRPC response type during handling the MFA OpenID authentication callback: {payload:#?}"); + Err(ApiError::InvalidResponseType) + } +} diff --git a/src/enterprise/handlers/mod.rs b/src/enterprise/handlers/mod.rs index 3e4fa9f9..5aeb92c2 100644 --- a/src/enterprise/handlers/mod.rs +++ b/src/enterprise/handlers/mod.rs @@ -1 +1,2 @@ +pub mod desktop_client_mfa; pub mod openid_login; diff --git a/src/enterprise/handlers/openid_login.rs b/src/enterprise/handlers/openid_login.rs index 476e923a..c65326b0 100644 --- a/src/enterprise/handlers/openid_login.rs +++ b/src/enterprise/handlers/openid_login.rs @@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize}; use time::Duration; use crate::{ + enterprise::handlers::desktop_client_mfa::mfa_auth_callback, error::ApiError, handlers::get_core_response, http::AppState, @@ -26,8 +27,9 @@ static NONCE_COOKIE_NAME: &str = "nonce_proxy"; pub(crate) fn router() -> Router { Router::new() - .route("/auth_info", get(auth_info)) + .route("/auth_info", post(auth_info)) .route("/callback", post(auth_callback)) + .route("/callback/mfa", post(mfa_auth_callback)) } #[derive(Serialize)] @@ -46,17 +48,49 @@ impl AuthInfo { } } +#[derive(Deserialize, Debug, PartialEq, Eq)] +pub(crate) enum FlowType { + Enrollment, + Mfa, +} + +impl std::str::FromStr for FlowType { + type Err = (); + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "enrollment" => Ok(FlowType::Enrollment), + "mfa" => Ok(FlowType::Mfa), + _ => Err(()), + } + } +} + +#[derive(Deserialize, Debug)] +struct RequestData { + state: Option, + #[serde(rename = "type")] + flow_type: String, +} + /// Request external OAuth2/OpenID provider details from Defguard Core. #[instrument(level = "debug", skip(state))] async fn auth_info( State(state): State, device_info: DeviceInfo, private_cookies: PrivateCookieJar, + Json(request_data): Json, ) -> Result<(PrivateCookieJar, Json), ApiError> { debug!("Getting auth info for OAuth2/OpenID login"); + let flow_type = request_data + .flow_type + .parse::() + .map_err(|_| ApiError::BadRequest("Invalid flow type".into()))?; + let request = AuthInfoRequest { - redirect_url: state.callback_url().to_string(), + redirect_url: state.callback_url(flow_type).to_string(), + state: request_data.state, }; let rx = state @@ -96,6 +130,8 @@ async fn auth_info( pub struct AuthenticationResponse { code: String, state: String, + #[serde(rename = "type")] + flow_type: String, } #[derive(Serialize)] @@ -111,6 +147,17 @@ async fn auth_callback( mut private_cookies: PrivateCookieJar, Json(payload): Json, ) -> Result<(PrivateCookieJar, Json), ApiError> { + let flow_type = payload + .flow_type + .parse::() + .map_err(|_| ApiError::BadRequest("Invalid flow type".into()))?; + + if flow_type != FlowType::Enrollment { + return Err(ApiError::BadRequest( + "Invalid flow type for OpenID enrollment callback".into(), + )); + } + let nonce = private_cookies .get(NONCE_COOKIE_NAME) .ok_or(ApiError::Unauthorized("Nonce cookie not found".into()))? @@ -133,13 +180,14 @@ async fn auth_callback( let request = AuthCallbackRequest { code: payload.code, nonce, - callback_url: state.callback_url().to_string(), + callback_url: state.callback_url(flow_type).to_string(), }; let rx = state .grpc_server .send(core_request::Payload::AuthCallback(request), device_info)?; let payload = get_core_response(rx).await?; + if let core_response::Payload::AuthCallback(AuthCallbackResponse { url, token }) = payload { debug!("Received auth callback response {url:?} {token:?}"); Ok((private_cookies, Json(CallbackResponseData { url, token }))) diff --git a/src/error.rs b/src/error.rs index fec4b2c7..293f50eb 100644 --- a/src/error.rs +++ b/src/error.rs @@ -26,6 +26,8 @@ pub enum ApiError { PermissionDenied(String), #[error("Enterprise not enabled")] EnterpriseNotEnabled, + #[error("Precondition required: {0}")] + PreconditionRequired(String), } impl IntoResponse for ApiError { @@ -39,6 +41,7 @@ impl IntoResponse for ApiError { StatusCode::PAYMENT_REQUIRED, "Enterprise features are not enabled".to_string(), ), + Self::PreconditionRequired(msg) => (StatusCode::PRECONDITION_REQUIRED, msg), _ => ( StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".to_string(), @@ -64,8 +67,9 @@ impl From for ApiError { Code::FailedPrecondition => match status.message().to_lowercase().as_str() { // TODO: find a better way than matching on the error message "no valid license" => ApiError::EnterpriseNotEnabled, - _ => ApiError::Unexpected(status.to_string()), + _ => ApiError::PreconditionRequired(status.message().to_string()), }, + Code::Unavailable => ApiError::CoreTimeout, _ => ApiError::Unexpected(status.to_string()), } } diff --git a/src/http.rs b/src/http.rs index e2a0e867..6c3c8da4 100644 --- a/src/http.rs +++ b/src/http.rs @@ -28,7 +28,7 @@ use url::Url; use crate::{ assets::{index, svg, web_asset}, config::Config, - enterprise::handlers::openid_login, + enterprise::handlers::openid_login::{self, FlowType}, error::ApiError, grpc::ProxyServer, handlers::{desktop_client_mfa, enrollment, password_reset, polling}, @@ -49,11 +49,14 @@ pub(crate) struct AppState { impl AppState { /// Returns configured URL with "auth/callback" appended to the path. #[must_use] - pub(crate) fn callback_url(&self) -> Url { + pub(crate) fn callback_url(&self, flow_type: FlowType) -> Url { let mut url = self.url.clone(); // Append "/openid/callback" to the URL. if let Ok(mut path_segments) = url.path_segments_mut() { - path_segments.extend(&["openid", "callback"]); + match flow_type { + FlowType::Enrollment => path_segments.extend(&["openid", "callback"]), + FlowType::Mfa => path_segments.extend(&["openid", "mfa", "callback"]), + }; } url } diff --git a/web/src/components/App/App.tsx b/web/src/components/App/App.tsx index e3a7166d..cea1e49c 100644 --- a/web/src/components/App/App.tsx +++ b/web/src/components/App/App.tsx @@ -23,6 +23,8 @@ import { PasswordResetPage } from '../../pages/passwordReset/PasswordResetPage'; import { SessionTimeoutPage } from '../../pages/sessionTimeout/SessionTimeoutPage'; import { TokenPage } from '../../pages/token/TokenPage'; import { routes } from '../../shared/routes'; +import { OpenIdMfaPage } from '../../pages/mfa/OpenIDRedirect'; +import { OpenIdMfaCallbackPage } from '../../pages/mfa/OpenIDCallback'; dayjs.extend(duration); dayjs.extend(utc); @@ -57,6 +59,14 @@ const router = createBrowserRouter([ path: routes.openidCallback, element: , }, + { + path: routes.openidMfa, + element: , + }, + { + path: routes.openidMfaCallback, + element: , + }, { path: '/*', element: , diff --git a/web/src/i18n/en/index.ts b/web/src/i18n/en/index.ts index bee21c25..5131fa0b 100644 --- a/web/src/i18n/en/index.ts +++ b/web/src/i18n/en/index.ts @@ -321,6 +321,26 @@ If you want to disengage your VPN connection, simply press "deactivate". }, }, }, + openidMfaCallback: { + error: { + title: 'Authentication Error', + message: + 'There was an error during authentication with the provider. Please go back to the **Defguard VPN Client** and repeat the process.', + detailsTitle: 'Error Details', + }, + success: { + title: 'Authentication Completed', + message: + 'You have been successfully authenticated. Please close this window and get back to the **Defguard VPN Client**.', + }, + }, + openidMfaRedirect: { + error: { + title: 'Authentication Error', + message: + 'No token provided in the URL. Please ensure you have a valid token to proceed with OpenID authentication.', + }, + }, }, } satisfies BaseTranslation; diff --git a/web/src/i18n/i18n-types.ts b/web/src/i18n/i18n-types.ts index f5b155a9..fba01d27 100644 --- a/web/src/i18n/i18n-types.ts +++ b/web/src/i18n/i18n-types.ts @@ -635,6 +635,44 @@ type RootTranslation = { } } } + openidMfaCallback: { + error: { + /** + * A​u​t​h​e​n​t​i​c​a​t​i​o​n​ ​E​r​r​o​r + */ + title: string + /** + * T​h​e​r​e​ ​w​a​s​ ​a​n​ ​e​r​r​o​r​ ​d​u​r​i​n​g​ ​a​u​t​h​e​n​t​i​c​a​t​i​o​n​ ​w​i​t​h​ ​t​h​e​ ​p​r​o​v​i​d​e​r​.​ ​P​l​e​a​s​e​ ​g​o​ ​b​a​c​k​ ​t​o​ ​t​h​e​ ​*​*​D​e​f​g​u​a​r​d​ ​V​P​N​ ​C​l​i​e​n​t​*​*​ ​a​n​d​ ​r​e​p​e​a​t​ ​t​h​e​ ​p​r​o​c​e​s​s​. + */ + message: string + /** + * E​r​r​o​r​ ​D​e​t​a​i​l​s + */ + detailsTitle: string + } + success: { + /** + * A​u​t​h​e​n​t​i​c​a​t​i​o​n​ ​C​o​m​p​l​e​t​e​d + */ + title: string + /** + * Y​o​u​ ​h​a​v​e​ ​b​e​e​n​ ​s​u​c​c​e​s​s​f​u​l​l​y​ ​a​u​t​h​e​n​t​i​c​a​t​e​d​.​ ​P​l​e​a​s​e​ ​c​l​o​s​e​ ​t​h​i​s​ ​w​i​n​d​o​w​ ​a​n​d​ ​g​e​t​ ​b​a​c​k​ ​t​o​ ​t​h​e​ ​*​*​D​e​f​g​u​a​r​d​ ​V​P​N​ ​C​l​i​e​n​t​*​*​. + */ + message: string + } + } + openidMfaRedirect: { + error: { + /** + * A​u​t​h​e​n​t​i​c​a​t​i​o​n​ ​E​r​r​o​r + */ + title: string + /** + * N​o​ ​t​o​k​e​n​ ​p​r​o​v​i​d​e​d​ ​i​n​ ​t​h​e​ ​U​R​L​.​ ​P​l​e​a​s​e​ ​e​n​s​u​r​e​ ​y​o​u​ ​h​a​v​e​ ​a​ ​v​a​l​i​d​ ​t​o​k​e​n​ ​t​o​ ​p​r​o​c​e​e​d​ ​w​i​t​h​ ​O​p​e​n​I​D​ ​a​u​t​h​e​n​t​i​c​a​t​i​o​n​. + */ + message: string + } + } } } @@ -1256,6 +1294,44 @@ export type TranslationFunctions = { } } } + openidMfaCallback: { + error: { + /** + * Authentication Error + */ + title: () => LocalizedString + /** + * There was an error during authentication with the provider. Please go back to the **Defguard VPN Client** and repeat the process. + */ + message: () => LocalizedString + /** + * Error Details + */ + detailsTitle: () => LocalizedString + } + success: { + /** + * Authentication Completed + */ + title: () => LocalizedString + /** + * You have been successfully authenticated. Please close this window and get back to the **Defguard VPN Client**. + */ + message: () => LocalizedString + } + } + openidMfaRedirect: { + error: { + /** + * Authentication Error + */ + title: () => LocalizedString + /** + * No token provided in the URL. Please ensure you have a valid token to proceed with OpenID authentication. + */ + message: () => LocalizedString + } + } } } diff --git a/web/src/pages/mfa/Icons.tsx b/web/src/pages/mfa/Icons.tsx new file mode 100644 index 00000000..db2eeabc --- /dev/null +++ b/web/src/pages/mfa/Icons.tsx @@ -0,0 +1,177 @@ +export const AuthFailIcon = () => ( + + + + + + + + + + + + + + + + +); + +export const ClientReturnIcon = () => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + +); diff --git a/web/src/pages/mfa/OpenIDCallback.tsx b/web/src/pages/mfa/OpenIDCallback.tsx new file mode 100644 index 00000000..6d5c6d48 --- /dev/null +++ b/web/src/pages/mfa/OpenIDCallback.tsx @@ -0,0 +1,97 @@ +import './style.scss'; + +import { LogoContainer } from '../../components/LogoContainer/LogoContainer'; +import { PageContainer } from '../../shared/components/layout/PageContainer/PageContainer'; +import { useApi } from '../../shared/hooks/api/useApi'; +import { useQuery } from '@tanstack/react-query'; + +import { useState } from 'react'; +import { useI18nContext } from '../../i18n/i18n-react'; +import { AxiosError } from 'axios'; +import { LoaderSpinner } from '../../shared/components/layout/LoaderSpinner/LoaderSpinner'; +import { AuthFailIcon, ClientReturnIcon } from './icons'; +import ReactMarkdown from 'react-markdown'; +import rehypeSanitize from 'rehype-sanitize'; + +type ErrorResponse = { + error: string; +}; + +export const OpenIdMfaCallbackPage = () => { + const { openIDMFACallback } = useApi(); + const { LL } = useI18nContext(); + const [error, setError] = useState(null); + + const { isLoading } = useQuery( + [], + () => { + const params = new URLSearchParams(window.location.search); + const error = params.get('error'); + if (error) { + setError(error); + return; + } + const code = params.get('code'); + const state = params.get('state'); + if (!code || !state) { + setError( + "Missing code or state in the callback's URL. \ + The provider might not be configured correctly.", + ); + return; + } + if (code && state) { + return openIDMFACallback({ + code, + state, + type: 'mfa', + }); + } + }, + { + retry: false, + refetchOnWindowFocus: false, + refetchOnMount: false, + onError: (error: AxiosError) => { + console.error(error); + const errorResponse = error.response?.data as ErrorResponse; + if (errorResponse.error) { + setError(errorResponse.error); + } else { + setError(String(error)); + } + }, + }, + ); + + return ( + + +
+ {isLoading ? ( + + ) : error ? ( + <> +

{LL.pages.openidMfaCallback.error.title()}

+ + + {LL.pages.openidMfaCallback.error.message()} + +
+

{LL.pages.openidMfaCallback.error.detailsTitle()}

+
{error}
+
+ + ) : ( + <> +

{LL.pages.openidMfaCallback.success.title()}

+ + + {LL.pages.openidMfaCallback.success.message()} + + + )} +
+
+ ); +}; diff --git a/web/src/pages/mfa/OpenIDRedirect.tsx b/web/src/pages/mfa/OpenIDRedirect.tsx new file mode 100644 index 00000000..beeaa2f8 --- /dev/null +++ b/web/src/pages/mfa/OpenIDRedirect.tsx @@ -0,0 +1,54 @@ +import './style.scss'; + +import { LogoContainer } from '../../components/LogoContainer/LogoContainer'; +import { LoaderSpinner } from '../../shared/components/layout/LoaderSpinner/LoaderSpinner'; +import { PageContainer } from '../../shared/components/layout/PageContainer/PageContainer'; +import { useApi } from '../../shared/hooks/api/useApi'; +import { useQuery } from '@tanstack/react-query'; + +import { useEffect } from 'react'; +import { AuthFailIcon } from './icons'; +import { useI18nContext } from '../../i18n/i18n-react'; + +export const OpenIdMfaPage = () => { + const { getOpenIDAuthInfo } = useApi(); + const { LL } = useI18nContext(); + const urlParams = new URLSearchParams(window.location.search); + const token = urlParams.get('token'); + + const { isLoading: openidLoading, data: openidData } = useQuery( + [], + () => + getOpenIDAuthInfo({ + state: token || undefined, + type: 'mfa', + }), + { + refetchOnMount: true, + refetchOnWindowFocus: false, + enabled: !!token, + }, + ); + + useEffect(() => { + if (!openidLoading && openidData?.url) { + window.location.href = openidData.url; + } + }, [openidLoading, openidData?.url]); + + return ( + + +
+ {!token && ( + <> +

{LL.pages.openidMfaRedirect.error.title()}

+ +

{LL.pages.openidMfaRedirect.error.message()}

+ + )} + {token && } +
+
+ ); +}; diff --git a/web/src/pages/mfa/style.scss b/web/src/pages/mfa/style.scss new file mode 100644 index 00000000..55e8fa7e --- /dev/null +++ b/web/src/pages/mfa/style.scss @@ -0,0 +1,32 @@ +@use '@scssutils' as *; + +#openid-mfa-page { + gap: var(--spacing-xs); + + .content { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 40px; + width: 440px; + text-align: center; + min-height: 340px; + + h1 { + @include typography(app-welcome-1); + } + + h2 { + @include typography(app-welcome-2); + } + + p { + @include typography(welcome-h2); + } + + .error-details { + @include typography(app-code); + } + } +} diff --git a/web/src/pages/openidCallback/components/OpenIDCallbackCard.tsx b/web/src/pages/openidCallback/components/OpenIDCallbackCard.tsx index 6f894b7b..ad77e0b6 100644 --- a/web/src/pages/openidCallback/components/OpenIDCallbackCard.tsx +++ b/web/src/pages/openidCallback/components/OpenIDCallbackCard.tsx @@ -53,6 +53,7 @@ export const OpenIDCallbackCard = () => { return openIDCallback({ code, state, + type: 'enrollment', }); } }, diff --git a/web/src/pages/token/components/TokenCard.tsx b/web/src/pages/token/components/TokenCard.tsx index f49825cc..2787bfc8 100644 --- a/web/src/pages/token/components/TokenCard.tsx +++ b/web/src/pages/token/components/TokenCard.tsx @@ -66,7 +66,10 @@ export const TokenCard = () => { const { isLoading: openidLoading, data: openidData } = useQuery( [], - () => getOpenIDAuthInfo(), + () => + getOpenIDAuthInfo({ + type: 'enrollment', + }), { refetchOnMount: true, refetchOnWindowFocus: false, diff --git a/web/src/shared/hooks/api/types.ts b/web/src/shared/hooks/api/types.ts index cb84692b..7cd0657d 100644 --- a/web/src/shared/hooks/api/types.ts +++ b/web/src/shared/hooks/api/types.ts @@ -95,9 +95,17 @@ export type UseApi = { reset: (data: PasswordResetRequest) => Promise; }; getAppInfo: () => Promise; - getOpenIDAuthInfo: () => Promise<{ url: string; button_display_name: string }>; - openIDCallback: (data: { code: string; state: string }) => Promise<{ + getOpenIDAuthInfo: (data: { + state?: string; + type: 'enrollment' | 'mfa'; + }) => Promise<{ url: string; button_display_name: string }>; + openIDCallback: (data: { code: string; state: string; type: 'enrollment' }) => Promise<{ token: string; url: string; }>; + openIDMFACallback: (data: { + code: string; + state: string; + type: 'mfa'; + }) => Promise; }; diff --git a/web/src/shared/hooks/api/useApi.tsx b/web/src/shared/hooks/api/useApi.tsx index e16dfb63..a37c5e67 100644 --- a/web/src/shared/hooks/api/useApi.tsx +++ b/web/src/shared/hooks/api/useApi.tsx @@ -33,9 +33,9 @@ export const useApi = (): UseApi => { const resetPassword: UseApi['passwordReset']['reset'] = (data) => client.post('/password-reset/reset', data).then(unpackRequest); - const getOpenIDAuthInfo: UseApi['getOpenIDAuthInfo'] = () => + const getOpenIDAuthInfo: UseApi['getOpenIDAuthInfo'] = (data) => client - .get('/openid/auth_info') + .post('/openid/auth_info', data) .then((res) => res.data) .catch(() => { return { @@ -45,6 +45,8 @@ export const useApi = (): UseApi => { const openIDCallback: UseApi['openIDCallback'] = (data) => client.post('/openid/callback', data).then(unpackRequest); + const openIDMFACallback: UseApi['openIDMFACallback'] = (data) => + client.post('/openid/callback/mfa', data).then(unpackRequest); return { enrollment: { @@ -60,5 +62,6 @@ export const useApi = (): UseApi => { getAppInfo, getOpenIDAuthInfo, openIDCallback, + openIDMFACallback, }; }; diff --git a/web/src/shared/routes.ts b/web/src/shared/routes.ts index 97c5e53e..9290b56e 100644 --- a/web/src/shared/routes.ts +++ b/web/src/shared/routes.ts @@ -5,4 +5,6 @@ export const routes = { timeout: '/timeout', passwordReset: '/password-reset', openidCallback: '/openid/callback', + openidMfa: '/openid/mfa', + openidMfaCallback: '/openid/mfa/callback', }; diff --git a/web/src/shared/scss/_spacing.scss b/web/src/shared/scss/_spacing.scss new file mode 100644 index 00000000..a0fea764 --- /dev/null +++ b/web/src/shared/scss/_spacing.scss @@ -0,0 +1,7 @@ +:root { + --spacing-xl: 70px; + --spacing-l: 50px; + --spacing-m: 30px; + --spacing-s: 20px; + --spacing-xs: 10px; +} diff --git a/web/src/shared/scss/index.scss b/web/src/shared/scss/index.scss index aa3d37a8..0423d672 100644 --- a/web/src/shared/scss/index.scss +++ b/web/src/shared/scss/index.scss @@ -3,3 +3,4 @@ @forward './effects'; @forward './tokens'; @forward './fonts'; +@forward './spacing'; From c4122eea20e3e915bfd40a68665ded498b8929b9 Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Tue, 24 Jun 2025 14:13:54 +0200 Subject: [PATCH 2/3] formatting --- src/enterprise/handlers/desktop_client_mfa.rs | 54 +++---------------- src/enterprise/handlers/openid_login.rs | 18 +++---- web/src/components/App/App.tsx | 4 +- web/src/pages/mfa/Icons.tsx | 48 ++++++++--------- web/src/pages/mfa/OpenIDCallback.tsx | 16 +++--- web/src/pages/mfa/OpenIDRedirect.tsx | 10 ++-- 6 files changed, 53 insertions(+), 97 deletions(-) diff --git a/src/enterprise/handlers/desktop_client_mfa.rs b/src/enterprise/handlers/desktop_client_mfa.rs index db2990f4..155f837e 100644 --- a/src/enterprise/handlers/desktop_client_mfa.rs +++ b/src/enterprise/handlers/desktop_client_mfa.rs @@ -1,59 +1,19 @@ -use axum::{ - extract::State, - routing::{get, post}, - Json, Router, -}; -use axum_extra::extract::{ - cookie::{Cookie, SameSite}, - PrivateCookieJar, -}; -use serde::{Deserialize, Serialize}; -use time::Duration; +use axum::{extract::State, Json}; +use axum_extra::extract::{cookie::Cookie, PrivateCookieJar}; use tracing::{debug, error, info, warn}; use crate::{ - enterprise::handlers::openid_login::FlowType, + enterprise::handlers::openid_login::{ + AuthenticationResponse, FlowType, CSRF_COOKIE_NAME, NONCE_COOKIE_NAME, + }, error::ApiError, handlers::get_core_response, http::AppState, - proto::{ - core_request, core_response, AuthCallbackRequest, AuthCallbackResponse, AuthInfoRequest, - ClientMfaOidcAuthenticateRequest, ClientMfaStartRequest, ClientMfaStartResponse, - DeviceInfo, - }, + proto::{core_request, core_response, ClientMfaOidcAuthenticateRequest, DeviceInfo}, }; -static CSRF_COOKIE_NAME: &str = "csrf_proxy"; -static NONCE_COOKIE_NAME: &str = "nonce_proxy"; - -#[derive(Serialize)] -pub struct AuthInfo { - url: String, -} - -impl AuthInfo { - #[must_use] - fn new(url: String) -> Self { - Self { url } - } -} - -#[derive(Debug, Deserialize)] -pub struct AuthenticationResponse { - code: String, - state: String, - #[serde(rename = "type")] - flow_type: String, -} - -#[derive(Serialize)] -struct CallbackResponseData { - url: String, - token: String, -} - #[instrument(level = "debug", skip(state))] -pub(crate) async fn mfa_auth_callback( +pub(super) async fn mfa_auth_callback( State(state): State, device_info: DeviceInfo, mut private_cookies: PrivateCookieJar, diff --git a/src/enterprise/handlers/openid_login.rs b/src/enterprise/handlers/openid_login.rs index c65326b0..7ca93c4f 100644 --- a/src/enterprise/handlers/openid_login.rs +++ b/src/enterprise/handlers/openid_login.rs @@ -1,8 +1,4 @@ -use axum::{ - extract::State, - routing::{get, post}, - Json, Router, -}; +use axum::{extract::State, routing::post, Json, Router}; use axum_extra::extract::{ cookie::{Cookie, SameSite}, PrivateCookieJar, @@ -22,8 +18,8 @@ use crate::{ }; const COOKIE_MAX_AGE: Duration = Duration::days(1); -static CSRF_COOKIE_NAME: &str = "csrf_proxy"; -static NONCE_COOKIE_NAME: &str = "nonce_proxy"; +pub(super) static CSRF_COOKIE_NAME: &str = "csrf_proxy"; +pub(super) static NONCE_COOKIE_NAME: &str = "nonce_proxy"; pub(crate) fn router() -> Router { Router::new() @@ -127,11 +123,11 @@ async fn auth_info( } #[derive(Debug, Deserialize)] -pub struct AuthenticationResponse { - code: String, - state: String, +pub(super) struct AuthenticationResponse { + pub(super) code: String, + pub(super) state: String, #[serde(rename = "type")] - flow_type: String, + pub(super) flow_type: String, } #[derive(Serialize)] diff --git a/web/src/components/App/App.tsx b/web/src/components/App/App.tsx index cea1e49c..97965de8 100644 --- a/web/src/components/App/App.tsx +++ b/web/src/components/App/App.tsx @@ -18,13 +18,13 @@ import { detectLocale } from '../../i18n/i18n-util'; import { loadLocaleAsync } from '../../i18n/i18n-util.async'; import { EnrollmentPage } from '../../pages/enrollment/EnrollmentPage'; import { MainPage } from '../../pages/main/MainPage'; +import { OpenIdMfaCallbackPage } from '../../pages/mfa/OpenIDCallback'; +import { OpenIdMfaPage } from '../../pages/mfa/OpenIDRedirect'; import { OpenIDCallbackPage } from '../../pages/openidCallback/OpenIDCallback'; import { PasswordResetPage } from '../../pages/passwordReset/PasswordResetPage'; import { SessionTimeoutPage } from '../../pages/sessionTimeout/SessionTimeoutPage'; import { TokenPage } from '../../pages/token/TokenPage'; import { routes } from '../../shared/routes'; -import { OpenIdMfaPage } from '../../pages/mfa/OpenIDRedirect'; -import { OpenIdMfaCallbackPage } from '../../pages/mfa/OpenIDCallback'; dayjs.extend(duration); dayjs.extend(utc); diff --git a/web/src/pages/mfa/Icons.tsx b/web/src/pages/mfa/Icons.tsx index db2eeabc..2445e7b3 100644 --- a/web/src/pages/mfa/Icons.tsx +++ b/web/src/pages/mfa/Icons.tsx @@ -13,13 +13,13 @@ export const AuthFailIcon = () => ( height="76.2553" rx="5.44681" stroke="#CBD3D8" - stroke-width="2.7234" + strokeWidth="2.7234" /> @@ -39,13 +39,13 @@ export const AuthFailIcon = () => ( height="76.2553" rx="5.44681" stroke="#899CA8" - stroke-width="2.7234" + strokeWidth="2.7234" /> @@ -53,8 +53,8 @@ export const AuthFailIcon = () => ( ( height="76.2553" rx="5.44681" stroke="#CBD3D8" - stroke-width="2.7234" + strokeWidth="2.7234" /> @@ -104,8 +104,8 @@ export const ClientReturnIcon = () => ( ( ( height="76.2553" rx="5.44681" stroke="#899CA8" - stroke-width="2.7234" + strokeWidth="2.7234" /> - + ( d="M101.691 61.2065L113.717 101.727L121.009 93.2644L124.988 98.6724C126.289 100.442 128.095 101.776 130.168 102.501L133.946 103.821L137.634 98.2282L135.47 97.5496C133.057 96.7928 130.983 95.22 129.602 93.101L126.826 88.8387L135.183 80.9599L101.691 61.2065Z" fill="white" stroke="#0C8CE0" - stroke-width="2.7234" - stroke-linejoin="round" + strokeWidth="2.7234" + strokeLinejoin="round" /> diff --git a/web/src/pages/mfa/OpenIDCallback.tsx b/web/src/pages/mfa/OpenIDCallback.tsx index 6d5c6d48..606a6efb 100644 --- a/web/src/pages/mfa/OpenIDCallback.tsx +++ b/web/src/pages/mfa/OpenIDCallback.tsx @@ -1,18 +1,18 @@ import './style.scss'; -import { LogoContainer } from '../../components/LogoContainer/LogoContainer'; -import { PageContainer } from '../../shared/components/layout/PageContainer/PageContainer'; -import { useApi } from '../../shared/hooks/api/useApi'; import { useQuery } from '@tanstack/react-query'; - -import { useState } from 'react'; -import { useI18nContext } from '../../i18n/i18n-react'; import { AxiosError } from 'axios'; -import { LoaderSpinner } from '../../shared/components/layout/LoaderSpinner/LoaderSpinner'; -import { AuthFailIcon, ClientReturnIcon } from './icons'; +import { useState } from 'react'; import ReactMarkdown from 'react-markdown'; import rehypeSanitize from 'rehype-sanitize'; +import { LogoContainer } from '../../components/LogoContainer/LogoContainer'; +import { useI18nContext } from '../../i18n/i18n-react'; +import { LoaderSpinner } from '../../shared/components/layout/LoaderSpinner/LoaderSpinner'; +import { PageContainer } from '../../shared/components/layout/PageContainer/PageContainer'; +import { useApi } from '../../shared/hooks/api/useApi'; +import { AuthFailIcon, ClientReturnIcon } from './Icons'; + type ErrorResponse = { error: string; }; diff --git a/web/src/pages/mfa/OpenIDRedirect.tsx b/web/src/pages/mfa/OpenIDRedirect.tsx index beeaa2f8..a08fd9cd 100644 --- a/web/src/pages/mfa/OpenIDRedirect.tsx +++ b/web/src/pages/mfa/OpenIDRedirect.tsx @@ -1,14 +1,14 @@ import './style.scss'; +import { useQuery } from '@tanstack/react-query'; +import { useEffect } from 'react'; + import { LogoContainer } from '../../components/LogoContainer/LogoContainer'; +import { useI18nContext } from '../../i18n/i18n-react'; import { LoaderSpinner } from '../../shared/components/layout/LoaderSpinner/LoaderSpinner'; import { PageContainer } from '../../shared/components/layout/PageContainer/PageContainer'; import { useApi } from '../../shared/hooks/api/useApi'; -import { useQuery } from '@tanstack/react-query'; - -import { useEffect } from 'react'; -import { AuthFailIcon } from './icons'; -import { useI18nContext } from '../../i18n/i18n-react'; +import { AuthFailIcon } from './Icons'; export const OpenIdMfaPage = () => { const { getOpenIDAuthInfo } = useApi(); From 7efa4cac276e6d80d69c22b4524131e5d5fd3b23 Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Wed, 25 Jun 2025 14:17:34 +0200 Subject: [PATCH 3/3] update protobufs --- proto | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proto b/proto index 20fe30df..eb4ac062 160000 --- a/proto +++ b/proto @@ -1 +1 @@ -Subproject commit 20fe30dfa1c2985bb7a6afe1c74dd9a709e034c6 +Subproject commit eb4ac0620f54bfa58669f2ac61ea5fce5c55b521