node.jsnestjspassport-jwtnestjs-passport

JWT strategy (extended PassportStrategy) executes validate() method twice


I am trying to solve two issues in NestJS authentication/authorization.

DEBUG [ApiKeyStrategy] running validate()
DEBUG [ApiKeyStrategy] req.params
{
  "0": "client/account/xxxx"
}
DEBUG [AccessGuard] running canActivate()
DEBUG [ApiKeyStrategy] running validate()
DEBUG [ApiKeyStrategy] req.params
{
  "internalId": "xxxx"
}

An example Controller:

@Controller('account')
export class AccountController {

  @Get(':internalId')
  async fetch() {
    return true;
  }
}

We use two global guards that look something like this (stripped of unnecessary code):

@Injectable()
export class AccessGuard extends AuthGuard(['jwt', 'headerapikey']) {
  constructor() {
    super();
  }

  canActivate(context: ExecutionContext) {
    return super.canActivate(context);
  }
}

@Injectable()
export class AuthorizationGuard implements CanActivate {
  async canActivate(context: ExecutionContext): Promise<boolean> {
    return true;
  }
}

Both guards are loaded inside a module so they are global - applied to all endpoints:

  static forRoot(): DynamicModule {
    return {
      module: AuthModule,
      imports: [PassportModule.register({})],
      providers: [
        ApiKeyStrategy,
        JwtStrategy,
        {
          provide: APP_GUARD,
          useClass: AccessGuard,
        },
        {
          provide: APP_GUARD,
          useClass: AuthorizationGuard,
        },
      ],
    };
  }

Then we have auth middleware:

export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(Sentry.Handlers.requestHandler()).forRoutes({
      path: '*',
      method: RequestMethod.ALL,
    });
    consumer.apply(AuthMiddleware).forRoutes('*');
  }
}

@Injectable()
export class AuthMiddleware implements NestMiddleware {
  use(req: Request, res: any, next: () => void) {
    // if it is an admin endpoint use jwt
    const strategy =
      req.originalUrl.indexOf('/admin/') !== -1 ? 'jwt' : 'headerapikey';
    const options = { session: false };

    passport.authenticate(strategy, options, () => {
      next();
    })(req, res, next);
  }
}

And finally the PassportStrategies:

@Injectable()
export class ApiKeyStrategy extends PassportStrategy(HeaderAPIKeyStrategy) {
  constructor() {
    super({ header: 'apiKey', prefix: '' }, true, async (apiKey, done, req) => {
      return await this.validate(apiKey, done, req);
    });
  }

  async validate(
    apiKey: string,
    done: (err: Error, user: UserDetails, info?: any) => void,
    req: Request,
  ) {
    const checkKey = validateApiKey(apiKey);
    if (!checkKey) {
      throw new UnauthorizedException();
    }
    console.log(req.params);
    return done(null, { company: {id: 42} });
  }
}

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      secretOrKeyProvider: passportJwtSecret({
        cache: true,
        rateLimit: true,
        jwksRequestsPerMinute: 5,
        jwksUri: `https://xxx/jwks.json`,
      }),
      jwtFromRequest: (req: Request) => {
        return ExtractJwt.fromAuthHeaderAsBearerToken()(req);
      },
      passReqToCallback: true,
    });
  }

  async validate(req: Request, payload: Auth0Payload): Promise<UserDetails> {
    console.log('running validate()');
    return {id: payload.sub};
  }
}

Any idea/explanation/hint highly appreciated.


Solution

  • The AuthMiddleware being set up is a single call to the passport strategy and the AuthGuard being used is another call. They are being passed different parameters based on how they get called (IIRC custom middleware is before body parsers, but I could be wrong). Moving the branching logic to inside your guard and using the appropriate strategy there would be a quick way to fix this.