Validating Next.js NextAuth.js JWT tokens in ASP.NET Core

July 01, 2021

This post explains how to configure ASP.NET Core to be able to validate JWT tokens generated by NextAuth.js, which is a user authentication plugin for Next.js that allows logging in with different providers such as Google, Facebook, LinkedIn, etc.

We are working on a new project that has an ASP.NET Core backend that exposes a GraphQL Hot Chocolate endpoint, and a Next.js frontend. On the frontend we authenticate users with NextAuth.js and JWT tokens. Whenever the frontend - either server or client side - makes a request to the backend GraphQL API, we want the backend to also validate if the user is logged in.

Cookie Sharing

This article assumes you use some sort of CDN to expose both your frontend and backend under the same (sub)domain. If instead your frontend and backend are on different hostnames, you will have a lot of trouble getting the frontend Apollo client to read and share the authentication cookie with the backend.

Frontend

I will not go into much detail on configuring the NextAuth.js side, but here is how to set the sinigng key in the pages\api\auth\[...nextauth].ts configuration file:

export default NextAuth({
  ...
  jwt: {
    // How we generated signing key: npm install -g node-jose-tools
    // Then we ran this to generate the signing key: jose newkey -s 512 -t oct -a HS512

    signingKey: process.env.NEXTAUTH_JWT_SIGNING_SECRET,
  },
  ...
});

Also note that you need to configure your GraphQL client in the frontend to pass the JWT token to the backend on every request, either through a cookie, URL parameter, or HTTP header. See the AuthUtils.ExtractToken function below on how the backend extracts the token from the HTTP requests coming from the frontend.

Backend

NextAuth.js internally uses jose to sign and verify tokens. Therefore, we need to make ASP.NET Core be able to understand the tokens generated by jose. The main gotcha is that the token we generated with jose newkey is not Base64, but rather a flavor of Base64 that is URL safe. So using the normal base64 decoding in ASP.NET Core will not yield the correct key.

We resolved this issue by taking heavy inspiration from the Base64Url class in jose-jwt, which is a C# port of jose. Here is our variant:

namespace OurProject.Common.Utils
{
    using System;

    // Adapted from: https://github.com/dvsekhvalnov/jose-jwt/blob/c7189c912a7f29ec68faa4565ddcdda592b05afb/jose-jwt/util/Base64Url.cs
    // MIT License: https://github.com/dvsekhvalnov/jose-jwt/blob/c7189c912a7f29ec68faa4565ddcdda592b05afb/LICENSE
    public static class Base64Url
    {
        public static string Encode(byte[] input)
        {
            var output = Convert.ToBase64String(input);
            output = output.Split('=')[0]; // Remove any trailing '='s
            output = output.Replace('+', '-'); // 62nd char of encoding
            output = output.Replace('/', '_'); // 63rd char of encoding
            return output;
        }

        public static byte[] Decode(string input)
        {
            input.RequireNotNull(
                paramName: "input");

            var output = input;
            output = output.Replace('-', '+'); // 62nd char of encoding
            output = output.Replace('_', '/'); // 63rd char of encoding

            // Pad with trailing '='s
            switch (output.Length % 4) 
            {
                case 0: break; // No pad chars in this case
                case 2: output += "=="; break; // Two pad chars
                case 3: output += "="; break; // One pad char
                default: throw new ArgumentOutOfRangeException("input", "Illegal base64url string!");
            }

            var converted = Convert.FromBase64String(output); // Standard base64 decoder
            return converted;
        }
    }
}

Now we can use this function to configure Startup.cs in ASP.NET Core to validate the JWT tokens correctly:

