diff --git a/web/messages/en.json b/web/messages/en.json index 7fc0623a..e8caa20e 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -98,5 +98,10 @@ "client_setup_mobile_forgot": "If you forgot to install the mobile app, click one of the buttons bellow.", "client_setup_mobile_google": "Google Play", "client_setup_mobile_apple": "Apple Store", - "client_setup_footer_extra": "Once your Defguard client is configured, you can close this window." + "client_setup_footer_extra": "Once your Defguard client is configured, you can close this window.", + "openid_mfa_redirect_error_title": "Authentication Error", + "openid_mfa_redirect_error_message": "No token provided in the URL. Please ensure you have a valid token to proceed with OpenID authentication.", + "openid_mfa_redirect_error_missing_args": "Missing code or state in the callback's URL. The provider might not be configured correctly.", + "openid_mfa_complete_title": "Authentication Completed", + "openid_mfa_complete_subtitle": "You have been successfully authenticated. Please close this window and get back to the Defguard VPN Client" } diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index 2dc0610c..0e0019e5 100644 --- a/web/src/routeTree.gen.ts +++ b/web/src/routeTree.gen.ts @@ -19,7 +19,10 @@ import { Route as IndexRouteImport } from './routes/index' import { Route as PasswordIndexRouteImport } from './routes/password/index' import { Route as PasswordSentRouteImport } from './routes/password/sent' import { Route as PasswordFinishRouteImport } from './routes/password/finish' +import { Route as OpenidErrorRouteImport } from './routes/openid/error' import { Route as OpenidCallbackRouteImport } from './routes/openid/callback' +import { Route as OpenidMfaIndexRouteImport } from './routes/openid/mfa/index' +import { Route as OpenidMfaCallbackRouteImport } from './routes/openid/mfa/callback' const TestRoute = TestRouteImport.update({ id: '/test', @@ -71,11 +74,26 @@ const PasswordFinishRoute = PasswordFinishRouteImport.update({ path: '/password/finish', getParentRoute: () => rootRouteImport, } as any) +const OpenidErrorRoute = OpenidErrorRouteImport.update({ + id: '/openid/error', + path: '/openid/error', + getParentRoute: () => rootRouteImport, +} as any) const OpenidCallbackRoute = OpenidCallbackRouteImport.update({ id: '/openid/callback', path: '/openid/callback', getParentRoute: () => rootRouteImport, } as any) +const OpenidMfaIndexRoute = OpenidMfaIndexRouteImport.update({ + id: '/openid/mfa/', + path: '/openid/mfa/', + getParentRoute: () => rootRouteImport, +} as any) +const OpenidMfaCallbackRoute = OpenidMfaCallbackRouteImport.update({ + id: '/openid/mfa/callback', + path: '/openid/mfa/callback', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -86,9 +104,12 @@ export interface FileRoutesByFullPath { '/session-end': typeof SessionEndRoute '/test': typeof TestRoute '/openid/callback': typeof OpenidCallbackRoute + '/openid/error': typeof OpenidErrorRoute '/password/finish': typeof PasswordFinishRoute '/password/sent': typeof PasswordSentRoute '/password': typeof PasswordIndexRoute + '/openid/mfa/callback': typeof OpenidMfaCallbackRoute + '/openid/mfa': typeof OpenidMfaIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -99,9 +120,12 @@ export interface FileRoutesByTo { '/session-end': typeof SessionEndRoute '/test': typeof TestRoute '/openid/callback': typeof OpenidCallbackRoute + '/openid/error': typeof OpenidErrorRoute '/password/finish': typeof PasswordFinishRoute '/password/sent': typeof PasswordSentRoute '/password': typeof PasswordIndexRoute + '/openid/mfa/callback': typeof OpenidMfaCallbackRoute + '/openid/mfa': typeof OpenidMfaIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -113,9 +137,12 @@ export interface FileRoutesById { '/session-end': typeof SessionEndRoute '/test': typeof TestRoute '/openid/callback': typeof OpenidCallbackRoute + '/openid/error': typeof OpenidErrorRoute '/password/finish': typeof PasswordFinishRoute '/password/sent': typeof PasswordSentRoute '/password/': typeof PasswordIndexRoute + '/openid/mfa/callback': typeof OpenidMfaCallbackRoute + '/openid/mfa/': typeof OpenidMfaIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -128,9 +155,12 @@ export interface FileRouteTypes { | '/session-end' | '/test' | '/openid/callback' + | '/openid/error' | '/password/finish' | '/password/sent' | '/password' + | '/openid/mfa/callback' + | '/openid/mfa' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -141,9 +171,12 @@ export interface FileRouteTypes { | '/session-end' | '/test' | '/openid/callback' + | '/openid/error' | '/password/finish' | '/password/sent' | '/password' + | '/openid/mfa/callback' + | '/openid/mfa' id: | '__root__' | '/' @@ -154,9 +187,12 @@ export interface FileRouteTypes { | '/session-end' | '/test' | '/openid/callback' + | '/openid/error' | '/password/finish' | '/password/sent' | '/password/' + | '/openid/mfa/callback' + | '/openid/mfa/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -168,9 +204,12 @@ export interface RootRouteChildren { SessionEndRoute: typeof SessionEndRoute TestRoute: typeof TestRoute OpenidCallbackRoute: typeof OpenidCallbackRoute + OpenidErrorRoute: typeof OpenidErrorRoute PasswordFinishRoute: typeof PasswordFinishRoute PasswordSentRoute: typeof PasswordSentRoute PasswordIndexRoute: typeof PasswordIndexRoute + OpenidMfaCallbackRoute: typeof OpenidMfaCallbackRoute + OpenidMfaIndexRoute: typeof OpenidMfaIndexRoute } declare module '@tanstack/react-router' { @@ -245,6 +284,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PasswordFinishRouteImport parentRoute: typeof rootRouteImport } + '/openid/error': { + id: '/openid/error' + path: '/openid/error' + fullPath: '/openid/error' + preLoaderRoute: typeof OpenidErrorRouteImport + parentRoute: typeof rootRouteImport + } '/openid/callback': { id: '/openid/callback' path: '/openid/callback' @@ -252,6 +298,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof OpenidCallbackRouteImport parentRoute: typeof rootRouteImport } + '/openid/mfa/': { + id: '/openid/mfa/' + path: '/openid/mfa' + fullPath: '/openid/mfa' + preLoaderRoute: typeof OpenidMfaIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/openid/mfa/callback': { + id: '/openid/mfa/callback' + path: '/openid/mfa/callback' + fullPath: '/openid/mfa/callback' + preLoaderRoute: typeof OpenidMfaCallbackRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -264,9 +324,12 @@ const rootRouteChildren: RootRouteChildren = { SessionEndRoute: SessionEndRoute, TestRoute: TestRoute, OpenidCallbackRoute: OpenidCallbackRoute, + OpenidErrorRoute: OpenidErrorRoute, PasswordFinishRoute: PasswordFinishRoute, PasswordSentRoute: PasswordSentRoute, PasswordIndexRoute: PasswordIndexRoute, + OpenidMfaCallbackRoute: OpenidMfaCallbackRoute, + OpenidMfaIndexRoute: OpenidMfaIndexRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/web/src/routes/openid/callback.tsx b/web/src/routes/openid/callback.tsx index 2304ba0a..0e9ee9c2 100644 --- a/web/src/routes/openid/callback.tsx +++ b/web/src/routes/openid/callback.tsx @@ -12,20 +12,33 @@ const schema = z.object({ export const Route = createFileRoute('/openid/callback')({ component: RouteComponent, validateSearch: schema, + onError: () => { + throw redirect({ to: '/enrollment-start', replace: true }); + }, loaderDeps: ({ search }) => ({ search }), beforeLoad: async ({ search }) => { - const openIdResponse = await api.openId.enrollmentCallback.callbackFn({ - data: { - code: search.code, - state: search.state, - type: 'enrollment', - }, - }); - const enrollmentStartResponse = await api.enrollment.start.callbackFn({ - data: { - token: openIdResponse.data.token, - }, - }); + const openIdResponse = await api.openId.enrollmentCallback + .callbackFn({ + data: { + code: search.code, + state: search.state, + type: 'enrollment', + }, + }) + .catch((e) => { + console.error(e); + throw redirect({ to: '/enrollment-start', replace: true }); + }); + const enrollmentStartResponse = await api.enrollment.start + .callbackFn({ + data: { + token: openIdResponse.data.token, + }, + }) + .catch((e) => { + console.error(e); + throw redirect({ to: '/enrollment-start', replace: true }); + }); useEnrollmentStore.setState({ enrollmentData: enrollmentStartResponse.data, token: openIdResponse.data.token, diff --git a/web/src/routes/openid/error.tsx b/web/src/routes/openid/error.tsx new file mode 100644 index 00000000..255c3dcf --- /dev/null +++ b/web/src/routes/openid/error.tsx @@ -0,0 +1,22 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { m } from '../../paraglide/messages'; +import { PageProcessEnd } from '../../shared/components/PageProcessEnd/PageProcessEnd'; +import { useOpenidStore } from '../../shared/hooks/useOpenIdStore'; + +export const Route = createFileRoute('/openid/error')({ + component: RouteComponent, +}); + +function RouteComponent() { + const openIdError = useOpenidStore( + (s) => s.error ?? m.openid_mfa_redirect_error_message(), + ); + + return ( + + ); +} diff --git a/web/src/routes/openid/mfa/callback.tsx b/web/src/routes/openid/mfa/callback.tsx new file mode 100644 index 00000000..260d4a62 --- /dev/null +++ b/web/src/routes/openid/mfa/callback.tsx @@ -0,0 +1,44 @@ +import { createFileRoute, redirect } from '@tanstack/react-router'; +import z from 'zod'; +import { m } from '../../../paraglide/messages'; +import { api } from '../../../shared/api/api'; +import { PageProcessEnd } from '../../../shared/components/PageProcessEnd/PageProcessEnd'; +import { useOpenidStore } from '../../../shared/hooks/useOpenIdStore'; + +const searchSchema = z.object({ + code: z.string().trim().min(1), + state: z.string().trim().min(1), +}); + +export const Route = createFileRoute('/openid/mfa/callback')({ + validateSearch: searchSchema, + onError: () => { + useOpenidStore.setState({ error: m.openid_mfa_redirect_error_missing_args() }); + throw redirect({ to: '/openid/error', replace: true }); + }, + loaderDeps: ({ search }) => ({ search }), + loader: async ({ deps }) => { + try { + await api.openId.mfaCallback.callbackFn({ + data: { + code: deps.search.code, + state: deps.search.state, + type: 'mfa', + }, + }); + } catch (e) { + console.error(e); + } + }, + component: RouteComponent, +}); + +function RouteComponent() { + return ( + + ); +} diff --git a/web/src/routes/openid/mfa/index.tsx b/web/src/routes/openid/mfa/index.tsx new file mode 100644 index 00000000..4be95977 --- /dev/null +++ b/web/src/routes/openid/mfa/index.tsx @@ -0,0 +1,58 @@ +import { createFileRoute, redirect, useLoaderData } from '@tanstack/react-router'; +import { useEffect } from 'react'; +import z from 'zod'; +import { api } from '../../../shared/api/api'; +import { isPresent } from '../../../shared/defguard-ui/utils/isPresent'; + +const searchSchema = z.object({ + token: z.string().trim().min(1), +}); + +export const Route = createFileRoute('/openid/mfa/')({ + component: RouteComponent, + validateSearch: searchSchema, + onError: () => { + throw redirect({ + to: '/openid/error', + replace: true, + }); + }, + loaderDeps: ({ search }) => ({ search }), + loader: async ({ deps }) => { + const response = await api.openId.authInfo + .callbackFn({ + data: { + type: 'mfa', + state: deps.search.token, + }, + }) + .catch((e) => { + console.error(e); + throw redirect({ + to: '/openid/error', + replace: true, + }); + }); + if (!isPresent(response.data.url)) { + console.error('Missing URL in server response.'); + throw redirect({ + to: '/openid/error', + replace: true, + }); + } + return response.data.url as string; + }, +}); + +function RouteComponent() { + const loaderData = useLoaderData({ + from: '/openid/mfa/', + }); + + useEffect(() => { + if (loaderData) { + window.location.href = loaderData; + } + }, [loaderData]); + return null; +} diff --git a/web/src/shared/components/PageProcessEnd/PageProcessEnd.tsx b/web/src/shared/components/PageProcessEnd/PageProcessEnd.tsx index 9dd2a261..1af90789 100644 --- a/web/src/shared/components/PageProcessEnd/PageProcessEnd.tsx +++ b/web/src/shared/components/PageProcessEnd/PageProcessEnd.tsx @@ -27,24 +27,25 @@ export const PageProcessEnd = ({ }: Props) => { return ( - - - - - {title} - - - - {subtitle} - - {isPresent(linkText) && isPresent(link) && ( - <> - - - - - > - )} + + + + + {title} + + + + {subtitle} + + {isPresent(linkText) && isPresent(link) && ( + <> + + + + + > + )} + ); }; diff --git a/web/src/shared/components/PageProcessEnd/style.scss b/web/src/shared/components/PageProcessEnd/style.scss index 275538f4..b7cb5d09 100644 --- a/web/src/shared/components/PageProcessEnd/style.scss +++ b/web/src/shared/components/PageProcessEnd/style.scss @@ -12,6 +12,10 @@ } .page-content { + & > svg { + padding-bottom: 0 !important; + } + h1, p { text-align: center; @@ -20,5 +24,17 @@ p { color: var(--fg-muted); } + + .content { + margin-top: auto; + margin-bottom: auto; + display: flex; + flex-flow: column; + align-items: center; + justify-content: center; + flex: none; + box-sizing: border-box; + padding: var(--spacing-4xl) 0; + } } } diff --git a/web/src/shared/consts.ts b/web/src/shared/consts.ts index 7d578318..4591ebfa 100644 --- a/web/src/shared/consts.ts +++ b/web/src/shared/consts.ts @@ -1,9 +1,3 @@ -export const motionTransitionStandard = { - type: 'tween', - ease: 'easeOut', - duration: 0.16, -} as const; - export const externalLink = { defguard: { download: 'https://defguard.net/download', diff --git a/web/src/shared/defguard-ui b/web/src/shared/defguard-ui index e15d8f01..571c67b1 160000 --- a/web/src/shared/defguard-ui +++ b/web/src/shared/defguard-ui @@ -1 +1 @@ -Subproject commit e15d8f017742d5a224cd9a32ded8c450926f3125 +Subproject commit 571c67b17261ff4ddfd58330c559197c383ff37f diff --git a/web/src/shared/hooks/useOpenIdStore.tsx b/web/src/shared/hooks/useOpenIdStore.tsx new file mode 100644 index 00000000..4839c05c --- /dev/null +++ b/web/src/shared/hooks/useOpenIdStore.tsx @@ -0,0 +1,15 @@ +import { create } from 'zustand'; + +type Store = StoreValues; + +type StoreValues = { + error?: string; +}; + +const defaults: StoreValues = { + error: undefined, +}; + +export const useOpenidStore = create(() => ({ + ...defaults, +}));