How to validate AWS Cognito JWT in .NET Core Web API using .AddJwtBearer()

This has been easily the most difficult bit of code I've had to work with in the last year. "Authenticating JWT tokens from AWS Cognito in a .NET Web API app". AWS documentation still leaves much to be desired.

Here's what I used for a new .NET 6 Web API solution (so Startup.cs is now contained within Program.cs. Adjust to fit your version of .NET if needed. Main difference vs .NET 5 and earlier is the Services object is accessed via a variable called builder, so anytime you see code like services.SomeMethod..., you can likely replace it with builder.Services.SomeMethod... to make it .NET 6-compatible):

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidIssuer = "https://cognito-idp.{aws region here}.amazonaws.com/{Cognito UserPoolId}",
            ValidateIssuerSigningKey = true,
            ValidateIssuer = true,
            ValidateLifetime = true,
            ValidAudience = "{Cognito AppClientId here}",
            ValidateAudience = false
        };

        options.MetadataAddress = "https://cognito-idp.{aws region here}.amazonaws.com/{Cognito UserPoolId here}/.well-known/openid-configuration";
    });

Note that I have ValidateAudience set to false. I was getting 401 Unauthorized responses from the .NET app otherwise. Someone else on SO has said they had to do this to get OAuth's Authentication/Authentication Code grant type to work. Evidently ValidateAudience = true will work just fine for implicit grant. Why are you using implicit grant in 2022 though?

Also note that I am setting options.MetadataAddress. Per another SO user, this apparently allows for behind the scenes caching of the signing keys from AWS that they rotate from time to time.

I was led astray by some official AWS documentation (boo) that had me using builder.Services.AddCognitoIdentity(); (services.AddCognitoIdentity(); for .NET 5 and earlier). Apparently this is for "ASP.NET" apps where the backend serves up the frontend (e.g. Razor/Blazor). Or maybe it's deprecated, who knows. It is on AWS's website so it could very well be deprecated...

As for the Controllers, a simple [Authorize] attribute at the class level sufficed. No need to specify "Bearer" as the AuthenticationScheme in the [Authorize] attribute, or create middleware.

If you want to skip having to add another using to every controller as well as the [Authorize] attribute, and you want every endpoint in every controller to require a JWT, you can put this in Startup/Program.cs:

builder.Services.AddControllers(opt =>
{
    var policy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build();
    opt.Filters.Add(new AuthorizeFilter(policy));
});

Make sure that in Program.cs (Startup.cs for .NET 5 and earlier) app.UseAuthentication comes before app.UseAuthorization().

Here are the usings in Program.cs/Startup.cs:

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.Authorization;
using Microsoft.IdentityModel.Tokens;

The answer lies primarily in correctly defining the TokenValidationParameters.IssuerSigningKeyResolver (parameters, etc. seen here: https://docs.microsoft.com/en-us/dotnet/api/microsoft.identitymodel.tokens.issuersigningkeyresolver?view=azure-dotnet).

This is what tells .NET Core what to verify the JWT sent against. One must also tell it where to find the list of keys. One cannot necessarily hard-code the key set, as it is often rotated by AWS.

One way to do it would be to fetch and serialize the list from the URL inside the IssuerSigningKeyResolver method. The whole .AddJwtBearer() might look something like this:

Startup.cs ConfigureServices() method:

services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
                .AddJwtBearer(options =>
                {
                    options.TokenValidationParameters = new TokenValidationParameters
                    {
                        IssuerSigningKeyResolver = (s, securityToken, identifier, parameters) =>
                        {
                            // get JsonWebKeySet from AWS
                            var json = new WebClient().DownloadString(parameters.ValidIssuer + "/.well-known/jwks.json");
                            // serialize the result
                            var keys = JsonConvert.DeserializeObject<JsonWebKeySet>(json).Keys;
                            // cast the result to be the type expected by IssuerSigningKeyResolver
                            return (IEnumerable<SecurityKey>)keys;
                        },

                        ValidIssuer = "https://cognito-idp.{region}.amazonaws.com/{pool ID}",
                        ValidateIssuerSigningKey = true,
                        ValidateIssuer = true,
                        ValidateLifetime = true,
                        ValidAudience = "{Cognito AppClientID}",
                        ValidateAudience = true
                    };
                });

If you use a JS library such as AWS Amplify, you can see parameters such as the ValidIssuer and ValidAudience in your browser's console by observing the result of Auth.currentSession()

A REST fetch request from a JS client to a .NET Core Web API utilizing the JWT Authentication achieved above as well as using the [Authorize] tag on your controller might look something like this:

JS Client using @aws-amplify/auth node package:

// get the current logged in user's info
Auth.currentSession().then((user) => {
fetch('https://localhost:5001/api/values',
  {
    method: 'GET',
    headers: {
      // get the user's JWT token given to it by AWS cognito 
      'Authorization': `Bearer ${user.signInUserSession.accessToken.jwtToken}`,
      'Content-Type': 'application/json'
    }
  }
).then(response => response.json())
 .then(data => console.log(data))
 .catch(e => console.error(e))
})

The provided answer here is only required if you need more fine grained control over validation.

Otherwise the following code is sufficient to validate jwt.

services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
    options.Authority = "{yourAuthorizationServerAddress}";
    options.Audience = "{yourAudience}";
});

Okta have a good article on this. https://developer.okta.com/blog/2018/03/23/token-authentication-aspnetcore-complete-guide

When the JwtBearer middleware handles a request for the first time, it tries to retrieve some metadata from the authorization server (also called an authority or issuer). This metadata, or discovery document in OpenID Connect terminology, contains the public keys and other details needed to validate tokens. (Curious what the metadata looks like? Here’s an example discovery document.)

If the JwtBearer middleware finds this metadata document, it configures itself automatically. Pretty nifty!