Table of Contents

While working on my appeal against Application.ReadWrite.All, I stumbled upon a potential way to rotate an application’s authentication certificate without Graph API permissions. After some experimentation, I was unable to get it running in PowerShell, so I set it aside for the time being to finish the article. However, the topic continued to intrigue me, as this method would have several advantages:

  • Application managers would have a quick self-service option to change an application’s certificate
  • Automations could replace a certificate shortly before its expiration, without user interaction
  • I would have another reason to grant, and use, fewer additional permissions

"Authentication certificate" refers to the credentials of the App Registration – not SAML Signing Certificates, which are used for Single Sign-On!

For these reasons, I set out to write this article:
Both as a guide for those who should, or would like to, use this method (see the result), as well as hopefully offering an interesting insight into challenges with Microsoft documentation and PowerShell troubleshooting. 😁


Challenge: Microsoft’s Code Suggestion for Proof of Possession

Adding certificate credentials to an App Registration via Graph API is nothing special; this part of the process is well documented. The real challenge lies in the peculiarity of the addKey endpoint – proving to the application that we currently have a valid certificate.

Fundamentally, this authentication process also relies on a JSON Web Token (JWT), which must first be constructed. Fortunately, Microsoft also offers documentation of this process. However, it quickly becomes clear that their PowerShell snippet is not optimal – usually, it will only lead to error messages when executed.

ExecutionError

Unable to find type […]


"Short" Primer:

  • .NET Assemblies are the compiled code libraries in .NET, mostly as .dll files, which contain metadata and .NET Types. They facilitate code reuse and support versioning
  • .NET Types represent data structures or methods in the code. They make it easier to represent objects and processes and contribute to simplifying programming

Example: The Assembly Microsoft.IdentityModel.JsonWebTokens includes, among other things, the Type [Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler], which provides a series of methods and properties to simplify the creation, validation, and processing of JWTs
By using this Type, developers can implement more complex processes around JWTs – without having to dive deep into the details of token creation and validation

The issue arises from the use of certain .NET Types that are not included in the Microsoft.Graph.Authentication module or the PowerShell environment. It’s unclear to me where these Types are supposed to come from, as I couldn’t find the associated assemblies in any of the usual PowerShell modules. This leaves me to suspect that the code was translated from C# to PowerShell 1:1, without verifying its executability for the average user. I am very interested in other explanations. 😉

SnippetExample

At this point, I could have started installing and loading the required assemblies. However, I prefer functions that are immediately executable on a wider range of systems, even if it makes the code seem more complex. Additionally, working with NuGet, the package manager for .NET libraries, is really not the best in PowerShell, as we will see later in the article.


My Integration Attempts

Important: the code snippets in the current chapter are primarily for illustrating the development process, and do not represent a complete script
The full source code can be found at the end of this article

While implementing Graph API authentication with username + password, I already successfully relied on the work of Adam The Automator when constructing JWTs, so I adapted it for this use case as well.

My goal now is to transfer the missing Types from the Microsoft example into generally available code:


Microsoft.IdentityModel.Tokens.SecurityTokenDescriptor

We start with this Code from the Microsoft Dokumentation:

# aud and iss are the only required claims.
$claims = [System.Collections.Generic.Dictionary[string,object]]::new()
$claims.aud = $aud
$claims.iss = $objectId # In my case $clientID

$now = (Get-Date).ToUniversalTime()
$securityTokenDescriptor = [Microsoft.IdentityModel.Tokens.SecurityTokenDescriptor]@{
    Claims = $claims
    NotBefore = $now
    Expires = $now.AddMinutes(10)
}

So this Type is used to define the contents of the token. There are several points that are dictated by the standard and do not need to be explicitly listed – useful if you want cleaner code, but a hindrance if you’re trying to recreate it. Ultimately, my equivalent looked as follows:

$StartDate = (Get-Date "1970-01-01T00:00:00Z").ToUniversalTime()
$now = (Get-Date).ToUniversalTime()
# Create JWT timestamp for expiration
$JWTExpirationTimeSpan = ( New-TimeSpan -Start $StartDate -End $now.AddMinutes(2) ).TotalSeconds
$JWTExpiration = [math]::Round($JWTExpirationTimeSpan, 0)

# Create JWT timestamp for beginning of validity
$NotBeforeExpirationTimeSpan = ( New-TimeSpan -Start $StartDate -End $now ).TotalSeconds
$NotBefore = [math]::Round($NotBeforeExpirationTimeSpan, 0)

