Skip to main content
  1. Writing/

Acquiring Tokens For Non-Graph APIs In Azure Functions

·1639 words

I had a customer conversation earlier last week where an interesting scenario popped up - they were using Entra ID to protect their API hosted in Azure Functions, and wanted to make sure that they can use the access token for other Azure API access. The authentication they used was what’s known as Easy Auth.

Easy Auth is great because it’s basically the implementation of “flip the switch and be on your way” to protect API endpoints.

Enabling Easy Auth for an Azure Functions deployment.
Enabling Easy Auth for an Azure Functions deployment.

Right after enabling this capability, navigating to the function from a web browser will prompt users to sign in:

Prompt to sign in with an Azure Functions app.
Prompt to sign in with an Azure Functions app.

This works really well, and you can even obtain tokens in a documented way! Say I deploy a PowerShell app. Inside that app I can read an access token like this:

using namespace System.Net

param($Request, $TriggerMetadata)

Write-Host "PowerShell HTTP trigger function processed a request for url '$($Request.Url)'"

Write-Host "All request headers:"
$Request.Headers.GetEnumerator() | ForEach-Object { Write-Host "  $($_.Key): $($_.Value)" }

$userPrincipalName = $Request.Headers['X-MS-CLIENT-PRINCIPAL-NAME']
$userId = $Request.Headers['X-MS-CLIENT-PRINCIPAL-ID']
$accessToken = $Request.Headers['X-MS-TOKEN-AAD-ACCESS-TOKEN']

But say I now want to use that token to request data from Azure through Azure Resource Manager (ARM). That’s when I will encounter this small wrinkle that is the token audience. If we take the JSON Web Token (JWT) that we get out-of-the-box and decode it, our token will resemble something like this:

{
  "typ": "JWT",
  "nonce": "c0MPGlpE1bE1xEKd6tObWX2p4QN9BMEcRYN4W-SkB9o",
  "alg": "RS256",
  "x5t": "JDNa_4i4r7FgigL3sHIlI3xV-IU",
  "kid": "JDNa_4i4r7FgigL3sHIlI3xV-IU"
}.{
  "aud": "00000003-0000-0000-c000-000000000000",
  "iss": "https://sts.windows.net/b811a652-39e6-4a0c-b563-4279a1dd5012/",
  "iat": 1742344168,
  "nbf": 1742344168,
  "exp": 1742349059,
  "acct": 0,
  "acr": "1",
  "acrs": [
    "p1"
  ],
  "aio": "SOME_VALUE",
  "altsecid": "SOME_VALUE",
  "amr": [
    "pwd",
    "mfa"
  ],
  "app_displayname": "SOME_VALUE",
  "appid": "SOME_VALUE",
  "appidacr": "1",
  "email": "SOME_VALUE",
  "family_name": "SOME_VALUE",
  "given_name": "SOME_VALUE",
  "idp": "SOME_VALUE",
  "idtyp": "SOME_VALUE",
  "ipaddr": "SOME_VALUE",
  "name": "SOME_VALUE",
  "oid": "SOME_VALUE",
  "platf": "3",
  "puid": "SOME_VALUE",
  "rh": "SOME_VALUE",
  "scp": "email openid profile",
  "sid": "SOME_VALUE",
  "signin_state": [
    "kmsi"
  ],
  "sub": "SOME_VALUE",
  "tenant_region_scope": "NA",
  "tid": "b811a652-39e6-4a0c-b563-4279a1dd5012",
  "unique_name": "SOME_VALUE",
  "uti": "SOME_VALUE",
  "ver": "1.0",
  "wids": [
    "SOME_VALUE",
    "SOME_VALUE"
  ],
  "xms_ftd": "SOME_VALUE",
  "xms_idrel": "1 30",
  "xms_st": {
    "sub": "SOME_VALUE"
  },
  "xms_tcdt": 1673308801
}.[Signature]

