Während ich an meinem Appell gegen Application.ReadWrite.All arbeitete, stieß ich auf eine potenzielle Möglichkeit, ohne Graph API-Rechte das Authentifizierungszertifikat einer Applikation zu rotieren. Nach einigem Herum-Probieren hatte ich es nicht zu meiner Zufriedenheit in PowerShell umgesetzt, entsprechend habe ich es vorerst beiseite gelegt, um den Artikel abszuschließen. Das Thema ließ mich aber nicht los, da diese Methode einige Vorteile hätte:

  • Applikationsverantwortliche hätten eine schnelle Self-Service-Option, das Zertifikat einer Applikation zu ändern
  • Automatismen könnten kurz vor Ablauf eines Zertifikats ohne Benutzerinteraktion einen Austausch vornehmen
  • Ich hätte einen weiteren Grund, weniger zusätzliche Berechtigungen zu vergeben bzw. zu nutzen

"Authentifizierungszertifikat" heißt, dass die Credentials bzw. Anmeldeinformationen ausgetauscht werden – nicht SAML Signing Certificates, die für Single-Sign-On genutzt werden!

Aus diesen Gründen habe ich mich an diesen Artikel gemacht, einerseits als Handreichung an diejenigen, die diese Methode nutzen sollen bzw. möchten (siehe das Ergebnis).
Andererseits hoffe ich, dass ich mit der ausführlichen Form einen interessanten Einblick in Herausforderungen mit der Microsoft Dokumentation und PowerShell Troubleshooting bieten kann. 😁


Herausforderung: Microsofts Codevorschlag für Proof of Posession

Einer App Registration per Graph API Zertifikatscredentials hinzufügen ist nichts besonderes, dieser Teil des Ablaufs ist gut dokumentiert. Die eigentliche Herausforderung liegt in der Spezialität des addKey Endpunkts – dem Nachweis gegenüber der Applikation, dass wir aktuell ein gültiges Zertifikat haben.

Fundamental basiert auch dieser Authentifizierungsprozess auf einem JSON Web Token (JWT), dieser muss aber erst gebaut werden. Glücklicherweise bietet Microsoft auch hier eine Dokumentation des Prozess. Allerdings wird schnell klar, dass ihr PowerShell-Snippet nicht optimal ist – in der Regel wird es bei der Ausführung nur zu Fehlermeldungen kommen.

ExecutionError

Unable to find type […]


"Kurzer" Primer:

  • .NET Assemblies sind die kompilierten Codebibliotheken in .NET, meist als .dll Dateien, die Metadaten und .NET Types enthalten. Sie erleichtern die Wiederverwendung von Code und unterstützen die Versionierung
  • .NET Types repräsentieren Datenstrukturen oder Methoden im Code. Sie erleichtern die Darstellung von Objekten und Abläufen und tragen zur Vereinfachung der Programmierung bei

Beispiel: Das Assembly Microsoft.IdentityModel.JsonWebTokens enthält unter anderem den Type [Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler], der eine Reihe von Methoden und Eigenschaften bereitstellt, um das Erstellen, Validieren und Verarbeiten von JWTs zu vereinfachen.
Durch die Verwendung dieses Types können Entwickler komplexere Abläufe rund um JWTs implementieren, ohne tief in die Details der Token-Erstellung und -Validierung einsteigen zu müssen

Verantwortlich für das Problem ist die Nutzung einiger .NET Types, die nicht im Microsoft.Graph.Authentication Modul oder der PowerShell Umgebung enthalten sind. Mir ist unklar, wo diese Types herkommen sollen, da ich die zugehörigen Assemblies in keinem der üblichen PowerShell Module finden konnte. Mir bleibt also nur die Vermutung, dass der Code 1:1 aus C# in PowerShell übersetzt wurde, ohne die Ausführbarkeit für den durchschnittlichen Anwender zu überprüfen. Ich bin an anderen Erklärungen sehr interessiert. 😉

SnippetExample

An dieser Stelle hätte ich anfangen können, die erforderlichen Assemblies zu installieren und zu laden. Ich bevorzuge jedoch Funktionen, die auf einer breiteren Palette von Systemen sofort ausführbar sind, auch wenn dadurch der Code komplexer scheint. Die Arbeit mit nuget, dem Paketmanager für .NET-Bibliotheken, ist meiner Erfahrung nach in PowerShell wirklich nicht das Beste, wie wir später im Artikel auch sehen werden.


