Private/_Auth.ps1

function String-ContainsValue {
    param(
        [AllowNull()]
        [string] $Value
    )

    return (-not [string]::IsNullOrWhiteSpace($Value))
}

function Get-PPDOPacPath {
    param(
        [string] $PacPath
    )

    if (String-ContainsValue $PacPath) {
        return $PacPath
    }

    return "$env:APPDATA\Capgemini.PowerPlatform.DevOps\PACTools\tools\pac.exe"
}

function Invoke-PPDOExternalCommand {
    param(
        [Parameter(Mandatory = $true)]
        [string] $FilePath,

        [Parameter(Mandatory = $false)]
        [string[]] $Arguments = @(),

        [Parameter(Mandatory = $false)]
        [switch] $ThrowOnError
    )

    Write-Verbose ("Running: {0} {1}" -f $FilePath, ($Arguments -join ' '))

    $output = & $FilePath @Arguments
    $exitCode = $LASTEXITCODE

    if ($ThrowOnError -and $exitCode -ne 0) {
        throw ("Command failed with exit code {0}: {1} {2}`n{3}" -f $exitCode, $FilePath, ($Arguments -join ' '), ($output -join [Environment]::NewLine))
    }

    return $output
}

function Resolve-PPDOAuthContext {
    [CmdletBinding()]
    param(
        [string] $ServerUrl = $global:devops_ServerUrl,
        [string] $UserName,
        [string] $Password,
        [string] $TenantId = $global:devops_TenantID,
        [ValidateSet('Auto', 'ServicePrincipal', 'User', 'ManagedIdentity', 'AzCredential')]
        [string] $AuthType = 'Auto',
        [bool] $UseClientSecret = $false,
        [bool] $UseManagedIdentity = $false,
        [string] $PacProfileName = 'ppdo',
        [string] $PacPath
    )

    if (-not (String-ContainsValue $ServerUrl)) {
        throw "ServerUrl was not provided and global variable devops_ServerUrl is empty."
    }    

    $resolvedType = $AuthType

    if ($resolvedType -eq 'Auto') {
        if ($UseManagedIdentity -or $global:devops_DataverseCredType -eq 'managedIdentity') {
            $resolvedType = 'ManagedIdentity'
        }
        elseif (-not (String-ContainsValue $UserName) -and -not(String-ContainsValue $Password)) {
            #When no user name or password, assume az credential
            $resolvedType = 'AzCredential'
        }
        elseif ($UseClientSecret -and $global:devops_DataverseCredType -eq 'servicePrincipal' -and (String-ContainsValue $global:devops_ClientID) -and ((String-ContainsValue $global:clientSecret) -or (String-ContainsValue $Password))) {
            #client secret is no longer best practice, use federated managed identity
            $resolvedType = 'ServicePrincipal'
        }
        elseif ($global:devops_DataverseCredType -eq 'user' -or (String-ContainsValue $UserName) -or (String-ContainsValue $global:devops_DataverseEmail)) {
            #Only when we don't have a service principal
            $resolvedType = 'User'
        }        
        else {
            #default to this strategy
            $resolvedType = 'AzCredential'
        }
    }

    switch ($resolvedType) {
        'ServicePrincipal' {
            if (-not (String-ContainsValue $UserName)) {
                $UserName = $global:devops_ClientID
            }

            if (-not (String-ContainsValue $Password)) {
                $Password = $global:clientSecret
            }

            if (-not (String-ContainsValue $TenantId)) {
                $TenantId = $global:devops_TenantID
            }

            if (-not (String-ContainsValue $UserName)) {
                throw "Service principal authentication requires ApplicationId/UserName or global devops_ClientID."
            }

            if (-not (String-ContainsValue $Password)) {
                throw "Service principal authentication requires ClientSecret/Password or global clientSecret."
            }

            if (-not (String-ContainsValue $TenantId)) {
                throw "Service principal authentication requires TenantId or global devops_TenantID."
            }
        }

        'User' {
            if (-not (String-ContainsValue $UserName)) {
                $UserName = $global:devops_DataverseEmail
            }

            if (-not (String-ContainsValue $Password)) {
                $Password = $global:Password
            }

            if (-not (String-ContainsValue $UserName)) {
                throw "User authentication requires UserName or global devops_DataverseEmail."
            }
        }

        'ManagedIdentity' {
            # If UserName is supplied for managed identity, treat it as a user-assigned managed identity client id.
            if (String-ContainsValue $UserName) {
                $env:AZURE_CLIENT_ID = $UserName
            }
        }

        'AzCredential' {

            Write-Verbose "Using current Azure CLI credential"

            $account = az account show 2>$null | ConvertFrom-Json

            if ($null -eq $account) {
                throw "No Azure CLI login found. Run 'az login' first."        
            }        
        }
    }

    $ppdoContext = [pscustomobject]@{
        AuthType           = $resolvedType
        ServerUrl          = $ServerUrl
        UserName           = $UserName
        Password           = $Password
        TenantId           = $TenantId
        PacProfileName     = $PacProfileName
        PacPath            = (Get-PPDOPacPath -PacPath $PacPath)
        AccessToken        = $null
        UseClientSecret    = ($resolvedType -eq 'ServicePrincipal')
        UseManagedIdentity = ($resolvedType -eq 'ManagedIdentity')
    }
    
    Write-Verbose ($ppdoContext | Format-List * | Out-String)

    return $ppdoContext
}

