diff --git a/docs/migrating/from_duende.rst b/docs/migrating/from_duende.rst index 09866cacd..6a9f7b64d 100644 --- a/docs/migrating/from_duende.rst +++ b/docs/migrating/from_duende.rst @@ -102,7 +102,7 @@ Migration Steps .. _refReadOnlyKeyStore: - If you were using automatic key management, you will now need to register read-only key store or add a signing key explicitly: + If you were using automatic key management, you will now need to register the read-only key store and/or add a signing key(s) explicitly: .. code-block:: c# @@ -113,6 +113,12 @@ Migration Steps // Explicit key registration builder.Services.AddIdentityServer() .AddSigningCredential(certificate); + + .. warning:: + If configuring the read-only key store, all keys created within a 90 days will be considered valid, and the newest + key within this timespan will be registered as a signing key. The extension method used to register the + compatibility store allows you to configure this ``.AddCompatibilityKeyStores(opt => opt.MaxLifetime = TimeSpan.FromDays(120))``. + 5. **Migrate the database schema (if applicable)** diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Crypto.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Crypto.cs index 57286c1ff..a0e2590a6 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Crypto.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Crypto.cs @@ -303,12 +303,17 @@ public static IIdentityServerBuilder AddValidationKey( /// Add the signing and validation key stores that target the IdentityServer compatibility key stores /// /// The builder. + /// Action to configure compatibility key store options /// - public static IIdentityServerBuilder AddCompatibilityKeyStores(this IIdentityServerBuilder builder) + public static IIdentityServerBuilder AddCompatibilityKeyStores(this IIdentityServerBuilder builder, Action configure = null) { + var options = new CompatibilityKeyStoreOptions(); + configure?.Invoke(options); + + builder.Services.AddSingleton(options); builder.Services.AddTransient(); - builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); return builder; } diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/CompatibilityKeyStoreOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/CompatibilityKeyStoreOptions.cs new file mode 100644 index 000000000..9bbfeb23c --- /dev/null +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/CompatibilityKeyStoreOptions.cs @@ -0,0 +1,17 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +using System; + +namespace Open.IdentityServer.Configuration; + +/// +/// Compatibility key store options. +/// +public class CompatibilityKeyStoreOptions +{ + /// + /// Maximum lifetime for keys read from the key store, defaults to 90 days. + /// + public TimeSpan MaxLifetime { get; set; } = TimeSpan.FromDays(90); +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/DataProtection/DataProtectedIdentityServerKeyMaterialConverter.cs b/src/Open.IdentityServer/src/DataProtection/DataProtectedIdentityServerKeyMaterialConverter.cs index f16e9aa9f..44e2950c1 100644 --- a/src/Open.IdentityServer/src/DataProtection/DataProtectedIdentityServerKeyMaterialConverter.cs +++ b/src/Open.IdentityServer/src/DataProtection/DataProtectedIdentityServerKeyMaterialConverter.cs @@ -18,7 +18,7 @@ namespace Open.IdentityServer.DataProtection; /// The data protection provider used to create a protector for unprotecting encrypted key material. public class DataProtectedIdentityServerKeyMaterialConverter(IDataProtectionProvider dataProtectionProvider) { - private IDataProtector dataProtector = dataProtectionProvider.CreateProtector("DataProtectionKeyProtector"); + private IDataProtector dataProtector = dataProtectionProvider.CreateProtector(DataProtectionConstants.KeyProtectorPurpose); /// /// Json serializer settings @@ -58,8 +58,6 @@ public SigningKey Convert(IdentityServerKeyMaterial keyMaterial) { var keyData = JsonSerializer.Deserialize(unprotectedData, Settings); - signingKey.Created = keyData.Created; - ECCurve curve = keyMaterial.Algorithm switch { "ES256" => CryptoHelper.GetCurveFromCrvValue("P-256"), @@ -70,6 +68,7 @@ public SigningKey Convert(IdentityServerKeyMaterial keyMaterial) var ecdsa = ECDsa.Create(new ECParameters { Curve = curve, D = keyData.D, Q = keyData.Q }); + signingKey.Created = keyData.Created; signingKey.Credentials = new SigningCredentials(new ECDsaSecurityKey(ecdsa) { KeyId = keyData.Id }, keyData.Algorithm); } @@ -79,7 +78,8 @@ public SigningKey Convert(IdentityServerKeyMaterial keyMaterial) var cert = X509CertificateLoader.LoadPkcs12(System.Convert.FromBase64String(keyData.CertificateRawData), null); - signingKey.Credentials = new SigningCredentials(new X509SecurityKey(cert), keyData.Algorithm); + signingKey.Created = keyData.Created; + signingKey.Credentials = new SigningCredentials(new X509SecurityKey(cert) { KeyId = keyData.Id }, keyData.Algorithm); } return signingKey; diff --git a/src/Open.IdentityServer/src/DataProtectionConstants.cs b/src/Open.IdentityServer/src/DataProtectionConstants.cs new file mode 100644 index 000000000..9e0485dcf --- /dev/null +++ b/src/Open.IdentityServer/src/DataProtectionConstants.cs @@ -0,0 +1,13 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +namespace Open.IdentityServer; + +/// +/// Constants for DataProtection in Open.IdentityServer. +/// +public static class DataProtectionConstants +{ + /// Purpose used when creating key material data protector. + public const string KeyProtectorPurpose = "DataProtectionKeyProtector"; +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerKeyStore.cs b/src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerKeyStore.cs new file mode 100644 index 000000000..b5ba1c614 --- /dev/null +++ b/src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerKeyStore.cs @@ -0,0 +1,45 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.DataProtection; +using Open.IdentityServer.Models; + +namespace Open.IdentityServer.Stores; + +/// +/// This class is the base implementation of the IdentityServer compatibility signing key store that retrieves legacy +/// from the existing IdentityServer read-only key store. +/// +/// The key store used to retrieve signing keys. +/// Converter used to decrypt and convert data-protected key material into usable credentials. +/// Time provider implementation +/// Compatibility key store options +public abstract class IdentityServerSigningKeyStore( + IIdentityServerKeyStore identityServerKeyStore, + DataProtectedIdentityServerKeyMaterialConverter dataProtectedIdentityServerKeyMaterialConverter, + TimeProvider timeProvider, + CompatibilityKeyStoreOptions options) +{ + private readonly IIdentityServerKeyStore identityServerKeyStore = identityServerKeyStore; + private readonly DataProtectedIdentityServerKeyMaterialConverter dataProtectedIdentityServerKeyMaterialConverter = dataProtectedIdentityServerKeyMaterialConverter; + private readonly CompatibilityKeyStoreOptions options = options; + + /// + /// Get all signing keys from the store, filtering based on the compatibility key store configuration and use + /// + /// + protected IEnumerable GetKeys() + { + var results = identityServerKeyStore.GetKeys() + .Where(x => x.Use == "signing") + .Select(dataProtectedIdentityServerKeyMaterialConverter.Convert) + .Where(x => x.Created.Add(options.MaxLifetime) > timeProvider.GetUtcNow().UtcDateTime) + .OrderByDescending(x => x.Created); + + return results; + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerSigningCredentialStore.cs b/src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerSigningCredentialStore.cs index 12588c90c..253bdbb71 100644 --- a/src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerSigningCredentialStore.cs +++ b/src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerSigningCredentialStore.cs @@ -1,9 +1,11 @@ // Copyright (c) 2026, Rock Solid Knowledge Ltd // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. +using System; using System.Linq; using System.Threading.Tasks; using Microsoft.IdentityModel.Tokens; +using Open.IdentityServer.Configuration; using Open.IdentityServer.DataProtection; namespace Open.IdentityServer.Stores; @@ -13,20 +15,25 @@ namespace Open.IdentityServer.Stores; /// /// The key store used to retrieve signing keys. /// Converter used to decrypt and convert data-protected key material into usable credentials. +/// Time provider implementation +/// Compatibility key store options public class IdentityServerSigningCredentialStore( IIdentityServerKeyStore identityServerKeyStore, - DataProtectedIdentityServerKeyMaterialConverter dataProtectedIdentityServerKeyMaterialConverter): ISigningCredentialStore + DataProtectedIdentityServerKeyMaterialConverter dataProtectedIdentityServerKeyMaterialConverter, + TimeProvider timeProvider, + CompatibilityKeyStoreOptions options + ): + IdentityServerSigningKeyStore(identityServerKeyStore, dataProtectedIdentityServerKeyMaterialConverter, timeProvider, options), + ISigningCredentialStore { + /// /// Gets the key to be used for signing from the key store, will be the newest key /// /// a key to be used for signing public async Task GetSigningCredentialsAsync() { - return identityServerKeyStore.GetKeys() - .Where(x => x.Use == "signing") - .Select(dataProtectedIdentityServerKeyMaterialConverter.Convert) - .OrderByDescending(x => x.Created) + return GetKeys() .Select(x => x.Credentials) .FirstOrDefault(); } diff --git a/src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerValidationKeysStore.cs b/src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerValidationKeysStore.cs index 70bd08882..0be098739 100644 --- a/src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerValidationKeysStore.cs +++ b/src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerValidationKeysStore.cs @@ -1,9 +1,11 @@ // Copyright (c) 2026, Rock Solid Knowledge Ltd // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Open.IdentityServer.Configuration; using Open.IdentityServer.DataProtection; using Open.IdentityServer.Models; @@ -14,9 +16,16 @@ namespace Open.IdentityServer.Stores; /// /// The key store used to retrieve all available signing keys. /// Converter used to decrypt and convert data-protected key material into usable credentials. +/// Time provider implementation +/// Compatibility key store options public class IdentityServerValidationKeysStore( IIdentityServerKeyStore identityServerKeyStore, - DataProtectedIdentityServerKeyMaterialConverter dataProtectedIdentityServerKeyMaterialConverter): IValidationKeysStore + DataProtectedIdentityServerKeyMaterialConverter dataProtectedIdentityServerKeyMaterialConverter, + TimeProvider timeProvider, + CompatibilityKeyStoreOptions options + ): + IdentityServerSigningKeyStore(identityServerKeyStore, dataProtectedIdentityServerKeyMaterialConverter, timeProvider, options), + IValidationKeysStore { /// /// Gets all keys to be used for validation in the Duende key store, will be any other keys that are not retired, or the @@ -25,10 +34,7 @@ public class IdentityServerValidationKeysStore( /// list of validation keys info public async Task> GetValidationKeysAsync() { - return identityServerKeyStore.GetKeys() - .Where(x => x.Use == "signing") - .Select(dataProtectedIdentityServerKeyMaterialConverter.Convert) - .OrderByDescending(x => x.Created) + return GetKeys() .Select(x => new SecurityKeyInfo { Key = x.Credentials.Key, SigningAlgorithm = x.Credentials.Algorithm }) .ToList(); } diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Discovery/FakeIdentityServerKeyStore.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Discovery/FakeIdentityServerKeyStore.cs index 53950c0df..d75543404 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Discovery/FakeIdentityServerKeyStore.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Discovery/FakeIdentityServerKeyStore.cs @@ -1,3 +1,7 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +using System; using System.Collections.Generic; using Open.IdentityServer.Models; using Open.IdentityServer.Stores; @@ -6,6 +10,8 @@ namespace IdentityServer.IntegrationTests.Endpoints.Discovery; public class FakeIdentityServerKeyStore: IIdentityServerKeyStore { + public static readonly DateTime FakeNow = new(2026, 02, 01, 12, 00, 00, DateTimeKind.Utc); + public IEnumerable GetKeys() { return @@ -15,6 +21,7 @@ public IEnumerable GetKeys() new IdentityServerKeyMaterial { Id = "FakeRSA_ES256", Version = 1, Use = "signing", DataProtected = false, Algorithm = "ES256", Data = FakeES256KeyData }, new IdentityServerKeyMaterial { Id = "FakeRSA_ES384", Version = 1, Use = "signing", DataProtected = false, Algorithm = "ES384", Data = FakeES384KeyData }, new IdentityServerKeyMaterial { Id = "FakeRSA_ES521", Version = 1, Use = "signing", DataProtected = false, Algorithm = "ES521", Data = FakeES521KeyData }, + new IdentityServerKeyMaterial { Id = "FakeRSA_PS512_Expired", Version = 1, Use = "signing", DataProtected = false, Algorithm = "PS512", Data = FakePS512KeyData }, ]; } @@ -97,4 +104,23 @@ public IEnumerable GetKeys() "Created": "2026-01-01T12:00:00" } """; + + private const string FakePS512KeyData = """ + { + "Parameters": { + "D": "Bj8nuAP4kWK6Bh5u7JM1SlkENF4MmaCDhptjtoG3mo+Blh7EfRb3IGCNP4E81xmWTBAmnNdb6/H9e0YSublgXVHnXcsEwLCiIucWCa9YmwuUeBw4cMzNIHVsWss2wv7XpY18SVLzZs9PW99zgZwREquQFbYcjVGfEQqEYNGe891Lz+AKTo5Qpsyx3laZLTOUL+VBb5bJGJxl2HIeFWDbrn/bqltBRUcjayYiX563IwtHBwLBWBAEbrBBurGPaIk9PjC4WVAvVdvWYZQgksfePsV7/1zWhi6K5VElAn0ohrpmImHWh6ob+nIV6oUJG/gXpNAmB2ZbMDopeRn1QRZKAQ==", + "DP": "QUvY4w84l8MoqhAcxoXA7H6DaPZRpDnoNuTPfMVJCjnMxBLrX8k2Yx8vPk2KhFXSklG9K0PrLWsp8EO/dHVplbUXqhluYbeAJ6UZXNXW1cmjw6AXiYn+SwibP7KY6HhElxTVqhMqm+66e6Coei11otsweRMWT3dSmPBMlYoxc/M=", + "DQ": "2HtvEUst8qBP7yl1n2cMoEBNo6X6Hv8On2oFBvAtsUgzfHO9BKRdrrwo7fGH6t6ZGoGYB+uE9tbenRpr58ZEHHE65bKwZ6z/Us6ogKq/QOsfroQCU509U0x5aXn3DfYwzNGXDDlw7RESh89L9YWf7j9UsMeITPpw1nINf06jIAE=", + "Exponent": "AQAB", + "InverseQ": "PUaNIRe81tN+RmxtA4OrfKupweBWPfGrKXosT9IbIlP3aqEGsR0+rLtWzs+5fw0bMyFSLcVUmEQAfso/RirlskqtO0A5Zg2qfQjSVZkTCvLrPYBnu94JwNhA6K/MawkoRcqsM682FVOlJvSqkhbCZuG/6AcF+Lu3To4oDjsm5y4=", + "Modulus": "zYRdc59usklvJRIgQhK+6jEp0P8YohIj3vYEWyv3S/dHqa8dSGloyHDJhG6RAdqnR8S2nhGTcdokqRE4hSdWMaQSd5FKgC2ZpKe0BM3qt4q7lzid8kezkUqkhqCV39ITEiMxMkeMDK+SZqiH2OB5J6n+G4b5PnroshElcNXRaWultIm+vnAWkPXJyhc0mvD+uAlFd9TMbTU5BZE7NZ0tn8Y0Ny2MAPLWW0UVmkGeSwS5rCROiaJTS3P50BJD5eAJrq4OAilVjciBWWt68+6uwxmUGZye9foqAc98P3jLM7ccLrMKklyejTBNIcisyNEY+3+WM1NkA3mMy0qVS0cbXw==", + "P": "7khqnAiMQG17a5daoEmL2zcB6rpnJ5t7cVqJd12UUCbfVzhP+pdxvjG5vt4Dpb5/lN29GlC+sJeL9WsmBSwtCENb1No3uUQ8QbQYCFA4gBInck5q2RBRuPeqP4RHJWglR+9rDSs+hcGvNaDPBL70BCyUQKcrlaBSy4NDwBCFZb8=", + "Q": "3MxE5TRElLXPnY0miYxqGc3zpjKayu/c7yRaM/kR+16+fRi1AWhscVBpusA27xHdS5SZiTGEeLdqiKQ+OVC/oNmXS2aXDaGOyb6014jsil43sBQVQekeiTfkKslPE03DlOr6w6KqeuiKTF+yQBHvH3yybDlXy3wqpiGLq7az8mE=" + }, + "Id": "FakeRSA_PS512_Expired", + "Algorithm": "PS512", + "HasX509Certificate": false, + "Created": "2025-10-01T12:00:00" + } + """; } \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Discovery/JwkEndpointTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Discovery/JwkEndpointTests.cs index 05a1ef11c..665eaa231 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Discovery/JwkEndpointTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Discovery/JwkEndpointTests.cs @@ -1,12 +1,14 @@ // Copyright (c) 2026, Rock Solid Knowledge Ltd // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. +using System; using System.Linq; using System.Text.Json; using System.Threading.Tasks; using AwesomeAssertions; using IdentityServer.IntegrationTests.Common; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Time.Testing; using Microsoft.IdentityModel.Tokens; using Open.IdentityServer; using Open.IdentityServer.Configuration; @@ -157,9 +159,12 @@ public async Task Jwks_with_two_key_using_different_algs_expect_different_alg_va public async Task Jwks_WhenCompatibilityStoreConfigured_ShouldGetKeysFromStore() { IdentityServerPipeline pipeline = new IdentityServerPipeline(); + FakeTimeProvider timeProvider = new FakeTimeProvider(); + timeProvider.SetUtcNow(FakeIdentityServerKeyStore.FakeNow); pipeline.OnPostConfigureServices += services => { + services.AddSingleton(timeProvider); services.AddScoped(); services.AddIdentityServerBuilder() .AddCompatibilityKeyStores(); diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Open.IdentityServer.IntegrationTests.csproj b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Open.IdentityServer.IntegrationTests.csproj index 242a19ac0..f280fc5d8 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Open.IdentityServer.IntegrationTests.csproj +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Open.IdentityServer.IntegrationTests.csproj @@ -16,6 +16,7 @@ + diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/DataProtection/DataProtectedIdentityServerKeyMaterialConverterTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/DataProtection/DataProtectedIdentityServerKeyMaterialConverterTests.cs new file mode 100644 index 000000000..44c9159b0 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/DataProtection/DataProtectedIdentityServerKeyMaterialConverterTests.cs @@ -0,0 +1,201 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +using System; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using AwesomeAssertions; +using IdentityServer.UnitTests.Stores.Compatibility; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.IdentityModel.Tokens; +using Moq; +using Open.IdentityServer; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.DataProtection; +using Open.IdentityServer.Models; +using Xunit; + +namespace IdentityServer.UnitTests.DataProtection; + +public class DataProtectedIdentityServerKeyMaterialConverterTests +{ + private readonly IDataProtectionProvider dataProtectionProvider = Mock.Of(); + private readonly IDataProtector dataProtector = Mock.Of(); + + private DataProtectedIdentityServerKeyMaterialConverter CreateSut() => new(dataProtectionProvider); + + public DataProtectedIdentityServerKeyMaterialConverterTests() + { + Mock.Get(dataProtectionProvider) + .Setup(x => x.CreateProtector(DataProtectionConstants.KeyProtectorPurpose)) + .Returns(dataProtector); + } + + [Fact] + public void Ctor_ShouldCreateProtectorWithCorrectName() + { + _ = CreateSut(); + + Mock.Get(dataProtectionProvider) + .Verify(x => x.CreateProtector(DataProtectionConstants.KeyProtectorPurpose)); + } + + [Fact] + public void Convert_WhenRsaIdentityServerKeyData_ShouldConvertToSigningKey() + { + RsaIdentityServerKeyData fakeRsaKey = new() + { + Id = "Fake_RS256", + Created = new DateTime(2026, 04, 25, 11, 20, 21, DateTimeKind.Utc), + Algorithm = "RS256", + Parameters = FakeKeyData.RsaSecurityKey256, + }; + IdentityServerKeyMaterial testKeyMaterial = new() + { + Id = fakeRsaKey.Id, Version = 1, Use = "signing", DataProtected = false, Algorithm = fakeRsaKey.Algorithm, + Data = fakeRsaKey.ToExpectedJson() + }; + + DataProtectedIdentityServerKeyMaterialConverter sut = CreateSut(); + SigningKey actual = sut.Convert(testKeyMaterial); + + SigningCredentials expectedCredentials = new(new RsaSecurityKey(fakeRsaKey.Parameters) { KeyId = fakeRsaKey.Id }, fakeRsaKey.Algorithm); + + actual.Id.Should().Be(fakeRsaKey.Id); + actual.Created.Should().Be(fakeRsaKey.Created); + actual.Credentials.Should().BeEquivalentTo(expectedCredentials); + } + + [Fact] + public void Convert_WhenEcIdentityServerKeyData_ShouldConvertToSigningKey() + { + EcIdentityServerKeyData fakeEcKey = new EcIdentityServerKeyData + { + Id = "Fake_ES256", + Created = new DateTime(2026, 02, 25, 11, 20, 21, DateTimeKind.Utc), + Algorithm = "ES256", + D = FakeKeyData.EcDsaSecurityKey256.D, + Q = FakeKeyData.EcDsaSecurityKey256.Q, + }; + IdentityServerKeyMaterial testKeyMaterial = new IdentityServerKeyMaterial + { + Id = fakeEcKey.Id, Version = 1, Use = "signing", DataProtected = false, Algorithm = fakeEcKey.Algorithm, + Data = fakeEcKey.ToExpectedJson() + }; + + DataProtectedIdentityServerKeyMaterialConverter sut = CreateSut(); + SigningKey actual = sut.Convert(testKeyMaterial); + + ECDsa expectedEcDsa = ECDsa.Create(new ECParameters + { + Curve = CryptoHelper.GetCurveFromCrvValue("P-256"), D = fakeEcKey.D, Q = fakeEcKey.Q, + }); + SigningCredentials expectedCredentials = new(new ECDsaSecurityKey(expectedEcDsa) { KeyId = fakeEcKey.Id }, fakeEcKey.Algorithm); + + actual.Id.Should().Be(fakeEcKey.Id); + actual.Created.Should().Be(fakeEcKey.Created); + actual.Credentials.Should().BeEquivalentTo(expectedCredentials); + } + + [Fact] + public void Convert_WhenX509IdentityServerKeyData_ContainingRsaKey_ShouldConvertToSigningKey() + { + RsaSecurityKey fakeRsaSecurityKey = new RsaSecurityKey(FakeKeyData.RsaSecurityKey256) + { + KeyId = "Fake_RS256", + }; + X509IdentityServerKeyData fakeX509CertData = new X509IdentityServerKeyData + { + Id = fakeRsaSecurityKey.KeyId, + Created = new DateTime(2026, 04, 25, 11, 20, 21, DateTimeKind.Utc), + Algorithm = "RS256", + CertificateRawData = fakeRsaSecurityKey.ToBase64Pfx(), + }; + IdentityServerKeyMaterial testKeyMaterial = new IdentityServerKeyMaterial + { + Id = fakeX509CertData.Id, Version = 1, Use = "signing", DataProtected = false, + Algorithm = fakeX509CertData.Algorithm, IsX509Certificate = true, Data = fakeX509CertData.ToExpectedJson(), + }; + + DataProtectedIdentityServerKeyMaterialConverter sut = CreateSut(); + SigningKey actual = sut.Convert(testKeyMaterial); + + var expectedCert = X509CertificateLoader.LoadPkcs12(Convert.FromBase64String(fakeX509CertData.CertificateRawData), null); + SigningCredentials expectedCredentials = new SigningCredentials(new X509SecurityKey(expectedCert) { KeyId = fakeX509CertData.Id }, fakeX509CertData.Algorithm); + + actual.Id.Should().Be(fakeX509CertData.Id); + actual.Created.Should().Be(fakeX509CertData.Created); + actual.Credentials.Should().BeEquivalentTo(expectedCredentials); + } + + [Fact] + public void Convert_WhenX509IdentityServerKeyData_ContainingECDsaKey_ShouldConvertToSigningKey() + { + ECDsaSecurityKey fakeECDsaSecurityKey = new ECDsaSecurityKey(ECDsa.Create(FakeKeyData.EcDsaSecurityKey384)) + { + KeyId = "Fake_ES384", + }; + X509IdentityServerKeyData fakeX509CertData = new X509IdentityServerKeyData + { + Id = fakeECDsaSecurityKey.KeyId, + Created = new DateTime(2026, 04, 25, 11, 20, 21, DateTimeKind.Utc), + Algorithm = "ES384", + CertificateRawData = fakeECDsaSecurityKey.ToBase64Pfx(), + }; + IdentityServerKeyMaterial testKeyMaterial = new IdentityServerKeyMaterial + { + Id = fakeX509CertData.Id, Version = 1, Use = "signing", DataProtected = false, + Algorithm = fakeX509CertData.Algorithm, IsX509Certificate = true, Data = fakeX509CertData.ToExpectedJson(), + }; + + DataProtectedIdentityServerKeyMaterialConverter sut = CreateSut(); + SigningKey actual = sut.Convert(testKeyMaterial); + + var expectedCert = X509CertificateLoader.LoadPkcs12(Convert.FromBase64String(fakeX509CertData.CertificateRawData), null); + SigningCredentials expectedCredentials = new SigningCredentials(new X509SecurityKey(expectedCert) { KeyId = fakeX509CertData.Id }, fakeX509CertData.Algorithm); + + actual.Id.Should().Be(fakeX509CertData.Id); + actual.Created.Should().Be(fakeX509CertData.Created); + actual.Credentials.Should().BeEquivalentTo(expectedCredentials); + } + + [Fact] + public void Convert_WhenDataProtected_ShouldConvertToSigningKey() + { + ECDsaSecurityKey fakeECDsaSecurityKey = new ECDsaSecurityKey(ECDsa.Create(FakeKeyData.EcDsaSecurityKey384)) + { + KeyId = "Fake_ES384", + }; + X509IdentityServerKeyData fakeX509CertData = new X509IdentityServerKeyData + { + Id = fakeECDsaSecurityKey.KeyId, + Created = new DateTime(2026, 04, 25, 11, 20, 21, DateTimeKind.Utc), + Algorithm = "ES384", + CertificateRawData = fakeECDsaSecurityKey.ToBase64Pfx(), + }; + var fakeX509Json = fakeX509CertData.ToExpectedJson(); + var fakeX509PfxProtectedBase64 = Convert.ToBase64String("FakeRsaKeyJson_PROTECTED"u8.ToArray()); + + Mock.Get(dataProtector) + .Setup(x => x.Unprotect(It.Is( + b => Convert.ToBase64String(b) == fakeX509PfxProtectedBase64))) + .Returns(Encoding.UTF8.GetBytes(fakeX509Json)); + + IdentityServerKeyMaterial testKeyMaterial = new IdentityServerKeyMaterial + { + Id = fakeX509CertData.Id, Version = 1, Use = "signing", DataProtected = true, + Algorithm = fakeX509CertData.Algorithm, IsX509Certificate = true, Data = fakeX509PfxProtectedBase64, + }; + + DataProtectedIdentityServerKeyMaterialConverter sut = CreateSut(); + SigningKey actual = sut.Convert(testKeyMaterial); + + var expectedCert = X509CertificateLoader.LoadPkcs12(Convert.FromBase64String(fakeX509CertData.CertificateRawData), null); + SigningCredentials expectedCredentials = new SigningCredentials(new X509SecurityKey(expectedCert) { KeyId = fakeX509CertData.Id }, fakeX509CertData.Algorithm); + + actual.Id.Should().Be(fakeX509CertData.Id); + actual.Created.Should().Be(fakeX509CertData.Created); + actual.Credentials.Should().BeEquivalentTo(expectedCredentials); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/LocalTimeZoneInfoMocker.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/LocalTimeZoneInfoMocker.cs new file mode 100644 index 000000000..b881e3cb9 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/LocalTimeZoneInfoMocker.cs @@ -0,0 +1,21 @@ +using System; +using System.Reflection; + +namespace Open.IdentityServer.UnitTests; + +public class LocalTimeZoneInfoMocker: IDisposable +{ + public LocalTimeZoneInfoMocker(TimeZoneInfo mockTimeZoneInfo) + { + var info = typeof(TimeZoneInfo).GetField("s_cachedData", BindingFlags.NonPublic | BindingFlags.Static); + var cachedData = info?.GetValue(null); + var field = cachedData?.GetType().GetField("_localTimeZone", + BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.Instance); + field?.SetValue(cachedData, mockTimeZoneInfo); + } + + public void Dispose() + { + TimeZoneInfo.ClearCachedData(); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Compatibility/FakeKeyData.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Compatibility/FakeKeyData.cs index 5f88af714..776cac4b7 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Compatibility/FakeKeyData.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Compatibility/FakeKeyData.cs @@ -12,7 +12,7 @@ namespace IdentityServer.UnitTests.Stores.Compatibility; public static class FakeKeyData { - public static RSAParameters RsaSecurityKey256 = new RSAParameters + public static RSAParameters RsaSecurityKey256 = new() { Modulus = Convert.FromBase64String("lhB1UxNfZTjo7y41Oj6YVOqk7Lr4kd4MEWkmEgS6OME+N7DPMlHQM0EUH5NswPKl/6gD8YW6vaMPtynhCO6X/tLnAfQXu0R7vPme8/5YsVhHcCzYYUZUUjCe2hCm0oeSdV+oVB9Q7N0RDbvI64upmKXs6WsmWQjR5gxVsWgtU4hnkBXgRMWSQZPTvAaGthGtT18e0xZPiRI3mi7++JonkgVpvln8lHubP4ov6OKOOmxW7yLaLFRyYJIKJpXl9G26QRV8VpwWDEV6klds2n9YNAuQJFCAD1qoFgk4p+JZm3yAFRHpZlQHL83E4LF2ub7bZwy/pSo948Ie0/S21uYiTw=="), Exponent = Convert.FromBase64String("AQAB"), @@ -24,7 +24,7 @@ public static class FakeKeyData InverseQ = Convert.FromBase64String("Rgz8DKhPbMDzBZJKnqqmNuqP5FEQVy9ixlHx1gjM1QzdXSOBxQM9hJ7xEVVeU72HQaGfONFfs+ywJRTTrGOYYFS0arQC7xSSKnQj84FGm/5M+geme5Libt2Tp2mNxcbUrxJBTHxoLAXvGfjlu7pL5xmYC/GJIOXiN8KoAP8O8i0=") }; - public static ECParameters EcDsaSecurityKey256 = new ECParameters + public static ECParameters EcDsaSecurityKey256 = new() { Curve = ECCurve.NamedCurves.nistP256, Q = new ECPoint @@ -35,7 +35,7 @@ public static class FakeKeyData D = Convert.FromBase64String("6l0Qd9ZoV5gFj7mrKuzDJvLuaCOAoWiuSuWhJMTFuts=") }; - public static ECParameters EcDsaSecurityKey384 = new ECParameters + public static ECParameters EcDsaSecurityKey384 = new() { Curve = ECCurve.NamedCurves.nistP384, Q = new ECPoint @@ -46,7 +46,7 @@ public static class FakeKeyData D = Convert.FromBase64String("FTG9qoWhAOdRtzovZJxq+4ZerL3u1Ji7zF8QNRRcZjpMLT1pLTwsq8ipwhXUjNTE") }; - public static ECParameters EcDsaSecurityKey521 = new ECParameters + public static ECParameters EcDsaSecurityKey521 = new() { Curve = ECCurve.NamedCurves.nistP521, Q = new ECPoint @@ -59,14 +59,20 @@ public static class FakeKeyData public static string ToBase64Pfx(this RsaSecurityKey rsaKey) { - using var rsa = RSA.Create(rsaKey.Parameters); - var request = new CertificateRequest($"CN={rsaKey.KeyId}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + using var rsa = RSA.Create(); + rsa.ImportParameters(rsaKey.Parameters); - using X509Certificate2 cert = request.CreateSelfSigned( + var request = new CertificateRequest( + $"CN={rsaKey.KeyId}", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + using var cert = request.CreateSelfSigned( DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(1)); - return Convert.ToBase64String(cert.Export(X509ContentType.Pfx, null as string)); + return Convert.ToBase64String(cert.Export(X509ContentType.Pfx)); } public static string ToBase64Pfx(this ECDsaSecurityKey ecKey) diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Compatibility/IdentityServerSigningCredentialStoreTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Compatibility/IdentityServerSigningCredentialStoreTests.cs index 302e18a60..f70e48ccb 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Compatibility/IdentityServerSigningCredentialStoreTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Compatibility/IdentityServerSigningCredentialStoreTests.cs @@ -9,28 +9,36 @@ using System.Threading.Tasks; using AwesomeAssertions; using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Time.Testing; using Microsoft.IdentityModel.Tokens; using Moq; using Open.IdentityServer.Configuration; using Open.IdentityServer.DataProtection; using Open.IdentityServer.Models; using Open.IdentityServer.Stores; +using Open.IdentityServer.UnitTests; using Xunit; namespace IdentityServer.UnitTests.Stores.Compatibility; public class IdentityServerSigningCredentialStoreTests { - private readonly IIdentityServerKeyStore _identityServerKeyStore = Mock.Of(); + private readonly IIdentityServerKeyStore identityServerKeyStore = Mock.Of(); private readonly IDataProtectionProvider dataProtectionProvider = Mock.Of(); private readonly IDataProtector dataProtector = Mock.Of(); - private readonly DataProtectedIdentityServerKeyMaterialConverter _dataProtectedIdentityServerKeyMaterialConverter; + private readonly DataProtectedIdentityServerKeyMaterialConverter dataProtectedIdentityServerKeyMaterialConverter; + private readonly FakeTimeProvider timeProvider = new(); + private readonly CompatibilityKeyStoreOptions fakeOptions = new(); + + private readonly DateTime fakeNow = new(2026, 05, 01, 12, 00, 00, DateTimeKind.Utc); private List fakeKeyMaterials = []; public IdentityServerSigningCredentialStoreTests() { - Mock.Get(_identityServerKeyStore) + timeProvider.SetUtcNow(fakeNow); + + Mock.Get(identityServerKeyStore) .Setup(x => x.GetKeys()) .Returns(fakeKeyMaterials); @@ -38,10 +46,43 @@ public IdentityServerSigningCredentialStoreTests() .Setup(x => x.CreateProtector("DataProtectionKeyProtector")) .Returns(dataProtector); - _dataProtectedIdentityServerKeyMaterialConverter = new DataProtectedIdentityServerKeyMaterialConverter(dataProtectionProvider); + dataProtectedIdentityServerKeyMaterialConverter = new DataProtectedIdentityServerKeyMaterialConverter(dataProtectionProvider); } - private IdentityServerSigningCredentialStore CreateSut() => new(_identityServerKeyStore, _dataProtectedIdentityServerKeyMaterialConverter); + private IdentityServerSigningCredentialStore CreateSut() => new(identityServerKeyStore, dataProtectedIdentityServerKeyMaterialConverter, timeProvider, fakeOptions); + + [Fact] + public async Task GetSigningCredentialsAsync_WhenUnspecifiedDateTime_ShouldTreatAsUtcTime() + { + //Ensuring timezone info is the same across environments + using var mockedTimezone = new LocalTimeZoneInfoMocker(TimeZoneInfo.FindSystemTimeZoneById("China Standard Time")); + + var testCreated = fakeNow.AddDays(-90).AddHours(1); + + var fakeRsaKey = new RsaIdentityServerKeyData + { + Id = "Fake_RS256", + Created = new DateTime(testCreated.Year, testCreated.Month, testCreated.Day, testCreated.Hour, testCreated.Minute, testCreated.Second, DateTimeKind.Unspecified), //Subtracts in total 89 days, 23 hours + Algorithm = "RS256", + Parameters = FakeKeyData.RsaSecurityKey256, + }; + + fakeKeyMaterials = [ + new IdentityServerKeyMaterial { Id = fakeRsaKey.Id, Version = 1, Use = "signing", DataProtected = false, Algorithm = fakeRsaKey.Algorithm, Data = fakeRsaKey.ToExpectedJson() }, + ]; + + Mock.Get(identityServerKeyStore) + .Setup(x => x.GetKeys()) + .Returns(fakeKeyMaterials); + + var sut = CreateSut(); + var actual = await sut.GetSigningCredentialsAsync(); + + SigningCredentials expectedSigningCredentials = new SigningCredentials(new RsaSecurityKey(fakeRsaKey.Parameters) { KeyId = fakeRsaKey.Id }, fakeRsaKey.Algorithm); + + actual.Should().BeEquivalentTo(expectedSigningCredentials); + + } [Fact] public async Task GetSigningCredentialsAsync_WhenDataUnprotected_AndLatestRsaKey_ShouldReturnSigningCredentialsRepresenting() @@ -68,7 +109,7 @@ public async Task GetSigningCredentialsAsync_WhenDataUnprotected_AndLatestRsaKey new IdentityServerKeyMaterial { Id = fakeRsaKey.Id, Version = 1, Use = "signing", DataProtected = false, Algorithm = fakeRsaKey.Algorithm, Data = fakeRsaKey.ToExpectedJson() }, ]; - Mock.Get(_identityServerKeyStore) + Mock.Get(identityServerKeyStore) .Setup(x => x.GetKeys()) .Returns(fakeKeyMaterials); @@ -105,7 +146,7 @@ public async Task GetSigningCredentialsAsync_ShouldFilterOutNonSigningKeys() new IdentityServerKeyMaterial { Id = fakeRsaKey.Id, Version = 1, Use = "encryption", DataProtected = false, Algorithm = fakeRsaKey.Algorithm, Data = fakeRsaKey.ToExpectedJson() }, ]; - Mock.Get(_identityServerKeyStore) + Mock.Get(identityServerKeyStore) .Setup(x => x.GetKeys()) .Returns(fakeKeyMaterials); @@ -121,6 +162,41 @@ public async Task GetSigningCredentialsAsync_ShouldFilterOutNonSigningKeys() actual.Should().BeEquivalentTo(expectedSigningCredentials); } + [Fact] + public async Task GetSigningCredentialsAsync_ShouldFilterOutKeysThatExceedMaxLifetime() + { + var fakeRsaKey = new RsaIdentityServerKeyData + { + Id = "Fake_RS256", + Created = fakeNow.AddDays(-130), + Algorithm = "RS256", + Parameters = FakeKeyData.RsaSecurityKey256, + }; + + var fakeEcKey = new EcIdentityServerKeyData + { + Id = "Fake_ES256", + Created = fakeNow.AddDays(-99), + Algorithm = "ES256", + D = FakeKeyData.EcDsaSecurityKey256.D, + Q = FakeKeyData.EcDsaSecurityKey256.Q, + }; + + fakeKeyMaterials = [ + new IdentityServerKeyMaterial { Id = fakeEcKey.Id, Version = 1, Use = "signing", DataProtected = false, Algorithm = fakeEcKey.Algorithm, Data = fakeEcKey.ToExpectedJson() }, + new IdentityServerKeyMaterial { Id = fakeRsaKey.Id, Version = 1, Use = "signing", DataProtected = false, Algorithm = fakeRsaKey.Algorithm, Data = fakeRsaKey.ToExpectedJson() }, + ]; + + Mock.Get(identityServerKeyStore) + .Setup(x => x.GetKeys()) + .Returns(fakeKeyMaterials); + + var sut = CreateSut(); + var actual = await sut.GetSigningCredentialsAsync(); + + actual.Should().BeNull(); + } + [Fact] public async Task GetSigningCredentialsAsync_WhenDataProtected_AndLatestEcdsaKey_ShouldReturnSigningCredentialsRepresenting() { @@ -150,7 +226,7 @@ public async Task GetSigningCredentialsAsync_WhenDataProtected_AndLatestEcdsaKey new IdentityServerKeyMaterial { Id = fakeRsaKey.Id, Version = 1, Use = "signing", DataProtected = true, Algorithm = fakeRsaKey.Algorithm, Data = fakeRsaKeyJsonProtectedBase64 }, ]; - Mock.Get(_identityServerKeyStore) + Mock.Get(identityServerKeyStore) .Setup(x => x.GetKeys()) .Returns(fakeKeyMaterials); @@ -194,7 +270,7 @@ public async Task GetSigningCredentialsAsync_WhenProtected_AndSingleEcdsa384Key_ new IdentityServerKeyMaterial { Id = fakeEcKey.Id, Version = 1, Use = "signing", DataProtected = true, Algorithm = fakeEcKey.Algorithm, Data = fakeEcKeyJsonProtectedBase64 }, ]; - Mock.Get(_identityServerKeyStore) + Mock.Get(identityServerKeyStore) .Setup(x => x.GetKeys()) .Returns(fakeKeyMaterials); @@ -232,7 +308,7 @@ public async Task GetSigningCredentialsAsync_WhenUnProtected_AndSingleEcdsa521Ke new IdentityServerKeyMaterial { Id = fakeEcKey.Id, Version = 1, Use = "signing", DataProtected = false, Algorithm = fakeEcKey.Algorithm, Data = fakeEcKeyJson }, ]; - Mock.Get(_identityServerKeyStore) + Mock.Get(identityServerKeyStore) .Setup(x => x.GetKeys()) .Returns(fakeKeyMaterials); @@ -258,7 +334,7 @@ public async Task GetSigningCredentialsAsync_WhenProtected_AndSingleRsaKeyWrappe var fakeX509CertData = new X509IdentityServerKeyData { Id = fakeRsaSecurityKey.KeyId, - Created = new DateTime(2026, 02, 25, 11, 20, 21, DateTimeKind.Utc), + Created = fakeNow.AddDays(-33), Algorithm = "RS256", CertificateRawData = fakeRsaSecurityKey.ToBase64Pfx(), }; @@ -269,7 +345,7 @@ public async Task GetSigningCredentialsAsync_WhenProtected_AndSingleRsaKeyWrappe new IdentityServerKeyMaterial { Id = fakeX509CertData.Id, Version = 1, Use = "signing", DataProtected = true, Algorithm = fakeX509CertData.Algorithm, IsX509Certificate = true, Data = fakeX509PfxProtectedBase64 }, ]; - Mock.Get(_identityServerKeyStore) + Mock.Get(identityServerKeyStore) .Setup(x => x.GetKeys()) .Returns(fakeKeyMaterials); @@ -282,7 +358,7 @@ public async Task GetSigningCredentialsAsync_WhenProtected_AndSingleRsaKeyWrappe var actual = await sut.GetSigningCredentialsAsync(); var expectedCert = X509CertificateLoader.LoadPkcs12(Convert.FromBase64String(fakeX509CertData.CertificateRawData), null); - SigningCredentials expectedSigningCredentials = new SigningCredentials(new X509SecurityKey(expectedCert), fakeX509CertData.Algorithm); + SigningCredentials expectedSigningCredentials = new SigningCredentials(new X509SecurityKey(expectedCert) { KeyId = fakeX509CertData.Id }, fakeX509CertData.Algorithm); actual.Should().BeEquivalentTo(expectedSigningCredentials); } @@ -292,13 +368,13 @@ public async Task GetSigningCredentialsAsync_WhenProtected_AndSingleECDsaKeyWrap { var fakeECDsaSecurityKey = new ECDsaSecurityKey(ECDsa.Create(FakeKeyData.EcDsaSecurityKey384)) { - KeyId = "Fake_EC384", + KeyId = "Fake_ES384", }; var fakeX509CertData = new X509IdentityServerKeyData { Id = fakeECDsaSecurityKey.KeyId, - Created = new DateTime(2026, 02, 25, 11, 20, 21, DateTimeKind.Utc), - Algorithm = "EC384", + Created = fakeNow.AddDays(-33), + Algorithm = "ES384", CertificateRawData = fakeECDsaSecurityKey.ToBase64Pfx(), }; var fakeX509Json = fakeX509CertData.ToExpectedJson(); @@ -308,7 +384,7 @@ public async Task GetSigningCredentialsAsync_WhenProtected_AndSingleECDsaKeyWrap new IdentityServerKeyMaterial { Id = fakeX509CertData.Id, Version = 1, Use = "signing", DataProtected = true, Algorithm = fakeX509CertData.Algorithm, IsX509Certificate = true, Data = fakeX509PfxProtectedBase64 }, ]; - Mock.Get(_identityServerKeyStore) + Mock.Get(identityServerKeyStore) .Setup(x => x.GetKeys()) .Returns(fakeKeyMaterials); @@ -321,7 +397,7 @@ public async Task GetSigningCredentialsAsync_WhenProtected_AndSingleECDsaKeyWrap var actual = await sut.GetSigningCredentialsAsync(); var expectedCert = X509CertificateLoader.LoadPkcs12(Convert.FromBase64String(fakeX509CertData.CertificateRawData), null); - SigningCredentials expectedSigningCredentials = new SigningCredentials(new X509SecurityKey(expectedCert), fakeX509CertData.Algorithm); + SigningCredentials expectedSigningCredentials = new SigningCredentials(new X509SecurityKey(expectedCert) { KeyId = fakeX509CertData.Id }, fakeX509CertData.Algorithm); actual.Should().BeEquivalentTo(expectedSigningCredentials); } @@ -329,7 +405,7 @@ public async Task GetSigningCredentialsAsync_WhenProtected_AndSingleECDsaKeyWrap [Fact] public async Task GetSigningCredentialsAsync_WhenTableIsEmpty_ShouldReturnNull() { - Mock.Get(_identityServerKeyStore) + Mock.Get(identityServerKeyStore) .Setup(x => x.GetKeys()) .Returns([]); diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Compatibility/IdentityServerValidationKeysStoreTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Compatibility/IdentityServerValidationKeysStoreTests.cs index 6f0ae28c5..c5238047c 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Compatibility/IdentityServerValidationKeysStoreTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Compatibility/IdentityServerValidationKeysStoreTests.cs @@ -7,30 +7,38 @@ using System.Threading.Tasks; using AwesomeAssertions; using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Time.Testing; using Microsoft.IdentityModel.Tokens; using Moq; using Open.IdentityServer.Configuration; using Open.IdentityServer.DataProtection; using Open.IdentityServer.Models; using Open.IdentityServer.Stores; +using Open.IdentityServer.UnitTests; using Xunit; namespace IdentityServer.UnitTests.Stores.Compatibility; public class IdentityServerValidationKeysStoreTests { - private readonly IIdentityServerKeyStore _identityServerKeyStore = Mock.Of(); + private readonly IIdentityServerKeyStore identityServerKeyStore = Mock.Of(); private readonly IDataProtectionProvider dataProtectionProvider = Mock.Of(); private IDataProtector dataProtector = Mock.Of(); - private readonly DataProtectedIdentityServerKeyMaterialConverter _dataProtectedIdentityServerKeyMaterialConverter; + private readonly DataProtectedIdentityServerKeyMaterialConverter dataProtectedIdentityServerKeyMaterialConverter; + private readonly FakeTimeProvider timeProvider = new(); + private readonly CompatibilityKeyStoreOptions fakeOptions = new(); + + private readonly DateTime fakeNow = new(2026, 05, 01, 12, 00, 00, DateTimeKind.Utc); private List fakeKeyMaterials = []; public IdentityServerValidationKeysStoreTests() { - _dataProtectedIdentityServerKeyMaterialConverter = new DataProtectedIdentityServerKeyMaterialConverter(dataProtectionProvider); + timeProvider.SetUtcNow(fakeNow); + + dataProtectedIdentityServerKeyMaterialConverter = new DataProtectedIdentityServerKeyMaterialConverter(dataProtectionProvider); - Mock.Get(_identityServerKeyStore) + Mock.Get(identityServerKeyStore) .Setup(x => x.GetKeys()) .Returns(fakeKeyMaterials); @@ -39,7 +47,84 @@ public IdentityServerValidationKeysStoreTests() .Returns(dataProtector); } - private IdentityServerValidationKeysStore CreateSut() => new(_identityServerKeyStore, _dataProtectedIdentityServerKeyMaterialConverter); + private IdentityServerValidationKeysStore CreateSut() => new(identityServerKeyStore, dataProtectedIdentityServerKeyMaterialConverter, timeProvider, fakeOptions); + + [Fact] + public async Task GetValidationKeysAsync_WhenUnspecifiedDateTime_ShouldTreatAsUtcTime() + { + //Ensuring timezone info is the same across environments + using var mockedTimezone = new LocalTimeZoneInfoMocker(TimeZoneInfo.FindSystemTimeZoneById("China Standard Time")); + + var fakeRsaKey0Created = fakeNow.AddDays(-90).AddHours(1); + var fakeRsaKey0 = new RsaIdentityServerKeyData + { + Id = "Fake_RS256_0", + Created = new DateTime(fakeRsaKey0Created.Year, fakeRsaKey0Created.Month, fakeRsaKey0Created.Day, fakeRsaKey0Created.Hour, fakeRsaKey0Created.Minute, fakeRsaKey0Created.Second, DateTimeKind.Unspecified), //Subtracts in total 89 days, 23 hours + Algorithm = "RS256", + Parameters = FakeKeyData.RsaSecurityKey256, + }; + + var fakeRsaKey1 = new RsaIdentityServerKeyData + { + Id = "Fake_RS256_1", + Created = fakeNow.AddDays(-10), + Algorithm = "RS256", + Parameters = FakeKeyData.RsaSecurityKey256, + }; + + var fakeEcKey0Created = fakeNow.AddDays(-90).AddHours(2); + var fakeEcKey0 = new EcIdentityServerKeyData + { + Id = "Fake_ES384_0", + Created = new DateTime(fakeEcKey0Created.Year, fakeEcKey0Created.Month, fakeEcKey0Created.Day, fakeEcKey0Created.Hour, fakeEcKey0Created.Minute, fakeEcKey0Created.Second, DateTimeKind.Unspecified), //Subtracts in total 89 days, 22 hours + Algorithm = "ES384", + D = FakeKeyData.EcDsaSecurityKey384.D, + Q = FakeKeyData.EcDsaSecurityKey384.Q, + }; + + var fakeEcKey1 = new EcIdentityServerKeyData + { + Id = "Fake_ES521_0", + Created = fakeNow.AddDays(-45), + Algorithm = "ES521", + D = FakeKeyData.EcDsaSecurityKey521.D, + Q = FakeKeyData.EcDsaSecurityKey521.Q, + }; + + fakeKeyMaterials = [ + new IdentityServerKeyMaterial { Id = fakeRsaKey0.Id, Version = 1, Use = "signing", DataProtected = false, Algorithm = fakeRsaKey0.Algorithm, Data = fakeRsaKey0.ToExpectedJson() }, + new IdentityServerKeyMaterial { Id = fakeRsaKey1.Id, Version = 1, Use = "signing", DataProtected = false, Algorithm = fakeRsaKey1.Algorithm, Data = fakeRsaKey1.ToExpectedJson() }, + new IdentityServerKeyMaterial { Id = fakeEcKey0.Id, Version = 1, Use = "signing", DataProtected = false, Algorithm = fakeEcKey0.Algorithm, Data = fakeEcKey0.ToExpectedJson() }, + new IdentityServerKeyMaterial { Id = fakeEcKey1.Id, Version = 1, Use = "signing", DataProtected = false, Algorithm = fakeEcKey1.Algorithm, Data = fakeEcKey1.ToExpectedJson() }, + ]; + + Mock.Get(identityServerKeyStore) + .Setup(x => x.GetKeys()) + .Returns(fakeKeyMaterials); + + var sut = CreateSut(); + var actual = await sut.GetValidationKeysAsync(); + + + ECDsa expectedEcDsa0 = ECDsa.Create(new ECParameters + { + Curve = CryptoHelper.GetCurveFromCrvValue("P-384"), D = fakeEcKey0.D, Q = fakeEcKey0.Q, + }); + + ECDsa expectedEcDsa1 = ECDsa.Create(new ECParameters + { + Curve = CryptoHelper.GetCurveFromCrvValue("P-521"), D = fakeEcKey1.D, Q = fakeEcKey1.Q, + }); + + IEnumerable expectedCredentials = [ + new() { Key = new RsaSecurityKey(fakeRsaKey0.Parameters) { KeyId = fakeRsaKey0.Id }, SigningAlgorithm = fakeRsaKey0.Algorithm }, + new() { Key = new RsaSecurityKey(fakeRsaKey1.Parameters) { KeyId = fakeRsaKey1.Id }, SigningAlgorithm = fakeRsaKey1.Algorithm }, + new() { Key = new ECDsaSecurityKey(expectedEcDsa0) { KeyId = fakeEcKey0.Id }, SigningAlgorithm = fakeEcKey0.Algorithm }, + new() { Key = new ECDsaSecurityKey(expectedEcDsa1) { KeyId = fakeEcKey1.Id }, SigningAlgorithm = fakeEcKey1.Algorithm }, + ]; + + actual.Should().BeEquivalentTo(expectedCredentials); + } [Fact] public async Task GetValidationKeysAsync_ShouldReturnCollectionOfSecurityKeyInfo() @@ -47,7 +132,7 @@ public async Task GetValidationKeysAsync_ShouldReturnCollectionOfSecurityKeyInfo var fakeRsaKey0 = new RsaIdentityServerKeyData { Id = "Fake_RS256_0", - Created = new DateTime(2026, 04, 25, 11, 20, 21, DateTimeKind.Utc), + Created = fakeNow.AddDays(-20), Algorithm = "RS256", Parameters = FakeKeyData.RsaSecurityKey256, }; @@ -55,7 +140,7 @@ public async Task GetValidationKeysAsync_ShouldReturnCollectionOfSecurityKeyInfo var fakeRsaKey1 = new RsaIdentityServerKeyData { Id = "Fake_RS256_1", - Created = new DateTime(2026, 03, 05, 11, 20, 21, DateTimeKind.Utc), + Created = fakeNow.AddDays(-10), Algorithm = "RS256", Parameters = FakeKeyData.RsaSecurityKey256, }; @@ -63,7 +148,7 @@ public async Task GetValidationKeysAsync_ShouldReturnCollectionOfSecurityKeyInfo var fakeEcKey0 = new EcIdentityServerKeyData { Id = "Fake_ES384_0", - Created = new DateTime(2026, 02, 19, 11, 20, 21, DateTimeKind.Utc), + Created = fakeNow.AddDays(-30), Algorithm = "ES384", D = FakeKeyData.EcDsaSecurityKey384.D, Q = FakeKeyData.EcDsaSecurityKey384.Q, @@ -72,7 +157,7 @@ public async Task GetValidationKeysAsync_ShouldReturnCollectionOfSecurityKeyInfo var fakeEcKey1 = new EcIdentityServerKeyData { Id = "Fake_ES521_0", - Created = new DateTime(2026, 01, 15, 11, 20, 21, DateTimeKind.Utc), + Created = fakeNow.AddDays(-45), Algorithm = "ES521", D = FakeKeyData.EcDsaSecurityKey521.D, Q = FakeKeyData.EcDsaSecurityKey521.Q, @@ -85,7 +170,7 @@ public async Task GetValidationKeysAsync_ShouldReturnCollectionOfSecurityKeyInfo new IdentityServerKeyMaterial { Id = fakeEcKey1.Id, Version = 1, Use = "signing", DataProtected = false, Algorithm = fakeEcKey1.Algorithm, Data = fakeEcKey1.ToExpectedJson() }, ]; - Mock.Get(_identityServerKeyStore) + Mock.Get(identityServerKeyStore) .Setup(x => x.GetKeys()) .Returns(fakeKeyMaterials); @@ -119,7 +204,7 @@ public async Task GetValidationKeysAsync_ShouldReturnCollectionSecurityKeyInfo_A var fakeRsaKey0 = new RsaIdentityServerKeyData { Id = "Fake_RS256_0", - Created = new DateTime(2026, 04, 25, 11, 20, 21, DateTimeKind.Utc), + Created = fakeNow.AddDays(-10), Algorithm = "RS256", Parameters = FakeKeyData.RsaSecurityKey256, }; @@ -127,7 +212,7 @@ public async Task GetValidationKeysAsync_ShouldReturnCollectionSecurityKeyInfo_A var fakeRsaKey1 = new RsaIdentityServerKeyData { Id = "Fake_RS256_1", - Created = new DateTime(2026, 03, 05, 11, 20, 21, DateTimeKind.Utc), + Created = fakeNow.AddDays(-30), Algorithm = "RS256", Parameters = FakeKeyData.RsaSecurityKey256, }; @@ -135,7 +220,7 @@ public async Task GetValidationKeysAsync_ShouldReturnCollectionSecurityKeyInfo_A var fakeEcKey0 = new EcIdentityServerKeyData { Id = "Fake_ES256_0", - Created = new DateTime(2026, 02, 19, 11, 20, 21, DateTimeKind.Utc), + Created = fakeNow.AddHours(-12), Algorithm = "ES256", D = FakeKeyData.EcDsaSecurityKey256.D, Q = FakeKeyData.EcDsaSecurityKey256.Q, @@ -144,7 +229,7 @@ public async Task GetValidationKeysAsync_ShouldReturnCollectionSecurityKeyInfo_A var fakeEcKey1 = new EcIdentityServerKeyData { Id = "Fake_ES521_0", - Created = new DateTime(2026, 01, 15, 11, 20, 21, DateTimeKind.Utc), + Created = fakeNow.AddDays(-20), Algorithm = "ES521", D = FakeKeyData.EcDsaSecurityKey521.D, Q = FakeKeyData.EcDsaSecurityKey521.Q, @@ -157,7 +242,72 @@ public async Task GetValidationKeysAsync_ShouldReturnCollectionSecurityKeyInfo_A new IdentityServerKeyMaterial { Id = fakeEcKey1.Id, Version = 1, Use = "signing", DataProtected = false, Algorithm = fakeEcKey1.Algorithm, Data = fakeEcKey1.ToExpectedJson() }, ]; - Mock.Get(_identityServerKeyStore) + Mock.Get(identityServerKeyStore) + .Setup(x => x.GetKeys()) + .Returns(fakeKeyMaterials); + + var sut = CreateSut(); + var actual = await sut.GetValidationKeysAsync(); + + ECDsa expectedEcDsa1 = ECDsa.Create(new ECParameters + { + Curve = CryptoHelper.GetCurveFromCrvValue("P-521"), D = fakeEcKey1.D, Q = fakeEcKey1.Q, + }); + + IEnumerable expectedCredentials = [ + new() { Key = new RsaSecurityKey(fakeRsaKey0.Parameters) { KeyId = fakeRsaKey0.Id }, SigningAlgorithm = fakeRsaKey0.Algorithm }, + new() { Key = new RsaSecurityKey(fakeRsaKey1.Parameters) { KeyId = fakeRsaKey1.Id }, SigningAlgorithm = fakeRsaKey1.Algorithm }, + new() { Key = new ECDsaSecurityKey(expectedEcDsa1) { KeyId = fakeEcKey1.Id }, SigningAlgorithm = fakeEcKey1.Algorithm }, + ]; + + actual.Should().BeEquivalentTo(expectedCredentials); + } + + [Fact] + public async Task GetValidationKeysAsync_ShouldReturnCollectionSecurityKeyInfo_AndFilterOutKeysThatExceedMaxLifetime() + { + var fakeRsaKey0 = new RsaIdentityServerKeyData + { + Id = "Fake_RS256_0", + Created = fakeNow.AddDays(-30), + Algorithm = "RS256", + Parameters = FakeKeyData.RsaSecurityKey256, + }; + + var fakeRsaKey1 = new RsaIdentityServerKeyData + { + Id = "Fake_RS256_1", + Created = fakeNow.AddHours(-12), + Algorithm = "RS256", + Parameters = FakeKeyData.RsaSecurityKey256, + }; + + var fakeEcKey0 = new EcIdentityServerKeyData + { + Id = "Fake_ES256_0", + Created = fakeNow.AddDays(-100), + Algorithm = "ES256", + D = FakeKeyData.EcDsaSecurityKey256.D, + Q = FakeKeyData.EcDsaSecurityKey256.Q, + }; + + var fakeEcKey1 = new EcIdentityServerKeyData + { + Id = "Fake_ES521_0", + Created = fakeNow.AddDays(-10), + Algorithm = "ES521", + D = FakeKeyData.EcDsaSecurityKey521.D, + Q = FakeKeyData.EcDsaSecurityKey521.Q, + }; + + fakeKeyMaterials = [ + new IdentityServerKeyMaterial { Id = fakeRsaKey0.Id, Version = 1, Use = "signing", DataProtected = false, Algorithm = fakeRsaKey0.Algorithm, Data = fakeRsaKey0.ToExpectedJson() }, + new IdentityServerKeyMaterial { Id = fakeRsaKey1.Id, Version = 1, Use = "signing", DataProtected = false, Algorithm = fakeRsaKey1.Algorithm, Data = fakeRsaKey1.ToExpectedJson() }, + new IdentityServerKeyMaterial { Id = fakeEcKey0.Id, Version = 1, Use = "signing", DataProtected = false, Algorithm = fakeEcKey0.Algorithm, Data = fakeEcKey0.ToExpectedJson() }, + new IdentityServerKeyMaterial { Id = fakeEcKey1.Id, Version = 1, Use = "signing", DataProtected = false, Algorithm = fakeEcKey1.Algorithm, Data = fakeEcKey1.ToExpectedJson() }, + ]; + + Mock.Get(identityServerKeyStore) .Setup(x => x.GetKeys()) .Returns(fakeKeyMaterials); @@ -181,7 +331,7 @@ public async Task GetValidationKeysAsync_ShouldReturnCollectionSecurityKeyInfo_A [Fact] public async Task GetValidationKeysAsync_WhenTableIsEmpty_ShouldReturnEmptyList() { - Mock.Get(_identityServerKeyStore) + Mock.Get(identityServerKeyStore) .Setup(x => x.GetKeys()) .Returns([]);