Meine Integrationsversuche

Wichtig: die Code-Snippets im jetzigen Kapitel dienen vorrangig der Veranschaulichung des Entwicklungsprozesses und stellen kein vollständiges Script dar
Der volle Quelltext findet sich am Ende des Artikels

Bei der Implementierung der Graph API Authentifizierung mit Benutzername + Passwort habe ich mich bereits erfolgreich auf die Arbeit von Adam The Automator im Bau von JWTs gestützt, entsprechend habe ich sie auch für diesen Usecase angepasst.

Mein Ziel ist es also jetzt, die fehlenden Types aus dem Microsoft-Beispiel in allgemein verfügbaren Code zu überführen:


Microsoft.IdentityModel.Tokens.SecurityTokenDescriptor

Wir starten mit diesem Code aus der Microsoft Dokumentation:

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

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

Mit diesem Type werden also die Inhalte des Tokens definiert. Dabei gibt es einige Punkte, die durch den Standard vorgegeben sind und nicht explizit gelistet werden müssen – praktisch, wenn man schöneren Code will, allerdings hinderlich wenn man es nachbauen will. Schlussendlich sah mein Äquivalent wie folgt aus:

$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
}

Bei genauer Betrachtung fällt auf, dass die Application ID der inzwischen eingestellten "Azure AD Graph" API als Audience (aud) des Claims verwendet wird, das ist jedoch notwendig


Durch die Nutzung der Assemblies spart man sich den Header und das Konvertieren der Zeiten. Das stellt zwar noch keinen dramatischen Vorteil dar, aber es wird deutlich, dass der Code kürzer wird, wenn man Bibliotheken nutzt – schockierend, ich weiß. Unser nächstes Beispiel verdeutlicht dies noch stärker.


Microsoft.IdentityModel.Tokens.X509SigningCredentials und Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler

Die Ablösung der nächsten zwei Types wird zusammengefasst, da "X509SigningCredentials" nur die Form des Zertifikats ist, das der JsonWebTokenHandler nutzt, um den Finalen JWT zu bauen. Hier wird auch tatsächlich die meiste Arbeit dem Entwickler abgenommen – im Original haben wir nur zwei Zeilen:

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

Aus dem Code können wir nicht wirklich lernen, was im Hintergrund genutzt wird – dankbarerweise haben wir ja bereits die Vorarbeit von Adam the Automator. Wir wissen also, dass Header und Payload base64 codiert und mit einer Signatur versehen werden müssen.

# 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 – wir haben unser Credential Token, oder auch "Proof".

JWT

Und an dieser Stelle habe ich initial etwas fatales übersehen. In der Microsoft Dokumentation steht klar, was beachtet werden muss – Halten Sie hier inne, um zu sehen, ob Sie den Fehler finden können. 😉


An sich sieht eigentlich alles schlüssig aus, aber dieser Fehler würde mich die nächsten Stunden verfolgen:

errorMessage

Authentication_MissingOrMalformed


Vorläufige Kapitulation

Nach vielen erfolglosen Versuchen, darunter Experimente mit den Claims im Header und der Signatur, hatte ich genug. Ein paar Tage Pause brachten neue Perspektive, und ich beschloss, die Microsoft-Vorlage zum Laufen zu bringen, um Vergleichswerte zu erhalten.

Als erstes müssen also die .NET Assemblys her – diese bekommt man aus dem NuGet Package Manager aus der NuGet Package Source, beides oftmals nicht vorinstalliert.
CheckNugetAvailability

Nur kurz angerissen, weil ich wie erwähnt kein Fan von diesem Pfad bin, da es mannigfaltige Probleme geben kann:

  • Unter PowerShell 7 (pwsh / Core) musste ich nur das Repository hinzu fügen Register-PackageSource -Name "NuGet" -Location "https://api.nuget.org/v3/index.json" -ProviderName NuGet
  • Der Package Provider kann wenn nötig mit Find-PackageProvider -Name NuGet | Install-PackageProvider -Scope CurrentUser -Force hinzugefügt werden
  • Das Repository v3 lässt sich unter älteren PowerShell Versionen nicht hinzu fügen – alternativ Register-PackageSource -Name "NuGetOld" -Location "https://www.nuget.org/api/v2/" -ProviderName NuGet

