support/Epp.Graph.ps1

function Assert-EppCliDirectoryContext {
    param([hashtable] $Inputs)

    $account = Invoke-EppAz account show --output json | ConvertFrom-Json -AsHashtable
    if ($account -isnot [Collections.IDictionary] -or $account['user'] -isnot [Collections.IDictionary] -or
        $account['tenantId'] -ine $Inputs.TenantId -or $account['environmentName'] -cne 'AzureCloud' -or
        $account.user['type'] -cne 'user') {
        throw 'The current Azure CLI directory does not match the selected tenant and public-cloud user session. Start Cloud Shell in the intended directory (or sign in with Azure CLI first). Setup will not switch the CLI default tenant.'
    }
}

function Invoke-EppDirectoryCommand {
    param([hashtable] $Inputs, [string[]] $Arguments)

    Assert-EppCliDirectoryContext -Inputs $Inputs
    if ($Arguments.Count -lt 2 -or $Arguments[0] -cne 'ad') { throw 'Expected an Azure CLI directory command.' }
    $output = Invoke-EppAz @Arguments --output json
    if ([string]::IsNullOrWhiteSpace($output)) { throw 'Azure CLI returned an empty directory response.' }
    return ConvertFrom-Json -InputObject $output -AsHashtable -NoEnumerate
}

function Invoke-EppGraphRequest {
    param(
        [hashtable] $Inputs,
        [ValidateSet('GET', 'POST', 'PATCH')][string] $Method = 'GET',
        [string] $Path,
        [Collections.IDictionary] $Query,
        [Collections.IDictionary] $Body
    )

    $uri = $null
    $address = if ($Path.StartsWith('/')) { "https://graph.microsoft.com$Path" } else { $Path }
    if (-not [Uri]::TryCreate($address, [UriKind]::Absolute, [ref]$uri) -or
        $uri.Scheme -cne 'https' -or $uri.Host -ine 'graph.microsoft.com' -or $uri.Port -ne 443 -or
        $uri.UserInfo -or $uri.Fragment -or $uri.AbsolutePath -cnotmatch '^/(v1\.0|beta)/' -or
        $uri.AbsolutePath -match '(?i)%2f|%5c|%2e|\\') {
        throw 'Graph requests must target the public Microsoft Graph v1.0 or beta endpoint.'
    }
    Assert-EppCliDirectoryContext -Inputs $Inputs
    $parameters = @{}
    foreach ($pair in $uri.Query.TrimStart('?').Split('&', [StringSplitOptions]::RemoveEmptyEntries)) {
        $parts = $pair.Split('=', 2)
        if ($parts.Count -ne 2) { throw 'Graph returned an invalid continuation query.' }
        $name = [Uri]::UnescapeDataString($parts[0].Replace('+', ' '))
        if ($parameters.ContainsKey($name)) { throw 'Graph returned duplicate continuation parameters.' }
        $parameters[$name] = [Uri]::UnescapeDataString($parts[1].Replace('+', ' '))
    }
    if ($Query) {
        foreach ($entry in $Query.GetEnumerator()) {
            if ($parameters.ContainsKey($entry.Key)) { throw 'Duplicate Graph query parameter.' }
            $parameters[$entry.Key] = $entry.Value
        }
    }
    $directory = Join-Path ([IO.Path]::GetTempPath()) "epp-graph-$([Guid]::NewGuid().ToString('N'))"
    try {
        New-Item -ItemType Directory -Path $directory | Out-Null
        $arguments = @('rest', '--method', $Method.ToLowerInvariant(), '--url', $uri.GetLeftPart([UriPartial]::Path),
            '--resource', 'https://graph.microsoft.com/', '--subscription', $Inputs.SubscriptionId, '--output', 'json')
        if ($parameters.Count) {
            $queryPath = Join-Path $directory 'query.json'
            $parameters | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $queryPath -Encoding utf8NoBOM
            $arguments += @('--url-parameters', "@$queryPath")
        }
        if ($null -ne $Body) {
            $bodyPath = Join-Path $directory 'body.json'
            $Body | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $bodyPath -Encoding utf8NoBOM
            $arguments += @('--body', "@$bodyPath")
        }
        # Azure CLI obtains/renews a Graph-audience token; no bearer token is exposed to PowerShell.
        $output = Invoke-EppAz @arguments
        if ([string]::IsNullOrWhiteSpace($output)) {
            if ($Method -eq 'PATCH') { return $null }
            throw 'Microsoft Graph returned an empty response.'
        }
        return ConvertFrom-Json -InputObject $output -AsHashtable -NoEnumerate
    }
    finally {
        if (Test-Path -LiteralPath $directory) { Remove-Item -LiteralPath $directory -Recurse -Force }
    }
}

