Skip to content

Commit

Permalink
Re: #90 Implement LinkIdentity and UnlinkIdentity
Browse files Browse the repository at this point in the history
  • Loading branch information
acupofjose committed Apr 5, 2024
1 parent ff0a293 commit e2712c6
Show file tree
Hide file tree
Showing 9 changed files with 183 additions and 63 deletions.
76 changes: 23 additions & 53 deletions Gotrue/Api.cs
Original file line number Diff line number Diff line change
Expand Up @@ -441,62 +441,17 @@ public async Task<ResetPasswordForEmailState> ResetPasswordForEmail(ResetPasswor
/// <returns></returns>
private Dictionary<string, string> CreateAuthedRequestHeaders(string jwt)
{
var headers = new Dictionary<string, string>(Headers);

headers["Authorization"] = $"Bearer {jwt}";
var headers = new Dictionary<string, string>(Headers)
{
["Authorization"] = $"Bearer {jwt}"
};

return headers;
}

/// <summary>
/// Generates the relevant login URI for a third-party provider.
/// </summary>
/// <param name="provider"></param>
/// <param name="options"></param>
/// <returns></returns>
public ProviderAuthState GetUriForProvider(Provider provider, SignInOptions? options = null)
{
var builder = new UriBuilder($"{Url}/authorize");
var result = new ProviderAuthState(builder.Uri);

var attr = Core.Helpers.GetMappedToAttr(provider);
var query = HttpUtility.ParseQueryString("");
options ??= new SignInOptions();

if (options.FlowType == OAuthFlowType.PKCE)
{
var codeVerifier = Helpers.GenerateNonce();
var codeChallenge = Helpers.GeneratePKCENonceVerifier(codeVerifier);

query.Add("flow_type", "pkce");
query.Add("code_challenge", codeChallenge);
query.Add("code_challenge_method", "s256");

result.PKCEVerifier = codeVerifier;
}

if (attr is MapToAttribute)
{
query.Add("provider", attr.Mapping);

if (!string.IsNullOrEmpty(options.Scopes))
query.Add("scopes", options.Scopes);

if (!string.IsNullOrEmpty(options.RedirectTo))
query.Add("redirect_to", options.RedirectTo);

if (options.QueryParams != null)
foreach (var param in options.QueryParams)
query[param.Key] = param.Value;

builder.Query = query.ToString();

result.Uri = builder.Uri;
return result;
}

throw new Exception("Unknown provider");
}
/// <inheritdoc />
public ProviderAuthState GetUriForProvider(Provider provider, SignInOptions? options = null) =>
Helpers.GetUrlForProvider($"{Url}/authorize", provider, options);

/// <summary>
/// Log in an existing user via code from third-party provider.
Expand All @@ -515,6 +470,21 @@ public ProviderAuthState GetUriForProvider(Provider provider, SignInOptions? opt

return Helpers.MakeRequest<Session>(HttpMethod.Post, url.ToString(), body, Headers);
}

/// <inheritdoc />
public async Task<ProviderAuthState> LinkIdentity(string token, Provider provider, SignInOptions options)
{
var state = Helpers.GetUrlForProvider($"{Url}/user/identities/authorize", provider, options);
await Helpers.MakeRequest(HttpMethod.Get, state.Uri.ToString(), null, CreateAuthedRequestHeaders(token));
return state;
}

/// <inheritdoc />
public async Task<bool> UnlinkIdentity(string token, UserIdentity userIdentity)
{
var result = await Helpers.MakeRequest(HttpMethod.Delete, $"{Url}/user/identities/${userIdentity.IdentityId}", null, CreateAuthedRequestHeaders(token));
return result.ResponseMessage is { IsSuccessStatusCode: true };
}

/// <summary>
/// Removes a logged-in session.
Expand Down Expand Up @@ -677,7 +647,7 @@ public Task<BaseResponse> DeleteUser(string uid, string jwt)
public Task<BaseResponse> GenerateLink(string jwt, GenerateLinkOptions options)
{
var url = string.IsNullOrEmpty(options.RedirectTo) ? $"{Url}/admin/generate_link" : $"{Url}/admin/generate_link?redirect_to={options.RedirectTo}";

return Helpers.MakeRequest(HttpMethod.Post, url, options, CreateAuthedRequestHeaders(jwt));
}

Expand Down
26 changes: 26 additions & 0 deletions Gotrue/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,33 @@ public Task<ProviderAuthState> SignIn(Provider provider, SignInOptions? options

return null;
}

/// <inheritdoc />
public Task<ProviderAuthState> LinkIdentity(Provider provider, SignInOptions options)
{
if (!Online)
throw new GotrueException("Only supported when online", Offline);

if (CurrentSession == null || CurrentUser == null)
throw new GotrueException("A valid session is required.", NoSessionFound);

if (options.FlowType != OAuthFlowType.PKCE)
throw new GotrueException("PKCE flow type is required for this action.", InvalidFlowType);

return _api.LinkIdentity(CurrentSession.AccessToken!, provider, options);
}

/// <inheritdoc />
public Task<bool> UnlinkIdentity(UserIdentity userIdentity)
{
if (!Online)
throw new GotrueException("Only supported when online", Offline);

if (CurrentSession == null || CurrentUser == null)
throw new GotrueException("A valid session is required.", NoSessionFound);

return _api.UnlinkIdentity(CurrentSession.AccessToken!, userIdentity);
}

/// <inheritdoc />
public async Task SignOut()
Expand Down
6 changes: 5 additions & 1 deletion Gotrue/Exceptions/FailureReason.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,11 @@ public enum Reason
/// <summary>
/// Something wrong with the URL to session transformation
/// </summary>
BadSessionUrl
BadSessionUrl,
/// <summary>
/// An invalid authentication flow has been selected.
/// </summary>
InvalidFlowType
}

