EntraAuth.psm1

$script:ModuleRoot = $PSScriptRoot

class EntraToken {
    #region Token Data
    [string]$AccessToken
    [System.DateTime]$ValidAfter
    [System.DateTime]$ValidUntil
    [string[]]$Scopes
    [string]$RefreshToken
    [string]$Audience
    [string]$Issuer
    [PSObject]$TokenData
    #endregion Token Data
    
    #region Connection Data
    [string]$Service
    [string]$Type
    [string]$ClientID
    [string]$TenantID
    [string]$ServiceUrl
    [Hashtable]$Header = @{}

    [string]$IdentityID
    [string]$IdentityType
    
    # Workflow: Client Secret
    [System.Security.SecureString]$ClientSecret
    
    # Workflow: Certificate
    [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate

    # Workflow: Username & Password
    [PSCredential]$Credential

    # Workflow: Key Vault
    [string]$VaultName
    [string]$SecretName
    #endregion Connection Data
    
    #region Constructors
    EntraToken([string]$Service, [string]$ClientID, [string]$TenantID, [Securestring]$ClientSecret, [string]$ServiceUrl) {
        $this.Service = $Service
        $this.ClientID = $ClientID
        $this.TenantID = $TenantID
        $this.ClientSecret = $ClientSecret
        $this.ServiceUrl = $ServiceUrl
        $this.Type = 'ClientSecret'
    }
    
    EntraToken([string]$Service, [string]$ClientID, [string]$TenantID, [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate, [string]$ServiceUrl) {
        $this.Service = $Service
        $this.ClientID = $ClientID
        $this.TenantID = $TenantID
        $this.Certificate = $Certificate
        $this.ServiceUrl = $ServiceUrl
        $this.Type = 'Certificate'
    }

    EntraToken([string]$Service, [string]$ClientID, [string]$TenantID, [pscredential]$Credential, [string]$ServiceUrl) {
        $this.Service = $Service
        $this.ClientID = $ClientID
        $this.TenantID = $TenantID
        $this.Credential = $Credential
        $this.ServiceUrl = $ServiceUrl
        $this.Type = 'UsernamePassword'
    }

    EntraToken([string]$Service, [string]$ClientID, [string]$TenantID, [string]$ServiceUrl, [bool]$IsDeviceCode) {
        $this.Service = $Service
        $this.ClientID = $ClientID
        $this.TenantID = $TenantID
        $this.ServiceUrl = $ServiceUrl
        if ($IsDeviceCode) { $this.Type = 'DeviceCode' }
        else { $this.Type = 'Browser' }
    }

    EntraToken([string]$Service, [string]$ClientID, [string]$TenantID, [string]$ServiceUrl, [string]$VaultName, [string]$SecretName) {
        $this.Service = $Service
        $this.ClientID = $ClientID
        $this.TenantID = $TenantID
        $this.ServiceUrl = $ServiceUrl
        $this.VaultName = $VaultName
        $this.SecretName = $SecretName
        $this.Type = 'KeyVault'
    }

    EntraToken([string]$Service, [string]$ServiceUrl, [string]$IdentityID, [string]$IdentityType) {
        $this.Service = $Service
        $this.ServiceUrl = $ServiceUrl
        $this.Type = 'Identity'

        if ($IdentityID) {
            $this.IdentityID = $IdentityID
            $this.IdentityType = $IdentityType
        }
    }
    #endregion Constructors

    [void]SetTokenMetadata([PSObject] $AuthToken) {
        $this.AccessToken = $AuthToken.AccessToken
        $this.ValidAfter = $AuthToken.ValidAfter
        $this.ValidUntil = $AuthToken.ValidUntil
        $this.Scopes = $AuthToken.Scopes
        if ($AuthToken.RefreshToken) { $this.RefreshToken = $AuthToken.RefreshToken }

        $tokenPayload = $AuthToken.AccessToken.Split(".")[1].Replace('-', '+').Replace('_', '/')
        while ($tokenPayload.Length % 4) { $tokenPayload += "=" }
        $bytes = [System.Convert]::FromBase64String($tokenPayload)
        $data = [System.Text.Encoding]::ASCII.GetString($bytes) | ConvertFrom-Json
        
        if ($data.roles) { $this.Scopes = $data.roles }
        elseif ($data.scp) { $this.Scopes = $data.scp -split " " }

        $this.Audience = $data.aud
        $this.Issuer = $data.iss
        $this.TokenData = $data
    }

    [hashtable]GetHeader() {
        if ($this.ValidUntil -lt (Get-Date).AddMinutes(5)) {
            $this.RenewToken()
        }

        $currentHeader = @{}
        if ($this.Header.Count -gt 0) {
            $currentHeader = $this.Header.Clone()
        }
        $currentHeader.Authorization = "Bearer $($this.AccessToken)"

        return $currentHeader
    }

    [void]RenewToken()
    {
        $defaultParam = @{
            TenantID = $this.TenantID
            ClientID = $this.ClientID
            Resource = $this.Audience
        }
        switch ($this.Type) {
            Certificate {
                $result = Connect-ServiceCertificate @defaultParam -Certificate $this.Certificate
                $this.SetTokenMetadata($result)
            }
            ClientSecret {
                $result = Connect-ServiceClientSecret @defaultParam -ClientSecret $this.ClientSecret
                $this.SetTokenMetadata($result)
            }
            UsernamePassword {
                $result = Connect-ServicePassword @defaultParam -Credential $this.Credential
                $this.SetTokenMetadata($result)
            }
            DeviceCode {
                if ($this.RefreshToken) {
                    Connect-ServiceRefreshToken -Token $this
                    return
                }

                $result = Connect-ServiceDeviceCode @defaultParam
                $this.SetTokenMetadata($result)
            }
            Browser {
                if ($this.RefreshToken) {
                    Connect-ServiceRefreshToken -Token $this
                    return
                }

                $result = Connect-ServiceBrowser @defaultParam -SelectAccount
                $this.SetTokenMetadata($result)
            }
            KeyVault {
                $secret = Get-VaultSecret -VaultName $this.VaultName -SecretName $this.SecretName
                $result = switch ($secret.Type) {
                    Certificate { Connect-ServiceCertificate @defaultParam -Certificate $secret.Certificate }
                    ClientSecret { Connect-ServiceClientSecret @defaultParam -ClientSecret $secret.ClientSecret }
                }
                $this.SetTokenMetadata($result)
            }
            Identity {
                $result = Connect-ServiceIdentity -Resource $this.Audience -IdentityID $this.IdentityID -IdentityType $this.IdentityType
                $this.SetTokenMetadata($result)
            }
        }
    }
}

function Connect-ServiceBrowser {
    <#
    .SYNOPSIS
        Interactive logon using the Authorization flow and browser. Supports SSO.
     
    .DESCRIPTION
        Interactive logon using the Authorization flow and browser. Supports SSO.
 
        This flow requires an App Registration configured for the platform "Mobile and desktop applications".
        Its redirect Uri must be "http://localhost"
 
        On successful authentication
     
    .PARAMETER ClientID
        The ID of the registered app used with this authentication request.
     
    .PARAMETER TenantID
        The ID of the tenant connected to with this authentication request.
     
    .PARAMETER SelectAccount
        Forces account selection on logon.
        As this flow supports single-sign-on, it will otherwise not prompt for anything if already signed in.
        This could be a problem if you want to connect using another (e.g. an admin) account.
     
    .PARAMETER Scopes
        Generally doesn't need to be changed from the default '.default'
 
    .PARAMETER LocalPort
        The local port that should be redirected to.
        In order to process the authentication response, we need to listen to a local web request on some port.
        Usually needs not be redirected.
        Defaults to: 8080
     
    .PARAMETER Resource
        The resource owning the api permissions / scopes requested.
 
    .PARAMETER Browser
        The path to the browser to use for the authentication flow.
        Provide the full path to the executable.
        The browser must accept the url to open as its only parameter.
        Defaults to your default browser.
 
    .PARAMETER BrowserMode
        How the browser used for authentication is selected.
        Options:
        + Auto (default): Automatically use the default browser.
        + PrintLink: The link to open is printed on console and user selects which browser to paste it into (must be used on the same machine)
     
    .PARAMETER NoReconnect
        Disables automatic reconnection.
        By default, this module will automatically try to reaquire a new token before the old one expires.
     
    .EXAMPLE
        PS C:\> Connect-ServiceBrowser -ClientID '<ClientID>' -TenantID '<TenantID>'
     
        Connects to the specified tenant using the specified client, prompting the user to authorize via Browser.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingWriteHost", "")]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $TenantID,

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

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

        [switch]
        $SelectAccount,

        [AllowEmptyCollection()]
        [string[]]
        $Scopes,

        [int]
        $LocalPort = 8080,

        [string]
        $Browser,

        [Parameter(ParameterSetName = 'Browser')]
        [ValidateSet('Auto', 'PrintLink')]
        [string]
        $BrowserMode = 'Auto',

        [switch]
        $NoReconnect
    )
    process {
        Add-Type -AssemblyName System.Web
        if (-not $Scopes) { $Scopes = @('.default') }

        $redirectUri = "http://localhost:$LocalPort"
        $actualScopes = $Scopes | Resolve-ScopeName -Resource $Resource

        if (-not $NoReconnect) {
            $actualScopes = @($actualScopes) + 'offline_access'
        }

        $uri = "https://login.microsoftonline.com/$TenantID/oauth2/v2.0/authorize?"
        $state = Get-Random
        $parameters = @{
            client_id     = $ClientID
            response_type = 'code'
            redirect_uri  = $redirectUri
            response_mode = 'query'
            scope         = $actualScopes -join ' '
            state         = $state
        }
        if ($SelectAccount) {
            $parameters.prompt = 'select_account'
        }

        $paramStrings = foreach ($pair in $parameters.GetEnumerator()) {
            $pair.Key, ([System.Web.HttpUtility]::UrlEncode($pair.Value)) -join '='
        }
        $uriFinal = $uri + ($paramStrings -join '&')
        Write-Verbose "Authorize Uri: $uriFinal"

        $redirectTo = 'https://raw.githubusercontent.com/FriedrichWeinmann/MiniGraph/master/nothing-to-see-here.txt'
        if ((Get-Random -Minimum 10 -Maximum 99) -eq 66) {
            $redirectTo = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
        }
        
        # Start local server to catch the redirect
        $http = [System.Net.HttpListener]::new()
        $http.Prefixes.Add("$redirectUri/")
        try { $http.Start() }
        catch { Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Failed to create local http listener on port $LocalPort. Use -LocalPort to select a different port. $_" -Category OpenError }

        switch ($BrowserMode) {
            Auto {
                # Execute in default browser
                if ($Browser) { & $Browser $uriFinal }
                else { Start-Process $uriFinal }
            }
            PrintLink {
                Write-Host @"
Ready to authenticate. Paste the following link into the browser of your choice on the local computer:
$uriFinal
"@

            }
        }

        # Get Result
        $task = $http.GetContextAsync()
        $authorizationCode, $stateReturn, $sessionState = $null
        try {
            while (-not $task.IsCompleted) {
                Start-Sleep -Milliseconds 200
            }
            $context = $task.Result
            $context.Response.Redirect($redirectTo)
            $context.Response.Close()
            $authorizationCode, $stateReturn, $sessionState = $context.Request.Url.Query -split "&"
        }
        finally {
            $http.Stop()
            $http.Dispose()
        }

        if (-not $stateReturn) {
            Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Authentication failed (see browser for details)" -Category AuthenticationError
        }

        if ($stateReturn -match '^error_description=') {
            $message = $stateReturn -replace '^error_description=' -replace '\+',' '
            $message = [System.Web.HttpUtility]::UrlDecode($message)
            Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Error processing the request: $message" -Category InvalidOperation
        }

        if ($state -ne $stateReturn.Split("=")[1]) {
            Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Received invalid authentication result. Likely returned from another flow redirecting to the same local port!" -Category InvalidOperation
        }

        $actualAuthorizationCode = $authorizationCode.Split("=")[1]

        $body = @{
            client_id    = $ClientID
            scope        = $actualScopes -join " "
            code         = $actualAuthorizationCode
            redirect_uri = $redirectUri
            grant_type   = 'authorization_code'
        }
        $uri = "https://login.microsoftonline.com/$TenantID/oauth2/v2.0/token"
        try { $authResponse = Invoke-RestMethod -Method Post -Uri $uri -Body $body -ErrorAction Stop }
        catch {
            if ($_ -notmatch '"error":\s*"invalid_client"') { Invoke-TerminatingException -Cmdlet $PSCmdlet -ErrorRecord $_ }
            Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "The App Registration $ClientID has not been configured correctly. Ensure you have a 'Mobile and desktop applications' platform with redirect to 'http://localhost' configured (and not a 'Web' Platform). $_" -Category $_.CategoryInfo.Category
        }
        Read-AuthResponse -AuthResponse $authResponse
    }
}

function Connect-ServiceCertificate {
    <#
    .SYNOPSIS
        Connects to AAD using a application ID and a certificate.
     
    .DESCRIPTION
        Connects to AAD using a application ID and a certificate.
     
    .PARAMETER Resource
        The resource owning the api permissions / scopes requested.
     
    .PARAMETER Certificate
        The certificate to use for authentication.
     
    .PARAMETER TenantID
        The ID of the tenant/directory to connect to.
     
    .PARAMETER ClientID
        The ID of the registered application used to authenticate as.
     
    .EXAMPLE
        PS C:\> Connect-ServiceCertificate -Certificate $cert -TenantID $tenantID -ClientID $clientID
     
        Connects to the specified tenant using the specified app & cert.
     
    .LINK
        https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-certificate-credentials
#>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $Resource,

        [Parameter(Mandatory = $true)]
        [System.Security.Cryptography.X509Certificates.X509Certificate2]
        $Certificate,
        
        [Parameter(Mandatory = $true)]
        [string]
        $TenantID,
        
        [Parameter(Mandatory = $true)]
        [string]
        $ClientID
    )
    
    #region Build Signature Payload
    $jwtHeader = @{
        alg = "RS256"
        typ = "JWT"
        x5t = [Convert]::ToBase64String($Certificate.GetCertHash()) -replace '\+', '-' -replace '/', '_' -replace '='
    }
    $encodedHeader = $jwtHeader | ConvertTo-Json | ConvertTo-Base64
    $claims = @{
        aud = "https://login.microsoftonline.com/$TenantID/v2.0"
        exp = ((Get-Date).AddMinutes(5) - (Get-Date -Date '1970.1.1')).TotalSeconds -as [int]
        iss = $ClientID
        jti = "$(New-Guid)"
        nbf = ((Get-Date) - (Get-Date -Date '1970.1.1')).TotalSeconds -as [int]
        sub = $ClientID
    }
    $encodedClaims = $claims | ConvertTo-Json | ConvertTo-Base64
    $jwtPreliminary = $encodedHeader, $encodedClaims -join "."
    $jwtSigned = ($jwtPreliminary | ConvertTo-SignedString -Certificate $Certificate) -replace '\+', '-' -replace '/', '_' -replace '='
    $jwt = $jwtPreliminary, $jwtSigned -join '.'
    #endregion Build Signature Payload
    
    $body = @{
        client_id             = $ClientID
        client_assertion      = $jwt
        client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
        scope                 = '{0}/.default' -f $Resource
        grant_type            = 'client_credentials'
    }
    $header = @{
        Authorization = "Bearer $jwt"
    }
    $uri = "https://login.microsoftonline.com/$TenantID/oauth2/v2.0/token"
    
    try { $authResponse = Invoke-RestMethod -Method Post -Uri $uri -Body $body -Headers $header -ContentType 'application/x-www-form-urlencoded' -ErrorAction Stop }
    catch { throw }
    
    Read-AuthResponse -AuthResponse $authResponse
}

function Connect-ServiceClientSecret {
    <#
    .SYNOPSIS
        Connets using a client secret.
     
    .DESCRIPTION
        Connets using a client secret.
     
    .PARAMETER Resource
        The resource owning the api permissions / scopes requested.
     
    .PARAMETER ClientID
        The ID of the registered app used with this authentication request.
     
    .PARAMETER TenantID
        The ID of the tenant connected to with this authentication request.
     
    .PARAMETER ClientSecret
        The actual secret used for authenticating the request.
     
    .EXAMPLE
        PS C:\> Connect-ServiceClientSecret -ClientID '<ClientID>' -TenantID '<TenantID>' -ClientSecret $secret
     
        Connects to the specified tenant using the specified client and secret.
#>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $Resource,

        [Parameter(Mandatory = $true)]
        [string]
        $ClientID,
        
        [Parameter(Mandatory = $true)]
        [string]
        $TenantID,
        
        [Parameter(Mandatory = $true)]
        [securestring]
        $ClientSecret
    )
    
