Skip to main content

2 posts tagged with "microsoft.identity.web"

View All Tags

Azure Easy Auth and Roles with .NET and Microsoft.Identity.Web

· 3 min read

I wrote recently about how to get Azure Easy Auth to work with roles. This involved borrowing the approach used by MaximeRouiller.Azure.AppService.EasyAuth.

As a consequence of writing that post I came to learn that official support for Azure Easy Auth had landed in October 2020 in v1.2 of Microsoft.Identity.Web. This was great news; I was delighted.

However, it turns out that the same authorization issue that MaximeRouiller.Azure.AppService.EasyAuth suffers from, is visited upon Microsoft.Identity.Web as well.

Getting set up#

We're using a .NET 5 project, running in an Azure App Service (Linux). In our .csproj we have:

<PackageReference Include="Microsoft.Identity.Web" Version="1.4.1" />

In our Startup.cs we're using:

public void ConfigureServices(IServiceCollection services) {    //...    services.AddMicrosoftIdentityWebAppAuthentication(Configuration);    //...}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env) {    //...    app.UseAuthentication();    app.UseAuthorization();    //...}

You gotta roles with it#

Whilst the authentication works, authorization does not. So whilst my app knows who I am - the authorization is not working with relation to roles.

When directly using Microsoft.Identity.Web when running locally, we see these claims:

[    // ...    {        "type": "http://schemas.microsoft.com/ws/2008/06/identity/claims/role",        "value": "Administrator"    },    {        "type": "http://schemas.microsoft.com/ws/2008/06/identity/claims/role",        "value": "Reader"    },    // ...
]

However, we get different behaviour with EasyAuth; it provides roles related claims with a different type:

[    // ...    {        "type": "roles",        "value": "Administrator"    },    {        "type": "roles",        "value": "Reader"    },    // ...]

This means that roles related authorization does not work with Easy Auth:

[Authorize(Roles = "Reader")][HttpGet("api/reader")]public string GetWithReader() =>    "this is a secure endpoint that users with the Reader role can access";

This is because .NET is looking for claims with a type of "http://schemas.microsoft.com/ws/2008/06/identity/claims/role" and not finding them with Easy Auth.

Claims transformation FTW#

There is a way to work around this issue .NET using IClaimsTransformation. This is a poorly documented feature, but fortunately Gunnar Peipman's blog does a grand job of explaining it.

Inside our Startup.cs I've registered a claims transformer:

services.AddScoped<IClaimsTransformation, AddRolesClaimsTransformation>();

And that claims transformer looks like this:

public class AddRolesClaimsTransformation : IClaimsTransformation {    private readonly ILogger<AddRolesClaimsTransformation> _logger;
    public AddRolesClaimsTransformation(ILogger<AddRolesClaimsTransformation> logger) {        _logger = logger;    }
    public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal) {        var mappedRolesClaims = principal.Claims            .Where(claim => claim.Type == "roles")            .Select(claim => new Claim(ClaimTypes.Role, claim.Value))            .ToList();
        // Clone current identity        var clone = principal.Clone();
        if (clone.Identity is not ClaimsIdentity newIdentity) return Task.FromResult(principal);
        // Add role claims to cloned identity        foreach (var mappedRoleClaim in mappedRolesClaims)             newIdentity.AddClaim(mappedRoleClaim);
        if (mappedRolesClaims.Count > 0)            _logger.LogInformation("Added roles claims {mappedRolesClaims}", mappedRolesClaims);        else            _logger.LogInformation("No roles claims added");
        return Task.FromResult(clone);    }}

The class above creates a new principal with "roles" claims mapped across to "http://schemas.microsoft.com/ws/2008/06/identity/claims/role". This is enough to get .NET treating roles the way you'd hope.

I've raised an issue against the Microsoft.Identity.Web repo about this. Perhaps one day this workaround will no longer be necessary.

Azure AD should 403

· 3 min read

By default Microsoft.Identity.Web responds to unauthorized requests with a 302 (redirect). Do you want a 403 (forbidden) instead? Here's how.

If you're using the tremendous Azure Active Directory for authentication with ASP.NET then there's a good chance you're using the Microsoft.Identity.Web library. It's this that allows us to drop the following statement into the ConfigureServices method of our Startup class:

services.AddMicrosoftIdentityWebAppAuthentication(Configuration);

Which (combined with configuration in our appsettings.json files) hooks us up with Azure AD for authentication. This is 95% awesome. The 5% is what we're here for. Here's a screenshot of the scenario that troubles us:

a screenshot of Chrome Devtools showing a 302

We've made a request to /WeatherForecast; a secured endpoint (a controller decorated with the Authorize attribute). We're authenticated; the app knows who we are. But we're not authorized / allowed to access this endpoint. We don't have permission. The HTTP specification caters directly for this scenario with status code 403 Forbidden:

The 403 (Forbidden) status code indicates that the server understood the request but refuses to authorize it.

However, Microsoft.Identity.Web is ploughing another furrow. Instead of returning 403, it's returning 302 Found and redirecting the browser to https://localhost:5001/Account/AccessDenied?ReturnUrl=%2FWeatherForecast. Now the intentions here are great. If you wanted to implement a page in your application at that endpoint that displayed some kind of useful message it would be really useful. However, what if you want the more HTTP-y behaviour instead? In the case of a HTTP request triggered by JavaScript (typical for Single Page Applications) then this redirect isn't that helpful. JavaScript doesn't really know what to do with the 302 and whilst you could code around this, it's not desirable.

We want 403 - we don't want 302.

Give us 403#

You can have this behaviour by dropping the following code after your services.AddMicrosoftIdentityWebAppAuthentication:

services.Configure<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme, options =>{    options.Events.OnRedirectToAccessDenied = new Func<RedirectContext<CookieAuthenticationOptions>, Task>(context =>    {        context.Response.StatusCode = StatusCodes.Status403Forbidden;        return context.Response.CompleteAsync();    });});

This code hijacks the redirect to AccessDenied and transforms it into a 403 instead. Tremendous! What does this look like?

a screenshot of Chrome Devtools showing a 403

This is the behaviour we want!

Extra customisation bonus points#

You may want to have some nuance to the way you handle unauthorized requests. Because of the nature of OnRedirectToAccessDenied this is entirely possible; you have complete access to the requests coming in which you can use to direct behaviour. To take a single example, let's say we want to direct normal browsing behaviour (AKA humans clicking about in Chrome) which is not authorized to a given screen, otherwise provide 403s. What would that look like?

services.Configure<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme, options =>{    options.Events.OnRedirectToAccessDenied = new Func<RedirectContext<CookieAuthenticationOptions>, Task>(context =>    {        var isRequestForHtml = context.Request.Headers["Accept"].ToString().Contains("text/html");        if (isRequestForHtml) {            context.Response.StatusCode = StatusCodes.Status302Found;            context.Response.Headers["Location"] = "/unauthorized";        }        else {            context.Response.StatusCode = StatusCodes.Status403Forbidden;        }
        return context.Response.CompleteAsync();    });});

So above, we check the request Accept headers and see if they contain "text/html"; which we're using as a signal that the request came from a users browsing. (This may not be bulletproof; better suggestions gratefully received.) If the request does contain a "text/html"``Accept header then we redirect the client to an /unauthorized screen, otherwise we return 403 as we did before. Super flexible and powerful!