Skip to content

Commit

Permalink
Add support for MFA signup and login flow (#103)
Browse files Browse the repository at this point in the history
* Add support for MFA

* Add ChallengeAndVerify and StatelessClient implementations

* Add MFA tests
  • Loading branch information
michaelschattgen authored Jul 25, 2024
1 parent 5fe6a8c commit 7758f32
Show file tree
Hide file tree
Showing 28 changed files with 1,084 additions and 3 deletions.
38 changes: 38 additions & 0 deletions Gotrue/Api.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Supabase.Core.Extensions;
using Supabase.Gotrue.Exceptions;
using Supabase.Gotrue.Interfaces;
using Supabase.Gotrue.Mfa;
using Supabase.Gotrue.Responses;
using static Supabase.Gotrue.Constants;

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

/// <inheritdoc />
public Task<MfaEnrollResponse?> Enroll(string jwt, MfaEnrollParams mfaEnrollParams)
{
var body = new Dictionary<string, object>
{
{ "friendly_name", mfaEnrollParams.FriendlyName },

Check warning on line 548 in Gotrue/Api.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Possible null reference argument for parameter 'value' in 'void Dictionary<string, object>.Add(string key, object value)'.

Check warning on line 548 in Gotrue/Api.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Possible null reference argument for parameter 'value' in 'void Dictionary<string, object>.Add(string key, object value)'.
{ "factor_type", mfaEnrollParams.FactorType },
{ "issuer", mfaEnrollParams.Issuer }

Check warning on line 550 in Gotrue/Api.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Possible null reference argument for parameter 'value' in 'void Dictionary<string, object>.Add(string key, object value)'.

Check warning on line 550 in Gotrue/Api.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Possible null reference argument for parameter 'value' in 'void Dictionary<string, object>.Add(string key, object value)'.
};

return Helpers.MakeRequest<MfaEnrollResponse>(HttpMethod.Post, $"{Url}/factors", body, CreateAuthedRequestHeaders(jwt));
}

/// <inheritdoc />
public Task<MfaChallengeResponse?> Challenge(string jwt, MfaChallengeParams mfaChallengeParams)
{
return Helpers.MakeRequest<MfaChallengeResponse>(HttpMethod.Post, $"{Url}/factors/{mfaChallengeParams.FactorId}/challenge", null, CreateAuthedRequestHeaders(jwt));
}

/// <inheritdoc />
public Task<MfaVerifyResponse?> Verify(string jwt, MfaVerifyParams mfaVerifyParams)
{
var body = new Dictionary<string, object>
{
{ "code", mfaVerifyParams.Code },
{ "challenge_id", mfaVerifyParams.ChallengeId }
};

return Helpers.MakeRequest<MfaVerifyResponse>(HttpMethod.Post, $"{Url}/factors/{mfaVerifyParams.FactorId}/verify", body, CreateAuthedRequestHeaders(jwt));
}

/// <inheritdoc />
public Task<MfaUnenrollResponse?> Unenroll(string jwt, MfaUnenrollParams mfaUnenrollParams)
{
return Helpers.MakeRequest<MfaUnenrollResponse>(HttpMethod.Delete, $"{Url}/factors/{mfaUnenrollParams.FactorId}", null, CreateAuthedRequestHeaders(jwt));
}

/// <inheritdoc />
public async Task<ProviderAuthState> LinkIdentity(string token, Provider provider, SignInOptions options)
{
Expand Down
164 changes: 163 additions & 1 deletion Gotrue/Client.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Threading.Tasks;
using System.Web;
using Newtonsoft.Json;
using Supabase.Gotrue.Exceptions;
using Supabase.Gotrue.Interfaces;
using Supabase.Gotrue.Mfa;
using static Supabase.Gotrue.Constants;
using static Supabase.Gotrue.Constants.AuthState;
using static Supabase.Gotrue.Exceptions.FailureHint.Reason;
Expand Down Expand Up @@ -586,7 +588,6 @@ public async Task<Session> SetSession(string accessToken, string refreshToken, b
return session;
}


/// <inheritdoc />
public async Task<Session?> RetrieveSessionAsync()
{
Expand Down Expand Up @@ -752,5 +753,166 @@ public void Shutdown()
{
NotifyAuthStateChange(AuthState.Shutdown);
}

/// <inheritdoc />
public async Task<MfaEnrollResponse?> Enroll(MfaEnrollParams mfaEnrollParams)
{
if (CurrentSession == null || string.IsNullOrEmpty(CurrentSession.AccessToken))
throw new GotrueException("Not Logged in.", NoSessionFound);

if (!Online)
throw new GotrueException("Only supported when online", Offline);

return await _api.Enroll(CurrentSession.AccessToken, mfaEnrollParams);
}

/// <inheritdoc />
public async Task<MfaChallengeResponse?> Challenge(MfaChallengeParams mfaChallengeParams)
{
if (CurrentSession == null || string.IsNullOrEmpty(CurrentSession.AccessToken))
throw new GotrueException("Not Logged in.", NoSessionFound);

if (!Online)
throw new GotrueException("Only supported when online", Offline);

return await _api.Challenge(CurrentSession.AccessToken, mfaChallengeParams);
}

/// <inheritdoc />
public async Task<Session?> Verify(MfaVerifyParams mfaVerifyParams)
{
if (CurrentSession == null || string.IsNullOrEmpty(CurrentSession.AccessToken))
throw new GotrueException("Not Logged in.", NoSessionFound);

if (!Online)
throw new GotrueException("Only supported when online", Offline);

var result = await _api.Verify(CurrentSession.AccessToken, mfaVerifyParams);

if (result == null || string.IsNullOrEmpty(result.AccessToken))
throw new GotrueException("Could not verify MFA.", MfaChallengeUnverified);

var session = new Session
{
AccessToken = result.AccessToken,
RefreshToken = result.RefreshToken,
TokenType = "bearer",
ExpiresIn = result.ExpiresIn,
User = result.User
};

UpdateSession(session);
NotifyAuthStateChange(MfaChallengeVerified);

return session;
}

/// <inheritdoc />
public async Task<Session?> ChallengeAndVerify(MfaChallengeAndVerifyParams mfaChallengeAndVerifyParams)
{
if (CurrentSession == null || string.IsNullOrEmpty(CurrentSession.AccessToken))
throw new GotrueException("Not Logged in.", NoSessionFound);

if (!Online)
throw new GotrueException("Only supported when online", Offline);

var challengeResponse = await _api.Challenge(CurrentSession.AccessToken, new MfaChallengeParams
{
FactorId = mfaChallengeAndVerifyParams.FactorId
});

if (challengeResponse == null)
{
return null;
}

var result = await _api.Verify(CurrentSession.AccessToken, new MfaVerifyParams
{
FactorId = mfaChallengeAndVerifyParams.FactorId,
Code = mfaChallengeAndVerifyParams.Code,
ChallengeId = challengeResponse.Id
});

if (result == null || string.IsNullOrEmpty(result.AccessToken))
throw new GotrueException("Could not verify MFA.", MfaChallengeUnverified);

var session = new Session
{
AccessToken = result.AccessToken,
RefreshToken = result.RefreshToken,
TokenType = "bearer",
ExpiresIn = result.ExpiresIn,
User = result.User
};

UpdateSession(session);
NotifyAuthStateChange(MfaChallengeVerified);

return session;
}

/// <inheritdoc />
public async Task<MfaUnenrollResponse?> Unenroll(MfaUnenrollParams mfaUnenrollParams)
{
if (CurrentSession == null || string.IsNullOrEmpty(CurrentSession.AccessToken))
throw new GotrueException("Not Logged in.", NoSessionFound);

if (!Online)
throw new GotrueException("Only supported when online", Offline);

return await _api.Unenroll(CurrentSession.AccessToken, mfaUnenrollParams);
}

/// <inheritdoc />
public Task<MfaListFactorsResponse?> ListFactors()
{
if (CurrentSession == null || string.IsNullOrEmpty(CurrentSession.AccessToken))
throw new GotrueException("Not Logged in.", NoSessionFound);

var response = new MfaListFactorsResponse()
{
All = CurrentSession.User!.Factors,
Totp = CurrentSession.User!.Factors?.Where(x => x.FactorType == "totp" && x.Status == "verified").ToList()
};

return Task.FromResult(response);
}

public Task<MfaGetAuthenticatorAssuranceLevelResponse?> GetAuthenticatorAssuranceLevel()
{
if (CurrentSession == null || string.IsNullOrEmpty(CurrentSession.AccessToken))
throw new GotrueException("Not Logged in.", NoSessionFound);

var payload = new JwtSecurityTokenHandler().ReadJwtToken(CurrentSession.AccessToken).Payload;

if (payload == null || payload.ValidTo == DateTime.MinValue)
throw new GotrueException("`accessToken`'s payload was of an unknown structure.", NoSessionFound);

AuthenticatorAssuranceLevel? currentLevel = null;

if (payload.ContainsKey("aal"))
{
currentLevel = Enum.TryParse(payload["aal"].ToString(), out AuthenticatorAssuranceLevel parsedLevel) ? parsedLevel : (AuthenticatorAssuranceLevel?)null;
}

AuthenticatorAssuranceLevel? nextLevel = currentLevel;

var verifiedFactors = CurrentSession.User!.Factors?.Where(factor => factor.Status == "verified").ToList() ?? new List<Factor>();
if (verifiedFactors.Count > 0)
{
nextLevel = AuthenticatorAssuranceLevel.aal2;
}

var currentAuthenticationMethods = payload.Amr.Select(x => JsonConvert.DeserializeObject<AmrEntry>(x));

var response = new MfaGetAuthenticatorAssuranceLevelResponse
{
CurrentLevel = currentLevel,
NextLevel = nextLevel,
CurrentAuthenticationMethods = currentAuthenticationMethods.ToArray()
};

return Task.FromResult(response);
}
}
}
3 changes: 2 additions & 1 deletion Gotrue/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,8 @@ public enum AuthState
UserUpdated,
PasswordRecovery,
TokenRefreshed,
Shutdown
Shutdown,
MfaChallengeVerified
}

