Skip to content

Commit fc11b53

Browse files
add login via oidc provider (#875)
1 parent fc42dcf commit fc11b53

8 files changed

Lines changed: 295 additions & 1 deletion

File tree

new-ui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"motion": "^12.38.0",
3131
"p-timeout": "^7.0.1",
3232
"prettier": "^3.8.3",
33+
"qrcode.react": "^4.2.0",
3334
"radashi": "^12.9.1",
3435
"react": "^19.2.6",
3536
"react-chartjs-2": "^5.3.1",

new-ui/pnpm-lock.yaml

Lines changed: 12 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 { LocationCardMfaOidcView } from './views/LocationCardMfaOidcView/LocationCardMfaOidcView';
2021
import { LocationCardMfaSettings } from './views/LocationCardMfaSettings/LocationCardMfaSettings';
2122
import { LocationCardMfaTotpView } from './views/LocationCardMfaTotpView/LocationCardMfaTotpView';
2223

@@ -32,7 +33,7 @@ const views: Record<LocationCardViewsValue, ReactNode> = {
3233
[LocationCardViews.Default]: <DefaultView />,
3334
[LocationCardViews.MfaTotp]: <LocationCardMfaTotpView />,
3435
[LocationCardViews.MfaEmail]: <LocationCardMfaEmailView />,
35-
[LocationCardViews.MfaOidc]: null,
36+
[LocationCardViews.MfaOidc]: <LocationCardMfaOidcView />,
3637
[LocationCardViews.MfaMobile]: null,
3738
[LocationCardViews.MfaSettings]: <LocationCardMfaSettings />,
3839
[LocationCardViews.Connecting]: null,
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { useMutation, useQuery } from '@tanstack/react-query';
2+
import { fetch } from '@tauri-apps/plugin-http';
3+
import { error } from '@tauri-apps/plugin-log';
4+
import { useCallback, useEffect, useRef, useState } from 'react';
5+
import { api } from '../../../rust-api/api';
6+
import { getInstancesQueryOptions } from '../../../rust-api/query';
7+
import { useLocationCardContext } from '../context/context';
8+
import { LocationCardViews } from '../context/types';
9+
10+
const MFA_ENDPOINT = 'api/v1/client-mfa';
11+
const POLL_INTERVAL_MS = 5_000;
12+
const POLL_TIMEOUT_MS = 5 * 60 * 1_000; // 5 minutes
13+
14+
type MfaStartResponse = { token: string };
15+
type MfaFinishResponse = { preshared_key: string };
16+
type MfaErrorResponse = { error: string };
17+
18+
export const useMfaOidcConnect = () => {
19+
const { location, setView } = useLocationCardContext();
20+
21+
const [isStarting, setIsStarting] = useState(false);
22+
const [startError, setStartError] = useState<string | null>(null);
23+
const [isPolling, setIsPolling] = useState(false);
24+
const [pollError, setPollError] = useState<string | null>(null);
25+
26+
const { data: instances } = useQuery(getInstancesQueryOptions);
27+
const instance = instances?.find((i) => i.id === location.instance_id);
28+
29+
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
30+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
31+
32+
const { mutate: connectMutate } = useMutation({
33+
mutationFn: api.connect,
34+
onSuccess: () => {
35+
setView(LocationCardViews.Connected);
36+
},
37+
onError: (err) => {
38+
error(`Connect command failed after successful OIDC MFA\n${err}`);
39+
setPollError('Failed to establish VPN connection');
40+
},
41+
});
42+
43+
const stopPolling = useCallback(() => {
44+
if (intervalRef.current !== null) {
45+
clearInterval(intervalRef.current);
46+
intervalRef.current = null;
47+
}
48+
if (timeoutRef.current !== null) {
49+
clearTimeout(timeoutRef.current);
50+
timeoutRef.current = null;
51+
}
52+
}, []);
53+
54+
// Clean up on unmount
55+
useEffect(() => {
56+
return () => {
57+
stopPolling();
58+
};
59+
}, [stopPolling]);
60+
61+
const startPolling = useCallback(
62+
(token: string, proxyUrl: string, headers: Record<string, string>) => {
63+
setIsPolling(true);
64+
setPollError(null);
65+
66+
const poll = async () => {
67+
try {
68+
const res = await fetch(`${proxyUrl}${MFA_ENDPOINT}/finish`, {
69+
method: 'POST',
70+
headers: { 'Content-Type': 'application/json', ...headers },
71+
body: JSON.stringify({ token }),
72+
});
73+
74+
if (res.ok) {
75+
stopPolling();
76+
setIsPolling(false);
77+
const data = (await res.json()) as MfaFinishResponse;
78+
connectMutate({
79+
locationId: location.id,
80+
connectionType: location.connection_type,
81+
presharedKey: data.preshared_key,
82+
});
83+
return;
84+
}
85+
86+
// 428 Precondition Required: user hasn't completed browser auth yet, keep polling
87+
if (res.status === 428) return;
88+
89+
// Any other error: stop polling and surface the error
90+
stopPolling();
91+
setIsPolling(false);
92+
const data = (await res.json()) as MfaErrorResponse;
93+
const msg = data.error;
94+
if (msg === 'invalid token' || msg === 'login session not found') {
95+
setPollError('Session expired. Please try again.');
96+
} else {
97+
setPollError('Authentication failed. Please try again.');
98+
}
99+
error(`OIDC MFA poll failed for location ${location.id}: ${msg}`);
100+
} catch (e) {
101+
stopPolling();
102+
setIsPolling(false);
103+
setPollError('Failed to reach server. Please try again.');
104+
error(`OIDC MFA poll network error for location ${location.id}: ${e}`);
105+
}
106+
};
107+
108+
intervalRef.current = setInterval(poll, POLL_INTERVAL_MS);
109+
110+
timeoutRef.current = setTimeout(() => {
111+
stopPolling();
112+
setIsPolling(false);
113+
setPollError('Authentication timed out. Please try again.');
114+
error(`OIDC MFA timed out for location ${location.id}`);
115+
}, POLL_TIMEOUT_MS);
116+
},
117+
[location, connectMutate, stopPolling],
118+
);
119+
120+
const start = useCallback(async () => {
121+
if (!instance) {
122+
setStartError('Instance not found');
123+
return;
124+
}
125+
126+
setIsStarting(true);
127+
setStartError(null);
128+
setPollError(null);
129+
stopPolling();
130+
131+
let headers: Record<string, string>;
132+
try {
133+
headers = await api.getEdgeRequestHeaders();
134+
} catch {
135+
setStartError('Failed to load request headers');
136+
setIsStarting(false);
137+
return;
138+
}
139+
140+
try {
141+
const res = await fetch(`${instance.proxy_url}${MFA_ENDPOINT}/start`, {
142+
method: 'POST',
143+
headers: { 'Content-Type': 'application/json', ...headers },
144+
body: JSON.stringify({
145+
method: 2,
146+
pubkey: instance.pubkey,
147+
location_id: location.network_id,
148+
}),
149+
});
150+
151+
if (res.ok) {
152+
const data = (await res.json()) as MfaStartResponse;
153+
await api.openLink(`${instance.proxy_url}openid/mfa?token=${data.token}`);
154+
startPolling(data.token, instance.proxy_url, headers);
155+
} else {
156+
const data = (await res.json()) as MfaErrorResponse;
157+
setStartError(data.error ?? 'Failed to start OIDC authentication');
158+
error(`OIDC MFA start failed for location ${location.id}: ${data.error}`);
159+
}
160+
} catch (e) {
161+
setStartError('Failed to reach server');
162+
error(`OIDC MFA start network error for location ${location.id}: ${e}`);
163+
} finally {
164+
setIsStarting(false);
165+
}
166+
}, [instance, location, startPolling, stopPolling]);
167+
168+
return { start, isStarting, startError, isPolling, pollError };
169+
};
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import './style.scss';
2+
import { useEffect, 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 { LocationViewHeader } from '../../components/LocationViewHeader/LocationViewHeader';
12+
import { useLocationCardContext } from '../../context/context';
13+
import { LocationCardViews } from '../../context/types';
14+
import { useMfaOidcConnect } from '../../hooks/useMfaOidcConnect';
15+
16+
type Screen = 'idle' | 'polling' | 'error';
17+
18+
export const LocationCardMfaOidcView = () => {
19+
const { setView } = useLocationCardContext();
20+
const { start, isStarting, startError, isPolling, pollError } = useMfaOidcConnect();
21+
const [screen, setScreen] = useState<Screen>('idle');
22+
23+
useEffect(() => {
24+
if (startError ?? pollError) {
25+
setScreen((prev) => (prev !== 'idle' ? 'error' : prev));
26+
} else if (isPolling) {
27+
setScreen('polling');
28+
}
29+
}, [startError, pollError, isPolling]);
30+
31+
const handleStart = async () => {
32+
await start();
33+
setScreen('polling');
34+
};
35+
36+
const errorMessage = startError ?? pollError;
37+
38+
const resetToIdle = () => {
39+
setScreen('idle');
40+
};
41+
42+
return (
43+
<div className="location-card-mfa-oidc">
44+
<Divider spacing={ThemeSpacing.Md} />
45+
<LocationViewHeader title="Two-factor authentication">
46+
{screen === 'idle' && (
47+
<p>
48+
To connect to the VPN, authenticate via your OpenID provider. A browser window
49+
will open for you to sign in.
50+
</p>
51+
)}
52+
{screen === 'polling' && (
53+
<p>
54+
{`Complete the sign-in in your browser. This page will update automatically.`}
55+
</p>
56+
)}
57+
{screen === 'error' && <p className="error">{errorMessage}</p>}
58+
</LocationViewHeader>
59+
<Controls>
60+
<IconButton
61+
variant={IconButtonVariant.BigSelected}
62+
icon={IconKind.ArrowBig}
63+
iconRotation="left"
64+
onClick={() => setView(LocationCardViews.Default)}
65+
/>
66+
<div className="right">
67+
{screen !== 'error' && (
68+
<Button
69+
text="Auth with OpenID"
70+
variant={ButtonVariant.Primary}
71+
loading={screen === 'polling' || isStarting}
72+
onClick={handleStart}
73+
/>
74+
)}
75+
{screen === 'error' && (
76+
<Button
77+
text="Try again"
78+
variant={ButtonVariant.Primary}
79+
onClick={resetToIdle}
80+
/>
81+
)}
82+
</div>
83+
</Controls>
84+
</div>
85+
);
86+
};
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
.location-card-view-header {
2+
p.error {
3+
color: var(--fg-critical);
4+
}
5+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import './style.scss';
2+
import { QRCodeCanvas } from 'qrcode.react';
3+
4+
interface Props {
5+
value: string;
6+
size?: number;
7+
}
8+
9+
export const QrCard = ({ value, size = 200 }: Props) => {
10+
return (
11+
<div className="qr-code-display">
12+
<QRCodeCanvas value={value} size={size} />
13+
</div>
14+
);
15+
};
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
.qr-code-display {
2+
background-color: var(--bg-white-100);
3+
border-radius: var(--radius-lg);
4+
padding: var(--spacing-md);
5+
}

0 commit comments

Comments
 (0)