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>
This commit is contained in:
2026-10-09 13:29:27 +02:00
parent 66b09ecf4d
commit 5ae1d05d30
9 changed files with 94 additions and 19 deletions

View File

@@ -16,8 +16,11 @@ EVENTS_BRIDGE_ENABLED=true
# Pireon identity server (https://oauth.pireon.dev/api/documentation) # Pireon identity server (https://oauth.pireon.dev/api/documentation)
PIREON_BASE_URL=https://oauth.pireon.dev PIREON_BASE_URL=https://oauth.pireon.dev
# Secret returned when registering the webhook (POST /api/clients/{client}/webhooks) # Secret returned when registering the webhook (POST /api/clients/{client}/webhooks).
# Pireon signs with the secret of the client that registered it: one per client.
PIREON_WEBHOOK_SECRET= PIREON_WEBHOOK_SECRET=
# The password client's webhook, if it has its own
PIREON_PASSWORD_WEBHOOK_SECRET=
# OAuth clients of task-stream in Pireon (POST /api/clients). Each Pireon client allows ONE grant type: # OAuth clients of task-stream in Pireon (POST /api/clients). Each Pireon client allows ONE grant type:
# authorization_code → "Sign in with Pireon" and Swagger UI. Redirect URIs to register: # authorization_code → "Sign in with Pireon" and Swagger UI. Redirect URIs to register:
# http://localhost:4200/auth/callback, http://localhost:3000/api/docs/oauth2-redirect.html # http://localhost:4200/auth/callback, http://localhost:3000/api/docs/oauth2-redirect.html

View File

