Skip to content
Addi edited this page Jun 23, 2024 · 15 revisions

MS Graph addKey API can be used to add a new certficate to any application. The idea is to automate rolling application expiring keys via addKey or removeKey.

Source code can be found here

Add a certificate to an existing application

⛔ applications that don’t have any existing valid certificates (i.e.: no certificates have been added yet, or all certificates have expired), won’t be able to use this service action. Update application can be used to perform an update instead.

Important

! This service actions are mainly used to help apps to rotate their own keys and only supported for app-only flow.

Prerequisite

  • Valid certificate. (For the sake of this example, we will use a self-signed certificate)
  • Consent to the needed permissions.
  • Registered application.
  • Rest API Client tool.

Caution Using a self-signed certificate is only recommended for development, not production.

Option 1: Create and export your public certificate without a private key

$cert = New-SelfSignedCertificate -Subject "CN={certificateName}" -CertStoreLocation "Cert:\CurrentUser\My" -KeyExportPolicy Exportable -KeySpec Signature -KeyLength 2048 -KeyAlgorithm RSA -HashAlgorithm SHA256    ## Replace {certificateName}
Export-Certificate -Cert $cert -FilePath "C:\Users\admin\Desktop\{certificateName}.cer"   ## Specify your preferred location and replace {certificateName}

Option 2: Create and export your public certificate with its private key

 $cert = New-SelfSignedCertificate -Subject "CN={certificateName}" -CertStoreLocation "Cert:\CurrentUser\My" -KeyExportPolicy Exportable -KeySpec Signature -KeyLength 2048 -KeyAlgorithm RSA -HashAlgorithm SHA256    ## Replace {certificateName}
Export-Certificate -Cert $cert -FilePath "C:\Users\admin\Desktop\{certificateName}.cer"   ## Specify your preferred location and replace {certificateName}
$mypwd = ConvertTo-SecureString -String "{myPassword}" -Force -AsPlainText  ## Replace {myPassword}
Export-PfxCertificate -Cert $cert -FilePath "C:\Users\admin\Desktop\{privateKeyName}.pfx" -Password $mypwd   ## Specify your preferred location and replace {privateKeyName}

In this example, we will generate accessToken for authentication via certificate credentials instead of client secret. To enable this, we need to generate a JSON Web Token (JWT) assertion signed with a certificate owned by the application.

To compute the assertion, you can use one of the many JWT libraries in the language of your choice - MSAL supports this using .WithCertificate(). The information is carried by the token in its Header, Claims, and Signature.

In this tutorial, we will make use of the official assertion format documented here to generate client_assertion.

The GenerateClientAssertion method will generate client_assertion

public string GenerateClientAssertion(string aud, string clientId, X509Certificate2 signingCert, string tenantID)
{
    Guid guid = Guid.NewGuid();

            // aud and iss are the only required claims.
            var claims = new Dictionary<string, object>()
            {
                { "aud", aud },
                { "iss", clientId },
                { "sub", clientId },
                { "jti", guid}
            };

            // token validity should not be more than 10 minutes
            var now = DateTime.UtcNow;
            var securityTokenDescriptor = new Microsoft.IdentityModel.Tokens.SecurityTokenDescriptor
            {
                Claims = claims,
                NotBefore = now,
                Expires = now.AddMinutes(10),
                SigningCredentials = new X509SigningCredentials(signingCert)
            };

            var handler = new JsonWebTokenHandler();
            // Get Client Assertion
            var client_assertion = handler.CreateToken(securityTokenDescriptor);

            return client_assertion;;
}

Then, the client_assertion will be used to generate accessToken using client credentials flow via GenerateAccessTokenWithClientAssertion method.

public string GenerateAccessTokenWithClientAssertion(string aud, string client_assertion, string clientId, X509Certificate2 signingCert, string tenantID)
{
    // GET ACCESS TOKEN
    var data = new[]
    {
        new KeyValuePair<string, string>("client_id", clientId),
        new KeyValuePair<string, string>("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"),
        new KeyValuePair<string, string>("client_assertion", client_assertion),
        new KeyValuePair<string, string>("grant_type", "client_credentials"),
        new KeyValuePair<string, string>("scope", "https://graph.microsoft.com/.default"),
    };

    var client = new HttpClient();
    var url = $"https://login.microsoftonline.com/{tenantID}/oauth2/v2.0/token";
    var res = client.PostAsync(url, new FormUrlEncodedContent(data)).GetAwaiter().GetResult();
    var token = "";
    using (HttpResponseMessage response = res)
    {
        response.EnsureSuccessStatusCode();
        string responseBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
        JObject obj = JObject.Parse(responseBody);
        token = (string)obj["access_token"];
    }

    return token;
}

