DEV Community

Jay McDoniel for NestJS

Posted on • Edited on

Setting Up Sessions with NestJS, Passport, and Redis

Jay is a member of the NestJS core team, primarily helping out the community on Discord and Github and contributing to various parts of the framework.

If you're here, you're either one of the avid readers of my, just stumbling about dev.to looking for something interesting to read, or you're searching for how to implement sessions with Passport and NestJS. Nest's own docs do a pretty good job of showing how to set up the use of JWTs with Passport, but are lacking when it comes to how to use sessions. Maybe you want to use a session store because of supporting some legacy software. Maybe it's because JWTs bring too much complexity with scope. Maybe it's because you're looking for an easier way to set up refresh tokens. Whatever the case, this article is going to be for you.

Pre-requisites

I'm going to be using NestJS (it's in the title, so I hope that's obvious) and I'm going to be making use of Guards so if you don't know what those are, I highly suggest reading up on them first. Don't worry, I'll wait.

I'm also going to be not using an HTTP client like Postman or Insomnia, but using cURL instead. I lke living in the terminal as much as I can, as it gives me immediate feedback between my terminals. Feel free to use whichever you prefer, but the code snippets will be curls.

And speaking of immediate feedback, I'm also going to be using tmux, which is a terminal multiplexer, to allow me to run multiple terminals at a time within the same window and logical grouping. This allows me to keep a single terminal window up and view my server logs, docker-compose instance and/or logs, and make curls without having to alt-tab to change views. Very handy, and very customizable.

Lastly, I'll be using docker and a docker-compose file to run a Redis instance for the session storage and to allow for running a redis-cli to be able to query the redis instance ran by Docker.

All of the code will be available to follow along with and run here. Just note that to run it after you clone and run the install for the repo, you'll need to cd blog-posts/nestjs-passport-sessions and then run nest start --watch yourself. Just a side effect of how the repo is set up for my dev.to blogs.

Following along from scratch

If you're following along with the code that's pre-built, feel free to skip over this.

To set up a similar project from scratch, you'll need to first set up a Nest project, which is easiest through the Nest CLI

nest new session-authentication
Enter fullscreen mode Exit fullscreen mode

Choose your package manager of choice, and then install the follow dependencies

pnpm i @nestjs/passport passport passport-local express-session redis connect-redis bcrypt
Enter fullscreen mode Exit fullscreen mode

And the following peer dependencies

pnpm i -D @types/passport-local @types/express-session @types/connect-redis @types/bcrypt @types/redis
Enter fullscreen mode Exit fullscreen mode

npm and yarn work fine as well, I just like pnpm as a package manager

Now you should be okay to follow along with the rest of the code, building as we go.

NestJS and Passport

The AuthGuard()

Like most @nestjs/ packages, the @nestjs/passport package is mostly a thin wrapper around passport, but Nest does do some cool things with the passport package that I think are worth mentioning. First, the AuthGuard mixin. At first glance, this mixin may look a little intimidating, but let's take it chunk by chunk.

export const AuthGuard: (type?: string | string[]) => Type<IAuthGuard> = memoize(createAuthGuard);
Enter fullscreen mode Exit fullscreen mode

Ignoring the memoize call, this createAuthGuard is where the magic of class creation happens. We end up passing the type, if applicable, to the createAuthGuard method and will eventually pass that back to the @UseGuards(). Everything from here on, unless mentioned otherwise, will be a part of the createAuthGuard method.

class MixinAuthGuard<TUser = any> implements CanActivate {
  constructor(@Optional() protected readonly options?: AuthModuleOptions) {
    this.options = this.options || {};
    if (!type && !this.options.defaultStrategy) {
      new Logger('AuthGuard').error(NO_STRATEGY_ERROR);
    }
  }
...
Enter fullscreen mode Exit fullscreen mode

The constructor allows for an optional injection of AuthModuleOptions. This is what is passed to PassportModule.register(). This just allows Nest to figure out if the defaultStrategy is used or the named one passed to AuthGuard.

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const options = {
      ...defaultOptions,
      ...this.options,
      ...await this.getAuthenticateOptions(context)
    };
    const [request, response] = [
      this.getRequest(context),
      this.getResponse(context)
    ];
    const passportFn = createPassportContext(request, response);
    const user = await passportFn(
      type || this.options.defaultStrategy,
      options,
      (err, user, info, status) =>
        this.handleRequest(err, user, info, context, status)
    );
    request[options.property || defaultOptions.property] = user;
    return true;
  }
