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:
commit
a9de3e4331
62
Dockerfile
Normal file
62
Dockerfile
Normal 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
20
jest.config.js
Normal 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
55
package.json
Normal 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
|
||||||
|
}
|
||||||
317
src/__tests__/auth.api.test.ts
Normal file
317
src/__tests__/auth.api.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
261
src/__tests__/auth.service.test.ts
Normal file
261
src/__tests__/auth.service.test.ts
Normal 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
25
src/__tests__/setup.ts
Normal 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
95
src/config.ts
Normal 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
509
src/index.ts
Normal 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;
|
||||||
788
src/services/auth.service.ts
Normal file
788
src/services/auth.service.ts
Normal 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();
|
||||||
607
src/services/rbac.service.ts
Normal file
607
src/services/rbac.service.ts
Normal 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();
|
||||||
433
src/services/team.service.ts
Normal file
433
src/services/team.service.ts
Normal 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
326
src/tools/index.ts
Normal 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
401
src/tools/rbac.tools.ts
Normal 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
438
src/tools/team.tools.ts
Normal 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
189
src/types/auth.types.ts
Normal 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
164
src/types/rbac.types.ts
Normal 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
164
src/types/team.types.ts
Normal 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
130
src/utils/errors.ts
Normal 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
45
src/utils/logger.ts
Normal 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
20
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user