diff --git a/products/02-iac-drift-detection/saas/src/auth/middleware.ts b/products/02-iac-drift-detection/saas/src/auth/middleware.ts new file mode 100644 index 0000000..46a0617 --- /dev/null +++ b/products/02-iac-drift-detection/saas/src/auth/middleware.ts @@ -0,0 +1,224 @@ +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, 50); + + 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 }); + }); +} diff --git a/products/02-iac-drift-detection/saas/src/index.ts b/products/02-iac-drift-detection/saas/src/index.ts index 6c83ab4..f198255 100644 --- a/products/02-iac-drift-detection/saas/src/index.ts +++ b/products/02-iac-drift-detection/saas/src/index.ts @@ -4,15 +4,13 @@ import helmet from '@fastify/helmet'; import { config } from './config/index.js'; import { registerProcessorRoutes } from './processor/routes.js'; import { registerApiRoutes } from './api/routes.js'; +import { registerAuth, registerAuthRoutes } from './auth/middleware.js'; import { createPool } from './data/db.js'; import { createRedis } from './data/redis.js'; const app = Fastify({ logger: { level: config.logLevel, - transport: config.nodeEnv === 'development' - ? { target: 'pino-pretty' } - : undefined, }, }); @@ -29,13 +27,19 @@ async function start() { app.decorate('redis', redis); app.decorate('config', config); + // Auth + registerAuth(app, config.jwtSecret, pool); + + // Health (before auth) + app.get('/health', async () => ({ status: 'ok' })); + + // Auth routes (signup/login) + registerAuthRoutes(app, config.jwtSecret, pool); + // Routes await registerProcessorRoutes(app); await registerApiRoutes(app); - // Health - app.get('/health', async () => ({ status: 'ok' })); - await app.listen({ port: config.port, host: '0.0.0.0' }); app.log.info(`dd0c/drift SaaS listening on :${config.port}`); }