function Connect-PPDOAzureCli {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [pscustomobject] $Context,

        [bool] $ForceLogin = $false,
        [bool] $UseDeviceCode = $false
    )

    # For user auth, an existing Azure CLI token is acceptable.
    # For service principal and managed identity, sign in explicitly so the active Azure CLI identity
    # matches the same context used by PAC CLI and the ServiceClient token provider.
    if (-not $ForceLogin -and $Context.AuthType -eq 'User') {
        $existingToken = $null
        try {
            $existingToken = az account get-access-token --resource $Context.ServerUrl --query accessToken --output tsv 2>$null
        }
        catch {
            $existingToken = $null
        }

        if (String-ContainsValue $existingToken) {
            Write-Verbose "Azure CLI already has a token for Dataverse."
            return
        }
    }

    switch ($Context.AuthType) {

        'AzCredential' {
            Write-Verbose "Using existing Azure CLI login"
        }

        'ServicePrincipal' {
            $args = @(
                'login',
                '--service-principal',
                '-u', $Context.UserName,
                '-p', $Context.Password,
                '--tenant', $Context.TenantId,
                '--allow-no-subscriptions'
            )

            $null = Invoke-PPDOExternalCommand -FilePath 'az' -Arguments $args -ThrowOnError
        }

        'ManagedIdentity' {
            $args = @('login', '--identity', '--allow-no-subscriptions')

            if (String-ContainsValue $Context.UserName) {
                $args += @('--username', $Context.UserName)
            }

            $null = Invoke-PPDOExternalCommand -FilePath 'az' -Arguments $args -ThrowOnError
        }

        'User' {
            $args = @('login', '--allow-no-subscriptions')

            if ($UseDeviceCode) {
                $args += '--use-device-code'
            }

            if (String-ContainsValue $Context.TenantId) {
                $args += @('--tenant', $Context.TenantId)
            }

            $null = Invoke-PPDOExternalCommand -FilePath 'az' -Arguments $args -ThrowOnError
        }
    }
}

function New-PPDOPacAuth {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [pscustomobject] $Context,

        [bool] $DeleteExisting = $false,
        [bool] $UseDeviceCode = $false
    )

    if ($DeleteExisting) {
        Write-Verbose ("Deleting any existing pac auth connection named '{0}'" -f $Context.PacProfileName)
        $null = Invoke-PPDOExternalCommand -FilePath $Context.PacPath -Arguments @('auth', 'delete', '--name', $Context.PacProfileName)
    }
    else {
        $profiles = Invoke-PPDOExternalCommand -FilePath $Context.PacPath -Arguments @('auth', 'list')
        if (($profiles -join [Environment]::NewLine) -match [regex]::Escape($Context.PacProfileName)) {
            $null = Invoke-PPDOExternalCommand -FilePath $Context.PacPath -Arguments @('auth', 'select', '--name', $Context.PacProfileName) -ThrowOnError
            return
        }
    }

    $args = @('auth', 'create', '--environment', $Context.ServerUrl, '--name', $Context.PacProfileName)

    switch ($Context.AuthType) {
        'ServicePrincipal' {
            $args += @(
                '--applicationId', $Context.UserName,
                '--clientSecret', $Context.Password,
                '--tenant', $Context.TenantId
            )
        }

        'ManagedIdentity' {
            $args += '--managedIdentity'
        }

        'User' {
            $args += @('--username', $Context.UserName)

            if (String-ContainsValue $Context.Password) {
                $args += @('--password', $Context.Password)
            }
            elseif ($UseDeviceCode) {
                $args += '--deviceCode'
            }
        }
    }

    $null = Invoke-PPDOExternalCommand -FilePath $Context.PacPath -Arguments $args -ThrowOnError
    $null = Invoke-PPDOExternalCommand -FilePath $Context.PacPath -Arguments @('auth', 'select', '--name', $Context.PacProfileName) -ThrowOnError
}

