Creating Nextjs JWT token using LDAP for Authentication & Authorization & use it in Nestjs GraphQL backend

muratas

murat

Posted on December 26, 2021

Creating Nextjs JWT token using LDAP for Authentication & Authorization & use it in Nestjs GraphQL backend

Introduction

In our new project, we're creating a GraphQL backend using Nestjs which is a wonderful framework for Nodejs. Our frontend app is in Nextjs. Awesome Reactjs framework. The React Framework
for Production
as they say! Nextjs has built in api route feature. In which we can implement our authentication module where we create JWT. We use next-auth for authentication in Nextjs. It provides the same functionalities in server side and client side rendering.

Since we're in an enterprise environment, we need to use our own Active Directory. So, we're supposed to implement an LDAP authentication flow. Moreover, we want to use the same JWT both for authentication and authorization. Since we can add custom claims to our JWT, it's quite reasonable to use it for authorization as well.

Eventually, we'll be using graphql queries and mutations. So, we need to implement authorization in our graphql resolvers. Our resolvers are supposed to be able to authorize the JWT created by Nextjs. But, our backend (nestjs) uses passportjs for authentication! Can we really do it? Yess :-)

You can find both finished backend and frontend projects in Github.

Internal details of both projects are beyond the scope of this article. We solely focus on our major goal.

Running existing projects

My node & npm versions are 14.17.2 & 8.1.3 respectively.

GraphQL backend

After you clone backend project checkout 06-auth branch. Please clean your npm cache. I needed to run npm cache clean -f. You may need to do the same. Then npm install. You need to create a postgresql database. You can eighter use an existing one or ,if you have docker desktop installed, you can just use the existing docker compose file in the root directory of the project. Just run docker-compose up

Existing .env file contains database info;

DB_NAME=countrydb
DB_HOST=localhost
DB_PORT=5432
DB_USERNAME=postgres
DB_PASSWORD=postgres
Enter fullscreen mode Exit fullscreen mode

If your db is ready, you need to run the database migrations. Npm sripts for migrations are ready to use. Just run yarn migration:run If everything went alright, you're supposed to have a countrydb filled with sample data. Just run yarn start:dev. You must have a running graphql backend on http://localhost:3100/graphql

Nextjs frontend

After you cloned frontend project, just run npm install and yarn dev. When the project runs, navigate to http:\\localhost:3000. You must see login page;

login

Authentication in Nextjs

Using LDAP

Please check .env file in the root directory. It contains necessary LDAP info. If you'd like to do LDAP authentication, read on. Otherwise proceed to next section.

GRAPHQL_URL_CLIENT=http://localhost:3100/graphql
GRAPHQL_URL_SSR=http://localhost:3100/graphql
TOOLBAR_COLOR='red'
LDAP_HOST=<your-ldap-host>
LDAP_DN=<your-ldap-dn>
LDAP_PASSWORD=<your-ldap-password>
LDAP_BASE_DN=<your-base-dn>
TOKEN_MAX_AGE=3600
TOKEN_SECRET=topSecret71
Enter fullscreen mode Exit fullscreen mode

It's a common Nextjs pattern to use a catch-all api route for authentication;

pages/api/auth/[...nextauth].ts

import NextAuth from 'next-auth';
import Providers from 'next-auth/providers';
import ldap from 'ldapjs';
import jwt from 'jsonwebtoken';
import { JWT } from 'next-auth/jwt';

async function authenticate(username: string, password: string) {
  const client = ldap.createClient({
    url: process.env.LDAP_HOST as string,
  });

  const entries: ldap.SearchEntry[] = [];

  return new Promise((resolve, reject) => {
    client.bind(
      process.env.LDAP_DN as string,
      process.env.LDAP_PASSWORD as string,
      (error) => {
        if (error) {
          reject('LDAP bound failed');
        } else {
          const opts: ldap.SearchOptions = {
            filter: `(&(sAMAccountName=${username}))`,
            scope: 'sub',
            attributes: ['dn', 'sn', 'cn', 'sAMAccountName'],
          };

          client.search(
            process.env.LDAP_BASE_DN as string,
            opts,
            (err, res) => {
              if (err) {
                reject(`User ${username} LDAP search error`);
              } else {
                res.on('searchRequest', (searchRequest) => {
                  //console.log('searchRequest: ', searchRequest.messageID);
                });
                res.on('searchEntry', (entry) => {
                  entries.push(entry);

                  client.bind(entry.dn, password, (err, res) => {
                    if (err) {
                      reject(`User ${username} username or password problem`);
                    } else {
                      resolve({
                        username,
                        password,
                      });
                    }
                  });
                });
                res.on('searchReference', (referral) => {
                  //console.log('referral: ' + referral.uris.join());
                });
                res.on('error', (err) => {
                  reject('LDAP SEARCH error');
                });
                res.on('end', (result) => {
                  if (entries.length == 0) {
                    reject(`User ${username} username or password problem`);
                  }
                });
              }
            }
          );
        }
      }
    );
  });
}

