diff --git a/.github/PSModule.yml b/.github/PSModule.yml index c66c28c..508dc3c 100644 --- a/.github/PSModule.yml +++ b/.github/PSModule.yml @@ -5,3 +5,27 @@ Test: CodeCoverage: PercentTarget: 0 +# TestResults: +# Skip: true +# SourceCode: +# Skip: true +# PSModule: +# Skip: true +# Module: +# Windows: +# Skip: true +# MacOS: +# Skip: true +Build: + Docs: + Skip: true + +Linter: + env: + VALIDATE_BIOME_FORMAT: false + VALIDATE_BIOME_LINT: false + VALIDATE_GITHUB_ACTIONS_ZIZMOR: false + VALIDATE_JSCPD: false + VALIDATE_JSON_PRETTIER: false + VALIDATE_MARKDOWN_PRETTIER: false + VALIDATE_YAML_PRETTIER: false diff --git a/.github/linters/.codespellrc b/.github/linters/.codespellrc new file mode 100644 index 0000000..351e9a0 --- /dev/null +++ b/.github/linters/.codespellrc @@ -0,0 +1,3 @@ +[codespell] +skip = ./.github/linters +ignore-words-list = afterall diff --git a/.github/linters/.jscpd.json b/.github/linters/.jscpd.json deleted file mode 100644 index 23970e8..0000000 --- a/.github/linters/.jscpd.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "threshold": 0, - "reporters": [ - "consoleFull" - ], - "ignore": [ - "**/tests/**" - ], - "absolute": true -} diff --git a/.github/linters/.markdown-lint.yml b/.github/linters/.markdown-lint.yml index b9ecdfa..57db57e 100644 --- a/.github/linters/.markdown-lint.yml +++ b/.github/linters/.markdown-lint.yml @@ -8,18 +8,19 @@ ############### # Rules by id # ############### -MD004: false # Unordered list style +MD004: false # Unordered list style MD007: - indent: 2 # Unordered list indentation + indent: 2 # Unordered list indentation MD013: - line_length: 808 # Line length + line_length: 808 # Line length +MD024: false # no-duplicate-heading, INPUTS and OUTPUTS _can_ be the same item MD026: - punctuation: ".,;:!。,;:" # List of not allowed -MD029: false # Ordered list item prefix -MD033: false # Allow inline HTML -MD036: false # Emphasis used instead of a heading + punctuation: '.,;:!。,;:' # List of not allowed +MD029: false # Ordered list item prefix +MD033: false # Allow inline HTML +MD036: false # Emphasis used instead of a heading ################# # Rules by tags # ################# -blank_lines: false # Error on blank lines +blank_lines: false # Error on blank lines diff --git a/.github/linters/trivy.yaml b/.github/linters/trivy.yaml new file mode 100644 index 0000000..0646d5d --- /dev/null +++ b/.github/linters/trivy.yaml @@ -0,0 +1,10 @@ +scan: + disable-telemetry: true + scanners: + - vuln + - misconfig + - secret + skip-files: + # src/JWT.psm1 is a build artifact generated by Process-PSModule and is + # not present in source. Skip it so lint runs against checked-in files only. + - src/JWT.psm1 diff --git a/.github/workflows/Linter.yml b/.github/workflows/Linter.yml deleted file mode 100644 index 1f677cb..0000000 --- a/.github/workflows/Linter.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Linter - -run-name: "Linter - [${{ github.event.pull_request.title }} #${{ github.event.pull_request.number }}] by @${{ github.actor }}" - -on: [pull_request] - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - packages: read - statuses: write - -jobs: - Lint: - name: Lint code base - runs-on: ubuntu-latest - steps: - - name: Checkout repo - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Lint code base - uses: super-linter/super-linter@latest - env: - GITHUB_TOKEN: ${{ github.token }} - VALIDATE_JSON_PRETTIER: false - VALIDATE_MARKDOWN_PRETTIER: false - VALIDATE_YAML_PRETTIER: false diff --git a/.github/workflows/Nightly-Run.yml b/.github/workflows/Nightly-Run.yml deleted file mode 100644 index b192d11..0000000 --- a/.github/workflows/Nightly-Run.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: Nightly Run - -on: - workflow_dispatch: - schedule: - - cron: '0 0 * * *' - -permissions: - contents: read - pull-requests: write - statuses: write - -jobs: - Process-PSModule: - uses: PSModule/Process-PSModule/.github/workflows/CI.yml@v4 - secrets: - APIKEY: ${{ secrets.APIKEY }} diff --git a/.github/workflows/Process-PSModule.yml b/.github/workflows/Process-PSModule.yml index bd51f85..67f2c25 100644 --- a/.github/workflows/Process-PSModule.yml +++ b/.github/workflows/Process-PSModule.yml @@ -1,8 +1,9 @@ name: Process-PSModule -run-name: "Process-PSModule - [${{ github.event.pull_request.title }} #${{ github.event.pull_request.number }}] by @${{ github.actor }}" - on: + workflow_dispatch: + schedule: + - cron: '0 0 * * *' pull_request: branches: - main @@ -26,6 +27,5 @@ permissions: jobs: Process-PSModule: - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v4 - secrets: - APIKEY: ${{ secrets.APIKEY }} + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@11117919e65242d3388727819a751f74ad24ea9e # v5.5.0 + secrets: inherit diff --git a/README.md b/README.md index 6319793..e88a481 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,54 @@ -# {{ NAME }} +# Jwt -{{ DESCRIPTION }} - -## Prerequisites - -This uses the following external resources: -- The [PSModule framework](https://github.com/PSModule/Process-PSModule) for building, testing and publishing the module. +`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. ## Installation -To install the module from the PowerShell Gallery, you can use the following command: - ```powershell -Install-PSResource -Name {{ NAME }} -Import-Module -Name {{ NAME }} +Install-PSResource -Name Jwt +Import-Module -Name Jwt ``` -## Usage +## Commands -Here is a list of example that are typical use cases for the module. - -### Example 1: Greet an entity - -Provide examples for typical commands that a user would like to do with the module. +The maintained module exports the same JWT commands and alias used by the current package: ```powershell -Greet-Entity -Name 'World' -Hello, World! +ConvertFrom-Base64UrlString +ConvertTo-Base64UrlString +Get-JwtHeader +Get-JwtPayload +New-Jwt +Test-Jwt +Verify-JwtSignature ``` -### Example 2 +## Usage -Provide examples for typical commands that a user would like to do with the module. +Create and validate an HMAC-signed JWT: ```powershell -Import-Module -Name PSModuleTemplate -``` +$header = '{"alg":"HS256","typ":"JWT"}' +$payload = '{"sub":"1234567890","name":"John Doe","admin":true,"iat":1516239022}' +$secret = 'a-string-secret-at-least-256-bits-long' -### Find more examples +$jwt = New-Jwt -Header $header -PayloadJson $payload -Secret $secret +Test-Jwt -jwt $jwt -Secret $secret +``` -To find more examples of how to use the module, please refer to the [examples](examples) folder. +Read the header and payload from an existing token: -Alternatively, you can use the Get-Command -Module 'This module' to find more commands that are available in the module. -To find examples of each of the commands you can use Get-Help -Examples 'CommandName'. +```powershell +Get-JwtHeader -jwt $jwt +Get-JwtPayload -jwt $jwt +``` -## Documentation +For more information about each command, use PowerShell help: -Link to further documentation if available, or describe where in the repository users can find more detailed documentation about -the module's functions and features. +```powershell +Get-Command -Module Jwt +Get-Help New-Jwt -Full +``` ## Contributing diff --git a/examples/General.ps1 b/examples/General.ps1 deleted file mode 100644 index e193423..0000000 --- a/examples/General.ps1 +++ /dev/null @@ -1,19 +0,0 @@ -<# - .SYNOPSIS - This is a general example of how to use the module. -#> - -# Import the module -Import-Module -Name 'PSModule' - -# Define the path to the font file -$FontFilePath = 'C:\Fonts\CodeNewRoman\CodeNewRomanNerdFontPropo-Regular.tff' - -# Install the font -Install-Font -Path $FontFilePath -Verbose - -# List installed fonts -Get-Font -Name 'CodeNewRomanNerdFontPropo-Regular' - -# Uninstall the font -Get-Font -Name 'CodeNewRomanNerdFontPropo-Regular' | Uninstall-Font -Verbose diff --git a/src/README.md b/src/README.md deleted file mode 100644 index af76160..0000000 --- a/src/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Details - -For more info about the expected structure of a module repository, please refer to [Build-PSModule](https://github.com/PSModule/Build-PSModule) diff --git a/src/assemblies/LsonLib.dll b/src/assemblies/LsonLib.dll deleted file mode 100644 index 3661807..0000000 Binary files a/src/assemblies/LsonLib.dll and /dev/null differ diff --git a/src/classes/private/SecretWriter.ps1 b/src/classes/private/SecretWriter.ps1 deleted file mode 100644 index 1b1732a..0000000 --- a/src/classes/private/SecretWriter.ps1 +++ /dev/null @@ -1,15 +0,0 @@ -class SecretWriter { - [string] $Alias - [string] $Name - [string] $Secret - - SecretWriter([string] $alias, [string] $name, [string] $secret) { - $this.Alias = $alias - $this.Name = $name - $this.Secret = $secret - } - - [string] GetAlias() { - return $this.Alias - } -} diff --git a/src/classes/public/Book.ps1 b/src/classes/public/Book.ps1 deleted file mode 100644 index 8917d9a..0000000 --- a/src/classes/public/Book.ps1 +++ /dev/null @@ -1,147 +0,0 @@ -class Book { - # Class properties - [string] $Title - [string] $Author - [string] $Synopsis - [string] $Publisher - [datetime] $PublishDate - [int] $PageCount - [string[]] $Tags - # Default constructor - Book() { $this.Init(@{}) } - # Convenience constructor from hashtable - Book([hashtable]$Properties) { $this.Init($Properties) } - # Common constructor for title and author - Book([string]$Title, [string]$Author) { - $this.Init(@{Title = $Title; Author = $Author }) - } - # Shared initializer method - [void] Init([hashtable]$Properties) { - foreach ($Property in $Properties.Keys) { - $this.$Property = $Properties.$Property - } - } - # Method to calculate reading time as 2 minutes per page - [timespan] GetReadingTime() { - if ($this.PageCount -le 0) { - throw 'Unable to determine reading time from page count.' - } - $Minutes = $this.PageCount * 2 - return [timespan]::new(0, $Minutes, 0) - } - # Method to calculate how long ago a book was published - [timespan] GetPublishedAge() { - if ( - $null -eq $this.PublishDate -or - $this.PublishDate -eq [datetime]::MinValue - ) { throw 'PublishDate not defined' } - - return (Get-Date) - $this.PublishDate - } - # Method to return a string representation of the book - [string] ToString() { - return "$($this.Title) by $($this.Author) ($($this.PublishDate.Year))" - } -} - -class BookList { - # Static property to hold the list of books - static [System.Collections.Generic.List[Book]] $Books - # Static method to initialize the list of books. Called in the other - # static methods to avoid needing to explicit initialize the value. - static [void] Initialize() { [BookList]::Initialize($false) } - static [bool] Initialize([bool]$force) { - if ([BookList]::Books.Count -gt 0 -and -not $force) { - return $false - } - - [BookList]::Books = [System.Collections.Generic.List[Book]]::new() - - return $true - } - # Ensure a book is valid for the list. - static [void] Validate([book]$Book) { - $Prefix = @( - 'Book validation failed: Book must be defined with the Title,' - 'Author, and PublishDate properties, but' - ) -join ' ' - if ($null -eq $Book) { throw "$Prefix was null" } - if ([string]::IsNullOrEmpty($Book.Title)) { - throw "$Prefix Title wasn't defined" - } - if ([string]::IsNullOrEmpty($Book.Author)) { - throw "$Prefix Author wasn't defined" - } - if ([datetime]::MinValue -eq $Book.PublishDate) { - throw "$Prefix PublishDate wasn't defined" - } - } - # Static methods to manage the list of books. - # Add a book if it's not already in the list. - static [void] Add([Book]$Book) { - [BookList]::Initialize() - [BookList]::Validate($Book) - if ([BookList]::Books.Contains($Book)) { - throw "Book '$Book' already in list" - } - - $FindPredicate = { - param([Book]$b) - - $b.Title -eq $Book.Title -and - $b.Author -eq $Book.Author -and - $b.PublishDate -eq $Book.PublishDate - }.GetNewClosure() - if ([BookList]::Books.Find($FindPredicate)) { - throw "Book '$Book' already in list" - } - - [BookList]::Books.Add($Book) - } - # Clear the list of books. - static [void] Clear() { - [BookList]::Initialize() - [BookList]::Books.Clear() - } - # Find a specific book using a filtering scriptblock. - static [Book] Find([scriptblock]$Predicate) { - [BookList]::Initialize() - return [BookList]::Books.Find($Predicate) - } - # Find every book matching the filtering scriptblock. - static [Book[]] FindAll([scriptblock]$Predicate) { - [BookList]::Initialize() - return [BookList]::Books.FindAll($Predicate) - } - # Remove a specific book. - static [void] Remove([Book]$Book) { - [BookList]::Initialize() - [BookList]::Books.Remove($Book) - } - # Remove a book by property value. - static [void] RemoveBy([string]$Property, [string]$Value) { - [BookList]::Initialize() - $Index = [BookList]::Books.FindIndex({ - param($b) - $b.$Property -eq $Value - }.GetNewClosure()) - if ($Index -ge 0) { - [BookList]::Books.RemoveAt($Index) - } - } -} - -enum Binding { - Hardcover - Paperback - EBook -} - -enum Genre { - Mystery - Thriller - Romance - ScienceFiction - Fantasy - Horror -} diff --git a/src/data/Config.psd1 b/src/data/Config.psd1 deleted file mode 100644 index fea4466..0000000 --- a/src/data/Config.psd1 +++ /dev/null @@ -1,3 +0,0 @@ -@{ - RandomKey = 'RandomValue' -} diff --git a/src/data/Settings.psd1 b/src/data/Settings.psd1 deleted file mode 100644 index bcfa7b4..0000000 --- a/src/data/Settings.psd1 +++ /dev/null @@ -1,3 +0,0 @@ -@{ - RandomSetting = 'RandomSettingValue' -} diff --git a/src/finally.ps1 b/src/finally.ps1 deleted file mode 100644 index d8fc207..0000000 --- a/src/finally.ps1 +++ /dev/null @@ -1,3 +0,0 @@ -Write-Verbose '------------------------------' -Write-Verbose '--- THIS IS A LAST LOADER ---' -Write-Verbose '------------------------------' diff --git a/src/formats/CultureInfo.Format.ps1xml b/src/formats/CultureInfo.Format.ps1xml deleted file mode 100644 index a715e08..0000000 --- a/src/formats/CultureInfo.Format.ps1xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - System.Globalization.CultureInfo - - System.Globalization.CultureInfo - - - - - 16 - - - 16 - - - - - - - - LCID - - - Name - - - DisplayName - - - - - - - - diff --git a/src/formats/Mygciview.Format.ps1xml b/src/formats/Mygciview.Format.ps1xml deleted file mode 100644 index 4c972c2..0000000 --- a/src/formats/Mygciview.Format.ps1xml +++ /dev/null @@ -1,65 +0,0 @@ - - - - - mygciview - - System.IO.DirectoryInfo - System.IO.FileInfo - - - PSParentPath - - - - - - 7 - Left - - - - 26 - Right - - - - 26 - Right - - - - 14 - Right - - - - Left - - - - - - - - ModeWithoutHardLink - - - LastWriteTime - - - CreationTime - - - Length - - - Name - - - - - - - - diff --git a/src/functions/private/Get-InternalPSModule.ps1 b/src/functions/private/Get-InternalPSModule.ps1 deleted file mode 100644 index 89f053c..0000000 --- a/src/functions/private/Get-InternalPSModule.ps1 +++ /dev/null @@ -1,18 +0,0 @@ -function Get-InternalPSModule { - <# - .SYNOPSIS - Performs tests on a module. - - .EXAMPLE - Test-PSModule -Name 'World' - - "Hello, World!" - #> - [CmdletBinding()] - param ( - # Name of the person to greet. - [Parameter(Mandatory)] - [string] $Name - ) - Write-Output "Hello, $Name!" -} diff --git a/src/functions/private/Set-InternalPSModule.ps1 b/src/functions/private/Set-InternalPSModule.ps1 deleted file mode 100644 index cf870ba..0000000 --- a/src/functions/private/Set-InternalPSModule.ps1 +++ /dev/null @@ -1,22 +0,0 @@ -function Set-InternalPSModule { - <# - .SYNOPSIS - Performs tests on a module. - - .EXAMPLE - Test-PSModule -Name 'World' - - "Hello, World!" - #> - [Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSUseShouldProcessForStateChangingFunctions', '', Scope = 'Function', - Justification = 'Reason for suppressing' - )] - [CmdletBinding()] - param ( - # Name of the person to greet. - [Parameter(Mandatory)] - [string] $Name - ) - Write-Output "Hello, $Name!" -} diff --git a/src/functions/public/ConvertFrom-Base64UrlString.ps1 b/src/functions/public/ConvertFrom-Base64UrlString.ps1 new file mode 100644 index 0000000..e6ae97e --- /dev/null +++ b/src/functions/public/ConvertFrom-Base64UrlString.ps1 @@ -0,0 +1,63 @@ +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 new file mode 100644 index 0000000..ee4c9e0 --- /dev/null +++ b/src/functions/public/ConvertTo-Base64UrlString.ps1 @@ -0,0 +1,56 @@ +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 { + throw [System.ArgumentException]::new("ConvertTo-Base64UrlString requires string or byte array input, received $($InputObject.GetType())") + } + } + + end {} +} diff --git a/src/functions/public/Get-JwtHeader.ps1 b/src/functions/public/Get-JwtHeader.ps1 new file mode 100644 index 0000000..c38bf86 --- /dev/null +++ b/src/functions/public/Get-JwtHeader.ps1 @@ -0,0 +1,56 @@ +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 new file mode 100644 index 0000000..78eb909 --- /dev/null +++ b/src/functions/public/Get-JwtPayload.ps1 @@ -0,0 +1,55 @@ +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/Get-PSModuleTest.ps1 b/src/functions/public/Get-PSModuleTest.ps1 deleted file mode 100644 index 0e9aacf..0000000 --- a/src/functions/public/Get-PSModuleTest.ps1 +++ /dev/null @@ -1,20 +0,0 @@ -#Requires -Modules Utilities - -function Get-PSModuleTest { - <# - .SYNOPSIS - Performs tests on a module. - - .EXAMPLE - Test-PSModule -Name 'World' - - "Hello, World!" - #> - [CmdletBinding()] - param ( - # Name of the person to greet. - [Parameter(Mandatory)] - [string] $Name - ) - Write-Output "Hello, $Name!" -} diff --git a/src/functions/public/New-Jwt.ps1 b/src/functions/public/New-Jwt.ps1 new file mode 100644 index 0000000..1d5bcb6 --- /dev/null +++ b/src/functions/public/New-Jwt.ps1 @@ -0,0 +1,152 @@ +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 { + throw [System.FormatException]::new("The supplied JWT header is not valid JSON. Header length: $($Header.Length) characters.") + } + 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 { + throw [System.FormatException]::new("The supplied JWT payload is not valid JSON. Payload length: $($PayloadJson.Length) characters.") + } + + $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')) { + throw [System.ArgumentException]::new('RS256 requires a -Cert parameter of type X509Certificate2.', 'Cert') + } + Write-Verbose "Signing certificate: $($Cert.Subject)" + $rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Cert) + if ($null -eq $rsa) { + throw [System.ArgumentException]::new('The supplied certificate has no RSA private key and cannot be used to sign.', '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]) { + throw [System.ArgumentException]::new("Expected Secret parameter as byte array or string, instead got $($Secret.GetType())", '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 { + throw [System.NotSupportedException]::new('The algorithm is not one of the supported: "RS256", "HS256", "none".') + } + } + + $jwtContent + '.' + $encodedSignature + } + + end {} +} diff --git a/src/functions/public/New-PSModuleTest.ps1 b/src/functions/public/New-PSModuleTest.ps1 deleted file mode 100644 index d4e6e26..0000000 --- a/src/functions/public/New-PSModuleTest.ps1 +++ /dev/null @@ -1,37 +0,0 @@ -#Requires -Modules @{ModuleName='PSSemVer'; ModuleVersion='1.0'} - -function New-PSModuleTest { - <# - .SYNOPSIS - Performs tests on a module. - - .EXAMPLE - Test-PSModule -Name 'World' - - "Hello, World!" - - .NOTES - Testing if a module can have a [Markdown based link](https://example.com). - !"#¤%&/()=?`´^¨*'-_+§½{[]}<>|@£$€¥¢:;.," - \[This is a test\] - #> - [Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSUseShouldProcessForStateChangingFunctions', '', Scope = 'Function', - Justification = 'Reason for suppressing' - )] - [Alias('New-PSModuleTestAlias1')] - [Alias('New-PSModuleTestAlias2')] - [CmdletBinding()] - param ( - # Name of the person to greet. - [Parameter(Mandatory)] - [string] $Name - ) - Write-Output "Hello, $Name!" -} - -New-Alias New-PSModuleTestAlias3 New-PSModuleTest -New-Alias -Name New-PSModuleTestAlias4 -Value New-PSModuleTest - - -Set-Alias New-PSModuleTestAlias5 New-PSModuleTest diff --git a/src/functions/public/Set-PSModuleTest.ps1 b/src/functions/public/Set-PSModuleTest.ps1 deleted file mode 100644 index a87ac11..0000000 --- a/src/functions/public/Set-PSModuleTest.ps1 +++ /dev/null @@ -1,22 +0,0 @@ -function Set-PSModuleTest { - <# - .SYNOPSIS - Performs tests on a module. - - .EXAMPLE - Test-PSModule -Name 'World' - - "Hello, World!" - #> - [Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSUseShouldProcessForStateChangingFunctions', '', Scope = 'Function', - Justification = 'Reason for suppressing' - )] - [CmdletBinding()] - param ( - # Name of the person to greet. - [Parameter(Mandatory)] - [string] $Name - ) - Write-Output "Hello, $Name!" -} diff --git a/src/functions/public/Test-Jwt.ps1 b/src/functions/public/Test-Jwt.ps1 new file mode 100644 index 0000000..445c474 --- /dev/null +++ b/src/functions/public/Test-Jwt.ps1 @@ -0,0 +1,160 @@ +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 { + throw [System.FormatException]::new("The supplied JWT header segment is not valid JSON. Header length: $($header.Length) characters.") + } + 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')) { + throw [System.ArgumentException]::new('RS256 requires a -Cert parameter of type X509Certificate2.', '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) { + throw [System.ArgumentException]::new('The supplied certificate has no RSA public key and cannot be used to verify.', '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]) { + throw [System.ArgumentException]::new("Expected Secret parameter as byte array or string, instead got $($Secret.GetType())", '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 { + throw [System.NotSupportedException]::new('The algorithm is not one of the supported: "RS256", "HS256", "none".') + } + } + } + + end {} +} diff --git a/src/functions/public/Test-PSModuleTest.ps1 b/src/functions/public/Test-PSModuleTest.ps1 deleted file mode 100644 index 26be2b9..0000000 --- a/src/functions/public/Test-PSModuleTest.ps1 +++ /dev/null @@ -1,18 +0,0 @@ -function Test-PSModuleTest { - <# - .SYNOPSIS - Performs tests on a module. - - .EXAMPLE - Test-PSModule -Name 'World' - - "Hello, World!" - #> - [CmdletBinding()] - param ( - # Name of the person to greet. - [Parameter(Mandatory)] - [string] $Name - ) - Write-Output "Hello, $Name!" -} diff --git a/src/header.ps1 b/src/header.ps1 deleted file mode 100644 index cc1fde9..0000000 --- a/src/header.ps1 +++ /dev/null @@ -1,3 +0,0 @@ -[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidLongLines', '', Justification = 'Contains long links.')] -[CmdletBinding()] -param() diff --git a/src/init/initializer.ps1 b/src/init/initializer.ps1 deleted file mode 100644 index 28396fb..0000000 --- a/src/init/initializer.ps1 +++ /dev/null @@ -1,3 +0,0 @@ -Write-Verbose '-------------------------------' -Write-Verbose '--- THIS IS AN INITIALIZER ---' -Write-Verbose '-------------------------------' diff --git a/src/manifest.psd1 b/src/manifest.psd1 index ff720bd..40bd8bb 100644 --- a/src/manifest.psd1 +++ b/src/manifest.psd1 @@ -1,5 +1,14 @@ -# This file always wins! -# Use this file to override any of the framework defaults and generated values. -@{ - ModuleVersion = '0.0.0' +@{ + PrivateData = @{ + PSData = @{ + Tags = @( + 'JWT' + 'JSON' + 'Token' + 'Authentication' + 'Security' + 'PSModule' + ) + } + } } diff --git a/src/modules/OtherPSModule.psm1 b/src/modules/OtherPSModule.psm1 deleted file mode 100644 index 5d6af8e..0000000 --- a/src/modules/OtherPSModule.psm1 +++ /dev/null @@ -1,19 +0,0 @@ -function Get-OtherPSModule { - <# - .SYNOPSIS - Performs tests on a module. - - .DESCRIPTION - A longer description of the function. - - .EXAMPLE - Get-OtherPSModule -Name 'World' - #> - [CmdletBinding()] - param( - # Name of the person to greet. - [Parameter(Mandatory)] - [string] $Name - ) - Write-Output "Hello, $Name!" -} diff --git a/src/scripts/loader.ps1 b/src/scripts/loader.ps1 deleted file mode 100644 index 973735a..0000000 --- a/src/scripts/loader.ps1 +++ /dev/null @@ -1,3 +0,0 @@ -Write-Verbose '-------------------------' -Write-Verbose '--- THIS IS A LOADER ---' -Write-Verbose '-------------------------' diff --git a/src/types/DirectoryInfo.Types.ps1xml b/src/types/DirectoryInfo.Types.ps1xml deleted file mode 100644 index aef538b..0000000 --- a/src/types/DirectoryInfo.Types.ps1xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - System.IO.FileInfo - - - Status - Success - - - - - System.IO.DirectoryInfo - - - Status - Success - - - - diff --git a/src/types/FileInfo.Types.ps1xml b/src/types/FileInfo.Types.ps1xml deleted file mode 100644 index 4cfaf6b..0000000 --- a/src/types/FileInfo.Types.ps1xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - System.IO.FileInfo - - - Age - - ((Get-Date) - ($this.CreationTime)).Days - - - - - diff --git a/src/variables/private/PrivateVariables.ps1 b/src/variables/private/PrivateVariables.ps1 deleted file mode 100644 index f1fc2c3..0000000 --- a/src/variables/private/PrivateVariables.ps1 +++ /dev/null @@ -1,47 +0,0 @@ -$script:HabitablePlanets = @( - @{ - Name = 'Earth' - Mass = 5.97 - Diameter = 12756 - DayLength = 24.0 - }, - @{ - Name = 'Mars' - Mass = 0.642 - Diameter = 6792 - DayLength = 24.7 - }, - @{ - Name = 'Proxima Centauri b' - Mass = 1.17 - Diameter = 11449 - DayLength = 5.15 - }, - @{ - Name = 'Kepler-442b' - Mass = 2.34 - Diameter = 11349 - DayLength = 5.7 - }, - @{ - Name = 'Kepler-452b' - Mass = 5.0 - Diameter = 17340 - DayLength = 20.0 - } -) - -$script:InhabitedPlanets = @( - @{ - Name = 'Earth' - Mass = 5.97 - Diameter = 12756 - DayLength = 24.0 - }, - @{ - Name = 'Mars' - Mass = 0.642 - Diameter = 6792 - DayLength = 24.7 - } -) diff --git a/src/variables/public/Moons.ps1 b/src/variables/public/Moons.ps1 deleted file mode 100644 index dd0f33c..0000000 --- a/src/variables/public/Moons.ps1 +++ /dev/null @@ -1,6 +0,0 @@ -$script:Moons = @( - @{ - Planet = 'Earth' - Name = 'Moon' - } -) diff --git a/src/variables/public/Planets.ps1 b/src/variables/public/Planets.ps1 deleted file mode 100644 index 736584b..0000000 --- a/src/variables/public/Planets.ps1 +++ /dev/null @@ -1,20 +0,0 @@ -$script:Planets = @( - @{ - Name = 'Mercury' - Mass = 0.330 - Diameter = 4879 - DayLength = 4222.6 - }, - @{ - Name = 'Venus' - Mass = 4.87 - Diameter = 12104 - DayLength = 2802.0 - }, - @{ - Name = 'Earth' - Mass = 5.97 - Diameter = 12756 - DayLength = 24.0 - } -) diff --git a/src/variables/public/SolarSystems.ps1 b/src/variables/public/SolarSystems.ps1 deleted file mode 100644 index acbcedf..0000000 --- a/src/variables/public/SolarSystems.ps1 +++ /dev/null @@ -1,17 +0,0 @@ -$script:SolarSystems = @( - @{ - Name = 'Solar System' - Planets = $script:Planets - Moons = $script:Moons - }, - @{ - Name = 'Alpha Centauri' - Planets = @() - Moons = @() - }, - @{ - Name = 'Sirius' - Planets = @() - Moons = @() - } -) diff --git a/tests/Data/TestCases.ps1 b/tests/Data/TestCases.ps1 new file mode 100644 index 0000000..f9e08fe --- /dev/null +++ b/tests/Data/TestCases.ps1 @@ -0,0 +1,36 @@ +@( + @{ + 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 = '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}' + } +) diff --git a/tests/Jwt.Tests.ps1 b/tests/Jwt.Tests.ps1 new file mode 100644 index 0000000..d32242c --- /dev/null +++ b/tests/Jwt.Tests.ps1 @@ -0,0 +1,153 @@ +[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/PSModuleTest.Tests.ps1 b/tests/PSModuleTest.Tests.ps1 deleted file mode 100644 index b856855..0000000 --- a/tests/PSModuleTest.Tests.ps1 +++ /dev/null @@ -1,25 +0,0 @@ -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSReviewUnusedParameter', '', - Justification = 'Required for Pester tests' -)] -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSUseDeclaredVarsMoreThanAssignments', '', - Justification = 'Required for Pester tests' -)] -[CmdletBinding()] -param() - -Describe 'Module' { - It 'Function: Get-PSModuleTest' { - Get-PSModuleTest -Name 'World' | Should -Be 'Hello, World!' - } - It 'Function: New-PSModuleTest' { - New-PSModuleTest -Name 'World' | Should -Be 'Hello, World!' - } - It 'Function: Set-PSModuleTest' { - Set-PSModuleTest -Name 'World' | Should -Be 'Hello, World!' - } - It 'Function: Test-PSModuleTest' { - Test-PSModuleTest -Name 'World' | Should -Be 'Hello, World!' - } -}