dotnet / AspNetCore.Docs

Documentation for ASP.NET Core
https://docs.microsoft.com/aspnet/core
Creative Commons Attribution 4.0 International
12.63k stars 25.29k forks source link

Blazor roles and groups authn in the WASM server API app (Azure AD Hosted) #19573

Closed SanderDevCode closed 4 years ago

SanderDevCode commented 4 years ago

Hi,

After the documentation issue (https://github.com/dotnet/AspNetCore.Docs/issues/19304). I got the roles and groups correctly in the user-claims (setup as https://docs.microsoft.com/en-us/aspnet/core/blazor/security/webassembly/azure-active-directory-groups-and-roles?view=aspnetcore-3.1).

Authorization works fine in the web app. I can call the server API app (due the API permission and scope) but I can't get the groups and roles as claim in the server API app to authorize the user that calls the API.

Maybe I'm missing something, but I can't get it to work... Is it possible to document a a bit more for this usecase?


Document Details

Do not edit this section. It is required for docs.microsoft.com ➟ GitHub issue linking.

guardrex commented 4 years ago

Hello @swannet ... I'll look into this as soon as I can. We're busy with .NET 5 and other work right now. I'll try to look at this sometime this week. In the meantime if you figure it out, please post back on this issue what the problem was.

guardrex commented 4 years ago

Ok ... back. So ...

I searched engineering issues, and I didn't see anything on this subject. Azure docs imply that with the implicit auth flow that one ... "probably" is the word that I think they used ... "probably" needs to call Graph API.

I know that you can make a Graph API call in the Server app, get a user's roles/groups for claims, and then have policies set based on the claims. What a pain tho, right?!! :smile: lol

I tried an approach looking at Azure AD docs where you specify optionalClaims in the app's manifest (in the Azure portal) trying to get Azure AD to place a claim into the access token (for example, groups) ...

"optionalClaims": {
  "idToken": [],
  "accessToken": [
    {
      "name": "groups",
      "source": null,
      "essential": false,
      "additionalProperties": []
    }
  ],
  "saml2Token": []
},

Alas, it didn't work. :cry:

I'll leave this on the issue on the Blazor.Docs project for triage so that Artak can take a look at it.

The question is ...... For a hosted Blazor app with Azure AD, is the only way for the Server app to get the user's roles and groups via its own Graph API call? ... or is there a way to get those claims set up in the access token in a more automatic way so that the Server app receives them as claims and then the Server app merely needs one or more policies set up?

guardrex commented 4 years ago

I did a bit more research on this today: Various remarks around the Net indicate that implicit flow OAuth 2.0 doesn't support the groups claim in access tokens. I see devs in various places having their server API app call Graph API in OpenIdConnectOptions > OnTokenValidated and setting user claims that way. Then, policies would make quick work of authorizing controllers in the app.

I'll hack around with it in my test app and report back.

guardrex commented 4 years ago

TL;DR

The ask is to document how to enforce Azure AD roles+groups for Server API controller calls with Graph API in the hosted Blazor with Azure AD scenario. This is a working approach based on ADAL (v1.0) bits and packages, which is supported until late 2022 but not recommended for new development. When RC1 lands, there will be a new approach (new topic version) for this scenario using the Identity Platform v2.0 API and packages.

Code version history of this comment

Original Post: 8/21

Code Updated: 8/23 - Used a different approach to set up the GraphServiceClient where the auth header is handed to the Request instead of being configured in a DelegateAuthenticationProvider. Include improved error trapping.

Code Updated: 8/24 - Return to DelegateAuthenticationProvider and align closer to MS examples.

Code Updated: 8/24 - Institute use of the ADAL token cache (check for silent token acquisition first) before making a user assertion with a client credential. After a token is in the cache, silent token acquisition works.

Warning

This isn't suitable for production use until it receives the approval of Azure AD/Identity security pros who, unlike me 😨, actually know what they're doing! I'll ask Javier to look at this after RC1 lands.

This is definitely 💀 Use At Your Own Risk:tm: 💀 until then. ......... but it's fairly close to current MS published examples, which any dev can confirm via Identity/ADAL v1.0 topics.

User-defined app roles vs. Azure built-in Administrator Roles and security groups

The approach in this section only works for Azure built-in Administrator Roles and security groups, which are obtained by an ADAL (v1.0) call to MemberOf. The Server API app has no understanding of a client app's user-defined roles. I'll consider expanding this for user-defined roles later.

Setup

Azure portal

Packages

Add packages to the Server app ...

<PackageReference Include="Microsoft.Graph" Version="3.10.0" />
<PackageReference Include="Microsoft.IdentityModel.Clients.ActiveDirectory" Version="5.2.8" />

App settings

Add a ClientSecret entry with the Server app's client secret from the Azure portal.

"AzureAd": {
  "Instance": "https://login.microsoftonline.com/",
  "Domain": "XXXXXXXXXXXX.onmicrosoft.com",
  "TenantId": "{GUID}",
  "ClientId": "{GUID}",
  "ClientSecret": "{CLIENT SECRET}"
},

Policy

Set up the policy in Startup.ConfigureServices of the Server app ...

services.AddAuthorization(options =>
{
    options.AddPolicy("BillingAdmin", policy => policy.RequireClaim("group", "69ff516a-b57d-4697-a429-9de4af7b5609"));
});

Controller

Require the policy for weather data on the WeatherForecastController in the Server app ...

[Authorize(Policy = "BillingAdmin")]
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
    ...
}

