diff --git a/README.md b/README.md index e0d9525..0dbdcb9 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ curl -X "POST" "https://keycloak.aam-digital.net/realms//protocol/op --data-urlencode "client_id=" \ --data-urlencode "client_secret=" \ --data-urlencode "grant_type=client_credentials" \ - --data-urlencode "scopes=openid reports_read reports_write" + --data-urlencode "scopes=openid reporting_read reporting_write" ``` Check API docs for the required "scopes". This returns a JWT access token required to provided as Bearer Token for any request to the API endpoints. Sample token: @@ -45,7 +45,7 @@ This returns a JWT access token required to provided as Bearer Token for any req "refresh_expires_in": 0, "token_type": "Bearer", "not-before-policy": 0, - "scope": "openid reports_read reports_write" + "scope": "openid reporting_read reporting_write" } ``` diff --git a/package-lock.json b/package-lock.json index 42d9a9d..c31eb12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@nestjs/common": "^10.3.3", "@nestjs/config": "^3.2.0", "@nestjs/core": "^10.3.3", + "@nestjs/jwt": "10.2.0", "@nestjs/platform-express": "^10.3.3", "@nestjs/schedule": "4.0.1", "@ntegral/nestjs-sentry": "^4.0.1", @@ -1999,6 +2000,26 @@ "fsevents": "~2.3.2" } }, + "node_modules/@nestjs/jwt": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-10.2.0.tgz", + "integrity": "sha512-x8cG90SURkEiLOehNaN2aRlotxT0KZESUliOPKKnjWiyJOcWurkF3w345WOX0P4MgFzUjGoZ1Sy0aZnxeihT0g==", + "dependencies": { + "@types/jsonwebtoken": "9.0.5", + "jsonwebtoken": "9.0.2" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/@nestjs/jwt/node_modules/@types/jsonwebtoken": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz", + "integrity": "sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@nestjs/mapped-types": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.0.2.tgz", @@ -2521,7 +2542,6 @@ "version": "20.11.20", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.20.tgz", "integrity": "sha512-7/rR21OS+fq8IyHTgtLkDK949uzsa6n8BkziAKtPVpugIkO6D+/ooXMvzXxDnZrmtXVfjb1bKQafYpb8s89LOg==", - "dev": true, "dependencies": { "undici-types": "~5.26.4" } @@ -3520,6 +3540,11 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -4188,6 +4213,14 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -6409,6 +6442,46 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -6484,6 +6557,36 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -6496,6 +6599,11 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -6713,8 +6821,7 @@ "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/multer": { "version": "1.4.4-lts.1", @@ -7652,7 +7759,6 @@ "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, "dependencies": { "lru-cache": "^6.0.0" }, @@ -7667,7 +7773,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "dependencies": { "yallist": "^4.0.0" }, @@ -7678,8 +7783,7 @@ "node_modules/semver/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/send": { "version": "0.18.0", @@ -8578,8 +8682,7 @@ "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" }, "node_modules/universalify": { "version": "2.0.1", diff --git a/package.json b/package.json index cfad1fc..2c46373 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@nestjs/common": "^10.3.3", "@nestjs/config": "^3.2.0", "@nestjs/core": "^10.3.3", + "@nestjs/jwt": "10.2.0", "@nestjs/platform-express": "^10.3.3", "@nestjs/schedule": "4.0.1", "@ntegral/nestjs-sentry": "^4.0.1", diff --git a/src/app.module.ts b/src/app.module.ts index 7df9f86..84fe54e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -9,6 +9,7 @@ import { ScheduleModule } from '@nestjs/schedule'; import { AppConfiguration } from './config/configuration'; import { ReportChangesModule } from './report-changes/report-changes.module'; import { NotificationModule } from './notification/notification.module'; +import { AuthModule } from './auth/auth.module'; const lowSeverityLevels: SeverityLevel[] = ['log', 'info']; @@ -35,6 +36,7 @@ const lowSeverityLevels: SeverityLevel[] = ['log', 'info']; ignoreEnvFile: false, load: [AppConfiguration], }), + AuthModule, SentryModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts new file mode 100644 index 0000000..f711d6c --- /dev/null +++ b/src/auth/auth.module.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { JwtAuthGuard } from './core/jwt-auth.guard'; +import { APP_GUARD } from '@nestjs/core'; +import { JwtConfigurationFactory } from './core/jwt.configuration'; +import { ConfigService } from '@nestjs/config'; +import { HttpModule } from '@nestjs/axios'; + +@Module({ + imports: [ + HttpModule, + JwtModule.registerAsync({ + global: true, + useFactory: JwtConfigurationFactory, + inject: [ConfigService], + }), + ], + providers: [ + JwtAuthGuard, + { + provide: APP_GUARD, + useClass: JwtAuthGuard, + }, + ], + exports: [JwtAuthGuard], +}) +export class AuthModule {} diff --git a/src/auth/core/jwt-auth.guard.ts b/src/auth/core/jwt-auth.guard.ts new file mode 100644 index 0000000..b83df2f --- /dev/null +++ b/src/auth/core/jwt-auth.guard.ts @@ -0,0 +1,121 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { Reflector } from '@nestjs/core'; +import { Scopes } from './scopes.decorator'; + +/** + * Represents a validated JwtTokenPayload + */ +export interface JwtTokenPayload { + exp?: string; + iat?: string; + jti?: string; + iss?: string; + sub?: string; + typ?: string; + azp?: string; // client-id + scope?: string; +} + +/** + * JwtAuthGuard + * + * Checks if a valid JWT token is sent within the request. + * + * Implemented checks (this order): + * - token set in Authentication header + * - expiration check + * - notBefore check + * - issuer public key + * - typ check for 'Bearer' + * - scope check + * + */ +@Injectable() +export class JwtAuthGuard implements CanActivate { + constructor( + private jwtService: JwtService, + private reflector: Reflector, + ) {} + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const token = this.extractTokenFromHeader(request); + + if (!token) { + console.debug(`[JwtAuthGuard]: No token found in Header`); + throw new UnauthorizedException('No token found in Header'); + } + + let payload: JwtTokenPayload; + + try { + payload = this.jwtService.verify(token, { + ignoreExpiration: false, + ignoreNotBefore: false, + }); + } catch (err: any) { + console.debug(`[JwtAuthGuard]: ${err.name} -> ${err.message}`); + throw new UnauthorizedException(`${err.message}`); + } + + if (payload.typ !== 'Bearer') { + console.debug(`[JwtAuthGuard]: Invalid 'typ'. Must be a 'Bearer' Token.`); + throw new UnauthorizedException( + "Invalid 'typ'. Must be a 'Bearer' TokenDecorator", + ); + } + + this.validateScope(context, payload); + + request['jwt-token-payload'] = payload; + + return true; + } + + private validateScope(context: ExecutionContext, payload: JwtTokenPayload) { + const neededScopes = this.reflector.get(Scopes, context.getHandler()); + const areScopesSufficient = this.areScopesSufficient( + neededScopes, + payload.scope, + ); + + if (!areScopesSufficient) { + console.debug(`[JwtAuthGuard]: Missing scope(s): ${neededScopes}`); + throw new UnauthorizedException(`Missing scope(s): ${neededScopes}`); + } + } + + private extractTokenFromHeader(request: Request): string | undefined { + const headers: any = request.headers; + const [type, token] = headers['authorization']?.split(' ') ?? []; + return type === 'Bearer' ? token : undefined; + } + + /** + * Returns true if user have all required scopes + * + * @param neededScopes all scopes required for this request e.g. ['scope-a', 'scope-b'] + * @param userScope all user scopes from jwt e.g. 'scope-a scope-b' + */ + private areScopesSufficient( + neededScopes: string[], + userScope: string | undefined, + ) { + if (userScope === undefined) { + return neededScopes.length === 0; + } + const userScopes = userScope.split(' '); + for (let i = 0; i < neededScopes.length; i++) { + if (userScopes.indexOf(neededScopes[i]) === -1) { + return false; + } + } + return true; + } +} diff --git a/src/auth/core/jwt.configuration.ts b/src/auth/core/jwt.configuration.ts new file mode 100644 index 0000000..3cc459d --- /dev/null +++ b/src/auth/core/jwt.configuration.ts @@ -0,0 +1,45 @@ +import { + JwtModuleOptions, + JwtVerifyOptions, +} from '@nestjs/jwt/dist/interfaces/jwt-module-options.interface'; +import { ConfigService } from '@nestjs/config'; +import axios from 'axios'; +import { HttpService } from '@nestjs/axios'; +import { firstValueFrom, map, switchMap } from 'rxjs'; +import { InternalServerErrorException } from '@nestjs/common'; + +export const JwtConfigurationFactory = ( + configService: ConfigService, +): Promise => { + const openIdConfigurationUrl = configService.getOrThrow( + 'OPENID_CONFIGURATION', + ); + + const axiosInstance = axios.create(); + const httpService = new HttpService(axiosInstance); + + return firstValueFrom( + httpService.get(openIdConfigurationUrl).pipe( + switchMap((openIdConfigResponse) => { + const issuer = openIdConfigResponse.data.issuer; + if (!issuer) { + throw new InternalServerErrorException( + `Could not load issuer from openid-configuration: ${openIdConfigurationUrl}`, + ); + } + return httpService.get(issuer); + }), + map((issuerResponse) => { + const rawPublicKey = issuerResponse.data.public_key; + if (!rawPublicKey) { + throw new InternalServerErrorException( + `Could not load public_key from issuer: ${openIdConfigurationUrl}`, + ); + } + return { + publicKey: `-----BEGIN PUBLIC KEY-----\n${rawPublicKey}\n-----END PUBLIC KEY-----`, + } as JwtVerifyOptions; + }), + ), + ); +}; diff --git a/src/auth/core/scopes.decorator.ts b/src/auth/core/scopes.decorator.ts new file mode 100644 index 0000000..e22c196 --- /dev/null +++ b/src/auth/core/scopes.decorator.ts @@ -0,0 +1,7 @@ +import { Reflector } from '@nestjs/core'; + +/** + * Annotate an endpoint to require a certain permission in the Auth token. + * All the values in the array are required to gain access. + */ +export const Scopes = Reflector.createDecorator(); diff --git a/src/auth/core/token.decorator.ts b/src/auth/core/token.decorator.ts new file mode 100644 index 0000000..c0a4034 --- /dev/null +++ b/src/auth/core/token.decorator.ts @@ -0,0 +1,12 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { JwtTokenPayload } from './jwt-auth.guard'; + +/** + * Extract token payload for advanced checks. + */ +export const Token = createParamDecorator( + (data: unknown, ctx: ExecutionContext): JwtTokenPayload | undefined => { + const request = ctx.switchToHttp().getRequest(); + return request['jwt-token-payload']; + }, +); diff --git a/src/config/app.yaml b/src/config/app.yaml index 83f5180..87f53dc 100644 --- a/src/config/app.yaml +++ b/src/config/app.yaml @@ -20,3 +20,5 @@ SQS_CLIENT: SCHEMA_DESIGN_CONFIG: /app/_design/sqlite:config REPORT_CHANGES_POLL_INTERVAL: 10000 + +OPENID_CONFIGURATION: http://localhost:8080/realms/dummy-realm/.well-known/openid-configuration diff --git a/src/notification/controller/webhook.controller.spec.ts b/src/notification/controller/webhook.controller.spec.ts index e4913f9..c38f581 100644 --- a/src/notification/controller/webhook.controller.spec.ts +++ b/src/notification/controller/webhook.controller.spec.ts @@ -2,14 +2,17 @@ import { Test, TestingModule } from '@nestjs/testing'; import { WebhookController } from './webhook.controller'; import { WebhookStorage } from '../storage/webhook-storage.service'; import { NotificationService } from '../core/notification.service'; +import { JwtService } from '@nestjs/jwt'; describe('WebhookController', () => { let controller: WebhookController; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ + imports: [], controllers: [WebhookController], providers: [ + { provide: JwtService, useValue: {} }, { provide: WebhookStorage, useValue: {} }, { provide: NotificationService, useValue: {} }, ], diff --git a/src/notification/controller/webhook.controller.ts b/src/notification/controller/webhook.controller.ts index fcc02d7..e11fa70 100644 --- a/src/notification/controller/webhook.controller.ts +++ b/src/notification/controller/webhook.controller.ts @@ -7,6 +7,7 @@ import { Headers, Param, Post, + UseGuards, } from '@nestjs/common'; import { defaultIfEmpty, map, Observable, zipAll } from 'rxjs'; import { Reference } from '../../domain/reference'; @@ -14,6 +15,8 @@ import { WebhookStorage } from '../storage/webhook-storage.service'; import { Webhook } from '../domain/webhook'; import { NotificationService } from '../core/notification.service'; import { CreateWebhookDto, WebhookDto } from './dtos'; +import { JwtAuthGuard } from '../../auth/core/jwt-auth.guard'; +import { Scopes } from '../../auth/core/scopes.decorator'; @Controller('/api/v1/reporting/webhook') export class WebhookController { @@ -23,10 +26,9 @@ export class WebhookController { ) {} @Get() - fetchWebhooksOfUser( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - @Headers('Authorization') token: string, - ): Observable { + @UseGuards(JwtAuthGuard) + @Scopes(['reporting_read']) + fetchWebhooksOfUser(): Observable { return this.webhookStorage.fetchAllWebhooks('user-token').pipe( map((webhooks) => webhooks.map((webhook) => this.mapToDto(webhook))), zipAll(), @@ -35,6 +37,8 @@ export class WebhookController { } @Get('/:webhookId') + @UseGuards(JwtAuthGuard) + @Scopes(['reporting_read']) fetchWebhook( @Headers('Authorization') token: string, @Param('webhookId') webhookId: string, @@ -51,6 +55,8 @@ export class WebhookController { } @Post() + @UseGuards(JwtAuthGuard) + @Scopes(['reporting_write']) createWebhook( @Headers('Authorization') token: string, @Body() requestBody: CreateWebhookDto, @@ -69,6 +75,8 @@ export class WebhookController { } @Post('/:webhookId/subscribe/report/:reportId') + @UseGuards(JwtAuthGuard) + @Scopes(['reporting_write']) subscribeReportNotifications( @Headers('Authorization') token: string, @Param('webhookId') webhookId: string, @@ -83,6 +91,8 @@ export class WebhookController { } @Delete('/:webhookId/subscribe/report/:reportId') + @UseGuards(JwtAuthGuard) + @Scopes(['reporting_write']) unsubscribeReportNotifications( @Headers('Authorization') token: string, @Param('webhookId') webhookId: string, diff --git a/src/report/controller/report-calculation.controller.spec.ts b/src/report/controller/report-calculation.controller.spec.ts index 8bd270f..4799fe3 100644 --- a/src/report/controller/report-calculation.controller.spec.ts +++ b/src/report/controller/report-calculation.controller.spec.ts @@ -8,6 +8,7 @@ import { ReportCalculationRepository } from '../repository/report-calculation-re import { ReportRepository } from '../repository/report-repository.service'; import { ConfigService } from '@nestjs/config'; import { CreateReportCalculationUseCase } from '../core/use-cases/create-report-calculation-use-case.service'; +import { JwtService } from '@nestjs/jwt'; describe('ReportCalculationController', () => { let controller: ReportCalculationController; @@ -23,6 +24,7 @@ describe('ReportCalculationController', () => { ReportCalculationRepository, ReportRepository, CreateReportCalculationUseCase, + { provide: JwtService, useValue: {} }, { provide: ConfigService, useValue: { diff --git a/src/report/controller/report-calculation.controller.ts b/src/report/controller/report-calculation.controller.ts index 2cf83b2..a67baaf 100644 --- a/src/report/controller/report-calculation.controller.ts +++ b/src/report/controller/report-calculation.controller.ts @@ -1,11 +1,11 @@ import { Controller, Get, - Headers, InternalServerErrorException, NotFoundException, Param, Post, + UseGuards, } from '@nestjs/common'; import { ReportingStorage } from '../storage/reporting-storage.service'; import { map, Observable, switchMap } from 'rxjs'; @@ -16,6 +16,8 @@ import { CreateReportCalculationFailed, CreateReportCalculationUseCase, } from '../core/use-cases/create-report-calculation-use-case.service'; +import { JwtAuthGuard } from '../../auth/core/jwt-auth.guard'; +import { Scopes } from '../../auth/core/scopes.decorator'; @Controller('/api/v1/reporting') export class ReportCalculationController { @@ -25,10 +27,9 @@ export class ReportCalculationController { ) {} @Post('/report-calculation/report/:reportId') - startCalculation( - @Headers('Authorization') token: string, - @Param('reportId') reportId: string, - ): Observable { + @UseGuards(JwtAuthGuard) + @Scopes(['reporting_write']) + startCalculation(@Param('reportId') reportId: string): Observable { return this.reportStorage.fetchReport(new Reference(reportId)).pipe( switchMap((value) => { if (!value) { @@ -50,16 +51,18 @@ export class ReportCalculationController { } @Get('/report-calculation/report/:reportId') + @UseGuards(JwtAuthGuard) + @Scopes(['reporting_read']) fetchReportCalculations( - @Headers('Authorization') token: string, @Param('reportId') reportId: string, ): Observable { return this.reportStorage.fetchCalculations(new Reference(reportId)); } @Get('/report-calculation/:calculationId') + @UseGuards(JwtAuthGuard) + @Scopes(['reporting_read']) fetchRun( - @Headers('Authorization') token: string, @Param('calculationId') calculationId: string, ): Observable { return this.reportStorage @@ -71,7 +74,7 @@ export class ReportCalculationController { } return this.reportStorage - .fetchReport(new Reference(calculation.report.id), token) + .fetchReport(new Reference(calculation.report.id)) .pipe( map((report) => { if (!report) { @@ -86,8 +89,9 @@ export class ReportCalculationController { } @Get('/report-calculation/:calculationId/data') + @UseGuards(JwtAuthGuard) + @Scopes(['reporting_read']) fetchRunData( - @Headers('Authorization') token: string, @Param('calculationId') calculationId: string, ): Observable { return this.reportStorage.fetchData(new Reference(calculationId)).pipe( diff --git a/src/report/controller/report.controller.spec.ts b/src/report/controller/report.controller.spec.ts index 6863be1..b2a664b 100644 --- a/src/report/controller/report.controller.spec.ts +++ b/src/report/controller/report.controller.spec.ts @@ -6,6 +6,7 @@ import { ReportCalculationRepository } from '../repository/report-calculation-re import { HttpModule } from '@nestjs/axios'; import { ConfigService } from '@nestjs/config'; import { CouchDbClient } from '../../couchdb/couch-db-client.service'; +import { JwtService } from '@nestjs/jwt'; describe('ReportController', () => { let service: ReportController; @@ -19,6 +20,7 @@ describe('ReportController', () => { ReportController, ReportRepository, ReportCalculationRepository, + { provide: JwtService, useValue: {} }, { provide: ConfigService, useValue: { diff --git a/src/report/controller/report.controller.ts b/src/report/controller/report.controller.ts index c53ad60..e24216d 100644 --- a/src/report/controller/report.controller.ts +++ b/src/report/controller/report.controller.ts @@ -1,9 +1,9 @@ import { Controller, Get, - Headers, NotFoundException, Param, + UseGuards, } from '@nestjs/common'; import { defaultIfEmpty, @@ -17,28 +17,31 @@ import { ReportingStorage } from '../storage/reporting-storage.service'; import { ReportDto } from './dtos'; import { Reference } from '../../domain/reference'; import { Report } from '../../domain/report'; +import { JwtAuthGuard } from '../../auth/core/jwt-auth.guard'; +import { Scopes } from '../../auth/core/scopes.decorator'; @Controller('/api/v1/reporting') export class ReportController { constructor(private reportStorage: ReportingStorage) {} @Get('/report') - fetchReports( - @Headers('Authorization') token: string, - ): Observable { - return this.reportStorage.fetchAllReports(token, 'sql').pipe( - mergeMap((reports) => reports.map((report) => this.getReportDto(report))), + @UseGuards(JwtAuthGuard) + @Scopes(['reporting_read']) + fetchReports(): Observable { + return this.reportStorage.fetchAllReports('sql').pipe( + mergeMap((reports: Report[]) => + reports.map((report) => this.getReportDto(report)), + ), zipAll(), defaultIfEmpty([]), ); } @Get('/report/:reportId') - fetchReport( - @Headers('Authorization') token: string, - @Param('reportId') reportId: string, - ): Observable { - return this.reportStorage.fetchReport(new Reference(reportId), token).pipe( + @UseGuards(JwtAuthGuard) + @Scopes(['reporting_read']) + fetchReport(@Param('reportId') reportId: string): Observable { + return this.reportStorage.fetchReport(new Reference(reportId)).pipe( switchMap((report) => { if (!report) { throw new NotFoundException(); diff --git a/src/report/repository/report-repository.service.ts b/src/report/repository/report-repository.service.ts index caced81..468e94b 100644 --- a/src/report/repository/report-repository.service.ts +++ b/src/report/repository/report-repository.service.ts @@ -32,7 +32,7 @@ interface FetchReportsResponse { export class ReportRepository { constructor(private couchDbClient: CouchDbClient) {} - fetchReports(authToken?: string): Observable { + fetchReports(): Observable { const config: any = { params: { include_docs: true, @@ -41,12 +41,6 @@ export class ReportRepository { }, }; - if (authToken) { - config.headers = { - Authorization: authToken, - }; - } - return this.couchDbClient .getDatabaseDocument({ documentId: '_all_docs', @@ -61,18 +55,9 @@ export class ReportRepository { ); } - fetchReport( - reportId: string, - authToken?: string | undefined, - ): Observable { + fetchReport(reportId: string): Observable { const config: any = {}; - if (authToken) { - config.headers = { - Authorization: authToken, - }; - } - return this.couchDbClient .getDatabaseDocument({ documentId: reportId, diff --git a/src/report/storage/reporting-storage.service.ts b/src/report/storage/reporting-storage.service.ts index 85ad176..4f284b0 100644 --- a/src/report/storage/reporting-storage.service.ts +++ b/src/report/storage/reporting-storage.service.ts @@ -24,8 +24,8 @@ export class ReportingStorage implements IReportStorage { reportCalculationUpdated = new Subject(); - fetchAllReports(authToken: string, mode = 'sql'): Observable { - return this.reportRepository.fetchReports(authToken).pipe( + fetchAllReports(mode = 'sql'): Observable { + return this.reportRepository.fetchReports().pipe( map((response) => { if (!response || !response.rows) { return []; @@ -49,11 +49,8 @@ export class ReportingStorage implements IReportStorage { ); } - fetchReport( - reportRef: Reference, - authToken?: string | undefined, - ): Observable { - return this.reportRepository.fetchReport(reportRef.id, authToken).pipe( + fetchReport(reportRef: Reference): Observable { + return this.reportRepository.fetchReport(reportRef.id).pipe( map((report) => { return new Report( report._id,