diff options
Diffstat (limited to 'plugins')
9 files changed, 552 insertions, 417 deletions
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs index 3201e18..8580036 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs @@ -170,7 +170,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints } } - switch (mfaType.ToLower()) + switch (mfaType.ToLower(null)) { //Process a Time based one time password(TOTP) creation/regeneration case "totp": 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<GetKeyRequest>() + .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<ReadOnlyJsonWebKey[]> GetRsaCertificate(Uri certUri) + private async Task<ReadOnlyJsonWebKey[]> 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<AccountData?> 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<AccountData?>(null); - } - - string fullName = userData.RootElement.GetProperty("name").GetString() ?? " "; - - return Task.FromResult<AccountData?>(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<UserLoginData?> 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<AccountData?> 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<AccountData?>(null); + } + + string fullName = userData.RootElement.GetProperty("name").GetString() ?? " "; + + return Task.FromResult<AccountData?>(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<DiscordProfileRequest>() + .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); } + ///<inheritdoc/> protected override async Task<AccountData?> 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<UserProfile> getProfileResponse = await client.Resource.ExecuteAsync<UserProfile>(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, }; } + ///<inheritdoc/> protected override async Task<UserLoginData?> 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<UserProfile> getProfileResponse = await client.Resource.ExecuteAsync<UserProfile>(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<UserProfile?> 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<UserProfile>(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<GetProfileRequest>() + .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<GetEmailRequest>() + .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<UserLoginData?> 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<GithubProfile> profResponse = await client.Resource.ExecuteAsync<GithubProfile>(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<GithubProfile>(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<AccountData?> 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<EmailContainer[]> getEmailResponse = await client.Resource.ExecuteAsync<EmailContainer[]>(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<EmailContainer[]>(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<GithubProfile> profResponse = await client.Resource.ExecuteAsync<GithubProfile>(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<GithubProfile>(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 diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/GetTokenRequest.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/GetTokenRequest.cs new file mode 100644 index 0000000..6e7635e --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/GetTokenRequest.cs @@ -0,0 +1,34 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.SocialOauth +* File: GetTokenRequest.cs +* +* GetTokenRequest.cs is part of VNLib.Plugins.Essentials.SocialOauth which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.SocialOauth is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Essentials.SocialOauth is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + +namespace VNLib.Plugins.Essentials.SocialOauth +{ + /// <summary> + /// A request message to get an OAuth2 token from a code + /// </summary> + /// <param name="Code">The clients authentication code</param> + /// <param name="RedirectUrl">The redirect url for current site</param> + public sealed record class GetTokenRequest(string Code, string RedirectUrl) + { } +} diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/OAuthSiteAdapter.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/OAuthSiteAdapter.cs new file mode 100644 index 0000000..ce4f08c --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/OAuthSiteAdapter.cs @@ -0,0 +1,72 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.SocialOauth +* File: OAuthSiteAdapter.cs +* +* OAuthSiteAdapter.cs is part of VNLib.Plugins.Essentials.SocialOauth which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.SocialOauth is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* VNLib.Plugins.Essentials.SocialOauth is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +using RestSharp; + +using VNLib.Net.Rest.Client; +using VNLib.Net.Rest.Client.Construction; + +namespace VNLib.Plugins.Essentials.SocialOauth +{ + /// <summary> + /// Provides strucutred http messaging to an OAuth2 site. + /// </summary> + public sealed class OAuthSiteAdapter : RestSiteAdapterBase + { + protected override RestClientPool Pool { get; } + + /// <summary> + /// Initializes a new instance of the <see cref="OAuthSiteAdapter"/> class. + /// </summary> + public OAuthSiteAdapter() + { + RestClientOptions poolOptions = new() + { + MaxTimeout = 5000, + AutomaticDecompression = DecompressionMethods.All, + Encoding = Encoding.UTF8, + //disable redirects, api should not redirect + FollowRedirects = false, + }; + + //Configure rest client to comunications to main discord api + Pool = new(10, poolOptions); + } + + ///<inheritdoc/> + public override void OnResponse(RestResponse response) + { } + + ///<inheritdoc/> + public override Task WaitAsync(CancellationToken cancellation = default) + { + return Task.CompletedTask; + } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/OauthClientConfig.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/OauthClientConfig.cs index e7e81d6..4e14063 100644 --- a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/OauthClientConfig.cs +++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/OauthClientConfig.cs @@ -23,35 +23,34 @@ */ using System; +using System.Net; using System.Collections.Generic; +using VNLib.Utils.Logging; using VNLib.Utils.Extensions; -using VNLib.Plugins.Essentials.Users; -using VNLib.Plugins.Essentials.Accounts; using VNLib.Plugins.Extensions.Loading; -using VNLib.Plugins.Extensions.Loading.Users; namespace VNLib.Plugins.Essentials.SocialOauth { + /// <summary> + /// Contains the standard configuration data for an OAuth2 endpoint + /// defined by plugin configuration + /// </summary> public sealed class OauthClientConfig { - private readonly string ConfigName; - public OauthClientConfig(PluginBase plugin, IConfigScope config) { - ConfigName = config.ScopeName; - - EndpointPath = config["path"].GetString() ?? throw new KeyNotFoundException($"Missing required key 'path' in config {ConfigName}"); + EndpointPath = config["path"].GetString() ?? throw new KeyNotFoundException($"Missing required key 'path' in config {config.ScopeName}"); //Set discord account origin - AccountOrigin = config["account_origin"].GetString() ?? throw new KeyNotFoundException($"Missing required key 'account_origin' in config {ConfigName}"); + AccountOrigin = config["account_origin"].GetString() ?? throw new KeyNotFoundException($"Missing required key 'account_origin' in config {config.ScopeName}"); //Get the auth and token urls - string authUrl = config["authorization_url"].GetString() ?? throw new KeyNotFoundException($"Missing required key 'authorization_url' in config {ConfigName}"); - string tokenUrl = config["token_url"].GetString() ?? throw new KeyNotFoundException($"Missing required key 'token_url' in config {ConfigName}"); - string userUrl = config["user_data_url"].GetString() ?? throw new KeyNotFoundException($"Missing required key 'user_data_url' in config {ConfigName}"); + string authUrl = config["authorization_url"].GetString() ?? throw new KeyNotFoundException($"Missing required key 'authorization_url' in config {config.ScopeName}"); + string tokenUrl = config["token_url"].GetString() ?? throw new KeyNotFoundException($"Missing required key 'token_url' in config {config.ScopeName}"); + string userUrl = config["user_data_url"].GetString() ?? throw new KeyNotFoundException($"Missing required key 'user_data_url' in config {config.ScopeName}"); //Create the uris AccessCodeUrl = new(authUrl); AccessTokenUrl = new(tokenUrl); @@ -59,20 +58,26 @@ namespace VNLib.Plugins.Essentials.SocialOauth AllowForLocalAccounts = config["allow_for_local"].GetBoolean(); AllowRegistration = config["allow_registration"].GetBoolean(); - LoginNonceLifetime = config["valid_for_sec"].GetTimeSpan(TimeParseType.Seconds); NonceByteSize = config["nonce_size"].GetUInt32(); RandomPasswordSize = config["password_size"].GetInt32(); InitClaimValidFor = config["claim_valid_for_sec"].GetTimeSpan(TimeParseType.Seconds); - Users = plugin.GetOrCreateSingleton<UserManager>(); - Passwords = plugin.GetOrCreateSingleton<ManagedPasswordHashing>(); - //Setup async lazy loaders for secrets - ClientID = plugin.GetSecretAsync($"{ConfigName}_client_id") + ClientID = plugin.GetSecretAsync($"{config.ScopeName}_client_id") .ToLazy(static r => r.Result.ToString()); - ClientSecret = plugin.GetSecretAsync($"{ConfigName}_client_secret") + ClientSecret = plugin.GetSecretAsync($"{config.ScopeName}_client_secret") .ToLazy(static r => r.Result.ToString()); + + //Log the token server ip address for the user to verify + if (plugin.Log.IsEnabled(LogLevel.Verbose)) + { + _ = plugin.ObserveWork(async () => + { + IPAddress[] addresses = await Dns.GetHostAddressesAsync(AccessTokenUrl.DnsSafeHost); + plugin.Log.Verbose("Token server {host} resolves to {ip}", AccessTokenUrl.DnsSafeHost, addresses); + }); + } } /// <summary> @@ -108,15 +113,6 @@ namespace VNLib.Plugins.Essentials.SocialOauth /// </summary> public Uri UserDataUrl { get; } - public TimeSpan LoginNonceLifetime { get; } - - /// <summary> - /// The user store to create/get users from - /// </summary> - public IUserManager Users { get; } - - public IPasswordHashingProvider Passwords { get; } - /// <summary> /// The endpoint route/path /// </summary> diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/SocialOauthBase.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/SocialOauthBase.cs index e594462..f36dc39 100644 --- a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/SocialOauthBase.cs +++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/SocialOauthBase.cs @@ -25,7 +25,6 @@ using System; using System.Net; using System.Text; -using System.Buffers; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -39,13 +38,13 @@ using FluentValidation; using RestSharp; using VNLib.Net.Http; -using VNLib.Net.Rest.Client; using VNLib.Hashing; using VNLib.Hashing.IdentityUtility; using VNLib.Utils; using VNLib.Utils.Memory; using VNLib.Utils.Logging; using VNLib.Utils.Extensions; +using VNLib.Net.Rest.Client.Construction; using VNLib.Plugins.Essentials.Users; using VNLib.Plugins.Essentials.Accounts; using VNLib.Plugins.Essentials.Endpoints; @@ -53,6 +52,7 @@ using VNLib.Plugins.Essentials.Extensions; using VNLib.Plugins.Essentials.SocialOauth.Validators; using VNLib.Plugins.Extensions.Loading; using VNLib.Plugins.Extensions.Validation; +using VNLib.Plugins.Extensions.Loading.Users; using ContentType = VNLib.Net.Http.ContentType; @@ -72,8 +72,6 @@ namespace VNLib.Plugins.Essentials.SocialOauth const string CLAIM_COOKIE_NAME = "extern-claim"; const int SIGNING_KEY_SIZE = 32; - private static HMAC GetSigningAlg(byte[] key) => new HMACSHA256(key); - /// <summary> /// The client configuration struct passed during base class construction /// </summary> @@ -83,10 +81,20 @@ namespace VNLib.Plugins.Essentials.SocialOauth protected override ProtectionSettings EndpointProtectionSettings { get; } = new(); /// <summary> - /// The resst client connection pool + /// The site adapter used to make requests to the OAuth2 provider + /// </summary> + protected OAuthSiteAdapter SiteAdapter { get; } + + /// <summary> + /// The user manager used to create and manage user accounts /// </summary> - protected RestClientPool ClientPool { get; } - + protected IUserManager Users { get; } + + /// <summary> + /// The password hashing provider used to hash user passwords + /// </summary> + protected IPasswordHashingProvider Passwords { get; } + private readonly IValidator<LoginClaim> ClaimValidator; private readonly IValidator<string> NonceValidator; private readonly IValidator<AccountData> AccountDataValidator; @@ -96,24 +104,31 @@ namespace VNLib.Plugins.Essentials.SocialOauth ClaimValidator = GetClaimValidator(); NonceValidator = GetNonceValidator(); AccountDataValidator = new AccountDataValidator(); - - RestClientOptions poolOptions = new() - { - MaxTimeout = 5000, - AutomaticDecompression = DecompressionMethods.All, - Encoding = Encoding.UTF8, - //disable redirects, api should not redirect - FollowRedirects = false, - }; - - //Configure rest client to comunications to main discord api - ClientPool = new(10, poolOptions, StaticClientPoolInitializer); - + //Get the configuration element for the derrived type Config = plugin.CreateService<OauthClientConfig>(config); //Init endpoint InitPathAndLog(Config.EndpointPath, plugin.Log); + + Users = plugin.GetOrCreateSingleton<UserManager>(); + Passwords = plugin.GetOrCreateSingleton<ManagedPasswordHashing>(); + + //Define the site adapter + SiteAdapter = new(); + + //Define the the get-token request endpoint + SiteAdapter.DefineSingleEndpoint() + .WithEndpoint<GetTokenRequest>() + .WithMethod(Method.Post) + .WithUrl(Config.AccessTokenUrl) + .WithHeader("Accept", HttpHelpers.GetContentTypeString(ContentType.Json)) + .WithParameter("client_id", c => Config.ClientID.Value) + .WithParameter("client_secret", c => Config.ClientSecret.Value) + .WithParameter("grant_type", "authorization_code") + .WithParameter("code", r => r.Code) + .WithParameter("redirect_uri", r => r.RedirectUrl); + } private static IValidator<LoginClaim> GetClaimValidator() @@ -121,11 +136,13 @@ namespace VNLib.Plugins.Essentials.SocialOauth InlineValidator<LoginClaim> val = new(); val.RuleFor(static s => s.ClientId) .Length(10, 100) + .WithMessage("Request is not valid") + .AlphaNumericOnly() .WithMessage("Request is not valid"); val.RuleFor(static s => s.PublicKey) .Length(50, 1024) - .WithMessage("Request is not valid"); + .WithMessage("Request is not valid"); return val; } @@ -161,17 +178,6 @@ namespace VNLib.Plugins.Essentials.SocialOauth } /// <summary> - /// Invoked by the constructor during rest client initlialization - /// </summary> - /// <param name="client">The new client to be configured</param> - protected virtual void StaticClientPoolInitializer(RestClient client) - { - client.AddDefaultHeader("accept", HttpHelpers.GetContentTypeString(ContentType.Json)); - } - - protected virtual void OnBeforeGetToken(HttpEntity entity, string code, RestRequest state) { } - - /// <summary> /// When derrived in a child class, exchanges an OAuth2 code grant type /// for an OAuth2 access token to make api requests /// </summary> @@ -184,25 +190,13 @@ namespace VNLib.Plugins.Essentials.SocialOauth /// </returns> protected virtual async Task<OAuthAccessState?> ExchangeCodeForTokenAsync(HttpEntity ev, string code, CancellationToken cancellationToken) { - //valid response, time to get the actual authorization from gh for client - RestRequest request = new(Config.AccessTokenUrl, Method.Post); - - //Add required params url-encoded - request.AddParameter("client_id", Config.ClientID.Value, ParameterType.GetOrPost); - request.AddParameter("client_secret", Config.ClientSecret.Value, ParameterType.GetOrPost); - request.AddParameter("grant_type", "authorization_code", ParameterType.GetOrPost); - request.AddParameter("code", code, ParameterType.GetOrPost); - request.AddParameter("redirect_uri", $"{ev.Server.RequestUri.Scheme}://{ev.Server.RequestUri.Authority}{Path}", ParameterType.GetOrPost); - - //Allow reconfiguration - OnBeforeGetToken(ev, code, request); - - //Get client from pool - using ClientContract client = ClientPool.Lease(); + //Create new request object + GetTokenRequest req = new(code, $"{ev.Server.RequestUri.Scheme}://{ev.Server.RequestUri.Authority}{Path}"); + //Execute request and attempt to recover the authorization response - RestResponse<OAuthAccessState> response = await client.Resource.ExecuteAsync<OAuthAccessState>(request, cancellationToken: cancellationToken); - //Make sure successfull, if so return the access token to store - return response.IsSuccessful && response.Data != null ? response.Data : null; + OAuthAccessState? response = await SiteAdapter.ExecuteAsync(req, cancellationToken).AsJson<OAuthAccessState>(); + + return response; } /// <summary> @@ -213,6 +207,7 @@ namespace VNLib.Plugins.Essentials.SocialOauth /// <param name="cancellationToken">A token to cancel the operation</param> /// <returns>The user's account data, null if not account exsits on the remote site, and process cannot continue</returns> protected abstract Task<AccountData?> GetAccountDataAsync(IOAuthAccessState clientAccess, CancellationToken cancellationToken); + /// <summary> /// Gets an object that represents the required information for logging-in a user (namley unique user-id) /// </summary> @@ -221,43 +216,7 @@ namespace VNLib.Plugins.Essentials.SocialOauth /// <returns></returns> protected abstract Task<UserLoginData?> GetLoginDataAsync(IOAuthAccessState clientAccess, CancellationToken cancellation); - sealed class LoginClaim : IClientSecInfo - { - [JsonPropertyName("public_key")] - public string? PublicKey { get; set; } - - [JsonPropertyName("browser_id")] - public string? ClientId { get; set; } - - [JsonPropertyName("exp")] - public long ExpirationSeconds { get; set; } - - [JsonPropertyName("iat")] - public long IssuedAtTime { get; set; } - - [JsonPropertyName("nonce")] - public string? Nonce { get; set; } - - public void ComputeNonce(int nonceSize) - { - byte[] buffer = ArrayPool<byte>.Shared.Rent(nonceSize); - try - { - Span<byte> nonce = buffer.AsSpan(0, nonceSize); - - //get random data - RandomHash.GetRandomBytes(nonce); - - //Encode nonce - Nonce = VnEncoding.ToBase32String(nonce); - } - finally - { - MemoryUtil.InitializeBlock(buffer.AsSpan()); - ArrayPool<byte>.Shared.Return(buffer); - } - } - } + /* * Claims are considered indempodent because they require no previous state @@ -265,6 +224,7 @@ namespace VNLib.Plugins.Essentials.SocialOauth * uniquely identifies the claim and authorization upgrade later */ + ///<inheritdoc/> protected override async ValueTask<VfReturnType> PutAsync(HttpEntity entity) { ValErrWebMessage webm = new(); @@ -295,11 +255,10 @@ namespace VNLib.Plugins.Essentials.SocialOauth claim.ComputeNonce((int)Config.NonceByteSize); //Build the redirect uri - webm.Result = new LoginUriBuilder() + webm.Result = new LoginUriBuilder(Config) .WithEncoding(entity.Server.Encoding) .WithUrl(entity.IsSecure ? "https" : "http", entity.Server.RequestUri.Authority, Path) .WithNonce(claim.Nonce!) - .Build(Config) .Encrypt(entity, claim); //Sign and set the claim cookie @@ -313,11 +272,12 @@ namespace VNLib.Plugins.Essentials.SocialOauth /* * Get method is invoked when the remote OAuth2 control has been passed back - * to this server. If successfull should include a code that grants authorization + * to this server. If successful should include a code that grants authorization * and include a state variable that the client decrypted from an initial claim * to prove its identity */ + ///<inheritdoc/> protected override async ValueTask<VfReturnType> GetAsync(HttpEntity entity) { //Make sure state and code parameters are available @@ -392,6 +352,7 @@ namespace VNLib.Plugins.Essentials.SocialOauth * Post messages finalize a login from a nonce */ + ///<inheritdoc/> protected override async ValueTask<VfReturnType> PostAsync(HttpEntity entity) { ValErrWebMessage webm = new(); @@ -440,7 +401,7 @@ namespace VNLib.Plugins.Essentials.SocialOauth { entity.CloseResponse(webm); return VfReturnType.VirtualSkip; - } + } //Safe to recover the access token IOAuthAccessState token = entity.Session.GetObject<OAuthAccessState>(SESSION_TOKEN_KEY_NAME); @@ -455,7 +416,7 @@ namespace VNLib.Plugins.Essentials.SocialOauth } //Fetch the user from the database - IUser? user = await Config.Users.GetUserFromIDAsync(userLogin.UserId, entity.EventCancellation); + IUser? user = await Users.GetUserFromIDAsync(userLogin.UserId, entity.EventCancellation); if(user == null) { @@ -483,11 +444,11 @@ namespace VNLib.Plugins.Essentials.SocialOauth } //Generate a new random passowrd incase the user wants to use a local account to log in sometime in the future - using PrivateString passhash = Config.Passwords.GetRandomPassword(Config.RandomPasswordSize); + using PrivateString passhash = Passwords.GetRandomPassword(Config.RandomPasswordSize); try { //Create the user with the specified email address, minimum privilage level, and an empty password - user = await Config.Users.CreateUserAsync(userLogin.UserId!, userAccount.EmailAddress, AccountUtil.MINIMUM_LEVEL, passhash, entity.EventCancellation); + user = await Users.CreateUserAsync(userLogin.UserId!, userAccount.EmailAddress, AccountUtil.MINIMUM_LEVEL, passhash, entity.EventCancellation); //Set active status user.Status = UserStatus.Active; //Store the new profile @@ -532,6 +493,7 @@ namespace VNLib.Plugins.Essentials.SocialOauth return VfReturnType.VirtualSkip; } } + //Finalze login try { @@ -582,126 +544,13 @@ namespace VNLib.Plugins.Essentials.SocialOauth entity.CloseResponse(webm); return VfReturnType.VirtualSkip; } - - - - /* - * Construct the client's redirect url based on their login claim, which contains - * a public key which can be used to encrypt the url so that only the client - * private-key holder can decrypt the url and redirect themselves to the - * target OAuth website. - * - * The result is an encrypted nonce that should guard against replay attacks and MITM - */ - - sealed class LoginUriBuilder - { - private readonly IMemoryHandle<byte> _buffer; - - private Span<byte> _binBuffer => _buffer.Span[1024..]; - private Span<char> _charBuffer => MemoryMarshal.Cast<byte, char>(_buffer.Span[..1024]); - - private string? redirectUrl; - private string? nonce; - private Encoding _encoding; - - private int _urlCharPointer; - - public LoginUriBuilder() - { - //Alloc buffer - _buffer = MemoryUtil.SafeAllocNearestPage<byte>(8000, true); - - //Set default encoding - _encoding = Encoding.UTF8; - } - - public LoginUriBuilder WithUrl(ReadOnlySpan<char> scheme, ReadOnlySpan<char> authority, ReadOnlySpan<char> path) - { - //buffer writer for easier syntax - ForwardOnlyWriter<char> writer = new(_charBuffer); - //first build the redirect url to re-encode it - writer.Append(scheme); - writer.Append("://"); - //Create redirect url (current page, default action is to authorize the client) - writer.Append(authority); - writer.Append(path); - //url encode the redirect path and save it for later - redirectUrl = Uri.EscapeDataString(writer.ToString()); - - return this; - } - - public LoginUriBuilder WithEncoding(Encoding encoding) - { - _encoding = encoding; - return this; - } - - public LoginUriBuilder WithNonce(string base32Nonce) - { - nonce = base32Nonce; - return this; - } - - public LoginUriBuilder Build(OauthClientConfig config) - { - //buffer writer for easier syntax - ForwardOnlyWriter<char> writer = new(_charBuffer); - - //Append the config redirect path - writer.Append(config.AccessCodeUrl.OriginalString); - //begin query arguments - writer.Append("&client_id="); - writer.Append(config.ClientID.Value); - //add the redirect url - writer.Append("&redirect_uri="); - writer.Append(redirectUrl); - //Append the state parameter - writer.Append("&state="); - writer.Append(nonce); - - //Update url pointer - _urlCharPointer = writer.Written; - - return this; - } - - public string Encrypt(HttpEntity client, IClientSecInfo secInfo) - { - try - { - ReadOnlySpan<char> url = _charBuffer[.._urlCharPointer]; - - //Separate buffers - Span<byte> encryptionBuffer = _binBuffer[1024..]; - Span<byte> encodingBuffer = _binBuffer[..1024]; - - //Encode the url to binary - int byteCount = _encoding.GetBytes(url, encodingBuffer); - - //Encrypt the binary data - ERRNO count = client.TryEncryptClientData(secInfo, encodingBuffer[..byteCount], encryptionBuffer); - - //base64 encode the encrypted - return Convert.ToBase64String(encryptionBuffer[0..(int)count]); - } - finally - { - _urlCharPointer = 0; - //Dispose buffer - _buffer.Dispose(); - } - } - - } private static bool VerifyAndGetClaim(HttpEntity entity, [NotNullWhen(true)] out LoginClaim? claim) { claim = null; //Try to get the cookie - if(!entity.Server.GetCookie(CLAIM_COOKIE_NAME, out string? cookieValue)) + if (!entity.Server.GetCookie(CLAIM_COOKIE_NAME, out string? cookieValue)) { return false; } @@ -713,7 +562,7 @@ namespace VNLib.Plugins.Essentials.SocialOauth if (key == null) { return false; - } + } try { @@ -721,12 +570,9 @@ namespace VNLib.Plugins.Essentials.SocialOauth using JsonWebToken jwt = JsonWebToken.Parse(cookieValue); //Verify the jwt - using(HMAC alg = GetSigningAlg(key)) + if (!jwt.Verify(key, HashAlg.SHA256)) { - if (!jwt.Verify(alg)) - { - return false; - } + return false; } //Recover the clam from the jwt @@ -737,6 +583,7 @@ namespace VNLib.Plugins.Essentials.SocialOauth } catch (FormatException) { + //JWT was corrupted and could not be parsed return false; } finally @@ -745,32 +592,40 @@ namespace VNLib.Plugins.Essentials.SocialOauth } } - private static void ClearClaimData(HttpEntity entity) + private static void ClearClaimData(HttpEntity entity) { + //Remove the upgrade cookie if (entity.Server.RequestCookies.ContainsKey(CLAIM_COOKIE_NAME)) { - entity.Server.ExpireCookie(CLAIM_COOKIE_NAME); + //Expire cookie + HttpCookie cookie = new(CLAIM_COOKIE_NAME, string.Empty) + { + Secure = true, + HttpOnly = true, + ValidFor = TimeSpan.Zero, + SameSite = CookieSameSite.SameSite + }; + + entity.Server.SetCookie(in cookie); } + //Clear the signing key from the session entity.Session[SESSION_SIG_KEY_NAME] = null!; } - private void SignAndSetCookie(HttpEntity entity, LoginClaim claim) + private void SignAndSetCookie(HttpEntity entity, LoginClaim claim) { //Setup Jwt using JsonWebToken jwt = new(); - //Write claim body + //Write claim body, we dont need a header jwt.WritePayload(claim); //Generate signing key byte[] sigKey = RandomHash.GetRandomBytes(SIGNING_KEY_SIZE); //Sign the jwt - using(HMAC alg = GetSigningAlg(sigKey)) - { - jwt.Sign(alg); - } + jwt.Sign(sigKey, HashAlg.SHA256); //Build and set cookie HttpCookie cookie = new(CLAIM_COOKIE_NAME, jwt.Compile()) @@ -786,7 +641,137 @@ namespace VNLib.Plugins.Essentials.SocialOauth //Encode and store the signing key in the clien't session entity.Session[SESSION_SIG_KEY_NAME] = VnEncoding.ToBase32String(sigKey); + //Clear the signing key MemoryUtil.InitializeBlock(sigKey.AsSpan()); } + + /* + * Construct the client's redirect url based on their login claim, which contains + * a public key which can be used to encrypt the url so that only the client + * private-key holder can decrypt the url and redirect themselves to the + * target OAuth website. + * + * The result is an encrypted nonce that should guard against replay attacks and MITM + */ + + sealed record class LoginUriBuilder(OauthClientConfig Config) + { + private string? redirectUrl; + private string? nonce; + private Encoding _encoding = Encoding.UTF8; + + public LoginUriBuilder WithUrl(ReadOnlySpan<char> scheme, ReadOnlySpan<char> authority, ReadOnlySpan<char> path) + { + //Alloc stack buffer for url + Span<char> buffer = stackalloc char[1024]; + + //buffer writer for easier syntax + ForwardOnlyWriter<char> writer = new(buffer); + //first build the redirect url to re-encode it + writer.Append(scheme); + writer.Append("://"); + //Create redirect url (current page, default action is to authorize the client) + writer.Append(authority); + writer.Append(path); + //url encode the redirect path and save it for later + redirectUrl = Uri.EscapeDataString(writer.ToString()); + + return this; + } + + public LoginUriBuilder WithEncoding(Encoding encoding) + { + _encoding = encoding; + return this; + } + + public LoginUriBuilder WithNonce(string base32Nonce) + { + nonce = base32Nonce; + return this; + } + + public string Encrypt(HttpEntity client, IClientSecInfo secInfo) + { + //Alloc buffer and split it into binary and char buffers + using UnsafeMemoryHandle<byte> buffer = MemoryUtil.UnsafeAllocNearestPage(8000); + + Span<byte> binBuffer = buffer.Span[2048..]; + Span<char> charBuffer = MemoryMarshal.Cast<byte, char>(buffer.Span[..2048]); + + + /* + * Build the character uri so we can encode it to binary, + * encrypt it and return it to the client + */ + + ForwardOnlyWriter<char> writer = new(charBuffer); + + //Append the config redirect path + writer.Append(Config.AccessCodeUrl.OriginalString); + //begin query arguments + writer.Append("&client_id="); + writer.Append(Config.ClientID.Value); + //add the redirect url + writer.Append("&redirect_uri="); + writer.Append(redirectUrl); + //Append the state parameter + writer.Append("&state="); + writer.Append(nonce); + + //Collect the written character data + ReadOnlySpan<char> url = writer.AsSpan(); + + //Separate bin buffers for encryption and encoding + Span<byte> encryptionBuffer = binBuffer[1024..]; + Span<byte> encodingBuffer = binBuffer[..1024]; + + //Encode the url to binary + int byteCount = _encoding.GetBytes(url, encodingBuffer); + + //Encrypt the binary data + ERRNO count = client.TryEncryptClientData(secInfo, encodingBuffer[..byteCount], encryptionBuffer); + + //base64 encode the encrypted + return Convert.ToBase64String(encryptionBuffer[0..(int)count]); + } + } + + + sealed class LoginClaim : IClientSecInfo + { + [JsonPropertyName("public_key")] + public string? PublicKey { get; set; } + + [JsonPropertyName("browser_id")] + public string? ClientId { get; set; } + + [JsonPropertyName("exp")] + public long ExpirationSeconds { get; set; } + + [JsonPropertyName("iat")] + public long IssuedAtTime { get; set; } + + [JsonPropertyName("nonce")] + public string? Nonce { get; set; } + + public void ComputeNonce(int nonceSize) + { + //Alloc nonce buffer + using UnsafeMemoryHandle<byte> buffer = MemoryUtil.UnsafeAlloc(nonceSize); + try + { + //fill the buffer with random data + RandomHash.GetRandomBytes(buffer.Span); + + //Base32-Encode nonce and save it + Nonce = VnEncoding.ToBase32String(buffer.Span); + } + finally + { + MemoryUtil.InitializeBlock(buffer.Span); + } + } + } } } diff --git a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/UserLoginData.cs b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/UserLoginData.cs index cb2406c..93f9f12 100644 --- a/plugins/VNLib.Plugins.Essentials.SocialOauth/src/UserLoginData.cs +++ b/plugins/VNLib.Plugins.Essentials.SocialOauth/src/UserLoginData.cs @@ -1,5 +1,5 @@ /* -* Copyright (c) 2022 Vaughn Nugent +* Copyright (c) 2023 Vaughn Nugent * * Library: VNLib * Package: VNLib.Plugins.Essentials.SocialOauth @@ -24,8 +24,6 @@ using System.Text.Json.Serialization; -#nullable enable - namespace VNLib.Plugins.Essentials.SocialOauth { public class UserLoginData |