    process {
        $body = @{
            resource      = $Resource
            client_id     = $ClientID
            client_secret = [PSCredential]::new('NoMatter', $ClientSecret).GetNetworkCredential().Password
            grant_type    = 'client_credentials'
        }
        try { $authResponse = Invoke-RestMethod -Method Post -Uri "https://login.microsoftonline.com/$TenantId/oauth2/token" -Body $body -ErrorAction Stop }
        catch { throw }
        
        Read-AuthResponse -AuthResponse $authResponse
    }
}

function Connect-ServiceDeviceCode {
    <#
    .SYNOPSIS
        Connects to Azure AD using the Device Code authentication workflow.
     
    .DESCRIPTION
        Connects to Azure AD using the Device Code authentication workflow.
     
    .PARAMETER Resource
        The resource owning the api permissions / scopes requested.
 
    .PARAMETER ClientID
        The ID of the registered app used with this authentication request.
     
    .PARAMETER TenantID
        The ID of the tenant connected to with this authentication request.
     
    .PARAMETER Scopes
        The scopes to request.
        Automatically scoped to the service specified via Service Url.
        Defaults to ".Default"
 
    .PARAMETER NoReconnect
        Disables automatic reconnection.
        By default, this module will automatically try to reaquire a new token before the old one expires.
     
    .EXAMPLE
        PS C:\> Connect-ServiceDeviceCode -ServiceUrl $url -ClientID '<ClientID>' -TenantID '<TenantID>'
     
        Connects to the specified tenant using the specified client, prompting the user to authorize via Browser.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingWriteHost", "")]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $Resource,

        [Parameter(Mandatory = $true)]
        [string]
        $ClientID,
        
        [Parameter(Mandatory = $true)]
        [string]
        $TenantID,
        
        [AllowEmptyCollection()]
        [string[]]
        $Scopes,

        [switch]
        $NoReconnect
    )

    if (-not $Scopes) { $Scopes = @('.default') }
    $actualScopes = $Scopes | Resolve-ScopeName -Resource $Resource

    if (-not $NoReconnect) {
        $actualScopes = @($actualScopes) + 'offline_access'
    }

    try {
        $initialResponse = Invoke-RestMethod -Method POST -Uri "https://login.microsoftonline.com/$TenantID/oauth2/v2.0/devicecode" -Body @{
            client_id = $ClientID
            scope     = $actualScopes -join " "
        } -ErrorAction Stop
    }
    catch {
        throw
    }

    Write-Host $initialResponse.message

    $paramRetrieve = @{
        Uri         = "https://login.microsoftonline.com/$TenantID/oauth2/v2.0/token"
        Method      = "POST"
        Body        = @{
            grant_type  = "urn:ietf:params:oauth:grant-type:device_code"
            client_id   = $ClientID
            device_code = $initialResponse.device_code
        }
        ErrorAction = 'Stop'
    }
    $limit = (Get-Date).AddSeconds($initialResponse.expires_in)
    while ($true) {
        if ((Get-Date) -gt $limit) {
            Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Timelimit exceeded, device code authentication failed" -Category AuthenticationError
        }
        Start-Sleep -Seconds $initialResponse.interval
        try { $authResponse = Invoke-RestMethod @paramRetrieve }
        catch {
            if ($_ -match '"error":\s*"authorization_pending"') { continue }
            $PSCmdlet.ThrowTerminatingError($_)
        }
        if ($authResponse) {
            break
        }
    }

    Read-AuthResponse -AuthResponse $authResponse
}