public class Startup
{
    ...
    public void ConfigureServices(
        IServiceCollection services)
    {
        IdentityModelEventSource.ShowPII = true; //Keep this set to false unless you are debugging on devbox. This enables logging more detailed authentication information for debugging, but it can also leak keys.
        ...
        services
            .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
            .AddJwtBearer(options =>
            {
                // You need to set this to the same value as the one in ```[...nextauth].ts``` as shown above
                var signingKey = this.Configuration["JWTSigningKeyBase64UrlEncoded"];

                options.TokenValidationParameters = new TokenValidationParameters
                {
                    ValidateIssuerSigningKey = true,
                    RequireSignedTokens = true,

                    // Note here we use Base64Url.Decode
                    IssuerSigningKey = new SymmetricSecurityKey(Base64Url.Decode(signingKey)),

                    ValidateIssuer = false,

                    ValidateAudience = false,

                    // Token will only be valid if not expired yet, with 5 minutes clock skew.
                    ValidateLifetime = true,
                    RequireExpirationTime = true,
                    ClockSkew = new TimeSpan(0, 5, 0),
                };

                options.Events = new JwtBearerEvents
                {
                    // Get token from NextAuth cookie, token query parameter, or Authorization Bearer header
                    // See below
                    OnMessageReceived = AuthUtils.ExtractToken,
                };
            });
        ...
    }
}

You may note that we also set OnMessageReceived to AuthUtils.ExtractToken. This function extracts the incoming JWT token from the HTTP payload. Our implementation looks for the token in Next.js cookies, the token url parameter, or an Authorization header:

public static class AuthUtils
{
    public static Task ExtractToken(
        MessageReceivedContext rawContext)
    {
        var context = rawContext.RequireNotNull(
            paramName: "context");

        var cookieToken = 
            context.Request.Cookies["__Secure-next-auth.session-token"] 
            ?? context.Request.Cookies["next-auth.session-token"];

        string urlToken = context.Request.Query["token"]; //// Here we are using string instead of var on purpose - https://stackoverflow.com/questions/60191812/why-is-stringvalues-assignable-to-string

        string? headerToken = null;

        string authHeader = context.Request.Headers["Authorization"]; //// Here we are using string instead of var on purpose

        if (authHeader != null
            && authHeader.StartsWith("Bearer "))
        {
            headerToken = authHeader.Substring("Bearer ".Length).Trim();
        }

        context.Token = cookieToken ?? urlToken ?? headerToken ?? context.Token;
        return Task.CompletedTask;
    }
}

Finally, also note you can optionally attach roles or custom claims based on the information inside the token by implementing IClaimsTransformation and registering it in Startup.cs:

services.AddScoped<IClaimsTransformation, AddRolesClaimsTransformation>();

For example, here is how you can access the provider stored inside the JWT token in TransformAsync:

...
// WARNING: This function needs to be re-entrant, as in it needs to support receiving
// the same principal time multiple times
public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
    if (principal == null)
    {
        this.logger.LogDebug("Principal was null");
        return new ClaimsPrincipal();
    }

    if (principal.Identity == null)
    {
        this.logger.LogDebug("Principal identity was null");
        return principal;
    }

    // From documentation: Note: this will be run on each AuthenticateAsync call, so its safer
    // to return a new ClaimsPrincipal if your transformation is not idempotent.
    // https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.authentication.iclaimstransformation.transformasync?view=aspnetcore-5.0
    principal = principal.Clone();

    var claims = principal.Claims;

    if (claims == null)
    {
        this.logger.LogDebug("Principal claims was null");
        return principal;
    }

    var rawLoginType = claims.FirstOrDefault(c => c.Type == "provider")?.Value;
    var providerId = claims.FirstOrDefault(c => c.Type == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value;

    if (string.IsNullOrWhiteSpace(rawLoginType)
        || string.IsNullOrWhiteSpace(providerId))
    {
        this.logger.LogDebug("Login Type or provider ID were null or empty, so user is probably not logged in");
        return principal;
    }

    // NextAuth.js supports more providers, these are just some examples
    var loginType = rawLoginType switch
    {
        "azure-ad-b2c" => LoginType.Microsoft,
        "linkedin" => LoginType.LinkedIn,
        "facebook" => LoginType.Facebook,
        "google" => LoginType.Google,
        _ => LoginType.Unknown
    };

    if (loginType == LoginType.Unknown)
    {
        this.logger.LogError($"Unknown login type for raw type: {rawLoginType}");
        return principal;
    }
    ...
    // Later on do something based on the extracted data, maybe add a new role:
    Claim customRoleClaim = new Claim(claimsIdentity.RoleClaimType, "User");
    claimsIdentity.AddClaim(customRoleClaim);
}
...

Profile picture

Written by Ovidiu Dan (Ovi), who is a developer in Seattle. Check out my LinkedIn and GitHub profiles.

© Ovidiu Dan (Ovi) 2021