Files
task-stream/docs/05-autenticacion.md
Josep Piguillem 6c3550ae9d docs(auth): two Pireon clients, production URLs and sign-in troubleshooting
- Registering clients: the authorization_code client is confidential and needs
  its secret; Pireon shows it once (rotate-secret if lost); containers must be
  recreated to read new environment variables.
- Production URLs (taskstream.pireon.dev, api.taskstream.pireon.dev) and their
  redirect URIs.
- "Si el login falla": each error seen while deploying (not configured,
  unauthorized_client, missing client_secret, invalid_client, redirect URI,
  expired code, wrong credentials, intermittent 404s) with cause and fix.
- READMEs mention both clients; Word/PDF regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:01:33 +02:00

10 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 y webhook).
  • 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.

La API verifica la firma X-Webhook-Signature (HMAC-SHA256 del cuerpo), marca al usuario como inactivo y borra sus sesiones cacheadas. Sin PIREON_WEBHOOK_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.