Service config

In Startup.ConfigureServices use the following code.

This is going to be the legacy approach with ADAL (Identity v1.0). This will change considerably for Identity v2.0 with .NET 5. I'll start working up the new coverage at RC1, which will be in late September.

Full namespaces for the Startup class ...

using System;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.AzureAD.UI;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Graph;
using Microsoft.IdentityModel.Clients.ActiveDirectory;
using Microsoft.IdentityModel.Logging;

Inside Startup.ConfigureServices ...

#if DEBUG
// For help trobleshooting exceptions ...
IdentityModelEventSource.ShowPII = true;
#endif

services.AddAuthentication(AzureADDefaults.JwtBearerAuthenticationScheme)
    .AddAzureADBearer(options => Configuration.Bind("AzureAd", options));
// Original code used the AzureADDefaults.BearerAuthenticationScheme

services.Configure<JwtBearerOptions>(AzureADDefaults.JwtBearerAuthenticationScheme, options =>
{
    options.Events = new JwtBearerEvents()
    {
        OnAuthenticationFailed = (context) =>
        {
            // Log the exception
            Console.WriteLine($"OnAuthenticationFailed: {context.Exception}");

            return Task.CompletedTask;
        },
        OnTokenValidated = async context =>
        {
            var accessToken = context.SecurityToken as JwtSecurityToken;
            var upn = accessToken.Claims.FirstOrDefault(x => x.Type == "upn")?.Value;

            if (!string.IsNullOrEmpty(upn))
            {
                var authContext = new AuthenticationContext(
                    $"{Configuration["AzureAd:Instance"]}{Configuration["AzureAd:TenantId"]}");
                AuthenticationResult authResult = null;

                try
                {
                    authResult = await authContext.AcquireTokenSilentAsync(
                        "https://graph.microsoft.com", Configuration["AzureAd:ClientId"]);
                }
                catch (AdalException adalException)
                {
                    if (adalException.ErrorCode == AdalError.FailedToAcquireTokenSilently || 
                        adalException.ErrorCode == AdalError.UserInteractionRequired)
                    {
                        var userAssertion = new UserAssertion(accessToken.RawData,
                            "urn:ietf:params:oauth:grant-type:jwt-bearer", upn);
                        var clientCredential = new ClientCredential(Configuration["AzureAd:ClientId"],
                            Configuration["AzureAd:ClientSecret"]);
                        authResult = await authContext.AcquireTokenAsync(
                            "https://graph.microsoft.com", clientCredential, userAssertion);
                    }
                }

                var graphClient = new GraphServiceClient(
                    new DelegateAuthenticationProvider(async requestMessage => {
                        requestMessage.Headers.Authorization =
                            new AuthenticationHeaderValue("Bearer", authResult.AccessToken);

                        await Task.CompletedTask;
                    }));

                var userIdentity = (ClaimsIdentity)context.Principal.Identity;

                IUserMemberOfCollectionWithReferencesPage groupsAndAzureRoles = null;

                // To prefer direct and transient memberships, use ...
                // IUserTransitiveMemberOfCollectionWithReferencesPage groupsAndRoles = null;

                try
                {
                    groupsAndAzureRoles = await graphClient.Users[upn].MemberOf.Request()
                        .GetAsync();

                    // To prefer direct and transient memberships, use ...
                    //groupsAndAzureRoles = await graphClient.Users[upn].TransitiveMemberOf
                    //    .Request().GetAsync();
                }
                catch (ServiceException serviceException)
                {
                    // Log the error
                    Console.WriteLine(
                        $"OnTokenValidated: Service Exception: {serviceException.Message}");
                }

                if (groupsAndAzureRoles != null)
                {
                    foreach (var entry in groupsAndAzureRoles)
                    {
                        // Distinguish between built-in Azure Administrator Roles and
                        // security groups by checking entry.ODataType. The value is
                        // either "#microsoft.graph.directoryRole" or "#microsoft.graph.group".

                        userIdentity.AddClaim(new Claim("group", entry.Id));
                    }
                }
            }
            else
            {
                // Log missing UPN
                Console.WriteLine($"OnTokenValidated: UPN missing: {accessToken.RawData}");
            }

            await Task.CompletedTask;
        }
    };
});

