Inicio / Artículos / Por qué los 401s concurrentes cierran la sesión de los usuarios y la solución para actualizar sin interrupciones

Por qué los 401s concurrentes cierran la sesión de los usuarios y la solución para actualizar sin interrupciones

Cómo las solicitudes paralelas y el cambio de tokens de actualización se competen entre sí, causando cerramientos inesperados de sesión, y cómo una promesa compartida de actualización en un interceptor de Axios lo evita.

1371 palabras

Un usuario está a mitad de un formulario largo cuando la aplicación lo devuelve a la pantalla de inicio de sesión. No hay error ni caída del programa, y todo lo que escribió desaparece. La lógica de vencimiento del token parece correcta, el flujo de actualización también parece correcto, y sin embargo las sesiones siguen terminando prematuramente. A continuación verá por qué ocurre esto cuando varias solicitudes llegan a un token vencido al mismo tiempo, por qué es tan difícil detectar el error al leer el código, y cómo un pequeño cambio en un interceptor de Axios, al compartir una sola promesa de actualización en curso, hace que desaparezca.

El síntoma: sesiones cerradas que deberían ser imposibles

Imagínese una plataforma de gestión de casos utilizada por el personal de campo. Los agentes ingresan datos de inspecciones o exámenes en tablets, a menudo a través de conexiones móviles poco fiables. Llega un informe, y luego otro de otro usuario: la aplicación los cerró de sesión mientras llenaban un formulario. En un sistema así, eso no es un simple fastidio; puede significar tener que volver a ingresar cuidadosamente los datos durante veinte minutos.

El sospechoso obvio es la expiración de los tokens, y a primera vista parece inocente. Los tokens de acceso duran 15 minutos. Cuando una llamada a la API devuelve 401, el cliente llama al punto de extremo de actualización, guarda el nuevo token y continúa. Nada en esa descripción debería terminar una sesión en medio del uso. Cuando la explicación obvia resulta correcta, lo más productivo es dejar de confiar en su interpretación del código y reproducir el fallo.

Reproducir un problema por expiración de tokens

El error solo aparece bajo una condición: varias llamadas a la API se realizan muy cerca unas de otras, justo en el momento en que vence el token de acceso. Un formulario con varias secciones es un desencadenante perfecto; puede enviar una solicitud de validación, una solicitud de guardado automático y una verificación del estado de subida de archivo en cuestión de milisegundos. Si el token vence dentro de ese intervalo, todas ellas devuelven 401 casi al mismo tiempo.

El interceptor fue diseñado para el caso de una sola solicitud: ante un 401, se llama al endpoint de actualización, se recibe un nuevo token y se vuelve a enviar la solicitud original. Al probarlo con una llamada a la vez, funciona perfectamente. Sin embargo, cuando cuatro llamadas fallan al mismo tiempo, se inician cuatro solicitudes de actualización independientes de inmediato.

Eso choca con una decisión sensata en el backend: la rotación de tokens de actualización. Cuando se utiliza un token de actualización, el servidor lo invalida y emite uno nuevo, de modo que un token robado no pueda ser reutilizado indefinidamente. Ahora, observe las cuatro solicitudes de actualización simultáneas:

  • La primera solicitud de actualización tiene éxito y recibe un nuevo token de actualización.
  • La segunda, tercera y cuarta ya se enviaron con el token de actualización antiguo, antes de que llegara la primera respuesta.
  • El servidor las rechaza, porque ese token acaba de ser invalidado.
  • El interceptor interpreta el fallo en la actualización como “la sesión realmente ha finalizado” y cierra la sesión del usuario.

La duración de vida del token de acceso nunca fue el problema. La suposición errónea era que las actualizaciones nunca se realizan de forma concurrente. Muchas implementaciones de rotación van aún más allá y consideran el reuso de un token de actualización antiguo como señal de robo, revocando toda la familia de tokens, lo que convierte esa misma situación en un proceso de cierre de sesión aún más agresivo.

Por qué leer el código no lo reveló

Se podría decir que esta es una lección más valiosa que la solución en sí. Al leerlo de arriba abajo, el interceptor es correcto: captura el 401, actualiza y vuelve a intentarlo. Esa es la secuencia para la que fue diseñado, y volver a leerlo solo confirma dicha secuencia.

Lo que la lectura oculta es que el interceptor no se ejecuta una sola vez. Se ejecuta una vez por cada solicitud fallida, y esas solicitudes son concurrentes, no secuenciales. Depurarlo como si fuera un script lineal implica razonar sobre qué hace cada paso mientras se ignora cuándo ocurre cada uno.

