Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Azure AD Claims with Static Web Apps and Azure Functions (Authorization) #988

Open
johnnyreilly opened this issue Nov 15, 2022 · 19 comments

Comments

@johnnyreilly
Copy link

johnnyreilly commented Nov 15, 2022

Describe the bug

Azure AD app role claims are not supplied to Azure Functions when linked with Azure Static Web Apps using the "bring your own functions" / linked backend approach. This impairs implementing authorization against endpoints.

To Reproduce
Steps to reproduce the behavior:

  1. Create an Azure AD application with some custom app roles; eg:

image

  1. Create a Static Web App and a Function App
  2. Both the SWA and FA should use the Azure AD application and be linked
  3. Take a look at the /.auth/me endpoint in the SWA - note the claims; they should include one of your custom app roles. eg OurApp.Read
{
  "clientPrincipal": {
    "identityProvider": "aad",
    "userId": "d9178465-3847-4d98-9d23-b8b9e403b323",
    "userDetails": "johnny_reilly@hotmail.com",
    "userRoles": ["authenticated", "anonymous"],
    "claims": [
      // ...
      {
        "typ": "http://schemas.microsoft.com/identity/claims/objectidentifier",
        "val": "d9178465-3847-4d98-9d23-b8b9e403b323"
      },
      {
        "typ": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
        "val": "johnny_reilly@hotmail.com"
      },
      {
        "typ": "name",
        "val": "John Reilly"
      },
      {
        "typ": "roles",
        "val": "OurApp.Read"
      },
      // ...
      {
        "typ": "ver",
        "val": "2.0"
      }
    ]
  }
}
  1. Take a look at the claims that the Function App endpoints receive. It's possible to see this by implementing a function which surfaces roles:
[FunctionName("GetRoles")]
public static async Task<IActionResult> Run(
    [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = "GetRoles")] HttpRequest req
)
{
    var roles = req.HttpContext.User?.Claims.Select(c => new { c.Type, c.Value });

    return new OkObjectResult(JsonConvert.SerializeObject(roles));
}

Which will then be accessed at the static web app's /api/GetRoles endpoint:

[
  {
    "Type": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier",
    "Value": "d9178465-3847-4d98-9d23-b8b9e403b323"
  },
  {
    "Type": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
    "Value": "johnny_reilly@hotmail.com"
  },
  {
    "Type": "http://schemas.microsoft.com/ws/2008/06/identity/claims/role",
    "Value": "authenticated"
  },
  {
    "Type": "http://schemas.microsoft.com/ws/2008/06/identity/claims/role",
    "Value": "anonymous"
  }
]

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.

Expected behavior

Where a Static Web App has a linked Function App, the Function App should receive a user's App Roles custom claims in calls to API endpoints, in the same way they do at the SWA's ./auth/me endpoint. Not just UserRoles.

Why is this important?

In a word: authorisation. App Roles custom claims are typically used to apply authorisation against applications. Without this in place people have to handroll an authorisation mechanism. The methods they come up with can be insecure and often do not scale.

Device info (if applicable):

N/A

Additional context

I'd be happy to demo this directly and share code. I'm working with Warren Joubert of Microsoft on a workaround for this and understand this to be a general problem that users are experiencing. It would be tremendous to get this remedied.

Writing this problem up here, with a workaround - ideally this shouldn't be needed

@johnnyreilly johnnyreilly changed the title Azure AD Claims with Static Web Apps and Azure Functions Azure AD Claims with Static Web Apps and Azure Functions (Authorisation) Nov 15, 2022
@johnnyreilly johnnyreilly changed the title Azure AD Claims with Static Web Apps and Azure Functions (Authorisation) Azure AD Claims with Static Web Apps and Azure Functions (Authorization) Nov 15, 2022
@davide-bergamini-sevenit

A similar problem exists when linking an App Service.
I think it will be great if it could pass the AD token "X-MS-TOKEN-AAD-ID-TOKEN" like EasyAuth do, in this way we could use Microsoft.Identity.Web.

@johnnyreilly
Copy link
Author

Agreed @davide-bergamini-sevenit - I suspect this is a general problem and affects all linked backends. So would expect container apps to have a similar issue.

@johnnyreilly
Copy link
Author

johnnyreilly commented Nov 17, 2022

I've written up my workaround in this post: https://johnnyreilly.com/azure-ad-claims-static-web-apps-azure-functions

Thanks @warrenandre for this assistance.

@houlgap
Copy link

houlgap commented Dec 9, 2022

Apologies if I have missed something in your description, and with a caveat that I've already upgraded my project to use .net 7.0 but I managed to get the user claims/roles working as expected when I deserialize the payload of the request to /getroles, rather than using the req.HttpContext.User property...

