diff --git a/Gotrue/Api.cs b/Gotrue/Api.cs index 2a2ba61..e777785 100644 --- a/Gotrue/Api.cs +++ b/Gotrue/Api.cs @@ -215,8 +215,8 @@ public async Task SignInWithOtp(SignInWithPasswordlessP var body = new Dictionary { - {"provider", Core.Helpers.GetMappedToAttr(provider).Mapping }, - {"id_token", idToken } + { "provider", Core.Helpers.GetMappedToAttr(provider).Mapping }, + { "id_token", idToken } }; if (!string.IsNullOrEmpty(nonce)) @@ -273,7 +273,8 @@ public Task InviteUserByEmail(string email, string jwt) /// public Task SignUpWithPhone(string phone, string password, SignUpOptions? options = null) { - var body = new Dictionary { + var body = new Dictionary + { { "phone", phone }, { "password", password }, }; @@ -304,7 +305,8 @@ public Task InviteUserByEmail(string email, string jwt) /// public Task SignInWithPhone(string phone, string password) { - var data = new Dictionary { + var data = new Dictionary + { { "phone", phone }, { "password", password } }; @@ -331,7 +333,8 @@ public Task SendMobileOTP(string phone) /// public Task VerifyMobileOTP(string phone, string token, MobileOtpType type) { - var data = new Dictionary { + var data = new Dictionary + { { "phone", phone }, { "token", token }, { "type", Core.Helpers.GetMappedToAttr(type).Mapping } @@ -348,7 +351,8 @@ public Task SendMobileOTP(string phone) /// public Task VerifyEmailOTP(string email, string token, EmailOtpType type) { - var data = new Dictionary { + var data = new Dictionary + { { "email", email }, { "token", token }, { "type", Core.Helpers.GetMappedToAttr(type).Mapping } @@ -367,6 +371,40 @@ public Task ResetPasswordForEmail(string email) return Helpers.MakeRequest(HttpMethod.Post, $"{Url}/recover", data, Headers); } + /// + /// Sends a password reset request to an email address. + /// + /// This Method supports the PKCE Flow + /// + /// + /// + public async Task ResetPasswordForEmail(ResetPasswordForEmailOptions options) + { + var url = string.IsNullOrEmpty(options.RedirectTo) ? $"{Url}/recover" : $"{Url}/recover?redirect_to={options.RedirectTo}"; + string? verifier = null; + + var body = new Dictionary + { + { "email", options.Email }, + }; + + if (options.FlowType == OAuthFlowType.PKCE) + { + var challenge = Helpers.GenerateNonce(); + verifier = Helpers.GeneratePKCENonceVerifier(challenge); + + body.Add("code_challenge", challenge); + body.Add("code_challenge_method", "s256"); + } + + if (!string.IsNullOrEmpty(options.CaptchaToken)) + body.Add("gotrue_meta_security", new Dictionary { { "captcha_token", options.CaptchaToken! } }); + + await Helpers.MakeRequest(HttpMethod.Post, url, body, Headers); + + return new ResetPasswordForEmailState { PKCEVerifier = verifier }; + } + /// /// Create a temporary object with all configured headers and adds the Authorization token to be used on request methods /// @@ -599,7 +637,7 @@ public Task DeleteUser(string uid, string jwt) { return Helpers.MakeRequest(HttpMethod.Get, $"{Url}/settings", null, Headers); } - + /// /// Generates a new Session given a user's access token and refresh token. /// @@ -612,12 +650,13 @@ public Task DeleteUser(string uid, string jwt) { { "Authorization", $"Bearer {accessToken}" }, }; - - var data = new Dictionary { + + var data = new Dictionary + { { "refresh_token", refreshToken } }; return Helpers.MakeRequest(HttpMethod.Post, $"{Url}/token?grant_type=refresh_token", data, Headers.MergeLeft(headers)); } } -} +} \ No newline at end of file diff --git a/Gotrue/Client.cs b/Gotrue/Client.cs index 30882ba..dfeefa5 100644 --- a/Gotrue/Client.cs +++ b/Gotrue/Client.cs @@ -382,6 +382,13 @@ public async Task ResetPasswordForEmail(string email) result.ResponseMessage?.EnsureSuccessStatusCode(); return true; } + + /// + public async Task ResetPasswordForEmail(ResetPasswordForEmailOptions options) + { + var state = await _api.ResetPasswordForEmail(options); + return state; + } /// public async Task RefreshSession() diff --git a/Gotrue/Interfaces/IGotrueApi.cs b/Gotrue/Interfaces/IGotrueApi.cs index 387d312..ebdc1d3 100644 --- a/Gotrue/Interfaces/IGotrueApi.cs +++ b/Gotrue/Interfaces/IGotrueApi.cs @@ -2,6 +2,7 @@ using Supabase.Core.Interfaces; using Supabase.Gotrue.Responses; using static Supabase.Gotrue.Constants; + #pragma warning disable CS1591 namespace Supabase.Gotrue.Interfaces @@ -18,6 +19,7 @@ public interface IGotrueApi : IGettableHeaders Task?> ListUsers(string jwt, string? filter = null, string? sortBy = null, SortOrder sortOrder = SortOrder.Descending, int? page = null, int? perPage = null); Task RefreshAccessToken(string accessToken, string refreshToken); Task ResetPasswordForEmail(string email); + Task ResetPasswordForEmail(ResetPasswordForEmailOptions options); Task SendMagicLinkEmail(string email, SignInOptions? options = null); Task SendMobileOTP(string phone); Task SignInWithIdToken(Provider provider, string idToken, string? nonce = null, string? captchaToken = null); diff --git a/Gotrue/Interfaces/IGotrueClient.cs b/Gotrue/Interfaces/IGotrueClient.cs index c61eab9..8c0309b 100644 --- a/Gotrue/Interfaces/IGotrueClient.cs +++ b/Gotrue/Interfaces/IGotrueClient.cs @@ -115,6 +115,15 @@ public interface IGotrueClient : IGettableHeaders /// Task ResetPasswordForEmail(string email); + /// + /// Sends a password reset request to an email address. + /// + /// Supports the PKCE Flow (the `verifier` from will be combined with in response) + /// + /// + /// + Task ResetPasswordForEmail(ResetPasswordForEmailOptions options); + /// /// Typically called as part of the startup process for the client. /// diff --git a/Gotrue/ResetPasswordForEmailOptions.cs b/Gotrue/ResetPasswordForEmailOptions.cs new file mode 100644 index 0000000..e6c9504 --- /dev/null +++ b/Gotrue/ResetPasswordForEmailOptions.cs @@ -0,0 +1,38 @@ +namespace Supabase.Gotrue +{ + /// + /// A utility class that represents a successful response from a request to send a user's password reset using the PKCE flow. + /// + public class ResetPasswordForEmailOptions + { + /// + /// The Email representing the user's account whose password is being reset. + /// + public string Email { get; private set; } + + /// + /// The OAuth Flow Type. + /// + public Constants.OAuthFlowType FlowType { get; set; } = Constants.OAuthFlowType.Implicit; + + /// + /// The URL to send the user to after they click the password reset link. + /// + public string? RedirectTo { get; set; } + + /// + /// Verification token received when the user completes the captcha on the site. + /// + public string? CaptchaToken { get; set; } + + /// + /// PKCE Verifier generated if using the PKCE flow type. + /// + public string? PKCEVerifier { get; set; } + + public ResetPasswordForEmailOptions(string email) + { + Email = email; + } + } +} \ No newline at end of file diff --git a/Gotrue/ResetPasswordForEmailState.cs b/Gotrue/ResetPasswordForEmailState.cs new file mode 100644 index 0000000..1fb7dd9 --- /dev/null +++ b/Gotrue/ResetPasswordForEmailState.cs @@ -0,0 +1,13 @@ +namespace Supabase.Gotrue +{ + /// + /// A utility class that represents a successful response from a request to send a user's password reset using the PKCE flow. + /// + public class ResetPasswordForEmailState + { + /// + /// PKCE Verifier generated if using the PKCE flow type. + /// + public string? PKCEVerifier { get; set; } + } +} \ No newline at end of file diff --git a/Gotrue/SignInWithPasswordlessEmailOptions.cs b/Gotrue/SignInWithPasswordlessOptions.cs similarity index 98% rename from Gotrue/SignInWithPasswordlessEmailOptions.cs rename to Gotrue/SignInWithPasswordlessOptions.cs index 40c54b3..ca6db98 100644 --- a/Gotrue/SignInWithPasswordlessEmailOptions.cs +++ b/Gotrue/SignInWithPasswordlessOptions.cs @@ -33,7 +33,7 @@ public class SignInWithPasswordlessEmailOptions : SignInWithPasswordlessOptions /// /// The user's email address. /// - public string Email { get; set; } + public string Email { get; private set; } /// /// The redirect url embedded in the email link. diff --git a/GotrueTests/AnonKeyClientTests.cs b/GotrueTests/AnonKeyClientTests.cs index 4c02d7e..ac75f85 100644 --- a/GotrueTests/AnonKeyClientTests.cs +++ b/GotrueTests/AnonKeyClientTests.cs @@ -355,6 +355,22 @@ public async Task ClientSendsResetPasswordForEmail() IsTrue(result); } + [TestMethod("Client: Send Reset Password Email (PKCE)")] + public async Task ClientSendsResetPasswordForEmailPKCE() + { + var email = $"{RandomString(12)}@supabase.io"; + await _client.SignUp(email, PASSWORD); + var options = new ResetPasswordForEmailOptions(email) + { + RedirectTo = "http://localhost:3000", + FlowType = Constants.OAuthFlowType.PKCE + }; + + var result = await _client.ResetPasswordForEmail(options); + + IsFalse(string.IsNullOrEmpty(result.PKCEVerifier)); + } + [TestMethod("Client: Get Settings")] public async Task Settings() { @@ -376,7 +392,7 @@ await _client.Update(new UserAttributes() await _client.SignOut(); var user = await _client.SignIn(email, newPassword); - Assert.IsTrue(user != null); + IsTrue(user != null); } [TestMethod("Client: Can Set Session")] @@ -385,18 +401,18 @@ public async Task ClientCanSetSession() var email = $"{RandomString(12)}@supabase.io"; await _client.SignUp(email, PASSWORD); - Assert.IsNotNull(_client.CurrentSession); - Assert.IsFalse(string.IsNullOrEmpty(_client.CurrentSession.AccessToken)); - Assert.IsFalse(string.IsNullOrEmpty(_client.CurrentSession.RefreshToken)); + IsNotNull(_client.CurrentSession); + IsFalse(string.IsNullOrEmpty(_client.CurrentSession.AccessToken)); + IsFalse(string.IsNullOrEmpty(_client.CurrentSession.RefreshToken)); var id = _client.CurrentUser.Id; var accessToken = _client.CurrentSession.AccessToken!; var refreshToken = _client.CurrentSession.RefreshToken!; - + var email2 = $"{RandomString(12)}@supabase.io"; await _client.SignUp(email2, PASSWORD); - - Assert.AreNotEqual(accessToken, _client.CurrentSession.AccessToken); + + AreNotEqual(accessToken, _client.CurrentSession.AccessToken); var hasStateChangedTsc = new TaskCompletionSource(); _client.AddStateChangedListener((sender, changed) => @@ -405,27 +421,27 @@ public async Task ClientCanSetSession() if (changed == SignedIn) hasStateChangedTsc.TrySetResult(true); }); - + await _client.SetSession(accessToken, refreshToken); var hasStateChanged = await hasStateChangedTsc.Task; - - Assert.IsTrue(hasStateChanged); - Assert.IsNotNull(_client.CurrentSession); - Assert.IsNotNull(_client.CurrentUser); - Assert.AreEqual(id, _client.CurrentUser.Id); - + + IsTrue(hasStateChanged); + IsNotNull(_client.CurrentSession); + IsNotNull(_client.CurrentUser); + AreEqual(id, _client.CurrentUser.Id); + // As these are fresh, a new token should not be generated. - Assert.AreEqual(accessToken, _client.CurrentSession.AccessToken); - Assert.AreEqual(refreshToken, _client.CurrentSession.RefreshToken); - + AreEqual(accessToken, _client.CurrentSession.AccessToken); + AreEqual(refreshToken, _client.CurrentSession.RefreshToken); + await _client.SetSession(accessToken, refreshToken, forceAccessTokenRefresh: true); - Assert.IsNotNull(_client.CurrentSession); - Assert.IsNotNull(_client.CurrentUser); - Assert.AreEqual(id, _client.CurrentUser.Id); - + IsNotNull(_client.CurrentSession); + IsNotNull(_client.CurrentUser); + AreEqual(id, _client.CurrentUser.Id); + // As this is being forced to regenerate, the original should be different than the cached. - Assert.AreNotEqual(refreshToken, _client.CurrentSession.RefreshToken); + AreNotEqual(refreshToken, _client.CurrentSession.RefreshToken); } } -} +} \ No newline at end of file