From cd5c949b6f43c65f94f4d7bf6eb535ce6996739e Mon Sep 17 00:00:00 2001 From: vnugent Date: Mon, 7 Aug 2023 20:10:37 -0400 Subject: Essentials, and build taskfile updates --- .../src/Endpoints/Auth0.cs | 92 ++++++------ .../src/Endpoints/DiscordOauth.cs | 123 +++++++++------- .../src/Endpoints/GitHubOauth.cs | 163 ++++++++++++--------- 3 files changed, 214 insertions(+), 164 deletions(-) (limited to 'plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints') diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/Auth0.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/Auth0.cs index 3166610..259e830 100644 --- a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/Auth0.cs +++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/Auth0.cs @@ -34,10 +34,9 @@ using RestSharp; using VNLib.Hashing; using VNLib.Hashing.IdentityUtility; using VNLib.Utils.Logging; -using VNLib.Net.Rest.Client; using VNLib.Plugins.Essentials.Accounts; using VNLib.Plugins.Extensions.Loading; - +using VNLib.Net.Rest.Client.Construction; namespace VNLib.Plugins.Essentials.SocialOauth.Endpoints { @@ -51,31 +50,26 @@ namespace VNLib.Plugins.Essentials.SocialOauth.Endpoints { string keyUrl = config["key_url"].GetString() ?? throw new KeyNotFoundException("Missing Auth0 'key_url' from config"); - Uri keyUri = new(keyUrl); + //Define the key endpoint + SiteAdapter.DefineSingleEndpoint() + .WithEndpoint() + .WithUrl(keyUrl) + .WithMethod(Method.Get) + .WithHeader("Accept", "application/json") + .OnResponse((r, res) => res.ThrowIfError()); //Get certificate on background thread - Auth0VerificationJwk = Task.Run(() => GetRsaCertificate(keyUri)).AsLazy(); + Auth0VerificationJwk = Task.Run(GetRsaCertificate).AsLazy(); } - - private async Task GetRsaCertificate(Uri certUri) + private async Task GetRsaCertificate() { try { Log.Debug("Getting Auth0 signing keys"); - //Get key request - RestRequest keyRequest = new(certUri, Method.Get); - keyRequest.AddHeader("Accept", "application/json"); //rent client from pool - RestResponse response; - - using (ClientContract client = ClientPool.Lease()) - { - response = await client.Resource.ExecuteAsync(keyRequest); - } - - response.ThrowIfError(); + RestResponse response = await SiteAdapter.ExecuteAsync(new GetKeyRequest()); //Get response as doc using JsonDocument doc = JsonDocument.Parse(response.RawBytes); @@ -98,40 +92,12 @@ namespace VNLib.Plugins.Essentials.SocialOauth.Endpoints } /* - * Account data may be recovered from the identity token - * and it happens after a call to GetLoginData so - * we do not need to re-verify the token + * Auth0 uses the format "platoform|{user_id}" for the user id so it should match the + * external platofrm as github and discord endoints also */ - protected override Task GetAccountDataAsync(IOAuthAccessState clientAccess, CancellationToken cancellationToken) - { - using JsonWebToken jwt = JsonWebToken.Parse(clientAccess.IdToken); - - //verify signature - - using JsonDocument userData = jwt.GetPayload(); - - if (!userData.RootElement.GetProperty("email_verified").GetBoolean()) - { - return Task.FromResult(null); - } - - string fullName = userData.RootElement.GetProperty("name").GetString() ?? " "; - - return Task.FromResult(new AccountData() - { - EmailAddress = userData.RootElement.GetProperty("email").GetString(), - First = fullName.Split(' ')[0], - Last = fullName.Split(' ')[1], - }); - } private static string GetUserIdFromPlatform(string userName) { - /* - * Auth0 uses the format "platoform|{user_id}" for the user id so it should match the - * external platofrm as github and discord endoints also - */ - return ManagedHash.ComputeHash(userName, HashAlg.SHA1, HashEncodingMode.Hexadecimal); } @@ -140,6 +106,7 @@ namespace VNLib.Plugins.Essentials.SocialOauth.Endpoints protected override Task GetLoginDataAsync(IOAuthAccessState clientAccess, CancellationToken cancellation) { + //recover the identity token using JsonWebToken jwt = JsonWebToken.Parse(clientAccess.IdToken); //Verify the token against the first signing key @@ -175,5 +142,36 @@ namespace VNLib.Plugins.Essentials.SocialOauth.Endpoints UserId = GetUserIdFromPlatform(userId) }); } + + /* + * Account data may be recovered from the identity token + * and it happens after a call to GetLoginData so + * we do not need to re-verify the token + */ + protected override Task GetAccountDataAsync(IOAuthAccessState clientAccess, CancellationToken cancellationToken) + { + using JsonWebToken jwt = JsonWebToken.Parse(clientAccess.IdToken); + + //verify signature + + using JsonDocument userData = jwt.GetPayload(); + + if (!userData.RootElement.GetProperty("email_verified").GetBoolean()) + { + return Task.FromResult(null); + } + + string fullName = userData.RootElement.GetProperty("name").GetString() ?? " "; + + return Task.FromResult(new AccountData() + { + EmailAddress = userData.RootElement.GetProperty("email").GetString(), + First = fullName.Split(' ').FirstOrDefault(), + Last = fullName.Split(' ').LastOrDefault(), + }); + } + + private sealed record class GetKeyRequest() + { } } } diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/DiscordOauth.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/DiscordOauth.cs index 93cb22d..2136d8a 100644 --- a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/DiscordOauth.cs +++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/DiscordOauth.cs @@ -23,7 +23,7 @@ */ using System; -using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using System.Text.Json.Serialization; @@ -32,9 +32,10 @@ using RestSharp; using VNLib.Hashing; using VNLib.Utils.Logging; -using VNLib.Net.Rest.Client; using VNLib.Plugins.Essentials.Accounts; using VNLib.Plugins.Extensions.Loading; +using VNLib.Net.Rest.Client.Construction; + namespace VNLib.Plugins.Essentials.SocialOauth.Endpoints { @@ -43,86 +44,108 @@ namespace VNLib.Plugins.Essentials.SocialOauth.Endpoints { public DiscordOauth(PluginBase plugin, IConfigScope config) : base(plugin, config) { + //Define profile endpoint + SiteAdapter.DefineSingleEndpoint() + .WithEndpoint() + .WithMethod(Method.Get) + .WithUrl(Config.UserDataUrl) + .WithHeader("Authorization", r => $"{r.AccessToken.Type} {r.AccessToken.Token}"); } - - private static string GetUserIdFromPlatform(string userName) - { - return ManagedHash.ComputeHash($"discord|{userName}", HashAlg.SHA1, HashEncodingMode.Hexadecimal); - } - - /* - * Matches the profile endpoint (@me) json object + * Creates a user-id from the users discord username, that is repeatable + * and matches the Auth0 social user-id format */ - private sealed class UserProfile + private static string GetUserIdFromPlatform(string userName) { - [JsonPropertyName("username")] - public string? Username { get; set; } - [JsonPropertyName("id")] - public string? UserID { get; set; } - [JsonPropertyName("url")] - public string? ProfileUrl { get; set; } - [JsonPropertyName("verified")] - public bool Verified { get; set; } - [JsonPropertyName("email")] - public string? EmailAddress { get; set; } + return ManagedHash.ComputeHash($"discord|{userName}", HashAlg.SHA1, HashEncodingMode.Hexadecimal); } + /// protected override async Task GetAccountDataAsync(IOAuthAccessState accessToken, CancellationToken cancellationToken) { - //Get the user's email address's - RestRequest request = new(Config.UserDataUrl); - //Add authorization token - request.AddHeader("Authorization", $"{accessToken.Type} {accessToken.Token}"); - //Get client from pool - using ClientContract client = ClientPool.Lease(); - //get user's profile data - RestResponse getProfileResponse = await client.Resource.ExecuteAsync(request, cancellationToken: cancellationToken); - //Check response - if (!getProfileResponse.IsSuccessful || getProfileResponse.Data == null) + //Get the user's profile + UserProfile? profile = await GetUserProfileAssync(accessToken, cancellationToken); + + if (profile == null) { - Log.Debug("Discord user request responded with code {code}:{data}", getProfileResponse.StatusCode, getProfileResponse.Content); return null; } - UserProfile discordProfile = getProfileResponse.Data; + //Make sure the user's account is verified - if (!discordProfile.Verified) + if (!profile.Verified) { return null; } + return new() { - EmailAddress = discordProfile.EmailAddress, - First = discordProfile.Username, + EmailAddress = profile.EmailAddress, + First = profile.Username, }; } + /// protected override async Task GetLoginDataAsync(IOAuthAccessState accessToken, CancellationToken cancellationToken) { - //Get the user's email address's - RestRequest request = new(Config.UserDataUrl); - //Add authorization token - request.AddHeader("Authorization", $"{accessToken.Type} {accessToken.Token}"); - //Get client from pool - using ClientContract client = ClientPool.Lease(); - //get user's profile data - RestResponse getProfileResponse = await client.Resource.ExecuteAsync(request, cancellationToken: cancellationToken); - //Check response - if (!getProfileResponse.IsSuccessful || getProfileResponse.Data?.UserID == null) + //Get the user's profile + UserProfile? profile = await GetUserProfileAssync(accessToken, cancellationToken); + + if(profile == null) { - Log.Debug("Discord user request responded with code {code}:{data}", getProfileResponse.StatusCode, getProfileResponse.Content); return null; } - UserProfile discordProfile = getProfileResponse.Data; - return new() { //Get unique user-id from the discord profile and sha1 hex hash to store in db - UserId = GetUserIdFromPlatform(discordProfile.UserID) + UserId = GetUserIdFromPlatform(profile.UserID) }; } + + private async Task GetUserProfileAssync(IOAuthAccessState accessToken, CancellationToken cancellationToken) + { + //Get the user's email address's + DiscordProfileRequest req = new(accessToken); + RestResponse response = await SiteAdapter.ExecuteAsync(req, cancellationToken); + + //Check response + if (!response.IsSuccessful || response.Content == null) + { + Log.Debug("Discord user request responded with code {code}:{data}", response.StatusCode, response.Content); + return null; + } + + UserProfile? discordProfile = JsonSerializer.Deserialize(response.RawBytes); + + if (string.IsNullOrWhiteSpace(discordProfile?.UserID)) + { + Log.Debug("Discord user request responded with invalid response data {code}:{data}", response.StatusCode, response.Content); + return null; + } + + return discordProfile; + } + + private sealed record class DiscordProfileRequest(IOAuthAccessState AccessToken) + { } + + /* + * Matches the profile endpoint (@me) json object + */ + private sealed class UserProfile + { + [JsonPropertyName("username")] + public string? Username { get; set; } + [JsonPropertyName("id")] + public string? UserID { get; set; } + [JsonPropertyName("url")] + public string? ProfileUrl { get; set; } + [JsonPropertyName("verified")] + public bool Verified { get; set; } + [JsonPropertyName("email")] + public string? EmailAddress { get; set; } + } } } \ No newline at end of file diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/GitHubOauth.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/GitHubOauth.cs index 8446b0f..1fd691b 100644 --- a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/GitHubOauth.cs +++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/Endpoints/GitHubOauth.cs @@ -24,6 +24,7 @@ using System; using System.Threading; +using System.Text.Json; using System.Threading.Tasks; using System.Collections.Generic; using System.Text.Json.Serialization; @@ -32,9 +33,10 @@ using RestSharp; using VNLib.Hashing; using VNLib.Utils.Logging; -using VNLib.Net.Rest.Client; using VNLib.Plugins.Essentials.Accounts; using VNLib.Plugins.Extensions.Loading; +using VNLib.Net.Rest.Client.Construction; + namespace VNLib.Plugins.Essentials.SocialOauth.Endpoints { @@ -49,99 +51,79 @@ namespace VNLib.Plugins.Essentials.SocialOauth.Endpoints public GitHubOauth(PluginBase plugin, IConfigScope config) : base(plugin, config) { UserEmailUrl = config["user_email_url"].GetString() ?? throw new KeyNotFoundException("Missing required key 'user_email_url' for github configuration"); - } - - protected override void StaticClientPoolInitializer(RestClient client) - { - //add accept types of normal json and github json - client.AcceptedContentTypes = new string[2] { "application/json", GITHUB_V3_ACCEPT }; - } - /* - * Matches the json result from the - */ - private sealed class GithubProfile - { - [JsonPropertyName("login")] - public string? Username { get; set; } - [JsonPropertyName("id")] - public int ID { get; set; } - [JsonPropertyName("node_id")] - public string? NodeID { get; set; } - [JsonPropertyName("avatar_url")] - public string? AvatarUrl { get; set; } - [JsonPropertyName("url")] - public string? ProfileUrl { get; set; } - [JsonPropertyName("type")] - public string? Type { get; set; } - [JsonPropertyName("name")] - public string? FullName { get; set; } - [JsonPropertyName("company")] - public string? Company { get; set; } + //Define profile endpoint, gets users required profile information + SiteAdapter.DefineSingleEndpoint() + .WithEndpoint() + .WithMethod(Method.Get) + .WithUrl(Config.UserDataUrl) + .WithHeader("Accept", GITHUB_V3_ACCEPT) + .WithHeader("Authorization", at => $"{at.AccessToken.Type} {at.AccessToken.Token}"); + + //Define email endpoint, gets users email address + SiteAdapter.DefineSingleEndpoint() + .WithEndpoint() + .WithMethod(Method.Get) + .WithUrl(UserEmailUrl) + .WithHeader("Authorization", at => $"{at.AccessToken.Type} {at.AccessToken.Token}") + .WithHeader("Accept", GITHUB_V3_ACCEPT); } + /* - * Matches the required data from the github email endpoint + * Creates a repeatable, and source specific user id for + * GitHub users. This format is identical to the algorithim used + * in the Auth0 Github connection, so it is compatible with Auth0 */ - private sealed class EmailContainer - { - [JsonPropertyName("email")] - public string? Email { get; set; } - [JsonPropertyName("primary")] - public bool Primary { get; set; } - [JsonPropertyName("verified")] - public bool Verified { get; set; } - } - private static string GetUserIdFromPlatform(int userId) { return ManagedHash.ComputeHash($"github|{userId}", HashAlg.SHA1, HashEncodingMode.Hexadecimal); } + protected override async Task GetLoginDataAsync(IOAuthAccessState accessToken, CancellationToken cancellationToken) { - //Get the user's email address's - RestRequest request = new(Config.UserDataUrl, Method.Get); - - //Add authorization token - request.AddHeader("Authorization", $"{accessToken.Type} {accessToken.Token}"); - - //Get new client from pool - using ClientContract client = ClientPool.Lease(); + GetProfileRequest req = new(accessToken); //Exec the get for the profile - RestResponse profResponse = await client.Resource.ExecuteAsync(request, cancellationToken); + RestResponse profResponse = await SiteAdapter.ExecuteAsync(req, cancellationToken); - if (!profResponse.IsSuccessful || profResponse.Data == null || profResponse.Data.ID < 100) + if (!profResponse.IsSuccessful || profResponse.RawBytes == null) { Log.Debug("Github login data attempt responded with status code {code}", profResponse.StatusCode); return null; } + GithubProfile profile = JsonSerializer.Deserialize(profResponse.RawBytes)!; + + if (profile.ID < 100) + { + Log.Debug("Github login data attempt responded with empty or invalid response body", profResponse.StatusCode); + return null; + } + //Return login data return new() { //User-id is just the SHA 1 - UserId = GetUserIdFromPlatform(profResponse.Data.ID) + UserId = GetUserIdFromPlatform(profile.ID) }; } protected override async Task GetAccountDataAsync(IOAuthAccessState accessToken, CancellationToken cancellationToken = default) { AccountData? accountData = null; - //Get the user's email address's - RestRequest request = new(UserEmailUrl, Method.Get); - //Add authorization token - request.AddHeader("Authorization", $"{accessToken.Type} {accessToken.Token}"); - using ClientContract client = ClientPool.Lease(); + //Get the user's email address's + GetEmailRequest request = new(accessToken); //get user's emails - RestResponse getEmailResponse = await client.Resource.ExecuteAsync(request, cancellationToken: cancellationToken); + RestResponse getEmailResponse = await SiteAdapter.ExecuteAsync(request, cancellationToken); + //Check status - if (getEmailResponse.IsSuccessful && getEmailResponse.Data != null) + if (getEmailResponse.IsSuccessful && getEmailResponse.RawBytes != null) { //Filter emails addresses - foreach (EmailContainer email in getEmailResponse.Data) + foreach (EmailContainer email in JsonSerializer.Deserialize(getEmailResponse.RawBytes)!) { //Capture the first primary email address and make sure its verified if (email.Primary && email.Verified) @@ -163,20 +145,24 @@ namespace VNLib.Plugins.Essentials.SocialOauth.Endpoints return null; } Continue: - //We need to get the user's profile in order to create a new account - request = new(Config.UserDataUrl, Method.Get); - //Add authorization token - request.AddHeader("Authorization", $"{accessToken.Type} {accessToken.Token}"); - //Exec the get for the profile - RestResponse profResponse = await client.Resource.ExecuteAsync(request, cancellationToken); - if (!profResponse.IsSuccessful || profResponse.Data == null) + + //We need to get the user's profile again + GetProfileRequest prof = new(accessToken); + + //Exec request against site adapter + RestResponse profResponse = await SiteAdapter.ExecuteAsync(prof, cancellationToken); + + if (!profResponse.IsSuccessful || profResponse.RawBytes == null) { Log.Debug("Github account data request failed but GH responded with status code {code}", profResponse.StatusCode); return null; } + //Deserialize the profile + GithubProfile profile = JsonSerializer.Deserialize(profResponse.RawBytes)!; + //Get the user's name from gh profile - string[] names = profResponse.Data.FullName!.Split(" ", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + string[] names = profile.FullName!.Split(" ", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); //setup the user's profile data accountData.First = names.Length > 0 ? names[0] : string.Empty; @@ -184,5 +170,48 @@ namespace VNLib.Plugins.Essentials.SocialOauth.Endpoints return accountData; } + //Requests to get required data from github + + private sealed record class GetProfileRequest(IOAuthAccessState AccessToken) + { } + + private sealed record class GetEmailRequest(IOAuthAccessState AccessToken) + { } + + /* + * Matches the json result from the + */ + private sealed class GithubProfile + { + [JsonPropertyName("login")] + public string? Username { get; set; } + [JsonPropertyName("id")] + public int ID { get; set; } + [JsonPropertyName("node_id")] + public string? NodeID { get; set; } + [JsonPropertyName("avatar_url")] + public string? AvatarUrl { get; set; } + [JsonPropertyName("url")] + public string? ProfileUrl { get; set; } + [JsonPropertyName("type")] + public string? Type { get; set; } + [JsonPropertyName("name")] + public string? FullName { get; set; } + [JsonPropertyName("company")] + public string? Company { get; set; } + } + /* + * Matches the required data from the github email endpoint + */ + private sealed class EmailContainer + { + [JsonPropertyName("email")] + public string? Email { get; set; } + [JsonPropertyName("primary")] + public bool Primary { get; set; } + [JsonPropertyName("verified")] + public bool Verified { get; set; } + } + } } \ No newline at end of file -- cgit