Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/tidy-wolves-wave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"@thexjs/auth": minor
---

New `@thexjs/auth` package: plug-and-play authentication for x apps.

- `defineAuth()` with credentials (username/password) and OAuth2 providers,
including a preconfigured GitHub preset.
- Passwords hashed with Argon2 via `Bun.password` (`hashPassword` /
`verifyPassword`).
- Session stores on the framework data layer (`createSQLiteSessionStore`,
`createPostgresSessionStore`); tokens are opaque, HMAC-SHA256 digests stored
at rest, individually revocable, and expire after `sessionMaxAge`.
- Single catch-all handler (`auth.handleRequest`) serving
`/api/auth/signin/:id`, `/api/auth/callback/:id`, `/api/auth/signout`, and
`/api/auth/session`, with automatic CSRF protection (`checkCsrf`) on auth
POST endpoints and OAuth state-challenge verification on callbacks.
- Server-side `getSession()` plus `setSessionCookie` / `clearSessionCookie`
helpers.

Credentials and OAuth callback flows, session expiry/revocation, and CSRF
integration are covered by unit tests in `packages/auth/src/auth.test.ts`.
38 changes: 38 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,44 @@ jobs:
- name: Test
run: bun test

test-postgres:
name: Test (Postgres migrations)
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false

- uses: oven-sh/setup-bun@v2
with:
# Pinned for reproducible builds — bump deliberately.
bun-version: 1.3.14

- name: Install dependencies
run: bun install --frozen-lockfile

# The runPostgresMigrations tests are gated on DATABASE_URL and skipped
# otherwise (see migrate.test.ts), so only this job exercises the real
# Postgres rollback/retry behavior via the service container above.
- name: Test (Postgres migrations)
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres
run: bun test packages/core/src/data/migrate.test.ts
Comment on lines +44 to +80

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Set explicit read-only token permissions.

This job has no permissions block. It inherits the repository default GITHUB_TOKEN permissions. A compromised action or executed dependency can use write access when the repository default permits it.

Add permissions: { contents: read } to test-postgres.

🧰 Tools
🪛 Checkov (3.3.9)

[medium] 79-80: Basic Auth Credentials

(CKV_SECRET_4)

🪛 zizmor (1.29.0)

[warning] 44-80: overly broad permissions (excessive-permissions): default permissions used due to no permissions: block

(excessive-permissions)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml around lines 44 - 80, Add a job-level permissions
block to test-postgres granting only contents: read, ensuring its GITHUB_TOKEN
cannot inherit broader repository permissions.

Source: Linters/SAST tools


audit:
name: Dependency audit
runs-on: ubuntu-latest
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ bun test

```
packages/core framework runtime (router, SSR/SSG, islands, server functions, data layer)
packages/auth credentials + OAuth2/GitHub auth, sessions, CSRF
packages/cli `x dev` / `x build` / `x start` CLI
packages/env type-safe env var validation
packages/adapter-vercel Vercel Build Output API adapter
Expand Down
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ Other templates: `basic` (pages + API + auth + dashboard), `blog` (markdown cont
packages/core file-based router, SSR renderer, middleware
packages/cli x dev / x build / x start
packages/env typed env variable validation
packages/auth credentials + OAuth2/GitHub auth, sessions, CSRF
packages/create-thexjs-app the scaffolder (bun create thexjs-app@latest)
examples/default minimal starter
examples/basic pages, API routes, auth, dashboard
Expand Down Expand Up @@ -220,6 +221,24 @@ const imageProxy = createImageProxyHandler({ remoteHosts: ["cdn.example.com"] })

Drop a `_middleware.ts` in any pages folder it runs for that folder and everything under it. Useful for auth checks, redirects, logging.

## Authentication

`@thexjs/auth` adds credentials (username/password) and OAuth2 (including a GitHub preset) sign-in with one `defineAuth()` call. Passwords are hashed with Argon2 via `Bun.password`, session tokens are HMAC'd at rest and revocable, sessions live in SQLite or Postgres through the data layer, and auth POST endpoints run the core CSRF module automatically. Mount it on a single catch-all API route:

```ts
import { defineAuth, createSQLiteSessionStore } from "@thexjs/auth";

export const auth = defineAuth({
secret: process.env.AUTH_SECRET!,
store: createSQLiteSessionStore(),
providers: [{ id: "github", name: "GitHub", type: "oauth", clientId: "...", clientSecret: "..." }],
});

