Exigíamos MFA. Un parámetro de consulta lo desactivó.
Lanzamos la autenticación multifactor. Después de la contraseña, la página de login pedía un segundo factor, como cabría esperar. Lo probamos, funcionaba, seguimos adelante. Luego, en una pasada previa al lanzamiento sobre nuestro propio servidor de auth, descubrimos que se podía saltar todo el proceso editando una URL.
Esta es la forma del asunto. En un flujo OIDC tu aplicación envía al usuario a /connect/authorize. Si todavía no ha iniciado sesión, el servidor lo rebota a la página de login con el destino original guardado en un parámetro: /login?returnUrl=/connect/authorize?.... Introduces tu contraseña, superas el MFA, y el flujo de login te devuelve a ese returnUrl, momento en el que /connect/authorize emite un código de autorización y los tokens vienen detrás.
El bug vivía en el orden de las operaciones. El paso de la contraseña te dejaba con la sesión iniciada. Es decir, establecía una cookie autenticada. El MFA era la siguiente página de la secuencia. Y /connect/authorize, el endpoint que realmente reparte tokens, hacía exactamente una pregunta antes de hacerlo: ¿está autenticada esta petición? Nunca preguntaba si la sesión había superado un segundo factor. "Autenticado" y "autenticado con MFA" eran la misma cookie.
Así que el bypass no tiene nada de ingenioso. Envías tu contraseña. Recibes la cookie autenticada. Entonces, en lugar de seguir el flujo hasta la pantalla de MFA, vas directo al returnUrl tú mismo, /connect/authorize?..., la dirección que el servidor te entregó al principio de todo. Authorize ve un principal autenticado, ejecuta su única comprobación y emite el código. El segundo factor nunca ocurre. La pantalla de MFA era un paso en un pasillo, y nada te impedía rodearlo.
La razón por la que esto sobrevive a las pruebas es que el camino feliz es hermético. Recorre la interfaz con clics como una persona y te pedirá el segundo factor todas las veces. La barrera es real. Solo que está en el sitio equivocado. La frontera de seguridad en un servidor OIDC no es la pantalla de login. Es /connect/authorize, el punto donde una sesión se convierte en tokens. Cualquier requisito que viva solo en las páginas que conducen a esa frontera es orientativo, porque cualquiera que pueda hacer la petición subyacente puede hacerla directamente.
Ese replanteamiento es la corrección. El MFA no es un paso que realizas. Es una propiedad que la sesión tiene o no tiene. Así que dejamos de modelarlo como una página y empezamos a modelarlo como un claim. Cuando superas el segundo factor, el flujo de login escribe un marcador mfa_authenticated en la cookie de auth. /connect/authorize ya no acepta "autenticado". Exige "autenticado y portando ese marcador". Una cookie de solo contraseña ahora es inerte en el token endpoint: la rebota de vuelta a completar el MFA apuntes a la URL que apuntes, porque lo que le falta es un claim, no una visita a una página.
La federación encaja limpiamente en esto. Cuando un usuario inicia sesión a través de un proveedor de identidad externo que ejecutó su propio MFA, ese proveedor responde por el segundo factor, así que el inicio de sesión federado establece el mismo marcador. A los usuarios de SSO no se les reta dos veces por algo que su IdP ya aplicó. El marcador es la única fuente de verdad, y cada ruta que puede satisfacer legítimamente el MFA lo escribe.
La lección transferible no tiene nada que ver con el MFA en particular. Es esta: aplica un control en la frontera que concede aquello que estás protegiendo, no en el paso de interfaz pensado para conducir hasta ella. Teníamos la comprobación. Habíamos escrito la lógica de MFA, la habíamos probado, la habíamos lanzado. Simplemente estaba enganchada a la parte del sistema que un atacante no tiene por qué usar. Pantallas de consentimiento, aceptación de términos, step-up para scopes sensibles: el mismo modo de fallo. Si el endpoint que emite la credencial no verifica la condición, la pantalla que la pide es una sugerencia.
Cazamos este antes de que ningún cliente lo sufriera, en la misma auditoría que destapó a unos cuantos de sus hermanos. Ese es el argumento para repasar tu propio auth como si lo estuvieras atacando, y en concreto para preguntarte, en cada endpoint que reparte algo valioso, no "si el usuario recorrió el flujo" sino "qué demuestra realmente esta petición". La nuestra demostraba una contraseña. La estábamos tratando como prueba de dos factores.
Aplicar el MFA en el endpoint que acuña los tokens, no en la página que lo pide, es toda la diferencia entre un MFA real y una sugerencia. Authagonal lo aplica donde se emite la credencial, de modo que una URL de retorno editada a mano no lleva a ninguna parte.