Getting an access token for AzureAD using PowerShell and device login flow

Page content

Intro

Have you ever wanted to query an API that uses access tokens from Azure Active Directory (AzureAD) from a PowerShell script?

There are a lot of solutions for this that uses an application in AzureAD and authenticates using its client-id and secret. If I have a web application or a non-interactive service this is the way to go. My friend and colleague Emanuel Palm wrote a great post on Microsoft Graph API with PowerShell for that scenario.

In this post, I’m going to cover how to get an access token from AzureAD using the OAuth 2.0 device authorization grant flow. This flow is great when I want my script to be run interactively with a user present. It relies on the access rights of the user and I don’t need to save any application secret in the script. This also makes sure that the script won’t perform any action the user doesn’t have access to perform.

This technique will give the user a similar experience to using Connect-AzAccount on PowerShell where the user is asked the following question:

To sign in, use a web browser to open the page https://microsoft.com/devicelogin and enter the code ARX2JKHNC to authenticate.

I’ll also cover a bit of a “hack” to simplify this flow for an interactive user so the user doesn’t have to copy and paste the code to a browser, but it relies on creating and opening a temporary HTML-file in the default browser by calling Start-Process.

Starting a device login flow

To start a login flow using the device authorization grant flow we need to call the REST method POST to an URI that looks like this:

"https://login.microsoftonline.com/$TenantID/oauth2/devicecode"

$TenantID can either be the ID of a specific tenant or be set to a well known generic setting, like ‘common’. Setting the TenantID to ‘common’ will use the tenant where the user was created. If I want to sign in to a tenant where I am invited as a guest, I’ll need to supply the TenantID.

You can read more about the available options for TenantID in the documentation on Application configuration options.

When calling the REST method I need to supply the two parameters client_id and resource in the body.

The client id is the id of an application that has delegated permissions to perform what I want my script to do. In this case, I’m going to list a few groups and for that, I’m going to borrow the client id of the application Azure PowerShell that is used by the Azure PowerShell module.

The resource defines where my access token will be valid. In my case, I’m going to use the token to access Microsoft Graph API so I will use “https://graph.microsoft.com/" as my resource.

Now let’s try this in PowerShell:

$ClientID = '1950a258-227b-4e31-a9cf-717495945fc2'
$TenantID = 'common'
$Resource = "https://graph.microsoft.com/"

$DeviceCodeRequestParams = @{
    Method = 'POST'
    Uri    = "https://login.microsoftonline.com/$TenantID/oauth2/devicecode"
    Body   = @{
        client_id = $ClientId
        resource  = $Resource
    }
}

$DeviceCodeRequest = Invoke-RestMethod @DeviceCodeRequestParams
Write-Host $DeviceCodeRequest.message -ForegroundColor Yellow

This will start a new sign-in process and give me a response containing a message that I print to the console with instructions to the user. The response also contains the properties user_code and device_code that we will use later on.

The sign-in experience

I’ve now shown a message to the user that instructs them to browse to https://microsoft.com/devicelogin and enter a code on a page that looks like this:

Enter code

After entering the code, the user will be asked to sign in to my application, in this case, “Microsoft Azure PowerShell”.

Sign in

Once signed in, the user will get a confirmation message instructing them to close the browser.

Signed in confirmation

Getting the token

Once the user has completed the sign-in process, my script will need to get the access token back. This is done by calling the REST API once again, this time using the token endpoint. Here is an example:

$TokenRequestParams = @{
    Method = 'POST'
    Uri    = "https://login.microsoftonline.com/$TenantId/oauth2/token"
    Body   = @{
        grant_type = "urn:ietf:params:oauth:grant-type:device_code"
        code       = $DeviceCodeRequest.device_code
        client_id  = $ClientId
    }
}
$TokenRequest = Invoke-RestMethod @TokenRequestParams

If all went well, I’ll now have my access token in the property access_token of the response.

Get groups from AzureAD using Microsoft Graph API

To use our token to authenticate to Microsoft Graph API, we need to use a header called Authorization and give it the value of “Bearer " followed by our token.

Here is an example of getting the top 1 group from my tenant using Graph API:

$Token = $TokenRequest.access_token
$AadGroupRequestParams = @{
    Method  = 'GET'
    Uri     = 'https://graph.microsoft.com/v1.0/groups?$top=1'
    Headers = @{
        'Authorization' = "Bearer $Token" 
    }
}
$AadGroupRequest = Invoke-RestMethod @AadGroupRequestParams
$AadGroupRequest.value

And that’s it! But wait, there is more!

Improving the experience

Using the device code flow works great for any script being run by an interactive user, but I think the user experience is a little bit clunky. I would prefer to skip the first three steps of opening a browser, navigating to https://microsoft.com/devicelogin and entering the device code.

Let’s see what I can do about that!

After some browser debugging, I’ve figured out that if I use the REST method POST and supply the code in a parameter called “otc” to the URI https://login.microsoftonline.com/common/oauth2/deviceauth I will bypass the steps where the user has to enter the code. However, opening a browser and instruct it fo POST a message to a site wasn’t as easy as I first hoped. My solution is to create some HTML code, drop it in a temporary file on disk and then open that file, hoping that it will open in the user’s default browser. My HTML code contains a form with the parameter “otc” and a small javascript that submits the form using a POST request. You can try this by running the full function below using the parameter “-Interactive”.

A complete function

