DEV Community

loading...

JWT Bearer Authentication and Authorization for ASP.NET Core 5

Sandrino Di Mattia
Originally published at sandrino.dev on ・7 min read

Introduction

On November 10th, 2020 Microsoft released .NET 5 and the updated ASP.NET Core platform which includes a long list of performance improvements.

.NET 5 Platform

In this article we'll cover how you can configure JWT Bearer authentication and authorization for APIs built with ASP.NET Core 5. There are plenty of resources out which cover how to build your own "JWT authentication" with symmetric signing, but in this article we'll be focussing on leveraging OpenID Connect and OAuth 2 flows (using Auth0/Identity Server/Okta/...) where APIs are protected resources. Let's first take a look at how all pieces fit together from a high level. The APIs you build are typically called by applications on the user's behalf or on their own behalf.

Users interact with a SPA/Mobile App/Desktop App/Web Application/CLI/... and will be authenticating using OpenID Connect (Authorization Code Grant). The authorization server will issue an id_token (used by the application to authenticate the user) and an access_token which is used by the application to call the API on the users behalf.

User Authentication

When applications need to call an API on their own behalf they'll use the OAuth 2.0 Client Credentials Grant to acquire an access_token directly:

Client Authentication

Configuring JWT Bearer Authentication

We'll start by creating a helper method which will handler all of the JWT Bearer configuration, using the Microsoft.AspNetCore.Authentication.JwtBearer package.

using System.Text.Json;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;

