import { z } from 'zod'; import jwt from 'jsonwebtoken'; import crypto from 'node:crypto'; import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import type { Pool } from 'pg'; 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: Pool) { app.decorateRequest('tenantId', ''); app.decorateRequest('userId', ''); app.decorateRequest('userRole', 'viewer'); app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => { if (req.url === '/health') return; if (req.url.startsWith('/webhooks/')) return; if (req.url.startsWith('/slack/')) return; if (req.url.startsWith('/api/v1/auth/login') || req.url.startsWith('/api/v1/auth/signup')) return; const apiKey = req.headers['x-api-key'] as string | undefined; const authHeader = req.headers['authorization']; if (apiKey) { 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 const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex'); const result = await pool.query( `SELECT ak.tenant_id, ak.user_id, u.role FROM api_keys ak JOIN users u ON ak.user_id = u.id WHERE ak.key_prefix = $1 AND ak.key_hash = $2 AND ak.revoked = false`, [prefix, keyHash], ); if (!result.rows[0]) { return reply.status(401).send({ error: 'Invalid API key' }); } (req as any).tenantId = result.rows[0].tenant_id; (req as any).userId = result.rows[0].user_id; (req as any).userRole = result.rows[0].role; return; } 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 { return reply.status(401).send({ error: 'Invalid or expired token' }); } } return reply.status(401).send({ error: 'Missing authentication' }); }); } 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; } export function signToken(payload: AuthPayload, secret: string, expiresIn = '24h'): string { return jwt.sign(payload, secret, { expiresIn } as jwt.SignOptions); } // --- Password hashing (scrypt — no native bcrypt dep needed) --- async function hashPassword(password: string): Promise { const salt = crypto.randomBytes(16).toString('hex'); return new Promise((resolve, reject) => { crypto.scrypt(password, salt, 64, (err, derived) => { if (err) reject(err); resolve(`${salt}:${derived.toString('hex')}`); }); }); } async function verifyPassword(password: string, hash: string): Promise { const [salt, key] = hash.split(':'); return new Promise((resolve, reject) => { crypto.scrypt(password, salt, 64, (err, derived) => { if (err) reject(err); resolve(crypto.timingSafeEqual(Buffer.from(key, 'hex'), derived)); }); }); } // --- Auth Routes --- 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: Pool) { app.post('/api/v1/auth/login', async (req, reply) => { const body = loginSchema.parse(req.body); const result = await pool.query( `SELECT u.id, u.tenant_id, u.email, u.password_hash, u.role FROM users u WHERE u.email = $1`, [body.email], ); const user = result.rows[0]; if (!user) return reply.status(401).send({ error: 'Invalid credentials' }); const valid = await verifyPassword(body.password, user.password_hash); if (!valid) return reply.status(401).send({ error: 'Invalid credentials' }); const token = signToken({ tenantId: user.tenant_id, userId: user.id, email: user.email, role: user.role, }, jwtSecret); return { token, expires_in: '24h' }; }); app.post('/api/v1/auth/signup', async (req, reply) => { const body = signupSchema.parse(req.body); // Check if email already exists const existing = await pool.query('SELECT id FROM users WHERE email = $1', [body.email]); if (existing.rows[0]) return reply.status(409).send({ error: 'Email already registered' }); const passwordHash = await hashPassword(body.password); const slug = body.tenant_name.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 42) + '-' + crypto.randomBytes(3).toString('hex'); const client = await pool.connect(); try { await client.query('BEGIN'); const tenant = await client.query( `INSERT INTO tenants (name, slug) VALUES ($1, $2) RETURNING id`, [body.tenant_name, slug], ); const tenantId = tenant.rows[0].id; const user = await client.query( `INSERT INTO users (tenant_id, email, password_hash, role) VALUES ($1, $2, $3, 'owner') RETURNING id`, [tenantId, body.email, passwordHash], ); await client.query('COMMIT'); const token = signToken({ tenantId, userId: user.rows[0].id, email: body.email, role: 'owner', }, jwtSecret); return reply.status(201).send({ token, tenant_id: tenantId, expires_in: '24h' }); } catch (err) { await client.query('ROLLBACK'); throw err; } finally { client.release(); } }); 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, }; }); // Generate API key app.post('/api/v1/auth/api-keys', async (req, reply) => { const tenantId = (req as any).tenantId; const userId = (req as any).userId; if (!requireRole(req, reply, 'admin')) return; const rawKey = `dd0c_${crypto.randomBytes(16).toString('hex')}`; const prefix = rawKey.slice(0, 13); const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex'); await pool.query( `INSERT INTO api_keys (tenant_id, user_id, key_prefix, key_hash) VALUES ($1, $2, $3, $4)`, [tenantId, userId, prefix, keyHash], ); // Return the raw key ONCE — it's never stored or retrievable again return reply.status(201).send({ api_key: rawKey, prefix }); }); }