Introduction
On November 10th, 2020 Microsoft released .NET 5 and the updated ASP.NET Core platform which includes a long list of performance improvements.
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.
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:
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
}));
}
};
});
}
}
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();
});
}
}
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"
},
...
}
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)
};
}
}
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;
}
}
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"
}
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'
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"
}
]
}
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.
- The OpenID Configuration is read first: https://sandrino-dev.auth0.com/.well-known/openid-configuration
- 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 - 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;
}
}
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>();
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"
};
}
}
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
&...
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"
}
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:
This will restrict access to the scopes defined on the API to users who have the required Role or Permission assigned.
We can now create a Role Billing Admin in which we'll add the read:billing_settings
permission:
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