aboutsummaryrefslogtreecommitdiff
path: root/Libs/VNLib.Plugins.Essentials.Oauth/Applications
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/VNLib.Plugins.Essentials.Oauth/Applications
parent8b09e20f6dbaf7644fc64833d7d8eeda4b576ad9 (diff)
Add project files.
Diffstat (limited to 'Libs/VNLib.Plugins.Essentials.Oauth/Applications')
-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
3 files changed, 412 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