From 7758f32cb39442e59c42417b6e14825b8716531b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Sch=C3=A4ttgen?= Date: Thu, 25 Jul 2024 20:32:15 +0200 Subject: [PATCH] Add support for MFA signup and login flow (#103) * Add support for MFA * Add ChallengeAndVerify and StatelessClient implementations * Add MFA tests --- Gotrue/Api.cs | 38 +++ Gotrue/Client.cs | 164 +++++++++++- Gotrue/Constants.cs | 3 +- Gotrue/Exceptions/FailureReason.cs | 3 +- Gotrue/Interfaces/IGotrueApi.cs | 5 + Gotrue/Interfaces/IGotrueClient.cs | 59 +++++ Gotrue/Interfaces/IGotrueStatelessClient.cs | 74 ++++++ Gotrue/Mfa/AmrEntry.cs | 16 ++ Gotrue/Mfa/AuthenticatorAssuranceLevel.cs | 8 + Gotrue/Mfa/Factor.cs | 26 ++ Gotrue/Mfa/MfaChallengeAndVerifyParams.cs | 8 + Gotrue/Mfa/MfaChallengeParams.cs | 8 + Gotrue/Mfa/MfaChallengeResponse.cs | 14 + Gotrue/Mfa/MfaEnrollParams.cs | 9 + Gotrue/Mfa/MfaEnrollResponse.cs | 23 ++ ...aGetAuthenticatorAssuranceLevelResponse.cs | 9 + Gotrue/Mfa/MfaListFactorsResponse.cs | 13 + Gotrue/Mfa/MfaUnenrollParams.cs | 7 + Gotrue/Mfa/MfaUnenrollResponse.cs | 10 + Gotrue/Mfa/MfaVerifyParams.cs | 14 + Gotrue/Mfa/MfaVerifyResponse.cs | 27 ++ Gotrue/Mfa/TOTP.cs | 24 ++ Gotrue/PersistenceListener.cs | 1 + Gotrue/StatelessClient.cs | 114 ++++++++ Gotrue/User.cs | 4 + GotrueTests/MfaClientTests.cs | 245 ++++++++++++++++++ GotrueTests/StatelessClientTests.cs | 77 ++++++ GotrueTests/Utils/TotpGenerator.cs | 84 ++++++ 28 files changed, 1084 insertions(+), 3 deletions(-) create mode 100644 Gotrue/Mfa/AmrEntry.cs create mode 100644 Gotrue/Mfa/AuthenticatorAssuranceLevel.cs create mode 100644 Gotrue/Mfa/Factor.cs create mode 100644 Gotrue/Mfa/MfaChallengeAndVerifyParams.cs create mode 100644 Gotrue/Mfa/MfaChallengeParams.cs create mode 100644 Gotrue/Mfa/MfaChallengeResponse.cs create mode 100644 Gotrue/Mfa/MfaEnrollParams.cs create mode 100644 Gotrue/Mfa/MfaEnrollResponse.cs create mode 100644 Gotrue/Mfa/MfaGetAuthenticatorAssuranceLevelResponse.cs create mode 100644 Gotrue/Mfa/MfaListFactorsResponse.cs create mode 100644 Gotrue/Mfa/MfaUnenrollParams.cs create mode 100644 Gotrue/Mfa/MfaUnenrollResponse.cs create mode 100644 Gotrue/Mfa/MfaVerifyParams.cs create mode 100644 Gotrue/Mfa/MfaVerifyResponse.cs create mode 100644 Gotrue/Mfa/TOTP.cs create mode 100644 GotrueTests/MfaClientTests.cs create mode 100644 GotrueTests/Utils/TotpGenerator.cs diff --git a/Gotrue/Api.cs b/Gotrue/Api.cs index 597d10b0..dcd9d97a 100644 --- a/Gotrue/Api.cs +++ b/Gotrue/Api.cs @@ -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; @@ -539,6 +540,43 @@ public ProviderAuthState GetUriForProvider(Provider provider, SignInOptions? opt return Helpers.MakeRequest(HttpMethod.Post, url.ToString(), body, Headers); } + /// + public Task Enroll(string jwt, MfaEnrollParams mfaEnrollParams) + { + var body = new Dictionary + { + { "friendly_name", mfaEnrollParams.FriendlyName }, + { "factor_type", mfaEnrollParams.FactorType }, + { "issuer", mfaEnrollParams.Issuer } + }; + + return Helpers.MakeRequest(HttpMethod.Post, $"{Url}/factors", body, CreateAuthedRequestHeaders(jwt)); + } + + /// + public Task Challenge(string jwt, MfaChallengeParams mfaChallengeParams) + { + return Helpers.MakeRequest(HttpMethod.Post, $"{Url}/factors/{mfaChallengeParams.FactorId}/challenge", null, CreateAuthedRequestHeaders(jwt)); + } + + /// + public Task Verify(string jwt, MfaVerifyParams mfaVerifyParams) + { + var body = new Dictionary + { + { "code", mfaVerifyParams.Code }, + { "challenge_id", mfaVerifyParams.ChallengeId } + }; + + return Helpers.MakeRequest(HttpMethod.Post, $"{Url}/factors/{mfaVerifyParams.FactorId}/verify", body, CreateAuthedRequestHeaders(jwt)); + } + + /// + public Task Unenroll(string jwt, MfaUnenrollParams mfaUnenrollParams) + { + return Helpers.MakeRequest(HttpMethod.Delete, $"{Url}/factors/{mfaUnenrollParams.FactorId}", null, CreateAuthedRequestHeaders(jwt)); + } + /// public async Task LinkIdentity(string token, Provider provider, SignInOptions options) { diff --git a/Gotrue/Client.cs b/Gotrue/Client.cs index 2a8946f8..544ceeb4 100644 --- a/Gotrue/Client.cs +++ b/Gotrue/Client.cs @@ -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; @@ -586,7 +588,6 @@ public async Task SetSession(string accessToken, string refreshToken, b return session; } - /// public async Task RetrieveSessionAsync() { @@ -752,5 +753,166 @@ public void Shutdown() { NotifyAuthStateChange(AuthState.Shutdown); } + + /// + public async Task 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); + } + + /// + public async Task 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); + } + + /// + public async Task 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; + } + + /// + public async Task 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; + } + + /// + public async Task 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); + } + + /// + public Task 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 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(); + if (verifiedFactors.Count > 0) + { + nextLevel = AuthenticatorAssuranceLevel.aal2; + } + + var currentAuthenticationMethods = payload.Amr.Select(x => JsonConvert.DeserializeObject(x)); + + var response = new MfaGetAuthenticatorAssuranceLevelResponse + { + CurrentLevel = currentLevel, + NextLevel = nextLevel, + CurrentAuthenticationMethods = currentAuthenticationMethods.ToArray() + }; + + return Task.FromResult(response); + } } } diff --git a/Gotrue/Constants.cs b/Gotrue/Constants.cs index d747b64d..fac3e04b 100644 --- a/Gotrue/Constants.cs +++ b/Gotrue/Constants.cs @@ -120,7 +120,8 @@ public enum AuthState UserUpdated, PasswordRecovery, TokenRefreshed, - Shutdown + Shutdown, + MfaChallengeVerified } /// diff --git a/Gotrue/Exceptions/FailureReason.cs b/Gotrue/Exceptions/FailureReason.cs index 7d81618f..3db40015 100644 --- a/Gotrue/Exceptions/FailureReason.cs +++ b/Gotrue/Exceptions/FailureReason.cs @@ -87,7 +87,8 @@ public enum Reason /// /// The sso provider ID was incorrect or does not exist /// - SsoProviderNotFound + SsoProviderNotFound, + MfaChallengeUnverified, } /// diff --git a/Gotrue/Interfaces/IGotrueApi.cs b/Gotrue/Interfaces/IGotrueApi.cs index 10215b4d..03ba467b 100644 --- a/Gotrue/Interfaces/IGotrueApi.cs +++ b/Gotrue/Interfaces/IGotrueApi.cs @@ -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; @@ -44,6 +45,10 @@ public interface IGotrueApi : IGettableHeaders Task ExchangeCodeForSession(string codeVerifier, string authCode); Task Settings(); Task GenerateLink(string jwt, GenerateLinkOptions options); + Task Enroll(string jwt, MfaEnrollParams mfaEnrollParams); + Task Challenge(string jwt, MfaChallengeParams mfaChallengeParams); + Task Verify(string jwt, MfaVerifyParams mfaVerifyParams); + Task Unenroll(string jwt, MfaUnenrollParams mfaVerifyParams); /// /// Links an oauth identity to an existing user. diff --git a/Gotrue/Interfaces/IGotrueClient.cs b/Gotrue/Interfaces/IGotrueClient.cs index c1f2b6c3..3b74aff0 100644 --- a/Gotrue/Interfaces/IGotrueClient.cs +++ b/Gotrue/Interfaces/IGotrueClient.cs @@ -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 @@ -463,5 +464,63 @@ public interface IGotrueClient : IGettableHeaders /// /// public Task RefreshToken(); + + #region MFA + /// + /// 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`. + /// + Task Enroll(MfaEnrollParams mfaEnrollParams); + + /// + /// Prepares a challenge used to verify that a user has access to a MFA + /// factor. + /// + Task Challenge(MfaChallengeParams mfaChallengeParams); + + /// + /// Verifies a code against a challenge. The verification code is + /// provided by the user by entering a code seen in their authenticator app. + /// + Task Verify(MfaVerifyParams mfaVerifyParams); + + /// + /// 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. + /// + Task ChallengeAndVerify(MfaChallengeAndVerifyParams mfaChallengeAndVerifyParams); + + /// + /// Unenroll removes a MFA factor. + /// A user has to have an `aal2` authenticator level in order to unenroll a `verified` factor. + /// + Task Unenroll(MfaUnenrollParams mfaUnenrollParams); + + /// + /// Returns the list of MFA factors enabled for this user + /// + Task ListFactors(); + + /// + /// 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. + /// + Task GetAuthenticatorAssuranceLevel(); + + #endregion + } } diff --git a/Gotrue/Interfaces/IGotrueStatelessClient.cs b/Gotrue/Interfaces/IGotrueStatelessClient.cs index 521284d8..538a912e 100644 --- a/Gotrue/Interfaces/IGotrueStatelessClient.cs +++ b/Gotrue/Interfaces/IGotrueStatelessClient.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using Supabase.Gotrue.Mfa; using static Supabase.Gotrue.Constants; using static Supabase.Gotrue.StatelessClient; @@ -272,5 +273,78 @@ public interface IGotrueStatelessClient /// /// Task Settings(StatelessClientOptions options); + + + /// + /// 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`. + /// + /// + /// + /// + Task Enroll(string jwt, MfaEnrollParams mfaEnrollParams, StatelessClientOptions options); + + /// + /// Prepares a challenge used to verify that a user has access to a MFA + /// factor. + /// + /// + /// + /// + Task Challenge(string jwt, MfaChallengeParams mfaChallengeParams, StatelessClientOptions options); + + /// + /// Verifies a code against a challenge. The verification code is + /// provided by the user by entering a code seen in their authenticator app. + /// + /// + /// + Task Verify(string jwt, MfaVerifyParams mfaVerifyParams, StatelessClientOptions options); + + /// + /// 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. + /// + /// + /// + /// + Task ChallengeAndVerify(string jwt, MfaChallengeAndVerifyParams mfaChallengeAndVerifyParams, StatelessClientOptions options); + + /// + /// Unenroll removes a MFA factor. + /// A user has to have an `aal2` authenticator level in order to unenroll a `verified` factor. + /// + /// + /// + /// + Task Unenroll(string jwt, MfaUnenrollParams mfaUnenrollParams, StatelessClientOptions options); + + /// + /// Returns the list of MFA factors enabled for this user + /// + /// + /// + Task ListFactors(string jwt, StatelessClientOptions options); + + /// + /// 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. + /// + /// + /// + Task GetAuthenticatorAssuranceLevel(string jwt, StatelessClientOptions options); } } \ No newline at end of file diff --git a/Gotrue/Mfa/AmrEntry.cs b/Gotrue/Mfa/AmrEntry.cs new file mode 100644 index 00000000..c99e9d7f --- /dev/null +++ b/Gotrue/Mfa/AmrEntry.cs @@ -0,0 +1,16 @@ +namespace Supabase.Gotrue.Mfa +{ + public class AmrEntry + { + /// + /// Authentication method name. + /// + public string Method { get; set; } + + /// + /// Timestamp when the method was successfully used. Represents number of + /// seconds since 1st January 1970 (UNIX epoch) in UTC. + /// + public long Timestamp { get; set; } + } +} \ No newline at end of file diff --git a/Gotrue/Mfa/AuthenticatorAssuranceLevel.cs b/Gotrue/Mfa/AuthenticatorAssuranceLevel.cs new file mode 100644 index 00000000..e232c8cc --- /dev/null +++ b/Gotrue/Mfa/AuthenticatorAssuranceLevel.cs @@ -0,0 +1,8 @@ +namespace Supabase.Gotrue.Mfa +{ + public enum AuthenticatorAssuranceLevel + { + aal1, + aal2 + } +} \ No newline at end of file diff --git a/Gotrue/Mfa/Factor.cs b/Gotrue/Mfa/Factor.cs new file mode 100644 index 00000000..574c9ee6 --- /dev/null +++ b/Gotrue/Mfa/Factor.cs @@ -0,0 +1,26 @@ +using System; +using Newtonsoft.Json; + +namespace Supabase.Gotrue.Mfa +{ + public class Factor + { + [JsonProperty("id")] + public string Id { get; set; } + + [JsonProperty("friendly_name")] + public string? FriendlyName { get; set; } + + [JsonProperty("factor_type")] + public string FactorType { get; set; } + + [JsonProperty("status")] + public string Status { get; set; } + + [JsonProperty("created_at")] + public DateTime CreatedAt { get; set; } + + [JsonProperty("updated_at")] + public DateTime UpdatedAt { get; set; } + } +} \ No newline at end of file diff --git a/Gotrue/Mfa/MfaChallengeAndVerifyParams.cs b/Gotrue/Mfa/MfaChallengeAndVerifyParams.cs new file mode 100644 index 00000000..c4b73576 --- /dev/null +++ b/Gotrue/Mfa/MfaChallengeAndVerifyParams.cs @@ -0,0 +1,8 @@ +namespace Supabase.Gotrue.Mfa +{ + public class MfaChallengeAndVerifyParams + { + public string FactorId { get; set; } + public string Code { get; set; } + } +} \ No newline at end of file diff --git a/Gotrue/Mfa/MfaChallengeParams.cs b/Gotrue/Mfa/MfaChallengeParams.cs new file mode 100644 index 00000000..e47eaca4 --- /dev/null +++ b/Gotrue/Mfa/MfaChallengeParams.cs @@ -0,0 +1,8 @@ +namespace Supabase.Gotrue.Mfa +{ + public class MfaChallengeParams + { + // Id of the factor to be challenged. Returned in enroll(). + public string FactorId { get; set; } + } +} \ No newline at end of file diff --git a/Gotrue/Mfa/MfaChallengeResponse.cs b/Gotrue/Mfa/MfaChallengeResponse.cs new file mode 100644 index 00000000..ab8d9e58 --- /dev/null +++ b/Gotrue/Mfa/MfaChallengeResponse.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; + +namespace Supabase.Gotrue.Mfa +{ + public class MfaChallengeResponse + { + // ID of the newly created challenge. + [JsonProperty("id")] + public string Id { get; set; } + + [JsonProperty("expires_at")] + public long ExpiresAt { get; set; } + } +} \ No newline at end of file diff --git a/Gotrue/Mfa/MfaEnrollParams.cs b/Gotrue/Mfa/MfaEnrollParams.cs new file mode 100644 index 00000000..feb5d35e --- /dev/null +++ b/Gotrue/Mfa/MfaEnrollParams.cs @@ -0,0 +1,9 @@ +namespace Supabase.Gotrue.Mfa +{ + public class MfaEnrollParams + { + public string FactorType { get; set; } + public string? Issuer { get; set; } + public string? FriendlyName { get; set; } + } +} \ No newline at end of file diff --git a/Gotrue/Mfa/MfaEnrollResponse.cs b/Gotrue/Mfa/MfaEnrollResponse.cs new file mode 100644 index 00000000..f94bb626 --- /dev/null +++ b/Gotrue/Mfa/MfaEnrollResponse.cs @@ -0,0 +1,23 @@ +using Newtonsoft.Json; + +namespace Supabase.Gotrue.Mfa +{ + public class MfaEnrollResponse + { + // ID of the factor that was just enrolled (in an unverified state). + [JsonProperty("id")] + public string Id { get; set; } + + // Type of MFA factor. Only `totp` supported for now. + [JsonProperty("type")] + public string Type { get; set; } + + // TOTP enrollment information. + [JsonProperty("totp")] + public TOTP Totp { get; set; } + + // Friendly name of the factor, useful for distinguishing between factors + [JsonProperty("friendly_name")] + public string FriendlyName { get; set; } + } +} \ No newline at end of file diff --git a/Gotrue/Mfa/MfaGetAuthenticatorAssuranceLevelResponse.cs b/Gotrue/Mfa/MfaGetAuthenticatorAssuranceLevelResponse.cs new file mode 100644 index 00000000..b07b2b6d --- /dev/null +++ b/Gotrue/Mfa/MfaGetAuthenticatorAssuranceLevelResponse.cs @@ -0,0 +1,9 @@ +namespace Supabase.Gotrue.Mfa +{ + public class MfaGetAuthenticatorAssuranceLevelResponse + { + public AuthenticatorAssuranceLevel? CurrentLevel { get; set; } + public AuthenticatorAssuranceLevel? NextLevel { get; set; } + public AmrEntry[] CurrentAuthenticationMethods { get; set; } + } +} \ No newline at end of file diff --git a/Gotrue/Mfa/MfaListFactorsResponse.cs b/Gotrue/Mfa/MfaListFactorsResponse.cs new file mode 100644 index 00000000..52f46b89 --- /dev/null +++ b/Gotrue/Mfa/MfaListFactorsResponse.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace Supabase.Gotrue.Mfa +{ + public class MfaListFactorsResponse + { + // All available factors (verified and unverified) + public List All { get; set; } = new List(); + + // Only verified TOTP factors. (A subset of `all`.) + public List Totp { get; set; } = new List(); + } +} \ No newline at end of file diff --git a/Gotrue/Mfa/MfaUnenrollParams.cs b/Gotrue/Mfa/MfaUnenrollParams.cs new file mode 100644 index 00000000..ded5e4d4 --- /dev/null +++ b/Gotrue/Mfa/MfaUnenrollParams.cs @@ -0,0 +1,7 @@ +namespace Supabase.Gotrue.Mfa +{ + public class MfaUnenrollParams + { + public string FactorId { get; set; } + } +} \ No newline at end of file diff --git a/Gotrue/Mfa/MfaUnenrollResponse.cs b/Gotrue/Mfa/MfaUnenrollResponse.cs new file mode 100644 index 00000000..fde213b8 --- /dev/null +++ b/Gotrue/Mfa/MfaUnenrollResponse.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace Supabase.Gotrue.Mfa +{ + public class MfaUnenrollResponse + { + [JsonProperty("id")] + public string Id { get; set; } + } +} \ No newline at end of file diff --git a/Gotrue/Mfa/MfaVerifyParams.cs b/Gotrue/Mfa/MfaVerifyParams.cs new file mode 100644 index 00000000..322f385f --- /dev/null +++ b/Gotrue/Mfa/MfaVerifyParams.cs @@ -0,0 +1,14 @@ +namespace Supabase.Gotrue.Mfa +{ + public class MfaVerifyParams + { + // ID of the factor being verified. Returned in enroll() + public string FactorId { get; set; } + + // ID of the challenge being verified. Returned in challenge() + public string ChallengeId { get; set; } + + // Verification code provided by the user + public string Code { get; set; } + } +} \ No newline at end of file diff --git a/Gotrue/Mfa/MfaVerifyResponse.cs b/Gotrue/Mfa/MfaVerifyResponse.cs new file mode 100644 index 00000000..ee362d5b --- /dev/null +++ b/Gotrue/Mfa/MfaVerifyResponse.cs @@ -0,0 +1,27 @@ +using Newtonsoft.Json; + +namespace Supabase.Gotrue.Mfa +{ + public class MfaVerifyResponse + { + // New access token (JWT) after successful verification + [JsonProperty("access_token")] + public string AccessToken { get; set; } + + // Type of token, typically `Bearer` + [JsonProperty("token_type")] + public string TokenType { get; set; } + + // Number of seconds in which the access token will expire + [JsonProperty("expires_in")] + public int ExpiresIn { get; set; } + + // Refresh token you can use to obtain new access tokens when expired + [JsonProperty("refresh_token")] + public string RefreshToken { get; set; } + + // Updated user profile + [JsonProperty("user")] + public User User { get; set; } + } +} \ No newline at end of file diff --git a/Gotrue/Mfa/TOTP.cs b/Gotrue/Mfa/TOTP.cs new file mode 100644 index 00000000..26886b94 --- /dev/null +++ b/Gotrue/Mfa/TOTP.cs @@ -0,0 +1,24 @@ +using Newtonsoft.Json; + +namespace Supabase.Gotrue.Mfa +{ + public class TOTP + { + /** Contains a QR code encoding the authenticator URI. You can + * convert it to a URL by prepending `data:image/svg+xml;utf-8,` to + * the value. Avoid logging this value to the console. */ + [JsonProperty("qr_code")] + public string QrCode { get; set; } + + /** The TOTP secret (also encoded in the QR code). Show this secret + * in a password-style field to the user, in case they are unable to + * scan the QR code. Avoid logging this value to the console. */ + [JsonProperty("secret")] + public string Secret { get; set; } + + /** The authenticator URI encoded within the QR code, should you need + * to use it. Avoid logging this value to the console. */ + [JsonProperty("uri")] + public string Uri { get; set; } + } +} \ No newline at end of file diff --git a/Gotrue/PersistenceListener.cs b/Gotrue/PersistenceListener.cs index dceb7a70..c52988c2 100644 --- a/Gotrue/PersistenceListener.cs +++ b/Gotrue/PersistenceListener.cs @@ -32,6 +32,7 @@ public void EventHandler(IGotrueClient sender, Constants.AuthStat switch (stateChanged) { case Constants.AuthState.SignedIn: + case Constants.AuthState.MfaChallengeVerified: if (sender == null) throw new ArgumentException("Tried to save a null session (1)"); if (sender.CurrentSession == null) diff --git a/Gotrue/StatelessClient.cs b/Gotrue/StatelessClient.cs index 6e448a47..129b26d4 100644 --- a/Gotrue/StatelessClient.cs +++ b/Gotrue/StatelessClient.cs @@ -1,8 +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; namespace Supabase.Gotrue @@ -17,6 +22,115 @@ public class StatelessClient : IGotrueStatelessClient return await api.Settings(); } + /// + public async Task Enroll(string jwt, MfaEnrollParams mfaEnrollParams, StatelessClientOptions options) + { + return await GetApi(options).Enroll(jwt, mfaEnrollParams); + } + + /// + public async Task Challenge(string jwt, MfaChallengeParams mfaChallengeParams, StatelessClientOptions options) + { + return await GetApi(options).Challenge(jwt, mfaChallengeParams); + } + + /// + public async Task Verify(string jwt, MfaVerifyParams mfaVerifyParams, StatelessClientOptions options) + { + return await GetApi(options).Verify(jwt, mfaVerifyParams); + } + + /// + public async Task ChallengeAndVerify(string jwt, MfaChallengeAndVerifyParams mfaChallengeAndVerifyParams, StatelessClientOptions options) + { + var api = GetApi(options); + var challengeResponse = await api.Challenge(jwt, new MfaChallengeParams + { + FactorId = mfaChallengeAndVerifyParams.FactorId + }); + + if (challengeResponse != null) + { + var verifyResponse = await api.Verify(jwt, new MfaVerifyParams + { + FactorId = mfaChallengeAndVerifyParams.FactorId, + ChallengeId = challengeResponse.Id, + Code = mfaChallengeAndVerifyParams.Code + }); + + return verifyResponse; + } + + return null; + } + + /// + public async Task Unenroll(string jwt, MfaUnenrollParams mfaUnenrollParams, StatelessClientOptions options) + { + return await GetApi(options).Unenroll(jwt, mfaUnenrollParams); + } + + /// + public async Task ListFactors(string jwt, StatelessClientOptions options) + { + var api = GetApi(options); + var user = await api.GetUser(jwt); + + if (user != null) + { + var response = new MfaListFactorsResponse() + { + All = user.Factors, + Totp = user.Factors.Where(x => x.FactorType == "totp" && x.Status == "verified").ToList() + }; + + return response; + } + + return null; + } + public async Task GetAuthenticatorAssuranceLevel(string jwt, StatelessClientOptions options) + { + var api = GetApi(options); + var user = await api.GetUser(jwt); + + if (user != null) + { + var payload = new JwtSecurityTokenHandler().ReadJwtToken(jwt).Payload; + + if (payload == null || payload.ValidTo == DateTime.MinValue) + throw new Exception("`accessToken`'s payload was of an unknown structure."); + + AuthenticatorAssuranceLevel? currentLevel = null; + + if (payload.ContainsKey("aal")) + { + currentLevel = Enum.TryParse(payload["aal"].ToString(), out AuthenticatorAssuranceLevel parsedLevel) ? parsedLevel : (AuthenticatorAssuranceLevel?)null; + } + + AuthenticatorAssuranceLevel? nextLevel = currentLevel; + + var verifiedFactors = user.Factors?.Where(factor => factor.Status == "verified").ToList() ?? new List(); + if (verifiedFactors.Count > 0) + { + nextLevel = AuthenticatorAssuranceLevel.aal2; + } + + var currentAuthenticationMethods = payload.Amr.Select(x => JsonConvert.DeserializeObject(x)); + + var response = new MfaGetAuthenticatorAssuranceLevelResponse + { + CurrentLevel = currentLevel, + NextLevel = nextLevel, + CurrentAuthenticationMethods = currentAuthenticationMethods.ToArray() + }; + + return response; + } + + return null; + } + /// public IGotrueApi GetApi(StatelessClientOptions options) => new Api(options.Url, options.Headers); diff --git a/Gotrue/User.cs b/Gotrue/User.cs index a6f24546..1e534722 100644 --- a/Gotrue/User.cs +++ b/Gotrue/User.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using Newtonsoft.Json; +using Supabase.Gotrue.Mfa; #pragma warning disable CS1591 @@ -67,6 +68,9 @@ public class User [JsonProperty("is_anonymous")] public bool IsAnonymous { get; set; } + [JsonProperty("factors")] + public List Factors { get; set; } = new List(); + [JsonProperty("user_metadata")] public Dictionary UserMetadata { get; set; } = new Dictionary(); } diff --git a/GotrueTests/MfaClientTests.cs b/GotrueTests/MfaClientTests.cs new file mode 100644 index 00000000..0162e2d5 --- /dev/null +++ b/GotrueTests/MfaClientTests.cs @@ -0,0 +1,245 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Supabase.Gotrue; +using Supabase.Gotrue.Exceptions; +using Supabase.Gotrue.Interfaces; +using Supabase.Gotrue.Mfa; +using static GotrueTests.TestUtils; +using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert; +using static Microsoft.VisualStudio.TestTools.UnitTesting.CollectionAssert; +using static Supabase.Gotrue.Constants.AuthState; + +namespace GotrueTests; + +[TestClass] +[SuppressMessage("ReSharper", "PossibleNullReferenceException")] +public class MfaClientTests +{ + private IGotrueClient _client; + + [TestInitialize] + public void TestInitializer() + { + _client = new Client(new ClientOptions { AllowUnconfirmedUserSessions = true }); + } + + [TestMethod("MFA: Complete flow")] + public async Task MfaFlow() + { + var email = $"{RandomString(12)}@supabase.io"; + var session = await _client.SignUp(email, PASSWORD); + VerifyGoodSession(session); + + var mfaEnrollParams = new MfaEnrollParams + { + Issuer = "Supabase", + FactorType = "totp", + FriendlyName = "Enroll test" + }; + + var enrollResponse = await _client.Enroll(mfaEnrollParams); + IsNotNull(enrollResponse.Id); + AreEqual(mfaEnrollParams.FriendlyName, enrollResponse.FriendlyName); + AreEqual(mfaEnrollParams.FactorType, enrollResponse.Type); + + var challengeResponse = await _client.Challenge(new MfaChallengeParams + { + FactorId = enrollResponse.Id + }); + IsNotNull(challengeResponse.Id); + + string totpCode = TotpGenerator.GeneratePin(enrollResponse.Totp.Secret, 30, 6); + var verifyResponse = await _client.Verify(new MfaVerifyParams + { + FactorId = enrollResponse.Id, + ChallengeId = challengeResponse.Id, + Code = totpCode + }); + IsNotNull(verifyResponse); + VerifyGoodSession(verifyResponse); + + await _client.SignOut(); + + session = await _client.SignIn(email, PASSWORD); + VerifyGoodSession(session); + + var assuranceLevel = await _client.GetAuthenticatorAssuranceLevel(); + AreEqual(AuthenticatorAssuranceLevel.aal1, assuranceLevel.CurrentLevel); + AreEqual(AuthenticatorAssuranceLevel.aal2, assuranceLevel.NextLevel); + + totpCode = TotpGenerator.GeneratePin(enrollResponse.Totp.Secret, 30, 6); + await _client.ChallengeAndVerify(new MfaChallengeAndVerifyParams + { + FactorId = enrollResponse.Id, + Code = totpCode + }); + + assuranceLevel = await _client.GetAuthenticatorAssuranceLevel(); + AreEqual(AuthenticatorAssuranceLevel.aal2, assuranceLevel.CurrentLevel); + AreEqual(AuthenticatorAssuranceLevel.aal2, assuranceLevel.NextLevel); + + var factors = await _client.ListFactors(); + IsTrue(factors.Totp.Count == 1); + + var unenrollResponse = await _client.Unenroll(new MfaUnenrollParams + { + FactorId = enrollResponse.Id + }); + IsNotNull(unenrollResponse); + + await _client.SignOut(); + + session = await _client.SignIn(email, PASSWORD); + VerifyGoodSession(session); + + assuranceLevel = await _client.GetAuthenticatorAssuranceLevel(); + AreEqual(AuthenticatorAssuranceLevel.aal1, assuranceLevel.CurrentLevel); + AreEqual(AuthenticatorAssuranceLevel.aal1, assuranceLevel.NextLevel); + + factors = await _client.ListFactors(); + IsTrue(factors.Totp.Count == 0); + } + + [TestMethod("MFA: Invalid TOTP")] + public async Task MfaInvalidTotp() + { + var email = $"{RandomString(12)}@supabase.io"; + var session = await _client.SignUp(email, PASSWORD); + VerifyGoodSession(session); + + var mfaEnrollParams = new MfaEnrollParams + { + Issuer = "Supabase", + FactorType = "totp", + FriendlyName = "Enroll test" + }; + + var enrollResponse = await _client.Enroll(mfaEnrollParams); + IsNotNull(enrollResponse.Id); + + await ThrowsExceptionAsync(async () => + { + await _client.ChallengeAndVerify(new MfaChallengeAndVerifyParams + { + FactorId = enrollResponse.Id, + Code = "12345", + }); + }); + } + + [TestMethod("MFA: Invalid TOTP type during Enroll")] + public async Task MfaInvalidTotpTypeDuringEnroll() + { + var email = $"{RandomString(12)}@supabase.io"; + var session = await _client.SignUp(email, PASSWORD); + VerifyGoodSession(session); + + var mfaEnrollParams = new MfaEnrollParams + { + Issuer = "Supabase", + FactorType = "InvalidType", + FriendlyName = "Enroll test" + }; + + await ThrowsExceptionAsync(async () => + { + await _client.Enroll(mfaEnrollParams); + }); + } + + [TestMethod("MFA: Invalid FactorId during Unenroll")] + public async Task MfaInvalidFactorIdDuringUnenroll() + { + var email = $"{RandomString(12)}@supabase.io"; + var session = await _client.SignUp(email, PASSWORD); + VerifyGoodSession(session); + + await ThrowsExceptionAsync(async () => + { + await _client.Unenroll(new MfaUnenrollParams { FactorId = "" }); + }); + } + + [TestMethod("MFA: Invalid FactorId during Challenge")] + public async Task MfaInvalidFactorIdDuringChallenge() + { + var email = $"{RandomString(12)}@supabase.io"; + var session = await _client.SignUp(email, PASSWORD); + VerifyGoodSession(session); + + await ThrowsExceptionAsync(async () => + { + await _client.Challenge(new MfaChallengeParams { FactorId = "" }); + }); + } + + [TestMethod("MFA: Invalid ChallengeId during Verify")] + public async Task MfaInvalidChallengeIdDuringVerify() + { + var email = $"{RandomString(12)}@supabase.io"; + var session = await _client.SignUp(email, PASSWORD); + VerifyGoodSession(session); + + var mfaEnrollParams = new MfaEnrollParams + { + Issuer = "Supabase", + FactorType = "totp", + FriendlyName = "Enroll test" + }; + + var enrollResponse = await _client.Enroll(mfaEnrollParams); + IsNotNull(enrollResponse.Id); + + var challengeResponse = await _client.Challenge(new MfaChallengeParams + { + FactorId = enrollResponse.Id + }); + IsNotNull(challengeResponse.Id); + + await ThrowsExceptionAsync(async () => + { + await _client.Verify(new MfaVerifyParams{ Code = "", ChallengeId = "", FactorId = enrollResponse.Id }); + }); + } + + [TestMethod("MFA: Invalid FactorId during Verify")] + public async Task MfaInvalidFactorIdDuringVerify() + { + var email = $"{RandomString(12)}@supabase.io"; + var session = await _client.SignUp(email, PASSWORD); + VerifyGoodSession(session); + + var mfaEnrollParams = new MfaEnrollParams + { + Issuer = "Supabase", + FactorType = "totp", + FriendlyName = "Enroll test" + }; + + var enrollResponse = await _client.Enroll(mfaEnrollParams); + IsNotNull(enrollResponse.Id); + + var challengeResponse = await _client.Challenge(new MfaChallengeParams + { + FactorId = enrollResponse.Id + }); + IsNotNull(challengeResponse.Id); + + await ThrowsExceptionAsync(async () => + { + await _client.Verify(new MfaVerifyParams{ Code = "", ChallengeId = challengeResponse.Id, FactorId = "" }); + }); + } + + + private void VerifyGoodSession(Session session) + { + AreEqual(_client.CurrentUser.Id, session.User.Id); + IsNotNull(session.AccessToken); + IsNotNull(session.RefreshToken); + IsNotNull(session.User); + } +} \ No newline at end of file diff --git a/GotrueTests/StatelessClientTests.cs b/GotrueTests/StatelessClientTests.cs index 05c51ea7..a0deb8e9 100644 --- a/GotrueTests/StatelessClientTests.cs +++ b/GotrueTests/StatelessClientTests.cs @@ -10,6 +10,7 @@ using Supabase.Gotrue; using Supabase.Gotrue.Exceptions; using Supabase.Gotrue.Interfaces; +using Supabase.Gotrue.Mfa; using static Supabase.Gotrue.StatelessClient; using static Supabase.Gotrue.Constants; @@ -367,5 +368,81 @@ public async Task ClientUpdateUserById() Assert.AreEqual(createdUser.Id, updatedUser.Id); Assert.AreNotEqual(createdUser.Email, updatedUser.Email); } + + [TestMethod("MFA: Enroll user")] + public async Task MfaEnroll() + { + var email = $"{RandomString(12)}@supabase.io"; + await _client.SignUp(email, PASSWORD, Options); + + var session = await _client.SignIn(email, PASSWORD, Options); + Assert.IsNotNull(session.AccessToken); + Assert.IsInstanceOfType(session.User, typeof(User)); + + var enrollResponse = await _client.Enroll(session.AccessToken, new MfaEnrollParams + { + FactorType = "totp", + Issuer = "Supabase", + FriendlyName = "Enroll test", + }, Options); + + Assert.IsNotNull(enrollResponse); + + var challengeResponse = await _client.Challenge(session.AccessToken, new MfaChallengeParams + { + FactorId = enrollResponse.Id + }, Options); + Assert.IsNotNull(challengeResponse.Id); + + string totpCode = TotpGenerator.GeneratePin(enrollResponse.Totp.Secret, 30, 6); + var verifyResponse = await _client.Verify(session.AccessToken, new MfaVerifyParams + { + FactorId = enrollResponse.Id, + ChallengeId = challengeResponse.Id, + Code = totpCode + }, Options); + Assert.IsNotNull(verifyResponse); + Assert.IsNotNull(verifyResponse.AccessToken); + + await _client.SignOut(session.AccessToken, Options); + + session = await _client.SignIn(email, PASSWORD, Options); + Assert.IsNotNull(session); + Assert.IsNotNull(session.AccessToken); + + var assuranceLevel = await _client.GetAuthenticatorAssuranceLevel(session.AccessToken, Options); + Assert.AreEqual(AuthenticatorAssuranceLevel.aal1, assuranceLevel.CurrentLevel); + Assert.AreEqual(AuthenticatorAssuranceLevel.aal2, assuranceLevel.NextLevel); + + totpCode = TotpGenerator.GeneratePin(enrollResponse.Totp.Secret, 30, 6); + var challengeAndVerify = await _client.ChallengeAndVerify(session.AccessToken, new MfaChallengeAndVerifyParams + { + FactorId = enrollResponse.Id, + Code = totpCode + }, Options); + + assuranceLevel = await _client.GetAuthenticatorAssuranceLevel(challengeAndVerify.AccessToken, Options); + Assert.AreEqual(AuthenticatorAssuranceLevel.aal2, assuranceLevel.CurrentLevel); + Assert.AreEqual(AuthenticatorAssuranceLevel.aal2, assuranceLevel.NextLevel); + + var factors = await _client.ListFactors(session.AccessToken, Options); + Assert.IsTrue(factors.Totp.Count == 1); + + var unenrollResponse = await _client.Unenroll(session.AccessToken, new MfaUnenrollParams + { + FactorId = enrollResponse.Id + }, Options); + Assert.IsNotNull(unenrollResponse); + + await _client.SignOut(session.AccessToken, Options); + + session = await _client.SignIn(email, PASSWORD, Options); + assuranceLevel = await _client.GetAuthenticatorAssuranceLevel(session.AccessToken, Options); + Assert.AreEqual(AuthenticatorAssuranceLevel.aal1, assuranceLevel.CurrentLevel); + Assert.AreEqual(AuthenticatorAssuranceLevel.aal1, assuranceLevel.NextLevel); + + factors = await _client.ListFactors(session.AccessToken, Options); + Assert.IsTrue(factors.Totp.Count == 0); + } } } diff --git a/GotrueTests/Utils/TotpGenerator.cs b/GotrueTests/Utils/TotpGenerator.cs new file mode 100644 index 00000000..bdb72f98 --- /dev/null +++ b/GotrueTests/Utils/TotpGenerator.cs @@ -0,0 +1,84 @@ +// This code is adapter from a GitHub Gist by @jimbojetset [https://gist.github.com/jimbojetset/c7944fd3e900b70a61cf] + +using System; +using System.Globalization; +using System.Security.Cryptography; +using System.Text.RegularExpressions; + +class TotpGenerator +{ + public static string GeneratePin(string base32secret, int interval, int pinLength) + { + if (IsBase32(base32secret) && (interval == 30 || interval == 60) && (pinLength == 6 || pinLength == 8)) + { + byte[] secretBytes = Base32StringToBytes(base32secret); + byte[] unixTimeBytes = BitConverter.GetBytes(((long)Math.Floor((DateTime.UtcNow - (new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc))).TotalSeconds)) / interval); + if (BitConverter.IsLittleEndian) + Array.Reverse(unixTimeBytes); + byte[] hashBytes = new HMACSHA1(secretBytes).ComputeHash(unixTimeBytes); + int Offset = hashBytes[hashBytes.Length - 1] & 0xF; + byte[] bytes = new byte[4]; + Buffer.BlockCopy(hashBytes, Offset, bytes, 0, 4); + if (BitConverter.IsLittleEndian) + Array.Reverse(bytes); + int integer = BitConverter.ToInt32(bytes, 0); + int truncated = integer & 0x7FFFFFFF; + int pin = truncated % 1000000; + return pin.ToString(CultureInfo.InvariantCulture).PadLeft(pinLength, '0'); + } + return String.Empty; + } + + private static byte[] Base32StringToBytes(string input) + { + input = input.TrimEnd('='); + int byteCount = input.Length * 5 / 8; + byte[] returnBytes = new byte[byteCount]; + byte curByte = 0; + byte bitsRemaining = 8; + int mask = 0; + int arrayIndex = 0; + foreach (char c in input) + { + int cValue = CharToValue(c); + if (bitsRemaining > 5) + { + mask = cValue << (bitsRemaining - 5); + curByte = (byte)(curByte | mask); + bitsRemaining -= 5; + } + else + { + mask = cValue >> (5 - bitsRemaining); + curByte = (byte)(curByte | mask); + returnBytes[arrayIndex++] = curByte; + curByte = (byte)(cValue << (3 + bitsRemaining)); + bitsRemaining += 3; + } + } + if (arrayIndex != byteCount) + returnBytes[arrayIndex] = curByte; + return returnBytes; + } + + private static int CharToValue(char c) + { + int value = (int)c; + if (value < 91 && value > 64) + return value - 65; + if (value < 56 && value > 49) + return value - 24; + if (value < 123 && value > 96) + return value - 97; + + throw new Exception(); + } + + public static bool IsBase32(string b32) + { + Regex regex = new Regex(@"^[A-Z2-7]+=*$"); + Match match = regex.Match(b32); + bool b = b32.Length % 8 == 0 && match.Success; + return b32.Length % 8 == 0 && match.Success; + } +} \ No newline at end of file