02Arquitectura6 min

Cuatro migraciones de auth y ninguna me dolió por el login

He cambiado de proveedor de auth cuatro veces y el login siempre fue lo fácil. Lo que se rompe es la forma de la sesión que el SDK te deja repartida por toda la app.

Por Salva Castells

Imagen de portada: Shixart1985, CC BY 2.0.
En este post

Hay un commit en mi boilerplate del que no estoy orgulloso. Deja la configuración del proveedor de auth así:

export const stackServerApp = new StackServerApp({
  tokenStore: "nextjs-cookie",
  urls: {
    signIn: "/ass",
    signUp: "/asdds",
  },
});

Estaba probando a mano a dónde redirigía cada cosa, tecleando basura en el config, y me lo dejé puesto. El commit siguiente, el mismo día, lo devuelve a /auth en los dos campos. Me hace gracia ahora, pero lo traigo porque resume bastante bien lo que he aprendido migrando de proveedor de auth cuatro veces en año y medio: lo que se rompe no es lo que esperas que se rompa.

Cómo llegué a cuatro

Siempre empiezo igual. El primer día de un proyecto no quiero escribir hash de contraseñas, ni verificación de email, ni pelearme con el OAuth de Google. Instalo un SDK, pongo dos variables de entorno y por la tarde hay login. Y lo sigo defendiendo, porque ese día el proyecto no sabe si va a existir dentro de un mes, y gastar una semana en auth propia para algo que igual tiro es peor idea que cualquier acoplamiento.

En el boilerplate, el commit que integra el primer proveedor y el que lo sustituye por el segundo están separados por dos días. El primero traía middleware para rutas protegidas y una pantalla de login. El segundo lo arrancó entero y metió otro SDK con sus propias páginas de sign-in y sign-up, porque el modelo de precios y lo atado que iba todo a su dashboard no me convencían. No sobrevivió ni una línea.

Con el segundo convivimos unos seis meses, y ahí fue donde se me enredó de verdad. Hay notas en el README sobre su comportamiento raro con Suspense, un refactor del middleware para leer los dominios protegidos de un fichero de config, y un cambio de useProfile a useUser que hice para dejar de mantener mi propia capa encima de la suya. Esa última decisión me pareció una simplificación en su momento. Luego pagué por ella.

El salto al tercero fue una PR entera titulada "migrate from" más el nombre del anterior. Treinta ficheros tocados. Se van stack.ts y la ruta catch-all app/handler/[...stack]/page.tsx, aparecen auth.ts en cliente y app/api/auth/[...path]/route.ts en servidor. Y el cuarto ya no fue a otro proveedor: el commit dice "replace neonAuth with custom auth middleware and update session handling". Del SDK me quedé con el cliente; lo demás pasó a ser mío. Casi un año después del primer commit de auth, la plantilla terminaba con un middleware escrito a mano.

Dónde duele de verdad

Las URLs, primero, y por eso empecé con el /ass. El proveedor no sabe dónde vive tu pantalla de login, así que hay una ruta declarada en su config que tiene que coincidir con una ruta real de tu router, y ningún tipo lo comprueba. Cuando no coinciden, el síntoma no es "URL incorrecta", es un 404 después de pulsar "Entrar", que se lee como "el login está roto" y te manda a mirar cookies y tokens durante un buen rato antes de mirar un string. Me ha pasado en más de una migración, y la última vez fue hace nada: arranqué un proyecto nuevo con un proveedor gestionado y el primer día no se podía entrar porque la URL base de auth seguía apuntando a un valor de relleno del boilerplate.

Pero lo caro es otra cosa. En el paso al middleware propio, getSession dejó de devolver el objeto del SDK y pasó a devolver algo que definía yo:

type SessionData = NonNullable<Awaited<ReturnType<typeof auth.getSession>>["data"]>;
export type Session = SessionData | { session: null; user: null };

