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