Jwt.psm1
|
[CmdletBinding()] param() $baseName = [System.IO.Path]::GetFileNameWithoutExtension($PSCommandPath) $script:PSModuleInfo = Import-PowerShellDataFile -Path "$PSScriptRoot\$baseName.psd1" $script:PSModuleInfo | Format-List | Out-String -Stream | ForEach-Object { Write-Debug $_ } $scriptName = $script:PSModuleInfo.Name Write-Debug "[$scriptName] - Importing module" #region [functions] - [public] Write-Debug "[$scriptName] - [functions] - [public] - Processing folder" #region [functions] - [public] - [ConvertFrom-Base64UrlString] Write-Debug "[$scriptName] - [functions] - [public] - [ConvertFrom-Base64UrlString] - Importing" function ConvertFrom-Base64UrlString { <# .SYNOPSIS Decodes a base64url string. .DESCRIPTION Decodes a base64url-encoded string to UTF-8 text by default. Use AsByteArray to return the decoded bytes. .EXAMPLE ```powershell 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9' | ConvertFrom-Base64UrlString ``` Decodes the base64url value to `{"alg":"RS256","typ":"JWT"}`. .INPUTS System.String .OUTPUTS System.String System.Byte[] .NOTES Converts JWT-safe base64url text by restoring standard base64 characters and padding before decoding. .LINK https://psmodule.io/Jwt/Functions/ConvertFrom-Base64UrlString/ .LINK https://jwt.io/ #> [OutputType([string], [byte[]])] [CmdletBinding()] param( # The base64url-encoded string to decode. [Parameter(Mandatory, ValueFromPipeline, Position = 0)] [ValidateNotNullOrEmpty()] [string] $Base64UrlString, # Return decoded bytes instead of UTF-8 text. [Parameter()] [switch] $AsByteArray ) begin {} process { $base64String = $Base64UrlString.Replace('-', '+').Replace('_', '/') switch ($base64String.Length % 4) { 0 { } 1 { throw [System.FormatException]::new('Invalid base64url string length.') } 2 { $base64String = $base64String + '==' } 3 { $base64String = $base64String + '=' } } if ($AsByteArray) { [Convert]::FromBase64String($base64String) } else { [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($base64String)) } } end {} } Write-Debug "[$scriptName] - [functions] - [public] - [ConvertFrom-Base64UrlString] - Done" #endregion [functions] - [public] - [ConvertFrom-Base64UrlString] #region [functions] - [public] - [ConvertTo-Base64UrlString] Write-Debug "[$scriptName] - [functions] - [public] - [ConvertTo-Base64UrlString] - Importing" function ConvertTo-Base64UrlString { <# .SYNOPSIS Encodes text or bytes as a base64url string. .DESCRIPTION Encodes a string or byte array using base64url encoding suitable for JWT headers, payloads, and signatures. .EXAMPLE ```powershell '{"alg":"RS256","typ":"JWT"}' | ConvertTo-Base64UrlString ``` Encodes the JWT header JSON as `eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9`. .INPUTS System.String System.Byte[] .OUTPUTS System.String .NOTES Converts standard base64 output to JWT-safe base64url text by replacing URL-sensitive characters and removing padding. .LINK https://psmodule.io/Jwt/Functions/ConvertTo-Base64UrlString/ .LINK https://jwt.io/ #> [OutputType([string])] [CmdletBinding()] param( # The string or byte array to encode. [Parameter(Mandatory, ValueFromPipeline, Position = 0)] [ValidateNotNull()] [Alias('in')] [object] $InputObject ) begin {} process { if ($InputObject -is [string]) { $bytes = [System.Text.Encoding]::UTF8.GetBytes($InputObject) [Convert]::ToBase64String($bytes) -replace '\+', '-' -replace '/', '_' -replace '=' } elseif ($InputObject -is [byte[]]) { [Convert]::ToBase64String($InputObject) -replace '\+', '-' -replace '/', '_' -replace '=' } else { $type = $InputObject.GetType() $message = "ConvertTo-Base64UrlString requires string or byte array input, received $type" throw [System.ArgumentException]::new($message) } } end {} } Write-Debug "[$scriptName] - [functions] - [public] - [ConvertTo-Base64UrlString] - Done" #endregion [functions] - [public] - [ConvertTo-Base64UrlString] #region [functions] - [public] - [Get-JwtHeader] Write-Debug "[$scriptName] - [functions] - [public] - [Get-JwtHeader] - Importing" function Get-JwtHeader { <# .SYNOPSIS Gets the decoded header from a JWT. .DESCRIPTION Decodes and returns the JSON header segment from a JSON Web Token. The payload and signature are ignored. .EXAMPLE ```powershell $jwt = 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJqb2UiLCJyb2xlIjoiYWRtaW4ifQ.' #gitleaks:allow Get-JwtHeader -Jwt $jwt ``` Gets the decoded header JSON from an unsigned JWT. .INPUTS System.String .OUTPUTS System.String .NOTES This command decodes only the header segment and does not validate the token signature. .LINK https://psmodule.io/Jwt/Functions/Get-JwtHeader/ .LINK https://jwt.io/ #> [OutputType([string])] [CmdletBinding()] param( # The JWT to read. [Parameter(Mandatory, ValueFromPipeline, Position = 0)] [ValidateNotNullOrEmpty()] [string] $Jwt ) begin {} process { Write-Verbose "Processing JWT with length $($Jwt.Length) characters" $parts = $Jwt.Split('.') if ($parts.Count -ne 3) { throw [System.ArgumentException]::new('JWT must have exactly 3 segments.') } if (-not $parts[0]) { throw [System.ArgumentException]::new('JWT header segment is missing.') } ConvertFrom-Base64UrlString $parts[0] } end {} } Write-Debug "[$scriptName] - [functions] - [public] - [Get-JwtHeader] - Done" #endregion [functions] - [public] - [Get-JwtHeader] #region [functions] - [public] - [Get-JwtPayload] Write-Debug "[$scriptName] - [functions] - [public] - [Get-JwtPayload] - Importing" function Get-JwtPayload { <# .SYNOPSIS Gets the decoded payload from a JWT. .DESCRIPTION Decodes and returns the JSON payload segment from a JSON Web Token. The header and signature are ignored. .EXAMPLE ```powershell $jwt | Get-JwtPayload ``` Gets the decoded payload JSON from a JWT. .INPUTS System.String .OUTPUTS System.String .NOTES This command decodes only the payload segment and does not validate the token signature. .LINK https://psmodule.io/Jwt/Functions/Get-JwtPayload/ .LINK https://jwt.io/ #> [OutputType([string])] [CmdletBinding()] param( # The JWT to read. [Parameter(Mandatory, ValueFromPipeline, Position = 0)] [ValidateNotNullOrEmpty()] [string] $Jwt ) begin {} process { Write-Verbose "Processing JWT with length $($Jwt.Length) characters" $parts = $Jwt.Split('.') if ($parts.Count -ne 3) { throw [System.ArgumentException]::new('JWT must have exactly 3 segments.') } if (-not $parts[1]) { throw [System.ArgumentException]::new('JWT payload segment is missing.') } ConvertFrom-Base64UrlString $parts[1] } end {} } Write-Debug "[$scriptName] - [functions] - [public] - [Get-JwtPayload] - Done" #endregion [functions] - [public] - [Get-JwtPayload] #region [functions] - [public] - [New-Jwt] Write-Debug "[$scriptName] - [functions] - [public] - [New-Jwt] - Importing" function New-Jwt { <# .SYNOPSIS Creates a JSON Web Token. .DESCRIPTION Creates a JWT from JSON header and payload strings. Supports RS256 with a signing certificate, HS256 with a shared secret, and the none algorithm. .EXAMPLE ```powershell $payload = '{"sub":"1234567890","name":"John Doe","admin":true,"iat":1516239022}' $secret = 'a-string-secret-at-least-256-bits-long' New-Jwt -Header '{"alg":"HS256","typ":"JWT"}' -PayloadJson $payload -Secret $secret ``` Creates an HS256-signed JWT. .EXAMPLE ```powershell $cert = (Get-ChildItem Cert:\CurrentUser\My)[1] $jwt = New-Jwt -Cert $cert -PayloadJson '{"token1":"value1","token2":"value2"}' $jwt.Split('.').Count ``` Creates an RS256-signed JWT with a certificate private key and returns the number of JWT segments. .INPUTS System.String .OUTPUTS System.String .NOTES RS256 requires a certificate with a private key. HS256 requires a string or byte array secret. .LINK https://psmodule.io/Jwt/Functions/New-Jwt/ .LINK https://jwt.io/ #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'New-Jwt creates an in-memory token and does not change system state.' )] [OutputType([string])] [CmdletBinding()] param( # The JWT header JSON. [Parameter()] [ValidateNotNullOrEmpty()] [string] $Header = '{"alg":"RS256","typ":"JWT"}', # The JWT payload JSON. [Parameter(Mandatory, ValueFromPipeline, Position = 0)] [ValidateNotNullOrEmpty()] [string] $PayloadJson, # The signing certificate to use for RS256 tokens. [Parameter()] [ValidateNotNull()] [System.Security.Cryptography.X509Certificates.X509Certificate2] $Cert, # The string or byte array secret to use for HS256 tokens. [Parameter()] [ValidateNotNull()] [object] $Secret ) begin {} process { Write-Verbose "Payload to sign length: $($PayloadJson.Length) characters" try { $algorithm = (ConvertFrom-Json -InputObject $Header -ErrorAction Stop).alg } catch { $message = "The supplied JWT header is not valid JSON. Header length: $($Header.Length) characters." throw [System.FormatException]::new($message) } if ([string]::IsNullOrEmpty($algorithm)) { throw [System.FormatException]::new('The JWT header is missing the required "alg" claim.') } Write-Verbose "Algorithm: $algorithm" try { $null = ConvertFrom-Json -InputObject $PayloadJson -ErrorAction Stop } catch { $message = "The supplied JWT payload is not valid JSON. Payload length: $($PayloadJson.Length) characters." throw [System.FormatException]::new($message) } $encodedHeader = ConvertTo-Base64UrlString $Header $encodedPayload = ConvertTo-Base64UrlString $PayloadJson $jwtContent = $encodedHeader + '.' + $encodedPayload $contentBytes = [System.Text.Encoding]::UTF8.GetBytes($jwtContent) switch ($algorithm) { 'RS256' { if (-not $PSBoundParameters.ContainsKey('Cert')) { $message = 'RS256 requires a -Cert parameter of type X509Certificate2.' throw [System.ArgumentException]::new($message, 'Cert') } Write-Verbose "Signing certificate: $($Cert.Subject)" $rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Cert) if ($null -eq $rsa) { $message = 'The supplied certificate has no RSA private key and cannot be used to sign.' throw [System.ArgumentException]::new($message, 'Cert') } else { try { $signature = $rsa.SignData( $contentBytes, [Security.Cryptography.HashAlgorithmName]::SHA256, [Security.Cryptography.RSASignaturePadding]::Pkcs1 ) $encodedSignature = ConvertTo-Base64UrlString $signature } catch { $message = "Signing with SHA256 and Pkcs1 padding failed using the certificate private key: $_" throw [System.Exception]::new($message, $_.Exception) } finally { $rsa.Dispose() } } } 'HS256' { if (-not ($PSBoundParameters.ContainsKey('Secret'))) { throw [System.ArgumentException]::new('HS256 requires a -Secret parameter.', 'Secret') } if ($Secret -isnot [byte[]] -and $Secret -isnot [string]) { $message = "Expected Secret parameter as byte array or string, instead got $($Secret.GetType())" throw [System.ArgumentException]::new($message, 'Secret') } $hmacsha256 = [System.Security.Cryptography.HMACSHA256]::new() try { $hmacsha256.Key = if ($Secret -is [byte[]]) { $Secret } else { [System.Text.Encoding]::UTF8.GetBytes($Secret) } $encodedSignature = ConvertTo-Base64UrlString $hmacsha256.ComputeHash($contentBytes) } catch { throw [System.Exception]::new("Signing with HMACSHA256 failed: $_", $_.Exception) } finally { $hmacsha256.Dispose() } } 'none' { $encodedSignature = $null } default { $message = 'The algorithm is not one of the supported: "RS256", "HS256", "none".' throw [System.NotSupportedException]::new($message) } } $jwtContent + '.' + $encodedSignature } end {} } Write-Debug "[$scriptName] - [functions] - [public] - [New-Jwt] - Done" #endregion [functions] - [public] - [New-Jwt] #region [functions] - [public] - [Test-Jwt] Write-Debug "[$scriptName] - [functions] - [public] - [Test-Jwt] - Importing" function Test-Jwt { <# .SYNOPSIS Tests the cryptographic integrity of a JWT. .DESCRIPTION Verifies a JWT signature using the signing certificate for RS256 or a shared secret for HS256. Tokens using the none algorithm are valid only when the signature segment is empty. .EXAMPLE ```powershell $jwt | Test-Jwt -Secret 'a-string-secret-at-least-256-bits-long' ``` Tests an HS256 JWT with a shared secret. .EXAMPLE ```powershell $jwt | Test-Jwt -Cert $cert ``` Tests an RS256 JWT with a public certificate. .INPUTS System.String .OUTPUTS System.Boolean .NOTES The Verify-JwtSignature alias is preserved for compatibility with the original module command surface. .LINK https://psmodule.io/Jwt/Functions/Test-Jwt/ .LINK https://jwt.io/ #> [OutputType([bool])] [Alias('Verify-JwtSignature')] [CmdletBinding()] param( # The JWT to test. [Parameter(Mandatory, ValueFromPipeline, Position = 0)] [ValidateNotNullOrEmpty()] [string] $Jwt, # The certificate to use for RS256 signature verification. [Parameter()] [ValidateNotNull()] [System.Security.Cryptography.X509Certificates.X509Certificate2] $Cert, # The string or byte array secret to use for HS256 signature verification. [Parameter()] [ValidateNotNull()] [object] $Secret ) begin {} process { Write-Verbose "Verifying JWT with length $($Jwt.Length) characters" $parts = $Jwt.Split('.') if ($parts.Count -ne 3) { throw [System.ArgumentException]::new('JWT must have exactly 3 segments.') } if (-not $parts[0]) { throw [System.ArgumentException]::new('JWT header segment is missing.') } if (-not $parts[1]) { throw [System.ArgumentException]::new('JWT payload segment is missing.') } $header = ConvertFrom-Base64UrlString $parts[0] try { $algorithm = (ConvertFrom-Json -InputObject $header -ErrorAction Stop).alg } catch { $message = "The supplied JWT header segment is not valid JSON. Header length: $($header.Length) characters." throw [System.FormatException]::new($message) } if ([string]::IsNullOrEmpty($algorithm)) { throw [System.FormatException]::new('The JWT header is missing the required "alg" claim.') } Write-Verbose "Algorithm: $algorithm" switch ($algorithm) { 'RS256' { if (-not $PSBoundParameters.ContainsKey('Cert')) { $message = 'RS256 requires a -Cert parameter of type X509Certificate2.' throw [System.ArgumentException]::new($message, 'Cert') } if ([string]::IsNullOrEmpty($parts[2])) { return $false } try { $bytes = ConvertFrom-Base64UrlString $parts[2] -AsByteArray } catch [System.FormatException] { return $false } Write-Verbose "Using certificate with subject: $($Cert.Subject)" $signedContent = [System.Text.Encoding]::UTF8.GetBytes($parts[0] + '.' + $parts[1]) $computed = [System.Security.Cryptography.SHA256]::HashData($signedContent) $rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPublicKey($Cert) if ($null -eq $rsa) { $message = 'The supplied certificate has no RSA public key and cannot be used to verify.' throw [System.ArgumentException]::new($message, 'Cert') } try { $rsa.VerifyHash( $computed, $bytes, [Security.Cryptography.HashAlgorithmName]::SHA256, [Security.Cryptography.RSASignaturePadding]::Pkcs1 ) } finally { $rsa.Dispose() } } 'HS256' { if (-not ($PSBoundParameters.ContainsKey('Secret'))) { throw [System.ArgumentException]::new('HS256 requires a -Secret parameter.', 'Secret') } if ($Secret -isnot [byte[]] -and $Secret -isnot [string]) { $message = "Expected Secret parameter as byte array or string, instead got $($Secret.GetType())" throw [System.ArgumentException]::new($message, 'Secret') } $hmacsha256 = [System.Security.Cryptography.HMACSHA256]::new() try { $hmacsha256.Key = if ($Secret -is [byte[]]) { $Secret } else { [System.Text.Encoding]::UTF8.GetBytes($Secret) } $signedContent = [System.Text.Encoding]::UTF8.GetBytes($parts[0] + '.' + $parts[1]) $signature = $hmacsha256.ComputeHash($signedContent) if (-not $parts[2]) { $false } else { try { $providedSignature = ConvertFrom-Base64UrlString $parts[2] -AsByteArray } catch [System.FormatException] { $providedSignature = $null } if ($null -eq $providedSignature -or $signature.Length -ne $providedSignature.Length) { $false } else { $difference = 0 for ($index = 0; $index -lt $signature.Length; $index++) { $difference = $difference -bor ($signature[$index] -bxor $providedSignature[$index]) } $difference -eq 0 } } } finally { $hmacsha256.Dispose() } } 'none' { $parts[2] -eq '' } default { $message = 'The algorithm is not one of the supported: "RS256", "HS256", "none".' throw [System.NotSupportedException]::new($message) } } } end {} } Write-Debug "[$scriptName] - [functions] - [public] - [Test-Jwt] - Done" #endregion [functions] - [public] - [Test-Jwt] Write-Debug "[$scriptName] - [functions] - [public] - Done" #endregion [functions] - [public] #region Member exporter $exports = @{ Alias = '*' Cmdlet = '' Function = @( 'ConvertFrom-Base64UrlString' 'ConvertTo-Base64UrlString' 'Get-JwtHeader' 'Get-JwtPayload' 'New-Jwt' 'Test-Jwt' ) Variable = '' } Export-ModuleMember @exports #endregion Member exporter |