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 = @{} # Workflow: Client Secret [System.Security.SecureString]$ClientSecret # Workflow: Certificate [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate # Workflow: Username & Password [PSCredential]$Credential #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' } } #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 = @{ ServiceUrl = $this.ServiceUrl TenantID = $this.TenantID ClientID = $this.ClientID } 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) } } } } 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-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 -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 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 { $after = (Get-Date -Date '1970-01-01').AddSeconds($AuthResponse.not_before).ToLocalTime() $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. .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 ) process { $elements = foreach ($pair in $QueryHash.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-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 -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 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. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseDeclaredVarsMoreThanAssignments", "")] [CmdletBinding(DefaultParameterSetName = 'Browser')] param ( [Parameter(Mandatory = $true)] [string] $ClientID, [Parameter(Mandatory = $true)] [string] $TenantID, [string[]] $Scopes, [Parameter(ParameterSetName = 'Browser')] [switch] $Browser, [Parameter(ParameterSetName = 'Browser')] [ValidateSet('Auto', 'PrintLink')] [string] $BrowserMode = 'Auto', [Parameter(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, [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 } #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. .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 ) process { $script:_EntraEndpoints[$Name] = [PSCustomObject]@{ PSTypeName = 'EntraAuth.Service' Name = $Name ServiceUrl = $ServiceUrl Resource = $Resource DefaultScopes = $DefaultScopes Header = $Header HelpUrl = $HelpUrl NoRefresh = $NoRefresh.ToBool() } } } 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 ) begin { if ($Token) { $tokenObject = $Token } else { Assert-EntraConnection -Service $Service -Cmdlet $PSCmdlet -RequiredScopes $RequiredScopes $tokenObject = $script:_EntraTokens.$Service } } process { $parameters = @{ Method = $Method Uri = "$($tokenObject.ServiceUrl.Trim("/"))/$($Path.TrimStart('/'))" } if ($Path -match '^https{0,1}://') { $parameters.Uri = $Path } if ($Body.Count -gt 0) { $parameters.Body = $Body | ConvertTo-Json -Compress -Depth $SerializationDepth } if ($Query.Count -gt 0) { $parameters.Uri += ConvertTo-QueryString -QueryHash $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 |