Skip to content

Commit 7dddd97

Browse files
Handle posture checks in new UI (#882)
* handle posture check in new UI MFA flow * fix formatting * add posture data to all MFA connection requests * startClientMfaSession helper, deduplicate mfa flow code * display posture check errors * handleMfaStartError helper * make shouldShowPostureError a type guard * handle posture errors during posture-only connect flow * posture check error styling * fix "Try again" button behavior * fix errors in old ui * docstrings * fix error typing * MfaMethod "enum"
1 parent 827de86 commit 7dddd97

20 files changed

Lines changed: 399 additions & 148 deletions

File tree

new-ui/src/shared/components/LocationCard/LocationCard.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { LocationCardMfaMobileView } from './views/LocationCardMfaMobileView/Loc
2121
import { LocationCardMfaOidcView } from './views/LocationCardMfaOidcView/LocationCardMfaOidcView';
2222
import { LocationCardMfaSettings } from './views/LocationCardMfaSettings/LocationCardMfaSettings';
2323
import { LocationCardMfaTotpView } from './views/LocationCardMfaTotpView/LocationCardMfaTotpView';
24+
import { LocationCardPostureCheckFailView } from './views/LocationCardPostureCheckFailView/LocationCardPostureCheckFailView';
2425

2526
interface Props {
2627
location: LocationInfo;
@@ -39,7 +40,7 @@ const views: Record<LocationCardViewsValue, ReactNode> = {
3940
[LocationCardViews.MfaSettings]: <LocationCardMfaSettings />,
4041
[LocationCardViews.Connecting]: null,
4142
[LocationCardViews.Connected]: <ConnectedView />,
42-
[LocationCardViews.PostureCheckFail]: null,
43+
[LocationCardViews.PostureCheckFail]: <LocationCardPostureCheckFailView />,
4344
};
4445

4546
interface InnerProps {
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import z from 'zod';
2+
3+
const connectErrorSchema = z.discriminatedUnion('kind', [
4+
z.object({
5+
kind: z.literal('postureCheckFailed'),
6+
message: z.string(),
7+
}),
8+
z.object({
9+
kind: z.literal('other'),
10+
message: z.string(),
11+
}),
12+
]);
13+
14+
export type ConnectError = z.infer<typeof connectErrorSchema>;
15+
16+
export const parseConnectError = (err: unknown): ConnectError | null => {
17+
const result = connectErrorSchema.safeParse(err);
18+
19+
return result.success ? result.data : null;
20+
};
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { fetch } from '@tauri-apps/plugin-http';
2+
import { api } from '../../../rust-api/api';
3+
import type {
4+
EdgeRequestHeaders,
5+
InstanceInfo,
6+
LocationInfo,
7+
} from '../../../rust-api/types';
8+
9+
export const CLIENT_MFA_ENDPOINT = 'api/v1/client-mfa';
10+
11+
/** Error raised when the MFA start request or its prerequisites fail. */
12+
export class MfaStartError extends Error {
13+
public readonly status?: number;
14+
15+
constructor(message: string, status?: number) {
16+
super(message);
17+
this.name = 'MfaStartError';
18+
this.status = status;
19+
}
20+
}
21+
22+
/** MFA method identifiers expected by the desktop-client MFA API */
23+
export const MfaStartMethod = {
24+
Totp: 0,
25+
Email: 1,
26+
Oidc: 2,
27+
MobileApprove: 4,
28+
} as const;
29+
30+
export type MfaStartMethod = (typeof MfaStartMethod)[keyof typeof MfaStartMethod];
31+
32+
/** Successful MFA start response returned by the proxy. */
33+
export type MfaStartResponse = {
34+
token: string;
35+
challenge?: string;
36+
};
37+
38+
/** Error response shape returned by the proxy for MFA start failures. */
39+
type MfaStartErrorResponse = {
40+
error?: string;
41+
};
42+
43+
/** Narrows MFA start errors that should open the posture failure view. */
44+
export const shouldShowPostureError = (
45+
err: unknown,
46+
location: LocationInfo,
47+
): err is MfaStartError =>
48+
err instanceof MfaStartError && err.status === 403 && location.posture_check_required;
49+
50+
/** Input required to start a desktop-client MFA session. */
51+
type StartClientMfaSessionParams = {
52+
instance: InstanceInfo;
53+
location: LocationInfo;
54+
method: MfaStartMethod;
55+
};
56+
57+
/** MFA start response plus request headers required by later MFA calls. */
58+
type StartClientMfaSessionResult = {
59+
response: MfaStartResponse;
60+
headers: EdgeRequestHeaders;
61+
};
62+
63+
/** Starts an MFA session, including posture data when the location requires it. */
64+
export const startClientMfaSession = async ({
65+
instance,
66+
location,
67+
method,
68+
}: StartClientMfaSessionParams): Promise<StartClientMfaSessionResult> => {
69+
let headers: EdgeRequestHeaders;
70+
try {
71+
headers = await api.getEdgeRequestHeaders();
72+
} catch {
73+
throw new MfaStartError('Failed to load request headers');
74+
}
75+
76+
let posture_data: unknown;
77+
try {
78+
posture_data = location.posture_check_required
79+
? await api.getPostureData()
80+
: undefined;
81+
} catch {
82+
throw new MfaStartError('Failed to load posture data');
83+
}
84+
85+
try {
86+
const response = await fetch(`${instance.proxy_url}${CLIENT_MFA_ENDPOINT}/start`, {
87+
method: 'POST',
88+
headers: {
89+
'Content-Type': 'application/json',
90+
...headers,
91+
},
92+
body: JSON.stringify({
93+
method,
94+
pubkey: instance.pubkey,
95+
location_id: location.network_id,
96+
posture_data,
97+
}),
98+
});
99+
100+
if (!response.ok) {
101+
let message = 'Failed to start MFA';
102+
try {
103+
const data = (await response.json()) as MfaStartErrorResponse;
104+
message = data.error ?? message;
105+
} catch {
106+
// Keep the response status even if the proxy sends a malformed error body.
107+
}
108+
throw new MfaStartError(message, response.status);
109+
}
110+
111+
return {
112+
response: (await response.json()) as MfaStartResponse,
113+
headers,
114+
};
115+
} catch (err) {
116+
if (err instanceof MfaStartError) {
117+
throw err;
118+
}
119+
throw new MfaStartError('Failed to reach server');
120+
}
121+
};

new-ui/src/shared/components/LocationCard/components/ConnectButton/ConnectButton.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,29 @@ import { useMutation } from '@tanstack/react-query';
33
import clsx from 'clsx';
44
import { api } from '../../../../rust-api/api';
55
import { LocationMfaMode } from '../../../../rust-api/types';
6+
import { parseConnectError } from '../../api/connectError';
67
import { useLocationCardContext } from '../../context/context';
78
import { LocationCardViews } from '../../context/types';
89

910
export const ConnectButton = () => {
10-
const { location, setView, startMfa } = useLocationCardContext();
11+
const { location, setPostureError, setView, startMfa } = useLocationCardContext();
1112

1213
const { mutate: connect } = useMutation({
1314
mutationFn: api.connect,
1415
onSuccess: () => {
1516
setView(LocationCardViews.Connected);
1617
},
18+
onError: (err) => {
19+
const connectError = parseConnectError(err);
20+
21+
if (
22+
location.posture_check_required &&
23+
connectError?.kind === 'postureCheckFailed'
24+
) {
25+
setPostureError(connectError.message);
26+
setView(LocationCardViews.PostureCheckFail);
27+
}
28+
},
1729
meta: {
1830
invalidate: ['locations'],
1931
},

new-ui/src/shared/components/LocationCard/context/context.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ interface LocationCardContextValue {
88
instance: InstanceInfo;
99
currentView: LocationCardViewsValue;
1010
previousView: LocationCardViewsValue | null;
11+
postureError: string | null;
1112
setView: (view: LocationCardViewsValue) => void;
13+
setPostureError: (error: string | null) => void;
1214
startMfa: () => void;
1315
localMfaMethod: MfaMethodValue;
1416
setLocalMfaMethod: (method: MfaMethodValue) => void;
@@ -36,6 +38,7 @@ export const LocationCardProvider = ({
3638
children,
3739
}: LocationCardProviderProps) => {
3840
const [previousView, setPreviousView] = useState<LocationCardViewsValue | null>(null);
41+
const [postureError, setPostureError] = useState<string | null>(null);
3942
const [currentView, setCurrentView] = useState<LocationCardViewsValue>(
4043
location.active ? LocationCardViews.Connected : LocationCardViews.Default,
4144
);
@@ -73,7 +76,9 @@ export const LocationCardProvider = ({
7376
value={{
7477
currentView,
7578
previousView,
79+
postureError,
7680
setView,
81+
setPostureError,
7782
location,
7883
instance,
7984
startMfa,
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { LocationInfo } from '../../../rust-api/types';
2+
import { shouldShowPostureError } from '../api/startClientMfaSession';
3+
import { LocationCardViews, type LocationCardViewsValue } from '../context/types';
4+
5+
type HandleMfaStartErrorParams = {
6+
err: unknown;
7+
location: LocationInfo;
8+
setPostureError: (error: string | null) => void;
9+
setView: (view: LocationCardViewsValue) => void;
10+
};
11+
12+
/** Handles MFA start posture failures and reports whether the error was consumed. */
13+
export const handleMfaStartError = ({
14+
err,
15+
location,
16+
setPostureError,
17+
setView,
18+
}: HandleMfaStartErrorParams): boolean => {
19+
if (!shouldShowPostureError(err, location)) {
20+
return false;
21+
}
22+
23+
setPostureError(err.message);
24+
setView(LocationCardViews.PostureCheckFail);
25+
return true;
26+
};

new-ui/src/shared/components/LocationCard/hooks/useMfaConnect.ts

Lines changed: 27 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,16 @@ import { fetch } from '@tauri-apps/plugin-http';
33
import { error } from '@tauri-apps/plugin-log';
44
import { useCallback, useEffect, useRef, useState } from 'react';
55
import { api } from '../../../rust-api/api';
6-
import {
7-
getInstancesQueryOptions,
8-
getPlatformHeaderQueryOptions,
9-
} from '../../../rust-api/query';
6+
import { getInstancesQueryOptions } from '../../../rust-api/query';
107
import type { EdgeRequestHeaders } from '../../../rust-api/types';
8+
import {
9+
CLIENT_MFA_ENDPOINT,
10+
type MfaStartMethod,
11+
startClientMfaSession,
12+
} from '../api/startClientMfaSession';
1113
import { useLocationCardContext } from '../context/context';
1214
import { LocationCardViews } from '../context/types';
13-
14-
const MFA_ENDPOINT = 'api/v1/client-mfa';
15-
16-
type MfaStartResponse = {
17-
token: string;
18-
challenge?: string;
19-
};
15+
import { handleMfaStartError } from './handleMfaStartError';
2016

2117
type MfaFinishResponse = {
2218
preshared_key: string;
@@ -26,8 +22,10 @@ type MfaErrorResponse = {
2622
error: string;
2723
};
2824

29-
export const useMfaConnect = (method: 0 | 1) => {
30-
const { location, setView } = useLocationCardContext();
25+
type CodeMfaStartMethod = Extract<MfaStartMethod, 0 | 1>;
26+
27+
export const useMfaConnect = (method: CodeMfaStartMethod) => {
28+
const { location, setPostureError, setView } = useLocationCardContext();
3129

3230
const [token, setToken] = useState<string | null>(null);
3331
const [isStarting, setIsStarting] = useState(false);
@@ -37,7 +35,6 @@ export const useMfaConnect = (method: 0 | 1) => {
3735
const [requestHeaders, setRequestHeaders] = useState<EdgeRequestHeaders | null>(null);
3836

3937
const { data: instances } = useQuery(getInstancesQueryOptions);
40-
const { data: platformHeader } = useQuery(getPlatformHeaderQueryOptions);
4138

4239
const instance = instances?.find((i) => i.id === location.instance_id);
4340

@@ -52,67 +49,47 @@ export const useMfaConnect = (method: 0 | 1) => {
5249
},
5350
});
5451

55-
// Fire the /start request exactly once when instance + platformHeader are ready.
52+
// Fire the /start request exactly once when instance data is ready.
5653
const startCalled = useRef(false);
5754

5855
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional one-shot trigger via startCalled ref
5956
useEffect(() => {
60-
if (!instance || !platformHeader || startCalled.current) return;
57+
if (!instance || startCalled.current) return;
6158
startCalled.current = true;
6259

6360
setIsStarting(true);
6461

6562
(async () => {
66-
let headers: EdgeRequestHeaders;
67-
try {
68-
headers = await api.getEdgeRequestHeaders();
69-
setRequestHeaders(headers);
70-
} catch {
71-
setStartError('Failed to load request headers');
72-
setIsStarting(false);
73-
return;
74-
}
75-
7663
try {
77-
const res = await fetch(`${instance.proxy_url}${MFA_ENDPOINT}/start`, {
78-
method: 'POST',
79-
headers: {
80-
'Content-Type': 'application/json',
81-
...headers,
82-
},
83-
body: JSON.stringify({
84-
method,
85-
pubkey: instance.pubkey,
86-
location_id: location.network_id,
87-
}),
64+
const { response, headers } = await startClientMfaSession({
65+
instance,
66+
location,
67+
method,
8868
});
89-
90-
if (res.ok) {
91-
const data = (await res.json()) as MfaStartResponse;
92-
setToken(data.token);
93-
} else {
94-
const data = (await res.json()) as MfaErrorResponse;
95-
setStartError(data.error ?? 'Failed to start MFA');
69+
setRequestHeaders(headers);
70+
setToken(response.token);
71+
} catch (err) {
72+
if (handleMfaStartError({ err, location, setPostureError, setView })) {
73+
return;
9674
}
97-
} catch {
98-
setStartError('Failed to reach server');
75+
setStartError(err instanceof Error ? err.message : 'Failed to start MFA');
9976
} finally {
10077
setIsStarting(false);
10178
}
10279
})();
103-
}, [instance, platformHeader]);
80+
}, [instance]);
10481

10582
const verifyCode = useCallback(
10683
async (code: string) => {
107-
if (!token || !instance || !platformHeader || !requestHeaders) return;
84+
if (!token || !instance || !requestHeaders) return;
10885

10986
setIsVerifying(true);
11087
setVerifyError(null);
11188

11289
const body = JSON.stringify({ token, code });
11390

11491
try {
115-
const res = await fetch(`${instance.proxy_url}${MFA_ENDPOINT}/finish`, {
92+
const res = await fetch(`${instance.proxy_url}${CLIENT_MFA_ENDPOINT}/finish`, {
11693
method: 'POST',
11794
headers: {
11895
'Content-Type': 'application/json',
@@ -148,7 +125,7 @@ export const useMfaConnect = (method: 0 | 1) => {
148125
setIsVerifying(false);
149126
}
150127
},
151-
[token, instance, platformHeader, requestHeaders, location, connectMutate, setView],
128+
[token, instance, requestHeaders, location, connectMutate, setView],
152129
);
153130

154131
return { token, isStarting, startError, verifyCode, isVerifying, verifyError };

0 commit comments

Comments
 (0)