Connecting to Microsoft Graph with a Native App using PowerShell

Introduction

Over the last few months, Microsoft has tightened security measures and has (or is at least starting to) deprecate the use of the well-known ClientID that many of use have grown accustom to using in our Microsoft Graph scripts. I had spent countless hours trying to figure out exactly how I can create my own app and had a heck of a time trying to find any information. This blog post is designed to disambiguate the creation of your own ClientID for use with Microsoft Graph and execute a Microsoft Graph query using PowerShell and your own ClientID. In addition to this, this script supports querying Microsoft Graph when MFA is enforced in the organization.

Pre-requisites

The easy way is to use the PowerShell Gallery to install the Azure module.

 Install-Package Azure

Note: When I was developing this script originally, it ran without any issue with the AccessToken function which was throwing the following error:

 Cannot find an overload for 'AcquireTokenAsync' and the argument count: '4' 

After further investigation, the version of the ADAL library that was in the AzureAD modules had a bug in Version 3.18 (version 3.17 worked fine and the current version 3.19 works as well). It was until I removed the AzureAD module and reinstalled it (to pull down the latest version) that I overcame this problem. To determine the version of the ADAL libraries you have installed, you can do the following:

 $AadModule = Get-Module -Name "AzureAD" -ListAvailable
if ($AadModule.count -gt 1) {
    $Latest_Version = ($AadModule | select version | Sort-Object)[-1]
    $aadModule      = $AadModule | ? { $_.version -eq $Latest_Version.version }
    $adal           = Join-Path $AadModule.ModuleBase "Microsoft.IdentityModel.Clients.ActiveDirectory.dll"
    }
else {$adal           = Join-Path $AadModule.ModuleBase "Microsoft.IdentityModel.Clients.ActiveDirectory.dll"}
[System.Reflection.Assembly]::LoadFrom($adal) | Select FullName

It should output something like this. Take note of the "Version=" value:

 Microsoft.IdentityModel.Clients.ActiveDirectory, Version=3.19.7.16602

Setup

Creating a Native App

First and foremost, you’ll need to log in to https://portal.azure.com and create an App registration by going to Azure Active Directory | App registrations | + New application registration:

 

Once you select ‘create’, you will be prompted to set a Name of the app. The App name can be anything you want, its just a friendly name for you to refer to if you need to modify its settings later. The Application type should be “Native” and the Redirect URI can be any URL. It doesn’t matter if you own the domain or if it is an active URL, it can literally be anything you want it to be, you’ll just need to take note of it later when you update the logic in your script.

 

Once the application is created, you’ll be taken back to the App registration page. If you do not see your newly created app, make sureyou select “All apps” from the drop down and search for it there. Since you are not defined as an owner of the app yet, it may not be displayed. Next, take note of the Application ID, this is the value you’ll use for the ClientID in your script. Note: the ClientID I have listed here is unique for my environment, it will not work for you (in fact, this Application ID will be deleted before this blog is posted).

Now that it is created, select the App so we can configure some additional settings, so you can take advantage of it.

 

Select Settings | Owners | + Add owner | add anyone that should be an owner of this App. Setting an “Owner” will allow for it to so up when you filter “My Apps” in the App registration section.

 

When an App is created, by default it has rights to access only the data of the user that had signed in with the account though the “Sign in and read user profile” delegated permissions. If you try to execute a script that uses this AppID/ClientID to query Azure AD to get a list of all users in the directory, you would receive an error stating (403) Forbidden because it didn’t have adequate permissions to do that activity.

Invoke-RestMethod : The remote server returned an error: (403) Forbidden.

 

Next, we will want to assign permissions to the App so that is has adequate rights to perform the tasks you need to. Depending on what kind of activities (get, post, patch) you will need to do against a specific service, you’ll need to delegate the rights to it accordingly. In this case, we want to query Azure Active Directory (Azure AD) to return a list of all users in the directory and we also want to update a user’s department name. Because we want to query users in Azure AD, we want to select the Microsoft Graph API.

 

Next, since we want to query all users and update certain user’s department information, we will want to enable the application to have delegated right to “Read and write directory data”:

 

Once the delegated permissions are selected, you’ll need to select “Grant Permissions” for them to be assigned to the App:

Creating the AccessToken Function

