Add shared auth middleware (JWT + API key + RBAC) and canonical withTenant() helper
This commit is contained in:
26
products/shared/README.md
Normal file
26
products/shared/README.md
Normal file
@@ -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 <token>` → 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.
|
||||
122
products/shared/auth.ts
Normal file
122
products/shared/auth.ts
Normal file
@@ -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<string, number> = { 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
41
products/shared/db.ts
Normal file
41
products/shared/db.ts
Normal file
@@ -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<T>(
|
||||
pool: pg.Pool,
|
||||
tenantId: string,
|
||||
fn: (client: pg.PoolClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
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<boolean> {
|
||||
try {
|
||||
await pool.query('SELECT 1');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user