Enter fullscreen mode Exit fullscreen mode

This here reads through decently well, we have custom methods for getting the authentication options (defaults to returning undefined), getting the request and response objects (defaults to context.switchToHttp().getRequest()/getResponse()), and then this createPassportContext method that is called and then it's return is immediately called with the strategy name and options. Then, we set req.user to the return of passportFn and return true to let the request continue. The next code block is not a part of the mixin or MixinAuthGuard class.

const createPassportContext = (request, response) => (type, options, callback: Function) =>
  new Promise<void>((resolve, reject) =>
    passport.authenticate(type, options, (err, user, info, status) => {
      try {
        request.authInfo = info;
        return resolve(callback(err, user, info, status));
      } catch (err) {
        reject(err);
      }
    })(request, response, err => (err ? reject(err) : resolve())),
  );
Enter fullscreen mode Exit fullscreen mode

Here's where some magic may be seen to happen: Nest ends up calling passport.authenticate for us, so that we don't have to call it ourselves. In doing so, it wraps passport in a promise, so that we can manage the callback properly, and provides it's own handler to the authenticate function. This entire method is actually creating a different callback function so that we can end up calling this.handleRequest with the err, user, info, and status returned by passport. This can take a bit of time to understand, and isn't necessarily needed, but it's usually good to know what some of the code under the hood is doing.

  handleRequest(err, user, info, context, status): TUser {
    if (err || !user) {
      throw err || new UnauthorizedException();
    }
    return user;
  }
Enter fullscreen mode Exit fullscreen mode

This is pretty straightforward, but it's useful to know this method is here. As mentioned in Nest's docs if you need to do any debugging about why the request is failing, here is a good place to do it. Generally just adding the line console.log({ err, user, info, context, status }) is enough, and will help you figure out pretty much anything going wrong within the passport part of the request.

There's two other classes I want to talk about before getting to the implementation, but I promise it'll be worth it!

The PassportStrategy()

So the next mixin we have to look at is the PassportStrategy mixin. This is how we end up actually registering our strategy class's validate method to passport's verify callback. This mixin does a little bit more in terms of some advance JS techniques, so again, lets take this chunk by chunk.

