Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions new-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"motion": "^12.38.0",
"p-timeout": "^7.0.1",
"prettier": "^3.8.3",
"qrcode.react": "^4.2.0",
"radashi": "^12.9.1",
"react": "^19.2.6",
"react-chartjs-2": "^5.3.1",
Expand Down
12 changes: 12 additions & 0 deletions new-ui/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion new-ui/src/shared/components/LocationCard/LocationCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { LocationCardViews, type LocationCardViewsValue } from './context/types'
import { ConnectedView } from './views/ConnectedView/ConnectedView';
import { DefaultView } from './views/DefaultView/DefaultView';
import { LocationCardMfaEmailView } from './views/LocationCardMfaEmailView/LocationCardMfaEmailView';
import { LocationCardMfaOidcView } from './views/LocationCardMfaOidcView/LocationCardMfaOidcView';
import { LocationCardMfaSettings } from './views/LocationCardMfaSettings/LocationCardMfaSettings';
import { LocationCardMfaTotpView } from './views/LocationCardMfaTotpView/LocationCardMfaTotpView';

Expand All @@ -32,7 +33,7 @@ const views: Record<LocationCardViewsValue, ReactNode> = {
[LocationCardViews.Default]: <DefaultView />,
[LocationCardViews.MfaTotp]: <LocationCardMfaTotpView />,
[LocationCardViews.MfaEmail]: <LocationCardMfaEmailView />,
[LocationCardViews.MfaOidc]: null,
[LocationCardViews.MfaOidc]: <LocationCardMfaOidcView />,
[LocationCardViews.MfaMobile]: null,
[LocationCardViews.MfaSettings]: <LocationCardMfaSettings />,
[LocationCardViews.Connecting]: null,
Expand Down
169 changes: 169 additions & 0 deletions new-ui/src/shared/components/LocationCard/hooks/useMfaOidcConnect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { fetch } from '@tauri-apps/plugin-http';
import { error } from '@tauri-apps/plugin-log';
import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '../../../rust-api/api';
import { getInstancesQueryOptions } from '../../../rust-api/query';
import { useLocationCardContext } from '../context/context';
import { LocationCardViews } from '../context/types';

const MFA_ENDPOINT = 'api/v1/client-mfa';
const POLL_INTERVAL_MS = 5_000;
const POLL_TIMEOUT_MS = 5 * 60 * 1_000; // 5 minutes

type MfaStartResponse = { token: string };
type MfaFinishResponse = { preshared_key: string };
type MfaErrorResponse = { error: string };

export const useMfaOidcConnect = () => {
const { location, setView } = useLocationCardContext();

const [isStarting, setIsStarting] = useState(false);
const [startError, setStartError] = useState<string | null>(null);
const [isPolling, setIsPolling] = useState(false);
const [pollError, setPollError] = useState<string | null>(null);

const { data: instances } = useQuery(getInstancesQueryOptions);
const instance = instances?.find((i) => i.id === location.instance_id);

const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const { mutate: connectMutate } = useMutation({
mutationFn: api.connect,
onSuccess: () => {
setView(LocationCardViews.Connected);
},
onError: (err) => {
error(`Connect command failed after successful OIDC MFA\n${err}`);
setPollError('Failed to establish VPN connection');
},
});

const stopPolling = useCallback(() => {
if (intervalRef.current !== null) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}, []);

// Clean up on unmount
useEffect(() => {
return () => {
stopPolling();
};
}, [stopPolling]);

const startPolling = useCallback(
(token: string, proxyUrl: string, headers: Record<string, string>) => {
setIsPolling(true);
setPollError(null);

const poll = async () => {
try {
const res = await fetch(`${proxyUrl}${MFA_ENDPOINT}/finish`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify({ token }),
});

if (res.ok) {
stopPolling();
setIsPolling(false);
const data = (await res.json()) as MfaFinishResponse;
connectMutate({
locationId: location.id,
connectionType: location.connection_type,
presharedKey: data.preshared_key,
});
return;
}

// 428 Precondition Required: user hasn't completed browser auth yet, keep polling
if (res.status === 428) return;

// Any other error: stop polling and surface the error
stopPolling();
setIsPolling(false);
const data = (await res.json()) as MfaErrorResponse;
const msg = data.error;
if (msg === 'invalid token' || msg === 'login session not found') {
setPollError('Session expired. Please try again.');
} else {
setPollError('Authentication failed. Please try again.');
}
error(`OIDC MFA poll failed for location ${location.id}: ${msg}`);
} catch (e) {
stopPolling();
setIsPolling(false);
setPollError('Failed to reach server. Please try again.');
error(`OIDC MFA poll network error for location ${location.id}: ${e}`);
}
};

intervalRef.current = setInterval(poll, POLL_INTERVAL_MS);

timeoutRef.current = setTimeout(() => {
stopPolling();
setIsPolling(false);
setPollError('Authentication timed out. Please try again.');
error(`OIDC MFA timed out for location ${location.id}`);
}, POLL_TIMEOUT_MS);
},
[location, connectMutate, stopPolling],
);

const start = useCallback(async () => {
if (!instance) {
setStartError('Instance not found');
return;
}

setIsStarting(true);
setStartError(null);
setPollError(null);
stopPolling();

let headers: Record<string, string>;
try {
headers = await api.getEdgeRequestHeaders();
} catch {
setStartError('Failed to load request headers');
setIsStarting(false);
return;
}

try {
const res = await fetch(`${instance.proxy_url}${MFA_ENDPOINT}/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify({
method: 2,
pubkey: instance.pubkey,
location_id: location.network_id,
}),
});

if (res.ok) {
const data = (await res.json()) as MfaStartResponse;
await api.openLink(`${instance.proxy_url}openid/mfa?token=${data.token}`);
startPolling(data.token, instance.proxy_url, headers);
} else {
const data = (await res.json()) as MfaErrorResponse;
setStartError(data.error ?? 'Failed to start OIDC authentication');
error(`OIDC MFA start failed for location ${location.id}: ${data.error}`);
}
} catch (e) {
setStartError('Failed to reach server');
error(`OIDC MFA start network error for location ${location.id}: ${e}`);
} finally {
setIsStarting(false);
}
}, [instance, location, startPolling, stopPolling]);

return { start, isStarting, startError, isPolling, pollError };
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import './style.scss';
import { useEffect, useState } from 'react';
import { ThemeSpacing } from '../../../../types';
import { Button } from '../../../Button/Button';
import { ButtonVariant } from '../../../Button/types';
import { Controls } from '../../../Controls/Controls';
import { Divider } from '../../../Divider/Divider';
import { IconKind } from '../../../Icon';
import { IconButton } from '../../../IconButton/IconButton';
import { IconButtonVariant } from '../../../IconButton/types';
import { LocationViewHeader } from '../../components/LocationViewHeader/LocationViewHeader';
import { useLocationCardContext } from '../../context/context';
import { LocationCardViews } from '../../context/types';
import { useMfaOidcConnect } from '../../hooks/useMfaOidcConnect';

type Screen = 'idle' | 'polling' | 'error';

export const LocationCardMfaOidcView = () => {
const { setView } = useLocationCardContext();
const { start, isStarting, startError, isPolling, pollError } = useMfaOidcConnect();
const [screen, setScreen] = useState<Screen>('idle');

useEffect(() => {
if (startError ?? pollError) {
setScreen((prev) => (prev !== 'idle' ? 'error' : prev));
} else if (isPolling) {
setScreen('polling');
}
}, [startError, pollError, isPolling]);

const handleStart = async () => {
await start();
setScreen('polling');
};

const errorMessage = startError ?? pollError;

const resetToIdle = () => {
setScreen('idle');
};

return (
<div className="location-card-mfa-oidc">
<Divider spacing={ThemeSpacing.Md} />
<LocationViewHeader title="Two-factor authentication">
{screen === 'idle' && (
<p>
To connect to the VPN, authenticate via your OpenID provider. A browser window
will open for you to sign in.
</p>
)}
{screen === 'polling' && (
<p>
{`Complete the sign-in in your browser. This page will update automatically.`}
</p>
)}
{screen === 'error' && <p className="error">{errorMessage}</p>}
</LocationViewHeader>
<Controls>
<IconButton
variant={IconButtonVariant.BigSelected}
icon={IconKind.ArrowBig}
iconRotation="left"
onClick={() => setView(LocationCardViews.Default)}
/>
<div className="right">
{screen !== 'error' && (
<Button
text="Auth with OpenID"
variant={ButtonVariant.Primary}
loading={screen === 'polling' || isStarting}
onClick={handleStart}
/>
)}
{screen === 'error' && (
<Button
text="Try again"
variant={ButtonVariant.Primary}
onClick={resetToIdle}
/>
)}
</div>
</Controls>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.location-card-view-header {
p.error {
color: var(--fg-critical);
}
}
15 changes: 15 additions & 0 deletions new-ui/src/shared/components/QrCard/QrCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import './style.scss';
import { QRCodeCanvas } from 'qrcode.react';

interface Props {
value: string;
size?: number;
}

export const QrCard = ({ value, size = 200 }: Props) => {
return (
<div className="qr-code-display">
<QRCodeCanvas value={value} size={size} />
</div>
);
};
5 changes: 5 additions & 0 deletions new-ui/src/shared/components/QrCard/style.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.qr-code-display {
background-color: var(--bg-white-100);
border-radius: var(--radius-lg);
padding: var(--spacing-md);
}
Loading