Authenticating to Azure by using Cloud KMS and client assertions

By using workload identity federation, we can let applications use Azure credentials to authenticate to Google Cloud. That’s useful if we have an application that runs on Azure and needs access to Google APIs.

But what if we are in the opposite situation, where we have an application on Google Cloud that needs access to Azure APIs?

Client secrets

One option is to authenticate a service principal by using a client secret:

  1. In Azure AD, create an application and generate a client secret.
  2. In Google Cloud, store the client secret in secret manager.
  3. Attach a service account to the compute resource that runs our application
  4. Grant the service account access to the respective secret in secret manager.

This approach works, but it’s not great:

  • The secret might be leaked between (1) and (2), especially if multiple people are involved in the process and the secret is passed over an insecure channel.
  • The application needs to load the secret into memory, from which it could be extracted.

Service account certificates (or not)

Instead of using client secrets, Azure also allows clients to authenticate by using an assertion. Assertions are JWTs that assert a client’s identity and look like this:

{
    "kid": "[certificate hash]",
    "alg": "RS256"
}.{
    "jti": "[random-guid]",
    "exp": 1630649504,
    "iss": "[client-id]",
    "nbf": 1630648904,
    "aud": "https://login.microsoftonline.com/[tenant-id]/v2.0",
    "sub": "[client-id]"
}.[Signature]

To be recognized by Azure, assertions must be signed by using an RSA private key, and the corresponding public key must be uploaded to Azure in the form of a X.509 certificate.

Overall, the mechanism to use assertions is quite similar to how service account keys work on Google Cloud.

Using assertions and certificates to authenticate to Azure sounds like an interesting option: Service accounts on Google Cloud already have a (Google-managed) RSA private key, and the corresponding public key is available as a X.509 certificate on https://www.googleapis.com/service _accounts/v1/metadata/x509/[email protected]. We can just upload this certificate to Azure AD, and let the application use its attached service account to sign the assertion and authenticate!

Unfortunately, the approach doesn’t work in practice because of key rotation: The Google-managed keys are rotated every few days, and once they are, the setup breaks.

KMS-based certificates

But not all is lost! Instead of using a service account key, we can use a KMS asymmetric key to sign assertions. KMS gives us full control over whether and when to rotate keys, and gives us even more control over how and where to store the key.

So the idea is:

  1. In Azure AD, create an application.
  2. In Cloud KMS, create an RSA signing key.
  3. Export the public key as an X.509 certificate and upload it to the Azure AD application. Cloud KMS doesn’t support X.509 certificates out of the box, but we already know how to add that capability.

With that setup in place, an app running on Google Cloud can:

  1. Use an attached service account to obtain a Google access token.
  2. Use the access token to access Cloud KMS and let it sign an assertion.
  3. Send the assertion to Azure AD to obtain an Azure access token.

Sequence

Although the overall process is more complex than using a client secret, it has several advantages:

  • We don’t need to manage any secrets.
  • Because the RSA private key is managed by Cloud KMS, it’s never revealed, can’t be exported, and is never loaded into memory.
  • We can even use Cloud HSM if we need to.

Authenticating with a KMS-based certificate

Let’s reuse the KmsSignatureGenerator class from last time and create a class that lets us:

  1. Create an X.509 certificate for a KMS signing key.
  2. Obtain an Azure access token by using a signed assertion.

Here’s the code:

class AzureClientAdapter
{
  private static readonly DateTime UnixEpoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
  private static readonly TimeSpan AssertionValidity = TimeSpan.FromMinutes(10);

  private readonly KmsSignatureGenerator signer;
  private readonly Guid tenantId;
  private readonly Guid clientId;

  public AzureClientAdapter(
    KmsSignatureGenerator certificateSigner,
    Guid tenantId,
    Guid clientId)
  {
    this.signer = certificateSigner;
    this.tenantId = tenantId;
    this.clientId = clientId;
  }

