ValidateAADJwt.psm1
using namespace System.Security.Cryptography.X509Certificates using namespace System.Security.Cryptography class TokenExpiredException : System.Exception { TokenExpiredException ([string] $Message) : base($Message){ } } class TokenVersionValidationFailedException : System.Exception { TokenVersionValidationFailedException ([string] $Message) : base($Message){ } } class TokenAudienceValidationFailedException : System.Exception { TokenAudienceValidationFailedException ([string] $Message) : base($Message){ } } class TokenSignatureValidationFailedException : System.Exception { TokenSignatureValidationFailedException ([string] $Message) : base($Message){ } } class TokenAzpacrValidationFailedException : System.Exception { TokenAzpacrValidationFailedException ([string] $Message) : base($Message){ } } class TokenTypValidationFailedException : System.Exception { TokenTypValidationFailedException ([string] $Message) : base($Message){ } } class TokenAlgValidationFailedException : System.Exception { TokenAlgValidationFailedException ([string] $Message) : base($Message){ } } class TokenKidValidationFailedException : System.Exception { TokenKidValidationFailedException ([string] $Message) : base($Message){ } } class TokenAzpValidationFailedException : System.Exception { TokenAzpValidationFailedException ([string] $Message) : base($Message){ } } class TokenIssValidationFailedException : System.Exception { TokenIssValidationFailedException ([string] $Message) : base($Message){ } } class TokenUnusableException : System.Exception { TokenUnusableException ([string] $Message) : base($Message){ } } Function Add-PublicKeysToCache { [cmdletbinding()] param( [parameter(Mandatory)] [string]$Kid ) Write-Verbose 'Add-PublicKeysToCache - Begin function' $HOMEPath = Get-HomePath $FullPath = Join-Path -Path $HOMEPath -ChildPath ".validateaadjwt" -AdditionalChildPath $Kid $x5c = Find-AzureX5c -Kid $Kid Write-Verbose 'Add-PublicKeysToCache - Update cache with new value' Set-Content -Path $FullPath -Value $x5c -Force Write-Verbose 'Add-PublicKeysToCache - End function' } function ConvertTo-X509Certificate2 { [CmdletBinding()] [OutputType([System.Security.Cryptography.X509Certificates.X509Certificate2])] param( [parameter(Mandatory)]$x5c ) $ModCertInfo = @" -----BEGIN CERTIFICATE----- $($x5c) -----END CERTIFICATE----- "@ $cBytes = [System.Text.Encoding]::UTF8.GetBytes($ModCertInfo) [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($cBytes) } function Find-AzureX5c { [CmdletBinding()] param( [Parameter(Mandatory)][String]$Kid ) Write-Verbose 'Find-AzureX5c - Begin function' $ErrorActionPreference = 'Stop' try{ #According to https://docs.microsoft.com/fr-fr/azure/active-directory/develop/access-tokens#validating-tokens $uri = 'https://login.microsoftonline.com/common/.well-known/openid-configuration' $WellKnownInfo = Invoke-RestMethod -Uri $uri -Method GET $PublicAADKeysURI = $WellKnownInfo.jwks_uri Write-Verbose "Find-AzureX5c - AAD Keys URI: $PublicAADKeysURI" $AADPublicKeys = Invoke-RestMethod -Uri $PublicAADKeysURI -Method GET Write-Verbose "Find-AzureX5c - AAD Keys: $AADPublicKeys" #Let's see if your Kid (cert thumbprint) parameter exist in Azure. If empty means your token is a bad one. If exist, means we have to pick one of Azure pubkey. $UsedKey = $AADPublicKeys.keys.Kid -contains $Kid Write-Verbose "Find-AzureX5c - AAD Used Key: $UsedKey" if ($UsedKey) { #$X5c represent the public key that has been used to encrypt your token Write-Verbose "Find-AzureX5c - Get public key value" $x5c = $AADPublicKeys.keys | Where-Object { $_.Kid -eq $Kid } | Select-Object -ExpandProperty x5c Write-Verbose 'Find-AzureX5c - End function' } else { Write-Verbose 'Find-AzureX5c - End function' $x5c = $null } return $x5c } catch{ New-CustomExceptionGenerator -SignatureValidationFailed } } function Get-HomePath { [CmdletBinding()] param() Write-Verbose 'Get-HomePath - Begin function' if ($env:FUNCTIONS_WORKER_RUNTIME -eq 'Powershell') { Write-Verbose 'Get-HomePath - Azure function detected' $HOMEPath = [Environment]::GetEnvironmentVariable('TEMP') } elseif($IsLinux){ Write-Verbose 'Get-HomePath - Linux detected' $HOMEPath = [Environment]::GetEnvironmentVariable('HOME') } else{ Write-Verbose 'Get-HomePath - Windows detected' $HOMEPath = Join-Path $env:HOMEDRIVE $env:HOMEPATH } Write-Verbose 'Get-HomePath - End function' return $HOMEPath } Function Get-PublicKeysFromCache { [cmdletbinding()] param( [parameter(Mandatory)] [string]$Kid ) Write-Verbose 'Get-PublicKeysFromCache - Begin function' #Define $HOMEPath variable dependin the platform $HOMEPath = Get-HomePath $FullPath = Join-Path -Path $HOMEPath -ChildPath ".validateaadjwt" -AdditionalChildPath $Kid Get-Content -Path $FullPath Write-Verbose 'Get-PublicKeysFromCache - End function' } function New-CacheFolder { [CmdletBinding()] param() Write-Verbose 'New-CacheFolder - Begin function' $Path = Join-Path -Path $(Get-HomePath) -ChildPath ".validateaadjwt" $null = New-Item -Path $path -ItemType Directory -Force Write-Verbose 'New-CacheFolder - Cache folder created' Write-Verbose 'New-CacheFolder - End function' } function New-CustomExceptionGenerator { param( [Parameter(Mandatory=$true,ParameterSetName='TokenExpired')] [switch]$TokenExpired, [Parameter(Mandatory=$true,ParameterSetName='VersionValidationFailed')] [switch]$VersionValidationFailed, [Parameter(Mandatory=$true,ParameterSetName='AudienceValidationFailed')] [switch]$AudienceValidationFailed, [Parameter(Mandatory=$true,ParameterSetName='SignatureValidationFailed')] [switch]$SignatureValidationFailed, [Parameter(Mandatory=$true,ParameterSetName='AzpacrValidationFailed')] [switch]$AzpacrValidationFailed, [Parameter(Mandatory=$true,ParameterSetName='AzpValidationFailed')] [switch]$AzpValidationFailed, [Parameter(Mandatory=$true,ParameterSetName='IssuerFailed')] [switch]$IssuerValidationFailed, [Parameter(Mandatory=$true,ParameterSetName='TokenUnusable')] [switch]$TokenUnusable ) # This function is a wrapper to generate custom terminated exception from classes (look _CustomExceptions.ps1) $null = $MyError switch($PSBoundParameters.Keys){ 'TokenExpired'{ $MyError = [TokenExpiredException]::new('Token provided is expired') break } 'VersionValidationFailed'{ $MyError = [TokenVersionValidationFailedException]::new('Token provided does not use the 2.0 endpoint version') break } 'AudienceValidationFailed'{ $MyError = [TokenAudienceValidationFailedException]::new('Token provided does target the right audience') break } 'SignatureValidationFailed'{ $MyError = [TokenSignatureValidationFailedException]::new('The signature of the provided token cannot be verified') break } 'AzpacrValidationFailed'{ $MyError = [TokenAzpacrValidationFailedException]::new('Token provided are not sent by a public application') break } 'AzpValidationFailed'{ $MyError = [TokenAzpValidationFailedException]::new('Token provided are not sent by a trusted application') break } 'IssuerValidationFailed'{ $MyError = [TokenIssValidationFailedException]::new('Token issuer is not valid') break } 'TokenUnusable'{ $MyError = [TokenUnusableException]::new('Token provided are not usable') break } 'TypValidationFailed'{ $MyError = [TokenTypValidationFailedException]::new('Token typ is not valid') break } 'AlgValidationFailed'{ $MyError = [TokenAlgValidationFailedException]::new('Token alg is not valid') break } 'KidValidationFailed'{ $MyError = [TokenKidValidationFailedException]::new('Token kid is not valid') break } } throw $MyError } function Test-CacheFolder { [CmdletBinding()] [OutputType([Bool])] param() Write-Verbose 'Test-CacheFolder - Begin function' $Path = Join-Path -Path $(Get-HomePath) -ChildPath ".validateaadjwt" Write-Verbose 'Test-CacheFolder- End function' Test-Path $Path } Function Test-PublicKeysToCache { [cmdletbinding()] param( [parameter(Mandatory)] [string]$Kid ) Write-Verbose 'Test-PublicKeysToCache - Begin function' #Define $HOMEPath variable dependin the platform $HOMEPath = Get-HomePath $FullPath = Join-Path -Path $HOMEPath -ChildPath ".validateaadjwt" -AdditionalChildPath $Kid Write-Verbose 'Test-PublicKeysToCache - End function' Test-Path $FullPath } function Clear-PublicKeysCache { [CmdletBinding()] param() Write-Verbose 'Clear-PublicKeysCache - Begin function' $Path = Join-Path -Path $(Get-HomePath) -ChildPath ".validateaadjwt" Remove-Item -Path $Path Write-Verbose 'Clear-PublicKeysCache - End function' } function ConvertFrom-Jwt { <# .SYNOPSIS This function will decode a base64 JWT token. .DESCRIPTION Big thank you to both Darren Robinson (https://github.com/darrenjrobinson/JWTDetails/blob/master/JWTDetails/1.0.0/JWTDetails.psm1) and Mehrdad Mirreza in the comment of the blog post (https://www.michev.info/Blog/Post/2140/decode-jwt-access-and-id-tokens-via-powershell) I've used both article for inspiration because: Darren does not have header wich is a mandatory peace according to me and Mehrdad does not have signature which is also a mandatory piece. .PARAMETER Token Specify the access token you want to decode .EXAMPLE PS> ConvertFrom-Jwt -Token "ey...." "will decode the token" .NOTES VERSION HISTORY 1.0 | 2021/07/06 | Francois LEON initial version POSSIBLE IMPROVEMENT - #> [cmdletbinding()] param( [Parameter(Mandatory = $true)] [string]$Token ) Write-Verbose 'ConvertFrom-Jwt - Begin function' $ErrorActionPreference = 'Stop' $Token = $Token.Replace('Bearer ','') try { # Validate as per https://tools.ietf.org/html/rfc7519 # Access and ID tokens are fine, Refresh tokens will not work if (!$Token.Contains('.') -or !$Token.StartsWith('eyJ')) { Write-Error 'Invalid token' -ErrorAction Stop } # Extract header and payload $tokenheader, $tokenPayload, $tokensignature = $Token.Split('.').Replace('-', '+').Replace('_', '/')[0..2] # Fix padding as needed, keep adding '=' until string length modulus 4 reaches 0 while ($tokenheader.Length % 4) { Write-Debug 'Invalid length for a Base-64 char array or string, adding ='; $tokenheader += '=' } while ($tokenPayload.Length % 4) { Write-Debug 'Invalid length for a Base-64 char array or string, adding ='; $tokenPayload += '=' } while ($tokenSignature.Length % 4) { Write-Debug 'Invalid length for a Base-64 char array or string, adding ='; $tokenSignature += '=' } Write-Verbose "ConvertFrom-Jwt - Base64 encoded (padded) header:`n$tokenheader" Write-Verbose "ConvertFrom-Jwt - Base64 encoded (padded) payoad:`n$tokenPayload" Write-Verbose "ConvertFrom-Jwt - Base64 encoded (padded) payoad:`n$tokenSignature" # Convert header from Base64 encoded string to PSObject all at once $header = [System.Text.Encoding]::ASCII.GetString([system.convert]::FromBase64String($tokenheader)) | ConvertFrom-Json # Convert payload to string array $tokenArray = [System.Text.Encoding]::ASCII.GetString([System.Convert]::FromBase64String($tokenPayload)) # Convert from JSON to PSObject $tokobj = $tokenArray | ConvertFrom-Json # Convert Expiry time to PowerShell DateTime $orig = (Get-Date -Year 1970 -Month 1 -Day 1 -hour 0 -Minute 0 -Second 0 -Millisecond 0) $timeZone = Get-TimeZone $utcTime = $orig.AddSeconds($tokobj.exp) $hoursOffset = $timeZone.GetUtcOffset($(Get-Date)).hours #Daylight saving needs to be calculated $localTime = $utcTime.AddHours($hoursOffset) # Return local time, # Time to Expiry $timeToExpiry = ($localTime - (get-date)) Write-Verbose 'ConvertFrom-Jwt - End function' [pscustomobject]@{ Tokenheader = $header TokenPayload = $tokobj TokenSignature = $tokenSignature TokenExpiryDateTime = $localTime TokentimeToExpiry = $timeToExpiry } } catch { New-CustomExceptionGenerator -TokenUnusable } } function Test-AADJWTSignature { <# .SYNOPSIS This function will validate Azure Active Directory token signature and other critical claims. .DESCRIPTION this function will also cache locally the public key used for the token signature to speed things up with offline token signature. .PARAMETER Token Specify the access token you want to verify. .PARAMETER TenantId Specify the Azure tenantId used to sign the token. .EXAMPLE PS> Test-AADJWTSignature -Token $AccessToken -TenantId "<my tenantid>" True means the token received is safe to use. .NOTES VERSION HISTORY 1.0 | 2023/003/27 | Francois LEON initial version POSSIBLE IMPROVEMENT - #> [CmdletBinding()] [OutputType([Bool])] param( [Parameter(Mandatory)][String]$Token, [Parameter(Mandatory)][string]$TenantId ) begin { Write-Verbose 'Test-AADJWTSignature - Begin function' # To avoid issue when we activate Azure func auth $Token = $Token.Replace('Bearer ','') if (-not $(Test-CacheFolder)) { New-CacheFolder } else { Write-Verbose 'Test-AADJWTSignature - Cache detected' } } process { $Jwt = ConvertFrom-Jwt -Token $Token -erroraction stop # Drop wrong token as quickly as possible # Azure expose only JWT Token with algorithm RS256, iss "https://login.microsoftonline.com/<tenantid>/v2.0" or "https://sts.windows.net/<tenantId>/" if ($Jwt.Tokenheader.typ -ne 'JWT') { Write-Verbose 'Test-AADJWTSignature - typ not equal JWT' New-CustomExceptionGenerator -TypValidationFailed } if ($Jwt.Tokenheader.alg -ne 'RS256') { Write-Verbose 'Test-AADJWTSignature - typ not equal alg' New-CustomExceptionGenerator -AlgValidationFailed } $exp = (Get-Date 01.01.1970) + ([System.TimeSpan]::fromseconds($Jwt.TokenPayload.exp)) if ((New-TimeSpan -Start $(Get-Date -AsUTC) -End $exp).TotalSeconds -lt 0) { New-CustomExceptionGenerator -TokenExpired } $iss = @("https://login.microsoftonline.com/$tenantid/v2.0","https://sts.windows.net/$tenantid/") #v1 and v2 endpoint included if ($Jwt.TokenPayload.iss -notin $iss) { Write-Verbose 'Test-AADJWTSignature - not issued by Azure' New-CustomExceptionGenerator -IssuerValidationFailed } if ($null -eq $Jwt.Tokenheader.kid) { Write-Verbose 'Test-AADJWTSignature - kid not defined' New-CustomExceptionGenerator -KidValidationFailed } # Now it's time to read the signature $kid = $Jwt.Tokenheader.kid #Update cache if needed for offline signature check if (-not $(Test-PublicKeysToCache -Kid $kid)) { Write-Verbose 'Test-AADJWTSignature - No cache detected' Add-PublicKeysToCache -Kid $kid } $x5c = Get-PublicKeysFromCache -Kid $kid $x509 = ConvertTo-X509Certificate2 -x5c $x5c # Now we can calculate the signature try { Write-Verbose "Test-AADJWTSignature - Verifying JWT signature" $parts = $Token.Split('.') #Compute our hash $SHA256 = New-Object Security.Cryptography.SHA256Managed $computed = $SHA256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($parts[0] + '.' + $parts[1])) # Computing SHA-256 hash of the JWT parts 1 and 2 - header and payload # Grab signature we received $signed = $parts[2].replace('-', '+').replace('_', '/') # Decoding Base64url to the original byte array $mod = $signed.Length % 4 switch ($mod) { 0 { $signed = $signed } 1 { $signed = $signed.Substring(0, $signed.Length - 1) } 2 { $signed = $signed + '==' } 3 { $signed = $signed + '=' } } $bytes = [Convert]::FromBase64String($signed) # Conversion completed #Compare the two return $x509.PublicKey.Key.VerifyHash($computed, $bytes, [Security.Cryptography.HashAlgorithmName]::SHA256, [Security.Cryptography.RSASignaturePadding]::Pkcs1) # Returns True if the hash verifies successfully } catch { New-CustomExceptionGenerator -SignatureValidationFailed } } } Export-ModuleMember -Function 'Clear-PublicKeysCache', 'ConvertFrom-Jwt', 'Test-AADJWTSignature' |