function Connect-ServiceIdentity {
    <#
    .SYNOPSIS
        Connect as the current Managed Identity.
 
    .DESCRIPTION
        Connect as the current Managed Identity.
        Only works from within the context of a managed environment, such as Azure Functions with enabled MSI.
 
    .PARAMETER Resource
        The resource to get a token for.
 
    .PARAMETER IdentityID
        ID of the User-Managed Identity to connect as.
 
    .PARAMETER IdentityType
        Type of the User-Managed Identity.
 
    .PARAMETER Cmdlet
        The $PSCmdlet of the calling command.
        If specified, errors are triggered in the caller's context.
 
    .EXAMPLE
        PS C:\> Connect-ServiceIdentity -Resource 'https://vault.azure.net'
 
        Connect as the current managed identity, retrieving a token for the Azure Key Vault.
 
    .LINK
        https://learn.microsoft.com/en-us/azure/app-service/overview-managed-identity
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $Resource,

        [AllowEmptyString()]
        [AllowNull()]
        [string]
        $IdentityID,

        [AllowEmptyString()]
        [AllowNull()]
        [string]
        $IdentityType,

        $Cmdlet = $PSCmdlet
    )
    process {
        # Logic for Azure VMs
        try {
            $vmMetadata = $null
            $vmMetadata = Invoke-RestMethod -Headers @{Metadata = "true" } -Method GET -NoProxy -Uri "http://169.254.169.254/metadata/instance?api-version=2021-02-01"
        }
        catch {
            $vmMetadata = $null
        }
        if ($vmMetadata.compute.azEnvironment -like "*Azure*") {
            Write-Verbose "We are running on an Azure VM. Setting Environment Variables"
            $isAzureVM = $true
            $env:IDENTITY_ENDPOINT = "http://169.254.169.254/metadata/identity/oauth2/token"
            $env:IDENTITY_API_VERSION = "2018-02-01"
        }

        if ((-not $env:IDENTITY_ENDPOINT) -or (-not $env:IDENTITY_HEADER)) {
            Invoke-TerminatingException -Cmdlet $Cmdlet -Message "Cannot identify a Managed Identity. MSI logon not possible!" -Category ConnectionError
        }

        $apiVersion = $env:IDENTITY_API_VERSION
        if (-not $apiVersion) { $apiVersion = '2019-08-01' }

        $url = "$($env:IDENTITY_ENDPOINT)?resource=$Resource&api-version=$apiVersion"
        if ($IdentityID) {
            $labels = @{
                ClientID    = 'client_id'
                ResourceID  = 'mi_res_id'
                PrincipalID = 'principal_id'
            }
            $url = $url + "&$($labels[$IdentityType])=$($IdentityID)"
        }

        try {
            Write-Verbose "$url"
            if ($isAzureVM) {
                $headers = @{Metadata = 'true' }
            }
            else {
                $headers = @{'X-IDENTITY-HEADER' = $env:IDENTITY_HEADER }
            }
            $authResponse = Invoke-RestMethod -Uri $url -Headers $headers -ErrorAction Stop
        }
        catch {
            Invoke-TerminatingException -Cmdlet $Cmdlet -Message "Failed to connect via Managed Identity: $_" -ErrorRecord $_
        }

        Read-AuthResponse -AuthResponse $authResponse
    }
}

function Connect-ServicePassword {
    <#
    .SYNOPSIS
        Connect to graph using username and password.
     
    .DESCRIPTION
        Connect to graph using username and password.
        This logs into graph as a user, not as an application.
        Only cloud-only accounts can be used for this workflow.
        Consent to scopes must be granted before using them, as this command cannot show the consent prompt.
     
    .PARAMETER Resource
        The resource owning the api permissions / scopes requested.
 
    .PARAMETER Credential
        Credentials of the user to connect as.
         
    .PARAMETER TenantID
        The Guid of the tenant to connect to.
 
    .PARAMETER ClientID
        The ClientID / ApplicationID of the application to use.
     
    .PARAMETER Scopes
        The permission scopes to request.
     
    .EXAMPLE
        PS C:\> Connect-ServicePassword -Credential max@contoso.com -ClientID $client -TenantID $tenant -Scopes 'user.read','user.readbasic.all'
         
        Connect as max@contoso.com with the rights to read user information.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $Resource,

        [Parameter(Mandatory = $true)]
        [System.Management.Automation.PSCredential]
        $Credential,
        
        [Parameter(Mandatory = $true)]
        [string]
        $ClientID,
        
        [Parameter(Mandatory = $true)]
        [string]
        $TenantID,
        
        [string[]]
        $Scopes = '.default'
    )

    $actualScopes = $Scopes | Resolve-ScopeName -Resource $Resource
    
    $request = @{
        client_id  = $ClientID
        scope      = $actualScopes -join " "
        username   = $Credential.UserName
        password   = $Credential.GetNetworkCredential().Password
        grant_type = 'password'
    }
    
    try { $authResponse = Invoke-RestMethod -Method POST -Uri "https://login.microsoftonline.com/$TenantID/oauth2/v2.0/token" -Body $request -ErrorAction Stop }
    catch { throw }
    
    Read-AuthResponse -AuthResponse $authResponse
}

function Connect-ServiceRefreshToken {
    <#
    .SYNOPSIS
        Connect with the refresh token provided previously.
     
    .DESCRIPTION
        Connect with the refresh token provided previously.
        Used mostly for delegate authentication flows to avoid interactivity.
 
    .PARAMETER Token
        The EntraToken object with the refresh token to use.
        The token is then refreshed in-place with no output provided.
     
    .EXAMPLE
        PS C:\> Connect-ServiceRefreshToken
         
        Connect with the refresh token provided previously.
    #>

    [CmdletBinding()]
    param (
        $Token
    )
    process {
        if (-not $Token.RefreshToken) {
            throw "Failed to refresh token: No refresh token found!"
        }

        $scopes = $Token.Scopes

        $body = @{
            client_id = $Token.ClientID
            scope = @($scopes).ForEach{"$($Token.Audience)/$($_)"} -join " "
            refresh_token = $Token.RefreshToken
            grant_type = 'refresh_token'
        }
        $uri = "https://login.microsoftonline.com/$($Token.TenantID)/oauth2/v2.0/token"
        $authResponse = Invoke-RestMethod -Method Post -Uri $uri -Body $body
        $Token.SetTokenMetadata((Read-AuthResponse -AuthResponse $authResponse))
    }
}

function Get-VaultSecret {
    <#
    .SYNOPSIS
        Retrieve a secret from Azure Key Vault.
     
    .DESCRIPTION
        Retrieve a secret from Azure Key Vault.
        Works for both certificates and secrets.
 
        Requires one of ...
        - An established connection with the AzureKeyVault service.
        - An established AZ session via Az.Accounts with the Az.KeyVault module present.
     
    .PARAMETER VaultName
        Name of the Vault to query.
     
    .PARAMETER SecretName
        Name of the Secret to retrieve.
     
    .PARAMETER Cmdlet
        The $PSCmdlet object of the caller, enabling errors to happen within the scope of the caller.
        Defaults to the current command's $PSCmdlet
     
    .EXAMPLE
        PS C:\> Get-VaultSecret -VaultName myvault -SecretName mysecret
         
        Retrieves the latest enabled version of mysecret from myvault
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingConvertToSecureStringWithPlainText", "")]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $VaultName,
        
        [Parameter(Mandatory = $true)]
        [string]
        $SecretName,

        $Cmdlet = $PSCmdlet
    )

    process {
        #region Via EntraAuth
        if (Get-EntraToken -Service AzureKeyVault) {
            try {
                $secretVersion = Invoke-EntraRequest -Service AzureKeyVault -Path "secrets/$SecretName/versions" -VaultName $VaultName -ErrorAction Stop | Where-Object {
                    $_.attributes.enabled
                } | Sort-Object { $_.attributes.created } -Descending | Select-Object -First 1
                $secretData = Invoke-EntraRequest -Service AzureKeyVault -Path $secretVersion.id -VaultName $VaultName -ErrorAction Stop
            }
            catch {
                Invoke-TerminatingException -Cmdlet $Cmdlet -ErrorRecord $_ -Message "Failed to retrieve secret '$SecretName' from '$VaultName'! $_"
            }

            if ($secretVersion.contentType) {
                $secretBytes = [convert]::FromBase64String($secretData)
                $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($secretBytes)
                [PSCustomObject]@{
                    Type         = 'Certificate'
                    Certificate  = $certificate
                    ClientSecret = $null
                }
            }
            else {
                [PSCustomObject]@{
                    Type         = 'ClientSecret'
                    Certificate  = $null
                    ClientSecret = $secretData | ConvertTo-SecureString -AsPlainText -Force
                }
            }

            return
        }
        #endregion Via EntraAuth

        #region Via Az.KeyVault
        if ((Get-Module Az.Accounts -ListAvailable) -and (Get-AzContext) -and (Get-Module Az.KeyVault -ListAvailable)) {
            try { $secret = Get-AzKeyVaultSecret -VaultName $VaultName -Name $SecretName }
            catch { Invoke-TerminatingException -Cmdlet $Cmdlet -ErrorRecord $_ -Message "Error accessing the secret '$Secretname' from Vault '$VaultName'. $_" }

            $type = 'Certificate'
            if (-not $secret.ContentType) { $type = 'ClientSecret' }

            $certificate = $null
            $clientSecret = $secret.SecretValue

            if ($type -eq 'Certificate') {
                $certString = [PSCredential]::New("irrelevant", $secret.SecretValue).GetNetworkCredential().Password
                $bytes = [convert]::FromBase64String($certString)
                $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($bytes)
                $clientSecret = $null
            }
            
            [PSCustomObject]@{
                Type         = $type
                Certificate  = $certificate
                ClientSecret = $clientSecret
            }

            return
        }
        #endregion Via Az.KeyVault

        Invoke-TerminatingException -Cmdlet $Cmdlet -Message "Not connected to azure yet! Either use 'Connect-EntraService -Service AzureKeyVault' or 'Connect-AzAccount' before trying to connect via KeyVault!" -Category ConnectionError
    }
}

