diff --git a/.changeset/tidy-wolves-wave.md b/.changeset/tidy-wolves-wave.md new file mode 100644 index 0000000..5515f35 --- /dev/null +++ b/.changeset/tidy-wolves-wave.md @@ -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`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5901511..2f5f3b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 + audit: name: Dependency audit runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 10fee8f..ef4545a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index fbc028d..84e1585 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index 6b2efe4..cca79ea 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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, diff --git a/SECURITY.md b/SECURITY.md index d545974..1f82f3b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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 diff --git a/bun.lock b/bun.lock index c2496c1..89cbbe3 100644 --- a/bun.lock +++ b/bun.lock @@ -132,6 +132,17 @@ "tsup": "^8.3.0", }, }, + "packages/auth": { + "name": "@thexjs/auth", + "version": "1.0.0", + "devDependencies": { + "@thexjs/core": "workspace:*", + "tsup": "^8.3.0", + }, + "peerDependencies": { + "@thexjs/core": "workspace:*", + }, + }, "packages/cli": { "name": "@thexjs/cli", "version": "1.0.0", @@ -518,6 +529,8 @@ "@thexjs/adapter-vercel": ["@thexjs/adapter-vercel@workspace:packages/adapter-vercel"], + "@thexjs/auth": ["@thexjs/auth@workspace:packages/auth"], + "@thexjs/cli": ["@thexjs/cli@workspace:packages/cli"], "@thexjs/core": ["@thexjs/core@workspace:packages/core"], diff --git a/package.json b/package.json index 2579c8b..c658f5c 100644 --- a/package.json +++ b/package.json @@ -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.'" diff --git a/packages/auth/LICENSE b/packages/auth/LICENSE new file mode 100644 index 0000000..55781e8 --- /dev/null +++ b/packages/auth/LICENSE @@ -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. diff --git a/packages/auth/README.md b/packages/auth/README.md new file mode 100644 index 0000000..11b9a67 --- /dev/null +++ b/packages/auth/README.md @@ -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 }; + }, + }, + { + 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/` | POST | credentials provider: `application/x-www-form-urlencoded` or `multipart` body with the provider's fields (e.g. `email`, `password`) | +| `/api/auth/signin/` | GET | OAuth2 provider: redirects the browser to the provider's authorization URL | +| `/api/auth/callback/` | 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 diff --git a/packages/auth/package.json b/packages/auth/package.json new file mode 100644 index 0000000..5921921 --- /dev/null +++ b/packages/auth/package.json @@ -0,0 +1,45 @@ +{ + "name": "@thexjs/auth", + "version": "1.0.0", + "description": "Plug-and-play authentication for x framework apps: credentials (Argon2 via Bun.password), OAuth2 and GitHub providers, session stores on the x data layer, and a catch-all API handler wired to the core CSRF module.", + "keywords": [ + "auth", + "authentication", + "oauth2", + "github", + "sessions", + "credentials", + "argon2", + "bun", + "react", + "framework", + "x" + ], + "license": "MIT", + "type": "module", + "sideEffects": false, + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": ["dist", "README.md", "LICENSE"], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsup src/index.ts --format esm --dts --clean --target node18", + "prepublishOnly": "bun run build" + }, + "peerDependencies": { + "@thexjs/core": "workspace:*" + }, + "devDependencies": { + "@thexjs/core": "workspace:*", + "tsup": "^8.3.0" + } +} diff --git a/packages/auth/src/auth.test.ts b/packages/auth/src/auth.test.ts new file mode 100644 index 0000000..f682be7 --- /dev/null +++ b/packages/auth/src/auth.test.ts @@ -0,0 +1,369 @@ +import { Database } from "bun:sqlite"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { checkCsrf, generateCsrfToken, withCsrfCookie } from "@thexjs/core"; +import { SESSION_COOKIE, defineAuth } from "./auth"; +import { hashPassword, verifyPassword } from "./password"; +import { createSQLiteSessionStore } from "./session"; +import type { AuthUser } from "./types"; + +const BASE_URL = "http://localhost:3000"; + +function cookieHeader(req: Request): string { + return req.headers.get("cookie") ?? ""; +} + +function extractCookie(res: Response, name: string): string | null { + const cookies = res.headers.getSetCookie(); + for (const c of cookies) { + const first = c.split(";")[0] ?? ""; + const eq = first.indexOf("="); + if (eq !== -1 && first.slice(0, eq) === name) return first.slice(eq + 1); + } + return null; +} + +function authedRequest(path: string, sessionCookie: string): Request { + return new Request(`${BASE_URL}${path}`, { + headers: { origin: BASE_URL, cookie: sessionCookie }, + }); +} + +describe("credentials provider", () => { + let db: Database; + let passwordHash: string; + let auth: ReturnType; + + const users = new Map([ + ["admin@example.com", { id: "u_1", name: "Admin", email: "admin@example.com" }], + ]); + + beforeAll(async () => { + passwordHash = await hashPassword("correct horse battery staple"); + db = new Database(":memory:"); + auth = defineAuth({ + secret: "test-secret", + store: createSQLiteSessionStore({ db }), + providers: [ + { + id: "local", + name: "Local", + type: "credentials", + async authorize(params) { + const email = params.email ?? ""; + const user = users.get(email); + if (!user) return null; + if (!(await verifyPassword(params.password ?? "", passwordHash))) return null; + return user; + }, + }, + ], + }); + }); + + afterAll(() => db.close()); + + test("rejects a sign-in without a valid Origin header (CSRF)", async () => { + const form = new FormData(); + form.set("email", "admin@example.com"); + form.set("password", "correct horse battery staple"); + const res = await auth.handleRequest( + new Request(`${BASE_URL}/api/auth/signin/local`, { method: "POST", body: form }), + ); + expect(res.status).toBe(403); + }); + + test("rejects a cross-origin POST (CSRF)", async () => { + const form = new FormData(); + form.set("email", "admin@example.com"); + form.set("password", "correct horse battery staple"); + const res = await auth.handleRequest( + new Request(`${BASE_URL}/api/auth/signin/local`, { + method: "POST", + headers: { origin: "https://evil.example.com" }, + body: form, + }), + ); + expect(res.status).toBe(403); + }); + + test("signs in with correct credentials and sets an HttpOnly session cookie", async () => { + const form = new FormData(); + form.set("email", "admin@example.com"); + form.set("password", "correct horse battery staple"); + const res = await auth.handleRequest( + new Request(`${BASE_URL}/api/auth/signin/local`, { + method: "POST", + headers: { origin: BASE_URL }, + body: form, + }), + ); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe("/"); + const cookie = extractCookie(res, SESSION_COOKIE); + expect(cookie).not.toBeNull(); + expect((res.headers.getSetCookie()[0] ?? "").includes("HttpOnly")).toBe(true); + }); + + test("rejects wrong password with 401", async () => { + const form = new FormData(); + form.set("email", "admin@example.com"); + form.set("password", "wrong-password"); + const res = await auth.handleRequest( + new Request(`${BASE_URL}/api/auth/signin/local`, { + method: "POST", + headers: { origin: BASE_URL }, + body: form, + }), + ); + expect(res.status).toBe(401); + }); + + test("rejects an unknown account with 401", async () => { + const form = new FormData(); + form.set("email", "nobody@example.com"); + form.set("password", "correct horse battery staple"); + const res = await auth.handleRequest( + new Request(`${BASE_URL}/api/auth/signin/local`, { + method: "POST", + headers: { origin: BASE_URL }, + body: form, + }), + ); + expect(res.status).toBe(401); + }); + + test("`/api/auth/session` returns the user when authenticated", async () => { + const form = new FormData(); + form.set("email", "admin@example.com"); + form.set("password", "correct horse battery staple"); + const signIn = await auth.handleRequest( + new Request(`${BASE_URL}/api/auth/signin/local`, { + method: "POST", + headers: { origin: BASE_URL }, + body: form, + }), + ); + const cookie = extractCookie(signIn, SESSION_COOKIE) as string; + + const sessionRes = await auth.handleRequest( + authedRequest("/api/auth/session", `x_session=${cookie}`), + ); + expect(sessionRes.status).toBe(200); + const body = (await sessionRes.json()) as { user: AuthUser }; + expect(body.user.email).toBe("admin@example.com"); + }); + + test("`/api/auth/session` returns 401 when unauthenticated", async () => { + const sessionRes = await auth.handleRequest(new Request(`${BASE_URL}/api/auth/session`)); + expect(sessionRes.status).toBe(401); + }); + + test("sign-out revokes the session and clears the cookie", async () => { + const form = new FormData(); + form.set("email", "admin@example.com"); + form.set("password", "correct horse battery staple"); + const signIn = await auth.handleRequest( + new Request(`${BASE_URL}/api/auth/signin/local`, { + method: "POST", + headers: { origin: BASE_URL }, + body: form, + }), + ); + const cookie = extractCookie(signIn, SESSION_COOKIE) as string; + + const signOut = await auth.handleRequest( + new Request(`${BASE_URL}/api/auth/signout`, { + method: "POST", + headers: { origin: BASE_URL, cookie: `x_session=${cookie}` }, + }), + ); + expect(signOut.status).toBe(302); + expect(extractCookie(signOut, SESSION_COOKIE)).toBe(""); + + const sessionRes = await auth.handleRequest( + authedRequest("/api/auth/session", `x_session=${cookie}`), + ); + expect(sessionRes.status).toBe(401); + }); +}); + +describe("session lifecycle", () => { + let db: Database; + let auth: ReturnType; + + beforeAll(() => { + db = new Database(":memory:"); + auth = defineAuth({ + secret: "test-secret", + sessionMaxAge: -1, // sessions expire immediately + store: createSQLiteSessionStore({ db }), + providers: [ + { + id: "local", + name: "Local", + type: "credentials", + async authorize(params) { + return params.email ? { id: "u_1", email: params.email } : null; + }, + }, + ], + }); + }); + + afterAll(() => db.close()); + + test("expired sessions are not returned and are revoked", async () => { + const res = await auth.setSessionCookie(new Response(null), { id: "u_1" }, "local"); + const cookie = extractCookie(res, SESSION_COOKIE) as string; + const req = authedRequest("/", `x_session=${cookie}`); + + const session = await auth.getSession(req); + expect(session).toBeNull(); + + // The store should no longer contain the token. + const after = await auth.getSession(req); + expect(after).toBeNull(); + }); + + test("active sessions are returned with their user snapshot", async () => { + const store = createSQLiteSessionStore({ db }); + const live = defineAuth({ + secret: "test-secret", + sessionMaxAge: 60, + store, + providers: [ + { + id: "local", + name: "Local", + type: "credentials", + async authorize() { + return null; + }, + }, + ], + }); + const res = await live.setSessionCookie( + new Response(null), + { id: "u_9", email: "u@x.dev" }, + "local", + ); + const cookie = extractCookie(res, SESSION_COOKIE) as string; + + const session = await live.getSession(authedRequest("/", `x_session=${cookie}`)); + expect(session?.userId).toBe("u_9"); + expect(session?.user.email).toBe("u@x.dev"); + }); +}); + +describe("OAuth2 (GitHub) provider", () => { + let db: Database; + let auth: ReturnType; + const realFetch = globalThis.fetch; + + beforeAll(() => { + db = new Database(":memory:"); + auth = defineAuth({ + secret: "test-secret", + store: createSQLiteSessionStore({ db }), + providers: [ + { + id: "github", + name: "GitHub", + type: "oauth", + clientId: "client-123", + clientSecret: "secret-123", + }, + ], + }); + + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.includes("login/oauth/access_token")) { + return Promise.resolve( + new Response(JSON.stringify({ access_token: "tok-123", token_type: "bearer" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + } + if (url.includes("api.github.com/user")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 42, + login: "octocat", + name: "Octo Cat", + email: "octo@example.com", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + } + return Promise.resolve(new Response("not found", { status: 404 })); + }) as typeof fetch; + }); + + afterAll(() => { + globalThis.fetch = realFetch; + db.close(); + }); + + test("sign-in redirects to the provider authorization URL with a state challenge", async () => { + const res = await auth.handleRequest(new Request(`${BASE_URL}/api/auth/signin/github`)); + expect(res.status).toBe(302); + const location = res.headers.get("location") as string; + expect(location.startsWith("https://github.com/login/oauth/authorize")).toBe(true); + expect(new URL(location).searchParams.get("client_id")).toBe("client-123"); + expect(new URL(location).searchParams.get("state")).not.toBeNull(); + expect(extractCookie(res, "x_oauth_state")).not.toBeNull(); + }); + + test("callback with a valid code and state establishes a session", async () => { + const signIn = await auth.handleRequest(new Request(`${BASE_URL}/api/auth/signin/github`)); + const state = new URL(signIn.headers.get("location") as string).searchParams.get("state"); + const stateCookie = extractCookie(signIn, "x_oauth_state") as string; + + const callback = await auth.handleRequest( + new Request(`${BASE_URL}/api/auth/callback/github?code=code-1&state=${state}`, { + headers: { cookie: `x_oauth_state=${stateCookie}` }, + }), + ); + expect(callback.status).toBe(302); + const sessionCookie = extractCookie(callback, SESSION_COOKIE); + expect(sessionCookie).not.toBeNull(); + + const session = await auth.getSession( + authedRequest("/", `x_session=${sessionCookie as string}`), + ); + expect(session?.provider).toBe("github"); + expect(session?.user.id).toBe("42"); + expect(session?.user.email).toBe("octo@example.com"); + }); + + test("callback with a mismatched state is rejected", async () => { + const res = await auth.handleRequest( + new Request(`${BASE_URL}/api/auth/callback/github?code=code-1&state=tampered-state`, { + headers: { cookie: "x_oauth_state=some-other-token" }, + }), + ); + expect(res.status).toBe(400); + }); +}); + +describe("CSRF integration", () => { + test("core double-submit helpers are compatible with the handler", async () => { + const token = generateCsrfToken(); + const req = new Request(`${BASE_URL}/api/auth/signin/local`, { + method: "POST", + headers: { + origin: BASE_URL, + cookie: `x_csrf_token=${token}`, + "x-csrf-token": token, + }, + }); + const res = withCsrfCookie(req, new Response(null)); + expect(checkCsrf(req).ok).toBe(true); + // Request already had the cookie, so no new one is issued. + expect(res.headers.getSetCookie()).toHaveLength(0); + }); +}); diff --git a/packages/auth/src/auth.ts b/packages/auth/src/auth.ts new file mode 100644 index 0000000..ccea884 --- /dev/null +++ b/packages/auth/src/auth.ts @@ -0,0 +1,308 @@ +import { createHmac } from "node:crypto"; +import { checkCsrf } from "@thexjs/core"; +import { readCookie } from "./cookies"; +import { + type CredentialsProvider, + type OAuth2ProviderConfig, + type Provider, + type ResolvedProvider, + buildAuthorizationUrl, + exchangeCode, + fetchUserInfo, + toOAuth2, +} from "./providers"; +import type { SessionStore } from "./session"; +import type { AuthUser, Session } from "./types"; + +export const SESSION_COOKIE = "x_session"; +export const OAUTH_STATE_COOKIE = "x_oauth_state"; + +const OAUTH_STATE_MAX_AGE = 300; // seconds + +export interface AuthConfig { + /** Providers registered on the auth handler (at least one). */ + providers: Provider[]; + /** + * Session store. Pass `createSQLiteSessionStore()` / `createPostgresSessionStore()`. + */ + store: SessionStore; + /** + * A secret used to HMAC session tokens at rest and OAuth state challenges. + * In production this must be a stable value; if omitted, a random dev + * secret is generated and a warning is printed. + */ + secret?: string; + /** Session lifetime in seconds. Default: 7 days. */ + sessionMaxAge?: number; + /** Where to redirect the browser after a successful sign-in. Default: `/`. */ + successRedirect?: string; + /** Where to redirect the browser after sign-out. Default: `/`. */ + signOutRedirect?: string; +} + +export interface ResolvedAuthConfig { + providers: Provider[]; + store: SessionStore; + secret: string; + sessionMaxAge: number; + successRedirect: string; + signOutRedirect: string; +} + +/** Additional runtime context for `handleRequest`. */ +export interface HandleRequestOptions { + /** The app's base URL, used to build OAuth redirect URIs. Default: `http://localhost:${port}`. */ + baseUrl?: string; +} + +export interface Auth { + config: ResolvedAuthConfig; + providers: Map; + /** + * Entry point for a catch-all route such as + * `src/api/auth/[...auth].ts`. Routes the OAuth/credentials actions: + * + * - GET/POST `/api/auth/signin/` — start a provider sign-in + * - GET `/api/auth/callback/` — OAuth callback + * - POST `/api/auth/signout` — revoke the session (CSRF-protected) + * - GET `/api/auth/session` — JSON `{ user }` or `401` + */ + handleRequest(req: Request, options?: HandleRequestOptions): Promise; + /** Reads the current session from a `Request`. Server-side helper. */ + getSession(req: Request): Promise; + /** Creates a session for `user` and attaches the session cookie to `res`. */ + setSessionCookie(res: Response, user: AuthUser, provider: string): Promise; + /** Clears the session cookie from `res` and revokes the session, if any. */ + clearSessionCookie(res: Response, req?: Request): Promise; +} + +/** Resolves `config` against defaults and returns the auth helper. */ +export function defineAuth(config: AuthConfig): Auth { + let secret = config.secret; + if (!secret) { + secret = Math.random().toString(36).slice(2) + Date.now().toString(36); + console.warn( + "[@thexjs/auth] No `secret` configured — generated an ephemeral one. " + + "Set a stable `secret` in production so sessions survive restarts.", + ); + } + const resolved: ResolvedAuthConfig = { + providers: config.providers, + store: config.store, + secret, + sessionMaxAge: config.sessionMaxAge ?? 60 * 60 * 24 * 7, + successRedirect: config.successRedirect ?? "/", + signOutRedirect: config.signOutRedirect ?? "/", + }; + + const providers = new Map(); + for (const p of resolved.providers) { + const normalized: ResolvedProvider = p.type === "oauth" ? toOAuth2(p) : p; + if (providers.has(normalized.id)) { + throw new Error(`[@thexjs/auth] Duplicate provider id "${normalized.id}"`); + } + providers.set(normalized.id, normalized); + } + if (providers.size === 0) { + throw new Error("[@thexjs/auth] At least one provider must be configured"); + } + + const hash = (value: string): Promise => { + return Promise.resolve(createHmac("sha256", resolved.secret).update(value).digest("hex")); + }; + + const createSessionToken = (): string => { + return crypto.randomUUID().replace(/-/g, "") + Math.random().toString(36).slice(2); + }; + + const isSecure = (): boolean => process.env.NODE_ENV === "production"; + + const cookieAttrs = (maxAge: number): string => + `HttpOnly; SameSite=Lax; Path=/; Max-Age=${maxAge}${isSecure() ? "; Secure" : ""}`; + + const sessionCookieHeader = (token: string): string => + `${SESSION_COOKIE}=${token}; ${cookieAttrs(resolved.sessionMaxAge)}`; + + const clearSessionCookieHeader = (): string => `${SESSION_COOKIE}=; ${cookieAttrs(0)}`; + + const withSetCookie = (res: Response, value: string): Response => { + res.headers.append("Set-Cookie", value); + return res; + }; + + const snapshotUser = (user: AuthUser): AuthUser => { + const snapshot: AuthUser = { id: user.id }; + if (typeof user.name === "string") snapshot.name = user.name; + if (typeof user.email === "string") snapshot.email = user.email; + return snapshot; + }; + + const createSession = async (user: AuthUser, provider: string): Promise => { + const token = createSessionToken(); + const now = Date.now(); + const session: Session = { + token: await hash(token), + userId: user.id, + provider, + user: snapshotUser(user), + expiresAt: now + resolved.sessionMaxAge * 1000, + createdAt: now, + }; + await resolved.store.create(session); + return token; + }; + + const establishSession = async ( + user: AuthUser, + provider: string, + extra?: HandleRequestOptions, + ): Promise => { + const token = await createSession(user, provider); + const location = extra?.baseUrl + ? new URL(resolved.successRedirect, extra.baseUrl).toString() + : resolved.successRedirect; + const res = new Response(null, { status: 302, headers: { Location: location } }); + return withSetCookie(res, sessionCookieHeader(token)); + }; + + const notFound = (): Response => new Response("Not found", { status: 404 }); + const methodNotAllowed = (): Response => new Response("Method not allowed", { status: 405 }); + + const handleOAuthSignIn = async ( + provider: OAuth2ProviderConfig, + baseUrl: string, + ): Promise => { + const stateToken = createSessionToken(); + const state = await hash(stateToken); + const res = new Response(null, { + status: 302, + headers: { Location: buildAuthorizationUrl(provider, baseUrl, state) }, + }); + return withSetCookie( + res, + `${OAUTH_STATE_COOKIE}=${stateToken}; ${cookieAttrs(OAUTH_STATE_MAX_AGE)}`, + ); + }; + + const handleOAuthCallback = async ( + provider: OAuth2ProviderConfig, + req: Request, + baseUrl: string, + ): Promise => { + const url = new URL(req.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + if (!code || !state) return new Response("Missing code or state", { status: 400 }); + + const stateToken = readCookie(req, OAUTH_STATE_COOKIE); + if (!stateToken || (await hash(stateToken)) !== state) { + return new Response("Invalid state", { status: 400 }); + } + + const tokens = await exchangeCode(provider, baseUrl, code); + const userInfo = await fetchUserInfo(provider, tokens.access_token); + const user = provider.profile(userInfo); + if (!user?.id) return new Response("Provider returned no user", { status: 401 }); + return establishSession(user, provider.id, { baseUrl }); + }; + + const handleCredentialsSignIn = async ( + provider: CredentialsProvider, + req: Request, + ): Promise => { + if (req.method !== "POST") return methodNotAllowed(); + const csrf = checkCsrf(req); + if (!csrf.ok) return new Response(`CSRF check failed: ${csrf.reason}`, { status: 403 }); + const form = await req.formData(); + const params: Record = {}; + for (const [key, value] of form.entries()) params[key] = String(value); + + const user = await provider.authorize(params, { request: req }); + if (!user?.id) return new Response("Invalid credentials", { status: 401 }); + return establishSession(user, provider.id); + }; + + const handleSession = async (req: Request): Promise => { + const session = await getSession(req); + const body = JSON.stringify({ user: session ? session.user : null }); + return new Response(body, { + status: session ? 200 : 401, + headers: { "Content-Type": "application/json" }, + }); + }; + + const handleSignOut = async (req: Request): Promise => { + if (req.method !== "POST") return methodNotAllowed(); + const csrf = checkCsrf(req); + if (!csrf.ok) return new Response(`CSRF check failed: ${csrf.reason}`, { status: 403 }); + const token = readCookie(req, SESSION_COOKIE); + if (token) await resolved.store.revoke(await hash(token)); + const res = new Response(null, { + status: 302, + headers: { Location: resolved.signOutRedirect }, + }); + return withSetCookie(res, clearSessionCookieHeader()); + }; + + const handleRequest = async ( + req: Request, + options: HandleRequestOptions = {}, + ): Promise => { + const url = new URL(req.url); + const baseUrl = options.baseUrl ?? `http://localhost:${process.env.PORT ?? "3000"}`; + const relative = url.pathname.replace(/^\/api\/auth\/?/, ""); + const [action, providerId, sub] = relative.split("/"); + const provider = providerId ? providers.get(providerId) : undefined; + + if (action === "signin" && provider) { + if (provider.type === "credentials") { + return handleCredentialsSignIn(provider, req); + } + return handleOAuthSignIn(provider, baseUrl); + } + if (action === "callback" && provider?.type === "oauth") { + if (req.method !== "GET") return methodNotAllowed(); + return handleOAuthCallback(provider, req, baseUrl); + } + if (action === "signout" && !sub) return handleSignOut(req); + if (action === "session" && !sub) return handleSession(req); + return notFound(); + }; + + const getSession = async (req: Request): Promise => { + const token = readCookie(req, SESSION_COOKIE); + if (!token) return null; + const hashed = await hash(token); + const session = await resolved.store.find(hashed); + if (!session) return null; + if (session.expiresAt <= Date.now()) { + await resolved.store.revoke(hashed); + return null; + } + return session; + }; + + const setSessionCookie = async ( + res: Response, + user: AuthUser, + provider: string, + ): Promise => { + const token = await createSession(user, provider); + return withSetCookie(res, sessionCookieHeader(token)); + }; + + const clearSessionCookie = async (res: Response, req?: Request): Promise => { + const token = req ? readCookie(req, SESSION_COOKIE) : null; + if (token) await resolved.store.revoke(await hash(token)); + return withSetCookie(res, clearSessionCookieHeader()); + }; + + return { + config: resolved, + providers, + handleRequest, + getSession, + setSessionCookie, + clearSessionCookie, + }; +} diff --git a/packages/auth/src/cookies.ts b/packages/auth/src/cookies.ts new file mode 100644 index 0000000..3768ea3 --- /dev/null +++ b/packages/auth/src/cookies.ts @@ -0,0 +1,29 @@ +/** Reads a single cookie value from a Request's `Cookie` header. */ +export function readCookie(req: Request, name: string): string | null { + const header = req.headers.get("cookie"); + if (!header) return null; + for (const part of header.split(";")) { + const trimmed = part.trim(); + const eq = trimmed.indexOf("="); + if (eq === -1) continue; + if (trimmed.slice(0, eq) === name) return trimmed.slice(eq + 1); + } + return null; +} + +/** Collects every Set-Cookie header from a Response (there may be more than one). */ +export function responseCookies(res: Response): string[] { + return typeof res.headers.getSetCookie === "function" + ? res.headers.getSetCookie() + : res.headers.get("set-cookie") + ? [res.headers.get("set-cookie") as string] + : []; +} + +/** Extracts the value of `name` from a Set-Cookie header string. */ +export function cookieValue(setCookie: string, name: string): string | null { + const first = setCookie.split(";")[0] ?? ""; + const eq = first.indexOf("="); + if (eq === -1 || first.slice(0, eq) !== name) return null; + return first.slice(eq + 1); +} diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts new file mode 100644 index 0000000..04fc781 --- /dev/null +++ b/packages/auth/src/index.ts @@ -0,0 +1,23 @@ +export { defineAuth, SESSION_COOKIE, OAUTH_STATE_COOKIE } from "./auth"; +export type { + Auth, + AuthConfig, + ResolvedAuthConfig, + HandleRequestOptions, +} from "./auth"; +export { + createSQLiteSessionStore, + createPostgresSessionStore, +} from "./session"; +export type { SessionStore, SQLiteSessionStoreOptions } from "./session"; +export { hashPassword, verifyPassword } from "./password"; +export { toOAuth2, buildAuthorizationUrl, exchangeCode, fetchUserInfo } from "./providers"; +export type { + Provider, + ResolvedProvider, + CredentialsProvider, + OAuth2ProviderConfig, + GitHubProviderConfig, + OAuthTokens, +} from "./providers"; +export type { AuthUser, Session } from "./types"; diff --git a/packages/auth/src/password.ts b/packages/auth/src/password.ts new file mode 100644 index 0000000..10056c5 --- /dev/null +++ b/packages/auth/src/password.ts @@ -0,0 +1,12 @@ +/** + * Password hashing helpers backed by `Bun.password` (Argon2id by default). + * Use these inside a credentials provider's `authorize` to compare a + * submitted password against a stored hash — never store plaintext. + */ +export function hashPassword(password: string): Promise { + return Bun.password.hash(password, { algorithm: "argon2id" }); +} + +export function verifyPassword(password: string, hash: string): Promise { + return Bun.password.verify(password, hash); +} diff --git a/packages/auth/src/providers.ts b/packages/auth/src/providers.ts new file mode 100644 index 0000000..465f62e --- /dev/null +++ b/packages/auth/src/providers.ts @@ -0,0 +1,167 @@ +import type { AuthUser } from "./types"; + +/** A generic provider union: either a credentials provider or an OAuth2 provider. */ +export type Provider = CredentialsProvider | OAuth2ProviderConfig | GitHubProviderConfig; + +/** A normalized provider: GitHub configs have been resolved to `OAuth2ProviderConfig`. */ +export type ResolvedProvider = CredentialsProvider | OAuth2ProviderConfig; + +/** + * A username/password provider. Wire `authorize` to your user table, compare + * the submitted password against the stored Argon2 hash with `verifyPassword` + * (from this package), and return the matching user — or `null` to reject. + */ +export interface CredentialsProvider { + id: string; + name: string; + type: "credentials"; + /** + * Resolve a submitted credential form into a user. Return `null` (or throw) + * to reject the sign-in. + */ + authorize(params: Record, ctx: { request: Request }): Promise; +} + +/** A generic OAuth2 (authorization code) provider. */ +export interface OAuth2ProviderConfig { + id: string; + name: string; + type: "oauth"; + clientId: string; + clientSecret: string; + authorizationUrl: string; + tokenUrl: string; + userInfoUrl?: string; + /** Extra query params appended to the authorization request. Default: `{ scope: "openid profile email" }`. */ + authorizationParams?: Record; + /** Extra body params for the token exchange. */ + tokenParams?: Record; + /** Build a normalized `AuthUser` from the userinfo endpoint response. */ + profile(profile: Record): AuthUser; +} + +/** A preconfigured GitHub OAuth2 provider (defaults applied via `toOAuth2`). */ +export interface GitHubProviderConfig { + id?: string; + name?: string; + type: "oauth"; + clientId: string; + clientSecret: string; + scope?: string; + /** Build a normalized `AuthUser` from GitHub's `/user` response. */ + profile?: (profile: Record) => AuthUser; +} + +const GITHUB_AUTHORIZATION_URL = "https://github.com/login/oauth/authorize"; +const GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token"; +const GITHUB_USERINFO_URL = "https://api.github.com/user"; + +/** Normalizes a GitHub provider config into a generic `OAuth2ProviderConfig`. */ +export function toOAuth2( + provider: GitHubProviderConfig | OAuth2ProviderConfig, +): OAuth2ProviderConfig { + if (provider.type !== "oauth") { + throw new Error(`Provider "${provider.id}" is not an OAuth provider`); + } + if ("authorizationUrl" in provider && "tokenUrl" in provider && "profile" in provider) { + return provider as OAuth2ProviderConfig; + } + const github = provider as GitHubProviderConfig; + const scope = github.scope ?? "read:user user:email"; + return { + id: github.id ?? "github", + name: github.name ?? "GitHub", + type: "oauth", + clientId: github.clientId, + clientSecret: github.clientSecret, + authorizationUrl: GITHUB_AUTHORIZATION_URL, + tokenUrl: GITHUB_TOKEN_URL, + userInfoUrl: GITHUB_USERINFO_URL, + authorizationParams: { scope }, + tokenParams: { accept: "json" }, + profile: + github.profile ?? + ((p) => { + const user: AuthUser = { id: String(p.id ?? "") }; + const name = p.name as string | undefined; + const email = p.email as string | undefined; + if (name) user.name = name; + if (email) user.email = email; + return user; + }), + }; +} + +export interface OAuthTokens { + access_token: string; + token_type?: string; + scope?: string; + refresh_token?: string; +} + +/** Builds the authorization URL for a sign-in redirect. */ +export function buildAuthorizationUrl( + provider: OAuth2ProviderConfig, + baseUrl: string, + state: string, +): string { + const url = new URL(provider.authorizationUrl); + const params = provider.authorizationParams ?? { scope: "openid profile email" }; + url.searchParams.set("client_id", provider.clientId); + url.searchParams.set("redirect_uri", `${baseUrl}/api/auth/callback/${provider.id}`); + url.searchParams.set("response_type", "code"); + url.searchParams.set("state", state); + for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value); + return url.toString(); +} + +/** Exchanges an authorization code for access tokens. */ +export async function exchangeCode( + provider: OAuth2ProviderConfig, + baseUrl: string, + code: string, +): Promise { + const body = new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: `${baseUrl}/api/auth/callback/${provider.id}`, + client_id: provider.clientId, + client_secret: provider.clientSecret, + ...provider.tokenParams, + }); + const res = await fetch(provider.tokenUrl, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + if (!res.ok) { + throw new Error(`Token exchange failed with status ${res.status}`); + } + const text = await res.text(); + try { + return JSON.parse(text) as OAuthTokens; + } catch { + // Some providers (e.g. GitHub without the `accept: json` param) answer + // with a query-string-encoded body instead of JSON. + const parsed = Object.fromEntries(new URLSearchParams(text)); + if (!parsed.access_token) throw new Error("Token exchange returned no access_token"); + return parsed as unknown as OAuthTokens; + } +} + +/** Fetches the user profile using the access token. */ +export async function fetchUserInfo( + provider: OAuth2ProviderConfig, + accessToken: string, +): Promise> { + if (!provider.userInfoUrl) { + throw new Error(`Provider "${provider.id}" has no userInfoUrl configured`); + } + const res = await fetch(provider.userInfoUrl, { + headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" }, + }); + if (!res.ok) { + throw new Error(`Userinfo request failed with status ${res.status}`); + } + return (await res.json()) as Record; +} diff --git a/packages/auth/src/session.ts b/packages/auth/src/session.ts new file mode 100644 index 0000000..7f70f98 --- /dev/null +++ b/packages/auth/src/session.ts @@ -0,0 +1,142 @@ +import type { Database } from "bun:sqlite"; +import { connectSQLite } from "@thexjs/core/data"; +import type { AuthUser, Session } from "./types"; + +/** Structural subset of the Postgres client returned by `connectPostgres`. */ +interface PostgresClient { + unsafe(query: string, params?: unknown[]): Promise; + (strings: TemplateStringsArray, ...values: unknown[]): Promise; +} + +/** + * A durable session store. Both bundled stores sit on the x data layer + * (`connectSQLite` / a `connectPostgres` client) and store sessions in a + * single `x_sessions` table keyed by an opaque token. + */ +export interface SessionStore { + create(session: Session): Promise; + find(token: string): Promise; + revoke(token: string): Promise; +} + +export interface SQLiteSessionStoreOptions { + /** Path for the SQLite file when no `db` is provided. Default: `data/auth.db`. */ + path?: string; + /** A pre-opened `bun:sqlite` Database (e.g. `:memory:` in tests). */ + db?: Database; +} + +const CREATE_SQLITE_TABLE = `CREATE TABLE IF NOT EXISTS x_sessions ( + token TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + provider TEXT NOT NULL, + user_data TEXT NOT NULL, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL +)`; + +const CREATE_POSTGRES_TABLE = `CREATE TABLE IF NOT EXISTS x_sessions ( + token TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + provider TEXT NOT NULL, + user_data TEXT NOT NULL, + expires_at BIGINT NOT NULL, + created_at BIGINT NOT NULL +)`; + +interface SessionRow { + token: string; + user_id: string; + provider: string; + user_data: string; + expires_at: number; + created_at: number; +} + +function rowToSession(row: SessionRow): Session { + let user: AuthUser; + try { + user = JSON.parse(row.user_data) as AuthUser; + } catch { + user = { id: row.user_id }; + } + return { + token: row.token, + userId: row.user_id, + provider: row.provider, + expiresAt: row.expires_at, + createdAt: row.created_at, + user, + }; +} + +/** A session store backed by `bun:sqlite` (via `@thexjs/core/data`'s `connectSQLite`). */ +export function createSQLiteSessionStore(options: SQLiteSessionStoreOptions = {}): SessionStore { + const db = options.db ?? connectSQLite({ path: options.path ?? "data/auth.db" }); + db.run(CREATE_SQLITE_TABLE); + + return { + async create(session) { + db.run( + "INSERT OR REPLACE INTO x_sessions (token, user_id, provider, user_data, expires_at, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + [ + session.token, + session.userId, + session.provider, + JSON.stringify(session.user), + session.expiresAt, + session.createdAt, + ], + ); + }, + async find(token) { + const row = db.query("SELECT * FROM x_sessions WHERE token = ?1").get(token) as + | SessionRow + | undefined; + return row ? rowToSession(row) : null; + }, + async revoke(token) { + db.run("DELETE FROM x_sessions WHERE token = ?1", [token]); + }, + }; +} + +/** A session store backed by Postgres through a `connectPostgres` client. */ +export function createPostgresSessionStore(client: PostgresClient): SessionStore { + // Bun.SQL connects lazily and DDL is idempotent, so ensure the table on + // first use instead of at construction time. + let ready: Promise | null = null; + const ensure = () => { + ready ??= client.unsafe(CREATE_POSTGRES_TABLE); + return ready; + }; + + return { + async create(session) { + await ensure(); + await client.unsafe( + "INSERT INTO x_sessions (token, user_id, provider, user_data, expires_at, created_at) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (token) DO UPDATE SET user_id = $2, provider = $3, user_data = $4, expires_at = $5", + [ + session.token, + session.userId, + session.provider, + JSON.stringify(session.user), + session.expiresAt, + session.createdAt, + ], + ); + }, + async find(token) { + await ensure(); + const rows = (await client.unsafe("SELECT * FROM x_sessions WHERE token = $1", [ + token, + ])) as SessionRow[]; + const row = rows[0]; + return row ? rowToSession(row) : null; + }, + async revoke(token) { + await ensure(); + await client.unsafe("DELETE FROM x_sessions WHERE token = $1", [token]); + }, + }; +} diff --git a/packages/auth/src/types.ts b/packages/auth/src/types.ts new file mode 100644 index 0000000..94e0cdf --- /dev/null +++ b/packages/auth/src/types.ts @@ -0,0 +1,18 @@ +/** A normalized application user, produced by a provider's `authorize`/`profile`. */ +export interface AuthUser { + id: string; + email?: string; + name?: string; + [key: string]: unknown; +} + +/** A persisted session record. */ +export interface Session { + token: string; + userId: string; + provider: string; + expiresAt: number; + createdAt: number; + /** Snapshot of the user at sign-in time, so loaders/middleware don't need a second lookup. */ + user: AuthUser; +} diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json new file mode 100644 index 0000000..5285d28 --- /dev/null +++ b/packages/auth/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/packages/core/src/content.test.ts b/packages/core/src/content.test.ts index 2a73ccf..b18a97d 100644 --- a/packages/core/src/content.test.ts +++ b/packages/core/src/content.test.ts @@ -1,7 +1,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { parseFrontmatter, scanContent } from "./content"; +import { parseFrontmatter, renderMarkdown, scanContent } from "./content"; const FIXTURE_DIR = join(import.meta.dir, "__fixtures__/content"); @@ -79,3 +79,154 @@ describe("scanContent", () => { expect(entries.some((e) => e.filePath.endsWith("plain.txt"))).toBe(false); }); }); + +describe("renderMarkdown", () => { + test("escapes raw HTML in prose so scripts cannot execute", () => { + const html = renderMarkdown("# Hello\n\n\n\nSafe."); + expect(html).not.toContain("