aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLibravatar vman <public@vaughnnugent.com>2022-11-20 15:27:58 -0500
committerLibravatar vman <public@vaughnnugent.com>2022-11-20 15:27:58 -0500
commite4ce3ae25802471bea0ea99698fbb3f6ffdf7953 (patch)
tree03761a12731538e365cc46dcddf886991809dda2
parent7b3c8209eb78029ca74c1bac781409c0d6dd50ce (diff)
Fix SocialOauth
-rw-r--r--VNLib.Plugins.Essentials.SocialOauth/ClientAccessTokenState.cs87
-rw-r--r--VNLib.Plugins.Essentials.SocialOauth/ClientRequestState.cs81
-rw-r--r--VNLib.Plugins.Essentials.SocialOauth/Endpoints/Auth0.cs190
-rw-r--r--VNLib.Plugins.Essentials.SocialOauth/Endpoints/DiscordOauth.cs152
-rw-r--r--VNLib.Plugins.Essentials.SocialOauth/Endpoints/GitHubOauth.cs214
-rw-r--r--VNLib.Plugins.Essentials.SocialOauth/IOAuthAccessState.cs57
-rw-r--r--VNLib.Plugins.Essentials.SocialOauth/OauthClientConfig.cs130
-rw-r--r--VNLib.Plugins.Essentials.SocialOauth/README.md45
-rw-r--r--VNLib.Plugins.Essentials.SocialOauth/SocialEntryPoint.cs82
-rw-r--r--VNLib.Plugins.Essentials.SocialOauth/SocialOauthBase.cs619
-rw-r--r--VNLib.Plugins.Essentials.SocialOauth/UserLoginData.cs36
-rw-r--r--VNLib.Plugins.Essentials.SocialOauth/VNLib.Plugins.Essentials.SocialOauth.csproj50
-rw-r--r--VNLib.Plugins.Essentials.SocialOauth/Validators/AccountDataValidator.cs74
-rw-r--r--VNLib.Plugins.Essentials.SocialOauth/Validators/LoginMessageValidation.cs60
14 files changed, 1877 insertions, 0 deletions
diff --git a/VNLib.Plugins.Essentials.SocialOauth/ClientAccessTokenState.cs b/VNLib.Plugins.Essentials.SocialOauth/ClientAccessTokenState.cs
new file mode 100644
index 0000000..4d6f38d
--- /dev/null
+++ b/VNLib.Plugins.Essentials.SocialOauth/ClientAccessTokenState.cs
@@ -0,0 +1,87 @@
+/*
+* Copyright (c) 2022 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.SocialOauth
+* File: ClientAccessTokenState.cs
+*
+* ClientAccessTokenState.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;
+using System.Security.Cryptography;
+using System.Text.Json.Serialization;
+
+using VNLib.Hashing;
+using VNLib.Utils.Memory;
+using VNLib.Utils.Memory.Caching;
+using VNLib.Plugins.Essentials.Accounts;
+
+#nullable enable
+
+namespace VNLib.Plugins.Essentials.SocialOauth
+{
+ public sealed class OAuthAccessState : IOAuthAccessState, ICacheable, INonce
+ {
+ ///<inheritdoc/>
+ [JsonPropertyName("access_token")]
+ public string? Token { get; set; }
+ ///<inheritdoc/>
+ [JsonPropertyName("scope")]
+ public string? Scope { get; set; }
+ ///<inheritdoc/>
+ [JsonPropertyName("token_type")]
+ public string? Type { get; set; }
+ ///<inheritdoc/>
+ [JsonPropertyName("refresh_token")]
+ public string? RefreshToken { get; set; }
+ ///<inheritdoc/>
+ [JsonPropertyName("id_token")]
+ public string? IdToken { get; set; }
+
+ //Ignore the public key and client ids
+ [JsonIgnore]
+ internal string? PublicKey { get; set; }
+ [JsonIgnore]
+ internal string? ClientId { get; set; }
+
+ /// <summary>
+ /// A random nonce generated when the access state is created and
+ /// deleted when then access token is evicted.
+ /// </summary>
+ [JsonIgnore]
+ internal ReadOnlyMemory<byte> Nonce { get; private set; }
+
+ DateTime ICacheable.Expires { get; set; }
+ bool IEquatable<ICacheable>.Equals(ICacheable? other) => GetHashCode() == other?.GetHashCode();
+ public override int GetHashCode() => Token!.GetHashCode();
+ void ICacheable.Evicted()
+ {
+ Memory.UnsafeZeroMemory(Nonce);
+ }
+
+ void INonce.ComputeNonce(Span<byte> buffer)
+ {
+ //Compute nonce
+ RandomHash.GetRandomBytes(buffer);
+ //Copy and store
+ Nonce = buffer.ToArray();
+ }
+
+ bool INonce.VerifyNonce(ReadOnlySpan<byte> nonceBytes) => CryptographicOperations.FixedTimeEquals(Nonce.Span, nonceBytes);
+ }
+} \ No newline at end of file
diff --git a/VNLib.Plugins.Essentials.SocialOauth/ClientRequestState.cs b/VNLib.Plugins.Essentials.SocialOauth/ClientRequestState.cs
new file mode 100644
index 0000000..2f35e48
--- /dev/null
+++ b/VNLib.Plugins.Essentials.SocialOauth/ClientRequestState.cs
@@ -0,0 +1,81 @@
+/*
+* Copyright (c) 2022 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.SocialOauth
+* File: ClientRequestState.cs
+*
+* ClientRequestState.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;
+using System.Security.Cryptography;
+
+using VNLib.Hashing;
+using VNLib.Utils;
+using VNLib.Utils.Memory;
+using VNLib.Utils.Memory.Caching;
+
+namespace VNLib.Plugins.Essentials.SocialOauth
+{
+ internal sealed class ClientRequestState : ICacheable
+ {
+ private readonly ReadOnlyMemory<byte> _rawKey;
+
+ /// <summary>
+ /// The raw nonce state bytes
+ /// </summary>
+ public ReadOnlyMemory<byte> State { get; private set; }
+
+ public ClientRequestState(ReadOnlySpan<char> keyChar, int nonceBytes)
+ {
+ //Get browser id
+ _rawKey = Convert.FromHexString(keyChar);
+ RecomputeState(nonceBytes);
+ }
+
+ /// <summary>
+ /// Recomputes a nonce state and signature for the current
+ /// connection
+ /// </summary>
+ /// <param name="nonceBytes">The size of the nonce (in bytes) to generate</param>
+ public void RecomputeState(int nonceBytes)
+ {
+ //Get random nonce buffer
+ State = RandomHash.GetRandomBytes(nonceBytes);
+ }
+ /// <summary>
+ /// Computes the signature of the supplied data based on the original
+ /// client state for this connection
+ /// </summary>
+ /// <param name="data"></param>
+ /// <returns></returns>
+ public ERRNO ComputeSignatureForClient(ReadOnlySpan<byte> data, Span<byte> output)
+ {
+ return HMACSHA512.TryHashData(_rawKey.Span, data, output, out int count) ? count : ERRNO.E_FAIL;
+ }
+
+ public DateTime Expires { get; set; }
+ bool IEquatable<ICacheable>.Equals(ICacheable other) => ReferenceEquals(this, other);
+ void ICacheable.Evicted()
+ {
+ //Zero secrets on eviction
+ Memory.UnsafeZeroMemory(State);
+ Memory.UnsafeZeroMemory(_rawKey);
+ }
+ }
+} \ No newline at end of file
diff --git a/VNLib.Plugins.Essentials.SocialOauth/Endpoints/Auth0.cs b/VNLib.Plugins.Essentials.SocialOauth/Endpoints/Auth0.cs
new file mode 100644
index 0000000..8518ea0
--- /dev/null
+++ b/VNLib.Plugins.Essentials.SocialOauth/Endpoints/Auth0.cs
@@ -0,0 +1,190 @@
+/*
+* Copyright (c) 2022 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.SocialOauth
+* File: Auth0.cs
+*
+* Auth0.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;
+using System.Linq;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Collections.Generic;
+
+using RestSharp;
+
+using VNLib.Net.Rest.Client;
+using VNLib.Hashing;
+using VNLib.Hashing.IdentityUtility;
+using VNLib.Utils.Logging;
+using VNLib.Plugins.Essentials.Accounts;
+using VNLib.Plugins.Extensions.Loading;
+using VNLib.Plugins.Extensions.Loading.Users;
+
+#nullable enable
+
+namespace VNLib.Plugins.Essentials.SocialOauth.Endpoints
+{
+
+ [ConfigurationName("auth0")]
+ internal class Auth0 : SocialOauthBase
+ {
+ protected override OauthClientConfig Config { get; }
+
+
+ private readonly Task<JsonDocument> RsaCertificate;
+
+ public Auth0(PluginBase plugin, IReadOnlyDictionary<string, JsonElement> config)
+ {
+ //Get id/secret
+ Task<string?> secret = plugin.TryGetSecretAsync("auth0_client_secret");
+ Task<string?> clientId = plugin.TryGetSecretAsync("auth0_client_id");
+
+ //Wait sync
+ Task.WaitAll(secret, clientId);
+
+ Config = new("auth0", config)
+ {
+ //get gh client secret and id
+ ClientID = clientId.Result ?? throw new KeyNotFoundException("Missing Auth0 client id from config or vault"),
+ ClientSecret = secret.Result ?? throw new KeyNotFoundException("Missing Auth0 client secret from config or vault"),
+
+ Passwords = plugin.GetPasswords(),
+ Users = plugin.GetUserManager(),
+ };
+
+ string keyUrl = config["key_url"].GetString() ?? throw new KeyNotFoundException("Missing Auth0 'key_url' from config");
+
+ Uri keyUri = new(keyUrl);
+
+ //Get certificate on background thread
+ RsaCertificate = Task.Run(() => GetRsaCertificate(keyUri));
+
+ InitPathAndLog(Config.EndpointPath, plugin.Log);
+ }
+
+
+ private async Task<JsonDocument> GetRsaCertificate(Uri certUri)
+ {
+ 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
+ using ClientContract client = ClientPool.Lease();
+
+ RestResponse response = await client.Resource.ExecuteAsync(keyRequest);
+
+ response.ThrowIfError();
+
+ return JsonDocument.Parse(response.RawBytes);
+ }
+ catch (Exception e)
+ {
+ Log.Error(e, "Failed to get Auth0 signing keys");
+ throw;
+ }
+ }
+
+ /*
+ * 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(' ')[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);
+ }
+
+
+ private static readonly Task<UserLoginData?> EmptyLoginData = Task.FromResult<UserLoginData?>(null);
+
+ protected override Task<UserLoginData?> GetLoginDataAsync(IOAuthAccessState clientAccess, CancellationToken cancellation)
+ {
+ using JsonWebToken jwt = JsonWebToken.Parse(clientAccess.IdToken);
+
+ //Verify the token against the first signing key
+ if (!jwt.VerifyFromJwk(RsaCertificate.Result.RootElement.GetProperty("keys").EnumerateArray().First()))
+ {
+ return EmptyLoginData;
+ }
+
+ using JsonDocument userData = jwt.GetPayload();
+
+ int iat = userData.RootElement.GetProperty("iat").GetInt32();
+ int exp = userData.RootElement.GetProperty("exp").GetInt32();
+
+ string userId = userData.RootElement.GetProperty("sub").GetString() ?? throw new Exception("Missing sub in jwt");
+ string audience = userData.RootElement.GetProperty("aud").GetString() ?? throw new Exception("Missing aud in jwt");
+ string issuer = userData.RootElement.GetProperty("iss").GetString() ?? throw new Exception("Missing iss in jwt");
+
+ if(exp < DateTimeOffset.UtcNow.ToUnixTimeSeconds())
+ {
+ //Expired
+ return EmptyLoginData;
+ }
+
+ //Verify audience matches client id
+ if (!Config.ClientID.Equals(audience, StringComparison.Ordinal))
+ {
+ //Invalid audience
+ return EmptyLoginData;
+ }
+
+ return Task.FromResult<UserLoginData?>(new UserLoginData()
+ {
+ UserId = GetUserIdFromPlatform(userId)
+ });
+ }
+ }
+}
diff --git a/VNLib.Plugins.Essentials.SocialOauth/Endpoints/DiscordOauth.cs b/VNLib.Plugins.Essentials.SocialOauth/Endpoints/DiscordOauth.cs
new file mode 100644
index 0000000..6ee7683
--- /dev/null
+++ b/VNLib.Plugins.Essentials.SocialOauth/Endpoints/DiscordOauth.cs
@@ -0,0 +1,152 @@
+/*
+* Copyright (c) 2022 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.SocialOauth
+* File: DiscordOauth.cs
+*
+* DiscordOauth.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;
+using System.Text;
+using System.Threading;
+using System.Text.Json;
+using System.Threading.Tasks;
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+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.Plugins.Extensions.Loading.Users;
+
+#nullable enable
+
+namespace VNLib.Plugins.Essentials.SocialOauth.Endpoints
+{
+ [ConfigurationName("discord")]
+ internal sealed class DiscordOauth : SocialOauthBase
+ {
+ protected override OauthClientConfig Config { get; }
+
+ public DiscordOauth(PluginBase plugin, IReadOnlyDictionary<string, JsonElement> config) : base()
+ {
+ //Get id/secret
+ Task<string?> secret = plugin.TryGetSecretAsync("discord_client_secret");
+ Task<string?> clientId = plugin.TryGetSecretAsync("discord_client_id");
+
+ //Wait sync
+ Task.WaitAll(secret, clientId);
+
+ Config = new("discord", config)
+ {
+ //get gh client secret and id
+ ClientID = clientId.Result ?? throw new KeyNotFoundException("Missing Discord client id from config or vault"),
+ ClientSecret = secret.Result ?? throw new KeyNotFoundException("Missing the Discord client secret from config or vault"),
+
+ Passwords = plugin.GetPasswords(),
+ Users = plugin.GetUserManager(),
+ };
+
+ InitPathAndLog(Config.EndpointPath, plugin.Log);
+ }
+
+ private static string GetUserIdFromPlatform(string userName)
+ {
+ return ManagedHash.ComputeHash($"discord|{userName}", HashAlg.SHA1, HashEncodingMode.Hexadecimal);
+ }
+
+
+ /*
+ * 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; }
+ }
+
+
+ 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)
+ {
+ 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)
+ {
+ return null;
+ }
+ return new()
+ {
+ EmailAddress = discordProfile.EmailAddress,
+ First = discordProfile.Username,
+ };
+ }
+
+ 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)
+ {
+ 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)
+ };
+ }
+ }
+} \ No newline at end of file
diff --git a/VNLib.Plugins.Essentials.SocialOauth/Endpoints/GitHubOauth.cs b/VNLib.Plugins.Essentials.SocialOauth/Endpoints/GitHubOauth.cs
new file mode 100644
index 0000000..78b4b49
--- /dev/null
+++ b/VNLib.Plugins.Essentials.SocialOauth/Endpoints/GitHubOauth.cs
@@ -0,0 +1,214 @@
+/*
+* Copyright (c) 2022 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.SocialOauth
+* File: GitHubOauth.cs
+*
+* GitHubOauth.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;
+using System.Text;
+using System.Threading;
+using System.Text.Json;
+using System.Threading.Tasks;
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+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.Plugins.Extensions.Loading.Users;
+
+#nullable enable
+
+namespace VNLib.Plugins.Essentials.SocialOauth.Endpoints
+{
+ [ConfigurationName("github")]
+ internal sealed partial class GitHubOauth : SocialOauthBase
+ {
+ private const string GITHUB_V3_ACCEPT = "application/vnd.github.v3+json";
+
+ private readonly string UserEmailUrl;
+
+ protected override OauthClientConfig Config { get; }
+
+ public GitHubOauth(PluginBase plugin, IReadOnlyDictionary<string, JsonElement> config) : base()
+ {
+ //Get id/secret
+ Task<string?> secret = plugin.TryGetSecretAsync("github_client_secret");
+ Task<string?> clientId = plugin.TryGetSecretAsync("github_client_id");
+
+ //Wait sync
+ Task.WaitAll(secret, clientId);
+
+ Config = new(configName: "github", config)
+ {
+ //get gh client secret and id
+ ClientID = clientId.Result ?? throw new KeyNotFoundException("Missing Github client id from config or vault"),
+ ClientSecret = secret.Result ?? throw new KeyNotFoundException("Missing Github client secret from config or vault"),
+
+ Passwords = plugin.GetPasswords(),
+ Users = plugin.GetUserManager(),
+ };
+
+
+ UserEmailUrl = config["user_email_url"].GetString() ?? throw new KeyNotFoundException("Missing required key 'user_email_url' for github configuration");
+
+ InitPathAndLog(Config.EndpointPath, plugin.Log);
+ }
+
+ protected override void StaticClientPoolInitializer(RestClient client)
+ {
+ client.UseSerializer<RestSharp.Serializers.Json.SystemTextJsonSerializer>();
+ //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; }
+ }
+ /*
+ * 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; }
+ }
+
+ 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();
+
+ //Exec the get for the profile
+ RestResponse<GithubProfile> profResponse = await client.Resource.ExecuteAsync<GithubProfile>(request, cancellationToken);
+
+ if (!profResponse.IsSuccessful || profResponse.Data == null || profResponse.Data.ID < 100)
+ {
+ Log.Debug("Client attempted a github login but GH responded with status code {code}", profResponse.StatusCode);
+ return null;
+ }
+
+ //Return login data
+ return new()
+ {
+ //User-id is just the SHA 1
+ UserId = GetUserIdFromPlatform(profResponse.Data.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 user's emails
+ RestResponse<EmailContainer[]> getEmailResponse = await client.Resource.ExecuteAsync<EmailContainer[]>(request, cancellationToken: cancellationToken);
+ //Check status
+ if (getEmailResponse.IsSuccessful && getEmailResponse.Data != null)
+ {
+ //Filter emails addresses
+ foreach (EmailContainer email in getEmailResponse.Data)
+ {
+ //Capture the first primary email address and make sure its verified
+ if (email.Primary && email.Verified)
+ {
+ accountData ??= new();
+ //store email on current profile
+ accountData.EmailAddress = email.Email;
+ goto Continue;
+ }
+ }
+ //No primary email found
+ return null;
+ }
+ else
+ {
+ Log.Debug("Client attempted a github login but GH responded with status code {code}", getEmailResponse.StatusCode);
+ 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)
+ {
+ Log.Debug("Client attempted a github login but GH responded with status code {code}", profResponse.StatusCode);
+ return null;
+ }
+
+ //Get the user's name from gh profile
+ string[] names = profResponse.Data.FullName!.Split(" ", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ //setup the user's profile data
+ accountData.First = names.Length > 0 ? names[0] : string.Empty;
+ accountData.Last = names.Length > 1 ? names[1] : string.Empty;
+ return accountData;
+ }
+
+
+ }
+} \ No newline at end of file
diff --git a/VNLib.Plugins.Essentials.SocialOauth/IOAuthAccessState.cs b/VNLib.Plugins.Essentials.SocialOauth/IOAuthAccessState.cs
new file mode 100644
index 0000000..888cc02
--- /dev/null
+++ b/VNLib.Plugins.Essentials.SocialOauth/IOAuthAccessState.cs
@@ -0,0 +1,57 @@
+/*
+* Copyright (c) 2022 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.SocialOauth
+* File: IOAuthAccessState.cs
+*
+* IOAuthAccessState.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/.
+*/
+
+#nullable enable
+
+namespace VNLib.Plugins.Essentials.SocialOauth
+{
+ /// <summary>
+ /// An object that represents an OAuth2 access token in its
+ /// standard form.
+ /// </summary>
+ public interface IOAuthAccessState
+ {
+ /// <summary>
+ /// The OAuth2 access token
+ /// </summary>
+ public string? Token { get; set; }
+ /// <summary>
+ /// Token grant scope
+ /// </summary>
+ string? Scope { get; set; }
+ /// <summary>
+ /// The OAuth2 token type, usually 'Bearer'
+ /// </summary>
+ string? Type { get; set; }
+ /// <summary>
+ /// Optional refresh token
+ /// </summary>
+ string? RefreshToken { get; set; }
+
+ /// <summary>
+ /// Optional ID OIDC token
+ /// </summary>
+ string? IdToken { get; set; }
+ }
+} \ No newline at end of file
diff --git a/VNLib.Plugins.Essentials.SocialOauth/OauthClientConfig.cs b/VNLib.Plugins.Essentials.SocialOauth/OauthClientConfig.cs
new file mode 100644
index 0000000..6e30802
--- /dev/null
+++ b/VNLib.Plugins.Essentials.SocialOauth/OauthClientConfig.cs
@@ -0,0 +1,130 @@
+/*
+* Copyright (c) 2022 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.SocialOauth
+* File: OauthClientConfig.cs
+*
+* OauthClientConfig.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;
+using System.Text.Json;
+using System.Collections.Generic;
+
+using VNLib.Utils.Extensions;
+using VNLib.Plugins.Essentials.Users;
+using VNLib.Plugins.Essentials.Accounts;
+using VNLib.Net.Rest.Client;
+
+#nullable enable
+
+namespace VNLib.Plugins.Essentials.SocialOauth
+{
+
+ public sealed class OauthClientConfig
+ {
+
+ public OauthClientConfig(string configName, IReadOnlyDictionary<string, JsonElement> config)
+ {
+ EndpointPath = config["path"].GetString() ?? throw new KeyNotFoundException($"Missing required key 'path' in config {configName}");
+
+ //Set discord account origin
+ AccountOrigin = config["account_origin"].GetString() ?? throw new KeyNotFoundException($"Missing required key 'account_origin' in config {configName}");
+
+ //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}");
+ //Create the uris
+ AccessCodeUrl = new(authUrl);
+ AccessTokenUrl = new(tokenUrl);
+ UserDataUrl = new(userUrl);
+
+ 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();
+ }
+
+
+ public RestClientPool ClientPool { get; }
+
+ public string ClientID { get; init; }
+
+ public string ClientSecret { get; init; }
+
+
+ /// <summary>
+ /// The user-account origin value. Specifies that the user account
+ /// was created outside of the local account system
+ /// </summary>
+ public string AccountOrigin { get; }
+
+ /// <summary>
+ /// The URL to redirect the user to the OAuth2 service
+ /// to begin the authentication process
+ /// </summary>
+ public Uri AccessCodeUrl { get; }
+
+ /// <summary>
+ /// The remote endoint to exchange codes for access tokens
+ /// </summary>
+ public Uri AccessTokenUrl { get; }
+
+ /// <summary>
+ /// The endpoint to get user-data object from
+ /// </summary>
+ public Uri UserDataUrl { get; }
+
+ public TimeSpan LoginNonceLifetime { get; }
+ /// <summary>
+ /// The user store to create/get users from
+ /// </summary>
+ public IUserManager Users { get; init; }
+
+ public PasswordHashing Passwords { get; init; }
+
+ /// <summary>
+ /// The endpoint route/path
+ /// </summary>
+ public string EndpointPath { get; }
+
+ /// <summary>
+ /// The size (in bytes) of the random generated nonce
+ /// </summary>
+ public uint NonceByteSize { get; }
+
+ /// <summary>
+ /// A value that specifies if locally created accounts are allowed
+ /// to be logged in from an OAuth2 source
+ /// </summary>
+ public bool AllowForLocalAccounts { get; }
+
+ /// <summary>
+ /// A value that indicates if accounts that do not exist will be created
+ /// and logged in immediatly, on successfull OAuth2 flow
+ /// </summary>
+ public bool AllowRegistration { get; }
+
+ /// <summary>
+ /// The size (in bytes) of the random password generated for new users
+ /// </summary>
+ public int RandomPasswordSize { get; }
+ }
+}
diff --git a/VNLib.Plugins.Essentials.SocialOauth/README.md b/VNLib.Plugins.Essentials.SocialOauth/README.md
new file mode 100644
index 0000000..7a54c20
--- /dev/null
+++ b/VNLib.Plugins.Essentials.SocialOauth/README.md
@@ -0,0 +1,45 @@
+# VNLib.Plugins.Essentials.SocialOauth
+
+A basic external OAuth2 authentication plugin.
+
+## Plugin Mode
+
+This library exports an IPlugin type that may be loaded directly by a host application, or
+imported to provide base classes for creating OAuth2 authentication endpoints.
+
+By default, exports 2 endpoints for Github and Discord authentication. Configuration
+variables for either endpoint may be omitted or included to export endpoints.
+
+## Library Mode
+
+Exports SocialOAuthBase to provide a base class for creating OAuth2 authentication
+endpoints, that is compatible with the VNLib web client library authentication flow
+
+
+## Authentication Flow
+
+The authentication flow works similar to the local account mechanism with an extra step that helps
+guard against replay, and MITM attacks. When an request claim is made (request to login) from client
+side code (via put request), a browser id is request (for login flow) along with the clients encryption
+public key (same key as Essentials.Accounts requires). The public key is used to encrypted a derived
+redirect url, which includes a "secret" state token (OAuth2 standard state) that only the private-key
+holder should be able to recover. When decrypted, should be used to redirect the client's browser to
+the remote authentication server. Assuming the request is granted, the browser is redirected to the
+originating endpoint, and the nonce is used to recover the initial claim and the flow continues. The
+request should also include the required OAuth2 'code' parameter used to exchange for an access token.
+If the access token is granted, a nonce is generated, passed to the browser via a redirect query parameter
+which the browser code will use in a POST request to the endpoint to continue the flow. The nonce is
+used to recover the access token and original claim data (public key, browser id, etc), which is used
+to recover a user account, or optionally create a new account. Once complete, the user account is used
+to upgrade the session and grant authorization to the client. The public key (and browser id) is used
+from the initial claim to authorize the session, which should guard against MITM, replay, and forgery
+attacks. However this only works if we assume the clients private key has not been stolen, which is a
+much larger issue and should be addressed separately.
+
+## Diagram
+
+PUT -> { public_key, browser_id } -> server -> { result: "base64 encrypted redirect url"} ->
+ OAuth2Server -> redirect -> "?code=some_code&state=decrypted_state_token"
+
+GET -> "?code=some_code&state=decrypted_state_token" -> server -> "?result=authorized&nonce=some_nonce"
+POST -> { nonce:"some_nonce" } -> server -> [authorization complete message] \ No newline at end of file
diff --git a/VNLib.Plugins.Essentials.SocialOauth/SocialEntryPoint.cs b/VNLib.Plugins.Essentials.SocialOauth/SocialEntryPoint.cs
new file mode 100644
index 0000000..d0f7a84
--- /dev/null
+++ b/VNLib.Plugins.Essentials.SocialOauth/SocialEntryPoint.cs
@@ -0,0 +1,82 @@
+/*
+* Copyright (c) 2022 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.SocialOauth
+* File: SocialEntryPoint.cs
+*
+* SocialEntryPoint.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;
+using System.Collections.Generic;
+
+using VNLib.Utils.Logging;
+using VNLib.Plugins.Essentials.SocialOauth.Endpoints;
+using VNLib.Plugins.Extensions.Loading;
+using VNLib.Plugins.Extensions.Loading.Routing;
+
+namespace VNLib.Plugins.Essentials.SocialOauth
+{
+ public sealed class SocialEntryPoint : PluginBase
+ {
+
+ public override string PluginName => "Essentials.SocialOauth";
+
+ protected override void OnLoad()
+ {
+ try
+ {
+ //Get the discord oauth config from the config file
+ if (this.HasConfigForType<DiscordOauth>())
+ {
+ //Add the discord login endpoint
+ this.Route<DiscordOauth>();
+ Log.Information("Discord social OAuth authentication loaded");
+ }
+ if (this.HasConfigForType<GitHubOauth>())
+ {
+ //Add the github login endpoint
+ this.Route<GitHubOauth>();
+ Log.Information("Github social OAuth authentication loaded");
+ }
+
+ if (this.HasConfigForType<Auth0>())
+ {
+ //Add the auth0 login endpoint
+ this.Route<Auth0>();
+ Log.Information("Auth0 social OAuth authentication loaded");
+ }
+ }
+ catch(KeyNotFoundException kne)
+ {
+ Log.Error("Missing required configuration variables, {reason}", kne.Message);
+ }
+ }
+
+
+ protected override void OnUnLoad()
+ {
+ Log.Information("Plugin unloaded");
+ }
+
+ protected override void ProcessHostCommand(string cmd)
+ {
+ throw new NotImplementedException();
+ }
+ }
+} \ No newline at end of file
diff --git a/VNLib.Plugins.Essentials.SocialOauth/SocialOauthBase.cs b/VNLib.Plugins.Essentials.SocialOauth/SocialOauthBase.cs
new file mode 100644
index 0000000..72d0d1b
--- /dev/null
+++ b/VNLib.Plugins.Essentials.SocialOauth/SocialOauthBase.cs
@@ -0,0 +1,619 @@
+/*
+* Copyright (c) 2022 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.SocialOauth
+* File: SocialOauthBase.cs
+*
+* SocialOauthBase.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;
+using System.Net;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Collections.Generic;
+using System.Security.Cryptography;
+using System.Text.Json.Serialization;
+using System.Runtime.InteropServices;
+
+using FluentValidation;
+
+using RestSharp;
+using VNLib.Net.Http;
+using VNLib.Net.Rest.Client;
+using VNLib.Hashing;
+using VNLib.Utils;
+using VNLib.Utils.Memory;
+using VNLib.Utils.Logging;
+using VNLib.Utils.Extensions;
+using VNLib.Utils.Memory.Caching;
+using VNLib.Plugins.Essentials.Users;
+using VNLib.Plugins.Essentials.Accounts;
+using VNLib.Plugins.Essentials.Endpoints;
+using VNLib.Plugins.Essentials.Extensions;
+using VNLib.Plugins.Extensions.Validation;
+using VNLib.Plugins.Essentials.SocialOauth.Validators;
+
+#nullable enable
+
+namespace VNLib.Plugins.Essentials.SocialOauth
+{
+
+ /// <summary>
+ /// Provides a base class for derriving commong OAuth2 implicit authentication
+ /// </summary>
+ public abstract class SocialOauthBase : UnprotectedWebEndpoint
+ {
+ const string AUTH_ERROR_MESSAGE = "You have no pending authentication requests.";
+
+ const string AUTH_GRANT_SESSION_NAME = "auth";
+
+ /// <summary>
+ /// The client configuration struct passed during base class construction
+ /// </summary>
+ protected abstract OauthClientConfig Config { get; }
+
+ ///<inheritdoc/>
+ protected override ProtectionSettings EndpointProtectionSettings { get; } = new()
+ {
+ /*
+ * Disable cross site checking because the OAuth2 flow requires
+ * cross site when redirecting the client back
+ */
+ CrossSiteDenied = false
+ };
+
+ /// <summary>
+ /// The resst client connection pool
+ /// </summary>
+ protected RestClientPool ClientPool { get; }
+
+ private readonly Dictionary<string, LoginClaim> ClaimStore;
+ private readonly Dictionary<string, OAuthAccessState> AuthorizationStore;
+ private readonly IValidator<LoginClaim> ClaimValidator;
+ private readonly IValidator<string> NonceValidator;
+ private readonly IValidator<AccountData> AccountDataValidator;
+
+ public SocialOauthBase()
+ {
+ ClaimStore = new(StringComparer.OrdinalIgnoreCase);
+ AuthorizationStore = new(StringComparer.OrdinalIgnoreCase);
+ 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);
+ }
+
+ private static IValidator<LoginClaim> GetClaimValidator()
+ {
+ InlineValidator<LoginClaim> val = new();
+ val.RuleFor(static s => s.ClientId)
+ .Length(10, 100)
+ .WithMessage("Request is not valid");
+
+ val.RuleFor(static s => s.PublicKey)
+ .Length(50, 1024)
+ .WithMessage("Request is not valid");
+
+ return val;
+ }
+ private static IValidator<string> GetNonceValidator()
+ {
+ InlineValidator<string> val = new();
+ val.RuleFor(static s => s)
+ .Length(10, 200)
+ //Nonces are base32, so only alpha num
+ .AlphaNumeric();
+ return val;
+ }
+
+ protected override ERRNO PreProccess(HttpEntity entity)
+ {
+ if (!base.PreProccess(entity))
+ {
+ return false;
+ }
+ /*
+ * Cross site checking is disabled because we need to allow cross site
+ * for OAuth2 redirect flows
+ */
+ if (entity.Server.Method != HttpMethod.GET && entity.Server.IsCrossSite())
+ {
+ return false;
+ }
+ //Make sure the user is not logged in
+ if(entity.LoginCookieMatches() || entity.TokenMatches())
+ {
+ return false;
+ }
+ return true;
+ }
+
+ /// <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));
+ client.UseSerializer<RestSharp.Serializers.Json.SystemTextJsonSerializer>();
+ }
+
+ 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>
+ /// <param name="ev"></param>
+ /// <param name="code">The raw code from the remote OAuth2 granting server</param>
+ /// <param name="cancellationToken">A token to cancel the operation</param>
+ /// <returns>
+ /// A task the resolves the <see cref="OAuthAccessState"/> that includes all relavent
+ /// authorization data. Result may be null if authorzation is invalid or not granted
+ /// </returns>
+ /// <param name="cancellationToken"></param>
+ protected 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, ParameterType.GetOrPost);
+ request.AddParameter("client_secret", Config.ClientSecret, 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();
+ //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;
+ }
+
+ /// <summary>
+ /// Gets an object that represents the user's account data from the OAuth provider when
+ /// creating a new user for the current platform
+ /// </summary>
+ /// <param name="clientAccess">The access state from the code/token exchange</param>
+ /// <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>
+ /// <param name="cancellationToken"></param>
+ 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>
+ /// <param name="clientAccess">The authorization information granted from the OAuth2 authorization server</param>
+ /// <param name="cancellation">A token to cancel the operation</param>
+ /// <returns></returns>
+ protected abstract Task<UserLoginData?> GetLoginDataAsync(IOAuthAccessState clientAccess, CancellationToken cancellation);
+
+ class LoginClaim : ICacheable, INonce
+ {
+ [JsonPropertyName("public_key")]
+ public string? PublicKey { get; set; }
+ [JsonPropertyName("browser_id")]
+ public string? ClientId { get; set; }
+
+ /// <summary>
+ /// The raw OAuth flow state parameter the client must decrypt before
+ /// navigating to remote authentication source
+ /// </summary>
+ [JsonIgnore]
+ public ReadOnlyMemory<byte> RawNonce { get; private set; }
+ [JsonIgnore]
+ DateTime ICacheable.Expires { get; set; }
+ bool IEquatable<ICacheable>.Equals(ICacheable? other) => Equals(other);
+ void ICacheable.Evicted()
+ {
+ //Erase nonce
+ Memory.UnsafeZeroMemory(RawNonce);
+ }
+
+ public override bool Equals(object? obj)
+ {
+ return obj is LoginClaim otherClaim && this.PublicKey!.Equals(otherClaim.PublicKey, StringComparison.Ordinal);
+ }
+ public override int GetHashCode() => PublicKey!.GetHashCode();
+
+ void INonce.ComputeNonce(Span<byte> buffer)
+ {
+ RandomHash.GetRandomBytes(buffer);
+ //Store copy
+ RawNonce = buffer.ToArray();
+ }
+
+ bool INonce.VerifyNonce(ReadOnlySpan<byte> nonceBytes)
+ {
+ return CryptographicOperations.FixedTimeEquals(RawNonce.Span, nonceBytes);
+ }
+ }
+
+ /*
+ * 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
+ * and include a state variable that the client decrypted from an initial claim
+ * to prove its identity
+ */
+
+ protected override async ValueTask<VfReturnType> GetAsync(HttpEntity entity)
+ {
+ //Make sure state and code parameters are available
+ if (entity.QueryArgs.TryGetNonEmptyValue("state", out string? state) && entity.QueryArgs.TryGetNonEmptyValue("code", out string? code))
+ {
+ //Disable refer headers when nonce is set
+ entity.Server.Headers["Referrer-Policy"] = "no-referrer";
+
+ //Check for security navigation headers. This should be a browser redirect,
+ if (!entity.Server.IsNavigation() || !entity.Server.IsUserInvoked())
+ {
+ //The connection was not a browser redirect
+ entity.Redirect(RedirectType.Temporary, $"{Path}?result=bad_sec");
+ return VfReturnType.VirtualSkip;
+ }
+ //Try to get the claim from the state parameter
+ if (ClaimStore.TryGetOrEvictRecord(state, out LoginClaim claim) < 1)
+ {
+ entity.Redirect(RedirectType.Temporary, $"{Path}?result=expired");
+ return VfReturnType.VirtualSkip;
+ }
+ //Lock on the claim to prevent replay
+ lock (claim)
+ {
+ bool isValid = claim.VerifyNonce(state);
+ //Evict the record inside the lock, also wipes nonce contents
+ ClaimStore.EvictRecord(state);
+
+ //Compare binary values of nonce incase of dicionary collision
+ if (!isValid)
+ {
+ entity.Redirect(RedirectType.Temporary, $"{Path}?result=invalid");
+ return VfReturnType.VirtualSkip;
+ }
+ }
+ //Exchange the OAuth code for a token (application specific)
+ OAuthAccessState? token = await ExchangeCodeForTokenAsync(entity, code, entity.EventCancellation);
+ //Token may be null
+ if(token == null)
+ {
+ entity.Redirect(RedirectType.Temporary, $"{Path}?result=invalid");
+ return VfReturnType.VirtualSkip;
+ }
+ //Store claim info
+ token.PublicKey = claim.PublicKey;
+ token.ClientId = claim.ClientId;
+ //Generate the new nonce
+ string nonce = token.ComputeNonce((int)Config.NonceByteSize);
+ //Collect expired records
+ AuthorizationStore.CollectRecords();
+ //Register the access token
+ AuthorizationStore.StoreRecord(nonce, token, Config.LoginNonceLifetime);
+ //Prepare redirect
+ entity.Redirect(RedirectType.Temporary, $"{Path}?result=authorized&nonce={nonce}");
+ return VfReturnType.VirtualSkip;
+ }
+ //Check to see if there was an error code set
+ if (entity.QueryArgs.TryGetNonEmptyValue("error", out string? errorCode))
+ {
+ Log.Debug("{Type} error {err}:{des}", Config.AccountOrigin, errorCode, entity.QueryArgs["error_description"]);
+ entity.Redirect(RedirectType.Temporary, $"{Path}?result=error");
+ return VfReturnType.VirtualSkip;
+ }
+ return VfReturnType.ProcessAsFile;
+ }
+
+ /*
+ * Post messages finalize a login from a nonce
+ */
+
+ protected override async ValueTask<VfReturnType> PostAsync(HttpEntity entity)
+ {
+ ValErrWebMessage webm = new();
+ //Get the finalization message
+ using JsonDocument? request = await entity.GetJsonFromFileAsync();
+ if (webm.Assert(request != null, "Request message is required"))
+ {
+ entity.CloseResponseJson(HttpStatusCode.BadRequest, webm);
+ return VfReturnType.VirtualSkip;
+ }
+ //Recover the nonce
+ string? base32Nonce = request.RootElement.GetPropString("nonce");
+ if(webm.Assert(base32Nonce != null, message: "Nonce parameter is required"))
+ {
+ entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm);
+ return VfReturnType.VirtualSkip;
+ }
+ //Validate nonce
+ if (!NonceValidator.Validate(base32Nonce, webm))
+ {
+ entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm);
+ return VfReturnType.VirtualSkip;
+ }
+ //Recover the access token
+ if (AuthorizationStore.TryGetOrEvictRecord(base32Nonce!, out OAuthAccessState token) < 1)
+ {
+ webm.Result = AUTH_ERROR_MESSAGE;
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+ bool valid;
+ //Valid token, now verify the nonce within the locked context
+ lock (token)
+ {
+ valid = token.VerifyNonce(base32Nonce);
+ //Evict (wipes nonce)
+ AuthorizationStore.EvictRecord(base32Nonce!);
+ }
+ if (webm.Assert(valid, AUTH_ERROR_MESSAGE))
+ {
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //get the user's login information (ie userid)
+ UserLoginData? userLogin = await GetLoginDataAsync(token, entity.EventCancellation);
+
+ if(webm.Assert(userLogin?.UserId != null, AUTH_ERROR_MESSAGE))
+ {
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Fetch the user from the database
+ IUser? user = await Config.Users.GetUserFromIDAsync(userLogin.UserId, entity.EventCancellation);
+
+ if(user == null)
+ {
+ //Get the clients personal info to being login process
+ AccountData? userAccount = await GetAccountDataAsync(token, entity.EventCancellation);
+
+ if (webm.Assert(userAccount != null, AUTH_ERROR_MESSAGE))
+ {
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Validate the account data
+ if (webm.Assert(AccountDataValidator.Validate(userAccount).IsValid, AUTH_ERROR_MESSAGE))
+ {
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //make sure registration is enabled
+ if (webm.Assert(Config.AllowRegistration, AUTH_ERROR_MESSAGE))
+ {
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+ //Create new user, create random passwords
+ byte[] randomPass = RandomHash.GetRandomBytes(Config.RandomPasswordSize);
+ //Generate a new random passowrd incase the user wants to use a local account to log in sometime in the future
+ PrivateString passhash = Config.Passwords.Hash(randomPass);
+ //overwite the password bytes
+ Memory.InitializeBlock(randomPass.AsSpan());
+ 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, AccountManager.MINIMUM_LEVEL, passhash, entity.EventCancellation);
+ //Set active status
+ user.Status = UserStatus.Active;
+ //Store the new profile
+ user.SetProfile(userAccount);
+ //Set the account creation origin
+ user.SetAccountOrigin(Config.AccountOrigin);
+ }
+ catch(UserCreationFailedException)
+ {
+ Log.Warn("Failed to create new user from new OAuth2 login, because a creation exception occured");
+ webm.Result = "Please try again later";
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+ finally
+ {
+ passhash.Dispose();
+ }
+ }
+ else
+ {
+ //Check for local only
+ if (webm.Assert(!user.LocalOnly, AUTH_ERROR_MESSAGE))
+ {
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+ //Make sure local accounts are allowed
+ if (webm.Assert(!user.IsLocalAccount() || Config.AllowForLocalAccounts, AUTH_ERROR_MESSAGE))
+ {
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+ //Reactivate inactive accounts
+ if(user.Status == UserStatus.Inactive)
+ {
+ user.Status = UserStatus.Active;
+ }
+
+ //Make sure the account is active
+ if(webm.Assert(user.Status == UserStatus.Active, AUTH_ERROR_MESSAGE))
+ {
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+ }
+ //Finalze login
+ try
+ {
+ //Generate authoization
+ webm.Token = entity.GenerateAuthorization(token.PublicKey!, token.ClientId!, user);
+ //Store the user current oauth information in the current session for others to digest
+ entity.Session.SetObject($"{Config.AccountOrigin}.{AUTH_GRANT_SESSION_NAME}", token);
+ //Send the username back to the client
+ webm.Result = new AccountData()
+ {
+ EmailAddress = user.EmailAddress,
+ };
+ //Set the success flag
+ webm.Success = true;
+ //Write to log
+ Log.Debug("Successful login for user {uid}... from {ip}", user.UserID[..8], entity.TrustedRemoteIp);
+ //release the user
+ await user.ReleaseAsync();
+ }
+ catch (CryptographicException ce)
+ {
+ Log.Debug("Failed to generate authorization for {user}, error {err}", user.UserID, ce.Message);
+ webm.Result = AUTH_ERROR_MESSAGE;
+ }
+ catch (OutOfMemoryException)
+ {
+ Log.Debug("Out of buffer space for token data encryption, for user {usr}, from ip {ip}", user.UserID, entity.TrustedRemoteIp);
+ webm.Result = AUTH_ERROR_MESSAGE;
+ }
+ catch(UserUpdateException uue)
+ {
+ webm.Token = null;
+ webm.Result = AUTH_ERROR_MESSAGE;
+ webm.Success = false;
+ entity.InvalidateLogin();
+ Log.Error(uue);
+ }
+ finally
+ {
+ user.Dispose();
+ }
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ /*
+ * Claims are considered indempodent because they require no previous state
+ * and will return a new secret authentication "token" (url + nonce) that
+ * uniquely identifies the claim and authorization upgrade later
+ */
+
+ protected override async ValueTask<VfReturnType> PutAsync(HttpEntity entity)
+ {
+ ValErrWebMessage webm = new();
+
+ //Get the login message
+ LoginClaim? claim = await entity.GetJsonFromFileAsync<LoginClaim>();
+
+ if (webm.Assert(claim != null, "Emtpy message body"))
+ {
+ entity.CloseResponseJson(HttpStatusCode.BadRequest, webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Validate the message
+ if (!ClaimValidator.Validate(claim, webm))
+ {
+ entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Cleanup old records
+ ClaimStore.CollectRecords();
+ //Set nonce
+ string base32Nonce = claim.ComputeNonce((int)Config.NonceByteSize);
+ //build the redirect url
+ webm.Result = BuildUrl(base32Nonce, claim.PublicKey!, entity.IsSecure ? "https" : "http", entity.Server.RequestUri.Authority, entity.Server.Encoding);
+ //Store the claim
+ ClaimStore.StoreRecord(base32Nonce, claim, Config.LoginNonceLifetime);
+ webm.Success = true;
+ //Response
+ 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
+ */
+
+ private string BuildUrl(string base32Nonce, string pubKey, ReadOnlySpan<char> scheme, ReadOnlySpan<char> redirectAuthority, Encoding enc)
+ {
+ //Char buffer for base32 and url building
+ using UnsafeMemoryHandle<byte> buffer = Memory.UnsafeAlloc<byte>(8192, true);
+ //get bin buffer slice
+ Span<byte> binBuffer = buffer.Span[1024..];
+
+ ReadOnlySpan<char> url;
+ {
+ //Get char buffer slice and cast to char
+ Span<char> charBuf = MemoryMarshal.Cast<byte, char>(buffer.Span[..1024]);
+ //buffer writer for easier syntax
+ ForwardOnlyWriter<char> writer = new(charBuf);
+ //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(redirectAuthority);
+ writer.Append(Path);
+ //url encode the redirect path and save it for later
+ string redirectFiltered = Uri.EscapeDataString(writer.ToString());
+ //reset the writer again to begin building the path
+ writer.Reset();
+ //Append the config redirect path
+ writer.Append(Config.AccessCodeUrl.OriginalString);
+ //begin query arguments
+ writer.Append("&client_id=");
+ writer.Append(Config.ClientID);
+ //add the redirect url
+ writer.Append("&redirect_uri=");
+ writer.Append(redirectFiltered);
+ //Append the state parameter
+ writer.Append("&state=");
+ writer.Append(base32Nonce);
+ url = writer.AsSpan();
+ }
+ //Separate buffers
+ Span<byte> encryptionBuffer = binBuffer[1024..];
+ Span<byte> encodingBuffer = binBuffer[..1024];
+ //Encode the url to binary
+ int byteCount = enc.GetBytes(url, encodingBuffer);
+ //Encrypt the binary
+ ERRNO count = AccountManager.TryEncryptClientData(pubKey, encodingBuffer[..byteCount], in encryptionBuffer);
+ //base64 encode the encrypted
+ return Convert.ToBase64String(encryptionBuffer[0..(int)count]);
+ }
+ }
+}
diff --git a/VNLib.Plugins.Essentials.SocialOauth/UserLoginData.cs b/VNLib.Plugins.Essentials.SocialOauth/UserLoginData.cs
new file mode 100644
index 0000000..cb2406c
--- /dev/null
+++ b/VNLib.Plugins.Essentials.SocialOauth/UserLoginData.cs
@@ -0,0 +1,36 @@
+/*
+* Copyright (c) 2022 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.SocialOauth
+* File: UserLoginData.cs
+*
+* UserLoginData.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.Text.Json.Serialization;
+
+#nullable enable
+
+namespace VNLib.Plugins.Essentials.SocialOauth
+{
+ public class UserLoginData
+ {
+ [JsonPropertyName("user_id")]
+ public string? UserId { get; set; }
+ }
+}
diff --git a/VNLib.Plugins.Essentials.SocialOauth/VNLib.Plugins.Essentials.SocialOauth.csproj b/VNLib.Plugins.Essentials.SocialOauth/VNLib.Plugins.Essentials.SocialOauth.csproj
new file mode 100644
index 0000000..0781d78
--- /dev/null
+++ b/VNLib.Plugins.Essentials.SocialOauth/VNLib.Plugins.Essentials.SocialOauth.csproj
@@ -0,0 +1,50 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>net6.0</TargetFramework>
+ <Authors>Vaughn Nugent</Authors>
+ <Product>SocialOauth</Product>
+ <Version>1.0.1.5</Version>
+ <Copyright>Copyright © 2022 Vaughn Nugent</Copyright>
+ <PackageProjectUrl>https://www.vaughnnugent.com/resources</PackageProjectUrl>
+ <AssemblyName>SocialOauth</AssemblyName>
+ <Platforms>AnyCPU;x64</Platforms>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="ErrorProne.NET.CoreAnalyzers" Version="0.1.2">
+ <PrivateAssets>all</PrivateAssets>
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ </PackageReference>
+ <PackageReference Include="ErrorProne.NET.Structs" Version="0.1.2">
+ <PrivateAssets>all</PrivateAssets>
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ </PackageReference>
+ <PackageReference Include="FluentValidation" Version="11.3.0" />
+ <PackageReference Include="RestSharp" Version="108.0.2" />
+ </ItemGroup>
+
+ <!-- Resolve nuget dll files and store them in the output dir -->
+ <PropertyGroup>
+ <!--Enable dynamic loading-->
+ <EnableDynamicLoading>true</EnableDynamicLoading>
+ </PropertyGroup>
+ <ItemGroup>
+ <ProjectReference Include="..\..\..\VNLib\Essentials\VNLib.Plugins.Essentials.csproj" />
+ <ProjectReference Include="..\..\Extensions\VNLib.Plugins.Extensions.Loading\VNLib.Plugins.Extensions.Loading.csproj" />
+ <ProjectReference Include="..\..\Extensions\VNLib.Plugins.Extensions.Validation\VNLib.Plugins.Extensions.Validation.csproj" />
+ <ProjectReference Include="..\..\PluginBase\VNLib.Plugins.PluginBase.csproj" />
+ <ProjectReference Include="..\..\VNLib.Net.Rest.Client\VNLib.Net.Rest.Client.csproj" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <None Update="SocialOauth.json">
+ <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+ </None>
+ </ItemGroup>
+
+ <Target Name="PostBuild" AfterTargets="PostBuildEvent">
+ <Exec Command="start xcopy &quot;$(TargetDir)&quot; &quot;F:\Programming\Web Plugins\DevPlugins\$(TargetName)&quot; /E /Y /R" />
+ </Target>
+
+</Project>
diff --git a/VNLib.Plugins.Essentials.SocialOauth/Validators/AccountDataValidator.cs b/VNLib.Plugins.Essentials.SocialOauth/Validators/AccountDataValidator.cs
new file mode 100644
index 0000000..7ebb37e
--- /dev/null
+++ b/VNLib.Plugins.Essentials.SocialOauth/Validators/AccountDataValidator.cs
@@ -0,0 +1,74 @@
+/*
+* Copyright (c) 2022 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.SocialOauth
+* File: AccountDataValidator.cs
+*
+* AccountDataValidator.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 FluentValidation;
+
+using VNLib.Plugins.Essentials.Accounts;
+using VNLib.Plugins.Extensions.Validation;
+
+#nullable enable
+
+namespace VNLib.Plugins.Essentials.SocialOauth.Validators
+{
+ internal class AccountDataValidator : AbstractValidator<AccountData>
+ {
+ public AccountDataValidator() : base()
+ {
+ RuleFor(t => t.EmailAddress)
+ .NotEmpty()
+ .WithMessage("Your account does not have an email address assigned to it");
+
+ RuleFor(t => t.EmailAddress)
+ .EmailAddress()
+ .WithMessage("Your account does not have a valid email address assigned to it");
+
+ //Validate city
+ RuleFor(t => t.City).MaximumLength(50);
+ RuleFor(t => t.City).AlphaOnly();
+
+ RuleFor(t => t.Company).MaximumLength(50);
+ RuleFor(t => t.Company).SpecialCharacters();
+
+ RuleFor(t => t.First).MaximumLength(35);
+ RuleFor(t => t.First).AlphaOnly();
+
+ RuleFor(t => t.Last).MaximumLength(35);
+ RuleFor(t => t.Last).AlphaOnly();
+
+ RuleFor(t => t.PhoneNumber)
+ .EmptyPhoneNumber()
+ .OverridePropertyName("Phone");
+
+ //State must be 2 characters for us states if set
+ RuleFor(t => t.State).Length(t => t.State?.Length != 0 ? 2 : 0);
+
+ RuleFor(t => t.Street).MaximumLength(50);
+ RuleFor(t => t.Street).AlphaNumericOnly();
+
+ RuleFor(t => t.Zip).NumericOnly();
+ //Allow empty zip codes, but if one is defined, is must be less than 7 characters
+ RuleFor(t => t.Zip).Length(ad => ad.Zip?.Length != 0 ? 7 : 0);
+ }
+ }
+}
diff --git a/VNLib.Plugins.Essentials.SocialOauth/Validators/LoginMessageValidation.cs b/VNLib.Plugins.Essentials.SocialOauth/Validators/LoginMessageValidation.cs
new file mode 100644
index 0000000..86893c5
--- /dev/null
+++ b/VNLib.Plugins.Essentials.SocialOauth/Validators/LoginMessageValidation.cs
@@ -0,0 +1,60 @@
+/*
+* Copyright (c) 2022 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.SocialOauth
+* File: LoginMessageValidation.cs
+*
+* LoginMessageValidation.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;
+
+using FluentValidation;
+
+using VNLib.Plugins.Essentials.Accounts;
+using VNLib.Plugins.Extensions.Validation;
+
+namespace VNLib.Plugins.Essentials.SocialOauth.Validators
+{
+ internal class LoginMessageValidation : AbstractValidator<LoginMessage>
+ {
+ /*
+ * A login message object is only used for common semantics within
+ * the user-system so validation operations are different than a
+ * normal login endpoint as named fields may be used differently
+ */
+ public LoginMessageValidation()
+ {
+ RuleFor(t => t.ClientID)
+ .Length(10, 50)
+ .WithMessage("Your browser is not sending required security information");
+
+ RuleFor(t => t.ClientPublicKey)
+ .NotEmpty()
+ .WithMessage("Your browser is not sending required security information");
+
+ //Password is only used for nonce tokens
+ RuleFor(t => t.Password).NotEmpty();
+
+ RuleFor(t => t.LocalLanguage)
+ .NotEmpty()
+ .WithMessage("Your language is not supported");
+ RuleFor(t => t.LocalLanguage).AlphaNumericOnly();
+ }
+ }
+}