diff --git a/docs/topics/compatibility.rst b/docs/topics/compatibility.rst index 64167204f..b80b4a9ac 100644 --- a/docs/topics/compatibility.rst +++ b/docs/topics/compatibility.rst @@ -11,8 +11,12 @@ these features in Open.IdentityServer. Keys Compatibility Store ^^^^^^^^^^^^^^^^^^^^^^^^ -You will be able to configure read-only access to the ``Keys`` table in Open.IdentityServer. This will take the latest -signing key that exists and use it for signing, and all signing keys in the table will be used for validation. We have added the extension method ``IIdentityServerBuilder.AddCompatibilityKeyStores`` which will register these key stores in the DI container for you. +You will be able to configure read-only access to the ``Keys`` table in Open.IdentityServer. This will register the active +key for each configured algorithm when found in the database. An active key is one that was created more than the +``CompatibilityKeyStoreOptions.KeyPropagationTime``, if all keys are younger than the propagation time setting, then the +oldest of these keys will be registered. And all non-expired signing keys in the table will be used for validation. We +have added the extension method ``IIdentityServerBuilder.AddCompatibilityKeyStores`` which will register these key +stores in the DI container for you. .. code-block:: csharp @@ -22,7 +26,12 @@ signing key that exists and use it for signing, and all signing keys in the tabl }) .AddCompatibilityKeyStores(); -.. note:: - By default keys will expire after 90 days. You can configure this behavior in the ``CompatabilityKeyStoreOptions``. - - If you need automatic key rotation, see our KeyRotation component on `identityserver.com products page `_. +.. note:: + The default propagation time will be respected if possible, and only ``RS256`` keys will be loaded for signing, + and keys will expire (not used for signing or validation) after 90 days. + + You can configure this behaviour in the ``CompatibilityKeyStoreOptions``. + + This store is intended only for transitioning to Open.IdentityServer, with more robust key management, should be + set up before keys reach expiry. If you need automatic key rotation, see our KeyRotation component on + `identityserver.com products page `_. diff --git a/src/Open.IdentityServer/src/Configuration/CryptoHelper.cs b/src/Open.IdentityServer/src/Configuration/CryptoHelper.cs index d1472a399..6d54f48a8 100644 --- a/src/Open.IdentityServer/src/Configuration/CryptoHelper.cs +++ b/src/Open.IdentityServer/src/Configuration/CryptoHelper.cs @@ -129,7 +129,7 @@ public static string GetCurveNameForAlgorithm(this string algorithm) { "ES256" => "P-256", "ES384" => "P-384", - "ES521" => "P-521", + "ES512" => "P-521", _ => throw new NotSupportedException($"Unexpected algorithm value for EC Curve: {algorithm}") }; } diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Crypto.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Crypto.cs index 3bf218d27..ed91f74f7 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Crypto.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Crypto.cs @@ -315,7 +315,18 @@ public static IIdentityServerBuilder AddCompatibilityKeyStores(this IIdentitySer builder.Services.AddSingleton(options); builder.Services.AddTransient(); builder.Services.AddScoped(); - builder.Services.AddScoped(); + + builder.Services.AddSingleton(); + + foreach (var supportedAlg in options.SupportedAlgorithms) + { + builder.Services.AddScoped(sp => + { + IIdentityServerSigningCredentialStoreFactory factory = sp.GetRequiredService(); + + return factory.Create(supportedAlg); + }); + } return builder; } diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/CompatibilityKeyStoreOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/CompatibilityKeyStoreOptions.cs index 9bbfeb23c..d46b77b3d 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/CompatibilityKeyStoreOptions.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/CompatibilityKeyStoreOptions.cs @@ -2,6 +2,8 @@ // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. using System; +using System.Collections.Generic; +using Microsoft.IdentityModel.Tokens; namespace Open.IdentityServer.Configuration; @@ -11,7 +13,27 @@ namespace Open.IdentityServer.Configuration; public class CompatibilityKeyStoreOptions { /// - /// Maximum lifetime for keys read from the key store, defaults to 90 days. + /// Maximum lifetime for keys read from the key store /// + /// defaults to 90 days public TimeSpan MaxLifetime { get; set; } = TimeSpan.FromDays(90); + + /// + /// Gets or sets the ordered list of supported signing algorithms. Should be the same list as configured when using + /// duende key management. Should be ordered by preference. + /// + /// + /// Supported values are RS256, RS384, RS512, PS256, + /// PS384, PS512, ES256, ES384, and ES512. + /// + /// defaults to [RS256] + public ICollection SupportedAlgorithms { get; set; } = [SecurityAlgorithms.RsaSha256]; + + /// + /// Key propagation time, if a valid key exists older than this timespan, it will be used. If none are older than + /// timespan, the oldest key will be used + /// else newest key + /// + /// defaults to 14 days + public TimeSpan KeyPropagationTime { get; set; } = TimeSpan.FromDays(14); } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Stores/Compatibility/IIdentityServerSigningCredentialStoreFactory.cs b/src/Open.IdentityServer/src/Stores/Compatibility/IIdentityServerSigningCredentialStoreFactory.cs new file mode 100644 index 000000000..894c0948d --- /dev/null +++ b/src/Open.IdentityServer/src/Stores/Compatibility/IIdentityServerSigningCredentialStoreFactory.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. + +namespace Open.IdentityServer.Stores; + +/// +/// Interface for the creating signing credential store +/// +public interface IIdentityServerSigningCredentialStoreFactory +{ + /// + /// Creates instance of setting algorithm setting to provided alg property + /// + /// algorithm of signing key to provide + /// + public IdentityServerSigningCredentialStore Create(string alg); +} \ 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 193276a01..b4931f348 100644 --- a/src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerSigningCredentialStore.cs +++ b/src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerSigningCredentialStore.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using System.Threading.Tasks; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.Extensions.Logging; using Microsoft.IdentityModel.Tokens; using Open.IdentityServer.Configuration; @@ -27,11 +28,16 @@ public class IdentityServerSigningCredentialStore( TimeProvider timeProvider, CompatibilityKeyStoreOptions options, ITelemetryService telemetry, - ILogger logger - ): - IdentityServerSigningKeyStore(identityServerKeyStore, dataProtectedIdentityServerKeyMaterialConverter, timeProvider, options), + ILogger logger) : + IdentityServerSigningKeyStore(identityServerKeyStore, dataProtectedIdentityServerKeyMaterialConverter, timeProvider, + options), ISigningCredentialStore { + /// + /// Specifies signing algorthm of signing key to retrieve, defaults to RS256 + /// + public string Algorithm { get; init; } = SecurityAlgorithms.RsaSha256; + /// /// Gets the key to be used for signing from the key store, will be the newest key /// @@ -40,15 +46,25 @@ public async Task GetSigningCredentialsAsync() { using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); - var credentials = GetKeys() - .Select(x => x.Credentials) - .FirstOrDefault(); + var query = GetKeys() + .Where(x => x.Credentials.Algorithm == Algorithm) + .ToList(); + + var credPublished = + query.FirstOrDefault(x => x.Created.Add(options.KeyPropagationTime) < timeProvider.GetUtcNow()); + + if (credPublished != null) + { + return credPublished.Credentials; + } + + var credentials = query.LastOrDefault(); if (credentials == null) { - logger.LogWarning("No non-expired signing credentials found in the key store."); + logger.LogWarning("No non-expired signing credentials found in the key store"); } - - return credentials; + + return credentials?.Credentials; } } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerSigningCredentialStoreFactory.cs b/src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerSigningCredentialStoreFactory.cs new file mode 100644 index 000000000..a9b00a0f6 --- /dev/null +++ b/src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerSigningCredentialStoreFactory.cs @@ -0,0 +1,36 @@ +// 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 Microsoft.Extensions.Logging; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.DataProtection; +using Open.IdentityServer.Services; + +namespace Open.IdentityServer.Stores; + +/// +/// Factory for the creating signing credential 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 +/// The telemetry service +/// The logger +public class IdentityServerSigningCredentialStoreFactory(IIdentityServerKeyStore identityServerKeyStore, + DataProtectedIdentityServerKeyMaterialConverter dataProtectedIdentityServerKeyMaterialConverter, + TimeProvider timeProvider, + CompatibilityKeyStoreOptions options, + ITelemetryService telemetry, + ILogger logger): IIdentityServerSigningCredentialStoreFactory +{ + /// + public IdentityServerSigningCredentialStore Create(string alg) + { + return new IdentityServerSigningCredentialStore(identityServerKeyStore, dataProtectedIdentityServerKeyMaterialConverter, timeProvider, options, telemetry, logger) + { + Algorithm = alg + }; + } +} \ No newline at end of file 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 d75543404..445a8ca22 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 @@ -20,7 +20,7 @@ public IEnumerable GetKeys() new IdentityServerKeyMaterial { Id = "FakeRSA_PS256_NONSIGN", Version = 1, Use = "other", DataProtected = false, Algorithm = "PS256", Data = FakePS256KeyData }, 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_ES512", Version = 1, Use = "signing", DataProtected = false, Algorithm = "ES512", Data = FakeES512KeyData }, new IdentityServerKeyMaterial { Id = "FakeRSA_PS512_Expired", Version = 1, Use = "signing", DataProtected = false, Algorithm = "PS512", Data = FakePS512KeyData }, ]; } @@ -91,15 +91,15 @@ public IEnumerable GetKeys() } """; - private const string FakeES521KeyData = """ + private const string FakeES512KeyData = """ { "D": "AJ7z4XAnvCP2Ea+Xm2Q8ZW7/10caVvPk+uzVw//4WhNSEUN0uNmvPq6/bUswvlBLDfLMgoH9HaI9l+I/De7OKJnj", "Q": { "X": "AJR23zqtji/WXqM+WKcr9IUBA2nErWkXQoahE8P3MejJfNX4ja5bR3fLx23OYF5vU/bvAmMZuhcl16o8+0+GuGpi", "Y": "AIiRICSC2ywpL4tr+7CaWp4nkaIzIJNA2Lt0ip2eFj4grn/qBhBdyy2v6wgnDXqOVELh33gNfoqI9FKxqN+sYUOQ" }, - "Id": "FakeEC_ES521", - "Algorithm": "ES521", + "Id": "FakeEC_ES512", + "Algorithm": "ES512", "HasX509Certificate": false, "Created": "2026-01-01T12:00:00" } 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 665eaa231..871eb419f 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 @@ -96,7 +96,7 @@ public async Task Jwks_entries_should_contain_alg() var alg = key.GetProperty("alg"); alg.Should().NotBeNull(); - alg.GetString().Should().Be(Constants.SigningAlgorithms.RSA_SHA_256); + alg.GetString().Should().Be(SecurityAlgorithms.RsaSha256); } [Theory] @@ -179,14 +179,14 @@ public async Task Jwks_WhenCompatibilityStoreConfigured_ShouldGetKeysFromStore() var jwks = new JsonWebKeySet(json); //RSA Keys - jwks.Keys.Should().Contain(x => x.KeyId == "FakeRSA_RS256" && x.Alg == "RS256"); + jwks.Keys.Should().Contain(x => x.KeyId == "FakeRSA_RS256" && x.Alg == SecurityAlgorithms.RsaSha256); //EC Curve Keys - jwks.Keys.Should().Contain(x => x.KeyId == "FakeEC_ES256" && x.Alg == "ES256"); - jwks.Keys.Should().Contain(x => x.KeyId == "FakeEC_ES384" && x.Alg == "ES384"); - jwks.Keys.Should().Contain(x => x.KeyId == "FakeEC_ES521" && x.Alg == "ES521"); + jwks.Keys.Should().Contain(x => x.KeyId == "FakeEC_ES256" && x.Alg == SecurityAlgorithms.EcdsaSha256); + jwks.Keys.Should().Contain(x => x.KeyId == "FakeEC_ES384" && x.Alg == SecurityAlgorithms.EcdsaSha384); + jwks.Keys.Should().Contain(x => x.KeyId == "FakeEC_ES512" && x.Alg == SecurityAlgorithms.EcdsaSha512); //Non Signing Keys - jwks.Keys.Should().NotContain(x => x.KeyId == "FakeRSA_PS256_NONSIGN" && x.Alg == "RS256"); + jwks.Keys.Should().NotContain(x => x.KeyId == "FakeRSA_PS256_NONSIGN" && x.Alg == SecurityAlgorithms.RsaSha256); } } \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/CryptoHelperTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/CryptoHelperTests.cs index 16389e436..eaf4e47e4 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/CryptoHelperTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/CryptoHelperTests.cs @@ -56,7 +56,7 @@ public void IsEcAlgorithm_ShouldReturnFalseForNonEcAlgorithms(string algorithm) [Theory] [InlineData("ES256", "P-256")] [InlineData("ES384", "P-384")] - [InlineData("ES521", "P-521")] + [InlineData("ES512", "P-521")] public void GetCurveNameForAlgorithm_ShouldReturnCorrectCurveNameForEcAlgorithms(string algorithm, string expectedCurveName) { algorithm.GetCurveNameForAlgorithm().Should().Be(expectedCurveName); diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/DependencyInjection/BuilderExtensions/CryptoTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/DependencyInjection/BuilderExtensions/CryptoTests.cs new file mode 100644 index 000000000..06d253d04 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/DependencyInjection/BuilderExtensions/CryptoTests.cs @@ -0,0 +1,82 @@ +// 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.Linq; +using AwesomeAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.DataProtection; +using Open.IdentityServer.Stores; +using Xunit; + +namespace Open.IdentityServer.UnitTests.Configuration.DependencyInjection; + +public class CryptoTests +{ + private IServiceCollection serviceCollection = new ServiceCollection(); + + [Fact] + public void AddCompatibilityKeyStores_WhenDefaultValues_ShouldRegisterCompatibilityStoreServices() + { + IIdentityServerBuilder builder = new IdentityServerBuilder(serviceCollection); + + builder.AddCompatibilityKeyStores(); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(CompatibilityKeyStoreOptions) && + d.Lifetime == ServiceLifetime.Singleton); + + serviceCollection.Should().ContainSingle(d => + d.ImplementationType == typeof(DataProtectedIdentityServerKeyMaterialConverter) && + d.Lifetime == ServiceLifetime.Transient); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(IValidationKeysStore) && + d.ImplementationType == typeof(IdentityServerValidationKeysStore) && + d.Lifetime == ServiceLifetime.Scoped); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(IIdentityServerSigningCredentialStoreFactory) && + d.ImplementationType == typeof(IdentityServerSigningCredentialStoreFactory) && + d.Lifetime == ServiceLifetime.Singleton); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(ISigningCredentialStore) && + d.Lifetime == ServiceLifetime.Scoped); + } + + [Fact] + public void AddCompatibilityKeyStores_WhenMultipleSupportedAlgorithms_ShouldRegisterISigningCredentialStoreForEachAlgorithm() + { + IIdentityServerBuilder builder = new IdentityServerBuilder(serviceCollection); + + builder.AddCompatibilityKeyStores(cnf => + { + cnf.SupportedAlgorithms = + [SecurityAlgorithms.RsaSha256, SecurityAlgorithms.RsaSsaPssSha384, SecurityAlgorithms.EcdsaSha512]; + }); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(CompatibilityKeyStoreOptions) && + d.Lifetime == ServiceLifetime.Singleton); + + serviceCollection.Should().ContainSingle(d => + d.ImplementationType == typeof(DataProtectedIdentityServerKeyMaterialConverter) && + d.Lifetime == ServiceLifetime.Transient); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(IValidationKeysStore) && + d.ImplementationType == typeof(IdentityServerValidationKeysStore) && + d.Lifetime == ServiceLifetime.Scoped); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(IIdentityServerSigningCredentialStoreFactory) && + d.ImplementationType == typeof(IdentityServerSigningCredentialStoreFactory) && + d.Lifetime == ServiceLifetime.Singleton); + + serviceCollection.Where(d => + d.ServiceType == typeof(ISigningCredentialStore) && + d.Lifetime == ServiceLifetime.Scoped).Should().HaveCount(3); + } +} \ No newline at end of file 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 a8b17cd0a..df8bd4436 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 @@ -52,13 +52,16 @@ public IdentityServerSigningCredentialStoreTests() dataProtectedIdentityServerKeyMaterialConverter = new DataProtectedIdentityServerKeyMaterialConverter(dataProtectionProvider); } - private IdentityServerSigningCredentialStore CreateSut() => new( + private IdentityServerSigningCredentialStore CreateSut(string algorithm = SecurityAlgorithms.RsaSha256) => new( identityServerKeyStore, dataProtectedIdentityServerKeyMaterialConverter, timeProvider, fakeOptions, telemetry, - new MockLogger()); + new MockLogger()) + { + Algorithm = algorithm, + }; [Fact] public async Task GetSigningCredentialsAsync_WhenUnspecifiedDateTime_ShouldTreatAsUtcTime() @@ -72,7 +75,7 @@ public async Task GetSigningCredentialsAsync_WhenUnspecifiedDateTime_ShouldTreat { 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", + Algorithm = SecurityAlgorithms.RsaSha256, Parameters = FakeKeyData.RsaSecurityKey256, }; @@ -90,7 +93,6 @@ public async Task GetSigningCredentialsAsync_WhenUnspecifiedDateTime_ShouldTreat SigningCredentials expectedSigningCredentials = new SigningCredentials(new RsaSecurityKey(fakeRsaKey.Parameters) { KeyId = fakeRsaKey.Id }, fakeRsaKey.Algorithm); actual.Should().BeEquivalentTo(expectedSigningCredentials); - } [Fact] @@ -100,7 +102,7 @@ public async Task GetSigningCredentialsAsync_WhenDataUnprotected_AndLatestRsaKey { Id = "Fake_RS256", Created = new DateTime(2026, 04, 25, 11, 20, 21, DateTimeKind.Utc), - Algorithm = "RS256", + Algorithm = SecurityAlgorithms.RsaSha256, Parameters = FakeKeyData.RsaSecurityKey256, }; @@ -108,7 +110,7 @@ public async Task GetSigningCredentialsAsync_WhenDataUnprotected_AndLatestRsaKey { Id = "Fake_ES256", Created = new DateTime(2026, 02, 25, 11, 20, 21, DateTimeKind.Utc), - Algorithm = "ES256", + Algorithm = SecurityAlgorithms.EcdsaSha256, D = FakeKeyData.EcDsaSecurityKey256.D, Q = FakeKeyData.EcDsaSecurityKey256.Q, }; @@ -131,13 +133,13 @@ public async Task GetSigningCredentialsAsync_WhenDataUnprotected_AndLatestRsaKey } [Fact] - public async Task GetSigningCredentialsAsync_ShouldFilterOutNonSigningKeys() + public async Task GetSigningCredentialsAsync_WhenDataUnprotected_AndLatestRsaKey_AndStoreAlgorithSetToES256_ShouldReturnES256SigningCredentialsRepresenting() { var fakeRsaKey = new RsaIdentityServerKeyData { Id = "Fake_RS256", Created = new DateTime(2026, 04, 25, 11, 20, 21, DateTimeKind.Utc), - Algorithm = "RS256", + Algorithm = SecurityAlgorithms.RsaSha256, Parameters = FakeKeyData.RsaSecurityKey256, }; @@ -145,21 +147,21 @@ public async Task GetSigningCredentialsAsync_ShouldFilterOutNonSigningKeys() { Id = "Fake_ES256", Created = new DateTime(2026, 02, 25, 11, 20, 21, DateTimeKind.Utc), - Algorithm = "ES256", + Algorithm = SecurityAlgorithms.EcdsaSha256, 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 = "encryption", DataProtected = false, Algorithm = fakeRsaKey.Algorithm, Data = fakeRsaKey.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 sut = CreateSut(SecurityAlgorithms.EcdsaSha256); var actual = await sut.GetSigningCredentialsAsync(); ECDsa expectedEcDsa = ECDsa.Create(new ECParameters @@ -171,6 +173,41 @@ public async Task GetSigningCredentialsAsync_ShouldFilterOutNonSigningKeys() actual.Should().BeEquivalentTo(expectedSigningCredentials); } + [Fact] + public async Task GetSigningCredentialsAsync_ShouldFilterOutNonSigningKeys() + { + var fakeRsaKey = new RsaIdentityServerKeyData + { + Id = "Fake_RS256", + Created = new DateTime(2026, 04, 25, 11, 20, 21, DateTimeKind.Utc), + Algorithm = SecurityAlgorithms.RsaSha256, + Parameters = FakeKeyData.RsaSecurityKey256, + }; + + var fakeEcKey = new EcIdentityServerKeyData + { + Id = "Fake_ES256", + Created = new DateTime(2026, 02, 25, 11, 20, 21, DateTimeKind.Utc), + Algorithm = SecurityAlgorithms.EcdsaSha256, + 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 = "encryption", 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_ShouldFilterOutKeysThatExceedMaxLifetime() { @@ -178,7 +215,7 @@ public async Task GetSigningCredentialsAsync_ShouldFilterOutKeysThatExceedMaxLif { Id = "Fake_RS256", Created = fakeNow.AddDays(-130), - Algorithm = "RS256", + Algorithm = SecurityAlgorithms.RsaSha256, Parameters = FakeKeyData.RsaSecurityKey256, }; @@ -186,7 +223,7 @@ public async Task GetSigningCredentialsAsync_ShouldFilterOutKeysThatExceedMaxLif { Id = "Fake_ES256", Created = fakeNow.AddDays(-99), - Algorithm = "ES256", + Algorithm = SecurityAlgorithms.EcdsaSha256, D = FakeKeyData.EcDsaSecurityKey256.D, Q = FakeKeyData.EcDsaSecurityKey256.Q, }; @@ -207,13 +244,13 @@ public async Task GetSigningCredentialsAsync_ShouldFilterOutKeysThatExceedMaxLif } [Fact] - public async Task GetSigningCredentialsAsync_WhenDataProtected_AndLatestEcdsaKey_ShouldReturnSigningCredentialsRepresenting() + public async Task GetSigningCredentialsAsync_WhenDataProtected_AndLatestEcdsaKey_StoreAlgorithSetToES256_ShouldReturnES256SigningCredentialsRepresenting() { var fakeRsaKey = new RsaIdentityServerKeyData { Id = "Fake_RS256", Created = new DateTime(2026, 02, 25, 11, 20, 21, DateTimeKind.Utc), - Algorithm = "RS256", + Algorithm = SecurityAlgorithms.RsaSha256, Parameters = FakeKeyData.RsaSecurityKey256, }; var fakeRsaKeyJson = fakeRsaKey.ToExpectedJson(); @@ -223,7 +260,7 @@ public async Task GetSigningCredentialsAsync_WhenDataProtected_AndLatestEcdsaKey { Id = "Fake_ES256", Created = new DateTime(2026, 04, 25, 11, 20, 21, DateTimeKind.Utc), - Algorithm = "ES256", + Algorithm = SecurityAlgorithms.EcdsaSha256, D = FakeKeyData.EcDsaSecurityKey256.D, Q = FakeKeyData.EcDsaSecurityKey256.Q, }; @@ -251,24 +288,22 @@ public async Task GetSigningCredentialsAsync_WhenDataProtected_AndLatestEcdsaKey var sut = CreateSut(); var actual = await sut.GetSigningCredentialsAsync(); - - ECDsa expectedEcDsa = ECDsa.Create(new ECParameters - { - Curve = CryptoHelper.GetCurveFromCrvValue("P-256"), D = fakeEcKey.D, Q = fakeEcKey.Q, - }); - SigningCredentials expectedSigningCredentials = new SigningCredentials(new ECDsaSecurityKey(expectedEcDsa) { KeyId = fakeEcKey.Id }, fakeEcKey.Algorithm); + + + SigningCredentials expectedSigningCredentials = new SigningCredentials(new RsaSecurityKey(fakeRsaKey.Parameters) { KeyId = fakeRsaKey.Id }, fakeRsaKey.Algorithm); + actual.Should().BeEquivalentTo(expectedSigningCredentials); } [Fact] - public async Task GetSigningCredentialsAsync_WhenProtected_AndSingleEcdsa384Key_ShouldReturnSigningCredentialsRepresentingThatKey() + public async Task GetSigningCredentialsAsync_WhenProtected_AndSingleES384Key_StoreAlgorithSetToES384_ShouldReturnSigningCredentialsRepresentingThatKey() { var fakeEcKey = new EcIdentityServerKeyData { Id = "Fake_ES384", Created = new DateTime(2026, 04, 25, 11, 20, 21, DateTimeKind.Utc), - Algorithm = "ES384", + Algorithm = SecurityAlgorithms.EcdsaSha384, D = FakeKeyData.EcDsaSecurityKey384.D, Q = FakeKeyData.EcDsaSecurityKey384.Q, }; @@ -288,7 +323,7 @@ public async Task GetSigningCredentialsAsync_WhenProtected_AndSingleEcdsa384Key_ b => Convert.ToBase64String(b) == fakeEcKeyJsonProtectedBase64))) .Returns(Encoding.UTF8.GetBytes(fakeEcKeyJson)); - var sut = CreateSut(); + var sut = CreateSut(SecurityAlgorithms.EcdsaSha384); var actual = await sut.GetSigningCredentialsAsync(); ECDsa expectedEcDsa = ECDsa.Create(new ECParameters @@ -301,13 +336,13 @@ public async Task GetSigningCredentialsAsync_WhenProtected_AndSingleEcdsa384Key_ } [Fact] - public async Task GetSigningCredentialsAsync_WhenUnProtected_AndSingleEcdsa521Key_ShouldReturnSigningCredentialsRepresentingThatKey() + public async Task GetSigningCredentialsAsync_WhenUnProtected_AndSingleES512Key_StoreAlgorithSetToES512_ShouldReturnSigningCredentialsRepresentingThatKey() { var fakeEcKey = new EcIdentityServerKeyData { - Id = "Fake_ES521", + Id = "Fake_ES512", Created = new DateTime(2026, 04, 25, 11, 20, 21, DateTimeKind.Utc), - Algorithm = "ES521", + Algorithm = SecurityAlgorithms.EcdsaSha512, D = FakeKeyData.EcDsaSecurityKey521.D, Q = FakeKeyData.EcDsaSecurityKey521.Q, }; @@ -321,7 +356,7 @@ public async Task GetSigningCredentialsAsync_WhenUnProtected_AndSingleEcdsa521Ke .Setup(x => x.GetKeys()) .Returns(fakeKeyMaterials); - var sut = CreateSut(); + var sut = CreateSut(SecurityAlgorithms.EcdsaSha512); var actual = await sut.GetSigningCredentialsAsync(); ECDsa expectedEcDsa = ECDsa.Create(new ECParameters @@ -344,7 +379,7 @@ public async Task GetSigningCredentialsAsync_WhenProtected_AndSingleRsaKeyWrappe { Id = fakeRsaSecurityKey.KeyId, Created = fakeNow.AddDays(-33), - Algorithm = "RS256", + Algorithm = SecurityAlgorithms.RsaSha256, CertificateRawData = fakeRsaSecurityKey.ToBase64Pfx(), }; var fakeX509Json = fakeX509CertData.ToExpectedJson(); @@ -373,18 +408,18 @@ public async Task GetSigningCredentialsAsync_WhenProtected_AndSingleRsaKeyWrappe } [Fact] - public async Task GetSigningCredentialsAsync_WhenProtected_AndSingleECDsaKeyWrappedInX509_ShouldReturnSigningCredentialsRepresentingThatCert() + public async Task GetSigningCredentialsAsync_WhenProtected_AndSingleES384KeyWrappedInX509_StoreAlgorithSetToES512_ShouldReturnSigningCredentialsRepresentingThatCert() { - var fakeECDsaSecurityKey = new ECDsaSecurityKey(ECDsa.Create(FakeKeyData.EcDsaSecurityKey384)) + var fakeEcDsaSecurityKey = new ECDsaSecurityKey(ECDsa.Create(FakeKeyData.EcDsaSecurityKey384)) { KeyId = "Fake_ES384", }; var fakeX509CertData = new X509IdentityServerKeyData { - Id = fakeECDsaSecurityKey.KeyId, + Id = fakeEcDsaSecurityKey.KeyId, Created = fakeNow.AddDays(-33), - Algorithm = "ES384", - CertificateRawData = fakeECDsaSecurityKey.ToBase64Pfx(), + Algorithm = SecurityAlgorithms.EcdsaSha384, + CertificateRawData = fakeEcDsaSecurityKey.ToBase64Pfx(), }; var fakeX509Json = fakeX509CertData.ToExpectedJson(); var fakeX509PfxProtectedBase64 = Convert.ToBase64String("FakeRsaKeyJson_PROTECTED"u8.ToArray()); @@ -402,7 +437,7 @@ public async Task GetSigningCredentialsAsync_WhenProtected_AndSingleECDsaKeyWrap b => Convert.ToBase64String(b) == fakeX509PfxProtectedBase64))) .Returns(Encoding.UTF8.GetBytes(fakeX509Json)); - var sut = CreateSut(); + var sut = CreateSut(SecurityAlgorithms.EcdsaSha384); var actual = await sut.GetSigningCredentialsAsync(); var expectedCert = X509CertificateLoader.LoadPkcs12(Convert.FromBase64String(fakeX509CertData.CertificateRawData), null); @@ -424,6 +459,83 @@ public async Task GetSigningCredentialsAsync_WhenTableIsEmpty_ShouldReturnNull() actual.Should().BeNull(); } + [Fact] + public async Task GetSigningCredentialsAsync_WhenThreeKeysOneYoungerThanDefaultPropagationTime_ShouldReturnNewestPublishedCredential() + { + var fakeNewestRsaKey = new RsaIdentityServerKeyData + { + Id = "Fake_RS256_Newest", + Created = fakeNow.AddDays(-2), + Algorithm = SecurityAlgorithms.RsaSha256, + Parameters = FakeKeyData.RsaSecurityKey256, + }; + + var fakeNewestPublishedRsaKey = new RsaIdentityServerKeyData + { + Id = "Fake_RS256_NewestPublished", + Created = fakeNow.AddDays(-20), + Algorithm = SecurityAlgorithms.RsaSha256, + Parameters = FakeKeyData.RsaSecurityKey256, + }; + + var fakeOldestPublishedRsaKey = new RsaIdentityServerKeyData + { + Id = "Fake_RS256_OldestPublished", + Created = fakeNow.AddDays(-25), + Algorithm = SecurityAlgorithms.RsaSha256, + Parameters = FakeKeyData.RsaSecurityKey256, + }; + + fakeKeyMaterials = [ + new IdentityServerKeyMaterial { Id = fakeNewestPublishedRsaKey.Id, Version = 1, Use = "signing", DataProtected = false, Algorithm = fakeNewestPublishedRsaKey.Algorithm, Data = fakeNewestPublishedRsaKey.ToExpectedJson() }, + new IdentityServerKeyMaterial { Id = fakeNewestRsaKey.Id, Version = 1, Use = "signing", DataProtected = false, Algorithm = fakeNewestRsaKey.Algorithm, Data = fakeNewestRsaKey.ToExpectedJson() }, + new IdentityServerKeyMaterial { Id = fakeOldestPublishedRsaKey.Id, Version = 1, Use = "signing", DataProtected = false, Algorithm = fakeOldestPublishedRsaKey.Algorithm, Data = fakeOldestPublishedRsaKey.ToExpectedJson() }, + ]; + + Mock.Get(identityServerKeyStore) + .Setup(x => x.GetKeys()) + .Returns(fakeKeyMaterials); + + var sut = CreateSut(); + var actual = await sut.GetSigningCredentialsAsync(); + + actual.Kid.Should().Be(fakeNewestPublishedRsaKey.Id); + } + + [Fact] + public async Task GetSigningCredentialsAsync_WhenAllKeysYoungerThanDefaultPropagationTime_ShouldReturnOldestCredential() + { + var fakeNewestRsaKey = new RsaIdentityServerKeyData + { + Id = "Fake_RS256_Newest", + Created = fakeNow.AddDays(-2), + Algorithm = SecurityAlgorithms.RsaSha256, + Parameters = FakeKeyData.RsaSecurityKey256, + }; + + var fakeOldestRsaKey = new RsaIdentityServerKeyData + { + Id = "Fake_RS256_Oldest", + Created = fakeNow.AddDays(-10), + Algorithm = SecurityAlgorithms.RsaSha256, + Parameters = FakeKeyData.RsaSecurityKey256, + }; + + fakeKeyMaterials = [ + new IdentityServerKeyMaterial { Id = fakeOldestRsaKey.Id, Version = 1, Use = "signing", DataProtected = false, Algorithm = fakeOldestRsaKey.Algorithm, Data = fakeOldestRsaKey.ToExpectedJson() }, + new IdentityServerKeyMaterial { Id = fakeNewestRsaKey.Id, Version = 1, Use = "signing", DataProtected = false, Algorithm = fakeNewestRsaKey.Algorithm, Data = fakeNewestRsaKey.ToExpectedJson() }, + ]; + + Mock.Get(identityServerKeyStore) + .Setup(x => x.GetKeys()) + .Returns(fakeKeyMaterials); + + var sut = CreateSut(); + var actual = await sut.GetSigningCredentialsAsync(); + + actual.Kid.Should().Be(fakeOldestRsaKey.Id); + } + [Fact] public async Task GetSigningCredentialsAsync_WhenCalled_ShouldTelemetryTrace() { @@ -437,7 +549,7 @@ public async Task GetSigningCredentialsAsync_WhenCalled_ShouldTelemetryTrace() // Verify that the telemetry trace was called Mock.Get(telemetry) .Verify(t => t.Trace( - TelemetryConstants.TraceCategories.Stores, sut, "GetSigningCredentialsAsync")); + TelemetryConstants.TraceCategories.Stores, sut, nameof(sut.GetSigningCredentialsAsync))); Mock.Get(trace).Verify(t => t.Dispose(), Times.Once); } } \ No newline at end of file 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 98c1e8653..a68df8c31 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 @@ -69,7 +69,7 @@ public async Task GetValidationKeysAsync_WhenUnspecifiedDateTime_ShouldTreatAsUt { 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", + Algorithm = SecurityAlgorithms.RsaSha256, Parameters = FakeKeyData.RsaSecurityKey256, }; @@ -77,7 +77,7 @@ public async Task GetValidationKeysAsync_WhenUnspecifiedDateTime_ShouldTreatAsUt { Id = "Fake_RS256_1", Created = fakeNow.AddDays(-10), - Algorithm = "RS256", + Algorithm = SecurityAlgorithms.RsaSha256, Parameters = FakeKeyData.RsaSecurityKey256, }; @@ -86,16 +86,16 @@ public async Task GetValidationKeysAsync_WhenUnspecifiedDateTime_ShouldTreatAsUt { 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", + Algorithm = SecurityAlgorithms.EcdsaSha384, D = FakeKeyData.EcDsaSecurityKey384.D, Q = FakeKeyData.EcDsaSecurityKey384.Q, }; var fakeEcKey1 = new EcIdentityServerKeyData { - Id = "Fake_ES521_0", + Id = "Fake_ES512_0", Created = fakeNow.AddDays(-45), - Algorithm = "ES521", + Algorithm = SecurityAlgorithms.EcdsaSha512, D = FakeKeyData.EcDsaSecurityKey521.D, Q = FakeKeyData.EcDsaSecurityKey521.Q, }; @@ -142,7 +142,7 @@ public async Task GetValidationKeysAsync_ShouldReturnCollectionOfSecurityKeyInfo { Id = "Fake_RS256_0", Created = fakeNow.AddDays(-20), - Algorithm = "RS256", + Algorithm = SecurityAlgorithms.RsaSha256, Parameters = FakeKeyData.RsaSecurityKey256, }; @@ -150,7 +150,7 @@ public async Task GetValidationKeysAsync_ShouldReturnCollectionOfSecurityKeyInfo { Id = "Fake_RS256_1", Created = fakeNow.AddDays(-10), - Algorithm = "RS256", + Algorithm = SecurityAlgorithms.RsaSha256, Parameters = FakeKeyData.RsaSecurityKey256, }; @@ -158,16 +158,16 @@ public async Task GetValidationKeysAsync_ShouldReturnCollectionOfSecurityKeyInfo { Id = "Fake_ES384_0", Created = fakeNow.AddDays(-30), - Algorithm = "ES384", + Algorithm = SecurityAlgorithms.EcdsaSha384, D = FakeKeyData.EcDsaSecurityKey384.D, Q = FakeKeyData.EcDsaSecurityKey384.Q, }; var fakeEcKey1 = new EcIdentityServerKeyData { - Id = "Fake_ES521_0", + Id = "Fake_ES512_0", Created = fakeNow.AddDays(-45), - Algorithm = "ES521", + Algorithm = SecurityAlgorithms.EcdsaSha512, D = FakeKeyData.EcDsaSecurityKey521.D, Q = FakeKeyData.EcDsaSecurityKey521.Q, }; @@ -214,7 +214,7 @@ public async Task GetValidationKeysAsync_ShouldReturnCollectionSecurityKeyInfo_A { Id = "Fake_RS256_0", Created = fakeNow.AddDays(-10), - Algorithm = "RS256", + Algorithm = SecurityAlgorithms.RsaSha256, Parameters = FakeKeyData.RsaSecurityKey256, }; @@ -222,7 +222,7 @@ public async Task GetValidationKeysAsync_ShouldReturnCollectionSecurityKeyInfo_A { Id = "Fake_RS256_1", Created = fakeNow.AddDays(-30), - Algorithm = "RS256", + Algorithm = SecurityAlgorithms.RsaSha256, Parameters = FakeKeyData.RsaSecurityKey256, }; @@ -230,16 +230,16 @@ public async Task GetValidationKeysAsync_ShouldReturnCollectionSecurityKeyInfo_A { Id = "Fake_ES256_0", Created = fakeNow.AddHours(-12), - Algorithm = "ES256", + Algorithm = SecurityAlgorithms.EcdsaSha256, D = FakeKeyData.EcDsaSecurityKey256.D, Q = FakeKeyData.EcDsaSecurityKey256.Q, }; var fakeEcKey1 = new EcIdentityServerKeyData { - Id = "Fake_ES521_0", + Id = "Fake_ES512_0", Created = fakeNow.AddDays(-20), - Algorithm = "ES521", + Algorithm = SecurityAlgorithms.EcdsaSha512, D = FakeKeyData.EcDsaSecurityKey521.D, Q = FakeKeyData.EcDsaSecurityKey521.Q, }; @@ -279,7 +279,7 @@ public async Task GetValidationKeysAsync_ShouldReturnCollectionSecurityKeyInfo_A { Id = "Fake_RS256_0", Created = fakeNow.AddDays(-30), - Algorithm = "RS256", + Algorithm = SecurityAlgorithms.RsaSha256, Parameters = FakeKeyData.RsaSecurityKey256, }; @@ -287,7 +287,7 @@ public async Task GetValidationKeysAsync_ShouldReturnCollectionSecurityKeyInfo_A { Id = "Fake_RS256_1", Created = fakeNow.AddHours(-12), - Algorithm = "RS256", + Algorithm = SecurityAlgorithms.RsaSha256, Parameters = FakeKeyData.RsaSecurityKey256, }; @@ -295,16 +295,16 @@ public async Task GetValidationKeysAsync_ShouldReturnCollectionSecurityKeyInfo_A { Id = "Fake_ES256_0", Created = fakeNow.AddDays(-100), - Algorithm = "ES256", + Algorithm = SecurityAlgorithms.EcdsaSha256, D = FakeKeyData.EcDsaSecurityKey256.D, Q = FakeKeyData.EcDsaSecurityKey256.Q, }; var fakeEcKey1 = new EcIdentityServerKeyData { - Id = "Fake_ES521_0", + Id = "Fake_ES512_0", Created = fakeNow.AddDays(-10), - Algorithm = "ES521", + Algorithm = SecurityAlgorithms.EcdsaSha512, D = FakeKeyData.EcDsaSecurityKey521.D, Q = FakeKeyData.EcDsaSecurityKey521.Q, };