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([]);