Category Archives: Series: PowerShell Monitoring

Automating with PowerShell: Teams Automapping

Something like 3 years ago I wrote a blog about using PowerShell to configure Onedrive sites using the odopen protocol. This was pretty much the only method to configure Onedrive to automatically map sites and have a zero-touch configuration.

Of course over the years the management side has improved and OneDrive usage has exploded. Unfortunately the onedrive automatic mapping structure isn’t where it should be yet. For example the GPO/intune method for automatic mapping configuration can take up to 8 hours to apply on any client.

During migrations and new deployment this is pretty much unacceptable. To make sure that mapping would be instant I’ve decided to create two scripts; One Azure Function which I’ve called AzMapper, and another client based script.

AzMapper requires you to create an Azure Function. To do that follow this manual and replace the script with the one below. This script is compatible with the Secure Application Model, and as such it can check all of your partner tenants too. Meaning you’ll only need to host a single version.

Replace $ApplicationID and $ApplicationSecret with your own from the Secure App Model.

You’ll also need to give your Secure Application Model a little more permissions, specifically to read the groups:

  • Go to the Azure Portal.
  • Click on Azure Active Directory, now click on “App Registrations”.
  • Find your Secure App Model application. You can search based on the ApplicationID.
  • Go to “API Permissions” and click Add a permission.
  • Choose “Microsoft Graph” and “Application permission”.
  • Search for “Sites” and click on “Sites.Read.All”. Click on add permission.
  • Search for “Team” and click on “TeamMember.Read.All”. Click on add permission.
  • Search for “Team” and click on “Team.ReadBasic.Alll”. Click on add permission.
  • Do the same for “Delegate Permissions”.
  • Finally, click on “Grant Admin Consent for Company Name.

AzMapper

using namespace System.Net
param($Request, $TriggerMetadata)
#
$ApplicationId = 'ApplicationID'
$ApplicationSecret = 'ApplicationSecret' 
#
$TenantID = $Request.Query.TenantID
$user = $Request.Query.Username

$body = @{
    'resource'      = 'https://graph.microsoft.com'
    'client_id'     = $ApplicationId
    'client_secret' = $ApplicationSecret
    'grant_type'    = "client_credentials"
    'scope'         = "openid"
}
$ClientToken = Invoke-RestMethod -Method post -Uri "https://login.microsoftonline.com/$($tenantid)/oauth2/token" -Body $body -ErrorAction Stop
$headers = @{ "Authorization" = "Bearer $($ClientToken.access_token)" }
$UserID = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/users/$($user)" -Headers $Headers -Method Get -ContentType "application/json").id

$AllTeamsURI = "https://graph.microsoft.com/beta/users/$($UserID)/JoinedTeams"
$Teams = (Invoke-RestMethod -Uri $AllTeamsURI -Headers $Headers -Method Get -ContentType "application/json").value
$MemberOf = foreach ($Team in $Teams) {
    $SiteRootUri = "https://graph.microsoft.com/beta/groups/$($Team.id)/sites/root"
    $SiteRootReq = Invoke-RestMethod -Uri $SiteRootUri  -Headers $Headers -Method Get -ContentType "application/json"
    $SiteDrivesUri = "https://graph.microsoft.com/beta/groups/$($Team.id)/sites/root/Lists"
    $SitesDrivesReq = (Invoke-RestMethod -Uri $SiteDrivesUri -Headers $Headers -Method Get -ContentType "application/json").value | where-object { $_.Name -eq "Shared Documents" }
    $DriveInfo = $SitesDrivesReq.ParentReference.siteid -split ','
    if($SiteRootReq.description -like "*no-auto-map*"){ continue }
    if ($null -eq [System.Web.HttpUtility]::UrlEncode($SitesDrivesReq.id)) { continue }
    [pscustomobject] @{
        SiteID    = [System.Web.HttpUtility]::UrlEncode("{$($DriveInfo[1])}")
        WebID     = [System.Web.HttpUtility]::UrlEncode("{$($DriveInfo[2])}")
        ListID    = [System.Web.HttpUtility]::UrlEncode($SitesDrivesReq.id)
        WebURL    = [System.Web.HttpUtility]::UrlEncode($SiteRootReq.webUrl)
        Webtitle  = [System.Web.HttpUtility]::UrlEncode($($Team.displayName)).Replace("+", "%20")
        listtitle = [System.Web.HttpUtility]::UrlEncode($SitesDrivesReq.name)
    }

}

# Associate values to output bindings by calling 'Push-OutputBinding'.
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
    StatusCode = [HttpStatusCode]::OK
    Body = $MemberOf
})

If you want sites to be skipped, you can add “no-auto-map” in the description in Teams. this will cause the script to skip that site.

Now we can browse to the AzMapper URL to check if our function is working. To test the AzMapper click on “Get Function URL” in the Azure Portal and copy the URL required. You’ll end up with something like:

https://azMapper.azurewebsites.net/api/AzOneMap?code=verylongapicodehere==

an example of a test url would be:

https://azMapper.azurewebsites.net/api/AzOneMap?code=verylongapicodehere==&Tenantid=TENANTIDHERE&Username=USERNAMEHERE

This should return all sites for that specific user, it will contain exactly the information you need to create a odopen:// URL.

Client script

So you can schedule the client script using whatever method you prefer – as a startup script, using your RMM, or just a one-off during migrations. When a site is already configured to be synced it will skip this site. We do assume that OneDrive is already configured and just waiting to sync sites. 😉

#########################
$AutoMapURL = "https://azmapper.azurewebsites.net/api/AzOneMap"
$AutomapAPIKey = "TheAPIKeyFromTheAppAlsoKnownAsTheCode"
#########################

