aboutsummaryrefslogtreecommitdiff
path: root/Libs
diff options
context:
space:
mode:
authorLibravatar vman <public@vaughnnugent.com>2022-11-18 17:43:57 -0500
committerLibravatar vman <public@vaughnnugent.com>2022-11-18 17:43:57 -0500
commitef98ef0329d6ee8cec7f040f6c472dc1ea68e8dd (patch)
tree9be4b437895534f1f63b3a281e9e92c2a4a10421 /Libs
parent8b09e20f6dbaf7644fc64833d7d8eeda4b576ad9 (diff)
Add project files.
Diffstat (limited to 'Libs')
-rw-r--r--Libs/VNLib.Plugins.Essentials.Oauth/Applications/Applications.cs260
-rw-r--r--Libs/VNLib.Plugins.Essentials.Oauth/Applications/UserAppContext.cs19
-rw-r--r--Libs/VNLib.Plugins.Essentials.Oauth/Applications/UserApplication.cs133
-rw-r--r--Libs/VNLib.Plugins.Essentials.Oauth/Tokens/ActiveToken.cs16
-rw-r--r--Libs/VNLib.Plugins.Essentials.Oauth/Tokens/IOAuth2TokenResult.cs14
-rw-r--r--Libs/VNLib.Plugins.Essentials.Oauth/Tokens/ITokenManager.cs27
-rw-r--r--Libs/VNLib.Plugins.Essentials.Oauth/Tokens/TokenStore.cs171
-rw-r--r--Libs/VNLib.Plugins.Essentials.Oauth/VNLib.Plugins.Essentials.Oauth.csproj45
-rw-r--r--Libs/VNLib.Plugins.Essentials.Oauth/VNLib.Plugins.Essentials.Oauth.xml155
9 files changed, 840 insertions, 0 deletions
diff --git a/Libs/VNLib.Plugins.Essentials.Oauth/Applications/Applications.cs b/Libs/VNLib.Plugins.Essentials.Oauth/Applications/Applications.cs
new file mode 100644
index 0000000..850ccba
--- /dev/null
+++ b/Libs/VNLib.Plugins.Essentials.Oauth/Applications/Applications.cs
@@ -0,0 +1,260 @@
+using System;
+using System.Data;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Collections.Generic;
+
+using Microsoft.EntityFrameworkCore;
+
+using VNLib.Hashing;
+using VNLib.Utils;
+using VNLib.Utils.Memory;
+using VNLib.Plugins.Extensions.Data;
+using VNLib.Plugins.Essentials.Accounts;
+using VNLib.Plugins.Essentials.Oauth.Tokens;
+
+namespace VNLib.Plugins.Essentials.Oauth.Applications
+{
+ /// <summary>
+ /// A DbStore for <see cref="UserApplication"/>s for OAuth2 client applications
+ /// </summary>
+ public sealed partial class Applications : DbStore<UserApplication>
+ {
+ public const int SECRET_SIZE = 32;
+ public const int CLIENT_ID_SIZE = 16;
+
+ private readonly PasswordHashing SecretHashing;
+ private readonly DbContextOptions ConextOptions;
+ private readonly ITokenManager TokenStore;
+
+ /// <summary>
+ /// Initializes a new <see cref="Applications"/> data store
+ /// uisng the specified EFCore <see cref="DbContextOptions"/> object.
+ /// </summary>
+ /// <param name="conextOptions">EFCore context options for connecting to a remote data-store</param>
+ /// <param name="secretHashing">A <see cref="PasswordHashing"/> structure for hashing client secrets</param>
+ public Applications(DbContextOptions conextOptions, PasswordHashing secretHashing)
+ {
+ this.ConextOptions = conextOptions;
+ this.SecretHashing = secretHashing;
+ this.TokenStore = new TokenStore(conextOptions);
+ }
+
+
+ /// <summary>
+ /// Updates the secret of an application, and if successful returns the new raw secret data
+ /// </summary>
+ /// <param name="userId">The user-id of that owns the application</param>
+ /// <param name="appId">The id of the application to update</param>
+ /// <returns>A task that resolves to the raw secret that was used to generate the hash, or null if the operation failed</returns>
+ public async Task<PrivateString?> UpdateSecretAsync(string userId, string appId)
+ {
+ /*
+ * Delete open apps first, incase there are any issues, worse case
+ * the user's will have to re-authenticate.
+ *
+ * If we delete tokens after update, the user wont see the new
+ * secret and may lose access to the updated app, not a big deal
+ * but avoidable.
+ */
+ await TokenStore.RevokeTokensForAppAsync(appId, CancellationToken.None);
+ //Generate the new secret
+ PrivateString secret = GenerateSecret();
+ //Hash the secret
+ using PrivateString secretHash = SecretHashing.Hash(secret);
+ //Open new db context
+ await using UserAppContext Database = new(ConextOptions);
+ //Open transaction
+ await Database.OpenTransactionAsync();
+ //Get the app to update the secret on
+ UserApplication? app = await (from ap in Database.OAuthApps
+ where ap.UserId == userId && ap.Id == appId
+ select ap)
+ .SingleOrDefaultAsync();
+ if (app == null)
+ {
+ return null;
+ }
+ //Store the new secret hash
+ app.SecretHash = (string)secretHash;
+ //Save changes
+ if (await Database.SaveChangesAsync() <= 0)
+ {
+ return null;
+ }
+ //Commit transaction
+ await Database.CommitTransactionAsync();
+ //return the raw secret
+ return secret;
+ }
+
+ /// <summary>
+ /// Attempts to retreive an application by the specified client id and compares the raw secret against the
+ /// stored secret hash.
+ /// </summary>
+ /// <param name="clientId">The clientid of the application to search</param>
+ /// <param name="secret">The secret to compare against</param>
+ /// <returns>True if the application was found and the secret matches the stored secret, false if the appliation was not found or the secret does not match</returns>
+ public async Task<UserApplication?> VerifyAppAsync(string clientId, PrivateString secret)
+ {
+ UserApplication? app;
+ //Open new db context
+ await using (UserAppContext Database = new(ConextOptions))
+ {
+ //Open transaction
+ await Database.OpenTransactionAsync();
+ //Get the application with its secret
+ app = await (from userApp in Database.OAuthApps
+ where userApp.ClientId == clientId
+ select userApp)
+ .FirstOrDefaultAsync();
+ //commit the transaction
+ await Database.CommitTransactionAsync();
+ }
+ //make sure app exists
+ if (string.IsNullOrWhiteSpace(app?.UserId) || !app.ClientId!.Equals(clientId, StringComparison.Ordinal))
+ {
+ //Not found or not valid
+ return null;
+ }
+ //Convert the secret hash to a private string so it will be cleaned up
+ using PrivateString secretHash = (PrivateString)app.SecretHash!;
+ //Verify the secret against the hash
+ if (SecretHashing.Verify(secretHash, secret))
+ {
+ app.SecretHash = null;
+ //App was successfully verified
+ return app;
+ }
+ //Not found or not valid
+ return null;
+ }
+ ///<inheritdoc/>
+ public override async Task<ERRNO> DeleteAsync(params string[] specifiers)
+ {
+ //get app id to remove tokens from
+ string appId = specifiers[0];
+ //Delete app from store
+ ERRNO result = await base.DeleteAsync(specifiers);
+ if(result)
+ {
+ //Delete active tokens
+ await TokenStore.RevokeTokensForAppAsync(appId, CancellationToken.None);
+ }
+ return result;
+ }
+
+ /// <summary>
+ /// Generates a client application secret using the <see cref="RandomHash"/> library
+ /// </summary>
+ /// <returns>The RNG secret</returns>
+ public static PrivateString GenerateSecret() => (PrivateString)RandomHash.GetRandomHex(SECRET_SIZE).ToLower()!;
+ /// <summary>
+ /// Creates and initializes a new <see cref="UserApplication"/> with a random clientid and
+ /// secret that must be disposed
+ /// </summary>
+ /// <param name="record">The new record to create</param>
+ /// <returns>The result of the operation</returns>
+ public override async Task<ERRNO> CreateAsync(UserApplication record)
+ {
+ record.RawSecret = GenerateSecret();
+ //Hash the secret
+ using PrivateString secretHash = SecretHashing.Hash(record.RawSecret);
+ record.ClientId = GenerateClientID();
+ record.SecretHash = (string)secretHash;
+ //Wait for the rescord to be created before wiping the secret
+ return await base.CreateAsync(record);
+ }
+
+ /// <summary>
+ /// Generates a new client ID using the <see cref="RandomHash"/> library
+ /// </summary>
+ /// <returns>The new client ID</returns>
+ public static string GenerateClientID() => RandomHash.GetRandomHex(CLIENT_ID_SIZE).ToLower();
+ ///<inheritdoc/>
+ public override string RecordIdBuilder => RandomHash.GetRandomHex(CLIENT_ID_SIZE).ToLower();
+ ///<inheritdoc/>
+ public override TransactionalDbContext NewContext() => new UserAppContext(ConextOptions);
+ ///<inheritdoc/>
+ protected override IQueryable<UserApplication> AddOrUpdateQueryBuilder(TransactionalDbContext context, UserApplication record)
+ {
+ UserAppContext ctx = (context as UserAppContext)!;
+ //get a single record by the id for the specific user
+ return from userApp in ctx.OAuthApps
+ where userApp.UserId == record.UserId
+ && userApp.Id == record.Id
+ select userApp;
+ }
+ ///<inheritdoc/>
+ protected override void OnRecordUpdate(UserApplication newRecord, UserApplication currentRecord)
+ {
+ currentRecord.AppDescription = newRecord.AppDescription;
+ currentRecord.AppName = newRecord.AppName;
+ }
+ ///<inheritdoc/>
+ protected override IQueryable<UserApplication> GetCollectionQueryBuilder(TransactionalDbContext context, string userId)
+ {
+ UserAppContext ctx = (context as UserAppContext)!;
+ //Get the user's applications based on their userid
+ return from userApp in ctx.OAuthApps
+ where userApp.UserId == userId
+ orderby userApp.Created ascending
+ select new UserApplication
+ {
+ AppDescription = userApp.AppDescription,
+ Id = userApp.Id,
+ AppName = userApp.AppName,
+ ClientId = userApp.ClientId,
+ Created = userApp.Created,
+ Permissions = userApp.Permissions
+ };
+ }
+ ///<inheritdoc/>
+ protected override IQueryable<UserApplication> GetCollectionQueryBuilder(TransactionalDbContext context, params string[] args)
+ {
+ return GetCollectionQueryBuilder(context, args[0]);
+ }
+ ///<inheritdoc/>
+ protected override IQueryable<UserApplication> GetSingleQueryBuilder(TransactionalDbContext context, params string[] constraints)
+ {
+ string appId = constraints[0];
+ string userId = constraints[1];
+ UserAppContext ctx = (context as UserAppContext)!;
+ //Query to get a new single application with limit results output
+ return from userApp in ctx.OAuthApps
+ where userApp.UserId == userId
+ && userApp.Id == appId
+ select new UserApplication
+ {
+ AppDescription = userApp.AppDescription,
+ Id = userApp.Id,
+ AppName = userApp.AppName,
+ ClientId = userApp.ClientId,
+ Created = userApp.Created,
+ Permissions = userApp.Permissions,
+ Version = userApp.Version
+ };
+ }
+ ///<inheritdoc/>
+ protected override IQueryable<UserApplication> GetSingleQueryBuilder(TransactionalDbContext context, UserApplication record)
+ {
+ //Use the query for single record with the other record's userid and record id
+ return GetSingleQueryBuilder(context, record.Id, record.UserId);
+ }
+ ///<inheritdoc/>
+ protected override IQueryable<UserApplication> UpdateQueryBuilder(TransactionalDbContext context, UserApplication record)
+ {
+ UserAppContext ctx = (context as UserAppContext)!;
+ return from userApp in ctx.OAuthApps
+ where userApp.UserId == record.UserId && userApp.Id == record.Id
+ select userApp;
+ }
+
+ //DO NOT ALLOW PAGINATION YET
+ public override Task<int> GetPageAsync(ICollection<UserApplication> collection, int page, int limit)
+ {
+ throw new NotSupportedException();
+ }
+ }
+} \ No newline at end of file
diff --git a/Libs/VNLib.Plugins.Essentials.Oauth/Applications/UserAppContext.cs b/Libs/VNLib.Plugins.Essentials.Oauth/Applications/UserAppContext.cs
new file mode 100644
index 0000000..5a24b49
--- /dev/null
+++ b/Libs/VNLib.Plugins.Essentials.Oauth/Applications/UserAppContext.cs
@@ -0,0 +1,19 @@
+using Microsoft.EntityFrameworkCore;
+
+
+using VNLib.Plugins.Extensions.Data;
+using VNLib.Plugins.Essentials.Oauth.Tokens;
+
+namespace VNLib.Plugins.Essentials.Oauth.Applications
+{
+ internal class UserAppContext : TransactionalDbContext
+ {
+ public DbSet<UserApplication> OAuthApps { get; set; }
+ public DbSet<ActiveToken> OAuthTokens { get; set; }
+#nullable disable
+ public UserAppContext(DbContextOptions options) : base(options)
+ {
+
+ }
+ }
+} \ No newline at end of file
diff --git a/Libs/VNLib.Plugins.Essentials.Oauth/Applications/UserApplication.cs b/Libs/VNLib.Plugins.Essentials.Oauth/Applications/UserApplication.cs
new file mode 100644
index 0000000..1fa7671
--- /dev/null
+++ b/Libs/VNLib.Plugins.Essentials.Oauth/Applications/UserApplication.cs
@@ -0,0 +1,133 @@
+using System;
+using System.Text.Json;
+using System.ComponentModel;
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+using VNLib.Utils.Memory;
+using VNLib.Utils.Extensions;
+using VNLib.Hashing.IdentityUtility;
+using VNLib.Plugins.Extensions.Data;
+using VNLib.Plugins.Extensions.Data.Abstractions;
+
+using IndexAttribute = Microsoft.EntityFrameworkCore.IndexAttribute;
+
+
+namespace VNLib.Plugins.Essentials.Oauth.Applications
+{
+ /// <summary>
+ ///
+ /// </summary>
+ [Index(nameof(ClientId), IsUnique = true)]
+ public class UserApplication : DbModelBase, IUserEntity, IJsonOnDeserialized
+ {
+ ///<inheritdoc/>
+ [Key, Required]
+ public override string? Id { get; set; }
+ ///<inheritdoc/>
+ public override DateTime Created { get; set; }
+ ///<inheritdoc/>
+ public override DateTime LastModified { get; set; }
+
+ ///<inheritdoc/>
+ [Required]
+ [JsonIgnore]
+ public string? UserId { get; set; }
+
+ /// <summary>
+ /// The OAuth2 application's associated client-id
+ /// </summary>
+ [Required]
+ [JsonPropertyName("client_id")]
+ public string? ClientId { get; set; }
+
+ /// <summary>
+ /// The hash of the application's secret (no json-serializable)
+ /// </summary>
+ [JsonIgnore]
+ [MaxLength(1000)]
+ public string? SecretHash { get; set; }
+
+ /// <summary>
+ /// The user-defined name of the application
+ /// </summary>
+ [DisplayName("Application Name")]
+ [JsonPropertyName("name")]
+ public string? AppName { get; set; }
+
+ /// <summary>
+ /// The user-defined description for the application
+ /// </summary>
+ [JsonPropertyName("description")]
+ [DisplayName("Application Description")]
+ public string? AppDescription { get; set; }
+
+ /// <summary>
+ /// The permissions for the application
+ /// </summary>
+ [JsonPropertyName("permissions")]
+ [Column("permissions")]
+ public string? Permissions { get; set; }
+
+
+ [NotMapped]
+ [JsonIgnore]
+ public PrivateString? RawSecret { get; set; }
+
+ void IJsonOnDeserialized.OnDeserialized()
+ {
+ Id = Id?.Trim();
+ ClientId = ClientId?.Trim();
+ UserId = UserId?.Trim();
+ AppName = AppName?.Trim();
+ AppDescription = AppDescription?.Trim();
+ Permissions = Permissions?.Trim();
+ }
+
+
+ /// <summary>
+ /// Creates a new <see cref="UserApplication"/> instance
+ /// from the supplied <see cref="JsonElement"/> assuming
+ /// JWT format
+ /// </summary>
+ /// <param name="appEl">The application JWT payalod element</param>
+ /// <returns>The recovered application</returns>
+ public static UserApplication FromJwtDoc(in JsonElement appEl)
+ {
+ return new()
+ {
+ UserId = appEl.GetPropString("sub"),
+ ClientId = appEl.GetPropString("azp"),
+ Id = appEl.GetPropString("appid"),
+ Permissions = appEl.GetPropString("scope"),
+ };
+ }
+ /// <summary>
+ /// Stores the
+ /// </summary>
+ /// <param name="app">The application to serialze to JWT format</param>
+ /// <param name="dict">Jwt dictionary payload</param>
+ public static void ToJwtDict(UserApplication app, IDictionary<string, string?> dict)
+ {
+ dict["appid"] = app.Id;
+ dict["azp"] = app.ClientId;
+ dict["sub"] = app.UserId;
+ dict["scope"] = app.Permissions;
+ }
+
+ /// <summary>
+ /// Stores the
+ /// </summary>
+ /// <param name="app"></param>
+ /// <param name="payload">JW payload parameter</param>
+ public static void ToJwtPayload(UserApplication app, in JwtPayload payload)
+ {
+ payload["appid"] = app.Id;
+ payload["azp"] = app.ClientId;
+ payload["sub"] = app.UserId;
+ payload["scope"] = app.Permissions;
+ }
+ }
+} \ No newline at end of file
diff --git a/Libs/VNLib.Plugins.Essentials.Oauth/Tokens/ActiveToken.cs b/Libs/VNLib.Plugins.Essentials.Oauth/Tokens/ActiveToken.cs
new file mode 100644
index 0000000..29760c2
--- /dev/null
+++ b/Libs/VNLib.Plugins.Essentials.Oauth/Tokens/ActiveToken.cs
@@ -0,0 +1,16 @@
+using System;
+
+using VNLib.Plugins.Extensions.Data;
+
+namespace VNLib.Plugins.Essentials.Oauth.Tokens
+{
+ public class ActiveToken : DbModelBase
+ {
+ public override string Id { get; set; }
+ public override DateTime Created { get; set; }
+ public override DateTime LastModified { get; set; }
+
+ public string? ApplicationId { get; set; }
+ public string? RefreshToken { get; set; }
+ }
+}
diff --git a/Libs/VNLib.Plugins.Essentials.Oauth/Tokens/IOAuth2TokenResult.cs b/Libs/VNLib.Plugins.Essentials.Oauth/Tokens/IOAuth2TokenResult.cs
new file mode 100644
index 0000000..a96810f
--- /dev/null
+++ b/Libs/VNLib.Plugins.Essentials.Oauth/Tokens/IOAuth2TokenResult.cs
@@ -0,0 +1,14 @@
+namespace VNLib.Plugins.Essentials.Oauth.Tokens
+{
+ /// <summary>
+ /// The result of an OAuth2Token creation
+ /// </summary>
+ public interface IOAuth2TokenResult
+ {
+ string? IdentityToken { get; }
+ string? AccessToken { get; }
+ string? RefreshToken { get; }
+ string? TokenType { get; }
+ int ExpiresSeconds { get; }
+ }
+} \ No newline at end of file
diff --git a/Libs/VNLib.Plugins.Essentials.Oauth/Tokens/ITokenManager.cs b/Libs/VNLib.Plugins.Essentials.Oauth/Tokens/ITokenManager.cs
new file mode 100644
index 0000000..046d512
--- /dev/null
+++ b/Libs/VNLib.Plugins.Essentials.Oauth/Tokens/ITokenManager.cs
@@ -0,0 +1,27 @@
+using System.Threading;
+using System.Threading.Tasks;
+using System.Collections.Generic;
+
+namespace VNLib.Plugins.Essentials.Oauth.Tokens
+{
+ /// <summary>
+ /// Provides token creation and revocation
+ /// </summary>
+ public interface ITokenManager
+ {
+ /// <summary>
+ /// Revokes a colleciton of toke
+ /// </summary>
+ /// <param name="tokens">A collection of tokens to revoke</param>
+ /// <param name="cancellation">A token to cancel the operation</param>
+ /// <returns>A task that completes when the tokens have been revoked</returns>
+ Task RevokeTokensAsync(IReadOnlyCollection<string> tokens, CancellationToken cancellation = default);
+ /// <summary>
+ /// Attempts to revoke tokens that belong to a specified application
+ /// </summary>
+ /// <param name="appId">The application to revoke tokens for</param>
+ /// <param name="cancellation">A token to cancel the operation</param>
+ /// <returns>A task that completes when the work is complete</returns>
+ Task RevokeTokensForAppAsync(string appId, CancellationToken cancellation = default);
+ }
+} \ No newline at end of file
diff --git a/Libs/VNLib.Plugins.Essentials.Oauth/Tokens/TokenStore.cs b/Libs/VNLib.Plugins.Essentials.Oauth/Tokens/TokenStore.cs
new file mode 100644
index 0000000..56bb9f7
--- /dev/null
+++ b/Libs/VNLib.Plugins.Essentials.Oauth/Tokens/TokenStore.cs
@@ -0,0 +1,171 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Microsoft.EntityFrameworkCore;
+
+using VNLib.Utils;
+using VNLib.Plugins.Essentials.Oauth.Applications;
+
+namespace VNLib.Plugins.Essentials.Oauth.Tokens
+{
+ /// <summary>
+ /// Represents a database backed <see cref="ITokenManager"/>
+ /// that allows for communicating token information to
+ /// plugins
+ /// </summary>
+ public sealed class TokenStore : ITokenManager
+ {
+ private readonly DbContextOptions Options;
+
+ /// <summary>
+ /// Initializes a new <see cref="TokenStore"/> that will make quries against
+ /// the supplied <see cref="DbContextOptions"/>
+ /// </summary>
+ /// <param name="options">The DB connection context</param>
+ public TokenStore(DbContextOptions options) => Options = options;
+
+ /// <summary>
+ /// Inserts a new token into the table for a specified application id. Also determines if
+ /// the user has reached the maximum number of allowed tokens
+ /// </summary>
+ /// <param name="token">The token (or session id)</param>
+ /// <param name="appId">The applicaiton the token belongs to</param>
+ /// <param name="refreshToken">The tokens refresh token</param>
+ /// <param name="maxTokens">The maxium number of allowed tokens for a given application</param>
+ /// <param name="cancellation">A token to cancel the operation</param>
+ /// <returns>
+ /// <see cref="ERRNO.SUCCESS"/> if the opreation succeeds (aka 0x01),
+ /// <see cref="ERRNO.E_FAIL"/> if the operation fails, or the number
+ /// of active tokens if the maximum has been reached.
+ /// </returns>
+ public async Task<ERRNO> InsertTokenAsync(string token, string appId, string? refreshToken, int maxTokens, CancellationToken cancellation)
+ {
+ await using UserAppContext ctx = new (Options);
+ await ctx.OpenTransactionAsync(cancellation);
+
+ //Check active token count
+ int count = await (from t in ctx.OAuthTokens
+ where t.ApplicationId == appId
+ select t)
+ .CountAsync(cancellation);
+ //Check count
+ if(count >= maxTokens)
+ {
+ return count;
+ }
+
+ //Try to add the new token
+ ActiveToken newToken = new()
+ {
+ ApplicationId = appId,
+ Id = token,
+ RefreshToken = refreshToken,
+ Created = DateTime.UtcNow,
+ LastModified = DateTime.UtcNow
+ };
+ //Add token to store
+ ctx.OAuthTokens.Add(newToken);
+ //commit changes
+ ERRNO result = await ctx.SaveChangesAsync(cancellation);
+ if (result)
+ {
+ //End transaction
+ await ctx.CommitTransactionAsync(cancellation);
+ }
+ else
+ {
+ await ctx.RollbackTransctionAsync(cancellation);
+ }
+ return result;
+ }
+
+ /// <summary>
+ /// Revokes/removes a single token from the store by its ID
+ /// </summary>
+ /// <param name="token">The token to remove</param>
+ /// <param name="cancellation"></param>
+ /// <returns>A task that revolves when the token is removed from the table if it exists</returns>
+ public async Task RevokeTokenAsync(string token, CancellationToken cancellation)
+ {
+ await using UserAppContext ctx = new (Options);
+ await ctx.OpenTransactionAsync(cancellation);
+ //Get the token from the db if it exists
+ ActiveToken? at = await (from t in ctx.OAuthTokens
+ where t.Id == token
+ select t)
+ .FirstOrDefaultAsync(cancellation);
+ if(at == null)
+ {
+ return;
+ }
+ //delete token
+ ctx.OAuthTokens.Remove(at);
+ //Save changes
+ await ctx.SaveChangesAsync(cancellation);
+ await ctx.CommitTransactionAsync(cancellation);
+ }
+ /// <summary>
+ /// Removes all token entires that were created before the specified time
+ /// </summary>
+ /// <param name="validAfter">The time before which all tokens are invaid</param>
+ /// <param name="cancellation">A token the cancel the operation</param>
+ /// <returns>A task that resolves to a collection of tokens that were removed</returns>
+ public async Task<IReadOnlyCollection<ActiveToken>> CleanupExpiredTokensAsync(DateTimeOffset validAfter, CancellationToken cancellation)
+ {
+ await using UserAppContext ctx = new (Options);
+ await ctx.OpenTransactionAsync(cancellation);
+ //Get the token from the db if it exists
+ ActiveToken[] at = await (from t in ctx.OAuthTokens
+ where t.Created < validAfter
+ select t)
+ .ToArrayAsync(cancellation);
+
+ //delete token
+ ctx.OAuthTokens.RemoveRange(at);
+ //Save changes
+ int count = await ctx.SaveChangesAsync(cancellation);
+ await ctx.CommitTransactionAsync(cancellation);
+ return at;
+ }
+ ///<inheritdoc/>
+ public async Task RevokeTokensAsync(IReadOnlyCollection<string> tokens, CancellationToken cancellation = default)
+ {
+ await using UserAppContext ctx = new (Options);
+ await ctx.OpenTransactionAsync(cancellation);
+ //Get all tokenes that are contained in the collection
+ ActiveToken[] at = await (from t in ctx.OAuthTokens
+ where tokens.Contains(t.Id)
+ select t)
+ .ToArrayAsync(cancellation);
+
+ //delete token
+ ctx.OAuthTokens.RemoveRange(at);
+ //Save changes
+ await ctx.SaveChangesAsync(cancellation);
+ await ctx.CommitTransactionAsync(cancellation);
+ }
+ ///<inheritdoc/>
+ async Task ITokenManager.RevokeTokensForAppAsync(string appId, CancellationToken cancellation)
+ {
+ await using UserAppContext ctx = new (Options);
+ await ctx.OpenTransactionAsync(cancellation);
+ //Get the token from the db if it exists
+ ActiveToken[] at = await (from t in ctx.OAuthTokens
+ where t.ApplicationId == appId
+ select t)
+ .ToArrayAsync(cancellation);
+ //Set created time to 0 to invalidate the token
+ foreach(ActiveToken t in at)
+ {
+ //Expire token so next cleanup round will wipe tokens
+ t.Created = DateTime.MinValue;
+ }
+ //Save changes
+ await ctx.SaveChangesAsync(cancellation);
+ await ctx.CommitTransactionAsync(cancellation);
+ }
+ }
+}
diff --git a/Libs/VNLib.Plugins.Essentials.Oauth/VNLib.Plugins.Essentials.Oauth.csproj b/Libs/VNLib.Plugins.Essentials.Oauth/VNLib.Plugins.Essentials.Oauth.csproj
new file mode 100644
index 0000000..49c3c84
--- /dev/null
+++ b/Libs/VNLib.Plugins.Essentials.Oauth/VNLib.Plugins.Essentials.Oauth.csproj
@@ -0,0 +1,45 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>net6.0</TargetFramework>
+ <Platforms>AnyCPU;x64</Platforms>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="ErrorProne.NET.CoreAnalyzers" Version="0.1.2">
+ <PrivateAssets>all</PrivateAssets>
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ </PackageReference>
+ <PackageReference Include="ErrorProne.NET.Structs" Version="0.1.2">
+ <PrivateAssets>all</PrivateAssets>
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ </PackageReference>
+ <PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.11" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\..\..\..\VNLib\Essentials\VNLib.Plugins.Essentials.csproj" />
+ <ProjectReference Include="..\..\..\..\VNLib\Hashing\VNLib.Hashing.Portable.csproj" />
+ <ProjectReference Include="..\..\..\..\VNLib\Utils\src\VNLib.Utils.csproj" />
+ <ProjectReference Include="..\..\..\Extensions\VNLib.Plugins.Extensions.Data\VNLib.Plugins.Extensions.Data.csproj" />
+ </ItemGroup>
+
+ <PropertyGroup>
+ <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
+ <Authors>Vaughn Nugent</Authors>
+ <Copyright>Copyright © 2022 Vaughn Nugent</Copyright>
+ <PackageProjectUrl>www.vaughnnugent.com/resources</PackageProjectUrl>
+ <Version>1.0.2.1</Version>
+ <Nullable>enable</Nullable>
+ <GenerateDocumentationFile>True</GenerateDocumentationFile>
+ </PropertyGroup>
+
+ <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
+ <DocumentationFile></DocumentationFile>
+ </PropertyGroup>
+
+ <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
+ <DocumentationFile></DocumentationFile>
+ </PropertyGroup>
+
+</Project>
diff --git a/Libs/VNLib.Plugins.Essentials.Oauth/VNLib.Plugins.Essentials.Oauth.xml b/Libs/VNLib.Plugins.Essentials.Oauth/VNLib.Plugins.Essentials.Oauth.xml
new file mode 100644
index 0000000..ad8e968
--- /dev/null
+++ b/Libs/VNLib.Plugins.Essentials.Oauth/VNLib.Plugins.Essentials.Oauth.xml
@@ -0,0 +1,155 @@
+<?xml version="1.0"?>
+<doc>
+ <assembly>
+ <name>VNLib.Plugins.Essentials.Oauth</name>
+ </assembly>
+ <members>
+ <member name="T:VNLib.Plugins.Essentials.Oauth.Applications">
+ <summary>
+ A DbStore for <see cref="T:VNLib.Plugins.Essentials.Oauth.UserApplication"/>s for OAuth2 client applications
+ </summary>
+ </member>
+ <member name="M:VNLib.Plugins.Essentials.Oauth.Applications.#ctor(Microsoft.EntityFrameworkCore.DbContextOptions,VNLib.Plugins.Essentials.Accounts.PasswordHashing,System.String,System.String)">
+ <summary>
+ Initializes a new <see cref="T:VNLib.Plugins.Essentials.Oauth.Applications"/> data store
+ uisng the specified EFCore <see cref="T:Microsoft.EntityFrameworkCore.DbContextOptions"/> object.
+ </summary>
+ <param name="ConextOptions">EFCore context options for connecting to a remote data-store</param>
+ <param name="secretHashing">A <see cref="T:VNLib.Plugins.Essentials.Accounts.PasswordHashing"/> structure for hashing client secrets</param>
+ <param name="tableName">The name of the applications table</param>
+ <param name="tokenTableName">The name of the active OAuth2 token table</param>
+ </member>
+ <member name="M:VNLib.Plugins.Essentials.Oauth.Applications.UpdateSecretAsync(System.String,System.String)">
+ <summary>
+ Updates the secret of an application, and if successful returns the new raw secret data
+ </summary>
+ <param name="userId">The user-id of that owns the application</param>
+ <param name="appId">The id of the application to update</param>
+ <returns>A task that resolves to the raw secret that was used to generate the hash, or null if the operation failed</returns>
+ </member>
+ <member name="M:VNLib.Plugins.Essentials.Oauth.Applications.VerifyAppAsync(System.String,VNLib.Utils.Memory.PrivateString)">
+ <summary>
+ Attempts to retreive an application by the specified client id and compares the raw secret against the
+ stored secret hash.
+ </summary>
+ <param name="clientId">The clientid of the application to search</param>
+ <param name="secret">The secret to compare against</param>
+ <param name="app">The found application</param>
+ <returns>True if the application was found and the secret matches the stored secret, false if the appliation was not found or the secret does not match</returns>
+ </member>
+ <member name="M:VNLib.Plugins.Essentials.Oauth.Applications.DeleteActiveTokensAsync(System.String)">
+ <summary>
+ Deletes all active tokens
+ </summary>
+ <param name="appid"></param>
+ </member>
+ <member name="M:VNLib.Plugins.Essentials.Oauth.Applications.GenerateSecret">
+ <summary>
+ Generates a client application secret using the <see cref="T:VNLib.Hashing.RandomHash"/> library
+ </summary>
+ <returns>The RNG secret</returns>
+ </member>
+ <member name="M:VNLib.Plugins.Essentials.Oauth.Applications.CreateAsync(VNLib.Plugins.Essentials.Oauth.UserApplication)">
+ <summary>
+ Creates and initializes a new <see cref="T:VNLib.Plugins.Essentials.Oauth.UserApplication"/> with a random clientid and
+ secret that must be disposed
+ </summary>
+ <param name="record">The new record to create</param>
+ <returns>The result of the operation</returns>
+ </member>
+ <member name="M:VNLib.Plugins.Essentials.Oauth.Applications.GenerateClientID">
+ <summary>
+ Generates a new client ID using the <see cref="T:VNLib.Hashing.RandomHash"/> library
+ </summary>
+ <returns>The new client ID</returns>
+ </member>
+ <member name="P:VNLib.Plugins.Essentials.Oauth.Applications.RecordIdBuilder">
+ <inheritdoc/>
+ </member>
+ <member name="M:VNLib.Plugins.Essentials.Oauth.Applications.NewContext">
+ <inheritdoc/>
+ </member>
+ <member name="M:VNLib.Plugins.Essentials.Oauth.Applications.AddOrUpdateQueryBuilder(VNLib.Plugins.Extensions.Data.TransactionalDbContext,VNLib.Plugins.Essentials.Oauth.UserApplication)">
+ <inheritdoc/>
+ </member>
+ <member name="M:VNLib.Plugins.Essentials.Oauth.Applications.OnRecordUpdate(VNLib.Plugins.Essentials.Oauth.UserApplication,VNLib.Plugins.Essentials.Oauth.UserApplication)">
+ <inheritdoc/>
+ </member>
+ <member name="M:VNLib.Plugins.Essentials.Oauth.Applications.GetCollectionQueryBuilder(VNLib.Plugins.Extensions.Data.TransactionalDbContext,System.String)">
+ <inheritdoc/>
+ </member>
+ <member name="M:VNLib.Plugins.Essentials.Oauth.Applications.GetCollectionQueryBuilder(VNLib.Plugins.Extensions.Data.TransactionalDbContext,System.String[])">
+ <inheritdoc/>
+ </member>
+ <member name="M:VNLib.Plugins.Essentials.Oauth.Applications.GetSingleQueryBuilder(VNLib.Plugins.Extensions.Data.TransactionalDbContext,System.String[])">
+ <inheritdoc/>
+ </member>
+ <member name="M:VNLib.Plugins.Essentials.Oauth.Applications.GetSingleQueryBuilder(VNLib.Plugins.Extensions.Data.TransactionalDbContext,VNLib.Plugins.Essentials.Oauth.UserApplication)">
+ <inheritdoc/>
+ </member>
+ <member name="M:VNLib.Plugins.Essentials.Oauth.Applications.UpdateQueryBuilder(VNLib.Plugins.Extensions.Data.TransactionalDbContext,VNLib.Plugins.Essentials.Oauth.UserApplication)">
+ <inheritdoc/>
+ </member>
+ <member name="T:VNLib.Plugins.Essentials.Oauth.Sessions.O2SessionHandle">
+ <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.Plugins.Essentials.Oauth.Sessions.OAuthSessionStore">
+ <summary>
+ Provides OAuth2 sessions (with caching) using VNLib caching store
+ </summary>
+ </member>
+ <member name="M:VNLib.Plugins.Essentials.Oauth.Sessions.OAuthSessionStore.#ctor(System.Func{System.Data.Common.DbConnection},System.String,System.Int32,System.Func{System.String})">
+ <summary>
+ Initializes a new <see cref="T:VNLib.Plugins.Essentials.Oauth.Sessions.OAuthSessionStore"/>
+ </summary>
+ <param name="connectionFactory">The a <see cref="T:System.Data.Common.DbConnection"/> factory function</param>
+ <param name="tableName">The name of the table that the backing store indexes</param>
+ <param name="maxCacheItems">The maximum number of sessions to keep in memory</param>
+ <param name="accessTokenFactory">A secure access token factory function</param>
+ </member>
+ <member name="M:VNLib.Plugins.Essentials.Oauth.Sessions.OAuthSessionStore.GetActiveTokenCountAsync(System.String)">
+ <summary>
+ Asynchronously gets the number of active tokens for a given application
+ </summary>
+ <param name="appId">The application id to get the count of</param>
+ <returns>A task that resolves the number of active tokens</returns>
+ </member>
+ <member name="M:VNLib.Plugins.Essentials.Oauth.Sessions.OAuthSessionStore.GetSessionAsync(System.String,System.Threading.CancellationToken)">
+ <summary>
+ Gets a session handle for the current token to attach to a connection
+ </summary>
+ <param name="sessionId">The access token (or session id) to get the session of</param>
+ <param name="cancellationToken">A token to cancel the operation</param>
+ <returns>A task the resolves a <see cref="T:VNLib.Plugins.Essentials.Sessions.ISessionHandle"/> around the session to connect to the token and entity</returns>
+ <exception cref="T:VNLib.Plugins.Essentials.Sessions.SessionException"></exception>
+ <exception cref="T:System.OperationCanceledException"></exception>
+ </member>
+ <member name="M:VNLib.Plugins.Essentials.Oauth.Sessions.OAuthSessionStore.CreateSessionAsync(System.String,System.Net.IPAddress,System.Threading.CancellationToken)">
+ <summary>
+ Creates a new OAuth2 session in the store and returns a handle to the new session
+ </summary>
+ <param name="appId">The application id</param>
+ <param name="userIp">The IP address of the client that created the token</param>
+ <param name="cancellationToken">A token to cancel the operation</param>
+ <returns>A task that compeltes with an <see cref="T:VNLib.Plugins.Essentials.Sessions.ISessionHandle"/> used to release the session</returns>
+ <exception cref="T:System.ObjectDisposedException"></exception>
+ </member>
+ <member name="P:VNLib.Plugins.Essentials.Oauth.OauthSession.Released">
+ <summary>
+ A value that indicates if the session has been released, returns false if the instance has not been initialized
+ </summary>
+ </member>
+ <member name="M:VNLib.Plugins.Essentials.Oauth.OauthSession.WaitAndLoadAsync(System.Net.IPAddress,System.Threading.CancellationToken)">
+ <summary>
+ Waits for exclusive access to the resource and lazily initializes the resource
+ from its backing store
+ </summary>
+ <param name="userIp">Optional ipaddressfor initalization</param>
+ <param name="cancellationToken">A token to cancel the operation</param>
+ <returns>A task the resolves to a value that indicates if the session is in a usable state</returns>
+ </member>
+ </members>
+</doc>