The audience here, represented by the aud claim (fancy way of saying “property”), is a way to specify who the intended recipient of the token is. Think of it like addressing a letter with a specific recipient’s name. The audience value in a JWT tells the service or application that receives the token who it was meant for. If the audience doesn’t match what the service expects, the token might be considered invalid and not be accepted.

In our case, the audience is 00000003-0000-0000-c000-000000000000, which is Microsoft Graph. This means that the token is meant for the Microsoft Graph API, and if we re-use it in other contexts, it will not be really that useful. If we try and use this token for an Azure API, we’ll get an error. For example, here is a test script PowerShell that I run inside a function to demo this scenario:

using namespace System.Net

param($Request, $TriggerMetadata)

Write-Host "PowerShell HTTP trigger function processed a request for url '$($Request.Url)'"

Write-Host "All request headers:"
$Request.Headers.GetEnumerator() | ForEach-Object { Write-Host "  $($_.Key): $($_.Value)" }

$userPrincipalName = $Request.Headers['X-MS-CLIENT-PRINCIPAL-NAME']
$userId = $Request.Headers['X-MS-CLIENT-PRINCIPAL-ID']
$accessToken = $Request.Headers['X-MS-TOKEN-AAD-ACCESS-TOKEN']

$clientPrincipal = $null
$clientPrincipalHeader = $Request.Headers['X-MS-CLIENT-PRINCIPAL']
if ($clientPrincipalHeader) {
    $decodedHeader = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($clientPrincipalHeader))
    $clientPrincipal = $decodedHeader | ConvertFrom-Json
}

# Initialize response object
$responseObject = @{
    isAuthenticated = $false
    userPrincipal = $null
    accessToken = $null
}

if ($userPrincipalName) {
    $responseObject.isAuthenticated = $true
    $responseObject.userPrincipal = @{
        name = $userPrincipalName
        id = $userId
    }
    
    if ($accessToken) {
        $responseObject.accessToken = $accessToken
    }
}

if ($accessToken) {
    try {
        Write-Host "Attempting to list Azure resource groups using REST API"
        
        # Create headers with the access token
        $headers = @{
            'Authorization' = "Bearer $accessToken"
            'Content-Type' = 'application/json'
        }

        # Get all subscriptions user has access to
        $subscriptionsUrl = "https://management.azure.com/subscriptions?api-version=2020-01-01"
        $subscriptionsResponse = Invoke-RestMethod -Uri $subscriptionsUrl -Headers $headers -Method Get
        
        # Initialize an array to store resource groups
        $allResourceGroups = @()
        
        # For each subscription, get resource groups
        foreach ($subscription in $subscriptionsResponse.value) {
            $subscriptionId = $subscription.subscriptionId
            $subscriptionName = $subscription.displayName
            Write-Host "Getting resource groups for subscription: $subscriptionName ($subscriptionId)"
            
            $resourceGroupsUrl = "https://management.azure.com/subscriptions/$subscriptionId/resourcegroups?api-version=2021-04-01"
            $resourceGroupsResponse = Invoke-RestMethod -Uri $resourceGroupsUrl -Headers $headers -Method Get
            
            # Add subscription info to each resource group
            foreach ($rg in $resourceGroupsResponse.value) {
                $rg | Add-Member -NotePropertyName 'subscriptionId' -NotePropertyValue $subscriptionId
                $rg | Add-Member -NotePropertyName 'subscriptionName' -NotePropertyValue $subscriptionName
                $allResourceGroups += $rg
            }
        }
        
        # Add resource groups to the response
        $responseObject.resourceGroups = $allResourceGroups
        Write-Host "Found $($allResourceGroups.Count) resource groups across all subscriptions"
        
    } catch {
        Write-Host "Error retrieving Azure resource groups: $_"
        $responseObject.error = "Failed to retrieve resource groups: $($_.Exception.Message)"
    }
}

$body = $responseObject | ConvertTo-Json -Depth 10

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

It might be intimidating to look at this wall of code, but all it does is produce a JSON response that contains all the resources that I have in a given Azure subscription.

Mole thinking.

