diff --git a/infra/gotrue/docker-compose.yml b/infra/gotrue/docker-compose.yml index fdf226200..2baaf4a5c 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: @@ -27,6 +27,8 @@ services: GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI: http://localhost:9998/callback GOTRUE_SECURITY_MANUAL_LINKING_ENABLED: 'true' GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: 'true' + GOTRUE_MFA_PHONE_ENROLL_ENABLED: 'true' + GOTRUE_MFA_PHONE_VERIFY_ENABLED: 'true' depends_on: - db diff --git a/packages/gotrue/lib/src/gotrue_mfa_api.dart b/packages/gotrue/lib/src/gotrue_mfa_api.dart index f24e3099d..2401b7022 100644 --- a/packages/gotrue/lib/src/gotrue_mfa_api.dart +++ b/packages/gotrue/lib/src/gotrue_mfa_api.dart @@ -28,39 +28,57 @@ class GoTrueMFAApi { /// 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. + /// For TOTP: To verify a factor, present the QR code or secret to the user and ask them to add it to their authenticator app. + /// For Phone: The user will receive an SMS with a verification code. + /// + /// The user has to enter the code from their authenticator app or SMS to verify it. /// /// Upon verifying a factor, all other sessions are logged out and the current session's authenticator level is promoted to `aal2`. /// /// [factorType] : Type of factor being enrolled. /// - /// [issuer] : Domain which the user is enrolled with. + /// [issuer] : Domain which the user is enrolled with (TOTP only). /// /// [friendlyName] : Human readable name assigned to the factor. + /// + /// [phone] : Phone number to enroll for Phone factor type. Future enroll({ FactorType factorType = FactorType.totp, String? issuer, String? friendlyName, + String? phone, }) async { final session = _client.currentSession; + + final body = { + 'friendly_name': friendlyName, + 'factor_type': factorType.name, + }; + + if (factorType == FactorType.totp && issuer != null) { + body['issuer'] = issuer; + } else if (factorType == FactorType.phone && phone != null) { + body['phone'] = phone; + } else { + throw ArgumentError( + 'Invalid arguments, expected an issuer for totp factor type or phone for phone factor. type'); + } + final data = await _fetch.request( '${_client._url}/factors', RequestMethodType.post, options: GotrueRequestOptions( headers: _client._headers, - body: { - 'friendly_name': friendlyName, - 'factor_type': factorType.name, - 'issuer': issuer, - }, + body: body, 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'] = + 'data:image/svg+xml;utf-8,${data['totp']['qr_code']}'; + } final response = AuthMFAEnrollResponse.fromJson(data); return response; @@ -150,8 +168,13 @@ class GoTrueMFAApi { factor.factorType == FactorType.totp && factor.status == FactorStatus.verified) .toList(); + final phone = factors + .where((factor) => + factor.factorType == FactorType.phone && + factor.status == FactorStatus.verified) + .toList(); - return AuthMFAListFactorsResponse(all: factors, totp: totp); + return AuthMFAListFactorsResponse(all: factors, totp: totp, phone: phone); } /// Returns the Authenticator Assurance Level (AAL) for the active session. diff --git a/packages/gotrue/lib/src/types/mfa.dart b/packages/gotrue/lib/src/types/mfa.dart index 148f3b1fb..015af0db1 100644 --- a/packages/gotrue/lib/src/types/mfa.dart +++ b/packages/gotrue/lib/src/types/mfa.dart @@ -4,23 +4,33 @@ class AuthMFAEnrollResponse { /// ID of the factor that was just enrolled (in an unverified state). final String id; - /// Type of MFA factor. Only `[FactorType.totp] supported for now. + /// Type of MFA factor. Supports both `[FactorType.totp]` and `[FactorType.phone]`. final FactorType type; - /// TOTP enrollment information. - final TOTPEnrollment totp; + /// TOTP enrollment information (only present when type is totp). + final TOTPEnrollment? totp; + + /// Phone enrollment information (only present when type is phone). + final PhoneEnrollment? phone; const AuthMFAEnrollResponse({ required this.id, required this.type, - required this.totp, + this.totp, + this.phone, }); factory AuthMFAEnrollResponse.fromJson(Map json) { + final type = FactorType.values.firstWhere((e) => e.name == json['type']); return AuthMFAEnrollResponse( id: json['id'], - type: FactorType.values.firstWhere((e) => e.name == json['type']), - totp: TOTPEnrollment.fromJson(json['totp']), + type: type, + totp: type == FactorType.totp && json['totp'] != null + ? TOTPEnrollment.fromJson(json['totp']) + : null, + phone: type == FactorType.phone && json['phone'] != null + ? PhoneEnrollment._fromJsonValue(json['phone']) + : null, ); } } @@ -54,6 +64,34 @@ class TOTPEnrollment { } } +class PhoneEnrollment { + /// The phone number that will receive the SMS OTP. + final String phone; + + const PhoneEnrollment({ + required this.phone, + }); + + factory PhoneEnrollment.fromJson(Map json) { + return PhoneEnrollment( + phone: json['phone'], + ); + } + + factory PhoneEnrollment._fromJsonValue(dynamic value) { + if (value is String) { + // Server returns phone number as a string directly + return PhoneEnrollment(phone: value); + } else if (value is Map) { + // Server returns phone data as an object + return PhoneEnrollment.fromJson(value); + } else { + throw ArgumentError( + 'Invalid phone enrollment data type: ${value.runtimeType}'); + } + } +} + class AuthMFAChallengeResponse { /// ID of the newly created challenge. final String id; @@ -120,8 +158,13 @@ class AuthMFAUnenrollResponse { class AuthMFAListFactorsResponse { final List all; final List totp; + final List phone; - AuthMFAListFactorsResponse({required this.all, required this.totp}); + AuthMFAListFactorsResponse({ + required this.all, + required this.totp, + required this.phone, + }); } class AuthMFAAdminListFactorsResponse { @@ -151,7 +194,7 @@ class AuthMFAAdminDeleteFactorResponse { enum FactorStatus { verified, unverified } -enum FactorType { totp } +enum FactorType { totp, phone } class Factor { /// ID of the factor. @@ -160,7 +203,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. Supports both `totp` and `phone`. 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 e4eac4528..a7295ad8e 100644 --- a/packages/gotrue/test/src/gotrue_mfa_api_test.dart +++ b/packages/gotrue/test/src/gotrue_mfa_api_test.dart @@ -33,18 +33,44 @@ void main() { ); }); - test('enroll', () async { + test('enroll 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); + final uri = Uri.parse(res.totp!.uri); expect(res.type, FactorType.totp); expect(uri.queryParameters['issuer'], 'MyFriend'); expect(uri.scheme, 'otpauth'); }); + test('enroll phone', () async { + await client.signInWithPassword(password: password, email: email1); + + final res = await client.mfa.enroll( + factorType: FactorType.phone, + phone: '+1234567890', + friendlyName: 'MyPhone', + ); + + expect(res.type, FactorType.phone); + expect(res.phone?.phone, '+1234567890'); + expect(res.totp, isNull); + }); + + test('enroll phone requires phone number', () async { + await client.signInWithPassword(password: password, email: email1); + + expect( + () async => await client.mfa.enroll( + factorType: FactorType.phone, + friendlyName: 'MyPhone', + ), + throwsArgumentError, + ); + }); + test('challenge', () async { await client.signInWithPassword(password: password, email: email1); @@ -56,10 +82,11 @@ void main() { test('verify', () async { await client.signInWithPassword(password: password, email: email1); - final challengeId = 'b824ca10-cc13-4250-adba-20ee6e5e7dcd'; + // Create a challenge first + final challengeRes = await client.mfa.challenge(factorId: factorId1); - final res = await client.mfa - .verify(factorId: factorId1, challengeId: challengeId, code: getTOTP()); + final res = await client.mfa.verify( + factorId: factorId1, challengeId: challengeRes.id, code: getTOTP()); expect(client.currentSession?.accessToken, res.accessToken); expect(client.currentUser, res.user); @@ -95,6 +122,7 @@ void main() { final res = await client.mfa.listFactors(); expect(res.totp.length, 1); + expect(res.phone.length, 0); expect(res.all.length, 1); expect(res.all.first.id, factorId2); expect(res.all.first.status, FactorStatus.verified); @@ -108,6 +136,43 @@ void main() { true); }); + test('list factors with phone enrollment', () async { + await client.signInWithPassword(password: password, email: email1); + + // First, enroll a phone factor + final enrollRes = await client.mfa.enroll( + factorType: FactorType.phone, + phone: '+1234567890', + friendlyName: 'TestPhone', + ); + + // Verify enrollment worked + expect(enrollRes.type, FactorType.phone); + expect(enrollRes.phone?.phone, '+1234567890'); + + // Now list factors and check that phone factor appears + final listRes = await client.mfa.listFactors(); + + // Should have 1 phone factor (unverified) and 0 verified phone factors + expect(listRes.all.length, greaterThanOrEqualTo(1)); + + // Find the phone factor we just enrolled + final phoneFactor = listRes.all.firstWhere( + (factor) => factor.factorType == FactorType.phone, + ); + + expect(phoneFactor.id, enrollRes.id); + expect(phoneFactor.factorType, FactorType.phone); + expect(phoneFactor.friendlyName, 'TestPhone'); + expect(phoneFactor.status, FactorStatus.unverified); + + // Verified phone factors should be empty since we haven't verified yet + expect(listRes.phone.length, 0); + + // But the factor should appear in the all list + expect(listRes.all.any((f) => f.factorType == FactorType.phone), true); + }); + test('aal1 for only password', () async { await client.signInWithPassword(password: password, email: email2); final res = client.mfa.getAuthenticatorAssuranceLevel();