export default NextAuth({
  secret: process.env.TOKEN_SECRET,

  jwt: {
    secret: process.env.TOKEN_SECRET,
    encode: async (data: any) => {
      const { secret, token, maxAge } = data;
      const jwtClaims = {
        username: token.username,
        rights: token.rights,
      };

      const encodedToken = jwt.sign(jwtClaims, secret, {
        expiresIn: '1h',
        algorithm: 'HS512',
      });
      return encodedToken;
    },
    async decode(data: any) {
      const { secret, token, maxAge } = data;
      const verify = jwt.verify(token, secret) as JWT;

      return verify;
    },
  },
  session: {
    jwt: true,
    maxAge: parseInt(process.env.TOKEN_MAX_AGE as string),
  },
  callbacks: {
    async jwt(token, user) {
      if (user?.username) {
        token.username = user.username;
        token.rights = ['removeCity'];
      }

      let expSeconds = token.exp as number;

      return token;
    },

    async session(session, token) {
      session.type = token.type;
      session.username = token.username;
      session.rights = token.rights;

      return session;
    },
  },
  providers: [
    Providers.Credentials({
      name: 'LDAP',
      async authorize(credentials: any, req) {
        const { username, password } = credentials;

        if (!username || !password) {
          throw new Error('enter username or password');
        }
        try {
          await authenticate(username, password);
          return {
            username,
          };
        } catch (error) {
          console.log(error);
          throw new Error(error as string);
        }
      },
    }),
  ],
});
Enter fullscreen mode Exit fullscreen mode

Basically, we're creating our NextAuth module here. In providers array, we declare that we're using Credentials. NextAuth has tons of providers, you can pick any. Here we'll be creating JWT using username & password. So, we use Credentials provider. We supply our own authorize method here. It encapsulates username & password handling and LDAP logic.

In session part, we declare to use JWT and setting maxAge of our token in seconds. We set it to 3600 seconds in .env file.

In jwt part, we set the secret. Secret is very important and should be kept somewhere safe. We'll be deploying to Kubernetes. So, kubernetes secrets is the place to keep it. Our GraphQL backend will be using the same secret to verify the token. We need to override the default encode & decode mechanisms of NextAuth. That's why we use jsonwebtoken. While encoding our token we should supply algorithm as below. The same algorithm is supposed to be used in GraphQL resolvers by Passportjs;

const encodedToken = jwt.sign(jwtClaims, secret, {
  expiresIn: '1h',
  algorithm: 'HS512',
});
Enter fullscreen mode Exit fullscreen mode

Callbacks part is important. jwt callback is called when a token is created or updated. We add our username and rights claims here. session callback is called when a session requested by the client. You're supposed to add the data to return to client. Not the sensitive ones of course!

authenticate method contains ldap query logic. What it does is to create an ldap client using process.env.LDAP_HOST and bind this client using process.env.LDAP_DN & process.env.LDAP_PASSWORD. If successfully bound, search on the client using process.env.LDAP_BASE_DN and custom filter options. If an entry found, searchEntry event raised. Then we bind the found entry again using found entry's dn and supplied password;

client.bind(entry.dn, password, (err, res) => {
  if (err) {
    reject(`User ${username} username or password problem`);
  } else {
    resolve({
      username,
    });
  }
});
Enter fullscreen mode Exit fullscreen mode

If successfull, then resolve.

If You Don't want to bother with LDAP, just craete a JWT and use it

If LDAP search doesn't interest you, you're welcome. Just replace authenticate method with the one below. No password check will be carried out, just a JWT will be created.

async function authenticate(username: string, password: string) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({
        username,
        password,
      });
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

Let's try to login with any dummy username & password. You're supposed to see below;

main

Congratulations! You can test viewing countries & cities and also add city and country. Also please test you can logout and login again.

What about our JWT

Please open your browser's developer tools. I'm using Chrome. In Application tab you can see next-auth.session-token cookie as below;

token

As you see, next-auth keeps the token in a HttpOnly cookie against cross side scripting. You can also see its expiration time. Let's copy the value of the cookie and navigate to https://jwt.io/ and past our token;

jwt.io1

You can see the header and the payload of our token. As you may notice, our token marked as 'InvalidSinature'. Because we did not supply a secret. Let's enter our secret, topSecret71 the one which we use when we create our token, and then paste the token again. It's verified now;

jwt.2

Let's Protect our removeCity & removeCountry Mutations in our GraphQL Backend

You can check our GraphQL schema in http://localhost:3100/graphql We have various city and country related queries and mutations. What needs to be protected here is only removeCity & removeCountry mutations. We don't want to impose any authorization on other mutations, only these two of them concern us.

So, we created an auth module in our Nestjs app. First, we add JwtStrategy;

src/auth/jwt.strategy.ts

import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: process.env.TOKEN_SECRET,
      algorithms: ['HS512'],
    });
  }

  async validate(payload: any): Promise<any> {
    return payload;
  }
}
Enter fullscreen mode Exit fullscreen mode