function Get-PPDODataverseAccessToken {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string] $ServerUrl,

        [pscustomobject] $Context
    )

    if ($null -ne $Context -and (String-ContainsValue $Context.AccessToken)) {
        return $Context.AccessToken
    }

    $token = az account get-access-token --resource $ServerUrl --query accessToken --output tsv

    if (-not (String-ContainsValue $token)) {
        throw "Azure CLI did not return an access token for '$ServerUrl'."
    }

    if ($null -ne $Context) {
        $Context.AccessToken = $token
    }

    return $token
}

function Connect-PPDOServiceClient {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [pscustomobject] $Context,

        [bool] $VerboseLogging = $false
    )

    if (-not (Get-Command Connect-Dataverse -ErrorAction SilentlyContinue)) {
        throw "Connect-Dataverse was not found."
    }

    switch ($Context.AuthType) {

        'ServicePrincipal' {

            $connectionString =
                "AuthType=ClientSecret;" +
                "Url=$($Context.ServerUrl);" +
                "ClientId=$($Context.UserName);" +
                "ClientSecret=$($Context.Password)"

            Write-Verbose "Connecting using Service Principal"

            $conn = Connect-Dataverse `
                -ConnectionString $connectionString `
                -Verbose:$VerboseLogging
        }

        'User' {

            if (-not [string]::IsNullOrWhiteSpace($Context.Password)) {

                $connectionString =
                    "AuthType=OAuth;" +
                    "Username=$($Context.UserName);" +
                    "Password=$($Context.Password);" +
                    "Url=$($Context.ServerUrl);" +
                    "AppId=51f81489-12ee-4a9e-aaae-a2591f45987d;" +
                    "RedirectUri=app://58145B91-0C36-4500-8554-080854F2AC97;" +
                    "LoginPrompt=Auto"

                Write-Verbose "Connecting using Username/Password"

                $conn = Connect-Dataverse `
                    -ConnectionString $connectionString `
                    -Verbose:$VerboseLogging
            }
            else {

                Write-Verbose "Connecting using Azure CLI token"

                $accessToken = Get-PPDODataverseAccessToken `
                    -ServerUrl $Context.ServerUrl `
                    -Context $Context

                $tokenScriptBlock = { param($ServerUrl) return $accessToken }.GetNewClosure()

                $conn = Connect-Dataverse `
                    -InstanceUrl $Context.ServerUrl `
                    -TokenProvider $tokenScriptBlock
            }
        }

        { $_ -in 'AzCredential', 'ManagedIdentity' } {

            Write-Verbose "Connecting using Azure CLI token"

            $accessToken = Get-PPDODataverseAccessToken `
                -ServerUrl $Context.ServerUrl `
                -Context $Context

            if ($VerboseLogging) {

                $claims = Get-PPDOJwtTokenClaims `
                    -JwtToken $accessToken

                Write-Verbose (
                    "Token aud={0}; appid={1}; oid={2}; tid={3}" -f `
                    $claims.aud,
                    $claims.appid,
                    $claims.oid,
                    $claims.tid
                )
            }

            $tokenScriptBlock = { param($ServerUrl) return $accessToken }.GetNewClosure()
            $conn = Connect-Dataverse `
                -InstanceUrl $Context.ServerUrl `
                -TokenProvider $tokenScriptBlock

        }
        default {
            throw "Unsupported AuthType '$($Context.AuthType)'"
        }
    }

    if ($null -eq $conn) {
        throw "Connect-Dataverse returned null."
    }

    if (-not $conn.IsReady) {

        Write-Verbose "LastCrmError: $($conn.LastCrmError)"

        if ($conn.LastCrmException) {
            Write-Verbose $conn.LastCrmException.Message
        }

        throw ("Dataverse ServiceClient is not ready. {0}" -f $conn.LastCrmError)
    }

    return $conn
}

