Public/Get-DataverseAuthToken.ps1

function Get-DataverseAuthToken {
    <#
    .SYNOPSIS
        Obtains an authentication token for Dataverse API access.
    .DESCRIPTION
        The Get-DataverseAuthToken function obtains an OAuth access token for authenticating with the Dataverse API.
        It supports service principal authentication using the Microsoft Identity Platform v2.0 endpoint and returns a formatted token object.
    .PARAMETER TenantId
        The Azure AD tenant ID.
    .PARAMETER Url
        The URL of the Power Platform environment. For example: https://myorg.crm.dynamics.com
    .PARAMETER ClientId
        The Application/Client ID for authentication.
    .PARAMETER ClientSecret
        The Client secret for service principal authentication.
    .EXAMPLE
        $token = Get-DataverseAuthToken -TenantId "00000000-0000-0000-0000-000000000000" -Url "https://myorg.crm.dynamics.com" -ClientId "00000000-0000-0000-0000-000000000000" -ClientSecret "mySecret"
    .NOTES
        This function returns a formatted token object with AccessToken, TokenType, ExpiresIn, and ExpiresOn properties.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$TenantId,
        
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$Url,
        
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$ClientId,
        
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$ClientSecret
    )
    Write-Verbose "Starting Get-DataverseAuthToken for URL: $Url"
    
    # Normalize URL (remove trailing slash if present)
    if ($Url.EndsWith("/")) {
        $Url = $Url.TrimEnd("/")
        Write-Verbose "Normalized URL: $Url"
    }
    try {
        # Prepare token request - using v2 endpoint
        $tokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
        $scope = "https://$(([System.Uri]$Url).Host)/.default"
        
        Write-Verbose "Requesting token from: $tokenEndpoint"
        Write-Verbose "Scope: $scope"
        
        # Create body for token request
        $body = @{
            grant_type    = "client_credentials"
            client_id     = $ClientId
            client_secret = $ClientSecret
            scope         = $scope
        }        
        
        # Make the request
        $response = Invoke-RestMethod -Uri $tokenEndpoint -Method Post -Body $body -ContentType "application/x-www-form-urlencoded" -ErrorAction Stop
        
        # Calculate the expiration time
        $tokenExpiresOn = (Get-Date).AddSeconds($response.expires_in)
        
        # Format and return the token response as a custom object
        return [PSCustomObject]@{
            AccessToken = $response.access_token
            TokenType   = $response.token_type
            ExpiresIn   = $response.expires_in
            ExpiresOn   = $tokenExpiresOn
        }
    }
    catch {
        # Check if the error response contains a JSON payload
        if ($_.Exception.Response) {
            try {
                # Extract the error details from the response
                $errorDetails = $_.ErrorDetails
                if ($null -eq $errorDetails) {
                    # For some errors, the error details might be in the response stream
                    $reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream())
                    $reader.BaseStream.Position = 0
                    $reader.DiscardBufferedData()
                    $responseContent = $reader.ReadToEnd()
                    $errorObject = $responseContent | ConvertFrom-Json
                } 
                else {
                    $errorObject = $errorDetails.Message | ConvertFrom-Json
                }

                # Format a detailed error message
                $detailedError = "Authentication Error Code: $($errorObject.error)`n"
                $detailedError += "Description: $($errorObject.error_description)`n"
                
                if ($errorObject.correlation_id) {
                    $detailedError += "Correlation ID: $($errorObject.correlation_id)`n"
                }
                
                if ($errorObject.trace_id) {
                    $detailedError += "Trace ID: $($errorObject.trace_id)`n"
                }
                # Add specific guidance based on error type
                switch -Regex ($errorObject.error) {
                    "invalid_scope" {
                        $detailedError += "Guidance: Verify the resource URL format. For client credential flows, ensure the resource URL is correctly formatted and the requested permissions are allowed for this application."
                    }
                    "invalid_client" {
                        $detailedError += "Guidance: Check that you're using the correct client ID and client secret value (not the secret ID) and that the secret hasn't expired."
                    }
                    "unauthorized_client" {
                        $detailedError += "Guidance: Your application is not authorized to use this authentication flow. Verify the app is registered for client credentials grant type in Azure AD."
                    }
                    "invalid_grant" {
                        $detailedError += "Guidance: The authorization grant is invalid, expired, or revoked. You may need to request a new authorization."
                    }
                    "invalid_request" {
                        $detailedError += "Guidance: The request is missing a required parameter, includes an invalid parameter value, or is otherwise malformed."
                    }
                    "unsupported_grant_type" {
                        $detailedError += "Guidance: The authorization grant type is not supported by the authorization server or is incorrectly specified."
                    }
                    "access_denied" {
                        $detailedError += "Guidance: The resource owner or authorization server denied the request. Verify the application has the necessary permissions."
                    }
                    default {
                        $detailedError += "Guidance: Please review your authentication parameters and try again."
                    }
                }
                
                Write-Error $detailedError
                throw $detailedError
            }
            catch {
                # Fallback if we can't parse the error response
                $errorMessage = "Failed to obtain authentication token (400 Bad Request): $($_.Exception.Message)"
                Write-Error $errorMessage
                throw $errorMessage
            }
        }
        else {
            # Handle non-400 errors
            $errorMessage = "Failed to obtain authentication token: $($_.Exception.Message)"
            Write-Error $errorMessage
            throw $errorMessage
        }
    }
}