Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"code": 404,
"error_code": "mfa_factor_not_found",
"msg": "The user has not enrolled recovery codes"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"id": "2b306a77-21dc-4110-ba71-537cb56b9e98",
"type": "recovery_code",
"total": 10,
"remaining": 7
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"code": 404,
"error_code": "user_not_found",
"msg": "User not found"
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
[TestClass]
[TestCategory("Contract")]
public class RecoveryCodesStatusContractTests
{
private const string AccessToken = "an-access-token";
private IGotrueClient<User, Session> 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<GotrueException>()).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<GotrueException>("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<GotrueException>()).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<GotrueException>().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<GotrueException>()).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<GotrueException>();
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<GotrueException>().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<OperationCanceledException>("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));
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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
/// <see cref="RecoveryCodesStatusContractTests" />.
/// </summary>
[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<GotrueException>()).Which.ErrorCode.Should().Be("no_authorization",
"anonymous users cannot use recovery codes (auth OpenAPI, GET /factors/recovery-codes 403)");
}
}
1 change: 1 addition & 0 deletions packages/Gotrue/Gotrue.Tests/Gotrue.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
<None Update="Common\Data\*.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="Identities\Data\*.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="UpdateFactor\Data\*.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="GetRecoveryCodesStatus\Data\*.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -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())
Expand Down
20 changes: 20 additions & 0 deletions packages/Gotrue/Gotrue/Api.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -637,6 +638,25 @@ public ProviderAuthState GetUriForProvider(Provider provider, SignInOptions? opt
/// <inheritdoc />
public Task<MfaUnenrollResponse?> Unenroll(string jwt, MfaUnenrollParams mfaUnenrollParams) => this.MakeRequestAsync<MfaUnenrollResponse>(HttpMethod.Delete, $"{this.Url}/factors/{PathSegment.Escape(mfaUnenrollParams.FactorId)}", null, this.CreateAuthedRequestHeaders(jwt));

/// <inheritdoc />
public async Task<RecoveryCodesStatus?> GetRecoveryCodesStatusAsync(string jwt, CancellationToken cancellationToken = default)
{
var status = await this.RequestRecoveryCodesStatusAsync(jwt, cancellationToken).ConfigureAwait(false);
return RecoveryCodesStatusResponse.Read(status);
}

private async Task<RecoveryCodesStatusResponse?> RequestRecoveryCodesStatusAsync(string jwt, CancellationToken cancellationToken)
{
try
{
return await this.MakeRequestAsync<RecoveryCodesStatusResponse>(HttpMethod.Get, $"{this.Url}/factors/recovery-codes", null, this.CreateAuthedRequestHeaders(jwt), cancellationToken).ConfigureAwait(false);
}
catch (GotrueException rejection) when (RecoveryCodesStatusResponse.MeansNotEnrolled(rejection))
{
return RecoveryCodesStatusResponse.NotEnrolled;
}
}

/// <inheritdoc />
public Task<BaseResponse> ListFactors(string jwt, MfaAdminListFactorsParams listFactorsParams) => this.MakeRequestAsync(HttpMethod.Get, $"{this.Url}/admin/users/{PathSegment.Escape(listFactorsParams.UserId)}/factors", null, this.CreateAuthedRequestHeaders(jwt));

Expand Down
14 changes: 14 additions & 0 deletions packages/Gotrue/Gotrue/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1210,6 +1211,19 @@ public void StartAutoRefresh()
return Task.FromResult(response);
}

/// <inheritdoc />
[ImplementedCapability("auth.mfa_recovery_codes.get_status")]
public async Task<RecoveryCodesStatus?> 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);
}

/// <inheritdoc />
[ImplementedCapability("auth.session.get_claims")]
public async Task<GetClaimsResponse> GetClaimsAsync(string? jwt = null, GetClaimsOptions? options = null, CancellationToken cancellationToken = default)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace Supabase.Gotrue.GetRecoveryCodesStatus;

/// <summary>
/// How many of the signed-in user's MFA recovery codes remain unused. The codes themselves are never returned.
/// </summary>
public sealed record RecoveryCodesStatus
{
/// <summary>The id of the user's recovery codes factor, as listed in <see cref="User.Factors" />.</summary>
public string FactorId { get; init; } = null!;

/// <summary>The number of codes in the current set.</summary>
public int Total { get; init; }

/// <summary>The number of codes in the current set not used yet. Zero once every code has been used.</summary>
public int Remaining { get; init; }
}
Loading
Loading