Skip to content

Commit 7758f32

Browse files
Add support for MFA signup and login flow (#103)
* Add support for MFA * Add ChallengeAndVerify and StatelessClient implementations * Add MFA tests
1 parent 5fe6a8c commit 7758f32

28 files changed

+1084
-3
lines changed

Gotrue/Api.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using Supabase.Core.Extensions;
1010
using Supabase.Gotrue.Exceptions;
1111
using Supabase.Gotrue.Interfaces;
12+
using Supabase.Gotrue.Mfa;
1213
using Supabase.Gotrue.Responses;
1314
using static Supabase.Gotrue.Constants;
1415

@@ -539,6 +540,43 @@ public ProviderAuthState GetUriForProvider(Provider provider, SignInOptions? opt
539540
return Helpers.MakeRequest<Session>(HttpMethod.Post, url.ToString(), body, Headers);
540541
}
541542

543+
/// <inheritdoc />
544+
public Task<MfaEnrollResponse?> Enroll(string jwt, MfaEnrollParams mfaEnrollParams)
545+
{
546+
var body = new Dictionary<string, object>
547+
{
548+
{ "friendly_name", mfaEnrollParams.FriendlyName },
549+
{ "factor_type", mfaEnrollParams.FactorType },
550+
{ "issuer", mfaEnrollParams.Issuer }
551+
};
552+
553+
return Helpers.MakeRequest<MfaEnrollResponse>(HttpMethod.Post, $"{Url}/factors", body, CreateAuthedRequestHeaders(jwt));
554+
}
555+
556+
/// <inheritdoc />
557+
public Task<MfaChallengeResponse?> Challenge(string jwt, MfaChallengeParams mfaChallengeParams)
558+
{
559+
return Helpers.MakeRequest<MfaChallengeResponse>(HttpMethod.Post, $"{Url}/factors/{mfaChallengeParams.FactorId}/challenge", null, CreateAuthedRequestHeaders(jwt));
560+
}
561+
562+
/// <inheritdoc />
563+
public Task<MfaVerifyResponse?> Verify(string jwt, MfaVerifyParams mfaVerifyParams)
564+
{
565+
var body = new Dictionary<string, object>
566+
{
567+
{ "code", mfaVerifyParams.Code },
568+
{ "challenge_id", mfaVerifyParams.ChallengeId }
569+
};
570+
571+
return Helpers.MakeRequest<MfaVerifyResponse>(HttpMethod.Post, $"{Url}/factors/{mfaVerifyParams.FactorId}/verify", body, CreateAuthedRequestHeaders(jwt));
572+
}
573+
574+
/// <inheritdoc />
575+
public Task<MfaUnenrollResponse?> Unenroll(string jwt, MfaUnenrollParams mfaUnenrollParams)
576+
{
577+
return Helpers.MakeRequest<MfaUnenrollResponse>(HttpMethod.Delete, $"{Url}/factors/{mfaUnenrollParams.FactorId}", null, CreateAuthedRequestHeaders(jwt));
578+
}
579+
542580
/// <inheritdoc />
543581
public async Task<ProviderAuthState> LinkIdentity(string token, Provider provider, SignInOptions options)
544582
{

Gotrue/Client.cs

Lines changed: 163 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
using System;
22
using System.Collections.Generic;
33
using System.IdentityModel.Tokens.Jwt;
4+
using System.Linq;
45
using System.Threading.Tasks;
56
using System.Web;
67
using Newtonsoft.Json;
78
using Supabase.Gotrue.Exceptions;
89
using Supabase.Gotrue.Interfaces;
10+
using Supabase.Gotrue.Mfa;
911
using static Supabase.Gotrue.Constants;
1012
using static Supabase.Gotrue.Constants.AuthState;
1113
using static Supabase.Gotrue.Exceptions.FailureHint.Reason;
@@ -586,7 +588,6 @@ public async Task<Session> SetSession(string accessToken, string refreshToken, b
586588
return session;
587589
}
588590

589-
590591
/// <inheritdoc />
591592
public async Task<Session?> RetrieveSessionAsync()
592593
{
@@ -752,5 +753,166 @@ public void Shutdown()
752753
{
753754
NotifyAuthStateChange(AuthState.Shutdown);
754755
}
756+
757+
/// <inheritdoc />
758+
public async Task<MfaEnrollResponse?> Enroll(MfaEnrollParams mfaEnrollParams)
759+
{
760+
if (CurrentSession == null || string.IsNullOrEmpty(CurrentSession.AccessToken))
761+
throw new GotrueException("Not Logged in.", NoSessionFound);
762+
763+
if (!Online)
764+
throw new GotrueException("Only supported when online", Offline);
765+
766+
return await _api.Enroll(CurrentSession.AccessToken, mfaEnrollParams);
767+
}
768+
769+
/// <inheritdoc />
770+
public async Task<MfaChallengeResponse?> Challenge(MfaChallengeParams mfaChallengeParams)
771+
{
772+
if (CurrentSession == null || string.IsNullOrEmpty(CurrentSession.AccessToken))
773+
throw new GotrueException("Not Logged in.", NoSessionFound);
774+
775+
if (!Online)
776+
throw new GotrueException("Only supported when online", Offline);
777+
778+
return await _api.Challenge(CurrentSession.AccessToken, mfaChallengeParams);
779+
}
780+
781+
/// <inheritdoc />
782+
public async Task<Session?> Verify(MfaVerifyParams mfaVerifyParams)
783+
{
784+
if (CurrentSession == null || string.IsNullOrEmpty(CurrentSession.AccessToken))
785+
throw new GotrueException("Not Logged in.", NoSessionFound);
786+
787+
if (!Online)
788+
throw new GotrueException("Only supported when online", Offline);
789+
790+
var result = await _api.Verify(CurrentSession.AccessToken, mfaVerifyParams);
791+
792+
if (result == null || string.IsNullOrEmpty(result.AccessToken))
793+
throw new GotrueException("Could not verify MFA.", MfaChallengeUnverified);
794+
795+
var session = new Session
796+
{
797+
AccessToken = result.AccessToken,
798+
RefreshToken = result.RefreshToken,
799+
TokenType = "bearer",
800+
ExpiresIn = result.ExpiresIn,
801+
User = result.User
802+
};
803+
804+
UpdateSession(session);
805+
NotifyAuthStateChange(MfaChallengeVerified);
806+
807+
return session;
808+
}
809+
810+
/// <inheritdoc />
811+
public async Task<Session?> ChallengeAndVerify(MfaChallengeAndVerifyParams mfaChallengeAndVerifyParams)
812+
{
813+
if (CurrentSession == null || string.IsNullOrEmpty(CurrentSession.AccessToken))
814+
throw new GotrueException("Not Logged in.", NoSessionFound);
815+
816+
if (!Online)
817+
throw new GotrueException("Only supported when online", Offline);
818+
819+
var challengeResponse = await _api.Challenge(CurrentSession.AccessToken, new MfaChallengeParams
820+
{
821+
FactorId = mfaChallengeAndVerifyParams.FactorId
822+
});
823+
824+
if (challengeResponse == null)
825+
{
826+
return null;
827+
}
828+
829+
var result = await _api.Verify(CurrentSession.AccessToken, new MfaVerifyParams
830+
{
831+
FactorId = mfaChallengeAndVerifyParams.FactorId,
832+
Code = mfaChallengeAndVerifyParams.Code,
833+
ChallengeId = challengeResponse.Id
834+
});
835+
836+
if (result == null || string.IsNullOrEmpty(result.AccessToken))
837+
throw new GotrueException("Could not verify MFA.", MfaChallengeUnverified);
838+
839+
var session = new Session
840+
{
841+
AccessToken = result.AccessToken,
842+
RefreshToken = result.RefreshToken,
843+
TokenType = "bearer",
844+
ExpiresIn = result.ExpiresIn,
845+
User = result.User
846+
};
847+
848+
UpdateSession(session);
849+
NotifyAuthStateChange(MfaChallengeVerified);
850+
851+
return session;
852+
}
853+
854+
/// <inheritdoc />
855+
public async Task<MfaUnenrollResponse?> Unenroll(MfaUnenrollParams mfaUnenrollParams)
856+
{
857+
if (CurrentSession == null || string.IsNullOrEmpty(CurrentSession.AccessToken))
858+
throw new GotrueException("Not Logged in.", NoSessionFound);
859+
860+
if (!Online)
861+
throw new GotrueException("Only supported when online", Offline);
862+
863+
return await _api.Unenroll(CurrentSession.AccessToken, mfaUnenrollParams);
864+
}
865+
866+
/// <inheritdoc />
867+
public Task<MfaListFactorsResponse?> ListFactors()
868+
{
869+
if (CurrentSession == null || string.IsNullOrEmpty(CurrentSession.AccessToken))
870+
throw new GotrueException("Not Logged in.", NoSessionFound);
871+
872+
var response = new MfaListFactorsResponse()
873+
{
874+
All = CurrentSession.User!.Factors,
875+
Totp = CurrentSession.User!.Factors?.Where(x => x.FactorType == "totp" && x.Status == "verified").ToList()
876+
};
877+
878+
return Task.FromResult(response);
879+
}
880+
881+
public Task<MfaGetAuthenticatorAssuranceLevelResponse?> GetAuthenticatorAssuranceLevel()
882+
{
883+
if (CurrentSession == null || string.IsNullOrEmpty(CurrentSession.AccessToken))
884+
throw new GotrueException("Not Logged in.", NoSessionFound);
885+
886+
var payload = new JwtSecurityTokenHandler().ReadJwtToken(CurrentSession.AccessToken).Payload;
887+
888+
if (payload == null || payload.ValidTo == DateTime.MinValue)
889+
throw new GotrueException("`accessToken`'s payload was of an unknown structure.", NoSessionFound);
890+
891+
AuthenticatorAssuranceLevel? currentLevel = null;
892+
893+
if (payload.ContainsKey("aal"))
894+
{
895+
currentLevel = Enum.TryParse(payload["aal"].ToString(), out AuthenticatorAssuranceLevel parsedLevel) ? parsedLevel : (AuthenticatorAssuranceLevel?)null;
896+
}
897+
898+
AuthenticatorAssuranceLevel? nextLevel = currentLevel;
899+
900+
var verifiedFactors = CurrentSession.User!.Factors?.Where(factor => factor.Status == "verified").ToList() ?? new List<Factor>();
901+
if (verifiedFactors.Count > 0)
902+
{
903+
nextLevel = AuthenticatorAssuranceLevel.aal2;
904+
}
905+
906+
var currentAuthenticationMethods = payload.Amr.Select(x => JsonConvert.DeserializeObject<AmrEntry>(x));
907+
908+
var response = new MfaGetAuthenticatorAssuranceLevelResponse
909+
{
910+
CurrentLevel = currentLevel,
911+
NextLevel = nextLevel,
912+
CurrentAuthenticationMethods = currentAuthenticationMethods.ToArray()
913+
};
914+
915+
return Task.FromResult(response);
916+
}
755917
}
756918
}

Gotrue/Constants.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,8 @@ public enum AuthState
120120
UserUpdated,
121121
PasswordRecovery,
122122
TokenRefreshed,
123-
Shutdown
123+
Shutdown,
124+
MfaChallengeVerified
124125
}
125126

