příklady / přihlašování

Přihlášení e-mailem s better-auth

Registrace, přihlášení a session cookies přes better-auth nad libsql databází.

authbetter-authsessionslibsqlsecrets

Kompletní přihlašování e-mailem a heslem postavené na better-auth, autentizačním frameworku pro TypeScript. Služba vystavuje HTTP API better-auth pod /api/auth/* a kolem něj serverově renderuje vlastní stránky pro registraci, přihlášení a profil, takže celý tok funguje bez JavaScriptu v prohlížeči.

Účty, hashovaná hesla i session žijí v připojené libsql databázi (proměnná AUTH_DB); schéma se vytvoří při prvním požadavku. Session cookie vydává better-auth a prochází obyčejnými HTML formuláři: registrace, přihlášení, profil, odhlášení.

Deploy vygeneruje BETTER_AUTH_SECRET pro podepisování session cookies a nastaví BETTER_AUTH_URL na veřejnou adresu služby, takže aplikace běží bez dalšího nastavování.

kód
main.ts
// Email + password login built on better-auth. The service mounts better-auth's
// HTTP API under /api/auth/* and server-renders sign-up, sign-in and profile
// pages around it. Plain HTML forms post back to this handler, which calls the
// better-auth server API and forwards its session cookie, so the whole flow
// works without any client-side JavaScript.
import { ensureTables, getAuth } from "./auth.ts";
import { profilePage, signInPage, signUpPage } from "./pages.ts";

const html = (body: string, status = 200): Response =>
  new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8" } });

// Redirect that carries over any Set-Cookie headers better-auth produced.
const redirect = (to: string, from?: Headers): Response => {
  const headers = new Headers({ location: to });
  for (const cookie of from?.getSetCookie() ?? []) headers.append("set-cookie", cookie);
  return new Response(null, { status: 303, headers });
};

// better-auth throws APIError for expected failures (wrong password, duplicate
// email, ...); its message is meant to be shown to the user.
const errorMessage = (err: unknown): string =>
  err instanceof Error && err.message ? err.message : "Something went wrong. Please try again.";

const field = (form: FormData, name: string): string => String(form.get(name) ?? "");

export default async (req: Request): Promise<Response> => {
  const url = new URL(req.url);
  await ensureTables();
  const auth = getAuth(url.origin);

  // better-auth's own endpoints: sign-in, sign-out, get-session and friends.
  if (url.pathname.startsWith("/api/auth/")) return auth.handler(req);

  const session = await auth.api.getSession({ headers: req.headers });

  if (req.method === "GET") {
    switch (url.pathname) {
      case "/":
        return session ? html(profilePage(session.user)) : redirect("/sign-in");
      case "/sign-in":
        return session ? redirect("/") : html(signInPage());
      case "/sign-up":
        return session ? redirect("/") : html(signUpPage());
    }
  }

  if (req.method === "POST" && url.pathname === "/sign-up") {
    const form = await req.formData();
    try {
      const { headers } = await auth.api.signUpEmail({
        body: { name: field(form, "name"), email: field(form, "email"), password: field(form, "password") },
        headers: req.headers,
        returnHeaders: true,
      });
      return redirect("/", headers);
    } catch (err) {
      return html(signUpPage(errorMessage(err)), 422);
    }
  }

  if (req.method === "POST" && url.pathname === "/sign-in") {
    const form = await req.formData();
    try {
      const { headers } = await auth.api.signInEmail({
        body: { email: field(form, "email"), password: field(form, "password") },
        headers: req.headers,
        returnHeaders: true,
      });
      return redirect("/", headers);
    } catch (err) {
      return html(signInPage(errorMessage(err)), 401);
    }
  }

  if (req.method === "POST" && url.pathname === "/sign-out") {
    try {
      const { headers } = await auth.api.signOut({ headers: req.headers, returnHeaders: true });
      return redirect("/sign-in", headers);
    } catch {
      return redirect("/sign-in");
    }
  }

  return new Response("Not found", { status: 404 });
};
auth.ts
// better-auth wired to libsql through the Kysely dialect. The instance is
// created on the first request so its base URL can be derived from the URL the
// app is actually served on.
import "./sandboxEnv.ts";
import { betterAuth } from "npm:better-auth@1.6.23";
import { LibsqlDialect } from "npm:@libsql/kysely-libsql@0.4.1";
import { createClient } from "npm:@libsql/client@0.14.0/web";

// AUTH_DB is the project database linked by the deploy. Every service also has
// its own implicit DATABASE_URL, which keeps this code runnable in any service.
const dbUrl = (): string => Deno.env.get("AUTH_DB") ?? Deno.env.get("DATABASE_URL")!;

type Auth = ReturnType<typeof betterAuth>;
let instance: Auth | undefined;

export function getAuth(origin: string): Auth {
  instance ??= betterAuth({
    // Prefer the origin the request arrived on; BETTER_AUTH_URL (set to this
    // service's public URL at deploy time) is the fallback.
    baseURL: origin || Deno.env.get("BETTER_AUTH_URL"),
    // Generated for this deployment; signs the session cookies.
    secret: Deno.env.get("BETTER_AUTH_SECRET"),
    database: { dialect: new LibsqlDialect({ url: dbUrl() }), type: "sqlite" },
    emailAndPassword: { enabled: true },
  });
  return instance;
}

// better-auth's core tables, the same schema its CLI migration would generate.
// IF NOT EXISTS makes this safe to run on every cold start.
let ready: Promise<void> | undefined;

export function ensureTables(): Promise<void> {
  ready ??= createClient({ url: dbUrl() })
    .batch(
      [
        `CREATE TABLE IF NOT EXISTS "user" (
          "id" TEXT PRIMARY KEY, "name" TEXT NOT NULL, "email" TEXT NOT NULL UNIQUE,
          "emailVerified" INTEGER NOT NULL DEFAULT 0, "image" TEXT,
          "createdAt" TEXT NOT NULL, "updatedAt" TEXT NOT NULL)`,
        `CREATE TABLE IF NOT EXISTS "session" (
          "id" TEXT PRIMARY KEY, "expiresAt" TEXT NOT NULL, "token" TEXT NOT NULL UNIQUE,
          "createdAt" TEXT NOT NULL, "updatedAt" TEXT NOT NULL,
          "ipAddress" TEXT, "userAgent" TEXT,
          "userId" TEXT NOT NULL REFERENCES "user"("id"))`,
        `CREATE TABLE IF NOT EXISTS "account" (
          "id" TEXT PRIMARY KEY, "accountId" TEXT NOT NULL, "providerId" TEXT NOT NULL,
          "userId" TEXT NOT NULL REFERENCES "user"("id"),
          "accessToken" TEXT, "refreshToken" TEXT, "idToken" TEXT,
          "accessTokenExpiresAt" TEXT, "refreshTokenExpiresAt" TEXT,
          "scope" TEXT, "password" TEXT,
          "createdAt" TEXT NOT NULL, "updatedAt" TEXT NOT NULL)`,
        `CREATE TABLE IF NOT EXISTS "verification" (
          "id" TEXT PRIMARY KEY, "identifier" TEXT NOT NULL, "value" TEXT NOT NULL,
          "expiresAt" TEXT NOT NULL, "createdAt" TEXT NOT NULL, "updatedAt" TEXT NOT NULL)`,
      ],
      "write",
    )
    .then(() => undefined);
  return ready;
}
pages.ts
// Server-rendered pages. Plain HTML, no client-side JavaScript.

type User = { name: string; email: string };

// Always escape user-provided values before putting them into HTML.
export const escapeHtml = (value: string): string =>
  value.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] ?? c);

const layout = (title: string, body: string): string => `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${title}</title>
<style>
  body { margin: 0; font-family: system-ui, sans-serif; background: #f4f4f2; color: #1c1c1c; }
  main { max-width: 26rem; margin: 8vh auto 0; padding: 1.5rem; background: #fff; border: 1px solid #ddd; }
  h1 { font-size: 1.2rem; margin: 0 0 1rem; }
  label { display: block; font-size: 0.85rem; margin: 0.75rem 0 0.25rem; }
  input { width: 100%; box-sizing: border-box; padding: 0.5rem; border: 1px solid #bbb; font: inherit; }
  button { margin-top: 1rem; padding: 0.5rem 1rem; border: 1px solid #1c1c1c; background: #1c1c1c; color: #fff; font: inherit; cursor: pointer; }
  .error { color: #b00020; font-size: 0.9rem; }
  .muted { color: #666; font-size: 0.85rem; }
  a { color: inherit; }
  dt { font-size: 0.8rem; color: #666; margin-top: 0.75rem; }
  dd { margin: 0.1rem 0 0; }
</style>
</head>
<body>
<main>
${body}
</main>
</body>
</html>
`;

const errorLine = (error?: string): string => (error ? `<p class="error">${escapeHtml(error)}</p>` : "");

export const signUpPage = (error?: string): string =>
  layout(
    "Create account",
    `<h1>Create account</h1>
${errorLine(error)}
<form method="post" action="/sign-up">
  <label for="name">Name</label>
  <input id="name" name="name" required>
  <label for="email">Email</label>
  <input id="email" name="email" type="email" required>
  <label for="password">Password (at least 8 characters)</label>
  <input id="password" name="password" type="password" minlength="8" required>
  <button>Sign up</button>
</form>
<p class="muted">Already have an account? <a href="/sign-in">Sign in</a></p>`,
  );

export const signInPage = (error?: string): string =>
  layout(
    "Sign in",
    `<h1>Sign in</h1>
${errorLine(error)}
<form method="post" action="/sign-in">
  <label for="email">Email</label>
  <input id="email" name="email" type="email" required>
  <label for="password">Password</label>
  <input id="password" name="password" type="password" required>
  <button>Sign in</button>
</form>
<p class="muted">New here? <a href="/sign-up">Create an account</a></p>`,
  );

export const profilePage = (user: User): string =>
  layout(
    "Profile",
    `<h1>Signed in</h1>
<dl>
  <dt>Name</dt><dd>${escapeHtml(user.name)}</dd>
  <dt>Email</dt><dd>${escapeHtml(user.email)}</dd>
</dl>
<form method="post" action="/sign-out">
  <button>Sign out</button>
</form>
<p class="muted">Sessions are issued by better-auth under /api/auth/* and stored in the linked libsql database.</p>`,
  );
sandboxEnv.ts
// The sandbox only allows reading this box's own env vars and throws for any
// other name. npm packages routinely probe generic variables such as NODE_ENV,
// so make those probes return undefined instead of throwing, and hand
// Node-compat code a plain env object. Import this module before any npm import.
import process from "node:process";

const realGet = Deno.env.get.bind(Deno.env);
Deno.env.get = (name: string) => {
  try {
    return realGet(name);
  } catch {
    return undefined;
  }
};

const env: Record<string, string | undefined> = { NODE_ENV: "production" };
for (const name of ["AUTH_DB", "BETTER_AUTH_SECRET", "BETTER_AUTH_URL"]) {
  env[name] = Deno.env.get(name);
}
Object.defineProperty(process, "env", { value: env, configurable: true });

Go beyond what seems possible.