/// <summary>
Expand Down
53 changes: 53 additions & 0 deletions Gotrue/Helpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
using System.Threading.Tasks;
using System.Web;
using Newtonsoft.Json;
using Supabase.Core.Attributes;
using Supabase.Core.Extensions;
using Supabase.Gotrue.Exceptions;
using Supabase.Gotrue.Responses;
namespace Supabase.Gotrue
Expand Down Expand Up @@ -74,6 +76,57 @@ public static string GenerateSHA256NonceFromRawNonce(string rawNonce)
return result;
}

/// <summary>
/// Generates the relevant login URL for a third-party provider.
///
/// Modeled after: https://github.com/supabase/auth-js/blob/92fefbd49f25e20793ca74d5b83142a1bb805a18/src/GoTrueClient.ts#L2294-L2332
/// </summary>
/// <param name="url"></param>
/// <param name="provider"></param>
/// <param name="options"></param>
/// <returns></returns>
internal static ProviderAuthState GetUrlForProvider(string url, Constants.Provider provider, SignInOptions? options = null)
{
var builder = new UriBuilder(url);
var result = new ProviderAuthState(builder.Uri);

var attr = Core.Helpers.GetMappedToAttr(provider);
var query = HttpUtility.ParseQueryString("");
options ??= new SignInOptions();

if (options.FlowType == Constants.OAuthFlowType.PKCE)
{
var codeVerifier = Helpers.GenerateNonce();
var codeChallenge = Helpers.GeneratePKCENonceVerifier(codeVerifier);

query.Add("flow_type", "pkce");
query.Add("code_challenge", codeChallenge);
query.Add("code_challenge_method", "s256");

result.PKCEVerifier = codeVerifier;
}

if (attr == null)
throw new Exception("Unknown provider");

query.Add("provider", attr.Mapping);

if (!string.IsNullOrEmpty(options.Scopes))
query.Add("scopes", options.Scopes);

if (!string.IsNullOrEmpty(options.RedirectTo))
query.Add("redirect_to", options.RedirectTo);

if (options.QueryParams != null)
foreach (var param in options.QueryParams)
query[param.Key] = param.Value;

builder.Query = query.ToString();

result.Uri = builder.Uri;
return result;
}

/// <summary>
/// Adds query params to a given Url
/// </summary>
Expand Down
19 changes: 19 additions & 0 deletions Gotrue/Interfaces/IGotrueApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,25 @@ public interface IGotrueApi<TUser, TSession> : IGettableHeaders
Task<Session?> ExchangeCodeForSession(string codeVerifier, string authCode);
Task<Settings?> Settings();
Task<BaseResponse> GenerateLink(string jwt, GenerateLinkOptions options);

/// <summary>
/// Links an oauth identity to an existing user.
///
/// This method requires the PKCE flow.
/// </summary>
/// <param name="token">User's token</param>
/// <param name="provider">Provider to Link</param>
/// <param name="options"></param>
/// <returns></returns>
Task<ProviderAuthState> LinkIdentity(string token, Provider provider, SignInOptions options);

