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))); +}