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.
My node & npm versions are 14.17.2 & 8.1.3 respectively.
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
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
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;
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
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);
}
},
}),
],
});
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',
});
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,
});
}
});
If successfull, then resolve.
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,
});
});
});
}
Let's try to login with any dummy username & password. You're supposed to see below;
Congratulations! You can test viewing countries & cities and also add city and country. Also please test you can logout and login again.
Please open your browser's developer tools. I'm using Chrome. In Application tab you can see next-auth.session-token
cookie as below;
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;
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;
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;
}
}
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 {}
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;
}
}
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);
}
.
.
.
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;
}
);
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[];
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);
}
.
.
.
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'];
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;
And add a 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;
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 😎