function Read-AuthResponse {
    <#
    .SYNOPSIS
        Produces a standard output representation of the authentication response received.
     
    .DESCRIPTION
        Produces a standard output representation of the authentication response received.
        This streamlines the token processing and simplifies the connection code.
     
    .PARAMETER AuthResponse
        The authentication response received.
     
    .EXAMPLE
        PS C:\> Read-AuthResponse -AuthResponse $authResponse
 
        Reads the authentication details received.
    #>

    [CmdletBinding()]
    param (
        $AuthResponse
    )
    process {
        if ($AuthResponse.expires_in) {
            $after = (Get-Date).AddMinutes(-5)
            $until = (Get-Date).AddSeconds($AuthResponse.expires_in)
        }
        else {
            if ($AuthResponse.not_before)  { $after = (Get-Date -Date '1970-01-01').AddSeconds($AuthResponse.not_before).ToLocalTime() }
            else { $after = Get-Date }
            $until = (Get-Date -Date '1970-01-01').AddSeconds($AuthResponse.expires_on).ToLocalTime()
        }
        $scopes = @()
        if ($AuthResponse.scope) { $scopes = $authResponse.scope -split " " }

        [pscustomobject]@{
            AccessToken  = $AuthResponse.access_token
            ValidAfter   = $after
            ValidUntil   = $until
            Scopes       = $scopes
            RefreshToken = $AuthResponse.refresh_token
        }
    }
}

function ConvertTo-Base64 {
<#
    .SYNOPSIS
        Converts the input-string to its base 64 encoded string form.
     
    .DESCRIPTION
        Converts the input-string to its base 64 encoded string form.
     
    .PARAMETER Text
        The text to convert.
     
    .PARAMETER Encoding
        The encoding of the input text.
        Used to correctly translate the input string into bytes before converting those to base 64.
        Defaults to UTF8
     
    .EXAMPLE
        PS C:\> Get-Content .\code.ps1 -Raw | ConvertTo-Base64
     
        Reads the input file and converts its content into base64.
#>

    [OutputType([string])]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [string[]]
        $Text,
        
        [System.Text.Encoding]
        $Encoding = [System.Text.Encoding]::UTF8
    )
    
    process {
        foreach ($entry in $Text) {
            $bytes = $Encoding.GetBytes($entry)
            [Convert]::ToBase64String($bytes)
        }
    }
}

function ConvertTo-Hashtable {
    <#
    .SYNOPSIS
        Converts input objects into hashtables.
     
    .DESCRIPTION
        Converts input objects into hashtables.
        Allows explicitly including some properties only and remapping key-names as required.
     
    .PARAMETER Include
        Only select the specified properties.
     
    .PARAMETER Mapping
        Remap hashtable/property keys.
        This allows you to rename parameters before passing them through to other commands.
        Example:
        @{ Select = '$select' }
        This will map the "Select"-property/key on the input object to be '$select' on the output item.
     
    .PARAMETER InputObject
        The object to convert.
     
    .EXAMPLE
        PS C:\> $__body = $PSBoundParameters | ConvertTo-Hashtable -Include Name, UserID -Mapping $__mapping
 
        Converts the object $PSBoundParameters into a hashtable, including the keys "Name" and "UserID" and remapping them as specified in $__mapping
    #>

    [OutputType([hashtable])]
    [CmdletBinding()]
    param (
        [AllowEmptyCollection()]
        [string[]]
        $Include,

        [Hashtable]
        $Mapping = @{ },

        [Parameter(ValueFromPipeline = $true)]
        $InputObject
    )

    process {
        $result = @{ }
        if ($InputObject -is [System.Collections.IDictionary]) {
            foreach ($pair in $InputObject.GetEnumerator()) {
                if ($pair.Key -notin $Include) { continue }
                if ($Mapping[$pair.Key]) { $result[$Mapping[$pair.Key]] = $pair.Value }
                else { $result[$pair.Key] = $pair.Value }
            }
        }
        else {
            foreach ($property in $InputObject.PSObject.Properties) {
                if ($property.Name -notin $Include) { continue }
                if ($Mapping[$property.Name]) { $result[$Mapping[$property.Name]] = $property.Value }
                else { $result[$property.Name] = $property.Value }
            }
        }
        $result
    }
}

function ConvertTo-QueryString {
    <#
    .SYNOPSIS
        Convert conditions in a hashtable to a Query string to append to a webrequest.
     
    .DESCRIPTION
        Convert conditions in a hashtable to a Query string to append to a webrequest.
     
    .PARAMETER QueryHash
        Hashtable of query modifiers - usually filter conditions - to include in a web request.
 
    .PARAMETER DefaultQuery
        Default query parameters defined in the service configuration.
        Default query settings are overriden by explicit query parameters.
     
    .EXAMPLE
        PS C:\> ConvertTo-QueryString -QueryHash $Query
 
        Converts the conditions in the specified hashtable to a Query string to append to a webrequest.
    #>

    [OutputType([string])]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [Hashtable]
        $QueryHash,

        [AllowNull()]
        [hashtable]
        $DefaultQuery
    )

    process {
        if ($DefaultQuery) { $query = $DefaultQuery.Clone() }
        else { $query = @{} }

        foreach ($key in $QueryHash.Keys) {
            $query[$key] = $QueryHash[$key]
        }
        if ($query.Count -lt 1) { return '' }


        $elements = foreach ($pair in $query.GetEnumerator()) {
            '{0}={1}' -f $pair.Name, ($pair.Value -join ",")
        }
        '?{0}' -f ($elements -join '&')
    }
}

function ConvertTo-SignedString {
<#
    .SYNOPSIS
        Signs input string with the offered certificate.
     
    .DESCRIPTION
        Signs input string with the offered certificate.
     
    .PARAMETER Text
        The text to sign.
     
    .PARAMETER Certificate
        The certificate to sign with.
        The Private Key must be available.
     
    .PARAMETER Padding
        What RSA Signature padding to use.
        Defaults to Pkcs1
     
    .PARAMETER Algorithm
        What algorithm to use for signing.
        Defaults to SHA256
     
    .PARAMETER Encoding
        The encoding to use for transforming the text to bytes before signing it.
        Defaults to UTF8
     
    .EXAMPLE
        PS C:\> ConvertTo-SignedString -Text $token
     
        Signs the specified token
#>

    [OutputType([string])]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [string[]]
        $Text,
        
        [System.Security.Cryptography.X509Certificates.X509Certificate2]
        $Certificate,
        
        [Security.Cryptography.RSASignaturePadding]
        $Padding = [Security.Cryptography.RSASignaturePadding]::Pkcs1,
        
        [Security.Cryptography.HashAlgorithmName]
        $Algorithm = [Security.Cryptography.HashAlgorithmName]::SHA256,
        
        [System.Text.Encoding]
        $Encoding = [System.Text.Encoding]::UTF8
    )
    
    begin {
        $privateKey = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Certificate)
    }
    process {
        foreach ($entry in $Text) {
            $inBytes = $Encoding.GetBytes($entry)
            $outBytes = $privateKey.SignData($inBytes, $Algorithm, $Padding)
            [convert]::ToBase64String($outBytes)
        }
    }
}

function Invoke-TerminatingException
{
<#
    .SYNOPSIS
        Throw a terminating exception in the context of the caller.
     
    .DESCRIPTION
        Throw a terminating exception in the context of the caller.
        Masks the actual code location from the end user in how the message will be displayed.
     
    .PARAMETER Cmdlet
        The $PSCmdlet variable of the calling command.
     
    .PARAMETER Message
        The message to show the user.
     
    .PARAMETER Exception
        A nested exception to include in the exception object.
     
    .PARAMETER Category
        The category of the error.
     
    .PARAMETER ErrorRecord
        A full error record that was caught by the caller.
        Use this when you want to rethrow an existing error.
     
    .EXAMPLE
        PS C:\> Invoke-TerminatingException -Cmdlet $PSCmdlet -Message 'Unknown calling module'
     
        Terminates the calling command, citing an unknown caller.
#>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)]
        $Cmdlet,
        
        [string]
        $Message,
        
        [System.Exception]
        $Exception,
        
        [System.Management.Automation.ErrorCategory]
        $Category = [System.Management.Automation.ErrorCategory]::NotSpecified,
        
        [System.Management.Automation.ErrorRecord]
        $ErrorRecord
    )
    
    process{
        if ($ErrorRecord -and -not $Message) {
            $Cmdlet.ThrowTerminatingError($ErrorRecord)
        }
        
        $exceptionType = switch ($Category) {
            default { [System.Exception] }
            'InvalidArgument' { [System.ArgumentException] }
            'InvalidData' { [System.IO.InvalidDataException] }
            'AuthenticationError' { [System.Security.Authentication.AuthenticationException] }
            'InvalidOperation' { [System.InvalidOperationException] }
        }
        
        
        if ($Exception) { $newException = $Exception.GetType()::new($Message, $Exception) }
        elseif ($ErrorRecord) { $newException = $ErrorRecord.Exception.GetType()::new($Message, $ErrorRecord.Exception) }
        else { $newException = $exceptionType::new($Message) }
        $record = [System.Management.Automation.ErrorRecord]::new($newException, (Get-PSCallStack)[1].FunctionName, $Category, $Target)
        $Cmdlet.ThrowTerminatingError($record)
    }
}