126127
/// <summary>

Gotrue/Exceptions/FailureReason.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,8 @@ public enum Reason
8787
/// <summary>
8888
/// The sso provider ID was incorrect or does not exist
8989
/// </summary>
90-
SsoProviderNotFound
90+
SsoProviderNotFound,
91+
MfaChallengeUnverified,
9192
}
9293

9394
/// <summary>

Gotrue/Interfaces/IGotrueApi.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System;
22
using System.Threading.Tasks;
33
using Supabase.Core.Interfaces;
4+
using Supabase.Gotrue.Mfa;
45
using Supabase.Gotrue.Responses;
56
using static Supabase.Gotrue.Constants;
67

@@ -44,6 +45,10 @@ public interface IGotrueApi<TUser, TSession> : IGettableHeaders
4445
Task<Session?> ExchangeCodeForSession(string codeVerifier, string authCode);
4546
Task<Settings?> Settings();
4647
Task<BaseResponse> GenerateLink(string jwt, GenerateLinkOptions options);
48+
Task<MfaEnrollResponse?> Enroll(string jwt, MfaEnrollParams mfaEnrollParams);
49+
Task<MfaChallengeResponse?> Challenge(string jwt, MfaChallengeParams mfaChallengeParams);
50+
Task<MfaVerifyResponse?> Verify(string jwt, MfaVerifyParams mfaVerifyParams);
51+
Task<MfaUnenrollResponse?> Unenroll(string jwt, MfaUnenrollParams mfaVerifyParams);
4752

