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 { /// /// A DbStore for s for OAuth2 client applications /// public sealed partial class Applications : DbStore { 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; /// /// Initializes a new data store /// uisng the specified EFCore object. /// /// EFCore context options for connecting to a remote data-store /// A structure for hashing client secrets public Applications(DbContextOptions conextOptions, PasswordHashing secretHashing) { this.ConextOptions = conextOptions; this.SecretHashing = secretHashing; this.TokenStore = new TokenStore(conextOptions); } /// /// Updates the secret of an application, and if successful returns the new raw secret data /// /// The user-id of that owns the application /// The id of the application to update /// A task that resolves to the raw secret that was used to generate the hash, or null if the operation failed public async Task 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; } /// /// Attempts to retreive an application by the specified client id and compares the raw secret against the /// stored secret hash. /// /// The clientid of the application to search /// The secret to compare against /// 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 public async Task 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; } /// public override async Task 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; } /// /// Generates a client application secret using the library /// /// The RNG secret public static PrivateString GenerateSecret() => (PrivateString)RandomHash.GetRandomHex(SECRET_SIZE).ToLower()!; /// /// Creates and initializes a new with a random clientid and /// secret that must be disposed /// /// The new record to create /// The result of the operation public override async Task 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); } /// /// Generates a new client ID using the library /// /// The new client ID public static string GenerateClientID() => RandomHash.GetRandomHex(CLIENT_ID_SIZE).ToLower(); /// public override string RecordIdBuilder => RandomHash.GetRandomHex(CLIENT_ID_SIZE).ToLower(); /// public override TransactionalDbContext NewContext() => new UserAppContext(ConextOptions); /// protected override IQueryable 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; } /// protected override void OnRecordUpdate(UserApplication newRecord, UserApplication currentRecord) { currentRecord.AppDescription = newRecord.AppDescription; currentRecord.AppName = newRecord.AppName; } /// protected override IQueryable 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 }; } /// protected override IQueryable GetCollectionQueryBuilder(TransactionalDbContext context, params string[] args) { return GetCollectionQueryBuilder(context, args[0]); } /// protected override IQueryable 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 }; } /// protected override IQueryable 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); } /// protected override IQueryable 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 GetPageAsync(ICollection collection, int page, int limit) { throw new NotSupportedException(); } } }