[]previous and next page

Auth.js and better-auth

Use Auth.js or better-auth in a Dusk app, mounted as an API route, with the same requireUser guard as the built-in OpenID Connect client.

For agentsUpdated

Mount Auth.js or better-auth as a catch-all API route. Both accept a web Request and return a Response; connect the resulting session to a Dusk guard before serving private data.

Start with Sign users in.

What each side owns#

  • The library owns its endpoints and its cookie. Auth.js sets authjs.session-token and authjs.csrf-token; better-auth sets better-auth.session_token. Do not also put the user in a Dusk session for the same sign-in: there would be two sources of truth.
  • Dusk's CSRF check still runs first on every POST to the mounted route. A form or fetch from your own site passes it; a request from another site is refused before the library sees it. The libraries have checks of their own (Auth.js' token, better-auth's trusted origins); they stay on.
  • The public address must be right. Both libraries build absolute URLs (callbacks, redirects) from the host of the request or from a configured base URL. Set security.allowedHosts and the library's base URL to your public origin.

Auth.js#

The configuration is one object. This one uses the credentials provider so the example needs no identity provider; in a real app the providers list holds GitHub, Google, e-mail links or any of the many others, imported from @auth/core/providers/<name>. Auth.js keeps the session in an encrypted cookie (the jwt strategy), so this needs no database; add an adapter to keep sessions and users in one.

The session callback copies the user's id into the session. Auth.js leaves it out of the public session by default, and a guard needs it. Declare the field once so TypeScript knows it:

src/auth/log.tsts
/** What Auth.js reported as an error. A real app sends these to its logger; the test reads them. */
export const authErrors: string[] = [];
src/auth/authjs.server.tsts
import { Auth, type AuthConfig } from "@auth/core";
import Credentials from "@auth/core/providers/credentials";
import type { OidcUser } from "@shadow-js/dusk/auth";
import type { RequestEvent } from "@shadow-js/dusk/server";
import { authErrors } from "./log";

declare module "@auth/core/types" {
  interface Session {
    /** The id of the signed-in user: the `sub` of the token. */
    userId?: string;
  }
}

/** The demo user table. A real app asks its database or an identity provider. */
const accounts = new Map([["ada@example.com", { id: "u-1", name: "Ada", password: "correct horse battery" }]]);

export const authConfig: AuthConfig = {
  secret: process.env["AUTH_SECRET"] ?? "",
  // Dusk checks the host (`security.allowedHosts`) before a request gets here, so Auth.js may trust it.
  trustHost: true,
  basePath: "/api/auth",
  logger: {
    error: (error) => {
      authErrors.push(error.name);
    },
  },
  session: { strategy: "jwt", maxAge: 60 * 60 * 24 },
  providers: [
    Credentials({
      credentials: { email: {}, password: {} },
      authorize(credentials) {
        const account = accounts.get(String(credentials["email"]));
        if (account === undefined || account.password !== credentials["password"]) return null;
        return { id: account.id, name: account.name, email: String(credentials["email"]) };
      },
    }),
  ],
  callbacks: {
    jwt({ token, user }) {
      if (user?.id !== undefined) token.sub = user.id;
      return token;
    },
    session: ({ session, token }) => ({
      ...session,
      ...(token.sub === undefined ? {} : { userId: token.sub }),
    }),
  },
};

/**
 * The signed-in user of a request, read by asking Auth.js for its session with the request's cookie. Shaped for
 * `requireUser`, which only needs a `user(event)` function.
 */
export const authjs = {
  async user(event: RequestEvent): Promise<OidcUser | undefined> {
    const cookie = event.request.headers.get("cookie");
    if (cookie === null) return undefined;
    const response = await Auth(
      new Request(new URL(`${authConfig.basePath}/session`, event.url), { headers: { cookie } }),
      authConfig,
    );
    const session = (await response.json()) as {
      userId?: string;
      user?: { name?: string | null; email?: string | null; image?: string | null };
    } | null;
    if (session?.userId === undefined) return undefined;
    return {
      id: session.userId,
      issuer: "authjs",
      ...(session.user?.name ? { name: session.user.name } : {}),
      ...(session.user?.email ? { email: session.user.email } : {}),
      ...(session.user?.image ? { picture: session.user.image } : {}),
    };
  },
};

trustHost: true is right here only because Dusk's security.allowedHosts has already checked the host of every request; without it, Auth.js would build its URLs from a header an attacker controls. Leave trustHost off if you do not set allowedHosts.

