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
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,12 @@ import {
type MfaMethodValue,
} from '../../../../shared/rust-api/types';
import { isPresent } from '../../../../shared/utils/isPresent';
import { setupMethodsOf } from '../../../../shared/utils/mfa';
import { dismissEdgeComsError } from '../components/EdgeComsError/useEdgeComsErrorStore';
import {
ConfigureMfaStep,
type ConfigureMfaStepValue,
MFA_WIZARD_STEPS,
type MfaVerificationMethod,
} from '../types';
type ClientAuthorizingMethod,
setupMethodsOf,
} from '../../../../shared/utils/mfa';
import { dismissEdgeComsError } from '../components/EdgeComsError/useEdgeComsErrorStore';
import { ConfigureMfaStep, type ConfigureMfaStepValue, MFA_WIZARD_STEPS } from '../types';
import {
isCodeMfaMethod,
isMfaFactorOfferable,
Expand All @@ -45,9 +43,9 @@ type StoreValues = {
/** Pre-ticked in the selection step, from the entry point or an earlier pass. */
initialSelection: MfaMethodValue[];
/** most preferred first, from the session since an older Core rejects some factors */
verificationMethods: MfaVerificationMethod[];
verificationMethods: ClientAuthorizingMethod[];
/** null until picked, asked only when the session offers more than one method */
verificationMethod: MfaVerificationMethod | null;
verificationMethod: ClientAuthorizingMethod | null;
/** No factor was configured, so an emailed code was the only way in. */
emailFallback: boolean;
/** ISO timestamp the session dies at, null once nothing in the flow needs it. */
Expand Down Expand Up @@ -136,7 +134,7 @@ interface Store extends StoreValues {
origin: ConfigureMfaOrigin,
) => void;
selectMethods: (methods: MfaMethodValue[]) => void;
selectVerificationMethod: (method: MfaVerificationMethod) => void;
selectVerificationMethod: (method: ClientAuthorizingMethod) => void;
/** Keeps the current picks ticked and the session alive. */
backToSelection: () => void;
backFromVerification: () => void;
Expand Down
12 changes: 1 addition & 11 deletions new-ui/src/pages/full/ConfigureMfaPage/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { MfaMethod, type MfaMethodValue } from '../../../shared/rust-api/types';
import type { MfaMethodValue } from '../../../shared/rust-api/types';

/** Wizard steps, in the order they run. Setup steps are the ones a factor can claim. */
export const ConfigureMfaStep = {
Expand All @@ -25,13 +25,3 @@ export type MfaFactor = {
/** Whether the account can hold several of these, so it stays offerable once configured. */
repeatable: boolean;
};

/** most preferred first */
export const mfaVerificationMethods = [
MfaMethod.Totp,
MfaMethod.Email,
MfaMethod.Fido2,
MfaMethod.Oidc,
] as const;

export type MfaVerificationMethod = (typeof mfaVerificationMethods)[number];
10 changes: 5 additions & 5 deletions new-ui/src/pages/full/ConfigureMfaPage/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@ import {
type MfaMethodValue,
} from '../../../shared/rust-api/types';
import {
CLIENT_AUTHORIZING_METHODS,
CLIENT_CONFIGURABLE_METHODS,
type ClientAuthorizingMethod,
type ClientConfigurableMethod,
} from '../../../shared/utils/mfa';
import {
ConfigureMfaStep,
type ConfigureMfaStepValue,
MFA_WIZARD_STEPS,
type MfaFactor,
type MfaVerificationMethod,
mfaVerificationMethods,
} from './types';

/** Keyed on the shared list, so a new factor fails to compile until mapped here. */
Expand Down Expand Up @@ -64,13 +64,13 @@ export const isCodeMfaMethod = (method: MfaMethodValue): boolean =>
/** ordered by preference, not by the input */
export const verificationMethodsOf = (
availableMethods: MfaMethodValue[],
): MfaVerificationMethod[] =>
mfaVerificationMethods.filter((method) => availableMethods.includes(method));
): ClientAuthorizingMethod[] =>
CLIENT_AUTHORIZING_METHODS.filter((method) => availableMethods.includes(method));

/** The fallback mails a code to the address on file, so email is the only way in. */
export const sessionVerificationMethodsOf = (
response: Pick<MfaConfigStartResult, 'email_fallback' | 'available_methods'>,
): MfaVerificationMethod[] =>
): ClientAuthorizingMethod[] =>
verificationMethodsOf(
response.email_fallback ? [MfaMethod.Email] : response.available_methods,
);
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { MfaMethod } from '../../../../shared/rust-api/types';
import { isPresent } from '../../../../shared/utils/isPresent';
import type { ClientAuthorizingMethod } from '../../../../shared/utils/mfa';
import { useConfigureMfaStore } from '../hooks/useConfigureMfaStore';
import type { MfaVerificationMethod } from '../types';
import { ConfigureSelectMethodsStep } from './ConfigureSelectMethodsStep/ConfigureSelectMethodsStep';
import { ConfigureSelectVerificationStep } from './ConfigureSelectVerificationStep/ConfigureSelectVerificationStep';
import { ConfigureVerifyEmailStep } from './ConfigureVerifyEmailStep/ConfigureVerifyEmailStep';
Expand Down Expand Up @@ -29,7 +29,7 @@ export const ConfigureMfaVerify = ({ onCancel, onSessionExpired }: Props) => {
}

// for the type only, a session with nothing to verify with is refused at start
const method: MfaVerificationMethod =
const method: ClientAuthorizingMethod =
verificationMethod ?? candidates[0] ?? MfaMethod.Email;

switch (method) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
isClientConfigurableMethod,
isDesktopDrivableMethod,
isMfaMethodAvailable,
isMfaMethodLocked,
mfaStepsOf as locationMfaSteps,
mfaToText,
setupMethodsOf,
Expand Down Expand Up @@ -103,9 +104,10 @@ export const ConfigureSelectMethodsStep = ({ onCancel }: Props) => {
const configured = accountConfigured.includes(method);
const available = isMfaMethodAvailable(method, instance);
const satisfied = configuredMethods.includes(method) && available;
const locked = isMfaMethodLocked(method, instance);

let hint: string | null = null;
if (!available) {
if (!available || locked) {
hint = `${mfaToText(method, instance ?? undefined)} is not available on this Defguard instance.`;
} else if (disabled && !configured) {
if (satisfied) {
Expand All @@ -128,6 +130,7 @@ export const ConfigureSelectMethodsStep = ({ onCancel }: Props) => {
disabled,
configured,
hint,
locked,
};
},
[accountConfigured, configuredMethods, setupMethods, selected, instance],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export type MethodRowState = {
configured: boolean;
/** Why the row is disabled, when a badge does not already say it. */
hint: string | null;
/** The instance withholds the factor, so the hint shows a lock. */
locked: boolean;
};

interface Props extends MethodRowState {
Expand All @@ -31,6 +33,7 @@ export const MethodRow = ({
disabled,
configured,
hint,
locked,
onToggle,
}: Props) => (
<div className="method-row">
Expand All @@ -52,7 +55,11 @@ export const MethodRow = ({
{isPresent(hint) && (
<TooltipProvider>
<TooltipTrigger>
<Icon icon="question" size={20} staticColor={ThemeVariable.BgWhite50} />
<Icon
icon={locked ? 'lock-closed' : 'question'}
size={20}
staticColor={ThemeVariable.BgWhite50}
/>
</TooltipTrigger>
<TooltipContent>
<p>{hint}</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,19 @@ import { ButtonVariant } from '../../../../../shared/components/Button/types';
import { Controls } from '../../../../../shared/components/Controls/Controls';
import { FullPageTitle } from '../../../../../shared/components/FullPageTitle/FullPageTitle';
import { FullPage } from '../../../../../shared/layouts/FullPage/FullPage';
import type { ClientAuthorizingMethod } from '../../../../../shared/utils/mfa';
import { ConfigureMfaVerificatorFactorSelector } from '../../components/ConfigureMfaVerificatorFactorSelector/ConfigureMfaVerificatorFactorSelector';
import { useConfigureMfaStore } from '../../hooks/useConfigureMfaStore';
import type { MfaVerificationMethod } from '../../types';
import '../style.scss';
import './style.scss';

/** shown only when the session offers more than one method */
export const ConfigureSelectVerificationStep = () => {
const methods = useConfigureMfaStore((s) => s.verificationMethods);

const [selected, setSelected] = useState<MfaVerificationMethod | undefined>(methods[0]);
const [selected, setSelected] = useState<ClientAuthorizingMethod | undefined>(
methods[0],
);

return (
<FullPage
Expand Down
28 changes: 28 additions & 0 deletions new-ui/src/shared/utils/mfa.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
connectionAbilityOf,
hasMfaMethodChoice,
isMfaMethodAvailable,
isMfaMethodLocked,
mfaConfigurableInstances,
resolveMfaStepPlan,
setupMethodsOf,
Expand Down Expand Up @@ -122,6 +123,33 @@ describe('setupMethodsOf', () => {
});
});

describe('isMfaMethodLocked', () => {
it('locks a client-configurable method Core leaves out of the setup methods', () => {
const instance = {
mfa_capabilities: capabilitiesOf([MfaMethod.Totp, MfaMethod.Email]),
};
expect(isMfaMethodLocked(MfaMethod.Fido2, instance)).toBe(true);
expect(isMfaMethodLocked(MfaMethod.Totp, instance)).toBe(false);
expect(isMfaMethodLocked(MfaMethod.Oidc, instance)).toBe(false);

const withoutTotp = {
mfa_capabilities: capabilitiesOf([MfaMethod.Email, MfaMethod.Fido2]),
};
expect(isMfaMethodLocked(MfaMethod.Totp, withoutTotp)).toBe(true);
});

it('does not lock FIDO2 when Core offers it or cannot configure from the client', () => {
const offered = {
mfa_capabilities: capabilitiesOf([MfaMethod.Totp, MfaMethod.Fido2]),
};
expect(isMfaMethodLocked(MfaMethod.Fido2, offered)).toBe(false);
expect(isMfaMethodLocked(MfaMethod.Fido2, { mfa_capabilities: null })).toBe(
false,
);
expect(isMfaMethodLocked(MfaMethod.Fido2, undefined)).toBe(false);
});
});

describe('canSetUpMfaMethod', () => {
it('needs both the client and the instance', () => {
const instance = {
Expand Down
28 changes: 23 additions & 5 deletions new-ui/src/shared/utils/mfa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,14 +208,32 @@ export const canSetUpMfaMethod = (
): method is ClientConfigurableMethod =>
setupMethodsOf(instance).some((candidate) => candidate === method);

/** What this client can authorize an MFA configuration session with. Mirrors
* AUTHORIZING_METHODS in core mfa_config.rs. */
const clientAuthorizingMethods: MfaMethodValue[] = [
/**
* Every Core that reports capabilities knows all client-configurable methods, so one it leaves out
* of the setup methods is withheld from this instance.
*/
export const isMfaMethodLocked = (
method: MfaMethodValue,
instance?: Pick<InstanceInfo, 'mfa_capabilities'> | null,
): boolean => {
const coreSetupMethods = instance?.mfa_capabilities?.setup_methods;
return (
isClientConfigurableMethod(method) &&
isPresent(coreSetupMethods) &&
!coreSetupMethods.includes(method)
);
};

/** What this client can authorize an MFA configuration session with, most preferred first.
* Mirrors AUTHORIZING_METHODS in core mfa_config.rs. */
export const CLIENT_AUTHORIZING_METHODS = [
MfaMethod.Totp,
MfaMethod.Email,
MfaMethod.Fido2,
MfaMethod.Oidc,
];
] as const;

export type ClientAuthorizingMethod = (typeof CLIENT_AUTHORIZING_METHODS)[number];

export type MfaConfigInstance = Pick<InstanceInfo, 'mfa_configured_methods'> &
MfaCapabilitiesInstance;
Expand All @@ -227,7 +245,7 @@ export type MfaConfigInstance = Pick<InstanceInfo, 'mfa_configured_methods'> &
export const canAuthorizeMfaConfig = (instance?: MfaConfigInstance | null): boolean => {
const authorizeMethods = instance?.mfa_capabilities?.authorize_methods ?? [];
const configuredMethods = instance?.mfa_configured_methods ?? [];
const holdsAuthorizer = clientAuthorizingMethods.some(
const holdsAuthorizer = CLIENT_AUTHORIZING_METHODS.some(
(method) =>
authorizeMethods.includes(method) &&
configuredMethods.includes(method) &&
Expand Down
4 changes: 2 additions & 2 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[workspace]
members = ["cli", "client-cli", "common", "client-proto", "core", "daemon", "fido2", "enterprise/posture", "enterprise/provisioning", "enterprise/config-sync", "enterprise/service-locations"]
members = ["cli", "client-cli", "common", "client-proto", "core", "daemon", "enterprise/fido2", "enterprise/posture", "enterprise/provisioning", "enterprise/config-sync", "enterprise/service-locations"]
default-members = ["cli", "client-cli", "daemon", "."]

[workspace.dependencies]
Expand Down Expand Up @@ -131,7 +131,7 @@ redundant_closure = "warn"
base64.workspace = true
chrono.workspace = true
defguard-cli.path = "client-cli"
defguard-client-fido2.path = "fido2"
defguard-client-fido2.path = "enterprise/fido2"
defguard-client-proto.path = "client-proto"
defguard-client-core.path = "core"
defguard-client-posture.path = "enterprise/posture"
Expand Down
6 changes: 3 additions & 3 deletions src-tauri/deny.toml
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,6 @@ exceptions = [
{ allow = [
"AGPL-3.0-or-later",
], crate = "defguard-client-core" },
{ allow = [
"AGPL-3.0-or-later",
], crate = "defguard-client-fido2" },
{ allow = [
"AGPL-3.0-or-later",
], crate = "defguard-client-service" },
Expand All @@ -168,6 +165,9 @@ exceptions = [
{ allow = [
"LicenseRef-Proprietary",
], crate = "defguard-client-service-locations" },
{ allow = [
"LicenseRef-Proprietary",
], crate = "defguard-client-fido2" },
]

# Some crates don't have (easily) machine readable licensing information,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ description = "FIDO2 security key ceremonies for the Defguard desktop client (Ta
authors.workspace = true
edition.workspace = true
homepage.workspace = true
license-file.workspace = true
license = "LicenseRef-Proprietary"
rust-version.workspace = true
version.workspace = true

Expand Down
File renamed without changes.
File renamed without changes.