I would like to introduce the OData authorization library for Web API. Using the OData ModelBuilder, you can annotate your EDM model with permission restrictions that inform your API what permissions are required for which operations. These annotations are based on the OData Capabilities Vocabulary. However, nothing enforces these permissions on your API. You would need to manually define authorization policies in your application to apply these permissions. With the new authorization library, these permissions can be applied automatically to your OData API endpoints with a bit of set-up code.
In this tutorial, I am going to show you how to use the library to add authorization to a simple OData API.
The library is currently available in beta version on NuGet as Microsoft.AspNetCore.OData.Authorization, it currently only supports OData WebApi 7.x applications based on AspNetCore 3.1 with endpoint routing.
Creating the Applicaton
- Create an ASP.NET Core 3.1 web application, using the API template. Let’s call the application ODataAuthorizationDemo
- Install the following NuGet packages:
Microsoft.AspNetCore.OData
(7.5.x)Microsoft.EntityFrameworkCore
(we’ll use EF Core for interacting with a database)Microsoft.EntityFrameworkCore.InMemory
(we’ll use an in-memory database for this demo)Microsoft.OData.ModelBuilder
(1.0.3 or later) (we’ll use to create the OData model and specify the permission restrictions)Microsoft.AspNetCore.OData.Authorization
(0.1.0-beta) (the WebApi Authorization library, see instructions below)
When installing the authorization package, go to Manage NuGet Packages as you would normally when installing packages. Make sure to check the Include prerelease checkbox. You should now be able to search for Microsoft.AspNetCore.OData.Authorization
.
Create the DB Context and model classes
For demo purposes, we are only going to create one entity: Product
.
Create a folder called Models
. Add the following class file to that folder and call it Product.cs
:
using System.ComponentModel.DataAnnotations; namespace ODataAuthorizationDemo.Models { public class Product { public int Id { get; set; } public string Name { get; set; } public int Price { get; set; } } }
Next, let’s create our EF Core database context. Add an AppDbContext.cs
file to the Models
folder with the following code:
using Microsoft.EntityFrameworkCore; namespace ODataAuthorizationDemo.Models { public class AppDbContext : DbContext { public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } public DbSet<Product> Products { get; set; } } }
Create the OData Model
For this demo, we’ll use the OData ModelBuilder package to create an OData model based on our C# model classes. We’ll also use the model builder to add permission restrictions.
Let’s add an AppEdmModel.cs
in the Models
folder with the following code:
using Microsoft.OData.Edm; using Microsoft.OData.ModelBuilder; namespace ODataAuthorizationDemo.Models { public static class AppEdmModel { public static IEdmModel GetModel() { var builder = new ODataConventionModelBuilder(); var products = builder.EntitySet<Product>("Products"); products.HasReadRestrictions() .HasPermissions(p => p.HasSchemeName("Scheme").HasScopes(s => s.HasScope("Product.Read"))) .HasReadByKeyRestrictions(r => r.HasPermissions(p => p.HasSchemeName("Scheme").HasScopes(s => s.HasScope("Product.ReadByKey")))); products.HasInsertRestrictions() .HasPermissions(p => p.HasSchemeName("Scheme").HasScopes(s => s.HasScope("Product.Create"))); products.HasUpdateRestrictions() .HasPermissions(p => p.HasSchemeName("Scheme").HasScopes(s => s.HasScope("Product.Update"))); products.HasDeleteRestrictions() .HasPermissions(p => p.HasSchemeName("Scheme").HasScopes(s => s.HasScope("Product.Delete"))); return builder.GetEdmModel(); } } }
This creates a model with a Products
entity set based on the Product
entity type. It adds permission restrictions for different CRUD operations on that entity set, specifying which scopes are required to execute those operations.
- Reading products (
GET /Products
) requires the user to have the scopeProduct.Read
- Reading a single product by its key (
GET /Products(1)
) can also be access with the scopeProduct.ReadByKey
in case the user does not have theProducts.Read
scope. - Creating a new product (
POST /Products(1)
) requires the scopeProduct.Create
- Updating a product (
PATCH /Products(1)
) requiresProduct.Update
- Deleting a product (
DELETE /Products(1)
requiresProduct.Delete
)
These restrictions are added as capability annotations to the OData model. The authorization middleware will read these annotations to extract permission scopes required for different requests. An operation for which no restrictions have been defined will be allowed by default.
Configure Startup services
Now let’s configure the different services and the app builder in the Startup.cs
file.
Let’s modify the ConfgiureServices
method so that it looks like this:
public void ConfigureServices(IServiceCollection services) { services.AddDbContext<AppDbContext>(opt => opt.UseInMemoryDatabase("ODataAuthDemo")); services.AddOData(); services.AddRouting(); }
And the Configure
method:
public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } app.UseRouting(); app.UseEndpoints(endpoints => { endpoints.Expand().Filter().Count().OrderBy(); endpoints.MapODataRoute("odata", "odata", AppEdmModel.GetModel()); }); }
using
statements:using Microsoft.EntityFrameworkCore; using Microsoft.AspNet.OData.Extensions; using ODataAuthorizationDemo.Models;
Now if we run the project and visit the GET /odata/$metadata
endpoint in a tool like Postman, we should see the generated EDM model. The model should contain annotations for the different restriction types that we specified: ReadRestrictions
, InsertRestrictions
, UpdateRestrictions
and DeleteRestrictions
. Each of these restriction annotations should a Permissions
property with the scopes that we defined.
Here’s an excerpt of the generated annotations:
<Annotations Target="Default.Container/Products"> <Annotation Term="Org.OData.Capabilities.V1.ReadRestrictions"> <Record> <PropertyValue Property="Permissions"> <Collection> <Record> <PropertyValue Property="SchemeName" String="Scheme" /> <PropertyValue Property="Scopes"> <Collection> <Record> <PropertyValue Property="Scope" String="Product.Read" /> </Record> </Collection> </PropertyValue> </Record> </Collection> </PropertyValue> <PropertyValue Property="ReadByKeyRestrictions"> <Record> <PropertyValue Property="Permissions"> <Collection> <Record> <PropertyValue Property="SchemeName" String="Scheme" /> <PropertyValue Property="Scopes"> <Collection> <Record> <PropertyValue Property="Scope" String="Product.ReadByKey" /> </Record> </Collection> </PropertyValue> </Record> </Collection> </PropertyValue> </Record> </PropertyValue> </Record> </Annotation> ... </Annotations>
Adding the controller
Let’s create a ProductsController
inside the Controllers
folder to implement our CRUD operations:
using System.Threading.Tasks; using Microsoft.AspNet.OData; using Microsoft.AspNetCore.Mvc; using ODataAuthorizationDemo.Models; namespace ODataAuthorizationDemo.Controllers { public class ProductsController: ODataController { private AppDbContext _dbContext; public ProductsController(AppDbContext dbContext) { _dbContext = dbContext; } public IActionResult Get() { return Ok(_dbContext.Products); } public IActionResult Get(int key) { return Ok(_dbContext.Products.Find(key)); } public async Task<IActionResult> Post([FromBody] Product product) { _dbContext.Products.Add(product); await _dbContext.SaveChangesAsync(); return Ok(product); } public async Task<IActionResult> Update(int key, [FromBody] Delta<Product> delta) { var product = await _dbContext.Products.FindAsync(key); delta.Patch(product); _dbContext.Products.Update(product); await _dbContext.SaveChangesAsync(); return Ok(product); } public async Task<IActionResult> Delete(int key) { var product = await _dbContext.Products.FindAsync(key); _dbContext.Products.Remove(product); await _dbContext.SaveChangesAsync(); return Ok(product); } } }
At this point you should be able to perform CRUD operations on the odata/Products
endpoint. The permission restrictions that are defined in the metadata do not automatically apply and all requests should still be authorized. To apply these permissions, we’ll need to configure the WebApi Authorization middleware. But before we can do that, we’ll need to set up authentication.
Setting up Authentication
While setting up authentication is required for the authorization system to work, WebApi Authorization does not depend on any specific authentication implementation or scheme. For this demo we are going to use a simple cookie-based authentication flow that will make it easy for us to test different scopes and scenarios.
Configuring Cookie authenticaton
Add the following statement in the ConfigureServices
method in Startup.cs
to add authentication to the app:
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie();
You may need the following using statements:
using Microsoft.AspNetCore.Authentication.Cookies;
Add the authentication middleware to in the Configure
method between app.UseRouting()
and app.UseEndpoints
(the order matters):
app.UseRouting(); app.UseAuthentication(); app.UseEndpoints(endpoints => /* ... */)
Authentication controller
Let’s create an authentication controller to handle our sign-in and sign-out flows.
To make things easy for the demo, we will not require username/password credentials. The login endpoint will allow the user to specify the scopes that they want via JSON and add those scopes as claims to the user principal. This will allow us to test how different scopes will be handled for different requests by the authorization middleware.
Our JSON body will look like:
{ "RequestedScopes": ["Product.Read", "Product.Insert"] }
Let’s create a model class to represent such a payload. In the Models
folder, create a class LoginData
with the following code:
namespace ODataAuthorizationDemo.Models { public class LoginData { public string[] RequestedScopes { get; set; } } }
Then, in the Controllers
folder, create the following AuthController
class:
using System.Linq; using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using ODataAuthorizationDemo.Models; namespace ODataAuthorizationDemo.Controllers { [Route("[controller]")] [ApiController] public class AuthController : ControllerBase { [HttpPost] [Route("login")] public async Task<IActionResult> Login([FromBody] LoginData data) { // create a claim for each requested scope var claims = data.RequestedScopes.Select(s => new Claim("Scope", s)); var claimsIdentity = new ClaimsIdentity( claims, CookieAuthenticationDefaults.AuthenticationScheme); var user = new ClaimsPrincipal(claimsIdentity); await HttpContext.SignInAsync( CookieAuthenticationDefaults.AuthenticationScheme, user); return Ok(); } [HttpPost] [Route("logout")] public async Task<IActionResult> Logout() { await HttpContext.SignOutAsync( CookieAuthenticationDefaults.AuthenticationScheme); return Ok(); } } }
POST /auth/login { "RequestedScopes": ["Product.Read", "Product.Delete"] }
This will create the auth cookie.
To log out, we make the following POST request without a body:
POST /auth/logout
Adding authorization
The authorization middleware will compare the scopes that the authenticated user has to the ones required for the current request based on the model’s restriction annotations. Since there are many ways in which the scopes could be stored, we need to tell the middleware how to extract the scopes from the current user.
Modify ConfigureServices
to match the following:
public void ConfigureServices(IServiceCollection services) { services.AddDbContext<AppDbContext>(opt => opt.UseInMemoryDatabase("ODataAuthDemo")); services.AddOData() .AddAuthorization(options => { options.ScopesFinder = context => { var userScopes = context.User.FindAll("Scope").Select(claim => claim.Value); return Task.FromResult(userScopes); }; options.ConfigureAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(); }); services.AddRouting(); }
In the above code, we add a call to .AddAuthorization()
to the odata builder in order to add the WebApi Authorization services. We configure it such that it will extract the scopes from the user claims that were added by the authentication system. We do this by providing a handler method to the options.ScopesFinder
property. We can also configure authentication directly using options.ConfigureAuthentication
.
We also need to add app.UseODataAuthorization()
after app.UseAuthentication()
in the Configure()
in order to add the middleware:
public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } app.UseRouting(); app.UseAuthentication(); app.UseODataAuthorization(); app.UseEndpoints(endpoints => { endpoints.Expand().Filter().Count().OrderBy(); endpoints.MapODataRoute("odata", "odata", AppEdmModel.GetModel()); }); }
Testing
Let’s start off by logging in without any scopes:
POST /auth/login { "RequestedScopes": [] }
If we attempt any of the CRUD requests to /odata/Products
, we should got a 403 Forbidden
response because we don’t have the required scopes to access these endpoints.
Now let’s logout:
POST /auth/logout
And request Product.Read
and Product.Create
scopes:
POST /auth/login { "RequestedScopes": ["Product.Read", "Product.Create"] }
Now we should be able to access GET /odata/Products
, GET /odata/Products({key})
and POST /odata/Products
.
We should get an error if we try to access DELETE /odata/Products({key})
or PATCH/PUT /odata/Products({key})
Note: Normally a 403 Forbidden
error response would be returned, but in this sample it might return a 404
error instead. This is because the cookie authentication handler attempts to redirect to a login page by default when authorization fails. And since this page does not exist in our sample application, a 404 Not found
error is returned.
Feedback
We are looking forward to getting your feedback on this library before a general version is released. The library is open-source, you can report issues and contribute pull-requests on the GitHub repository: https://github.com/OData/WebApiAuthorization
You can also find more information about the library from the official documentation.
Is Windows Authentication supported?