diff --git a/proto b/proto index 20fe30df..eb4ac062 160000 --- a/proto +++ b/proto @@ -1 +1 @@ -Subproject commit 20fe30dfa1c2985bb7a6afe1c74dd9a709e034c6 +Subproject commit eb4ac0620f54bfa58669f2ac61ea5fce5c55b521 diff --git a/src/enterprise/handlers/desktop_client_mfa.rs b/src/enterprise/handlers/desktop_client_mfa.rs new file mode 100644 index 00000000..155f837e --- /dev/null +++ b/src/enterprise/handlers/desktop_client_mfa.rs @@ -0,0 +1,101 @@ +use axum::{extract::State, Json}; +use axum_extra::extract::{cookie::Cookie, PrivateCookieJar}; +use tracing::{debug, error, info, warn}; + +use crate::{ + 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, ClientMfaOidcAuthenticateRequest, DeviceInfo}, +}; + +#[instrument(level = "debug", skip(state))] +pub(super) 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..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, @@ -11,6 +7,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, @@ -21,13 +18,14 @@ 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() - .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 +44,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 @@ -93,9 +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")] + pub(super) flow_type: String, } #[derive(Serialize)] @@ -111,6 +143,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 +176,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..97965de8 100644 --- a/web/src/components/App/App.tsx +++ b/web/src/components/App/App.tsx @@ -18,6 +18,8 @@ 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'; @@ -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..2445e7b3 --- /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..606a6efb --- /dev/null +++ b/web/src/pages/mfa/OpenIDCallback.tsx @@ -0,0 +1,97 @@ +import './style.scss'; + +import { useQuery } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; +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; +}; + +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..a08fd9cd --- /dev/null +++ b/web/src/pages/mfa/OpenIDRedirect.tsx @@ -0,0 +1,54 @@ +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 { AuthFailIcon } from './Icons'; + +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';