Search code examples
node.jsnestjspassport-jwtnestjs-passport

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


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

  • First issue is that validate() method is executed twice in a strategy (it doesn't matter which strategy is used, both are always executed twice)
    • e.g. simple call to localhost:8080/api/admin/some-endpoint with Authorization header containing JWT results into JwtStrategy::validate() method to be fired twice. I can even completely remove the AuthorizationGuard (so it's not used/loaded) and it still executes the JwtStrategy::validate() method twice.
  • Second issue: when using ApiKeyStrategy (so providing apiKey with the request, but I guess it is probably the same in JwtStrategy), its validate() method is also executed twice the req.params contains different value in each run.
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.