aboutsummaryrefslogtreecommitdiff
path: root/Libs
diff options
context:
space:
mode:
authorLibravatar vman <public@vaughnnugent.com>2022-10-30 02:28:12 -0400
committerLibravatar vman <public@vaughnnugent.com>2022-10-30 02:28:12 -0400
commita8510fb835dcc5e1142d700164ce5a4bd44e1a25 (patch)
tree28caab320f777a384cb6883b68dd999cdc8c0a3f /Libs
Add project files.
Diffstat (limited to 'Libs')
-rw-r--r--Libs/VNLib.Plugins.Essentials.Sessions.OAuth/Endpoints/AccessTokenEndpoint.cs109
-rw-r--r--Libs/VNLib.Plugins.Essentials.Sessions.OAuth/Endpoints/RevocationEndpoint.cs31
-rw-r--r--Libs/VNLib.Plugins.Essentials.Sessions.OAuth/IOauthSessionIdFactory.cs38
-rw-r--r--Libs/VNLib.Plugins.Essentials.Sessions.OAuth/O2SessionProviderEntry.cs117
-rw-r--r--Libs/VNLib.Plugins.Essentials.Sessions.OAuth/OAuth2Session.cs107
-rw-r--r--Libs/VNLib.Plugins.Essentials.Sessions.OAuth/OAuth2SessionIdProvider.cs109
-rw-r--r--Libs/VNLib.Plugins.Essentials.Sessions.OAuth/OAuth2SessionProvider.cs183
-rw-r--r--Libs/VNLib.Plugins.Essentials.Sessions.OAuth/OAuth2TokenResult.cs13
-rw-r--r--Libs/VNLib.Plugins.Essentials.Sessions.OAuth/OauthTokenResponseMessage.cs20
-rw-r--r--Libs/VNLib.Plugins.Essentials.Sessions.OAuth/TokenAndSessionIdResult.cs20
-rw-r--r--Libs/VNLib.Plugins.Essentials.Sessions.OAuth/VNLib.Plugins.Essentials.Sessions.OAuth.csproj28
-rw-r--r--Libs/VNLib.Plugins.Essentials.Sessions.Runtime/IRuntimeSessionProvider.cs26
-rw-r--r--Libs/VNLib.Plugins.Essentials.Sessions.Runtime/ISessionIdFactory.cs17
-rw-r--r--Libs/VNLib.Plugins.Essentials.Sessions.Runtime/VNLib.Plugins.Essentials.Sessions.Runtime.csproj40
-rw-r--r--Libs/VNLib.Plugins.Essentials.Sessions.Runtime/VnCacheClient.cs162
-rw-r--r--Libs/VNLib.Plugins.Essentials.Sessions.VNCache/IWebSessionIdFactory.cs26
-rw-r--r--Libs/VNLib.Plugins.Essentials.Sessions.VNCache/VNLib.Plugins.Essentials.Sessions.VNCache.csproj47
-rw-r--r--Libs/VNLib.Plugins.Essentials.Sessions.VNCache/WebSession.cs103
-rw-r--r--Libs/VNLib.Plugins.Essentials.Sessions.VNCache/WebSessionIdFactoryImpl.cs96
-rw-r--r--Libs/VNLib.Plugins.Essentials.Sessions.VNCache/WebSessionProvider.cs122
-rw-r--r--Libs/VNLib.Plugins.Essentials.Sessions.VNCache/WebSessionProviderEntry.cs101
-rw-r--r--Libs/VNLib.Plugins.Essentials.Sessions/MemorySession.cs107
-rw-r--r--Libs/VNLib.Plugins.Essentials.Sessions/MemorySessionConfig.cs33
-rw-r--r--Libs/VNLib.Plugins.Essentials.Sessions/MemorySessionEntrypoint.cs64
-rw-r--r--Libs/VNLib.Plugins.Essentials.Sessions/MemorySessionStore.cs127
-rw-r--r--Libs/VNLib.Plugins.Essentials.Sessions/VNLib.Plugins.Essentials.Sessions.Memory.csproj43
-rw-r--r--Libs/VNLib.Plugins.Essentials.Sessions/VNLib.Plugins.Essentials.Sessions.Memory.xml75
-rw-r--r--Libs/VNLib.Plugins.Sessions.Cache.Client/Exceptions/MessageTooLargeException.cs27
-rw-r--r--Libs/VNLib.Plugins.Sessions.Cache.Client/Exceptions/SessionStatusException.cs19
-rw-r--r--Libs/VNLib.Plugins.Sessions.Cache.Client/Exceptions/SessionUpdateFailedException.cs17
-rw-r--r--Libs/VNLib.Plugins.Sessions.Cache.Client/RemoteSession.cs173
-rw-r--r--Libs/VNLib.Plugins.Sessions.Cache.Client/SessionCacheClient.cs159
-rw-r--r--Libs/VNLib.Plugins.Sessions.Cache.Client/VNLib.Plugins.Sessions.Cache.Client.csproj33
-rw-r--r--Libs/VNLib.Plugins.Sessions.Cache.Client/VNLib.Plugins.Sessions.Cache.Client.xml69
34 files changed, 2461 insertions, 0 deletions
diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/Endpoints/AccessTokenEndpoint.cs b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/Endpoints/AccessTokenEndpoint.cs
new file mode 100644
index 0000000..271328a
--- /dev/null
+++ b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/Endpoints/AccessTokenEndpoint.cs
@@ -0,0 +1,109 @@
+using System;
+using System.Net;
+
+using VNLib.Utils.Memory;
+using VNLib.Plugins.Essentials.Oauth;
+using VNLib.Plugins.Essentials.Extensions;
+using VNLib.Plugins.Extensions.Validation;
+using VNLib.Plugins.Extensions.Loading;
+using VNLib.Plugins.Extensions.Loading.Sql;
+
+
+namespace VNLib.Plugins.Essentials.Sessions.OAuth.Endpoints
+{
+ /// <summary>
+ /// Grants authorization to OAuth2 clients to protected resources
+ /// with access tokens
+ /// </summary>
+ internal sealed class AccessTokenEndpoint : ResourceEndpointBase
+ {
+
+ private readonly Lazy<ITokenManager> TokenStore;
+ private readonly Applications Applications;
+
+ //override protection settings to allow most connections to authenticate
+ protected override ProtectionSettings EndpointProtectionSettings { get; } = new()
+ {
+ BrowsersOnly = false,
+ SessionsRequired = false,
+ VerifySessionCors = false
+ };
+
+ public AccessTokenEndpoint(string path, PluginBase pbase, Lazy<ITokenManager> tokenStore)
+ {
+ InitPathAndLog(path, pbase.Log);
+ TokenStore = tokenStore;
+ Applications = new(pbase.GetContextOptions(), pbase.GetPasswords());
+ }
+
+ protected override async ValueTask<VfReturnType> PostAsync(HttpEntity entity)
+ {
+ //Check for refresh token
+ if (entity.RequestArgs.IsArgumentSet("grant_type", "refresh_token"))
+ {
+ //process a refresh token
+ }
+ //Check for grant_type parameter from the request body
+ if (!entity.RequestArgs.IsArgumentSet("grant_type", "client_credentials"))
+ {
+ entity.CloseResponseError(HttpStatusCode.BadRequest, ErrorType.InvalidClient, "Invalid grant type");
+ //Default to bad request
+ return VfReturnType.VirtualSkip;
+ }
+ //Get client id and secret (and make sure theyre not empty
+ if (entity.RequestArgs.TryGetNonEmptyValue("client_id", out string? clientId) &&
+ entity.RequestArgs.TryGetNonEmptyValue("client_secret", out string? secret))
+ {
+ if (!ValidatorExtensions.OnlyAlphaNumRegx.IsMatch(clientId))
+ {
+ //respond with error message
+ entity.CloseResponseError(HttpStatusCode.UnprocessableEntity, ErrorType.InvalidRequest, "Invalid client_id");
+ return VfReturnType.VirtualSkip;
+ }
+ if (!ValidatorExtensions.OnlyAlphaNumRegx.IsMatch(secret))
+ {
+ //respond with error message
+ entity.CloseResponseError(HttpStatusCode.UnprocessableEntity, ErrorType.InvalidRequest, "Invalid client_secret");
+ return VfReturnType.VirtualSkip;
+ }
+ //Convert the clientid and secret to lowercase
+ clientId = clientId.ToLower();
+ secret = secret.ToLower();
+ //Convert secret to private string
+ PrivateString secretPv = new(secret, false);
+ //Get the application from apps store
+ UserApplication? app = await Applications.VerifyAppAsync(clientId, secretPv);
+ if (app == null)
+ {
+ //App was not found or the credentials do not match
+ entity.CloseResponseError(HttpStatusCode.UnprocessableEntity, ErrorType.InvalidClient, "The client credentials are invalid");
+ return VfReturnType.VirtualSkip;
+ }
+ //Create a new session
+ IOAuth2TokenResult? result = await TokenStore.Value.CreateAccessTokenAsync(entity.Entity, app, entity.EventCancellation);
+ if (result == null)
+ {
+ entity.CloseResponseError(HttpStatusCode.ServiceUnavailable, ErrorType.TemporarilyUnabavailable, "You have reached the maximum number of valid tokens for this application");
+ return VfReturnType.VirtualSkip;
+ }
+ //Create the new response message
+ OauthTokenResponseMessage tokenMessage = new()
+ {
+ AccessToken = result.AccessToken,
+
+ //set expired as seconds in int form
+ Expires = result.ExpiresSeconds,
+ RefreshToken = result.RefreshToken,
+ TokenType = result.TokenType
+ };
+ //Respond with the token message
+ entity.CloseResponseJson(HttpStatusCode.OK, tokenMessage);
+ return VfReturnType.VirtualSkip;
+
+ }
+ //respond with error message
+ entity.CloseResponseError(HttpStatusCode.UnprocessableEntity, ErrorType.InvalidClient, "The request was missing required arguments");
+ return VfReturnType.VirtualSkip;
+ }
+ }
+} \ No newline at end of file
diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/Endpoints/RevocationEndpoint.cs b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/Endpoints/RevocationEndpoint.cs
new file mode 100644
index 0000000..095e07e
--- /dev/null
+++ b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/Endpoints/RevocationEndpoint.cs
@@ -0,0 +1,31 @@
+using System;
+using System.Text.Json;
+
+using VNLib.Plugins.Essentials.Oauth;
+using VNLib.Plugins.Extensions.Loading.Configuration;
+
+namespace VNLib.Plugins.Essentials.Sessions.OAuth.Endpoints
+{
+ /// <summary>
+ /// An OAuth2 authorized endpoint for revoking the access token
+ /// held by the current connection
+ /// </summary>
+ [ConfigurationName("oauth2")]
+ internal class RevocationEndpoint : O2EndpointBase
+ {
+
+ public RevocationEndpoint(PluginBase pbase, IReadOnlyDictionary<string, JsonElement> config)
+ {
+ string? path = config["revocation_path"].GetString();
+ InitPathAndLog(path, pbase.Log);
+ }
+
+ protected override VfReturnType Post(HttpEntity entity)
+ {
+ //Revoke the access token, by invalidating it
+ entity.Session.Invalidate();
+ entity.CloseResponse(System.Net.HttpStatusCode.OK);
+ return VfReturnType.VirtualSkip;
+ }
+ }
+}
diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/IOauthSessionIdFactory.cs b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/IOauthSessionIdFactory.cs
new file mode 100644
index 0000000..9a65d62
--- /dev/null
+++ b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/IOauthSessionIdFactory.cs
@@ -0,0 +1,38 @@
+using System;
+
+using VNLib.Net.Http;
+using VNLib.Plugins.Essentials.Oauth;
+using VNLib.Plugins.Sessions.Cache.Client;
+
+namespace VNLib.Plugins.Essentials.Sessions.OAuth
+{
+ public interface IOauthSessionIdFactory : ISessionIdFactory
+ {
+ /// <summary>
+ /// The maxium number of tokens allowed to be created per OAuth application
+ /// </summary>
+ int MaxTokensPerApp { get; }
+ /// <summary>
+ /// Allows for custom configuration of the newly created session and
+ /// the <see cref="IHttpEvent"/> its attached to
+ /// </summary>
+ /// <param name="session">The newly created session</param>
+ /// <param name="app">The application associated with the session</param>
+ /// <param name="entity">The http event that generated the new session</param>
+ void InitNewSession(RemoteSession session, UserApplication app, IHttpEvent entity);
+ /// <summary>
+ /// The time a session is valid for
+ /// </summary>
+ TimeSpan SessionValidFor { get; }
+ /// <summary>
+ /// Called when the session provider wishes to generate a new session
+ /// and required credential information to generate the new session
+ /// </summary>
+ /// <returns>The information genreated for the news ession</returns>
+ TokenAndSessionIdResult GenerateTokensAndId();
+ /// <summary>
+ /// The type of token this session provider generates
+ /// </summary>
+ string TokenType { get; }
+ }
+}
diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/O2SessionProviderEntry.cs b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/O2SessionProviderEntry.cs
new file mode 100644
index 0000000..e15c6e4
--- /dev/null
+++ b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/O2SessionProviderEntry.cs
@@ -0,0 +1,117 @@
+
+using System.Text.Json;
+
+using VNLib.Net.Http;
+using VNLib.Utils.Logging;
+using VNLib.Utils.Extensions;
+using VNLib.Plugins.Essentials.Oauth;
+using VNLib.Plugins.Essentials.Sessions.OAuth;
+using VNLib.Plugins.Essentials.Sessions.OAuth.Endpoints;
+using VNLib.Plugins.Extensions.Loading;
+using VNLib.Plugins.Extensions.Loading.Sql;
+using VNLib.Plugins.Extensions.Loading.Events;
+using VNLib.Plugins.Extensions.Loading.Routing;
+using VNLib.Plugins.Extensions.Loading.Configuration;
+
+namespace VNLib.Plugins.Essentials.Sessions.Oauth
+{
+ public sealed class O2SessionProviderEntry : IRuntimeSessionProvider
+ {
+ const string VNCACHE_CONFIG_KEY = "vncache";
+ const string OAUTH2_CONFIG_KEY = "oauth2";
+
+ private OAuth2SessionProvider? _sessions;
+
+ bool IRuntimeSessionProvider.CanProcess(IHttpEvent entity)
+ {
+ //If authorization header is set try to process as oauth2 session
+ return entity.Server.Headers.HeaderSet(System.Net.HttpRequestHeader.Authorization);
+ }
+
+ ValueTask<SessionHandle> ISessionProvider.GetSessionAsync(IHttpEvent entity, CancellationToken cancellationToken)
+ {
+ return _sessions!.GetSessionAsync(entity, cancellationToken);
+ }
+
+
+ void IRuntimeSessionProvider.Load(PluginBase plugin, ILogProvider localized)
+ {
+ //Try get vncache config element
+ IReadOnlyDictionary<string, JsonElement> cacheConfig = plugin.GetConfig(VNCACHE_CONFIG_KEY);
+
+ IReadOnlyDictionary<string, JsonElement> oauth2Config = plugin.GetConfig(OAUTH2_CONFIG_KEY);
+
+ string tokenEpPath = oauth2Config["token_path"].GetString() ?? throw new KeyNotFoundException($"Missing required 'token_path' in '{OAUTH2_CONFIG_KEY}' config");
+
+ //TODO fix with method that will wait until cache is actually loaded
+ Lazy<ITokenManager> lazyTokenMan = new(() => _sessions!, false);
+
+ //Init auth endpoint
+ AccessTokenEndpoint authEp = new(tokenEpPath, plugin, lazyTokenMan);
+
+ //route auth endpoint
+ plugin.Route(authEp);
+
+ //Route revocation endpoint
+ plugin.Route<RevocationEndpoint>();
+
+ //Run
+ _ = WokerDoWorkAsync(plugin, localized, cacheConfig, oauth2Config);
+ }
+
+ /*
+ * Starts and monitors the VNCache connection
+ */
+
+ private async Task WokerDoWorkAsync(PluginBase plugin, ILogProvider localized, IReadOnlyDictionary<string, JsonElement> cacheConfig, IReadOnlyDictionary<string, JsonElement> oauth2Config)
+ {
+ //Init cache client
+ using VnCacheClient cache = new(plugin.IsDebug() ? plugin.Log : null, Utils.Memory.Memory.Shared);
+
+ try
+ {
+ int cacheLimit = oauth2Config["cache_size"].GetInt32();
+ int maxTokensPerApp = oauth2Config["max_tokens_per_app"].GetInt32();
+ int sessionIdSize = (int)oauth2Config["access_token_size"].GetUInt32();
+ TimeSpan tokenValidFor = oauth2Config["token_valid_for_sec"].GetTimeSpan(TimeParseType.Seconds);
+ TimeSpan cleanupInterval = oauth2Config["gc_interval_sec"].GetTimeSpan(TimeParseType.Seconds);
+ string sessionIdPrefix = oauth2Config["cache_prefix"].GetString() ?? throw new KeyNotFoundException($"Missing required key 'cache_prefix' in '{OAUTH2_CONFIG_KEY}' config");
+
+ //init the id provider
+ OAuth2SessionIdProvider idProv = new(sessionIdPrefix, maxTokensPerApp, sessionIdSize, tokenValidFor);
+
+ //Try loading config
+ await cache.LoadConfigAsync(plugin, cacheConfig);
+
+ //Init session provider now that client is loaded
+ _sessions = new(cache.Resource!, cacheLimit, idProv, plugin.GetContextOptions());
+
+ //Schedule cleanup interval with the plugin scheduler
+ plugin.ScheduleInterval(_sessions, cleanupInterval);
+
+
+ localized.Information("Session provider loaded");
+
+ //Run and wait for exit
+ await cache.RunAsync(localized, plugin.UnloadToken);
+
+ }
+ catch (OperationCanceledException)
+ {}
+ catch (KeyNotFoundException e)
+ {
+ localized.Error("Missing required configuration variable for VnCache client: {0}", e.Message);
+ }
+ catch (Exception ex)
+ {
+ localized.Error(ex, "Cache client error occured in session provider");
+ }
+ finally
+ {
+ _sessions = null;
+ }
+
+ localized.Information("Cache client exited");
+ }
+ }
+} \ No newline at end of file
diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/OAuth2Session.cs b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/OAuth2Session.cs
new file mode 100644
index 0000000..5987c81
--- /dev/null
+++ b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/OAuth2Session.cs
@@ -0,0 +1,107 @@
+using System;
+
+using VNLib.Net.Http;
+using VNLib.Net.Messaging.FBM.Client;
+using VNLib.Plugins.Sessions.Cache.Client;
+
+using static VNLib.Plugins.Essentials.Sessions.ISessionExtensions;
+
+namespace VNLib.Plugins.Essentials.Sessions.OAuth
+{
+ /// <summary>
+ /// The implementation of the OAuth2 session container for HTTP sessions
+ /// </summary>
+ internal sealed class OAuth2Session : RemoteSession
+ {
+ private readonly Action<OAuth2Session> InvalidateCache;
+
+ /// <summary>
+ /// Initalizes a new <see cref="OAuth2Session"/>
+ /// </summary>
+ /// <param name="sessionId">The session id (or token)</param>
+ /// <param name="client">The <see cref="FBMClient"/> used as the backing cache provider</param>
+ /// <param name="backgroundTimeOut">The ammount of time to wait for a background operation (delete, update, get)</param>
+ /// <param name="invalidCache">Called when the session has been marked as invalid and the close even hook is being executed</param>
+ public OAuth2Session(string sessionId, FBMClient client, TimeSpan backgroundTimeOut, Action<OAuth2Session> invalidCache)
+ : base(sessionId, client, backgroundTimeOut)
+ {
+ InvalidateCache = invalidCache;
+ IsInvalid = false;
+ }
+
+ public bool IsInvalid { get; private set; }
+
+
+ ///<inheritdoc/>
+ ///<exception cref="NotSupportedException"></exception>
+ public override string Token
+ {
+ get => throw new NotSupportedException("Token property is not supported for OAuth2 sessions");
+ set => throw new NotSupportedException("Token property is not supported for OAuth2 sessions");
+ }
+
+ ///<inheritdoc/>
+ protected override void IndexerSet(string key, string value)
+ {
+ //Guard protected entires
+ switch (key)
+ {
+ case TOKEN_ENTRY:
+ case LOGIN_TOKEN_ENTRY:
+ throw new InvalidOperationException("Token entry may not be changed!");
+ }
+ base.IndexerSet(key, value);
+ }
+ ///<inheritdoc/>
+ ///<exception cref="SessionStatusException"></exception>
+ public override async Task WaitAndLoadAsync(IHttpEvent entity, CancellationToken token = default)
+ {
+ //Wait to enter lock
+ await base.WaitAndLoadAsync(entity, token);
+ if (IsInvalid)
+ {
+ //Release lock
+ MainLock.Release();
+ throw new SessionStatusException("The session has been invalidated");
+ }
+ //Set session type
+ if (IsNew)
+ {
+ SessionType = SessionType.OAuth2;
+ }
+ }
+ ///<inheritdoc/>
+ protected override async ValueTask<Task?> UpdateResource(bool isAsync, IHttpEvent state)
+ {
+ Task? result = null;
+ //Invalid flag is set, so exit
+ if (IsInvalid)
+ {
+ result = Task.CompletedTask;
+ }
+ //Check flags in priority level, Invalid is highest state priority
+ else if (Flags.IsSet(INVALID_MSK))
+ {
+ //Clear all stored values
+ DataStore!.Clear();
+ //Delete the entity syncronously
+ await ProcessDeleteAsync();
+ //Set invalid flag
+ IsInvalid = true;
+ //Invlidate cache
+ InvalidateCache(this);
+ result = Task.CompletedTask;
+ }
+ else if (Flags.IsSet(MODIFIED_MSK))
+ {
+ //Send update to server
+ result = Task.Run(ProcessUpdateAsync);
+ }
+
+ //Clear all flags
+ Flags.ClearAll();
+
+ return result;
+ }
+ }
+}
diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/OAuth2SessionIdProvider.cs b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/OAuth2SessionIdProvider.cs
new file mode 100644
index 0000000..2b23721
--- /dev/null
+++ b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/OAuth2SessionIdProvider.cs
@@ -0,0 +1,109 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+
+using VNLib.Utils;
+using VNLib.Utils.Memory;
+using VNLib.Utils.Extensions;
+using VNLib.Hashing;
+using VNLib.Net.Http;
+using VNLib.Plugins.Essentials.Oauth;
+using VNLib.Plugins.Essentials.Extensions;
+using VNLib.Plugins.Sessions.Cache.Client;
+using static VNLib.Plugins.Essentials.Oauth.OauthSessionExtensions;
+
+
+namespace VNLib.Plugins.Essentials.Sessions.OAuth
+{
+ /// <summary>
+ /// Generates secure OAuth2 session tokens and initalizes new OAuth2 sessions
+ /// </summary>
+ internal class OAuth2SessionIdProvider : IOauthSessionIdFactory
+ {
+ private readonly string SessionIdPrefix;
+ private readonly int _bufferSize;
+ private readonly int _tokenSize;
+
+ ///<inheritdoc/>
+ public int MaxTokensPerApp { get; }
+ ///<inheritdoc/>
+ public TimeSpan SessionValidFor { get; }
+ ///<inheritdoc/>
+ string IOauthSessionIdFactory.TokenType => "Bearer";
+
+ public OAuth2SessionIdProvider(string sessionIdPrefix, int maxTokensPerApp, int tokenSize, TimeSpan validFor)
+ {
+ SessionIdPrefix = sessionIdPrefix;
+ MaxTokensPerApp = maxTokensPerApp;
+ SessionValidFor = validFor;
+ _tokenSize = tokenSize;
+ _bufferSize = tokenSize * 2;
+ }
+
+ ///<inheritdoc/>
+ bool ISessionIdFactory.TryGetSessionId(IHttpEvent entity, [NotNullWhen(true)] out string? sessionId)
+ {
+ //Get authorization token and make sure its not too large to cause a buffer overflow
+ if (entity.Server.HasAuthorization(out string? token) && (token.Length + SessionIdPrefix.Length) <= _bufferSize)
+ {
+ //Compute session id from token
+ sessionId = ComputeSessionIdFromToken(token);
+
+ return true;
+ }
+ else
+ {
+ sessionId = null;
+ }
+
+ return false;
+ }
+
+ private string ComputeSessionIdFromToken(string token)
+ {
+ //Buffer to copy data to
+ using UnsafeMemoryHandle<char> buffer = Memory.UnsafeAlloc<char>(_bufferSize, true);
+
+ //Writer to accumulate data
+ ForwardOnlyWriter<char> writer = new(buffer.Span);
+
+ //Append session id prefix and token
+ writer.Append(SessionIdPrefix);
+ writer.Append(token);
+
+ //Compute base64 hash of token and
+ return ManagedHash.ComputeBase64Hash(writer.AsSpan(), HashAlg.SHA256);
+ }
+
+ ///<inheritdoc/>
+ TokenAndSessionIdResult IOauthSessionIdFactory.GenerateTokensAndId()
+ {
+ //Alloc buffer for random data
+ using UnsafeMemoryHandle<byte> mem = Memory.UnsafeAlloc<byte>(_tokenSize, true);
+
+ //Generate token from random cng bytes
+ RandomHash.GetRandomBytes(mem);
+
+ //Token is the raw value
+ string token = Convert.ToBase64String(mem.Span);
+
+ //The session id is the HMAC of the token
+ string sessionId = ComputeSessionIdFromToken(token);
+
+ //Clear buffer
+ Utils.Memory.Memory.InitializeBlock(mem.Span);
+
+ //Return sessid result
+ return new(sessionId, token, null);
+ }
+
+ ///<inheritdoc/>
+ void IOauthSessionIdFactory.InitNewSession(RemoteSession session, UserApplication app, IHttpEvent entity)
+ {
+ //Store session variables
+ session[APP_ID_ENTRY] = app.Id;
+ session[TOKEN_TYPE_ENTRY] = "client_credential,bearer";
+ session[SCOPES_ENTRY] = app.Permissions;
+ session.UserID = app.UserId;
+ }
+ }
+}
diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/OAuth2SessionProvider.cs b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/OAuth2SessionProvider.cs
new file mode 100644
index 0000000..d938641
--- /dev/null
+++ b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/OAuth2SessionProvider.cs
@@ -0,0 +1,183 @@
+using System;
+using System.Net;
+
+using Microsoft.EntityFrameworkCore;
+
+using VNLib.Utils;
+using VNLib.Utils.Logging;
+using VNLib.Net.Http;
+using VNLib.Data.Caching;
+using VNLib.Data.Caching.Exceptions;
+using VNLib.Net.Messaging.FBM.Client;
+using VNLib.Plugins.Essentials.Oauth;
+using VNLib.Plugins.Essentials.Oauth.Tokens;
+using VNLib.Plugins.Sessions.Cache.Client;
+using VNLib.Plugins.Extensions.Loading.Events;
+
+namespace VNLib.Plugins.Essentials.Sessions.OAuth
+{
+
+ /// <summary>
+ /// Provides OAuth2 session management
+ /// </summary>
+ internal sealed class OAuth2SessionProvider : SessionCacheClient, ISessionProvider, ITokenManager, IIntervalScheduleable
+ {
+
+ private static readonly SessionHandle NotFoundHandle = new(null, FileProcessArgs.NotFound, null);
+ static readonly TimeSpan BackgroundTimeout = TimeSpan.FromSeconds(10);
+
+ private readonly IOauthSessionIdFactory factory;
+ private readonly TokenStore TokenStore;
+
+ public OAuth2SessionProvider(FBMClient client, int maxCacheItems, IOauthSessionIdFactory idFactory, DbContextOptions dbCtx)
+ : base(client, maxCacheItems)
+ {
+ factory = idFactory;
+ TokenStore = new(dbCtx);
+ }
+
+ ///<inheritdoc/>
+ protected override RemoteSession SessionCtor(string sessionId) => new OAuth2Session(sessionId, Client, BackgroundTimeout, InvlidatateCache);
+
+ private void InvlidatateCache(OAuth2Session session)
+ {
+ lock (CacheLock)
+ {
+ _ = CacheTable.Remove(session.SessionID);
+ }
+ }
+
+ ///<inheritdoc/>
+ public async ValueTask<SessionHandle> GetSessionAsync(IHttpEvent entity, CancellationToken cancellationToken)
+ {
+ //Callback to close the session when the handle is closeed
+ static ValueTask HandleClosedAsync(ISession session, IHttpEvent entity)
+ {
+ return ((SessionBase)session).UpdateAndRelease(true, entity);
+ }
+ try
+ {
+ //Get session id
+ if (!factory.TryGetSessionId(entity, out string? sessionId))
+ {
+ //Id not allowed/found, so do not attach a session
+ return SessionHandle.Empty;
+ }
+
+ //Recover the session
+ RemoteSession session = await base.GetSessionAsync(entity, sessionId, cancellationToken);
+
+ //Session should not be new
+ if (session.IsNew)
+ {
+ //Invalidate the session, so it is deleted
+ session.Invalidate();
+ await session.UpdateAndRelease(true, entity);
+ return SessionHandle.Empty;
+ }
+ //Make sure session is still valid
+ if (session.Created.Add(factory.SessionValidFor) < DateTimeOffset.UtcNow)
+ {
+ //Invalidate the handle
+ session.Invalidate();
+ //Flush changes
+ await session.UpdateAndRelease(false, entity);
+ //Remove the token from the db backing store
+ await TokenStore.RevokeTokenAsync(sessionId, cancellationToken);
+ //close entity
+ entity.CloseResponseError(HttpStatusCode.Unauthorized, ErrorType.InvalidToken, "The token has expired");
+ //return a completed handle
+ return NotFoundHandle;
+ }
+
+ return new SessionHandle(session, HandleClosedAsync);
+ }
+ //Pass session exceptions
+ catch (SessionException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ throw new SessionException("Exception raised while retreiving or loading OAuth2 session", ex);
+ }
+ }
+ ///<inheritdoc/>
+ async Task<IOAuth2TokenResult?> ITokenManager.CreateAccessTokenAsync(IHttpEvent ev, UserApplication app, CancellationToken cancellation)
+ {
+ //Get a new session for the current connection
+ TokenAndSessionIdResult ids = factory.GenerateTokensAndId();
+ //try to insert token into the store, may fail if max has been reached
+ if (await TokenStore.InsertTokenAsync(ids.SessionId, app.Id, ids.RefreshToken, factory.MaxTokensPerApp, cancellation) != ERRNO.SUCCESS)
+ {
+ return null;
+ }
+ //Create new session from the session id
+ RemoteSession session = SessionCtor(ids.SessionId);
+ await session.WaitAndLoadAsync(ev, cancellation);
+ try
+ {
+ //Init new session
+ factory.InitNewSession(session, app, ev);
+ }
+ finally
+ {
+ await session.UpdateAndRelease(false, ev);
+ }
+ //Init new token result to pass to client
+ return new OAuth2TokenResult()
+ {
+ ExpiresSeconds = (int)factory.SessionValidFor.TotalSeconds,
+ TokenType = factory.TokenType,
+ //Return token and refresh token
+ AccessToken = ids.AccessToken,
+ RefreshToken = ids.RefreshToken,
+ };
+ }
+ ///<inheritdoc/>
+ Task ITokenManager.RevokeTokensAsync(IReadOnlyCollection<string> tokens, CancellationToken cancellation)
+ {
+ throw new NotImplementedException();
+ }
+ ///<inheritdoc/>
+ Task ITokenManager.RevokeTokensForAppAsync(string appId, CancellationToken cancellation)
+ {
+ throw new NotImplementedException();
+ }
+
+ async Task IIntervalScheduleable.OnIntervalAsync(ILogProvider log, CancellationToken cancellationToken)
+ {
+ //Calculate valid token time
+ DateTimeOffset validAfter = DateTimeOffset.UtcNow.Subtract(factory.SessionValidFor);
+ //Remove tokens from db store
+ IReadOnlyCollection<ActiveToken> revoked = await TokenStore.CleanupExpiredTokensAsync(validAfter, cancellationToken);
+ //exception list
+ List<Exception>? errors = null;
+ //Remove all sessions from the store
+ foreach (ActiveToken token in revoked)
+ {
+ try
+ {
+ //Remove tokens by thier object id from cache
+ await base.Client.DeleteObjectAsync(token.Id, cancellationToken);
+ }
+ //Ignore if the object has already been removed
+ catch (ObjectNotFoundException)
+ {}
+ catch (Exception ex)
+ {
+ errors ??= new();
+ errors.Add(ex);
+ }
+ }
+ if (errors?.Count > 0)
+ {
+ throw new AggregateException(errors);
+ }
+ if(revoked.Count > 0)
+ {
+ log.Debug("Cleaned up {0} expired tokens", revoked.Count);
+ }
+ }
+ }
+}
diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/OAuth2TokenResult.cs b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/OAuth2TokenResult.cs
new file mode 100644
index 0000000..1bff743
--- /dev/null
+++ b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/OAuth2TokenResult.cs
@@ -0,0 +1,13 @@
+using VNLib.Plugins.Essentials.Oauth;
+
+namespace VNLib.Plugins.Essentials.Sessions.OAuth
+{
+ internal class OAuth2TokenResult : IOAuth2TokenResult
+ {
+ public string? IdentityToken { get; }
+ public string? AccessToken { get; set; }
+ public string? RefreshToken { get; set; }
+ public string? TokenType { get; set; }
+ public int ExpiresSeconds { get; set; }
+ }
+}
diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/OauthTokenResponseMessage.cs b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/OauthTokenResponseMessage.cs
new file mode 100644
index 0000000..c80891e
--- /dev/null
+++ b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/OauthTokenResponseMessage.cs
@@ -0,0 +1,20 @@
+using System.Text.Json.Serialization;
+
+#nullable enable
+
+namespace VNLib.Plugins.Essentials.Sessions.OAuth
+{
+ public sealed class OauthTokenResponseMessage
+ {
+ [JsonPropertyName("access_token")]
+ public string? AccessToken { get; set; }
+ [JsonPropertyName("refresh_token")]
+ public string? RefreshToken { get; set; }
+ [JsonPropertyName("token_type")]
+ public string? TokenType { get; set; }
+ [JsonPropertyName("expires_in")]
+ public int Expires { get; set; }
+ [JsonPropertyName("id_token")]
+ public string? IdToken { get; set; }
+ }
+} \ No newline at end of file
diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/TokenAndSessionIdResult.cs b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/TokenAndSessionIdResult.cs
new file mode 100644
index 0000000..f8d381f
--- /dev/null
+++ b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/TokenAndSessionIdResult.cs
@@ -0,0 +1,20 @@
+#nullable enable
+
+using VNLib;
+
+namespace VNLib.Plugins.Essentials.Sessions.OAuth
+{
+ public readonly struct TokenAndSessionIdResult
+ {
+ public readonly string SessionId;
+ public readonly string AccessToken;
+ public readonly string? RefreshToken;
+
+ public TokenAndSessionIdResult(string sessionId, string token, string? refreshToken)
+ {
+ SessionId = sessionId;
+ AccessToken = token;
+ RefreshToken = refreshToken;
+ }
+ }
+}
diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/VNLib.Plugins.Essentials.Sessions.OAuth.csproj b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/VNLib.Plugins.Essentials.Sessions.OAuth.csproj
new file mode 100644
index 0000000..cba7822
--- /dev/null
+++ b/Libs/VNLib.Plugins.Essentials.Sessions.OAuth/VNLib.Plugins.Essentials.Sessions.OAuth.csproj
@@ -0,0 +1,28 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>net6.0</TargetFramework>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <Nullable>enable</Nullable>
+ <PlatformTarget>x64</PlatformTarget>
+ <GenerateDocumentationFile>False</GenerateDocumentationFile>
+ <Authors>Vaughn Nugent</Authors>
+ <Copyright>Copyright © 2022 Vaughn Nugent</Copyright>
+
+ <EnableDynamicLoading>true</EnableDynamicLoading>
+
+ </PropertyGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\..\..\Extensions\VNLib.Plugins.Extensions.Loading.Sql\VNLib.Plugins.Extensions.Loading.Sql.csproj" />
+ <ProjectReference Include="..\..\..\Extensions\VNLib.Plugins.Extensions.Validation\VNLib.Plugins.Extensions.Validation.csproj" />
+ <ProjectReference Include="..\..\..\Oauth Plugins\VNLib.Plugins.Essentials.Oauth\VNLib.Plugins.Essentials.Oauth.csproj" />
+ <ProjectReference Include="..\..\Libs\VNLib.Plugins.Sessions.Cache.Client\VNLib.Plugins.Sessions.Cache.Client.csproj" />
+ <ProjectReference Include="..\VNLib.Plugins.Essentials.Sessions.Runtime\VNLib.Plugins.Essentials.Sessions.Runtime.csproj" />
+ </ItemGroup>
+
+ <Target Name="PostBuild" AfterTargets="PostBuildEvent">
+ <Exec Command="start xcopy &quot;$(TargetDir)&quot; &quot;F:\Programming\Web Plugins\DevPlugins\SessionProviders\$(TargetName)&quot; /E /Y /R" />
+ </Target>
+
+</Project>
diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.Runtime/IRuntimeSessionProvider.cs b/Libs/VNLib.Plugins.Essentials.Sessions.Runtime/IRuntimeSessionProvider.cs
new file mode 100644
index 0000000..9941e6a
--- /dev/null
+++ b/Libs/VNLib.Plugins.Essentials.Sessions.Runtime/IRuntimeSessionProvider.cs
@@ -0,0 +1,26 @@
+
+using VNLib.Net.Http;
+using VNLib.Utils.Logging;
+
+namespace VNLib.Plugins.Essentials.Sessions
+{
+ /// <summary>
+ /// Represents a dynamically loadable type that an provide sessions to http connections
+ /// </summary>
+ public interface IRuntimeSessionProvider : ISessionProvider
+ {
+ /// <summary>
+ /// Called immediatly after the plugin is loaded into the appdomain
+ /// </summary>
+ /// <param name="plugin">The plugin instance that is loading the module</param>
+ /// <param name="localizedLog">The localized log provider for the provider</param>
+ void Load(PluginBase plugin, ILogProvider localizedLog);
+
+ /// <summary>
+ /// Determines if the provider can return a session for the connection
+ /// </summary>
+ /// <param name="entity">The entity to process</param>
+ /// <returns>A value indicating if this provider should be called to load a session for</returns>
+ bool CanProcess(IHttpEvent entity);
+ }
+}
diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.Runtime/ISessionIdFactory.cs b/Libs/VNLib.Plugins.Essentials.Sessions.Runtime/ISessionIdFactory.cs
new file mode 100644
index 0000000..1e88e5c
--- /dev/null
+++ b/Libs/VNLib.Plugins.Essentials.Sessions.Runtime/ISessionIdFactory.cs
@@ -0,0 +1,17 @@
+using System.Diagnostics.CodeAnalysis;
+
+using VNLib.Net.Http;
+
+namespace VNLib.Plugins.Essentials.Sessions
+{
+ public interface ISessionIdFactory
+ {
+ /// <summary>
+ /// Attempts to recover a session-id from the connection
+ /// </summary>
+ /// <param name="entity">The connection to process</param>
+ /// <param name="sessionId"></param>
+ /// <returns></returns>
+ bool TryGetSessionId(IHttpEvent entity, [NotNullWhen(true)] out string? sessionId);
+ }
+}
diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.Runtime/VNLib.Plugins.Essentials.Sessions.Runtime.csproj b/Libs/VNLib.Plugins.Essentials.Sessions.Runtime/VNLib.Plugins.Essentials.Sessions.Runtime.csproj
new file mode 100644
index 0000000..5924b93
--- /dev/null
+++ b/Libs/VNLib.Plugins.Essentials.Sessions.Runtime/VNLib.Plugins.Essentials.Sessions.Runtime.csproj
@@ -0,0 +1,40 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>net6.0</TargetFramework>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <Nullable>enable</Nullable>
+ <Platforms>AnyCPU;x64</Platforms>
+ <Authors>Vaughn Nugent</Authors>
+ <Copyright>Copyright © 2022 Vaughn Nugent</Copyright>
+ <Version>1.0.0.1</Version>
+ <PlatformTarget>x64</PlatformTarget>
+ <SignAssembly>False</SignAssembly>
+ <PackageProjectUrl>www.vaughnnugent.com/resources</PackageProjectUrl>
+ </PropertyGroup>
+
+ <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
+ <Deterministic>True</Deterministic>
+ </PropertyGroup>
+
+ <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
+ <Deterministic>True</Deterministic>
+ </PropertyGroup>
+
+ <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
+ <Deterministic>False</Deterministic>
+ </PropertyGroup>
+
+ <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
+ <Deterministic>False</Deterministic>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\..\..\..\VNLib\Essentials\VNLib.Plugins.Essentials.csproj" />
+ <ProjectReference Include="..\..\..\..\VNLib\Http\VNLib.Net.Http.csproj" />
+ <ProjectReference Include="..\..\..\DataCaching\VNLib.Data.Caching.Extensions\VNLib.Data.Caching.Extensions.csproj" />
+ <ProjectReference Include="..\..\..\Extensions\VNLib.Plugins.Extensions.Loading\VNLib.Plugins.Extensions.Loading.csproj" />
+ <ProjectReference Include="..\..\..\PluginBase\VNLib.Plugins.PluginBase.csproj" />
+ </ItemGroup>
+
+</Project>
diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.Runtime/VnCacheClient.cs b/Libs/VNLib.Plugins.Essentials.Sessions.Runtime/VnCacheClient.cs
new file mode 100644
index 0000000..a6979ef
--- /dev/null
+++ b/Libs/VNLib.Plugins.Essentials.Sessions.Runtime/VnCacheClient.cs
@@ -0,0 +1,162 @@
+using System;
+using System.Text.Json;
+using System.Net.Sockets;
+using System.Net.WebSockets;
+using System.Security.Cryptography;
+
+using VNLib.Utils;
+using VNLib.Utils.Memory;
+using VNLib.Utils.Logging;
+using VNLib.Utils.Extensions;
+using VNLib.Data.Caching.Extensions;
+using VNLib.Net.Messaging.FBM.Client;
+using VNLib.Plugins.Extensions.Loading;
+
+namespace VNLib.Plugins.Essentials.Sessions
+{
+ /// <summary>
+ /// A wrapper to simplify a cache client object
+ /// </summary>
+ public sealed class VnCacheClient : OpenResourceHandle<FBMClient?>
+ {
+ FBMClient? _client;
+
+ /// <summary>
+ /// The wrapped client
+ /// </summary>
+ public override FBMClient? Resource => _client;
+
+
+ private TimeSpan RetryInterval;
+
+ private readonly ILogProvider? DebugLog;
+ private readonly IUnmangedHeap? ClientHeap;
+
+ /// <summary>
+ /// Initializes an emtpy client wrapper that still requires
+ /// configuration loading
+ /// </summary>
+ /// <param name="debugLog">An optional debugging log</param>
+ /// <param name="heap">An optional <see cref="IUnmangedHeap"/> for <see cref="FBMClient"/> buffers</param>
+ public VnCacheClient(ILogProvider? debugLog, IUnmangedHeap? heap = null)
+ {
+ DebugLog = debugLog;
+ //Default to 10 seconds
+ RetryInterval = TimeSpan.FromSeconds(10);
+
+ ClientHeap = heap;
+ }
+
+ protected override void Free()
+ {
+ _client?.Dispose();
+ _client = null;
+ }
+
+ /// <summary>
+ /// Loads required configuration variables from the config store and
+ /// intializes the interal client
+ /// </summary>
+ /// <param name="config">A dictionary of configuration varables</param>
+ /// <exception cref="KeyNotFoundException"></exception>
+ public async Task LoadConfigAsync(PluginBase pbase, IReadOnlyDictionary<string, JsonElement> config)
+ {
+ int maxMessageSize = config["max_message_size"].GetInt32();
+ string? brokerAddress = config["broker_address"].GetString() ?? throw new KeyNotFoundException("Missing required configuration variable broker_address");
+
+ //Get keys async
+ Task<string?> clientPrivTask = pbase.TryGetSecretAsync("client_private_key");
+ Task<string?> brokerPubTask = pbase.TryGetSecretAsync("broker_public_key");
+
+ //Wait for all tasks to complete
+ string?[] keys = await Task.WhenAll(clientPrivTask, brokerPubTask);
+
+ byte[] privKey = Convert.FromBase64String(keys[0] ?? throw new KeyNotFoundException("Missing required secret client_private_key"));
+ byte[] brokerPub = Convert.FromBase64String(keys[1] ?? throw new KeyNotFoundException("Missing required secret broker_public_key"));
+
+ RetryInterval = config["retry_interval_sec"].GetTimeSpan(TimeParseType.Seconds);
+
+ Uri brokerUri = new(brokerAddress);
+
+ //Init the client with default settings
+ FBMClientConfig conf = FBMDataCacheExtensions.GetDefaultConfig(ClientHeap ?? Memory.Shared, maxMessageSize, DebugLog);
+
+ _client = new(conf);
+ //Add the configuration
+ _client.UseBroker(brokerUri)
+ .ImportBrokerPublicKey(brokerPub)
+ .ImportClientPrivateKey(privKey)
+ .UseTls(brokerUri.Scheme == Uri.UriSchemeHttps);
+
+ //Zero the key memory
+ Memory.InitializeBlock(privKey.AsSpan());
+ Memory.InitializeBlock(brokerPub.AsSpan());
+ }
+
+ /// <summary>
+ /// Discovers nodes in the configured cluster and connects to a random node
+ /// </summary>
+ /// <param name="Log">A <see cref="ILogProvider"/> to write log events to</param>
+ /// <param name="cancellationToken">A token to cancel the operation</param>
+ /// <returns>A task that completes when the operation has been cancelled or an unrecoverable error occured</returns>
+ /// <exception cref="InvalidOperationException"></exception>
+ /// <exception cref="OperationCanceledException"></exception>
+ public async Task RunAsync(ILogProvider Log, CancellationToken cancellationToken)
+ {
+ _ = Resource ?? throw new InvalidOperationException("Client configuration not loaded, cannot connect to cache servers");
+
+ while (true)
+ {
+ //Load the server list
+ ActiveServer[]? servers;
+ while (true)
+ {
+ try
+ {
+ Log.Debug("Discovering cluster nodes in broker");
+ //Get server list
+ servers = await Resource.DiscoverNodesAsync(cancellationToken);
+ break;
+ }
+ catch (HttpRequestException re) when (re.InnerException is SocketException)
+ {
+ Log.Warn("Broker server is unreachable");
+ }
+ catch (Exception ex)
+ {
+ Log.Warn("Failed to get server list from broker, reason {r}", ex.Message);
+ }
+ //Gen random ms delay
+ int randomMsDelay = RandomNumberGenerator.GetInt32(1000, 2000);
+ await Task.Delay(randomMsDelay, cancellationToken);
+ }
+ if (servers?.Length == 0)
+ {
+ Log.Warn("No cluster nodes found, retrying");
+ await Task.Delay(RetryInterval, cancellationToken);
+ continue;
+ }
+ //select random server from the list of servers
+ ActiveServer selected = servers!.SelectRandom();
+ try
+ {
+ Log.Debug("Connecting to server {server}", selected.ServerId);
+ //Try to connect to server
+ await Resource.ConnectAndWaitForExitAsync(selected, cancellationToken);
+ Log.Debug("Cache server disconnected");
+ }
+ catch (WebSocketException wse)
+ {
+ Log.Warn("Failed to connect to cache server {reason}", wse.Message);
+ continue;
+ }
+ catch (HttpRequestException he) when (he.InnerException is SocketException)
+ {
+ Log.Debug("Failed to connect to recommended server {server}", selected.ServerId);
+ //Continue next loop
+ continue;
+ }
+ }
+ }
+ }
+}
diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.VNCache/IWebSessionIdFactory.cs b/Libs/VNLib.Plugins.Essentials.Sessions.VNCache/IWebSessionIdFactory.cs
new file mode 100644
index 0000000..81f44b8
--- /dev/null
+++ b/Libs/VNLib.Plugins.Essentials.Sessions.VNCache/IWebSessionIdFactory.cs
@@ -0,0 +1,26 @@
+
+using VNLib.Net.Http;
+
+namespace VNLib.Plugins.Essentials.Sessions.VNCache
+{
+ /// <summary>
+ /// Id factory for <see cref="WebSessionProvider"/>
+ /// </summary>
+ internal interface IWebSessionIdFactory: ISessionIdFactory
+ {
+ /// <summary>
+ /// The maxium amount of time a session is valid for. Sessions will be invalidated
+ /// after this time
+ /// </summary>
+ TimeSpan ValidFor { get; }
+
+ /// <summary>
+ /// Gets a new session-id for the connection and manipulates the entity as necessary
+ /// </summary>
+ /// <param name="entity">The connection to generate the new session for</param>
+ /// <returns>The new session-id</returns>
+ string GenerateSessionId(IHttpEvent entity);
+ }
+
+
+}
diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.VNCache/VNLib.Plugins.Essentials.Sessions.VNCache.csproj b/Libs/VNLib.Plugins.Essentials.Sessions.VNCache/VNLib.Plugins.Essentials.Sessions.VNCache.csproj
new file mode 100644
index 0000000..ecb80b1
--- /dev/null
+++ b/Libs/VNLib.Plugins.Essentials.Sessions.VNCache/VNLib.Plugins.Essentials.Sessions.VNCache.csproj
@@ -0,0 +1,47 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>net6.0</TargetFramework>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <Nullable>enable</Nullable>
+ <Platforms>AnyCPU;x64</Platforms>
+ <Authors>Vaughn Nugent</Authors>
+ <Copyright>Copyright © 2022 Vaughn Nugent</Copyright>
+ <Version>1.0.0.1</Version>
+ <PlatformTarget>x64</PlatformTarget>
+ <SignAssembly>False</SignAssembly>
+ <PackageProjectUrl>www.vaughnnugent.com/resources</PackageProjectUrl>
+
+ <EnableDynamicLoading>true</EnableDynamicLoading>
+ </PropertyGroup>
+
+ <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
+ <Deterministic>True</Deterministic>
+ </PropertyGroup>
+
+ <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
+ <Deterministic>True</Deterministic>
+ </PropertyGroup>
+
+ <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
+ <Deterministic>False</Deterministic>
+ </PropertyGroup>
+
+ <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
+ <Deterministic>False</Deterministic>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\VNLib.Plugins.Essentials.Sessions.Runtime\VNLib.Plugins.Essentials.Sessions.Runtime.csproj" />
+ <ProjectReference Include="..\VNLib.Plugins.Sessions.Cache.Client\VNLib.Plugins.Sessions.Cache.Client.csproj" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <Folder Include="Endpoints\" />
+ </ItemGroup>
+
+ <Target Name="PostBuild" AfterTargets="PostBuildEvent">
+ <Exec Command="start xcopy &quot;$(TargetDir)&quot; &quot;F:\Programming\Web Plugins\DevPlugins\SessionProviders\$(TargetName)&quot; /E /Y /R" />
+ </Target>
+
+</Project>
diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.VNCache/WebSession.cs b/Libs/VNLib.Plugins.Essentials.Sessions.VNCache/WebSession.cs
new file mode 100644
index 0000000..a7a6f5e
--- /dev/null
+++ b/Libs/VNLib.Plugins.Essentials.Sessions.VNCache/WebSession.cs
@@ -0,0 +1,103 @@
+using VNLib.Net.Http;
+using VNLib.Data.Caching;
+using VNLib.Net.Messaging.FBM.Client;
+using VNLib.Plugins.Essentials.Extensions;
+using VNLib.Plugins.Sessions.Cache.Client;
+using static VNLib.Plugins.Essentials.Sessions.ISessionExtensions;
+
+
+namespace VNLib.Plugins.Essentials.Sessions.VNCache
+{
+ internal class WebSession : RemoteSession
+ {
+ protected const ulong UPGRADE_MSK = 0b0000000000010000UL;
+
+ protected readonly Func<IHttpEvent, string, string> UpdateId;
+ private string? _oldId;
+
+ public WebSession(string sessionId, FBMClient client, TimeSpan backgroundTimeOut, Func<IHttpEvent, string, string> UpdateId)
+ : base(sessionId, client, backgroundTimeOut)
+ {
+ this.UpdateId = UpdateId;
+ }
+
+ protected override void IndexerSet(string key, string value)
+ {
+ //Set value
+ base.IndexerSet(key, value);
+ switch (key)
+ {
+ //Set the upgrade flag when token data is modified
+ case LOGIN_TOKEN_ENTRY:
+ case TOKEN_ENTRY:
+ Flags.Set(UPGRADE_MSK);
+ break;
+ }
+ }
+
+ public override async Task WaitAndLoadAsync(IHttpEvent entity, CancellationToken cancellationToken)
+ {
+ //Wait for the session to load
+ await base.WaitAndLoadAsync(entity, cancellationToken);
+ //If the session is new, set to web mode
+ if (IsNew)
+ {
+ SessionType = SessionType.Web;
+ }
+ }
+
+ private async Task ProcessUpgradeAsync()
+ {
+ //Setup timeout cancellation for the update, to cancel it
+ using CancellationTokenSource cts = new(UpdateTimeout);
+ await Client.AddOrUpdateObjectAsync(_oldId!, SessionID, DataStore, cts.Token);
+ _oldId = null;
+ }
+
+ protected override ValueTask<Task?> UpdateResource(bool isAsync, IHttpEvent state)
+ {
+ Task? result = null;
+ //Check flags in priority level, Invalid is highest state priority
+ if (Flags.IsSet(INVALID_MSK))
+ {
+ //Clear all stored values
+ DataStore!.Clear();
+ //Reset ip-address
+ UserIP = state.Server.GetTrustedIp();
+ //Update created time
+ Created = DateTimeOffset.UtcNow;
+ //Init the new session-data
+ this.InitNewSession(state.Server);
+ //Restore session type
+ SessionType = SessionType.Web;
+ //generate new session-id and update the record in the store
+ _oldId = SessionID;
+ //Update the session-id
+ SessionID = UpdateId(state, _oldId);
+ //write update to server
+ result = Task.Run(ProcessUpgradeAsync);
+ }
+ else if (Flags.IsSet(UPGRADE_MSK | REGEN_ID_MSK))
+ {
+ //generate new session-id and update the record in the store
+ _oldId = SessionID;
+ //Update the session-id
+ SessionID = UpdateId(state, _oldId);
+ //Update created time
+ Created = DateTimeOffset.UtcNow;
+ //write update to server
+ result = Task.Run(ProcessUpgradeAsync);
+ }
+ else if (Flags.IsSet(MODIFIED_MSK))
+ {
+ //Send update to server
+ result = Task.Run(ProcessUpdateAsync);
+ }
+
+ //Clear all flags
+ Flags.ClearAll();
+
+ return ValueTask.FromResult<Task?>(null);
+ }
+ }
+}
diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.VNCache/WebSessionIdFactoryImpl.cs b/Libs/VNLib.Plugins.Essentials.Sessions.VNCache/WebSessionIdFactoryImpl.cs
new file mode 100644
index 0000000..001723a
--- /dev/null
+++ b/Libs/VNLib.Plugins.Essentials.Sessions.VNCache/WebSessionIdFactoryImpl.cs
@@ -0,0 +1,96 @@
+using System.Diagnostics.CodeAnalysis;
+
+using VNLib.Hashing;
+using VNLib.Net.Http;
+using VNLib.Utils.Memory;
+using VNLib.Utils.Extensions;
+using VNLib.Plugins.Essentials.Extensions;
+
+
+namespace VNLib.Plugins.Essentials.Sessions.VNCache
+{
+ /// <summary>
+ /// <see cref="IWebSessionIdFactory"/> implementation, using
+ /// http cookies as session id storage
+ /// </summary>
+ internal sealed class WebSessionIdFactoryImpl : IWebSessionIdFactory
+ {
+ public TimeSpan ValidFor { get; }
+
+ public string GenerateSessionId(IHttpEvent entity)
+ {
+ //Random hex hash
+ string cookie = RandomHash.GetRandomBase32(_tokenSize);
+
+ //Set the session id cookie
+ entity.Server.SetCookie(SessionCookieName, cookie, ValidFor, secure: true, httpOnly: true);
+
+ //return session-id value from cookie value
+ return ComputeSessionIdFromCookie(cookie);
+ }
+
+ bool ISessionIdFactory.TryGetSessionId(IHttpEvent entity, [NotNullWhen(true)] out string? sessionId)
+ {
+ //Get authorization token and make sure its not too large to cause a buffer overflow
+ if (entity.Server.GetCookie(SessionCookieName, out string? cookie) && (cookie.Length + SessionIdPrefix.Length) <= _bufferSize)
+ {
+ //Compute session id from token
+ sessionId = ComputeSessionIdFromCookie(cookie);
+
+ return true;
+ }
+ //Only add sessions for user-agents
+ else if(entity.Server.IsBrowser())
+ {
+ //Get a new session id
+ sessionId = GenerateSessionId(entity);
+
+ return true;
+ }
+ else
+ {
+ sessionId = null;
+ return false;
+ }
+ }
+
+ private readonly string SessionCookieName;
+ private readonly string SessionIdPrefix;
+ private readonly int _bufferSize;
+ private readonly int _tokenSize;
+
+ /// <summary>
+ /// Initialzies a new web session Id factory
+ /// </summary>
+ /// <param name="cookieSize">The size of the cookie in bytes</param>
+ /// <param name="sessionCookieName">The name of the session cookie</param>
+ /// <param name="sessionIdPrefix">The session-id internal prefix</param>
+ /// <param name="validFor">The time the session cookie is valid for</param>
+ public WebSessionIdFactoryImpl(uint cookieSize, string sessionCookieName, string sessionIdPrefix, TimeSpan validFor)
+ {
+ ValidFor = validFor;
+ SessionCookieName = sessionCookieName;
+ SessionIdPrefix = sessionIdPrefix;
+ _tokenSize = (int)cookieSize;
+ //Calc buffer size
+ _bufferSize = Math.Max(32, ((int)cookieSize * 3) + sessionIdPrefix.Length);
+ }
+
+
+ private string ComputeSessionIdFromCookie(string sessionId)
+ {
+ //Buffer to copy data to
+ using UnsafeMemoryHandle<char> buffer = Memory.UnsafeAlloc<char>(_bufferSize, true);
+
+ //Writer to accumulate data
+ ForwardOnlyWriter<char> writer = new(buffer.Span);
+
+ //Append prefix and session id
+ writer.Append(SessionIdPrefix);
+ writer.Append(sessionId);
+
+ //Compute base64 hash of token and
+ return ManagedHash.ComputeBase64Hash(writer.AsSpan(), HashAlg.SHA256);
+ }
+ }
+} \ No newline at end of file
diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.VNCache/WebSessionProvider.cs b/Libs/VNLib.Plugins.Essentials.Sessions.VNCache/WebSessionProvider.cs
new file mode 100644
index 0000000..fd725bf
--- /dev/null
+++ b/Libs/VNLib.Plugins.Essentials.Sessions.VNCache/WebSessionProvider.cs
@@ -0,0 +1,122 @@
+using System;
+
+using VNLib.Net.Http;
+using VNLib.Net.Messaging.FBM.Client;
+using VNLib.Plugins.Sessions.Cache.Client;
+
+namespace VNLib.Plugins.Essentials.Sessions.VNCache
+{
+ /// <summary>
+ /// The implementation of a VNCache web based session
+ /// </summary>
+ internal sealed class WebSessionProvider : SessionCacheClient, ISessionProvider
+ {
+ static readonly TimeSpan BackgroundUpdateTimeout = TimeSpan.FromSeconds(10);
+
+ private readonly IWebSessionIdFactory factory;
+ private readonly uint MaxConnections;
+
+ /// <summary>
+ /// Initializes a new <see cref="WebSessionProvider"/>
+ /// </summary>
+ /// <param name="client">The cache client to make cache operations against</param>
+ /// <param name="maxCacheItems">The max number of items to store in cache</param>
+ /// <param name="maxWaiting">The maxium number of waiting session events before 503s are sent</param>
+ /// <param name="factory">The session-id factory</param>
+ public WebSessionProvider(FBMClient client, int maxCacheItems, uint maxWaiting, IWebSessionIdFactory factory) : base(client, maxCacheItems)
+ {
+ this.factory = factory;
+ MaxConnections = maxWaiting;
+ }
+
+ private string UpdateSessionId(IHttpEvent entity, string oldId)
+ {
+ //Generate and set a new sessionid
+ string newid = factory.GenerateSessionId(entity);
+ //Aquire lock on cache
+ lock (CacheLock)
+ {
+ //Change the cache lookup id
+ if (CacheTable.Remove(oldId, out RemoteSession? session))
+ {
+ CacheTable.Add(newid, session);
+ }
+ }
+ return newid;
+ }
+
+ protected override RemoteSession SessionCtor(string sessionId) => new WebSession(sessionId, Client, BackgroundUpdateTimeout, UpdateSessionId);
+
+
+ private uint _waitingCount;
+
+ public async ValueTask<SessionHandle> GetSessionAsync(IHttpEvent entity, CancellationToken cancellationToken)
+ {
+ //Callback to close the session when the handle is closeed
+ static ValueTask HandleClosedAsync(ISession session, IHttpEvent entity)
+ {
+ return (session as SessionBase)!.UpdateAndRelease(true, entity);
+ }
+
+ try
+ {
+ //Get session id
+ if (!factory.TryGetSessionId(entity, out string? sessionId))
+ {
+ //Id not allowed/found, so do not attach a session
+ return SessionHandle.Empty;
+ }
+
+ //Limit max number of waiting clients
+ if (_waitingCount > MaxConnections)
+ {
+ //Set 503 for temporary unavail
+ entity.CloseResponse(System.Net.HttpStatusCode.ServiceUnavailable);
+ return new SessionHandle(null, FileProcessArgs.VirtualSkip, null);
+ }
+
+ RemoteSession session;
+
+ //Inc waiting count
+ Interlocked.Increment(ref _waitingCount);
+ try
+ {
+ //Recover the session
+ session = await GetSessionAsync(entity, sessionId, cancellationToken);
+ }
+ finally
+ {
+ //Dec on exit
+ Interlocked.Decrement(ref _waitingCount);
+ }
+
+ //If the session is new (not in cache), then overwrite the session id with a new one as user may have specified their own
+ if (session.IsNew)
+ {
+ session.RegenID();
+ }
+
+ //Make sure the session has not expired yet
+ if (session.Created.Add(factory.ValidFor) < DateTimeOffset.UtcNow)
+ {
+ //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
+ session.Token = null;
+ session.UserID = null;
+ session.Privilages = 0;
+ session.SetLoginToken(null);
+ }
+ return new SessionHandle(session, HandleClosedAsync);
+ }
+ catch (SessionException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ throw new SessionException("Exception raised while retreiving or loading Web session", ex);
+ }
+ }
+ }
+}
diff --git a/Libs/VNLib.Plugins.Essentials.Sessions.VNCache/WebSessionProviderEntry.cs b/Libs/VNLib.Plugins.Essentials.Sessions.VNCache/WebSessionProviderEntry.cs
new file mode 100644
index 0000000..f72d1c5
--- /dev/null
+++ b/Libs/VNLib.Plugins.Essentials.Sessions.VNCache/WebSessionProviderEntry.cs
@@ -0,0 +1,101 @@
+using System.Text.Json;
+
+using VNLib.Net.Http;
+using VNLib.Utils.Memory;
+using VNLib.Utils.Logging;
+using VNLib.Utils.Extensions;
+using VNLib.Plugins.Extensions.Loading;
+using VNLib.Plugins.Extensions.Loading.Configuration;
+
+namespace VNLib.Plugins.Essentials.Sessions.VNCache
+{
+ public sealed class WebSessionProviderEntry : IRuntimeSessionProvider
+ {
+ const string VNCACHE_CONFIG_KEY = "vncache";
+ const string WEB_SESSION_CONFIG = "web";
+
+ private WebSessionProvider? _sessions;
+
+ public bool CanProcess(IHttpEvent entity)
+ {
+ //Web sessions can always be provided so long as cache is loaded
+ return _sessions != null;
+ }
+
+ public ValueTask<SessionHandle> GetSessionAsync(IHttpEvent entity, CancellationToken cancellationToken)
+ {
+ return _sessions!.GetSessionAsync(entity, cancellationToken);
+ }
+
+ void IRuntimeSessionProvider.Load(PluginBase plugin, ILogProvider localized)
+ {
+ //Try get vncache config element
+ IReadOnlyDictionary<string, JsonElement> cacheConfig = plugin.GetConfig(VNCACHE_CONFIG_KEY);
+
+ IReadOnlyDictionary<string, JsonElement> webSessionConfig = plugin.GetConfig(WEB_SESSION_CONFIG);
+
+ uint cookieSize = webSessionConfig["cookie_size"].GetUInt32();
+ string cookieName = webSessionConfig["cookie_name"].GetString() ?? throw new KeyNotFoundException($"Missing required element 'cookie_name' for config '{WEB_SESSION_CONFIG}'");
+ string cachePrefix = webSessionConfig["cache_prefix"].GetString() ?? throw new KeyNotFoundException($"Missing required element 'cache_prefix' for config '{WEB_SESSION_CONFIG}'");
+ TimeSpan validFor = webSessionConfig["valid_for_sec"].GetTimeSpan(TimeParseType.Seconds);
+
+
+ //Init id factory
+ WebSessionIdFactoryImpl idFactory = new(cookieSize, cookieName, cachePrefix, validFor);
+
+ //Run client connection
+ _ = WokerDoWorkAsync(plugin, localized, idFactory, cacheConfig, webSessionConfig);
+ }
+
+
+ /*
+ * Starts and monitors the VNCache connection
+ */
+
+ private async Task WokerDoWorkAsync(
+ PluginBase plugin,
+ ILogProvider localized,
+ WebSessionIdFactoryImpl idFactory,
+ IReadOnlyDictionary<string, JsonElement> cacheConfig,
+ IReadOnlyDictionary<string, JsonElement> webSessionConfig)
+ {
+ //Init cache client
+ using VnCacheClient cache = new(plugin.IsDebug() ? plugin.Log : null, Memory.Shared);
+
+ try
+ {
+ int cacheLimit = (int)webSessionConfig["cache_size"].GetUInt32();
+ uint maxConnections = webSessionConfig["max_waiting_connections"].GetUInt32();
+
+ //Try loading config
+ await cache.LoadConfigAsync(plugin, cacheConfig);
+
+ //Init provider
+ _sessions = new(cache.Resource!, cacheLimit, maxConnections, idFactory);
+
+
+ localized.Information("Session provider loaded");
+
+ //Run and wait for exit
+ await cache.RunAsync(localized, plugin.UnloadToken);
+
+ }
+ catch (OperationCanceledException)
+ { }
+ catch (KeyNotFoundException e)
+ {
+ localized.Error("Missing required configuration variable for VnCache client: {0}", e.Message);
+ }
+ catch (Exception ex)
+ {
+ localized.Error(ex, "Cache client error occured in session provider");
+ }
+ finally
+ {
+ _sessions = null;
+ }
+
+ localized.Information("Cache client exited");
+ }
+ }
+} \ No newline at end of file
diff --git a/Libs/VNLib.Plugins.Essentials.Sessions/MemorySession.cs b/Libs/VNLib.Plugins.Essentials.Sessions/MemorySession.cs
new file mode 100644
index 0000000..35e2fea
--- /dev/null
+++ b/Libs/VNLib.Plugins.Essentials.Sessions/MemorySession.cs
@@ -0,0 +1,107 @@
+using System;
+using System.Net;
+using System.Threading.Tasks;
+using System.Collections.Generic;
+
+using VNLib.Net.Http;
+using VNLib.Plugins.Essentials.Extensions;
+
+using static VNLib.Plugins.Essentials.Sessions.ISessionExtensions;
+
+#nullable enable
+
+namespace VNLib.Plugins.Essentials.Sessions.Memory
+{
+ internal class MemorySession : SessionBase
+ {
+ private readonly Dictionary<string, string> DataStorage;
+
+ private readonly MemorySessionStore SessionStore;
+
+ public MemorySession(IPAddress ipAddress, MemorySessionStore SessionStore)
+ {
+ //Set the initial is-new flag
+ DataStorage = new Dictionary<string, string>(10);
+ this.SessionStore = SessionStore;
+ //Get new session id
+ SessionID = SessionStore.NewSessionID;
+ UserIP = ipAddress;
+ SessionType = SessionType.Web;
+ Created = DateTimeOffset.UtcNow;
+ //Init
+ IsNew = true;
+ }
+ //Store in memory directly
+ public override IPAddress UserIP { get; protected set; }
+
+ //Session type has no backing store, so safe to hard-code it's always web
+
+ public override SessionType SessionType => SessionType.Web;
+
+ protected override ValueTask<Task?> UpdateResource(bool isAsync, IHttpEvent state)
+ {
+ //if invalid is set, invalide the current session
+ if (Flags.IsSet(INVALID_MSK))
+ {
+ //Clear storage, and regenerate the sessionid
+ DataStorage.Clear();
+ RegenId(state);
+ //Reset ip-address
+ UserIP = state.Server.GetTrustedIp();
+ //Update created-time
+ Created = DateTimeOffset.UtcNow;
+ //Re-initialize the session to the state of the current connection
+ this.InitNewSession(state.Server);
+ //Modified flag doesnt matter since there is no write-back
+
+ }
+ else if (Flags.IsSet(REGEN_ID_MSK))
+ {
+ //Regen id without modifying the data store
+ RegenId(state);
+ }
+ //Clear flags
+ Flags.ClearAll();
+ //Memory session always completes
+ return ValueTask.FromResult<Task?>(null);
+ }
+
+ private void RegenId(IHttpEvent entity)
+ {
+ //Get a new session-id
+ string newId = SessionStore.NewSessionID;
+ //Update the cache entry
+ SessionStore.UpdateRecord(newId, this);
+ //store new sessionid
+ SessionID = newId;
+ //set cookie
+ SessionStore.SetSessionCookie(entity, this);
+ }
+
+ protected override Task OnEvictedAsync()
+ {
+ //Clear all session data
+ DataStorage.Clear();
+ return Task.CompletedTask;
+ }
+
+ protected override string IndexerGet(string key)
+ {
+ return DataStorage.GetValueOrDefault(key, string.Empty);
+ }
+
+ protected override void IndexerSet(string key, string value)
+ {
+ //Check for special keys
+ switch (key)
+ {
+ //For tokens/login hashes, we can set the upgrade flag
+ case TOKEN_ENTRY:
+ case LOGIN_TOKEN_ENTRY:
+ Flags.Set(REGEN_ID_MSK);
+ break;
+ }
+ DataStorage[key] = value;
+ }
+ }
+}
diff --git a/Libs/VNLib.Plugins.Essentials.Sessions/MemorySessionConfig.cs b/Libs/VNLib.Plugins.Essentials.Sessions/MemorySessionConfig.cs
new file mode 100644
index 0000000..cbbaf53
--- /dev/null
+++ b/Libs/VNLib.Plugins.Essentials.Sessions/MemorySessionConfig.cs
@@ -0,0 +1,33 @@
+using System;
+using VNLib.Utils.Logging;
+
+namespace VNLib.Net.Sessions
+{
+ /// <summary>
+ /// Represents configration variables used to create and operate http sessions.
+ /// </summary>
+ public readonly struct MemorySessionConfig
+ {
+ /// <summary>
+ /// The name of the cookie to use for matching sessions
+ /// </summary>
+ public string SessionCookieID { get; init; }
+ /// <summary>
+ /// The size (in bytes) of the genreated SessionIds
+ /// </summary>
+ public uint SessionIdSizeBytes { get; init; }
+ /// <summary>
+ /// The amount of time a session is valid (within the backing store)
+ /// </summary>
+ public TimeSpan SessionTimeout { get; init; }
+ /// <summary>
+ /// The log for which all errors within the <see cref="SessionProvider"/> instance will be written to.
+ /// </summary>
+ public ILogProvider SessionLog { get; init; }
+ /// <summary>
+ /// The maximum number of sessions allowed to be cached in memory. If this value is exceed requests to this
+ /// server will be denied with a 503 error code
+ /// </summary>
+ public int MaxAllowedSessions { get; init; }
+ }
+} \ No newline at end of file
diff --git a/Libs/VNLib.Plugins.Essentials.Sessions/MemorySessionEntrypoint.cs b/Libs/VNLib.Plugins.Essentials.Sessions/MemorySessionEntrypoint.cs
new file mode 100644
index 0000000..f41d384
--- /dev/null
+++ b/Libs/VNLib.Plugins.Essentials.Sessions/MemorySessionEntrypoint.cs
@@ -0,0 +1,64 @@
+using System;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Collections.Generic;
+
+
+using VNLib.Net.Http;
+using VNLib.Net.Sessions;
+using VNLib.Utils.Logging;
+using VNLib.Utils.Extensions;
+using VNLib.Plugins.Extensions.Loading.Configuration;
+using VNLib.Plugins.Extensions.Loading.Events;
+
+#nullable enable
+
+namespace VNLib.Plugins.Essentials.Sessions.Memory
+{
+ public sealed class MemorySessionEntrypoint : IRuntimeSessionProvider, IIntervalScheduleable
+ {
+ const string WEB_SESSION_CONFIG = "web";
+
+ private MemorySessionStore? _sessions;
+
+ bool IRuntimeSessionProvider.CanProcess(IHttpEvent entity)
+ {
+ //Web sessions can always be provided
+ return _sessions != null;
+ }
+
+ public ValueTask<SessionHandle> GetSessionAsync(IHttpEvent entity, CancellationToken cancellationToken)
+ {
+ return _sessions!.GetSessionAsync(entity, cancellationToken);
+ }
+
+ void IRuntimeSessionProvider.Load(PluginBase plugin, ILogProvider localized)
+ {
+ //Get websessions config element
+
+ IReadOnlyDictionary<string, JsonElement> webSessionConfig = plugin.GetConfig(WEB_SESSION_CONFIG);
+
+ MemorySessionConfig config = new()
+ {
+ SessionLog = localized,
+ MaxAllowedSessions = webSessionConfig["cache_size"].GetInt32(),
+ SessionIdSizeBytes = webSessionConfig["cookie_size"].GetUInt32(),
+ SessionTimeout = webSessionConfig["valid_for_sec"].GetTimeSpan(TimeParseType.Seconds),
+ SessionCookieID = webSessionConfig["cookie_name"].GetString() ?? throw new KeyNotFoundException($"Missing required element 'cookie_name' for config '{WEB_SESSION_CONFIG}'"),
+ };
+
+ _sessions = new(config);
+
+ //Schedule garbage collector
+ _ = plugin.ScheduleInterval(this, TimeSpan.FromMinutes(1));
+ }
+
+ Task IIntervalScheduleable.OnIntervalAsync(ILogProvider log, CancellationToken cancellationToken)
+ {
+ //Cleanup expired sessions on interval
+ _sessions?.GC();
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/Libs/VNLib.Plugins.Essentials.Sessions/MemorySessionStore.cs b/Libs/VNLib.Plugins.Essentials.Sessions/MemorySessionStore.cs
new file mode 100644
index 0000000..15c3002
--- /dev/null
+++ b/Libs/VNLib.Plugins.Essentials.Sessions/MemorySessionStore.cs
@@ -0,0 +1,127 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Collections.Generic;
+
+using VNLib.Hashing;
+using VNLib.Net.Http;
+using VNLib.Net.Sessions;
+using VNLib.Utils;
+using VNLib.Utils.Async;
+using VNLib.Utils.Extensions;
+using VNLib.Plugins.Essentials.Extensions;
+
+
+namespace VNLib.Plugins.Essentials.Sessions.Memory
+{
+
+ /// <summary>
+ /// An <see cref="ISessionProvider"/> for in-process-memory backed sessions
+ /// </summary>
+ internal sealed class MemorySessionStore : ISessionProvider
+ {
+ private readonly Dictionary<string, MemorySession> SessionsStore;
+
+ internal readonly MemorySessionConfig Config;
+
+ public MemorySessionStore(MemorySessionConfig config)
+ {
+ Config = config;
+ SessionsStore = new(config.MaxAllowedSessions, StringComparer.Ordinal);
+ }
+
+ ///<inheritdoc/>
+ public async ValueTask<SessionHandle> GetSessionAsync(IHttpEvent entity, CancellationToken cancellationToken)
+ {
+
+ static ValueTask SessionHandleClosedAsync(ISession session, IHttpEvent ev)
+ {
+ return (session as MemorySession).UpdateAndRelease(true, ev);
+ }
+
+ //Check for previous session cookie
+ if (entity.Server.RequestCookies.TryGetNonEmptyValue(Config.SessionCookieID, out string sessionId))
+ {
+ //Try to get the old record or evict it
+ ERRNO result = SessionsStore.TryGetOrEvictRecord(sessionId, out MemorySession session);
+ if(result > 0)
+ {
+ //Valid, now wait for exclusive access
+ await session.WaitOneAsync(cancellationToken);
+ return new (session, SessionHandleClosedAsync);
+ }
+ //Continue creating a new session
+ }
+
+ //Dont service non browsers for new sessions
+ if (!entity.Server.IsBrowser())
+ {
+ return SessionHandle.Empty;
+ }
+
+ //try to cleanup expired records
+ SessionsStore.CollectRecords();
+ //Make sure there is enough room to add a new session
+ if (SessionsStore.Count >= Config.MaxAllowedSessions)
+ {
+ entity.Server.SetNoCache();
+ //Set 503 when full
+ entity.CloseResponse(System.Net.HttpStatusCode.ServiceUnavailable);
+ //Cannot service new session
+ return new(null, FileProcessArgs.VirtualSkip, null);
+ }
+ //Initialze a new session
+ MemorySession ms = new(entity.Server.GetTrustedIp(), this);
+ //Set session cookie
+ SetSessionCookie(entity, ms);
+ //Increment the semaphore
+ (ms as IWaitHandle).WaitOne();
+ //store the session in cache while holding semaphore, and set its expiration
+ SessionsStore.StoreRecord(ms.SessionID, ms, Config.SessionTimeout);
+ //Init new session handle
+ return new SessionHandle(ms, SessionHandleClosedAsync);
+ }
+
+ /// <summary>
+ /// Gets a new unique sessionid for sessions
+ /// </summary>
+ internal string NewSessionID => RandomHash.GetRandomHex((int)Config.SessionIdSizeBytes);
+
+ internal void UpdateRecord(string newSessId, MemorySession session)
+ {
+ lock (SessionsStore)
+ {
+ //Remove old record from the store
+ SessionsStore.Remove(session.SessionID);
+ //Insert the new session
+ SessionsStore.Add(newSessId, session);
+ }
+ }
+ /// <summary>
+ /// Sets a standard session cookie for an entity/connection
+ /// </summary>
+ /// <param name="entity">The entity to set the cookie on</param>
+ /// <param name="session">The session attached to the </param>
+ internal void SetSessionCookie(IHttpEvent entity, MemorySession session)
+ {
+ //Set session cookie
+ entity.Server.SetCookie(Config.SessionCookieID, session.SessionID, null, "/", Config.SessionTimeout, CookieSameSite.Lax, true, true);
+ }
+ /// <summary>
+ /// Evicts all sessions from the current store
+ /// </summary>
+ public void Cleanup()
+ {
+ //Expire all old records to cleanup all entires
+ this.SessionsStore.CollectRecords(DateTime.MaxValue);
+ }
+ /// <summary>
+ /// Collects all expired records from the current store
+ /// </summary>
+ public void GC()
+ {
+ //collect expired records
+ this.SessionsStore.CollectRecords();
+ }
+ }
+}
diff --git a/Libs/VNLib.Plugins.Essentials.Sessions/VNLib.Plugins.Essentials.Sessions.Memory.csproj b/Libs/VNLib.Plugins.Essentials.Sessions/VNLib.Plugins.Essentials.Sessions.Memory.csproj
new file mode 100644
index 0000000..0d21b31
--- /dev/null
+++ b/Libs/VNLib.Plugins.Essentials.Sessions/VNLib.Plugins.Essentials.Sessions.Memory.csproj
@@ -0,0 +1,43 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>net6.0</TargetFramework>
+ <Platforms>AnyCPU;x64</Platforms>
+
+ <EnableDynamicLoading>true</EnableDynamicLoading>
+ </PropertyGroup>
+
+ <!-- Resolve nuget dll files and store them in the output dir -->
+ <PropertyGroup>
+ <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
+ <AssemblyName>VNLib.Plugins.Essentials.Sessions.Memory</AssemblyName>
+ <RootNamespace>VNLib.Plugins.Essentials.Sessions.Memory</RootNamespace>
+ <Authors>Vaughn Nugent</Authors>
+ <Copyright>Copyright © 2022 Vaughn Nugent</Copyright>
+ <PackageProjectUrl>www.vaughnnugent.com/resources</PackageProjectUrl>
+ </PropertyGroup>
+ <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
+ <DocumentationFile></DocumentationFile>
+ </PropertyGroup>
+ <ProjectExtensions><VisualStudio><UserProperties /></VisualStudio></ProjectExtensions>
+ <ItemGroup>
+ <PackageReference Include="ErrorProne.NET.CoreAnalyzers" Version="0.1.2">
+ <PrivateAssets>all</PrivateAssets>
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ </PackageReference>
+ <PackageReference Include="ErrorProne.NET.Structs" Version="0.1.2">
+ <PrivateAssets>all</PrivateAssets>
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ </PackageReference>
+ </ItemGroup>
+ <ItemGroup>
+ <ProjectReference Include="..\..\..\..\VNLib\Essentials\VNLib.Plugins.Essentials.csproj" />
+ <ProjectReference Include="..\..\..\..\VNLib\Http\VNLib.Net.Http.csproj" />
+ <ProjectReference Include="..\VNLib.Plugins.Essentials.Sessions.Runtime\VNLib.Plugins.Essentials.Sessions.Runtime.csproj" />
+ </ItemGroup>
+ <Target Name="PostBuild" AfterTargets="PostBuildEvent">
+ <Exec Command="start xcopy &quot;$(TargetDir)&quot; &quot;F:\Programming\Web Plugins\DevPlugins\SessionProviders\$(TargetName)&quot; /E /Y /R" />
+ </Target>
+
+
+</Project>
diff --git a/Libs/VNLib.Plugins.Essentials.Sessions/VNLib.Plugins.Essentials.Sessions.Memory.xml b/Libs/VNLib.Plugins.Essentials.Sessions/VNLib.Plugins.Essentials.Sessions.Memory.xml
new file mode 100644
index 0000000..9c596c3
--- /dev/null
+++ b/Libs/VNLib.Plugins.Essentials.Sessions/VNLib.Plugins.Essentials.Sessions.Memory.xml
@@ -0,0 +1,75 @@
+<?xml version="1.0"?>
+<doc>
+ <assembly>
+ <name>VNLib.Plugins.Essentials.Sessions.Memory</name>
+ </assembly>
+ <members>
+ <member name="T:VNLib.Plugins.Essentials.Sessions.Memory.MemorySessionStore">
+ <summary>
+ An <see cref="T:VNLib.Plugins.Essentials.Sessions.ISessionProvider"/> for in-process-memory backed sessions
+ </summary>
+ </member>
+ <member name="M:VNLib.Plugins.Essentials.Sessions.Memory.MemorySessionStore.GetSessionAsync(VNLib.Net.Http.HttpEvent,System.Threading.CancellationToken)">
+ <inheritdoc/>
+ </member>
+ <member name="P:VNLib.Plugins.Essentials.Sessions.Memory.MemorySessionStore.NewSessionID">
+ <summary>
+ Gets a new unique sessionid for sessions
+ </summary>
+ </member>
+ <member name="M:VNLib.Plugins.Essentials.Sessions.Memory.MemorySessionStore.SetSessionCookie(VNLib.Net.Http.HttpEvent,VNLib.Plugins.Essentials.Sessions.Memory.MemorySession)">
+ <summary>
+ Sets a standard session cookie for an entity/connection
+ </summary>
+ <param name="entity">The entity to set the cookie on</param>
+ <param name="session">The session attached to the </param>
+ </member>
+ <member name="M:VNLib.Plugins.Essentials.Sessions.Memory.MemorySessionStore.Cleanup">
+ <summary>
+ Evicts all sessions from the current store
+ </summary>
+ </member>
+ <member name="M:VNLib.Plugins.Essentials.Sessions.Memory.MemorySessionStore.GC">
+ <summary>
+ Collects all expired records from the current store
+ </summary>
+ </member>
+ <member name="T:VNLib.Plugins.Essentials.Sessions.Memory.MemSessionHandle">
+ <summary>
+ Provides a one-time-use handle (similar to asyncReleaser, or openHandle)
+ that holds exclusive access to a session until it is released
+ </summary>
+ </member>
+ <member name="T:VNLib.Net.Sessions.MemorySessionConfig">
+ <summary>
+ Represents configration variables used to create and operate http sessions.
+ </summary>
+ </member>
+ <member name="P:VNLib.Net.Sessions.MemorySessionConfig.SessionCookieID">
+ <summary>
+ The name of the cookie to use for matching sessions
+ </summary>
+ </member>
+ <member name="P:VNLib.Net.Sessions.MemorySessionConfig.SessionIdSizeBytes">
+ <summary>
+ The size (in bytes) of the genreated SessionIds
+ </summary>
+ </member>
+ <member name="P:VNLib.Net.Sessions.MemorySessionConfig.SessionTimeout">
+ <summary>
+ The amount of time a session is valid (within the backing store)
+ </summary>
+ </member>
+ <member name="P:VNLib.Net.Sessions.MemorySessionConfig.SessionLog">
+ <summary>
+ The log for which all errors within the <see cref="!:SessionProvider"/> instance will be written to.
+ </summary>
+ </member>
+ <member name="P:VNLib.Net.Sessions.MemorySessionConfig.MaxAllowedSessions">
+ <summary>
+ The maximum number of sessions allowed to be cached in memory. If this value is exceed requests to this
+ server will be denied with a 503 error code
+ </summary>
+ </member>
+ </members>
+</doc>
diff --git a/Libs/VNLib.Plugins.Sessions.Cache.Client/Exceptions/MessageTooLargeException.cs b/Libs/VNLib.Plugins.Sessions.Cache.Client/Exceptions/MessageTooLargeException.cs
new file mode 100644
index 0000000..15164ca
--- /dev/null
+++ b/Libs/VNLib.Plugins.Sessions.Cache.Client/Exceptions/MessageTooLargeException.cs
@@ -0,0 +1,27 @@
+using System;
+using System.Runtime.Serialization;
+
+using VNLib.Net.Messaging.FBM;
+
+namespace VNLib.Plugins.Sessions.Cache.Client
+{
+ /// <summary>
+ /// Raised when a request message is too large to send to
+ /// the server and the server may close the connection.
+ /// </summary>
+ public class MessageTooLargeException : FBMException
+ {
+ ///<inheritdoc/>
+ public MessageTooLargeException()
+ {}
+ ///<inheritdoc/>
+ public MessageTooLargeException(string message) : base(message)
+ {}
+ ///<inheritdoc/>
+ public MessageTooLargeException(string message, Exception innerException) : base(message, innerException)
+ {}
+ ///<inheritdoc/>
+ protected MessageTooLargeException(SerializationInfo info, StreamingContext context) : base(info, context)
+ {}
+ }
+}
diff --git a/Libs/VNLib.Plugins.Sessions.Cache.Client/Exceptions/SessionStatusException.cs b/Libs/VNLib.Plugins.Sessions.Cache.Client/Exceptions/SessionStatusException.cs
new file mode 100644
index 0000000..5bb6f42
--- /dev/null
+++ b/Libs/VNLib.Plugins.Sessions.Cache.Client/Exceptions/SessionStatusException.cs
@@ -0,0 +1,19 @@
+using System;
+using System.Runtime.Serialization;
+
+using VNLib.Plugins.Essentials.Sessions;
+
+namespace VNLib.Plugins.Sessions.Cache.Client
+{
+ public class SessionStatusException : SessionException
+ {
+ public SessionStatusException()
+ {}
+ public SessionStatusException(string message) : base(message)
+ {}
+ public SessionStatusException(string message, Exception innerException) : base(message, innerException)
+ {}
+ protected SessionStatusException(SerializationInfo info, StreamingContext context) : base(info, context)
+ {}
+ }
+}
diff --git a/Libs/VNLib.Plugins.Sessions.Cache.Client/Exceptions/SessionUpdateFailedException.cs b/Libs/VNLib.Plugins.Sessions.Cache.Client/Exceptions/SessionUpdateFailedException.cs
new file mode 100644
index 0000000..1b842b7
--- /dev/null
+++ b/Libs/VNLib.Plugins.Sessions.Cache.Client/Exceptions/SessionUpdateFailedException.cs
@@ -0,0 +1,17 @@
+using System;
+using System.Runtime.Serialization;
+
+namespace VNLib.Plugins.Sessions.Cache.Client
+{
+ public class SessionUpdateFailedException : SessionStatusException
+ {
+ public SessionUpdateFailedException()
+ {}
+ public SessionUpdateFailedException(string message) : base(message)
+ {}
+ public SessionUpdateFailedException(string message, Exception innerException) : base(message, innerException)
+ {}
+ protected SessionUpdateFailedException(SerializationInfo info, StreamingContext context) : base(info, context)
+ {}
+ }
+}
diff --git a/Libs/VNLib.Plugins.Sessions.Cache.Client/RemoteSession.cs b/Libs/VNLib.Plugins.Sessions.Cache.Client/RemoteSession.cs
new file mode 100644
index 0000000..2ab27f5
--- /dev/null
+++ b/Libs/VNLib.Plugins.Sessions.Cache.Client/RemoteSession.cs
@@ -0,0 +1,173 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Collections.Generic;
+
+using Microsoft.VisualStudio.Threading;
+
+using VNLib.Net.Http;
+using VNLib.Data.Caching;
+using VNLib.Data.Caching.Exceptions;
+using VNLib.Utils.Extensions;
+using VNLib.Net.Messaging.FBM.Client;
+using VNLib.Plugins.Essentials.Sessions;
+using VNLib.Plugins.Essentials.Extensions;
+
+#nullable enable
+
+namespace VNLib.Plugins.Sessions.Cache.Client
+{
+ /// <summary>
+ /// Base class for cacheable lazy initialized session entires
+ /// that exist in a remote caching server
+ /// </summary>
+ public abstract class RemoteSession : SessionBase
+ {
+ protected const string CREATED_TIME_ENTRY = "__.i.ctime";
+
+ protected readonly FBMClient Client;
+ protected readonly TimeSpan UpdateTimeout;
+
+ private readonly AsyncLazyInitializer Initializer;
+
+ /// <summary>
+ /// The lazy loaded data-store
+ /// </summary>
+ protected Dictionary<string, string>? DataStore;
+
+ public RemoteSession(string sessionId, FBMClient client, TimeSpan backgroundTimeOut)
+ {
+ SessionID = sessionId;
+ UpdateTimeout = backgroundTimeOut;
+ Client = client;
+ Initializer = new(InitializeAsync, null);
+ }
+
+ /// <summary>
+ /// The data initializer, loads the data store from the connected cache server
+ /// </summary>
+ /// <returns>A task that completes when the get operation completes</returns>
+ protected virtual async Task InitializeAsync()
+ {
+ //Setup timeout cancellation for the get, to cancel it
+ using CancellationTokenSource cts = new(UpdateTimeout);
+ //get or create a new session
+ DataStore = await Client.GetObjectAsync<Dictionary<string, string>>(SessionID, cancellationToken: cts.Token);
+ }
+ /// <summary>
+ /// Updates the current sessin agaisnt the cache store
+ /// </summary>
+ /// <returns>A task that complets when the update has completed</returns>
+ protected virtual async Task ProcessUpdateAsync()
+ {
+ //Setup timeout cancellation for the update, to cancel it
+ using CancellationTokenSource cts = new(UpdateTimeout);
+ await Client.AddOrUpdateObjectAsync(SessionID, null, DataStore, cts.Token);
+ }
+ /// <summary>
+ /// Delets the current session in the remote store
+ /// </summary>
+ /// <returns>A task that completes when instance has been deleted</returns>
+ protected virtual async Task ProcessDeleteAsync()
+ {
+ //Setup timeout cancellation for the update, to cancel it
+ using CancellationTokenSource cts = new(UpdateTimeout);
+ try
+ {
+ await Client.DeleteObjectAsync(SessionID, cts.Token);
+ }
+ catch (ObjectNotFoundException)
+ {
+ //This is fine, if the object does not exist, nothing to invalidate
+ }
+ }
+
+ ///<inheritdoc/>
+ public override DateTimeOffset Created
+ {
+ get
+ {
+ //Deserialze the base32 ms
+ long unixMs = this.GetValueType<string, long>(CREATED_TIME_ENTRY);
+ //set created time from ms
+ return DateTimeOffset.FromUnixTimeMilliseconds(unixMs);
+ }
+
+ protected set => this.SetValueType(CREATED_TIME_ENTRY, value.ToUnixTimeMilliseconds());
+ }
+ ///<inheritdoc/>
+ protected override string IndexerGet(string key)
+ {
+ //Get the value at the key or an empty string as a default
+ return DataStore!.GetValueOrDefault(key, string.Empty);
+ }
+ ///<inheritdoc/>
+ protected override void IndexerSet(string key, string value)
+ {
+ //If the value is null, remove the key from the store
+ if (value == null)
+ {
+ //Set modified flag
+ IsModified |= DataStore!.Remove(key);
+ }
+ else
+ {
+ //Store the value at the specified key
+ DataStore![key] = value;
+ IsModified = true;
+ }
+ }
+
+
+ /*
+ * If the data-store is not found it means the session does not
+ * exist in cache, so its technically not dangerous to reuse,
+ * so the new mask needs to be set, but the old ID is going
+ * to be reused
+ */
+
+ /// <summary>
+ /// Waits for exclusive access to the session, and initializes
+ /// session data (loads it from the remote store)
+ /// </summary>
+ /// <param name="entity">The event to attach a session to</param>
+ /// <param name="cancellationToken">A token to cancel the operaion</param>
+ /// <returns></returns>
+ public virtual async Task WaitAndLoadAsync(IHttpEvent entity, CancellationToken cancellationToken)
+ {
+ //Wait for exclusive access
+ await base.WaitOneAsync(cancellationToken);
+ try
+ {
+ //Lazily initalize the current instance
+ await Initializer.InitializeAsync(cancellationToken);
+ //See if data-store is null (new session was created
+ if (DataStore == null)
+ {
+ //New session was created
+ DataStore = new(10);
+ //Set is-new flag
+ Flags.Set(IS_NEW_MSK);
+ //Set created time
+ Created = DateTimeOffset.UtcNow;
+ //Init ipaddress
+ UserIP = entity.Server.GetTrustedIp();
+ //Set modified flag so session will be updated
+ IsModified = true;
+ }
+ }
+ catch
+ {
+ MainLock.Release();
+ throw;
+ }
+ }
+ ///<inheritdoc/>
+ protected override Task OnEvictedAsync()
+ {
+ //empty the dict to help the GC
+ DataStore!.Clear();
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/Libs/VNLib.Plugins.Sessions.Cache.Client/SessionCacheClient.cs b/Libs/VNLib.Plugins.Sessions.Cache.Client/SessionCacheClient.cs
new file mode 100644
index 0000000..de0e370
--- /dev/null
+++ b/Libs/VNLib.Plugins.Sessions.Cache.Client/SessionCacheClient.cs
@@ -0,0 +1,159 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+
+using VNLib.Utils;
+using VNLib.Utils.Memory.Caching;
+using VNLib.Net.Http;
+using VNLib.Net.Messaging.FBM.Client;
+using VNLib.Plugins.Essentials.Sessions;
+
+#nullable enable
+
+namespace VNLib.Plugins.Sessions.Cache.Client
+{
+
+ /// <summary>
+ /// A client that allows access to sessions located on external servers
+ /// </summary>
+ public abstract class SessionCacheClient : VnDisposeable, ICacheHolder
+ {
+ public class LRUSessionStore<T> : LRUCache<string, T> where T : ISession, ICacheable
+ {
+ public override bool IsReadOnly => false;
+ protected override int MaxCapacity { get; }
+
+ public LRUSessionStore(int maxCapacity) : base(StringComparer.Ordinal) => MaxCapacity = maxCapacity;
+
+ protected override bool CacheMiss(string key, [NotNullWhen(true)] out T? value)
+ {
+ value = default;
+ return false;
+ }
+ protected override void Evicted(KeyValuePair<string, T> evicted)
+ {
+ //Evice record
+ evicted.Value.Evicted();
+ }
+ }
+
+ protected readonly LRUSessionStore<RemoteSession> CacheTable;
+ protected readonly object CacheLock;
+ protected readonly int MaxLoadedEntires;
+
+ protected FBMClient Client { get; }
+
+ /// <summary>
+ /// Initializes a new <see cref="SessionCacheClient"/>
+ /// </summary>
+ /// <param name="client"></param>
+ /// <param name="maxCacheItems">The maximum number of sessions to keep in memory</param>
+ public SessionCacheClient(FBMClient client, int maxCacheItems)
+ {
+ MaxLoadedEntires = maxCacheItems;
+ CacheLock = new();
+ CacheTable = new(maxCacheItems);
+ Client = client;
+ //Listen for close events
+ Client.ConnectionClosed += Client_ConnectionClosed;
+ }
+
+ private void Client_ConnectionClosed(object? sender, EventArgs e) => CacheHardClear();
+
+ /// <summary>
+ /// Attempts to get a session from the cache identified by its sessionId asynchronously
+ /// </summary>
+ /// <param name="entity">The connection/request to attach the session to</param>
+ /// <param name="sessionId">The ID of the session to retrieve</param>
+ /// <param name="cancellationToken">A token to cancel the operation</param>
+ /// <returns>A <see cref="ValueTask"/> that resolves the remote session</returns>
+ /// <exception cref="SessionException"></exception>
+ public virtual async ValueTask<RemoteSession> GetSessionAsync(IHttpEvent entity, string sessionId, CancellationToken cancellationToken)
+ {
+ Check();
+ try
+ {
+ RemoteSession? session;
+ //Aquire lock on cache
+ lock (CacheLock)
+ {
+ //See if session is loaded into cache
+ if (!CacheTable.TryGetValue(sessionId, out session))
+ {
+ //Init new record
+ session = SessionCtor(sessionId);
+ //Add to cache
+ CacheTable.Add(session.SessionID, session);
+ }
+ //Valid entry found in cache
+ }
+ try
+ {
+ //Load session-data
+ await session.WaitAndLoadAsync(entity, cancellationToken);
+ return session;
+ }
+ catch
+ {
+ //Remove the invalid cached session
+ lock (CacheLock)
+ {
+ _ = CacheTable.Remove(sessionId);
+ }
+ throw;
+ }
+ }
+ catch (SessionException)
+ {
+ throw;
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ //Wrap exceptions
+ catch (Exception ex)
+ {
+ throw new SessionException("An unhandled exception was raised", ex);
+ }
+ }
+
+ /// <summary>
+ /// Gets a new <see cref="RemoteSession"/> instances for the given sessionId,
+ /// and places it a the head of internal cache
+ /// </summary>
+ /// <param name="sessionId">The session identifier</param>
+ /// <returns>The new session for the given ID</returns>
+ protected abstract RemoteSession SessionCtor(string sessionId);
+
+ ///<inheritdoc/>
+ public void CacheClear()
+ {
+
+ }
+ ///<inheritdoc/>
+ public void CacheHardClear()
+ {
+ //Cleanup cache when disconnected
+ lock (CacheLock)
+ {
+ CacheTable.Clear();
+ foreach (RemoteSession session in (IEnumerable<RemoteSession>)CacheTable)
+ {
+ session.Evicted();
+ }
+ CacheTable.Clear();
+ }
+ }
+
+ protected override void Free()
+ {
+ //Unsub from events
+ Client.ConnectionClosed -= Client_ConnectionClosed;
+ //Clear all cached sessions
+ CacheHardClear();
+ }
+ }
+}
diff --git a/Libs/VNLib.Plugins.Sessions.Cache.Client/VNLib.Plugins.Sessions.Cache.Client.csproj b/Libs/VNLib.Plugins.Sessions.Cache.Client/VNLib.Plugins.Sessions.Cache.Client.csproj
new file mode 100644
index 0000000..0c12cec
--- /dev/null
+++ b/Libs/VNLib.Plugins.Sessions.Cache.Client/VNLib.Plugins.Sessions.Cache.Client.csproj
@@ -0,0 +1,33 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>net6.0</TargetFramework>
+ <Platforms>AnyCPU;x64</Platforms>
+ <Authors>Vaughn Nugent</Authors>
+ <Copyright>Copyright © 2022 Vaughn Nugent</Copyright>
+ <Version>1.0.0.1</Version>
+ </PropertyGroup>
+
+ <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
+ <DocumentationFile></DocumentationFile>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="ErrorProne.NET.CoreAnalyzers" Version="0.1.2">
+ <PrivateAssets>all</PrivateAssets>
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ </PackageReference>
+ <PackageReference Include="ErrorProne.NET.Structs" Version="0.1.2">
+ <PrivateAssets>all</PrivateAssets>
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ </PackageReference>
+ <PackageReference Include="Microsoft.VisualStudio.Threading" Version="17.3.44" />
+ <PackageReference Include="RestSharp" Version="108.0.2" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\..\..\..\VNLib\Essentials\VNLib.Plugins.Essentials.csproj" />
+ <ProjectReference Include="..\..\..\DataCaching\VNLib.Data.Caching\src\VNLib.Data.Caching.csproj" />
+ </ItemGroup>
+
+</Project>
diff --git a/Libs/VNLib.Plugins.Sessions.Cache.Client/VNLib.Plugins.Sessions.Cache.Client.xml b/Libs/VNLib.Plugins.Sessions.Cache.Client/VNLib.Plugins.Sessions.Cache.Client.xml
new file mode 100644
index 0000000..95fafd7
--- /dev/null
+++ b/Libs/VNLib.Plugins.Sessions.Cache.Client/VNLib.Plugins.Sessions.Cache.Client.xml
@@ -0,0 +1,69 @@
+<?xml version="1.0"?>
+<doc>
+ <assembly>
+ <name>VNLib.Plugins.Sessions.Cache.Client</name>
+ </assembly>
+ <members>
+ <member name="T:VNLib.Plugins.Sessions.Cache.Client.MessageTooLargeException">
+ <summary>
+ Raised when a request message is too large to send to
+ the server and the server may close the connection.
+ </summary>
+ </member>
+ <member name="M:VNLib.Plugins.Sessions.Cache.Client.MessageTooLargeException.#ctor">
+ <inheritdoc/>
+ </member>
+ <member name="M:VNLib.Plugins.Sessions.Cache.Client.MessageTooLargeException.#ctor(System.String)">
+ <inheritdoc/>
+ </member>
+ <member name="M:VNLib.Plugins.Sessions.Cache.Client.MessageTooLargeException.#ctor(System.String,System.Exception)">
+ <inheritdoc/>
+ </member>
+ <member name="M:VNLib.Plugins.Sessions.Cache.Client.MessageTooLargeException.#ctor(System.Runtime.Serialization.SerializationInfo,System.Runtime.Serialization.StreamingContext)">
+ <inheritdoc/>
+ </member>
+ <member name="T:VNLib.Plugins.Sessions.Cache.Client.SessionClient">
+ <summary>
+ A client that allows access to sessions located on external servers
+ </summary>
+ </member>
+ <member name="P:VNLib.Plugins.Sessions.Cache.Client.SessionClient.GetSessionId">
+ <summary>
+ A callback that produces a session-id from the connection (or a new id if needed)
+ </summary>
+ </member>
+ <member name="P:VNLib.Plugins.Sessions.Cache.Client.SessionClient.NewSessionId">
+ <summary>
+ A callback that produces a new session-id for the connection (and updates the client if necessary)
+ </summary>
+ </member>
+ <member name="M:VNLib.Plugins.Sessions.Cache.Client.SessionClient.#ctor(System.Int32,System.Int32,System.Int32,VNLib.Utils.Logging.ILogProvider,VNLib.Utils.Memory.PrivateHeap)">
+ <summary>
+ Initializes a new <see cref="T:VNLib.Plugins.Sessions.Cache.Client.SessionClient"/>
+ </summary>
+ <param name="maxMessageSize">The maxium message size (in bytes) the client will allow receiving (maximum data size for sessions)</param>
+ <param name="recvBufferSize">The size (in bytes) of the client message receive buffer</param>
+ <param name="maxCacheItems">The maximum number of sessions to keep in memory</param>
+ <param name="log">A <see cref="T:VNLib.Utils.Logging.ILogProvider"/> to write log events to</param>
+ <param name="heap">The <see cref="T:VNLib.Utils.Memory.PrivateHeap"/> to allocate buffers from</param>
+ </member>
+ <member name="M:VNLib.Plugins.Sessions.Cache.Client.SessionClient.GetSessionAsync(VNLib.Net.Http.HttpEvent,System.Threading.CancellationToken)">
+ <inheritdoc/>
+ </member>
+ <member name="M:VNLib.Plugins.Sessions.Cache.Client.SessionClient.CacheClear">
+ <inheritdoc/>
+ </member>
+ <member name="M:VNLib.Plugins.Sessions.Cache.Client.SessionClient.CacheHardClear">
+ <inheritdoc/>
+ </member>
+ <member name="M:VNLib.Plugins.Sessions.Cache.Client.SessionClient.OnConnected">
+ <inheritdoc/>
+ </member>
+ <member name="M:VNLib.Plugins.Sessions.Cache.Client.SessionClient.OnError(VNLib.Net.Messaging.FBM.Client.FMBClientErrorEventArgs)">
+ <inheritdoc/>
+ </member>
+ <member name="M:VNLib.Plugins.Sessions.Cache.Client.SessionClient.OnDisconnected">
+ <inheritdoc/>
+ </member>
+ </members>
+</doc>