Open damienbod opened 6 years ago
Of course :)
I'm just gonna add the code in this post and you can pick the parts you like.
The idea is like this: When you create a new certificate in Azure Key Vault you can set it to automaticly renew (create a new version) at a certain time. The extension will add all "Enabled" versions if the certificate to the ValidationKey set, and add the currect active version as the signing certificate. The extension caches the keys for 1 day because of performance reasons. When you decide that your key rollover periode is over, you go into Azure Key Vault and disable the old version.
Note: MSI (Managed Service Identities) does not work out of the box on a local dev machine so its better to check environment and use the methods where you add client id and secret.
MSI should work on Azure Scale Sets and Azure App Services (but not in deployment slot scenarios yet, as far as I know, long post on github about it)
The code needs the following nuget packages to work:
<PackageReference Include="IdentityServer4" Version="2.2.0" />
<PackageReference Include="Microsoft.Azure.KeyVault" Version="3.0.0" />
<PackageReference Include="Microsoft.Azure.Services.AppAuthentication" Version="1.0.3" />
DISCLAIMER: I have not fully tested the code yet so there might be bugs left ;)
using IdentityServer4.Stores;
using Microsoft.Azure.KeyVault;
using Microsoft.Azure.Services.AppAuthentication;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Clients.ActiveDirectory;
using Microsoft.IdentityModel.Tokens;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using System.Threading.Tasks;
namespace Authentication.Identity.Service.Extensions
{
/// <summary>
/// Extension methods for using Azure Key Vault with <see cref="IIdentityServerBuilder"/>.
/// </summary>
public static class IdentityServerAzureKeyVaultConfigurationExtensions
{
/// <summary>
/// Adds a SigningCredentialStore and a ValidationKeysStore that reads the signing certificate from the Azure KeyVault.
/// </summary>
/// <param name="identityServerbuilder">The <see cref="IIdentityServerBuilder"/> to add to.</param>
/// <param name="vault">The Azure KeyVault uri.</param>
/// <param name="clientId">The application client id.</param>
/// <param name="clientSecret">The client secret to use for authentication.</param>
/// <param name="certificateName">The name of the certificate to use as the signing certificate.</param>
/// <returns>The <see cref="IIdentityServerBuilder"/>.</returns>
public static IIdentityServerBuilder AddSigningCredentialFromAzureKeyVault(this IIdentityServerBuilder identityServerbuilder, string vault, string clientId, string clientSecret, string certificateName, int signingKeyRolloverTimeInHours)
{
KeyVaultClient.AuthenticationCallback authenticationCallback = (authority, resource, scope) => GetTokenFromClientSecret(authority, resource, clientId, clientSecret);
var keyVaultClient = new KeyVaultClient(authenticationCallback);
identityServerbuilder.Services.AddMemoryCache();
var sp = identityServerbuilder.Services.BuildServiceProvider();
identityServerbuilder.Services.AddSingleton<ISigningCredentialStore>(new AzureKeyVaultSigningCredentialStore(sp.GetService<IMemoryCache>(), keyVaultClient, vault, certificateName, signingKeyRolloverTimeInHours));
identityServerbuilder.Services.AddSingleton<IValidationKeysStore>(new AzureKeyVaultValidationKeysStore(sp.GetService<IMemoryCache>(), keyVaultClient, vault, certificateName));
return identityServerbuilder;
}
/// <summary>
/// Adds a SigningCredentialStore and a ValidationKeysStore that reads the signing certificate from the Azure KeyVault.
/// </summary>
/// <param name="identityServerbuilder">The <see cref="IIdentityServerBuilder"/> to add to.</param>
/// <param name="vault">The Azure KeyVault uri.</param>
/// <param name="certificateName">The name of the certificate to use as the signing certificate.</param>
/// <remarks>Use this if you are using MSI (Managed Service Identity)</remarks>
/// <returns>The <see cref="IIdentityServerBuilder"/>.</returns>
public static IIdentityServerBuilder AddSigningCredentialFromAzureKeyVault(this IIdentityServerBuilder builder, string vault, string certificateName, int signingKeyRolloverTimeInHours)
{
var azureServiceTokenProvider = new AzureServiceTokenProvider();
var authenticationCallback = new KeyVaultClient.AuthenticationCallback(azureServiceTokenProvider.KeyVaultTokenCallback);
var keyVaultClient = new KeyVaultClient(authenticationCallback);
builder.Services.AddMemoryCache();
var sp = builder.Services.BuildServiceProvider();
builder.Services.AddSingleton<ISigningCredentialStore>(new AzureKeyVaultSigningCredentialStore(sp.GetService<IMemoryCache>(), keyVaultClient, vault, certificateName, signingKeyRolloverTimeInHours));
builder.Services.AddSingleton<IValidationKeysStore>(new AzureKeyVaultValidationKeysStore(sp.GetService<IMemoryCache>(), keyVaultClient, vault, certificateName));
return builder;
}
private static async Task<string> GetTokenFromClientSecret(string authority, string resource, string clientId, string clientSecret)
{
var authContext = new AuthenticationContext(authority);
var clientCred = new ClientCredential(clientId, clientSecret);
var result = await authContext.AcquireTokenAsync(resource, clientCred);
return result.AccessToken;
}
}
public class AzureKeyVaultSigningCredentialStore : KeyStore, ISigningCredentialStore
{
private readonly IMemoryCache _cache;
private readonly KeyVaultClient _keyVaultClient;
private readonly string _vault;
private readonly string _certificateName;
private readonly int _signingKeyRolloverTimeInHours;
public AzureKeyVaultSigningCredentialStore(IMemoryCache memoryCache, KeyVaultClient keyVaultClient, string vault, string certificateName, int signingKeyRolloverTimeInHours) : base(keyVaultClient, vault)
{
_cache = memoryCache;
_keyVaultClient = keyVaultClient;
_vault = vault;
_certificateName = certificateName;
_signingKeyRolloverTimeInHours = signingKeyRolloverTimeInHours;
}
public async Task<SigningCredentials> GetSigningCredentialsAsync()
{
// Try get the signing credentials from the cache
if (_cache.TryGetValue("SigningCredentials", out SigningCredentials signingCredentials))
return signingCredentials;
signingCredentials = await GetFirstValidSigningCredentials();
if (signingCredentials == null)
return null;
// Cache it
var options = new MemoryCacheEntryOptions();
options.AbsoluteExpiration = DateTime.Now.AddDays(1);
_cache.Set("SigningCredentials", signingCredentials, options);
return signingCredentials;
}
private async Task<SigningCredentials> GetFirstValidSigningCredentials()
{
// Find all enabled versions of the certificate
var enabledCertificateVersions = await GetAllEnabledCertificateVersionsAsync(_certificateName);
if (!enabledCertificateVersions.Any())
{
return null;
}
// Find the first certificate version that has a passed rollover time
var certificateVersionWithPassedRolloverTime = enabledCertificateVersions
.FirstOrDefault(certVersion => certVersion.Attributes.Created.HasValue && certVersion.Attributes.Created.Value < DateTime.UtcNow.AddHours(-_signingKeyRolloverTimeInHours));
// If no certificate with passed rollovertime was found, pick the first enabled version of the certificate (This can happen if it's a newly created certificate)
if (certificateVersionWithPassedRolloverTime == null)
{
return await GetSigningCredentialsFromCertificateAsync(enabledCertificateVersions.First());
}
else
{
return await GetSigningCredentialsFromCertificateAsync(certificateVersionWithPassedRolloverTime);
}
}
}
public class AzureKeyVaultValidationKeysStore : KeyStore, IValidationKeysStore
{
private readonly IMemoryCache _cache;
private readonly KeyVaultClient _keyVaultClient;
private readonly string _vault;
private readonly string _certificateName;
public AzureKeyVaultValidationKeysStore(IMemoryCache memoryCache, KeyVaultClient keyVaultClient, string vault, string certificateName) : base(keyVaultClient, vault)
{
_cache = memoryCache;
_keyVaultClient = keyVaultClient;
_vault = vault;
_certificateName = certificateName;
}
public async Task<IEnumerable<SecurityKey>> GetValidationKeysAsync()
{
// Try get the signing credentials from the cache
if (_cache.TryGetValue("ValidationKeys", out List<SecurityKey> validationKeys))
return validationKeys;
validationKeys = new List<SecurityKey>();
// Get all the certificate versions (this will also get the currect active version)
var enabledCertificateVersions = await GetAllEnabledCertificateVersionsAsync(_certificateName);
foreach (var certificateItem in enabledCertificateVersions)
{
// Add the security key to validation keys so any JWT tokens signed with a older version of the signing certificate
validationKeys.Add(await GetSecurityKeyFromCertificateAsync(certificateItem));
}
// Add the validation keys to the cache
var options = new MemoryCacheEntryOptions();
options.AbsoluteExpiration = DateTime.Now.AddDays(1);
_cache.Set("ValidationKeys", validationKeys, options);
return validationKeys;
}
}
public abstract class KeyStore
{
private readonly KeyVaultClient _keyVaultClient;
private readonly string _vault;
public KeyStore(KeyVaultClient keyVaultClient, string vault)
{
_keyVaultClient = keyVaultClient;
_vault = vault;
}
internal async Task<List<Microsoft.Azure.KeyVault.Models.CertificateItem>> GetAllEnabledCertificateVersionsAsync(string certificateName)
{
// Get all the certificate versions (this will also get the currect active version)
var certificateVersions = await _keyVaultClient.GetCertificateVersionsAsync(_vault, certificateName);
// Find all enabled versions of the certificate and sort them by creation date in decending order
return certificateVersions
.Where(certVersion => certVersion.Attributes.Enabled.HasValue && certVersion.Attributes.Enabled.Value)
.OrderByDescending(certVersion => certVersion.Attributes.Created)
.ToList();
}
internal async Task<SigningCredentials> GetSigningCredentialsFromCertificateAsync(Microsoft.Azure.KeyVault.Models.CertificateItem certificateItem)
{
var certificateVersionSecurityKey = await GetSecurityKeyFromCertificateAsync(certificateItem);
return new SigningCredentials(certificateVersionSecurityKey, SecurityAlgorithms.RsaSha256);
}
internal async Task<SecurityKey> GetSecurityKeyFromCertificateAsync(Microsoft.Azure.KeyVault.Models.CertificateItem certificateItem)
{
var certificateVersionBundle = await _keyVaultClient.GetCertificateAsync(certificateItem.Identifier.Identifier);
var certificatePrivateKeySecretBundle = await _keyVaultClient.GetSecretAsync(certificateVersionBundle.SecretIdentifier.Identifier);
var privateKeyBytes = Convert.FromBase64String(certificatePrivateKeySecretBundle.Value);
var certificateWithPrivateKey = new X509Certificate2(privateKeyBytes, (string)null, X509KeyStorageFlags.MachineKeySet);
return new X509SecurityKey(certificateWithPrivateKey);
}
}
}
You use the code by calling one of the public methods above from "ConfigureServices" inside your startup.cs like this
var identityServerBuilder = services.AddIdentityServer();
identityServerBuilder.AddSigningCredentialFromAzureKeyVault(Configuration["AzureKeyVault:Url"], "<My Key vault client id>", "<My key vault secret>", "<My Cert Name>", <Signing Key Rollover period in hours>);
or if you are using MSI, you can do it like this:
var identityServerBuilder = services.AddIdentityServer();
identityServerBuilder.AddSigningCredentialFromAzureKeyVault(Configuration["AzureKeyVault:Url"], "<My Cert Name>", <Signing Key Rollover period in hours>);
When Azure auto-renews a certificate (or if you manually create a new version of the cert), won't it immediately become the "current" version of the certificate, which according to this code would cause it to immediately become the new signing credential without first being exposed for a while as a validation key? Without that delay, remote clients won't have a chance to learn about the new key via the discovery document before they start receiving tokens that have been signed with the new key.
A reasonable solution would be to update this code so that it prefers to not use a new key for signing until it is at least X days old. If no alternative key is available (ie: if the new key is the only version of the key), then it will use the new key, but if an older version of the key is still available, then it will use the older key for signing until the new one has been around for a configurable number of days.
Yeah, I think you are right. I’ll take a look at this and make the necessary adjustments to the code.
Thanks for pointing this out :)
@Eneuman @kroymann Thanks for your help!
I have updated the code above and added the abillity to specify a singing key rollover period. It will search the different versions of the certificate and preferable use the one where the rollover time has passed. If not found, it falls back to any enabled version.
@kroymann Please check and see if the code looks okey to you.
That's reasonably close to what I have implemented in my codebase. There are two improvements that could be made:
Here's what I've written for use in our project (based in part on what you originally wrote): https://gist.github.com/kroymann/72952c079dc46dad774b32d6f154404c
Thank you for the feedback. I have made the suggested changes to the code above. Sorting is now handled by the function retrieving the certificate versions instead of relying on MS Api. I cleanup the code and moved some functions into a abstract base class. I also choose to use LINQ for readabilty in some places. Performance isn't a issue here since it all beeing cached.
What's should i consider when picking a SigningKeyRollover length? Any recommended value?
If the downstream services that are receiving and validating your signed tokens are AspNetCore applications using the built-in authentication library, then I believe the default rate at which they will refresh the discovery document is 24 hours. This means that you will need to guarantee that the new key has been exposed as a validation key in the discovery document for at least 24 hours before you start using it to sign anything. Next, if you are using @Eneuman's code from above, you'll see that the values pulled from the AzureKeyVault are cached in memory and are only refreshed every 24 hours, which means the new cert could be 24 hours old before it is ever exposed via the discovery document. Combine those two together and you get a minimum SigningKeyRollover of 48 hours.
Also note that if you using the code above, a good practice would be to go into azure key vault and mark the old certificate version as disabled when you are sure all the downstream services have started using the new certificate (rollover time has passed + a day or two). But this will invalidate any access token signed with the old version of the certificate, so if you are using long lived access token, you need your old certificate to be valid and enabled until the access token time to live + SigningKeyRollover has passed.
Thank you, that was a good explanation.
Thanks for the code. So what is the recommended time to set in Azure KeyVault to set the certificate to auto-renew and based off that time, what is the recommended SigningKeyRollover?
@kroymann I see you've used a transient lifetime for the ISigningCredentialStore and IValidationKeysStore whereas @Eneuman has used a singleton. In my mind singleton would be more appropriate for this?
I have also noticed that @Eneuman version doesn't enumerate pages via GetCertificateVersionsNextAsync()
@sguryev GetCertificateVersionsAsync will retrieve up to 25 versions of the certificate. When using this extension, a best practice would be to remove the expired version from azure key vault after it’s no longer needed (see expire time in previous post). I choose to use GetCertificateVersionsAsync since in my case, I would never reach 25 versions of the same certificate so enumeration was not needed.
@gcbenjamin It really depends on your security requirements and how much you are willing to spend on certificates :) As a base I would say: 1 year renewal for certificate. Automatically renew it after 11 months. Set rollover time to 3 weeks. If you are using long lived access tokens you might need to adjust this.
Also make sure to add a health check that validates that the certificate you are using is not going to expire in 3 weeks. Expired CCs is no fun ;)
@kroymann I see you've used a transient lifetime for the ISigningCredentialStore and IValidationKeysStore whereas @Eneuman has used a singleton. In my mind singleton would be more appropriate for this?
@gcbenjamin Sorry for the delayed response (the notification from GitHub ended up in my spam folder for some reason). The lifetime of those services as implemented in my gist isn't super important as there's no stored state. Singleton would also be fine, but it doesn't really matter too much.
What is the status on this - I could really use the extensions and was just wondering. Will it be added to the official IdentityServer4 package at some point?
Hi @Eneuman and thanks for the fantastic code of AddSigningCredentialFromAzureKeyVault
above.
I just noticed the need for a reusable module that does exactly this and was just about to start implementing a package for it when I found your code. Do you have any plans on publishing it to NuGet? I would be more than happy to help out.
We have created a couple of libraries for authentication under Active Login, the major one handles BankID in .NET: https://github.com/ActiveLogin/ActiveLogin.Authentication
It would be really nice to get your code published it to NuGet for easier access. If you have no such plans yourself, I would be more than happy to host it as part of Active Login with full credits to you. We have all the code signing certificates etc in place to handle all the "yak shaving" for NuGet package publishing.
/Peter
Hi @PeterOrneholm It sounds like a great idea to publish it as a NuGet. I don’t have time to do it but if you want to host it as part of Active Login I’m all for it. I was about to start looking for a package that could handle BankID, how funny :)
//Per
It sounds like a great idea to publish it as a NuGet. I don’t have time to do it but if you want to host it as part of Active Login I’m all for it.
Cool, I'll have a look at it and notify you on the progress :)
I was about to start looking for a package that could handle BankID, how funny :)
Hehe, nice! Let me know what you think and if it fits your needs.
Thanks for contributing this, will be interested to see it become a NuGet as I've also got similar code I was about to refactor for ID4 v3. Consequently, this is now slightly out of date due to the new SecurityKeyInfo change (https://github.com/IdentityServer/IdentityServer4/pull/3561).
Here's a suggested fix:
KeyStore
GetSecurityKeyFromCertificateAsync
=> GetSecurityKeyInfoFromCertificateAsync
Task<SecurityKeyInfo>
=> Task<SecurityKeyInfo>
SecurityKeyInfo
object
Note: I couldn't quickly see a neat way to determine the SigningAlgorithm (as ID4 would want it) so it's hardcoded to
SecurityAlgorithms.RsaSha256
I here. Other neat suggestions welcome.
new SigningCredentials(…)
internal async Task<SigningCredentials> GetSigningCredentialsFromCertificateAsync(Microsoft.Azure.KeyVault.Models.CertificateItem certificateItem)
{
var certificateVersionSecurityKey = await GetSecurityKeyInfoFromCertificateAsync(certificateItem);
return new SigningCredentials(certificateVersionSecurityKey.Key, certificateVersionSecurityKey.SigningAlgorithm);
}
internal async Task<SecurityKeyInfo> GetSecurityKeyInfoFromCertificateAsync(Microsoft.Azure.KeyVault.Models.CertificateItem certificateItem)
{
var certificateVersionBundle = await _keyVaultClient.GetCertificateAsync(certificateItem.Identifier.Identifier);
var certificatePrivateKeySecretBundle = await _keyVaultClient.GetSecretAsync(certificateVersionBundle.SecretIdentifier.Identifier);
var privateKeyBytes = Convert.FromBase64String(certificatePrivateKeySecretBundle.Value);
var certificateWithPrivateKey = new X509Certificate2(privateKeyBytes, (string)null, X509KeyStorageFlags.MachineKeySet);
var key = new X509SecurityKey(certificateWithPrivateKey);
var keyInfo = new SecurityKeyInfo { Key = key, SigningAlgorithm = SecurityAlgorithms.RsaSha256 };
return keyInfo;
}
AzureKeyVaultValidationKeysStore
SecurityKey
=> SecurityKeyInfo
GetSecurityKeyFromCertificateAsync
=> GetSecurityKeyInfoFromCertificateAsync
public async Task<IEnumerable<SecurityKeyInfo>> GetValidationKeysAsync()
{
// Try get the signing credentials from the cache
if (_cache.TryGetValue("ValidationKeys", out List<SecurityKeyInfo> validationKeys))
return validationKeys;
validationKeys = new List<SecurityKeyInfo>();
// Get all the certificate versions (this will also get the currect active version)
var enabledCertificateVersions = await GetAllEnabledCertificateVersionsAsync(_certificateName);
foreach (var certificateItem in enabledCertificateVersions)
{
// Add the security key to validation keys so any JWT tokens signed with a older version of the signing certificate
validationKeys.Add(await GetSecurityKeyInfoFromCertificateAsync(certificateItem));
}
// Add the validation keys to the cache
var options = new MemoryCacheEntryOptions();
options.AbsoluteExpiration = DateTime.Now.AddDays(1);
_cache.Set("ValidationKeys", validationKeys, options);
return validationKeys;
}
Btw, may I also suggest an IdentityServerAzureKeyVaultOptions
object instead of signingKeyRolloverTimeInHours
, which could also include the cache timeout (both could be of type TimeSpan
rather than hours / minute integers).
public class IdentityServerAzureKeyVaultOptions
{
public TimeSpan SigningKeyRolloverTime { get; set; } = new TimeSpan(2, 0, 0, 0);
public TimeSpan DefaultCacheDuration { get; set; } = new TimeSpan(1, 0, 0, 0);
}
Implemented something like this:
public static class IdentityServerAzureKeyVaultConfigurationExtensions
{
...
public static IIdentityServerBuilder AddSigningCredentialFromAzureKeyVault(this IIdentityServerBuilder identityServerbuilder, string vault, string clientId, string clientSecret, string certificateName, IdentityServerAzureKeyVaultOptions options = null)
{
KeyVaultClient.AuthenticationCallback authenticationCallback = (authority, resource, scope) => GetTokenFromClientSecret(authority, resource, clientId, clientSecret);
var keyVaultClient = new KeyVaultClient(authenticationCallback);
identityServerbuilder.Services.AddMemoryCache();
var sp = identityServerbuilder.Services.BuildServiceProvider();
identityServerbuilder.Services.AddSingleton<ISigningCredentialStore>(new AzureKeyVaultSigningCredentialStore(sp.GetService<IMemoryCache>(), keyVaultClient, vault, certificateName, options));
identityServerbuilder.Services.AddSingleton<IValidationKeysStore>(new AzureKeyVaultValidationKeysStore(sp.GetService<IMemoryCache>(), keyVaultClient, vault, certificateName, options));
return identityServerbuilder;
}
...
and in the Store objects as follows:
public class AzureKeyVaultSigningCredentialStore : KeyStore, ISigningCredentialStore
{
...
public AzureKeyVaultSigningCredentialStore(IMemoryCache memoryCache, KeyVaultClient keyVaultClient, string vault, string certificateName, IdentityServerAzureKeyVaultOptions options) : base(keyVaultClient, vault)
{
_cache = memoryCache;
_keyVaultClient = keyVaultClient;
_vault = vault;
_certificateName = certificateName;
_options = options ?? new IdentityServerAzureKeyVaultOptions();
}
public async Task<SigningCredentials> GetSigningCredentialsAsync()
{
// Try get the signing credentials from the cache
if (_cache.TryGetValue("SigningCredentials", out SigningCredentials signingCredentials))
return signingCredentials;
signingCredentials = await GetFirstValidSigningCredentials();
if (signingCredentials == null)
return null;
// Cache it
var cacheOptions = new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = _options.DefaultCacheDuration
};
_cache.Set("SigningCredentials", signingCredentials, cacheOptions);
return signingCredentials;
}
private async Task<SigningCredentials> GetFirstValidSigningCredentials()
{
// Find all enabled versions of the certificate
var enabledCertificateVersions = await GetAllEnabledCertificateVersionsAsync(_certificateName);
if (!enabledCertificateVersions.Any())
{
return null;
}
// Find the first certificate version that has a passed rollover time
var certificateVersionWithPassedRolloverTime = enabledCertificateVersions
.FirstOrDefault(certVersion => certVersion.Attributes.Created.HasValue && certVersion.Attributes.Created.Value < DateTime.UtcNow.Subtract(_options.SigningKeyRolloverTime));
// If no certificate with passed rollovertime was found, pick the first enabled version of the certificate (This can happen if it's a newly created certificate)
if (certificateVersionWithPassedRolloverTime == null)
{
return await GetSigningCredentialsFromCertificateAsync(enabledCertificateVersions.First());
}
else
{
return await GetSigningCredentialsFromCertificateAsync(certificateVersionWithPassedRolloverTime);
}
}
}
...
Hi All
I have started to improve the Key Vault handling, certificate initialization in this package. I have used your comments, and code for the first version. I decided to keep the handling and the ID4 config separate for the moment. The certificates in my process are cleaned up in Devops etc.
2 certificates can now be used from the Key Vault so that existing sessions will continue to work after an update. The newest certificate is used to sign, second newest to support the existing sessions.
Would you give me feedback, improvement suggestions what you think could be improved?
Maybe we could create a full key vault integration direct in the ID4 config. Opinions?
Would be grateful for feedback, PRs
Greetings Damien
Hi @damienbod - can you please share your implementation - how do you create the signing certificate and process for renewal as well? I think it will be useful for others. In your current implementation is probable necessary to restart app for retrieving updated certs, right? Thank you.
Hi @skoruba These are just scripts using azure cli called from Azure Devops. Yes the app needs to be restarted.
I plan, when I get the time, to add a second extension which does the full rollover, like some of the code above. This would be nice for servers, which would like to rotate without a restart.
az keyvault certificate create --vault-name damienbod -n demoCert --policy `@defaultpolicy.json
Greetings Damien
Thanks for sharing some tips. I will definetely use your current implementation in my project, it is very helpful. I will look forward to your second extension.
thanks for the feedback. Cool that it's useful for you.
Greetings Damien
Just thought I'd share my implementation which is mostly copied from @Eneuman's and @Simon-Gregory-LG's code snippets above.
I'm pretty new to OAuth and certs so any feedback on improvements or obvious bugs then please let give me a shout :)
GetOrCreateAsync
method on IMemoryStore
doesn't appear to be thread-safe. I'll have to keep an eye on the performance impact of this.using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using System.Threading;
using System.Threading.Tasks;
using IdentityServer4.Models;
using IdentityServer4.Stores;
using Microsoft.Azure.KeyVault;
using Microsoft.Azure.KeyVault.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.IdentityModel.Tokens;
namespace Example
{
public sealed class AzureKeyVaultSigningCredentialsStore : ISigningCredentialStore, IValidationKeysStore
{
private const string MemoryCacheKey = "OAuthCerts";
private const string SigningAlgorithm = SecurityAlgorithms.RsaSha256;
private readonly SemaphoreSlim _cacheLock;
private readonly KeyVaultClient _keyVaultClient;
private readonly IMemoryCache _cache;
private readonly IKeyVaultConfig _keyVaultConfig;
public AzureKeyVaultSigningCredentialsStore(KeyVaultClient keyVaultClient, IKeyVaultConfig keyVaultConfig, IMemoryCache cache)
{
_keyVaultClient = keyVaultClient;
_keyVaultConfig = keyVaultConfig;
_cache = cache;
// MemoryCache.GetOrCreateAsync does not appear to be thread safe:
// https://github.com/aspnet/Caching/blob/56447f941b39337947273476b2c366b3dffde565/src/Microsoft.Extensions.Caching.Abstractions/MemoryCacheExtensions.cs#L92-L106
_cacheLock = new SemaphoreSlim(1);
}
public async Task<SigningCredentials> GetSigningCredentialsAsync()
{
await _cacheLock.WaitAsync();
try
{
var (active, _) = await _cache.GetOrCreateAsync(MemoryCacheKey, RefreshCacheAsync);
return active;
}
finally
{
_cacheLock.Release();
}
}
public async Task<IEnumerable<SecurityKeyInfo>> GetValidationKeysAsync()
{
await _cacheLock.WaitAsync();
try
{
var (_, secondary) = await _cache.GetOrCreateAsync(MemoryCacheKey, RefreshCacheAsync);
return secondary;
}
finally
{
_cacheLock.Release();
}
}
private async Task<(SigningCredentials active, IEnumerable<SecurityKeyInfo> secondary)> RefreshCacheAsync(ICacheEntry cache)
{
cache.AbsoluteExpiration = DateTime.Now.AddDays(1);
var enabledCertificateVersions = await GetAllEnabledCertificateVersionsAsync(_keyVaultClient, _keyVaultConfig.KeyVaultName, _keyVaultConfig.KeyVaultCertificateName);
var active = await GetActiveCertificateAsync(_keyVaultClient, _keyVaultConfig.KeyVaultRolloverHours, enabledCertificateVersions);
var secondary = await GetSecondaryCertificatesAsync(_keyVaultClient, enabledCertificateVersions);
return (active, secondary);
static async Task<List<CertificateItem>> GetAllEnabledCertificateVersionsAsync(KeyVaultClient keyVaultClient, string keyVaultName, string certName)
{
// Get all the certificate versions
var certificateVersions = await keyVaultClient.GetCertificateVersionsAsync($"https://{keyVaultName}.vault.azure.net/", certName);
// Find all enabled versions of the certificate and sort them by creation date in decending order
return certificateVersions
.Where(certVersion => certVersion.Attributes.Enabled == true)
.Where(certVersion => certVersion.Attributes.Created.HasValue)
.OrderByDescending(certVersion => certVersion.Attributes.Created)
.ToList();
}
static async Task<SigningCredentials> GetActiveCertificateAsync(KeyVaultClient keyVaultClient, int rollOverHours, List<CertificateItem> enabledCertificateVersions)
{
// Find the first certificate version that is older than the rollover duration
var rolloverTime = DateTimeOffset.UtcNow.AddHours(-rollOverHours);
var filteredEnabledCertificateVersions = enabledCertificateVersions
.Where(certVersion => certVersion.Attributes.Created < rolloverTime)
.ToList();
if (filteredEnabledCertificateVersions.Any())
{
return new SigningCredentials(
await GetCertificateAsync(keyVaultClient, filteredEnabledCertificateVersions.First()),
SigningAlgorithm);
}
else if (enabledCertificateVersions.Any())
{
// If no certificates older than the rollover duration was found, pick the first enabled version of the certificate (this can happen if it's a newly created certificate)
return new SigningCredentials(
await GetCertificateAsync(keyVaultClient, enabledCertificateVersions.First()),
SigningAlgorithm);
}
else
{
// No certificates found
return default;
}
}
static async Task<List<SecurityKeyInfo>> GetSecondaryCertificatesAsync(KeyVaultClient keyVaultClient, List<CertificateItem> enabledCertificateVersions)
{
var keys = await Task.WhenAll(enabledCertificateVersions.Select(item => GetCertificateAsync(keyVaultClient, item)));
return keys
.Select(key => new SecurityKeyInfo { Key = key, SigningAlgorithm = SigningAlgorithm })
.ToList();
}
static async Task<X509SecurityKey> GetCertificateAsync(KeyVaultClient keyVaultClient, CertificateItem item)
{
var certificateVersionBundle = await keyVaultClient.GetCertificateAsync(item.Identifier.Identifier);
var certificatePrivateKeySecretBundle = await keyVaultClient.GetSecretAsync(certificateVersionBundle.SecretIdentifier.Identifier);
var privateKeyBytes = Convert.FromBase64String(certificatePrivateKeySecretBundle.Value);
var certificateWithPrivateKey = new X509Certificate2(privateKeyBytes, (string)null, X509KeyStorageFlags.MachineKeySet);
return new X509SecurityKey(certificateWithPrivateKey);
}
}
}
}
Register it in DI using:
using System.Diagnostics.CodeAnalysis;
using IdentityServer4.Stores;
using Microsoft.Azure.KeyVault;
using Microsoft.Azure.Services.AppAuthentication;
using Microsoft.Extensions.DependencyInjection;
namespace Example
{
public static class AzureKeyVaultServiceCollectionExtensions
{
public static IServiceCollection AddKeyVaultSigningCredentials(this IServiceCollection @this)
{
var azureServiceTokenProvider = new AzureServiceTokenProvider();
var authenticationCallback = new KeyVaultClient.AuthenticationCallback(azureServiceTokenProvider.KeyVaultTokenCallback);
var keyVaultClient = new KeyVaultClient(authenticationCallback);
@this.AddMemoryCache();
@this.AddSingleton(keyVaultClient);
@this.AddSingleton<AzureKeyVaultSigningCredentialsStore>();
@this.AddSingleton<ISigningCredentialStore>(services => services.GetRequiredService<AzureKeyVaultSigningCredentialsStore>());
@this.AddSingleton<IValidationKeysStore>(services => services.GetRequiredService<AzureKeyVaultSigningCredentialsStore>());
return @this;
}
}
}
You can then register the IKeyVaultConfig
interface however you do your app config:
public interface IKeyVaultConfig
{
string KeyVaultName { get; }
string KeyVaultCertificateName { get; }
int KeyVaultRolloverHours { get; }
}
I've used MSI (Managed Service Identity) but it's easy enough to adapt it to use a client secret in the AddKeyVaultSigningCredentials
method.
@Eneuman Would you be interested in adding your code here?
Saw this issue: SigningKey Azure Key Vault Provider
I have some helpers for this, maybe your solution is better. I use this as a template or quick starter for creating STS servers, which can be deployed easily to Azure App Services or IIS
Greetings Damien