How to Handle Credential Provisioning and Key Rolling with the Microsoft Graph API

Not too long ago I investigated the options to manage the lifecycle of Azure Active Directory app registrations at scale. Most importantly, it needed to be fully automated; the numbers are simply too large to have manual steps in the entire process. For obvious reasons, the Microsoft Graph API plays a big role in making this a reality. And while the documentation and samples are pretty comprehensive (especially for the more common use cases), I stumbled upon a little gem in the API that’s not documented at all, and only sparsely so in the documentation for the Azure AD Graph API (the predecessor of the Microsoft Graph). It’s the addKey (and removeKey) action on the Application object, and in the end it enabled me to do key rolling with nothing more than direct communication between the registered app and the Graph API. But it took me half a day to get it to work, so I’m sharing my findings here; maybe it saves someone that half a day. I’ll be going through the details of how this works; if you’re just looking for the end-to-end solution, just skip right over there.

Let’s start with an outline of what we’re trying to achieve. First of all, upon provisioning a new app, we want to provision it with a temporary key of some sort. This temp key should enable the app to generate and register its own key that has a more extended validity period. That way, we want to ensure that only the app itself and Azure AD have knowledge of keys that are valid for a prolonged period of time. And secondly, even though the app-generated key should be valid for a longer period, best practices dictate that it should still have an expiration date. Determining a reasonable validity period depends on the context and possible compliancy regulations, but something like 1 or 2 years would be typical. When the expiration date approaches, the app needs to generate a new key, register that with Azure AD, and possibly retract the previous key.

Now, Azure AD app registrations allow for both symmetric and asymmetric (i.e. certificate) keys, but it’s a best practice to use asymmetric keys wherever possible. To add to that, certificate credentials are required for the approach I’m detailing here, so we’re using certificates. All the heavy lifting is done by a request to https://graph.microsoft.com/v1.0/applications/{id}/microsoft.graph.addKey
– which, as said, is not documented anywhere in the documentation. It is mentioned in the Azure AD Graph API documentation, and it was only through the Microsoft.Graph Nuget package that I suspected it might be available and functional in the Microsoft Graph. So based on the Azure AD Graph docs, let’s dissect all the pieces for a valid request.

Authorization header

Of course, every call to the Graph API must include a Bearer token in the Authorization header. There are numerous examples online on how to obtain such a token; one way would be to use the Microsoft Authentication Library (MSAL):