export function PassportStrategy<T extends Type<any> = any>(
  Strategy: T,
  name?: string | undefined
): {
  new (...args): InstanceType<T>;
} {
  abstract class MixinStrategy extends Strategy {
Enter fullscreen mode Exit fullscreen mode

This part is pretty straightforward, we're just passing the passport strategy class and an optional renaming of the strategy to the mixin.

constructor(...args: any[]) {
  const callback = async (...params: any[]) => {
    const done = params[params.length - 1];
    try {
      const validateResult = await this.validate(...params);
      if (Array.isArray(validateResult)) {
        done(null, ...validateResult);
      } else {
        done(null, validateResult);
      }
    } catch (err) {
      done(err, null);
    }
  };
Enter fullscreen mode Exit fullscreen mode

This is the first half of the constructor. You'll probably notice right of the bat that we don;'t call super, at least not yet. This is because we're setting up the callback to be passed to passport later. So what's happening here is we're setting up a function that's going to be calling this.validate and getting the result from it. If that result happens to be an array, we spread the array (passport will use the first value), otherwise we'll end up calling the done callback with just the result. If there happens to be an error, in good ole callback style, it'll be passed as the first value to the done method.

  super(...args, callback);
  const passportInstance = this.getPassportInstance();
  if (name) {
    passportInstance.use(name, this as any);
  } else {
    passportInstance.use(this as any);
  }
}
Enter fullscreen mode Exit fullscreen mode

Now we end up calling super, and in doing so, we overwrite the original verify with the new callback we just created. This sets up the entire passport Strategy class that we're going to use for the strategy's name. Now all that's left to do is tell passport about it, by calling passportInstance.use(this) (or passing the custom name as the first argument).

If any of that went a little too deep, don't worry. It's something you can come back to if you really want, but isn't necessary for the rest of ths article.

PassportSerializer

Finally, an actual class! This is the most straightforward and the last bit of passport I'll talk about before getting into the implementation of sessions. This class usually won't be used in Nest applications _unless you are using sessions, and we're about to see why.

So passport has the notion of serializing and deserializing a user. Serializing a user is just taking the user's information and compressing it/ making it as minimal as possible. In many cases, this is just using the ID of the user. Deserializing a user is the opposite, taking an ID and hydrating an entire user out of it. This usually means a call to a database, but it's not necessary if you don't want to worry about it. Now, Nest has a PassportSerializer class like so:

export abstract class PassportSerializer {
  abstract serializeUser(user: any, done: Function);
  abstract deserializeUser(payload: any, done: Function);

  constructor() {
    const passportInstance = this.getPassportInstance();
    passportInstance.serializeUser((user, done) => this.serializeUser(user, done));
    passportInstance.deserializeUser((payload, done) => this.deserializeUser(payload, done));
  }

  getPassportInstance() {
    return passport;
  }
}
Enter fullscreen mode Exit fullscreen mode

You should only ever have one class extending the PassportSerializer, and what it should do is set up the general serialization and deserialization of the user for the session storage. The user passed to serializeUser is usually the same value as req.user, and the payload passed to deserializeUser is the value passed as the second parameter to the done of serializeUser. This will make a bit more sens when it is seen in code.

Break Time

Okay, that was a lot of information about NestJS and Passport all at once, and some pretty complex code to go through. Take a break here if you need to. Get some coffee, stretch your legs, go play that mobile game you've been wanting to. Whatever you want to do, or continue on with the article if you want.

Running Redis Locally

You can either install and run redis locally on your machine, or you can use a docker-compose.yml file to run redis inside a container. The following compose fle is what I used while working on this article

# docker-compose.yml

version: '3'
services:
  redis:
    image: redis:latest
    ports:
      - '6379:6379'
  rcli:
    image: redis:latest
    links:
      - redis
    command: redis-cli -h redis

Enter fullscreen mode Exit fullscreen mode

And then to run redis, I just used docker compose up redis -d. When I needed to run the redis CLI, I used docker compose run rcli to connect to the redis instance via the docker network.

Setting Up the Middleware

Now on to the middleware we're going to be using: for setting up sessions and a way to store them, I'm going to be using express-session, and connect-redis for the session and session store, and redis as the redis client for connect-redis. I'm also going to be setting up our middleware via a Nest middleware instead of using app.use in the bootstrap so that when we do e2e testing, the middleware is already set up (that's out of the scope of this article). I've also got redis set up as a custom provider using the following code

// src/redis/redis.module.ts

import { Module } from '@nestjs/common';
import * as Redis from 'redis';

import { REDIS } from './redis.constants';

@Module({
  providers: [
    {
      provide: REDIS,
      useValue: Redis.createClient({ port: 6379, host: 'localhost' }),
    },
  ],
  exports: [REDIS],
})
export class RedisModule {}

Enter fullscreen mode Exit fullscreen mode
// src/redis/redis.constants.ts

export const REDIS = Symbol('AUTH:REDIS');

Enter fullscreen mode Exit fullscreen mode

which allows for us to use @Inject(REDIS) to inject the redis client. Now we can configure our middleware like so:

// src/app.module.ts

import { Inject, Logger, MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import * as RedisStore from 'connect-redis';
import * as session from 'express-session';
import * as passport from 'passport';
import { RedisClient } from 'redis';

import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AuthModule } from './auth';
import { REDIS, RedisModule } from './redis';

@Module({
  imports: [AuthModule, RedisModule],
  providers: [AppService, Logger],
  controllers: [AppController],
})
export class AppModule implements NestModule {
  constructor(@Inject(REDIS) private readonly redis: RedisClient) {}
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(
        session({
          store: new (RedisStore(session))({ client: this.redis, logErrors: true }),
          saveUninitialized: false,
          secret: 'sup3rs3cr3t',
          resave: false,
          cookie: {
            sameSite: true,
            httpOnly: false,
            maxAge: 60000,
          },
        }),
        passport.initialize(),
        passport.session(),
      )
      .forRoutes('*');
  }
}

Enter fullscreen mode Exit fullscreen mode

