Back
Featured image of post Update a conditional access named location with the new external (public) IP address

Update a conditional access named location with the new external (public) IP address

With all the working from home, I would like to access my Microsoft 365 tenant from my home office, without Multi-factor authentication (MFA).

So I added my external IP to my Conditional access named locations and marked it as trusted. That worked just fine since the beginning of the SARS-CoV-2 (a/k/a Covid-19) crisis. But now my DSL modem starts to disconnect from time to time. To bad, at home I have a regular DSL contract (non Business), and you can’t get a fixed IP for those! So I have to update the conditional access named location object manually whenever my modem reconnects.

That manual process sucked, so I decided to write a quick and dirty scripted approach that will to the Job for me!

I compiled the script and it runs as service on a spare Windows 10 system that I have running anyway.

Here is the Script:

#requires -Version 3.0
   <#
         .SYNOPSIS
         Update a Conditional access named location with the new external (public) IP address

         .DESCRIPTION
         Update a Conditional access named location with the new external (public) IP address.
         Since my router disconnects from time to time, this was something I needed badly!

         .EXAMPLE
         PS C:\> .\ConditionalAccessNamedLocationToolingForGraph.ps1

         .NOTES
         Additional information about the file.
   #>
   [CmdletBinding(ConfirmImpact = 'None')]
   param ()

   begin
   {
      #region Configuration
      # Where to store the cache file
      $ToolPath = 'c:\temp\'

      # The application (client) ID for your AzureAD app, e.g. b7ca6bb8-4a3f-465d-ace2-8e8aae841162
      $ClientId = '<YourAppID>'

      # The application (client) secret (password) for your AzureAD app, e.g. Mnq(eL9Wd83(8w^roBu4
      $ClientSecret = '<TheSuperSecretPassword>'

      # Your AzureAD Domain
      # Valid is:
      # contoso.onmicrosoft.com
      # contoso.com
      # The ID (e.g. 09f89b81-0707-4f46-a6d2-c1989d515067)
      $TenantName = '<YourTenantID>'

      # The ID of the location you want to check/update, e.g. 5a28f1e1-7b97-4b0c-8f08-793a4fec7ea5
      $LocationID = '<TheLocationID>'

      # Cache the Location info on the local disk? (this is highly recommended)
      $CacheLocationInfo = $true

      # Cache File name (Json)
      $CacheLocationInfoFile = '<LocationInfoCacheFile>'

      # To you want to store the token in a global varable?
      $CacheToken = $true
      #endregion Configuration

      #region HelperFunctions
      function Compare-LocationInformation
      {
         <#
               .SYNOPSIS
               Compare the external address with the existing location information

               .DESCRIPTION
               Compare the external address with the existing location information

               .PARAMETER ReferenceObject
               The location IP Address

               .PARAMETER DifferenceObject
               The new IP external IP address

               .EXAMPLE
               PS C:\> Compare-LocationInformation -ReferenceObject $value1 -DifferenceObject $value2
               True

               Compare the external address with the existing location information and they match

               .EXAMPLE
               PS C:\> Compare-LocationInformation -ReferenceObject $value1 -DifferenceObject $value2
               False

               Compare the external address with the existing location information and they do NOT match

               .NOTES
               Only the .net ipaddress class is supported. So not use any other format here (e.g. String)
         #>
         [CmdletBinding(ConfirmImpact = 'None')]
         [OutputType([bool])]
         param
         (
            [Parameter(Mandatory, HelpMessage = 'The location IP Address',
                  ValueFromPipeline,
                  ValueFromPipelineByPropertyName,
            Position = 0)]
            [ValidateNotNullOrEmpty()]
            [Alias('ExistingIP')]
            [ipaddress]
            $ReferenceObject,
            [Parameter(Mandatory, HelpMessage = 'The new IP external IP address',
                  ValueFromPipeline,
                  ValueFromPipelineByPropertyName,
            Position = 1)]
            [ValidateNotNullOrEmpty()]
            [Alias('ExternalIP')]
            [ipaddress]
            $DifferenceObject
         )

         begin
         {
            # The default
            [bool]$Result = $false
         }

         process
         {
            if ($ReferenceObject -eq $DifferenceObject)
            {
               [bool]$Result = $true
            }
            else
            {
               [bool]$Result = $false
            }
         }

         end
         {
            # Dump the info to the console
            $Result
         }
      }

      function Get-MSGraphAuthenticationToken
      {
         <#
               .SYNOPSIS
               This function is used to get an authentication token for the Graph API REST interface

               .DESCRIPTION
               This function uses the application (client) ID and application secret to get an authentication token for the Microsoft Graph API REST interface

               .PARAMETER ClientId
               The application (client) ID that you will get in the AzureAD Application Center

               .PARAMETER ClientSecret
               The Client Secret that you will get in the AzureAD Application Center

               .PARAMETER TenantName
               The Directory (tenant) ID, Domain, or Tenant Name.

               Valid input is:
               The tenant name: contoso.onmicrosoft.com
               The directory (tenant) ID: 8076c776-6780-4e95-b62a-7e5581d159e7
               Any registered tenant domain: contoso.com

               .EXAMPLE
               PS C:\> Get-MSGraphAuthenticationToken -ClientId '8076c776-6780-4e95-b62a-7e5581d159e7' -ClientSecret 'U975D^o9iv5()(4*' -TenantName 'c24d4a92-a38f-433f-807f-5d2a1a20bd49'

               Get the access token

               .EXAMPLE
               $paramGetMSGraphAuthenticationToken = @{
               ClientId       = '8076c776-6780-4e95-b62a-7e5581d159e7'
               ClientSecret = 'U975D^o9iv5()(4*'
               TenantName   = 'c24d4a92-a38f-433f-807f-5d2a1a20bd49'
               }
               PS C:\> $GraphAccessToken = (Get-MSGraphAuthenticationToken @paramGetMSGraphAuthenticationToken)

               Get the Access Token, same as above with splated parameters

               .NOTES
               Only application (client) ID and application secret are supported here!
               If you want to use any other method to get the token, please modify or replace the function.
               I prefer to use a certificate, but in this special case, I decided to go with application (client) ID and application secret!
         #>
         [CmdletBinding(ConfirmImpact = 'None')]
         [OutputType([psobject])]
         param
         (
            [Parameter(Mandatory, HelpMessage = 'The Application (client) ID that you will get in the AzureAD Application Center',
                  ValueFromPipeline,
            ValueFromPipelineByPropertyName)]
            [ValidateNotNullOrEmpty()]
            [Alias('ApplicationID')]
            [string]
            $ClientId,
            [Parameter(Mandatory, HelpMessage = 'The Client Secret that you will get in the AzureAD Application Center',
                  ValueFromPipeline,
            ValueFromPipelineByPropertyName)]
            [ValidateNotNullOrEmpty()]
            [string]
            $ClientSecret,
            [Parameter(Mandatory, HelpMessage = 'The Directory (tenant) ID, Domain, or Tenant Name.',
                  ValueFromPipeline,
            ValueFromPipelineByPropertyName)]
            [ValidateNotNullOrEmpty()]
            [Alias('TenantID', 'DirectoryID')]
            [string]
            $TenantName
         )

         begin
         {
            # Purpose of the access token
            $ResourceValue = 'https://graph.microsoft.com/'

            # Splat the request body element
            $AuthRequestBody = @{
               Grant_Type    = 'client_credentials'
               Scope         = ($ResourceValue + '.default')
               client_Id     = $ClientId
               Client_Secret = $ClientSecret
            }

            # Cleanup
            $AccessToken = $null
         }

         process
         {
            try
            {
               $paramInvokeRestMethod = @{
                  Uri         = ('https://login.microsoftonline.com/' + $TenantName + '/oauth2/v2.0/token')
                  Method      = 'POST'
                  Body        = $AuthRequestBody
                  ErrorAction = 'Stop'
               }
               $AccessToken = (Invoke-RestMethod @paramInvokeRestMethod)
            }
            catch
            {
               #region ErrorHandler
               # get error record
               [Management.Automation.ErrorRecord]$e = $_

               # retrieve information about runtime error
               $info = [PSCustomObject]@{
                  Exception = $e.Exception.Message
                  Reason    = $e.CategoryInfo.Reason
                  Target    = $e.CategoryInfo.TargetName
                  Script    = $e.InvocationInfo.ScriptName
                  Line      = $e.InvocationInfo.ScriptLineNumber
                  Column    = $e.InvocationInfo.OffsetInLine
               }

               $info | Out-String | Write-Verbose

               Write-Error -Message ($info.Exception) -ErrorAction Stop

               # Only here to catch a global ErrorAction overwrite
               break
               #endregion ErrorHandler
            }
         }

         end
         {
            # Dump the info to the console
            $AccessToken
         }
      }

      function Get-MSGraphconditionalAccessNamedLocation
      {
         <#
               .SYNOPSIS
               Get the conditional access named location, all or single

               .DESCRIPTION
               Get the conditional access named location, all or single via Microsoft Graph Call

               .PARAMETER GraphAccessToken
               The access token for the Microsoft Graph API Call

               .PARAMETER Location
               Get one location instead of all locations?
               If you want just one, you have to specify the ID of the location here, Names are not (yet) supported.
               This might come in a future version of the function.

               .EXAMPLE
               PS C:\> Get-MSGraphconditionalAccessNamedLocation -GraphAccessToken $GraphAccessToken

               Get all conditional access named location

               .EXAMPLE
               PS C:\> Get-MSGraphconditionalAccessNamedLocation -GraphAccessToken $GraphAccessToken - Location $LocationID

               Get one conditional access named location

               .EXAMPLE
               PS C:\> Get-MSGraphconditionalAccessNamedLocation -GraphAccessToken $GraphAccessToken - Location '06abccd1-7ed4-4b63-894e-c2b323345b72'

               Get one conditional access named location

               .EXAMPLE
               $paramGetMSGraphconditionalAccessNamedLocation = @{
               GraphAccessToken = $GraphAccessToken
               Location            = $LocationID
               }
               PS C:\> Get-MSGraphconditionalAccessNamedLocation @paramGetMSGraphconditionalAccessNamedLocation

               Get one conditional access named location

               .NOTES
               Maybe the next verion work with a filter to get locations by name
         #>
         [CmdletBinding(ConfirmImpact = 'None')]
         [OutputType([psobject])]
         param
         (
            [Parameter(Mandatory, HelpMessage = 'The Access Token for the Graph Call',
                  ValueFromPipeline,
            ValueFromPipelineByPropertyName)]
            [ValidateNotNullOrEmpty()]
            [Alias('MsGraphAccessToken', 'AccessToken')]
            [psobject]
            $GraphAccessToken,
            [Parameter(ParameterSetName = 'SingleLocation',
                  ValueFromPipeline,
            ValueFromPipelineByPropertyName)]
            [ValidateNotNullOrEmpty()]
            [Alias('SingleLocation')]
            [string]
            $Location
         )

         begin
         {
            if (-not ($GraphAccessToken))
            {
               $paramWriteError = @{
                  Message      = 'The Access Token is missing'
                  Exception    = 'The Access Token is missing'
                  Category     = 'ObjectNotFound'
                  TargetObject = $GraphAccessToken
                  ErrorAction  = 'Stop'
               }
               Write-Error @paramWriteError
            }

            $BaseURI = 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations/'

            switch ($PsCmdlet.ParameterSetName)
            {
               'SingleLocation'
               {
                  $BaseURI = $BaseURI + $Location
               }
            }
         }

         process
         {
            # Cleanup
            $Result = $null

            # Splat the parameters
            $paramInvokeRestMethod = @{
               Headers = @{
                  Authorization = ('Bearer ' + $GraphAccessToken.access_token)
               }
               Uri     = $BaseURI
               Method  = 'Get'
            }

            $Result = (Invoke-RestMethod @paramInvokeRestMethod)
         }

         end
         {
            # Dump the info to the console
            $Result
         }
      }

      function Start-WaitLoop
      {
         <#
               .SYNOPSIS
               Wrapper for Start-Sleep that use minutes instead of seconds

               .DESCRIPTION
               Simple wrapper for the regular Start-Sleep cmdlet that use minutes instead of seconds.

               .PARAMETER Minutes
               The Number of minutes to wait

               .PARAMETER Hours
               The number of hours to wait

               .EXAMPLE
               PS C:\> Start-WaitLoop

               Waits 5 minutes, this is the default

               .EXAMPLE
               PS C:\> Start-WaitLoop -Hours 1

               Waits one hour

               .EXAMPLE
               PS C:\> Start-WaitLoop -Minutes

               Waits 15 minutes

               .NOTES
               If you do not pass any parameter, it will wait 5 minutes!
         #>
         [CmdletBinding(DefaultParameterSetName = 'MinutesToWait',
         ConfirmImpact = 'None')]
         param
         (
            [Parameter(ParameterSetName = 'MinutesToWait',
                  ValueFromPipeline,
            ValueFromPipelineByPropertyName)]
            [ValidateNotNullOrEmpty()]
            [Alias('min')]
            [int]
            $Minutes = 5,
            [Parameter(ParameterSetName = 'HoursToWait',
                  ValueFromPipeline,
            ValueFromPipelineByPropertyName)]
            [ValidateNotNullOrEmpty()]
            [Alias('hrs')]
            [int]
            $Hours = 1
         )

         begin
         {
            # Cleanup
            $SleepTimer = $null

            # Any parameters?
            switch ($PsCmdlet.ParameterSetName)
            {
               'MinutesToWait'
               {
                  if (-not ($Minutes))
                  {
                     [int]$Minutes = 5
                  }
                  [int]$SleepTimer = $Minutes * 60
               }
               'HoursToWait'
               {
                  if (-not ($Hours))
                  {
                     $paramWriteError = @{
                        Message      = 'Sorry, with the Hours value you have to specify something!'
                        TargetObject = $Hours
                        ErrorAction  = 'Stop'
                        Exception    = 'Sorry, with the Hours value you have to specify something!'
                        Category     = 'ObjectNotFound'
                     }
                     Write-Error @paramWriteError
                  }
                  [int]$SleepTimer = $Hours * 3600
               }
               default
               {
                  [int]$SleepTimer = 300
               }
            }
         }

         process
         {
            $paramStartSleep = @{
               Seconds = $SleepTimer
            }
            $null = (Start-Sleep @paramStartSleep)
         }

         end
         {
            # Cleanup
            $SleepTimer = $null
         }
      }

      function Start-HandleCacheLocationInfo
      {
         <#
               .SYNOPSIS
               Check if the location info is cached and get it if it exists

               .DESCRIPTION
               Check if the location info is cached and get it if it exists

               .PARAMETER Path
               Where to find the location Info

               .EXAMPLE
               PS C:\> Start-HandleCacheLocationInfo -Path '.\CacheObject.json'

               Check if the location info is cached and get it if it exists

               .NOTES
               The warning will be removed in the next version
         #>
         [CmdletBinding(ConfirmImpact = 'None')]
         [OutputType([psobject])]
         param
         (
            [Parameter(Mandatory, HelpMessage = 'Where to find the location Info',
                  ValueFromPipeline,
            ValueFromPipelineByPropertyName)]
            [ValidateNotNullOrEmpty()]
            [Alias('LocationInfoFile')]
            [string]
            $Path
         )

         begin
         {
            # Cleanup
            $Result = $null
         }

         process
         {
            $paramTestPath = @{
               Path        = $Path
               ErrorAction = 'SilentlyContinue'
            }
            if (-not (Test-Path @paramTestPath))
            {
               # Cleanup
               $Result = $null

               # This will be removed in the next version
               Write-Warning -Message 'Given Cache File does NOT exist!'
            }
            else
            {
               try
               {
                  # Get the cache file content
                  $paramGetContent = @{
                     Path        = $Path
                     Force       = $true
                     Encoding    = 'UTF8'
                     ErrorAction = 'Stop'
                  }
                  $RawJson = (Get-Content @paramGetContent)

                  # convert the json content to a PSObject
                  $paramConvertFromJson = @{
                     ErrorAction = 'Stop'
                  }
                  $Result = ($RawJson | ConvertFrom-Json @paramConvertFromJson)
               }
               catch
               {
                  #region ErrorHandler
                  # get error record
                  [Management.Automation.ErrorRecord]$e = $_

                  # retrieve information about runtime error
                  $info = [PSCustomObject]@{
                     Exception = $e.Exception.Message
                     Reason    = $e.CategoryInfo.Reason
                     Target    = $e.CategoryInfo.TargetName
                     Script    = $e.InvocationInfo.ScriptName
                     Line      = $e.InvocationInfo.ScriptLineNumber
                     Column    = $e.InvocationInfo.OffsetInLine
                  }

                  $info | Out-String | Write-Verbose

                  Write-Error -Message ($info.Exception) -ErrorAction Stop

                  # Only here to catch a global ErrorAction overwrite
                  break
                  #endregion ErrorHandler
               }
            }
         }

         end
         {
            # Dump the info to the console
            $Result
         }
      }

      function Get-ExternalIpAddress
      {
         <#
               .SYNOPSIS
               Return your external IP address from a given service

               .DESCRIPTION
               Return your external IP address from a given service

               .PARAMETER Service
               Service to use to get your external IP address

               .EXAMPLE
               PS C:\> Get-ExternalIpAddress

               Return your external IP address from 'https://ip.enatec.net/ip'

               .EXAMPLE
               PS C:\> Get-ExternalIpAddress -Service 'https://ipinfo.io/ip'

               Return your external IP address from a given service

               .NOTES
               Helper function to get the external ip address via a given web service
         #>
         [CmdletBinding(ConfirmImpact = 'None')]
         [OutputType([ipaddress])]
         param
         (
            [Parameter(ValueFromPipeline,
                  ValueFromPipelineByPropertyName,
            Position = 0)]
            [ValidateNotNullOrEmpty()]
            [ValidateSet('https://ipinfo.io/ip', 'https://ifconfig.me/ip', 'https://ip.enatec.net/ip', IgnoreCase = $true)]
            [Alias('ServiceURI', 'ServiceURL')]
            [string]
            $Service = 'https://ip.enatec.net/ip'
         )

         begin
         {
            # Cleanup
            $Result = $null
         }

         process
         {
            try
            {
               # Request the info from the given service / We also extract the IP only
               $paramInvokeWebRequest = @{
                  Uri         = $Service
                  ErrorAction = 'Stop'
               }
               [IPAddress]$Result = ((Invoke-WebRequest @paramInvokeWebRequest).Content).Trim()
            }
            catch
            {
               #region ErrorHandler
               # get error record
               [Management.Automation.ErrorRecord]$e = $_

               # retrieve information about runtime error
               $info = [PSCustomObject]@{
                  Exception = $e.Exception.Message
                  Reason    = $e.CategoryInfo.Reason
                  Target    = $e.CategoryInfo.TargetName
                  Script    = $e.InvocationInfo.ScriptName
                  Line      = $e.InvocationInfo.ScriptLineNumber
                  Column    = $e.InvocationInfo.OffsetInLine
               }

               $info | Out-String | Write-Verbose

               Write-Error -Message ($info.Exception) -ErrorAction Stop

               # Only here to catch a global ErrorAction overwrite
               break
               #endregion ErrorHandler
            }
         }

         end
         {
            # Dump the info to the console
            $Result
         }
      }

      function Get-AcctualConditionalAccessNamedLocationIp
      {
         <#
               .SYNOPSIS
               Extract the IP address from the conditional access named location object

               .DESCRIPTION
               Extract the IP address from the conditional access named location object

               .PARAMETER Object
               The conditional access named location object from the Microsoft Graph call or from the local cache.

               .EXAMPLE
               PS C:\> Get-AcctualConditionalAccessNamedLocationIp -Object $ConditionalAccessNamedLocationOject

               Extract the IP address from the conditional access named location object $ConditionalAccessNamedLocationOject

               .NOTES
               Helper function
         #>
         [CmdletBinding(ConfirmImpact = 'None')]
         [OutputType([ipaddress])]
         param
         (
            [Parameter(Mandatory,
                  ValueFromPipeline,
                  ValueFromPipelineByPropertyName,
                  Position = 0,
            HelpMessage = 'The conditional access named location object from the Microsoft Graph call or from the local cache.')]
            [ValidateNotNullOrEmpty()]
            [Alias('ConditionalAccessNamedLocationInfo')]
            [pscustomobject]
            $Object
         )

         begin
         {
            # Cleanup
            $Result = $null
         }
         process
         {
            # Exctract the IP address
            [String]$Result = ($Object.ipRanges | Select-Object -ExpandProperty cidrAddress)

            # Mangle the object to remove the CIDR part (should be /32)
            [IPAddress]$Result = (($Result -split '/')[0])
         }

         end
         {
            # Dump the info to the console
            $Result
         }
      }

      function Set-MSGraphConditionalAccessNamedLocation
      {
         <#
               .SYNOPSIS
               Modify the conditional access named location via Microsoft Graph

               .DESCRIPTION
               Modify the conditional access named location via Microsoft Graph.
               It will update the IP Address

               .PARAMETER GraphAccessToken
               The access token for the Graph Call

               .PARAMETER Location
               The location obect (cached or from the API call

               .PARAMETER UpdatedIP
               The new external IP Address

               .PARAMETER
               A description of the parameter.

               .EXAMPLE
               PS C:\> Set-MSGraphConditionalAccessNamedLocation -GraphAccessToken $GraphAccessToken -Location $CachedLocationInfoData -UpdatedIP $ActIP

               Modify the conditional access named location via Microsoft Graph, the Token is stored in the $GraphAccessToken,
               the location in $CachedLocationInfoData, and the new ip in $ActIP

               .EXAMPLE
               $paramSetMSGraphConditionalAccessNamedLocation = @{
               GraphAccessToken = $GraphAccessToken
               Location         = $CachedLocationInfoData
               UpdatedIP          = $ActIP
               }
               PS C:\> Set-MSGraphConditionalAccessNamedLocation @paramSetMSGraphConditionalAccessNamedLocation

               Modify the conditional access named location via Microsoft Graph, the Token is stored in the $GraphAccessToken,
               the location in $CachedLocationInfoData, and the new ip in $ActIP

               .NOTES
               There is no feedback in any kind.
         #>
         [CmdletBinding(ConfirmImpact = 'None')]
         param
         (
            [Parameter(Mandatory,
                  ValueFromPipeline,
                  ValueFromPipelineByPropertyName,
                  Position = 0,
            HelpMessage = 'The Access Token for the Graph Call')]
            [ValidateNotNullOrEmpty()]
            [Alias('MsGraphAccessToken', 'AccessToken')]
            [psobject]
            $GraphAccessToken,
            [Parameter(Mandatory,
                  ValueFromPipeline,
                  ValueFromPipelineByPropertyName,
                  Position = 1,
            HelpMessage = 'The Location Obect (Cached or from the API call')]
            [ValidateNotNullOrEmpty()]
            [Alias('SingleLocation')]
            [psobject]
            $Location,
            [Parameter(Mandatory,
                  ValueFromPipeline,
                  ValueFromPipelineByPropertyName,
                  Position = 2,
            HelpMessage = 'The new external UP Address')]
            [ValidateNotNullOrEmpty()]
            [Alias('NewIP', 'ExternalIP')]
            [ipaddress]
            $UpdatedIP
         )

         begin
         {
            if (-not ($GraphAccessToken))
            {
               $paramWriteError = @{
                  Message      = 'The Access Token is missing'
                  Exception    = 'The Access Token is missing'
                  Category     = 'ObjectNotFound'
                  TargetObject = $GraphAccessToken
                  ErrorAction  = 'Stop'
               }
               Write-Error @paramWriteError
            }

            # Extract the location ID
            $LocationID = (($Location).id)

            # URI to call
            $BaseURI = 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations/' + $LocationID

            # Extract the IP address
            [String]$UpdatedIP = (($UpdatedIP).IPAddressToString).Trim()
         }

         process
         {
            # Modify the location object
            $Location.ipRanges | ForEach-Object {
               if ($_ -match '/32')
               {
                  # Change to the new address in Sigle IP CIDR (fixed value only)
                  $_.cidrAddress = ($UpdatedIP + '/32')
               }
            }

            $paramConvertToJson = @{
               InputObject = $Location
               Compress    = $true
            }
            $paramInvokeRestMethod = @{
               Headers = @{
                  Authorization = ('Bearer ' + $GraphAccessToken.access_token)
                  'Content-type' = 'application/json'
               }
               Uri     = $BaseURI
               Method  = 'Patch'
               Body    = (ConvertTo-Json @paramConvertToJson)
            }

            if ($PsCmdlet.ShouldProcess('Conditional Access Named Locations', 'Update via Microsoft Graph'))
            {
               $null = (Invoke-RestMethod @paramInvokeRestMethod)
            }
         }
      }
      #endregion HelperFunctions
   }

   process
   {
      #region ExecuteLogic
      try
      {
         # Do we need a new access token?
         if (-not ($GraphAccessToken))
         {
            # Splat the parameters
            $paramGetMSGraphAuthenticationToken = @{
               ClientId     = $ClientId
               ClientSecret = $ClientSecret
               TenantName   = $TenantName
            }
            # Get the access token
            $GraphAccessToken = (Get-MSGraphAuthenticationToken @paramGetMSGraphAuthenticationToken)

            if ($CacheToken -eq $true)
            {
               $Global:GraphAccessToken = $GraphAccessToken
            }
         }

         # Cleanup
         $CachedLocationInfoData = $null

         # Are we using caching?
         if (($CacheLocationInfo -eq $true) -and ($CacheLocationInfoFile))
         {
            # Call the Helper function to handle the cache
            $paramStartHandleCacheLocationInfo = @{
               Path = ($ToolPath + $CacheLocationInfoFile)
            }
            $CachedLocationInfoData = (Start-HandleCacheLocationInfo @paramStartHandleCacheLocationInfo)
         }

         # Do we have any cached infos?
         if (-not ($CachedLocationInfoData))
         {
            # Get the Location we want
            $paramGetMSGraphconditionalAccessNamedLocation = @{
               GraphAccessToken = $GraphAccessToken
               Location         = $LocationID
            }
            $CachedLocationInfoData = (Get-MSGraphconditionalAccessNamedLocation @paramGetMSGraphconditionalAccessNamedLocation)


            # Cache the Info ?
            if (($CacheLocationInfo -eq $true) -and ($CacheLocationInfoFile))
            {
               # Covert the object to JSON and store it in a local file
               $paramConvertToJson = @{
                  InputObject = $CachedLocationInfoData
                  Compress    = $true
               }
               $paramNewItem = @{
                  Path  = ($ToolPath + $CacheLocationInfoFile)
                  Force = $true
               }
               $null = (ConvertTo-Json @paramConvertToJson | New-Item @paramNewItem)
            }
         }

         # Remove Objects that might cause issues later
         try
         {
            $CachedLocationInfoData.PSObject.Properties.Remove('@odata.context')
            $CachedLocationInfoData.PSObject.Properties.Remove('createdDateTime')
            $CachedLocationInfoData.PSObject.Properties.Remove('modifiedDateTime')
         }
         catch
         {
            Write-Verbose -Message 'Whoopsie'
         }

         # Cleanup
         $ActIP = $null
         $MyTrustedIP = $null

         # Get the external IP address
         $paramGetExternalIpAddress = @{
            Service = 'https://ipinfo.io/ip'
         }
         [ipaddress]$ActIP = (Get-ExternalIpAddress @paramGetExternalIpAddress)

         # Get the conditional access named location IP value
         $paramGetAcctualConditionalAccessNamedLocationIp = @{
            Object = $CachedLocationInfoData
         }
         [IPAddress]$MyTrustedIP = (Get-AcctualConditionalAccessNamedLocationIp @paramGetAcctualConditionalAccessNamedLocationIp)

         # Compare the objects we have
         $paramCompareLocationInformation = @{
            ReferenceObject  = $MyTrustedIP
            DifferenceObject = $ActIP
         }
         if ((Compare-LocationInformation @paramCompareLocationInformation) -eq $false)
         {
            # Update the conditional access named location entry with the latest external IP
            $null = (Set-MSGraphConditionalAccessNamedLocation -GraphAccessToken $GraphAccessToken -Location $CachedLocationInfoData -UpdatedIP $ActIP)

            if (($CacheLocationInfo -eq $true) -and ($CacheLocationInfoFile))
            {
               # Remove the cache file
               $paramRemoveItem = @{
                  Path    = ($ToolPath + $CacheLocationInfoFile)
                  Force   = $true
                  Confirm = $false
               }
               $null = (Remove-Item @paramRemoveItem)

               # Get the location we want
               $paramGetMSGraphconditionalAccessNamedLocation = @{
                  GraphAccessToken = $GraphAccessToken
                  Location         = $LocationID
               }
               $CachedLocationInfoData = (Get-MSGraphconditionalAccessNamedLocation @paramGetMSGraphconditionalAccessNamedLocation)

               # Remove Objects that might cause issues later
               try
               {
                  $CachedLocationInfoData.PSObject.Properties.Remove('@odata.context')
                  $CachedLocationInfoData.PSObject.Properties.Remove('createdDateTime')
                  $CachedLocationInfoData.PSObject.Properties.Remove('modifiedDateTime')
               }
               catch
               {
                  Write-Verbose -Message 'Whoopsie'
               }

               # Save the info
               $paramConvertToJson = @{
                  InputObject = $CachedLocationInfoData
                  Compress    = $true
               }
               $paramNewItem = @{
                  Path  = ($ToolPath + $CacheLocationInfoFile)
                  Force = $true
               }
               $null = (ConvertTo-Json @paramConvertToJson | New-Item @paramNewItem)
            }
         }
      }
      catch
      {
         #region ErrorHandler
         # get error record
         [Management.Automation.ErrorRecord]$e = $_

         # retrieve information about runtime error
         $info = [PSCustomObject]@{
            Exception = $e.Exception.Message
            Reason    = $e.CategoryInfo.Reason
            Target    = $e.CategoryInfo.TargetName
            Script    = $e.InvocationInfo.ScriptName
            Line      = $e.InvocationInfo.ScriptLineNumber
            Column    = $e.InvocationInfo.OffsetInLine
         }

         $info | Out-String | Write-Verbose

         Write-Error -Message ($info.Exception) -ErrorAction Stop

         # Only here to catch a global ErrorAction overwrite
         break
         #endregion ErrorHandler
      }
      #endregion ExecuteLogic
   }

There is also a Gist available and my open-source repository.

You see, that everything is hardcoded, even the App secret! This is normally something I try to avoid! I use certificates instead. In this case, I decided to keep it simple and put everything to the script. It’s compiled anyway and it runs on a system where nobody should have access to! And in this case, the App I created in AzureAD have very limited permissions. I also want the solution portable, in case I have to move it to another system here in my home office!

You will need to create an App in your Microsoft 365 tenant! This app needs at least to following permissions:

  • Policy.Read.All
  • Policy.ReadWrite.ConditionalAccess

I recommend to keep the App permission as simple as possible!

There are many, many, really good instructions available that will tell you how to create an App in AzureAD for your Tenant.

Here is a very quick and brief overview:

Get and copy your Application ID

Create a Client Secret

Name your Client Secret

Copy your Client Secret

Things to know and to keep in Mind, before using the script:

  • Everything is hardcoded! Keep the script in a very secure place. Do not share it on GitHub or any other public repository service
  • There is no real error handling. As I mentioned above: Quick and dirty!
  • If you don’t know what a trusted location does, you better should not use this script! Trust me
  • If the script works, fine… If not, please try to fix it for yourself
  • The script is provided ​“AS IS
  • There are no warranties, express or implied, and hereby disclaims all implied warranties, including any warranty of merchantability and warranty of fitness for a particular purpose