The Quest for a Microsoft Graph Access Token

For a long time I’ve used the following PowerShell cmdlet from the Intune PowerShell SDK to get an access token for Microsoft Graph for running ad-hoc Graph queries or testing automation code locally:

$GraphToken = Connect-MSGraph -PassThru

Since most of the time I prefer to construct my own code to call the REST API directly for Microsoft Graph than to use the cmdlets in the Microsoft Graph PowerShell SDK, I do need an access token. However, Microsoft don’t make it particularly easy to get such an access token now that the Enterprise app used by the Intune PowerShell SDK is being removed.

The need for a suitable access token remains though, and in this post I’ll cover 6 ways to get a token, including pros and cons for each. These are not the only ways of course – just 6 that I’ve picked. Note that this is purely for obtaining an access token through interactive / SSO authentication, not for any automation scenario or silent auth flows using certificates or client secrets etc.

My 6 alternatives are:

  • Microsoft Graph SDK
  • Azure CLI
  • Azure PowerShell
  • MSAL.PS
  • Raw http requests using the authentication code flow
  • MSAL.Net custom wrapper 

For some of these methods you are limited to using a particular Enterprise app to authenticate and for others you can you use your own app registration or another 3rd or 1st party app. Even though I don’t use the Microsoft Graph SDK much, I do have it installed and we can certainly make use of its Enterprise app where we may already have granted the delegated permissions that we need for Microsoft Graph.

Microsoft Graph SDK

Pros: Quick and easy

Cons: Not an officially supported method. Might change in the future.

Microsoft seem to have gone to great lengths to hide the access token when you connect to MS Graph with their PowerShell SDK, but that doesn’t mean it’s impossible. This method is a bit of a hack I suppose, but if you send a graph request and specify the output type as an HttpResponseMessage, you will find the access token in the message headers.

Connect-MgGraph
$token = (Invoke-MgGraphRequest -Method GET -Uri "/v1.0/me" -OutputType "HttpResponseMessage").RequestMessage.Headers.Authorization.Parameter

Thanks to Heusser.Pro for this tip!

Azure CLI

Pros: Quick and easy

Cons: Requires Azure CLI installed. Limited 3rd party app support. May require additional permissions on the app.

This is a great option for its simplicity – if you have the Azure CLI installed, 2 lines of code will get you an access token. However this token will be obtained using the Azure CLI’s own Enterprise app and support for 3rd party apps is limited. In order to get a useful token for Microsoft Graph with this method, it may be necessary to add your own permissions to the app – more on that in a minute.

az login
$token = az account get-access-token --resource https://graph.microsoft.com --query accessToken -o tsv

If we examine the resulting access token, we can see that it is using an Enterprise app called Microsoft Azure CLI with an app Id of 04b07795-8ddb-461a-bbee-02f9e1bf7b46. In my environment, some delegated permissions with admin consent have been added to this app:

"scp": "AuditLog.Read.All Directory.AccessAsUser.All email Group.ReadWrite.All openid profile User.ReadWrite.All"

The Directory.AccessAsUser.All is a powerful permission if your user account has high-level permissions, and may be more than you really need.

Azure PowerShell

Pros: Quick and easy

Cons: Requires Azure PowerShell installed. Limited 3rd party app support. May require additional permissions on the app. Recommend PowerShell 7 +

This method is very similar to the previous method – two lines of code, may even just one, will get you an access token.

Connect-AzAccount
$token = (Get-AzAccessToken -ResourceUrl 'https://graph.microsoft.com').Token

Similar to the CLI, Azure PowerShell uses its own Enterprise app called Microsoft Azure PowerShell with an app Id of 1950a258-227b-4e31-a9cf-717495945fc2. Again 3rd party app support is limited, which means you may need to update the Oath2 permissions on the app to get a useable token. Microsoft’s documentation does recommend using PowerShell 7 or higher with Azure PowerShell however.

Updating 1st Party App Delegated Permissions

Before we continue, lets take a quick look at how we can add new permissions to these 1st party apps used by Azure CLI and Azure PowerShell. By default, these apps don’t appear in your directory, but you can make them appear just by creating a service principal for them. To do this, I will use the Microsoft Graph PowerShell SDK. Let’s sign in:

Connect-MgGraph -Scopes "Directory.AccessAsUser.All" 
Import-Module Microsoft.Graph.Identity.SignIns

Then lets register the Microsoft Azure PowerShell app:

New-MgServicePrincipal -AppId "1950a258-227b-4e31-a9cf-717495945fc2" -DisplayName "Microsoft Azure PowerShell"

Now it appears in my tenant directory:

Finally, I will run the following code to add the Directory.AccessAsUser.All delegated permission with admin consent to this app. You’ll need the necessary permissions to do this.