Run the app

🥁 roll plz! ...

  1. Run the app.
  2. Log in.
  3. Navigate to the FetchData component page ...

:tada: Booyeah! :beers:

Capture

Test failure

If you change that GUID in the policy ... just knock the "9" off of the end of that GUID ... run the app again, log in, go to the FetchData page ... 💥 403 - Forbidden 💥.

Capture1
guardrex commented 4 years ago

@jmprieur ... Do you have a few minutes to take a look at my ADAL v1.0 on-behalf-of approach for Graph API via a web API backend in a hosted Blazor solution? ... or can you suggest someone who might have a few minutes to take a look at this? I ask because I'm referencing docs for this approach that you've worked on in this area.

This is just to tide us over to .NET 5 GA. I'll have the coverage for the new Blazor templates with Identity v2.0 API and packages worked up for the release (I hope) by mid-October in new hosted Blazor WASM security topics. What I'm working on here is for the current coverage, which is all based on Identity v1.0.

Everything is in just one comment on this issue at :point_up: ...

https://github.com/dotnet/AspNetCore.Docs/issues/19573#issuecomment-678442702

... and it follows the MS examples fairly closely. It seems to run well.

One thing that doesn't match up to MS examples is that I don't attempt to acquire a token silently. I do the following ...

  1. Grab the upn from the JWT that arrives to the web API ...

    var accessToken = context.SecurityToken as JwtSecurityToken;
    var upn = accessToken.Claims.FirstOrDefault(x => x.Type == "upn")?.Value;
  2. Use the upn for my user assertion:

    var userAssertion = new UserAssertion(accessToken.RawData, "urn:ietf:params:oauth:grant-type:jwt-bearer", upn);

    ... and my Graph API call ...

    userDefinedRoles = await graphClient.Users[upn].AppRoleAssignments.Request().GetAsync();
  3. If anything fails ... AdalException, ServiceException, etc. ... the code merely fails to provide a valid claim, thus the anthn policy fails and the web API controller is inaccessible with the calling app getting back a 403.

