Categories
Backend

Declaratively bypassing guards in a NestJS endpoint with JWT and RBAC

I recently started using NestJS – a relatively new Node.js backend framework, which puts convention over configuration. One of the concepts introduced by NestJS are guards, which are a context aware alternative to regular Express middleware. In one of my projects, I’ve run into a use case, which well illustrates the advantages of guards vs middleware.

When developing a REST API, it’s very common to have certain routes accessible only to users with a given permissions level. That requirement is usually satisfied by using regular middleware, which is placed in front of a router responsible for handling endpoints that belong to a given route.

Things tend to get more complicated when you want to bypass checks performed by said middleware only for certain endpoints. Why would you want to do so? Let’s examine one use case – file downloads. It’s not uncommon, especially when developing admin panels, to have REST API endpoints that respond with a downloadable resource. On the frontend side, such endpoints are most conveniently handled with an <a> tag with download attribute. The problem is that such approach doesn’t work particularly well with a JWT token, as an <a> tag with download attribute doesn’t allow for specifying the request’s Bearer token header. Consequently, such limitation is oftentimes worked around by implementing a different authentication mechanism, that relies on storing all the necessary information in the GET request query string.

Let’s take a look at and example Express middleware that guards all endpoints within a given router with exception of one:

const adminMiddleware = async (req, res, next) => {
    if (req.path.startsWith('/someroute')) return next();
    if (req.token && req.roles && req.roles.includes('admin')) {
        next();
    } else {
        res.status(403).send({
            error:
                'You don't have a permission to access this resource.',
        });
    }
};

The example above assumes that there is another middleware responsible for JWT validation and as a result of that process, req.token and req.roles are set.

As you can probably tell, such solution is far from ideal. First of all it lacks transparency, as the /someroute endpoint handler in the router will not be annotated in any way. A declarative solution would be much better.

Additionally, this solution requires placement of the middleware exactly before the endpoint handler. If we were to move the endpoint to let’s say /someroute/someresource, the middleware would have to be either modified or moved.

Finally, it’s an awkward solution as even such a low-level framework as Express should handle requests parsing on its own without the developer having to implement additional path checks. Why does it matter? Bypassing auth based on some pattern found in in the relative path from a given middleware could introduce security vulnerabilities.

Let’s now take a look at how that problem could be solved using NestJS guards mechanism. We are going to start with an example service called Places with two endpoints in its controller. The code snippets are largely taken from NestJS documentation on setting up Auth Guards. Files that are not relevant to the solution (among others: modules, services, schemas and DTOs) were omitted.

places.controller.ts

import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';
import { Role } from 'src/role.enum';
import { Roles } from 'src/roles.decorator';
import { RolesGuard } from 'src/roles.guard';
import { CreatePlaceDto } from './create-place.dto';
import { PlacesService } from './places.service';

// the controller is guarded with JWT guard and RBAC guard
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.Admin)
@Controller('places')
export class PlacesController {
  constructor(private readonly placesService: PlacesService) {}

  @Get('')
  // we are going to bypass auth within this endpoint
  async getPlaces() {
    const places = await this.placesService.getPlaces();
    return places;
  }

  @Post('')
  createPlace(@Body() place: CreatePlaceDto) {
    return this.placesService.createPlace(place);
  }
}

jwt-auth.guard.ts

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

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

roles.guard.ts

import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role } from './role.enum';
import { ROLES_KEY } from './roles.decorator';

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);

    const ctx = context.switchToHttp().getRequest();
    const { user } = ctx;
    if (!requiredRoles) {
      return true;
    }

    return requiredRoles.some((role) => user.roles?.includes(role));
  }
}

role.enum.ts

export enum Role {
  User = 'user',
  Admin = 'admin',
}

roles.decorator.ts

import { SetMetadata } from '@nestjs/common';
import { Role } from './role.enum';

export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => {
  return SetMetadata(ROLES_KEY, roles);
};

Let’s start by creating a custom @BypassAuth decorator that will set appropriate metadata to our endpoint informing other auth guards to bypass checks.

import { ExecutionContext, SetMetadata } from '@nestjs/common';
import { Reflector } from '@nestjs/core';

export const BYPASS_KEY = 'bypass';
export const BypassAuth = () => {
  return SetMetadata(BYPASS_KEY, true);
};

Now let’s create a function that will be used by existing guards (JWT and Roles) in order to determine whether or not the checks should be bypassed based on the metadata set by the decorator above.

export const shouldBypassAuth = (
  context: ExecutionContext,
  reflector: Reflector,
): boolean => {
  return reflector.get(BYPASS_KEY, context.getHandler());
};

We now have to modify the RolesGuard as well as JWTAuthGuard in order to trigger shouldBypassAuth:

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  // constructor provided
  // so that the reflector gets injected
  constructor(private reflector: Reflector) {
    super();
  }

  canActivate(context: ExecutionContext) {
    return (
      shouldBypassAuth(context, this.reflector)
      || super.canActivate(context)
    );
  }
}

What is left now is to decorate the getPlaces method with @BypassAuth:

  @Get('')
  @BypassAuth()
  async getPlaces() {
    const places = await this.placesService.getPlaces();
    return places;
  }

Finally, the /places GET endpoint will be publicly available – it will require neither a valid JWT nor an admin role despite the fact that both guards are registered at the controller level.

As you can see, such solution can be reused across the entire application, the fact that a given controller method is bypassing auth is stated explicitly via the @BypassAuth decorator and there is no need to manually parse request path. It’s all possible thanks to the fact that NestJS guards, unlike Express middleware, are context aware.

Leave a Reply

Your email address will not be published. Required fields are marked *