// api/auth/[...auth].ts — forward GET/POST to auth.handleRequest(req)
```

See [packages/auth/README.md](packages/auth/README.md) for the endpoint map (`/api/auth/signin/:id`, `/callback/:id`, `/signout`, `/session`) and `getSession()` usage.

## Data layer

Built-in SQLite (`connectSQLite`, zero config, good for dev) and Postgres (`connectPostgres`, connection pooling, for production) with versioned migrations for both.
Expand Down Expand Up @@ -262,7 +281,7 @@ signal you should pin versions (`^0.1.0` will still allow `0.1.x → 0.2.0`).
`NPM_TOKEN` secret, which is currently not configured (see
`.github/workflows/release.yml`).
- Published packages: `@thexjs/core`, `@thexjs/cli`, `@thexjs/env`,
`@thexjs/adapter-vercel`, `create-thexjs-app`.
`@thexjs/auth`, `@thexjs/adapter-vercel`, `create-thexjs-app`.

## Known limitations

Expand Down
8 changes: 4 additions & 4 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,16 @@ writing.
| Vercel adapter (output v3, config.json) | stable | yes | `adapter.test.ts` |
| Layouts / middleware chains | stable | partial | scanning tested; multi-layer rendering less so |
| Content collections (`scanContent`) | beta | yes | `content.test.ts`; frontmatter parser is a small custom YAML subset |
| Data layer (SQLite/Postgres migrations) | beta | no | thin wrapper over `bun:sqlite` / `Bun.sql`; migration runner untested |
| Data layer (SQLite/Postgres migrations) | beta | yes | `data/migrate.test.ts`; thin wrapper over `bun:sqlite` / `Bun.sql` |
| Islands / client hydration | beta | partial | build emits islands; runtime hydration tested via examples only |
| Content-MDX | experimental | no | `.mdx` support exists but is the least exercised surface |
| Observability (health/readyz, metrics) | beta | partial | health checked in `createApp-request.test.ts`; reporter flushing untested |

## Coverage debt (high → low priority)

1. **Data layer**: migration runner, WAL/FK setup, retry/backoff behavior.
Users depend on this for real apps; today it's untested glue around Bun's
drivers.
1. **Data layer**: WAL/FK setup and Postgres retry/backoff behavior. The
migration runner is now tested (`data/migrate.test.ts`), but the runtime
connection behaviors are not.
2. **Content pipeline**: frontmatter parsing edge cases, content route
generation, `.mdx` compile path.
3. **Islands runtime**: hydration lifecycle, island props serialization,
Expand Down
38 changes: 27 additions & 11 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,17 +154,33 @@ STRIPE_SECRET_KEY=sk_live_...

## Authentication & sessions

The framework does not ship an opinionated auth system; it ships the
primitives you build one on (cookies, middleware, server functions, data
layer). The `basic` and `saas` templates include a **demo** auth (hardcoded
`admin` / `admin`, no password hashing, no expiry) that is explicitly marked
DEMO ONLY. Before shipping:

- Store **hashed** passwords (e.g. `Bun.password.hash` / Argon2).
- Add session expiry and revocation.
- Use `Secure; HttpOnly; SameSite=Lax` cookies (the templates and CSRF cookie
set `Secure` automatically when `NODE_ENV=production`).
- Protect routes with `_middleware.ts`.
`@thexjs/auth` is the framework's opt-in auth package: credentials
(username/password) and OAuth2 (including a GitHub preset) providers, session
stores on the data layer (SQLite/Postgres), and a catch-all API handler. Its
security properties:

- **Passwords** — hashed with Argon2id via `Bun.password` (`hashPassword` /
`verifyPassword`). Never store plaintext.
- **Session tokens** — opaque random 128-bit strings. Only an HMAC-SHA256
digest of the token (keyed by `AUTH_SECRET`) is stored, so a database leak
does not expose usable session cookies. Sessions expire after
`sessionMaxAge` (default 7 days) and are individually revocable.
- **OAuth state** — a `x_oauth_state` cookie challenge (HMAC'd, 5-minute
expiry) must match the `state` param on the callback, preventing
login-CSRF / session-fixation via crafted callbacks.
- **CSRF** — auth `POST` endpoints (`signin`, `signout`) run the core
`checkCsrf` (Origin/Referer verification; see above) and return `403` on
failure. To add the double-submit token to auth endpoints, set
`security.csrf.requireToken: true` — auth routes honor it automatically.
- **Cookies** — `HttpOnly; SameSite=Lax`, plus `Secure` when
`NODE_ENV=production`. Set a **stable** `secret` in production; an omitted
secret generates a per-process value that doesn't survive restarts.

The framework itself still ships the primitives you'd build auth on
(cookies, middleware, server functions, data layer), and the `basic` and
`saas` templates include a **demo** auth (hardcoded `admin` / `admin`, no
password hashing, no expiry) that is explicitly marked DEMO ONLY — replace it
with `@thexjs/auth` (or your own) before shipping.

## Health endpoints

Expand Down
13 changes: 13 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"lint:fix": "biome check --write .",
"format": "biome check --write .",
"build": "bun run build:packages && bun run --cwd examples/basic build",
"build:packages": "bun run --cwd packages/core build && bun run --cwd packages/env build && bun run --cwd packages/adapter-vercel build && bun run --cwd packages/cli build",
"build:packages": "bun run --cwd packages/core build && bun run --cwd packages/env build && bun run --cwd packages/adapter-vercel build && bun run --cwd packages/cli build && bun run --cwd packages/auth build",
"dev": "bun run build:packages && bun --cwd examples/basic run dev",
"vercel-build": "bun run build:packages && bun run --cwd examples/basic vercel-build && rm -rf .vercel/output && cp -R examples/basic/.vercel/output .vercel/output && rm -rf examples/basic/.vercel",
"postinstall": "bun run build:packages && cd packages/cli && bun link --force && echo '[x] \"x\" CLI linked globally. Make sure ~/.bun/bin is in your PATH.' || echo '[x] Could not link globally. Use \"bun run dev\" in the example instead.'"
Expand Down
21 changes: 21 additions & 0 deletions packages/auth/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Abdelkabir Ouadoukou

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
112 changes: 112 additions & 0 deletions packages/auth/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# @thexjs/auth

Plug-and-play authentication for [x](https://www.npmjs.com/package/@thexjs/core) framework apps. Add credentials (username/password) and OAuth2 (including GitHub) sign-in with one `defineAuth()` call, a sessions table in SQLite or Postgres via the framework's data layer, and a single catch-all API route. Passwords are hashed with Argon2 via `Bun.password`, session tokens are HMAC'd at rest, and every mutating endpoint is protected by the core CSRF module automatically.

```sh
bun add @thexjs/auth
```

## Quick start

```ts
// 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 };
Comment on lines +23 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Initialize the application user database in the quick start.

