From 27b487b6d0befdb2197a58ceadb1f1ac2b337786 Mon Sep 17 00:00:00 2001 From: vnugent Date: Sun, 24 Mar 2024 21:15:47 -0400 Subject: Squashed commit of the following: commit 2a114541a3bfddae887adaa98c1ed326b125d511 Author: vnugent Date: Sun Mar 24 20:53:38 2024 -0400 refactor: pull apart session authorization for future dev commit f8aea6453ddb2d56c1ce2ecb6a9e67d1af523c2e Author: vnugent Date: Thu Mar 21 14:33:21 2024 -0400 feat: Add optional svg base64 icons for social OAuth2 connections commit cc29bed99dc9e151315cce75e50d55dca306b532 Author: vnugent Date: Sun Mar 10 21:58:27 2024 -0400 source tree project location updated --- Module.Taskfile.yaml | 25 +- Taskfile.yaml | 8 +- lib/vnlib.browser/src/social/index.ts | 13 +- .../VNLib.Plugins.Essentials.Accounts.Admin.csproj | 6 +- ...Plugins.Essentials.Accounts.Registration.csproj | 10 +- .../VNLib.Plugins.Essentials.Accounts/README.md | 2 +- .../src/AccountsEntryPoint.cs | 2 +- .../src/SecurityProvider/AccountSecConfig.cs | 193 ++++++ .../src/SecurityProvider/AccountSecProvider.cs | 685 +-------------------- .../src/SecurityProvider/ClientAuthData.cs | 32 + .../src/SecurityProvider/ClientWebAuthManager.cs | 554 +++++++++++++++++ .../SecurityProvider/RsaClientDataEncryption.cs | 98 +++ .../src/VNLib.Plugins.Essentials.Accounts.csproj | 8 +- .../src/PortalsEndpoint.cs | 37 +- .../src/SocialOAuthPortal.cs | 3 +- .../VNLib.Plugins.Essentials.Auth.Social.csproj | 8 +- ...VNLib.Plugins.Essentials.Content.Routing.csproj | 6 +- .../src/Auth0Portal.cs | 7 +- .../src/DiscordPortal.cs | 7 +- .../src/GithubPortal.cs | 7 +- 20 files changed, 1001 insertions(+), 710 deletions(-) create mode 100644 plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecConfig.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/ClientAuthData.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/ClientWebAuthManager.cs create mode 100644 plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/RsaClientDataEncryption.cs diff --git a/Module.Taskfile.yaml b/Module.Taskfile.yaml index 378faca..0d4cd95 100644 --- a/Module.Taskfile.yaml +++ b/Module.Taskfile.yaml @@ -17,13 +17,13 @@ vars: tasks: -#called by build pipeline to sync repo + #called by build pipeline to sync repo update: cmds: - - git remote update - - git reset --hard + - git reset --hard #clean up any local changes + - git remote update - git pull origin {{.BRANCH_NAME}} --verify-signatures - #re-write semver after hard reset so build still works properly + #re-write semver after hard reset - dotnet-gitversion.exe /updateprojectfiles #called by build pipeline to build module @@ -35,24 +35,21 @@ tasks: - task: build_debug - task: build_release - postbuild_success: + publish: cmds: + #git archive in the module directory + - git archive --format {{.ARCHIVE_FILE_FORMAT}} --output {{.ARCHIVE_FILE_NAME}} HEAD #push packages to the sleet feed (feed path is vnbuild global) - sleet push "{{.PACK_OUT}}/debug/" --source debug --config "{{.SLEET_CONFIG_PATH}}" --force - sleet push "{{.PACK_OUT}}/release/" --source release --config "{{.SLEET_CONFIG_PATH}}" --force - #git archive in the module directory - - git archive --format {{.ARCHIVE_FILE_FORMAT}} --output {{.ARCHIVE_FILE_NAME}} HEAD - - postbuild_failed: - cmds: - - echo "postbuild failed {{.MODULE_NAME}}" - #called by build pipeline to clean module clean: cmds: - #clean solution - - dotnet clean /p:BuildInParallel=true /p:MultiProcessorCompilation=true + #clean solution + - dotnet clean /p:BuildInParallel=true /p:MultiProcessorCompilation=true + - cmd: powershell -Command "rm {{ .ARCHIVE_FILE_NAME }} --Force" + ignore_error: true #Internal tasks diff --git a/Taskfile.yaml b/Taskfile.yaml index f59d467..2857d21 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -12,7 +12,6 @@ version: '3' vars: TARGET: '{{.USER_WORKING_DIR}}/bin' RELEASE_DIR: "./bin/release/{{.TARGET_FRAMEWORK}}/publish" - SOURCE_OUT: "{{.USER_WORKING_DIR}}/bin/source" tasks: @@ -37,8 +36,7 @@ tasks: postbuild_failed: dir: '{{.USER_WORKING_DIR}}' - cmds: - - echo "postbuild failed {{.PROJECT_NAME}}" + cmds: [] postbuild: @@ -68,5 +66,5 @@ tasks: dir: '{{.USER_WORKING_DIR}}' ignore_error: true cmds: - - cmd: powershell Remove-Item -Recurse './bin' - - cmd: powershell Remove-Item -Recurse './obj' + - for: ['bin/', 'obj/'] + cmd: powershell Remove-Item -Recurse '{{.ITEM}}' diff --git a/lib/vnlib.browser/src/social/index.ts b/lib/vnlib.browser/src/social/index.ts index dac3cc9..7d80687 100644 --- a/lib/vnlib.browser/src/social/index.ts +++ b/lib/vnlib.browser/src/social/index.ts @@ -55,6 +55,7 @@ export interface SocialOAuthPortal { readonly id: string; readonly login: string; readonly logout?: string; + readonly icon?: string; } /** @@ -66,6 +67,10 @@ export interface OAuthMethod { * The unique id of the method */ readonly id: string; + /** + * Optional bas64encoded icon image url for the method + */ + readonly icon?: string; /** * Determines if the current flow is active for this method */ @@ -89,6 +94,10 @@ export interface SocialOauthMethod { * Gets the url to the login endpoint for this method */ readonly id: string + /** + * Optional bas64encoded icon image url for the method + */ + readonly icon?: string /** * The endpoint to submit the authentication request to */ @@ -214,7 +223,8 @@ export const fromSocialConnections = (methods: T[], return map(methods, method => { return{ id: method.id, - + icon: method.icon, + async beginLoginFlow() { //Prepare the login claim` const claim = await prepareLogin() @@ -314,6 +324,7 @@ export const fromSocialPortals = (portals: SocialOAuthPortal[]): SocialOauthMeth return map(portals, p => { return { id: p.id, + icon: p.icon, loginUrl : () => p.login, //Get the logout data from the server getLogoutData: () => ({ url: p.logout!, args: {}}), diff --git a/plugins/VNLib.Plugins.Essentials.Accounts.Admin/src/VNLib.Plugins.Essentials.Accounts.Admin.csproj b/plugins/VNLib.Plugins.Essentials.Accounts.Admin/src/VNLib.Plugins.Essentials.Accounts.Admin.csproj index 321b605..9ff8376 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts.Admin/src/VNLib.Plugins.Essentials.Accounts.Admin.csproj +++ b/plugins/VNLib.Plugins.Essentials.Accounts.Admin/src/VNLib.Plugins.Essentials.Accounts.Admin.csproj @@ -43,9 +43,9 @@ - - - + + + 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 c703461..6f1ea65 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 @@ -47,12 +47,12 @@ - + - - - - + + + + diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/README.md b/plugins/VNLib.Plugins.Essentials.Accounts/README.md index 13f2dad..3caedb2 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/README.md +++ b/plugins/VNLib.Plugins.Essentials.Accounts/README.md @@ -2,7 +2,7 @@ *An Essentials web plugin that provides endpoints for authenticating, registering, resetting, local user accounts including multi-factor authentication using TOTP (for now).* ### Dependency notice -This library uses some external dependencies: VaultSharp, Serilog, libargon2, and FluentValidation. +This library uses some external dependencies: Serilog, libargon2, and FluentValidation. ## Builds Debug build w/ symbols & xml docs, release builds, NuGet packages, and individually packaged source code are available on my website (link below). diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs index 318f3ce..219239e 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs @@ -92,7 +92,7 @@ namespace VNLib.Plugins.Essentials.Accounts this.ExportService(securityProvider); //Also add the middleware array - this.ExportService(new IHttpMiddleware[] { securityProvider }); + this.ExportService([ securityProvider ]); Log.Information("Configuring the account security provider service"); } diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecConfig.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecConfig.cs new file mode 100644 index 0000000..180e30e --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecConfig.cs @@ -0,0 +1,193 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts +* File: AccountSecConfig.cs +* +* AccountSecConfig.cs is part of VNLib.Plugins.Essentials.Accounts which is part +* of the larger VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Accounts 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.Accounts 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; + +using FluentValidation; + +using VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Extensions.Validation; + +namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider +{ + internal sealed class AccountSecConfig : IOnConfigValidation + { + private static IValidator _validator { get; } = GetValidator(); + + private static IValidator GetValidator() + { + InlineValidator val = new(); + + //Cookie domain may be null/emmpty + val.RuleFor(c => c.CookieDomain); + + //Cookie path may be empty or null + val.RuleFor(c => c.CookiePath); + + val.RuleFor(c => c.AuthorizationValidFor) + .GreaterThan(TimeSpan.FromMinutes(1)) + .WithMessage("The authorization should be valid for at-least 1 minute"); + + val.RuleFor(C => C.ClientStatusCookieName) + .Length(1, 50) + .AlphaNumericOnly(); + + //header name is required, but not allowed to contain "illegal" chars + val.RuleFor(c => c.TokenHeaderName) + .NotEmpty() + .IllegalCharacters(); + + + val.RuleFor(c => c.PubKeyCookieName) + .Length(1, 50) + .IllegalCharacters(); + + //Signing keys are base32 encoded and stored in the session, we dont want to take up too much space + val.RuleFor(c => c.PubKeySigningKeySize) + .InclusiveBetween(8, 512) + .WithMessage("Your public key signing key should be between 8 and 512 bytes"); + + //Time difference doesnt need to be validated, it may be 0 to effectively disable it + val.RuleFor(c => c.SignedTokenTimeDiff); + + val.RuleFor(c => c.TokenKeySize) + .InclusiveBetween(8, 512) + .WithMessage("You should choose an OTP symmetric key size between 8 and 512 bytes"); + + val.RuleFor(c => c.WebSessionValidForSeconds) + .InclusiveBetween((uint)1, uint.MaxValue) + .WithMessage("You must specify a valid value for a web session timeout in seconds"); + + val.RuleForEach(c => c.AllowedOrigins) + .Matches(@"^https?://[a-z0-9\-\.]+$") + .WithMessage("The allowed origins must be valid http(s) urls"); + + return val; + } + + /// + /// The domain all authoization cookies will be set for + /// + [JsonPropertyName("cookie_domain")] + public string CookieDomain { get; set; } = ""; + + /// + /// The path all authorization cookies will be set for + /// + [JsonPropertyName("cookie_path")] + public string? CookiePath { get; set; } = "/"; + + /// + /// The amount if time new authorizations are valid for. This also + /// sets the duration of client cookies. + /// + [JsonIgnore] + internal TimeSpan AuthorizationValidFor { get; set; } = TimeSpan.FromMinutes(60); + + /// + /// The name of the cookie used to set the client's login status message + /// + [JsonPropertyName("status_cookie_name")] + public string ClientStatusCookieName { get; set; } = "li"; + + /// + /// The name of the header used by the client to send the one-time use + /// authorization token + /// + [JsonPropertyName("otp_header_name")] + public string TokenHeaderName { get; set; } = "X-Web-Token"; + + /// + /// The size (in bytes) of the symmetric key used + /// by the client to sign token messages + /// + [JsonPropertyName("otp_key_size")] + public int TokenKeySize { get; set; } = 64; + + /// + /// The name of the cookie that stores the user's signed public encryption key + /// + [JsonPropertyName("pubkey_cookie_name")] + public string PubKeyCookieName { get; set; } = "client_id"; + + /// + /// The size (in bytes) of the randomly generated key + /// used to sign the user's public key + /// + [JsonPropertyName("pubkey_signing_key_size")] + public int PubKeySigningKeySize { get; set; } = 32; + + /// + /// The allowed time difference in the issuance time of the client's signed + /// one time use tokens + /// + [JsonIgnore] + internal TimeSpan SignedTokenTimeDiff { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// The amount of time a web session is valid for + /// + [JsonPropertyName("session_valid_for_sec")] + public uint WebSessionValidForSeconds { get; set; } = 3600; + + [JsonPropertyName("otp_time_diff_sec")] + public uint SigTokenTimeDifSeconds + { + get => (uint)SignedTokenTimeDiff.TotalSeconds; + set => SignedTokenTimeDiff = TimeSpan.FromSeconds(value); + } + + /// + /// Enforce that the client's token is only valid for the origin + /// it was read from. Will break sites hosted from multiple origins + /// + [JsonPropertyName("strict_origin")] + public bool EnforceSameOriginToken { get; set; } = true; + + /// + /// Enable/disable origin verification for the client's token + /// + [JsonIgnore] + public bool VerifyOrigin => AllowedOrigins != null && AllowedOrigins.Length > 0; + + /// + /// The list of origins that are allowed to send requests to the server + /// + [JsonPropertyName("allowed_origins")] + public string[]? AllowedOrigins { get; set; } + + /// + /// Enforce strict path checking for the client's token + /// + [JsonPropertyName("strict_path")] + public bool VerifyPath { get; set; } = true; + + void IOnConfigValidation.Validate() + { + //Validate the current instance + _validator.ValidateAndThrow(this); + } + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs index 8770930..2e0c259 100644 --- a/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/AccountSecProvider.cs @@ -32,28 +32,16 @@ */ using System; -using System.Linq; -using System.Text.Json; using System.Threading.Tasks; -using System.Security.Cryptography; -using System.Text.Json.Serialization; -using System.Diagnostics.CodeAnalysis; -using FluentValidation; - -using VNLib.Hashing; -using VNLib.Hashing.IdentityUtility; using VNLib.Net.Http; using VNLib.Utils; -using VNLib.Utils.Memory; using VNLib.Utils.Logging; -using VNLib.Utils.Extensions; using VNLib.Plugins.Essentials.Users; using VNLib.Plugins.Essentials.Sessions; using VNLib.Plugins.Essentials.Middleware; using VNLib.Plugins.Essentials.Extensions; using VNLib.Plugins.Extensions.Loading; -using VNLib.Plugins.Extensions.Validation; namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider { @@ -62,21 +50,9 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider [MiddlewareImpl(MiddlewareImplOptions.SecurityCritical)] internal sealed class AccountSecProvider : IAccountSecurityProvider, IHttpMiddleware { - private const int PUB_KEY_JWT_NONCE_SIZE = 16; - - //Session entry keys - private const string PUBLIC_KEY_SIG_KEY_ENTRY = "acnt.pbsk"; - - private const HashAlg ClientTokenHmacType = HashAlg.SHA256; - - /// - /// The client data encryption padding. - /// - public static readonly RSAEncryptionPadding ClientEncryptonPadding = RSAEncryptionPadding.OaepSHA256; - private readonly AccountSecConfig _config; private readonly SingleCookieController _statusCookie; - private readonly SingleCookieController _pubkeyCookie; + private readonly ClientWebAuthManager _authManager; private readonly ILogProvider _logger; public AccountSecProvider(PluginBase plugin) @@ -105,17 +81,9 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider Secure = true }; - //Public key cookie handler - _pubkeyCookie = new(_config.PubKeyCookieName, _config.AuthorizationValidFor) - { - Domain = _config.CookieDomain, - Path = _config.CookiePath, - SameSite = CookieSameSite.Strict, - HttpOnly = true, - Secure = true - }; - _logger = plugin.Log.CreateScope("Acnt-Sec"); + + _authManager = new(config, _logger); } /* @@ -146,7 +114,7 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider else { //See if the session might be elevated - if (!string.IsNullOrWhiteSpace(session.Token)) + if (!ClientWebAuthManager.IsSessionElevated(in session)) { //If the session stored a user-agent, make sure it matches the connection if (session.UserAgent != null && !session.UserAgent.Equals(entity.Server.UserAgent, StringComparison.Ordinal)) @@ -159,6 +127,7 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider //If the session is new, or not supposed to be logged in, clear the login cookies if they were set if (session.IsNew || string.IsNullOrEmpty(session.Token)) { + //Do not force clear cookies (saves bandwidth) ExpireCookies(entity, false); } } @@ -178,14 +147,10 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider if (session.Created.AddSeconds(_config.WebSessionValidForSeconds) < entity.RequestedTimeUtc) { //Invalidate the session, so its technically valid for this request, but will be cleared on this handle close cycle - session.Invalidate(); - - //Clear basic login status now so checks will fail later - session.Token = null!; - session.UserID = null!; - session.Privilages = 0; - session[PUBLIC_KEY_SIG_KEY_ENTRY] = null!; + entity.Session.Invalidate(); + //Clear auth specifc cookies + _authManager.DestroyAuthorization(entity); return true; } @@ -209,7 +174,15 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider throw new ArgumentException("The session is no configured for authorization"); } - return GenerateAuth(entity, clientInfo.PublicKey, user.IsLocalAccount()); + ClientAuthData cad = ClientAuthData.FromSecInfo(clientInfo); + + string clientData = _authManager.AuthorizeConnection(entity, in cad); + + //set client status cookie via handler + _statusCookie.SetCookie(entity, user.IsLocalAccount() ? "1" : "2"); + + //Return the new authorzation + return new EncryptedTokenAuthorization(clientData); } /// @@ -221,13 +194,15 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider throw new InvalidOperationException("The session is not configured for authorization"); } + string clientData = string.Empty; + //recover the client's public key - if (!TryGetPublicKey(entity, out string? pubKey)) + if (!_authManager.TryReAuthorizeConnection(entity, ref clientData)) { throw new InvalidOperationException("The user does not have the required public key token stored"); } - return GenerateAuth(entity, pubKey, entity.Session.HasLocalAccount()); + return new EncryptedTokenAuthorization(clientData); } /// @@ -237,8 +212,7 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider ExpireCookies(entity, true); //Clear known security keys - entity.Session.Token = null!; - entity.Session[PUBLIC_KEY_SIG_KEY_ENTRY] = null!; + _authManager.DestroyAuthorization(entity); } /// @@ -253,9 +227,9 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider return level switch { //Accept the client token or the cookie as any/medium - AuthorzationCheckLevel.Any or AuthorzationCheckLevel.Medium => VerifyClientToken(entity) || TryGetPublicKey(entity, out _), + AuthorzationCheckLevel.Any or AuthorzationCheckLevel.Medium => _authManager.HasMinimalAuthorization(entity), //Critical requires that the client cookie is set and the token is set - AuthorzationCheckLevel.Critical => TryGetPublicKey(entity, out _) && VerifyClientToken(entity), + AuthorzationCheckLevel.Critical => _authManager.VerifyConnectionOTP(entity), //Default to false condition _ => false, }; @@ -264,621 +238,25 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider /// ERRNO IAccountSecurityProvider.TryEncryptClientData(HttpEntity entity, ReadOnlySpan data, Span outputBuffer) { + string pubKey = string.Empty; + //Recover the signed public key, already does session checks - return TryGetPublicKey(entity, out string? pubKey) ? TryEncryptClientData(pubKey, data, outputBuffer) : ERRNO.E_FAIL; + return _authManager.TryGetEncryptionPubkey(entity, ref pubKey) ? RsaClientDataEncryption.TryEncrypt(pubKey, data, outputBuffer) : ERRNO.E_FAIL; } /// ERRNO IAccountSecurityProvider.TryEncryptClientData(IClientSecInfo entity, ReadOnlySpan data, Span outputBuffer) { //Use the public key supplied by the csecinfo - return TryEncryptClientData(entity.PublicKey, data, outputBuffer); - } - - private IClientAuthorization GenerateAuth(HttpEntity entity, string publicKey, bool localAccount) - { - //Try to generate a new authorization - GenerateToken(publicKey, out string serverToken, out string clientToken); - - /* - * The user's public key will be stored via a jwt cookie - * signed by this specific signing key, we will save the signing key - * in the session - */ - entity.Session[PUBLIC_KEY_SIG_KEY_ENTRY] = SetPublicKeyCookie(entity, publicKey); - entity.Session.Token = serverToken; - - //set client status cookie via handler - _statusCookie.SetCookie(entity, localAccount ? "1" : "2"); - - //Return the new authorzation - return new EncryptedTokenAuthorization(clientToken); - } - - #endregion - - #region Security Tokens - - /* - * A client token was an older term used for a single random token generated - * by the server and sent by the client. - * - * The latest revision generates a keypair on authorization, the public key - * is stored id the client's session, and the private key gets encrypted - * and sent to the client. The client uses this ECDSA key to sign one time use - * JWT tokens - * - */ - - private void GenerateToken(ReadOnlySpan publicKey, out string serverToken, out string clientToken) - { - //Alloc buffer for encode/decode - using IMemoryHandle buffer = MemoryUtil.SafeAllocNearestPage(4000, true); - try - { - Span secretBuffer = buffer.Span[.._config.TokenKeySize]; - Span outputBuffer = buffer.Span[_config.TokenKeySize..]; - - //Computes a random shared key - RandomHash.GetRandomBytes(secretBuffer); - - ERRNO bytesEncrypted = TryEncryptClientData(publicKey, secretBuffer, outputBuffer); - - //Encyrpt the secret key to send to client - if (!bytesEncrypted) - { - throw new InternalBufferTooSmallException("The internal buffer used to store the encrypted token is too small"); - } - - //Client token is the encrypted secret key - clientToken = Convert.ToBase64String(outputBuffer[..(int)bytesEncrypted]); - - //Encode base64 url safe - serverToken = VnEncoding.ToBase64UrlSafeString(secretBuffer, false); - } - finally - { - //Zero buffer when complete - MemoryUtil.InitializeBlock(ref buffer.GetReference(), buffer.GetIntLength()); - } - } - - private bool VerifyClientToken(HttpEntity entity) - { - //Get the token from the client header, the client should always sent this - string? signedMessage = entity.Server.Headers[_config.TokenHeaderName]; - - //Make sure a session is loaded - if (!entity.Session.IsSet || entity.Session.IsNew || string.IsNullOrWhiteSpace(signedMessage)) - { - return false; - } - - //Get the stored shared symetric key - string sharedKey = entity.Session.Token; - if (string.IsNullOrWhiteSpace(sharedKey)) - { - return false; - } - - /* - * The clients signed message is a json web token that includes basic information - * Clients may send bad data, so we should swallow exceptions and return false - */ - - try - { - bool isValid = true; - - //Parse the client jwt signed message - using JsonWebToken jwt = JsonWebToken.Parse(signedMessage); - - using (UnsafeMemoryHandle decodeBuffer = MemoryUtil.UnsafeAllocNearestPage(_config.TokenKeySize, true)) - { - //Recover the key from base32 - ERRNO count = VnEncoding.Base64UrlDecode(sharedKey, decodeBuffer.Span); - - if (!count) - { - return false; - } - - //Verity the jwt against the store symmetric key - isValid &= jwt.Verify(decodeBuffer.AsSpan(0, count), ClientTokenHmacType); - } - - //Get the message payload - using JsonDocument data = jwt.GetPayload(); - - //Get iat time - if (data.RootElement.TryGetProperty("iat", out JsonElement iatEl) - && iatEl.ValueKind == JsonValueKind.Number) - { - //Try to get iat in unint seconds - isValid &= iatEl.TryGetInt64(out long iatSec); - - //Recover dto from unix seconds regardless of int success - DateTimeOffset iat = DateTimeOffset.FromUnixTimeSeconds(iatSec); - - //Verify iat against current time with allowed disparity - isValid &= iat.Add(_config.SignedTokenTimeDiff) > entity.RequestedTimeUtc; - - //Message is too far into the future! - isValid &= iat.Subtract(_config.SignedTokenTimeDiff) < entity.RequestedTimeUtc; - } - else - { - //No time element provided - isValid = false; - } - - if (_config.VerifyOrigin) - { - //Check the audience matches the request uri - if (data.RootElement.TryGetProperty("aud", out JsonElement tokenOriginEl) - && tokenOriginEl.ValueKind == JsonValueKind.String) - { - string? unsafeUserOrigin = tokenOriginEl.GetString(); - - if(string.IsNullOrWhiteSpace(unsafeUserOrigin)) - { - isValid = false; - } - else if (_config.EnforceSameOriginToken) - { - //enforce strict origin checking - string strictOrigin = entity.Server.RequestUri.GetLeftPart(UriPartial.Authority); - isValid &= string.Equals(unsafeUserOrigin, strictOrigin, StringComparison.OrdinalIgnoreCase); - - if (!isValid) - { - _logger.Debug("Client security OTP JWT origin mismatch from {ip} : strict origin {current} != {token}", - entity.TrustedRemoteIp, - strictOrigin, - unsafeUserOrigin - ); - } - } - else - { - //Verify against allow list - isValid &= _config.AllowedOrigins!.Contains(unsafeUserOrigin, StringComparer.OrdinalIgnoreCase); - - if (!isValid) - { - _logger.Debug("CST origin not allowed {ip} : {token}", - entity.TrustedRemoteIp, - unsafeUserOrigin - ); - } - } - } - else - { - isValid = false; - } - } - - if (_config.VerifyPath) - { - //Check the subject (path) matches the request uri - if (data.RootElement.TryGetProperty("path", out JsonElement tokenPathEl) - && tokenPathEl.ValueKind == JsonValueKind.String) - { - - ReadOnlySpan unsafeUserPath = tokenPathEl.GetString(); - /* - * Query parameters are optional, so we need to check if the path contains a - * query, if so we can compare the entire path and query, otherwise we need to - * compare the path only - */ - if (unsafeUserPath.Contains("?", StringComparison.OrdinalIgnoreCase)) - { - //Compare path and query when possible - string requestPath = entity.Server.RequestUri.PathAndQuery; - - isValid &= unsafeUserPath.Equals(requestPath, StringComparison.OrdinalIgnoreCase); - - if (!isValid && _logger.IsEnabled(LogLevel.Debug)) - { - _logger.Debug("Client security OTP JWT path mismatch from {ip} : {current} != {token}", - entity.TrustedRemoteIp, - requestPath, - unsafeUserPath.ToString() - ); - } - } - else - { - //Use path only - string requestPath = entity.Server.RequestUri.LocalPath; - - //Compare path only - isValid &= unsafeUserPath.Equals(requestPath, StringComparison.OrdinalIgnoreCase); - - if (!isValid && _logger.IsEnabled(LogLevel.Debug)) - { - _logger.Debug("Client security OTP JWT path mismatch from {ip} : {current} != {token}", - entity.TrustedRemoteIp, - requestPath, - unsafeUserPath.ToString() - ); - } - } - } - else - { - isValid = false; - } - } + return RsaClientDataEncryption.TryEncrypt(entity.PublicKey, data, outputBuffer); + } - return isValid; - } - catch (FormatException) - { - //we may catch the format exception for a malformatted jwt - _logger.Debug("Client security OTP JWT not valid from {ip}", entity.TrustedRemoteIp); - return false; - } - } - - #endregion - - #region Cookies + #endregion private void ExpireCookies(HttpEntity entity, bool force) { - //Do not force clear cookies (saves bandwidth) _statusCookie.ExpireCookie(entity, force); - _pubkeyCookie.ExpireCookie(entity, force); - } - - #endregion - - #region Data Encryption - - /// - /// Tries to encrypt the specified data using the specified public key - /// - /// A base64 encoded public key used to encrypt client data - /// Data to encrypt - /// The buffer to store encrypted data in - /// - /// The number of encrypted bytes written to the output buffer, - /// or false (0) if the operation failed, or if no credential is - /// specified. - /// - /// - private static ERRNO TryEncryptClientData(ReadOnlySpan base64PubKey, ReadOnlySpan data, Span outputBuffer) - { - if (base64PubKey.IsEmpty) - { - return ERRNO.E_FAIL; - } - - //Alloc a buffer for decoding the public key - using UnsafeMemoryHandle pubKeyBuffer = MemoryUtil.UnsafeAllocNearestPage(base64PubKey.Length, true); - - //Decode the public key - ERRNO pbkBytesWritten = VnEncoding.TryFromBase64Chars(base64PubKey, pubKeyBuffer.Span); - - //Try to encrypt the data - return pbkBytesWritten ? TryEncryptClientData(pubKeyBuffer.Span[..(int)pbkBytesWritten], data, outputBuffer) : ERRNO.E_FAIL; - } - - /// - /// Tries to encrypt the specified data using the specified public key - /// - /// The raw SKI public key - /// Data to encrypt - /// The buffer to store encrypted data in - /// - /// The number of encrypted bytes written to the output buffer, - /// or false (0) if the operation failed, or if no credential is - /// specified. - /// - /// - private static ERRNO TryEncryptClientData(ReadOnlySpan rawPubKey, ReadOnlySpan data, Span outputBuffer) - { - if (rawPubKey.IsEmpty) - { - return false; - } - - //Setup new empty rsa - using RSA rsa = RSA.Create(); - - //Import the public key - rsa.ImportSubjectPublicKeyInfo(rawPubKey, out _); - - //Encrypt data with OaepSha256 as configured in the browser - return rsa.TryEncrypt(data, outputBuffer, ClientEncryptonPadding, out int bytesWritten) ? bytesWritten : ERRNO.E_FAIL; - } - - #endregion - - - #region Client Encryption Key - - /* - * Stores the public key the client provided as a signed JWT a and sets - * it as a cookie in the user's browser. - * - * The signing key is randomly generated and stored in the client's session - * so it cannot "stolen" - * - * This was done mostly to save session storage space - */ - - private string SetPublicKeyCookie(HttpEntity entity, string pubKey) - { - //generate a random nonce - string nonce = RandomHash.GetRandomHex(PUB_KEY_JWT_NONCE_SIZE); - - //Generate signing key - using JsonWebToken jwt = new(); - //No header to write, we know the format - - //add the clients public key and set iat/exp - jwt.InitPayloadClaim() - .AddClaim("sub", pubKey) - .AddClaim("iat", entity.RequestedTimeUtc.ToUnixTimeSeconds()) - .AddClaim("exp", entity.RequestedTimeUtc.Add(_config.AuthorizationValidFor).ToUnixTimeSeconds()) - .AddClaim("nonce", nonce) - .CommitClaims(); - - //genreate random signing key to store in the user's session - byte[] signingKey = RandomHash.GetRandomBytes(_config.PubKeySigningKeySize); - - //Sign jwt - jwt.Sign(signingKey, ClientTokenHmacType); - - //base32 encode the signing key - string base32SigningKey = VnEncoding.ToBase32String(signingKey, false); - - //Zero signing key now were done using it - MemoryUtil.InitializeBlock(signingKey.AsSpan()); - - //Compile the jwt for the cookie value - string jwtValue = jwt.Compile(); - - _pubkeyCookie.SetCookie(entity, jwtValue); - - //Return the signing key - return base32SigningKey; - } - - private bool TryGetPublicKey(HttpEntity entity, [NotNullWhen(true)] out string? pubKey) - { - pubKey = null; - - //Check session is valid for use - if (!entity.Session.IsSet || entity.Session.IsNew || entity.Session.SessionType != SessionType.Web) - { - return false; - } - - //Get the jwt cookie - string? pubKeyJwt = _pubkeyCookie.GetCookie(entity); - - if (string.IsNullOrWhiteSpace(pubKeyJwt)) - { - return false; - } - - //Get the client signature - string? base32Sig = entity.Session[PUBLIC_KEY_SIG_KEY_ENTRY]; - - if (string.IsNullOrWhiteSpace(base32Sig)) - { - return false; - } - - try - { - - //Parse the jwt - using JsonWebToken jwt = JsonWebToken.Parse(pubKeyJwt); - - //Recover the signing key bytes - byte[] signingKey = VnEncoding.FromBase32String(base32Sig)!; - - //verify the client signature - if (!jwt.Verify(signingKey, ClientTokenHmacType)) - { - return false; - } - - //Erase the signing key bytes - MemoryUtil.InitializeBlock(signingKey); - - //Verify expiration - using JsonDocument payload = jwt.GetPayload(); - - //Get the expiration time from the jwt - long expTimeSec = payload.RootElement.GetProperty("exp").GetInt64(); - DateTimeOffset expired = DateTimeOffset.FromUnixTimeSeconds(expTimeSec); - - //Check if expired - if (expired.Ticks < entity.RequestedTimeUtc.Ticks) - { - return false; - } - - //Store the public key - pubKey = payload.RootElement.GetProperty("sub").GetString()!; - - return true; - } - catch (FormatException) - { - //JWT is invalid and could not be parsed - _logger.Debug("Client public key JWT or message body was not valid from {ip}", entity.TrustedRemoteIp); - } - - return false; - } - - #endregion - - - private sealed class AccountSecConfig : IOnConfigValidation - { - private static IValidator _validator { get; } = GetValidator(); - - private static IValidator GetValidator() - { - InlineValidator val = new(); - - //Cookie domain may be null/emmpty - val.RuleFor(c => c.CookieDomain); - - //Cookie path may be empty or null - val.RuleFor(c => c.CookiePath); - - val.RuleFor(c => c.AuthorizationValidFor) - .GreaterThan(TimeSpan.FromMinutes(1)) - .WithMessage("The authorization should be valid for at-least 1 minute"); - - val.RuleFor(C => C.ClientStatusCookieName) - .Length(1, 50) - .AlphaNumericOnly(); - - //header name is required, but not allowed to contain "illegal" chars - val.RuleFor(c => c.TokenHeaderName) - .NotEmpty() - .IllegalCharacters(); - - - val.RuleFor(c => c.PubKeyCookieName) - .Length(1, 50) - .IllegalCharacters(); - - //Signing keys are base32 encoded and stored in the session, we dont want to take up too much space - val.RuleFor(c => c.PubKeySigningKeySize) - .InclusiveBetween(8, 512) - .WithMessage("Your public key signing key should be between 8 and 512 bytes"); - - //Time difference doesnt need to be validated, it may be 0 to effectively disable it - val.RuleFor(c => c.SignedTokenTimeDiff); - - val.RuleFor(c => c.TokenKeySize) - .InclusiveBetween(8, 512) - .WithMessage("You should choose an OTP symmetric key size between 8 and 512 bytes"); - - val.RuleFor(c => c.WebSessionValidForSeconds) - .InclusiveBetween((uint)1, uint.MaxValue) - .WithMessage("You must specify a valid value for a web session timeout in seconds"); - - val.RuleForEach(c => c.AllowedOrigins) - .Matches(@"^https?://[a-z0-9\-\.]+$") - .WithMessage("The allowed origins must be valid http(s) urls"); - - return val; - } - - /// - /// The domain all authoization cookies will be set for - /// - [JsonPropertyName("cookie_domain")] - public string CookieDomain { get; set; } = ""; - - /// - /// The path all authorization cookies will be set for - /// - [JsonPropertyName("cookie_path")] - public string? CookiePath { get; set; } = "/"; - - /// - /// The amount if time new authorizations are valid for. This also - /// sets the duration of client cookies. - /// - [JsonIgnore] - internal TimeSpan AuthorizationValidFor { get; set; } = TimeSpan.FromMinutes(60); - - /// - /// The name of the cookie used to set the client's login status message - /// - [JsonPropertyName("status_cookie_name")] - public string ClientStatusCookieName { get; set; } = "li"; - - /// - /// The name of the header used by the client to send the one-time use - /// authorization token - /// - [JsonPropertyName("otp_header_name")] - public string TokenHeaderName { get; set; } = "X-Web-Token"; - - /// - /// The size (in bytes) of the symmetric key used - /// by the client to sign token messages - /// - [JsonPropertyName("otp_key_size")] - public int TokenKeySize { get; set; } = 64; - - /// - /// The name of the cookie that stores the user's signed public encryption key - /// - [JsonPropertyName("pubkey_cookie_name")] - public string PubKeyCookieName { get; set; } = "client_id"; - - /// - /// The size (in bytes) of the randomly generated key - /// used to sign the user's public key - /// - [JsonPropertyName("pubkey_signing_key_size")] - public int PubKeySigningKeySize { get; set; } = 32; - - /// - /// The allowed time difference in the issuance time of the client's signed - /// one time use tokens - /// - [JsonIgnore] - internal TimeSpan SignedTokenTimeDiff { get; set; } = TimeSpan.FromSeconds(30); - - /// - /// The amount of time a web session is valid for - /// - [JsonPropertyName("session_valid_for_sec")] - public uint WebSessionValidForSeconds { get; set; } = 3600; - - [JsonPropertyName("otp_time_diff_sec")] - public uint SigTokenTimeDifSeconds - { - get => (uint)SignedTokenTimeDiff.TotalSeconds; - set => SignedTokenTimeDiff = TimeSpan.FromSeconds(value); - } - - /// - /// Enforce that the client's token is only valid for the origin - /// it was read from. Will break sites hosted from multiple origins - /// - [JsonPropertyName("strict_origin")] - public bool EnforceSameOriginToken { get; set; } = true; - - /// - /// Enable/disable origin verification for the client's token - /// - [JsonIgnore] - public bool VerifyOrigin => AllowedOrigins != null && AllowedOrigins.Length > 0; - - /// - /// The list of origins that are allowed to send requests to the server - /// - [JsonPropertyName("allowed_origins")] - public string[]? AllowedOrigins { get; set; } - - /// - /// Enforce strict path checking for the client's token - /// - [JsonPropertyName("strict_path")] - public bool VerifyPath { get; set; } = true; - - void IOnConfigValidation.Validate() - { - //Validate the current instance - _validator.ValidateAndThrow(this); - } + _authManager.ExpireCookies(entity, force); } private sealed class EncryptedTokenAuthorization(string ClientAuthToken) : IClientAuthorization @@ -889,6 +267,5 @@ namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider /// public string GetClientAuthDataString() => ClientAuthToken; } - } } diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/ClientAuthData.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/ClientAuthData.cs new file mode 100644 index 0000000..c2369ec --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/ClientAuthData.cs @@ -0,0 +1,32 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts +* File: ClientAuthData.cs +* +* ClientAuthData.cs is part of VNLib.Plugins.Essentials.Accounts which is +* part of the larger VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Accounts 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.Accounts 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.Accounts.SecurityProvider +{ + readonly record struct ClientAuthData(string PublicKey, string ClientData) + { + public static ClientAuthData FromSecInfo(IClientSecInfo secInfo) => new(secInfo.PublicKey, secInfo.ClientId); + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/ClientWebAuthManager.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/ClientWebAuthManager.cs new file mode 100644 index 0000000..c4b0c26 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/ClientWebAuthManager.cs @@ -0,0 +1,554 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts +* File: ClientWebAuthManager.cs +* +* ClientWebAuthManager.cs is part of VNLib.Plugins.Essentials.Accounts which is +* part of the larger VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Accounts 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.Accounts 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/. +*/ + + +/* + * Implements the IAccountSecurityProvider interface to provide the shared + * service to the host application for securing user/account based connections + * via authorization. + * + * This system is technically configurable and optionally loadable + */ + +using System; +using System.Linq; +using System.Text.Json; + +using VNLib.Hashing; +using VNLib.Hashing.IdentityUtility; +using VNLib.Net.Http; +using VNLib.Utils; +using VNLib.Utils.Memory; +using VNLib.Utils.Logging; +using VNLib.Utils.Extensions; +using VNLib.Plugins.Essentials.Sessions; +using VNLib.Plugins.Essentials.Extensions; + +namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider +{ + internal sealed class ClientWebAuthManager(AccountSecConfig config, ILogProvider logger) + { + const string PUBLIC_KEY_SIG_KEY_ENTRY = "acnt.pbsk"; + const string LOGIN_TOKEN_ENTRY = "acnt.lgk"; + const int PUB_KEY_JWT_NONCE_SIZE = 16; + const HashAlg ClientTokenHmacType = HashAlg.SHA256; + + private readonly AccountSecConfig _config = config; + private readonly ILogProvider _logger = logger; + private readonly SingleCookieController _pubkeyCookie = new (config.PubKeyCookieName, config.AuthorizationValidFor) + { + Domain = config.CookieDomain, + Path = config.CookiePath, + SameSite = CookieSameSite.Strict, + HttpOnly = true, + Secure = true + }; + + /// + /// Destroys the connection's authorization and session + /// + /// The connection to destroy authorization data for + public void DestroyAuthorization(HttpEntity entity) + { + entity.Session.UserID = null!; + entity.Session.Privilages = 0; + + SetLoginToken(in entity.Session, null); + SetSigningKey(in entity.Session, null); + + _pubkeyCookie.ExpireCookie(entity, true); + } + + /// + /// Attempts to regenerate the authorization data for the connection + /// using existing credentials. This function does not test the + /// existing authorization status, it assumes the connection is + /// authorized already. + /// + /// The connection to re-authorize + /// The authentication data to return to the client + /// True if the connection could be reauthorized, false otherwise + public bool TryReAuthorizeConnection(HttpEntity entity, ref string clientAuthData) + { + ClientAuthData cad = default; + + if (!TryGetSavedAuthData(entity, ref cad)) + { + return false; + } + + //Generate the authorization data + clientAuthData = AuthorizeConnection(entity, ref cad); + + return true; + } + + /// + /// Expires all authorization related cookies for the connection + /// + /// The entity to clear cookies from + /// A value that indicates whether the cookie should be sent to the client even if it isnt set + public void ExpireCookies(HttpEntity entity, bool force) => _pubkeyCookie.ExpireCookie(entity, force); + + /// + /// Verifies the client's connection OTP token header to ensure the connection + /// is authorized. + /// + /// The connection to verify + /// True if the connection is authorized or false otherwise + public bool VerifyConnectionOTP(HttpEntity entity) + { + ClientAuthData cad = default; + + /* + * When calling TryGetSavedAuthData() it ensures the client + * has a valid, signed, client auth data in its session. + * + * Second we can verify the client's OTP token sent in + * a header to ensure the client. + * + * The header should be a valid JWT signed with the shared + * key sent during authorization + */ + return TryGetSavedAuthData(entity, ref cad) && VerifyConnectionOTPInternal(entity); + } + + /// + /// Determines if the connection has minimal auhtorization and should be + /// able to check for a higher level of authorization + /// + /// The connection to verify + /// A value that indicates if the connection has a minimal authorization status + public bool HasMinimalAuthorization(HttpEntity entity) + { + ClientAuthData cad = default; + return TryGetSavedAuthData(entity, ref cad); + } + + /// + /// Upgrades the desired connection using the provided security information + /// + /// The connection to upgrade + /// The client's security information used for the upgrade + /// The encoded data to return to the client + public string AuthorizeConnection(HttpEntity entity, ref readonly ClientAuthData authData) + { + string serverToken = string.Empty; + string clientToken = string.Empty; + string encodedSigKey = string.Empty; + string pubkeyCookieValue = string.Empty; + + //Generate the authorization data + GenerateToken(in authData, ref serverToken, ref clientToken); + GenerateClientAuthCookie(in authData, entity, ref pubkeyCookieValue, ref encodedSigKey); + + //Upgrade the connection and session + SetLoginToken(in entity.Session, serverToken); + SetSigningKey(in entity.Session, encodedSigKey); + SetPubkeyCookie(entity, pubkeyCookieValue); + + return clientToken; + } + + /// + /// Attempts to recover the client's encryption public key from the connection + /// used to encrypt client data + /// + /// The connection to recover the public key from + /// A reference to the public key string + /// A value that indicates if the public key could be recovered + public bool TryGetEncryptionPubkey(HttpEntity entity, ref string pubkey) + { + ClientAuthData cad = default; + + if (!TryGetSavedAuthData(entity, ref cad)) + { + return false; + } + + pubkey = cad.PublicKey; + return true; + } + + private void GenerateToken(ref readonly ClientAuthData secInfo, ref string serverToken, ref string clientToken) + { + //Alloc buffer for encode/decode + using UnsafeMemoryHandle buffer = MemoryUtil.UnsafeAllocNearestPage(4000, true); + try + { + Span secretBuffer = buffer.Span[.._config.TokenKeySize]; + Span outputBuffer = buffer.Span[_config.TokenKeySize..]; + + //Computes a random shared key + RandomHash.GetRandomBytes(secretBuffer); + + ERRNO bytesEncrypted = RsaClientDataEncryption.TryEncrypt(secInfo.PublicKey, secretBuffer, outputBuffer); + + //Encyrpt the secret key to send to client + if (!bytesEncrypted) + { + throw new InternalBufferTooSmallException("The internal buffer used to store the encrypted token is too small"); + } + + //Client token is the encrypted secret key + clientToken = Convert.ToBase64String(outputBuffer[..(int)bytesEncrypted]); + + //Encode base64 url safe + serverToken = VnEncoding.ToBase64UrlSafeString(secretBuffer, false); + } + finally + { + //Zero buffer when complete + MemoryUtil.InitializeBlock(ref buffer.GetReference(), buffer.GetIntLength()); + } + } + + /* + * Stores the public key the client provided as a signed JWT a and sets + * it as a cookie in the user's browser. + * + * The signing key is randomly generated and stored in the client's session + * so it cannot "stolen" + * + * This was done mostly to save session storage space + */ + + private void GenerateClientAuthCookie(ref readonly ClientAuthData secInfo, HttpEntity entity, ref string cookieValue, ref string encodedSigKey) + { + //generate a random nonce + string nonce = RandomHash.GetRandomHex(PUB_KEY_JWT_NONCE_SIZE); + + //Generate signing key + using JsonWebToken jwt = new(); + //No header to write, we know the format + + //add the clients public key and set iat/exp + jwt.InitPayloadClaim() + .AddClaim("sub", secInfo.ClientData) + .AddClaim("iat", entity.RequestedTimeUtc.ToUnixTimeSeconds()) + .AddClaim("exp", entity.RequestedTimeUtc.Add(_config.AuthorizationValidFor).ToUnixTimeSeconds()) + .AddClaim("nonce", nonce) + .AddClaim("aud", entity.Server.RequestUri.GetLeftPart(UriPartial.Authority)) + .AddClaim("pk", secInfo.PublicKey) + .CommitClaims(); + + //genreate random signing key to store in the user's session + byte[] signingKey = RandomHash.GetRandomBytes(_config.PubKeySigningKeySize); + + //Sign jwt + jwt.Sign(signingKey, ClientTokenHmacType); + + //base32 encode the signing key + encodedSigKey = VnEncoding.ToBase32String(signingKey, false); + + //Compile the jwt for the cookie value + cookieValue = jwt.Compile(); + + //Zero signing key now were done using it + MemoryUtil.InitializeBlock(signingKey); + } + + private bool TryGetSavedAuthData(HttpEntity entity, ref ClientAuthData authData) + { + //Check session is valid for use + if (!IsSessionValid(in entity.Session)) + { + return false; + } + + //Get the jwt cookie + string? pubKeyJwt = _pubkeyCookie.GetCookie(entity); + + if (string.IsNullOrWhiteSpace(pubKeyJwt)) + { + return false; + } + + //Get the client signature + string? base32Sig = GetSigningKey(in entity.Session); + + if (string.IsNullOrWhiteSpace(base32Sig)) + { + return false; + } + + try + { + + //Parse the jwt + using JsonWebToken jwt = JsonWebToken.Parse(pubKeyJwt); + + //Recover the signing key bytes + byte[] signingKey = VnEncoding.FromBase32String(base32Sig)!; + + //verify the client signature + if (!jwt.Verify(signingKey, ClientTokenHmacType)) + { + return false; + } + + MemoryUtil.InitializeBlock(signingKey); + + using JsonDocument payload = jwt.GetPayload(); + + /* + * If the signature is valid we should be able to safely recover the + * propertes we need. We should be able to assume all servers in the + * network assign the same properties to the jwt + */ + + string aud = payload.RootElement.GetProperty("aud").GetString()!; + long exp = payload.RootElement.GetProperty("exp").GetInt64(); + + //Check the audience matches the authority of the connection + if (!string.Equals(aud, entity.Server.RequestUri.GetLeftPart(UriPartial.Authority), StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + //Check the expiration time + if (exp < entity.RequestedTimeUtc.ToUnixTimeSeconds()) + { + return false; + } + + authData = new() + { + ClientData = payload.RootElement.GetProperty("sub").GetString()!, + PublicKey = payload.RootElement.GetProperty("pk").GetString()! + }; + + return true; + } + catch (FormatException) + { + //JWT is invalid and could not be parsed + _logger.Debug("Client public key JWT or message body was not valid from {ip}", entity.TrustedRemoteIp); + } + + return false; + } + + private bool VerifyConnectionOTPInternal(HttpEntity entity) + { + //Get the token from the client header, the client should always sent this + string? signedMessage = entity.Server.Headers[_config.TokenHeaderName]; + + //Make sure a session is loaded + if (!entity.Session.IsSet || entity.Session.IsNew || string.IsNullOrWhiteSpace(signedMessage)) + { + return false; + } + + //Get the stored shared symetric key + string? sharedKey = GetLoginToken(in entity.Session); + if (string.IsNullOrWhiteSpace(sharedKey)) + { + return false; + } + + /* + * The clients signed message is a json web token that includes basic information + * Clients may send bad data, so we should swallow exceptions and return false + */ + + try + { + bool isValid = true; + + //Parse the client jwt signed message + using JsonWebToken jwt = JsonWebToken.Parse(signedMessage); + + using (UnsafeMemoryHandle decodeBuffer = MemoryUtil.UnsafeAllocNearestPage(_config.TokenKeySize, true)) + { + //Recover the key from base32 + ERRNO count = VnEncoding.Base64UrlDecode(sharedKey, decodeBuffer.Span); + + if (!count) + { + return false; + } + + //Verity the jwt against the store symmetric key + isValid &= jwt.Verify(decodeBuffer.AsSpan(0, count), ClientTokenHmacType); + } + + //Get the message payload + using JsonDocument data = jwt.GetPayload(); + + //Get iat time + if (data.RootElement.TryGetProperty("iat", out JsonElement iatEl) + && iatEl.ValueKind == JsonValueKind.Number) + { + //Try to get iat in unint seconds + isValid &= iatEl.TryGetInt64(out long iatSec); + + //Recover dto from unix seconds regardless of int success + DateTimeOffset iat = DateTimeOffset.FromUnixTimeSeconds(iatSec); + + //Verify iat against current time with allowed disparity + isValid &= iat.Add(_config.SignedTokenTimeDiff) > entity.RequestedTimeUtc; + + //Message is too far into the future! + isValid &= iat.Subtract(_config.SignedTokenTimeDiff) < entity.RequestedTimeUtc; + } + else + { + //No time element provided + isValid = false; + } + + if (_config.VerifyOrigin) + { + //Check the audience matches the request uri + if (data.RootElement.TryGetProperty("aud", out JsonElement tokenOriginEl) + && tokenOriginEl.ValueKind == JsonValueKind.String) + { + string? unsafeUserOrigin = tokenOriginEl.GetString(); + + if (string.IsNullOrWhiteSpace(unsafeUserOrigin)) + { + isValid = false; + } + else if (_config.EnforceSameOriginToken) + { + //enforce strict origin checking + string strictOrigin = entity.Server.RequestUri.GetLeftPart(UriPartial.Authority); + isValid &= string.Equals(unsafeUserOrigin, strictOrigin, StringComparison.OrdinalIgnoreCase); + + if (!isValid) + { + _logger.Debug("Client security OTP JWT origin mismatch from {ip} : strict origin {current} != {token}", + entity.TrustedRemoteIp, + strictOrigin, + unsafeUserOrigin + ); + } + } + else + { + //Verify against allow list + isValid &= _config.AllowedOrigins!.Contains(unsafeUserOrigin, StringComparer.OrdinalIgnoreCase); + + if (!isValid) + { + _logger.Debug("CST origin not allowed {ip} : {token}", + entity.TrustedRemoteIp, + unsafeUserOrigin + ); + } + } + } + else + { + isValid = false; + } + } + + if (_config.VerifyPath) + { + //Check the subject (path) matches the request uri + if (data.RootElement.TryGetProperty("path", out JsonElement tokenPathEl) + && tokenPathEl.ValueKind == JsonValueKind.String) + { + + ReadOnlySpan unsafeUserPath = tokenPathEl.GetString(); + /* + * Query parameters are optional, so we need to check if the path contains a + * query, if so we can compare the entire path and query, otherwise we need to + * compare the path only + */ + if (unsafeUserPath.Contains("?", StringComparison.OrdinalIgnoreCase)) + { + //Compare path and query when possible + string requestPath = entity.Server.RequestUri.PathAndQuery; + + isValid &= unsafeUserPath.Equals(requestPath, StringComparison.OrdinalIgnoreCase); + + if (!isValid && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.Debug("Client security OTP JWT path mismatch from {ip} : {current} != {token}", + entity.TrustedRemoteIp, + requestPath, + unsafeUserPath.ToString() + ); + } + } + else + { + //Use path only + string requestPath = entity.Server.RequestUri.LocalPath; + + //Compare path only + isValid &= unsafeUserPath.Equals(requestPath, StringComparison.OrdinalIgnoreCase); + + if (!isValid && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.Debug("Client security OTP JWT path mismatch from {ip} : {current} != {token}", + entity.TrustedRemoteIp, + requestPath, + unsafeUserPath.ToString() + ); + } + } + } + else + { + isValid = false; + } + } + + return isValid; + } + catch (FormatException) + { + //we may catch the format exception for a malformatted jwt + _logger.Debug("Client security OTP JWT not valid from {ip}", entity.TrustedRemoteIp); + return false; + } + } + + #region helperFunctions + + /// + /// A non-secure check to determine if the connection has been elevated + /// + /// The session to check the status of + /// True of the session might be elevated + public static bool IsSessionElevated(ref readonly SessionInfo session) + => string.IsNullOrWhiteSpace(GetLoginToken(in session)) == false; + + private void SetPubkeyCookie(HttpEntity entity, string value) => _pubkeyCookie.SetCookie(entity, value); + + private static void SetSigningKey(ref readonly SessionInfo session, string? value) => session[PUBLIC_KEY_SIG_KEY_ENTRY] = value!; + private static void SetLoginToken(ref readonly SessionInfo session, string? value) => session[LOGIN_TOKEN_ENTRY] = value!; + + private static string? GetSigningKey(ref readonly SessionInfo session) => session[PUBLIC_KEY_SIG_KEY_ENTRY]; + private static string? GetLoginToken(ref readonly SessionInfo session) => session[LOGIN_TOKEN_ENTRY]; + + private static bool IsSessionValid(ref readonly SessionInfo session) => session.IsSet && !session.IsNew && session.SessionType == SessionType.Web; + + #endregion + } +} diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/RsaClientDataEncryption.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/RsaClientDataEncryption.cs new file mode 100644 index 0000000..89aaf73 --- /dev/null +++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/SecurityProvider/RsaClientDataEncryption.cs @@ -0,0 +1,98 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Essentials.Accounts +* File: RsaClientDataEncryption.cs +* +* RsaClientDataEncryption.cs is part of VNLib.Plugins.Essentials.Accounts +* which is part of the larger VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Essentials.Accounts 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.Accounts 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.Utils; +using VNLib.Utils.Memory; + +namespace VNLib.Plugins.Essentials.Accounts.SecurityProvider +{ + internal static class RsaClientDataEncryption + { + /// + /// The client data encryption padding. Client library must match this padding + /// + public static readonly RSAEncryptionPadding ClientEncryptonPadding = RSAEncryptionPadding.OaepSHA256; + + /// + /// Tries to encrypt the specified data using the specified public key + /// + /// A base64 encoded public key used to encrypt client data + /// Data to encrypt + /// The buffer to store encrypted data in + /// + /// The number of encrypted bytes written to the output buffer, + /// or false (0) if the operation failed, or if no credential is + /// specified. + /// + /// + public static ERRNO TryEncrypt(ReadOnlySpan base64PubKey, ReadOnlySpan data, Span outputBuffer) + { + if (base64PubKey.IsEmpty) + { + return ERRNO.E_FAIL; + } + + //Alloc a buffer for decoding the public key + using UnsafeMemoryHandle pubKeyBuffer = MemoryUtil.UnsafeAllocNearestPage(base64PubKey.Length, true); + + //Decode the public key + ERRNO pbkBytesWritten = VnEncoding.TryFromBase64Chars(base64PubKey, pubKeyBuffer.Span); + + //Try to encrypt the data + return pbkBytesWritten ? TryEncrypt(pubKeyBuffer.Span[..(int)pbkBytesWritten], data, outputBuffer) : ERRNO.E_FAIL; + } + + /// + /// Tries to encrypt the specified data using the specified public key + /// + /// The raw SKI public key + /// Data to encrypt + /// The buffer to store encrypted data in + /// + /// The number of encrypted bytes written to the output buffer, + /// or false (0) if the operation failed, or if no credential is + /// specified. + /// + /// + public static ERRNO TryEncrypt(ReadOnlySpan rawPubKey, ReadOnlySpan data, Span outputBuffer) + { + if (rawPubKey.IsEmpty) + { + return false; + } + + //Setup new empty rsa + using RSA rsa = RSA.Create(); + + //Import the public key + rsa.ImportSubjectPublicKeyInfo(rawPubKey, out _); + + //Encrypt data with OaepSha256 as configured in the browser + return rsa.TryEncrypt(data, outputBuffer, ClientEncryptonPadding, out int bytesWritten) ? bytesWritten : ERRNO.E_FAIL; + } + } +} 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 7d30bc4..a9c207a 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 @@ -53,10 +53,10 @@ - - - - + + + + diff --git a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/PortalsEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/PortalsEndpoint.cs index 4e5f867..177988f 100644 --- a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/PortalsEndpoint.cs +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/PortalsEndpoint.cs @@ -23,44 +23,63 @@ */ using System; +using System.Net; using System.Linq; +using System.Text.Json; using System.Collections.Generic; +using VNLib.Utils.IO; +using VNLib.Net.Http; using VNLib.Plugins.Essentials.Endpoints; using VNLib.Plugins.Extensions.Loading; - namespace VNLib.Plugins.Essentials.Auth.Social { [ConfigurationName("portals")] - internal sealed class PortalsEndpoint : UnprotectedWebEndpoint + internal sealed class PortalsEndpoint : UnprotectedWebEndpoint, IDisposable { - private PortalDefJson[] _portals; + private readonly VnMemoryStream _portals; public PortalsEndpoint(PluginBase plugin, IConfigScope config) { string path = config.GetRequiredProperty("path", p => p.GetString()!); InitPathAndLog(path, plugin.Log); - //Empty array by default - _portals = []; + _portals = new VnMemoryStream(); } public void SetPortals(IEnumerable portals) { //Convert to json - _portals = portals.Select(p => new PortalDefJson + PortalDefJson[] jsn = portals.Select(p => new PortalDefJson { id = p.PortalId, login = p.LoginEndpoint.Path, logout = p.LogoutEndpoint?.Path, + icon = p.Base64Icon }).ToArray(); + + //Serialize portals array to memory stream + JsonSerializer.Serialize(_portals, jsn); + + //Set memory stream to readonly so shallow copy can be returned + _ = VnMemoryStream.CreateReadonly(_portals); } protected override VfReturnType Get(HttpEntity entity) { - //return portals array as json - return VirtualOkJson(entity, _portals); + //return portals array, pre-serialized + return VirtualClose( + entity, + HttpStatusCode.OK, + ContentType.Json, + _portals!.GetReadonlyShallowCopy() + ); + } + + void IDisposable.Dispose() + { + _portals?.Dispose(); } private sealed class PortalDefJson @@ -70,6 +89,8 @@ namespace VNLib.Plugins.Essentials.Auth.Social public string? login { get; set; } public string? logout { get; set; } + + public string? icon { get; set; } } } } \ 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 index 3fe6ddf..db5ed6e 100644 --- a/plugins/VNLib.Plugins.Essentials.Auth.Social/src/SocialOAuthPortal.cs +++ b/plugins/VNLib.Plugins.Essentials.Auth.Social/src/SocialOAuthPortal.cs @@ -30,5 +30,6 @@ namespace VNLib.Plugins.Essentials.Auth.Social /// 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); + /// Optional base64 image icon src for the client to load and display + public record SocialOAuthPortal(string PortalId, IEndpoint LoginEndpoint, IEndpoint? LogoutEndpoint, string? Base64Icon); } \ No newline at end of file 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 index dd6132c..ef49728 100644 --- 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 @@ -50,10 +50,10 @@ - - - - + + + + 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 aa3fa56..afd278a 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 @@ -52,9 +52,9 @@ - - - + + + diff --git a/plugins/providers/VNLib.Plugins.Essentials.Auth.Auth0/src/Auth0Portal.cs b/plugins/providers/VNLib.Plugins.Essentials.Auth.Auth0/src/Auth0Portal.cs index 2fcc477..a698806 100644 --- a/plugins/providers/VNLib.Plugins.Essentials.Auth.Auth0/src/Auth0Portal.cs +++ b/plugins/providers/VNLib.Plugins.Essentials.Auth.Auth0/src/Auth0Portal.cs @@ -33,7 +33,7 @@ namespace VNLib.Plugins.Essentials.Auth.Auth0 [ServiceExport] [ConfigurationName(ConfigKey)] - public sealed class Auth0Portal(PluginBase plugin) : IOAuthProvider + public sealed class Auth0Portal(PluginBase plugin, IConfigScope config) : IOAuthProvider { internal const string ConfigKey = "auth0"; @@ -43,12 +43,15 @@ namespace VNLib.Plugins.Essentials.Auth.Auth0 /// public SocialOAuthPortal[] GetPortals() { + string? base64IconData = config.GetValueOrDefault("icon", p => p.GetString()!, null); + //Return the Auth0 portal return [ new SocialOAuthPortal( ConfigKey, _loginEndpoint, - _logoutEndpoint + _logoutEndpoint, + base64IconData ) ]; diff --git a/plugins/providers/VNLib.Plugins.Essentials.Auth.Discord/src/DiscordPortal.cs b/plugins/providers/VNLib.Plugins.Essentials.Auth.Discord/src/DiscordPortal.cs index ed3940f..01bfe8d 100644 --- a/plugins/providers/VNLib.Plugins.Essentials.Auth.Discord/src/DiscordPortal.cs +++ b/plugins/providers/VNLib.Plugins.Essentials.Auth.Discord/src/DiscordPortal.cs @@ -35,7 +35,7 @@ namespace VNLib.Plugins.Essentials.Auth.Discord [ServiceExport] [ConfigurationName(ConfigKey)] - public sealed class DiscordPortal(PluginBase plugin) : IOAuthProvider + public sealed class DiscordPortal(PluginBase plugin, IConfigScope config) : IOAuthProvider { internal const string ConfigKey = "discord"; @@ -44,12 +44,15 @@ namespace VNLib.Plugins.Essentials.Auth.Discord /// public SocialOAuthPortal[] GetPortals() { + string? base64IconData = config.GetValueOrDefault("icon", p => p.GetString()!, null); + //Return the Discord portal return [ new SocialOAuthPortal( ConfigKey, _loginEndpoint, - null + null, + base64IconData ) ]; diff --git a/plugins/providers/VNLib.Plugins.Essentials.Auth.Github/src/GithubPortal.cs b/plugins/providers/VNLib.Plugins.Essentials.Auth.Github/src/GithubPortal.cs index 946531d..c438bfe 100644 --- a/plugins/providers/VNLib.Plugins.Essentials.Auth.Github/src/GithubPortal.cs +++ b/plugins/providers/VNLib.Plugins.Essentials.Auth.Github/src/GithubPortal.cs @@ -33,7 +33,7 @@ namespace VNLib.Plugins.Essentials.Auth.Github [ServiceExport] [ConfigurationName(ConfigKey)] - public sealed class GithubPortal(PluginBase plugin) : IOAuthProvider + public sealed class GithubPortal(PluginBase plugin, IConfigScope config) : IOAuthProvider { internal const string ConfigKey = "github"; @@ -42,12 +42,15 @@ namespace VNLib.Plugins.Essentials.Auth.Github /// public SocialOAuthPortal[] GetPortals() { + string? base64IconData = config.GetValueOrDefault("icon", p => p.GetString()!, null); + //Return the github portal return [ new SocialOAuthPortal( ConfigKey, _loginEndpoint, - null + null, + base64IconData ) ]; -- cgit