·6 min de lectura·Mounaji Studio

Inicio de sesión único entre dos apps sin proveedor de identidad: el ticket de un minuto

Dos herramientas internas en dominios distintos, sin cookie compartida y sin ganas de montar un proveedor de identidad para un puñado de usuarios. Un ticket firmado que vive sesenta segundos y sirve una sola vez reemplazó al segundo login. El flujo, el código y las cuatro respuestas que medimos en producción.

engineeringauthsecurityweb

También en: English

Tenemos dos herramientas internas que las mismas personas usan una detrás de la otra. Una es un panel que corre en Vercel. La otra es el editor donde escribimos las propuestas, y corre en nuestro propio servidor. El panel tiene un botón "Abrir en el editor", y hasta el 29 de septiembre de 2026 ese botón te llevaba a un segundo formulario de login: ya habías entrado al panel, y el editor te volvía a pedir email y contraseña.

No pueden compartir una cookie porque viven en dominios distintos. La respuesta habitual es un proveedor de identidad (Auth0, Keycloak, Google Workspace por OIDC) en el que confíen las dos apps. Para dos apps y una lista corta de usuarios, eso es un servicio más que operar, una cuenta más que pagar y un lugar más donde el login se puede romper. Lo resolvimos con un ticket.

El flujo

El ticket de un minuto en cinco pasos: el navegador aprieta Abrir en el editor; el servidor del panel le pide un ticket al editor con su propia API key; el editor revisa su lista y firma un ticket que vence en 60 segundos; el panel redirige el navegador a /entrar con el ticket; el editor lo verifica, lo quema y abre su sesión de siempre

  1. La persona, que ya tiene sesión en el panel, aprieta "Abrir en el editor".
  2. El servidor del panel, después de comprobar que esa persona es dueña, llama a POST /api/integracion/sesion en el editor con { email, a }, donde a es la ruta del editor que hay que abrir. Se autentica con una API key que el navegador nunca ve.
  3. El editor comprueba que el email esté en su propia lista de usuarios, firma un ticket y contesta { url: "https://<editor>/entrar?t=<ticket>" }.
  4. El panel redirige el navegador a esa URL.
  5. La página /entrar del editor canjea el ticket por su sesión de siempre (la misma sesión de next-auth que deja un login con contraseña) y sigue a a.

Las dos apps no comparten ningún secreto. La clave de firma nunca sale del editor: firma el ticket en el paso 3 y lo verifica en el paso 5. El panel sólo tiene una API key que puede pedir tickets.

El ticket

Es un cuerpo JSON en base64url más un HMAC-SHA256 de ese cuerpo:

export const VIDA_MS = 60_000;
const CONTEXTO = 'propuestas:entrada-del-hub:v1';

const firma = (cuerpo, secreto) =>
  createHmac('sha256', secreto).update(`${CONTEXTO}.${cuerpo}`).digest('base64url');

export function emitirTicket({ email, a }, { secreto = process.env.NEXTAUTH_SECRET, ahora = Date.now() } = {}) {
  if (!secreto) throw new Error('falta NEXTAUTH_SECRET');
  const cuerpo = b64(JSON.stringify({
    e: email.trim().toLowerCase(),
    a: destinoSeguro(a),
    x: ahora + VIDA_MS,                     // vence en 60 s
    j: randomBytes(12).toString('base64url'), // id único, para que sirva una vez
  }));
  return `${cuerpo}.${firma(cuerpo, secreto)}`;
}

e es el email, a el destino, x el vencimiento y j el id del ticket. Hay dos detalles que es fácil saltarse:

  • La cadena de contexto (propuestas:entrada-del-hub:v1) entra en el HMAC. El editor reutiliza su secreto de sesión, y el contexto evita que esta firma valga en cualquier otro lugar donde ese secreto firme algo.
  • Sin secreto no hay ticket. Un ticket firmado con undefined lo podría falsificar cualquiera, así que la función falla en vez de emitir uno.

