I am trying to solve two issues in NestJS authentication/authorization.
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.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.
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.