write-host "Grabbing OneDrive info from registry" -ForegroundColor Green
$TenantID = (get-itemproperty "HKCU:\SOFTWARE\Microsoft\OneDrive\Accounts\Business1").ConfiguredTenantId
$TenantDisplayName = (get-itemproperty "HKCU:\SOFTWARE\Microsoft\OneDrive\Accounts\Business1").Displayname
$username = (get-itemproperty "HKCU:\SOFTWARE\Microsoft\OneDrive\Accounts\Business1").userEmail
$CurrentlySynced = (get-itemproperty "HKCU:\SOFTWARE\Microsoft\OneDrive\Accounts\Business1\Tenants\$($tenantdisplayname)" -ErrorAction SilentlyContinue)
Write-Host "Retrieving all possible teams."  -ForegroundColor Green
$ListOfTeams = Invoke-RestMethod -Method get -uri "$($AutoMapURL)?Tenantid=$($TenantID)&Username=$($Username)&code=$($AutomapAPIKey)" -UseBasicParsing
$Upn = [System.Web.HttpUtility]::Urldecode($username)
foreach ($Team in $ListOfTeams) {
    write-host "Checking if site is not already synced" -ForegroundColor Green
    $sitename = [System.Web.HttpUtility]::Urldecode($Team.Webtitle)
    if ($CurrentlySynced.psobject.Properties.name -like "*$($sitename) -*") {
        write-host "Site $Sitename is already being synced. Skipping." -ForegroundColor Green 
        continue
    }
    else {
        write-host "Mapping Team: $Sitename" -ForegroundColor Green
        Start-Process "odopen://sync/?siteId=$($team.SiteID)&webId=$($team.webid)&listId=$($team.ListID)&userEmail=$upn&webUrl=$($team.Weburl)&webtitle=$($team.Webtitle)"
        start-sleep 5
    }
}

And that’s it! running this script will map all the sites this specific user has access to, it won’t give weird pop-ups for users that do not have access and this should help you ease all Teams deployments by a lot. As always, Happy PowerShelling!

Monitoring with PowerShell: AD KRBTGT & making your own canaries

I decided this time I’m gonna be combining two small blogs, because they’re both pretty small and easy. Both are somewhat security oriented. The first part of the blog we will tackle monitoring the KRBTGT password. This needs to be reset on a regular schedule to ensure bad actors can’t abuse it.

The second part we’ll focus on creating our own ‘Canary’ files. These files can be used for a lot of things but the most common is to detect if ransomware has touched them in someway or the other. So, lets get started!

Monitoring KRBTGT Password age

So it’s actually straight forward to monitor the KRBTGT account, as it’s just a AD account. We’ll monitor this by grabbing the PasswordLastSet Attributes from the Active Directory. If you want to automatically resolve this, I’d strongly suggest to look at the script in this Github.

$Days = (Get-Date).AddDays(-31)
$Account = Get-AdUser krbtgt -property passwordlastset
$Setdate = if($Account.PasswordLastSet -gt $Days){ "Healthy - Password set date $($Account.Passwordlastset)" } else {" Unhealthy - Password set date $($Account.Passwordlastset)" }

You can change the amount of days to what you are comfortable with. I believe the documentation doesn’t have a strong suggestion in how much you should, but as this is a completely automated solution we perform this on a monthly basis.

Creating and monitoring file canaries

So, canaries are files that you place on strategic locations on a machine to check if the files aren’t being touched, corrupted, or encrypted in any way. Primarily they are used to prevent a full encryption of a computer and minimize data loss and lateral movement.

So with this script, we create canaries in a couple of locations;

  • The My Documents folder of each user
  • The Desktop Folder of each user
  • The root of each drive on the machine

You’ll also be able to create them in locations you want by adding to the $CreateLocations variable. We create the files as hidden, so users should not see the file, the file name will be canaryfile.pdf, even though it’s just a simple text file.

So the script creates a file in each location, and immediately starts alerting on two properties; If the file has been edited in the past hour, and if the file contains the correct string. I’d advice to apply the monitoring to the device, wait an hour, and then actually start alerting on it or reacting.

$CreateLocations = @('AllDesktops', 'AllDocuments', 'AllDrives', 'C:\temp')
$FileContent = "This file is a special file created by your managed services provider. For more information contact the IT Support desk."

foreach ($Locations in $CreateLocations) {
    $AllLocations = switch ($Locations) {
        "AllDesktops" { (Get-ChildItem "C:\Users" -Recurse -Force -filter 'Desktop' -Depth 3).FullName }
        "AllDocuments" { (Get-ChildItem "C:\Users" -Recurse -Force -Filter 'Documents' -Depth 3).fullname }
        "AllDrives" { ([System.IO.DriveInfo]::getdrives() | Where-Object { $_.DriveType -eq 'Fixed' }).Name }
        default { $Locations }
    }
  $CanaryStatus = foreach ($Location in $AllLocations) {
        if ((test-path "$Location\CanaryFile.pdf") -eq $false) {
            $File = New-Item $Location -Name "CanaryFile.pdf" -Value $FileContent
            $file.Attributes = 'hidden'
        }
        else {
            $ExistingFile = get-item "$Location\CanaryFile.pdf" -Force
            if ($ExistingFile.LastWriteTime -gt (get-date).AddHours(-1)) { "$Location\CanaryFile.pdf is unhealthy. The LastWriteTime was $($ExistingFile.LastWriteTime)" }
            $ExistingFileContents = get-content $ExistingFile -Force
            if ($ExistingFileContents -ne $FileContent) { "$Location\CanaryFile.pdf is unhealthy. The contents do not match. This is a sign the file has most likely been encrypted" }
        }
    }
}
if(!$CanaryStatus){
    $CanaryStatus = "Healthy"
}

$CanaryStatus

If you feel confident enough about this, you could set up some self-healing like disabling network access, or shutting the device down before the machine is completely encrypted. And that’s it. As always, Happy PowerShelling!

Monitoring with PowerShell: Monitoring Shodan results (in-depth)

Sometime ago I made a blog about monitoring your environments by using PowerShell and the Shodan API. This blog was well received but I felt like it could use a lot of improvements. The data returned wasn’t all that useful for some, and sometimes you want to exclude specific ports in case of an actual webserver for example.

