Microsoft has recently announced a new feature that allows us to use managed identities to authenticate across tenants. This is a big deal, and opens up a new secure way of managing multiple tenants in Azure.
One of the biggest security concerns facing developers today is credential leakage and features like this help alleviate the risk of potentially leaving secrets in source control by removing the need for them entirely!
In this post I’ll show you how to set this up to authenticate to a second tenant in an Azure function app, and how to use it to call the Microsoft Graph API!
TL;DR
You can now use managed identities to obtain access tokens from app registrations.
This allows you to securely access resources in your tenant, and, by using multi-tenant app registrations, in other tenants - all without needing client secrets or certificates!
Overview
In the last post, I described how to forcefully apply Graph API permissions to a managed identity to allow a function app to programmatically access the Microsoft Graph API in a secure fashion.
While this method is still valid, it felt a little kludgy to me, and it limited the access I had to only the tenant I was working in. So I began to look for a more elegant solution…
Since writing that post, A new (preview) feature has been announced that lets us set up trust between application registrations and managed identities
With this new feature, you can now use the security and simplicity of managed identities as an authentication mechanism for multi-tenant application registrations… so we can access resources in other tenants without EVER needing to worry about secrets or certificates!
What this essentially means, is you can take what we learned in the previous post, but now the API permissions will be applied to the application registration, and with a small change to the app logic, we can authenticate to the application registration in ANY tenant that has enrolled the application registration!
There has been a few articles that I’ve found about this feature, but most focus on how to use this new feature with Azure automation runbooks - weirdly noone seems to be showing how to use this with Azure function apps, so that’s what we will do here!
Pre-requisites
As I’ve discussed setting up Azure function apps and managed identities in the past, I won’t go into detail here. If you don’t have a function app set up, go do that now, and make sure you enable a system assigned managed identity.
Once you’ve got everything set up, make sure to copy the Object ID
of the managed identity (Function app > Settings > Identity
), as we will need this later.
Setting up a multi-tenant app registration
Let’s begin by setting up a new application registration in our main tenant. This is the tenant that will have the Azure function app and the managed identity.
We don’t need to do too much here, just create a new app registration, set up the account type to Accounts in any organizational directory (Any Azure AD directory - Multitenant)
and add a Web Redirect URI
of https://admin.microsoft.com
.
Once you’ve registered the application, from the overview page, copy the Application (client) ID
and Directory (tenant) ID
values. We will need these later.
Hop into the API Permissions and add any Application permissions
you need for your specific requirements - for this demo, I’m simply going to list all devices in a second tenant, so I’ll add the Device.Read.All
permission.
Federated credentials - the secret sauce!
Hop into the Certificates & secrets
blade and go into the Federated Credentials
tab. This is where we will set up the trust between the managed identity and the application registration.
Create a new federated credential and configure the following:
- Set the scenario to
Other Issuer
- Set the Issuer to
https://login.microsoftonline.com/{tenantId}/v2.0
(where{tenantId}
is the tenant ID of our main tenant) - Set the type to
Explicit Subject Identifier
and the paste theObject ID
of the managed identity from earlier into theValue
field. - Give the federated credential an appropriate name and a description and click
Add
.
That’s it! We’ve now set up the trust between the managed identity and the app registration. This means that once we have an access token for the managed identity (scoped to a specific resource), we will be able to use that token as a federated Client Assertion
to request an access token for the app registration.
Setting up the second tenant
For any multi-tenant application to work in another tenant, the app needs to be enrolled into that tenant. In this case, we can do this by logging into the tenant as a Global Administrator, opening another tab and navigating to the following URL:
https://login.microsoftonline.com/organizations/adminconsent?client_id={clientId}
(where{clientId}
is theApplication (client) ID
of the app registration we created earlier)
Once you go to that URL, you will be greeted with a consent screen. Review this carefully (you are giving the app explicit access to your tenant, after all!) and click Accept
.
Setting up the PowerShell Azure function app.
I’m going to show you two ways to authenticate in a PowerShell function app:
- Using the Az.Accounts module (which is my default way to quickly stand up a function app)
- Using no modules at all (this is cool, and we are cool, so we do cool things)
Using the Az.Accounts method
Firstly, set up an environment variable to store the Client Id
of the app registration. For my function app, I’ve stored this with the name CLIENT_ID
. You can set up the environment variable in VS Code, or the portal - use whatever you are comfortable with.
Now, head to your function app code (I’ll assume you are developing this locally, because we should not be doing any development work in the portal) and make the following changes:
- In the
Requirements.psd1
, add theAz.Accounts
module.
@{ 'Az.Accounts' = '4.*' }
- Finally, in the function code itself -
run.ps1
, we can start writing the authentication code.
$remoteTenantId = {tenantId of the second tenant}
if ($env:MSI_SECRET) {
$msiAuth = ( Get-AzAccessToken -ResourceUrl "api://AzureADTokenExchange" -AsSecureString )
$msiToken = $msiAuth | ConvertFrom-SecureString -AsPlainText
Connect-AzAccount -Tenant $remoteTenantId -ApplicationId $env:CLIENT_ID -FederatedCredential $msiToken
$token = Get-AzAccessToken -ResourceUrl "https://graph.microsoft.com" -AsSecureString
$accessToken = $token.Token | ConvertFrom-SecureString -AsPlainText
}
Let’s break this down a bit:
- The
$remoteTenantId
variable is the tenant ID of the second tenant we are trying to access. This is the tenant that we just consented to the app registration in - you should probably make this a parameter you can change when you hit the function app endpoint to support multiple tenants. - The
Get-AzAccessToken
cmdlet is used to get the access token for the managed identity. This is scoped to theapi://AzureADTokenExchange
resource, which is a special resource that allows us to exchange the managed identity token for an access token for the app registration. - The
Connect-AzAccount
cmdlet is used to connect to the app registration using the managed identity token as a federated credential. This is where the magic happens! The-Tenant
parameter is used to specify the tenant ID of the second tenant we are trying to access, and the-ApplicationId
parameter is used to specify the client ID of the app registration. We supply the managed identity token as a federated credential, which allows us to use the token as a client assertion to request an access token for the app registration. - Finally, we use the
Get-AzAccessToken
cmdlet again to swap the access token for a token scoped for the Microsoft Graph API.
Now that we’ve got a usable token, the rest of the process is exactly the same as any other Graph API call.
$devicesUri = "https://graph.microsoft.com/beta/devices"
$headers = @{ Authorization = "Bearer $($accessToken)" }
$restParams = @{
Method = 'Get'
Uri = $devicesUri
Headers = $headers
ContentType = 'application/json'
}
$graphRequest = Invoke-RestMethod @restParams
Write-Output "Devices Found: $($graphRequest.value.count)"
$response = $graphRequest.value | ConvertTo-Json -Depth 100
$body = $response
Push-OutputBinding -Name Response -Value (
[HttpResponseContext]@{
StatusCode = [HttpStatusCode]::OK
Body = $body
}
)
Now we can deploy and run this function app and we will recieve a list of devices and their properties from the target tenant!
Using no modules at all
This is a bit more complex, but it shows exactly what is happening with this auth flow and as an added benefit, it should make your function app run a bit faster as it doesn’t need to load any modules from cold starts.
Get the managed identity token
The flow is exactly the same as before, but we can remove some configuration from our function app, as we don’t need the Az.Accounts
module anymore.
- In the
Requirements.psd1
, remove theAz.Accounts
module - in fact, the entire requirements file can be empty for this. - In the
profile.ps1
file, do the same - the boiler plate code that is generated does NOT work if we are not using theAz.Accounts
module.
Now we can begin. First, we need to get an access token for the managed identity, scoped to the api://AzureADTokenExchange
resource.
$remoteTenantId = {tenantId of the second tenant}
if ($env:MSI_SECRET) {
$resourceUri = 'api://AzureADTokenExchange'
$tokenUri = '{0}?resource={1}&api-version=2019-08-01' -f $env:MSI_ENDPOINT, $resourceUri
$tokenHeader = @{ "X-IDENTITY-HEADER" = $env:MSI_SECRET }
$msiTokenRequest = Invoke-RestMethod -Method Get -Uri $tokenUri -Headers $tokenHeader
}
breaking this down a bit:
- The resource URI that we use to make this work is
api://AzureADTokenExchange
. This is a special resource that allows us to exchange the managed identity token for an access token for the app registration. - The token URI calls a locally hosted endpoint on the function app that exists when we enable managed identities. The endpoint url is a variation of
http://localhost:{port}/MSI/token
where the port number changes depending on how the function app is built. We add the query parametersresource
andapi-version
to the URL to specify the resource we are requesting the token for and the API version we are using. More details can be found on the official documentation here. - We then build a header with the
X-IDENTITY-HEADER
key and the value of theMSI_SECRET
environment variable. This is a special header that is used to protect requests against Server Side Request Forgery (SSRF) attacks. The value of$env:MSI_SECRET
is rotated regularly by the app service hosting the function app. - Finally, we use the
Invoke-RestMethod
command to send a request to the token URI, which will return a token for the managed identity scoped to theapi://AzureADTokenExchange
resource.
Note: Most articles on this topic talk about using environmental variables
IDENTITY_ENDPOINT
andIDENTITY_HEADER
. Please note that in function apps,MSI_ENDPOINT
andMSI_SECRET
contain the same values, and either can be used interchangeably. I prefer to use theMSI
variables as they are more descriptive of what they are doing.
Get the access token for the remote tenant
Now that we have the managed identity token, we will use it to request an access token for the app registration in the second tenant.
$clientTokenBody = @{
client_id = $env:CLIENT_ID
scope = 'https://graph.microsoft.com/.default'
grant_type = 'client_credentials'
client_assertion_type = 'urn:ietf:params:oauth:grant-type:jwt-bearer'
client_assertion = $msiTokenRequest.access_token
}
$clientAuthUri = 'https://login.microsoftonline.com/{0}/oauth2/v2.0/token' -f $remoteTenantId
$clientAuthRequest = Invoke-RestMethod -Method Post -Uri $clientAuthUri -Form $clientTokenBody
$accessToken = $clientAuthRequest.access_token
There is slightly less to break down here, but for completeness:
- The
$clientTokenBody
variable is a hashtable that contains the parameters we need to send to the token endpoint. Theclient_assertion
parameter is the managed identity token we got from the previous step, and theclient_assertion_type
parameter is set tourn:ietf:params:oauth:grant-type:jwt-bearer
, which tells Azure AD that we are using a JWT token as a client assertion. - The
$clientAuthUri
is crafted to specifically refer to the tenant we are trying to access. - Finally, we use the
Invoke-RestMethod
command to send a request to the token endpoint, which will return an access token for the app registration in the second tenant.
Now that we have the access token, we can use the same code as before to make a request to the Microsoft Graph API!
If you’ve made it this far, congratulations! You’ve successfully set up a multi-tenant app registration that uses managed identities to securely obtain access to resources, while drastically lowering your security risk by removing the need to storing and managing secrets and certificates.
As always, I’ve put a working example of both authentication scenarios in my Code Dump repository.
Please remember that when working with managed identities, any access tokens you generate are as sensitive as any other credential, so make sure not to store them anywhere and DO NOT WRITE THEM TO LOGS. The tokens are valid for a limited time, but it is still critical to keep them secure.