function Get-EppGraphCollection {
    param([hashtable] $Inputs, [string] $Path, [Collections.IDictionary] $Query)

    $next = $Path
    $collection = [Uri]$(if ($Path.StartsWith('/')) { "https://graph.microsoft.com$Path" } else { $Path })
    $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
    for ($pageNumber = 1; $pageNumber -le 100; $pageNumber++) {
        $page = Invoke-EppGraphRequest -Inputs $Inputs -Path $next -Query $Query
        if ($page -isnot [Collections.IDictionary] -or $page['value'] -isnot [Array]) {
            throw 'Microsoft Graph returned an invalid collection response.'
        }
        foreach ($item in $page.value) {
            if ($item -isnot [Collections.IDictionary]) { throw 'Microsoft Graph returned an invalid collection item.' }
            [pscustomobject]$item
        }
        if (-not $page['@odata.nextLink']) { return }
        $uri = $null
        if (-not [Uri]::TryCreate([string]$page['@odata.nextLink'], [UriKind]::Absolute, [ref]$uri) -or
            $uri.GetLeftPart([UriPartial]::Path) -cne $collection.GetLeftPart([UriPartial]::Path) -or
            -not $seen.Add($uri.AbsoluteUri)) {
            throw 'Microsoft Graph returned an invalid or repeated collection continuation.'
        }
        $next = $uri.AbsoluteUri
        $Query = $null
    }
    throw 'Microsoft Graph collection pagination exceeded the supported limit.'
}

function ConvertFrom-EppGraphApplication {
    param([Collections.IDictionary] $Application)

    if (-not $Application) { throw 'Microsoft Graph returned an empty application.' }
    $keys = @(foreach ($credential in @($Application['keyCredentials'])) {
        if ($null -eq $credential) { continue }
        if ($credential -isnot [Collections.IDictionary]) { throw 'Microsoft Graph returned an invalid key credential.' }
        $binary = @{}
        foreach ($field in @('key', 'customKeyIdentifier')) {
            $binary[$field] = $null
            try { if ($credential[$field]) { $binary[$field] = [Convert]::FromBase64String([string]$credential[$field]) } }
            catch [FormatException] { throw "Microsoft Graph returned invalid base64 in keyCredentials.$field." }
        }
        [pscustomobject]@{
            KeyId = ConvertTo-EppGuid ([string]$credential['keyId'])
            Type = $credential['type']; Usage = $credential['usage']; DisplayName = $credential['displayName']
            Key = $binary.key; CustomKeyIdentifier = $binary.customKeyIdentifier
            StartDateTime = $credential['startDateTime']; EndDateTime = $credential['endDateTime']
        }
    })
    $api = $Application['api']
    if ($api -and $api -isnot [Collections.IDictionary]) { throw 'Microsoft Graph returned invalid application API settings.' }
    return [pscustomobject]@{
        Id = ConvertTo-EppGuid ([string]$Application['id'])
        AppId = ConvertTo-EppGuid ([string]$Application['appId'])
        DisplayName = [string]$Application['displayName']; SignInAudience = [string]$Application['signInAudience']
        Api = [pscustomobject]@{ RequestedAccessTokenVersion = if ($api) { $api['requestedAccessTokenVersion'] } else { $null } }
        AppRoles = @($Application['appRoles'] | Where-Object { $null -ne $_ } | ForEach-Object { [pscustomobject]$_ })
        IdentifierUris = @($Application['identifierUris'] | Where-Object { $null -ne $_ })
        KeyCredentials = $keys; TokenEncryptionKeyId = $Application['tokenEncryptionKeyId']
    }
}

