Reputation: 789
I am trying to avoid using any additional PowerShell modules (running in 5.1), so I am running this script ($clientId, $tenantId, and $certificateThumbprint are random values for this post):
$thumbprint = "<thumbprint taken from the cert properties>"
$expUnixTime = [math]::truncate(((Get-Date).AddMinutes(30).ToUniversalTime()).Subtract((Get-Date "1970-01-01").ToUniversalTime()).TotalSeconds)
$payload = @{
iss = "CN=<issuer>"
sub = "CN=<subject (same as issuer)>"
aud = "https: //login.microsoftonline.com/<tenant name>.onmicrosoft.com/oauth2/v2.0/token"
exp = $expUnixTime # Token expiration time (30 minutes from now)
}
$encodedPayload = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes(($payload | ConvertTo-Json)))
$cert = Get-Item -Path Cert:\CurrentUser\My\$thumbprint
$rsaPrivateKey = $cert.PrivateKey
$rsaCryptoProvider = New-Object System.Security.Cryptography.RSACryptoServiceProvider
$rsaCryptoProvider.ImportParameters($rsaPrivateKey.ExportParameters("PKCS8"))
$signatureBytes = $rsaCryptoProvider.SignData([System.Text.Encoding]::UTF8.GetBytes($encodedPayload), "SHA256")
$encodedSignature = [Convert]::ToBase64String($signatureBytes)
$jwtAssertion = "$encodedPayload.$encodedSignature"
$clientId = "<client id guid>"
$tenantId = "<teant id guid>"
$tokenUrl = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token"
try {
$body = @{
client_id = $clientId
tenant_id = $tenantId
client_assertion_type = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
client_assertion = $jwtAssertion
scope = "https://graph.microsoft.com/.default"
grant_type = "client_credentials"
}
$bodyQueryString = $(Foreach ($item in $body.GetEnumerator()) {
"$($item.Key)=$($item.Value)"
}) -join '&'
$response = Invoke-RestMethod -Uri $tokenUrl -Method Post -Body $bodyQueryString -ContentType "application/x-www-form-urlencoded"
$response
} catch {
Write-Error "Error: $($_.Exception.Message)"
}
I am getting back this error:
{"error":"invalid_request","error_description":"AADSTS50027: JWT token is invalid or malformed. Trace ID: Correlation ID: Timestamp: 2024-02-20 20:50:49Z","error_codes":[50027],"timestamp":"2024-02-20 20:50:49Z","trace_id":"","correlation_id":"","error_uri":"https://login.microsoftonline.com/error?code=50027"}
I have looked at a few other questions, but have not identified anything helpful.
Upvotes: 0
Views: 520
Reputation: 60838
The process of acquiring the client_assertion
is long and tedious, which is why most folks default to the MSAL Library or the modules that have been built around it, i.e.: MSAL.PS.
The process is documented in:
If you want to go full vanilla to get the JWT Assertion, this class should help creating it:
class JwtAssertion {
[System.Security.Cryptography.X509Certificates.X509Certificate2] $Certificate
hidden [System.Text.Encoding] $encoding = [System.Text.Encoding]::UTF8
hidden [hashtable] $claims = @{
# exp: 5-10 minutes after nbf at most
exp = [System.DateTimeOffset]::UtcNow.AddMinutes(5).ToUnixTimeSeconds()
# jti: a GUID, unique identifier for the request
jti = [guid]::NewGuid().ToString()
# nbf: (not before), Using the current time is appropriate
nbf = [System.DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
# iat: this is an optional claim, we dont need it here
}
hidden [hashtable] $header = @{
alg = 'RS256'
typ = 'JWT'
}
JwtAssertion([System.Security.Cryptography.X509Certificates.X509Certificate2] $cert) {
$this.Certificate = $cert
# x5t: Base64url-encoded SHA-1 thumbprint of the X.509 certificate's DER encoding.
$this.header['x5t'] = [JwtAssertion]::ToBase64UrlEncodedString($cert.GetCertHash())
}
[JwtAssertion] WithTenantId([guid] $tenantId) {
$this.claims['aud'] = 'https://login.microsoftonline.com/{0}/oauth2/v2.0/token' -f $tenantId
return $this
}
[JwtAssertion] WithClientId([guid] $clientId) {
# iss: GUID application ID
# sub: Use the same value as iss
$this.claims['iss'] = $this.claims['sub'] = $clientId.ToString()
return $this
}
[JwtAssertion] SetExpirationTime([int] $minutes) {
$this.claims['exp'] = [System.DateTimeOffset]::UtcNow.AddMinutes($minutes).ToUnixTimeSeconds()
return $this
}
static [string] ToBase64UrlEncodedString([byte[]] $bytes) {
return [System.Convert]::ToBase64String($bytes).
Replace('+', '-').Replace('/', '_').TrimEnd('=')
}
[string] GetAssertion() {
$headerAsJson = $this.header | ConvertTo-Json -Compress
$headerB64 = [JwtAssertion]::ToBase64UrlEncodedString($this.encoding.GetBytes($headerAsJson))
$claimsAsJson = $this.claims | ConvertTo-Json -Compress
$claimsB64 = [JwtAssertion]::ToBase64UrlEncodedString($this.encoding.GetBytes($claimsAsJson))
$provider = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($this.Certificate)
$signature = [JwtAssertion]::ToBase64UrlEncodedString($provider.SignData(
$this.encoding.GetBytes("$headerB64.$claimsB64"),
[Security.Cryptography.HashAlgorithmName]::SHA256,
[Security.Cryptography.RSASignaturePadding]::Pkcs1))
if ($provider) {
$provider.Dispose()
}
return "$headerB64.$claimsB64.$signature"
}
}
To get the assertion using that class and then request the access token with a certificate credential:
$cert = Get-Item Cert:\CurrentUser\My\MyThumbprint
$TenantId = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
$clientId = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
$jwtAssertion = [JwtAssertion]::new($cert).
WithTenantId($tenantId).
WithClientId($clientId).
SetExpirationTime(10). # the value is in minutes
GetAssertion()
$invokeRestMethodSplat = @{
Uri = 'https://login.microsoftonline.com/{0}/oauth2/v2.0/token' -f $tenantId
Method = 'POST'
Body = @{
client_id = $clientId
client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
client_assertion = $jwtAssertion
scope = 'https://graph.microsoft.com/.default'
grant_type = 'client_credentials'
}
}
Invoke-RestMethod @invokeRestMethodSplat
Upvotes: 0