I’ve used Azure Active Directory (AAD) authentication and authorization in a variety of Web Apps for logins, calling external APIs (e.g. Graph API) and authorizing site area access and while authentication is reasonably simple to get working authorization has always been a bit more confusing.

This is fair enough as it’s essentially still a work-in-progress with Microsoft.Identity.Web having just been released as a v0.1.0 preview package. However it does mean that the documentation isn’t always accurate which makes getting to grips with it harder.

A case in point is the examples provided here which aren’t all that consistant with each other in terms of features available (though I’m still very happy they’ve provided such comprehensive samples).

I’ve been trying to get group based authorization working by following this sample which essentially works by assigning your groups to roles and then using role based authorization which feels like a bit of a hack but might be something that’s updated later.

The hardest part in this was correctly setting up the application in the Azure portal as the documentation seems to miss out a few crucial steps. I’m going to document this below but I wouldn’t be suprised if it went out of date pretty quickly.

The full project for this is available here.

The first step is to create a new Web Application, I created a .NET Core 3.1 MVC project and I’m not sure if all functionality will be available in older versions.

Once created add the following packages.

  • Microsoft.Graph
  • Microsoft.Identity.Web
  • Microsoft.Identity.Web.UI

Currently both Microsoft.Identity.Web and Microsoft.Identity.Web.UI are only available as preview versions so these won’t appear in the NuGet browser unless the “Include prerelease” box is checked.

Next add the Infrastructure and Services classes. These are based on those from both the roles and groups examples.

Startup.cs

The startup class now needs updating to add authorization and authentication and for this to be configured to cache tokens in memory (for demo purposes) and to map the groups attribute of the token to roles.

public class Startup
{
	public Startup(IConfiguration configuration)
	{
		Configuration = configuration;
	}

	public IConfiguration Configuration { get; }

	// This method gets called by the runtime. Use this method to add services to the container.
	public void ConfigureServices(IServiceCollection services)
	{
		services.AddSignIn(Configuration);

		// The following lines code instruct the asp.net core middleware to use the data in the "groups" claim in the Authorize attribute and User.IsInrole()
		// See https://docs.microsoft.com/aspnet/core/security/authorization/roles?view=aspnetcore-2.2 for more info.
		services.Configure<OpenIdConnectOptions>(options =>
		{
			// Use the groups claim for populating roles
			options.TokenValidationParameters.RoleClaimType = "groups";
		});

		services.AddOptions();

		// Token acquisition service based on MSAL.NET
		// and chosen token cache implementation
		services.AddWebAppCallsProtectedWebApi(Configuration, new string[] { Constants.ScopeUserRead, Constants.ScopeDirectoryReadAll })
			.AddInMemoryTokenCaches();

		// Add Graph
		services.AddGraphService(Configuration);
		services.AddMSGraphService(Configuration);

		services.AddControllersWithViews(options =>
		{
			var policy = new AuthorizationPolicyBuilder()
				.RequireAuthenticatedUser()
				.Build();
			options.Filters.Add(new AuthorizeFilter(policy));
		}).AddMicrosoftIdentityUI();

		services.AddRazorPages();
	}

	// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
	public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
	{
		if (env.IsDevelopment())
		{
			app.UseDeveloperExceptionPage();
		}
		else
		{
			app.UseExceptionHandler("/Home/Error");
			// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
			app.UseHsts();
		}

		app.UseHttpsRedirection();
		app.UseStaticFiles();
		app.UseCookiePolicy();

		app.UseRouting();
		app.UseAuthentication();
		app.UseAuthorization();

		app.UseEndpoints(endpoints =>
		{
			endpoints.MapControllerRoute(
				name: "default",
				pattern: "{controller=Home}/{action=Index}/{id?}");
			endpoints.MapRazorPages();
		});
	}
}

Now that we’ve scaffolded the Web App we need to register the AAD application that we’ll be using to manage authentication and authorization. This can be done from the “App registrations” blade of the “Azure Active Directory” menu.

I created a new application called “AADGroupAuthorization”, once created Azure will take you to the overview page from which you’ll need to note down the “Application (client) ID” and “Directory (tenant) ID”, you’ll also require the name of your domain, this will be something like <YOURNAME>.onmicrosoft.com.

A secret is also needed for the Web App to access the AAD application, this can be created in the “Certificates & secrets” blade.

With the IDs created above, add the following section to your appsettings.json file replacing the placeholder values.

"AzureAd": {
	"Instance": "https://login.microsoftonline.com/",
	"Domain": "<YOURNAME>.onmicrosoft.com",
	"TenantId": "<YOURTENANTID>",
	"ClientId": "<YOURCLIENTID>",
	"ClientSecret": "<YOURCLIENTSECRET>",
	"CallbackPath": "/signin-oidc",
	"SignedOutCallbackPath ": "/signout-callback-oidc"
},
"GraphApiUrl": "https://graph.microsoft.com/beta",

