diff --git a/packages/Gotrue/Gotrue.Tests/Gotrue.Tests.csproj b/packages/Gotrue/Gotrue.Tests/Gotrue.Tests.csproj index 5a2f578e..2d702c33 100644 --- a/packages/Gotrue/Gotrue.Tests/Gotrue.Tests.csproj +++ b/packages/Gotrue/Gotrue.Tests/Gotrue.Tests.csproj @@ -36,6 +36,7 @@ + diff --git a/packages/Gotrue/Gotrue.Tests/Observability/ObservabilityContractTests.cs b/packages/Gotrue/Gotrue.Tests/Observability/ObservabilityContractTests.cs index bcd0d6a5..8c0e51a3 100644 --- a/packages/Gotrue/Gotrue.Tests/Observability/ObservabilityContractTests.cs +++ b/packages/Gotrue/Gotrue.Tests/Observability/ObservabilityContractTests.cs @@ -141,6 +141,17 @@ public async Task DomainSpan_ShouldParentTheHttpSpan_GivenRecoveryCodesStatusRea this.activities.Should().ContainSingle(a => a.OperationName == "GET /factors/recovery-codes").Which.ParentSpanId.Should().Be(domainSpan.SpanId); } + [TestMethod] + public async Task DomainSpan_ShouldParentTheHttpSpan_GivenRecoveryCodesUnenrollment() + { + this.MockTokenSuccess(); + await this.Refresh(); + this.MockRecoveryCodesUnenrollmentSuccess(); + await this.client.UnenrollRecoveryCodesAsync(); + var domainSpan = this.activities.Should().ContainSingle(a => a.OperationName == "gotrue.unenroll_recovery_codes").Which; + this.activities.Should().ContainSingle(a => a.OperationName == "DELETE /factors/recovery-codes").Which.ParentSpanId.Should().Be(domainSpan.SpanId); + } + [TestMethod] public async Task HttpSpan_ShouldBeMarkedError_GivenFailedRequest() { @@ -255,6 +266,14 @@ private void MockRecoveryCodesStatusSuccess() => .WithHeader("Content-Type", "application/json") .WithBody(File.ReadAllText(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "GetRecoveryCodesStatus", "Data", "recovery_codes_status.json")))); + private void MockRecoveryCodesUnenrollmentSuccess() => + this.server + .Given(Request.Create().WithPath("/factors/recovery-codes").UsingDelete()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(File.ReadAllText(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "UnenrollRecoveryCodes", "Data", "unenrolled.json")))); + private void MockTokenFailure() => this.server .Given(Request.Create().WithPath("/token").UsingPost()) diff --git a/packages/Gotrue/Gotrue.Tests/UnenrollRecoveryCodes/Data/insufficient_aal_error.json b/packages/Gotrue/Gotrue.Tests/UnenrollRecoveryCodes/Data/insufficient_aal_error.json new file mode 100644 index 00000000..c528ead2 --- /dev/null +++ b/packages/Gotrue/Gotrue.Tests/UnenrollRecoveryCodes/Data/insufficient_aal_error.json @@ -0,0 +1,5 @@ +{ + "code": 403, + "error_code": "insufficient_aal", + "msg": "AAL2 required to delete recovery codes" +} diff --git a/packages/Gotrue/Gotrue.Tests/UnenrollRecoveryCodes/Data/not_enrolled_error.json b/packages/Gotrue/Gotrue.Tests/UnenrollRecoveryCodes/Data/not_enrolled_error.json new file mode 100644 index 00000000..ae20295e --- /dev/null +++ b/packages/Gotrue/Gotrue.Tests/UnenrollRecoveryCodes/Data/not_enrolled_error.json @@ -0,0 +1,5 @@ +{ + "code": 404, + "error_code": "mfa_factor_not_found", + "msg": "The user has not enrolled recovery codes" +} diff --git a/packages/Gotrue/Gotrue.Tests/UnenrollRecoveryCodes/Data/unenrolled.json b/packages/Gotrue/Gotrue.Tests/UnenrollRecoveryCodes/Data/unenrolled.json new file mode 100644 index 00000000..b4951f74 --- /dev/null +++ b/packages/Gotrue/Gotrue.Tests/UnenrollRecoveryCodes/Data/unenrolled.json @@ -0,0 +1,3 @@ +{ + "id": "2b306a77-21dc-4110-ba71-537cb56b9e98" +} diff --git a/packages/Gotrue/Gotrue.Tests/UnenrollRecoveryCodes/UnenrollRecoveryCodesContractTests.cs b/packages/Gotrue/Gotrue.Tests/UnenrollRecoveryCodes/UnenrollRecoveryCodesContractTests.cs new file mode 100644 index 00000000..c89cb8b3 --- /dev/null +++ b/packages/Gotrue/Gotrue.Tests/UnenrollRecoveryCodes/UnenrollRecoveryCodesContractTests.cs @@ -0,0 +1,162 @@ +#region + +using System; +using System.IO; +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.Interfaces; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using static Supabase.Gotrue.Exceptions.FailureHint.Reason; + +#endregion + +namespace Gotrue.Tests.UnenrollRecoveryCodes; + +/// +/// Pins how UnenrollRecoveryCodesAsync removes the signed-in user's MFA recovery codes: the +/// DELETE /factors/recovery-codes request it puts on the wire, and the failures raised before and after the +/// request, all against a stubbed server. +/// +[TestClass] +[TestCategory("Contract")] +public class UnenrollRecoveryCodesContractTests +{ + private const string AccessToken = "an-access-token"; + private IGotrueClient client = null!; + private MockGotrueServer server = null!; + + [TestInitialize] + public void TestInitializer() + { + this.server = new MockGotrueServer(); + this.client = TestClients.Against(this.server); + } + + [TestCleanup] + public void TestCleanup() => this.server.Dispose(); + + [TestMethod] + public async Task UnenrollRecoveryCodesAsync_ShouldDeleteTheRecoveryCodesWithTheSessionBearer() + { + this.SignIn(); + this.MockUnenrollResponse(200, Fixture("unenrolled.json")); + await this.client.UnenrollRecoveryCodesAsync(); + this.server.VerifySingleReceivedRequest() + .WithMethod(HttpMethod.Delete) + .WithPath("/factors/recovery-codes") + .WithHeader("Authorization", $"Bearer {AccessToken}") + .WithHeader("apikey", MockGotrueServer.ApiKey); + } + + [TestMethod] + public async Task UnenrollRecoveryCodesAsync_ShouldComplete_GivenEnrolledUser() + { + this.SignIn(); + this.MockUnenrollResponse(200, Fixture("unenrolled.json")); + var unenrolling = () => this.client.UnenrollRecoveryCodesAsync(); + await unenrolling.Should().NotThrowAsync("GoTrue answers 200 with the removed factor id once the recovery codes are deleted"); + } + + [TestMethod] + public async Task UnenrollRecoveryCodesAsync_ShouldThrowServerRejection_GivenSessionBelowAal2() + { + this.SignIn(); + this.MockUnenrollResponse(403, Fixture("insufficient_aal_error.json")); + var unenrolling = () => this.client.UnenrollRecoveryCodesAsync(); + (await unenrolling.Should().ThrowAsync()).Which.ErrorCode.Should().Be("insufficient_aal", + "DELETE /factors/recovery-codes requires an aal2 session (auth OpenAPI, 403 insufficient_aal)"); + } + + [TestMethod] + public async Task UnenrollRecoveryCodesAsync_ShouldThrowServerRejection_GivenUserWithoutRecoveryCodes() + { + this.SignIn(); + this.MockUnenrollResponse(404, Fixture("not_enrolled_error.json")); + var unenrolling = () => this.client.UnenrollRecoveryCodesAsync(); + (await unenrolling.Should().ThrowAsync()).Which.ErrorCode.Should().Be("mfa_factor_not_found", + "there is nothing to remove when the user has not enrolled recovery codes (auth OpenAPI, 404 mfa_factor_not_found)"); + } + + [TestMethod] + public async Task UnenrollRecoveryCodesAsync_ShouldThrowNoSessionFound_GivenNoSession() + { + var unenrolling = () => this.client.UnenrollRecoveryCodesAsync(); + (await unenrolling.Should().ThrowAsync().WithMessage("A valid session is required.")).Which.Reason.Should().Be(NoSessionFound, + "DELETE /factors/recovery-codes requires the signed-in user's access token"); + } + + [TestMethod] + public async Task UnenrollRecoveryCodesAsync_ShouldThrowNoSessionFound_GivenSessionWithoutAccessToken() + { + this.SignIn(new Session { RefreshToken = "a-refresh-token", ExpiresIn = 3600 }); + var unenrolling = () => this.client.UnenrollRecoveryCodesAsync(); + (await unenrolling.Should().ThrowAsync()).Which.Reason.Should().Be(NoSessionFound, + "DELETE /factors/recovery-codes requires the signed-in user's access token"); + } + + [TestMethod] + public async Task UnenrollRecoveryCodesAsync_ShouldNotSendARequest_GivenNoSession() + { + var unenrolling = () => this.client.UnenrollRecoveryCodesAsync(); + await unenrolling.Should().ThrowAsync(); + this.server.CountReceivedRequests().Should().Be(0, "the missing session is detected before the wire"); + } + + [TestMethod] + public async Task UnenrollRecoveryCodesAsync_ShouldThrowOffline_GivenClientIsOffline() + { + this.SignIn(); + this.client.Online = false; + var unenrolling = () => this.client.UnenrollRecoveryCodesAsync(); + (await unenrolling.Should().ThrowAsync().WithMessage("Only supported when online")).Which.Reason.Should().Be(Offline, + "removing the recovery codes needs the server, like the other MFA operations"); + } + + [TestMethod] + public async Task UnenrollRecoveryCodesAsync_ShouldCancelTheRequest_GivenCancelledToken() + { + this.SignIn(); + this.MockUnenrollResponse(200, Fixture("unenrolled.json")); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var unenrolling = () => this.client.UnenrollRecoveryCodesAsync(cancellation.Token); + await unenrolling.Should().ThrowAsync("the cancellation token reaches the HTTP call"); + } + + [TestMethod] + public void UnenrollRecoveryCodesAsync_ShouldComplete_GivenCallerBlockingOnContextThatNeverRunsContinuations() + { + this.SignIn(); + this.MockUnenrollResponse(200, Fixture("unenrolled.json")); + BlockingCaller.Completes(() => this.client.UnenrollRecoveryCodesAsync()).Should().BeTrue( + "a library must not resume on the caller's context: a UI thread blocking on the task would deadlock"); + } + + private void SignIn() => this.SignIn(new Session { AccessToken = AccessToken, RefreshToken = "a-refresh-token", ExpiresIn = 3600, User = new User { Id = "user-id-123" } }); + + private void SignIn(Session session) + { + var persistence = SessionPersistenceSubstitute.Tracking(); + persistence.SaveSession(session); + this.client.SetPersistence(persistence); + this.client.LoadSession(); + } + + private void MockUnenrollResponse(int statusCode, string body) => + this.server + .Given(Request.Create().WithPath("/factors/recovery-codes").UsingDelete()) + .RespondWith(Response.Create() + .WithStatusCode(statusCode) + .WithHeader("Content-Type", "application/json") + .WithBody(body)); + + private static string Fixture(string name) => + File.ReadAllText(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "UnenrollRecoveryCodes", "Data", name)); +} diff --git a/packages/Gotrue/Gotrue.Tests/UnenrollRecoveryCodes/UnenrollRecoveryCodesTests.cs b/packages/Gotrue/Gotrue.Tests/UnenrollRecoveryCodes/UnenrollRecoveryCodesTests.cs new file mode 100644 index 00000000..230b75df --- /dev/null +++ b/packages/Gotrue/Gotrue.Tests/UnenrollRecoveryCodes/UnenrollRecoveryCodesTests.cs @@ -0,0 +1,43 @@ +#region + +using System.Threading.Tasks; +using FluentAssertions; +using Gotrue.Tests.Mfa; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Supabase.Gotrue.Exceptions; +using Supabase.Gotrue.Mfa; + +#endregion + +namespace Gotrue.Tests.UnenrollRecoveryCodes; + +/// +/// End-to-end removal of the signed-in user's MFA recovery codes against the live stack. The local stack +/// cannot enroll recovery codes (the CLI does not expose the auth server's recovery codes enroll setting), +/// so these cover the rejections the stack can reach; a successful removal is pinned by +/// . +/// +[TestClass] +[TestCategory("E2E")] +public class UnenrollRecoveryCodesTests : MfaFixture +{ + [TestMethod] + public async Task UnenrollRecoveryCodesAsync_ShouldThrowInsufficientAal_GivenAal1Session() + { + await this.SignUpNewUser(); + var unenrolling = () => this.Client.UnenrollRecoveryCodesAsync(); + (await unenrolling.Should().ThrowAsync()).Which.ErrorCode.Should().Be("insufficient_aal", + "DELETE /factors/recovery-codes requires an aal2 session (auth OpenAPI, 403 insufficient_aal)"); + } + + [TestMethod] + public async Task UnenrollRecoveryCodesAsync_ShouldThrowFactorNotFound_GivenAal2SessionWithoutRecoveryCodes() + { + await this.SignUpNewUser(); + var enrollment = await this.EnrollTotp(); + await this.Client.ChallengeAndVerify(new MfaChallengeAndVerifyParams { FactorId = enrollment.Id, Code = TotpCode(enrollment) }); + var unenrolling = () => this.Client.UnenrollRecoveryCodesAsync(); + (await unenrolling.Should().ThrowAsync()).Which.ErrorCode.Should().Be("mfa_factor_not_found", + "an aal2 user who never enrolled recovery codes has nothing to remove (auth OpenAPI, 404 mfa_factor_not_found)"); + } +} diff --git a/packages/Gotrue/Gotrue/Api.cs b/packages/Gotrue/Gotrue/Api.cs index 4f9c2248..ea6d050b 100644 --- a/packages/Gotrue/Gotrue/Api.cs +++ b/packages/Gotrue/Gotrue/Api.cs @@ -657,6 +657,10 @@ public ProviderAuthState GetUriForProvider(Provider provider, SignInOptions? opt } } + /// + public Task UnenrollRecoveryCodesAsync(string jwt, CancellationToken cancellationToken = default) => + this.MakeRequestAsync(HttpMethod.Delete, $"{this.Url}/factors/recovery-codes", null, this.CreateAuthedRequestHeaders(jwt), cancellationToken); + /// public Task ListFactors(string jwt, MfaAdminListFactorsParams listFactorsParams) => this.MakeRequestAsync(HttpMethod.Get, $"{this.Url}/admin/users/{PathSegment.Escape(listFactorsParams.UserId)}/factors", null, this.CreateAuthedRequestHeaders(jwt)); diff --git a/packages/Gotrue/Gotrue/Client.cs b/packages/Gotrue/Gotrue/Client.cs index 366c02ba..08748075 100644 --- a/packages/Gotrue/Gotrue/Client.cs +++ b/packages/Gotrue/Gotrue/Client.cs @@ -1224,6 +1224,19 @@ public void StartAutoRefresh() return await this.api.GetRecoveryCodesStatusAsync(accessToken, cancellationToken).ConfigureAwait(false); } + /// + [ImplementedCapability("auth.mfa_recovery_codes.unenroll")] + public async Task UnenrollRecoveryCodesAsync(CancellationToken cancellationToken = default) + { + using var activity = GotrueInstrumentation.Source.StartActivity(GotrueInstrumentation.Spans.UnenrollRecoveryCodes); + if (!this.Online) + { + throw new GotrueException("Only supported when online", Offline); + } + var accessToken = this.CurrentSession?.AccessToken ?? throw new GotrueException("A valid session is required.", NoSessionFound); + await this.api.UnenrollRecoveryCodesAsync(accessToken, cancellationToken).ConfigureAwait(false); + } + /// [ImplementedCapability("auth.session.get_claims")] public async Task GetClaimsAsync(string? jwt = null, GetClaimsOptions? options = null, CancellationToken cancellationToken = default) diff --git a/packages/Gotrue/Gotrue/GotrueInstrumentation.cs b/packages/Gotrue/Gotrue/GotrueInstrumentation.cs index 72fe6470..e1db7ad5 100644 --- a/packages/Gotrue/Gotrue/GotrueInstrumentation.cs +++ b/packages/Gotrue/Gotrue/GotrueInstrumentation.cs @@ -72,6 +72,7 @@ internal static class Spans internal const string LinkIdentityWithIdToken = "gotrue.link_identity.id_token"; internal const string GetUserIdentities = "gotrue.get_user_identities"; internal const string GetRecoveryCodesStatus = "gotrue.get_recovery_codes_status"; + internal const string UnenrollRecoveryCodes = "gotrue.unenroll_recovery_codes"; internal const string SignInWithOtp = "gotrue.sign_in.otp"; internal const string SignIn = "gotrue.sign_in"; internal const string SignInAnonymously = "gotrue.sign_in.anonymous"; diff --git a/packages/Gotrue/Gotrue/Interfaces/IGotrueApi.cs b/packages/Gotrue/Gotrue/Interfaces/IGotrueApi.cs index 0bb9ca73..032577aa 100644 --- a/packages/Gotrue/Gotrue/Interfaces/IGotrueApi.cs +++ b/packages/Gotrue/Gotrue/Interfaces/IGotrueApi.cs @@ -80,6 +80,14 @@ public interface IGotrueApi : IGettableHeaders /// The recovery codes status, or null when the user has no recovery codes. /// The server rejected the request. Task GetRecoveryCodesStatusAsync(string jwt, CancellationToken cancellationToken = default); + + /// + /// Removes the user's MFA recovery codes factor together with every remaining code. + /// + /// The user's access token, at the aal2 assurance level. + /// Cancels the request. + /// The server rejected the request. + Task UnenrollRecoveryCodesAsync(string jwt, CancellationToken cancellationToken = default); 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); diff --git a/packages/Gotrue/Gotrue/Interfaces/IGotrueClient.cs b/packages/Gotrue/Gotrue/Interfaces/IGotrueClient.cs index ba36865a..e6bcaeb2 100644 --- a/packages/Gotrue/Gotrue/Interfaces/IGotrueClient.cs +++ b/packages/Gotrue/Gotrue/Interfaces/IGotrueClient.cs @@ -679,6 +679,29 @@ public interface IGotrueClient : IGettableHeaders /// Task GetRecoveryCodesStatusAsync(CancellationToken cancellationToken = default); + /// + /// Removes the signed-in user's MFA recovery codes factor, invalidating every remaining code. The session must + /// be at the aal2 assurance level. Sessions that reached aal2 with a recovery code are lowered to + /// aal1 on the server; the current session keeps its tokens until it is refreshed. + /// Recovery codes are experimental on the auth server. + /// + /// Cancels the request. + /// + /// when the client is offline, + /// when no user is signed in, + /// or the server's rejection of the request, with its : + /// insufficient_aal when the session is not at aal2, + /// mfa_factor_not_found when the user has no recovery codes, + /// no_authorization for an anonymous user. + /// + /// + /// + /// await client.ChallengeAndVerify(new MfaChallengeAndVerifyParams { FactorId = totpFactorId, Code = code }); + /// await client.UnenrollRecoveryCodesAsync(); + /// + /// + Task UnenrollRecoveryCodesAsync(CancellationToken cancellationToken = default); + #endregion } diff --git a/packages/Gotrue/Gotrue/PublicAPI.Unshipped.txt b/packages/Gotrue/Gotrue/PublicAPI.Unshipped.txt index bcb084b3..740165df 100644 --- a/packages/Gotrue/Gotrue/PublicAPI.Unshipped.txt +++ b/packages/Gotrue/Gotrue/PublicAPI.Unshipped.txt @@ -45,10 +45,12 @@ Supabase.Gotrue.Api.CreateCustomProviderAsync(string! jwt, Supabase.Gotrue.Creat Supabase.Gotrue.Api.GetCustomProviderAsync(string! jwt, string! identifier, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Supabase.Gotrue.Api.GetRecoveryCodesStatusAsync(string! jwt, 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.UnenrollRecoveryCodesAsync(string! jwt, 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.GetRecoveryCodesStatusAsync(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.Client.UnenrollRecoveryCodesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Supabase.Gotrue.Common.CustomProvider Supabase.Gotrue.Common.CustomProvider.$() -> Supabase.Gotrue.Common.CustomProvider! Supabase.Gotrue.Common.CustomProvider.AcceptableClientIds.get -> System.Collections.Generic.IReadOnlyList! @@ -185,10 +187,12 @@ Supabase.Gotrue.Interfaces.IGotrueApi.GetCustomProviderAsync(st Supabase.Gotrue.Interfaces.IGotrueApi.GetRecoveryCodesStatusAsync(string! jwt, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! 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.UnenrollRecoveryCodesAsync(string! jwt, 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.GetRecoveryCodesStatusAsync(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.Interfaces.IGotrueClient.UnenrollRecoveryCodesAsync(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