Using Azure Key Vault with ASOS and OpenIddict

Last week, I received an email from someone who was asking me whether ASOS or OpenIddict could be used with Azure Key Vault (Microsoft's cloud-based Hardware Security Module offer). Since it's quite a recurring question, I thought it was worth writing a blog post to demonstrate how to do that.

So, good news: this is definitely possible! And thanks to a new project released recently by Oren Novotny, this has never been so easy.

What is a Hardware Security Module?

A HSM is a hardened device – generally a PCI board or a standalone appliance – that is exclusively dedicated to cryptographic operations (data encryption/decryption, data signing/verification, key management, etc.). Though HSMs are complex systems, the main idea is actually simple: key material should never leave the HSM's secure enclave, which is protected against physical or digital intrusions.

As such, when a service requires encrypting or signing data (e.g a JWT access token in our case), it has to ask the Hardware Security Module to execute the cryptographic operation on its behalf.

Use Azure Key Vault to sign the JWT tokens issued by ASOS/OpenIddict

Create a new HSM key and register a client application allowed to access it

If you don't have a key yet, you'll have to create one, register a new application in the Azure Active Directory database and grant it an access to your signing key. If you're not familiar with this procedure, I encourage you to read this tutorial or watch the corresponding video:

Azure Key Vault actually offers two pricing tiers: standard and premium. To be able to generate a HSM-protected key (referred to as "hardware key"), you must choose the premium tier when creating your vault. Don't worry, the difference between the two tiers is unbelievably marginal: at the time of writing, $1/month per RSA 2048-bit key.

Register the aspnet-contrib and rsakeyvaultprovider NuGet feeds

To register the required feeds, simply update or add a NuGet.config file at the root of your project:

1
2
3
4
5
6
7
8
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="NuGet" value="https://api.nuget.org/v3/index.json" />
<add key="aspnet-contrib" value="https://www.myget.org/F/aspnet-contrib/api/v3/index.json" />
<add key="rsakeyvaultprovider" value="https://www.myget.org/F/rsakeyvaultprovider/api/v3/index.json" />
</packageSources>
</configuration>

Install the ASOS/OpenIddict and RSAKeyVaultProvider packages

For that, simply update your .csproj file to register the RSAKeyVaultProvider package and, depending on whether you're using ASOS or OpenIddict, OpenIddict (and OpenIddict.EntityFrameworkCore) or AspNet.Security.OpenIdConnect.Server:

ASOS

1
2
3
4
5
6
7
8
9
10
11
12
13
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>netcoreapp2.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="AspNet.Security.OpenIdConnect.Server" Version="2.0.0-*" />
<PackageReference Include="Microsoft.AspNetCore.All" Version="2.0.0" />
<PackageReference Include="RSAKeyVaultProvider" Version="1.0.0-preview.1.build.16" />
</ItemGroup>

</Project>

OpenIddict

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>netcoreapp2.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.All" Version="2.0.0" />
<PackageReference Include="OpenIddict" Version="2.0.0-*" />
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="2.0.0-*" />
<PackageReference Include="RSAKeyVaultProvider" Version="1.0.0-preview.1.build.16" />
</ItemGroup>

</Project>

This blog post was written for the ASP.NET Core 2.0 RTM versions of ASOS and OpenIddict, so make sure you're using the corresponding packages and not packages designed for ASP.NET Core 1.x.

Retrieve the RSA security key bundle from Azure Key Vault

If you're already familiar with Azure Key Vault's API, the following snippet should be straightforward. The only particularity is the use of the ToRSA(KeyBundle keyBundle) extension provided by the RSAKeyVaultProvider package.

Its task is simple: returning a RSA instance that delegates the signing and decryption operations – that both require having direct access to the private key and thus can't be executed locally – to Azure Key Vault. This way, when ASOS or OpenIddict will need to sign a JWT access or identity token, they'll indirectly call Azure Key Vault's REST endpoints to generate the precious signature instead of doing that locally.

KeyVaultHelper.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public static class KeyVaultHelper
{
public static RsaSecurityKey GetSigningKey()
{
// Note: Azure Key Vault's SDK automatically takes care of
// renewing access tokens when necessary. You don't need to
// handle that yourself as it includes a static cache.
var client = new KeyVaultClient(async (authority, resource, scope) =>
{
var context = new AuthenticationContext(authority);
var credentials = new ClientCredential("{client identifier}", "{client secret}");

var result = await context.AcquireTokenAsync(resource, credentials);
if (result == null)
{
throw new InvalidOperationException(
"An error occurred while retrieving a token from Azure AD.");
}

return result.AccessToken;
});

var key = client.GetKeyAsync("{vault URL}", "{key name}").GetAwaiter().GetResult();
if (key == null)
{
throw new InvalidOperationException(
"An error occurred while retrieving the signing key from Azure Key Vault.");
}

return new RsaSecurityKey(client.ToRSA(key));
}
}

Create custom authorization and test controllers

For the sake of simplicity, I decided to go with a unique authorization controller that can natively work with both ASOS and OpenIddict.

With ASOS, if you prefer handling token requests directly in the provider class, you can move this code to the OpenIdConnectServerProvider.(On)HandleTokenRequest event. For more information, read Creating your own OpenID Connect server with ASOS: implementing the resource owner password credentials grant.

AuthorizationController.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class AuthorizationController : Controller
{
[HttpPost("~/connect/token")]
[Produces("application/json")]
public IActionResult Exchange()
{
var request = HttpContext.GetOpenIdConnectRequest();
if (request.IsPasswordGrantType())
{
// Validate the user credentials.

// Note: to mitigate brute force attacks, you SHOULD strongly consider
// applying a key derivation function like PBKDF2 to slow down
// the password validation process. You SHOULD also consider
// using a time-constant comparer to prevent timing attacks.
if (request.Username != "alice@wonderland.com" ||
request.Password != "P@ssw0rd")
{
return Forbid(OpenIdConnectServerDefaults.AuthenticationScheme);
}

// Create a new ClaimsIdentity holding the user identity.
var identity = new ClaimsIdentity(OpenIdConnectServerDefaults.AuthenticationScheme);
identity.AddClaim(OpenIdConnectConstants.Claims.Subject, "Alice");

var ticket = new AuthenticationTicket(
new ClaimsPrincipal(identity),
OpenIdConnectServerDefaults.AuthenticationScheme);
ticket.SetResources("resource-server");

// Ask ASOS or OpenIddict to generate and return a token response.
return SignIn(ticket.Principal, ticket.Properties, ticket.AuthenticationScheme);
}

throw new NotSupportedException("The specified grant type is not supported.");
}
}
TestController.cs
1
2
3
4
5
6
7
8
9
public class TestController : Controller
{
[Authorize]
[HttpGet("~/test")]
public string Test()
{
return User.GetClaim(OpenIdConnectConstants.Claims.Subject);
}
}

Register ASOS or OpenIddict in the ASP.NET Core pipeline

The handler registration part slightly differs depending on whether you're using ASOS or OpenIddict, but in both cases you'll have the JWT bearer handler and register it as the default scheme in the ASP.NET Core 2.0 authentication options:

ASOS

Startup.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddAuthentication(options =>
{
options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
})

.AddJwtBearer(options =>
{
// Note: make sure you're replacing this URL
// by the absolute URL of your application.
options.Authority = "http://localhost:10444/";
options.Audience = "resource-server";
options.RequireHttpsMetadata = false;

options.SecurityTokenValidators.Clear();
options.SecurityTokenValidators.Add(new JwtSecurityTokenHandler
{
// Disable the built-in JWT claims mapping feature.
InboundClaimTypeMap = new Dictionary<string, string>()
});
})

.AddOpenIdConnectServer(options =>
{
options.AllowInsecureHttp = true;
options.TokenEndpointPath = "/connect/token";

// Register the HSM signing key.
options.SigningCredentials.AddKey(KeyVaultHelper.GetSigningKey());

options.AccessTokenHandler = new JwtSecurityTokenHandler
{
// Disable the built-in JWT claims mapping feature.
OutboundClaimTypeMap = new Dictionary<string, string>()
};

options.Provider.OnValidateTokenRequest = context =>
{
if (!context.Request.IsPasswordGrantType())
{
context.Reject(
error: OpenIdConnectConstants.Errors.UnsupportedGrantType,
description: "The specified grant type is not supported.");

return Task.CompletedTask;
}

context.Skip();

return Task.CompletedTask;
};

options.Provider.OnHandleTokenRequest = context =>
{
// Note: SkipHandler() is used to force ASOS to invoke MVC
// so that token requests can be handled in a MVC controller.
context.SkipHandler();

return Task.CompletedTask;
};
});
}