So I’ve made an updated version that is able to return more info, This version allows you to add both exclusions, and get more information like who the ISP is, and if known vulnerabilities have been found. It also keeps a history of the previous result and runs a compare against this, to check if something has been changed.

As always these scripts are designed to run with your RMM, on environments where you’d expect no open ports or a very limited subset. Monitoring this on all your devices probably aint the best plan 🙂

$PortExclusions = @('80', '443')
$CurrentIP = (Invoke-WebRequest -uri "http://ifconfig.me/ip" -UseBasicParsing ).Content
$ListIPs = @($CurrentIP)
$Shodan = foreach ($ip in $ListIPs) {
    try {
        $ReqFull = Invoke-RestMethod -uri "https://api.shodan.io/shodan/host/$($ip)?key=$APIKEY"
    }
    catch {
        write-host "Could not get information for host $IP. Error was:  $($_.Exception.Message)"
        continue
    }
    foreach ($req in $ReqFull.data | Where-Object { $_.port -notin $PortExclusions }) {
        [PSCustomObject]@{
            'IP'                    = $req.ip_str
            'Detected OS'           = $Req.data.OS
            'Detected Port'         = $req.port
            'Detected ISP'          = $req.isp
            'Detected Data'         = $req.data
            'Found vulnerabilities' = $req.opts.vulns
        }
    }
}

$previousResult = get-content "$($Env:Programdata)\ShodanScan\LastScan.txt" -ErrorAction SilentlyContinue | ConvertFrom-Json
if($previousResult) {$CompareObject = Compare-Object $previousresult $Shodan}
if ($CompareObject) { Write-Host "There is a different between the previous result and the current result. Please investigate" }
new-item "$($Env:Programdata)\ShodanScan" -ItemType Directory -Force
ConvertTo-Json $Shodan  | Out-File "$($Env:Programdata)\ShodanScan\LastScan.txt"


if (!$Shodan) { write-host "Healthy - Hosts are not found in Shodan." } else { write-host "Hosts are found in Shodan. Information: $($Shodan | out-string)" } 

And that’s it! as always, Happy PowerShelling.

Monitoring with PowerShell: Monitoring Azure AD Devices and users age.

So we’re managing more and more cloud only clients. This is fantastic because you don’t have to worry about all the old worries like keeping a server online and updated. Another cool thing is that it becomes a lot easier to manage devices and endpoints.

The thing is, even with Azure AD you still have maintenance tasks that never seem to disappear. This time, we’re picking up the age old issue of keeping your Active Directory cleaned up. In this case; The Azure Active Directory.

With the following script we detect a couple of things; any user that has not logged in for 90 days, but also any device that has not logged into the Azure AD for 90 days. Finding these older devices gives you the ability to see if your off-boarding procedures are running well and you’re not having a total mess.

A good real life example came to me recently; one of our employees had a device stolen and I logged into the intune portal to start a remote wipe. The problem was that this user had around 10 devices in the portal and I could not be sure which was the current one. If I had maintained the portal and ran this script more often, finding the device would’ve been much easier.

Lets get to the script! As always I’ll publish two versions

Single tenant script

########################## Secure App Model Settings ############################
$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'YourApplicationSecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'YourTenantID'
$RefreshToken = 'YourRefreshToken'
$UPN = "YourUPN"
$CustomerTenant = "Customer.onmicrosoft.com"
########################## Script Settings  ############################
$Date = (get-date).AddDays(-90)
$Baseuri = "https://graph.microsoft.com/beta"
write-host "Generating token to log into Azure AD. Grabbing all tenants" -ForegroundColor Green
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$CustGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes "https://graph.microsoft.com/.default" -ServicePrincipal -Tenant $CustomerTenant
write-host "$($Tenant.Displayname): Starting process." -ForegroundColor Green
$Header = @{
    Authorization = "Bearer $($CustGraphToken.AccessToken)"
}
write-host " $($Tenant.Displayname): Grabbing all Users that have not logged in for 90 days." -ForegroundColor Green
$UserList = (Invoke-RestMethod -Uri "$baseuri/users/?`$select=displayName,UserPrincipalName,signInActivity" -Headers $Header -Method get -ContentType "application/json").value | select-object DisplayName, UserPrincipalName, @{Name = 'LastLogon'; Expression = { [datetime]::Parse($_.SignInActivity.lastSignInDateTime) } } | Where-Object { $_.LastLogon -lt $Date }
$devicesList = (Invoke-RestMethod -Uri "$baseuri/devices" -Headers $Header -Method get -ContentType "application/json").value | select-object Displayname, @{Name = 'LastLogon'; Expression = { [datetime]::Parse($_.approximateLastSignInDateTime) } }

   
$OldObjects = [PSCustomObject]@{
    Users   = $UserList | where-object { $_.LastLogon -ne $null }
    Devices = $devicesList | Where-Object { $_.LastLogon -lt $Date }
}

if (!$OldObjects) { write-host "No old objects found in any tenant" } else { write-host "Old objects found."; $Oldobjects }

All tenants script

########################## Secure App Model Settings ############################
$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'YourApplicationSecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'YourTenantID'
$RefreshToken = 'YourRefreshToken'
$UPN = "YourUPN"
########################## Script Settings  ############################
$Date = (get-date).AddDays((-90))
$Baseuri = "https://graph.microsoft.com/beta"
write-host "Generating token to log into Azure AD. Grabbing all tenants" -ForegroundColor Green
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID 
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID 
Connect-AzureAD -AadAccessToken $aadGraphToken.AccessToken -AccountId $upn -MsAccessToken $graphToken.AccessToken -TenantId $tenantID | Out-Null
$tenants = Get-AzureAdContract -All:$true
Disconnect-AzureAD
$OldObjects = foreach ($Tenant in $Tenants) {

    $CustGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes "https://graph.microsoft.com/.default" -ServicePrincipal -Tenant $tenant.CustomerContextId
    write-host "$($Tenant.Displayname): Starting process." -ForegroundColor Green
    $Header = @{
        Authorization = "Bearer $($CustGraphToken.AccessToken)"
    }
    write-host " $($Tenant.Displayname): Grabbing all Users that have not logged in for 90 days." -ForegroundColor Green
    $UserList = (Invoke-RestMethod -Uri "$baseuri/users/?`$select=displayName,UserPrincipalName,signInActivity" -Headers $Header -Method get -ContentType "application/json").value | select-object DisplayName,UserPrincipalName,@{Name='LastLogon';Expression={[datetime]::Parse($_.SignInActivity.lastSignInDateTime)}} | Where-Object { $_.LastLogon -lt $Date }
    $devicesList = (Invoke-RestMethod -Uri "$baseuri/devices" -Headers $Header -Method get -ContentType "application/json").value | select-object Displayname,@{Name='LastLogon';Expression={[datetime]::Parse($_.approximateLastSignInDateTime)}}

   
    [PSCustomObject]@{
        Users = $UserList | where-object {$_.LastLogon -ne $null}
        Devices = $devicesList | Where-Object {$_.LastLogon -lt $Date}
    }
}

