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:
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user