Esta guía describe el modelo de seguridad de los microservicios y los pasos manuales obligatorios antes de desplegar.
flowchart LR
Client(["🌐 Cliente"])
subgraph priv ["🔒 internal-network · internal: true"]
direction LR
Nginx["Nginx · <b>front</b><br/>SPA + proxy /api/*"]
Gateway["<b>Gateway</b><br/>JWT_ACCESS_SECRET / JWT_REFRESH_SECRET (HS256)<br/>INTERNAL_JWT_PRIVATE_KEY (Ed25519, firma)"]
API["<b>API</b><br/>INTERNAL_JWT_PUBLIC_KEY (Ed25519, sólo verifica)"]
DB[("PostgreSQL")]
Nginx -->|"proxy_pass /api/"| Gateway
Gateway -->|"X-Internal-Auth · EdDSA"| API
API --> DB
end
Client ==>|"cookie + Authorization<br/>única puerta pública"| Nginx
classDef public fill:#1f6feb,stroke:#0b3d91,color:#fff;
class Nginx public;
- Nginx (contenedor
front) es la puerta pública: sirve la SPA y hace reverse-proxy de/api/*al gateway (mismo origen, para que las cookies viajen sin CORS). El cliente nunca contacta al gateway directamente. - Gateway vive en
internal-network(privado, sin entrada desde Internet) — es el servicio que firma los tokens de cara al cliente y proxia hacia el api privado. Posee:JWT_ACCESS_SECRETyJWT_REFRESH_SECRET(HS256) para los tokens del cliente.INTERNAL_JWT_PRIVATE_KEY(Ed25519) para firmar las llamadas que envía al API.
- API vive en una red privada (
internal-network). Sólo conoce:INTERNAL_JWT_PUBLIC_KEY(Ed25519) para verificar las llamadas del gateway. No puede firmar tokens internos.- La conexión a Postgres.
Si el API queda comprometido, el atacante no puede firmar tokens válidos para otros microservicios futuros — sólo el gateway puede hacerlo.
Cada login emite dos JWT distintos:
| Token | Secreto | TTL | Reside en |
|---|---|---|---|
| Access | JWT_ACCESS_SECRET |
JWT_EXPIRES_IN (4h) |
Header Authorization |
| Refresh | JWT_REFRESH_SECRET |
JWT_REFRESH_EXPIRES_IN (8h; JWT_REFRESH_REMEMBER_DAYS, 30 por defecto, si remember) |
Cookie HttpOnly Secure |
Los tokens del cliente además llevan iss/aud (gateway/web) que el
gateway verifica, de modo que un token emitido para otro contexto no se puede
reutilizar aquí. En cada rotación el gateway re-lee los permisos del usuario
desde el API (fuente autoritativa) en lugar de copiarlos del refresh viejo, así
que una cuenta degradada/revocada o borrada pierde acceso en la siguiente
rotación, no al cabo de toda la vida del refresh.
Cada token lleva:
typ:'access'o'refresh'. El verificador rechaza usar uno como el otro (mitiga token confusion).jti: UUID v4 único, usado por el API para rastrear la familia de refresh y detectar reuso (ver siguiente sección).
La tabla public.refresh_token_family registra cada refresh JWT emitido:
- En cada rotación (cuando el cliente cambia un refresh válido por
uno nuevo), el API marca el
jtiantiguo como usado y crea uno nuevo en la misma familia. - Si el mismo
jtise presenta dos veces (alguien interceptó la cookie y la usó después de la rotación), el API revoca la familia completa y devuelve 401. El gateway limpia la cookie del cliente. - En
logout, el gateway revoca la familia completa (no sólo eljtipresentado): elfamilyIdviaja dentro del refresh JWT, de modo que cerrar sesión termina el linaje entero.
El token interno (X-Internal-Auth, EdDSA) que el gateway firma para llamar al
API es de un solo request y de vida muy corta (TTL 60s, ver
internal-auth.constants.ts). Lleva un requestId de correlación, pero el
verificador no impone unicidad (no hay store de jti/nonce). En
consecuencia:
Suposición de seguridad explícita. Dentro de su ventana de TTL (60s, más una tolerancia de reloj de 5s), un
X-Internal-Authcapturado podría reusarse contra el API. Esto está mitigado por el diseño de red: el token nunca sale deinternal-network(redinternal: true, sin entrada desde Internet) y el gateway es la única puerta pública. Explotarlo requiere estar ya dentro de la red interna, o un SSRF/leak separado.
El requestId lo genera siempre el gateway en servidor (randomUUID()); el
API deriva el requestId del token verificado, nunca de una cabecera entrante
del cliente. El gateway, además, hace strip de cualquier x-internal-auth /
x-request-id entrante antes de proxiar.
Si el borde interno llegara a ser cruzado por servicios menos confiables
(p. ej. una malla de servicios multirust), endurecer añadiendo una caché de
jti de corta vida en verifyInternalAuth para rechazar replays: incluir un
jti único en el token interno y registrar los vistos durante su TTL.
# Cada uno debe ser fuerte y distinto al otro
JWT_ACCESS_SECRET=$(openssl rand -base64 64 | tr -d '\n')
JWT_REFRESH_SECRET=$(openssl rand -base64 64 | tr -d '\n')Recomendado — usá el script, que emite las dos líneas listas para pegar
en .env con el formato correcto (una línea, entre comillas, con \n):
bash scripts/gen-internal-keys.shSalida (copiar tal cual en el .env):
INTERNAL_JWT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
INTERNAL_JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"El script detecta un openssl con soporte Ed25519 (en macOS el LibreSSL del sistema no lo soporta) y, si no lo encuentra, genera con Node. No escribe ningún archivo a disco.
Formato — el origen de los fallos de arranque más comunes. La clave debe ir en una sola línea entre comillas dobles con
\nliterales:
- PEM multilínea sin comillas → dotenv lo trunca en el primer salto y jose falla con
Invalid keyData / Failed to read private key.\nescapado de más (\\n) →joselanzaInvalidCharacterErrorenatob. El normalizador (normalisePem) sólo consume un backslash.El script ya produce el formato seguro; evitá el escapado manual.
Alternativa manual (sin el script)
openssl genpkey -algorithm ed25519 -out internal_private.pem
openssl pkey -in internal_private.pem -pubout -out internal_public.pem
# Convertir cada PEM a una sola línea con `\n` literales y entre comillas:
echo "INTERNAL_JWT_PRIVATE_KEY=\"$(awk 'NF {printf "%s\\n", $0}' internal_private.pem | sed 's/\\n$//')\""
echo "INTERNAL_JWT_PUBLIC_KEY=\"$(awk 'NF {printf "%s\\n", $0}' internal_public.pem | sed 's/\\n$//')\""JWT_ACCESS_SECRET=...
JWT_REFRESH_SECRET=...
INTERNAL_JWT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
INTERNAL_JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"Compose inyecta INTERNAL_JWT_PRIVATE_KEY sólo al servicio gateway y
INTERNAL_JWT_PUBLIC_KEY sólo al servicio api. Nunca repliques la
clave privada en otros servicios.
- Cambia
JWT_ACCESS_SECRETyJWT_REFRESH_SECRETinvalida toda sesión activa al reiniciar el gateway. Acepta esto como expected behaviour. - Cambiar el par Ed25519 invalida todos los tokens internos en vuelo; rotar simultáneamente la pública en el API y la privada en el gateway.
- En entornos con alta disponibilidad podés soportar rotación gradual
cargando dos pares y verificando con ambas claves públicas. No está
implementado en el starter — extender
requireInternalAuthaceptando un array.
- El API ejecuta
db/20.refresh_token_family.sqlen el primer arranque (víadocker-entrypoint-initdb.d). Para entornos existentes, correr la migración manualmente. - El gateway hace
fetchsíncrono al API en cada login/refresh/logout. Si el API está caído, el login responde 503; los access tokens válidos siguen funcionando hasta que expiren. - Los logs incluyen
requestId(cabeceraX-Request-Id) para correlar trazas entre gateway y API.
El gateway actúa como Relying Party (RP) OIDC. Hace todo el handshake y termina convirtiendo la identidad federada en la misma sesión local de siempre (access JWT + cookie refresh con familia/rotación). El API nunca habla con el IdP y sigue confiando solo en el JWT interno EdDSA. Sin proveedores configurados, el SSO está desactivado y el sistema se comporta igual que hoy.
browser ──/auth/sso/:p/login──▶ gateway (state+nonce+PKCE → cookie tx firmada)
│ │ 302
▼ ▼
IdP ◀── authorization_endpoint ──┘
│ login del usuario
▼ 302 con code+state
browser ──/auth/sso/:p/callback──▶ gateway
│ valida state (cookie) + provider (mix-up)
│ canjea code con PKCE; openid-client valida
│ el ID token (firma/JWKS, iss, aud, exp, nonce)
│ mapea grupos→permisos (sugerencia)
│ ApiClient.resolveFederatedUser (scope
│ federated.identity) → usuario local
│ respondWithTokens (sesión local estándar)
▼ 302 a returnTo (allowlist same-site)
- Authorization Code + PKCE (S256) — nunca implicit flow.
- state anti-CSRF y nonce anti-replay viven en una cookie de transacción JWT firmada, HttpOnly, SameSite=Lax (Lax es obligatorio para sobrevivir el redirect cross-site del IdP), TTL 5 min, single-use.
- Validación del ID token delegada a
openid-client(firma vía JWKS,iss,aud,exp,nonce); algoritmos seguros,alg:nonerechazado. - Mix-up multi-IdP: el callback se ata al proveedor que inició el flujo.
- Account takeover: solo se vincula/aprovisiona con
email_verified === true. Usuarios existentes conservan sus permisos almacenados (los claims de grupos son sugerencia, nunca autoritativos sobre una cuenta viva). - Escalada de privilegios: el aprovisionamiento JIT nunca concede ADMIN y aplica mínimo privilegio por defecto. Los permisos son autoritativos en el API, no en el cliente.
- Usuarios federados sin contraseña local (
password = NULL,auth_source = 'federated');validateCredentialsrechaza el login local de cuentas con password NULL oauth_source != 'local'(cierra el bypass). - SSRF en discovery/JWKS: solo issuers
https, con bloqueo de loopback/link-local/metadata (169.254.169.254)/RFC1918. Escape hatchSSO_ALLOW_INSECURE_ISSUERSsolo dev. - Open redirect:
returnToypost_logout_redirect_urirestringidos a rutas same-site (se rechazan absolutas,//,\\,javascript:, control). - Sin fuga de secretos/errores:
client_secretsolo en el gateway, nunca logueado; loserror_descriptiondel IdP nunca se reflejan (redirect genérico/login?sso_error=1). - Logout federado (RP-initiated
end_session): siempre revoca la familia de refresh local primero (IdP caído nunca mantiene viva la sesión), luego redirige alend_session_endpointconid_token_hint(en cookie firmada HttpOnly). Back-channel logout queda fuera de alcance del starter (requiere endpoint receptor de logout_token con validación de firma/events); el RP-initiated cubre el caso principal sin estado de servidor adicional.
- Registrá la app en el IdP. El callback URL debe ser exacto (sin
comodines):
https://<tu-dominio>/api/v1/auth/sso/<name>/callback. - Definí en el entorno del gateway (ver
.env.example):SSO_<NAME>_ISSUER,_CLIENT_ID,_CLIENT_SECRET,_REDIRECT_URIy, opcionalmente,_SCOPES,_GROUPS_CLAIM,_PERMISSION_MAP,_POST_LOGOUT_REDIRECT_URI,_DISPLAY_NAME,_ICON_KEY. - Configurá
SSO_STATE_SECRET(secreto dedicado en producción). <NAME>(en minúsculas) es el id del proveedor. El front pinta un botón por proveedor desdeGET /api/v1/auth/sso/providers(solo metadatos públicos).