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. */
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;
/** set when a method fails after the picker handed off to it, shown back on the picker */
verificationError: string | null;
/** No factor was configured, so an emailed code was the only way in. */
Expand Down Expand Up @@ -139,7 +137,7 @@ interface Store extends StoreValues {
origin: ConfigureMfaOrigin,
) => void;
selectMethods: (methods: MfaMethodValue[]) => void;
selectVerificationMethod: (method: MfaVerificationMethod) => void;
selectVerificationMethod: (method: ClientAuthorizingMethod) => void;
backFromVerification: (error?: string) => void;
/** The fresh deadline bounds every setup still to come, not just the next one. */
authorize: (response: MfaConfigAuthorizeResult) => 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];
12 changes: 6 additions & 6 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,17 +64,17 @@ 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));

/** OpenID opens the browser, so even as the only method it waits for a click on the picker. */
export const showsVerificationPicker = (methods: MfaVerificationMethod[]): boolean =>
export const showsVerificationPicker = (methods: ClientAuthorizingMethod[]): boolean =>
methods.length > 1 || methods[0] === MfaMethod.Oidc;

/** 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 { showsVerificationPicker } from '../utils';
import { ConfigureSelectVerificationStep } from './ConfigureSelectVerificationStep/ConfigureSelectVerificationStep';
import { ConfigureVerifyEmailStep } from './ConfigureVerifyEmailStep/ConfigureVerifyEmailStep';
Expand All @@ -24,7 +24,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 @@ -26,6 +26,7 @@ import {
isClientConfigurableMethod,
isDesktopDrivableMethod,
isMfaMethodAvailable,
isMfaMethodLocked,
mfaStepsOf as locationMfaSteps,
mfaToText,
setupMethodsOf,
Expand All @@ -46,6 +47,8 @@ type MethodRowState = {
badge?: string;
/** Why the row is disabled, shown on hover when the badge alone is too terse. */
hint: string | null;
/** The instance withholds the factor. */
locked: boolean;
};

interface Props {
Expand Down Expand Up @@ -113,10 +116,11 @@ 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;
let badge: string | undefined;
if (!available) {
if (!available || locked) {
hint = `${mfaToText(method, instance ?? undefined)} is not available on this Defguard instance.`;
badge = 'Unavailable';
} else if (disabled && !configured) {
Expand Down Expand Up @@ -145,6 +149,7 @@ export const ConfigureSelectMethodsStep = ({ onCancel }: Props) => {
disabled,
badge,
hint,
locked,
};
},
[accountConfigured, configuredMethods, setupMethods, selected, instance],
Expand Down Expand Up @@ -201,9 +206,11 @@ export const ConfigureSelectMethodsStep = ({ onCancel }: Props) => {
useConfigureMfaStore.getState().selectMethods(selected);
}, [selected, emailFallback, isLocationAware, locationSteps, isStepSatisfied]);

/** Without `explained` the row drops its badge and hint, leaving the reason to its section. */
const renderMethod = (method: MfaMethodValue, explained: boolean) => {
const { checked, disabled, badge, hint } = describeMethod(method);
/** Without `sectionExplained` the row drops its badge and hint, leaving the reason to its section. */
const renderMethod = (method: MfaMethodValue, sectionExplained: boolean) => {
const { checked, disabled, badge, hint, locked } = describeMethod(method);
// The "Not configurable" section text blames version incompatibility, so a withheld row says it.
const explained = sectionExplained || locked;
const row = (
<FactorSelector
key={method}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@ import { FullPageTitle } from '../../../../../shared/components/FullPageTitle/Fu
import { FullPage } from '../../../../../shared/layouts/FullPage/FullPage';
import { ConfigureFactorsSource, MfaMethod } from '../../../../../shared/rust-api/types';
import { isPresent } from '../../../../../shared/utils/isPresent';
import { findOpenIdProvider, openIdProviderName } from '../../../../../shared/utils/mfa';
import {
type ClientAuthorizingMethod,
findOpenIdProvider,
openIdProviderName,
} from '../../../../../shared/utils/mfa';
import { ConfigureMfaVerificatorFactorSelector } from '../../components/ConfigureMfaVerificatorFactorSelector/ConfigureMfaVerificatorFactorSelector';
import {
discardMfaConfiguration,
useConfigureMfaStore,
} from '../../hooks/useConfigureMfaStore';
import type { MfaVerificationMethod } from '../../types';
import '../style.scss';
import './style.scss';

Expand All @@ -33,7 +36,9 @@ export const ConfigureSelectVerificationStep = ({ onCancel }: Props) => {
);
const instance = useConfigureMfaStore((s) => s.instance) ?? undefined;

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

const { mutate: cancel, isPending: isCancelling } = useMutation({
// the source is read before the discard resets the store
Expand Down
26 changes: 26 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,31 @@ 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 @@ -211,14 +211,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 @@ -230,7 +248,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 @@ -154,9 +154,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 @@ -178,6 +175,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.
Loading