This function is fed parameters from the body of the script, so nothing needs to be modified within it. The function first loads all of the approperiate .DLLs for ADAL/ModernAuth in support of features like MFA/2FA (Multi-factor Authentication aka Two-factor Authentication).  Added to this function is a feature that allows you to control the authentication mechanism to Microsoft Graph, so that if a valid token already exists, it will use it - unless you tell it otherwise (auto v. always).

 Function Get-AccessToken ($TenantName, $ClientID, $redirectUri, $resourceAppIdURI, $CredPrompt){
    Write-Host "Checking for AzureAD module..."
    if (!$CredPrompt){$CredPrompt = 'Auto'}
    $AadModule = Get-Module -Name "AzureAD" -ListAvailable
    if ($AadModule -eq $null) {$AadModule = Get-Module -Name "AzureADPreview" -ListAvailable}
    if ($AadModule -eq $null) {write-host "AzureAD Powershell module is not installed. The module can be installed by running 'Install-Module AzureAD' or 'Install-Module AzureADPreview' from an elevated PowerShell prompt. Stopping." -f Yellow;exit}
    if ($AadModule.count -gt 1) {
        $Latest_Version = ($AadModule | select version | Sort-Object)[-1]
        $aadModule      = $AadModule | ? { $_.version -eq $Latest_Version.version }
        $adal           = Join-Path $AadModule.ModuleBase "Microsoft.IdentityModel.Clients.ActiveDirectory.dll"
        $adalforms      = Join-Path $AadModule.ModuleBase "Microsoft.IdentityModel.Clients.ActiveDirectory.Platform.dll"
        }
    else {
        $adal           = Join-Path $AadModule.ModuleBase "Microsoft.IdentityModel.Clients.ActiveDirectory.dll"
        $adalforms      = Join-Path $AadModule.ModuleBase "Microsoft.IdentityModel.Clients.ActiveDirectory.Platform.dll"
        }
    [System.Reflection.Assembly]::LoadFrom($adal) | Out-Null
    [System.Reflection.Assembly]::LoadFrom($adalforms) | Out-Null
    $authority          = "https://login.microsoftonline.com/$TenantName"
    $authContext        = New-Object "Microsoft.IdentityModel.Clients.ActiveDirectory.AuthenticationContext" -ArgumentList $authority
    $platformParameters = New-Object "Microsoft.IdentityModel.Clients.ActiveDirectory.PlatformParameters"    -ArgumentList $CredPrompt
    $authResult         = $authContext.AcquireTokenAsync($resourceAppIdURI, $clientId, $redirectUri, $platformParameters).Result
    return $authResult
    }

Creating the Invoke-MSGraphQuery Function

The second function is what performs the REST query itself.  This handles both GET and PATCH requests. If it is a PATCH request, JSON must be passed in order to take approperiate PATCH activities.

 Function Invoke-MSGraphQuery($AccessToken, $Uri, $Method, $Body){
    Write-Progress -Id 1 -Activity "Executing query: $Uri" -CurrentOperation "Invoking MS Graph API"
    $Header = @{
        'Content-Type'  = 'application\json'
        'Authorization' = $AccessToken.CreateAuthorizationHeader()
        }
    $QueryResults = @()
    if($Method -eq "Get"){
        do{
            $Results =  Invoke-RestMethod -Headers $Header -Uri $Uri -UseBasicParsing -Method $Method -ContentType "application/json"
            if ($Results.value -ne $null){$QueryResults += $Results.value}
            else{$QueryResults += $Results}
            write-host "Method: $Method | URI $Uri | Found:" ($QueryResults).Count
            $uri = $Results.'@odata.nextlink'
            }until ($uri -eq $null)
        }
    if($Method -eq "Patch"){
        $Results =  Invoke-RestMethod -Headers $Header -Uri $Uri -Method $Method -ContentType "application/json" -Body $Body
        write-host "Method: $Method | URI $Uri | Executing"
        }
    Write-Progress -Id 1 -Activity "Executing query: $Uri" -Completed
    Return $QueryResults
    }

Configuring the Script Parameters

The script requires a number of parameters. The values below are provided for reference however, they will need to be updated to reflect the attributes of your AppID/ClientID.

The ClientID (39b7009d-46c2-4807-9a45-0b34dee40c9c) is the Application ID from the App that we created at the beginning of this article. This value is unique to your application and cannot be reused from one tenant to another.

The Tenant Name (Cloudlojik.onmicrosoft.com) is the name of the tenant that the ClientID belongs to and is ultimately the tenant the you will be querying.

CredPrompt determines how frequently you are prompted for your credentials. If it is set to Always, each time you run the script, you will be required to reenter your credentials. if it is set to Auto, only once the current sessions token expires, you'll be prompted for credentials once more.

 $resourceAppIdURI = "https://graph.microsoft.com"
