From e2712c62deb34206ed4b6a41d318accc19095e1f Mon Sep 17 00:00:00 2001 From: Joseph Schultz Date: Thu, 4 Apr 2024 22:08:44 -0500 Subject: [PATCH] Re: #90 Implement `LinkIdentity` and `UnlinkIdentity` --- Gotrue/Api.cs | 76 +++++++++--------------------- Gotrue/Client.cs | 26 ++++++++++ Gotrue/Exceptions/FailureReason.cs | 6 ++- Gotrue/Helpers.cs | 53 +++++++++++++++++++++ Gotrue/Interfaces/IGotrueApi.cs | 19 ++++++++ Gotrue/Interfaces/IGotrueClient.cs | 17 +++++++ Gotrue/User.cs | 21 +++++---- GotrueTests/AnonKeyClientTests.cs | 23 +++++++++ docker-compose.yml | 5 ++ 9 files changed, 183 insertions(+), 63 deletions(-) diff --git a/Gotrue/Api.cs b/Gotrue/Api.cs index ebba07c..db412f9 100644 --- a/Gotrue/Api.cs +++ b/Gotrue/Api.cs @@ -441,62 +441,17 @@ public async Task ResetPasswordForEmail(ResetPasswor /// private Dictionary CreateAuthedRequestHeaders(string jwt) { - var headers = new Dictionary(Headers); - - headers["Authorization"] = $"Bearer {jwt}"; + var headers = new Dictionary(Headers) + { + ["Authorization"] = $"Bearer {jwt}" + }; return headers; } - /// - /// Generates the relevant login URI for a third-party provider. - /// - /// - /// - /// - 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"); - } + /// + public ProviderAuthState GetUriForProvider(Provider provider, SignInOptions? options = null) => + Helpers.GetUrlForProvider($"{Url}/authorize", provider, options); /// /// Log in an existing user via code from third-party provider. @@ -515,6 +470,21 @@ public ProviderAuthState GetUriForProvider(Provider provider, SignInOptions? opt return Helpers.MakeRequest(HttpMethod.Post, url.ToString(), body, Headers); } + + /// + public async Task 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; + } + + /// + public async Task 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 }; + } /// /// Removes a logged-in session. @@ -677,7 +647,7 @@ public Task DeleteUser(string uid, string jwt) public Task 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)); } diff --git a/Gotrue/Client.cs b/Gotrue/Client.cs index 28698a5..74a1586 100644 --- a/Gotrue/Client.cs +++ b/Gotrue/Client.cs @@ -347,7 +347,33 @@ public Task SignIn(Provider provider, SignInOptions? options return null; } + + /// + public Task 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); + } + /// + public Task 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); + } /// public async Task SignOut() diff --git a/Gotrue/Exceptions/FailureReason.cs b/Gotrue/Exceptions/FailureReason.cs index 36231f0..b16542a 100644 --- a/Gotrue/Exceptions/FailureReason.cs +++ b/Gotrue/Exceptions/FailureReason.cs @@ -75,7 +75,11 @@ public enum Reason /// /// Something wrong with the URL to session transformation /// - BadSessionUrl + BadSessionUrl, + /// + /// An invalid authentication flow has been selected. + /// + InvalidFlowType } /// diff --git a/Gotrue/Helpers.cs b/Gotrue/Helpers.cs index 6e41367..8324b0b 100644 --- a/Gotrue/Helpers.cs +++ b/Gotrue/Helpers.cs @@ -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 @@ -74,6 +76,57 @@ public static string GenerateSHA256NonceFromRawNonce(string rawNonce) return result; } + /// + /// 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 + /// + /// + /// + /// + /// + 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; + } + /// /// Adds query params to a given Url /// diff --git a/Gotrue/Interfaces/IGotrueApi.cs b/Gotrue/Interfaces/IGotrueApi.cs index 1fcbad1..acdb69d 100644 --- a/Gotrue/Interfaces/IGotrueApi.cs +++ b/Gotrue/Interfaces/IGotrueApi.cs @@ -40,6 +40,25 @@ public interface IGotrueApi : IGettableHeaders Task ExchangeCodeForSession(string codeVerifier, string authCode); Task Settings(); Task GenerateLink(string jwt, GenerateLinkOptions options); + + /// + /// Links an oauth identity to an existing user. + /// + /// This method requires the PKCE flow. + /// + /// User's token + /// Provider to Link + /// + /// + Task LinkIdentity(string token, Provider provider, SignInOptions options); + + /// + /// 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. + /// + /// User's token + /// Identity to be unlinked + /// + Task UnlinkIdentity(string token, UserIdentity userIdentity); } } \ No newline at end of file diff --git a/Gotrue/Interfaces/IGotrueClient.cs b/Gotrue/Interfaces/IGotrueClient.cs index 81fd0bb..ab1a4e2 100644 --- a/Gotrue/Interfaces/IGotrueClient.cs +++ b/Gotrue/Interfaces/IGotrueClient.cs @@ -362,6 +362,23 @@ public interface IGotrueClient : IGettableHeaders /// Task VerifyOTP(string email, string token, EmailOtpType type = EmailOtpType.MagicLink); + /// + /// Links an oauth identity to an existing user. + /// + /// This method requires the PKCE flow. + /// + /// Provider to Link + /// + /// + Task LinkIdentity(Provider provider, SignInOptions options); + + /// + /// 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. + /// + /// Identity to be unlinked + /// + Task UnlinkIdentity(UserIdentity userIdentity); + /// /// 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 diff --git a/Gotrue/User.cs b/Gotrue/User.cs index 8aa4e7a..023fd2c 100644 --- a/Gotrue/User.cs +++ b/Gotrue/User.cs @@ -165,25 +165,28 @@ public class UserList /// 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 IdentityData { get; set; } = new Dictionary(); - [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; } } } \ No newline at end of file diff --git a/GotrueTests/AnonKeyClientTests.cs b/GotrueTests/AnonKeyClientTests.cs index f0e1225..784fec0 100644 --- a/GotrueTests/AnonKeyClientTests.cs +++ b/GotrueTests/AnonKeyClientTests.cs @@ -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; @@ -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(async () => await _client.LinkIdentity(Constants.Provider.Github, new SignInOptions + { + FlowType = Constants.OAuthFlowType.PKCE + })); + + await ThrowsExceptionAsync(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() { diff --git a/docker-compose.yml b/docker-compose.yml index 90db24b..08a4ebe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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