Private/DataverseAuth.ps1

# DataverseAuth.ps1 - OAuth2 authentication for Dataverse
# Replaces dependency on Microsoft.Xrm.Data.PowerShell
# NOTE: DataverseConnection class is defined in PPDS.Tools.psm1

# Well-known Azure AD application ID for Dynamics 365 (public client)
$script:DefaultClientId = "51f81489-12ee-4a9e-aaae-a2591f45987d"

function Get-DataverseToken {
    <#
    .SYNOPSIS
        Acquires an access token for Dataverse using client credentials (service principal).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$EnvironmentUrl,

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

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

        [Parameter(Mandatory)]
        [string]$ClientSecret
    )

    $tokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
    $scope = "$($EnvironmentUrl.TrimEnd('/'))/.default"

    $body = @{
        grant_type    = "client_credentials"
        client_id     = $ClientId
        client_secret = $ClientSecret
        scope         = $scope
    }

    try {
        $response = Invoke-RestMethod -Uri $tokenEndpoint -Method POST -Body $body -ContentType "application/x-www-form-urlencoded"

        return @{
            AccessToken = $response.access_token
            ExpiresIn   = $response.expires_in
            TokenType   = $response.token_type
        }
    }
    catch {
        $errorMessage = $_.ErrorDetails.Message
        if ($errorMessage) {
            $errorObj = $errorMessage | ConvertFrom-Json -ErrorAction SilentlyContinue
            if ($errorObj.error_description) {
                throw "Authentication failed: $($errorObj.error_description)"
            }
        }
        throw "Authentication failed: $($_.Exception.Message)"
    }
}

function Get-DataverseTokenDeviceCode {
    <#
    .SYNOPSIS
        Acquires an access token for Dataverse using device code flow (interactive).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$EnvironmentUrl,

        [Parameter()]
        [string]$TenantId = "organizations",

        [Parameter()]
        [string]$ClientId = $script:DefaultClientId
    )

    $deviceCodeEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/devicecode"
    $tokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
    $scope = "$($EnvironmentUrl.TrimEnd('/'))/.default offline_access"

    # Request device code
    $deviceCodeBody = @{
        client_id = $ClientId
        scope     = $scope
    }

    try {
        $deviceCodeResponse = Invoke-RestMethod -Uri $deviceCodeEndpoint -Method POST -Body $deviceCodeBody -ContentType "application/x-www-form-urlencoded"
    }
    catch {
        throw "Failed to initiate device code flow: $($_.Exception.Message)"
    }

    # Display instructions to user
    Write-Host ""
    Write-Host "To sign in, use a web browser to open the page:" -ForegroundColor Cyan
    Write-Host " $($deviceCodeResponse.verification_uri)" -ForegroundColor Yellow
    Write-Host ""
    Write-Host "Enter the code:" -ForegroundColor Cyan
    Write-Host " $($deviceCodeResponse.user_code)" -ForegroundColor Yellow
    Write-Host ""
    Write-Host "Waiting for authentication..." -ForegroundColor Gray

    # Poll for token
    $tokenBody = @{
        grant_type  = "urn:ietf:params:oauth:grant-type:device_code"
        client_id   = $ClientId
        device_code = $deviceCodeResponse.device_code
    }

    $interval = $deviceCodeResponse.interval
    if (-not $interval) { $interval = 5 }
    $expiresAt = [datetime]::UtcNow.AddSeconds($deviceCodeResponse.expires_in)

    while ([datetime]::UtcNow -lt $expiresAt) {
        Start-Sleep -Seconds $interval

        try {
            $tokenResponse = Invoke-RestMethod -Uri $tokenEndpoint -Method POST -Body $tokenBody -ContentType "application/x-www-form-urlencoded"

            Write-Host "Authentication successful!" -ForegroundColor Green

            return @{
                AccessToken  = $tokenResponse.access_token
                RefreshToken = $tokenResponse.refresh_token
                ExpiresIn    = $tokenResponse.expires_in
                TokenType    = $tokenResponse.token_type
            }
        }
        catch {
            $errorBody = $_.ErrorDetails.Message | ConvertFrom-Json -ErrorAction SilentlyContinue

            if ($errorBody.error -eq "authorization_pending") {
                # Still waiting for user - continue polling
                continue
            }
            elseif ($errorBody.error -eq "slow_down") {
                # Increase polling interval
                $interval += 5
                continue
            }
            elseif ($errorBody.error -eq "expired_token") {
                throw "Device code expired. Please try again."
            }
            else {
                if ($errorBody.error_description) {
                    throw "Authentication failed: $($errorBody.error_description)"
                }
                else {
                    throw "Authentication failed: $($_.Exception.Message)"
                }
            }
        }
    }

    throw "Device code flow timed out. Please try again."
}

