aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore476
-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
-rw-r--r--Plugins/OAuth2ClientApplications/Endpoints/ApplicationEndpoint.cs359
-rw-r--r--Plugins/OAuth2ClientApplications/Endpoints/UserAppValidator.cs43
-rw-r--r--Plugins/OAuth2ClientApplications/OAuth2ClientApplications.csproj51
-rw-r--r--Plugins/OAuth2ClientApplications/OAuth2ClientAppsEntryPoint.cs41
14 files changed, 1810 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..63641d6
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,476 @@
+# Created by https://www.toptal.com/developers/gitignore/api/c,c++,visualstudio
+# Edit at https://www.toptal.com/developers/gitignore?templates=c,c++,visualstudio
+
+### C ###
+# Prerequisites
+*.d
+
+# Object files
+*.o
+*.ko
+*.obj
+*.elf
+
+# Linker output
+*.ilk
+*.map
+*.exp
+
+# Precompiled Headers
+*.gch
+*.pch
+
+# Libraries
+*.lib
+*.a
+*.la
+*.lo
+
+# Shared objects (inc. Windows DLLs)
+*.dll
+*.so
+*.so.*
+*.dylib
+
+# Executables
+*.exe
+*.out
+*.app
+*.i*86
+*.x86_64
+*.hex
+
+# Debug files
+*.dSYM/
+*.su
+*.idb
+*.pdb
+
+# Kernel Module Compile Results
+*.mod*
+*.cmd
+.tmp_versions/
+modules.order
+Module.symvers
+Mkfile.old
+dkms.conf
+
+### C++ ###
+# Prerequisites
+
+# Compiled Object files
+*.slo
+
+# Precompiled Headers
+
+# Compiled Dynamic libraries
+
+# Fortran module files
+*.mod
+*.smod
+
+# Compiled Static libraries
+*.lai
+
+# Executables
+
+### VisualStudio ###
+## Ignore Visual Studio temporary files, build results, and
+## files generated by popular Visual Studio add-ons.
+##
+## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
+
+# User-specific files
+*.rsuser
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# User-specific files (MonoDevelop/Xamarin Studio)
+*.userprefs
+
+# Mono auto generated files
+mono_crash.*
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+[Ww][Ii][Nn]32/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
+bld/
+[Bb]in/
+[Oo]bj/
+[Ll]og/
+[Ll]ogs/
+
+# Visual Studio 2015/2017 cache/options directory
+.vs/
+# Uncomment if you have tasks that create the project's static files in wwwroot
+#wwwroot/
+
+# Visual Studio 2017 auto generated files
+Generated\ Files/
+
+# MSTest test Results
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+
+# NUnit
+*.VisualState.xml
+TestResult.xml
+nunit-*.xml
+
+# Build Results of an ATL Project
+[Dd]ebugPS/
+[Rr]eleasePS/
+dlldata.c
+
+# Benchmark Results
+BenchmarkDotNet.Artifacts/
+
+# .NET Core
+project.lock.json
+project.fragment.lock.json
+artifacts/
+
+# ASP.NET Scaffolding
+ScaffoldingReadMe.txt
+
+# StyleCop
+StyleCopReport.xml
+
+# Files built by Visual Studio
+*_i.c
+*_p.c
+*_h.h
+*.meta
+*.iobj
+*.ipdb
+*.pgc
+*.pgd
+*.rsp
+*.sbr
+*.tlb
+*.tli
+*.tlh
+*.tmp
+*.tmp_proj
+*_wpftmp.csproj
+*.log
+*.tlog
+*.vspscc
+*.vssscc
+.builds
+*.pidb
+*.svclog
+*.scc
+
+# Chutzpah Test files
+_Chutzpah*
+
+# Visual C++ cache files
+ipch/
+*.aps
+*.ncb
+*.opendb
+*.opensdf
+*.sdf
+*.cachefile
+*.VC.db
+*.VC.VC.opendb
+
+# Visual Studio profiler
+*.psess
+*.vsp
+*.vspx
+*.sap
+
+# Visual Studio Trace Files
+*.e2e
+
+# TFS 2012 Local Workspace
+$tf/
+
+# Guidance Automation Toolkit
+*.gpState
+
+# ReSharper is a .NET coding add-in
+_ReSharper*/
+*.[Rr]e[Ss]harper
+*.DotSettings.user
+
+# TeamCity is a build add-in
+_TeamCity*
+
+# DotCover is a Code Coverage Tool
+*.dotCover
+
+# AxoCover is a Code Coverage Tool
+.axoCover/*
+!.axoCover/settings.json
+
+# Coverlet is a free, cross platform Code Coverage Tool
+coverage*.json
+coverage*.xml
+coverage*.info
+
+# Visual Studio code coverage results
+*.coverage
+*.coveragexml
+
+# NCrunch
+_NCrunch_*
+.*crunch*.local.xml
+nCrunchTemp_*
+
+# MightyMoose
+*.mm.*
+AutoTest.Net/
+
+# Web workbench (sass)
+.sass-cache/
+
+# Installshield output folder
+[Ee]xpress/
+
+# DocProject is a documentation generator add-in
+DocProject/buildhelp/
+DocProject/Help/*.HxT
+DocProject/Help/*.HxC
+DocProject/Help/*.hhc
+DocProject/Help/*.hhk
+DocProject/Help/*.hhp
+DocProject/Help/Html2
+DocProject/Help/html
+
+# Click-Once directory
+publish/
+
+# Publish Web Output
+*.[Pp]ublish.xml
+*.azurePubxml
+# Note: Comment the next line if you want to checkin your web deploy settings,
+# but database connection strings (with potential passwords) will be unencrypted
+*.pubxml
+*.publishproj
+
+# Microsoft Azure Web App publish settings. Comment the next line if you want to
+# checkin your Azure Web App publish settings, but sensitive information contained
+# in these scripts will be unencrypted
+PublishScripts/
+
+# NuGet Packages
+*.nupkg
+# NuGet Symbol Packages
+*.snupkg
+# The packages folder can be ignored because of Package Restore
+**/[Pp]ackages/*
+# except build/, which is used as an MSBuild target.
+!**/[Pp]ackages/build/
+# Uncomment if necessary however generally it will be regenerated when needed
+#!**/[Pp]ackages/repositories.config
+# NuGet v3's project.json files produces more ignorable files
+*.nuget.props
+*.nuget.targets
+
+# Microsoft Azure Build Output
+csx/
+*.build.csdef
+
+# Microsoft Azure Emulator
+ecf/
+rcf/
+
+# Windows Store app package directories and files
+AppPackages/
+BundleArtifacts/
+Package.StoreAssociation.xml
+_pkginfo.txt
+*.appx
+*.appxbundle
+*.appxupload
+
+# Visual Studio cache files
+# files ending in .cache can be ignored
+*.[Cc]ache
+# but keep track of directories ending in .cache
+!?*.[Cc]ache/
+
+# Others
+ClientBin/
+~$*
+*~
+*.dbmdl
+*.dbproj.schemaview
+*.jfm
+*.pfx
+*.publishsettings
+orleans.codegen.cs
+
+# Including strong name files can present a security risk
+# (https://github.com/github/gitignore/pull/2483#issue-259490424)
+#*.snk
+
+# Since there are multiple workflows, uncomment next line to ignore bower_components
+# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
+#bower_components/
+
+# RIA/Silverlight projects
+Generated_Code/
+
+# Backup & report files from converting an old project file
+# to a newer Visual Studio version. Backup files are not needed,
+# because we have git ;-)
+_UpgradeReport_Files/
+Backup*/
+UpgradeLog*.XML
+UpgradeLog*.htm
+ServiceFabricBackup/
+*.rptproj.bak
+
+# SQL Server files
+*.mdf
+*.ldf
+*.ndf
+
+# Business Intelligence projects
+*.rdl.data
+*.bim.layout
+*.bim_*.settings
+*.rptproj.rsuser
+*- [Bb]ackup.rdl
+*- [Bb]ackup ([0-9]).rdl
+*- [Bb]ackup ([0-9][0-9]).rdl
+
+# Microsoft Fakes
+FakesAssemblies/
+
+# GhostDoc plugin setting file
+*.GhostDoc.xml
+
+# Node.js Tools for Visual Studio
+.ntvs_analysis.dat
+node_modules/
+
+# Visual Studio 6 build log
+*.plg
+
+# Visual Studio 6 workspace options file
+*.opt
+
+# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
+*.vbw
+
+# Visual Studio 6 auto-generated project file (contains which files were open etc.)
+*.vbp
+
+# Visual Studio 6 workspace and project file (working project files containing files to include in project)
+*.dsw
+*.dsp
+
+# Visual Studio 6 technical files
+
+# Visual Studio LightSwitch build output
+**/*.HTMLClient/GeneratedArtifacts
+**/*.DesktopClient/GeneratedArtifacts
+**/*.DesktopClient/ModelManifest.xml
+**/*.Server/GeneratedArtifacts
+**/*.Server/ModelManifest.xml
+_Pvt_Extensions
+
+# Paket dependency manager
+.paket/paket.exe
+paket-files/
+
+# FAKE - F# Make
+.fake/
+
+# CodeRush personal settings
+.cr/personal
+
+# Python Tools for Visual Studio (PTVS)
+__pycache__/
+*.pyc
+
+# Cake - Uncomment if you are using it
+# tools/**
+# !tools/packages.config
+
+# Tabs Studio
+*.tss
+
+# Telerik's JustMock configuration file
+*.jmconfig
+
+# BizTalk build output
+*.btp.cs
+*.btm.cs
+*.odx.cs
+*.xsd.cs
+
+# OpenCover UI analysis results
+OpenCover/
+
+# Azure Stream Analytics local run output
+ASALocalRun/
+
+# MSBuild Binary and Structured Log
+*.binlog
+
+# NVidia Nsight GPU debugger configuration file
+*.nvuser
+
+# MFractors (Xamarin productivity tool) working folder
+.mfractor/
+
+# Local History for Visual Studio
+.localhistory/
+
+# Visual Studio History (VSHistory) files
+.vshistory/
+
+# BeatPulse healthcheck temp database
+healthchecksdb
+
+# Backup folder for Package Reference Convert tool in Visual Studio 2017
+MigrationBackup/
+
+# Ionide (cross platform F# VS Code tools) working folder
+.ionide/
+
+# Fody - auto-generated XML schema
+FodyWeavers.xsd
+
+# VS Code files for those working on multiple tools
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+*.code-workspace
+
+# Local History for Visual Studio Code
+.history/
+
+# Windows Installer files from build outputs
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# JetBrains Rider
+*.sln.iml
+
+### VisualStudio Patch ###
+# Additional files built by Visual Studio
+
+# End of https://www.toptal.com/developers/gitignore/api/c,c++,visualstudio
+
+*.json \ No newline at end of file
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>
diff --git a/Plugins/OAuth2ClientApplications/Endpoints/ApplicationEndpoint.cs b/Plugins/OAuth2ClientApplications/Endpoints/ApplicationEndpoint.cs
new file mode 100644
index 0000000..757fdac
--- /dev/null
+++ b/Plugins/OAuth2ClientApplications/Endpoints/ApplicationEndpoint.cs
@@ -0,0 +1,359 @@
+using System;
+using System.Net;
+using System.Text;
+using System.Text.Json;
+using System.Threading.Tasks;
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+using VNLib.Utils.Memory;
+using VNLib.Utils.Logging;
+using VNLib.Utils.Extensions;
+using VNLib.Plugins;
+using VNLib.Plugins.Essentials;
+using VNLib.Plugins.Essentials.Accounts;
+using VNLib.Plugins.Essentials.Endpoints;
+using VNLib.Plugins.Essentials.Extensions;
+using VNLib.Plugins.Essentials.Oauth.Applications;
+using VNLib.Plugins.Extensions.Validation;
+using VNLib.Plugins.Extensions.Loading;
+using VNLib.Plugins.Extensions.Loading.Sql;
+using static VNLib.Plugins.Essentials.Statics;
+
+namespace OAuth2ClientApplications.Endpoints
+{
+ [ConfigurationName("applications")]
+ internal sealed class ApplicationEndpoint : ProtectedWebEndpoint
+ {
+ private class ApplicationMessage
+ {
+ [JsonPropertyName("name")]
+ public string? ApplicationName { get; set; }
+ [JsonPropertyName("description")]
+ public string? Description { get; set; }
+ [JsonPropertyName("client_id")]
+ public string? ClientID { get; set; }
+ [JsonPropertyName("raw_secret")]
+ public string? RawSecret { get; set; }
+ [JsonPropertyName("Id")]
+ public string? ApplicationID { get; set; }
+ [JsonPropertyName("permissions")]
+ public string? Permissions { get; set; }
+ }
+
+ private readonly Applications Applications;
+ private readonly int MaxAppsPerUser;
+ private readonly string MaxAppOverloadMessage;
+ private readonly Task<JsonDocument?> JwtSigningKey;
+
+ private static readonly UserAppValidator Validator = new();
+
+ public ApplicationEndpoint(PluginBase plugin, IReadOnlyDictionary<string, JsonElement> config)
+ {
+ //Get configuration variables from plugin
+ string? path = config["path"].GetString();
+ MaxAppsPerUser = config["max_apps_per_user"].GetInt32();
+
+ InitPathAndLog(path, plugin.Log);
+ //Load apps
+ Applications = new(plugin.GetContextOptions(), plugin.GetPasswords());
+
+ //Complie overload message
+ MaxAppOverloadMessage = $"You have reached the limit of {MaxAppsPerUser} applications, this application cannot be created";
+
+ //Get jwt signing key
+ JwtSigningKey = plugin.TryGetSecretAsync("jwt_signing_key").ContinueWith(s => s.Result == null ? null : JsonDocument.Parse(s.Result));
+ }
+
+ protected override async ValueTask<VfReturnType> GetAsync(HttpEntity ev)
+ {
+ //Try to get a single application from the database
+
+ //Get a single specific application from an appid
+ if (ev.QueryArgs.TryGetNonEmptyValue("Id", out string? appid))
+ {
+ appid = ValidatorExtensions.OnlyAlphaRegx.Replace(appid, "");
+ //Execute get single app
+ UserApplication? singeApp = await Applications.GetSingleAsync(appid, ev.Session.UserID);
+ if (singeApp == null)
+ {
+ ev.CloseResponse(HttpStatusCode.NotFound);
+ return VfReturnType.VirtualSkip;
+ }
+ ev.CloseResponseJson(HttpStatusCode.OK, singeApp);
+ return VfReturnType.VirtualSkip;
+ }
+ //Process a "get all"
+ else
+ {
+ //Create list to store all applications
+ List<UserApplication> applications = Applications.ListRental.Rent();
+ try
+ {
+ //Get all applications to fill the list
+ _ = await Applications.GetCollectionAsync(applications, ev.Session.UserID, MaxAppsPerUser);
+ //Write response (will convert json as needed before releasing the list)
+ ev.CloseResponseJson(HttpStatusCode.OK, applications);
+ return VfReturnType.VirtualSkip;
+ }
+ finally
+ {
+ //Return the list
+ Applications.ListRental.Return(applications);
+ }
+ }
+ }
+ protected override async ValueTask<VfReturnType> PostAsync(HttpEntity entity)
+ {
+ //Default response
+ WebMessage webm = new();
+ //Oauth is only available for local accounts
+ if (!entity.Session.HasLocalAccount())
+ {
+ webm.Result = "OAuth is only available for internal user accounts";
+ entity.CloseResponseJson(HttpStatusCode.Forbidden, webm);
+ return VfReturnType.VirtualSkip;
+ }
+ if (entity.QueryArgs.IsArgumentSet("action", "create"))
+ {
+ return await CreateAppAsync(entity);
+ }
+
+ //Update the application secret
+ else if (entity.QueryArgs.IsArgumentSet("action", "secret"))
+ {
+ using JsonDocument? update = await entity.GetJsonFromFileAsync();
+
+ if(webm.Assert(update != null, "Invalid request"))
+ {
+ entity.CloseResponseJson(HttpStatusCode.BadRequest, webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Update message will include a challenge and an app id
+ string? appId = update.RootElement.GetPropString("Id");
+ string? challenge = update.RootElement.GetPropString("challenge");
+
+ if (string.IsNullOrWhiteSpace(appId))
+ {
+ return VfReturnType.NotFound;
+ }
+
+ /*
+ * A secret update requires a client challenge because
+ * it can log-out active sessions and break access to
+ * other applications
+ */
+
+ if (string.IsNullOrWhiteSpace(challenge) || !entity.Session.VerifyChallenge(challenge))
+ {
+ //return unauthorized
+ webm.Result = "Please check your password";
+ entity.CloseResponseJson(HttpStatusCode.Unauthorized, webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Update the app's secret
+ using PrivateString? secret = await Applications.UpdateSecretAsync(entity.Session.UserID, appId);
+
+ if (webm.Assert(secret != null, "Failed to update the application secret"))
+ {
+ entity.CloseResponseJson(HttpStatusCode.InternalServerError, webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ /*
+ * We must return the secret to the user.
+ *
+ * The PrivateString must be casted and serialized
+ * while the using statment is in scope
+ */
+ ApplicationMessage result = new()
+ {
+ ApplicationID = appId,
+ //Send raw secret
+ RawSecret = (string)secret
+ };
+ //Must write response while password is in scope
+ entity.CloseResponseJson(HttpStatusCode.OK, result);
+ return VfReturnType.VirtualSkip;
+ }
+ else if (entity.QueryArgs.IsArgumentSet("action", "delete"))
+ {
+ using JsonDocument? update = await entity.GetJsonFromFileAsync();
+
+ if(webm.Assert(update != null, "Invalid request"))
+ {
+ entity.CloseResponseJson(HttpStatusCode.BadRequest, webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Update message will include a challenge and an app id
+ string? appId = update.RootElement.GetPropString("Id");
+ string? challenge = update.RootElement.GetPropString("challenge");
+
+ if (string.IsNullOrWhiteSpace(appId))
+ {
+ return VfReturnType.NotFound;
+ }
+
+ /*
+ * A secret update requires a client challenge because
+ * it can log-out active sessions and break access to
+ * other applications
+ */
+ if (string.IsNullOrWhiteSpace(challenge) || !entity.Session.VerifyChallenge(challenge))
+ {
+ webm.Result = "Please check your password";
+ //return unauthorized
+ entity.CloseResponseJson(HttpStatusCode.Unauthorized, webm);
+ return VfReturnType.VirtualSkip;
+ }
+ //Try to delete the app
+ if (await Applications.DeleteAsync(appId, entity.Session.UserID))
+ {
+ entity.CloseResponse(HttpStatusCode.NoContent);
+ return VfReturnType.VirtualSkip;
+ }
+ }
+ else
+ {
+ webm.Result = "The update type specified is not defined";
+ entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm);
+ return VfReturnType.VirtualSkip;
+ }
+ return VfReturnType.BadRequest;
+ }
+ protected override async ValueTask<VfReturnType> PutAsync(HttpEntity entity)
+ {
+ ValErrWebMessage webm = new();
+
+ //Oauth is only available for local accounts
+ if (!entity.Session.HasLocalAccount())
+ {
+ webm.Result = "OAuth is only available for internal user accounts";
+ entity.CloseResponseJson(HttpStatusCode.Forbidden, webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Get the application from client
+ UserApplication? app = await entity.GetJsonFromFileAsync<UserApplication>(SR_OPTIONS);
+
+ if (webm.Assert(app != null, "Application is empty"))
+ {
+ entity.CloseResponseJson(HttpStatusCode.BadRequest, app);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //set user-id
+ app.UserId = entity.Session.UserID;
+ //remove permissions
+ app.Permissions = null;
+
+ //perform validation on the application update (should remove unused fields)
+ if (!Validator.Validate(app, webm))
+ {
+ entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Update the app's meta
+ if (await Applications.UpdateAsync(app))
+ {
+ //Send the app to the client
+ entity.CloseResponse(HttpStatusCode.NoContent);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //The app was not found and could not be updated
+ return VfReturnType.NotFound;
+ }
+
+ private async ValueTask<VfReturnType> CreateAppAsync(HttpEntity entity)
+ {
+ ValErrWebMessage webm = new();
+
+ //Get the application from client
+ UserApplication? newApp = await entity.GetJsonFromFileAsync<UserApplication>(SR_OPTIONS);
+
+ if (webm.Assert(newApp != null, "Application is empty"))
+ {
+ entity.CloseResponseJson(HttpStatusCode.BadRequest, webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Validate the new application
+ if (!Validator.Validate(newApp, webm))
+ {
+ entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //If no premissions are specified, set to "none"
+ if (string.IsNullOrWhiteSpace(newApp.Permissions))
+ {
+ newApp.Permissions = "none";
+ }
+
+ //See if the user has enough room for more apps
+ long appCount = await Applications.GetCountAsync(entity.Session.UserID);
+
+ if (appCount == -1)
+ {
+ webm.Result = $"There was a server error during creation of your application";
+ Log.Error("There was an error retreiving the number of applications for user {id}", entity.Session.UserID);
+ entity.CloseResponseJson(HttpStatusCode.InternalServerError, webm);
+ return VfReturnType.VirtualSkip;
+ }
+ if (webm.Assert(appCount < MaxAppsPerUser, MaxAppOverloadMessage))
+ {
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Parse permission string an re-build it to clean it up
+ newApp.Permissions = ParsePermissions(newApp.Permissions);
+ //Set user-id
+ newApp.UserId = entity.Session.UserID;
+ //Create the new application
+ if (!await Applications.CreateAsync(newApp))
+ {
+ webm.Result = "The was an issue creating your application";
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Make sure to dispose the secret
+ using PrivateString secret = newApp.RawSecret!;
+
+ //Success, now respond to the client with the new app information
+ ApplicationMessage mess = new()
+ {
+ ApplicationID = newApp.Id,
+ ApplicationName = newApp.AppName,
+ RawSecret = (string)secret,
+ ClientID = newApp.ClientId,
+ Description = newApp.AppDescription
+ };
+
+ //Must write response while the secret is in scope
+ entity.CloseResponseJson(HttpStatusCode.Created, mess);
+ return VfReturnType.VirtualSkip;
+ }
+
+ private static string ParsePermissions(string permissions)
+ {
+ StringBuilder builder = new();
+ //Local function for splitting permissions
+ static void SplitCb(ReadOnlySpan<char> permission, StringBuilder builder)
+ {
+ builder.Append(permission);
+ builder.Append(',');
+ }
+ //Split permissions at comma and clean up the entires
+ permissions.AsSpan().Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries, SplitCb, builder);
+ //return the string
+ return builder.ToString();
+ }
+ }
+} \ No newline at end of file
diff --git a/Plugins/OAuth2ClientApplications/Endpoints/UserAppValidator.cs b/Plugins/OAuth2ClientApplications/Endpoints/UserAppValidator.cs
new file mode 100644
index 0000000..dc865e9
--- /dev/null
+++ b/Plugins/OAuth2ClientApplications/Endpoints/UserAppValidator.cs
@@ -0,0 +1,43 @@
+using FluentValidation;
+using FluentValidation.Results;
+
+using VNLib.Plugins.Extensions.Validation;
+using VNLib.Plugins.Essentials.Oauth.Applications;
+
+namespace OAuth2ClientApplications.Endpoints
+{
+ internal class UserAppValidator : AbstractValidator<UserApplication>
+ {
+ public UserAppValidator()
+ {
+ //Name rules
+ RuleFor(p => p.AppName)
+ .Length(1, 50)
+ .WithName("App name")
+ .SpecialCharacters()
+ .WithName("App name");
+ //Description rules
+ RuleFor(app => app.AppDescription)
+ .SpecialCharacters()
+ .WithName("Description")
+ .MaximumLength(100)
+ .WithName("Description");
+ RuleFor(app => app.Permissions)
+ .MaximumLength(100)
+ .SpecialCharacters()
+ .WithMessage("Invalid permissions");
+ }
+
+ public override ValidationResult Validate(ValidationContext<UserApplication> context)
+ {
+ //Get a ref to the app
+ UserApplication app = context.InstanceToValidate;
+ //remove unused fields
+ app.ClientId = null;
+ app.SecretHash = null;
+ //validate the rest of the app
+ return base.Validate(context);
+ }
+ }
+
+} \ No newline at end of file
diff --git a/Plugins/OAuth2ClientApplications/OAuth2ClientApplications.csproj b/Plugins/OAuth2ClientApplications/OAuth2ClientApplications.csproj
new file mode 100644
index 0000000..bbb6deb
--- /dev/null
+++ b/Plugins/OAuth2ClientApplications/OAuth2ClientApplications.csproj
@@ -0,0 +1,51 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>net6.0</TargetFramework>
+ <Copyright>Copyright © 2022 Vaughn Nugent</Copyright>
+ <Company>Vaughn Nugent</Company>
+ <Authors>Vaughn Nugent</Authors>
+ <AssemblyVersion>1.0.2.1</AssemblyVersion>
+ <FileVersion>1.0.2.1</FileVersion>
+ <PackageProjectUrl>www.vaughnnugent.com/resources</PackageProjectUrl>
+ <Version>1.0.2.1</Version>
+ <Platforms>AnyCPU;x64</Platforms>
+ </PropertyGroup>
+
+ <!-- Resolve nuget dll files and store them in the output dir -->
+ <PropertyGroup>
+ <!--Enable dynamic loading-->
+ <EnableDynamicLoading>true</EnableDynamicLoading>
+ <Nullable>enable</Nullable>
+ </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>
+ </ItemGroup>
+ <ItemGroup>
+ <ProjectReference Include="..\..\..\..\VNLib\Utils\src\VNLib.Utils.csproj" />
+ <ProjectReference Include="..\..\..\Extensions\VNLib.Plugins.Extensions.Data\VNLib.Plugins.Extensions.Data.csproj" />
+ <ProjectReference Include="..\..\..\Extensions\VNLib.Plugins.Extensions.Loading.Sql\VNLib.Plugins.Extensions.Loading.Sql.csproj" />
+ <ProjectReference Include="..\..\..\Extensions\VNLib.Plugins.Extensions.Loading\VNLib.Plugins.Extensions.Loading.csproj" />
+ <ProjectReference Include="..\..\..\Extensions\VNLib.Plugins.Extensions.Validation\VNLib.Plugins.Extensions.Validation.csproj" />
+ <ProjectReference Include="..\..\..\PluginBase\VNLib.Plugins.PluginBase.csproj" />
+ <ProjectReference Include="..\..\Libs\VNLib.Plugins.Essentials.Oauth\VNLib.Plugins.Essentials.Oauth.csproj" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <None Update="OAuth2ClientApplications.json">
+ <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+ </None>
+ </ItemGroup>
+
+ <Target Name="PostBuild" AfterTargets="PostBuildEvent">
+ <Exec Command="start xcopy &quot;$(TargetDir)&quot; &quot;F:\Programming\Web Plugins\DevPlugins\$(TargetName)&quot; /E /Y /R" />
+ </Target>
+
+</Project>
diff --git a/Plugins/OAuth2ClientApplications/OAuth2ClientAppsEntryPoint.cs b/Plugins/OAuth2ClientApplications/OAuth2ClientAppsEntryPoint.cs
new file mode 100644
index 0000000..b2354e5
--- /dev/null
+++ b/Plugins/OAuth2ClientApplications/OAuth2ClientAppsEntryPoint.cs
@@ -0,0 +1,41 @@
+using System;
+using System.Collections.Generic;
+
+using OAuth2ClientApplications.Endpoints;
+
+using VNLib.Utils.Logging;
+using VNLib.Plugins;
+using VNLib.Plugins.Extensions.Loading.Routing;
+
+namespace OAuth2ClientApplications
+{
+ public class OAuth2ClientAppsEntryPoint : PluginBase
+ {
+ public override string PluginName => "OAuth2.ClientApps";
+
+ protected override void OnLoad()
+ {
+ try
+ {
+ //Route the applications endpoint
+ this.Route<ApplicationEndpoint>();
+
+ Log.Information("Plugin Loaded");
+ }
+ catch (KeyNotFoundException kne)
+ {
+ Log.Error("Missing required configuration variables, {reason}", kne.Message);
+ }
+ }
+
+ protected override void OnUnLoad()
+ {
+ Log.Information("Plugin unloaded");
+ }
+
+ protected override void ProcessHostCommand(string cmd)
+ {
+ throw new NotImplementedException();
+ }
+ }
+}