Salir y ser expulsado: dos finales distintos
Cerrar sesión y perderla terminan en la misma ausencia de usuario y exigen comportamientos opuestos, porque uno es una decisión y el otro una interrupción. Esta lección separa ambos finales en estados distintos, define qué conserva cada uno y qué destruye, ordena la limpieza entre memoria, cache de servidor, almacenamiento persistente y credenciales del servidor, y desarma la carrera más traicionera del área: el efecto pendiente que termina después de la salida y resucita una sesión que ya no debía existir.
Al final de toda sesión hay un usuario que ya no está, y esa igualdad superficial esconde dos situaciones que no se parecen en nada. Cuando alguien pulsa salir, ha terminado, ha decidido, probablemente comparte el equipo y quiere que no quede rastro; cuando el sistema le expulsa porque la credencial murió, estaba a mitad de una frase, no ha decidido nada y espera volver justo donde estaba. Colapsar ambos finales en un mismo estado anónimo es el atajo que produce las dos peores experiencias del área: la disculpa dirigida a quien se marchó voluntariamente, y el borrado silencioso del trabajo de quien fue interrumpido. La distinción no es de mensaje: es de estado, y de ahí se derivan políticas de limpieza distintas.
- Separar la salida deliberada de la expulsión en estados con políticas de conservación distintas.
- Modelar el cierre con revocación remota sin bloquear jamás la respuesta de la interfaz.
- Ordenar la limpieza entre contexto, cache de datos, almacenamiento persistente y credenciales.
- Neutralizar la resurrección de sesión provocada por efectos que terminan tras la salida.
Dos finales, dos políticas
La pregunta que decide todo es qué merece sobrevivir al final de la sesión. En la salida deliberada la respuesta es nada: el usuario pidió irse, quizá desde un ordenador compartido, y cualquier residuo es a la vez una molestia y un riesgo. En la expulsión la respuesta es casi todo lo que no sea privado: la dirección donde estaba, el borrador a medio escribir, el filtro de la tabla que había configurado y, sobre todo, la explicación de por qué se le pide entrar de nuevo. Esa asimetría se pierde por completo si ambos caminos llevan al mismo destino.
autenticado: {
on: {
SALIR: 'cerrando',
CREDENCIAL_RECHAZADA: 'expirado',
INACTIVIDAD: 'expirado',
},
},
cerrando: {
invoke: {
src: 'revocar',
onDone: 'anonimo',
onError: 'anonimo',
},
after: { LIMITE_CIERRE: 'anonimo' },
},
anonimo: {
entry: ['olvidarTodo', 'vaciarCacheDeDatos', 'cerrarCanalesVivos'],
on: { ENTRAR: 'autenticando' },
},
expirado: {
entry: ['olvidarCredencial', 'invalidarDatosPrivados', 'anotarMotivo'],
on: { ENTRAR: 'autenticando', DESCARTAR: 'anonimo' },
},
Las tres diferencias están en las acciones de entrada y son deliberadas. anonimo olvida todo, incluido el destino guardado y cualquier borrador asociado al usuario, y vacía la cache de datos por completo. expirado olvida únicamente la credencial, marca los datos privados como no válidos para que se vuelvan a pedir cuando haya sesión otra vez, y anota el motivo para que la pantalla de entrada pueda explicarse. La cache no se vacía sino que se invalida, que no es lo mismo: los datos siguen ahí, marcados como sospechosos, y desaparecerán solos si nadie vuelve; si el usuario entra de nuevo en diez segundos, la aplicación se repuebla al instante.
Salida deliberada
El usuario decidió irse. No hay nada que explicar, no hay nada que retomar y no debe quedar rastro en el dispositivo.
Credencial caducada
El sistema interrumpió el trabajo. Hay que explicarse, conservar el destino y devolver al usuario exactamente donde estaba.
Sesión revocada
Alguien con autoridad la cerró desde fuera. Se comporta como caducidad, pero el reintento inmediato no la va a resolver.
Inactividad prolongada
Ni salida ni caducidad: casi siempre debería ser un bloqueo reversible en lugar de un cierre completo.
Las dos últimas tarjetas son las que casi nadie modela y las que más confusión producen en soporte. Una sesión revocada por el servidor y una credencial simplemente vencida llegan al cliente con respuestas parecidas y exigen mensajes opuestos, porque en un caso volver a entrar funciona y en el otro no. Distinguirlas exige que el servidor comunique el motivo y que la máquina lo guarde, y no cuesta más que un campo en el contexto.
El estado cerrando existe porque la salida tiene una parte remota que casi todo el mundo trata como opcional y no lo es. Si la credencial no se revoca en el servidor, sigue siendo válida hasta su caducidad natural, y quien la haya copiado puede seguir usándola después de que el usuario creyera haber cerrado. Ahora bien, esa revocación no puede secuestrar la interfaz: el plazo del estado garantiza que, pase lo que pase con la red, la aplicación aterriza en anonimo en menos de un segundo. Salir siempre funciona, incluso sin conexión; lo que puede fallar es la parte que el usuario no ve.
Una sesión cerrada solo en el cliente es un teatro: el estado local dice que no hay nadie y la credencial sigue siendo aceptada por el servidor durante todo el tiempo que le quedara de vida. En un dispositivo compartido o comprometido esa diferencia es la totalidad del problema de seguridad que la salida pretendía resolver. Revoca siempre, no esperes al resultado para actualizar la interfaz, y registra los fallos de revocación para poder detectar el caso en que un servidor caído deje credenciales vivas sin que nadie se entere.
El orden de la limpieza
Limpiar parece trivial hasta que se enumera lo que hay que limpiar, y entonces resulta que el estado de un usuario está repartido en cinco lugares con dueños distintos y ciclos de vida distintos. La máquina no puede ser dueña de todos, pero sí debe ser quien ordena la operación, porque es el único componente que sabe en qué momento exacto la sesión dejó de existir.
| Depósito | Quién lo posee | Qué hacer al salir | Qué hacer al expirar |
|---|---|---|---|
| contexto de la máquina | la propia máquina | vaciar por completo | conservar destino y motivo |
| cache de estado de servidor | la capa de datos | vaciar todo | invalidar lo privado |
| almacenamiento persistente | la aplicación | borrar claves del usuario | conservar borradores no sensibles |
| credencial del servidor | el servidor | revocar la familia entera | ya está muerta |
| canales vivos y suscripciones | los actores hijos | cerrar antes de la transición | cerrar y permitir reapertura |
El orden importa por una razón concreta: si vacías la cache de datos antes de que la interfaz haya dejado de mostrar las pantallas privadas, algunos componentes se volverán a montar, pedirán sus datos, encontrarán la cache vacía y lanzarán peticiones nuevas con una credencial que acaba de morir. El resultado es una ráfaga de rechazos justo después de salir, que en el mejor caso ensucia los registros y en el peor dispara un refresco reactivo que reabre el problema. La secuencia correcta es primero la transición, para que la interfaz deje de pedir cosas, y después la purga, que en una máquina se consigue gratis poniendo la limpieza como acción de entrada del destino y no como acción de salida del origen.
stateDiagram-v2 autenticado --> cerrando: SALIR autenticado --> expirado: credencial rechazada autenticado --> expirado: inactividad prolongada refrescando --> cerrando: SALIR refrescando --> expirado: el refresco falla cerrando --> anonimo: revocado o vencido el plazo expirado --> autenticando: ENTRAR expirado --> anonimo: DESCARTAR anonimo --> autenticando: ENTRAR
Muchas aplicaciones tratan la inactividad prolongada como una expulsión y muchas otras deberían tratarla como un bloqueo. La diferencia es qué ocurre al volver: una expulsión exige credenciales completas, un bloqueo puede pedir solo un segundo factor o una confirmación breve y devolver al usuario exactamente donde estaba. Si tu producto se usa en jornadas largas con pausas, un estado intermedio de pantalla bloqueada, con la sesión técnicamente viva pero la interfaz cubierta, es mucho mejor experiencia y a la vez mejor seguridad que un cierre completo que la gente aprende a odiar.
La sesión que resucita
Queda la carrera más desagradable de este nivel y la que más tiempo consume en depuración cuando aparece. El usuario sale mientras una renovación estaba en vuelo; la salida transiciona a cerrando, la interfaz se vacía, todo parece correcto; medio segundo después la renovación responde con éxito, su manejador asigna la sesión nueva y el usuario aparece dentro otra vez. En una implementación con promesas sueltas ese fallo es casi inevitable, porque nadie canceló nada y el manejador no tiene forma de saber que el mundo cambió mientras esperaba.
refrescando: {
invoke: {
src: 'renovar',
onDone: { target: 'autenticado', actions: 'guardarSesion' },
onError: 'expirado',
},
on: { SALIR: 'cerrando' }, // salir de aqui detiene el actor invocado
},
La solución está en la semántica de la invocación y no exige escribir nada: un actor invocado pertenece al estado que lo invoca, así que al abandonar refrescando el intérprete lo detiene y descarta su resultado, con la señal de aborto propagada a la petición si el actor la respeta. La transición onDone no puede dispararse porque el estado que la declaraba ya no está activo, y la acción de asignación no llega a ejecutarse jamás. La resurrección deja de ser un caso que hay que recordar y pasa a ser algo que no tiene mecanismo por el que ocurrir.
Con esa base, la disciplina se resume en una frase: todo efecto que pueda escribir en la sesión debe estar invocado desde el estado al que pertenece, nunca lanzado por libre desde un efecto de la interfaz. En cuanto un temporizador o una promesa vive fuera del grafo, el grafo pierde la capacidad de cancelarlo y vuelven todas las carreras que creíamos eliminadas. Los temporizadores declarados con after desaparecen solos al salir del estado; los que se crean a mano hay que recordarlos y limpiarlos, y esa es precisamente la clase de recuerdo que falla.
La salida debe propagarse además al resto de pestañas del mismo navegador, con el mismo canal que usábamos para coordinar el refresco. La pestaña que cierra emite un aviso y las demás lo reciben como un evento cualquiera, con la particularidad de que en ellas no hay que revocar nada porque la revocación ya ocurrió una vez.
const canal = new BroadcastChannel('auth')
canal.onmessage = (e) => {
if (e.data.tipo === 'fin') actorAuth.send({ type: 'CIERRE_EXTERNO', motivo: e.data.motivo })
}
// En la maquina, el aviso ajeno entra por una arista distinta que no revoca de nuevo.
CIERRE_EXTERNO: [
{ target: 'expirado', guard: 'motivoEsCaducidad' },
{ target: 'anonimo' },
],
Sin esa propagación, el usuario cierra en una ventana, ve otra abierta con su nombre todavía visible y pierde por completo la confianza en que el cierre haya servido de algo. Y en el caso contrario, cuando el cierre nace de una expulsión, propagarlo evita que tres pestañas descubran la caducidad por separado y disparen tres intentos de renovación contra un servidor que ya dijo que no.
Guardar en el contexto por qué terminó la sesión cuesta un campo y paga durante años. Permite que la pantalla de entrada diga algo distinto según el caso, permite que soporte distinga una caducidad normal de una revocación administrativa sin pedir capturas, y permite medir cuántas sesiones acaban por decisión del usuario frente a cuántas acaban por fallo del sistema. Esa proporción es uno de los indicadores más honestos que existen sobre la salud real de tu autenticación.
Cuando algo termina, la tentación es representarlo con un vacío: sin usuario, sin datos, sin nada. Pero un vacío no distingue entre no haber empezado nunca y haber sido interrumpido, y esa distinción suele ser la más valiosa que el sistema tiene en ese momento. La forma de terminar es un dato de pleno derecho, y modelarla decide cosas que ningún otro sitio puede decidir: si la aplicación pide disculpas o da la bienvenida, si conserva el trabajo a medias o lo tira, si recuerda a dónde iba el usuario o le devuelve al principio, si trata el próximo intento de entrada como el de un desconocido o como el regreso de alguien que estaba a mitad de algo. Esta lección lo muestra en la autenticación porque ahí duele antes, pero la ley es general y se reconoce en cualquier proceso serio: una descarga cancelada no es una descarga que no empezó, una transacción abortada no es una transacción inexistente, una conexión caída no es una conexión que nunca se abrió. En todos esos casos, colapsar el final en la nada equivale a destruir el contexto justo cuando más falta hace para decidir bien. Por eso los estados finales de un buen statechart llevan nombre y no son un mismo agujero: cada uno codifica una historia distinta de cómo se llegó hasta ahí, y esa historia es lo único que permite al sistema comportarse con la clase de sensatez que los usuarios llaman, sin saber por qué, sentirse bien tratados.
- Recorre tu código y localiza todos los caminos que terminan una sesión; comprueba cuántos distinguen la salida de la expulsión.
- Escribe la política de conservación de cada final en una tabla: qué se borra, qué se conserva y por qué.
- Comprueba que tu salida revoca la credencial en el servidor y mide qué ocurre si esa revocación tarda diez segundos.
- Sal mientras una renovación está en vuelo y verifica que la sesión no reaparece medio segundo después.
- Vacía la cache de datos en distinto orden respecto a la transición y observa la ráfaga de peticiones rechazadas que provoca el orden incorrecto.
- Propaga el cierre entre pestañas y confirma que ninguna ventana abierta sigue mostrando datos del usuario que acaba de salir.