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..57154b3d 100644 --- a/packages/gotrue/lib/src/gotrue_mfa_api.dart +++ b/packages/gotrue/lib/src/gotrue_mfa_api.dart @@ -38,12 +38,39 @@ 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. + @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) { + return enrollWithParams( + PhoneEnrollParams(phone: phone, friendlyName: friendlyName)); + } + + 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', @@ -51,16 +78,24 @@ class GoTrueMFAApi { options: GotrueRequestOptions( headers: _client._headers, body: { - 'friendly_name': friendlyName, - 'factor_type': factorType.name, - 'issuer': issuer, + '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, ), ); - data['totp']['qr_code'] = - 'data:image/svg+xml;utf-8,${data['totp']['qr_code']}'; + if (params is TOTPEnrollParams && + 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..3c6b7060 100644 --- a/packages/gotrue/lib/src/types/mfa.dart +++ b/packages/gotrue/lib/src/types/mfa.dart @@ -8,19 +8,55 @@ 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 +187,7 @@ class AuthMFAAdminDeleteFactorResponse { enum FactorStatus { verified, unverified } -enum FactorType { totp } +enum FactorType { totp, phone } class Factor { /// ID of the factor. @@ -160,7 +196,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. @@ -303,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 e4eac452..66f69335 100644 --- a/packages/gotrue/test/src/gotrue_mfa_api_test.dart +++ b/packages/gotrue/test/src/gotrue_mfa_api_test.dart @@ -33,16 +33,92 @@ 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', () { + 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.', + )), + ); + }); + }); + + 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', + )), + ); + }); + }); }); test('challenge', () async {