Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { DisciplinesModule } from './disciplines/disciplines.module';
import { AdminInfoModule } from './admin-info/admin-info.module';
import { CandidateInfoModule } from './candidate-info/candidate-info.module';
import { AdminProvisioningModule } from './admin-provisioning/admin-provisioning.module';
import { PandadocWebhookModule } from './pandadoc-webhook/pandadoc-webhook.module';

@Module({
imports: [
Expand All @@ -36,6 +37,8 @@ import { AdminProvisioningModule } from './admin-provisioning/admin-provisioning
AdminProvisioningModule,
LearnerInfoModule,
ApplicationsModule,
CandidateInfoModule,
PandadocWebhookModule,
],
controllers: [AppController],
providers: [AppService],
Expand Down
1 change: 1 addition & 0 deletions apps/backend/src/applications/applications.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,6 @@ import { EmailService } from '../util/email/email.service';
ApplicationValidationEmailFilter,
ApplicationCreationErrorFilter,
],
exports: [ApplicationsService],
})
export class ApplicationsModule {}
1 change: 1 addition & 0 deletions apps/backend/src/learner-info/learner-info.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor
imports: [TypeOrmModule.forFeature([LearnerInfo]), AuthModule, UsersModule],
controllers: [LearnerInfoController],
providers: [LearnerInfoService, CurrentUserInterceptor],
exports: [LearnerInfoService],
})
export class LearnerInfoModule {}
58 changes: 58 additions & 0 deletions apps/backend/src/pandadoc-webhook/pandadoc-signature.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PandadocSignatureGuard } from './pandadoc-signature.guard';

function makeContext(
headers: Record<string, string | string[]>,
): ExecutionContext {
return {
switchToHttp: () => ({
getRequest: () => ({ headers }),
}),
} as unknown as ExecutionContext;
}

function buildGuard(key: string | undefined): PandadocSignatureGuard {
const configService = {
get: jest.fn().mockReturnValue(key),
} as unknown as ConfigService;
return new PandadocSignatureGuard(configService);
}

describe('PandadocSignatureGuard', () => {
describe('when PANDADOC_WEBHOOK_KEY is set', () => {
const KEY = 'sandbox-key-abc123';

it('allows the request when signature matches', () => {
const guard = buildGuard(KEY);
const context = makeContext({ 'x-pandadoc-signature': KEY });
expect(guard.canActivate(context)).toBe(true);
});

it('rejects with UnauthorizedException when signature is missing', () => {
const guard = buildGuard(KEY);
const context = makeContext({});
expect(() => guard.canActivate(context)).toThrow(UnauthorizedException);
});

it('rejects with UnauthorizedException when signature is wrong', () => {
const guard = buildGuard(KEY);
const context = makeContext({ 'x-pandadoc-signature': 'WRONG' });
expect(() => guard.canActivate(context)).toThrow(UnauthorizedException);
});

it('handles array-valued header by checking the first entry', () => {
const guard = buildGuard(KEY);
const context = makeContext({ 'x-pandadoc-signature': [KEY, 'extra'] });
expect(guard.canActivate(context)).toBe(true);
});
});

describe('when PANDADOC_WEBHOOK_KEY is unset', () => {
it('allows the request and skips signature check', () => {
const guard = buildGuard(undefined);
const context = makeContext({});
expect(guard.canActivate(context)).toBe(true);
});
});
});
55 changes: 55 additions & 0 deletions apps/backend/src/pandadoc-webhook/pandadoc-signature.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import {
CanActivate,
ExecutionContext,
Injectable,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';

const SIGNATURE_HEADER = 'x-pandadoc-signature';

/**
* Verifies the `x-pandadoc-signature` header against the configured
* `PANDADOC_WEBHOOK_KEY`. If the env var is unset, the guard logs a warning
* once and allows requests through (useful for local dev). Otherwise the
* header must match exactly or the request is rejected with 401.
*
* Implemented as a guard so `UnauthorizedException` is handled by Nest's
* default 401 response rather than being intercepted by route-scoped
* `@Catch(Error)` exception filters.
*/
@Injectable()
export class PandadocSignatureGuard implements CanActivate {
private readonly logger = new Logger(PandadocSignatureGuard.name);
private readonly webhookKey: string | undefined;
private warnedAboutMissingKey = false;

constructor(configService: ConfigService) {
this.webhookKey = configService.get<string>('PANDADOC_WEBHOOK_KEY');
}

canActivate(context: ExecutionContext): boolean {
if (!this.webhookKey) {
if (!this.warnedAboutMissingKey) {
this.logger.warn(
'PANDADOC_WEBHOOK_KEY is not set — webhook signature verification is disabled',
);
this.warnedAboutMissingKey = true;
}
return true;
}

const request = context.switchToHttp().getRequest<Request>();
const signature = request.headers[SIGNATURE_HEADER];
const provided = Array.isArray(signature) ? signature[0] : signature;

if (!provided || provided !== this.webhookKey) {
this.logger.warn('[PandaDoc] Invalid or missing webhook signature');
throw new UnauthorizedException('Invalid webhook signature');
}

return true;
}
}
25 changes: 25 additions & 0 deletions apps/backend/src/pandadoc-webhook/pandadoc-webhook.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Body, Controller, Logger, Post, UseGuards } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { PandadocWebhookService } from './pandadoc-webhook.service';
import { PandadocSignatureGuard } from './pandadoc-signature.guard';

/**
* Public endpoint that receives PandaDoc webhook events.
* Authenticated by `x-pandadoc-signature` (see {@link PandadocSignatureGuard}),
* not by JWT — PandaDoc calls this externally.
*/
@ApiTags('PandaDoc Webhook')
@Controller('pandadoc-webhook')
@UseGuards(PandadocSignatureGuard)
export class PandadocWebhookController {
private readonly logger = new Logger(PandadocWebhookController.name);

constructor(private readonly webhookService: PandadocWebhookService) {}

@Post()
async handleWebhook(@Body() body: Record<string, unknown>) {
this.logger.log('[PandaDoc] Incoming webhook request');
const result = await this.webhookService.processWebhook(body);
return { status: 'ok', appId: result.appId };
}
}
12 changes: 12 additions & 0 deletions apps/backend/src/pandadoc-webhook/pandadoc-webhook.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { PandadocWebhookController } from './pandadoc-webhook.controller';
import { PandadocWebhookService } from './pandadoc-webhook.service';
import { PandadocSignatureGuard } from './pandadoc-signature.guard';

@Module({
imports: [ConfigModule],
controllers: [PandadocWebhookController],
providers: [PandadocWebhookService, PandadocSignatureGuard],
})
export class PandadocWebhookModule {}
Loading
Loading