# Add or update the Oath2 permissions with "Directory.AccessAsUser.All" 
$EnterpriseAppName = "Microsoft Azure PowerShell"
$GraphSp = Get-MgServicePrincipal -Filter "AppId eq '00000003-0000-0000-c000-000000000000'"
$AppSp = Get-MgServicePrincipal -Filter "DisplayName eq '$EnterpriseAppName'"
$pgs = Get-MgOauth2PermissionGrant -All | Where-Object {$_.ClientId -eq $AppSp.Id}
$GraphPgs = $pgs | Where {$_.ResourceId -eq $GraphSp.Id}
if ($null -ne $GraphPgs)
{
    $ExistingScope = $GraphPgs.Scope
    $NewScope = $ExistingScope + " Directory.AccessAsUser.All"
    $pgid = $GraphPgs.id
    $params = @{
       Scope = $NewScope
    }
    Update-MgOauth2PermissionGrant -OAuth2PermissionGrantId $pgid -BodyParameter $params
}
else
{
    $params = @{
	    clientId = $AppSp.Id
	    consentType = "AllPrincipals"
	    resourceId = $GraphSp.Id
	    scope = "Directory.AccessAsUser.All"
    }
    New-MgOauth2PermissionGrant -BodyParameter $params
}

Now on the Permissions blade of the app, I can see the newly added permission:

This permission will be added to my access token from now on whenever I request a token for Microsoft Graph with Azure PowerShell.

MSAL.PS

Pros: Reasonably easy to use. Use an Enterprise app of your choice. Specify your own scopes with user-consented permissions (where allowed)

Cons: Requires the MSAL.PS module. Not supported by Microsoft.

MSAL.PS is a very nice PowerShell module that is basically a wrapper around MSAL.Net, or more specifically the Microsoft.Identity.Client library. It makes it pretty easy to interactively get an access token and can be used for several different auth flows and scenarios. Shame, then, that it is not officially supported by Microsoft and the project has been archived and may not be up-to-date with the current features of MSAL, but as long as it still works for your needs, it’s an option to consider.

$connectionDetails = @{
    'TenantId'    = '<MyTenantId>'
    'ClientId'    = '14d82eec-204b-4c2f-b7e8-296a70dab67e' # Microsoft Graph PowerShell, or your own App Id
    'Scope'       =  'https://graph.microsoft.com/.default'
    'Interactive' = $true
}

$token = (Get-MsalToken @connectionDetails).AccessToken

It’s worth mentioning that MSAL.PS is not the only PowerShell wrapper for MSAL.Net out there, there are other community projects too, like PSMSALNet, or EntraAuth which uses OAuth 2.0 authentication flows directly instead of a Microsoft-supported library.

Authentication code flow with Raw HTTP requests

Pros: No module or library dependencies. Use an Enterprise app of your choice. Specify your own scopes with user-consented permissions (where allowed)

Cons: Makes the raw HTTP calls, which Microsoft don’t recommend. More complex code.

Under the hood of the supported Microsoft methods, interactive auth is making use of the Oath 2.0 authorization code flow. It is possible to create the raw http requests required for this flow yourself, but Microsoft don’t recommend it as they’d like you to use one of their supported libraries instead.

This auth flow basically involves posting two http requests, one to get an auth code, and the second to redeem the auth code for an access token. The code below uses the default browser to perform interactive or SSO auth and get the auth code required to request an access token. One good thing about using code like this to make the raw Http calls is that you can modify it to do additional things if you need to, such as request an additional claim to satisfy a conditional access policy requirement, for example.

# Auth request parameters
$clientId = "14d82eec-204b-4c2f-b7e8-296a70dab67e" # Microsoft Graph PowerShell, or your own App Id
$tenantId = "<YourTenantId>"
$redirectUri = "http://localhost:8080"
$scope = "https://graph.microsoft.com/.default"

# Generate the authorization URL
$authUrl = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/authorize?client_id=$clientId&response_type=code&redirect_uri=$redirectUri&response_mode=query&scope=$scope"

# Start a local HTTP listener to capture the authorization code
$listener = New-Object System.Net.HttpListener
$listener.Prefixes.Add($redirectUri + "/")
$listener.Start()

# Open the authorization URL in the default browser
Start-Process $authUrl

# Wait for the authorization response
$context = $listener.GetContext()
$response = $context.Response
$request = $context.Request

# Extract the authorization code from the query parameters
$authCode = $request.QueryString["code"]

if ($authCode) 
{
    $responseString = "Authorization code received. You can close this window."
} 
else 
{
    $responseString = "No authorization code received. Please try again."
}

# Send a response to the browser
$buffer = [System.Text.Encoding]::UTF8.GetBytes($responseString)
$response.ContentLength64 = $buffer.Length
$response.OutputStream.Write($buffer, 0, $buffer.Length)
$response.OutputStream.Close()

