From 9b79c68e7c05c05f0c2d40ff091d16133e99dbd4 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 5 Jun 2025 15:44:08 -0300 Subject: [PATCH 1/2] feat(gotrue): Add support for phone factorType when enrolling MFA --- infra/gotrue/docker-compose.yml | 2 +- packages/gotrue/lib/src/gotrue_mfa_api.dart | 22 ++++++- packages/gotrue/lib/src/types/mfa.dart | 42 +++++++++++-- .../gotrue/test/src/gotrue_mfa_api_test.dart | 61 ++++++++++++++++--- 4 files changed, 109 insertions(+), 18 deletions(-) diff --git a/infra/gotrue/docker-compose.yml b/infra/gotrue/docker-compose.yml index fdf22620..5c2505c1 100644 --- a/infra/gotrue/docker-compose.yml +++ b/infra/gotrue/docker-compose.yml @@ -2,7 +2,7 @@ version: '3' services: gotrue: # Signup enabled, autoconfirm on - image: supabase/auth:v2.151.0 + image: supabase/auth:v2.175.0 ports: - '9998:9998' environment: diff --git a/packages/gotrue/lib/src/gotrue_mfa_api.dart b/packages/gotrue/lib/src/gotrue_mfa_api.dart index f24e3099..8508299d 100644 --- a/packages/gotrue/lib/src/gotrue_mfa_api.dart +++ b/packages/gotrue/lib/src/gotrue_mfa_api.dart @@ -38,12 +38,23 @@ class GoTrueMFAApi { /// /// [issuer] : Domain which the user is enrolled with. /// + /// [phone] : Phone number of the MFA factor in E.164 format. Used to send messages. + /// /// [friendlyName] : Human readable name assigned to the factor. Future enroll({ FactorType factorType = FactorType.totp, String? issuer, + String? phone, String? friendlyName, }) async { + if (factorType == FactorType.phone && phone == null) { + throw ArgumentError('Phone number is required for phone factor type'); + } + + if (factorType == FactorType.totp && issuer == null) { + throw ArgumentError('Issuer is required for totp factor type'); + } + final session = _client.currentSession; final data = await _fetch.request( '${_client._url}/factors', @@ -53,14 +64,19 @@ class GoTrueMFAApi { body: { 'friendly_name': friendlyName, 'factor_type': factorType.name, - 'issuer': issuer, + if (factorType == FactorType.totp) 'issuer': issuer, + if (factorType == FactorType.phone) 'phone': phone, }, jwt: session?.accessToken, ), ); - data['totp']['qr_code'] = - 'data:image/svg+xml;utf-8,${data['totp']['qr_code']}'; + if (factorType == FactorType.totp && + data['totp'] != null && + data['totp']['qr_code'] != null) { + data['totp']['qr_code'] = + 'data:image/svg+xml;utf-8,${data['totp']['qr_code']}'; + } final response = AuthMFAEnrollResponse.fromJson(data); return response; diff --git a/packages/gotrue/lib/src/types/mfa.dart b/packages/gotrue/lib/src/types/mfa.dart index 148f3b1f..cac0a574 100644 --- a/packages/gotrue/lib/src/types/mfa.dart +++ b/packages/gotrue/lib/src/types/mfa.dart @@ -8,19 +8,53 @@ class AuthMFAEnrollResponse { final FactorType type; /// TOTP enrollment information. - final TOTPEnrollment totp; + final TOTPEnrollment? totp; + + /// Phone enrollment information. + final PhoneEnrollment? phone; const AuthMFAEnrollResponse({ required this.id, required this.type, required this.totp, + required this.phone, }); factory AuthMFAEnrollResponse.fromJson(Map json) { return AuthMFAEnrollResponse( id: json['id'], type: FactorType.values.firstWhere((e) => e.name == json['type']), - totp: TOTPEnrollment.fromJson(json['totp']), + totp: json['totp'] != null ? TOTPEnrollment.fromJson(json['totp']) : null, + phone: json['phone'] != null ? PhoneEnrollment.fromJson(json['phone']) : null, + ); + } +} + +class PhoneEnrollment { + /// ID of the factor that was just enrolled (in an unverified state). + final String id; + + /// Type of MFA factor. + final FactorType type; + + /// Phone number of the MFA factor in E.164 format. Used to send messages. + final String phone; + + /// Friendly name of the factor, useful to disambiguate between multiple factors. + final String? friendlyName; + + const PhoneEnrollment( + {required this.id, + required this.type, + required this.phone, + required this.friendlyName}); + + factory PhoneEnrollment.fromJson(Map json) { + return PhoneEnrollment( + id: json['id'], + type: FactorType.values.firstWhere((e) => e.name == json['type']), + phone: json['phone'], + friendlyName: json['friendly_name'], ); } } @@ -151,7 +185,7 @@ class AuthMFAAdminDeleteFactorResponse { enum FactorStatus { verified, unverified } -enum FactorType { totp } +enum FactorType { totp, phone } class Factor { /// ID of the factor. @@ -160,7 +194,7 @@ class Factor { /// Friendly name of the factor, useful to disambiguate between multiple factors. final String? friendlyName; - /// Type of factor. Only `totp` supported with this version but may change in future versions. + /// Type of factor. final FactorType factorType; /// Factor's status. diff --git a/packages/gotrue/test/src/gotrue_mfa_api_test.dart b/packages/gotrue/test/src/gotrue_mfa_api_test.dart index e4eac452..70d94274 100644 --- a/packages/gotrue/test/src/gotrue_mfa_api_test.dart +++ b/packages/gotrue/test/src/gotrue_mfa_api_test.dart @@ -33,16 +33,57 @@ void main() { ); }); - test('enroll', () async { - await client.signInWithPassword(password: password, email: email1); - - final res = await client.mfa - .enroll(issuer: 'MyFriend', friendlyName: 'MyFriendName'); - final uri = Uri.parse(res.totp.uri); - - expect(res.type, FactorType.totp); - expect(uri.queryParameters['issuer'], 'MyFriend'); - expect(uri.scheme, 'otpauth'); + group('enroll', () { + test('totp', () async { + await client.signInWithPassword(password: password, email: email1); + + final res = await client.mfa + .enroll(issuer: 'MyFriend', friendlyName: 'MyFriendName'); + final uri = Uri.parse(res.totp!.uri); + + expect(res.type, FactorType.totp); + expect(uri.queryParameters['issuer'], 'MyFriend'); + expect(uri.scheme, 'otpauth'); + }); + + test('phone', () async { + await client.signInWithPassword(password: password, email: email1); + + expect( + () => client.mfa + .enroll(factorType: FactorType.phone, phone: '+1234567890'), + throwsA(isA().having( + (e) => e.message, + 'message', + 'MFA enroll is disabled for Phone', + )), + ); + }); + + test( + 'throws ArgumentError when phone factor type is used without phone number', + () async { + expect( + () => client.mfa.enroll(factorType: FactorType.phone), + throwsA(isA().having( + (e) => e.message, + 'message', + 'Phone number is required for phone factor type', + )), + ); + }); + + test('throws ArgumentError when totp factor type is used without issuer', + () async { + expect( + () => client.mfa.enroll(factorType: FactorType.totp), + throwsA(isA().having( + (e) => e.message, + 'message', + 'Issuer is required for totp factor type', + )), + ); + }); }); test('challenge', () async { From 1188243516176b02a7ed5959e184220d798748df Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 5 Jun 2025 16:15:56 -0300 Subject: [PATCH 2/2] use sealed class and deprecate old method --- packages/gotrue/lib/src/gotrue_mfa_api.dart | 37 +++-- packages/gotrue/lib/src/types/mfa.dart | 39 +++++- .../gotrue/test/src/gotrue_mfa_api_test.dart | 129 +++++++++++------- 3 files changed, 148 insertions(+), 57 deletions(-) diff --git a/packages/gotrue/lib/src/gotrue_mfa_api.dart b/packages/gotrue/lib/src/gotrue_mfa_api.dart index 8508299d..57154b3d 100644 --- a/packages/gotrue/lib/src/gotrue_mfa_api.dart +++ b/packages/gotrue/lib/src/gotrue_mfa_api.dart @@ -41,20 +41,36 @@ class GoTrueMFAApi { /// [phone] : Phone number of the MFA factor in E.164 format. Used to send messages. /// /// [friendlyName] : Human readable name assigned to the factor. + @Deprecated('Use enrollWithParams instead for better type safety') Future enroll({ FactorType factorType = FactorType.totp, String? issuer, String? phone, String? friendlyName, }) async { - if (factorType == FactorType.phone && phone == null) { - throw ArgumentError('Phone number is required for phone factor type'); + if (factorType == FactorType.phone && phone != null) { + return enrollWithParams( + PhoneEnrollParams(phone: phone, friendlyName: friendlyName)); } - if (factorType == FactorType.totp && issuer == null) { - throw ArgumentError('Issuer is required for totp factor type'); + if (factorType == FactorType.totp && issuer != null) { + return enrollWithParams( + TOTPEnrollParams(issuer: issuer, friendlyName: friendlyName)); } + throw ArgumentError('Invalid arguments, expected either phone or issuer.'); + } + + /// Starts the enrollment process for a new Multi-Factor Authentication (MFA) factor. + /// This method creates a new `unverified` factor. + /// To verify a factor, present the QR code or secret to the user and ask them to add it to their authenticator app. + /// + /// The user has to enter the code from their authenticator app to verify it. + /// + /// Upon verifying a factor, all other sessions are logged out and the current session's authenticator level is promoted to `aal2`. + /// + /// [params] : Type-safe parameters for enrolling a new MFA factor. + Future enrollWithParams(MFAEnrollParams params) async { final session = _client.currentSession; final data = await _fetch.request( '${_client._url}/factors', @@ -62,16 +78,19 @@ class GoTrueMFAApi { options: GotrueRequestOptions( headers: _client._headers, body: { - 'friendly_name': friendlyName, - 'factor_type': factorType.name, - if (factorType == FactorType.totp) 'issuer': issuer, - if (factorType == FactorType.phone) 'phone': phone, + 'friendly_name': params.friendlyName, + 'factor_type': switch (params) { + TOTPEnrollParams() => FactorType.totp.name, + PhoneEnrollParams() => FactorType.phone.name, + }, + if (params is TOTPEnrollParams) 'issuer': params.issuer, + if (params is PhoneEnrollParams) 'phone': params.phone, }, jwt: session?.accessToken, ), ); - if (factorType == FactorType.totp && + if (params is TOTPEnrollParams && data['totp'] != null && data['totp']['qr_code'] != null) { data['totp']['qr_code'] = diff --git a/packages/gotrue/lib/src/types/mfa.dart b/packages/gotrue/lib/src/types/mfa.dart index cac0a574..3c6b7060 100644 --- a/packages/gotrue/lib/src/types/mfa.dart +++ b/packages/gotrue/lib/src/types/mfa.dart @@ -25,7 +25,9 @@ class AuthMFAEnrollResponse { id: json['id'], type: FactorType.values.firstWhere((e) => e.name == json['type']), totp: json['totp'] != null ? TOTPEnrollment.fromJson(json['totp']) : null, - phone: json['phone'] != null ? PhoneEnrollment.fromJson(json['phone']) : null, + phone: json['phone'] != null + ? PhoneEnrollment.fromJson(json['phone']) + : null, ); } } @@ -337,3 +339,38 @@ class AMREntry { ); } } + +/// Parameters for enrolling a new MFA factor +sealed class MFAEnrollParams { + /// Friendly name of the factor, useful to disambiguate between multiple factors. + final String? friendlyName; + + /// Type of factor being enrolled. + final FactorType factorType; + + const MFAEnrollParams({this.friendlyName, required this.factorType}); +} + +/// Parameters for enrolling a TOTP factor +class TOTPEnrollParams extends MFAEnrollParams { + /// Domain which the user is enrolled with. + final String issuer; + + const TOTPEnrollParams({ + required this.issuer, + super.friendlyName, + super.factorType = FactorType.totp, + }); +} + +/// Parameters for enrolling a phone factor +class PhoneEnrollParams extends MFAEnrollParams { + /// Phone number of the MFA factor in E.164 format. Used to send messages. + final String phone; + + const PhoneEnrollParams({ + required this.phone, + super.friendlyName, + super.factorType = FactorType.phone, + }); +} diff --git a/packages/gotrue/test/src/gotrue_mfa_api_test.dart b/packages/gotrue/test/src/gotrue_mfa_api_test.dart index 70d94274..66f69335 100644 --- a/packages/gotrue/test/src/gotrue_mfa_api_test.dart +++ b/packages/gotrue/test/src/gotrue_mfa_api_test.dart @@ -34,55 +34,90 @@ void main() { }); group('enroll', () { - test('totp', () async { - await client.signInWithPassword(password: password, email: email1); - - final res = await client.mfa - .enroll(issuer: 'MyFriend', friendlyName: 'MyFriendName'); - final uri = Uri.parse(res.totp!.uri); - - expect(res.type, FactorType.totp); - expect(uri.queryParameters['issuer'], 'MyFriend'); - expect(uri.scheme, 'otpauth'); - }); - - test('phone', () async { - await client.signInWithPassword(password: password, email: email1); - - expect( - () => client.mfa - .enroll(factorType: FactorType.phone, phone: '+1234567890'), - throwsA(isA().having( - (e) => e.message, - 'message', - 'MFA enroll is disabled for Phone', - )), - ); - }); - - test( - 'throws ArgumentError when phone factor type is used without phone number', - () async { - expect( - () => client.mfa.enroll(factorType: FactorType.phone), - throwsA(isA().having( - (e) => e.message, - 'message', - 'Phone number is required for phone factor type', - )), - ); + group('deprecated method', () { + test('totp', () async { + await client.signInWithPassword(password: password, email: email1); + + final res = await client.mfa.enroll( + issuer: 'MyFriend', + friendlyName: 'MyFriendName', + ); + final uri = Uri.parse(res.totp!.uri); + + expect(res.type, FactorType.totp); + expect(uri.queryParameters['issuer'], 'MyFriend'); + expect(uri.scheme, 'otpauth'); + }); + + test('phone', () async { + await client.signInWithPassword(password: password, email: email1); + + expect( + () => client.mfa.enroll( + factorType: FactorType.phone, + phone: '+1234567890', + ), + throwsA(isA().having( + (e) => e.message, + 'message', + 'MFA enroll is disabled for Phone', + )), + ); + }); + + test('throws ArgumentError when invalid arguments are provided', + () async { + expect( + () => client.mfa.enroll(factorType: FactorType.phone), + throwsA(isA().having( + (e) => e.message, + 'message', + 'Invalid arguments, expected either phone or issuer.', + )), + ); + + expect( + () => client.mfa.enroll(factorType: FactorType.totp), + throwsA(isA().having( + (e) => e.message, + 'message', + 'Invalid arguments, expected either phone or issuer.', + )), + ); + }); }); - test('throws ArgumentError when totp factor type is used without issuer', - () async { - expect( - () => client.mfa.enroll(factorType: FactorType.totp), - throwsA(isA().having( - (e) => e.message, - 'message', - 'Issuer is required for totp factor type', - )), - ); + group('enrollWithParams', () { + test('totp', () async { + await client.signInWithPassword(password: password, email: email1); + + final res = await client.mfa.enrollWithParams( + TOTPEnrollParams( + issuer: 'MyFriend', + friendlyName: 'MyFriendName', + ), + ); + final uri = Uri.parse(res.totp!.uri); + + expect(res.type, FactorType.totp); + expect(uri.queryParameters['issuer'], 'MyFriend'); + expect(uri.scheme, 'otpauth'); + }); + + test('phone', () async { + await client.signInWithPassword(password: password, email: email1); + + expect( + () => client.mfa.enrollWithParams( + PhoneEnrollParams(phone: '+1234567890'), + ), + throwsA(isA().having( + (e) => e.message, + 'message', + 'MFA enroll is disabled for Phone', + )), + ); + }); }); });