I have of course combined this code in a function ready to be used. Give it a spin and let me know what you think!

<#
.SYNOPSIS
Gets an access token from Azure Active Directory

.DESCRIPTION
Gets an access token from Azure Active Directory that can be used to authenticate to for example Microsoft Graph or Azure Resource Manager.

Run without parameters to get an access token to Microsoft Graph and the users original tenant.

Use the parameter -Interactive and the script will open the sign in experience in the default browser without user having to copy any code.

.PARAMETER ClientID
Application client ID, defaults to well-known ID for Microsoft Azure PowerShell

.PARAMETER Interactive
Tries to open sign-in experience in default browser. If this succeeds the user don't need to copy and paste any device code.

.PARAMETER TenantID
ID of tenant to sign in to, defaults to the tenant where the user was created

.PARAMETER Resource
Identifier for target resource, this is where the token will be valid. Defaults to  "https://graph.microsoft.com/"
Use "https://management.azure.com" to get a token that works with Azure Resource Manager (ARM)

.EXAMPLE
$Token = Connect-AzureDevicelogin -Interactive
$Headers = @{'Authorization' = "Bearer $Token" }
$UsersUri = 'https://graph.microsoft.com/v1.0/users?$top=5'
$Users = Invoke-RestMethod -Method GET -Uri $UsersUri -Headers $Headers
$Users.value.userprincipalname

Using Microsoft Graph to print the userprincipalname of 5 users in the tenant.

.EXAMPLE
$Token = Connect-AzureDevicelogin -Interactive -Resource 'https://management.azure.com'
$Headers = @{'Authorization' = "Bearer $Token" }
$SubscriptionsURI = 'https://management.azure.com/subscriptions?api-version=2019-11-01'
$Subscriptions = Invoke-RestMethod -Method GET -Uri $SubscriptionsURI -Headers $Headers
$Subscriptions.value.displayName

Using Azure Resource Manager (ARM) to print the display name for all the subscriptions the user has access to.

.NOTES

#>
function Connect-AzureDevicelogin {
    [cmdletbinding()]
    param
        [Parameter()]
        $ClientID = '1950a258-227b-4e31-a9cf-717495945fc2',
        
        [Parameter()]
        [switch]$Interactive,
        
        [Parameter()]
        $TenantID = 'common',
        
        [Parameter()]
        $Resource = "https://graph.microsoft.com/",
        
        # Timeout in seconds to wait for user to complete sign in process
        [Parameter(DontShow)]
        $Timeout = 300
    )
    try {
        $DeviceCodeRequestParams = @{
            Method = 'POST'
            Uri    = "https://login.microsoftonline.com/$TenantID/oauth2/devicecode"
            Body   = @{
                resource  = $Resource
                client_id = $ClientId
            }
        }
        $DeviceCodeRequest = Invoke-RestMethod @DeviceCodeRequestParams

        if ($Interactive.IsPresent) {
            Write-Host 'Trying to open a browser with login prompt. Please sign in.' -ForegroundColor Yellow
            Start-Sleep -Second 1
            $PostParameters = @{otc = $DeviceCodeRequest.user_code }
            $InputFields = foreach ($entry in $PostParameters.GetEnumerator()) {
                "<input type=`"hidden`" name=`"$($entry.Name)`" value=`"$($entry.Value)`">"
            }
            $PostUrl = "https://login.microsoftonline.com/common/oauth2/deviceauth"
            $LocalHTML = @"
        <!DOCTYPE html>
<html>
 <head>
  <title>&hellip;</title>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
  <script type="text/javascript">
   function dosubmit() { document.forms[0].submit(); }
  </script>
 </head>
 <body onload="dosubmit();">
  <form action="$PostUrl" method="POST" accept-charset="utf-8">
   $InputFields
  </form>
 </body>
</html>
"@
            $TempPage = New-TemporaryFile
            $TempPage = Rename-Item -Path $TempPage.FullName ($TempPage.FullName -replace '$''.html') -PassThru    
            Out-File -FilePath $TempPage.FullName -InputObject $LocalHTML
            Start-Process $TempPage.FullName
        }
        else {
            Write-Host $DeviceCodeRequest.message -ForegroundColor Yellow
        }

        $TokenRequestParams = @{
            Method = 'POST'
            Uri    = "https://login.microsoftonline.com/$TenantId/oauth2/token"
            Body   = @{
                grant_type = "urn:ietf:params:oauth:grant-type:device_code"
                code       = $DeviceCodeRequest.device_code
                client_id  = $ClientId
            }
        }
        $TimeoutTimer = [System.Diagnostics.Stopwatch]::StartNew()
        while ([string]::IsNullOrEmpty($TokenRequest.access_token)) {
            if ($TimeoutTimer.Elapsed.TotalSeconds -gt $Timeout) {
                throw 'Login timed out, please try again.'
            }
            $TokenRequest = try {
                Invoke-RestMethod @TokenRequestParams -ErrorAction Stop
            }
            catch {
                $Message = $_.ErrorDetails.Message | ConvertFrom-Json
                if ($Message.error -ne "authorization_pending") {
                    throw
                }
            }
            Start-Sleep -Seconds 1
        }
        Write-Output $TokenRequest.access_token
    }
    finally {
        try {
            Remove-Item -Path $TempPage.FullName -Force -ErrorAction Stop
            $TimeoutTimer.Stop()
        }
        catch {
            # We don't care about errors here
        }
    }
}