Public/Get-GitHubAppToken.ps1

<#
.NOTES
    Requires PowerShell 7+ (pwsh). RSA.ImportFromPem is a .NET 5+ API
    not available in Windows PowerShell 5.1.
#>


# ---------------------------------------------------------------------------
# Get-GitHubAppToken
# Generates a short-lived installation access token for a GitHub App.
#
# Flow:
# 1. Build a JWT (header + payload) signed with the app's RSA private
# key using RS256 (RSASSA-PKCS1-v1_5 + SHA-256). GitHub requires
# this algorithm and limits JWT lifetime to 10 minutes.
# 2. Call POST /app/installations/{id}/access_tokens with the JWT as
# the Bearer token. GitHub validates the JWT and returns a
# short-lived installation access token (1-hour TTL).
#
# The returned Token is a bearer token for Invoke-GitHubApi. Callers
# should refresh it when ExpiresAt is within 5 minutes to avoid
# mid-operation failures.
#
# Security: PrivateKeyPath must point to a file secured on disk.
# The key is loaded into memory only for the duration of the signing
# operation and disposed immediately after.
# ---------------------------------------------------------------------------

function Get-GitHubAppToken {
    [CmdletBinding()]
    param(
        # App ID shown on the GitHub App's settings page under "App ID".
        # Not the client ID.
        [Parameter(Mandatory)]
        [int] $AppId,

        # Installation ID for the target repository or organisation.
        # Visible on the app's installation page in GitHub settings.
        [Parameter(Mandatory)]
        [int] $InstallationId,

        # Path to the RSA private key (.pem) downloaded from the GitHub App
        # settings page. The file must be readable by the current user.
        [Parameter(Mandatory)]
        [string] $PrivateKeyPath,

        # Restrict the token to these repositories only. When omitted, the
        # token covers all repos in the installation. Use to apply least-
        # privilege: a token scoped to one repo cannot touch others even if
        # the installation has broader access.
        [Parameter()]
        [string[]] $Repositories = @(),

        # Restrict the token to a subset of the installation's declared
        # permissions. Keys are GitHub permission names (e.g. 'administration',
        # 'contents'); values are 'read' or 'write'. When omitted, the token
        # carries all permissions granted to the installation.
        [Parameter()]
        [hashtable] $Permissions = @{}
    )

    # GitHub allows a JWT lifetime of up to 10 minutes (600 seconds).
    # The token is built immediately before the API call so it arrives fresh.
    $now = [DateTimeOffset]::UtcNow
    $iat = $now.ToUnixTimeSeconds()
    $exp = $now.AddMinutes(10).ToUnixTimeSeconds()

    # RFC 7515 base64url: standard base64 with + -> -, / -> _, no padding.
    # Inlined as a scriptblock to avoid polluting the module namespace with a
    # private helper function.
    $toB64Url = {
        param([byte[]] $bytes)
        [Convert]::ToBase64String($bytes) `
            -replace '\+', '-' `
            -replace '/',  '_' `
            -replace '=',  ''
    }

    # Header keys must be in the exact order below to match GitHub's parser
    # expectations (alg before typ). Payload key order is not significant
    # for JWT purposes but is kept consistent for readability.
    $headerB64  = & $toB64Url ([Text.Encoding]::UTF8.GetBytes('{"alg":"RS256","typ":"JWT"}'))
    $payloadB64 = & $toB64Url ([Text.Encoding]::UTF8.GetBytes(
        "{`"iat`":$iat,`"exp`":$exp,`"iss`":$AppId}"))

    $signingInput = "$headerB64.$payloadB64"

    # Load the private key, sign, then immediately dispose the RSA object so
    # the key material does not linger in memory beyond this scope.
    $rsa = [Security.Cryptography.RSA]::Create()
    try {
        $rsa.ImportFromPem((Get-Content -Path $PrivateKeyPath -Raw -ErrorAction Stop))
        $sigBytes = $rsa.SignData(
            [Text.Encoding]::UTF8.GetBytes($signingInput),
            [Security.Cryptography.HashAlgorithmName]::SHA256,
            [Security.Cryptography.RSASignaturePadding]::Pkcs1)
    }
    finally {
        $rsa.Dispose()
    }

    $jwt = "$signingInput.$(& $toB64Url $sigBytes)"

    # Build the request body only when scope restrictions are provided.
    # Omitting the body entirely (rather than sending an empty object) matches
    # the GitHub API's documented default behaviour for unscoped tokens.
    $apiParams = @{
        Token    = $jwt
        Endpoint = "app/installations/$InstallationId/access_tokens"
        Method   = 'Post'
    }

    $body = @{}
    if ($Repositories.Count -gt 0) { $body['repositories'] = $Repositories }
    if ($Permissions.Count  -gt 0) { $body['permissions']  = $Permissions  }
    if ($body.Count         -gt 0) { $apiParams['Body']    = $body          }

    $response = Invoke-GitHubApi @apiParams

    [PSCustomObject]@{
        Token     = $response.token
        ExpiresAt = $response.expires_at
    }
}