function Resolve-Certificate {
    <#
    .SYNOPSIS
        Helper function to resolve certificate input.
     
    .DESCRIPTION
        Helper function to resolve certificate input.
        This function expects the full $PSBoundParameters from the calling command and will (in this order) look for these parameter names:
 
        + Certificate: A full X509Certificate2 object with private key
        + CertificateThumbprint: The thumbprint of a certificate to use. Will look first in the user store, then the machine store for it.
        + CertificateName: The subject of the certificate to look for. Will look first in the user store, then the machine store for it. Will select the certificate with the longest expiration period.
        + CertificatePath: Path to a PFX file to load. Also expects a CertificatePassword parameter to unlock the file.
     
    .PARAMETER BoundParameters
        The $PSBoundParameter variable of the caller to simplify passthrough.
        See Description for more details on what the command expects,
     
    .EXAMPLE
        PS C:\> $certificateObject = Resolve-Certificate -BoundParameters $PSBoundParameters
 
        Resolves the certificate based on the parameters provided to the calling command.
    #>

    [OutputType([System.Security.Cryptography.X509Certificates.X509Certificate2])]
    [CmdletBinding()]
    param (
        $BoundParameters
    )
    
    if ($BoundParameters.Certificate) { return $BoundParameters.Certificate }
    if ($BoundParameters.CertificateThumbprint) {
        if (Test-Path -Path "cert:\CurrentUser\My\$($BoundParameters.CertificateThumbprint)") {
            return Get-Item "cert:\CurrentUser\My\$($BoundParameters.CertificateThumbprint)"
        }
        if (Test-Path -Path "cert:\LocalMachine\My\$($BoundParameters.CertificateThumbprint)") {
            return Get-Item "cert:\LocalMachine\My\$($BoundParameters.CertificateThumbprint)"
        }
        Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Unable to find certificate with thumbprint '$($BoundParameters.CertificateThumbprint)'"
    }
    if ($BoundParameters.CertificateName) {
        if ($certificate = (Get-ChildItem 'Cert:\CurrentUser\My\').Where{ $_.Subject -eq $BoundParameters.CertificateName -and $_.HasPrivateKey }) {
            return $certificate | Sort-Object NotAfter -Descending | Select-Object -First 1
        }
        if ($certificate = (Get-ChildItem 'Cert:\LocalMachine\My\').Where{ $_.Subject -eq $BoundParameters.CertificateName -and $_.HasPrivateKey }) {
            return $certificate | Sort-Object NotAfter -Descending | Select-Object -First 1
        }
        Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Unable to find certificate with subject '$($BoundParameters.CertificateName)'"
    }
    if ($BoundParameters.CertificatePath) {
        try { [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($BoundParameters.CertificatePath, $BoundParameters.CertificatePassword) }
        catch {
            Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Unable to load certificate from file '$($BoundParameters.CertificatePath)': $_" -ErrorRecord $_
        }
    }
}

function Resolve-RequestUri {
    <#
    .SYNOPSIS
        Resolves the actual Uri used for a request in Invoke-EntraRequest.
     
    .DESCRIPTION
        Resolves the actual Uri used for a request in Invoke-EntraRequest.
        If the path provided is a full url, it will be returned as is.
        Otherwise, any present parameters will be resolved in the base service url before merging it with the specified path.
     
    .PARAMETER TokenObject
        The object representing the token used for the request.
     
    .PARAMETER ServiceObject
        The service object (if any) used with the request.
        The parameters to be inserted into the query will be read from here.
     
    .PARAMETER BoundParameters
        The parameters provided to Invoke-EntraRequest.
     
    .EXAMPLE
        PS C:\> Resolve-RequestUri -TokenObject $tokenObject -ServiceObject $script:_EntraEndpoints.$($tokenObject.Service) -BoundParameters $PSBoundParameters
 
        Resolves the uri for the needed request based on token, service and parameters provided
    #>

    [OutputType([string])]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        $TokenObject,

        [Parameter(Mandatory = $true)]
        [AllowNull()]
        $ServiceObject,

        [Parameter(Mandatory = $true)]
        $BoundParameters
    )
    process {
        if ($BoundParameters.Path -match '^https{0,1}://') {
            return $BoundParameters.Path
        }

        $serviceUrlBase = $TokenObject.ServiceUrl.Trim()
        foreach ($key in $ServiceObject.Parameters.Keys) {
            $serviceUrlBase = $serviceUrlBase -replace "%$key%", $BoundParameters.$key
        }

        "$($serviceUrlBase.TrimEnd('/'))/$($Path.TrimStart('/'))"
    }
}

function Resolve-ScopeName {
    <#
    .SYNOPSIS
        Normalizes scope names.
     
    .DESCRIPTION
        Normalizes scope names.
        To help manage correct scopes naming with services that don't map directly to their urls.
     
    .PARAMETER Scopes
        The scopes to normalize.
     
    .PARAMETER Resource
        The Resource the scopes are meant for.
     
    .EXAMPLE
        PS C:\> $scopes | Resolve-ScopeName -Resource $Resource
         
        Resolves all them scopes
    #>

    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipeline = $true)]
        [string[]]
        $Scopes,

        [Parameter(Mandatory = $true)]
        [string]
        $Resource
    )
    process {
        foreach ($scope in $Scopes) {
            foreach ($scope in $Scopes) {
                if ($scope -like 'https://*/*') { $scope }
                elseif ($scope -like 'api:/') { $scope }
                else { "{0}/{1}" -f $Resource, $scope }
            }
        }
    }
}

function Assert-ServiceName {
    <#
    .SYNOPSIS
        Asserts a service name actually exists.
     
    .DESCRIPTION
        Asserts a service name actually exists.
        Used in validation scripts to ensure proper service names were provided.
     
    .PARAMETER Name
        The name of the service to verify.
     
    .EXAMPLE
        PS C:\> Assert-ServiceName -Name $_
         
        Returns $true if the service exists and throws a terminating exception if not so.
    #>

    [OutputType([bool])]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [AllowNUll()]
        [string]
        $Name
    )
    process {
        if ($script:_EntraEndpoints.Keys -contains $Name) { return $true }

        $serviceNames = $script:_EntraEndpoints.Keys -join ', '
        Write-Warning "Invalid service name: '$Name'. Legal service names: $serviceNames"
        Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Invalid service name: '$Name'. Legal service names: $serviceNames"
    }
}

