From 5ae1d05d30661d9d1298e123b17ae3b0baa0f9bd Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Fri, 9 Oct 2026 13:29:27 +0200 Subject: [PATCH] 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 --- .env.example | 5 +- apps/api/openapi.json | 4 +- apps/api/src/app/config.schema.ts | 2 + apps/mcp/src/app/api/taskstream.schema.ts | 4 +- docker-compose.yml | 1 + docs/05-autenticacion.md | 11 +++-- docs/07-despliegue.md | 1 + .../http/pireon-webhook.controller.spec.ts | 48 +++++++++++++++++++ .../src/lib/http/pireon-webhook.controller.ts | 37 +++++++++----- 9 files changed, 94 insertions(+), 19 deletions(-) create mode 100644 libs/api/identity/presenters/src/lib/http/pireon-webhook.controller.spec.ts diff --git a/.env.example b/.env.example index aff6001..aa60980 100644 --- a/.env.example +++ b/.env.example @@ -16,8 +16,11 @@ EVENTS_BRIDGE_ENABLED=true # Pireon identity server (https://oauth.pireon.dev/api/documentation) 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= +# 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: # 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 diff --git a/apps/api/openapi.json b/apps/api/openapi.json index a82862c..50446f5 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -344,7 +344,7 @@ }, "/api/webhooks/pireon": { "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", "parameters": [ { @@ -358,7 +358,7 @@ { "name": "X-Webhook-Signature", "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, "schema": { "type": "string" diff --git a/apps/api/src/app/config.schema.ts b/apps/api/src/app/config.schema.ts index 698b870..3b8e0bc 100644 --- a/apps/api/src/app/config.schema.ts +++ b/apps/api/src/app/config.schema.ts @@ -23,6 +23,8 @@ export const configSchema = Joi.object({ // Pireon identity server PIREON_BASE_URL: Joi.string().uri().default('https://oauth.pireon.dev'), 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): // authorization_code for "Sign in with Pireon" / Swagger UI… PIREON_CLIENT_ID: Joi.string().allow('').optional(), diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index 44e4f52..14bedc5 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -123,7 +123,7 @@ export interface paths { put?: never; /** * 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"]; delete?: never; @@ -2825,7 +2825,7 @@ export interface operations { query?: never; header: { "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; }; path?: never; diff --git a/docker-compose.yml b/docker-compose.yml index 9d2c05f..ae3c21d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -45,6 +45,7 @@ services: EVENTS_BRIDGE_ENABLED: 'true' PIREON_BASE_URL: https://oauth.pireon.dev PIREON_WEBHOOK_SECRET: ${PIREON_WEBHOOK_SECRET:-} + PIREON_PASSWORD_WEBHOOK_SECRET: ${PIREON_PASSWORD_WEBHOOK_SECRET:-} PIREON_CLIENT_ID: ${PIREON_CLIENT_ID:-} PIREON_CLIENT_SECRET: ${PIREON_CLIENT_SECRET:-} # Pireon clients allow one grant type: the one above is authorization_code, diff --git a/docs/05-autenticacion.md b/docs/05-autenticacion.md index 0c8f420..7efba29 100644 --- a/docs/05-autenticacion.md +++ b/docs/05-autenticacion.md @@ -122,11 +122,16 @@ caduque la caché): 1. Registra `https:///api/webhooks/pireon` con los eventos `user.deactivated` y `token.revoked` (`POST /api/clients/{client}/webhooks`). 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 -tickets abiertos en todos los proyectos (ver [Arquitectura](02-arquitectura.md)). Sin `PIREON_WEBHOOK_SECRET`, el -endpoint rechaza todas las llamadas. +tickets abiertos en todos los proyectos (ver [Arquitectura](02-arquitectura.md)). Repetir un aviso (llega uno por +cliente) no tiene efecto. Sin ningún secret, el endpoint rechaza todas las llamadas. ## 5.4 Usuarios en TaskStream diff --git a/docs/07-despliegue.md b/docs/07-despliegue.md index 06bbd00..0229449 100644 --- a/docs/07-despliegue.md +++ b/docs/07-despliegue.md @@ -87,6 +87,7 @@ donde los lee Compose. | `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_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 | | `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 | diff --git a/libs/api/identity/presenters/src/lib/http/pireon-webhook.controller.spec.ts b/libs/api/identity/presenters/src/lib/http/pireon-webhook.controller.spec.ts new file mode 100644 index 0000000..910ef67 --- /dev/null +++ b/libs/api/identity/presenters/src/lib/http/pireon-webhook.controller.spec.ts @@ -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) => + 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([]); + }); +}); diff --git a/libs/api/identity/presenters/src/lib/http/pireon-webhook.controller.ts b/libs/api/identity/presenters/src/lib/http/pireon-webhook.controller.ts index cda43e4..0835f2d 100644 --- a/libs/api/identity/presenters/src/lib/http/pireon-webhook.controller.ts +++ b/libs/api/identity/presenters/src/lib/http/pireon-webhook.controller.ts @@ -21,7 +21,13 @@ import { Request } from 'express'; import { createHmac, timingSafeEqual } from 'node:crypto'; 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') @Controller('webhooks/pireon') export class PireonWebhookController { @@ -37,11 +43,12 @@ export class PireonWebhookController { 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 is set.', + '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 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, }) @ApiNoContentResponse({ description: 'Processed' }) @@ -63,13 +70,16 @@ export class PireonWebhookController { 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( rawBody: Buffer | undefined, signature: string | undefined, ): void { - const secret = this.config.get('PIREON_WEBHOOK_SECRET'); - if (!secret) { + 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)', ); @@ -77,11 +87,16 @@ export class PireonWebhookController { if (!rawBody || !signature) throw new UnauthorizedException('Missing signature'); - const expected = createHmac('sha256', secret).update(rawBody).digest('hex'); - const received = signature.replace(/^sha256=/, ''); - const valid = - received.length === expected.length && - timingSafeEqual(Buffer.from(received), Buffer.from(expected)); + 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'); } }