diff --git a/README.md b/README.md index e88a481..a3d91b1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Jwt -`Jwt` is a PowerShell module for creating and verifying JSON Web Tokens. This repository maintains the current `Jwt` module command surface under PSModule maintenance so existing users can continue to install and use the package from PowerShell Gallery. +`Jwt` is a PowerShell module for creating, parsing, validating, and inspecting [JSON Web Tokens (RFC 7519)](https://datatracker.ietf.org/doc/html/rfc7519) and the JOSE specs it builds on ([RFC 7515 — JWS](https://datatracker.ietf.org/doc/html/rfc7515), [RFC 7517 — JWK](https://datatracker.ietf.org/doc/html/rfc7517), [RFC 7518 — JWA](https://datatracker.ietf.org/doc/html/rfc7518), [RFC 7638 — JWK Thumbprint](https://datatracker.ietf.org/doc/html/rfc7638)). All cryptography uses the .NET BCL — no third-party dependencies. + +> **Breaking change in v2.** The v1 surface (`New-Jwt -PayloadJson`, `Test-Jwt -Cert`, etc.) has been replaced with a typed object model. See [Migration from v1](#migration-from-v1). ## Installation @@ -9,47 +11,171 @@ Install-PSResource -Name Jwt Import-Module -Name Jwt ``` -## Commands +Requires PowerShell 7.6 or newer. Windows PowerShell 5.1 is not supported. + +## Algorithms + +| Family | Algorithms | Key shapes | +| ------ | ----------------------------------- | ----------------------------------------------- | +| HMAC | `HS256`, `HS384`, `HS512` | `byte[]`, raw secret string, `SecureString`, `JwtKey` (kty=oct) | +| RSA | `RS256`, `RS384`, `RS512` | `RSA`, RSA PEM string, `JwtKey` (kty=RSA) | +| RSA-PSS | `PS256`, `PS384`, `PS512` | `RSA`, RSA PEM string, `JwtKey` (kty=RSA) | +| ECDSA | `ES256` (P-256), `ES384` (P-384), `ES512` (P-521) | `ECDsa`, EC PEM string, `JwtKey` (kty=EC) | +| None | `none` | No key. Rejected by `Test-Jwt` unless `-AllowUnsigned` is supplied. | + +The curve attached to an ECDSA key is checked against the algorithm's required curve before any signature work, and HMAC keys are rejected when supplied for an asymmetric algorithm — both block the classic [algorithm-confusion attack](https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/). + +## Public surface -The maintained module exports the same JWT commands and alias used by the current package: +| Function | Purpose | +| ----------------------- | ---------------------------------------------------------------------------------- | +| `New-Jwt` | Create a JWT from header overrides and a claims hashtable; sign locally or `-Unsigned` | +| `ConvertFrom-Jwt` | Parse a compact JWT string into a typed `[Jwt]` (no validation) | +| `Test-Jwt` | Verify the signature and registered claims (`exp`, `nbf`, `iss`, `aud`) | +| `Get-JwtHeader` | Return the parsed `[JwtHeader]` of a token | +| `Get-JwtPayload` | Return the parsed `[JwtPayload]` of a token | +| `Get-JwtClaim` | Return one or more named claims (registered or private) | +| `ConvertTo-JwtKey` | Convert an `RSA` / `ECDsa` / `byte[]` into a `[JwtKey]` (JWK) | +| `ConvertFrom-JwtKey` | Convert a `[JwtKey]` (JWK) back into a .NET key | +| `ConvertTo-JwtKeySet` | Wrap one or more `[JwtKey]` in a `[JwtKeySet]` (JWKS) | +| `ConvertFrom-JwtKeySet` | Parse a JWKS JSON document into a `[JwtKeySet]` | +| `Get-JwtKeyFromSet` | Look up a `[JwtKey]` in a `[JwtKeySet]` by `kid` | +| `Get-JwtKeyThumbprint` | Compute the RFC 7638 JWK thumbprint of a key (`SHA-256` / `SHA-384` / `SHA-512`) | +| `ConvertTo-Base64UrlString` / `ConvertFrom-Base64UrlString` | Base64url codec helpers (RFC 4648 §5) | + +Public types: `[Jwt]`, `[JwtHeader]`, `[JwtPayload]`, `[JwtKey]`, `[JwtKeySet]`, `[JwtBase64Url]`. + +## Create + +### HS256 with a shared secret ```powershell -ConvertFrom-Base64UrlString -ConvertTo-Base64UrlString -Get-JwtHeader -Get-JwtPayload -New-Jwt -Test-Jwt -Verify-JwtSignature +$jwt = New-Jwt -Payload @{ + sub = '1234567890' + name = 'John Doe' + admin = $true + iat = 1516239022 +} -Algorithm HS256 -Key 'a-string-secret-at-least-256-bits-long' + +$jwt.ToString() ``` -## Usage +### RS256 / PS256 with a local RSA key + +```powershell +$rsa = [System.Security.Cryptography.RSA]::Create(2048) +New-Jwt -Payload @{ sub = 'app'; iss = 'https://issuer'; exp = 1900000000 } ` + -Header @{ kid = 'key-1' } -Algorithm RS256 -Key $rsa + +# RSA-PSS variant +New-Jwt -Payload @{ sub = 'app' } -Algorithm PS256 -Key $rsa +``` -Create and validate an HMAC-signed JWT: +### ES256 / ES384 / ES512 with an EC key ```powershell -$header = '{"alg":"HS256","typ":"JWT"}' -$payload = '{"sub":"1234567890","name":"John Doe","admin":true,"iat":1516239022}' -$secret = 'a-string-secret-at-least-256-bits-long' +$ec = [System.Security.Cryptography.ECDsa]::Create( + [System.Security.Cryptography.ECCurve]::CreateFromValue('1.2.840.10045.3.1.7')) # P-256 +New-Jwt -Payload @{ sub = 'app' } -Algorithm ES256 -Key $ec +``` -$jwt = New-Jwt -Header $header -PayloadJson $payload -Secret $secret -Test-Jwt -jwt $jwt -Secret $secret +### Unsigned token, sign externally (HSM / Azure Key Vault) + +```powershell +$jwt = New-Jwt -Payload @{ sub = 'app' } -Algorithm RS256 -Unsigned +$jwt.SigningInput() # 'header.payload' — feed this to your external signer +$jwt.Signature = $externalSig # base64url signature returned by Key Vault / HSM +$jwt.ToString() ``` -Read the header and payload from an existing token: +## Parse ```powershell -Get-JwtHeader -jwt $jwt -Get-JwtPayload -jwt $jwt +$parsed = ConvertFrom-Jwt -Token $compactString +$parsed.Header.alg +$parsed.Payload.sub +$parsed.Payload.AdditionalFields['groups'] ``` -For more information about each command, use PowerShell help: +## Inspect ```powershell -Get-Command -Module Jwt -Get-Help New-Jwt -Full +Get-JwtHeader -Token $compactString +Get-JwtPayload -Token $compactString +Get-JwtClaim -Token $compactString -Name 'sub' +Get-JwtClaim -Token $compactString -Name @('sub', 'role', 'missing') # ordered hashtable, $null for missing ``` +`Get-JwtClaim` silently returns `$null` for a missing single claim; pass `-ErrorIfMissing` to escalate to non-terminating errors. + +## Validate + +```powershell +Test-Jwt -Token $compactString -Key $rsaPublic ` + -Issuer 'https://issuer' -Audience 'api' -ClockSkew ([timespan]::FromMinutes(2)) + +# Structured report +Test-Jwt -Token $compactString -Key $rsaPublic -Detailed +``` + +`-Detailed` returns: + +```text +Valid : True +SignatureValidated : True +Algorithm : RS256 +Checks : @( + @{ Name = 'Algorithm'; Passed = $true; Reason = $null } + @{ Name = 'Signature'; Passed = $true; Reason = $null } + @{ Name = 'Expiration'; Passed = $true; Reason = $null } + @{ Name = 'NotBefore'; Passed = $true; Reason = $null } + @{ Name = 'Issuer'; Passed = $true; Reason = $null } + @{ Name = 'Audience'; Passed = $true; Reason = $null } +) +``` + +## Keys (JWK + JWKS + thumbprints) + +```powershell +$rsa = [System.Security.Cryptography.RSA]::Create(2048) +$jwk = ConvertTo-JwtKey -Key $rsa -KeyId 'key-1' -Algorithm 'RS256' +$jwk.ToJson() + +$rsa2 = ConvertFrom-JwtKey -Key $jwk + +# RFC 7638 thumbprint, suitable as a stable kid +Get-JwtKeyThumbprint -Key $jwk # SHA-256 (default) +Get-JwtKeyThumbprint -Key $jwk -HashAlgorithm SHA384 + +# JWK Set — publish or consume a JWKS endpoint +$set = $jwk1, $jwk2 | ConvertTo-JwtKeySet +$json = $set.ToJson() # publish + +$set2 = ConvertFrom-JwtKeySet -Json (Invoke-RestMethod 'https://issuer/.well-known/jwks.json' | ConvertTo-Json -Depth 100) +$key = Get-JwtKeyFromSet -KeySet $set2 -KeyId (Get-JwtHeader $token).kid +Test-Jwt -Token $token -Key $key +``` + +Supported `kty`: `RSA`, `EC` (P-256 / P-384 / P-521), `oct` (HMAC). + +## Roadmap + +The v2 release covers the JWS half of JOSE end to end (RFC 7515 / 7517 / 7518 §3 / 7519 / 7638). The following are tracked as follow-ups: + +- **JWE — RFC 7516 + RFC 7518 §4–§5.** `Protect-Jwt` / `Unprotect-Jwt` plus the full key-management and content-encryption matrix (`RSA-OAEP-256`, `A128/192/256KW`, `A128/192/256GCMKW`, `dir`, `ECDH-ES` family, `PBES2-*`, content algorithms `A128/192/256GCM`, `A128CBC-HS256` family). Not in scope for v2 because the surface is large and the AES-CBC-HMAC mode in particular requires careful constant-time MAC-then-decrypt to avoid padding-oracle bugs. +- **EdDSA — RFC 8037.** `Ed25519` and `Ed448` over the `OKP` key type. Blocked on first-party Ed25519 support landing in `System.Security.Cryptography`; the project's "no third-party dependencies" rule rules out a BouncyCastle workaround. +- **`RSA1_5` key wrap.** Spec-listed but Bleichenbacher-vulnerable. Will not be implemented; modern profiles use `RSA-OAEP-256`. + +## Migration from v1 + +| v1 | v2 | +| ------------------------------------------------------- | ------------------------------------------------------------------------ | +| `New-Jwt -Header '{...}' -PayloadJson '{...}' -Secret` | `New-Jwt -Payload @{...} -Algorithm HS256 -Key $secret` | +| `New-Jwt -Cert $cert ...` | `$rsa = $cert.GetRSAPrivateKey(); New-Jwt -Key $rsa` | +| `Test-Jwt -Cert $cert ...` | `Test-Jwt -Key $rsa ...` (or `-Key $jwk`) | +| `Get-JwtHeader` / `Get-JwtPayload` returned strings | Now return typed `[JwtHeader]` / `[JwtPayload]` objects | +| `Verify-JwtSignature` alias | Removed — use `Test-Jwt` | + ## Contributing Coder or not, you can contribute to the project! We welcome all contributions. @@ -57,14 +183,10 @@ Coder or not, you can contribute to the project! We welcome all contributions. ### For Users If you don't code, you still sit on valuable information that can make this project even better. If you experience that the -product does unexpected things, throw errors or is missing functionality, you can help by submitting bugs and feature requests. +product does unexpected things, throws errors, or is missing functionality, you can help by submitting bugs and feature requests. Please see the issues tab on this project and submit a new issue that matches your needs. ### For Developers If you do code, we'd love to have your contributions. Please read the [Contribution guidelines](CONTRIBUTING.md) for more information. You can either help by picking up an existing issue or submit a new one if you have an idea for a new feature or improvement. - -## Acknowledgements - -Here is a list of people and projects that helped this project in some way. diff --git a/src/classes/public/Jwt.ps1 b/src/classes/public/Jwt.ps1 new file mode 100644 index 0000000..b54ef2d --- /dev/null +++ b/src/classes/public/Jwt.ps1 @@ -0,0 +1,47 @@ +class Jwt { + [JwtHeader] $Header + [JwtPayload] $Payload + [string] $Signature + [string] $EncodedHeader + [string] $EncodedPayload + + Jwt() {} + + Jwt([JwtHeader] $header, [JwtPayload] $payload) { + $this.Header = $header + $this.Payload = $payload + $this.EncodedHeader = [JwtBase64Url]::EncodeString($header.ToJson()) + $this.EncodedPayload = [JwtBase64Url]::EncodeString($payload.ToJson()) + $this.Signature = '' + } + + Jwt([JwtHeader] $header, [JwtPayload] $payload, [string] $signature) { + $this.Header = $header + $this.Payload = $payload + $this.EncodedHeader = [JwtBase64Url]::EncodeString($header.ToJson()) + $this.EncodedPayload = [JwtBase64Url]::EncodeString($payload.ToJson()) + $this.Signature = $signature + } + + Jwt( + [JwtHeader] $header, + [JwtPayload] $payload, + [string] $signature, + [string] $encodedHeader, + [string] $encodedPayload + ) { + $this.Header = $header + $this.Payload = $payload + $this.EncodedHeader = $encodedHeader + $this.EncodedPayload = $encodedPayload + $this.Signature = $signature + } + + [string] SigningInput() { + return "$($this.EncodedHeader).$($this.EncodedPayload)" + } + + [string] ToString() { + return "$($this.EncodedHeader).$($this.EncodedPayload).$($this.Signature)" + } +} diff --git a/src/classes/public/JwtBase64Url.ps1 b/src/classes/public/JwtBase64Url.ps1 new file mode 100644 index 0000000..ee33ad4 --- /dev/null +++ b/src/classes/public/JwtBase64Url.ps1 @@ -0,0 +1,28 @@ +class JwtBase64Url { + static [string] Encode([byte[]] $bytes) { + if ($null -eq $bytes -or $bytes.Length -eq 0) { return '' } + $b64 = [Convert]::ToBase64String($bytes) + return $b64.TrimEnd('=').Replace('+', '-').Replace('/', '_') + } + + static [string] EncodeString([string] $value) { + if ($null -eq $value) { return '' } + return [JwtBase64Url]::Encode([System.Text.Encoding]::UTF8.GetBytes($value)) + } + + static [byte[]] Decode([string] $value) { + if ([string]::IsNullOrEmpty($value)) { return , [byte[]]::new(0) } + $s = $value.Replace('-', '+').Replace('_', '/') + switch ($s.Length % 4) { + 2 { $s += '==' } + 3 { $s += '=' } + 0 {} + default { throw [System.FormatException]::new("Invalid base64url string length: $($value.Length).") } + } + return [Convert]::FromBase64String($s) + } + + static [string] DecodeString([string] $value) { + return [System.Text.Encoding]::UTF8.GetString([JwtBase64Url]::Decode($value)) + } +} diff --git a/src/classes/public/JwtHeader.ps1 b/src/classes/public/JwtHeader.ps1 new file mode 100644 index 0000000..317cfe6 --- /dev/null +++ b/src/classes/public/JwtHeader.ps1 @@ -0,0 +1,37 @@ +class JwtHeader { + [string] $alg + [string] $typ = 'JWT' + [string] $kid + [hashtable] $AdditionalFields = @{} + + JwtHeader() {} + + JwtHeader([System.Collections.IDictionary] $values) { + if ($null -eq $values) { return } + foreach ($key in $values.Keys) { + switch ($key) { + 'alg' { $this.alg = [string]$values[$key] } + 'typ' { $this.typ = [string]$values[$key] } + 'kid' { $this.kid = [string]$values[$key] } + default { $this.AdditionalFields[$key] = $values[$key] } + } + } + } + + [System.Collections.Specialized.OrderedDictionary] ToOrderedDictionary() { + $o = [ordered]@{} + if ($this.alg) { $o['alg'] = $this.alg } + if ($this.typ) { $o['typ'] = $this.typ } + if ($this.kid) { $o['kid'] = $this.kid } + if ($null -ne $this.AdditionalFields) { + foreach ($key in $this.AdditionalFields.Keys) { + $o[$key] = $this.AdditionalFields[$key] + } + } + return $o + } + + [string] ToJson() { + return ConvertTo-Json -InputObject $this.ToOrderedDictionary() -Depth 100 -Compress + } +} diff --git a/src/classes/public/JwtKey.ps1 b/src/classes/public/JwtKey.ps1 new file mode 100644 index 0000000..58b4dd2 --- /dev/null +++ b/src/classes/public/JwtKey.ps1 @@ -0,0 +1,68 @@ +class JwtKey { + [string] $kty + [string] $use + [string[]] $key_ops + [string] $alg + [string] $kid + [string] $x5u + [string[]] $x5c + [string] $x5t + [string] ${x5t#S256} + + [string] $n + [string] $e + [string] $d + [string] $p + [string] $q + [string] $dp + [string] $dq + [string] $qi + [object[]] $oth + + [string] $crv + [string] $x + [string] $y + + [string] $k + + [hashtable] $AdditionalFields = @{} + + static [string[]] $KnownFields = @( + 'kty', 'use', 'key_ops', 'alg', 'kid', 'x5u', 'x5c', 'x5t', 'x5t#S256', + 'n', 'e', 'd', 'p', 'q', 'dp', 'dq', 'qi', 'oth', + 'crv', 'x', 'y', 'k' + ) + + JwtKey() {} + + JwtKey([hashtable] $values) { + if ($null -eq $values) { return } + foreach ($key in $values.Keys) { + if ([JwtKey]::KnownFields -contains $key) { + $this.$key = $values[$key] + } else { + $this.AdditionalFields[$key] = $values[$key] + } + } + } + + [System.Collections.Specialized.OrderedDictionary] ToOrderedDictionary() { + $o = [ordered]@{} + foreach ($field in [JwtKey]::KnownFields) { + $value = $this.$field + if ($null -eq $value) { continue } + if ($value -is [string] -and [string]::IsNullOrEmpty($value)) { continue } + $o[$field] = $value + } + if ($null -ne $this.AdditionalFields) { + foreach ($key in $this.AdditionalFields.Keys) { + $o[$key] = $this.AdditionalFields[$key] + } + } + return $o + } + + [string] ToJson() { + return ConvertTo-Json -InputObject $this.ToOrderedDictionary() -Depth 100 -Compress + } +} diff --git a/src/classes/public/JwtKeySet.ps1 b/src/classes/public/JwtKeySet.ps1 new file mode 100644 index 0000000..e85013c --- /dev/null +++ b/src/classes/public/JwtKeySet.ps1 @@ -0,0 +1,56 @@ +class JwtKeySet { + [JwtKey[]] $keys = @() + [System.Collections.Specialized.OrderedDictionary] $AdditionalFields = [ordered]@{} + + JwtKeySet() {} + + JwtKeySet([JwtKey[]] $keys) { + if ($null -ne $keys) { $this.keys = $keys } + } + + JwtKeySet([System.Collections.IDictionary] $values) { + if ($null -eq $values) { return } + foreach ($key in $values.Keys) { + if ($key -eq 'keys') { + $list = [System.Collections.Generic.List[JwtKey]]::new() + foreach ($entry in $values[$key]) { + if ($entry -is [JwtKey]) { $list.Add($entry); continue } + if ($entry -is [System.Collections.IDictionary]) { + $list.Add([JwtKey]::new([hashtable]$entry)) + continue + } + throw [System.ArgumentException]::new( + "JWK Set 'keys' entries must be JwtKey or IDictionary. Got [$($entry.GetType().FullName)]." + ) + } + $this.keys = $list.ToArray() + } else { + $this.AdditionalFields[$key] = $values[$key] + } + } + } + + [JwtKey] FindByKid([string] $kid) { + foreach ($key in $this.keys) { + if ($key.kid -eq $kid) { return $key } + } + return $null + } + + [System.Collections.Specialized.OrderedDictionary] ToOrderedDictionary() { + $o = [ordered]@{} + $keyDicts = [System.Collections.Generic.List[System.Collections.Specialized.OrderedDictionary]]::new() + foreach ($key in $this.keys) { + $keyDicts.Add($key.ToOrderedDictionary()) + } + $o['keys'] = $keyDicts.ToArray() + foreach ($field in $this.AdditionalFields.Keys) { + $o[$field] = $this.AdditionalFields[$field] + } + return $o + } + + [string] ToJson() { + return ConvertTo-Json -InputObject $this.ToOrderedDictionary() -Depth 100 -Compress + } +} diff --git a/src/classes/public/JwtPayload.ps1 b/src/classes/public/JwtPayload.ps1 new file mode 100644 index 0000000..8af14a1 --- /dev/null +++ b/src/classes/public/JwtPayload.ps1 @@ -0,0 +1,79 @@ +class JwtPayload { + [string] $iss + [string] $sub + [object] $aud + [Nullable[long]] $exp + [Nullable[long]] $nbf + [Nullable[long]] $iat + [string] $jti + [System.Collections.Specialized.OrderedDictionary] $AdditionalFields = [ordered]@{} + hidden [System.Collections.Generic.List[string]] $_keyOrder = [System.Collections.Generic.List[string]]::new() + + static [string[]] $RegisteredClaims = @('iss', 'sub', 'aud', 'exp', 'nbf', 'iat', 'jti') + + JwtPayload() {} + + JwtPayload([System.Collections.IDictionary] $values) { + if ($null -eq $values) { return } + foreach ($key in $values.Keys) { + $this._keyOrder.Add([string]$key) + switch ([string]$key) { + 'iss' { $this.iss = [string]$values[$key] } + 'sub' { $this.sub = [string]$values[$key] } + 'aud' { $this.aud = $values[$key] } + 'exp' { $this.exp = [long]$values[$key] } + 'nbf' { $this.nbf = [long]$values[$key] } + 'iat' { $this.iat = [long]$values[$key] } + 'jti' { $this.jti = [string]$values[$key] } + default { $this.AdditionalFields[$key] = $values[$key] } + } + } + } + + hidden [object] _GetValueFor([string] $key) { + switch ($key) { + 'iss' { if ($this.iss) { return $this.iss } else { return [System.Management.Automation.Internal.AutomationNull]::Value } } + 'sub' { if ($this.sub) { return $this.sub } else { return [System.Management.Automation.Internal.AutomationNull]::Value } } + 'aud' { if ($null -ne $this.aud) { return $this.aud } else { return [System.Management.Automation.Internal.AutomationNull]::Value } } + 'exp' { if ($null -ne $this.exp) { return [long]$this.exp } else { return [System.Management.Automation.Internal.AutomationNull]::Value } } + 'nbf' { if ($null -ne $this.nbf) { return [long]$this.nbf } else { return [System.Management.Automation.Internal.AutomationNull]::Value } } + 'iat' { if ($null -ne $this.iat) { return [long]$this.iat } else { return [System.Management.Automation.Internal.AutomationNull]::Value } } + 'jti' { if ($this.jti) { return $this.jti } else { return [System.Management.Automation.Internal.AutomationNull]::Value } } + default { + if ($this.AdditionalFields.Contains($key)) { return $this.AdditionalFields[$key] } + return [System.Management.Automation.Internal.AutomationNull]::Value + } + } + return [System.Management.Automation.Internal.AutomationNull]::Value + } + + [System.Collections.Specialized.OrderedDictionary] ToOrderedDictionary() { + $o = [ordered]@{} + $emitted = [System.Collections.Generic.HashSet[string]]::new() + foreach ($key in $this._keyOrder) { + $val = $this._GetValueFor($key) + if ($null -ne $val -and $val -isnot [System.Management.Automation.Internal.AutomationNull]) { + $o[$key] = $val + [void]$emitted.Add($key) + } + } + foreach ($claim in [JwtPayload]::RegisteredClaims) { + if ($emitted.Contains($claim)) { continue } + $val = $this._GetValueFor($claim) + if ($null -ne $val -and $val -isnot [System.Management.Automation.Internal.AutomationNull]) { + $o[$claim] = $val + [void]$emitted.Add($claim) + } + } + foreach ($key in $this.AdditionalFields.Keys) { + if ($emitted.Contains($key)) { continue } + $o[$key] = $this.AdditionalFields[$key] + [void]$emitted.Add($key) + } + return $o + } + + [string] ToJson() { + return ConvertTo-Json -InputObject $this.ToOrderedDictionary() -Depth 100 -Compress + } +} diff --git a/src/functions/private/Get-JwtAlgorithmHash.ps1 b/src/functions/private/Get-JwtAlgorithmHash.ps1 new file mode 100644 index 0000000..e1611f9 --- /dev/null +++ b/src/functions/private/Get-JwtAlgorithmHash.ps1 @@ -0,0 +1,23 @@ +function Get-JwtAlgorithmHash { + <# + .SYNOPSIS + Returns the [HashAlgorithmName] used by a JWS algorithm. + + .DESCRIPTION + Internal helper used by signing and verification to map RFC 7518 §3 algorithm + names to the .NET HashAlgorithmName enum. + #> + [OutputType([System.Security.Cryptography.HashAlgorithmName])] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $Algorithm + ) + + switch -Regex ($Algorithm) { + '256$' { return [System.Security.Cryptography.HashAlgorithmName]::SHA256 } + '384$' { return [System.Security.Cryptography.HashAlgorithmName]::SHA384 } + '512$' { return [System.Security.Cryptography.HashAlgorithmName]::SHA512 } + } + throw [System.NotSupportedException]::new("No hash mapping for algorithm '$Algorithm'.") +} diff --git a/src/functions/private/New-JwtHmac.ps1 b/src/functions/private/New-JwtHmac.ps1 new file mode 100644 index 0000000..8ad44ee --- /dev/null +++ b/src/functions/private/New-JwtHmac.ps1 @@ -0,0 +1,27 @@ +function New-JwtHmac { + <# + .SYNOPSIS + Creates an [HMAC] instance sized for an HS-family JWS algorithm. + + .DESCRIPTION + Internal factory that maps HS256/HS384/HS512 to HMACSHA256/384/512. Centralized + so Resolve-JwtKey, Test-JwtSignature, and New-Jwt all agree on the hash size. + #> + [OutputType([System.Security.Cryptography.HMAC])] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateSet('HS256', 'HS384', 'HS512')] + [string] $Algorithm, + + [Parameter(Mandatory)] + [byte[]] $KeyBytes + ) + + switch ($Algorithm) { + 'HS256' { return [System.Security.Cryptography.HMACSHA256]::new($KeyBytes) } + 'HS384' { return [System.Security.Cryptography.HMACSHA384]::new($KeyBytes) } + 'HS512' { return [System.Security.Cryptography.HMACSHA512]::new($KeyBytes) } + } + return $null +} diff --git a/src/functions/private/Resolve-JwtKey.ps1 b/src/functions/private/Resolve-JwtKey.ps1 new file mode 100644 index 0000000..f9f0f2d --- /dev/null +++ b/src/functions/private/Resolve-JwtKey.ps1 @@ -0,0 +1,202 @@ +function Resolve-JwtKey { + <# + .SYNOPSIS + Resolves a -Key parameter into a typed .NET key for a given JWS algorithm. + + .DESCRIPTION + Performs the algorithm-key compatibility check required by Test-Jwt and New-Jwt + before any signing or verification work. Rejects mismatched key types (e.g., + an RSA public key supplied for an HS256 token) with a terminating error to + block algorithm-confusion attacks. + + Supports all JWS algorithms registered in RFC 7518 §3: + HS256/HS384/HS512, RS256/RS384/RS512, ES256/ES384/ES512, PS256/PS384/PS512, none. + + .EXAMPLE + Resolve-JwtKey -Algorithm 'RS256' -Key $rsaPem + + Returns an [RSA] populated from the PEM-encoded key. + #> + [OutputType([object])] + [CmdletBinding()] + param( + # The algorithm declared by the JWT header. + [Parameter(Mandatory)] + [ValidateSet( + 'HS256', 'HS384', 'HS512', + 'RS256', 'RS384', 'RS512', + 'ES256', 'ES384', 'ES512', + 'PS256', 'PS384', 'PS512', + 'none' + )] + [string] $Algorithm, + + # The key material. Acceptable shapes depend on $Algorithm. + [Parameter()] + [object] $Key + ) + + if ($Algorithm -eq 'none') { + if ($null -ne $Key) { + throw [System.ArgumentException]::new( + "Algorithm 'none' does not accept a key. Remove -Key or pass a non-'none' algorithm.", + 'Key' + ) + } + return $null + } + + if ($null -eq $Key) { + throw [System.ArgumentException]::new("Algorithm '$Algorithm' requires a -Key value.", 'Key') + } + + if ($Key -is [System.Security.SecureString]) { + $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($Key) + try { + $plain = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) + } finally { + [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) + } + $Key = $plain + } + + $family = switch -Regex ($Algorithm) { + '^HS' { 'HS' } + '^RS' { 'RSA' } + '^PS' { 'RSA' } + '^ES' { 'EC' } + } + + $expectedCurve = switch ($Algorithm) { + 'ES256' { 'P-256' } + 'ES384' { 'P-384' } + 'ES512' { 'P-521' } + default { $null } + } + + $expectedCurveOid = switch ($Algorithm) { + 'ES256' { '1.2.840.10045.3.1.7' } + 'ES384' { '1.3.132.0.34' } + 'ES512' { '1.3.132.0.35' } + default { $null } + } + + if ($Key -is [JwtKey]) { + switch ($family) { + 'RSA' { + if ($Key.kty -ne 'RSA') { + throw [System.ArgumentException]::new( + "Algorithm $Algorithm requires a JwtKey with kty='RSA'. Got kty='$($Key.kty)'.", + 'Key' + ) + } + return (ConvertFrom-JwtKey -Key $Key) + } + 'HS' { + if ($Key.kty -ne 'oct') { + throw [System.ArgumentException]::new( + "Algorithm $Algorithm requires a JwtKey with kty='oct'. Got kty='$($Key.kty)'.", + 'Key' + ) + } + $bytes = [JwtBase64Url]::Decode($Key.k) + return (New-JwtHmac -Algorithm $Algorithm -KeyBytes $bytes) + } + 'EC' { + if ($Key.kty -ne 'EC') { + throw [System.ArgumentException]::new( + "Algorithm $Algorithm requires a JwtKey with kty='EC'. Got kty='$($Key.kty)'.", + 'Key' + ) + } + if ($Key.crv -ne $expectedCurve) { + throw [System.ArgumentException]::new( + "Algorithm $Algorithm requires a JwtKey with crv='$expectedCurve'. Got crv='$($Key.crv)'.", + 'Key' + ) + } + return (ConvertFrom-JwtKey -Key $Key) + } + } + } + + switch ($family) { + 'RSA' { + if ($Key -is [System.Security.Cryptography.RSA]) { return $Key } + if ($Key -is [string]) { + if ($Key -notmatch '-----BEGIN [A-Z ]*KEY-----') { + throw [System.ArgumentException]::new( + "Algorithm $Algorithm requires a PEM-encoded RSA key string. The supplied string is not PEM.", + 'Key' + ) + } + $rsa = [System.Security.Cryptography.RSA]::Create() + $rsa.ImportFromPem($Key) + return $rsa + } + throw [System.ArgumentException]::new( + "Algorithm $Algorithm does not accept a key of type [$($Key.GetType().FullName)]. " + + 'Use an RSA instance, a PEM string, or a JwtKey with kty=RSA.', + 'Key' + ) + } + 'HS' { + if ($Key -is [byte[]]) { return (New-JwtHmac -Algorithm $Algorithm -KeyBytes $Key) } + if ($Key -is [string]) { + if ($Key -match '-----BEGIN [A-Z ]*KEY-----') { + throw [System.ArgumentException]::new( + "Algorithm $Algorithm rejected a PEM-encoded key. $Algorithm is symmetric and requires a raw secret. " + + 'This blocks the classic HS+RSA-public-key algorithm-confusion attack.', + 'Key' + ) + } + return (New-JwtHmac -Algorithm $Algorithm -KeyBytes ([System.Text.Encoding]::UTF8.GetBytes($Key))) + } + throw [System.ArgumentException]::new( + "Algorithm $Algorithm does not accept a key of type [$($Key.GetType().FullName)]. " + + 'Use a byte[], a raw secret string/SecureString, or a JwtKey with kty=oct.', + 'Key' + ) + } + 'EC' { + if ($Key -is [System.Security.Cryptography.ECDsa]) { + $params = $Key.ExportParameters($false) + $oid = $params.Curve.Oid.Value + if ($oid -and $oid -ne $expectedCurveOid) { + throw [System.ArgumentException]::new( + "Algorithm $Algorithm requires curve $expectedCurve (OID $expectedCurveOid). The supplied ECDsa key uses OID $oid.", + 'Key' + ) + } + return $Key + } + if ($Key -is [string]) { + if ($Key -notmatch '-----BEGIN [A-Z ]*KEY-----') { + throw [System.ArgumentException]::new( + "Algorithm $Algorithm requires a PEM-encoded EC key string. The supplied string is not PEM.", + 'Key' + ) + } + $ecdsa = [System.Security.Cryptography.ECDsa]::Create() + $ecdsa.ImportFromPem($Key) + $params = $ecdsa.ExportParameters($false) + $oid = $params.Curve.Oid.Value + if ($oid -and $oid -ne $expectedCurveOid) { + $ecdsa.Dispose() + throw [System.ArgumentException]::new( + "Algorithm $Algorithm requires curve $expectedCurve (OID $expectedCurveOid). The supplied EC PEM uses OID $oid.", + 'Key' + ) + } + return $ecdsa + } + throw [System.ArgumentException]::new( + "Algorithm $Algorithm does not accept a key of type [$($Key.GetType().FullName)]. " + + 'Use an ECDsa instance, a PEM string, or a JwtKey with kty=EC.', + 'Key' + ) + } + } + + throw [System.NotSupportedException]::new("Algorithm '$Algorithm' is not supported.") +} diff --git a/src/functions/private/Test-JwtClaim.ps1 b/src/functions/private/Test-JwtClaim.ps1 new file mode 100644 index 0000000..49c991f --- /dev/null +++ b/src/functions/private/Test-JwtClaim.ps1 @@ -0,0 +1,101 @@ +function Test-JwtClaim { + <# + .SYNOPSIS + Validates the registered claims of a JWT payload against a set of constraints. + + .DESCRIPTION + Internal helper used by Test-Jwt. Returns an array of check result hashtables + with Name, Passed, and Reason fields. Audience matching is array-aware per + RFC 7519 §4.1.3. + #> + [OutputType([hashtable[]])] + [CmdletBinding()] + param( + # The payload to validate. + [Parameter(Mandatory)] + [JwtPayload] $Payload, + + # Expected issuer. + [Parameter()] + [string] $Issuer, + + # Accepted audiences. + [Parameter()] + [string[]] $Audience, + + # Allowed clock skew. + [Parameter()] + [timespan] $ClockSkew = [timespan]::Zero, + + # Require an exp claim to be present. + [Parameter()] + [bool] $RequireExpiration = $true, + + # The reference time. Defaults to UtcNow. + [Parameter()] + [datetime] $Now = [datetime]::UtcNow + ) + + $checks = @() + $skewSec = [long]$ClockSkew.TotalSeconds + $nowSec = [DateTimeOffset]::new($Now.ToUniversalTime()).ToUnixTimeSeconds() + + if ($null -ne $Payload.exp) { + $expVal = [long]$Payload.exp + if ($nowSec -gt ($expVal + $skewSec)) { + $expAt = [DateTimeOffset]::FromUnixTimeSeconds($expVal).UtcDateTime.ToString('o') + $checks += @{ Name = 'Expiration'; Passed = $false; Reason = "Token expired at $expAt." } + } else { + $checks += @{ Name = 'Expiration'; Passed = $true; Reason = $null } + } + } else { + if ($RequireExpiration) { + $checks += @{ Name = 'Expiration'; Passed = $false; Reason = "Token has no 'exp' claim." } + } else { + $checks += @{ Name = 'Expiration'; Passed = $true; Reason = $null } + } + } + + if ($null -ne $Payload.nbf) { + $nbfVal = [long]$Payload.nbf + if ($nowSec -lt ($nbfVal - $skewSec)) { + $nbfAt = [DateTimeOffset]::FromUnixTimeSeconds($nbfVal).UtcDateTime.ToString('o') + $checks += @{ Name = 'NotBefore'; Passed = $false; Reason = "Token not valid before $nbfAt." } + } else { + $checks += @{ Name = 'NotBefore'; Passed = $true; Reason = $null } + } + } else { + $checks += @{ Name = 'NotBefore'; Passed = $true; Reason = $null } + } + + if ($PSBoundParameters.ContainsKey('Issuer')) { + if ($Payload.iss -cne $Issuer) { + $checks += @{ Name = 'Issuer'; Passed = $false; Reason = "Issuer '$($Payload.iss)' does not match expected '$Issuer'." } + } else { + $checks += @{ Name = 'Issuer'; Passed = $true; Reason = $null } + } + } else { + $checks += @{ Name = 'Issuer'; Passed = $true; Reason = $null } + } + + if ($PSBoundParameters.ContainsKey('Audience')) { + $tokenAud = $Payload.aud + $tokenAudList = if ($tokenAud -is [array]) { @($tokenAud | ForEach-Object { [string]$_ }) } + elseif ($null -eq $tokenAud) { @() } + else { @([string]$tokenAud) } + + $matched = $false + foreach ($a in $Audience) { + if ($tokenAudList -ccontains $a) { $matched = $true; break } + } + if ($matched) { + $checks += @{ Name = 'Audience'; Passed = $true; Reason = $null } + } else { + $checks += @{ Name = 'Audience'; Passed = $false; Reason = "None of the supplied audiences matched the token's 'aud' claim." } + } + } else { + $checks += @{ Name = 'Audience'; Passed = $true; Reason = $null } + } + + return , $checks +} diff --git a/src/functions/private/Test-JwtSignature.ps1 b/src/functions/private/Test-JwtSignature.ps1 new file mode 100644 index 0000000..1347457 --- /dev/null +++ b/src/functions/private/Test-JwtSignature.ps1 @@ -0,0 +1,89 @@ +function Test-JwtSignature { + <# + .SYNOPSIS + Verifies a JWT signature for one of the supported algorithms. + + .DESCRIPTION + Internal verification primitive used by Test-Jwt. Assumes the algorithm-key + compatibility check has already been done by Resolve-JwtKey. Supports all + JWS algorithms in RFC 7518 §3. + + .EXAMPLE + Test-JwtSignature -SigningInput $jwt.SigningInput() -Signature $jwt.Signature -Algorithm 'HS256' -ResolvedKey $hmac + + Returns $true when the signature matches. + #> + [OutputType([bool])] + [CmdletBinding()] + param( + # The signing input (header.payload). + [Parameter(Mandatory)] + [string] $SigningInput, + + # The base64url-encoded signature segment from the token. + [Parameter()] + [AllowEmptyString()] + [string] $Signature, + + # The JWS algorithm. + [Parameter(Mandatory)] + [ValidateSet( + 'HS256', 'HS384', 'HS512', + 'RS256', 'RS384', 'RS512', + 'ES256', 'ES384', 'ES512', + 'PS256', 'PS384', 'PS512' + )] + [string] $Algorithm, + + # A typed key returned by Resolve-JwtKey. + [Parameter(Mandatory)] + [object] $ResolvedKey + ) + + if ([string]::IsNullOrEmpty($Signature)) { return $false } + + try { + $sigBytes = [JwtBase64Url]::Decode($Signature) + } catch [System.FormatException] { + return $false + } + + $contentBytes = [System.Text.Encoding]::UTF8.GetBytes($SigningInput) + $hash = Get-JwtAlgorithmHash -Algorithm $Algorithm + + switch -Regex ($Algorithm) { + '^RS' { + $rsa = [System.Security.Cryptography.RSA] $ResolvedKey + return $rsa.VerifyData( + $contentBytes, + $sigBytes, + $hash, + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1 + ) + } + '^PS' { + $rsa = [System.Security.Cryptography.RSA] $ResolvedKey + return $rsa.VerifyData( + $contentBytes, + $sigBytes, + $hash, + [System.Security.Cryptography.RSASignaturePadding]::Pss + ) + } + '^HS' { + $hmac = [System.Security.Cryptography.HMAC] $ResolvedKey + $computed = $hmac.ComputeHash($contentBytes) + if ($computed.Length -ne $sigBytes.Length) { return $false } + $diff = 0 + for ($i = 0; $i -lt $computed.Length; $i++) { + $diff = $diff -bor ($computed[$i] -bxor $sigBytes[$i]) + } + return $diff -eq 0 + } + '^ES' { + $ecdsa = [System.Security.Cryptography.ECDsa] $ResolvedKey + return $ecdsa.VerifyData($contentBytes, $sigBytes, $hash) + } + } + return $false +} diff --git a/src/functions/public/ConvertFrom-Base64UrlString.ps1 b/src/functions/public/ConvertFrom-Base64UrlString.ps1 index e6ae97e..13d9ad9 100644 --- a/src/functions/public/ConvertFrom-Base64UrlString.ps1 +++ b/src/functions/public/ConvertFrom-Base64UrlString.ps1 @@ -4,60 +4,30 @@ Decodes a base64url string. .DESCRIPTION - Decodes a base64url-encoded string to UTF-8 text by default. Use AsByteArray to return the decoded bytes. + Internal helper that wraps the [JwtBase64Url] class. Returns a UTF-8 string by + default or the raw byte array when -AsByteArray is supplied. .EXAMPLE - ```powershell - 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9' | ConvertFrom-Base64UrlString - ``` + ConvertFrom-Base64UrlString 'SGVsbG8' - 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/ + Decodes the base64url string and returns the UTF-8 representation. #> [OutputType([string], [byte[]])] [CmdletBinding()] param( - # The base64url-encoded string to decode. - [Parameter(Mandatory, ValueFromPipeline, Position = 0)] - [ValidateNotNullOrEmpty()] - [string] $Base64UrlString, + # The base64url-encoded value to decode. + [Parameter(Mandatory, Position = 0, ValueFromPipeline)] + [ValidateNotNull()] + [string] $InputObject, - # Return decoded bytes instead of UTF-8 text. + # Return the raw bytes instead of a UTF-8 string. [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)) - } + $bytes = [JwtBase64Url]::Decode($InputObject) + if ($AsByteArray) { return , $bytes } + return [System.Text.Encoding]::UTF8.GetString($bytes) } - - end {} } diff --git a/src/functions/public/ConvertFrom-Jwt.ps1 b/src/functions/public/ConvertFrom-Jwt.ps1 new file mode 100644 index 0000000..7929410 --- /dev/null +++ b/src/functions/public/ConvertFrom-Jwt.ps1 @@ -0,0 +1,75 @@ +function ConvertFrom-Jwt { + <# + .SYNOPSIS + Parses a compact JWT string into a typed [Jwt] object. + + .DESCRIPTION + Splits a JWT into its three segments, decodes the header and payload, and + returns a [Jwt] object that round-trips back to the original encoded form + (the parsed segments are kept verbatim). No signature verification is + performed — use Test-Jwt for that. + + .EXAMPLE + $jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqb2UifQ.sig' | ConvertFrom-Jwt + + Parses the token and returns a [Jwt] object. + + .OUTPUTS + Jwt + #> + [OutputType([Jwt])] + [CmdletBinding()] + param( + # The compact JWT string. Pipeline-bound. + [Parameter(Mandatory, Position = 0, ValueFromPipeline)] + [ValidateNotNullOrEmpty()] + [object] $Token + ) + + process { + $tokenString = if ($Token -is [Jwt]) { $Token.ToString() } + elseif ($Token -is [System.Security.SecureString]) { + $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($Token) + try { [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } + finally { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } + } else { [string]$Token } + + $parts = $tokenString.Split('.') + if ($parts.Count -ne 3) { + throw [System.FormatException]::new( + "JWT must have exactly 3 segments separated by '.'. Got $($parts.Count)." + ) + } + if ([string]::IsNullOrEmpty($parts[0])) { + throw [System.FormatException]::new('JWT header segment is empty.') + } + if ([string]::IsNullOrEmpty($parts[1])) { + throw [System.FormatException]::new('JWT payload segment is empty.') + } + + try { $headerJson = [JwtBase64Url]::DecodeString($parts[0]) } + catch [System.FormatException] { + throw [System.FormatException]::new('JWT header segment contains invalid base64url characters.') + } + try { $payloadJson = [JwtBase64Url]::DecodeString($parts[1]) } + catch [System.FormatException] { + throw [System.FormatException]::new('JWT payload segment contains invalid base64url characters.') + } + + try { $headerHash = ConvertFrom-Json -InputObject $headerJson -AsHashtable -Depth 100 -ErrorAction Stop } + catch { throw [System.FormatException]::new('JWT header segment is not valid JSON.') } + try { $payloadHash = ConvertFrom-Json -InputObject $payloadJson -AsHashtable -Depth 100 -ErrorAction Stop } + catch { throw [System.FormatException]::new('JWT payload segment is not valid JSON.') } + + if ($null -eq $headerHash) { + throw [System.FormatException]::new('JWT header segment decoded to null.') + } + if ($null -eq $payloadHash) { + throw [System.FormatException]::new('JWT payload segment decoded to null.') + } + + $jwtHeader = [JwtHeader]::new($headerHash) + $jwtPayload = [JwtPayload]::new($payloadHash) + return [Jwt]::new($jwtHeader, $jwtPayload, $parts[2], $parts[0], $parts[1]) + } +} diff --git a/src/functions/public/ConvertFrom-JwtKey.ps1 b/src/functions/public/ConvertFrom-JwtKey.ps1 new file mode 100644 index 0000000..6d2b273 --- /dev/null +++ b/src/functions/public/ConvertFrom-JwtKey.ps1 @@ -0,0 +1,83 @@ +function ConvertFrom-JwtKey { + <# + .SYNOPSIS + Converts a [JwtKey] (JWK) into a .NET key suitable for signing or verification. + + .DESCRIPTION + Returns an [RSA], [ECDsa], or [HMAC] depending on the JWK kty: + + - kty='RSA' → [RSA] populated from n/e (and optionally d/p/q/dp/dq/qi). + - kty='EC' → [ECDsa] populated from crv/x/y (and optionally d). + - kty='oct' → [HMACSHA256] populated from k. The hash size matches the JWK alg + (HS256 default). + + .EXAMPLE + $rsa = ConvertFrom-JwtKey -Key $jwk + + Returns an RSA usable with Test-Jwt -Key $rsa. + + .OUTPUTS + System.Security.Cryptography.RSA + System.Security.Cryptography.ECDsa + System.Security.Cryptography.HMAC + #> + [OutputType([System.Security.Cryptography.AsymmetricAlgorithm], [System.Security.Cryptography.HMAC])] + [CmdletBinding()] + param( + # The JWK to convert. + [Parameter(Mandatory, Position = 0, ValueFromPipeline)] + [ValidateNotNull()] + [JwtKey] $Key + ) + + process { + switch ($Key.kty) { + 'RSA' { + $params = [System.Security.Cryptography.RSAParameters]::new() + $params.Modulus = [JwtBase64Url]::Decode($Key.n) + $params.Exponent = [JwtBase64Url]::Decode($Key.e) + if ($Key.d) { + $params.D = [JwtBase64Url]::Decode($Key.d) + $params.P = [JwtBase64Url]::Decode($Key.p) + $params.Q = [JwtBase64Url]::Decode($Key.q) + $params.DP = [JwtBase64Url]::Decode($Key.dp) + $params.DQ = [JwtBase64Url]::Decode($Key.dq) + $params.InverseQ = [JwtBase64Url]::Decode($Key.qi) + } + $rsa = [System.Security.Cryptography.RSA]::Create() + $rsa.ImportParameters($params) + return $rsa + } + 'EC' { + $curve = switch ($Key.crv) { + 'P-256' { [System.Security.Cryptography.ECCurve]::CreateFromValue('1.2.840.10045.3.1.7') } + 'P-384' { [System.Security.Cryptography.ECCurve]::CreateFromValue('1.3.132.0.34') } + 'P-521' { [System.Security.Cryptography.ECCurve]::CreateFromValue('1.3.132.0.35') } + default { throw [System.NotSupportedException]::new("EC curve '$($Key.crv)' is not supported.") } + } + $point = [System.Security.Cryptography.ECPoint]@{ + X = [JwtBase64Url]::Decode($Key.x) + Y = [JwtBase64Url]::Decode($Key.y) + } + $params = [System.Security.Cryptography.ECParameters]@{ + Curve = $curve + Q = $point + } + if ($Key.d) { + $params.D = [JwtBase64Url]::Decode($Key.d) + } + $ecdsa = [System.Security.Cryptography.ECDsa]::Create() + $ecdsa.ImportParameters($params) + return $ecdsa + } + 'oct' { + $bytes = [JwtBase64Url]::Decode($Key.k) + return [System.Security.Cryptography.HMACSHA256]::new($bytes) + } + default { + throw [System.NotSupportedException]::new("JWK kty '$($Key.kty)' is not supported.") + } + } + return $null + } +} diff --git a/src/functions/public/ConvertFrom-JwtKeySet.ps1 b/src/functions/public/ConvertFrom-JwtKeySet.ps1 new file mode 100644 index 0000000..175648d --- /dev/null +++ b/src/functions/public/ConvertFrom-JwtKeySet.ps1 @@ -0,0 +1,38 @@ +function ConvertFrom-JwtKeySet { + <# + .SYNOPSIS + Parses a JWK Set (JWKS) JSON string into a [JwtKeySet]. + + .DESCRIPTION + Accepts a JWKS JSON document per RFC 7517 §5 and returns a typed [JwtKeySet] + containing the parsed [JwtKey] entries. Unknown top-level fields are preserved + in AdditionalFields. + + .EXAMPLE + $set = ConvertFrom-JwtKeySet -Json (Invoke-RestMethod 'https://issuer/.well-known/jwks.json' | ConvertTo-Json -Depth 10 -Compress) + + Parses a JWKS retrieved from a discovery endpoint. + + .OUTPUTS + JwtKeySet + #> + [OutputType([JwtKeySet])] + [CmdletBinding()] + param( + # The JWKS JSON document. + [Parameter(Mandatory, Position = 0, ValueFromPipeline)] + [ValidateNotNullOrEmpty()] + [string] $Json + ) + + process { + $parsed = $Json | ConvertFrom-Json -AsHashtable -Depth 100 + if ($parsed -isnot [System.Collections.IDictionary]) { + throw [System.ArgumentException]::new('JWKS JSON must be a JSON object.', 'Json') + } + if (-not $parsed.Contains('keys')) { + throw [System.ArgumentException]::new("JWKS JSON is missing the required 'keys' member (RFC 7517 §5.1).", 'Json') + } + return [JwtKeySet]::new($parsed) + } +} diff --git a/src/functions/public/ConvertTo-Base64UrlString.ps1 b/src/functions/public/ConvertTo-Base64UrlString.ps1 index bde84d5..d018804 100644 --- a/src/functions/public/ConvertTo-Base64UrlString.ps1 +++ b/src/functions/public/ConvertTo-Base64UrlString.ps1 @@ -1,59 +1,36 @@ function ConvertTo-Base64UrlString { <# .SYNOPSIS - Encodes text or bytes as a base64url string. + Encodes a string or byte array as base64url. .DESCRIPTION - Encodes a string or byte array using base64url encoding suitable for JWT headers, payloads, and signatures. + Internal helper that wraps the [JwtBase64Url] class and produces an unpadded + base64url string per RFC 4648 §5. .EXAMPLE - ```powershell - '{"alg":"RS256","typ":"JWT"}' | ConvertTo-Base64UrlString - ``` + ConvertTo-Base64UrlString 'Hello' - 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/ + Encodes the UTF-8 bytes of the string as base64url. #> [OutputType([string])] [CmdletBinding()] param( - # The string or byte array to encode. - [Parameter(Mandatory, ValueFromPipeline, Position = 0)] + # The value to encode. Accepts a [string] (UTF-8 encoded) or a [byte[]]. + [Parameter(Mandatory, Position = 0, ValueFromPipeline)] [ValidateNotNull()] - [Alias('in')] [object] $InputObject ) - begin {} - process { + if ($InputObject -is [byte[]]) { + return [JwtBase64Url]::Encode($InputObject) + } 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) + return [JwtBase64Url]::EncodeString($InputObject) } + throw [System.ArgumentException]::new( + "ConvertTo-Base64UrlString requires string or byte array input. Got [$($InputObject.GetType().FullName)].", + 'InputObject' + ) } - - end {} } diff --git a/src/functions/public/ConvertTo-JwtKey.ps1 b/src/functions/public/ConvertTo-JwtKey.ps1 new file mode 100644 index 0000000..943e0be --- /dev/null +++ b/src/functions/public/ConvertTo-JwtKey.ps1 @@ -0,0 +1,104 @@ +function ConvertTo-JwtKey { + <# + .SYNOPSIS + Converts a .NET key into a [JwtKey] (JWK). + + .DESCRIPTION + Accepts an [RSA], [ECDsa], or [byte[]] (HMAC secret) and returns a [JwtKey] + populated per RFC 7517 / RFC 7518 with the appropriate kty and key fields. + For asymmetric keys, only public parameters are emitted unless the supplied + instance carries private parameters and -IncludePrivateParameters is set. + + .EXAMPLE + $rsa = [System.Security.Cryptography.RSA]::Create(2048) + ConvertTo-JwtKey -Key $rsa + + Returns a [JwtKey] with kty='RSA' and the public n/e fields. + + .OUTPUTS + JwtKey + #> + [OutputType([JwtKey])] + [CmdletBinding()] + param( + # The .NET key to convert. + [Parameter(Mandatory, Position = 0, ValueFromPipeline)] + [ValidateNotNull()] + [object] $Key, + + # Include private key parameters in the JWK. + [Parameter()] + [switch] $IncludePrivateParameters, + + # Optional algorithm to record on the JWK. + [Parameter()] + [string] $Algorithm, + + # Optional key id to record on the JWK. + [Parameter()] + [string] $KeyId + ) + + process { + $jwk = [JwtKey]::new() + if ($PSBoundParameters.ContainsKey('Algorithm')) { $jwk.alg = $Algorithm } + if ($PSBoundParameters.ContainsKey('KeyId')) { $jwk.kid = $KeyId } + + if ($Key -is [System.Security.Cryptography.RSA]) { + $params = $Key.ExportParameters($IncludePrivateParameters.IsPresent) + $jwk.kty = 'RSA' + $jwk.n = [JwtBase64Url]::Encode($params.Modulus) + $jwk.e = [JwtBase64Url]::Encode($params.Exponent) + if ($IncludePrivateParameters -and $params.D) { + $jwk.d = [JwtBase64Url]::Encode($params.D) + $jwk.p = [JwtBase64Url]::Encode($params.P) + $jwk.q = [JwtBase64Url]::Encode($params.Q) + $jwk.dp = [JwtBase64Url]::Encode($params.DP) + $jwk.dq = [JwtBase64Url]::Encode($params.DQ) + $jwk.qi = [JwtBase64Url]::Encode($params.InverseQ) + } + return $jwk + } + + if ($Key -is [System.Security.Cryptography.ECDsa]) { + $params = $Key.ExportParameters($IncludePrivateParameters.IsPresent) + $jwk.kty = 'EC' + $oidValue = $params.Curve.Oid.Value + $oidName = $params.Curve.Oid.FriendlyName + $jwk.crv = switch -Regex ($oidValue) { + '^1\.2\.840\.10045\.3\.1\.7$' { 'P-256'; break } + '^1\.3\.132\.0\.34$' { 'P-384'; break } + '^1\.3\.132\.0\.35$' { 'P-521'; break } + default { + switch ($oidName) { + 'nistP256' { 'P-256' } + 'ECDSA_P256' { 'P-256' } + 'nistP384' { 'P-384' } + 'ECDSA_P384' { 'P-384' } + 'nistP521' { 'P-521' } + 'ECDSA_P521' { 'P-521' } + default { $oidName } + } + } + } + $jwk.x = [JwtBase64Url]::Encode($params.Q.X) + $jwk.y = [JwtBase64Url]::Encode($params.Q.Y) + if ($IncludePrivateParameters -and $params.D) { + $jwk.d = [JwtBase64Url]::Encode($params.D) + } + return $jwk + } + + if ($Key -is [byte[]]) { + $jwk.kty = 'oct' + $jwk.k = [JwtBase64Url]::Encode($Key) + return $jwk + } + + throw [System.ArgumentException]::new( + "ConvertTo-JwtKey does not support a key of type [$($Key.GetType().FullName)]. " + + 'Use RSA, ECDsa, or byte[].', + 'Key' + ) + } +} diff --git a/src/functions/public/ConvertTo-JwtKeySet.ps1 b/src/functions/public/ConvertTo-JwtKeySet.ps1 new file mode 100644 index 0000000..c10f9c2 --- /dev/null +++ b/src/functions/public/ConvertTo-JwtKeySet.ps1 @@ -0,0 +1,39 @@ +function ConvertTo-JwtKeySet { + <# + .SYNOPSIS + Wraps one or more [JwtKey] objects in a [JwtKeySet] (JWKS). + + .DESCRIPTION + Returns a [JwtKeySet] suitable for serialization with .ToJson(). Accepts JwtKey + instances via pipeline. + + .EXAMPLE + $jwks = $rsa, $ec | ConvertTo-JwtKey -IncludePrivateParameters | ConvertTo-JwtKeySet + $jwks.ToJson() + + Builds a JWKS JSON document from a collection of .NET keys. + + .OUTPUTS + JwtKeySet + #> + [OutputType([JwtKeySet])] + [CmdletBinding()] + param( + # The JWK(s) to include in the set. + [Parameter(Mandatory, Position = 0, ValueFromPipeline)] + [ValidateNotNull()] + [JwtKey[]] $Key + ) + + begin { + $accumulated = [System.Collections.Generic.List[JwtKey]]::new() + } + + process { + foreach ($k in $Key) { $accumulated.Add($k) } + } + + end { + return [JwtKeySet]::new($accumulated.ToArray()) + } +} diff --git a/src/functions/public/Get-JwtClaim.ps1 b/src/functions/public/Get-JwtClaim.ps1 new file mode 100644 index 0000000..869cc64 --- /dev/null +++ b/src/functions/public/Get-JwtClaim.ps1 @@ -0,0 +1,90 @@ +function Get-JwtClaim { + <# + .SYNOPSIS + Returns the value of one or more claims from a JWT. + + .DESCRIPTION + Returns the value of a named claim from the JWT payload. Supports both + registered claims (iss, sub, aud, exp, nbf, iat, jti) and private claims + (anything in AdditionalFields). + + Behavior: + - A single -Name that is missing returns $null silently. + - An array of -Name values returns an [ordered] hashtable keyed by the + requested names. Missing names map to $null so the return shape is stable. + - -ErrorIfMissing escalates each missing name to a non-terminating error. + + .EXAMPLE + Get-JwtClaim -Token $jwt -Name 'sub' + + Returns the subject claim, or $null if absent. + + .EXAMPLE + Get-JwtClaim -Token $jwt -Name 'sub','role','missing' + + Returns @{ sub = '...'; role = '...'; missing = $null }. + + .OUTPUTS + Object + #> + [OutputType([object])] + [CmdletBinding()] + param( + # The JWT string or [Jwt] object. + [Parameter(Mandatory, Position = 0, ValueFromPipeline)] + [ValidateNotNull()] + [object] $Token, + + # The claim name(s) to retrieve. + [Parameter(Mandatory, Position = 1)] + [string[]] $Name, + + # Emit a non-terminating error for each missing claim. + [Parameter()] + [switch] $ErrorIfMissing + ) + + process { + $payload = (ConvertFrom-Jwt -Token $Token).Payload + $registered = [JwtPayload]::RegisteredClaims + $missing = [object]::new() + + $resolve = { + param($claimName) + if ($registered -contains $claimName) { + $value = $payload.$claimName + if ($null -eq $value) { return $missing } + return $value + } + if ($payload.AdditionalFields.Contains($claimName)) { + return $payload.AdditionalFields[$claimName] + } + return $missing + } + + if ($Name.Count -eq 1) { + $value = & $resolve $Name[0] + if ([object]::ReferenceEquals($value, $missing)) { + if ($ErrorIfMissing) { + Write-Error "Claim '$($Name[0])' is not present in the JWT payload." + } + return $null + } + return $value + } + + $result = [ordered]@{} + foreach ($n in $Name) { + $value = & $resolve $n + if ([object]::ReferenceEquals($value, $missing)) { + if ($ErrorIfMissing) { + Write-Error "Claim '$n' is not present in the JWT payload." + } + $result[$n] = $null + } else { + $result[$n] = $value + } + } + return $result + } +} diff --git a/src/functions/public/Get-JwtHeader.ps1 b/src/functions/public/Get-JwtHeader.ps1 index c38bf86..b077702 100644 --- a/src/functions/public/Get-JwtHeader.ps1 +++ b/src/functions/public/Get-JwtHeader.ps1 @@ -1,56 +1,31 @@ function Get-JwtHeader { <# .SYNOPSIS - Gets the decoded header from a JWT. + Returns the parsed header of a JWT. .DESCRIPTION - Decodes and returns the JSON header segment from a JSON Web Token. The payload and signature are ignored. + Parses the supplied compact JWT string (or [Jwt] object) and returns the + [JwtHeader]. No signature verification is performed. .EXAMPLE - ```powershell - $jwt = 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJqb2UiLCJyb2xlIjoiYWRtaW4ifQ.' #gitleaks:allow - Get-JwtHeader -Jwt $jwt - ``` + Get-JwtHeader -Token $jwt - Gets the decoded header JSON from an unsigned JWT. - - .INPUTS - System.String + Returns the typed header. .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/ + JwtHeader #> - [OutputType([string])] + [OutputType([JwtHeader])] [CmdletBinding()] param( - # The JWT to read. - [Parameter(Mandatory, ValueFromPipeline, Position = 0)] - [ValidateNotNullOrEmpty()] - [string] $Jwt + # The JWT string or [Jwt] object. + [Parameter(Mandatory, Position = 0, ValueFromPipeline)] + [ValidateNotNull()] + [object] $Token ) - 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] + $parsed = ConvertFrom-Jwt -Token $Token + return $parsed.Header } - - end {} } diff --git a/src/functions/public/Get-JwtKeyFromSet.ps1 b/src/functions/public/Get-JwtKeyFromSet.ps1 new file mode 100644 index 0000000..aac7f28 --- /dev/null +++ b/src/functions/public/Get-JwtKeyFromSet.ps1 @@ -0,0 +1,44 @@ +function Get-JwtKeyFromSet { + <# + .SYNOPSIS + Looks up a [JwtKey] in a [JwtKeySet] by kid. + + .DESCRIPTION + Returns the first [JwtKey] in the set whose kid matches. Returns $null when no + match is found. Pass -ErrorIfMissing to escalate to a non-terminating error. + + .EXAMPLE + $key = Get-JwtKeyFromSet -KeySet $jwks -KeyId (Get-JwtHeader $token).kid + + Resolves the signing key for a token by reading the header kid and looking it + up in a JWKS. + + .OUTPUTS + JwtKey + #> + [OutputType([JwtKey])] + [CmdletBinding()] + param( + # The JWK Set to search. + [Parameter(Mandatory, Position = 0, ValueFromPipeline)] + [ValidateNotNull()] + [JwtKeySet] $KeySet, + + # The kid to find. + [Parameter(Mandatory, Position = 1)] + [ValidateNotNullOrEmpty()] + [string] $KeyId, + + # Emit a non-terminating error when the kid is not found. + [Parameter()] + [switch] $ErrorIfMissing + ) + + process { + $match = $KeySet.FindByKid($KeyId) + if ($null -eq $match -and $ErrorIfMissing) { + Write-Error "No JWK in the set has kid='$KeyId'." + } + return $match + } +} diff --git a/src/functions/public/Get-JwtKeyThumbprint.ps1 b/src/functions/public/Get-JwtKeyThumbprint.ps1 new file mode 100644 index 0000000..2b599f1 --- /dev/null +++ b/src/functions/public/Get-JwtKeyThumbprint.ps1 @@ -0,0 +1,82 @@ +function Get-JwtKeyThumbprint { + <# + .SYNOPSIS + Computes the JWK Thumbprint of a key per RFC 7638. + + .DESCRIPTION + Builds the canonical JSON representation containing only the required members + for the key's kty (per RFC 7638 §3.2), in lexicographic order, with no + whitespace; hashes it with the requested hash algorithm; and returns the + base64url-encoded digest. + + Required members: + - RSA: e, kty, n + - EC : crv, kty, x, y + - oct: k, kty + + .EXAMPLE + Get-JwtKeyThumbprint -Key $jwk + + Returns the SHA-256 thumbprint as a base64url string suitable for use as a kid. + + .EXAMPLE + Get-JwtKeyThumbprint -Key $jwk -HashAlgorithm SHA384 + + Returns the SHA-384 thumbprint. + + .OUTPUTS + System.String + #> + [OutputType([string])] + [CmdletBinding()] + param( + # The JWK to fingerprint. + [Parameter(Mandatory, Position = 0, ValueFromPipeline)] + [ValidateNotNull()] + [JwtKey] $Key, + + # The hash algorithm. Default SHA-256 per RFC 7638 §3.4. + [Parameter()] + [ValidateSet('SHA256', 'SHA384', 'SHA512')] + [string] $HashAlgorithm = 'SHA256' + ) + + process { + $required = switch ($Key.kty) { + 'RSA' { @('e', 'kty', 'n') } + 'EC' { @('crv', 'kty', 'x', 'y') } + 'oct' { @('k', 'kty') } + default { + throw [System.NotSupportedException]::new( + "JWK kty '$($Key.kty)' is not supported by RFC 7638 thumbprint." + ) + } + } + + $canonical = [ordered]@{} + foreach ($field in $required) { + $value = $Key.$field + if ([string]::IsNullOrEmpty($value)) { + throw [System.InvalidOperationException]::new( + "JWK is missing required field '$field' for thumbprint computation." + ) + } + $canonical[$field] = $value + } + + $json = ConvertTo-Json -InputObject $canonical -Depth 10 -Compress + $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) + + $hasher = switch ($HashAlgorithm) { + 'SHA256' { [System.Security.Cryptography.SHA256]::Create() } + 'SHA384' { [System.Security.Cryptography.SHA384]::Create() } + 'SHA512' { [System.Security.Cryptography.SHA512]::Create() } + } + try { + $digest = $hasher.ComputeHash($bytes) + } finally { + $hasher.Dispose() + } + return [JwtBase64Url]::Encode($digest) + } +} diff --git a/src/functions/public/Get-JwtPayload.ps1 b/src/functions/public/Get-JwtPayload.ps1 index 78eb909..2012a53 100644 --- a/src/functions/public/Get-JwtPayload.ps1 +++ b/src/functions/public/Get-JwtPayload.ps1 @@ -1,55 +1,31 @@ function Get-JwtPayload { <# .SYNOPSIS - Gets the decoded payload from a JWT. + Returns the parsed payload of a JWT. .DESCRIPTION - Decodes and returns the JSON payload segment from a JSON Web Token. The header and signature are ignored. + Parses the supplied compact JWT string (or [Jwt] object) and returns the + [JwtPayload]. No signature verification is performed. .EXAMPLE - ```powershell - $jwt | Get-JwtPayload - ``` + Get-JwtPayload -Token $jwt - Gets the decoded payload JSON from a JWT. - - .INPUTS - System.String + Returns the typed payload. .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/ + JwtPayload #> - [OutputType([string])] + [OutputType([JwtPayload])] [CmdletBinding()] param( - # The JWT to read. - [Parameter(Mandatory, ValueFromPipeline, Position = 0)] - [ValidateNotNullOrEmpty()] - [string] $Jwt + # The JWT string or [Jwt] object. + [Parameter(Mandatory, Position = 0, ValueFromPipeline)] + [ValidateNotNull()] + [object] $Token ) - 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] + $parsed = ConvertFrom-Jwt -Token $Token + return $parsed.Payload } - - end {} } diff --git a/src/functions/public/New-Jwt.ps1 b/src/functions/public/New-Jwt.ps1 index 8c9f1b0..c76d3e6 100644 --- a/src/functions/public/New-Jwt.ps1 +++ b/src/functions/public/New-Jwt.ps1 @@ -4,159 +4,117 @@ 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. + Builds a [Jwt] from a header overrides hashtable and a claims payload. + The default mode signs the token with the supplied -Key using the requested + -Algorithm. The -Unsigned switch produces a token with an empty signature so + the signature can be attached by an external signing process (HSM, Azure + Key Vault, etc.) by writing to $jwt.Signature. - .EXAMPLE - ```powershell - $payload = '{"sub":"1234567890","name":"John Doe","admin":true,"iat":1516239022}' - $secret = 'a-string-secret-at-least-256-bits-long' + Header alg and typ are set automatically. Pass kid or other JOSE fields via + -Header. Registered claims (iss, sub, aud, exp, nbf, iat, jti) on -Payload are + recognized; other entries flow through as private claims. + + All JSON serialization uses -Depth 100 -Compress to preserve nested claim values. - New-Jwt -Header '{"alg":"HS256","typ":"JWT"}' -PayloadJson $payload -Secret $secret - ``` + .EXAMPLE + $jwt = New-Jwt -Payload @{ sub = 'user@example.com'; exp = 1900000000 } -Key $secret -Algorithm HS256 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 - ``` + $jwt = New-Jwt -Payload @{ sub = 'app' } -Algorithm RS256 -Unsigned + $jwt.SigningInput() | Send-ToKeyVault | ForEach-Object { $jwt.Signature = $_ } - Creates an RS256-signed JWT with a certificate private key and returns the number of JWT segments. - - .INPUTS - System.String + Creates an unsigned token, signs the SigningInput externally, and attaches the result. .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/ + Jwt #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseShouldProcessForStateChangingFunctions', '', - Justification = 'New-Jwt creates an in-memory token and does not change system state.' + Justification = 'New-Jwt builds an in-memory token and does not change system state.' )] - [OutputType([string])] - [CmdletBinding()] + [OutputType([Jwt])] + [CmdletBinding(DefaultParameterSetName = 'Signed')] param( - # The JWT header JSON. + # Optional header overrides. alg and typ are set automatically. [Parameter()] - [ValidateNotNullOrEmpty()] - [string] $Header = '{"alg":"RS256","typ":"JWT"}', + [System.Collections.IDictionary] $Header, - # The JWT payload JSON. - [Parameter(Mandatory, ValueFromPipeline, Position = 0)] - [ValidateNotNullOrEmpty()] - [string] $PayloadJson, + # The JWT claims dictionary. Pass an [ordered]@{} to control on-the-wire JSON key order. + [Parameter(Mandatory, Position = 0, ValueFromPipeline)] + [System.Collections.IDictionary] $Payload, - # The signing certificate to use for RS256 tokens. - [Parameter()] - [ValidateNotNull()] - [System.Security.Cryptography.X509Certificates.X509Certificate2] $Cert, + # The signing key. Format depends on -Algorithm. + [Parameter(Mandatory, ParameterSetName = 'Signed')] + [object] $Key, + + # Produce an unsigned token. The signature must be attached externally via $jwt.Signature. + [Parameter(Mandatory, ParameterSetName = 'Unsigned')] + [switch] $Unsigned, - # The string or byte array secret to use for HS256 tokens. + # The signing algorithm. [Parameter()] - [ValidateNotNull()] - [object] $Secret + [ValidateSet( + 'HS256', 'HS384', 'HS512', + 'RS256', 'RS384', 'RS512', + 'ES256', 'ES384', 'ES512', + 'PS256', 'PS384', 'PS512' + )] + [string] $Algorithm = 'RS256' ) - 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.') + $headerValues = [ordered]@{} + if ($Header) { foreach ($k in $Header.Keys) { $headerValues[$k] = $Header[$k] } } + $headerValues['alg'] = $Algorithm + if (-not $headerValues.Contains('typ')) { $headerValues['typ'] = 'JWT' } + + $jwtHeader = [JwtHeader]::new($headerValues) + $jwtPayload = [JwtPayload]::new($Payload) + $token = [Jwt]::new($jwtHeader, $jwtPayload) + + if ($Unsigned) { + $token.Signature = '' + return $token } - Write-Verbose "Algorithm: $algorithm" + $resolved = Resolve-JwtKey -Algorithm $Algorithm -Key $Key + $contentBytes = [System.Text.Encoding]::UTF8.GetBytes($token.SigningInput()) + $hash = Get-JwtAlgorithmHash -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') + switch -Regex ($Algorithm) { + '^RS' { + $rsa = [System.Security.Cryptography.RSA] $resolved + $sigBytes = $rsa.SignData( + $contentBytes, + $hash, + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1 + ) } - 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() - } + '^PS' { + $rsa = [System.Security.Cryptography.RSA] $resolved + $sigBytes = $rsa.SignData( + $contentBytes, + $hash, + [System.Security.Cryptography.RSASignaturePadding]::Pss + ) } - } - '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') + '^HS' { + $hmac = [System.Security.Cryptography.HMAC] $resolved + $sigBytes = $hmac.ComputeHash($contentBytes) } - $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() + '^ES' { + $ecdsa = [System.Security.Cryptography.ECDsa] $resolved + $sigBytes = $ecdsa.SignData($contentBytes, $hash) } } - 'none' { - $encodedSignature = $null - } - default { - $message = 'The algorithm is not one of the supported: "RS256", "HS256", "none".' - throw [System.NotSupportedException]::new($message) + $token.Signature = [JwtBase64Url]::Encode($sigBytes) + } finally { + if ($resolved -is [System.IDisposable] -and $resolved -isnot [System.Security.Cryptography.RSA] -and $Key -isnot [System.Security.Cryptography.RSA] -and $Key -isnot [System.Security.Cryptography.ECDsa]) { + $resolved.Dispose() } } - - $jwtContent + '.' + $encodedSignature + return $token } - - end {} } diff --git a/src/functions/public/Test-Jwt.ps1 b/src/functions/public/Test-Jwt.ps1 index a83726b..cbd608e 100644 --- a/src/functions/public/Test-Jwt.ps1 +++ b/src/functions/public/Test-Jwt.ps1 @@ -1,169 +1,143 @@ -function Test-Jwt { +function Test-Jwt { <# .SYNOPSIS - Tests the cryptographic integrity of a JWT. + Verifies the signature and claims 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. + Performs the full JWT validation pipeline: - .EXAMPLE - ```powershell - $jwt | Test-Jwt -Secret 'a-string-secret-at-least-256-bits-long' - ``` + 1. Algorithm-key compatibility check (blocks the HS256-with-RSA-public-key + algorithm-confusion attack and unknown alg values). + 2. Signature verification. + 3. Registered claim validation (exp, nbf, iss, aud), with -ClockSkew tolerance. + + Returns $true / $false by default. With -Detailed, returns a [pscustomobject] + whose Checks property is a stable, ordered array indexable by Name. - Tests an HS256 JWT with a shared secret. + Unsigned tokens (alg=none) are rejected unless -AllowUnsigned is supplied. + When -AllowUnsigned is used, claim validation still runs and -Detailed + reports SignatureValidated=$false with Reason='Skipped (unsigned token)'. .EXAMPLE - ```powershell - $jwt | Test-Jwt -Cert $cert - ``` + $jwt | Test-Jwt -Key $secret - Tests an RS256 JWT with a public certificate. + Verifies an HS256 token. - .INPUTS - System.String + .EXAMPLE + Test-Jwt -Token $jwt -Key $rsa -Issuer 'https://issuer' -Audience 'api' -Detailed + + Returns a structured validation report. .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/ + System.Management.Automation.PSCustomObject #> - [OutputType([bool])] - [Alias('Verify-JwtSignature')] + [OutputType([bool], [pscustomobject])] [CmdletBinding()] param( - # The JWT to test. - [Parameter(Mandatory, ValueFromPipeline, Position = 0)] - [ValidateNotNullOrEmpty()] - [string] $Jwt, + # The JWT to validate. + [Parameter(Mandatory, Position = 0, ValueFromPipeline)] + [ValidateNotNull()] + [object] $Token, - # The certificate to use for RS256 signature verification. + # The verification key. Format depends on the token's alg. [Parameter()] - [ValidateNotNull()] - [System.Security.Cryptography.X509Certificates.X509Certificate2] $Cert, + [object] $Key, - # The string or byte array secret to use for HS256 signature verification. + # Expected issuer. [Parameter()] - [ValidateNotNull()] - [object] $Secret - ) + [string] $Issuer, + + # Accepted audiences (any-match). + [Parameter()] + [string[]] $Audience, + + # Allowed clock skew for exp / nbf checks. + [Parameter()] + [timespan] $ClockSkew = [timespan]::Zero, - begin {} + # Require an exp claim. Defaults to $true. + [Parameter()] + [bool] $RequireExpiration = $true, + + # Allow alg=none unsigned tokens. + [Parameter()] + [switch] $AllowUnsigned, + + # Return a structured report instead of [bool]. + [Parameter()] + [switch] $Detailed + ) process { - Write-Verbose "Verifying JWT with length $($Jwt.Length) characters" + $parsed = ConvertFrom-Jwt -Token $Token + $alg = $parsed.Header.alg - $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.') + $algCheck = @{ Name = 'Algorithm'; Passed = $true; Reason = $null } + $sigCheck = @{ Name = 'Signature'; Passed = $false; Reason = $null } + $signatureValidated = $false + + if ([string]::IsNullOrEmpty($alg)) { + $algCheck.Passed = $false + $algCheck.Reason = "JWT header is missing the 'alg' claim." + throw [System.Security.Authentication.AuthenticationException]::new($algCheck.Reason) } - 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() - } + if ($alg -eq 'none') { + if (-not $AllowUnsigned) { + $algCheck.Passed = $false + $algCheck.Reason = "Algorithm 'none' rejected. Pass -AllowUnsigned to permit unsigned tokens." + throw [System.Security.Authentication.AuthenticationException]::new($algCheck.Reason) } - '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() + if ($PSBoundParameters.ContainsKey('Key')) { + $algCheck.Passed = $false + $algCheck.Reason = "Algorithm 'none' does not accept a key." + throw [System.ArgumentException]::new($algCheck.Reason, 'Key') + } + $sigCheck.Passed = $true + $sigCheck.Reason = 'Skipped (unsigned token)' + $signatureValidated = $false + } elseif ($alg -in @('HS256', 'HS384', 'HS512', 'RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'PS256', 'PS384', 'PS512')) { + $resolved = Resolve-JwtKey -Algorithm $alg -Key $Key + try { + $sigOk = Test-JwtSignature ` + -SigningInput $parsed.SigningInput() ` + -Signature $parsed.Signature ` + -Algorithm $alg ` + -ResolvedKey $resolved + } finally { + if ($resolved -is [System.IDisposable] -and $Key -isnot [System.Security.Cryptography.RSA] -and $Key -isnot [System.Security.Cryptography.ECDsa]) { + $resolved.Dispose() } } - 'none' { - $parts[2] -eq '' + if ($sigOk) { + $sigCheck.Passed = $true + $signatureValidated = $true + } else { + $sigCheck.Reason = 'Signature verification failed.' } - default { - $message = 'The algorithm is not one of the supported: "RS256", "HS256", "none".' - throw [System.NotSupportedException]::new($message) + } else { + $algCheck.Passed = $false + $algCheck.Reason = "Algorithm '$alg' is not supported. Allowed: HS256, HS384, HS512, RS256, RS384, RS512, ES256, ES384, ES512, PS256, PS384, PS512, none." + throw [System.Security.Authentication.AuthenticationException]::new($algCheck.Reason) + } + + $claimArgs = @{ Payload = $parsed.Payload; ClockSkew = $ClockSkew; RequireExpiration = $RequireExpiration } + if ($PSBoundParameters.ContainsKey('Issuer')) { $claimArgs['Issuer'] = $Issuer } + if ($PSBoundParameters.ContainsKey('Audience')) { $claimArgs['Audience'] = $Audience } + $claimChecks = Test-JwtClaim @claimArgs + + $checks = @($algCheck, $sigCheck) + $claimChecks + $valid = -not ($checks | Where-Object { -not $_.Passed }) + + if ($Detailed) { + return [pscustomobject]@{ + Valid = [bool]$valid + SignatureValidated = $signatureValidated + Algorithm = $alg + Checks = $checks } } + return [bool]$valid } - - end {} } diff --git a/src/manifest.psd1 b/src/manifest.psd1 index 40bd8bb..a0fc253 100644 --- a/src/manifest.psd1 +++ b/src/manifest.psd1 @@ -1,8 +1,13 @@ @{ - PrivateData = @{ + PowerShellVersion = '7.6' + CompatiblePSEditions = @('Core') + PrivateData = @{ PSData = @{ Tags = @( 'JWT' + 'JWS' + 'JWK' + 'JOSE' 'JSON' 'Token' 'Authentication' diff --git a/tests/Data/TestCases.ps1 b/tests/Data/TestCases.ps1 index f9e08fe..dc5d694 100644 --- a/tests/Data/TestCases.ps1 +++ b/tests/Data/TestCases.ps1 @@ -1,36 +1,22 @@ @( @{ - Name = 'local HS256 token' - Header = '{"alg":"HS256","typ":"JWT"}' - HeaderEncoded = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' - Payload = '{"sub":"joe","role":"admin"}' - PayloadEncoded = 'eyJzdWIiOiJqb2UiLCJyb2xlIjoiYWRtaW4ifQ' - Secret = 'super-secret' - ExtractionToken = @( - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' - 'eyJzdWIiOiJqb2UiLCJyb2xlIjoiYWRtaW4ifQ' - 'c2lnbmF0dXJl' - ) -join '.' - ExpectedToken = $null - TamperedPayload = '{"sub":"joe","role":"user"}' + Name = 'jwt.io HS256 default sample' + Algorithm = 'HS256' + Header = [ordered]@{ alg = 'HS256'; typ = 'JWT' } + Payload = [ordered]@{ sub = '1234567890'; name = 'John Doe'; admin = $true; iat = 1516239022 } + Secret = 'a-string-secret-at-least-256-bits-long' + EncodedHeader = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' + EncodedPayload = 'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0' + EncodedSig = 'KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30' } @{ - Name = 'current jwt.io default HS256 example' - Header = '{"alg":"HS256","typ":"JWT"}' - HeaderEncoded = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' - Payload = '{"sub":"1234567890","name":"John Doe","admin":true,"iat":1516239022}' - PayloadEncoded = 'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0' - Secret = 'a-string-secret-at-least-256-bits-long' - ExtractionToken = @( - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' - 'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0' - 'KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30' - ) -join '.' - ExpectedToken = @( - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' - 'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0' - 'KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30' - ) -join '.' - TamperedPayload = '{"sub":"1234567890","name":"John Doe","admin":false,"iat":1516239022}' + Name = 'minimal HS256 sub claim' + Algorithm = 'HS256' + Header = [ordered]@{ alg = 'HS256'; typ = 'JWT' } + Payload = [ordered]@{ sub = 'joe' } + Secret = 'super-secret' + EncodedHeader = $null + EncodedPayload = $null + EncodedSig = $null } ) diff --git a/tests/Jwt.Tests.ps1 b/tests/Jwt.Tests.ps1 index d32242c..b187335 100644 --- a/tests/Jwt.Tests.ps1 +++ b/tests/Jwt.Tests.ps1 @@ -5,149 +5,594 @@ [CmdletBinding()] param() -Describe 'Data-driven tests' { - $testCases = . "$PSScriptRoot/Data/TestCases.ps1" +# The Process-PSModule test harness builds the Jwt module and auto-imports it before +# this script runs, so no Import-Module call is needed here. - Context '' -ForEach $testCases { - It 'ConvertTo-Base64UrlString - encodes the header as base64url' { - ConvertTo-Base64UrlString $Header | Should -Be $HeaderEncoded +$testCases = . "$PSScriptRoot/Data/TestCases.ps1" + +Describe 'Jwt module' { + Context 'Data-driven HS256 cases - ' -ForEach $testCases { + It 'New-Jwt produces a token whose signing input matches the expected encoded segments' { + $payloadHash = [ordered]@{} + foreach ($k in $Payload.Keys) { $payloadHash[$k] = $Payload[$k] } + $headerHash = [ordered]@{} + foreach ($k in $Header.Keys) { if ($k -ne 'alg') { $headerHash[$k] = $Header[$k] } } + + $jwt = New-Jwt -Header $headerHash -Payload $payloadHash -Algorithm $Algorithm -Key $Secret + + $jwt.GetType().Name | Should -Be 'Jwt' + $jwt.Header.alg | Should -Be $Algorithm + $jwt.Header.typ | Should -Be 'JWT' + if ($EncodedHeader) { $jwt.EncodedHeader | Should -Be $EncodedHeader } + if ($EncodedPayload) { $jwt.EncodedPayload | Should -Be $EncodedPayload } + if ($EncodedSig) { $jwt.Signature | Should -Be $EncodedSig } + } + + It 'Test-Jwt validates a freshly signed token' { + $payloadHash = @{} + foreach ($k in $Payload.Keys) { $payloadHash[$k] = $Payload[$k] } + $jwt = New-Jwt -Payload $payloadHash -Algorithm $Algorithm -Key $Secret + + Test-Jwt -Token $jwt -Key $Secret -RequireExpiration $false | Should -BeTrue + } + + It 'ConvertFrom-Jwt round-trips the compact form' { + $payloadHash = @{} + foreach ($k in $Payload.Keys) { $payloadHash[$k] = $Payload[$k] } + $jwt = New-Jwt -Payload $payloadHash -Algorithm $Algorithm -Key $Secret + $compact = $jwt.ToString() + + $parsed = ConvertFrom-Jwt -Token $compact + $parsed.ToString() | Should -Be $compact + $parsed.EncodedHeader | Should -Be $jwt.EncodedHeader + $parsed.EncodedPayload | Should -Be $jwt.EncodedPayload + $parsed.Signature | Should -Be $jwt.Signature } + } - It 'ConvertFrom-Base64UrlString - decodes the header from base64url' { - ConvertFrom-Base64UrlString $HeaderEncoded | Should -Be $Header + Context 'Creation - signed and unsigned modes' { + It 'New-Jwt -Unsigned produces a token with empty signature and a trailing dot' { + $jwt = New-Jwt -Payload @{ sub = 'app' } -Algorithm RS256 -Unsigned + + $jwt.GetType().Name | Should -Be 'Jwt' + $jwt.Signature | Should -Be '' + $jwt.ToString() | Should -Match '\.$' + $jwt.SigningInput() | Should -Be ($jwt.EncodedHeader + '.' + $jwt.EncodedPayload) } - It 'ConvertTo-Base64UrlString - encodes the payload as base64url' { - ConvertTo-Base64UrlString $Payload | Should -Be $PayloadEncoded + It 'New-Jwt accepts HS256 with a byte[] secret' { + $bytes = [System.Text.Encoding]::UTF8.GetBytes('a-string-secret-at-least-256-bits-long') + $jwt = New-Jwt -Payload @{ sub = 'joe' } -Algorithm HS256 -Key $bytes + + Test-Jwt -Token $jwt -Key $bytes -RequireExpiration $false | Should -BeTrue } - It 'ConvertFrom-Base64UrlString - decodes the payload from base64url' { - ConvertFrom-Base64UrlString $PayloadEncoded | Should -Be $Payload + It 'New-Jwt with HS256 and a SecureString raw secret round-trips' { + $secret = ConvertTo-SecureString 'a-string-secret-at-least-256-bits-long' -AsPlainText -Force + $jwt = New-Jwt -Payload @{ sub = 'joe' } -Algorithm HS256 -Key $secret + Test-Jwt -Token $jwt -Key $secret -RequireExpiration $false | Should -BeTrue } - It 'Get-JwtHeader - extracts the header' { - Get-JwtHeader $ExtractionToken | Should -Be $Header + It 'New-Jwt RS256 with a generated RSA key round-trips' { + $rsa = [System.Security.Cryptography.RSA]::Create(2048) + try { + $jwt = New-Jwt -Payload @{ sub = 'joe' } -Algorithm RS256 -Key $rsa + Test-Jwt -Token $jwt -Key $rsa -RequireExpiration $false | Should -BeTrue + } finally { $rsa.Dispose() } } - It 'Get-JwtPayload - extracts the payload' { - Get-JwtPayload $ExtractionToken | Should -Be $Payload + It 'New-Jwt ES256 with a generated EC P-256 key round-trips' { + $ecdsa = [System.Security.Cryptography.ECDsa]::Create( + [System.Security.Cryptography.ECCurve]::CreateFromValue('1.2.840.10045.3.1.7')) + try { + $jwt = New-Jwt -Payload @{ sub = 'joe' } -Algorithm ES256 -Key $ecdsa + Test-Jwt -Token $jwt -Key $ecdsa -RequireExpiration $false | Should -BeTrue + } finally { $ecdsa.Dispose() } } - It 'New-Jwt/Test-Jwt - creates and validates the token' { - $jwt = New-Jwt -Header $Header -PayloadJson $Payload -Secret $Secret + It 'New-Jwt merges custom header fields like kid' { + $jwt = New-Jwt -Header @{ kid = 'key-1' } -Payload @{ sub = 'joe' } ` + -Algorithm HS256 -Key 'super-secret' + $jwt.Header.kid | Should -Be 'key-1' + $jwt.Header.alg | Should -Be 'HS256' + $jwt.Header.typ | Should -Be 'JWT' + } - $parts = $jwt.Split('.') - $parts.Count | Should -Be 3 - if ($null -ne $ExpectedToken) { - $jwt | Should -Be $ExpectedToken + It 'New-Jwt preserves nested claim structure (no -Depth 2 truncation)' { + $payload = @{ + sub = 'joe' + groups = @( + @{ id = 1; name = 'admins'; meta = @{ source = 'aad'; tier = 'gold' } }, + @{ id = 2; name = 'users'; meta = @{ source = 'aad'; tier = 'silver' } } + ) } - Get-JwtHeader $jwt | Should -Be $Header - Get-JwtPayload $jwt | Should -Be $Payload - Test-Jwt -jwt $jwt -Secret $Secret | Should -BeTrue + $jwt = New-Jwt -Payload $payload -Algorithm HS256 -Key 'super-secret' + $parsed = ConvertFrom-Jwt -Token $jwt.ToString() + $groups = $parsed.Payload.AdditionalFields['groups'] + $groups.Count | Should -Be 2 + $groups[0].meta.source | Should -Be 'aad' } + } - It 'Test-Jwt - fails validation for a tampered token' { - $jwt = New-Jwt -Header $Header -PayloadJson $Payload -Secret $Secret - $parts = $jwt.Split('.') - $parts[1] = ConvertTo-Base64UrlString $TamperedPayload + Context 'Parsing - malformed inputs' { + It 'rejects a token with too few segments' { + { ConvertFrom-Jwt -Token 'a.b' } | Should -Throw '*3 segments*' + } - Test-Jwt -jwt ($parts -join '.') -Secret $Secret | Should -BeFalse + It 'rejects a token with too many segments' { + { ConvertFrom-Jwt -Token 'a.b.c.d' } | Should -Throw '*3 segments*' } - It 'New-Jwt - requires a secret' { - { New-Jwt -Header $Header -PayloadJson $Payload } | Should -Throw '*HS256 requires*Secret*' + It 'rejects an empty header segment' { + { ConvertFrom-Jwt -Token '.abc.def' } | Should -Throw '*header segment is empty*' + } + + It 'rejects an empty payload segment' { + $h = ConvertTo-Base64UrlString '{"alg":"HS256"}' + { ConvertFrom-Jwt -Token "$h..sig" } | Should -Throw '*payload segment is empty*' + } + + It 'rejects a header that is not valid JSON' { + $h = ConvertTo-Base64UrlString 'not-json' + $p = ConvertTo-Base64UrlString '{}' + { ConvertFrom-Jwt -Token "$h.$p.sig" } | Should -Throw '*header*not valid JSON*' + } + + It 'rejects a payload that is not valid JSON' { + $h = ConvertTo-Base64UrlString '{"alg":"HS256"}' + $p = ConvertTo-Base64UrlString 'not-json' + { ConvertFrom-Jwt -Token "$h.$p.sig" } | Should -Throw '*payload*not valid JSON*' + } + + It 'rejects non-base64url characters in segments' { + { ConvertFrom-Jwt -Token '!!!.???.sig' } | Should -Throw '*invalid base64url*' } } - Context 'General behavior' { - It 'ConvertFrom-Base64UrlString - returns bytes when requested' { - $bytes = ConvertFrom-Base64UrlString 'SGVsbG8' -AsByteArray + Context 'Validation - signature outcomes' { + BeforeAll { + $script:secret = 'a-string-secret-at-least-256-bits-long' + $script:goodJwt = New-Jwt -Payload @{ sub = 'joe' } -Algorithm HS256 -Key $script:secret + } - [System.Text.Encoding]::UTF8.GetString($bytes) | Should -Be 'Hello' + It 'returns true for a valid HS256 token' { + Test-Jwt -Token $script:goodJwt -Key $script:secret -RequireExpiration $false | Should -BeTrue } - It 'ConvertFrom-Base64UrlString - rejects invalid base64url length' { - { ConvertFrom-Base64UrlString 'A' } | Should -Throw '*Invalid base64url string length*' + It 'returns false for a tampered signature' { + $compact = $script:goodJwt.ToString() + $parts = $compact.Split('.') + $parts[2] = ConvertTo-Base64UrlString ([byte[]](1..32)) + $tampered = $parts -join '.' + Test-Jwt -Token $tampered -Key $script:secret -RequireExpiration $false | Should -BeFalse } - It 'ConvertTo-Base64UrlString - throws for unsupported input types' { - { ConvertTo-Base64UrlString ([pscustomobject]@{ Value = 'invalid' }) } | Should -Throw '*requires string or byte array input*' + It 'returns false for a tampered payload' { + $compact = $script:goodJwt.ToString() + $parts = $compact.Split('.') + $parts[1] = ConvertTo-Base64UrlString '{"sub":"attacker"}' + $tampered = $parts -join '.' + Test-Jwt -Token $tampered -Key $script:secret -RequireExpiration $false | Should -BeFalse } + } - It 'New-Jwt/Test-Jwt - creates an unsigned token when using the none algorithm' { - $jwt = New-Jwt -Header '{"alg":"none","typ":"JWT"}' -PayloadJson '{"sub":"joe","role":"admin"}' + Context 'Validation - claim outcomes' { + BeforeAll { + $script:secret = 'a-string-secret-at-least-256-bits-long' + $script:nowSec = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + } - $jwt | Should -Match '\.$' - Test-Jwt -jwt $jwt | Should -BeTrue + It 'fails when the token has expired' { + $jwt = New-Jwt -Payload @{ sub = 'joe'; exp = $script:nowSec - 60 } ` + -Algorithm HS256 -Key $script:secret + Test-Jwt -Token $jwt -Key $script:secret | Should -BeFalse } - It 'New-Jwt - requires the payload to be valid JSON' { - $header = '{"alg":"HS256","typ":"JWT"}' - { New-Jwt -Header $header -PayloadJson 'not-json' -Secret 'super-secret' } | - Should -Throw '*payload is not valid JSON*' + It 'passes when expired within the clock skew window' { + $jwt = New-Jwt -Payload @{ sub = 'joe'; exp = $script:nowSec - 30 } ` + -Algorithm HS256 -Key $script:secret + Test-Jwt -Token $jwt -Key $script:secret -ClockSkew ([timespan]::FromMinutes(5)) | Should -BeTrue } - It 'New-Jwt - rejects a header missing the alg claim' { - { New-Jwt -Header '{"typ":"JWT"}' -PayloadJson '{"sub":"joe"}' -Secret 'super-secret' } | - Should -Throw '*missing the required "alg" claim*' + It 'fails when expired beyond the clock skew window' { + $jwt = New-Jwt -Payload @{ sub = 'joe'; exp = $script:nowSec - 600 } ` + -Algorithm HS256 -Key $script:secret + Test-Jwt -Token $jwt -Key $script:secret -ClockSkew ([timespan]::FromMinutes(5)) | Should -BeFalse } - It 'Test-Jwt - rejects a token with a header missing the alg claim' { - $header = ConvertTo-Base64UrlString '{"typ":"JWT"}' - $payload = ConvertTo-Base64UrlString '{"sub":"joe"}' - $sig = ConvertTo-Base64UrlString 'fakesig' - { Test-Jwt "$header.$payload.$sig" -Secret 'super-secret' } | - Should -Throw '*missing the required "alg" claim*' + It 'fails when nbf is in the future' { + $jwt = New-Jwt -Payload @{ sub = 'joe'; nbf = $script:nowSec + 600 } ` + -Algorithm HS256 -Key $script:secret + Test-Jwt -Token $jwt -Key $script:secret -RequireExpiration $false | Should -BeFalse } - It 'Get-JwtHeader - requires exactly three JWT segments' { - { Get-JwtHeader 'header.payload' } | Should -Throw '*JWT must have exactly 3 segments*' + It 'passes when nbf is in the future but within skew' { + $jwt = New-Jwt -Payload @{ sub = 'joe'; nbf = $script:nowSec + 30 } ` + -Algorithm HS256 -Key $script:secret + Test-Jwt -Token $jwt -Key $script:secret -RequireExpiration $false ` + -ClockSkew ([timespan]::FromMinutes(5)) | Should -BeTrue } - It 'Get-JwtPayload - requires a payload segment' { - { Get-JwtPayload 'header..signature' } | Should -Throw '*JWT payload segment is missing*' + It 'fails when issuer does not match' { + $jwt = New-Jwt -Payload @{ sub = 'joe'; iss = 'a' } ` + -Algorithm HS256 -Key $script:secret + Test-Jwt -Token $jwt -Key $script:secret -RequireExpiration $false -Issuer 'b' | Should -BeFalse } - It 'Test-Jwt - requires exactly three JWT segments' { - { Test-Jwt 'header.payload' } | Should -Throw '*JWT must have exactly 3 segments*' + It 'passes when audience matches a single-string aud' { + $jwt = New-Jwt -Payload @{ sub = 'joe'; aud = 'api' } ` + -Algorithm HS256 -Key $script:secret + Test-Jwt -Token $jwt -Key $script:secret -RequireExpiration $false ` + -Audience 'api' | Should -BeTrue } - It 'Test-Jwt - rejects unsigned tokens without a third segment' { - $header = ConvertTo-Base64UrlString '{"alg":"none","typ":"JWT"}' - $payload = ConvertTo-Base64UrlString '{"sub":"joe","role":"admin"}' + It 'passes when any supplied audience appears in an array aud' { + $jwt = New-Jwt -Payload @{ sub = 'joe'; aud = @('a', 'b') } ` + -Algorithm HS256 -Key $script:secret + Test-Jwt -Token $jwt -Key $script:secret -RequireExpiration $false ` + -Audience @('x', 'b', 'y') | Should -BeTrue + } - { Test-Jwt "$header.$payload" } | Should -Throw '*JWT must have exactly 3 segments*' + It 'fails when no supplied audience matches' { + $jwt = New-Jwt -Payload @{ sub = 'joe'; aud = @('a', 'b') } ` + -Algorithm HS256 -Key $script:secret + Test-Jwt -Token $jwt -Key $script:secret -RequireExpiration $false ` + -Audience @('x', 'y') | Should -BeFalse } + } - It 'Test-Jwt - returns false for an invalid HS256 signature segment' { - $jwt = New-Jwt -Header '{"alg":"HS256","typ":"JWT"}' -PayloadJson '{"sub":"joe","role":"admin"}' -Secret 'super-secret' - $parts = $jwt.Split('.') - $parts[2] = 'A' + Context 'Validation - alg, none, and algorithm-confusion' { + It "rejects an alg value that is not in the supported set" { + $h = ConvertTo-Base64UrlString '{"alg":"HS999","typ":"JWT"}' + $p = ConvertTo-Base64UrlString '{"sub":"joe"}' + { Test-Jwt -Token "$h.$p.sig" -Key 'super-secret' } | Should -Throw '*not supported*' + } + + It "rejects a token with a missing alg claim" { + $h = ConvertTo-Base64UrlString '{"typ":"JWT"}' + $p = ConvertTo-Base64UrlString '{"sub":"joe"}' + { Test-Jwt -Token "$h.$p.sig" -Key 'super-secret' } | Should -Throw "*missing the 'alg'*" + } - Test-Jwt -jwt ($parts -join '.') -Secret 'super-secret' | Should -BeFalse + It "rejects alg=none by default" { + $h = ConvertTo-Base64UrlString '{"alg":"none","typ":"JWT"}' + $p = ConvertTo-Base64UrlString '{"sub":"joe"}' + { Test-Jwt -Token "$h.$p." } | Should -Throw "*'none'*" } - It 'Verbose output does not include JWT or payload values' { - $payload = '{"sub":"joe","role":"admin"}' - $jwt = New-Jwt -Header '{"alg":"HS256","typ":"JWT"}' -PayloadJson $payload -Secret 'super-secret' + It "accepts alg=none with -AllowUnsigned and reports SignatureValidated=false" { + $h = ConvertTo-Base64UrlString '{"alg":"none","typ":"JWT"}' + $p = ConvertTo-Base64UrlString '{"sub":"joe"}' + $result = Test-Jwt -Token "$h.$p." -AllowUnsigned -RequireExpiration $false -Detailed + $result.Valid | Should -BeTrue + $result.SignatureValidated | Should -BeFalse + ($result.Checks | Where-Object Name -EQ 'Signature').Reason | Should -Be 'Skipped (unsigned token)' + } + + It "blocks the HS256+RSA-public-key algorithm-confusion attack" { + $rsa = [System.Security.Cryptography.RSA]::Create(2048) + try { + $pem = $rsa.ExportSubjectPublicKeyInfoPem() + $h = ConvertTo-Base64UrlString '{"alg":"HS256","typ":"JWT"}' + $p = ConvertTo-Base64UrlString '{"sub":"attacker"}' + $sig = ConvertTo-Base64UrlString ([byte[]](1..32)) + { Test-Jwt -Token "$h.$p.$sig" -Key $pem } | Should -Throw '*HS256*' + } finally { $rsa.Dispose() } + } + } + + Context 'Test-Jwt -Detailed output shape' { + It 'returns a stable Checks array indexable by Name' { + $secret = 'a-string-secret-at-least-256-bits-long' + $jwt = New-Jwt -Payload @{ sub = 'joe' } -Algorithm HS256 -Key $secret + $r = Test-Jwt -Token $jwt -Key $secret -RequireExpiration $false -Detailed + + $r | Should -BeOfType [pscustomobject] + $r.Valid | Should -BeTrue + $r.SignatureValidated | Should -BeTrue + $r.Algorithm | Should -Be 'HS256' + $r.Checks.Count | Should -Be 6 + ($r.Checks | ForEach-Object Name) | Should -Be @( + 'Algorithm', 'Signature', 'Expiration', 'NotBefore', 'Issuer', 'Audience' + ) + } + } + + Context 'Get-JwtClaim' { + BeforeAll { + $script:jwt = New-Jwt -Payload @{ sub = 'joe'; role = 'admin'; iat = 1516239022 } ` + -Algorithm HS256 -Key 'super-secret' + } + + It 'returns the value of a present registered claim' { + Get-JwtClaim -Token $script:jwt -Name 'sub' | Should -Be 'joe' + } + + It 'returns the value of a present private claim' { + Get-JwtClaim -Token $script:jwt -Name 'role' | Should -Be 'admin' + } + + It 'returns the value of a numeric registered claim' { + Get-JwtClaim -Token $script:jwt -Name 'iat' | Should -Be 1516239022 + } + + It 'returns $null silently for a missing single name' { + Get-JwtClaim -Token $script:jwt -Name 'missing' | Should -BeNullOrEmpty + } + + It 'returns an ordered hashtable for an array of names with $null for missing' { + $r = Get-JwtClaim -Token $script:jwt -Name @('sub', 'missing', 'role') + $r['sub'] | Should -Be 'joe' + $r['role'] | Should -Be 'admin' + $r['missing'] | Should -BeNullOrEmpty + @($r.Keys) | Should -Be @('sub', 'missing', 'role') + } + + It '-ErrorIfMissing emits a non-terminating error per missing name' { + $err = $null + Get-JwtClaim -Token $script:jwt -Name 'missing' -ErrorIfMissing -ErrorVariable err -ErrorAction SilentlyContinue + $err.Count | Should -BeGreaterThan 0 + $err[0].ToString() | Should -Match 'missing' + } + } + + Context 'Pipeline binding' { + It 'accepts a token via the pipeline for ConvertFrom-Jwt' { + $jwt = New-Jwt -Payload @{ sub = 'joe' } -Algorithm HS256 -Key 'super-secret' + $compact = $jwt.ToString() + ($compact | ConvertFrom-Jwt).Payload.sub | Should -Be 'joe' + } + + It 'accepts a token via the pipeline for Get-JwtHeader / Get-JwtPayload / Get-JwtClaim' { + $jwt = New-Jwt -Payload @{ sub = 'joe' } -Algorithm HS256 -Key 'super-secret' + $compact = $jwt.ToString() + ($compact | Get-JwtHeader).alg | Should -Be 'HS256' + ($compact | Get-JwtPayload).sub | Should -Be 'joe' + ($compact | Get-JwtClaim -Name 'sub') | Should -Be 'joe' + } + + It 'accepts a token via the pipeline for Test-Jwt' { + $secret = 'a-string-secret-at-least-256-bits-long' + $jwt = New-Jwt -Payload @{ sub = 'joe' } -Algorithm HS256 -Key $secret + $jwt.ToString() | Test-Jwt -Key $secret -RequireExpiration $false | Should -BeTrue + } + } - $newJwtVerbose = & { New-Jwt -Header '{"alg":"HS256","typ":"JWT"}' -PayloadJson $payload -Secret 'super-secret' -Verbose } 4>&1 | - Where-Object { $_.GetType().Name -eq 'VerboseRecord' } | - Out-String - $getHeaderVerbose = & { Get-JwtHeader $jwt -Verbose } 4>&1 | - Where-Object { $_.GetType().Name -eq 'VerboseRecord' } | - Out-String - $getPayloadVerbose = & { Get-JwtPayload $jwt -Verbose } 4>&1 | - Where-Object { $_.GetType().Name -eq 'VerboseRecord' } | - Out-String - $testJwtVerbose = & { Test-Jwt -jwt $jwt -Secret 'super-secret' -Verbose } 4>&1 | - Where-Object { $_.GetType().Name -eq 'VerboseRecord' } | - Out-String + Context 'JWK round-trip' { + It 'round-trips an RSA key through ConvertTo-JwtKey / ConvertFrom-JwtKey' { + $rsa = [System.Security.Cryptography.RSA]::Create(2048) + try { + $jwk = ConvertTo-JwtKey -Key $rsa -IncludePrivateParameters + $jwk.kty | Should -Be 'RSA' + $jwk.n | Should -Not -BeNullOrEmpty + $jwk.e | Should -Not -BeNullOrEmpty + + $rsa2 = ConvertFrom-JwtKey -Key $jwk + try { + $jwt = New-Jwt -Payload @{ sub = 'joe' } -Algorithm RS256 -Key $rsa + Test-Jwt -Token $jwt -Key $rsa2 -RequireExpiration $false | Should -BeTrue + } finally { $rsa2.Dispose() } + } finally { $rsa.Dispose() } + } + + It 'round-trips an EC P-256 key through ConvertTo-JwtKey / ConvertFrom-JwtKey' { + $ecdsa = [System.Security.Cryptography.ECDsa]::Create( + [System.Security.Cryptography.ECCurve]::CreateFromValue('1.2.840.10045.3.1.7')) + try { + $jwk = ConvertTo-JwtKey -Key $ecdsa -IncludePrivateParameters + $jwk.kty | Should -Be 'EC' + $jwk.crv | Should -Be 'P-256' + + $ecdsa2 = ConvertFrom-JwtKey -Key $jwk + try { + $jwt = New-Jwt -Payload @{ sub = 'joe' } -Algorithm ES256 -Key $ecdsa + Test-Jwt -Token $jwt -Key $ecdsa2 -RequireExpiration $false | Should -BeTrue + } finally { $ecdsa2.Dispose() } + } finally { $ecdsa.Dispose() } + } + + It 'round-trips an HMAC byte[] key through ConvertTo-JwtKey / ConvertFrom-JwtKey' { + $bytes = [System.Text.Encoding]::UTF8.GetBytes('a-string-secret-at-least-256-bits-long') + $jwk = ConvertTo-JwtKey -Key $bytes + $jwk.kty | Should -Be 'oct' + $jwk.k | Should -Not -BeNullOrEmpty + + $jwt = New-Jwt -Payload @{ sub = 'joe' } -Algorithm HS256 -Key $bytes + Test-Jwt -Token $jwt -Key $jwk -RequireExpiration $false | Should -BeTrue + } + } + + Context 'JWS algorithm coverage (RFC 7518 §3)' { + BeforeAll { + $script:secret = 'a-string-secret-at-least-256-bits-long' + $script:rsa = [System.Security.Cryptography.RSA]::Create(2048) + $script:ec256 = [System.Security.Cryptography.ECDsa]::Create( + [System.Security.Cryptography.ECCurve]::CreateFromValue('1.2.840.10045.3.1.7')) + $script:ec384 = [System.Security.Cryptography.ECDsa]::Create( + [System.Security.Cryptography.ECCurve]::CreateFromValue('1.3.132.0.34')) + $script:ec521 = [System.Security.Cryptography.ECDsa]::Create( + [System.Security.Cryptography.ECCurve]::CreateFromValue('1.3.132.0.35')) + } + + AfterAll { + $script:rsa.Dispose() + $script:ec256.Dispose() + $script:ec384.Dispose() + $script:ec521.Dispose() + } + + It 'signs and verifies ' -ForEach @( + @{ Alg = 'HS256' }, + @{ Alg = 'HS384' }, + @{ Alg = 'HS512' } + ) { + $jwt = New-Jwt -Payload @{ sub = 'joe' } -Algorithm $Alg -Key $script:secret + $jwt.Header.alg | Should -Be $Alg + Test-Jwt -Token $jwt -Key $script:secret -RequireExpiration $false | Should -BeTrue + } + + It 'signs and verifies ' -ForEach @( + @{ Alg = 'RS256' }, + @{ Alg = 'RS384' }, + @{ Alg = 'RS512' }, + @{ Alg = 'PS256' }, + @{ Alg = 'PS384' }, + @{ Alg = 'PS512' } + ) { + $jwt = New-Jwt -Payload @{ sub = 'joe' } -Algorithm $Alg -Key $script:rsa + $jwt.Header.alg | Should -Be $Alg + Test-Jwt -Token $jwt -Key $script:rsa -RequireExpiration $false | Should -BeTrue + } + + It 'signs and verifies with curve ' -ForEach @( + @{ Alg = 'ES256'; Crv = 'P-256'; KeyVar = 'ec256' }, + @{ Alg = 'ES384'; Crv = 'P-384'; KeyVar = 'ec384' }, + @{ Alg = 'ES512'; Crv = 'P-521'; KeyVar = 'ec521' } + ) { + $key = (Get-Variable -Scope Script -Name $KeyVar -ValueOnly) + $jwt = New-Jwt -Payload @{ sub = 'joe' } -Algorithm $Alg -Key $key + $jwt.Header.alg | Should -Be $Alg + Test-Jwt -Token $jwt -Key $key -RequireExpiration $false | Should -BeTrue + } + + It 'rejects an EC key whose curve does not match the algorithm' { + { New-Jwt -Payload @{ sub = 'x' } -Algorithm ES384 -Key $script:ec256 } | + Should -Throw '*P-384*' + } + + It 'rejects RS512 sign-attempts that use a HS512 key' { + { New-Jwt -Payload @{ sub = 'x' } -Algorithm RS512 -Key $script:secret } | + Should -Throw '*RS512*' + } + + It 'PS256 signatures are not bit-identical across runs (PSS is randomized)' { + $a = (New-Jwt -Payload @{ sub = 'x' } -Algorithm PS256 -Key $script:rsa).Signature + $b = (New-Jwt -Payload @{ sub = 'x' } -Algorithm PS256 -Key $script:rsa).Signature + $a | Should -Not -Be $b + } + + It 'RS256 signatures are deterministic for the same input' { + $a = (New-Jwt -Payload @{ sub = 'x' } -Algorithm RS256 -Key $script:rsa).Signature + $b = (New-Jwt -Payload @{ sub = 'x' } -Algorithm RS256 -Key $script:rsa).Signature + $a | Should -Be $b + } + } + + Context 'JWK Thumbprint (RFC 7638)' { + It 'matches the RFC 7638 §3.1 reference vector' { + $json = @' +{"keys":[{"kty":"RSA","n":"0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw","e":"AQAB","alg":"RS256","kid":"2011-04-29"}]} +'@ + $jwk = (ConvertFrom-JwtKeySet -Json $json).keys[0] + Get-JwtKeyThumbprint -Key $jwk | Should -Be 'NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs' + } + + It 'computes thumbprints for EC and oct kty' { + $rsa = [System.Security.Cryptography.RSA]::Create(2048) + $ec = [System.Security.Cryptography.ECDsa]::Create( + [System.Security.Cryptography.ECCurve]::CreateFromValue('1.2.840.10045.3.1.7')) + try { + $rsaJwk = ConvertTo-JwtKey -Key $rsa + $ecJwk = ConvertTo-JwtKey -Key $ec + $octJwk = ConvertTo-JwtKey -Key ([byte[]](1..32)) + + Get-JwtKeyThumbprint -Key $rsaJwk | Should -Match '^[A-Za-z0-9_-]{43}$' + Get-JwtKeyThumbprint -Key $ecJwk | Should -Match '^[A-Za-z0-9_-]{43}$' + Get-JwtKeyThumbprint -Key $octJwk | Should -Match '^[A-Za-z0-9_-]{43}$' + } finally { + $rsa.Dispose() + $ec.Dispose() + } + } + + It 'supports SHA-384 and SHA-512 thumbprint variants' { + $rsa = [System.Security.Cryptography.RSA]::Create(2048) + try { + $jwk = ConvertTo-JwtKey -Key $rsa + Get-JwtKeyThumbprint -Key $jwk -HashAlgorithm SHA384 | Should -Match '^[A-Za-z0-9_-]{64}$' + Get-JwtKeyThumbprint -Key $jwk -HashAlgorithm SHA512 | Should -Match '^[A-Za-z0-9_-]{86}$' + } finally { $rsa.Dispose() } + } + + It 'fails when a required field is missing' { + $jwk = (ConvertFrom-JwtKeySet -Json '{"keys":[{"kty":"RSA","e":"AQAB"}]}').keys[0] + { Get-JwtKeyThumbprint -Key $jwk } | Should -Throw '*missing*' + } + } + + Context 'JWK Set (RFC 7517 §5)' { + BeforeAll { + $rsa = [System.Security.Cryptography.RSA]::Create(2048) + $ec = [System.Security.Cryptography.ECDsa]::Create( + [System.Security.Cryptography.ECCurve]::CreateFromValue('1.2.840.10045.3.1.7')) + $script:rsaForSet = $rsa + $script:ecForSet = $ec + $script:rsaJwk = ConvertTo-JwtKey -Key $rsa -KeyId 'rsa-1' -Algorithm RS256 + $script:ecJwk = ConvertTo-JwtKey -Key $ec -KeyId 'ec-1' -Algorithm ES256 + } + + AfterAll { + $script:rsaForSet.Dispose() + $script:ecForSet.Dispose() + } + + It 'wraps multiple keys via ConvertTo-JwtKeySet' { + $set = $script:rsaJwk, $script:ecJwk | ConvertTo-JwtKeySet + $set.GetType().Name | Should -Be 'JwtKeySet' + $set.keys.Count | Should -Be 2 + } + + It 'serializes to a JWKS JSON document with a "keys" array' { + $set = $script:rsaJwk, $script:ecJwk | ConvertTo-JwtKeySet + $json = $set.ToJson() + $json | Should -Match '"keys":\[' + $json | Should -Match '"kid":"rsa-1"' + $json | Should -Match '"kid":"ec-1"' + } + + It 'round-trips through ConvertFrom-JwtKeySet' { + $set = $script:rsaJwk, $script:ecJwk | ConvertTo-JwtKeySet + $parsed = ConvertFrom-JwtKeySet -Json $set.ToJson() + $parsed.keys.Count | Should -Be 2 + $parsed.keys[0].kid | Should -Be 'rsa-1' + $parsed.keys[1].kid | Should -Be 'ec-1' + } + + It 'rejects JWKS JSON missing the keys array' { + { ConvertFrom-JwtKeySet -Json '{}' } | Should -Throw "*'keys'*" + } + + It 'Get-JwtKeyFromSet returns the matching JwtKey by kid' { + $set = $script:rsaJwk, $script:ecJwk | ConvertTo-JwtKeySet + (Get-JwtKeyFromSet -KeySet $set -KeyId 'ec-1').crv | Should -Be 'P-256' + } + + It 'Get-JwtKeyFromSet returns $null for an unknown kid' { + $set = $script:rsaJwk, $script:ecJwk | ConvertTo-JwtKeySet + Get-JwtKeyFromSet -KeySet $set -KeyId 'nope' | Should -BeNullOrEmpty + } + + It '-ErrorIfMissing emits a non-terminating error for an unknown kid' { + $set = $script:rsaJwk, $script:ecJwk | ConvertTo-JwtKeySet + $err = $null + Get-JwtKeyFromSet -KeySet $set -KeyId 'nope' -ErrorIfMissing -ErrorVariable err -ErrorAction SilentlyContinue + $err.Count | Should -BeGreaterThan 0 + } + + It 'Test-Jwt verifies a token whose kid is resolved from a JWKS' { + $set = $script:rsaJwk, $script:ecJwk | ConvertTo-JwtKeySet + $jwt = New-Jwt -Payload @{ sub = 'app' } -Algorithm ES256 -Key $script:ecForSet -Header @{ kid = 'ec-1' } + $kid = (Get-JwtHeader -Token $jwt).kid + $resolved = Get-JwtKeyFromSet -KeySet $set -KeyId $kid + Test-Jwt -Token $jwt -Key $resolved -RequireExpiration $false | Should -BeTrue + } + } - $newJwtVerbose | Should -Not -Match ([regex]::Escape($payload)) - $getHeaderVerbose | Should -Not -Match ([regex]::Escape($jwt)) - $getPayloadVerbose | Should -Not -Match ([regex]::Escape($jwt)) - $testJwtVerbose | Should -Not -Match ([regex]::Escape($jwt)) + Context 'Module manifest' { + It 'declares PowerShell 7.6 as the minimum version' { + $manifest = Import-PowerShellDataFile "$PSScriptRoot/../src/manifest.psd1" + $manifest.PowerShellVersion | Should -Be '7.6' + $manifest.CompatiblePSEditions | Should -Be @('Core') } } }