function global:Get-ServiceCompletion {
    <#
    .SYNOPSIS
        Returns the values to complete for.service names.
     
    .DESCRIPTION
        Returns the values to complete for.service names.
        Use this command in argument completers.
     
    .PARAMETER ArgumentList
        The arguments an argumentcompleter receives.
        The third item will be the word to complete.
     
    .EXAMPLE
        PS C:\> Get-ServiceCompletion -ArgumentList $args
         
        Returns the values to complete for.service names.
    #>

    [OutputType([System.Management.Automation.CompletionResult])]
    [CmdletBinding()]
    param (
        $ArgumentList
    )
    process {
        $wordToComplete = $ArgumentList[2].Trim("'`"")
        foreach ($service in Get-EntraService) {
            if ($service.Name -notlike "$($wordToComplete)*") { continue }

            $text = if ($service.Name -notmatch '\s') { $service.Name }    else { "'$($service.Name)'" }
            [System.Management.Automation.CompletionResult]::new(
                $text,
                $text,
                'Text',
                $service.ServiceUrl
            )
        }
    }
}
$ExecutionContext.InvokeCommand.GetCommand("Get-ServiceCompletion","Function").Visibility = 'Private'

function Assert-EntraConnection
{
<#
    .SYNOPSIS
        Asserts a connection has been established.
     
    .DESCRIPTION
        Asserts a connection has been established.
        Fails the calling command in a terminating exception if not connected yet.
         
    .PARAMETER Service
        The service to which a connection needs to be established.
     
    .PARAMETER Cmdlet
        The $PSCmdlet variable of the calling command.
        Used to execute the terminating exception in the caller scope if needed.
 
    .PARAMETER RequiredScopes
        Scopes needed, for better error messages.
     
    .EXAMPLE
        PS C:\> Assert-EntraConnection -Service 'Endpoint' -Cmdlet $PSCmdlet
     
        Silently does nothing if already connected to the specified defender for endpoint service.
        Kills the calling command if not yet connected.
#>

    [CmdletBinding()]
    param (
        [ArgumentCompleter({ Get-ServiceCompletion $args })]
        [Parameter(Mandatory = $true)]
        [string]
        $Service,
        
        [Parameter(Mandatory = $true)]
        $Cmdlet,
        
        [AllowEmptyCollection()]
        [string[]]
        $RequiredScopes
    )
    
    process
    {
        if ($script:_EntraTokens["$Service"]) { return }
        
        $message = "Not connected yet! Use Connect-EntraService to establish a connection to '$Service' first."
        if ($RequiredScopes) { $message = $message + " Scopes required for this call: $($RequiredScopes -join ', ')"}
        Invoke-TerminatingException -Cmdlet $Cmdlet -Message $message -Category ConnectionError
    }
}

function Connect-EntraService {
    <#
    .SYNOPSIS
        Establish a connection to an Entra Service.
     
    .DESCRIPTION
        Establish a connection to an Entra Service.
        Prerequisite before executing any requests / commands.
     
    .PARAMETER ClientID
        ID of the registered/enterprise application used for authentication.
     
    .PARAMETER TenantID
        The ID of the tenant/directory to connect to.
     
    .PARAMETER Scopes
        Any scopes to include in the request.
        Only used for interactive/delegate workflows, ignored for Certificate based authentication or when using Client Secrets.
 
    .PARAMETER Browser
        Use an interactive logon in your default browser.
        This is the default logon experience.
 
    .PARAMETER BrowserMode
        How the browser used for authentication is selected.
        Options:
        + Auto (default): Automatically use the default browser.
        + PrintLink: The link to open is printed on console and user selects which browser to paste it into (must be used on the same machine)
 
    .PARAMETER DeviceCode
        Use the Device Code delegate authentication flow.
        This will prompt the user to complete login via browser.
     
    .PARAMETER Certificate
        The Certificate object used to authenticate with.
         
        Part of the Application Certificate authentication workflow.
     
    .PARAMETER CertificateThumbprint
        Thumbprint of the certificate to authenticate with.
        The certificate must be stored either in the user or computer certificate store.
         
        Part of the Application Certificate authentication workflow.
     
    .PARAMETER CertificateName
        The name/subject of the certificate to authenticate with.
        The certificate must be stored either in the user or computer certificate store.
        The newest certificate with a private key will be chosen.
         
        Part of the Application Certificate authentication workflow.
     
    .PARAMETER CertificatePath
        Path to a PFX file containing the certificate to authenticate with.
         
        Part of the Application Certificate authentication workflow.
     
    .PARAMETER CertificatePassword
        Password to use to read a PFX certificate file.
        Only used together with -CertificatePath.
         
        Part of the Application Certificate authentication workflow.
     
    .PARAMETER ClientSecret
        The client secret configured in the registered/enterprise application.
         
        Part of the Client Secret Certificate authentication workflow.
 
    .PARAMETER Credential
        The username / password to authenticate with.
 
        Part of the Resource Owner Password Credential (ROPC) workflow.
 
    .PARAMETER VaultName
        Name of the Azure Key Vault from which to retrieve the certificate or client secret used for the authentication.
        Secrets retrieved from the vault are not cached, on token expiration they will be retrieved from the Vault again.
        In order for this flow to work, please ensure that you either have an active AzureKeyVault service connection,
        or are connected via Connect-AzAccount.
 
    .PARAMETER SecretName
        Name of the secret to use from the Azure Key Vault specified through the '-VaultName' parameter.
        In order for this flow to work, please ensure that you either have an active AzureKeyVault service connection,
        or are connected via Connect-AzAccount.
 
    .PARAMETER Identity
        Log on as the Managed Identity of the current system.
        Only works in environments with managed identities, such as Azure Function Apps or Runbooks.
 
    .PARAMETER IdentityID
        ID of the User-Managed Identity to connect as.
        https://learn.microsoft.com/en-us/azure/app-service/overview-managed-identity
 
    .PARAMETER IdentityType
        Type of the User-Managed Identity.
 
    .PARAMETER Service
        The service to connect to.
        Individual commands using Invoke-EntraRequest specify the service to use and thus identify the token needed.
        Defaults to: Graph
 
    .PARAMETER ServiceUrl
        The base url for requests to the service connecting to.
        Overrides the default service url configured with the service settings.
 
    .PARAMETER Resource
        The resource to authenticate to.
        Used to authenticate to a service without requiring a full service configuration.
        Automatically implies PassThru.
        This token is not registered as a service and cannot be implicitly used by Invoke-EntraRequest.
        Also provide the "-ServiceUrl" parameter, if you later want to use this token explicitly in Invoke-EntraRequest.
 
    .PARAMETER MakeDefault
        Makes this service the new default service for all subsequent Connect-EntraService & Invoke-EntraRequest calls.
 
    .PARAMETER PassThru
        Return the token received for the current connection.
     
    .EXAMPLE
        PS C:\> Connect-EntraService -ClientID $clientID -TenantID $tenantID
     
        Establish a connection to the graph API, prompting the user for login on their default browser.
     
    .EXAMPLE
        PS C:\> Connect-EntraService -ClientID $clientID -TenantID $tenantID -Certificate $cert
     
        Establish a connection to the graph API using the provided certificate.
     
    .EXAMPLE
        PS C:\> Connect-EntraService -ClientID $clientID -TenantID $tenantID -CertificatePath C:\secrets\certs\mde.pfx -CertificatePassword (Read-Host -AsSecureString)
     
        Establish a connection to the graph API using the provided certificate file.
        Prompts you to enter the certificate-file's password first.
     
    .EXAMPLE
        PS C:\> Connect-EntraService -Service Endpoint -ClientID $clientID -TenantID $tenantID -ClientSecret $secret
     
        Establish a connection to Defender for Endpoint using a client secret.
     
    .EXAMPLE
        PS C:\> Connect-EntraService -ClientID $clientID -TenantID $tenantID -VaultName myVault -Secretname GraphCert
     
        Establish a connection to the graph API, after retrieving the necessary certificate from the specified Azure Key Vault.
#>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseDeclaredVarsMoreThanAssignments", "")]
    [CmdletBinding(DefaultParameterSetName = 'Browser')]
    param (
        [Parameter(Mandatory = $true, ParameterSetName = 'Browser')]
        [Parameter(Mandatory = $true, ParameterSetName = 'DeviceCode')]
        [Parameter(Mandatory = $true, ParameterSetName = 'AppCertificate')]
        [Parameter(Mandatory = $true, ParameterSetName = 'AppSecret')]
        [Parameter(Mandatory = $true, ParameterSetName = 'UsernamePassword')]
        [Parameter(Mandatory = $true, ParameterSetName = 'KeyVault')]
        [string]
        $ClientID,
        
        [Parameter(Mandatory = $true, ParameterSetName = 'Browser')]
        [Parameter(Mandatory = $true, ParameterSetName = 'DeviceCode')]
        [Parameter(Mandatory = $true, ParameterSetName = 'AppCertificate')]
        [Parameter(Mandatory = $true, ParameterSetName = 'AppSecret')]
        [Parameter(Mandatory = $true, ParameterSetName = 'UsernamePassword')]
        [Parameter(Mandatory = $true, ParameterSetName = 'KeyVault')]
        [string]
        $TenantID,
        
        [Parameter(ParameterSetName = 'Browser')]
        [Parameter(ParameterSetName = 'DeviceCode')]
        [string[]]
        $Scopes,

        [Parameter(ParameterSetName = 'Browser')]
        [switch]
        $Browser,

        [Parameter(ParameterSetName = 'Browser')]
        [ValidateSet('Auto', 'PrintLink')]
        [string]
        $BrowserMode = 'Auto',

        [Parameter(Mandatory = $true, ParameterSetName = 'DeviceCode')]
        [switch]
        $DeviceCode,
        
        [Parameter(ParameterSetName = 'AppCertificate')]
        [System.Security.Cryptography.X509Certificates.X509Certificate2]
        $Certificate,
        
        [Parameter(ParameterSetName = 'AppCertificate')]
        [string]
        $CertificateThumbprint,
        
        [Parameter(ParameterSetName = 'AppCertificate')]
        [string]
        $CertificateName,
        
        [Parameter(ParameterSetName = 'AppCertificate')]
        [string]
        $CertificatePath,
        
        [Parameter(ParameterSetName = 'AppCertificate')]
        [System.Security.SecureString]
        $CertificatePassword,
        
        [Parameter(Mandatory = $true, ParameterSetName = 'AppSecret')]
        [System.Security.SecureString]
        $ClientSecret,

        [Parameter(Mandatory = $true, ParameterSetName = 'UsernamePassword')]
        [PSCredential]
        $Credential,

        [Parameter(Mandatory = $true, ParameterSetName = 'KeyVault')]
        [string]
        $VaultName,

        [Parameter(Mandatory = $true, ParameterSetName = 'KeyVault')]
        [string]
        $SecretName,

        [Parameter(Mandatory = $true, ParameterSetName = 'Identity')]
        [switch]
        $Identity,

        [Parameter(ParameterSetName = 'Identity')]
        [string]
        $IdentityID,

        [Parameter(ParameterSetName = 'Identity')]
        [ValidateSet('ClientID', 'ResourceID', 'PrincipalID')]
        [string]
        $IdentityType = 'ClientID',

        [ArgumentCompleter({ Get-ServiceCompletion $args })]
        [ValidateScript({ Assert-ServiceName -Name $_ })]
        [string[]]
        $Service = $script:_DefaultService,

        [string]
        $ServiceUrl,

        [string]
        $Resource,

        [switch]
        $MakeDefault,

        [switch]
        $PassThru
    )
    begin {
        $doRegister = $PSBoundParameters.Keys -notcontains 'Resource'
        $doPassThru = $PassThru -or $Resource
    }
    process {
        foreach ($serviceName in $Service) {
            $serviceObject = $null
            if (-not $Resource) {
                $serviceObject = Get-EntraService -Name $serviceName
            }
            else {
                $serviceName = '<custom>'
            }

            $commonParam = @{
                ClientID = $ClientID
                TenantID = $TenantID
                Resource = $serviceObject.Resource
            }
            $effectiveServiceUrl = $ServiceUrl
            if (-not $ServiceUrl -and $serviceObject) { $effectiveServiceUrl = $serviceObject.ServiceUrl }
            if ($Resource) { $commonParam.Resource = $Resource }
            
            #region Connection
            switch ($PSCmdlet.ParameterSetName) {
                #region Browser
                Browser {
                    $scopesToUse = $Scopes
                    if (-not $Scopes) { $scopesToUse = $serviceObject.DefaultScopes }

                    Write-Verbose "[$serviceName] Connecting via Browser ($($scopesToUse -join ', '))"
                    try { $result = Connect-ServiceBrowser @commonParam -SelectAccount -Scopes $scopesToUse -NoReconnect:$($serviceObject.NoRefresh) -BrowserMode $BrowserMode -ErrorAction Stop }
                    catch {
                        Write-Warning "[$serviceName] Failed to connect: $_"
                        $PSCmdlet.ThrowTerminatingError($_)
                    }
                    
                    $token = [EntraToken]::new($serviceName, $ClientID, $TenantID, $effectiveServiceUrl, $false)
                    if ($serviceObject.Header.Count -gt 0) { $token.Header = $serviceObject.Header.Clone() }
                    $token.SetTokenMetadata($result)
                    if ($doRegister) { $script:_EntraTokens[$serviceName] = $token }
                    Write-Verbose "[$serviceName] Connected via Browser ($($token.Scopes -join ', '))"
                }
                #endregion Browser

                #region DeviceCode
                DeviceCode {
                    $scopesToUse = $Scopes
                    if (-not $Scopes) { $scopesToUse = $serviceObject.DefaultScopes }

                    Write-Verbose "[$serviceName] Connecting via DeviceCode ($($scopesToUse -join ', '))"
                    try { $result = Connect-ServiceDeviceCode @commonParam -Scopes $scopesToUse -NoReconnect:$($serviceObject.NoRefresh) -ErrorAction Stop }
                    catch {
                        Write-Warning "[$serviceName] Failed to connect: $_"
                        $PSCmdlet.ThrowTerminatingError($_)
                    }

                    $token = [EntraToken]::new($serviceName, $ClientID, $TenantID, $effectiveServiceUrl, $true)
                    if ($serviceObject.Header.Count -gt 0) { $token.Header = $serviceObject.Header.Clone() }
                    $token.SetTokenMetadata($result)
                    if ($doRegister) { $script:_EntraTokens[$serviceName] = $token }
                    Write-Verbose "[$serviceName] Connected via DeviceCode ($($token.Scopes -join ', '))"
                }
                #endregion DeviceCode

                #region ROPC
                UsernamePassword {
                    Write-Verbose "[$serviceName] Connecting via Credential"
                    try { $result = Connect-ServicePassword @commonParam -Credential $Credential -ErrorAction Stop }
                    catch {
                        Write-Warning "[$serviceName] Failed to connect: $_"
                        $PSCmdlet.ThrowTerminatingError($_)
                    }

                    $token = [EntraToken]::new($serviceName, $ClientID, $TenantID, $Credential, $effectiveServiceUrl)
                    if ($serviceObject.Header.Count -gt 0) { $token.Header = $serviceObject.Header.Clone() }
                    $token.SetTokenMetadata($result)
                    if ($doRegister) { $script:_EntraTokens[$serviceName] = $token }
                    Write-Verbose "[$serviceName] Connected via Credential ($($token.Scopes -join ', '))"
                }
                #endregion ROPC

                #region AppSecret
                AppSecret {
                    Write-Verbose "[$serviceName] Connecting via AppSecret"
                    try { $result = Connect-ServiceClientSecret @commonParam -ClientSecret $ClientSecret -ErrorAction Stop }
                    catch {
                        Write-Warning "[$serviceName] Failed to connect: $_"
                        $PSCmdlet.ThrowTerminatingError($_)
                    }

                    $token = [EntraToken]::new($serviceName, $ClientID, $TenantID, $ClientSecret, $effectiveServiceUrl)
                    if ($serviceObject.Header.Count -gt 0) { $token.Header = $serviceObject.Header.Clone() }
                    $token.SetTokenMetadata($result)
                    if ($doRegister) { $script:_EntraTokens[$serviceName] = $token }
                    Write-Verbose "[$serviceName] Connected via AppSecret ($($token.Scopes -join ', '))"
                }
                #endregion AppSecret

                #region AppCertificate
                AppCertificate {
                    Write-Verbose "[$serviceName] Connecting via Certificate"
                    try { $certificateObject = Resolve-Certificate -BoundParameters $PSBoundParameters }
                    catch {
                        Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Cannot resolve certificate" -ErrorRecord $_ -Category InvalidArgument
                    }

                    try { $result = Connect-ServiceCertificate @commonParam -Certificate $certificateObject -ErrorAction Stop }
                    catch {
                        Write-Warning "[$serviceName] Failed to connect: $_"
                        $PSCmdlet.ThrowTerminatingError($_)
                    }

                    $token = [EntraToken]::new($serviceName, $ClientID, $TenantID, $certificateObject, $effectiveServiceUrl)
                    if ($serviceObject.Header.Count -gt 0) { $token.Header = $serviceObject.Header.Clone() }
                    $token.SetTokenMetadata($result)
                    if ($doRegister) { $script:_EntraTokens[$serviceName] = $token }
                    Write-Verbose "[$serviceName] Connected via Certificate ($($token.Scopes -join ', '))"
                }
                #endregion AppCertificate
            
                #region KeyVault
                KeyVault {
                    Write-Verbose "[$serviceName] Connecting via KeyVault"
                    try { $secret = Get-VaultSecret -VaultName $VaultName -SecretName $SecretName }
                    catch {
                        Write-Warning "[$serviceName] Failed to retrieve secret from KeyVault: $_"
                        $PSCmdlet.ThrowTerminatingError($_)
                    }
                    try {
                        $result = switch ($secret.Type) {
                            Certificate { Connect-ServiceCertificate @commonParam -Certificate $secret.Certificate -ErrorAction Stop }
                            ClientSecret { Connect-ServiceClientSecret @commonParam -ClientSecret $secret.ClientSecret -ErrorAction Stop }
                        }
                    }
                    catch {
                        Write-Warning "[$serviceName] Failed to connect: $_"
                        $PSCmdlet.ThrowTerminatingError($_)
                    }
                    $token = [EntraToken]::new($serviceName, $ClientID, $TenantID, $effectiveServiceUrl, $VaultName, $SecretName)
                    if ($serviceObject.Header.Count -gt 0) { $token.Header = $serviceObject.Header.Clone() }
                    $token.SetTokenMetadata($result)
                    if ($doRegister) { $script:_EntraTokens[$serviceName] = $token }
                    Write-Verbose "[$serviceName] Connected via KeyVault ($($token.Scopes -join ', '))"
                }
                #endregion KeyVault

                #region Identity
                Identity {
                    Write-Verbose "[$serviceName] Connecting via Managed Identity"

                    $result = Connect-ServiceIdentity -Resource $commonParam.Resource -IdentityID $IdentityID -IdentityType $IdentityType -Cmdlet $PSCmdlet

                    $token = [EntraToken]::new($serviceName, $effectiveServiceUrl, $IdentityID, $IdentityType)
                    if ($serviceObject.Header.Count -gt 0) { $token.Header = $serviceObject.Header.Clone() }
                    $token.SetTokenMetadata($result)
                    if ($doRegister) { $script:_EntraTokens[$serviceName] = $token }

                    Write-Verbose "[$serviceName] Connected via Managed Identity ($($token.Scopes -join ', '))"
                }
                #endregion Identity
            }
            #endregion Connection

            if ($MakeDefault -and -not $Resource) {
                $script:_DefaultService = $serviceName
            }
            if ($doPassThru) { $token }
        }
    }
}

function Get-EntraService {
    <#
    .SYNOPSIS
        Returns the list of available Entra ID services that can be connected to.
     
    .DESCRIPTION
        Returns the list of available Entra ID services that can be connected to.
        Includes for each the endpoint/service url and the default requested scopes.
     
    .PARAMETER Name
        Name of the service to return.
        Defaults to: *
     
    .EXAMPLE
        PS C:\> Get-EntraService
 
        List all available services.
    #>

    [CmdletBinding()]
    param (
        [ArgumentCompleter({ Get-ServiceCompletion $args })]
        [string]
        $Name = '*'
    )
    process {
        $script:_EntraEndpoints.Values | Where-Object Name -like $Name
    }
}

function Get-EntraToken {
    <#
    .SYNOPSIS
        Returns the session token of an Entra ID connection.
     
    .DESCRIPTION
        Returns the session token of an Entra ID connection.
        The main use for those token objects is calling their "GetHeader()" method to get an authentication header
        that automatically refreshes tokens as needed.
     
    .PARAMETER Service
        The service for which to retrieve the token.
        Defaults to: *
     
    .EXAMPLE
        PS C:\> Get-EntraToken
         
        Returns all current session tokens
    #>

    [CmdletBinding()]
    param (
        [ArgumentCompleter({ Get-ServiceCompletion $args })]
        [string]
        $Service = '*'
    )
    process {
        $script:_EntraTokens.Values | Where-Object Service -like $Service
    }
}

function Register-EntraService {
    <#
    .SYNOPSIS
        Define a new Entra ID Service to connect to.
     
    .DESCRIPTION
        Define a new Entra ID Service to connect to.
        This allows defining new endpoints to connect to ... or overriding existing endpoints to a different configuration.
     
    .PARAMETER Name
        Name of the Service.
     
    .PARAMETER ServiceUrl
        The base Url requests will use.
     
    .PARAMETER Resource
        The Resource ID. Used when connecting to identify which scopes of an App Registration to use.
     
    .PARAMETER DefaultScopes
        Default scopes to request.
        Used in interactive delegate flows to provide a good default user experience.
        Default scopes should usually include common read scenarios.
 
    .PARAMETER Header
        Header data to include in each request.
     
    .PARAMETER HelpUrl
        Link for more information about this service.
        Ideally to documentation that helps setting up the connection.
 
    .PARAMETER NoRefresh
        Delegate authentication flows should not request refresh tokens.
        By default, delegate authentication flows will automatically request offline_access to get a refresh token.
        This refresh token allows requesting new tokens when the current one is expiring without requiring additional
        interactive logon actions.
        However, not all services support this scope.
 
    .PARAMETER Parameters
        Extra parameters a request will require.
        It expects a hashtable with the key being the parameter name, and the value being a description of that parameter.
        The ServiceUrl must include a placeholder for each parameter to insert into it.
 
        Example:
        Parameter: @{ VaultName = 'Name of the Key Vault to execute against' }
        ServiceUrl: https://%VAULTNAME%.vault.azure.net
 
    .PARAMETER Query
        Extra Query Parameters to automatically include on all requests.
     
    .EXAMPLE
        PS C:\> Register-EntraService -Name Endpoint -ServiceUrl 'https://api.securitycenter.microsoft.com/api' -Resource 'https://api.securitycenter.microsoft.com'
         
        Registers the defender for endpoint API as a service.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $Name,

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

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

        [AllowEmptyCollection()]
        [string[]]
        $DefaultScopes = @(),

        [hashtable]
        $Header = @{},

        [string]
        $HelpUrl,

        [switch]
        $NoRefresh,

        [hashtable]
        $Parameters = @{},

        [Hashtable]
        $Query = @{}
    )
    process {
        $command = Get-Command Invoke-EntraRequest
        $badParameters = $Parameters.Keys | Where-Object { $_ -in $command.Parameters.Keys }
        if ($badParameters) {
            Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Cannot define parameters that collide with Invoke-EntraRequest: $($badParameters -join ', ')"
        }

        $script:_EntraEndpoints[$Name] = [PSCustomObject]@{
            PSTypeName    = 'EntraAuth.Service'
            Name          = $Name
            ServiceUrl    = $ServiceUrl
            Resource      = $Resource
            DefaultScopes = $DefaultScopes
            Header        = $Header
            HelpUrl       = $HelpUrl
            NoRefresh     = $NoRefresh.ToBool()
            Parameters    = $Parameters
            Query         = $Query
        }
    }
}

