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'); } }