@jun/soul/auth
Auth
Login con Google, sesion JWT en cookie y guards para proteger rutas.
Que resuelve#
El ciclo completo de autenticacion: mandar al usuario a Google, recibirlo de vuelta, crear o encontrar su cuenta, dejarle una sesion firmada y ofrecer los guards para proteger rutas. Tu no escribes nada de OAuth.
En el primer login soul tambien crea la organizacion personal del usuario y su membresia como owner. Es la invariante que sostiene todo lo demas: todo usuario pertenece siempre al menos a una organizacion.
Montarlo#
import { createAuth } from "@jun/soul/auth";
import { createShell } from "@jun/soul/ui";
const { shell, errorPage } = createShell({ siteName: "Mi SaaS" });
export const auth = createAuth({ errorPage });
// en src/index.ts
app.route("/auth", auth.routes); // GET /login, /callback, /logout
Opciones#
errorPage: (titulo, mensaje) => string- Requerido. La pagina de error que se muestra ante un fallo.
cookieName?: string- Nombre de la cookie de sesion. Por defecto
"soul_session". sessionTtlSeconds?: number- Vigencia de la sesion. Por defecto 30 dias.
postLoginRedirect?: string- A donde ir tras entrar. Por defecto
"/dashboard". postLogoutRedirect?: string- A donde ir tras salir. Por defecto
"/". loginPath?: string- Ruta del login, usada por los redirects. Por defecto
"/auth/login". statelessUser?: boolean- Por defecto true desde la v4.1. Ver abajo.
sessionHintCookie?: string- Cookie pista para landings estaticas. Ver Static.
Los guards#
auth.requireSession()- Exige sesion valida. Sin ella redirige a
loginPath. auth.requireAdmin()- Exige superadmin de plataforma. Responde 403 si no lo es.
Ambos dejan datos listos en el contexto:
app.get("/perfil", auth.requireSession(), (c) => {
c.var.session; // { userId, email, exp }
c.var.user; // { name, email, isAdmin }
return c.text(`Hola ${c.var.user.name}`);
});
Sesiones sin tocar la base#
Desde la v4.1 statelessUser viene encendido. El nombre y el isAdmin viajan
firmados como claims dentro del JWT, asi que requireSession() resuelve con cero consultas
a D1. En un portal, eso es casi todo el trafico.
Como esta protegido#
- JWT HS256 con el algoritmo pinneado (no se acepta lo que diga el token).
- Cookie HttpOnly,
SameSite=Lax, ySecureautomatico siAPP_URLes https. - Anti-CSRF en el flujo OAuth: un
statealeatorio en cookie con 10 minutos de vida. - Del
id_tokende Google se validanaudyemail_verified.
Portal completo con login#
import { Hono } from "hono";
import type { SoulEnv } from "@jun/soul";
import { auth, shell } from "./core";
const app = new Hono<{ Bindings: SoulEnv }>();
app.route("/auth", auth.routes);
// Landing publica con el boton de entrar.
app.get("/", (c) =>
c.html(shell({
title: "Mi SaaS",
body: `<main><h1>Mi SaaS</h1><a href="/auth/login">Entrar con Google</a></main>`,
})),
);
// Todo lo que cuelga de /dashboard exige sesion.
const portal = new Hono<{ Bindings: SoulEnv }>();
portal.use("*", auth.requireSession());
portal.get("/", (c) => c.html(shell({ title: "Panel", body: `<p>Hola ${c.var.user.email}</p>` })));
app.route("/dashboard", portal);
export default app;
Cookie propia y redirects propios#
export const auth = createAuth({
errorPage,
cookieName: "milagro_session",
sessionTtlSeconds: 7 * 24 * 3600, // una semana
postLoginRedirect: "/app",
postLogoutRedirect: "/hasta-pronto",
loginPath: "/entrar",
});
Panel de superadmin detras del guard#
import { Hono } from "hono";
import type { AuthVariables } from "@jun/soul/auth";
import { auth, admin } from "./core";
// createAdmin NO trae auth: se monta detras de requireAdmin().
const adminPanel = new Hono<{ Bindings: SoulEnv; Variables: AuthVariables }>();
adminPanel.use("*", auth.requireAdmin());
adminPanel.route("/", admin.routes);
app.route("/admin", adminPanel);
Dos instancias de auth a la vez#
Un proyecto puede tener dos publicos distintos: el equipo interno y los clientes finales. Nada impide crear dos instancias con cookies separadas.
// Equipo: sesion larga, va al panel.
export const auth = createAuth({ errorPage, postLoginRedirect: "/app" });
// Clientes: cookie propia, sesion corta, otra landing.
export const clientAuth = createAuth({
errorPage,
cookieName: "cliente_session",
sessionTtlSeconds: 3 * 24 * 3600,
postLoginRedirect: "/portal",
loginPath: "/portal/entrar",
});
Leer la sesion a mano#
Los helpers sueltos sirven para casos fuera del ciclo normal, como un endpoint que quiere saber si hay sesion sin obligar a tenerla.
import { readSession, createSessionCookie, clearSessionCookie } from "@jun/soul/auth";
app.get("/api/estado", async (c) => {
const session = await readSession(c, c.env.SESSION_SECRET);
return c.json({ autenticado: Boolean(session), email: session?.email ?? null });
});
Probar rutas protegidas#
import { createTestUser, sessionCookieFor } from "@jun/soul/testing";
const userId = await createTestUser(env.DB, { email: "ana@test.dev" });
const cookie = await sessionCookieFor(env.SESSION_SECRET, userId, "ana@test.dev");
const res = await SELF.fetch("http://localhost/dashboard", { headers: { Cookie: cookie } });
expect(res.status).toBe(200);