# Stop the listener
$listener.Stop()
$listener.Dispose()

# Get Access Token
$Body = @{
    grant_type   = 'authorization_code'
    client_id    = $ClientID  
    scope        = $Scope 
    code         = $AuthCode  
    redirect_uri = $RedirectUri 
}
$Response = Invoke-RestMethod "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token" -Method POST -Body $Body
$token = $Response.access_token

MSAL.Net Custom Wrapper

Pros: Uses Microsoft supported auth libraries. Use an Enterprise app of your choice. Specify your own scopes with user-consented permissions (where allowed)

Cons: More complex code. Unsupported PowerShell wrapper. Requires Az.Accounts module.

Back in the day ADAL was used for Azure AD auth, but MSAL is the current way to go and there are two .Net code packages from Microsoft that are commonly used for this – Microsoft.Identity.Client and Azure.Identity, which makes use of the Microsoft.Identity.Client and is more focused on working with the Azure SDKs. Since Microsoft want us to use their supported libraries  there is no reason we can’t furnish our own code wrapper to use them as MSAL.PS does, so long as we remember the PS code wrapper itself is not supported code. To use the libraries we will need them to be present on the machine and a simple way to get them is just to install the Az.Accounts module. 

The PowerShell function below is my own simple wrapper that works in both Windows PowerShell and PowerShell Core. You can use either the Microsoft.Identity.Client library directly (the default) or the Azure.Identity library by adding the -UseAzureIdentity switch.  The end result is the same – an access token for Microsoft Graph. The function is able to use in-memory token cache so you don’t need to continually re-authenticate within the same session.

To use the function, I recommend adding the client (app) Id of the Enterprise app you wish to use within the function along with your tenant Id so you don’t have to keep passing those parameters every time. Then just call the function using Get-MicrosoftGraphAccessToken or Get-MicrosoftGraphAccessToken -UseAzureIdentity.

