aRustyDev / aRustyDev/mdbook-htmx

docs(examples): Cloudflare Workers with D1 Authentication

Open
#38 0 comments 0 reactions 1 assignee Claimed by @aRustyDev View on GitHub
documentation
Dominant language
Rust
Stars
0
Forks
1
PR merge metrics
No merged PRs in 30d

Description

# Cloudflare Workers with D1 Authentication

Deploy mdbook-htmx with user authentication using Cloudflare Workers and D1 database.

## Implementation

This is a **Phase 3** feature. The auth implementation lives in the mdbook-htmx repository:

```
mdbook-htmx/
├── wrangler.jsonc # D1 + KV bindings
├── workers/
│ ├── index.ts # Main worker entry
│ ├── auth.ts # Session validation, OAuth helpers
│ ├── middleware.ts # Auth middleware
│ └── types.ts # TypeScript types
└── migrations/
└── 0001_sessions.sql # D1 schema
```

See [ADR-0016](../adr/0016-implementation-phasing-strategy.md) for the phasing strategy.

## Overview

This deployment uses:
- **Cloudflare Workers**: Edge compute for authentication logic
- **D1**: SQLite database for user sessions and access control
- **Pages**: Static asset serving
- **KV** (optional): Session caching

## Architecture

```
┌─────────────────┐
│ User Browser │
└────────┬────────┘


┌─────────────────┐ ┌─────────────────┐
│ Cloudflare Edge │────▶│ D1 Database │
│ (Worker) │ │ (SQLite edge) │
└────────┬────────┘ └─────────────────┘


┌─────────────────┐
│ Static Assets │
│ (Pages/R2) │
└─────────────────┘
```

## Project Structure

```
my-docs/
├── book.toml
├── src/
├── book/ # Static output
├── worker/
│ ├── src/
│ │ ├── index.ts # Main worker
│ │ ├── auth.ts # Authentication logic
│ │ ├── middleware.ts # Request middleware
│ │ └── types.ts # TypeScript types
│ ├── wrangler.toml
│ └── package.json
└── migrations/
└── 0001_initial.sql # D1 schema
```

## D1 Database Schema

```sql
-- migrations/0001_initial.sql

-- Users table
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT,
roles TEXT DEFAULT '[]', -- JSON array of roles
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

-- Sessions table
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

-- OAuth tokens (for OAuth providers)
CREATE TABLE oauth_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
access_token TEXT,
refresh_token TEXT,
expires_at DATETIME,
UNIQUE(user_id, provider)
);

-- Access control entries
CREATE TABLE access_control (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path_pattern TEXT NOT NULL,
required_roles TEXT DEFAULT '[]', -- JSON array
allow_anonymous BOOLEAN DEFAULT FALSE
);

-- Indexes
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
CREATE INDEX idx_access_control_path ON access_control(path_pattern);
```

## Worker Configuration

### wrangler.toml

```toml
name = "docs-auth"
main = "src/index.ts"
compatibility_date = "2024-01-01"

# D1 Database binding
[[d1_databases]]
binding = "DB"
database_name = "docs-auth"
database_id = "your-database-id"

# KV for session cache (optional)
[[kv_namespaces]]
binding = "SESSION_CACHE"
id = "your-kv-id"

# Environment variables
[vars]
DOCS_ORIGIN = "https://docs.example.com"
SESSION_DURATION_HOURS = "24"

# Secrets (set via wrangler secret put)
# GITHUB_CLIENT_ID
# GITHUB_CLIENT_SECRET
# SESSION_SECRET

# Routes
routes = [
{ pattern = "docs.example.com/*", zone_name = "example.com" }
]
```

## Worker Implementation

### src/index.ts