and have passport ready to use sessions. There's two important things to note here:

  1. passport.initialize() must be called before passport.session().
  2. session() must be called before passport.initialize()

With this now out of the way, let's move on to our auth module.

The AuthModule

To start off, let's define our User as the following

// src/auth/models/user.interface.ts

export interface User {
  id: number;
  firstName: string;
  lastName: string;
  email: string;
  password: string;
  role: string;
}

Enter fullscreen mode Exit fullscreen mode

And then have RegisterUserDto and LoginUserDto as

// src/auth/models/register-user.dto.ts

export class RegisterUserDto {
  firstName: string;
  lastName: string;
  email: string;
  password: string;
  confirmationPassword: string;
  role = 'user';
}

Enter fullscreen mode Exit fullscreen mode

and

// src/auth/models/login-user.dto.ts

export class LoginUserDto {
  email: string;
  password: string;
}

Enter fullscreen mode Exit fullscreen mode

Now we'll set up our LocalStrategy as

// src/auth/local.strategy.ts

import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { AuthService } from './auth.service';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly authService: AuthService) {
    super({
      usernameField: 'email',
    });
  }

  async validate(email: string, password: string) {
    return this.authService.validateUser({ email, password });
  }
}

Enter fullscreen mode Exit fullscreen mode

Notice here we're passing usernameField: 'email' to super. This is because in our RegisterUserDto and LoginUserDto we're using the email field and not username which is passport's default. You can change the passwordField too, but I had no reason to do that for this article. Now we'll make our AuthService,

// src/auth/auth.service.ts

import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
import { compare, hash } from 'bcrypt';

import { LoginUserDto, RegisterUserDto } from './models';
import { User } from './models/user.interface';

@Injectable()
export class AuthService {
  private users: User[] = [
    {
      id: 1,
      firstName: 'Joe',
      lastName: 'Foo',
      email: 'joefoo@test.com',
      // Passw0rd!
      password: '$2b$12$s50omJrK/N3yCM6ynZYmNeen9WERDIVTncywePc75.Ul8.9PUk0LK',
      role: 'admin',
    },
    {
      id: 2,
      firstName: 'Jen',
      lastName: 'Bar',
      email: 'jenbar@test.com',
      // P4ssword!
      password: '$2b$12$FHUV7sHexgNoBbP8HsD4Su/CeiWbuX/JCo8l2nlY1yCo2LcR3SjmC',
      role: 'user',
    },
  ];

  async validateUser(user: LoginUserDto) {
    const foundUser = this.users.find(u => u.email === user.email);
    if (!user || !(await compare(user.password, foundUser.password))) {
      throw new UnauthorizedException('Incorrect username or password');
    }
    const { password: _password, ...retUser } = foundUser;
    return retUser;
  }

  async registerUser(user: RegisterUserDto): Promise<Omit<User, 'password'>> {
    const existingUser = this.users.find(u => u.email === user.email);
    if (existingUser) {
      throw new BadRequestException('User remail must be unique');
    }
    if (user.password !== user.confirmationPassword) {
      throw new BadRequestException('Password and Confirmation Password must match');
    }
    const { confirmationPassword: _, ...newUser } = user;
    this.users.push({
      ...newUser,
      password: await hash(user.password, 12),
      id: this.users.length + 1,
    });
    return {
      id: this.users.length,
      firstName: user.firstName,
      lastName: user.lastName,
      email: user.email,
      role: user.role,
    };
  }

  findById(id: number): Omit<User, 'password'> {
    const { password: _, ...user } = this.users.find(u => u.id === id);
    if (!user) {
      throw new BadRequestException(`No user found with id ${id}`);
    }
    return user;
  }
}

Enter fullscreen mode Exit fullscreen mode

our controller

// src/auth/auth.controller.ts

import { Body, Controller, Post, Req, UseGuards } from '@nestjs/common';

import { LocalGuard } from '../local.guard';
import { AuthService } from './auth.service';
import { LoginUserDto, RegisterUserDto } from './models';

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @Post('register')
  registerUser(@Body() user: RegisterUserDto) {
    return this.authService.registerUser(user);
  }

  @UseGuards(LocalGuard)
  @Post('login')
  loginUser(@Req() req, @Body() user: LoginUserDto) {
    return req.session;
  }
}

