Skip to content

Commit 56d8fc0

Browse files
connect mfa via mobile confirmation in tray ui (#877)
1 parent fc11b53 commit 56d8fc0

7 files changed

Lines changed: 308 additions & 3 deletions

File tree

new-ui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"dependencies": {
1616
"@biomejs/biome": "^2.4.15",
1717
"@floating-ui/react": "^0.27.19",
18+
"@stablelib/base64": "^2.0.1",
1819
"@tanstack/react-form": "^1.32.0",
1920
"@tanstack/react-query": "^5.100.10",
2021
"@tanstack/react-router": "^1.170.4",

new-ui/pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { LocationCardViews, type LocationCardViewsValue } from './context/types'
1717
import { ConnectedView } from './views/ConnectedView/ConnectedView';
1818
import { DefaultView } from './views/DefaultView/DefaultView';
1919
import { LocationCardMfaEmailView } from './views/LocationCardMfaEmailView/LocationCardMfaEmailView';
20+
import { LocationCardMfaMobileView } from './views/LocationCardMfaMobileView/LocationCardMfaMobileView';
2021
import { LocationCardMfaOidcView } from './views/LocationCardMfaOidcView/LocationCardMfaOidcView';
2122
import { LocationCardMfaSettings } from './views/LocationCardMfaSettings/LocationCardMfaSettings';
2223
import { LocationCardMfaTotpView } from './views/LocationCardMfaTotpView/LocationCardMfaTotpView';
@@ -34,7 +35,7 @@ const views: Record<LocationCardViewsValue, ReactNode> = {
3435
[LocationCardViews.MfaTotp]: <LocationCardMfaTotpView />,
3536
[LocationCardViews.MfaEmail]: <LocationCardMfaEmailView />,
3637
[LocationCardViews.MfaOidc]: <LocationCardMfaOidcView />,
37-
[LocationCardViews.MfaMobile]: null,
38+
[LocationCardViews.MfaMobile]: <LocationCardMfaMobileView />,
3839
[LocationCardViews.MfaSettings]: <LocationCardMfaSettings />,
3940
[LocationCardViews.Connecting]: null,
4041
[LocationCardViews.Connected]: <ConnectedView />,
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { encode } from '@stablelib/base64';
2+
import { useMutation } from '@tanstack/react-query';
3+
import { fetch } from '@tauri-apps/plugin-http';
4+
import { error } from '@tauri-apps/plugin-log';
5+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
6+
import { api } from '../../../rust-api/api';
7+
import { useLocationCardContext } from '../context/context';
8+
import { LocationCardViews } from '../context/types';
9+
10+
const MFA_ENDPOINT = 'api/v1/client-mfa';
11+
12+
type MfaStartResponse = {
13+
token: string;
14+
challenge: string;
15+
};
16+
17+
type MfaErrorResponse = {
18+
error: string;
19+
};
20+
21+
type TokenData = {
22+
token: string;
23+
challenge: string;
24+
};
25+
26+
export const useMfaMobileConnect = () => {
27+
const { location, instance, setView } = useLocationCardContext();
28+
29+
const [isStarting, setIsStarting] = useState(false);
30+
const [startError, setStartError] = useState<string | null>(null);
31+
const [tokenData, setTokenData] = useState<TokenData | null>(null);
32+
const [isConnecting, setIsConnecting] = useState(false);
33+
const [connectionError, setConnectionError] = useState<string | null>(null);
34+
35+
const wsRef = useRef<WebSocket | null>(null);
36+
const expectedCloseRef = useRef(false);
37+
38+
const { mutate: connectMutate } = useMutation({
39+
mutationFn: api.connect,
40+
onSuccess: () => {
41+
setView(LocationCardViews.Connected);
42+
},
43+
onError: (err) => {
44+
error(`Connect command failed after successful mobile MFA\n${err}`);
45+
setConnectionError('Failed to establish VPN connection');
46+
},
47+
});
48+
49+
// Open WebSocket when tokenData is available
50+
useEffect(() => {
51+
if (!tokenData) return;
52+
53+
const wsUrl = `${instance.proxy_url
54+
.replace(/^http:/, 'ws:')
55+
.replace(
56+
/^https:/,
57+
'wss:',
58+
)}${MFA_ENDPOINT}/remote?token=${encodeURIComponent(tokenData.token)}`;
59+
60+
expectedCloseRef.current = false;
61+
const ws = new WebSocket(wsUrl);
62+
wsRef.current = ws;
63+
64+
ws.onopen = () => {
65+
setIsConnecting(true);
66+
setConnectionError(null);
67+
};
68+
69+
ws.onmessage = (event: MessageEvent) => {
70+
try {
71+
const parsed = JSON.parse(event.data as string) as unknown;
72+
if (
73+
typeof parsed === 'object' &&
74+
parsed !== null &&
75+
'preshared_key' in parsed &&
76+
typeof (parsed as Record<string, unknown>).preshared_key === 'string'
77+
) {
78+
const presharedKey = (parsed as { preshared_key: string }).preshared_key;
79+
expectedCloseRef.current = true;
80+
connectMutate({
81+
locationId: location.id,
82+
connectionType: location.connection_type,
83+
presharedKey,
84+
});
85+
} else {
86+
error(
87+
`Unexpected mobile MFA WS message for location ${location.id}: ${event.data}`,
88+
);
89+
}
90+
} catch (e) {
91+
error(`Failed to parse mobile MFA WS message for location ${location.id}: ${e}`);
92+
}
93+
};
94+
95+
ws.onerror = () => {
96+
if (!expectedCloseRef.current) {
97+
setIsConnecting(false);
98+
setConnectionError('Connection error. Please try again.');
99+
error(`Mobile MFA WebSocket error for location ${location.id}`);
100+
}
101+
};
102+
103+
ws.onclose = () => {
104+
if (!expectedCloseRef.current) {
105+
setIsConnecting(false);
106+
setConnectionError('Connection closed unexpectedly. Please try again.');
107+
error(`Mobile MFA WebSocket closed unexpectedly for location ${location.id}`);
108+
}
109+
};
110+
111+
return () => {
112+
expectedCloseRef.current = true;
113+
ws.close();
114+
wsRef.current = null;
115+
setIsConnecting(false);
116+
};
117+
}, [tokenData, instance, connectMutate, location]);
118+
119+
// Clean up WebSocket on unmount
120+
useEffect(() => {
121+
return () => {
122+
if (wsRef.current) {
123+
expectedCloseRef.current = true;
124+
wsRef.current.close();
125+
wsRef.current = null;
126+
}
127+
};
128+
}, []);
129+
130+
const qrValue = useMemo(() => {
131+
if (!tokenData) return null;
132+
const json = JSON.stringify({
133+
token: tokenData.token,
134+
challenge: tokenData.challenge,
135+
instance_id: instance.uuid,
136+
});
137+
return encode(new TextEncoder().encode(json));
138+
}, [tokenData, instance.uuid]);
139+
140+
const start = useCallback(async () => {
141+
setIsStarting(true);
142+
setStartError(null);
143+
setConnectionError(null);
144+
// Clear previous token → triggers WS cleanup via effect
145+
setTokenData(null);
146+
147+
let headers: Record<string, string>;
148+
try {
149+
headers = await api.getEdgeRequestHeaders();
150+
} catch {
151+
setStartError('Failed to load request headers');
152+
setIsStarting(false);
153+
return;
154+
}
155+
156+
try {
157+
const res = await fetch(`${instance.proxy_url}${MFA_ENDPOINT}/start`, {
158+
method: 'POST',
159+
headers: { 'Content-Type': 'application/json', ...headers },
160+
body: JSON.stringify({
161+
method: 4,
162+
pubkey: instance.pubkey,
163+
location_id: location.network_id,
164+
}),
165+
});
166+
167+
if (res.ok) {
168+
const data = (await res.json()) as MfaStartResponse;
169+
setTokenData({ token: data.token, challenge: data.challenge });
170+
} else {
171+
const data = (await res.json()) as MfaErrorResponse;
172+
setStartError(data.error ?? 'Failed to start mobile authentication');
173+
error(`Mobile MFA start failed for location ${location.id}: ${data.error}`);
174+
}
175+
} catch (e) {
176+
setStartError('Failed to reach server');
177+
error(`Mobile MFA start network error for location ${location.id}: ${e}`);
178+
} finally {
179+
setIsStarting(false);
180+
}
181+
}, [instance, location]);
182+
183+
const reset = useCallback(() => {
184+
if (wsRef.current) {
185+
expectedCloseRef.current = true;
186+
wsRef.current.close();
187+
wsRef.current = null;
188+
}
189+
setTokenData(null);
190+
setIsStarting(false);
191+
setStartError(null);
192+
setIsConnecting(false);
193+
setConnectionError(null);
194+
}, []);
195+
196+
return { start, isStarting, startError, qrValue, isConnecting, connectionError, reset };
197+
};
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import './style.scss';
2+
import { useEffect, useRef, useState } from 'react';
3+
import { ThemeSpacing } from '../../../../types';
4+
import { Button } from '../../../Button/Button';
5+
import { ButtonVariant } from '../../../Button/types';
6+
import { Controls } from '../../../Controls/Controls';
7+
import { Divider } from '../../../Divider/Divider';
8+
import { IconKind } from '../../../Icon';
9+
import { IconButton } from '../../../IconButton/IconButton';
10+
import { IconButtonVariant } from '../../../IconButton/types';
11+
import { QrCard } from '../../../QrCard/QrCard';
12+
import { LocationViewHeader } from '../../components/LocationViewHeader/LocationViewHeader';
13+
import { useLocationCardContext } from '../../context/context';
14+
import { LocationCardViews } from '../../context/types';
15+
import { useMfaMobileConnect } from '../../hooks/useMfaMobileConnect';
16+
17+
type Screen = 'loading' | 'qr' | 'error';
18+
19+
export const LocationCardMfaMobileView = () => {
20+
const { setView } = useLocationCardContext();
21+
const { start, startError, qrValue, connectionError, reset } = useMfaMobileConnect();
22+
const [screen, setScreen] = useState<Screen>('loading');
23+
const startedRef = useRef(false);
24+
25+
// Auto-start on mount
26+
useEffect(() => {
27+
if (startedRef.current) return;
28+
startedRef.current = true;
29+
void start();
30+
}, [start]);
31+
32+
useEffect(() => {
33+
if (startError ?? connectionError) {
34+
setScreen('error');
35+
} else if (qrValue) {
36+
setScreen('qr');
37+
}
38+
}, [startError, connectionError, qrValue]);
39+
40+
const retry = () => {
41+
reset();
42+
setScreen('loading');
43+
void start();
44+
};
45+
46+
const errorMessage = startError ?? connectionError;
47+
48+
return (
49+
<div className="location-card-mfa-mobile">
50+
<Divider spacing={ThemeSpacing.Md} />
51+
<LocationViewHeader title="Two-factor authentication">
52+
{screen === 'loading' && <p>Preparing authentication...</p>}
53+
{screen === 'qr' && (
54+
<p>Open your Defguard mobile app and scan the QR code you see bellow.</p>
55+
)}
56+
{screen === 'error' && <p className="error">{errorMessage}</p>}
57+
</LocationViewHeader>
58+
{screen === 'qr' && qrValue && (
59+
<div className="qr-wrapper">
60+
<QrCard value={qrValue} size={184} />
61+
</div>
62+
)}
63+
<Controls>
64+
<IconButton
65+
variant={IconButtonVariant.BigSelected}
66+
icon={IconKind.ArrowBig}
67+
iconRotation="left"
68+
onClick={() => setView(LocationCardViews.Default)}
69+
/>
70+
<div className="right">
71+
{screen === 'error' && (
72+
<Button text="Try again" variant={ButtonVariant.Primary} onClick={retry} />
73+
)}
74+
</div>
75+
</Controls>
76+
</div>
77+
);
78+
};
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
.location-card-mfa-mobile {
2+
display: flex;
3+
flex-direction: column;
4+
5+
.qr-wrapper {
6+
display: flex;
7+
flex-direction: column;
8+
align-items: center;
9+
gap: var(--spacing-md);
10+
padding-top: var(--spacing-lg);
11+
12+
p {
13+
text-align: center;
14+
}
15+
}
16+
17+
.location-card-view-header {
18+
p.error {
19+
color: var(--fg-critical);
20+
}
21+
}
22+
}

new-ui/src/shared/rust-api/types.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -286,8 +286,6 @@ export type SaveDeviceConfigResponse = {
286286
locations: LocationInfo[];
287287
};
288288

289-
// ── Request argument types ───────────────────────────────────────────────────
290-
291289
export type ConnectionArgs = {
292290
locationId: number;
293291
connectionType: ConnectionType;

0 commit comments

Comments
 (0)