Saltar a contenido

30 de junio de 2026

Cerré la capa de validación del milestone con el schema de usuarios, que es la primera vez que el borde se apoya en una pieza del dominio de acceso.

La segunda compuerta cierra la Task 4: el schema de usuarios (Task 4b)

Hoy escribí la pieza que le faltaba a la validación para quedar completa: el schema de usuarios, el que corre cuando el admin da de alta o edita a alguien en la whitelist. Es hermana del de tiendas que dejé el otro día, pero tiene una gracia que la de tiendas no tenía, y es justo la razón por la que quise que entrara sola en el historial: es la primera vez en todo el proyecto que la validación del borde estira la mano hacia el dominio de acceso y reusa algo de ahí.

Reusa dos cosas, en realidad. La primera es la normalización del correo, esa función chiquita que saqué a su propio lugar en la 3b. Antes de mirar si el correo es del dominio que corresponde, lo paso por ella: lo dejo en minúsculas y sin espacios a los costados. Recién sobre esa forma canónica aplico la regla del dominio, que es la que de verdad importa acá: tiene que ser un @zavidoro.com.py y no cualquier otra cosa. Así un " Diana@ZAVIDORO.COM.PY " entra, se acomoda solo y queda guardado como diana@zavidoro.com.py, prolijo; y un diana@gmail.com se cae en la puerta. La segunda pieza que reuso es la lista de roles —ROLES, el {admin, auditor} que ya vivía en la decisión de acceso—. No quise tener dos definiciones de "qué es un rol válido" sueltas por ahí, una en el dominio y otra acá, porque el día que cambie una y me olvide de la otra empieza el dolor de cabeza. Una sola fuente, y el schema la consulta.

Hubo un caso de prueba que me dio gusto escribir, medio de sombrero de seguridad. Es fácil chequear el dominio mirando que el correo "termine en" @zavidoro.com.py, pero ahí se cuela un diana@zavidoro.com.py.atacante.com, que termina en otra cosa y igual aparenta ser de los nuestros. Por eso no fui por el "termina en" sino por una expresión anclada de punta a punta: algo no vacío, sin arrobas ni espacios, y después el dominio exacto cerrando la cadena. Con eso el subdominio engañoso no pasa, y lo dejé como test para que quede documentado que el caso lo pensé.

El ciclo, el de siempre. Nueve pruebas primero —el auditor bien formado, el admin, el correo de otro dominio, el correo sin nada antes del arroba, el del subdominio tramposo, la canonización de mayúsculas y espacios, el rol que no existe, el nombre vacío y el recorte de espacios del nombre—, después el rojo de cajón con el módulo sin existir, y recién ahí el schema. Nueve en verde, la suite entera del proyecto en treinta y nueve sin una caída, cobertura al 100% sobre lib, tipos y linter sin una queja. Con esto la Task 4 queda entera: las dos compuertas de la validación, tiendas y usuarios, escritas y probadas. Lo que viene es el tramo grande del milestone, cablear Auth.js con Entra (la Task 5), donde toda esta lógica de dominio y validación que vengo dejando lista por separado al fin se enchufa en el flujo real de inicio de sesión.

Afiné el grano de la Task 5 antes de meterle mano: de 4 a 5 commits

Antes de arrancar con Auth.js me frené a mirar una vez más cómo tenía partida la Task 5, porque es la más pesada del milestone y no quería entrar con el desglose mal pensado. Estaba en cuatro commits: provider + arranque, callback de signIn, callbacks de jwt/session, y la vinculación del ms_oid. La pregunta que me hice fue si cuatro era de verdad lo más fino que se podía, sin caer en el vicio que vengo cuidando —partir por archivo o por endpoint para inflar el conteo, que en el cuaderno se lee como lista de supermercado y no como trabajo pensado.

Repasando pieza por pieza me di cuenta de que había exactamente un corte más que sí valía la pena, y uno solo. El primer commit juntaba dos cosas que no son la misma: validar que los secretos estén al arranque, y cablear el provider de Entra. Lo primero es una pieza pura y aislable —una guarda que, dado el entorno, exige que estén AUTH_* y AUTH_URL o se cae fuerte—, con su propio ciclo de TDD, idéntica en espíritu a cuando aparté la normalización del correo en la 3b. Y además es buen diseño en sí mismo: validar los secretos al arranque es justo lo que pide la disciplina de seguridad que vengo siguiendo. Así que esa la saco a su propio commit, y el provider queda en el siguiente, apoyándose en ella.

Donde no me dejé tentar fue en el resto. Los callbacks de jwt y session son un solo comportamiento —el rol y el id que viajan hasta la sesión—; un jwt sin su session no expone nada que se pueda observar, así que separarlos sería esa granularidad de mentira que la regla justamente prohíbe. Lo mismo con el signIn: la consulta a la whitelist y el redirect a /denied son una sola conducta, "el no habilitado se frena en la puerta", y la decisión de fondo ya vive aislada desde la 3a. Conclusión: el techo honesto de la Task 5 es cinco commits, no más. Actualicé el plan con ese desglose —5a guarda de secretos, 5b provider, 5c signIn, 5d jwt/session, 5e ms_oid— y el total del milestone pasó de unos veinte a unos veintiuno. Ahora sí, con el grano bien cortado, arranco con la 5a.

