/*
* Copyright (c) 2023 Vaughn Nugent
*
* Library: VNLib
* Package: VNLib.Plugins.Essentials.Oauth
* File: ApplicationStore.cs
*
* ApplicationStore.cs is part of VNLib.Plugins.Essentials.Oauth which is part of the larger
* VNLib collection of libraries and utilities.
*
* VNLib.Plugins.Essentials.Oauth is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* VNLib.Plugins.Essentials.Oauth is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
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 ApplicationStore : DbStore
{
public const int SECRET_SIZE = 32;
public const int CLIENT_ID_SIZE = 16;
private readonly IPasswordHashingProvider 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 ApplicationStore(DbContextOptions conextOptions, IPasswordHashingProvider 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 specifier)
{
UserAppContext ctx = (context as UserAppContext)!;
//Get the user's applications based on their userid
return from userApp in ctx.OAuthApps
where userApp.UserId == specifier
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[] constraints)
{
return GetCollectionQueryBuilder(context, constraints[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();
}
}
}