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