private async Task<string> GetTokenAsync(string appId, X509Certificate2 certificate, string tenantId)
{
var app = ConfidentialClientApplicationBuilder.Create(appId)
.WithAuthority($"https://login.microsoftonline.com/{tenantId}/")
.WithCertificate(certificate)
.Build();
var scopes = new[] { "https://graph.microsoft.com/.default&quot; };
var result = await app.AcquireTokenForClient(scopes).ExecuteAsync();
return result.AccessToken;
}

The interesting bit here is that the token should represent the app for which we’re trying to call addKey. So we don’t need another app identity that has permissions to manage apps in the Azure AD tenant to make this call for us. In fact, that’s not even possible: this addKey action seems to be designed from the ground up to provide self-service key management functionality to registered apps. And the best thing is that the app doesn’t need any special permission for this; a newly registered app with default permissions can do this just fine.

Proof

Moving on to the request body, the proof property is the most interesting one: it’s supposed to be “A signed JWT token used as a proof of possession of the existing keys“. And this existing key “is the private key of one of the application existing certificates“. This is why certificate credentials are required for this approach. Together with some other requirements for this self-signed JWT token, the full code for constructing one looks like this:


private string GetJwtTokenProof(X509Certificate2 signingCert, string appId)
{
var notBefore = DateTime.Now;
var expires = notBefore.AddMinutes(10);
var handler = new JwtSecurityTokenHandler();
var credentials = new X509SigningCredentials(signingCert);
var jwtToken = handler.CreateJwtSecurityToken(appId, "https://graph.windows.net&quot;, null, notBefore, expires, null, credentials);
return handler.WriteToken(jwtToken);
}

Note that this code requires the System.IdentityModel.Tokens.Jwt Nuget package.

Key credential

The details regarding the request body depend on whether or not you’re using the Graph client (as opposed to manually constructing the HTTP calls, for example), but if you are, this is simply a matter a creating a KeyCredential object:


private KeyCredential CreateKeyCredential(X509Certificate2 certificate)
{
return new KeyCredential()
{
Key = certificate.RawData,
Usage = "Verify",
Type = "AsymmetricX509Cert"
};
}

Putting it all together

This is all there is to it to enable an app to make a call to Azure AD to register a new certificate for itself (or revoke one, for that matter). So it nicely fulfills our requirements: we can provision the app with a temporary certificate we create centrally, with a validity of just 1 or 2 days. Using that certificate, the app can self-sign a new certificate, use the temporary one to sign the JWT token proof to register the new one, and then use the newly registered certificate to revoke the temporary one. Equally, when a certificate is about to expire, it can use the same flow to create and register a new one and revoke the old one. The complete code looks like this:


using Microsoft.Graph;
using Microsoft.Identity.Client;
using Microsoft.IdentityModel.Tokens;
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Net.Http.Headers;
using System.Security.Cryptography.X509Certificates;
using System.Threading.Tasks;
namespace AzureADAppManagement
{
public class AppCertificateManager
{
public async Task RollCertificatesAsync(string appId, string appObjectId, string tenantId, X509Certificate2 existingCertificate, X509Certificate2 newCertificate)
{
var graph = GetGraphClient(() => GetTokenAsync(appId, existingCertificate, tenantId));
var keyCredential = CreateKeyCredential(newCertificate);
var proof = GetJwtTokenProof(existingCertificate, appId);
await graph.Applications[appObjectId].AddKey(keyCredential, proof).Request().PostAsync();
// Waiting 120 secs; proceeding immediately may result in failure if the new cert is not fully processed server side
// Not sure how much time is appropriate here, to be honest. 120 secs seems excessive, but 20 secs for example has proven too short
await Task.Delay(120000);
// Re-init the client to use the new cert for token retrieval
graph = GetGraphClient(() => GetTokenAsync(appId, newCertificate, tenantId));
// Find the keyId for the old certificate
var app = await graph.Applications[appObjectId].Request().GetAsync();
var keyId = app.KeyCredentials.Single(key =>
{
return Convert.ToBase64String(key.CustomKeyIdentifier).Equals(existingCertificate.Thumbprint, StringComparison.OrdinalIgnoreCase);
}).KeyId;
// Create new proof based on the new certificate
proof = GetJwtTokenProof(newCertificate, appId);
// Remove the old certificate
await graph.Applications[appObjectId].RemoveKey(keyId.Value, proof).Request().PostAsync();
}
private GraphServiceClient GetGraphClient(Func<Task<string>> tokenDelegate)
{
return new GraphServiceClient(new DelegateAuthenticationProvider(async (requestMessage) =>
{
var token = await tokenDelegate();
requestMessage
.Headers
.Authorization = new AuthenticationHeaderValue("bearer", token);
}));
}
private async Task<string> GetTokenAsync(string appId, X509Certificate2 certificate, string tenantId)
{
var app = ConfidentialClientApplicationBuilder.Create(appId)
.WithAuthority($"https://login.microsoftonline.com/{tenantId}/")
.WithCertificate(certificate)
.Build();
var scopes = new[] { "https://graph.microsoft.com/.default&quot; };
var result = await app.AcquireTokenForClient(scopes).ExecuteAsync();
return result.AccessToken;
}
private string GetJwtTokenProof(X509Certificate2 signingCert, string appId)
{
var notBefore = DateTime.Now;
var expires = notBefore.AddMinutes(10);
var handler = new JwtSecurityTokenHandler();
var credentials = new X509SigningCredentials(signingCert);
var jwtToken = handler.CreateJwtSecurityToken(appId, "https://graph.windows.net&quot;, null, notBefore, expires, null, credentials);
return handler.WriteToken(jwtToken);
}
private KeyCredential CreateKeyCredential(X509Certificate2 certificate)
{
return new KeyCredential()
{
Key = certificate.RawData,
Usage = "Verify",
Type = "AsymmetricX509Cert"
};
}
}
}

Why I prefer this approach

Of course there are different ways of handling credential provisioning and key rolling. For example: the application could just be provisioned with a centrally generated (symmetric or asymmetric) key that’s intended as the definitive key (during that 1 or 2 year validity period). However, this would mean that this central agent has, at some point in time, knowledge of these long-term credentials, which would increase the risk associated and therefore the measures taken to properly protect it. The same applies to key rolling: you could have the apps call into a custom-built API to signal its desire to renew its key, or you could orchestrate the key rolling process from a central agent altogether. But again, that would imply having these credentials available in a runtime that’s neither client nor Identity Provider. Furthermore, this agent would need extensive permissions on the Microsoft Graph to actually be able to register new credentials. Especially in case this agent is callable by external parties (such as a client initiating a key rolling process), you would need to make very sure that you’ve covered your bases to prevent Elevation of Privilege.

And just to reiterate: the addKey approach works without special Microsoft Graph permissions, and it only works when the call includes a Bearer token that represents the app itself, so the possible attack surface is greatly reduced. Of course you’d still need to centrally provision that initial temporary certificate, so security measures are still applicable for the agent handling that, but the keys it generates can have a very limited validity. And since it plays no part in the key rolling process, it’s not callable from the outside, and is therefore more easily secured.

So, all in all, I really like this hidden gem in the Microsoft Graph API. Let me know what you think in the comments!