if(!$OldObjects) { write-host "No old objects found in any tenant"} else { write-host "Old objects found."; $Oldobjects}

And that’s it! as always, Happy PowerShelling!

Monitoring with PowerShell: Monitoring Active Directory Health

Some time ago I wrote a blog about monitoring Active Directory replication. A couple of days ago a friend in Slack asked me if I have anything for monitoring the entire general health of a domain controller, and not just replication.

So I researched some options, I found this blog by Adam, which was 90% of what I needed. I just wanted a script that was slightly more complete – Not just the health is important but also specific settings are.

For example; we want the replication time to be under 30 minutes, we want the recycle bin to always be active, and password complexity should be enabled everywhere. To monitor all of this, I’ve built the script below.

The Script

$DiagInfo = dcdiag
$DCDiagResult = $Diaginfo | select-string -pattern '\. (.*) \b(passed|failed)\b test (.*)' | foreach {
    $obj = @{
        TestName   = $_.Matches.Groups[3].Value
        TestResult = $_.Matches.Groups[2].Value
        Entity     = $_.Matches.Groups[1].Value
    }
    [pscustomobject]$obj
}

$DCDiagStatus = foreach ($FailedResult in $DCDiagResult | Where-Object { $_.Testresult -ne "passed" }) {
    "DC diag test not succesfull on entity $($FailedResult.entity) - $($FailedResult.testname)"
}
if(!$DCDiagStatus){ $DCDiagStatus = "Healthy. No DCDiag Tests failed" }

$ReplicationSchedule = Get-ADReplicationSiteLink -filter *
$ReplicationSchedStatus = foreach ($Replication in $ReplicationSchedule) {
    if ($replication.ReplicationFrequencyInMinutes -gt 30) { "Potentional replication schedulde issue. $($Replication.name) has a replication schedulde of $($replication.ReplicationFrequencyInMinutes)" }
}
if (!$ReplicationSchedStatus) { $ReplicationSchedStatus = "Healthy - Replication Schedulde for all sites is lower than 30 minutes" }

$PasswordPolcy = Get-ADDefaultDomainPasswordPolicy
if ($PasswordPolcy.complexityEnabled -ne $true) { $PasswordComplexityHealthy = "Unhealthy - Password Complexity is disabled" }else {
    $PasswordComplexityHealthy = "Healthy - Password Complxity is enabled" 
}

$ADRecycler = Get-ADOptionalFeature -Filter 'name -like "Recycle Bin Feature"'
if (!$ADRecycler.enabledscopes) { $ADRecyclerHealth = "Unhealthy. AD Recycle Bin Feature is disabled" } else { $ADRecyclerHealth = "Healthy - Recycle bin is enabled." }

$DCDiagStatus 
$ReplicationSchedStatus
$PasswordComplexityHealthy
$ADRecyclerHealth

So load this up in your RMM, check the values of the bottom 4 variables and done. 🙂 Active Directory Monitoring made easy.

As always, Happy PowerShelling!

Automating with PowerShell: Using the new Autotask REST API

So I’m a bit later than normal with blogging, that’s mostly because I was working on this project a little longer than usual. Autotask recently released update 2020.2 and this update includes a new REST API.

This is super cool, because the old API was a SOAP api and terribly inconvenient to actively use. To help people with using the new Autotask API I’ve created a module. The module is still in alpha/beta but you can download it from the PSGallery now.

The project page is here. Feel free to report any issues or do a pull request if you want to help develop the module! Just be prepared for breakage in the first couple of weeks, I’m still working on finding the best methods 🙂

 Installation instructions

The module is published to the PSGallery, download it using:

   install-module AutotaskAPI

Usage

To get items using the Autotask API you’ll first have to add the authentication headers using the` Add-AutotaskAPIAuth` function.

$Creds = get-credential    Add-AutotaskAPIAuth -ApiIntegrationcode 'ABCDEFGH00100244MMEEE333 -credentials $Creds

When the command runs, You will be asked for credentials. Using these we will try to decide the correct webservices URL for your zone based on the email address. If this fails you must manually set the webservices URL.

Add-AutotaskBaseURI -BaseURI https://webservices1.autotask.net/atservicesrest

The Base URI value has tab completion to help you find the correct one easily.

To find resources using the API, execute the Get-autotaskAPIResource function. For the Get-AutotaskAPIResource function you will need either the ID of the resource you want to retrieve, or the JSON SearchQuery you want to execute. 

Examples


To find the company with ID 12345

Get-AutotaskAPIResource -Resource Companies -ID 12345

 To get all companies that are Active:

Get-AutotaskAPIResource -Resource Companies -SearchQuery "{filter='active -eq True'}"


To create a new company, we can either make the entire JSON body ourselves, or use the New-AutotaskBody function.

$Body = New-AutotaskBody -Definitions CompanyModel 


 This creates a body for the model Company. Definitions can be tab-completed. The body will contain all expected values. If you want an empty body instead use:

  $Body = New-AutotaskBody -Definitions CompanyModel -NoContent