Next, add a web platform to your AAD application in the “Authentication” blade. In this you need to set the redirect and logout URIs to the path of your site and add an ID token to the implicit flow.

You should also add the root URI of your site to the redirect URIs list.

The AAD application manifest then needs updating to return group memberships, this can be done by adding the following property to the JSON in the “Manifest” blade.

"groupMembershipClaims": "SecurityGroup",

Next is the part which isn’t documented in the examples which is to configure the token to include the groups claim. This can be done on the “Token configuration” blade and more information is available here.

Once that’s all been done you should now be able to run your Web App and hopefully authenticate successfully. The first time a user logs into the app it will ask for permissions for the various rights that we have requested for it which are to access the user and directory details on the Graph API.

In order to make use of these permissions that we have requested from the Graph API we can update our Web App to display details about the logged in user account and the groups it is in.

HomeController.cs

[Authorize]
public class HomeController : Controller
{
	private readonly ILogger<HomeController> _logger;
	private readonly ITokenAcquisition _tokenAcquisition;
	private readonly IMSGraphService _graphService;

	public HomeController(ILogger<HomeController> logger, ITokenAcquisition tokenAcquisition, IMSGraphService graphService)
	{
		_logger = logger;
		_tokenAcquisition = tokenAcquisition;
		_graphService = graphService;
	}

	public IActionResult Index()
	{
		return View();
	}

	public IActionResult Privacy()
	{
		return View();
	}

	[AuthorizeForScopes(Scopes = new[] { Infrastructure.Constants.ScopeUserRead })]
	public async Task<IActionResult> Profile()
	{
		string token = await _tokenAcquisition.GetAccessTokenForUserAsync(new[] { Infrastructure.Constants.ScopeUserRead, Infrastructure.Constants.ScopeDirectoryReadAll });

		User me = await _graphService.GetMeAsync(token);
		ViewData["Me"] = me;

		try
		{
			// Get user photo
			ViewData["Photo"] = await _graphService.GetMyPhotoAsync(token);
		}
		catch (System.Exception)
		{
			ViewData["Photo"] = null;
		}

		IList<Group> groups = await _graphService.GetMyMemberOfGroupsAsync(token);

		ViewData["Groups"] = groups;

		return View();
	}

	[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
	public IActionResult Error()
	{
		return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
	}
}

Profile.cshtml

@{
    ViewData["Title"] = "Profile";
}
<h2>@ViewData["Title"]</h2>
<h3>@ViewData["Message"]</h3>

<table class="table table-striped table-condensed" style="font-family: monospace">
    <tr>
        <th>Property</th>
        <th>Value</th>
    </tr>
    <tr>
        <td>photo</td>
        <td>
            @{
                if (ViewData["photo"] != null)
                {
                    <img style="margin: 5px 0; width: 150px" src="data:image/jpeg;base64, @ViewData["photo"]" />
                }
                else
                {
                    <h3>NO PHOTO</h3>
                    <p>Check user profile in Azure Active Directory to add a photo.</p>
                }
            }
        </td>
    </tr>
    @{
        var me = ViewData["me"] as Microsoft.Graph.User;
        var properties = me.GetType().GetProperties();
        foreach (var child in properties)
        {
            object value = child.GetValue(me);
            string stringRepresentation;
            if (!(value is string) && value is IEnumerable<string>)
            {
                stringRepresentation = "["
                    + string.Join(", ", (value as IEnumerable<string>).OfType<object>().Select(c => c.ToString()))
                    + "]";
            }
            else
            {
                stringRepresentation = value?.ToString();
            }

            <tr>
                <td> @child.Name </td>
                <td> @stringRepresentation </td>
            </tr>
        }
    }
</table>

<h3>Your Current Groups:</h3>

<h4 style="color:blue">Group Membership Acquired via GraphAPI Calls. </h4>

<table class="table table-striped table-bordered table-condensed table-hover">
    <tr>
        <th>Name</th>
        <th>Group's ObjectID</th>
    </tr>

