newX 1.3: islands to disk, server-mode islands, and an image proxy

Packages

@thexjs/auth

Plug-and-play authentication for X apps. Add credentials (username/password) and OAuth2, including a preconfigured GitHub provider, with one defineAuth() call, a sessions table in SQLite or Postgres via the framework's data layer, and a single catch-all API route.

terminal
bun add @thexjs/auth

Quick start

Define your providers and session store once:

lib/auth.ts
import { defineAuth, createSQLiteSessionStore, hashPassword, verifyPassword } from "@thexjs/auth";export const auth = defineAuth({  secret: process.env.AUTH_SECRET!,  store: createSQLiteSessionStore(), // or createPostgresSessionStore(client)  providers: [    {      id: "local",      name: "Local",      type: "credentials",      async authorize({ email, password }) {        const user = await db.query("SELECT * FROM users WHERE email = ?").get(email);        if (!user) return null;        if (!(await verifyPassword(password, user.password_hash))) return null;        return { id: String(user.id), name: user.name, email: user.email };      },    },    {      id: "github",      name: "GitHub",      type: "oauth",      clientId: process.env.GITHUB_CLIENT_ID!,      clientSecret: process.env.GITHUB_CLIENT_SECRET!,    },  ],});

The github provider is a preset: give it a client ID and secret and the authorization, token, and user-info URLs are wired up for you. A generic OAuth2 provider is available too for any other authorization-code provider.

Wire it up with one catch-all API route:

api/auth/[...auth].ts
import { auth } from "../../lib/auth";export async function POST(req: Request) {  return auth.handleRequest(req);}export async function GET(req: Request) {  return auth.handleRequest(req);}

Create users at sign-up with hashPassword (Argon2id) and store the hash, never the plaintext:

signup
import { hashPassword } from "@thexjs/auth";await hashPassword("correct horse battery staple");

Endpoints

handleRequest routes the path below api/auth:

routes
Route                        Method  Purpose/api/auth/signin/<id>        POST    credentials provider: form or multipart body with the                                     provider's fields (e.g. email, password)/api/auth/signin/<id>        GET     OAuth2 provider: redirects the browser to the provider's                                     authorization URL/api/auth/callback/<id>      GET     OAuth2 callback: exchanges the code, validates the state                                     challenge, signs the user in/api/auth/signout            POST    revokes the session and clears the cookie/api/auth/session            GET     JSON { "user": { ... } } or 401

A sign-in form POSTs to /api/auth/signin/localand, after success, the browser follows the 302 to successRedirect (default /). For OAuth, the button or link is just a GET to /api/auth/signin/github.

Reading the session

middleware or loader
const session = await auth.getSession(request);if (!session) return new Response("Unauthorized", { status: 401 });session.user; // { id, name?, email? } snapshot from sign-in

getSession hashes the x_session cookie, looks up the token in the store, and returns null for expired or revoked sessions. For programmatic flows, the defineAuth() result also exposes setSessionCookie(res, user, provider) and clearSessionCookie(res, req?).

Security

  • Passwords are Argon2id via Bun.password ( hashPassword / verifyPassword).
  • Session tokens are opaque random strings; only an HMAC-SHA256 digest (keyed by secret) is stored, so a database leak doesn't expose usable session cookies. Tokens are random 128-bit values, revocable, and expire after sessionMaxAge (default 7 days).
  • OAuth state: an x_oauth_state cookie challenge must match the state param on the callback (HMAC'd, 5-minute expiry), preventing login-CSRF and session-fixation via crafted callbacks.
  • CSRF: POST endpoints ( signin, signout ) run the core checkCsrf automatically: Origin/Referer verification by default, or requireToken for double-submit defense in depth, and it rejects non-conforming requests with 403. See Security for how the module is configured.
  • Cookies are HttpOnly, SameSite=Lax, Secure in production.

Set a stable secret in production. If omitted, a random per-process secret is generated and a warning is printed, which means sessions won't survive restarts.

Session stores

Both stores use a single x_sessions table and implement the SessionStore interface ( create, find, revoke), so you can bring your own:

stores
createSQLiteSessionStore({ path: "data/auth.db" });   // default: data/auth.dbcreatePostgresSessionStore(connectPostgres({ url: process.env.DATABASE_URL }));

The Postgres store ensures the table lazily on first use and takes a client returned by connectPostgres from @thexjs/core/data, so it inherits the connection pool, TLS policy, and retry behavior of the framework. See Data Layer for the underlying stores.

Route guards

The auth object turns session checks into framework middleware. auth.requireAuth(), auth.requireRole(), and auth.requirePermission() each return a MiddlewareFn: failing checks short-circuit with a 401/403, or a 302 when you pass redirectTo. Drop them in a _middleware.ts to guard everything in a folder:

src/pages/dashboard/_middleware.ts
import { auth } from "../../lib/auth";export const middleware = auth.requireRole("admin", {  redirectTo: "/signin",});

Denied-but-signed-in attempts are written to the audit trail automatically, so permission failures are reviewable after the fact.

Brute-force protection

createBruteForceGuard tracks failed sign-ins in two separate buckets: one per account identifier, one per client IP (so one IP spraying many accounts locks out fast). Defaults: 5 attempts per 15-minute window.

guarding the credentials flow
import { createBruteForceGuard } from "@thexjs/auth";const guard = createBruteForceGuard({ maxAttempts: 5, windowMs: 15 * 60_000 });// before verifying a password — check the account bucket and the IP bucket:const account = guard.accountKey(email);if (!guard.status(account).ok || !guard.status(guard.ipKey(req)).ok) {  return Response.json({ error: "Too many attempts" }, { status: 429 });}// after a failed verification:guard.recordFailure(account);