Skip to content

Commit

Permalink
Implements #78
Browse files Browse the repository at this point in the history
  • Loading branch information
acupofjose committed Oct 1, 2023
1 parent 01fad74 commit a688462
Show file tree
Hide file tree
Showing 8 changed files with 158 additions and 34 deletions.
59 changes: 49 additions & 10 deletions Gotrue/Api.cs
Original file line number Diff line number Diff line change
Expand Up @@ -215,8 +215,8 @@ public async Task<PasswordlessSignInState> SignInWithOtp(SignInWithPasswordlessP

var body = new Dictionary<string, object?>
{
{"provider", Core.Helpers.GetMappedToAttr(provider).Mapping },
{"id_token", idToken }
{ "provider", Core.Helpers.GetMappedToAttr(provider).Mapping },
{ "id_token", idToken }
};

if (!string.IsNullOrEmpty(nonce))
Expand Down Expand Up @@ -273,7 +273,8 @@ public Task<BaseResponse> InviteUserByEmail(string email, string jwt)
/// <returns></returns>
public Task<Session?> SignUpWithPhone(string phone, string password, SignUpOptions? options = null)
{
var body = new Dictionary<string, object> {
var body = new Dictionary<string, object>
{
{ "phone", phone },
{ "password", password },
};
Expand Down Expand Up @@ -304,7 +305,8 @@ public Task<BaseResponse> InviteUserByEmail(string email, string jwt)
/// <returns></returns>
public Task<Session?> SignInWithPhone(string phone, string password)
{
var data = new Dictionary<string, object> {
var data = new Dictionary<string, object>
{
{ "phone", phone },
{ "password", password }
};
Expand All @@ -331,7 +333,8 @@ public Task<BaseResponse> SendMobileOTP(string phone)
/// <returns></returns>
public Task<Session?> VerifyMobileOTP(string phone, string token, MobileOtpType type)
{
var data = new Dictionary<string, string> {
var data = new Dictionary<string, string>
{
{ "phone", phone },
{ "token", token },
{ "type", Core.Helpers.GetMappedToAttr(type).Mapping }
Expand All @@ -348,7 +351,8 @@ public Task<BaseResponse> SendMobileOTP(string phone)
/// <returns></returns>
public Task<Session?> VerifyEmailOTP(string email, string token, EmailOtpType type)
{
var data = new Dictionary<string, string> {
var data = new Dictionary<string, string>
{
{ "email", email },
{ "token", token },
{ "type", Core.Helpers.GetMappedToAttr(type).Mapping }
Expand All @@ -367,6 +371,40 @@ public Task<BaseResponse> ResetPasswordForEmail(string email)
return Helpers.MakeRequest(HttpMethod.Post, $"{Url}/recover", data, Headers);
}

/// <summary>
/// Sends a password reset request to an email address.
///
/// This Method supports the PKCE Flow
/// </summary>
/// <param name="options"></param>
/// <returns></returns>
public async Task<ResetPasswordForEmailState> ResetPasswordForEmail(ResetPasswordForEmailOptions options)
{
var url = string.IsNullOrEmpty(options.RedirectTo) ? $"{Url}/recover" : $"{Url}/recover?redirect_to={options.RedirectTo}";
string? verifier = null;

var body = new Dictionary<string, object>
{
{ "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<string, string> { { "captcha_token", options.CaptchaToken! } });

await Helpers.MakeRequest(HttpMethod.Post, url, body, Headers);

return new ResetPasswordForEmailState { PKCEVerifier = verifier };
}

/// <summary>
/// Create a temporary object with all configured headers and adds the Authorization token to be used on request methods
/// </summary>
Expand Down Expand Up @@ -599,7 +637,7 @@ public Task<BaseResponse> DeleteUser(string uid, string jwt)
{
return Helpers.MakeRequest<Settings>(HttpMethod.Get, $"{Url}/settings", null, Headers);
}

/// <summary>
/// Generates a new Session given a user's access token and refresh token.
/// </summary>
Expand All @@ -612,12 +650,13 @@ public Task<BaseResponse> DeleteUser(string uid, string jwt)
{
{ "Authorization", $"Bearer {accessToken}" },
};

var data = new Dictionary<string, string> {

var data = new Dictionary<string, string>
{
{ "refresh_token", refreshToken }
};

return Helpers.MakeRequest<Session>(HttpMethod.Post, $"{Url}/token?grant_type=refresh_token", data, Headers.MergeLeft(headers));
}
}
}
}
7 changes: 7 additions & 0 deletions Gotrue/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,13 @@ public async Task<bool> ResetPasswordForEmail(string email)
result.ResponseMessage?.EnsureSuccessStatusCode();
return true;
}

/// <inheritdoc />
public async Task<ResetPasswordForEmailState> ResetPasswordForEmail(ResetPasswordForEmailOptions options)
{
var state = await _api.ResetPasswordForEmail(options);
return state;
}

/// <inheritdoc />
public async Task<Session?> RefreshSession()
Expand Down
2 changes: 2 additions & 0 deletions Gotrue/Interfaces/IGotrueApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,6 +19,7 @@ public interface IGotrueApi<TUser, TSession> : IGettableHeaders
Task<UserList<TUser>?> ListUsers(string jwt, string? filter = null, string? sortBy = null, SortOrder sortOrder = SortOrder.Descending, int? page = null, int? perPage = null);
Task<TSession?> RefreshAccessToken(string accessToken, string refreshToken);
Task<BaseResponse> ResetPasswordForEmail(string email);
Task<ResetPasswordForEmailState> ResetPasswordForEmail(ResetPasswordForEmailOptions options);
Task<BaseResponse> SendMagicLinkEmail(string email, SignInOptions? options = null);
Task<BaseResponse> SendMobileOTP(string phone);
Task<TSession?> SignInWithIdToken(Provider provider, string idToken, string? nonce = null, string? captchaToken = null);
Expand Down
9 changes: 9 additions & 0 deletions Gotrue/Interfaces/IGotrueClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,15 @@ public interface IGotrueClient<TUser, TSession> : IGettableHeaders
/// <returns></returns>
Task<bool> ResetPasswordForEmail(string email);

/// <summary>
/// Sends a password reset request to an email address.
///
/// Supports the PKCE Flow (the `verifier` from <see cref="ResetPasswordForEmailState"/> will be combined with <see cref="ExchangeCodeForSession"/> in response)
/// </summary>
/// <param name="options"></param>
/// <returns></returns>
Task<ResetPasswordForEmailState> ResetPasswordForEmail(ResetPasswordForEmailOptions options);

/// <summary>
/// Typically called as part of the startup process for the client.
///
Expand Down
38 changes: 38 additions & 0 deletions Gotrue/ResetPasswordForEmailOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
namespace Supabase.Gotrue
{
/// <summary>
/// A utility class that represents a successful response from a request to send a user's password reset using the PKCE flow.
/// </summary>
public class ResetPasswordForEmailOptions
{
/// <summary>
/// The Email representing the user's account whose password is being reset.
/// </summary>
public string Email { get; private set; }

/// <summary>
/// The OAuth Flow Type.
/// </summary>
public Constants.OAuthFlowType FlowType { get; set; } = Constants.OAuthFlowType.Implicit;

/// <summary>
/// The URL to send the user to after they click the password reset link.
/// </summary>
public string? RedirectTo { get; set; }

/// <summary>
/// Verification token received when the user completes the captcha on the site.
/// </summary>
public string? CaptchaToken { get; set; }

/// <summary>
/// PKCE Verifier generated if using the PKCE flow type.
/// </summary>
public string? PKCEVerifier { get; set; }

public ResetPasswordForEmailOptions(string email)

Check warning on line 33 in Gotrue/ResetPasswordForEmailOptions.cs

View workflow job for this annotation

GitHub Actions / buildAndTest

Missing XML comment for publicly visible type or member 'ResetPasswordForEmailOptions.ResetPasswordForEmailOptions(string)'

Check warning on line 33 in Gotrue/ResetPasswordForEmailOptions.cs

View workflow job for this annotation

GitHub Actions / buildAndTest

Missing XML comment for publicly visible type or member 'ResetPasswordForEmailOptions.ResetPasswordForEmailOptions(string)'

Check warning on line 33 in Gotrue/ResetPasswordForEmailOptions.cs

View workflow job for this annotation

GitHub Actions / buildAndTest

Missing XML comment for publicly visible type or member 'ResetPasswordForEmailOptions.ResetPasswordForEmailOptions(string)'
{
Email = email;
}
}
}
13 changes: 13 additions & 0 deletions Gotrue/ResetPasswordForEmailState.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace Supabase.Gotrue
{
/// <summary>
/// A utility class that represents a successful response from a request to send a user's password reset using the PKCE flow.
/// </summary>
public class ResetPasswordForEmailState
{
/// <summary>
/// PKCE Verifier generated if using the PKCE flow type.
/// </summary>
public string? PKCEVerifier { get; set; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public class SignInWithPasswordlessEmailOptions : SignInWithPasswordlessOptions
/// <summary>
/// The user's email address.
/// </summary>
public string Email { get; set; }
public string Email { get; private set; }

/// <summary>
/// The redirect url embedded in the email link.
Expand Down
62 changes: 39 additions & 23 deletions GotrueTests/AnonKeyClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand All @@ -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")]
Expand All @@ -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<bool>();
_client.AddStateChangedListener((sender, changed) =>
Expand All @@ -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);
}
}
}
}

0 comments on commit a688462

Please sign in to comment.