aboutsummaryrefslogtreecommitdiff
path: root/Libs/VNLib.Plugins.Essentials.Sessions.OAuth
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/VNLib.Plugins.Essentials.Sessions.OAuth
Add project files.
Diffstat (limited to 'Libs/VNLib.Plugins.Essentials.Sessions.OAuth')
-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
11 files changed, 775 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>