Private/Get-PckAccessToken.ps1

function Get-PckAccessToken {
    <#
    .SYNOPSIS
    Acquires a bearer token for the given resource. Never reads pac auth state.

    .DESCRIPTION
    Implements the token-source decision in design 12.8. Mode selection is implicit:
    a complete PCK_SPN_* set means CI, anything else means dev.

    CI mode: client credentials against the Entra token endpoint. No dependency
    beyond HTTPS.

    Dev mode, first match wins:
      1. PCK_ACCESS_TOKEN (explicit, always wins)
      2. az account get-access-token, if the Azure CLI is on the path
      3. Get-AzAccessToken from Az.Accounts, if the module is present
      4. Hard error naming all three.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $Resource
    )

    $Resource = $Resource.TrimEnd('/')

    $spn = @($env:PCK_SPN_TENANT, $env:PCK_SPN_APP_ID, $env:PCK_SPN_SECRET)
    $spnPresent = @($spn | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })

    if ($spnPresent.Count -eq 3) {
        $body = @{
            grant_type    = 'client_credentials'
            client_id     = $env:PCK_SPN_APP_ID
            client_secret = $env:PCK_SPN_SECRET
            scope         = "$Resource/.default"
        }
        $resp = Invoke-RestMethod -Method Post `
            -Uri "https://login.microsoftonline.com/$($env:PCK_SPN_TENANT)/oauth2/v2.0/token" `
            -Body $body
        return @{
            AccessToken = $resp.access_token
            ExpiresOn   = (Get-Date).AddSeconds([int]$resp.expires_in)
        }
    }
    if ($spnPresent.Count -gt 0) {
        throw [PckPreflightError]::new(
            'CI mode is selected by the PCK_SPN_* variables, but the set is incomplete. All three of PCK_SPN_TENANT, PCK_SPN_APP_ID, and PCK_SPN_SECRET are required.',
            $script:PckExitCode.TokenUnavailable)
    }

    if (-not [string]::IsNullOrWhiteSpace($env:PCK_ACCESS_TOKEN)) {
        return @{
            AccessToken = $env:PCK_ACCESS_TOKEN
            ExpiresOn   = Get-PckJwtExpiry -Token $env:PCK_ACCESS_TOKEN
        }
    }

    if (Get-Command -Name az -CommandType Application -ErrorAction Ignore) {
        $raw = az account get-access-token --resource $Resource --output json 2>$null
        if ($LASTEXITCODE -eq 0 -and $raw) {
            $tok = ($raw -join "`n") | ConvertFrom-Json
            $expires = if ($tok.PSObject.Properties['expires_on'] -and $tok.expires_on) {
                [DateTimeOffset]::FromUnixTimeSeconds([long]$tok.expires_on).LocalDateTime
            }
            else {
                (Get-Date).AddMinutes(25)
            }
            return @{ AccessToken = $tok.accessToken; ExpiresOn = $expires }
        }
        Write-Verbose 'The Azure CLI is present but returned no token; falling through to Az.Accounts.'
    }

    if (Get-Module -ListAvailable -Name Az.Accounts) {
        Import-Module Az.Accounts -ErrorAction Stop
        $azTok = Get-AzAccessToken -ResourceUrl $Resource -ErrorAction Stop
        $plain = if ($azTok.Token -is [securestring]) {
            ConvertFrom-SecureString -SecureString $azTok.Token -AsPlainText
        }
        else {
            [string]$azTok.Token
        }
        return @{ AccessToken = $plain; ExpiresOn = $azTok.ExpiresOn.LocalDateTime }
    }

    throw [PckPreflightError]::new(
        'No token source available. Provide one of: PCK_ACCESS_TOKEN, a signed-in Azure CLI (az login), or the Az.Accounts module (Connect-AzAccount). CI mode uses PCK_SPN_TENANT, PCK_SPN_APP_ID, and PCK_SPN_SECRET instead.',
        $script:PckExitCode.TokenUnavailable)
}