Enter fullscreen mode Exit fullscreen mode

and our serializer

// src/auth/serialization.provider.ts

import { Injectable } from '@nestjs/common';
import { PassportSerializer } from '@nestjs/passport';

import { AuthService } from './auth.service';
import { User } from './models/user.interface';

@Injectable()
export class AuthSerializer extends PassportSerializer {
  constructor(private readonly authService: AuthService) {
    super();
  }
  serializeUser(user: User, done: (err: Error, user: { id: number; role: string }) => void) {
    done(null, { id: user.id, role: user.role });
  }

  deserializeUser(payload: { id: number; role: string }, done: (err: Error, user: Omit<User, 'password'>) => void) {
    const user = this.authService.findById(payload.id);
    done(null, user);
  }
}

Enter fullscreen mode Exit fullscreen mode

along with our module

// src/auth/auth.module.ts

import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';

import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { LocalStrategy } from './local.strategy';
import { AuthSerializer } from './serialization.provider';

@Module({
  imports: [
    PassportModule.register({
      session: true,
    }),
  ],
  providers: [AuthService, LocalStrategy, AuthSerializer],
  controllers: [AuthController],
})
export class AuthModule {}

Enter fullscreen mode Exit fullscreen mode

All we need to do for the AuthSerializer is to add it to the providers array. Nest will instantiate it, which will end up calling passport.serializeUser and passport.deserializeUser for us (told you going over that would be useful).

The Guards

So now let's get to our guards, as you'll notice up in the AuthController we're not using AuthGuard('local'), but LocalGuard. The reason for this is because we need to end up calling super.logIn(request), which the AuthGuard has, but doesn't make use of by default. This just ends up calling request.login(user, (err) => done(err ? err : null, null)) for us, which is how the user serialization happens. This is what kicks off the session. I'll repeat that because it's super important. super.logIn(request) is how the user gets a session. To make use of this method, we can set up the LocalGuard as below

// src/local.guard.ts

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

@Injectable()
export class LocalGuard extends AuthGuard('local') {
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const result = (await super.canActivate(context)) as boolean;
    await super.logIn(context.switchToHttp().getRequest());
    return result;
  }
}

Enter fullscreen mode Exit fullscreen mode

We have another guard as well, the LoggedInGuard. This guards ends up just calling request.isAuthenticated() which is a method that passport ends up adding to the request object when sessions are in use. We can use this instead of having to have the user pass us the username and password every request, because there will be a cookie with the user's session id on it.

// src/logged-in.guard.ts

import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';


@Injectable()
export class LoggedInGuard implements CanActivate {
  canActivate(context: ExecutionContext) {
    return context.switchToHttp().getRequest().isAuthenticated();
  }
}

Enter fullscreen mode Exit fullscreen mode

And now we have one other guard for checking if a user is an admin or not.

// src/admin.guard.ts

import { ExecutionContext, Injectable } from '@nestjs/common';

import { LoggedInGuard } from './logged-in.guard';

@Injectable()
export class AdminGuard extends LoggedInGuard {
  canActivate(context: ExecutionContext): boolean {
    const req = context.switchToHttp().getRequest();
    return super.canActivate(context) && req.session.passport.user.role === 'admin';
  }
}

Enter fullscreen mode Exit fullscreen mode

This guard extends our usual LoggedInGuard and checks for the user's role, which is saved in the redis session, via the AuthSerializer we created earlier.

A couple of extra classes

There's a few other classes that I'm making use of. It'll be easiest to view them in the GitHub repo, but I'll add them here if you just want to copy paste:

// src/app.controller.ts

import { Controller, Get, UseGuards } from '@nestjs/common';
import { AdminGuard } from './admin.guard';

import { AppService } from './app.service';
import { LoggedInGuard } from './logged-in.guard';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  publicRoute() {
    return this.appService.getPublicMessage();
  }

  @UseGuards(LoggedInGuard)
  @Get('protected')
  guardedRoute() {
    return this.appService.getPrivateMessage();
  }

  @UseGuards(AdminGuard)
  @Get('admin')
  getAdminMessage() {
    return this.appService.getAdminMessage();
  }
}

Enter fullscreen mode Exit fullscreen mode
// src/app.service.ts

