Providers/Okta/Private/New-OktaRsaKeyPair.ps1

function New-OktaRsaKeyPair {
    <#
    .SYNOPSIS
        Generates an RSA key pair and returns it as a public and a private JWK

    .DESCRIPTION
        The service app authenticates with private_key_jwt, which means Okta holds the public
        key and this machine holds the private one. Okta wants the public half as a JWK, so
        JWK is also the most convenient shape to store the private half in: it survives a
        round trip through JSON, it is what the Okta admin console shows you, and it avoids
        PEM export entirely.

        PEM is avoided on purpose. ExportRSAPrivateKey does not exist on .NET Framework, so a
        PEM path would mean hand-rolling DER encoding to keep Windows PowerShell 5.1 working.
        A private JWK needs no encoding beyond base64url and imports back into RSAParameters
        directly.

        The key is never written to disk by this function. Export-OktaAppCredential owns
        that decision, and the ACL that goes with it.

    .PARAMETER KeySize
        RSA modulus size in bits. Okta accepts 2048 and above.

    .PARAMETER KeyId
        The kid to stamp on both halves. Defaults to a new GUID.

    .OUTPUTS
        PSCustomObject with KeyId, PublicJwk (hashtable, safe to send to Okta) and
        PrivateJwk (hashtable, secret).

    .EXAMPLE
        $keyPair = New-OktaRsaKeyPair
        $keyPair.PublicJwk

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


    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Nothing outside this process is changed; the key is returned, never stored.')]
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter()]
        [ValidateSet(2048, 3072, 4096)]
        [int]$KeySize = 2048,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]$KeyId = [Guid]::NewGuid().ToString()
    )

    $rsa = $null
    try {
        # RSA.Create(int) is the portable call on PowerShell 7 and on .NET Framework 4.7.2,
        # but not on every 4.x that Windows PowerShell 5.1 might be sitting on. Fall back to
        # the concrete CSP type, which has taken a key size in its constructor since .NET 1.
        try {
            $rsa = [System.Security.Cryptography.RSA]::Create($KeySize)
        }
        catch {
            Write-Verbose "RSA::Create(int) unavailable ($($_.Exception.Message)); using RSACryptoServiceProvider."
            $rsa = New-Object System.Security.Cryptography.RSACryptoServiceProvider($KeySize)
        }

        if ($rsa.KeySize -ne $KeySize) {
            throw "Requested a $KeySize-bit key but the provider produced $($rsa.KeySize) bits."
        }

        $parameters = $rsa.ExportParameters($true)

        $publicJwk = [ordered]@{
            kty = 'RSA'
            kid = $KeyId
            use = 'sig'
            alg = 'RS256'
            e   = ConvertTo-TestBase64Url -Bytes $parameters.Exponent
            n   = ConvertTo-TestBase64Url -Bytes $parameters.Modulus
        }

        # The private members are the CRT parameters. Keeping all of them, rather than just
        # d, is what lets ImportParameters reconstruct the key without recomputing anything.
        $privateJwk = [ordered]@{
            kty = 'RSA'
            kid = $KeyId
            use = 'sig'
            alg = 'RS256'
            e   = $publicJwk.e
            n   = $publicJwk.n
            d   = ConvertTo-TestBase64Url -Bytes $parameters.D
            p   = ConvertTo-TestBase64Url -Bytes $parameters.P
            q   = ConvertTo-TestBase64Url -Bytes $parameters.Q
            dp  = ConvertTo-TestBase64Url -Bytes $parameters.DP
            dq  = ConvertTo-TestBase64Url -Bytes $parameters.DQ
            qi  = ConvertTo-TestBase64Url -Bytes $parameters.InverseQ
        }

        return [PSCustomObject]@{
            KeyId      = $KeyId
            KeySize    = $rsa.KeySize
            PublicJwk  = $publicJwk
            PrivateJwk = $privateJwk
        }
    }
    finally {
        if ($rsa -is [System.IDisposable]) { $rsa.Dispose() }
    }
}