From 40215610a971f36322178c32e292191875e94735 Mon Sep 17 00:00:00 2001 From: James Britton Date: Tue, 27 Oct 2026 16:37:13 +0000 Subject: [PATCH 1/4] bug: only one legacy key registered from key management store. No ability to specify multiple supported algorithm support --- .../src/Configuration/CryptoHelper.cs | 2 +- .../BuilderExtensions/Crypto.cs | 13 ++- .../Options/CompatibilityKeyStoreOptions.cs | 12 ++ .../IdentityServerSigningCredentialStore.cs | 10 +- ...tityServerSigningCredentialStoreFactory.cs | 50 +++++++++ .../Discovery/FakeIdentityServerKeyStore.cs | 8 +- .../Endpoints/Discovery/JwkEndpointTests.cs | 12 +- .../BuilderExtensions/CryptoTests.cs | 17 +++ ...entityServerSigningCredentialStoreTests.cs | 104 +++++++++++++----- .../IdentityServerValidationKeysStoreTests.cs | 40 +++---- 10 files changed, 204 insertions(+), 64 deletions(-) create mode 100644 src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerSigningCredentialStoreFactory.cs create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/DependencyInjection/BuilderExtensions/CryptoTests.cs 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..6204149ba 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; @@ -14,4 +16,14 @@ public class CompatibilityKeyStoreOptions /// 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. + /// + public ICollection SupportedAlgorithms { get; set; } = [SecurityAlgorithms.RsaSha256]; } \ 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..5c320a95d 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,15 @@ public class IdentityServerSigningCredentialStore( TimeProvider timeProvider, CompatibilityKeyStoreOptions options, ITelemetryService telemetry, - ILogger logger - ): + ILogger logger): IdentityServerSigningKeyStore(identityServerKeyStore, dataProtectedIdentityServerKeyMaterialConverter, timeProvider, options), ISigningCredentialStore { + /// + /// Specifies signing algorthm of signing key to retrieve + /// + public string Algorithm = Constants.SigningAlgorithms.RSA_SHA_256; //Default + /// /// Gets the key to be used for signing from the key store, will be the newest key /// @@ -41,6 +46,7 @@ public async Task GetSigningCredentialsAsync() using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); var credentials = GetKeys() + .Where(x => x.Credentials.Algorithm == Algorithm) .Select(x => x.Credentials) .FirstOrDefault(); 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..20b18cab8 --- /dev/null +++ b/src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerSigningCredentialStoreFactory.cs @@ -0,0 +1,50 @@ +using System; +using Microsoft.Extensions.Logging; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.DataProtection; +using Open.IdentityServer.Services; + +namespace Open.IdentityServer.Stores; + +/// +/// +/// +public interface IIdentityServerSigningCredentialStoreFactory +{ + /// + /// + /// + /// + /// + public IdentityServerSigningCredentialStore Create(string alg); +} + +/// +/// +/// +/// +/// +/// +/// +/// +/// +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/DependencyInjection/BuilderExtensions/CryptoTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/DependencyInjection/BuilderExtensions/CryptoTests.cs new file mode 100644 index 000000000..16d813a10 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/DependencyInjection/BuilderExtensions/CryptoTests.cs @@ -0,0 +1,17 @@ +using AwesomeAssertions; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Open.IdentityServer.UnitTests.Configuration.DependencyInjection; + +public class CryptoTests +{ + private IServiceCollection serviceCollection = new ServiceCollection(); + + [Fact] + public void AddCompatibilityKeyStores_When_Should() + { + //TODO Implement tests + false.Should().BeTrue(); + } +} \ 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..de34c8d3b 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 @@ -72,7 +72,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 +90,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 +99,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 +107,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 +130,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,14 +144,14 @@ 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) @@ -160,6 +159,7 @@ public async Task GetSigningCredentialsAsync_ShouldFilterOutNonSigningKeys() .Returns(fakeKeyMaterials); var sut = CreateSut(); + sut.Algorithm = SecurityAlgorithms.EcdsaSha256; var actual = await sut.GetSigningCredentialsAsync(); ECDsa expectedEcDsa = ECDsa.Create(new ECParameters @@ -171,6 +171,49 @@ 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(); + + // 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); + // + // actual.Should().BeEquivalentTo(expectedSigningCredentials); + } + [Fact] public async Task GetSigningCredentialsAsync_ShouldFilterOutKeysThatExceedMaxLifetime() { @@ -178,7 +221,7 @@ public async Task GetSigningCredentialsAsync_ShouldFilterOutKeysThatExceedMaxLif { Id = "Fake_RS256", Created = fakeNow.AddDays(-130), - Algorithm = "RS256", + Algorithm = SecurityAlgorithms.RsaSha256, Parameters = FakeKeyData.RsaSecurityKey256, }; @@ -186,7 +229,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 +250,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 +266,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 +294,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, }; @@ -289,6 +330,7 @@ public async Task GetSigningCredentialsAsync_WhenProtected_AndSingleEcdsa384Key_ .Returns(Encoding.UTF8.GetBytes(fakeEcKeyJson)); var sut = CreateSut(); + sut.Algorithm = SecurityAlgorithms.EcdsaSha384; var actual = await sut.GetSigningCredentialsAsync(); ECDsa expectedEcDsa = ECDsa.Create(new ECParameters @@ -301,13 +343,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, }; @@ -322,6 +364,7 @@ public async Task GetSigningCredentialsAsync_WhenUnProtected_AndSingleEcdsa521Ke .Returns(fakeKeyMaterials); var sut = CreateSut(); + sut.Algorithm = SecurityAlgorithms.EcdsaSha512; var actual = await sut.GetSigningCredentialsAsync(); ECDsa expectedEcDsa = ECDsa.Create(new ECParameters @@ -344,7 +387,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 +416,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()); @@ -403,6 +446,7 @@ public async Task GetSigningCredentialsAsync_WhenProtected_AndSingleECDsaKeyWrap .Returns(Encoding.UTF8.GetBytes(fakeX509Json)); var sut = CreateSut(); + sut.Algorithm = SecurityAlgorithms.EcdsaSha384; var actual = await sut.GetSigningCredentialsAsync(); var expectedCert = X509CertificateLoader.LoadPkcs12(Convert.FromBase64String(fakeX509CertData.CertificateRawData), null); 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, }; From 38b63e42ff0249711f7b63e50e4c52a743fa6912 Mon Sep 17 00:00:00 2001 From: James Britton Date: Wed, 28 Oct 2026 11:54:14 +0000 Subject: [PATCH 2/4] feat: add propergation time setting to prioritise published keys in compatibility store --- .../Options/CompatibilityKeyStoreOptions.cs | 8 ++ .../IdentityServerSigningCredentialStore.cs | 32 +++++--- ...entityServerSigningCredentialStoreTests.cs | 79 ++++++++++++++++++- 3 files changed, 107 insertions(+), 12 deletions(-) diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/CompatibilityKeyStoreOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/CompatibilityKeyStoreOptions.cs index 6204149ba..efea87ad1 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/CompatibilityKeyStoreOptions.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/CompatibilityKeyStoreOptions.cs @@ -25,5 +25,13 @@ public class CompatibilityKeyStoreOptions /// 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 with a creation time greater than this TimeSpan away will be used, + /// else newest key + /// + /// defaults to 14 days + public TimeSpan KeyPropagationTime = TimeSpan.FromDays(14); } \ 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 5c320a95d..c285bbfa4 100644 --- a/src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerSigningCredentialStore.cs +++ b/src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerSigningCredentialStore.cs @@ -28,15 +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 + /// Specifies signing algorthm of signing key to retrieve, defaults to RS256 /// - public string Algorithm = Constants.SigningAlgorithms.RSA_SHA_256; //Default - + public string Algorithm = SecurityAlgorithms.RsaSha256; + /// /// Gets the key to be used for signing from the key store, will be the newest key /// @@ -45,16 +46,25 @@ public async Task GetSigningCredentialsAsync() { using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); - var credentials = GetKeys() + var query = GetKeys() .Where(x => x.Credentials.Algorithm == Algorithm) - .Select(x => x.Credentials) - .FirstOrDefault(); + .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/test/Open.IdentityServer.UnitTests/Stores/Compatibility/IdentityServerSigningCredentialStoreTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Compatibility/IdentityServerSigningCredentialStoreTests.cs index de34c8d3b..514ba8b0b 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 @@ -468,6 +468,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() { @@ -481,7 +558,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 From 1760c2daf6d82e57887a55be7c00b65b2ce8ea1f Mon Sep 17 00:00:00 2001 From: James Britton Date: Wed, 28 Oct 2026 12:18:33 +0000 Subject: [PATCH 3/4] docs: updated documentation for compatibility key store --- docs/topics/compatibility.rst | 17 ++++++++++++----- .../Options/CompatibilityKeyStoreOptions.cs | 6 ++++-- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/docs/topics/compatibility.rst b/docs/topics/compatibility.rst index 64167204f..f8c8b106c 100644 --- a/docs/topics/compatibility.rst +++ b/docs/topics/compatibility.rst @@ -12,7 +12,9 @@ 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. +signing key that exists for each configured algorithm and use it for signing, 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 +24,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:: + By default, 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, 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/DependencyInjection/Options/CompatibilityKeyStoreOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/CompatibilityKeyStoreOptions.cs index efea87ad1..57fea3d0d 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/CompatibilityKeyStoreOptions.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/CompatibilityKeyStoreOptions.cs @@ -13,8 +13,9 @@ 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); /// @@ -29,7 +30,8 @@ public class CompatibilityKeyStoreOptions public ICollection SupportedAlgorithms { get; set; } = [SecurityAlgorithms.RsaSha256]; /// - /// Key propagation time, if a valid key exists with a creation time greater than this TimeSpan away will be used, + /// 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 From 6882a22509d573ec19caadde82ad18e9a9a00a6d Mon Sep 17 00:00:00 2001 From: James Britton Date: Thu, 29 Oct 2026 18:20:11 +0000 Subject: [PATCH 4/4] pr: correcting issues commented on in pull request review --- docs/topics/compatibility.rst | 14 ++-- .../Options/CompatibilityKeyStoreOptions.cs | 2 +- ...tityServerSigningCredentialStoreFactory.cs | 17 +++++ .../IdentityServerSigningCredentialStore.cs | 2 +- ...tityServerSigningCredentialStoreFactory.cs | 36 +++------- .../Configuration/CryptoHelperTests.cs | 2 +- .../BuilderExtensions/CryptoTests.cs | 71 ++++++++++++++++++- ...entityServerSigningCredentialStoreTests.cs | 27 +++---- 8 files changed, 116 insertions(+), 55 deletions(-) create mode 100644 src/Open.IdentityServer/src/Stores/Compatibility/IIdentityServerSigningCredentialStoreFactory.cs diff --git a/docs/topics/compatibility.rst b/docs/topics/compatibility.rst index f8c8b106c..b80b4a9ac 100644 --- a/docs/topics/compatibility.rst +++ b/docs/topics/compatibility.rst @@ -11,10 +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 for each configured algorithm and use it for signing, 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. +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 @@ -25,11 +27,11 @@ which will register these key stores in the DI container for you. .AddCompatibilityKeyStores(); .. note:: - By default, the default propagation time will be respected if possible, and only RS256 keys will be loaded for signing, + 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, more robust key management should be + 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/DependencyInjection/Options/CompatibilityKeyStoreOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/CompatibilityKeyStoreOptions.cs index 57fea3d0d..d46b77b3d 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/CompatibilityKeyStoreOptions.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/CompatibilityKeyStoreOptions.cs @@ -35,5 +35,5 @@ public class CompatibilityKeyStoreOptions /// else newest key /// /// defaults to 14 days - public TimeSpan KeyPropagationTime = TimeSpan.FromDays(14); + 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 c285bbfa4..b4931f348 100644 --- a/src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerSigningCredentialStore.cs +++ b/src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerSigningCredentialStore.cs @@ -36,7 +36,7 @@ public class IdentityServerSigningCredentialStore( /// /// Specifies signing algorthm of signing key to retrieve, defaults to RS256 /// - public string Algorithm = SecurityAlgorithms.RsaSha256; + public string Algorithm { get; init; } = SecurityAlgorithms.RsaSha256; /// /// Gets the key to be used for signing from the key store, will be the newest key diff --git a/src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerSigningCredentialStoreFactory.cs b/src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerSigningCredentialStoreFactory.cs index 20b18cab8..a9b00a0f6 100644 --- a/src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerSigningCredentialStoreFactory.cs +++ b/src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerSigningCredentialStoreFactory.cs @@ -1,3 +1,6 @@ +// 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; @@ -7,27 +10,14 @@ namespace Open.IdentityServer.Stores; /// -/// -/// -public interface IIdentityServerSigningCredentialStoreFactory -{ - /// - /// - /// - /// - /// - public IdentityServerSigningCredentialStore Create(string alg); -} - -/// -/// +/// 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, @@ -35,11 +25,7 @@ public class IdentityServerSigningCredentialStoreFactory(IIdentityServerKeyStore ITelemetryService telemetry, ILogger logger): IIdentityServerSigningCredentialStoreFactory { - /// - /// - /// - /// - /// + /// public IdentityServerSigningCredentialStore Create(string alg) { return new IdentityServerSigningCredentialStore(identityServerKeyStore, dataProtectedIdentityServerKeyMaterialConverter, timeProvider, options, telemetry, logger) 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 index 16d813a10..06d253d04 100644 --- 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 @@ -1,5 +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. + +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; @@ -9,9 +17,66 @@ public class CryptoTests private IServiceCollection serviceCollection = new ServiceCollection(); [Fact] - public void AddCompatibilityKeyStores_When_Should() + 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() { - //TODO Implement tests - false.Should().BeTrue(); + 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 514ba8b0b..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() @@ -158,8 +161,7 @@ public async Task GetSigningCredentialsAsync_WhenDataUnprotected_AndLatestRsaKey .Setup(x => x.GetKeys()) .Returns(fakeKeyMaterials); - var sut = CreateSut(); - sut.Algorithm = SecurityAlgorithms.EcdsaSha256; + var sut = CreateSut(SecurityAlgorithms.EcdsaSha256); var actual = await sut.GetSigningCredentialsAsync(); ECDsa expectedEcDsa = ECDsa.Create(new ECParameters @@ -204,14 +206,6 @@ public async Task GetSigningCredentialsAsync_ShouldFilterOutNonSigningKeys() var actual = await sut.GetSigningCredentialsAsync(); actual.Should().BeNull(); - - // 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); - // - // actual.Should().BeEquivalentTo(expectedSigningCredentials); } [Fact] @@ -329,8 +323,7 @@ public async Task GetSigningCredentialsAsync_WhenProtected_AndSingleES384Key_Sto b => Convert.ToBase64String(b) == fakeEcKeyJsonProtectedBase64))) .Returns(Encoding.UTF8.GetBytes(fakeEcKeyJson)); - var sut = CreateSut(); - sut.Algorithm = SecurityAlgorithms.EcdsaSha384; + var sut = CreateSut(SecurityAlgorithms.EcdsaSha384); var actual = await sut.GetSigningCredentialsAsync(); ECDsa expectedEcDsa = ECDsa.Create(new ECParameters @@ -363,8 +356,7 @@ public async Task GetSigningCredentialsAsync_WhenUnProtected_AndSingleES512Key_S .Setup(x => x.GetKeys()) .Returns(fakeKeyMaterials); - var sut = CreateSut(); - sut.Algorithm = SecurityAlgorithms.EcdsaSha512; + var sut = CreateSut(SecurityAlgorithms.EcdsaSha512); var actual = await sut.GetSigningCredentialsAsync(); ECDsa expectedEcDsa = ECDsa.Create(new ECParameters @@ -445,8 +437,7 @@ public async Task GetSigningCredentialsAsync_WhenProtected_AndSingleES384KeyWrap b => Convert.ToBase64String(b) == fakeX509PfxProtectedBase64))) .Returns(Encoding.UTF8.GetBytes(fakeX509Json)); - var sut = CreateSut(); - sut.Algorithm = SecurityAlgorithms.EcdsaSha384; + var sut = CreateSut(SecurityAlgorithms.EcdsaSha384); var actual = await sut.GetSigningCredentialsAsync(); var expectedCert = X509CertificateLoader.LoadPkcs12(Convert.FromBase64String(fakeX509CertData.CertificateRawData), null);