After setting the values for the body you want, execute: New-AutotaskAPIResource -Resource Companies -Body $body

Contributions

Feel free to send pull requests or fill out issues when you encounter any.

Automating with PowerShell: an Azure DynDNS replacement.

We’ve been using DynDNS Managed DNS for a long time. We use Managed DNS to offer dynamically updating DNS records for clients with either on-site services, or where we believe that dynamic updating of records is needed.

Oracle has bought DynDNS somewhere in 2019 and decided to slowly start killing off the DynDNS Managed DNS services. This has caused us to look for a different solution. Of course, I immediately thought of Azure, with an Azure DNS hosted zone.

The only issue was that I had some constraints;

  • I wanted to be able to keep using the clients we are using, sometimes built into devices such as the Unifi USG Gateway
  • I needed to have the ability to dynamically create records, instead of always having to do this manually.
  • I also hoped for a method that would allow us to authenticate with an API key, instead of a username/password.
  • and of course a business requirement; I needed to get this for the same price or lower than Managed DNS.

After looking into it for some moment, I immediately found the business constraint would be no issue. Azure DNS zones are extremely cheap. Combined with an Azure Function I would be able to fill in all other issues. The total price for our solution is less than 2€ a month.(Azure Calculator) That’s a big difference with the 200€ a month we paid for Managed DNS.

So, lets start the setup and get our own DynDNS service going!

Setup

First we’ll need a domain name. I’ll leave it up to you where you’ll get the domain. After buying the domain, click on this link to create a new Azure DNS Zone. After creating the zone, point your NS servers to the listed NS servers Azure gives you.

Next, we’ll create our Azure function, you can jump to that by clicking this link. You can follow the default manual for this like with our AzGlue function. There are just some small differences:

  • When asked for the language, choose PowerShell Core 6.
  • After creating the application, click on “Identity” in the left hand menu. Enable the system Identity here.
  • Now click on ‘Configuration” and add a property called APIKey. This will be the key clients will use to update their DNS configuration. Put any string you’d like here.
  • Close this blade, and go to the Azure DNS zone we’ve created, and click on “Security”. Add the Function App as a “DNS Zone Contributor”.
  • Close everything, and return to the Function App. Now click on Functions -> Add -> HTTP Trigger
  • Name your trigger. I’ve called mine “AzDynaDNS”. Select the “Anonymous” Authorization level.
  • When the trigger is deployed, click on “Integration” and then on the “HTTP Trigger”. change the route template to “{*path}” and click save.
  • now click on “Code & Test” and paste in the PowerShell code found below.

This PowerShell code simulates the way the DynDNS client works, this means we can use this in conjunction with routers, or IoT devices that support the DynDNS client.

We’ve made some modifications though; we’re dumping the username, and only using an API key instead. If you do want to use username/password based authentication, or 1 API key per client, feel free to modify the script.

The Script

So the process is pretty straight forward; Whenever the Azure Function API is called using a DynDNS client, the script compares the password if it contains the API key, it then checks if the hostname exists, and creates it if not. It also compares the current IP to the new IP and updates this, if required.

using namespace System.Net
param($Request, $TriggerMetadata)
############### Settings ##############
#Only used if Autodetection fails, e.g. multiple DNS zones, etc.
$ResourceGroup = "YOURRESOURCEGROUPNAME"
$ZoneName = "YOURZONENAME.COM"
############### /Settings ##############
if (!$request.headers.authorization) {
    write-host "No API key"
    Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
            StatusCode = [HttpStatusCode]::OK
            Body       = "401 - No API token or token is invalid."
        })
    exit
}

$Base64APIKey = $request.headers.authorization -split " " | select-object -last 1
$APIKey = [Text.Encoding]::Utf8.GetString([Convert]::FromBase64String($Base64APIKey)) -split ":" | select-object -last 1

if ($APIKey -ne $env:APIKey) {
    write-host "Invalid API key"
    Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
            StatusCode = [HttpStatusCode]::OK
            Body       = "401 - No API token or token is invalid."
        })
    exit
}

$AutoDetect = (get-azresource -ResourceType "Microsoft.Network/dnszones")
If ($AutoDetect) { 
    write-host "Using Autodetect."
    $ZoneName = $Autodetect.name
    $ResourceGroup = $AutoDetect.ResourceGroupName
}


$Domain = $request.Query.hostname.split('.') | select-object -first 1
$NewIP = $request.Query.myip
write-host "$domain has $newip. Checking record and creating if required."
$ExistingRecord = Get-AzDnsRecordSet -ResourceGroupName $ResourceGroup -ZoneName $ZoneName -Name $Domain -RecordType A -ErrorAction SilentlyContinue
if (!$ExistingRecord) {
    write-host "Creating new record for $domain"
    New-AzDnsRecordSet -name $domain -Zonename $ZoneName -ResourceGroupName $ResourceGroup -RecordType A -Ttl 60 -DnsRecords (New-AzDnsRecordConfig -Ipv4Address $NewIP)
    Push-OutputBinding -Name Response -Value (  [HttpResponseContext]@{
            StatusCode = [HttpStatusCode]::OK
            Body       = "good $newip"
        })
    exit 
}
else {
    if ($ExistingRecord.Records[-1].Ipv4Address -ne $NewIP) { 
        write-host "Updating record for $domain - new IP is $newIP"
        $ExistingRecord.Records[-1].Ipv4Address = $NewIP
        Set-AzDnsRecordSet -RecordSet $ExistingRecord
        Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
                StatusCode = [HttpStatusCode]::OK
                Body       = "good $newip"
            })
    }
    else {
        write-host "No Change - $domain IP is still $newIP"
        Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
                StatusCode = [HttpStatusCode]::OK
                Body       = "nochg $NewIP"
            })
    }
    exit
}

So, now that we’ve done all of this, we can point our DynDNS settings towards the Azure Function, and you should see records getting created and updated:

And that’s it! an easy way to create a faster, and cheaper alternative to Oracles DynDNS Managed DNS service. As always, Happy PowerShelling.

