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>
103 lines
3.2 KiB
TypeScript
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');
|
|
}
|
|
}
|