Azure AD Claims with Static Web Apps and Azure Functions
Authorization in Azure Functions is impaired by an issue with Azure Static Web Apps linked to Azure Functions. Azure AD app role claims are not supplied to Azure Functions. This post will demonstrate a workaround.
Updated 28th November 2022
After I posted this, Thomas Gauvin (Product manager for Static Web Apps) was kind enough to tweet this:
So by the sounds of it, this blog post will not be required in the longer term, as support should to be added directly. Tremendous news!
Related posts
- Graph API: getting users Active Directory group names and ids with the CSDK
- Microsoft Graph client: how to filter by endswith
Where's my claims?
There is a limitation that affects authorization when you have a linked backend paired with an Azure Static Web App. Let's take the case of having an Azure Function App as the linked backend. Essentially the Azure Function app does not receive the claims that the Static Web App receives. There's an issue tracking this on GitHub, and it seems that this is a general problem with Static Web Apps, Azure AD and linked backends.
We have a Static Web App, with an associated CFunction App (using the Bring Your Own Functions AKA "linked backend" approach). Both the Static Web App and Function App are associated with the same Azure AD App Registration.
When we're authenticated with Azure AD and go to the auth endpoint in our Static Web App: /.auth/me we see:
Note the claims in there. These include custom claims that we've configured against our Azure AD App Registration such as roles with OurApp.Read.
So we can access claims successfully in the Static Web App (the front end). However, the associated Function App does not have access to the claims.
It's possible to see this by implementing a function in our Azure Function App which surfaces roles:
When this /api/GetRoles endpoint is accessed we see this:
At first look, this seems great; we have claims! But when we look again we realise that we have far less claims than we might have hoped for. Crucially, our custom claims / app roles like OurApp.Read are missing.
Maybe they're hiding in x-ms-client-principal?
If we look directly at the x-ms-client-principal header, maybe we'll find what we need?
Alas not. We have the user's email and some simple roles ("authenticated" and "anonymous"), but no sign of our custom claims / app roles:
This is the problem: we want our Azure Function App to be able to make use of the same custom claims / app roles that we use for authorization in the Static Web App. How can we achieve this?
Microsoft Graph API
The answer lies with the Microsoft Graph API. We can interrogate it to get the app role assignments for the user. This will give us the same information that we have in the Static Web App. (Well to be strictly accurate, it will be a slightly different set of claims. But what matters is it will be the app role assignment claims that we want to use for authorization.)
We already have an Azure AD app registration. In order that we can interrogate the Microsoft Graph API, we'll need the following permissions:
- User.Read - to sign in
- User.Read.All - for acquiring the app role assignments against a user
- Application.Read.All - to get more information about the app role assignments - allowing us to translate the app role assignments into the claims that we want to use for authorization
Of the above permissions, it's likely that you'll already have delegated User.Read in place; the other two you might need to add and ensure they're granted in Azure.
Interrogating the Microsoft Graph API
Now we have an Azure AD App Registration with sufficient permissions, we'll need a GraphClient to interrogate the Microsoft Graph API. To get that we're going to build an AuthenticatedGraphClientFactory:
When we execute GetAuthenticatedGraphClientAndClientId we'll get back a GraphServiceClient that we can use to interrogate the Microsoft Graph API. We'll also get back the client ID of the Graph API App. We'll need this later. Note that the AuthenticatedGraphClientFactory requires the client ID, client secret and tenant ID of the Azure AD App Registration.
Now we have the ability to interrogate the Microsoft Graph API, we can write a PrincipalService.cs class that will interrogate it and return the app role assignments for the user:
Quite a lot of code! Let's walk through what it does:
It takes the x-ms-client-principal header and deserializes it into a MsClientPrincipal object - this is the cut down version of the ClaimsPrincipal object that we saw earlier:
It creates a new ClaimsIdentity using that information, but stripping out the anonymous role as it's superfluous.
Using the userDetails (email address) from the MsClientPrincipal object, it gets the app role assignments for that user from the Graph API. (We needed User.Read.All to do this.)
In a perfect world, we'd be able to use the AppRoleAssignments property on the User object to get the app role assignments for a user, but unfortunately that doesn't come with the human readable name you'd hope for; the MyApp.Read. So we have to interrogate the Graph API once more and use the Application that represents our Azure AD App Registration (we acquire this by filtering for an appId matching our clientId). Then we can get the human readable / MyApp.Read role assignment.
It adds the app role assignments as role claims to the ClaimsIdentity object.
It returns the ClaimsIdentity object wrapped in a ClaimsPrincipal object.
Using the PrincipalService
In order that we can make use of our PrincipalService we need to configure it and the AuthenticatedGraphClientFactory in our Startup class:
With that in place, we can now use the IPrincipalService in a function:
The above class has 2 functions:
- GetPrincipal - returns the ClaimsPrincipal object as JSON
- AmIInRole - takes a role query parameter, tests if a user has that role and returns a 403 if they don't and a 200 with a welcome message if they do
GetPrincipal - what claims do we have?
Let's try out the GetPrincipal function, when I go to the /api/get-principal endpoint I see this:
This isn't the same information as the Static Web Apps principal, but it's close enough for our purposes. Crucially, we can see the AppRoleAssignment OurApp.Read that we assigned to our user in the Azure Portal. That is the key information that we need, and that we are missing by default.
Crucially this is enough information for us to be able to apply authorization to our functions.
AmIInRole - test IsInRole functionality
We can demonstrate applying authorization by using the AmIInRole function. This internally uses the inbuilt IsInRole functionality of the ClaimsPrincipal object, and returns an appropriate API result accordingly.
If I go to the /api/am-i-in-role?role=OurApp.Read endpoint I get a 200 status code and the message: Welcome johnny_reilly@hotmail.com - you have role OurApp.Read!. This makes sense, my user account has the OurApp.Read role.
Let's test that we also deny access appropriately. There is an OurApp.Write role; my account does not have this. If I go to the /api/am-i-in-role?role=OurApp.Write endpoint I get a 403 status code and the message: Forbidden for OurApp.Write.
It works!
Conclusion
We've demonstrated a way to acquire a ClaimsPrincipal object that contains the AppRoleAssignments for a user. This is enough information for us to be able to apply authorization to our functions.
It would be ideal if this wasn't required, and I'm hoping that the Static Web Apps team will be able to provide a solution for this in the future. Keep an eye on this GitHub issue. In the meantime, this is a workable solution.
Thanks to Warren Joubert for his help with this post.
Discussion in the ATmosphere