```typescript
import { authMiddleware } from './middleware';
import { handleAuth, handleCallback, handleSignout } from './auth';
import { Env, Session } from './types';

export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise {
const url = new URL(request.url);
const path = url.pathname;

// Auth routes
if (path.startsWith('/auth/')) {
if (path === '/auth/github') {
return handleAuth(request, env, 'github');
}
if (path === '/auth/github/callback') {
return handleCallback(request, env, 'github');
}
if (path === '/auth/signout') {
return handleSignout(request, env);
}
if (path === '/auth/signin') {
return serveSigninPage(request, env);
}
}

// Check authentication for protected routes
const authResult = await authMiddleware(request, env);

if (!authResult.allowed) {
// Return 401 or redirect to signin
if (request.headers.get('HX-Request')) {
// HTMX request - return partial
return new Response(await renderAccessDenied(authResult), {
status: 401,
headers: { 'Content-Type': 'text/html' }
});
}
// Full page redirect
return Response.redirect(`${env.DOCS_ORIGIN}/auth/signin?redirect=${encodeURIComponent(path)}`);
}

// Inject user context into request
const modifiedRequest = new Request(request, {
headers: new Headers(request.headers)
});
modifiedRequest.headers.set('X-User-Id', authResult.session?.userId || '');
modifiedRequest.headers.set('X-User-Roles', JSON.stringify(authResult.session?.roles || []));

// Forward to static assets
return fetch(modifiedRequest);
}
};
```

### src/auth.ts

