In the previous post, we looked at how to apply Graph API permissions to a managed identity. In this post, we’ll look at how to leverage this to make calls to the Microsoft Graph API from an Azure function app.

How function apps use managed identities

When a managed identity is added to a function app, behind the scenes, an environment variable is injected into the app named MSI_SECRET. This environment variable contains a rotating value used to securely acquire a token using the managed identity. If you look at the profile.ps1 file that is automantically generated when you build a PowerShell function app (which runs when the function app starts), even if you haven’t enabled a managed identity in your app, a small block of code is run to check for the presence of this environment variable.

if ($env:MSI_SECRET) {
    Disable-AzContextAutosave -Scope Process | Out-Null
    Connect-AzAccount -Identity
}

What is happening here should be self-explanatory, but let’s break it down:

  • If the MSI_SECRET environment variable is present, the Disable-AzContextAutosave cmdlet is called to prevent the Azure context from being saved to disk. This is important from a security perspective, because the managed identity token, while short-lived, is still a sensitive piece of information, similar to a password.
  • The Connect-AzAccount cmdlet is called with the -Identity parameter. This parameter tells the cmdlet to use the managed identity, along with the MSI_SECRET environment variable to acquire an access token for the managed identity.

Once the managed identity token is used to authenticate with the Connect-AzAccount cmdlet, the identity token is used to generate an access token that is scoped to allow programmatic access to Azure resources. Which is neat, but if we tried to reach out to the Microsoft Graph API at this point, we’d get an error. Why? Because the managed identity token is only valid for Azure resources, not external resources like the Microsoft Graph API.

Let’s fix that.

Swapping the managed identity token for a Graph token

To call the Microsoft Graph API, we need to swap Azure scoped access token for one that has the specific Graph API permissions that we laid out in the previous post. Luckily this is a very straight forward process.

Firstly, we need to make sure that we have a specific module installed in our app service. In the requirements.psd1 file, let’s remove everything there (which by default is mostly commented out) and add the following:

@{ Az.Accounts = 4.* }

This will ensure that the Az.Accounts module is installed in our app service. This module contains the Get-AzAccessToken cmdlet, which we will use to get the correct access token for the managed identity.

Now, at the top of our function code, before any of our logic, we need to play the auth dance - which is surprisingly simple!

if ($env:MSI_SECRET) {
    $token = (Get-AzAccessToken -ResourceUrl "https://graph.microsoft.com").Token
}

That’s it! We’ve now used the jwt token stored in the MSI_SECRET environment variable to retrieve an access token scoped to the Microsoft Graph API. We can now use this token to make calls to the Graph API, which we can do with this sample below:

$authHeader = @{ Authorization = "Bearer $token" }
$restParams = @{
    Method      = 'Get'
    Uri         = 'https://graph.microsoft.com/beta/devices'
    Headers     = $authHeader
    ContentType = 'application/json'
}
$restCall = Invoke-RestMethod @restParams
Write-Output "Devices Found: $($restCall.value.count)"
$response = $restCall.value | ConvertTo-Json -Depth 100

Push-OutputBinding -Name Response -Value (
    [HttpResponseContext]@{
        StatusCode = [HttpStatusCode]::OK
        Body       = $response
    }
)

So, if we put all of this together in a function app locally, we should expect to see a list of devices from our tenant returned to us… right?

Graph Authentication Fail

Well, not quite. The downside, and benefit - if you care about security (you do), is that the managed identity token is tied to the resource in Azure - you don’t have access to it locally. This means that if you are developing a solution or debugging locally, you’ll need another way to generate an access token.

The easiest way to do this is to simply create another app registration (with the same Graph API permissions as your managed identity has) that you will use for local development and testing with either a client secret or certificate and then adding some logic below the managed identity auth code block to grab a token if being run locally.

if ($env:MSI_SECRET) { $token = (Get-AzAccessToken -resourceUrl "https://graph.microsoft.com/").Token }
else {
    Disable-AzContextAutosave -Scope Process | Out-Null
    $cred = New-Object System.Management.Automation.PSCredential $env:appId, ($env:secret | ConvertTo-SecureString -AsPlainText -Force)
    Connect-AzAccount -ServicePrincipal -Credential $cred -Tenant $env:tenantId
    $token = (Get-AzAccessToken -resourceUrl "https://graph.microsoft.com/").Token
}
$authHeader = @{ Authorization = "Bearer $token" }

I’ve actually used this authentication pattern in all of my function apps for the last few years and is the boiler plate pattern I will continue to use as it allows me to easily switch between local development and production without having to change anything.

For this to work for you, just make sure that you set up the necessary environment variables - appId, secret, and tenantId - in your local environment inside the local.settings.json file.

{
    "IsEncrypted": false,
    "Values": {
        "AzureWebJobsStorage": "UseDevelopmentStorage=true",
        "FUNCTIONS_WORKER_RUNTIME": "powershell",
        "appId": "your-app-id",
        "secret": "your-secret",
        "tenantId": "your-tenant-id"
    }
}

That was easy wasn’t it?! Following the last two posts, you should now have a fully functioning Azure function app written entirely in PowerShell, leveraging managed identities to securely authenticate to the Microsoft Graph API - as well as the ability to develop and troubleshoot issues locally.

As always, the code for today’s post can be found in the Code dump.

— Ben