Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(api): ApiKey auth guard performance #4972

Merged
merged 41 commits into from
Dec 13, 2023
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
fccce09
feat(api): Add passport apikey strategy
rifont Dec 11, 2023
2de1938
fix(api): Remove redundant check from roles guard
rifont Dec 11, 2023
47444a3
fix(api): Remove redundant authentication check from root env guard
rifont Dec 11, 2023
bb79d6f
fix(api): Remove redundant authentication check from session decorator
rifont Dec 11, 2023
aa91a0f
fix(api): Use user payload in throttler and idempotency interceptors
rifont Dec 11, 2023
277963e
fix(api): Remove redundant logger assign in jwt strategy
rifont Dec 11, 2023
36c79c9
fix(api): Remove redundant logger assign in trigger use-case
rifont Dec 11, 2023
798fea5
fix(app-gen): Update auth service to return expected auth validation …
rifont Dec 11, 2023
d533922
fix(api): Update throttler guard to read from req.user
rifont Dec 11, 2023
4464446
fix(app-gen): Remove log
rifont Dec 11, 2023
68d2abb
fix(api): Add headerapikey to cspell
rifont Dec 11, 2023
3460070
fix(api): Remove redundant log
rifont Dec 11, 2023
4409858
revert(api): Auth guard authscheme checl
rifont Dec 11, 2023
49b7290
fix(api): Auth scheme resolution
rifont Dec 11, 2023
96ec1a9
Update apps/api/src/app/shared/framework/idempotency.interceptor.ts
rifont Dec 12, 2023
e1d276c
Merge branch 'next' into nv-3055-fix-apikey-auth-guard-performance
rifont Dec 12, 2023
8bce71e
feat(api): Return api service level in rate limit headers
rifont Dec 12, 2023
499fd1f
Merge branch 'next' into nv-3064-new-relic-alerts
rifont Dec 12, 2023
81ef5ac
fix(api): Remove custom from enum
rifont Dec 12, 2023
48a6805
fix(api): Revert accidental change
rifont Dec 12, 2023
304b8a0
Merge branch 'next' into nv-3055-fix-apikey-auth-guard-performance
rifont Dec 12, 2023
4df3d58
fix(api): Add catch for apiheader strategy
rifont Dec 12, 2023
01792a0
fix(api): Typesafe user handling in auth guard
rifont Dec 12, 2023
e8c2fc8
feat(app-gen): Add custom user attributes to all logs
rifont Dec 12, 2023
2437f34
fix(api): Return false on error
rifont Dec 12, 2023
fc67c8d
revert(app-gen): Revert changes to trigger use-case logger assign
rifont Dec 12, 2023
7593079
chore(api, app-gen, shared): Create reusable enums for auth scheme an…
rifont Dec 12, 2023
1189fbf
chore(api, app-gen): Rename JwtAuthGuard to UserAuthGuard
rifont Dec 12, 2023
a521eb7
fix(infra): Fix cspell
rifont Dec 12, 2023
552c383
fix(api): Fix getApiKeyUser signature to match cache build signature
rifont Dec 13, 2023
a381571
Merge branch 'nv-3055-fix-apikey-auth-guard-performance' into nv-3064…
rifont Dec 13, 2023
38b67cd
fix(api): Handle undefined user
rifont Dec 13, 2023
fef2d12
Merge branch 'next' into nv-3055-fix-apikey-auth-guard-performance
rifont Dec 13, 2023
f71ac75
Merge branch 'nv-3055-fix-apikey-auth-guard-performance' into nv-3064…
rifont Dec 13, 2023
6ba5265
test(api): Add auth guard tests
rifont Dec 13, 2023
d9d1b18
fix(api): More reuse of http header enum
rifont Dec 13, 2023
973cb58
fix(api): Header keys import
rifont Dec 13, 2023
139e860
Revert "fix(api): Revert accidental change"
rifont Dec 13, 2023
8762ba2
Revert "fix(api): Remove custom from enum"
rifont Dec 13, 2023
c50321b
revert rate limit change
rifont Dec 13, 2023
7686063
Merge branch 'next' into nv-3055-fix-apikey-auth-guard-performance
rifont Dec 13, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,7 @@
"Ratelimit",
"stdev",
"Stdev",
"headerapikey",
],
"flagWords": [],
"patterns": [
Expand Down
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
"newrelic": "^9.15.0",
"passport": "0.6.0",
"passport-github2": "^0.1.12",
"passport-headerapikey": "^1.2.2",
"passport-jwt": "^4.0.0",
"passport-oauth2": "^1.6.1",
"recursive-diff": "^1.0.8",
Expand Down
14 changes: 3 additions & 11 deletions apps/api/src/app/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ import { EnvironmentsModule } from '../environments/environments.module';
import { JwtSubscriberStrategy } from './services/passport/subscriber-jwt.strategy';
import { JwtAuthGuard } from './framework/auth.guard';
import { RootEnvironmentGuard } from './framework/root-environment-guard.service';
import { ApiKeyStrategy } from './services/passport/apikey.strategy';

const AUTH_STRATEGIES: Provider[] = [];
const AUTH_STRATEGIES: Provider[] = [JwtStrategy, ApiKeyStrategy, JwtSubscriberStrategy];
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving the passport strategies out to where they belong.


if (process.env.GITHUB_OAUTH_CLIENT_ID) {
AUTH_STRATEGIES.push(GitHubStrategy);
Expand All @@ -42,16 +43,7 @@ if (process.env.GITHUB_OAUTH_CLIENT_ID) {
EnvironmentsModule,
],
controllers: [AuthController],
providers: [
JwtAuthGuard,
...USE_CASES,
...AUTH_STRATEGIES,
JwtStrategy,
AuthService,
RolesGuard,
JwtSubscriberStrategy,
RootEnvironmentGuard,
],
providers: [JwtAuthGuard, ...USE_CASES, ...AUTH_STRATEGIES, AuthService, RolesGuard, RootEnvironmentGuard],
exports: [RolesGuard, RootEnvironmentGuard, AuthService, ...USE_CASES, JwtAuthGuard],
})
export class AuthModule implements NestModule {
Expand Down
12 changes: 0 additions & 12 deletions apps/api/src/app/auth/framework/roles.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,6 @@ export class RolesGuard implements CanActivate {
return true;
}

const request = context.switchToHttp().getRequest();
if (!request.headers.authorization) return false;

const token = request.headers.authorization.split(' ')[1];
if (!token) return false;

const authorizationHeader = request.headers.authorization;
if (!authorizationHeader?.includes('ApiKey')) {
const user = jwt.decode(token) as IJwtPayload;
if (!user) return false;
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is handled in the Authentication flow, and shouldn't be part of Authorization.

/*
* TODO: The roles check implementation is currently not enabled
* As we are not using roles in the system at this point
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,7 @@ export class RootEnvironmentGuard implements CanActivate {

async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest();
if (!request.headers.authorization) return false;

const token = request.headers.authorization.split(' ')[1];
if (!token) return false;

const user = jwt.decode(token) as IJwtPayload;
if (!user) return false;
if (!user.environmentId) return false;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As above, this is handled in the Authentication flow, and shouldn't be part of Authorization.

const user = request.user;

const environment = await this.authService.isRootEnvironment(user);

Expand Down
24 changes: 24 additions & 0 deletions apps/api/src/app/auth/services/passport/apikey.strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { HeaderAPIKeyStrategy } from 'passport-headerapikey';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, Logger } from '@nestjs/common';
import { AuthService } from '@novu/application-generic';
import { IJwtPayload } from '@novu/shared';

@Injectable()
export class ApiKeyStrategy extends PassportStrategy(HeaderAPIKeyStrategy) {
constructor(private readonly authService: AuthService) {
super(
{ header: 'Authorization', prefix: 'ApiKey ' },
true,
(apikey: string, verified: (err: Error | null, user?: IJwtPayload | false) => void) => {
this.authService.validateApiKey(apikey).then((user) => {
rifont marked this conversation as resolved.
Show resolved Hide resolved
if (!user) {
return verified(null, false);
}

return verified(null, user);
});
}
);
}
}
10 changes: 2 additions & 8 deletions apps/api/src/app/auth/services/passport/jwt.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { IJwtPayload } from '@novu/shared';
import { AuthService, Instrument, PinoLogger } from '@novu/application-generic';
import { AuthService, Instrument } from '@novu/application-generic';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private readonly authService: AuthService, private logger: PinoLogger) {
constructor(private readonly authService: AuthService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_SECRET,
Expand All @@ -20,12 +20,6 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
throw new UnauthorizedException();
}

this.logger.assign({
userId: user._id,
environmentId: payload.environmentId,
organizationId: payload.organizationId,
});

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now handled in a single place in the Auth Guard, for all Auth strategies.

return payload;
}
}
4 changes: 1 addition & 3 deletions apps/api/src/app/rate-limiting/guards/throttler.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import { EvaluateApiRateLimit, EvaluateApiRateLimitCommand } from '../usecases/e
import { Reflector } from '@nestjs/core';
import { FeatureFlagCommand, GetIsApiRateLimitingEnabled } from '@novu/application-generic';
import { ApiRateLimitCategoryEnum, ApiRateLimitCostEnum, IJwtPayload } from '@novu/shared';
import * as jwt from 'jsonwebtoken';
import { ThrottlerCost, ThrottlerCategory } from './throttler.decorator';

export enum RateLimitHeaderKeysEnum {
Expand Down Expand Up @@ -175,8 +174,7 @@ export class ApiRateLimitInterceptor extends ThrottlerGuard implements NestInter

private getReqUser(context: ExecutionContext): IJwtPayload {
const req = context.switchToHttp().getRequest();
const token = req.headers[RateLimitHeaderKeysEnum.AUTHORIZATION.toLowerCase()].split(' ')[1];

return jwt.decode(token);
return req.user;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

req.user is always present now.

}
}
23 changes: 9 additions & 14 deletions apps/api/src/app/shared/framework/idempotency.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import { CacheService } from '@novu/application-generic';
import { Observable, of, throwError } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { createHash } from 'crypto';
import * as jwt from 'jsonwebtoken';
import { IJwtPayload } from '@novu/shared';

const LOG_CONTEXT = 'IdempotencyInterceptor';
Expand Down Expand Up @@ -93,26 +92,22 @@ export class IdempotencyInterceptor implements NestInterceptor {
return request.headers[HEADER_KEYS.IDEMPOTENCY_KEY.toLocaleLowerCase()];
}

private getReqUser(context: ExecutionContext): IJwtPayload | null {
private getReqUser(context: ExecutionContext): IJwtPayload {
const req = context.switchToHttp().getRequest();
if (req?.user?.organizationId) {
return req.user;
}
if (req.headers?.authorization?.length) {
const token = req.headers.authorization.split(' ')[1];
if (token) {
return jwt.decode(token);
}
}

return null;
return req.user;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As above, req.user is always present now.

}

private getCacheKey(context: ExecutionContext): string {
const { organizationId } = this.getReqUser(context) || {};
const user = this.getReqUser(context);
if (user === undefined) {
const message = 'Cannot build cache key without user';
rifont marked this conversation as resolved.
Show resolved Hide resolved
Logger.error(message, LOG_CONTEXT);
throw new InternalServerErrorException(message);
}
const env = process.env.NODE_ENV;

return `${env}-${organizationId}-${this.getIdempotencyKey(context)}`;
return `${env}-${user.organizationId}-${this.getIdempotencyKey(context)}`;
}

async setCache(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { createParamDecorator, UnauthorizedException } from '@nestjs/common';
import jwt from 'jsonwebtoken';
import {
InternalServerErrorException,
Logger,
createParamDecorator,
} from '@nestjs/common';

// eslint-disable-next-line @typescript-eslint/naming-convention
export const UserSession = createParamDecorator((data, ctx) => {
Expand All @@ -11,28 +14,12 @@ export const UserSession = createParamDecorator((data, ctx) => {
}

if (req.user) {
/**
* This helps with sentry and other tools that need to know who the user is based on `id` property.
*/
req.user.id = req.user._id;
req.user.username = (req.user.firstName || '').trim();
req.user.domain = req.user.email?.split('@')[1];

return req.user;
}

if (req.headers) {
if (req.headers.authorization) {
const tokenParts = req.headers.authorization.split(' ');
if (tokenParts[0] !== 'Bearer')
throw new UnauthorizedException('bad_token');
if (!tokenParts[1]) throw new UnauthorizedException('bad_token');

const user = jwt.decode(tokenParts[1]);

return user;
}
}

return null;
Logger.error(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As above, this is handled in the Authentication flow, and shouldn't be part of Authorization.

If we don't have a req.user, this is indicative of a developer error, and results in a 500 Server error

'Attempted to access user session without a user in the request. You probably forgot to add the AuthGuard',
'UserSession'
);
throw new InternalServerErrorException();
});
81 changes: 51 additions & 30 deletions packages/application-generic/src/services/auth/auth.guard.ts
rifont marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -1,54 +1,75 @@
import {
ExecutionContext,
forwardRef,
Inject,
Injectable,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AuthGuard, IAuthModuleOptions } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
import { AuthService } from './auth.service';
import { Instrument } from '../../instrumentation';
import { PinoLogger } from 'nestjs-pino';
rifont marked this conversation as resolved.
Show resolved Hide resolved
import { IJwtPayload } from '@novu/shared';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
export class JwtAuthGuard extends AuthGuard(['jwt', 'headerapikey']) {
private readonly reflector: Reflector;

constructor(
@Inject(forwardRef(() => AuthService)) private authService: AuthService
) {
constructor(private logger: PinoLogger) {
super();
this.reflector = new Reflector();
}

@Instrument()
async canActivate(context: ExecutionContext) {
getAuthenticateOptions(context: ExecutionContext): IAuthModuleOptions<any> {
const request = context.switchToHttp().getRequest();
const authorizationHeader = request.headers.authorization;
const authScheme = authorizationHeader.split(' ')[0];
request.authScheme = authScheme;

if (authorizationHeader) {
const authScheme = authorizationHeader.split(' ')[0];
request.authScheme = authScheme;
}
switch (authScheme) {
case 'Bearer':
return {
session: false,
defaultStrategy: 'jwt',
rifont marked this conversation as resolved.
Show resolved Hide resolved
};
case 'ApiKey':
const apiEnabled = this.reflector.get<boolean>(
'external_api_accessible',
context.getHandler()
);
if (!apiEnabled)
throw new UnauthorizedException('API endpoint not available');

if (authorizationHeader && authorizationHeader.includes('ApiKey')) {
const apiEnabled = this.reflector.get<boolean>(
'external_api_accessible',
context.getHandler()
);
if (!apiEnabled)
throw new UnauthorizedException('API endpoint not available');
return {
session: false,
defaultStrategy: 'headerapikey',
};
default:
throw new UnauthorizedException('Invalid auth scheme');
}
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Handling invocation different authentication schemes passport strategies with an authScheme switch statement


const key = authorizationHeader.split(' ')[1];
handleRequest<TUser = IJwtPayload>(
rifont marked this conversation as resolved.
Show resolved Hide resolved
err: any,
user: any,
info: any,
context: ExecutionContext,
status?: any
): TUser {
const request = context.switchToHttp().getRequest();

// eslint-disable-next-line @typescript-eslint/no-explicit-any
return this.authService.apiKeyAuthenticate(key).then<any>((result) => {
request.headers.authorization = `Bearer ${result}`;
this.logger.assign({
userId: user._id,
environmentId: user.environmentId,
organizationId: user.organizationId,
authScheme: request.authScheme,
});

return true;
});
}
/**
* This helps with sentry and other tools that need to know who the user is based on `id` property.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀

*/
user.id = user._id;
user.username = (user.firstName || '').trim();
user.domain = user.email?.split('@')[1];

return super.canActivate(context);
return user;
}
}
Loading
Loading