Stop, stop, STOP. Are you really also printing the access token in the response above? Isn’t that really bad? Like, really, really bad?

Indeed it is! This script is only used for diagnostics. You should never print tokens into logs, API outputs, or any other customer-facing artifact that is easily visible or can lead to compromise. Because I am using a test account, on a test subscription, with a test resource, this is a-OK, but you should never do this in production (or even staging).

Only "experts" should print any tokens for debugging.
Only “experts” should print any tokens for debugging.

But back to our PowerShell experiment - if I run the code as-is inside an Azure Functions app deployment, I will hit a wall:

Failed to retrieve resource groups:
Response status code does not indicate success: 401 (Unauthorized).

Uh oh. So, we need to adjust our token configuration a bit to make sure that the audience is properly reflected (along with the scopes). But how, when the UI doesn’t really offer this capability?

For that, we will need to use the Azure REST API. Well, maybe we can even go as far as use Azure CLI for it to make things easier. Previously, you could use the Azure Resource Explorer (https://resources.azure.com), but that’s going away, so we will use this as one last reference:

Soon to be deprecated Azure Resource Explorer.
Soon to be deprecated Azure Resource Explorer.

What we need to modify is under:

subscriptions
  - yourSubscriptionId
    - resourceGroups
      - yourResourceGroup
        - providers
          - Microsoft.Web
            - sites
              - yourFunctionId
                - config
                  - authSettingsV2

In there, we need to modify the login property to also include a new property - loginParameters:

"loginParameters": [
  "scope=openid email profile https://management.azure.com/user_impersonation"
]

Using standard OIDC openid, email, and profile scopes will allow us to get basic user information. https://management.azure.com/user_impersonation will allow us to access Azure resources with the token. Just setting this scope will automatically ensure that the right audience is used for the token (the actual scope reflected in the token will be user_impersonation).

To perform the action above with Azure CLI, we will first need to compose a JSON file that is reflecting of the authSettingsV2. It looks like this:

{
  "id": "/subscriptions/YOUR_SUBSCRIPTION/resourceGroups/YOUR_RG/providers/Microsoft.Web/sites/YOUR_FUNCTION_NAME/config/authsettingsV2",
  "name": "authsettingsV2",
  "type": "Microsoft.Web/sites/config",
  "location": "West US 3",
  "tags": {
    "hidden-link: /app-insights-resource-id": "/subscriptions/YOUR_SUBSCRIPTION/resourceGroups/YOUR_RG/providers/Microsoft.Insights/components/YOUR_FUNCTION_NAME"
  },
  "properties": {
    "platform": {
      "enabled": true,
      "runtimeVersion": "~1"
    },
    "globalValidation": {
      "requireAuthentication": true,
      "unauthenticatedClientAction": "RedirectToLoginPage",
      "redirectToProvider": "azureactivedirectory"
    },
    "identityProviders": {
      "azureActiveDirectory": {
        "enabled": true,
        "registration": {
          "openIdIssuer": "https://sts.windows.net/YOUR_TENANT_ID/v2.0",
          "clientId": "YOUR_CLIENT_ID",
          "clientSecretSettingName": "MICROSOFT_PROVIDER_AUTHENTICATION_SECRET"
        },
        "login": {
    "loginParameters": [
            "scope=openid email profile https://management.azure.com/user_impersonation"
          ],
          "disableWWWAuthenticate": false
        },
        "validation": {
          "jwtClaimChecks": {},
          "allowedAudiences": [
            "api://YOUR_CLIENT_ID"
          ],
          "defaultAuthorizationPolicy": {
            "allowedPrincipals": {},
            "allowedApplications": [
              "YOUR_CLIENT_ID"
            ]
          }
        },
        "isAutoProvisioned": true
      },
      "facebook": {
        "enabled": true,
        "registration": {},
        "login": {}
      },
      "gitHub": {
        "enabled": true,
        "registration": {},
        "login": {}
      },
      "google": {
        "enabled": true,
        "registration": {},
        "login": {},
        "validation": {}
      },
      "twitter": {
        "enabled": true,
        "registration": {}
      },
      "legacyMicrosoftAccount": {
        "enabled": true,
        "registration": {},
        "login": {},
        "validation": {}
      },
      "apple": {
        "enabled": true,
        "registration": {},
        "login": {}
      }
    },
    "login": {
      "routes": {},
      "tokenStore": {
        "enabled": true,
        "tokenRefreshExtensionHours": 72,
        "fileSystem": {},
        "azureBlobStorage": {}
      },
      "preserveUrlFragmentsForLogins": false,
      "cookieExpiration": {
        "convention": "FixedTime",
        "timeToExpiration": "08:00:00"
      },
      "nonce": {
        "validateNonce": true,
        "nonceExpirationInterval": "00:05:00"
      }
    },
    "httpSettings": {
      "requireHttps": true,
      "routes": {
        "apiPrefix": "/.auth"
      },
      "forwardProxy": {
        "convention": "NoProxy"
      }
    },
    "clearInboundClaimsMapping": "false"
  }
}

Save the JSON blob above to a file. Then, you can invoke a command like this:

az rest \
  --method put \
  --uri "https://management.azure.com/subscriptions/YOUR_SUB/resourceGroups/YOUR_RG/providers/Microsoft.Web/sites/YOUR_FUNCTION_NAME/config/authsettingsV2/?api-version=2020-12-01" \
  --body @app.json

Where app.json is the name of your JSON file. If everything went well, you should get a confirmation in the terminal of your new configuration:

Updating the `authSettingsV2` configuration.
Updating the authSettingsV2 configuration.

Now, when you navigate to the deployed Azure Functions app (you don’t need to redeploy it after changing auth settings), you should be prompted to consent to Azure resource scopes too:

Consent prompt asking for Azure permissions.
Consent prompt asking for Azure permissions.

And of course, once you consent, you should be able to see the existing Azure resource groups, just like the original script intended:

Successfully getting information about Azure resources from Azure Functions app.
Successfully getting information about Azure resources from Azure Functions app.

If we look inside our JWT now, we see that the aud and scp claims are quite different now:



{
  "typ": "JWT",
  "alg": "RS256",
  "x5t": "JDNa_4i4r7FgigL3sHIlI3xV-IU",
  "kid": "JDNa_4i4r7FgigL3sHIlI3xV-IU"
}.{
  "aud": "https://management.azure.com",
  "iss": "https://sts.windows.net/b811a652-39e6-4a0c-b563-4279a1dd5012/",
  "iat": 1742346886,
  "nbf": 1742346886,
  "exp": 1742351496,
  "acr": "1",
  "aio": "SOME_VALUE",
  "altsecid": "SOME_VALUE",
  "amr": [
    "pwd",
    "mfa"
  ],
  "appid": "SOME_VALUE",
  "appidacr": "1",
  "email": "SOME_VALUE",
  "family_name": "SOME_VALUE",
  "given_name": "SOME_VALUE",
  "groups": [
    "SOME_VALUE",
    "SOME_VALUE"
  ],
  "idp": "SOME_VALUE",
  "idtyp": "user",
  "ipaddr": "SOME_VALUE",
  "name": "SOME_VALUE",
  "oid": "SOME_VALUE",
  "puid": "SOME_VALUE",
  "rh": "SOME_VALUE",
  "scp": "user_impersonation",
  "sid": "SOME_VALUE",
  "sub": "SOME_VALUE",
  "tid": "b811a652-39e6-4a0c-b563-4279a1dd5012",
  "unique_name": "SOME_VALUE",
  "uti": "SOME_VALUE",
  "ver": "1.0",
  "wids": [
    "SOME_VALUE",
    "SOME_VALUE"
  ],
  "xms_edov": true,
  "xms_idrel": "14 1",
  "xms_tcdt": 1673308801
}.[Signature]

We got what we needed. Congratulations! You now can authenticate users with Azure scopes and use that token to get information about Azure resources the user has access to.

Oh, and of course - this approach can also work with any other scopes that you might need in the future.