El patrón se vuelve visible casi de inmediato una vez que se registra una marca de tiempo en cada llamada de actualización. En un escenario como este, se observarían cuatro intentos de actualización en aproximadamente 40 milisegundos uno tras otro, todos dirigidos al mismo endpoint. Una hora mirando la lógica puede reemplazarse por dos minutos observando los tiempos. Cuando un error “no puede ocurrir” según el código, instrumentar el orden y el tiempo de los eventos suele ser más rápido que leer el código nuevamente.

La solución: una actualización en curso, todos los demás esperan

Una vez que queda claro la verdadera naturaleza del problema, la solución es sencilla. En lugar de permitir que cada 401 inicie su propio proceso de actualización, el interceptor verifica si ya hay una actualización en curso. Si la hay, el nuevo error no inicia otra; espera a que se resuelva la promesa pendiente y vuelve a intentarlo cuando eso ocurra. Esto se conoce comúnmente como patrón de vuelo único.

La primera parte es una variable a nivel de módulo que almacena la actualización en curso, o null cuando no hay ninguna actualización activa:

let refreshPromise = null;

El manejador a continuación se llama para una solicitud que falló con el código 401. Si refreshPromise está vacío, llama a refreshAccessToken() y almacena la promesa resultante, adjuntando un bloque finally que restablece la variable en null independientemente de si el proceso de actualización tiene éxito o falla, de modo que la próxima fecha de vencimiento pueda provocar un nuevo intento. Cada llamante, el primero y todos los posteriores, espera entonces esa misma promesa, establece el nuevo token de acceso en la configuración original de la solicitud y vuelve a enviarla mediante axios.

async function handleUnauthorized(originalRequest) {
  if (!refreshPromise) {
    refreshPromise = refreshAccessToken().finally(() => {
      refreshPromise = null;
    });
  }  const newToken = await refreshPromise;
  originalRequest.headers.Authorization = `Bearer ${newToken}`;
  return axios(originalRequest);
}

La sintaxis importa menos que la idea: una única promesa compartida a la que esperan todas las solicitudes fallidas, en lugar de que cada una intente actualizar por su cuenta. El primer 401 inicia el proceso de actualización; cualquier otro 401 que llegue mientras este proceso está en curso se basa en ese resultado en lugar de tener que utilizar nuevamente el token de actualización. Dado que JavaScript ejecuta este código en un único hilo, la verificación y asignación de refreshPromise no pueden intercalarse entre las llamadas, lo que hace que una protección tan simple sea suficiente.

Hay algunos detalles que conviene tener en cuenta al integrar esto en un interceptor real:

  • Si la actualización en sí falla, todas las solicitudes en espera se rechazan al mismo tiempo, de modo que la aplicación puede realizar un cierre de sesión limpio en lugar de varios intentos concurrentes.
  • Mark volvió a enviar las solicitudes (por ejemplo, con una marca _retry en la configuración) para que una solicitud que falle con 401 al refrescarse no entre en un bucle infinito.
  • Asegúrese de que la solicitud de refresco en sí evite este procesador, ya que un 401 del endpoint de refresco intentará refrescarse a sí mismo.
  • Cada pestaña del navegador tiene su propio contexto de JavaScript, por lo que aún pueden competir entre sí; si eso es importante para su aplicación, coordine las acciones entre pestañas o realice el refresco de forma proactiva antes de que expire.
  • Para conocer el diseño del lado servidor, incluyendo la rotación y revocación de tokens, consulte nuestra guía sobre la estrategia de tokens de refresco para sistemas de autenticación en Node.js.

    Puntos clave

    Con la actualización de un solo vuelo implementada, los cierres inesperados de sesión cesan. El cambio más importante es adoptar una costumbre: tratar las llamadas concurrentes como el caso por defecto, y no como un caso extremo para resolver más tarde. Cada vez que escriba un interceptor, una cola o cualquier gestor que responda a un fallo asíncrono, pregúntese qué sucedería si se ejecutara cuatro veces dentro de un intervalo de cincuenta milisegundos. El tráfico en producción proveniente de formularios con alto volumen y conexiones móviles inestables generará esa situación tarde o temprano.

    • El error no se debía realmente a los tokens de actualización; se trataba de código que asumía que los eventos ocurrían uno tras otro.
    • La rotación de tokens de actualización es una buena práctica de seguridad, y es precisamente ella la que expone solicitudes duplicadas de actualización.
    • El código que parece correcto al leerse línea por línea aún puede fallar en entornos concurrentes; registre las marcas de tiempo para saber cuándo ocurren las cosas, no solo qué ocurren.
  • Comparta una promesa de actualización en vuelo entre todas las solicitudes fallidas, réstrela en finally y evite bucles de intentos repetidos.
  • Lecturas relacionadas