# Create JWT header
$JWTHeader = @{
    alg = "RS256"
    typ = "JWT"
    x5t = $CertificateBase64Hash 
}

# Create JWT payload
$JWTPayLoad = @{
    aud = "00000002-0000-0000-c000-000000000000"
    exp = $JWTExpiration
    iss = $clientID
    jti = [guid]::NewGuid()
    nbf = $NotBefore
    sub = $clientID
}

Upon closer inspection, you might recognize the Application ID of the now-discontinued "Azure AD Graph" API as the Audience (aud) of the claim, this is necessary


By using the assemblies you save the effort of crafting the header and converting time formats. While this doesn’t represent a dramatic upside yet, it becomes evident that code becomes shorter when utilizing libraries – shocking, I know. Our next example will illustrate this even clearer.


Microsoft.IdentityModel.Tokens.X509SigningCredentials and Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler

The replacement of the next two Types is handled together because "X509SigningCredentials" only represents the form of the certificate that the JsonWebTokenHandler uses to construct the final JWT. Here most of the work is taken off the developer’s hands – in the original, we have only two lines:

$handler = [Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler]::new()
$token = $handler.CreateToken($securityTokenDescriptor)

From the code, we can’t really learn what’s being used in the background – thankfully, we’ve already got the groundwork from Adam the Automator. So, we know that the header and payload need to be base64 encoded and cryptographically signed.

# Convert header and payload to base64
$JWTHeaderToByte = [System.Text.Encoding]::UTF8.GetBytes(($JWTHeader | ConvertTo-Json))
$EncodedHeader = [System.Convert]::ToBase64String($JWTHeaderToByte) 

$JWTPayLoadToByte = [System.Text.Encoding]::UTF8.GetBytes(($JWTPayload | ConvertTo-Json))
$EncodedPayload = [System.Convert]::ToBase64String($JWTPayLoadToByte)

$JWT = $EncodedHeader + "." + $EncodedPayload

# Define RSA signature and hashing algorithm
$RSAPadding = [Security.Cryptography.RSASignaturePadding]::Pkcs1
$HashAlgorithm = [Security.Cryptography.HashAlgorithmName]::SHA256

# Sign the JWT
$rsaCert = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($cert)
$Signature = [Convert]::ToBase64String(
    $rsaCert.SignData([System.Text.Encoding]::UTF8.GetBytes($JWT), $HashAlgorithm, $RSAPadding)
) -replace '\+', '-' -replace '/', '_' -replace '='

# Add Signature to JWT
$JWT = $JWT + "." + $Signature

TaDa – we now have our Credential Token, or "Proof".

JWT

And this is where I initially overlooked something critical. The Microsoft documentation clearly states what needs to be considered – Pause here to see if you can spot the issue. 😉


Everything seems reasonable, but this error would haunt me for the next few hours:

errorMessage

Authentication_MissingOrMalformed


Temporary Surrender

After many unsuccessful attempts, including experiments with the claims in the header and the signature, I had enough. A few days’ break brought a new perspective, and I decided to get the Microsoft example working – to obtain comparative values.

First, we need the .NET assemblies – these can be obtained from the NuGet Package Manager from the NuGet Package Source, noth often not pre-installed.
CheckNugetAvailability

In brief, as I am not a fan of this path – it can lead to manifold problems:

  • Under PowerShell 7 (pwsh / Core), I only had to add the repository: Register-PackageSource -Name "NuGet" -Location "https://api.nuget.org/v3/index.json" -ProviderName NuGet
  • The Package Provider can be added if necessary with Find-PackageProvider -Name NuGet | Install-PackageProvider -Scope CurrentUser -Force
  • The v3 repository cannot be added under older PowerShell versions – alternatively Register-PackageSource -Name "NuGetOld" -Location "https://www.nuget.org/api/v2/" -ProviderName NuGet

Adding the provider and the repository does not conclude the extra work – NuGet in PowerShell is known for causing circular references when resolving dependencies within the assemblies. Thus, the installation runs indefinitely – or, after a while, you might encounter the error Dependency loop detected for package ...

Therefore, we must install the packages with -SkipDependencies – and then for every subsequent Type not Found error, seek the associated assembly.
Skipping forward a bit, the dependencies are limited; we need:

  • Microsoft.IdentityModel.JsonWebTokens
  • Microsoft.IdentityModel.Logging
  • Microsoft.IdentityModel.Tokens

All other dependencies are already covered by the Microsoft.Graph.Authentication module or the global catalog.

To install and then load the assemblies, I wrote the following helper code:

