diff --git a/.github/linters/.powershell-psscriptanalyzer.psd1 b/.github/linters/.powershell-psscriptanalyzer.psd1
index 09cc3d0..2226a5c 100644
--- a/.github/linters/.powershell-psscriptanalyzer.psd1
+++ b/.github/linters/.powershell-psscriptanalyzer.psd1
@@ -51,6 +51,7 @@
}
ExcludeRules = @(
'PSMissingModuleManifestField', # This rule is not applicable until the module is built.
- 'PSUseToExportFieldsInManifest'
+ 'PSUseToExportFieldsInManifest',
+ 'TypeNotFound' # False positive: class cross-references are resolved at runtime, not parse time.
)
}
diff --git a/.github/workflows/Process-PSModule.yml b/.github/workflows/Process-PSModule.yml
index 753abb3..b1dfd4b 100644
--- a/.github/workflows/Process-PSModule.yml
+++ b/.github/workflows/Process-PSModule.yml
@@ -27,5 +27,7 @@ permissions:
jobs:
Process-PSModule:
- uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@e8f5b22925c5a4dcf462d8b212570b66ce6a8df4 # v5.5.2
- secrets: inherit
+ uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@0307c0d443c7daa295d10684017f1ac015efab38 # v6.1.12
+ secrets:
+ APIKey: ${{ secrets.APIKey }}
+ TestData: ${{ secrets.TestData }}
diff --git a/README.md b/README.md
index e88a481..1687377 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,190 @@ 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
+
+| Function | Purpose |
+| ----------------------------------------------------------- | --------------------------------------------------------------------------------- |
+| `New-Jwt` | Create a JWT from header overrides and claims; 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`, `iat`, `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) |
+| `New-JwtSigningKey` | Generate a compatible signing key (`byte[]` / `RSA` / `ECDsa`) or JWK |
+| `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) |
-The maintained module exports the same JWT commands and alias used by the current package:
+Public types: `[Jwt]`, `[JwtHeader]`, `[JwtPayload]`, `[JwtKey]`, `[JwtKeySet]`, `[JwtBase64Url]`.
+
+Default type/format metadata is included for these classes.
+`JwtKey` output is intentionally summary-only so private key material
+(`d`, `p`, `q`, `dp`, `dq`, `qi`, `oth`, `k`) is not shown in default views.
+
+## 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 = New-JwtSigningKey -Algorithm RS256 -RsaKeySize 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
+```
+
+### ES256 / ES384 / ES512 with an EC key
+
+```powershell
+$ec = New-JwtSigningKey -Algorithm ES256
+New-Jwt -Payload @{ sub = 'app' } -Algorithm ES256 -Key $ec
+```
+
+### Let `New-Jwt` generate the key (parameter-set path)
+
+```powershell
+$jwt = New-Jwt -Payload @{ sub = 'app' } -Algorithm ES256 -GenerateKey -GeneratedKeyId 'ec-1'
+$jwt
+```
+
+### 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()
+```
-Create and validate an HMAC-signed JWT:
+## Parse
```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'
+$parsed = ConvertFrom-Jwt -Token $compactString
+$parsed.Header.alg
+$parsed.Payload.sub
+$parsed.Payload.AdditionalFields['groups']
+```
-$jwt = New-Jwt -Header $header -PayloadJson $payload -Secret $secret
-Test-Jwt -jwt $jwt -Secret $secret
+## Inspect
+
+```powershell
+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
```
-Read the header and payload from an existing token:
+`Get-JwtClaim` silently returns `$null` for a missing single claim; pass `-ErrorIfMissing` to escalate to non-terminating errors.
+
+## Validate
```powershell
-Get-JwtHeader -jwt $jwt
-Get-JwtPayload -jwt $jwt
+Test-Jwt -Token $compactString -Key $rsaPublic `
+ -Issuer 'https://issuer' -Audience 'api' -ClockSkew ([timespan]::FromMinutes(2))
+
+# Tokens with JOSE critical headers require explicit allow-listing
+Test-Jwt -Token $compactString -Key $rsaPublic -AllowedCriticalHeader 'kid'
+
+# Structured report
+Test-Jwt -Token $compactString -Key $rsaPublic -Detailed
+
+# Unsigned validation path (alg=none only)
+Test-Jwt -Token $unsignedCompact -AllowUnsigned -RequireExpiration $false
```
-For more information about each command, use PowerShell help:
+`-Detailed` returns:
+
+```text
+Valid : True
+SignatureValidated : True
+Algorithm : RS256
+Checks : @(
+ @{ Name = 'Algorithm'; Passed = $true; Reason = $null }
+ @{ Name = 'CriticalHeaders'; Passed = $true; Reason = $null }
+ @{ Name = 'Signature'; Passed = $true; Reason = $null }
+ @{ Name = 'Expiration'; Passed = $true; Reason = $null }
+ @{ Name = 'NotBefore'; Passed = $true; Reason = $null }
+ @{ Name = 'IssuedAt'; Passed = $true; Reason = $null }
+ @{ Name = 'Issuer'; Passed = $true; Reason = $null }
+ @{ Name = 'Audience'; Passed = $true; Reason = $null }
+)
+```
+
+## Keys (JWK + JWKS + thumbprints)
```powershell
-Get-Command -Module Jwt
-Get-Help New-Jwt -Full
+$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 +202,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/examples/create-validate-hs256.ps1 b/examples/create-validate-hs256.ps1
new file mode 100644
index 0000000..94efedf
--- /dev/null
+++ b/examples/create-validate-hs256.ps1
@@ -0,0 +1,10 @@
+$secret = 'a-string-secret-at-least-256-bits-long'
+
+$jwt = New-Jwt -Payload @{
+ sub = 'user@example.com'
+ iss = 'https://issuer.example'
+ aud = 'api'
+ exp = [DateTimeOffset]::UtcNow.AddMinutes(15).ToUnixTimeSeconds()
+} -Algorithm HS256 -Key $secret
+
+Test-Jwt -Token $jwt -Key $secret -Issuer 'https://issuer.example' -Audience 'api'
diff --git a/examples/jwks-key-selection.ps1 b/examples/jwks-key-selection.ps1
new file mode 100644
index 0000000..440e284
--- /dev/null
+++ b/examples/jwks-key-selection.ps1
@@ -0,0 +1,22 @@
+$rsa = New-JwtSigningKey -Algorithm RS256
+$ec = New-JwtSigningKey -Algorithm ES256
+
+try {
+ $rsaJwk = ConvertTo-JwtKey -Key $rsa -KeyId 'rsa-1' -Algorithm RS256
+ $ecJwk = ConvertTo-JwtKey -Key $ec -KeyId 'ec-1' -Algorithm ES256
+ $set = $rsaJwk, $ecJwk | ConvertTo-JwtKeySet
+
+ # Path 1: keep explicit key ownership in caller code
+ $token = New-Jwt -Payload @{ sub = 'app' } -Algorithm ES256 -Key $ec -Header @{ kid = 'ec-1' }
+
+ # Path 2: let New-Jwt generate and own the signing key lifecycle
+ # $token = New-Jwt -Payload @{ sub = 'app' } -Algorithm ES256 -GenerateKey -GeneratedKeyId 'ec-1'
+
+ $kid = (Get-JwtHeader -Token $token).kid
+ $key = Get-JwtKeyFromSet -KeySet $set -KeyId $kid
+
+ Test-Jwt -Token $token -Key $key -RequireExpiration $false
+} finally {
+ $rsa.Dispose()
+ $ec.Dispose()
+}
diff --git a/examples/low-level-base64url.ps1 b/examples/low-level-base64url.ps1
new file mode 100644
index 0000000..50baed1
--- /dev/null
+++ b/examples/low-level-base64url.ps1
@@ -0,0 +1,6 @@
+$json = '{"sub":"joe"}'
+$encoded = [JwtBase64Url]::EncodeString($json)
+$decoded = [JwtBase64Url]::DecodeString($encoded)
+
+$encoded
+$decoded
diff --git a/examples/parse-inspect.ps1 b/examples/parse-inspect.ps1
new file mode 100644
index 0000000..a6ffb2c
--- /dev/null
+++ b/examples/parse-inspect.ps1
@@ -0,0 +1,13 @@
+$token = New-Jwt -Payload @{
+ sub = 'joe'
+ role = 'admin'
+} -Algorithm HS256 -Key 'a-string-secret-at-least-256-bits-long'
+
+$compact = $token.ToString()
+$parsed = ConvertFrom-Jwt -Token $compact
+
+Get-JwtHeader -Token $compact
+Get-JwtPayload -Token $compact
+Get-JwtClaim -Token $compact -Name @('sub', 'role')
+
+$parsed
diff --git a/src/classes/public/JwtBase64Url.ps1 b/src/classes/public/JwtBase64Url.ps1
new file mode 100644
index 0000000..d437531
--- /dev/null
+++ b/src/classes/public/JwtBase64Url.ps1
@@ -0,0 +1,31 @@
+<#
+ Base64url codec helper used by JWT/JWS/JWK serialization paths.
+#>
+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..16a5267
--- /dev/null
+++ b/src/classes/public/JwtHeader.ps1
@@ -0,0 +1,41 @@
+<#
+ JOSE header model for compact JWT/JWS tokens.
+ Unknown header parameters are preserved in AdditionalFields.
+#>
+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..8dec896
--- /dev/null
+++ b/src/classes/public/JwtKey.ps1
@@ -0,0 +1,72 @@
+<#
+ JSON Web Key (JWK) model for RSA, EC, and oct key types.
+ Unknown members are preserved in AdditionalFields.
+#>
+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..da07a08
--- /dev/null
+++ b/src/classes/public/JwtKeySet.ps1
@@ -0,0 +1,59 @@
+<#
+ JSON Web Key Set (JWKS) model containing one or more JwtKey entries.
+#>
+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..598cd47
--- /dev/null
+++ b/src/classes/public/JwtPayload.ps1
@@ -0,0 +1,83 @@
+<#
+ JWT payload model with typed registered claims and preserved private claims.
+#>
+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) {
+ $autoNull = [System.Management.Automation.Internal.AutomationNull]::Value
+ switch ($key) {
+ 'iss' { if ($this.iss) { return $this.iss } else { return $autoNull } }
+ 'sub' { if ($this.sub) { return $this.sub } else { return $autoNull } }
+ 'aud' { if ($null -ne $this.aud) { return $this.aud } else { return $autoNull } }
+ 'exp' { if ($null -ne $this.exp) { return [long]$this.exp } else { return $autoNull } }
+ 'nbf' { if ($null -ne $this.nbf) { return [long]$this.nbf } else { return $autoNull } }
+ 'iat' { if ($null -ne $this.iat) { return [long]$this.iat } else { return $autoNull } }
+ 'jti' { if ($this.jti) { return $this.jti } else { return $autoNull } }
+ default {
+ if ($this.AdditionalFields.Contains($key)) { return $this.AdditionalFields[$key] }
+ return $autoNull
+ }
+ }
+ return $autoNull
+ }
+
+ [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/classes/public/JwtToken.ps1 b/src/classes/public/JwtToken.ps1
new file mode 100644
index 0000000..42ea2f8
--- /dev/null
+++ b/src/classes/public/JwtToken.ps1
@@ -0,0 +1,50 @@
+<#
+ Compact JWT model containing typed header/payload plus encoded segments/signature.
+#>
+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/formats/Jwt.Format.ps1xml b/src/formats/Jwt.Format.ps1xml
new file mode 100644
index 0000000..bcd7cac
--- /dev/null
+++ b/src/formats/Jwt.Format.ps1xml
@@ -0,0 +1,203 @@
+
+
+
+
+ JwtSummary
+
+ Jwt
+
+
+
+
+
+
+
+ Algorithm
+
+
+
+ KeyId
+
+
+
+ Issuer
+
+
+
+ Subject
+
+
+
+ Audience
+
+
+
+ ExpiresAtUtc
+
+
+
+ HasSignature
+
+
+
+
+
+
+
+ JwtHeaderSummary
+
+ JwtHeader
+
+
+
+
+
+
+
+ alg
+
+
+
+ typ
+
+
+
+ kid
+
+
+
+ AdditionalFieldCount
+
+
+
+
+
+
+
+ JwtPayloadSummary
+
+ JwtPayload
+
+
+
+
+
+
+
+ iss
+
+
+
+ sub
+
+
+
+ Audience
+
+
+
+ ExpiresAtUtc
+
+
+
+ NotBeforeUtc
+
+
+
+ IssuedAtUtc
+
+
+
+ jti
+
+
+
+ AdditionalFieldCount
+
+
+
+
+
+
+
+ JwtKeySummary
+
+ JwtKey
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ kid
+
+
+ kty
+
+
+ alg
+
+
+ use
+
+
+ crv
+
+
+ Operations
+
+
+ HasPrivateMaterial
+
+
+
+
+
+
+
+ JwtKeySetSummary
+
+ JwtKeySet
+
+
+
+
+
+
+
+ KeyCount
+
+
+
+ KeyIds
+
+
+
+
+
+
+
+
diff --git a/src/functions/private/Crypto/Get-JwtAlgorithmHash.ps1 b/src/functions/private/Crypto/Get-JwtAlgorithmHash.ps1
new file mode 100644
index 0000000..25206e7
--- /dev/null
+++ b/src/functions/private/Crypto/Get-JwtAlgorithmHash.ps1
@@ -0,0 +1,28 @@
+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.
+ .LINK
+ https://psmodule.io/Jwt/Functions/Crypto/Get-JwtAlgorithmHash/
+
+ #>
+ [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/Crypto/New-JwtHmac.ps1 b/src/functions/private/Crypto/New-JwtHmac.ps1
new file mode 100644
index 0000000..3bc9788
--- /dev/null
+++ b/src/functions/private/Crypto/New-JwtHmac.ps1
@@ -0,0 +1,39 @@
+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.
+ .LINK
+ https://psmodule.io/Jwt/Functions/Crypto/New-JwtHmac/
+
+ #>
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
+ 'PSUseShouldProcessForStateChangingFunctions', '',
+ Justification = 'New-JwtHmac creates an in-memory HMAC instance and does not change system state.'
+ )]
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
+ 'PSUseOutputTypeCorrectly', '',
+ Justification = 'Returns HMACSHA256/384/512 which derive from HMAC.'
+ )]
+ [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/Keys/Resolve-JwtKey.ps1 b/src/functions/private/Keys/Resolve-JwtKey.ps1
new file mode 100644
index 0000000..e7fb0a5
--- /dev/null
+++ b/src/functions/private/Keys/Resolve-JwtKey.ps1
@@ -0,0 +1,211 @@
+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.
+ .LINK
+ https://psmodule.io/Jwt/Functions/Keys/Resolve-JwtKey/
+
+ #>
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
+ 'PSUseOutputTypeCorrectly', '',
+ Justification = 'Returns RSA/ECDsa/HMAC depending on algorithm family; all derive from object.'
+ )]
+ [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/Token/Test-JwtClaim.ps1 b/src/functions/private/Token/Test-JwtClaim.ps1
new file mode 100644
index 0000000..4024c1a
--- /dev/null
+++ b/src/functions/private/Token/Test-JwtClaim.ps1
@@ -0,0 +1,121 @@
+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.
+ .LINK
+ https://psmodule.io/Jwt/Functions/Token/Test-JwtClaim/
+
+ #>
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
+ 'PSUseOutputTypeCorrectly', '',
+ Justification = 'Returns hashtable[] via unary comma to prevent pipeline unrolling.'
+ )]
+ [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 = [System.Collections.Generic.List[hashtable]]::new()
+ $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.Add(@{ Name = 'Expiration'; Passed = $false; Reason = "Token expired at $expAt." })
+ } else {
+ $checks.Add(@{ Name = 'Expiration'; Passed = $true; Reason = $null })
+ }
+ } else {
+ if ($RequireExpiration) {
+ $checks.Add(@{ Name = 'Expiration'; Passed = $false; Reason = "Token has no 'exp' claim." })
+ } else {
+ $checks.Add(@{ 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.Add(@{ Name = 'NotBefore'; Passed = $false; Reason = "Token not valid before $nbfAt." })
+ } else {
+ $checks.Add(@{ Name = 'NotBefore'; Passed = $true; Reason = $null })
+ }
+ } else {
+ $checks.Add(@{ Name = 'NotBefore'; Passed = $true; Reason = $null })
+ }
+
+ if ($null -ne $Payload.iat) {
+ $iatVal = [long]$Payload.iat
+ if ($nowSec -lt ($iatVal - $skewSec)) {
+ $iatAt = [DateTimeOffset]::FromUnixTimeSeconds($iatVal).UtcDateTime.ToString('o')
+ $checks.Add(@{ Name = 'IssuedAt'; Passed = $false; Reason = "Token issued-at is in the future at $iatAt." })
+ } else {
+ $checks.Add(@{ Name = 'IssuedAt'; Passed = $true; Reason = $null })
+ }
+ } else {
+ $checks.Add(@{ Name = 'IssuedAt'; Passed = $true; Reason = $null })
+ }
+
+ if ($PSBoundParameters.ContainsKey('Issuer')) {
+ if ($Payload.iss -cne $Issuer) {
+ $checks.Add(@{ Name = 'Issuer'; Passed = $false; Reason = "Issuer '$($Payload.iss)' does not match expected '$Issuer'." })
+ } else {
+ $checks.Add(@{ Name = 'Issuer'; Passed = $true; Reason = $null })
+ }
+ } else {
+ $checks.Add(@{ 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.Add(@{ Name = 'Audience'; Passed = $true; Reason = $null })
+ } else {
+ $checks.Add(@{ Name = 'Audience'; Passed = $false; Reason = "None of the supplied audiences matched the token's 'aud' claim." })
+ }
+ } else {
+ $checks.Add(@{ Name = 'Audience'; Passed = $true; Reason = $null })
+ }
+
+ return , $checks.ToArray()
+}
+
diff --git a/src/functions/private/Token/Test-JwtSignature.ps1 b/src/functions/private/Token/Test-JwtSignature.ps1
new file mode 100644
index 0000000..11cbaed
--- /dev/null
+++ b/src/functions/private/Token/Test-JwtSignature.ps1
@@ -0,0 +1,88 @@
+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.
+ .LINK
+ https://psmodule.io/Jwt/Functions/Token/Test-JwtSignature/
+
+ #>
+ [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)
+ return [System.Security.Cryptography.CryptographicOperations]::FixedTimeEquals($computed, $sigBytes)
+ }
+ '^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
deleted file mode 100644
index e6ae97e..0000000
--- a/src/functions/public/ConvertFrom-Base64UrlString.ps1
+++ /dev/null
@@ -1,63 +0,0 @@
-function ConvertFrom-Base64UrlString {
- <#
- .SYNOPSIS
- Decodes a base64url string.
-
- .DESCRIPTION
- Decodes a base64url-encoded string to UTF-8 text by default. Use AsByteArray to return the decoded bytes.
-
- .EXAMPLE
- ```powershell
- 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9' | ConvertFrom-Base64UrlString
- ```
-
- Decodes the base64url value to `{"alg":"RS256","typ":"JWT"}`.
-
- .INPUTS
- System.String
-
- .OUTPUTS
- System.String
- System.Byte[]
-
- .NOTES
- Converts JWT-safe base64url text by restoring standard base64 characters and padding before decoding.
-
- .LINK
- https://psmodule.io/Jwt/Functions/ConvertFrom-Base64UrlString/
-
- .LINK
- https://jwt.io/
- #>
- [OutputType([string], [byte[]])]
- [CmdletBinding()]
- param(
- # The base64url-encoded string to decode.
- [Parameter(Mandatory, ValueFromPipeline, Position = 0)]
- [ValidateNotNullOrEmpty()]
- [string] $Base64UrlString,
-
- # Return decoded bytes instead of UTF-8 text.
- [Parameter()]
- [switch] $AsByteArray
- )
-
- begin {}
-
- process {
- $base64String = $Base64UrlString.Replace('-', '+').Replace('_', '/')
- switch ($base64String.Length % 4) {
- 0 { }
- 1 { throw [System.FormatException]::new('Invalid base64url string length.') }
- 2 { $base64String = $base64String + '==' }
- 3 { $base64String = $base64String + '=' }
- }
- if ($AsByteArray) {
- [Convert]::FromBase64String($base64String)
- } else {
- [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($base64String))
- }
- }
-
- end {}
-}
diff --git a/src/functions/public/ConvertTo-Base64UrlString.ps1 b/src/functions/public/ConvertTo-Base64UrlString.ps1
deleted file mode 100644
index bde84d5..0000000
--- a/src/functions/public/ConvertTo-Base64UrlString.ps1
+++ /dev/null
@@ -1,59 +0,0 @@
-function ConvertTo-Base64UrlString {
- <#
- .SYNOPSIS
- Encodes text or bytes as a base64url string.
-
- .DESCRIPTION
- Encodes a string or byte array using base64url encoding suitable for JWT headers, payloads, and signatures.
-
- .EXAMPLE
- ```powershell
- '{"alg":"RS256","typ":"JWT"}' | ConvertTo-Base64UrlString
- ```
-
- Encodes the JWT header JSON as `eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9`.
-
- .INPUTS
- System.String
- System.Byte[]
-
- .OUTPUTS
- System.String
-
- .NOTES
- Converts standard base64 output to JWT-safe base64url text by replacing URL-sensitive
- characters and removing padding.
-
- .LINK
- https://psmodule.io/Jwt/Functions/ConvertTo-Base64UrlString/
-
- .LINK
- https://jwt.io/
- #>
- [OutputType([string])]
- [CmdletBinding()]
- param(
- # The string or byte array to encode.
- [Parameter(Mandatory, ValueFromPipeline, Position = 0)]
- [ValidateNotNull()]
- [Alias('in')]
- [object] $InputObject
- )
-
- begin {}
-
- process {
- if ($InputObject -is [string]) {
- $bytes = [System.Text.Encoding]::UTF8.GetBytes($InputObject)
- [Convert]::ToBase64String($bytes) -replace '\+', '-' -replace '/', '_' -replace '='
- } elseif ($InputObject -is [byte[]]) {
- [Convert]::ToBase64String($InputObject) -replace '\+', '-' -replace '/', '_' -replace '='
- } else {
- $type = $InputObject.GetType()
- $message = "ConvertTo-Base64UrlString requires string or byte array input, received $type"
- throw [System.ArgumentException]::new($message)
- }
- }
-
- end {}
-}
diff --git a/src/functions/public/Encoding/ConvertFrom-Base64UrlString.ps1 b/src/functions/public/Encoding/ConvertFrom-Base64UrlString.ps1
new file mode 100644
index 0000000..d4918bc
--- /dev/null
+++ b/src/functions/public/Encoding/ConvertFrom-Base64UrlString.ps1
@@ -0,0 +1,41 @@
+function ConvertFrom-Base64UrlString {
+ <#
+ .SYNOPSIS
+ Decodes a base64url string.
+
+ .DESCRIPTION
+ Internal helper that wraps the [JwtBase64Url] class. Returns a UTF-8 string by
+ default or the raw byte array when -AsByteArray is supplied.
+
+ .EXAMPLE
+ ConvertFrom-Base64UrlString 'SGVsbG8'
+
+ Decodes the base64url string and returns the UTF-8 representation.
+ .LINK
+ https://psmodule.io/Jwt/Functions/Encoding/ConvertFrom-Base64UrlString/
+
+ #>
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
+ 'PSUseOutputTypeCorrectly', '',
+ Justification = 'Returns byte[] via unary comma to prevent pipeline unrolling; declared types are correct.'
+ )]
+ [OutputType([string], [byte[]])]
+ [CmdletBinding()]
+ param(
+ # The base64url-encoded value to decode.
+ [Parameter(Mandatory, Position = 0, ValueFromPipeline)]
+ [ValidateNotNull()]
+ [string] $InputObject,
+
+ # Return the raw bytes instead of a UTF-8 string.
+ [Parameter()]
+ [switch] $AsByteArray
+ )
+
+ process {
+ $bytes = [JwtBase64Url]::Decode($InputObject)
+ if ($AsByteArray) { return , $bytes }
+ return [System.Text.Encoding]::UTF8.GetString($bytes)
+ }
+}
+
diff --git a/src/functions/public/Encoding/ConvertTo-Base64UrlString.ps1 b/src/functions/public/Encoding/ConvertTo-Base64UrlString.ps1
new file mode 100644
index 0000000..17bfa63
--- /dev/null
+++ b/src/functions/public/Encoding/ConvertTo-Base64UrlString.ps1
@@ -0,0 +1,41 @@
+function ConvertTo-Base64UrlString {
+ <#
+ .SYNOPSIS
+ Encodes a string or byte array as base64url.
+
+ .DESCRIPTION
+ Internal helper that wraps the [JwtBase64Url] class and produces an unpadded
+ base64url string per RFC 4648 §5.
+
+ .EXAMPLE
+ ConvertTo-Base64UrlString 'Hello'
+
+ Encodes the UTF-8 bytes of the string as base64url.
+ .LINK
+ https://psmodule.io/Jwt/Functions/Encoding/ConvertTo-Base64UrlString/
+
+ #>
+ [OutputType([string])]
+ [CmdletBinding()]
+ param(
+ # The value to encode. Accepts a [string] (UTF-8 encoded) or a [byte[]].
+ [Parameter(Mandatory, Position = 0, ValueFromPipeline)]
+ [ValidateNotNull()]
+ [object] $InputObject
+ )
+
+ process {
+ if ($InputObject -is [byte[]]) {
+ return [JwtBase64Url]::Encode($InputObject)
+ }
+ if ($InputObject -is [string]) {
+ return [JwtBase64Url]::EncodeString($InputObject)
+ }
+ throw [System.ArgumentException]::new(
+ "ConvertTo-Base64UrlString requires string or byte array input. Got [$($InputObject.GetType().FullName)].",
+ 'InputObject'
+ )
+ }
+}
+
+
diff --git a/src/functions/public/Encoding/Encoding.md b/src/functions/public/Encoding/Encoding.md
new file mode 100644
index 0000000..9d28856
--- /dev/null
+++ b/src/functions/public/Encoding/Encoding.md
@@ -0,0 +1,5 @@
+# Encoding commands
+
+Public helpers for Base64URL encoding and decoding per RFC 4648 section 5.
+
+These are utility commands used by token and key workflows.
diff --git a/src/functions/public/Get-JwtHeader.ps1 b/src/functions/public/Get-JwtHeader.ps1
deleted file mode 100644
index c38bf86..0000000
--- a/src/functions/public/Get-JwtHeader.ps1
+++ /dev/null
@@ -1,56 +0,0 @@
-function Get-JwtHeader {
- <#
- .SYNOPSIS
- Gets the decoded header from a JWT.
-
- .DESCRIPTION
- Decodes and returns the JSON header segment from a JSON Web Token. The payload and signature are ignored.
-
- .EXAMPLE
- ```powershell
- $jwt = 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJqb2UiLCJyb2xlIjoiYWRtaW4ifQ.' #gitleaks:allow
- Get-JwtHeader -Jwt $jwt
- ```
-
- Gets the decoded header JSON from an unsigned JWT.
-
- .INPUTS
- System.String
-
- .OUTPUTS
- System.String
-
- .NOTES
- This command decodes only the header segment and does not validate the token signature.
-
- .LINK
- https://psmodule.io/Jwt/Functions/Get-JwtHeader/
-
- .LINK
- https://jwt.io/
- #>
- [OutputType([string])]
- [CmdletBinding()]
- param(
- # The JWT to read.
- [Parameter(Mandatory, ValueFromPipeline, Position = 0)]
- [ValidateNotNullOrEmpty()]
- [string] $Jwt
- )
-
- begin {}
-
- process {
- Write-Verbose "Processing JWT with length $($Jwt.Length) characters"
- $parts = $Jwt.Split('.')
- if ($parts.Count -ne 3) {
- throw [System.ArgumentException]::new('JWT must have exactly 3 segments.')
- }
- if (-not $parts[0]) {
- throw [System.ArgumentException]::new('JWT header segment is missing.')
- }
- ConvertFrom-Base64UrlString $parts[0]
- }
-
- end {}
-}
diff --git a/src/functions/public/Get-JwtPayload.ps1 b/src/functions/public/Get-JwtPayload.ps1
deleted file mode 100644
index 78eb909..0000000
--- a/src/functions/public/Get-JwtPayload.ps1
+++ /dev/null
@@ -1,55 +0,0 @@
-function Get-JwtPayload {
- <#
- .SYNOPSIS
- Gets the decoded payload from a JWT.
-
- .DESCRIPTION
- Decodes and returns the JSON payload segment from a JSON Web Token. The header and signature are ignored.
-
- .EXAMPLE
- ```powershell
- $jwt | Get-JwtPayload
- ```
-
- Gets the decoded payload JSON from a JWT.
-
- .INPUTS
- System.String
-
- .OUTPUTS
- System.String
-
- .NOTES
- This command decodes only the payload segment and does not validate the token signature.
-
- .LINK
- https://psmodule.io/Jwt/Functions/Get-JwtPayload/
-
- .LINK
- https://jwt.io/
- #>
- [OutputType([string])]
- [CmdletBinding()]
- param(
- # The JWT to read.
- [Parameter(Mandatory, ValueFromPipeline, Position = 0)]
- [ValidateNotNullOrEmpty()]
- [string] $Jwt
- )
-
- begin {}
-
- process {
- Write-Verbose "Processing JWT with length $($Jwt.Length) characters"
- $parts = $Jwt.Split('.')
- if ($parts.Count -ne 3) {
- throw [System.ArgumentException]::new('JWT must have exactly 3 segments.')
- }
- if (-not $parts[1]) {
- throw [System.ArgumentException]::new('JWT payload segment is missing.')
- }
- ConvertFrom-Base64UrlString $parts[1]
- }
-
- end {}
-}
diff --git a/src/functions/public/Keys/ConvertFrom-JwtKey.ps1 b/src/functions/public/Keys/ConvertFrom-JwtKey.ps1
new file mode 100644
index 0000000..4dbf190
--- /dev/null
+++ b/src/functions/public/Keys/ConvertFrom-JwtKey.ps1
@@ -0,0 +1,121 @@
+function ConvertFrom-JwtKey {
+ <#
+ .SYNOPSIS
+ Converts a [JwtKey] (JWK) into a .NET key suitable for signing or verification.
+
+ .DESCRIPTION
+ Returns an [RSA], [ECDsa], or [byte[]] 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' → raw secret [byte[]] decoded from k.
+
+ Use -AsHmac to materialize an [HMAC] instance for oct keys when needed.
+
+ .EXAMPLE
+ $rsa = ConvertFrom-JwtKey -Key $jwk
+
+ Returns an RSA usable with Test-Jwt -Key $rsa.
+
+ .EXAMPLE
+ $hmac = ConvertFrom-JwtKey -Key $octJwk -AsHmac -Algorithm HS512
+
+ Returns an HMACSHA512 from an oct JWK.
+ .LINK
+ https://psmodule.io/Jwt/Functions/Keys/ConvertFrom-JwtKey/
+
+ .INPUTS
+ JwtKey
+
+ .OUTPUTS
+ System.Security.Cryptography.RSA
+ System.Security.Cryptography.ECDsa
+ System.Byte[]
+ System.Security.Cryptography.HMAC
+
+ .NOTES
+ Returning raw bytes for oct avoids implicit algorithm coupling in the conversion layer.
+ #>
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
+ 'PSUseOutputTypeCorrectly', '',
+ Justification = 'Returns RSA/ECDsa/byte[] by default and HMAC for oct with -AsHmac.'
+ )]
+ [OutputType([System.Security.Cryptography.AsymmetricAlgorithm], [byte[]], [System.Security.Cryptography.HMAC])]
+ [CmdletBinding()]
+ param(
+ # The JWK to convert.
+ [Parameter(Mandatory, Position = 0, ValueFromPipeline)]
+ [ValidateNotNull()]
+ [JwtKey] $Key,
+
+ # Return an HMAC instance for oct keys instead of raw secret bytes.
+ [Parameter()]
+ [switch] $AsHmac,
+
+ # HMAC algorithm used with -AsHmac.
+ [Parameter()]
+ [ValidateSet('HS256', 'HS384', 'HS512')]
+ [string] $Algorithm = 'HS256'
+ )
+
+ process {
+ if ($AsHmac -and $Key.kty -ne 'oct') {
+ throw [System.ArgumentException]::new(
+ '-AsHmac is only supported for JWK keys where kty=oct.',
+ 'AsHmac'
+ )
+ }
+
+ 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)
+ if ($AsHmac) {
+ return New-JwtHmac -Algorithm $Algorithm -KeyBytes $bytes
+ }
+ return $bytes
+ }
+ default {
+ throw [System.NotSupportedException]::new("JWK kty '$($Key.kty)' is not supported.")
+ }
+ }
+ }
+}
+
diff --git a/src/functions/public/Keys/ConvertFrom-JwtKeySet.ps1 b/src/functions/public/Keys/ConvertFrom-JwtKeySet.ps1
new file mode 100644
index 0000000..19b7217
--- /dev/null
+++ b/src/functions/public/Keys/ConvertFrom-JwtKeySet.ps1
@@ -0,0 +1,42 @@
+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.
+ .LINK
+ https://psmodule.io/Jwt/Functions/Keys/ConvertFrom-JwtKeySet/
+
+ .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/Keys/ConvertTo-JwtKey.ps1 b/src/functions/public/Keys/ConvertTo-JwtKey.ps1
new file mode 100644
index 0000000..17174f5
--- /dev/null
+++ b/src/functions/public/Keys/ConvertTo-JwtKey.ps1
@@ -0,0 +1,107 @@
+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.
+ .LINK
+ https://psmodule.io/Jwt/Functions/Keys/ConvertTo-JwtKey/
+
+ .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/Keys/ConvertTo-JwtKeySet.ps1 b/src/functions/public/Keys/ConvertTo-JwtKeySet.ps1
new file mode 100644
index 0000000..bae6216
--- /dev/null
+++ b/src/functions/public/Keys/ConvertTo-JwtKeySet.ps1
@@ -0,0 +1,42 @@
+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.
+ .LINK
+ https://psmodule.io/Jwt/Functions/Keys/ConvertTo-JwtKeySet/
+
+ .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/Keys/Get-JwtKeyFromSet.ps1 b/src/functions/public/Keys/Get-JwtKeyFromSet.ps1
new file mode 100644
index 0000000..ecc09e9
--- /dev/null
+++ b/src/functions/public/Keys/Get-JwtKeyFromSet.ps1
@@ -0,0 +1,47 @@
+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.
+ .LINK
+ https://psmodule.io/Jwt/Functions/Keys/Get-JwtKeyFromSet/
+
+ .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/Keys/Get-JwtKeyThumbprint.ps1 b/src/functions/public/Keys/Get-JwtKeyThumbprint.ps1
new file mode 100644
index 0000000..9b405dc
--- /dev/null
+++ b/src/functions/public/Keys/Get-JwtKeyThumbprint.ps1
@@ -0,0 +1,86 @@
+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.
+ .LINK
+ https://psmodule.io/Jwt/Functions/Keys/Get-JwtKeyThumbprint/
+
+ .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/Keys/Keys.md b/src/functions/public/Keys/Keys.md
new file mode 100644
index 0000000..cb53e93
--- /dev/null
+++ b/src/functions/public/Keys/Keys.md
@@ -0,0 +1,9 @@
+# Key commands
+
+Public commands for JWK and JWKS workflows:
+
+- converting .NET keys to and from JWK
+- building and parsing JWKS documents
+- resolving keys by `kid`
+- computing RFC 7638 JWK thumbprints
+- generating signing keys with `New-JwtSigningKey`
diff --git a/src/functions/public/Keys/New-JwtSigningKey.ps1 b/src/functions/public/Keys/New-JwtSigningKey.ps1
new file mode 100644
index 0000000..6096f96
--- /dev/null
+++ b/src/functions/public/Keys/New-JwtSigningKey.ps1
@@ -0,0 +1,120 @@
+function New-JwtSigningKey {
+ <#
+ .SYNOPSIS
+ Generates a signing key compatible with a JWS algorithm.
+
+ .DESCRIPTION
+ Creates a new key for HS*, RS*/PS*, or ES* algorithms using .NET cryptography
+ primitives. Returns a .NET key by default:
+
+ - HS* -> byte[]
+ - RS*/PS* -> RSA
+ - ES* -> ECDsa
+
+ Use -AsJwk to return a [JwtKey] instead (with private parameters included)
+ so the generated key can be serialized or transported.
+
+ .EXAMPLE
+ $key = New-JwtSigningKey -Algorithm ES256
+ $jwt = New-Jwt -Payload @{ sub = 'app' } -Algorithm ES256 -Key $key
+
+ Generates an EC P-256 key and uses it directly for signing.
+
+ .EXAMPLE
+ $jwk = New-JwtSigningKey -Algorithm RS256 -AsJwk -KeyId 'rsa-1'
+
+ Generates a private RSA key and returns it as a [JwtKey].
+ .LINK
+ https://psmodule.io/Jwt/Functions/Keys/New-JwtSigningKey/
+
+ .INPUTS
+ None
+
+ .OUTPUTS
+ System.Byte[]
+ System.Security.Cryptography.RSA
+ System.Security.Cryptography.ECDsa
+ JwtKey
+
+ .NOTES
+ Use -AsJwk when generated private material must be serialized and stored.
+ #>
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
+ 'PSUseOutputTypeCorrectly', '',
+ Justification = 'Returns byte[]/RSA/ECDsa by algorithm family, or JwtKey with -AsJwk.'
+ )]
+ [OutputType([byte[]], [System.Security.Cryptography.RSA], [System.Security.Cryptography.ECDsa], [JwtKey])]
+ [CmdletBinding(DefaultParameterSetName = 'DotNetKey')]
+ param(
+ # The algorithm family to generate a key for.
+ [Parameter(Mandatory)]
+ [ValidateSet(
+ 'HS256', 'HS384', 'HS512',
+ 'RS256', 'RS384', 'RS512',
+ 'ES256', 'ES384', 'ES512',
+ 'PS256', 'PS384', 'PS512'
+ )]
+ [string] $Algorithm,
+
+ # RSA modulus size for RS*/PS* generation.
+ [Parameter()]
+ [ValidateSet(2048, 3072, 4096)]
+ [int] $RsaKeySize = 2048,
+
+ # Return a JwtKey (JWK) instead of a .NET key.
+ [Parameter(Mandatory, ParameterSetName = 'Jwk')]
+ [switch] $AsJwk,
+
+ # Optional key id to stamp on returned JWK.
+ [Parameter(ParameterSetName = 'Jwk')]
+ [string] $KeyId
+ )
+
+ process {
+ $key = $null
+ switch -Regex ($Algorithm) {
+ '^HS' {
+ $keyLength = switch ($Algorithm) {
+ 'HS256' { 32 }
+ 'HS384' { 48 }
+ 'HS512' { 64 }
+ default { throw [System.NotSupportedException]::new("Algorithm '$Algorithm' is not supported.") }
+ }
+ $bytes = [byte[]]::new($keyLength)
+ [System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
+ $key = $bytes
+ }
+ '^(RS|PS)' {
+ $rsa = [System.Security.Cryptography.RSA]::Create()
+ $rsa.KeySize = $RsaKeySize
+ $key = $rsa
+ }
+ '^ES' {
+ $curveOid = switch ($Algorithm) {
+ 'ES256' { '1.2.840.10045.3.1.7' }
+ 'ES384' { '1.3.132.0.34' }
+ 'ES512' { '1.3.132.0.35' }
+ default { throw [System.NotSupportedException]::new("Algorithm '$Algorithm' is not supported.") }
+ }
+ $key = [System.Security.Cryptography.ECDsa]::Create(
+ [System.Security.Cryptography.ECCurve]::CreateFromValue($curveOid)
+ )
+ }
+ default {
+ throw [System.NotSupportedException]::new("Algorithm '$Algorithm' is not supported.")
+ }
+ }
+
+ if ($AsJwk) {
+ try {
+ return ConvertTo-JwtKey -Key $key -IncludePrivateParameters -Algorithm $Algorithm -KeyId $KeyId
+ } finally {
+ if ($key -is [System.IDisposable]) {
+ $key.Dispose()
+ }
+ }
+ }
+
+ return $key
+ }
+}
diff --git a/src/functions/public/New-Jwt.ps1 b/src/functions/public/New-Jwt.ps1
deleted file mode 100644
index 8c9f1b0..0000000
--- a/src/functions/public/New-Jwt.ps1
+++ /dev/null
@@ -1,162 +0,0 @@
-function New-Jwt {
- <#
- .SYNOPSIS
- Creates a JSON Web Token.
-
- .DESCRIPTION
- Creates a JWT from JSON header and payload strings. Supports RS256 with a signing certificate, HS256 with a
- shared secret, and the none algorithm.
-
- .EXAMPLE
- ```powershell
- $payload = '{"sub":"1234567890","name":"John Doe","admin":true,"iat":1516239022}'
- $secret = 'a-string-secret-at-least-256-bits-long'
-
- New-Jwt -Header '{"alg":"HS256","typ":"JWT"}' -PayloadJson $payload -Secret $secret
- ```
-
- Creates an HS256-signed JWT.
-
- .EXAMPLE
- ```powershell
- $cert = (Get-ChildItem Cert:\CurrentUser\My)[1]
- $jwt = New-Jwt -Cert $cert -PayloadJson '{"token1":"value1","token2":"value2"}'
- $jwt.Split('.').Count
- ```
-
- Creates an RS256-signed JWT with a certificate private key and returns the number of JWT segments.
-
- .INPUTS
- System.String
-
- .OUTPUTS
- System.String
-
- .NOTES
- RS256 requires a certificate with a private key. HS256 requires a string or byte array secret.
-
- .LINK
- https://psmodule.io/Jwt/Functions/New-Jwt/
-
- .LINK
- https://jwt.io/
- #>
- [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
- 'PSUseShouldProcessForStateChangingFunctions', '',
- Justification = 'New-Jwt creates an in-memory token and does not change system state.'
- )]
- [OutputType([string])]
- [CmdletBinding()]
- param(
- # The JWT header JSON.
- [Parameter()]
- [ValidateNotNullOrEmpty()]
- [string] $Header = '{"alg":"RS256","typ":"JWT"}',
-
- # The JWT payload JSON.
- [Parameter(Mandatory, ValueFromPipeline, Position = 0)]
- [ValidateNotNullOrEmpty()]
- [string] $PayloadJson,
-
- # The signing certificate to use for RS256 tokens.
- [Parameter()]
- [ValidateNotNull()]
- [System.Security.Cryptography.X509Certificates.X509Certificate2] $Cert,
-
- # The string or byte array secret to use for HS256 tokens.
- [Parameter()]
- [ValidateNotNull()]
- [object] $Secret
- )
-
- begin {}
-
- process {
- Write-Verbose "Payload to sign length: $($PayloadJson.Length) characters"
-
- try {
- $algorithm = (ConvertFrom-Json -InputObject $Header -ErrorAction Stop).alg
- } catch {
- $message = "The supplied JWT header is not valid JSON. Header length: $($Header.Length) characters."
- throw [System.FormatException]::new($message)
- }
- if ([string]::IsNullOrEmpty($algorithm)) {
- throw [System.FormatException]::new('The JWT header is missing the required "alg" claim.')
- }
- Write-Verbose "Algorithm: $algorithm"
-
- try {
- $null = ConvertFrom-Json -InputObject $PayloadJson -ErrorAction Stop
- } catch {
- $message = "The supplied JWT payload is not valid JSON. Payload length: $($PayloadJson.Length) characters."
- throw [System.FormatException]::new($message)
- }
-
- $encodedHeader = ConvertTo-Base64UrlString $Header
- $encodedPayload = ConvertTo-Base64UrlString $PayloadJson
- $jwtContent = $encodedHeader + '.' + $encodedPayload
- $contentBytes = [System.Text.Encoding]::UTF8.GetBytes($jwtContent)
-
- switch ($algorithm) {
- 'RS256' {
- if (-not $PSBoundParameters.ContainsKey('Cert')) {
- $message = 'RS256 requires a -Cert parameter of type X509Certificate2.'
- throw [System.ArgumentException]::new($message, 'Cert')
- }
- Write-Verbose "Signing certificate: $($Cert.Subject)"
- $rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Cert)
- if ($null -eq $rsa) {
- $message = 'The supplied certificate has no RSA private key and cannot be used to sign.'
- throw [System.ArgumentException]::new($message, 'Cert')
- } else {
- try {
- $signature = $rsa.SignData(
- $contentBytes,
- [Security.Cryptography.HashAlgorithmName]::SHA256,
- [Security.Cryptography.RSASignaturePadding]::Pkcs1
- )
- $encodedSignature = ConvertTo-Base64UrlString $signature
- } catch {
- $message = "Signing with SHA256 and Pkcs1 padding failed using the certificate private key: $_"
- throw [System.Exception]::new($message, $_.Exception)
- } finally {
- $rsa.Dispose()
- }
- }
- }
- 'HS256' {
- if (-not ($PSBoundParameters.ContainsKey('Secret'))) {
- throw [System.ArgumentException]::new('HS256 requires a -Secret parameter.', 'Secret')
- }
- if ($Secret -isnot [byte[]] -and $Secret -isnot [string]) {
- $message = "Expected Secret parameter as byte array or string, instead got $($Secret.GetType())"
- throw [System.ArgumentException]::new($message, 'Secret')
- }
- $hmacsha256 = [System.Security.Cryptography.HMACSHA256]::new()
- try {
- $hmacsha256.Key = if ($Secret -is [byte[]]) {
- $Secret
- } else {
- [System.Text.Encoding]::UTF8.GetBytes($Secret)
- }
- $encodedSignature = ConvertTo-Base64UrlString $hmacsha256.ComputeHash($contentBytes)
- } catch {
- throw [System.Exception]::new("Signing with HMACSHA256 failed: $_", $_.Exception)
- } finally {
- $hmacsha256.Dispose()
- }
- }
- 'none' {
- $encodedSignature = $null
- }
- default {
- $message = 'The algorithm is not one of the supported: "RS256", "HS256", "none".'
- throw [System.NotSupportedException]::new($message)
- }
- }
-
- $jwtContent + '.' + $encodedSignature
- }
-
- end {}
-}
diff --git a/src/functions/public/Test-Jwt.ps1 b/src/functions/public/Test-Jwt.ps1
deleted file mode 100644
index a83726b..0000000
--- a/src/functions/public/Test-Jwt.ps1
+++ /dev/null
@@ -1,169 +0,0 @@
-function Test-Jwt {
- <#
- .SYNOPSIS
- Tests the cryptographic integrity of a JWT.
-
- .DESCRIPTION
- Verifies a JWT signature using the signing certificate for RS256 or a shared secret for HS256. Tokens using the
- none algorithm are valid only when the signature segment is empty.
-
- .EXAMPLE
- ```powershell
- $jwt | Test-Jwt -Secret 'a-string-secret-at-least-256-bits-long'
- ```
-
- Tests an HS256 JWT with a shared secret.
-
- .EXAMPLE
- ```powershell
- $jwt | Test-Jwt -Cert $cert
- ```
-
- Tests an RS256 JWT with a public certificate.
-
- .INPUTS
- System.String
-
- .OUTPUTS
- System.Boolean
-
- .NOTES
- The Verify-JwtSignature alias is preserved for compatibility with the original module command surface.
-
- .LINK
- https://psmodule.io/Jwt/Functions/Test-Jwt/
-
- .LINK
- https://jwt.io/
- #>
- [OutputType([bool])]
- [Alias('Verify-JwtSignature')]
- [CmdletBinding()]
- param(
- # The JWT to test.
- [Parameter(Mandatory, ValueFromPipeline, Position = 0)]
- [ValidateNotNullOrEmpty()]
- [string] $Jwt,
-
- # The certificate to use for RS256 signature verification.
- [Parameter()]
- [ValidateNotNull()]
- [System.Security.Cryptography.X509Certificates.X509Certificate2] $Cert,
-
- # The string or byte array secret to use for HS256 signature verification.
- [Parameter()]
- [ValidateNotNull()]
- [object] $Secret
- )
-
- begin {}
-
- process {
- Write-Verbose "Verifying JWT with length $($Jwt.Length) characters"
-
- $parts = $Jwt.Split('.')
- if ($parts.Count -ne 3) {
- throw [System.ArgumentException]::new('JWT must have exactly 3 segments.')
- }
- if (-not $parts[0]) {
- throw [System.ArgumentException]::new('JWT header segment is missing.')
- }
- if (-not $parts[1]) {
- throw [System.ArgumentException]::new('JWT payload segment is missing.')
- }
- $header = ConvertFrom-Base64UrlString $parts[0]
- try {
- $algorithm = (ConvertFrom-Json -InputObject $header -ErrorAction Stop).alg
- } catch {
- $message = "The supplied JWT header segment is not valid JSON. Header length: $($header.Length) characters."
- throw [System.FormatException]::new($message)
- }
- if ([string]::IsNullOrEmpty($algorithm)) {
- throw [System.FormatException]::new('The JWT header is missing the required "alg" claim.')
- }
- Write-Verbose "Algorithm: $algorithm"
-
- switch ($algorithm) {
- 'RS256' {
- if (-not $PSBoundParameters.ContainsKey('Cert')) {
- $message = 'RS256 requires a -Cert parameter of type X509Certificate2.'
- throw [System.ArgumentException]::new($message, 'Cert')
- }
- if ([string]::IsNullOrEmpty($parts[2])) {
- return $false
- }
- try {
- $bytes = ConvertFrom-Base64UrlString $parts[2] -AsByteArray
- } catch [System.FormatException] {
- return $false
- }
- Write-Verbose "Using certificate with subject: $($Cert.Subject)"
- $signedContent = [System.Text.Encoding]::UTF8.GetBytes($parts[0] + '.' + $parts[1])
- $computed = [System.Security.Cryptography.SHA256]::HashData($signedContent)
- $rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPublicKey($Cert)
- if ($null -eq $rsa) {
- $message = 'The supplied certificate has no RSA public key and cannot be used to verify.'
- throw [System.ArgumentException]::new($message, 'Cert')
- }
- try {
- $rsa.VerifyHash(
- $computed,
- $bytes,
- [Security.Cryptography.HashAlgorithmName]::SHA256,
- [Security.Cryptography.RSASignaturePadding]::Pkcs1
- )
- } finally {
- $rsa.Dispose()
- }
- }
- 'HS256' {
- if (-not ($PSBoundParameters.ContainsKey('Secret'))) {
- throw [System.ArgumentException]::new('HS256 requires a -Secret parameter.', 'Secret')
- }
- if ($Secret -isnot [byte[]] -and $Secret -isnot [string]) {
- $message = "Expected Secret parameter as byte array or string, instead got $($Secret.GetType())"
- throw [System.ArgumentException]::new($message, 'Secret')
- }
- $hmacsha256 = [System.Security.Cryptography.HMACSHA256]::new()
- try {
- $hmacsha256.Key = if ($Secret -is [byte[]]) {
- $Secret
- } else {
- [System.Text.Encoding]::UTF8.GetBytes($Secret)
- }
- $signedContent = [System.Text.Encoding]::UTF8.GetBytes($parts[0] + '.' + $parts[1])
- $signature = $hmacsha256.ComputeHash($signedContent)
- if (-not $parts[2]) {
- $false
- } else {
- try {
- $providedSignature = ConvertFrom-Base64UrlString $parts[2] -AsByteArray
- } catch [System.FormatException] {
- $providedSignature = $null
- }
- if ($null -eq $providedSignature -or $signature.Length -ne $providedSignature.Length) {
- $false
- } else {
- $difference = 0
- for ($index = 0; $index -lt $signature.Length; $index++) {
- $difference = $difference -bor ($signature[$index] -bxor $providedSignature[$index])
- }
- $difference -eq 0
- }
- }
- } finally {
- $hmacsha256.Dispose()
- }
- }
- 'none' {
- $parts[2] -eq ''
- }
- default {
- $message = 'The algorithm is not one of the supported: "RS256", "HS256", "none".'
- throw [System.NotSupportedException]::new($message)
- }
- }
- }
-
- end {}
-}
diff --git a/src/functions/public/Token/ConvertFrom-Jwt.ps1 b/src/functions/public/Token/ConvertFrom-Jwt.ps1
new file mode 100644
index 0000000..347d088
--- /dev/null
+++ b/src/functions/public/Token/ConvertFrom-Jwt.ps1
@@ -0,0 +1,80 @@
+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.
+ .LINK
+ https://psmodule.io/Jwt/Functions/Token/ConvertFrom-Jwt/
+
+ .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('.', 3)
+ $hasExtraSegments = $parts.Count -eq 3 -and $parts[2].Contains('.')
+ if ($parts.Count -ne 3 -or $hasExtraSegments) {
+ $segmentCount = [System.Text.RegularExpressions.Regex]::Matches($tokenString, '\.').Count + 1
+ throw [System.FormatException]::new(
+ "JWT must have exactly 3 segments separated by '.'. Got $segmentCount."
+ )
+ }
+ 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/Token/Get-JwtClaim.ps1 b/src/functions/public/Token/Get-JwtClaim.ps1
new file mode 100644
index 0000000..e7baf6f
--- /dev/null
+++ b/src/functions/public/Token/Get-JwtClaim.ps1
@@ -0,0 +1,97 @@
+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 }.
+ .LINK
+ https://psmodule.io/Jwt/Functions/Token/Get-JwtClaim/
+
+ .OUTPUTS
+ Object
+ #>
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
+ 'PSUseOutputTypeCorrectly', '',
+ Justification = 'Returns OrderedDictionary for multi-name queries; single-name returns 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/Token/Get-JwtHeader.ps1 b/src/functions/public/Token/Get-JwtHeader.ps1
new file mode 100644
index 0000000..fb94416
--- /dev/null
+++ b/src/functions/public/Token/Get-JwtHeader.ps1
@@ -0,0 +1,34 @@
+function Get-JwtHeader {
+ <#
+ .SYNOPSIS
+ Returns the parsed header of a JWT.
+
+ .DESCRIPTION
+ Parses the supplied compact JWT string (or [Jwt] object) and returns the
+ [JwtHeader]. No signature verification is performed.
+
+ .EXAMPLE
+ Get-JwtHeader -Token $jwt
+
+ Returns the typed header.
+ .LINK
+ https://psmodule.io/Jwt/Functions/Token/Get-JwtHeader/
+
+ .OUTPUTS
+ JwtHeader
+ #>
+ [OutputType([JwtHeader])]
+ [CmdletBinding()]
+ param(
+ # The JWT string or [Jwt] object.
+ [Parameter(Mandatory, Position = 0, ValueFromPipeline)]
+ [ValidateNotNull()]
+ [object] $Token
+ )
+
+ process {
+ $parsed = ConvertFrom-Jwt -Token $Token
+ return $parsed.Header
+ }
+}
+
diff --git a/src/functions/public/Token/Get-JwtPayload.ps1 b/src/functions/public/Token/Get-JwtPayload.ps1
new file mode 100644
index 0000000..8a054c6
--- /dev/null
+++ b/src/functions/public/Token/Get-JwtPayload.ps1
@@ -0,0 +1,34 @@
+function Get-JwtPayload {
+ <#
+ .SYNOPSIS
+ Returns the parsed payload of a JWT.
+
+ .DESCRIPTION
+ Parses the supplied compact JWT string (or [Jwt] object) and returns the
+ [JwtPayload]. No signature verification is performed.
+
+ .EXAMPLE
+ Get-JwtPayload -Token $jwt
+
+ Returns the typed payload.
+ .LINK
+ https://psmodule.io/Jwt/Functions/Token/Get-JwtPayload/
+
+ .OUTPUTS
+ JwtPayload
+ #>
+ [OutputType([JwtPayload])]
+ [CmdletBinding()]
+ param(
+ # The JWT string or [Jwt] object.
+ [Parameter(Mandatory, Position = 0, ValueFromPipeline)]
+ [ValidateNotNull()]
+ [object] $Token
+ )
+
+ process {
+ $parsed = ConvertFrom-Jwt -Token $Token
+ return $parsed.Payload
+ }
+}
+
diff --git a/src/functions/public/Token/New-Jwt.ps1 b/src/functions/public/Token/New-Jwt.ps1
new file mode 100644
index 0000000..fb68c21
--- /dev/null
+++ b/src/functions/public/Token/New-Jwt.ps1
@@ -0,0 +1,170 @@
+function New-Jwt {
+ <#
+ .SYNOPSIS
+ Creates a JSON Web Token.
+
+ .DESCRIPTION
+ Builds a [Jwt] from a header overrides hashtable and a claims payload.
+ The default signed mode takes -Key. The generated-key mode (-GenerateKey)
+ creates a compatible signing key internally using New-JwtSigningKey and
+ keeps New-Jwt output shape stable as [Jwt].
+ 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.
+
+ 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.
+
+ .EXAMPLE
+ $jwt = New-Jwt -Payload @{ sub = 'user@example.com'; exp = 1900000000 } -Key $secret -Algorithm HS256
+
+ Creates an HS256-signed JWT.
+
+ .EXAMPLE
+ $jwt = New-Jwt -Payload @{ sub = 'app' } -Algorithm RS256 -Unsigned
+ $jwt.SigningInput() | Send-ToKeyVault | ForEach-Object { $jwt.Signature = $_ }
+
+ Creates an unsigned token, signs the SigningInput externally, and attaches the result.
+
+ .EXAMPLE
+ $jwt = New-Jwt -Payload @{ sub = 'app' } -Algorithm ES256 -GenerateKey
+
+ Generates a key internally and returns a signed [Jwt].
+ .LINK
+ https://psmodule.io/Jwt/Functions/Token/New-Jwt/
+
+ .INPUTS
+ System.Collections.IDictionary
+
+ .OUTPUTS
+ Jwt
+
+ .NOTES
+ Use New-JwtSigningKey if the caller needs to retain or export generated key material.
+ #>
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
+ 'PSUseShouldProcessForStateChangingFunctions', '',
+ Justification = 'New-Jwt builds an in-memory token and does not change system state.'
+ )]
+ [OutputType([Jwt])]
+ [CmdletBinding(DefaultParameterSetName = 'SignedWithKey')]
+ param(
+ # Optional header overrides. alg and typ are set automatically.
+ [Parameter()]
+ [System.Collections.IDictionary] $Header,
+
+ # 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 key. Format depends on -Algorithm.
+ [Parameter(Mandatory, ParameterSetName = 'SignedWithKey')]
+ [object] $Key,
+
+ # Generate a compatible key internally and sign with it.
+ [Parameter(Mandatory, ParameterSetName = 'GeneratedSigned')]
+ [switch] $GenerateKey,
+
+ # RSA key size when generating RS*/PS* keys.
+ [Parameter(ParameterSetName = 'GeneratedSigned')]
+ [ValidateSet(2048, 3072, 4096)]
+ [int] $RsaKeySize = 2048,
+
+ # Optional key id used as default header.kid in generated-key mode.
+ [Parameter(ParameterSetName = 'GeneratedSigned')]
+ [string] $GeneratedKeyId,
+
+ # Produce an unsigned token. The signature must be attached externally via $jwt.Signature.
+ [Parameter(Mandatory, ParameterSetName = 'Unsigned')]
+ [switch] $Unsigned,
+
+ # The signing algorithm.
+ [Parameter()]
+ [ValidateSet(
+ 'HS256', 'HS384', 'HS512',
+ 'RS256', 'RS384', 'RS512',
+ 'ES256', 'ES384', 'ES512',
+ 'PS256', 'PS384', 'PS512'
+ )]
+ [string] $Algorithm = 'RS256'
+ )
+
+ process {
+ $headerValues = [ordered]@{}
+ if ($Header) { foreach ($k in $Header.Keys) { $headerValues[$k] = $Header[$k] } }
+ $headerValues['alg'] = $Algorithm
+ if (-not $headerValues.Contains('typ')) { $headerValues['typ'] = 'JWT' }
+ if ($PSCmdlet.ParameterSetName -eq 'GeneratedSigned' -and $GeneratedKeyId -and -not $headerValues.Contains('kid')) {
+ $headerValues['kid'] = $GeneratedKeyId
+ }
+
+ $jwtHeader = [JwtHeader]::new($headerValues)
+ $jwtPayload = [JwtPayload]::new($Payload)
+ $token = [Jwt]::new($jwtHeader, $jwtPayload)
+
+ if ($Unsigned) {
+ $token.Signature = ''
+ return $token
+ }
+
+ $effectiveKey = $Key
+ $generatedKey = $null
+ if ($GenerateKey.IsPresent) {
+ $generatedKey = New-JwtSigningKey -Algorithm $Algorithm -RsaKeySize $RsaKeySize
+ $effectiveKey = $generatedKey
+ }
+
+ $resolved = Resolve-JwtKey -Algorithm $Algorithm -Key $effectiveKey
+ $contentBytes = [System.Text.Encoding]::UTF8.GetBytes($token.SigningInput())
+ $hash = Get-JwtAlgorithmHash -Algorithm $Algorithm
+ try {
+ switch -Regex ($Algorithm) {
+ '^RS' {
+ $rsa = [System.Security.Cryptography.RSA] $resolved
+ $sigBytes = $rsa.SignData(
+ $contentBytes,
+ $hash,
+ [System.Security.Cryptography.RSASignaturePadding]::Pkcs1
+ )
+ }
+ '^PS' {
+ $rsa = [System.Security.Cryptography.RSA] $resolved
+ $sigBytes = $rsa.SignData(
+ $contentBytes,
+ $hash,
+ [System.Security.Cryptography.RSASignaturePadding]::Pss
+ )
+ }
+ '^HS' {
+ $hmac = [System.Security.Cryptography.HMAC] $resolved
+ $sigBytes = $hmac.ComputeHash($contentBytes)
+ }
+ '^ES' {
+ $ecdsa = [System.Security.Cryptography.ECDsa] $resolved
+ $sigBytes = $ecdsa.SignData($contentBytes, $hash)
+ }
+ }
+ $token.Signature = [JwtBase64Url]::Encode($sigBytes)
+ } finally {
+ $shouldDispose = $false
+ if ($resolved -is [System.IDisposable]) {
+ if ($generatedKey -is [System.IDisposable]) {
+ $shouldDispose = $true
+ } else {
+ $shouldDispose = (
+ $effectiveKey -isnot [System.Security.Cryptography.RSA] -and
+ $effectiveKey -isnot [System.Security.Cryptography.ECDsa]
+ )
+ }
+ }
+ if ($shouldDispose) {
+ $resolved.Dispose()
+ }
+ }
+
+ return $token
+ }
+}
diff --git a/src/functions/public/Token/Test-Jwt.ps1 b/src/functions/public/Token/Test-Jwt.ps1
new file mode 100644
index 0000000..3714120
--- /dev/null
+++ b/src/functions/public/Token/Test-Jwt.ps1
@@ -0,0 +1,256 @@
+function Test-Jwt {
+ <#
+ .SYNOPSIS
+ Verifies the signature and claims of a JWT.
+
+ .DESCRIPTION
+ Performs the full JWT validation pipeline:
+
+ 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, iat, 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.
+
+ Parameter sets steer validation mode:
+ - SignedValidation: requires -Key and validates signed algorithms.
+ - UnsignedValidation: requires -AllowUnsigned and only permits alg=none.
+
+ 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
+ $jwt | Test-Jwt -Key $secret
+
+ Verifies an HS256 token.
+
+ .EXAMPLE
+ Test-Jwt -Token $jwt -Key $rsa -Issuer 'https://issuer' -Audience 'api' -Detailed
+
+ Returns a structured validation report.
+
+ .EXAMPLE
+ Test-Jwt -Token $jwt -Key $secret -AllowedCriticalHeader 'kid'
+
+ Validates a token that declares a supported critical JOSE header parameter.
+ .LINK
+ https://psmodule.io/Jwt/Functions/Token/Test-Jwt/
+
+ .INPUTS
+ System.Object
+
+ .OUTPUTS
+ System.Boolean
+ System.Management.Automation.PSCustomObject
+
+ .NOTES
+ Tokens declaring a JOSE 'crit' header are rejected unless each declared critical
+ parameter is explicitly allowed via -AllowedCriticalHeader.
+ #>
+ [OutputType([bool], [pscustomobject])]
+ [CmdletBinding(DefaultParameterSetName = 'SignedValidation')]
+ param(
+ # The JWT to validate.
+ [Parameter(Mandatory, Position = 0, ValueFromPipeline, ParameterSetName = 'SignedValidation')]
+ [Parameter(Mandatory, Position = 0, ValueFromPipeline, ParameterSetName = 'UnsignedValidation')]
+ [ValidateNotNull()]
+ [object] $Token,
+
+ # The verification key. Format depends on the token's alg.
+ [Parameter(Mandatory, ParameterSetName = 'SignedValidation')]
+ [object] $Key,
+
+ # Expected issuer.
+ [Parameter(ParameterSetName = 'SignedValidation')]
+ [Parameter(ParameterSetName = 'UnsignedValidation')]
+ [string] $Issuer,
+
+ # Accepted audiences (any-match).
+ [Parameter(ParameterSetName = 'SignedValidation')]
+ [Parameter(ParameterSetName = 'UnsignedValidation')]
+ [string[]] $Audience,
+
+ # Allowed clock skew for exp / nbf / iat checks.
+ [Parameter(ParameterSetName = 'SignedValidation')]
+ [Parameter(ParameterSetName = 'UnsignedValidation')]
+ [timespan] $ClockSkew = [timespan]::Zero,
+
+ # Require an exp claim. Defaults to $true.
+ [Parameter(ParameterSetName = 'SignedValidation')]
+ [Parameter(ParameterSetName = 'UnsignedValidation')]
+ [bool] $RequireExpiration = $true,
+
+ # Allow alg=none unsigned tokens.
+ [Parameter(Mandatory, ParameterSetName = 'UnsignedValidation')]
+ [switch] $AllowUnsigned,
+
+ # Return a structured report instead of [bool].
+ [Parameter(ParameterSetName = 'SignedValidation')]
+ [Parameter(ParameterSetName = 'UnsignedValidation')]
+ [switch] $Detailed,
+
+ # Allow-list for JOSE critical header parameters declared in the token's 'crit' array.
+ [Parameter(ParameterSetName = 'SignedValidation')]
+ [Parameter(ParameterSetName = 'UnsignedValidation')]
+ [string[]] $AllowedCriticalHeader
+ )
+
+ process {
+ $parsed = ConvertFrom-Jwt -Token $Token
+ $alg = $parsed.Header.alg
+
+ $algCheck = @{ Name = 'Algorithm'; Passed = $true; Reason = $null }
+ $criticalCheck = @{ Name = 'CriticalHeaders'; 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)
+ }
+
+ $supportedAlgs = @(
+ 'HS256', 'HS384', 'HS512',
+ 'RS256', 'RS384', 'RS512',
+ 'ES256', 'ES384', 'ES512',
+ 'PS256', 'PS384', 'PS512'
+ )
+
+ if ($PSCmdlet.ParameterSetName -eq 'UnsignedValidation') {
+ if ($alg -ne 'none') {
+ $algCheck.Passed = $false
+ $algCheck.Reason = "Parameter set 'UnsignedValidation' only supports tokens with alg='none'."
+ throw [System.Security.Authentication.AuthenticationException]::new($algCheck.Reason)
+ }
+
+ $sigCheck.Passed = $true
+ $sigCheck.Reason = 'Skipped (unsigned token)'
+ $signatureValidated = $false
+ } else {
+ if ($alg -eq 'none') {
+ $algCheck.Passed = $false
+ $algCheck.Reason = "Algorithm 'none' rejected. Use -AllowUnsigned for unsigned tokens."
+ throw [System.Security.Authentication.AuthenticationException]::new($algCheck.Reason)
+ }
+
+ if ($alg -in $supportedAlgs) {
+ $resolved = Resolve-JwtKey -Algorithm $alg -Key $Key
+ try {
+ $sigOk = Test-JwtSignature `
+ -SigningInput $parsed.SigningInput() `
+ -Signature $parsed.Signature `
+ -Algorithm $alg `
+ -ResolvedKey $resolved
+ } finally {
+ $shouldDispose = (
+ $resolved -is [System.IDisposable] -and
+ $Key -isnot [System.Security.Cryptography.RSA] -and
+ $Key -isnot [System.Security.Cryptography.ECDsa]
+ )
+ if ($shouldDispose) {
+ $resolved.Dispose()
+ }
+ }
+
+ $headerValues = $parsed.Header.ToOrderedDictionary()
+ if ($headerValues.Contains('crit')) {
+ $critRaw = $headerValues['crit']
+ if ($critRaw -is [string]) {
+ $critHeaders = @($critRaw)
+ } elseif ($critRaw -is [System.Collections.IEnumerable]) {
+ $critHeaders = @($critRaw)
+ } else {
+ $criticalCheck.Passed = $false
+ $criticalCheck.Reason = "JWT header 'crit' must be an array of strings. Got [$($critRaw.GetType().FullName)]."
+ throw [System.Security.Authentication.AuthenticationException]::new($criticalCheck.Reason)
+ }
+
+ $headerNameSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
+ foreach ($name in $headerValues.Keys) {
+ [void]$headerNameSet.Add([string]$name)
+ }
+
+ $allowedSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
+ if ($PSBoundParameters.ContainsKey('AllowedCriticalHeader')) {
+ foreach ($name in $AllowedCriticalHeader) {
+ if (-not [string]::IsNullOrWhiteSpace($name)) {
+ [void]$allowedSet.Add($name)
+ }
+ }
+ }
+
+ $invalidNames = [System.Collections.Generic.List[string]]::new()
+ $missingHeaders = [System.Collections.Generic.List[string]]::new()
+ $unsupportedHeaders = [System.Collections.Generic.List[string]]::new()
+ foreach ($critName in $critHeaders) {
+ if ($critName -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$critName)) {
+ $invalidNames.Add([string]$critName)
+ continue
+ }
+
+ $name = [string]$critName
+ if (-not $headerNameSet.Contains($name)) {
+ $missingHeaders.Add($name)
+ }
+ if (-not $allowedSet.Contains($name)) {
+ $unsupportedHeaders.Add($name)
+ }
+ }
+
+ if ($invalidNames.Count -gt 0) {
+ $criticalCheck.Passed = $false
+ $criticalCheck.Reason = "JWT header 'crit' contains non-string or empty values."
+ throw [System.Security.Authentication.AuthenticationException]::new($criticalCheck.Reason)
+ }
+ if ($missingHeaders.Count -gt 0) {
+ $missing = $missingHeaders -join ', '
+ $criticalCheck.Passed = $false
+ $criticalCheck.Reason = "JWT header 'crit' references parameters not present in header: $missing."
+ throw [System.Security.Authentication.AuthenticationException]::new($criticalCheck.Reason)
+ }
+ if ($unsupportedHeaders.Count -gt 0) {
+ $unsupported = $unsupportedHeaders -join ', '
+ $criticalCheck.Passed = $false
+ $criticalCheck.Reason = "Unsupported critical header parameters: $unsupported. Supply -AllowedCriticalHeader to explicitly permit them."
+ throw [System.Security.Authentication.AuthenticationException]::new($criticalCheck.Reason)
+ }
+ }
+
+ if ($sigOk) {
+ $sigCheck.Passed = $true
+ $signatureValidated = $true
+ } else {
+ $sigCheck.Reason = 'Signature verification failed.'
+ }
+ } else {
+ $algCheck.Passed = $false
+ $allowed = ($supportedAlgs + 'none') -join ', '
+ $algCheck.Reason = "Algorithm '$alg' is not supported. Allowed: $allowed."
+ 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, $criticalCheck, $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
+ }
+}
diff --git a/src/functions/public/Token/Token.md b/src/functions/public/Token/Token.md
new file mode 100644
index 0000000..86de81e
--- /dev/null
+++ b/src/functions/public/Token/Token.md
@@ -0,0 +1,13 @@
+# Token commands
+
+Public commands for JWT lifecycle operations:
+
+- creating signed and unsigned tokens
+- parsing compact JWT strings into typed objects
+- inspecting token header, payload, and claims
+- validating signature and registered claims
+
+Validation path is parameter-set driven:
+
+- `Test-Jwt -Token -Key ` for signed algorithms
+- `Test-Jwt -Token -AllowUnsigned` for `alg = none`
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/src/types/Jwt.Types.ps1xml b/src/types/Jwt.Types.ps1xml
new file mode 100644
index 0000000..edffb3d
--- /dev/null
+++ b/src/types/Jwt.Types.ps1xml
@@ -0,0 +1,174 @@
+
+
+
+ Jwt
+
+
+ Algorithm
+ $this.Header.alg
+
+
+ KeyId
+ $this.Header.kid
+
+
+ Issuer
+ $this.Payload.iss
+
+
+ Subject
+ $this.Payload.sub
+
+
+ Audience
+ if ($this.Payload.aud -is [array]) { return ($this.Payload.aud -join ',') } ; return $this.Payload.aud
+
+
+ ExpiresAtUtc
+ if ($null -eq $this.Payload.exp) { return $null } ; return [DateTimeOffset]::FromUnixTimeSeconds([long]$this.Payload.exp).UtcDateTime.ToString('o')
+
+
+ HasSignature
+ -not [string]::IsNullOrEmpty($this.Signature)
+
+
+ PSStandardMembers
+
+
+ DefaultDisplayPropertySet
+
+ Algorithm
+ KeyId
+ Issuer
+ Subject
+ Audience
+ ExpiresAtUtc
+ HasSignature
+
+
+
+
+
+
+
+ JwtHeader
+
+
+ AdditionalFieldCount
+ if ($null -eq $this.AdditionalFields) { return 0 } ; return $this.AdditionalFields.Count
+
+
+ PSStandardMembers
+
+
+ DefaultDisplayPropertySet
+
+ alg
+ typ
+ kid
+ AdditionalFieldCount
+
+
+
+
+
+
+
+ JwtPayload
+
+
+ Audience
+ if ($this.aud -is [array]) { return ($this.aud -join ',') } ; return $this.aud
+
+
+ ExpiresAtUtc
+ if ($null -eq $this.exp) { return $null } ; return [DateTimeOffset]::FromUnixTimeSeconds([long]$this.exp).UtcDateTime.ToString('o')
+
+
+ NotBeforeUtc
+ if ($null -eq $this.nbf) { return $null } ; return [DateTimeOffset]::FromUnixTimeSeconds([long]$this.nbf).UtcDateTime.ToString('o')
+
+
+ IssuedAtUtc
+ if ($null -eq $this.iat) { return $null } ; return [DateTimeOffset]::FromUnixTimeSeconds([long]$this.iat).UtcDateTime.ToString('o')
+
+
+ AdditionalFieldCount
+ if ($null -eq $this.AdditionalFields) { return 0 } ; return $this.AdditionalFields.Count
+
+
+ PSStandardMembers
+
+
+ DefaultDisplayPropertySet
+
+ iss
+ sub
+ Audience
+ ExpiresAtUtc
+ NotBeforeUtc
+ IssuedAtUtc
+ jti
+ AdditionalFieldCount
+
+
+
+
+
+
+
+ JwtKey
+
+
+ Operations
+ if ($null -eq $this.key_ops) { return $null } ; return ($this.key_ops -join ',')
+
+
+ HasPrivateMaterial
+ $null -ne $this.d -or $null -ne $this.p -or $null -ne $this.q -or $null -ne $this.dp -or $null -ne $this.dq -or $null -ne $this.qi -or $null -ne $this.oth -or $null -ne $this.k
+
+
+ PSStandardMembers
+
+
+ DefaultDisplayPropertySet
+
+ kid
+ kty
+ alg
+ use
+ crv
+ Operations
+ HasPrivateMaterial
+
+
+
+
+
+
+
+ JwtKeySet
+
+
+ KeyCount
+ if ($null -eq $this.keys) { return 0 } ; return $this.keys.Count
+
+
+ KeyIds
+ if ($null -eq $this.keys) { return $null } ; return (($this.keys | ForEach-Object { $_.kid } | Where-Object { -not [string]::IsNullOrEmpty($_) }) -join ',')
+
+
+ PSStandardMembers
+
+
+ DefaultDisplayPropertySet
+
+ KeyCount
+ KeyIds
+
+
+
+
+
+
+
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/Integration.Jwt.Tests.ps1 b/tests/Integration.Jwt.Tests.ps1
new file mode 100644
index 0000000..690d321
--- /dev/null
+++ b/tests/Integration.Jwt.Tests.ps1
@@ -0,0 +1,675 @@
+#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' }
+[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
+ 'PSUseDeclaredVarsMoreThanAssignments', '',
+ Justification = 'Required for Pester tests'
+)]
+[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
+ 'PSAvoidUsingConvertToSecureStringWithPlainText', '',
+ Justification = 'Test uses plaintext secrets intentionally for deterministic testing.'
+)]
+[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
+ 'PSAvoidLongLines', '',
+ Justification = 'Test data (RFC reference vectors) cannot be broken across lines.'
+)]
+[CmdletBinding()]
+param()
+
+# 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.
+
+$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
+ }
+ }
+
+ 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 '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 '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 '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 '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 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'
+ }
+
+ 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' } }
+ )
+ }
+ $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'
+ }
+ }
+
+ Context 'Parsing - malformed inputs' {
+ It 'rejects a token with too few segments' {
+ { ConvertFrom-Jwt -Token 'a.b' } | Should -Throw '*3 segments*'
+ }
+
+ It 'rejects a token with too many segments' {
+ { ConvertFrom-Jwt -Token 'a.b.c.d' } | Should -Throw '*3 segments*'
+ }
+
+ 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 '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
+ }
+
+ It 'returns true for a valid HS256 token' {
+ Test-Jwt -Token $script:goodJwt -Key $script:secret -RequireExpiration $false | Should -BeTrue
+ }
+
+ 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 '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
+ }
+ }
+
+ Context 'Validation - claim outcomes' {
+ BeforeAll {
+ $script:secret = 'a-string-secret-at-least-256-bits-long'
+ $script:nowSec = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
+ }
+
+ 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 '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 '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 '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 '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 'fails when iat is in the future' {
+ $jwt = New-Jwt -Payload @{ sub = 'joe'; iat = $script:nowSec + 600 } `
+ -Algorithm HS256 -Key $script:secret
+ Test-Jwt -Token $jwt -Key $script:secret -RequireExpiration $false | Should -BeFalse
+ }
+
+ It 'passes when iat is in the future but within skew' {
+ $jwt = New-Jwt -Payload @{ sub = 'joe'; iat = $script:nowSec + 30 } `
+ -Algorithm HS256 -Key $script:secret
+ Test-Jwt -Token $jwt -Key $script:secret -RequireExpiration $false `
+ -ClockSkew ([timespan]::FromMinutes(5)) | Should -BeTrue
+ }
+
+ 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 '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 '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
+ }
+
+ 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
+ }
+ }
+
+ 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'*"
+ }
+
+ It "rejects alg=none by default on signed validation path" {
+ $h = ConvertTo-Base64UrlString '{"alg":"none","typ":"JWT"}'
+ $p = ConvertTo-Base64UrlString '{"sub":"joe"}'
+ { Test-Jwt -Token "$h.$p." -Key 'super-secret' } | Should -Throw "*'none'*"
+ }
+
+ 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 'rejects -AllowUnsigned for a token using a signed algorithm' {
+ $jwt = New-Jwt -Payload @{ sub = 'joe' } -Algorithm HS256 -Key 'super-secret'
+ { Test-Jwt -Token $jwt -AllowUnsigned -RequireExpiration $false } | Should -Throw "*only supports tokens with alg='none'*"
+ }
+
+ It "rejects crit headers unless explicitly allow-listed" {
+ $secret = 'a-string-secret-at-least-256-bits-long'
+ $jwt = New-Jwt -Payload @{ sub = 'joe' } -Algorithm HS256 -Key $secret -Header @{ kid = 'key-1'; crit = @('kid') }
+ { Test-Jwt -Token $jwt -Key $secret -RequireExpiration $false } | Should -Throw '*Unsupported critical header parameters*'
+ Test-Jwt -Token $jwt -Key $secret -RequireExpiration $false -AllowedCriticalHeader 'kid' | Should -BeTrue
+ }
+
+ 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 8
+ ($r.Checks | ForEach-Object Name) | Should -Be @(
+ 'Algorithm', 'CriticalHeaders', 'Signature', 'Expiration', 'NotBefore', 'IssuedAt', '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
+ }
+ }
+
+ 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
+ }
+ }
+
+ Context 'Base64Url helpers' {
+ It 'ConvertFrom-Base64UrlString decodes a UTF-8 string' {
+ ConvertFrom-Base64UrlString 'SGVsbG8' | Should -Be 'Hello'
+ }
+
+ It 'ConvertFrom-Base64UrlString -AsByteArray returns bytes' {
+ $bytes = ConvertFrom-Base64UrlString 'SGVsbG8' -AsByteArray
+ $bytes | Should -BeOfType [byte]
+ [System.Text.Encoding]::UTF8.GetString($bytes) | Should -Be 'Hello'
+ }
+
+ It 'ConvertTo-Base64UrlString round-trips with ConvertFrom-Base64UrlString' {
+ $original = 'JWT test payload with special chars: +/='
+ $encoded = ConvertTo-Base64UrlString $original
+ $decoded = ConvertFrom-Base64UrlString $encoded
+ $decoded | Should -Be $original
+ }
+ }
+
+ 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')
+ }
+ }
+
+ Context 'Type and format metadata' {
+ It 'ships valid type and format definition files' {
+ $typesPath = Join-Path $PSScriptRoot '..\src\types\Jwt.Types.ps1xml'
+ $formatPath = Join-Path $PSScriptRoot '..\src\formats\Jwt.Format.ps1xml'
+ Test-Path $typesPath | Should -BeTrue
+ Test-Path $formatPath | Should -BeTrue
+ { [xml](Get-Content -Raw -Path $typesPath) } | Should -Not -Throw
+ { [xml](Get-Content -Raw -Path $formatPath) } | Should -Not -Throw
+ }
+
+ It 'does not expose JwtKey private fields in the default display set' {
+ $typesPath = Join-Path $PSScriptRoot '..\src\types\Jwt.Types.ps1xml'
+ $typesXml = [xml](Get-Content -Raw -Path $typesPath)
+ $jwtKeyType = $typesXml.Types.Type | Where-Object { $_.Name -eq 'JwtKey' }
+ $defaultProps = @($jwtKeyType.Members.MemberSet.Members.PropertySet.ReferencedProperties.Name)
+
+ $defaultProps | Should -Contain 'HasPrivateMaterial'
+ foreach ($secretField in @('d', 'p', 'q', 'dp', 'dq', 'qi', 'oth', 'k')) {
+ $defaultProps | Should -Not -Contain $secretField
+ }
+ }
+ }
+}
+
diff --git a/tests/Jwt.Tests.ps1 b/tests/Jwt.Tests.ps1
deleted file mode 100644
index d32242c..0000000
--- a/tests/Jwt.Tests.ps1
+++ /dev/null
@@ -1,153 +0,0 @@
-[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
- 'PSUseDeclaredVarsMoreThanAssignments', '',
- Justification = 'Required for Pester tests'
-)]
-[CmdletBinding()]
-param()
-
-Describe 'Data-driven tests' {
- $testCases = . "$PSScriptRoot/Data/TestCases.ps1"
-
- Context '' -ForEach $testCases {
- It 'ConvertTo-Base64UrlString - encodes the header as base64url' {
- ConvertTo-Base64UrlString $Header | Should -Be $HeaderEncoded
- }
-
- It 'ConvertFrom-Base64UrlString - decodes the header from base64url' {
- ConvertFrom-Base64UrlString $HeaderEncoded | Should -Be $Header
- }
-
- It 'ConvertTo-Base64UrlString - encodes the payload as base64url' {
- ConvertTo-Base64UrlString $Payload | Should -Be $PayloadEncoded
- }
-
- It 'ConvertFrom-Base64UrlString - decodes the payload from base64url' {
- ConvertFrom-Base64UrlString $PayloadEncoded | Should -Be $Payload
- }
-
- It 'Get-JwtHeader - extracts the header' {
- Get-JwtHeader $ExtractionToken | Should -Be $Header
- }
-
- It 'Get-JwtPayload - extracts the payload' {
- Get-JwtPayload $ExtractionToken | Should -Be $Payload
- }
-
- It 'New-Jwt/Test-Jwt - creates and validates the token' {
- $jwt = New-Jwt -Header $Header -PayloadJson $Payload -Secret $Secret
-
- $parts = $jwt.Split('.')
- $parts.Count | Should -Be 3
- if ($null -ne $ExpectedToken) {
- $jwt | Should -Be $ExpectedToken
- }
- Get-JwtHeader $jwt | Should -Be $Header
- Get-JwtPayload $jwt | Should -Be $Payload
- Test-Jwt -jwt $jwt -Secret $Secret | Should -BeTrue
- }
-
- 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
-
- Test-Jwt -jwt ($parts -join '.') -Secret $Secret | Should -BeFalse
- }
-
- It 'New-Jwt - requires a secret' {
- { New-Jwt -Header $Header -PayloadJson $Payload } | Should -Throw '*HS256 requires*Secret*'
- }
- }
-
- Context 'General behavior' {
- It 'ConvertFrom-Base64UrlString - returns bytes when requested' {
- $bytes = ConvertFrom-Base64UrlString 'SGVsbG8' -AsByteArray
-
- [System.Text.Encoding]::UTF8.GetString($bytes) | Should -Be 'Hello'
- }
-
- It 'ConvertFrom-Base64UrlString - rejects invalid base64url length' {
- { ConvertFrom-Base64UrlString 'A' } | Should -Throw '*Invalid base64url string length*'
- }
-
- It 'ConvertTo-Base64UrlString - throws for unsupported input types' {
- { ConvertTo-Base64UrlString ([pscustomobject]@{ Value = 'invalid' }) } | Should -Throw '*requires string or byte array input*'
- }
-
- 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"}'
-
- $jwt | Should -Match '\.$'
- Test-Jwt -jwt $jwt | Should -BeTrue
- }
-
- 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 '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 '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 'Get-JwtHeader - requires exactly three JWT segments' {
- { Get-JwtHeader 'header.payload' } | Should -Throw '*JWT must have exactly 3 segments*'
- }
-
- It 'Get-JwtPayload - requires a payload segment' {
- { Get-JwtPayload 'header..signature' } | Should -Throw '*JWT payload segment is missing*'
- }
-
- It 'Test-Jwt - requires exactly three JWT segments' {
- { Test-Jwt 'header.payload' } | Should -Throw '*JWT must have exactly 3 segments*'
- }
-
- It 'Test-Jwt - rejects unsigned tokens without a third segment' {
- $header = ConvertTo-Base64UrlString '{"alg":"none","typ":"JWT"}'
- $payload = ConvertTo-Base64UrlString '{"sub":"joe","role":"admin"}'
-
- { Test-Jwt "$header.$payload" } | Should -Throw '*JWT must have exactly 3 segments*'
- }
-
- 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'
-
- Test-Jwt -jwt ($parts -join '.') -Secret 'super-secret' | Should -BeFalse
- }
-
- 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'
-
- $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
-
- $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))
- }
- }
-}
diff --git a/tests/unit/ConvertFrom-Base64UrlString.Tests.ps1 b/tests/unit/ConvertFrom-Base64UrlString.Tests.ps1
new file mode 100644
index 0000000..11dbbc8
--- /dev/null
+++ b/tests/unit/ConvertFrom-Base64UrlString.Tests.ps1
@@ -0,0 +1,10 @@
+#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' }
+[CmdletBinding()]
+param()
+
+Describe 'ConvertFrom-Base64UrlString' {
+ It 'decodes base64url strings to UTF-8 text' {
+ ConvertFrom-Base64UrlString 'SGVsbG8' | Should -Be 'Hello'
+ }
+}
+
diff --git a/tests/unit/ConvertFrom-Jwt.Tests.ps1 b/tests/unit/ConvertFrom-Jwt.Tests.ps1
new file mode 100644
index 0000000..50c6a18
--- /dev/null
+++ b/tests/unit/ConvertFrom-Jwt.Tests.ps1
@@ -0,0 +1,11 @@
+#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' }
+[CmdletBinding()]
+param()
+
+Describe 'ConvertFrom-Jwt' {
+ It 'parses a compact JWT into a Jwt object' {
+ $token = New-Jwt -Payload @{ sub = 'joe' } -Algorithm HS256 -Key 'a-string-secret-at-least-256-bits-long'
+ (ConvertFrom-Jwt -Token $token.ToString()).GetType().Name | Should -Be 'Jwt'
+ }
+}
+
diff --git a/tests/unit/ConvertFrom-JwtKey.Tests.ps1 b/tests/unit/ConvertFrom-JwtKey.Tests.ps1
new file mode 100644
index 0000000..afcde75
--- /dev/null
+++ b/tests/unit/ConvertFrom-JwtKey.Tests.ps1
@@ -0,0 +1,34 @@
+#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' }
+[CmdletBinding()]
+param()
+
+Describe 'ConvertFrom-JwtKey' {
+ It 'converts a JWK back into a .NET key' {
+ $rsa = [System.Security.Cryptography.RSA]::Create(2048)
+ try {
+ $jwk = ConvertTo-JwtKey -Key $rsa
+ (ConvertFrom-JwtKey -Key $jwk) | Should -BeOfType [System.Security.Cryptography.RSA]
+ } finally {
+ $rsa.Dispose()
+ }
+ }
+
+ It 'returns raw bytes for oct keys by default' {
+ $secret = [System.Text.Encoding]::UTF8.GetBytes('a-string-secret-at-least-256-bits-long')
+ $jwk = ConvertTo-JwtKey -Key $secret
+ $resolved = ConvertFrom-JwtKey -Key $jwk
+ $resolved | Should -BeOfType [byte]
+ [System.Convert]::ToBase64String($resolved) | Should -Be ([System.Convert]::ToBase64String($secret))
+ }
+
+ It 'returns HMAC when -AsHmac is requested for oct keys' {
+ $secret = [System.Text.Encoding]::UTF8.GetBytes('a-string-secret-at-least-256-bits-long')
+ $jwk = ConvertTo-JwtKey -Key $secret
+ $resolved = ConvertFrom-JwtKey -Key $jwk -AsHmac -Algorithm HS512
+ try {
+ $resolved | Should -BeOfType [System.Security.Cryptography.HMACSHA512]
+ } finally {
+ $resolved.Dispose()
+ }
+ }
+}
diff --git a/tests/unit/ConvertFrom-JwtKeySet.Tests.ps1 b/tests/unit/ConvertFrom-JwtKeySet.Tests.ps1
new file mode 100644
index 0000000..a4cf2a7
--- /dev/null
+++ b/tests/unit/ConvertFrom-JwtKeySet.Tests.ps1
@@ -0,0 +1,12 @@
+#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' }
+[CmdletBinding()]
+param()
+
+Describe 'ConvertFrom-JwtKeySet' {
+ It 'parses a valid JWKS JSON document' {
+ $jwk = ConvertTo-JwtKey -Key ([System.Text.Encoding]::UTF8.GetBytes('a-string-secret-at-least-256-bits-long'))
+ $set = $jwk | ConvertTo-JwtKeySet
+ (ConvertFrom-JwtKeySet -Json $set.ToJson()).keys.Count | Should -Be 1
+ }
+}
+
diff --git a/tests/unit/ConvertTo-Base64UrlString.Tests.ps1 b/tests/unit/ConvertTo-Base64UrlString.Tests.ps1
new file mode 100644
index 0000000..04d354a
--- /dev/null
+++ b/tests/unit/ConvertTo-Base64UrlString.Tests.ps1
@@ -0,0 +1,10 @@
+#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' }
+[CmdletBinding()]
+param()
+
+Describe 'ConvertTo-Base64UrlString' {
+ It 'encodes UTF-8 strings into base64url' {
+ ConvertTo-Base64UrlString 'Hello' | Should -Be 'SGVsbG8'
+ }
+}
+
diff --git a/tests/unit/ConvertTo-JwtKey.Tests.ps1 b/tests/unit/ConvertTo-JwtKey.Tests.ps1
new file mode 100644
index 0000000..0467e39
--- /dev/null
+++ b/tests/unit/ConvertTo-JwtKey.Tests.ps1
@@ -0,0 +1,12 @@
+#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' }
+[CmdletBinding()]
+param()
+
+Describe 'ConvertTo-JwtKey' {
+ It 'converts a byte array into an oct JWK' {
+ $jwk = ConvertTo-JwtKey -Key ([System.Text.Encoding]::UTF8.GetBytes('a-string-secret-at-least-256-bits-long'))
+ $jwk.kty | Should -Be 'oct'
+ $jwk.k | Should -Not -BeNullOrEmpty
+ }
+}
+
diff --git a/tests/unit/ConvertTo-JwtKeySet.Tests.ps1 b/tests/unit/ConvertTo-JwtKeySet.Tests.ps1
new file mode 100644
index 0000000..bc7f419
--- /dev/null
+++ b/tests/unit/ConvertTo-JwtKeySet.Tests.ps1
@@ -0,0 +1,11 @@
+#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' }
+[CmdletBinding()]
+param()
+
+Describe 'ConvertTo-JwtKeySet' {
+ It 'wraps one or more JwtKey objects in a JwtKeySet' {
+ $jwk = ConvertTo-JwtKey -Key ([System.Text.Encoding]::UTF8.GetBytes('a-string-secret-at-least-256-bits-long'))
+ ($jwk | ConvertTo-JwtKeySet).GetType().Name | Should -Be 'JwtKeySet'
+ }
+}
+
diff --git a/tests/unit/Get-JwtClaim.Tests.ps1 b/tests/unit/Get-JwtClaim.Tests.ps1
new file mode 100644
index 0000000..e0a4a22
--- /dev/null
+++ b/tests/unit/Get-JwtClaim.Tests.ps1
@@ -0,0 +1,11 @@
+#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' }
+[CmdletBinding()]
+param()
+
+Describe 'Get-JwtClaim' {
+ It 'returns a single named claim' {
+ $token = New-Jwt -Payload @{ sub = 'joe'; role = 'admin' } -Algorithm HS256 -Key 'a-string-secret-at-least-256-bits-long'
+ Get-JwtClaim -Token $token -Name 'role' | Should -Be 'admin'
+ }
+}
+
diff --git a/tests/unit/Get-JwtHeader.Tests.ps1 b/tests/unit/Get-JwtHeader.Tests.ps1
new file mode 100644
index 0000000..965d691
--- /dev/null
+++ b/tests/unit/Get-JwtHeader.Tests.ps1
@@ -0,0 +1,11 @@
+#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' }
+[CmdletBinding()]
+param()
+
+Describe 'Get-JwtHeader' {
+ It 'returns a header with alg set' {
+ $token = New-Jwt -Payload @{ sub = 'joe' } -Algorithm HS256 -Key 'a-string-secret-at-least-256-bits-long'
+ (Get-JwtHeader -Token $token).alg | Should -Be 'HS256'
+ }
+}
+
diff --git a/tests/unit/Get-JwtKeyFromSet.Tests.ps1 b/tests/unit/Get-JwtKeyFromSet.Tests.ps1
new file mode 100644
index 0000000..94d2849
--- /dev/null
+++ b/tests/unit/Get-JwtKeyFromSet.Tests.ps1
@@ -0,0 +1,17 @@
+#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' }
+[CmdletBinding()]
+param()
+
+Describe 'Get-JwtKeyFromSet' {
+ It 'returns the key matching the requested kid' {
+ $rsa = [System.Security.Cryptography.RSA]::Create(2048)
+ try {
+ $jwk = ConvertTo-JwtKey -Key $rsa -KeyId 'rsa-1' -Algorithm RS256
+ $set = $jwk | ConvertTo-JwtKeySet
+ (Get-JwtKeyFromSet -KeySet $set -KeyId 'rsa-1').kid | Should -Be 'rsa-1'
+ } finally {
+ $rsa.Dispose()
+ }
+ }
+}
+
diff --git a/tests/unit/Get-JwtKeyThumbprint.Tests.ps1 b/tests/unit/Get-JwtKeyThumbprint.Tests.ps1
new file mode 100644
index 0000000..299f408
--- /dev/null
+++ b/tests/unit/Get-JwtKeyThumbprint.Tests.ps1
@@ -0,0 +1,16 @@
+#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' }
+[CmdletBinding()]
+param()
+
+Describe 'Get-JwtKeyThumbprint' {
+ It 'returns a base64url thumbprint for a valid key' {
+ $rsa = [System.Security.Cryptography.RSA]::Create(2048)
+ try {
+ $jwk = ConvertTo-JwtKey -Key $rsa
+ Get-JwtKeyThumbprint -Key $jwk | Should -Match '^[A-Za-z0-9_-]{43}$'
+ } finally {
+ $rsa.Dispose()
+ }
+ }
+}
+
diff --git a/tests/unit/Get-JwtPayload.Tests.ps1 b/tests/unit/Get-JwtPayload.Tests.ps1
new file mode 100644
index 0000000..3054d01
--- /dev/null
+++ b/tests/unit/Get-JwtPayload.Tests.ps1
@@ -0,0 +1,11 @@
+#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' }
+[CmdletBinding()]
+param()
+
+Describe 'Get-JwtPayload' {
+ It 'returns payload claims' {
+ $token = New-Jwt -Payload @{ sub = 'joe' } -Algorithm HS256 -Key 'a-string-secret-at-least-256-bits-long'
+ (Get-JwtPayload -Token $token).sub | Should -Be 'joe'
+ }
+}
+
diff --git a/tests/unit/New-Jwt.Tests.ps1 b/tests/unit/New-Jwt.Tests.ps1
new file mode 100644
index 0000000..626bd42
--- /dev/null
+++ b/tests/unit/New-Jwt.Tests.ps1
@@ -0,0 +1,22 @@
+#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' }
+[CmdletBinding()]
+param()
+
+Describe 'New-Jwt' {
+ It 'creates a compact token with 3 segments' {
+ $token = New-Jwt -Payload @{ sub = 'joe' } -Algorithm HS256 -Key 'a-string-secret-at-least-256-bits-long'
+ $token.ToString().Split('.').Count | Should -Be 3
+ }
+
+ It 'can generate a signing key internally and still produce a valid token' {
+ $token = New-Jwt -Payload @{ sub = 'joe' } -Algorithm ES256 -GenerateKey
+ $token | Should -BeOfType [Jwt]
+ $token.ToString().Split('.').Count | Should -Be 3
+ $token.Header.alg | Should -Be 'ES256'
+ }
+
+ It 'applies -GeneratedKeyId to header kid in generated-key mode' {
+ $token = New-Jwt -Payload @{ sub = 'joe' } -Algorithm RS256 -GenerateKey -GeneratedKeyId 'rsa-1'
+ $token.Header.kid | Should -Be 'rsa-1'
+ }
+}
diff --git a/tests/unit/New-JwtSigningKey.Tests.ps1 b/tests/unit/New-JwtSigningKey.Tests.ps1
new file mode 100644
index 0000000..568888c
--- /dev/null
+++ b/tests/unit/New-JwtSigningKey.Tests.ps1
@@ -0,0 +1,41 @@
+#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' }
+[CmdletBinding()]
+param()
+
+Describe 'New-JwtSigningKey' {
+ It 'generates RSA key material for RS256' {
+ $key = New-JwtSigningKey -Algorithm RS256 -RsaKeySize 2048
+ try {
+ $key | Should -BeOfType [System.Security.Cryptography.RSA]
+ $key.KeySize | Should -Be 2048
+ } finally {
+ $key.Dispose()
+ }
+ }
+
+ It 'generates ECDsa key material for ES256' {
+ $key = New-JwtSigningKey -Algorithm ES256
+ try {
+ $key | Should -BeOfType [System.Security.Cryptography.ECDsa]
+ $params = $key.ExportParameters($false)
+ $params.Curve.Oid.Value | Should -Be '1.2.840.10045.3.1.7'
+ } finally {
+ $key.Dispose()
+ }
+ }
+
+ It 'generates an HMAC secret for HS512 with expected length' {
+ $key = New-JwtSigningKey -Algorithm HS512
+ $key | Should -BeOfType [byte]
+ $key.Length | Should -Be 64
+ }
+
+ It 'can return a JWK with private material' {
+ $jwk = New-JwtSigningKey -Algorithm RS256 -AsJwk -KeyId 'rsa-1'
+ $jwk | Should -BeOfType [JwtKey]
+ $jwk.kty | Should -Be 'RSA'
+ $jwk.kid | Should -Be 'rsa-1'
+ $jwk.d | Should -Not -BeNullOrEmpty
+ }
+}
+
diff --git a/tests/unit/Test-Jwt.Tests.ps1 b/tests/unit/Test-Jwt.Tests.ps1
new file mode 100644
index 0000000..519a7a7
--- /dev/null
+++ b/tests/unit/Test-Jwt.Tests.ps1
@@ -0,0 +1,31 @@
+#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' }
+[CmdletBinding()]
+param()
+
+Describe 'Test-Jwt' {
+ It 'returns true for a valid HS256 token' {
+ $secret = 'a-string-secret-at-least-256-bits-long'
+ $token = New-Jwt -Payload @{ sub = 'joe' } -Algorithm HS256 -Key $secret
+ Test-Jwt -Token $token -Key $secret -RequireExpiration $false | Should -BeTrue
+ }
+
+ It "requires -AllowUnsigned for alg=none tokens" {
+ $h = ConvertTo-Base64UrlString '{"alg":"none","typ":"JWT"}'
+ $p = ConvertTo-Base64UrlString '{"sub":"joe"}'
+ { Test-Jwt -Token "$h.$p." -Key 'super-secret' } | Should -Throw
+ Test-Jwt -Token "$h.$p." -AllowUnsigned -RequireExpiration $false | Should -BeTrue
+ }
+
+ It "rejects crit headers unless explicitly allow-listed" {
+ $secret = 'a-string-secret-at-least-256-bits-long'
+ $token = New-Jwt -Payload @{ sub = 'joe' } -Algorithm HS256 -Key $secret -Header @{ kid = 'key-1'; crit = @('kid') }
+ { Test-Jwt -Token $token -Key $secret -RequireExpiration $false } | Should -Throw '*Unsupported critical header parameters*'
+ Test-Jwt -Token $token -Key $secret -RequireExpiration $false -AllowedCriticalHeader 'kid' | Should -BeTrue
+ }
+
+ It "rejects crit entries that do not exist in the header" {
+ $secret = 'a-string-secret-at-least-256-bits-long'
+ $token = New-Jwt -Payload @{ sub = 'joe' } -Algorithm HS256 -Key $secret -Header @{ crit = @('kid') }
+ { Test-Jwt -Token $token -Key $secret -RequireExpiration $false -AllowedCriticalHeader 'kid' } | Should -Throw '*not present in header*'
+ }
+}