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
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 @@ -36,6 +36,7 @@
<None Update="Identities\Data\*.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="UpdateFactor\Data\*.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="GetRecoveryCodesStatus\Data\*.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="UnenrollRecoveryCodes\Data\*.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -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())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"code": 403,
"error_code": "insufficient_aal",
"msg": "AAL2 required to delete recovery codes"
}
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,3 @@
{
"id": "2b306a77-21dc-4110-ba71-537cb56b9e98"
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
[TestClass]
[TestCategory("Contract")]
public class UnenrollRecoveryCodesContractTests
{
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 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<GotrueException>()).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<GotrueException>()).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<GotrueException>().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<GotrueException>()).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<GotrueException>();
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<GotrueException>().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<OperationCanceledException>("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));
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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
/// <see cref="UnenrollRecoveryCodesContractTests" />.
/// </summary>
[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<GotrueException>()).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<GotrueException>()).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)");
}
}
4 changes: 4 additions & 0 deletions packages/Gotrue/Gotrue/Api.cs
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,10 @@ public ProviderAuthState GetUriForProvider(Provider provider, SignInOptions? opt
}
}

/// <inheritdoc />
public Task UnenrollRecoveryCodesAsync(string jwt, CancellationToken cancellationToken = default) =>
this.MakeRequestAsync(HttpMethod.Delete, $"{this.Url}/factors/recovery-codes", null, this.CreateAuthedRequestHeaders(jwt), cancellationToken);

/// <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
13 changes: 13 additions & 0 deletions packages/Gotrue/Gotrue/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1224,6 +1224,19 @@ public void StartAutoRefresh()
return await this.api.GetRecoveryCodesStatusAsync(accessToken, cancellationToken).ConfigureAwait(false);
}

/// <inheritdoc />
[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);
}

/// <inheritdoc />
[ImplementedCapability("auth.session.get_claims")]
public async Task<GetClaimsResponse> GetClaimsAsync(string? jwt = null, GetClaimsOptions? options = null, CancellationToken cancellationToken = default)
Expand Down
1 change: 1 addition & 0 deletions packages/Gotrue/Gotrue/GotrueInstrumentation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
8 changes: 8 additions & 0 deletions packages/Gotrue/Gotrue/Interfaces/IGotrueApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ public interface IGotrueApi<TUser, TSession> : IGettableHeaders
/// <returns>The recovery codes status, or null when the user has no recovery codes.</returns>
/// <exception cref="Exceptions.GotrueException">The server rejected the request.</exception>
Task<RecoveryCodesStatus?> GetRecoveryCodesStatusAsync(string jwt, CancellationToken cancellationToken = default);

/// <summary>
/// Removes the user's MFA recovery codes factor together with every remaining code.
/// </summary>
/// <param name="jwt">The user's access token, at the aal2 assurance level.</param>
/// <param name="cancellationToken">Cancels the request.</param>
/// <exception cref="Exceptions.GotrueException">The server rejected the request.</exception>
Task UnenrollRecoveryCodesAsync(string jwt, CancellationToken cancellationToken = default);
Task<BaseResponse> ListFactors(string jwt, MfaAdminListFactorsParams listFactorsParams);
Task<MfaAdminDeleteFactorResponse?> DeleteFactor(string jwt, MfaAdminDeleteFactorParams deleteFactorParams);
Task<Factor> UpdateFactorAsync(string jwt, string userId, string factorId, FactorChanges changes, CancellationToken cancellationToken = default);
Expand Down
23 changes: 23 additions & 0 deletions packages/Gotrue/Gotrue/Interfaces/IGotrueClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,29 @@ public interface IGotrueClient<TUser, TSession> : IGettableHeaders
/// </example>
Task<RecoveryCodesStatus?> GetRecoveryCodesStatusAsync(CancellationToken cancellationToken = default);

/// <summary>
/// Removes the signed-in user's MFA recovery codes factor, invalidating every remaining code. The session must
/// be at the <c>aal2</c> assurance level. Sessions that reached <c>aal2</c> with a recovery code are lowered to
/// <c>aal1</c> on the server; the current session keeps its tokens until it is refreshed.
/// Recovery codes are experimental on the auth server.
/// </summary>
/// <param name="cancellationToken">Cancels the request.</param>
/// <exception cref="GotrueException">
/// <see cref="FailureHint.Reason.Offline" /> when the client is offline,
/// <see cref="FailureHint.Reason.NoSessionFound" /> when no user is signed in,
/// or the server's rejection of the request, with its <see cref="GotrueException.ErrorCode" />:
/// <c>insufficient_aal</c> when the session is not at <c>aal2</c>,
/// <c>mfa_factor_not_found</c> when the user has no recovery codes,
/// <c>no_authorization</c> for an anonymous user.
/// </exception>
/// <example>
/// <code>
/// await client.ChallengeAndVerify(new MfaChallengeAndVerifyParams { FactorId = totpFactorId, Code = code });
/// await client.UnenrollRecoveryCodesAsync();
/// </code>
/// </example>
Task UnenrollRecoveryCodesAsync(CancellationToken cancellationToken = default);

#endregion

}
Loading
Loading