import { Injectable } from '@nestjs/common';

@Injectable()
export class AppService {
  getPublicMessage(): string {
    return 'This message is public to all!';
  }

  getPrivateMessage(): string {
    return 'You can only see this if you are authenticated';
  }

  getAdminMessage(): string {
    return 'You can only see this if you are an admin';
  }
}

Enter fullscreen mode Exit fullscreen mode
// src/main.ts

import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';

import { AppModule } from './app.module';

const bootstrap = async () => {
  const app = await NestFactory.create(AppModule);
  const logger = app.get(Logger);
  await app.listen(3000);
  logger.log(`Application listening at ${await app.getUrl()}`);
};

bootstrap();

Enter fullscreen mode Exit fullscreen mode

Testing out the flow

So now, we can run everything all together and test out the flow. First things first, make sure the Redis instance is running. Without that, the server won't start. Once it's running, run nest start --watch to start the server in dev mode which will recompile and restart on file change. Now it's time to send some curls.

Testing Existing Users

So let's start off with some existing user test. We'll try to log in as Joe Foo.

curl http://localhost:3000/auth/login -d 'email=joefoo@test.com&password=Passw0rd!' -c cookie.joe.txt
Enter fullscreen mode Exit fullscreen mode

If you aren't familiar with curl, the -d make the request a POST, and sends the data as application/x-www-form-urlencoded which Nest accepts by default. The -c tells curl that it should start the cookie engine and save the cookies to a file. If all goes well, you should get a response like

{"cookie":{"originalMaxAge":60000,"expires":"2021-08-16T05:30:51.621Z","httpOnly":false,"path":"/","sameSite":true},"passport":{"user":1}}
Enter fullscreen mode Exit fullscreen mode

Now we can send a request to /protected and get our protected response back

curl http://localhost:3000/protected -b cookie.joe.txt
Enter fullscreen mode Exit fullscreen mode

With -b we are telling curl to use the cookies found in this file.

Now let's check the registration:

curl http://localhost:3000/auth/register -c cookie.new.txt -d 'email=new.email@test.com&password=password&confirmationPassword=password&firstName=New&lastName=Test'
Enter fullscreen mode Exit fullscreen mode

You'll notice that no session was created for the new user, which means they still need to log in. Now let's send that login request

curl http://localhost:3000/auth/login -c cookie.new.txt -d 'email=new.email@test.com&password=password'
Enter fullscreen mode Exit fullscreen mode

And check that we did indeed create a session

curl http://localhost:3000/protected -b cookie.new.txt`
Enter fullscreen mode Exit fullscreen mode

And just like that, we've implemented a session login with NestJS, Redis, and Passport.

To view the session IDs in redis, you can connect the redis-cli to the running instance and run KEYS * to get all of the set keys. By default connect-redis uses sess: as a session key prefix.

Conclusion

Phew, okay, that was definitely a longer article than I had anticipated with a much deeper focus on Nest's integration with Passport, but hopefully it helps paint a picture of how everything ties together. With the above, it should be possible to integrate sessions with any kind of login, basic, local, OAuth2.0, so long as the user object remains the same.

One last thing to note, when using sessions, cookies are a must. The client must be able to work with cookies, otherwise the session will essentially be lost on each request.

If you have any questions, feel free to leave a comment or find me on the NestJS Discord Server

Top comments (28)

Collapse
 
hinogi profile image
Stefan Schneider

import { session as passportSession, initialize as passportInitialize } from 'passport';
This doesn't seem to work.
import * as passport from 'passport';
and then passport.session(), passport.initialize() seems to work. Maybe passport has no esm export?!

Collapse
 
jmcdo29 profile image
Jay McDoniel

This could be dependent on of your trying to use esm, or your tsconfig. By default, Typescript in a node project still uses the CommonJS syntax and the above methods are named exports. This runs fine for me and I'm fact there shouldn't be a difference between import * as passport...passport.session() and import { session }...session(), as the second one should just be destructing the first

Collapse
 
hinogi profile image
Stefan Schneider • Edited

This is what it turns out with trying to use named exports.
error

Thread Thread
 
jmcdo29 profile image
Jay McDoniel • Edited

