From 1be3702c182787d4e114eb8f62ce5a60d8036276 Mon Sep 17 00:00:00 2001 From: iamacook Date: Wed, 3 Apr 2024 12:32:43 +0200 Subject: [PATCH] Add `JwtService` --- .env.sample | 5 + docker-compose.yml | 2 + package.json | 2 + src/config/configuration.validator.spec.ts | 6 + src/config/configuration.validator.ts | 2 + .../entities/__tests__/configuration.ts | 4 + src/config/entities/configuration.ts | 4 + src/datasources/jwt/jwt.module.ts | 28 +++++ src/datasources/jwt/jwt.service.interface.ts | 13 ++ src/datasources/jwt/jwt.service.spec.ts | 74 +++++++++++ src/datasources/jwt/jwt.service.ts | 38 ++++++ yarn.lock | 117 +++++++++++++++++- 12 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 src/datasources/jwt/jwt.module.ts create mode 100644 src/datasources/jwt/jwt.service.interface.ts create mode 100644 src/datasources/jwt/jwt.service.spec.ts create mode 100644 src/datasources/jwt/jwt.service.ts diff --git a/.env.sample b/.env.sample index 1f8aaadd22..1f0840106f 100644 --- a/.env.sample +++ b/.env.sample @@ -99,6 +99,11 @@ # The AUTH_TOKEN should always be set #AUTH_TOKEN= +# The issuer and secret used to sign and verify JWTs. +# The JWT_ISSUER and JWT_SECRET should always be set +#JWT_ISSUER= +#JWT_SECRET= + # Log level for the service. #LOG_LEVEL= diff --git a/docker-compose.yml b/docker-compose.yml index 4ce41c3feb..69cdd13618 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -52,6 +52,8 @@ services: EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX: ${EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX-example_template_unknown_recovery_tx} EMAIL_TEMPLATE_RECOVERY_TX: ${EMAIL_TEMPLATE_RECOVERY_TX-example_template_recovery_tx} EMAIL_TEMPLATE_VERIFICATION_CODE: ${EMAIL_TEMPLATE_VERIFICATION_CODE-example_template_verification_code} + JWT_ISSUER: ${JWT_ISSUER-example_issuer} + JWT_TOKEN: ${JWT_TOKEN-example_token} RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN: ${RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN-example_api_key} RELAY_PROVIDER_API_KEY_SEPOLIA: ${RELAY_PROVIDER_API_KEY_SEPOLIA-example_api_key} depends_on: diff --git a/package.json b/package.json index 4ca2a8f378..90dbf3f086 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@safe-global/safe-deployments": "^1.33.0", "ajv": "^8.12.0", "ajv-formats": "^2.1.1", + "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", "nestjs-cls": "^4.3.0", "postgres": "^3.4.3", @@ -53,6 +54,7 @@ "@nestjs/testing": "^10.3.7", "@types/express": "^4.17.21", "@types/jest": "29.5.12", + "@types/jsonwebtoken": "^9", "@types/lodash": "^4.17.0", "@types/node": "^20.12.2", "@types/semver": "^7.5.8", diff --git a/src/config/configuration.validator.spec.ts b/src/config/configuration.validator.spec.ts index 5858f94330..64138a652b 100644 --- a/src/config/configuration.validator.spec.ts +++ b/src/config/configuration.validator.spec.ts @@ -17,6 +17,8 @@ describe('Configuration validator', () => { EMAIL_TEMPLATE_RECOVERY_TX: faker.string.alphanumeric(), EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX: faker.string.alphanumeric(), EMAIL_TEMPLATE_VERIFICATION_CODE: faker.string.alphanumeric(), + JWT_ISSUER: faker.lorem.word(), + JWT_SECRET: faker.string.alphanumeric(), RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN: faker.string.uuid(), RELAY_PROVIDER_API_KEY_SEPOLIA: faker.string.uuid(), }; @@ -46,6 +48,8 @@ describe('Configuration validator', () => { { key: 'EMAIL_TEMPLATE_RECOVERY_TX' }, { key: 'EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX' }, { key: 'EMAIL_TEMPLATE_VERIFICATION_CODE' }, + { key: 'JWT_ISSUER' }, + { key: 'JWT_SECRET' }, { key: 'RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN' }, { key: 'RELAY_PROVIDER_API_KEY_SEPOLIA' }, ])( @@ -75,6 +79,8 @@ describe('Configuration validator', () => { EMAIL_TEMPLATE_RECOVERY_TX: faker.string.alphanumeric(), EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX: faker.string.alphanumeric(), EMAIL_TEMPLATE_VERIFICATION_CODE: faker.string.alphanumeric(), + JWT_ISSUER: faker.lorem.word(), + JWT_SECRET: faker.string.alphanumeric(), RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN: faker.string.uuid(), RELAY_PROVIDER_API_KEY_SEPOLIA: faker.string.uuid(), }), diff --git a/src/config/configuration.validator.ts b/src/config/configuration.validator.ts index 340d8aed5a..f9a5907611 100644 --- a/src/config/configuration.validator.ts +++ b/src/config/configuration.validator.ts @@ -15,6 +15,8 @@ const ConfigurationSchema = z.object({ EMAIL_TEMPLATE_RECOVERY_TX: z.string(), EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX: z.string(), EMAIL_TEMPLATE_VERIFICATION_CODE: z.string(), + JWT_ISSUER: z.string(), + JWT_SECRET: z.string(), RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN: z.string(), RELAY_PROVIDER_API_KEY_SEPOLIA: z.string(), }); diff --git a/src/config/entities/__tests__/configuration.ts b/src/config/entities/__tests__/configuration.ts index 8236ba5bd4..60a4a963ba 100644 --- a/src/config/entities/__tests__/configuration.ts +++ b/src/config/entities/__tests__/configuration.ts @@ -178,6 +178,10 @@ export default (): ReturnType => ({ historyDebugLogs: false, }, httpClient: { requestTimeout: faker.number.int() }, + jwt: { + issuer: faker.lorem.word(), + secret: faker.string.alphanumeric(), + }, locking: { baseUri: faker.internet.url({ appendSlash: false }), }, diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index fdd52ef0e9..ece08140a9 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -181,6 +181,10 @@ export default () => ({ process.env.HTTP_CLIENT_REQUEST_TIMEOUT_MILLISECONDS ?? `${5_000}`, ), }, + jwt: { + issuer: process.env.JWT_ISSUER, + secret: process.env.JWT_SECRET, + }, locking: { // TODO: Add fallback value and requirement validation baseUri: process.env.LOCKING_PROVIDER_API_BASE_URI || '', diff --git a/src/datasources/jwt/jwt.module.ts b/src/datasources/jwt/jwt.module.ts new file mode 100644 index 0000000000..867948c797 --- /dev/null +++ b/src/datasources/jwt/jwt.module.ts @@ -0,0 +1,28 @@ +import * as jwt from 'jsonwebtoken'; +import { Global, Module } from '@nestjs/common'; +import { JwtService } from '@/datasources/jwt/jwt.service'; +import { IJwtService } from '@/datasources/jwt/jwt.service.interface'; + +// Use inferred type +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +function jwtClientFactory() { + return { + sign: jwt.sign, + verify: jwt.verify, + }; +} + +export type JwtClient = ReturnType; + +@Global() +@Module({ + providers: [ + { + provide: 'JwtClient', + useFactory: jwtClientFactory, + }, + { provide: IJwtService, useClass: JwtService }, + ], + exports: [IJwtService], +}) +export class JwtModule {} diff --git a/src/datasources/jwt/jwt.service.interface.ts b/src/datasources/jwt/jwt.service.interface.ts new file mode 100644 index 0000000000..4e834bd5d0 --- /dev/null +++ b/src/datasources/jwt/jwt.service.interface.ts @@ -0,0 +1,13 @@ +export const IJwtService = Symbol('IJwtService'); + +export interface IJwtService { + sign( + payload: T, + options: { + expiresIn?: number; + notBefore?: number; + }, + ): string; + + verify(token: string): T; +} diff --git a/src/datasources/jwt/jwt.service.spec.ts b/src/datasources/jwt/jwt.service.spec.ts new file mode 100644 index 0000000000..431f4dda00 --- /dev/null +++ b/src/datasources/jwt/jwt.service.spec.ts @@ -0,0 +1,74 @@ +import { fakeJson } from '@/__tests__/faker'; +import { FakeConfigurationService } from '@/config/__tests__/fake.configuration.service'; +import { JwtClient } from '@/datasources/jwt/jwt.module'; +import { JwtService } from '@/datasources/jwt/jwt.service'; +import { faker } from '@faker-js/faker'; + +const jwtClientMock: jest.MockedObjectDeep = jest.mocked({ + sign: jest.fn(), + verify: jest.fn(), +}); + +describe('JwtService', () => { + let service: JwtService; + let fakeConfigurationService: FakeConfigurationService; + + let jwtIssuer: string; + let jwtSecret: string; + + beforeEach(() => { + jest.resetAllMocks(); + + jwtIssuer = faker.lorem.word(); + jwtSecret = faker.string.alphanumeric(); + + fakeConfigurationService = new FakeConfigurationService(); + fakeConfigurationService.set('jwt.issuer', jwtIssuer); + fakeConfigurationService.set('jwt.secret', jwtSecret); + + service = new JwtService(jwtClientMock, fakeConfigurationService); + }); + + describe('sign', () => { + it('should sign a payload with the issuer', () => { + const payload = fakeJson(); + + service.sign(payload); + + expect(jwtClientMock.sign).toHaveBeenCalledTimes(1); + expect(jwtClientMock.sign).toHaveBeenCalledWith(payload, jwtSecret, { + issuer: jwtIssuer, + }); + }); + + it('should sign a payload with options and the issuer', () => { + const payload = fakeJson(); + const options = { + expiresIn: faker.number.int({ min: 1 }), + notBefore: faker.number.int({ min: 1 }), + }; + + service.sign(payload, options); + + expect(jwtClientMock.sign).toHaveBeenCalledTimes(1); + expect(jwtClientMock.sign).toHaveBeenCalledWith(payload, jwtSecret, { + ...options, + issuer: jwtIssuer, + }); + }); + }); + + describe('verify', () => { + it('should verify a token with the issuer and explicit return of payload', () => { + const token = fakeJson(); + + service.verify(token); + + expect(jwtClientMock.verify).toHaveBeenCalledTimes(1); + expect(jwtClientMock.verify).toHaveBeenCalledWith(token, jwtSecret, { + issuer: jwtIssuer, + complete: false, + }); + }); + }); +}); diff --git a/src/datasources/jwt/jwt.service.ts b/src/datasources/jwt/jwt.service.ts new file mode 100644 index 0000000000..8ee58ff3d8 --- /dev/null +++ b/src/datasources/jwt/jwt.service.ts @@ -0,0 +1,38 @@ +import { IConfigurationService } from '@/config/configuration.service.interface'; +import { JwtClient } from '@/datasources/jwt/jwt.module'; +import { IJwtService } from '@/datasources/jwt/jwt.service.interface'; +import { Inject, Injectable } from '@nestjs/common'; + +@Injectable() +export class JwtService implements IJwtService { + private readonly issuer: string; + private readonly secret: string; + + constructor( + @Inject('JwtClient') + private readonly client: JwtClient, + @Inject(IConfigurationService) + private readonly configurationService: IConfigurationService, + ) { + this.issuer = this.configurationService.getOrThrow('jwt.issuer'); + this.secret = this.configurationService.getOrThrow('jwt.secret'); + } + + sign( + payload: T, + options: { expiresIn?: number; notBefore?: number } = {}, + ): string { + return this.client.sign(payload, this.secret, { + ...options, + issuer: this.issuer, + }); + } + + verify(token: string): T { + return this.client.verify(token, this.secret, { + issuer: this.issuer, + // Return the content of the payload + complete: false, + }) as T; + } +} diff --git a/yarn.lock b/yarn.lock index 5a2afaa318..430476a2a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1845,6 +1845,15 @@ __metadata: languageName: node linkType: hard +"@types/jsonwebtoken@npm:^9": + version: 9.0.6 + resolution: "@types/jsonwebtoken@npm:9.0.6" + dependencies: + "@types/node": "npm:*" + checksum: 10/1f2145222f62da1b3dbfc586160c4f9685782a671f4a4f4a72151c773945fe25807fd88ed1c270536b76f49053ed932c5dbf714ea0ed77f785665abb75beef05 + languageName: node + linkType: hard + "@types/lodash@npm:^4.17.0": version: 4.17.0 resolution: "@types/lodash@npm:4.17.0" @@ -2858,6 +2867,13 @@ __metadata: languageName: node linkType: hard +"buffer-equal-constant-time@npm:1.0.1": + version: 1.0.1 + resolution: "buffer-equal-constant-time@npm:1.0.1" + checksum: 10/80bb945f5d782a56f374b292770901065bad21420e34936ecbe949e57724b4a13874f735850dd1cc61f078773c4fb5493a41391e7bda40d1fa388d6bd80daaab + languageName: node + linkType: hard + "buffer-from@npm:^1.0.0": version: 1.1.2 resolution: "buffer-from@npm:1.1.2" @@ -3625,6 +3641,15 @@ __metadata: languageName: node linkType: hard +"ecdsa-sig-formatter@npm:1.0.11": + version: 1.0.11 + resolution: "ecdsa-sig-formatter@npm:1.0.11" + dependencies: + safe-buffer: "npm:^5.0.1" + checksum: 10/878e1aab8a42773320bc04c6de420bee21aebd71810e40b1799880a8a1c4594bcd6adc3d4213a0fb8147d4c3f529d8f9a618d7f59ad5a9a41b142058aceda23f + languageName: node + linkType: hard + "ee-first@npm:1.1.1": version: 1.1.1 resolution: "ee-first@npm:1.1.1" @@ -5721,6 +5746,45 @@ __metadata: languageName: node linkType: hard +"jsonwebtoken@npm:^9.0.2": + version: 9.0.2 + resolution: "jsonwebtoken@npm:9.0.2" + dependencies: + jws: "npm:^3.2.2" + lodash.includes: "npm:^4.3.0" + lodash.isboolean: "npm:^3.0.3" + lodash.isinteger: "npm:^4.0.4" + lodash.isnumber: "npm:^3.0.3" + lodash.isplainobject: "npm:^4.0.6" + lodash.isstring: "npm:^4.0.1" + lodash.once: "npm:^4.0.0" + ms: "npm:^2.1.1" + semver: "npm:^7.5.4" + checksum: 10/6e9b6d879cec2b27f2f3a88a0c0973edc7ba956a5d9356b2626c4fddfda969e34a3832deaf79c3e1c6c9a525bc2c4f2c2447fa477f8ac660f0017c31a59ae96b + languageName: node + linkType: hard + +"jwa@npm:^1.4.1": + version: 1.4.1 + resolution: "jwa@npm:1.4.1" + dependencies: + buffer-equal-constant-time: "npm:1.0.1" + ecdsa-sig-formatter: "npm:1.0.11" + safe-buffer: "npm:^5.0.1" + checksum: 10/0bc002b71dd70480fedc7d442a4d2b9185a9947352a027dcb4935864ad2323c57b5d391adf968a3622b61e940cef4f3484d5813b95864539272d41cac145d6f3 + languageName: node + linkType: hard + +"jws@npm:^3.2.2": + version: 3.2.2 + resolution: "jws@npm:3.2.2" + dependencies: + jwa: "npm:^1.4.1" + safe-buffer: "npm:^5.0.1" + checksum: 10/70b016974af8a76d25030c80a0097b24ed5b17a9cf10f43b163c11cb4eb248d5d04a3fe48c0d724d2884c32879d878ccad7be0663720f46b464f662f7ed778fe + languageName: node + linkType: hard + "kleur@npm:^3.0.3": version: 3.0.3 resolution: "kleur@npm:3.0.3" @@ -5784,6 +5848,48 @@ __metadata: languageName: node linkType: hard +"lodash.includes@npm:^4.3.0": + version: 4.3.0 + resolution: "lodash.includes@npm:4.3.0" + checksum: 10/45e0a7c7838c931732cbfede6327da321b2b10482d5063ed21c020fa72b09ca3a4aa3bda4073906ab3f436cf36eb85a52ea3f08b7bab1e0baca8235b0e08fe51 + languageName: node + linkType: hard + +"lodash.isboolean@npm:^3.0.3": + version: 3.0.3 + resolution: "lodash.isboolean@npm:3.0.3" + checksum: 10/b70068b4a8b8837912b54052557b21fc4774174e3512ed3c5b94621e5aff5eb6c68089d0a386b7e801d679cd105d2e35417978a5e99071750aa2ed90bffd0250 + languageName: node + linkType: hard + +"lodash.isinteger@npm:^4.0.4": + version: 4.0.4 + resolution: "lodash.isinteger@npm:4.0.4" + checksum: 10/c971f5a2d67384f429892715550c67bac9f285604a0dd79275fd19fef7717aec7f2a6a33d60769686e436ceb9771fd95fe7fcb68ad030fc907d568d5a3b65f70 + languageName: node + linkType: hard + +"lodash.isnumber@npm:^3.0.3": + version: 3.0.3 + resolution: "lodash.isnumber@npm:3.0.3" + checksum: 10/913784275b565346255e6ae6a6e30b760a0da70abc29f3e1f409081585875105138cda4a429ff02577e1bc0a7ae2a90e0a3079a37f3a04c3d6c5aaa532f4cab2 + languageName: node + linkType: hard + +"lodash.isplainobject@npm:^4.0.6": + version: 4.0.6 + resolution: "lodash.isplainobject@npm:4.0.6" + checksum: 10/29c6351f281e0d9a1d58f1a4c8f4400924b4c79f18dfc4613624d7d54784df07efaff97c1ff2659f3e085ecf4fff493300adc4837553104cef2634110b0d5337 + languageName: node + linkType: hard + +"lodash.isstring@npm:^4.0.1": + version: 4.0.1 + resolution: "lodash.isstring@npm:4.0.1" + checksum: 10/eaac87ae9636848af08021083d796e2eea3d02e80082ab8a9955309569cb3a463ce97fd281d7dc119e402b2e7d8c54a23914b15d2fc7fff56461511dc8937ba0 + languageName: node + linkType: hard + "lodash.memoize@npm:4.x": version: 4.1.2 resolution: "lodash.memoize@npm:4.1.2" @@ -5798,6 +5904,13 @@ __metadata: languageName: node linkType: hard +"lodash.once@npm:^4.0.0": + version: 4.1.1 + resolution: "lodash.once@npm:4.1.1" + checksum: 10/202f2c8c3d45e401b148a96de228e50ea6951ee5a9315ca5e15733d5a07a6b1a02d9da1e7fdf6950679e17e8ca8f7190ec33cae47beb249b0c50019d753f38f3 + languageName: node + linkType: hard + "lodash@npm:4.17.21, lodash@npm:^4.17.21": version: 4.17.21 resolution: "lodash@npm:4.17.21" @@ -7070,7 +7183,7 @@ __metadata: languageName: node linkType: hard -"safe-buffer@npm:5.2.1, safe-buffer@npm:^5.1.0, safe-buffer@npm:~5.2.0": +"safe-buffer@npm:5.2.1, safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.0, safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" checksum: 10/32872cd0ff68a3ddade7a7617b8f4c2ae8764d8b7d884c651b74457967a9e0e886267d3ecc781220629c44a865167b61c375d2da6c720c840ecd73f45d5d9451 @@ -7101,6 +7214,7 @@ __metadata: "@safe-global/safe-deployments": "npm:^1.33.0" "@types/express": "npm:^4.17.21" "@types/jest": "npm:29.5.12" + "@types/jsonwebtoken": "npm:^9" "@types/lodash": "npm:^4.17.0" "@types/node": "npm:^20.12.2" "@types/semver": "npm:^7.5.8" @@ -7113,6 +7227,7 @@ __metadata: eslint-config-prettier: "npm:^9.1.0" husky: "npm:^9.0.11" jest: "npm:29.7.0" + jsonwebtoken: "npm:^9.0.2" lodash: "npm:^4.17.21" nestjs-cls: "npm:^4.3.0" postgres: "npm:^3.4.3"