function Test-PPDODataverseToken {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string] $ServerUrl,

        [pscustomobject] $Context
    )

    $accessToken = Get-PPDODataverseAccessToken -ServerUrl $ServerUrl -Context $Context

    $headers = @{
        Authorization      = "Bearer $accessToken"
        Accept             = 'application/json'
        'OData-Version'    = '4.0'
        'OData-MaxVersion' = '4.0'
    }

    return Invoke-RestMethod -Method Get -Uri "$ServerUrl/api/data/v9.2/WhoAmI" -Headers $headers
}

function Connect-PPDODataverse {
    [CmdletBinding()]
    param(
        [string] $ServerUrl = $global:devops_ServerUrl,
        [string] $UserName,
        [string] $Password,
        [string] $TenantId = $global:devops_TenantID,

        [ValidateSet('Auto', 'ServicePrincipal', 'User', 'ManagedIdentity', 'AzCredential')]
        [string] $AuthType = 'Auto',

        [bool] $UseClientSecret = $false,
        [bool] $UseManagedIdentity = $false,
        [bool] $UseDeviceCode = $false,

        [bool] $ConnectAzureCli = $true,
        [bool] $ConnectPacCli = $true,
        [bool] $ConnectServiceClient = $true,
        [bool] $ValidateWhoAmI = $true,
        [bool] $VerboseLogging = $false,

        [string] $PacProfileName = 'ppdo',
        [string] $PacPath
    )

    Write-Verbose "ServerUrl = $ServerUrl"

    $context = Resolve-PPDOAuthContext `
        -ServerUrl $ServerUrl `
        -UserName $UserName `
        -Password $Password `
        -TenantId $TenantId `
        -AuthType $AuthType `
        -UseClientSecret $UseClientSecret `
        -UseManagedIdentity $UseManagedIdentity `
        -PacProfileName $PacProfileName `
        -PacPath $PacPath

    $global:devops_DataverseCredType = $context.AuthType.Substring(0, 1).ToLowerInvariant() + $context.AuthType.Substring(1)
    $global:devops_ServerUrl = $context.ServerUrl
    $global:devops_TenantID = $context.TenantId

    if ($context.AuthType -eq 'ServicePrincipal') {
        $global:devops_ClientID = $context.UserName
        $global:clientSecret = $context.Password
    }
    elseif ($context.AuthType -eq 'User') {
        $global:devops_DataverseEmail = $context.UserName
        $global:Password = $context.Password
    }

    if ($ConnectAzureCli) {
        Write-Verbose "Connecting Azure CLI"
        Connect-PPDOAzureCli -Context $context -UseDeviceCode $UseDeviceCode
    }

    if ($ConnectPacCli) {
        Write-Verbose "Connecting PAC"
        New-PPDOPacAuth -Context $context -UseDeviceCode $UseDeviceCode
    }

    $whoAmI = $null
    if ($ValidateWhoAmI) {
        Write-Verbose "Testing Token"
        $whoAmI = Test-PPDODataverseToken -ServerUrl $context.ServerUrl -Context $context
    }

    $connection = $null
    if ($ConnectServiceClient) {
        Write-Verbose "Connecting Service Client"
        $connection = Connect-PPDOServiceClient -Context $context -VerboseLogging $VerboseLogging
    }

    $global:devops_HasDataverseLogin = $true

    $ppdoConnection = [pscustomobject]@{
        AuthType      = $context.AuthType
        ServerUrl     = $context.ServerUrl
        TenantId      = $context.TenantId
        UserName      = $context.UserName
        PacProfile    = $context.PacProfileName
        WhoAmI        = $whoAmI
        ServiceClient = $connection
    }

    return $ppdoConnection
}

function Get-PPDOJwtTokenClaims {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string] $JwtToken
    )

    $tokenSplit = $JwtToken.Split('.')

    if ($tokenSplit.Count -lt 2) {
        throw 'The supplied token is not a valid JWT.'
    }

    $claimsSegment = $tokenSplit[1].Replace(' ', '+').Replace('-', '+').Replace('_', '/')
    $mod = $claimsSegment.Length % 4

    if ($mod -gt 0) {
        $claimsSegment = $claimsSegment + ('=' * (4 - $mod))
    }

    $decodedClaimsSegment = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($claimsSegment))
    return ($decodedClaimsSegment | ConvertFrom-Json)
}

# Backwards-compatible wrapper for existing calls that only need PAC authentication.
function New-PACAuth {
    [CmdletBinding()]
    param(
        [string] $ServerUrl = $global:devops_ServerUrl,
        [string] $UserName,
        [string] $Password,
        [string] $TenantId = $global:devops_TenantID,
        [bool] $UseManagedIdentity = $false,
        [bool] $UseClientSecret = $false,
        [bool] $UseDeviceCode = $false
    )

    $null = Connect-PPDODataverse `
        -ServerUrl $ServerUrl `
        -UserName $UserName `
        -Password $Password `
        -TenantId $TenantId `
        -AuthType Auto `
        -UseManagedIdentity $UseManagedIdentity `
        -UseClientSecret $UseClientSecret `
        -UseDeviceCode $UseDeviceCode `
        -ConnectAzureCli $false `
        -ConnectPacCli $true `
        -ConnectServiceClient $false
}

