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

138 lines
11 KiB
Markdown

# 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
```mermaid
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](03-api.md#33-rest-con-openapi-para-otras-apps))
→ `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](07-despliegue.md#nombres-únicos-en-la-red) |
### 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](02-arquitectura.md)). 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.