Skip to content

Commit

Permalink
✨ feat(api): add jwt access token validation to graphql ws connections
Browse files Browse the repository at this point in the history
  • Loading branch information
drackp2m committed Mar 25, 2024
1 parent 8d79796 commit 52365fd
Show file tree
Hide file tree
Showing 10 changed files with 103 additions and 28 deletions.
4 changes: 1 addition & 3 deletions apps/api/src/module/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { ConfigModule } from '@nestjs/config';
import { GraphQLModule } from '@nestjs/graphql';

import { ConfigurationModule } from '../../shared/module/config/configuration.module';
import { ConfigurationService } from '../../shared/module/config/configuration.service';
import { GqlFactory } from '../../shared/module/config/factories/gql.factory';
import { MikroOrmFactory } from '../../shared/module/config/factories/mikro-orm.factory';
import { appConfig } from '../../shared/module/config/registers/app.config';
Expand All @@ -30,8 +29,7 @@ import { AppService } from './app.service';
useClass: MikroOrmFactory,
}),
GraphQLModule.forRootAsync<ApolloDriverConfig>({
imports: [ConfigurationModule],
inject: [ConfigurationService],
imports: [ConfigurationModule, AuthModule],
useClass: GqlFactory,
driver: ApolloDriver,
}),
Expand Down
5 changes: 3 additions & 2 deletions apps/api/src/module/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { User } from '../user/user.entity';
import { AuthController } from './auth.controller';
import { JwtGuard } from './guard/jwt.guard';
import { JwtStrategyService } from './strategy/jwt.strategy.service';
import { CheckJwtRefreshTokenUseCase } from './use-case/check-jwt-refresh-token.use-case';
import { CheckJwtTokenUseCase } from './use-case/check-jwt-token.use-case';
import { CreateJwtAccessTokenUseCase } from './use-case/create-jwt-access-token.use-case';
import { CreateJwtRefreshTokenUseCase } from './use-case/create-jwt-refresh-token.use-case';
import { LoginUseCase } from './use-case/login.use-case';
Expand Down Expand Up @@ -43,12 +43,13 @@ import { SetJwtTokenUseCase } from './use-case/set-jwt-token.use-case';
JwtStrategyService,
HashPasswordUseCase,
CheckPasswordUseCase,
CheckJwtRefreshTokenUseCase,
CheckJwtTokenUseCase,
CreateJwtAccessTokenUseCase,
CreateJwtRefreshTokenUseCase,
SetJwtTokenUseCase,
RefreshSessionUseCase,
],
exports: [CheckJwtTokenUseCase],
controllers: [AuthController],
})
export class AuthModule {}
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ import { ConfigurationService } from '../../../shared/module/config/configuratio
import { JwtFactory } from '../../../shared/module/config/factories/jwt.factory';
import { EditableDate } from '../../../shared/util/editable-date';

import { CheckJwtRefreshTokenUseCase } from './check-jwt-refresh-token.use-case';
import { CheckJwtTokenUseCase } from './check-jwt-token.use-case';

describe('CheckJwtRefreshTokenUseCase', () => {
let module: TestingModule;
let useCase: CheckJwtRefreshTokenUseCase;
let useCase: CheckJwtTokenUseCase;

const jwtServiceVerify = jest.spyOn(JwtService.prototype, 'verify');
const validJwtDate = new Date('2024-03-20T22:10:42.000Z');
Expand All @@ -27,12 +27,12 @@ describe('CheckJwtRefreshTokenUseCase', () => {
}),
],
providers: [
CheckJwtRefreshTokenUseCase,
CheckJwtTokenUseCase,
{ provide: ConfigurationService, useValue: configurationService },
],
}).compile();

useCase = await module.resolve(CheckJwtRefreshTokenUseCase);
useCase = await module.resolve(CheckJwtTokenUseCase);
};