That's definitely strange. All the code is available in the mentioned git repo along with the steps to run it. This is everything I used to run the project locally while driving into the code, so I'm confident that it works. There's probably a difference in a tsconfig file somewhere

Thread Thread
 
hinogi profile image
Stefan Schneider

nest cli will generate a different tsconfig as in the repo. If you follow from scratch.

Thread Thread
 
jmcdo29 profile image
Jay McDoniel

Sure enough. That's super interesting. I couldn't modify the tsconfig to match the one in my sample repository either, though I know this was working there. Very strange issue indeed. It seemed to have to do with how the import was being generated

passport_1.session()
Enter fullscreen mode Exit fullscreen mode

vs

(0, passport_1.session)()
Enter fullscreen mode Exit fullscreen mode

I'll get the tutorial updated with your working fix.

Thread Thread
 
jmcdo29 profile image
Jay McDoniel

@hinogi are you, by chance, on typescript 4.4.2? I just noticed that difference in my test repo (using nest new) and my blog repo. The blog repo is on 4.3.5 and works fine, but the same config on 4.4.2 failed

Thread Thread
 
hinogi profile image
Stefan Schneider

Yes I am on 4.4.2

Thread Thread
 
jmcdo29 profile image
Jay McDoniel

Looks like this was a breaking change of 4.4.0 which explains why it works with 4.3.5 but not 4.4.2. Thanks for pointing it out and helping me see what else has changed.

Collapse
 
kallezz profile image
Kalle210496 • Edited

Just want to mention that this guide does not work with redis package version 4.
After troubleshooting I couldn't find a solution other than downgrading to ^3.1.2. Redis throws an error (below).

Some notes I discovered with redis v4+:
The type RedisClient is RedisClientType
host / port options for createClient have to be replaced by "url" option

Redis fails with:
return Promise.reject(new errors_1.ClientClosedError());
Error: The client is closed

Collapse
 
wolfhoundjesse profile image
Jesse M. Holmes

Your error, The client is closed, is due to this breaking change.

You’ll need to make a change to the code above (in addition to the changes you already mentioned, like the url property) to call connect() on your new client, or you will get an error from connect-redis asking you to supply the client directly. I’m writing this from my phone at 1:30am, so if it doesn’t come out right, I’ll fix it in the morning. 😂

import { Module } from '@nestjs/common';
import * as Redis from 'redis';

import { REDIS } from './redis.constants';

@Module({
  providers: [
    {
      provide: REDIS,
      useFactory: async () => {
        const client = Redis.createClient({
          url: 'rediss://username:password@your.redis.url', 
          legacyMode: true 
        })
        await client.connect()
        return client
      },
    },
  ],
  exports: [REDIS],
})
export class RedisModule {}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
jmcdo29 profile image
Jay McDoniel

Thank you for pointing this out!

Collapse
 
danbachar profile image
Dan Bachar

hey @jmcdo29 ,