# Backwards-compatible wrapper for existing code that expects a Dataverse connection object.
function Get-DataverseConnection {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string] $DeployServerUrl,

        [bool] $UseManagedIdentity = $false,
        [bool] $UseClientSecret = $false,
        [bool] $UseDeviceCode = $false,
        [bool] $ValidateWhoAmI = $false
    )

    $result = Connect-PPDODataverse `
        -ServerUrl $DeployServerUrl `
        -AuthType Auto `
        -UseManagedIdentity $UseManagedIdentity `
        -UseClientSecret $UseClientSecret `
        -UseDeviceCode $UseDeviceCode `
        -ValidateWhoAmI $ValidateWhoAmI `
        -ConnectAzureCli $true `
        -ConnectPacCli $true `
        -ConnectServiceClient $true

    return $result.ServiceClient
}

# Backwards-compatible wrapper for your previous Azure CLI token based ServiceClient path.
function Get-DataverseConnection2 {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string] $ServerUrl,

        [bool] $VerboseLogging = $false
    )

    $result = Connect-PPDODataverse `
        -ServerUrl $ServerUrl `
        -AuthType Auto `
        -VerboseLogging $VerboseLogging `
        -ConnectAzureCli $true `
        -ConnectPacCli $false `
        -ConnectServiceClient $true

    return $result.ServiceClient
}

# Backwards-compatible wrapper for the old Connect-Cli function.
function Connect-Cli {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string] $UserName,

        [Parameter(Mandatory = $false)]
        [string] $Password = '',

        [Parameter(Mandatory = $true)]
        [string] $TenantId,

        [bool] $UseClientSecret = $false,
        [bool] $UseManagedIdentity = $false,
        [bool] $UseDeviceCode = $false
    )

    $result = Connect-PPDODataverse `
        -ServerUrl $global:devops_ServerUrl `
        -UserName $UserName `
        -Password $Password `
        -TenantId $TenantId `
        -AuthType Auto `
        -UseClientSecret $UseClientSecret `
        -UseManagedIdentity $UseManagedIdentity `
        -UseDeviceCode $UseDeviceCode `
        -ConnectAzureCli $true `
        -ConnectPacCli $true `
        -ConnectServiceClient $false

    return $result
}

function Get-AdoAccessToken {

    [CmdletBinding()]
    param()

    Write-Verbose "Getting Updated ADO Access Token"

    $azureDevopsResourceId = "499b84ac-1321-427f-aa17-267ca6975798"

    az account show --output none 2>$null

    if ($LASTEXITCODE -ne 0) {
        Write-Host "No active session found. Please sign in..."
        az login
    } 

    $token = az account get-access-token `
        --resource $azureDevopsResourceId |
        ConvertFrom-Json

    $env:AZURE_DEVOPS_EXT_PAT = $token.accessToken

    return $token
}