Nuestro inicio de sesión se colgaba exactamente 10 segundos. Lo provocaban nuestras propias cabeceras de seguridad.
Durante aproximadamente una semana, los usuarios recurrentes que abrían nuestro portal veían la palabra "Cargando…" quedarse en la pantalla durante diez segundos antes de que apareciera la página de inicio de sesión. No a veces. No aproximadamente. Diez segundos, cada vez, y luego todo funcionaba a la perfección. Los visitantes que llegaban por primera vez nunca lo veían. Solo las personas que ya habían iniciado sesión antes.
Un error que tarda una cantidad de tiempo aleatoria es un problema de rendimiento. Un error que tarda exactamente diez segundos es una confesión. Nada en una petición web sana se redondea a una potencia limpia de diez. Ese número no es la suma de algún trabajo real; es el techo de un tiempo de espera, y un tiempo de espera significa que algo, en algún lugar, aguarda con paciencia una cosa que nunca va a llegar.
Esta es la historia de lo que esperaba, y de por qué aquello que esperaba estaba bloqueado por una cabecera de seguridad de la que estábamos orgullosos.
Lo que hace el portal al montarse
Nuestro portal es una aplicación de página única. Cuando carga, antes de mostrarte nada, intenta responder a una pregunta: ¿ya has iniciado sesión? La forma educada de hacerlo con OIDC es una comprobación silenciosa. La aplicación le pregunta al proveedor de identidad "si este navegador ya tiene una sesión, dame un token nuevo sin molestar al usuario". Nuestra biblioteca de cliente, oidc-client-ts, expone esto como signinSilent(), y lo llamábamos al montarse desde un ayudante renewSession().
Hay dos maneras de ejecutar esa comprobación silenciosa. Si la aplicación tiene un token de actualización, la biblioteca hace un intercambio discreto por canal trasero, sin interfaz de por medio, y ya estás dentro. Si no tiene un token de actualización, la biblioteca recurre al mecanismo más antiguo: abre un iframe oculto apuntado al endpoint de autorización del proveedor de identidad con prompt=none, y espera a que ese iframe le devuelva un resultado. Todo el propósito del iframe es que sea invisible. Se supone que nunca lo ves, y en una configuración sana nunca lo haces, porque se resuelve en milisegundos.
El nuestro no se resolvía en absoluto.
El muro que construimos nosotros mismos
El iframe carga el host de autenticación. Y el host de autenticación, como cada host que operamos y que aspira a que lo tomen en serio, envía dos cabeceras cuyo único trabajo es decir "no puedes ponerme dentro de un frame":
X-Frame-Options: DENYContent-Security-Policy: frame-ancestors 'none'
Son defensas contra clickjacking, y son correctas. Un atacante que pueda meter tu página de inicio de sesión en un iframe puede flotarla bajo un señuelo, engañar a un usuario para que escriba credenciales reales en lo que parece algo inofensivo, y cosecharlas. frame-ancestors 'none' es la instrucción moderna de que ningún origen, ni siquiera el nuestro, puede incrustar esta página. La activamos de forma deliberada. Es exactamente el tipo de cosa que una revisión de seguridad busca y recompensa.
Así que cuando oidc-client-ts abría su iframe oculto contra ese host, el navegador hacía exactamente lo que le habíamos dicho: se negaba a renderizar la página dentro de un frame. Y aquí viene la parte cruel. Un frame rechazado no lanza ninguna excepción. No hay evento de error que la biblioteca pueda capturar, ni promesa rechazada, ni línea en la consola. El iframe simplemente se queda ahí, vacío, indefinidamente. Desde el punto de vista de la biblioteca todavía no ha pasado nada, así que hace lo único que puede. Espera a que se agote su tiempo de espera. Ese tiempo de espera, el silentRequestTimeout por defecto, es de diez segundos.
Diez segundos de un iframe oculto mirando fijamente a un muro en blanco, luego la biblioteca se rinde, la promesa por fin se rechaza, la aplicación se encoge de hombros y te redirige a la verdadera página de inicio de sesión, y todo funciona. El bloqueo nunca fue un fallo. Fue un éxito que tomó el camino panorámico a través de un iframe condenado.
Dos cosas correctas, una mala costura
Lo que hizo esto genuinamente difícil de ver es que nada estaba roto. Las cabeceras de seguridad eran correctas. El mecanismo de reserva de renovación silenciosa era correcto, un patrón de OIDC legítimo y ampliamente usado. Cada componente se comportaba exactamente como se había diseñado y exactamente como cualquier revisor querría. El bloqueo de diez segundos no vivía dentro de ninguno de ellos. Vivía en el espacio entre ellos, en la suposición que cada uno hacía sobre el otro. La biblioteca de SSO daba por hecho que podía meter al proveedor de identidad en un frame. El proveedor de identidad daba por hecho que nunca se debía permitir a nadie meterlo en un frame. Ambas suposiciones eran defendibles. Simplemente eran incompatibles, y ningún archivo por sí solo contenía la contradicción.
Por qué recurría al iframe siquiera
Eso todavía dejaba una pregunta. La ruta rápida, el intercambio de token de actualización, se habría saltado el iframe por completo. ¿Por qué acababan los usuarios recurrentes en la ruta lenta? Porque no tenían ningún token de actualización que retener. Y no tenían ningún token de actualización porque nuestros propios clientes OAuth del portal se habían aprovisionado sin AllowOfflineAccess, la marca que autoriza a un cliente a que se le emita uno. Sin acceso sin conexión, sin token de actualización, sin ruta rápida, y todos los usuarios recurrentes eran desviados al iframe que nunca podía cargar.
Ese era el defecto real, y era un problema de datos repartido por cada inquilino, no un cambio de código de una sola línea que pudiéramos publicar una vez. Así que la reparación es un servicio de reconciliación que vuelve a aplicar AllowOfflineAccess a los clientes del portal de cada inquilino al arrancar, corrigiendo toda la flota en el siguiente despliegue sin que nadie tenga que tocar un inquilino a mano. Los tokens de actualización volvieron a fluir, y la ruta rápida volvió a la vida por sí sola.
La solución, y la lección
El servicio de reconciliación arregló la causa. Pero un inicio de sesión no debería atascarse durante diez segundos ni siquiera cuando termina en la ruta lenta, así que también reforzamos la costura. renewSession() ahora inspecciona primero el usuario almacenado: si no hay ningún token de actualización a mano, hace un cortocircuito y no devuelve nada de inmediato, se salta el iframe que ya sabe que está condenado, y envía al usuario directamente al inicio de sesión interactivo. La ruta rápida del token de actualización queda intacta. Y como red de seguridad para cualquier renovación en segundo plano que aún abra un iframe, recortamos el tiempo de espera de diez segundos a cinco, de modo que el peor caso es la mitad de malo.
La lección que de verdad nos llevamos trata sobre una categoría de error, no sobre este caso concreto. Un bloqueo es un error, aunque no emita ningún error, ninguna excepción, ninguna línea roja en un registro. La única evidencia que deja es el tiempo transcurrido. Y cuando ese tiempo es un número redondo y limpio, no vayas a la caza de trabajo lento que optimizar. Ve a la caza de un tiempo de espera, y luego encuentra la cosa al otro extremo que, silenciosa y permanentemente, nunca va a responder. La nuestra era un iframe, llamando con educación a una puerta que habíamos cerrado con cerrojo a propósito.
Si prefieres que tu flujo de inicio de sesión ya supiera que un host de autenticación blindado y un iframe de renovación silenciosa no se llevan bien, esa es una costura con la que ya nos hemos topado para que tú nunca tengas que hacerlo. Authagonal entrega la fontanería de SSO y las cabeceras de seguridad como un solo sistema que se probó en conjunto, no como dos mitades correctas que descubres que son incompatibles a razón de diez segundos por carga de página.