first of all, I wanted to thank you for this article. It's very well written and have done a wonderful done explaining to me how session storage, passport and nest play together.
I followed and can confirm the flow works when using cURL, and I have a question about using a proper front end. I am using fetch to authenticate my statically-served frontend (i.e. React), and am getting an HTTP 201 from the server when I enter the right credentials.
How do I "save" the session and make sure the client can communicate with the server? I added credentials: 'include'` to my authenticated requests, and I keep getting HTTP 401, which leads me to thinking I'm missing something here still.
I was wondering if you had any idea what might that be.

Thanks,
Dan

Collapse
 
jmcdo29 profile image
Jay McDoniel

If you're getting a 401, that sounds like passport is being used on other routes than just the login, and is causing the issue here. Wouldn't be able to really tell without seeing code though

Collapse
 
danbachar profile image
Dan Bachar

already solved per discord, thanks!

Collapse
 
dokuslab profile image
DokusLab

Great article!

Is it necessary to protect this against CSRF?
I added the following to the Session options :



cookie: {
        sameSite: 'none', // Because my API sits on a diffrent domain
        secure: true,
        httpOnly: true,
},


Enter fullscreen mode Exit fullscreen mode

Should I add more protection like XSRF-TOKEN?

Collapse
 
hiteshpathak profile image
Hitesh Pathak • Edited

With the latest versions for redis and connect-redis. I am getting TS errors.

configure(consumer: MiddlewareConsumer) {
consumer.apply(
session({
store: new (RedisStore(session))({
client: this.redis,
}),
saveUninitialized: false,
resave: false,
cookie: {
httpOnly: true,
},
secret: this.configService.get('SESSION_SECRET'),
}),
);
}

TS Error: Type 'RedisClientType' is not assignable to type 'Client'

Redis no longer exports RedisClient, which is what the article uses. For now I'm simply ignoring it with client: this.redis as Client with import { Client } from 'connect-redis'

If someone knows how to type this correctly, I'd be glad to get some pointers towards resolving this issue..

Collapse
 
khaledalhamwie profile image
khaled al hamwie

hi nice blog thank you for the effort
I would like to point to that the code inside the app.module in the source need to be changed to

// src/app.module.ts

import { Inject, Logger, MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
// used to be import * as  RedisStore from 'connect-redis';
import  RedisStore from 'connect-redis';
import * as session from 'express-session';
import * as passport from 'passport';

import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AuthModule } from './auth';
import { REDIS, RedisModule } from './redis';

@Module({
  imports: [AuthModule, RedisModule],
  providers: [AppService, Logger],
  controllers: [AppController],
})
export class AppModule implements NestModule {
  constructor(@Inject(REDIS) private readonly redis: RedisClient) {}
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(
        session({
// used to be new (RedisStore(session))({ client: this.redis, logErrors: true }),
          store: new RedisStore({ client: this.redis }),
          saveUninitialized: false,
          secret: 'sup3rs3cr3t',
          resave: false,
          cookie: {
            sameSite: true,
            httpOnly: false,
            maxAge: 60000,
          },
        }),
        passport.initialize(),
        passport.session(),
      )
      .forRoutes('*');
  }
}

Enter fullscreen mode Exit fullscreen mode

and you also need to remove the package @types/connect-redis and the legacy option in redis.module.ts in the Redius.createClient check the git hub link bellow
the source
stack over flow post
stackoverflow.com/questions/757875...
git hub page
github.com/tj/connect-redis/releas...
and thank for the blog post

have a nice day

Collapse
 
makaseloli profile image
まかせロリ

How would I go about using Nest's ConfigService in the configure method of the AppModule? I need to set up the session options (e.g. secret, sameSite, maxAge) depending on the environment, so hard-coding is a no-go. Should I use some other configuration method instead?

Collapse
 
jmcdo29 profile image
Jay McDoniel

You can inject the ConfigService just like we do Redis and use this.config.get('SESSION-SECRET') or whatever else you would need to

Collapse
 
motivatedcoder profile image
Ayoub Elmendoub

hey guys, i did setup session based authentication w/ redis store & i want to prevent the cookie object from being stored in redis

  {
    "cookie": {
        "originalMaxAge": null,
        "expires": null,
        "secure": false,
        "httpOnly": true,
        "path": "/",
        "sameSite": "lax"
    },
    "passport": {
        "user": {
            "username": "maria3",
            "hashedPassword": "$2b$10$9hGSvpgFkk/8ENRQUok0duAYg8N2hkFPYkrJwlo5kVXHmtSE/AdAW",
            "salt": "$2b$10$9hGSvpgFkk/8ENRQUok0du",
            "role": "CUSTOMER",
            "id": 7,
            "createdAt": "2022-05-16T15:09:12.514Z",
            "updatedAt": "2022-05-16T15:09:12.514Z",
            "deleteAt": null
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
wguerram profile image
wguerram

Thanks for sharing this, why do you think I'm having the following issue:

When using the LoggedInGuard in my app.controller an exception is thrown because passport is not initialized but if I use the LoggedInGuard in my auth.controller it's working. The one thing I have different I'm doing a MicroORMModule.forRoot call just before the session.

Collapse
 
wguerram profile image
wguerram

My issue was related to not adding a path in my controller decorator.

Collapse
 
grostmaster profile image
Oleh Butsyk

How can I correctly make logout from the session?

Collapse
 
wguerram profile image
wguerram

Try
req.logout():
req.session.destroy();

Collapse
 
jmcdo29 profile image
Jay McDoniel

I believe that just req.logout() should do it , as passport should help in maintaining the session information under the hood