Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions docs/topics/compatibility.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 <https://www.identityserver.com/products>`_.
.. 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 <https://www.identityserver.com/products>`_.
2 changes: 1 addition & 1 deletion src/Open.IdentityServer/src/Configuration/CryptoHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,18 @@ public static IIdentityServerBuilder AddCompatibilityKeyStores(this IIdentitySer
builder.Services.AddSingleton(options);
builder.Services.AddTransient<DataProtectedIdentityServerKeyMaterialConverter>();
builder.Services.AddScoped<IValidationKeysStore, IdentityServerValidationKeysStore>();
builder.Services.AddScoped<ISigningCredentialStore, IdentityServerSigningCredentialStore>();

builder.Services.AddSingleton<IIdentityServerSigningCredentialStoreFactory, IdentityServerSigningCredentialStoreFactory>();

foreach (var supportedAlg in options.SupportedAlgorithms)
{
builder.Services.AddScoped<ISigningCredentialStore>(sp =>
{
IIdentityServerSigningCredentialStoreFactory factory = sp.GetRequiredService<IIdentityServerSigningCredentialStoreFactory>();

return factory.Create(supportedAlg);
});
}

return builder;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -11,7 +13,27 @@ namespace Open.IdentityServer.Configuration;
public class CompatibilityKeyStoreOptions
{
/// <summary>
/// Maximum lifetime for keys read from the key store, defaults to 90 days.
/// Maximum lifetime for keys read from the key store
/// </summary>
/// <value>defaults to 90 days</value>
public TimeSpan MaxLifetime { get; set; } = TimeSpan.FromDays(90);

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Supported values are <c>RS256</c>, <c>RS384</c>, <c>RS512</c>, <c>PS256</c>,
/// <c>PS384</c>, <c>PS512</c>, <c>ES256</c>, <c>ES384</c>, and <c>ES512</c>.
/// </remarks>
/// <value>defaults to [<c>RS256</c>]</value>
public ICollection<string> SupportedAlgorithms { get; set; } = [SecurityAlgorithms.RsaSha256];
Comment thread
patchandthat marked this conversation as resolved.
Resolved

/// <summary>
/// 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
/// </summary>
/// <value>defaults to 14 days</value>
public TimeSpan KeyPropagationTime { get; set; } = TimeSpan.FromDays(14);
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Interface for the creating signing credential store
/// </summary>
public interface IIdentityServerSigningCredentialStoreFactory
{
/// <summary>
/// Creates instance of <see cref="IdentityServerSigningCredentialStore"/> setting algorithm setting to provided alg property
/// </summary>
/// <param name="alg">algorithm of signing key to provide</param>
/// <returns></returns>
public IdentityServerSigningCredentialStore Create(string alg);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -27,11 +28,16 @@
TimeProvider timeProvider,
CompatibilityKeyStoreOptions options,
ITelemetryService telemetry,
ILogger<IdentityServerSigningCredentialStore> logger
):
IdentityServerSigningKeyStore(identityServerKeyStore, dataProtectedIdentityServerKeyMaterialConverter, timeProvider, options),
ILogger<IdentityServerSigningCredentialStore> logger) :
IdentityServerSigningKeyStore(identityServerKeyStore, dataProtectedIdentityServerKeyMaterialConverter, timeProvider,

Check warning on line 32 in src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerSigningCredentialStore.cs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

Parameter 'TimeProvider timeProvider' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.

Check warning on line 32 in src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerSigningCredentialStore.cs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

Parameter 'TimeProvider timeProvider' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.

Check warning on line 32 in src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerSigningCredentialStore.cs

View workflow job for this annotation

GitHub Actions / build (windows-latest)

Parameter 'TimeProvider timeProvider' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.

Check warning on line 32 in src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerSigningCredentialStore.cs

View workflow job for this annotation

GitHub Actions / build (windows-latest)

Parameter 'TimeProvider timeProvider' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.

Check warning on line 32 in src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerSigningCredentialStore.cs

View workflow job for this annotation

GitHub Actions / build (macos-latest)

Parameter 'TimeProvider timeProvider' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.

Check warning on line 32 in src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerSigningCredentialStore.cs

View workflow job for this annotation

GitHub Actions / build (macos-latest)

Parameter 'TimeProvider timeProvider' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.
options),

Check warning on line 33 in src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerSigningCredentialStore.cs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

Parameter 'CompatibilityKeyStoreOptions options' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.

Check warning on line 33 in src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerSigningCredentialStore.cs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

Parameter 'CompatibilityKeyStoreOptions options' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.

Check warning on line 33 in src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerSigningCredentialStore.cs

View workflow job for this annotation

GitHub Actions / build (windows-latest)

Parameter 'CompatibilityKeyStoreOptions options' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.

Check warning on line 33 in src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerSigningCredentialStore.cs

View workflow job for this annotation

GitHub Actions / build (windows-latest)

Parameter 'CompatibilityKeyStoreOptions options' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.

Check warning on line 33 in src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerSigningCredentialStore.cs

View workflow job for this annotation

GitHub Actions / build (macos-latest)

Parameter 'CompatibilityKeyStoreOptions options' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.

Check warning on line 33 in src/Open.IdentityServer/src/Stores/Compatibility/IdentityServerSigningCredentialStore.cs

View workflow job for this annotation

GitHub Actions / build (macos-latest)

Parameter 'CompatibilityKeyStoreOptions options' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.
ISigningCredentialStore
{
/// <summary>
/// Specifies signing algorthm of signing key to retrieve, defaults to <c>RS256</c>
/// </summary>
public string Algorithm { get; init; } = SecurityAlgorithms.RsaSha256;

/// <summary>
/// Gets the key to be used for signing from the key store, will be the newest key
/// </summary>
Expand All @@ -40,15 +46,25 @@
{
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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
Comment thread
jhbritton-RSK marked this conversation as resolved.
Resolved
using Microsoft.Extensions.Logging;
using Open.IdentityServer.Configuration;
using Open.IdentityServer.DataProtection;
using Open.IdentityServer.Services;

namespace Open.IdentityServer.Stores;

/// <summary>
/// Factory for the creating signing credential store
/// </summary>
/// <param name="identityServerKeyStore">The key store used to retrieve signing keys.</param>
/// <param name="dataProtectedIdentityServerKeyMaterialConverter">Converter used to decrypt and convert data-protected key material into usable credentials.</param>
/// <param name="timeProvider">Time provider implementation</param>
/// <param name="options">Compatibility key store options</param>
/// <param name="telemetry">The telemetry service</param>
/// <param name="logger">The logger</param>
public class IdentityServerSigningCredentialStoreFactory(IIdentityServerKeyStore identityServerKeyStore,
DataProtectedIdentityServerKeyMaterialConverter dataProtectedIdentityServerKeyMaterialConverter,
TimeProvider timeProvider,
CompatibilityKeyStoreOptions options,
ITelemetryService telemetry,
ILogger<IdentityServerSigningCredentialStore> logger): IIdentityServerSigningCredentialStoreFactory
{
/// <inheritdoc />
public IdentityServerSigningCredentialStore Create(string alg)
{
return new IdentityServerSigningCredentialStore(identityServerKeyStore, dataProtectedIdentityServerKeyMaterialConverter, timeProvider, options, telemetry, logger)
{
Algorithm = alg
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public IEnumerable<IdentityServerKeyMaterial> 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 },
];
}
Expand Down Expand Up @@ -91,15 +91,15 @@ public IEnumerable<IdentityServerKeyMaterial> 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"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Comment thread
jhbritton-RSK marked this conversation as resolved.
Resolved
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);
}
}
Loading
Loading