From 22fa52497f9c5e2e3f38d0de1cbe8e4ca7094985 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gunnlaugur=20Gu=C3=B0mundsson?= Date: Thu, 3 Oct 2024 14:19:05 +0000 Subject: [PATCH 01/11] gm-delegation-webhook --- .../auth/admin-api/infra/auth-admin-api.ts | 2 + .../delegation-admin.controller.ts | 26 ++- .../test/delegation-admin.auth.spec.ts | 89 ++++++++- .../delegations/test/delegation-admin.spec.ts | 35 +++- apps/services/auth/admin-api/src/main.ts | 12 ++ .../admin/delegation-admin-custom.service.ts | 173 ++++++++++++------ .../src/lib/delegations/constants/zendesk.ts | 2 + libs/auth-nest-tools/src/index.ts | 1 + .../src/lib/zendeskAuth.guard.ts | 37 ++++ libs/infra-nest-server/src/lib/bootstrap.ts | 4 + libs/infra-nest-server/src/lib/types.ts | 7 + libs/testing/nest/src/lib/setup.ts | 3 + libs/testing/nest/src/lib/testServer.ts | 13 ++ 13 files changed, 348 insertions(+), 56 deletions(-) create mode 100644 libs/auth-nest-tools/src/lib/zendeskAuth.guard.ts diff --git a/apps/services/auth/admin-api/infra/auth-admin-api.ts b/apps/services/auth/admin-api/infra/auth-admin-api.ts index 376206480242..0fc25a62d5d4 100644 --- a/apps/services/auth/admin-api/infra/auth-admin-api.ts +++ b/apps/services/auth/admin-api/infra/auth-admin-api.ts @@ -74,6 +74,8 @@ export const serviceSetup = (): ServiceBuilder<'services-auth-admin-api'> => { .secrets({ ZENDESK_CONTACT_FORM_EMAIL: '/k8s/api/ZENDESK_CONTACT_FORM_EMAIL', ZENDESK_CONTACT_FORM_TOKEN: '/k8s/api/ZENDESK_CONTACT_FORM_TOKEN', + ZENDESK_WEBHOOK_SECRET_GENERAL_MANDATE: + '/k8s/services-auth/ZENDESK_WEBHOOK_SECRET_GENERAL_MANDATE', CLIENT_SECRET_ENCRYPTION_KEY: '/k8s/services-auth/admin-api/CLIENT_SECRET_ENCRYPTION_KEY', IDENTITY_SERVER_CLIENT_SECRET: diff --git a/apps/services/auth/admin-api/src/app/v2/delegations/delegation-admin.controller.ts b/apps/services/auth/admin-api/src/app/v2/delegations/delegation-admin.controller.ts index 4ab51563c17a..5cdc8224393a 100644 --- a/apps/services/auth/admin-api/src/app/v2/delegations/delegation-admin.controller.ts +++ b/apps/services/auth/admin-api/src/app/v2/delegations/delegation-admin.controller.ts @@ -11,11 +11,13 @@ import { import { ApiTags } from '@nestjs/swagger' import { + BypassAuth, CurrentUser, IdsUserGuard, Scopes, ScopesGuard, User, + ZendeskAuthGuard, } from '@island.is/auth-nest-tools' import { CreatePaperDelegationDto, @@ -30,9 +32,10 @@ import flatMap from 'lodash/flatMap' import { isDefined } from '@island.is/shared/utils' const namespace = '@island.is/auth/delegation-admin' +const ZENDESK_WEBHOOK_SECRET_GENERAL_MANDATE = + process.env.ZENDESK_WEBHOOK_SECRET_GENERAL_MANDATE ?? '' @UseGuards(IdsUserGuard, ScopesGuard) -@Scopes(DelegationAdminScopes.read) @ApiTags('delegation-admin') @Controller('delegation-admin') @Audit({ namespace }) @@ -42,6 +45,7 @@ export class DelegationAdminController { private readonly auditService: AuditService, ) {} + @Scopes(DelegationAdminScopes.read) @Get() @Documentation({ response: { status: 200, type: DelegationAdminCustomDto }, @@ -91,6 +95,26 @@ export class DelegationAdminController { ) } + @BypassAuth() + @UseGuards(ZendeskAuthGuard(ZENDESK_WEBHOOK_SECRET_GENERAL_MANDATE)) + @Post(':zendeskId') + @Documentation({ + response: { status: 201, type: DelegationDTO }, + request: { + params: { + zendeskId: { + required: true, + description: 'The id of the zendesk ticket containing the delegation', + }, + }, + }, + }) + createByZendeskId( + @Param('zendeskId') zendeskId: string, + ): Promise { + return this.delegationAdminService.createDelegationByZendeskId(zendeskId) + } + @Delete(':delegationId') @Scopes(DelegationAdminScopes.admin) @Documentation({ diff --git a/apps/services/auth/admin-api/src/app/v2/delegations/test/delegation-admin.auth.spec.ts b/apps/services/auth/admin-api/src/app/v2/delegations/test/delegation-admin.auth.spec.ts index f97758aaf310..db6293c0a689 100644 --- a/apps/services/auth/admin-api/src/app/v2/delegations/test/delegation-admin.auth.spec.ts +++ b/apps/services/auth/admin-api/src/app/v2/delegations/test/delegation-admin.auth.spec.ts @@ -1,4 +1,5 @@ import request from 'supertest' +import bodyParser from 'body-parser' import { getRequestMethod, @@ -11,7 +12,8 @@ import { User } from '@island.is/auth-nest-tools' import { FixtureFactory } from '@island.is/services/auth/testing' import { createCurrentUser } from '@island.is/testing/fixtures' import { DelegationAdminScopes } from '@island.is/auth/scopes' -import { SequelizeConfigService } from '@island.is/auth-api-lib' +import { DelegationDTO, SequelizeConfigService } from '@island.is/auth-api-lib' +import { DelegationAdminCustomService } from '@island.is/auth-api-lib' import { AppModule } from '../../../app.module' @@ -132,4 +134,89 @@ describe('withoutAuth and permissions', () => { app.cleanUp() }, ) + + describe('POST /delegation-admin/:zendeskId', () => { + let app: TestApp + let server: request.SuperTest + let delegationAdminService: DelegationAdminCustomService + + beforeEach(async () => { + app = await setupAppWithoutAuth({ + AppModule, + SequelizeConfigService, + dbType: 'postgres', + beforeServerStart: async (app) => { + await new Promise((resolve) => + resolve( + app.use( + bodyParser.json({ + verify: (req: any, res, buf) => { + if (buf && buf.length) { + req.rawBody = buf + } + }, + }), + ), + ), + ) + }, + }) + + server = request(app.getHttpServer()) + + delegationAdminService = app.get(DelegationAdminCustomService) + + jest + .spyOn(delegationAdminService, 'createDelegationByZendeskId') + .mockImplementation(() => Promise.resolve({} as DelegationDTO)) + }) + + afterEach(() => { + app.cleanUp() + }) + + it('POST /delegation-admin/:zendeskId should return 403 Forbidden when user does not have correct headers for the body', async () => { + // Act + const res = await getRequestMethod( + server, + 'POST', + )('/delegation-admin/123') + .send({ + custom: 'Incorrect body', + }) + .set( + 'x-zendesk-webhook-signature', + '6sUtGV8C8OdoGgCdsV2xRm3XeskZ33Bc5124RiAK4Q4=', + ) + .set('x-zendesk-webhook-signature-timestamp', '2024-10-02T14:21:04Z') + + // Assert + expect(res.status).toEqual(403) + expect(res.body).toMatchObject({ + status: 403, + type: 'https://httpstatuses.org/403', + title: 'Forbidden', + detail: 'Forbidden resource', + }) + }) + + it('POST /delegation-admin/:zendeskId should return 201 since the correct headers are set for that body', async () => { + // Act + const res = await getRequestMethod( + server, + 'POST', + )('/delegation-admin/123') + .send({ + custom: 'test', + }) + .set( + 'x-zendesk-webhook-signature', + '6sUtGV8C8OdoGgCdsV2xRm3XeskZ33Bc5124RiAK4Q4=', + ) + .set('x-zendesk-webhook-signature-timestamp', '2024-10-02T14:21:04Z') + + // Assert + expect(res.status).toEqual(201) + }) + }) }) diff --git a/apps/services/auth/admin-api/src/app/v2/delegations/test/delegation-admin.spec.ts b/apps/services/auth/admin-api/src/app/v2/delegations/test/delegation-admin.spec.ts index 718b6427b800..19cc29c1c92f 100644 --- a/apps/services/auth/admin-api/src/app/v2/delegations/test/delegation-admin.spec.ts +++ b/apps/services/auth/admin-api/src/app/v2/delegations/test/delegation-admin.spec.ts @@ -224,15 +224,25 @@ describe('DelegationAdmin - With authentication', () => { const mockZendeskService = ( toNationalId: string, fromNationalId: string, + info?: { + tags?: string[] + status?: TicketStatus + }, ) => { + const { tags, status } = { + tags: [DELEGATION_TAG], + status: TicketStatus.Solved, + ...info, + } + zendeskServiceApiSpy = jest .spyOn(zendeskService, 'getTicket') .mockImplementation((ticketId: string) => { return new Promise((resolve) => resolve({ id: ticketId, - tags: [DELEGATION_TAG], - status: TicketStatus.Solved, + tags: tags, + status: status, custom_fields: [ { id: ZENDESK_CUSTOM_FIELDS.DelegationToReferenceId, @@ -328,5 +338,26 @@ describe('DelegationAdmin - With authentication', () => { // Assert expect(res.status).toEqual(400) }) + + it('POST /delegation-admin should not create delegation with incorrect zendesk ticket status', async () => { + // Arrange + mockZendeskService(toNationalId, fromNationalId, { + status: TicketStatus.Open, + }) + + const delegation: CreatePaperDelegationDto = { + toNationalId, + fromNationalId, + referenceId: 'ref1', + } + + // Act + const res = await getRequestMethod( + server, + 'POST', + )('/delegation-admin').send(delegation) + + expect(res.status).toEqual(400) + }) }) }) diff --git a/apps/services/auth/admin-api/src/main.ts b/apps/services/auth/admin-api/src/main.ts index 5b9ad800db75..03919e51b2e6 100644 --- a/apps/services/auth/admin-api/src/main.ts +++ b/apps/services/auth/admin-api/src/main.ts @@ -3,6 +3,7 @@ import { bootstrap } from '@island.is/infra-nest-server' import { AppModule } from './app/app.module' import { environment as env } from './environments' import { openApi } from './openApi' +import bodyParser from 'body-parser' bootstrap({ appModule: AppModule, @@ -14,4 +15,15 @@ bootstrap({ healthCheck: { database: true, }, + beforeServerStart: async (app) => { + app.use( + bodyParser.json({ + verify: (req: any, res, buf) => { + if (buf && buf.length) { + req.rawBody = buf + } + }, + }), + ) + }, }) diff --git a/libs/auth-api-lib/src/lib/delegations/admin/delegation-admin-custom.service.ts b/libs/auth-api-lib/src/lib/delegations/admin/delegation-admin-custom.service.ts index 99f41074d6e3..9da454cb1df2 100644 --- a/libs/auth-api-lib/src/lib/delegations/admin/delegation-admin-custom.service.ts +++ b/libs/auth-api-lib/src/lib/delegations/admin/delegation-admin-custom.service.ts @@ -46,9 +46,11 @@ export class DelegationAdminCustomService { private sequelize: Sequelize, ) {} - private getNationalIdsFromZendeskTicket(ticket: Ticket): { + private getZendeskCustomFields(ticket: Ticket): { fromReferenceId: string toReferenceId: string + validTo: string | null + createdByNationalId: string | null } { const fromReferenceId = ticket.custom_fields.find( (field) => field.id === ZENDESK_CUSTOM_FIELDS.DelegationFromReferenceId, @@ -56,6 +58,12 @@ export class DelegationAdminCustomService { const toReferenceId = ticket.custom_fields.find( (field) => field.id === ZENDESK_CUSTOM_FIELDS.DelegationToReferenceId, ) + const validTo = ticket.custom_fields.find( + (field) => field.id === ZENDESK_CUSTOM_FIELDS.DelegationValidToId, + ) + const createdById = ticket.custom_fields.find( + (field) => field.id === ZENDESK_CUSTOM_FIELDS.DelegationCreatedById, + ) if (!fromReferenceId || !toReferenceId) { throw new BadRequestException({ @@ -67,6 +75,8 @@ export class DelegationAdminCustomService { return { fromReferenceId: fromReferenceId.value, toReferenceId: toReferenceId.value, + createdByNationalId: createdById?.value ?? null, + validTo: validTo?.value ?? null, } } @@ -143,6 +153,32 @@ export class DelegationAdminCustomService { } } + async createDelegationByZendeskId(zendeskId: string): Promise { + const zendeskCase = await this.zendeskService.getTicket(zendeskId) + + const { fromReferenceId, toReferenceId, validTo, createdByNationalId } = + this.getZendeskCustomFields(zendeskCase) + + if (!createdByNationalId) { + throw new BadRequestException({ + message: 'Zendesk ticket is missing created by national id', + error: ErrorCodes.ZENDESK_CUSTOM_FIELDS_MISSING, + }) + } + + this.verifyZendeskTicket(zendeskCase, fromReferenceId, toReferenceId) + + const newDelegation = await this.insertDelegation({ + fromNationalId: fromReferenceId, + toNationalId: toReferenceId, + referenceId: zendeskId, + validTo: this.formatZendeskDate(validTo), + createdBy: createdByNationalId, + }) + + return newDelegation.toDTO(AuthDelegationType.GeneralMandate) + } + async createDelegation( user: User, delegation: CreatePaperDelegationDto, @@ -156,59 +192,17 @@ export class DelegationAdminCustomService { delegation.referenceId, ) - if (!zendeskCase.tags.includes(DELEGATION_TAG)) { - throw new BadRequestException({ - message: 'Zendesk case is missing required tag', - error: ErrorCodes.ZENDESK_TAG_MISSING, - }) - } - - if (zendeskCase.status !== TicketStatus.Solved) { - throw new BadRequestException({ - message: 'Zendesk case is not solved', - error: ErrorCodes.ZENDESK_STATUS, - }) - } - - const { fromReferenceId, toReferenceId } = - this.getNationalIdsFromZendeskTicket(zendeskCase) - - if ( - fromReferenceId !== delegation.fromNationalId || - toReferenceId !== delegation.toNationalId - ) { - throw new BadRequestException({ - message: 'National Ids do not match the Zendesk ticket', - error: ErrorCodes.ZENDESK_NATIONAL_IDS_MISMATCH, - }) - } - - const [fromDisplayName, toName] = await Promise.all([ - this.namesService.getPersonName(delegation.fromNationalId), - this.namesService.getPersonName(delegation.toNationalId), - ]) - - const newDelegation = await this.delegationModel.create( - { - id: uuid(), - toNationalId: delegation.toNationalId, - fromNationalId: delegation.fromNationalId, - createdByNationalId: user.actor?.nationalId ?? user.nationalId, - referenceId: delegation.referenceId, - toName, - fromDisplayName, - delegationDelegationTypes: [ - { - delegationTypeId: AuthDelegationType.GeneralMandate, - validTo: delegation.validTo, - }, - ] as DelegationDelegationType[], - }, - { - include: [this.delegationDelegationTypeModel], - }, + this.verifyZendeskTicket( + zendeskCase, + delegation.fromNationalId, + delegation.toNationalId, ) + const newDelegation = await this.insertDelegation({ + ...delegation, + createdBy: user.actor?.nationalId ?? user.nationalId, + }) + // Index delegations for the toNationalId void this.indexDelegations(delegation.toNationalId, user) @@ -285,4 +279,79 @@ export class DelegationAdminCustomService { auth, ) } + + private async insertDelegation( + delegation: CreatePaperDelegationDto & { + createdBy: string + }, + ): Promise { + const [fromDisplayName, toName] = await Promise.all([ + this.namesService.getPersonName(delegation.fromNationalId), + this.namesService.getPersonName(delegation.toNationalId), + ]) + + return this.sequelize.transaction(async (transaction) => { + return this.delegationModel.create( + { + id: uuid(), + toNationalId: delegation.toNationalId, + fromNationalId: delegation.fromNationalId, + createdByNationalId: delegation.createdBy, + referenceId: delegation.referenceId, + toName, + fromDisplayName, + delegationDelegationTypes: [ + { + delegationTypeId: AuthDelegationType.GeneralMandate, + validTo: delegation.validTo ? new Date(delegation.validTo) : null, + }, + ] as DelegationDelegationType[], + }, + { + transaction, + include: [this.delegationDelegationTypeModel], + }, + ) + }) + } + + private verifyZendeskTicket( + ticket: Ticket, + fromNationalId: string, + toNationalId: string, + ) { + if (!ticket.tags.includes(DELEGATION_TAG)) { + throw new BadRequestException({ + message: 'Zendesk case is missing required tag', + error: ErrorCodes.ZENDESK_TAG_MISSING, + }) + } + + if (ticket.status !== TicketStatus.Solved) { + throw new BadRequestException({ + message: 'Zendesk case is not solved', + error: ErrorCodes.ZENDESK_STATUS, + }) + } + + const { fromReferenceId, toReferenceId } = + this.getZendeskCustomFields(ticket) + + if (fromReferenceId !== fromNationalId || toReferenceId !== toNationalId) { + throw new BadRequestException({ + message: 'National Ids do not match the Zendesk ticket', + error: ErrorCodes.ZENDESK_NATIONAL_IDS_MISMATCH, + }) + } + } + + private formatZendeskDate(date: string | null): Date | null { + if (!date) { + return null + } + + const [day, month, year] = date.split('.').map(Number) + + return new Date(year, month - 1, day) + } } diff --git a/libs/auth-api-lib/src/lib/delegations/constants/zendesk.ts b/libs/auth-api-lib/src/lib/delegations/constants/zendesk.ts index 75b1df95fc5c..b6d26abce195 100644 --- a/libs/auth-api-lib/src/lib/delegations/constants/zendesk.ts +++ b/libs/auth-api-lib/src/lib/delegations/constants/zendesk.ts @@ -2,4 +2,6 @@ export const DELEGATION_TAG = 'umsokn_um_umboð_a_mínum_síðum' export const ZENDESK_CUSTOM_FIELDS = { DelegationToReferenceId: 21401464004498, DelegationFromReferenceId: 21401435545234, + DelegationValidToId: 21683921674002, + DelegationCreatedById: 21718016990866, } diff --git a/libs/auth-nest-tools/src/index.ts b/libs/auth-nest-tools/src/index.ts index 6980553c9229..613be5a55f12 100644 --- a/libs/auth-nest-tools/src/index.ts +++ b/libs/auth-nest-tools/src/index.ts @@ -13,5 +13,6 @@ export * from './lib/auth' export * from './lib/auth.middleware' export * from './lib/bypass-auth.decorator' export * from './lib/authHeader.middleware' +export * from './lib/zendeskAuth.guard' export type { GraphQLContext } from './lib/graphql.context' export { getRequest } from './lib/getRequest' diff --git a/libs/auth-nest-tools/src/lib/zendeskAuth.guard.ts b/libs/auth-nest-tools/src/lib/zendeskAuth.guard.ts new file mode 100644 index 000000000000..834d0fa2feae --- /dev/null +++ b/libs/auth-nest-tools/src/lib/zendeskAuth.guard.ts @@ -0,0 +1,37 @@ +import { Injectable, CanActivate, ExecutionContext, Type } from '@nestjs/common' +import { Request } from 'express' +import * as crypto from 'crypto' + +const SIGNING_SECRET_ALGORITHM = 'sha256' + +export function ZendeskAuthGuard(signingSecret: string): Type { + @Injectable() + class ZendeskAuthGuardMixin implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request: Request = context.switchToHttp().getRequest() + + const signature = request.headers['x-zendesk-webhook-signature'] as string + const timestamp = request.headers[ + 'x-zendesk-webhook-signature-timestamp' + ] as string + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + const body = request.rawBody?.toString() ?? '' + + return this.isValidSignature(signature, body, timestamp) + } + + isValidSignature( + signature: string, + body: string, + timestamp: string, + ): boolean { + const hmac = crypto.createHmac(SIGNING_SECRET_ALGORITHM, signingSecret) + const sig = hmac.update(timestamp + body).digest('base64') + + return Buffer.compare(Buffer.from(signature), Buffer.from(sig)) === 0 + } + } + + return ZendeskAuthGuardMixin +} diff --git a/libs/infra-nest-server/src/lib/bootstrap.ts b/libs/infra-nest-server/src/lib/bootstrap.ts index edc410b7a285..68111c96ed99 100644 --- a/libs/infra-nest-server/src/lib/bootstrap.ts +++ b/libs/infra-nest-server/src/lib/bootstrap.ts @@ -127,6 +127,10 @@ export const bootstrap = async ( }) } + if (options.beforeServerStart) { + await options.beforeServerStart(app) + } + const serverPort = process.env.PORT ? parseInt(process.env.PORT, 10) : options.port ?? 3333 diff --git a/libs/infra-nest-server/src/lib/types.ts b/libs/infra-nest-server/src/lib/types.ts index f0c6b054b20b..a23579c3ab09 100644 --- a/libs/infra-nest-server/src/lib/types.ts +++ b/libs/infra-nest-server/src/lib/types.ts @@ -48,6 +48,13 @@ export type RunServerOptions = { */ enableVersioning?: boolean + /** + * Hook to run before server is started. + * @param app The nest application instance. + * @returns a promise that resolves when the hook is done. + */ + beforeServerStart?: (app: INestApplication) => Promise + /** * Configures metrics collection and starts metric server. Default: true. */ diff --git a/libs/testing/nest/src/lib/setup.ts b/libs/testing/nest/src/lib/setup.ts index c15f2b8ba899..4c7c6f38bb79 100644 --- a/libs/testing/nest/src/lib/setup.ts +++ b/libs/testing/nest/src/lib/setup.ts @@ -14,6 +14,7 @@ interface SetupOptions { user?: User dbType?: 'sqlite' | 'postgres' override?: (builder: TestingModuleBuilder) => TestingModuleBuilder + beforeServerStart?: (app: TestApp) => Promise | undefined } export const setupApp = ({ @@ -40,10 +41,12 @@ export const setupAppWithoutAuth = async ({ SequelizeConfigService, dbType = 'sqlite', override = (builder) => builder, + beforeServerStart = undefined, }: SetupOptions): Promise => testServer({ appModule: AppModule, enableVersioning: true, hooks: [useDatabase({ type: dbType, provider: SequelizeConfigService })], override: (builder: TestingModuleBuilder) => override(builder), + beforeServerStart, }) diff --git a/libs/testing/nest/src/lib/testServer.ts b/libs/testing/nest/src/lib/testServer.ts index 38baf7fa5a7d..00ae8af89a00 100644 --- a/libs/testing/nest/src/lib/testServer.ts +++ b/libs/testing/nest/src/lib/testServer.ts @@ -3,6 +3,7 @@ import { Test } from '@nestjs/testing' import { TestingModuleBuilder } from '@nestjs/testing/testing-module.builder' import { InfraModule, HealthCheckOptions } from '@island.is/infra-nest-server' +import bodyParser from 'body-parser' type CleanUp = () => Promise | undefined @@ -25,6 +26,13 @@ export type TestServerOptions = { * Configure health checks for the test server setup */ healthCheck?: boolean | HealthCheckOptions + + /** + * Hook to run before server is started. + * @param app The nest application instance. + * @returns a promise that resolves when the hook is done. + */ + beforeServerStart?: (app: TestApp) => Promise | undefined } export const testServer = async ({ @@ -33,6 +41,7 @@ export const testServer = async ({ override, enableVersioning, healthCheck, + beforeServerStart, }: TestServerOptions): Promise => { let builder = Test.createTestingModule({ imports: [InfraModule.forRoot({ appModule, healthCheck })], @@ -61,6 +70,10 @@ export const testServer = async ({ app.enableVersioning() } + if (beforeServerStart) { + await beforeServerStart(app) + } + await app.init() const hookCleanups = await Promise.all( From 3ed83682c1b6e00aaaac2c667e667a8c600eb54d Mon Sep 17 00:00:00 2001 From: andes-it Date: Thu, 3 Oct 2024 14:24:15 +0000 Subject: [PATCH 02/11] chore: charts update dirty files --- charts/identity-server/values.dev.yaml | 1 + charts/identity-server/values.prod.yaml | 1 + charts/identity-server/values.staging.yaml | 1 + 3 files changed, 3 insertions(+) diff --git a/charts/identity-server/values.dev.yaml b/charts/identity-server/values.dev.yaml index 5f76287c7ee4..d50deba82d2a 100644 --- a/charts/identity-server/values.dev.yaml +++ b/charts/identity-server/values.dev.yaml @@ -298,6 +298,7 @@ services-auth-admin-api: SYSLUMENN_USERNAME: '/k8s/services-auth/SYSLUMENN_USERNAME' ZENDESK_CONTACT_FORM_EMAIL: '/k8s/api/ZENDESK_CONTACT_FORM_EMAIL' ZENDESK_CONTACT_FORM_TOKEN: '/k8s/api/ZENDESK_CONTACT_FORM_TOKEN' + ZENDESK_WEBHOOK_SECRET_GENERAL_MANDATE: '/k8s/services-auth/ZENDESK_WEBHOOK_SECRET_GENERAL_MANDATE' securityContext: allowPrivilegeEscalation: false privileged: false diff --git a/charts/identity-server/values.prod.yaml b/charts/identity-server/values.prod.yaml index 077392613786..f29985ef17e7 100644 --- a/charts/identity-server/values.prod.yaml +++ b/charts/identity-server/values.prod.yaml @@ -295,6 +295,7 @@ services-auth-admin-api: SYSLUMENN_USERNAME: '/k8s/services-auth/SYSLUMENN_USERNAME' ZENDESK_CONTACT_FORM_EMAIL: '/k8s/api/ZENDESK_CONTACT_FORM_EMAIL' ZENDESK_CONTACT_FORM_TOKEN: '/k8s/api/ZENDESK_CONTACT_FORM_TOKEN' + ZENDESK_WEBHOOK_SECRET_GENERAL_MANDATE: '/k8s/services-auth/ZENDESK_WEBHOOK_SECRET_GENERAL_MANDATE' securityContext: allowPrivilegeEscalation: false privileged: false diff --git a/charts/identity-server/values.staging.yaml b/charts/identity-server/values.staging.yaml index bee228ee9292..a52d3a746a75 100644 --- a/charts/identity-server/values.staging.yaml +++ b/charts/identity-server/values.staging.yaml @@ -298,6 +298,7 @@ services-auth-admin-api: SYSLUMENN_USERNAME: '/k8s/services-auth/SYSLUMENN_USERNAME' ZENDESK_CONTACT_FORM_EMAIL: '/k8s/api/ZENDESK_CONTACT_FORM_EMAIL' ZENDESK_CONTACT_FORM_TOKEN: '/k8s/api/ZENDESK_CONTACT_FORM_TOKEN' + ZENDESK_WEBHOOK_SECRET_GENERAL_MANDATE: '/k8s/services-auth/ZENDESK_WEBHOOK_SECRET_GENERAL_MANDATE' securityContext: allowPrivilegeEscalation: false privileged: false From eeb80b019a6ed2420d6ba12887da18004c0bb860 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gunnlaugur=20Gu=C3=B0mundsson?= Date: Fri, 4 Oct 2024 13:06:12 +0000 Subject: [PATCH 03/11] pr comments fixes --- .../delegation-admin.controller.ts | 21 +++++--------- .../test/delegation-admin.auth.spec.ts | 29 +++++++------------ apps/services/auth/admin-api/src/main.ts | 16 ++++------ libs/auth-api-lib/src/index.ts | 1 + .../dto/zendesk-webhook-input.dto.ts | 8 +++++ libs/infra-nest-server/src/index.ts | 1 + .../src/lib/includeRawBodyMiddleware.ts | 14 +++++++++ 7 files changed, 46 insertions(+), 44 deletions(-) create mode 100644 libs/auth-api-lib/src/lib/delegations/dto/zendesk-webhook-input.dto.ts create mode 100644 libs/infra-nest-server/src/lib/includeRawBodyMiddleware.ts diff --git a/apps/services/auth/admin-api/src/app/v2/delegations/delegation-admin.controller.ts b/apps/services/auth/admin-api/src/app/v2/delegations/delegation-admin.controller.ts index 5cdc8224393a..8d218b31cd26 100644 --- a/apps/services/auth/admin-api/src/app/v2/delegations/delegation-admin.controller.ts +++ b/apps/services/auth/admin-api/src/app/v2/delegations/delegation-admin.controller.ts @@ -9,6 +9,7 @@ import { UseGuards, } from '@nestjs/common' import { ApiTags } from '@nestjs/swagger' +import flatMap from 'lodash/flatMap' import { BypassAuth, @@ -24,11 +25,11 @@ import { DelegationAdminCustomDto, DelegationAdminCustomService, DelegationDTO, + ZendeskWebhookInputDto, } from '@island.is/auth-api-lib' import { Documentation } from '@island.is/nest/swagger' import { Audit, AuditService } from '@island.is/nest/audit' import { DelegationAdminScopes } from '@island.is/auth/scopes' -import flatMap from 'lodash/flatMap' import { isDefined } from '@island.is/shared/utils' const namespace = '@island.is/auth/delegation-admin' @@ -45,8 +46,8 @@ export class DelegationAdminController { private readonly auditService: AuditService, ) {} - @Scopes(DelegationAdminScopes.read) @Get() + @Scopes(DelegationAdminScopes.read) @Documentation({ response: { status: 200, type: DelegationAdminCustomDto }, request: { @@ -97,22 +98,14 @@ export class DelegationAdminController { @BypassAuth() @UseGuards(ZendeskAuthGuard(ZENDESK_WEBHOOK_SECRET_GENERAL_MANDATE)) - @Post(':zendeskId') + @Post('/zendesk') @Documentation({ - response: { status: 201, type: DelegationDTO }, - request: { - params: { - zendeskId: { - required: true, - description: 'The id of the zendesk ticket containing the delegation', - }, - }, - }, + response: { status: 200, type: DelegationDTO }, }) createByZendeskId( - @Param('zendeskId') zendeskId: string, + @Body() { id }: ZendeskWebhookInputDto, ): Promise { - return this.delegationAdminService.createDelegationByZendeskId(zendeskId) + return this.delegationAdminService.createDelegationByZendeskId(id) } @Delete(':delegationId') diff --git a/apps/services/auth/admin-api/src/app/v2/delegations/test/delegation-admin.auth.spec.ts b/apps/services/auth/admin-api/src/app/v2/delegations/test/delegation-admin.auth.spec.ts index db6293c0a689..f4943d43c822 100644 --- a/apps/services/auth/admin-api/src/app/v2/delegations/test/delegation-admin.auth.spec.ts +++ b/apps/services/auth/admin-api/src/app/v2/delegations/test/delegation-admin.auth.spec.ts @@ -16,6 +16,7 @@ import { DelegationDTO, SequelizeConfigService } from '@island.is/auth-api-lib' import { DelegationAdminCustomService } from '@island.is/auth-api-lib' import { AppModule } from '../../../app.module' +import { includeRawBodyMiddleware } from '@island.is/infra-nest-server' describe('withoutAuth and permissions', () => { async function formatUrl(app: TestApp, endpoint: string, user?: User) { @@ -147,17 +148,7 @@ describe('withoutAuth and permissions', () => { dbType: 'postgres', beforeServerStart: async (app) => { await new Promise((resolve) => - resolve( - app.use( - bodyParser.json({ - verify: (req: any, res, buf) => { - if (buf && buf.length) { - req.rawBody = buf - } - }, - }), - ), - ), + resolve(app.use(includeRawBodyMiddleware())), ) }, }) @@ -175,14 +166,14 @@ describe('withoutAuth and permissions', () => { app.cleanUp() }) - it('POST /delegation-admin/:zendeskId should return 403 Forbidden when user does not have correct headers for the body', async () => { + it('POST /delegation-admin/zendesk should return 403 Forbidden when request signature is invalid.', async () => { // Act const res = await getRequestMethod( server, 'POST', - )('/delegation-admin/123') + )('/delegation-admin/zendesk') .send({ - custom: 'Incorrect body', + id: 'Incorrect body', }) .set( 'x-zendesk-webhook-signature', @@ -200,23 +191,23 @@ describe('withoutAuth and permissions', () => { }) }) - it('POST /delegation-admin/:zendeskId should return 201 since the correct headers are set for that body', async () => { + it('POST /delegation-admin/zendesk should return 201 when signature is valid', async () => { // Act const res = await getRequestMethod( server, 'POST', - )('/delegation-admin/123') + )('/delegation-admin/zendesk') .send({ - custom: 'test', + id: 'test', }) .set( 'x-zendesk-webhook-signature', - '6sUtGV8C8OdoGgCdsV2xRm3XeskZ33Bc5124RiAK4Q4=', + 'ntgS06VGgd4z73lHjIpC2sk9azhRNi4u1xkXF/KPKTs=', ) .set('x-zendesk-webhook-signature-timestamp', '2024-10-02T14:21:04Z') // Assert - expect(res.status).toEqual(201) + expect(res.status).toEqual(200) }) }) }) diff --git a/apps/services/auth/admin-api/src/main.ts b/apps/services/auth/admin-api/src/main.ts index 03919e51b2e6..69bcbf501c32 100644 --- a/apps/services/auth/admin-api/src/main.ts +++ b/apps/services/auth/admin-api/src/main.ts @@ -1,9 +1,11 @@ -import { bootstrap } from '@island.is/infra-nest-server' +import { + bootstrap, + includeRawBodyMiddleware, +} from '@island.is/infra-nest-server' import { AppModule } from './app/app.module' import { environment as env } from './environments' import { openApi } from './openApi' -import bodyParser from 'body-parser' bootstrap({ appModule: AppModule, @@ -16,14 +18,6 @@ bootstrap({ database: true, }, beforeServerStart: async (app) => { - app.use( - bodyParser.json({ - verify: (req: any, res, buf) => { - if (buf && buf.length) { - req.rawBody = buf - } - }, - }), - ) + app.use(includeRawBodyMiddleware()) }, }) diff --git a/libs/auth-api-lib/src/index.ts b/libs/auth-api-lib/src/index.ts index b9305d456101..28b1b0007819 100644 --- a/libs/auth-api-lib/src/index.ts +++ b/libs/auth-api-lib/src/index.ts @@ -47,6 +47,7 @@ export * from './lib/delegations/dto/delegation-index.dto' export * from './lib/delegations/dto/paginated-delegation-provider.dto' export * from './lib/delegations/dto/delegation-provider.dto' export * from './lib/delegations/dto/merged-delegation.dto' +export * from './lib/delegations/dto/zendesk-webhook-input.dto' export * from './lib/delegations/models/delegation.model' export * from './lib/delegations/models/delegation.model' export * from './lib/delegations/models/delegation-scope.model' diff --git a/libs/auth-api-lib/src/lib/delegations/dto/zendesk-webhook-input.dto.ts b/libs/auth-api-lib/src/lib/delegations/dto/zendesk-webhook-input.dto.ts new file mode 100644 index 000000000000..31d71c17ccad --- /dev/null +++ b/libs/auth-api-lib/src/lib/delegations/dto/zendesk-webhook-input.dto.ts @@ -0,0 +1,8 @@ +import { IsString } from 'class-validator' +import { ApiProperty } from '@nestjs/swagger' + +export class ZendeskWebhookInputDto { + @IsString() + @ApiProperty() + id!: string +} diff --git a/libs/infra-nest-server/src/index.ts b/libs/infra-nest-server/src/index.ts index e6a5847c8151..6fe69a8ae2bd 100644 --- a/libs/infra-nest-server/src/index.ts +++ b/libs/infra-nest-server/src/index.ts @@ -3,5 +3,6 @@ export * from './lib/buildOpenApi' export * from './lib/infra/infra.controller' export { InfraModule } from './lib/infra/infra.module' export { HealthCheckOptions } from './lib/infra/health/types' +export * from './lib/includeRawBodyMiddleware' export * from './lib/processJob' export * from './lib/types' diff --git a/libs/infra-nest-server/src/lib/includeRawBodyMiddleware.ts b/libs/infra-nest-server/src/lib/includeRawBodyMiddleware.ts new file mode 100644 index 000000000000..d059a4462ffb --- /dev/null +++ b/libs/infra-nest-server/src/lib/includeRawBodyMiddleware.ts @@ -0,0 +1,14 @@ +import bodyParser from 'body-parser' + +/** + * Middleware that includes the raw body in the request object. + */ +export const includeRawBodyMiddleware = () => { + return bodyParser.json({ + verify: (req: any, res, buf) => { + if (buf && buf.length) { + req.rawBody = buf + } + }, + }) +} From 3f5870f6957a15409f4e61dfc2e55e33dc4db174 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gunnlaugur=20Gu=C3=B0mundsson?= Date: Fri, 4 Oct 2024 13:34:21 +0000 Subject: [PATCH 04/11] pr comments fixes --- .../delegations/delegation-admin.controller.ts | 17 ++++++++++++----- .../admin/delegation-admin-custom.service.ts | 13 +++++++++---- .../src/lib/zendeskAuth.guard.ts | 15 ++++++++++++--- libs/shared/utils/src/lib/errorCodes.ts | 1 + 4 files changed, 34 insertions(+), 12 deletions(-) diff --git a/apps/services/auth/admin-api/src/app/v2/delegations/delegation-admin.controller.ts b/apps/services/auth/admin-api/src/app/v2/delegations/delegation-admin.controller.ts index 8d218b31cd26..6b40704775ec 100644 --- a/apps/services/auth/admin-api/src/app/v2/delegations/delegation-admin.controller.ts +++ b/apps/services/auth/admin-api/src/app/v2/delegations/delegation-admin.controller.ts @@ -33,8 +33,15 @@ import { DelegationAdminScopes } from '@island.is/auth/scopes' import { isDefined } from '@island.is/shared/utils' const namespace = '@island.is/auth/delegation-admin' + const ZENDESK_WEBHOOK_SECRET_GENERAL_MANDATE = - process.env.ZENDESK_WEBHOOK_SECRET_GENERAL_MANDATE ?? '' + process.env.ZENDESK_WEBHOOK_SECRET_GENERAL_MANDATE + +if (!ZENDESK_WEBHOOK_SECRET_GENERAL_MANDATE) { + throw new Error( + 'Environment variable ZENDESK_WEBHOOK_SECRET_GENERAL_MANDATE must be set', + ) +} @UseGuards(IdsUserGuard, ScopesGuard) @ApiTags('delegation-admin') @@ -100,12 +107,12 @@ export class DelegationAdminController { @UseGuards(ZendeskAuthGuard(ZENDESK_WEBHOOK_SECRET_GENERAL_MANDATE)) @Post('/zendesk') @Documentation({ - response: { status: 200, type: DelegationDTO }, + response: { status: 200 }, }) - createByZendeskId( + async createByZendeskId( @Body() { id }: ZendeskWebhookInputDto, - ): Promise { - return this.delegationAdminService.createDelegationByZendeskId(id) + ): Promise { + await this.delegationAdminService.createDelegationByZendeskId(id) } @Delete(':delegationId') diff --git a/libs/auth-api-lib/src/lib/delegations/admin/delegation-admin-custom.service.ts b/libs/auth-api-lib/src/lib/delegations/admin/delegation-admin-custom.service.ts index 9da454cb1df2..bf7d213c1a56 100644 --- a/libs/auth-api-lib/src/lib/delegations/admin/delegation-admin-custom.service.ts +++ b/libs/auth-api-lib/src/lib/delegations/admin/delegation-admin-custom.service.ts @@ -153,7 +153,7 @@ export class DelegationAdminCustomService { } } - async createDelegationByZendeskId(zendeskId: string): Promise { + async createDelegationByZendeskId(zendeskId: string): Promise { const zendeskCase = await this.zendeskService.getTicket(zendeskId) const { fromReferenceId, toReferenceId, validTo, createdByNationalId } = @@ -168,15 +168,13 @@ export class DelegationAdminCustomService { this.verifyZendeskTicket(zendeskCase, fromReferenceId, toReferenceId) - const newDelegation = await this.insertDelegation({ + await this.insertDelegation({ fromNationalId: fromReferenceId, toNationalId: toReferenceId, referenceId: zendeskId, validTo: this.formatZendeskDate(validTo), createdBy: createdByNationalId, }) - - return newDelegation.toDTO(AuthDelegationType.GeneralMandate) } async createDelegation( @@ -352,6 +350,13 @@ export class DelegationAdminCustomService { const [day, month, year] = date.split('.').map(Number) + if (!day || !month || !year || isNaN(day) || isNaN(month) || isNaN(year)) { + throw new BadRequestException({ + message: 'Invalid date format in Zendesk ticket', + error: ErrorCodes.INVALID_DATE_FORMAT, + }) + } + return new Date(year, month - 1, day) } } diff --git a/libs/auth-nest-tools/src/lib/zendeskAuth.guard.ts b/libs/auth-nest-tools/src/lib/zendeskAuth.guard.ts index 834d0fa2feae..649b2bb90392 100644 --- a/libs/auth-nest-tools/src/lib/zendeskAuth.guard.ts +++ b/libs/auth-nest-tools/src/lib/zendeskAuth.guard.ts @@ -4,7 +4,13 @@ import * as crypto from 'crypto' const SIGNING_SECRET_ALGORITHM = 'sha256' -export function ZendeskAuthGuard(signingSecret: string): Type { +export function ZendeskAuthGuard( + signingSecret: string | undefined, +): Type { + if (!signingSecret) { + throw new Error('Signing secret must be set') + } + @Injectable() class ZendeskAuthGuardMixin implements CanActivate { canActivate(context: ExecutionContext): boolean { @@ -20,13 +26,16 @@ export function ZendeskAuthGuard(signingSecret: string): Type { return this.isValidSignature(signature, body, timestamp) } - + isValidSignature( signature: string, body: string, timestamp: string, ): boolean { - const hmac = crypto.createHmac(SIGNING_SECRET_ALGORITHM, signingSecret) + const hmac = crypto.createHmac( + SIGNING_SECRET_ALGORITHM, + signingSecret as string, + ) const sig = hmac.update(timestamp + body).digest('base64') return Buffer.compare(Buffer.from(signature), Buffer.from(sig)) === 0 diff --git a/libs/shared/utils/src/lib/errorCodes.ts b/libs/shared/utils/src/lib/errorCodes.ts index d0ec30392c07..54ed91b8d870 100644 --- a/libs/shared/utils/src/lib/errorCodes.ts +++ b/libs/shared/utils/src/lib/errorCodes.ts @@ -5,4 +5,5 @@ export enum ErrorCodes { ZENDESK_STATUS = 'ZENDESK_STATUS', INPUT_VALIDATION_SAME_NATIONAL_ID = 'INPUT_VALIDATION_SAME_NATIONAL_ID', INPUT_VALIDATION_INVALID_PERSON = 'INPUT_VALIDATION_INVALID_PERSON', + INVALID_DATE_FORMAT = 'INVALID_DATE_FORMAT', } From 36714628d36e832f8a0d47056a0e309a2dbb1657 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gunnlaugur=20Gu=C3=B0mundsson?= Date: Fri, 4 Oct 2024 14:01:58 +0000 Subject: [PATCH 05/11] fix broken test --- .../src/app/v2/delegations/test/delegation-admin.auth.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/services/auth/admin-api/src/app/v2/delegations/test/delegation-admin.auth.spec.ts b/apps/services/auth/admin-api/src/app/v2/delegations/test/delegation-admin.auth.spec.ts index f4943d43c822..5179dd40778f 100644 --- a/apps/services/auth/admin-api/src/app/v2/delegations/test/delegation-admin.auth.spec.ts +++ b/apps/services/auth/admin-api/src/app/v2/delegations/test/delegation-admin.auth.spec.ts @@ -159,7 +159,7 @@ describe('withoutAuth and permissions', () => { jest .spyOn(delegationAdminService, 'createDelegationByZendeskId') - .mockImplementation(() => Promise.resolve({} as DelegationDTO)) + .mockImplementation(() => Promise.resolve()) }) afterEach(() => { From 1c1d384c1049ed2db15cdafb0f198c4f7eaa043f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gunnlaugur=20Gu=C3=B0mundsson?= Date: Mon, 7 Oct 2024 10:04:50 +0000 Subject: [PATCH 06/11] Pr comments --- .../delegation-admin.controller.ts | 6 +- apps/services/auth/admin-api/src/main.ts | 2 +- .../admin/delegation-admin-custom.service.ts | 34 ++++++++-- .../src/lib/rawBodyRequest.type.ts | 5 ++ .../src/lib/zendeskAuth.guard.ts | 66 +++++++++---------- libs/infra-nest-server/src/lib/bootstrap.ts | 2 +- libs/infra-nest-server/src/lib/types.ts | 2 +- 7 files changed, 72 insertions(+), 45 deletions(-) create mode 100644 libs/auth-nest-tools/src/lib/rawBodyRequest.type.ts diff --git a/apps/services/auth/admin-api/src/app/v2/delegations/delegation-admin.controller.ts b/apps/services/auth/admin-api/src/app/v2/delegations/delegation-admin.controller.ts index 6b40704775ec..c79876adf6f3 100644 --- a/apps/services/auth/admin-api/src/app/v2/delegations/delegation-admin.controller.ts +++ b/apps/services/auth/admin-api/src/app/v2/delegations/delegation-admin.controller.ts @@ -43,6 +43,10 @@ if (!ZENDESK_WEBHOOK_SECRET_GENERAL_MANDATE) { ) } +const ZendeskAuthGuardInstance = new ZendeskAuthGuard( + ZENDESK_WEBHOOK_SECRET_GENERAL_MANDATE, +) + @UseGuards(IdsUserGuard, ScopesGuard) @ApiTags('delegation-admin') @Controller('delegation-admin') @@ -104,7 +108,7 @@ export class DelegationAdminController { } @BypassAuth() - @UseGuards(ZendeskAuthGuard(ZENDESK_WEBHOOK_SECRET_GENERAL_MANDATE)) + @UseGuards(ZendeskAuthGuardInstance) @Post('/zendesk') @Documentation({ response: { status: 200 }, diff --git a/apps/services/auth/admin-api/src/main.ts b/apps/services/auth/admin-api/src/main.ts index 69bcbf501c32..e93b97811c1f 100644 --- a/apps/services/auth/admin-api/src/main.ts +++ b/apps/services/auth/admin-api/src/main.ts @@ -17,7 +17,7 @@ bootstrap({ healthCheck: { database: true, }, - beforeServerStart: async (app) => { + beforeServerStart: (app) => { app.use(includeRawBodyMiddleware()) }, }) diff --git a/libs/auth-api-lib/src/lib/delegations/admin/delegation-admin-custom.service.ts b/libs/auth-api-lib/src/lib/delegations/admin/delegation-admin-custom.service.ts index bf7d213c1a56..181d00317086 100644 --- a/libs/auth-api-lib/src/lib/delegations/admin/delegation-admin-custom.service.ts +++ b/libs/auth-api-lib/src/lib/delegations/admin/delegation-admin-custom.service.ts @@ -166,8 +166,26 @@ export class DelegationAdminCustomService { }) } - this.verifyZendeskTicket(zendeskCase, fromReferenceId, toReferenceId) + if ( + !kennitala.isPerson(createdByNationalId) || + !kennitala.isPerson(toReferenceId) || + !kennitala.isPerson(fromReferenceId) + ) { + throw new BadRequestException({ + message: 'Created by National Id is not valid person national id', + error: ErrorCodes.INPUT_VALIDATION_INVALID_PERSON, + }) + } + + if (toReferenceId === fromReferenceId) { + throw new BadRequestException({ + message: 'National Ids cannot be the same', + error: ErrorCodes.INPUT_VALIDATION_SAME_NATIONAL_ID, + }) + } + this.verifyTicketCompletion(zendeskCase) + await this.insertDelegation({ fromNationalId: fromReferenceId, toNationalId: toReferenceId, @@ -313,11 +331,7 @@ export class DelegationAdminCustomService { }) } - private verifyZendeskTicket( - ticket: Ticket, - fromNationalId: string, - toNationalId: string, - ) { + private verifyTicketCompletion(ticket: Ticket) { if (!ticket.tags.includes(DELEGATION_TAG)) { throw new BadRequestException({ message: 'Zendesk case is missing required tag', @@ -331,6 +345,14 @@ export class DelegationAdminCustomService { error: ErrorCodes.ZENDESK_STATUS, }) } + } + + private verifyZendeskTicket( + ticket: Ticket, + fromNationalId: string, + toNationalId: string, + ) { + this.verifyTicketCompletion(ticket) const { fromReferenceId, toReferenceId } = this.getZendeskCustomFields(ticket) diff --git a/libs/auth-nest-tools/src/lib/rawBodyRequest.type.ts b/libs/auth-nest-tools/src/lib/rawBodyRequest.type.ts new file mode 100644 index 000000000000..d664687c217d --- /dev/null +++ b/libs/auth-nest-tools/src/lib/rawBodyRequest.type.ts @@ -0,0 +1,5 @@ +import { Request } from 'express' + +export interface RawBodyRequest extends Request { + rawBody: Buffer +} diff --git a/libs/auth-nest-tools/src/lib/zendeskAuth.guard.ts b/libs/auth-nest-tools/src/lib/zendeskAuth.guard.ts index 649b2bb90392..9f82412c0df9 100644 --- a/libs/auth-nest-tools/src/lib/zendeskAuth.guard.ts +++ b/libs/auth-nest-tools/src/lib/zendeskAuth.guard.ts @@ -1,46 +1,42 @@ -import { Injectable, CanActivate, ExecutionContext, Type } from '@nestjs/common' -import { Request } from 'express' +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common' import * as crypto from 'crypto' +import { RawBodyRequest } from './rawBodyRequest.type' + const SIGNING_SECRET_ALGORITHM = 'sha256' -export function ZendeskAuthGuard( - signingSecret: string | undefined, -): Type { - if (!signingSecret) { - throw new Error('Signing secret must be set') +@Injectable() +export class ZendeskAuthGuard implements CanActivate { + private readonly signingSecret: string + + constructor(signingSecret: string | undefined) { + if (!signingSecret) { + throw new Error('No signing secret provided') + } + + this.signingSecret = signingSecret } - @Injectable() - class ZendeskAuthGuardMixin implements CanActivate { - canActivate(context: ExecutionContext): boolean { - const request: Request = context.switchToHttp().getRequest() + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest() - const signature = request.headers['x-zendesk-webhook-signature'] as string - const timestamp = request.headers[ - 'x-zendesk-webhook-signature-timestamp' - ] as string - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - const body = request.rawBody?.toString() ?? '' + const signature = request.headers['x-zendesk-webhook-signature'] as string + const timestamp = request.headers[ + 'x-zendesk-webhook-signature-timestamp' + ] as string + const body = request.rawBody?.toString() ?? '' - return this.isValidSignature(signature, body, timestamp) - } - - isValidSignature( - signature: string, - body: string, - timestamp: string, - ): boolean { - const hmac = crypto.createHmac( - SIGNING_SECRET_ALGORITHM, - signingSecret as string, - ) - const sig = hmac.update(timestamp + body).digest('base64') - - return Buffer.compare(Buffer.from(signature), Buffer.from(sig)) === 0 - } + return this.isValidSignature(signature, body, timestamp) } - return ZendeskAuthGuardMixin + isValidSignature( + signature: string, + body: string, + timestamp: string, + ): boolean { + const hmac = crypto.createHmac(SIGNING_SECRET_ALGORITHM, this.signingSecret) + const sig = hmac.update(timestamp + body).digest('base64') + + return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(sig)) + } } diff --git a/libs/infra-nest-server/src/lib/bootstrap.ts b/libs/infra-nest-server/src/lib/bootstrap.ts index 68111c96ed99..01a442e76f77 100644 --- a/libs/infra-nest-server/src/lib/bootstrap.ts +++ b/libs/infra-nest-server/src/lib/bootstrap.ts @@ -128,7 +128,7 @@ export const bootstrap = async ( } if (options.beforeServerStart) { - await options.beforeServerStart(app) + options.beforeServerStart(app) } const serverPort = process.env.PORT diff --git a/libs/infra-nest-server/src/lib/types.ts b/libs/infra-nest-server/src/lib/types.ts index a23579c3ab09..7e37851701ff 100644 --- a/libs/infra-nest-server/src/lib/types.ts +++ b/libs/infra-nest-server/src/lib/types.ts @@ -53,7 +53,7 @@ export type RunServerOptions = { * @param app The nest application instance. * @returns a promise that resolves when the hook is done. */ - beforeServerStart?: (app: INestApplication) => Promise + beforeServerStart?: (app: INestApplication) => void /** * Configures metrics collection and starts metric server. Default: true. From a8137391ed60c764fe0f6b63e43a3352ab22bee0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gunnlaugur=20Gu=C3=B0mundsson?= Date: Mon, 7 Oct 2024 14:21:16 +0000 Subject: [PATCH 07/11] Pr comments --- .../auth/admin-api/src/app/app.module.ts | 1 - .../delegations/delegation-admin.controller.ts | 17 +++-------------- .../admin-api/src/environments/environment.ts | 5 +++++ .../src/lib/zendeskAuth.guard.ts | 15 +++++++-------- 4 files changed, 15 insertions(+), 23 deletions(-) diff --git a/apps/services/auth/admin-api/src/app/app.module.ts b/apps/services/auth/admin-api/src/app/app.module.ts index 982ab803273f..305deb6362b6 100644 --- a/apps/services/auth/admin-api/src/app/app.module.ts +++ b/apps/services/auth/admin-api/src/app/app.module.ts @@ -3,7 +3,6 @@ import { ConfigModule } from '@nestjs/config' import { SequelizeModule } from '@nestjs/sequelize' import { - DelegationApiUserSystemNotificationConfig, DelegationConfig, SequelizeConfigService, } from '@island.is/auth-api-lib' diff --git a/apps/services/auth/admin-api/src/app/v2/delegations/delegation-admin.controller.ts b/apps/services/auth/admin-api/src/app/v2/delegations/delegation-admin.controller.ts index c79876adf6f3..ddbd8359d273 100644 --- a/apps/services/auth/admin-api/src/app/v2/delegations/delegation-admin.controller.ts +++ b/apps/services/auth/admin-api/src/app/v2/delegations/delegation-admin.controller.ts @@ -32,20 +32,9 @@ import { Audit, AuditService } from '@island.is/nest/audit' import { DelegationAdminScopes } from '@island.is/auth/scopes' import { isDefined } from '@island.is/shared/utils' -const namespace = '@island.is/auth/delegation-admin' - -const ZENDESK_WEBHOOK_SECRET_GENERAL_MANDATE = - process.env.ZENDESK_WEBHOOK_SECRET_GENERAL_MANDATE +import env from '../../../environments/environment' -if (!ZENDESK_WEBHOOK_SECRET_GENERAL_MANDATE) { - throw new Error( - 'Environment variable ZENDESK_WEBHOOK_SECRET_GENERAL_MANDATE must be set', - ) -} - -const ZendeskAuthGuardInstance = new ZendeskAuthGuard( - ZENDESK_WEBHOOK_SECRET_GENERAL_MANDATE, -) +const namespace = '@island.is/auth/delegation-admin' @UseGuards(IdsUserGuard, ScopesGuard) @ApiTags('delegation-admin') @@ -108,7 +97,7 @@ export class DelegationAdminController { } @BypassAuth() - @UseGuards(ZendeskAuthGuardInstance) + @UseGuards(new ZendeskAuthGuard(env.zendeskGeneralMandateWebhookSecret)) @Post('/zendesk') @Documentation({ response: { status: 200 }, diff --git a/apps/services/auth/admin-api/src/environments/environment.ts b/apps/services/auth/admin-api/src/environments/environment.ts index 4dd0c52c607b..dea28fd4ba37 100644 --- a/apps/services/auth/admin-api/src/environments/environment.ts +++ b/apps/services/auth/admin-api/src/environments/environment.ts @@ -12,6 +12,9 @@ const devConfig = { port: 6333, clientSecretEncryptionKey: process.env.CLIENT_SECRET_ENCRYPTION_KEY ?? 'secret', + zendeskGeneralMandateWebhookSecret: + process.env.ZENDESK_WEBHOOK_SECRET_GENERAL_MANDATE ?? + 'dGhpc19zZWNyZXRfaXNfZm9yX3Rlc3Rpbmdfb25seQ==', } const prodConfig = { @@ -27,6 +30,8 @@ const prodConfig = { }, port: 3333, clientSecretEncryptionKey: process.env.CLIENT_SECRET_ENCRYPTION_KEY, + zendeskGeneralMandateWebhookSecret: + process.env.ZENDESK_WEBHOOK_SECRET_GENERAL_MANDATE, } export default process.env.NODE_ENV === 'production' ? prodConfig : devConfig diff --git a/libs/auth-nest-tools/src/lib/zendeskAuth.guard.ts b/libs/auth-nest-tools/src/lib/zendeskAuth.guard.ts index 9f82412c0df9..2291e24db0c9 100644 --- a/libs/auth-nest-tools/src/lib/zendeskAuth.guard.ts +++ b/libs/auth-nest-tools/src/lib/zendeskAuth.guard.ts @@ -7,14 +7,10 @@ const SIGNING_SECRET_ALGORITHM = 'sha256' @Injectable() export class ZendeskAuthGuard implements CanActivate { - private readonly signingSecret: string - - constructor(signingSecret: string | undefined) { - if (!signingSecret) { - throw new Error('No signing secret provided') + constructor(private secret: string | undefined) { + if (!secret) { + throw new Error('ZendeskAuthGuard: secret is required') } - - this.signingSecret = signingSecret } canActivate(context: ExecutionContext): boolean { @@ -34,7 +30,10 @@ export class ZendeskAuthGuard implements CanActivate { body: string, timestamp: string, ): boolean { - const hmac = crypto.createHmac(SIGNING_SECRET_ALGORITHM, this.signingSecret) + const hmac = crypto.createHmac( + SIGNING_SECRET_ALGORITHM, + this.secret as string, + ) const sig = hmac.update(timestamp + body).digest('base64') return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(sig)) From f901c16291f2b6aa9d533015357ee6ae1374b1a6 Mon Sep 17 00:00:00 2001 From: andes-it Date: Mon, 7 Oct 2024 14:35:54 +0000 Subject: [PATCH 08/11] chore: nx format:write update dirty files --- .../lib/delegations/admin/delegation-admin-custom.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/auth-api-lib/src/lib/delegations/admin/delegation-admin-custom.service.ts b/libs/auth-api-lib/src/lib/delegations/admin/delegation-admin-custom.service.ts index 181d00317086..06e57f44e190 100644 --- a/libs/auth-api-lib/src/lib/delegations/admin/delegation-admin-custom.service.ts +++ b/libs/auth-api-lib/src/lib/delegations/admin/delegation-admin-custom.service.ts @@ -185,7 +185,7 @@ export class DelegationAdminCustomService { } this.verifyTicketCompletion(zendeskCase) - + await this.insertDelegation({ fromNationalId: fromReferenceId, toNationalId: toReferenceId, From dd31dd88fba5dc08931420be0cc8fe73344e5e10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gunnlaugur=20Gu=C3=B0mundsson?= Date: Mon, 7 Oct 2024 15:17:33 +0000 Subject: [PATCH 09/11] fix translation string --- libs/portals/admin/delegation-admin/src/constants/errors.ts | 1 + libs/portals/admin/delegation-admin/src/lib/messages.ts | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/libs/portals/admin/delegation-admin/src/constants/errors.ts b/libs/portals/admin/delegation-admin/src/constants/errors.ts index 373be7f6a2d2..ca4a8a0d020b 100644 --- a/libs/portals/admin/delegation-admin/src/constants/errors.ts +++ b/libs/portals/admin/delegation-admin/src/constants/errors.ts @@ -11,4 +11,5 @@ export const FORM_ERRORS: Record = { [ErrorCodes.ZENDESK_STATUS]: m.zendeskCaseNotSolvedError, [ErrorCodes.INPUT_VALIDATION_SAME_NATIONAL_ID]: m.sameNationalIdError, [ErrorCodes.INPUT_VALIDATION_INVALID_PERSON]: m.validPersonError, + [ErrorCodes.INVALID_DATE_FORMAT]: m.invalidDateFormatError, } diff --git a/libs/portals/admin/delegation-admin/src/lib/messages.ts b/libs/portals/admin/delegation-admin/src/lib/messages.ts index 1735e3f2b94d..32baab4f9048 100644 --- a/libs/portals/admin/delegation-admin/src/lib/messages.ts +++ b/libs/portals/admin/delegation-admin/src/lib/messages.ts @@ -147,4 +147,8 @@ export const m = defineMessages({ id: 'admin.delegationAdmin:validPersonError', defaultMessage: 'Kennitölur þurfa að vera gildar kennitölur', }, + invalidDateFormatError: { + id: 'admin.delegationAdmin:invalidDateFormatError', + defaultMessage: 'Dagsetning er ekki á réttu sniði', + }, }) From edcf3f189396962402b9eeb1f60d5b932e4d557d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A6var=20M=C3=A1r=20Atlason?= Date: Mon, 7 Oct 2024 22:32:00 +0000 Subject: [PATCH 10/11] Add comment to signing secret dev value. Reuse validatePersonsNationalIds in createDeleagtionByZendeskId --- .../admin-api/src/environments/environment.ts | 2 ++ .../admin/delegation-admin-custom.service.ts | 25 ++++++++----------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/apps/services/auth/admin-api/src/environments/environment.ts b/apps/services/auth/admin-api/src/environments/environment.ts index dea28fd4ba37..a86d4bbb93fe 100644 --- a/apps/services/auth/admin-api/src/environments/environment.ts +++ b/apps/services/auth/admin-api/src/environments/environment.ts @@ -14,6 +14,8 @@ const devConfig = { process.env.CLIENT_SECRET_ENCRYPTION_KEY ?? 'secret', zendeskGeneralMandateWebhookSecret: process.env.ZENDESK_WEBHOOK_SECRET_GENERAL_MANDATE ?? + //The static test signing secret from Zendesk as described in their docs + // https://developer.zendesk.com/documentation/webhooks/verifying/#signing-secrets-on-new-webhooks 'dGhpc19zZWNyZXRfaXNfZm9yX3Rlc3Rpbmdfb25seQ==', } diff --git a/libs/auth-api-lib/src/lib/delegations/admin/delegation-admin-custom.service.ts b/libs/auth-api-lib/src/lib/delegations/admin/delegation-admin-custom.service.ts index 06e57f44e190..b8ce95211bee 100644 --- a/libs/auth-api-lib/src/lib/delegations/admin/delegation-admin-custom.service.ts +++ b/libs/auth-api-lib/src/lib/delegations/admin/delegation-admin-custom.service.ts @@ -156,8 +156,12 @@ export class DelegationAdminCustomService { async createDelegationByZendeskId(zendeskId: string): Promise { const zendeskCase = await this.zendeskService.getTicket(zendeskId) - const { fromReferenceId, toReferenceId, validTo, createdByNationalId } = - this.getZendeskCustomFields(zendeskCase) + const { + fromReferenceId: fromNationalId, + toReferenceId: toNationalId, + validTo, + createdByNationalId, + } = this.getZendeskCustomFields(zendeskCase) if (!createdByNationalId) { throw new BadRequestException({ @@ -166,29 +170,20 @@ export class DelegationAdminCustomService { }) } - if ( - !kennitala.isPerson(createdByNationalId) || - !kennitala.isPerson(toReferenceId) || - !kennitala.isPerson(fromReferenceId) - ) { + if (!kennitala.isPerson(createdByNationalId)) { throw new BadRequestException({ message: 'Created by National Id is not valid person national id', error: ErrorCodes.INPUT_VALIDATION_INVALID_PERSON, }) } - if (toReferenceId === fromReferenceId) { - throw new BadRequestException({ - message: 'National Ids cannot be the same', - error: ErrorCodes.INPUT_VALIDATION_SAME_NATIONAL_ID, - }) - } + this.validatePersonsNationalIds(toNationalId, fromNationalId) this.verifyTicketCompletion(zendeskCase) await this.insertDelegation({ - fromNationalId: fromReferenceId, - toNationalId: toReferenceId, + fromNationalId, + toNationalId, referenceId: zendeskId, validTo: this.formatZendeskDate(validTo), createdBy: createdByNationalId, From ed9add0115cddbf0027a196f3915c8c67b764e5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A6var=20M=C3=A1r=20Atlason?= Date: Mon, 7 Oct 2024 22:36:36 +0000 Subject: [PATCH 11/11] Update test labels --- .../src/app/v2/delegations/test/delegation-admin.auth.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/services/auth/admin-api/src/app/v2/delegations/test/delegation-admin.auth.spec.ts b/apps/services/auth/admin-api/src/app/v2/delegations/test/delegation-admin.auth.spec.ts index 5179dd40778f..6fddc5aacb23 100644 --- a/apps/services/auth/admin-api/src/app/v2/delegations/test/delegation-admin.auth.spec.ts +++ b/apps/services/auth/admin-api/src/app/v2/delegations/test/delegation-admin.auth.spec.ts @@ -136,7 +136,7 @@ describe('withoutAuth and permissions', () => { }, ) - describe('POST /delegation-admin/:zendeskId', () => { + describe('POST /delegation-admin/zendesk', () => { let app: TestApp let server: request.SuperTest let delegationAdminService: DelegationAdminCustomService @@ -191,7 +191,7 @@ describe('withoutAuth and permissions', () => { }) }) - it('POST /delegation-admin/zendesk should return 201 when signature is valid', async () => { + it('POST /delegation-admin/zendesk should return 200 when signature is valid', async () => { // Act const res = await getRequestMethod( server,