4853
/// <summary>
4954
/// Links an oauth identity to an existing user.

Gotrue/Interfaces/IGotrueClient.cs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Threading.Tasks;
33
using Supabase.Core.Interfaces;
44
using Supabase.Gotrue.Exceptions;
5+
using Supabase.Gotrue.Mfa;
56
using static Supabase.Gotrue.Constants;
67

78
#pragma warning disable CS1591
@@ -463,5 +464,63 @@ public interface IGotrueClient<TUser, TSession> : IGettableHeaders
463464
/// </summary>
464465
/// <returns></returns>
465466
public Task RefreshToken();
467+
468+
#region MFA
469+
/// <summary>
470+
/// Starts the enrollment process for a new Multi-Factor Authentication (MFA)
471+
/// factor. This method creates a new `unverified` factor.
472+
/// To verify a factor, present the QR code or secret to the user and ask them to add it to their
473+
/// authenticator app.
474+
/// The user has to enter the code from their authenticator app to verify it.
475+
///
476+
/// Upon verifying a factor, all other sessions are logged out and the current session's authenticator level is promoted to `aal2`.
477+
/// </summary>
478+
Task<MfaEnrollResponse?> Enroll(MfaEnrollParams mfaEnrollParams);
479+
480+
/// <summary>
481+
/// Prepares a challenge used to verify that a user has access to a MFA
482+
/// factor.
483+
/// </summary>
484+
Task<MfaChallengeResponse?> Challenge(MfaChallengeParams mfaChallengeParams);
485+
486+
/// <summary>
487+
/// Verifies a code against a challenge. The verification code is
488+
/// provided by the user by entering a code seen in their authenticator app.
489+
/// </summary>
490+
Task<Session?> Verify(MfaVerifyParams mfaVerifyParams);
491+
492+
/// <summary>
493+
/// Helper method which creates a challenge and immediately uses the given code to verify against it thereafter. The verification code is
494+
/// provided by the user by entering a code seen in their authenticator app.
495+
/// </summary>
496+
Task<Session?> ChallengeAndVerify(MfaChallengeAndVerifyParams mfaChallengeAndVerifyParams);
497+
498+
/// <summary>
499+
/// Unenroll removes a MFA factor.
500+
/// A user has to have an `aal2` authenticator level in order to unenroll a `verified` factor.
501+
/// </summary>
502+
Task<MfaUnenrollResponse?> Unenroll(MfaUnenrollParams mfaUnenrollParams);
503+
504+
/// <summary>
505+
/// Returns the list of MFA factors enabled for this user
506+
/// </summary>
507+
Task<MfaListFactorsResponse?> ListFactors();
508+
509+
/// <summary>
510+
/// Returns the Authenticator Assurance Level (AAL) for the active session.
511+
///
512+
/// - `aal1` (or `null`) means that the user's identity has been verified only
513+
/// with a conventional login (email+password, OTP, magic link, social login,
514+
/// etc.).
515+
/// - `aal2` means that the user's identity has been verified both with a conventional login and at least one MFA factor.
516+
///
517+
/// Although this method returns a promise, it's fairly quick (microseconds)
518+
/// and rarely uses the network. You can use this to check whether the current
519+
/// user needs to be shown a screen to verify their MFA factors.
520+
/// </summary>
521+
Task<MfaGetAuthenticatorAssuranceLevelResponse?> GetAuthenticatorAssuranceLevel();
522+
523+
#endregion
524+
466525
}
467526
}

0 commit comments

Comments
 (0)