Introduction
In the previous post we added Azure Active Directory to our API, next we will add roles and scopes to further limit access to our API.
Terminology
A secured resource can define a set of operations allowed by the client application by using scopes. You’ll typically use this to separate functionality on your API between e.g. a mobile user application and an administration web site.
You can use following documentation to expose scopes on your API.
While you could (ab)use scopes for role-based access control (RBAC), there’s also the concept of roles in AAD to give your application users certain rights based on groups. So far we have done everything in the portal under the App Registrations menu, but after creating the roles there you’ll have to switch to Enterprise applications to assign users or groups. More info on this page.
The code
Adding a scope
We previously added the Authorize
attribute on top of the Create
method to ensure that someone is logged in before being able to call this API endpoint. However, adding the Authorize
attribute only forces ASP.NET Core to validate the token. So someone might be logged in and pass through even though that person doesn’t have any roles assigned to work in your application.
Since we will be calling the API from our frontend on behalf of the user, we can use scopes. For that reason we’ve also added HttpContext.VerifyUserHasAnyAcceptedScope
in the method below.
/// <summary>
/// Register a new bird
/// </summary>
/// <param name="createBirdCommand">Required properties of a bird</param>
/// <returns>Bird object and location where to find the bird</returns>
[Authorize]
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(Bird))]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[HttpPost]
public IActionResult Create([FromBody] CreateBirdCommand createBirdCommand)
{
HttpContext.VerifyUserHasAnyAcceptedScope(new string[] { "api.read" });
Bird newBird = new Bird {Id = Guid.NewGuid()}; // TODO map + insert
return CreatedAtAction(nameof(Get), new {id = newBird.Id}, newBird);
}
An alternative to the method call above would be to verify the same scope as an attribute with either hardcoded values or a value from the configuration.
const string scopeRequiredByApi = "access_as_user";
[HttpGet]
[RequiredScope(scopeRequiredByApi)]
Adding roles
Scopes are most often used to assign access to a resource (API). What a user can do on that resource is defined in user roles. So after verifying access, we can go a step further and check the role as well before creating a new bird in the system.
[Authorize]
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(Bird))]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[HttpPost]
public IActionResult Create([FromBody] CreateBirdCommand createBirdCommand)
{
// in case you want to verify scopes (limit application functionality by client)
HttpContext.VerifyUserHasAnyAcceptedScope(new string[] { "api.read" });
// in case you want to verify user roles (limit functionality on user permissions).
if (User.IsInRole("BirdAtlas.API.Admins"))
{
Bird newBird = new Bird { Id = Guid.NewGuid() }; // TODO map + insert
return CreatedAtAction(nameof(Get), new { id = newBird.Id }, newBird);
}
return new ForbidResult();
}
Here we also have the option to put everything in an attribute instead, which is good enough for the code above, but might be not flexible enough if you have a fork in your code with multiple paths based on the role.
[Authorize(Roles = "BirdAtlas.API.Admins")]
We need one more step before we can actually use these roles: map the claims in our JWT token to roles in the User object. This is done by adding following code in the ConfigureServices
method of the Startup
class :
// Add AAD security
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApi(Configuration.GetSection("AzureAd"));
JwtSecurityTokenHandler.DefaultMapInboundClaims = true; // true is default
// The following lines code instruct the asp.net core middleware to use the data in the "roles" 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>(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
// The claim in the Jwt token where App roles are available.
options.TokenValidationParameters.RoleClaimType = "roles";
});
At this point we have a secured API with Azure Active Directory login, scopes and roles claims. This also means we’re no longer able to test the endpoints we have just protected with SwaggerUI. We’ll fix that in the next blog post.
Disclaimer and code
Technology is always evolving. Most of the concepts in this blog series will be applicable in future releases of .NET, although actual code might change. Throughout the blog posts I’m linking to as much official documentation as possible, as this is updated by the product teams on a regular basis.
Code for this blog series is available at https://github.com/SanITy-BV/BirdAtlas-backend. We try to keep the repository updated with new principles and newer .NET versions as it is our primary tool for demoing. Code might change slightly from what is described in the blog post, this both because of simplification for the reader of this post and possible future updates in the codebase. Always review the code before using in production software. Please reach out if you’re having issues with following the blog post because of these possible changes.