Primer ladrillo de Auth.js: la guarda de secretos al arranque (Task 5a)

Y arranqué. La primera pieza del bloque de autenticación no toca todavía ni el provider ni un solo callback: es la guarda que mira el entorno y exige que estén todos los secretos antes de dejar levantar la app. Suena defensivo de más para ser lo primero, pero lo pensé al revés. Auth.js con Entra necesita un puñado de cosas para funcionar —el secreto de firma, la URL canónica, y las tres credenciales del registro de la app en Entra: client id, client secret y el issuer del tenant—. Si falta cualquiera de esas, no quiero que la app levante igual y que el agujero recién se note cuando alguien intenta iniciar sesión y se topa con un error opaco. Prefiero que se caiga en el arranque, fuerte y diciendo exactamente cuál secreto falta.

Por eso la hice como lo que es: una función pura, assertAuthEnv, que recibe el entorno y o devuelve los secretos ya validados y tipados —para que el provider de la 5b los consuma sin volver a chequear—, o tira un error que nombra a todos los que faltan, no solo al primero. Un detalle que me importó: un secreto que está pero viene vacío, o con solo espacios, lo trato igual que si no estuviera. Un AUTH_URL="" es tan inútil como un AUTH_URL ausente, así que ambos caen del mismo lado.

El ciclo fue el de siempre. Seis pruebas primero —el entorno completo que pasa, el que devuelve los valores tipados, la falta del secreto de firma, la falta del client id, el secreto en blanco que cuenta como ausente, y el caso de varios faltantes a la vez para confirmar que los nombra a todos—, después el rojo con el módulo sin existir, y recién ahí la guarda. Seis en verde, la suite del proyecto ya en cuarenta y cinco sin una caída, cobertura al 100% incluyendo este módulo nuevo, tipos y linter sin queja. Le di su propia carpeta, lib/auth/, separada del dominio y de la validación, porque acá empieza a vivir lo específico de Auth.js y quería que tuviera su lugar. Con la guarda lista, lo que sigue es cablear el provider de Entra apoyado en ella: la 5b.

El provider de Entra ya arranca: cableo Auth.js apoyado en la guarda (Task 5b)

Segundo ladrillo del bloque, y el primero que trae de verdad a Auth.js al proyecto. Sumé next-auth en su versión 5 —la que trabaja con el App Router— y armé el arranque. Lo pensé en dos piezas a propósito, no en una sola, y esa separación es lo que más me interesa dejar anotado.

Por un lado quedó buildAuthConfig, una función pura que recibe los secretos ya validados y devuelve la configuración de Auth.js con el provider de Microsoft Entra single-tenant adentro, cargado con las credenciales del registro de la app. Por el otro, en la raíz, auth.ts: apenas tres líneas que componen lo que ya estaba probado por separado —corre la guarda de la 5a sobre el entorno, le pasa el resultado a buildAuthConfig, y con eso instancia NextAuth—. La gracia de partirlo así es doble. Una, que la lógica de armado, que es la que tiene sustancia, queda en una función pura que puedo probar sin levantar el runtime entero de Auth.js. Y dos, que esa config separada es justo lo que después voy a poder reusar desde el middleware del edge sin arrastrar todo el peso de NextAuth, que es un patrón que Auth.js recomienda y que me va a servir en la Task 6.

Me topé con una piedra al probar, y la cuento porque la solución dice algo. Instanciar NextAuth de verdad dentro de las pruebas arrastra un import de next/server que el motor de tests no sabe resolver fuera del contexto de Next. La primera reacción sería pelearse con la configuración del resolver, pero me frené a pensar qué estaba probando en realidad. El comportamiento interno de NextAuth no es cosa mía —de eso responde la librería—; lo mío es que auth.ts llame a la guarda antes que nada y recién después arme la config. Así que en esa prueba mockeo el runtime de Auth.js y me quedo mirando lo que sí me importa: que con el entorno completo expone los helpers, que instancia NextAuth con la config que trae el provider ya armado, y que si falta un secreto el arranque se corta en la guarda sin llegar a instanciar nada. El armado del provider, en cambio, corre real en su propia prueba, sin mocks.

Cerré con el ciclo de siempre: los casos primero —tres para el armado de la config, tres para el arranque—, el rojo, y después las dos piezas. Doce pruebas en verde entre las tres del módulo de auth, cincuenta y una en todo el proyecto, cobertura al 100% sobre lib, y esta vez sumé el build de producción al control porque ya hay una pieza de Next de por medio: pasa limpio y sin warnings. El provider ya está configurado y guardado; todavía no decide quién entra. Eso llega en la 5c, cuando el callback de signIn consulte la whitelist y se apoye en la decisión de acceso que dejé lista en la Task 3.