/// <summary>
Expand Down
3 changes: 2 additions & 1 deletion Gotrue/Exceptions/FailureReason.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ public enum Reason
/// <summary>
/// The sso provider ID was incorrect or does not exist
/// </summary>
SsoProviderNotFound
SsoProviderNotFound,
MfaChallengeUnverified,
}

/// <summary>
Expand Down
5 changes: 5 additions & 0 deletions Gotrue/Interfaces/IGotrueApi.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Threading.Tasks;
using Supabase.Core.Interfaces;
using Supabase.Gotrue.Mfa;
using Supabase.Gotrue.Responses;
using static Supabase.Gotrue.Constants;

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

/// <summary>
/// Links an oauth identity to an existing user.
Expand Down
59 changes: 59 additions & 0 deletions Gotrue/Interfaces/IGotrueClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Threading.Tasks;
using Supabase.Core.Interfaces;
using Supabase.Gotrue.Exceptions;
using Supabase.Gotrue.Mfa;
using static Supabase.Gotrue.Constants;

#pragma warning disable CS1591
Expand Down Expand Up @@ -463,5 +464,63 @@ public interface IGotrueClient<TUser, TSession> : IGettableHeaders
/// </summary>
/// <returns></returns>
public Task RefreshToken();

#region MFA
/// <summary>
/// Starts the enrollment process for a new Multi-Factor Authentication (MFA)
/// factor. This method creates a new `unverified` factor.
/// To verify a factor, present the QR code or secret to the user and ask them to add it to their
/// authenticator app.
/// The user has to enter the code from their authenticator app to verify it.
///
/// Upon verifying a factor, all other sessions are logged out and the current session's authenticator level is promoted to `aal2`.
/// </summary>
Task<MfaEnrollResponse?> Enroll(MfaEnrollParams mfaEnrollParams);

