import { BadRequestException, Controller, Headers, HttpCode, Post, RawBodyRequest, Req, UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { ApiHeader, ApiNoContentResponse, ApiOperation, ApiTags, ApiUnauthorizedResponse, } from '@nestjs/swagger'; import { IdentityService } from '@task-stream/api/identity/application'; import { Request } from 'express'; import { createHmac, timingSafeEqual } from 'node:crypto'; import { Public } from '../auth/auth.decorators'; /** * Receives `user.deactivated` / `token.revoked` from Pireon. * * Pireon signs each webhook with the secret of the client that registered * it, and this API has two (the web app's and the password grant's): a * delivery is good if it verifies with either. */ @ApiTags('Webhooks') @Controller('webhooks/pireon') export class PireonWebhookController { constructor( private readonly identity: IdentityService, private readonly config: ConfigService, ) {} @Public() @Post() @HttpCode(204) @ApiOperation({ summary: 'Pireon events (called by Pireon, not by apps)', description: "`user.deactivated` closes the user's sessions; `token.revoked` forgets the token. " + 'Disabled unless PIREON_WEBHOOK_SECRET or PIREON_PASSWORD_WEBHOOK_SECRET is set.', }) @ApiHeader({ name: 'X-Webhook-Signature', description: 'HMAC-SHA256 of the raw body with the secret of the Pireon client that registered the webhook (hex)', required: true, }) @ApiNoContentResponse({ description: 'Processed' }) @ApiUnauthorizedResponse({ description: 'Missing or invalid signature' }) async receive( @Req() req: RawBodyRequest, @Headers('x-webhook-signature') signature?: string, ): Promise { this.verifySignature(req.rawBody, signature); const body = req.body as { event?: string; type?: string; data?: unknown; payload?: unknown; }; const event = body.event ?? body.type; if (!event) throw new BadRequestException('Missing event name'); const data = (body.data ?? body.payload ?? body) as Record; await this.identity.handleWebhook(event, data); } /** X-Webhook-Signature = HMAC-SHA256(raw body, the client's webhook secret). */ private verifySignature( rawBody: Buffer | undefined, signature: string | undefined, ): void { const secrets = [ this.config.get('PIREON_WEBHOOK_SECRET'), this.config.get('PIREON_PASSWORD_WEBHOOK_SECRET'), ].filter((secret): secret is string => !!secret); if (!secrets.length) { throw new UnauthorizedException( 'Webhooks are disabled (PIREON_WEBHOOK_SECRET not set)', ); } if (!rawBody || !signature) throw new UnauthorizedException('Missing signature'); const received = Buffer.from(signature.replace(/^sha256=/, '')); const valid = secrets.some((secret) => { const expected = Buffer.from( createHmac('sha256', secret).update(rawBody).digest('hex'), ); return ( received.length === expected.length && timingSafeEqual(received, expected) ); }); if (!valid) throw new UnauthorizedException('Invalid signature'); } }