In our backend we'll be expecting to receive the token in request header as bearer token. We're using the same token & algorithm as we create the token on frontend.

src/auth/auth.module.ts

@Module({
  imports: [
    ConfigModule.forRoot(),
    PassportModule.register({ defaultStrategy: 'jwt' }),
    JwtModule.register({
      secret: process.env.TOKEN_SECRET,
      signOptions: {
        expiresIn: '1h',
        algorithm: 'HS512',
      },
    }),
  ],

  providers: [AuthService, JwtStrategy],
  exports: [JwtStrategy, PassportModule],
  controllers: [AuthController],
})
export class AuthModule {}
Enter fullscreen mode Exit fullscreen mode

Here we register PassportModule and export JwtStrategy, PassportModule

We use Nestjs guard machanism to check if the request has a valid token. Let's create jwt-auth guard;

src/auth/jwt-auth.guard.ts

import { ExecutionContext, Injectable } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  getRequest(context: ExecutionContext) {
    const ctx = GqlExecutionContext.create(context);
    const req = ctx.getContext().req;
    return req;
  }
}
Enter fullscreen mode Exit fullscreen mode

If jwt is valid, then we're creating a request from GraphQL execution context. Thank you NestJS :-) Eventually we use this guard in our resolvers to protect sensitive mutations;

src/country/country.resolvers.ts

.
.
.
 @Mutation(() => Int, { nullable: true })
  @UseGuards(JwtAuthGuard)
  removeCountry(
    @GetUser() user: any,
    @Args('id', { type: () => Int, nullable: false }) id: number,
  ) {
    return this.countryService.remove(id);
  }
  .
  .
  .
Enter fullscreen mode Exit fullscreen mode

Authorizing Routes, Checking Rights

Authentication is done. Now we need to check the authenticated JWT if it contains the necessary right claim to run our sensitive mutations. Let's create user decorator;

src/auth/get-user.decorator.ts

import {
  createParamDecorator,
  ExecutionContext,
  UnauthorizedException,
} from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';

export const GetUser = createParamDecorator(
  (data, context: ExecutionContext) => {
    const fieldName = context.getHandler().name;

    const ctx = GqlExecutionContext.create(context).getContext();
    const user = ctx.req.user;
    const rights = user.rights as string[];

    if (!rights.includes(fieldName)) {
      throw new UnauthorizedException(`You must have ${fieldName} right`);
    }

    return user;
  }
);
Enter fullscreen mode Exit fullscreen mode

In createParamDecorator we have ExecutionContext. Initially we obtain the actual handler's name. i.e., actual query or mutation name. We name it fieldName. After that, we created our execution context. Which contains the request object. This request object has user object that has the token info;

const ctx = GqlExecutionContext.create(context).getContext();
const user = ctx.req.user;
const rights = user.rights as string[];
Enter fullscreen mode Exit fullscreen mode

Now the last thing to do is to check if the rights array of the user object has a right with the same name ar our handler. If not, throw UnauthorizedException. Eventually we use the user decorator in relevant mutation declarations in the rosolvers;

.
.
.

  @Mutation(() => Int, { nullable: true })
  @UseGuards(JwtAuthGuard)
  removeCountry(
    @GetUser() user: any,
    @Args('id', { type: () => Int, nullable: false }) id: number,
  ) {
    return this.countryService.remove(id);
  }
  .
  .
  .

  @Mutation(() => Int, { nullable: true })
  @UseGuards(JwtAuthGuard)
  removeCity(
    @GetUser() user: any,
    @Args('id', { type: () => Int, nullable: false }) id: number,
  ) {
    return this.cityService.remove(id);
  }
  .
  .
  .
Enter fullscreen mode Exit fullscreen mode

We've set authorization check for removeCity & removeCountry mutations. Apart from having a valid JWT, a user is supposed to have necessary rights to execute them as well. Please recall that we'd supply a rights array in next-auth callback;

token.username = user.username;
token.rights = ['removeCity'];
Enter fullscreen mode Exit fullscreen mode

Here we manually add one single right just to demonstrate. Ideally, the relevant rights of the authenticated user should be collected from maybe a database.
With this configuration, we're supposed to be able to remove a city. However, when we try to remove a country, we're expecting to receive a 401, Unauthorized error. Let's give it a try. First, you can add one country;

add-country

And add a city;

add-city

Now, navigate to cities page. Cannes should be in the list. Delete it by clicking the delete icon. It should disappear from the list.
Now, navigate to countries page. Andorra should be in the list. Try to delete it. You're supposed to see below warning;

delete-country

Final words

If we're developing production ready apps, for both Nodejs & Reactjs environments, we absolutely need solid frameworks. Both Nextjs and Nestjs are awesome. You don't need to invent wheel. Just pick the best solution and use it!.

As a GraphQL developer, Nestjs makes you feel really at home!

In this article, our particular goal was to use the same JWT created by next-auth in both backend & frontend.

That was fun 😎

💖 💪 🙅 🚩
muratas
murat

Posted on December 26, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related