```typescript
import { Env, Session, User } from './types';

// GitHub OAuth
export async function handleAuth(request: Request, env: Env, provider: string): Promise {
const state = crypto.randomUUID();
const url = new URL(request.url);
const redirect = url.searchParams.get('redirect') || '/';

// Store state in KV with redirect URL
await env.SESSION_CACHE.put(`oauth_state:${state}`, redirect, { expirationTtl: 300 });

const authUrl = new URL('https://github.com/login/oauth/authorize');
authUrl.searchParams.set('client_id', env.GITHUB_CLIENT_ID);
authUrl.searchParams.set('redirect_uri', `${env.DOCS_ORIGIN}/auth/github/callback`);
authUrl.searchParams.set('scope', 'read:user user:email');
authUrl.searchParams.set('state', state);

return Response.redirect(authUrl.toString());
}

export async function handleCallback(request: Request, env: Env, provider: string): Promise {
const url = new URL(request.url);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');

// Verify state
const redirect = await env.SESSION_CACHE.get(`oauth_state:${state}`);
if (!redirect) {
return new Response('Invalid state', { status: 400 });
}
await env.SESSION_CACHE.delete(`oauth_state:${state}`);

// Exchange code for token
const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
client_id: env.GITHUB_CLIENT_ID,
client_secret: env.GITHUB_CLIENT_SECRET,
code,
}),
});

const { access_token } = await tokenResponse.json();

// Get user info
const userResponse = await fetch('https://api.github.com/user', {
headers: {
'Authorization': `Bearer ${access_token}`,
'User-Agent': 'mdbook-htmx-auth',
},
});

const githubUser = await userResponse.json();

// Upsert user in D1
const user = await upsertUser(env.DB, {
id: `github:${githubUser.id}`,
email: githubUser.email,
name: githubUser.name || githubUser.login,
});

// Create session
const sessionId = crypto.randomUUID();
const expiresAt = new Date(Date.now() + parseInt(env.SESSION_DURATION_HOURS) * 60 * 60 * 1000);

await env.DB.prepare(
'INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)'
).bind(sessionId, user.id, expiresAt.toISOString()).run();

// Set session cookie and redirect
const response = Response.redirect(redirect);
response.headers.set('Set-Cookie',
`session=${sessionId}; Path=/; HttpOnly; Secure; SameSite=Lax; Expires=${expiresAt.toUTCString()}`
);

return response;
}

export async function handleSignout(request: Request, env: Env): Promise {
const sessionId = getSessionIdFromCookie(request);

if (sessionId) {
await env.DB.prepare('DELETE FROM sessions WHERE id = ?').bind(sessionId).run();
}

return new Response(null, {
status: 302,
headers: {
'Location': '/',
'Set-Cookie': 'session=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0',
},
});
}

async function upsertUser(db: D1Database, userData: Partial): Promise {
await db.prepare(`
INSERT INTO users (id, email, name, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET
email = excluded.email,
name = excluded.name,
updated_at = CURRENT_TIMESTAMP
`).bind(userData.id, userData.email, userData.name).run();

const result = await db.prepare('SELECT * FROM users WHERE id = ?')
.bind(userData.id)
.first();

return result!;
}

function getSessionIdFromCookie(request: Request): string | null {
const cookie = request.headers.get('Cookie');
if (!cookie) return null;

const match = cookie.match(/session=([^;]+)/);
return match ? match[1] : null;
}
```

### src/middleware.ts

```typescript
import { Env, Session, AuthResult } from './types';

export async function authMiddleware(request: Request, env: Env): Promise {
const url = new URL(request.url);
const path = url.pathname;

// Check access control rules
const accessRule = await getAccessRule(env.DB, path);

if (accessRule?.allow_anonymous) {
return { allowed: true };
}

// Get session
const session = await getSession(request, env);

if (!session) {
return {
allowed: !accessRule, // Allow if no rule defined
requiredRoles: accessRule?.required_roles,
};
}

// Check roles
if (accessRule?.required_roles?.length > 0) {
const userRoles = new Set(session.roles);
const hasRequiredRole = accessRule.required_roles.some(r => userRoles.has(r));

if (!hasRequiredRole) {
return {
allowed: false,
session,
requiredRoles: accessRule.required_roles,
};
}
}

return { allowed: true, session };
}

async function getSession(request: Request, env: Env): Promise {
const cookie = request.headers.get('Cookie');
if (!cookie) return null;

const match = cookie.match(/session=([^;]+)/);
if (!match) return null;

const sessionId = match[1];

// Check cache first
const cached = await env.SESSION_CACHE.get(`session:${sessionId}`, 'json');
if (cached) return cached as Session;

// Query D1
const result = await env.DB.prepare(`
SELECT s.id, s.user_id, s.expires_at, u.email, u.name, u.roles
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.id = ? AND s.expires_at > datetime('now')
`).bind(sessionId).first();

if (!result) return null;

const session: Session = {
id: result.id as string,
userId: result.user_id as string,
email: result.email as string,
name: result.name as string,
roles: JSON.parse(result.roles as string || '[]'),
expiresAt: result.expires_at as string,
};

// Cache for 5 minutes
await env.SESSION_CACHE.put(`session:${sessionId}`, JSON.stringify(session), { expirationTtl: 300 });

return session;
}

async function getAccessRule(db: D1Database, path: string) {
// Get matching access rules (most specific first)
const result = await db.prepare(`
SELECT * FROM access_control
WHERE ? LIKE replace(path_pattern, '*', '%')
ORDER BY length(path_pattern) DESC
LIMIT 1
`).bind(path).first();

if (!result) return null;

return {
path_pattern: result.path_pattern as string,
required_roles: JSON.parse(result.required_roles as string || '[]'),
allow_anonymous: result.allow_anonymous as boolean,
};
}
```

## Deployment

### Create D1 Database

```bash
# Create database
wrangler d1 create docs-auth

# Apply migrations
wrangler d1 execute docs-auth --file=./migrations/0001_initial.sql

# Seed access control rules
wrangler d1 execute docs-auth --command="
INSERT INTO access_control (path_pattern, required_roles, allow_anonymous) VALUES
('/', '[]', true),
('/docs/*', '[]', false),
('/docs/internal/*', '[\"internal\"]', false),
('/admin/*', '[\"admin\"]', false);
"
```

### Set Secrets

```bash
wrangler secret put GITHUB_CLIENT_ID
wrangler secret put GITHUB_CLIENT_SECRET
wrangler secret put SESSION_SECRET
```

### Deploy

```bash
wrangler deploy
```

## book.toml Configuration

```toml
[output.htmx.authn]
enabled = true
mode = "server"
signin_page = "/auth/signin"
signin_partial = "/partials/signin"

[output.htmx.authn.providers]
github = { name = "GitHub", icon = "github" }

[output.htmx.authz]
default_policy = "deny"

[[output.htmx.authz.rules]]
paths = ["/", "/docs"]
allow_anonymous = true

[[output.htmx.authz.rules]]
paths = ["/docs/internal/*"]
required_roles = ["internal"]
```

## Cost Estimate

| Component | Free Tier | Paid |
|-----------|-----------|------|
| Workers | 100K req/day | $5/10M requests |
| D1 | 5M rows read/day | $0.001/M reads |
| KV | 100K reads/day | $0.50/M reads |

Typical documentation site: **Free** or <$5/month

## Next Steps

- Add [KV caching layer](./cf-workers-kv-cache.md)
- Set up [Meilisearch search](./meilisearch-cf-tunnel.md)
- Configure additional OAuth providers

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.