From 3bd7effc15d0b87adce01281b073aa1db67d3cba Mon Sep 17 00:00:00 2001 From: vnugent Date: Sat, 6 Jan 2024 18:06:01 -0500 Subject: social portal conversion, pull provider libraries & include some prebuilts --- ...Plugins.Essentials.Accounts.Registration.csproj | 2 +- .../src/VNLib.Plugins.Essentials.Accounts.csproj | 2 +- .../VNLib.Plugins.Essentials.Auth.Social/README.md | 18 + .../build.readme.md | 0 .../src/ClientAccessTokenState.cs | 48 ++ .../src/ClientClaimManager.cs | 126 +++++ .../src/ClientRequestState.cs | 81 +++ .../src/GetTokenRequest.cs | 34 ++ .../src/IOAuthAccessState.cs | 55 ++ .../src/IOAuthProvider.cs | 40 ++ .../src/LoginClaim.cs | 73 +++ .../src/LoginUriBuilder.cs | 127 +++++ .../src/OAuthSiteAdapter.cs | 73 +++ .../src/OauthClientConfig.cs | 148 ++++++ .../src/PortalsEndpoint.cs | 75 +++ .../src/SocialAuthEntry.cs | 99 ++++ .../src/SocialOAuthPortal.cs | 34 ++ .../src/SocialOauthBase.cs | 572 +++++++++++++++++++++ .../src/UserLoginData.cs | 34 ++ .../VNLib.Plugins.Essentials.Auth.Social.csproj | 63 +++ .../src/Validators/AccountDataValidator.cs | 83 +++ .../src/Validators/LoginMessageValidation.cs | 65 +++ ...VNLib.Plugins.Essentials.Content.Routing.csproj | 4 +- .../VNLib.Plugins.Essentials.Auth.Auth0/README.md | 15 + .../build.readme.md | 11 + .../src/Auth0Portal.cs | 67 +++ .../src/Endpoints/LoginEndpoint.cs | 191 +++++++ .../src/Endpoints/LogoutEndpoint.cs | 70 +++ .../src/VNLib.Plugins.Essentials.Auth.Auth0.csproj | 48 ++ .../README.md | 15 + .../build.readme.md | 11 + .../src/DiscordPortal.cs | 65 +++ .../src/Endpoint/DiscordOauth.cs | 148 ++++++ .../VNLib.Plugins.Essentials.Auth.Discord.csproj | 48 ++ .../VNLib.Plugins.Essentials.Auth.Github/README.md | 15 + .../build.readme.md | 11 + .../src/Endpoint/GitHubOauth.cs | 213 ++++++++ .../src/GithubPortal.cs | 65 +++ .../VNLib.Plugins.Essentials.Auth.Github.csproj | 48 ++ 39 files changed, 2893 insertions(+), 4 deletions(-) create mode 100644 plugins/VNLib.Plugins.Essentials.Auth.Social/README.md create mode 100644 plugins/VNLib.Plugins.Essentials.Auth.Social/build.readme.md create mode 100644 plugins/VNLib.Plugins.Essentials.Auth.Social/src/ClientAccessTokenState.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Auth.Social/src/ClientClaimManager.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Auth.Social/src/ClientRequestState.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Auth.Social/src/GetTokenRequest.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Auth.Social/src/IOAuthAccessState.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Auth.Social/src/IOAuthProvider.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Auth.Social/src/LoginClaim.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Auth.Social/src/LoginUriBuilder.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Auth.Social/src/OAuthSiteAdapter.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Auth.Social/src/OauthClientConfig.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Auth.Social/src/PortalsEndpoint.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Auth.Social/src/SocialAuthEntry.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Auth.Social/src/SocialOAuthPortal.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Auth.Social/src/SocialOauthBase.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Auth.Social/src/UserLoginData.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Auth.Social/src/VNLib.Plugins.Essentials.Auth.Social.csproj create mode 100644 plugins/VNLib.Plugins.Essentials.Auth.Social/src/Validators/AccountDataValidator.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Auth.Social/src/Validators/LoginMessageValidation.cs create mode 100644 plugins/providers/VNLib.Plugins.Essentials.Auth.Auth0/README.md create mode 100644 plugins/providers/VNLib.Plugins.Essentials.Auth.Auth0/build.readme.md create mode 100644 plugins/providers/VNLib.Plugins.Essentials.Auth.Auth0/src/Auth0Portal.cs create mode 100644 plugins/providers/VNLib.Plugins.Essentials.Auth.Auth0/src/Endpoints/LoginEndpoint.cs create mode 100644 plugins/providers/VNLib.Plugins.Essentials.Auth.Auth0/src/Endpoints/LogoutEndpoint.cs create mode 100644 plugins/providers/VNLib.Plugins.Essentials.Auth.Auth0/src/VNLib.Plugins.Essentials.Auth.Auth0.csproj create mode 100644 plugins/providers/VNLib.Plugins.Essentials.Auth.Discord/README.md create mode 100644 plugins/providers/VNLib.Plugins.Essentials.Auth.Discord/build.readme.md create mode 100644 plugins/providers/VNLib.Plugins.Essentials.Auth.Discord/src/DiscordPortal.cs create mode 100644 plugins/providers/VNLib.Plugins.Essentials.Auth.Discord/src/Endpoint/DiscordOauth.cs create mode 100644 plugins/providers/VNLib.Plugins.Essentials.Auth.Discord/src/VNLib.Plugins.Essentials.Auth.Discord.csproj create mode 100644 plugins/providers/VNLib.Plugins.Essentials.Auth.Github/README.md create mode 100644 plugins/providers/VNLib.Plugins.Essentials.Auth.Github/build.readme.md create mode 100644 plugins/providers/VNLib.Plugins.Essentials.Auth.Github/src/Endpoint/GitHubOauth.cs create mode 100644 plugins/providers/VNLib.Plugins.Essentials.Auth.Github/src/GithubPortal.cs create mode 100644 plugins/providers/VNLib.Plugins.Essentials.Auth.Github/src/VNLib.Plugins.Essentials.Auth.Github.csproj (limited to 'plugins') diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.Registration/src/VNLib.Plugins.Essentials.Accounts.Registration.csproj b/plugins/VNLib.Plugins.Essentials.Accounts.Registration/src/VNLib.Plugins.Essentials.Accounts.Registration.csproj index 1f003a2..c703461 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts.Registration/src/VNLib.Plugins.Essentials.Accounts.Registration.csproj +++ b/plugins/VNLib.Plugins.Essentials.Accounts.Registration/src/VNLib.Plugins.Essentials.Accounts.Registration.csproj @@ -56,7 +56,7 @@ - + diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/VNLib.Plugins.Essentials.Accounts.csproj b/plugins/VNLib.Plugins.Essentials.Accounts/src/VNLib.Plugins.Essentials.Accounts.csproj index f8f7083..6faa14d 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/VNLib.Plugins.Essentials.Accounts.csproj +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/VNLib.Plugins.Essentials.Accounts.csproj @@ -60,7 +60,7 @@ - + diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/README.md b/plugins/VNLib.Plugins.Essentials.Auth.Social/README.md new file mode 100644 index 0000000..9b7a992 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/README.md @@ -0,0 +1,18 @@ +# VNLib.Plugins.Essentials.Auth.Social +*Essentials framework plugin that loads dynamic providers like my packaged GitHub, Discord, and Auth0 providers.* + +This package also contains abstractions for building other providers and may be inlcuded as a library to build a new providers. + +## Builds +Debug build w/ symbols & xml docs, release builds, NuGet packages, and individually packaged source code are available on my website (link below). + +## Docs and Guides +Documentation, specifications, and setup guides are available on my website. + +[Docs and Articles](https://www.vaughnnugent.com/resources/software/articles?tags=docs,_VNLib.Plugins.Essentials.Auth.Social) +[Builds and Source](https://www.vaughnnugent.com/resources/software/modules/Plugins.Essentials) +[Nuget Feeds](https://www.vaughnnugent.com/resources/software/modules) + +## License +All source files in this repository is licensed under the GNU Affero General Public License (or any later version). +See the LICENSE file for more information. diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/build.readme.md b/plugins/VNLib.Plugins.Essentials.Auth.Social/build.readme.md new file mode 100644 index 0000000..e69de29 diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/ClientAccessTokenState.cs b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/ClientAccessTokenState.cs new file mode 100644 index 0000000..5ba77f2 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/ClientAccessTokenState.cs @@ -0,0 +1,48 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Social +* File: OAuthAccessState.cs +* +* OAuthAccessState.cs is part of VNLib.Plugins.Essentials.Auth.Social which +* is part of the larger VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Social 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.Auth.Social 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.Serialization; + +namespace VNLib.Plugins.Essentials.Auth.Social +{ + public class OAuthAccessState : IOAuthAccessState + { + /// + [JsonPropertyName("access_token")] + public string? Token { get; set; } + /// + [JsonPropertyName("scope")] + public string? Scope { get; set; } + /// + [JsonPropertyName("token_type")] + public string? Type { get; set; } + /// + [JsonPropertyName("refresh_token")] + public string? RefreshToken { get; set; } + /// + [JsonPropertyName("id_token")] + public string? IdToken { get; set; } + } +} \ No newline at end of file diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/ClientClaimManager.cs b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/ClientClaimManager.cs new file mode 100644 index 0000000..0c4f9ba --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/ClientClaimManager.cs @@ -0,0 +1,126 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Social +* File: ClientClaimManager.cs +* +* ClientClaimManager.cs is part of VNLib.Plugins.Essentials.Auth.Social which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Social 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.Auth.Social 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.Diagnostics.CodeAnalysis; + +using VNLib.Hashing; +using VNLib.Hashing.IdentityUtility; +using VNLib.Utils; +using VNLib.Utils.Memory; +using VNLib.Utils.Extensions; +using VNLib.Plugins.Essentials.Accounts; +using VNLib.Plugins.Essentials.Extensions; + +namespace VNLib.Plugins.Essentials.Auth.Social +{ + internal sealed record class ClientClaimManager(ICookieController Cookies) + { + const string SESSION_SIG_KEY_NAME = "soa.sig"; + const int SIGNING_KEY_SIZE = 32; + + public bool VerifyAndGetClaim(HttpEntity entity, [NotNullWhen(true)] out LoginClaim? claim) + { + claim = null; + + string? cookieValue = Cookies.GetCookie(entity); + + //Try to get the cookie + if (cookieValue == null) + { + return false; + } + + //Recover the signing key from the user's session + string sigKey = entity.Session[SESSION_SIG_KEY_NAME]; + Span key = stackalloc byte[SIGNING_KEY_SIZE + 16]; + + ERRNO keySize = VnEncoding.Base64UrlDecode(sigKey, key); + + if (keySize < 1) + { + return false; + } + + try + { + //Try to parse the jwt + using JsonWebToken jwt = JsonWebToken.Parse(cookieValue); + + //Verify the jwt + if (!jwt.Verify(key[..(int)keySize], HashAlg.SHA256)) + { + return false; + } + + //Recover the clam from the jwt + claim = jwt.GetPayload(); + + //Verify the expiration time + return claim.ExpirationSeconds > entity.RequestedTimeUtc.ToUnixTimeSeconds(); + } + catch (FormatException) + { + //JWT was corrupted and could not be parsed + return false; + } + finally + { + MemoryUtil.InitializeBlock(key); + } + } + + public void ClearClaimData(HttpEntity entity) + { + //Remove the upgrade cookie + Cookies.ExpireCookie(entity, false); + + //Clear the signing key from the session + entity.Session[SESSION_SIG_KEY_NAME] = null!; + } + + public void SignAndSetCookie(HttpEntity entity, LoginClaim claim) + { + //Setup Jwt + using JsonWebToken jwt = new(); + + //Write claim body, we dont need a header + jwt.WritePayload(claim, Statics.SR_OPTIONS); + + //Generate signing key + byte[] sigKey = RandomHash.GetRandomBytes(SIGNING_KEY_SIZE); + + //Sign the jwt + jwt.Sign(sigKey, HashAlg.SHA256); + + Cookies.SetCookie(entity, jwt.Compile()); + + //Encode and store the signing key in the clien't session + entity.Session[SESSION_SIG_KEY_NAME] = VnEncoding.ToBase64UrlSafeString(sigKey, false); + + //Clear the signing key + MemoryUtil.InitializeBlock(sigKey.AsSpan()); + } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/ClientRequestState.cs b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/ClientRequestState.cs new file mode 100644 index 0000000..ea8eec9 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/ClientRequestState.cs @@ -0,0 +1,81 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Social +* File: ClientRequestState.cs +* +* ClientRequestState.cs is part of VNLib.Plugins.Essentials.Auth.Social which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Social 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.Auth.Social 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.Auth.Social +{ + internal sealed class ClientRequestState : ICacheable + { + private readonly ReadOnlyMemory _rawKey; + + /// + /// The raw nonce state bytes + /// + public ReadOnlyMemory State { get; private set; } + + public ClientRequestState(ReadOnlySpan keyChar, int nonceBytes) + { + //Get browser id + _rawKey = Convert.FromHexString(keyChar); + RecomputeState(nonceBytes); + } + + /// + /// Recomputes a nonce state and signature for the current + /// connection + /// + /// The size of the nonce (in bytes) to generate + public void RecomputeState(int nonceBytes) + { + //Get random nonce buffer + State = RandomHash.GetRandomBytes(nonceBytes); + } + /// + /// Computes the signature of the supplied data based on the original + /// client state for this connection + /// + /// + /// + public ERRNO ComputeSignatureForClient(ReadOnlySpan data, Span output) + { + return HMACSHA512.TryHashData(_rawKey.Span, data, output, out int count) ? count : ERRNO.E_FAIL; + } + + public DateTime Expires { get; set; } + bool IEquatable.Equals(ICacheable other) => ReferenceEquals(this, other); + void ICacheable.Evicted() + { + //Zero secrets on eviction + MemoryUtil.UnsafeZeroMemory(State); + MemoryUtil.UnsafeZeroMemory(_rawKey); + } + } +} \ No newline at end of file diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/GetTokenRequest.cs b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/GetTokenRequest.cs new file mode 100644 index 0000000..d061937 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/GetTokenRequest.cs @@ -0,0 +1,34 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Social +* File: GetTokenRequest.cs +* +* GetTokenRequest.cs is part of VNLib.Plugins.Essentials.Auth.Social which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Social 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.Auth.Social 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.Auth.Social +{ + /// + /// A request message to get an OAuth2 token from a code + /// + /// The clients authentication code + /// The redirect url for current site + public sealed record class GetTokenRequest(string Code, string RedirectUrl) + { } +} diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/IOAuthAccessState.cs b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/IOAuthAccessState.cs new file mode 100644 index 0000000..cbdd41a --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/IOAuthAccessState.cs @@ -0,0 +1,55 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Social +* File: IOAuthAccessState.cs +* +* IOAuthAccessState.cs is part of VNLib.Plugins.Essentials.Auth.Social which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Social 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.Auth.Social 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.Auth.Social +{ + /// + /// An object that represents an OAuth2 access token in its + /// standard form. + /// + public interface IOAuthAccessState + { + /// + /// The OAuth2 access token + /// + public string? Token { get; set; } + /// + /// Token grant scope + /// + string? Scope { get; set; } + /// + /// The OAuth2 token type, usually 'Bearer' + /// + string? Type { get; set; } + /// + /// Optional refresh token + /// + string? RefreshToken { get; set; } + + /// + /// Optional ID OIDC token + /// + string? IdToken { get; set; } + } +} \ No newline at end of file diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/IOAuthProvider.cs b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/IOAuthProvider.cs new file mode 100644 index 0000000..6968e05 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/IOAuthProvider.cs @@ -0,0 +1,40 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Social +* File: IOAuthProvider.cs +* +* IOAuthProvider.cs is part of VNLib.Plugins.Essentials.Auth.Social which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Social 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.Auth.Social 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.Auth.Social +{ + /// + /// Represents a dynamically loaded social login provider + /// that exposes a set of social login portals + /// + public interface IOAuthProvider + { + /// + /// Gets all exported social login portals to be advertised + /// to clients + /// + /// The portal array + SocialOAuthPortal[] GetPortals(); + } +} \ No newline at end of file diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/LoginClaim.cs b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/LoginClaim.cs new file mode 100644 index 0000000..70acff0 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/LoginClaim.cs @@ -0,0 +1,73 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Social +* File: LoginClaim.cs +* +* LoginClaim.cs is part of VNLib.Plugins.Essentials.Auth.Social which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Social 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.Auth.Social 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; + +using VNLib.Hashing; +using VNLib.Utils; +using VNLib.Utils.Memory; +using VNLib.Plugins.Essentials.Accounts; + +namespace VNLib.Plugins.Essentials.Auth.Social +{ + internal sealed class LoginClaim : IClientSecInfo + { + [JsonPropertyName("exp")] + public long ExpirationSeconds { get; set; } + + [JsonPropertyName("iat")] + public long IssuedAtTime { get; set; } + + [JsonPropertyName("nonce")] + public string? Nonce { get; set; } + + [JsonPropertyName("locallanguage")] + public string? LocalLanguage { get; set; } + + [JsonPropertyName("pubkey")] + public string? PublicKey { get; set; } + + [JsonPropertyName("clientid")] + public string? ClientId { get; set; } + + + public void ComputeNonce(int nonceSize) + { + //Alloc nonce buffer + using UnsafeMemoryHandle buffer = MemoryUtil.UnsafeAlloc(nonceSize); + try + { + //fill the buffer with random data + RandomHash.GetRandomBytes(buffer.Span); + + //Base32-Encode nonce and save it + Nonce = VnEncoding.ToBase64UrlSafeString(buffer.Span, false); + } + finally + { + MemoryUtil.InitializeBlock(buffer.Span); + } + } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/LoginUriBuilder.cs b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/LoginUriBuilder.cs new file mode 100644 index 0000000..da37fb7 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/LoginUriBuilder.cs @@ -0,0 +1,127 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Social +* File: LoginUriBuilder.cs +* +* LoginUriBuilder.cs is part of VNLib.Plugins.Essentials.Auth.Social which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Social 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.Auth.Social 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.Runtime.InteropServices; + +using VNLib.Utils; +using VNLib.Utils.Memory; +using VNLib.Utils.Extensions; +using VNLib.Plugins.Essentials.Accounts; + +namespace VNLib.Plugins.Essentials.Auth.Social +{ + /* + * 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 + */ + + internal sealed record class LoginUriBuilder(OauthClientConfig Config) + { + private string? redirectUrl; + private string? nonce; + private Encoding _encoding = Encoding.UTF8; + + public LoginUriBuilder WithUrl(ReadOnlySpan scheme, ReadOnlySpan authority, ReadOnlySpan path) + { + //Alloc stack buffer for url + Span buffer = stackalloc char[1024]; + + //buffer writer for easier syntax + ForwardOnlyWriter 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 buffer = MemoryUtil.UnsafeAllocNearestPage(8000); + + Span binBuffer = buffer.Span[2048..]; + Span charBuffer = MemoryMarshal.Cast(buffer.Span[..2048]); + + + /* + * Build the character uri so we can encode it to binary, + * encrypt it and return it to the client + */ + + ForwardOnlyWriter 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 url = writer.AsSpan(); + + //Separate bin buffers for encryption and encoding + Span encryptionBuffer = binBuffer[1024..]; + Span 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]); + } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/OAuthSiteAdapter.cs b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/OAuthSiteAdapter.cs new file mode 100644 index 0000000..37dd7e0 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/OAuthSiteAdapter.cs @@ -0,0 +1,73 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Social +* File: OAuthSiteAdapter.cs +* +* OAuthSiteAdapter.cs is part of VNLib.Plugins.Essentials.Auth.Social which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Social 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.Auth.Social 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.Auth.Social +{ + /// + /// Provides strucutred http messaging to an OAuth2 site. + /// + public sealed class OAuthSiteAdapter : RestSiteAdapterBase + { + /// + protected override RestClientPool Pool { get; } + + /// + /// Initializes a new instance of the class. + /// + 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); + } + + /// + public override void OnResponse(RestResponse response) + { } + + /// + public override Task WaitAsync(CancellationToken cancellation = default) + { + return Task.CompletedTask; + } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/OauthClientConfig.cs b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/OauthClientConfig.cs new file mode 100644 index 0000000..a3b43ad --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/OauthClientConfig.cs @@ -0,0 +1,148 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Social +* File: OauthClientConfig.cs +* +* OauthClientConfig.cs is part of VNLib.Plugins.Essentials.Auth.Social which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Social 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.Auth.Social 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.Collections.Generic; + +using VNLib.Utils.Logging; +using VNLib.Utils.Extensions; +using VNLib.Plugins.Extensions.Loading; + +namespace VNLib.Plugins.Essentials.Auth.Social +{ + + /// + /// Contains the standard configuration data for an OAuth2 endpoint + /// defined by plugin configuration + /// + public sealed class OauthClientConfig + { + + public OauthClientConfig(PluginBase plugin, IConfigScope config) + { + 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 {config.ScopeName}"); + + //Get the auth and token urls + 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); + UserDataUrl = new(userUrl); + + AllowForLocalAccounts = config["allow_for_local"].GetBoolean(); + AllowRegistration = config["allow_registration"].GetBoolean(); + NonceByteSize = config["nonce_size"].GetUInt32(); + RandomPasswordSize = config["password_size"].GetInt32(); + InitClaimValidFor = config["claim_valid_for_sec"].GetTimeSpan(TimeParseType.Seconds); + + //Setup async lazy loaders for secrets + ClientID = plugin.GetSecretAsync($"{config.ScopeName}_client_id") + .ToLazy(static r => r.Result.ToString()); + + 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); + }); + } + } + + /// + /// The client ID for the OAuth2 service + /// + public IAsyncLazy ClientID { get; } + + /// + /// The client secret for the OAuth2 service + /// + public IAsyncLazy ClientSecret { get; } + + + /// + /// The user-account origin value. Specifies that the user account + /// was created outside of the local account system + /// + public string AccountOrigin { get; } + + /// + /// The URL to redirect the user to the OAuth2 service + /// to begin the authentication process + /// + public Uri AccessCodeUrl { get; } + + /// + /// The remote endoint to exchange codes for access tokens + /// + public Uri AccessTokenUrl { get; } + + /// + /// The endpoint to get user-data object from + /// + public Uri UserDataUrl { get; } + + /// + /// The endpoint route/path + /// + public string EndpointPath { get; } + + /// + /// The size (in bytes) of the random generated nonce + /// + public uint NonceByteSize { get; } + + /// + /// A value that specifies if locally created accounts are allowed + /// to be logged in from an OAuth2 source + /// + public bool AllowForLocalAccounts { get; } + + /// + /// A value that indicates if accounts that do not exist will be created + /// and logged in immediatly, on successfull OAuth2 flow + /// + public bool AllowRegistration { get; } + + /// + /// The size (in bytes) of the random password generated for new users + /// + public int RandomPasswordSize { get; } + + /// + /// The initial time the login claim is valid for + /// + public TimeSpan InitClaimValidFor { get; } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/PortalsEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/PortalsEndpoint.cs new file mode 100644 index 0000000..4e5f867 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/PortalsEndpoint.cs @@ -0,0 +1,75 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Social +* File: PortalsEndpoint.cs +* +* PortalsEndpoint.cs is part of VNLib.Plugins.Essentials.Auth.Social which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Social 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.Auth.Social 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.Collections.Generic; + +using VNLib.Plugins.Essentials.Endpoints; +using VNLib.Plugins.Extensions.Loading; + + +namespace VNLib.Plugins.Essentials.Auth.Social +{ + [ConfigurationName("portals")] + internal sealed class PortalsEndpoint : UnprotectedWebEndpoint + { + private PortalDefJson[] _portals; + + public PortalsEndpoint(PluginBase plugin, IConfigScope config) + { + string path = config.GetRequiredProperty("path", p => p.GetString()!); + InitPathAndLog(path, plugin.Log); + + //Empty array by default + _portals = []; + } + + public void SetPortals(IEnumerable portals) + { + //Convert to json + _portals = portals.Select(p => new PortalDefJson + { + id = p.PortalId, + login = p.LoginEndpoint.Path, + logout = p.LogoutEndpoint?.Path, + }).ToArray(); + } + + protected override VfReturnType Get(HttpEntity entity) + { + //return portals array as json + return VirtualOkJson(entity, _portals); + } + + private sealed class PortalDefJson + { + public string? id { get; set; } + + public string? login { get; set; } + + public string? logout { get; set; } + } + } +} \ No newline at end of file diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/SocialAuthEntry.cs b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/SocialAuthEntry.cs new file mode 100644 index 0000000..397d688 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/SocialAuthEntry.cs @@ -0,0 +1,99 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Social +* File: SocialAuthEntry.cs +* +* SocialAuthEntry.cs is part of VNLib.Plugins.Essentials.Auth.Social which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Social 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.Auth.Social 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.Json; +using System.Collections.Generic; + +using VNLib.Utils.Logging; +using VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Extensions.Loading.Routing; + + +namespace VNLib.Plugins.Essentials.Auth.Social +{ + + public sealed class SocialAuthEntry : PluginBase + { + const string ProviderConfigKey = "providers"; + + /// + public override string PluginName => "Auth.Socal"; + + /// + protected override void OnLoad() + { + Log.Information("Loading social authentication providers"); + + //Get provider array + if(PluginConfig.TryGetProperty(ProviderConfigKey, out JsonElement providerArray)) + { + //Get dll file names + string[] providerDlls = providerArray.EnumerateArray() + .Select(e => e.GetString()!) + .ToArray(); + + List portals = new(); + + /* + * Using the loading library to create the exported services + * which are IOAuthProvider implementations + */ + foreach (string dll in providerDlls) + { + //Load the dll + IOAuthProvider provider = this.CreateServiceExternal(dll); + + //Capture all portals + portals.AddRange(provider.GetPortals()); + + Log.Information($"Loaded OAuth method {provider.GetType().Name}"); + } + + //Define portals endpoint and set portals + PortalsEndpoint p = this.Route(); + p.SetPortals(portals); + } + else + { + Log.Warn("No providers array defined in config"); + } + } + + /// + 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/plugins/VNLib.Plugins.Essentials.Auth.Social/src/SocialOAuthPortal.cs b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/SocialOAuthPortal.cs new file mode 100644 index 0000000..3fe6ddf --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/SocialOAuthPortal.cs @@ -0,0 +1,34 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Social +* File: SocialOAuthPortal.cs +* +* SocialOAuthPortal.cs is part of VNLib.Plugins.Essentials.Auth.Social which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Social 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.Auth.Social 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.Auth.Social +{ + /// + /// Defines a single oauth social login portal + /// + /// The unique identifier for the portal + /// Required login endpoint to advertise to the client + /// Optional logout endpoint to advertise to the client + public record SocialOAuthPortal(string PortalId, IEndpoint LoginEndpoint, IEndpoint? LogoutEndpoint); +} \ No newline at end of file diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/SocialOauthBase.cs b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/SocialOauthBase.cs new file mode 100644 index 0000000..52da637 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/SocialOauthBase.cs @@ -0,0 +1,572 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Social +* File: SocialOauthBase.cs +* +* SocialOauthBase.cs is part of VNLib.Plugins.Essentials.Auth.Social which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Social 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.Auth.Social 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.Json; +using System.Threading; +using System.Threading.Tasks; +using System.Security.Cryptography; +using System.Text.Json.Serialization; + +using FluentValidation; + +using RestSharp; + +using VNLib.Net.Http; +using VNLib.Utils; +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; +using VNLib.Plugins.Essentials.Extensions; +using VNLib.Plugins.Essentials.Auth.Social.Validators; +using VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Extensions.Validation; +using VNLib.Plugins.Extensions.Loading.Users; + +using ContentType = VNLib.Net.Http.ContentType; + +namespace VNLib.Plugins.Essentials.Auth.Social +{ + + /// + /// Provides a base class for derriving commong OAuth2 implicit authentication + /// + public abstract class SocialOauthBase : UnprotectedWebEndpoint + { + const string AUTH_ERROR_MESSAGE = "You have no pending authentication requests."; + + const string AUTH_GRANT_SESSION_NAME = "auth"; + const string SESSION_TOKEN_KEY_NAME = "soa.tkn"; + const string CLAIM_COOKIE_NAME = "extern-claim"; + + + /// + /// The client configuration struct passed during base class construction + /// + protected virtual OauthClientConfig Config { get; } + + /// + protected override ProtectionSettings EndpointProtectionSettings { get; } + + /// + /// The site adapter used to make requests to the OAuth2 provider + /// + protected OAuthSiteAdapter SiteAdapter { get; } + + /// + /// The user manager used to create and manage user accounts + /// + protected IUserManager Users { get; } + + private readonly IValidator ClaimValidator; + private readonly IValidator NonceValidator; + private readonly IValidator AccountDataValidator; + private readonly ClientClaimManager _claims; + + protected SocialOauthBase(PluginBase plugin, IConfigScope config) + { + ClaimValidator = GetClaimValidator(); + NonceValidator = GetNonceValidator(); + AccountDataValidator = new AccountDataValidator(); + + //Get the configuration element for the derrived type + Config = plugin.CreateService(config); + + //Init endpoint + InitPathAndLog(Config.EndpointPath, plugin.Log); + + Users = plugin.GetOrCreateSingleton(); + + + //Setup cookie controller and claim manager + SingleCookieController cookies = new(CLAIM_COOKIE_NAME, Config.InitClaimValidFor) + { + Secure = true, + HttpOnly = true, + SameSite = CookieSameSite.None, + Path = Path + }; + + _claims = new(cookies); + + //Define the site adapter + SiteAdapter = new(); + + //Define the the get-token request endpoint + SiteAdapter.DefineSingleEndpoint() + .WithEndpoint() + .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 GetClaimValidator() + { + InlineValidator 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"); + + val.RuleFor(static s => s.LocalLanguage) + .Length(2, 10) + .WithMessage("Request is not valid"); + + return val; + } + + private static IValidator GetNonceValidator() + { + InlineValidator 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 + return !entity.IsClientAuthorized(AuthorzationCheckLevel.Any); + } + + /// + /// When derrived in a child class, exchanges an OAuth2 code grant type + /// for an OAuth2 access token to make api requests + /// + /// + /// The raw code from the remote OAuth2 granting server + /// A token to cancel the operation + /// + /// A task the resolves the that includes all relavent + /// authorization data. Result may be null if authorzation is invalid or not granted + /// + protected virtual async Task ExchangeCodeForTokenAsync(HttpEntity ev, string code, CancellationToken cancellationToken) + { + //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 + Oauth2TokenResult? response = await SiteAdapter.ExecuteAsync(req, cancellationToken).AsJson(); + + if(response?.Error != null) + { + Log.Debug("Error result from {conf} code {code} description: {err}", Config.AccountOrigin, response.Error, response.ErrorDescription); + return null; + } + + return response; + } + + /// + /// Gets an object that represents the user's account data from the OAuth provider when + /// creating a new user for the current platform + /// + /// The access state from the code/token exchange + /// A token to cancel the operation + /// The user's account data, null if not account exsits on the remote site, and process cannot continue + protected abstract Task GetAccountDataAsync(IOAuthAccessState clientAccess, CancellationToken cancellationToken); + + /// + /// Gets an object that represents the required information for logging-in a user (namley unique user-id) + /// + /// The authorization information granted from the OAuth2 authorization server + /// A token to cancel the operation + /// + protected abstract Task GetLoginDataAsync(IOAuthAccessState clientAccess, CancellationToken cancellation); + + + + /* + * 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 PutAsync(HttpEntity entity) + { + ValErrWebMessage webm = new(); + + //Get the login message + LoginClaim? claim = await entity.GetJsonFromFileAsync(); + + 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; + } + + //Configure the login claim + claim.IssuedAtTime = entity.RequestedTimeUtc.ToUnixTimeSeconds(); + + //Set expiration time in seconds + claim.ExpirationSeconds = entity.RequestedTimeUtc.Add(Config.InitClaimValidFor).ToUnixTimeMilliseconds(); + + //Set nonce + claim.ComputeNonce((int)Config.NonceByteSize); + + //Build the redirect uri + webm.Result = new LoginUriBuilder(Config) + .WithEncoding(entity.Server.Encoding) + .WithUrl(entity.Server.RequestUri.Scheme, entity.Server.RequestUri.Authority, Path) + .WithNonce(claim.Nonce!) + .Encrypt(entity, claim); + + //Sign and set the claim cookie + _claims.SignAndSetCookie(entity, claim); + + webm.Success = true; + //Response + return VirtualOk(entity, webm); + } + + /* + * Get method is invoked when the remote OAuth2 control has been passed back + * 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 + */ + + /// + protected override async ValueTask 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()) + { + _claims.ClearClaimData(entity); + //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 (!_claims.VerifyAndGetClaim(entity, out LoginClaim? claim)) + { + _claims.ClearClaimData(entity); + entity.Redirect(RedirectType.Temporary, $"{Path}?result=expired"); + return VfReturnType.VirtualSkip; + } + + //Confirm the nonce matches the claim + if (string.CompareOrdinal(claim.Nonce, state) != 0) + { + _claims.ClearClaimData(entity); + 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) + { + _claims.ClearClaimData(entity); + entity.Redirect(RedirectType.Temporary, $"{Path}?result=invalid"); + return VfReturnType.VirtualSkip; + } + + //Create the new nonce + claim.ComputeNonce((int)Config.NonceByteSize); + + //Store access state in the user's session + entity.Session.SetObject(SESSION_TOKEN_KEY_NAME, token); + + //Sign and set cookie + _claims.SignAndSetCookie(entity, claim); + + //Prepare redirect + entity.Redirect(RedirectType.Temporary, $"{Path}?result=authorized&nonce={claim.Nonce}"); + return VfReturnType.VirtualSkip; + } + + //Check to see if there was an error code set + if (entity.QueryArgs.TryGetNonEmptyValue("error", out string? errorCode)) + { + _claims.ClearClaimData(entity); + 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 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")) + { + return VirtualClose(entity, webm, HttpStatusCode.BadRequest); + } + + //Recover the nonce + string? base32Nonce = request.RootElement.GetPropString("nonce"); + + if(webm.Assert(base32Nonce != null, "Nonce parameter is required")) + { + return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity); + } + + //Validate nonce + if (!NonceValidator.Validate(base32Nonce, webm)) + { + return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity); + } + + //Recover the access token + if (webm.Assert(_claims.VerifyAndGetClaim(entity, out LoginClaim? claim), AUTH_ERROR_MESSAGE)) + { + return VirtualOk(entity, webm); + } + + //We can clear the client's access claim + _claims.ClearClaimData(entity); + + //Confirm nonce matches the client's nonce string + bool nonceValid = string.CompareOrdinal(claim.Nonce, base32Nonce) == 0; + + if (webm.Assert(nonceValid, AUTH_ERROR_MESSAGE)) + { + return VirtualOk(entity, webm); + } + + //Safe to recover the access token + IOAuthAccessState token = entity.Session.GetObject(SESSION_TOKEN_KEY_NAME); + + //get the user's login information (ie userid) + UserLoginData? userLogin = await GetLoginDataAsync(token, entity.EventCancellation); + + if(webm.Assert(userLogin?.UserId != null, AUTH_ERROR_MESSAGE)) + { + return VirtualOk(entity, webm); + } + + //Convert the platform user-id to a database-safe user-id + string computedId = Users.ComputeSafeUserId(userLogin.UserId!); + + //Fetch the user from the database + IUser? user = await Users.GetUserFromIDAsync(computedId, entity.EventCancellation); + + /* + * If a user is not found, we can optionally create a new user account + * if the configuration allows it. + */ + if(user == null) + { + //make sure registration is enabled + if (webm.Assert(Config.AllowRegistration, AUTH_ERROR_MESSAGE)) + { + return VirtualOk(entity, webm); + } + + //Get the clients personal info to being login process + AccountData? userAccount = await GetAccountDataAsync(token, entity.EventCancellation); + + if (webm.Assert(userAccount != null, AUTH_ERROR_MESSAGE)) + { + return VirtualOk(entity, webm); + } + + //Validate the account data + if (webm.Assert(AccountDataValidator.Validate(userAccount).IsValid, AUTH_ERROR_MESSAGE)) + { + return VirtualOk(entity, webm); + } + + //See if user by email address exists + user = await Users.GetUserFromEmailAsync(userAccount.EmailAddress!, entity.EventCancellation); + + if (user == null) + { + //Create the new user account + UserCreationRequest creation = new() + { + EmailAddress = userAccount.EmailAddress!, + InitialStatus = UserStatus.Active, + }; + + try + { + //Create the user with the specified email address, minimum privilage level, and an empty password + user = await Users.CreateUserAsync(creation, computedId, entity.EventCancellation); + + //Store the new profile and origin + user.SetProfile(userAccount); + 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"; + return VirtualOk(entity, webm); + } + + //Skip check since we just created the user + goto Authorize; + } + + /* + * User account already exists via email address but not + * user-id + */ + } + + //Make sure local accounts are allowed + if (webm.Assert(!user.IsLocalAccount() || Config.AllowForLocalAccounts, AUTH_ERROR_MESSAGE)) + { + return VirtualOk(entity, webm); + } + + //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)) + { + return VirtualOk(entity, webm); + } + + Authorize: + + try + { + //Generate authoization + entity.GenerateAuthorization(claim, user, webm); + + //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 social 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; + + //destroy any login data on failure + entity.InvalidateLogin(); + + Log.Error("Failed to update the user's account cause:\n{err}",uue); + } + finally + { + user.Dispose(); + } + return VirtualOk(entity, webm); + } + + + sealed class Oauth2TokenResult: OAuthAccessState + { + [JsonPropertyName("error")] + public string? Error { get; set; } + + [JsonPropertyName("error_description")] + public string? ErrorDescription { get; set; } + } + + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/UserLoginData.cs b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/UserLoginData.cs new file mode 100644 index 0000000..7451539 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/UserLoginData.cs @@ -0,0 +1,34 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Social +* File: UserLoginData.cs +* +* UserLoginData.cs is part of VNLib.Plugins.Essentials.Auth.Social which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Social 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.Auth.Social 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; + +namespace VNLib.Plugins.Essentials.Auth.Social +{ + public class UserLoginData + { + [JsonPropertyName("user_id")] + public string? UserId { get; set; } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/VNLib.Plugins.Essentials.Auth.Social.csproj b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/VNLib.Plugins.Essentials.Auth.Social.csproj new file mode 100644 index 0000000..dd6132c --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/VNLib.Plugins.Essentials.Auth.Social.csproj @@ -0,0 +1,63 @@ + + + + enable + net8.0 + VNLib.Plugins.Essentials.Auth.Social + Auth.Social + True + latest-all + en-US + true + + + + VNLib.Plugins.Essentials.Auth.Social + Vaughn Nugent + Vaughn Nugent + A shared library for building social OAuth authentication endpoints. + Copyright © 2024 Vaughn Nugent + https://www.vaughnnugent.com/resources/software/modules/Plugins.Essentials + https://github.com/VnUgE/Plugins.Essentials/tree/master/plugins/VNLib.Plugins.Essentials.Auth.Social + Essentials framework plugin that loads dynamic providers like my packaged GitHub, Discord, and Auth0 providers. This package also contains abstractions for building other providers. and may be inlcuded as a library to build a new provider + + + + README.md + LICENSE + + + + True + \ + Always + + + True + \ + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/Validators/AccountDataValidator.cs b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/Validators/AccountDataValidator.cs new file mode 100644 index 0000000..269a9f3 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/Validators/AccountDataValidator.cs @@ -0,0 +1,83 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Social +* File: AccountDataValidator.cs +* +* AccountDataValidator.cs is part of VNLib.Plugins.Essentials.Auth.Social which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Social 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.Auth.Social 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; + +namespace VNLib.Plugins.Essentials.Auth.Social.Validators +{ + internal class AccountDataValidator : AbstractValidator + { + public AccountDataValidator() : base() + { + RuleFor(t => t.EmailAddress) + .NotEmpty() + .WithMessage("Your account does not have an email address assigned to it"); + + RuleFor(t => t.City) + .MaximumLength(35) + .AlphaOnly() + .When(t => t.City?.Length > 0); + + RuleFor(t => t.Company) + .MaximumLength(50) + .SpecialCharacters() + .When(t => t.Company?.Length > 0); + + //Require a first and last names to be set together + When(t => t.First?.Length > 0 || t.Last?.Length > 0, () => + { + RuleFor(t => t.First) + .Length(1, 35) + .AlphaOnly(); + RuleFor(t => t.Last) + .Length(1, 35) + .AlphaOnly(); + }); + + RuleFor(t => t.PhoneNumber) + .PhoneNumber() + .When(t => t.PhoneNumber?.Length > 0) + .OverridePropertyName("Phone"); + + //State must be 2 characters for us states if set + RuleFor(t => t.State) + .Length(2) + .When(t => t.State?.Length > 0); + + RuleFor(t => t.Street) + .AlphaNumericOnly() + .MaximumLength(50) + .When(t => t.Street?.Length > 0); + + //Allow empty zip codes, but if one is defined, is must be less than 7 characters + RuleFor(t => t.Zip) + .NumericOnly() + .MaximumLength(7) + .When(t => t.Zip?.Length > 0); + } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/Validators/LoginMessageValidation.cs b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/Validators/LoginMessageValidation.cs new file mode 100644 index 0000000..f3894c9 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/Validators/LoginMessageValidation.cs @@ -0,0 +1,65 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Social +* File: LoginMessageValidation.cs +* +* LoginMessageValidation.cs is part of VNLib.Plugins.Essentials.Auth.Social which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Social 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.Auth.Social 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.Auth.Social.Validators +{ + internal class LoginMessageValidation : AbstractValidator + { + /* + * 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") + .IllegalCharacters() + .WithMessage("Your browser is not sending required security information"); + + RuleFor(t => t.ClientPublicKey) + .Length (50, 1000) + .WithMessage("Your browser is not sending required security information") + .IllegalCharacters() + .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") + .AlphaNumericOnly() + .WithMessage("Your language is not supported"); + } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Content.Routing/src/VNLib.Plugins.Essentials.Content.Routing.csproj b/plugins/VNLib.Plugins.Essentials.Content.Routing/src/VNLib.Plugins.Essentials.Content.Routing.csproj index df4c4cd..aa3fa56 100644 --- a/plugins/VNLib.Plugins.Essentials.Content.Routing/src/VNLib.Plugins.Essentials.Content.Routing.csproj +++ b/plugins/VNLib.Plugins.Essentials.Content.Routing/src/VNLib.Plugins.Essentials.Content.Routing.csproj @@ -56,9 +56,9 @@ - + - + diff --git a/plugins/providers/VNLib.Plugins.Essentials.Auth.Auth0/README.md b/plugins/providers/VNLib.Plugins.Essentials.Auth.Auth0/README.md new file mode 100644 index 0000000..1cacc6b --- /dev/null +++ b/plugins/providers/VNLib.Plugins.Essentials.Auth.Auth0/README.md @@ -0,0 +1,15 @@ +# VNLib.Plugins.Essentials.Auth.Auth0 +*A runtime asset library that provides enterprise Auth0 OAuth authentication to your application that is using the [Auth.Social](../../VNLib.Plugins.Essentials.Auth.Social) plugin* + +## Builds +Debug build w/ symbols & xml docs, release builds, NuGet packages, and individually packaged source code are available on my website (link below). + +## Docs and Guides +Documentation, specifications, and setup guides are available on my website. + +[Docs and Articles](https://www.vaughnnugent.com/resources/software/articles?tags=docs,_VNLib.Plugins.Essentials.Auth.Auth0) +[Builds and Source](https://www.vaughnnugent.com/resources/software/modules/Plugins.Essentials) +[Nuget Feeds](https://www.vaughnnugent.com/resources/software/modules) + +## License +Source files in for this project are licensed to you under the GNU Affero General Public License (or any later version). See the LICENSE files for more information. \ No newline at end of file diff --git a/plugins/providers/VNLib.Plugins.Essentials.Auth.Auth0/build.readme.md b/plugins/providers/VNLib.Plugins.Essentials.Auth.Auth0/build.readme.md new file mode 100644 index 0000000..4533b0d --- /dev/null +++ b/plugins/providers/VNLib.Plugins.Essentials.Auth.Auth0/build.readme.md @@ -0,0 +1,11 @@ +VNLib.Plugins.Essentials.Auth.Auth0 Copyright © 2024 Vaughn Nugent +Contact: Vaughn Nugent vnpublic[at]proton.me + +LICENSE: +You should have recieved a copy of the GNU Affero General Public License along with this program. If not, see . + +In the future there will be more information for installtion in this file, but for now go to my website + +https://www.vaughnnugent.com/resources/software/articles?tags=docs,_VNLib.Plugins.Essentials.Auth.Auth0 + +Thank you! \ No newline at end of file diff --git a/plugins/providers/VNLib.Plugins.Essentials.Auth.Auth0/src/Auth0Portal.cs b/plugins/providers/VNLib.Plugins.Essentials.Auth.Auth0/src/Auth0Portal.cs new file mode 100644 index 0000000..0ae92f4 --- /dev/null +++ b/plugins/providers/VNLib.Plugins.Essentials.Auth.Auth0/src/Auth0Portal.cs @@ -0,0 +1,67 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Auth0 +* File: Auth0Portal.cs +* +* Auth0Portal.cs is part of VNLib.Plugins.Essentials.Auth.Auth0 which is +* part of the larger VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Auth0 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.Auth.Auth0 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 VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Extensions.Loading.Routing; +using VNLib.Plugins.Essentials.Auth.Social; + +using VNLib.Plugins.Essentials.Auth.Auth0.Endpoints; + +namespace VNLib.Plugins.Essentials.Auth.Auth0 +{ + + [ServiceExport] + [ConfigurationName(ConfigKey)] + public sealed class Auth0Portal : IOAuthProvider + { + internal const string ConfigKey = "auth0"; + + private readonly LoginEndpoint _loginEndpoint; + private readonly LogoutEndpoint _logoutEndpoint; + + public Auth0Portal(PluginBase plugin, IConfigScope config) + { + //Init the login endpoint + _loginEndpoint = plugin.Route(); + _logoutEndpoint = plugin.Route(); + } + + /// + public SocialOAuthPortal[] GetPortals() + { + + //Return the Auth0 portal + return [ + new SocialOAuthPortal( + ConfigKey, + _loginEndpoint, + _logoutEndpoint + ) + ]; + + } + } +} diff --git a/plugins/providers/VNLib.Plugins.Essentials.Auth.Auth0/src/Endpoints/LoginEndpoint.cs b/plugins/providers/VNLib.Plugins.Essentials.Auth.Auth0/src/Endpoints/LoginEndpoint.cs new file mode 100644 index 0000000..52be461 --- /dev/null +++ b/plugins/providers/VNLib.Plugins.Essentials.Auth.Auth0/src/Endpoints/LoginEndpoint.cs @@ -0,0 +1,191 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Auth0 +* File: LoginEndpoint.cs +* +* LoginEndpoint.cs is part of VNLib.Plugins.Essentials.Auth.Auth0 which is +* part of the larger VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Auth0 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.Auth.Auth0 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.Json; +using System.Threading; +using System.Threading.Tasks; +using System.Collections.Generic; + +using RestSharp; + +using VNLib.Hashing; +using VNLib.Hashing.IdentityUtility; +using VNLib.Utils.Logging; +using VNLib.Plugins.Essentials.Accounts; +using VNLib.Plugins.Extensions.Loading; +using VNLib.Net.Rest.Client.Construction; +using VNLib.Plugins.Essentials.Auth.Social; + +/* + * Provides specialized login for Auth0 identity managment system. Auth0 apis use JWT tokens + * and JWK signing keys. Keys are downloaded when the plugin is first loaded and cached for + * the lifetime of the plugin. The keys are used to verify the JWT token and extract the user + */ + +namespace VNLib.Plugins.Essentials.Auth.Auth0.Endpoints +{ + + [ConfigurationName(Auth0Portal.ConfigKey)] + internal sealed class LoginEndpoint : SocialOauthBase + { + private readonly IAsyncLazy Auth0VerificationJwk; + private readonly bool VerifyEmail; + + public LoginEndpoint(PluginBase plugin, IConfigScope config) : base(plugin, config) + { + string keyUrl = config["key_url"].GetString() ?? throw new KeyNotFoundException("Missing Auth0 'key_url' from config"); + + //Define the key endpoint + SiteAdapter.DefineSingleEndpoint() + .WithEndpoint() + .WithUrl(keyUrl) + .WithMethod(Method.Get) + .WithHeader("Accept", "application/json") + .OnResponse((r, res) => res.ThrowIfError()); + + //Check for email verification + VerifyEmail = config.TryGetValue("verified_email", out JsonElement el) && el.GetBoolean(); + + //Get certificate on background thread + Auth0VerificationJwk = Task.Run(GetRsaCertificate).AsLazy(); + } + + private async Task GetRsaCertificate() + { + try + { + Log.Debug("Getting Auth0 signing keys"); + + //rent client from pool + RestResponse response = await SiteAdapter.ExecuteAsync(new GetKeyRequest()); + + //Get response as doc + using JsonDocument doc = JsonDocument.Parse(response.RawBytes); + + //Create a new jwk from each key element in the response + ReadOnlyJsonWebKey[] keys = doc.RootElement.GetProperty("keys") + .EnumerateArray() + .Select(static k => new ReadOnlyJsonWebKey(k)) + .ToArray(); + + Log.Debug("Found {count} Auth0 signing keys", keys.Length); + + return keys; + } + catch (Exception e) + { + Log.Error(e, "Failed to get Auth0 signing keys"); + throw; + } + } + + /* + * Auth0 uses the format "platoform|{user_id}" for the user id so it should match the + * external platofrm as github and discord endoints also + */ + + private static string GetUserIdFromPlatform(string userName) + { + return ManagedHash.ComputeHash(userName, HashAlg.SHA1, HashEncodingMode.Hexadecimal); + } + + + private static readonly Task EmptyLoginData = Task.FromResult(null); + private static readonly Task EmptyUserData = Task.FromResult(null); + + /// + protected override Task GetLoginDataAsync(IOAuthAccessState clientAccess, CancellationToken cancellation) + { + //recover the identity token + using JsonWebToken jwt = JsonWebToken.Parse(clientAccess.IdToken); + + //Verify the token against the first signing key + if (!jwt.VerifyFromJwk(Auth0VerificationJwk.Value[0])) + { + 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.Value.Equals(audience, StringComparison.Ordinal)) + { + //Invalid audience + return EmptyLoginData; + } + + return Task.FromResult(new UserLoginData() + { + UserId = GetUserIdFromPlatform(userId) + }); + } + + /* + * Account data may be recovered from the identity token + * and it happens after a call to GetLoginData so + * we do not need to re-verify the token + */ + /// + protected override Task GetAccountDataAsync(IOAuthAccessState clientAccess, CancellationToken cancellationToken) + { + //Parse token again to get the user data + using JsonWebToken jwt = JsonWebToken.Parse(clientAccess.IdToken); + + using JsonDocument userData = jwt.GetPayload(); + + //Confirm email is verified + if (!userData.RootElement.GetProperty("email_verified").GetBoolean() && VerifyEmail) + { + return EmptyUserData; + } + + string fullName = userData.RootElement.GetProperty("name").GetString() ?? " "; + + return Task.FromResult(new AccountData() + { + EmailAddress = userData.RootElement.GetProperty("email").GetString(), + First = fullName.Split(' ').FirstOrDefault(), + Last = fullName.Split(' ').LastOrDefault(), + }); + } + + private sealed record class GetKeyRequest() + { } + } +} diff --git a/plugins/providers/VNLib.Plugins.Essentials.Auth.Auth0/src/Endpoints/LogoutEndpoint.cs b/plugins/providers/VNLib.Plugins.Essentials.Auth.Auth0/src/Endpoints/LogoutEndpoint.cs new file mode 100644 index 0000000..497357a --- /dev/null +++ b/plugins/providers/VNLib.Plugins.Essentials.Auth.Auth0/src/Endpoints/LogoutEndpoint.cs @@ -0,0 +1,70 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Auth0 +* File: LogoutEndpoint.cs +* +* LogoutEndpoint.cs is part of VNLib.Plugins.Essentials.Auth.Auth0 which is +* part of the larger VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Auth0 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.Auth.Auth0 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 VNLib.Utils; + +using VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Essentials.Accounts; +using VNLib.Plugins.Essentials.Endpoints; +using VNLib.Plugins.Essentials.Extensions; + + +namespace VNLib.Plugins.Essentials.Auth.Auth0.Endpoints +{ + [ConfigurationName(Auth0Portal.ConfigKey)] + internal sealed class LogoutEndpoint : ProtectedWebEndpoint + { + private readonly IAsyncLazy ReturnUrl; + + public LogoutEndpoint(PluginBase plugin, IConfigScope config) + { + string returnToUrl = config.GetRequiredProperty("return_to_url", p => p.GetString()!); + string logoutUrl = config.GetRequiredProperty("logout_url", p => p.GetString()!); + string path = config.GetRequiredProperty("path", p => p.GetString()!); + + InitPathAndLog($"{path}/logout", plugin.Log); + + //Build the return url once the client id is available + ReturnUrl = plugin.GetSecretAsync("auth0_client_id").ToLazy(sr => + { + return $"{logoutUrl}?client_id={sr.Result.ToString()}&returnTo={returnToUrl}"; + }); + } + + protected override ERRNO PreProccess(HttpEntity entity) + { + //Client required to be fully authorized + return base.PreProccess(entity) + && entity.IsClientAuthorized(AuthorzationCheckLevel.Critical); + } + + protected override VfReturnType Post(HttpEntity entity) + { + //Invalidate the login before redirecting the client + entity.InvalidateLogin(); + entity.Redirect(RedirectType.Temporary, ReturnUrl.Value); + return VfReturnType.VirtualSkip; + } + } +} \ No newline at end of file diff --git a/plugins/providers/VNLib.Plugins.Essentials.Auth.Auth0/src/VNLib.Plugins.Essentials.Auth.Auth0.csproj b/plugins/providers/VNLib.Plugins.Essentials.Auth.Auth0/src/VNLib.Plugins.Essentials.Auth.Auth0.csproj new file mode 100644 index 0000000..2beb64f --- /dev/null +++ b/plugins/providers/VNLib.Plugins.Essentials.Auth.Auth0/src/VNLib.Plugins.Essentials.Auth.Auth0.csproj @@ -0,0 +1,48 @@ + + + + enable + net8.0 + VNLib.Plugins.Essentials.Auth.Auth0 + VNLib.Plugins.Essentials.Auth.Auth0 + True + latest-all + en-US + true + + + + Vaughn Nugent + Vaughn Nugent + VNLib.Plugins.Essentials.Auth.Auth0 + VNLib.Plugins.Essentials.Auth.Auth0 + A runtime asset library that adds Auth0 social OAuth autentication integration with Auth.Social plugin library + Copyright © 2024 Vaughn Nugent + https://www.vaughnnugent.com/resources/software/modules/Plugins.Essentials + https://Auth0.com/VnUgE/Plugins.Essentials/tree/master/plugins/providers/VNLib.Plugins.Essentials.Auth.Auth0 + README.md + LICENSE + True + + + + + True + \ + Always + + + True + \ + + + + + + + + + + + + diff --git a/plugins/providers/VNLib.Plugins.Essentials.Auth.Discord/README.md b/plugins/providers/VNLib.Plugins.Essentials.Auth.Discord/README.md new file mode 100644 index 0000000..fd523ad --- /dev/null +++ b/plugins/providers/VNLib.Plugins.Essentials.Auth.Discord/README.md @@ -0,0 +1,15 @@ +# VNLib.Plugins.Essentials.Auth.Discord +*A runtime asset library that provides Discord OAuth authentication to your application that is using the [Auth.Social](../../VNLib.Plugins.Essentials.Auth.Social) plugin* + +## Builds +Debug build w/ symbols & xml docs, release builds, NuGet packages, and individually packaged source code are available on my website (link below). + +## Docs and Guides +Documentation, specifications, and setup guides are available on my website. + +[Docs and Articles](https://www.vaughnnugent.com/resources/software/articles?tags=docs,_VNLib.Plugins.Essentials.Auth.Discord) +[Builds and Source](https://www.vaughnnugent.com/resources/software/modules/Plugins.Essentials) +[Nuget Feeds](https://www.vaughnnugent.com/resources/software/modules) + +## License +Source files in for this project are licensed to you under the GNU Affero General Public License (or any later version). See the LICENSE files for more information. \ No newline at end of file diff --git a/plugins/providers/VNLib.Plugins.Essentials.Auth.Discord/build.readme.md b/plugins/providers/VNLib.Plugins.Essentials.Auth.Discord/build.readme.md new file mode 100644 index 0000000..3098245 --- /dev/null +++ b/plugins/providers/VNLib.Plugins.Essentials.Auth.Discord/build.readme.md @@ -0,0 +1,11 @@ +VNLib.Plugins.Essentials.Auth.Discord Copyright © 2024 Vaughn Nugent +Contact: Vaughn Nugent vnpublic[at]proton.me + +LICENSE: +You should have recieved a copy of the GNU Affero General Public License along with this program. If not, see . + +In the future there will be more information for installtion in this file, but for now go to my website + +https://www.vaughnnugent.com/resources/software/articles?tags=docs,_VNLib.Plugins.Essentials.Auth.Discord + +Thank you! \ No newline at end of file diff --git a/plugins/providers/VNLib.Plugins.Essentials.Auth.Discord/src/DiscordPortal.cs b/plugins/providers/VNLib.Plugins.Essentials.Auth.Discord/src/DiscordPortal.cs new file mode 100644 index 0000000..5b0503e --- /dev/null +++ b/plugins/providers/VNLib.Plugins.Essentials.Auth.Discord/src/DiscordPortal.cs @@ -0,0 +1,65 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Discord +* File: DiscordPortal.cs +* +* DiscordPortal.cs is part of VNLib.Plugins.Essentials.Auth.Discord which is +* part of the larger VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Discord 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.Auth.Discord 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 VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Extensions.Loading.Routing; +using VNLib.Plugins.Essentials.Auth.Social; + +using VNLib.Plugins.Essentials.Auth.Discord.Endpoints; + +namespace VNLib.Plugins.Essentials.Auth.Discord +{ + + [ServiceExport] + [ConfigurationName(ConfigKey)] + public sealed class DiscordPortal : IOAuthProvider + { + internal const string ConfigKey = "discord"; + + private readonly DiscordOauth _loginEndpoint; + + public DiscordPortal(PluginBase plugin, IConfigScope config) + { + //Init the login endpoint + _loginEndpoint = plugin.Route(); + } + + /// + public SocialOAuthPortal[] GetPortals() + { + + //Return the Discord portal + return [ + new SocialOAuthPortal( + ConfigKey, + _loginEndpoint, + null + ) + ]; + + } + } +} diff --git a/plugins/providers/VNLib.Plugins.Essentials.Auth.Discord/src/Endpoint/DiscordOauth.cs b/plugins/providers/VNLib.Plugins.Essentials.Auth.Discord/src/Endpoint/DiscordOauth.cs new file mode 100644 index 0000000..4aa7a64 --- /dev/null +++ b/plugins/providers/VNLib.Plugins.Essentials.Auth.Discord/src/Endpoint/DiscordOauth.cs @@ -0,0 +1,148 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Discord +* File: DiscordOauth.cs +* +* DiscordOauth.cs is part of VNLib.Plugins.Essentials.Auth.Discord which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Auth.Discord 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.Auth.Discord 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.Threading; +using System.Threading.Tasks; +using System.Text.Json.Serialization; + +using RestSharp; + +using VNLib.Hashing; +using VNLib.Utils.Logging; +using VNLib.Plugins.Essentials.Accounts; +using VNLib.Plugins.Extensions.Loading; +using VNLib.Net.Rest.Client.Construction; +using VNLib.Plugins.Essentials.Auth.Social; + +namespace VNLib.Plugins.Essentials.Auth.Discord.Endpoints +{ + [ConfigurationName(DiscordPortal.ConfigKey)] + internal sealed class DiscordOauth : SocialOauthBase + { + public DiscordOauth(PluginBase plugin, IConfigScope config) : base(plugin, config) + { + //Define profile endpoint + SiteAdapter.DefineSingleEndpoint() + .WithEndpoint() + .WithMethod(Method.Get) + .WithUrl(Config.UserDataUrl) + .WithHeader("Authorization", r => $"{r.AccessToken.Type} {r.AccessToken.Token}"); + } + + /* + * Creates a user-id from the users discord username, that is repeatable + * and matches the Auth0 social user-id format + */ + private static string GetUserIdFromPlatform(string userName) => $"discord|{userName}"; + + + /// + protected override async Task GetAccountDataAsync(IOAuthAccessState accessToken, CancellationToken cancellationToken) + { + //Get the user's profile + UserProfile? profile = await GetUserProfileAssync(accessToken, cancellationToken); + + if (profile == null) + { + return null; + } + + //Make sure the user's account is verified + if (!profile.Verified) + { + return null; + } + + return new() + { + EmailAddress = profile.EmailAddress, + First = profile.Username, + }; + } + + /// + protected override async Task GetLoginDataAsync(IOAuthAccessState accessToken, CancellationToken cancellationToken) + { + //Get the user's profile + UserProfile? profile = await GetUserProfileAssync(accessToken, cancellationToken); + + if (profile == null) + { + return null; + } + + return new() + { + //Get unique user-id from the discord profile and sha1 hex hash to store in db + UserId = GetUserIdFromPlatform(profile.UserID) + }; + } + + private async Task GetUserProfileAssync(IOAuthAccessState accessToken, CancellationToken cancellationToken) + { + //Get the user's email address's + DiscordProfileRequest req = new(accessToken); + RestResponse response = await SiteAdapter.ExecuteAsync(req, cancellationToken); + + //Check response + if (!response.IsSuccessful || response.Content == null) + { + Log.Debug("Discord user request responded with code {code}:{data}", response.StatusCode, response.Content); + return null; + } + + UserProfile? discordProfile = JsonSerializer.Deserialize(response.RawBytes); + + if (string.IsNullOrWhiteSpace(discordProfile?.UserID)) + { + Log.Debug("Discord user request responded with invalid response data {code}:{data}", response.StatusCode, response.Content); + return null; + } + + return discordProfile; + } + + private sealed record class DiscordProfileRequest(IOAuthAccessState AccessToken) + { } + + /* + * Matches the profile endpoint (@me) json object + */ + private sealed class UserProfile + { + [JsonPropertyName("username")] + public string? Username { get; set; } + [JsonPropertyName("id")] + public string? UserID { get; set; } + [JsonPropertyName("url")] + public string? ProfileUrl { get; set; } + [JsonPropertyName("verified")] + public bool Verified { get; set; } + [JsonPropertyName("email")] + public string? EmailAddress { get; set; } + } + } +} \ No newline at end of file diff --git a/plugins/providers/VNLib.Plugins.Essentials.Auth.Discord/src/VNLib.Plugins.Essentials.Auth.Discord.csproj b/plugins/providers/VNLib.Plugins.Essentials.Auth.Discord/src/VNLib.Plugins.Essentials.Auth.Discord.csproj new file mode 100644 index 0000000..d64ebe6 --- /dev/null +++ b/plugins/providers/VNLib.Plugins.Essentials.Auth.Discord/src/VNLib.Plugins.Essentials.Auth.Discord.csproj @@ -0,0 +1,48 @@ + + + + enable + net8.0 + VNLib.Plugins.Essentials.Auth.Discord + VNLib.Plugins.Essentials.Auth.Discord + True + latest-all + en-US + true + + + + Vaughn Nugent + Vaughn Nugent + VNLib.Plugins.Essentials.Auth.Discord + VNLib.Plugins.Essentials.Auth.Discord + A runtime asset library that adds Discord social OAuth autentication integration with Auth.Social plugin library + Copyright © 2024 Vaughn Nugent + https://www.vaughnnugent.com/resources/software/modules/Plugins.Essentials + https://Discord.com/VnUgE/Plugins.Essentials/tree/master/plugins/providers/VNLib.Plugins.Essentials.Auth.Discord + README.md + LICENSE + True + + + + + True + \ + Always + + + True + \ + + + + + + + + + + + + diff --git a/plugins/providers/VNLib.Plugins.Essentials.Auth.Github/README.md b/plugins/providers/VNLib.Plugins.Essentials.Auth.Github/README.md new file mode 100644 index 0000000..c4c91dd --- /dev/null +++ b/plugins/providers/VNLib.Plugins.Essentials.Auth.Github/README.md @@ -0,0 +1,15 @@ +# VNLib.Plugins.Essentials.Auth.Github +*A runtime asset library that provides Github OAuth authentication to your application that is using the [Auth.Social](../../VNLib.Plugins.Essentials.Auth.Social) plugin* + +## Builds +Debug build w/ symbols & xml docs, release builds, NuGet packages, and individually packaged source code are available on my website (link below). + +## Docs and Guides +Documentation, specifications, and setup guides are available on my website. + +[Docs and Articles](https://www.vaughnnugent.com/resources/software/articles?tags=docs,_VNLib.Plugins.Essentials.Auth.Github) +[Builds and Source](https://www.vaughnnugent.com/resources/software/modules/Plugins.Essentials) +[Nuget Feeds](https://www.vaughnnugent.com/resources/software/modules) + +## License +Source files in for this project are licensed to you under the GNU Affero General Public License (or any later version). See the LICENSE files for more information. \ No newline at end of file diff --git a/plugins/providers/VNLib.Plugins.Essentials.Auth.Github/build.readme.md b/plugins/providers/VNLib.Plugins.Essentials.Auth.Github/build.readme.md new file mode 100644 index 0000000..3b7c356 --- /dev/null +++ b/plugins/providers/VNLib.Plugins.Essentials.Auth.Github/build.readme.md @@ -0,0 +1,11 @@ +VNLib.Plugins.Essentials.Auth.Github Copyright © 2024 Vaughn Nugent +Contact: Vaughn Nugent vnpublic[at]proton.me + +LICENSE: +You should have recieved a copy of the GNU Affero General Public License along with this program. If not, see . + +In the future there will be more information for installtion in this file, but for now go to my website + +https://www.vaughnnugent.com/resources/software/articles?tags=docs,_VNLib.Plugins.Essentials.Auth.Github + +Thank you! \ No newline at end of file diff --git a/plugins/providers/VNLib.Plugins.Essentials.Auth.Github/src/Endpoint/GitHubOauth.cs b/plugins/providers/VNLib.Plugins.Essentials.Auth.Github/src/Endpoint/GitHubOauth.cs new file mode 100644 index 0000000..b23188c --- /dev/null +++ b/plugins/providers/VNLib.Plugins.Essentials.Auth.Github/src/Endpoint/GitHubOauth.cs @@ -0,0 +1,213 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Github +* File: GitHubOauth.cs +* +* GitHubOauth.cs is part ofVNLib.Plugins.Essentials.Auth.Githubwhich is part of the larger +* VNLib collection of libraries and utilities. +* +*VNLib.Plugins.Essentials.Auth.Githubis 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.Auth.Githubis 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.Threading; +using System.Text.Json; +using System.Threading.Tasks; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +using RestSharp; + +using VNLib.Utils.Logging; +using VNLib.Plugins.Essentials.Accounts; +using VNLib.Plugins.Extensions.Loading; +using VNLib.Net.Rest.Client.Construction; +using VNLib.Plugins.Essentials.Auth.Social; + +namespace VNLib.Plugins.Essentials.Auth.Github.Endpoints +{ + [ConfigurationName(GithubPortal.ConfigKey)] + internal sealed partial class GitHubOauth : SocialOauthBase + { + private const string GITHUB_V3_ACCEPT = "application/vnd.github.v3+json"; + + private readonly string UserEmailUrl; + + + 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"); + + //Define profile endpoint, gets users required profile information + SiteAdapter.DefineSingleEndpoint() + .WithEndpoint() + .WithMethod(Method.Get) + .WithUrl(Config.UserDataUrl) + .WithHeader("Accept", GITHUB_V3_ACCEPT) + .WithHeader("Authorization", at => $"{at.AccessToken.Type} {at.AccessToken.Token}"); + + //Define email endpoint, gets users email address + SiteAdapter.DefineSingleEndpoint() + .WithEndpoint() + .WithMethod(Method.Get) + .WithUrl(UserEmailUrl) + .WithHeader("Accept", GITHUB_V3_ACCEPT) + .WithHeader("Authorization", at => $"{at.AccessToken.Type} {at.AccessToken.Token}"); + } + + /* + * 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 static string GetUserIdFromPlatform(int userId) => $"github|{userId}"; + + + protected override async Task GetLoginDataAsync(IOAuthAccessState accessToken, CancellationToken cancellationToken) + { + GetProfileRequest req = new(accessToken); + + //Exec the get for the profile + RestResponse profResponse = await SiteAdapter.ExecuteAsync(req, cancellationToken); + + if (!profResponse.IsSuccessful || profResponse.RawBytes == null) + { + Log.Debug("Github login data attempt responded with status code {code}", profResponse.StatusCode); + return null; + } + + GithubProfile profile = JsonSerializer.Deserialize(profResponse.RawBytes)!; + + if (profile.ID < 100) + { + Log.Debug("Github login data attempt responded with empty or invalid response body", profResponse.StatusCode); + return null; + } + + //Return login data + return new() + { + //User-id is just the SHA 1 + UserId = GetUserIdFromPlatform(profile.ID) + }; + } + + protected override async Task GetAccountDataAsync(IOAuthAccessState accessToken, CancellationToken cancellationToken = default) + { + AccountData? accountData = null; + + //Get the user's email address's + GetEmailRequest request = new(accessToken); + + //get user's emails + RestResponse getEmailResponse = await SiteAdapter.ExecuteAsync(request, cancellationToken); + + //Check status + if (getEmailResponse.IsSuccessful && getEmailResponse.RawBytes != null) + { + //Filter emails addresses + foreach (EmailContainer email in JsonSerializer.Deserialize(getEmailResponse.RawBytes)!) + { + //Capture the first primary email address and make sure its verified + if (email.Primary && email.Verified) + { + accountData = new() + { + //store email on current profile + EmailAddress = email.Email + }; + goto Continue; + } + } + //No primary email found + return null; + } + else + { + Log.Debug("Github account data request failed but GH responded with status code {code}", getEmailResponse.StatusCode); + return null; + } + Continue: + + //We need to get the user's profile again + GetProfileRequest prof = new(accessToken); + + //Exec request against site adapter + RestResponse profResponse = await SiteAdapter.ExecuteAsync(prof, cancellationToken); + + if (!profResponse.IsSuccessful || profResponse.RawBytes == null) + { + Log.Debug("Github account data request failed but GH responded with status code {code}", profResponse.StatusCode); + return null; + } + + //Deserialize the profile + GithubProfile profile = JsonSerializer.Deserialize(profResponse.RawBytes)!; + + //Get the user's name from gh profile + string[] names = profile.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; + } + + //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/providers/VNLib.Plugins.Essentials.Auth.Github/src/GithubPortal.cs b/plugins/providers/VNLib.Plugins.Essentials.Auth.Github/src/GithubPortal.cs new file mode 100644 index 0000000..99b0ebf --- /dev/null +++ b/plugins/providers/VNLib.Plugins.Essentials.Auth.Github/src/GithubPortal.cs @@ -0,0 +1,65 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Auth.Github +* File: GithubPortal.cs +* +* GithubPortal.cs is part ofVNLib.Plugins.Essentials.Auth.Githubwhich is +* part of the larger VNLib collection of libraries and utilities. +* +*VNLib.Plugins.Essentials.Auth.Githubis 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.Auth.Githubis 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 VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Extensions.Loading.Routing; +using VNLib.Plugins.Essentials.Auth.Social; + +using VNLib.Plugins.Essentials.Auth.Github.Endpoints; + +namespace VNLib.Plugins.Essentials.Auth.Github +{ + + [ServiceExport] + [ConfigurationName(ConfigKey)] + public sealed class GithubPortal : IOAuthProvider + { + internal const string ConfigKey = "github"; + + private readonly GitHubOauth _loginEndpoint; + + public GithubPortal(PluginBase plugin, IConfigScope config) + { + //Init the login endpoint + _loginEndpoint = plugin.Route(); + } + + /// + public SocialOAuthPortal[] GetPortals() + { + + //Return the github portal + return [ + new SocialOAuthPortal( + ConfigKey, + _loginEndpoint, + null + ) + ]; + + } + } +} diff --git a/plugins/providers/VNLib.Plugins.Essentials.Auth.Github/src/VNLib.Plugins.Essentials.Auth.Github.csproj b/plugins/providers/VNLib.Plugins.Essentials.Auth.Github/src/VNLib.Plugins.Essentials.Auth.Github.csproj new file mode 100644 index 0000000..e6643f9 --- /dev/null +++ b/plugins/providers/VNLib.Plugins.Essentials.Auth.Github/src/VNLib.Plugins.Essentials.Auth.Github.csproj @@ -0,0 +1,48 @@ + + + + enable + net8.0 + VNLib.Plugins.Essentials.Auth.Github + VNLib.Plugins.Essentials.Auth.Github + True + latest-all + en-US + true + + + + Vaughn Nugent + Vaughn Nugent + VNLib.Plugins.Essentials.Auth.Github + VNLib.Plugins.Essentials.Auth.Github + A runtime asset library that adds Github social OAuth autentication integration with Auth.Social plugin library + Copyright © 2024 Vaughn Nugent + https://www.vaughnnugent.com/resources/software/modules/Plugins.Essentials + https://github.com/VnUgE/Plugins.Essentials/tree/master/plugins/providers/VNLib.Plugins.Essentials.Auth.Github + README.md + LICENSE + True + + + + + True + \ + Always + + + True + \ + + + + + + + + + + + + -- cgit