Por qué mi login OAuth funcionaba en dev y fallaba en producción
Durante semanas el inicio de sesión con Google y GitHub de samuelsantana.dev funcionó perfectamente — en mi entorno local. En producción, el popup se abría, el usuario autorizaba, y después de eso no pasaba nada. Sin errores en la consola, sin 500, sin nada obvio. Solo un popup cerrado y una aplicación que seguía creyendo que nadie había iniciado sesión.
Este tipo de bug — funciona en local, se rompe en producción, sin stack trace — suele significar una cosa: una suposición que era cierta por accidente, no por diseño.
La suposición oculta
El frontend (vertex-web, en Vercel) y la API (vertex-api, en Render) son dos aplicaciones separadas, en dominios diferentes. El flujo de OAuth, en la primera versión, era lo más obvio posible: el popup iba directo a Google, Google lo devolvía al callback de la API, y la API — después de validar al usuario y generar el JWT — establecía una cookie HttpOnly en su propia respuesta y cerraba el popup.
Tenía sentido. Y funcionaba, en dev.
El problema es por qué funcionaba: localhost:3000 (frontend) y localhost:3333 (API) tienen el mismo hostname. Las cookies no están delimitadas por puerto — solo por dominio. Entonces, localmente, la cookie que establecía la API era, sin que yo me diera cuenta, "visible" para el frontend solo porque los dos compartían localhost. En producción, vertex-web-zeta.vercel.app y vertex-api-xxxx.onrender.com no tienen absolutamente nada en común. La cookie que establecía la API se quedaba atrapada en su dominio — Next.js nunca tendría forma de verla, sin importar cuánto tiempo estuviera haciendo polling.
No era un bug. Era una arquitectura que solo tenía sentido por una coincidencia del entorno de desarrollo.
El camino equivocado (y por qué es tentador)
La corrección obvia parece ser: hacer que la API redirija al frontend después del callback, pasando el token en la URL (?token=...), y dejar que el frontend establezca la cookie él mismo, en el dominio correcto.
Funciona. Y es una pésima idea.
Un JWT en una query string se filtra a cualquier lugar que registre URLs por defecto: historial del navegador, logs de acceso de proxies y CDNs, el header Referer de cualquier enlace clickeado desde esa página. Es un token de sesión válido, posado en texto plano en lugares que no controlo.
El patrón definitivo: código de intercambio, no token
La solución final separa dos cosas que el primer intento mezclaba: demostrar que el login ocurrió y entregar la credencial real.
- El popup va directo a
/auth/googlede la API (URL absoluta). - Después de que Google confirma, la API no genera el JWT ahí. Genera un código aleatorio, de muy corta duración y de un solo uso — nunca el token real — y redirige el popup al frontend con este código en la URL.
- La página de callback del frontend hace dos cosas, en este orden: primero elimina el código de la URL con
history.replaceState— incluso antes de que salga cualquier petición — y solo entonces intercambia este código por el token de verdad, en una llamada de servidor a servidor (POST /auth/exchange). La cookie se establece allí, en el dominio del frontend, mediante un Server Action.
// vertex-api — el código nunca es el token, y nunca sobrevive más de 60s
createOAuthExchangeCode(payload: JwtPayload): string {
const code = randomBytes(32).toString("hex");
this.oauthExchangeCodes.set(code, {
payload,
expiresAt: Date.now() + OAUTH_EXCHANGE_CODE_TTL_MS, // 60s
});
return code;
}
La parte que más me gusta de esta implementación es exchangeOAuthCode: el código se borra del mapa antes de comprobar si es válido, no después.
async exchangeOAuthCode(code: string): Promise<string> {
const entry = this.oauthExchangeCodes.get(code);
// Borrado incondicionalmente — es de un solo uso sin importar el resultado
this.oauthExchangeCodes.delete(code);
if (!entry || entry.expiresAt < Date.now()) {
throw new UnauthorizedException("Invalid or expired exchange code");
}
return this.generateAccessToken(entry.payload);
}
Esto no es un detalle cosmético. Si el delete solo ocurriera en la ruta de éxito, dos peticiones simultáneas con el mismo código (un replay, o solo un retry de red con mal comportamiento) podrían encontrar la entrada aún válida en una condición de carrera (race condition). Al borrarlo siempre, en la entrada de la función, el código muere en su primer uso — válido o inválido — y la ventana de replay se cierra.
Incluso si alguien captura este código de algún log o del historial del navegador, expira en 60 segundos y solo puede ser intercambiado una vez. En el peor de los casos, ya fue usado por el dueño legítimo antes de que cualquier otra persona pueda hacer algo con él.
La trampa que no esperaba
Después de resolver el problema del dominio, faltaba avisar a la ventana original (la pestaña donde el usuario estaba leyendo el blog) que el login había terminado, para que reaccionara y mostrara al usuario logueado. La forma obvia es que el popup llame a window.opener.postMessage(...) antes de cerrarse.
Esto simplemente no funcionaba, y por un buen tiempo no entendí por qué. La respuesta: las propias páginas de OAuth de Google y GitHub envían un header Cross-Origin-Opener-Policy estricto, que existe justamente para aislar estas páginas de terceros — y un efecto secundario del mismo es cortar la referencia window.opener de forma permanente e irreversible en cuanto el popup navega hacia allá. No importa que la siguiente navegación sea de vuelta a mi propio dominio: la referencia ya se cortó en el camino.
Pasé por dos soluciones intermedias antes de aceptar esto — un intento de detectar el cierre del popup mediante polling, luego un intento de hacer polling en una ruta de sesión — ambas funcionaban "casi siempre", lo que en autenticación es lo mismo que no funcionar. La solución final fue cambiar la comunicación directa entre ventanas por un BroadcastChannel: un canal con alcance de origen, que sobrevive exactamente al tipo de aislamiento que impone COOP, porque no depende de una referencia entre las dos ventanas — solo del hecho de que ambas están en el mismo origen.
Por qué importa esto
Ninguna de estas decisiones aparece en un tutorial de "añade login con Google en 5 minutos". Solo surgen cuando dos aplicaciones reales, en dominios reales, con headers de seguridad reales que no controlas, necesitan hablar entre sí — y es exactamente ahí donde la diferencia entre "funciona en mi entorno" y "funciona en producción" vale la pena (o te pasa factura).
El código completo — el servicio de intercambio de código, la página de callback, el uso del BroadcastChannel y los tests que cubren el flujo — está abierto en los repositorios de vertex-api y vertex-web.
Comentarios
Cargando comentarios...