function Get-DataverseOrgInfo {
    <#
    .SYNOPSIS
        Gets organization information from Dataverse to verify connection.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$EnvironmentUrl,

        [Parameter(Mandatory)]
        [string]$AccessToken
    )

    $apiUrl = "$($EnvironmentUrl.TrimEnd('/'))/api/data/v9.2"

    $headers = @{
        "Authorization" = "Bearer $AccessToken"
        "Accept"        = "application/json"
    }

    try {
        # Query WhoAmI to verify token works
        $whoAmI = Invoke-RestMethod -Uri "$apiUrl/WhoAmI" -Headers $headers -Method GET

        # Get organization name
        $orgId = $whoAmI.OrganizationId
        $org = Invoke-RestMethod -Uri "$apiUrl/organizations($orgId)?`$select=name" -Headers $headers -Method GET

        return @{
            UserId         = $whoAmI.UserId
            OrganizationId = $whoAmI.OrganizationId
            OrgName        = $org.name
        }
    }
    catch {
        throw "Failed to connect to Dataverse: $($_.Exception.Message)"
    }
}

function New-DataverseConnection {
    <#
    .SYNOPSIS
        Creates a new DataverseConnection object using service principal authentication.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$EnvironmentUrl,

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

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

        [Parameter(Mandatory)]
        [string]$ClientSecret
    )

    # Get token
    $tokenResult = Get-DataverseToken -EnvironmentUrl $EnvironmentUrl -TenantId $TenantId -ClientId $ClientId -ClientSecret $ClientSecret

    # Get org info
    $orgInfo = Get-DataverseOrgInfo -EnvironmentUrl $EnvironmentUrl -AccessToken $tokenResult.AccessToken

    # Calculate expiry
    $expiry = [datetime]::UtcNow.AddSeconds($tokenResult.ExpiresIn)

    # Create connection
    $connection = [DataverseConnection]::new(
        $EnvironmentUrl,
        $tokenResult.AccessToken,
        $expiry,
        $orgInfo.OrgName
    )

    # Store auth context (non-sensitive only)
    $connection.TenantId = $TenantId
    $connection.ClientId = $ClientId
    $connection.AuthMethod = "ServicePrincipal"

    return $connection
}

function New-DataverseConnectionInteractive {
    <#
    .SYNOPSIS
        Creates a new DataverseConnection object using device code (interactive) authentication.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$EnvironmentUrl,

        [Parameter()]
        [string]$TenantId = "organizations"
    )

    # Get token via device code flow
    $tokenResult = Get-DataverseTokenDeviceCode -EnvironmentUrl $EnvironmentUrl -TenantId $TenantId

    # Get org info
    $orgInfo = Get-DataverseOrgInfo -EnvironmentUrl $EnvironmentUrl -AccessToken $tokenResult.AccessToken

    # Calculate expiry
    $expiry = [datetime]::UtcNow.AddSeconds($tokenResult.ExpiresIn)

    # Create connection
    $connection = [DataverseConnection]::new(
        $EnvironmentUrl,
        $tokenResult.AccessToken,
        $expiry,
        $orgInfo.OrgName
    )

    # Store auth context (non-sensitive only)
    $connection.TenantId = $TenantId
    $connection.ClientId = $script:DefaultClientId
    $connection.AuthMethod = "DeviceCode"

    return $connection
}