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/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 4ab51563c17a..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 @@ -9,30 +9,34 @@ import { UseGuards, } from '@nestjs/common' import { ApiTags } from '@nestjs/swagger' +import flatMap from 'lodash/flatMap' import { + BypassAuth, CurrentUser, IdsUserGuard, Scopes, ScopesGuard, User, + ZendeskAuthGuard, } from '@island.is/auth-nest-tools' import { CreatePaperDelegationDto, 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' +import env from '../../../environments/environment' + const namespace = '@island.is/auth/delegation-admin' @UseGuards(IdsUserGuard, ScopesGuard) -@Scopes(DelegationAdminScopes.read) @ApiTags('delegation-admin') @Controller('delegation-admin') @Audit({ namespace }) @@ -43,6 +47,7 @@ export class DelegationAdminController { ) {} @Get() + @Scopes(DelegationAdminScopes.read) @Documentation({ response: { status: 200, type: DelegationAdminCustomDto }, request: { @@ -91,6 +96,18 @@ export class DelegationAdminController { ) } + @BypassAuth() + @UseGuards(new ZendeskAuthGuard(env.zendeskGeneralMandateWebhookSecret)) + @Post('/zendesk') + @Documentation({ + response: { status: 200 }, + }) + async createByZendeskId( + @Body() { id }: ZendeskWebhookInputDto, + ): Promise { + await this.delegationAdminService.createDelegationByZendeskId(id) + } + @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..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 @@ -1,4 +1,5 @@ import request from 'supertest' +import bodyParser from 'body-parser' import { getRequestMethod, @@ -11,9 +12,11 @@ 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' +import { includeRawBodyMiddleware } from '@island.is/infra-nest-server' describe('withoutAuth and permissions', () => { async function formatUrl(app: TestApp, endpoint: string, user?: User) { @@ -132,4 +135,79 @@ describe('withoutAuth and permissions', () => { app.cleanUp() }, ) + + describe('POST /delegation-admin/zendesk', () => { + 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(includeRawBodyMiddleware())), + ) + }, + }) + + server = request(app.getHttpServer()) + + delegationAdminService = app.get(DelegationAdminCustomService) + + jest + .spyOn(delegationAdminService, 'createDelegationByZendeskId') + .mockImplementation(() => Promise.resolve()) + }) + + afterEach(() => { + app.cleanUp() + }) + + it('POST /delegation-admin/zendesk should return 403 Forbidden when request signature is invalid.', async () => { + // Act + const res = await getRequestMethod( + server, + 'POST', + )('/delegation-admin/zendesk') + .send({ + id: '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/zendesk should return 200 when signature is valid', async () => { + // Act + const res = await getRequestMethod( + server, + 'POST', + )('/delegation-admin/zendesk') + .send({ + id: 'test', + }) + .set( + 'x-zendesk-webhook-signature', + 'ntgS06VGgd4z73lHjIpC2sk9azhRNi4u1xkXF/KPKTs=', + ) + .set('x-zendesk-webhook-signature-timestamp', '2024-10-02T14:21:04Z') + + // Assert + expect(res.status).toEqual(200) + }) + }) }) 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/environments/environment.ts b/apps/services/auth/admin-api/src/environments/environment.ts index 4dd0c52c607b..a86d4bbb93fe 100644 --- a/apps/services/auth/admin-api/src/environments/environment.ts +++ b/apps/services/auth/admin-api/src/environments/environment.ts @@ -12,6 +12,11 @@ const devConfig = { port: 6333, clientSecretEncryptionKey: 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==', } const prodConfig = { @@ -27,6 +32,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/apps/services/auth/admin-api/src/main.ts b/apps/services/auth/admin-api/src/main.ts index 5b9ad800db75..e93b97811c1f 100644 --- a/apps/services/auth/admin-api/src/main.ts +++ b/apps/services/auth/admin-api/src/main.ts @@ -1,4 +1,7 @@ -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' @@ -14,4 +17,7 @@ bootstrap({ healthCheck: { database: true, }, + beforeServerStart: (app) => { + app.use(includeRawBodyMiddleware()) + }, }) 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 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/admin/delegation-admin-custom.service.ts b/libs/auth-api-lib/src/lib/delegations/admin/delegation-admin-custom.service.ts index 99f41074d6e3..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 @@ -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,72 +153,67 @@ export class DelegationAdminCustomService { } } - async createDelegation( - user: User, - delegation: CreatePaperDelegationDto, - ): Promise { - this.validatePersonsNationalIds( - delegation.toNationalId, - delegation.fromNationalId, - ) + async createDelegationByZendeskId(zendeskId: string): Promise { + const zendeskCase = await this.zendeskService.getTicket(zendeskId) - const zendeskCase = await this.zendeskService.getTicket( - delegation.referenceId, - ) + const { + fromReferenceId: fromNationalId, + toReferenceId: toNationalId, + validTo, + createdByNationalId, + } = this.getZendeskCustomFields(zendeskCase) - if (!zendeskCase.tags.includes(DELEGATION_TAG)) { + if (!createdByNationalId) { throw new BadRequestException({ - message: 'Zendesk case is missing required tag', - error: ErrorCodes.ZENDESK_TAG_MISSING, + message: 'Zendesk ticket is missing created by national id', + error: ErrorCodes.ZENDESK_CUSTOM_FIELDS_MISSING, }) } - if (zendeskCase.status !== TicketStatus.Solved) { + if (!kennitala.isPerson(createdByNationalId)) { throw new BadRequestException({ - message: 'Zendesk case is not solved', - error: ErrorCodes.ZENDESK_STATUS, + message: 'Created by National Id is not valid person national id', + error: ErrorCodes.INPUT_VALIDATION_INVALID_PERSON, }) } - const { fromReferenceId, toReferenceId } = - this.getNationalIdsFromZendeskTicket(zendeskCase) + this.validatePersonsNationalIds(toNationalId, fromNationalId) - 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, - }) - } + this.verifyTicketCompletion(zendeskCase) - const [fromDisplayName, toName] = await Promise.all([ - this.namesService.getPersonName(delegation.fromNationalId), - this.namesService.getPersonName(delegation.toNationalId), - ]) + await this.insertDelegation({ + fromNationalId, + toNationalId, + referenceId: zendeskId, + validTo: this.formatZendeskDate(validTo), + createdBy: createdByNationalId, + }) + } - 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], - }, + async createDelegation( + user: User, + delegation: CreatePaperDelegationDto, + ): Promise { + this.validatePersonsNationalIds( + delegation.toNationalId, + delegation.fromNationalId, + ) + + const zendeskCase = await this.zendeskService.getTicket( + delegation.referenceId, + ) + + 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 +290,90 @@ 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 verifyTicketCompletion(ticket: Ticket) { + 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, + }) + } + } + + private verifyZendeskTicket( + ticket: Ticket, + fromNationalId: string, + toNationalId: string, + ) { + this.verifyTicketCompletion(ticket) + + 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) + + 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-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-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/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/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 new file mode 100644 index 000000000000..2291e24db0c9 --- /dev/null +++ b/libs/auth-nest-tools/src/lib/zendeskAuth.guard.ts @@ -0,0 +1,41 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common' +import * as crypto from 'crypto' + +import { RawBodyRequest } from './rawBodyRequest.type' + +const SIGNING_SECRET_ALGORITHM = 'sha256' + +@Injectable() +export class ZendeskAuthGuard implements CanActivate { + constructor(private secret: string | undefined) { + if (!secret) { + throw new Error('ZendeskAuthGuard: secret is required') + } + } + + 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 + 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, + this.secret as string, + ) + 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/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/bootstrap.ts b/libs/infra-nest-server/src/lib/bootstrap.ts index edc410b7a285..01a442e76f77 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) { + 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/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 + } + }, + }) +} diff --git a/libs/infra-nest-server/src/lib/types.ts b/libs/infra-nest-server/src/lib/types.ts index f0c6b054b20b..7e37851701ff 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) => void + /** * Configures metrics collection and starts metric server. Default: true. */ 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', + }, }) 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', } 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(