diff --git a/new-ui/src/pages/full/ConfigureMfaPage/hooks/useConfigureMfaStore.tsx b/new-ui/src/pages/full/ConfigureMfaPage/hooks/useConfigureMfaStore.tsx index 5b74909d1..51c1ecd8d 100644 --- a/new-ui/src/pages/full/ConfigureMfaPage/hooks/useConfigureMfaStore.tsx +++ b/new-ui/src/pages/full/ConfigureMfaPage/hooks/useConfigureMfaStore.tsx @@ -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, @@ -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. */ @@ -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; diff --git a/new-ui/src/pages/full/ConfigureMfaPage/types.ts b/new-ui/src/pages/full/ConfigureMfaPage/types.ts index 3be9ef2ba..7f6ac7614 100644 --- a/new-ui/src/pages/full/ConfigureMfaPage/types.ts +++ b/new-ui/src/pages/full/ConfigureMfaPage/types.ts @@ -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 = { @@ -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]; diff --git a/new-ui/src/pages/full/ConfigureMfaPage/utils.ts b/new-ui/src/pages/full/ConfigureMfaPage/utils.ts index 36141ad1a..06ff09628 100644 --- a/new-ui/src/pages/full/ConfigureMfaPage/utils.ts +++ b/new-ui/src/pages/full/ConfigureMfaPage/utils.ts @@ -4,7 +4,9 @@ import { type MfaMethodValue, } from '../../../shared/rust-api/types'; import { + CLIENT_AUTHORIZING_METHODS, CLIENT_CONFIGURABLE_METHODS, + type ClientAuthorizingMethod, type ClientConfigurableMethod, } from '../../../shared/utils/mfa'; import { @@ -12,8 +14,6 @@ import { 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. */ @@ -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, -): MfaVerificationMethod[] => +): ClientAuthorizingMethod[] => verificationMethodsOf( response.email_fallback ? [MfaMethod.Email] : response.available_methods, ); diff --git a/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureMfaVerify.tsx b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureMfaVerify.tsx index 5a27b4e7b..1780f3779 100644 --- a/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureMfaVerify.tsx +++ b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureMfaVerify.tsx @@ -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'; @@ -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) { diff --git a/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureSelectMethodsStep/ConfigureSelectMethodsStep.tsx b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureSelectMethodsStep/ConfigureSelectMethodsStep.tsx index 0c7ef3bcf..fe0eaa9a5 100644 --- a/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureSelectMethodsStep/ConfigureSelectMethodsStep.tsx +++ b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureSelectMethodsStep/ConfigureSelectMethodsStep.tsx @@ -26,6 +26,7 @@ import { isClientConfigurableMethod, isDesktopDrivableMethod, isMfaMethodAvailable, + isMfaMethodLocked, mfaStepsOf as locationMfaSteps, mfaToText, setupMethodsOf, @@ -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 { @@ -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) { @@ -145,6 +149,7 @@ export const ConfigureSelectMethodsStep = ({ onCancel }: Props) => { disabled, badge, hint, + locked, }; }, [accountConfigured, configuredMethods, setupMethods, selected, instance], @@ -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 = ( { ); const instance = useConfigureMfaStore((s) => s.instance) ?? undefined; - const [selected, setSelected] = useState(methods[0]); + const [selected, setSelected] = useState( + methods[0], + ); const { mutate: cancel, isPending: isCancelling } = useMutation({ // the source is read before the discard resets the store diff --git a/new-ui/src/shared/utils/mfa.test.ts b/new-ui/src/shared/utils/mfa.test.ts index 89d453ed2..2054ab9a6 100644 --- a/new-ui/src/shared/utils/mfa.test.ts +++ b/new-ui/src/shared/utils/mfa.test.ts @@ -15,6 +15,7 @@ import { connectionAbilityOf, hasMfaMethodChoice, isMfaMethodAvailable, + isMfaMethodLocked, mfaConfigurableInstances, resolveMfaStepPlan, setupMethodsOf, @@ -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 = { diff --git a/new-ui/src/shared/utils/mfa.ts b/new-ui/src/shared/utils/mfa.ts index 3bd0b3f01..0942476c3 100644 --- a/new-ui/src/shared/utils/mfa.ts +++ b/new-ui/src/shared/utils/mfa.ts @@ -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 | 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 & MfaCapabilitiesInstance; @@ -230,7 +248,7 @@ export type MfaConfigInstance = Pick & 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) && diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 08dd3c8a5..19d7b1805 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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] @@ -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" diff --git a/src-tauri/deny.toml b/src-tauri/deny.toml index 80d8ceb8f..5258451e8 100644 --- a/src-tauri/deny.toml +++ b/src-tauri/deny.toml @@ -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" }, @@ -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, diff --git a/src-tauri/fido2/Cargo.toml b/src-tauri/enterprise/fido2/Cargo.toml similarity index 97% rename from src-tauri/fido2/Cargo.toml rename to src-tauri/enterprise/fido2/Cargo.toml index d37be7abc..1ff3cf7ef 100644 --- a/src-tauri/fido2/Cargo.toml +++ b/src-tauri/enterprise/fido2/Cargo.toml @@ -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 diff --git a/src-tauri/fido2/src/hid.rs b/src-tauri/enterprise/fido2/src/hid.rs similarity index 100% rename from src-tauri/fido2/src/hid.rs rename to src-tauri/enterprise/fido2/src/hid.rs diff --git a/src-tauri/fido2/src/lib.rs b/src-tauri/enterprise/fido2/src/lib.rs similarity index 100% rename from src-tauri/fido2/src/lib.rs rename to src-tauri/enterprise/fido2/src/lib.rs diff --git a/src-tauri/fido2/src/protocol.rs b/src-tauri/enterprise/fido2/src/protocol.rs similarity index 100% rename from src-tauri/fido2/src/protocol.rs rename to src-tauri/enterprise/fido2/src/protocol.rs diff --git a/src-tauri/fido2/src/protocol/tests.rs b/src-tauri/enterprise/fido2/src/protocol/tests.rs similarity index 100% rename from src-tauri/fido2/src/protocol/tests.rs rename to src-tauri/enterprise/fido2/src/protocol/tests.rs diff --git a/src-tauri/fido2/src/windows/api.rs b/src-tauri/enterprise/fido2/src/windows/api.rs similarity index 100% rename from src-tauri/fido2/src/windows/api.rs rename to src-tauri/enterprise/fido2/src/windows/api.rs diff --git a/src-tauri/fido2/src/windows/convert.rs b/src-tauri/enterprise/fido2/src/windows/convert.rs similarity index 100% rename from src-tauri/fido2/src/windows/convert.rs rename to src-tauri/enterprise/fido2/src/windows/convert.rs diff --git a/src-tauri/fido2/src/windows/mod.rs b/src-tauri/enterprise/fido2/src/windows/mod.rs similarity index 100% rename from src-tauri/fido2/src/windows/mod.rs rename to src-tauri/enterprise/fido2/src/windows/mod.rs diff --git a/src-tauri/fido2/src/windows/options.rs b/src-tauri/enterprise/fido2/src/windows/options.rs similarity index 100% rename from src-tauri/fido2/src/windows/options.rs rename to src-tauri/enterprise/fido2/src/windows/options.rs