function Set-EntraService {
    <#
    .SYNOPSIS
        Modify the settings on an existing Service configuration.
     
    .DESCRIPTION
        Modify the settings on an existing Service configuration.
        Service configurations are defined using Register-EntraService and define how connections and requests to a specific API service / endpoint are performed.
     
    .PARAMETER Name
        The name of the already existing Service configuration.
     
    .PARAMETER ServiceUrl
        The base Url requests will use.
     
    .PARAMETER Resource
        The Resource ID. Used when connecting to identify which scopes of an App Registration to use.
     
    .PARAMETER DefaultScopes
        Default scopes to request.
        Used in interactive delegate flows to provide a good default user experience.
        Default scopes should usually include common read scenarios.
 
    .PARAMETER Header
        Header data to include in each request.
     
    .PARAMETER HelpUrl
        Link for more information about this service.
        Ideally to documentation that helps setting up the connection.
 
    .PARAMETER NoRefresh
        Delegate authentication flows should not request refresh tokens.
        By default, delegate authentication flows will automatically request offline_access to get a refresh token.
        This refresh token allows requesting new tokens when the current one is expiring without requiring additional
        interactive logon actions.
        However, not all services support this scope.
     
    .EXAMPLE
        PS C:\> Set-EntraService -Name Endpoint -ServiceUrl 'https://api-us.securitycenter.microsoft.com/api'
 
        Changes the service url for the "Endpoint" service to 'https://api-us.securitycenter.microsoft.com/api'.
        Note: It is generally recommened to select the service url most suitable for your tenant, geographically:
        https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/api/exposed-apis-list?view=o365-worldwide#versioning
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [ArgumentCompleter({ Get-ServiceCompletion $args })]
        [ValidateScript({ Assert-ServiceName -Name $_ })]
        [string]
        $Name,

        [string]
        $ServiceUrl,

        [string]
        $Resource,

        [AllowEmptyCollection()]
        [string[]]
        $DefaultScopes,

        [Hashtable]
        $Header,

        [string]
        $HelpUrl,

        [switch]
        $NoRefresh
    )
    process {
        $service = $script:_EntraEndpoints.$Name
        if ($PSBoundParameters.Keys -contains 'ServiceUrl') { $service.ServiceUrl = $ServiceUrl }
        if ($PSBoundParameters.Keys -contains 'Resource') { $service.Resource = $Resource }
        if ($PSBoundParameters.Keys -contains 'DefaultScopes') { $service.DefaultScopes = $DefaultScopes }
        if ($PSBoundParameters.Keys -contains 'Header') { $service.Header = $Header }
        if ($PSBoundParameters.Keys -contains 'HelpUrl') { $service.HelpUrl = $HelpUrl }
        if ($PSBoundParameters.Keys -contains 'NoRefresh') { $service.HelpUrl = $NoRefresh.ToBool() }
    }
}

