Packages@lunora/auth@lunora/auth

@lunora/auth

A thin better-auth wrapper for Lunora — email/password, OAuth, plugins, D1-backed.

@lunora/auth is a thin wrapper around better-auth that runs on your own Cloudflare account. createAuth(options) is betterAuth(options) with a few Cloudflare-friendly defaults; user and session records live in D1, and there is no external auth service to boot. better-auth owns the actual behaviour — sign-in flows, password hashing (scrypt), OAuth, sessions — and this package adds the Cloudflare wiring: a D1 adapter, a /api/auth/* router, schema migrations, a ctx.authApi middleware, an admin surface for the studio, and standalone Turnstile helpers.

import { createAuth, lunoraD1Adapter } from "@lunora/auth";
import { passkey } from "@lunora/auth/plugins";

export const auth = createAuth({
    secret: env.AUTH_SECRET,
    database: lunoraD1Adapter(env.DB),
    emailAndPassword: { enabled: true },
    plugins: [passkey()],
});

createAuth is a thin wrapper over better-auth. It requires secret up front, so a misconfigured deployment fails loudly at setup. Curated plugins (passkeys, 2FA, magic-link, organization, and more) are re-exported from @lunora/auth/plugins.

Mount the routes

handleAuthRequest(auth, request) serves every auth endpoint under /api/auth/* (sign-up, sign-in, OAuth callbacks, session refresh, and each plugin's routes). Call it at the top of your worker's fetch. It returns a Response for an auth request and a falsy value otherwise, so you fall through to the Lunora worker for everything else:

import { ensureMigrated, handleAuthRequest } from "@lunora/auth";

export default {
    async fetch(request, env, ctx) {
        await ensureMigrated(auth);

        const response = await handleAuthRequest(auth, request);
        if (response) return response;

        // … hand off to your Lunora worker
    },
};

Read ctx.auth in functions

The runtime resolves the inbound session and populates ctx.auth on every query / mutation / action. It carries the verified identity:

  • ctx.auth.userId is the signed-in user's id, or null for an anonymous request. A truthy check narrows it to string and doubles as your "is signed in" guard.
  • ctx.auth.getIdentity() resolves the decoded identity claims, or null when anonymous.
if (!ctx.auth.userId) throw new Error("must be signed in");
const identity = await ctx.auth.getIdentity();

Sessions

Sessions are better-auth's, stored in the session table on the same D1 database as the user / account / verification tables. better-auth writes the session cookie and validates it on each request; createAuth applies a secure-by-default cookie posture on top — httpOnly, sameSite: "lax", path: "/", and useSecureCookies forced on for an HTTPS baseURL (the process.env.NODE_ENV heuristic better-auth uses to decide this is unreliable on Workers).

Tune session lifetime and rotation through the session field, a SessionPolicy (a typed alias for better-auth's session option). createAuth validates the durations and forwards them verbatim, and sessionPresets gives you ready-made trade-offs:

import { createAuth, lunoraD1Adapter, sessionPresets } from "@lunora/auth";

export const auth = createAuth({
    secret: env.AUTH_SECRET,
    database: lunoraD1Adapter(env.DB),
    // 7-day absolute expiry, rotated once per day; override individual fields.
    session: { ...sessionPresets.rolling, freshAge: 60 * 5 },
});

The presets are rolling (7-day expiry, daily rotation), strict (1-hour expiry, 15-minute rotation), and longLived (30-day expiry, daily rotation). The underlying fields are expiresIn, updateAge, freshAge, disableSessionRefresh, and cookieCache — see better-auth's session option for the full list.

OAuth providers

OAuth is entirely better-auth's. Built-in social providers run through its socialProviders config, and anything else goes through the genericOAuth plugin (re-exported from @lunora/auth/plugins). createAuth forwards socialProviders unchanged — the code/token/userinfo exchange, PKCE, and id_token verification are all better-auth's:

import { createAuth, lunoraD1Adapter } from "@lunora/auth";

export const auth = createAuth({
    secret: env.AUTH_SECRET,
    database: lunoraD1Adapter(env.DB),
    socialProviders: {
        github: { clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET },
        google: { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET },
    },
});

Callbacks are served under /api/auth/callback/<provider> by handleAuthRequest. For providers beyond the built-in list, add genericOAuth({ config: [...] }) to plugins.

Background tasks (waitUntil)

better-auth runs some work after sending the response — most importantly the password-reset email, whose background send keeps reset responses constant-time (so the response doesn't reveal whether the account exists). On Cloudflare Workers a promise not handed to ctx.waitUntil can be cancelled the moment the response returns, dropping that send. Wire the per-request ctx.waitUntil into better-auth's background handler:

const auth = createAuth({
    secret: env.AUTH_SECRET,
    database: lunoraD1Adapter(env.DB),
    advanced: {
        backgroundTasks: { handler: (promise) => ctx.waitUntil(promise) },
    },
});

createAuth can't set this for you — ctx.waitUntil is per-request, but createAuth runs once at worker setup.

Rate limiting

/api/auth/* is rate-limited by default. better-auth enables rate limiting automatically, but only when NODE_ENV === "production" — a check that is unreliable on Cloudflare Workers, where there is no Node process.env at request time. So createAuth defaults rateLimit.enabled to true whenever you don't set it explicitly, ensuring auth endpoints are throttled on real deployments (and in dev) rather than silently wide open.

The defaults are better-auth's: a 10-second window with a max of 100 requests per IP, plus stricter per-path rules for sensitive endpoints (sign-in, sign-up, etc.). Configure it through the rateLimit option, which is forwarded to better-auth verbatim:

export const auth = createAuth({
    secret: env.AUTH_SECRET,
    rateLimit: {
        window: 60, // seconds
        max: 100, // requests per window per IP
        customRules: {
            "/sign-in/email": { window: 10, max: 5 },
        },
    },
});

To turn rate limiting off — for example when you front auth with your own limiter (see @lunora/ratelimit) — pass an explicit flag; an explicit enabled value always wins over the default:

export const auth = createAuth({
    secret: env.AUTH_SECRET,
    rateLimit: { enabled: false },
});

Password hashing

Password hashing is better-auth's, not Lunora's — by default it hashes with scrypt (no Node polyfills needed on Workers). To swap the algorithm, pass emailAndPassword.password: { hash, verify } to createAuth; it forwards to better-auth unchanged.

Calling the plugin API from procedures

@lunora/auth/middleware exports withAuthPlugins(auth), a Lunora middleware that mounts the full better-auth endpoint surface on ctx.authApi (typed against whatever plugins your instance loaded). Because ctx.authApi is the privileged surface (banUser, setRole, impersonation, …), the middleware installs a runtime guard by default: a call that omits headers throws LunoraAuthHeadersError instead of running as a trusted server-to-server invocation. See plugins for the full pattern and the withoutHeaders() escape hatch.

Disposable / free-email gating

Reject throwaway/disposable signups (and branch on free-vs-business email) at registration by reusing the visulima email lists. The classification is pure-data and edge-safe on the default path — no DNS, no filesystem.

Wire it into better-auth's native /sign-up/email endpoint with withEmailGate (or spread emailGateDatabaseHooks(...) into createAuth({ databaseHooks })):

import { createAuth, lunoraD1Adapter, withEmailGate } from "@lunora/auth";

const auth = createAuth(
    withEmailGate(
        { secret: env.AUTH_SECRET, database: lunoraD1Adapter(env.DB), emailAndPassword: { enabled: true } },
        {
            blockDisposable: true, // default: reject disposable domains with `EMAIL_DOMAIN_BLOCKED` (400)
            allowDomains: ["your-company.com"], // never blocked; always classified `business`
            denyDomains: [], // extra domains to treat as disposable
            onClassify: (classification, user) => {
                // classification.emailClass is "disposable" | "free" | "business"
            },
        },
    ),
);

A blocked signup rejects with the coded EMAIL_DOMAIN_BLOCKED error; a business/free address passes. Everything is config-gated and defaults sensibly.

For non-auth procedures, @lunora/auth/email-guard exports the building blocks:

  • classifyEmail(email, config) — sync, pure-data → { emailClass, domain }.
  • assertEmailAllowed(email, config) — async; throws the coded error on a policy failure.
  • emailGateMiddleware({ email: (ctx) => ctx.args.email }) — a .use() gate for your own signup mutation.
  • loadEmailDomainLists() — call once at worker init on workerd (the gate helpers already do).

MX / deliverability (opt-in, needs DNS). Passing mx: true runs an MX check via @visulima/email-verifier/checks/mx, which uses node:dns and is therefore not edge-safe — it is loaded through a dynamic import so the DNS module never enters the default bundle. Enable it only where DNS is available (nodejs_compat or a DNS-over-HTTPS shim); an undeliverable domain then fails with EMAIL_UNDELIVERABLE.

The advisor ships a signup_mutation_without_disposable_gating lint that flags a public account-creating mutation with no email gate (pairs with the existing user_creating_mutation_without_captcha lint).

Security / audit trail

Record authentication & security events — sign-in, sign-up, password change, MFA enable/disable, token refresh, session revoke, account link/unlink — to a durable, queryable audit trail (the compliance/forensics surface Supabase and Firebase expose). Install the better-auth hooks.after recorder with authAuditHook (or compose via withAuthAudit):

import { authAuditHook, createAuth, d1Executor, lunoraD1Adapter, readAuthAuditLog } from "@lunora/auth";

const executor = d1Executor(env.DB); // same D1 as the auth tables

const auth = createAuth({
    secret: env.AUTH_SECRET,
    database: lunoraD1Adapter(env.DB),
    hooks: {
        after: authAuditHook({
            executor,
            retention: 100_000, // CONFIGURABLE, NOT capped — omit for an unbounded (compliance) trail
            onRecord: (entry) => forwardToSiem(entry), // optional export tap for SIEM forwarding
        }),
    },
});

// Read the trail (RLS/admin-gate this in your own query):
const signIns = await readAuthAuditLog(executor, { event: "sign-in", limit: 100 });

Each row captures the actor (id + email), event type, client IP/User-Agent, timestamp, and outcome. The free-form detail payload is scrubbed with @visulima/redact before it is persisted, so a token/password that leaks into an event's context never reaches the durable table. The trail lives in the reserved __lunora_auth_audit__ table (auto-hidden from the data browser); retention is configurable via retention and defaults to unbounded.

See also