Migración desde trading-platform/apps/mcp-auth - Estándar multi-repo v2

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-16 08:33:07 -06:00
commit a9de3e4331
20 changed files with 5049 additions and 0 deletions

62
Dockerfile Normal file
View File

@ -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"]

20
jest.config.js Normal file
View File

@ -0,0 +1,20 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/*.test.ts'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/index.ts',
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
setupFilesAfterEnv: ['<rootDir>/src/__tests__/setup.ts'],
testTimeout: 30000,
verbose: true,
};

55
package.json Normal file
View File

@ -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
}

View File

@ -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);
});
});

View File

@ -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);
});
});

25
src/__tests__/setup.ts Normal file
View File

@ -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(),
},
}));

95
src/config.ts Normal file
View File

@ -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<void> {
if (pool) {
await pool.end();
pool = null;
}
}
// Set tenant context for RLS
export async function setTenantContext(
client: ReturnType<Pool['connect']> extends Promise<infer R> ? R : never,
tenantId: string
): Promise<void> {
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');
}
}
}

509
src/index.ts Normal file
View File

@ -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;

View File

@ -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<User, 'passwordHash' | 'mfaSecret'>; 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<LoginResponse> {
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<RefreshResponse> {
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<JWTPayload, 'iat' | 'exp'>,
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<JWTPayload> {
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<Session[]> {
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<string, unknown>): Promise<void> {
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<void> {
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<JWTPayload, 'iat' | 'exp'>): 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<Session> {
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<string, unknown>): Omit<User, 'passwordHash' | 'mfaSecret'> {
const { password_hash, mfa_secret, ...rest } = row;
return this.mapUser(rest);
}
private mapUser(row: Record<string, unknown>): Omit<User, 'passwordHash' | 'mfaSecret'> {
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<User, 'passwordHash' | 'mfaSecret'>;
}
private mapSession(row: Record<string, unknown>): 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();

View File

@ -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<Role[]> {
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<RoleWithPermissions | null> {
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<Role> {
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<Role> {
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<boolean> {
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<Permission[]> {
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<number> {
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<UserRole> {
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<boolean> {
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<boolean> {
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<UserPermissions> {
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<UserRole[]> {
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<void> {
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<string, unknown>): 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<string, unknown>) || {},
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<string, unknown>): 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<string, unknown>): 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();

View File

@ -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<TeamMember> {
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<TeamMember> {
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<boolean> {
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<TeamMemberWithUser[]> {
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<TeamMemberWithUser | null> {
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<number> {
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<CreateInvitationResult> {
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<AcceptInvitationResult> {
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<ResendInvitationResult> {
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<boolean> {
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<InvitationWithDetails[]> {
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<InvitationWithDetails | null> {
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<InvitationWithDetails | null> {
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<number> {
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<string, unknown>): 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<string, unknown>) || {},
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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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();

326
src/tools/index.ts Normal file
View File

@ -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<string, unknown>
): 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,
};
}
}

401
src/tools/rbac.tools.ts Normal file
View File

@ -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<string, unknown>
): 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,
};
}
}

438
src/tools/team.tools.ts Normal file
View File

@ -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<string, unknown>
): 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,
};
}
}

189
src/types/auth.types.ts Normal file
View File

@ -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<string, unknown>;
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<User, 'passwordHash' | 'mfaSecret'>;
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;
}

164
src/types/rbac.types.ts Normal file
View File

@ -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<string, unknown>;
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<string, unknown> | 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;
}

164
src/types/team.types.ts Normal file
View File

@ -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<string, unknown>;
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;
}

130
src/utils/errors.ts Normal file
View File

@ -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<string, unknown>;
constructor(
message: string,
code: string,
statusCode: number = 400,
details?: Record<string, unknown>
) {
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;
}

45
src/utils/logger.ts Normal file
View File

@ -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',
})
);
}

20
tsconfig.json Normal file
View File

@ -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"]
}