Beim Hinzufügen des Providers und des Repository endet der zusätzliche Aufwand aber noch nicht – NuGet in PowerShell ist bekannt dafür, dass es bei der Auflösung der Abhängigkeiten innerhalb der Assemblies zu Zirkelbezügen kommen kann. Die Installation läuft also für immer – oder man bekommt nach einer Weile den Fehler Dependency loop detected for package ...

Wir müssen also die Pakete mit -SkipDependencies installieren, ohne die Abhängigkeiten aufzulösen – und dürfen dann für jeden weiteren Type not Found Fehler das zugehörige Assembly suchen.
Schon mal vorgegriffen, die Abhängigkeiten halten sich in Grenzen, wir brauchen:

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

Alle weiteren Abhängigkeiten werden bereits von dem Microsoft.Graph.Authentication Modul oder dem globalen Katalog abgedeckt.

Um die Assemblies zu installieren, und dann zu laden, habe ich mir folgenden Hilfscode geschrieben:

# 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
}

!Achtung! : Die Assemblies müssen in jeder Sitzung neu geladen werden – ich will sie nur zum Troubleshooting nutzen und nicht permanent

Nach dieser Vorbereitung funktioniert das Microsoft-Beispiel, und wir erhalten einen validen Token (geparst über https://jwt.ms):
MicrosoftToken


Der Ah-Hah Moment

Nachdem ich die notwendige Vorarbeit abgeschlossen hatte, wollte ich auf einem neuen PC verifizieren, dass alles generell funktioniert. "Works on my Machine" entspricht nicht meiner Philosophie. Beim erneuten Kopieren des Code-Snippets frisch aus der Microsoft-Dokumentation, las ich zum ersten Mal bewusst einen Satz, der ganz am Ende des Artikels steht:

CriticalSentence

Zu meiner Verteidigung – ich hatte zu Beginn nach "Azure KeyVault" aufgehört zu lesen, da es für mich nicht relevant schien. Die gelistete Fehlermeldung "Authentication_MissingOrMalformed" war mir jedoch zu diesem Zeitpunkt so vertraut, dass sie mir sofort ins Auge sprang.

Betrachten wir noch einmal den Token, den mein Code generiert:
IssueHighlighted

Huh. Ich wünsche mir manchmal, solche Fehler würden im Rückblick nicht immer so lächerlich simpel aussehen. 🤦 Falls jemand den Fehler oben im Artikel (nicht) eigenständig entdeckt hat, würde ich mich freuen, davon zu hören.

Alles was also fehlte, war das entfernen des "=" Zeichen, wenn wir unseren Header und Payload zu Base64 konvertieren:

#...
$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 '='
#....

Wenigstens war der Exkurs über NuGet nicht umsonst – da ich jetzt ein Microsoft-Beispiel des Tokens hatte, konnte ich sowohl den Header als auch die Claims angleichen. Neben diesen Anpassungen habe ich den Code bereinigt und das Generieren des Prooftoken in eine Funktion umgewandelt.


Das Ergebnis

Nach einem ausgiebigen Umweg (oder auch nicht, falls Sie direkt zum Ergebnis gesprungen sind 😉) – so präsentiert sich nun der Prozess, wenn ich das Zertifikat einer Enterprise App aktualisiere:

Funktion für Prooftokens

Dieser Part ist der Umfangreichste und muss zwei mal genutzt werden, entsprechend ist Funktionalisierung sinnvoll:

Aktualisierungen pflege ich in meinem 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()
    }
}

Rotieren des Zertifikats

Zusammengefasst machen wir folgendes:

  1. Laden der Zertifikate
  2. Verbinden mit der Enterprise App
  3. Hinzufügen des neuen Zertifikats
  4. [In der Applikation, die die Enterprise App nutzt, die Zertifikate wechseln!]
  5. Zur Sicherheit neu verbinden
  6. Das alte Zertifikat entfernen

Für all das sind keine zusätzlichen Rechte auf der Enterprise App notwendig!

###### 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

Dieses Thema war umfangreich und teilweise sehr herausfordernd, doch gibt es kaum etwas Befriedigenderes als das funktionierende Ergebnis harter Arbeit. Haben Sie neue Erkenntnisse gewonnen oder konnten Sie Ihre Verwaltung von Credentials vereinfachen? Lassen sie es mich wissen.

Ich möchte Ihre E-Mail-Adresse nicht, also bitte diskutieren Sie in meinem zugehörigen LinkedIn-Post.

Wenn Sie an den Dingen interessiert sind, die ich mache, folgen Sie mir auf LinkedIn.

Zuletzt bearbeitet: 30. März 2024