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>
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 yGET /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 esundefinedy 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:
-
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) yhttp://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.htmlyhttps://api.taskstream.pireon.dev/api/docs/oauth2-redirect.html(ver API REST)
→
PIREON_CLIENT_IDyPIREON_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). -
Cliente para email y contraseña:
{"name": "TaskStream (password)", "grant_type": "password"}→PIREON_PASSWORD_CLIENT_IDyPIREON_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 enlocalStoragey 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
statedel flujo PKCE protege contra CSRF; elcode_verifiervive ensessionStoragesolo 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é):
- Registra
https://<tu-api>/api/webhooks/pireoncon los eventosuser.deactivatedytoken.revoked(POST /api/clients/{client}/webhooks). - 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
usersde 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 scopeusers: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.