public void Configure(IApplicationBuilder app)
{
app.UseDeveloperExceptionPage();

app.UseAuthentication();

app.UseMvc();
}
}

OpenIddict

Startup.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddAuthentication(options =>
{
options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
})

.AddJwtBearer(options =>
{
// Note: make sure you're replacing this URL
// by the absolute URL of your application.
options.Authority = "http://localhost:30940/";
options.Audience = "resource-server";
options.RequireHttpsMetadata = false;

options.SecurityTokenValidators.Clear();
options.SecurityTokenValidators.Add(new JwtSecurityTokenHandler
{
// Disable the built-in JWT claims mapping feature.
InboundClaimTypeMap = new Dictionary<string, string>()
});
});

services.AddDbContext<DbContext>(options =>
{
options.UseInMemoryDatabase(nameof(DbContext));
options.UseOpenIddict();
});

services.AddOpenIddict(options =>
{
options.AddEntityFrameworkCoreStores<DbContext>();

options.EnableTokenEndpoint("/connect/token");
options.AllowPasswordFlow();
options.DisableHttpsRequirement();
options.UseJsonWebTokens();

// Register the HSM signing key.
options.AddSigningKey(KeyVaultHelper.GetSigningKey());
});

services.AddMvc();
}

public void Configure(IApplicationBuilder app)
{
app.UseDeveloperExceptionPage();

app.UseAuthentication();

app.UseMvc();
}
}

