Files
task-stream/docs/05-autenticacion.md
Josep Piguillem cbaad17fc7 fix(auth): separate Pireon clients for authorization code and password
Pireon OAuth clients allow a single grant type. With one PIREON_CLIENT_ID of
type password, email + password worked but "Sign in with Pireon" failed with
`unauthorized_client`.

- PIREON_CLIENT_ID/SECRET: authorization_code client (Sign in with Pireon,
  Swagger UI). New PIREON_PASSWORD_CLIENT_ID/SECRET: password client, falling
  back to the former so existing setups keep working.
- Refresh tries both clients: a refresh token only works with its issuer.
- PIREON_PASSWORD_GRANT=false now also rejects password grants in the API
  (before it only hid the form).
- /api/auth/config returns clientId: null when there is no authorization_code
  client, and the web hides "Sign in with Pireon".
- docker-compose passes the new variables; docs 05/07, .env.example,
  openapi.json; unit tests for the client selection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:41:50 +02:00

6.0 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.

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

Ponlos en .env (o en el entorno del contenedor; en Dokploy, en Environment) y reinicia la API.

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.

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.