/// <summary>
/// Unlinks an identity from a user by deleting it. The user will no longer be able to sign in with that identity once it's unlinked.
/// </summary>
/// <param name="token">User's token</param>
/// <param name="userIdentity">Identity to be unlinked</param>
/// <returns></returns>
Task<bool> UnlinkIdentity(string token, UserIdentity userIdentity);
}

}
17 changes: 17 additions & 0 deletions Gotrue/Interfaces/IGotrueClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,23 @@ public interface IGotrueClient<TUser, TSession> : IGettableHeaders
/// <returns></returns>
Task<TSession?> VerifyOTP(string email, string token, EmailOtpType type = EmailOtpType.MagicLink);

/// <summary>
/// Links an oauth identity to an existing user.
///
/// This method requires the PKCE flow.
/// </summary>
/// <param name="provider">Provider to Link</param>
/// <param name="options"></param>
/// <returns></returns>
Task<ProviderAuthState> LinkIdentity(Provider provider, SignInOptions options);

/// <summary>
/// Unlinks an identity from a user by deleting it. The user will no longer be able to sign in with that identity once it's unlinked.
/// </summary>
/// <param name="userIdentity">Identity to be unlinked</param>
/// <returns></returns>
Task<bool> UnlinkIdentity(UserIdentity userIdentity);

/// <summary>
/// Add a listener to get errors that occur outside of a typical Exception flow.
/// In particular, this is used to get errors and messages from the background thread
Expand Down
21 changes: 12 additions & 9 deletions Gotrue/User.cs
Original file line number Diff line number Diff line change
Expand Up @@ -165,25 +165,28 @@ public class UserList<TUser>
/// </summary>
public class UserIdentity
{
[JsonProperty("created_at")]
public DateTime CreatedAt { get; set; }

[JsonProperty("id")]
public string? Id { get; set; }

[JsonProperty("user_id")]
public string? UserId { get; set; }

[JsonProperty("identity_data")]
public Dictionary<string, object> IdentityData { get; set; } = new Dictionary<string, object>();

[JsonProperty("last_sign_in_at")]
public DateTime LastSignInAt { get; set; }

[JsonProperty("identity_id")]
public string IdentityId { get; set; } = null!;
[JsonProperty("provider")]
public string? Provider { get; set; }

[JsonProperty("created_at")]
public DateTime CreatedAt { get; set; }

[JsonProperty("last_sign_in_at")]
public DateTime LastSignInAt { get; set; }

[JsonProperty("updated_at")]
public DateTime? UpdatedAt { get; set; }

[JsonProperty("user_id")]
public string? UserId { get; set; }
}
}
23 changes: 23 additions & 0 deletions GotrueTests/AnonKeyClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Threading.Tasks;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Supabase.Gotrue;
using Supabase.Gotrue.Exceptions;
using Supabase.Gotrue.Interfaces;
using static GotrueTests.TestUtils;
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
Expand Down Expand Up @@ -386,6 +387,28 @@ public async Task ClientSendsResetPasswordForEmailPKCE()
IsFalse(string.IsNullOrEmpty(result.PKCEVerifier));
}

[TestMethod("Client: Can Form LinkIdentity (PKCE)")]
public async Task ClientLinkIdentityPKCE()
{
var email = $"{RandomString(12)}@supabase.io";

await ThrowsExceptionAsync<GotrueException>(async () => await _client.LinkIdentity(Constants.Provider.Github, new SignInOptions
{
FlowType = Constants.OAuthFlowType.PKCE
}));

await ThrowsExceptionAsync<GotrueException>(async () => await _client.LinkIdentity(Constants.Provider.Github, new SignInOptions()));

var session = await _client.SignUp(email, PASSWORD);

var result = await _client.LinkIdentity(Constants.Provider.Github, new SignInOptions
{
FlowType = Constants.OAuthFlowType.PKCE
});

IsFalse(string.IsNullOrEmpty(result.PKCEVerifier));
}

[TestMethod("Client: Get Settings")]
public async Task Settings()
{
Expand Down
5 changes: 5 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ services:
GOTRUE_SMS_AUTOCONFIRM: 'true'
GOTRUE_SMS_PROVIDER: "twilio"
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: 'true'
GOTRUE_EXTERNAL_GITHUB_ENABLED: true
GOTRUE_EXTERNAL_GITHUB_CLIENT_ID: myappclientid
GOTRUE_EXTERNAL_GITHUB_SECRET: clientsecretvaluessssh
GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI: http://localhost:3000/callback
GOTRUE_SECURITY_MANUAL_LINKING_ENABLED: 'true'

depends_on:
- db
Expand Down

0 comments on commit e2712c6

Please sign in to comment.