From d06711172f1aa16ce676cf469f1f4b885744a665 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 11 Aug 2026 08:43:06 +0200 Subject: [PATCH 1/7] add client types to support multi-step MFA --- common/client_types.proto | 106 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 102 insertions(+), 4 deletions(-) diff --git a/common/client_types.proto b/common/client_types.proto index c2870f9..af60e8d 100644 --- a/common/client_types.proto +++ b/common/client_types.proto @@ -128,10 +128,20 @@ message DeviceConfig { // DEPRECATED(1.5): superseded by location_mfa_mode bool mfa_enabled = 9 [deprecated = true]; int32 keepalive_interval = 10; - optional LocationMfaMode location_mfa_mode = 11; + // DEPRECATED(2.2): superseded by steps (field 14). + // Populated server-side only for backward-compatible locations + // (single-flow, single-step, full method set). + // Omitted (along with the location) when incompatible. + optional LocationMfaMode location_mfa_mode = 11 [deprecated = true]; optional ServiceLocationMode service_location_mode = 12; // added for 2.1 optional bool posture_check_required = 13; + // [2.2] Resolved MFA flow for this location+user. + // The server resolves group membership at config-build time and delivers + // a single effective MfaStep list per location. Every MfaStepMethod.configured + // already reflects whether THIS user has that method set up — no intersection + // with InstanceInfo.mfa_user_state is needed. + repeated MfaStep steps = 14; } enum ClientTrafficPolicy { @@ -156,6 +166,11 @@ message InstanceInfo { // client and CLI hide, block, and disconnect bare WireGuard tunnels // (OR-across-instances semantics). optional bool disable_tunnels = 10; + // [2.2] Lightweight summary of the enrolled user's configured MFA methods. + // Used for quick checks (e.g. "does this user have any MFA methods?"). + // The client does NOT intersect this with DeviceConfig.steps — the resolved + // flow in DeviceConfig already carries per-user configured flags. + MfaUserState mfa_user_state = 11; } message DeviceConfigResponse { @@ -201,15 +216,29 @@ enum MfaMethod { message ClientMfaStartRequest { int64 location_id = 1; string pubkey = 2; - MfaMethod method = 3; + // Legacy single-step path: set on its own to trigger the fused Start + + // StepStart adapter for pre-2.2.0 clients. Must not be set together with + // selected_methods. + optional MfaMethod method = 3; // [2.1] Required when the location has posture policies assigned. optional defguard.enterprise.posture.v2.DevicePostureData posture_data = 4; + // [2.2] Multi-step path: the client's full per-step method plan, one entry + // per step in flow order — index i is the chosen method for step i. + // Must contain exactly one entry per step; a mismatched length is rejected + // with WRONG_PLAN_LENGTH. Non-empty triggers the multi-step Start flow. + repeated MfaMethod selected_methods = 5; } message ClientMfaStartResponse { string token = 1; - // for biometric mfa method + // for biometric mfa method (legacy fused path only) optional string challenge = 2; + // [2.2] The flow descriptor with per-user configured annotations set. + // Returned on success as a safety net for stale DeviceConfig. + repeated MfaStep steps = 3; + // [2.2] Structured rejection list. Non-empty on failure; the client + // maps rejections to picker steps by MfaStepRejection.step. + repeated MfaStepRejection rejections = 4; } message ClientMfaFinishRequest { @@ -219,8 +248,11 @@ message ClientMfaFinishRequest { } message ClientMfaFinishResponse { - string preshared_key = 1; + string preshared_key = 1 [deprecated = true]; optional string token = 2; + // [2.2] Per-step result — either MfaAdvanced (proceed to next_step) + // or MfaCompleted (flow done, preshared_key inside). + MfaStepResult result = 3; } message RegisterMobileAuthRequest { @@ -251,6 +283,72 @@ message CodeMfaSetupFinishResponse { repeated string recovery_codes = 1; } +// Multi-step MFA (added for 2.2) + +message MfaStepMethod { + MfaMethod method = 1; + // Per-user configured status, set server-side. + // On DeviceConfig.steps: the server resolves per-user state at config-build + // time — the client uses it directly. + // On ClientMfaStartResponse.steps: returned as a safety net for stale + // DeviceConfig; the flow shape is authoritative. + bool configured = 2; +} + +message MfaStep { + // Positionally addressed throughout the client proto: a step is identified by + // its index in the ordered list it appears in. No server-side row id crosses + // the wire, and the client never reasons about flow identity. + repeated MfaStepMethod methods = 1; +} + +message MfaUserState { + // Lightweight summary: the set of MFA methods this user can currently use + // on this device. For quick checks (e.g. "does this user have any MFA + // methods at all?"). + repeated MfaMethod configured_methods = 1; +} + +enum MfaStartRejectionReason { + WRONG_PLAN_LENGTH = 0; + METHOD_NOT_IN_STEP = 1; + METHOD_NOT_CONFIGURED = 2; + STEP_EMPTY_AFTER_LICENSE = 3; + STEP_NO_CONFIGURED_METHODS = 4; +} + +// Sparse: only failing steps are returned, so the step index is carried +// explicitly here (unlike the request plan, which is positional). +message MfaStepRejection { + uint32 step = 1; + MfaStartRejectionReason reason = 2; +} + +message MfaAdvanced { + uint32 next_step = 1; +} + +message MfaCompleted { + string preshared_key = 1; +} + +message MfaStepResult { + oneof outcome { + MfaAdvanced advanced = 1; + MfaCompleted completed = 2; + } +} + +message ClientMfaStepStartRequest { + string token = 1; + MfaMethod method = 2; +} + +message ClientMfaStepStartResponse { + string step_attempt_id = 1; + optional string challenge = 2; +} + // External OIDC authentication flow enum AuthFlowType { AUTH_FLOW_TYPE_UNSPECIFIED = 0; From c041a55a95ca366fbfc3d6362261123da8355ce9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 11 Aug 2026 08:43:23 +0200 Subject: [PATCH 2/7] update proxy RPC for multi-step MFA --- v2/proxy.proto | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/v2/proxy.proto b/v2/proxy.proto index 5705ec7..803325c 100644 --- a/v2/proxy.proto +++ b/v2/proxy.proto @@ -69,7 +69,9 @@ message AwaitRemoteMfaFinishRequest { } message AwaitRemoteMfaFinishResponse { - string preshared_key = 1; + string preshared_key = 1 [deprecated = true]; + // [2.2] Per-step result when the flow completes via mobile approve. + defguard.client_types.MfaStepResult result = 2; } message InitialInfo { @@ -113,6 +115,7 @@ message CoreResponse { defguard.enterprise.posture.v2.DevicePostureCheckResponse device_posture_check = 19; defguard.enterprise.posture.v2.DevicePostureRejection device_posture_rejected = 20; PublicSettings public_settings = 21; + defguard.client_types.ClientMfaStepStartResponse client_mfa_step_start = 22; } } @@ -209,6 +212,7 @@ message CoreRequest { AwaitRemoteMfaFinishRequest await_remote_mfa_finish = 20; AcmeCertificate acme_certificate = 21; defguard.enterprise.posture.v2.DevicePostureCheckRequest device_posture_check = 22; + defguard.client_types.ClientMfaStepStartRequest client_mfa_step_start = 23; } } From f88597a0ff4fdd682bc632b0416c7b10b453a694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 11 Aug 2026 08:57:29 +0200 Subject: [PATCH 3/7] support mid-flow failures --- common/client_types.proto | 61 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/common/client_types.proto b/common/client_types.proto index af60e8d..ed8b04d 100644 --- a/common/client_types.proto +++ b/common/client_types.proto @@ -332,10 +332,54 @@ message MfaCompleted { string preshared_key = 1; } +// Why a step could not be completed. Values are prefixed because proto3 scopes +// enum value names to the package, and the unprefixed names are already taken +// by MfaStartRejectionReason. +enum MfaStepFailureReason { + MFA_STEP_FAILURE_UNSPECIFIED = 0; + // Credential rejected: wrong TOTP or emailed code, bad biometric signature, + // denied mobile approval. The only reason that counts against the attempt limit. + MFA_STEP_FAILURE_INVALID_CREDENTIAL = 1; + // Chosen method is not in this step's allowed set. Reachable via the mid-flow + // method switch on StepStart, or an admin editing the flow during the session. + MFA_STEP_FAILURE_METHOD_NOT_IN_STEP = 2; + // Method is allowed for the step but this user has not set it up. + MFA_STEP_FAILURE_METHOD_NOT_CONFIGURED = 3; + // Method lost its license mid-session. Fail closed: the step does not advance + // and the factor is never silently dropped. + MFA_STEP_FAILURE_METHOD_NOT_LICENSED = 4; + // No active session for this token: expired, superseded by a newer Start, or + // destroyed after the attempt limit was reached. + MFA_STEP_FAILURE_SESSION_INVALID = 5; + // An out-of-band step has not completed yet (OIDC callback not received, + // mobile approval not given). Not a credential failure and not counted; the + // client should keep waiting rather than treat this as an error. + MFA_STEP_FAILURE_AWAITING_EXTERNAL = 6; +} + +// No step index: exactly one step is ever in flight (the session holds a single +// current_step cursor), the client already knows which step it submitted, and a +// failure does not move the cursor. Contrast MfaStepRejection, which is sparse +// and can name several steps at once. +message MfaStepFailure { + MfaStepFailureReason reason = 1; + // Credential attempts left before the session is destroyed. Meaningful only + // for MFA_STEP_FAILURE_INVALID_CREDENTIAL; zero otherwise. + uint32 attempts_remaining = 2; + // The session no longer exists and the client must restart from Start. + // Always set for MFA_STEP_FAILURE_SESSION_INVALID, and for the credential + // failure that exhausts the attempt limit. Derivable from the two fields + // above, but stated explicitly because it decides retry versus start-over. + bool session_terminated = 3; +} + message MfaStepResult { oneof outcome { MfaAdvanced advanced = 1; MfaCompleted completed = 2; + // Business-level failure. Returned with an OK status, not a gRPC error; + // transport-level errors remain reserved for malformed or internal faults. + MfaStepFailure failed = 3; } } @@ -344,11 +388,26 @@ message ClientMfaStepStartRequest { MfaMethod method = 2; } -message ClientMfaStepStartResponse { +message MfaStepStarted { + // Nonce identifying this attempt at the current step. The client round-trips + // it through the OIDC state parameter and the mobile-approve payload so late + // callbacks can be matched against the attempt that is actually current. string step_attempt_id = 1; + // Biometric or mobile-approve challenge, when the method needs one. optional string challenge = 2; } +// A oneof rather than presence-routed flat fields (as ClientMfaStartResponse +// uses): this message is new in 2.2, so no client has ever parsed it and there +// is no wire format to preserve. The oneof makes "started and failed" and +// "neither" unrepresentable. +message ClientMfaStepStartResponse { + oneof outcome { + MfaStepStarted started = 1; + MfaStepFailure failed = 2; + } +} + // External OIDC authentication flow enum AuthFlowType { AUTH_FLOW_TYPE_UNSPECIFIED = 0; From 5ee7894a45bf736abf195d615e8bcc31320a454d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 11 Aug 2026 09:46:04 +0200 Subject: [PATCH 4/7] update comments --- v2/proxy.proto | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/v2/proxy.proto b/v2/proxy.proto index 803325c..4401950 100644 --- a/v2/proxy.proto +++ b/v2/proxy.proto @@ -69,8 +69,9 @@ message AwaitRemoteMfaFinishRequest { } message AwaitRemoteMfaFinishResponse { + // DEPRECATED(2.2): superseded by result (MfaCompleted.preshared_key) string preshared_key = 1 [deprecated = true]; - // [2.2] Per-step result when the flow completes via mobile approve. + // [2.2] Outcome of the remote-approved step. defguard.client_types.MfaStepResult result = 2; } From 2a45b838070e076e379a135411e7f636ff025b9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 11 Aug 2026 09:58:07 +0200 Subject: [PATCH 5/7] cleanup --- common/client_types.proto | 214 +++++++++++++++++++------------------- 1 file changed, 105 insertions(+), 109 deletions(-) diff --git a/common/client_types.proto b/common/client_types.proto index ed8b04d..220e193 100644 --- a/common/client_types.proto +++ b/common/client_types.proto @@ -136,11 +136,8 @@ message DeviceConfig { optional ServiceLocationMode service_location_mode = 12; // added for 2.1 optional bool posture_check_required = 13; - // [2.2] Resolved MFA flow for this location+user. - // The server resolves group membership at config-build time and delivers - // a single effective MfaStep list per location. Every MfaStepMethod.configured - // already reflects whether THIS user has that method set up — no intersection - // with InstanceInfo.mfa_user_state is needed. + // [2.2] The ordered steps of the MFA flow resolved for this user at this + // location. repeated MfaStep steps = 14; } @@ -168,8 +165,6 @@ message InstanceInfo { optional bool disable_tunnels = 10; // [2.2] Lightweight summary of the enrolled user's configured MFA methods. // Used for quick checks (e.g. "does this user have any MFA methods?"). - // The client does NOT intersect this with DeviceConfig.steps — the resolved - // flow in DeviceConfig already carries per-user configured flags. MfaUserState mfa_user_state = 11; } @@ -213,113 +208,48 @@ enum MfaMethod { MOBILE_APPROVE = 4; } -message ClientMfaStartRequest { - int64 location_id = 1; - string pubkey = 2; - // Legacy single-step path: set on its own to trigger the fused Start + - // StepStart adapter for pre-2.2.0 clients. Must not be set together with - // selected_methods. - optional MfaMethod method = 3; - // [2.1] Required when the location has posture policies assigned. - optional defguard.enterprise.posture.v2.DevicePostureData posture_data = 4; - // [2.2] Multi-step path: the client's full per-step method plan, one entry - // per step in flow order — index i is the chosen method for step i. - // Must contain exactly one entry per step; a mismatched length is rejected - // with WRONG_PLAN_LENGTH. Non-empty triggers the multi-step Start flow. - repeated MfaMethod selected_methods = 5; -} - -message ClientMfaStartResponse { - string token = 1; - // for biometric mfa method (legacy fused path only) - optional string challenge = 2; - // [2.2] The flow descriptor with per-user configured annotations set. - // Returned on success as a safety net for stale DeviceConfig. - repeated MfaStep steps = 3; - // [2.2] Structured rejection list. Non-empty on failure; the client - // maps rejections to picker steps by MfaStepRejection.step. - repeated MfaStepRejection rejections = 4; -} - -message ClientMfaFinishRequest { - string token = 1; - optional string code = 2; - optional string auth_pub_key = 3; -} - -message ClientMfaFinishResponse { - string preshared_key = 1 [deprecated = true]; - optional string token = 2; - // [2.2] Per-step result — either MfaAdvanced (proceed to next_step) - // or MfaCompleted (flow done, preshared_key inside). - MfaStepResult result = 3; -} - -message RegisterMobileAuthRequest { - string token = 1; - string auth_pub_key = 2; - string device_pub_key = 3; -} - -// TOTP and Email MFA Setup - -message CodeMfaSetupStartRequest { - MfaMethod method = 1; - string token = 2; -} - -// in case of email secret is empty -message CodeMfaSetupStartResponse { - optional string totp_secret = 1; -} - -message CodeMfaSetupFinishRequest { - string code = 1; - string token = 2; - MfaMethod method = 3; -} - -message CodeMfaSetupFinishResponse { - repeated string recovery_codes = 1; -} - // Multi-step MFA (added for 2.2) message MfaStepMethod { MfaMethod method = 1; - // Per-user configured status, set server-side. - // On DeviceConfig.steps: the server resolves per-user state at config-build - // time — the client uses it directly. - // On ClientMfaStartResponse.steps: returned as a safety net for stale - // DeviceConfig; the flow shape is authoritative. + // Whether THIS user has the method set up, resolved server-side at + // config-build time. bool configured = 2; } message MfaStep { // Positionally addressed throughout the client proto: a step is identified by - // its index in the ordered list it appears in. No server-side row id crosses - // the wire, and the client never reasons about flow identity. + // its index in the ordered list it appears in. repeated MfaStepMethod methods = 1; } message MfaUserState { - // Lightweight summary: the set of MFA methods this user can currently use - // on this device. For quick checks (e.g. "does this user have any MFA - // methods at all?"). + // Lightweight summary: the set of MFA methods this user has configured. For + // quick checks (e.g. "does this user have any MFA methods at all?"). repeated MfaMethod configured_methods = 1; } +// Why a step of the submitted plan was refused at Start. Every reason names one +// specific step. A plan whose length does not match the resolved flow is a +// malformed request and is refused with an INVALID_ARGUMENT status instead of +// appearing here. enum MfaStartRejectionReason { - WRONG_PLAN_LENGTH = 0; - METHOD_NOT_IN_STEP = 1; - METHOD_NOT_CONFIGURED = 2; - STEP_EMPTY_AFTER_LICENSE = 3; - STEP_NO_CONFIGURED_METHODS = 4; + MFA_START_REJECTION_UNSPECIFIED = 0; + // Chosen method is not in this step's allowed set. + MFA_START_REJECTION_METHOD_NOT_IN_STEP = 1; + // The step has no methods left once the license filter is applied. + MFA_START_REJECTION_STEP_EMPTY_AFTER_LICENSE = 2; + // This user cannot satisfy the step. Deliberately opaque: Start is + // authenticated by device pubkey alone, before any factor is proven, so it + // must not report whether the method is unconfigured, unlicensed, or the + // step has no configured methods at all. + MFA_START_REJECTION_STEP_UNAVAILABLE = 3; } // Sparse: only failing steps are returned, so the step index is carried // explicitly here (unlike the request plan, which is positional). message MfaStepRejection { + // Index of the rejected step. uint32 step = 1; MfaStartRejectionReason reason = 2; } @@ -332,9 +262,7 @@ message MfaCompleted { string preshared_key = 1; } -// Why a step could not be completed. Values are prefixed because proto3 scopes -// enum value names to the package, and the unprefixed names are already taken -// by MfaStartRejectionReason. +// Why a step could not be completed. Detailed, unlike MfaStartRejectionReason. enum MfaStepFailureReason { MFA_STEP_FAILURE_UNSPECIFIED = 0; // Credential rejected: wrong TOTP or emailed code, bad biometric signature, @@ -357,19 +285,16 @@ enum MfaStepFailureReason { MFA_STEP_FAILURE_AWAITING_EXTERNAL = 6; } -// No step index: exactly one step is ever in flight (the session holds a single -// current_step cursor), the client already knows which step it submitted, and a -// failure does not move the cursor. Contrast MfaStepRejection, which is sparse -// and can name several steps at once. message MfaStepFailure { MfaStepFailureReason reason = 1; - // Credential attempts left before the session is destroyed. Meaningful only - // for MFA_STEP_FAILURE_INVALID_CREDENTIAL; zero otherwise. + // Credential attempts left before the session is destroyed. Advisory, for + // display only. Meaningful only for MFA_STEP_FAILURE_INVALID_CREDENTIAL; + // zero otherwise. uint32 attempts_remaining = 2; - // The session no longer exists and the client must restart from Start. - // Always set for MFA_STEP_FAILURE_SESSION_INVALID, and for the credential - // failure that exhausts the attempt limit. Derivable from the two fields - // above, but stated explicitly because it decides retry versus start-over. + // Authoritative signal for retry versus start-over: when true the session no + // longer exists and the client must go back to Start. Do not derive this from + // reason and attempts_remaining - which failures destroy a session is server + // policy, and reimplementing it per client is how the three clients drift. bool session_terminated = 3; } @@ -397,10 +322,6 @@ message MfaStepStarted { optional string challenge = 2; } -// A oneof rather than presence-routed flat fields (as ClientMfaStartResponse -// uses): this message is new in 2.2, so no client has ever parsed it and there -// is no wire format to preserve. The oneof makes "started and failed" and -// "neither" unrepresentable. message ClientMfaStepStartResponse { oneof outcome { MfaStepStarted started = 1; @@ -408,6 +329,81 @@ message ClientMfaStepStartResponse { } } +message ClientMfaStartRequest { + int64 location_id = 1; + string pubkey = 2; + // Legacy single-step path. Read ONLY when selected_methods is empty. + // + // Never test this field for presence. MfaMethod.TOTP is 0 and pre-2.2 clients + // encode with implicit presence, so a legacy client selecting TOTP omits the + // field entirely - "absent" and "TOTP" are indistinguishable on the wire. The + // proto3 default of 0 is the correct reading in both cases. + MfaMethod method = 3; + // [2.1] Required when the location has posture policies assigned. + optional defguard.enterprise.posture.v2.DevicePostureData posture_data = 4; + // [2.2] Multi-step path: the client's full per-step method plan, one entry + // per step in flow order - index i is the chosen method for step i. + // Non-empty is the SOLE discriminator between the multi-step flow and the + // legacy fused Start + StepStart adapter. A length that does not match the + // resolved flow is refused with an INVALID_ARGUMENT status. + repeated MfaMethod selected_methods = 5; +} + +// Flat presence-routed fields rather than a oneof, unlike +// ClientMfaStepStartResponse: this message predates 2.2, so token and challenge +// are already parsed by deployed clients and cannot be moved inside a oneof. +// repeated fields cannot live in a oneof either. +message ClientMfaStartResponse { + string token = 1; + // for biometric mfa method (legacy fused path only) + optional string challenge = 2; + // [2.2] Per-step rejections, sparse - only failing steps appear. Non-empty + // means the plan was refused and no session was created. + repeated MfaStepRejection rejections = 3; +} + +message ClientMfaFinishRequest { + string token = 1; + optional string code = 2; + optional string auth_pub_key = 3; +} + +message ClientMfaFinishResponse { + // DEPRECATED(2.2): superseded by result (MfaCompleted.preshared_key) + string preshared_key = 1 [deprecated = true]; + optional string token = 2; + // [2.2] Outcome of the step just submitted. + MfaStepResult result = 3; +} + +message RegisterMobileAuthRequest { + string token = 1; + string auth_pub_key = 2; + string device_pub_key = 3; +} + +// TOTP and Email MFA Setup + +message CodeMfaSetupStartRequest { + MfaMethod method = 1; + string token = 2; +} + +// in case of email secret is empty +message CodeMfaSetupStartResponse { + optional string totp_secret = 1; +} + +message CodeMfaSetupFinishRequest { + string code = 1; + string token = 2; + MfaMethod method = 3; +} + +message CodeMfaSetupFinishResponse { + repeated string recovery_codes = 1; +} + // External OIDC authentication flow enum AuthFlowType { AUTH_FLOW_TYPE_UNSPECIFIED = 0; From 11d30483e90ef602d9c92ba8a47d061f0efc5474 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 11 Aug 2026 10:07:00 +0200 Subject: [PATCH 6/7] formatting --- common/client_types.proto | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/client_types.proto b/common/client_types.proto index 220e193..3822be4 100644 --- a/common/client_types.proto +++ b/common/client_types.proto @@ -242,14 +242,14 @@ enum MfaStartRejectionReason { // This user cannot satisfy the step. Deliberately opaque: Start is // authenticated by device pubkey alone, before any factor is proven, so it // must not report whether the method is unconfigured, unlicensed, or the - // step has no configured methods at all. + // step has no configured methods at all. MFA_START_REJECTION_STEP_UNAVAILABLE = 3; } // Sparse: only failing steps are returned, so the step index is carried // explicitly here (unlike the request plan, which is positional). message MfaStepRejection { - // Index of the rejected step. + // Index of the rejected step. uint32 step = 1; MfaStartRejectionReason reason = 2; } From 546446304e1ac7a3e1df652c0a7ca916e64b1246 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Thu, 13 Aug 2026 10:31:47 +0200 Subject: [PATCH 7/7] change approach to errors to support legacy clients --- common/client_types.proto | 67 +++++++++++---------------------------- 1 file changed, 18 insertions(+), 49 deletions(-) diff --git a/common/client_types.proto b/common/client_types.proto index 3822be4..3a2a1ec 100644 --- a/common/client_types.proto +++ b/common/client_types.proto @@ -262,49 +262,23 @@ message MfaCompleted { string preshared_key = 1; } -// Why a step could not be completed. Detailed, unlike MfaStartRejectionReason. -enum MfaStepFailureReason { - MFA_STEP_FAILURE_UNSPECIFIED = 0; - // Credential rejected: wrong TOTP or emailed code, bad biometric signature, - // denied mobile approval. The only reason that counts against the attempt limit. - MFA_STEP_FAILURE_INVALID_CREDENTIAL = 1; - // Chosen method is not in this step's allowed set. Reachable via the mid-flow - // method switch on StepStart, or an admin editing the flow during the session. - MFA_STEP_FAILURE_METHOD_NOT_IN_STEP = 2; - // Method is allowed for the step but this user has not set it up. - MFA_STEP_FAILURE_METHOD_NOT_CONFIGURED = 3; - // Method lost its license mid-session. Fail closed: the step does not advance - // and the factor is never silently dropped. - MFA_STEP_FAILURE_METHOD_NOT_LICENSED = 4; - // No active session for this token: expired, superseded by a newer Start, or - // destroyed after the attempt limit was reached. - MFA_STEP_FAILURE_SESSION_INVALID = 5; - // An out-of-band step has not completed yet (OIDC callback not received, - // mobile approval not given). Not a credential failure and not counted; the - // client should keep waiting rather than treat this as an error. - MFA_STEP_FAILURE_AWAITING_EXTERNAL = 6; -} - -message MfaStepFailure { - MfaStepFailureReason reason = 1; - // Credential attempts left before the session is destroyed. Advisory, for - // display only. Meaningful only for MFA_STEP_FAILURE_INVALID_CREDENTIAL; - // zero otherwise. - uint32 attempts_remaining = 2; - // Authoritative signal for retry versus start-over: when true the session no - // longer exists and the client must go back to Start. Do not derive this from - // reason and attempts_remaining - which failures destroy a session is server - // policy, and reimplementing it per client is how the three clients drift. - bool session_terminated = 3; -} - +// An out-of-band step has not resolved yet: the OIDC callback has not arrived, +// or the mobile approval has not been given. Not a failure - the client keeps +// waiting, and it does not count against the attempt limit. +message MfaAwaitingExternal {} + +// Step failures are NOT carried here. They are returned as gRPC error statuses. +// +// ClientMfaFinishResponse is shared with pre-2.2 clients, and those gate on the +// status alone: the desktop client connects the tunnel on any OK response, and +// configures the peer with no preshared key when one is absent. An OK response +// carrying a failure would let a deployed client treat a rejected factor as +// success. The status code is the protection, not the deprecation marker. message MfaStepResult { oneof outcome { MfaAdvanced advanced = 1; MfaCompleted completed = 2; - // Business-level failure. Returned with an OK status, not a gRPC error; - // transport-level errors remain reserved for malformed or internal faults. - MfaStepFailure failed = 3; + MfaAwaitingExternal awaiting_external = 3; } } @@ -313,7 +287,9 @@ message ClientMfaStepStartRequest { MfaMethod method = 2; } -message MfaStepStarted { +// Returned only when the step actually started; failures are gRPC error +// statuses, mapped as described on MfaStepResult. +message ClientMfaStepStartResponse { // Nonce identifying this attempt at the current step. The client round-trips // it through the OIDC state parameter and the mobile-approve payload so late // callbacks can be matched against the attempt that is actually current. @@ -322,13 +298,6 @@ message MfaStepStarted { optional string challenge = 2; } -message ClientMfaStepStartResponse { - oneof outcome { - MfaStepStarted started = 1; - MfaStepFailure failed = 2; - } -} - message ClientMfaStartRequest { int64 location_id = 1; string pubkey = 2; @@ -338,7 +307,8 @@ message ClientMfaStartRequest { // encode with implicit presence, so a legacy client selecting TOTP omits the // field entirely - "absent" and "TOTP" are indistinguishable on the wire. The // proto3 default of 0 is the correct reading in both cases. - MfaMethod method = 3; + // DEPRECATED(2.2): superseded by selected_methods (MfaCompleted.preshared_key) + MfaMethod method = 3 [deprecated = true]; // [2.1] Required when the location has posture policies assigned. optional defguard.enterprise.posture.v2.DevicePostureData posture_data = 4; // [2.2] Multi-step path: the client's full per-step method plan, one entry @@ -383,7 +353,6 @@ message RegisterMobileAuthRequest { } // TOTP and Email MFA Setup - message CodeMfaSetupStartRequest { MfaMethod method = 1; string token = 2;