Ese | { session: null; user: null } parece una tontería. El SDK anterior devolvía un nullable pelado y medio código hacía session?.user alegremente. Al normalizarlo, todos los sitios que daban por hecho que la sesión podía ser undefined dejaron de compilar a la vez. Y aquí es donde me acordé del cambio de useProfile a useUser: al quitar mi capa para usar la suya directamente, había dejado que la forma de su usuario se colara en componentes, server actions y utilidades. Cuando tocó cambiarla, estaba en todas partes.

Esa es la tesis del post, si tiene una. Una migración de auth no es cambiar un login por otro. Es cambiar un tipo que tu app ha ido absorbiendo sin que te des cuenta, y el tamaño de la migración es exactamente el número de sitios donde ese tipo se ha colado.

Y hay un segundo sitio donde se cuela, peor todavía: la base de datos. El user_id que genera el proveedor acaba siendo clave foránea en todas tus tablas si le dejas. Entonces cambiar de proveedor ya no es cambiar una librería, es migrar datos.

Lo que gané con el middleware propio (y lo que no)

La parte buena es control. Sé qué forma tiene la sesión, dónde se guarda la cookie y qué pasa exactamente cuando no hay sesión. Y algo que no había valorado hasta que lo necesité: la autorización puede vivir en el mismo sitio.

En una herramienta de BI para una empresa grande, la regla de quién podía ver qué acabó siendo una lista de correos dentro del middleware:

const authorizedEmails = [ /* ... */ ];
const hasFullAccess = userEmail && authorizedEmails.includes(userEmail);

Con dos redirecciones: el no autenticado al login, el autenticado sin permiso a la portada. Con un proveedor gestionado eso es pelearte con sus claims o con su panel; ahí fueron diez líneas. Diez líneas y un console.log("User email:", userEmail) que se me quedó puesto y que quité en un commit posterior. Esa app, por cierto, reescribió su auth tres veces en un mes, así que tampoco pondría la mano en el fuego por mi yo de entonces.

Lo que pierdo es todo lo que el proveedor hacía sin que lo vieras: rotación de tokens, invalidación de sesiones, el correo de recuperación, el rate limit del login. Nada es imposible, pero ahora es código mío, con los tests que yo haya escrito y ninguno más. Si alguien me pregunta si recomiendo escribirse la auth, la respuesta es que no especialmente. Lo que recomiendo es poder hacerlo el día que haga falta sin tocar treinta ficheros.

Lo que hago ahora antes de instalar nada

Asumo que voy a cambiar de proveedor. No porque sea pesimista, sino porque llevo cuatro de cuatro. En la práctica eso son tres o cuatro decisiones pequeñas el primer día:

Una sola función getSession() en mi código, en un fichero que es el único que importa el SDK. El tipo de retorno lo escribo yo en vez de inferirlo del proveedor. Algo así (simplificado):

// lib/session.ts: el único fichero que conoce al proveedor
export type AppSession = { userId: string; email: string } | null;

export async function getSession(): Promise<AppSession> {
  const { data } = await auth.getSession();
  if (!data?.user) return null;

  const row = await db.query.users.findFirst({
    where: eq(users.providerId, data.user.id),
  });
  return row ? { userId: row.id, email: row.email } : null;
}

Fíjate en el providerId. El id del usuario en mi base de datos es mío, y el del proveedor va en una columna aparte. Cuesta una consulta más (que puedes cachear o meter en la cookie) y te ahorra una migración de datos.

Las URLs de login y registro van en una constante que usan tanto el config del proveedor como mis enlaces y redirecciones. Si alguien vuelve a escribir /ass, al menos se rompe en todos lados a la vez y se ve enseguida.

Y la autorización nunca dentro del proveedor. Quién eres lo decide él; qué puedes hacer lo decide mi código.

¿Es sobreingeniería para un proyecto que igual no pasa del mes? Un poco, lo concedo. Pero es media hora el primer día, y la alternativa ya sé cuánto cuesta. El proyecto nuevo del que hablaba arriba ya va por el primer proveedor gestionado, así que dentro de unos meses sabré si esta vez he aprendido o solo he escrito un post sobre ello.