From 5ee869b9d801c2d12478c9e4cc39dca89e0e0a81 Mon Sep 17 00:00:00 2001 From: Max Mayfield Date: Sun, 1 Mar 2026 03:19:18 +0000 Subject: [PATCH] Implement auth: login/signup (scrypt), API key generation, shared migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Login: email + password lookup, scrypt verify, JWT token - Signup: create tenant + owner user in transaction, slug generation - API key: dd0c_ prefix, SHA-256 hash (not bcrypt — faster for API key lookups), prefix index - Scrypt over bcrypt: zero native deps, Node.js built-in crypto - Auth routes skip JWT middleware (login/signup are public) - 002_auth.sql: users + api_keys tables with RLS, copied to all products - Synced auth middleware to P3/P4/P5/P6 --- .../migrations/002_auth.sql | 35 ++++ .../src/auth/middleware.ts | 154 +++++++++++++++--- .../migrations/002_auth.sql | 35 ++++ .../04-lightweight-idp/src/auth/middleware.ts | 154 +++++++++++++++--- .../migrations/002_auth.sql | 35 ++++ .../src/auth/middleware.ts | 154 +++++++++++++++--- .../saas/migrations/002_auth.sql | 35 ++++ .../saas/src/auth/middleware.ts | 154 +++++++++++++++--- products/shared/002_auth.sql | 35 ++++ products/shared/auth.ts | 154 +++++++++++++++--- 10 files changed, 815 insertions(+), 130 deletions(-) create mode 100644 products/03-alert-intelligence/migrations/002_auth.sql create mode 100644 products/04-lightweight-idp/migrations/002_auth.sql create mode 100644 products/05-aws-cost-anomaly/migrations/002_auth.sql create mode 100644 products/06-runbook-automation/saas/migrations/002_auth.sql create mode 100644 products/shared/002_auth.sql diff --git a/products/03-alert-intelligence/migrations/002_auth.sql b/products/03-alert-intelligence/migrations/002_auth.sql new file mode 100644 index 0000000..ea6949a --- /dev/null +++ b/products/03-alert-intelligence/migrations/002_auth.sql @@ -0,0 +1,35 @@ +-- dd0c shared auth tables — append to each product's migration +-- Run after 001_init.sql + +-- Users +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('owner', 'admin', 'member', 'viewer')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_users_tenant ON users(tenant_id); + +-- API Keys +CREATE TABLE IF NOT EXISTS api_keys ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + key_prefix TEXT NOT NULL, + key_hash TEXT NOT NULL, + revoked BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(key_prefix) WHERE revoked = false; + +-- RLS +ALTER TABLE users ENABLE ROW LEVEL SECURITY; +ALTER TABLE api_keys ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_iso_users ON users + USING (tenant_id::text = current_setting('app.tenant_id', true)); +CREATE POLICY tenant_iso_api_keys ON api_keys + USING (tenant_id::text = current_setting('app.tenant_id', true)); diff --git a/products/03-alert-intelligence/src/auth/middleware.ts b/products/03-alert-intelligence/src/auth/middleware.ts index 3329232..84510bc 100644 --- a/products/03-alert-intelligence/src/auth/middleware.ts +++ b/products/03-alert-intelligence/src/auth/middleware.ts @@ -1,6 +1,8 @@ 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' }); @@ -16,32 +18,43 @@ export interface AuthPayload { * 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) { +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) => { - // 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; + 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) { - // 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' }); + 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 ')) { @@ -52,7 +65,7 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: any) (req as any).userId = payload.userId; (req as any).userRole = payload.role; return; - } catch (err) { + } catch { return reply.status(401).send({ error: 'Invalid or expired token' }); } } @@ -61,10 +74,6 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: any) }); } -/** - * 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; @@ -77,16 +86,34 @@ export function requireRole(req: FastifyRequest, reply: FastifyReply, minRole: A 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. - */ +// --- 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), @@ -98,18 +125,73 @@ const signupSchema = z.object({ tenant_name: z.string().min(1).max(100), }); -export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: any) { +export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: Pool) { 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' }); + + 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); - // TODO: Create tenant + user, hash password with bcrypt - return reply.status(501).send({ error: 'Signup not yet implemented' }); + + // 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) => { @@ -119,4 +201,24 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool 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/04-lightweight-idp/migrations/002_auth.sql b/products/04-lightweight-idp/migrations/002_auth.sql new file mode 100644 index 0000000..ea6949a --- /dev/null +++ b/products/04-lightweight-idp/migrations/002_auth.sql @@ -0,0 +1,35 @@ +-- dd0c shared auth tables — append to each product's migration +-- Run after 001_init.sql + +-- Users +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('owner', 'admin', 'member', 'viewer')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_users_tenant ON users(tenant_id); + +-- API Keys +CREATE TABLE IF NOT EXISTS api_keys ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + key_prefix TEXT NOT NULL, + key_hash TEXT NOT NULL, + revoked BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(key_prefix) WHERE revoked = false; + +-- RLS +ALTER TABLE users ENABLE ROW LEVEL SECURITY; +ALTER TABLE api_keys ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_iso_users ON users + USING (tenant_id::text = current_setting('app.tenant_id', true)); +CREATE POLICY tenant_iso_api_keys ON api_keys + USING (tenant_id::text = current_setting('app.tenant_id', true)); diff --git a/products/04-lightweight-idp/src/auth/middleware.ts b/products/04-lightweight-idp/src/auth/middleware.ts index 3329232..84510bc 100644 --- a/products/04-lightweight-idp/src/auth/middleware.ts +++ b/products/04-lightweight-idp/src/auth/middleware.ts @@ -1,6 +1,8 @@ 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' }); @@ -16,32 +18,43 @@ export interface AuthPayload { * 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) { +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) => { - // 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; + 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) { - // 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' }); + 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 ')) { @@ -52,7 +65,7 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: any) (req as any).userId = payload.userId; (req as any).userRole = payload.role; return; - } catch (err) { + } catch { return reply.status(401).send({ error: 'Invalid or expired token' }); } } @@ -61,10 +74,6 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: any) }); } -/** - * 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; @@ -77,16 +86,34 @@ export function requireRole(req: FastifyRequest, reply: FastifyReply, minRole: A 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. - */ +// --- 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), @@ -98,18 +125,73 @@ const signupSchema = z.object({ tenant_name: z.string().min(1).max(100), }); -export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: any) { +export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: Pool) { 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' }); + + 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); - // TODO: Create tenant + user, hash password with bcrypt - return reply.status(501).send({ error: 'Signup not yet implemented' }); + + // 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) => { @@ -119,4 +201,24 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool 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/05-aws-cost-anomaly/migrations/002_auth.sql b/products/05-aws-cost-anomaly/migrations/002_auth.sql new file mode 100644 index 0000000..ea6949a --- /dev/null +++ b/products/05-aws-cost-anomaly/migrations/002_auth.sql @@ -0,0 +1,35 @@ +-- dd0c shared auth tables — append to each product's migration +-- Run after 001_init.sql + +-- Users +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('owner', 'admin', 'member', 'viewer')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_users_tenant ON users(tenant_id); + +-- API Keys +CREATE TABLE IF NOT EXISTS api_keys ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + key_prefix TEXT NOT NULL, + key_hash TEXT NOT NULL, + revoked BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(key_prefix) WHERE revoked = false; + +-- RLS +ALTER TABLE users ENABLE ROW LEVEL SECURITY; +ALTER TABLE api_keys ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_iso_users ON users + USING (tenant_id::text = current_setting('app.tenant_id', true)); +CREATE POLICY tenant_iso_api_keys ON api_keys + USING (tenant_id::text = current_setting('app.tenant_id', true)); diff --git a/products/05-aws-cost-anomaly/src/auth/middleware.ts b/products/05-aws-cost-anomaly/src/auth/middleware.ts index 3329232..84510bc 100644 --- a/products/05-aws-cost-anomaly/src/auth/middleware.ts +++ b/products/05-aws-cost-anomaly/src/auth/middleware.ts @@ -1,6 +1,8 @@ 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' }); @@ -16,32 +18,43 @@ export interface AuthPayload { * 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) { +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) => { - // 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; + 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) { - // 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' }); + 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 ')) { @@ -52,7 +65,7 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: any) (req as any).userId = payload.userId; (req as any).userRole = payload.role; return; - } catch (err) { + } catch { return reply.status(401).send({ error: 'Invalid or expired token' }); } } @@ -61,10 +74,6 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: any) }); } -/** - * 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; @@ -77,16 +86,34 @@ export function requireRole(req: FastifyRequest, reply: FastifyReply, minRole: A 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. - */ +// --- 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), @@ -98,18 +125,73 @@ const signupSchema = z.object({ tenant_name: z.string().min(1).max(100), }); -export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: any) { +export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: Pool) { 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' }); + + 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); - // TODO: Create tenant + user, hash password with bcrypt - return reply.status(501).send({ error: 'Signup not yet implemented' }); + + // 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) => { @@ -119,4 +201,24 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool 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/06-runbook-automation/saas/migrations/002_auth.sql b/products/06-runbook-automation/saas/migrations/002_auth.sql new file mode 100644 index 0000000..ea6949a --- /dev/null +++ b/products/06-runbook-automation/saas/migrations/002_auth.sql @@ -0,0 +1,35 @@ +-- dd0c shared auth tables — append to each product's migration +-- Run after 001_init.sql + +-- Users +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('owner', 'admin', 'member', 'viewer')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_users_tenant ON users(tenant_id); + +-- API Keys +CREATE TABLE IF NOT EXISTS api_keys ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + key_prefix TEXT NOT NULL, + key_hash TEXT NOT NULL, + revoked BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(key_prefix) WHERE revoked = false; + +-- RLS +ALTER TABLE users ENABLE ROW LEVEL SECURITY; +ALTER TABLE api_keys ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_iso_users ON users + USING (tenant_id::text = current_setting('app.tenant_id', true)); +CREATE POLICY tenant_iso_api_keys ON api_keys + USING (tenant_id::text = current_setting('app.tenant_id', true)); diff --git a/products/06-runbook-automation/saas/src/auth/middleware.ts b/products/06-runbook-automation/saas/src/auth/middleware.ts index 3329232..84510bc 100644 --- a/products/06-runbook-automation/saas/src/auth/middleware.ts +++ b/products/06-runbook-automation/saas/src/auth/middleware.ts @@ -1,6 +1,8 @@ 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' }); @@ -16,32 +18,43 @@ export interface AuthPayload { * 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) { +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) => { - // 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; + 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) { - // 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' }); + 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 ')) { @@ -52,7 +65,7 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: any) (req as any).userId = payload.userId; (req as any).userRole = payload.role; return; - } catch (err) { + } catch { return reply.status(401).send({ error: 'Invalid or expired token' }); } } @@ -61,10 +74,6 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: any) }); } -/** - * 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; @@ -77,16 +86,34 @@ export function requireRole(req: FastifyRequest, reply: FastifyReply, minRole: A 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. - */ +// --- 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), @@ -98,18 +125,73 @@ const signupSchema = z.object({ tenant_name: z.string().min(1).max(100), }); -export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: any) { +export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: Pool) { 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' }); + + 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); - // TODO: Create tenant + user, hash password with bcrypt - return reply.status(501).send({ error: 'Signup not yet implemented' }); + + // 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) => { @@ -119,4 +201,24 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool 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/shared/002_auth.sql b/products/shared/002_auth.sql new file mode 100644 index 0000000..ea6949a --- /dev/null +++ b/products/shared/002_auth.sql @@ -0,0 +1,35 @@ +-- dd0c shared auth tables — append to each product's migration +-- Run after 001_init.sql + +-- Users +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('owner', 'admin', 'member', 'viewer')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_users_tenant ON users(tenant_id); + +-- API Keys +CREATE TABLE IF NOT EXISTS api_keys ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + key_prefix TEXT NOT NULL, + key_hash TEXT NOT NULL, + revoked BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(key_prefix) WHERE revoked = false; + +-- RLS +ALTER TABLE users ENABLE ROW LEVEL SECURITY; +ALTER TABLE api_keys ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_iso_users ON users + USING (tenant_id::text = current_setting('app.tenant_id', true)); +CREATE POLICY tenant_iso_api_keys ON api_keys + USING (tenant_id::text = current_setting('app.tenant_id', true)); diff --git a/products/shared/auth.ts b/products/shared/auth.ts index 3329232..84510bc 100644 --- a/products/shared/auth.ts +++ b/products/shared/auth.ts @@ -1,6 +1,8 @@ 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' }); @@ -16,32 +18,43 @@ export interface AuthPayload { * 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) { +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) => { - // 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; + 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) { - // 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' }); + 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 ')) { @@ -52,7 +65,7 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: any) (req as any).userId = payload.userId; (req as any).userRole = payload.role; return; - } catch (err) { + } catch { return reply.status(401).send({ error: 'Invalid or expired token' }); } } @@ -61,10 +74,6 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: any) }); } -/** - * 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; @@ -77,16 +86,34 @@ export function requireRole(req: FastifyRequest, reply: FastifyReply, minRole: A 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. - */ +// --- 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), @@ -98,18 +125,73 @@ const signupSchema = z.object({ tenant_name: z.string().min(1).max(100), }); -export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: any) { +export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: Pool) { 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' }); + + 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); - // TODO: Create tenant + user, hash password with bcrypt - return reply.status(501).send({ error: 'Signup not yet implemented' }); + + // 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) => { @@ -119,4 +201,24 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool 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 }); + }); }