diff --git a/packages/Gotrue/Gotrue.Tests/GetRecoveryCodesStatus/Data/expired_jwt_error.json b/packages/Gotrue/Gotrue.Tests/GetRecoveryCodesStatus/Data/expired_jwt_error.json
new file mode 100644
index 00000000..f3d911f8
--- /dev/null
+++ b/packages/Gotrue/Gotrue.Tests/GetRecoveryCodesStatus/Data/expired_jwt_error.json
@@ -0,0 +1,5 @@
+{
+ "code": 403,
+ "error_code": "bad_jwt",
+ "msg": "invalid JWT: unable to parse or verify signature, token has invalid claims: token is expired"
+}
diff --git a/packages/Gotrue/Gotrue.Tests/GetRecoveryCodesStatus/Data/not_enrolled_error.json b/packages/Gotrue/Gotrue.Tests/GetRecoveryCodesStatus/Data/not_enrolled_error.json
new file mode 100644
index 00000000..ae20295e
--- /dev/null
+++ b/packages/Gotrue/Gotrue.Tests/GetRecoveryCodesStatus/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/GetRecoveryCodesStatus/Data/recovery_codes_status.json b/packages/Gotrue/Gotrue.Tests/GetRecoveryCodesStatus/Data/recovery_codes_status.json
new file mode 100644
index 00000000..676a7f37
--- /dev/null
+++ b/packages/Gotrue/Gotrue.Tests/GetRecoveryCodesStatus/Data/recovery_codes_status.json
@@ -0,0 +1,6 @@
+{
+ "id": "2b306a77-21dc-4110-ba71-537cb56b9e98",
+ "type": "recovery_code",
+ "total": 10,
+ "remaining": 7
+}
diff --git a/packages/Gotrue/Gotrue.Tests/GetRecoveryCodesStatus/Data/user_not_found_error.json b/packages/Gotrue/Gotrue.Tests/GetRecoveryCodesStatus/Data/user_not_found_error.json
new file mode 100644
index 00000000..d3b8bc01
--- /dev/null
+++ b/packages/Gotrue/Gotrue.Tests/GetRecoveryCodesStatus/Data/user_not_found_error.json
@@ -0,0 +1,5 @@
+{
+ "code": 404,
+ "error_code": "user_not_found",
+ "msg": "User not found"
+}
diff --git a/packages/Gotrue/Gotrue.Tests/GetRecoveryCodesStatus/RecoveryCodesStatusContractTests.cs b/packages/Gotrue/Gotrue.Tests/GetRecoveryCodesStatus/RecoveryCodesStatusContractTests.cs
new file mode 100644
index 00000000..272a7f7a
--- /dev/null
+++ b/packages/Gotrue/Gotrue.Tests/GetRecoveryCodesStatus/RecoveryCodesStatusContractTests.cs
@@ -0,0 +1,184 @@
+#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.GetRecoveryCodesStatus;
+using Supabase.Gotrue.Interfaces;
+using WireMock.RequestBuilders;
+using WireMock.ResponseBuilders;
+using static Supabase.Gotrue.Exceptions.FailureHint.Reason;
+
+#endregion
+
+namespace Gotrue.Tests.GetRecoveryCodesStatus;
+
+///
+/// Pins how GetRecoveryCodesStatusAsync reads the signed-in user's MFA recovery codes status: the
+/// GET /factors/recovery-codes request it puts on the wire, how the status body maps to the result, the
+/// not-enrolled answer that maps to null, and the failures raised before and after the request, all against
+/// a stubbed server.
+///
+[TestClass]
+[TestCategory("Contract")]
+public class RecoveryCodesStatusContractTests
+{
+ 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 GetRecoveryCodesStatusAsync_ShouldGetTheStatusWithTheSessionBearer()
+ {
+ this.SignIn();
+ this.MockStatusResponse(200, Fixture("recovery_codes_status.json"));
+ await this.client.GetRecoveryCodesStatusAsync();
+ this.server.VerifySingleReceivedRequest()
+ .WithMethod(HttpMethod.Get)
+ .WithPath("/factors/recovery-codes")
+ .WithHeader("Authorization", $"Bearer {AccessToken}")
+ .WithHeader("apikey", MockGotrueServer.ApiKey);
+ }
+
+ [TestMethod]
+ public async Task GetRecoveryCodesStatusAsync_ShouldReturnTheFactorAndCodeCounts_GivenEnrolledUser()
+ {
+ this.SignIn();
+ this.MockStatusResponse(200, Fixture("recovery_codes_status.json"));
+ var status = await this.client.GetRecoveryCodesStatusAsync();
+ status.Should().Be(new RecoveryCodesStatus { FactorId = "2b306a77-21dc-4110-ba71-537cb56b9e98", Total = 10, Remaining = 7 },
+ "the status is the id, total and remaining of MFARecoveryCodesStatusSchema (auth OpenAPI, GET /factors/recovery-codes)");
+ }
+
+ [TestMethod]
+ public async Task GetRecoveryCodesStatusAsync_ShouldReturnNull_GivenUserWithoutRecoveryCodes()
+ {
+ this.SignIn();
+ this.MockStatusResponse(404, Fixture("not_enrolled_error.json"));
+ var status = await this.client.GetRecoveryCodesStatusAsync();
+ status.Should().BeNull("GoTrue answers 404 mfa_factor_not_found when the user has not enrolled recovery codes, which is a status, not a failure");
+ }
+
+ [TestMethod]
+ public async Task GetRecoveryCodesStatusAsync_ShouldThrowServerRejection_GivenNotFoundForAnotherReason()
+ {
+ this.SignIn();
+ this.MockStatusResponse(404, Fixture("user_not_found_error.json"));
+ var reading = () => this.client.GetRecoveryCodesStatusAsync();
+ (await reading.Should().ThrowAsync()).Which.ErrorCode.Should().Be("user_not_found",
+ "only mfa_factor_not_found means the user has no recovery codes; any other 404 is a failure");
+ }
+
+ [TestMethod]
+ public async Task GetRecoveryCodesStatusAsync_ShouldThrow_GivenSuccessWithoutBody()
+ {
+ this.SignIn();
+ this.MockStatusResponse(200, "null");
+ var reading = () => this.client.GetRecoveryCodesStatusAsync();
+ await reading.Should().ThrowAsync("a 200 answer must carry the status; an empty one is a broken server, not a user without codes")
+ .WithMessage("The auth server returned no recovery codes status.");
+ }
+
+ [TestMethod]
+ public async Task GetRecoveryCodesStatusAsync_ShouldThrowServerRejection_GivenExpiredJwt()
+ {
+ this.SignIn();
+ this.MockStatusResponse(403, Fixture("expired_jwt_error.json"));
+ var reading = () => this.client.GetRecoveryCodesStatusAsync();
+ (await reading.Should().ThrowAsync()).Which.ErrorCode.Should().Be("bad_jwt",
+ "GoTrue rejects an expired access token with error_code bad_jwt");
+ }
+
+ [TestMethod]
+ public async Task GetRecoveryCodesStatusAsync_ShouldThrowNoSessionFound_GivenNoSession()
+ {
+ var reading = () => this.client.GetRecoveryCodesStatusAsync();
+ (await reading.Should().ThrowAsync().WithMessage("A valid session is required.")).Which.Reason.Should().Be(NoSessionFound,
+ "GET /factors/recovery-codes requires the signed-in user's access token");
+ }
+
+ [TestMethod]
+ public async Task GetRecoveryCodesStatusAsync_ShouldThrowNoSessionFound_GivenSessionWithoutAccessToken()
+ {
+ this.SignIn(new Session { RefreshToken = "a-refresh-token", ExpiresIn = 3600 });
+ var reading = () => this.client.GetRecoveryCodesStatusAsync();
+ (await reading.Should().ThrowAsync()).Which.Reason.Should().Be(NoSessionFound,
+ "GET /factors/recovery-codes requires the signed-in user's access token");
+ }
+
+ [TestMethod]
+ public async Task GetRecoveryCodesStatusAsync_ShouldNotSendARequest_GivenNoSession()
+ {
+ var reading = () => this.client.GetRecoveryCodesStatusAsync();
+ await reading.Should().ThrowAsync();
+ this.server.CountReceivedRequests().Should().Be(0, "the missing session is detected before the wire");
+ }
+
+ [TestMethod]
+ public async Task GetRecoveryCodesStatusAsync_ShouldThrowOffline_GivenClientIsOffline()
+ {
+ this.SignIn();
+ this.client.Online = false;
+ var reading = () => this.client.GetRecoveryCodesStatusAsync();
+ (await reading.Should().ThrowAsync().WithMessage("Only supported when online")).Which.Reason.Should().Be(Offline,
+ "the status is read from the server, like the other MFA operations");
+ }
+
+ [TestMethod]
+ public async Task GetRecoveryCodesStatusAsync_ShouldCancelTheRequest_GivenCancelledToken()
+ {
+ this.SignIn();
+ this.MockStatusResponse(200, Fixture("recovery_codes_status.json"));
+ using var cancellation = new CancellationTokenSource();
+ cancellation.Cancel();
+ var reading = () => this.client.GetRecoveryCodesStatusAsync(cancellation.Token);
+ await reading.Should().ThrowAsync("the cancellation token reaches the HTTP call");
+ }
+
+ [TestMethod]
+ public void GetRecoveryCodesStatusAsync_ShouldComplete_GivenCallerBlockingOnContextThatNeverRunsContinuations()
+ {
+ this.SignIn();
+ this.MockStatusResponse(200, Fixture("recovery_codes_status.json"));
+ BlockingCaller.Completes(() => this.client.GetRecoveryCodesStatusAsync()).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 MockStatusResponse(int statusCode, string body) =>
+ this.server
+ .Given(Request.Create().WithPath("/factors/recovery-codes").UsingGet())
+ .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, "GetRecoveryCodesStatus", "Data", name));
+}
diff --git a/packages/Gotrue/Gotrue.Tests/GetRecoveryCodesStatus/RecoveryCodesStatusTests.cs b/packages/Gotrue/Gotrue.Tests/GetRecoveryCodesStatus/RecoveryCodesStatusTests.cs
new file mode 100644
index 00000000..8a64e587
--- /dev/null
+++ b/packages/Gotrue/Gotrue.Tests/GetRecoveryCodesStatus/RecoveryCodesStatusTests.cs
@@ -0,0 +1,39 @@
+#region
+
+using System.Threading.Tasks;
+using FluentAssertions;
+using Gotrue.Tests.Support;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Supabase.Gotrue.Exceptions;
+
+#endregion
+
+namespace Gotrue.Tests.GetRecoveryCodesStatus;
+
+///
+/// End-to-end reading of the signed-in user's MFA recovery codes status 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 not-enrolled and anonymous answers; the enrolled status is pinned by
+/// .
+///
+[TestClass]
+[TestCategory("E2E")]
+public class RecoveryCodesStatusTests : AuthClientFixture
+{
+ [TestMethod]
+ public async Task GetRecoveryCodesStatusAsync_ShouldReturnNull_GivenUserWithoutRecoveryCodes()
+ {
+ await this.SignUpNewUser();
+ var status = await this.Client.GetRecoveryCodesStatusAsync();
+ status.Should().BeNull("a freshly signed-up user has not enrolled recovery codes (GET /factors/recovery-codes answers mfa_factor_not_found)");
+ }
+
+ [TestMethod]
+ public async Task GetRecoveryCodesStatusAsync_ShouldThrowNoAuthorization_GivenAnonymousUser()
+ {
+ await this.Client.SignInAnonymously();
+ var reading = () => this.Client.GetRecoveryCodesStatusAsync();
+ (await reading.Should().ThrowAsync()).Which.ErrorCode.Should().Be("no_authorization",
+ "anonymous users cannot use recovery codes (auth OpenAPI, GET /factors/recovery-codes 403)");
+ }
+}
diff --git a/packages/Gotrue/Gotrue.Tests/Gotrue.Tests.csproj b/packages/Gotrue/Gotrue.Tests/Gotrue.Tests.csproj
index 248f1526..5a2f578e 100644
--- a/packages/Gotrue/Gotrue.Tests/Gotrue.Tests.csproj
+++ b/packages/Gotrue/Gotrue.Tests/Gotrue.Tests.csproj
@@ -35,6 +35,7 @@
+
diff --git a/packages/Gotrue/Gotrue.Tests/Observability/ObservabilityContractTests.cs b/packages/Gotrue/Gotrue.Tests/Observability/ObservabilityContractTests.cs
index 36008076..bcd0d6a5 100644
--- a/packages/Gotrue/Gotrue.Tests/Observability/ObservabilityContractTests.cs
+++ b/packages/Gotrue/Gotrue.Tests/Observability/ObservabilityContractTests.cs
@@ -130,6 +130,17 @@ public async Task DomainSpan_ShouldParentTheHttpSpan_GivenUserIdentitiesListing(
this.activities.Should().ContainSingle(a => a.OperationName == "GET /user").Which.ParentSpanId.Should().Be(domainSpan.SpanId);
}
+ [TestMethod]
+ public async Task DomainSpan_ShouldParentTheHttpSpan_GivenRecoveryCodesStatusReading()
+ {
+ this.MockTokenSuccess();
+ await this.Refresh();
+ this.MockRecoveryCodesStatusSuccess();
+ await this.client.GetRecoveryCodesStatusAsync();
+ var domainSpan = this.activities.Should().ContainSingle(a => a.OperationName == "gotrue.get_recovery_codes_status").Which;
+ this.activities.Should().ContainSingle(a => a.OperationName == "GET /factors/recovery-codes").Which.ParentSpanId.Should().Be(domainSpan.SpanId);
+ }
+
[TestMethod]
public async Task HttpSpan_ShouldBeMarkedError_GivenFailedRequest()
{
@@ -236,6 +247,14 @@ private void MockUserSuccess() =>
.WithHeader("Content-Type", "application/json")
.WithBody(File.ReadAllText(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Identities", "Data", "user_with_identities.json"))));
+ private void MockRecoveryCodesStatusSuccess() =>
+ this.server
+ .Given(Request.Create().WithPath("/factors/recovery-codes").UsingGet())
+ .RespondWith(Response.Create()
+ .WithStatusCode(200)
+ .WithHeader("Content-Type", "application/json")
+ .WithBody(File.ReadAllText(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "GetRecoveryCodesStatus", "Data", "recovery_codes_status.json"))));
+
private void MockTokenFailure() =>
this.server
.Given(Request.Create().WithPath("/token").UsingPost())
diff --git a/packages/Gotrue/Gotrue/Api.cs b/packages/Gotrue/Gotrue/Api.cs
index e2d9331a..4f9c2248 100644
--- a/packages/Gotrue/Gotrue/Api.cs
+++ b/packages/Gotrue/Gotrue/Api.cs
@@ -13,6 +13,7 @@
using Supabase.Gotrue.CreateCustomProvider;
using Supabase.Gotrue.CustomProviders;
using Supabase.Gotrue.Exceptions;
+using Supabase.Gotrue.GetRecoveryCodesStatus;
using Supabase.Gotrue.Interfaces;
using Supabase.Gotrue.ListCustomProviders;
using Supabase.Gotrue.Mfa;
@@ -637,6 +638,25 @@ public ProviderAuthState GetUriForProvider(Provider provider, SignInOptions? opt
///
public Task Unenroll(string jwt, MfaUnenrollParams mfaUnenrollParams) => this.MakeRequestAsync(HttpMethod.Delete, $"{this.Url}/factors/{PathSegment.Escape(mfaUnenrollParams.FactorId)}", null, this.CreateAuthedRequestHeaders(jwt));
+ ///
+ public async Task GetRecoveryCodesStatusAsync(string jwt, CancellationToken cancellationToken = default)
+ {
+ var status = await this.RequestRecoveryCodesStatusAsync(jwt, cancellationToken).ConfigureAwait(false);
+ return RecoveryCodesStatusResponse.Read(status);
+ }
+
+ private async Task RequestRecoveryCodesStatusAsync(string jwt, CancellationToken cancellationToken)
+ {
+ try
+ {
+ return await this.MakeRequestAsync(HttpMethod.Get, $"{this.Url}/factors/recovery-codes", null, this.CreateAuthedRequestHeaders(jwt), cancellationToken).ConfigureAwait(false);
+ }
+ catch (GotrueException rejection) when (RecoveryCodesStatusResponse.MeansNotEnrolled(rejection))
+ {
+ return RecoveryCodesStatusResponse.NotEnrolled;
+ }
+ }
+
///
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 c5e15d7e..366c02ba 100644
--- a/packages/Gotrue/Gotrue/Client.cs
+++ b/packages/Gotrue/Gotrue/Client.cs
@@ -12,6 +12,7 @@
using Supabase.Core.Diagnostics;
using Supabase.Gotrue.Claims;
using Supabase.Gotrue.Exceptions;
+using Supabase.Gotrue.GetRecoveryCodesStatus;
using Supabase.Gotrue.Interfaces;
using Supabase.Gotrue.Mfa;
using Supabase.Gotrue.Resend;
@@ -1210,6 +1211,19 @@ public void StartAutoRefresh()
return Task.FromResult(response);
}
+ ///
+ [ImplementedCapability("auth.mfa_recovery_codes.get_status")]
+ public async Task GetRecoveryCodesStatusAsync(CancellationToken cancellationToken = default)
+ {
+ using var activity = GotrueInstrumentation.Source.StartActivity(GotrueInstrumentation.Spans.GetRecoveryCodesStatus);
+ 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);
+ return await this.api.GetRecoveryCodesStatusAsync(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/GetRecoveryCodesStatus/RecoveryCodesStatus.cs b/packages/Gotrue/Gotrue/GetRecoveryCodesStatus/RecoveryCodesStatus.cs
new file mode 100644
index 00000000..0ad16196
--- /dev/null
+++ b/packages/Gotrue/Gotrue/GetRecoveryCodesStatus/RecoveryCodesStatus.cs
@@ -0,0 +1,16 @@
+namespace Supabase.Gotrue.GetRecoveryCodesStatus;
+
+///
+/// How many of the signed-in user's MFA recovery codes remain unused. The codes themselves are never returned.
+///
+public sealed record RecoveryCodesStatus
+{
+ /// The id of the user's recovery codes factor, as listed in .
+ public string FactorId { get; init; } = null!;
+
+ /// The number of codes in the current set.
+ public int Total { get; init; }
+
+ /// The number of codes in the current set not used yet. Zero once every code has been used.
+ public int Remaining { get; init; }
+}
diff --git a/packages/Gotrue/Gotrue/GetRecoveryCodesStatus/RecoveryCodesStatusResponse.cs b/packages/Gotrue/Gotrue/GetRecoveryCodesStatus/RecoveryCodesStatusResponse.cs
new file mode 100644
index 00000000..3520e3ea
--- /dev/null
+++ b/packages/Gotrue/Gotrue/GetRecoveryCodesStatus/RecoveryCodesStatusResponse.cs
@@ -0,0 +1,41 @@
+using System.Text.Json.Serialization;
+using Supabase.Gotrue.Exceptions;
+
+namespace Supabase.Gotrue.GetRecoveryCodesStatus;
+
+internal sealed record RecoveryCodesStatusResponse
+{
+ private const string NotEnrolledErrorCode = "mfa_factor_not_found";
+
+ internal static RecoveryCodesStatusResponse NotEnrolled { get; } = new() { IsEnrolled = false };
+
+ [JsonPropertyName("id")]
+ public string Id { get; init; } = null!;
+
+ [JsonPropertyName("total")]
+ public int Total { get; init; }
+
+ [JsonPropertyName("remaining")]
+ public int Remaining { get; init; }
+
+ [JsonIgnore]
+ public bool IsEnrolled { get; private init; } = true;
+
+ internal static RecoveryCodesStatus? Read(RecoveryCodesStatusResponse? response) =>
+ response switch
+ {
+ null => throw new GotrueException("The auth server returned no recovery codes status."),
+ { IsEnrolled: false } => null,
+ _ => response.ToRecoveryCodesStatus(),
+ };
+
+ internal static bool MeansNotEnrolled(GotrueException rejection) => rejection.ErrorCode == NotEnrolledErrorCode;
+
+ private RecoveryCodesStatus ToRecoveryCodesStatus() =>
+ new()
+ {
+ FactorId = this.Id,
+ Total = this.Total,
+ Remaining = this.Remaining,
+ };
+}
diff --git a/packages/Gotrue/Gotrue/GotrueInstrumentation.cs b/packages/Gotrue/Gotrue/GotrueInstrumentation.cs
index 1090f20f..72fe6470 100644
--- a/packages/Gotrue/Gotrue/GotrueInstrumentation.cs
+++ b/packages/Gotrue/Gotrue/GotrueInstrumentation.cs
@@ -71,6 +71,7 @@ internal static class Spans
internal const string SignInWithIdToken = "gotrue.sign_in.id_token";
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 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 e42350a5..0bb9ca73 100644
--- a/packages/Gotrue/Gotrue/Interfaces/IGotrueApi.cs
+++ b/packages/Gotrue/Gotrue/Interfaces/IGotrueApi.cs
@@ -6,6 +6,7 @@
using Supabase.Gotrue.Claims;
using Supabase.Gotrue.Common;
using Supabase.Gotrue.CreateCustomProvider;
+using Supabase.Gotrue.GetRecoveryCodesStatus;
using Supabase.Gotrue.Mfa;
using Supabase.Gotrue.Resend;
using Supabase.Gotrue.Responses;
@@ -70,6 +71,15 @@ public interface IGotrueApi : IGettableHeaders
Task Challenge(string jwt, MfaChallengeParams mfaChallengeParams);
Task Verify(string jwt, MfaVerifyParams mfaVerifyParams);
Task Unenroll(string jwt, MfaUnenrollParams mfaVerifyParams);
+
+ ///
+ /// Reads how many of the user's MFA recovery codes remain unused.
+ ///
+ /// The user's access token.
+ /// Cancels the request.
+ /// 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);
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 18e06978..ba36865a 100644
--- a/packages/Gotrue/Gotrue/Interfaces/IGotrueClient.cs
+++ b/packages/Gotrue/Gotrue/Interfaces/IGotrueClient.cs
@@ -7,6 +7,7 @@
using Supabase.Core.Interfaces;
using Supabase.Gotrue.Claims;
using Supabase.Gotrue.Exceptions;
+using Supabase.Gotrue.GetRecoveryCodesStatus;
using Supabase.Gotrue.Mfa;
using Supabase.Gotrue.Resend;
using Supabase.Gotrue.Responses;
@@ -649,6 +650,35 @@ public interface IGotrueClient : IGettableHeaders
///
Task GetAuthenticatorAssuranceLevel();
+ ///
+ /// Returns how many of the signed-in user's MFA recovery codes remain unused, read from the server. The codes
+ /// themselves are never returned. Works at any authenticator assurance level. Returns null when the user has
+ /// not generated recovery codes.
+ /// Recovery codes are experimental on the auth server.
+ ///
+ /// Cancels the request.
+ /// The recovery codes status, or null when the user has no recovery codes.
+ ///
+ /// when the client is offline,
+ /// when no user is signed in,
+ /// or the server's rejection of the request, with its
+ /// (no_authorization for an anonymous user).
+ ///
+ ///
+ ///
+ /// var status = await client.GetRecoveryCodesStatusAsync();
+ /// if (status == null)
+ /// {
+ /// // offer to generate recovery codes
+ /// }
+ /// else if (status.Remaining == 0)
+ /// {
+ /// // every code is used: prompt the user to regenerate them
+ /// }
+ ///
+ ///
+ Task GetRecoveryCodesStatusAsync(CancellationToken cancellationToken = default);
+
#endregion
}
diff --git a/packages/Gotrue/Gotrue/PublicAPI.Unshipped.txt b/packages/Gotrue/Gotrue/PublicAPI.Unshipped.txt
index cf406715..bcb084b3 100644
--- a/packages/Gotrue/Gotrue/PublicAPI.Unshipped.txt
+++ b/packages/Gotrue/Gotrue/PublicAPI.Unshipped.txt
@@ -11,6 +11,9 @@ override Supabase.Gotrue.Common.OidcDiscoveryDocument.ToString() -> string!
override Supabase.Gotrue.CreateCustomProvider.NewCustomProvider.Equals(object? obj) -> bool
override Supabase.Gotrue.CreateCustomProvider.NewCustomProvider.GetHashCode() -> int
override Supabase.Gotrue.CreateCustomProvider.NewCustomProvider.ToString() -> string!
+override Supabase.Gotrue.GetRecoveryCodesStatus.RecoveryCodesStatus.Equals(object? obj) -> bool
+override Supabase.Gotrue.GetRecoveryCodesStatus.RecoveryCodesStatus.GetHashCode() -> int
+override Supabase.Gotrue.GetRecoveryCodesStatus.RecoveryCodesStatus.ToString() -> string!
override Supabase.Gotrue.UpdateCustomProvider.CustomProviderChanges.Equals(object? obj) -> bool
override Supabase.Gotrue.UpdateCustomProvider.CustomProviderChanges.GetHashCode() -> int
override Supabase.Gotrue.UpdateCustomProvider.CustomProviderChanges.ToString() -> string!
@@ -27,6 +30,8 @@ static Supabase.Gotrue.CreateCustomProvider.NewCustomProvider.OAuth2(string! ide
static Supabase.Gotrue.CreateCustomProvider.NewCustomProvider.Oidc(string! identifier, string! name, string! clientId, string! clientSecret, System.Uri! issuer) -> Supabase.Gotrue.CreateCustomProvider.NewCustomProvider!
static Supabase.Gotrue.CreateCustomProvider.NewCustomProvider.operator !=(Supabase.Gotrue.CreateCustomProvider.NewCustomProvider? left, Supabase.Gotrue.CreateCustomProvider.NewCustomProvider? right) -> bool
static Supabase.Gotrue.CreateCustomProvider.NewCustomProvider.operator ==(Supabase.Gotrue.CreateCustomProvider.NewCustomProvider? left, Supabase.Gotrue.CreateCustomProvider.NewCustomProvider? right) -> bool
+static Supabase.Gotrue.GetRecoveryCodesStatus.RecoveryCodesStatus.operator !=(Supabase.Gotrue.GetRecoveryCodesStatus.RecoveryCodesStatus? left, Supabase.Gotrue.GetRecoveryCodesStatus.RecoveryCodesStatus? right) -> bool
+static Supabase.Gotrue.GetRecoveryCodesStatus.RecoveryCodesStatus.operator ==(Supabase.Gotrue.GetRecoveryCodesStatus.RecoveryCodesStatus? left, Supabase.Gotrue.GetRecoveryCodesStatus.RecoveryCodesStatus? 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
@@ -38,9 +43,11 @@ Supabase.Gotrue.AdminClient.UpdateCustomProviderAsync(string! identifier, Supaba
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.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.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.Common.CustomProvider
Supabase.Gotrue.Common.CustomProvider.$() -> Supabase.Gotrue.Common.CustomProvider!
@@ -155,6 +162,16 @@ Supabase.Gotrue.CreateCustomProvider.NewCustomProvider.Name.get -> string!
Supabase.Gotrue.CreateCustomProvider.NewCustomProvider.TokenUrl.get -> System.Uri?
Supabase.Gotrue.CreateCustomProvider.NewCustomProvider.Type.get -> Supabase.Gotrue.Common.CustomProviderType
Supabase.Gotrue.CreateCustomProvider.NewCustomProvider.UserInfoUrl.get -> System.Uri?
+Supabase.Gotrue.GetRecoveryCodesStatus.RecoveryCodesStatus
+Supabase.Gotrue.GetRecoveryCodesStatus.RecoveryCodesStatus.$() -> Supabase.Gotrue.GetRecoveryCodesStatus.RecoveryCodesStatus!
+Supabase.Gotrue.GetRecoveryCodesStatus.RecoveryCodesStatus.Equals(Supabase.Gotrue.GetRecoveryCodesStatus.RecoveryCodesStatus? other) -> bool
+Supabase.Gotrue.GetRecoveryCodesStatus.RecoveryCodesStatus.FactorId.get -> string!
+Supabase.Gotrue.GetRecoveryCodesStatus.RecoveryCodesStatus.FactorId.init -> void
+Supabase.Gotrue.GetRecoveryCodesStatus.RecoveryCodesStatus.RecoveryCodesStatus() -> void
+Supabase.Gotrue.GetRecoveryCodesStatus.RecoveryCodesStatus.Remaining.get -> int
+Supabase.Gotrue.GetRecoveryCodesStatus.RecoveryCodesStatus.Remaining.init -> void
+Supabase.Gotrue.GetRecoveryCodesStatus.RecoveryCodesStatus.Total.get -> int
+Supabase.Gotrue.GetRecoveryCodesStatus.RecoveryCodesStatus.Total.init -> void
Supabase.Gotrue.Interfaces.IGotrueAdminClient.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.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!>!
@@ -165,10 +182,12 @@ Supabase.Gotrue.AdminClient.SignOutAsync(string! accessToken, Supabase.Gotrue.Co
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!
Supabase.Gotrue.Interfaces.IGotrueAdminClient.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.Interfaces.IGotrueApi.GetCustomProviderAsync(string! jwt, string! identifier, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+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.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.Mfa.Factor.Phone.get -> string?
Supabase.Gotrue.Mfa.Factor.Phone.set -> void