    @foreach (Microsoft.Graph.Group group in (List<Microsoft.Graph.Group>)ViewData["Groups"])
    {
        <tr>
            <td>@group.DisplayName</td>
            <td>@group.Id</td>
        </tr>
    }

</table>

_Layout.cshtml

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>@ViewData["Title"] - AADGroupAuthorization.Web</title>
    <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
    <link rel="stylesheet" href="~/css/site.css" />
</head>
<body>
    <header>
        <nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3">
            <div class="container">
                <button class="navbar-toggler" type="button" data-toggle="collapse" data-target=".navbar-collapse" aria-controls="navbarSupportedContent"
                        aria-expanded="false" aria-label="Toggle navigation">
                    <span class="navbar-toggler-icon"></span>
                </button>
                <div class="collapse navbar-collapse" id="navbarSupportedContent">
                    <ul class="navbar-nav mr-auto">
                        <li>
                            <div class="brand-logo">
                                <img src="~/images/shinigami.png" />
                            </div>
                        </li>
                        <li class="nav-item">
                            <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">Home</a>
                        </li>
                        <li class="nav-item">
                            <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
                        </li>
                    </ul>
                    <partial name="_LoginPartial" />
                </div>
            </div>
        </nav>
    </header>
    <div class="container">
        <main role="main" class="pb-3">
            @RenderBody()
        </main>
    </div>

    <footer class="border-top footer text-muted">
        <div class="container">
            &copy; 2020 - AADGroupAuthorization.Web - <a asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
        </div>
    </footer>
    <script src="~/lib/jquery/dist/jquery.min.js"></script>
    <script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
    <script src="~/js/site.js" asp-append-version="true"></script>
    @RenderSection("Scripts", required: false)
</body>
</html>

_LoginPartial.cshtml

@using Microsoft.Identity.Web
@if (User.Identity.IsAuthenticated)
{
    <ul class="nav navbar-nav navbar-right">
        <li class="navbar-text"><a class="nav-link" asp-controller="Home" asp-action="Profile">@User.GetDisplayName()</a></li>
        <li class="navbar-text"><a class="nav-link" asp-area="MicrosoftIdentity" asp-controller="Account" asp-action="SignOut">Sign out</a></li>
    </ul>
}
else
{
    <ul class="nav navbar-nav navbar-right">
        <li class="navbar-text"><a asp-area="MicrosoftIdentity" asp-controller="Account" asp-action="SignIn">Sign in</a></li>
    </ul>
}

These scripts add a sign in/out link to the layout as well as linking to the new Profile page from the users name on the layout page.

Now that we’re successfully authorizing our requests against the Graph API the final step is to add in group level authorization.

In order to use this you need to create some groups from the “Azure Active Directory” menu. I created 2 groups, AADGroupAuthorizationAdmin and AADGroupAuthorizationUser, I then assigned myself as a user to the admin group and a secondary login to the user group.

For this example I’m going to grant the AADGroupAuthorizationAdmin group access to the Privacy page and prevent members of the AADGroupAuthorizationUser group from accessing it. In order to do this note down the ID of the AADGroupAuthorizationAdmin group and then modify the Privacy method in the HomeController like so.

[Authorize(Roles = "<YOURGROUPID>")]
public IActionResult Privacy()
{
	return View();
}

You can then use the IsInRole method of the User object to control whether the link to the Privacy page is displayed in the layout or not.

@if (User.IsInRole("<YOURGROUPID>"))
{
	<li class="nav-item">
		<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
	</li>
}

Currently when a user isn’t authorized to view a page they are redirected to Account/AccessDenied which doesn’t exist (though is easy enough to create), there is a MicrosoftIdentity/Account/Access page as part of the Microsoft.Identity.Web.UI package but this is in a MicrosoftIdentity area so the route doesn’t match. I’m assuming there’s some way of directing the AccessDenied page here but until then it’s probably worth copying the AccountController and AccessDenied.cshtml pages from Microsoft.Identity.Web.UI into your projects.


3 Comments

Sam · 29th September 2020 at 2:00 am

Is there any way to get access_token from ITokenAcquisition in startup.cs class? All the samples on Internet demonstrates its use in Controller. Thanks!

    Shinigami · 29th September 2020 at 10:18 am

    Hi, I don’t think it possible to access an instance of ITokenAcquisition in Startup unfortunatly.

    I was going to suggest to retrieve it in the Configure method like so, but it appears that the service isn’t available (at least in my example) at runtime.

    var tokenService = app.ApplicationServices.GetService();
    string token = tokenService.GetAccessTokenForUserAsync(new[] { Infrastructure.Constants.ScopeUserRead, Infrastructure.Constants.ScopeDirectoryReadAll }).Result;

    I’m guessing that the service doesn’t get created until after authentication which occurs outside of the Startup scope, though it might be possible to unpick the extension methods that create it and try and create it earlier.

    Depending on your use case you might be able to use AzureServiceTokenProvider which allows you to get access tokens for Azure resources in Startup using MSI, though I don’t think this will work for controlling user access.

    https://docs.microsoft.com/en-us/azure/key-vault/general/service-to-service-authentication

      Sam · 29th September 2020 at 3:43 pm

      Ah ok! Thanks for confirmation.

Leave a Reply

Your email address will not be published. Required fields are marked *