Capítulo 27 - Parte 4: SSR con autenticación, cookies y APIs privadas¶
Parte 4 de 4 · Capítulo 27 · PARTE XII - Optimización y Rendimiento
La autenticación es el punto donde SSR se vuelve verdaderamente complejo. En el cliente todo es familiar: el token vive en localStorage, se lee al iniciar la app, se adjunta a los headers de cada petición. En el servidor Node.js ese mundo no existe. No hay localStorage, no hay sessionStorage, no hay window. Pero sí hay cookies, y las cookies son la pieza central de una estrategia de autenticación SSR correctamente diseñada.
Por qué localStorage no funciona en el servidor¶
localStorage es una API del navegador. Cuando Angular corre en Node.js durante el renderizado SSR, el objeto window no existe y cualquier acceso a localStorage lanza un ReferenceError en tiempo de ejecución. El mismo problema afecta a document.cookie, navigator, window.location y cualquier API que asuma la existencia de un entorno browser.
La solución no es envolver todo en try/catch, sino diseñar los servicios de autenticación para que funcionen correctamente en ambos entornos: leyendo cookies cuando están en el servidor y usando el mecanismo apropiado cuando están en el cliente.
El token REQUEST de @angular/ssr¶
@angular/ssr expone dos injection tokens especiales que permiten acceder al objeto Request y Response de Express desde cualquier servicio de Angular:
// src/app/core/services/autenticacion.service.ts
import { Injectable, inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
import { REQUEST, RESPONSE_INIT } from '@angular/ssr';
@Injectable({ providedIn: 'root' })
export class AutenticacionService {
private plataformaId = inject(PLATFORM_ID);
// REQUEST solo tiene valor en el servidor; en el cliente es null
private peticion = inject(REQUEST, { optional: true });
obtenerToken(): string | null {
if (isPlatformServer(this.plataformaId)) {
// En el servidor leemos la cookie de la request de Express
const cookies = this.peticion?.headers.get('cookie') ?? '';
return this.parsearCookie(cookies, 'session_token');
}
// En el cliente el token no lo guardamos en localStorage sino en una cookie
// que el servidor también puede leer (HttpOnly: false para este caso)
return this.parsearCookie(document.cookie, 'session_token');
}
private parsearCookie(encabezado: string, nombre: string): string | null {
const coincidencia = encabezado
.split(';')
.map(c => c.trim())
.find(c => c.startsWith(`${nombre}=`));
return coincidencia ? coincidencia.split('=')[1] : null;
}
}
Dos detalles importantes: inject(REQUEST, { optional: true }) es obligatorio -en el cliente no existe y sin optional: true Angular lanzaría un error al inicializar el servicio en el browser. Y la cookie session_token debe ser accesible tanto desde el servidor (en el header cookie de la request) como desde el cliente (en document.cookie), por lo que no puede ser HttpOnly en este caso. Más adelante veremos cuándo sí conviene usar HttpOnly.
isPlatformBrowser e isPlatformServer para código condicional¶
El patrón más común en servicios que necesitan comportarse diferente según el entorno es la comprobación de plataforma. Angular provee dos funciones de ayuda para este propósito:
// src/app/core/services/almacenamiento.service.ts
import { Injectable, inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
@Injectable({ providedIn: 'root' })
export class AlmacenamientoService {
private plataformaId = inject(PLATFORM_ID);
// Cache en memoria para el servidor (vive solo mientras dura el request)
private memoriaServidor = new Map<string, string>();
guardar(clave: string, valor: string): void {
if (isPlatformBrowser(this.plataformaId)) {
// En el cliente podemos usar localStorage normalmente
localStorage.setItem(clave, valor);
} else {
// En el servidor usamos un Map temporal - solo dura lo que el request
this.memoriaServidor.set(clave, valor);
}
}
obtener(clave: string): string | null {
if (isPlatformBrowser(this.plataformaId)) {
return localStorage.getItem(clave);
}
return this.memoriaServidor.get(clave) ?? null;
}
}
Este patrón evita el ReferenceError en el servidor y mantiene el código de los componentes limpio: ellos llaman a AlmacenamientoService sin saber nada de la plataforma subyacente.
Cookies HttpOnly: la opción segura para tokens de sesión¶
Para aplicaciones donde la seguridad es prioritaria, el token de sesión debe vivir en una cookie HttpOnly. Estas cookies son invisibles para JavaScript del cliente -lo que las protege de ataques XSS- pero están disponibles en los headers de cada petición HTTP, incluyendo las que llegan al servidor Express durante SSR.
El servidor Express establece la cookie al autenticar:
// server.ts - endpoint de login
import express from 'express';
const servidor = express();
servidor.post('/api/login', async (req, res) => {
const { usuario, contrasena } = req.body as { usuario: string; contrasena: string };
const token = await autenticarUsuario(usuario, contrasena);
if (!token) {
res.status(401).json({ error: 'Credenciales inválidas' });
return;
}
// Cookie HttpOnly: segura (HTTPS), invisible para JS del cliente
res.cookie('session_token', token, {
httpOnly: true, // No accesible desde document.cookie
secure: true, // Solo se envía por HTTPS
sameSite: 'strict',
maxAge: 86400000 // 24 horas en milisegundos
});
res.json({ ok: true });
});
Y el servicio de autenticación Angular la lee en el servidor usando REQUEST:
// src/app/core/services/sesion.service.ts
import { Injectable, inject, PLATFORM_ID } from '@angular/core';
import { isPlatformServer } from '@angular/common';
import { REQUEST } from '@angular/ssr';
@Injectable({ providedIn: 'root' })
export class SesionService {
private plataformaId = inject(PLATFORM_ID);
private peticion = inject(REQUEST, { optional: true });
estaAutenticado(): boolean {
if (isPlatformServer(this.plataformaId)) {
// La cookie HttpOnly llega en los headers de la request de Express
const encabezadoCookies = this.peticion?.headers.get('cookie') ?? '';
return encabezadoCookies.includes('session_token=');
}
// En el cliente no podemos leer la cookie HttpOnly directamente,
// pero podemos usar una cookie no-HttpOnly separada como indicador de sesión,
// o mantener el estado de autenticación en un Signal.
return this.tieneIndicadorSesion();
}
private tieneIndicadorSesion(): boolean {
// Cookie pública (no HttpOnly) que solo indica si hay sesión activa
return document.cookie.includes('sesion_activa=true');
}
}
Redirigir al login desde el servidor¶
Cuando una ruta protegida recibe una petición sin sesión válida, el servidor debe responder con una redirección (HTTP 302) en lugar de renderizar la página protegida. Esto evita exponer contenido privado y mejora la experiencia de usuario al no mostrar un flash de la página antes del redirect.
El guard funcional verifica la sesión y, en el servidor, usa el token RESPONSE_INIT para manipular los headers de la respuesta:
// src/app/core/guards/autenticacion.guard.ts
import { inject } from '@angular/core';
import { Router, CanActivateFn } from '@angular/router';
import { SesionService } from '../services/sesion.service';
import { PLATFORM_ID } from '@angular/core';
import { isPlatformServer } from '@angular/common';
import { RESPONSE_INIT } from '@angular/ssr';
export const autenticacionGuard: CanActivateFn = () => {
const sesion = inject(SesionService);
const router = inject(Router);
const plataformaId = inject(PLATFORM_ID);
const respuestaInit = inject(RESPONSE_INIT, { optional: true });
if (sesion.estaAutenticado()) {
return true;
}
if (isPlatformServer(plataformaId) && respuestaInit) {
// En el servidor configuramos el status 302 antes de que Angular termine
respuestaInit.status = 302;
respuestaInit.headers = { ...respuestaInit.headers, Location: '/login' };
}
// Tanto en servidor como en cliente, redirigimos con el router
return router.createUrlTree(['/login']);
};
La diferencia entre el comportamiento servidor y cliente es que en el servidor necesitamos que Express devuelva un 302 real al navegador, no solo que Angular navegue internamente. El token RESPONSE_INIT permite modificar los metadatos de la respuesta HTTP que Express va a enviar una vez que Angular termine de renderizar.
DOCUMENT: el token seguro para acceder al DOM¶
Cuando un componente o servicio necesita acceder a document -para leer cookies, manipular clases del body, o interactuar con el DOM- la forma correcta en SSR es usar el token DOCUMENT en lugar de la variable global:
// src/app/core/services/tema.service.ts
import { Injectable, inject, PLATFORM_ID } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { isPlatformBrowser } from '@angular/common';
@Injectable({ providedIn: 'root' })
export class TemaService {
private documento = inject(DOCUMENT);
private plataformaId = inject(PLATFORM_ID);
aplicarTemaOscuro(): void {
// document está disponible en ambas plataformas a través del token DOCUMENT,
// pero en el servidor es una implementación simulada (domino/jsdom)
this.documento.documentElement.classList.add('tema-oscuro');
}
leerPreferenciaDeTema(): string {
if (isPlatformBrowser(this.plataformaId)) {
// localStorage solo disponible en el cliente
return localStorage.getItem('tema') ?? 'claro';
}
// En el servidor devolvemos el valor por defecto
return 'claro';
}
}
DOCUMENT funciona en ambas plataformas porque en el servidor Angular usa una implementación de DOM compatible (como @angular/platform-server con el DOM de Node.js). La variable global document lanzaría ReferenceError si Node.js no la tiene configurada; el token DOCUMENT siempre es seguro.
Diagrama: flujo de autenticación en SSR¶
sequenceDiagram
participant N as Navegador
participant E as Express (server.ts)
participant A as Angular SSR
participant G as Guard de autenticación
N->>E: GET /panel (cookie: session_token=xyz)
E->>A: Renderizar /panel con Request
A->>G: CanActivate → estaAutenticado()
G->>A: Lee REQUEST.headers.cookie
alt Tiene sesión válida
A->>A: Renderiza /panel con datos del usuario
A-->>E: HTML completo
E-->>N: 200 OK + HTML renderizado
else Sin sesión
G->>A: RESPONSE_INIT.status = 302
A-->>E: Redirect a /login
E-->>N: 302 Found → Location: /login
end
Puntos clave¶
localStorageno existe en Node.js; los tokens de sesión en SSR deben vivir en cookies que viajan en los headers HTTPinject(REQUEST, { optional: true })da acceso a la request de Express desde cualquier servicio Angular;optional: trueevita errores en el cliente donde no existeisPlatformBrowser(PLATFORM_ID)eisPlatformServer(PLATFORM_ID)son los guardias para código que solo debe ejecutarse en uno de los dos entornos- Las cookies
HttpOnlyson la opción más segura para tokens de sesión: el servidor las lee, el cliente nunca las ve desde JavaScript - El token
DOCUMENTes la alternativa segura a la variable globaldocument; el tokenRESPONSE_INITpermite configurar el status HTTP de la respuesta desde un guard o servicio
¿Qué sigue?¶
El Capítulo 28 explora el ecosistema de librerías de UI con Angular Material 3, donde aplicaremos todo lo visto sobre rendimiento y SSR para construir interfaces accesibles y visualmente coherentes con Design Tokens.