diff --git a/src/NetDevPack.Security.Jwt.Core/DefaultStore/InMemoryStore.cs b/src/NetDevPack.Security.Jwt.Core/DefaultStore/InMemoryStore.cs index 01d4d66..9bd2511 100644 --- a/src/NetDevPack.Security.Jwt.Core/DefaultStore/InMemoryStore.cs +++ b/src/NetDevPack.Security.Jwt.Core/DefaultStore/InMemoryStore.cs @@ -7,11 +7,14 @@ namespace NetDevPack.Security.Jwt.Core.DefaultStore; internal class InMemoryStore : IJsonWebKeyStore { - internal const string DefaultRevocationReason = "Revoked"; private static readonly List _store = new(); private readonly SemaphoreSlim _slim = new(1); + internal const string DefaultRevocationReason = "Revoked"; + public Task Store(KeyMaterial keyMaterial) { + if (keyMaterial is null) throw new InvalidOperationException("Can't store empty value."); + _slim.Wait(); _store.Add(keyMaterial); _slim.Release(); @@ -19,9 +22,9 @@ public Task Store(KeyMaterial keyMaterial) return Task.CompletedTask; } - public Task GetCurrent(JwtKeyType jwtKeyType) + public Task GetCurrent(JwtKeyType jwtKeyType = JwtKeyType.Jws) { - return Task.FromResult(_store.OrderByDescending(s => s.CreationDate).FirstOrDefault()); + return Task.FromResult(_store.Where(s => s.Use == (jwtKeyType == JwtKeyType.Jws ? "sig" : "enc")).OrderByDescending(s => s.CreationDate).FirstOrDefault()); } public async Task Revoke(KeyMaterial keyMaterial, string reason = null) @@ -41,7 +44,7 @@ public async Task Revoke(KeyMaterial keyMaterial, string reason = null) } } - public Task> GetLastKeys(int quantity, JwtKeyType? jwtKeyType) + public Task> GetLastKeys(int quantity, JwtKeyType? jwtKeyType = null) { return Task.FromResult( _store diff --git a/src/NetDevPack.Security.Jwt.Core/Jwt/JwtService.cs b/src/NetDevPack.Security.Jwt.Core/Jwt/JwtService.cs index 4e8938b..56c89c2 100644 --- a/src/NetDevPack.Security.Jwt.Core/Jwt/JwtService.cs +++ b/src/NetDevPack.Security.Jwt.Core/Jwt/JwtService.cs @@ -61,14 +61,7 @@ public async Task GetCurrentEncryptingCredentials() public Task> GetLastKeys(int? i = null) { - JwtKeyType? jwtKeyType = null; - - if (_options.Value.ExposedKeyType == JwtType.Jws) - jwtKeyType = JwtKeyType.Jws; - else if (_options.Value.ExposedKeyType == JwtType.Jwe) - jwtKeyType = JwtKeyType.Jwe; - - return _store.GetLastKeys(_options.Value.AlgorithmsToKeep, jwtKeyType); + return _store.GetLastKeys(_options.Value.AlgorithmsToKeep, null); } public Task> GetLastKeys(int i, JwtKeyType jwtKeyType) diff --git a/src/NetDevPack.Security.Jwt.Core/JwtOptions.cs b/src/NetDevPack.Security.Jwt.Core/JwtOptions.cs index cddc93f..e00b40c 100644 --- a/src/NetDevPack.Security.Jwt.Core/JwtOptions.cs +++ b/src/NetDevPack.Security.Jwt.Core/JwtOptions.cs @@ -10,5 +10,4 @@ public class JwtOptions public string KeyPrefix { get; set; } = $"{Environment.MachineName}_"; public int AlgorithmsToKeep { get; set; } = 2; public TimeSpan CacheTime { get; set; } = TimeSpan.FromMinutes(15); - public JwtType ExposedKeyType { get; set; } = JwtType.Jws; } \ No newline at end of file diff --git a/src/NetDevPack.Security.Jwt.Core/Model/KeyMaterial.cs b/src/NetDevPack.Security.Jwt.Core/Model/KeyMaterial.cs index 196ede2..058e927 100644 --- a/src/NetDevPack.Security.Jwt.Core/Model/KeyMaterial.cs +++ b/src/NetDevPack.Security.Jwt.Core/Model/KeyMaterial.cs @@ -35,7 +35,7 @@ public JsonWebKey GetJsonWebKey() }; jsonWebKey.Use = Algorithm.CryptographyType == CryptographyType.DigitalSignature ? "sig" : "enc"; - jsonWebKey.Alg = Algorithm.Alg; // Assure-toi que `Algorithm.Name` contient l'algorithme correct + jsonWebKey.Alg = Algorithm.Alg; return jsonWebKey; } diff --git a/src/NetDevPack.Security.Jwt.Store.EntityFrameworkCore/DatabaseJsonWebKeyStore.cs b/src/NetDevPack.Security.Jwt.Store.EntityFrameworkCore/DatabaseJsonWebKeyStore.cs index 34291b4..d9c7f02 100644 --- a/src/NetDevPack.Security.Jwt.Store.EntityFrameworkCore/DatabaseJsonWebKeyStore.cs +++ b/src/NetDevPack.Security.Jwt.Store.EntityFrameworkCore/DatabaseJsonWebKeyStore.cs @@ -22,6 +22,7 @@ internal class DatabaseJsonWebKeyStore : IJsonWebKeyStore private readonly IOptions _options; private readonly IMemoryCache _memoryCache; private readonly ILogger> _logger; + internal const string DefaultRevocationReason = "Revoked"; public DatabaseJsonWebKeyStore(TContext context, ILogger> logger, IOptions options, IMemoryCache memoryCache) { @@ -46,10 +47,11 @@ public async Task GetCurrent(JwtKeyType jwtKeyType = JwtKeyType.Jws if (!_memoryCache.TryGetValue(cacheKey, out KeyMaterial credentials)) { + var keyType = (jwtKeyType == JwtKeyType.Jws ? "sig" : "enc"); #if NET5_0_OR_GREATER - credentials = await _context.SecurityKeys.Where(X => X.IsRevoked == false).OrderByDescending(d => d.CreationDate).AsNoTrackingWithIdentityResolution().FirstOrDefaultAsync(); + credentials = await _context.SecurityKeys.Where(X => X.IsRevoked == false).Where(s => s.Use == keyType).OrderByDescending(d => d.CreationDate).AsNoTrackingWithIdentityResolution().FirstOrDefaultAsync(); #else - credentials = await _context.SecurityKeys.Where(X => X.IsRevoked == false).OrderByDescending(d => d.CreationDate).AsNoTracking().FirstOrDefaultAsync(); + credentials = await _context.SecurityKeys.Where(X => X.IsRevoked == false).Where(s => s.Use == keyType).OrderByDescending(d => d.CreationDate).AsNoTracking().FirstOrDefaultAsync(); #endif // Set cache options. @@ -73,9 +75,11 @@ public async Task> GetLastKeys(int quantity = 5, if (!_memoryCache.TryGetValue(cacheKey, out ReadOnlyCollection keys)) { #if NET5_0_OR_GREATER - keys = _context.SecurityKeys.OrderByDescending(d => d.CreationDate).Take(quantity).AsNoTrackingWithIdentityResolution().ToList().AsReadOnly(); + keys = (await _context.SecurityKeys.Where(s => jwtKeyType == null || s.Use == (jwtKeyType == JwtKeyType.Jws ? "sig" : "enc")) + .OrderByDescending(d => d.CreationDate).AsNoTrackingWithIdentityResolution().ToListAsync()).AsReadOnly(); #else - keys = _context.SecurityKeys.OrderByDescending(d => d.CreationDate).Take(quantity).AsNoTracking().ToList().AsReadOnly(); + keys = _context.SecurityKeys.Where(s => jwtKeyType == null || s.Use == (jwtKeyType == JwtKeyType.Jws ? "sig" : "enc")) + .OrderByDescending(d => d.CreationDate).AsNoTracking().ToList().AsReadOnly(); #endif // Set cache options. var cacheEntryOptions = new MemoryCacheEntryOptions() @@ -84,11 +88,11 @@ public async Task> GetLastKeys(int quantity = 5, if (keys.Any()) _memoryCache.Set(cacheKey, keys, cacheEntryOptions); - - return keys; } - return keys; + return keys.GroupBy(s => s.Use) + .SelectMany(g => g.Take(quantity)) + .ToList().AsReadOnly(); } public Task Get(string keyId) @@ -113,7 +117,7 @@ public async Task Revoke(KeyMaterial securityKeyWithPrivate, string reason = nul if (securityKeyWithPrivate == null) return; - securityKeyWithPrivate.Revoke(reason); + securityKeyWithPrivate.Revoke(reason ?? DefaultRevocationReason); _context.Attach(securityKeyWithPrivate); _context.SecurityKeys.Update(securityKeyWithPrivate); await _context.SaveChangesAsync(); diff --git a/src/NetDevPack.Security.Jwt.Store.FileSystem/FileSystemStore.cs b/src/NetDevPack.Security.Jwt.Store.FileSystem/FileSystemStore.cs index 52e1c41..f3375bf 100644 --- a/src/NetDevPack.Security.Jwt.Store.FileSystem/FileSystemStore.cs +++ b/src/NetDevPack.Security.Jwt.Store.FileSystem/FileSystemStore.cs @@ -12,6 +12,7 @@ namespace NetDevPack.Security.Jwt.Store.FileSystem { public class FileSystemStore : IJsonWebKeyStore { + internal const string DefaultRevocationReason = "Revoked"; private readonly IOptions _options; private readonly IMemoryCache _memoryCache; public DirectoryInfo KeysPath { get; } @@ -25,13 +26,13 @@ public FileSystemStore(DirectoryInfo keysPath, IOptions options, IMe KeysPath.Create(); } - private string GetCurrentFile() + private string GetCurrentFile(JwtKeyType jwtKeyType) { - var files = Directory.GetFiles(KeysPath.FullName, $"*current*.key"); + var files = Directory.GetFiles(KeysPath.FullName, $"*current*.{jwtKeyType}.key"); if (files.Any()) - return Path.Combine(KeysPath.FullName, files.First()); + return files.First(); - return Path.Combine(KeysPath.FullName, $"{_options.Value.KeyPrefix}current.key"); + return Path.Combine(KeysPath.FullName, $"{_options.Value.KeyPrefix}current.{jwtKeyType}.key"); } public async Task Store(KeyMaterial securityParamteres) @@ -39,17 +40,21 @@ public async Task Store(KeyMaterial securityParamteres) if (!KeysPath.Exists) KeysPath.Create(); + JwtKeyType keyType = securityParamteres.Use.Equals("enc", StringComparison.InvariantCultureIgnoreCase) ? JwtKeyType.Jwe : JwtKeyType.Jws; + // Datetime it's just to be easy searchable. - if (File.Exists(GetCurrentFile())) - File.Copy(GetCurrentFile(), Path.Combine(KeysPath.FullName, $"{_options.Value.KeyPrefix}old-{DateTime.Now:yyyy-MM-dd}-{securityParamteres.KeyId}.key")); + if (File.Exists(GetCurrentFile(keyType))) + File.Copy(GetCurrentFile(keyType), Path.Combine(KeysPath.FullName, $"{_options.Value.KeyPrefix}old-{DateTime.Now:yyyy-MM-dd}-{securityParamteres.KeyId}.key")); - await File.WriteAllTextAsync(Path.Combine(KeysPath.FullName, $"{_options.Value.KeyPrefix}current-{securityParamteres.KeyId}.key"), JsonSerializer.Serialize(securityParamteres, new JsonSerializerOptions() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull })); + await File.WriteAllTextAsync(Path.Combine(KeysPath.FullName, $"{_options.Value.KeyPrefix}current-{securityParamteres.KeyId}.{keyType}.key"), JsonSerializer.Serialize(securityParamteres, new JsonSerializerOptions() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull })); ClearCache(); } - public bool NeedsUpdate() + public bool NeedsUpdate(KeyMaterial current) { - return !File.Exists(GetCurrentFile()) || File.GetCreationTimeUtc(GetCurrentFile()).AddDays(_options.Value.DaysUntilExpire) < DateTime.UtcNow.Date; + JwtKeyType keyType = current.Use.Equals("enc", StringComparison.InvariantCultureIgnoreCase) ? JwtKeyType.Jwe : JwtKeyType.Jws; + + return !File.Exists(GetCurrentFile(keyType)) || File.GetCreationTimeUtc(GetCurrentFile(keyType)).AddDays(_options.Value.DaysUntilExpire) < DateTime.UtcNow.Date; } public async Task Revoke(KeyMaterial securityKeyWithPrivate, string reason = null) @@ -57,7 +62,7 @@ public async Task Revoke(KeyMaterial securityKeyWithPrivate, string reason = nul if (securityKeyWithPrivate == null) return; - securityKeyWithPrivate?.Revoke(); + securityKeyWithPrivate?.Revoke(reason ?? DefaultRevocationReason); foreach (var fileInfo in KeysPath.GetFiles("*.key")) { var key = GetKey(fileInfo.FullName); @@ -75,7 +80,7 @@ public async Task Revoke(KeyMaterial securityKeyWithPrivate, string reason = nul if (!_memoryCache.TryGetValue(cacheKey, out KeyMaterial credentials)) { - credentials = GetKey(GetCurrentFile()); + credentials = GetKey(GetCurrentFile(jwtKeyType)); // Set cache options. var cacheEntryOptions = new MemoryCacheEntryOptions() // Keep in cache for this time, reset time if accessed. @@ -87,9 +92,9 @@ public async Task Revoke(KeyMaterial securityKeyWithPrivate, string reason = nul return Task.FromResult(credentials); } - private KeyMaterial GetKey(string file) + private KeyMaterial? GetKey(string file) { - if (!File.Exists(file)) throw new FileNotFoundException("Check configuration - cannot find auth key file: " + file); + if (!File.Exists(file)) return null; var keyParams = JsonSerializer.Deserialize(File.ReadAllText(file)); return keyParams!; @@ -101,8 +106,9 @@ public Task> GetLastKeys(int quantity = 5, JwtKe if (!_memoryCache.TryGetValue(cacheKey, out IReadOnlyCollection keys)) { - keys = KeysPath.GetFiles("*.key") - .Take(quantity) + var type = jwtKeyType == null ? "*" : jwtKeyType.ToString(); + + keys = KeysPath.GetFiles($"*.{type}.key") .Select(s => s.FullName) .Select(GetKey).ToList().AsReadOnly(); @@ -115,7 +121,10 @@ public Task> GetLastKeys(int quantity = 5, JwtKe _memoryCache.Set(cacheKey, keys, cacheEntryOptions); } - return Task.FromResult(keys.ToList().AsReadOnly()); + return Task.FromResult(keys + .GroupBy(s => s.Use) + .SelectMany(g => g.Take(quantity)) + .ToList().AsReadOnly()); } public Task Get(string keyId) diff --git a/tests/NetDevPack.Security.Jwt.Tests/JwtTests/JwtServiceTest.cs b/tests/NetDevPack.Security.Jwt.Tests/JwtTests/JwtServiceTest.cs index b093e23..fd7fc2e 100644 --- a/tests/NetDevPack.Security.Jwt.Tests/JwtTests/JwtServiceTest.cs +++ b/tests/NetDevPack.Security.Jwt.Tests/JwtTests/JwtServiceTest.cs @@ -22,8 +22,6 @@ public JwtServiceTest(WarmupInMemoryStore warmup) _jwksService = warmup.Services.GetRequiredService(); } - - [Fact] public async Task Should_Create_New_Key() { diff --git a/tests/NetDevPack.Security.Jwt.Tests/StoreTests/DatabaseInMemoryStoreTests.cs b/tests/NetDevPack.Security.Jwt.Tests/StoreTests/DatabaseInMemoryStoreTests.cs new file mode 100644 index 0000000..adf4bdb --- /dev/null +++ b/tests/NetDevPack.Security.Jwt.Tests/StoreTests/DatabaseInMemoryStoreTests.cs @@ -0,0 +1,12 @@ +using NetDevPack.Security.Jwt.Tests.Warmups; +using Xunit; + +namespace NetDevPack.Security.Jwt.Tests.StoreTests; + +[Trait("Category", "DatabaseInMemory Tests")] +public class DatabaseInMemoryStoreTests : GenericStoreServiceTest +{ + public DatabaseInMemoryStoreTests(WarmupDatabaseInMemoryStore unifiedContext) : base(unifiedContext) + { + } +} \ No newline at end of file diff --git a/tests/NetDevPack.Security.Jwt.Tests/StoreTests/FileSystemStoreTests.cs b/tests/NetDevPack.Security.Jwt.Tests/StoreTests/FileSystemStoreTests.cs index bfa7106..6cd0555 100644 --- a/tests/NetDevPack.Security.Jwt.Tests/StoreTests/FileSystemStoreTests.cs +++ b/tests/NetDevPack.Security.Jwt.Tests/StoreTests/FileSystemStoreTests.cs @@ -3,11 +3,12 @@ namespace NetDevPack.Security.Jwt.Tests.StoreTests { - //[Trait("Category", "InMemory Tests")] - //public class FileSystemStoreTests : GenericStoreServiceTest - //{ - // public FileSystemStoreTests(WarmupFileStore unifiedContext) : base(unifiedContext) - // { - // } - //} + [Trait("Category", "InMemory Tests")] + public class FileSystemStoreTests : GenericStoreServiceTest + { + public FileSystemStoreTests(WarmupFileStore unifiedContext) : base(unifiedContext) + { + } + + } } diff --git a/tests/NetDevPack.Security.Jwt.Tests/StoreTests/GenericStoreServiceTest.cs b/tests/NetDevPack.Security.Jwt.Tests/StoreTests/GenericStoreServiceTest.cs index 87909aa..88b910c 100644 --- a/tests/NetDevPack.Security.Jwt.Tests/StoreTests/GenericStoreServiceTest.cs +++ b/tests/NetDevPack.Security.Jwt.Tests/StoreTests/GenericStoreServiceTest.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Security.Claims; using System.Threading; @@ -23,6 +24,7 @@ public abstract class GenericStoreServiceTest : IClassFixture { private static SemaphoreSlim TestSync = new(1); protected readonly IJsonWebKeyStore _store; + protected readonly IJwtService _jwtService; private readonly IOptions _options; public TWarmup WarmupData { get; } @@ -30,6 +32,7 @@ public GenericStoreServiceTest(TWarmup warmup) { WarmupData = warmup; _store = WarmupData.Services.GetRequiredService(); + _jwtService = WarmupData.Services.GetRequiredService(); _options = WarmupData.Services.GetRequiredService>(); this.WarmupData.Clear(); } @@ -519,6 +522,28 @@ public async Task Should_Read_NonDefault_Revocation_Reason(string reason) await CheckRevocationReasonIsStored(keyMaterial.KeyId, reason); } + [Fact] + public async Task Should_Generate_Different_Keys_For_JWS_And_JWE_And_Retrieve_Them_Correctly() + { + var defaultVal = await _jwtService.GetCurrentSecurityKey(); + var jwe = await _jwtService.GetCurrentSecurityKey(JwtKeyType.Jwe); + var jws = await _jwtService.GetCurrentSecurityKey(JwtKeyType.Jws); + + var getLast2DefaultVal = await _jwtService.GetLastKeys(1); + var getLastJwe = (await _jwtService.GetLastKeys(1, JwtKeyType.Jwe)).First(); + var getLastJws = (await _jwtService.GetLastKeys(1, JwtKeyType.Jws)).First(); + + jws.KeyId.Should().NotBe(jwe.KeyId); + getLastJws.KeyId.Should().NotBe(getLastJwe.KeyId); + defaultVal.KeyId.Should().Be(jws.KeyId); + jwe.KeyId.Should().Be(getLastJwe.KeyId); + jws.KeyId.Should().Be(getLastJws.KeyId); + + getLast2DefaultVal.Should().HaveCount(2); + getLast2DefaultVal.Should().ContainSingle(x => x.Use == "enc"); + getLast2DefaultVal.Should().ContainSingle(x => x.Use == "sig"); + } + private async Task CheckRevocationReasonIsStored(string keyId, string revocationReason) { var dbKey = (await _store.GetLastKeys(5)).First(w => w.KeyId == keyId); diff --git a/tests/NetDevPack.Security.Jwt.Tests/Warmups/WarmupDatabaseInMemory.cs b/tests/NetDevPack.Security.Jwt.Tests/Warmups/WarmupDatabaseInMemory.cs index 3151700..2ebbac7 100644 --- a/tests/NetDevPack.Security.Jwt.Tests/Warmups/WarmupDatabaseInMemory.cs +++ b/tests/NetDevPack.Security.Jwt.Tests/Warmups/WarmupDatabaseInMemory.cs @@ -6,12 +6,12 @@ namespace NetDevPack.Security.Jwt.Tests.Warmups; -public class WarmupDatabaseInMemory : IWarmupTest +public class WarmupDatabaseInMemoryStore : IWarmupTest { private readonly IJsonWebKeyStore _jsonWebKeyStore; public ServiceProvider Services { get; set; } - public WarmupDatabaseInMemory() + public WarmupDatabaseInMemoryStore() { var serviceCollection = new ServiceCollection(); @@ -21,11 +21,7 @@ public WarmupDatabaseInMemory() serviceCollection.AddLogging(); serviceCollection.AddDbContext(DatabaseOptions); - serviceCollection.AddJwksManager(o => - { - o.Jws = Algorithm.Create(AlgorithmType.AES, JwtType.Jws); - o.Jwe = Algorithm.Create(AlgorithmType.AES, JwtType.Jwe); - }) + serviceCollection.AddJwksManager() .PersistKeysToDatabaseStore(); Services = serviceCollection.BuildServiceProvider(); _jsonWebKeyStore = Services.GetRequiredService(); diff --git a/tests/NetDevPack.Security.Jwt.Tests/Warmups/WarmupFileStore.cs b/tests/NetDevPack.Security.Jwt.Tests/Warmups/WarmupFileStore.cs index 87c961b..19f51eb 100644 --- a/tests/NetDevPack.Security.Jwt.Tests/Warmups/WarmupFileStore.cs +++ b/tests/NetDevPack.Security.Jwt.Tests/Warmups/WarmupFileStore.cs @@ -1,4 +1,5 @@ -using System.IO; +using System; +using System.IO; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using NetDevPack.Security.Jwt.Core; @@ -11,19 +12,32 @@ public class WarmupFileStore : IWarmupTest { private readonly IJsonWebKeyStore _jsonWebKeyStore; public ServiceProvider Services { get; set; } + public DirectoryInfo _directoryInfo; + public WarmupFileStore() { + _directoryInfo = TempDirectoryTest(); + var serviceCollection = new ServiceCollection(); serviceCollection.AddLogging(); serviceCollection.AddMemoryCache(); - serviceCollection.AddJwksManager().PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(Directory.GetCurrentDirectory(), "files"))); + serviceCollection.AddJwksManager().PersistKeysToFileSystem(_directoryInfo); Services = serviceCollection.BuildServiceProvider(); _jsonWebKeyStore = Services.GetRequiredService(); } + public DirectoryInfo TempDirectoryTest() + { + // Créer un répertoire temporaire unique + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + return Directory.CreateDirectory(tempDir); + } + public async Task Clear() { await _jsonWebKeyStore.Clear(); + _directoryInfo.Delete(true); + Directory.CreateDirectory(_directoryInfo.FullName); } } }