Files
task-stream/libs/api/identity/presenters/src/lib/http/pireon-webhook.controller.ts
Josep Piguillem 5ae1d05d30 fix(api): accept Pireon webhooks from the password client too
Pireon signs each webhook with the secret of the client that registered
it, and the API has two clients (the web app's and the password
grant's). Only PIREON_WEBHOOK_SECRET was accepted, so a token revoked
for someone signed in with a password went unheard. A delivery now
verifies with PIREON_WEBHOOK_SECRET or PIREON_PASSWORD_WEBHOOK_SECRET;
the same event arriving from both clients changes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:29:27 +02:00

103 lines
3.2 KiB
TypeScript

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<Request>,
@Headers('x-webhook-signature') signature?: string,
): Promise<void> {
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<string, unknown>;
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<string>('PIREON_WEBHOOK_SECRET'),
this.config.get<string>('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');
}
}