  public X509Certificate2 GetCertificate()
  {
    //
    // Create a certificate for the KMS signing key. The certificate
    // must be the same every time we re-create it - otherwise its
    // hash changes and Azure won't recognize it anymore.
    // So we must use the same metadata (name, expiry, etc) every
    // single time.
    //
    // (1) Create a certificate signing request for the public key.
    //
    var csr = new CertificateRequest(
      new X500DistinguishedName("CN=Azure adapter"),
      this.signer.PublicKey,
      HashAlgorithmName.SHA256);

    //
    // (2) Sign the certificate signing request using the KMS
    // signing key, effectively creating a self-signed certificate.
    //
    return csr.Create(
      csr.SubjectName,
      this.signer,
      new DateTime(2020, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc),
      new DateTime(9999, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc),
      new byte[] { (byte)1 });
  }

  public async Task<AuthenticationResult> AcquireTokenAsync(
    IEnumerable<string> scopes,
    CancellationToken cancellationToken)
  {
    //
    // (1) Create claims for an assertion.
    //
    // https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-net-client-assertions#alternative-method
    //
    var issuedAt = DateTime.UtcNow;
    var header = new Hashtable()
    {
      { "alg", "RS256"},
      { "kid",  Base64UrlEncoder.Encode(GetCertificate().GetCertHash()) }
    };
    var body = new Hashtable()
    {
      { "aud", $"https://login.microsoftonline.com/{this.tenantId}/v2.0" },
      { "iss", this.clientId.ToString() },
      { "jti", Guid.NewGuid().ToString() },
      { "sub", this.clientId.ToString() },
      { "nbf", ((long)(issuedAt - UnixEpoch).TotalSeconds) },
      { "exp", ((long)(issuedAt.Add(AssertionValidity) - UnixEpoch).TotalSeconds) },
    };

    //
    // (2) Create a JWT header and body.
    //
    string unsignedToken = string.Concat(
      Base64UrlEncoder.Encode(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(header))),
      ".",
      Base64UrlEncoder.Encode(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(body))));

    //
    // (3) Use KMS to sign the JWT header and body.
    //
    var signature = this.signer.SignData(
      Encoding.UTF8.GetBytes(unsignedToken),
      HashAlgorithmName.SHA256);

    //
    // (4) Append signature. We now have a proper, signed JWT.
    //
    var signedToken = string.Concat(
      unsignedToken,
      ".",
      Base64UrlEncoder.Encode(signature));

    //
    // (5) Use JWT as assertion.
    //
    var application = ConfidentialClientApplicationBuilder
      .Create(this.clientId.ToString())
      .WithAuthority(AzureCloudInstance.AzurePublic, this.tenantId)
      .WithClientAssertion(signedToken)
      .Build();

    //
    // (6) Acquire an access token.
    //
    return await application
      .AcquireTokenForClient(scopes)
      .ExecuteAsync()
      .ConfigureAwait(false);
  }
}

Note that the code is using 3 NuGet packages:

  • Google.Apis.CloudKMS.v1 (to access KMS)
  • Microsoft.Identity.Client (to talk to Azure)
  • Microsoft.IdentityModel.Tokens (for some convenience methods)

Using the AzureClientAdapter, we can now obtain an Azure access token in just a few lines of code – all without having to manage any secrets:

var kmsService = new CloudKMSService(
  new Google.Apis.Services.BaseClientService.Initializer()
  {
    HttpClientInitializer = googleCredential
  });   

var adapter = new AzureClientAdapter(
  new KmsSignatureGenerator(
    kmsService,
    "projects/my-project/locations/global/keyRings/my-keyring/cryptoKeys/my-key/cryptoKeyVersions/1"),
  new Guid("60e24c4..."),  // Tenant ID
  new Guid("0dd4af0f...")); // Client ID
  
var token = await adapter.AcquireTokenAsync(   
  new[] { "https://graph.microsoft.com/.default" },    
  CancellationToken.None);

Any opinions expressed on this blog are Johannes' own. Refer to the respective vendor’s product documentation for authoritative information.
« Back to home