diff --git a/new-ui/package.json b/new-ui/package.json index c1cab9a06..ffb5bdeec 100644 --- a/new-ui/package.json +++ b/new-ui/package.json @@ -50,7 +50,7 @@ "remark-gfm": "^4.0.1", "rxjs": "^7.8.2", "sass": "^1.105.1", - "zod": "^4.6.5", + "zod": "^4.6.7", "zustand": "^5.0.15" }, "devDependencies": { diff --git a/new-ui/pnpm-lock.yaml b/new-ui/pnpm-lock.yaml index becdc7524..229654b5d 100644 --- a/new-ui/pnpm-lock.yaml +++ b/new-ui/pnpm-lock.yaml @@ -117,8 +117,8 @@ importers: specifier: ^1.105.1 version: 1.105.1 zod: - specifier: ^4.6.5 - version: 4.6.5 + specifier: ^4.6.7 + version: 4.6.7 zustand: specifier: ^5.0.15 version: 5.0.15(@types/react@19.3.0)(react@19.3.0)(use-sync-external-store@1.7.0(react@19.3.0)) @@ -2453,8 +2453,8 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - zod@4.6.5: - resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + zod@4.6.7: + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} zustand@5.0.15: resolution: {integrity: sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==} @@ -3085,7 +3085,7 @@ snapshots: jiti: 2.7.0 magic-string: 0.30.21 prettier: 3.9.9 - zod: 4.6.5 + zod: 4.6.7 transitivePeerDependencies: - supports-color @@ -3099,7 +3099,7 @@ snapshots: '@tanstack/router-utils': 1.162.3(supports-color@10.2.2) chokidar: 5.0.0 unplugin: 3.4.0(rolldown@1.2.12)(vite@8.3.3(@types/node@26.6.4)(jiti@2.7.0)(sass@1.105.1)) - zod: 4.6.5 + zod: 4.6.7 optionalDependencies: '@tanstack/react-router': 1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0) vite: 8.3.3(@types/node@26.6.4)(jiti@2.7.0)(sass@1.105.1) @@ -4802,7 +4802,7 @@ snapshots: yallist@3.1.1: {} - zod@4.6.5: {} + zod@4.6.7: {} zustand@5.0.15(@types/react@19.3.0)(react@19.3.0)(use-sync-external-store@1.7.0(react@19.3.0)): optionalDependencies: diff --git a/new-ui/src/shared/components/LocationCard/hooks/useMfaMobileConnect.test.ts b/new-ui/src/shared/components/LocationCard/hooks/useMfaMobileConnect.test.ts index 35a2cf7c2..60b8d3327 100644 --- a/new-ui/src/shared/components/LocationCard/hooks/useMfaMobileConnect.test.ts +++ b/new-ui/src/shared/components/LocationCard/hooks/useMfaMobileConnect.test.ts @@ -262,6 +262,43 @@ describe('useMfaMobileConnect', () => { expect(mocks.mfaConnectMobileApprove).toHaveBeenCalledTimes(1); }); + it('shows an upgrade message when the mobile client is outdated', async () => { + mocks.mfaBeginStep.mockResolvedValue({ + challenge: 'challenge-1', + token: 'token-1', + stepAttemptId: 'attempt-1', + }); + mocks.mfaConnectMobileApprove.mockResolvedValue('task-1'); + const onConnected = vi.fn(); + const onStepAdvanced = vi.fn(); + const { result } = renderMobileHook({ onConnected, onStepAdvanced, mfaToken: null }); + + await act(async () => { + await result.current.start(); + }); + await waitFor(() => expect(mocks.listen).toHaveBeenCalledTimes(3)); + expect(result.current.qrValue).not.toBeNull(); + + const errorListener = mocks.listen.mock.calls.find( + ([event]) => event === TauriEvent.MfaMobileError, + )?.[1] as (event: { payload: { error: string } }) => void; + + await act(async () => { + errorListener({ + payload: { error: JSON.stringify({ type: 'mobile_client_outdated' }) }, + }); + }); + + expect(result.current.connectionError).toBe( + 'Your Defguard mobile app is out of date. Update it to approve this connection.', + ); + expect(result.current.qrValue).toBeNull(); + expect(result.current.isConnecting).toBe(false); + expect(mocks.setMfaToken).toHaveBeenLastCalledWith(null); + expect(onConnected).not.toHaveBeenCalled(); + expect(onStepAdvanced).not.toHaveBeenCalled(); + }); + it('retains and reuses the token after a retryable mobile timeout', async () => { mocks.mfaBeginStep .mockResolvedValueOnce({ challenge: 'challenge-1', token: 'token-1' }) diff --git a/new-ui/src/shared/components/LocationCard/hooks/useMfaMobileConnect.ts b/new-ui/src/shared/components/LocationCard/hooks/useMfaMobileConnect.ts index 527c35646..9e138a3ae 100644 --- a/new-ui/src/shared/components/LocationCard/hooks/useMfaMobileConnect.ts +++ b/new-ui/src/shared/components/LocationCard/hooks/useMfaMobileConnect.ts @@ -11,6 +11,7 @@ import { isServiceUnavailable, isTimeout, mfaErrorMessage, + parseMfaError, } from '../../../rust-api/mfaError'; import { getInstancesQueryOptions } from '../../../rust-api/query'; import type { @@ -159,6 +160,10 @@ export const useMfaMobileConnect = ( isTimeout(event.payload.error) || isServiceUnavailable(event.payload.error); setTokenData(null); if (!retryable) setMfaTokenRef.current(null); + if (parseMfaError(event.payload.error)?.type === 'mobile_client_outdated') { + setConnectionError(message); + return; + } setConnectionError( isConnectFailure(message) ? 'Failed to establish VPN connection' diff --git a/new-ui/src/shared/rust-api/mfaError.test.ts b/new-ui/src/shared/rust-api/mfaError.test.ts index 93c8fb416..ca2d5b735 100644 --- a/new-ui/src/shared/rust-api/mfaError.test.ts +++ b/new-ui/src/shared/rust-api/mfaError.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { isInvalidCode } from './mfaError'; +import { isInvalidCode, mfaErrorMessage } from './mfaError'; describe('isInvalidCode', () => { it('matches exact rejected-code messages without case sensitivity', () => { @@ -14,3 +14,18 @@ describe('isInvalidCode', () => { expect(isInvalidCode('invalid token')).toBe(false); }); }); + +describe('mfaErrorMessage', () => { + it('maps the outdated mobile client type to an upgrade message', () => { + expect(mfaErrorMessage(JSON.stringify({ type: 'mobile_client_outdated' }))).toBe( + 'Your Defguard mobile app is out of date. Update it to approve this connection.', + ); + }); + + it('preserves other error messages and plain text', () => { + expect( + mfaErrorMessage(JSON.stringify({ type: 'other', message: 'Existing error' })), + ).toBe('Existing error'); + expect(mfaErrorMessage('Plain error')).toBe('Plain error'); + }); +}); diff --git a/new-ui/src/shared/rust-api/mfaError.ts b/new-ui/src/shared/rust-api/mfaError.ts index fe7237ce0..13d8b59a9 100644 --- a/new-ui/src/shared/rust-api/mfaError.ts +++ b/new-ui/src/shared/rust-api/mfaError.ts @@ -17,9 +17,13 @@ export const parseMfaError = (err: unknown): ParsedMfaError | null => { } }; -/** Returns the error message, or the original error text. */ -export const mfaErrorMessage = (err: unknown): string => - parseMfaError(err)?.message ?? String(err); +export const mfaErrorMessage = (err: unknown): string => { + const parsed = parseMfaError(err); + if (parsed?.type === 'mobile_client_outdated') { + return 'Your Defguard mobile app is out of date. Update it to approve this connection.'; + } + return parsed?.message ?? String(err); +}; /** Returns true when MFA was rejected by the device posture check. */ export const isMfaPostureError = (err: unknown, location: LocationInfo): boolean => diff --git a/nix/package.nix b/nix/package.nix index 478fe1420..1384f845a 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -92,7 +92,7 @@ inherit version pnpm; src = ../new-ui; fetcherVersion = 4; - hash = "sha256-J0TiPTNLr1+KW1j/rYWeQL0Bn+msgHemGUD9OkxVdjA="; + hash = "sha256-x7iOltZ3uWB432vIOC5HkWjzJHEFbzmBgaNY91XJVjQ="; }; # Pre-build the new UI frontend so Tauri can serve it as WebviewUrl::App("compact/") and "full/". diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e37e0c301..38e228f2c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -139,7 +139,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -150,7 +150,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -181,7 +181,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.52.0", "wl-clipboard-rs", "x11rb", ] @@ -1974,7 +1974,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.3", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -2239,7 +2239,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] @@ -3461,7 +3461,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] @@ -3711,7 +3711,7 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a1886916523694cd6ea3d175f03a1e5010699a2a4cc13696d83d7bea1d80638" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -4042,7 +4042,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.21", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4250,7 +4250,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -5419,7 +5419,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] @@ -5850,7 +5850,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] @@ -5909,7 +5909,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] @@ -6378,7 +6378,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7398,7 +7398,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] @@ -7532,9 +7532,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.2" +version = "1.54.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" dependencies = [ "bytes", "libc", @@ -7940,7 +7940,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.21", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7997,7 +7997,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8567,7 +8567,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] @@ -8588,7 +8588,7 @@ dependencies = [ "objc2-foundation", "objc2-quartz-core", "raw-window-handle", - "windows-sys 0.60.2", + "windows-sys 0.61.2", "windows-version", ] @@ -9060,7 +9060,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" dependencies = [ "cfg-if", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/src-tauri/client-cli/src/mfa.rs b/src-tauri/client-cli/src/mfa.rs index b03d01e7c..0fc1c9555 100644 --- a/src-tauri/client-cli/src/mfa.rs +++ b/src-tauri/client-cli/src/mfa.rs @@ -54,6 +54,7 @@ fn into_cli(err: mfa::MfaError) -> CliError { mfa::MfaError::MfaRejected { .. } | mfa::MfaError::PostureRejected { .. } | mfa::MfaError::AttemptLimit { .. } + | mfa::MfaError::MobileClientOutdated | mfa::MfaError::Timeout => CliError::MfaFailed(msg), mfa::MfaError::Cancelled => CliError::Cancelled(msg), } @@ -910,6 +911,13 @@ mod tests { use super::*; + #[test] + fn test_outdated_mobile_client_is_mfa_failure() { + let error = mfa::MfaError::MobileClientOutdated; + let message = error.to_string(); + assert!(matches!(into_cli(error), CliError::MfaFailed(msg) if msg == message)); + } + #[test] fn test_finish_psk_uses_contract_for_completion() { let legacy = mfa::MfaFinishResponse::Legacy { diff --git a/src-tauri/core/src/mfa.rs b/src-tauri/core/src/mfa.rs index fdb4a81a8..f5164e03e 100644 --- a/src-tauri/core/src/mfa.rs +++ b/src-tauri/core/src/mfa.rs @@ -11,9 +11,10 @@ use defguard_client_proto::defguard::{ mfa_flow_step_finish_request::Submission as MfaFlowFinishSubmission, mfa_step_result, mfa_step_started, ClientMfaFinishRequest, ClientMfaFinishResponse, ClientMfaStartRequest, ClientMfaStartResponse, MfaAdvanced, MfaCodeCredential, MfaCompleted, MfaFido2Assertion, - MfaFlowStartRequest, MfaFlowStartResponse, MfaFlowStepFinishRequest, + MfaFlowRemoteRejected, MfaFlowStartRequest, MfaFlowStartResponse, MfaFlowStepFinishRequest, MfaFlowStepFinishResponse, MfaFlowStepStartRequest, MfaFlowStepStartResponse, MfaMethod, - MfaStartRejectionReason, MfaStepRejection, MfaStepResult, MfaStepStarted, + MfaRemoteRejectionReason, MfaStartRejectionReason, MfaStepRejection, MfaStepResult, + MfaStepStarted, }, enterprise::posture::v2::DevicePostureData, }; @@ -71,6 +72,9 @@ pub enum MfaError { #[error("{message}")] AttemptLimit { message: String }, + #[error("The Defguard mobile app must be updated to approve this connection")] + MobileClientOutdated, + #[error("MFA operation timed out")] Timeout, @@ -135,10 +139,10 @@ struct LegacyMobileMfaResponse { } #[derive(Deserialize)] -struct MultiStepMobileMfaResponse { - #[serde(rename = "type")] - kind: String, - result: MultiStepMobileMfaResult, +#[serde(tag = "type", rename_all = "snake_case")] +enum MultiStepMobileMfaResponse { + MfaResult { result: MultiStepMobileMfaResult }, + MfaRejected { rejected: MfaFlowRemoteRejected }, } #[derive(Deserialize)] @@ -157,12 +161,20 @@ fn decode_multi_step_mobile_mfa_frame(text: &str) -> Result result, + MultiStepMobileMfaResponse::MfaRejected { rejected } => { + return Err(match rejected.reason() { + MfaRemoteRejectionReason::MfaRemoteRejectionMobileClientOutdated => { + MfaError::MobileClientOutdated + } + _ => MfaError::MfaRejected { + message: "Mobile approval rejected".into(), + }, + }); + } + }; + let outcome = match result.outcome { MultiStepMobileMfaOutcome::Advanced(advanced) => { mfa_step_result::Outcome::Advanced(advanced) } diff --git a/src-tauri/core/src/mfa/tests.rs b/src-tauri/core/src/mfa/tests.rs index ab9fff7e0..61a91297b 100644 --- a/src-tauri/core/src/mfa/tests.rs +++ b/src-tauri/core/src/mfa/tests.rs @@ -1133,6 +1133,31 @@ async fn test_mobile_approve_multi_step_rejects_wrong_discriminator() { )); } +#[tokio::test] +async fn test_mobile_approve_outdated_client_rejection() { + let error = multi_step_mobile_approve_error(WsStubCommand::SendMessage( + r#"{"type":"mfa_rejected","rejected":{"reason":1}}"#.into(), + )) + .await; + + assert!(matches!(error, MfaError::MobileClientOutdated)); + assert_eq!( + serde_json::to_value(error).unwrap(), + json!({"type": "mobile_client_outdated"}) + ); +} + +#[tokio::test] +async fn test_mobile_approve_unknown_rejection_reason() { + assert!(matches!( + multi_step_mobile_approve_error(WsStubCommand::SendMessage( + r#"{"type":"mfa_rejected","rejected":{"reason":99}}"#.into() + )) + .await, + MfaError::MfaRejected { .. } + )); +} + #[tokio::test] async fn test_mobile_approve_multi_step_rejects_missing_outcome() { assert!(matches!( diff --git a/src-tauri/proto b/src-tauri/proto index 9c2bafb60..2a39d0a09 160000 --- a/src-tauri/proto +++ b/src-tauri/proto @@ -1 +1 @@ -Subproject commit 9c2bafb6045cde3d465c5658a7615d4f376c1f47 +Subproject commit 2a39d0a09ebed4fe3ea08f1b54be5259ec63bed6 diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 880ee56f1..199815a8b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -226,6 +226,7 @@ fn mfa_error_is_terminal(error: &mfa::MfaError) -> bool { | mfa::MfaError::AttemptLimit { .. } | mfa::MfaError::PostureRejected { .. } | mfa::MfaError::MfaRejected { .. } + | mfa::MfaError::MobileClientOutdated | mfa::MfaError::Other { .. } => true, mfa::MfaError::NetworkError { .. } | mfa::MfaError::ProxyError { .. } @@ -3476,6 +3477,7 @@ mod tests { #[test] fn test_mfa_error_cleanup_keeps_retryable_errors() { + assert!(mfa_error_is_terminal(&mfa::MfaError::MobileClientOutdated)); assert!(mfa_error_is_terminal(&mfa::MfaError::Cancelled)); assert!(mfa_error_is_terminal(&mfa::MfaError::AttemptLimit { message: "attempt limit reached".into(),