La máquina de autenticación: el mapa completo
Seis estados bastan para describir la vida entera de una sesión: comprobando, anónimo, autenticando, autenticado, refrescando y expirado. Esta lección levanta el grafo completo de una sola vez, con sus transiciones legales, sus destinos de error y su reparto entre estado finito y contexto, y muestra qué clase de fallos desaparecen por construcción cuando el flujo deja de ser una constelación de booleanos correlacionados: el usuario cargado sin credencial, el formulario que se envía dos veces, el modo autenticado sin token vivo y la pantalla que decide antes de saber.
La autenticación es el dominio donde el estado disperso hace más daño y donde más veces se escribe igualmente disperso. Casi toda aplicación empieza con un usuario que puede ser nulo, un cargando que empieza en falso y un error que nadie limpia, y termina descubriendo que esas tres variables admiten ocho combinaciones de las cuales seis no significan nada. La máquina de autenticación no es un ejercicio de elegancia: es el reconocimiento de que una sesión atraviesa fases con reglas estrictas sobre qué puede ocurrir después de qué, y de que esas reglas son exactamente lo que un grafo expresa y una constelación de banderas jamás pudo. Este nivel entero se construye sobre el mapa que dibujamos aquí, así que conviene levantarlo completo antes de discutir ninguna de sus partes.
- Enumerar los seis estados canónicos de una sesión y justificar por qué ninguno sobra.
- Distinguir qué información pertenece al estado finito y cuál pertenece al contexto.
- Escribir el grafo con sus destinos de error explícitos y sus transiciones ilegales ausentes.
- Reconocer los fallos clásicos que el grafo elimina sin necesidad de una sola condición defensiva.
Seis estados, ni uno más
El primer instinto al modelar la autenticación es reducirla a dos situaciones, dentro o fuera, y tratar todo lo demás como ruido temporal. Ese instinto es el origen de casi todos los defectos del área, porque lo que llamamos ruido temporal es precisamente donde el usuario pasa los momentos críticos: el arranque, el envío de credenciales, la renovación silenciosa y la caducidad. Cada uno de esos momentos admite eventos distintos y exige una interfaz distinta, y eso es la definición operativa de un estado.
comprobando es el estado en el que la aplicación aún no sabe si hay sesión. No es anónimo con un indicador de carga encima: es una tercera situación epistémica que no debe confundirse con ninguna de las otras dos, y toda la lección siguiente gira alrededor de ella. anonimo es la ausencia confirmada de sesión, un estado estable donde el único evento significativo es el intento de entrar. autenticando es la ventana durante la cual las credenciales están en vuelo; su valor no es informativo sino normativo, porque es el estado donde el envío duplicado debe ser imposible. autenticado es el régimen normal de operación. refrescando es la renovación en curso, con la sesión aún válida pero la credencial en tránsito. Y expirado es la pérdida involuntaria de la sesión, que no es lo mismo que la salida deliberada por más que ambas terminen sin usuario.
import { setup, assign, fromPromise } from 'xstate'
type Sesion = { usuario: { id: string; nombre: string }; expiraEn: number }
type Credenciales = { correo: string; clave: string }
export const auth = setup({
types: {
context: {} as { sesion: Sesion | null; error: string | null; destino: string | null },
events: {} as
| { type: 'ENTRAR'; correo: string; clave: string }
| { type: 'SALIR' }
| { type: 'RENOVAR' },
},
actors: {
restaurar: fromPromise(({ signal }): Promise<Sesion | null> => api.sesionActual(signal)),
iniciar: fromPromise(({ input }: { input: Credenciales }) => api.entrar(input)),
renovar: fromPromise(({ signal }): Promise<Sesion> => api.refrescar(signal)),
},
guards: {
haySesion: ({ event }: any) => event.output !== null,
},
delays: {
ANTES_DE_CADUCAR: ({ context }) =>
Math.max(5_000, (context.sesion?.expiraEn ?? 0) - Date.now() - 60_000),
},
}).createMachine({
id: 'auth',
initial: 'comprobando',
context: { sesion: null, error: null, destino: null },
states: {
comprobando: {
invoke: {
src: 'restaurar',
onDone: [
{ target: 'autenticado', guard: 'haySesion', actions: assign({ sesion: ({ event }) => event.output }) },
{ target: 'anonimo' },
],
onError: 'anonimo',
},
},
anonimo: {
on: { ENTRAR: 'autenticando' },
},
autenticando: {
entry: assign({ error: null }),
invoke: {
src: 'iniciar',
input: ({ event }: any) => ({ correo: event.correo, clave: event.clave }),
onDone: { target: 'autenticado', actions: assign({ sesion: ({ event }) => event.output }) },
onError: { target: 'anonimo', actions: assign({ error: ({ event }) => String(event.error) }) },
},
},
autenticado: {
after: { ANTES_DE_CADUCAR: 'refrescando' },
on: { SALIR: 'anonimo', RENOVAR: 'refrescando' },
},
refrescando: {
invoke: {
src: 'renovar',
onDone: { target: 'autenticado', actions: assign({ sesion: ({ event }) => event.output }) },
onError: 'expirado',
},
on: { SALIR: 'anonimo' },
},
expirado: {
entry: assign({ sesion: null, error: 'sesion caducada' }),
on: { ENTRAR: 'autenticando' },
},
},
})
Merece la pena detenerse en lo que este grafo no contiene, porque ahí está el trabajo real. No hay ninguna transición desde autenticando hacia autenticando, así que el segundo clic en el botón de entrar no dispara una segunda petición: el evento simplemente no existe en ese estado y el intérprete lo descarta. No hay ninguna arista desde anonimo hacia refrescando, así que ningún temporizador huérfano puede intentar renovar lo que no existe. Y comprobando no acepta ENTRAR, lo que impide la carrera clásica en la que el usuario envía el formulario mientras la restauración de sesión sigue en vuelo y ambas respuestas compiten por escribir el mismo contexto.
La regla de reparto es constante en todo el nivel: el estado finito responde a en qué situación estoy y qué es legal ahora, mientras que el contexto guarda datos que no cambian las reglas. El nombre del usuario, el momento de caducidad y el último mensaje de error son hechos y viven en el contexto; la diferencia entre estar renovando y estar caducado es una regla y vive en el grafo. Cuando algo empieza a aparecer en las dos partes a la vez, casi siempre es señal de que el estado finito no está lo bastante desarrollado.
El grafo entero de un vistazo
stateDiagram-v2 [*] --> comprobando comprobando --> autenticado: hay sesion restaurada comprobando --> anonimo: no hay sesion anonimo --> autenticando: ENTRAR autenticando --> autenticado: credenciales validas autenticando --> anonimo: credenciales rechazadas autenticado --> refrescando: vence el margen o RENOVAR autenticado --> anonimo: SALIR refrescando --> autenticado: credencial renovada refrescando --> expirado: el refresco falla refrescando --> anonimo: SALIR expirado --> autenticando: ENTRAR
La tabla de transiciones dice lo mismo en el registro que un revisor puede auditar línea a línea, y sirve además como contrato con quien escriba la interfaz, porque fija qué eventos tiene sentido ofrecer en cada pantalla.
| Estado | Eventos admitidos | Destinos posibles | Qué muestra la interfaz |
|---|---|---|---|
comprobando |
ninguno | autenticado | anonimo |
esqueleto neutro, sin decidir |
anonimo |
ENTRAR |
autenticando |
formulario y error previo si lo hubo |
autenticando |
ninguno | autenticado | anonimo |
botón inhabilitado, envío en curso |
autenticado |
SALIR | RENOVAR |
refrescando | anonimo |
la aplicación completa |
refrescando |
SALIR |
autenticado | expirado | anonimo |
la aplicación, sin interrupción visible |
expirado |
ENTRAR |
autenticando |
aviso de caducidad y formulario |
La forma más económica de impedir un envío duplicado no es una bandera enviando consultada por el manejador del clic, sino no declarar ENTRAR dentro de autenticando. La primera solución exige que todo el mundo se acuerde de mirar la bandera; la segunda es imposible de olvidar porque no hay nada que recordar. Cada condición defensiva que borras al pasar a un grafo es una oportunidad menos de que alguien la escriba mal dentro de seis meses.
Lo que el grafo elimina sin escribir una línea
Autenticado sin credencial
Imposible: la única entrada a autenticado asigna la sesión en la misma transición, así que el modo y el dato nacen juntos.
Doble envío del formulario
Imposible: autenticando no declara ENTRAR, y el intérprete descarta el evento en lugar de lanzar una segunda petición.
Decidir antes de saber
Imposible: comprobando es un estado propio, no un anónimo provisional, y nadie puede confundirlo con una ausencia confirmada.
Caducar y salir confundidos
Distinguibles: expirado conserva el motivo y la intención del usuario; anonimo tras SALIR no arrastra nada de la sesión previa.
Las cuatro tarjetas comparten una misma mecánica y conviene nombrarla, porque es el argumento central de todo el nivel: ninguno de esos fallos se corrige, se vuelve inexpresable. La diferencia es enorme en la práctica. Un fallo corregido depende de que la corrección siga presente después de cada refactorización, de cada nueva pantalla y de cada persona que se incorpora al equipo. Un fallo inexpresable no necesita mantenimiento, porque el código que lo produciría no compila o no tiene efecto.
Queda una advertencia importante antes de seguir. Todo lo anterior describe el cliente, y el cliente nunca es la autoridad. Este grafo modela lo que la aplicación cree sobre su sesión y qué le ofrece al usuario en consecuencia; la verdad sobre si una petición está permitida vive en el servidor y se verifica en cada llamada. Una máquina de autenticación bien hecha no sustituye la verificación remota: la vuelve innecesaria como mecanismo de interfaz, para que el servidor pueda dedicarse a ser autoridad en lugar de fuente de sorpresas.
La transición temporizada hacia refrescando es una estimación optimista basada en el reloj local, y el reloj local miente: el portátil se suspende, la pestaña se congela en segundo plano y el desfase horario del dispositivo puede ser de minutos. Trata el temporizador como una renovación preventiva, nunca como la definición de caducidad. La verdad llega siempre del servidor, en forma de respuesta rechazada, y el grafo debe aceptar esa noticia desde autenticado igual que la acepta desde refrescando.
El error fundacional de casi toda implementación de autenticación es tratar la sesión como una propiedad y no como un proceso. Una propiedad se consulta, es verdadera o falsa, y su historia es irrelevante; un proceso tiene fases, y cada fase determina qué puede ocurrir a continuación. La prueba de que la sesión es lo segundo está en que dos situaciones con el mismo valor de la supuesta propiedad exigen comportamientos opuestos: anonimo y expirado coinciden en no tener usuario y difieren en todo lo demás, porque uno no llegó nunca a entrar y el otro fue expulsado a mitad de una tarea, y esa diferencia decide si la interfaz muestra una bienvenida o una disculpa, si conserva el destino al que el usuario iba o lo descarta, si preserva el borrador a medio escribir o lo tira. Ninguna cantidad de banderas captura eso, porque las banderas describen el presente y aquí lo que importa es el camino. Por eso el mapa de seis estados no es una decoración sobre un problema binario: es la afirmación de que autenticarse es una trayectoria y de que la posición en esa trayectoria es el dato más importante del sistema. Cuando lo aceptas, la pregunta que hace tu código deja de ser está autenticado, que casi nunca es la pregunta correcta, y pasa a ser dónde está esta sesión ahora mismo y qué le está permitido, que es la única que conoce la diferencia entre un usuario que acaba de llegar, uno que está entrando, uno que trabaja, uno cuya credencial se renueva en silencio y uno al que el sistema acaba de dejar fuera.
- Localiza en tu aplicación todas las variables que participan en la autenticación y cuenta cuántas combinaciones admiten frente a cuántas significan algo.
- Escribe la lista de eventos reales de tu dominio y coloca cada uno bajo los estados donde debe ser legal, sin permitirte un evento global.
- Dibuja el grafo con los seis estados y marca en rojo cada arista que hoy existe en tu código y no debería.
- Comprueba en tu implementación actual qué ocurre al pulsar dos veces seguidas el botón de entrar y compáralo con lo que hace el grafo.
- Separa el contexto del estado finito por escrito: una columna de hechos y otra de reglas, sin que ningún dato aparezca en las dos.
- Justifica ante alguien de tu equipo por qué
expiradoyanonimono pueden ser el mismo estado, usando un caso concreto de tu producto.