Automating with PowerShell: Storing Office 365 audit logs longer than 90 days

A friend of mine recently bumped into an issue; his client wanted to know when a specific user logged on for the last time. The problem was that he did not have the unified audit log enabled, but even if he did the time-span was too long. We got lucky at using just the mailbox audit log over the past days so he could help his clients. He still worried about the Unified Audit logs and only having 90 days of logging.

I’ve told him about a couple of SIEM products, including Azure Sentinel which is able to ingest logs from Office 365. The problem is that for SIEM tooling you require a lot of a setup and a team to keep everything running smoothly, next to that most SIEMs aren’t really focused on the MSP-workflow. It was a bit overkill for what he wanted too.

So first I helped him setup the unified audit log for all his clients, and afterwards shared a script with him to store the audit logs in a readable format, with both a .CSV file and a HTML representation.

You can run this script in an Azure Runbook, Function, or just scheduled on a secure workstation.

The script downloads the information over the previous 1.2 days. We do this to cover the gap just in case the script runs a bit longer than expected.

The Script

As always, we’re using the Secure Application model. The script creates the folder structure for you.

# Write to the Azure Functions log stream.
Write-Host "Start at $(get-date)"
##########################################
$ApplicationId = 'ApplicationID'
$ApplicationSecret = 'YoursecretySecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'YourTenantID'
$RefreshToken = 'verylongtoken'
$ExchangeRefreshToken = 'anotherverylongtoken'
$UPN = "any-valid-partner-upn"
$outputfolder = "D:\home\site\wwwroot\reports"
$ModulePath = "D:\home\site\wwwroot\AuditLogRetrieval\Modules"
##########################################
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID 
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID 
Connect-AzureAD -AadAccessToken $aadGraphToken.AccessToken -AccountId $upn -MsAccessToken $graphToken.AccessToken
#Logged in. Moving on to creating folders and getting data.
$folderName = (Get-Date).tostring("yyyy-MM-dd")
New-item -Path $outputfolder -ItemType Directory -Name $folderName -Force
$customers = Get-AzureADContract -All:$true
foreach ($customer in $customers) {
    New-item "$($outputfolder)" -name $Customer.DisplayName -ItemType Directory -Force
    $token = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716'-RefreshToken $ExchangeRefreshToken -Scopes 'https://outlook.office365.com/.default' -Tenant $customer.CustomerContextId
    $tokenValue = ConvertTo-SecureString "Bearer $($token.AccessToken)" -AsPlainText -Force
    $credential = New-Object System.Management.Automation.PSCredential($upn, $tokenValue)
    $customerId = $customer.DefaultDomainName
    $session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.outlook.com/powershell-liveid?DelegatedOrg=$($customerId)&BasicAuthToOAuthConversion=true" -Credential $credential -Authentication Basic -AllowRedirection
    Import-PSSession $session -allowclobber -DisableNameChecking -CommandName "Search-unifiedAuditLog"

    $startDate = (Get-Date).AddDays( - '1.2')
    $endDate = (Get-Date)
    $Logs = @()
    Write-Host "Retrieving logs for $($customer.displayname)" -ForegroundColor Blue
    do {
        $logs += Search-unifiedAuditLog -SessionCommand ReturnLargeSet -SessionId $customer.displayname -ResultSize 5000 -StartDate $startDate -EndDate $endDate
        Write-Host "Retrieved $($logs.count) logs" -ForegroundColor Yellow
    }while ($Logs.count % 5000 -eq 0 -and $logs.count -ne 0)


    Write-Host "Finished Retrieving logs" -ForegroundColor Green
    $ObjLogs = foreach ($Log in $Logs) {
        $log.auditdata | convertfrom-json
    }


    $PreContent = @"
<H1> $($Customer.DisplayName) - Audit Log from $StartDate until $EndDate </H1><br>
 
<br> Please note that this log is not complete - It is a representation where fields have been selected that are most commonly filtered on. .<br>
To analyze the complete log for this day, please click here for the complete CSV file log: <a href="$($folderName).csv"/>CSV Logbook</a>
<br/>
<br/>
 
<input type="text" id="myInput" onkeyup="myFunction()" placeholder="Search...">
"@
    $head = @"
<script>
function myFunction() {
    const filter = document.querySelector('#myInput').value.toUpperCase();
    const trs = document.querySelectorAll('table tr:not(.header)');
    trs.forEach(tr => tr.style.display = [...tr.children].find(td => td.innerHTML.toUpperCase().includes(filter)) ? '' : 'none');
  }</script>
<Title>Audit Log Report</Title>
<style>
body { background-color:#E5E4E2;
      font-family:Monospace;
      font-size:10pt; }
td, th { border:0px solid black; 
        border-collapse:collapse;
        white-space:pre; }
th { color:white;
    background-color:black; }
table, tr, td, th {
     padding: 2px; 
     margin: 0px;
     white-space:pre; }
tr:nth-child(odd) {background-color: lightgray}
table { width:95%;margin-left:5px; margin-bottom:20px; }
h2 {
font-family:Tahoma;
color:#6D7B8D;
}
.footer 
{ color:green; 
 margin-left:10px; 
 font-family:Tahoma;
 font-size:8pt;
 font-style:italic;
}
#myInput {
  background-image: url('https://www.w3schools.com/css/searchicon.png'); /* Add a search icon to input */
  background-position: 10px 12px; /* Position the search icon */
  background-repeat: no-repeat; /* Do not repeat the icon image */
  width: 50%; /* Full-width */
  font-size: 16px; /* Increase font-size */
  padding: 12px 20px 12px 40px; /* Add some padding */
  border: 1px solid #ddd; /* Add a grey border */
  margin-bottom: 12px; /* Add some space below the input */
}
</style>
"@
    #$ObjLogs
    $Logs | export-csv "$($outputfolder)\$($Customer.DisplayName)\$($FolderName).csv" -NoTypeInformation
    $ObjLogs | Select-object CreationTime, UserID, Operation, ResultStatus, ClientIP, Workload, ClientInfoString | Convertto-html -head $head -PreContent $PreContent | out-file "$($outputfolder)\$($Customer.DisplayName)\$($FolderName).html"
  
    write-host "Generating root HTML file with all dates" -ForegroundColor Green
    $Items = get-childitem "$($outputfolder)\$($Customer.DisplayName)" -Filter "*.html"
    $tableheader = "<table><tr><th>Date</th><th>HTML Report</th><th>CSV Report</th></tr>" 
    $URLS = foreach ($item in $Items) {
        @"
        <tr><td>$($item.basename)</td><td><a href=`"$($item.basename).html`"/>HTML Report</a></td><td><a href=`"$($item.basename).CSV`"/>CSV Report</a></td></tr>
"@
    }

    $Head, $tableheader, $URLS | out-file "$($outputfolder)\$($Customer.DisplayName)\index.html"
}

write-host "Generating index file for all clients." -ForegroundColor Green
$tableheader = "<table><tr><th>Customer Name</th><th>Onmicrosoft domain</th><th>Reports</th></tr>" 
$customerhtml = foreach ($customer in $customers) {
    @"
    <tr><td>$($customer.DisplayName)</td><td>$($customer.DefaultDomainName)</td><td><a href=`"$($customer.DisplayName)\index.html`"/>Reports</a></td></tr>
"@

}