public static class JwtBearerConfiguration
{
  public static AuthenticationBuilder AddJwtBearerConfiguration(this AuthenticationBuilder builder, string issuer, string audience)
  {
    return builder.AddJwtBearer(options =>
    {
      options.Authority = issuer;
      options.Audience = audience;
      options.TokenValidationParameters = new TokenValidationParameters()
      {
        ClockSkew = new System.TimeSpan(0, 0, 30)
      };
      options.Events = new JwtBearerEvents()
      {
        OnChallenge = context =>
        {
          context.HandleResponse();
          context.Response.StatusCode = StatusCodes.Status401Unauthorized;
          context.Response.ContentType = "application/json";

          // Ensure we always have an error and error description.
          if (string.IsNullOrEmpty(context.Error))
            context.Error = "invalid_token";
          if (string.IsNullOrEmpty(context.ErrorDescription))
            context.ErrorDescription = "This request requires a valid JWT access token to be provided";

          // Add some extra context for expired tokens.
          if (context.AuthenticateFailure != null && context.AuthenticateFailure.GetType() == typeof(SecurityTokenExpiredException))
          {
            var authenticationException = context.AuthenticateFailure as SecurityTokenExpiredException;
            context.Response.Headers.Add("x-token-expired", authenticationException.Expires.ToString("o"));
            context.ErrorDescription = $"The token expired on {authenticationException.Expires.ToString("o")}";
          }

          return context.Response.WriteAsync(JsonSerializer.Serialize(new
          {
            error = context.Error,
            error_description = context.ErrorDescription
          }));
        }
      };
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

In the code above we're configuring the AddJwtBearer method with the following:

  • Authority: The issuer (eg: https://sandrino.auth0.com/)
  • Audience: Typically the identifier of your API which has been registered at the authorization server (eg: http://my-api)
  • ClockSkew: This is set to 5 minutes by default, but 30 seconds should be fine in most cases. This is to handle any differences in time between the authorization server and the API.

We're also modifiying the response of any JWT validation error to return a JSON object instead of the standard WWW-Authenticate challenge. This is optional and provides your clients with more context which can be useful to handle the error.

The helper can now be used to register an authentication service in the Startup class:

public class Startup
{
  private readonly IConfiguration _configuration;

  public Startup(IConfiguration configuration)
  {
    _configuration = configuration;
  }

  public void ConfigureServices(IServiceCollection services)
  {
    services.AddControllers();

    // Configure JWT authentication.
    services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
      .AddJwtBearerConfiguration(
        _configuration["Jwt:Issuer"],
        _configuration["Jwt:Audience"]
      );
  }

  public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
  {
    app.UseRouting();
    app.UseAuthentication();
    app.UseAuthorization();
    app.UseEndpoints(endpoints =>
    {
      endpoints.MapControllers();
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

The Jwt.Issuer and Jwt.Audience settings will be read the appsettings.json configuration file:

{
  "Jwt": {
    "Issuer": "https://sandrino-dev.auth0.com/",
    "Audience": "urn:my-api"
  },
  ...
}
Enter fullscreen mode Exit fullscreen mode

And that's it, we can now start creating the necessary APIs and secure them.

Creating a protected API

Let's start by creating a simple API which returns the claims for the current identity. In the Get action we'll use the [Authorize] attribute which requires the HTTP request to be authenticated.

public class UserInfo
{
  [JsonPropertyName("id")]
  public string Id { get; set; }

  [JsonPropertyName("claims")]
  public Dictionary<string, string> Claims { get; set; }
}

[ApiController]
[Route("/api/claims")]
public class UserController : ControllerBase
{
  [HttpGet]
  [Authorize]
  public UserInfo Get()
  {
    return new UserInfo()
    {
      Id = this.User.GetId(),
      Claims = this.User.Claims.ToDictionary(claim => claim.Type, claim => claim.Value)
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

In order to get the user's identifier using GetId() we can write a small helper class:

using System.Security.Claims;

public static class UserHelpers
{
  public static string GetId(this ClaimsPrincipal principal)
  {
    var userIdClaim = principal.FindFirst(c => c.Type == ClaimTypes.NameIdentifier) ?? principal.FindFirst(c => c.Type == "sub");
    if (userIdClaim != null && !string.IsNullOrEmpty(userIdClaim.Value))
    {
      return userIdClaim.Value;
    }

    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

Let's go ahead and start our API. If we call this endpoint without providing a valid access_token in the Authorization header this will result in the following error:

{
  "error": "invalid_token",
  "error_description": "This request requires a valid JWT access token to be provided"
}
Enter fullscreen mode Exit fullscreen mode

We can now try that same request with a valid token in the Authorization header:

curl \
  --request GET 'http://localhost:5001/api/claims' \
  --header 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InA0UVUtODVUY09GeG03c05JMWlaYyJ9.eyJpc3MiOiJodHRwczovL3NhbmRyaW5vLWRldi5hdXRoMC5jb20vIiwic3ViIjoiYXV0aDB8NTk3YTA2NTExM2Y0MGIwODQ4NWVlN2JkIiwiYXVkIjpbInVybjpteS1hcGkiLCJodHRwczovL3NhbmRyaW5vLWRldi5hdXRoMC5jb20vdXNlcmluZm8iXSwiaWF0IjoxNjA4Mjg2OTMxLCJleHAiOjE2MDgyOTY5MzEsImF6cCI6IllRd0Q0YTBBMTFreURJQzJPcVBLNnVDR3FHNEQ3cnVJIiwic2NvcGUiOiJvcGVuaWQgcHJvZmlsZSBvZmZsaW5lX2FjY2VzcyIsImd0eSI6InBhc3N3b3JkIn0.l9dOVOXvnFhmMbUAelGiQJTwlCpgXqE6nbrdbTJhg1shxhMiGSuMg3YN3eFLD3-TfU8T5nHNttjgHdlIus-oQuJspYg4Mqu6NTIE0PxGnQQDYqADnXzpLV4OdFc2k1YuZwCpE8dJDJ0lzvXTsio3DKvWq_Vq3gL7qAWtF5EefKbsfTOaLhVPZ8YIcY8C0VSReJnC2M8da0KAdP0SqYJB_BIZYeQiPg668MrGFWsKuQv1h4C9DU3o9Ol0S1nHZ6r8KiiMSQRJyFV7v82VQ3dZWjrj5YWGGR4Uk1Wuf3iochLxRz64MQp-iV_fuE1DECLjKTt6Bj-nLR2PZFDTHAheCA'
Enter fullscreen mode Exit fullscreen mode

And this will then return the user's ID and the claims as expected:

{
  "id": "auth0|597a065113f40b08485ee7bd",
  "claims": [
    {
      "name": "iss",
      "value": "https://sandrino-dev.auth0.com/"
    },
    {
      "name": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier",
      "value": "auth0|597a065113f40b08485ee7bd"
    },
    {
      "name": "aud",
      "value": "urn:my-api"
    },
    {
      "name": "aud",
      "value": "https://sandrino-dev.auth0.com/userinfo"
    },
    {
      "name": "iat",
      "value": "1608286931"
    },
    {
      "name": "exp",
      "value": "1608296931"
    },
    {
      "name": "azp",
      "value": "YQwD4a0A11kyDIC2OqPK6uCGqG4D7ruI"
    },
    {
      "name": "scope",
      "value": "openid profile offline_access"
    },
    {
      "name": "gty",
      "value": "password"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Note that you can easily test the above using Auth0 and Insomnia.

What just happened?

You might be wondering what just happened?! How was the API able to validate the JWT Bearer token without having to configure a secret or a public key? This is because the authentication service will use the OIDC metadata endpoints to get all of the necessary information.

  1. The OpenID Configuration is read first: https://sandrino-dev.auth0.com/.well-known/openid-configuration
  2. From there it will find the url to the jwks_uri and then load that one: https://sandrino-dev.auth0.com/.well-known/jwks.json
  3. The public key(s) are loaded from that document and used to verify the incoming JWT Bearer tokens

Creating an Authorization Policy

The above is a good step to create a secure API, but it might not be granular enough. Not everyone might have access to all operations that are exposed in your API. This is where you'll want to create an Authorization Policy in which you'll be able to restrict access to certain operations.

In our example we'll create an endpoint to query the Billing Settings which is only available to users who have the read:billing_settings scope. In your Authorization Server you'll typically configure that only users that are member of a certain group, only users with a specific role or permission ... can receive this scope. But once the application can request that scope on the user's behalf it will be available in the access_token and the call to this endpoint will succeed.

The authorization handler implements our business requirement. It will extract the scope claim from the current principal and will then validate if the configured claim (eg: read:billing_settings) is available. If it is, then the request is allowed to continue.

public class ScopeRequirement : IAuthorizationRequirement
{
  public string Issuer { get; }

  public string Scope { get; }

  public ScopeRequirement(string issuer, string scope)
  {
    Issuer = issuer ?? throw new ArgumentNullException(nameof(issuer));
    Scope = scope ?? throw new ArgumentNullException(nameof(scope));
  }
}

public class RequireScopeHandler : AuthorizationHandler<ScopeRequirement>
{
  protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, ScopeRequirement requirement)
  {
    // The scope must have originated from our issuer.
    var scopeClaim = context.User.FindFirst(c => c.Type == "scope" && c.Issuer == requirement.Issuer);
    if (scopeClaim == null || String.IsNullOrEmpty(scopeClaim.Value))
      return Task.CompletedTask;

    // A token can contain multiple scopes and we need at least one exact match.
    if (scopeClaim.Value.Split(' ').Any(s => s == requirement.Scope))
      context.Succeed(requirement);
    return Task.CompletedTask;
  }
}
Enter fullscreen mode Exit fullscreen mode

We should then register this policy for every scope our API supports and also register the handler:

// Create an authorization policy for each scope supported by my API.
services.AddAuthorization(options =>
{
  var scopes = new[] {
    "read:billing_settings",
    "update:billing_settings",
    "read:customers",
    "read:files"
  };

  Array.ForEach(scopes, scope =>
    options.AddPolicy(scope,
      policy => policy.Requirements.Add(
        new ScopeRequirement(_configuration["Jwt:Issuer"], scope)
      )
    )
  );
});

// Register our authorization handler.
services.AddSingleton<IAuthorizationHandler, RequireScopeHandler>();
Enter fullscreen mode Exit fullscreen mode

For each scope we register a policy with the name of that scope, allowing us to use [Authorize("read:billing_settings")] later in our code.

As a final step we can now create a BillingController in which we'll expose the necessary functionality for a user to manage their billing settings. The /api/billing/settings endpoint requires the presence of the read:billing_settings scope:

[ApiController]
[Route("/api/billing")]
public class BillingController : ControllerBase
{
  [HttpGet]
  [Route("settings")]
  [Authorize("read:billing_settings")]
  public BillingSettings Get()
  {
    return new BillingSettings()
    {
      Country = "United States",
      State = "Washington",
      Street = "Microsoft Road 1",
      VATNumber = "987654321"
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

When interacting with my authorization server I'll want to make sure to request this scope:

https://sandrino-dev.auth0.com/authorize
  ?client_id=5CMfGLsLworduMTOfVD0Kap2IQm4xpLH
  &scope=openid profile read:billing_settings
  &redirect_uri=https://jwt.io
  &response_type=code
  &audience=urn:my-api
  &...
Enter fullscreen mode Exit fullscreen mode

And then the resulting access_token which now includes the read:billing_settings scope can be used to call the endpoint:

GET /api/billing/settings
Host: localhost:5001
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InA0UVUtODVUY09GeG03c05JMWlaYyJ9.eyJpc3MiOiJodHRwczovL3NhbmRyaW5vLWRldi5hdXRoMC5jb20vIiwic3ViIjoiYXV0aDB8NTk3YTA2NTExM2Y0MGIwODQ4NWVlN2JkIiwiYXVkIjpbInVybjpteS1hcGkiLCJodHRwczovL3NhbmRyaW5vLWRldi5hdXRoMC5jb20vdXNlcmluZm8iXSwiaWF0IjoxNjA4Mjg3MzY4LCJleHAiOjE2MDgyOTczNjgsImF6cCI6IllRd0Q0YTBBMTFreURJQzJPcVBLNnVDR3FHNEQ3cnVJIiwic2NvcGUiOiJvcGVuaWQgcHJvZmlsZSByZWFkOmJpbGxpbmdfc2V0dGluZ3Mgb2ZmbGluZV9hY2Nlc3MiLCJndHkiOiJwYXNzd29yZCJ9.CMvAa4gVO5dFnXBqWK8UIq5mB--3JacVv9MwocnWFTSR5p938zhw5hMREqUGCesIWy5UUZeb7ka7Dhp4Mf3tK-8h3psfsPMMpI3OP4q8IglKplt1KaXe5rn8Fmm2daNDnxmMccXusLI7T_Ea3hfVrjrfURprNfXW9vCS17Xj6mRHF9RBHNkeg8CKyotatPQojY_uex2L3qBhJhGXBd8CHvnnbEVZMYVlc_D02tqMu4bvs9QCml8y3qQkyvBHOAEJcE7b84trIJK2vIh7B339l-ukeSyK1AEkf5hHAlUjGRuB1dhtfodWLexEd5rH-Tn55xwdvL2CyQI-J2JVIQS0Kw

{
  "country": "United States",
  "state": "Washington",
  "street": "Microsoft Road 1",
  "vat_number": "987654321"
}
Enter fullscreen mode Exit fullscreen mode

Calling this endpoint without the required read:billing_settings scope will result in a 403 Forbidden.

Auth0 Role Based Access Control

If you're using Auth0 as your authorization server you can configure the "RBAC authorization policies" for your APIs:

RBAC

This will restrict access to the scopes defined on the API to users who have the required Role or Permission assigned.

Scopes

We can now create a Role Billing Admin in which we'll add the read:billing_settings permission:

Scopes

And as a final step we can assign the role to our users, allowing applications to request the read:billing_settings scope for them.

✅ Success!

With all of the above you should be all setup to configure JWT Bearer authentication and authorization in your own APIs.

A full demo application is available on GitHub: https://github.com/sandrinodimattia/aspnet-core-5-jwt-bearer-demo

Discussion (0)