From 762e2db9dfe9050806775a181e76e72c7c86a858 Mon Sep 17 00:00:00 2001 From: Max Mayfield Date: Sun, 1 Mar 2026 03:09:01 +0000 Subject: [PATCH] Add shared auth middleware (JWT + API key + RBAC) and canonical withTenant() helper --- products/shared/README.md | 26 ++++++++ products/shared/auth.ts | 122 ++++++++++++++++++++++++++++++++++++++ products/shared/db.ts | 41 +++++++++++++ 3 files changed, 189 insertions(+) create mode 100644 products/shared/README.md create mode 100644 products/shared/auth.ts create mode 100644 products/shared/db.ts diff --git a/products/shared/README.md b/products/shared/README.md new file mode 100644 index 0000000..2a72ade --- /dev/null +++ b/products/shared/README.md @@ -0,0 +1,26 @@ +# dd0c Shared Modules + +Reusable code shared across all dd0c products. + +## Files + +- `auth.ts` — JWT + API key authentication middleware, RBAC, login/signup routes +- `db.ts` — PostgreSQL connection pool with RLS `withTenant()` helper + +## Usage + +Copy into each product's `src/` directory, or symlink during build. +These are kept here as the canonical source of truth. + +## Auth Flow + +1. **JWT (Browser/API):** `Authorization: Bearer ` → decoded → `req.tenantId`, `req.userId`, `req.userRole` +2. **API Key (Agent/CLI):** `X-API-Key: dd0c_<32hex>` → prefix lookup → bcrypt verify → tenant context +3. **Webhook (HMAC):** Per-provider signature validation (skips JWT middleware) +4. **Slack (Signing Secret):** Slack request signature verification (skips JWT middleware) + +## RBAC Hierarchy + +`owner > admin > member > viewer` + +Use `requireRole(req, reply, 'admin')` in route handlers for access control. diff --git a/products/shared/auth.ts b/products/shared/auth.ts new file mode 100644 index 0000000..3329232 --- /dev/null +++ b/products/shared/auth.ts @@ -0,0 +1,122 @@ +import { z } from 'zod'; +import jwt from 'jsonwebtoken'; +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import pino from 'pino'; + +const logger = pino({ name: 'auth' }); + +export interface AuthPayload { + tenantId: string; + userId: string; + email: string; + role: 'owner' | 'admin' | 'member' | 'viewer'; +} + +/** + * JWT auth middleware. Extracts tenant context from Bearer token. + * Also supports API key auth via `X-API-Key` header (dd0c_ prefix). + */ +export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: any) { + app.decorateRequest('tenantId', ''); + app.decorateRequest('userId', ''); + app.decorateRequest('userRole', 'viewer'); + + app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => { + // Skip health check + if (req.url === '/health') return; + // Skip webhook endpoints (they use HMAC auth) + if (req.url.startsWith('/webhooks/')) return; + // Skip Slack endpoints (they use Slack signature) + if (req.url.startsWith('/slack/')) return; + + const apiKey = req.headers['x-api-key'] as string | undefined; + const authHeader = req.headers['authorization']; + + if (apiKey) { + // API key auth: dd0c_ prefix + 32 hex chars + if (!apiKey.startsWith('dd0c_') || apiKey.length !== 37) { + return reply.status(401).send({ error: 'Invalid API key format' }); + } + + const prefix = apiKey.slice(0, 13); // dd0c_ + 8 hex chars for fast lookup + // TODO: Look up by prefix, bcrypt compare full key + // For now, reject — real implementation needs DB lookup + return reply.status(401).send({ error: 'API key auth not yet implemented' }); + } + + if (authHeader?.startsWith('Bearer ')) { + const token = authHeader.slice(7); + try { + const payload = jwt.verify(token, jwtSecret) as AuthPayload; + (req as any).tenantId = payload.tenantId; + (req as any).userId = payload.userId; + (req as any).userRole = payload.role; + return; + } catch (err) { + return reply.status(401).send({ error: 'Invalid or expired token' }); + } + } + + return reply.status(401).send({ error: 'Missing authentication' }); + }); +} + +/** + * Role-based access control check. + * Use in route handlers: requireRole(req, reply, 'admin') + */ +export function requireRole(req: FastifyRequest, reply: FastifyReply, minRole: AuthPayload['role']): boolean { + const hierarchy: Record = { owner: 4, admin: 3, member: 2, viewer: 1 }; + const userLevel = hierarchy[(req as any).userRole] ?? 0; + const requiredLevel = hierarchy[minRole] ?? 0; + + if (userLevel < requiredLevel) { + reply.status(403).send({ error: `Requires ${minRole} role or higher` }); + return false; + } + return true; +} + +/** + * Generate a JWT token (for login/signup endpoints). + */ +export function signToken(payload: AuthPayload, secret: string, expiresIn = '24h'): string { + return jwt.sign(payload, secret, { expiresIn }); +} + +/** + * Login endpoint registration. + */ +const loginSchema = z.object({ + email: z.string().email(), + password: z.string().min(8), +}); + +const signupSchema = z.object({ + email: z.string().email(), + password: z.string().min(8), + tenant_name: z.string().min(1).max(100), +}); + +export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: any) { + app.post('/api/v1/auth/login', async (req, reply) => { + const body = loginSchema.parse(req.body); + // TODO: Look up user by email, bcrypt compare password + // For now, return placeholder + return reply.status(501).send({ error: 'Login not yet implemented' }); + }); + + app.post('/api/v1/auth/signup', async (req, reply) => { + const body = signupSchema.parse(req.body); + // TODO: Create tenant + user, hash password with bcrypt + return reply.status(501).send({ error: 'Signup not yet implemented' }); + }); + + app.get('/api/v1/auth/me', async (req, reply) => { + return { + tenant_id: (req as any).tenantId, + user_id: (req as any).userId, + role: (req as any).userRole, + }; + }); +} diff --git a/products/shared/db.ts b/products/shared/db.ts new file mode 100644 index 0000000..5e181e5 --- /dev/null +++ b/products/shared/db.ts @@ -0,0 +1,41 @@ +/** + * Shared withTenant() RLS helper. + * Copy into each product's src/data/db.ts or import from shared. + * + * CRITICAL (BMad must-have): Always RESET app.tenant_id in finally block + * to prevent connection pool tenant context leakage. + */ +import pg from 'pg'; + +export async function withTenant( + pool: pg.Pool, + tenantId: string, + fn: (client: pg.PoolClient) => Promise, +): Promise { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query(`SET LOCAL app.tenant_id = '${tenantId}'`); + const result = await fn(client); + await client.query('COMMIT'); + return result; + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + await client.query('RESET app.tenant_id'); + client.release(); + } +} + +/** + * Health check query — verifies DB connectivity. + */ +export async function healthCheck(pool: pg.Pool): Promise { + try { + await pool.query('SELECT 1'); + return true; + } catch { + return false; + } +}