Test your application

Retrieve a JWT access token signed by Azure Key Vault

To retrieve an access token, send a POST request to /connect/token with the grant_type=password parameter and the user credentials:

1
2
3
4
5
POST /connect/token HTTP/1.1
Host: localhost:10444
Content-Type: application/x-www-form-urlencoded

grant_type=password&username=alice%40wonderland.com&password=P%40ssw0rd

If the credentials are valid, you'll get a JSON response containing the access token:

1
2
3
4
5
6
{
"resource": "resource-server",
"token_type": "Bearer",
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjlIU1FUVFlRVENQV1NQWlFVNVo2V0pfSU9MUi1CNU1VR1daVE9MRkEiLCJ0eXAiOiJKV1QifQ.eyJzdWIiOiJBbGljZSIsInRva2VuX3VzYWdlIjoiYWNjZXNzX3Rva2VuIiwianRpIjoiMDBkNDI1OTktMDVjMi00YmU1LTgyZGQtZmYzZjM4NmU1NjE0IiwiYXVkIjoicmVzb3VyY2Utc2VydmVyIiwibmJmIjoxNTAyODAzOTc0LCJleHAiOjE1MDI4MDc1NzQsImlhdCI6MTUwMjgwMzk3NCwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDozMDk0MC8ifQ.H5scgtaqe3V_p-ibC2HzxUCForYlm5kuPP1wJKEdL-I44BVmPxCCF8s0r4SciLVv-hGpATIFK9eiuex0jkgLMDJ40vLVnUMOUkeUcqrBrIHkIrf-s7kenpuPA3Xljnd-IS6ParF-nsSs_8t8vxIKWPM0wcpEQo8NKLkp6ptc-s6D3PwIXogY3WiFq3iKIVxRZF-L-RdfKHWSWKlKk3ZyT9Plf9OoMpvNejULynIfa51_ZQEgBR5LyTxUK-XIxRL_gz2fLadmZQX8nHs4Z4DvIKw7KQTbf_t1NOtsLekudmMaJSEheAVdBqy_-AQtdL_Xo6fO3UQn_Hmd96FUUaSo9A",
"expires_in": 3600
}

Send an API request to your test controller

To send an authenticated request, simply attach the bearer token to the Authorization header using the following syntax: Authorization: Bearer [your bearer token] (without the square brackets)

1
2
3
GET /test HTTP/1.1
Host: localhost:10444
Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjlIU1FUVFlRVENQV1NQWlFVNVo2V0pfSU9MUi1CNU1VR1daVE9MRkEiLCJ0eXAiOiJKV1QifQ.eyJzdWIiOiJBbGljZSIsInRva2VuX3VzYWdlIjoiYWNjZXNzX3Rva2VuIiwianRpIjoiMDBkNDI1OTktMDVjMi00YmU1LTgyZGQtZmYzZjM4NmU1NjE0IiwiYXVkIjoicmVzb3VyY2Utc2VydmVyIiwibmJmIjoxNTAyODAzOTc0LCJleHAiOjE1MDI4MDc1NzQsImlhdCI6MTUwMjgwMzk3NCwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDozMDk0MC8ifQ.H5scgtaqe3V_p-ibC2HzxUCForYlm5kuPP1wJKEdL-I44BVmPxCCF8s0r4SciLVv-hGpATIFK9eiuex0jkgLMDJ40vLVnUMOUkeUcqrBrIHkIrf-s7kenpuPA3Xljnd-IS6ParF-nsSs_8t8vxIKWPM0wcpEQo8NKLkp6ptc-s6D3PwIXogY3WiFq3iKIVxRZF-L-RdfKHWSWKlKk3ZyT9Plf9OoMpvNejULynIfa51_ZQEgBR5LyTxUK-XIxRL_gz2fLadmZQX8nHs4Z4DvIKw7KQTbf_t1NOtsLekudmMaJSEheAVdBqy_-AQtdL_Xo6fO3UQn_Hmd96FUUaSo9A

If the access token is valid, you'll get the user identifier as returned by the API:

1
Alice

To confirm that the access token is correctly signed by the HSM key, you can use Application Insights' dependencies tab (you should see an HTTP POST request pointing to https://{vault URL}/keys/{key name}/{key version}/sign?api-version=2016-10-01).

Got a question? Don't hesitate to post a comment!