$Head, $tableheader, $customerhtml | out-file "$($outputfolder)\index.html"
write-host "Ended at $(Get-date)"

And that’s it! an easy way to store the log files for longer than 90 days, and not have the investment of a SIEM. I’d still suggest looking into a SIEM for the long run though. As always, Happy PowerShelling!

Automating with PowerShell: Using the Secure Application model updates.

I have a feeling I might be giving people a blogging overdose, but I’ve been playing with so much cool stuff the last couple of days. So lets get the ball rolling; I’ve finally found a method to connect to the SCC succesfully using the Secure Application model.

I’ve also found that non-partners can use the Secure Application Model too, for Exchange and the SCC. This is thanks to some people in the PowerShell Discord that inspired me to get to coding. 🙂

First off, partners can still use the older blog here to use the secure application model. This script is targeted to Microsoft Partners.

So, what exactly is the Secure Application Model?

The Secure application model is a method of connecting to Office365 services by using oauth instead of a regular username/password combination. By using oauth you use tokens instead. These tokens have a specific life-time and are revoked if they are not used.

The great benefit it gives is that you can run headless scripts, while still having MFA enabled. You won’t need to authenticate with MFA each time the script runs.

Non-Microsoft partners

To get started, you’ll need to give consent to the Exchange Application for your tenant. Microsoft uses the well-known-application-id “a0c73c16-a7e3-4564-9a95-2bdf47383716” for this. You can give consent by executing the following code:

$Exchangetoken = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716' -Scopes 'https://outlook.office365.com/.default' -Tenant $TenantID -UseDeviceAuthentication
write-host "Exchange Token: $($ExchangeToken.RefreshToken)"

After this, you should store this token somewhere safe like an Azure Keyvault or password manager. With this token we can start connecting to resources.

Connecting to the Security center can be done with the following code. If you want to connect to both Exchange, and the Security center you will have to use a different token for each, as the token gets invalidated after use by one of the applications.

$ExchangeRefreshToken = 'ExchangeRefreshToken'
$UPN = "Exisiting-UPN-with-Permissions"

$token = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716'-RefreshToken $ExchangeRefreshToken -Scopes 'https://outlook.office365.com/.default'
$tokenValue = ConvertTo-SecureString "Bearer $($token.AccessToken)" -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($upn, $tokenValue)

$SccSession = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.compliance.protection.outlook.com/powershell-liveid?BasicAuthToOAuthConversion=true&DelegatedOrg=$($customerId)" -Credential $credential -AllowRedirection -Authentication Basic
import-session $SccSession -disablenamechecking -allowclobber

To connect to Exchange, you can use this code.

$ExchangeRefreshToken = 'ExchangeRefreshToken'
$UPN = "Exisiting-UPN-with-Partner-Permissions"

$token = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716'-RefreshToken $Exchangetoken -Scopes 'https://outlook.office365.com/.default'
$tokenValue = ConvertTo-SecureString "Bearer $($token.AccessToken)" -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($upn, $tokenValue)

$session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.outlook.com/powershell-liveid?BasicAuthToOAuthConversion=true" -Credential $credential -Authentication Basic -AllowRedirection
Import-PSSession $session -AllowClobber -DisableNameChecking

And that’s it for non partners. With this you can run unattended, headless scripts for O365 as a non-microsoft partner. You will need to install the PartnerCenter module to create the authentication tokens.

Partner Methods

After you collect your tokens, you can use this method to connect to the Security Center using the Secure Application Model for partners. This allows your to connect to each tenant under your administration.

$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'ApplicationSecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'Your-tenantID'
$RefreshToken = 'RefreshToken'
$ExchangeRefreshToken = 'ExchangeRefreshToken'
$UPN = "Exisiting-UPN-with-Partner-Permissions"

$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)

$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID 
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID 

Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
$customers = Get-MsolPartnerContract -All
foreach($customer in $customers){
$customerId = $customer.DefaultDomainName
$token = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716'-RefreshToken $ExchangeRefreshToken -Scopes 'https://outlook.office365.com/.default'
$tokenValue = ConvertTo-SecureString "Bearer $($token.AccessToken)" -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($upn, $tokenValue)
$SccSession = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.compliance.protection.outlook.com/powershell-liveid?BasicAuthToOAuthConversion=true&DelegatedOrg=$($customerId)" -Credential $credential -AllowRedirection -Authentication Basic
import-pssession $SccSession -disablenamechecking -allowclobber
#YourCommands here

#/End of Commands

}

The rest of the partner methods remain the same, unless you want to connect to both the Security center and Exchange at the same time, then use the following code.

