Modules/businessdev.ALbuild.OnPrem/Public/New-BcApiAuthContext.ps1

function New-BcApiAuthContext {
    <#
    .SYNOPSIS
        Acquires an OAuth2 access token for the Business Central API.
 
    .DESCRIPTION
        Acquires a bearer token for the Business Central REST / automation API from Azure AD using
        one of three flows, selected by the parameters you pass:
 
          * ClientSecret - service-to-service (client_credentials) with an app-registration secret.
          * Certificate - service-to-service (client_credentials) with a signed client_assertion
                           JWT. The signing certificate lives in Azure Key Vault and its private key
                           never leaves the vault (a service principal reads the public certificate
                           and performs the RS256 signature via the Key Vault key 'sign' operation).
          * RefreshToken - exchanges an OAuth2 refresh token (ALbuild V1 parity).
 
        Returns an auth context whose AccessToken is passed to Publish-BcPerTenantExtension (or any
        other Business Central API cmdlet).
 
    .PARAMETER TenantId
        Azure AD tenant id (or verified domain) of the Business Central tenant. Default 'common'.
 
    .PARAMETER ClientId
        Application (client) id to authenticate as. Required for the ClientSecret and Certificate
        flows. For the RefreshToken flow it defaults to the well-known Business Central PowerShell
        client id the V1 task used.
 
    .PARAMETER ClientSecret
        App-registration client secret (SecureString or string). Selects the ClientSecret flow.
 
    .PARAMETER RefreshToken
        OAuth2 refresh token (SecureString or string). Selects the RefreshToken flow.
 
    .PARAMETER KeyVaultUrl
        Key Vault base URL holding the client-assertion certificate. Selects the Certificate flow.
 
    .PARAMETER CertificateName
        Certificate name in Key Vault (Certificate flow).
 
    .PARAMETER KeyVaultTenantId
        Azure AD tenant of the service principal used to access Key Vault. Defaults to -TenantId.
 
    .PARAMETER KeyVaultClientId
        Application (client) id of the service principal used to access Key Vault (Certificate flow).
 
    .PARAMETER KeyVaultClientSecret
        Client secret of the service principal used to access Key Vault (Certificate flow).
 
    .PARAMETER Scope
        OAuth2 scope. Default 'https://api.businesscentral.dynamics.com/.default'. The RefreshToken
        flow additionally requests 'offline_access'.
 
    .EXAMPLE
        $ctx = New-BcApiAuthContext -TenantId $t -ClientId $c -ClientSecret $s
        Publish-BcPerTenantExtension -TenantId $t -Environment 'Production' -AccessToken $ctx.AccessToken -AppFile .\out\My.app
 
    .EXAMPLE
        $ctx = New-BcApiAuthContext -TenantId $t -ClientId $appRegId -KeyVaultUrl $kv -CertificateName 'bc-deploy' `
            -KeyVaultClientId $sp -KeyVaultClientSecret $spSecret
 
    .OUTPUTS
        PSCustomObject with AccessToken, TokenType, ExpiresOn, TenantId, ClientId, Scope.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Acquires an OAuth token; it does not change persistent system state.')]
    [CmdletBinding(DefaultParameterSetName = 'ClientSecret')]
    [OutputType([PSCustomObject])]
    param(
        [string] $TenantId = 'common',

        [Parameter(ParameterSetName = 'ClientSecret', Mandatory)]
        [Parameter(ParameterSetName = 'Certificate', Mandatory)]
        [Parameter(ParameterSetName = 'RefreshToken')]
        [string] $ClientId,

        [Parameter(ParameterSetName = 'ClientSecret', Mandatory)]
        [object] $ClientSecret,

        [Parameter(ParameterSetName = 'RefreshToken', Mandatory)]
        [object] $RefreshToken,

        [Parameter(ParameterSetName = 'Certificate', Mandatory)]
        [string] $KeyVaultUrl,

        [Parameter(ParameterSetName = 'Certificate', Mandatory)]
        [string] $CertificateName,

        [Parameter(ParameterSetName = 'Certificate')]
        [string] $KeyVaultTenantId,

        [Parameter(ParameterSetName = 'Certificate', Mandatory)]
        [string] $KeyVaultClientId,

        [Parameter(ParameterSetName = 'Certificate', Mandatory)]
        [object] $KeyVaultClientSecret,

        [string] $Scope = 'https://api.businesscentral.dynamics.com/.default'
    )

    # No license gate here: acquiring a token is a free utility; the licensed step is the deployment
    # cmdlet that uses the token (e.g. Publish-BcPerTenantExtension).

    # Unwrap a SecureString or pass a plain string through unchanged.
    function Get-PlainText([object] $Value) {
        if ($null -eq $Value) { return $null }
        if ($Value -is [System.Security.SecureString]) {
            return [System.Net.NetworkCredential]::new('', $Value).Password
        }
        return [string] $Value
    }

    $tokenUrl = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"

    switch ($PSCmdlet.ParameterSetName) {
        'ClientSecret' {
            $body = @{
                client_id     = $ClientId
                client_secret = Get-PlainText $ClientSecret
                scope         = $Scope
                grant_type    = 'client_credentials'
            }
        }
        'RefreshToken' {
            $effectiveClientId = if ($ClientId) { $ClientId } else { '1950a258-227b-4e31-a9cf-717495945fc2' }
            $body = @{
                client_id     = $effectiveClientId
                refresh_token = Get-PlainText $RefreshToken
                scope         = "$Scope offline_access"
                grant_type    = 'refresh_token'
            }
            $ClientId = $effectiveClientId
        }
        'Certificate' {
            $kvTenant = if ($KeyVaultTenantId) { $KeyVaultTenantId } else { $TenantId }
            $assertion = New-BcKeyVaultClientAssertion -TenantId $TenantId -ClientId $ClientId `
                -KeyVaultUrl $KeyVaultUrl -CertificateName $CertificateName `
                -KeyVaultTenantId $kvTenant -KeyVaultClientId $KeyVaultClientId `
                -KeyVaultClientSecret (Get-PlainText $KeyVaultClientSecret)
            $body = @{
                client_id             = $ClientId
                scope                 = $Scope
                grant_type            = 'client_credentials'
                client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
                client_assertion      = $assertion
            }
        }
    }

    # Entra ID answers a rejected token request with HTTP 400 and a JSON body that names the actual
    # problem ("error": "invalid_grant", plus an AADSTS<code> description). Invoke-RestMethod shows only
    # "The remote server returned an error: (400) Bad Request.", which made a failed per-tenant release
    # undiagnosable - the reason was in the discarded body all along. Read it and add the remedy for the
    # flow being used.
    try {
        $response = Invoke-RestMethod -Uri $tokenUrl -Method Post -Body $body -ContentType 'application/x-www-form-urlencoded' -ErrorAction Stop
    }
    catch {
        $rawBody = $null
        if ($_.ErrorDetails -and $_.ErrorDetails.Message) { $rawBody = "$($_.ErrorDetails.Message)" }
        else {
            $responseProperty = $_.Exception.PSObject.Properties['Response']
            if ($responseProperty -and $responseProperty.Value) {
                try {
                    $stream = $responseProperty.Value.GetResponseStream()
                    if ($stream) {
                        $reader = New-Object System.IO.StreamReader($stream)
                        try {
                            if ($reader.BaseStream.CanSeek) { $reader.BaseStream.Position = 0 }
                            $rawBody = $reader.ReadToEnd()
                        }
                        finally { $reader.Close() }
                    }
                }
                catch { Write-Verbose "Could not read the token error response body: $($_.Exception.Message)" }
            }
        }

        $errorCode = ''
        $errorText = ''
        if ($rawBody) {
            try {
                $parsed = $rawBody | ConvertFrom-Json
                if ($parsed.PSObject.Properties['error']) { $errorCode = "$($parsed.error)" }
                if ($parsed.PSObject.Properties['error_description']) { $errorText = "$($parsed.error_description)" }
            }
            catch { Write-Verbose "Token error body is not JSON: $($_.Exception.Message)" }
        }

        # Flow-specific remedy: what the operator has to actually do.
        $remedy = switch ($PSCmdlet.ParameterSetName) {
            'RefreshToken' {
                'The refresh token is no longer usable. Refresh tokens expire (90 days of inactivity), are ' +
                'invalidated by a password change / revoked consent / a conditional-access policy, and are ' +
                'rotated on use - a statically stored one stops working. Issue a new refresh token and update ' +
                'the pipeline secret, or move this release to ClientSecret (S2S) authentication, which does not expire that way.'
            }
            'ClientSecret' { 'Check the client secret (they expire) and that the app registration has admin-consented Business Central API permissions.' }
            default { 'Check the certificate in Key Vault (validity + the app registration credential) and the granted API permissions.' }
        }

        throw ("Could not acquire a Business Central API token via $($PSCmdlet.ParameterSetName) for tenant '$TenantId'" +
            $(if ($errorCode) { " - $errorCode" }) + ': ' +
            $(if ($errorText) { $errorText } else { "$($_.Exception.Message)" }) +
            "`n$remedy" +
            $(if (-not $errorText -and $rawBody) { "`nEntra ID response: $($rawBody.Trim())" }))
    }

    # Read fields defensively so a partial/error response yields a clear message under Set-StrictMode.
    function Get-ResponseField([object] $Object, [string] $Name) {
        $prop = $Object.PSObject.Properties[$Name]
        if ($prop) { $prop.Value } else { $null }
    }
    $accessToken = Get-ResponseField $response 'access_token'
    if (-not $accessToken) { throw 'Azure AD did not return an access token.' }
    $expiresIn = Get-ResponseField $response 'expires_in'

    Write-ALbuildLog -Level Success "Acquired Business Central API token ($($PSCmdlet.ParameterSetName))."
    return [PSCustomObject]@{
        AccessToken = $accessToken
        TokenType   = Get-ResponseField $response 'token_type'
        ExpiresOn   = if ($expiresIn) { (Get-Date).AddSeconds([int]$expiresIn) } else { $null }
        TenantId    = $TenantId
        ClientId    = $ClientId
        Scope       = $Scope
    }
}