[Function("GetRoles")]
public async Task<HttpResponseData> GetRoles(
    [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")]
    HttpRequestData req)
{
    var payload = JsonConvert.DeserializeObject<UserPayload>(await req.ReadAsStringAsync());

    var roles = new List<string>();
    foreach (var claim in payload.claims)
    {
        if (claim.typ == ClaimTypes.Role)
        {
            roles.Add(claim.val);
        }
    }
   var response = req.CreateResponse(HttpStatusCode.OK);
   await response.WriteAsJsonAsync(new { roles = roles });
   return response;
}

public class UserPayload
{
    public string identityProvider { get; set; }
    public string userId { get; set; }
    public string userDetails { get; set; }
    public string accessToken { get; set; }
    public List<UserClaims> claims { get; set; } = new();

    public class UserClaims
    {
        public string typ { get; set; }
        public string val { get; set; }
    }
}

If I then query the /.auth/me endpoint of the deployed application, I see both the claim and the userRole set.
The role is also included in all subsequent requests in the x-ms-client-principal header

{
  "clientPrincipal": {
    "identityProvider": "aad",
    "userId": "...",
    "userDetails": "...",
    "userRoles": [
      "anonymous",
      "authenticated",
      "test-role"
    ],
    "claims": [
      ...
      {
        "typ": "http://schemas.microsoft.com/ws/2008/06/identity/claims/role",
        "val": "test-role"
      },
      ...
    ]
  }
}

@johnnyreilly
Copy link
Author

johnnyreilly commented Dec 9, 2022

Is your test-role role a custom claims that you've configured against your Azure AD App Registration? That's what we were / are missing.

Oh wait, you're manually adding a role first? I'm not quite following what your Function("GetRoles") is intended to do?

@houlgap
Copy link

houlgap commented Dec 9, 2022

Yes, I created an application role in the App registration, then assign it to the required user/group in the corresponding Enterprise Application.

Without the getRoles function reading the claims and returning the custom role it isn't included in the userRoles array

image

@houlgap
Copy link

houlgap commented Dec 9, 2022

Sorry, missed a crucial detail as it coincided with how you had named your function.
In your staticwebapp.config.json file, you need to specify the rolesSource property to point to the GetRoles function. This is then called by the platform when a user logs in.

 "auth": {
        "rolesSource": "/api/GetRoles",
        "identityProviders": ...
    }

@johnnyreilly
Copy link
Author

Oh I see! (I think)

You're not manually calling /api/getroles yourself, you're implementing that function and with that in place, *other" functions will now (behind the scenes) invoke this and as a consequence have the custom claims that you've configured against your Azure AD App Registration?

https://learn.microsoft.com/en-us/azure/static-web-apps/assign-roles-microsoft-graph

That's worth knowing! And also mighty peculiar!

@houlgap
Copy link

houlgap commented Dec 9, 2022

yes, sorry for the confusion!

@johnnyreilly
Copy link
Author

I'll try and test this out - thanks for sharing!

@johnnyreilly
Copy link
Author

It's funny, you read the docs here: https://learn.microsoft.com/en-us/azure/static-web-apps/assign-roles-microsoft-graph#verify-custom-roles and it's not obvious how the approach you're suggesting would work. However that could totally be ashortcoming of the docs - will have to suck it and see

@gbelenky
Copy link

gbelenky commented Dec 30, 2022

works for me too as @houlgap suggested. Thanks, I spent so much time with @johnnyreilly blog, but was happy to find a solution which does not involve all those Graph queries. Happy New Year!

@steverhall
Copy link

steverhall commented May 4, 2023

We had mixed results using solution from @houlgap, then realized that sometimes, the "typ" of the claim was just "roles", not "http://schemas.microsoft.com/ws/2008/06/identity/claims/role". Unclear as to when or why this happens, but it happens frequently with the same browser from the same user.

Changed code to:

if (claim.typ == ClaimTypes.Role || claim.typ == "roles")
{
    roles.Add(claim.val);
}

as our work-around.

@leevi-sa
Copy link

leevi-sa commented Jul 31, 2023

@houlgap @steverhall Thank you for all sharing. But I found more mysterious problem.

In my PoC, the /api/getRoles is not called at all by the SWA platform. I can call that function directly through browser with the generated function-call url with the code parameter. But through the Function's Monitor, I can see there's no other calls except my manual ones.

Do you have thoughts on this issue?

I'm wondering if it's caused by that my backend function is deployed independently and linked to the SWA afterwards. Except this issue, all the interactions among AAD, SWA and AZ Function work well as excepted.

My frontend is a simple React app. And the backend is .Net/C# AZ Function.

@steverhall
Copy link

steverhall commented Jul 31, 2023 via email

@leevi-sa
Copy link

Hi @steverhall, thank you for the direction. Yes. It works now.

The mistake I made is that I did not change the default authLevel. VS uses AuthorizationLevel.Function as the default value. It's working perfectly after I change it back to AuthorizationLevel.Anonymous.

@johschmidt42
Copy link

johschmidt42 commented Sep 8, 2023

Thank you for providing this approach @johnnyreilly and others. I've got a few questions:

Edit: Questions got pretty much answered by reading the docs more carefully. Leaving the answers here though.

  • Will the roles returned by the rolesSource endpoint be added to the userRoles claim? I assume so but it's not clear to me from reading the docs. Edit: Yes, it will be added as explained in the docs.
  • Instead of using the managed Azure Function of the SWA, can this be a suppored BringYourOwn backend? E.g. an App Service? Edit: Yes, "You can use a managed function app or bring your own function app."
  • Where can I read up on the specs for creating a rolesSource? What's the input and what's the expected output? What happens if there is an error? How would I detect if calling this endpoint fails? Edit: Also explained in the docs, however, no idea how to check if it works other than checking /.auth/me or checking the request traces.
  • Instead of using the user's access token, why not using a service principal that has the permission to call msgraph to look up the users aad membership?

@johnnyreilly
Copy link
Author

To be honest, it's been a while since I did this and all the knowledge I have had been composed into this post:

https://johnnyreilly.com/azure-ad-claims-static-web-apps-azure-functions

@carlin-q-scott
Copy link

Am understanding correctly that the backchannel principal header only contains claims of type role from the Web App /.auth/me document? I wanted to use this for a multi-tenant app, so the tenantid claim is the most important to me, and it's missing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

8 participants