beforeEach(async () => {
Expand All @@ -52,6 +52,8 @@ describe('CheckJwtRefreshTokenUseCase', () => {
expect(useCase).toBeDefined();
});

// ToDo => add tests for accessToken

describe('execute', () => {
it('throw JsonWebTokenError with "invalid signature" when secret is not valid', async () => {
const configurationService = mock<ConfigurationService>({
Expand All @@ -65,7 +67,7 @@ describe('CheckJwtRefreshTokenUseCase', () => {

await prepareTestingModule(configurationService);

expect(() => useCase.execute(fakeJwtRefreshToken)).toThrow(
expect(() => useCase.execute(fakeJwtRefreshToken, 'refresh')).toThrow(
new JsonWebTokenError('invalid signature'),
);

Expand All @@ -91,7 +93,7 @@ describe('CheckJwtRefreshTokenUseCase', () => {

await prepareTestingModule(configurationService);

expect(() => useCase.execute(fakeJwtRefreshToken)).toThrow(
expect(() => useCase.execute(fakeJwtRefreshToken, 'refresh')).toThrow(
new JsonWebTokenError('jwt jwtid invalid. expected: iduu'),
);

Expand All @@ -117,7 +119,7 @@ describe('CheckJwtRefreshTokenUseCase', () => {

await prepareTestingModule(configurationService);

expect(() => useCase.execute(fakeJwtRefreshToken)).toThrow(
expect(() => useCase.execute(fakeJwtRefreshToken, 'refresh')).toThrow(
new JsonWebTokenError('jwt audience invalid. expected: test-mock-refresh-token'),
);

Expand All @@ -143,7 +145,7 @@ describe('CheckJwtRefreshTokenUseCase', () => {

await prepareTestingModule(configurationService);

expect(() => useCase.execute(fakeJwtRefreshToken)).toThrow(
expect(() => useCase.execute(fakeJwtRefreshToken, 'refresh')).toThrow(
new JsonWebTokenError('jwt issuer invalid. expected: spec'),
);

Expand All @@ -158,7 +160,7 @@ describe('CheckJwtRefreshTokenUseCase', () => {
it('throw JsonWebTokenError with "jwt not active" when system date is below jwt notBefore time', async () => {
jest.useFakeTimers().setSystemTime(new EditableDate(validJwtDate).edit('day', -1));

expect(() => useCase.execute(fakeJwtRefreshToken)).toThrow(
expect(() => useCase.execute(fakeJwtRefreshToken, 'refresh')).toThrow(
new JsonWebTokenError('jwt not active'),
);

Expand All @@ -173,7 +175,7 @@ describe('CheckJwtRefreshTokenUseCase', () => {
it('throw JsonWebTokenError with "jwt not active" when system date is above jwt expiration time', async () => {
jest.useFakeTimers().setSystemTime(new EditableDate(validJwtDate).edit('month', 1));

expect(() => useCase.execute(fakeJwtRefreshToken)).toThrow(
expect(() => useCase.execute(fakeJwtRefreshToken, 'refresh')).toThrow(
new JsonWebTokenError('jwt expired'),
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,16 @@ import { ConfigurationService } from '../../../shared/module/config/configuratio
import { JsonWebToken } from '../definition/json-web-token.interface';

@Injectable()
export class CheckJwtRefreshTokenUseCase {
export class CheckJwtTokenUseCase {
constructor(
private readonly jwtService: JwtService,
private readonly configurationService: ConfigurationService,
) {}

execute(token: string): JsonWebToken {
execute(token: string, type: 'access' | 'refresh'): JsonWebToken {
return this.jwtService.verify(token, {
jwtid: this.configurationService.jwt.id,
audience: `${this.configurationService.jwt.audience}-refresh-token`,
audience: `${this.configurationService.jwt.audience}-${type}-token`,
issuer: this.configurationService.jwt.issuer,
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { UnauthorizedException } from '../../../shared/exception/unauthorized-ex
import { ConfigurationService } from '../../../shared/module/config/configuration.service';
import { JwtFactory } from '../../../shared/module/config/factories/jwt.factory';

import { CheckJwtRefreshTokenUseCase } from './check-jwt-refresh-token.use-case';
import { CheckJwtTokenUseCase } from './check-jwt-token.use-case';
import { CreateJwtAccessTokenUseCase } from './create-jwt-access-token.use-case';
import { CreateJwtRefreshTokenUseCase } from './create-jwt-refresh-token.use-case';
import { RefreshSessionUseCase } from './refresh-session.use-case';
Expand All @@ -22,7 +22,7 @@ describe('RefreshSessionUseCase', () => {
let module: TestingModule;
let useCase: RefreshSessionUseCase;

const checkJwtRefreshTokenExecute = jest.spyOn(CheckJwtRefreshTokenUseCase.prototype, 'execute');
const checkJwtRefreshTokenExecute = jest.spyOn(CheckJwtTokenUseCase.prototype, 'execute');

const createAccessToken = mock<CreateJwtAccessTokenUseCase>();
const createRefreshToken = mock<CreateJwtRefreshTokenUseCase>();
Expand All @@ -41,7 +41,7 @@ describe('RefreshSessionUseCase', () => {
],
providers: [
RefreshSessionUseCase,
CheckJwtRefreshTokenUseCase,
CheckJwtTokenUseCase,
{ provide: REQUEST, useValue: request },
{ provide: ConfigurationService, useValue: configurationService },
{ provide: CreateJwtAccessTokenUseCase, useValue: createAccessToken },
Expand Down Expand Up @@ -90,7 +90,7 @@ describe('RefreshSessionUseCase', () => {
expect(result).rejects.toMatchObject({ response: { refreshToken: 'jwt malformed' } });

expect(checkJwtRefreshTokenExecute).toHaveBeenCalledTimes(1);
expect(checkJwtRefreshTokenExecute).toHaveBeenCalledWith('wrong-refresh-token');
expect(checkJwtRefreshTokenExecute).toHaveBeenCalledWith('wrong-refresh-token', 'refresh');
});

it('should call two times to setJwtToken useCase', async () => {
Expand Down Expand Up @@ -118,7 +118,7 @@ describe('RefreshSessionUseCase', () => {
expect(result).toStrictEqual(undefined);

expect(checkJwtRefreshTokenExecute).toHaveBeenCalledTimes(1);
expect(checkJwtRefreshTokenExecute).toHaveBeenCalledWith(fakeRefreshToken);
expect(checkJwtRefreshTokenExecute).toHaveBeenCalledWith(fakeRefreshToken, 'refresh');

expect(createAccessToken.execute).toHaveBeenCalledTimes(1);
expect(createAccessToken.execute).toHaveBeenCalledWith('user-uuid');
Expand Down
6 changes: 3 additions & 3 deletions apps/api/src/module/auth/use-case/refresh-session.use-case.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Request } from 'express';
import { UnauthorizedException } from '../../../shared/exception/unauthorized-exception.exception';
import { JwtCookie } from '../definition/jwt-cookie.enum';

import { CheckJwtRefreshTokenUseCase } from './check-jwt-refresh-token.use-case';
import { CheckJwtTokenUseCase } from './check-jwt-token.use-case';
import { CreateJwtAccessTokenUseCase } from './create-jwt-access-token.use-case';
import { CreateJwtRefreshTokenUseCase } from './create-jwt-refresh-token.use-case';
import { SetJwtTokenUseCase } from './set-jwt-token.use-case';
Expand All @@ -14,7 +14,7 @@ import { SetJwtTokenUseCase } from './set-jwt-token.use-case';
export class RefreshSessionUseCase {
constructor(
@Inject(REQUEST) private readonly request: Request,
private readonly checkrefreshToken: CheckJwtRefreshTokenUseCase,
private readonly checkJwtToken: CheckJwtTokenUseCase,
private readonly createAccessToken: CreateJwtAccessTokenUseCase,
private readonly createRefreshToken: CreateJwtRefreshTokenUseCase,
private readonly setJwtToken: SetJwtTokenUseCase,
Expand All @@ -24,7 +24,7 @@ export class RefreshSessionUseCase {
try {
const currentRefreshToken = this.request.signedCookies[JwtCookie.refresh];

const refreshTokenPayload = this.checkrefreshToken.execute(currentRefreshToken);
const refreshTokenPayload = this.checkJwtToken.execute(currentRefreshToken, 'refresh');

const accessToken = this.createAccessToken.execute(refreshTokenPayload.sub);
const refreshToken = this.createRefreshToken.execute(refreshTokenPayload.sub);
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/module/user/user.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export class UserResolver {
async getMany(): Promise<User[]> {
this.pubSub.publish('getManySubscription', 'Hello from getMany');

const users = await this.userRepository.getMany();
const users = await this.userRepository.getMany({}, {});

return users;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
export interface GraphqlWsConnectionExtraInterface {
socket: Socket;
request: Request;
}

interface Request {
socket: Client;
httpVersionMajor: number;
httpVersionMinor: number;
httpVersion: string;
complete: boolean;
rawHeaders: string[];
rawTrailers: unknown[];
joinDuplicateHeaders: null;
aborted: boolean;
upgrade: boolean;
url: string;
method: string;
statusCode: null;
statusMessage: null;
client: Client;
parser: null;
}

interface Client {
secureConnecting: boolean;
servername: string;
alpnProtocol: string;
authorized: boolean;
authorizationError: null;
encrypted: boolean;
connecting: boolean;
allowHalfOpen: boolean;
timeout: number;
parser: null;
}

interface Socket {
socket: Client;
}
35 changes: 34 additions & 1 deletion apps/api/src/shared/module/config/factories/gql.factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,19 @@ import { ApolloServerPluginLandingPageLocalDefault } from '@apollo/server/plugin
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { Injectable } from '@nestjs/common';
import { GqlOptionsFactory } from '@nestjs/graphql';
import { Context } from 'graphql-ws';

import { JwtCookie } from '../../../../module/auth/definition/jwt-cookie.enum';
import { CheckJwtTokenUseCase } from '../../../../module/auth/use-case/check-jwt-token.use-case';
import { GraphqlWsConnectionExtraInterface } from '../../../interface/graphql-ws-connection-extra.interface';
import { ConfigurationService } from '../configuration.service';

@Injectable()
export class GqlFactory implements GqlOptionsFactory {
constructor(private readonly configurationService: ConfigurationService) {}
constructor(
private readonly configurationService: ConfigurationService,
private readonly checkJwtToken: CheckJwtTokenUseCase,
) {}

createGqlOptions(): ApolloDriverConfig {
const isProduction = this.configurationService.app.environment === 'production';
Expand All @@ -21,13 +28,39 @@ export class GqlFactory implements GqlOptionsFactory {
buildSchemaOptions: {
dateScalarMode: 'isoDate',
numberScalarMode: 'float',
noDuplicatedFields: true,
},
definitions: {
path: 'libs/api-definitions/src/lib/graphql/definitions.ts',
},
subscriptions: {
'graphql-ws': {
path: '/graphql',
onConnect: (
context: Context<Record<string, unknown>, GraphqlWsConnectionExtraInterface>,
) => {
// ToDo => move this to help function

const accessTokenParts = context.extra.request.rawHeaders
.find((header) => header.includes(JwtCookie.access))
?.split('=')[1]
.replace('s%3A', '')
.split('.');

accessTokenParts.pop();

const accessToken = accessTokenParts?.join('.');

if (accessToken) {
try {
this.checkJwtToken.execute(accessToken, 'access');
} catch (error) {
return false;
}
}

return true;
},
},
},
playground: false,
Expand Down
1 change: 1 addition & 0 deletions apps/app/src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const appConfig: ApplicationConfig = {
const ws = new GraphQLWsLink(
createClient({
url: `${environment.wsUrl}/graphql`,
connectionAckWaitTimeout: 1000,
}),
);

Expand Down

0 comments on commit 52365fd

Please sign in to comment.