@jun/soul/credentials
Credentials
Una primitiva para OTP por email, magic links y API keys.
La idea#
Un codigo al email, un magic link y una API key parecen tres cosas distintas, pero son la misma: un secreto con expiracion que se valida por hash. Cambia el formato, la vida util y si se consume al usarse. soul los unifica en una tabla y una API; cada proyecto arma su flujo encima.
De que se hace cargo soul#
- El secreto se guarda hasheado, nunca en claro. En la base solo vive su sha256.
- La comparacion es en tiempo constante.
- El uso unico se consume de forma atomica: no hay ventana de carrera.
- Los intentos fallidos se cuentan y matan la credencial (fuerza bruta sobre codigos cortos).
- La generacion usa
crypto.getRandomValues, con rechazo de los bytes altos para no sesgar los digitos.
Emitir#
import { issueCredential } from "@jun/soul/credentials";
const { secret, hint, expiresAt } = await issueCredential(db, {
kind: "otp", // separa espacios: un secreto de un kind nunca vale en otro
subject: email, // a quien pertenece, en tus terminos
format: "digits", // "digits" (tecleable) o "token" (hex opaco)
digits: 6,
ttlSeconds: 600,
singleUse: true,
maxAttempts: 5,
replacePrevious: true, // matar el codigo anterior de este mismo email
});
Validar: dos modos#
| Modo | Cuando | Comportamiento |
|---|---|---|
Con subject | OTP: el secreto es corto y adivinable | Busca la credencial viva de ese titular y cuenta los intentos fallidos. Al llegar a maxAttempts deja de servir. |
Sin subject | Magic link, API key, token de sesion | El hash del secreto es la identidad. No hay intentos que contar porque un token de 32 bytes no se adivina: limita por IP con ratelimit. |
import { verifyCredential } from "@jun/soul/credentials";
const r = await verifyCredential(db, { kind: "otp", subject: email, secret: codigo });
if (!r.ok) {
// r.reason: "not_found" | "expired" | "consumed" | "revoked" | "too_many_attempts"
return errorPara(r.reason);
}
r.credential; // { id, kind, subject, orgId, hint, expiresAt, meta, createdAt }
El resto de la API#
revokeCredentials(db, { id?, kind?, subject?, orgId? })- Revoca y devuelve cuantas filas afecto. Lanza si no le pasas ningun filtro: revocar la tabla entera siempre es un bug.
listCredentials(db, { kind, subject?, orgId?, includeInactive? })- Lista las vivas. Nunca hay secretos aca — sirve para un panel de API keys.
cleanupCredentials(db, olderThanSeconds = 7d)- Borra las muertas (expiradas, consumidas o revocadas). Llamalo desde el cron; las vivas no se tocan.
requireCredential({ kind, header?, onUnauthorized? })- Guard para endpoints con API key. Lee
Authorization: Bearery deja la credencial enc.var.credential.
Migracion#
Necesita 0007_soul_credentials.sql (npx soul sync-migrations), que crea la tabla
soul_credentials: una sola para los tres flujos.
Portal con codigo al email, completo#
Este es el flujo real del portal de clientes de Ply. Dos kinds de la misma primitiva: el codigo y la sesion.
import { issueCredential, verifyCredential, revokeCredentials } from "@jun/soul/credentials";
import { rateLimit } from "@jun/soul/ratelimit";
import { sendEmail } from "@jun/soul/lib";
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
const OTP_KIND = "client_otp";
const SESSION_KIND = "client_session";
const COOKIE = "portal_session";
// 1. Pedir el codigo
portal.post("/request", async (c) => {
const email = String((await c.req.parseBody()).email ?? "").trim().toLowerCase();
if (!email) return c.html(errorPage("Falta el email", "El email es obligatorio."), 400);
// Se cuenta ANTES de mirar si el email es cliente: si solo limitaramos a los
// que si lo son, el 429 delataria quien tiene cuenta.
const limite = await rateLimit(c.env.DB, {
scope: "portal-otp", key: email, limit: 3, windowSeconds: 3600,
});
if (!limite.ok) return c.html(errorPage("Demasiados intentos", "Prueba en un rato."), 429);
const esCliente = await c.env.DB.prepare(
"SELECT 1 FROM clientes WHERE email = ? LIMIT 1",
).bind(email).first();
if (esCliente) {
const { secret: code } = await issueCredential(c.env.DB, {
kind: OTP_KIND, subject: email, format: "digits", digits: 6,
ttlSeconds: 600, singleUse: true, maxAttempts: 5, replacePrevious: true,
});
c.executionCtx.waitUntil(sendEmail(c.env, {
to: email, subject: "Tu codigo de acceso",
text: `Tu codigo es: ${code}\n\nExpira en 10 minutos.`,
}));
}
// La misma respuesta exista o no la cuenta.
return c.html(shell({ title: "Revisa tu correo", body: formularioDeCodigo(email) }));
});
// 2. Canjearlo por una sesion
portal.post("/verify", async (c) => {
const body = await c.req.parseBody();
const email = String(body.email ?? "").trim().toLowerCase();
const code = String(body.code ?? "").trim();
const r = await verifyCredential(c.env.DB, { kind: OTP_KIND, subject: email, secret: code });
if (!r.ok) {
const msg = r.reason === "expired" ? "El codigo expiro."
: r.reason === "too_many_attempts" ? "Demasiados intentos."
: "Codigo incorrecto.";
return c.html(errorPage("No pudimos entrar", msg), 400);
}
const sesion = await issueCredential(c.env.DB, {
kind: SESSION_KIND, subject: email, ttlSeconds: 30 * 24 * 3600,
});
setCookie(c, COOKIE, sesion.secret, {
httpOnly: true,
secure: new URL(c.req.url).protocol === "https:",
sameSite: "Lax", path: "/", maxAge: 30 * 24 * 3600,
});
return c.redirect("/portal");
});
// 3. Guard del portal (modo opaco: sin subject)
portal.use("/app/*", async (c, next) => {
const token = getCookie(c, COOKIE);
if (!token) return c.redirect("/portal");
const r = await verifyCredential(c.env.DB, { kind: SESSION_KIND, secret: token });
if (!r.ok) {
deleteCookie(c, COOKIE, { path: "/" });
return c.redirect("/portal");
}
c.set("clientEmail", r.credential.subject);
await next();
});
// 4. Salir: revoca SOLO esta sesion, no las de los otros dispositivos
portal.post("/logout", async (c) => {
const token = getCookie(c, COOKIE);
if (token) {
const r = await verifyCredential(c.env.DB, { kind: SESSION_KIND, secret: token });
if (r.ok) await revokeCredentials(c.env.DB, { id: r.credential.id });
}
deleteCookie(c, COOKIE, { path: "/" });
return c.redirect("/portal");
});
API keys por organizacion#
import { issueCredential, listCredentials, revokeCredentials, requireCredential } from "@jun/soul/credentials";
// Emitir desde el panel. El secreto se muestra una sola vez.
ajustes.post("/keys", tenancy.requirePermission("billing.manage"), async (c) => {
const { secret, hint } = await issueCredential(c.env.DB, {
kind: "api_key",
subject: `org:${c.var.org.id}`,
orgId: c.var.org.id,
prefix: "sk_live_", // viaja en claro: lo reconoce un humano y lo detectan los escaneres
ttlSeconds: null, // no expira
meta: { scopes: ["read", "write"] },
});
return c.html(shell({ title: "Clave creada", body: `
<p>Guardala ahora, no se vuelve a mostrar:</p>
<pre>${secret}</pre><p>Quedara identificada como <code>…${hint}</code>.</p>` }));
});
// Listarlas (nunca hay secretos aca)
const keys = await listCredentials(c.env.DB, { kind: "api_key", orgId: c.var.org.id });
// Revocar una
await revokeCredentials(c.env.DB, { id: Number(c.req.param("id")) });
Y para proteger la API con esa clave, el guard hace todo el trabajo:
import { requireCredential, type CredentialVariables } from "@jun/soul/credentials";
const api = new Hono<{ Bindings: SoulEnv; Variables: CredentialVariables }>();
api.use("*", requireCredential({ kind: "api_key" }));
api.get("/facturas", async (c) => {
// De la credencial sale el orgId con el que scopear.
const orgId = c.var.credential.orgId;
const { results } = await c.env.DB.prepare(
"SELECT id, title FROM facturas WHERE org_id = ?",
).bind(orgId).all();
return c.json({ facturas: results });
});
Magic link de un solo uso#
// Emitir y enviar
const { secret } = await issueCredential(c.env.DB, {
kind: "magic", subject: email, ttlSeconds: 900, singleUse: true,
meta: { redirect: "/app/bienvenida" },
});
await sendEmail(c.env, {
to: email, subject: "Tu acceso",
text: `Entra aqui: ${c.env.APP_URL}/entrar?t=${secret}`,
});
// Canjear. Dos clics simultaneos en el mismo enlace: gana exactamente uno.
app.get("/entrar", async (c) => {
const r = await verifyCredential(c.env.DB, {
kind: "magic", secret: c.req.query("t") ?? "",
});
if (!r.ok) return c.html(errorPage("Enlace no valido", "Pide uno nuevo."), 400);
const destino = (r.credential.meta as { redirect?: string })?.redirect ?? "/app";
// ...emitir sesion
return c.redirect(destino);
});
Limpieza desde el cron#
import { cleanupCredentials } from "@jun/soul/credentials";
import { cleanupRateLimits } from "@jun/soul/ratelimit";
export const scheduled = async (event: ScheduledEvent, env: SoulEnv) => {
await cleanupCredentials(env.DB); // borra las muertas de mas de 7 dias
await cleanupRateLimits(env.DB); // vacia las ventanas ya cerradas
};
Probarlo#
import { issueCredential } from "@jun/soul/credentials";
it("el codigo no sirve dos veces", async () => {
const { secret } = await issueCredential(env.DB, {
kind: "otp", subject: "ana@test.dev", format: "digits",
ttlSeconds: 600, singleUse: true, maxAttempts: 5,
});
const primera = await verifyCredential(env.DB, {
kind: "otp", subject: "ana@test.dev", secret,
});
expect(primera.ok).toBe(true);
const segunda = await verifyCredential(env.DB, {
kind: "otp", subject: "ana@test.dev", secret,
});
expect(segunda.ok).toBe(false);
});
it("se vence a mano para probar la expiracion", async () => {
const { secret, id } = await issueCredential(env.DB, {
kind: "otp", subject: "ana@test.dev", format: "digits", ttlSeconds: 600,
});
await env.DB.prepare("UPDATE soul_credentials SET expires_at = ? WHERE id = ?")
.bind(Math.floor(Date.now() / 1000) - 10, id).run();
const r = await verifyCredential(env.DB, { kind: "otp", subject: "ana@test.dev", secret });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toBe("expired");
});