Autenticación
Cómo funciona
El panel no emite sesiones propias: delega todo en ajolote (better-auth). El esquema es híbrido:
- Browser ↔ ajolote: cookie httpOnly first-party emitida por ajolote (hace de refresh token).
- Browser ↔ backend: el front cambia la cookie por un JWT RS256 corto (
GET /api/auth/token) y lo manda comoAuthorization: Beareren las llamadas tRPC/REST. - Backend: valida el JWT localmente contra el JWKS de ajolote. No guarda sesión ni consulta la DB para autenticar.
El JWT está atado a:
issuer=AUTH_SERVICE_URL(backend) — debe ser idéntico a la base URL con la que ajolote firma (BETTER_AUTH_URL).audience=AUTH_APP_ID(backend) — debe coincidir con elappIdque pide el front (hoy'pupo', hardcodeado enauth-client.ts). Mismatch →INVALID_APP_ID.
Diferencia clave entre prod y pre
| Prod | Pre | |
|---|---|---|
| ajolote | público (DigitalOcean) | Tailscale-only (http://100.64.188.115:3010) |
| Cómo llega el front a ajolote | directo | vía reverse-proxy del backend |
VITE_AUTH_URL (front) | URL pública del ajolote de prod | el dominio del backend de pre (https://pupo-pre.anfibia.io) |
ENABLE_AUTH_PROXY (back) | off | true |
En prod, ajolote es público y el browser le pega directo. En pre, ajolote es Tailscale-only: el browser (en internet, fuera de la tailnet) no puede alcanzarlo. El backend sí (está en los dos mundos), así que hace de intermediario.
El reverse-proxy /api/auth (solo pre)
Implementado en src/common/ajolote/auth-proxy.ts (configureAuthProxy), montado en main.ts antes del body-parser (bodyParser: false, luego se restaura json()/urlencoded()). Se activa solo si ENABLE_AUTH_PROXY=true, así prod queda intacto. Qué hace:
- Proxya
/api/auth/*aAUTH_SERVICE_URL(ajolote por Tailscale). - Reescribe la cookie a
SameSite=None; Secure(front y backend son cross-site). - Normaliza el
OriginaFRONT_URLpara el CSRF (trustedOrigins) de ajolote → así ajolote necesita un solo origin y los preview deployments andan. - Maneja el CORS real hacia el browser (
credentials: true, refleja el origin; regex opcionalAUTH_PROXY_ORIGIN_REGEXpara previews).
Ver la guía reusable completa en el repo: auth-panel/docs/AJOLOTE_PROXY.md.
:::note Detalle de implementación
cors es CommonJS y el proyecto no usa esModuleInterop, por eso se importa con import cors = require("cors") (el default import compilaba a cors_1.default, undefined en runtime). express se declaró como dependencia directa (venía solo como transitiva y con pnpm no resolvía en el node_modules de producción).
:::
Variables de auth
| Variable | Prod | Pre |
|---|---|---|
AUTH_SERVICE_URL (back) | ajolote prod público | http://100.64.188.115:3010 (ajolote dev Tailscale) |
AUTH_APP_ID (back) | pupo | pupo |
ENABLE_AUTH_PROXY (back) | — | true |
FRONT_URL (back) | — | https://dev.pupo-panel.pages.dev |
CORS_ORIGIN (back) | dominio front prod | https://dev.pupo-panel.pages.dev |
AJOLOTE_SERVICE_KEY (back) | key del ajolote prod | key del ajolote dev |
VITE_AUTH_URL (front) | ajolote prod público | https://pupo-pre.anfibia.io (el backend) |
Del lado de ajolote (dev)
BETTER_AUTH_URLdebe ser idéntico aAUTH_SERVICE_URLdel backend (http://100.64.188.115:3010) — es elissdel JWT.ALLOWED_ORIGINSdebe incluir el valor deFRONT_URL(https://dev.pupo-panel.pages.dev). Gracias a la normalización de Origin del proxy, alcanza con ese único origin (no hace falta wildcard).- Los usuarios admin deben existir en el ajolote de dev (el login se valida ahí, no en el panel).