Skip to content
Open
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
2 changes: 2 additions & 0 deletions examples/auth-agent/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Generate with: openssl rand -base64 32
BETTER_AUTH_SECRET=your-secret-key-here
136 changes: 136 additions & 0 deletions examples/auth-agent/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# Auth Agent

Securing a Cloudflare Agents server with [better-auth](https://www.better-auth.com/), JWT authentication, and Cloudflare D1.

## What this demonstrates

- Email/password auth with better-auth on Workers
- D1 as the auth database (users, sessions, JWKS keys)
- JWT issuance and JWKS-based verification
- Protecting WebSocket connections via `onBeforeConnect`
- Protecting HTTP agent routes via `onBeforeRequest`

## Getting started

```sh
npm install

# Create .dev.vars with your secret
echo "BETTER_AUTH_SECRET=$(openssl rand -base64 32)" > .dev.vars

# Create the D1 tables
npm run db:setup

# Start dev server
npm start
```

## Architecture

```
Browser (React SPA)
├── /api/auth/* → better-auth (sign-up, sign-in, JWT, JWKS)
├── /agents/* → routeAgentRequest() with JWT middleware
└── /* → Vite SPA (wrangler assets)
```

### Auth flow

1. User signs in → better-auth sets a **session cookie** (same-origin, automatic)
2. Client calls `authClient.token()` → `GET /api/auth/token` (authenticated via cookie) → returns a short-lived **JWT**
3. JWT stored in `localStorage`
4. `useAgent({ query: async () => ({ token }) })` passes JWT as a WebSocket query parameter
5. Server's `onBeforeConnect` verifies JWT using JWKS read from D1

## Key decisions

### Why better-auth

[better-auth](https://www.better-auth.com/) is a framework-agnostic TypeScript auth library that runs on any runtime, including Workers. It provides email/password auth, session management, JWT issuance, and JWKS out of the box via plugins. No external auth service required — everything runs in your Worker.

### Why D1 (not memoryAdapter or stateless mode)

better-auth needs a database for user records regardless of how sessions work. D1 is Cloudflare's serverless SQLite — zero config, no connection strings, available as a binding. The `memoryAdapter` is for testing only; it loses data on every request in Workers since each invocation is stateless.

### Why kysely-d1

better-auth uses [Kysely](https://kysely.dev/) internally as its query builder. D1 has its own API surface. [`kysely-d1`](https://github.com/nickkatsios/kysely-d1) is the dialect that bridges the two:

```
better-auth → Kysely → kysely-d1 → D1
```

This is the adapter chain we use (Drizzle with `drizzle-orm/d1` is another option). You pass it directly to better-auth's `database` config:

```ts
database: {
dialect: new D1Dialect({ database: env.AUTH_DB }),
type: "sqlite"
}
```

### Why cookies for browser auth + JWT for WebSocket auth

**Browser → auth API**: Cookies are automatic on same-origin. No manual token management needed — the browser sends them on every request. This is simpler and more reliable than managing bearer tokens in `localStorage`.

**Browser → agent WebSocket**: WebSocket upgrade requests cannot send custom headers. The JWT must be passed as a URL query parameter (`?token=...`). This is why we need both mechanisms.

### Why createLocalJWKSet (not createRemoteJWKSet)

The JWKS endpoint (`/api/auth/jwks`) lives on the same Worker that needs to verify tokens. Using `createRemoteJWKSet` would cause the Worker to `fetch()` its own URL. By default, Cloudflare routes same-zone subrequests to the origin server, bypassing Workers — so the JWKS endpoint is never reached. On `workers.dev` (where there is no origin), this fails outright. With the `global_fetch_strictly_public` compatibility flag, true loopback is possible — but it adds latency, consumes a subrequest, and requires an opt-in flag.

Instead, we read the JWKS keys directly from D1 (the `jwks` table that better-auth's JWT plugin manages) and build a local key set with [`jose`](https://github.com/panva/jose):

```ts
// Simplified — see auth.ts for full typed version
const result = await env.AUTH_DB.prepare(
"SELECT id, publicKey, privateKey, createdAt FROM jwks"
).all();

const jwks = createLocalJWKSet({
keys: result.results.map((row) => ({
...JSON.parse(row.publicKey),
kid: row.id
}))
});

const { payload } = await jwtVerify(token, jwks);
```

## File overview

| File | Purpose |
| -------------------- | ------------------------------------------------------------------------------------------ |
| `src/server.ts` | Worker fetch handler — routes to auth, agents, or SPA. Exports `SecuredChatAgent` DO. |
| `src/auth.ts` | `getAuth()` lazy singleton + `verifyToken()` — D1 via kysely-d1, JWT verification via jose |
| `src/auth-client.ts` | Browser auth client — `fetchAndStoreJwt()`, `clearTokens()` |
| `src/client.tsx` | React UI — auth form + chat view |
| `db/setup.sql` | Creates better-auth tables (user, session, account, jwks) |
| `db/reset.sql` | Drops and recreates all tables |

## Scripts

| Script | Description |
| ------------------ | ---------------------------- |
| `npm start` | Start Vite dev server |
| `npm run db:setup` | Create D1 tables locally |
| `npm run db:reset` | Drop and recreate all tables |
| `npm run deploy` | Build and deploy to Workers |
| `npm run types` | Regenerate `env.d.ts` |

## Environment variables

| Variable | Required | Description |
| -------------------- | -------- | --------------------------------------------------------------------- |
| `BETTER_AUTH_SECRET` | Yes | Secret for signing sessions/tokens. Min 32 chars. Put in `.dev.vars`. |
| `BETTER_AUTH_URL` | No | Set in `wrangler.jsonc`. Defaults to `http://localhost:5173`. |

## Stack

- **Runtime**: Cloudflare Workers + Durable Objects + D1
- **Agent**: [@cloudflare/ai-chat](https://www.npmjs.com/package/@cloudflare/ai-chat) (`AIChatAgent` base class + `useAgentChat` React hook)
- **Auth**: [better-auth](https://www.better-auth.com/) with JWT + bearer plugins
- **JWT verification**: [jose](https://github.com/panva/jose) with `createLocalJWKSet`
- **Database adapter**: [kysely-d1](https://github.com/nickkatsios/kysely-d1)
- **UI**: React, Tailwind CSS, [Kumo](https://kumo-ui.com/) (workers theme)
- **Build**: Vite + `@cloudflare/vite-plugin`
51 changes: 51 additions & 0 deletions examples/auth-agent/db/reset.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
-- Drop all better-auth tables and recreate them empty.
-- Run with: npm run db:reset

DROP TABLE IF EXISTS "session";
DROP TABLE IF EXISTS "account";
DROP TABLE IF EXISTS "jwks";
DROP TABLE IF EXISTS "user";

CREATE TABLE "user" (
"id" TEXT PRIMARY KEY NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL UNIQUE,
"emailVerified" INTEGER NOT NULL DEFAULT 0,
"image" TEXT,
"createdAt" TEXT NOT NULL,
"updatedAt" TEXT NOT NULL
);

CREATE TABLE "session" (
"id" TEXT PRIMARY KEY NOT NULL,
"expiresAt" TEXT NOT NULL,
"token" TEXT NOT NULL UNIQUE,
"createdAt" TEXT NOT NULL,
"updatedAt" TEXT NOT NULL,
"ipAddress" TEXT,
"userAgent" TEXT,
"userId" TEXT NOT NULL REFERENCES "user"("id")
);

CREATE TABLE "account" (
"id" TEXT PRIMARY KEY NOT NULL,
"accountId" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"userId" TEXT NOT NULL REFERENCES "user"("id"),
"accessToken" TEXT,
"refreshToken" TEXT,
"idToken" TEXT,
"accessTokenExpiresAt" TEXT,
"refreshTokenExpiresAt" TEXT,
"scope" TEXT,
"password" TEXT,
"createdAt" TEXT NOT NULL,
"updatedAt" TEXT NOT NULL
);

CREATE TABLE "jwks" (
"id" TEXT PRIMARY KEY NOT NULL,
"publicKey" TEXT NOT NULL,
"privateKey" TEXT NOT NULL,
"createdAt" TEXT NOT NULL
);
47 changes: 47 additions & 0 deletions examples/auth-agent/db/setup.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
-- better-auth schema for D1 (SQLite)
-- Tables: user, session, account, jwks
-- Run with: npm run db:setup

CREATE TABLE IF NOT EXISTS "user" (
"id" TEXT PRIMARY KEY NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL UNIQUE,
"emailVerified" INTEGER NOT NULL DEFAULT 0,
"image" TEXT,
"createdAt" TEXT NOT NULL,
"updatedAt" TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS "session" (
"id" TEXT PRIMARY KEY NOT NULL,
"expiresAt" TEXT NOT NULL,
"token" TEXT NOT NULL UNIQUE,
"createdAt" TEXT NOT NULL,
"updatedAt" TEXT NOT NULL,
"ipAddress" TEXT,
"userAgent" TEXT,
"userId" TEXT NOT NULL REFERENCES "user"("id")
);

CREATE TABLE IF NOT EXISTS "account" (
"id" TEXT PRIMARY KEY NOT NULL,
"accountId" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"userId" TEXT NOT NULL REFERENCES "user"("id"),
"accessToken" TEXT,
"refreshToken" TEXT,
"idToken" TEXT,
"accessTokenExpiresAt" TEXT,
"refreshTokenExpiresAt" TEXT,
"scope" TEXT,
"password" TEXT,
"createdAt" TEXT NOT NULL,
"updatedAt" TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS "jwks" (
"id" TEXT PRIMARY KEY NOT NULL,
"publicKey" TEXT NOT NULL,
"privateKey" TEXT NOT NULL,
"createdAt" TEXT NOT NULL
);
27 changes: 27 additions & 0 deletions examples/auth-agent/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/* eslint-disable */
// Generated by Wrangler by running `wrangler types env.d.ts --include-runtime false` (hash: da268fb7f3e5cf33e8cc5a32e8c54e65)
declare namespace Cloudflare {
interface GlobalProps {
mainModule: typeof import("./src/server");
durableNamespaces: "SecuredChatAgent";
}
interface Env {
AUTH_DB: D1Database;
BETTER_AUTH_URL: "http://localhost:5173";
BETTER_AUTH_SECRET: string;
SecuredChatAgent: DurableObjectNamespace<
import("./src/server").SecuredChatAgent
>;
}
}
interface Env extends Cloudflare.Env {}
type StringifyValues<EnvType extends Record<string, unknown>> = {
[Binding in keyof EnvType]: EnvType[Binding] extends string
? EnvType[Binding]
: string;
};
declare namespace NodeJS {
interface ProcessEnv extends StringifyValues<
Pick<Cloudflare.Env, "BETTER_AUTH_URL" | "BETTER_AUTH_SECRET">
> {}
}
21 changes: 21 additions & 0 deletions examples/auth-agent/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en" data-theme="workers">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Auth Agent</title>
<script>
// Prevent flash — apply dark mode before first paint
(() => {
const stored = localStorage.getItem("theme");
const mode = stored || "light";
document.documentElement.setAttribute("data-mode", mode);
document.documentElement.style.colorScheme = mode;
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
31 changes: 31 additions & 0 deletions examples/auth-agent/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"author": "",
"description": "Securing an Agents server with better-auth, JWT authentication, and Cloudflare D1",
"license": "ISC",
"name": "@cloudflare/agents-auth-agent",
"private": true,
"scripts": {
"deploy": "vite build && wrangler deploy",
"types": "wrangler types env.d.ts --include-runtime false",
"start": "vite dev",
"db:setup": "wrangler d1 execute auth-agent-db --local --file=db/setup.sql",
"db:reset": "wrangler d1 execute auth-agent-db --local --file=db/reset.sql"
},
"type": "module",
"version": "0.0.0",
"dependencies": {
"@cloudflare/agents-ui": "*",
"@cloudflare/ai-chat": "*",
"@cloudflare/kumo": "^1.6.0",
"@phosphor-icons/react": "^2.1.10",
"agents": "*",
"better-auth": "^1.2.0",
"jose": "^6.0.11",
"kysely": "^0.28.5",
"kysely-d1": "^0.4.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4",
"tailwindcss": "^4.2.0"
}
}
30 changes: 30 additions & 0 deletions examples/auth-agent/src/auth-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* better-auth React client.
*
* Flow: sign in → session cookie set → fetchAndStoreJwt() gets a JWT via
* the cookie → JWT stored in localStorage → passed to WebSocket as ?token=.
*/

import { createAuthClient } from "better-auth/react";
import { jwtClient } from "better-auth/client/plugins";

export const authClient = createAuthClient({
plugins: [jwtClient()]
});

/** Fetch a JWT from /api/auth/token (cookie-authenticated) and cache it. */
export async function fetchAndStoreJwt(): Promise<string | null> {
const result = await authClient.token();
if (result.data?.token) {
localStorage.setItem("jwt_token", result.data.token);
return result.data.token;
}
return null;
}

/** Clear stored JWT. Called on sign-out. */
export function clearTokens() {
localStorage.removeItem("jwt_token");
}

export const { signIn, signUp, signOut } = authClient;
Loading
Loading