From 8f4f38f2cc4526c248b3c02507e5aacb72bb10f0 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Fri, 16 Oct 2026 11:43:40 +0200 Subject: [PATCH] feat(auth): add admin updateFactor for MFA factors --- .github/workflows/sync-compliance.yml | 2 +- .github/workflows/validate-capabilities.yml | 2 +- .../lib/src/auth_admin_mfa_api.dart | 29 ++++++++ packages/supabase_auth/lib/src/types/mfa.dart | 12 ++++ packages/supabase_auth/test/admin_test.dart | 20 ++++++ .../test/admin_update_factor_test.dart | 69 +++++++++++++++++++ .../test/src/auth_admin_mfa_api_test.dart | 24 +++++++ sdk-compliance.yaml | 9 +++ 8 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 packages/supabase_auth/test/admin_update_factor_test.dart diff --git a/.github/workflows/sync-compliance.yml b/.github/workflows/sync-compliance.yml index a9033a41d..ed1c53113 100644 --- a/.github/workflows/sync-compliance.yml +++ b/.github/workflows/sync-compliance.yml @@ -10,7 +10,7 @@ jobs: permissions: contents: write pull-requests: write - uses: supabase/sdk/.github/workflows/sync-sdk-compliance.yml@2a4d008ba8e6815e4a94fab59a54a2f202d489a5 # capability-matrix/v1.14.0 + uses: supabase/sdk/.github/workflows/sync-sdk-compliance.yml@85867c5d06a15ecc0b49c07dd21682966d5c7be5 # capability-matrix/v1.15.0 secrets: client-id: ${{ vars.GH_APP_CLIENT_ID }} private-key: ${{ secrets.PRIVATE_KEY }} diff --git a/.github/workflows/validate-capabilities.yml b/.github/workflows/validate-capabilities.yml index cab702798..78b6b80f3 100644 --- a/.github/workflows/validate-capabilities.yml +++ b/.github/workflows/validate-capabilities.yml @@ -13,4 +13,4 @@ jobs: permissions: contents: read pull-requests: write - uses: supabase/sdk/.github/workflows/validate-sdk-compliance-dart.yml@2a4d008ba8e6815e4a94fab59a54a2f202d489a5 # capability-matrix/v1.14.0 \ No newline at end of file + uses: supabase/sdk/.github/workflows/validate-sdk-compliance-dart.yml@85867c5d06a15ecc0b49c07dd21682966d5c7be5 # capability-matrix/v1.15.0 \ No newline at end of file diff --git a/packages/supabase_auth/lib/src/auth_admin_mfa_api.dart b/packages/supabase_auth/lib/src/auth_admin_mfa_api.dart index 98b7eabaa..1b84999c5 100644 --- a/packages/supabase_auth/lib/src/auth_admin_mfa_api.dart +++ b/packages/supabase_auth/lib/src/auth_admin_mfa_api.dart @@ -36,6 +36,35 @@ class AuthAdminMFAApi { ); } + /// Updates the MFA factor identified by [factorId] on the user identified + /// by [userId]. + /// + /// Only the fields that are given are changed. The server ignores [phone] + /// on any factor that is not a phone factor. + Future updateFactor({ + required String userId, + required String factorId, + String? friendlyName, + String? phone, + }) async { + validateUuid(userId); + validateUuid(factorId); + + final data = await _fetch.request( + '$_url/admin/users/$userId/factors/$factorId', + HttpMethod.put, + options: AuthRequestOptions( + headers: _headers, + body: { + 'friendly_name': ?friendlyName, + 'phone': ?phone, + }, + ), + ); + + return AuthMFAAdminUpdateFactorResponse.fromJson(data); + } + /// Deletes the MFA factor identified by [factorId] from the user /// identified by [userId]. Future deleteFactor({ diff --git a/packages/supabase_auth/lib/src/types/mfa.dart b/packages/supabase_auth/lib/src/types/mfa.dart index fa3b26d69..76077a157 100644 --- a/packages/supabase_auth/lib/src/types/mfa.dart +++ b/packages/supabase_auth/lib/src/types/mfa.dart @@ -331,6 +331,18 @@ class AuthMFAAdminDeleteFactorResponse { final String id; } +/// The response of `AuthAdminMFAApi.updateFactor`. +class AuthMFAAdminUpdateFactorResponse { + const AuthMFAAdminUpdateFactorResponse({required this.factor}); + + factory AuthMFAAdminUpdateFactorResponse.fromJson(Map json) { + return AuthMFAAdminUpdateFactorResponse(factor: Factor.fromJson(json)); + } + + /// The factor after the update. + final Factor factor; +} + /// Whether an MFA [Factor] can be used to satisfy a challenge. enum FactorStatus { /// The factor completed enrollment and can be used to verify a challenge. diff --git a/packages/supabase_auth/test/admin_test.dart b/packages/supabase_auth/test/admin_test.dart index 041cc09d4..4ddfff4a2 100644 --- a/packages/supabase_auth/test/admin_test.dart +++ b/packages/supabase_auth/test/admin_test.dart @@ -214,6 +214,26 @@ void main() { ); }); + test('updateFactor() validates ids', () { + expect( + () => client.admin.mfa.updateFactor( + userId: 'invalid-id', + factorId: factorId2, + friendlyName: 'name', + ), + throwsA(isA()), + ); + + expect( + () => client.admin.mfa.updateFactor( + userId: userId1, + factorId: 'invalid-factor-id', + friendlyName: 'name', + ), + throwsA(isA()), + ); + }); + test('deleteFactor() validates ids', () { expect( () => client.admin.mfa.deleteFactor( diff --git a/packages/supabase_auth/test/admin_update_factor_test.dart b/packages/supabase_auth/test/admin_update_factor_test.dart new file mode 100644 index 000000000..c71c897e1 --- /dev/null +++ b/packages/supabase_auth/test/admin_update_factor_test.dart @@ -0,0 +1,69 @@ +import 'package:supabase_auth/supabase_auth.dart'; +import 'package:supabase_common/supabase_common.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +void main() { + const userId = 'ef507d02-ce6a-4b3a-a8a6-6f0e14740136'; + const factorId = '2d3aa138-da96-4aea-8217-af07daa6b82d'; + final factorJson = { + 'id': factorId, + 'friendly_name': 'Work phone', + 'factor_type': 'phone', + 'status': 'verified', + 'created_at': '2026-01-01T00:00:00Z', + 'updated_at': '2026-01-02T00:00:00Z', + }; + + late MockSupabaseHttpClient httpClient; + late AuthClient client; + + setUp(() { + httpClient = MockSupabaseHttpClient()..stub(factorJson); + client = AuthClient( + url: 'http://localhost:9999', + httpClient: httpClient, + asyncStorage: TestAsyncStorage(), + ); + }); + + test('updateFactor sends a PUT to the factor path', () async { + await client.admin.mfa.updateFactor( + userId: userId, + factorId: factorId, + friendlyName: 'Work phone', + phone: '+15555550100', + ); + + final requests = httpClient.requestsTo( + '/admin/users/$userId/factors/$factorId', + method: HttpMethod.put.value, + ); + expect(requests, hasLength(1)); + expect(requests.single.jsonBody, { + 'friendly_name': 'Work phone', + 'phone': '+15555550100', + }); + }); + + test('updateFactor only sends the fields that are given', () async { + await client.admin.mfa.updateFactor( + userId: userId, + factorId: factorId, + phone: '+15555550100', + ); + + expect(httpClient.requests.last.jsonBody, {'phone': '+15555550100'}); + }); + + test('updateFactor returns the updated factor', () async { + final response = await client.admin.mfa.updateFactor( + userId: userId, + factorId: factorId, + friendlyName: 'Work phone', + ); + + expect(response.factor, Factor.fromJson(factorJson)); + }); +} diff --git a/packages/supabase_auth/test/src/auth_admin_mfa_api_test.dart b/packages/supabase_auth/test/src/auth_admin_mfa_api_test.dart index 5ada6b574..8f6f003fb 100644 --- a/packages/supabase_auth/test/src/auth_admin_mfa_api_test.dart +++ b/packages/supabase_auth/test/src/auth_admin_mfa_api_test.dart @@ -52,6 +52,30 @@ void main() { expect(factor.id, factorId2); }); + test('update factor friendly name', () async { + final response = await client.admin.mfa.updateFactor( + userId: userId2, + factorId: factorId2, + friendlyName: 'Renamed factor', + ); + + expect(response.factor.id, factorId2); + expect(response.factor.friendlyName, 'Renamed factor'); + + final listResponse = await client.admin.mfa.listFactors(userId: userId2); + expect(listResponse.factors.single.friendlyName, 'Renamed factor'); + }); + + test('update factor with no fields leaves it unchanged', () async { + final response = await client.admin.mfa.updateFactor( + userId: userId2, + factorId: factorId2, + ); + + expect(response.factor.id, factorId2); + expect(response.factor.friendlyName, 'VerifiedFactor'); + }); + test('delete factor', () async { final response = await client.admin.mfa.deleteFactor( userId: userId2, diff --git a/sdk-compliance.yaml b/sdk-compliance.yaml index 78e91a44b..68a4d98f7 100644 --- a/sdk-compliance.yaml +++ b/sdk-compliance.yaml @@ -627,6 +627,15 @@ features: - AuthMFAAdminDeleteFactorResponse.AuthMFAAdminDeleteFactorResponse - AuthMFAAdminDeleteFactorResponse.fromJson - AuthMFAAdminDeleteFactorResponse.id + auth.admin.update_mfa_factor: + status: implemented + symbols: + - AuthAdminMFAApi.updateFactor + supporting_symbols: + - AuthMFAAdminUpdateFactorResponse + - AuthMFAAdminUpdateFactorResponse.AuthMFAAdminUpdateFactorResponse + - AuthMFAAdminUpdateFactorResponse.factor + - AuthMFAAdminUpdateFactorResponse.fromJson auth.admin.create_provider: status: implemented symbols: