Files
task-stream/docs/05-autenticacion.md
Josep Piguillem e656222b54 feat: public projects, project deletion and sagas
Public projects:
- Project.isPublic (create/update, owners and admins). Anyone can read a
  public project without a token: project, board, tickets, comments,
  history, releases, activity, tracking and statistics, over GraphQL and
  REST. Writes stay for members (401 anonymous, 403 outsiders).
- @OptionalAuth() / @ApiOptionalPireonAuth(): anonymous callers allowed,
  invalid tokens still rejected. ReadAccess lets anonymous viewers read
  public projects only; linked tickets are filtered by visibility.
- publicProjects (GraphQL) and GET /api/explore/projects (REST).
- Web: «Explorar» page, project pages open without a session (read-only
  notice, sign-in hints), public badge, visibility switch in settings,
  no realtime subscriptions without a session.

Project deletion:
- Owner only, confirming the key (deleteProject / DELETE
  /api/projects/{project}?confirm=KEY). The key is freed; the read model
  drops the project and members are notified (web goes home).

Sagas (run by the EventsBridge after projections, durable and ordered):
- ProjectDeletionSaga: project deleted → delete its tickets and releases;
  ticket deleted → purge its comments and drop links from other tickets.
- ReleaseArchivedSaga: unfinished tickets leave an archived release.
- UserDeactivatedSaga: the Pireon webhook records UserDeactivatedEvent in
  the event store (EventRecorder); the tracker unassigns that user's open
  tickets as the system actor.
- Internal idempotent commands; domain refusals are logged and skipped,
  concurrency conflicts retried. Affected streams come from the event store
  (TrackerStreamsRepository).

Tests: domain lifecycle specs, saga specs run like the bridge, facade
tests, e2e for public access, cascade deletion, archived releases and
deactivation webhook. Docs, READMEs, schema.gql/openapi.json and Word/PDF
updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:04:20 +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.

La API verifica la firma X-Webhook-Signature (HMAC-SHA256 del cuerpo), 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). 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.