/// <summary>
/// Prepares a challenge used to verify that a user has access to a MFA
/// factor.
/// </summary>
Task<MfaChallengeResponse?> Challenge(MfaChallengeParams mfaChallengeParams);

/// <summary>
/// Verifies a code against a challenge. The verification code is
/// provided by the user by entering a code seen in their authenticator app.
/// </summary>
Task<Session?> Verify(MfaVerifyParams mfaVerifyParams);

/// <summary>
/// Helper method which creates a challenge and immediately uses the given code to verify against it thereafter. The verification code is
/// provided by the user by entering a code seen in their authenticator app.
/// </summary>
Task<Session?> ChallengeAndVerify(MfaChallengeAndVerifyParams mfaChallengeAndVerifyParams);

/// <summary>
/// Unenroll removes a MFA factor.
/// A user has to have an `aal2` authenticator level in order to unenroll a `verified` factor.
/// </summary>
Task<MfaUnenrollResponse?> Unenroll(MfaUnenrollParams mfaUnenrollParams);

/// <summary>
/// Returns the list of MFA factors enabled for this user
/// </summary>
Task<MfaListFactorsResponse?> ListFactors();

/// <summary>
/// Returns the Authenticator Assurance Level (AAL) for the active session.
///
/// - `aal1` (or `null`) means that the user's identity has been verified only
/// with a conventional login (email+password, OTP, magic link, social login,
/// etc.).
/// - `aal2` means that the user's identity has been verified both with a conventional login and at least one MFA factor.
///
/// Although this method returns a promise, it's fairly quick (microseconds)
/// and rarely uses the network. You can use this to check whether the current
/// user needs to be shown a screen to verify their MFA factors.
/// </summary>
Task<MfaGetAuthenticatorAssuranceLevelResponse?> GetAuthenticatorAssuranceLevel();

#endregion

}
}
Loading

0 comments on commit 7758f32

Please sign in to comment.