El canje revisa, en este orden: la firma (con timingSafeEqual), la forma, el vencimiento, que el email siga en la lista y que el id no se haya usado. Recién ahí anota el id como usado y devuelve la sesión. El motivo de un rechazo va al log del servidor; la pantalla dice lo mismo en todos los casos: el link venció, vuelve y aprieta de nuevo, o entra con tu contraseña.

Cuatro decisiones que sostienen la seguridad

1. El ticket se pide de servidor a servidor, con una key propia. La key que usa el panel tiene una sola capacidad, sesion, en una entrada aparte. No se hereda de las otras keys del panel (la que lee la cartera de propuestas, la del portal de clientes). Una key que puede sacar tickets para un usuario de la lista vale exactamente lo mismo que el login de ese usuario, así que no tiene que venir pegada a nada más.

2. Quién entra lo sigue decidiendo el editor. Que el panel diga "es dueño" no alcanza: el email tiene que estar en la lista de usuarios del propio editor, y se revisa dos veces, al pedir el ticket y al canjearlo, por si la lista cambió en el medio. El ticket no crea usuarios. Cuando lo publicamos, una de las cuentas dueñas del panel no estaba en la lista del editor, y el editor le siguió pidiendo contraseña a esa cuenta. Eso es el diseño funcionando, y el arreglo es sumar la cuenta a la lista, no confiar más en el panel.

3. Sesenta segundos y un solo uso, porque viaja en una URL. Una URL termina en el historial del navegador y en los logs de acceso. Un ticket vencido o ya canjeado ahí no vale nada. El ticket lo usa una redirección que ocurre justo después de emitirlo, así que un minuto deja margen para una red lenta y poco tiempo para cualquier otro.

4. El destino sólo puede ser una ruta del editor. a tiene que empezar con /, no puede empezar con // ni con /\ y no puede tener saltos de línea. Cualquier otra cosa se vuelve /. Sin eso, el endpoint del ticket sería una redirección abierta que además lleva una sesión iniciada.

Cómo lo comprobamos

El 29 de septiembre de 2026 corrimos esto contra producción con curl:

pedido respuesta
primer canje de un ticket devuelve la sesión, y /documentos contesta 200
segundo canje del mismo ticket 401
pedir un ticket sin la key 401
pedir un ticket para un email que no está en la lista 403
pedir un ticket con un destino externo el destino se reescribe a una ruta del editor

La segunda fila es la que más importa: muestra que el ticket de verdad se quema. Un login que funciona prueba muy poco sobre las partes que tienen que fallar.

Antes de copiarlo

  • El registro de usados vive en memoria. El editor corre en un solo contenedor, así que alcanza con un Map. Si se reinicia, lo peor que pasa es que un ticket de hace menos de un minuto se pueda canjear una vez más. Con más de una réplica, ese registro tiene que pasar a un almacenamiento compartido (una tabla, Redis), o cada réplica va a aceptar el mismo ticket una vez.
  • Rotar el secreto de sesión también invalida los tickets en vuelo. Con sesenta segundos de vida, no hace daño.
  • La página /entrar lee el destino del ticket sin verificarlo, sólo para saber a dónde ir después del canje. La verificación ocurre en el servidor, y si falla no hay sesión, así que el destino no abre. Si copias el patrón, respeta ese orden.
  • Cuándo no usarlo. Esto funciona porque son pocos usuarios internos, dos apps, y una de ellas puede ser la que firma. Con usuarios externos, más apps o la necesidad de cerrar sesión en todas a la vez, usa un proveedor de identidad de verdad.

Reemplazó un segundo login con un módulo chico, una ruta, una página y una API key más. Si tienes dos herramientas que le piden la misma contraseña dos veces a tu equipo, escríbenos y cuéntanos cómo lo tienes armado.

Comentarios y correcciones

Chat with us