diff --git a/client/drift_schemas/defguard/drift_schema_v8.json b/client/drift_schemas/defguard/drift_schema_v8.json new file mode 100644 index 0000000..7cf180c --- /dev/null +++ b/client/drift_schemas/defguard/drift_schema_v8.json @@ -0,0 +1,447 @@ +{ + "_meta": { + "description": "This file contains a serialized version of schema entities for drift.", + "version": "1.3.0" + }, + "options": { + "store_date_time_values_as_text": false + }, + "entities": [ + { + "id": 0, + "references": [], + "type": "table", + "data": { + "name": "defguard_instances", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "PRIMARY KEY AUTOINCREMENT", + "dialectAwareDefaultConstraints": { + "sqlite": "PRIMARY KEY AUTOINCREMENT" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "auto-increment" + ] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "uuid", + "getter_name": "uuid", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "url", + "getter_name": "url", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "device_id", + "getter_name": "deviceId", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "proxy_url", + "getter_name": "proxyUrl", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "username", + "getter_name": "username", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "client_traffic_policy", + "getter_name": "clientTrafficPolicy", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const ClientTrafficPolicyConverter()", + "dart_type_name": "ClientTrafficPolicy" + } + }, + { + "name": "enterprise_enabled", + "getter_name": "enterpriseEnabled", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"enterprise_enabled\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"enterprise_enabled\" IN (0, 1))" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "pub_key", + "getter_name": "pubKey", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "mfa_keys_stored", + "getter_name": "mfaKeysStored", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"mfa_keys_stored\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"mfa_keys_stored\" IN (0, 1))" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "openid_display_name", + "getter_name": "openidDisplayName", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "openid_provider_kind", + "getter_name": "openidProviderKind", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(OpenIdProvider.values)", + "dart_type_name": "OpenIdProvider" + } + }, + { + "name": "mfa_contract", + "getter_name": "mfaContract", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'legacy\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(MfaContract.values)", + "dart_type_name": "MfaContract" + } + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 1, + "references": [ + 0 + ], + "type": "table", + "data": { + "name": "locations", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "PRIMARY KEY AUTOINCREMENT", + "dialectAwareDefaultConstraints": { + "sqlite": "PRIMARY KEY AUTOINCREMENT" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "auto-increment" + ] + }, + { + "name": "instance", + "getter_name": "instance", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES defguard_instances (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES defguard_instances (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "defguard_instances", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": "cascade" + } + } + ] + }, + { + "name": "network_id", + "getter_name": "networkId", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "address", + "getter_name": "address", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "pub_key", + "getter_name": "pubKey", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "endpoint", + "getter_name": "endpoint", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "allowed_ips", + "getter_name": "allowedIps", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "dns", + "getter_name": "dns", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "mfa_enabled", + "getter_name": "mfaEnabled", + "moor_type": "bool", + "nullable": true, + "customConstraints": null, + "defaultConstraints": "CHECK (\"mfa_enabled\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"mfa_enabled\" IN (0, 1))" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "traffic_method", + "getter_name": "trafficMethod", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(RoutingMethod.values)", + "dart_type_name": "RoutingMethod" + } + }, + { + "name": "mfa_method", + "getter_name": "mfaMethod", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const MfaMethodConverter()", + "dart_type_name": "MfaMethod" + } + }, + { + "name": "keep_alive_interval", + "getter_name": "keepAliveInterval", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "location_mfa_mode", + "getter_name": "locationMfaMode", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const LocationMfaModeConverter()", + "dart_type_name": "LocationMfaMode" + } + }, + { + "name": "posture_check_required", + "getter_name": "postureCheckRequired", + "moor_type": "bool", + "nullable": true, + "customConstraints": null, + "defaultConstraints": "CHECK (\"posture_check_required\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"posture_check_required\" IN (0, 1))" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "mfa_steps", + "getter_name": "mfaSteps", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'[]\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const MfaStepsConverter()", + "dart_type_name": "List" + } + }, + { + "name": "mfa_step_plan", + "getter_name": "mfaStepPlan", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'[]\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const MfaStepPlanConverter()", + "dart_type_name": "List" + } + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + } + ], + "fixed_sql": [ + { + "name": "defguard_instances", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"defguard_instances\" (\"id\" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, \"name\" TEXT NOT NULL, \"uuid\" TEXT NOT NULL, \"url\" TEXT NOT NULL, \"device_id\" INTEGER NOT NULL, \"proxy_url\" TEXT NOT NULL, \"username\" TEXT NOT NULL, \"client_traffic_policy\" INTEGER NOT NULL DEFAULT 0, \"enterprise_enabled\" INTEGER NOT NULL CHECK (\"enterprise_enabled\" IN (0, 1)), \"pub_key\" TEXT NOT NULL, \"mfa_keys_stored\" INTEGER NOT NULL CHECK (\"mfa_keys_stored\" IN (0, 1)), \"openid_display_name\" TEXT NULL, \"openid_provider_kind\" TEXT NULL, \"mfa_contract\" TEXT NOT NULL DEFAULT 'legacy');" + } + ] + }, + { + "name": "locations", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"locations\" (\"id\" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, \"instance\" INTEGER NOT NULL REFERENCES defguard_instances (id) ON DELETE CASCADE, \"network_id\" INTEGER NOT NULL, \"name\" TEXT NOT NULL, \"address\" TEXT NOT NULL, \"pub_key\" TEXT NOT NULL, \"endpoint\" TEXT NOT NULL, \"allowed_ips\" TEXT NOT NULL, \"dns\" TEXT NULL, \"mfa_enabled\" INTEGER NULL CHECK (\"mfa_enabled\" IN (0, 1)), \"traffic_method\" TEXT NULL, \"mfa_method\" INTEGER NULL, \"keep_alive_interval\" INTEGER NOT NULL, \"location_mfa_mode\" INTEGER NULL, \"posture_check_required\" INTEGER NULL CHECK (\"posture_check_required\" IN (0, 1)), \"mfa_steps\" TEXT NOT NULL DEFAULT '[]', \"mfa_step_plan\" TEXT NOT NULL DEFAULT '[]');" + } + ] + } + ] +} \ No newline at end of file diff --git a/client/lib/data/db/database.dart b/client/lib/data/db/database.dart index f629b6b..0126105 100644 --- a/client/lib/data/db/database.dart +++ b/client/lib/data/db/database.dart @@ -48,6 +48,9 @@ class DefguardInstances extends Table with AutoIncrementingPrimaryKey { TextColumn get openidDisplayName => text().nullable()(); TextColumn get openidProviderKind => textEnum().nullable()(); + + TextColumn get mfaContract => + textEnum().withDefault(const Constant('legacy'))(); } @DataClassName('Location') @@ -132,7 +135,7 @@ class AppDatabase extends _$AppDatabase { } @override - int get schemaVersion => 7; + int get schemaVersion => 8; @override MigrationStrategy get migration { @@ -215,6 +218,12 @@ class AppDatabase extends _$AppDatabase { schema.defguardInstances.openidProviderKind, ); }, + from7To8: (m, schema) async { + await m.addColumn( + schema.defguardInstances, + schema.defguardInstances.mfaContract, + ); + }, ), ); } diff --git a/client/lib/data/db/database.g.dart b/client/lib/data/db/database.g.dart index 68d649f..ea648b0 100644 --- a/client/lib/data/db/database.g.dart +++ b/client/lib/data/db/database.g.dart @@ -157,6 +157,18 @@ class $DefguardInstancesTable extends DefguardInstances $DefguardInstancesTable.$converteropenidProviderKindn, ); @override + late final GeneratedColumnWithTypeConverter mfaContract = + GeneratedColumn( + 'mfa_contract', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('legacy'), + ).withConverter( + $DefguardInstancesTable.$convertermfaContract, + ); + @override List get $columns => [ id, name, @@ -171,6 +183,7 @@ class $DefguardInstancesTable extends DefguardInstances mfaKeysStored, openidDisplayName, openidProviderKind, + mfaContract, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -341,6 +354,12 @@ class $DefguardInstancesTable extends DefguardInstances data['${effectivePrefix}openid_provider_kind'], ), ), + mfaContract: $DefguardInstancesTable.$convertermfaContract.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}mfa_contract'], + )!, + ), ); } @@ -359,6 +378,8 @@ class $DefguardInstancesTable extends DefguardInstances $converteropenidProviderKindn = JsonTypeConverter2.asNullable( $converteropenidProviderKind, ); + static JsonTypeConverter2 $convertermfaContract = + const EnumNameConverter(MfaContract.values); } class DefguardInstance extends DataClass @@ -376,6 +397,7 @@ class DefguardInstance extends DataClass final bool mfaKeysStored; final String? openidDisplayName; final OpenIdProvider? openidProviderKind; + final MfaContract mfaContract; const DefguardInstance({ required this.id, required this.name, @@ -390,6 +412,7 @@ class DefguardInstance extends DataClass required this.mfaKeysStored, this.openidDisplayName, this.openidProviderKind, + required this.mfaContract, }); @override Map toColumns(bool nullToAbsent) { @@ -421,6 +444,11 @@ class DefguardInstance extends DataClass ), ); } + { + map['mfa_contract'] = Variable( + $DefguardInstancesTable.$convertermfaContract.toSql(mfaContract), + ); + } return map; } @@ -443,6 +471,7 @@ class DefguardInstance extends DataClass openidProviderKind: openidProviderKind == null && nullToAbsent ? const Value.absent() : Value(openidProviderKind), + mfaContract: Value(mfaContract), ); } @@ -470,6 +499,9 @@ class DefguardInstance extends DataClass ), openidProviderKind: $DefguardInstancesTable.$converteropenidProviderKindn .fromJson(serializer.fromJson(json['openidProviderKind'])), + mfaContract: $DefguardInstancesTable.$convertermfaContract.fromJson( + serializer.fromJson(json['mfaContract']), + ), ); } @override @@ -495,6 +527,9 @@ class DefguardInstance extends DataClass openidProviderKind, ), ), + 'mfaContract': serializer.toJson( + $DefguardInstancesTable.$convertermfaContract.toJson(mfaContract), + ), }; } @@ -512,6 +547,7 @@ class DefguardInstance extends DataClass bool? mfaKeysStored, Value openidDisplayName = const Value.absent(), Value openidProviderKind = const Value.absent(), + MfaContract? mfaContract, }) => DefguardInstance( id: id ?? this.id, name: name ?? this.name, @@ -530,6 +566,7 @@ class DefguardInstance extends DataClass openidProviderKind: openidProviderKind.present ? openidProviderKind.value : this.openidProviderKind, + mfaContract: mfaContract ?? this.mfaContract, ); DefguardInstance copyWithCompanion(DefguardInstancesCompanion data) { return DefguardInstance( @@ -556,6 +593,9 @@ class DefguardInstance extends DataClass openidProviderKind: data.openidProviderKind.present ? data.openidProviderKind.value : this.openidProviderKind, + mfaContract: data.mfaContract.present + ? data.mfaContract.value + : this.mfaContract, ); } @@ -574,7 +614,8 @@ class DefguardInstance extends DataClass ..write('pubKey: $pubKey, ') ..write('mfaKeysStored: $mfaKeysStored, ') ..write('openidDisplayName: $openidDisplayName, ') - ..write('openidProviderKind: $openidProviderKind') + ..write('openidProviderKind: $openidProviderKind, ') + ..write('mfaContract: $mfaContract') ..write(')')) .toString(); } @@ -594,6 +635,7 @@ class DefguardInstance extends DataClass mfaKeysStored, openidDisplayName, openidProviderKind, + mfaContract, ); @override bool operator ==(Object other) => @@ -611,7 +653,8 @@ class DefguardInstance extends DataClass other.pubKey == this.pubKey && other.mfaKeysStored == this.mfaKeysStored && other.openidDisplayName == this.openidDisplayName && - other.openidProviderKind == this.openidProviderKind); + other.openidProviderKind == this.openidProviderKind && + other.mfaContract == this.mfaContract); } class DefguardInstancesCompanion extends UpdateCompanion { @@ -628,6 +671,7 @@ class DefguardInstancesCompanion extends UpdateCompanion { final Value mfaKeysStored; final Value openidDisplayName; final Value openidProviderKind; + final Value mfaContract; const DefguardInstancesCompanion({ this.id = const Value.absent(), this.name = const Value.absent(), @@ -642,6 +686,7 @@ class DefguardInstancesCompanion extends UpdateCompanion { this.mfaKeysStored = const Value.absent(), this.openidDisplayName = const Value.absent(), this.openidProviderKind = const Value.absent(), + this.mfaContract = const Value.absent(), }); DefguardInstancesCompanion.insert({ this.id = const Value.absent(), @@ -657,6 +702,7 @@ class DefguardInstancesCompanion extends UpdateCompanion { required bool mfaKeysStored, this.openidDisplayName = const Value.absent(), this.openidProviderKind = const Value.absent(), + this.mfaContract = const Value.absent(), }) : name = Value(name), uuid = Value(uuid), url = Value(url), @@ -680,6 +726,7 @@ class DefguardInstancesCompanion extends UpdateCompanion { Expression? mfaKeysStored, Expression? openidDisplayName, Expression? openidProviderKind, + Expression? mfaContract, }) { return RawValuesInsertable({ if (id != null) 'id': id, @@ -697,6 +744,7 @@ class DefguardInstancesCompanion extends UpdateCompanion { if (openidDisplayName != null) 'openid_display_name': openidDisplayName, if (openidProviderKind != null) 'openid_provider_kind': openidProviderKind, + if (mfaContract != null) 'mfa_contract': mfaContract, }); } @@ -714,6 +762,7 @@ class DefguardInstancesCompanion extends UpdateCompanion { Value? mfaKeysStored, Value? openidDisplayName, Value? openidProviderKind, + Value? mfaContract, }) { return DefguardInstancesCompanion( id: id ?? this.id, @@ -729,6 +778,7 @@ class DefguardInstancesCompanion extends UpdateCompanion { mfaKeysStored: mfaKeysStored ?? this.mfaKeysStored, openidDisplayName: openidDisplayName ?? this.openidDisplayName, openidProviderKind: openidProviderKind ?? this.openidProviderKind, + mfaContract: mfaContract ?? this.mfaContract, ); } @@ -782,6 +832,11 @@ class DefguardInstancesCompanion extends UpdateCompanion { ), ); } + if (mfaContract.present) { + map['mfa_contract'] = Variable( + $DefguardInstancesTable.$convertermfaContract.toSql(mfaContract.value), + ); + } return map; } @@ -800,7 +855,8 @@ class DefguardInstancesCompanion extends UpdateCompanion { ..write('pubKey: $pubKey, ') ..write('mfaKeysStored: $mfaKeysStored, ') ..write('openidDisplayName: $openidDisplayName, ') - ..write('openidProviderKind: $openidProviderKind') + ..write('openidProviderKind: $openidProviderKind, ') + ..write('mfaContract: $mfaContract') ..write(')')) .toString(); } @@ -1841,6 +1897,7 @@ typedef $$DefguardInstancesTableCreateCompanionBuilder = required bool mfaKeysStored, Value openidDisplayName, Value openidProviderKind, + Value mfaContract, }); typedef $$DefguardInstancesTableUpdateCompanionBuilder = DefguardInstancesCompanion Function({ @@ -1857,6 +1914,7 @@ typedef $$DefguardInstancesTableUpdateCompanionBuilder = Value mfaKeysStored, Value openidDisplayName, Value openidProviderKind, + Value mfaContract, }); final class $$DefguardInstancesTableReferences @@ -1967,6 +2025,12 @@ class $$DefguardInstancesTableFilterComposer builder: (column) => ColumnWithTypeConverterFilters(column), ); + ColumnWithTypeConverterFilters + get mfaContract => $composableBuilder( + column: $table.mfaContract, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + Expression locationsRefs( Expression Function($$LocationsTableFilterComposer f) f, ) { @@ -2066,6 +2130,11 @@ class $$DefguardInstancesTableOrderingComposer column: $table.openidProviderKind, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get mfaContract => $composableBuilder( + column: $table.mfaContract, + builder: (column) => ColumnOrderings(column), + ); } class $$DefguardInstancesTableAnnotationComposer @@ -2128,6 +2197,12 @@ class $$DefguardInstancesTableAnnotationComposer builder: (column) => column, ); + GeneratedColumnWithTypeConverter get mfaContract => + $composableBuilder( + column: $table.mfaContract, + builder: (column) => column, + ); + Expression locationsRefs( Expression Function($$LocationsTableAnnotationComposer a) f, ) { @@ -2202,6 +2277,7 @@ class $$DefguardInstancesTableTableManager Value openidDisplayName = const Value.absent(), Value openidProviderKind = const Value.absent(), + Value mfaContract = const Value.absent(), }) => DefguardInstancesCompanion( id: id, name: name, @@ -2216,6 +2292,7 @@ class $$DefguardInstancesTableTableManager mfaKeysStored: mfaKeysStored, openidDisplayName: openidDisplayName, openidProviderKind: openidProviderKind, + mfaContract: mfaContract, ), createCompanionCallback: ({ @@ -2234,6 +2311,7 @@ class $$DefguardInstancesTableTableManager Value openidDisplayName = const Value.absent(), Value openidProviderKind = const Value.absent(), + Value mfaContract = const Value.absent(), }) => DefguardInstancesCompanion.insert( id: id, name: name, @@ -2248,6 +2326,7 @@ class $$DefguardInstancesTableTableManager mfaKeysStored: mfaKeysStored, openidDisplayName: openidDisplayName, openidProviderKind: openidProviderKind, + mfaContract: mfaContract, ), withReferenceMapper: (p0) => p0 .map( diff --git a/client/lib/data/db/database.steps.dart b/client/lib/data/db/database.steps.dart index e1a2e6d..fb7d0d4 100644 --- a/client/lib/data/db/database.steps.dart +++ b/client/lib/data/db/database.steps.dart @@ -1061,6 +1061,112 @@ i1.GeneratedColumn _column_52(String aliasedName) => type: i1.DriftSqlType.string, $customConstraints: 'NULL', ); + +final class Schema8 extends i0.VersionedSchema { + Schema8({required super.database}) : super(version: 8); + @override + late final List entities = [ + defguardInstances, + locations, + ]; + late final Shape8 defguardInstances = Shape8( + source: i0.VersionedTable( + entityName: 'defguard_instances', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [ + _column_26, + _column_27, + _column_28, + _column_29, + _column_30, + _column_31, + _column_32, + _column_33, + _column_34, + _column_35, + _column_36, + _column_37, + _column_52, + _column_53, + ], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape6 locations = Shape6( + source: i0.VersionedTable( + entityName: 'locations', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [ + _column_26, + _column_38, + _column_39, + _column_27, + _column_40, + _column_35, + _column_41, + _column_42, + _column_43, + _column_44, + _column_45, + _column_46, + _column_47, + _column_48, + _column_49, + _column_50, + _column_51, + ], + attachedDatabase: database, + ), + alias: null, + ); +} + +class Shape8 extends i0.VersionedTable { + Shape8({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get id => + columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get name => + columnsByName['name']! as i1.GeneratedColumn; + i1.GeneratedColumn get uuid => + columnsByName['uuid']! as i1.GeneratedColumn; + i1.GeneratedColumn get url => + columnsByName['url']! as i1.GeneratedColumn; + i1.GeneratedColumn get deviceId => + columnsByName['device_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get proxyUrl => + columnsByName['proxy_url']! as i1.GeneratedColumn; + i1.GeneratedColumn get username => + columnsByName['username']! as i1.GeneratedColumn; + i1.GeneratedColumn get clientTrafficPolicy => + columnsByName['client_traffic_policy']! as i1.GeneratedColumn; + i1.GeneratedColumn get enterpriseEnabled => + columnsByName['enterprise_enabled']! as i1.GeneratedColumn; + i1.GeneratedColumn get pubKey => + columnsByName['pub_key']! as i1.GeneratedColumn; + i1.GeneratedColumn get mfaKeysStored => + columnsByName['mfa_keys_stored']! as i1.GeneratedColumn; + i1.GeneratedColumn get openidDisplayName => + columnsByName['openid_display_name']! as i1.GeneratedColumn; + i1.GeneratedColumn get openidProviderKind => + columnsByName['openid_provider_kind']! as i1.GeneratedColumn; + i1.GeneratedColumn get mfaContract => + columnsByName['mfa_contract']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_53(String aliasedName) => + i1.GeneratedColumn( + 'mfa_contract', + aliasedName, + false, + type: i1.DriftSqlType.string, + $customConstraints: 'NOT NULL DEFAULT \'legacy\'', + defaultValue: const i1.CustomExpression('\'legacy\''), + ); i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema2 schema) from1To2, required Future Function(i1.Migrator m, Schema3 schema) from2To3, @@ -1068,6 +1174,7 @@ i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema5 schema) from4To5, required Future Function(i1.Migrator m, Schema6 schema) from5To6, required Future Function(i1.Migrator m, Schema7 schema) from6To7, + required Future Function(i1.Migrator m, Schema8 schema) from7To8, }) { return (currentVersion, database) async { switch (currentVersion) { @@ -1101,6 +1208,11 @@ i0.MigrationStepWithVersion migrationSteps({ final migrator = i1.Migrator(database, schema); await from6To7(migrator, schema); return 7; + case 7: + final schema = Schema8(database: database); + final migrator = i1.Migrator(database, schema); + await from7To8(migrator, schema); + return 8; default: throw ArgumentError.value('Unknown migration from $currentVersion'); } @@ -1114,6 +1226,7 @@ i1.OnUpgrade stepByStep({ required Future Function(i1.Migrator m, Schema5 schema) from4To5, required Future Function(i1.Migrator m, Schema6 schema) from5To6, required Future Function(i1.Migrator m, Schema7 schema) from6To7, + required Future Function(i1.Migrator m, Schema8 schema) from7To8, }) => i0.VersionedSchema.stepByStepHelper( step: migrationSteps( from1To2: from1To2, @@ -1122,5 +1235,6 @@ i1.OnUpgrade stepByStep({ from4To5: from4To5, from5To6: from5To6, from6To7: from6To7, + from7To8: from7To8, ), ); diff --git a/client/lib/data/db/enums.dart b/client/lib/data/db/enums.dart index ce15483..698f4ea 100644 --- a/client/lib/data/db/enums.dart +++ b/client/lib/data/db/enums.dart @@ -71,6 +71,8 @@ enum MfaMethod { } } +enum MfaContract { legacy, multiStep } + @j.JsonEnum() enum OpenIdProvider { @j.JsonValue(1) diff --git a/client/lib/data/mfa/mfa_flow.dart b/client/lib/data/mfa/mfa_flow.dart index f193713..b970006 100644 --- a/client/lib/data/mfa/mfa_flow.dart +++ b/client/lib/data/mfa/mfa_flow.dart @@ -1,37 +1,6 @@ -import 'dart:convert'; - import 'package:mobile/data/db/enums.dart'; -import 'package:mobile/data/proxy/mfa.dart'; +import 'package:mobile/data/mfa/mfa_transport.dart'; import 'package:mobile/enterprise/postures.dart'; -import 'package:mobile/open/api.dart'; - -/// The three calls the MFA flow makes, behind an interface so the flow can be -/// driven without a proxy in tests. -abstract class MfaTransport { - Future start(StartMfaRequest request); - - Future stepStart(StepStartMfaRequest request); - - Future finish(FinishMfaRequest request); -} - -class ProxyMfaTransport implements MfaTransport { - final Uri proxyUrl; - - const ProxyMfaTransport(this.proxyUrl); - - @override - Future start(StartMfaRequest request) => - proxyApi.startMfa(proxyUrl, request); - - @override - Future stepStart(StepStartMfaRequest request) => - proxyApi.stepStartMfa(proxyUrl, request); - - @override - Future finish(FinishMfaRequest request) => - proxyApi.finishMfa(proxyUrl, request); -} /// What the server said about the proof just submitted. sealed class MfaStepProgress { @@ -50,19 +19,16 @@ class MfaStepAwaiting extends MfaStepProgress { const MfaStepAwaiting(); } -/// Drives one connect-time MFA flow: opens each step, submits its proof, and -/// decides what the server's answer means. Holds the preshared key privately so -/// it never reaches a widget. +typedef MfaPlanRefresh = Future?> Function(); + +/// Drives one connect-time MFA flow and keeps the preshared key out of widgets. class MfaFlowController { - /// One method per step, in flow order. - final List plan; + List _plan; final String devicePubkey; final int networkId; - - /// Collected once for the whole flow and sent only with the first call. final DevicePostureData? postureData; - final MfaTransport transport; + final MfaPlanRefresh? refreshPlan; int _stepIndex = 0; String? _token; @@ -72,121 +38,107 @@ class MfaFlowController { String? _presharedKey; bool _cancelled = false; bool _stepOpen = false; + bool _startRetried = false; MfaFlowController({ required this.transport, - required this.plan, + required List plan, required this.devicePubkey, required this.networkId, this.postureData, - }) : assert(plan.isNotEmpty, "a flow needs at least one step"); + this.refreshPlan, + }) : _plan = List.unmodifiable(plan), + assert(plan.isNotEmpty, 'a flow needs at least one step'); + + List get plan => _plan; int get stepIndex => _stepIndex; - int get stepCount => plan.length; + int get stepCount => _plan.length; - MfaMethod get method => plan[_stepIndex]; + MfaMethod get method => _plan[_stepIndex]; String? get token => _token; String? get challenge => _challenge; - /// Security keys the server will accept for the current FIDO2 step. + String? get stepAttemptId => _stepAttemptId; + List get credentialIds => _credentialIds; bool get isCancelled => _cancelled; - /// Only shown for a flow with more than one step. String? get stepLabel => - plan.length > 1 ? "Step ${_stepIndex + 1}/${plan.length}" : null; + _plan.length > 1 ? 'Step ${_stepIndex + 1}/${_plan.length}' : null; void cancel() => _cancelled = true; - /// Reads the preshared key out, leaving nothing behind. String? takePresharedKey() { final key = _presharedKey; _presharedKey = null; return key; } - /// Opens the current step. The first call creates the session and submits the - /// whole plan; later ones open a step within it. A single-step flow never - /// calls step-start, which a pre-2.2 proxy does not have. + /// Opens the current step. A single-step flow never calls step-start, which + /// the legacy contract does not have. Future startStep() async { final token = _token; - if (token != null && plan.length > 1) { - final step = await transport.stepStart( - StepStartMfaRequest(token: token, method: method), - ); - _stepAttemptId = step.stepAttemptId; - _challenge = step.challenge; - _credentialIds = step.credentialIds; - _stepOpen = true; + if (token != null && _plan.length > 1) { + _setStep(await transport.startStep(token, method)); return; } - final session = await transport.start( - StartMfaRequest( - pubkey: devicePubkey, - locationId: networkId, - method: plan.first, - selectedMethods: plan, - postureData: postureData, - ), - ); + final session = await _startWithRetry(); _token = session.token; - _challenge = session.challenge; - _credentialIds = session.credentialIds; - _stepAttemptId = null; - _stepOpen = true; + _setStep(session.firstStep); } - /// Submits a proof for the current step, or polls for an out-of-band one when - /// [code] is null. The attempt id is reused across retries, so a rejected code - /// does not need the step reopening. - Future submit({String? code}) => _finish( - (token) => FinishMfaRequest( - token: token, - code: code, - stepAttemptId: _stepAttemptId, - ), - ); + Future _startWithRetry() async { + try { + return await _start(); + } on MfaStartRejectedException { + final refresh = refreshPlan; + if (_startRetried || refresh == null) rethrow; + _startRetried = true; + final refreshedPlan = await refresh(); + if (refreshedPlan == null || refreshedPlan.isEmpty) rethrow; + _plan = List.unmodifiable(refreshedPlan); + return _start(); + } + } - /// Submits a security key assertion. The server reads the signature from - /// `auth_pub_key`, as the desktop client sends it. - Future submitFido2({ - required List signature, - required List authData, - required List credentialId, - }) => _finish( - (token) => FinishMfaRequest( - token: token, - authPubKey: base64Url.encode(signature).replaceAll('=', ''), - authData: authData, - credentialId: credentialId, - stepAttemptId: _stepAttemptId, - ), + Future _start() => transport.start( + devicePubkey: devicePubkey, + networkId: networkId, + plan: _plan, + postureData: postureData, ); - Future _finish( - FinishMfaRequest Function(String token) buildRequest, - ) async { + void _setStep(MfaStepChallenge step) { + _stepAttemptId = step.stepAttemptId; + _challenge = step.challenge; + _credentialIds = step.credentialIds; + _stepOpen = true; + } + + Future submit({MfaCredential? credential}) async { final token = _token; if (token == null || !_stepOpen) { // The previous step's screen stays on top while the next one is being // opened, so it can still be tapped in that gap. - throw StateError("MFA proof submitted while no step was open"); + throw StateError('MFA proof submitted while no step was open'); } + _validateCredential(credential); + final result = await transport.finish( + token: token, + stepAttemptId: _stepAttemptId, + credential: credential, + ); - final response = await transport.finish(buildRequest(token)); - - switch (response.outcome) { - case MfaAdvanced(:final nextStep): - if (nextStep < 0 || nextStep >= plan.length) { - throw FormatException( - "Server advanced to step ${nextStep + 1} of a " - "${plan.length}-step flow", - ); + switch (result) { + case MfaFinishAdvanced(:final nextStep): + if (nextStep < 0 || nextStep >= _plan.length) { + throw const FormatException('Invalid MFA flow step transition'); } _stepIndex = nextStep; _challenge = null; @@ -194,21 +146,38 @@ class MfaFlowController { _stepAttemptId = null; _stepOpen = false; return const MfaStepAdvanced(); - case MfaCompleted(:final presharedKey): + case MfaFinishCompleted(:final presharedKey): _presharedKey = presharedKey; _stepOpen = false; return const MfaStepCompleted(); - case MfaAwaitingExternal(): + case MfaFinishAwaitingExternal(): return const MfaStepAwaiting(); - case null: - // Pre-2.2 proxies report completion through the deprecated field only, - // and an absent key there has always meant the proof was not accepted. - if (response.presharedKey == null) { - throw const MfaCodeRejectedException(); - } - _presharedKey = response.presharedKey; - _stepOpen = false; - return const MfaStepCompleted(); } } + + void _validateCredential(MfaCredential? credential) { + final valid = switch (credential) { + MfaCodeCredential() => + method == MfaMethod.totp || method == MfaMethod.email, + MfaBiometricCredential(:final signature, :final authPubKey) => + method == MfaMethod.biometric && + signature.isNotEmpty && + authPubKey.isNotEmpty, + MfaFido2Credential() => method == MfaMethod.fido2, + null => method == MfaMethod.openid, + }; + if (!valid) throw StateError('Credential does not match the MFA step'); + } + + Future submitFido2({ + required List signature, + required List authData, + required List credentialId, + }) => submit( + credential: MfaFido2Credential.fromAssertion( + signature: signature, + authenticatorData: authData, + credentialId: credentialId, + ), + ); } diff --git a/client/lib/data/mfa/mfa_plan.dart b/client/lib/data/mfa/mfa_plan.dart index f08042a..c9b56f0 100644 --- a/client/lib/data/mfa/mfa_plan.dart +++ b/client/lib/data/mfa/mfa_plan.dart @@ -35,6 +35,25 @@ List sanitizeMfaStepPlan( }, growable: false); } +class MfaCapabilities { + final bool biometricAvailable; + final MfaContract contract; + + const MfaCapabilities({ + required this.biometricAvailable, + required this.contract, + }); + + /// The legacy contract carries one step and has no FIDO2 proof. + bool get supportsMultipleSteps => contract == MfaContract.multiStep; + + bool supports(MfaMethod method) => + method != MfaMethod.fido2 || contract == MfaContract.multiStep; + + bool canRun(List steps) => + steps.length <= 1 || supportsMultipleSteps; +} + /// Why a step's method cannot be used right now. enum MfaMethodAvailability { usable, @@ -45,7 +64,8 @@ enum MfaMethodAvailability { /// Biometric, but this device has no usable biometric storage. biometryUnavailable, - /// A factor this client cannot perform at all. + /// A factor this client cannot perform, or one the server's MFA contract + /// does not carry. unsupported, } @@ -54,11 +74,14 @@ enum MfaUnpassableReason { setUpBiometry, notConfigured, desktopOnly } MfaMethodAvailability mfaMethodAvailability( MfaStepMethod entry, { - required bool biometricAvailable, + required MfaCapabilities capabilities, }) { - if (entry.method == null) return MfaMethodAvailability.unsupported; + final method = entry.method; + if (method == null || !capabilities.supports(method)) { + return MfaMethodAvailability.unsupported; + } if (!entry.configured) return MfaMethodAvailability.notConfigured; - if (entry.method == MfaMethod.biometric && !biometricAvailable) { + if (method == MfaMethod.biometric && !capabilities.biometricAvailable) { return MfaMethodAvailability.biometryUnavailable; } return MfaMethodAvailability.usable; @@ -67,14 +90,11 @@ MfaMethodAvailability mfaMethodAvailability( /// Methods of [step] this device can actually prove right now. List usableMfaMethods( MfaStep step, { - required bool biometricAvailable, + required MfaCapabilities capabilities, }) => step.methods .where( (entry) => - mfaMethodAvailability( - entry, - biometricAvailable: biometricAvailable, - ) == + mfaMethodAvailability(entry, capabilities: capabilities) == MfaMethodAvailability.usable, ) .toList(growable: false); @@ -82,9 +102,14 @@ List usableMfaMethods( /// Methods of [step] worth showing, unusable ones included so the user can see /// why. Falls back to the raw list so a step made entirely of factors this /// client cannot perform still renders something. -List pickableMfaMethods(MfaStep step) { +List pickableMfaMethods( + MfaStep step, { + required MfaCapabilities capabilities, +}) { final supported = step.methods - .where((entry) => entry.method != null) + .where( + (entry) => entry.method != null && capabilities.supports(entry.method!), + ) .toList(growable: false); return supported.isNotEmpty ? supported : step.methods; } @@ -94,16 +119,16 @@ List pickableMfaMethods(MfaStep step) { List resolveMfaStepPlan( Location location, { List oneOff = const [], - required bool biometricAvailable, + required MfaCapabilities capabilities, }) { final steps = effectiveMfaSteps(location); + if (!capabilities.canRun(steps)) { + return List.filled(steps.length, null, growable: false); + } final saved = location.mfaStepPlan; return List.generate(steps.length, (index) { - final usable = usableMfaMethods( - steps[index], - biometricAvailable: biometricAvailable, - ); + final usable = usableMfaMethods(steps[index], capabilities: capabilities); bool isUsable(MfaMethod? method) => method != null && usable.any((entry) => entry.method == method); @@ -115,31 +140,47 @@ List resolveMfaStepPlan( }, growable: false); } +/// Resolves a retry only when refresh kept the contract used for this attempt. +List? resolveMfaRetryPlan( + Location location, { + required MfaCapabilities attempt, + required MfaContract refreshedContract, + List oneOff = const [], +}) { + if (attempt.contract != refreshedContract) return null; + final plan = resolveMfaStepPlan( + location, + oneOff: oneOff, + capabilities: attempt, + ); + if (plan.isEmpty || plan.contains(null)) return null; + return plan.cast(); +} + bool hasUnpassableMfaStep( Location location, { - required bool biometricAvailable, -}) => effectiveMfaSteps(location).any( - (step) => - usableMfaMethods(step, biometricAvailable: biometricAvailable).isEmpty, -); + required MfaCapabilities capabilities, +}) { + final steps = effectiveMfaSteps(location); + return !capabilities.canRun(steps) || + steps.any( + (step) => usableMfaMethods(step, capabilities: capabilities).isEmpty, + ); +} MfaUnpassableReason? unpassableStepReason( Location location, { - required bool biometricAvailable, + required MfaCapabilities capabilities, }) { - final step = effectiveMfaSteps(location).firstWhereOrNull( - (step) => - usableMfaMethods(step, biometricAvailable: biometricAvailable).isEmpty, + final steps = effectiveMfaSteps(location); + if (!capabilities.canRun(steps)) return MfaUnpassableReason.desktopOnly; + final step = steps.firstWhereOrNull( + (step) => usableMfaMethods(step, capabilities: capabilities).isEmpty, ); if (step == null) return null; final states = step.methods - .map( - (entry) => mfaMethodAvailability( - entry, - biometricAvailable: biometricAvailable, - ), - ) + .map((entry) => mfaMethodAvailability(entry, capabilities: capabilities)) .toSet(); if (states.contains(MfaMethodAvailability.biometryUnavailable)) { return MfaUnpassableReason.setUpBiometry; @@ -150,4 +191,4 @@ MfaUnpassableReason? unpassableStepReason( return MfaUnpassableReason.desktopOnly; } -String mfaStepsToText(int count) => "$count-step verification"; +String mfaStepsToText(int count) => '$count-step verification'; diff --git a/client/lib/data/mfa/mfa_transport.dart b/client/lib/data/mfa/mfa_transport.dart new file mode 100644 index 0000000..320aced --- /dev/null +++ b/client/lib/data/mfa/mfa_transport.dart @@ -0,0 +1,318 @@ +import 'package:mobile/data/db/enums.dart'; +import 'package:mobile/data/proxy/mfa.dart'; +import 'package:mobile/data/proxy/mfa_flow.dart' as wire; +import 'package:mobile/enterprise/postures.dart'; +import 'package:mobile/open/api.dart'; + +abstract class MfaTransport { + Future start({ + required String devicePubkey, + required int networkId, + required List plan, + DevicePostureData? postureData, + }); + + Future startStep(String token, MfaMethod method); + + Future finish({ + required String token, + required String? stepAttemptId, + required MfaCredential? credential, + }); +} + +class MfaSessionStart { + final String token; + final MfaStepChallenge firstStep; + + const MfaSessionStart({required this.token, required this.firstStep}); +} + +class MfaStepChallenge { + final String? stepAttemptId; + final String? challenge; + final List credentialIds; + + const MfaStepChallenge({ + this.stepAttemptId, + this.challenge, + this.credentialIds = const [], + }); +} + +sealed class MfaCredential { + const MfaCredential(); +} + +final class MfaCodeCredential extends MfaCredential { + final String code; + + const MfaCodeCredential(this.code); +} + +final class MfaBiometricCredential extends MfaCredential { + final String signature; + final String authPubKey; + + const MfaBiometricCredential({ + required this.signature, + required this.authPubKey, + }); +} + +final class MfaFido2Credential extends MfaCredential { + final List rpIdHash; + final List authenticatorData; + final List signature; + final List credentialId; + + const MfaFido2Credential._({ + required this.rpIdHash, + required this.authenticatorData, + required this.signature, + required this.credentialId, + }); + + factory MfaFido2Credential.fromAssertion({ + required List signature, + required List authenticatorData, + required List credentialId, + }) { + if (authenticatorData.length < 37 || + signature.isEmpty || + credentialId.isEmpty) { + throw const FormatException('Invalid security key assertion'); + } + return MfaFido2Credential._( + rpIdHash: authenticatorData.sublist(0, 32), + authenticatorData: List.of(authenticatorData), + signature: List.of(signature), + credentialId: List.of(credentialId), + ); + } +} + +sealed class MfaFinishResult { + const MfaFinishResult(); +} + +final class MfaFinishAdvanced extends MfaFinishResult { + final int nextStep; + + const MfaFinishAdvanced(this.nextStep); +} + +final class MfaFinishCompleted extends MfaFinishResult { + final String? presharedKey; + + const MfaFinishCompleted(this.presharedKey); +} + +final class MfaFinishAwaitingExternal extends MfaFinishResult { + const MfaFinishAwaitingExternal(); +} + +class MfaStartRejectedException implements Exception { + final String message; + + const MfaStartRejectedException(this.message); + + @override + String toString() => message; +} + +class LegacyMfaTransport implements MfaTransport { + final Uri proxyUrl; + final ProxyApi _api; + + LegacyMfaTransport(this.proxyUrl, {ProxyApi? api}) : _api = api ?? proxyApi; + + @override + Future start({ + required String devicePubkey, + required int networkId, + required List plan, + DevicePostureData? postureData, + }) async { + if (plan.length != 1 || plan.single == MfaMethod.fido2) { + throw UnsupportedError( + 'The legacy MFA contract supports one non-FIDO2 step', + ); + } + final response = await _api.startMfa( + proxyUrl, + StartMfaRequest( + pubkey: devicePubkey, + locationId: networkId, + method: plan.single, + postureData: postureData, + ), + ); + return MfaSessionStart( + token: response.token, + firstStep: MfaStepChallenge(challenge: response.challenge), + ); + } + + @override + Future startStep(String token, MfaMethod method) => + throw UnsupportedError('The legacy MFA contract has no step-start route'); + + @override + Future finish({ + required String token, + required String? stepAttemptId, + required MfaCredential? credential, + }) async { + if (stepAttemptId != null) { + throw StateError('The legacy MFA contract does not use step attempt IDs'); + } + final request = switch (credential) { + null => FinishMfaRequest(token: token), + MfaCodeCredential(:final code) => FinishMfaRequest( + token: token, + code: code, + ), + MfaBiometricCredential(:final signature, :final authPubKey) => + FinishMfaRequest( + token: token, + code: signature, + authPubKey: authPubKey, + ), + MfaFido2Credential() => throw UnsupportedError( + 'FIDO2 is unavailable on the legacy MFA contract', + ), + }; + final response = await _api.finishMfa(proxyUrl, request); + if (response == null) return const MfaFinishAwaitingExternal(); + final presharedKey = response.presharedKey; + if (presharedKey == null) throw const MfaCodeRejectedException(); + return MfaFinishCompleted(presharedKey); + } +} + +class MfaFlowProxyTransport implements MfaTransport { + final Uri proxyUrl; + final ProxyApi _api; + + MfaFlowProxyTransport(this.proxyUrl, {ProxyApi? api}) + : _api = api ?? proxyApi; + + @override + Future start({ + required String devicePubkey, + required int networkId, + required List plan, + DevicePostureData? postureData, + }) async { + final response = await _api.startMfaFlow( + proxyUrl, + wire.MfaFlowStartRequest( + locationId: networkId, + pubkey: devicePubkey, + postureData: postureData, + selectedMethods: plan, + ), + ); + switch (response.outcome) { + case wire.MfaFlowAccepted(:final token, :final firstStep): + return MfaSessionStart( + token: token, + firstStep: _stepChallenge(firstStep), + ); + case wire.MfaFlowRejected(:final rejections): + final message = rejections + .map((rejection) => rejection.message) + .join(' '); + throw MfaStartRejectedException( + message.isEmpty ? 'The server rejected the MFA plan.' : message, + ); + case wire.MfaFlowStartUnknown(): + throw const FormatException('Unrecognized MFA flow start response'); + } + } + + @override + Future startStep(String token, MfaMethod method) async { + final response = await _api.startMfaFlowStep( + proxyUrl, + wire.MfaFlowStepStartRequest(token: token, method: method), + ); + final started = response.started; + if (started == null) { + throw const FormatException( + 'MFA flow step response did not include a step', + ); + } + return _stepChallenge(started); + } + + @override + Future finish({ + required String token, + required String? stepAttemptId, + required MfaCredential? credential, + }) async { + if (stepAttemptId == null || stepAttemptId.isEmpty) { + throw StateError('The MFA flow contract requires a step attempt ID'); + } + final submission = switch (credential) { + null => null, + MfaCodeCredential(:final code) => wire.MfaFlowCodeSubmission(code), + MfaBiometricCredential(:final signature) => + wire.MfaFlowBiometricSubmission(signature), + MfaFido2Credential( + :final rpIdHash, + :final authenticatorData, + :final signature, + :final credentialId, + ) => + wire.MfaFlowFido2Submission( + rpIdHash: rpIdHash, + authenticatorData: authenticatorData, + signature: signature, + credentialId: credentialId, + ), + }; + final response = await _api.finishMfaFlow( + proxyUrl, + wire.MfaFlowStepFinishRequest( + token: token, + stepAttemptId: stepAttemptId, + submission: submission, + ), + ); + return switch (response.result) { + wire.MfaFlowAdvanced(:final nextStep) => MfaFinishAdvanced(nextStep), + wire.MfaFlowCompleted(:final presharedKey) => MfaFinishCompleted( + presharedKey, + ), + wire.MfaFlowAwaitingExternal() => const MfaFinishAwaitingExternal(), + _ => throw const FormatException('Unrecognized MFA flow finish response'), + }; + } +} + +MfaStepChallenge _stepChallenge(wire.MfaFlowStepStarted step) => + switch (step.challenge) { + null => MfaStepChallenge(stepAttemptId: step.stepAttemptId), + wire.MfaSignatureChallenge(:final challenge) => MfaStepChallenge( + stepAttemptId: step.stepAttemptId, + challenge: challenge, + ), + wire.MfaFido2Challenge(:final challenge, :final credentialIds) => + MfaStepChallenge( + stepAttemptId: step.stepAttemptId, + challenge: challenge, + credentialIds: credentialIds, + ), + wire.MfaUnknownChallenge() => throw const FormatException( + 'Unrecognized MFA flow challenge', + ), + }; + +MfaTransport mfaTransportForContract(MfaContract contract, Uri proxyUrl) => + switch (contract) { + MfaContract.legacy => LegacyMfaTransport(proxyUrl), + MfaContract.multiStep => MfaFlowProxyTransport(proxyUrl), + }; diff --git a/client/lib/data/proxy/enrollment.dart b/client/lib/data/proxy/enrollment.dart index fbbcc37..368b2f9 100644 --- a/client/lib/data/proxy/enrollment.dart +++ b/client/lib/data/proxy/enrollment.dart @@ -277,6 +277,7 @@ class InstanceInfo { final String? openidDisplayName; @JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue) final OpenIdProvider? openidProviderKind; + final Map? mfaUserState; const InstanceInfo({ required this.id, @@ -289,6 +290,7 @@ class InstanceInfo { required this.clientTrafficPolicy, this.openidDisplayName, this.openidProviderKind, + this.mfaUserState, }); factory InstanceInfo.fromJson(Map json) => @@ -305,7 +307,8 @@ class InstanceInfo { enterpriseEnabled == other.enterpriseEnabled && getPolicy() == other.clientTrafficPolicy && openidDisplayName == other.openidDisplayName && - openidProviderKind == other.openidProviderKind; + openidProviderKind == other.openidProviderKind && + mfaContract == other.mfaContract; } DefguardInstancesCompanion toCompanion({DefguardInstance? instance}) { @@ -322,6 +325,7 @@ class InstanceInfo { uuid: d.Value(id), openidDisplayName: d.Value(openidDisplayName), openidProviderKind: d.Value(openidProviderKind), + mfaContract: d.Value(mfaContract), ); } @@ -332,6 +336,9 @@ class InstanceInfo { ? ClientTrafficPolicy.disableAllTraffic : ClientTrafficPolicy.none); } + + MfaContract get mfaContract => + mfaUserState == null ? MfaContract.legacy : MfaContract.multiStep; } @JsonSerializable() diff --git a/client/lib/data/proxy/enrollment.g.dart b/client/lib/data/proxy/enrollment.g.dart index b202dd6..44c78b7 100644 --- a/client/lib/data/proxy/enrollment.g.dart +++ b/client/lib/data/proxy/enrollment.g.dart @@ -422,6 +422,10 @@ InstanceInfo _$InstanceInfoFromJson(Map json) => unknownValue: JsonKey.nullForUndefinedEnumValue, ), ), + mfaUserState: $checkedConvert( + 'mfa_user_state', + (v) => v as Map?, + ), ); return val; }, @@ -432,6 +436,7 @@ InstanceInfo _$InstanceInfoFromJson(Map json) => 'clientTrafficPolicy': 'client_traffic_policy', 'openidDisplayName': 'openid_display_name', 'openidProviderKind': 'openid_provider_kind', + 'mfaUserState': 'mfa_user_state', }, ); @@ -446,6 +451,7 @@ const _$InstanceInfoFieldMap = { 'clientTrafficPolicy': 'client_traffic_policy', 'openidDisplayName': 'openid_display_name', 'openidProviderKind': 'openid_provider_kind', + 'mfaUserState': 'mfa_user_state', }; Map _$InstanceInfoToJson( @@ -462,6 +468,7 @@ Map _$InstanceInfoToJson( _$ClientTrafficPolicyEnumMap[instance.clientTrafficPolicy], 'openid_display_name': instance.openidDisplayName, 'openid_provider_kind': _$OpenIdProviderEnumMap[instance.openidProviderKind], + 'mfa_user_state': instance.mfaUserState, }; const _$ClientTrafficPolicyEnumMap = { diff --git a/client/lib/data/proxy/mfa.dart b/client/lib/data/proxy/mfa.dart index 078f960..7a005c0 100644 --- a/client/lib/data/proxy/mfa.dart +++ b/client/lib/data/proxy/mfa.dart @@ -1,5 +1,3 @@ -import 'dart:convert'; - import 'package:json_annotation/json_annotation.dart'; import 'package:mobile/data/db/enums.dart'; import 'package:mobile/enterprise/postures.dart'; @@ -10,21 +8,13 @@ part 'mfa.g.dart'; class StartMfaRequest { final String pubkey; final int locationId; - - /// Read by a pre-2.2 proxy, which ignores [selectedMethods]. Always the first - /// step's method. final MfaMethod method; final DevicePostureData? postureData; - /// One method per step in flow order. Non-empty is what tells a 2.2 proxy to - /// run the multi-step flow rather than the legacy single-step adapter. - final List selectedMethods; - const StartMfaRequest({ required this.pubkey, required this.locationId, required this.method, - required this.selectedMethods, this.postureData, }); @@ -39,27 +29,12 @@ class StartMfaResponse { final String token; final String? challenge; - /// Sparse, and only ever sent by a 2.2 proxy. Non-empty means the plan was - /// refused and no session exists. - @JsonKey(defaultValue: []) - final List rejections; - - /// Base64url ids of the user's security keys, sent when the first step is - /// FIDO2. - @JsonKey(defaultValue: []) - final List credentialIds; + const StartMfaResponse({required this.token, required this.challenge}); factory StartMfaResponse.fromJson(Map json) => _$StartMfaResponseFromJson(json); Map toJson() => _$StartMfaResponseToJson(this); - - const StartMfaResponse({ - required this.token, - required this.challenge, - required this.rejections, - this.credentialIds = const [], - }); } @JsonSerializable() @@ -68,26 +43,7 @@ class FinishMfaRequest { final String? code; final String? authPubKey; - /// Binds this proof to one attempt at the current step. Omitted on the legacy - /// single-step path, which has no attempt id. - @JsonKey(includeIfNull: false) - final String? stepAttemptId; - - /// FIDO2 only. The proxy's serde reads proto `bytes` as a number array. - @JsonKey(includeIfNull: false) - final List? authData; - - @JsonKey(includeIfNull: false) - final List? credentialId; - - const FinishMfaRequest({ - required this.token, - this.code, - this.authPubKey, - this.stepAttemptId, - this.authData, - this.credentialId, - }); + const FinishMfaRequest({required this.token, this.code, this.authPubKey}); factory FinishMfaRequest.fromJson(Map json) => _$FinishMfaRequestFromJson(json); @@ -95,21 +51,23 @@ class FinishMfaRequest { Map toJson() => _$FinishMfaRequestToJson(this); } +@JsonSerializable() class FinishMfaResponse { - /// Deprecated in 2.2 and the only field a pre-2.2 proxy sets. final String? presharedKey; - /// Absent from a pre-2.2 proxy, which reports completion through - /// [presharedKey] and failure through the status code. - final MfaOutcome? outcome; - - const FinishMfaResponse({this.presharedKey, this.outcome}); + const FinishMfaResponse({this.presharedKey}); factory FinishMfaResponse.fromJson(Map json) => - FinishMfaResponse( - presharedKey: json['preshared_key'] as String?, - outcome: parseMfaOutcome(json['result']), - ); + _$FinishMfaResponseFromJson(json); + + Map toJson() => _$FinishMfaResponseToJson(this); +} + +class MfaCodeRejectedException implements Exception { + const MfaCodeRejectedException(); + + @override + String toString() => 'MFA proof rejected'; } @JsonSerializable() @@ -128,171 +86,111 @@ class SecureInstanceStorage { }); } -@JsonSerializable() -class RemoteMfaQr { +final _uuid = RegExp( + r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$', +); + +/// Core sends short alphanumeric values. The cap bounds size with headroom. +final _qrSecret = RegExp(r'^[A-Za-z0-9]{1,128}$'); + +/// A 2.2 token is alphanumeric, but a pre-2.2 token is a JWT. +final _qrToken = RegExp(r'^[A-Za-z0-9._-]{1,4096}$'); + +bool _isQrSecret(Object? value) => value is String && _qrSecret.hasMatch(value); + +sealed class RemoteMfaQr { final String instanceId; final String token; final String challenge; - factory RemoteMfaQr.fromJson(Map json) => - _$RemoteMfaQrFromJson(json); - - Map toJson() => _$RemoteMfaQrToJson(this); - const RemoteMfaQr({ required this.instanceId, required this.token, required this.challenge, }); -} -enum MfaStartRejectionReason { - @JsonValue(0) - unspecified, - @JsonValue(1) - methodNotInStep, - @JsonValue(2) - stepEmptyAfterLicense, - @JsonValue(3) - stepUnavailable, -} - -@JsonSerializable() -class MfaStepRejection { - final int step; - @JsonKey(unknownEnumValue: MfaStartRejectionReason.unspecified) - final MfaStartRejectionReason reason; - - const MfaStepRejection({required this.step, required this.reason}); - - factory MfaStepRejection.fromJson(Map json) => - _$MfaStepRejectionFromJson(json); - - Map toJson() => _$MfaStepRejectionToJson(this); - - /// Step numbers are zero-based on the wire and one-based for people. - String get message => switch (reason) { - MfaStartRejectionReason.methodNotInStep => - "The method chosen for verification step ${step + 1} is not allowed. " - "The location's MFA settings have changed, so pick a method again.", - MfaStartRejectionReason.stepEmptyAfterLicense => - "Verification step ${step + 1} has no method available on this server. " - "Contact your administrator.", - MfaStartRejectionReason.stepUnavailable => - "The method chosen for verification step ${step + 1} cannot be used. " - "Set it up first, or pick a different one.", - MfaStartRejectionReason.unspecified => - "The server rejected verification step ${step + 1}.", - }; + factory RemoteMfaQr.fromJson(Map json) { + final instanceId = json['instance_id']; + final token = json['token']; + final challenge = json['challenge']; + if (instanceId is! String || + !_uuid.hasMatch(instanceId) || + token is! String || + !_qrToken.hasMatch(token) || + !_isQrSecret(challenge)) { + throw const FormatException('Invalid remote MFA QR'); + } + + if (!json.containsKey('step_attempt_id')) { + return LegacyRemoteMfaQr( + instanceId: instanceId, + token: token, + challenge: challenge, + ); + } + + final stepAttemptId = json['step_attempt_id']; + if (!_isQrSecret(stepAttemptId)) { + throw const FormatException('Invalid remote MFA flow QR'); + } + return MfaFlowRemoteMfaQr( + instanceId: instanceId, + token: token, + challenge: challenge, + stepAttemptId: stepAttemptId, + ); + } } -class MfaRejectedException implements Exception { - final List rejections; - - const MfaRejectedException(this.rejections); - - String get message => rejections.map((r) => r.message).join(' '); - - @override - String toString() => "MFA start rejected: $message"; +final class LegacyRemoteMfaQr extends RemoteMfaQr { + const LegacyRemoteMfaQr({ + required super.instanceId, + required super.token, + required super.challenge, + }); } -class MfaCodeRejectedException implements Exception { - const MfaCodeRejectedException(); +final class MfaFlowRemoteMfaQr extends RemoteMfaQr { + final String stepAttemptId; - @override - String toString() => "MFA proof rejected"; + const MfaFlowRemoteMfaQr({ + required super.instanceId, + required super.token, + required super.challenge, + required this.stepAttemptId, + }); } @JsonSerializable() -class StepStartMfaRequest { - final String token; - final MfaMethod method; +class MfaMobileApprovalProof { + final String signature; + final String authPubKey; - const StepStartMfaRequest({required this.token, required this.method}); + const MfaMobileApprovalProof({ + required this.signature, + required this.authPubKey, + }); - factory StepStartMfaRequest.fromJson(Map json) => - _$StepStartMfaRequestFromJson(json); + factory MfaMobileApprovalProof.fromJson(Map json) => + _$MfaMobileApprovalProofFromJson(json); - Map toJson() => _$StepStartMfaRequestToJson(this); + Map toJson() => _$MfaMobileApprovalProofToJson(this); } @JsonSerializable() -class StepStartMfaResponse { +class MfaFlowApproveRequest { + final String token; final String stepAttemptId; - final String? challenge; + final MfaMobileApprovalProof proof; - @JsonKey(defaultValue: []) - final List credentialIds; - - const StepStartMfaResponse({ + const MfaFlowApproveRequest({ + required this.token, required this.stepAttemptId, - this.challenge, - this.credentialIds = const [], + required this.proof, }); - factory StepStartMfaResponse.fromJson(Map json) => - _$StepStartMfaResponseFromJson(json); - - Map toJson() => _$StepStartMfaResponseToJson(this); -} - -/// Outcome of one submitted proof. -sealed class MfaOutcome { - const MfaOutcome(); -} - -class MfaAdvanced extends MfaOutcome { - final int nextStep; - - const MfaAdvanced(this.nextStep); -} - -class MfaCompleted extends MfaOutcome { - /// Absent when the location's peer needs no preshared key. - final String? presharedKey; - - const MfaCompleted(this.presharedKey); -} - -/// The out-of-band factor has not resolved yet. Not a failure, and it does not -/// count against the attempt limit. -class MfaAwaitingExternal extends MfaOutcome { - const MfaAwaitingExternal(); -} - -String _outcomeKey(String key) => key.toLowerCase().replaceAll('_', ''); - -Never _unrecognized(Object? result) => - throw FormatException('Unrecognized MFA step result', jsonEncode(result)); - -/// Reads `ClientMfaFinishResponse.result`. The proxy serializes the proto -/// `oneof` through serde, so the variant arrives as the single key of an -/// `outcome` object. Only the key spelling is treated as uncertain; an -/// unexpected shape throws with the body so the mismatch is visible. -MfaOutcome? parseMfaOutcome(Object? result) { - if (result == null) return null; - if (result is! Map) _unrecognized(result); - - final outcome = result['outcome']; - if (outcome == null) return null; - if (outcome is! Map || outcome.length != 1) { - _unrecognized(result); - } + factory MfaFlowApproveRequest.fromJson(Map json) => + _$MfaFlowApproveRequestFromJson(json); - final body = outcome.values.single; - return switch (_outcomeKey(outcome.keys.single)) { - 'advanced' => MfaAdvanced( - body is Map - ? (body['next_step'] ?? body['nextStep']) as int - : _unrecognized(result), - ), - 'completed' => MfaCompleted( - body is Map - ? (body['preshared_key'] ?? body['presharedKey']) as String? - : _unrecognized(result), - ), - 'awaitingexternal' => const MfaAwaitingExternal(), - _ => _unrecognized(result), - }; + Map toJson() => _$MfaFlowApproveRequestToJson(this); } diff --git a/client/lib/data/proxy/mfa.g.dart b/client/lib/data/proxy/mfa.g.dart index 0f8ffec..7ccb056 100644 --- a/client/lib/data/proxy/mfa.g.dart +++ b/client/lib/data/proxy/mfa.g.dart @@ -18,12 +18,6 @@ StartMfaRequest _$StartMfaRequestFromJson(Map json) => 'method', (v) => $enumDecode(_$MfaMethodEnumMap, v), ), - selectedMethods: $checkedConvert( - 'selected_methods', - (v) => (v as List) - .map((e) => $enumDecode(_$MfaMethodEnumMap, e)) - .toList(), - ), postureData: $checkedConvert( 'posture_data', (v) => v == null @@ -35,7 +29,6 @@ StartMfaRequest _$StartMfaRequestFromJson(Map json) => }, fieldKeyMap: const { 'locationId': 'location_id', - 'selectedMethods': 'selected_methods', 'postureData': 'posture_data', }, ); @@ -45,7 +38,6 @@ const _$StartMfaRequestFieldMap = { 'locationId': 'location_id', 'method': 'method', 'postureData': 'posture_data', - 'selectedMethods': 'selected_methods', }; Map _$StartMfaRequestToJson(StartMfaRequest instance) => @@ -54,9 +46,6 @@ Map _$StartMfaRequestToJson(StartMfaRequest instance) => 'location_id': instance.locationId, 'method': _$MfaMethodEnumMap[instance.method]!, 'posture_data': instance.postureData, - 'selected_methods': instance.selectedMethods - .map((e) => _$MfaMethodEnumMap[e]!) - .toList(), }; const _$MfaMethodEnumMap = { @@ -72,80 +61,32 @@ StartMfaResponse _$StartMfaResponseFromJson(Map json) => final val = StartMfaResponse( token: $checkedConvert('token', (v) => v as String), challenge: $checkedConvert('challenge', (v) => v as String?), - rejections: $checkedConvert( - 'rejections', - (v) => - (v as List?) - ?.map( - (e) => MfaStepRejection.fromJson(e as Map), - ) - .toList() ?? - [], - ), - credentialIds: $checkedConvert( - 'credential_ids', - (v) => (v as List?)?.map((e) => e as String).toList() ?? [], - ), ); return val; - }, fieldKeyMap: const {'credentialIds': 'credential_ids'}); + }); const _$StartMfaResponseFieldMap = { 'token': 'token', 'challenge': 'challenge', - 'rejections': 'rejections', - 'credentialIds': 'credential_ids', }; Map _$StartMfaResponseToJson(StartMfaResponse instance) => - { - 'token': instance.token, - 'challenge': instance.challenge, - 'rejections': instance.rejections, - 'credential_ids': instance.credentialIds, - }; + {'token': instance.token, 'challenge': instance.challenge}; FinishMfaRequest _$FinishMfaRequestFromJson(Map json) => - $checkedCreate( - 'FinishMfaRequest', - json, - ($checkedConvert) { - final val = FinishMfaRequest( - token: $checkedConvert('token', (v) => v as String), - code: $checkedConvert('code', (v) => v as String?), - authPubKey: $checkedConvert('auth_pub_key', (v) => v as String?), - stepAttemptId: $checkedConvert( - 'step_attempt_id', - (v) => v as String?, - ), - authData: $checkedConvert( - 'auth_data', - (v) => - (v as List?)?.map((e) => (e as num).toInt()).toList(), - ), - credentialId: $checkedConvert( - 'credential_id', - (v) => - (v as List?)?.map((e) => (e as num).toInt()).toList(), - ), - ); - return val; - }, - fieldKeyMap: const { - 'authPubKey': 'auth_pub_key', - 'stepAttemptId': 'step_attempt_id', - 'authData': 'auth_data', - 'credentialId': 'credential_id', - }, - ); + $checkedCreate('FinishMfaRequest', json, ($checkedConvert) { + final val = FinishMfaRequest( + token: $checkedConvert('token', (v) => v as String), + code: $checkedConvert('code', (v) => v as String?), + authPubKey: $checkedConvert('auth_pub_key', (v) => v as String?), + ); + return val; + }, fieldKeyMap: const {'authPubKey': 'auth_pub_key'}); const _$FinishMfaRequestFieldMap = { 'token': 'token', 'code': 'code', 'authPubKey': 'auth_pub_key', - 'stepAttemptId': 'step_attempt_id', - 'authData': 'auth_data', - 'credentialId': 'credential_id', }; Map _$FinishMfaRequestToJson(FinishMfaRequest instance) => @@ -153,11 +94,23 @@ Map _$FinishMfaRequestToJson(FinishMfaRequest instance) => 'token': instance.token, 'code': instance.code, 'auth_pub_key': instance.authPubKey, - 'step_attempt_id': ?instance.stepAttemptId, - 'auth_data': ?instance.authData, - 'credential_id': ?instance.credentialId, }; +FinishMfaResponse _$FinishMfaResponseFromJson(Map json) => + $checkedCreate('FinishMfaResponse', json, ($checkedConvert) { + final val = FinishMfaResponse( + presharedKey: $checkedConvert('preshared_key', (v) => v as String?), + ); + return val; + }, fieldKeyMap: const {'presharedKey': 'preshared_key'}); + +const _$FinishMfaResponseFieldMap = { + 'presharedKey': 'preshared_key', +}; + +Map _$FinishMfaResponseToJson(FinishMfaResponse instance) => + {'preshared_key': instance.presharedKey}; + SecureInstanceStorage _$SecureInstanceStorageFromJson( Map json, ) => $checkedCreate('SecureInstanceStorage', json, ($checkedConvert) { @@ -180,119 +133,52 @@ Map _$SecureInstanceStorageToJson( 'public_key': instance.publicKey, }; -RemoteMfaQr _$RemoteMfaQrFromJson(Map json) => - $checkedCreate('RemoteMfaQr', json, ($checkedConvert) { - final val = RemoteMfaQr( - instanceId: $checkedConvert('instance_id', (v) => v as String), - token: $checkedConvert('token', (v) => v as String), - challenge: $checkedConvert('challenge', (v) => v as String), - ); - return val; - }, fieldKeyMap: const {'instanceId': 'instance_id'}); - -const _$RemoteMfaQrFieldMap = { - 'instanceId': 'instance_id', - 'token': 'token', - 'challenge': 'challenge', -}; - -Map _$RemoteMfaQrToJson(RemoteMfaQr instance) => - { - 'instance_id': instance.instanceId, - 'token': instance.token, - 'challenge': instance.challenge, - }; - -MfaStepRejection _$MfaStepRejectionFromJson(Map json) => - $checkedCreate('MfaStepRejection', json, ($checkedConvert) { - final val = MfaStepRejection( - step: $checkedConvert('step', (v) => (v as num).toInt()), - reason: $checkedConvert( - 'reason', - (v) => $enumDecode( - _$MfaStartRejectionReasonEnumMap, - v, - unknownValue: MfaStartRejectionReason.unspecified, - ), - ), - ); - return val; - }); - -const _$MfaStepRejectionFieldMap = { - 'step': 'step', - 'reason': 'reason', -}; - -Map _$MfaStepRejectionToJson(MfaStepRejection instance) => - { - 'step': instance.step, - 'reason': _$MfaStartRejectionReasonEnumMap[instance.reason]!, - }; - -const _$MfaStartRejectionReasonEnumMap = { - MfaStartRejectionReason.unspecified: 0, - MfaStartRejectionReason.methodNotInStep: 1, - MfaStartRejectionReason.stepEmptyAfterLicense: 2, - MfaStartRejectionReason.stepUnavailable: 3, -}; - -StepStartMfaRequest _$StepStartMfaRequestFromJson(Map json) => - $checkedCreate('StepStartMfaRequest', json, ($checkedConvert) { - final val = StepStartMfaRequest( - token: $checkedConvert('token', (v) => v as String), - method: $checkedConvert( - 'method', - (v) => $enumDecode(_$MfaMethodEnumMap, v), - ), - ); - return val; - }); +MfaMobileApprovalProof _$MfaMobileApprovalProofFromJson( + Map json, +) => $checkedCreate('MfaMobileApprovalProof', json, ($checkedConvert) { + final val = MfaMobileApprovalProof( + signature: $checkedConvert('signature', (v) => v as String), + authPubKey: $checkedConvert('auth_pub_key', (v) => v as String), + ); + return val; +}, fieldKeyMap: const {'authPubKey': 'auth_pub_key'}); -const _$StepStartMfaRequestFieldMap = { - 'token': 'token', - 'method': 'method', +const _$MfaMobileApprovalProofFieldMap = { + 'signature': 'signature', + 'authPubKey': 'auth_pub_key', }; -Map _$StepStartMfaRequestToJson( - StepStartMfaRequest instance, +Map _$MfaMobileApprovalProofToJson( + MfaMobileApprovalProof instance, ) => { - 'token': instance.token, - 'method': _$MfaMethodEnumMap[instance.method]!, + 'signature': instance.signature, + 'auth_pub_key': instance.authPubKey, }; -StepStartMfaResponse _$StepStartMfaResponseFromJson( +MfaFlowApproveRequest _$MfaFlowApproveRequestFromJson( Map json, -) => $checkedCreate( - 'StepStartMfaResponse', - json, - ($checkedConvert) { - final val = StepStartMfaResponse( - stepAttemptId: $checkedConvert('step_attempt_id', (v) => v as String), - challenge: $checkedConvert('challenge', (v) => v as String?), - credentialIds: $checkedConvert( - 'credential_ids', - (v) => (v as List?)?.map((e) => e as String).toList() ?? [], - ), - ); - return val; - }, - fieldKeyMap: const { - 'stepAttemptId': 'step_attempt_id', - 'credentialIds': 'credential_ids', - }, -); +) => $checkedCreate('MfaFlowApproveRequest', json, ($checkedConvert) { + final val = MfaFlowApproveRequest( + token: $checkedConvert('token', (v) => v as String), + stepAttemptId: $checkedConvert('step_attempt_id', (v) => v as String), + proof: $checkedConvert( + 'proof', + (v) => MfaMobileApprovalProof.fromJson(v as Map), + ), + ); + return val; +}, fieldKeyMap: const {'stepAttemptId': 'step_attempt_id'}); -const _$StepStartMfaResponseFieldMap = { +const _$MfaFlowApproveRequestFieldMap = { + 'token': 'token', 'stepAttemptId': 'step_attempt_id', - 'challenge': 'challenge', - 'credentialIds': 'credential_ids', + 'proof': 'proof', }; -Map _$StepStartMfaResponseToJson( - StepStartMfaResponse instance, +Map _$MfaFlowApproveRequestToJson( + MfaFlowApproveRequest instance, ) => { + 'token': instance.token, 'step_attempt_id': instance.stepAttemptId, - 'challenge': instance.challenge, - 'credential_ids': instance.credentialIds, + 'proof': instance.proof, }; diff --git a/client/lib/data/proxy/mfa_flow.dart b/client/lib/data/proxy/mfa_flow.dart new file mode 100644 index 0000000..e4951a4 --- /dev/null +++ b/client/lib/data/proxy/mfa_flow.dart @@ -0,0 +1,365 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:mobile/data/db/enums.dart'; +import 'package:mobile/enterprise/postures.dart'; + +part 'mfa_flow.g.dart'; + +@JsonSerializable(createFactory: false) +class MfaFlowStartRequest { + final int locationId; + final String pubkey; + final DevicePostureData? postureData; + final List selectedMethods; + + const MfaFlowStartRequest({ + required this.locationId, + required this.pubkey, + this.postureData, + required this.selectedMethods, + }); + + Map toJson() => _$MfaFlowStartRequestToJson(this); +} + +sealed class MfaFlowStartOutcome { + const MfaFlowStartOutcome(); +} + +final class MfaFlowAccepted extends MfaFlowStartOutcome { + final String token; + final MfaFlowStepStarted firstStep; + + const MfaFlowAccepted({required this.token, required this.firstStep}); + + factory MfaFlowAccepted.fromJson(Map json) { + final token = json['token']; + final firstStep = _object(json['first_step']); + if (token is! String || firstStep == null) { + throw const FormatException('Invalid MFA flow start response'); + } + return MfaFlowAccepted( + token: token, + firstStep: MfaFlowStepStarted.fromJson(firstStep), + ); + } +} + +final class MfaFlowRejected extends MfaFlowStartOutcome { + final List rejections; + + const MfaFlowRejected(this.rejections); + + factory MfaFlowRejected.fromJson(Map json) { + final values = json['rejections']; + if (values == null) return const MfaFlowRejected([]); + if (values is! List) { + throw const FormatException('Invalid MFA flow start response'); + } + return MfaFlowRejected( + values + .map((value) { + final rejection = _object(value); + if (rejection == null) { + throw const FormatException('Invalid MFA flow start response'); + } + return MfaStepRejection.fromJson(rejection); + }) + .toList(growable: false), + ); + } +} + +final class MfaFlowStartUnknown extends MfaFlowStartOutcome { + const MfaFlowStartUnknown(); +} + +class MfaFlowStartResponse { + final MfaFlowStartOutcome outcome; + + const MfaFlowStartResponse(this.outcome); + + factory MfaFlowStartResponse.fromJson(Map json) => + switch (_variant(json['outcome'])) { + ('Accepted', final body) => MfaFlowStartResponse( + MfaFlowAccepted.fromJson(body), + ), + ('Rejected', final body) => MfaFlowStartResponse( + MfaFlowRejected.fromJson(body), + ), + _ => const MfaFlowStartResponse(MfaFlowStartUnknown()), + }; +} + +@JsonSerializable(createFactory: false) +class MfaFlowStepStartRequest { + final String token; + final MfaMethod method; + + const MfaFlowStepStartRequest({required this.token, required this.method}); + + Map toJson() => _$MfaFlowStepStartRequestToJson(this); +} + +sealed class MfaFlowChallenge { + const MfaFlowChallenge(); +} + +final class MfaSignatureChallenge extends MfaFlowChallenge { + final String challenge; + + const MfaSignatureChallenge(this.challenge); +} + +final class MfaFido2Challenge extends MfaFlowChallenge { + final String challenge; + final List credentialIds; + + const MfaFido2Challenge({ + required this.challenge, + required this.credentialIds, + }); +} + +final class MfaUnknownChallenge extends MfaFlowChallenge { + const MfaUnknownChallenge(); +} + +class MfaFlowStepStarted { + final String stepAttemptId; + final MfaFlowChallenge? challenge; + + const MfaFlowStepStarted({required this.stepAttemptId, this.challenge}); + + factory MfaFlowStepStarted.fromJson(Map json) { + final attemptId = json['step_attempt_id']; + if (attemptId is! String || attemptId.isEmpty) { + throw const FormatException('Invalid MFA flow step response'); + } + return MfaFlowStepStarted( + stepAttemptId: attemptId, + challenge: _parseChallenge(json['challenge']), + ); + } +} + +MfaFlowChallenge? _parseChallenge(Object? value) { + if (value == null) return null; + return switch (_variant(value)) { + ('Signature', final body) => + body['challenge'] is String + ? MfaSignatureChallenge(body['challenge'] as String) + : const MfaUnknownChallenge(), + ('Fido2', final body) => _parseFido2Challenge(body), + _ => const MfaUnknownChallenge(), + }; +} + +MfaFlowChallenge _parseFido2Challenge(Map json) { + final challenge = json['challenge']; + final credentialIds = json['credential_ids']; + if (challenge is! String || + credentialIds is! List || + credentialIds.any((id) => id is! String)) { + return const MfaUnknownChallenge(); + } + return MfaFido2Challenge( + challenge: challenge, + credentialIds: credentialIds.cast(), + ); +} + +class MfaFlowStepStartResponse { + final MfaFlowStepStarted? started; + + const MfaFlowStepStartResponse({this.started}); + + factory MfaFlowStepStartResponse.fromJson(Map json) { + final started = _object(json['started']); + return MfaFlowStepStartResponse( + started: started == null ? null : MfaFlowStepStarted.fromJson(started), + ); + } +} + +sealed class MfaFlowSubmission { + const MfaFlowSubmission(); + + Map toJson(); +} + +final class MfaFlowCodeSubmission extends MfaFlowSubmission { + final String code; + + const MfaFlowCodeSubmission(this.code); + + @override + Map toJson() => { + 'Code': {'code': code}, + }; +} + +final class MfaFlowBiometricSubmission extends MfaFlowSubmission { + final String signature; + + const MfaFlowBiometricSubmission(this.signature); + + @override + Map toJson() => { + 'Biometric': {'signature': signature}, + }; +} + +final class MfaFlowFido2Submission extends MfaFlowSubmission { + final List rpIdHash; + final List authenticatorData; + final List signature; + final List credentialId; + + const MfaFlowFido2Submission({ + required this.rpIdHash, + required this.authenticatorData, + required this.signature, + required this.credentialId, + }); + + @override + Map toJson() => { + 'Fido2': { + 'rp_id_hash': rpIdHash, + 'authenticator_data': authenticatorData, + 'signature': signature, + 'credential_id': credentialId, + }, + }; +} + +class MfaFlowStepFinishRequest { + final String token; + final String stepAttemptId; + final MfaFlowSubmission? submission; + + const MfaFlowStepFinishRequest({ + required this.token, + required this.stepAttemptId, + this.submission, + }); + + Map toJson() => { + 'token': token, + 'step_attempt_id': stepAttemptId, + 'submission': submission?.toJson(), + }; +} + +sealed class MfaFlowResult { + const MfaFlowResult(); +} + +final class MfaFlowAdvanced extends MfaFlowResult { + final int nextStep; + + const MfaFlowAdvanced(this.nextStep); +} + +final class MfaFlowCompleted extends MfaFlowResult { + final String? presharedKey; + + const MfaFlowCompleted(this.presharedKey); +} + +final class MfaFlowAwaitingExternal extends MfaFlowResult { + const MfaFlowAwaitingExternal(); +} + +final class MfaFlowResultUnknown extends MfaFlowResult { + const MfaFlowResultUnknown(); +} + +class MfaFlowStepFinishResponse { + final MfaFlowResult? result; + + const MfaFlowStepFinishResponse({this.result}); + + factory MfaFlowStepFinishResponse.fromJson(Map json) { + final result = _object(json['result']); + if (result == null) return const MfaFlowStepFinishResponse(); + + final parsed = switch (_variant(result['outcome'])) { + ('Advanced', final body) => + body['next_step'] is int + ? MfaFlowAdvanced(body['next_step'] as int) + : const MfaFlowResultUnknown(), + ('Completed', final body) => + body['preshared_key'] == null || body['preshared_key'] is String + ? MfaFlowCompleted(body['preshared_key'] as String?) + : const MfaFlowResultUnknown(), + ('AwaitingExternal', _) => const MfaFlowAwaitingExternal(), + _ => const MfaFlowResultUnknown(), + }; + return MfaFlowStepFinishResponse(result: parsed); + } +} + +enum MfaStartRejectionReason { + unspecified(0), + methodNotInStep(1), + stepEmptyAfterLicense(2), + stepUnavailable(3); + + final int wireValue; + + const MfaStartRejectionReason(this.wireValue); + + /// A newer server may send a reason this client does not know yet. + static MfaStartRejectionReason fromWire(int value) => values.firstWhere( + (reason) => reason.wireValue == value, + orElse: () => unspecified, + ); +} + +class MfaStepRejection { + final int step; + final MfaStartRejectionReason reason; + + const MfaStepRejection({required this.step, required this.reason}); + + factory MfaStepRejection.fromJson(Map json) { + final step = json['step']; + final reason = json['reason']; + if (step is! int || reason is! int) { + throw const FormatException('Invalid MFA flow start response'); + } + return MfaStepRejection( + step: step, + reason: MfaStartRejectionReason.fromWire(reason), + ); + } + + /// Step numbers are zero-based on the wire and one-based for people. + String get message => switch (reason) { + MfaStartRejectionReason.methodNotInStep => + "The method chosen for verification step ${step + 1} is not allowed. " + "The location's MFA settings have changed, so pick a method again.", + MfaStartRejectionReason.stepEmptyAfterLicense => + "Verification step ${step + 1} has no method available on this server. " + "Contact your administrator.", + MfaStartRejectionReason.stepUnavailable => + "The method chosen for verification step ${step + 1} cannot be used. " + "Set it up first, or pick a different one.", + MfaStartRejectionReason.unspecified => + "The server rejected verification step ${step + 1}.", + }; +} + +Map? _object(Object? value) => + value is Map ? Map.from(value) : null; + +/// Splits a serde externally tagged oneof into its tag and body, or null when +/// the value has another shape. +(String, Map)? _variant(Object? value) { + final tagged = _object(value); + if (tagged == null || tagged.length != 1) return null; + final body = _object(tagged.values.single); + if (body == null) return null; + return (tagged.keys.single, body); +} diff --git a/client/lib/data/proxy/mfa_flow.g.dart b/client/lib/data/proxy/mfa_flow.g.dart new file mode 100644 index 0000000..2cb7173 --- /dev/null +++ b/client/lib/data/proxy/mfa_flow.g.dart @@ -0,0 +1,45 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'mfa_flow.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +const _$MfaFlowStartRequestFieldMap = { + 'locationId': 'location_id', + 'pubkey': 'pubkey', + 'postureData': 'posture_data', + 'selectedMethods': 'selected_methods', +}; + +Map _$MfaFlowStartRequestToJson( + MfaFlowStartRequest instance, +) => { + 'location_id': instance.locationId, + 'pubkey': instance.pubkey, + 'posture_data': instance.postureData, + 'selected_methods': instance.selectedMethods + .map((e) => _$MfaMethodEnumMap[e]!) + .toList(), +}; + +const _$MfaMethodEnumMap = { + MfaMethod.totp: 0, + MfaMethod.email: 1, + MfaMethod.openid: 2, + MfaMethod.biometric: 3, + MfaMethod.fido2: 5, +}; + +const _$MfaFlowStepStartRequestFieldMap = { + 'token': 'token', + 'method': 'method', +}; + +Map _$MfaFlowStepStartRequestToJson( + MfaFlowStepStartRequest instance, +) => { + 'token': instance.token, + 'method': _$MfaMethodEnumMap[instance.method]!, +}; diff --git a/client/lib/enterprise/screens/mfa/openid_mfa_screen.dart b/client/lib/enterprise/screens/mfa/openid_mfa_screen.dart index 4b7678a..b1ee049 100644 --- a/client/lib/enterprise/screens/mfa/openid_mfa_screen.dart +++ b/client/lib/enterprise/screens/mfa/openid_mfa_screen.dart @@ -9,6 +9,25 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:mobile/logging.dart'; import 'openid_mfa_waiting_screen.dart'; +Uri buildOpenIdMfaUri({ + required String proxyUrl, + required String token, + String? stepAttemptId, +}) { + final base = Uri.parse(proxyUrl); + return base.replace( + pathSegments: [ + ...base.pathSegments.where((segment) => segment.isNotEmpty), + 'openid', + 'mfa', + ], + queryParameters: { + 'token': token, + 'step_attempt_id': ?stepAttemptId, + }, + ); +} + class OpenIdMfaScreen extends HookConsumerWidget { final MfaStepHost host; final String proxyUrl; @@ -24,10 +43,14 @@ class OpenIdMfaScreen extends HookConsumerWidget { }); Future _launchUrl() async { - final url = Uri.parse( - "${proxyUrl}openid/mfa?token=${host.controller.token}", + final token = host.controller.token; + if (token == null) throw StateError('MFA session has not started'); + final url = buildOpenIdMfaUri( + proxyUrl: proxyUrl, + token: token, + stepAttemptId: host.controller.stepAttemptId, ); - return await launchUrl(url, mode: LaunchMode.externalApplication); + return launchUrl(url, mode: LaunchMode.externalApplication); } @override @@ -56,7 +79,7 @@ class OpenIdMfaScreen extends HookConsumerWidget { ), ); } catch (e) { - talker.error("Failed to open browser! Reason: $e"); + talker.error('Failed to open MFA browser'); toaster.showError(message: "Failed to open the browser."); } }, diff --git a/client/lib/enterprise/screens/mfa/openid_mfa_waiting_screen.dart b/client/lib/enterprise/screens/mfa/openid_mfa_waiting_screen.dart index 2e362ff..3a8fe63 100644 --- a/client/lib/enterprise/screens/mfa/openid_mfa_waiting_screen.dart +++ b/client/lib/enterprise/screens/mfa/openid_mfa_waiting_screen.dart @@ -1,8 +1,8 @@ -import 'package:dio/dio.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:material_ui/material_ui.dart'; import 'package:mobile/data/mfa/mfa_flow.dart'; +import 'package:mobile/open/api.dart'; import 'package:mobile/open/screens/mfa/mfa_step_chrome.dart'; import 'package:mobile/open/widgets/icons/dg_icon.dart'; import 'package:mobile/open/widgets/dg_app_bar.dart'; @@ -21,9 +21,8 @@ class OpenIdMfaWaitingScreen extends HookConsumerWidget { const OpenIdMfaWaitingScreen({super.key, required this.host}); - /// Polls until the browser hop resolves. An unresolved factor comes back - /// either as an `awaitingExternal` outcome or, from a pre-2.2 proxy, as a 428 - /// that the api layer normalizes into the same thing. + /// Polls until the browser hop resolves. Legacy reports pending as 428, the + /// flow API as an awaiting external result. Future _pollOpenidMfa(bool Function() isCancelled) async { final startTime = DateTime.now(); const timeoutDuration = Duration(minutes: 2); @@ -46,15 +45,9 @@ class OpenIdMfaWaitingScreen extends HookConsumerWidget { } talker.debug("User did not complete openid browser login, waiting"); await Future.delayed(const Duration(seconds: 2)); - } on DioException catch (e) { - final isNetworkError = - e.type == DioExceptionType.connectionError || - e.type == DioExceptionType.connectionTimeout || - (e.error?.toString().contains("-1005") ?? false) || - (e.message?.contains("-1005") ?? false); - - if (!isNetworkError) rethrow; - talker.warning("Network error during MFA polling, retrying: $e"); + } on MfaRequestException catch (e) { + if (!e.isNetworkError) rethrow; + talker.warning('Network error during MFA polling, retrying'); await Future.delayed(const Duration(seconds: 2)); } } diff --git a/client/lib/open/api.dart b/client/lib/open/api.dart index 5f447e9..fb3db3b 100644 --- a/client/lib/open/api.dart +++ b/client/lib/open/api.dart @@ -9,6 +9,7 @@ import 'package:mobile/data/db/enums.dart'; import 'package:mobile/data/proxy/config.dart'; import 'package:mobile/data/proxy/enrollment.dart'; import 'package:mobile/data/proxy/mfa.dart'; +import 'package:mobile/data/proxy/mfa_flow.dart' as wire; import 'package:mobile/enterprise/postures.dart'; import 'package:mobile/open/client_headers_interceptor.dart'; import 'package:native_dio_adapter/native_dio_adapter.dart'; @@ -19,8 +20,15 @@ import '../logging.dart'; const _apiV1Segments = ['api', 'v1']; final enrollmentPathSegments = ['api', 'v1', 'enrollment']; final mfaPathSegments = ['api', 'v1', 'client-mfa']; +final mfaFlowPathSegments = [..._apiV1Segments, 'mfa-flow']; final posturePathSegments = ['api', 'v1', 'posture']; +const _iosConnectionLostCode = '-1005'; + +bool _isMfaEndpoint(Uri uri) => + uri.pathSegments.contains(mfaPathSegments.last) || + uri.pathSegments.contains(mfaFlowPathSegments.last); + class PostureCheckException implements Exception { final String message; @@ -36,11 +44,57 @@ class MfaMethodNotAvailableException implements Exception { const MfaMethodNotAvailableException(this.method); @override - String toString() { - return "Requested Mfa method not available on the account - ${method.toReadableString()}"; - } + String toString() => + 'Requested MFA method is not available on the account: ${method.toReadableString()}'; } +enum MfaRequestFailure { network, policyDenied, attemptLimit, notReady, server } + +class MfaRequestException implements Exception { + final String operation; + final int? statusCode; + final MfaRequestFailure failure; + + const MfaRequestException( + this.operation, { + this.statusCode, + this.failure = MfaRequestFailure.server, + }); + + bool get isNetworkError => failure == MfaRequestFailure.network; + + @override + String toString() => statusCode == null + ? '$operation failed' + : '$operation failed. Status: $statusCode'; +} + +MfaRequestException _mfaRequestException( + String operation, + DioException error, { + Map statusFailures = const {}, +}) { + final statusCode = error.response?.statusCode; + final networkError = + error.response == null && + (error.type == DioExceptionType.connectionError || + error.type == DioExceptionType.connectionTimeout || + (error.error?.toString().contains(_iosConnectionLostCode) ?? false) || + (error.message?.contains(_iosConnectionLostCode) ?? false)); + return MfaRequestException( + operation, + statusCode: statusCode, + failure: networkError + ? MfaRequestFailure.network + : statusFailures[statusCode] ?? MfaRequestFailure.server, + ); +} + +const _startFailures = { + 403: MfaRequestFailure.policyDenied, + 428: MfaRequestFailure.notReady, +}; + /// The only sanctioned path to the proxy. Every request through this Dio is /// held by [ClientHeadersInterceptor] until the client identity is known. @visibleForTesting @@ -65,20 +119,27 @@ Dio buildProxyDio({ printRequestHeaders: !kReleaseMode, printResponseData: !kReleaseMode, printErrorData: !kReleaseMode, + requestFilter: (request) => !_isMfaEndpoint(request.uri), + responseFilter: (response) => + !_isMfaEndpoint(response.requestOptions.uri), + errorFilter: (error) => !_isMfaEndpoint(error.requestOptions.uri), ), ), ]); return dio; } -class _ProxyApi { - static final _ProxyApi _instance = _ProxyApi._internal(); +class ProxyApi { + static final ProxyApi _instance = ProxyApi._internal(); + + factory ProxyApi() => _instance; - factory _ProxyApi() => _instance; + final Dio _dio; - late final Dio _dio = buildProxyDio(identity: clientIdentity); + ProxyApi._internal() : _dio = buildProxyDio(identity: clientIdentity); - _ProxyApi._internal(); + @visibleForTesting + ProxyApi.forTesting(this._dio); Future<(ConfigurationPollResponse?, int?, Headers?)> pollConfiguration( String proxyUrl, @@ -105,8 +166,12 @@ class _ProxyApi { } final responseData = InstanceInfoResponse.fromJson(response.data); return (responseData.deviceConfig, status, response.headers); - } catch (e) { - talker.error("Failed to poll configuration!", e); + } on DioException catch (e) { + talker.error( + 'Failed to poll configuration. Status: ${e.response?.statusCode}', + ); + } catch (_) { + talker.error('Failed to parse configuration response'); } return (null, null, null); } @@ -172,43 +237,47 @@ class _ProxyApi { try { final response = await _dio.postUri(endpoint, data: data.toJson()); - final startResponse = StartMfaResponse.fromJson(response.data); - if (startResponse.rejections.isNotEmpty) { - throw MfaRejectedException(startResponse.rejections); - } - return startResponse; - } on MfaRejectedException { - rethrow; + return StartMfaResponse.fromJson(response.data); } on DioException catch (e) { - if (e.response != null) { - if (e.response!.data != null && - e.response!.data is Map) { - final responseData = e.response!.data!; - final dataError = responseData['error']; - final missingMFAMethodError = "selected MFA method not available" - .toLowerCase(); - if (dataError is String && - data.selectedMethods.length <= 1 && - dataError.toLowerCase().trim() == missingMFAMethodError) { - throw MfaMethodNotAvailableException(data.method); - } - - if (e.response?.statusCode == 403) { - final error = responseData['error'] ?? responseData['message']; - if (error is String) { - throw HttpException(error); - } - } - } - throw HttpException( - "Failed to start MFA. Status: ${e.response?.statusCode} Body: ${e.response?.data}", - ); + final responseData = e.response?.data; + final dataError = responseData is Map + ? responseData['error'] + : null; + if (dataError is String && + dataError.toLowerCase().trim() == + 'selected mfa method not available') { + throw MfaMethodNotAvailableException(data.method); } - rethrow; + throw _mfaRequestException( + 'MFA start', + e, + statusFailures: _startFailures, + ); } catch (e) { - throw FormatException( - "Invalid JSON sent by start MFA endpoint! Error: $e", + talker.error('Invalid MFA start response: ${e.runtimeType}'); + throw const FormatException('Invalid MFA start response'); + } + } + + Future startMfaFlow( + Uri url, + wire.MfaFlowStartRequest data, + ) async { + final endpoint = url.replace( + pathSegments: [...url.pathSegments, ...mfaFlowPathSegments, 'start'], + ); + try { + final response = await _dio.postUri(endpoint, data: data.toJson()); + return wire.MfaFlowStartResponse.fromJson(response.data); + } on DioException catch (e) { + throw _mfaRequestException( + 'MFA flow start', + e, + statusFailures: _startFailures, ); + } catch (e) { + talker.error('Invalid MFA flow start response: ${e.runtimeType}'); + throw const FormatException('Invalid MFA flow start response'); } } @@ -245,33 +314,32 @@ class _ProxyApi { } } - Future stepStartMfa( + Future startMfaFlowStep( Uri url, - StepStartMfaRequest data, + wire.MfaFlowStepStartRequest data, ) async { final endpoint = url.replace( - pathSegments: [...url.pathSegments, ...mfaPathSegments, 'step-start'], + pathSegments: [...url.pathSegments, ...mfaFlowPathSegments, 'step-start'], ); - try { final response = await _dio.postUri(endpoint, data: data.toJson()); - return StepStartMfaResponse.fromJson(response.data); + return wire.MfaFlowStepStartResponse.fromJson(response.data); } on DioException catch (e) { - if (e.response != null) { - throw HttpException( - "Failed to start MFA step. Status: ${e.response?.statusCode} " - "Body: ${e.response?.data}", - ); - } - rethrow; - } catch (e) { - throw FormatException( - "Invalid JSON sent by MFA step start endpoint! Error: $e", + throw _mfaRequestException( + 'MFA flow step start', + e, + statusFailures: const {428: MfaRequestFailure.notReady}, ); + } catch (e) { + talker.error('Invalid MFA flow step start response: ${e.runtimeType}'); + throw const FormatException('Invalid MFA flow step start response'); } } - Future finishMfa(Uri url, FinishMfaRequest data) async { + Future finishMfa( + Uri url, + FinishMfaRequest data, + ) async { final endpoint = url.replace( pathSegments: [...url.pathSegments, ...mfaPathSegments, 'finish'], ); @@ -284,15 +352,45 @@ class _ProxyApi { status != null && (status < 400 || status == 428), ), ); - if (response.statusCode == 428) { - return const FinishMfaResponse(outcome: MfaAwaitingExternal()); - } + if (response.statusCode == 428) return null; return FinishMfaResponse.fromJson(response.data); } on DioException catch (e) { if (e.response?.statusCode == 401) { throw const MfaCodeRejectedException(); } - rethrow; + throw _mfaRequestException('MFA finish', e); + } catch (e) { + talker.error('Invalid MFA finish response: ${e.runtimeType}'); + throw const FormatException('Invalid MFA finish response'); + } + } + + Future finishMfaFlow( + Uri url, + wire.MfaFlowStepFinishRequest data, + ) async { + final endpoint = url.replace( + pathSegments: [ + ...url.pathSegments, + ...mfaFlowPathSegments, + 'step-finish', + ], + ); + try { + final response = await _dio.postUri(endpoint, data: data.toJson()); + return wire.MfaFlowStepFinishResponse.fromJson(response.data); + } on DioException catch (e) { + if (e.response?.statusCode == 401) { + throw const MfaCodeRejectedException(); + } + throw _mfaRequestException( + 'MFA flow step finish', + e, + statusFailures: const {403: MfaRequestFailure.attemptLimit}, + ); + } catch (e) { + talker.error('Invalid MFA flow step finish response: ${e.runtimeType}'); + throw const FormatException('Invalid MFA flow step finish response'); } } @@ -300,7 +398,22 @@ class _ProxyApi { final endpoint = url.replace( pathSegments: [...url.pathSegments, ...mfaPathSegments, 'finish-remote'], ); - await _dio.postUri(endpoint, data: data.toJson()); + try { + await _dio.postUri(endpoint, data: data.toJson()); + } on DioException catch (e) { + throw _mfaRequestException('Remote MFA finish', e); + } + } + + Future approveMfaFlow(Uri url, MfaFlowApproveRequest data) async { + final endpoint = url.replace( + pathSegments: [...url.pathSegments, ...mfaFlowPathSegments, 'approve'], + ); + try { + await _dio.postUri(endpoint, data: data.toJson()); + } on DioException catch (e) { + throw _mfaRequestException('MFA flow approval', e); + } } Future networkInfo(Uri url, String pubKey) async { @@ -338,4 +451,4 @@ class _ProxyApi { } } -final proxyApi = _ProxyApi(); +final proxyApi = ProxyApi(); diff --git a/client/lib/open/screens/add_instance/screens/name_device_screen.dart b/client/lib/open/screens/add_instance/screens/name_device_screen.dart index 1ca2613..1e48dfb 100644 --- a/client/lib/open/screens/add_instance/screens/name_device_screen.dart +++ b/client/lib/open/screens/add_instance/screens/name_device_screen.dart @@ -84,6 +84,7 @@ class NameDeviceScreen extends HookConsumerWidget { openidProviderKind: drift.Value( createResponse.instance.openidProviderKind, ), + mfaContract: drift.Value(createResponse.instance.mfaContract), ), mode: drift.InsertMode.insertOrFail, ); diff --git a/client/lib/open/screens/instance/services/tunnel_service.dart b/client/lib/open/screens/instance/services/tunnel_service.dart index 8753761..517075b 100644 --- a/client/lib/open/screens/instance/services/tunnel_service.dart +++ b/client/lib/open/screens/instance/services/tunnel_service.dart @@ -5,6 +5,7 @@ import 'package:drift/drift.dart' as drift; import 'package:material_ui/material_ui.dart'; import 'package:mobile/data/db/database.dart'; import 'package:mobile/data/mfa/mfa_flow.dart'; +import 'package:mobile/data/mfa/mfa_transport.dart'; import 'package:mobile/data/mfa/mfa_plan.dart'; import 'package:mobile/data/plugin/plugin.dart'; import 'package:mobile/enterprise/postures.dart'; @@ -13,6 +14,7 @@ import 'package:mobile/open/api.dart'; import 'package:mobile/open/riverpod/biometrics_state.dart'; import 'package:mobile/open/screens/mfa/mfa_step_flow.dart'; import 'package:mobile/utils/instance_secrets.dart'; +import 'package:mobile/utils/update_instance.dart'; import '../../../../data/db/enums.dart'; import '../../../../utils/notifications.dart'; @@ -82,15 +84,6 @@ class TunnelService { }) async { final navigator = Navigator.of(context); - // The instance policy outranks whatever the caller asked for - it is the - // administrator's setting, not a user preference. - final RoutingMethod selectedTrafficMethod = - switch (instance.clientTrafficPolicy) { - ClientTrafficPolicy.disableAllTraffic => RoutingMethod.predefined, - ClientTrafficPolicy.forceAllTraffic => RoutingMethod.all, - ClientTrafficPolicy.none => trafficMethod, - }; - final privateKey = await instance.wireguardPrivateKey(); if (privateKey == null) { reportMissingSecret(instance.logName, "WireGuard private key"); @@ -99,19 +92,21 @@ class TunnelService { PluginConnectPayload payload = _makePayload( instance, location, - selectedTrafficMethod, + trafficMethod, privateKey, ); - MfaMethod? authorizedWith; if (shouldStartMfa(location)) { - final biometricAvailable = - instance.mfaKeysStored && biometricsStatus.canOpenStorage; + final capabilities = MfaCapabilities( + biometricAvailable: + instance.mfaKeysStored && biometricsStatus.canOpenStorage, + contract: instance.mfaContract, + ); final resolved = resolveMfaStepPlan( location, oneOff: mfaPlan, - biometricAvailable: biometricAvailable, + capabilities: capabilities, ); if (resolved.isEmpty || resolved.contains(null)) { return const ConnectResult.failed( @@ -129,11 +124,41 @@ class TunnelService { final flow = MfaStepFlow( navigator: navigator, controller: MfaFlowController( - transport: ProxyMfaTransport(Uri.parse(instance.proxyUrl)), + transport: mfaTransportForContract( + instance.mfaContract, + Uri.parse(instance.proxyUrl), + ), plan: plan, devicePubkey: payload.devicePublicKey, networkId: payload.networkId, postureData: payload.postureCheckRequired ? await getPosture() : null, + refreshPlan: instance.mfaContract == MfaContract.multiStep + ? () async { + final retry = await _refreshMfaPlan( + db: db, + instance: instance, + location: location, + oneOff: mfaPlan, + capabilities: capabilities, + ); + if (retry == null) return null; + final refreshed = _makePayload( + retry.instance, + retry.location, + trafficMethod, + privateKey, + ); + if (!canReuseMfaAttempt(payload, refreshed)) { + talker.warning( + "Not retrying MFA for networkId ${payload.networkId}: " + "the refresh changed what the started attempt was bound to", + ); + return null; + } + payload = refreshed; + return retry.plan; + } + : null, ), proxyUrl: instance.proxyUrl, instanceUrl: instance.url, @@ -154,9 +179,10 @@ class TunnelService { case MfaFlowConnected(): payload.presharedKey = flow.controller.takePresharedKey(); } - // Only meaningful for a single-step flow; a multi-step one is described - // by its step count instead. - authorizedWith = plan.length == 1 ? plan.single : null; + // Only a single-step flow has one method to show. A multi-step one shows + // its step count instead. + final completedPlan = flow.controller.plan; + authorizedWith = completedPlan.length == 1 ? completedPlan.single : null; } else if (payload.postureCheckRequired) { final poolingToken = await instance.poolingToken(); if (poolingToken == null) { @@ -176,6 +202,8 @@ class TunnelService { await wireguardPlugin.startTunnel(jsonEncode(payload.toJson())); + // The original instance, not a refreshed one: its policy decided whether + // the connect sheet let the user pick [trafficMethod]. await _rememberPreferences( db, instance, @@ -238,6 +266,69 @@ class TunnelService { } } + /// The started attempt is bound to the device key, network and posture data, + /// so a retry may reuse it only when the refresh left those unchanged. + @visibleForTesting + static bool canReuseMfaAttempt( + PluginConnectPayload attempt, + PluginConnectPayload refreshed, + ) => + attempt.devicePublicKey == refreshed.devicePublicKey && + attempt.networkId == refreshed.networkId && + attempt.postureCheckRequired == refreshed.postureCheckRequired; + + static Future< + ({List plan, DefguardInstance instance, Location location})? + > + _refreshMfaPlan({ + required AppDatabase db, + required DefguardInstance instance, + required Location location, + required List oneOff, + required MfaCapabilities capabilities, + }) async { + final token = await instance.poolingToken(); + if (token == null) return null; + + final (config, _, _) = await proxyApi.pollConfiguration( + instance.proxyUrl, + token, + ); + if (config == null) return null; + + final update = await updateInstance( + db: db, + instance: instance, + configs: config.configs, + info: config.instance, + token: config.token, + ); + if (update == null) return null; + + final refreshedInstance = await db.managers.defguardInstances + .filter((row) => row.id.equals(instance.id)) + .getSingleOrNull(); + if (refreshedInstance == null) return null; + + final refreshedLocation = await db.managers.locations + .filter((row) => row.id.equals(location.id)) + .getSingleOrNull(); + if (refreshedLocation == null) return null; + + final plan = resolveMfaRetryPlan( + refreshedLocation, + attempt: capabilities, + refreshedContract: refreshedInstance.mfaContract, + oneOff: oneOff, + ); + if (plan == null) return null; + return ( + plan: plan, + instance: refreshedInstance, + location: refreshedLocation, + ); + } + /// Whether the location has an MFA flow to satisfy. Legacy single-mode /// locations are normalized into a one-step flow by `effectiveMfaSteps`. static bool checkMfaEnabled(Location location) => shouldStartMfa(location); @@ -308,6 +399,12 @@ class TunnelService { RoutingMethod trafficMethod, String privateKey, ) { + // The administrator's instance policy outranks the caller's choice. + final selectedTrafficMethod = switch (instance.clientTrafficPolicy) { + ClientTrafficPolicy.disableAllTraffic => RoutingMethod.predefined, + ClientTrafficPolicy.forceAllTraffic => RoutingMethod.all, + ClientTrafficPolicy.none => trafficMethod, + }; return PluginConnectPayload( publicKey: location.pubKey, devicePublicKey: instance.pubKey, @@ -321,7 +418,7 @@ class TunnelService { locationId: location.id, networkId: location.networkId, instanceId: instance.id, - traffic: trafficMethod, + traffic: selectedTrafficMethod, postureCheckRequired: location.postureCheckRequired == true, ); } diff --git a/client/lib/open/screens/instance/widgets/connect_dialog.dart b/client/lib/open/screens/instance/widgets/connect_dialog.dart index 6d25a17..753bda1 100644 --- a/client/lib/open/screens/instance/widgets/connect_dialog.dart +++ b/client/lib/open/screens/instance/widgets/connect_dialog.dart @@ -34,8 +34,11 @@ class ConnectDialog extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final biometricsStatus = ref.watch(biometricsCapabilityProvider); - final biometricAvailable = - instance.mfaKeysStored && biometricsStatus.canOpenStorage; + final capabilities = MfaCapabilities( + biometricAvailable: + instance.mfaKeysStored && biometricsStatus.canOpenStorage, + contract: instance.mfaContract, + ); final steps = effectiveMfaSteps(location); final bool canChangeTraffic = @@ -53,26 +56,29 @@ class ConnectDialog extends HookConsumerWidget { () => resolveMfaStepPlan( location.copyWith(mfaStepPlan: savedPlan.value), oneOff: selection.value == null ? const [] : [selection.value], - biometricAvailable: biometricAvailable, + capabilities: capabilities, ), - [location, savedPlan.value, selection.value, biometricAvailable], + [ + location, + savedPlan.value, + selection.value, + capabilities.biometricAvailable, + capabilities.contract, + ], ); final unpassable = plan.contains(null); final canEditDefaults = + capabilities.supportsMultipleSteps && steps.length > 1 && steps.any( (step) => - usableMfaMethods( - step, - biometricAvailable: biometricAvailable, - ).length > - 1, + usableMfaMethods(step, capabilities: capabilities).length > 1, ); List mfaDefaults(List saved) => resolveMfaStepPlan( location.copyWith(mfaStepPlan: saved), - biometricAvailable: biometricAvailable, + capabilities: capabilities, ); final showingMfa = useState(false); @@ -135,7 +141,7 @@ class ConnectDialog extends HookConsumerWidget { steps: steps, plan: plan, savedPlan: savedPlan.value, - biometricAvailable: biometricAvailable, + capabilities: capabilities, instance: instance, allTraffic: allTraffic.value, canChangeTraffic: canChangeTraffic, @@ -148,7 +154,7 @@ class ConnectDialog extends HookConsumerWidget { context, reason: unpassableStepReason( location, - biometricAvailable: biometricAvailable, + capabilities: capabilities, ), instanceId: instance.id, ) @@ -171,7 +177,7 @@ class ConnectDialog extends HookConsumerWidget { steps: steps, working: mfaWorking.value, savedPlan: savedPlan.value, - biometricAvailable: biometricAvailable, + capabilities: capabilities, instance: instance, isSaving: isSaving.value, onSelected: (index, method) { diff --git a/client/lib/open/screens/instance/widgets/connect_pane.dart b/client/lib/open/screens/instance/widgets/connect_pane.dart index 43b5ba4..ce47a55 100644 --- a/client/lib/open/screens/instance/widgets/connect_pane.dart +++ b/client/lib/open/screens/instance/widgets/connect_pane.dart @@ -21,7 +21,7 @@ class ConnectPane extends StatelessWidget { final List plan; final List savedPlan; - final bool biometricAvailable; + final MfaCapabilities capabilities; final DefguardInstance instance; final bool allTraffic; final bool canChangeTraffic; @@ -38,7 +38,7 @@ class ConnectPane extends StatelessWidget { required this.steps, required this.plan, required this.savedPlan, - required this.biometricAvailable, + required this.capabilities, required this.instance, required this.allTraffic, required this.canChangeTraffic, @@ -103,6 +103,7 @@ class ConnectPane extends StatelessWidget { _StepSummary( steps: steps, plan: plan, + capabilities: capabilities, instance: instance, ) else @@ -110,7 +111,7 @@ class ConnectPane extends StatelessWidget { step: steps.single, selected: plan.single, savedDefault: savedPlan.isEmpty ? null : savedPlan.first, - biometricAvailable: biometricAvailable, + capabilities: capabilities, instance: instance, onSelected: onMethodSelected, ), @@ -144,11 +145,13 @@ class ConnectPane extends StatelessWidget { class _StepSummary extends StatelessWidget { final List steps; final List plan; + final MfaCapabilities capabilities; final DefguardInstance instance; const _StepSummary({ required this.steps, required this.plan, + required this.capabilities, required this.instance, }); @@ -179,7 +182,7 @@ class _StepSummary extends StatelessWidget { } String _unusableLabel(MfaStep step) { - final entries = pickableMfaMethods(step); + final entries = pickableMfaMethods(step, capabilities: capabilities); if (entries.isEmpty) return "No method available"; return entries.first.method?.toUiString( openidDisplayName: instance.openidDisplayName, @@ -192,7 +195,7 @@ class _MethodPicker extends StatelessWidget { final MfaStep step; final MfaMethod? selected; final MfaMethod? savedDefault; - final bool biometricAvailable; + final MfaCapabilities capabilities; final DefguardInstance instance; final ValueChanged onSelected; @@ -200,7 +203,7 @@ class _MethodPicker extends StatelessWidget { required this.step, required this.selected, required this.savedDefault, - required this.biometricAvailable, + required this.capabilities, required this.instance, required this.onSelected, }); @@ -211,10 +214,13 @@ class _MethodPicker extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, spacing: DgSpacing.md, children: [ - for (final entry in pickableMfaMethods(step)) + for (final entry in pickableMfaMethods( + step, + capabilities: capabilities, + )) mfaMethodRow( entry: entry, - biometricAvailable: biometricAvailable, + capabilities: capabilities, instance: instance, selected: selected, savedDefault: savedDefault, diff --git a/client/lib/open/screens/instance/widgets/mfa_method_row.dart b/client/lib/open/screens/instance/widgets/mfa_method_row.dart index 176c54f..c66f5a8 100644 --- a/client/lib/open/screens/instance/widgets/mfa_method_row.dart +++ b/client/lib/open/screens/instance/widgets/mfa_method_row.dart @@ -15,7 +15,7 @@ String availabilityNote(MfaMethodAvailability availability) => Widget mfaMethodRow({ required MfaStepMethod entry, - required bool biometricAvailable, + required MfaCapabilities capabilities, required DefguardInstance instance, required MfaMethod? selected, required MfaMethod? savedDefault, @@ -23,7 +23,7 @@ Widget mfaMethodRow({ }) { final availability = mfaMethodAvailability( entry, - biometricAvailable: biometricAvailable, + capabilities: capabilities, ); final usable = availability == MfaMethodAvailability.usable; diff --git a/client/lib/open/screens/instance/widgets/mfa_settings_pane.dart b/client/lib/open/screens/instance/widgets/mfa_settings_pane.dart index d2f064a..90b42ac 100644 --- a/client/lib/open/screens/instance/widgets/mfa_settings_pane.dart +++ b/client/lib/open/screens/instance/widgets/mfa_settings_pane.dart @@ -16,7 +16,7 @@ class MfaSettingsPane extends StatelessWidget { final List working; final List savedPlan; - final bool biometricAvailable; + final MfaCapabilities capabilities; final DefguardInstance instance; final bool isSaving; final void Function(int step, MfaMethod method) onSelected; @@ -28,7 +28,7 @@ class MfaSettingsPane extends StatelessWidget { required this.steps, required this.working, required this.savedPlan, - required this.biometricAvailable, + required this.capabilities, required this.instance, required this.isSaving, required this.onSelected, @@ -82,7 +82,7 @@ class MfaSettingsPane extends StatelessWidget { savedDefault: index < savedPlan.length ? savedPlan[index] : null, - biometricAvailable: biometricAvailable, + capabilities: capabilities, instance: instance, onSelected: (method) => onSelected(index, method), ), @@ -106,7 +106,7 @@ class _StepSection extends StatelessWidget { final int number; final MfaMethod? selected; final MfaMethod? savedDefault; - final bool biometricAvailable; + final MfaCapabilities capabilities; final DefguardInstance instance; final ValueChanged onSelected; @@ -115,7 +115,7 @@ class _StepSection extends StatelessWidget { required this.number, required this.selected, required this.savedDefault, - required this.biometricAvailable, + required this.capabilities, required this.instance, required this.onSelected, }); @@ -130,10 +130,13 @@ class _StepSection extends StatelessWidget { "Step $number", style: DgText.bodyXs400.copyWith(color: DgColor.fgWhite60), ), - for (final entry in pickableMfaMethods(step)) + for (final entry in pickableMfaMethods( + step, + capabilities: capabilities, + )) mfaMethodRow( entry: entry, - biometricAvailable: biometricAvailable, + capabilities: capabilities, instance: instance, selected: selected, savedDefault: savedDefault, diff --git a/client/lib/open/screens/instance/widgets/refresh_instance_dialog.dart b/client/lib/open/screens/instance/widgets/refresh_instance_dialog.dart index 48395eb..415dc85 100644 --- a/client/lib/open/screens/instance/widgets/refresh_instance_dialog.dart +++ b/client/lib/open/screens/instance/widgets/refresh_instance_dialog.dart @@ -31,7 +31,7 @@ class RefreshInstanceDialog extends HookConsumerWidget { final url = proxyUrlController.text; final token = tokenController.text; final uri = Uri.parse(url); - talker.debug("Submitting instance refresh form ($url | $token)"); + talker.debug("Submitting instance refresh form"); try { // this is only for dio to capture cookies required for network info call diff --git a/client/lib/open/screens/mfa/mfa_biometric_screen.dart b/client/lib/open/screens/mfa/mfa_biometric_screen.dart index 77f71f7..5346a93 100644 --- a/client/lib/open/screens/mfa/mfa_biometric_screen.dart +++ b/client/lib/open/screens/mfa/mfa_biometric_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:material_ui/material_ui.dart'; import 'package:mobile/data/mfa/mfa_flow.dart'; +import 'package:mobile/data/mfa/mfa_transport.dart'; import 'package:mobile/data/proxy/mfa.dart'; import 'package:mobile/open/screens/mfa/mfa_step_chrome.dart'; import 'package:mobile/open/widgets/icons/dg_icon.dart'; @@ -73,13 +74,19 @@ class MfaBiometricScreen extends HookConsumerWidget { try { final signed = signChallenge(challenge, storage.privateKey); - final progress = await host.controller.submit(code: signed); + final progress = await host.controller.submit( + credential: MfaBiometricCredential( + signature: signed, + authPubKey: storage.publicKey, + ), + ); if (progress is MfaStepAwaiting) { throw StateError("biometric step returned an out-of-band outcome"); } host.reportProgress(progress); return; } catch (e) { + if (reportIfAttemptLimit(host, e)) return; toaster.showError( message: "Verification failed. Please try again.", logMessage: "Biometric MFA challenge submit failed!", diff --git a/client/lib/open/screens/mfa/mfa_code_screen.dart b/client/lib/open/screens/mfa/mfa_code_screen.dart index 4112ae7..7d82ec9 100644 --- a/client/lib/open/screens/mfa/mfa_code_screen.dart +++ b/client/lib/open/screens/mfa/mfa_code_screen.dart @@ -1,6 +1,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:material_ui/material_ui.dart'; import 'package:mobile/data/mfa/mfa_flow.dart'; +import 'package:mobile/data/mfa/mfa_transport.dart'; import 'package:mobile/data/proxy/mfa.dart'; import 'package:mobile/open/screens/mfa/mfa_step_chrome.dart'; import 'package:mobile/open/widgets/dg_code_entry_layout.dart'; @@ -38,7 +39,9 @@ class MfaCodeScreen extends ConsumerWidget { onBack: host.abort, onSubmit: (code, setError) async { try { - final progress = await host.controller.submit(code: code); + final progress = await host.controller.submit( + credential: MfaCodeCredential(code), + ); if (progress is MfaStepAwaiting) { toaster.showError( message: "Unexpected verification state. Please try again.", @@ -51,6 +54,7 @@ class MfaCodeScreen extends ConsumerWidget { } on MfaCodeRejectedException { setError('Enter valid code'); } catch (e) { + if (reportIfAttemptLimit(host, e)) return; toaster.showError( message: ErrorHandler.getHumanReadableError(e), logMessage: "$logLabel MFA code submit failed!", diff --git a/client/lib/open/screens/mfa/mfa_fido2_screen.dart b/client/lib/open/screens/mfa/mfa_fido2_screen.dart index d815a52..3dafc78 100644 --- a/client/lib/open/screens/mfa/mfa_fido2_screen.dart +++ b/client/lib/open/screens/mfa/mfa_fido2_screen.dart @@ -95,6 +95,7 @@ class MfaFido2Screen extends HookConsumerWidget { error: e, ); } catch (e) { + if (reportIfAttemptLimit(host, e)) return; toaster.showError( message: ErrorHandler.getHumanReadableError(e), logMessage: "FIDO2 MFA assertion submit failed", diff --git a/client/lib/open/screens/mfa/mfa_step_chrome.dart b/client/lib/open/screens/mfa/mfa_step_chrome.dart index 3ff7498..48bb340 100644 --- a/client/lib/open/screens/mfa/mfa_step_chrome.dart +++ b/client/lib/open/screens/mfa/mfa_step_chrome.dart @@ -1,5 +1,7 @@ import 'package:material_ui/material_ui.dart'; import 'package:mobile/data/mfa/mfa_flow.dart'; +import 'package:mobile/open/api.dart'; +import 'package:mobile/utils/error_handler.dart'; /// Names every route the MFA flow pushes, so unwinding the whole flow is one /// `popUntil` no matter how many steps were stacked. @@ -23,6 +25,21 @@ abstract class MfaStepHost { void abort(); } +/// The server drops the session at the attempt limit, so the step cannot be +/// retried and the whole flow ends instead. +bool reportIfAttemptLimit(MfaStepHost host, Object error) { + if (error is! MfaRequestException || + error.failure != MfaRequestFailure.attemptLimit) { + return false; + } + host.reportFailure( + message: ErrorHandler.getHumanReadableError(error), + logMessage: 'MFA attempt limit reached', + error: error, + ); + return true; +} + class MfaStepScope extends StatelessWidget { final MfaStepHost host; final Widget child; diff --git a/client/lib/open/screens/mfa/mfa_step_flow.dart b/client/lib/open/screens/mfa/mfa_step_flow.dart index 3ee3ae8..029f8f6 100644 --- a/client/lib/open/screens/mfa/mfa_step_flow.dart +++ b/client/lib/open/screens/mfa/mfa_step_flow.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:material_ui/material_ui.dart'; import 'package:mobile/data/db/enums.dart'; import 'package:mobile/data/mfa/mfa_flow.dart'; -import 'package:mobile/data/proxy/mfa.dart'; +import 'package:mobile/data/mfa/mfa_transport.dart'; import 'package:mobile/enterprise/screens/mfa/openid_mfa_screen.dart'; import 'package:mobile/open/api.dart'; import 'package:mobile/open/screens/mfa/mfa_biometric_screen.dart'; @@ -78,7 +78,7 @@ class MfaStepFlow implements MfaStepHost { Future _openStep() async { try { await controller.startStep(); - } on MfaRejectedException catch (e) { + } on MfaStartRejectedException catch (e) { _finish( MfaFlowFailed( message: e.message, diff --git a/client/lib/open/screens/mfa/remote_mfa_qr_screen.dart b/client/lib/open/screens/mfa/remote_mfa_qr_screen.dart index 29613e9..dff6e97 100644 --- a/client/lib/open/screens/mfa/remote_mfa_qr_screen.dart +++ b/client/lib/open/screens/mfa/remote_mfa_qr_screen.dart @@ -78,15 +78,29 @@ class RemoteMfaQrScreen extends HookConsumerWidget { } final signature = signChallenge(data.challenge, storage.privateKey); - final requestData = FinishMfaRequest( - token: data.token, - code: signature, - authPubKey: storage.publicKey, - ); - await proxyApi.finishRemoteMfa( - Uri.parse(instance.proxyUrl), - requestData, - ); + final proxyUrl = Uri.parse(instance.proxyUrl); + await switch (data) { + MfaFlowRemoteMfaQr(:final stepAttemptId) => + proxyApi.approveMfaFlow( + proxyUrl, + MfaFlowApproveRequest( + token: data.token, + stepAttemptId: stepAttemptId, + proof: MfaMobileApprovalProof( + signature: signature, + authPubKey: storage.publicKey, + ), + ), + ), + LegacyRemoteMfaQr() => proxyApi.finishRemoteMfa( + proxyUrl, + FinishMfaRequest( + token: data.token, + code: signature, + authPubKey: storage.publicKey, + ), + ), + }; talker.info( "Successfully authorized instance ${instance.logName}.", ); diff --git a/client/lib/utils/error_handler.dart b/client/lib/utils/error_handler.dart index c24083b..41b7c5b 100644 --- a/client/lib/utils/error_handler.dart +++ b/client/lib/utils/error_handler.dart @@ -1,4 +1,5 @@ import 'package:dio/dio.dart'; +import 'package:mobile/open/api.dart'; class ErrorHandler { static String getHumanReadableError(Object e) { @@ -9,22 +10,9 @@ class ErrorHandler { case DioExceptionType.receiveTimeout: return "Connection timed out. Please check your internet connection."; case DioExceptionType.connectionError: - return "Unable to connect to the server. Please check your internet connection."; + return _connectionErrorMessage; case DioExceptionType.badResponse: - if (e.response?.statusCode == 401) { - return "Unauthorized. Please check your credentials."; - } - if (e.response?.statusCode == 403) { - return "Access forbidden."; - } - if (e.response?.statusCode == 404) { - return "Service not found."; - } - if (e.response?.statusCode != null && - e.response!.statusCode! >= 500) { - return "Server error. Please try again later."; - } - return "Server returned an error: ${e.response?.statusCode}"; + return _statusMessage(e.response?.statusCode); case DioExceptionType.cancel: return "Request was cancelled."; default: @@ -40,10 +28,45 @@ class ErrorHandler { } } + if (e is MfaRequestException) { + return switch (e.failure) { + MfaRequestFailure.network => _connectionErrorMessage, + MfaRequestFailure.policyDenied => + "This device does not meet the location's security requirements.", + MfaRequestFailure.attemptLimit => + "Too many failed attempts. Start connecting again.", + MfaRequestFailure.notReady => + "Verification can't start right now. Try again in a moment.", + MfaRequestFailure.server => _statusMessage(e.statusCode), + }; + } + final s = e.toString(); if (s.startsWith("Exception: ")) { return s.substring(11); } return s; } + + static const _connectionErrorMessage = + "Unable to connect to the server. Please check your internet connection."; + + static String _statusMessage(int? statusCode) { + if (statusCode == 401) { + return "Unauthorized. Please check your credentials."; + } + if (statusCode == 403) { + return "Access forbidden."; + } + if (statusCode == 404) { + return "Service not found."; + } + if (statusCode != null && statusCode >= 500) { + return "Server error. Please try again later."; + } + if (statusCode == null) { + return "An unexpected network error occurred."; + } + return "Server returned an error: $statusCode"; + } } diff --git a/client/test/api/proxy_dio_test.dart b/client/test/api/proxy_dio_test.dart index 8b86816..9ac7e26 100644 --- a/client/test/api/proxy_dio_test.dart +++ b/client/test/api/proxy_dio_test.dart @@ -47,6 +47,37 @@ void main() { ); }); + test('MFA requests, responses, and errors are excluded from Dio logs', () { + final logger = dio.interceptors.whereType().single; + final settings = logger.settings; + + for (final path in [ + 'https://proxy.example/api/v1/client-mfa/start', + 'https://proxy.example/api/v1/client-mfa/finish', + 'https://proxy.example/api/v1/client-mfa/finish-remote', + 'https://proxy.example/api/v1/mfa-flow/start', + 'https://proxy.example/api/v1/mfa-flow/step-start', + 'https://proxy.example/api/v1/mfa-flow/step-finish', + 'https://proxy.example/api/v1/mfa-flow/approve', + ]) { + final request = RequestOptions(path: path); + expect(settings.requestFilter!(request), isFalse); + expect( + settings.responseFilter!(Response(requestOptions: request)), + isFalse, + ); + expect( + settings.errorFilter!(DioException(requestOptions: request)), + isFalse, + ); + } + + final otherRequest = RequestOptions( + path: 'https://proxy.example/api/v1/poll', + ); + expect(settings.requestFilter!(otherRequest), isTrue); + }); + test('base options carry no capability headers', () { expect(dio.options.headers.containsKey(clientVersionHeader), isFalse); expect(dio.options.headers.containsKey(clientPlatformHeader), isFalse); diff --git a/client/test/connect_dialog_panes_test.dart b/client/test/connect_dialog_panes_test.dart index e03e1e2..be8fbbe 100644 --- a/client/test/connect_dialog_panes_test.dart +++ b/client/test/connect_dialog_panes_test.dart @@ -36,6 +36,7 @@ const _instance = DefguardInstance( enterpriseEnabled: false, pubKey: 'instance-pubkey', mfaKeysStored: true, + mfaContract: MfaContract.multiStep, ); MfaStep _step(List methods) => diff --git a/client/test/drift/defguard/generated/schema.dart b/client/test/drift/defguard/generated/schema.dart index e146985..f76d308 100644 --- a/client/test/drift/defguard/generated/schema.dart +++ b/client/test/drift/defguard/generated/schema.dart @@ -11,6 +11,7 @@ import 'schema_v4.dart' as v4; import 'schema_v5.dart' as v5; import 'schema_v6.dart' as v6; import 'schema_v7.dart' as v7; +import 'schema_v8.dart' as v8; class GeneratedHelper implements SchemaInstantiationHelper { @override @@ -30,10 +31,12 @@ class GeneratedHelper implements SchemaInstantiationHelper { return v6.DatabaseAtV6(db); case 7: return v7.DatabaseAtV7(db); + case 8: + return v8.DatabaseAtV8(db); default: throw MissingSchemaException(version, versions); } } - static const versions = const [1, 2, 3, 4, 5, 6, 7]; + static const versions = const [1, 2, 3, 4, 5, 6, 7, 8]; } diff --git a/client/test/drift/defguard/generated/schema_v8.dart b/client/test/drift/defguard/generated/schema_v8.dart new file mode 100644 index 0000000..5b2e351 --- /dev/null +++ b/client/test/drift/defguard/generated/schema_v8.dart @@ -0,0 +1,1481 @@ +// dart format width=80 +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// +import 'package:drift/drift.dart'; + +class DefguardInstances extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + DefguardInstances(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL PRIMARY KEY AUTOINCREMENT', + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn uuid = GeneratedColumn( + 'uuid', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn url = GeneratedColumn( + 'url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn deviceId = GeneratedColumn( + 'device_id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn proxyUrl = GeneratedColumn( + 'proxy_url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn username = GeneratedColumn( + 'username', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn clientTrafficPolicy = GeneratedColumn( + 'client_traffic_policy', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn enterpriseEnabled = GeneratedColumn( + 'enterprise_enabled', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL CHECK (enterprise_enabled IN (0, 1))', + ); + late final GeneratedColumn pubKey = GeneratedColumn( + 'pub_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn mfaKeysStored = GeneratedColumn( + 'mfa_keys_stored', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL CHECK (mfa_keys_stored IN (0, 1))', + ); + late final GeneratedColumn openidDisplayName = + GeneratedColumn( + 'openid_display_name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn openidProviderKind = + GeneratedColumn( + 'openid_provider_kind', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn mfaContract = GeneratedColumn( + 'mfa_contract', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'legacy\'', + defaultValue: const CustomExpression('\'legacy\''), + ); + @override + List get $columns => [ + id, + name, + uuid, + url, + deviceId, + proxyUrl, + username, + clientTrafficPolicy, + enterpriseEnabled, + pubKey, + mfaKeysStored, + openidDisplayName, + openidProviderKind, + mfaContract, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'defguard_instances'; + @override + Set get $primaryKey => {id}; + @override + DefguardInstancesData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return DefguardInstancesData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + uuid: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}uuid'], + )!, + url: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}url'], + )!, + deviceId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}device_id'], + )!, + proxyUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}proxy_url'], + )!, + username: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}username'], + )!, + clientTrafficPolicy: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}client_traffic_policy'], + )!, + enterpriseEnabled: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}enterprise_enabled'], + )!, + pubKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}pub_key'], + )!, + mfaKeysStored: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}mfa_keys_stored'], + )!, + openidDisplayName: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}openid_display_name'], + ), + openidProviderKind: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}openid_provider_kind'], + ), + mfaContract: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}mfa_contract'], + )!, + ); + } + + @override + DefguardInstances createAlias(String alias) { + return DefguardInstances(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class DefguardInstancesData extends DataClass + implements Insertable { + final int id; + final String name; + final String uuid; + final String url; + final int deviceId; + final String proxyUrl; + final String username; + final int clientTrafficPolicy; + final int enterpriseEnabled; + final String pubKey; + final int mfaKeysStored; + final String? openidDisplayName; + final String? openidProviderKind; + final String mfaContract; + const DefguardInstancesData({ + required this.id, + required this.name, + required this.uuid, + required this.url, + required this.deviceId, + required this.proxyUrl, + required this.username, + required this.clientTrafficPolicy, + required this.enterpriseEnabled, + required this.pubKey, + required this.mfaKeysStored, + this.openidDisplayName, + this.openidProviderKind, + required this.mfaContract, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['uuid'] = Variable(uuid); + map['url'] = Variable(url); + map['device_id'] = Variable(deviceId); + map['proxy_url'] = Variable(proxyUrl); + map['username'] = Variable(username); + map['client_traffic_policy'] = Variable(clientTrafficPolicy); + map['enterprise_enabled'] = Variable(enterpriseEnabled); + map['pub_key'] = Variable(pubKey); + map['mfa_keys_stored'] = Variable(mfaKeysStored); + if (!nullToAbsent || openidDisplayName != null) { + map['openid_display_name'] = Variable(openidDisplayName); + } + if (!nullToAbsent || openidProviderKind != null) { + map['openid_provider_kind'] = Variable(openidProviderKind); + } + map['mfa_contract'] = Variable(mfaContract); + return map; + } + + DefguardInstancesCompanion toCompanion(bool nullToAbsent) { + return DefguardInstancesCompanion( + id: Value(id), + name: Value(name), + uuid: Value(uuid), + url: Value(url), + deviceId: Value(deviceId), + proxyUrl: Value(proxyUrl), + username: Value(username), + clientTrafficPolicy: Value(clientTrafficPolicy), + enterpriseEnabled: Value(enterpriseEnabled), + pubKey: Value(pubKey), + mfaKeysStored: Value(mfaKeysStored), + openidDisplayName: openidDisplayName == null && nullToAbsent + ? const Value.absent() + : Value(openidDisplayName), + openidProviderKind: openidProviderKind == null && nullToAbsent + ? const Value.absent() + : Value(openidProviderKind), + mfaContract: Value(mfaContract), + ); + } + + factory DefguardInstancesData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return DefguardInstancesData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + uuid: serializer.fromJson(json['uuid']), + url: serializer.fromJson(json['url']), + deviceId: serializer.fromJson(json['deviceId']), + proxyUrl: serializer.fromJson(json['proxyUrl']), + username: serializer.fromJson(json['username']), + clientTrafficPolicy: serializer.fromJson( + json['clientTrafficPolicy'], + ), + enterpriseEnabled: serializer.fromJson(json['enterpriseEnabled']), + pubKey: serializer.fromJson(json['pubKey']), + mfaKeysStored: serializer.fromJson(json['mfaKeysStored']), + openidDisplayName: serializer.fromJson( + json['openidDisplayName'], + ), + openidProviderKind: serializer.fromJson( + json['openidProviderKind'], + ), + mfaContract: serializer.fromJson(json['mfaContract']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'uuid': serializer.toJson(uuid), + 'url': serializer.toJson(url), + 'deviceId': serializer.toJson(deviceId), + 'proxyUrl': serializer.toJson(proxyUrl), + 'username': serializer.toJson(username), + 'clientTrafficPolicy': serializer.toJson(clientTrafficPolicy), + 'enterpriseEnabled': serializer.toJson(enterpriseEnabled), + 'pubKey': serializer.toJson(pubKey), + 'mfaKeysStored': serializer.toJson(mfaKeysStored), + 'openidDisplayName': serializer.toJson(openidDisplayName), + 'openidProviderKind': serializer.toJson(openidProviderKind), + 'mfaContract': serializer.toJson(mfaContract), + }; + } + + DefguardInstancesData copyWith({ + int? id, + String? name, + String? uuid, + String? url, + int? deviceId, + String? proxyUrl, + String? username, + int? clientTrafficPolicy, + int? enterpriseEnabled, + String? pubKey, + int? mfaKeysStored, + Value openidDisplayName = const Value.absent(), + Value openidProviderKind = const Value.absent(), + String? mfaContract, + }) => DefguardInstancesData( + id: id ?? this.id, + name: name ?? this.name, + uuid: uuid ?? this.uuid, + url: url ?? this.url, + deviceId: deviceId ?? this.deviceId, + proxyUrl: proxyUrl ?? this.proxyUrl, + username: username ?? this.username, + clientTrafficPolicy: clientTrafficPolicy ?? this.clientTrafficPolicy, + enterpriseEnabled: enterpriseEnabled ?? this.enterpriseEnabled, + pubKey: pubKey ?? this.pubKey, + mfaKeysStored: mfaKeysStored ?? this.mfaKeysStored, + openidDisplayName: openidDisplayName.present + ? openidDisplayName.value + : this.openidDisplayName, + openidProviderKind: openidProviderKind.present + ? openidProviderKind.value + : this.openidProviderKind, + mfaContract: mfaContract ?? this.mfaContract, + ); + DefguardInstancesData copyWithCompanion(DefguardInstancesCompanion data) { + return DefguardInstancesData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + uuid: data.uuid.present ? data.uuid.value : this.uuid, + url: data.url.present ? data.url.value : this.url, + deviceId: data.deviceId.present ? data.deviceId.value : this.deviceId, + proxyUrl: data.proxyUrl.present ? data.proxyUrl.value : this.proxyUrl, + username: data.username.present ? data.username.value : this.username, + clientTrafficPolicy: data.clientTrafficPolicy.present + ? data.clientTrafficPolicy.value + : this.clientTrafficPolicy, + enterpriseEnabled: data.enterpriseEnabled.present + ? data.enterpriseEnabled.value + : this.enterpriseEnabled, + pubKey: data.pubKey.present ? data.pubKey.value : this.pubKey, + mfaKeysStored: data.mfaKeysStored.present + ? data.mfaKeysStored.value + : this.mfaKeysStored, + openidDisplayName: data.openidDisplayName.present + ? data.openidDisplayName.value + : this.openidDisplayName, + openidProviderKind: data.openidProviderKind.present + ? data.openidProviderKind.value + : this.openidProviderKind, + mfaContract: data.mfaContract.present + ? data.mfaContract.value + : this.mfaContract, + ); + } + + @override + String toString() { + return (StringBuffer('DefguardInstancesData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('uuid: $uuid, ') + ..write('url: $url, ') + ..write('deviceId: $deviceId, ') + ..write('proxyUrl: $proxyUrl, ') + ..write('username: $username, ') + ..write('clientTrafficPolicy: $clientTrafficPolicy, ') + ..write('enterpriseEnabled: $enterpriseEnabled, ') + ..write('pubKey: $pubKey, ') + ..write('mfaKeysStored: $mfaKeysStored, ') + ..write('openidDisplayName: $openidDisplayName, ') + ..write('openidProviderKind: $openidProviderKind, ') + ..write('mfaContract: $mfaContract') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + name, + uuid, + url, + deviceId, + proxyUrl, + username, + clientTrafficPolicy, + enterpriseEnabled, + pubKey, + mfaKeysStored, + openidDisplayName, + openidProviderKind, + mfaContract, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is DefguardInstancesData && + other.id == this.id && + other.name == this.name && + other.uuid == this.uuid && + other.url == this.url && + other.deviceId == this.deviceId && + other.proxyUrl == this.proxyUrl && + other.username == this.username && + other.clientTrafficPolicy == this.clientTrafficPolicy && + other.enterpriseEnabled == this.enterpriseEnabled && + other.pubKey == this.pubKey && + other.mfaKeysStored == this.mfaKeysStored && + other.openidDisplayName == this.openidDisplayName && + other.openidProviderKind == this.openidProviderKind && + other.mfaContract == this.mfaContract); +} + +class DefguardInstancesCompanion + extends UpdateCompanion { + final Value id; + final Value name; + final Value uuid; + final Value url; + final Value deviceId; + final Value proxyUrl; + final Value username; + final Value clientTrafficPolicy; + final Value enterpriseEnabled; + final Value pubKey; + final Value mfaKeysStored; + final Value openidDisplayName; + final Value openidProviderKind; + final Value mfaContract; + const DefguardInstancesCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.uuid = const Value.absent(), + this.url = const Value.absent(), + this.deviceId = const Value.absent(), + this.proxyUrl = const Value.absent(), + this.username = const Value.absent(), + this.clientTrafficPolicy = const Value.absent(), + this.enterpriseEnabled = const Value.absent(), + this.pubKey = const Value.absent(), + this.mfaKeysStored = const Value.absent(), + this.openidDisplayName = const Value.absent(), + this.openidProviderKind = const Value.absent(), + this.mfaContract = const Value.absent(), + }); + DefguardInstancesCompanion.insert({ + this.id = const Value.absent(), + required String name, + required String uuid, + required String url, + required int deviceId, + required String proxyUrl, + required String username, + this.clientTrafficPolicy = const Value.absent(), + required int enterpriseEnabled, + required String pubKey, + required int mfaKeysStored, + this.openidDisplayName = const Value.absent(), + this.openidProviderKind = const Value.absent(), + this.mfaContract = const Value.absent(), + }) : name = Value(name), + uuid = Value(uuid), + url = Value(url), + deviceId = Value(deviceId), + proxyUrl = Value(proxyUrl), + username = Value(username), + enterpriseEnabled = Value(enterpriseEnabled), + pubKey = Value(pubKey), + mfaKeysStored = Value(mfaKeysStored); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? uuid, + Expression? url, + Expression? deviceId, + Expression? proxyUrl, + Expression? username, + Expression? clientTrafficPolicy, + Expression? enterpriseEnabled, + Expression? pubKey, + Expression? mfaKeysStored, + Expression? openidDisplayName, + Expression? openidProviderKind, + Expression? mfaContract, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (uuid != null) 'uuid': uuid, + if (url != null) 'url': url, + if (deviceId != null) 'device_id': deviceId, + if (proxyUrl != null) 'proxy_url': proxyUrl, + if (username != null) 'username': username, + if (clientTrafficPolicy != null) + 'client_traffic_policy': clientTrafficPolicy, + if (enterpriseEnabled != null) 'enterprise_enabled': enterpriseEnabled, + if (pubKey != null) 'pub_key': pubKey, + if (mfaKeysStored != null) 'mfa_keys_stored': mfaKeysStored, + if (openidDisplayName != null) 'openid_display_name': openidDisplayName, + if (openidProviderKind != null) + 'openid_provider_kind': openidProviderKind, + if (mfaContract != null) 'mfa_contract': mfaContract, + }); + } + + DefguardInstancesCompanion copyWith({ + Value? id, + Value? name, + Value? uuid, + Value? url, + Value? deviceId, + Value? proxyUrl, + Value? username, + Value? clientTrafficPolicy, + Value? enterpriseEnabled, + Value? pubKey, + Value? mfaKeysStored, + Value? openidDisplayName, + Value? openidProviderKind, + Value? mfaContract, + }) { + return DefguardInstancesCompanion( + id: id ?? this.id, + name: name ?? this.name, + uuid: uuid ?? this.uuid, + url: url ?? this.url, + deviceId: deviceId ?? this.deviceId, + proxyUrl: proxyUrl ?? this.proxyUrl, + username: username ?? this.username, + clientTrafficPolicy: clientTrafficPolicy ?? this.clientTrafficPolicy, + enterpriseEnabled: enterpriseEnabled ?? this.enterpriseEnabled, + pubKey: pubKey ?? this.pubKey, + mfaKeysStored: mfaKeysStored ?? this.mfaKeysStored, + openidDisplayName: openidDisplayName ?? this.openidDisplayName, + openidProviderKind: openidProviderKind ?? this.openidProviderKind, + mfaContract: mfaContract ?? this.mfaContract, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (uuid.present) { + map['uuid'] = Variable(uuid.value); + } + if (url.present) { + map['url'] = Variable(url.value); + } + if (deviceId.present) { + map['device_id'] = Variable(deviceId.value); + } + if (proxyUrl.present) { + map['proxy_url'] = Variable(proxyUrl.value); + } + if (username.present) { + map['username'] = Variable(username.value); + } + if (clientTrafficPolicy.present) { + map['client_traffic_policy'] = Variable(clientTrafficPolicy.value); + } + if (enterpriseEnabled.present) { + map['enterprise_enabled'] = Variable(enterpriseEnabled.value); + } + if (pubKey.present) { + map['pub_key'] = Variable(pubKey.value); + } + if (mfaKeysStored.present) { + map['mfa_keys_stored'] = Variable(mfaKeysStored.value); + } + if (openidDisplayName.present) { + map['openid_display_name'] = Variable(openidDisplayName.value); + } + if (openidProviderKind.present) { + map['openid_provider_kind'] = Variable(openidProviderKind.value); + } + if (mfaContract.present) { + map['mfa_contract'] = Variable(mfaContract.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('DefguardInstancesCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('uuid: $uuid, ') + ..write('url: $url, ') + ..write('deviceId: $deviceId, ') + ..write('proxyUrl: $proxyUrl, ') + ..write('username: $username, ') + ..write('clientTrafficPolicy: $clientTrafficPolicy, ') + ..write('enterpriseEnabled: $enterpriseEnabled, ') + ..write('pubKey: $pubKey, ') + ..write('mfaKeysStored: $mfaKeysStored, ') + ..write('openidDisplayName: $openidDisplayName, ') + ..write('openidProviderKind: $openidProviderKind, ') + ..write('mfaContract: $mfaContract') + ..write(')')) + .toString(); + } +} + +class Locations extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Locations(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL PRIMARY KEY AUTOINCREMENT', + ); + late final GeneratedColumn instance = GeneratedColumn( + 'instance', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: + 'NOT NULL REFERENCES defguard_instances(id)ON DELETE CASCADE', + ); + late final GeneratedColumn networkId = GeneratedColumn( + 'network_id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn address = GeneratedColumn( + 'address', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn pubKey = GeneratedColumn( + 'pub_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn endpoint = GeneratedColumn( + 'endpoint', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn allowedIps = GeneratedColumn( + 'allowed_ips', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn dns = GeneratedColumn( + 'dns', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn mfaEnabled = GeneratedColumn( + 'mfa_enabled', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL CHECK (mfa_enabled IN (0, 1))', + ); + late final GeneratedColumn trafficMethod = GeneratedColumn( + 'traffic_method', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn mfaMethod = GeneratedColumn( + 'mfa_method', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn keepAliveInterval = GeneratedColumn( + 'keep_alive_interval', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn locationMfaMode = GeneratedColumn( + 'location_mfa_mode', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn postureCheckRequired = GeneratedColumn( + 'posture_check_required', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL CHECK (posture_check_required IN (0, 1))', + ); + late final GeneratedColumn mfaSteps = GeneratedColumn( + 'mfa_steps', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'[]\'', + defaultValue: const CustomExpression('\'[]\''), + ); + late final GeneratedColumn mfaStepPlan = GeneratedColumn( + 'mfa_step_plan', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'[]\'', + defaultValue: const CustomExpression('\'[]\''), + ); + @override + List get $columns => [ + id, + instance, + networkId, + name, + address, + pubKey, + endpoint, + allowedIps, + dns, + mfaEnabled, + trafficMethod, + mfaMethod, + keepAliveInterval, + locationMfaMode, + postureCheckRequired, + mfaSteps, + mfaStepPlan, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'locations'; + @override + Set get $primaryKey => {id}; + @override + LocationsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return LocationsData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + instance: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}instance'], + )!, + networkId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}network_id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + address: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}address'], + )!, + pubKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}pub_key'], + )!, + endpoint: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}endpoint'], + )!, + allowedIps: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}allowed_ips'], + )!, + dns: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}dns'], + ), + mfaEnabled: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}mfa_enabled'], + ), + trafficMethod: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}traffic_method'], + ), + mfaMethod: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}mfa_method'], + ), + keepAliveInterval: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}keep_alive_interval'], + )!, + locationMfaMode: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}location_mfa_mode'], + ), + postureCheckRequired: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}posture_check_required'], + ), + mfaSteps: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}mfa_steps'], + )!, + mfaStepPlan: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}mfa_step_plan'], + )!, + ); + } + + @override + Locations createAlias(String alias) { + return Locations(attachedDatabase, alias); + } + + @override + bool get dontWriteConstraints => true; +} + +class LocationsData extends DataClass implements Insertable { + final int id; + final int instance; + final int networkId; + final String name; + final String address; + final String pubKey; + final String endpoint; + final String allowedIps; + final String? dns; + final int? mfaEnabled; + final String? trafficMethod; + final int? mfaMethod; + final int keepAliveInterval; + final int? locationMfaMode; + final int? postureCheckRequired; + final String mfaSteps; + final String mfaStepPlan; + const LocationsData({ + required this.id, + required this.instance, + required this.networkId, + required this.name, + required this.address, + required this.pubKey, + required this.endpoint, + required this.allowedIps, + this.dns, + this.mfaEnabled, + this.trafficMethod, + this.mfaMethod, + required this.keepAliveInterval, + this.locationMfaMode, + this.postureCheckRequired, + required this.mfaSteps, + required this.mfaStepPlan, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['instance'] = Variable(instance); + map['network_id'] = Variable(networkId); + map['name'] = Variable(name); + map['address'] = Variable(address); + map['pub_key'] = Variable(pubKey); + map['endpoint'] = Variable(endpoint); + map['allowed_ips'] = Variable(allowedIps); + if (!nullToAbsent || dns != null) { + map['dns'] = Variable(dns); + } + if (!nullToAbsent || mfaEnabled != null) { + map['mfa_enabled'] = Variable(mfaEnabled); + } + if (!nullToAbsent || trafficMethod != null) { + map['traffic_method'] = Variable(trafficMethod); + } + if (!nullToAbsent || mfaMethod != null) { + map['mfa_method'] = Variable(mfaMethod); + } + map['keep_alive_interval'] = Variable(keepAliveInterval); + if (!nullToAbsent || locationMfaMode != null) { + map['location_mfa_mode'] = Variable(locationMfaMode); + } + if (!nullToAbsent || postureCheckRequired != null) { + map['posture_check_required'] = Variable(postureCheckRequired); + } + map['mfa_steps'] = Variable(mfaSteps); + map['mfa_step_plan'] = Variable(mfaStepPlan); + return map; + } + + LocationsCompanion toCompanion(bool nullToAbsent) { + return LocationsCompanion( + id: Value(id), + instance: Value(instance), + networkId: Value(networkId), + name: Value(name), + address: Value(address), + pubKey: Value(pubKey), + endpoint: Value(endpoint), + allowedIps: Value(allowedIps), + dns: dns == null && nullToAbsent ? const Value.absent() : Value(dns), + mfaEnabled: mfaEnabled == null && nullToAbsent + ? const Value.absent() + : Value(mfaEnabled), + trafficMethod: trafficMethod == null && nullToAbsent + ? const Value.absent() + : Value(trafficMethod), + mfaMethod: mfaMethod == null && nullToAbsent + ? const Value.absent() + : Value(mfaMethod), + keepAliveInterval: Value(keepAliveInterval), + locationMfaMode: locationMfaMode == null && nullToAbsent + ? const Value.absent() + : Value(locationMfaMode), + postureCheckRequired: postureCheckRequired == null && nullToAbsent + ? const Value.absent() + : Value(postureCheckRequired), + mfaSteps: Value(mfaSteps), + mfaStepPlan: Value(mfaStepPlan), + ); + } + + factory LocationsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LocationsData( + id: serializer.fromJson(json['id']), + instance: serializer.fromJson(json['instance']), + networkId: serializer.fromJson(json['networkId']), + name: serializer.fromJson(json['name']), + address: serializer.fromJson(json['address']), + pubKey: serializer.fromJson(json['pubKey']), + endpoint: serializer.fromJson(json['endpoint']), + allowedIps: serializer.fromJson(json['allowedIps']), + dns: serializer.fromJson(json['dns']), + mfaEnabled: serializer.fromJson(json['mfaEnabled']), + trafficMethod: serializer.fromJson(json['trafficMethod']), + mfaMethod: serializer.fromJson(json['mfaMethod']), + keepAliveInterval: serializer.fromJson(json['keepAliveInterval']), + locationMfaMode: serializer.fromJson(json['locationMfaMode']), + postureCheckRequired: serializer.fromJson( + json['postureCheckRequired'], + ), + mfaSteps: serializer.fromJson(json['mfaSteps']), + mfaStepPlan: serializer.fromJson(json['mfaStepPlan']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'instance': serializer.toJson(instance), + 'networkId': serializer.toJson(networkId), + 'name': serializer.toJson(name), + 'address': serializer.toJson(address), + 'pubKey': serializer.toJson(pubKey), + 'endpoint': serializer.toJson(endpoint), + 'allowedIps': serializer.toJson(allowedIps), + 'dns': serializer.toJson(dns), + 'mfaEnabled': serializer.toJson(mfaEnabled), + 'trafficMethod': serializer.toJson(trafficMethod), + 'mfaMethod': serializer.toJson(mfaMethod), + 'keepAliveInterval': serializer.toJson(keepAliveInterval), + 'locationMfaMode': serializer.toJson(locationMfaMode), + 'postureCheckRequired': serializer.toJson(postureCheckRequired), + 'mfaSteps': serializer.toJson(mfaSteps), + 'mfaStepPlan': serializer.toJson(mfaStepPlan), + }; + } + + LocationsData copyWith({ + int? id, + int? instance, + int? networkId, + String? name, + String? address, + String? pubKey, + String? endpoint, + String? allowedIps, + Value dns = const Value.absent(), + Value mfaEnabled = const Value.absent(), + Value trafficMethod = const Value.absent(), + Value mfaMethod = const Value.absent(), + int? keepAliveInterval, + Value locationMfaMode = const Value.absent(), + Value postureCheckRequired = const Value.absent(), + String? mfaSteps, + String? mfaStepPlan, + }) => LocationsData( + id: id ?? this.id, + instance: instance ?? this.instance, + networkId: networkId ?? this.networkId, + name: name ?? this.name, + address: address ?? this.address, + pubKey: pubKey ?? this.pubKey, + endpoint: endpoint ?? this.endpoint, + allowedIps: allowedIps ?? this.allowedIps, + dns: dns.present ? dns.value : this.dns, + mfaEnabled: mfaEnabled.present ? mfaEnabled.value : this.mfaEnabled, + trafficMethod: trafficMethod.present + ? trafficMethod.value + : this.trafficMethod, + mfaMethod: mfaMethod.present ? mfaMethod.value : this.mfaMethod, + keepAliveInterval: keepAliveInterval ?? this.keepAliveInterval, + locationMfaMode: locationMfaMode.present + ? locationMfaMode.value + : this.locationMfaMode, + postureCheckRequired: postureCheckRequired.present + ? postureCheckRequired.value + : this.postureCheckRequired, + mfaSteps: mfaSteps ?? this.mfaSteps, + mfaStepPlan: mfaStepPlan ?? this.mfaStepPlan, + ); + LocationsData copyWithCompanion(LocationsCompanion data) { + return LocationsData( + id: data.id.present ? data.id.value : this.id, + instance: data.instance.present ? data.instance.value : this.instance, + networkId: data.networkId.present ? data.networkId.value : this.networkId, + name: data.name.present ? data.name.value : this.name, + address: data.address.present ? data.address.value : this.address, + pubKey: data.pubKey.present ? data.pubKey.value : this.pubKey, + endpoint: data.endpoint.present ? data.endpoint.value : this.endpoint, + allowedIps: data.allowedIps.present + ? data.allowedIps.value + : this.allowedIps, + dns: data.dns.present ? data.dns.value : this.dns, + mfaEnabled: data.mfaEnabled.present + ? data.mfaEnabled.value + : this.mfaEnabled, + trafficMethod: data.trafficMethod.present + ? data.trafficMethod.value + : this.trafficMethod, + mfaMethod: data.mfaMethod.present ? data.mfaMethod.value : this.mfaMethod, + keepAliveInterval: data.keepAliveInterval.present + ? data.keepAliveInterval.value + : this.keepAliveInterval, + locationMfaMode: data.locationMfaMode.present + ? data.locationMfaMode.value + : this.locationMfaMode, + postureCheckRequired: data.postureCheckRequired.present + ? data.postureCheckRequired.value + : this.postureCheckRequired, + mfaSteps: data.mfaSteps.present ? data.mfaSteps.value : this.mfaSteps, + mfaStepPlan: data.mfaStepPlan.present + ? data.mfaStepPlan.value + : this.mfaStepPlan, + ); + } + + @override + String toString() { + return (StringBuffer('LocationsData(') + ..write('id: $id, ') + ..write('instance: $instance, ') + ..write('networkId: $networkId, ') + ..write('name: $name, ') + ..write('address: $address, ') + ..write('pubKey: $pubKey, ') + ..write('endpoint: $endpoint, ') + ..write('allowedIps: $allowedIps, ') + ..write('dns: $dns, ') + ..write('mfaEnabled: $mfaEnabled, ') + ..write('trafficMethod: $trafficMethod, ') + ..write('mfaMethod: $mfaMethod, ') + ..write('keepAliveInterval: $keepAliveInterval, ') + ..write('locationMfaMode: $locationMfaMode, ') + ..write('postureCheckRequired: $postureCheckRequired, ') + ..write('mfaSteps: $mfaSteps, ') + ..write('mfaStepPlan: $mfaStepPlan') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + instance, + networkId, + name, + address, + pubKey, + endpoint, + allowedIps, + dns, + mfaEnabled, + trafficMethod, + mfaMethod, + keepAliveInterval, + locationMfaMode, + postureCheckRequired, + mfaSteps, + mfaStepPlan, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is LocationsData && + other.id == this.id && + other.instance == this.instance && + other.networkId == this.networkId && + other.name == this.name && + other.address == this.address && + other.pubKey == this.pubKey && + other.endpoint == this.endpoint && + other.allowedIps == this.allowedIps && + other.dns == this.dns && + other.mfaEnabled == this.mfaEnabled && + other.trafficMethod == this.trafficMethod && + other.mfaMethod == this.mfaMethod && + other.keepAliveInterval == this.keepAliveInterval && + other.locationMfaMode == this.locationMfaMode && + other.postureCheckRequired == this.postureCheckRequired && + other.mfaSteps == this.mfaSteps && + other.mfaStepPlan == this.mfaStepPlan); +} + +class LocationsCompanion extends UpdateCompanion { + final Value id; + final Value instance; + final Value networkId; + final Value name; + final Value address; + final Value pubKey; + final Value endpoint; + final Value allowedIps; + final Value dns; + final Value mfaEnabled; + final Value trafficMethod; + final Value mfaMethod; + final Value keepAliveInterval; + final Value locationMfaMode; + final Value postureCheckRequired; + final Value mfaSteps; + final Value mfaStepPlan; + const LocationsCompanion({ + this.id = const Value.absent(), + this.instance = const Value.absent(), + this.networkId = const Value.absent(), + this.name = const Value.absent(), + this.address = const Value.absent(), + this.pubKey = const Value.absent(), + this.endpoint = const Value.absent(), + this.allowedIps = const Value.absent(), + this.dns = const Value.absent(), + this.mfaEnabled = const Value.absent(), + this.trafficMethod = const Value.absent(), + this.mfaMethod = const Value.absent(), + this.keepAliveInterval = const Value.absent(), + this.locationMfaMode = const Value.absent(), + this.postureCheckRequired = const Value.absent(), + this.mfaSteps = const Value.absent(), + this.mfaStepPlan = const Value.absent(), + }); + LocationsCompanion.insert({ + this.id = const Value.absent(), + required int instance, + required int networkId, + required String name, + required String address, + required String pubKey, + required String endpoint, + required String allowedIps, + this.dns = const Value.absent(), + this.mfaEnabled = const Value.absent(), + this.trafficMethod = const Value.absent(), + this.mfaMethod = const Value.absent(), + required int keepAliveInterval, + this.locationMfaMode = const Value.absent(), + this.postureCheckRequired = const Value.absent(), + this.mfaSteps = const Value.absent(), + this.mfaStepPlan = const Value.absent(), + }) : instance = Value(instance), + networkId = Value(networkId), + name = Value(name), + address = Value(address), + pubKey = Value(pubKey), + endpoint = Value(endpoint), + allowedIps = Value(allowedIps), + keepAliveInterval = Value(keepAliveInterval); + static Insertable custom({ + Expression? id, + Expression? instance, + Expression? networkId, + Expression? name, + Expression? address, + Expression? pubKey, + Expression? endpoint, + Expression? allowedIps, + Expression? dns, + Expression? mfaEnabled, + Expression? trafficMethod, + Expression? mfaMethod, + Expression? keepAliveInterval, + Expression? locationMfaMode, + Expression? postureCheckRequired, + Expression? mfaSteps, + Expression? mfaStepPlan, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (instance != null) 'instance': instance, + if (networkId != null) 'network_id': networkId, + if (name != null) 'name': name, + if (address != null) 'address': address, + if (pubKey != null) 'pub_key': pubKey, + if (endpoint != null) 'endpoint': endpoint, + if (allowedIps != null) 'allowed_ips': allowedIps, + if (dns != null) 'dns': dns, + if (mfaEnabled != null) 'mfa_enabled': mfaEnabled, + if (trafficMethod != null) 'traffic_method': trafficMethod, + if (mfaMethod != null) 'mfa_method': mfaMethod, + if (keepAliveInterval != null) 'keep_alive_interval': keepAliveInterval, + if (locationMfaMode != null) 'location_mfa_mode': locationMfaMode, + if (postureCheckRequired != null) + 'posture_check_required': postureCheckRequired, + if (mfaSteps != null) 'mfa_steps': mfaSteps, + if (mfaStepPlan != null) 'mfa_step_plan': mfaStepPlan, + }); + } + + LocationsCompanion copyWith({ + Value? id, + Value? instance, + Value? networkId, + Value? name, + Value? address, + Value? pubKey, + Value? endpoint, + Value? allowedIps, + Value? dns, + Value? mfaEnabled, + Value? trafficMethod, + Value? mfaMethod, + Value? keepAliveInterval, + Value? locationMfaMode, + Value? postureCheckRequired, + Value? mfaSteps, + Value? mfaStepPlan, + }) { + return LocationsCompanion( + id: id ?? this.id, + instance: instance ?? this.instance, + networkId: networkId ?? this.networkId, + name: name ?? this.name, + address: address ?? this.address, + pubKey: pubKey ?? this.pubKey, + endpoint: endpoint ?? this.endpoint, + allowedIps: allowedIps ?? this.allowedIps, + dns: dns ?? this.dns, + mfaEnabled: mfaEnabled ?? this.mfaEnabled, + trafficMethod: trafficMethod ?? this.trafficMethod, + mfaMethod: mfaMethod ?? this.mfaMethod, + keepAliveInterval: keepAliveInterval ?? this.keepAliveInterval, + locationMfaMode: locationMfaMode ?? this.locationMfaMode, + postureCheckRequired: postureCheckRequired ?? this.postureCheckRequired, + mfaSteps: mfaSteps ?? this.mfaSteps, + mfaStepPlan: mfaStepPlan ?? this.mfaStepPlan, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (instance.present) { + map['instance'] = Variable(instance.value); + } + if (networkId.present) { + map['network_id'] = Variable(networkId.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (address.present) { + map['address'] = Variable(address.value); + } + if (pubKey.present) { + map['pub_key'] = Variable(pubKey.value); + } + if (endpoint.present) { + map['endpoint'] = Variable(endpoint.value); + } + if (allowedIps.present) { + map['allowed_ips'] = Variable(allowedIps.value); + } + if (dns.present) { + map['dns'] = Variable(dns.value); + } + if (mfaEnabled.present) { + map['mfa_enabled'] = Variable(mfaEnabled.value); + } + if (trafficMethod.present) { + map['traffic_method'] = Variable(trafficMethod.value); + } + if (mfaMethod.present) { + map['mfa_method'] = Variable(mfaMethod.value); + } + if (keepAliveInterval.present) { + map['keep_alive_interval'] = Variable(keepAliveInterval.value); + } + if (locationMfaMode.present) { + map['location_mfa_mode'] = Variable(locationMfaMode.value); + } + if (postureCheckRequired.present) { + map['posture_check_required'] = Variable(postureCheckRequired.value); + } + if (mfaSteps.present) { + map['mfa_steps'] = Variable(mfaSteps.value); + } + if (mfaStepPlan.present) { + map['mfa_step_plan'] = Variable(mfaStepPlan.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LocationsCompanion(') + ..write('id: $id, ') + ..write('instance: $instance, ') + ..write('networkId: $networkId, ') + ..write('name: $name, ') + ..write('address: $address, ') + ..write('pubKey: $pubKey, ') + ..write('endpoint: $endpoint, ') + ..write('allowedIps: $allowedIps, ') + ..write('dns: $dns, ') + ..write('mfaEnabled: $mfaEnabled, ') + ..write('trafficMethod: $trafficMethod, ') + ..write('mfaMethod: $mfaMethod, ') + ..write('keepAliveInterval: $keepAliveInterval, ') + ..write('locationMfaMode: $locationMfaMode, ') + ..write('postureCheckRequired: $postureCheckRequired, ') + ..write('mfaSteps: $mfaSteps, ') + ..write('mfaStepPlan: $mfaStepPlan') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV8 extends GeneratedDatabase { + DatabaseAtV8(QueryExecutor e) : super(e); + late final DefguardInstances defguardInstances = DefguardInstances(this); + late final Locations locations = Locations(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + defguardInstances, + locations, + ]; + @override + StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([ + WritePropagation( + on: TableUpdateQuery.onTableName( + 'defguard_instances', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('locations', kind: UpdateKind.delete)], + ), + ]); + @override + int get schemaVersion => 8; +} diff --git a/client/test/drift/defguard/migration_test.dart b/client/test/drift/defguard/migration_test.dart index c54588d..c29be71 100644 --- a/client/test/drift/defguard/migration_test.dart +++ b/client/test/drift/defguard/migration_test.dart @@ -14,6 +14,8 @@ import 'generated/schema_v2.dart' as v2; import 'generated/schema_v4.dart' as v4; import 'generated/schema_v5.dart' as v5; import 'generated/schema_v6.dart' as v6; +import 'generated/schema_v7.dart' as v7; +import 'generated/schema_v8.dart' as v8; void main() { driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; @@ -42,6 +44,42 @@ void main() { } }); + test('migration from v7 to v8 defaults the MFA contract to legacy', () async { + const oldInstance = v7.DefguardInstancesData( + id: 1, + name: 'instance', + uuid: 'instance-uuid', + url: 'https://defguard.example', + deviceId: 7, + proxyUrl: 'https://proxy.defguard.example', + username: 'user', + clientTrafficPolicy: 0, + enterpriseEnabled: 1, + pubKey: 'public-key', + mfaKeysStored: 0, + openidProviderKind: 'google', + ); + + await verifier.testWithDataIntegrity( + oldVersion: 7, + newVersion: 8, + createOld: v7.DatabaseAtV7.new, + createNew: v8.DatabaseAtV8.new, + openTestedDatabase: AppDatabase.new, + createItems: (batch, oldDb) { + batch.insertAll(oldDb.defguardInstances, [oldInstance]); + }, + validateItems: (newDb) async { + final instance = await newDb + .select(newDb.defguardInstances) + .getSingle(); + expect(instance.uuid, 'instance-uuid'); + expect(instance.openidProviderKind, 'google'); + expect(instance.mfaContract, 'legacy'); + }, + ); + }); + // The following template shows how to write tests ensuring your migrations // preserve existing data. // Testing this can be useful for migrations that change existing columns diff --git a/client/test/enterprise/openid_mfa_waiting_screen_test.dart b/client/test/enterprise/openid_mfa_waiting_screen_test.dart new file mode 100644 index 0000000..e1744bf --- /dev/null +++ b/client/test/enterprise/openid_mfa_waiting_screen_test.dart @@ -0,0 +1,113 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:mobile/data/db/enums.dart'; +import 'package:mobile/data/mfa/mfa_flow.dart'; +import 'package:mobile/data/mfa/mfa_transport.dart'; +import 'package:mobile/enterprise/postures.dart'; +import 'package:mobile/enterprise/screens/mfa/openid_mfa_waiting_screen.dart'; +import 'package:mobile/open/screens/mfa/mfa_step_chrome.dart'; + +class _PollingTransport implements MfaTransport { + final Queue results; + final stepAttemptIds = []; + final credentials = []; + + _PollingTransport(Iterable results) + : results = Queue.of(results); + + @override + Future start({ + required String devicePubkey, + required int networkId, + required List plan, + DevicePostureData? postureData, + }) async => const MfaSessionStart( + token: 'session-token', + firstStep: MfaStepChallenge(stepAttemptId: 'openid-attempt'), + ); + + @override + Future startStep(String token, MfaMethod method) => + throw StateError('OIDC polling does not open another step'); + + @override + Future finish({ + required String token, + required String? stepAttemptId, + required MfaCredential? credential, + }) async { + stepAttemptIds.add(stepAttemptId); + credentials.add(credential); + return results.removeFirst(); + } +} + +class _Host implements MfaStepHost { + @override + final MfaFlowController controller; + final progress = Completer(); + + _Host(this.controller); + + @override + void reportProgress(MfaStepProgress value) { + if (!progress.isCompleted) progress.complete(value); + } + + @override + void reportFailure({ + required String message, + String? logMessage, + Object? error, + }) { + if (!progress.isCompleted) progress.completeError(StateError(message)); + } + + @override + void abort() { + controller.cancel(); + if (!progress.isCompleted) progress.completeError(StateError('cancelled')); + } +} + +void main() { + testWidgets('polls without a proof until the OIDC attempt completes', ( + tester, + ) async { + final transport = _PollingTransport([ + const MfaFinishAwaitingExternal(), + const MfaFinishCompleted('preshared-key'), + ]); + final controller = MfaFlowController( + transport: transport, + plan: [MfaMethod.openid], + devicePubkey: 'device-pubkey', + networkId: 11, + ); + await controller.startStep(); + final host = _Host(controller); + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp(home: OpenIdMfaWaitingScreen(host: host)), + ), + ); + await tester.pumpAndSettle(); + expect( + find.text('Waiting for authentication in your browser...'), + findsOneWidget, + ); + + await tester.pump(const Duration(seconds: 2)); + await tester.pump(); + + expect(await host.progress.future, isA()); + expect(transport.stepAttemptIds, ['openid-attempt', 'openid-attempt']); + expect(transport.credentials, [null, null]); + expect(controller.takePresharedKey(), 'preshared-key'); + }); +} diff --git a/client/test/mfa/mfa_approval_api_test.dart b/client/test/mfa/mfa_approval_api_test.dart new file mode 100644 index 0000000..a4f301a --- /dev/null +++ b/client/test/mfa/mfa_approval_api_test.dart @@ -0,0 +1,94 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mobile/data/proxy/mfa.dart'; +import 'package:mobile/open/api.dart'; + +class _RecordingAdapter implements HttpClientAdapter { + final requests = []; + final bodies = []; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + requests.add(options); + bodies.add( + requestStream == null + ? '' + : utf8.decode(await requestStream.expand((chunk) => chunk).toList()), + ); + return ResponseBody.fromString( + '{}', + 200, + headers: { + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); + } + + @override + void close({bool force = false}) {} +} + +void main() { + late _RecordingAdapter adapter; + late ProxyApi api; + + setUp(() { + adapter = _RecordingAdapter(); + final dio = Dio(BaseOptions(responseType: ResponseType.json)); + dio.httpClientAdapter = adapter; + api = ProxyApi.forTesting(dio); + }); + + test( + 'flow approval posts the proxy JSON and accepts an empty response', + () async { + await api.approveMfaFlow( + Uri.parse('https://proxy.example/base'), + const MfaFlowApproveRequest( + token: 'flow-token', + stepAttemptId: 'attempt-id', + proof: MfaMobileApprovalProof( + signature: 'signature', + authPubKey: 'auth-public-key', + ), + ), + ); + + expect(adapter.requests.single.uri.path, '/base/api/v1/mfa-flow/approve'); + expect( + adapter.bodies.single, + '{"token":"flow-token","step_attempt_id":"attempt-id",' + '"proof":{"signature":"signature",' + '"auth_pub_key":"auth-public-key"}}', + ); + }, + ); + + test('legacy approval keeps its existing request JSON and route', () async { + await api.finishRemoteMfa( + Uri.parse('https://proxy.example/base'), + const FinishMfaRequest( + token: 'legacy-token', + code: 'signature', + authPubKey: 'auth-public-key', + ), + ); + + expect( + adapter.requests.single.uri.path, + '/base/api/v1/client-mfa/finish-remote', + ); + expect( + adapter.bodies.single, + '{"token":"legacy-token","code":"signature",' + '"auth_pub_key":"auth-public-key"}', + ); + }); +} diff --git a/client/test/mfa/mfa_contract_test.dart b/client/test/mfa/mfa_contract_test.dart new file mode 100644 index 0000000..dccc0e2 --- /dev/null +++ b/client/test/mfa/mfa_contract_test.dart @@ -0,0 +1,221 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mobile/data/db/database.dart'; +import 'package:mobile/data/db/enums.dart'; +import 'package:mobile/data/mfa/mfa_flow.dart'; +import 'package:mobile/data/mfa/mfa_transport.dart'; +import 'package:mobile/data/mfa/mfa_plan.dart'; +import 'package:mobile/data/mfa/mfa_steps.dart'; +import 'package:mobile/enterprise/postures.dart'; +import 'package:mobile/data/proxy/config.dart' as proxy_config; +import 'package:mobile/data/proxy/enrollment.dart'; +import 'package:mobile/utils/update_instance.dart'; + +class _RejectingFlowTransport implements MfaTransport { + int startCalls = 0; + + @override + Future start({ + required String devicePubkey, + required int networkId, + required List plan, + DevicePostureData? postureData, + }) async { + startCalls++; + throw const MfaStartRejectedException('stale flow plan'); + } + + @override + Future startStep(String token, MfaMethod method) async => + throw StateError('unexpected second flow step'); + + @override + Future finish({ + required String token, + required String? stepAttemptId, + required MfaCredential? credential, + }) async => throw StateError('unexpected flow finish'); +} + +DeviceConfig _config({required bool legacy}) => DeviceConfig( + networkId: 11, + networkName: 'Warsaw', + config: 'wg-config', + endpoint: 'vpn.example:51820', + assignedIp: '10.0.0.1/24', + pubkey: 'pubkey', + allowedIps: '0.0.0.0/0', + mfaEnabled: true, + keepaliveInterval: 25, + locationMfaMode: LocationMfaMode.internal, + steps: legacy + ? const [] + : [ + MfaStep([MfaStepMethod.supported(MfaMethod.totp)]), + ], +); + +void main() { + const base = { + 'id': 'instance-id', + 'name': 'instance', + 'url': 'https://defguard.example', + 'proxy_url': 'https://proxy.defguard.example', + 'username': 'user', + 'enterprise_enabled': false, + 'disable_all_traffic': false, + 'client_traffic_policy': 0, + }; + + test('missing or null mfa_user_state means legacy', () { + final absent = InstanceInfo.fromJson(base); + final nullValue = InstanceInfo.fromJson({...base, 'mfa_user_state': null}); + + expect(absent.mfaContract, MfaContract.legacy); + expect(nullValue.mfaContract, MfaContract.legacy); + expect(absent.toCompanion().mfaContract.value, MfaContract.legacy); + }); + + test('polling config with mfa_user_state means multi-step', () { + final response = proxy_config.InstanceInfoResponse.fromJson({ + 'device_config': { + 'configs': [], + 'instance': { + ...base, + 'mfa_user_state': { + 'configured_methods': [0], + }, + }, + }, + }); + final info = response.deviceConfig!.instance!; + + expect(info.mfaContract, MfaContract.multiStep); + expect(info.toCompanion().mfaContract.value, MfaContract.multiStep); + }); + + test('config refresh promotes and demotes the stored contract', () async { + final db = AppDatabase(NativeDatabase.memory()); + addTearDown(db.close); + await db + .into(db.defguardInstances) + .insert( + DefguardInstancesCompanion.insert( + name: 'instance', + uuid: 'instance-id', + url: 'https://defguard.example', + deviceId: 7, + proxyUrl: 'https://proxy.defguard.example', + username: 'user', + enterpriseEnabled: false, + pubKey: 'public-key', + mfaKeysStored: false, + ), + ); + + var instance = await db.select(db.defguardInstances).getSingle(); + expect(instance.mfaContract, MfaContract.legacy); + + final promoted = await updateInstance( + db: db, + instance: instance, + configs: const [], + info: InstanceInfo.fromJson({ + ...base, + 'mfa_user_state': {'configured_methods': []}, + }), + ); + expect(promoted?.instanceChanged, isTrue); + instance = await db.select(db.defguardInstances).getSingle(); + expect(instance.mfaContract, MfaContract.multiStep); + + final demoted = await updateInstance( + db: db, + instance: instance, + configs: const [], + info: InstanceInfo.fromJson(base), + ); + expect(demoted?.instanceChanged, isTrue); + instance = await db.select(db.defguardInstances).getSingle(); + expect(instance.mfaContract, MfaContract.legacy); + }); + + test( + 'a refresh demoting the stored contract never retries the flow transport', + () async { + final db = AppDatabase(NativeDatabase.memory()); + addTearDown(db.close); + await db + .into(db.defguardInstances) + .insert( + DefguardInstancesCompanion.insert( + name: 'instance', + uuid: 'instance-id', + url: 'https://defguard.example', + deviceId: 7, + proxyUrl: 'https://proxy.defguard.example', + username: 'user', + enterpriseEnabled: false, + pubKey: 'public-key', + mfaKeysStored: false, + ), + ); + + var instance = await db.select(db.defguardInstances).getSingle(); + final promoted = await updateInstance( + db: db, + instance: instance, + configs: [_config(legacy: false)], + info: InstanceInfo.fromJson({ + ...base, + 'mfa_user_state': {'configured_methods': []}, + }), + ); + expect(promoted?.locationsAdded, 1); + instance = await db.select(db.defguardInstances).getSingle(); + expect(instance.mfaContract, MfaContract.multiStep); + + final transport = _RejectingFlowTransport(); + final controller = MfaFlowController( + transport: transport, + plan: [MfaMethod.totp], + devicePubkey: 'device-pubkey', + networkId: 11, + refreshPlan: () async { + final result = await updateInstance( + db: db, + instance: instance, + configs: [_config(legacy: true)], + info: InstanceInfo.fromJson(base), + ); + if (result == null) return null; + final refreshed = await db.select(db.defguardInstances).getSingle(); + final refreshedLocation = await db.select(db.locations).getSingle(); + expect(refreshedLocation.networkId, 11); + expect( + effectiveMfaSteps( + refreshedLocation, + ).single.methods.map((method) => method.method), + [MfaMethod.biometric, MfaMethod.totp, MfaMethod.email], + ); + return resolveMfaRetryPlan( + refreshedLocation, + attempt: MfaCapabilities( + biometricAvailable: true, + contract: instance.mfaContract, + ), + refreshedContract: refreshed.mfaContract, + ); + }, + ); + + await expectLater( + controller.startStep(), + throwsA(isA()), + ); + final refreshed = await db.select(db.defguardInstances).getSingle(); + expect(refreshed.mfaContract, MfaContract.legacy); + expect(transport.startCalls, 1); + }, + ); +} diff --git a/client/test/mfa/mfa_flow_api_test.dart b/client/test/mfa/mfa_flow_api_test.dart new file mode 100644 index 0000000..bc27843 --- /dev/null +++ b/client/test/mfa/mfa_flow_api_test.dart @@ -0,0 +1,375 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mobile/data/db/enums.dart'; +import 'package:mobile/data/mfa/mfa_transport.dart'; +import 'package:mobile/data/proxy/mfa.dart'; +import 'package:mobile/data/proxy/mfa_flow.dart' as flow; +import 'package:mobile/open/api.dart'; + +class _RecordingAdapter implements HttpClientAdapter { + final requests = []; + final bodies = []; + int statusCode = 200; + String responseBody = '{}'; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + requests.add(options); + bodies.add( + requestStream == null + ? '' + : utf8.decode(await requestStream.expand((chunk) => chunk).toList()), + ); + return ResponseBody.fromString( + responseBody, + statusCode, + headers: { + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); + } + + @override + void close({bool force = false}) {} +} + +void main() { + late _RecordingAdapter adapter; + late ProxyApi api; + + setUp(() { + adapter = _RecordingAdapter(); + final dio = Dio(BaseOptions(responseType: ResponseType.json)); + dio.httpClientAdapter = adapter; + api = ProxyApi.forTesting(dio); + }); + + test('legacy own-connect stays on the v1.7.0 routes and payloads', () async { + adapter.responseBody = '{"token":"legacy-token","challenge":null}'; + final start = await api.startMfa( + Uri.parse('https://proxy.example/base'), + const StartMfaRequest( + pubkey: 'device-pubkey', + locationId: 11, + method: MfaMethod.totp, + ), + ); + expect(start.token, 'legacy-token'); + expect(start.challenge, isNull); + expect(adapter.requests.single.uri.path, '/base/api/v1/client-mfa/start'); + expect( + adapter.bodies.single, + '{"pubkey":"device-pubkey","location_id":11,"method":0,"posture_data":null}', + ); + + adapter.requests.clear(); + adapter.bodies.clear(); + adapter.responseBody = '{"preshared_key":"psk","token":null}'; + final finish = await api.finishMfa( + Uri.parse('https://proxy.example/base'), + const FinishMfaRequest( + token: 'legacy-token', + code: 'signature', + authPubKey: 'auth-public-key', + ), + ); + expect(finish?.presharedKey, 'psk'); + expect(adapter.requests.single.uri.path, '/base/api/v1/client-mfa/finish'); + expect( + adapter.bodies.single, + '{"token":"legacy-token","code":"signature",' + '"auth_pub_key":"auth-public-key"}', + ); + }); + + test( + 'flow start uses only the flow route and selected-method payload', + () async { + adapter.responseBody = + '{"outcome":{"Accepted":{"token":"flow-token","first_step":{"step_attempt_id":"attempt-id","challenge":{"Signature":{"challenge":"challenge"}}}}}}'; + final response = await api.startMfaFlow( + Uri.parse('https://proxy.example/base'), + const flow.MfaFlowStartRequest( + locationId: 11, + pubkey: 'device-pubkey', + selectedMethods: [MfaMethod.totp, MfaMethod.email], + ), + ); + + expect(response.outcome, isA()); + expect( + (response.outcome as flow.MfaFlowAccepted).firstStep.stepAttemptId, + 'attempt-id', + ); + expect(adapter.requests.single.uri.path, '/base/api/v1/mfa-flow/start'); + expect( + adapter.bodies.single, + '{"location_id":11,"pubkey":"device-pubkey",' + '"posture_data":null,"selected_methods":[0,1]}', + ); + }, + ); + + test('flow step start and finish use their dedicated routes', () async { + adapter.responseBody = + '{"started":{"step_attempt_id":"attempt-id","challenge":{"Signature":{"challenge":"challenge"}}}}'; + final started = await api.startMfaFlowStep( + Uri.parse('https://proxy.example/base'), + const flow.MfaFlowStepStartRequest( + token: 'flow-token', + method: MfaMethod.email, + ), + ); + expect(started.started?.stepAttemptId, 'attempt-id'); + expect( + adapter.requests.single.uri.path, + '/base/api/v1/mfa-flow/step-start', + ); + expect(adapter.bodies.single, '{"token":"flow-token","method":1}'); + + adapter.requests.clear(); + adapter.bodies.clear(); + adapter.responseBody = '{"result":{"outcome":{"AwaitingExternal":{}}}}'; + final finish = await api.finishMfaFlow( + Uri.parse('https://proxy.example/base'), + const flow.MfaFlowStepFinishRequest( + token: 'flow-token', + stepAttemptId: 'attempt-id', + ), + ); + expect(finish.result, isA()); + expect( + adapter.requests.single.uri.path, + '/base/api/v1/mfa-flow/step-finish', + ); + expect( + adapter.bodies.single, + '{"token":"flow-token","step_attempt_id":"attempt-id",' + '"submission":null}', + ); + }); + + test('legacy biometric transport sends both legacy proof fields', () async { + adapter.responseBody = '{"preshared_key":"psk","token":null}'; + final transport = LegacyMfaTransport( + Uri.parse('https://proxy.example/base'), + api: api, + ); + await transport.finish( + token: 'legacy-token', + stepAttemptId: null, + credential: const MfaBiometricCredential( + signature: 'signature', + authPubKey: 'auth-public-key', + ), + ); + + expect(adapter.requests.single.uri.path, '/base/api/v1/client-mfa/finish'); + expect( + adapter.bodies.single, + '{"token":"legacy-token","code":"signature",' + '"auth_pub_key":"auth-public-key"}', + ); + }); + + test( + 'flow transport maps FIDO2 assertion bytes to the flow variant', + () async { + adapter.responseBody = + '{"result":{"outcome":{"Completed":{"preshared_key":"psk"}}}}'; + final transport = MfaFlowProxyTransport( + Uri.parse('https://proxy.example/base'), + api: api, + ); + final credential = MfaFido2Credential.fromAssertion( + signature: [5, 6], + authenticatorData: List.generate(37, (index) => index), + credentialId: [7, 8], + ); + await transport.finish( + token: 'flow-token', + stepAttemptId: 'attempt-1', + credential: credential, + ); + + expect( + adapter.requests.single.uri.path, + '/base/api/v1/mfa-flow/step-finish', + ); + expect( + jsonDecode(adapter.bodies.single), + { + 'token': 'flow-token', + 'step_attempt_id': 'attempt-1', + 'submission': { + 'Fido2': { + 'rp_id_hash': List.generate(32, (index) => index), + 'authenticator_data': List.generate(37, (index) => index), + 'signature': [5, 6], + 'credential_id': [7, 8], + }, + }, + }, + ); + }, + ); + + test('legacy external approval and proof rejection stay distinct', () async { + final transport = LegacyMfaTransport( + Uri.parse('https://proxy.example/base'), + api: api, + ); + adapter.statusCode = 428; + adapter.responseBody = '{}'; + expect( + await transport.finish( + token: 'legacy-token', + stepAttemptId: null, + credential: null, + ), + isA(), + ); + + adapter.statusCode = 401; + adapter.responseBody = '{"error":"secret response body"}'; + await expectLater( + transport.finish( + token: 'legacy-token', + stepAttemptId: null, + credential: const MfaCodeCredential('123456'), + ), + throwsA(isA()), + ); + }); + + test('MFA errors never retain the response body', () async { + adapter.statusCode = 500; + adapter.responseBody = '{"error":"secret response body"}'; + await expectLater( + api.startMfaFlow( + Uri.parse('https://proxy.example'), + const flow.MfaFlowStartRequest( + locationId: 11, + pubkey: 'device-pubkey', + selectedMethods: [MfaMethod.totp], + ), + ), + throwsA( + isA().having( + (error) => error.toString(), + 'safe error text', + isNot(contains('secret response body')), + ), + ), + ); + }); + + group('MFA request failures', () { + final url = Uri.parse('https://proxy.example'); + const flowStart = flow.MfaFlowStartRequest( + locationId: 11, + pubkey: 'device-pubkey', + selectedMethods: [MfaMethod.totp], + ); + const legacyStart = StartMfaRequest( + pubkey: 'device-pubkey', + locationId: 11, + method: MfaMethod.totp, + ); + + Matcher failsWith(MfaRequestFailure failure) => throwsA( + isA().having( + (error) => error.failure, + 'failure', + failure, + ), + ); + + Future expectFailure( + int status, + Future Function() call, + MfaRequestFailure failure, + ) async { + adapter.statusCode = status; + adapter.responseBody = '{"error":"server text"}'; + await expectLater(call(), failsWith(failure)); + } + + test('a start denied by policy or not ready is told apart', () async { + await expectFailure( + 403, + () => api.startMfaFlow(url, flowStart), + MfaRequestFailure.policyDenied, + ); + await expectFailure( + 428, + () => api.startMfaFlow(url, flowStart), + MfaRequestFailure.notReady, + ); + await expectFailure( + 403, + () => api.startMfa(url, legacyStart), + MfaRequestFailure.policyDenied, + ); + await expectFailure( + 428, + () => api.startMfa(url, legacyStart), + MfaRequestFailure.notReady, + ); + }); + + test('a step that cannot open yet is not ready', () async { + await expectFailure( + 428, + () => api.startMfaFlowStep( + url, + const flow.MfaFlowStepStartRequest( + token: 'flow-token', + method: MfaMethod.email, + ), + ), + MfaRequestFailure.notReady, + ); + }); + + test('a forbidden step finish is the attempt limit', () async { + await expectFailure( + 403, + () => api.finishMfaFlow( + url, + const flow.MfaFlowStepFinishRequest( + token: 'flow-token', + stepAttemptId: 'attempt-1', + ), + ), + MfaRequestFailure.attemptLimit, + ); + }); + + test('other statuses stay server failures', () async { + await expectFailure( + 500, + () => api.startMfaFlow(url, flowStart), + MfaRequestFailure.server, + ); + await expectFailure( + 403, + () => api.startMfaFlowStep( + url, + const flow.MfaFlowStepStartRequest( + token: 'flow-token', + method: MfaMethod.email, + ), + ), + MfaRequestFailure.server, + ); + }); + }); +} diff --git a/client/test/mfa/mfa_flow_test.dart b/client/test/mfa/mfa_flow_test.dart index d503c41..cbad5dc 100644 --- a/client/test/mfa/mfa_flow_test.dart +++ b/client/test/mfa/mfa_flow_test.dart @@ -1,218 +1,170 @@ +import 'dart:collection'; + import 'package:flutter_test/flutter_test.dart'; import 'package:mobile/data/db/enums.dart'; import 'package:mobile/data/mfa/mfa_flow.dart'; +import 'package:mobile/data/mfa/mfa_transport.dart'; import 'package:mobile/data/proxy/mfa.dart'; +import 'package:mobile/enterprise/postures.dart'; class _FakeTransport implements MfaTransport { - final List calls = []; - final List finishes = []; - final List starts = []; - - /// Answers for successive finish calls. A response is returned, an exception - /// is thrown. - final List finishAnswers; - - int _attempt = 0; - - _FakeTransport({this.finishAnswers = const []}); + final Queue startAnswers; + final Queue stepAnswers; + final Queue finishAnswers; + final calls = []; + final plans = >[]; + final openedSteps = []; + final stepAttemptIds = []; + final credentials = []; + + _FakeTransport({ + Iterable startAnswers = const [], + Iterable stepAnswers = const [], + Iterable finishAnswers = const [], + }) : startAnswers = Queue.of(startAnswers), + stepAnswers = Queue.of(stepAnswers), + finishAnswers = Queue.of(finishAnswers); @override - Future start(StartMfaRequest request) async { + Future start({ + required String devicePubkey, + required int networkId, + required List plan, + DevicePostureData? postureData, + }) async { calls.add('start'); - starts.add(request); - return StartMfaResponse( - token: 'session-token', - challenge: 'start-challenge', - rejections: const [], - credentialIds: request.method == MfaMethod.fido2 - ? const ['start-key'] - : const [], - ); + plans.add(List.of(plan)); + final answer = startAnswers.removeFirst(); + if (answer is MfaStartRejectedException) throw answer; + return answer as MfaSessionStart; } @override - Future stepStart(StepStartMfaRequest request) async { - calls.add('step-start:${request.method.name}'); - return StepStartMfaResponse( - stepAttemptId: 'attempt-${++_attempt}', - challenge: 'step-challenge', - credentialIds: request.method == MfaMethod.fido2 - ? const ['step-key'] - : const [], - ); + Future startStep(String token, MfaMethod method) async { + calls.add('step-start'); + openedSteps.add(method); + return stepAnswers.removeFirst(); } @override - Future finish(FinishMfaRequest request) async { + Future finish({ + required String token, + required String? stepAttemptId, + required MfaCredential? credential, + }) async { calls.add('finish'); - finishes.add(request); - final answer = finishAnswers[finishes.length - 1]; - if (answer is Exception) throw answer; - return answer as FinishMfaResponse; + stepAttemptIds.add(stepAttemptId); + credentials.add(credential); + final answer = finishAnswers.removeFirst(); + if (answer is MfaCodeRejectedException) throw answer; + return answer as MfaFinishResult; } } -MfaFlowController _controller(_FakeTransport transport, List plan) => - MfaFlowController( - transport: transport, - plan: plan, - devicePubkey: 'device-pubkey', - networkId: 11, - ); - -FinishMfaResponse _advanced(int nextStep) => - FinishMfaResponse(outcome: MfaAdvanced(nextStep)); - -FinishMfaResponse _completed([String? psk = 'psk']) => - FinishMfaResponse(outcome: MfaCompleted(psk)); +MfaSessionStart _session({ + String token = 'token', + String? attemptId = 'first-attempt', + String? challenge = 'start-challenge', + List credentialIds = const [], +}) => MfaSessionStart( + token: token, + firstStep: MfaStepChallenge( + stepAttemptId: attemptId, + challenge: challenge, + credentialIds: credentialIds, + ), +); + +MfaStepChallenge _step({ + String attemptId = 'step-attempt', + String? challenge = 'step-challenge', + List credentialIds = const [], +}) => MfaStepChallenge( + stepAttemptId: attemptId, + challenge: challenge, + credentialIds: credentialIds, +); + +MfaFlowController _controller( + MfaTransport transport, + List plan, { + MfaPlanRefresh? refreshPlan, +}) => MfaFlowController( + transport: transport, + plan: plan, + devicePubkey: 'device-pubkey', + networkId: 11, + refreshPlan: refreshPlan, +); void main() { - test('a single-step flow never opens a step separately', () async { - final transport = _FakeTransport(finishAnswers: [_completed()]); - final controller = _controller(transport, [MfaMethod.totp]); - - await controller.startStep(); - expect(await controller.submit(code: '123456'), isA()); - - expect(transport.calls, ['start', 'finish']); - expect(transport.starts.single.selectedMethods, [MfaMethod.totp]); - expect(transport.starts.single.method, MfaMethod.totp); - expect(transport.finishes.single.stepAttemptId, isNull); - expect(controller.takePresharedKey(), 'psk'); - expect(controller.stepLabel, isNull); - }); - - test('a pre-2.2 completion comes through the deprecated field', () async { - final transport = _FakeTransport( - finishAnswers: [const FinishMfaResponse(presharedKey: 'legacy-psk')], - ); - final controller = _controller(transport, [MfaMethod.email]); - - await controller.startStep(); - expect(await controller.submit(code: '123456'), isA()); - expect(controller.takePresharedKey(), 'legacy-psk'); - }); - - test('a pre-2.2 response with no key at all is a rejected proof', () async { - final transport = _FakeTransport( - finishAnswers: [const FinishMfaResponse()], - ); - final controller = _controller(transport, [MfaMethod.totp]); - - await controller.startStep(); - await expectLater( - controller.submit(code: '000000'), - throwsA(isA()), - ); - }); - - test('a three-step flow opens every step but the first', () async { - final transport = _FakeTransport( - finishAnswers: [_advanced(1), _advanced(2), _completed()], - ); - final plan = [MfaMethod.totp, MfaMethod.email, MfaMethod.biometric]; - final controller = _controller(transport, plan); - - await controller.startStep(); - expect(controller.stepLabel, 'Step 1/3'); - expect(await controller.submit(code: '111111'), isA()); - expect(controller.stepIndex, 1); - - await controller.startStep(); - expect(controller.stepLabel, 'Step 2/3'); - expect(await controller.submit(code: '222222'), isA()); - - await controller.startStep(); - expect(controller.method, MfaMethod.biometric); - expect(await controller.submit(code: 'signature'), isA()); - - expect(transport.calls, [ - 'start', - 'finish', - 'step-start:email', - 'finish', - 'step-start:biometric', - 'finish', - ]); - expect(transport.starts.single.selectedMethods, plan); - expect(transport.finishes.map((r) => r.stepAttemptId), [ - null, - 'attempt-1', - 'attempt-2', - ]); - expect(controller.takePresharedKey(), 'psk'); - }); - - test('the server decides which step comes next', () async { + test('opens each step and advances only from the server result', () async { final transport = _FakeTransport( - finishAnswers: [_advanced(2), _completed()], + startAnswers: [_session()], + stepAnswers: [_step(attemptId: 'second-attempt')], + finishAnswers: [ + const MfaFinishAdvanced(1), + const MfaFinishCompleted('psk'), + ], ); final controller = _controller(transport, [ MfaMethod.totp, - MfaMethod.email, MfaMethod.biometric, ]); await controller.startStep(); - await controller.submit(code: '111111'); - expect(controller.stepIndex, 2); - expect(controller.method, MfaMethod.biometric); - }); - - test('an advance past the end of the plan is refused', () async { - final transport = _FakeTransport(finishAnswers: [_advanced(2)]); - final controller = _controller(transport, [ - MfaMethod.totp, - MfaMethod.email, - ]); + expect(controller.token, 'token'); + expect(controller.stepAttemptId, 'first-attempt'); + expect(controller.challenge, 'start-challenge'); + expect(transport.calls, ['start']); + expect( + await controller.submit(credential: const MfaCodeCredential('123456')), + isA(), + ); + expect(controller.stepIndex, 1); + expect(controller.stepAttemptId, isNull); await controller.startStep(); - await expectLater( - controller.submit(code: '111111'), - throwsA(isA()), + expect(transport.openedSteps, [MfaMethod.biometric]); + expect(controller.stepAttemptId, 'second-attempt'); + expect(controller.challenge, 'step-challenge'); + + expect( + await controller.submit( + credential: const MfaBiometricCredential( + signature: 'signature', + authPubKey: 'auth-public-key', + ), + ), + isA(), ); + expect(controller.takePresharedKey(), 'psk'); + expect(controller.takePresharedKey(), isNull); }); - test('a rejected code is retried on the same attempt', () async { + test('a single step never calls step-start', () async { final transport = _FakeTransport( - finishAnswers: [ - _advanced(1), - const MfaCodeRejectedException(), - _completed(), - ], + startAnswers: [_session()], + finishAnswers: [const MfaFinishCompleted('psk')], ); - final controller = _controller(transport, [ - MfaMethod.totp, - MfaMethod.email, - ]); + final controller = _controller(transport, [MfaMethod.totp]); await controller.startStep(); - await controller.submit(code: '111111'); - await controller.startStep(); - - await expectLater( - controller.submit(code: 'wrong'), - throwsA(isA()), + expect( + await controller.submit(credential: const MfaCodeCredential('123456')), + isA(), ); - expect(await controller.submit(code: 'right'), isA()); - - expect(transport.calls, [ - 'start', - 'finish', - 'step-start:email', - 'finish', - 'finish', - ]); - expect(transport.finishes.last.stepAttemptId, 'attempt-1'); + expect(transport.calls, ['start', 'finish']); }); test( - 'an unresolved external factor is neither success nor failure', + 'OIDC polling stays pending until external completion on the same attempt', () async { final transport = _FakeTransport( + startAnswers: [_session()], finishAnswers: [ - const FinishMfaResponse(outcome: MfaAwaitingExternal()), - _completed(), + const MfaFinishAwaitingExternal(), + const MfaFinishCompleted('psk'), ], ); final controller = _controller(transport, [MfaMethod.openid]); @@ -222,143 +174,288 @@ void main() { expect(controller.takePresharedKey(), isNull); expect(await controller.submit(), isA()); expect(controller.takePresharedKey(), 'psk'); + expect(transport.stepAttemptIds, ['first-attempt', 'first-attempt']); + expect(transport.credentials, [null, null]); }, ); - test('a completion with no preshared key still completes', () async { - final transport = _FakeTransport(finishAnswers: [_completed(null)]); + test('a completion without a preshared key still completes', () async { + final transport = _FakeTransport( + startAnswers: [_session()], + finishAnswers: [const MfaFinishCompleted(null)], + ); final controller = _controller(transport, [MfaMethod.totp]); await controller.startStep(); - expect(await controller.submit(code: '123456'), isA()); + expect( + await controller.submit(credential: const MfaCodeCredential('123456')), + isA(), + ); expect(controller.takePresharedKey(), isNull); }); - test('the preshared key is handed over exactly once', () async { - final transport = _FakeTransport(finishAnswers: [_completed()]); - final controller = _controller(transport, [MfaMethod.totp]); + test('FIDO2 sends the raw assertion and authenticator rpId hash', () async { + final transport = _FakeTransport( + startAnswers: [ + _session( + attemptId: 'fido-attempt', + challenge: 'fido-challenge', + credentialIds: ['offered-key'], + ), + ], + finishAnswers: [const MfaFinishCompleted('psk')], + ); + final controller = _controller(transport, [MfaMethod.fido2]); await controller.startStep(); - await controller.submit(code: '123456'); - expect(controller.takePresharedKey(), 'psk'); - expect(controller.takePresharedKey(), isNull); - }); - - test('the challenge follows the step being opened', () async { - final transport = _FakeTransport(finishAnswers: [_advanced(1)]); - final controller = _controller(transport, [ - MfaMethod.totp, - MfaMethod.biometric, - ]); + expect(controller.credentialIds, ['offered-key']); + await controller.submitFido2( + signature: [0xfb, 0xff], + authData: List.generate(37, (index) => index), + credentialId: [7, 8], + ); - await controller.startStep(); - expect(controller.challenge, 'start-challenge'); - await controller.submit(code: '111111'); - expect(controller.challenge, isNull); - await controller.startStep(); - expect(controller.challenge, 'step-challenge'); + final assertion = transport.credentials.single as MfaFido2Credential; + expect(assertion.rpIdHash, List.generate(32, (index) => index)); + expect( + assertion.authenticatorData, + List.generate(37, (index) => index), + ); + expect(assertion.signature, [0xfb, 0xff]); + expect(assertion.credentialId, [7, 8]); + expect(transport.stepAttemptIds.single, 'fido-attempt'); }); - test('a FIDO2 first step exposes the offered keys', () async { - final transport = _FakeTransport(finishAnswers: [_completed()]); + test('malformed FIDO2 assertions are never submitted', () async { + final transport = _FakeTransport(startAnswers: [_session()]); final controller = _controller(transport, [MfaMethod.fido2]); - await controller.startStep(); - expect(controller.credentialIds, ['start-key']); + + expect( + () => controller.submitFido2( + signature: [], + authData: List.filled(37, 1), + credentialId: [1], + ), + throwsFormatException, + ); + expect( + () => controller.submitFido2( + signature: [1], + authData: List.filled(36, 1), + credentialId: [1], + ), + throwsFormatException, + ); + expect( + () => controller.submitFido2( + signature: [1], + authData: List.filled(37, 1), + credentialId: [], + ), + throwsFormatException, + ); + expect(transport.calls, ['start']); }); - test('the offered keys follow the step being opened', () async { - final transport = _FakeTransport(finishAnswers: [_advanced(1)]); - final controller = _controller(transport, [ - MfaMethod.totp, - MfaMethod.fido2, - ]); + test('a rejected start refreshes the plan and retries once', () async { + final transport = _FakeTransport( + startAnswers: [ + const MfaStartRejectedException('stale plan'), + _session(), + ], + ); + var refreshes = 0; + final controller = _controller( + transport, + [MfaMethod.totp], + refreshPlan: () async { + refreshes++; + return [MfaMethod.email]; + }, + ); await controller.startStep(); - expect(controller.credentialIds, isEmpty); - await controller.submit(code: '111111'); - await controller.startStep(); - expect(controller.credentialIds, ['step-key']); + expect(refreshes, 1); + expect(transport.calls, ['start', 'start']); + expect(transport.plans, [ + [MfaMethod.totp], + [MfaMethod.email], + ]); + expect(controller.plan, [MfaMethod.email]); + expect(controller.method, MfaMethod.email); }); - test('a FIDO2 proof carries the signature as unpadded base64url', () async { + test('a second rejection stops without a third start', () async { final transport = _FakeTransport( - finishAnswers: [_advanced(1), _completed()], + startAnswers: [ + const MfaStartRejectedException('stale plan'), + const MfaStartRejectedException('still stale'), + ], ); - final controller = _controller(transport, [ - MfaMethod.totp, - MfaMethod.fido2, - ]); - - await controller.startStep(); - await controller.submit(code: '111111'); - await controller.startStep(); - final progress = await controller.submitFido2( - signature: [0xfb, 0xff], - authData: [1, 2, 3], - credentialId: [7], + var refreshes = 0; + final controller = _controller( + transport, + [MfaMethod.totp], + refreshPlan: () async { + refreshes++; + return [MfaMethod.email]; + }, ); - expect(progress, isA()); - final finish = transport.finishes.last; - expect(finish.authPubKey, '-_8'); - expect(finish.code, isNull); - expect(finish.authData, [1, 2, 3]); - expect(finish.credentialId, [7]); - expect(finish.stepAttemptId, 'attempt-1'); + await expectLater( + controller.startStep(), + throwsA( + isA().having( + (error) => error.message, + 'message', + 'still stale', + ), + ), + ); + expect(refreshes, 1); + expect(transport.calls, ['start', 'start']); }); - test('cancelling is observable and submits nothing further', () async { - final transport = _FakeTransport(finishAnswers: [_advanced(1)]); - final controller = _controller(transport, [ - MfaMethod.totp, - MfaMethod.email, - ]); - - await controller.startStep(); - await controller.submit(code: '111111'); - controller.cancel(); - - expect(controller.isCancelled, isTrue); - expect(transport.calls, ['start', 'finish']); + test('a legacy transport refuses multi-step and FIDO2 starts', () async { + final transport = mfaTransportForContract( + MfaContract.legacy, + Uri.parse('https://proxy.example'), + ); + expect(transport, isA()); + await expectLater( + transport.start( + devicePubkey: 'device-pubkey', + networkId: 11, + plan: [MfaMethod.totp, MfaMethod.email], + ), + throwsUnsupportedError, + ); + await expectLater( + transport.start( + devicePubkey: 'device-pubkey', + networkId: 11, + plan: [MfaMethod.fido2], + ), + throwsUnsupportedError, + ); }); - test('submitting before the session exists is a programming error', () async { - final controller = _controller(_FakeTransport(), [MfaMethod.totp]); - await expectLater(controller.submit(code: '123456'), throwsStateError); + test('contract selection chooses one transport', () { + expect( + mfaTransportForContract( + MfaContract.multiStep, + Uri.parse('https://proxy.example'), + ), + isA(), + ); }); - test('a proof cannot be submitted into the gap between steps', () async { - final transport = _FakeTransport(finishAnswers: [_advanced(1)]); + test('the server selects the next step and opens its challenge', () async { + final transport = _FakeTransport( + startAnswers: [_session()], + stepAnswers: [ + _step( + attemptId: 'fido-attempt', + challenge: 'fido-challenge', + credentialIds: ['offered-key'], + ), + ], + finishAnswers: [ + const MfaFinishAdvanced(2), + const MfaFinishCompleted('psk'), + ], + ); final controller = _controller(transport, [ MfaMethod.totp, MfaMethod.email, + MfaMethod.fido2, ]); await controller.startStep(); - await controller.submit(code: '111111'); + expect( + await controller.submit(credential: const MfaCodeCredential('123456')), + isA(), + ); + expect(controller.stepIndex, 2); + await expectLater( + controller.submit(credential: const MfaCodeCredential('123456')), + throwsStateError, + ); - // The previous step's screen is still on top until the next one is pushed. - await expectLater(controller.submit(code: '111111'), throwsStateError); - expect(transport.calls, ['start', 'finish']); + await controller.startStep(); + expect(transport.openedSteps, [MfaMethod.fido2]); + expect(controller.challenge, 'fido-challenge'); + expect(controller.credentialIds, ['offered-key']); + await controller.submitFido2( + signature: [1], + authData: List.generate(37, (index) => index), + credentialId: [2], + ); + expect(transport.stepAttemptIds, ['first-attempt', 'fido-attempt']); + expect(controller.takePresharedKey(), 'psk'); + }); + test('an advance beyond the plan is rejected', () async { + final transport = _FakeTransport( + startAnswers: [_session()], + finishAnswers: [const MfaFinishAdvanced(1)], + ); + final controller = _controller(transport, [MfaMethod.totp]); await controller.startStep(); - expect(transport.calls.last, 'step-start:email'); + + await expectLater( + controller.submit(credential: const MfaCodeCredential('123456')), + throwsA(isA()), + ); + expect(controller.stepIndex, 0); + expect(transport.calls, ['start', 'finish']); }); - test('polling an open external step does not close it', () async { + test('a rejected proof can be retried on the same step attempt', () async { final transport = _FakeTransport( + startAnswers: [_session()], finishAnswers: [ - const FinishMfaResponse(outcome: MfaAwaitingExternal()), - const FinishMfaResponse(outcome: MfaAwaitingExternal()), - _completed(), + const MfaCodeRejectedException(), + const MfaFinishCompleted('psk'), ], ); - final controller = _controller(transport, [MfaMethod.openid]); + final controller = _controller(transport, [MfaMethod.totp]); + await controller.startStep(); + await expectLater( + controller.submit(credential: const MfaCodeCredential('bad-code')), + throwsA(isA()), + ); + expect(controller.stepAttemptId, 'first-attempt'); + expect(controller.stepIndex, 0); + await controller.submit(credential: const MfaCodeCredential('123456')); + expect(transport.stepAttemptIds, ['first-attempt', 'first-attempt']); + expect(controller.takePresharedKey(), 'psk'); + }); + + test('submitting before a step is open is a programming error', () async { + final controller = _controller(_FakeTransport(), [MfaMethod.totp]); + await expectLater( + controller.submit(credential: const MfaCodeCredential('123456')), + throwsStateError, + ); + }); + + test('a credential for another method never reaches the transport', () async { + final transport = _FakeTransport(startAnswers: [_session()]); + final controller = _controller(transport, [MfaMethod.totp]); await controller.startStep(); - expect(await controller.submit(), isA()); - expect(await controller.submit(), isA()); - expect(await controller.submit(), isA()); + + await expectLater( + controller.submit( + credential: const MfaBiometricCredential( + signature: 'signature', + authPubKey: 'auth-public-key', + ), + ), + throwsStateError, + ); + expect(transport.calls, ['start']); }); } diff --git a/client/test/mfa/mfa_openid_uri_test.dart b/client/test/mfa/mfa_openid_uri_test.dart new file mode 100644 index 0000000..b3866eb --- /dev/null +++ b/client/test/mfa/mfa_openid_uri_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mobile/enterprise/screens/mfa/openid_mfa_screen.dart'; + +void main() { + test( + 'flow callback includes the current attempt ID as a query parameter', + () { + final uri = buildOpenIdMfaUri( + proxyUrl: 'https://proxy.example/base/', + token: 'session+token', + stepAttemptId: 'attempt/1', + ); + + expect(uri.path, '/base/openid/mfa'); + expect(uri.queryParameters, { + 'token': 'session+token', + 'step_attempt_id': 'attempt/1', + }); + }, + ); + + test('legacy callback does not add a flow-only attempt ID', () { + final uri = buildOpenIdMfaUri( + proxyUrl: 'https://proxy.example/base/', + token: 'legacy-token', + ); + + expect(uri.path, '/base/openid/mfa'); + expect(uri.queryParameters, {'token': 'legacy-token'}); + }); +} diff --git a/client/test/mfa/mfa_plan_test.dart b/client/test/mfa/mfa_plan_test.dart index 872db20..f528f49 100644 --- a/client/test/mfa/mfa_plan_test.dart +++ b/client/test/mfa/mfa_plan_test.dart @@ -176,26 +176,41 @@ void main() { expect( mfaMethodAvailability( _entry(MfaMethod.totp), - biometricAvailable: false, + capabilities: MfaCapabilities( + biometricAvailable: false, + contract: MfaContract.legacy, + ), ), MfaMethodAvailability.usable, ); expect( mfaMethodAvailability( _entry(MfaMethod.email, configured: false), - biometricAvailable: true, + capabilities: MfaCapabilities( + biometricAvailable: true, + contract: MfaContract.legacy, + ), ), MfaMethodAvailability.notConfigured, ); expect( mfaMethodAvailability( _entry(MfaMethod.biometric), - biometricAvailable: false, + capabilities: MfaCapabilities( + biometricAvailable: false, + contract: MfaContract.legacy, + ), ), MfaMethodAvailability.biometryUnavailable, ); expect( - mfaMethodAvailability(_unsupported, biometricAvailable: true), + mfaMethodAvailability( + _unsupported, + capabilities: MfaCapabilities( + biometricAvailable: true, + contract: MfaContract.legacy, + ), + ), MfaMethodAvailability.unsupported, ); }); @@ -204,7 +219,10 @@ void main() { expect( mfaMethodAvailability( _entry(MfaMethod.biometric, configured: false), - biometricAvailable: false, + capabilities: MfaCapabilities( + biometricAvailable: false, + contract: MfaContract.legacy, + ), ), MfaMethodAvailability.notConfigured, ); @@ -220,30 +238,66 @@ void main() { _unsupported, ]); expect( - usableMfaMethods(step, biometricAvailable: false).map((e) => e.method), + usableMfaMethods( + step, + capabilities: MfaCapabilities( + biometricAvailable: false, + contract: MfaContract.legacy, + ), + ).map((e) => e.method), [MfaMethod.totp], ); expect( - usableMfaMethods(step, biometricAvailable: true).map((e) => e.method), + usableMfaMethods( + step, + capabilities: MfaCapabilities( + biometricAvailable: true, + contract: MfaContract.legacy, + ), + ).map((e) => e.method), [MfaMethod.totp, MfaMethod.biometric], ); }); }); group('pickableMfaMethods', () { + const legacy = MfaCapabilities( + biometricAvailable: true, + contract: MfaContract.legacy, + ); + const multiStep = MfaCapabilities( + biometricAvailable: true, + contract: MfaContract.multiStep, + ); + test('hides factors this client cannot perform', () { final step = _step([_entry(MfaMethod.totp), _unsupported]); - expect(pickableMfaMethods(step).map((e) => e.method), [MfaMethod.totp]); + expect( + pickableMfaMethods(step, capabilities: legacy).map((e) => e.method), + [MfaMethod.totp], + ); }); test('keeps unconfigured methods so the user can see them', () { final step = _step([_entry(MfaMethod.email, configured: false)]); - expect(pickableMfaMethods(step), hasLength(1)); + expect(pickableMfaMethods(step, capabilities: legacy), hasLength(1)); }); test('falls back to the raw list when nothing is supported', () { final step = _step([_unsupported]); - expect(pickableMfaMethods(step), [_unsupported]); + expect(pickableMfaMethods(step, capabilities: legacy), [_unsupported]); + }); + + test('offers FIDO2 only on the multi-step contract', () { + final step = _step([_entry(MfaMethod.totp), _entry(MfaMethod.fido2)]); + expect( + pickableMfaMethods(step, capabilities: legacy).map((e) => e.method), + [MfaMethod.totp], + ); + expect( + pickableMfaMethods(step, capabilities: multiStep).map((e) => e.method), + [MfaMethod.totp, MfaMethod.fido2], + ); }); }); @@ -258,7 +312,10 @@ void main() { resolveMfaStepPlan( _location(steps: steps, plan: [MfaMethod.totp, MfaMethod.email]), oneOff: [MfaMethod.email], - biometricAvailable: true, + capabilities: MfaCapabilities( + biometricAvailable: true, + contract: MfaContract.multiStep, + ), ), [MfaMethod.email, MfaMethod.email], ); @@ -268,7 +325,10 @@ void main() { expect( resolveMfaStepPlan( _location(steps: steps, plan: [MfaMethod.email, MfaMethod.biometric]), - biometricAvailable: true, + capabilities: MfaCapabilities( + biometricAvailable: true, + contract: MfaContract.multiStep, + ), ), [MfaMethod.email, MfaMethod.biometric], ); @@ -276,7 +336,13 @@ void main() { test('then the first usable method', () { expect( - resolveMfaStepPlan(_location(steps: steps), biometricAvailable: true), + resolveMfaStepPlan( + _location(steps: steps), + capabilities: MfaCapabilities( + biometricAvailable: true, + contract: MfaContract.multiStep, + ), + ), [MfaMethod.totp, MfaMethod.email], ); }); @@ -286,7 +352,10 @@ void main() { resolveMfaStepPlan( _location(steps: steps, plan: [MfaMethod.biometric, null]), oneOff: [MfaMethod.openid], - biometricAvailable: true, + capabilities: MfaCapabilities( + biometricAvailable: true, + contract: MfaContract.multiStep, + ), ), [MfaMethod.totp, MfaMethod.email], ); @@ -296,7 +365,10 @@ void main() { expect( resolveMfaStepPlan( _location(steps: steps, plan: [MfaMethod.totp, MfaMethod.biometric]), - biometricAvailable: false, + capabilities: MfaCapabilities( + biometricAvailable: false, + contract: MfaContract.multiStep, + ), ), [MfaMethod.totp, MfaMethod.email], ); @@ -310,7 +382,10 @@ void main() { _step([_unsupported]), ], ), - biometricAvailable: true, + capabilities: MfaCapabilities( + biometricAvailable: true, + contract: MfaContract.legacy, + ), ), [null], ); @@ -320,7 +395,10 @@ void main() { expect( resolveMfaStepPlan( _location(steps: steps, plan: [MfaMethod.email]), - biometricAvailable: true, + capabilities: MfaCapabilities( + biometricAvailable: true, + contract: MfaContract.multiStep, + ), ), [MfaMethod.email, MfaMethod.email], ); @@ -333,7 +411,10 @@ void main() { steps: steps.take(1).toList(), plan: [MfaMethod.email, MfaMethod.biometric, MfaMethod.totp], ), - biometricAvailable: true, + capabilities: MfaCapabilities( + biometricAvailable: true, + contract: MfaContract.legacy, + ), ), [MfaMethod.email], ); @@ -341,22 +422,79 @@ void main() { test('resolves a legacy location the way the old sheet did', () { final legacy = _location(mode: LocationMfaMode.internal); - expect(resolveMfaStepPlan(legacy, biometricAvailable: true), [ - MfaMethod.biometric, - ]); - expect(resolveMfaStepPlan(legacy, biometricAvailable: false), [ - MfaMethod.totp, - ]); + expect( + resolveMfaStepPlan( + legacy, + capabilities: MfaCapabilities( + biometricAvailable: true, + contract: MfaContract.legacy, + ), + ), + [ + MfaMethod.biometric, + ], + ); + expect( + resolveMfaStepPlan( + legacy, + capabilities: MfaCapabilities( + biometricAvailable: false, + contract: MfaContract.legacy, + ), + ), + [ + MfaMethod.totp, + ], + ); expect( resolveMfaStepPlan( _location(mode: LocationMfaMode.internal, plan: [MfaMethod.email]), - biometricAvailable: true, + capabilities: MfaCapabilities( + biometricAvailable: true, + contract: MfaContract.legacy, + ), ), [MfaMethod.email], ); }); }); + group('resolveMfaRetryPlan', () { + final location = _location( + steps: [ + _step([_entry(MfaMethod.totp)]), + ], + ); + + test('fails closed when refresh changes the contract', () { + expect( + resolveMfaRetryPlan( + location, + attempt: MfaCapabilities( + biometricAvailable: true, + contract: MfaContract.multiStep, + ), + refreshedContract: MfaContract.legacy, + ), + isNull, + ); + }); + + test('resolves a plan when the contract remains unchanged', () { + expect( + resolveMfaRetryPlan( + location, + attempt: MfaCapabilities( + biometricAvailable: true, + contract: MfaContract.multiStep, + ), + refreshedContract: MfaContract.multiStep, + ), + [MfaMethod.totp], + ); + }); + }); + group('unpassable steps', () { test('a flow with a usable method for every step is passable', () { final location = _location( @@ -366,10 +504,67 @@ void main() { ], ); expect( - hasUnpassableMfaStep(location, biometricAvailable: false), + hasUnpassableMfaStep( + location, + capabilities: MfaCapabilities( + biometricAvailable: false, + contract: MfaContract.multiStep, + ), + ), isFalse, ); - expect(unpassableStepReason(location, biometricAvailable: false), isNull); + expect( + unpassableStepReason( + location, + capabilities: MfaCapabilities( + biometricAvailable: false, + contract: MfaContract.multiStep, + ), + ), + isNull, + ); + }); + + test('legacy cannot run a multi-step location', () { + final location = _location( + steps: [ + _step([_entry(MfaMethod.totp)]), + _step([_entry(MfaMethod.email)]), + ], + ); + expect( + resolveMfaStepPlan( + location, + capabilities: MfaCapabilities( + biometricAvailable: true, + contract: MfaContract.legacy, + ), + ), + [ + null, + null, + ], + ); + expect( + hasUnpassableMfaStep( + location, + capabilities: MfaCapabilities( + biometricAvailable: true, + contract: MfaContract.legacy, + ), + ), + isTrue, + ); + expect( + unpassableStepReason( + location, + capabilities: MfaCapabilities( + biometricAvailable: true, + contract: MfaContract.legacy, + ), + ), + MfaUnpassableReason.desktopOnly, + ); }); test('points at biometry setup when that is the fixable cause', () { @@ -378,12 +573,36 @@ void main() { _step([_entry(MfaMethod.biometric)]), ], ); - expect(hasUnpassableMfaStep(location, biometricAvailable: false), isTrue); expect( - unpassableStepReason(location, biometricAvailable: false), + hasUnpassableMfaStep( + location, + capabilities: MfaCapabilities( + biometricAvailable: false, + contract: MfaContract.legacy, + ), + ), + isTrue, + ); + expect( + unpassableStepReason( + location, + capabilities: MfaCapabilities( + biometricAvailable: false, + contract: MfaContract.legacy, + ), + ), MfaUnpassableReason.setUpBiometry, ); - expect(hasUnpassableMfaStep(location, biometricAvailable: true), isFalse); + expect( + hasUnpassableMfaStep( + location, + capabilities: MfaCapabilities( + biometricAvailable: true, + contract: MfaContract.legacy, + ), + ), + isFalse, + ); }); test('reports server-side setup when the method is unconfigured', () { @@ -393,7 +612,13 @@ void main() { ], ); expect( - unpassableStepReason(location, biometricAvailable: true), + unpassableStepReason( + location, + capabilities: MfaCapabilities( + biometricAvailable: true, + contract: MfaContract.legacy, + ), + ), MfaUnpassableReason.notConfigured, ); }); @@ -405,7 +630,13 @@ void main() { ], ); expect( - unpassableStepReason(location, biometricAvailable: true), + unpassableStepReason( + location, + capabilities: MfaCapabilities( + biometricAvailable: true, + contract: MfaContract.legacy, + ), + ), MfaUnpassableReason.desktopOnly, ); }); @@ -421,7 +652,13 @@ void main() { ], ); expect( - unpassableStepReason(location, biometricAvailable: false), + unpassableStepReason( + location, + capabilities: MfaCapabilities( + biometricAvailable: false, + contract: MfaContract.legacy, + ), + ), MfaUnpassableReason.setUpBiometry, ); }); @@ -442,7 +679,13 @@ void main() { test('is usable on Android', () { debugDefaultTargetPlatformOverride = TargetPlatform.android; expect( - mfaMethodAvailability(fido2, biometricAvailable: false), + mfaMethodAvailability( + fido2, + capabilities: MfaCapabilities( + biometricAvailable: false, + contract: MfaContract.multiStep, + ), + ), MfaMethodAvailability.usable, ); expect( @@ -452,7 +695,10 @@ void main() { _step([fido2]), ], ), - biometricAvailable: false, + capabilities: MfaCapabilities( + biometricAvailable: false, + contract: MfaContract.multiStep, + ), ), [MfaMethod.fido2], ); @@ -461,7 +707,13 @@ void main() { test('is usable on iOS', () { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; expect( - mfaMethodAvailability(fido2, biometricAvailable: false), + mfaMethodAvailability( + fido2, + capabilities: MfaCapabilities( + biometricAvailable: false, + contract: MfaContract.multiStep, + ), + ), MfaMethodAvailability.usable, ); expect( @@ -471,12 +723,38 @@ void main() { _step([fido2]), ], ), - biometricAvailable: false, + capabilities: MfaCapabilities( + biometricAvailable: false, + contract: MfaContract.multiStep, + ), ), [MfaMethod.fido2], ); }); + test('is unavailable on the legacy contract', () { + expect( + mfaMethodAvailability( + fido2, + capabilities: MfaCapabilities( + biometricAvailable: true, + contract: MfaContract.legacy, + ), + ), + MfaMethodAvailability.unsupported, + ); + expect( + usableMfaMethods( + _step([fido2]), + capabilities: MfaCapabilities( + biometricAvailable: true, + contract: MfaContract.legacy, + ), + ), + isEmpty, + ); + }); + test('is not usable before a key is registered', () { debugDefaultTargetPlatformOverride = TargetPlatform.android; expect( @@ -485,7 +763,10 @@ void main() { apiMethod: ApiMfaMethod.fido2, configured: false, ), - biometricAvailable: true, + capabilities: MfaCapabilities( + biometricAvailable: true, + contract: MfaContract.multiStep, + ), ), MfaMethodAvailability.notConfigured, ); diff --git a/client/test/mfa/mfa_retry_payload_test.dart b/client/test/mfa/mfa_retry_payload_test.dart new file mode 100644 index 0000000..8b1454c --- /dev/null +++ b/client/test/mfa/mfa_retry_payload_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mobile/data/db/enums.dart'; +import 'package:mobile/data/plugin/plugin.dart'; +import 'package:mobile/open/screens/instance/services/tunnel_service.dart'; + +PluginConnectPayload _payload({ + String devicePublicKey = 'device-key', + int networkId = 7, + bool postureCheckRequired = false, + String endpoint = 'old.example:51820', +}) => PluginConnectPayload( + publicKey: 'location-key', + devicePublicKey: devicePublicKey, + privateKey: 'private-key', + address: '10.0.0.2/32', + endpoint: endpoint, + allowedIps: '10.0.0.0/24', + keepalive: 25, + locationName: 'office', + locationId: 1, + instanceId: 1, + traffic: RoutingMethod.predefined, + networkId: networkId, + postureCheckRequired: postureCheckRequired, +); + +void main() { + test('a retry reuses the attempt when only tunnel settings changed', () { + expect( + TunnelService.canReuseMfaAttempt( + _payload(), + _payload(endpoint: 'new.example:51820'), + ), + isTrue, + ); + }); + + test('a retry abandons the attempt when its binding changed', () { + for (final refreshed in [ + _payload(networkId: 8), + _payload(devicePublicKey: 'other-key'), + _payload(postureCheckRequired: true), + ]) { + expect(TunnelService.canReuseMfaAttempt(_payload(), refreshed), isFalse); + } + }); +} diff --git a/client/test/mfa/mfa_step_flow_test.dart b/client/test/mfa/mfa_step_flow_test.dart index 89c1c8b..b75508f 100644 --- a/client/test/mfa/mfa_step_flow_test.dart +++ b/client/test/mfa/mfa_step_flow_test.dart @@ -2,28 +2,39 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:material_ui/material_ui.dart'; import 'package:mobile/data/db/enums.dart'; import 'package:mobile/data/mfa/mfa_flow.dart'; -import 'package:mobile/data/proxy/mfa.dart'; +import 'package:mobile/data/mfa/mfa_transport.dart'; +import 'package:mobile/enterprise/postures.dart'; import 'package:mobile/open/screens/mfa/mfa_step_chrome.dart'; import 'package:mobile/open/screens/mfa/mfa_step_flow.dart'; class _StubTransport implements MfaTransport { - final List answers; + final List answers; int _finishes = 0; int _attempts = 0; _StubTransport(this.answers); @override - Future start(StartMfaRequest request) async => - const StartMfaResponse(token: 'token', challenge: null, rejections: []); + Future start({ + required String devicePubkey, + required int networkId, + required List plan, + DevicePostureData? postureData, + }) async => MfaSessionStart( + token: 'token', + firstStep: const MfaStepChallenge(stepAttemptId: 'first-attempt'), + ); @override - Future stepStart(StepStartMfaRequest request) async => - StepStartMfaResponse(stepAttemptId: 'attempt-${++_attempts}'); + Future startStep(String token, MfaMethod method) async => + MfaStepChallenge(stepAttemptId: 'attempt-${++_attempts}'); @override - Future finish(FinishMfaRequest request) async => - answers[_finishes++]; + Future finish({ + required String token, + required String? stepAttemptId, + required MfaCredential? credential, + }) async => answers[_finishes++]; } /// Stands in for a real step screen: shows which step it is and nothing else. @@ -43,8 +54,6 @@ class _StubStep extends StatelessWidget { const String _sheetRoute = 'connect-sheet'; -/// Counts the stacked step routes. Routes below the top one stay in the tree -/// offstage, so a step left behind still shows up here. int _stepRouteCount() => find.byType(_StubStep, skipOffstage: false).evaluate().length; @@ -54,7 +63,7 @@ void main() { Future pumpFlow( WidgetTester tester, { required List plan, - required List answers, + required List answers, }) async { final key = GlobalKey(); await tester.pumpWidget( @@ -88,7 +97,7 @@ void main() { final flow = await pumpFlow( tester, plan: [MfaMethod.totp], - answers: [const FinishMfaResponse(outcome: MfaCompleted('psk'))], + answers: [const MfaFinishCompleted('psk')], ); final result = flow.run(); @@ -96,7 +105,11 @@ void main() { expect(find.text('step 0'), findsOneWidget); expect(_stepRouteCount(), 1); - flow.reportProgress(await flow.controller.submit(code: '123456')); + flow.reportProgress( + await flow.controller.submit( + credential: const MfaCodeCredential('123456'), + ), + ); await tester.pumpAndSettle(); expect(await result, isA()); @@ -112,26 +125,38 @@ void main() { tester, plan: [MfaMethod.totp, MfaMethod.email, MfaMethod.totp], answers: [ - const FinishMfaResponse(outcome: MfaAdvanced(1)), - const FinishMfaResponse(outcome: MfaAdvanced(2)), - const FinishMfaResponse(outcome: MfaCompleted('psk')), + const MfaFinishAdvanced(1), + const MfaFinishAdvanced(2), + const MfaFinishCompleted('psk'), ], ); final result = flow.run(); await tester.pumpAndSettle(); - flow.reportProgress(await flow.controller.submit(code: '111111')); + flow.reportProgress( + await flow.controller.submit( + credential: const MfaCodeCredential('111111'), + ), + ); await tester.pumpAndSettle(); expect(find.text('step 1'), findsOneWidget); expect(_stepRouteCount(), 2); - flow.reportProgress(await flow.controller.submit(code: '222222')); + flow.reportProgress( + await flow.controller.submit( + credential: const MfaCodeCredential('222222'), + ), + ); await tester.pumpAndSettle(); expect(find.text('step 2'), findsOneWidget); expect(_stepRouteCount(), 3); - flow.reportProgress(await flow.controller.submit(code: '333333')); + flow.reportProgress( + await flow.controller.submit( + credential: const MfaCodeCredential('333333'), + ), + ); await tester.pumpAndSettle(); expect(await result, isA()); @@ -143,12 +168,16 @@ void main() { final flow = await pumpFlow( tester, plan: [MfaMethod.totp, MfaMethod.email], - answers: [const FinishMfaResponse(outcome: MfaAdvanced(1))], + answers: [const MfaFinishAdvanced(1)], ); final result = flow.run(); await tester.pumpAndSettle(); - flow.reportProgress(await flow.controller.submit(code: '111111')); + flow.reportProgress( + await flow.controller.submit( + credential: const MfaCodeCredential('111111'), + ), + ); await tester.pumpAndSettle(); expect(_stepRouteCount(), 2); @@ -183,8 +212,8 @@ void main() { ) async { final flow = await pumpFlow( tester, - plan: [MfaMethod.totp], - answers: [const FinishMfaResponse(outcome: MfaAwaitingExternal())], + plan: [MfaMethod.openid], + answers: [const MfaFinishAwaitingExternal()], ); flow.run(); diff --git a/client/test/mfa/mfa_steps_test.dart b/client/test/mfa/mfa_steps_test.dart index e61839d..b9ce5a4 100644 --- a/client/test/mfa/mfa_steps_test.dart +++ b/client/test/mfa/mfa_steps_test.dart @@ -63,6 +63,12 @@ void main() { }); group('MfaStep parsing', () { + test('reads future method values as an explicit unknown', () { + final entry = MfaStepMethod.fromJson({'method': 99, 'configured': true}); + expect(entry.apiMethod, ApiMfaMethod.unknown); + expect(entry.method, isNull); + }); + test('keeps the raw method and marks it unsupported', () { final entry = MfaStepMethod.fromJson({'method': 4, 'configured': true}); expect(entry.apiMethod, ApiMfaMethod.mobileApprove); diff --git a/client/test/mfa/mfa_wire_test.dart b/client/test/mfa/mfa_wire_test.dart index 81289ec..e94bb33 100644 --- a/client/test/mfa/mfa_wire_test.dart +++ b/client/test/mfa/mfa_wire_test.dart @@ -1,276 +1,236 @@ +import 'dart:convert'; + import 'package:flutter_test/flutter_test.dart'; import 'package:mobile/data/db/enums.dart'; import 'package:mobile/data/proxy/mfa.dart'; +import 'package:mobile/data/proxy/mfa_flow.dart' as flow; + +// Captured from the proxy's generated Prost types with serde_json::to_string. +const _accepted = + r'{"outcome":{"Accepted":{"token":"flow-token","first_step":{"step_attempt_id":"attempt-id","challenge":{"Signature":{"challenge":"challenge"}}}}}}'; +const _rejected = + r'{"outcome":{"Rejected":{"rejections":[{"step":0,"reason":1}]}}}'; +const _fido2Step = + r'{"started":{"step_attempt_id":"attempt-id","challenge":{"Fido2":{"challenge":"fido-challenge","credential_ids":["a2V5LWE"]}}}}'; +const _advanced = r'{"result":{"outcome":{"Advanced":{"next_step":1}}}}'; +const _completed = + r'{"result":{"outcome":{"Completed":{"preshared_key":"psk"}}}}'; +const _awaiting = r'{"result":{"outcome":{"AwaitingExternal":{}}}}'; +const _legacyStartRequest = + r'{"location_id":11,"pubkey":"device-pubkey","method":0,"posture_data":null}'; +const _legacyFinishRequest = + r'{"token":"legacy-token","code":"signature","auth_pub_key":"auth-public-key"}'; +const _flowStartRequest = + r'{"location_id":11,"pubkey":"device-pubkey","posture_data":null,"selected_methods":[0,1]}'; +const _flowStepStartRequest = r'{"token":"flow-token","method":1}'; +const _flowCodeFinishRequest = + r'{"token":"flow-token","step_attempt_id":"attempt-id","submission":{"Code":{"code":"123456"}}}'; +const _flowBiometricFinishRequest = + r'{"token":"flow-token","step_attempt_id":"attempt-id","submission":{"Biometric":{"signature":"signature"}}}'; +const _flowFido2FinishRequest = + r'{"token":"flow-token","step_attempt_id":"attempt-id","submission":{"Fido2":{"rp_id_hash":[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31],"authenticator_data":[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33],"signature":[5,6],"credential_id":[7,8]}}}'; +const _legacyStart = r'{"token":"legacy-token","challenge":null}'; +const _legacyFinish = r'{"preshared_key":"legacy-psk","token":null}'; + +Map _fixture(String value) => + jsonDecode(value) as Map; void main() { - group('StartMfaRequest', () { - test('sends the plan and the legacy method side by side', () { + group('legacy DTOs', () { + test('start request keeps the v1.7.0 shape', () { const request = StartMfaRequest( pubkey: 'device-pubkey', locationId: 11, method: MfaMethod.totp, - selectedMethods: [MfaMethod.totp, MfaMethod.email], ); - expect(request.toJson(), { - 'pubkey': 'device-pubkey', - 'location_id': 11, - 'method': 0, - 'posture_data': null, - 'selected_methods': [0, 1], - }); + expect(request.toJson(), _fixture(_legacyStartRequest)); }); - }); - group('StartMfaResponse', () { - test('reads a pre-2.2 response as having no rejections', () { - final response = StartMfaResponse.fromJson({ - 'token': 'session-token', - 'challenge': null, - }); - expect(response.token, 'session-token'); - expect(response.rejections, isEmpty); - expect(response.credentialIds, isEmpty); - }); - - test('reads the security keys offered for a FIDO2 first step', () { - final response = StartMfaResponse.fromJson({ - 'token': 'session-token', - 'challenge': 'challenge', - 'credential_ids': ['a2V5LWE'], - }); - expect(response.credentialIds, ['a2V5LWE']); - }); - - test('reads rejections and numbers the steps for people', () { - final response = StartMfaResponse.fromJson({ - 'token': 'session-token', - 'challenge': null, - 'rejections': [ - {'step': 0, 'reason': 1}, - {'step': 1, 'reason': 2}, - {'step': 2, 'reason': 3}, - {'step': 3, 'reason': 0}, - ], - }); + test('finish request keeps nullable legacy fields', () { expect( - MfaRejectedException(response.rejections).message, - [ - "The method chosen for verification step 1 is not allowed. The " - "location's MFA settings have changed, so pick a method again.", - "Verification step 2 has no method available on this server. Contact " - "your administrator.", - "The method chosen for verification step 3 cannot be used. Set it up " - "first, or pick a different one.", - "The server rejected verification step 4.", - ].join(' '), + const FinishMfaRequest(token: 'legacy-token').toJson(), + {'token': 'legacy-token', 'code': null, 'auth_pub_key': null}, + ); + expect( + const FinishMfaRequest( + token: 'legacy-token', + code: 'signature', + authPubKey: 'auth-public-key', + ).toJson(), + _fixture(_legacyFinishRequest), ); }); - test('reads a reason added after 2.2 as unspecified', () { - final response = StartMfaResponse.fromJson({ - 'token': 'session-token', - 'challenge': null, - 'rejections': [ - {'step': 0, 'reason': 99}, - ], - }); + test('start and finish responses retain their released fields', () { + expect( + const StartMfaResponse(token: 'legacy-token', challenge: null).toJson(), + {'token': 'legacy-token', 'challenge': null}, + ); + expect( + StartMfaResponse.fromJson(_fixture(_legacyStart)).token, + 'legacy-token', + ); + expect( + const FinishMfaResponse(presharedKey: 'psk').toJson(), + {'preshared_key': 'psk'}, + ); expect( - response.rejections.single.reason, - MfaStartRejectionReason.unspecified, + FinishMfaResponse.fromJson(_fixture(_legacyFinish)).presharedKey, + 'legacy-psk', ); }); }); - group('FinishMfaRequest', () { - test('omits the attempt id on the legacy path', () { - const request = FinishMfaRequest(token: 'session-token', code: '123456'); - expect(request.toJson().containsKey('step_attempt_id'), isFalse); + group('flow start', () { + test('serializes selected methods separately from the legacy request', () { + const request = flow.MfaFlowStartRequest( + locationId: 11, + pubkey: 'device-pubkey', + selectedMethods: [MfaMethod.totp, MfaMethod.email], + ); + expect(request.toJson(), _fixture(_flowStartRequest)); }); - test('sends the attempt id when there is one', () { - const request = FinishMfaRequest( - token: 'session-token', - code: '123456', - stepAttemptId: 'attempt-1', + test('reads the accepted response and first step', () { + final response = flow.MfaFlowStartResponse.fromJson(_fixture(_accepted)); + final accepted = response.outcome as flow.MfaFlowAccepted; + expect(accepted.token, 'flow-token'); + expect(accepted.firstStep.stepAttemptId, 'attempt-id'); + expect( + (accepted.firstStep.challenge as flow.MfaSignatureChallenge).challenge, + 'challenge', ); - expect(request.toJson()['step_attempt_id'], 'attempt-1'); }); - test('omits the FIDO2 fields for a code proof', () { - final json = const FinishMfaRequest( - token: 'session-token', - code: '123456', - ).toJson(); - expect(json.containsKey('auth_data'), isFalse); - expect(json.containsKey('credential_id'), isFalse); + test('reads start rejections and unknown reasons safely', () { + final response = flow.MfaFlowStartResponse.fromJson(_fixture(_rejected)); + final rejected = response.outcome as flow.MfaFlowRejected; + expect( + rejected.rejections.single.reason, + flow.MfaStartRejectionReason.methodNotInStep, + ); + expect(rejected.rejections.single.message, contains('step 1')); + final unknown = + flow.MfaFlowStartResponse.fromJson({ + 'outcome': { + 'Rejected': { + 'rejections': [ + {'step': 1, 'reason': 99}, + ], + }, + }, + }).outcome + as flow.MfaFlowRejected; + expect( + unknown.rejections.single.reason, + flow.MfaStartRejectionReason.unspecified, + ); }); - test('sends FIDO2 bytes as number arrays, as prost serde reads them', () { - const request = FinishMfaRequest( - token: 'session-token', - authPubKey: 'c2ln', - authData: [1, 2, 255], - credentialId: [9, 8], - stepAttemptId: 'attempt-1', - ); - expect(request.toJson(), { - 'token': 'session-token', - 'code': null, - 'auth_pub_key': 'c2ln', - 'step_attempt_id': 'attempt-1', - 'auth_data': [1, 2, 255], - 'credential_id': [9, 8], + test('keeps an unknown start variant explicit', () { + final response = flow.MfaFlowStartResponse.fromJson({ + 'outcome': {'FutureVariant': {}}, }); + expect(response.outcome, isA()); }); }); - group('StepStartMfa', () { - test('reads the attempt id and challenge', () { - final response = StepStartMfaResponse.fromJson({ - 'step_attempt_id': 'attempt-1', - 'challenge': 'challenge', - }); - expect(response.stepAttemptId, 'attempt-1'); - expect(response.challenge, 'challenge'); - expect(response.credentialIds, isEmpty); + group('flow step start', () { + test('serializes the selected method', () { + const request = flow.MfaFlowStepStartRequest( + token: 'flow-token', + method: MfaMethod.email, + ); + expect(request.toJson(), _fixture(_flowStepStartRequest)); }); - test('reads the security keys offered for a FIDO2 step', () { - final response = StepStartMfaResponse.fromJson({ - 'step_attempt_id': 'attempt-1', - 'challenge': 'challenge', - 'credential_ids': ['a2V5LWE', 'a2V5LWI'], - }); - expect(response.credentialIds, ['a2V5LWE', 'a2V5LWI']); + test('reads FIDO2 challenges and credentials', () { + final response = flow.MfaFlowStepStartResponse.fromJson( + _fixture(_fido2Step), + ); + final started = response.started!; + expect(started.stepAttemptId, 'attempt-id'); + final challenge = started.challenge as flow.MfaFido2Challenge; + expect(challenge.challenge, 'fido-challenge'); + expect(challenge.credentialIds, ['a2V5LWE']); + }); + + test('keeps unknown challenge variants explicit', () { + final started = flow.MfaFlowStepStartResponse.fromJson({ + 'started': { + 'step_attempt_id': 'attempt-2', + 'challenge': {'FutureChallenge': {}}, + }, + }).started!; + expect(started.challenge, isA()); }); + }); - test('sends the token and the step method', () { + group('flow step finish', () { + test('serializes typed submissions to proxy fixtures', () { expect( - const StepStartMfaRequest( - token: 'session-token', - method: MfaMethod.biometric, + const flow.MfaFlowStepFinishRequest( + token: 'flow-token', + stepAttemptId: 'attempt-id', + submission: flow.MfaFlowCodeSubmission('123456'), ).toJson(), - {'token': 'session-token', 'method': 3}, + _fixture(_flowCodeFinishRequest), + ); + expect( + const flow.MfaFlowStepFinishRequest( + token: 'flow-token', + stepAttemptId: 'attempt-id', + submission: flow.MfaFlowBiometricSubmission('signature'), + ).toJson(), + _fixture(_flowBiometricFinishRequest), + ); + final fido2 = flow.MfaFlowStepFinishRequest( + token: 'flow-token', + stepAttemptId: 'attempt-id', + submission: flow.MfaFlowFido2Submission( + rpIdHash: List.generate(32, (index) => index), + authenticatorData: List.generate(34, (index) => index), + signature: [5, 6], + credentialId: [7, 8], + ), + ); + expect(fido2.toJson(), _fixture(_flowFido2FinishRequest)); + expect( + const flow.MfaFlowStepFinishRequest( + token: 'flow-token', + stepAttemptId: 'attempt-id', + ).toJson()['submission'], + isNull, ); - }); - }); - - group('FinishMfaResponse', () { - test('reads a pre-2.2 completion', () { - final response = FinishMfaResponse.fromJson({'preshared_key': 'psk'}); - expect(response.presharedKey, 'psk'); - expect(response.outcome, isNull); }); - test('reads an advance', () { - final response = FinishMfaResponse.fromJson({ - 'preshared_key': '', - 'result': { - 'outcome': { - 'Advanced': {'next_step': 1}, - }, - }, - }); - expect((response.outcome as MfaAdvanced).nextStep, 1); - }); + test('reads each known result outcome', () { + final advanced = flow.MfaFlowStepFinishResponse.fromJson( + _fixture(_advanced), + ).result; + expect((advanced as flow.MfaFlowAdvanced).nextStep, 1); - test('reads a completion', () { - final response = FinishMfaResponse.fromJson({ - 'preshared_key': '', - 'result': { - 'outcome': { - 'Completed': {'preshared_key': 'psk'}, - }, - }, - }); - expect((response.outcome as MfaCompleted).presharedKey, 'psk'); - }); + final completed = flow.MfaFlowStepFinishResponse.fromJson( + _fixture(_completed), + ).result; + expect((completed as flow.MfaFlowCompleted).presharedKey, 'psk'); - test('reads a completion for a peer with no preshared key', () { - final response = FinishMfaResponse.fromJson({ - 'result': { - 'outcome': {'Completed': {}}, - }, - }); - expect((response.outcome as MfaCompleted).presharedKey, isNull); + final awaiting = flow.MfaFlowStepFinishResponse.fromJson( + _fixture(_awaiting), + ).result; + expect(awaiting, isA()); }); - test('reads an unresolved external factor', () { - final response = FinishMfaResponse.fromJson({ + test('keeps unknown outcomes explicit without retaining the body', () { + final result = flow.MfaFlowStepFinishResponse.fromJson({ 'result': { - 'outcome': {'AwaitingExternal': {}}, - }, - }); - expect(response.outcome, isA()); - }); - - test('reads a null or absent result as no outcome', () { - expect(FinishMfaResponse.fromJson({'result': null}).outcome, isNull); - expect( - FinishMfaResponse.fromJson({ - 'result': {'outcome': null}, - }).outcome, - isNull, - ); - }); - }); - - group('parseMfaOutcome', () { - test('accepts every plausible spelling of the variant keys', () { - for (final key in ['Advanced', 'advanced']) { - final outcome = parseMfaOutcome({ 'outcome': { - key: {'nextStep': 2}, + 'FutureResult': {'secret': 'not retained'}, }, - }); - expect((outcome as MfaAdvanced).nextStep, 2); - } - for (final key in [ - 'AwaitingExternal', - 'awaiting_external', - 'awaitingExternal', - ]) { - expect( - parseMfaOutcome({ - 'outcome': {key: {}}, - }), - isA(), - ); - } - final completed = parseMfaOutcome({ - 'outcome': { - 'Completed': {'presharedKey': 'psk'}, }, - }); - expect((completed as MfaCompleted).presharedKey, 'psk'); - }); - - test('throws with the body when the shape is not recognized', () { - expect( - () => parseMfaOutcome({ - 'outcome': {'Rejected': {}}, - }), - throwsA( - isA().having( - (e) => e.source, - 'source', - contains('Rejected'), - ), - ), - ); - expect(() => parseMfaOutcome('done'), throwsFormatException); - expect( - () => parseMfaOutcome({ - 'outcome': {'Advanced': 1}, - }), - throwsFormatException, - ); - expect( - () => parseMfaOutcome({ - 'outcome': {'Advanced': {}, 'Completed': 2}, - }), - throwsFormatException, - ); + }).result; + expect(result, isA()); + expect(result.toString(), isNot(contains('not retained'))); }); }); } diff --git a/client/test/mfa/remote_mfa_qr_test.dart b/client/test/mfa/remote_mfa_qr_test.dart new file mode 100644 index 0000000..1ae0d30 --- /dev/null +++ b/client/test/mfa/remote_mfa_qr_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mobile/data/proxy/mfa.dart'; + +void main() { + const instanceId = '0f8fad5b-d9cb-469f-a165-70867728950e'; + const challenge = 'Q7xk2LmP9vRt4WzA8nHc3JbY6uFd1GsE5oKiXqVpNwMa'; + const legacyJson = { + 'instance_id': instanceId, + 'token': 'aB3dE5gH7jK9mN1pQ3sT5vW7yZ9bC1dF', + 'challenge': challenge, + }; + const attemptId = 'Zx8Cv7Bn6Ml5Kj4Hg3Fd2Sa1Qw9Er8Ty'; + + test('decodes a legacy QR and ignores unknown keys', () { + final qr = RemoteMfaQr.fromJson({...legacyJson, 'future_field': true}); + + expect(qr, isA()); + expect(qr.instanceId, instanceId); + expect(qr.token, legacyJson['token']); + expect(qr.challenge, challenge); + }); + + test('accepts a pre-2.2 JWT token', () { + const jwt = + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJkZXZpY2UifQ.c2lnbmF0dXJlLXZhbHVl_-x'; + final qr = RemoteMfaQr.fromJson({...legacyJson, 'token': jwt}); + + expect(qr.token, jwt); + }); + + test('decodes a flow QR by its step attempt id', () { + final qr = RemoteMfaQr.fromJson({ + ...legacyJson, + 'step_attempt_id': attemptId, + }); + + expect(qr, isA()); + expect((qr as MfaFlowRemoteMfaQr).stepAttemptId, attemptId); + }); + + test('rejects malformed legacy QR fields', () { + expect( + () => RemoteMfaQr.fromJson({'instance_id': instanceId}), + throwsFormatException, + ); + }); + + test('rejects fields outside the expected format or size', () { + final invalid = >[ + {...legacyJson, 'instance_id': 'instance-id'}, + {...legacyJson, 'token': ''}, + {...legacyJson, 'token': 'token with spaces'}, + {...legacyJson, 'token': 'a' * 4097}, + {...legacyJson, 'challenge': 'chal.lenge'}, + {...legacyJson, 'challenge': 'a' * 129}, + ]; + for (final json in invalid) { + expect(() => RemoteMfaQr.fromJson(json), throwsFormatException); + } + }); + + test('does not treat an invalid flow attempt id as a legacy QR', () { + for (final stepAttemptId in [null, '', 7, 'attempt-id', 'a' * 129]) { + expect( + () => RemoteMfaQr.fromJson({ + ...legacyJson, + 'step_attempt_id': stepAttemptId, + }), + throwsFormatException, + ); + } + }); +} diff --git a/client/test/utils/error_handler_test.dart b/client/test/utils/error_handler_test.dart new file mode 100644 index 0000000..4e0406c --- /dev/null +++ b/client/test/utils/error_handler_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mobile/open/api.dart'; +import 'package:mobile/utils/error_handler.dart'; + +void main() { + String message(MfaRequestFailure failure, {int? statusCode}) => + ErrorHandler.getHumanReadableError( + MfaRequestException( + 'MFA flow start', + statusCode: statusCode, + failure: failure, + ), + ); + + test('MFA failures get fixed messages without request details', () { + expect( + message(MfaRequestFailure.network), + 'Unable to connect to the server. Please check your internet connection.', + ); + expect( + message(MfaRequestFailure.policyDenied, statusCode: 403), + "This device does not meet the location's security requirements.", + ); + expect( + message(MfaRequestFailure.attemptLimit, statusCode: 403), + 'Too many failed attempts. Start connecting again.', + ); + expect( + message(MfaRequestFailure.notReady, statusCode: 428), + "Verification can't start right now. Try again in a moment.", + ); + }); + + test('other MFA server failures read like other HTTP errors', () { + expect( + message(MfaRequestFailure.server, statusCode: 503), + 'Server error. Please try again later.', + ); + expect( + message(MfaRequestFailure.server, statusCode: 404), + 'Service not found.', + ); + expect( + message(MfaRequestFailure.server), + isNot(contains('MFA flow start')), + ); + }); +} diff --git a/flake.lock b/flake.lock index 333e23c..80ea8e0 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1763421233, - "narHash": "sha256-Stk9ZYRkGrnnpyJ4eqt9eQtdFWRRIvMxpNRf4sIegnw=", + "lastModified": 1791048980, + "narHash": "sha256-KgItSKML8Xvte0B7/uGnBDsYzSnnKHcOaiUbgWBXLXw=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "89c2b2330e733d6cdb5eae7b899326930c2c0648", + "rev": "a7868a727837f3c09cee2ce0ca671c76b1589fed", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index c2ca9d3..1a33643 100644 --- a/flake.nix +++ b/flake.nix @@ -23,11 +23,11 @@ # platformVersions = [ "34" "28" ]; # abiVersions = [ "armeabi-v7a" "arm64-v8a" ]; toolsVersion = "26.1.1"; - platformToolsVersion = "34.0.5"; - buildToolsVersions = ["35.0.0"]; + platformToolsVersion = "37.0.1"; + buildToolsVersions = ["35.0.0" "36.0.0"]; includeEmulator = false; emulatorVersion = "34.1.9"; - platformVersions = ["31" "32" "33" "34" "35" "36"]; + platformVersions = ["31" "32" "33" "34" "35" "36" "37"]; includeSources = false; includeSystemImages = false; systemImageTypes = ["google_apis_playstore"]; @@ -40,12 +40,19 @@ }; androidSdk = androidComposition.androidsdk; - emulatorPkg = pkgs.androidenv.emulateApp { + emulatorPkg = pkgs.androidenv.emulateApp.override { + composeAndroidPackages = args: + pkgs.androidenv.composeAndroidPackages (args // { + cmdLineToolsVersion = "latest"; + }); + } { name = "emulate-deguard-client"; platformVersion = "36"; abiVersion = "x86_64"; systemImageType = "google_apis_playstore"; deviceName = "pixel_9"; + androidAvdFlags = "--device pixel_9"; + androidEmulatorFlags = "-gpu host -camera-back webcam0"; }; flutterDevPkg = pkgs.writeShellScriptBin "flutter-dev" '' @@ -77,6 +84,7 @@ ]; packages = [ emulatorPkg + unzip ]; shellHook = '' export PATH=${flutterDevPkg.outPath}/bin:$PATH