damienbod / IdentityServer4AspNetCoreIdentityTemplate

An ASP.NET Core 6.0 IdentityServer4 Identity Bootstrap 4 template with localization
https://www.nuget.org/packages/IdentityServer4AspNetCoreIdentityTemplate/
MIT License
326 stars 64 forks source link

Improve cert handling for Azure deployments #30

Open damienbod opened 6 years ago

damienbod commented 6 years ago

@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

Eneuman commented 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>);
kroymann commented 5 years ago

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.

Eneuman commented 5 years ago

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 :)

damienbod commented 5 years ago

@Eneuman @kroymann Thanks for your help!

Eneuman commented 5 years ago

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.

kroymann commented 5 years ago

That's reasonably close to what I have implemented in my codebase. There are two improvements that could be made:

  1. You're doing identical work to retrieve the complete list of certs and construct X509SecurityKeys from all of them in both the ValidationKeyStore and SigningCredentialStore classes. You could combine them into a single class that implements both interfaces, and which does all that work just once.
  2. Your logic for picking the best signing key credential should be explicitly trying to find the newest version of the cert that matches the required conditions, rather than simply the first version of the cert that matches them. That way, you ensure that new versions of the cert go into effect asap after the SigningKeyRollover period has elapsed. I don't know if there is any guarantee on the order in which the cert versions are returned by the key vault. If they come back sorted oldest to newest, then your current code will choose the oldest version of the cert (that could stop working at any moment) rather than the new version.

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

Eneuman commented 5 years ago

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.

abergs commented 5 years ago

What's should i consider when picking a SigningKeyRollover length? Any recommended value?

kroymann commented 5 years ago

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.

Eneuman commented 5 years ago

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.

abergs commented 5 years ago

Thank you, that was a good explanation.

gcbenjamin commented 5 years ago

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?

sguryev commented 5 years ago

I have also noticed that @Eneuman version doesn't enumerate pages via GetCertificateVersionsNextAsync()

Eneuman commented 5 years ago

@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.

Eneuman commented 5 years ago

@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 commented 5 years ago

@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.

rvplauborg commented 4 years ago

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?

PeterOrneholm commented 4 years ago

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

Eneuman commented 4 years ago

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

PeterOrneholm commented 4 years ago

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.

Simon-Gregory-LG commented 4 years ago

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:

        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;
        }
        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;
        }
Simon-Gregory-LG commented 4 years ago

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);
            }
        }
    }
...
damienbod commented 4 years ago

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

https://github.com/damienbod/IdentityServer4AspNetCoreIdentityTemplate/blob/master/content/StsServerIdentity/Startup.cs#L212-L233

https://github.com/damienbod/IdentityServer4AspNetCoreIdentityTemplate/blob/master/content/StsServerIdentity/Services/Certificate/CertificateService.cs

https://github.com/damienbod/IdentityServer4AspNetCoreIdentityTemplate/blob/master/content/StsServerIdentity/Services/Certificate/KeyVaultCertificateService.cs

Greetings Damien

skoruba commented 4 years ago

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.

damienbod commented 4 years ago

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

skoruba commented 4 years ago

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.

damienbod commented 4 years ago

thanks for the feedback. Cool that it's useful for you.

Greetings Damien

connelhooley commented 4 years ago

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 :)

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.