Function Get-MicrosoftGraphAccessToken {
[CmdletBinding()]
param (
[Parameter()]
[string]
$ClientId = "14d82eec-204b-4c2f-b7e8-296a70dab67e", # Microsoft Graph PowerShell, or your own app Id
[Parameter()]
[string]
$TenantId = "<YourTenantId>",
[Parameter()]
[string[]]
$Scopes = "https://graph.microsoft.com/.default&quot;,
[Parameter()]
[string]
$RedirectUri = "http://localhost&quot;,
[Parameter()]
[switch]
$UseAzureIdentity
)
# Check for Az.Accounts module
$AzAccountsModule = Get-Module -Name Az.Accounts -ListAvailable
If ($null -eq $AzAccountsModule)
{
try
{
Install-Module -Name Az.Accounts -Force -AllowClobber -Scope CurrentUser -Repository PSGallery -ErrorAction Stop
}
catch
{
throw "Failed to install Az.Accounts module: $_"
}
}
# Import Az.Accounts module
try
{
Import-Module Az.Accounts -ErrorAction Stop
}
catch
{
throw $_
}
If ($UseAzureIdentity)
{
# For PS Core, it is necessary to load the required assemblies
If ($PSEdition -eq "Core")
{
# Find the location of the Azure.Common assembly
$LoadedAssemblies = [System.AppDomain]::CurrentDomain.GetAssemblies() | Select -ExpandProperty Location
$AzureCommon = $LoadedAssemblies |
Where-Object { $_ -match "\\Modules\\Az.Accounts\\" -and $_ -match "Microsoft.Azure.Common" }
$AzureCommonLocation = $AzureCommon.TrimEnd("Microsoft.Azure.Common.dll")
# Load the required assemblies
$dllsToLoad = @(
'Microsoft.IdentityModel.Abstractions.dll'
'Microsoft.Identity.Client.dll'
'Azure.Identity.dll'
'Azure.Core.dll'
'Microsoft.Identity.Client.Extensions.Msal.dll'
)
foreach ($dll in $dllsToLoad)
{
try
{
$dllLocation = Get-ChildItem -Path $AzureCommonLocation -Filter $dll -Recurse -File | Select -First 1 -ExpandProperty FullName
[void][System.Reflection.Assembly]::LoadFrom($dllLocation)
}
catch
{
throw $_
}
}
}
# Set the credential options
$ibcOptions = [Azure.Identity.InteractiveBrowserCredentialOptions]::new()
$ibcOptions.ClientId = $ClientId
$ibcOptions.TenantId = $TenantId
$ibcOptions.RedirectUri = $RedirectUri
$ibcOptions.TokenCachePersistenceOptions = [Azure.Identity.TokenCachePersistenceOptions]::new()
if ($null -ne $script:authenticationRecord1976)
{
$ibcOptions.AuthenticationRecord = $authenticationRecord1976
}
# Acquire a token
$ibc = [Azure.Identity.InteractiveBrowserCredential]::new($ibcOptions)
$requestContext = [Azure.Core.TokenRequestContext]::new($Scopes)
$cancellationTokenSource = [System.Threading.CancellationTokenSource]::new([timespan]::FromSeconds(90)) # Automatic cancellation after 90 seconds
if ($null -eq $script:authenticationRecord1976)
{
try
{
$script:authenticationRecord1976 = $ibc.AuthenticateAsync($requestContext,$cancellationTokenSource.Token).GetAwaiter().GetResult()
}
catch
{
throw $_
}
}
return $ibc.GetTokenAsync($requestContext,$cancellationTokenSource.Token).GetAwaiter().GetResult().Token
}
else
{
# For PS Core, it is necessary to load the required assemblies
If ($PSEdition -eq "Core")
{
# Find the location of the Azure.Common assembly
$LoadedAssemblies = [System.AppDomain]::CurrentDomain.GetAssemblies() | Select -ExpandProperty Location
$AzureCommon = $LoadedAssemblies |
Where-Object { $_ -match "\\Modules\\Az.Accounts\\" -and $_ -match "Microsoft.Azure.Common" }
$AzureCommonLocation = $AzureCommon.TrimEnd("Microsoft.Azure.Common.dll")
# Load the required assemblies
$dllsToLoad = @(
'Microsoft.IdentityModel.Abstractions.dll'
'Microsoft.Identity.Client.dll'
'Microsoft.Identity.Client.Extensions.Msal.dll'
)
foreach ($dll in $dllsToLoad)
{
try
{
$dllLocation = Get-ChildItem -Path $AzureCommonLocation -Filter $dll -Recurse -File | Select -First 1 -ExpandProperty FullName
[void][System.Reflection.Assembly]::LoadFrom($dllLocation)
}
catch
{
throw $_
}
}
}
if ($null -eq $script:publicClientApp1976)
{
$script:publicClientApp1976 = [Microsoft.Identity.Client.PublicClientApplicationBuilder]::Create($ClientId).WithRedirectUri($RedirectUri).WithTenantId($TenantId).Build()
}
# Alternate code to create / register a token cache on disk instead of in-memory cache
#$cacheFilePath = [System.IO.Path]::Combine([Microsoft.Identity.Client.Extensions.Msal.MsalCacheHelper]::UserRootDirectory, "msal.cache")
#$cacheFileName = [System.IO.Path]::GetFileName($cacheFilePath)
#$cacheDir = [System.IO.Path]::GetDirectoryName($cacheFilePath)
#$storageProperties = [Microsoft.Identity.Client.Extensions.Msal.StorageCreationPropertiesBuilder]::new($cacheFileName,$cacheDir,$ClientId).Build()
#$cacheHelper = [Microsoft.Identity.Client.Extensions.Msal.MsalCacheHelper]::CreateAsync($storageProperties).GetAwaiter().GetResult()
#$cacheHelper.RegisterCache($publicClientApp.UserTokenCache)
# Acquire a token
$cancellationTokenSource = [System.Threading.CancellationTokenSource]::new([timespan]::FromSeconds(90)) # Automatic cancellation after 90 seconds
$account = $publicClientApp1976.GetAccountsAsync().GetAwaiter().GetResult() | Select -First 1
if ($account)
{
# Check the token cache first
try
{
$token = $publicClientApp1976.AcquireTokenSilent($Scopes,$account).ExecuteAsync($cancellationTokenSource.Token).GetAwaiter().GetResult().AccessToken
}
catch
{
$token = $publicClientApp1976.AcquireTokenInteractive($Scopes).ExecuteAsync($cancellationTokenSource.Token).GetAwaiter().GetResult().AccessToken
}
}
else
{
$token = $publicClientApp1976.AcquireTokenInteractive($Scopes).ExecuteAsync($cancellationTokenSource.Token).GetAwaiter().GetResult().AccessToken
}
return $token
}
}
# Examples
$token = Get-MicrosoftGraphAccessToken
$token = Get-MicrosoftGraphAccessToken -UseAzureIdentity

Final thoughts

A few things to bear in mind:

  • Scopes. Providing your own scopes in any of the above methods where it is supported is only applicable if user-consent is enabled for applications. If there are delegated permissions consented to by an administrator, you will always get those permissions in your access token regardless. Remember that the permissions you actually get are the permissions currently active on your account limited to the permissions granted you by the app.
  • Enterprise apps. If you register your own app in Microsoft Entra to use with Microsoft Graph, remember to add Redirect URIs for a public client/native (mobile & desktop) app, including adding http://localhost (required by PowerShell Code) and enabling https://login.microsoftonline.com/common/oauth2/nativeclient for Windows PowerShell.
  • MFA. I have not tested the methods above with MFA, so if your Enterprise app is required to use MFA by Conditional access, do test that out.

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.