If the approach is a 💩 Rex Code Smell:tm: 👃😄, then I'll need a tip on the correct approach. Attempting to acquire a token silently first doesn't quite make sense to me for this scenario (yet ... I might be missing a critical concept on that point). It seems to make more sense to just attempt token acquisition on behalf of the user. If it fails ... 403 back ... if it succeeds ... let the web API run and return the result. Besides all of that, when I tried acquiring a token silently, it always threw :boom: (but perhaps my code was bad).

The whole process is laid out here ...

https://github.com/dotnet/AspNetCore.Docs/issues/19573#issuecomment-678442702

jmprieur commented 4 years ago

@swannet 2 things:

What is the exact exception?

However: ADAL is being deprecated and Microsoft Identity asks every first party partner to move to MSAL and MIcrosoft Graph. See Alex Simon's blog: https://techcommunity.microsoft.com/t5/azure-active-directory-identity/update-your-applications-to-use-microsoft-authentication-library/ba-p/1257363

So I would really not invest in any article or code sample on ADAL at that point.

SanderDevCode commented 4 years ago

@jmprieur

Thanks for the headsup. I can understand it would be better to go for the MSAL and Graph API approach, I followed the guide https://docs.microsoft.com/en-us/aspnet/core/blazor/security/webassembly/hosted-with-azure-active-directory?view=aspnetcore-3.1 for .NET core 3.1 which uses the "Microsoft.AspNetCore.Authentication.AzureAD.UI" package for the backend (ServerAPI) instead of the MSAL approach. The ClientApp is using "Microsoft.Authentication.WebAssembly.Msal"

Correct my if I'm wrong but is your suggestion to use a MSAL approach for the ServerAPI too? If so, is it just as easy as change the packages and setup the code just like the ClientApp project? And will the token be accepted by the ServerAPI that way?

guardrex commented 4 years ago

Sorry @swannet ... Don't mean to step on your question. Let me interject in passing ...

that the tenant admin needs to provide admin consent for the Web API to call the downstream API (as API cannot interact with the user) ... You should not need to provide the upn, to my knowledge

Thanks ... it seems then that my code was bad in the silent token acquisition approach. I'll try again.

every first party partner to move to MSAL and MIcrosoft Graph

MSAL.NET relies on Identity v2.0 packages and API, which is still in preview ...

For ASP.NET Core web apps and web APIs, MSAL.NET is encapsulated in a higher level library named Microsoft.Identity.Web

The goal of this approach is to cover an end-to-end approach without the use of preview packages. Support for ADAL runs to 2022.

Yes tho on coverage of MSAL.NET with Identity v2.0 for .NET 5. That's the plan. New docs will cover the use of the new templates, new API, and new packages. The new content will (probably) be up in preview docs by mid-October and final content for release mid-November.

guardrex commented 4 years ago

I may have an answer as to why silent token acquisition fails on the first pass and why the silent token call is there at all. Until a token is obtained on behalf of the user the first time, there's no token in the cache for silent token acquisition to occur (imo, not well covered by the docs I was looking at, but it's probably in other docs that I didn't see). Therefore, silent token acquisition should be implemented for future requests. I'll run some additional tests now to confirm that that's the case.

guardrex commented 4 years ago

CONFIRMED! ... The behavior is ...

I wish I had caught an example that explained it out, but the docs that I hit weren't crystal clear. They relied upon looking at the API and knowing more about web API with OAuth 2.0. My professional experience didn't encompass it.

@swannet ... I've updated the code in the remark :point_up: with the latest approach. Of course, it's still 💀 use at your own risk 💀 until Javier gets free after RC1 is released and can take a look. However, it does follow the MS published examples now very closely.

WRT MSAL.NET (and the new Blazor template with Microsoft.Identity.Web API+packages), absolutely that's next. I'll get that up in preview documentation shortly after we reach RC1 release. I don't usually doc things that complex until RC1 because I've (and MS) been burned before documenting API that shifts before RC1 lands. It's costly in 🕐 ... thus 💰 ... when it happens.