Line 24 uses db, but the example does not import or create it. A copied example cannot run.

Show a server-side database initialization through the data-layer helper before defineAuth, or state that db is an existing application database.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/auth/README.md` around lines 23 - 27, Update the quick-start example
before defineAuth to initialize the application database through the project’s
data-layer helper, or explicitly declare that db is an existing application
database. Ensure the authorize callback’s db reference resolves in a copied
example without changing its authentication logic.

},
},
{
id: "github",
name: "GitHub",
type: "oauth",
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
],
});
```

Wire it up with one catch-all API route:

```ts
// 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 plaintext:

```ts
import { hashPassword } from "@thexjs/auth";

await hashPassword("correct horse battery staple");
```

## Endpoints

`handleRequest` routes the path below `api/auth`:

| Route | Method | Purpose |
|---|---|---|
| `/api/auth/signin/<id>` | POST | credentials provider: `application/x-www-form-urlencoded` 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/local`; after success the browser follows the `302` to `successRedirect` (default `/`). For OAuth, the button/link is just a GET to `/api/auth/signin/github`.

## Reading the session

```ts
// 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/revoked sessions. `setSessionCookie(res, user, provider)` and `clearSessionCookie(res, req?)` are also exported for programmatic flows.

## Security

- **Passwords** — Argon2id via `Bun.password` (`hashPassword` / `verifyPassword`).
- **Session tokens** — opaque random strings; only an HMAC-SHA256 digest (`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** — a `x_oauth_state` cookie challenge must match the `state` param on the callback (HMAC'd, 5-minute expiry), preventing login-CSRF / session-fixation via crafted callbacks.
- **CSRF** — POST endpoints (`signin`, `signout`) run the core `checkCsrf` (Origin/Referer verification by default; pass `requireToken` for double-submit defense in depth) and reject non-conforming requests with `403`.
- **Cookies** — `HttpOnly`, `SameSite=Lax`, `Secure` in production.

Set a **stable `secret`** in production. If omitted, a random per-process secret is generated and a warning 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`) if you want to bring your own.

```ts
createSQLiteSessionStore({ path: "data/auth.db" }); // default: data/auth.db
createPostgresSessionStore(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.

## License

MIT
Loading
Loading