@@ -344,7 +344,7 @@
}, },
"/api/webhooks/pireon": { "/api/webhooks/pireon": {
"post": { "post": {
"description": "`user.deactivated` closes the user's sessions; `token.revoked` forgets the token. Disabled unless PIREON_WEBHOOK_SECRET is set.", "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.",
"operationId": "PireonWebhookController_receive", "operationId": "PireonWebhookController_receive",
"parameters": [ "parameters": [
{ {
@@ -358,7 +358,7 @@
{ {
"name": "X-Webhook-Signature", "name": "X-Webhook-Signature",
"in": "header", "in": "header",
"description": "HMAC-SHA256 of the raw body with PIREON_WEBHOOK_SECRET (hex)", "description": "HMAC-SHA256 of the raw body with the secret of the Pireon client that registered the webhook (hex)",
"required": true, "required": true,
"schema": { "schema": {
"type": "string" "type": "string"

View File

@@ -23,6 +23,8 @@ export const configSchema = Joi.object({
// Pireon identity server // Pireon identity server
PIREON_BASE_URL: Joi.string().uri().default('https://oauth.pireon.dev'), PIREON_BASE_URL: Joi.string().uri().default('https://oauth.pireon.dev'),
PIREON_WEBHOOK_SECRET: Joi.string().allow('').optional(), PIREON_WEBHOOK_SECRET: Joi.string().allow('').optional(),
// The password client registers its own webhook, signed with its own secret.
PIREON_PASSWORD_WEBHOOK_SECRET: Joi.string().allow('').optional(),
// OAuth clients of task-stream in Pireon (each Pireon client allows one grant type): // OAuth clients of task-stream in Pireon (each Pireon client allows one grant type):
// authorization_code for "Sign in with Pireon" / Swagger UI… // authorization_code for "Sign in with Pireon" / Swagger UI…
PIREON_CLIENT_ID: Joi.string().allow('').optional(), PIREON_CLIENT_ID: Joi.string().allow('').optional(),

View File

@@ -123,7 +123,7 @@ export interface paths {
put?: never; put?: never;
/** /**
* Pireon events (called by Pireon, not by apps) * 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 is set. * @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.
*/ */
post: operations["PireonWebhookController_receive"]; post: operations["PireonWebhookController_receive"];
delete?: never; delete?: never;
@@ -2825,7 +2825,7 @@ export interface operations {
query?: never; query?: never;
header: { header: {
"x-webhook-signature": string; "x-webhook-signature": string;
/** @description HMAC-SHA256 of the raw body with PIREON_WEBHOOK_SECRET (hex) */ /** @description HMAC-SHA256 of the raw body with the secret of the Pireon client that registered the webhook (hex) */
"X-Webhook-Signature": string; "X-Webhook-Signature": string;
}; };
path?: never; path?: never;

View File

@@ -45,6 +45,7 @@ services:
EVENTS_BRIDGE_ENABLED: 'true' EVENTS_BRIDGE_ENABLED: 'true'
PIREON_BASE_URL: https://oauth.pireon.dev PIREON_BASE_URL: https://oauth.pireon.dev
PIREON_WEBHOOK_SECRET: ${PIREON_WEBHOOK_SECRET:-} PIREON_WEBHOOK_SECRET: ${PIREON_WEBHOOK_SECRET:-}
PIREON_PASSWORD_WEBHOOK_SECRET: ${PIREON_PASSWORD_WEBHOOK_SECRET:-}
PIREON_CLIENT_ID: ${PIREON_CLIENT_ID:-} PIREON_CLIENT_ID: ${PIREON_CLIENT_ID:-}
PIREON_CLIENT_SECRET: ${PIREON_CLIENT_SECRET:-} PIREON_CLIENT_SECRET: ${PIREON_CLIENT_SECRET:-}
# Pireon clients allow one grant type: the one above is authorization_code, # Pireon clients allow one grant type: the one above is authorization_code,

View File

@@ -122,11 +122,16 @@ caduque la caché):
1. Registra `https://<tu-api>/api/webhooks/pireon` con los eventos `user.deactivated` y `token.revoked` 1. Registra `https://<tu-api>/api/webhooks/pireon` con los eventos `user.deactivated` y `token.revoked`
(`POST /api/clients/{client}/webhooks`). (`POST /api/clients/{client}/webhooks`).
2. Pon el secret que devuelve en `PIREON_WEBHOOK_SECRET`. 2. Pon el secret que devuelve en `PIREON_WEBHOOK_SECRET`.
3. Si usas un cliente aparte para el acceso con contraseña (`PIREON_PASSWORD_CLIENT_ID`), registra el webhook también
en ese cliente y pon su secret en `PIREON_PASSWORD_WEBHOOK_SECRET`. Pireon firma cada aviso con el secret del
cliente que lo registró, y la revocación de un token solo la avisa el cliente que lo emitió: sin este paso, los
tokens de quien entra con contraseña no se olvidan al revocarlos.
La API verifica la firma `X-Webhook-Signature` (HMAC-SHA256 del cuerpo), marca al usuario como inactivo y borra sus La API verifica la firma `X-Webhook-Signature` (HMAC-SHA256 del cuerpo, válida con cualquiera de los dos secrets),
marca al usuario como inactivo y borra sus
sesiones cacheadas. Además guarda `UserDeactivatedEvent` en el event store, y una saga del tracker deja sin asignar sus sesiones cacheadas. Además guarda `UserDeactivatedEvent` en el event store, y una saga del tracker deja sin asignar sus
tickets abiertos en todos los proyectos (ver [Arquitectura](02-arquitectura.md)). Sin `PIREON_WEBHOOK_SECRET`, el tickets abiertos en todos los proyectos (ver [Arquitectura](02-arquitectura.md)). Repetir un aviso (llega uno por
endpoint rechaza todas las llamadas. cliente) no tiene efecto. Sin ningún secret, el endpoint rechaza todas las llamadas.
## 5.4 Usuarios en TaskStream ## 5.4 Usuarios en TaskStream

View File

@@ -87,6 +87,7 @@ donde los lee Compose.
| `PIREON_SCOPES` | `users:read` | Scopes que se piden al iniciar sesión | | `PIREON_SCOPES` | `users:read` | Scopes que se piden al iniciar sesión |
| `PIREON_PASSWORD_GRANT` | `true` | Permite el login con email y contraseña | | `PIREON_PASSWORD_GRANT` | `true` | Permite el login con email y contraseña |
| `PIREON_WEBHOOK_SECRET` | — | Activa y firma los webhooks de Pireon | | `PIREON_WEBHOOK_SECRET` | — | Activa y firma los webhooks de Pireon |
| `PIREON_PASSWORD_WEBHOOK_SECRET` | — | El del webhook del cliente de contraseña, si es otro cliente |
| `PIREON_TIMEOUT_MS` | `5000` | Timeout de las llamadas a Pireon | | `PIREON_TIMEOUT_MS` | `5000` | Timeout de las llamadas a Pireon |
| `AUTH_SESSION_TTL_SECONDS` | `60` | Cuánto se confía en un token validado antes de volver a preguntar a Pireon | | `AUTH_SESSION_TTL_SECONDS` | `60` | Cuánto se confía en un token validado antes de volver a preguntar a Pireon |
| `GRAPHQL_PLAYGROUND` | `true` | GraphiQL e introspección | | `GRAPHQL_PLAYGROUND` | `true` | GraphiQL e introspección |

View File

@@ -0,0 +1,48 @@
import { UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IdentityService } from '@task-stream/api/identity/application';
import { createHmac } from 'node:crypto';
import { PireonWebhookController } from './pireon-webhook.controller';
describe('PireonWebhookController', () => {
const handled: string[] = [];
const controller = (secrets: Record<string, string>) =>
new PireonWebhookController(
{
handleWebhook: async (event: string) => {
handled.push(event);
},
} as unknown as IdentityService,
{ get: (key: string) => secrets[key] } as unknown as ConfigService,
);
const body = { event: 'token.revoked', data: { token_id: 't-1' } };
const raw = Buffer.from(JSON.stringify(body));
const signed = (secret: string) =>
createHmac('sha256', secret).update(raw).digest('hex');
const request = { rawBody: raw, body } as never;
beforeEach(() => (handled.length = 0));
it("accepts a delivery signed with either Pireon client's secret", async () => {
const both = controller({
PIREON_WEBHOOK_SECRET: 'web-secret',
PIREON_PASSWORD_WEBHOOK_SECRET: 'password-secret',
});
await both.receive(request, signed('web-secret'));
await both.receive(request, `sha256=${signed('password-secret')}`);
expect(handled).toEqual(['token.revoked', 'token.revoked']);
});
it('refuses any other signature, and everything when no secret is set', async () => {
await expect(
controller({ PIREON_WEBHOOK_SECRET: 'web-secret' }).receive(
request,
signed('password-secret'),
),
).rejects.toThrow(UnauthorizedException);
await expect(
controller({}).receive(request, signed('web-secret')),
).rejects.toThrow(/disabled/);
expect(handled).toEqual([]);
});
});

View File

@@ -21,7 +21,13 @@ import { Request } from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto'; import { createHmac, timingSafeEqual } from 'node:crypto';
import { Public } from '../auth/auth.decorators'; import { Public } from '../auth/auth.decorators';
/** Receives `user.deactivated` / `token.revoked` from Pireon. */ /**
* 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') @ApiTags('Webhooks')
@Controller('webhooks/pireon') @Controller('webhooks/pireon')
export class PireonWebhookController { export class PireonWebhookController {
@@ -37,11 +43,12 @@ export class PireonWebhookController {
summary: 'Pireon events (called by Pireon, not by apps)', summary: 'Pireon events (called by Pireon, not by apps)',
description: description:
"`user.deactivated` closes the user's sessions; `token.revoked` forgets the token. " + "`user.deactivated` closes the user's sessions; `token.revoked` forgets the token. " +
'Disabled unless PIREON_WEBHOOK_SECRET is set.', 'Disabled unless PIREON_WEBHOOK_SECRET or PIREON_PASSWORD_WEBHOOK_SECRET is set.',
}) })
@ApiHeader({ @ApiHeader({
name: 'X-Webhook-Signature', name: 'X-Webhook-Signature',
description: 'HMAC-SHA256 of the raw body with PIREON_WEBHOOK_SECRET (hex)', description:
'HMAC-SHA256 of the raw body with the secret of the Pireon client that registered the webhook (hex)',
required: true, required: true,
}) })
@ApiNoContentResponse({ description: 'Processed' }) @ApiNoContentResponse({ description: 'Processed' })
@@ -63,13 +70,16 @@ export class PireonWebhookController {
await this.identity.handleWebhook(event, data); await this.identity.handleWebhook(event, data);
} }
/** X-Webhook-Signature = HMAC-SHA256(raw body, PIREON_WEBHOOK_SECRET). */ /** X-Webhook-Signature = HMAC-SHA256(raw body, the client's webhook secret). */
private verifySignature( private verifySignature(
rawBody: Buffer | undefined, rawBody: Buffer | undefined,
signature: string | undefined, signature: string | undefined,
): void { ): void {
const secret = this.config.get<string>('PIREON_WEBHOOK_SECRET'); const secrets = [
if (!secret) { 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( throw new UnauthorizedException(
'Webhooks are disabled (PIREON_WEBHOOK_SECRET not set)', 'Webhooks are disabled (PIREON_WEBHOOK_SECRET not set)',
); );
@@ -77,11 +87,16 @@ export class PireonWebhookController {
if (!rawBody || !signature) if (!rawBody || !signature)
throw new UnauthorizedException('Missing signature'); throw new UnauthorizedException('Missing signature');
const expected = createHmac('sha256', secret).update(rawBody).digest('hex'); const received = Buffer.from(signature.replace(/^sha256=/, ''));
const received = signature.replace(/^sha256=/, ''); const valid = secrets.some((secret) => {
const valid = const expected = Buffer.from(
received.length === expected.length && createHmac('sha256', secret).update(rawBody).digest('hex'),
timingSafeEqual(Buffer.from(received), Buffer.from(expected)); );
return (
received.length === expected.length &&
timingSafeEqual(received, expected)
);
});
if (!valid) throw new UnauthorizedException('Invalid signature'); if (!valid) throw new UnauthorizedException('Invalid signature');
} }
} }