From 8ca81871a096804824bd448b470fc61634a58f8a Mon Sep 17 00:00:00 2001 From: dshukertjr Date: Fri, 6 Jun 2025 12:15:03 +0900 Subject: [PATCH 1/7] feat: phone mfa enrollment --- infra/gotrue/docker-compose.yml | 4 +- packages/gotrue/lib/src/gotrue_mfa_api.dart | 45 +++++++++--- packages/gotrue/lib/src/types/mfa.dart | 48 ++++++++++--- .../gotrue/test/src/gotrue_mfa_api_test.dart | 68 ++++++++++++++++++- 4 files changed, 142 insertions(+), 23 deletions(-) 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..02e572ef7 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. + /// + /// 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 to verify it. + /// 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) { + body['issuer'] = issuer; + } else if (factorType == FactorType.phone) { + if (phone == null) { + throw ArgumentError('Phone number is required for phone factor type'); + } + body['phone'] = phone; + } + 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..96c234d97 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.fromJson(json['phone']) + : null, ); } } @@ -54,6 +64,21 @@ 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'], + ); + } +} + class AuthMFAChallengeResponse { /// ID of the newly created challenge. final String id; @@ -120,8 +145,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 +181,7 @@ class AuthMFAAdminDeleteFactorResponse { enum FactorStatus { verified, unverified } -enum FactorType { totp } +enum FactorType { totp, phone } class Factor { /// ID of the factor. @@ -160,7 +190,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..e0c478691 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); @@ -95,6 +121,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 +135,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(); From 0a37c2d7f547b9190b9e732373b387ab0d7c6053 Mon Sep 17 00:00:00 2001 From: dshukertjr Date: Fri, 6 Jun 2025 12:30:37 +0900 Subject: [PATCH 2/7] fix json parsing --- packages/gotrue/lib/src/types/mfa.dart | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/gotrue/lib/src/types/mfa.dart b/packages/gotrue/lib/src/types/mfa.dart index 96c234d97..015af0db1 100644 --- a/packages/gotrue/lib/src/types/mfa.dart +++ b/packages/gotrue/lib/src/types/mfa.dart @@ -29,7 +29,7 @@ class AuthMFAEnrollResponse { ? TOTPEnrollment.fromJson(json['totp']) : null, phone: type == FactorType.phone && json['phone'] != null - ? PhoneEnrollment.fromJson(json['phone']) + ? PhoneEnrollment._fromJsonValue(json['phone']) : null, ); } @@ -77,6 +77,19 @@ class 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 { From 48bd503218e7a1384e2a8bb59b63832b60f5c7f8 Mon Sep 17 00:00:00 2001 From: dshukertjr Date: Fri, 6 Jun 2025 12:46:09 +0900 Subject: [PATCH 3/7] properly get a challenge id in test --- packages/gotrue/test/src/gotrue_mfa_api_test.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/gotrue/test/src/gotrue_mfa_api_test.dart b/packages/gotrue/test/src/gotrue_mfa_api_test.dart index e0c478691..5b6629034 100644 --- a/packages/gotrue/test/src/gotrue_mfa_api_test.dart +++ b/packages/gotrue/test/src/gotrue_mfa_api_test.dart @@ -82,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()); + .verify(factorId: factorId1, challengeId: challengeRes.id, code: getTOTP()); expect(client.currentSession?.accessToken, res.accessToken); expect(client.currentUser, res.user); From a7c91f62b26f295dde2fb614f29584e205818e4d Mon Sep 17 00:00:00 2001 From: dshukertjr Date: Fri, 6 Jun 2025 12:48:30 +0900 Subject: [PATCH 4/7] format document --- packages/gotrue/test/src/gotrue_mfa_api_test.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/gotrue/test/src/gotrue_mfa_api_test.dart b/packages/gotrue/test/src/gotrue_mfa_api_test.dart index 5b6629034..a7295ad8e 100644 --- a/packages/gotrue/test/src/gotrue_mfa_api_test.dart +++ b/packages/gotrue/test/src/gotrue_mfa_api_test.dart @@ -85,8 +85,8 @@ void main() { // Create a challenge first final challengeRes = await client.mfa.challenge(factorId: factorId1); - final res = await client.mfa - .verify(factorId: factorId1, challengeId: challengeRes.id, 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); @@ -155,20 +155,20 @@ void main() { // 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); }); From a17f875f00d899aa3219391e631cf6d329664113 Mon Sep 17 00:00:00 2001 From: dshukertjr Date: Fri, 6 Jun 2025 12:51:22 +0900 Subject: [PATCH 5/7] format mfa api --- packages/gotrue/lib/src/gotrue_mfa_api.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/gotrue/lib/src/gotrue_mfa_api.dart b/packages/gotrue/lib/src/gotrue_mfa_api.dart index 02e572ef7..c3643a0ea 100644 --- a/packages/gotrue/lib/src/gotrue_mfa_api.dart +++ b/packages/gotrue/lib/src/gotrue_mfa_api.dart @@ -28,7 +28,7 @@ class GoTrueMFAApi { /// Starts the enrollment process for a new Multi-Factor Authentication (MFA) factor. /// This method creates a new `unverified` factor. - /// + /// /// 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. /// @@ -50,12 +50,12 @@ class GoTrueMFAApi { String? phone, }) async { final session = _client.currentSession; - + final body = { 'friendly_name': friendlyName, 'factor_type': factorType.name, }; - + if (factorType == FactorType.totp) { body['issuer'] = issuer; } else if (factorType == FactorType.phone) { @@ -64,7 +64,7 @@ class GoTrueMFAApi { } body['phone'] = phone; } - + final data = await _fetch.request( '${_client._url}/factors', RequestMethodType.post, From c13ed34a9b6c4cf76f307467031b6569badb0406 Mon Sep 17 00:00:00 2001 From: Tyler Date: Mon, 9 Jun 2025 21:32:23 +0900 Subject: [PATCH 6/7] Update packages/gotrue/lib/src/gotrue_mfa_api.dart Co-authored-by: Guilherme Souza --- packages/gotrue/lib/src/gotrue_mfa_api.dart | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/gotrue/lib/src/gotrue_mfa_api.dart b/packages/gotrue/lib/src/gotrue_mfa_api.dart index c3643a0ea..8267cd7e8 100644 --- a/packages/gotrue/lib/src/gotrue_mfa_api.dart +++ b/packages/gotrue/lib/src/gotrue_mfa_api.dart @@ -56,13 +56,12 @@ class GoTrueMFAApi { 'factor_type': factorType.name, }; - if (factorType == FactorType.totp) { + if (factorType == FactorType.totp && issuer != null) { body['issuer'] = issuer; - } else if (factorType == FactorType.phone) { - if (phone == null) { - throw ArgumentError('Phone number is required for phone factor type'); - } + } 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( From 30aa27a61343ceff3a0dd8600547af07056f5820 Mon Sep 17 00:00:00 2001 From: dshukertjr Date: Mon, 9 Jun 2025 21:35:35 +0900 Subject: [PATCH 7/7] format document --- packages/gotrue/lib/src/gotrue_mfa_api.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/gotrue/lib/src/gotrue_mfa_api.dart b/packages/gotrue/lib/src/gotrue_mfa_api.dart index 8267cd7e8..2401b7022 100644 --- a/packages/gotrue/lib/src/gotrue_mfa_api.dart +++ b/packages/gotrue/lib/src/gotrue_mfa_api.dart @@ -61,7 +61,8 @@ class GoTrueMFAApi { } 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'); + throw ArgumentError( + 'Invalid arguments, expected an issuer for totp factor type or phone for phone factor. type'); } final data = await _fetch.request(