function Get-EppApplication {
    param([hashtable] $Inputs, [string] $ClientId, [string] $ObjectId)

    if ($ClientId) {
        $ClientId = ConvertTo-EppGuid $ClientId
        $basic = Invoke-EppDirectoryCommand -Inputs $Inputs -Arguments @('ad', 'app', 'show', '--id', $ClientId)
        if ($basic -isnot [Collections.IDictionary] -or $basic['appId'] -ine $ClientId) {
            throw 'The selected application client ID did not resolve to the expected app registration.'
        }
        $ObjectId = ConvertTo-EppGuid ([string]$basic['id'])
    }
    else { $ObjectId = ConvertTo-EppGuid $ObjectId }
    $raw = Invoke-EppGraphRequest -Inputs $Inputs -Path "/v1.0/applications/$ObjectId" -Query @{
        '$select' = 'id,appId,displayName,signInAudience,api,appRoles,identifierUris,keyCredentials,tokenEncryptionKeyId'
    }
    $application = ConvertFrom-EppGraphApplication -Application $raw
    if ($application.Id -ine $ObjectId -or ($ClientId -and $application.AppId -ine $ClientId)) {
        throw 'Microsoft Graph application readback does not match the requested registration.'
    }
    return $application
}

function ConvertTo-EppGraphKeyCredentials {
    param([object[]] $Credentials)

    foreach ($key in $Credentials) {
        [ordered]@{
            keyId = [string]$key.KeyId; type = $key.Type; usage = $key.Usage; displayName = $key.DisplayName
            key = if ($key.Key) { [Convert]::ToBase64String([byte[]]$key.Key) } else { $null }
            customKeyIdentifier = if ($key.CustomKeyIdentifier) { [Convert]::ToBase64String([byte[]]$key.CustomKeyIdentifier) } else { $null }
            startDateTime = if ($key.StartDateTime) { ([DateTimeOffset]$key.StartDateTime).ToUniversalTime().ToString('o') } else { $null }
            endDateTime = if ($key.EndDateTime) { ([DateTimeOffset]$key.EndDateTime).ToUniversalTime().ToString('o') } else { $null }
        }
    }
}

function Get-EppAppRoleAssignments {
    param([hashtable] $Inputs, [string] $PrincipalId)

    $id = ConvertTo-EppGuid $PrincipalId
    Get-EppGraphCollection -Inputs $Inputs -Path "/v1.0/servicePrincipals/$id/appRoleAssignments"
}

function ConvertFrom-EppGraphServicePrincipal {
    param([Collections.IDictionary] $Principal)

    if (-not $Principal) { throw 'Azure CLI returned an empty service principal.' }
    foreach ($field in @('accountEnabled', 'appRoleAssignmentRequired')) {
        if ($null -ne $Principal[$field] -and $Principal[$field] -isnot [bool]) {
            throw "Azure CLI returned invalid service-principal $field metadata."
        }
    }
    return [pscustomobject]@{
        Id = ConvertTo-EppGuid ([string]$Principal['id'])
        AppId = ConvertTo-EppGuid ([string]$Principal['appId'])
        DisplayName = [string]$Principal['displayName']
        AccountEnabled = $Principal['accountEnabled']
        AppRoleAssignmentRequired = $Principal['appRoleAssignmentRequired']
        AppRoles = @($Principal['appRoles'] | Where-Object { $null -ne $_ } | ForEach-Object { [pscustomobject]$_ })
    }
}

function New-EppFederatedCredential {
    param([hashtable] $Inputs, [string] $ObjectId, [Collections.IDictionary] $Credential)

    $id = ConvertTo-EppGuid $ObjectId
    $path = Join-Path ([IO.Path]::GetTempPath()) "epp-federation-$([Guid]::NewGuid().ToString('N')).json"
    try {
        $Credential | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $path -Encoding utf8NoBOM
        Invoke-EppDirectoryCommand -Inputs $Inputs -Arguments @('ad', 'app', 'federated-credential', 'create', '--id', $id, '--parameters', $path) | Out-Null
    }
    finally { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Force } }
}