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