Private/EAI/Request-EAIAccessToken.ps1

function Request-EAIAccessToken {
    <#
    .SYNOPSIS
    Exchanges Edwin client credentials for a Bearer access token.

    .DESCRIPTION
    POSTs to /auth/token using OAuth2 client_credentials grant.
    Does not use Invoke-EAIRestMethod to avoid circular authentication.
    #>

    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', Justification = 'Required to store API access tokens securely')]
    param(
        [Parameter(Mandatory)]
        [String]$EdwinOrg,

        [Parameter(Mandatory)]
        [String]$ClientId,

        [Parameter(Mandatory)]
        [SecureString]$ClientSecret,

        [System.Management.Automation.PSCmdlet]$CallerPSCmdlet
    )

    $uri = "https://$EdwinOrg.dexda.ai/auth/token"
    $clientSecretPlain = [System.Net.NetworkCredential]::new('', $ClientSecret).Password
    $body = "grant_type=client_credentials&client_id=$([uri]::EscapeDataString($ClientId))&client_secret=$([uri]::EscapeDataString($clientSecretPlain))"

    try {
        $response = Invoke-RestMethod -Uri $uri -Method POST -Body $body -ContentType 'application/x-www-form-urlencoded' -TimeoutSec 30 -ErrorAction Stop

        if ([string]::IsNullOrWhiteSpace($response.access_token)) {
            throw 'Edwin token response did not include an access_token.'
        }

        $expiresAt = $null
        if ($null -ne $response.PSObject.Properties['expires_at'] -and $response.expires_at) {
            $expiresAt = [DateTimeOffset]::FromUnixTimeMilliseconds([long]$response.expires_at).UtcDateTime
        }
        else {
            $expiresIn = 1800
            if ($null -ne $response.PSObject.Properties['expires_in'] -and $response.expires_in) {
                $expiresIn = [int]$response.expires_in
            }

            $expiresAt = (Get-Date).ToUniversalTime().AddSeconds($expiresIn)
        }

        return [PSCustomObject]@{
            AccessToken = ($response.access_token | ConvertTo-SecureString -AsPlainText -Force)
            ExpiresAt   = $expiresAt
        }
    }
    catch {
        if ($_.Exception -is [EAIException] -or $_.FullyQualifiedErrorId -like 'EAI.*') {
            throw $_
        }

        $errorDetails = Get-EAIHttpErrorDetails -ErrorRecord $_
        $resolvedError = Resolve-EAIException -StatusCode $errorDetails.StatusCode -ResponseBody $errorDetails.Body

        if ($errorDetails.StatusCode -eq 401) {
            $traceIdSuffix = ''
            if (-not [string]::IsNullOrWhiteSpace($errorDetails.Body)) {
                try {
                    $parsed = $errorDetails.Body | ConvertFrom-Json -ErrorAction Stop
                    if ($null -ne $parsed.PSObject.Properties['id'] -and -not [string]::IsNullOrWhiteSpace([string]$parsed.id)) {
                        $traceIdSuffix = " (trace id: $($parsed.id))"
                    }
                }
                catch {
                }
            }

            $resolvedError.Message = "Invalid Edwin credentials for portal '$EdwinOrg'. Verify EdwinOrg, ClientId, and ClientSecret.$traceIdSuffix"
        }

        $errorRecord = New-EAIErrorRecord -ResolvedError $resolvedError -Uri $uri

        if ($CallerPSCmdlet) {
            $CallerPSCmdlet.ThrowTerminatingError($errorRecord)
        }

        throw $errorRecord
    }
}