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 } } |