# Change to your liking, be aware that this will create directories
$destination = "$env:OneDrive\Dokumente\WindowsPowerShell\Modules\"

# Versions are relevant, since they might have specific dependencies
$packages = @{
"Microsoft.IdentityModel.JsonWebTokens" = "7.3.1"
"Microsoft.IdentityModel.Logging" = "7.5.0"
"Microsoft.IdentityModel.Tokens" = "7.5.0"
}

# Install / Load Packages
foreach ($p in $packages.keys){ 
    $dllPath = $destination + "\$p.$($packages.$p)\lib\netstandard2.0\$p.dll"
    if ( -not (Test-Path $dllPath) ){
        Install-Package -Name $p -ProviderName NuGet -Scope CurrentUser -Destination $destination -RequiredVersion $packages.$p -Force -SkipDependencies
    }    
    [System.Reflection.Assembly]::LoadFrom($dllPath) | Out-Null
}

!Caution!: The assemblies must be reloaded each session – I only want to use them for troubleshooting, not permanently

After this preparation, the Microsoft example works, and we obtain a valid token (parsed via https://jwt.ms):

MicrosoftToken


The Ah-Hah Moment

After I had completed the necessary groundwork, I wanted to verify that everything generally works on a new PC. "Works on my machine" does not fit my philosophy. When copying the code snippet fresh from the Microsoft documentation again, I first consciously read a sentence that is at the very end of the article:

CriticalSentence

In my defense – I had stopped reading at "Azure KeyVault" at the beginning, as it seemed irrelevant to me. However, the listed error message "Authentication_MissingOrMalformed" was burned into my eyes by that point, so I could not miss it.

Let`s take another look at the Token my Code generates:

IssueHighlighted

Huh. Sometimes, I wish these mistakes wouldn’t look so ridiculously simple in hindsight. 🤦 If anyone discovered (or not) the mistake when I mentioned it earlier in the article, I’d love to hear about it.

So, all that was missing was removing the "=" sign when we convert our header and payload to Base64:

#...
$JWTHeaderToByte = [System.Text.Encoding]::UTF8.GetBytes(($JWTHeader | ConvertTo-Json))
$EncodedHeader = [System.Convert]::ToBase64String($JWTHeaderToByte) -replace '='

$JWTPayLoadToByte = [System.Text.Encoding]::UTF8.GetBytes(($JWTPayload | ConvertTo-Json))
$EncodedPayload = [System.Convert]::ToBase64String($JWTPayLoadToByte) -replace '='
#...

At least the NuGet detour wasn’t for nought – now that I had a Microsoft example of the token, I could match both the header and the claims. Besides these adjustments, I’ve cleaned up the code and turned the generation of the prooftoken into a function.


The Result

After a considerable detour (or not, if you’ve jumped straight to the result 😉) – the process now looks like this when I update the certificate of an Enterprise App:

Function for Prooftokens

This part is the most extensive and needs to be used twice, so functionalization is the word:

I will keep updates in my GitHub gist

function New-Prooftoken {
    param (
        [Parameter(Mandatory = $true)]
        [string]$clientId,

        [Parameter(Mandatory = $true)]
        [System.Security.Cryptography.X509Certificates.X509Certificate2]$cert
    )

    # Very far removed, but still based on https://adamtheautomator.com/powershell-graph-api/

    # Get base64 hash of certificate in Web Encoding
    $CertificateBase64Hash = [System.Convert]::ToBase64String( $cert.GetCertHash() ) -replace '\+', '-' -replace '/', '_' -replace '='

    $StartDate = (Get-Date "1970-01-01T00:00:00Z").ToUniversalTime()
    $now = (Get-Date).ToUniversalTime()

    # Create JWT timestamp for expiration - 5 Minute Lifetime here
    $JWTExpirationTimeSpan = ( New-TimeSpan -Start $StartDate -End $now.AddMinutes(5) ).TotalSeconds
    $JWTExpiration = [math]::Round($JWTExpirationTimeSpan, 0)

    # Create JWT validity start timestamp
    $NotBeforeExpirationTimeSpan = ( New-TimeSpan -Start $StartDate -End $now ).TotalSeconds
    $NotBefore = [math]::Round($NotBeforeExpirationTimeSpan, 0)

    # Create JWT header
    $JWTHeader = @{
        alg = "RS256"
        typ = "JWT"
        x5t = $CertificateBase64Hash 
        kid = $cert.Thumbprint
    }

    # Create JWT payload
    $JWTPayLoad = @{
        aud = "00000002-0000-0000-c000-000000000000"
        exp = $JWTExpiration
        iss = $clientID
        nbf = $NotBefore
        iat = $NotBefore
    }

    # Convert header and payload to base64
    $JWTHeaderToByte = [System.Text.Encoding]::UTF8.GetBytes(($JWTHeader | ConvertTo-Json))
    $EncodedHeader = [System.Convert]::ToBase64String($JWTHeaderToByte) -replace '='

    $JWTPayLoadToByte = [System.Text.Encoding]::UTF8.GetBytes(($JWTPayload | ConvertTo-Json))
    $EncodedPayload = [System.Convert]::ToBase64String($JWTPayLoadToByte) -replace '='

    $JWT = $EncodedHeader + "." + $EncodedPayload

    # Define RSA signature and hashing algorithm
    $RSAPadding = [Security.Cryptography.RSASignaturePadding]::Pkcs1
    $HashAlgorithm = [Security.Cryptography.HashAlgorithmName]::SHA256

    # Sign the JWT
    $rsaCert = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($cert)
    $Signature = [Convert]::ToBase64String(
        $rsaCert.SignData([System.Text.Encoding]::UTF8.GetBytes($JWT), $HashAlgorithm, $RSAPadding)
    ) -replace '\+', '-' -replace '/', '_' -replace '='

    # Add Signature to JWT
    $JWT = $JWT + "." + $Signature

    return ConvertTo-SecureString $JWT -AsPlainText -Force 

    end {
        # Clear Senstive Values
        $sensitiveVars = @("Signature","JWT")
        Remove-Variable $sensitiveVars
        [gc]::collect()
    }
}

Rotating the Certificate

In summary, we do the following:

  1. Load the certificates
  2. Connect to the Enterprise App
  3. Add the new certificate
  4. [Switch the certificates in the application that uses the Enterprise App!]
  5. Reconnect for safety
  6. Remove the old certificate

None of this requires additional permissions on the Enterprise App!

###### Configure Here #####

# Load Certificates, Alternative: Get-PfxCertificate for .cer Files
$oldcert = Get-Item "Cert:\CurrentUser\My\$($graphConfig.thumb)"
$newCert = Get-Item "Cert:\CurrentUser\My\$($graphConfig.newThumb)"

# Replace to your Values
$clientID = $graphconfig.clientID
$tenantID = $graphconfig.tenantID

###########################

Connect-MgGraph -TenantId $tenantID -ClientId $clientID -Certificate $oldCert -NoWelcome

# Add new Certificate, store information to Rotate
$proof = New-Prooftoken -clientId $clientID -cert $oldcert
$params = @{
    keyCredential      = @{
        type  = "AsymmetricX509Cert"
        usage = "Verify"
        key   = [convert]::ToBase64String($newCert.GetRawCertData())
    }
    passwordCredential = $null
    proof              = [System.Net.NetworkCredential]::new('',$proof).Password
}
$newKeyCredential = Invoke-MgGraphRequest POST "/v1.0/applications(appId='$clientID')/addKey" -Body $params -OutputType PSObject

# Reconnect to make sure that the new certificate is loaded
Disconnect-MgGraph | Out-Null
Connect-MgGraph -TenantId $tenantID -ClientId $clientID -Certificate $newCert -NoWelcome

# Rotate the Certificates in your Application NOW ;)

# Read all currently Registered Certificate Credentials, Filter to only the old Credentials
$currentKeys = Invoke-MgGraphRequest GET "/v1.0/applications(appId='$clientID')?`$select=id,appId,keyCredentials" -OutputType PSObject
$oldKeys = $currentKeys.keyCredentials | Where-Object keyId -ne $newKeyCredential.keyId

# Remove the old Certificate(s)
foreach ($k in $oldKeys){
    $proof = New-Prooftoken -clientId $clientID -cert $newcert
    $params = @{
        keyId = $oldkeys.keyId
        proof = [System.Net.NetworkCredential]::new('',$proof).Password
    }
    $null = Invoke-MgGraphRequest POST "/v1.0/applications(appId='$clientID')/removeKey" -Body $params
}

Disconnect-MgGraph | Out-Null
Remove-Variable @("params", "proof")
[gc]::collect()


In Review

This topic was extensive and at times very challenging, yet there are few things as satisfying as the working result of hard work. Have you gained new insights or were you able to simplify your credential management? Let me know.

I don’t want your email address, so please discuss in my associated LinkedIn post.

If you’re interested in the things I do, follow me on LinkedIn.

Last modified: 4. November 2024

Comments

Write a Reply or Comment