From 27fd8d950fd8d592dc0d29ad48bfc72b54899cd3 Mon Sep 17 00:00:00 2001 From: Kuba <78603704+jakub-tldr@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:22:57 +0200 Subject: [PATCH 01/18] bump client to 2.2 --- src-tauri/Cargo.lock | 22 +++++++++++----------- src-tauri/client-proto/build.rs | 9 +++++++++ src-tauri/core/src/mfa.rs | 7 +++++++ src-tauri/proto | 2 +- src-tauri/tauri.conf.json | 2 +- 5 files changed, 29 insertions(+), 13 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 664d4560..0fbc96e4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1484,7 +1484,7 @@ checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" [[package]] name = "defguard-cli" -version = "2.1.0" +version = "2.2.0" dependencies = [ "base64 0.22.1", "chrono", @@ -1520,7 +1520,7 @@ dependencies = [ [[package]] name = "defguard-client" -version = "2.1.0" +version = "2.2.0" dependencies = [ "anyhow", "async-stream", @@ -1600,7 +1600,7 @@ dependencies = [ [[package]] name = "defguard-client-common" -version = "2.1.0" +version = "2.2.0" dependencies = [ "nix", "vergen-git2", @@ -1608,7 +1608,7 @@ dependencies = [ [[package]] name = "defguard-client-config-sync" -version = "2.1.0" +version = "2.2.0" dependencies = [ "defguard-client-core", "defguard-client-proto", @@ -1626,7 +1626,7 @@ dependencies = [ [[package]] name = "defguard-client-core" -version = "2.1.0" +version = "2.2.0" dependencies = [ "base64 0.22.1", "block2 0.6.2", @@ -1670,7 +1670,7 @@ dependencies = [ [[package]] name = "defguard-client-posture" -version = "2.1.0" +version = "2.2.0" dependencies = [ "defguard-client-core", "defguard-client-proto", @@ -1688,7 +1688,7 @@ dependencies = [ [[package]] name = "defguard-client-proto" -version = "2.1.0" +version = "2.2.0" dependencies = [ "defguard_wireguard_rs", "prost", @@ -1703,7 +1703,7 @@ dependencies = [ [[package]] name = "defguard-client-provisioning" -version = "2.1.0" +version = "2.2.0" dependencies = [ "defguard-client-core", "log", @@ -1714,7 +1714,7 @@ dependencies = [ [[package]] name = "defguard-client-service" -version = "2.1.0" +version = "2.2.0" dependencies = [ "anyhow", "async-stream", @@ -1742,7 +1742,7 @@ dependencies = [ [[package]] name = "defguard-client-service-locations" -version = "2.1.0" +version = "2.2.0" dependencies = [ "base64 0.22.1", "defguard-client-common", @@ -1767,7 +1767,7 @@ dependencies = [ [[package]] name = "defguard-dg" -version = "2.1.0" +version = "2.2.0" dependencies = [ "clap", "defguard-client-common", diff --git a/src-tauri/client-proto/build.rs b/src-tauri/client-proto/build.rs index 1529c157..8eec52a3 100644 --- a/src-tauri/client-proto/build.rs +++ b/src-tauri/client-proto/build.rs @@ -26,6 +26,15 @@ fn main() -> Result<(), Box> { ".defguard.client.v1.ServiceLocation.posture_check_required", "#[serde(default)]", ) + // [2.2] These repeated fields are absent in responses from pre-2.2 edges. + .field_attribute( + ".defguard.client_types.DeviceConfig.steps", + "#[serde(default)]", + ) + .field_attribute( + ".defguard.client_types.ClientMfaStartResponse.rejections", + "#[serde(default)]", + ) // Use proto defaults for missing fields in enrollment types that // may differ across proxy versions. .type_attribute(".defguard.client_types.AdminInfo", "#[serde(default)]") diff --git a/src-tauri/core/src/mfa.rs b/src-tauri/core/src/mfa.rs index c10f0219..d1dcea1d 100644 --- a/src-tauri/core/src/mfa.rs +++ b/src-tauri/core/src/mfa.rs @@ -130,6 +130,9 @@ pub async fn mfa_start( Ok(response) => response, Err(err) => return Err(rewrap_mobile_start_error(request.method, err)), }; + // TODO(multi-step-mfa): a non-empty `rejections` in the response means the sent + // plan was refused and no session was created. Surface it instead of returning + // the response as if it were a success. response.json().await.map_err(|e| MfaError::Other { message: format!("Invalid MFA start response: {e}"), }) @@ -374,9 +377,11 @@ async fn wait_for_mfa_success( if let Ok(parsed) = serde_json::from_str::(&text) { if parsed.get("type").and_then(|v| v.as_str()) == Some("mfa_success") { if let Some(key) = parsed["preshared_key"].as_str() { + // #TODO (multi-step-mfa) MfaStepResult here once the step loop exists. return Ok(ClientMfaFinishResponse { preshared_key: key.to_string(), token: None, + result: None, }); } } @@ -408,6 +413,7 @@ mod tests { pubkey: "pk".into(), method: 0, // TOTP posture_data: None, + selected_methods: Vec::new(), } } @@ -545,6 +551,7 @@ mod tests { pubkey: "pk".into(), method: MfaMethod::MobileApprove as i32, posture_data: None, + selected_methods: Vec::new(), }; match mfa_start(url, request).await.unwrap_err() { MfaError::MfaRejected { message } => { diff --git a/src-tauri/proto b/src-tauri/proto index 7e1c6a5e..0b44d8c6 160000 --- a/src-tauri/proto +++ b/src-tauri/proto @@ -1 +1 @@ -Subproject commit 7e1c6a5ed1336522bff0610edf1e216f7dcde444 +Subproject commit 0b44d8c6730e0129ac79f078041894813631a8e3 diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index fde6c1b2..998aa3c1 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -67,7 +67,7 @@ "productName": "Defguard", "mainBinaryName": "defguard-client", "identifier": "net.defguard", - "version": "2.1.0", + "version": "2.2.0", "app": { "security": { "capabilities": ["main-capability"], From 23fd4441984fc15a5f080dba6e001eb9440a37cf Mon Sep 17 00:00:00 2001 From: Kuba <78603704+jakub-tldr@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:58:15 +0200 Subject: [PATCH 02/18] store per-location mfa flow steps --- ...2bb446b2f0bf59caed8d75ecfd672bf25d83.json} | 10 ++- ...b79bfd1304af999b43d2fd28499daf519fec.json} | 10 ++- ...6b21fa7e795a5f59c466394e301f16cfda57.json} | 6 +- ...2e05d6af0b7a33ddb011999fbe2f49da6644.json} | 10 ++- ...08b251ae2620c2ada2a9fb43c41fc4e1692e.json} | 6 +- ...d1a37b1e9b05dfc436774de4f992397848a9.json} | 10 ++- ...7b4037c66196175d3deb9c2179bd6998fdc9.json} | 10 ++- src-tauri/Cargo.toml | 4 +- src-tauri/client-cli/src/commands/list.rs | 7 +- src-tauri/client-cli/src/commands/location.rs | 7 +- src-tauri/client-cli/src/mfa.rs | 7 ++ src-tauri/client-cli/src/resolve.rs | 1 + .../core/src/database/models/connection.rs | 1 + .../core/src/database/models/location.rs | 68 ++++++++++++++++--- .../src/database/models/location_stats.rs | 1 + src-tauri/core/src/lib.rs | 10 ++- src-tauri/core/src/mfa.rs | 4 +- .../enterprise/config-sync/src/commands.rs | 1 + src-tauri/enterprise/config-sync/src/lib.rs | 1 + .../20260820120000_add_location_mfa_steps.sql | 1 + src-tauri/src/commands.rs | 6 +- 21 files changed, 145 insertions(+), 36 deletions(-) rename src-tauri/.sqlx/{query-56b89ac487ed011e33080a9058700f706a9860e6a6f3070305f8ea85b5ff8efb.json => query-06346116cf4b39692fa3d452b69d2bb446b2f0bf59caed8d75ecfd672bf25d83.json} (85%) rename src-tauri/.sqlx/{query-a25979219918af2df8abca48a48d7fba459b79b74d462565088bf27d8e9fcd5d.json => query-3cab577f59f7f4436d6fca874bc1b79bfd1304af999b43d2fd28499daf519fec.json} (89%) rename src-tauri/.sqlx/{query-e27705d75d504385fbaea05c43eadecccdb12fcb43060dd383e7c9fd1516179e.json => query-7ecda7a2f1a2319915d57f34a2436b21fa7e795a5f59c466394e301f16cfda57.json} (63%) rename src-tauri/.sqlx/{query-1c07ca7013959226ca9af064037bb64da07f7023f58ff8678d828a0ba50e2470.json => query-97e6f30cc3b985a6b2b8862a82c02e05d6af0b7a33ddb011999fbe2f49da6644.json} (86%) rename src-tauri/.sqlx/{query-e16f46ba4c2365de31db15551084eddaabf35d813a54eced9d38c951965ce83e.json => query-a6f74bd474e671ddc0df91b9622308b251ae2620c2ada2a9fb43c41fc4e1692e.json} (68%) rename src-tauri/.sqlx/{query-c6a5e793cccc520039e28da8b4fb73e0c79c6a8d671c300ec2ea3eb0d58342b5.json => query-ddfa8313a275ebd61382712cbf10d1a37b1e9b05dfc436774de4f992397848a9.json} (85%) rename src-tauri/.sqlx/{query-97a52a8bbf020b77afe5dc427efb66abfdc6b571d1631a4f77fbf4fa5cfbe7e7.json => query-ea79a76cac4cdaea674534d00a007b4037c66196175d3deb9c2179bd6998fdc9.json} (88%) create mode 100644 src-tauri/migrations/20260820120000_add_location_mfa_steps.sql diff --git a/src-tauri/.sqlx/query-56b89ac487ed011e33080a9058700f706a9860e6a6f3070305f8ea85b5ff8efb.json b/src-tauri/.sqlx/query-06346116cf4b39692fa3d452b69d2bb446b2f0bf59caed8d75ecfd672bf25d83.json similarity index 85% rename from src-tauri/.sqlx/query-56b89ac487ed011e33080a9058700f706a9860e6a6f3070305f8ea85b5ff8efb.json rename to src-tauri/.sqlx/query-06346116cf4b39692fa3d452b69d2bb446b2f0bf59caed8d75ecfd672bf25d83.json index c40c1f56..58753633 100644 --- a/src-tauri/.sqlx/query-56b89ac487ed011e33080a9058700f706a9860e6a6f3070305f8ea85b5ff8efb.json +++ b/src-tauri/.sqlx/query-06346116cf4b39692fa3d452b69d2bb446b2f0bf59caed8d75ecfd672bf25d83.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id, instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\", mfa_method \"mfa_method: _\", posture_check_required FROM location WHERE name = $1 AND service_location_mode <= $2 ORDER BY name ASC", + "query": "SELECT id, instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\", mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\" FROM location WHERE name = $1 AND service_location_mode <= $2 ORDER BY name ASC", "describe": { "columns": [ { @@ -77,6 +77,11 @@ "name": "posture_check_required", "ordinal": 14, "type_info": "Bool" + }, + { + "name": "mfa_steps: _", + "ordinal": 15, + "type_info": "Text" } ], "parameters": { @@ -97,8 +102,9 @@ false, false, true, + false, false ] }, - "hash": "56b89ac487ed011e33080a9058700f706a9860e6a6f3070305f8ea85b5ff8efb" + "hash": "06346116cf4b39692fa3d452b69d2bb446b2f0bf59caed8d75ecfd672bf25d83" } diff --git a/src-tauri/.sqlx/query-a25979219918af2df8abca48a48d7fba459b79b74d462565088bf27d8e9fcd5d.json b/src-tauri/.sqlx/query-3cab577f59f7f4436d6fca874bc1b79bfd1304af999b43d2fd28499daf519fec.json similarity index 89% rename from src-tauri/.sqlx/query-a25979219918af2df8abca48a48d7fba459b79b74d462565088bf27d8e9fcd5d.json rename to src-tauri/.sqlx/query-3cab577f59f7f4436d6fca874bc1b79bfd1304af999b43d2fd28499daf519fec.json index 48cdb545..c08927ee 100644 --- a/src-tauri/.sqlx/query-a25979219918af2df8abca48a48d7fba459b79b74d462565088bf27d8e9fcd5d.json +++ b/src-tauri/.sqlx/query-3cab577f59f7f4436d6fca874bc1b79bfd1304af999b43d2fd28499daf519fec.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id \"id: _\", instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\",\n mfa_method \"mfa_method: _\", posture_check_required FROM location WHERE id = $1", + "query": "SELECT id \"id: _\", instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\",\n mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\" FROM location WHERE id = $1", "describe": { "columns": [ { @@ -77,6 +77,11 @@ "name": "posture_check_required", "ordinal": 14, "type_info": "Bool" + }, + { + "name": "mfa_steps: _", + "ordinal": 15, + "type_info": "Text" } ], "parameters": { @@ -97,8 +102,9 @@ false, false, true, + false, false ] }, - "hash": "a25979219918af2df8abca48a48d7fba459b79b74d462565088bf27d8e9fcd5d" + "hash": "3cab577f59f7f4436d6fca874bc1b79bfd1304af999b43d2fd28499daf519fec" } diff --git a/src-tauri/.sqlx/query-e27705d75d504385fbaea05c43eadecccdb12fcb43060dd383e7c9fd1516179e.json b/src-tauri/.sqlx/query-7ecda7a2f1a2319915d57f34a2436b21fa7e795a5f59c466394e301f16cfda57.json similarity index 63% rename from src-tauri/.sqlx/query-e27705d75d504385fbaea05c43eadecccdb12fcb43060dd383e7c9fd1516179e.json rename to src-tauri/.sqlx/query-7ecda7a2f1a2319915d57f34a2436b21fa7e795a5f59c466394e301f16cfda57.json index 538701cf..9848220c 100644 --- a/src-tauri/.sqlx/query-e27705d75d504385fbaea05c43eadecccdb12fcb43060dd383e7c9fd1516179e.json +++ b/src-tauri/.sqlx/query-7ecda7a2f1a2319915d57f34a2436b21fa7e795a5f59c466394e301f16cfda57.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "INSERT INTO location (instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode, service_location_mode, mfa_method, posture_check_required) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id \"id!\"", + "query": "INSERT INTO location (instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode, service_location_mode, mfa_method, posture_check_required, mfa_steps) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id \"id!\"", "describe": { "columns": [ { @@ -10,11 +10,11 @@ } ], "parameters": { - "Right": 14 + "Right": 15 }, "nullable": [ true ] }, - "hash": "e27705d75d504385fbaea05c43eadecccdb12fcb43060dd383e7c9fd1516179e" + "hash": "7ecda7a2f1a2319915d57f34a2436b21fa7e795a5f59c466394e301f16cfda57" } diff --git a/src-tauri/.sqlx/query-1c07ca7013959226ca9af064037bb64da07f7023f58ff8678d828a0ba50e2470.json b/src-tauri/.sqlx/query-97e6f30cc3b985a6b2b8862a82c02e05d6af0b7a33ddb011999fbe2f49da6644.json similarity index 86% rename from src-tauri/.sqlx/query-1c07ca7013959226ca9af064037bb64da07f7023f58ff8678d828a0ba50e2470.json rename to src-tauri/.sqlx/query-97e6f30cc3b985a6b2b8862a82c02e05d6af0b7a33ddb011999fbe2f49da6644.json index d5164870..ac6ec2f9 100644 --- a/src-tauri/.sqlx/query-1c07ca7013959226ca9af064037bb64da07f7023f58ff8678d828a0ba50e2470.json +++ b/src-tauri/.sqlx/query-97e6f30cc3b985a6b2b8862a82c02e05d6af0b7a33ddb011999fbe2f49da6644.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id \"id: _\", instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\",\n mfa_method \"mfa_method: _\", posture_check_required FROM location WHERE instance_id = $1 AND service_location_mode <= $2 ORDER BY name ASC", + "query": "SELECT id \"id: _\", instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\",\n mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\" FROM location WHERE instance_id = $1 AND service_location_mode <= $2 ORDER BY name ASC", "describe": { "columns": [ { @@ -77,6 +77,11 @@ "name": "posture_check_required", "ordinal": 14, "type_info": "Bool" + }, + { + "name": "mfa_steps: _", + "ordinal": 15, + "type_info": "Text" } ], "parameters": { @@ -97,8 +102,9 @@ false, false, true, + false, false ] }, - "hash": "1c07ca7013959226ca9af064037bb64da07f7023f58ff8678d828a0ba50e2470" + "hash": "97e6f30cc3b985a6b2b8862a82c02e05d6af0b7a33ddb011999fbe2f49da6644" } diff --git a/src-tauri/.sqlx/query-e16f46ba4c2365de31db15551084eddaabf35d813a54eced9d38c951965ce83e.json b/src-tauri/.sqlx/query-a6f74bd474e671ddc0df91b9622308b251ae2620c2ada2a9fb43c41fc4e1692e.json similarity index 68% rename from src-tauri/.sqlx/query-e16f46ba4c2365de31db15551084eddaabf35d813a54eced9d38c951965ce83e.json rename to src-tauri/.sqlx/query-a6f74bd474e671ddc0df91b9622308b251ae2620c2ada2a9fb43c41fc4e1692e.json index 01577f67..fc938b63 100644 --- a/src-tauri/.sqlx/query-e16f46ba4c2365de31db15551084eddaabf35d813a54eced9d38c951965ce83e.json +++ b/src-tauri/.sqlx/query-a6f74bd474e671ddc0df91b9622308b251ae2620c2ada2a9fb43c41fc4e1692e.json @@ -1,12 +1,12 @@ { "db_name": "SQLite", - "query": "UPDATE location SET instance_id = $1, name = $2, address = $3, pubkey = $4, endpoint = $5, allowed_ips = $6, dns = $7, network_id = $8, route_all_traffic = $9, keepalive_interval = $10, location_mfa_mode = $11, service_location_mode = $12, mfa_method = $13, posture_check_required = $14 WHERE id = $15", + "query": "UPDATE location SET instance_id = $1, name = $2, address = $3, pubkey = $4, endpoint = $5, allowed_ips = $6, dns = $7, network_id = $8, route_all_traffic = $9, keepalive_interval = $10, location_mfa_mode = $11, service_location_mode = $12, mfa_method = $13, posture_check_required = $14, mfa_steps = $15 WHERE id = $16", "describe": { "columns": [], "parameters": { - "Right": 15 + "Right": 16 }, "nullable": [] }, - "hash": "e16f46ba4c2365de31db15551084eddaabf35d813a54eced9d38c951965ce83e" + "hash": "a6f74bd474e671ddc0df91b9622308b251ae2620c2ada2a9fb43c41fc4e1692e" } diff --git a/src-tauri/.sqlx/query-c6a5e793cccc520039e28da8b4fb73e0c79c6a8d671c300ec2ea3eb0d58342b5.json b/src-tauri/.sqlx/query-ddfa8313a275ebd61382712cbf10d1a37b1e9b05dfc436774de4f992397848a9.json similarity index 85% rename from src-tauri/.sqlx/query-c6a5e793cccc520039e28da8b4fb73e0c79c6a8d671c300ec2ea3eb0d58342b5.json rename to src-tauri/.sqlx/query-ddfa8313a275ebd61382712cbf10d1a37b1e9b05dfc436774de4f992397848a9.json index 2436bff9..3bbba910 100644 --- a/src-tauri/.sqlx/query-c6a5e793cccc520039e28da8b4fb73e0c79c6a8d671c300ec2ea3eb0d58342b5.json +++ b/src-tauri/.sqlx/query-ddfa8313a275ebd61382712cbf10d1a37b1e9b05dfc436774de4f992397848a9.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id, instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\", mfa_method \"mfa_method: _\", posture_check_required FROM location WHERE service_location_mode <= $1 ORDER BY name ASC", + "query": "SELECT id, instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\", mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\" FROM location WHERE service_location_mode <= $1 ORDER BY name ASC", "describe": { "columns": [ { @@ -77,6 +77,11 @@ "name": "posture_check_required", "ordinal": 14, "type_info": "Bool" + }, + { + "name": "mfa_steps: _", + "ordinal": 15, + "type_info": "Text" } ], "parameters": { @@ -97,8 +102,9 @@ false, false, true, + false, false ] }, - "hash": "c6a5e793cccc520039e28da8b4fb73e0c79c6a8d671c300ec2ea3eb0d58342b5" + "hash": "ddfa8313a275ebd61382712cbf10d1a37b1e9b05dfc436774de4f992397848a9" } diff --git a/src-tauri/.sqlx/query-97a52a8bbf020b77afe5dc427efb66abfdc6b571d1631a4f77fbf4fa5cfbe7e7.json b/src-tauri/.sqlx/query-ea79a76cac4cdaea674534d00a007b4037c66196175d3deb9c2179bd6998fdc9.json similarity index 88% rename from src-tauri/.sqlx/query-97a52a8bbf020b77afe5dc427efb66abfdc6b571d1631a4f77fbf4fa5cfbe7e7.json rename to src-tauri/.sqlx/query-ea79a76cac4cdaea674534d00a007b4037c66196175d3deb9c2179bd6998fdc9.json index e0c2aeee..d004f20c 100644 --- a/src-tauri/.sqlx/query-97a52a8bbf020b77afe5dc427efb66abfdc6b571d1631a4f77fbf4fa5cfbe7e7.json +++ b/src-tauri/.sqlx/query-ea79a76cac4cdaea674534d00a007b4037c66196175d3deb9c2179bd6998fdc9.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id \"id: _\", instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\",\n mfa_method \"mfa_method: _\", posture_check_required FROM location WHERE pubkey = $1", + "query": "SELECT id \"id: _\", instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\",\n mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\" FROM location WHERE pubkey = $1", "describe": { "columns": [ { @@ -77,6 +77,11 @@ "name": "posture_check_required", "ordinal": 14, "type_info": "Bool" + }, + { + "name": "mfa_steps: _", + "ordinal": 15, + "type_info": "Text" } ], "parameters": { @@ -97,8 +102,9 @@ false, false, true, + false, false ] }, - "hash": "97a52a8bbf020b77afe5dc427efb66abfdc6b571d1631a4f77fbf4fa5cfbe7e7" + "hash": "ea79a76cac4cdaea674534d00a007b4037c66196175d3deb9c2179bd6998fdc9" } diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cc25a2a9..de729692 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -16,7 +16,7 @@ semver = { version = "1.0", features = ["serde"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_with = "3.11" -sqlx = { version = "0.8", features = ["chrono", "runtime-tokio", "sqlite", "uuid", "macros"] } +sqlx = { version = "0.8", features = ["chrono", "json", "runtime-tokio", "sqlite", "uuid", "macros"] } thiserror = "2.0" tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] } tonic = { version = "0.14", default-features = false, features = [ @@ -54,7 +54,7 @@ edition = "2021" homepage = "https://github.com/DefGuard/client" license-file = "../LICENSE.md" rust-version = "1.95" -version = "2.1.0" +version = "2.2.0" [package] name = "defguard-client" diff --git a/src-tauri/client-cli/src/commands/list.rs b/src-tauri/client-cli/src/commands/list.rs index 9228ebf9..60997220 100644 --- a/src-tauri/client-cli/src/commands/list.rs +++ b/src-tauri/client-cli/src/commands/list.rs @@ -20,7 +20,11 @@ const MIN_TUNNEL_NAME_COL_WIDTH: usize = 4; pub(crate) async fn handle(state: &State) -> Result { let instances = Instance::all(&state.pool).await?; - let locations = Location::all(&state.pool, false).await?; + let locations = Location::all(&state.pool, false) + .await? + .into_iter() + .filter(|location| location.mfa_steps.len() <= 1) + .collect::>(); let tunnels = if Instance::tunnels_disabled(&state.pool).await? { Vec::new() } else { @@ -242,6 +246,7 @@ mod tests { service_location_mode: ServiceLocationMode::Disabled, mfa_method: None, posture_check_required: false, + mfa_steps: Default::default(), } } diff --git a/src-tauri/client-cli/src/commands/location.rs b/src-tauri/client-cli/src/commands/location.rs index 139ac4dc..298e65c8 100644 --- a/src-tauri/client-cli/src/commands/location.rs +++ b/src-tauri/client-cli/src/commands/location.rs @@ -18,7 +18,11 @@ const MIN_ENDPOINT_COL_WIDTH: usize = 8; const MIN_INST_COL_WIDTH: usize = 8; pub(crate) async fn handle_list(state: &State) -> Result { - let locations = Location::all(&state.pool, false).await?; + let locations = Location::all(&state.pool, false) + .await? + .into_iter() + .filter(|location| location.mfa_steps.len() <= 1) + .collect::>(); let instance_details = Instance::all(&state.pool) .await? @@ -345,6 +349,7 @@ mod tests { mfa: bool, ) -> Location { Location { + mfa_steps: Default::default(), id, instance_id, network_id: 1, diff --git a/src-tauri/client-cli/src/mfa.rs b/src-tauri/client-cli/src/mfa.rs index f61ddb0f..97fb6f6e 100644 --- a/src-tauri/client-cli/src/mfa.rs +++ b/src-tauri/client-cli/src/mfa.rs @@ -160,6 +160,8 @@ pub(crate) async fn authorize( pubkey: wireguard_keys.pubkey, method: method as i32, posture_data, + // empty = legacy path; the CLI has no multi-step MFA + selected_methods: Vec::new(), }; let info = mfa::mfa_start(proxy_url.clone(), request) .await @@ -218,6 +220,8 @@ pub(crate) async fn authorize_oidc( pubkey: wireguard_keys.pubkey, method: MfaMethod::Oidc as i32, posture_data, + // empty = legacy path; the CLI has no multi-step MFA + selected_methods: Vec::new(), }; let info = mfa::mfa_start(proxy_url.clone(), request) .await @@ -290,6 +294,8 @@ pub(crate) async fn authorize_mobile_approve( pubkey: wireguard_keys.pubkey, method: MfaMethod::MobileApprove as i32, posture_data, + // empty = legacy path; the CLI has no multi-step MFA + selected_methods: Vec::new(), }; let info = mfa::mfa_start(proxy_url.clone(), request) .await @@ -400,6 +406,7 @@ mod tests { fn location(name: &str, mode: LocationMfaMode) -> Location { Location { + mfa_steps: Default::default(), id: 1, instance_id: 1, network_id: 1, diff --git a/src-tauri/client-cli/src/resolve.rs b/src-tauri/client-cli/src/resolve.rs index 64584565..4e623734 100644 --- a/src-tauri/client-cli/src/resolve.rs +++ b/src-tauri/client-cli/src/resolve.rs @@ -206,6 +206,7 @@ mod tests { service_location_mode: ServiceLocationMode::Disabled, mfa_method: None, posture_check_required: false, + mfa_steps: Default::default(), } } diff --git a/src-tauri/core/src/database/models/connection.rs b/src-tauri/core/src/database/models/connection.rs index 9cb87d07..249442bf 100644 --- a/src-tauri/core/src/database/models/connection.rs +++ b/src-tauri/core/src/database/models/connection.rs @@ -205,6 +205,7 @@ mod tests { service_location_mode: ServiceLocationMode::Disabled, mfa_method: None, posture_check_required: false, + mfa_steps: Default::default(), } .save(pool) .await diff --git a/src-tauri/core/src/database/models/location.rs b/src-tauri/core/src/database/models/location.rs index 297ecf17..fbb1040c 100644 --- a/src-tauri/core/src/database/models/location.rs +++ b/src-tauri/core/src/database/models/location.rs @@ -5,7 +5,7 @@ use std::str::FromStr; #[cfg(not(target_os = "macos"))] use defguard_wireguard_rs::{key::Key, net::IpAddrMask, peer::Peer, InterfaceConfiguration}; use serde::{Deserialize, Serialize}; -use sqlx::{prelude::Type, query, query_as, query_scalar, SqliteExecutor}; +use sqlx::{prelude::Type, query, query_as, query_scalar, types::Json, SqliteExecutor}; #[cfg(not(target_os = "macos"))] use super::wireguard_keys::WireguardKeys; @@ -17,7 +17,8 @@ use crate::{ }, error::Error, proto::client_types::{ - LocationMfaMode as ProtoLocationMfaMode, ServiceLocationMode as ProtoServiceLocationMode, + LocationMfaMode as ProtoLocationMfaMode, MfaMethod as ProtoMfaMethod, + MfaStep as ProtoMfaStep, ServiceLocationMode as ProtoServiceLocationMode, }, }; #[cfg(not(target_os = "macos"))] @@ -90,6 +91,44 @@ impl LocationMfaMethod { } } +impl From for LocationMfaMethod { + fn from(value: ProtoMfaMethod) -> Self { + match value { + ProtoMfaMethod::Totp => LocationMfaMethod::Totp, + ProtoMfaMethod::Email => LocationMfaMethod::Email, + ProtoMfaMethod::Oidc => LocationMfaMethod::Oidc, + ProtoMfaMethod::Biometric => LocationMfaMethod::Biometric, + ProtoMfaMethod::MobileApprove => LocationMfaMethod::MobileApprove, + } + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, Eq, Hash, PartialEq)] +pub struct LocationMfaStepMethod { + pub method: LocationMfaMethod, + pub configured: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize, Eq, Hash, PartialEq)] +pub struct LocationMfaStep { + pub methods: Vec, +} + +impl From for LocationMfaStep { + fn from(value: ProtoMfaStep) -> Self { + Self { + methods: value + .methods + .iter() + .map(|entry| LocationMfaStepMethod { + method: entry.method().into(), + configured: entry.configured, + }) + .collect(), + } + } +} + #[must_use] pub fn infer_mfa_method( mode: LocationMfaMode, @@ -124,6 +163,8 @@ pub struct Location { pub mfa_method: Option, #[serde(default)] pub posture_check_required: bool, + #[serde(default)] + pub mfa_steps: Json>, } impl fmt::Display for Location { @@ -152,7 +193,7 @@ impl Location { network_id, route_all_traffic, keepalive_interval, \ location_mfa_mode \"location_mfa_mode: LocationMfaMode\", \ service_location_mode \"service_location_mode: ServiceLocationMode\", \ - mfa_method \"mfa_method: _\", posture_check_required \ + mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\" \ FROM location WHERE service_location_mode <= $1 \ ORDER BY name ASC", max_service_location_mode @@ -190,7 +231,7 @@ impl Location { network_id, route_all_traffic, keepalive_interval, \ location_mfa_mode \"location_mfa_mode: LocationMfaMode\", \ service_location_mode \"service_location_mode: ServiceLocationMode\", \ - mfa_method \"mfa_method: _\", posture_check_required \ + mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\" \ FROM location WHERE name = $1 AND service_location_mode <= $2 ORDER BY name ASC", name, max, @@ -208,8 +249,8 @@ impl Location { "UPDATE location SET instance_id = $1, name = $2, address = $3, pubkey = $4, \ endpoint = $5, allowed_ips = $6, dns = $7, network_id = $8, route_all_traffic = $9, \ keepalive_interval = $10, location_mfa_mode = $11, service_location_mode = $12, \ - mfa_method = $13, posture_check_required = $14 \ - WHERE id = $15", + mfa_method = $13, posture_check_required = $14, mfa_steps = $15 \ + WHERE id = $16", self.instance_id, self.name, self.address, @@ -224,6 +265,7 @@ impl Location { self.service_location_mode, self.mfa_method, self.posture_check_required, + self.mfa_steps, self.id, ) .execute(executor) @@ -242,7 +284,7 @@ impl Location { network_id, route_all_traffic, keepalive_interval, \ location_mfa_mode \"location_mfa_mode: LocationMfaMode\", \ service_location_mode \"service_location_mode: ServiceLocationMode\", - mfa_method \"mfa_method: _\", posture_check_required \ + mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\" \ FROM location WHERE id = $1", location_id ) @@ -266,7 +308,7 @@ impl Location { network_id, route_all_traffic, keepalive_interval, \ location_mfa_mode \"location_mfa_mode: LocationMfaMode\", \ service_location_mode \"service_location_mode: ServiceLocationMode\", - mfa_method \"mfa_method: _\", posture_check_required \ + mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\" \ FROM location WHERE instance_id = $1 AND service_location_mode <= $2 \ ORDER BY name ASC", instance_id, @@ -286,7 +328,7 @@ impl Location { network_id, route_all_traffic, keepalive_interval, \ location_mfa_mode \"location_mfa_mode: LocationMfaMode\", \ service_location_mode \"service_location_mode: ServiceLocationMode\", - mfa_method \"mfa_method: _\", posture_check_required \ + mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\" \ FROM location WHERE pubkey = $1", pubkey ) @@ -505,8 +547,8 @@ impl Location { let id = query_scalar!( "INSERT INTO location (instance_id, name, address, pubkey, endpoint, allowed_ips, \ dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode, \ - service_location_mode, mfa_method, posture_check_required) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) \ + service_location_mode, mfa_method, posture_check_required, mfa_steps) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) \ RETURNING id \"id!\"", self.instance_id, self.name, @@ -522,6 +564,7 @@ impl Location { self.service_location_mode, self.mfa_method, self.posture_check_required, + self.mfa_steps, ) .fetch_one(executor) .await?; @@ -542,6 +585,7 @@ impl Location { service_location_mode: self.service_location_mode, mfa_method: self.mfa_method, posture_check_required: self.posture_check_required, + mfa_steps: self.mfa_steps, }) } } @@ -571,6 +615,7 @@ impl From> for Location { service_location_mode: location.service_location_mode, mfa_method: location.mfa_method, posture_check_required: location.posture_check_required, + mfa_steps: location.mfa_steps, } } } @@ -615,6 +660,7 @@ mod tests { service_location_mode: ServiceLocationMode::Disabled, mfa_method: None, posture_check_required: false, + mfa_steps: Default::default(), } } diff --git a/src-tauri/core/src/database/models/location_stats.rs b/src-tauri/core/src/database/models/location_stats.rs index 30515754..fb21d04d 100644 --- a/src-tauri/core/src/database/models/location_stats.rs +++ b/src-tauri/core/src/database/models/location_stats.rs @@ -279,6 +279,7 @@ mod tests { service_location_mode: ServiceLocationMode::Disabled, mfa_method: None, posture_check_required: false, + mfa_steps: Default::default(), } .save(pool) .await diff --git a/src-tauri/core/src/lib.rs b/src-tauri/core/src/lib.rs index b9c00fef..08435f13 100644 --- a/src-tauri/core/src/lib.rs +++ b/src-tauri/core/src/lib.rs @@ -7,11 +7,12 @@ use std::{ use chrono::{Duration, NaiveDateTime, Utc}; use database::models::{ - location::{infer_mfa_method, Location, LocationMfaMode, ServiceLocationMode}, + location::{infer_mfa_method, Location, LocationMfaMode, LocationMfaStep, ServiceLocationMode}, Id, }; use defguard_client_proto::defguard::client_types::DeviceConfig; use serde::{Deserialize, Serialize}; +use sqlx::types::Json; pub mod app_config; pub mod connection; @@ -190,6 +191,13 @@ pub fn into_location(dev_config: DeviceConfig, instance_id: Id) -> Location>(), + ), } } diff --git a/src-tauri/core/src/mfa.rs b/src-tauri/core/src/mfa.rs index d1dcea1d..154e5c75 100644 --- a/src-tauri/core/src/mfa.rs +++ b/src-tauri/core/src/mfa.rs @@ -130,9 +130,7 @@ pub async fn mfa_start( Ok(response) => response, Err(err) => return Err(rewrap_mobile_start_error(request.method, err)), }; - // TODO(multi-step-mfa): a non-empty `rejections` in the response means the sent - // plan was refused and no session was created. Surface it instead of returning - // the response as if it were a success. + // TODO(multi-step-mfa): non-empty `rejections` means the plan was refused response.json().await.map_err(|e| MfaError::Other { message: format!("Invalid MFA start response: {e}"), }) diff --git a/src-tauri/enterprise/config-sync/src/commands.rs b/src-tauri/enterprise/config-sync/src/commands.rs index 9f78f72c..2e5ef0eb 100644 --- a/src-tauri/enterprise/config-sync/src/commands.rs +++ b/src-tauri/enterprise/config-sync/src/commands.rs @@ -121,6 +121,7 @@ pub async fn do_update_instance( current_location.mfa_method, ); current_location.posture_check_required = new_location.posture_check_required; + current_location.mfa_steps = new_location.mfa_steps; current_location.save(transaction.as_mut()).await?; info!("Location {current_location} configuration updated for instance {instance}"); current_location diff --git a/src-tauri/enterprise/config-sync/src/lib.rs b/src-tauri/enterprise/config-sync/src/lib.rs index d4475944..737cd099 100644 --- a/src-tauri/enterprise/config-sync/src/lib.rs +++ b/src-tauri/enterprise/config-sync/src/lib.rs @@ -653,6 +653,7 @@ mod tests { service_location_mode: ServiceLocationMode::Disabled, mfa_method: None, posture_check_required: false, + mfa_steps: Default::default(), } .save(pool) .await diff --git a/src-tauri/migrations/20260820120000_add_location_mfa_steps.sql b/src-tauri/migrations/20260820120000_add_location_mfa_steps.sql new file mode 100644 index 00000000..484ff027 --- /dev/null +++ b/src-tauri/migrations/20260820120000_add_location_mfa_steps.sql @@ -0,0 +1 @@ +ALTER TABLE location ADD COLUMN mfa_steps TEXT NOT NULL DEFAULT '[]'; diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 0b925d16..31e4e9e0 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -42,7 +42,7 @@ use crate::{ models::{ connection::{ActiveConnection, Connection, ConnectionInfo}, instance::{Instance, InstanceInfo}, - location::{Location, LocationMfaMethod, LocationMfaMode}, + location::{Location, LocationMfaMethod, LocationMfaMode, LocationMfaStep}, location_stats::LocationStats, tunnel::{Tunnel, TunnelConnection, TunnelConnectionInfo, TunnelStats}, wireguard_keys::WireguardKeys, @@ -585,6 +585,7 @@ pub struct LocationInfo { pub location_mfa_mode: LocationMfaMode, pub posture_check_required: bool, pub mfa_method: Option, + pub mfa_steps: Vec, } impl LocationInfo { @@ -639,6 +640,7 @@ pub async fn all_locations(instance_id: Id) -> Result, Error> location_mfa_mode: location.location_mfa_mode, posture_check_required: location.posture_check_required, mfa_method: location.mfa_method, + mfa_steps: location.mfa_steps.0, }; location_info.push(info); } @@ -1616,6 +1618,8 @@ pub async fn mfa_start( pubkey: keys.pubkey, method: method as i32, posture_data, + // TODO(multi-step-mfa): empty = legacy path; send one method per step + selected_methods: Vec::new(), }; mfa::mfa_start(proxy_url, request) .await From 90d7e4e4da4de53e91255aaa89e9515d72a1f049 Mon Sep 17 00:00:00 2001 From: Kuba <78603704+jakub-tldr@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:01:30 +0200 Subject: [PATCH 03/18] store per-step mfa choices --- ...51ab37a108a36013654abed126ee64da881f.json} | 10 ++++- ...8893e9c4854fd6e4e7a912790d27f1c20c9c.json} | 10 ++++- ...534a1794aaf8efc7315512a2b1952adc99f1.json} | 10 ++++- ...ec1f8180d976de306997946742a8b597a398.json} | 6 +-- ...df1ac7d98bb0598b92343e279cd339afdd8a.json} | 6 +-- ...bd0e736da3f08636061f302425b97fa609c8.json} | 10 ++++- ...71849de22deddc2564a493c8a998bca3e343.json} | 10 ++++- src-tauri/client-cli/src/commands/list.rs | 1 + src-tauri/client-cli/src/commands/location.rs | 1 + src-tauri/client-cli/src/mfa.rs | 1 + src-tauri/client-cli/src/resolve.rs | 1 + .../core/src/database/models/connection.rs | 1 + .../core/src/database/models/location.rs | 45 +++++++++++++++---- .../src/database/models/location_stats.rs | 1 + src-tauri/core/src/lib.rs | 1 + src-tauri/enterprise/config-sync/src/lib.rs | 1 + .../20260820120000_add_location_mfa_steps.sql | 1 + src-tauri/permissions/default.toml | 1 + src-tauri/src/commands.rs | 17 +++++++ src-tauri/src/gui.rs | 1 + 20 files changed, 110 insertions(+), 25 deletions(-) rename src-tauri/.sqlx/{query-06346116cf4b39692fa3d452b69d2bb446b2f0bf59caed8d75ecfd672bf25d83.json => query-0857b9594a855229c4444f07b02351ab37a108a36013654abed126ee64da881f.json} (87%) rename src-tauri/.sqlx/{query-3cab577f59f7f4436d6fca874bc1b79bfd1304af999b43d2fd28499daf519fec.json => query-48e11c4f529c9e702ceb4180d1548893e9c4854fd6e4e7a912790d27f1c20c9c.json} (88%) rename src-tauri/.sqlx/{query-97e6f30cc3b985a6b2b8862a82c02e05d6af0b7a33ddb011999fbe2f49da6644.json => query-561bcb97e47bda40f58ea3a99353534a1794aaf8efc7315512a2b1952adc99f1.json} (86%) rename src-tauri/.sqlx/{query-a6f74bd474e671ddc0df91b9622308b251ae2620c2ada2a9fb43c41fc4e1692e.json => query-84effef815ae3f82276e4ad5a5f6ec1f8180d976de306997946742a8b597a398.json} (65%) rename src-tauri/.sqlx/{query-7ecda7a2f1a2319915d57f34a2436b21fa7e795a5f59c466394e301f16cfda57.json => query-b910d1f333eccacde5476abeae0cdf1ac7d98bb0598b92343e279cd339afdd8a.json} (61%) rename src-tauri/.sqlx/{query-ea79a76cac4cdaea674534d00a007b4037c66196175d3deb9c2179bd6998fdc9.json => query-cc57fb8a4da341dd34639a720a81bd0e736da3f08636061f302425b97fa609c8.json} (88%) rename src-tauri/.sqlx/{query-ddfa8313a275ebd61382712cbf10d1a37b1e9b05dfc436774de4f992397848a9.json => query-db446260d0c5aa6b1cbb5fd389c571849de22deddc2564a493c8a998bca3e343.json} (87%) diff --git a/src-tauri/.sqlx/query-06346116cf4b39692fa3d452b69d2bb446b2f0bf59caed8d75ecfd672bf25d83.json b/src-tauri/.sqlx/query-0857b9594a855229c4444f07b02351ab37a108a36013654abed126ee64da881f.json similarity index 87% rename from src-tauri/.sqlx/query-06346116cf4b39692fa3d452b69d2bb446b2f0bf59caed8d75ecfd672bf25d83.json rename to src-tauri/.sqlx/query-0857b9594a855229c4444f07b02351ab37a108a36013654abed126ee64da881f.json index 58753633..dbe151db 100644 --- a/src-tauri/.sqlx/query-06346116cf4b39692fa3d452b69d2bb446b2f0bf59caed8d75ecfd672bf25d83.json +++ b/src-tauri/.sqlx/query-0857b9594a855229c4444f07b02351ab37a108a36013654abed126ee64da881f.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id, instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\", mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\" FROM location WHERE name = $1 AND service_location_mode <= $2 ORDER BY name ASC", + "query": "SELECT id, instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\", mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\", mfa_step_plan \"mfa_step_plan: _\" FROM location WHERE name = $1 AND service_location_mode <= $2 ORDER BY name ASC", "describe": { "columns": [ { @@ -82,6 +82,11 @@ "name": "mfa_steps: _", "ordinal": 15, "type_info": "Text" + }, + { + "name": "mfa_step_plan: _", + "ordinal": 16, + "type_info": "Text" } ], "parameters": { @@ -103,8 +108,9 @@ false, true, false, + false, false ] }, - "hash": "06346116cf4b39692fa3d452b69d2bb446b2f0bf59caed8d75ecfd672bf25d83" + "hash": "0857b9594a855229c4444f07b02351ab37a108a36013654abed126ee64da881f" } diff --git a/src-tauri/.sqlx/query-3cab577f59f7f4436d6fca874bc1b79bfd1304af999b43d2fd28499daf519fec.json b/src-tauri/.sqlx/query-48e11c4f529c9e702ceb4180d1548893e9c4854fd6e4e7a912790d27f1c20c9c.json similarity index 88% rename from src-tauri/.sqlx/query-3cab577f59f7f4436d6fca874bc1b79bfd1304af999b43d2fd28499daf519fec.json rename to src-tauri/.sqlx/query-48e11c4f529c9e702ceb4180d1548893e9c4854fd6e4e7a912790d27f1c20c9c.json index c08927ee..d1551b0c 100644 --- a/src-tauri/.sqlx/query-3cab577f59f7f4436d6fca874bc1b79bfd1304af999b43d2fd28499daf519fec.json +++ b/src-tauri/.sqlx/query-48e11c4f529c9e702ceb4180d1548893e9c4854fd6e4e7a912790d27f1c20c9c.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id \"id: _\", instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\",\n mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\" FROM location WHERE id = $1", + "query": "SELECT id \"id: _\", instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\",\n mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\", mfa_step_plan \"mfa_step_plan: _\" FROM location WHERE id = $1", "describe": { "columns": [ { @@ -82,6 +82,11 @@ "name": "mfa_steps: _", "ordinal": 15, "type_info": "Text" + }, + { + "name": "mfa_step_plan: _", + "ordinal": 16, + "type_info": "Text" } ], "parameters": { @@ -103,8 +108,9 @@ false, true, false, + false, false ] }, - "hash": "3cab577f59f7f4436d6fca874bc1b79bfd1304af999b43d2fd28499daf519fec" + "hash": "48e11c4f529c9e702ceb4180d1548893e9c4854fd6e4e7a912790d27f1c20c9c" } diff --git a/src-tauri/.sqlx/query-97e6f30cc3b985a6b2b8862a82c02e05d6af0b7a33ddb011999fbe2f49da6644.json b/src-tauri/.sqlx/query-561bcb97e47bda40f58ea3a99353534a1794aaf8efc7315512a2b1952adc99f1.json similarity index 86% rename from src-tauri/.sqlx/query-97e6f30cc3b985a6b2b8862a82c02e05d6af0b7a33ddb011999fbe2f49da6644.json rename to src-tauri/.sqlx/query-561bcb97e47bda40f58ea3a99353534a1794aaf8efc7315512a2b1952adc99f1.json index ac6ec2f9..088bcb92 100644 --- a/src-tauri/.sqlx/query-97e6f30cc3b985a6b2b8862a82c02e05d6af0b7a33ddb011999fbe2f49da6644.json +++ b/src-tauri/.sqlx/query-561bcb97e47bda40f58ea3a99353534a1794aaf8efc7315512a2b1952adc99f1.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id \"id: _\", instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\",\n mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\" FROM location WHERE instance_id = $1 AND service_location_mode <= $2 ORDER BY name ASC", + "query": "SELECT id \"id: _\", instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\",\n mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\", mfa_step_plan \"mfa_step_plan: _\" FROM location WHERE instance_id = $1 AND service_location_mode <= $2 ORDER BY name ASC", "describe": { "columns": [ { @@ -82,6 +82,11 @@ "name": "mfa_steps: _", "ordinal": 15, "type_info": "Text" + }, + { + "name": "mfa_step_plan: _", + "ordinal": 16, + "type_info": "Text" } ], "parameters": { @@ -103,8 +108,9 @@ false, true, false, + false, false ] }, - "hash": "97e6f30cc3b985a6b2b8862a82c02e05d6af0b7a33ddb011999fbe2f49da6644" + "hash": "561bcb97e47bda40f58ea3a99353534a1794aaf8efc7315512a2b1952adc99f1" } diff --git a/src-tauri/.sqlx/query-a6f74bd474e671ddc0df91b9622308b251ae2620c2ada2a9fb43c41fc4e1692e.json b/src-tauri/.sqlx/query-84effef815ae3f82276e4ad5a5f6ec1f8180d976de306997946742a8b597a398.json similarity index 65% rename from src-tauri/.sqlx/query-a6f74bd474e671ddc0df91b9622308b251ae2620c2ada2a9fb43c41fc4e1692e.json rename to src-tauri/.sqlx/query-84effef815ae3f82276e4ad5a5f6ec1f8180d976de306997946742a8b597a398.json index fc938b63..516eed93 100644 --- a/src-tauri/.sqlx/query-a6f74bd474e671ddc0df91b9622308b251ae2620c2ada2a9fb43c41fc4e1692e.json +++ b/src-tauri/.sqlx/query-84effef815ae3f82276e4ad5a5f6ec1f8180d976de306997946742a8b597a398.json @@ -1,12 +1,12 @@ { "db_name": "SQLite", - "query": "UPDATE location SET instance_id = $1, name = $2, address = $3, pubkey = $4, endpoint = $5, allowed_ips = $6, dns = $7, network_id = $8, route_all_traffic = $9, keepalive_interval = $10, location_mfa_mode = $11, service_location_mode = $12, mfa_method = $13, posture_check_required = $14, mfa_steps = $15 WHERE id = $16", + "query": "UPDATE location SET instance_id = $1, name = $2, address = $3, pubkey = $4, endpoint = $5, allowed_ips = $6, dns = $7, network_id = $8, route_all_traffic = $9, keepalive_interval = $10, location_mfa_mode = $11, service_location_mode = $12, mfa_method = $13, posture_check_required = $14, mfa_steps = $15, mfa_step_plan = $16 WHERE id = $17", "describe": { "columns": [], "parameters": { - "Right": 16 + "Right": 17 }, "nullable": [] }, - "hash": "a6f74bd474e671ddc0df91b9622308b251ae2620c2ada2a9fb43c41fc4e1692e" + "hash": "84effef815ae3f82276e4ad5a5f6ec1f8180d976de306997946742a8b597a398" } diff --git a/src-tauri/.sqlx/query-7ecda7a2f1a2319915d57f34a2436b21fa7e795a5f59c466394e301f16cfda57.json b/src-tauri/.sqlx/query-b910d1f333eccacde5476abeae0cdf1ac7d98bb0598b92343e279cd339afdd8a.json similarity index 61% rename from src-tauri/.sqlx/query-7ecda7a2f1a2319915d57f34a2436b21fa7e795a5f59c466394e301f16cfda57.json rename to src-tauri/.sqlx/query-b910d1f333eccacde5476abeae0cdf1ac7d98bb0598b92343e279cd339afdd8a.json index 9848220c..a4b1c5cf 100644 --- a/src-tauri/.sqlx/query-7ecda7a2f1a2319915d57f34a2436b21fa7e795a5f59c466394e301f16cfda57.json +++ b/src-tauri/.sqlx/query-b910d1f333eccacde5476abeae0cdf1ac7d98bb0598b92343e279cd339afdd8a.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "INSERT INTO location (instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode, service_location_mode, mfa_method, posture_check_required, mfa_steps) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id \"id!\"", + "query": "INSERT INTO location (instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode, service_location_mode, mfa_method, posture_check_required, mfa_steps, mfa_step_plan) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING id \"id!\"", "describe": { "columns": [ { @@ -10,11 +10,11 @@ } ], "parameters": { - "Right": 15 + "Right": 16 }, "nullable": [ true ] }, - "hash": "7ecda7a2f1a2319915d57f34a2436b21fa7e795a5f59c466394e301f16cfda57" + "hash": "b910d1f333eccacde5476abeae0cdf1ac7d98bb0598b92343e279cd339afdd8a" } diff --git a/src-tauri/.sqlx/query-ea79a76cac4cdaea674534d00a007b4037c66196175d3deb9c2179bd6998fdc9.json b/src-tauri/.sqlx/query-cc57fb8a4da341dd34639a720a81bd0e736da3f08636061f302425b97fa609c8.json similarity index 88% rename from src-tauri/.sqlx/query-ea79a76cac4cdaea674534d00a007b4037c66196175d3deb9c2179bd6998fdc9.json rename to src-tauri/.sqlx/query-cc57fb8a4da341dd34639a720a81bd0e736da3f08636061f302425b97fa609c8.json index d004f20c..292823f5 100644 --- a/src-tauri/.sqlx/query-ea79a76cac4cdaea674534d00a007b4037c66196175d3deb9c2179bd6998fdc9.json +++ b/src-tauri/.sqlx/query-cc57fb8a4da341dd34639a720a81bd0e736da3f08636061f302425b97fa609c8.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id \"id: _\", instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\",\n mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\" FROM location WHERE pubkey = $1", + "query": "SELECT id \"id: _\", instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\",\n mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\", mfa_step_plan \"mfa_step_plan: _\" FROM location WHERE pubkey = $1", "describe": { "columns": [ { @@ -82,6 +82,11 @@ "name": "mfa_steps: _", "ordinal": 15, "type_info": "Text" + }, + { + "name": "mfa_step_plan: _", + "ordinal": 16, + "type_info": "Text" } ], "parameters": { @@ -103,8 +108,9 @@ false, true, false, + false, false ] }, - "hash": "ea79a76cac4cdaea674534d00a007b4037c66196175d3deb9c2179bd6998fdc9" + "hash": "cc57fb8a4da341dd34639a720a81bd0e736da3f08636061f302425b97fa609c8" } diff --git a/src-tauri/.sqlx/query-ddfa8313a275ebd61382712cbf10d1a37b1e9b05dfc436774de4f992397848a9.json b/src-tauri/.sqlx/query-db446260d0c5aa6b1cbb5fd389c571849de22deddc2564a493c8a998bca3e343.json similarity index 87% rename from src-tauri/.sqlx/query-ddfa8313a275ebd61382712cbf10d1a37b1e9b05dfc436774de4f992397848a9.json rename to src-tauri/.sqlx/query-db446260d0c5aa6b1cbb5fd389c571849de22deddc2564a493c8a998bca3e343.json index 3bbba910..86d2c790 100644 --- a/src-tauri/.sqlx/query-ddfa8313a275ebd61382712cbf10d1a37b1e9b05dfc436774de4f992397848a9.json +++ b/src-tauri/.sqlx/query-db446260d0c5aa6b1cbb5fd389c571849de22deddc2564a493c8a998bca3e343.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id, instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\", mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\" FROM location WHERE service_location_mode <= $1 ORDER BY name ASC", + "query": "SELECT id, instance_id, name, address, pubkey, endpoint, allowed_ips, dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode \"location_mfa_mode: LocationMfaMode\", service_location_mode \"service_location_mode: ServiceLocationMode\", mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\", mfa_step_plan \"mfa_step_plan: _\" FROM location WHERE service_location_mode <= $1 ORDER BY name ASC", "describe": { "columns": [ { @@ -82,6 +82,11 @@ "name": "mfa_steps: _", "ordinal": 15, "type_info": "Text" + }, + { + "name": "mfa_step_plan: _", + "ordinal": 16, + "type_info": "Text" } ], "parameters": { @@ -103,8 +108,9 @@ false, true, false, + false, false ] }, - "hash": "ddfa8313a275ebd61382712cbf10d1a37b1e9b05dfc436774de4f992397848a9" + "hash": "db446260d0c5aa6b1cbb5fd389c571849de22deddc2564a493c8a998bca3e343" } diff --git a/src-tauri/client-cli/src/commands/list.rs b/src-tauri/client-cli/src/commands/list.rs index 60997220..4f65aa1a 100644 --- a/src-tauri/client-cli/src/commands/list.rs +++ b/src-tauri/client-cli/src/commands/list.rs @@ -247,6 +247,7 @@ mod tests { mfa_method: None, posture_check_required: false, mfa_steps: Default::default(), + mfa_step_plan: Default::default(), } } diff --git a/src-tauri/client-cli/src/commands/location.rs b/src-tauri/client-cli/src/commands/location.rs index 298e65c8..d83ea966 100644 --- a/src-tauri/client-cli/src/commands/location.rs +++ b/src-tauri/client-cli/src/commands/location.rs @@ -350,6 +350,7 @@ mod tests { ) -> Location { Location { mfa_steps: Default::default(), + mfa_step_plan: Default::default(), id, instance_id, network_id: 1, diff --git a/src-tauri/client-cli/src/mfa.rs b/src-tauri/client-cli/src/mfa.rs index 97fb6f6e..241e20d8 100644 --- a/src-tauri/client-cli/src/mfa.rs +++ b/src-tauri/client-cli/src/mfa.rs @@ -407,6 +407,7 @@ mod tests { fn location(name: &str, mode: LocationMfaMode) -> Location { Location { mfa_steps: Default::default(), + mfa_step_plan: Default::default(), id: 1, instance_id: 1, network_id: 1, diff --git a/src-tauri/client-cli/src/resolve.rs b/src-tauri/client-cli/src/resolve.rs index 4e623734..0066347a 100644 --- a/src-tauri/client-cli/src/resolve.rs +++ b/src-tauri/client-cli/src/resolve.rs @@ -207,6 +207,7 @@ mod tests { mfa_method: None, posture_check_required: false, mfa_steps: Default::default(), + mfa_step_plan: Default::default(), } } diff --git a/src-tauri/core/src/database/models/connection.rs b/src-tauri/core/src/database/models/connection.rs index 249442bf..40f79f8c 100644 --- a/src-tauri/core/src/database/models/connection.rs +++ b/src-tauri/core/src/database/models/connection.rs @@ -206,6 +206,7 @@ mod tests { mfa_method: None, posture_check_required: false, mfa_steps: Default::default(), + mfa_step_plan: Default::default(), } .save(pool) .await diff --git a/src-tauri/core/src/database/models/location.rs b/src-tauri/core/src/database/models/location.rs index fbb1040c..d99eb046 100644 --- a/src-tauri/core/src/database/models/location.rs +++ b/src-tauri/core/src/database/models/location.rs @@ -165,6 +165,8 @@ pub struct Location { pub posture_check_required: bool, #[serde(default)] pub mfa_steps: Json>, + #[serde(default)] + pub mfa_step_plan: Json>, } impl fmt::Display for Location { @@ -193,7 +195,8 @@ impl Location { network_id, route_all_traffic, keepalive_interval, \ location_mfa_mode \"location_mfa_mode: LocationMfaMode\", \ service_location_mode \"service_location_mode: ServiceLocationMode\", \ - mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\" \ + mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\", \ + mfa_step_plan \"mfa_step_plan: _\" \ FROM location WHERE service_location_mode <= $1 \ ORDER BY name ASC", max_service_location_mode @@ -231,7 +234,8 @@ impl Location { network_id, route_all_traffic, keepalive_interval, \ location_mfa_mode \"location_mfa_mode: LocationMfaMode\", \ service_location_mode \"service_location_mode: ServiceLocationMode\", \ - mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\" \ + mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\", \ + mfa_step_plan \"mfa_step_plan: _\" \ FROM location WHERE name = $1 AND service_location_mode <= $2 ORDER BY name ASC", name, max, @@ -249,8 +253,9 @@ impl Location { "UPDATE location SET instance_id = $1, name = $2, address = $3, pubkey = $4, \ endpoint = $5, allowed_ips = $6, dns = $7, network_id = $8, route_all_traffic = $9, \ keepalive_interval = $10, location_mfa_mode = $11, service_location_mode = $12, \ - mfa_method = $13, posture_check_required = $14, mfa_steps = $15 \ - WHERE id = $16", + mfa_method = $13, posture_check_required = $14, mfa_steps = $15, \ + mfa_step_plan = $16 \ + WHERE id = $17", self.instance_id, self.name, self.address, @@ -266,6 +271,7 @@ impl Location { self.mfa_method, self.posture_check_required, self.mfa_steps, + self.mfa_step_plan, self.id, ) .execute(executor) @@ -284,7 +290,8 @@ impl Location { network_id, route_all_traffic, keepalive_interval, \ location_mfa_mode \"location_mfa_mode: LocationMfaMode\", \ service_location_mode \"service_location_mode: ServiceLocationMode\", - mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\" \ + mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\", \ + mfa_step_plan \"mfa_step_plan: _\" \ FROM location WHERE id = $1", location_id ) @@ -308,7 +315,8 @@ impl Location { network_id, route_all_traffic, keepalive_interval, \ location_mfa_mode \"location_mfa_mode: LocationMfaMode\", \ service_location_mode \"service_location_mode: ServiceLocationMode\", - mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\" \ + mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\", \ + mfa_step_plan \"mfa_step_plan: _\" \ FROM location WHERE instance_id = $1 AND service_location_mode <= $2 \ ORDER BY name ASC", instance_id, @@ -328,7 +336,8 @@ impl Location { network_id, route_all_traffic, keepalive_interval, \ location_mfa_mode \"location_mfa_mode: LocationMfaMode\", \ service_location_mode \"service_location_mode: ServiceLocationMode\", - mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\" \ + mfa_method \"mfa_method: _\", posture_check_required, mfa_steps \"mfa_steps: _\", \ + mfa_step_plan \"mfa_step_plan: _\" \ FROM location WHERE pubkey = $1", pubkey ) @@ -491,6 +500,19 @@ impl Location { Ok(()) } + pub async fn set_mfa_step_plan( + pool: &DbPool, + location_id: Id, + plan: Vec, + ) -> Result<(), Error> { + let mut location = Self::find_by_id(pool, location_id) + .await? + .ok_or(Error::NotFound)?; + location.mfa_step_plan = Json(plan); + location.save(pool).await?; + Ok(()) + } + /// Persist the route-all-traffic flag for a location, rejecting the update if /// the owning instance's [`ClientTrafficPolicy`] forbids the requested value. pub async fn update_routing( @@ -547,8 +569,9 @@ impl Location { let id = query_scalar!( "INSERT INTO location (instance_id, name, address, pubkey, endpoint, allowed_ips, \ dns, network_id, route_all_traffic, keepalive_interval, location_mfa_mode, \ - service_location_mode, mfa_method, posture_check_required, mfa_steps) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) \ + service_location_mode, mfa_method, posture_check_required, mfa_steps, \ + mfa_step_plan) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) \ RETURNING id \"id!\"", self.instance_id, self.name, @@ -565,6 +588,7 @@ impl Location { self.mfa_method, self.posture_check_required, self.mfa_steps, + self.mfa_step_plan, ) .fetch_one(executor) .await?; @@ -586,6 +610,7 @@ impl Location { mfa_method: self.mfa_method, posture_check_required: self.posture_check_required, mfa_steps: self.mfa_steps, + mfa_step_plan: self.mfa_step_plan, }) } } @@ -616,6 +641,7 @@ impl From> for Location { mfa_method: location.mfa_method, posture_check_required: location.posture_check_required, mfa_steps: location.mfa_steps, + mfa_step_plan: location.mfa_step_plan, } } } @@ -661,6 +687,7 @@ mod tests { mfa_method: None, posture_check_required: false, mfa_steps: Default::default(), + mfa_step_plan: Default::default(), } } diff --git a/src-tauri/core/src/database/models/location_stats.rs b/src-tauri/core/src/database/models/location_stats.rs index fb21d04d..40981eef 100644 --- a/src-tauri/core/src/database/models/location_stats.rs +++ b/src-tauri/core/src/database/models/location_stats.rs @@ -280,6 +280,7 @@ mod tests { mfa_method: None, posture_check_required: false, mfa_steps: Default::default(), + mfa_step_plan: Default::default(), } .save(pool) .await diff --git a/src-tauri/core/src/lib.rs b/src-tauri/core/src/lib.rs index 08435f13..c990dcbe 100644 --- a/src-tauri/core/src/lib.rs +++ b/src-tauri/core/src/lib.rs @@ -198,6 +198,7 @@ pub fn into_location(dev_config: DeviceConfig, instance_id: Id) -> Location>(), ), + mfa_step_plan: Json::default(), } } diff --git a/src-tauri/enterprise/config-sync/src/lib.rs b/src-tauri/enterprise/config-sync/src/lib.rs index 737cd099..474e05b0 100644 --- a/src-tauri/enterprise/config-sync/src/lib.rs +++ b/src-tauri/enterprise/config-sync/src/lib.rs @@ -654,6 +654,7 @@ mod tests { mfa_method: None, posture_check_required: false, mfa_steps: Default::default(), + mfa_step_plan: Default::default(), } .save(pool) .await diff --git a/src-tauri/migrations/20260820120000_add_location_mfa_steps.sql b/src-tauri/migrations/20260820120000_add_location_mfa_steps.sql index 484ff027..cad98352 100644 --- a/src-tauri/migrations/20260820120000_add_location_mfa_steps.sql +++ b/src-tauri/migrations/20260820120000_add_location_mfa_steps.sql @@ -1 +1,2 @@ ALTER TABLE location ADD COLUMN mfa_steps TEXT NOT NULL DEFAULT '[]'; +ALTER TABLE location ADD COLUMN mfa_step_plan TEXT NOT NULL DEFAULT '[]'; diff --git a/src-tauri/permissions/default.toml b/src-tauri/permissions/default.toml index e6aac4cc..83f276b6 100644 --- a/src-tauri/permissions/default.toml +++ b/src-tauri/permissions/default.toml @@ -43,6 +43,7 @@ commands.allow = [ "get_provisioning_config", "get_platform_header", "set_location_mfa_method", + "set_location_mfa_step_plan", "open_tray_window", "open_full_view_window", "swap_to_tray", diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 31e4e9e0..c5fba683 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -586,6 +586,7 @@ pub struct LocationInfo { pub posture_check_required: bool, pub mfa_method: Option, pub mfa_steps: Vec, + pub mfa_step_plan: Vec, } impl LocationInfo { @@ -641,6 +642,7 @@ pub async fn all_locations(instance_id: Id) -> Result, Error> posture_check_required: location.posture_check_required, mfa_method: location.mfa_method, mfa_steps: location.mfa_steps.0, + mfa_step_plan: location.mfa_step_plan.0, }; location_info.push(info); } @@ -909,6 +911,21 @@ pub async fn set_location_mfa_method( Ok(()) } +#[tauri::command(async)] +pub async fn set_location_mfa_step_plan( + location_id: Id, + mfa_step_plan: Vec, + handle: AppHandle, +) -> Result<(), Error> { + debug!("Received command to set MFA step plan for location {location_id}"); + Location::set_mfa_step_plan(&DB_POOL, location_id, mfa_step_plan).await?; + debug!("MFA step plan updated for location (ID: {location_id})"); + handle + .emit(EventKey::LocationUpdate.into(), ()) + .map_err(tauri_err_to_app_err)?; + Ok(()) +} + #[cfg(target_os = "macos")] #[tauri::command(async)] pub async fn delete_instance(instance_id: Id, handle: AppHandle) -> Result<(), Error> { diff --git a/src-tauri/src/gui.rs b/src-tauri/src/gui.rs index 9551ca1b..3d10e7c4 100644 --- a/src-tauri/src/gui.rs +++ b/src-tauri/src/gui.rs @@ -195,6 +195,7 @@ pub fn run_app() { get_platform_header, get_posture_data, set_location_mfa_method, + set_location_mfa_step_plan, open_tray_window, open_full_view_window, swap_to_tray, From be846da5fc1e2cd056bafecadd45738858018b96 Mon Sep 17 00:00:00 2001 From: Kuba <78603704+jakub-tldr@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:20:32 +0200 Subject: [PATCH 04/18] add mfa step types and commands --- new-ui/src/shared/rust-api/api.ts | 5 +++++ new-ui/src/shared/rust-api/types.ts | 17 +++++++++++++++++ new-ui/src/shared/utils/mfa.ts | 26 ++++++++++++++++++++++++-- 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/new-ui/src/shared/rust-api/api.ts b/new-ui/src/shared/rust-api/api.ts index 98c23ff6..7c06e6d8 100644 --- a/new-ui/src/shared/rust-api/api.ts +++ b/new-ui/src/shared/rust-api/api.ts @@ -25,6 +25,7 @@ import type { SessionState, SessionStatePatch, SetLocationMfaMethodArgs, + SetLocationMfaStepPlanArgs, StatsArgs, TunnelInfo, TunnelRequest, @@ -59,6 +60,9 @@ const updateLocationRouting = (args: RoutingArgs): Promise => const setLocationMfaMethod = (args: SetLocationMfaMethodArgs): Promise => invoke(TauriCommand.SetLocationMfaMethod, args); +const setLocationMfaStepPlan = (args: SetLocationMfaStepPlanArgs): Promise => + invoke(TauriCommand.SetLocationMfaStepPlan, args); + const connect = (args: ConnectionArgs): Promise => invoke(TauriCommand.Connect, args); @@ -240,6 +244,7 @@ export const api = { getLocationDetails, updateLocationRouting, setLocationMfaMethod, + setLocationMfaStepPlan, // Connections connect, disconnect, diff --git a/new-ui/src/shared/rust-api/types.ts b/new-ui/src/shared/rust-api/types.ts index f6fc68fd..2f7a4eb1 100644 --- a/new-ui/src/shared/rust-api/types.ts +++ b/new-ui/src/shared/rust-api/types.ts @@ -106,6 +106,7 @@ export const TauriCommand = { LocationInterfaceDetails: 'location_interface_details', UpdateLocationRouting: 'update_location_routing', SetLocationMfaMethod: 'set_location_mfa_method', + SetLocationMfaStepPlan: 'set_location_mfa_step_plan', // Connections Connect: 'connect', Disconnect: 'disconnect', @@ -233,6 +234,15 @@ export type InstanceInfo = { openid_display_name: string | null; }; +export type MfaStepMethod = { + method: MfaMethodValue; + configured: boolean; +}; + +export type MfaStep = { + methods: MfaStepMethod[]; +}; + export type LocationInfo = { id: number; instance_id: number; @@ -247,6 +257,8 @@ export type LocationInfo = { location_mfa_mode: LocationMfaMode; mfa_method?: MfaMethodValue; posture_check_required: boolean; + mfa_steps: MfaStep[]; + mfa_step_plan: MfaMethodValue[]; }; export type LocationStats = { @@ -404,6 +416,11 @@ export type SetLocationMfaMethodArgs = { mfaMethod: MfaMethodValue; }; +export type SetLocationMfaStepPlanArgs = { + locationId: number; + mfaStepPlan: MfaMethodValue[]; +}; + export type OverviewViewSelection = { kind: 'instance' | 'tunnel'; id: number; diff --git a/new-ui/src/shared/utils/mfa.ts b/new-ui/src/shared/utils/mfa.ts index c29b07d6..d9a7a875 100644 --- a/new-ui/src/shared/utils/mfa.ts +++ b/new-ui/src/shared/utils/mfa.ts @@ -4,6 +4,7 @@ import { LocationMfaMode, MfaMethod, type MfaMethodValue, + type MfaStepMethod, } from '../rust-api/types'; const mfaMethodLabels: Record = { @@ -31,7 +32,28 @@ export const mfaToApi = (factor: MfaMethodValue): string => mfaMethodApiValues[f * server-managed locations (never bare tunnels) that have MFA enabled. */ export const shouldStartMfa = ( - location: Pick, + location: Pick, ): boolean => location.connection_type !== ConnectionType.Tunnel && - location.location_mfa_mode !== LocationMfaMode.Disabled; + (location.mfa_steps.length > 0 || + location.location_mfa_mode !== LocationMfaMode.Disabled); + +export const mfaStepCount = ( + location: Pick, +): number => + location.connection_type !== ConnectionType.Tunnel ? location.mfa_steps.length : 0; + +export const isMfaMethodUsable = (entry: MfaStepMethod): boolean => + entry.configured && entry.method !== MfaMethod.Biometric; + +/** + * A step with no usable factor cannot be passed on the desktop. This shouldn't happen + * because such configuration won't be sent from core. + * TODO: block connecting only until the user can configure the missing factors in place. + */ +export const hasUnpassableMfaStep = ( + location: Pick, +): boolean => location.mfa_steps.some((step) => !step.methods.some(isMfaMethodUsable)); + +export const mfaStepsToText = (stepCount: number): string => + `${stepCount}-step verification`; From 80a633aeba62e0127749c835a989caaafec91388 Mon Sep 17 00:00:00 2001 From: Kuba <78603704+jakub-tldr@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:17:39 +0200 Subject: [PATCH 05/18] tray-view multi-mfa flow --- .../components/LocationCard/LocationCard.tsx | 7 +- .../components/LocationCardConnectButton.tsx | 8 +- .../LocationCardConnectionTiles.tsx | 37 +++--- .../LocationCardMfaEdit.tsx | 19 ++- .../components/MfaSelector/MfaSelector.tsx | 26 +++- .../components/MfaSelector/style.scss | 32 ++++- .../LocationCard/context/context.tsx | 91 +++++++++++--- .../components/LocationCard/context/types.ts | 17 +++ .../LocationCard/hooks/useMfaConnect.ts | 19 ++- .../LocationCard/hooks/useMfaMobileConnect.ts | 26 +++- .../LocationCard/hooks/useMfaOidcConnect.ts | 15 ++- .../LocationCardMfaEmailView.tsx | 28 +++-- .../LocationCardMfaMobileView.tsx | 28 +++-- .../LocationCardMfaOidcView.tsx | 5 +- .../LocationCardMfaSettings.tsx | 116 +++++++++++++----- .../views/LocationCardMfaSettings/style.scss | 24 ++++ .../LocationCardMfaTotpView.tsx | 28 +++-- .../OverviewLocationCard.tsx | 4 +- 18 files changed, 420 insertions(+), 110 deletions(-) diff --git a/new-ui/src/shared/components/LocationCard/LocationCard.tsx b/new-ui/src/shared/components/LocationCard/LocationCard.tsx index 35a08166..0fcc05eb 100644 --- a/new-ui/src/shared/components/LocationCard/LocationCard.tsx +++ b/new-ui/src/shared/components/LocationCard/LocationCard.tsx @@ -1,6 +1,7 @@ import './style.scss'; import clsx from 'clsx'; import type { ReactNode } from 'react'; +import { Fragment } from 'react/jsx-runtime'; import type { InstanceInfo, LocationInfo } from '../../rust-api/types'; import { Direction } from '../../types'; import { Fold } from '../Fold/Fold'; @@ -48,7 +49,7 @@ interface InnerProps { } const LocationCardInner = ({ isOpen, onOpen, disableOpen }: InnerProps) => { - const { location, currentView } = useLocationCardContext(); + const { location, currentView, stepIndex } = useLocationCardContext(); return (
{ )}
- {views[currentView]} + + {views[currentView]} + ); }; diff --git a/new-ui/src/shared/components/LocationCard/components/LocationCardConnectButton.tsx b/new-ui/src/shared/components/LocationCard/components/LocationCardConnectButton.tsx index ce435921..a1a1c263 100644 --- a/new-ui/src/shared/components/LocationCard/components/LocationCardConnectButton.tsx +++ b/new-ui/src/shared/components/LocationCard/components/LocationCardConnectButton.tsx @@ -1,6 +1,6 @@ import { useMutation } from '@tanstack/react-query'; import { api } from '../../../rust-api/api'; -import { shouldStartMfa } from '../../../utils/mfa'; +import { hasUnpassableMfaStep, shouldStartMfa } from '../../../utils/mfa'; import { parseConnectError } from '../api/connectError'; import { useLocationCardContext } from '../context/context'; import { LocationCardViews } from '../context/types'; @@ -61,6 +61,10 @@ export const LocationCardConnectButton = () => { }; return ( - + ); }; diff --git a/new-ui/src/shared/components/LocationCard/components/LocationCardConnectionTiles/LocationCardConnectionTiles.tsx b/new-ui/src/shared/components/LocationCard/components/LocationCardConnectionTiles/LocationCardConnectionTiles.tsx index a0331475..c702a9fc 100644 --- a/new-ui/src/shared/components/LocationCard/components/LocationCardConnectionTiles/LocationCardConnectionTiles.tsx +++ b/new-ui/src/shared/components/LocationCard/components/LocationCardConnectionTiles/LocationCardConnectionTiles.tsx @@ -9,7 +9,7 @@ import { LocationMfaMode, } from '../../../../rust-api/types'; import { isPresent } from '../../../../utils/isPresent'; -import { mfaToText } from '../../../../utils/mfa'; +import { mfaStepCount, mfaStepsToText, mfaToText } from '../../../../utils/mfa'; import { BoxIcon } from '../../../BoxIcon/BoxIcon'; import { Icon, IconKind } from '../../../Icon'; @@ -26,11 +26,21 @@ export const LocationCardConnectionTiles = ({ location, instance, variant }: Pro location.route_all_traffic || instance?.client_traffic_policy === ClientTrafficPolicy.ForceAllTraffic; - const mfaMethod = useMemo(() => { + const stepCount = mfaStepCount(location); + const isMultiStep = stepCount > 1; + + const mfaLabel = useMemo(() => { + if (isMultiStep) return mfaStepsToText(stepCount); const key = `${location.connection_type.toLowerCase()}-${location.id}`; const method = connectionMfaMethod[key]; - return method; - }, [connectionMfaMethod, location.connection_type.toLowerCase, location.id]); + return isPresent(method) ? mfaToText(method) : null; + }, [ + connectionMfaMethod, + location.connection_type, + location.id, + stepCount, + isMultiStep, + ]); return (
@@ -43,16 +53,15 @@ export const LocationCardConnectionTiles = ({ location, instance, variant }: Pro {routeAllTraffic ? 'All traffic' : 'Predefined traffic'}

- {location.location_mfa_mode !== LocationMfaMode.Disabled && - isPresent(mfaMethod) && ( -
- - - -

Active MFA

-

{mfaToText(mfaMethod)}

-
- )} + {location.location_mfa_mode !== LocationMfaMode.Disabled && isPresent(mfaLabel) && ( +
+ + + +

Active MFA

+

{mfaLabel}

+
+ )} ); }; diff --git a/new-ui/src/shared/components/LocationCard/components/LocationCardMfaEdit/LocationCardMfaEdit.tsx b/new-ui/src/shared/components/LocationCard/components/LocationCardMfaEdit/LocationCardMfaEdit.tsx index 5f3724fd..1ed59a95 100644 --- a/new-ui/src/shared/components/LocationCard/components/LocationCardMfaEdit/LocationCardMfaEdit.tsx +++ b/new-ui/src/shared/components/LocationCard/components/LocationCardMfaEdit/LocationCardMfaEdit.tsx @@ -1,7 +1,7 @@ import './style.scss'; import clsx from 'clsx'; import type { LocationInfo } from '../../../../rust-api/types'; -import { mfaToText } from '../../../../utils/mfa'; +import { mfaStepCount, mfaStepsToText, mfaToText } from '../../../../utils/mfa'; import { IconButton } from '../../../IconButton/IconButton'; import { IconButtonVariant } from '../../../IconButton/types'; @@ -12,15 +12,26 @@ interface Props { } export const LocationCardMfaEdit = ({ location, onEdit, variant }: Props) => { - if (location.location_mfa_mode === 'disabled' || !location.mfa_method) return null; + const stepCount = mfaStepCount(location); + const isMultiStep = stepCount > 1; + + const label = isMultiStep + ? mfaStepsToText(stepCount) + : location.mfa_method && mfaToText(location.mfa_method); + + const canEdit = isMultiStep + ? location.mfa_steps.some((step) => step.methods.length > 1) + : location.location_mfa_mode === 'internal'; + + if ((location.location_mfa_mode === 'disabled' && !isMultiStep) || !label) return null; return (

MFA

-

{mfaToText(location.mfa_method)}

- {location.location_mfa_mode === 'internal' && !location.active && ( +

{label}

+ {canEdit && !location.active && ( ; containerProps?: Omit, 'onClick'>; } @@ -19,6 +20,7 @@ export const MfaSelector = ({ containerProps, selected = false, isDefault = false, + configured = true, }: Props) => { const iconKind = useMemo((): IconKindValue => { switch (factor) { @@ -35,25 +37,41 @@ export const MfaSelector = ({ } }, [factor]); + const isMobileOnly = factor === MfaMethod.Biometric; + const selectable = configured && !isMobileOnly; + return (
{ + if (selectable) { + onClick?.(event); + } + }} data-factor={factor} >

{mfaToText(factor)}

- {isDefault && ( +
+
+ {!selectable && ( +

+ {isMobileOnly ? 'Mobile client only' : 'Not configured'} +

+ )} + {selectable && isDefault && (

Default

)} + {selectable && !isDefault && selected && }
- {selected && }
); }; diff --git a/new-ui/src/shared/components/LocationCard/components/MfaSelector/style.scss b/new-ui/src/shared/components/LocationCard/components/MfaSelector/style.scss index 16432580..82b67188 100644 --- a/new-ui/src/shared/components/LocationCard/components/MfaSelector/style.scss +++ b/new-ui/src/shared/components/LocationCard/components/MfaSelector/style.scss @@ -6,7 +6,7 @@ --box-shadow: box-shadow: 0 4px 4px 0 rgb(0 0 0 / 0%); display: grid; - grid-template-columns: 20px minmax(0, 1fr) 16px; + grid-template-columns: 20px minmax(0, 1fr) auto; column-gap: var(--spacing-sm); background: var(--bg); border: 1px solid var(--border); @@ -39,6 +39,22 @@ --box-shadow: box-shadow: 0 4px 4px 0 rgb(0 0 0 / 5%); } + &.disabled { + --bg: transparent; + --border: var(--border-action-disabled); + --color: var(--fg-white-40); + --icon: var(--fg-white-40); + + cursor: not-allowed; + + &:hover { + --bg: transparent; + --border: var(--border-action-disabled); + --color: var(--fg-white-40); + --icon: var(--fg-white-40); + } + } + > .middle { display: flex; flex-flow: row nowrap; @@ -47,6 +63,14 @@ column-gap: var(--spacing-md); } + > .right { + display: flex; + flex-flow: row nowrap; + align-items: center; + justify-content: flex-end; + column-gap: var(--spacing-sm); + } + .default-badge { display: inline-block; box-sizing: border-box; @@ -63,6 +87,12 @@ } } + .disabled-label { + color: var(--fg-white-40); + font: var(--t-body-sm-400); + white-space: nowrap; + } + .factor-icon { --icon-color: var(--icon); diff --git a/new-ui/src/shared/components/LocationCard/context/context.tsx b/new-ui/src/shared/components/LocationCard/context/context.tsx index e9ae26d3..dd31ba45 100644 --- a/new-ui/src/shared/components/LocationCard/context/context.tsx +++ b/new-ui/src/shared/components/LocationCard/context/context.tsx @@ -4,16 +4,22 @@ import { useCallback, useContext, useEffect, + useMemo, useRef, useState, } from 'react'; import { useAppData } from '../../../providers/AppDataContext'; import { api } from '../../../rust-api/api'; -import type { InstanceInfo, LocationInfo } from '../../../rust-api/types'; +import type { InstanceInfo, LocationInfo, MfaStep } from '../../../rust-api/types'; import { ConnectionType, MfaMethod, type MfaMethodValue } from '../../../rust-api/types'; import { useAppStore } from '../../../store/useAppStore'; import { isPresent } from '../../../utils/isPresent'; -import { LocationCardViews, type LocationCardViewsValue } from './types'; +import { isMfaMethodUsable, mfaToText, shouldStartMfa } from '../../../utils/mfa'; +import { + LocationCardViews, + type LocationCardViewsValue, + mfaMethodToLocationCardView, +} from './types'; interface LocationCardContextValue { location: LocationInfo; @@ -23,7 +29,13 @@ interface LocationCardContextValue { postureError: string | null; autoConnectOpenid: boolean; mfaMethod: MfaMethodValue; + canPickOtherMethod: boolean; + stepPlan: MfaMethodValue[]; + stepIndex: number; + stepLabel: string | null; + onStepPassed?: () => void; setMfaMethod: (value: MfaMethodValue) => void; + setStepPlanOnce: (plan: MfaMethodValue[]) => void; setView: (view: LocationCardViewsValue) => void; setPostureError: (error: string | null) => void; startMfa: () => void; @@ -63,6 +75,27 @@ export const LocationCardProvider = ({ location.mfa_method ?? MfaMethod.Totp, ); + const mfaSteps = useMemo( + () => (shouldStartMfa(location) ? location.mfa_steps : []), + [location], + ); + const isMultiStep = mfaSteps.length > 1; + + // one-off choice made through "Other methods", dropped when a new flow starts + const [stepPlanOnce, setStepPlanOnce] = useState([]); + const stepPlan = useMemo( + () => + mfaSteps.map((step, index) => { + const usable = step.methods.filter(isMfaMethodUsable); + const chosen = [stepPlanOnce[index], location.mfa_step_plan[index]].find( + (method) => usable.some((entry) => entry.method === method), + ); + return chosen ?? (usable[0] ?? step.methods[0]).method; + }), + [mfaSteps, stepPlanOnce, location.mfa_step_plan], + ); + const [stepIndex, setStepIndex] = useState(0); + // Other location updates must not undo an optimistic connection transition. // biome-ignore lint/correctness/useExhaustiveDependencies: synchronize only on active state useEffect(() => { @@ -71,6 +104,7 @@ export const LocationCardProvider = ({ } else { setMfaMethod(location.mfa_method ?? MfaMethod.Totp); setCurrentView(LocationCardViews.Default); + setStepIndex(0); } }, [location.active]); @@ -82,25 +116,33 @@ export const LocationCardProvider = ({ [currentView], ); + const passStep = useCallback(() => { + const next = stepIndex + 1; + if (next < stepPlan.length) { + setStepIndex(next); + setView(mfaMethodToLocationCardView(stepPlan[next])); + return; + } + setStepIndex(0); + // TODO(mock): the last step ends the flow without connecting; connect here once + // MfaCompleted brings up the tunnel + setView(LocationCardViews.Default); + }, [setView, stepPlan, stepIndex]); + + const onStepPassed = isMultiStep ? passStep : undefined; + const startMfa = useCallback(async () => { mfaStarted.current = true; const appConfig = await api.getAppConfig(); setAutoConnectOpenid(appConfig.auto_start_openid_mfa); - switch (mfaMethod) { - case MfaMethod.Totp: - setView(LocationCardViews.MfaTotp); - break; - case MfaMethod.Email: - setView(LocationCardViews.MfaEmail); - break; - case MfaMethod.Oidc: - setView(LocationCardViews.MfaOidc); - break; - case MfaMethod.MobileApprove: - setView(LocationCardViews.MfaMobile); - break; + if (isMultiStep) { + setStepPlanOnce([]); + setStepIndex(0); + setView(mfaMethodToLocationCardView(stepPlan[0])); + return; } - }, [setView, mfaMethod]); + setView(mfaMethodToLocationCardView(mfaMethod)); + }, [setView, mfaMethod, isMultiStep, stepPlan]); const mfaAutoStartRequested = useAppStore( (s) => s.mfaAutoStartLocationId === location.id, @@ -135,6 +177,17 @@ export const LocationCardProvider = ({ } }, [location.active]); + const usableStepMethods = (mfaSteps[stepIndex]?.methods ?? []).filter( + isMfaMethodUsable, + ); + const canPickOtherMethod = !isMultiStep || usableStepMethods.length > 1; + + const stepMethod = stepPlan[stepIndex]; + const showStepLabel = isMultiStep && isPresent(stepMethod); + const stepLabel = showStepLabel + ? `Step ${stepIndex + 1}/${mfaSteps.length}: ${mfaToText(stepMethod)}` + : null; + return ( {children} diff --git a/new-ui/src/shared/components/LocationCard/context/types.ts b/new-ui/src/shared/components/LocationCard/context/types.ts index 007abfe4..8d205276 100644 --- a/new-ui/src/shared/components/LocationCard/context/types.ts +++ b/new-ui/src/shared/components/LocationCard/context/types.ts @@ -1,3 +1,5 @@ +import { MfaMethod, type MfaMethodValue } from '../../../rust-api/types'; + export const LocationCardViews = { Default: 'default', MfaTotp: 'mfa-totp', @@ -13,3 +15,18 @@ export const LocationCardViews = { export type LocationCardViewsValue = (typeof LocationCardViews)[keyof typeof LocationCardViews]; + +export const mfaMethodToLocationCardView = ( + method: MfaMethodValue, +): LocationCardViewsValue => { + switch (method) { + case MfaMethod.Email: + return LocationCardViews.MfaEmail; + case MfaMethod.Oidc: + return LocationCardViews.MfaOidc; + case MfaMethod.MobileApprove: + return LocationCardViews.MfaMobile; + default: + return LocationCardViews.MfaTotp; + } +}; diff --git a/new-ui/src/shared/components/LocationCard/hooks/useMfaConnect.ts b/new-ui/src/shared/components/LocationCard/hooks/useMfaConnect.ts index 699ff946..c11340a5 100644 --- a/new-ui/src/shared/components/LocationCard/hooks/useMfaConnect.ts +++ b/new-ui/src/shared/components/LocationCard/hooks/useMfaConnect.ts @@ -17,12 +17,16 @@ type CodeMfaMethod = typeof MfaMethod.Totp | typeof MfaMethod.Email; type UseMfaConnectOptions = { debounceMs?: number; + onStepPassed?: () => void; onConnected?: () => void; onSessionExpired?: () => void; onPostureError?: (message: string) => void; onServiceUnavailable?: () => void; }; +// TODO: delete this +const MOCK_STEP_TOKEN = 'mock-step-token'; + const waitForMinimumDuration = async (startedAt: number, minimumMs: number) => { const remainingMs = Math.max(minimumMs - (performance.now() - startedAt), 0); if (remainingMs === 0) return; @@ -35,6 +39,7 @@ export const useMfaConnect = ( method: CodeMfaMethod, { debounceMs = 0, + onStepPassed, onConnected, onSessionExpired, onPostureError, @@ -65,6 +70,12 @@ export const useMfaConnect = ( (async () => { try { + // TODO(mock): drop this branch and always call mfaStart with the step's method + if (onStepPassed) { + await waitForMinimumDuration(startedAt, debounceMs); + setToken(MOCK_STEP_TOKEN); + return; + } const info = await api.mfaStart(instance.id, location.id, method); await waitForMinimumDuration(startedAt, debounceMs); setToken(info.token); @@ -94,6 +105,12 @@ export const useMfaConnect = ( setVerifyError(null); try { + // TODO(mock): call mfaFinishCode here too and advance only on MfaAdvanced, + // falling through to onConnected on MfaCompleted + if (onStepPassed) { + onStepPassed(); + return; + } // mfaFinishCode completes MFA and brings up the connection in the // backend; the preshared key never reaches the frontend. await api.mfaFinishCode(instance.id, location.id, token, code); @@ -114,7 +131,7 @@ export const useMfaConnect = ( setIsVerifying(false); } }, - [token, instance, location, onConnected, onSessionExpired], + [token, instance, location, onStepPassed, onConnected, onSessionExpired], ); return { token, isStarting, startError, verifyCode, isVerifying, verifyError }; diff --git a/new-ui/src/shared/components/LocationCard/hooks/useMfaMobileConnect.ts b/new-ui/src/shared/components/LocationCard/hooks/useMfaMobileConnect.ts index e1a5f72b..d052025f 100644 --- a/new-ui/src/shared/components/LocationCard/hooks/useMfaMobileConnect.ts +++ b/new-ui/src/shared/components/LocationCard/hooks/useMfaMobileConnect.ts @@ -15,19 +15,27 @@ import { getInstancesQueryOptions } from '../../../rust-api/query'; import type { LocationInfo, MfaErrorPayload } from '../../../rust-api/types'; import { MfaMethod, TauriEvent } from '../../../rust-api/types'; +// TODO: delete this + +const MOCK_TOKEN = 'mock-step-token'; +const MOCK_CHALLENGE = 'mock-step-challenge'; +const MOCK_APPROVE_DELAY_MS = 2500; + type TokenData = { token: string; challenge: string; }; type Options = { + onStepPassed?: () => void; onConnected?: () => void; onPostureError?: (message?: string) => void; onServiceUnavailable?: () => void; }; export const useMfaMobileConnect = (location: LocationInfo, options?: Options) => { - const { onConnected, onPostureError, onServiceUnavailable } = options ?? {}; + const { onStepPassed, onConnected, onPostureError, onServiceUnavailable } = + options ?? {}; const { data: instances } = useQuery(getInstancesQueryOptions); const instance = instances?.find((i) => i.id === location.instance_id); @@ -63,6 +71,13 @@ export const useMfaMobileConnect = (location: LocationInfo, options?: Options) = useEffect(() => { if (!tokenData || !instance) return; + // TODO(mock): keep the websocket in a step too and call onStepPassed once the + // approval arrives, instead of faking it on a timer + if (onStepPassed) { + const timeout = window.setTimeout(onStepPassed, MOCK_APPROVE_DELAY_MS); + return () => window.clearTimeout(timeout); + } + let cancelled = false; cleanupListeners(); setIsConnecting(true); @@ -123,7 +138,7 @@ export const useMfaMobileConnect = (location: LocationInfo, options?: Options) = cleanupListeners(); setIsConnecting(false); }; - }, [tokenData, instance, location, onConnected, cleanupListeners]); + }, [tokenData, instance, location, onStepPassed, onConnected, cleanupListeners]); const qrValue = useMemo(() => { if (!tokenData || !instance) return null; @@ -148,6 +163,11 @@ export const useMfaMobileConnect = (location: LocationInfo, options?: Options) = setTokenData(null); try { + // TODO(mock): drop this branch and always call mfaStart for the real challenge + if (onStepPassed) { + setTokenData({ token: MOCK_TOKEN, challenge: MOCK_CHALLENGE }); + return; + } const info = await api.mfaStart(instance.id, location.id, MfaMethod.MobileApprove); if (!info.challenge) { setStartError('Unsupported response from proxy'); @@ -169,7 +189,7 @@ export const useMfaMobileConnect = (location: LocationInfo, options?: Options) = } finally { setIsStarting(false); } - }, [instance, location, onPostureError, onServiceUnavailable]); + }, [instance, location, onStepPassed, onPostureError, onServiceUnavailable]); const reset = useCallback(() => { cleanupListeners(); diff --git a/new-ui/src/shared/components/LocationCard/hooks/useMfaOidcConnect.ts b/new-ui/src/shared/components/LocationCard/hooks/useMfaOidcConnect.ts index 7e28f80f..17d9f576 100644 --- a/new-ui/src/shared/components/LocationCard/hooks/useMfaOidcConnect.ts +++ b/new-ui/src/shared/components/LocationCard/hooks/useMfaOidcConnect.ts @@ -18,8 +18,12 @@ import { MfaMethod, TauriEvent } from '../../../rust-api/types'; import { useLocationCardContext } from '../context/context'; import { LocationCardViews } from '../context/types'; +// TODO: delete this +const MOCK_OIDC_URL = 'https://example.com/openid/mfa?token=mock-step-token'; +const MOCK_OIDC_DELAY_MS = 1500; + export const useMfaOidcConnect = () => { - const { location, setPostureError, setView } = useLocationCardContext(); + const { location, setPostureError, setView, onStepPassed } = useLocationCardContext(); const [isStarting, setIsStarting] = useState(false); const [startError, setStartError] = useState(null); @@ -62,6 +66,13 @@ export const useMfaOidcConnect = () => { cleanup(); try { + // TODO(mock): open the real proxy link and call onStepPassed from the poll result, instead of faking it on a timer + if (onStepPassed) { + await api.openLink(MOCK_OIDC_URL); + setIsPolling(true); + window.setTimeout(onStepPassed, MOCK_OIDC_DELAY_MS); + return; + } const info = await api.mfaStart(instance.id, location.id, MfaMethod.Oidc); await api.openLink(`${instance.proxy_url}openid/mfa?token=${info.token}`); @@ -116,7 +127,7 @@ export const useMfaOidcConnect = () => { } finally { setIsStarting(false); } - }, [instance, location, setPostureError, setView, cleanup]); + }, [instance, location, setPostureError, setView, cleanup, onStepPassed]); return { start, isStarting, startError, isPolling, pollError }; }; diff --git a/new-ui/src/shared/components/LocationCard/views/LocationCardMfaEmailView/LocationCardMfaEmailView.tsx b/new-ui/src/shared/components/LocationCard/views/LocationCardMfaEmailView/LocationCardMfaEmailView.tsx index 4d56aa24..4f842b31 100644 --- a/new-ui/src/shared/components/LocationCard/views/LocationCardMfaEmailView/LocationCardMfaEmailView.tsx +++ b/new-ui/src/shared/components/LocationCard/views/LocationCardMfaEmailView/LocationCardMfaEmailView.tsx @@ -20,11 +20,19 @@ import { LocationCardMfaStartLoader } from '../LocationCardMfaStartLoader/Locati const MIN_POSTURE_LOADER_MS = 500; export const LocationCardMfaEmailView = () => { - const { setView, location, setPostureError } = useLocationCardContext(); + const { + setView, + location, + setPostureError, + stepLabel, + onStepPassed, + canPickOtherMethod, + } = useLocationCardContext(); const { verifyCode, isVerifying, verifyError, isStarting, startError } = useMfaConnect( location, MfaMethod.Email, { + onStepPassed, debounceMs: location.posture_check_required ? MIN_POSTURE_LOADER_MS : 0, onConnected: () => setView(LocationCardViews.Connected), onSessionExpired: () => setView(LocationCardViews.Default), @@ -79,7 +87,7 @@ export const LocationCardMfaEmailView = () => { }} > - +

Enter the 6-digit code sent to your email address.

@@ -102,13 +110,15 @@ export const LocationCardMfaEmailView = () => { }} />
-
diff --git a/new-ui/src/shared/components/LocationCard/views/LocationCardMfaSettings/style.scss b/new-ui/src/shared/components/LocationCard/views/LocationCardMfaSettings/style.scss index f1133fe7..354e36b9 100644 --- a/new-ui/src/shared/components/LocationCard/views/LocationCardMfaSettings/style.scss +++ b/new-ui/src/shared/components/LocationCard/views/LocationCardMfaSettings/style.scss @@ -18,4 +18,28 @@ row-gap: var(--spacing-md); padding-bottom: var(--spacing-md); } + + > .steps { + display: flex; + flex-flow: column; + row-gap: var(--spacing-xl); + padding-bottom: var(--spacing-md); + + > .step { + display: flex; + flex-flow: column; + row-gap: var(--spacing-sm); + + > .step-label { + font: var(--t-body-xs-400); + color: var(--fg-white-70); + } + + > .methods { + display: flex; + flex-flow: column; + row-gap: var(--spacing-md); + } + } + } } diff --git a/new-ui/src/shared/components/LocationCard/views/LocationCardMfaTotpView/LocationCardMfaTotpView.tsx b/new-ui/src/shared/components/LocationCard/views/LocationCardMfaTotpView/LocationCardMfaTotpView.tsx index 5cf19f23..bd7e74c3 100644 --- a/new-ui/src/shared/components/LocationCard/views/LocationCardMfaTotpView/LocationCardMfaTotpView.tsx +++ b/new-ui/src/shared/components/LocationCard/views/LocationCardMfaTotpView/LocationCardMfaTotpView.tsx @@ -20,11 +20,19 @@ import { LocationCardMfaStartLoader } from '../LocationCardMfaStartLoader/Locati const MIN_POSTURE_LOADER_MS = 500; export const LocationCardMfaTotpView = () => { - const { setView, location, setPostureError } = useLocationCardContext(); + const { + setView, + location, + setPostureError, + stepLabel, + onStepPassed, + canPickOtherMethod, + } = useLocationCardContext(); const { verifyCode, isVerifying, verifyError, isStarting, startError } = useMfaConnect( location, MfaMethod.Totp, { + onStepPassed, debounceMs: location.posture_check_required ? MIN_POSTURE_LOADER_MS : 0, onConnected: () => setView(LocationCardViews.Connected), onSessionExpired: () => setView(LocationCardViews.Default), @@ -80,7 +88,7 @@ export const LocationCardMfaTotpView = () => { }} > - +

Paste the code from your Authenticator Application.

@@ -103,13 +111,15 @@ export const LocationCardMfaTotpView = () => { }} />
-
From 4ec5af9a0c6c8c5acb7c1a6a4d25ed97cb7be811 Mon Sep 17 00:00:00 2001 From: Kuba <78603704+jakub-tldr@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:17:46 +0200 Subject: [PATCH 06/18] fix typo --- new-ui/src/shared/utils/mfa.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/new-ui/src/shared/utils/mfa.ts b/new-ui/src/shared/utils/mfa.ts index d9a7a875..f843638c 100644 --- a/new-ui/src/shared/utils/mfa.ts +++ b/new-ui/src/shared/utils/mfa.ts @@ -12,7 +12,7 @@ const mfaMethodLabels: Record = { [MfaMethod.MobileApprove]: 'Mobile Client', [MfaMethod.Oidc]: 'OpenID', [MfaMethod.Totp]: 'Authenticator app', - [MfaMethod.Biometric]: 'Biometric', + [MfaMethod.Biometric]: 'Biometrics', }; export const mfaToText = (factor: MfaMethodValue): string => mfaMethodLabels[factor]; From 6b66a8b3e4c9932e21e908c3a75a70bf0e94f125 Mon Sep 17 00:00:00 2001 From: Kuba <78603704+jakub-tldr@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:29:28 +0200 Subject: [PATCH 07/18] bump proto and add step_id --- src-tauri/client-cli/src/mfa.rs | 1 + src-tauri/core/src/mfa.rs | 3 +++ src-tauri/proto | 2 +- src-tauri/src/commands.rs | 1 + 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src-tauri/client-cli/src/mfa.rs b/src-tauri/client-cli/src/mfa.rs index 241e20d8..87e43590 100644 --- a/src-tauri/client-cli/src/mfa.rs +++ b/src-tauri/client-cli/src/mfa.rs @@ -177,6 +177,7 @@ pub(crate) async fn authorize( token: info.token, code: Some(code.expose_secret().to_string()), auth_pub_key: None, + step_attempt_id: None, }; let psk = mfa::mfa_finish_code(proxy_url, finish_req) .await diff --git a/src-tauri/core/src/mfa.rs b/src-tauri/core/src/mfa.rs index 154e5c75..32faebdf 100644 --- a/src-tauri/core/src/mfa.rs +++ b/src-tauri/core/src/mfa.rs @@ -228,6 +228,7 @@ pub async fn poll_openid_mfa( token, code: None, auth_pub_key: None, + step_attempt_id: None, }; loop { @@ -607,6 +608,7 @@ mod tests { token: "token".into(), code: Some("123456".into()), auth_pub_key: None, + step_attempt_id: None, }, ) .await @@ -634,6 +636,7 @@ mod tests { token: "token".into(), code: Some("000000".into()), auth_pub_key: None, + step_attempt_id: None, }, ) .await diff --git a/src-tauri/proto b/src-tauri/proto index 0b44d8c6..735d7226 160000 --- a/src-tauri/proto +++ b/src-tauri/proto @@ -1 +1 @@ -Subproject commit 0b44d8c6730e0129ac79f078041894813631a8e3 +Subproject commit 735d72260ae9d73d5be8a7fe477da8ec7356a681 diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index c5fba683..1c300e5a 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1662,6 +1662,7 @@ pub async fn mfa_finish_code( token, code: Some(code), auth_pub_key: None, + step_attempt_id: None, }; let response = mfa::mfa_finish_code(proxy_url, request) .await From ebc228348e7d6a195a6f201f4126fcda7654de3e Mon Sep 17 00:00:00 2001 From: Kuba <78603704+jakub-tldr@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:40:21 +0200 Subject: [PATCH 08/18] add todos --- src-tauri/core/src/mfa.rs | 1 + src-tauri/src/commands.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/src-tauri/core/src/mfa.rs b/src-tauri/core/src/mfa.rs index 32faebdf..1c1243da 100644 --- a/src-tauri/core/src/mfa.rs +++ b/src-tauri/core/src/mfa.rs @@ -228,6 +228,7 @@ pub async fn poll_openid_mfa( token, code: None, auth_pub_key: None, + // #TODO (multi-step-mfa) pass the id minted by StepStart for this step. step_attempt_id: None, }; diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 1c300e5a..e4268884 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1662,6 +1662,7 @@ pub async fn mfa_finish_code( token, code: Some(code), auth_pub_key: None, + // #TODO (multi-step-mfa) pass the id minted by StepStart for this step. step_attempt_id: None, }; let response = mfa::mfa_finish_code(proxy_url, request) From 1537b9ca312cb46a9454d52c3c842abbe87f535d Mon Sep 17 00:00:00 2001 From: Kuba <78603704+jakub-tldr@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:40:32 +0200 Subject: [PATCH 09/18] set default label --- .../views/LocationCardMfaSettings/LocationCardMfaSettings.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/new-ui/src/shared/components/LocationCard/views/LocationCardMfaSettings/LocationCardMfaSettings.tsx b/new-ui/src/shared/components/LocationCard/views/LocationCardMfaSettings/LocationCardMfaSettings.tsx index 86e1a746..33c3d6b3 100644 --- a/new-ui/src/shared/components/LocationCard/views/LocationCardMfaSettings/LocationCardMfaSettings.tsx +++ b/new-ui/src/shared/components/LocationCard/views/LocationCardMfaSettings/LocationCardMfaSettings.tsx @@ -138,6 +138,7 @@ export const LocationCardMfaSettings = () => { key={entry.method} factor={entry.method} selected={selectedStepMethods[index] === entry.method} + isDefault={stepPlan[index] === entry.method} configured={entry.configured} onClick={() => selectStepMethod(index, entry.method)} /> From 5ab2db1c21b5ee9060e83d476663484594161dc4 Mon Sep 17 00:00:00 2001 From: Kuba <78603704+jakub-tldr@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:26:31 +0200 Subject: [PATCH 10/18] support legacy methods + tests --- .../core/src/database/models/location.rs | 22 ++++++ src-tauri/core/src/lib.rs | 69 +++++++++++++++++-- 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/src-tauri/core/src/database/models/location.rs b/src-tauri/core/src/database/models/location.rs index d99eb046..cab5db6e 100644 --- a/src-tauri/core/src/database/models/location.rs +++ b/src-tauri/core/src/database/models/location.rs @@ -129,6 +129,28 @@ impl From for LocationMfaStep { } } +#[must_use] +pub fn legacy_mfa_steps(mode: LocationMfaMode) -> Vec { + let methods = match mode { + LocationMfaMode::Disabled => return Vec::new(), + LocationMfaMode::Internal => vec![ + LocationMfaMethod::Totp, + LocationMfaMethod::Email, + LocationMfaMethod::MobileApprove, + ], + LocationMfaMode::External => vec![LocationMfaMethod::Oidc], + }; + vec![LocationMfaStep { + methods: methods + .into_iter() + .map(|method| LocationMfaStepMethod { + method, + configured: true, + }) + .collect(), + }] +} + #[must_use] pub fn infer_mfa_method( mode: LocationMfaMode, diff --git a/src-tauri/core/src/lib.rs b/src-tauri/core/src/lib.rs index c990dcbe..1313182a 100644 --- a/src-tauri/core/src/lib.rs +++ b/src-tauri/core/src/lib.rs @@ -7,7 +7,10 @@ use std::{ use chrono::{Duration, NaiveDateTime, Utc}; use database::models::{ - location::{infer_mfa_method, Location, LocationMfaMode, LocationMfaStep, ServiceLocationMode}, + location::{ + infer_mfa_method, legacy_mfa_steps, Location, LocationMfaMode, LocationMfaStep, + ServiceLocationMode, + }, Id, }; use defguard_client_proto::defguard::client_types::DeviceConfig; @@ -191,13 +194,15 @@ pub fn into_location(dev_config: DeviceConfig, instance_id: Id) -> Location>(), - ), + .collect::>() + }), mfa_step_plan: Json::default(), } } @@ -205,7 +210,10 @@ pub fn into_location(dev_config: DeviceConfig, instance_id: Id) -> Location>(), + vec![ + LocationMfaMethod::Totp, + LocationMfaMethod::Email, + LocationMfaMethod::MobileApprove + ] + ); + assert!(location.mfa_steps.0[0] + .methods + .iter() + .all(|entry| entry.configured)); + } + + #[test] + fn test_into_location_legacy_disabled_has_no_steps() { + let location = into_location(base_dev_config(), 1); + assert_eq!(location.location_mfa_mode, LocationMfaMode::Disabled); + assert!(location.mfa_steps.0.is_empty()); + } + + #[test] + fn test_into_location_steps_win_over_deprecated_mode() { + let mut cfg = base_dev_config(); + cfg.location_mfa_mode = Some(crate::proto::client_types::LocationMfaMode::Internal as i32); + cfg.steps = vec![ProtoMfaStep { + methods: vec![ProtoMfaStepMethod { + method: ProtoMfaMethod::Oidc as i32, + configured: false, + }], + }]; + let location = into_location(cfg, 1); + assert_eq!(location.mfa_steps.0.len(), 1); + assert_eq!(location.mfa_steps.0[0].methods.len(), 1); + assert_eq!( + location.mfa_steps.0[0].methods[0].method, + LocationMfaMethod::Oidc + ); + assert!(!location.mfa_steps.0[0].methods[0].configured); + } + #[test] fn test_into_location_explicit_mfa_mode() { let mut cfg = base_dev_config(); From da7800b9d3544e521e106596d7b4deaf7245f914 Mon Sep 17 00:00:00 2001 From: Kuba <78603704+jakub-tldr@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:57:13 +0200 Subject: [PATCH 11/18] full-view multi-mfa flow + fixes/tweaks --- .../components/ConnectModal/ConnectModal.tsx | 24 ++- .../ConnectModal/hooks/useConnectModal.tsx | 26 ++- .../hooks/useConnectModalMfaOidc.ts | 14 ++ .../ConnectModal/hooks/useMfaStep.ts | 19 ++ .../ConnectModalMfaEmail.tsx | 20 +- .../ConnectModalMfaMobile.tsx | 8 +- .../ConnectModalMfaOidc.tsx | 4 + .../ConnectModalMfaSettings.tsx | 160 +++++++++------- .../views/ConnectModalMfaSettings/style.scss | 17 ++ .../ConnectModalMfaTotp.tsx | 20 +- .../LocationCardConnectionTiles.tsx | 10 +- .../LocationCardMfaEdit.tsx | 23 +-- .../LocationCard/context/context.tsx | 51 +++-- .../views/DefaultView/DefaultView.tsx | 11 +- .../LocationCardMfaSettings.tsx | 178 +++++++----------- .../OverviewLocationCard.tsx | 42 +++-- .../shared/providers/TauriEventProvider.tsx | 6 +- .../shared/utils/decideLocationMfaMethod.ts | 17 -- new-ui/src/shared/utils/mfa.ts | 43 ++++- 19 files changed, 394 insertions(+), 299 deletions(-) create mode 100644 new-ui/src/pages/full/OverviewPage/components/ConnectModal/hooks/useMfaStep.ts delete mode 100644 new-ui/src/shared/utils/decideLocationMfaMethod.ts diff --git a/new-ui/src/pages/full/OverviewPage/components/ConnectModal/ConnectModal.tsx b/new-ui/src/pages/full/OverviewPage/components/ConnectModal/ConnectModal.tsx index febf5911..3fc75a72 100644 --- a/new-ui/src/pages/full/OverviewPage/components/ConnectModal/ConnectModal.tsx +++ b/new-ui/src/pages/full/OverviewPage/components/ConnectModal/ConnectModal.tsx @@ -2,16 +2,18 @@ import './style.scss'; import { useQuery } from '@tanstack/react-query'; -import { type ReactNode, useEffect } from 'react'; +import { Fragment, type ReactNode, useEffect } from 'react'; import { useShallow } from 'zustand/shallow'; import { Modal } from '../../../../../shared/components/Modal/Modal'; import { useAppData } from '../../../../../shared/providers/AppDataContext'; import { api } from '../../../../../shared/rust-api/api'; import { isPresent } from '../../../../../shared/utils/isPresent'; +import { mfaStepCount, mfaToText } from '../../../../../shared/utils/mfa'; import { ConnectModalTitle, ConnectModalView, type ConnectModalViewValue, + mfaMethodToConnectModalView, } from './hooks/types'; import { useConnectModal } from './hooks/useConnectModal'; import { ConnectModalConnectionError } from './views/ConnectModalConnectionError/ConnectModalConnectionError'; @@ -25,17 +27,26 @@ import { ConnectModalPostureCheckFail } from './views/ConnectModalPostureCheckFa export const ConnectModal = () => { const reset = useConnectModal((s) => s.reset); - const [view, visible, location] = useConnectModal( - useShallow((s) => [s.view, s.visible, s.location]), + const [view, visible, location, stepIndex, stepPlan] = useConnectModal( + useShallow((s) => [s.view, s.visible, s.location, s.stepIndex, s.stepPlan]), ); + const stepCount = isPresent(location) ? mfaStepCount(location) : 0; + const stepMethod = stepPlan[stepIndex]; + const isOnMfaStepView = + isPresent(stepMethod) && view === mfaMethodToConnectModalView(stepMethod); + const stepLabel = + stepCount > 1 && isOnMfaStepView + ? `Step ${stepIndex + 1}/${stepCount}: ${mfaToText(stepMethod)}` + : null; + const isOpen = isPresent(view) && isPresent(location) && visible; return ( { reset(); @@ -75,6 +86,7 @@ const ModalContent = () => { retry: false, }); const activeView = useConnectModal((s) => s.view); + const stepIndex = useConnectModal((s) => s.stepIndex); // When user completes connection and it's working modal is no longer needed so auto close it // biome-ignore lint/correctness/useExhaustiveDependencies: side-effect on connect @@ -88,5 +100,7 @@ const ModalContent = () => { if (!activeView) return null; - return viewContent[activeView]; + return ( + {viewContent[activeView]} + ); }; diff --git a/new-ui/src/pages/full/OverviewPage/components/ConnectModal/hooks/useConnectModal.tsx b/new-ui/src/pages/full/OverviewPage/components/ConnectModal/hooks/useConnectModal.tsx index 4e79e839..54090755 100644 --- a/new-ui/src/pages/full/OverviewPage/components/ConnectModal/hooks/useConnectModal.tsx +++ b/new-ui/src/pages/full/OverviewPage/components/ConnectModal/hooks/useConnectModal.tsx @@ -4,7 +4,9 @@ import { MfaMethod, type MfaMethodValue, } from '../../../../../../shared/rust-api/types'; -import type { ConnectModalViewValue } from './types'; +import { isPresent } from '../../../../../../shared/utils/isPresent'; +import { resolveMfaStepPlan } from '../../../../../../shared/utils/mfa'; +import { type ConnectModalViewValue, mfaMethodToConnectModalView } from './types'; interface StoreValues { visible: boolean; @@ -14,6 +16,8 @@ interface StoreValues { postureError: string | null; autoStartOpenId: boolean; mfaMethod: MfaMethodValue; + stepIndex: number; + stepPlan: MfaMethodValue[]; } const defaults: StoreValues = { @@ -24,11 +28,14 @@ const defaults: StoreValues = { perviousView: null, postureError: null, autoStartOpenId: false, + stepIndex: 0, + stepPlan: [], } as const; interface Store extends StoreValues { open: (init?: Partial) => void; setView: (view: ConnectModalViewValue, values?: Partial) => void; + passStep: () => void; reset: () => void; } @@ -38,7 +45,22 @@ export const useConnectModal = create((set, get) => ({ set(defaults); }, open: (init) => { - set({ ...defaults, ...init, visible: true }); + const location = init?.location ?? null; + const stepPlan = isPresent(location) ? resolveMfaStepPlan(location) : []; + set({ ...defaults, ...init, stepPlan, visible: true }); + }, + passStep: () => { + const { stepIndex, stepPlan, setView } = get(); + const nextStepIndex = stepIndex + 1; + if (nextStepIndex < stepPlan.length) { + setView(mfaMethodToConnectModalView(stepPlan[nextStepIndex]), { + stepIndex: nextStepIndex, + }); + return; + } + // TODO(mock): the last step closes the modal without connecting; connect here once + // MfaCompleted brings up the tunnel + set({ stepIndex: 0, visible: false }); }, setView: (view, vals) => { const pervious = get().view ?? null; diff --git a/new-ui/src/pages/full/OverviewPage/components/ConnectModal/hooks/useConnectModalMfaOidc.ts b/new-ui/src/pages/full/OverviewPage/components/ConnectModal/hooks/useConnectModalMfaOidc.ts index 27a4dc19..1118715d 100644 --- a/new-ui/src/pages/full/OverviewPage/components/ConnectModal/hooks/useConnectModalMfaOidc.ts +++ b/new-ui/src/pages/full/OverviewPage/components/ConnectModal/hooks/useConnectModalMfaOidc.ts @@ -18,13 +18,19 @@ import type { MfaErrorPayload } from '../../../../../../shared/rust-api/types'; import { MfaMethod, TauriEvent } from '../../../../../../shared/rust-api/types'; import { useConnectModal } from './useConnectModal'; +// TODO: delete this +const MOCK_OIDC_URL = 'https://example.com/openid/mfa?token=mock-step-token'; +const MOCK_OIDC_DELAY_MS = 1500; + type Options = { + onStepPassed?: () => void; onPostureError?: (msg: string) => void; onSessionExpired?: () => void; onServiceUnavailable?: () => void; }; export const useConnectModalMfaOidc = ({ + onStepPassed, onPostureError, onSessionExpired, onServiceUnavailable, @@ -71,6 +77,13 @@ export const useConnectModalMfaOidc = ({ cleanup(); try { + // TODO(mock): open the real proxy link and call onStepPassed from the poll result, instead of faking it on a timer + if (onStepPassed) { + await api.openLink(MOCK_OIDC_URL); + setIsPolling(true); + window.setTimeout(onStepPassed, MOCK_OIDC_DELAY_MS); + return; + } const info = await api.mfaStart(instance.id, location.id, MfaMethod.Oidc); await api.openLink(`${instance.proxy_url}openid/mfa?token=${info.token}`); @@ -127,6 +140,7 @@ export const useConnectModalMfaOidc = ({ instance, location, cleanup, + onStepPassed, onPostureError, onSessionExpired, onServiceUnavailable, diff --git a/new-ui/src/pages/full/OverviewPage/components/ConnectModal/hooks/useMfaStep.ts b/new-ui/src/pages/full/OverviewPage/components/ConnectModal/hooks/useMfaStep.ts new file mode 100644 index 00000000..e8bd576f --- /dev/null +++ b/new-ui/src/pages/full/OverviewPage/components/ConnectModal/hooks/useMfaStep.ts @@ -0,0 +1,19 @@ +import { useShallow } from 'zustand/shallow'; +import { isPresent } from '../../../../../../shared/utils/isPresent'; +import { mfaStepCount, usableMfaMethods } from '../../../../../../shared/utils/mfa'; +import { useConnectModal } from './useConnectModal'; + +export const useMfaStep = () => { + const [location, stepIndex, passStep] = useConnectModal( + useShallow((s) => [s.location, s.stepIndex, s.passStep]), + ); + + const currentStep = location?.mfa_steps[stepIndex]; + const isMultiStep = isPresent(location) && mfaStepCount(location) > 1; + + return { + canPickOtherMethod: + isPresent(currentStep) && usableMfaMethods(currentStep).length > 1, + onStepPassed: isMultiStep ? passStep : undefined, + }; +}; diff --git a/new-ui/src/pages/full/OverviewPage/components/ConnectModal/views/ConnectModalMfaEmail/ConnectModalMfaEmail.tsx b/new-ui/src/pages/full/OverviewPage/components/ConnectModal/views/ConnectModalMfaEmail/ConnectModalMfaEmail.tsx index 65973f13..f0b69d80 100644 --- a/new-ui/src/pages/full/OverviewPage/components/ConnectModal/views/ConnectModalMfaEmail/ConnectModalMfaEmail.tsx +++ b/new-ui/src/pages/full/OverviewPage/components/ConnectModal/views/ConnectModalMfaEmail/ConnectModalMfaEmail.tsx @@ -10,6 +10,7 @@ import { isPresent } from '../../../../../../../shared/utils/isPresent'; import { ConnectModalPostureCheckLoading } from '../../components/ConnectModalPostureCheckLoading/ConnectModalPostureCheckLoading'; import { ConnectModalView } from '../../hooks/types'; import { useConnectModal } from '../../hooks/useConnectModal'; +import { useMfaStep } from '../../hooks/useMfaStep'; const MIN_POSTURE_LOADER_MS = 500; @@ -18,10 +19,13 @@ export const ConnectModalMfaEmail = () => { useShallow((s) => [s.perviousView, s.location]), ); + const { canPickOtherMethod, onStepPassed } = useMfaStep(); + const { verifyCode, isVerifying, verifyError, isStarting, startError } = useMfaConnect( location as LocationInfo, MfaMethod.Email, { + onStepPassed, debounceMs: location?.posture_check_required ? MIN_POSTURE_LOADER_MS : 0, onSessionExpired: () => useConnectModal.getState().setView(perviousView ?? ConnectModalView.MfaSettings), @@ -87,13 +91,15 @@ export const ConnectModalMfaEmail = () => { }} /> -