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 ) # 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)" # Exchange the signed JWT for an installation access token. # GitHub validates the JWT, checks the installation, and returns # { token, expires_at } scoped to the installation's permissions. $response = Invoke-GitHubApi ` -Token $jwt ` -Endpoint "app/installations/$InstallationId/access_tokens" ` -Method 'Post' [PSCustomObject]@{ Token = $response.token ExpiresAt = $response.expires_at } } |