Private/Get-AACAppToken.ps1
|
function Get-AACAppToken { <# .SYNOPSIS Gets an app-only access token - for a service principal (a client secret or a certificate) or a managed identity - for one Azure API. .DESCRIPTION The sign-ins with no user and no refresh token (Connect-AAC -ClientSecret, -CertificatePath / -CertificateThumbprint, -Identity) ask for a new token each time one is needed; Get-AACAccessToken keeps it until it nears expiry. ClientSecret the client credentials grant with the secret Certificate the client credentials grant with a signed client assertion (RS256, the certificate's SHA-1 thumbprint as x5t) - the private key never leaves the machine ManagedIdentity the identity endpoint of where this runs: Azure Automation, App Service and Functions (IDENTITY_ENDPOINT + IDENTITY_HEADER), Cloud Shell (MSI_ENDPOINT), or a VM's instance metadata service (169.254.169.254); -ClientId picks a user-assigned identity -Resource is the API's audience, e.g. https://management.azure.com. Returns @{ AccessToken; ExpiresOn; Claims }. #> [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)] [ValidateSet('ClientSecret', 'Certificate', 'ManagedIdentity')] [string] $Flow, [string] $TenantId, [string] $ClientId, [securestring] $ClientSecret, [System.Security.Cryptography.X509Certificates.X509Certificate2] $Certificate, [string] $Resource = 'https://management.azure.com' ) $ProgressPreference = 'SilentlyContinue' $audience = $Resource.TrimEnd('/') $reasonOf = { param($ErrorRecord) $reason = $ErrorRecord.Exception.Message if ($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) { $details = $ErrorRecord.ErrorDetails.Message | ConvertFrom-Json -ErrorAction Ignore $text = if ($details) { @((Get-AACPropertyValue -InputObject $details -Name 'error_description'), (Get-AACPropertyValue -InputObject $details -Name 'message'), (Get-AACPropertyValue -InputObject $details -Name 'error')) | Where-Object { $_ } | Select-Object -First 1 } else { $null } if ($text) { $reason = ([string]$text -split '\r?\n')[0] } } $reason.TrimEnd('.') } if ($Flow -eq 'ManagedIdentity') { $query = "resource=$([uri]::EscapeDataString($audience))$(if ($ClientId) { "&client_id=$([uri]::EscapeDataString($ClientId))" })" $attempts = [System.Collections.Generic.List[hashtable]]::new() if ($env:IDENTITY_ENDPOINT -and $env:IDENTITY_HEADER) { # App Service and Functions want an api-version; Azure Automation's # endpoint is asked the way its documentation shows, without one. $headers = @{ 'X-IDENTITY-HEADER' = $env:IDENTITY_HEADER; Metadata = 'true' } $attempts.Add(@{ Uri = "$($env:IDENTITY_ENDPOINT)?$query&api-version=2019-08-01"; Headers = $headers; Where = 'the managed identity endpoint (IDENTITY_ENDPOINT)' }) $attempts.Add(@{ Uri = "$($env:IDENTITY_ENDPOINT)?$query"; Headers = $headers; Where = 'the managed identity endpoint (IDENTITY_ENDPOINT)' }) } elseif ($env:MSI_ENDPOINT) { $attempts.Add(@{ Uri = "$($env:MSI_ENDPOINT)?$query"; Headers = @{ Metadata = 'true' }; Where = 'the managed identity endpoint (MSI_ENDPOINT)' }) } else { $attempts.Add(@{ Uri = "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&$query"; Headers = @{ Metadata = 'true' }; Where = 'the instance metadata service (169.254.169.254)' }) } $response = $null $failure = '' foreach ($attempt in $attempts) { try { $response = Invoke-RestMethod -Uri $attempt.Uri -Headers $attempt.Headers -Method Get -TimeoutSec 30 -ErrorAction Stop -Verbose:$false break } catch { $failure = "$($attempt.Where): $(& $reasonOf $_)" } } if (-not $response) { $problem = [System.InvalidOperationException]::new("Could not get a managed identity token for ${audience} from $failure.") $problem.Data['AACHint'] = 'Run this where a managed identity is available - an Azure Automation account, a VM, App Service or Functions with an identity turned on - and, for a user-assigned identity, pass its client ID with -ClientId.' throw $problem } } else { if (-not $TenantId -or $TenantId -in 'organizations', 'common', 'consumers') { throw 'A service principal signs in to one tenant: pass -TenantId (its ID or domain).' } $endpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" $body = @{ grant_type = 'client_credentials'; client_id = $ClientId; scope = "$audience/.default" } if ($Flow -eq 'ClientSecret') { $body.client_secret = [System.Net.NetworkCredential]::new('', $ClientSecret).Password } else { # A client assertion: a JWT signed with the certificate's private key. $now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() $header = @{ alg = 'RS256'; typ = 'JWT'; x5t = (ConvertTo-AACBase64Url -Bytes $Certificate.GetCertHash()) } | ConvertTo-Json -Compress $claims = [ordered]@{ aud = $endpoint; iss = $ClientId; sub = $ClientId; jti = [guid]::NewGuid().ToString(); nbf = $now - 60; iat = $now - 60; exp = $now + 600 } | ConvertTo-Json -Compress $unsigned = "$(ConvertTo-AACBase64Url -Bytes ([System.Text.Encoding]::UTF8.GetBytes($header))).$(ConvertTo-AACBase64Url -Bytes ([System.Text.Encoding]::UTF8.GetBytes($claims)))" $key = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Certificate) if (-not $key) { throw "The certificate $($Certificate.Subject) has no RSA private key this process can use." } $signature = $key.SignData([System.Text.Encoding]::UTF8.GetBytes($unsigned), [System.Security.Cryptography.HashAlgorithmName]::SHA256, [System.Security.Cryptography.RSASignaturePadding]::Pkcs1) $body.client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' $body.client_assertion = "$unsigned.$(ConvertTo-AACBase64Url -Bytes $signature)" } try { $response = Invoke-RestMethod -Uri $endpoint -Method Post -Body $body -ContentType 'application/x-www-form-urlencoded' -ErrorAction Stop -Verbose:$false } catch { $problem = [System.InvalidOperationException]::new("The service principal $ClientId could not get a token for ${audience}: $(& $reasonOf $_).") $problem.Data['AACHint'] = 'Check the tenant, the application (client) ID and its secret or certificate in Entra ID > App registrations, and that the service principal has Reader on what you assess.' throw $problem } } $token = [string](Get-AACPropertyValue -InputObject $response -Name 'access_token') $expiresIn = Get-AACPropertyValue -InputObject $response -Name 'expires_in' $expiresOn = Get-AACPropertyValue -InputObject $response -Name 'expires_on' $expiry = if ($expiresIn) { (Get-Date).AddSeconds([int]$expiresIn) } elseif ($expiresOn -as [long]) { [DateTimeOffset]::FromUnixTimeSeconds([long]$expiresOn).LocalDateTime } else { (Get-Date).AddMinutes(30) } @{ AccessToken = $token; ExpiresOn = $expiry; Claims = (ConvertFrom-AACJwt -Token $token) } } |