Files
task-stream/docs/05-autenticacion.md
Josep Piguillem 5ae1d05d30 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>
2026-10-09 13:29:27 +02:00

11 KiB

5. Autenticación (Pireon)

Los usuarios pertenecen a Pireon (https://oauth.pireon.dev, OAuth2 con Laravel Passport). TaskStream no guarda contraseñas: solo valida tokens y guarda una copia local de nombre y email para mostrarlos.

5.1 Cómo valida la API un token

sequenceDiagram
  participant C as Cliente
  participant G as AuthGuard (presenters)
  participant Q as ResolveSessionQuery
  participant R as Redis (caché)
  participant P as Pireon

  C->>G: Authorization: Bearer <token>
  G->>Q: resolveSession(token)
  Q->>R: GET auth:session:sha256(token)
  alt en caché
    R-->>Q: usuario
  else no está
    Q->>P: GET /api/user
    P-->>Q: usuario + roles
    Q->>R: SET (AUTH_SESSION_TTL_SECONDS)
  end
  Q-->>G: User (o 401)
  • Se cachea el hash SHA-256 del token, nunca el token en claro.
  • Si Pireon marca al usuario como inactivo, el token se rechaza.
  • El guard es global: todo requiere token salvo lo marcado con @Public() (health, configuración de login, proxy de tokens, webhook y GET /api/explore/projects).
  • @OptionalAuth() (o @ApiOptionalPireonAuth() en REST) deja pasar sin token: es para las lecturas que permiten los proyectos públicos (proyecto, tablero, tickets, comentarios, releases, actividad, estadísticas). Sin token, el usuario es undefined y el visitante es anónimo; un token presente pero inválido sigue dando 401. Las escrituras, las subscriptions y lo «mío» (myProjects, myIssues, /api/me/…) siguen exigiendo sesión.
  • En subscriptions, el token se valida una vez al abrir el WebSocket (connection_init).
  • @RequireAppRoles('…') permite exigir roles del cliente OAuth en Pireon (los admins globales de Pireon los tienen todos).

5.2 Iniciar sesión desde el navegador

Pireon no permite CORS en /oauth/token y el client_secret no debe llegar al navegador, así que la web pasa por la API: POST /api/auth/token hace de proxy hacia Pireon.

Cada cliente OAuth de Pireon admite un único tipo de grant, así que cada forma de entrar usa su cliente:

Método Cliente de Pireon (grant_type) Variables Flujo
Entrar con Pireon (Authorization Code + PKCE) authorization_code PIREON_CLIENT_ID, PIREON_CLIENT_SECRET Web → /oauth/authorize de Pireon → /auth/callback → POST /api/auth/token (authorization_code + code_verifier)
Email y contraseña password PIREON_PASSWORD_CLIENT_ID, PIREON_PASSWORD_CLIENT_SECRET POST /api/auth/token (password); se desactiva con PIREON_PASSWORD_GRANT=false

Si no se definen las variables PIREON_PASSWORD_CLIENT_*, el login con contraseña usa PIREON_CLIENT_ID (sirve si solo hay un cliente de tipo password, pero entonces «Entrar con Pireon» falla con unauthorized_client). Al renovar la sesión, la API prueba los dos clientes: un refresh_token solo vale con el cliente que lo emitió.

El email y la contraseña son los de la cuenta de Pireon; TaskStream no los guarda, solo los reenvía. (El login con token de acceso personal se ha retirado de la web por ahora; la API sigue aceptando esos tokens como Bearer.)

Registrar TaskStream como cliente en Pireon

Con un token tuyo de Pireon (scope account:write), POST https://oauth.pireon.dev/api/clients:

  1. Cliente para «Entrar con Pireon»: {"name": "TaskStream", "grant_type": "authorization_code", "confidential": true, "redirect_uris": [...]} con estas redirect URIs:

    • http://localhost:4200/auth/callback (desarrollo) y http://localhost:8080/auth/callback (Docker)
    • https://taskstream.pireon.dev/auth/callback (producción)
    • para el «Authorize» de Swagger UI: http://localhost:3000/api/docs/oauth2-redirect.html y https://api.taskstream.pireon.dev/api/docs/oauth2-redirect.html (ver API REST)

    → PIREON_CLIENT_ID y PIREON_CLIENT_SECRET. Al ser confidencial, el secret es obligatorio: sin él Pireon rechaza el canje del código (ver la tabla de errores más abajo).

  2. Cliente para email y contraseña: {"name": "TaskStream (password)", "grant_type": "password"} → PIREON_PASSWORD_CLIENT_ID y PIREON_PASSWORD_CLIENT_SECRET.

Pireon muestra el secret (plain_secret) una sola vez, en la respuesta de alta. Si se pierde, se genera otro con POST https://oauth.pireon.dev/api/clients/{client}/rotate-secret (el anterior sigue valiendo 48 h).

Ponlos en .env (o en el entorno del contenedor; en Dokploy, en Environment) y reinicia la API; en Docker/Dokploy, recrea el contenedor (redesplegar), porque un reinicio no relee el entorno.

En producción:

Servicio URL Redirect URIs registradas en el cliente authorization_code
Web https://taskstream.pireon.dev https://taskstream.pireon.dev/auth/callback
API (REST) https://api.taskstream.pireon.dev https://api.taskstream.pireon.dev/api/docs/oauth2-redirect.html

GET /api/auth/config le dice a la web qué formas de entrar hay: clientId del cliente authorization_code (o null: la web oculta «Entrar con Pireon») y passwordGrant. Devuelve null si no hay ningún cliente; en ese caso el login muestra el aviso y no deja entrar.

Si el login falla

La API reenvía el error de Pireon con su hint entre paréntesis (lo que de verdad falla) y lo registra en su log como Pireon rejected <grant> for client <id>: … (hint: …).

Error Causa Solución
El login dice «Inicio de sesión no configurado» GET /api/auth/config devuelve null: no hay ningún cliente en el entorno de la API Define PIREON_CLIENT_ID y/o PIREON_PASSWORD_CLIENT_ID (con sus secrets) y recrea el contenedor
unauthorized_client: not authorized to use this authorization grant type El cliente no es del tipo que pide el flujo (p. ej. uno password en PIREON_CLIENT_ID) Un cliente authorization_code en PIREON_CLIENT_ID y uno password en PIREON_PASSWORD_CLIENT_ID
invalid_request … (Check the client_secret parameter) El cliente es confidencial y la API no envía secret: la variable está vacía o no llega Pon el secret en PIREON_CLIENT_SECRET (o PIREON_PASSWORD_CLIENT_SECRET) y redespliega
invalid_client: Client authentication failed El secret llega pero no es el de ese cliente Revisa que id y secret sean del mismo cliente; si no lo tienes, rótalo
invalid_request … (Invalid redirect URI) La redirect URI del canje no es la de la autorización Regístrala exactamente igual en el cliente (esquema, dominio y ruta, sin barra final)
invalid_grant: Authorization code has expired / has been revoked El código caducó o ya se usó (p. ej. al recargar /auth/callback) Vuelve a «Entrar con Pireon»
The user credentials were incorrect. Email o contraseña de Pireon incorrectos —
Errores 404 Cannot GET/POST /api/… intermitentes a través de la web Otro api en la red compartida de Dokploy recibe parte de las peticiones Ya resuelto con alias únicos (task-stream-api…), ver Despliegue

Sesión en la web

  • La sesión (accessToken, refreshToken, caducidad) se guarda en localStorage y se restaura al arrancar.
  • Un minuto antes de caducar se renueva con refresh_token. Si llegan varias peticiones a la vez, se renueva una sola vez.
  • Si la API responde UNAUTHENTICATED, se cierra la sesión y se vuelve al login.
  • El parámetro state del flujo PKCE protege contra CSRF; el code_verifier vive en sessionStorage solo durante la redirección.

5.3 Webhooks de Pireon (opcional)

Para que la desactivación de un usuario o la revocación de un token surtan efecto al instante (sin esperar a que caduque la caché):

  1. Registra https://<tu-api>/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, 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). 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

  • Al validar un token, el usuario se guarda (nombre, email, activo) en la colección users de MongoDB.
  • users(search) sincroniza antes el directorio de Pireon con el token del usuario, así que se puede añadir a gente que aún no ha entrado en TaskStream (requiere el scope users:read).
  • Los read models guardan el nombre de los usuarios desnormalizado: si alguien cambia su nombre en Pireon, las entradas antiguas mantienen el nombre anterior.