Skip to content

Implement JWT creation and signing functionality #3

@MariusStorhaug

Description

Automation scripts and PowerShell modules that need to authenticate via JWT (e.g., as a GitHub App) currently depend on the full GitHub module or must implement JWT handling from scratch. The JWT creation and signing logic in the GitHub module is general-purpose and not specific to the GitHub API.

Request

Desired capability

A standalone Jwt module that provides functions and classes for creating, signing, and representing JSON Web Tokens per RFC 7519. The module should support:

  • Creating unsigned JWTs from arbitrary headers and claims
  • Signing JWTs with a local RSA private key (RS256)
  • Signing JWTs via Azure Key Vault REST API
  • Representing JWT components (header, payload, signature) as typed PowerShell classes
  • Displaying the encoded JWT string when the token object is printed

The GitHub module currently contains this logic in the following files:

Current location in GitHub module Purpose
GitHubJWTComponent Base64URL encoding utilities
New-GitHubUnsignedJWT Creates unsigned JWT (header.payload)
Add-GitHubLocalJWTSignature Signs JWT using local RSA private key
Add-GitHubKeyVaultJWTSignature Signs JWT via Azure Key Vault
Update-GitHubAppJWT Orchestrates JWT creation and refresh (GitHub-specific, stays in GitHub module)
Test-GitHubJWTRefreshRequired Checks if JWT needs refresh (GitHub-specific, stays in GitHub module)

Acceptance criteria

  • The module exports a public function for creating signed JWTs with configurable headers and claims
  • RSA signing with a local PEM private key is supported (RS256 algorithm)
  • Azure Key Vault signing is supported via the Key Vault Sign REST API
  • JWT components are represented as PowerShell classes with proper typing
  • A JWT object's ToString() method returns the encoded header.payload.signature string
  • The skeleton/template code from the PSModule framework scaffolding (Book, Planets, etc.) is removed
  • The module can be consumed independently without the GitHub module

Technical decisions

Skeleton cleanup: The module repository currently contains template/placeholder code from the PSModule framework scaffolding (Book class, Planets variables, PSModuleTest functions, etc.). All template content is removed before implementing JWT functionality. The directory structure (src/classes/, src/functions/public/, src/functions/private/) is retained.

Function naming: Functions follow the Verb-Jwt* naming convention, consistent with the PSModule pattern of prefixing nouns with the module name:

Function Visibility Purpose
New-Jwt Public Creates a signed JWT from header, claims, and a signing key
New-JwtUnsigned Private Builds the unsigned token (header.payload) from header and claims hashtables
Add-JwtLocalSignature Private Signs using a local RSA private key (PEM format)
Add-JwtKeyVaultSignature Private Signs using Azure Key Vault REST API

Class design:

Class Purpose
JwtBase64Url Static utility methods for Base64URL encoding/decoding per RFC 4648 §5
JwtHeader Represents the JWT header (alg, typ, custom fields)
JwtPayload Represents the JWT payload/claims (iat, exp, iss, custom claims)
Jwt Represents a complete JWT token; ToString() returns the encoded header.payload.signature string

Signing approach: The module supports two signing methods, selected via parameter sets on New-Jwt:

  1. Local RSA — uses [System.Security.Cryptography.RSA]::Create() and ImportFromPem() for RS256 signing with PKCS1 padding (same approach as the existing Add-GitHubLocalJWTSignature)
  2. Azure Key Vault — calls the Key Vault Sign REST API (API version 7.4) using a bearer token obtained from Azure CLI or Az PowerShell (same approach as Add-GitHubKeyVaultJWTSignature)

Return type: New-Jwt returns a [Jwt] object rather than a [securestring]. The Jwt class stores the token components and exposes the full encoded token via ToString(). Consumers that need a securestring can convert explicitly. This departs from the GitHub module's approach (which returns securestring) — the rationale is that a typed object is more useful for inspection, debugging, and pipeline composition.

Azure dependency handling: The Key Vault signing path requires Azure authentication (Azure CLI or Az PowerShell). These are runtime-only dependencies — the module does not declare a module dependency on Az.Accounts. The function checks for availability at call time and provides a clear error if neither is available, matching the existing pattern in the GitHub module.

What stays in the GitHub module: The JWT refresh orchestration (Update-GitHubAppJWT) and refresh check (Test-GitHubJWTRefreshRequired) are GitHub-context-specific and remain in the GitHub module. A follow-up issue in the GitHub repository will update those functions to consume the Jwt module. The GitHub-specific claims (hardcoded iss = ClientID, fixed ±10 minute window) will move to the GitHub module's call site as parameters passed to New-Jwt.

Algorithm support: Initial implementation supports RS256 only, matching the current GitHub module implementation. Additional algorithms (HS256, RS384, RS512, ES256, etc.) can be added in future issues — see issue #2 for inspiration from existing PowerShell JWT modules. The JwtHeader class stores the alg field for forward compatibility.

Test approach: Unit tests with Pester. Test scenarios: creating unsigned JWTs, signing with a test RSA key pair, verifying Base64URL encoding/decoding, and validating the Jwt class ToString() output format.


Implementation plan

Skeleton cleanup

  • Remove template classes (Book.ps1, SecretWriter.ps1)
  • Remove template functions (Get-PSModuleTest.ps1, New-PSModuleTest.ps1, Set-PSModuleTest.ps1, Test-PSModuleTest.ps1, Get-InternalPSModule.ps1, Set-InternalPSModule.ps1)
  • Remove template variables (Moons.ps1, Planets.ps1, SolarSystems.ps1, PrivateVariables.ps1)
  • Remove template data files, formats, types, modules, scripts, init, and assemblies
  • Remove template test file (PSModuleTest.Tests.ps1)
  • Clean up header.ps1 and finally.ps1

Classes

  • Create JwtBase64Url class in src/classes/public/JwtBase64Url.ps1 with static Encode and ConvertToBase64UrlFormat methods
  • Create JwtHeader class in src/classes/public/JwtHeader.ps1
  • Create JwtPayload class in src/classes/public/JwtPayload.ps1
  • Create Jwt class in src/classes/public/Jwt.ps1 with Header, Payload, Signature properties and ToString() returning the encoded token

Functions

  • Create New-Jwt public function in src/functions/public/New-Jwt.ps1 with parameter sets for local RSA and Key Vault signing
  • Create New-JwtUnsigned private function in src/functions/private/New-JwtUnsigned.ps1
  • Create Add-JwtLocalSignature private function in src/functions/private/Add-JwtLocalSignature.ps1
  • Create Add-JwtKeyVaultSignature private function in src/functions/private/Add-JwtKeyVaultSignature.ps1

Module configuration

  • Update src/manifest.psd1 with module metadata (description, tags, project URI)
  • Update src/README.md with module description
  • Update root README.md with usage documentation and examples

Tests

  • Create tests/Jwt.Tests.ps1 with unit tests for JWT creation, signing, and class behavior

Follow-up (separate issues)

  • Update the GitHub module to depend on Jwt and replace inline JWT logic (tracked in the GitHub repository)
  • Add JWT decoding/parsing functions (ConvertFrom-Jwt) — see issue #2 for inspiration
  • Add JWT validation functions (Test-Jwt)
  • Add support for additional signing algorithms (HS256, ES256, etc.)

Metadata

Metadata

Labels

FeatureFeature requestsMinorNew features or enhancements

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions