StackExchangeGuy
StackExchangeGuy

Reputation: 789

Trouble authenticating to Microsoft Graph API with certificate in PowerShell

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

Answers (1)

Santiago Squarzon
Santiago Squarzon

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

Related Questions