Mount the library once, for every method it uses. The splat [...auth] matches /api/auth/csrf, /api/auth/session, /api/auth/signin/<provider> and the rest:

src/routes/api/auth/[...auth].tsts
import { Auth } from "@auth/core";
import type { RequestEvent } from "@shadow-js/dusk/server";
import { authConfig } from "../../../auth/authjs.server";

const handle = (event: RequestEvent): Promise<Response> => Auth(event.request, authConfig);

export const GET = handle;
export const POST = handle;

A guarded route is the same as with the built-in client:

src/routes/api/authjs-me.tsts
import { requireUser } from "@shadow-js/dusk/auth";
import { apiHandler, json } from "@shadow-js/dusk/server";
import { authjs } from "../../auth/authjs.server";

export const GET = apiHandler({ guard: requireUser(authjs) }, async ({ event }) =>
  json({ user: await authjs.user(event) }),
);

better-auth#

better-auth keeps users and sessions in a database. The example uses its in-memory adapter; production uses a database adapter (Drizzle, Prisma, Kysely, MongoDB and others), for instance the Drizzle one with the connection from Dusk: databases. Sessions are validated against the database on each request (with an optional cookie cache), so signing a user out on the server takes effect at once.

src/auth/better.server.tsts
import type { OidcUser } from "@shadow-js/dusk/auth";
import type { RequestEvent } from "@shadow-js/dusk/server";
import { betterAuth } from "better-auth";
import { memoryAdapter } from "better-auth/adapters/memory";

/** The in-memory tables. Replace the adapter with a database adapter in a real app. */
const tables = { user: [], session: [], account: [], verification: [] };

export const auth = betterAuth({
  database: memoryAdapter(tables),
  secret: process.env["BETTER_AUTH_SECRET"] ?? "",
  baseURL: process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000",
  basePath: "/api/better",
  emailAndPassword: { enabled: true },
});

/** The signed-in user of a request, shaped for `requireUser`. */
export const betterAuthUser = {
  async user(event: RequestEvent): Promise<OidcUser | undefined> {
    const session = await auth.api.getSession({ headers: event.request.headers });
    if (session === null) return undefined;
    return {
      id: session.user.id,
      issuer: "better-auth",
      name: session.user.name,
      email: session.user.email,
      emailVerified: session.user.emailVerified,
      ...(session.user.image ? { picture: session.user.image } : {}),
    };
  },
};

auth.handler takes a Request and returns a Response, so the route is two lines:

src/routes/api/better/[...all].tsts
import type { RequestEvent } from "@shadow-js/dusk/server";
import { auth } from "../../../auth/better.server";

const handle = (event: RequestEvent): Promise<Response> => auth.handler(event.request);

export const GET = handle;
export const POST = handle;
src/routes/api/better-me.tsts
import { requireUser } from "@shadow-js/dusk/auth";
import { apiHandler, json } from "@shadow-js/dusk/server";
import { betterAuthUser } from "../../auth/better.server";

export const GET = apiHandler({ guard: requireUser(betterAuthUser) }, async ({ event }) =>
  json({ user: await betterAuthUser.user(event) }),
);

The entry sets the hosts Dusk accepts. Set the environment variables used by the auth modules before starting the server.

src/entry-server.tsts
import type { HandlerOptions } from "@shadow-js/dusk/server";

const options: Omit<HandlerOptions, "manifest"> = {
  security: { allowedHosts: ["localhost"] },
};

export default options;

Pages#

A page that needs a user runs the same guard in a folder's middleware, with loginPath pointing at your sign-in page, exactly as in Dusk: sign-in with OAuth and OpenID Connect. The only difference is the object you pass to requireUser (authjs or betterAuthUser here). To show the user in the page, return it from a loader that calls a server function guarded by the same object.

Production checklist#

  • Secrets (AUTH_SECRET, BETTER_AUTH_SECRET) are long and random and come from the environment; the apps above refuse to work with an empty one.
  • security.allowedHosts is set, the library's base URL is your public origin, and your proxy sets the forwarded host and protocol.
  • The mounted route is rate limited (Dusk: security): sign-in and sign-up are brute-force targets.
  • Auth.js with the credentials provider is for testing and legacy password tables; prefer an identity provider or better-auth for passwords, which add hashing, rate limits and reset flows.
  • A shared database for better-auth when you run more than one process; an in-memory adapter forgets users when the process restarts.
  • Keep the libraries' packages up to date: they are security-sensitive.