function Invoke-EntraRequest {
    <#
    .SYNOPSIS
        Executes a web request against an entra-based service
     
    .DESCRIPTION
        Executes a web request against an entra-based service
        Handles all the authentication details once connected using Connect-EntraService.
     
    .PARAMETER Path
        The relative path of the endpoint to query.
        For example, to retrieve Microsoft Graph users, it would be a plain "users".
        To access details on a particular defender for endpoint machine instead it would look thus: "machines/1e5bc9d7e413ddd7902c2932e418702b84d0cc07"
     
    .PARAMETER Body
        Any body content needed for the request.
 
    .PARAMETER Query
        Any query content to include in the request.
        In opposite to -Body this is attached to the request Url and usually used for filtering.
     
    .PARAMETER Method
        The Rest Method to use.
        Defaults to GET
     
    .PARAMETER RequiredScopes
        Any authentication scopes needed.
        Used for documentary purposes only.
 
    .PARAMETER Header
        Any additional headers to include on top of authentication and content-type.
     
    .PARAMETER Service
        Which service to execute against.
        Determines the API endpoint called to.
        Defaults to "Graph"
 
    .PARAMETER SerializationDepth
        How deeply to serialize the request body when converting it to json.
        Defaults to: 99
 
    .PARAMETER Token
        A Token as created and maintained by this module.
        If specified, it will override the -Service parameter.
 
    .PARAMETER NoPaging
        Do not automatically page through responses sets.
        By default, Invoke-EntraRequest is going to keep retrieving result pages until all data has been retrieved.
 
    .PARAMETER Raw
        Do not process the response object and instead return the raw result returned by the API.
     
    .EXAMPLE
        PS C:\> Invoke-EntraRequest -Path 'alerts' -RequiredScopes 'Alert.Read'
     
        Return a list of defender alerts.
#>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $Path,
        
        [Hashtable]
        $Body = @{ },

        [Hashtable]
        $Query = @{ },
        
        [string]
        $Method = 'GET',
        
        [string[]]
        $RequiredScopes,

        [hashtable]
        $Header = @{},
        
        [ArgumentCompleter({ Get-ServiceCompletion $args })]
        [ValidateScript({ Assert-ServiceName -Name $_ })]
        [string]
        $Service = $script:_DefaultService,

        [ValidateRange(1, 666)]
        [int]
        $SerializationDepth = 99,

        [EntraToken]
        $Token,

        [switch]
        $NoPaging,

        [switch]
        $Raw
    )
    
    DynamicParam {
        if ($Resource) { return }

        $actualService = $Service
        if (-not $actualService) { $actualService = $script:_DefaultService }
        $serviceObject = $script:_EntraEndpoints.$actualService
        if (-not $serviceObject) { return }
        if ($serviceObject.Parameters.Count -lt 1) { return }

        $results = [System.Management.Automation.RuntimeDefinedParameterDictionary]::new()
        foreach ($pair in $serviceObject.Parameters.GetEnumerator()) {
            $parameterAttribute = [System.Management.Automation.ParameterAttribute]::new()
            $parameterAttribute.ParameterSetName = '__AllParameterSets'
            $parameterAttribute.Mandatory = $true
            $parameterAttribute.HelpMessage = $pair.Value
            $attributesCollection = [System.Collections.ObjectModel.Collection[System.Attribute]]::new()
            $attributesCollection.Add($parameterAttribute)
            $RuntimeParam = [System.Management.Automation.RuntimeDefinedParameter]::new($pair.Key, [object], $attributesCollection)

            $results.Add($pair.Key, $RuntimeParam)
        }

        $results
    }

    begin {
        if ($Token) {
            $tokenObject = $Token
        }
        else {
            Assert-EntraConnection -Service $Service -Cmdlet $PSCmdlet -RequiredScopes $RequiredScopes
            $tokenObject = $script:_EntraTokens.$Service
        }
        
        $serviceObject = $script:_EntraEndpoints.$($tokenObject.Service)
    }
    process {
        $parameters = @{
            Method = $Method
            Uri    = Resolve-RequestUri -TokenObject $tokenObject -ServiceObject $script:_EntraEndpoints.$($tokenObject.Service) -BoundParameters $PSBoundParameters
        }
        
        if ($Body.Count -gt 0) {
            $parameters.Body = $Body | ConvertTo-Json -Compress -Depth $SerializationDepth
        }
        $parameters.Uri += ConvertTo-QueryString -QueryHash $Query -DefaultQuery $serviceObject.Query

        do {
            $parameters.Headers = $tokenObject.GetHeader() + $Header # GetHeader() automatically refreshes expried tokens
            Write-Verbose "Executing Request: $($Method) -> $($parameters.Uri)"
            try { $result = Invoke-RestMethod @parameters -ErrorAction Stop }
            catch {
                $letItBurn = $true
                $failure = $_

                if ($_.ErrorDetails.Message) {
                    $details = $_.ErrorDetails.Message | ConvertFrom-Json
                    if ($details.Error.Code -eq 'TooManyRequests') {
                        Write-Verbose "Throttling: $($details.error.message)"
                        $delay = 1 + ($details.error.message -replace '^.+ (\d+) .+$', '$1' -as [int])
                        if ($delay -gt 5) { Write-Warning "Request is being throttled for $delay seconds" }
                        Start-Sleep -Seconds $delay
                        try {
                            $result = Invoke-RestMethod @parameters -ErrorAction Stop
                            $letItBurn = $false
                        }
                        catch {
                            $failure = $_
                        }
                    }
                }

                if ($letItBurn) {
                    Write-Warning "Request failed: $($Method) -> $($parameters.Uri)"
                    $PSCmdlet.ThrowTerminatingError($failure)
                }
            }
            if (-not $Raw -and $result.PSObject.Properties.Where{ $_.Name -eq 'value' }) { $result.Value }
            else { $result }
            $parameters.Uri = $result.'@odata.nextLink'
        }
        while ($parameters.Uri -and -not $NoPaging)
    }
}

# Available Tokens
$script:_EntraTokens = @{}

# Endpoint Configuration for Requests
$script:_EntraEndpoints = @{}

# The default service to connect to
$script:_DefaultService = 'Graph'

# Registers the default service configurations
$endpointCfg = @{
    Name          = 'Endpoint'
    ServiceUrl    = 'https://api.securitycenter.microsoft.com/api'
    Resource      = 'https://api.securitycenter.microsoft.com'
    DefaultScopes = @()
    Header        = @{ 'Content-Type' = 'application/json' }
    HelpUrl       = 'https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/api/apis-intro?view=o365-worldwide'
}
Register-EntraService @endpointCfg

$securityCfg = @{
    Name          = 'Security'
    ServiceUrl    = 'https://api.security.microsoft.com/api'
    Resource      = 'https://security.microsoft.com/mtp/'
    DefaultScopes = @('AdvancedHunting.Read')
    Header        = @{ 'Content-Type' = 'application/json' }
    HelpUrl       = 'https://learn.microsoft.com/en-us/microsoft-365/security/defender/api-create-app-web?view=o365-worldwide'
}
Register-EntraService @securityCfg

$graphCfg = @{
    Name          = 'Graph'
    ServiceUrl    = 'https://graph.microsoft.com/v1.0'
    Resource      = 'https://graph.microsoft.com'
    DefaultScopes = @()
    HelpUrl       = 'https://developer.microsoft.com/en-us/graph/quick-start'
}
Register-EntraService @graphCfg

$graphBetaCfg = @{
    Name          = 'GraphBeta'
    ServiceUrl    = 'https://graph.microsoft.com/beta'
    Resource      = 'https://graph.microsoft.com'
    DefaultScopes = @()
    HelpUrl       = 'https://developer.microsoft.com/en-us/graph/quick-start'
}
Register-EntraService @graphBetaCfg

$azureCfg = @{
    Name          = 'Azure'
    ServiceUrl    = 'https://management.azure.com'
    Resource      = 'https://management.core.windows.net/'
    DefaultScopes = @()
    HelpUrl       = 'https://learn.microsoft.com/en-us/rest/api/azure/?view=rest-resources-2022-12-01'
}
Register-EntraService @azureCfg

$azureKeyVaultCfg = @{
    Name          = 'AzureKeyVault'
    ServiceUrl    = 'https://%VAULTNAME%.vault.azure.net'
    Resource      = 'https://vault.azure.net'
    DefaultScopes = @()
    HelpUrl       = 'https://learn.microsoft.com/en-us/rest/api/keyvault/?view=rest-keyvault-secrets-7.4'
    Parameters    = @{
        VaultName = 'Name of the Key Vault to execute against'
    }
    Query         = @{
        'api-version' = '7.4'
    }
}
Register-EntraService @azureKeyVaultCfg