Now, the access token will be returned as documented here.

❗ Authentication_MissingOrMalformed error will be returned if PoP is not signed with the already uploaded certificate

You can refer to this official code sample to generate proof of possession token

public string GeneratePoPToken(string objectId, string aud, X509Certificate2 signingCert)
{
    Guid guid = Guid.NewGuid();

    // aud and iss are the only required claims.
    var claims = new Dictionary<string, object>()
    {
    { "aud", aud },
    { "iss", objectId }
    };

    // token validity should not be more than 10 minutes
    var now = DateTime.UtcNow;
    var securityTokenDescriptor = new Microsoft.IdentityModel.Tokens.SecurityTokenDescriptor
    {
        Claims = claims,
        NotBefore = now,
        Expires = now.AddMinutes(10),
        SigningCredentials = new X509SigningCredentials(signingCert)
    };

    var handler = new JsonWebTokenHandler();
    var poP = handler.CreateToken(securityTokenDescriptor);
    // Console.WriteLine("\n\"Generate Proof of Possession Token:\"\n--------------------------------------------");
    // Console.WriteLine($"PoP: {poP}");

    return poP;
}

On successful code execution, the PoP will be returned. PoP

Now, we have both access_token and PoP which will be used to call addKey API.

Finally, call the Graph API and Upload the new certificate.

All the required properties have been added to the request body and a successful 200 OK response code should be returned.

public HttpStatusCode AddKeyWithPassword(string poP, string objectId, string api, string accessToken)
{
    var client = new HttpClient();
    var url = $"{api}{objectId}/addKey";

    var defaultRequestHeaders = client.DefaultRequestHeaders;
    if (defaultRequestHeaders.Accept == null || !defaultRequestHeaders.Accept.Any(m => m.MediaType == "application/json"))
    {
        client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
    }
    defaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);

    // Get the new certificate info which will be uploaded via the graph API 
    string pfxFilePath = "cert which will be added via API call\\newCertToUpload.pfx";
    string password = "Test@123";
    X509Certificate2 CurrentCertUsed = new X509Certificate2(pfxFilePath, password);
    var key = new Helper().GetCertificateKey(CurrentCertUsed);

    var payload = new
    {
        keyCredential = new
        {
            type = "X509CertAndPassword",
            usage = "Sign",
            key,
        },
        passwordCredential = new
        {
            secretText = password,
        },
        proof = poP
    };
    var stringPayload = JsonConvert.SerializeObject(payload);
    var httpContent = new StringContent(stringPayload, Encoding.UTF8, "application/json");

    var res = client.PostAsync(url, httpContent).GetAwaiter().GetResult();

    return res.StatusCode;
}
The provided code in this tutorial can be used to extract certificate key as follow:
  • Using GetCertificateKey method in the Helper class as follow :
public string GetCertificateKey(X509Certificate2 cert)
{
    return Convert.ToBase64String(cert.GetRawCertData());
}

On successful code execution a Uploaded! message will be printed out to indicate a successful 200 OK response code returned from the API: (see below example)

octocat

To confirm the existent of the newly added certificate, we can do a GET request to the following Graph API, see below screenshots.

  https://graph.microsoft.com/v1.0/applications/{Object ID}

octocat

To remove a certificate for the application you can use one of the below methods.

  1. Using RemoveKey method located here to directly call the API.
  2. Or RemoveKey_GraphSDK to utilize Graph SDK instead.

Also you can utilize the same code to call service principal API instead of application API to upload a certificate via replacing the following methods.

  1. Open the appsettings.json file.
  2. Find the app key ApiUrl and replace https://graph.microsoft.com/v1.0/applications/ with https://graph.microsoft.com/v1.0/servicePrincipals/ .
  3. For Graph SDK, change the methods inside GraphSDK.cs from graphClient.Applications[objectId] to graphClient.ServicePrincipals[objectId] as documented here.

REF: servicePrincipal: addKey Generate proof of possession tokens for rolling keys