commit a9de3e4331d8edc5d84fdb55e6bbbed194b58456 Author: rckrdmrd Date: Fri Jan 16 08:33:07 2026 -0600 Migración desde trading-platform/apps/mcp-auth - Estándar multi-repo v2 Co-Authored-By: Claude Opus 4.5 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5c57ff4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,62 @@ +# ============================================================================= +# MCP AUTH SERVER DOCKERFILE +# ============================================================================= +# Authentication service with JWT, Sessions, Password Reset +# Port: 3095 +# ============================================================================= + +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Install build dependencies for bcrypt native module +RUN apk add --no-cache python3 make g++ + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci --only=production=false + +# Copy source +COPY tsconfig.json ./ +COPY src ./src + +# Build +RUN npm run build + +# Prune dev dependencies +RUN npm prune --production + +# ============================================================================= +# Production stage +# ============================================================================= +FROM node:20-alpine AS production + +WORKDIR /app + +# Create non-root user +RUN addgroup -g 1001 -S nodejs && \ + adduser -S auth -u 1001 + +# Copy built artifacts +COPY --from=builder --chown=auth:nodejs /app/node_modules ./node_modules +COPY --from=builder --chown=auth:nodejs /app/dist ./dist +COPY --from=builder --chown=auth:nodejs /app/package.json ./ + +# Create logs directory +RUN mkdir -p logs && chown auth:nodejs logs + +# Switch to non-root user +USER auth + +# Expose port +EXPOSE 3095 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3095/health || exit 1 + +# Start server +CMD ["node", "dist/index.js"] diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..7dd0e64 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,20 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: ['**/*.test.ts'], + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts', + '!src/index.ts', + ], + coverageDirectory: 'coverage', + coverageReporters: ['text', 'lcov', 'html'], + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + }, + setupFilesAfterEnv: ['/src/__tests__/setup.ts'], + testTimeout: 30000, + verbose: true, +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..24fc755 --- /dev/null +++ b/package.json @@ -0,0 +1,55 @@ +{ + "name": "@trading-platform/mcp-auth", + "version": "1.0.0", + "description": "MCP Server for Authentication - JWT, Sessions, Password Reset", + "main": "dist/index.js", + "scripts": { + "dev": "ts-node-dev --respawn --transpile-only src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "lint": "eslint src --ext .ts", + "typecheck": "tsc --noEmit", + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", + "express": "^4.18.2", + "pg": "^8.11.3", + "zod": "^3.22.4", + "winston": "^3.11.0", + "uuid": "^9.0.1", + "dotenv": "^16.3.1", + "helmet": "^7.1.0", + "cors": "^2.8.5", + "bcrypt": "^5.1.1", + "jsonwebtoken": "^9.0.2", + "crypto": "^1.0.1" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.10.0", + "@types/pg": "^8.10.9", + "@types/uuid": "^9.0.7", + "@types/cors": "^2.8.17", + "@types/bcrypt": "^5.0.2", + "@types/jsonwebtoken": "^9.0.5", + "typescript": "^5.3.2", + "ts-node-dev": "^2.0.0", + "jest": "^29.7.0", + "@types/jest": "^29.5.11", + "ts-jest": "^29.1.1", + "supertest": "^6.3.3", + "@types/supertest": "^2.0.16", + "eslint": "^8.55.0", + "@typescript-eslint/eslint-plugin": "^6.13.0", + "@typescript-eslint/parser": "^6.13.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "author": "Trading Platform Team", + "license": "UNLICENSED", + "private": true +} diff --git a/src/__tests__/auth.api.test.ts b/src/__tests__/auth.api.test.ts new file mode 100644 index 0000000..16cab53 --- /dev/null +++ b/src/__tests__/auth.api.test.ts @@ -0,0 +1,317 @@ +/** + * Auth API Endpoint Tests + */ + +import request from 'supertest'; +import express, { Express } from 'express'; +import jwt from 'jsonwebtoken'; +import { jwtConfig } from '../config'; + +// Create a minimal test app +function createTestApp(): Express { + const app = express(); + app.use(express.json()); + + // Mock health endpoint + app.get('/health', (_req, res) => { + res.json({ status: 'healthy', service: 'mcp-auth' }); + }); + + // Mock verify endpoint + app.post('/api/auth/verify', (req, res) => { + const { token } = req.body; + + if (!token) { + return res.status(400).json({ error: 'Token required', code: 'VALIDATION_ERROR' }); + } + + try { + const decoded = jwt.verify(token, jwtConfig.secret); + res.json(decoded); + } catch (error) { + if (error instanceof jwt.TokenExpiredError) { + return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' }); + } + return res.status(401).json({ error: 'Invalid token', code: 'INVALID_TOKEN' }); + } + }); + + // Mock refresh endpoint + app.post('/api/auth/refresh', (req, res) => { + const { refreshToken } = req.body; + + if (!refreshToken) { + return res.status(400).json({ error: 'Refresh token required', code: 'VALIDATION_ERROR' }); + } + + try { + const decoded = jwt.verify(refreshToken, jwtConfig.secret) as { sub: string; email: string; tenantId: string; isOwner: boolean }; + + const newAccessToken = jwt.sign( + { + sub: decoded.sub, + email: decoded.email, + tenantId: decoded.tenantId, + isOwner: decoded.isOwner, + }, + jwtConfig.secret, + { expiresIn: jwtConfig.accessTokenExpiry } + ); + + res.json({ accessToken: newAccessToken, expiresIn: 900 }); + } catch (error) { + return res.status(401).json({ error: 'Invalid refresh token', code: 'INVALID_TOKEN' }); + } + }); + + return app; +} + +describe('Auth API Endpoints', () => { + let app: Express; + + beforeAll(() => { + app = createTestApp(); + }); + + describe('GET /health', () => { + it('should return healthy status', async () => { + const response = await request(app).get('/health'); + + expect(response.status).toBe(200); + expect(response.body.status).toBe('healthy'); + expect(response.body.service).toBe('mcp-auth'); + }); + }); + + describe('POST /api/auth/verify', () => { + it('should verify valid token', async () => { + const token = jwt.sign( + { sub: 'user-123', email: 'test@example.com', tenantId: 'tenant-456', isOwner: true }, + jwtConfig.secret, + { expiresIn: '1h' } + ); + + const response = await request(app) + .post('/api/auth/verify') + .send({ token }); + + expect(response.status).toBe(200); + expect(response.body.sub).toBe('user-123'); + expect(response.body.email).toBe('test@example.com'); + expect(response.body.tenantId).toBe('tenant-456'); + }); + + it('should reject request without token', async () => { + const response = await request(app) + .post('/api/auth/verify') + .send({}); + + expect(response.status).toBe(400); + expect(response.body.code).toBe('VALIDATION_ERROR'); + }); + + it('should reject invalid token', async () => { + const response = await request(app) + .post('/api/auth/verify') + .send({ token: 'invalid-token' }); + + expect(response.status).toBe(401); + expect(response.body.code).toBe('INVALID_TOKEN'); + }); + + it('should reject expired token', async () => { + const token = jwt.sign( + { sub: 'user-123', email: 'test@example.com', tenantId: 'tenant-456', isOwner: true }, + jwtConfig.secret, + { expiresIn: '-1s' } + ); + + const response = await request(app) + .post('/api/auth/verify') + .send({ token }); + + expect(response.status).toBe(401); + expect(response.body.code).toBe('TOKEN_EXPIRED'); + }); + }); + + describe('POST /api/auth/refresh', () => { + it('should refresh valid token', async () => { + const refreshToken = jwt.sign( + { sub: 'user-123', email: 'test@example.com', tenantId: 'tenant-456', isOwner: true }, + jwtConfig.secret, + { expiresIn: '7d' } + ); + + const response = await request(app) + .post('/api/auth/refresh') + .send({ refreshToken }); + + expect(response.status).toBe(200); + expect(response.body.accessToken).toBeDefined(); + expect(response.body.expiresIn).toBe(900); + + // Verify the new token is valid + const decoded = jwt.verify(response.body.accessToken, jwtConfig.secret) as { sub: string }; + expect(decoded.sub).toBe('user-123'); + }); + + it('should reject request without refresh token', async () => { + const response = await request(app) + .post('/api/auth/refresh') + .send({}); + + expect(response.status).toBe(400); + expect(response.body.code).toBe('VALIDATION_ERROR'); + }); + + it('should reject invalid refresh token', async () => { + const response = await request(app) + .post('/api/auth/refresh') + .send({ refreshToken: 'invalid-token' }); + + expect(response.status).toBe(401); + expect(response.body.code).toBe('INVALID_TOKEN'); + }); + + it('should reject expired refresh token', async () => { + const refreshToken = jwt.sign( + { sub: 'user-123', email: 'test@example.com', tenantId: 'tenant-456', isOwner: true }, + jwtConfig.secret, + { expiresIn: '-1s' } + ); + + const response = await request(app) + .post('/api/auth/refresh') + .send({ refreshToken }); + + expect(response.status).toBe(401); + }); + }); +}); + +describe('Auth Middleware Simulation', () => { + let app: Express; + + beforeAll(() => { + app = express(); + app.use(express.json()); + + // Simulated auth middleware + const authMiddleware = (req: express.Request, res: express.Response, next: express.NextFunction) => { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'No token provided', code: 'MISSING_TOKEN' }); + } + + const token = authHeader.substring(7); + + try { + const decoded = jwt.verify(token, jwtConfig.secret) as { sub: string; tenantId: string }; + (req as any).userId = decoded.sub; + (req as any).tenantId = decoded.tenantId; + next(); + } catch (error) { + return res.status(401).json({ error: 'Invalid token', code: 'INVALID_TOKEN' }); + } + }; + + // Protected endpoint + app.get('/api/protected', authMiddleware, (req, res) => { + res.json({ + message: 'Access granted', + userId: (req as any).userId, + tenantId: (req as any).tenantId, + }); + }); + }); + + it('should allow access with valid token', async () => { + const token = jwt.sign( + { sub: 'user-123', tenantId: 'tenant-456' }, + jwtConfig.secret, + { expiresIn: '1h' } + ); + + const response = await request(app) + .get('/api/protected') + .set('Authorization', `Bearer ${token}`); + + expect(response.status).toBe(200); + expect(response.body.message).toBe('Access granted'); + expect(response.body.userId).toBe('user-123'); + expect(response.body.tenantId).toBe('tenant-456'); + }); + + it('should reject request without authorization header', async () => { + const response = await request(app).get('/api/protected'); + + expect(response.status).toBe(401); + expect(response.body.code).toBe('MISSING_TOKEN'); + }); + + it('should reject request with invalid authorization format', async () => { + const response = await request(app) + .get('/api/protected') + .set('Authorization', 'InvalidFormat token'); + + expect(response.status).toBe(401); + expect(response.body.code).toBe('MISSING_TOKEN'); + }); + + it('should reject request with invalid token', async () => { + const response = await request(app) + .get('/api/protected') + .set('Authorization', 'Bearer invalid-token'); + + expect(response.status).toBe(401); + expect(response.body.code).toBe('INVALID_TOKEN'); + }); + + it('should reject request with expired token', async () => { + const token = jwt.sign( + { sub: 'user-123', tenantId: 'tenant-456' }, + jwtConfig.secret, + { expiresIn: '-1s' } + ); + + const response = await request(app) + .get('/api/protected') + .set('Authorization', `Bearer ${token}`); + + expect(response.status).toBe(401); + }); +}); + +describe('Input Validation', () => { + it('should validate email format', () => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + + expect(emailRegex.test('valid@email.com')).toBe(true); + expect(emailRegex.test('user.name@domain.co.uk')).toBe(true); + expect(emailRegex.test('invalid-email')).toBe(false); + expect(emailRegex.test('@nodomain.com')).toBe(false); + expect(emailRegex.test('noat.com')).toBe(false); + }); + + it('should validate tenant slug format', () => { + const slugRegex = /^[a-z0-9-]+$/; + + expect(slugRegex.test('my-tenant')).toBe(true); + expect(slugRegex.test('tenant123')).toBe(true); + expect(slugRegex.test('my-tenant-123')).toBe(true); + expect(slugRegex.test('Invalid')).toBe(false); + expect(slugRegex.test('has spaces')).toBe(false); + expect(slugRegex.test('special@chars')).toBe(false); + }); + + it('should validate UUID format', () => { + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + + expect(uuidRegex.test('123e4567-e89b-12d3-a456-426614174000')).toBe(true); + expect(uuidRegex.test('not-a-uuid')).toBe(false); + expect(uuidRegex.test('123')).toBe(false); + }); +}); diff --git a/src/__tests__/auth.service.test.ts b/src/__tests__/auth.service.test.ts new file mode 100644 index 0000000..96eda15 --- /dev/null +++ b/src/__tests__/auth.service.test.ts @@ -0,0 +1,261 @@ +/** + * Auth Service Tests + */ + +import bcrypt from 'bcrypt'; +import jwt from 'jsonwebtoken'; +import { passwordConfig, jwtConfig } from '../config'; +import { + WeakPasswordError, + InvalidCredentialsError, + UserAlreadyExistsError, + InvalidTokenError, +} from '../utils/errors'; + +// Test password validation logic (extracted for testing) +function validatePassword(password: string): string[] { + const issues: string[] = []; + + if (password.length < passwordConfig.minLength) { + issues.push(`Password must be at least ${passwordConfig.minLength} characters`); + } + + if (passwordConfig.requireUppercase && !/[A-Z]/.test(password)) { + issues.push('Password must contain at least one uppercase letter'); + } + + if (passwordConfig.requireLowercase && !/[a-z]/.test(password)) { + issues.push('Password must contain at least one lowercase letter'); + } + + if (passwordConfig.requireNumbers && !/\d/.test(password)) { + issues.push('Password must contain at least one number'); + } + + if (passwordConfig.requireSpecialChars && !/[!@#$%^&*(),.?":{}|<>]/.test(password)) { + issues.push('Password must contain at least one special character'); + } + + return issues; +} + +describe('Password Validation', () => { + it('should accept a valid password', () => { + const issues = validatePassword('SecurePass123'); + expect(issues).toHaveLength(0); + }); + + it('should reject password shorter than minimum length', () => { + const issues = validatePassword('Abc1'); + expect(issues).toContain(`Password must be at least ${passwordConfig.minLength} characters`); + }); + + it('should reject password without uppercase', () => { + const issues = validatePassword('securepass123'); + expect(issues).toContain('Password must contain at least one uppercase letter'); + }); + + it('should reject password without lowercase', () => { + const issues = validatePassword('SECUREPASS123'); + expect(issues).toContain('Password must contain at least one lowercase letter'); + }); + + it('should reject password without numbers', () => { + const issues = validatePassword('SecurePassword'); + expect(issues).toContain('Password must contain at least one number'); + }); + + it('should return multiple issues for very weak password', () => { + const issues = validatePassword('weak'); + expect(issues.length).toBeGreaterThan(1); + }); +}); + +describe('Password Hashing', () => { + it('should hash password correctly', async () => { + const password = 'SecurePass123'; + const hash = await bcrypt.hash(password, passwordConfig.saltRounds); + + expect(hash).not.toBe(password); + expect(hash.length).toBeGreaterThan(0); + }); + + it('should verify correct password', async () => { + const password = 'SecurePass123'; + const hash = await bcrypt.hash(password, passwordConfig.saltRounds); + + const isValid = await bcrypt.compare(password, hash); + expect(isValid).toBe(true); + }); + + it('should reject incorrect password', async () => { + const password = 'SecurePass123'; + const wrongPassword = 'WrongPass123'; + const hash = await bcrypt.hash(password, passwordConfig.saltRounds); + + const isValid = await bcrypt.compare(wrongPassword, hash); + expect(isValid).toBe(false); + }); + + it('should generate different hashes for same password', async () => { + const password = 'SecurePass123'; + const hash1 = await bcrypt.hash(password, passwordConfig.saltRounds); + const hash2 = await bcrypt.hash(password, passwordConfig.saltRounds); + + expect(hash1).not.toBe(hash2); + }); +}); + +describe('JWT Token Generation', () => { + const testPayload = { + sub: 'user-123', + email: 'test@example.com', + tenantId: 'tenant-456', + isOwner: true, + }; + + it('should generate valid access token', () => { + const token = jwt.sign(testPayload, jwtConfig.secret, { + expiresIn: jwtConfig.accessTokenExpiry, + issuer: jwtConfig.issuer, + }); + + expect(token).toBeDefined(); + expect(typeof token).toBe('string'); + expect(token.split('.')).toHaveLength(3); // JWT has 3 parts + }); + + it('should generate valid refresh token', () => { + const token = jwt.sign(testPayload, jwtConfig.secret, { + expiresIn: jwtConfig.refreshTokenExpiry, + issuer: jwtConfig.issuer, + }); + + expect(token).toBeDefined(); + expect(typeof token).toBe('string'); + }); + + it('should decode token correctly', () => { + const token = jwt.sign(testPayload, jwtConfig.secret, { + expiresIn: jwtConfig.accessTokenExpiry, + }); + + const decoded = jwt.verify(token, jwtConfig.secret) as typeof testPayload & { iat: number; exp: number }; + + expect(decoded.sub).toBe(testPayload.sub); + expect(decoded.email).toBe(testPayload.email); + expect(decoded.tenantId).toBe(testPayload.tenantId); + expect(decoded.isOwner).toBe(testPayload.isOwner); + expect(decoded.iat).toBeDefined(); + expect(decoded.exp).toBeDefined(); + }); + + it('should reject token with wrong secret', () => { + const token = jwt.sign(testPayload, jwtConfig.secret); + + expect(() => { + jwt.verify(token, 'wrong-secret'); + }).toThrow(); + }); + + it('should reject expired token', () => { + const token = jwt.sign(testPayload, jwtConfig.secret, { + expiresIn: '-1s', // Already expired + }); + + expect(() => { + jwt.verify(token, jwtConfig.secret); + }).toThrow(jwt.TokenExpiredError); + }); + + it('should reject malformed token', () => { + expect(() => { + jwt.verify('not-a-valid-token', jwtConfig.secret); + }).toThrow(jwt.JsonWebTokenError); + }); +}); + +describe('Auth Error Classes', () => { + it('should create WeakPasswordError with requirements', () => { + const requirements = ['At least 8 characters', 'One uppercase letter']; + const error = new WeakPasswordError(requirements); + + expect(error.message).toBe('Password does not meet requirements'); + expect(error.code).toBe('WEAK_PASSWORD'); + expect(error.statusCode).toBe(400); + expect(error.details).toEqual({ requirements }); + }); + + it('should create InvalidCredentialsError', () => { + const error = new InvalidCredentialsError(); + + expect(error.message).toBe('Invalid email or password'); + expect(error.code).toBe('INVALID_CREDENTIALS'); + expect(error.statusCode).toBe(401); + }); + + it('should create UserAlreadyExistsError', () => { + const error = new UserAlreadyExistsError('test@example.com'); + + expect(error.message).toContain('test@example.com'); + expect(error.code).toBe('USER_ALREADY_EXISTS'); + expect(error.statusCode).toBe(409); + }); + + it('should create InvalidTokenError', () => { + const error = new InvalidTokenError(); + + expect(error.message).toBe('Invalid or expired token'); + expect(error.code).toBe('INVALID_TOKEN'); + expect(error.statusCode).toBe(401); + }); + + it('should serialize error to JSON', () => { + const error = new InvalidCredentialsError(); + const json = error.toJSON(); + + expect(json).toEqual({ + error: 'Invalid email or password', + code: 'INVALID_CREDENTIALS', + details: undefined, + }); + }); +}); + +describe('Token Expiry Parsing', () => { + function parseExpiry(expiry: string): number { + const match = expiry.match(/^(\d+)([smhd])$/); + if (!match) return 900; + + const value = parseInt(match[1], 10); + const unit = match[2]; + + switch (unit) { + case 's': return value; + case 'm': return value * 60; + case 'h': return value * 60 * 60; + case 'd': return value * 24 * 60 * 60; + default: return 900; + } + } + + it('should parse seconds', () => { + expect(parseExpiry('30s')).toBe(30); + }); + + it('should parse minutes', () => { + expect(parseExpiry('15m')).toBe(900); + }); + + it('should parse hours', () => { + expect(parseExpiry('1h')).toBe(3600); + }); + + it('should parse days', () => { + expect(parseExpiry('7d')).toBe(604800); + }); + + it('should return default for invalid format', () => { + expect(parseExpiry('invalid')).toBe(900); + }); +}); diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts new file mode 100644 index 0000000..dc9b5a1 --- /dev/null +++ b/src/__tests__/setup.ts @@ -0,0 +1,25 @@ +/** + * Jest Test Setup + */ + +// Set test environment variables +process.env.NODE_ENV = 'test'; +process.env.JWT_SECRET = 'test-jwt-secret-for-testing-purposes-only-min-32-chars'; +process.env.DB_HOST = 'localhost'; +process.env.DB_PORT = '5432'; +process.env.DB_NAME = 'trading_platform_test'; +process.env.DB_USER = 'trading_user'; +process.env.DB_PASSWORD = 'trading_dev_2025'; + +// Increase timeout for database operations +jest.setTimeout(30000); + +// Mock winston logger to reduce noise in tests +jest.mock('../utils/logger', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..e93a06a --- /dev/null +++ b/src/config.ts @@ -0,0 +1,95 @@ +/** + * Configuration for MCP Auth Server + */ + +import { Pool, PoolConfig } from 'pg'; +import dotenv from 'dotenv'; + +dotenv.config(); + +// Server configuration +export const serverConfig = { + port: parseInt(process.env.AUTH_PORT || '3095', 10), + nodeEnv: process.env.NODE_ENV || 'development', +}; + +// JWT configuration +export const jwtConfig = { + secret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production-min-256-bits', + accessTokenExpiry: process.env.JWT_EXPIRES_IN || '15m', + refreshTokenExpiry: process.env.JWT_REFRESH_EXPIRES_IN || '7d', + issuer: process.env.JWT_ISSUER || 'trading-platform', +}; + +// Password configuration +export const passwordConfig = { + saltRounds: 10, + minLength: 8, + requireUppercase: true, + requireLowercase: true, + requireNumbers: true, + requireSpecialChars: false, +}; + +// Security configuration +export const securityConfig = { + maxLoginAttempts: 5, + lockoutDuration: 15 * 60 * 1000, // 15 minutes in ms + sessionDuration: 7 * 24 * 60 * 60 * 1000, // 7 days in ms +}; + +// Database configuration +const poolConfig: PoolConfig = { + host: process.env.DB_HOST || 'localhost', + port: parseInt(process.env.DB_PORT || '5432', 10), + database: process.env.DB_NAME || 'trading_platform', + user: process.env.DB_USER || 'trading_user', + password: process.env.DB_PASSWORD || 'trading_dev_2025', + max: parseInt(process.env.DB_POOL_SIZE || '20', 10), + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 5000, +}; + +let pool: Pool | null = null; + +export function getPool(): Pool { + if (!pool) { + pool = new Pool(poolConfig); + + pool.on('error', (err) => { + console.error('Unexpected database error:', err); + }); + } + return pool; +} + +export async function closePool(): Promise { + if (pool) { + await pool.end(); + pool = null; + } +} + +// Set tenant context for RLS +export async function setTenantContext( + client: ReturnType extends Promise ? R : never, + tenantId: string +): Promise { + await client.query(`SET app.current_tenant_id = $1`, [tenantId]); +} + +// Validate required environment variables in production +export function validateConfig(): void { + if (serverConfig.nodeEnv === 'production') { + const required = ['JWT_SECRET', 'DB_PASSWORD']; + const missing = required.filter((key) => !process.env[key]); + + if (missing.length > 0) { + throw new Error(`Missing required environment variables: ${missing.join(', ')}`); + } + + if (jwtConfig.secret.length < 32) { + throw new Error('JWT_SECRET must be at least 32 characters in production'); + } + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..5d05668 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,509 @@ +/** + * MCP Auth Server - Main Entry Point + * + * Authentication MCP server for Trading Platform SaaS + * Provides JWT-based authentication with multi-tenancy support + */ + +import express, { Request, Response, NextFunction } from 'express'; +import cors from 'cors'; +import helmet from 'helmet'; +import { serverConfig, validateConfig, closePool } from './config'; +import { authTools, handleAuthTool, rbacTools, handleRbacTool, teamTools, handleTeamTool } from './tools'; +import { logger } from './utils/logger'; + +// Combine all tools +const allTools = [...authTools, ...rbacTools, ...teamTools]; + +// Validate configuration on startup +validateConfig(); + +const app = express(); + +// Security middleware +app.use(helmet()); +app.use(cors({ + origin: process.env.CORS_ORIGIN || '*', + credentials: true, +})); + +// Body parsing +app.use(express.json({ limit: '1mb' })); + +// Request logging +app.use((req: Request, _res: Response, next: NextFunction) => { + logger.debug('Incoming request', { + method: req.method, + path: req.path, + ip: req.ip, + }); + next(); +}); + +// Health check endpoint +app.get('/health', (_req: Request, res: Response) => { + res.json({ + status: 'healthy', + service: 'mcp-auth', + timestamp: new Date().toISOString(), + }); +}); + +// MCP Tools listing endpoint +app.get('/mcp/tools', (_req: Request, res: Response) => { + res.json({ + tools: allTools, + }); +}); + +// MCP Tool execution endpoint +app.post('/mcp/tools/:toolName', async (req: Request, res: Response) => { + const { toolName } = req.params; + const args = req.body; + + logger.info('Tool execution request', { toolName }); + + try { + // Route to appropriate handler based on tool prefix + let result; + if (toolName.startsWith('rbac_')) { + result = await handleRbacTool(toolName, args); + } else if (toolName.startsWith('team_')) { + result = await handleTeamTool(toolName, args); + } else { + result = await handleAuthTool(toolName, args); + } + + if (result.isError) { + // Determine appropriate status code from error content + const errorContent = result.content[0]?.text || '{}'; + const errorData = JSON.parse(errorContent); + + let statusCode = 400; + if (errorData.code === 'INVALID_CREDENTIALS' || errorData.code === 'INVALID_TOKEN') { + statusCode = 401; + } else if (errorData.code === 'USER_NOT_FOUND' || errorData.code === 'TENANT_NOT_FOUND') { + statusCode = 404; + } else if (errorData.code === 'USER_ALREADY_EXISTS') { + statusCode = 409; + } else if (errorData.code === 'ACCOUNT_LOCKED') { + statusCode = 423; + } else if (errorData.code === 'ACCOUNT_SUSPENDED' || errorData.code === 'EMAIL_NOT_VERIFIED') { + statusCode = 403; + } + + res.status(statusCode).json(errorData); + } else { + const successContent = result.content[0]?.text || '{}'; + res.json(JSON.parse(successContent)); + } + } catch (error) { + logger.error('Unhandled tool execution error', { toolName, error }); + res.status(500).json({ + error: 'Internal server error', + code: 'INTERNAL_ERROR', + }); + } +}); + +// REST API endpoints for direct access (optional, for frontend integration) +const apiRouter = express.Router(); + +// Register +apiRouter.post('/register', async (req: Request, res: Response) => { + const result = await handleAuthTool('auth_register', req.body); + sendToolResult(res, result); +}); + +// Login +apiRouter.post('/login', async (req: Request, res: Response) => { + // Add IP address from request + const args = { + ...req.body, + deviceInfo: { + ...req.body.deviceInfo, + ipAddress: req.ip, + userAgent: req.get('user-agent'), + }, + }; + const result = await handleAuthTool('auth_login', args); + sendToolResult(res, result); +}); + +// Refresh token +apiRouter.post('/refresh', async (req: Request, res: Response) => { + const result = await handleAuthTool('auth_refresh', req.body); + sendToolResult(res, result); +}); + +// Logout +apiRouter.post('/logout', async (req: Request, res: Response) => { + const result = await handleAuthTool('auth_logout', req.body); + sendToolResult(res, result); +}); + +// Request password reset +apiRouter.post('/password-reset/request', async (req: Request, res: Response) => { + const result = await handleAuthTool('auth_request_password_reset', req.body); + sendToolResult(res, result); +}); + +// Reset password +apiRouter.post('/password-reset', async (req: Request, res: Response) => { + const result = await handleAuthTool('auth_reset_password', req.body); + sendToolResult(res, result); +}); + +// Change password +apiRouter.post('/password/change', async (req: Request, res: Response) => { + const result = await handleAuthTool('auth_change_password', req.body); + sendToolResult(res, result); +}); + +// Verify token +apiRouter.post('/verify', async (req: Request, res: Response) => { + const result = await handleAuthTool('auth_verify_token', req.body); + sendToolResult(res, result); +}); + +// Get sessions +apiRouter.get('/sessions', async (req: Request, res: Response) => { + const { userId, tenantId } = req.query; + const result = await handleAuthTool('auth_get_sessions', { + userId: userId as string, + tenantId: tenantId as string, + }); + sendToolResult(res, result); +}); + +app.use('/api/auth', apiRouter); + +// RBAC REST API endpoints +const rbacRouter = express.Router(); + +// List roles +rbacRouter.get('/roles', async (req: Request, res: Response) => { + const { tenantId, includeSystem, includeInactive } = req.query; + const result = await handleRbacTool('rbac_list_roles', { + tenantId: tenantId as string, + includeSystem: includeSystem === 'true', + includeInactive: includeInactive === 'true', + }); + sendToolResult(res, result); +}); + +// Get role +rbacRouter.get('/roles/:roleId', async (req: Request, res: Response) => { + const { roleId } = req.params; + const { tenantId } = req.query; + const result = await handleRbacTool('rbac_get_role', { + roleId, + tenantId: tenantId as string, + }); + sendToolResult(res, result); +}); + +// Create role +rbacRouter.post('/roles', async (req: Request, res: Response) => { + const result = await handleRbacTool('rbac_create_role', req.body); + sendToolResult(res, result); +}); + +// Update role +rbacRouter.put('/roles/:roleId', async (req: Request, res: Response) => { + const { roleId } = req.params; + const result = await handleRbacTool('rbac_update_role', { + roleId, + ...req.body, + }); + sendToolResult(res, result); +}); + +// Delete role +rbacRouter.delete('/roles/:roleId', async (req: Request, res: Response) => { + const { roleId } = req.params; + const { tenantId } = req.query; + const result = await handleRbacTool('rbac_delete_role', { + roleId, + tenantId: tenantId as string, + }); + sendToolResult(res, result); +}); + +// List permissions +rbacRouter.get('/permissions', async (req: Request, res: Response) => { + const { module, category } = req.query; + const result = await handleRbacTool('rbac_list_permissions', { + module: module as string, + category: category as string, + }); + sendToolResult(res, result); +}); + +// Assign permissions to role +rbacRouter.post('/roles/:roleId/permissions', async (req: Request, res: Response) => { + const { roleId } = req.params; + const result = await handleRbacTool('rbac_assign_permissions', { + roleId, + ...req.body, + }); + sendToolResult(res, result); +}); + +// Get user permissions +rbacRouter.get('/users/:userId/permissions', async (req: Request, res: Response) => { + const { userId } = req.params; + const { tenantId } = req.query; + const result = await handleRbacTool('rbac_get_user_permissions', { + userId, + tenantId: tenantId as string, + }); + sendToolResult(res, result); +}); + +// Check user permission +rbacRouter.get('/users/:userId/check-permission', async (req: Request, res: Response) => { + const { userId } = req.params; + const { tenantId, permissionCode } = req.query; + const result = await handleRbacTool('rbac_check_permission', { + userId, + tenantId: tenantId as string, + permissionCode: permissionCode as string, + }); + sendToolResult(res, result); +}); + +// Assign role to user +rbacRouter.post('/users/:userId/roles', async (req: Request, res: Response) => { + const { userId } = req.params; + const result = await handleRbacTool('rbac_assign_role', { + userId, + ...req.body, + }); + sendToolResult(res, result); +}); + +// Revoke role from user +rbacRouter.delete('/users/:userId/roles/:roleId', async (req: Request, res: Response) => { + const { userId, roleId } = req.params; + const { tenantId, revokedBy } = req.query; + const result = await handleRbacTool('rbac_revoke_role', { + userId, + roleId, + tenantId: tenantId as string, + revokedBy: revokedBy as string, + }); + sendToolResult(res, result); +}); + +// Initialize tenant roles +rbacRouter.post('/tenants/:tenantId/initialize', async (req: Request, res: Response) => { + const { tenantId } = req.params; + const { ownerId } = req.body; + const result = await handleRbacTool('rbac_initialize_tenant', { + tenantId, + ownerId, + }); + sendToolResult(res, result); +}); + +app.use('/api/rbac', rbacRouter); + +// Team REST API endpoints +const teamRouter = express.Router(); + +// List team members +teamRouter.get('/members', async (req: Request, res: Response) => { + const { tenantId, status, memberRole, department } = req.query; + const result = await handleTeamTool('team_list_members', { + tenantId: tenantId as string, + status: status as string, + memberRole: memberRole as string, + department: department as string, + }); + sendToolResult(res, result); +}); + +// Get team member +teamRouter.get('/members/:userId', async (req: Request, res: Response) => { + const { userId } = req.params; + const { tenantId } = req.query; + const result = await handleTeamTool('team_get_member', { + tenantId: tenantId as string, + userId, + }); + sendToolResult(res, result); +}); + +// Add team member +teamRouter.post('/members', async (req: Request, res: Response) => { + const result = await handleTeamTool('team_add_member', req.body); + sendToolResult(res, result); +}); + +// Update team member +teamRouter.put('/members/:userId', async (req: Request, res: Response) => { + const { userId } = req.params; + const result = await handleTeamTool('team_update_member', { + userId, + ...req.body, + }); + sendToolResult(res, result); +}); + +// Remove team member +teamRouter.delete('/members/:userId', async (req: Request, res: Response) => { + const { userId } = req.params; + const { tenantId, removedBy } = req.query; + const result = await handleTeamTool('team_remove_member', { + tenantId: tenantId as string, + userId, + removedBy: removedBy as string, + }); + sendToolResult(res, result); +}); + +// Get team member count +teamRouter.get('/count', async (req: Request, res: Response) => { + const { tenantId } = req.query; + const result = await handleTeamTool('team_get_member_count', { + tenantId: tenantId as string, + }); + sendToolResult(res, result); +}); + +// List invitations +teamRouter.get('/invitations', async (req: Request, res: Response) => { + const { tenantId, status } = req.query; + const result = await handleTeamTool('team_list_invitations', { + tenantId: tenantId as string, + status: status as string, + }); + sendToolResult(res, result); +}); + +// Get invitation +teamRouter.get('/invitations/:invitationId', async (req: Request, res: Response) => { + const { invitationId } = req.params; + const result = await handleTeamTool('team_get_invitation', { invitationId }); + sendToolResult(res, result); +}); + +// Get invitation by token (public endpoint) +teamRouter.get('/invitations/token/:token', async (req: Request, res: Response) => { + const { token } = req.params; + const result = await handleTeamTool('team_get_invitation_by_token', { token }); + sendToolResult(res, result); +}); + +// Create invitation +teamRouter.post('/invitations', async (req: Request, res: Response) => { + const result = await handleTeamTool('team_create_invitation', req.body); + sendToolResult(res, result); +}); + +// Accept invitation +teamRouter.post('/invitations/accept', async (req: Request, res: Response) => { + const result = await handleTeamTool('team_accept_invitation', req.body); + sendToolResult(res, result); +}); + +// Resend invitation +teamRouter.post('/invitations/:invitationId/resend', async (req: Request, res: Response) => { + const { invitationId } = req.params; + const { resentBy } = req.body; + const result = await handleTeamTool('team_resend_invitation', { + invitationId, + resentBy, + }); + sendToolResult(res, result); +}); + +// Revoke invitation +teamRouter.delete('/invitations/:invitationId', async (req: Request, res: Response) => { + const { invitationId } = req.params; + const { revokedBy } = req.query; + const result = await handleTeamTool('team_revoke_invitation', { + invitationId, + revokedBy: revokedBy as string, + }); + sendToolResult(res, result); +}); + +app.use('/api/team', teamRouter); + +// Helper function to send tool results +function sendToolResult( + res: Response, + result: { content: Array<{ type: 'text'; text: string }>; isError?: boolean } +): void { + try { + const content = result.content[0]?.text || '{}'; + const data = JSON.parse(content); + + if (result.isError) { + let statusCode = 400; + if (data.code === 'INVALID_CREDENTIALS' || data.code === 'INVALID_TOKEN') { + statusCode = 401; + } else if (data.code === 'USER_NOT_FOUND' || data.code === 'TENANT_NOT_FOUND') { + statusCode = 404; + } else if (data.code === 'USER_ALREADY_EXISTS') { + statusCode = 409; + } else if (data.code === 'ACCOUNT_LOCKED') { + statusCode = 423; + } else if (data.code === 'ACCOUNT_SUSPENDED' || data.code === 'EMAIL_NOT_VERIFIED') { + statusCode = 403; + } + res.status(statusCode).json(data); + } else { + res.json(data); + } + } catch { + res.status(500).json({ error: 'Response parsing error' }); + } +} + +// Error handling middleware +app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { + logger.error('Unhandled error', { error: err.message, stack: err.stack }); + res.status(500).json({ + error: 'Internal server error', + code: 'INTERNAL_ERROR', + }); +}); + +// 404 handler +app.use((_req: Request, res: Response) => { + res.status(404).json({ + error: 'Not found', + code: 'NOT_FOUND', + }); +}); + +// Graceful shutdown +const shutdown = async (signal: string) => { + logger.info(`Received ${signal}, shutting down gracefully...`); + await closePool(); + process.exit(0); +}; + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); + +// Start server +const PORT = serverConfig.port; + +app.listen(PORT, () => { + logger.info(`MCP Auth Server started`, { + port: PORT, + env: serverConfig.nodeEnv, + }); + console.log(`🔐 MCP Auth Server running on http://localhost:${PORT}`); + console.log(` - Health: http://localhost:${PORT}/health`); + console.log(` - MCP Tools: http://localhost:${PORT}/mcp/tools`); + console.log(` - Auth API: http://localhost:${PORT}/api/auth/*`); + console.log(` - RBAC API: http://localhost:${PORT}/api/rbac/*`); + console.log(` - Team API: http://localhost:${PORT}/api/team/*`); +}); + +export default app; diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts new file mode 100644 index 0000000..50242b5 --- /dev/null +++ b/src/services/auth.service.ts @@ -0,0 +1,788 @@ +/** + * Auth Service - Core authentication business logic + */ + +import bcrypt from 'bcrypt'; +import jwt from 'jsonwebtoken'; +import crypto from 'crypto'; +import { Pool, PoolClient } from 'pg'; +import { + User, + Session, + Token, + Tenant, + RegisterInput, + LoginInput, + LoginResponse, + RefreshInput, + RefreshResponse, + PasswordResetRequestInput, + PasswordResetInput, + ChangePasswordInput, + LogoutInput, + AuthTokens, + JWTPayload, + DeviceInfo, + UserStatus, +} from '../types/auth.types'; +import { + InvalidCredentialsError, + UserNotFoundError, + UserAlreadyExistsError, + TenantNotFoundError, + SessionExpiredError, + SessionRevokedError, + InvalidTokenError, + AccountLockedError, + AccountSuspendedError, + AccountNotVerifiedError, + PasswordMismatchError, + WeakPasswordError, +} from '../utils/errors'; +import { getPool, setTenantContext, jwtConfig, passwordConfig, securityConfig } from '../config'; +import { logger } from '../utils/logger'; + +export class AuthService { + private pool: Pool; + + constructor() { + this.pool = getPool(); + } + + /** + * Register a new user + */ + async register(input: RegisterInput): Promise<{ user: Omit; tenant: Tenant }> { + const client = await this.pool.connect(); + + try { + await client.query('BEGIN'); + + // Validate password strength + this.validatePassword(input.password); + + // Hash password + const passwordHash = await bcrypt.hash(input.password, passwordConfig.saltRounds); + + // Create tenant if tenant info provided + let tenant: Tenant; + + if (input.tenantName && input.tenantSlug) { + // Check if tenant slug exists + const existingTenant = await client.query( + 'SELECT id FROM tenants.tenants WHERE slug = $1', + [input.tenantSlug] + ); + + if (existingTenant.rows.length > 0) { + throw new UserAlreadyExistsError(`Tenant with slug ${input.tenantSlug} already exists`); + } + + // Create new tenant + const tenantResult = await client.query( + `INSERT INTO tenants.tenants (name, slug, owner_email, status) + VALUES ($1, $2, $3, 'active') + RETURNING id, name, slug, status`, + [input.tenantName, input.tenantSlug, input.email] + ); + tenant = tenantResult.rows[0]; + } else { + throw new Error('Tenant name and slug are required for registration'); + } + + // Set tenant context for RLS + await setTenantContext(client, tenant.id); + + // Check if user exists in this tenant + const existingUser = await client.query( + 'SELECT id FROM users.users WHERE email = $1 AND tenant_id = $2', + [input.email, tenant.id] + ); + + if (existingUser.rows.length > 0) { + throw new UserAlreadyExistsError(input.email); + } + + // Create user + const userResult = await client.query( + `INSERT INTO users.users ( + tenant_id, email, password_hash, first_name, last_name, + display_name, status, is_owner, email_verified, password_changed_at + ) VALUES ($1, $2, $3, $4, $5, $6, 'pending', true, false, NOW()) + RETURNING *`, + [ + tenant.id, + input.email, + passwordHash, + input.firstName || null, + input.lastName || null, + input.firstName && input.lastName + ? `${input.firstName} ${input.lastName}` + : input.firstName || input.email.split('@')[0], + ] + ); + + await client.query('COMMIT'); + + const user = this.sanitizeUser(userResult.rows[0]); + + logger.info('User registered successfully', { userId: user.id, tenantId: tenant.id }); + + return { user, tenant }; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } + + /** + * Login user + */ + async login(input: LoginInput): Promise { + const client = await this.pool.connect(); + + try { + // Find user by email + let userQuery = ` + SELECT u.*, t.id as "tenantId", t.name as "tenantName", t.slug as "tenantSlug", t.status as "tenantStatus" + FROM users.users u + JOIN tenants.tenants t ON u.tenant_id = t.id + WHERE u.email = $1 + `; + const params: string[] = [input.email]; + + if (input.tenantId) { + userQuery += ' AND u.tenant_id = $2'; + params.push(input.tenantId); + } + + const result = await client.query(userQuery, params); + + if (result.rows.length === 0) { + throw new InvalidCredentialsError(); + } + + // If multiple tenants, require tenant selection + if (result.rows.length > 1 && !input.tenantId) { + const tenants = result.rows.map((r) => ({ + id: r.tenantId, + name: r.tenantName, + slug: r.tenantSlug, + })); + throw new InvalidCredentialsError( + `Multiple tenants found. Please specify tenantId. Available: ${JSON.stringify(tenants)}` + ); + } + + const userRow = result.rows[0]; + const tenantId = userRow.tenant_id; + + // Set tenant context + await setTenantContext(client, tenantId); + + // Check account status + await this.checkAccountStatus(userRow); + + // Verify password + const isValidPassword = await bcrypt.compare(input.password, userRow.password_hash); + + if (!isValidPassword) { + await this.handleFailedLogin(client, userRow.id, tenantId); + throw new InvalidCredentialsError(); + } + + // Reset failed login attempts on success + await client.query( + 'UPDATE users.users SET failed_login_attempts = 0, locked_until = NULL WHERE id = $1 AND tenant_id = $2', + [userRow.id, tenantId] + ); + + // Generate tokens + const tokens = this.generateTokens({ + sub: userRow.id, + email: userRow.email, + tenantId, + isOwner: userRow.is_owner, + }); + + // Create session + const session = await this.createSession( + client, + userRow.id, + tenantId, + tokens.refreshToken, + input.deviceInfo + ); + + // Update last login + await client.query( + `UPDATE users.users + SET last_login_at = NOW(), last_login_ip = $3 + WHERE id = $1 AND tenant_id = $2`, + [userRow.id, tenantId, input.deviceInfo?.ipAddress || null] + ); + + const user = this.sanitizeUser(userRow); + + logger.info('User logged in successfully', { userId: user.id, tenantId }); + + return { + user, + tokens, + session: { + id: session.id, + deviceType: session.deviceType, + expiresAt: session.expiresAt, + }, + }; + } finally { + client.release(); + } + } + + /** + * Refresh access token + */ + async refresh(input: RefreshInput): Promise { + const client = await this.pool.connect(); + + try { + // Verify refresh token + let decoded: JWTPayload; + try { + decoded = jwt.verify(input.refreshToken, jwtConfig.secret) as JWTPayload; + } catch { + throw new InvalidTokenError('Invalid refresh token'); + } + + // Set tenant context + await setTenantContext(client, decoded.tenantId); + + // Find session by token hash + const tokenHash = this.hashToken(input.refreshToken); + const sessionResult = await client.query( + `SELECT s.*, u.email, u.is_owner, u.status as user_status + FROM auth.sessions s + JOIN users.users u ON s.user_id = u.id + WHERE s.token_hash = $1 AND s.tenant_id = $2`, + [tokenHash, decoded.tenantId] + ); + + if (sessionResult.rows.length === 0) { + throw new SessionExpiredError(); + } + + const session = sessionResult.rows[0]; + + // Check session status + if (session.status === 'revoked') { + throw new SessionRevokedError(); + } + + if (session.status === 'expired' || new Date(session.expires_at) < new Date()) { + throw new SessionExpiredError(); + } + + // Check user status + if (session.user_status !== 'active') { + throw new AccountSuspendedError(); + } + + // Update session last active + await client.query( + 'UPDATE auth.sessions SET last_active_at = NOW() WHERE id = $1', + [session.id] + ); + + // Generate new access token + const accessToken = jwt.sign( + { + sub: decoded.sub, + email: session.email, + tenantId: decoded.tenantId, + isOwner: session.is_owner, + } as Omit, + jwtConfig.secret, + { + expiresIn: jwtConfig.accessTokenExpiry, + issuer: jwtConfig.issuer, + } + ); + + const expiresIn = this.parseExpiry(jwtConfig.accessTokenExpiry); + + logger.info('Token refreshed', { userId: decoded.sub, tenantId: decoded.tenantId }); + + return { + accessToken, + expiresIn, + }; + } finally { + client.release(); + } + } + + /** + * Logout user + */ + async logout(input: LogoutInput): Promise<{ message: string; sessionsRevoked: number }> { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, input.tenantId); + + let result; + + if (input.logoutAll) { + // Revoke all sessions for user + result = await client.query( + `UPDATE auth.sessions + SET status = 'revoked', revoked_at = NOW() + WHERE user_id = $1 AND tenant_id = $2 AND status = 'active'`, + [input.userId, input.tenantId] + ); + } else if (input.sessionId) { + // Revoke specific session + result = await client.query( + `UPDATE auth.sessions + SET status = 'revoked', revoked_at = NOW() + WHERE id = $1 AND user_id = $2 AND tenant_id = $3 AND status = 'active'`, + [input.sessionId, input.userId, input.tenantId] + ); + } else { + throw new Error('Either sessionId or logoutAll must be provided'); + } + + logger.info('User logged out', { + userId: input.userId, + tenantId: input.tenantId, + sessionsRevoked: result.rowCount, + }); + + return { + message: 'Logged out successfully', + sessionsRevoked: result.rowCount || 0, + }; + } finally { + client.release(); + } + } + + /** + * Request password reset + */ + async requestPasswordReset(input: PasswordResetRequestInput): Promise<{ message: string }> { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, input.tenantId); + + // Find user + const userResult = await client.query( + 'SELECT id, email FROM users.users WHERE email = $1 AND tenant_id = $2', + [input.email, input.tenantId] + ); + + // Always return success to prevent email enumeration + if (userResult.rows.length === 0) { + return { message: 'If the email exists, a reset link will be sent' }; + } + + const user = userResult.rows[0]; + + // Generate reset token + const resetToken = crypto.randomBytes(32).toString('hex'); + const tokenHash = this.hashToken(resetToken); + const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour + + // Invalidate existing reset tokens + await client.query( + `UPDATE auth.tokens + SET status = 'revoked', revoked_at = NOW() + WHERE user_id = $1 AND tenant_id = $2 AND token_type = 'password_reset' AND status = 'active'`, + [user.id, input.tenantId] + ); + + // Create new reset token + await client.query( + `INSERT INTO auth.tokens (user_id, tenant_id, token_type, token_hash, status, expires_at) + VALUES ($1, $2, 'password_reset', $3, 'active', $4)`, + [user.id, input.tenantId, tokenHash, expiresAt] + ); + + // TODO: Send email with reset link containing resetToken + logger.info('Password reset requested', { userId: user.id, tenantId: input.tenantId }); + + return { message: 'If the email exists, a reset link will be sent' }; + } finally { + client.release(); + } + } + + /** + * Reset password with token + */ + async resetPassword(input: PasswordResetInput): Promise<{ message: string }> { + const client = await this.pool.connect(); + + try { + await client.query('BEGIN'); + + const tokenHash = this.hashToken(input.token); + + // Find valid token + const tokenResult = await client.query( + `SELECT t.*, u.tenant_id + FROM auth.tokens t + JOIN users.users u ON t.user_id = u.id + WHERE t.token_hash = $1 AND t.token_type = 'password_reset' AND t.status = 'active'`, + [tokenHash] + ); + + if (tokenResult.rows.length === 0) { + throw new InvalidTokenError('Invalid or expired reset token'); + } + + const token = tokenResult.rows[0]; + + if (new Date(token.expires_at) < new Date()) { + throw new InvalidTokenError('Reset token has expired'); + } + + await setTenantContext(client, token.tenant_id); + + // Validate new password + this.validatePassword(input.newPassword); + + // Hash new password + const passwordHash = await bcrypt.hash(input.newPassword, passwordConfig.saltRounds); + + // Update password + await client.query( + `UPDATE users.users + SET password_hash = $1, password_changed_at = NOW(), failed_login_attempts = 0, locked_until = NULL + WHERE id = $2 AND tenant_id = $3`, + [passwordHash, token.user_id, token.tenant_id] + ); + + // Mark token as used + await client.query( + `UPDATE auth.tokens SET status = 'used', used_at = NOW() WHERE id = $1`, + [token.id] + ); + + // Revoke all active sessions + await client.query( + `UPDATE auth.sessions + SET status = 'revoked', revoked_at = NOW() + WHERE user_id = $1 AND tenant_id = $2 AND status = 'active'`, + [token.user_id, token.tenant_id] + ); + + await client.query('COMMIT'); + + logger.info('Password reset successfully', { userId: token.user_id, tenantId: token.tenant_id }); + + return { message: 'Password reset successfully' }; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } + + /** + * Change password (authenticated) + */ + async changePassword(input: ChangePasswordInput): Promise<{ message: string }> { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, input.tenantId); + + // Get user + const userResult = await client.query( + 'SELECT id, password_hash FROM users.users WHERE id = $1 AND tenant_id = $2', + [input.userId, input.tenantId] + ); + + if (userResult.rows.length === 0) { + throw new UserNotFoundError(input.userId); + } + + const user = userResult.rows[0]; + + // Verify current password + const isValidPassword = await bcrypt.compare(input.currentPassword, user.password_hash); + if (!isValidPassword) { + throw new PasswordMismatchError(); + } + + // Validate new password + this.validatePassword(input.newPassword); + + // Hash new password + const passwordHash = await bcrypt.hash(input.newPassword, passwordConfig.saltRounds); + + // Update password + await client.query( + `UPDATE users.users SET password_hash = $1, password_changed_at = NOW() WHERE id = $2 AND tenant_id = $3`, + [passwordHash, input.userId, input.tenantId] + ); + + logger.info('Password changed', { userId: input.userId, tenantId: input.tenantId }); + + return { message: 'Password changed successfully' }; + } finally { + client.release(); + } + } + + /** + * Verify access token + */ + async verifyToken(token: string): Promise { + try { + const decoded = jwt.verify(token, jwtConfig.secret) as JWTPayload; + return decoded; + } catch { + throw new InvalidTokenError(); + } + } + + /** + * Get user sessions + */ + async getUserSessions(userId: string, tenantId: string): Promise { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, tenantId); + + const result = await client.query( + `SELECT * FROM auth.sessions + WHERE user_id = $1 AND tenant_id = $2 AND status = 'active' + ORDER BY last_active_at DESC`, + [userId, tenantId] + ); + + return result.rows.map(this.mapSession); + } finally { + client.release(); + } + } + + // Private helper methods + + private validatePassword(password: string): void { + const issues: string[] = []; + + if (password.length < passwordConfig.minLength) { + issues.push(`Password must be at least ${passwordConfig.minLength} characters`); + } + + if (passwordConfig.requireUppercase && !/[A-Z]/.test(password)) { + issues.push('Password must contain at least one uppercase letter'); + } + + if (passwordConfig.requireLowercase && !/[a-z]/.test(password)) { + issues.push('Password must contain at least one lowercase letter'); + } + + if (passwordConfig.requireNumbers && !/\d/.test(password)) { + issues.push('Password must contain at least one number'); + } + + if (passwordConfig.requireSpecialChars && !/[!@#$%^&*(),.?":{}|<>]/.test(password)) { + issues.push('Password must contain at least one special character'); + } + + if (issues.length > 0) { + throw new WeakPasswordError(issues); + } + } + + private async checkAccountStatus(user: Record): Promise { + const status = user.status as UserStatus; + + if (status === 'suspended' || status === 'banned') { + throw new AccountSuspendedError(); + } + + if (status === 'deleted') { + throw new InvalidCredentialsError(); + } + + // Check if account is locked + if (user.locked_until && new Date(user.locked_until as string) > new Date()) { + throw new AccountLockedError(new Date(user.locked_until as string)); + } + + // Check if email verification is required + // Uncomment to enforce email verification + // if (!user.email_verified) { + // throw new AccountNotVerifiedError(); + // } + } + + private async handleFailedLogin(client: PoolClient, userId: string, tenantId: string): Promise { + const result = await client.query( + `UPDATE users.users + SET failed_login_attempts = failed_login_attempts + 1 + WHERE id = $1 AND tenant_id = $2 + RETURNING failed_login_attempts`, + [userId, tenantId] + ); + + const attempts = result.rows[0]?.failed_login_attempts || 0; + + if (attempts >= securityConfig.maxLoginAttempts) { + const lockedUntil = new Date(Date.now() + securityConfig.lockoutDuration); + await client.query( + 'UPDATE users.users SET locked_until = $1 WHERE id = $2 AND tenant_id = $3', + [lockedUntil, userId, tenantId] + ); + + logger.warn('Account locked due to failed login attempts', { userId, tenantId, attempts }); + } + } + + private generateTokens(payload: Omit): AuthTokens { + const accessToken = jwt.sign(payload, jwtConfig.secret, { + expiresIn: jwtConfig.accessTokenExpiry, + issuer: jwtConfig.issuer, + }); + + const refreshToken = jwt.sign(payload, jwtConfig.secret, { + expiresIn: jwtConfig.refreshTokenExpiry, + issuer: jwtConfig.issuer, + }); + + return { + accessToken, + refreshToken, + expiresIn: this.parseExpiry(jwtConfig.accessTokenExpiry), + tokenType: 'Bearer', + }; + } + + private async createSession( + client: PoolClient, + userId: string, + tenantId: string, + refreshToken: string, + deviceInfo?: DeviceInfo + ): Promise { + const tokenHash = this.hashToken(refreshToken); + const expiresAt = new Date(Date.now() + securityConfig.sessionDuration); + + const result = await client.query( + `INSERT INTO auth.sessions ( + user_id, tenant_id, token_hash, device_type, device_name, + browser, browser_version, os, os_version, ip_address, + user_agent, status, expires_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'active', $12) + RETURNING *`, + [ + userId, + tenantId, + tokenHash, + deviceInfo?.deviceType || 'unknown', + deviceInfo?.deviceName || null, + deviceInfo?.browser || null, + deviceInfo?.browserVersion || null, + deviceInfo?.os || null, + deviceInfo?.osVersion || null, + deviceInfo?.ipAddress || null, + deviceInfo?.userAgent || null, + expiresAt, + ] + ); + + return this.mapSession(result.rows[0]); + } + + private hashToken(token: string): string { + return crypto.createHash('sha256').update(token).digest('hex'); + } + + private parseExpiry(expiry: string): number { + const match = expiry.match(/^(\d+)([smhd])$/); + if (!match) return 900; // default 15 minutes + + const value = parseInt(match[1], 10); + const unit = match[2]; + + switch (unit) { + case 's': return value; + case 'm': return value * 60; + case 'h': return value * 60 * 60; + case 'd': return value * 24 * 60 * 60; + default: return 900; + } + } + + private sanitizeUser(row: Record): Omit { + const { password_hash, mfa_secret, ...rest } = row; + return this.mapUser(rest); + } + + private mapUser(row: Record): Omit { + return { + id: row.id as string, + tenantId: row.tenant_id as string, + email: row.email as string, + firstName: row.first_name as string | null, + lastName: row.last_name as string | null, + displayName: row.display_name as string | null, + avatarUrl: row.avatar_url as string | null, + phone: row.phone as string | null, + status: row.status as UserStatus, + isOwner: row.is_owner as boolean, + emailVerified: row.email_verified as boolean, + emailVerifiedAt: row.email_verified_at ? new Date(row.email_verified_at as string) : null, + phoneVerified: row.phone_verified as boolean, + phoneVerifiedAt: row.phone_verified_at ? new Date(row.phone_verified_at as string) : null, + mfaEnabled: row.mfa_enabled as boolean, + passwordChangedAt: new Date(row.password_changed_at as string), + failedLoginAttempts: row.failed_login_attempts as number, + lockedUntil: row.locked_until ? new Date(row.locked_until as string) : null, + lastLoginAt: row.last_login_at ? new Date(row.last_login_at as string) : null, + lastLoginIp: row.last_login_ip as string | null, + preferences: (row.preferences as User['preferences']) || { + theme: 'light', + language: 'es', + notifications: { email: true, push: true, sms: false }, + }, + createdAt: new Date(row.created_at as string), + updatedAt: new Date(row.updated_at as string), + } as Omit; + } + + private mapSession(row: Record): Session { + return { + id: row.id as string, + userId: row.user_id as string, + tenantId: row.tenant_id as string, + tokenHash: row.token_hash as string, + deviceType: row.device_type as Session['deviceType'], + deviceName: row.device_name as string | null, + browser: row.browser as string | null, + browserVersion: row.browser_version as string | null, + os: row.os as string | null, + osVersion: row.os_version as string | null, + ipAddress: row.ip_address as string | null, + userAgent: row.user_agent as string | null, + status: row.status as Session['status'], + lastActiveAt: new Date(row.last_active_at as string), + expiresAt: new Date(row.expires_at as string), + revokedAt: row.revoked_at ? new Date(row.revoked_at as string) : null, + createdAt: new Date(row.created_at as string), + }; + } +} + +export const authService = new AuthService(); diff --git a/src/services/rbac.service.ts b/src/services/rbac.service.ts new file mode 100644 index 0000000..3d52219 --- /dev/null +++ b/src/services/rbac.service.ts @@ -0,0 +1,607 @@ +/** + * RBAC Service - Role-Based Access Control + */ + +import { Pool } from 'pg'; +import { + Role, + Permission, + UserRole, + RoleWithPermissions, + UserPermissions, + CreateRoleInput, + UpdateRoleInput, + AssignPermissionsInput, + AssignRoleInput, + RevokeRoleInput, + CheckPermissionInput, + GetUserPermissionsInput, + ListRolesInput, + ListPermissionsInput, +} from '../types/rbac.types'; +import { getPool, setTenantContext } from '../config'; +import { logger } from '../utils/logger'; +import { AuthError } from '../utils/errors'; + +export class RBACService { + private pool: Pool; + + constructor() { + this.pool = getPool(); + } + + /** + * List roles for a tenant + */ + async listRoles(input: ListRolesInput): Promise { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, input.tenantId); + + let query = ` + SELECT * FROM rbac.roles + WHERE tenant_id = $1 + `; + const params: unknown[] = [input.tenantId]; + + if (!input.includeSystem) { + query += ` AND role_type = 'custom'`; + } + + if (!input.includeInactive) { + query += ` AND is_active = true`; + } + + query += ` ORDER BY hierarchy_level, name`; + + const result = await client.query(query, params); + return result.rows.map(this.mapRole); + } finally { + client.release(); + } + } + + /** + * Get role by ID + */ + async getRole(roleId: string, tenantId: string): Promise { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, tenantId); + + // Get role + const roleResult = await client.query( + 'SELECT * FROM rbac.roles WHERE id = $1 AND tenant_id = $2', + [roleId, tenantId] + ); + + if (roleResult.rows.length === 0) { + return null; + } + + // Get permissions + const permResult = await client.query( + `SELECT p.code, p.name, p.module, p.action, p.resource, rp.grant_type + FROM rbac.role_permissions rp + JOIN rbac.permissions p ON rp.permission_id = p.id + WHERE rp.role_id = $1`, + [roleId] + ); + + const role = this.mapRole(roleResult.rows[0]); + + return { + ...role, + permissions: permResult.rows.map((row) => ({ + code: row.code, + name: row.name, + module: row.module, + action: row.action, + resource: row.resource, + grantType: row.grant_type, + })), + }; + } finally { + client.release(); + } + } + + /** + * Create a custom role + */ + async createRole(input: CreateRoleInput): Promise { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, input.tenantId); + + // Generate slug from name if not provided + const slug = input.slug || input.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''); + + // Check if slug already exists + const existing = await client.query( + 'SELECT id FROM rbac.roles WHERE tenant_id = $1 AND slug = $2', + [input.tenantId, slug] + ); + + if (existing.rows.length > 0) { + throw new AuthError('Role with this name already exists', 'ROLE_EXISTS', 409); + } + + const result = await client.query( + `INSERT INTO rbac.roles ( + tenant_id, name, slug, description, role_type, + hierarchy_level, created_by + ) VALUES ($1, $2, $3, $4, 'custom', $5, $6) + RETURNING *`, + [ + input.tenantId, + input.name, + slug, + input.description || null, + input.hierarchyLevel || 100, + input.createdBy, + ] + ); + + logger.info('Role created', { roleId: result.rows[0].id, tenantId: input.tenantId }); + + return this.mapRole(result.rows[0]); + } finally { + client.release(); + } + } + + /** + * Update a role + */ + async updateRole(input: UpdateRoleInput): Promise { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, input.tenantId); + + // Check if role exists and is custom + const existing = await client.query( + 'SELECT role_type FROM rbac.roles WHERE id = $1 AND tenant_id = $2', + [input.roleId, input.tenantId] + ); + + if (existing.rows.length === 0) { + throw new AuthError('Role not found', 'ROLE_NOT_FOUND', 404); + } + + if (existing.rows[0].role_type === 'system') { + throw new AuthError('Cannot modify system roles', 'SYSTEM_ROLE', 403); + } + + const updates: string[] = []; + const params: unknown[] = []; + let paramIndex = 1; + + if (input.name !== undefined) { + updates.push(`name = $${paramIndex++}`); + params.push(input.name); + } + + if (input.description !== undefined) { + updates.push(`description = $${paramIndex++}`); + params.push(input.description); + } + + if (input.hierarchyLevel !== undefined) { + updates.push(`hierarchy_level = $${paramIndex++}`); + params.push(input.hierarchyLevel); + } + + if (input.isActive !== undefined) { + updates.push(`is_active = $${paramIndex++}`); + params.push(input.isActive); + } + + updates.push(`updated_by = $${paramIndex++}`); + params.push(input.updatedBy); + + params.push(input.roleId); + params.push(input.tenantId); + + const result = await client.query( + `UPDATE rbac.roles SET ${updates.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex} + RETURNING *`, + params + ); + + logger.info('Role updated', { roleId: input.roleId, tenantId: input.tenantId }); + + return this.mapRole(result.rows[0]); + } finally { + client.release(); + } + } + + /** + * Delete a custom role + */ + async deleteRole(roleId: string, tenantId: string): Promise { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, tenantId); + + // Check if role exists and is custom + const existing = await client.query( + 'SELECT role_type FROM rbac.roles WHERE id = $1 AND tenant_id = $2', + [roleId, tenantId] + ); + + if (existing.rows.length === 0) { + throw new AuthError('Role not found', 'ROLE_NOT_FOUND', 404); + } + + if (existing.rows[0].role_type === 'system') { + throw new AuthError('Cannot delete system roles', 'SYSTEM_ROLE', 403); + } + + // Check if role is assigned to any users + const assignments = await client.query( + 'SELECT COUNT(*) FROM rbac.user_roles WHERE role_id = $1 AND is_active = true', + [roleId] + ); + + if (parseInt(assignments.rows[0].count) > 0) { + throw new AuthError('Cannot delete role with active assignments', 'ROLE_IN_USE', 409); + } + + await client.query( + 'DELETE FROM rbac.roles WHERE id = $1 AND tenant_id = $2', + [roleId, tenantId] + ); + + logger.info('Role deleted', { roleId, tenantId }); + + return true; + } finally { + client.release(); + } + } + + /** + * List all permissions + */ + async listPermissions(input: ListPermissionsInput = {}): Promise { + const client = await this.pool.connect(); + + try { + let query = 'SELECT * FROM rbac.permissions WHERE is_active = true'; + const params: unknown[] = []; + + if (input.module) { + params.push(input.module); + query += ` AND module = $${params.length}`; + } + + if (input.category) { + params.push(input.category); + query += ` AND category = $${params.length}`; + } + + query += ' ORDER BY module, category, code'; + + const result = await client.query(query, params); + return result.rows.map(this.mapPermission); + } finally { + client.release(); + } + } + + /** + * Assign permissions to a role + */ + async assignPermissions(input: AssignPermissionsInput): Promise { + const client = await this.pool.connect(); + + try { + await client.query('BEGIN'); + await setTenantContext(client, input.tenantId); + + // Verify role exists + const roleExists = await client.query( + 'SELECT id FROM rbac.roles WHERE id = $1 AND tenant_id = $2', + [input.roleId, input.tenantId] + ); + + if (roleExists.rows.length === 0) { + throw new AuthError('Role not found', 'ROLE_NOT_FOUND', 404); + } + + // Clear existing permissions + await client.query( + 'DELETE FROM rbac.role_permissions WHERE role_id = $1', + [input.roleId] + ); + + // Assign new permissions + let assignedCount = 0; + + for (const code of input.permissionCodes) { + const perm = await client.query( + 'SELECT id FROM rbac.permissions WHERE code = $1 AND is_active = true', + [code] + ); + + if (perm.rows.length > 0) { + await client.query( + `INSERT INTO rbac.role_permissions (role_id, permission_id, created_by) + VALUES ($1, $2, $3)`, + [input.roleId, perm.rows[0].id, input.createdBy] + ); + assignedCount++; + } + } + + await client.query('COMMIT'); + + logger.info('Permissions assigned to role', { + roleId: input.roleId, + count: assignedCount, + }); + + return assignedCount; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } + + /** + * Assign role to user + */ + async assignRoleToUser(input: AssignRoleInput): Promise { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, input.tenantId); + + const result = await client.query( + `SELECT * FROM rbac.assign_role_to_user($1, $2, $3, $4, $5, $6)`, + [ + input.userId, + input.roleId, + input.tenantId, + input.assignedBy, + input.isPrimary || false, + input.validUntil || null, + ] + ); + + // Get the full assignment + const assignment = await client.query( + 'SELECT * FROM rbac.user_roles WHERE id = $1', + [result.rows[0].assign_role_to_user] + ); + + logger.info('Role assigned to user', { + userId: input.userId, + roleId: input.roleId, + tenantId: input.tenantId, + }); + + return this.mapUserRole(assignment.rows[0]); + } finally { + client.release(); + } + } + + /** + * Revoke role from user + */ + async revokeRoleFromUser(input: RevokeRoleInput): Promise { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, input.tenantId); + + const result = await client.query( + `UPDATE rbac.user_roles + SET is_active = false, revoked_at = NOW(), revoked_by = $4 + WHERE user_id = $1 AND role_id = $2 AND tenant_id = $3 AND is_active = true`, + [input.userId, input.roleId, input.tenantId, input.revokedBy] + ); + + if (result.rowCount === 0) { + throw new AuthError('Role assignment not found', 'ASSIGNMENT_NOT_FOUND', 404); + } + + logger.info('Role revoked from user', { + userId: input.userId, + roleId: input.roleId, + tenantId: input.tenantId, + }); + + return true; + } finally { + client.release(); + } + } + + /** + * Check if user has a specific permission + */ + async checkPermission(input: CheckPermissionInput): Promise { + const client = await this.pool.connect(); + + try { + const result = await client.query( + 'SELECT rbac.user_has_permission($1, $2, $3) as has_permission', + [input.userId, input.tenantId, input.permissionCode] + ); + + return result.rows[0]?.has_permission || false; + } finally { + client.release(); + } + } + + /** + * Get user's roles and permissions + */ + async getUserPermissions(input: GetUserPermissionsInput): Promise { + const client = await this.pool.connect(); + + try { + // Get user's roles + const rolesResult = await client.query( + `SELECT r.id, r.name, r.slug, ur.is_primary + FROM rbac.user_roles ur + JOIN rbac.roles r ON ur.role_id = r.id + WHERE ur.user_id = $1 AND ur.tenant_id = $2 AND ur.is_active = true + AND (ur.valid_until IS NULL OR ur.valid_until > NOW()) + ORDER BY ur.is_primary DESC, r.hierarchy_level`, + [input.userId, input.tenantId] + ); + + // Get user's permissions + const permsResult = await client.query( + 'SELECT permission_code FROM rbac.get_user_permissions($1, $2)', + [input.userId, input.tenantId] + ); + + return { + userId: input.userId, + tenantId: input.tenantId, + roles: rolesResult.rows.map((row) => ({ + id: row.id, + name: row.name, + slug: row.slug, + isPrimary: row.is_primary, + })), + permissions: permsResult.rows.map((row) => row.permission_code), + }; + } finally { + client.release(); + } + } + + /** + * Get user's role assignments + */ + async getUserRoles(userId: string, tenantId: string): Promise { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, tenantId); + + const result = await client.query( + `SELECT * FROM rbac.user_roles + WHERE user_id = $1 AND tenant_id = $2 + ORDER BY is_primary DESC, created_at`, + [userId, tenantId] + ); + + return result.rows.map(this.mapUserRole); + } finally { + client.release(); + } + } + + /** + * Initialize default roles for a new tenant + */ + async initializeTenantRoles(tenantId: string, ownerId: string): Promise { + const client = await this.pool.connect(); + + try { + await client.query('BEGIN'); + + // Create default roles + await client.query('SELECT rbac.create_default_roles($1, $2)', [tenantId, ownerId]); + + // Assign default permissions + await client.query('SELECT rbac.assign_default_role_permissions($1)', [tenantId]); + + // Assign owner role to the owner + const ownerRole = await client.query( + `SELECT id FROM rbac.roles WHERE tenant_id = $1 AND slug = 'owner'`, + [tenantId] + ); + + if (ownerRole.rows.length > 0) { + await client.query( + 'SELECT rbac.assign_role_to_user($1, $2, $3, $4, true)', + [ownerId, ownerRole.rows[0].id, tenantId, ownerId] + ); + } + + await client.query('COMMIT'); + + logger.info('Tenant roles initialized', { tenantId, ownerId }); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } + + // Mapping functions + private mapRole(row: Record): Role { + return { + id: row.id as string, + tenantId: row.tenant_id as string, + name: row.name as string, + slug: row.slug as string, + description: row.description as string | null, + roleType: row.role_type as Role['roleType'], + hierarchyLevel: row.hierarchy_level as number, + isActive: row.is_active as boolean, + settings: (row.settings as Record) || {}, + createdAt: new Date(row.created_at as string), + updatedAt: new Date(row.updated_at as string), + createdBy: row.created_by as string | null, + updatedBy: row.updated_by as string | null, + }; + } + + private mapPermission(row: Record): Permission { + return { + id: row.id as string, + code: row.code as string, + name: row.name as string, + description: row.description as string | null, + module: row.module as string, + category: row.category as string, + action: row.action as Permission['action'], + resource: row.resource as string, + isSystem: row.is_system as boolean, + isActive: row.is_active as boolean, + createdAt: new Date(row.created_at as string), + updatedAt: new Date(row.updated_at as string), + }; + } + + private mapUserRole(row: Record): UserRole { + return { + id: row.id as string, + userId: row.user_id as string, + roleId: row.role_id as string, + tenantId: row.tenant_id as string, + isPrimary: row.is_primary as boolean, + assignedReason: row.assigned_reason as string | null, + validFrom: new Date(row.valid_from as string), + validUntil: row.valid_until ? new Date(row.valid_until as string) : null, + isActive: row.is_active as boolean, + createdAt: new Date(row.created_at as string), + updatedAt: new Date(row.updated_at as string), + assignedBy: row.assigned_by as string | null, + revokedAt: row.revoked_at ? new Date(row.revoked_at as string) : null, + revokedBy: row.revoked_by as string | null, + }; + } +} + +export const rbacService = new RBACService(); diff --git a/src/services/team.service.ts b/src/services/team.service.ts new file mode 100644 index 0000000..69d8699 --- /dev/null +++ b/src/services/team.service.ts @@ -0,0 +1,433 @@ +/** + * Team Service - Team management operations + */ + +import { getPool } from '../config'; +import { logger } from '../utils/logger'; +import { + TeamMember, + TeamMemberWithUser, + Invitation, + InvitationWithDetails, + AddTeamMemberInput, + UpdateTeamMemberInput, + RemoveTeamMemberInput, + ListTeamMembersInput, + CreateInvitationInput, + CreateInvitationResult, + AcceptInvitationInput, + AcceptInvitationResult, + ResendInvitationInput, + ResendInvitationResult, + RevokeInvitationInput, + ListInvitationsInput, + GetInvitationByTokenInput, +} from '../types/team.types'; + +class TeamService { + // ================== Team Members ================== + + /** + * Add a team member + */ + async addTeamMember(input: AddTeamMemberInput): Promise { + const pool = getPool(); + + logger.info('Adding team member', { + tenantId: input.tenantId, + userId: input.userId, + }); + + const result = await pool.query( + `SELECT * FROM teams.add_team_member($1, $2, $3, $4, NULL, $5, $6)`, + [ + input.tenantId, + input.userId, + input.memberRole || 'member', + input.addedBy, + input.department, + input.jobTitle, + ] + ); + + // Get the created member + const memberResult = await pool.query( + `SELECT * FROM teams.team_members + WHERE tenant_id = $1 AND user_id = $2 AND removed_at IS NULL`, + [input.tenantId, input.userId] + ); + + return this.mapTeamMemberRow(memberResult.rows[0]); + } + + /** + * Update a team member + */ + async updateTeamMember(input: UpdateTeamMemberInput): Promise { + const pool = getPool(); + + logger.info('Updating team member', { + tenantId: input.tenantId, + userId: input.userId, + }); + + const updates: string[] = []; + const values: unknown[] = []; + let paramIndex = 1; + + if (input.memberRole !== undefined) { + updates.push(`member_role = $${paramIndex++}`); + values.push(input.memberRole); + } + if (input.department !== undefined) { + updates.push(`department = $${paramIndex++}`); + values.push(input.department); + } + if (input.jobTitle !== undefined) { + updates.push(`job_title = $${paramIndex++}`); + values.push(input.jobTitle); + } + if (input.status !== undefined) { + updates.push(`status = $${paramIndex++}`); + values.push(input.status); + } + + updates.push(`updated_at = CURRENT_TIMESTAMP`); + + values.push(input.tenantId); + values.push(input.userId); + + const result = await pool.query( + `UPDATE teams.team_members + SET ${updates.join(', ')} + WHERE tenant_id = $${paramIndex++} AND user_id = $${paramIndex++} AND removed_at IS NULL + RETURNING *`, + values + ); + + if (result.rowCount === 0) { + throw new Error('Team member not found'); + } + + return this.mapTeamMemberRow(result.rows[0]); + } + + /** + * Remove a team member + */ + async removeTeamMember(input: RemoveTeamMemberInput): Promise { + const pool = getPool(); + + logger.info('Removing team member', { + tenantId: input.tenantId, + userId: input.userId, + }); + + await pool.query(`SELECT teams.remove_team_member($1, $2, $3)`, [ + input.tenantId, + input.userId, + input.removedBy, + ]); + + return true; + } + + /** + * List team members + */ + async listTeamMembers(input: ListTeamMembersInput): Promise { + const pool = getPool(); + + let query = `SELECT * FROM teams.v_team_members WHERE tenant_id = $1`; + const values: unknown[] = [input.tenantId]; + let paramIndex = 2; + + if (input.status) { + query += ` AND status = $${paramIndex++}`; + values.push(input.status); + } + if (input.memberRole) { + query += ` AND member_role = $${paramIndex++}`; + values.push(input.memberRole); + } + if (input.department) { + query += ` AND department = $${paramIndex++}`; + values.push(input.department); + } + + query += ` ORDER BY joined_at DESC`; + + const result = await pool.query(query, values); + return result.rows.map((row) => this.mapTeamMemberWithUserRow(row)); + } + + /** + * Get team member by user ID + */ + async getTeamMember(tenantId: string, userId: string): Promise { + const pool = getPool(); + + const result = await pool.query( + `SELECT * FROM teams.v_team_members WHERE tenant_id = $1 AND user_id = $2`, + [tenantId, userId] + ); + + if (result.rowCount === 0) { + return null; + } + + return this.mapTeamMemberWithUserRow(result.rows[0]); + } + + /** + * Get team member count + */ + async getTeamMemberCount(tenantId: string): Promise { + const pool = getPool(); + + const result = await pool.query( + `SELECT COUNT(*) FROM teams.team_members + WHERE tenant_id = $1 AND removed_at IS NULL AND status = 'active'`, + [tenantId] + ); + + return parseInt(result.rows[0].count, 10); + } + + // ================== Invitations ================== + + /** + * Create an invitation + */ + async createInvitation(input: CreateInvitationInput): Promise { + const pool = getPool(); + + logger.info('Creating invitation', { + tenantId: input.tenantId, + email: input.email, + }); + + const result = await pool.query( + `SELECT * FROM teams.create_invitation($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + [ + input.tenantId, + input.email, + input.invitedBy, + input.roleId || null, + input.firstName || null, + input.lastName || null, + input.department || null, + input.jobTitle || null, + input.personalMessage || null, + input.expiresInDays || 7, + ] + ); + + return { + invitationId: result.rows[0].invitation_id, + token: result.rows[0].token, + }; + } + + /** + * Accept an invitation + */ + async acceptInvitation(input: AcceptInvitationInput): Promise { + const pool = getPool(); + + logger.info('Accepting invitation', { userId: input.userId }); + + const result = await pool.query(`SELECT * FROM teams.accept_invitation($1, $2)`, [ + input.token, + input.userId, + ]); + + const row = result.rows[0]; + return { + success: row.success, + tenantId: row.tenant_id, + roleId: row.role_id, + message: row.message, + }; + } + + /** + * Resend an invitation + */ + async resendInvitation(input: ResendInvitationInput): Promise { + const pool = getPool(); + + logger.info('Resending invitation', { invitationId: input.invitationId }); + + const result = await pool.query(`SELECT * FROM teams.resend_invitation($1, $2)`, [ + input.invitationId, + input.resentBy, + ]); + + const row = result.rows[0]; + return { + success: row.success, + token: row.token, + message: row.message, + }; + } + + /** + * Revoke an invitation + */ + async revokeInvitation(input: RevokeInvitationInput): Promise { + const pool = getPool(); + + logger.info('Revoking invitation', { invitationId: input.invitationId }); + + const result = await pool.query( + `UPDATE teams.invitations + SET status = 'revoked', revoked_at = CURRENT_TIMESTAMP, revoked_by = $2, updated_at = CURRENT_TIMESTAMP + WHERE id = $1 AND status = 'pending'`, + [input.invitationId, input.revokedBy] + ); + + return result.rowCount !== null && result.rowCount > 0; + } + + /** + * List invitations + */ + async listInvitations(input: ListInvitationsInput): Promise { + const pool = getPool(); + + let query = `SELECT * FROM teams.v_invitations WHERE tenant_id = $1`; + const values: unknown[] = [input.tenantId]; + + if (input.status) { + query += ` AND status = $2`; + values.push(input.status); + } + + query += ` ORDER BY sent_at DESC`; + + const result = await pool.query(query, values); + return result.rows.map((row) => this.mapInvitationWithDetailsRow(row)); + } + + /** + * Get invitation by ID + */ + async getInvitation(invitationId: string): Promise { + const pool = getPool(); + + const result = await pool.query(`SELECT * FROM teams.v_invitations WHERE id = $1`, [ + invitationId, + ]); + + if (result.rowCount === 0) { + return null; + } + + return this.mapInvitationWithDetailsRow(result.rows[0]); + } + + /** + * Get invitation by token (for acceptance page) + */ + async getInvitationByToken(input: GetInvitationByTokenInput): Promise { + const pool = getPool(); + + const result = await pool.query( + `SELECT v.* FROM teams.v_invitations v + JOIN teams.invitations i ON v.id = i.id + WHERE i.token_hash = encode(sha256($1::bytea), 'hex')`, + [input.token] + ); + + if (result.rowCount === 0) { + return null; + } + + return this.mapInvitationWithDetailsRow(result.rows[0]); + } + + /** + * Expire old invitations (scheduled job) + */ + async expireOldInvitations(): Promise { + const pool = getPool(); + + const result = await pool.query(`SELECT teams.expire_old_invitations()`); + return result.rows[0].expire_old_invitations; + } + + // ================== Row Mappers ================== + + private mapTeamMemberRow(row: Record): TeamMember { + return { + id: row.id as string, + tenantId: row.tenant_id as string, + userId: row.user_id as string, + memberRole: row.member_role as TeamMember['memberRole'], + status: row.status as TeamMember['status'], + department: row.department as string | null, + jobTitle: row.job_title as string | null, + joinedViaInvitation: row.joined_via_invitation as string | null, + joinedAt: new Date(row.joined_at as string), + lastActiveAt: row.last_active_at ? new Date(row.last_active_at as string) : null, + settings: (row.settings as Record) || {}, + createdAt: new Date(row.created_at as string), + updatedAt: new Date(row.updated_at as string), + addedBy: row.added_by as string | null, + removedAt: row.removed_at ? new Date(row.removed_at as string) : null, + removedBy: row.removed_by as string | null, + }; + } + + private mapTeamMemberWithUserRow(row: Record): TeamMemberWithUser { + return { + ...this.mapTeamMemberRow(row), + userEmail: row.user_email as string, + userDisplayName: row.user_display_name as string | null, + userFirstName: row.user_first_name as string | null, + userLastName: row.user_last_name as string | null, + userAvatarUrl: row.user_avatar_url as string | null, + primaryRoleName: row.primary_role_name as string | null, + }; + } + + private mapInvitationRow(row: Record): Invitation { + return { + id: row.id as string, + tenantId: row.tenant_id as string, + email: row.email as string, + firstName: row.first_name as string | null, + lastName: row.last_name as string | null, + roleId: row.role_id as string | null, + department: row.department as string | null, + jobTitle: row.job_title as string | null, + personalMessage: row.personal_message as string | null, + status: row.status as Invitation['status'], + expiresAt: new Date(row.expires_at as string), + sentAt: new Date(row.sent_at as string), + resentCount: row.resent_count as number, + lastResentAt: row.last_resent_at ? new Date(row.last_resent_at as string) : null, + respondedAt: row.responded_at ? new Date(row.responded_at as string) : null, + acceptedUserId: row.accepted_user_id as string | null, + createdAt: new Date(row.created_at as string), + updatedAt: new Date(row.updated_at as string), + invitedBy: row.invited_by as string, + revokedAt: row.revoked_at ? new Date(row.revoked_at as string) : null, + revokedBy: row.revoked_by as string | null, + }; + } + + private mapInvitationWithDetailsRow(row: Record): InvitationWithDetails { + return { + ...this.mapInvitationRow(row), + tenantName: row.tenant_name as string, + roleName: row.role_name as string | null, + invitedByEmail: row.invited_by_email as string, + invitedByName: row.invited_by_name as string | null, + isExpired: row.is_expired as boolean, + }; + } +} + +export const teamService = new TeamService(); diff --git a/src/tools/index.ts b/src/tools/index.ts new file mode 100644 index 0000000..e2676dd --- /dev/null +++ b/src/tools/index.ts @@ -0,0 +1,326 @@ +/** + * MCP Auth Tools - Tool definitions and handlers + */ + +import { z } from 'zod'; +import { authService } from '../services/auth.service'; +import { isAuthError } from '../utils/errors'; +import { logger } from '../utils/logger'; + +// Re-export RBAC tools +export { rbacTools, handleRbacTool } from './rbac.tools'; + +// Re-export Team tools +export { teamTools, handleTeamTool } from './team.tools'; + +// Schema definitions for tool inputs +const RegisterSchema = z.object({ + email: z.string().email('Invalid email format'), + password: z.string().min(8, 'Password must be at least 8 characters'), + firstName: z.string().optional(), + lastName: z.string().optional(), + tenantName: z.string().min(2, 'Tenant name required'), + tenantSlug: z.string().min(2, 'Tenant slug required').regex(/^[a-z0-9-]+$/, 'Slug must be lowercase alphanumeric with hyphens'), +}); + +const LoginSchema = z.object({ + email: z.string().email('Invalid email format'), + password: z.string().min(1, 'Password required'), + tenantId: z.string().uuid().optional(), + deviceInfo: z.object({ + deviceType: z.enum(['desktop', 'mobile', 'tablet', 'unknown']).optional(), + deviceName: z.string().optional(), + browser: z.string().optional(), + browserVersion: z.string().optional(), + os: z.string().optional(), + osVersion: z.string().optional(), + userAgent: z.string().optional(), + ipAddress: z.string().optional(), + }).optional(), +}); + +const RefreshSchema = z.object({ + refreshToken: z.string().min(1, 'Refresh token required'), +}); + +const LogoutSchema = z.object({ + userId: z.string().uuid('Invalid user ID'), + tenantId: z.string().uuid('Invalid tenant ID'), + sessionId: z.string().uuid().optional(), + logoutAll: z.boolean().optional(), +}); + +const PasswordResetRequestSchema = z.object({ + email: z.string().email('Invalid email format'), + tenantId: z.string().uuid('Invalid tenant ID'), +}); + +const PasswordResetSchema = z.object({ + token: z.string().min(1, 'Reset token required'), + newPassword: z.string().min(8, 'Password must be at least 8 characters'), +}); + +const ChangePasswordSchema = z.object({ + userId: z.string().uuid('Invalid user ID'), + tenantId: z.string().uuid('Invalid tenant ID'), + currentPassword: z.string().min(1, 'Current password required'), + newPassword: z.string().min(8, 'New password must be at least 8 characters'), +}); + +const VerifyTokenSchema = z.object({ + token: z.string().min(1, 'Token required'), +}); + +const GetSessionsSchema = z.object({ + userId: z.string().uuid('Invalid user ID'), + tenantId: z.string().uuid('Invalid tenant ID'), +}); + +// Tool definitions for MCP +export const authTools = [ + { + name: 'auth_register', + description: 'Register a new user and create a tenant organization', + inputSchema: { + type: 'object', + properties: { + email: { type: 'string', description: 'User email address' }, + password: { type: 'string', description: 'User password (min 8 chars, uppercase, lowercase, number)' }, + firstName: { type: 'string', description: 'User first name (optional)' }, + lastName: { type: 'string', description: 'User last name (optional)' }, + tenantName: { type: 'string', description: 'Organization/tenant name' }, + tenantSlug: { type: 'string', description: 'Organization slug (lowercase, alphanumeric, hyphens)' }, + }, + required: ['email', 'password', 'tenantName', 'tenantSlug'], + }, + }, + { + name: 'auth_login', + description: 'Authenticate user and get access/refresh tokens', + inputSchema: { + type: 'object', + properties: { + email: { type: 'string', description: 'User email address' }, + password: { type: 'string', description: 'User password' }, + tenantId: { type: 'string', description: 'Tenant ID (required if user has multiple tenants)' }, + deviceInfo: { + type: 'object', + description: 'Device information for session tracking', + properties: { + deviceType: { type: 'string', enum: ['desktop', 'mobile', 'tablet', 'unknown'] }, + deviceName: { type: 'string' }, + browser: { type: 'string' }, + browserVersion: { type: 'string' }, + os: { type: 'string' }, + osVersion: { type: 'string' }, + userAgent: { type: 'string' }, + ipAddress: { type: 'string' }, + }, + }, + }, + required: ['email', 'password'], + }, + }, + { + name: 'auth_refresh', + description: 'Refresh access token using refresh token', + inputSchema: { + type: 'object', + properties: { + refreshToken: { type: 'string', description: 'Refresh token from login' }, + }, + required: ['refreshToken'], + }, + }, + { + name: 'auth_logout', + description: 'Logout user and revoke session(s)', + inputSchema: { + type: 'object', + properties: { + userId: { type: 'string', description: 'User ID' }, + tenantId: { type: 'string', description: 'Tenant ID' }, + sessionId: { type: 'string', description: 'Specific session to revoke (optional)' }, + logoutAll: { type: 'boolean', description: 'Revoke all sessions for user' }, + }, + required: ['userId', 'tenantId'], + }, + }, + { + name: 'auth_request_password_reset', + description: 'Request a password reset email', + inputSchema: { + type: 'object', + properties: { + email: { type: 'string', description: 'User email address' }, + tenantId: { type: 'string', description: 'Tenant ID' }, + }, + required: ['email', 'tenantId'], + }, + }, + { + name: 'auth_reset_password', + description: 'Reset password using reset token', + inputSchema: { + type: 'object', + properties: { + token: { type: 'string', description: 'Password reset token from email' }, + newPassword: { type: 'string', description: 'New password' }, + }, + required: ['token', 'newPassword'], + }, + }, + { + name: 'auth_change_password', + description: 'Change password for authenticated user', + inputSchema: { + type: 'object', + properties: { + userId: { type: 'string', description: 'User ID' }, + tenantId: { type: 'string', description: 'Tenant ID' }, + currentPassword: { type: 'string', description: 'Current password' }, + newPassword: { type: 'string', description: 'New password' }, + }, + required: ['userId', 'tenantId', 'currentPassword', 'newPassword'], + }, + }, + { + name: 'auth_verify_token', + description: 'Verify and decode JWT access token', + inputSchema: { + type: 'object', + properties: { + token: { type: 'string', description: 'JWT access token' }, + }, + required: ['token'], + }, + }, + { + name: 'auth_get_sessions', + description: 'Get all active sessions for a user', + inputSchema: { + type: 'object', + properties: { + userId: { type: 'string', description: 'User ID' }, + tenantId: { type: 'string', description: 'Tenant ID' }, + }, + required: ['userId', 'tenantId'], + }, + }, +]; + +// Tool handlers +export async function handleAuthTool( + toolName: string, + args: Record +): Promise<{ content: Array<{ type: 'text'; text: string }>; isError?: boolean }> { + try { + let result: unknown; + + switch (toolName) { + case 'auth_register': { + const input = RegisterSchema.parse(args); + result = await authService.register(input); + break; + } + + case 'auth_login': { + const input = LoginSchema.parse(args); + result = await authService.login(input); + break; + } + + case 'auth_refresh': { + const input = RefreshSchema.parse(args); + result = await authService.refresh(input); + break; + } + + case 'auth_logout': { + const input = LogoutSchema.parse(args); + result = await authService.logout(input); + break; + } + + case 'auth_request_password_reset': { + const input = PasswordResetRequestSchema.parse(args); + result = await authService.requestPasswordReset(input); + break; + } + + case 'auth_reset_password': { + const input = PasswordResetSchema.parse(args); + result = await authService.resetPassword(input); + break; + } + + case 'auth_change_password': { + const input = ChangePasswordSchema.parse(args); + result = await authService.changePassword(input); + break; + } + + case 'auth_verify_token': { + const input = VerifyTokenSchema.parse(args); + result = await authService.verifyToken(input.token); + break; + } + + case 'auth_get_sessions': { + const input = GetSessionsSchema.parse(args); + result = await authService.getUserSessions(input.userId, input.tenantId); + break; + } + + default: + return { + content: [{ type: 'text', text: `Unknown tool: ${toolName}` }], + isError: true, + }; + } + + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + }; + } catch (error) { + logger.error('Tool execution error', { toolName, error }); + + if (error instanceof z.ZodError) { + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + error: 'Validation error', + details: error.errors.map((e) => ({ + path: e.path.join('.'), + message: e.message, + })), + }), + }, + ], + isError: true, + }; + } + + if (isAuthError(error)) { + return { + content: [{ type: 'text', text: JSON.stringify(error.toJSON()) }], + isError: true, + }; + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + error: 'Internal error', + message: error instanceof Error ? error.message : 'Unknown error', + }), + }, + ], + isError: true, + }; + } +} diff --git a/src/tools/rbac.tools.ts b/src/tools/rbac.tools.ts new file mode 100644 index 0000000..a4dde50 --- /dev/null +++ b/src/tools/rbac.tools.ts @@ -0,0 +1,401 @@ +/** + * MCP RBAC Tools - Role-Based Access Control tool definitions and handlers + */ + +import { z } from 'zod'; +import { rbacService } from '../services/rbac.service'; +import { logger } from '../utils/logger'; + +// Schema definitions for RBAC tool inputs +const ListRolesSchema = z.object({ + tenantId: z.string().uuid('Invalid tenant ID'), + includeSystem: z.boolean().optional(), + includeInactive: z.boolean().optional(), +}); + +const GetRoleSchema = z.object({ + roleId: z.string().uuid('Invalid role ID'), + tenantId: z.string().uuid('Invalid tenant ID'), +}); + +const CreateRoleSchema = z.object({ + tenantId: z.string().uuid('Invalid tenant ID'), + name: z.string().min(2, 'Role name must be at least 2 characters'), + slug: z.string().regex(/^[a-z0-9-]+$/, 'Slug must be lowercase alphanumeric with hyphens').optional(), + description: z.string().optional(), + hierarchyLevel: z.number().int().min(0).max(1000).optional(), + createdBy: z.string().uuid('Invalid user ID'), +}); + +const UpdateRoleSchema = z.object({ + roleId: z.string().uuid('Invalid role ID'), + tenantId: z.string().uuid('Invalid tenant ID'), + name: z.string().min(2).optional(), + description: z.string().optional(), + hierarchyLevel: z.number().int().min(0).max(1000).optional(), + isActive: z.boolean().optional(), + updatedBy: z.string().uuid('Invalid user ID'), +}); + +const DeleteRoleSchema = z.object({ + roleId: z.string().uuid('Invalid role ID'), + tenantId: z.string().uuid('Invalid tenant ID'), +}); + +const ListPermissionsSchema = z.object({ + module: z.string().optional(), + category: z.string().optional(), +}); + +const AssignPermissionsSchema = z.object({ + roleId: z.string().uuid('Invalid role ID'), + tenantId: z.string().uuid('Invalid tenant ID'), + permissionCodes: z.array(z.string()).min(1, 'At least one permission required'), + createdBy: z.string().uuid('Invalid user ID'), +}); + +const AssignRoleSchema = z.object({ + userId: z.string().uuid('Invalid user ID'), + roleId: z.string().uuid('Invalid role ID'), + tenantId: z.string().uuid('Invalid tenant ID'), + isPrimary: z.boolean().optional(), + validUntil: z.string().datetime().optional(), + assignedBy: z.string().uuid('Invalid user ID'), +}); + +const RevokeRoleSchema = z.object({ + userId: z.string().uuid('Invalid user ID'), + roleId: z.string().uuid('Invalid role ID'), + tenantId: z.string().uuid('Invalid tenant ID'), + revokedBy: z.string().uuid('Invalid user ID'), +}); + +const CheckPermissionSchema = z.object({ + userId: z.string().uuid('Invalid user ID'), + tenantId: z.string().uuid('Invalid tenant ID'), + permissionCode: z.string().min(1, 'Permission code required'), +}); + +const GetUserPermissionsSchema = z.object({ + userId: z.string().uuid('Invalid user ID'), + tenantId: z.string().uuid('Invalid tenant ID'), +}); + +const InitializeTenantRolesSchema = z.object({ + tenantId: z.string().uuid('Invalid tenant ID'), + ownerId: z.string().uuid('Invalid owner ID'), +}); + +// Tool definitions for MCP +export const rbacTools = [ + { + name: 'rbac_list_roles', + description: 'List all roles for a tenant', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', description: 'Tenant ID' }, + includeSystem: { type: 'boolean', description: 'Include system roles' }, + includeInactive: { type: 'boolean', description: 'Include inactive roles' }, + }, + required: ['tenantId'], + }, + }, + { + name: 'rbac_get_role', + description: 'Get role details with permissions', + inputSchema: { + type: 'object', + properties: { + roleId: { type: 'string', description: 'Role ID' }, + tenantId: { type: 'string', description: 'Tenant ID' }, + }, + required: ['roleId', 'tenantId'], + }, + }, + { + name: 'rbac_create_role', + description: 'Create a new custom role', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', description: 'Tenant ID' }, + name: { type: 'string', description: 'Role name' }, + slug: { type: 'string', description: 'Role slug (auto-generated if not provided)' }, + description: { type: 'string', description: 'Role description' }, + hierarchyLevel: { type: 'number', description: 'Hierarchy level (0=highest, higher=lower)' }, + createdBy: { type: 'string', description: 'User ID creating the role' }, + }, + required: ['tenantId', 'name', 'createdBy'], + }, + }, + { + name: 'rbac_update_role', + description: 'Update an existing role', + inputSchema: { + type: 'object', + properties: { + roleId: { type: 'string', description: 'Role ID' }, + tenantId: { type: 'string', description: 'Tenant ID' }, + name: { type: 'string', description: 'New role name' }, + description: { type: 'string', description: 'New description' }, + hierarchyLevel: { type: 'number', description: 'New hierarchy level' }, + isActive: { type: 'boolean', description: 'Active status' }, + updatedBy: { type: 'string', description: 'User ID updating the role' }, + }, + required: ['roleId', 'tenantId', 'updatedBy'], + }, + }, + { + name: 'rbac_delete_role', + description: 'Delete a custom role', + inputSchema: { + type: 'object', + properties: { + roleId: { type: 'string', description: 'Role ID' }, + tenantId: { type: 'string', description: 'Tenant ID' }, + }, + required: ['roleId', 'tenantId'], + }, + }, + { + name: 'rbac_list_permissions', + description: 'List all available permissions', + inputSchema: { + type: 'object', + properties: { + module: { type: 'string', description: 'Filter by module' }, + category: { type: 'string', description: 'Filter by category' }, + }, + }, + }, + { + name: 'rbac_assign_permissions', + description: 'Assign permissions to a role', + inputSchema: { + type: 'object', + properties: { + roleId: { type: 'string', description: 'Role ID' }, + tenantId: { type: 'string', description: 'Tenant ID' }, + permissionCodes: { + type: 'array', + items: { type: 'string' }, + description: 'Permission codes to assign', + }, + createdBy: { type: 'string', description: 'User ID assigning permissions' }, + }, + required: ['roleId', 'tenantId', 'permissionCodes', 'createdBy'], + }, + }, + { + name: 'rbac_assign_role', + description: 'Assign a role to a user', + inputSchema: { + type: 'object', + properties: { + userId: { type: 'string', description: 'User ID' }, + roleId: { type: 'string', description: 'Role ID' }, + tenantId: { type: 'string', description: 'Tenant ID' }, + isPrimary: { type: 'boolean', description: 'Set as primary role' }, + validUntil: { type: 'string', description: 'Expiration date (ISO format)' }, + assignedBy: { type: 'string', description: 'User ID assigning the role' }, + }, + required: ['userId', 'roleId', 'tenantId', 'assignedBy'], + }, + }, + { + name: 'rbac_revoke_role', + description: 'Revoke a role from a user', + inputSchema: { + type: 'object', + properties: { + userId: { type: 'string', description: 'User ID' }, + roleId: { type: 'string', description: 'Role ID' }, + tenantId: { type: 'string', description: 'Tenant ID' }, + revokedBy: { type: 'string', description: 'User ID revoking the role' }, + }, + required: ['userId', 'roleId', 'tenantId', 'revokedBy'], + }, + }, + { + name: 'rbac_check_permission', + description: 'Check if user has a specific permission', + inputSchema: { + type: 'object', + properties: { + userId: { type: 'string', description: 'User ID' }, + tenantId: { type: 'string', description: 'Tenant ID' }, + permissionCode: { type: 'string', description: 'Permission code to check' }, + }, + required: ['userId', 'tenantId', 'permissionCode'], + }, + }, + { + name: 'rbac_get_user_permissions', + description: 'Get all permissions for a user', + inputSchema: { + type: 'object', + properties: { + userId: { type: 'string', description: 'User ID' }, + tenantId: { type: 'string', description: 'Tenant ID' }, + }, + required: ['userId', 'tenantId'], + }, + }, + { + name: 'rbac_initialize_tenant', + description: 'Initialize default roles for a new tenant', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', description: 'Tenant ID' }, + ownerId: { type: 'string', description: 'Owner user ID' }, + }, + required: ['tenantId', 'ownerId'], + }, + }, +]; + +// Tool handlers +export async function handleRbacTool( + toolName: string, + args: Record +): Promise<{ content: Array<{ type: 'text'; text: string }>; isError?: boolean }> { + try { + let result: unknown; + + switch (toolName) { + case 'rbac_list_roles': { + const input = ListRolesSchema.parse(args); + result = await rbacService.listRoles(input); + break; + } + + case 'rbac_get_role': { + const input = GetRoleSchema.parse(args); + const role = await rbacService.getRole(input.roleId, input.tenantId); + if (!role) { + return { + content: [{ type: 'text', text: JSON.stringify({ error: 'Role not found', code: 'ROLE_NOT_FOUND' }) }], + isError: true, + }; + } + result = role; + break; + } + + case 'rbac_create_role': { + const input = CreateRoleSchema.parse(args); + result = await rbacService.createRole(input); + break; + } + + case 'rbac_update_role': { + const input = UpdateRoleSchema.parse(args); + result = await rbacService.updateRole(input); + break; + } + + case 'rbac_delete_role': { + const input = DeleteRoleSchema.parse(args); + const deleted = await rbacService.deleteRole(input.roleId, input.tenantId); + result = { success: deleted }; + break; + } + + case 'rbac_list_permissions': { + const input = ListPermissionsSchema.parse(args); + result = await rbacService.listPermissions(input); + break; + } + + case 'rbac_assign_permissions': { + const input = AssignPermissionsSchema.parse(args); + const count = await rbacService.assignPermissions(input); + result = { assigned: count }; + break; + } + + case 'rbac_assign_role': { + const input = AssignRoleSchema.parse(args); + const parsedInput = { + ...input, + validUntil: input.validUntil ? new Date(input.validUntil) : undefined, + }; + result = await rbacService.assignRoleToUser(parsedInput); + break; + } + + case 'rbac_revoke_role': { + const input = RevokeRoleSchema.parse(args); + const revoked = await rbacService.revokeRoleFromUser(input); + result = { success: revoked }; + break; + } + + case 'rbac_check_permission': { + const input = CheckPermissionSchema.parse(args); + const hasPermission = await rbacService.checkPermission(input); + result = { hasPermission }; + break; + } + + case 'rbac_get_user_permissions': { + const input = GetUserPermissionsSchema.parse(args); + result = await rbacService.getUserPermissions(input); + break; + } + + case 'rbac_initialize_tenant': { + const input = InitializeTenantRolesSchema.parse(args); + await rbacService.initializeTenantRoles(input.tenantId, input.ownerId); + result = { success: true, message: 'Tenant roles initialized' }; + break; + } + + default: + return { + content: [{ type: 'text', text: `Unknown tool: ${toolName}` }], + isError: true, + }; + } + + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + }; + } catch (error) { + logger.error('RBAC tool execution error', { toolName, error }); + + if (error instanceof z.ZodError) { + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + error: 'Validation error', + details: error.errors.map((e) => ({ + path: e.path.join('.'), + message: e.message, + })), + }), + }, + ], + isError: true, + }; + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + error: 'Internal error', + code: 'RBAC_ERROR', + message: error instanceof Error ? error.message : 'Unknown error', + }), + }, + ], + isError: true, + }; + } +} diff --git a/src/tools/team.tools.ts b/src/tools/team.tools.ts new file mode 100644 index 0000000..2b6356f --- /dev/null +++ b/src/tools/team.tools.ts @@ -0,0 +1,438 @@ +/** + * MCP Team Tools - Team management tool definitions and handlers + */ + +import { z } from 'zod'; +import { teamService } from '../services/team.service'; +import { logger } from '../utils/logger'; + +// Schema definitions for Team tool inputs +const AddTeamMemberSchema = z.object({ + tenantId: z.string().uuid('Invalid tenant ID'), + userId: z.string().uuid('Invalid user ID'), + memberRole: z.enum(['owner', 'admin', 'member', 'guest']).optional(), + department: z.string().optional(), + jobTitle: z.string().optional(), + addedBy: z.string().uuid('Invalid user ID'), +}); + +const UpdateTeamMemberSchema = z.object({ + tenantId: z.string().uuid('Invalid tenant ID'), + userId: z.string().uuid('Invalid user ID'), + memberRole: z.enum(['owner', 'admin', 'member', 'guest']).optional(), + department: z.string().optional(), + jobTitle: z.string().optional(), + status: z.enum(['active', 'inactive', 'suspended']).optional(), + updatedBy: z.string().uuid('Invalid user ID'), +}); + +const RemoveTeamMemberSchema = z.object({ + tenantId: z.string().uuid('Invalid tenant ID'), + userId: z.string().uuid('Invalid user ID'), + removedBy: z.string().uuid('Invalid user ID'), +}); + +const ListTeamMembersSchema = z.object({ + tenantId: z.string().uuid('Invalid tenant ID'), + status: z.enum(['active', 'inactive', 'suspended']).optional(), + memberRole: z.enum(['owner', 'admin', 'member', 'guest']).optional(), + department: z.string().optional(), +}); + +const GetTeamMemberSchema = z.object({ + tenantId: z.string().uuid('Invalid tenant ID'), + userId: z.string().uuid('Invalid user ID'), +}); + +const CreateInvitationSchema = z.object({ + tenantId: z.string().uuid('Invalid tenant ID'), + email: z.string().email('Invalid email'), + invitedBy: z.string().uuid('Invalid user ID'), + roleId: z.string().uuid().optional(), + firstName: z.string().optional(), + lastName: z.string().optional(), + department: z.string().optional(), + jobTitle: z.string().optional(), + personalMessage: z.string().optional(), + expiresInDays: z.number().int().min(1).max(30).optional(), +}); + +const AcceptInvitationSchema = z.object({ + token: z.string().min(1, 'Token required'), + userId: z.string().uuid('Invalid user ID'), +}); + +const ResendInvitationSchema = z.object({ + invitationId: z.string().uuid('Invalid invitation ID'), + resentBy: z.string().uuid('Invalid user ID'), +}); + +const RevokeInvitationSchema = z.object({ + invitationId: z.string().uuid('Invalid invitation ID'), + revokedBy: z.string().uuid('Invalid user ID'), +}); + +const ListInvitationsSchema = z.object({ + tenantId: z.string().uuid('Invalid tenant ID'), + status: z.enum(['pending', 'accepted', 'declined', 'expired', 'revoked']).optional(), +}); + +const GetInvitationSchema = z.object({ + invitationId: z.string().uuid('Invalid invitation ID'), +}); + +const GetInvitationByTokenSchema = z.object({ + token: z.string().min(1, 'Token required'), +}); + +const GetTeamMemberCountSchema = z.object({ + tenantId: z.string().uuid('Invalid tenant ID'), +}); + +// Tool definitions for MCP +export const teamTools = [ + { + name: 'team_add_member', + description: 'Add a user as a team member', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', description: 'Tenant ID' }, + userId: { type: 'string', description: 'User ID to add' }, + memberRole: { + type: 'string', + enum: ['owner', 'admin', 'member', 'guest'], + description: 'Team member role', + }, + department: { type: 'string', description: 'Department' }, + jobTitle: { type: 'string', description: 'Job title' }, + addedBy: { type: 'string', description: 'User ID adding the member' }, + }, + required: ['tenantId', 'userId', 'addedBy'], + }, + }, + { + name: 'team_update_member', + description: 'Update a team member', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', description: 'Tenant ID' }, + userId: { type: 'string', description: 'User ID' }, + memberRole: { type: 'string', enum: ['owner', 'admin', 'member', 'guest'] }, + department: { type: 'string' }, + jobTitle: { type: 'string' }, + status: { type: 'string', enum: ['active', 'inactive', 'suspended'] }, + updatedBy: { type: 'string', description: 'User ID updating' }, + }, + required: ['tenantId', 'userId', 'updatedBy'], + }, + }, + { + name: 'team_remove_member', + description: 'Remove a team member', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', description: 'Tenant ID' }, + userId: { type: 'string', description: 'User ID to remove' }, + removedBy: { type: 'string', description: 'User ID removing' }, + }, + required: ['tenantId', 'userId', 'removedBy'], + }, + }, + { + name: 'team_list_members', + description: 'List team members', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', description: 'Tenant ID' }, + status: { type: 'string', enum: ['active', 'inactive', 'suspended'] }, + memberRole: { type: 'string', enum: ['owner', 'admin', 'member', 'guest'] }, + department: { type: 'string' }, + }, + required: ['tenantId'], + }, + }, + { + name: 'team_get_member', + description: 'Get team member details', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', description: 'Tenant ID' }, + userId: { type: 'string', description: 'User ID' }, + }, + required: ['tenantId', 'userId'], + }, + }, + { + name: 'team_get_member_count', + description: 'Get active team member count', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', description: 'Tenant ID' }, + }, + required: ['tenantId'], + }, + }, + { + name: 'team_create_invitation', + description: 'Create a team invitation', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', description: 'Tenant ID' }, + email: { type: 'string', description: 'Email to invite' }, + invitedBy: { type: 'string', description: 'User ID inviting' }, + roleId: { type: 'string', description: 'Role to assign on acceptance' }, + firstName: { type: 'string' }, + lastName: { type: 'string' }, + department: { type: 'string' }, + jobTitle: { type: 'string' }, + personalMessage: { type: 'string', description: 'Personal message for invitation' }, + expiresInDays: { type: 'number', description: 'Days until expiration (1-30)' }, + }, + required: ['tenantId', 'email', 'invitedBy'], + }, + }, + { + name: 'team_accept_invitation', + description: 'Accept a team invitation', + inputSchema: { + type: 'object', + properties: { + token: { type: 'string', description: 'Invitation token' }, + userId: { type: 'string', description: 'User ID accepting' }, + }, + required: ['token', 'userId'], + }, + }, + { + name: 'team_resend_invitation', + description: 'Resend a pending invitation', + inputSchema: { + type: 'object', + properties: { + invitationId: { type: 'string', description: 'Invitation ID' }, + resentBy: { type: 'string', description: 'User ID resending' }, + }, + required: ['invitationId', 'resentBy'], + }, + }, + { + name: 'team_revoke_invitation', + description: 'Revoke a pending invitation', + inputSchema: { + type: 'object', + properties: { + invitationId: { type: 'string', description: 'Invitation ID' }, + revokedBy: { type: 'string', description: 'User ID revoking' }, + }, + required: ['invitationId', 'revokedBy'], + }, + }, + { + name: 'team_list_invitations', + description: 'List team invitations', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', description: 'Tenant ID' }, + status: { + type: 'string', + enum: ['pending', 'accepted', 'declined', 'expired', 'revoked'], + }, + }, + required: ['tenantId'], + }, + }, + { + name: 'team_get_invitation', + description: 'Get invitation details', + inputSchema: { + type: 'object', + properties: { + invitationId: { type: 'string', description: 'Invitation ID' }, + }, + required: ['invitationId'], + }, + }, + { + name: 'team_get_invitation_by_token', + description: 'Get invitation by token (for acceptance page)', + inputSchema: { + type: 'object', + properties: { + token: { type: 'string', description: 'Invitation token' }, + }, + required: ['token'], + }, + }, +]; + +// Tool handlers +export async function handleTeamTool( + toolName: string, + args: Record +): Promise<{ content: Array<{ type: 'text'; text: string }>; isError?: boolean }> { + try { + let result: unknown; + + switch (toolName) { + case 'team_add_member': { + const input = AddTeamMemberSchema.parse(args); + result = await teamService.addTeamMember(input); + break; + } + + case 'team_update_member': { + const input = UpdateTeamMemberSchema.parse(args); + result = await teamService.updateTeamMember(input); + break; + } + + case 'team_remove_member': { + const input = RemoveTeamMemberSchema.parse(args); + const removed = await teamService.removeTeamMember(input); + result = { success: removed }; + break; + } + + case 'team_list_members': { + const input = ListTeamMembersSchema.parse(args); + result = await teamService.listTeamMembers(input); + break; + } + + case 'team_get_member': { + const input = GetTeamMemberSchema.parse(args); + const member = await teamService.getTeamMember(input.tenantId, input.userId); + if (!member) { + return { + content: [ + { type: 'text', text: JSON.stringify({ error: 'Member not found', code: 'MEMBER_NOT_FOUND' }) }, + ], + isError: true, + }; + } + result = member; + break; + } + + case 'team_get_member_count': { + const input = GetTeamMemberCountSchema.parse(args); + const count = await teamService.getTeamMemberCount(input.tenantId); + result = { count }; + break; + } + + case 'team_create_invitation': { + const input = CreateInvitationSchema.parse(args); + result = await teamService.createInvitation(input); + break; + } + + case 'team_accept_invitation': { + const input = AcceptInvitationSchema.parse(args); + result = await teamService.acceptInvitation(input); + break; + } + + case 'team_resend_invitation': { + const input = ResendInvitationSchema.parse(args); + result = await teamService.resendInvitation(input); + break; + } + + case 'team_revoke_invitation': { + const input = RevokeInvitationSchema.parse(args); + const revoked = await teamService.revokeInvitation(input); + result = { success: revoked }; + break; + } + + case 'team_list_invitations': { + const input = ListInvitationsSchema.parse(args); + result = await teamService.listInvitations(input); + break; + } + + case 'team_get_invitation': { + const input = GetInvitationSchema.parse(args); + const invitation = await teamService.getInvitation(input.invitationId); + if (!invitation) { + return { + content: [ + { type: 'text', text: JSON.stringify({ error: 'Invitation not found', code: 'INVITATION_NOT_FOUND' }) }, + ], + isError: true, + }; + } + result = invitation; + break; + } + + case 'team_get_invitation_by_token': { + const input = GetInvitationByTokenSchema.parse(args); + const invitation = await teamService.getInvitationByToken(input); + if (!invitation) { + return { + content: [ + { type: 'text', text: JSON.stringify({ error: 'Invalid invitation token', code: 'INVALID_TOKEN' }) }, + ], + isError: true, + }; + } + result = invitation; + break; + } + + default: + return { + content: [{ type: 'text', text: `Unknown tool: ${toolName}` }], + isError: true, + }; + } + + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + }; + } catch (error) { + logger.error('Team tool execution error', { toolName, error }); + + if (error instanceof z.ZodError) { + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + error: 'Validation error', + details: error.errors.map((e) => ({ + path: e.path.join('.'), + message: e.message, + })), + }), + }, + ], + isError: true, + }; + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + error: 'Internal error', + code: 'TEAM_ERROR', + message: error instanceof Error ? error.message : 'Unknown error', + }), + }, + ], + isError: true, + }; + } +} diff --git a/src/types/auth.types.ts b/src/types/auth.types.ts new file mode 100644 index 0000000..ad18e14 --- /dev/null +++ b/src/types/auth.types.ts @@ -0,0 +1,189 @@ +/** + * Auth Types + */ + +// User status enum (matches DDL) +export type UserStatus = 'pending' | 'active' | 'suspended' | 'banned' | 'deleted'; + +// Session status enum (matches DDL) +export type SessionStatus = 'active' | 'expired' | 'revoked'; + +// Device type enum (matches DDL) +export type DeviceType = 'desktop' | 'mobile' | 'tablet' | 'unknown'; + +// Token type enum (matches DDL) +export type TokenType = 'refresh' | 'password_reset' | 'email_verify' | 'phone_verify' | 'api_key' | 'invitation'; + +// Token status enum (matches DDL) +export type TokenStatus = 'active' | 'used' | 'expired' | 'revoked'; + +// User interface +export interface User { + id: string; + tenantId: string; + email: string; + passwordHash: string; + firstName: string | null; + lastName: string | null; + displayName: string | null; + avatarUrl: string | null; + phone: string | null; + status: UserStatus; + isOwner: boolean; + emailVerified: boolean; + emailVerifiedAt: Date | null; + phoneVerified: boolean; + phoneVerifiedAt: Date | null; + mfaEnabled: boolean; + mfaSecret: string | null; + passwordChangedAt: Date; + failedLoginAttempts: number; + lockedUntil: Date | null; + lastLoginAt: Date | null; + lastLoginIp: string | null; + preferences: UserPreferences; + createdAt: Date; + updatedAt: Date; +} + +export interface UserPreferences { + theme: 'light' | 'dark'; + language: string; + notifications: { + email: boolean; + push: boolean; + sms: boolean; + }; +} + +// Session interface +export interface Session { + id: string; + userId: string; + tenantId: string; + tokenHash: string; + deviceType: DeviceType; + deviceName: string | null; + browser: string | null; + browserVersion: string | null; + os: string | null; + osVersion: string | null; + ipAddress: string | null; + userAgent: string | null; + status: SessionStatus; + lastActiveAt: Date; + expiresAt: Date; + revokedAt: Date | null; + createdAt: Date; +} + +// Token interface +export interface Token { + id: string; + userId: string | null; + tenantId: string; + tokenType: TokenType; + tokenHash: string; + status: TokenStatus; + metadata: Record; + expiresAt: Date; + usedAt: Date | null; + revokedAt: Date | null; + createdAt: Date; +} + +// Tenant interface (basic for auth context) +export interface Tenant { + id: string; + name: string; + slug: string; + status: 'pending' | 'active' | 'suspended' | 'deleted'; +} + +// JWT Payload +export interface JWTPayload { + sub: string; // user_id + email: string; + tenantId: string; + isOwner: boolean; + iat: number; + exp: number; +} + +// Auth request/response types +export interface RegisterInput { + email: string; + password: string; + firstName?: string; + lastName?: string; + tenantName?: string; // For new tenant creation + tenantSlug?: string; +} + +export interface LoginInput { + email: string; + password: string; + tenantId?: string; // Optional if only one tenant + deviceInfo?: DeviceInfo; +} + +export interface DeviceInfo { + deviceType?: DeviceType; + deviceName?: string; + browser?: string; + browserVersion?: string; + os?: string; + osVersion?: string; + userAgent?: string; + ipAddress?: string; +} + +export interface AuthTokens { + accessToken: string; + refreshToken: string; + expiresIn: number; // seconds + tokenType: 'Bearer'; +} + +export interface LoginResponse { + user: Omit; + tokens: AuthTokens; + session: { + id: string; + deviceType: DeviceType; + expiresAt: Date; + }; +} + +export interface RefreshInput { + refreshToken: string; +} + +export interface RefreshResponse { + accessToken: string; + expiresIn: number; +} + +export interface PasswordResetRequestInput { + email: string; + tenantId: string; +} + +export interface PasswordResetInput { + token: string; + newPassword: string; +} + +export interface ChangePasswordInput { + userId: string; + tenantId: string; + currentPassword: string; + newPassword: string; +} + +export interface LogoutInput { + sessionId?: string; + userId: string; + tenantId: string; + logoutAll?: boolean; +} diff --git a/src/types/rbac.types.ts b/src/types/rbac.types.ts new file mode 100644 index 0000000..c4f267a --- /dev/null +++ b/src/types/rbac.types.ts @@ -0,0 +1,164 @@ +/** + * RBAC Types + */ + +// Role type enum +export type RoleType = 'system' | 'custom'; + +// Grant type enum +export type GrantType = 'allow' | 'deny'; + +// Permission action enum +export type PermissionAction = 'create' | 'read' | 'update' | 'delete' | 'manage' | 'execute'; + +// Role interface +export interface Role { + id: string; + tenantId: string; + name: string; + slug: string; + description: string | null; + roleType: RoleType; + hierarchyLevel: number; + isActive: boolean; + settings: Record; + createdAt: Date; + updatedAt: Date; + createdBy: string | null; + updatedBy: string | null; +} + +// Permission interface +export interface Permission { + id: string; + code: string; + name: string; + description: string | null; + module: string; + category: string; + action: PermissionAction; + resource: string; + isSystem: boolean; + isActive: boolean; + createdAt: Date; + updatedAt: Date; +} + +// Role permission mapping +export interface RolePermission { + id: string; + roleId: string; + permissionId: string; + grantType: GrantType; + conditions: Record | null; + createdAt: Date; + createdBy: string | null; +} + +// User role assignment +export interface UserRole { + id: string; + userId: string; + roleId: string; + tenantId: string; + isPrimary: boolean; + assignedReason: string | null; + validFrom: Date; + validUntil: Date | null; + isActive: boolean; + createdAt: Date; + updatedAt: Date; + assignedBy: string | null; + revokedAt: Date | null; + revokedBy: string | null; +} + +// Role with permissions +export interface RoleWithPermissions extends Role { + permissions: { + code: string; + name: string; + module: string; + action: PermissionAction; + resource: string; + grantType: GrantType; + }[]; +} + +// User with roles and permissions +export interface UserPermissions { + userId: string; + tenantId: string; + roles: { + id: string; + name: string; + slug: string; + isPrimary: boolean; + }[]; + permissions: string[]; // Permission codes +} + +// Input types +export interface CreateRoleInput { + tenantId: string; + name: string; + slug?: string; + description?: string; + hierarchyLevel?: number; + createdBy: string; +} + +export interface UpdateRoleInput { + roleId: string; + tenantId: string; + name?: string; + description?: string; + hierarchyLevel?: number; + isActive?: boolean; + updatedBy: string; +} + +export interface AssignPermissionsInput { + roleId: string; + tenantId: string; + permissionCodes: string[]; + createdBy: string; +} + +export interface AssignRoleInput { + userId: string; + roleId: string; + tenantId: string; + isPrimary?: boolean; + validUntil?: Date; + assignedBy: string; +} + +export interface RevokeRoleInput { + userId: string; + roleId: string; + tenantId: string; + revokedBy: string; +} + +export interface CheckPermissionInput { + userId: string; + tenantId: string; + permissionCode: string; +} + +export interface GetUserPermissionsInput { + userId: string; + tenantId: string; +} + +export interface ListRolesInput { + tenantId: string; + includeSystem?: boolean; + includeInactive?: boolean; +} + +export interface ListPermissionsInput { + module?: string; + category?: string; +} diff --git a/src/types/team.types.ts b/src/types/team.types.ts new file mode 100644 index 0000000..54b5bb4 --- /dev/null +++ b/src/types/team.types.ts @@ -0,0 +1,164 @@ +/** + * Team Management Types + */ + +// Team member role enum +export type TeamMemberRole = 'owner' | 'admin' | 'member' | 'guest'; + +// Team member status +export type TeamMemberStatus = 'active' | 'inactive' | 'suspended'; + +// Invitation status +export type InvitationStatus = 'pending' | 'accepted' | 'declined' | 'expired' | 'revoked'; + +// Team member interface +export interface TeamMember { + id: string; + tenantId: string; + userId: string; + memberRole: TeamMemberRole; + status: TeamMemberStatus; + department: string | null; + jobTitle: string | null; + joinedViaInvitation: string | null; + joinedAt: Date; + lastActiveAt: Date | null; + settings: Record; + createdAt: Date; + updatedAt: Date; + addedBy: string | null; + removedAt: Date | null; + removedBy: string | null; +} + +// Team member with user details +export interface TeamMemberWithUser extends TeamMember { + userEmail: string; + userDisplayName: string | null; + userFirstName: string | null; + userLastName: string | null; + userAvatarUrl: string | null; + primaryRoleName: string | null; +} + +// Invitation interface +export interface Invitation { + id: string; + tenantId: string; + email: string; + firstName: string | null; + lastName: string | null; + roleId: string | null; + department: string | null; + jobTitle: string | null; + personalMessage: string | null; + status: InvitationStatus; + expiresAt: Date; + sentAt: Date; + resentCount: number; + lastResentAt: Date | null; + respondedAt: Date | null; + acceptedUserId: string | null; + createdAt: Date; + updatedAt: Date; + invitedBy: string; + revokedAt: Date | null; + revokedBy: string | null; +} + +// Invitation with details +export interface InvitationWithDetails extends Invitation { + tenantName: string; + roleName: string | null; + invitedByEmail: string; + invitedByName: string | null; + isExpired: boolean; +} + +// Input types +export interface AddTeamMemberInput { + tenantId: string; + userId: string; + memberRole?: TeamMemberRole; + department?: string; + jobTitle?: string; + addedBy: string; +} + +export interface UpdateTeamMemberInput { + tenantId: string; + userId: string; + memberRole?: TeamMemberRole; + department?: string; + jobTitle?: string; + status?: TeamMemberStatus; + updatedBy: string; +} + +export interface RemoveTeamMemberInput { + tenantId: string; + userId: string; + removedBy: string; +} + +export interface ListTeamMembersInput { + tenantId: string; + status?: TeamMemberStatus; + memberRole?: TeamMemberRole; + department?: string; +} + +export interface CreateInvitationInput { + tenantId: string; + email: string; + invitedBy: string; + roleId?: string; + firstName?: string; + lastName?: string; + department?: string; + jobTitle?: string; + personalMessage?: string; + expiresInDays?: number; +} + +export interface CreateInvitationResult { + invitationId: string; + token: string; +} + +export interface AcceptInvitationInput { + token: string; + userId: string; +} + +export interface AcceptInvitationResult { + success: boolean; + tenantId: string | null; + roleId: string | null; + message: string; +} + +export interface ResendInvitationInput { + invitationId: string; + resentBy: string; +} + +export interface ResendInvitationResult { + success: boolean; + token: string | null; + message: string; +} + +export interface RevokeInvitationInput { + invitationId: string; + revokedBy: string; +} + +export interface ListInvitationsInput { + tenantId: string; + status?: InvitationStatus; +} + +export interface GetInvitationByTokenInput { + token: string; +} diff --git a/src/utils/errors.ts b/src/utils/errors.ts new file mode 100644 index 0000000..a40c18c --- /dev/null +++ b/src/utils/errors.ts @@ -0,0 +1,130 @@ +/** + * Custom error classes for Auth operations + */ + +export class AuthError extends Error { + public readonly code: string; + public readonly statusCode: number; + public readonly details?: Record; + + constructor( + message: string, + code: string, + statusCode: number = 400, + details?: Record + ) { + super(message); + this.name = 'AuthError'; + this.code = code; + this.statusCode = statusCode; + this.details = details; + } + + toJSON() { + return { + error: this.message, + code: this.code, + details: this.details, + }; + } +} + +// Specific error types +export class InvalidCredentialsError extends AuthError { + constructor(message: string = 'Invalid email or password') { + super(message, 'INVALID_CREDENTIALS', 401); + } +} + +export class UserNotFoundError extends AuthError { + constructor(identifier: string) { + super(`User not found: ${identifier}`, 'USER_NOT_FOUND', 404); + } +} + +export class UserAlreadyExistsError extends AuthError { + constructor(email: string) { + super(`User with email ${email} already exists`, 'USER_ALREADY_EXISTS', 409); + } +} + +export class TenantNotFoundError extends AuthError { + constructor(identifier: string) { + super(`Tenant not found: ${identifier}`, 'TENANT_NOT_FOUND', 404); + } +} + +export class SessionExpiredError extends AuthError { + constructor() { + super('Session has expired', 'SESSION_EXPIRED', 401); + } +} + +export class SessionRevokedError extends AuthError { + constructor() { + super('Session has been revoked', 'SESSION_REVOKED', 401); + } +} + +export class InvalidTokenError extends AuthError { + constructor(message: string = 'Invalid or expired token') { + super(message, 'INVALID_TOKEN', 401); + } +} + +export class AccountLockedError extends AuthError { + constructor(lockedUntil: Date) { + super( + `Account is locked until ${lockedUntil.toISOString()}`, + 'ACCOUNT_LOCKED', + 423, + { lockedUntil } + ); + } +} + +export class AccountSuspendedError extends AuthError { + constructor() { + super('Account has been suspended', 'ACCOUNT_SUSPENDED', 403); + } +} + +export class AccountNotVerifiedError extends AuthError { + constructor() { + super('Email not verified. Please verify your email first.', 'EMAIL_NOT_VERIFIED', 403); + } +} + +export class PasswordMismatchError extends AuthError { + constructor() { + super('Current password is incorrect', 'PASSWORD_MISMATCH', 400); + } +} + +export class WeakPasswordError extends AuthError { + constructor(requirements: string[]) { + super( + 'Password does not meet requirements', + 'WEAK_PASSWORD', + 400, + { requirements } + ); + } +} + +export class MFARequiredError extends AuthError { + constructor() { + super('MFA verification required', 'MFA_REQUIRED', 403); + } +} + +export class InvalidMFACodeError extends AuthError { + constructor() { + super('Invalid MFA code', 'INVALID_MFA_CODE', 401); + } +} + +// Type guard +export function isAuthError(error: unknown): error is AuthError { + return error instanceof AuthError; +} diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..24641a6 --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,45 @@ +/** + * Logger utility for MCP Auth Server + */ + +import winston from 'winston'; + +const logLevel = process.env.LOG_LEVEL || 'info'; + +export const logger = winston.createLogger({ + level: logLevel, + format: winston.format.combine( + winston.format.timestamp(), + winston.format.errors({ stack: true }), + winston.format.json() + ), + defaultMeta: { service: 'mcp-auth' }, + transports: [ + new winston.transports.Console({ + format: winston.format.combine( + winston.format.colorize(), + winston.format.printf(({ level, message, timestamp, ...meta }) => { + const metaStr = Object.keys(meta).length > 1 + ? ` ${JSON.stringify(meta)}` + : ''; + return `${timestamp} [${level}]: ${message}${metaStr}`; + }) + ), + }), + ], +}); + +// Add file transport in production +if (process.env.NODE_ENV === 'production') { + logger.add( + new winston.transports.File({ + filename: 'logs/error.log', + level: 'error', + }) + ); + logger.add( + new winston.transports.File({ + filename: 'logs/combined.log', + }) + ); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..39562be --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "moduleResolution": "node" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +}