Providers/Okta/Private/New-OktaClientAssertion.ps1

function New-OktaClientAssertion {
    <#
    .SYNOPSIS
        Builds the signed JWT the service app presents instead of a client secret

    .DESCRIPTION
        Okta's private_key_jwt client authentication wants an RS256-signed JWT whose issuer
        and subject are both the client_id and whose audience is the org token endpoint. This
        assembles that JWT from the private JWK produced by New-OktaRsaKeyPair.

        Written by hand rather than pulled from a JWT library because the whole point of this
        module is that it installs nothing. Requiring a gallery module just to get an access
        token would undercut that, and the signing itself is a dozen lines of in-box .NET.

        The lifetime is short on purpose. The assertion is single use in practice, Okta
        rejects anything longer than an hour, and there is no reason for the window to be
        wider than one request.

    .PARAMETER PrivateJwk
        The private JWK, either as a hashtable or as the object parsed out of the saved
        credential file

    .PARAMETER ClientId
        The service app client_id, used as both iss and sub

    .PARAMETER Audience
        The token endpoint URL, which is what Okta checks aud against

    .PARAMETER LifetimeSeconds
        How long the assertion stays valid. Okta's ceiling is one hour.

    .OUTPUTS
        String containing the compact-serialised JWT

    .EXAMPLE
        $assertion = New-OktaClientAssertion -PrivateJwk $jwk -ClientId $clientId `
            -Audience 'https://trial-123456.okta.com/oauth2/v1/token'

    .NOTES
        Author: Jeffrey Stuhr
        Version: 1.0.0
        Last Updated: 2026-08-07
    #>


    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Builds and returns a string. Nothing is created, sent or persisted.')]
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true)]
        [object]$PrivateJwk,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$ClientId,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$Audience,

        [Parameter()]
        [ValidateRange(30, 3600)]
        [int]$LifetimeSeconds = 300
    )

    foreach ($member in @('n', 'e', 'd', 'p', 'q', 'dp', 'dq', 'qi')) {
        if (-not $PrivateJwk.$member) {
            throw ("The private JWK is missing the '$member' member, so it is a public key " +
                'rather than a private one. Re-run New-OktaServiceApp to mint a new pair.')
        }
    }

    $rsa = $null
    try {
        $parameters = New-Object System.Security.Cryptography.RSAParameters
        $parameters.Modulus  = ConvertFrom-TestBase64Url -Text $PrivateJwk.n
        $parameters.Exponent = ConvertFrom-TestBase64Url -Text $PrivateJwk.e
        $parameters.D        = ConvertFrom-TestBase64Url -Text $PrivateJwk.d
        $parameters.P        = ConvertFrom-TestBase64Url -Text $PrivateJwk.p
        $parameters.Q        = ConvertFrom-TestBase64Url -Text $PrivateJwk.q
        $parameters.DP       = ConvertFrom-TestBase64Url -Text $PrivateJwk.dp
        $parameters.DQ       = ConvertFrom-TestBase64Url -Text $PrivateJwk.dq
        $parameters.InverseQ = ConvertFrom-TestBase64Url -Text $PrivateJwk.qi

        try {
            $rsa = [System.Security.Cryptography.RSA]::Create()
        }
        catch {
            Write-Verbose "RSA::Create() unavailable ($($_.Exception.Message)); using RSACryptoServiceProvider."
            $rsa = New-Object System.Security.Cryptography.RSACryptoServiceProvider
        }
        $rsa.ImportParameters($parameters)

        $header = [ordered]@{ alg = 'RS256'; typ = 'JWT' }
        if ($PrivateJwk.kid) { $header.kid = $PrivateJwk.kid }

        $issuedAt = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
        $payload = [ordered]@{
            iss = $ClientId
            sub = $ClientId
            aud = $Audience
            iat = $issuedAt
            exp = $issuedAt + $LifetimeSeconds
            jti = [Guid]::NewGuid().ToString()
        }

        $encodedHeader = ConvertTo-TestBase64Url -Bytes (
            [System.Text.Encoding]::UTF8.GetBytes(($header | ConvertTo-Json -Compress)))
        $encodedPayload = ConvertTo-TestBase64Url -Bytes (
            [System.Text.Encoding]::UTF8.GetBytes(($payload | ConvertTo-Json -Compress)))

        $signingInput = "$encodedHeader.$encodedPayload"
        $signature = $rsa.SignData(
            [System.Text.Encoding]::ASCII.GetBytes($signingInput),
            [System.Security.Cryptography.HashAlgorithmName]::SHA256,
            [System.Security.Cryptography.RSASignaturePadding]::Pkcs1)

        return "$signingInput.$(ConvertTo-TestBase64Url -Bytes $signature)"
    }
    finally {
        if ($rsa -is [System.IDisposable]) { $rsa.Dispose() }
    }
}