$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'ApplicationSecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'Your-tenantID'
$RefreshToken = 'RefreshToken'
$ExchangeRefreshToken = 'ExchangeRefreshToken'
$UPN = "Exisiting-UPN-with-Partner-Permissions"

$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)

$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID 
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID 

Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
$customers = Get-MsolPartnerContract -All
foreach ($customer in $customers) {

    $customerId = $customer.DefaultDomainName

    write-host "Connecting to the Security Center for client $($customer.name)"
    $SCCToken = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716'-RefreshToken $ExchangeRefreshToken -Scopes 'https://outlook.office365.com/.default'
    $SCCTokenValue = ConvertTo-SecureString "Bearer $($SCCToken.AccessToken)" -AsPlainText -Force
    $SCCcredential = New-Object System.Management.Automation.PSCredential($upn, $SCCTokenValue)
    $SccSession = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.compliance.protection.outlook.com/powershell-liveid?BasicAuthToOAuthConversion=true&DelegatedOrg=$($customerId)" -Credential $SCCcredential -AllowRedirection -Authentication Basic
    import-session $SccSession -disablenamechecking -allowclobber
    #YourCommands here

    #/End of Commands

    Remove-session $SccSession
    write-host "Connecting to the Exchange managed console for client $($customer.name)"

    Write-host "Enabling all settings for $($Customer.Name)" -ForegroundColor Green
    $token = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716'-RefreshToken $ExchangeRefreshToken -Scopes 'https://outlook.office365.com/.default' -Tenant $customer.TenantId
    $tokenValue = ConvertTo-SecureString "Bearer $($token.AccessToken)" -AsPlainText -Force
    $credentialExchange = New-Object System.Management.Automation.PSCredential($upn, $tokenValue)

    $ExchangeOnlineSession = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.outlook.com/powershell-liveid?DelegatedOrg=$($customerId)&BasicAuthToOAuthConversion=true" -Credential $credentialExchange -Authentication Basic -AllowRedirection -erroraction Stop
    Import-PSSession -Session $ExchangeOnlineSession -AllowClobber -DisableNameChecking
    #YourCommands here

    #/End of Commands
    Remove-PSSession $ExchangeOnlineSession
}

And that’s it! I hope that helps partners and non-partners alike. As always, Happy PowerShelling. 🙂

Automating with PowerShell: Creating dynamic distribution groups in all O365 tenants

Someone on the /r/msp reddit, and a Slack that I frequent asked if it is possible to create an “all users” distribution group with PowerShell, and keep it up to date. I figured to spend some time on it.

To achieve a list that is always up to date, we can use Dynamic Distribution groups. Dynamic groups allow you to add specific users based on their properties. for example; if a user has a full mailbox, it will be added. You can make these queries as complex as you want.

Our script uses the Secure Application Model to create a couple of new dynamic distribution groups; a single one that contains all users called “AllCompanyUsers” and one per domain that the client has. The domain group only contains people that use that domain’s email address. For example; Cybedrain.com@cyberdrain.com contains John, Hank, and Bob. It does not contain Janet because she uses @Janetdrain.com as an e-mail.

The script

$ApplicationId = 'APPLICATIONID'
$ApplicationSecret = 'APPLICATIONSECRET' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'YOUURTENANTID'
$RefreshToken = 'Freakishly long refreshtoken'
$ExchangeRefreshToken = 'Freakishly long refresh'
$UPN = "UPN-Of-User-Generating-Tokens"

$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)

$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID 
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID 

Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
$customers = Get-MsolPartnerContract -All
foreach ($customer in $customers) {
    $token = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716'-RefreshToken $ExchangeRefreshToken -Scopes 'https://outlook.office365.com/.default' -Tenant $customer.TenantId
    $tokenValue = ConvertTo-SecureString "Bearer $($token.AccessToken)" -AsPlainText -Force
    $credential = New-Object System.Management.Automation.PSCredential($upn, $tokenValue)
    $customerId = $customer.DefaultDomainName
    $session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.outlook.com/powershell-liveid?DelegatedOrg=$($customerId)&BasicAuthToOAuthConversion=true" -Credential $credential -Authentication Basic -AllowRedirection
    Import-PSSession $session -AllowClobber -DisableNameChecking -CommandName "Get-DynamicDistributionGroup","New-DynamicDistributionGroup","Set-DynamicDistributionGroup"
    write-host "Logged into tenant $($customer.defaultdomainname)" -ForegroundColor Green
    write-host "Checking if all users list exist, if not, creating them." -ForegroundColor Green
    $ExisitingAllUserList = Get-DynamicDistributionGroup -anr "AllCompanyUsers"

    if (!$ExisitingAllUserList) { 
        write-host "Creating AllCompanyUsers group" -ForegroundColor Green
        New-DynamicDistributionGroup -Name "AllCompanyUsers" -RecipientFilter "(RecipientType -eq 'UserMailbox')" | out-null
        Get-DynamicDistributionGroup -anr "AllCompanyUsers" | Set-DynamicDistributionGroup -RequireSenderAuthenticationEnabled $False -HiddenFromAddressListsEnabled $true | out-null
    }
    
    write-host "Checking all domains, and creating a list for each domain."  -ForegroundColor Green
    $Domains = Get-MsolDomain -TenantId $customer.TenantId
    foreach ($Domain in $domains.name) {
        write-host "Checking domain $($Domain) creating if it does not exist."  -ForegroundColor Green
        $ExisitingDomainList = Get-DynamicDistributionGroup -anr $Domain
        if (!$ExisitingDomainList) {
            write-host "   Creating $domain list"  -ForegroundColor Green
            New-DynamicDistributionGroup -Name "$domain" -RecipientFilter "(RecipientType -eq 'UserMailbox') -and (EmailAddresses -like '$Domain')" | out-null
            Get-DynamicDistributionGroup -anr "$Domain" | Set-DynamicDistributionGroup -RequireSenderAuthenticationEnabled $False -HiddenFromAddressListsEnabled $true | out-null
        }
    }
    Remove-PSSession $session
}

So, that’s it! as always, Happy PowerShelling!