diff --git a/packages/Gotrue/Gotrue.Tests/Gotrue.Tests.csproj b/packages/Gotrue/Gotrue.Tests/Gotrue.Tests.csproj
index 0af7db47..248f1526 100644
--- a/packages/Gotrue/Gotrue.Tests/Gotrue.Tests.csproj
+++ b/packages/Gotrue/Gotrue.Tests/Gotrue.Tests.csproj
@@ -34,6 +34,7 @@
+
diff --git a/packages/Gotrue/Gotrue.Tests/Mfa/MfaFixture.cs b/packages/Gotrue/Gotrue.Tests/Mfa/MfaFixture.cs
index 76477bcb..5c9f8d1a 100644
--- a/packages/Gotrue/Gotrue.Tests/Mfa/MfaFixture.cs
+++ b/packages/Gotrue/Gotrue.Tests/Mfa/MfaFixture.cs
@@ -18,11 +18,11 @@ namespace Gotrue.Tests.Mfa;
public abstract class MfaFixture : AuthClientFixture
{
private IGotrueAdminClient? admin;
- protected IGotrueAdminClient Admin => admin ??= TestClients.AdminAgainstCliStack();
+ protected IGotrueAdminClient Admin => this.admin ??= TestClients.AdminAgainstCliStack();
- protected async Task EnrollTotp()
+ protected async Task EnrollTotp(string friendlyName = "Enroll test")
{
- var enrollment = await this.Client.Enroll(new MfaEnrollParams { Issuer = "Supabase", FactorType = "totp", FriendlyName = "Enroll test" });
+ var enrollment = await this.Client.Enroll(new MfaEnrollParams { Issuer = "Supabase", FactorType = "totp", FriendlyName = friendlyName });
enrollment.Should().NotBeNull();
return enrollment!;
}
diff --git a/packages/Gotrue/Gotrue.Tests/UpdateFactor/Data/UpdateFactorApprovalTests.UpdateFactorRequest_ShouldSendEmptyObject_GivenNoChange.verified.txt b/packages/Gotrue/Gotrue.Tests/UpdateFactor/Data/UpdateFactorApprovalTests.UpdateFactorRequest_ShouldSendEmptyObject_GivenNoChange.verified.txt
new file mode 100644
index 00000000..22fdca1b
--- /dev/null
+++ b/packages/Gotrue/Gotrue.Tests/UpdateFactor/Data/UpdateFactorApprovalTests.UpdateFactorRequest_ShouldSendEmptyObject_GivenNoChange.verified.txt
@@ -0,0 +1 @@
+{}
\ No newline at end of file
diff --git a/packages/Gotrue/Gotrue.Tests/UpdateFactor/Data/UpdateFactorApprovalTests.UpdateFactorRequest_ShouldSendEveryChange_GivenEveryMember.verified.txt b/packages/Gotrue/Gotrue.Tests/UpdateFactor/Data/UpdateFactorApprovalTests.UpdateFactorRequest_ShouldSendEveryChange_GivenEveryMember.verified.txt
new file mode 100644
index 00000000..cc9f385a
--- /dev/null
+++ b/packages/Gotrue/Gotrue.Tests/UpdateFactor/Data/UpdateFactorApprovalTests.UpdateFactorRequest_ShouldSendEveryChange_GivenEveryMember.verified.txt
@@ -0,0 +1 @@
+{"friendly_name":"Work phone","phone":"+15555550199"}
\ No newline at end of file
diff --git a/packages/Gotrue/Gotrue.Tests/UpdateFactor/Data/UpdateFactorApprovalTests.UpdateFactorRequest_ShouldSendOnlyTheFriendlyName_GivenFriendlyNameOnly.verified.txt b/packages/Gotrue/Gotrue.Tests/UpdateFactor/Data/UpdateFactorApprovalTests.UpdateFactorRequest_ShouldSendOnlyTheFriendlyName_GivenFriendlyNameOnly.verified.txt
new file mode 100644
index 00000000..405298a3
--- /dev/null
+++ b/packages/Gotrue/Gotrue.Tests/UpdateFactor/Data/UpdateFactorApprovalTests.UpdateFactorRequest_ShouldSendOnlyTheFriendlyName_GivenFriendlyNameOnly.verified.txt
@@ -0,0 +1 @@
+{"friendly_name":"Work phone"}
\ No newline at end of file
diff --git a/packages/Gotrue/Gotrue.Tests/UpdateFactor/Data/phone_factor.json b/packages/Gotrue/Gotrue.Tests/UpdateFactor/Data/phone_factor.json
new file mode 100644
index 00000000..e1a469f6
--- /dev/null
+++ b/packages/Gotrue/Gotrue.Tests/UpdateFactor/Data/phone_factor.json
@@ -0,0 +1,10 @@
+{
+ "id": "2b306a77-21dc-4110-ba71-537cb56b9e98",
+ "created_at": "2026-10-30T09:15:00Z",
+ "updated_at": "2026-10-30T10:20:00Z",
+ "status": "verified",
+ "friendly_name": "Work phone",
+ "factor_type": "phone",
+ "phone": "15555550199",
+ "last_challenged_at": null
+}
diff --git a/packages/Gotrue/Gotrue.Tests/UpdateFactor/FactorResponses.cs b/packages/Gotrue/Gotrue.Tests/UpdateFactor/FactorResponses.cs
new file mode 100644
index 00000000..be87332d
--- /dev/null
+++ b/packages/Gotrue/Gotrue.Tests/UpdateFactor/FactorResponses.cs
@@ -0,0 +1,16 @@
+#region
+
+using System;
+using System.IO;
+
+#endregion
+
+namespace Gotrue.Tests.UpdateFactor;
+
+///
+/// Factor bodies as the auth server returns them, read from UpdateFactor/Data.
+///
+internal static class FactorResponses
+{
+ internal static string PhoneFactor => File.ReadAllText(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "UpdateFactor", "Data", "phone_factor.json"));
+}
diff --git a/packages/Gotrue/Gotrue.Tests/UpdateFactor/UpdateFactorApprovalTests.cs b/packages/Gotrue/Gotrue.Tests/UpdateFactor/UpdateFactorApprovalTests.cs
new file mode 100644
index 00000000..e9164fc0
--- /dev/null
+++ b/packages/Gotrue/Gotrue.Tests/UpdateFactor/UpdateFactorApprovalTests.cs
@@ -0,0 +1,44 @@
+#region
+
+using System.Threading.Tasks;
+using Gotrue.Tests.Support;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Supabase.Gotrue.UpdateFactor;
+
+#endregion
+
+namespace Gotrue.Tests.UpdateFactor;
+
+///
+/// Pins the exact bytes a factor update puts on the wire: only the members set, every member when all are set, and
+/// an empty object when none is.
+///
+[TestClass]
+[TestCategory("Contract")]
+public class UpdateFactorApprovalTests : RequestApprovalFixture
+{
+ private const string UserId = "8d3f1c2a-5b6e-4f70-9a81-b2c3d4e5f607";
+
+ private const string FactorId = "2b306a77-21dc-4110-ba71-537cb56b9e98";
+
+ [TestMethod]
+ public async Task UpdateFactorRequest_ShouldSendOnlyTheFriendlyName_GivenFriendlyNameOnly()
+ {
+ await this.Api.UpdateFactorAsync("service-jwt", UserId, FactorId, new FactorChanges { FriendlyName = "Work phone" });
+ await this.Verify(this.EmittedRequestBody).UseDirectory("Data");
+ }
+
+ [TestMethod]
+ public async Task UpdateFactorRequest_ShouldSendEveryChange_GivenEveryMember()
+ {
+ await this.Api.UpdateFactorAsync("service-jwt", UserId, FactorId, new FactorChanges { FriendlyName = "Work phone", Phone = "+15555550199" });
+ await this.Verify(this.EmittedRequestBody).UseDirectory("Data");
+ }
+
+ [TestMethod]
+ public async Task UpdateFactorRequest_ShouldSendEmptyObject_GivenNoChange()
+ {
+ await this.Api.UpdateFactorAsync("service-jwt", UserId, FactorId, new FactorChanges());
+ await this.Verify(this.EmittedRequestBody).UseDirectory("Data");
+ }
+}
diff --git a/packages/Gotrue/Gotrue.Tests/UpdateFactor/UpdateFactorContractTests.cs b/packages/Gotrue/Gotrue.Tests/UpdateFactor/UpdateFactorContractTests.cs
new file mode 100644
index 00000000..ac66d6d5
--- /dev/null
+++ b/packages/Gotrue/Gotrue.Tests/UpdateFactor/UpdateFactorContractTests.cs
@@ -0,0 +1,141 @@
+#region
+
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Gotrue.Tests.Support;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Supabase.Gotrue;
+using Supabase.Gotrue.Exceptions;
+using Supabase.Gotrue.UpdateFactor;
+using WireMock.Matchers;
+using WireMock.RequestBuilders;
+using WireMock.ResponseBuilders;
+
+#endregion
+
+namespace Gotrue.Tests.UpdateFactor;
+
+///
+/// Pins the wire contract of updating a user's MFA factor against a stubbed auth server: the route, the
+/// service-role credentials, the response and failure mapping, argument checks, and cancellation.
+///
+[TestClass]
+[TestCategory("Contract")]
+public class UpdateFactorContractTests
+{
+ private const string ServiceKey = "service-role-jwt";
+
+ private const string UserId = "8d3f1c2a-5b6e-4f70-9a81-b2c3d4e5f607";
+
+ private const string FactorId = "2b306a77-21dc-4110-ba71-537cb56b9e98";
+
+ private static readonly FactorChanges Rename = new() { FriendlyName = "Work phone" };
+
+ private MockGotrueServer server = null!;
+
+ [TestInitialize]
+ public void TestInitialize() => this.server = new MockGotrueServer();
+
+ [TestCleanup]
+ public void TestCleanup() => this.server.Dispose();
+
+ [TestMethod]
+ public async Task UpdateFactorAsync_ShouldPutChangesByUserAndFactorWithServiceRoleCredentials()
+ {
+ this.AnswerWith(HttpStatusCode.OK, FactorResponses.PhoneFactor);
+ await this.Admin.UpdateFactorAsync(UserId, FactorId, Rename);
+ this.server.VerifySingleReceivedRequest()
+ .WithMethod(HttpMethod.Put)
+ .WithPath($"/admin/users/{UserId}/factors/{FactorId}")
+ .WithHeader("Authorization", $"Bearer {ServiceKey}")
+ .WithJsonContentType();
+ }
+
+ [TestMethod]
+ public async Task UpdateFactorAsync_ShouldSendIdsAsGiven_GivenUppercaseIds()
+ {
+ this.AnswerWith(HttpStatusCode.OK, FactorResponses.PhoneFactor);
+ await this.Admin.UpdateFactorAsync(UserId.ToUpperInvariant(), FactorId.ToUpperInvariant(), Rename);
+ this.server.VerifySingleReceivedRequest().WithPath($"/admin/users/{UserId.ToUpperInvariant()}/factors/{FactorId.ToUpperInvariant()}");
+ }
+
+ [TestMethod]
+ public async Task UpdateFactorAsync_ShouldReturnUpdatedFactor()
+ {
+ this.AnswerWith(HttpStatusCode.OK, FactorResponses.PhoneFactor);
+ (await this.Admin.UpdateFactorAsync(UserId, FactorId, new FactorChanges { Phone = "+15555550199" })).Should().BeEquivalentTo(new
+ {
+ Id = FactorId,
+ FriendlyName = "Work phone",
+ FactorType = "phone",
+ Status = "verified",
+ Phone = "15555550199",
+ }, "the auth server answers 200 with the factor as stored after the update");
+ }
+
+ [TestMethod]
+ public async Task UpdateFactorAsync_ShouldThrowWithErrorCode_GivenUnknownFactor()
+ {
+ this.AnswerWith(HttpStatusCode.NotFound, """{"code":404,"error_code":"mfa_factor_not_found","msg":"Factor not found"}""");
+ var update = () => this.Admin.UpdateFactorAsync(UserId, FactorId, Rename);
+ await update.Should().ThrowExactlyAsync()
+ .Where(failure => failure.ErrorCode == "mfa_factor_not_found",
+ "the auth server answers 404 mfa_factor_not_found when the user owns no such factor");
+ }
+
+ [TestMethod]
+ public async Task UpdateFactorAsync_ShouldThrowWithErrorCode_GivenPhoneNotInE164()
+ {
+ this.AnswerWith(HttpStatusCode.BadRequest, """{"code":400,"error_code":"validation_failed","msg":"Invalid phone number format (E.164 required)"}""");
+ var update = () => this.Admin.UpdateFactorAsync(UserId, FactorId, new FactorChanges { Phone = "not-a-phone" });
+ await update.Should().ThrowExactlyAsync()
+ .Where(failure => failure.ErrorCode == "validation_failed",
+ "the auth server answers 400 validation_failed to a phone number that is not E.164 on a phone factor");
+ }
+
+ [TestMethod]
+ public async Task UpdateFactorAsync_ShouldThrowGotrueException_GivenNullResponseBody()
+ {
+ this.AnswerWith(HttpStatusCode.OK, "null");
+ var update = () => this.Admin.UpdateFactorAsync(UserId, FactorId, Rename);
+ await update.Should().ThrowExactlyAsync().WithMessage("*returned no factor*",
+ "a 200 always carries the updated factor; anything else is a broken response, not a factor");
+ }
+
+ [TestMethod]
+ public async Task UpdateFactorAsync_ShouldThrowArgumentNull_GivenNullChanges()
+ {
+ var update = () => this.Admin.UpdateFactorAsync(UserId, FactorId, null!);
+ await update.Should().ThrowExactlyAsync().WithParameterName("changes");
+ this.server.CountReceivedRequests().Should().Be(0, "argument misuse is rejected before any I/O");
+ }
+
+ [TestMethod]
+ public void UpdateFactorAsync_ShouldComplete_GivenCallerBlockingOnContextThatNeverRunsContinuations()
+ {
+ this.AnswerWith(HttpStatusCode.OK, FactorResponses.PhoneFactor);
+ BlockingCaller.Completes(() => this.Admin.UpdateFactorAsync(UserId, FactorId, Rename)).Should().BeTrue(
+ "a library must not resume on the caller's context: a UI thread blocking on the task would deadlock");
+ }
+
+ [TestMethod]
+ public async Task UpdateFactorAsync_ShouldNotSendRequest_GivenCancelledToken()
+ {
+ using var cancelled = new CancellationTokenSource();
+ cancelled.Cancel();
+ var update = () => this.Admin.UpdateFactorAsync(UserId, FactorId, Rename, cancelled.Token);
+ await update.Should().ThrowAsync();
+ this.server.CountReceivedRequests().Should().Be(0, "a cancelled token must stop the request before it reaches the wire");
+ }
+
+ private AdminClient Admin => new(ServiceKey, new ClientOptions { Url = this.server.Url });
+
+ private void AnswerWith(HttpStatusCode status, string body) =>
+ this.server.Given(Request.Create().WithPath(new WildcardMatcher("/admin/users/*/factors/*")).UsingPut())
+ .RespondWith(Response.Create().WithStatusCode(status)
+ .WithHeader("Content-Type", "application/json").WithBody(body));
+}
diff --git a/packages/Gotrue/Gotrue.Tests/UpdateFactor/UpdateFactorIdTests.cs b/packages/Gotrue/Gotrue.Tests/UpdateFactor/UpdateFactorIdTests.cs
new file mode 100644
index 00000000..e559a63b
--- /dev/null
+++ b/packages/Gotrue/Gotrue.Tests/UpdateFactor/UpdateFactorIdTests.cs
@@ -0,0 +1,74 @@
+#region
+
+using System;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Supabase.Gotrue;
+using Supabase.Gotrue.UpdateFactor;
+
+#endregion
+
+namespace Gotrue.Tests.UpdateFactor;
+
+///
+/// The id checks made before a factor update leaves the SDK: both ids must be canonical UUIDs, the form the auth
+/// server issues them in, so a typo never reaches the server as a 404 that reads like a missing record.
+///
+[TestClass]
+[TestCategory("Unit")]
+public class UpdateFactorIdTests
+{
+ private const string Uuid = "2b306a77-21dc-4110-ba71-537cb56b9e98";
+
+ private static readonly AdminClient Admin = new("service-role-jwt", new ClientOptions { Url = "http://localhost" });
+
+ private static readonly FactorChanges Rename = new() { FriendlyName = "Renamed" };
+
+ [TestMethod]
+ [DataRow("")]
+ [DataRow("not-a-uuid")]
+ [DataRow("2b306a7721dc4110ba71537cb56b9e98")]
+ [DataRow("{2b306a77-21dc-4110-ba71-537cb56b9e98}")]
+ [DataRow("x/../../users/2b306a77-21dc-4110-ba71-537cb56b9e98")]
+ public async Task UpdateFactorAsync_ShouldThrowArgument_GivenUserIdThatIsNotCanonicalUuid(string userId)
+ {
+ var update = () => Admin.UpdateFactorAsync(userId, Uuid, Rename);
+ await update.Should().ThrowExactlyAsync()
+ .WithParameterName("userId", "the spec rejects a user id that is not a UUID before any request is sent");
+ }
+
+ [TestMethod]
+ [DataRow("")]
+ [DataRow("not-a-uuid")]
+ [DataRow("2b306a7721dc4110ba71537cb56b9e98")]
+ [DataRow("x/../../2b306a77-21dc-4110-ba71-537cb56b9e98")]
+ public async Task UpdateFactorAsync_ShouldThrowArgument_GivenFactorIdThatIsNotCanonicalUuid(string factorId)
+ {
+ var update = () => Admin.UpdateFactorAsync(Uuid, factorId, Rename);
+ await update.Should().ThrowExactlyAsync()
+ .WithParameterName("factorId", "the spec rejects a factor id that is not a UUID before any request is sent");
+ }
+
+ [TestMethod]
+ public async Task UpdateFactorAsync_ShouldShowTheExpectedForm_GivenIdThatIsNotCanonicalUuid()
+ {
+ var update = () => Admin.UpdateFactorAsync(Uuid, "not-a-uuid", Rename);
+ await update.Should().ThrowExactlyAsync()
+ .WithMessage("*canonical form, for example '????????-????-????-????-????????????'*", "the caller learns which form of id the SDK accepts without reading its source");
+ }
+
+ [TestMethod]
+ public async Task UpdateFactorAsync_ShouldThrowArgumentNull_GivenNullUserId()
+ {
+ var update = () => Admin.UpdateFactorAsync(null!, Uuid, Rename);
+ await update.Should().ThrowExactlyAsync().WithParameterName("userId");
+ }
+
+ [TestMethod]
+ public async Task UpdateFactorAsync_ShouldThrowArgumentNull_GivenNullFactorId()
+ {
+ var update = () => Admin.UpdateFactorAsync(Uuid, null!, Rename);
+ await update.Should().ThrowExactlyAsync().WithParameterName("factorId");
+ }
+}
diff --git a/packages/Gotrue/Gotrue.Tests/UpdateFactor/UpdateFactorTests.cs b/packages/Gotrue/Gotrue.Tests/UpdateFactor/UpdateFactorTests.cs
new file mode 100644
index 00000000..f497910b
--- /dev/null
+++ b/packages/Gotrue/Gotrue.Tests/UpdateFactor/UpdateFactorTests.cs
@@ -0,0 +1,79 @@
+#region
+
+using System;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Gotrue.Tests.Mfa;
+using Gotrue.Tests.Support;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Supabase.Gotrue.Exceptions;
+using Supabase.Gotrue.UpdateFactor;
+
+#endregion
+
+namespace Gotrue.Tests.UpdateFactor;
+
+///
+/// End-to-end service-role update of a user's MFA factor against the live stack: renaming a factor, a phone number
+/// sent for a factor that is not a phone factor, and the failures the auth server answers with.
+///
+[TestClass]
+[TestCategory("E2E")]
+public class UpdateFactorTests : MfaFixture
+{
+ [TestMethod]
+ public async Task UpdateFactorAsync_ShouldRenameTheFactor()
+ {
+ var session = await this.SignUpNewUser();
+ var enrollment = await this.EnrollTotp();
+ var updated = await this.Admin.UpdateFactorAsync(session.User!.Id!, enrollment.Id, new FactorChanges { FriendlyName = "Work authenticator" });
+ updated.FriendlyName.Should().Be("Work authenticator", "the auth server applies the new friendly name and answers with the stored factor");
+ }
+
+ [TestMethod]
+ public async Task UpdateFactorAsync_ShouldIgnoreThePhone_GivenTotpFactor()
+ {
+ var session = await this.SignUpNewUser();
+ var enrollment = await this.EnrollTotp();
+ var updated = await this.Admin.UpdateFactorAsync(session.User!.Id!, enrollment.Id, new FactorChanges { Phone = "+15555550199" });
+ updated.Phone.Should().BeNullOrEmpty("the auth server ignores a phone number sent for a factor that is not a phone factor");
+ }
+
+ [TestMethod]
+ public async Task UpdateFactorAsync_ShouldThrowFactorNotFound_GivenUnknownFactor()
+ {
+ var session = await this.SignUpNewUser();
+ var update = () => this.Admin.UpdateFactorAsync(session.User!.Id!, Guid.NewGuid().ToString(), new FactorChanges { FriendlyName = "Renamed" });
+ await update.Should().ThrowAsync()
+ .Where(failure => failure.ErrorCode == "mfa_factor_not_found", "the auth server answers 404 mfa_factor_not_found when the user owns no such factor");
+ }
+
+ [TestMethod]
+ public async Task UpdateFactorAsync_ShouldThrowUserNotFound_GivenUnknownUser()
+ {
+ var update = () => this.Admin.UpdateFactorAsync(Guid.NewGuid().ToString(), Guid.NewGuid().ToString(), new FactorChanges { FriendlyName = "Renamed" });
+ await update.Should().ThrowAsync()
+ .Where(failure => failure.ErrorCode == "user_not_found", "the auth server answers 404 user_not_found when no user has the id");
+ }
+
+ [TestMethod]
+ public async Task UpdateFactorAsync_ShouldThrow_GivenFriendlyNameOfAnotherFactor()
+ {
+ var session = await this.SignUpNewUser();
+ var first = await this.EnrollTotp();
+ var second = await this.EnrollTotp("Second authenticator");
+ var update = () => this.Admin.UpdateFactorAsync(session.User!.Id!, second.Id, new FactorChanges { FriendlyName = first.FriendlyName });
+ await update.Should().ThrowAsync()
+ .Where(failure => failure.StatusCode == 500, "the auth server does not check names on update, so the unique index on (friendly_name, user_id) fails the write");
+ }
+
+ [TestMethod]
+ public async Task UpdateFactorAsync_ShouldThrowAdminTokenRequired_GivenNonAdminKey()
+ {
+ var session = await this.SignUpNewUser();
+ var enrollment = await this.EnrollTotp();
+ var update = () => TestClients.AdminAgainstCliStack(TestClients.CliAnonKey).UpdateFactorAsync(session.User!.Id!, enrollment.Id, new FactorChanges { FriendlyName = "Renamed" });
+ await update.Should().ThrowAsync()
+ .Where(failure => failure.Reason == FailureHint.Reason.AdminTokenRequired, "the auth server answers 403 not_admin to a key without the service_role claim");
+ }
+}
diff --git a/packages/Gotrue/Gotrue/AdminClient.cs b/packages/Gotrue/Gotrue/AdminClient.cs
index b1001034..d442d4af 100644
--- a/packages/Gotrue/Gotrue/AdminClient.cs
+++ b/packages/Gotrue/Gotrue/AdminClient.cs
@@ -9,6 +9,7 @@
using Supabase.Gotrue.Mfa;
using Supabase.Gotrue.Responses;
using Supabase.Gotrue.UpdateCustomProvider;
+using Supabase.Gotrue.UpdateFactor;
namespace Supabase.Gotrue;
@@ -145,6 +146,11 @@ public async Task DeleteUser(string uid, bool shouldSoftDelete = false)
[ImplementedCapability("auth.admin.delete_mfa_factor")]
public async Task DeleteFactor(MfaAdminDeleteFactorParams deleteFactorParams) => await this.api.DeleteFactor(this.serviceKey, deleteFactorParams);
+ ///
+ [ImplementedCapability("auth.admin.update_mfa_factor")]
+ public Task UpdateFactorAsync(string userId, string factorId, FactorChanges changes, CancellationToken cancellationToken = default) =>
+ this.api.UpdateFactorAsync(this.serviceKey, userId, factorId, changes, cancellationToken);
+
///
[ImplementedCapability("auth.admin.delete_provider")]
public Task DeleteCustomProviderAsync(string identifier, CancellationToken cancellationToken = default) =>
diff --git a/packages/Gotrue/Gotrue/Api.cs b/packages/Gotrue/Gotrue/Api.cs
index 2c83effc..e2d9331a 100644
--- a/packages/Gotrue/Gotrue/Api.cs
+++ b/packages/Gotrue/Gotrue/Api.cs
@@ -19,6 +19,7 @@
using Supabase.Gotrue.Resend;
using Supabase.Gotrue.Responses;
using Supabase.Gotrue.UpdateCustomProvider;
+using Supabase.Gotrue.UpdateFactor;
using static Supabase.Gotrue.Constants;
namespace Supabase.Gotrue;
@@ -642,6 +643,18 @@ public ProviderAuthState GetUriForProvider(Provider provider, SignInOptions? opt
///
public Task DeleteFactor(string jwt, MfaAdminDeleteFactorParams deleteFactorParams) => this.MakeRequestAsync(HttpMethod.Delete, $"{this.Url}/admin/users/{PathSegment.Escape(deleteFactorParams.UserId)}/factors/{PathSegment.Escape(deleteFactorParams.Id)}", null, this.CreateAuthedRequestHeaders(jwt));
+ ///
+ public async Task UpdateFactorAsync(string jwt, string userId, string factorId, FactorChanges changes, CancellationToken cancellationToken = default)
+ {
+ var url = this.AdminFactorUrl(userId, factorId);
+ var body = UpdateFactorBody.For(changes);
+ var updated = await this.MakeRequestAsync(HttpMethod.Put, url, body, this.CreateAuthedRequestHeaders(jwt), cancellationToken).ConfigureAwait(false);
+ return updated ?? throw new GotrueException("The auth server returned no factor.");
+ }
+
+ private string AdminFactorUrl(string userId, string factorId) =>
+ $"{this.Url}/admin/users/{CanonicalUuid.EnsureRoutable(userId)}/factors/{CanonicalUuid.EnsureRoutable(factorId)}";
+
///
public Task DeleteCustomProviderAsync(string jwt, string identifier, CancellationToken cancellationToken = default) =>
this.MakeRequestAsync(HttpMethod.Delete, this.CustomProviderUrl(identifier), null, this.CreateAuthedRequestHeaders(jwt), cancellationToken);
diff --git a/packages/Gotrue/Gotrue/Interfaces/IGotrueAdminClient.cs b/packages/Gotrue/Gotrue/Interfaces/IGotrueAdminClient.cs
index 97894232..0abbd74e 100644
--- a/packages/Gotrue/Gotrue/Interfaces/IGotrueAdminClient.cs
+++ b/packages/Gotrue/Gotrue/Interfaces/IGotrueAdminClient.cs
@@ -8,6 +8,7 @@
using Supabase.Gotrue.Mfa;
using Supabase.Gotrue.Responses;
using Supabase.Gotrue.UpdateCustomProvider;
+using Supabase.Gotrue.UpdateFactor;
namespace Supabase.Gotrue.Interfaces;
@@ -141,6 +142,41 @@ public interface IGotrueAdminClient : IGettableHeaders
/// A containing the deleted factor id.
Task DeleteFactor(MfaAdminDeleteFactorParams deleteFactorParams);
+ ///
+ /// Updates a user's MFA factor, such as its friendly name or the phone number of a phone factor. Only the members set
+ /// on are sent; the others keep their current value. Requires a full-access API key, such
+ /// as the service_role key: call it from a server, never from a client app.
+ ///
+ /// The id of the user who owns the factor, a UUID such as 8d3f1c2a-5b6e-4f70-9a81-b2c3d4e5f607.
+ /// The id of the factor, a UUID such as 2b306a77-21dc-4110-ba71-537cb56b9e98.
+ ///
+ /// The changes. An empty or blank value cannot clear a setting: the server reads it as unchanged. The server ignores
+ /// on a factor that is not a phone factor.
+ ///
+ /// Cancels the request.
+ /// The updated factor.
+ /// , or is null. Nothing is sent.
+ ///
+ /// or is not a UUID in its canonical, hyphenated form. Nothing is sent.
+ ///
+ ///
+ /// The server rejected the request. is user_not_found when
+ /// no user has , mfa_factor_not_found when the user owns no factor with
+ /// , and validation_failed when the phone number is not in E.164 format;
+ /// is
+ /// when the key is not a full-access key. A friendly name another factor of the user already has fails with
+ /// status code 500.
+ ///
+ ///
+ ///
+ /// using Supabase.Gotrue.UpdateFactor;
+ ///
+ /// var admin = new AdminClient(serviceRoleKey, new ClientOptions { Url = authUrl });
+ /// var factor = await admin.UpdateFactorAsync(userId, factorId, new FactorChanges { FriendlyName = "Work phone" });
+ ///
+ ///
+ Task UpdateFactorAsync(string userId, string factorId, FactorChanges changes, CancellationToken cancellationToken = default);
+
///
/// Deletes a custom OAuth/OIDC provider. Requires a full-access API key, such as the service_role key:
/// call it from a server, never from a client app.
diff --git a/packages/Gotrue/Gotrue/Interfaces/IGotrueApi.cs b/packages/Gotrue/Gotrue/Interfaces/IGotrueApi.cs
index 36872cd1..e42350a5 100644
--- a/packages/Gotrue/Gotrue/Interfaces/IGotrueApi.cs
+++ b/packages/Gotrue/Gotrue/Interfaces/IGotrueApi.cs
@@ -10,6 +10,7 @@
using Supabase.Gotrue.Resend;
using Supabase.Gotrue.Responses;
using Supabase.Gotrue.UpdateCustomProvider;
+using Supabase.Gotrue.UpdateFactor;
using static Supabase.Gotrue.Constants;
#pragma warning disable CS1591
@@ -71,6 +72,7 @@ public interface IGotrueApi : IGettableHeaders
Task Unenroll(string jwt, MfaUnenrollParams mfaVerifyParams);
Task ListFactors(string jwt, MfaAdminListFactorsParams listFactorsParams);
Task DeleteFactor(string jwt, MfaAdminDeleteFactorParams deleteFactorParams);
+ Task UpdateFactorAsync(string jwt, string userId, string factorId, FactorChanges changes, CancellationToken cancellationToken = default);
///
/// Deletes a custom OAuth/OIDC provider.
diff --git a/packages/Gotrue/Gotrue/Mfa/Factor.cs b/packages/Gotrue/Gotrue/Mfa/Factor.cs
index 2c293f8d..e8f054b6 100644
--- a/packages/Gotrue/Gotrue/Mfa/Factor.cs
+++ b/packages/Gotrue/Gotrue/Mfa/Factor.cs
@@ -22,4 +22,11 @@ public class Factor
[JsonPropertyName("updated_at")]
public DateTime UpdatedAt { get; set; }
+
+ ///
+ /// Phone factors only: the phone number codes are sent to, in E.164 format without the leading +. Null on other
+ /// factor types.
+ ///
+ [JsonPropertyName("phone")]
+ public string? Phone { get; set; }
}
diff --git a/packages/Gotrue/Gotrue/PublicAPI.Unshipped.txt b/packages/Gotrue/Gotrue/PublicAPI.Unshipped.txt
index c6c65b66..cf406715 100644
--- a/packages/Gotrue/Gotrue/PublicAPI.Unshipped.txt
+++ b/packages/Gotrue/Gotrue/PublicAPI.Unshipped.txt
@@ -14,6 +14,9 @@ override Supabase.Gotrue.CreateCustomProvider.NewCustomProvider.ToString() -> st
override Supabase.Gotrue.UpdateCustomProvider.CustomProviderChanges.Equals(object? obj) -> bool
override Supabase.Gotrue.UpdateCustomProvider.CustomProviderChanges.GetHashCode() -> int
override Supabase.Gotrue.UpdateCustomProvider.CustomProviderChanges.ToString() -> string!
+override Supabase.Gotrue.UpdateFactor.FactorChanges.Equals(object? obj) -> bool
+override Supabase.Gotrue.UpdateFactor.FactorChanges.GetHashCode() -> int
+override Supabase.Gotrue.UpdateFactor.FactorChanges.ToString() -> string!
static Supabase.Gotrue.Common.CustomProvider.operator !=(Supabase.Gotrue.Common.CustomProvider? left, Supabase.Gotrue.Common.CustomProvider? right) -> bool
static Supabase.Gotrue.Common.CustomProvider.operator ==(Supabase.Gotrue.Common.CustomProvider? left, Supabase.Gotrue.Common.CustomProvider? right) -> bool
static Supabase.Gotrue.Common.CustomProviderOptions.operator !=(Supabase.Gotrue.Common.CustomProviderOptions? left, Supabase.Gotrue.Common.CustomProviderOptions? right) -> bool
@@ -26,14 +29,18 @@ static Supabase.Gotrue.CreateCustomProvider.NewCustomProvider.operator !=(Supaba
static Supabase.Gotrue.CreateCustomProvider.NewCustomProvider.operator ==(Supabase.Gotrue.CreateCustomProvider.NewCustomProvider? left, Supabase.Gotrue.CreateCustomProvider.NewCustomProvider? right) -> bool
static Supabase.Gotrue.UpdateCustomProvider.CustomProviderChanges.operator !=(Supabase.Gotrue.UpdateCustomProvider.CustomProviderChanges? left, Supabase.Gotrue.UpdateCustomProvider.CustomProviderChanges? right) -> bool
static Supabase.Gotrue.UpdateCustomProvider.CustomProviderChanges.operator ==(Supabase.Gotrue.UpdateCustomProvider.CustomProviderChanges? left, Supabase.Gotrue.UpdateCustomProvider.CustomProviderChanges? right) -> bool
+static Supabase.Gotrue.UpdateFactor.FactorChanges.operator !=(Supabase.Gotrue.UpdateFactor.FactorChanges? left, Supabase.Gotrue.UpdateFactor.FactorChanges? right) -> bool
+static Supabase.Gotrue.UpdateFactor.FactorChanges.operator ==(Supabase.Gotrue.UpdateFactor.FactorChanges? left, Supabase.Gotrue.UpdateFactor.FactorChanges? right) -> bool
Supabase.Gotrue.AdminClient.CreateCustomProviderAsync(Supabase.Gotrue.CreateCustomProvider.NewCustomProvider! provider, Supabase.Gotrue.Common.CustomProviderOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
Supabase.Gotrue.AdminClient.GetCustomProviderAsync(string! identifier, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
Supabase.Gotrue.AdminClient.ListCustomProvidersAsync(Supabase.Gotrue.Common.CustomProviderType? type = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
Supabase.Gotrue.AdminClient.UpdateCustomProviderAsync(string! identifier, Supabase.Gotrue.UpdateCustomProvider.CustomProviderChanges! changes, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Supabase.Gotrue.AdminClient.UpdateFactorAsync(string! userId, string! factorId, Supabase.Gotrue.UpdateFactor.FactorChanges! changes, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
Supabase.Gotrue.Api.CreateCustomProviderAsync(string! jwt, Supabase.Gotrue.CreateCustomProvider.NewCustomProvider! provider, Supabase.Gotrue.Common.CustomProviderOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
Supabase.Gotrue.Api.GetCustomProviderAsync(string! jwt, string! identifier, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
Supabase.Gotrue.Api.ListCustomProvidersAsync(string! jwt, Supabase.Gotrue.Common.CustomProviderType? type = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
Supabase.Gotrue.Api.UpdateCustomProviderAsync(string! jwt, string! identifier, Supabase.Gotrue.UpdateCustomProvider.CustomProviderChanges! changes, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Supabase.Gotrue.Api.UpdateFactorAsync(string! jwt, string! userId, string! factorId, Supabase.Gotrue.UpdateFactor.FactorChanges! changes, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
Supabase.Gotrue.Client.GetUserIdentitiesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
Supabase.Gotrue.Common.CustomProvider
Supabase.Gotrue.Common.CustomProvider.$() -> Supabase.Gotrue.Common.CustomProvider!
@@ -152,6 +159,7 @@ Supabase.Gotrue.Interfaces.IGotrueAdminClient.CreateCustomProviderAsync(S
Supabase.Gotrue.Interfaces.IGotrueAdminClient.GetCustomProviderAsync(string! identifier, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
Supabase.Gotrue.Interfaces.IGotrueAdminClient.ListCustomProvidersAsync(Supabase.Gotrue.Common.CustomProviderType? type = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
Supabase.Gotrue.Interfaces.IGotrueAdminClient.UpdateCustomProviderAsync(string! identifier, Supabase.Gotrue.UpdateCustomProvider.CustomProviderChanges! changes, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Supabase.Gotrue.Interfaces.IGotrueAdminClient.UpdateFactorAsync(string! userId, string! factorId, Supabase.Gotrue.UpdateFactor.FactorChanges! changes, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
Supabase.Gotrue.Interfaces.IGotrueApi.CreateCustomProviderAsync(string! jwt, Supabase.Gotrue.CreateCustomProvider.NewCustomProvider! provider, Supabase.Gotrue.Common.CustomProviderOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
Supabase.Gotrue.AdminClient.SignOutAsync(string! accessToken, Supabase.Gotrue.Constants.SignOutScope scope = Supabase.Gotrue.Constants.SignOutScope.Global, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
Supabase.Gotrue.Api.SignOutAsync(string! jwt, Supabase.Gotrue.Constants.SignOutScope scope = Supabase.Gotrue.Constants.SignOutScope.Global, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
@@ -160,7 +168,10 @@ Supabase.Gotrue.Interfaces.IGotrueApi.GetCustomProviderAsync(st
Supabase.Gotrue.Interfaces.IGotrueApi.ListCustomProvidersAsync(string! jwt, Supabase.Gotrue.Common.CustomProviderType? type = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
Supabase.Gotrue.Interfaces.IGotrueApi.SignOutAsync(string! jwt, Supabase.Gotrue.Constants.SignOutScope scope = Supabase.Gotrue.Constants.SignOutScope.Global, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
Supabase.Gotrue.Interfaces.IGotrueApi.UpdateCustomProviderAsync(string! jwt, string! identifier, Supabase.Gotrue.UpdateCustomProvider.CustomProviderChanges! changes, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Supabase.Gotrue.Interfaces.IGotrueApi.UpdateFactorAsync(string! jwt, string! userId, string! factorId, Supabase.Gotrue.UpdateFactor.FactorChanges! changes, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
Supabase.Gotrue.Interfaces.IGotrueClient.GetUserIdentitiesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
+Supabase.Gotrue.Mfa.Factor.Phone.get -> string?
+Supabase.Gotrue.Mfa.Factor.Phone.set -> void
Supabase.Gotrue.UpdateCustomProvider.CustomProviderChanges
Supabase.Gotrue.UpdateCustomProvider.CustomProviderChanges.$() -> Supabase.Gotrue.UpdateCustomProvider.CustomProviderChanges!
Supabase.Gotrue.UpdateCustomProvider.CustomProviderChanges.AuthorizationUrl.get -> System.Uri?
@@ -181,3 +192,11 @@ Supabase.Gotrue.UpdateCustomProvider.CustomProviderChanges.TokenUrl.get -> Syste
Supabase.Gotrue.UpdateCustomProvider.CustomProviderChanges.TokenUrl.init -> void
Supabase.Gotrue.UpdateCustomProvider.CustomProviderChanges.UserInfoUrl.get -> System.Uri?
Supabase.Gotrue.UpdateCustomProvider.CustomProviderChanges.UserInfoUrl.init -> void
+Supabase.Gotrue.UpdateFactor.FactorChanges
+Supabase.Gotrue.UpdateFactor.FactorChanges.$() -> Supabase.Gotrue.UpdateFactor.FactorChanges!
+Supabase.Gotrue.UpdateFactor.FactorChanges.Equals(Supabase.Gotrue.UpdateFactor.FactorChanges? other) -> bool
+Supabase.Gotrue.UpdateFactor.FactorChanges.FactorChanges() -> void
+Supabase.Gotrue.UpdateFactor.FactorChanges.FriendlyName.get -> string?
+Supabase.Gotrue.UpdateFactor.FactorChanges.FriendlyName.init -> void
+Supabase.Gotrue.UpdateFactor.FactorChanges.Phone.get -> string?
+Supabase.Gotrue.UpdateFactor.FactorChanges.Phone.init -> void
diff --git a/packages/Gotrue/Gotrue/UpdateFactor/CanonicalUuid.cs b/packages/Gotrue/Gotrue/UpdateFactor/CanonicalUuid.cs
new file mode 100644
index 00000000..42ec7a7f
--- /dev/null
+++ b/packages/Gotrue/Gotrue/UpdateFactor/CanonicalUuid.cs
@@ -0,0 +1,18 @@
+using System;
+using System.Runtime.CompilerServices;
+
+namespace Supabase.Gotrue.UpdateFactor;
+
+internal static class CanonicalUuid
+{
+ private const string HyphenatedFormat = "D";
+
+ internal static string EnsureRoutable(string value, [CallerArgumentExpression(nameof(value))] string? argumentName = null)
+ {
+ if (value is null)
+ throw new ArgumentNullException(argumentName);
+ if (!Guid.TryParseExact(value, HyphenatedFormat, out _))
+ throw new ArgumentException("The id must be a UUID in its canonical form, for example '2b306a77-21dc-4110-ba71-537cb56b9e98'.", argumentName);
+ return value;
+ }
+}
diff --git a/packages/Gotrue/Gotrue/UpdateFactor/FactorChanges.cs b/packages/Gotrue/Gotrue/UpdateFactor/FactorChanges.cs
new file mode 100644
index 00000000..4330273c
--- /dev/null
+++ b/packages/Gotrue/Gotrue/UpdateFactor/FactorChanges.cs
@@ -0,0 +1,16 @@
+namespace Supabase.Gotrue.UpdateFactor;
+
+///
+/// Changes to a user's MFA factor. A member left null or empty keeps its current value: the auth server cannot clear one.
+///
+public sealed record FactorChanges
+{
+ /// The new friendly name.
+ public string? FriendlyName { get; init; }
+
+ ///
+ /// Phone factors only: the new phone number, in E.164 format (for example +15555550100). The server ignores it on
+ /// any other factor type.
+ ///
+ public string? Phone { get; init; }
+}
diff --git a/packages/Gotrue/Gotrue/UpdateFactor/UpdateFactorBody.cs b/packages/Gotrue/Gotrue/UpdateFactor/UpdateFactorBody.cs
new file mode 100644
index 00000000..118432a0
--- /dev/null
+++ b/packages/Gotrue/Gotrue/UpdateFactor/UpdateFactorBody.cs
@@ -0,0 +1,24 @@
+using System;
+using System.Text.Json.Serialization;
+
+namespace Supabase.Gotrue.UpdateFactor;
+
+internal sealed record UpdateFactorBody
+{
+ private UpdateFactorBody(FactorChanges changes)
+ {
+ this.FriendlyName = changes.FriendlyName;
+ this.Phone = changes.Phone;
+ }
+
+ [JsonPropertyName("friendly_name")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? FriendlyName { get; }
+
+ [JsonPropertyName("phone")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Phone { get; }
+
+ internal static UpdateFactorBody For(FactorChanges changes) =>
+ new(changes ?? throw new ArgumentNullException(nameof(changes)));
+}