Tests/New-MsecClientAssertion.Tests.ps1

#Requires -Module Pester
#
# Tests for New-MsecClientAssertion. Signing is delegated to Key Vault (via
# Invoke-MsecKeyVaultSign), so the private key never touches local disk - these
# tests mock that call and verify:
# 1. The JWT header carries alg=RS256, typ=JWT, x5t = base64url(thumbprint).
# 2. The JWT payload carries the right aud/iss/sub.
# 3. The digest passed to KV is SHA-256 of "<header>.<payload>".
# 4. The signature bytes returned by KV land verbatim in the JWT.

BeforeAll {
    $modulePath = Join-Path $PSScriptRoot '..' 'Msec.psm1'
    Import-Module $modulePath -Force -ErrorAction Stop

    # A stable fake SHA-1 thumbprint for x5t header tests.
    $script:TestThumbBytes = [byte[]](1..20)
}

AfterAll {
    Remove-Module Msec -Force -ErrorAction SilentlyContinue
}

Describe 'New-MsecClientAssertion (Key Vault signing)' {
    It 'assembles a 3-part JWT, sends SHA-256 of signing input to KV, and embeds the returned signature' {
        # The expected signature bytes are defined here AND hardcoded inline in the MockWith block
        # below - Pester's Mock script block does not reliably see $script:/closure variables from
        # outside, so inlining the literal is the most robust pattern.
        $signatureFromKv = [byte[]](100..255)

        $result = InModuleScope Msec -Parameters @{ Thumb = $script:TestThumbBytes } {
            param($Thumb)

            $script:CapturedDigest = $null
            Mock Invoke-MsecKeyVaultSign -MockWith {
                $script:CapturedDigest = $Digest
                [byte[]](100..255)
            }

            $jwt = New-MsecClientAssertion `
                -TenantId 'tenant-guid' -ClientId 'client-guid' `
                -VaultName 'kv-test'    -KeyName 'msec-app' `
                -ThumbprintBytes $Thumb

            [pscustomobject]@{ Jwt = $jwt; Digest = $script:CapturedDigest }
        }

        $parts = $result.Jwt -split '\.'
        $parts.Count | Should -Be 3

        $pad = { param($s) $s + ('=' * ((4 - ($s.Length % 4)) % 4)) }
        $b64u = { param($s) ($s.Replace('-', '+').Replace('_', '/')) }

        # Header: alg/typ/x5t
        $headerJson = [Text.Encoding]::UTF8.GetString(
            [Convert]::FromBase64String((& $pad (& $b64u $parts[0]))))
        $header = $headerJson | ConvertFrom-Json
        $header.alg | Should -Be 'RS256'
        $header.typ | Should -Be 'JWT'
        $expectedX5t = [Convert]::ToBase64String($script:TestThumbBytes).TrimEnd('=').Replace('+', '-').Replace('/', '_')
        $header.x5t | Should -Be $expectedX5t

        # Payload: aud/iss/sub
        $payloadJson = [Text.Encoding]::UTF8.GetString(
            [Convert]::FromBase64String((& $pad (& $b64u $parts[1]))))
        $payload = $payloadJson | ConvertFrom-Json
        $payload.iss | Should -Be 'client-guid'
        $payload.sub | Should -Be 'client-guid'
        $payload.aud | Should -Be 'https://login.microsoftonline.com/tenant-guid/oauth2/v2.0/token'

        # Digest passed to KV equals SHA-256 of the signing input ("$header.$payload").
        $sha = [System.Security.Cryptography.SHA256]::Create()
        try {
            $expectedDigest = $sha.ComputeHash([Text.Encoding]::UTF8.GetBytes("$($parts[0]).$($parts[1])"))
        } finally { $sha.Dispose() }
        [Convert]::ToBase64String([byte[]]$result.Digest) | Should -Be ([Convert]::ToBase64String($expectedDigest))

        # Signature bytes returned by KV land verbatim in the JWT (after base64url-decoding).
        $jwtSigBytes = [Convert]::FromBase64String((& $pad (& $b64u $parts[2])))
        [Convert]::ToBase64String($jwtSigBytes) | Should -Be ([Convert]::ToBase64String($signatureFromKv))
    }
}