Public/Confirm-EntraIDAccessToken.ps1

<#
.SYNOPSIS
Verifies that a provided token matches certain criteria

.EXAMPLE
Confirm-EntraIDAccessToken

#>

function Confirm-EntraIDAccessToken {
    [CmdletBinding()]

    Param(
        [Parameter(Mandatory = $false)]
        [String] $Tid = $null,

        [Parameter(Mandatory = $false)]
        [String] $Aud = $null,

        [Parameter(Mandatory = $false)]
        [String] $Iss = $null,

        [Parameter(Mandatory = $false)]
        [ValidateSet("user", "app")]
        [String] $Idtyp = $null,

        [Parameter(Mandatory = $false)]
        [String] $Idp = $null,

        [Parameter(Mandatory = $false)]
        [String] $Sub = $null,

        [Parameter(Mandatory = $false)]
        [String] $Appid = $null,

        [Parameter(Mandatory = $false)]
        [String] $Azp = $null,

        [Parameter(Mandatory = $false)]
        [String] $Oid = $null,

        [Parameter(Mandatory = $false)]
        [String[]] $Scopes = $null,

        [Parameter(Mandatory = $false)]
        [String[]] $Wids = $null,

        [Parameter(Mandatory = $false)]
        [String[]] $Roles = $null,

        [Parameter(Mandatory = $false)]
        [System.Collections.Hashtable] $OtherClaims = $null,

        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [String] $AccessToken
    )

    Process {
        # Remove "bearer " prefix if it exists
        if ($AccessToken -like "bearer *") {
            Write-Debug "Removing 'bearer ' prefix from the input object"
            $AccessToken = $AccessToken.Substring(7).Trim()
        }

        # Extract payload from the JWT token
        $Payload = $AccessToken | Get-EntraIDAccessTokenPayload -ErrorAction SilentlyContinue

        if (!$Payload) {
            Write-Error "Failed to decode the access token payload. Ensure the token is valid and properly formatted."
            return
        }
        $AllMatch = $true

        if ($Payload.aud -eq 'https://graph.microsoft.com') {
            Write-Error "Microsoft is doing weird stuff to the access token used by Microsoft Graph. The signature cannot be validated."
            return
        }


        if (
            $Payload.iss -notmatch "^https://sts\.windows\.net/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/$" -and
            $Payload.iss -notmatch "^https://login\.microsoftonline\.com/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/v2\.0$"
        ) {
            Write-Error "The 'iss' claim in the token does not match the expected format for Microsoft Entra ID."
            return
        }

        # Extract the header from the JWT token
        $headerjson = $AccessToken.Split(".")[0]
        $headerjson = $headerjson.PadRight($headerjson.Length + (4 - ($headerjson.Length % 4)), "=").Replace("====", "")
        try {
            $header = ConvertFrom-Json -InputObject ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($headerjson)))
        }
        catch {
            Write-Error "Failed to decode the JWT header: $_"
            return
        }

        if (!$header.kid) {
            Write-Error "JWT header does not contain 'kid' claim"
            return
        }

        if (!$Script:ConfirmEntraIDAccessTokenJWKSCache[$Payload.iss]?.ContainsKey($header.kid)) {
            $DiscoveryUrl = $Payload.iss.TrimEnd("/") + "/.well-known/openid-configuration"

            Write-Debug "Fetching JWKS from discovery URL: $DiscoveryUrl"
            try {
                $Discovery = Invoke-RestMethod -Uri $DiscoveryUrl -Method Get
                if ($Discovery.jwks_uri) {
                    Write-Debug "Found JWKS URI in discovery document: $($Discovery.jwks_uri)"
                    $JwksResult = Invoke-RestMethod -Uri "$($Discovery.jwks_uri)?appid=$($Payload.appid ?? $Payload.azp)" -Method Get
                    $JwksResult.keys | ForEach-Object {
                        $Script:ConfirmEntraIDAccessTokenJWKSCache[$Payload.iss] ??= @{}
                        $Script:ConfirmEntraIDAccessTokenJWKSCache[$Payload.iss][$_.kid] = $_
                    }
                }
                else {
                    Write-Error "Discovery document does not contain 'jwks_uri' claim"
                    return
                }
            }
            catch {
                Write-Error "Failed to fetch discovery document from $($DiscoveryUrl): $_"
                return
            }
        }
        $matchingKey = $Script:ConfirmEntraIDAccessTokenJWKSCache[$Payload.iss][$header.kid]

        if (!$matchingKey) {
            Write-Error "No matching key found in JWKS for kid '$($header.kid)'"
            return
        }
        else {
            Write-Debug "Found matching key in JWKS for kid '$($header.kid)'"
        }

        # Validate the matching key
        if (($null -eq $matchingKey.kty) -or ($null -eq $matchingKey.n) -or ($null -eq $matchingKey.e)) {
            Write-Error "Matching key in JWKS is missing required properties (kty, n, e)"
            return
        }

        # Validate the kty is RSA
        if ($matchingKey.kty -ne "RSA") {
            Write-Error "Matching key in JWKS is not of type 'RSA', found '$($matchingKey.kty)'"
            return
        }

        # Validate signature - inspired by https://github.com/anthonyg-1/PSJsonWebToken/blob/main/PSJsonWebToken/PrivateFunctions/Test-JwtJwkSignature.ps1
        $ToSign = $AccessToken.Substring(0, $AccessToken.LastIndexOf("."))
        #$ToSign += "="

        # $ToSign = "{0}.{1}" -f $AccessToken.Split(".")[0].PadRight($AccessToken.Split(".")[0].Length + (4 - ($AccessToken.Split(".")[0].Length % 4)), "=").Replace("====", ""), $AccessToken.Split(".")[1].PadRight($AccessToken.Split(".")[1].Length + (4 - ($AccessToken.Split(".")[1].Length % 4)), "=").Replace("====", "")
        [byte[]] $Signature = $AccessToken.Split(".")[2] | ConvertFrom-Base64UrlEncodedString -ByteArray
        if (-not $Signature) {
            Write-Error "Failed to decode the JWT signature"
            return
        }

        # $publicKey = [System.Security.Cryptography.RSACryptoServiceProvider]::new()
        $publicKey = [System.Security.Cryptography.RSA]::Create()
        try {
            $rsaParams = [System.Security.Cryptography.RSAParameters]::new()
            $rsaParams.Modulus = $matchingKey.n | ConvertFrom-Base64UrlEncodedString -ByteArray
            $rsaParams.Exponent = $matchingKey.e | ConvertFrom-Base64UrlEncodedString -ByteArray
            $publicKey.ImportParameters($rsaParams)

            # $hasher = [System.Security.Cryptography.HashAlgorithm]::Create('sha256')
            # $hash = $hasher.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($ToSign))

            [byte[]] $HeaderAndPayloadBytes = [System.Text.Encoding]::UTF8.GetBytes($ToSign)


            # $SignatureVerification = $publicKey.VerifyHash($hash, $Signature, [System.Security.Cryptography.HashAlgorithmName]::SHA256, [System.Security.Cryptography.RSASignaturePadding]::Pkcs1)
            # Write-Host "Signature verification result: $SignatureVerification"

            $SignatureVerification = $publicKey.VerifyData($HeaderAndPayloadBytes, $Signature, [System.Security.Cryptography.HashAlgorithmName]::SHA256 , [System.Security.Cryptography.RSASignaturePadding]::Pkcs1)
        }
        catch {
            $SignatureVerification = $false
        }
        finally {
            $publicKey.Dispose()
        }

        if (!$SignatureVerification) {
            Write-Error "Signature verification failed for the JWT token"
            return
        }

        # Validate that the nbf and exp claims are present and valid
        if ($Payload.nbf -and $Payload.exp) {
            $CurrentTime = [DateTimeOffset]::Now.ToUnixTimeSeconds()
            if ($Payload.nbf -gt $CurrentTime) {
                Write-Error "Token is not yet valid (nbf: $($Payload.nbf))"
                return
            }
            if ($Payload.exp -lt $CurrentTime) {
                Write-Error "Token has expired (exp: $($Payload.exp))"
                return
            }
        }
        else {
            Write-Error "Token does not contain nbf or exp claims"
            return
        }

        # TODO: Validate that the InputObject is a valid access token, signed by Microsoft Entra ID, and that it is not expired.

        # Check tid only if the tid parameter is provided
        if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey("tid") -and $Payload.tid -ne $Tid) {
            Write-Verbose "tid does not match: expected '$Tid', got '$($Payload.tid)'"
            $AllMatch = $false
        }

        # Check aud only if the aud parameter is provided
        if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey("aud") -and $Payload.aud -ne $Aud) {
            Write-Verbose "aud does not match: expected '$Aud', got '$($Payload.aud)'"
            $AllMatch = $false
        }

        # Check iss only if the iss parameter is provided
        if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey("iss") -and $Payload.iss -ne $Iss) {
            Write-Verbose "iss does not match: expected '$Iss', got '$($Payload.iss)'"
            $AllMatch = $false
        }

        # Check idp only if the idp parameter is provided
        if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey("idp") -and $Payload.idp -ne $Idp) {
            Write-Verbose "idp does not match: expected '$Idp', got '$($Payload.idp)'"
            $AllMatch = $false
        }

        # Check sub only if the sub parameter is provided
        if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey("sub") -and $Payload.sub -ne $Sub) {
            Write-Verbose "sub does not match: expected '$Sub', got '$($Payload.sub)'"
            $AllMatch = $false
        }

        # Check idtyp only if the idtyp parameter is provided
        if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey("idtyp") -and $Payload.idtyp -ne $Idtyp) {
            Write-Verbose "idtyp does not match: expected '$Idtyp', got '$($Payload.idtyp)'"
            $AllMatch = $false
        }

        # Check appid only if the appid parameter is provided
        if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey("appid") -and $Payload.appid -ne $Appid) {
            Write-Verbose "appid does not match: expected '$Appid', got '$($Payload.appid)'"
            $AllMatch = $false
        }

        # Check azp only if the azp parameter is provided
        if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey("azp") -and $Payload.azp -ne $Azp) {
            Write-Verbose "azp does not match: expected '$Azp', got '$($Payload.azp)'"
            $AllMatch = $false
        }

        # Check oid only if the oid parameter is provided
        if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey("oid") -and $Payload.oid -ne $Oid) {
            Write-Verbose "oid does not match: expected '$Oid', got '$($Payload.oid)'"
            $AllMatch = $false
        }

        # Check scopes only if the scopes parameter is provided
        if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey("scopes")) {
            $scpSplit = $Payload.scp -split ' ' | ForEach-Object { $_.Trim() }
            foreach ($Scope in $Scopes) {
                if ($scpSplit -notcontains $Scope) {
                    Write-Verbose "Scope '$Scope' not found in token"
                    $AllMatch = $false
                }
            }
        }

        # Check wids only if the wids parameter is provided
        if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey("wids")) {
            foreach ($Wid in $Wids) {
                if ($Payload.wids -notcontains $Wid) {
                    Write-Verbose "Wid '$Wid' not found in token"
                    $AllMatch = $false
                }
            }
        }

        # Check roles only if the roles parameter is provided
        if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey("roles")) {
            foreach ($Role in $Roles) {
                if ($Payload.roles -notcontains $Role) {
                    Write-Verbose "Role '$Role' not found in token"
                    $AllMatch = $false
                }
            }
        }

        # Check other claims only if the OtherClaims parameter is provided
        if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey("OtherClaims")) {
            foreach ($Key in $OtherClaims.Keys) {
                if (-not $Payload.ContainsKey($Key)) {
                    Write-Verbose "Claim '$Key' not found in token"
                    $AllMatch = $false
                }
                elseif ($Payload[$Key] -ne $OtherClaims[$Key]) {
                    Write-Verbose "Claim '$Key' does not match: expected '$($OtherClaims[$Key])', got '$($Payload[$Key])'"
                    $AllMatch = $false
                }
            }
        }

        if ($AllMatch) {
            return $AccessToken
        }
        else {
            Write-Verbose "Token does not match the specified criteria"
        }
    }
}