$ClientID         = "39b7009d-46c2-4807-9a45-0b34dee40c9c"   #AKA Application ID
$TenantName       = "cloudlojik.onmicrosoft.com"             #Your Tenant Name
$CredPrompt       = "Auto"                                   #Auto, Always, Never, RefreshSession
$redirectUri      = "https://RedirectURI.com"                #Your Application's Redirect URI
$Uri              = "https://graph.microsoft.com/beta/users" #The query you want to issue to Invoke a REST command with
$Method           = "Get"                                    #GET or PATCH
$AccessToken      = Get-AccessToken -TenantName $TenantName -ClientID $ClientID -redirectUri $redirectUri -resourceAppIdURI $resourceAppIdURI -CredPrompt $CredPrompt
$JSON = @" 
    {
    "userPrincipalName": "MCC-MigAdmin01@cloudlojik.onmicrosoft.com"
    }
"@ #JSON Syntax if you are performing a PATCH

Execution

Finally, Invoke-MSGraphQuery will execute the REST requests you are trying to perform and in my case, provided these (abbreviated) results:

 Invoke-MSGraphQuery -AccessToken $AccessToken -Uri $Uri -Method $Method -Body $JSON

PS C:\Users\paulkot> C:\PowerShellCommandCenter\Testing\CloudLojikGraphQuery.ps1
Checking for AzureAD module...
Method: Get | URI https://graph.microsoft.com/beta/users | Found: 100
Method: Get | URI https://graph.microsoft.com/beta/users?$skiptoken=X%.........%27 | Found: 200
Method: Get | URI https://graph.microsoft.com/beta/users?$skiptoken=X%.........%27 | Found: 300
Method: Get | URI https://graph.microsoft.com/beta/users?$skiptoken=X%.........%27 | Found: 313

id                             : bf14124a-35ac-4079-9a5b-07637e337665
deletedDateTime                : 
accountEnabled                 : True
ageGroup                       : 
businessPhones                 : {}
city                           : 
createdDateTime                : 2017-09-06T16:57:02Z
companyName                    : 
consentProvidedForMinor        : 
country                        : 
department                     : 
displayName                    : Admin
employeeId                     : 
givenName                      : 
jobTitle                       : 
legalAgeGroupClassification    : 
mail                           : admin@cloudlojik.com
mailNickname                   : admin
mobilePhone                    : 
onPremisesDomainName           : cloudlojik.com
onPremisesImmutableId          : GJAMfUzDmkWWTKVSxcZr3A==
onPremisesLastSyncDateTime     : 2017-09-06T17:57:07Z
onPremisesSecurityIdentifier   : S-1-5-21-3133886935-1569346377-3125956357-5104
onPremisesSamAccountName       : admin
onPremisesSyncEnabled          : True
onPremisesUserPrincipalName    : admin@cloudlojik.com
passwordPolicies               : DisablePasswordExpiration
passwordProfile                : 
officeLocation                 : 
postalCode                     : 
preferredDataLocation          : 
preferredLanguage              : 
proxyAddresses                 : {x500:/o=ExchangeLabs/ou=Exchange Administrative Group (FYDIBOHF23SPDLT)/cn=Recipients/cn=07c0a25fdaa846eab6f23051fa695d67-Admin, X500:/o=CloudlojikExchange/ou=Exchange Administrative Group (FYDIBOHF23SPDLT)/cn=Recipients/cn=Admin688, smtp:admin@cloudlojik.onmicrosoft.com, smtp:admin@cloudlojik.mail.onmicrosoft.com...}
refreshTokensValidFromDateTime : 2017-09-06T16:33:40Z
showInAddressList              : 
imAddresses                    : {}
state                          : 
streetAddress                  : 
surname                        : 
usageLocation                  : 
userPrincipalName              : admin@cloudlojik.com
userType                       : Member
assignedLicenses               : {}
assignedPlans                  : {}
deviceKeys                     : {}
onPremisesExtensionAttributes  : @{extensionAttribute1=; extensionAttribute2=; extensionAttribute3=; extensionAttribute4=; extensionAttribute5=; extensionAttribute6=; extensionAttribute7=; extensionAttribute8=; extensionAttribute9=; extensionAttribute10=; extensionAttribute11=; extensionAttribute12=; extensionAttribute13=; extensionAttribute14=; 
                                 extensionAttribute15=}
onPremisesProvisioningErrors   : {}
provisionedPlans               : {}

Screenshot for reference:

You can download the sample script in its entirety here:
QueryGraphWithMFA.ps1.txt