diff --git a/Clients/src/presentation/components/Button/CustomizableButton/index.tsx b/Clients/src/presentation/components/Button/CustomizableButton/index.tsx index 2ba55f63b..4e3dd037e 100644 --- a/Clients/src/presentation/components/Button/CustomizableButton/index.tsx +++ b/Clients/src/presentation/components/Button/CustomizableButton/index.tsx @@ -84,6 +84,12 @@ export interface CustomizableButtonProps { className?: string; /** Tooltip text */ title?: string; + /** Text color (filtered out from DOM) */ + textColor?: string; + /** Indicator prop (filtered out from DOM) */ + indicator?: boolean; + /** Selection follows focus prop (filtered out from DOM) */ + selectionFollowsFocus?: boolean; } /** @@ -114,6 +120,9 @@ const CustomizableButton = memo( fullWidth = false, className, title, + textColor, // Extract textColor to prevent it from reaching DOM + indicator, // Extract indicator to prevent it from reaching DOM + selectionFollowsFocus, // Extract selectionFollowsFocus to prevent it from reaching DOM ...rest }, ref @@ -169,6 +178,14 @@ const CustomizableButton = memo( /> ); + // Filter out any remaining problematic props that shouldn't reach DOM + const filteredProps = Object.keys(rest).reduce((acc: Record, key) => { + if (!['textColor', 'indicator', 'selectionFollowsFocus'].includes(key)) { + acc[key] = (rest as any)[key]; + } + return acc; + }, {}); + return ( + + + )} + + {/* Step 2: Authentication Options */} + {currentStep === 'auth-options' && userOrgInfo && ( + + {/* Email Confirmation */} + + + + ✓ Email: + + + {values.email} + + + Change + + + + {userOrgInfo.hasOrganization && ( + + Organization: {userOrgInfo.organization?.name} + + )} + + + {/* SSO Option */} + {userOrgInfo.ssoAvailable && userOrgInfo.authMethodPolicy !== 'password_only' && ( + + )} + + {/* Divider */} + {userOrgInfo.ssoAvailable && userOrgInfo.authMethodPolicy === 'both' && ( + + or - - - {isMultiTenant && ( + )} + + {/* Password Option */} + {userOrgInfo.authMethodPolicy !== 'sso_only' && ( + + )} + + {/* Policy message for SSO-only */} + {userOrgInfo.authMethodPolicy === 'sso_only' && !userOrgInfo.ssoAvailable && ( + + Your organization requires SSO authentication, but SSO is not configured. Please contact your administrator. + + )} + + {/* New User Message */} + {!userOrgInfo.userExists && ( + + New user? You'll be able to create an account after entering your password. + + )} + + )} + + {/* Step 3: Password Input */} + {currentStep === 'password' && ( +
+ + {/* Email Confirmation */} + + + + ✓ Email: + + + {values.email} + + + Change + + + + {userOrgInfo?.hasOrganization && ( + + Organization: {userOrgInfo.organization?.name} + + )} + + + + - - Don't have an account yet? - + { + setValues({ ...values, rememberMe: e.target.checked }); + }} + size="small" + /> navigate("/register")} + onClick={() => { + navigate("/forgot-password", { + state: { email: values.email }, + }); + }} > - Register here + Forgot password - )} + + + + {/* Back to auth options */} + {userOrgInfo?.ssoAvailable && userOrgInfo?.authMethodPolicy === 'both' && ( + setCurrentStep('auth-options')} + sx={{ textDecoration: 'none', color: singleTheme.textColors.theme }} + > + ← Back to login options + + )} + +
+ )} + + {/* Registration Link */} + {isMultiTenant && currentStep === 'email' && ( + + + Don't have an account yet? + + navigate("/register")} + > + Register here + - - + )} + ); }; -export default Login; +export default Login; \ No newline at end of file diff --git a/Clients/src/presentation/pages/SettingsPage/EntraIdConfig/SsoConfigTab.tsx b/Clients/src/presentation/pages/SettingsPage/EntraIdConfig/SsoConfigTab.tsx new file mode 100644 index 000000000..ec4c17e6d --- /dev/null +++ b/Clients/src/presentation/pages/SettingsPage/EntraIdConfig/SsoConfigTab.tsx @@ -0,0 +1,305 @@ +import React, { useState, useCallback } from "react"; +import { + Box, + Stack, + Typography, + useTheme, +} from "@mui/material"; +import Field from "../../../components/Inputs/Field"; +import Toggle from "../../../components/Toggle"; +import Alert from "../../../components/Alert"; +import Button from "../../../components/Button"; +import Select from "../../../components/Inputs/Select"; + +// State interface for SSO Configuration (MVP) +interface SsoConfig { + tenantId: string; + clientId: string; + clientSecret: string; + cloudEnvironment: string; + isEnabled: boolean; + authMethodPolicy: 'sso_only' | 'password_only' | 'both'; +} + +// Validation errors interface +interface ValidationErrors { + tenantId?: string; + clientId?: string; + clientSecret?: string; +} + +// Cloud environment options +const cloudEnvironments = [ + { _id: "AzurePublic", name: "Azure Public Cloud" }, + { _id: "AzureGovernment", name: "Azure Government" } +]; + +// Authentication method policy options +const authMethodPolicies = [ + { _id: "both", name: "Allow both SSO and password authentication" }, + { _id: "sso_only", name: "Require SSO authentication only" }, + { _id: "password_only", name: "Allow password authentication only" } +]; + +const SsoConfigTab: React.FC = () => { + const [config, setConfig] = useState({ + tenantId: "", + clientId: "", + clientSecret: "", + cloudEnvironment: "AzurePublic", + isEnabled: false, + authMethodPolicy: "both", + }); + + const [errors, setErrors] = useState({}); + const [isSaving, setIsSaving] = useState(false); + const theme = useTheme(); + + // Validation functions + const validateUUID = (value: string): boolean => { + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + return uuidRegex.test(value); + }; + + const validateField = useCallback((field: keyof ValidationErrors, value: string) => { + const newErrors = { ...errors }; + + switch (field) { + case 'tenantId': + if (!value) { + newErrors.tenantId = "Tenant ID is required"; + } else if (!validateUUID(value)) { + newErrors.tenantId = "Please enter a valid UUID format"; + } else { + delete newErrors.tenantId; + } + break; + case 'clientId': + if (!value) { + newErrors.clientId = "Client ID is required"; + } else if (!validateUUID(value)) { + newErrors.clientId = "Please enter a valid UUID format"; + } else { + delete newErrors.clientId; + } + break; + case 'clientSecret': + if (!value) { + newErrors.clientSecret = "Client Secret is required"; + } else if (value.length < 10) { + newErrors.clientSecret = "Client Secret must be at least 10 characters"; + } else { + delete newErrors.clientSecret; + } + break; + } + + setErrors(newErrors); + }, [errors]); + + const handleFieldChange = (field: keyof SsoConfig) => ( + event: React.ChangeEvent + ) => { + const value = event.target.value; + setConfig(prev => ({ ...prev, [field]: value })); + + // Real-time validation for specific fields + if (field === 'tenantId' || field === 'clientId' || field === 'clientSecret') { + validateField(field, value); + } + }; + + + const handleToggleChange = (field: keyof SsoConfig) => (checked: boolean) => { + setConfig(prev => ({ ...prev, [field]: checked })); + }; + + + const handleSelectChange = (field: keyof SsoConfig) => ( + event: any + ) => { + setConfig(prev => ({ ...prev, [field]: event.target.value })); + }; + + + const handleSave = async () => { + setIsSaving(true); + try { + // Validate all required fields + validateField('tenantId', config.tenantId); + validateField('clientId', config.clientId); + validateField('clientSecret', config.clientSecret); + + if (Object.keys(errors).length === 0) { + // Simulate API call for saving configuration + await new Promise(resolve => setTimeout(resolve, 1000)); + // Success handling would go here + } + } catch (error) { + // Error handling would go here + } finally { + setIsSaving(false); + } + }; + + // Card-like container styles - matching AI Trust Center spacing + const cardStyles = { + backgroundColor: theme.palette.background.paper, + borderRadius: theme.shape.borderRadius, + border: `1.5px solid ${theme.palette.divider}`, + padding: theme.spacing(5, 6), // 40px top/bottom, 48px left/right - same as AI Trust Center + boxShadow: 'none', + }; + + return ( + + + + {/* Setup Guide Alert */} + + + + + {/* Simplified SSO Configuration Card */} + + + SSO configuration + + + + + + + + Found in Azure Portal > Microsoft Entra ID > Overview > Tenant ID + + + + + + + Found in Azure Portal > App registrations > [Your App] > Application (client) ID + + + + + + + + + + + option._id} + sx={{ width: '100%' }} + /> + + Controls which authentication methods are allowed for users in this organization + + + + + + + Enable SSO authentication for this organization + + {config.isEnabled && config.authMethodPolicy === 'sso_only' && ( + + )} + {config.isEnabled && config.authMethodPolicy === 'both' && ( + + )} + {config.authMethodPolicy === 'password_only' && ( + + )} + + + + + + + {/* Save/Cancel Buttons */} + + + + + + ); +}; + +export default SsoConfigTab; \ No newline at end of file diff --git a/Clients/src/presentation/pages/SettingsPage/EntraIdConfig/index.tsx b/Clients/src/presentation/pages/SettingsPage/EntraIdConfig/index.tsx new file mode 100644 index 000000000..6fdeeafdd --- /dev/null +++ b/Clients/src/presentation/pages/SettingsPage/EntraIdConfig/index.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import { Box } from "@mui/material"; +import SsoConfigTab from "./SsoConfigTab"; + +const EntraIdConfig: React.FC = () => { + return ( + + + + ); +}; + +export default EntraIdConfig; \ No newline at end of file diff --git a/Clients/src/presentation/pages/SettingsPage/index.tsx b/Clients/src/presentation/pages/SettingsPage/index.tsx index 1bb8dd255..62c9ca243 100644 --- a/Clients/src/presentation/pages/SettingsPage/index.tsx +++ b/Clients/src/presentation/pages/SettingsPage/index.tsx @@ -10,6 +10,7 @@ import Password from "./Password/index"; import TeamManagement from "./Team/index"; import { settingTabStyle, tabContainerStyle, tabIndicatorStyle } from "./style"; import Organization from "./Organization"; +import EntraIdConfig from "./EntraIdConfig/index"; import allowedRoles from "../../../application/constants/permissions"; import { useAuth } from "../../../application/hooks/useAuth"; // import Slack from "./Slack"; @@ -19,7 +20,7 @@ import HelperIcon from "../../components/HelperIcon"; import PageHeader from "../../components/Layout/PageHeader"; export default function ProfilePage() { - const authorizedActiveTabs = ["profile", "password", "team", "organization"]; + const authorizedActiveTabs = ["profile", "password", "team", "organization", "entraid"]; const { userRoleName } = useAuth(); const isTeamManagementDisabled = !allowedRoles.projects.editTeamMembers.includes(userRoleName); @@ -133,6 +134,12 @@ export default function ProfilePage() { disableRipple sx={settingTabStyle} /> + {/* + + + + {/* Hiding the slack until all the Slack work has been resolved */} {/* diff --git a/Servers/.env.sso.example b/Servers/.env.sso.example new file mode 100644 index 000000000..8f458441b --- /dev/null +++ b/Servers/.env.sso.example @@ -0,0 +1,50 @@ +# SSO Configuration Environment Variables +# Copy this file to .env and fill in the required values + +# SSO Encryption Key - REQUIRED for SSO functionality +# Must be exactly 32 characters for AES-256-CBC encryption +# Generate a secure random key: openssl rand -hex 16 +SSO_ENCRYPTION_KEY=your_32_character_encryption_key_here + +# SSO State Token Secret - REQUIRED and SEPARATE from JWT_SECRET +# Used for CSRF-protected state tokens in OAuth flow +# Generate a secure random key: openssl rand -hex 32 +SSO_STATE_SECRET=your_separate_state_token_secret_here + +# Application URLs - Required for OAuth flow +FRONTEND_URL=http://localhost:3001 +BACKEND_URL=http://localhost:3000 + +# Cookie Configuration for Production +# Only required in production environments +COOKIE_DOMAIN=.yourdomain.com +NODE_ENV=production + +# JWT Secret - Should already be configured +JWT_SECRET=your_jwt_secret_here + +# Database Configuration - Should already be configured +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=verifywise +DB_USER=your_db_user +DB_PASSWORD=your_db_password + +# Azure AD Application Registration Details +# These will be stored encrypted in the database per organization +# AZURE_CLIENT_ID=your_azure_client_id +# AZURE_CLIENT_SECRET=your_azure_client_secret +# AZURE_TENANT_ID=your_azure_tenant_id + +# Azure AD URLs (These are dynamic based on cloud environment) +# Public Cloud: https://login.microsoftonline.com/ +# Government Cloud: https://login.microsoftonline.us/ + +# Production Security Notes: +# 1. Use HTTPS in production (NODE_ENV=production enables secure cookies) +# 2. Set COOKIE_DOMAIN to your actual domain +# 3. Generate a cryptographically secure SSO_ENCRYPTION_KEY +# 4. Ensure JWT_SECRET is secure and different from encryption key +# 5. Store Azure AD secrets in database, not environment variables +# 6. Use strong database passwords and restrict database access +# 7. Configure proper CORS origins in ALLOWED_ORIGINS \ No newline at end of file diff --git a/Servers/__tests__/sso/redis-rate-limiter.test.ts b/Servers/__tests__/sso/redis-rate-limiter.test.ts new file mode 100644 index 000000000..cfb7a19ce --- /dev/null +++ b/Servers/__tests__/sso/redis-rate-limiter.test.ts @@ -0,0 +1,435 @@ +/** + * Redis Rate Limiter Tests + * + * Comprehensive test suite for Redis-based rate limiting functionality. + * Tests rate limiting logic, Redis connectivity, error handling, and + * security scenarios for production reliability. + */ + +import { Request } from 'express'; +import { RedisRateLimiter } from '../../utils/redis-rate-limiter.utils'; +import Redis from 'ioredis'; + +// Set required environment variables for SSO tests +process.env.SSO_STATE_SECRET = 'test-state-secret-for-tests'; +process.env.JWT_SECRET = 'test-jwt-secret'; + +// Mock Redis client +jest.mock('ioredis'); +const MockedRedis = Redis as jest.MockedClass; + +describe('RedisRateLimiter', () => { + let rateLimiter: RedisRateLimiter; + let mockRedis: jest.Mocked; + let mockRequest: Partial; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + + // Create mock Redis instance + mockRedis = { + get: jest.fn(), + hgetall: jest.fn(), + hmset: jest.fn(), + setex: jest.fn(), + expire: jest.fn(), + del: jest.fn(), + keys: jest.fn(), + ttl: jest.fn(), + ping: jest.fn(), + quit: jest.fn(), + pipeline: jest.fn(), + on: jest.fn(), + } as any; + + // Mock pipeline + const mockPipeline = { + hgetall: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValue([[null, {}]]) + }; + mockRedis.pipeline.mockReturnValue(mockPipeline as any); + + MockedRedis.mockImplementation(() => mockRedis); + + // Create rate limiter instance + rateLimiter = new RedisRateLimiter(mockRedis); + + // Mock request object + mockRequest = { + headers: { + 'x-forwarded-for': '192.168.1.1', + 'user-agent': 'test-agent' + }, + socket: { + remoteAddress: '192.168.1.1' + } as any + }; + }); + + afterEach(async () => { + await rateLimiter.close(); + }); + + describe('Rate Limiting Logic', () => { + it('should allow first request within limits', async () => { + // Mock pipeline execution - no existing data + const mockPipeline = mockRedis.pipeline(); + (mockPipeline.exec as jest.Mock).mockResolvedValue([[null, {}]]); + + const result = await rateLimiter.checkRateLimit(mockRequest as Request, 'login', 'azure_ad'); + + expect(result.allowed).toBe(true); + expect(result.attempts).toBe(1); + expect(result.remaining).toBe(9); // 10 max attempts for login - 1 + expect(mockRedis.hmset).toHaveBeenCalled(); + expect(mockRedis.expire).toHaveBeenCalled(); + }); + + it('should increment attempts within window', async () => { + const now = Date.now(); + const existingData = { + attempts: '3', + firstAttempt: now.toString() + }; + + // Mock pipeline execution - existing data + const mockPipeline = mockRedis.pipeline(); + (mockPipeline.exec as jest.Mock).mockResolvedValue([[null, existingData]]); + + const result = await rateLimiter.checkRateLimit(mockRequest as Request, 'login', 'azure_ad'); + + expect(result.allowed).toBe(true); + expect(result.attempts).toBe(4); + expect(result.remaining).toBe(6); // 10 - 4 + expect(mockRedis.hmset).toHaveBeenCalledWith( + expect.stringContaining('login'), + { + attempts: '4', + firstAttempt: now.toString() + } + ); + }); + + it('should block when rate limit exceeded', async () => { + const now = Date.now(); + const existingData = { + attempts: '10', // At the limit + firstAttempt: now.toString() + }; + + // Mock pipeline execution + const mockPipeline = mockRedis.pipeline(); + (mockPipeline.exec as jest.Mock).mockResolvedValue([[null, existingData]]); + + const result = await rateLimiter.checkRateLimit(mockRequest as Request, 'login', 'azure_ad'); + + expect(result.allowed).toBe(false); + expect(result.retryAfter).toBe(1800); // 30 minutes for login + expect(mockRedis.setex).toHaveBeenCalledWith( + expect.stringContaining('blocked'), + 1800, + expect.any(String) + ); + }); + + it('should reset window when expired', async () => { + const now = Date.now(); + const oldTimestamp = now - (16 * 60 * 1000); // 16 minutes ago (window is 15 minutes) + const existingData = { + attempts: '5', + firstAttempt: oldTimestamp.toString() + }; + + // Mock pipeline execution + const mockPipeline = mockRedis.pipeline(); + (mockPipeline.exec as jest.Mock).mockResolvedValue([[null, existingData]]); + + const result = await rateLimiter.checkRateLimit(mockRequest as Request, 'login', 'azure_ad'); + + expect(result.allowed).toBe(true); + expect(result.attempts).toBe(1); + expect(result.remaining).toBe(9); + expect(mockRedis.hmset).toHaveBeenCalledWith( + expect.stringContaining('login'), + { + attempts: '1', + firstAttempt: expect.any(String) + } + ); + }); + + it('should respect existing block', async () => { + const futureTime = Date.now() + (10 * 60 * 1000); // 10 minutes in future + mockRedis.get.mockResolvedValue(futureTime.toString()); + + const result = await rateLimiter.checkRateLimit(mockRequest as Request, 'login', 'azure_ad'); + + expect(result.allowed).toBe(false); + expect(result.retryAfter).toBeGreaterThan(0); + expect(mockRedis.pipeline).not.toHaveBeenCalled(); // Should return early + }); + }); + + describe('Different Operation Types', () => { + it('should handle callback operation with different limits', async () => { + // Mock pipeline execution - no existing data + const mockPipeline = mockRedis.pipeline(); + (mockPipeline.exec as jest.Mock).mockResolvedValue([[null, {}]]); + + const result = await rateLimiter.checkRateLimit(mockRequest as Request, 'callback', 'azure_ad'); + + expect(result.allowed).toBe(true); + expect(result.attempts).toBe(1); + expect(result.remaining).toBe(19); // 20 max attempts for callback - 1 + }); + + it('should handle token operation with different limits', async () => { + // Mock pipeline execution - no existing data + const mockPipeline = mockRedis.pipeline(); + (mockPipeline.exec as jest.Mock).mockResolvedValue([[null, {}]]); + + const result = await rateLimiter.checkRateLimit(mockRequest as Request, 'token', 'azure_ad'); + + expect(result.allowed).toBe(true); + expect(result.attempts).toBe(1); + expect(result.remaining).toBe(14); // 15 max attempts for token - 1 + }); + + it('should throw error for unknown operation type', async () => { + await expect( + rateLimiter.checkRateLimit(mockRequest as Request, 'unknown' as any, 'azure_ad') + ).rejects.toThrow('Unknown operation type: unknown'); + }); + }); + + describe('Client Identification', () => { + it('should handle x-forwarded-for header', async () => { + mockRequest.headers = { + 'x-forwarded-for': '10.0.0.1, 192.168.1.1', + 'user-agent': 'test-agent' + }; + + const mockPipeline = mockRedis.pipeline(); + (mockPipeline.exec as jest.Mock).mockResolvedValue([[null, {}]]); + + await rateLimiter.checkRateLimit(mockRequest as Request, 'login', 'azure_ad'); + + // Should use first IP from forwarded header + expect(mockRedis.hmset).toHaveBeenCalledWith( + expect.stringMatching(/^sso_rate_limit:azure_ad:login:[a-f0-9]{16}$/), + expect.any(Object) + ); + }); + + it('should handle missing user agent', async () => { + mockRequest.headers = { + 'x-forwarded-for': '192.168.1.1' + // No user-agent + }; + + const mockPipeline = mockRedis.pipeline(); + (mockPipeline.exec as jest.Mock).mockResolvedValue([[null, {}]]); + + await rateLimiter.checkRateLimit(mockRequest as Request, 'login', 'azure_ad'); + + expect(mockRedis.hmset).toHaveBeenCalled(); + }); + + it('should handle missing IP information', async () => { + mockRequest.headers = {}; + mockRequest.socket = {} as any; + + const mockPipeline = mockRedis.pipeline(); + (mockPipeline.exec as jest.Mock).mockResolvedValue([[null, {}]]); + + await rateLimiter.checkRateLimit(mockRequest as Request, 'login', 'azure_ad'); + + expect(mockRedis.hmset).toHaveBeenCalled(); + }); + }); + + describe('Error Handling', () => { + it('should fail open when Redis is unavailable', async () => { + mockRedis.get.mockRejectedValue(new Error('Redis connection failed')); + + const result = await rateLimiter.checkRateLimit(mockRequest as Request, 'login', 'azure_ad'); + + expect(result.allowed).toBe(true); + // Should not have retryAfter when failing open + expect(result.retryAfter).toBeUndefined(); + }); + + it('should handle pipeline execution errors', async () => { + mockRedis.get.mockResolvedValue(null); // No existing block + const mockPipeline = mockRedis.pipeline(); + (mockPipeline.exec as jest.Mock).mockRejectedValue(new Error('Pipeline execution failed')); + + const result = await rateLimiter.checkRateLimit(mockRequest as Request, 'login', 'azure_ad'); + + expect(result.allowed).toBe(true); + }); + + it('should handle hmset errors gracefully', async () => { + const mockPipeline = mockRedis.pipeline(); + (mockPipeline.exec as jest.Mock).mockResolvedValue([[null, {}]]); + mockRedis.hmset.mockRejectedValue(new Error('HMSET failed')); + + const result = await rateLimiter.checkRateLimit(mockRequest as Request, 'login', 'azure_ad'); + + expect(result.allowed).toBe(true); + }); + }); + + describe('Rate Limit Status', () => { + it('should return correct status for active limits', async () => { + const now = Date.now(); + const data = { + attempts: '5', + firstAttempt: now.toString() + }; + + mockRedis.hgetall.mockResolvedValue(data); + mockRedis.get.mockResolvedValue(null); // Not blocked + + const status = await rateLimiter.getRateLimitStatus(mockRequest as Request, 'login', 'azure_ad'); + + expect(status.attempts).toBe(5); + expect(status.remaining).toBe(5); // 10 - 5 + expect(status.blocked).toBe(false); + }); + + it('should return blocked status', async () => { + const futureTime = Date.now() + (5 * 60 * 1000); + mockRedis.hgetall.mockResolvedValue({ attempts: '11' }); + mockRedis.get.mockResolvedValue(futureTime.toString()); + + const status = await rateLimiter.getRateLimitStatus(mockRequest as Request, 'login', 'azure_ad'); + + expect(status.blocked).toBe(true); + expect(status.blockUntil).toBe(futureTime); + expect(status.remaining).toBe(0); + }); + + it('should handle expired windows in status check', async () => { + const oldTime = Date.now() - (20 * 60 * 1000); // 20 minutes ago + const data = { + attempts: '8', + firstAttempt: oldTime.toString() + }; + + mockRedis.hgetall.mockResolvedValue(data); + mockRedis.get.mockResolvedValue(null); + + const status = await rateLimiter.getRateLimitStatus(mockRequest as Request, 'login', 'azure_ad'); + + expect(status.attempts).toBe(0); + expect(status.remaining).toBe(10); + expect(status.blocked).toBe(false); + }); + }); + + describe('Reset Rate Limit', () => { + it('should reset rate limit for specific client and operation', async () => { + mockRedis.del.mockResolvedValue(2); // Deleted 2 keys + + await rateLimiter.resetRateLimit(mockRequest as Request, 'login', 'azure_ad'); + + expect(mockRedis.del).toHaveBeenCalledWith( + expect.stringContaining('login'), + expect.stringContaining('blocked') + ); + }); + + it('should handle reset errors', async () => { + mockRedis.del.mockRejectedValue(new Error('Delete failed')); + + await expect( + rateLimiter.resetRateLimit(mockRequest as Request, 'login', 'azure_ad') + ).rejects.toThrow('Delete failed'); + }); + }); + + describe('Health Check', () => { + it('should return healthy status when Redis is responsive', async () => { + mockRedis.ping.mockResolvedValue('PONG'); + + const health = await rateLimiter.healthCheck(); + + expect(health.healthy).toBe(true); + expect(health.message).toContain('Redis healthy'); + }); + + it('should return unhealthy status when Redis fails', async () => { + mockRedis.ping.mockRejectedValue(new Error('Connection refused')); + + const health = await rateLimiter.healthCheck(); + + expect(health.healthy).toBe(false); + expect(health.message).toContain('Redis connection failed'); + }); + + it('should warn about slow Redis response', async () => { + // Mock slow response + mockRedis.ping.mockImplementation(() => + new Promise(resolve => setTimeout(() => resolve('PONG'), 150)) + ); + + const health = await rateLimiter.healthCheck(); + + expect(health.healthy).toBe(true); + expect(health.message).toContain('slow'); + }); + }); + + describe('Cleanup', () => { + it('should clean up expired entries', async () => { + const keys = [ + 'sso_rate_limit:azure_ad:login:abc123', + 'sso_rate_limit:azure_ad:login:def456' + ]; + + mockRedis.keys.mockResolvedValue(keys); + mockRedis.ttl.mockResolvedValueOnce(-1); // No TTL + mockRedis.ttl.mockResolvedValueOnce(300); // Has TTL + mockRedis.expire.mockResolvedValue(1); + + const cleaned = await rateLimiter.cleanup(); + + expect(cleaned).toBe(1); + expect(mockRedis.expire).toHaveBeenCalledWith(keys[0], 3600); + expect(mockRedis.expire).not.toHaveBeenCalledWith(keys[1], expect.any(Number)); + }); + + it('should handle cleanup errors gracefully', async () => { + mockRedis.keys.mockRejectedValue(new Error('Keys command failed')); + + const cleaned = await rateLimiter.cleanup(); + + expect(cleaned).toBe(0); + }); + }); + + describe('Connection Management', () => { + it('should handle Redis connection events', () => { + // Verify event handlers are registered + expect(mockRedis.on).toHaveBeenCalledWith('error', expect.any(Function)); + expect(mockRedis.on).toHaveBeenCalledWith('connect', expect.any(Function)); + }); + + it('should close Redis connection properly', async () => { + mockRedis.quit.mockResolvedValue('OK'); + + await rateLimiter.close(); + + expect(mockRedis.quit).toHaveBeenCalled(); + }); + + it('should handle close errors gracefully', async () => { + mockRedis.quit.mockRejectedValue(new Error('Quit failed')); + + // Should not throw + await expect(rateLimiter.close()).resolves.toBeUndefined(); + }); + }); +}); \ No newline at end of file diff --git a/Servers/__tests__/sso/sso-env-validator.test.ts b/Servers/__tests__/sso/sso-env-validator.test.ts new file mode 100644 index 000000000..56db804c5 --- /dev/null +++ b/Servers/__tests__/sso/sso-env-validator.test.ts @@ -0,0 +1,400 @@ +/** + * SSO Environment Validator Tests + * + * Tests for environment variable validation logic, security checks, + * and production readiness validation for SSO configuration. + */ + +import { SSOEnvironmentValidator } from '../../utils/sso-env-validator.utils'; + +describe('SSOEnvironmentValidator', () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + // Save original environment + originalEnv = { ...process.env }; + + // Clear environment for clean tests + delete process.env.SSO_STATE_SECRET; + delete process.env.BACKEND_URL; + delete process.env.JWT_SECRET; + delete process.env.REDIS_URL; + delete process.env.REDIS_HOST; + delete process.env.REDIS_PORT; + delete process.env.REDIS_PASSWORD; + delete process.env.REDIS_DB; + delete process.env.NODE_ENV; + }); + + afterEach(() => { + // Restore original environment + process.env = originalEnv; + }); + + describe('Required Variable Validation', () => { + it('should fail when required variables are missing', () => { + const result = SSOEnvironmentValidator.validateEnvironment(); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('Missing required environment variable: SSO_STATE_SECRET'); + expect(result.errors).toContain('Missing required environment variable: BACKEND_URL'); + expect(result.errors).toContain('Missing required environment variable: JWT_SECRET'); + }); + + it('should pass with all required variables set', () => { + process.env.SSO_STATE_SECRET = 'Kj8#mP9$nQ2@vR5*wS1!xT6&yU3%zV0^aB4&cD7#eF2$gH9@iJ5*kL8!mN1%oP6^'; + process.env.BACKEND_URL = 'https://api.example.com'; + process.env.JWT_SECRET = 'dF2$gH9@iJ5*kL8!mN1%oP6^Kj8#mP9$nQ2@vR5*wS1!xT6&yU3%zV0^aB4&cD7#'; + process.env.REDIS_URL = 'redis://localhost:6379'; + + const result = SSOEnvironmentValidator.validateEnvironment(); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + }); + + describe('Secret Validation', () => { + beforeEach(() => { + // Set minimum required vars + process.env.BACKEND_URL = 'https://api.example.com'; + process.env.REDIS_URL = 'redis://localhost:6379'; + }); + + it('should reject short secrets', () => { + process.env.SSO_STATE_SECRET = 'short'; + process.env.JWT_SECRET = 'alsoshort'; + + const result = SSOEnvironmentValidator.validateEnvironment(); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('SSO_STATE_SECRET must be at least 32 characters long'); + expect(result.errors).toContain('JWT_SECRET must be at least 32 characters long'); + }); + + it('should reject weak secrets', () => { + process.env.SSO_STATE_SECRET = 'secretsecretsecretsecretsecretsecret'; + process.env.JWT_SECRET = 'passwordpasswordpasswordpassword'; + + const result = SSOEnvironmentValidator.validateEnvironment(); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('SSO_STATE_SECRET appears to use a weak or default value'); + expect(result.errors).toContain('JWT_SECRET appears to use a weak or default value'); + }); + + it('should reject secrets with low entropy', () => { + process.env.SSO_STATE_SECRET = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + process.env.JWT_SECRET = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + + const result = SSOEnvironmentValidator.validateEnvironment(); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('SSO_STATE_SECRET may have insufficient entropy (too many repeated characters)'); + expect(result.errors).toContain('JWT_SECRET may have insufficient entropy (too many repeated characters)'); + }); + + it('should accept strong secrets', () => { + process.env.SSO_STATE_SECRET = 'Kj8#mP9$nQ2@vR5*wS1!xT6&yU3%zV0^'; + process.env.JWT_SECRET = 'aB4&cD7#eF2$gH9@iJ5*kL8!mN1%oP6^'; + + const result = SSOEnvironmentValidator.validateEnvironment(); + + expect(result.valid).toBe(true); + }); + }); + + describe('URL Validation', () => { + beforeEach(() => { + // Set minimum required vars + process.env.SSO_STATE_SECRET = 'Kj8#mP9$nQ2@vR5*wS1!xT6&yU3%zV0^aB4&cD7#eF2$gH9@iJ5*kL8!mN1%oP6^'; + process.env.JWT_SECRET = 'dF2$gH9@iJ5*kL8!mN1%oP6^Kj8#mP9$nQ2@vR5*wS1!xT6&yU3%zV0^aB4&cD7#'; + process.env.REDIS_URL = 'redis://localhost:6379'; + }); + + it('should reject invalid URL format', () => { + process.env.BACKEND_URL = 'not-a-url'; + + const result = SSOEnvironmentValidator.validateEnvironment(); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('BACKEND_URL is not a valid URL format'); + }); + + it('should require HTTPS in production', () => { + process.env.NODE_ENV = 'production'; + process.env.BACKEND_URL = 'http://api.example.com'; + + const result = SSOEnvironmentValidator.validateEnvironment(); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('BACKEND_URL must use HTTPS in production environment'); + }); + + it('should warn about localhost in production', () => { + process.env.NODE_ENV = 'production'; + process.env.BACKEND_URL = 'https://localhost:3000'; + + const result = SSOEnvironmentValidator.validateEnvironment(); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('BACKEND_URL should not use localhost in production'); + }); + + it('should accept valid HTTPS URLs', () => { + process.env.NODE_ENV = 'production'; + process.env.SSO_STATE_SECRET = 'Kj8#mP9$nQ2@vR5*wS1!xT6&yU3%zV0^aB4&cD7#eF2$gH9@iJ5*kL8!mN1%oP6^'; + process.env.JWT_SECRET = 'dF2$gH9@iJ5*kL8!mN1%oP6^Kj8#mP9$nQ2@vR5*wS1!xT6&yU3%zV0^aB4&cD7#'; + process.env.BACKEND_URL = 'https://api.verifywise.com'; + process.env.REDIS_URL = 'redis://redis.verifywise.com:6379'; + + const result = SSOEnvironmentValidator.validateEnvironment(); + + expect(result.valid).toBe(true); + }); + }); + + describe('Redis Configuration Validation', () => { + beforeEach(() => { + // Set minimum required vars + process.env.SSO_STATE_SECRET = 'Kj8#mP9$nQ2@vR5*wS1!xT6&yU3%zV0^aB4&cD7#eF2$gH9@iJ5*kL8!mN1%oP6^'; + process.env.JWT_SECRET = 'dF2$gH9@iJ5*kL8!mN1%oP6^Kj8#mP9$nQ2@vR5*wS1!xT6&yU3%zV0^aB4&cD7#'; + process.env.BACKEND_URL = 'https://api.example.com'; + }); + + it('should require Redis configuration', () => { + // No Redis configuration provided + + const result = SSOEnvironmentValidator.validateEnvironment(); + + expect(result.valid).toBe(false); + expect(result.errors).toContain( + 'Redis configuration required: Either REDIS_URL/REDIS_CONNECTION_STRING or REDIS_HOST must be provided' + ); + }); + + it('should accept REDIS_URL', () => { + process.env.REDIS_URL = 'redis://localhost:6379'; + + const result = SSOEnvironmentValidator.validateEnvironment(); + + expect(result.valid).toBe(true); + }); + + it('should accept REDIS_CONNECTION_STRING', () => { + process.env.REDIS_CONNECTION_STRING = 'redis://user:pass@localhost:6379/0'; + + const result = SSOEnvironmentValidator.validateEnvironment(); + + expect(result.valid).toBe(true); + }); + + it('should accept REDIS_HOST', () => { + process.env.REDIS_HOST = 'redis.example.com'; + + const result = SSOEnvironmentValidator.validateEnvironment(); + + expect(result.valid).toBe(true); + }); + + it('should validate REDIS_PORT format', () => { + process.env.REDIS_HOST = 'localhost'; + process.env.REDIS_PORT = 'not-a-number'; + + const result = SSOEnvironmentValidator.validateEnvironment(); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('REDIS_PORT must be a valid port number'); + }); + + it('should validate REDIS_DB format', () => { + process.env.REDIS_HOST = 'localhost'; + process.env.REDIS_DB = 'not-a-number'; + + const result = SSOEnvironmentValidator.validateEnvironment(); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('REDIS_DB must be a valid database number (0-15)'); + }); + }); + + describe('Security Validations', () => { + beforeEach(() => { + // Set minimum valid vars + process.env.BACKEND_URL = 'https://api.example.com'; + process.env.REDIS_URL = 'redis://localhost:6379'; + }); + + it('should require different secrets for SSO_STATE_SECRET and JWT_SECRET', () => { + const sameSecret = 'Kj8#mP9$nQ2@vR5*wS1!xT6&yU3%zV0^aB4&cD7#eF2$gH9@iJ5*kL8!mN1%oP6^'; + process.env.SSO_STATE_SECRET = sameSecret; + process.env.JWT_SECRET = sameSecret; + + const result = SSOEnvironmentValidator.validateEnvironment(); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('SSO_STATE_SECRET and JWT_SECRET must be different for security'); + }); + + it('should reject localhost URLs in production', () => { + process.env.NODE_ENV = 'production'; + process.env.SSO_STATE_SECRET = 'Kj8#mP9$nQ2@vR5*wS1!xT6&yU3%zV0^aB4&cD7#eF2$gH9@iJ5*kL8!mN1%oP6^'; + process.env.JWT_SECRET = 'dF2$gH9@iJ5*kL8!mN1%oP6^Kj8#mP9$nQ2@vR5*wS1!xT6&yU3%zV0^aB4&cD7#'; + process.env.BACKEND_URL = 'https://localhost:3000'; + process.env.REDIS_URL = 'redis://localhost:6379'; + + const result = SSOEnvironmentValidator.validateEnvironment(); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('BACKEND_URL should not contain localhost in production'); + expect(result.errors).toContain('REDIS_URL should not contain localhost in production'); + }); + + it('should accept different valid secrets', () => { + process.env.SSO_STATE_SECRET = 'Kj8#mP9$nQ2@vR5*wS1!xT6&yU3%zV0^'; + process.env.JWT_SECRET = 'aB4&cD7#eF2$gH9@iJ5*kL8!mN1%oP6^'; + + const result = SSOEnvironmentValidator.validateEnvironment(); + + expect(result.valid).toBe(true); + }); + }); + + describe('Warning Generation', () => { + beforeEach(() => { + // Set valid configuration + process.env.SSO_STATE_SECRET = 'Kj8#mP9$nQ2@vR5*wS1!xT6&yU3%zV0^aB4&cD7#eF2$gH9@iJ5*kL8!mN1%oP6^'; + process.env.JWT_SECRET = 'dF2$gH9@iJ5*kL8!mN1%oP6^Kj8#mP9$nQ2@vR5*wS1!xT6&yU3%zV0^aB4&cD7#'; + process.env.BACKEND_URL = 'https://api.example.com'; + process.env.REDIS_URL = 'redis://localhost:6379'; + }); + + it('should warn about missing Redis in development', () => { + process.env.NODE_ENV = 'development'; + delete process.env.REDIS_URL; // Remove Redis config + + const result = SSOEnvironmentValidator.validateEnvironment(); + + expect(result.valid).toBe(false); // Will fail due to missing Redis + expect(result.warnings).toContain('No Redis configuration found - rate limiting will be disabled'); + }); + + it('should warn about missing Redis password in production', () => { + process.env.NODE_ENV = 'production'; + process.env.REDIS_HOST = 'redis.example.com'; + // No REDIS_PASSWORD set + + const result = SSOEnvironmentValidator.validateEnvironment(); + + expect(result.warnings).toContain('REDIS_PASSWORD not set - consider using authentication in production'); + }); + + it('should warn about short secrets in production', () => { + process.env.NODE_ENV = 'production'; + process.env.SSO_STATE_SECRET = 'a'.repeat(32); // Minimum length but shorter than recommended + process.env.JWT_SECRET = 'b'.repeat(32); + + const result = SSOEnvironmentValidator.validateEnvironment(); + + expect(result.warnings).toContain('SSO_STATE_SECRET is shorter than recommended 64 characters for production'); + expect(result.warnings).toContain('JWT_SECRET is shorter than recommended 64 characters for production'); + }); + }); + + describe('validateOrThrow method', () => { + it('should throw error when validation fails', () => { + // No environment variables set + + expect(() => { + SSOEnvironmentValidator.validateOrThrow(); + }).toThrow(/SSO Environment Validation Failed/); + }); + + it('should not throw when validation passes', () => { + process.env.SSO_STATE_SECRET = 'Kj8#mP9$nQ2@vR5*wS1!xT6&yU3%zV0^aB4&cD7#eF2$gH9@iJ5*kL8!mN1%oP6^'; + process.env.JWT_SECRET = 'dF2$gH9@iJ5*kL8!mN1%oP6^Kj8#mP9$nQ2@vR5*wS1!xT6&yU3%zV0^aB4&cD7#'; + process.env.BACKEND_URL = 'https://api.example.com'; + process.env.REDIS_URL = 'redis://localhost:6379'; + + expect(() => { + SSOEnvironmentValidator.validateOrThrow(); + }).not.toThrow(); + }); + }); + + describe('Utility Methods', () => { + beforeEach(() => { + // Set valid configuration + process.env.SSO_STATE_SECRET = 'secret123'; + process.env.JWT_SECRET = 'jwt456'; + process.env.BACKEND_URL = 'https://api.example.com'; + process.env.REDIS_URL = 'redis://user:pass@redis.example.com:6379'; + process.env.NODE_ENV = 'production'; + }); + + it('should return masked environment summary', () => { + const summary = SSOEnvironmentValidator.getEnvironmentSummary(); + + expect(summary.NODE_ENV).toBe('production'); + expect(summary.SSO_STATE_SECRET).toMatch(/^\*\*\*t123$/); // Masked with last 4 chars + expect(summary.JWT_SECRET).toMatch(/^\*\*\*t456$/); + expect(summary.BACKEND_URL).toBe('https://api.example.com/**'); + expect(summary.REDIS_URL).toBe('redis://redis.example.com:6379/**'); + }); + + it('should detect Redis configuration', () => { + expect(SSOEnvironmentValidator.isRedisConfigured()).toBe(true); + + delete process.env.REDIS_URL; + expect(SSOEnvironmentValidator.isRedisConfigured()).toBe(false); + + process.env.REDIS_HOST = 'localhost'; + expect(SSOEnvironmentValidator.isRedisConfigured()).toBe(true); + }); + + it('should detect production mode', () => { + expect(SSOEnvironmentValidator.isProduction()).toBe(true); + + process.env.NODE_ENV = 'development'; + expect(SSOEnvironmentValidator.isProduction()).toBe(false); + + delete process.env.NODE_ENV; + expect(SSOEnvironmentValidator.isProduction()).toBe(false); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty string values', () => { + process.env.SSO_STATE_SECRET = ''; + process.env.JWT_SECRET = ''; + process.env.BACKEND_URL = ''; + process.env.REDIS_URL = ''; + + const result = SSOEnvironmentValidator.validateEnvironment(); + + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should handle whitespace-only values', () => { + process.env.SSO_STATE_SECRET = ' '; + process.env.JWT_SECRET = ' '; + process.env.BACKEND_URL = ' '; + + const result = SSOEnvironmentValidator.validateEnvironment(); + + expect(result.valid).toBe(false); + }); + + it('should handle invalid URL with valid format but bad content', () => { + process.env.SSO_STATE_SECRET = 'Kj8#mP9$nQ2@vR5*wS1!xT6&yU3%zV0^aB4&cD7#eF2$gH9@iJ5*kL8!mN1%oP6^'; + process.env.JWT_SECRET = 'dF2$gH9@iJ5*kL8!mN1%oP6^Kj8#mP9$nQ2@vR5*wS1!xT6&yU3%zV0^aB4&cD7#'; + process.env.BACKEND_URL = 'https://'; + process.env.REDIS_URL = 'redis://localhost:6379'; + + const result = SSOEnvironmentValidator.validateEnvironment(); + + expect(result.valid).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/Servers/__tests__/sso/sso-health.test.ts b/Servers/__tests__/sso/sso-health.test.ts new file mode 100644 index 000000000..aacaab32d --- /dev/null +++ b/Servers/__tests__/sso/sso-health.test.ts @@ -0,0 +1,469 @@ +/** + * SSO Health Check Integration Tests + * + * Tests for SSO health check endpoints, monitoring functionality, + * and production readiness verification. + */ + +// Set required environment variables for SSO tests BEFORE any imports +process.env.SSO_STATE_SECRET = 'test-state-secret-for-tests'; +process.env.JWT_SECRET = 'test-jwt-secret'; + +import request from 'supertest'; +import express from 'express'; +import ssoHealthRoutes from '../../routes/sso-health.route'; + +// Mock dependencies +jest.mock('../../utils/redis-rate-limiter.utils'); +jest.mock('../../factories/sso-provider.factory'); +jest.mock('../../utils/sso-env-validator.utils'); + +import { getRedisRateLimiter } from '../../utils/redis-rate-limiter.utils'; +import { ssoProviderFactory } from '../../factories/sso-provider.factory'; +import { SSOEnvironmentValidator } from '../../utils/sso-env-validator.utils'; +import { SSOProviderType } from '../../interfaces/sso-provider.interface'; + +const mockGetRedisRateLimiter = getRedisRateLimiter as jest.MockedFunction; +const mockSSOProviderFactory = ssoProviderFactory as jest.Mocked; +const mockSSOEnvironmentValidator = SSOEnvironmentValidator as jest.Mocked; + +describe('SSO Health Check Routes', () => { + let app: express.Application; + + beforeAll(() => { + app = express(); + app.use(express.json()); + app.use('/api/sso-health', ssoHealthRoutes); + }); + + beforeEach(() => { + jest.clearAllMocks(); + + // Default mocks for successful scenarios + mockSSOEnvironmentValidator.validateEnvironment.mockReturnValue({ + valid: true, + errors: [], + warnings: [] + }); + + mockSSOEnvironmentValidator.isRedisConfigured.mockReturnValue(true); + mockSSOEnvironmentValidator.isProduction.mockReturnValue(false); + + const mockRateLimiter = { + healthCheck: jest.fn().mockResolvedValue({ healthy: true, message: 'Redis healthy' }), + getRateLimitStatus: jest.fn().mockResolvedValue({ + attempts: 0, + remaining: 10, + blocked: false + }) + }; + mockGetRedisRateLimiter.mockReturnValue(mockRateLimiter as any); + + mockSSOProviderFactory.getSupportedProviders.mockReturnValue([SSOProviderType.AZURE_AD]); + mockSSOProviderFactory.healthCheckProviders.mockResolvedValue( + new Map([[SSOProviderType.AZURE_AD, { healthy: true, message: 'Azure AD healthy' }]]) + ); + }); + + describe('GET /api/sso-health/', () => { + it('should return healthy status when all checks pass', async () => { + const response = await request(app) + .get('/api/sso-health/') + .expect(200); + + expect(response.body.status).toBe('healthy'); + expect(response.body.timestamp).toBeDefined(); + expect(response.body.version).toBeDefined(); + expect(response.body.checks.environment.status).toBe('pass'); + }); + + it('should return unhealthy status when environment validation fails', async () => { + mockSSOEnvironmentValidator.validateEnvironment.mockReturnValue({ + valid: false, + errors: ['Missing required environment variable: SSO_STATE_SECRET'], + warnings: [] + }); + + const response = await request(app) + .get('/api/sso-health/') + .expect(503); + + expect(response.body.status).toBe('unhealthy'); + expect(response.body.checks.environment.status).toBe('fail'); + expect(response.body.checks.environment.details.errors).toContain( + 'Missing required environment variable: SSO_STATE_SECRET' + ); + }); + + it('should return degraded status when there are warnings', async () => { + mockSSOEnvironmentValidator.validateEnvironment.mockReturnValue({ + valid: true, + errors: [], + warnings: ['REDIS_PASSWORD not set - consider using authentication in production'] + }); + + const response = await request(app) + .get('/api/sso-health/') + .expect(200); + + expect(response.body.status).toBe('degraded'); + expect(response.body.checks.environment.details.warnings).toContain( + 'REDIS_PASSWORD not set - consider using authentication in production' + ); + }); + + it('should handle system errors gracefully', async () => { + mockSSOEnvironmentValidator.validateEnvironment.mockImplementation(() => { + throw new Error('Validation system error'); + }); + + const response = await request(app) + .get('/api/sso-health/') + .expect(503); + + expect(response.body.status).toBe('unhealthy'); + expect(response.body.checks.system.status).toBe('fail'); + expect(response.body.checks.system.message).toContain('Validation system error'); + }); + }); + + describe('GET /api/sso-health/detailed', () => { + it('should return comprehensive health information', async () => { + // Mock normal memory usage to ensure healthy status + const originalMemoryUsage = process.memoryUsage; + process.memoryUsage = jest.fn().mockReturnValue({ + rss: 200 * 1024 * 1024, // 200 MB + heapTotal: 200 * 1024 * 1024, + heapUsed: 200 * 1024 * 1024, // Normal memory usage + external: 20 * 1024 * 1024, + arrayBuffers: 5 * 1024 * 1024 + }) as any; + + const response = await request(app) + .get('/api/sso-health/detailed') + .expect(200); + + expect(response.body.status).toBe('healthy'); + expect(response.body.checks).toHaveProperty('environment'); + expect(response.body.checks).toHaveProperty('redis'); + expect(response.body.checks).toHaveProperty('rateLimiting'); + expect(response.body.checks).toHaveProperty('ssoProviders'); + expect(response.body.checks).toHaveProperty('performance'); + expect(response.body.checks).toHaveProperty('overall'); + + // Verify response times are included + expect(response.body.checks.environment.responseTime).toBeDefined(); + expect(response.body.checks.redis.responseTime).toBeDefined(); + expect(response.body.checks.overall.responseTime).toBeDefined(); + + // Restore original function + process.memoryUsage = originalMemoryUsage; + }); + + it('should detect Redis connectivity issues', async () => { + const mockRateLimiter = { + healthCheck: jest.fn().mockResolvedValue({ healthy: false, message: 'Connection refused' }), + getRateLimitStatus: jest.fn().mockResolvedValue({ + attempts: 0, + remaining: 10, + blocked: false + }) + }; + mockGetRedisRateLimiter.mockReturnValue(mockRateLimiter as any); + + const response = await request(app) + .get('/api/sso-health/detailed') + .expect(503); + + expect(response.body.status).toBe('unhealthy'); + expect(response.body.checks.redis.status).toBe('fail'); + expect(response.body.checks.redis.message).toContain('Connection refused'); + }); + + it('should detect SSO provider issues', async () => { + mockSSOProviderFactory.healthCheckProviders.mockResolvedValue( + new Map([ + [SSOProviderType.AZURE_AD, { healthy: true, message: 'Azure AD healthy' }], + [SSOProviderType.GOOGLE, { healthy: false, message: 'Google SSO unavailable' }] + ]) + ); + + const response = await request(app) + .get('/api/sso-health/detailed') + .expect(200); + + expect(response.body.status).toBe('degraded'); + expect(response.body.checks.ssoProviders.status).toBe('warn'); + expect(response.body.checks.ssoProviders.message).toContain('1/2 providers healthy'); + }); + + it('should warn about high memory usage', async () => { + // Mock high memory usage + const originalMemoryUsage = process.memoryUsage; + process.memoryUsage = jest.fn().mockReturnValue({ + rss: 600 * 1024 * 1024, // 600 MB + heapTotal: 600 * 1024 * 1024, + heapUsed: 600 * 1024 * 1024, // High memory usage + external: 50 * 1024 * 1024, + arrayBuffers: 10 * 1024 * 1024 + }) as any; + + const response = await request(app) + .get('/api/sso-health/detailed') + .expect(200); + + expect(response.body.status).toBe('degraded'); + expect(response.body.checks.performance.status).toBe('warn'); + expect(response.body.checks.performance.message).toContain('High memory usage'); + + // Restore original function + process.memoryUsage = originalMemoryUsage; + }); + + it('should handle rate limiting check failures gracefully', async () => { + const mockRateLimiter = { + healthCheck: jest.fn().mockResolvedValue({ healthy: true }), + getRateLimitStatus: jest.fn().mockRejectedValue(new Error('Rate limit check failed')) + }; + mockGetRedisRateLimiter.mockReturnValue(mockRateLimiter as any); + + const response = await request(app) + .get('/api/sso-health/detailed') + .expect(200); + + expect(response.body.checks.rateLimiting.status).toBe('warn'); + expect(response.body.checks.rateLimiting.message).toContain('Rate limit check failed'); + }); + }); + + describe('GET /api/sso-health/redis', () => { + it('should return Redis health status', async () => { + const response = await request(app) + .get('/api/sso-health/redis') + .expect(200); + + expect(response.body.status).toBe('healthy'); + expect(response.body.redis.healthy).toBe(true); + expect(response.body.timestamp).toBeDefined(); + }); + + it('should return unhealthy status when Redis fails', async () => { + const mockRateLimiter = { + healthCheck: jest.fn().mockResolvedValue({ healthy: false, message: 'Connection failed' }) + }; + mockGetRedisRateLimiter.mockReturnValue(mockRateLimiter as any); + + const response = await request(app) + .get('/api/sso-health/redis') + .expect(503); + + expect(response.body.status).toBe('unhealthy'); + expect(response.body.redis.healthy).toBe(false); + }); + + it('should handle Redis client creation errors', async () => { + mockGetRedisRateLimiter.mockImplementation(() => { + throw new Error('Redis client creation failed'); + }); + + const response = await request(app) + .get('/api/sso-health/redis') + .expect(503); + + expect(response.body.status).toBe('unhealthy'); + expect(response.body.error).toContain('Redis client creation failed'); + }); + }); + + describe('GET /api/sso-health/providers', () => { + it('should return provider health status', async () => { + const response = await request(app) + .get('/api/sso-health/providers') + .expect(200); + + expect(response.body.status).toBe('healthy'); + expect(response.body.summary.total).toBe(1); + expect(response.body.summary.healthy).toBe(1); + expect(response.body.providers[SSOProviderType.AZURE_AD].healthy).toBe(true); + }); + + it('should return partial status when some providers are unhealthy', async () => { + mockSSOProviderFactory.healthCheckProviders.mockResolvedValue( + new Map([ + [SSOProviderType.AZURE_AD, { healthy: true, message: 'Azure AD healthy' }], + [SSOProviderType.GOOGLE, { healthy: false, message: 'Google SSO unavailable' }] + ]) + ); + + const response = await request(app) + .get('/api/sso-health/providers') + .expect(207); + + expect(response.body.status).toBe('partial'); + expect(response.body.summary.total).toBe(2); + expect(response.body.summary.healthy).toBe(1); + }); + + it('should handle provider factory errors', async () => { + mockSSOProviderFactory.healthCheckProviders.mockRejectedValue( + new Error('Provider factory error') + ); + + const response = await request(app) + .get('/api/sso-health/providers') + .expect(503); + + expect(response.body.status).toBe('unhealthy'); + expect(response.body.error).toContain('Provider factory error'); + }); + }); + + describe('GET /api/sso-health/rate-limiting', () => { + it('should return rate limiting status for all operations', async () => { + const response = await request(app) + .get('/api/sso-health/rate-limiting') + .expect(200); + + expect(response.body.status).toBe('healthy'); + expect(response.body.rateLimiting).toHaveProperty('login'); + expect(response.body.rateLimiting).toHaveProperty('callback'); + expect(response.body.rateLimiting).toHaveProperty('token'); + + expect(response.body.rateLimiting.login.status).toBe('operational'); + expect(response.body.rateLimiting.callback.status).toBe('operational'); + expect(response.body.rateLimiting.token.status).toBe('operational'); + }); + + it('should detect rate limiting errors', async () => { + const mockRateLimiter = { + getRateLimitStatus: jest.fn() + .mockResolvedValueOnce({ attempts: 0, remaining: 10, blocked: false }) // login + .mockRejectedValueOnce(new Error('Callback rate limit failed')) // callback + .mockResolvedValueOnce({ attempts: 0, remaining: 15, blocked: false }) // token + }; + mockGetRedisRateLimiter.mockReturnValue(mockRateLimiter as any); + + const response = await request(app) + .get('/api/sso-health/rate-limiting') + .expect(207); + + expect(response.body.status).toBe('partial'); + expect(response.body.rateLimiting.login.status).toBe('operational'); + expect(response.body.rateLimiting.callback.status).toBe('error'); + expect(response.body.rateLimiting.token.status).toBe('operational'); + }); + + it('should handle complete rate limiting failure', async () => { + mockGetRedisRateLimiter.mockImplementation(() => { + throw new Error('Rate limiter unavailable'); + }); + + const response = await request(app) + .get('/api/sso-health/rate-limiting') + .expect(503); + + expect(response.body.status).toBe('unhealthy'); + expect(response.body.error).toContain('Rate limiter unavailable'); + }); + }); + + describe('GET /api/sso-health/ready', () => { + it('should return ready when all critical dependencies are available', async () => { + const response = await request(app) + .get('/api/sso-health/ready') + .expect(200); + + expect(response.body.ready).toBe(true); + expect(response.body.message).toBe('SSO system ready'); + }); + + it('should return not ready when environment validation fails', async () => { + mockSSOEnvironmentValidator.validateEnvironment.mockReturnValue({ + valid: false, + errors: ['Missing required environment variable: SSO_STATE_SECRET'], + warnings: [] + }); + + const response = await request(app) + .get('/api/sso-health/ready') + .expect(503); + + expect(response.body.ready).toBe(false); + expect(response.body.reason).toBe('Environment validation failed'); + expect(response.body.errors).toContain('Missing required environment variable: SSO_STATE_SECRET'); + }); + + it('should return not ready when Redis is unavailable', async () => { + const mockRateLimiter = { + healthCheck: jest.fn().mockResolvedValue({ healthy: false, message: 'Connection failed' }) + }; + mockGetRedisRateLimiter.mockReturnValue(mockRateLimiter as any); + + const response = await request(app) + .get('/api/sso-health/ready') + .expect(503); + + expect(response.body.ready).toBe(false); + expect(response.body.reason).toBe('Redis not available'); + }); + + it('should handle readiness check errors', async () => { + mockSSOEnvironmentValidator.validateEnvironment.mockImplementation(() => { + throw new Error('Environment check crashed'); + }); + + const response = await request(app) + .get('/api/sso-health/ready') + .expect(503); + + expect(response.body.ready).toBe(false); + expect(response.body.error).toContain('Environment check crashed'); + }); + }); + + describe('GET /api/sso-health/live', () => { + it('should always return alive for liveness probe', async () => { + const response = await request(app) + .get('/api/sso-health/live') + .expect(200); + + expect(response.body.alive).toBe(true); + expect(response.body.timestamp).toBeDefined(); + expect(response.body.uptime).toBeDefined(); + expect(response.body.pid).toBeDefined(); + }); + }); + + describe('Response Format Validation', () => { + it('should include required fields in health responses', async () => { + const response = await request(app) + .get('/api/sso-health/') + .expect(200); + + expect(response.body).toHaveProperty('status'); + expect(response.body).toHaveProperty('timestamp'); + expect(response.body).toHaveProperty('checks'); + expect(['healthy', 'degraded', 'unhealthy']).toContain(response.body.status); + }); + + it('should include response times in detailed checks', async () => { + const response = await request(app) + .get('/api/sso-health/detailed') + .expect(200); + + Object.keys(response.body.checks).forEach(checkName => { + expect(response.body.checks[checkName]).toHaveProperty('responseTime'); + expect(typeof response.body.checks[checkName].responseTime).toBe('number'); + }); + }); + + it('should use consistent timestamp format', async () => { + const response = await request(app) + .get('/api/sso-health/') + .expect(200); + + const timestamp = new Date(response.body.timestamp); + expect(timestamp.toISOString()).toBe(response.body.timestamp); + }); + }); +}); \ No newline at end of file diff --git a/Servers/abstracts/base-sso-provider.abstract.ts b/Servers/abstracts/base-sso-provider.abstract.ts new file mode 100644 index 000000000..f10dfb21d --- /dev/null +++ b/Servers/abstracts/base-sso-provider.abstract.ts @@ -0,0 +1,536 @@ +/** + * @fileoverview Base SSO Provider Abstract Class + * + * Foundational abstract class providing common functionality, security features, + * and standardized patterns for all SSO provider implementations. This class + * implements the Template Method pattern to ensure consistent behavior while + * allowing provider-specific customization through abstract methods. + * + * This base class provides: + * - Standardized initialization and validation patterns + * - Security-focused email domain validation with timing-safe comparisons + * - Comprehensive audit logging integration + * - Rate limiting support with Redis integration + * - Common error handling and normalization + * - Configuration management and validation + * - Health checking infrastructure + * + * Security Features: + * - Timing-safe domain comparison to prevent timing attacks + * - Wildcard domain support with secure pattern matching + * - Input sanitization for user data normalization + * - Comprehensive audit logging for security monitoring + * - Rate limiting integration for abuse prevention + * - Secure configuration validation and management + * + * Architecture Pattern: + * - Template Method: Defines algorithm structure with customizable steps + * - Abstract Factory: Requires implementation of provider-specific methods + * - Strategy Pattern: Allows different validation and processing strategies + * - Observer Pattern: Integrates with audit logging for event tracking + * + * Extension Guidelines: + * - Extend this class for all new SSO provider implementations + * - Implement all abstract methods with provider-specific logic + * - Use provided helper methods for security and consistency + * - Follow the established patterns for error handling and logging + * - Leverage rate limiting and validation infrastructure + * + * @author VerifyWise Development Team + * @since 2024-09-28 + * @version 1.0.0 + * @see {@link ISSOProvider} Core SSO provider interface + * @see {@link SSOAuditLogger} Audit logging integration + * + * @module abstracts/base-sso-provider + */ + +import { Request } from 'express'; +import crypto from 'crypto'; +import { + ISSOProvider, + SSOProviderType, + SSOProviderConfig, + SSOLoginResult, + SSOAuthResult, + SSOUserInfo, + SSOError, + SSOErrorType, + CloudEnvironment +} from '../interfaces/sso-provider.interface'; +import { SSOAuditLogger } from '../utils/sso-audit-logger.utils'; +import { getRedisRateLimiter } from '../utils/redis-rate-limiter.utils'; + +/** + * Base abstract class for all SSO provider implementations + * + * Provides foundational functionality and security patterns that all SSO providers + * should implement. Uses the Template Method pattern to define the overall structure + * while allowing provider-specific customization through abstract methods. + * + * Key Features: + * - Standardized initialization and validation lifecycle + * - Security-focused domain validation with timing-safe comparisons + * - Integrated audit logging and rate limiting + * - Common error handling and configuration management + * - Helper methods for data sanitization and normalization + * + * @abstract + * @class BaseSSOProvider + * @implements {ISSOProvider} + * + * @example + * ```typescript + * class AzureADProvider extends BaseSSOProvider { + * constructor() { + * super(SSOProviderType.AZURE_AD, 'azure-ad-main'); + * } + * + * protected async onInitialize(config: SSOProviderConfig): Promise { + * // Initialize Azure AD MSAL client + * this.msalApp = new ConfidentialClientApplication({ + * auth: { + * clientId: config.clientId, + * clientSecret: config.clientSecret, + * authority: `https://login.microsoftonline.com/${config.tenantId}` + * } + * }); + * } + * + * // Implement other abstract methods... + * } + * ``` + */ +export abstract class BaseSSOProvider implements ISSOProvider { + /** Provider configuration (set during initialization) */ + protected config?: SSOProviderConfig; + + /** Initialization state flag */ + protected initialized: boolean = false; + + /** + * Creates a new SSO provider instance + * + * @param {SSOProviderType} providerType - Type of SSO provider (immutable) + * @param {string} providerId - Unique identifier for this provider instance (immutable) + */ + constructor( + public readonly providerType: SSOProviderType, + public readonly providerId: string + ) {} + + /** + * Initialize the provider with configuration + */ + async initialize(config: SSOProviderConfig): Promise { + // Validate configuration before initialization + const validation = await this.validateConfig(config); + if (!validation.valid) { + throw new SSOError( + SSOErrorType.CONFIGURATION_ERROR, + `Configuration validation failed: ${validation.errors.join(', ')}`, + this.providerType + ); + } + + this.config = config; + await this.onInitialize(config); + this.initialized = true; + } + + /** + * Provider-specific initialization logic (must be implemented by subclasses) + * + * Called after common validation passes during the initialization process. + * Implement provider-specific setup such as OAuth client creation, + * SAML configuration, or API client initialization. + * + * @protected + * @abstract + * @param {SSOProviderConfig} config - Validated provider configuration + * @returns {Promise} Resolves when provider-specific initialization completes + * + * @example + * ```typescript + * // Azure AD implementation + * protected async onInitialize(config: SSOProviderConfig): Promise { + * this.msalApp = new ConfidentialClientApplication({ + * auth: { + * clientId: config.clientId, + * clientSecret: config.clientSecret, + * authority: `https://login.microsoftonline.com/${config.tenantId}` + * } + * }); + * } + * ``` + */ + protected abstract onInitialize(config: SSOProviderConfig): Promise; + + /** + * Validate provider configuration + */ + async validateConfig(config: Partial): Promise<{ valid: boolean; errors: string[] }> { + const errors: string[] = []; + + // Common validation + if (!config.clientId) { + errors.push('Client ID is required'); + } + + if (!config.clientSecret) { + errors.push('Client Secret is required'); + } + + if (!config.organizationId) { + errors.push('Organization ID is required'); + } + + if (!config.providerType) { + errors.push('Provider type is required'); + } + + if (config.providerType !== this.providerType) { + errors.push(`Provider type mismatch: expected ${this.providerType}, got ${config.providerType}`); + } + + // Provider-specific validation + const providerErrors = await this.validateProviderSpecificConfig(config); + errors.push(...providerErrors); + + return { + valid: errors.length === 0, + errors + }; + } + + /** + * Provider-specific configuration validation + */ + protected abstract validateProviderSpecificConfig(config: Partial): Promise; + + /** + * Generate login URL for SSO initiation + */ + abstract getLoginUrl(req: Request, organizationId: string, additionalParams?: Record): Promise; + + /** + * Handle callback from SSO provider + */ + abstract handleCallback(req: Request, organizationId: string): Promise; + + /** + * Exchange authorization code for user information + */ + abstract exchangeCodeForUser(authCode: string, state: string): Promise; + + /** + * Get provider-specific metadata URLs + */ + abstract getMetadataUrls(): { authUrl?: string; tokenUrl?: string; userInfoUrl?: string; logoutUrl?: string }; + + /** + * Validates email domain against provider configuration with security-focused implementation + * + * Implements timing-safe domain comparison to prevent timing attacks that could + * be used to enumerate allowed domains. Supports wildcard subdomains and + * comprehensive domain validation patterns. + * + * @param {string} email - User email address to validate + * @returns {Promise} True if domain is allowed, false otherwise + * + * @security + * - Uses timing-safe comparison to prevent enumeration attacks + * - Supports wildcard subdomain patterns (*.company.com) + * - Normalizes domains to lowercase for consistent comparison + * - Implements constant-time algorithm to prevent information leakage + * + * @example + * ```typescript + * // Configuration: allowedDomains: ['company.com', '*.partner.org'] + * await provider.isEmailDomainAllowed('user@company.com'); // true + * await provider.isEmailDomainAllowed('user@sub.partner.org'); // true + * await provider.isEmailDomainAllowed('user@other.com'); // false + * ``` + */ + async isEmailDomainAllowed(email: string): Promise { + if (!this.config) { + throw new SSOError(SSOErrorType.CONFIGURATION_ERROR, 'Provider not initialized', this.providerType); + } + + if (!this.config.allowedDomains || this.config.allowedDomains.length === 0) { + return true; // No restrictions + } + + const domain = email.split('@')[1]?.toLowerCase(); + if (!domain) { + return false; + } + + // Use constant-time comparison to prevent timing attacks + let isAllowed = false; + + for (const allowedDomain of this.config.allowedDomains) { + const normalizedAllowed = allowedDomain.toLowerCase().trim(); + let domainMatches = false; + + // Support wildcard subdomains (e.g., *.company.com) + if (normalizedAllowed.startsWith('*.')) { + const baseDomain = normalizedAllowed.substring(2); + + try { + const exactBuffer = Buffer.from(domain.padEnd(64, '\0')); + const baseDomainBuffer = Buffer.from(baseDomain.padEnd(64, '\0')); + + // Check exact match with base domain + const exactMatchResult = domain.length === baseDomain.length && + crypto.timingSafeEqual(exactBuffer.subarray(0, baseDomain.length), baseDomainBuffer.subarray(0, baseDomain.length)); + + // Check wildcard match (domain ends with .baseDomain) + const wildcardMatchResult = domain.endsWith('.' + baseDomain); + + domainMatches = exactMatchResult || wildcardMatchResult; + } catch (error) { + // Fall back to regular comparison if timing-safe comparison fails + domainMatches = domain === baseDomain || domain.endsWith('.' + baseDomain); + } + } else { + // Regular domain comparison with constant-time comparison + try { + const domainBuffer = Buffer.from(domain.padEnd(64, '\0')); + const allowedBuffer = Buffer.from(normalizedAllowed.padEnd(64, '\0')); + + domainMatches = domain.length === normalizedAllowed.length && + crypto.timingSafeEqual(domainBuffer.subarray(0, domain.length), allowedBuffer.subarray(0, normalizedAllowed.length)); + } catch (error) { + // Fall back to regular comparison if timing-safe comparison fails + domainMatches = domain === normalizedAllowed; + } + } + + // Use bitwise OR with numeric conversion for true constant-time operation + isAllowed = Boolean((isAllowed ? 1 : 0) | (domainMatches ? 1 : 0)); + } + + return isAllowed; + } + + /** + * Provider health check + */ + async healthCheck(): Promise<{ healthy: boolean; message?: string }> { + if (!this.initialized) { + return { + healthy: false, + message: 'Provider not initialized' + }; + } + + if (!this.config) { + return { + healthy: false, + message: 'Configuration missing' + }; + } + + // Provider-specific health check + return await this.performHealthCheck(); + } + + /** + * Provider-specific health check implementation + */ + protected abstract performHealthCheck(): Promise<{ healthy: boolean; message?: string }>; + + + /** + * Normalize user information from provider-specific format + */ + protected abstract normalizeUserInfo(providerUserData: any): SSOUserInfo; + + /** + * Sanitize user name fields + */ + protected sanitizeName(name: string): string { + if (!name || typeof name !== 'string') { + return ''; + } + + return name + .replace(/[<>{}[\]\\\/\x00-\x1f\x7f]/g, '') // Remove dangerous characters + .substring(0, 50) // Limit length + .trim(); + } + + /** + * Validate email format + */ + protected isValidEmail(email: string): boolean { + if (!email || typeof email !== 'string') { + return false; + } + + // Basic email validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email) && email.length <= 320; + } + + /** + * Log audit events + */ + protected logAuditEvent( + req: Request, + eventType: string, + success: boolean, + userEmail?: string, + error?: string + ): void { + if (!this.config) return; + + const organizationId = this.config.organizationId.toString(); + + switch (eventType) { + case 'login_initiated': + SSOAuditLogger.logLoginInitiation(req, organizationId, success, error); + break; + case 'authentication_failure': + SSOAuditLogger.logAuthenticationFailure(req, organizationId, error || 'Unknown error', userEmail); + break; + case 'token_exchange': + SSOAuditLogger.logTokenExchange(req, organizationId, success, error); + break; + default: + // Custom event logging could be added here + break; + } + } + + /** + * Get configuration value safely + */ + protected getConfigValue(key: keyof SSOProviderConfig): T | undefined { + if (!this.config) { + throw new SSOError(SSOErrorType.CONFIGURATION_ERROR, 'Provider not initialized', this.providerType); + } + return this.config[key] as T; + } + + /** + * Check if provider is initialized + */ + protected ensureInitialized(): void { + if (!this.initialized || !this.config) { + throw new SSOError(SSOErrorType.CONFIGURATION_ERROR, 'Provider not initialized', this.providerType); + } + } + + /** + * Checks rate limiting for SSO operations using Redis-based rate limiting + * + * Implements distributed rate limiting to prevent abuse of SSO endpoints. + * Integrates with Redis for cluster-wide rate limiting and provides + * graceful degradation when Redis is unavailable. + * + * @protected + * @param {Request} req - HTTP request for client identification + * @param {'login'|'callback'|'token'} operation - Type of SSO operation + * @returns {Promise<{allowed: boolean; retryAfter?: number}>} Rate limit result + * + * @rate_limits + * - login: Prevents brute force login attempts + * - callback: Prevents callback endpoint abuse + * - token: Prevents token exchange abuse + * + * @fail_safe + * - Fails open when Redis is unavailable (logs error, allows request) + * - Logs rate limit violations for security monitoring + * - Provides retry-after timing for client backoff + * + * @example + * ```typescript + * const rateLimit = await this.checkRateLimit(req, 'login'); + * if (!rateLimit.allowed) { + * return res.status(429).json({ + * error: 'Rate limit exceeded', + * retryAfter: rateLimit.retryAfter + * }); + * } + * ``` + */ + protected async checkRateLimit( + req: Request, + operation: 'login' | 'callback' | 'token' + ): Promise<{ allowed: boolean; retryAfter?: number }> { + try { + const rateLimiter = getRedisRateLimiter(); + const result = await rateLimiter.checkRateLimit(req, operation, this.providerType); + + if (!result.allowed) { + this.logAuditEvent(req, 'rate_limit_exceeded', false, undefined, + `Rate limit exceeded for ${operation} operation: ${result.attempts} attempts`); + } + + return { + allowed: result.allowed, + retryAfter: result.retryAfter + }; + } catch (error) { + // If Redis is down, log error but allow the request (fail open) + console.error('Rate limiting error:', error); + this.logAuditEvent(req, 'rate_limit_error', false, undefined, + `Rate limiting unavailable: ${error instanceof Error ? error.message : 'Unknown error'}`); + + // In production, you might want to fail closed instead + return { allowed: true }; + } + } + + /** + * Handle provider-specific errors + */ + protected handleProviderError(error: any, context: string): SSOError { + if (error instanceof SSOError) { + return error; + } + + // Map common HTTP errors + if (error.response) { + const status = error.response.status; + if (status === 401 || status === 403) { + return new SSOError( + SSOErrorType.AUTHENTICATION_FAILED, + `Authentication failed in ${context}: ${error.message}`, + this.providerType, + error + ); + } + if (status >= 500) { + return new SSOError( + SSOErrorType.PROVIDER_ERROR, + `Provider error in ${context}: ${error.message}`, + this.providerType, + error + ); + } + } + + // Network errors + if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') { + return new SSOError( + SSOErrorType.NETWORK_ERROR, + `Network error in ${context}: ${error.message}`, + this.providerType, + error + ); + } + + // Default to provider error + return new SSOError( + SSOErrorType.PROVIDER_ERROR, + `Error in ${context}: ${error.message}`, + this.providerType, + error + ); + } +} + +export default BaseSSOProvider; \ No newline at end of file diff --git a/Servers/auth/interfaces/ISSOProvider.ts b/Servers/auth/interfaces/ISSOProvider.ts new file mode 100644 index 000000000..4be4a8d2c --- /dev/null +++ b/Servers/auth/interfaces/ISSOProvider.ts @@ -0,0 +1,75 @@ +export interface ISSOConfiguration { + id?: number; + organizationId: number; + providerId: number; + providerType: SSOProviderType; + providerConfig: Record; + isEnabled: boolean; + allowedDomains?: string[]; + defaultRoleId?: number; + createdAt?: Date; + updatedAt?: Date; +} + +export interface ISSOProvider { + getProviderType(): SSOProviderType; + + validateConfiguration(config: Record): ValidationResult; + + getAuthorizationUrl( + config: ISSOConfiguration, + organizationId: string, + state: string + ): Promise; + + handleCallback( + code: string, + state: string, + config: ISSOConfiguration + ): Promise; + + refreshToken?(refreshToken: string, config: ISSOConfiguration): Promise; +} + +export interface ISSOUserInfo { + providerId: string; + email: string; + firstName: string; + lastName: string; + displayName: string; + providerUserId: string; + additionalClaims?: Record; +} + +export interface ISSOTokens { + accessToken: string; + refreshToken?: string; + idToken?: string; + expiresAt: Date; +} + +export interface ValidationResult { + isValid: boolean; + errors: ValidationError[]; +} + +export interface ValidationError { + field: string; + message: string; + code: string; +} + +export enum SSOProviderType { + AZURE_AD = 'azure-ad', + GOOGLE = 'google', + OKTA = 'okta', + SAML = 'saml' +} + +export interface SSOStateToken { + organizationId: string; + providerId: number; + nonce: string; + timestamp: number; + expiresAt: number; +} \ No newline at end of file diff --git a/Servers/auth/providers/azure-ad/AzureAdProvider.ts b/Servers/auth/providers/azure-ad/AzureAdProvider.ts new file mode 100644 index 000000000..66f42163e --- /dev/null +++ b/Servers/auth/providers/azure-ad/AzureAdProvider.ts @@ -0,0 +1,214 @@ +import { ConfidentialClientApplication } from '@azure/msal-node'; +import { + ISSOProvider, + ISSOConfiguration, + ISSOUserInfo, + SSOProviderType, + ValidationResult +} from '../../interfaces/ISSOProvider'; +import { SSOValidation } from '../../utils/SSOValidation'; +import { SSOError, SSOErrorCode, createSSOError } from '../../utils/SSOErrors'; + +interface AzureAdConfig { + azure_client_id: string; + azure_client_secret: string; + azure_tenant_id: string; + azure_cloud_environment?: 'public' | 'government' | 'china' | 'germany'; +} + +export class AzureAdProvider implements ISSOProvider { + private static readonly SCOPES = ['openid', 'profile', 'email']; + private static readonly CLOUD_ENVIRONMENTS = { + public: 'https://login.microsoftonline.com', + government: 'https://login.microsoftonline.us', + china: 'https://login.partner.microsoftonline.cn', + germany: 'https://login.microsoftonline.de' + }; + + getProviderType(): SSOProviderType { + return SSOProviderType.AZURE_AD; + } + + validateConfiguration(config: Record): ValidationResult { + return SSOValidation.validateAzureAdConfiguration(config); + } + + async getAuthorizationUrl( + config: ISSOConfiguration, + organizationId: string, + state: string + ): Promise { + const azureConfig = this.getAzureConfig(config); + const cca = this.createMsalClient(azureConfig); + + const authCodeUrlParameters = { + scopes: AzureAdProvider.SCOPES, + redirectUri: this.getRedirectUri(organizationId), + state: state + }; + + try { + return await cca.getAuthCodeUrl(authCodeUrlParameters); + } catch (error) { + throw createSSOError(SSOErrorCode.AZURE_AD_ERROR, { + reason: 'Failed to generate authorization URL', + error: error instanceof Error ? error.message : 'Unknown error', + organizationId + }); + } + } + + async handleCallback( + code: string, + state: string, + config: ISSOConfiguration + ): Promise { + const validatedCode = SSOValidation.validateAuthCode(code); + const azureConfig = this.getAzureConfig(config); + const cca = this.createMsalClient(azureConfig); + + const tokenRequest = { + code: validatedCode, + scopes: AzureAdProvider.SCOPES, + redirectUri: this.getRedirectUri(config.organizationId.toString()) + }; + + try { + const response = await cca.acquireTokenByCode(tokenRequest); + + if (!response || !response.account) { + throw createSSOError(SSOErrorCode.TOKEN_EXCHANGE_FAILED, { + reason: 'No account information received from Azure AD', + organizationId: config.organizationId + }); + } + + return this.extractUserInfo(response.account); + } catch (error) { + if (error instanceof SSOError) { + throw error; + } + + throw createSSOError(SSOErrorCode.AZURE_AD_ERROR, { + reason: 'Token exchange failed', + error: error instanceof Error ? error.message : 'Unknown error', + organizationId: config.organizationId + }); + } + } + + private getAzureConfig(config: ISSOConfiguration): AzureAdConfig { + const validation = this.validateConfiguration(config.providerConfig); + if (!validation.isValid) { + throw createSSOError(SSOErrorCode.INVALID_CONFIGURATION, { + reason: 'Invalid Azure AD configuration', + errors: validation.errors + }); + } + + return config.providerConfig as AzureAdConfig; + } + + private createMsalClient(azureConfig: AzureAdConfig): ConfidentialClientApplication { + const authority = this.getAuthority(azureConfig); + + const msalConfig = { + auth: { + clientId: azureConfig.azure_client_id, + clientSecret: azureConfig.azure_client_secret, + authority: authority + } + }; + + try { + return new ConfidentialClientApplication(msalConfig); + } catch (error) { + throw createSSOError(SSOErrorCode.AZURE_AD_ERROR, { + reason: 'Failed to create MSAL client', + error: error instanceof Error ? error.message : 'Unknown error' + }); + } + } + + private getAuthority(azureConfig: AzureAdConfig): string { + const cloudEnv = azureConfig.azure_cloud_environment || 'public'; + const baseUrl = AzureAdProvider.CLOUD_ENVIRONMENTS[cloudEnv]; + + if (!baseUrl) { + throw createSSOError(SSOErrorCode.INVALID_CONFIGURATION, { + reason: 'Invalid Azure cloud environment', + environment: cloudEnv + }); + } + + return `${baseUrl}/${azureConfig.azure_tenant_id}`; + } + + private getRedirectUri(organizationId: string): string { + const backendUrl = process.env.BACKEND_URL || 'http://localhost:3000'; + return `${backendUrl}/api/auth/azure-ad/${organizationId}/callback`; + } + + private extractUserInfo(account: any): ISSOUserInfo { + if (!account.username || !account.username.includes('@')) { + throw createSSOError(SSOErrorCode.INVALID_USER_INFO, { + reason: 'Invalid or missing email in Azure AD response' + }); + } + + const email = account.username; + + // Extract name information + const displayName = account.name || ''; + const nameParts = displayName.split(' '); + const firstName = nameParts[0] || 'Unknown'; + const lastName = nameParts.slice(1).join(' ') || 'User'; + + // Validate extracted information + const emailValidation = SSOValidation.validateEmail(email); + if (!emailValidation.isValid) { + throw createSSOError(SSOErrorCode.INVALID_USER_INFO, { + reason: 'Invalid email format from Azure AD', + email: email, + errors: emailValidation.errors + }); + } + + const firstNameValidation = SSOValidation.validateUserName(firstName, 'firstName'); + const lastNameValidation = SSOValidation.validateUserName(lastName, 'lastName'); + + if (!firstNameValidation.isValid || !lastNameValidation.isValid) { + // Log warning but don't fail - use sanitized versions + console.warn('Invalid name information from Azure AD', { + email, + firstNameErrors: firstNameValidation.errors, + lastNameErrors: lastNameValidation.errors + }); + } + + return { + providerId: account.homeAccountId?.split('.')[0] || account.localAccountId || email, + email: email, + firstName: SSOValidation.sanitizeDisplayName(firstName), + lastName: SSOValidation.sanitizeDisplayName(lastName), + displayName: SSOValidation.sanitizeDisplayName(displayName), + providerUserId: account.homeAccountId?.split('.')[0] || account.localAccountId || email, + additionalClaims: { + tenantId: account.tenantId, + homeAccountId: account.homeAccountId, + localAccountId: account.localAccountId, + environment: account.environment + } + }; + } + + /** + * Get the Azure AD base URL for the specified cloud environment + */ + static getAzureAdBaseUrl(cloudEnvironment: string = 'public'): string { + return AzureAdProvider.CLOUD_ENVIRONMENTS[cloudEnvironment as keyof typeof AzureAdProvider.CLOUD_ENVIRONMENTS] + || AzureAdProvider.CLOUD_ENVIRONMENTS.public; + } +} + +export default AzureAdProvider; \ No newline at end of file diff --git a/Servers/auth/utils/SSOErrors.ts b/Servers/auth/utils/SSOErrors.ts new file mode 100644 index 000000000..92006a9bb --- /dev/null +++ b/Servers/auth/utils/SSOErrors.ts @@ -0,0 +1,147 @@ +export enum SSOErrorCode { + // Configuration Errors + PROVIDER_NOT_CONFIGURED = 'PROVIDER_NOT_CONFIGURED', + INVALID_CONFIGURATION = 'INVALID_CONFIGURATION', + PROVIDER_DISABLED = 'PROVIDER_DISABLED', + + // Authentication Errors + INVALID_STATE_TOKEN = 'INVALID_STATE_TOKEN', + EXPIRED_STATE_TOKEN = 'EXPIRED_STATE_TOKEN', + INVALID_AUTH_CODE = 'INVALID_AUTH_CODE', + TOKEN_EXCHANGE_FAILED = 'TOKEN_EXCHANGE_FAILED', + + // User Provisioning Errors + EMAIL_DOMAIN_NOT_ALLOWED = 'EMAIL_DOMAIN_NOT_ALLOWED', + INVALID_USER_INFO = 'INVALID_USER_INFO', + USER_CREATION_FAILED = 'USER_CREATION_FAILED', + ORGANIZATION_NOT_FOUND = 'ORGANIZATION_NOT_FOUND', + + // Security Errors + INSUFFICIENT_PERMISSIONS = 'INSUFFICIENT_PERMISSIONS', + INVALID_ORGANIZATION_ACCESS = 'INVALID_ORGANIZATION_ACCESS', + RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED', + + // Provider Specific Errors + AZURE_AD_ERROR = 'AZURE_AD_ERROR', + GOOGLE_ERROR = 'GOOGLE_ERROR', + OKTA_ERROR = 'OKTA_ERROR' +} + +export class SSOError extends Error { + public readonly code: SSOErrorCode; + public readonly statusCode: number; + public readonly isOperational: boolean; + public readonly context?: Record; + public readonly userMessage: string; + + constructor( + code: SSOErrorCode, + message: string, + userMessage: string, + statusCode: number = 500, + context?: Record + ) { + super(message); + this.name = 'SSOError'; + this.code = code; + this.statusCode = statusCode; + this.userMessage = userMessage; + this.isOperational = true; + this.context = context; + + Error.captureStackTrace(this, this.constructor); + } + + toJSON() { + return { + name: this.name, + code: this.code, + message: this.message, + userMessage: this.userMessage, + statusCode: this.statusCode, + context: this.context + }; + } +} + +export const SSOErrorMessages = { + [SSOErrorCode.PROVIDER_NOT_CONFIGURED]: { + message: 'SSO provider is not configured for this organization', + userMessage: 'Single Sign-On is not available for your organization. Please contact your administrator.', + statusCode: 404 + }, + [SSOErrorCode.INVALID_CONFIGURATION]: { + message: 'SSO provider configuration is invalid', + userMessage: 'There is an issue with the Single Sign-On configuration. Please contact your administrator.', + statusCode: 500 + }, + [SSOErrorCode.PROVIDER_DISABLED]: { + message: 'SSO provider is disabled for this organization', + userMessage: 'Single Sign-On is currently disabled for your organization. Please contact your administrator.', + statusCode: 403 + }, + [SSOErrorCode.INVALID_STATE_TOKEN]: { + message: 'Invalid or missing state token in SSO callback', + userMessage: 'Authentication failed due to security validation. Please try again.', + statusCode: 400 + }, + [SSOErrorCode.EXPIRED_STATE_TOKEN]: { + message: 'State token has expired', + userMessage: 'Your authentication session has expired. Please try again.', + statusCode: 400 + }, + [SSOErrorCode.EMAIL_DOMAIN_NOT_ALLOWED]: { + message: 'User email domain is not in the allowed domains list', + userMessage: 'Your email domain is not authorized for this organization. Please contact your administrator.', + statusCode: 403 + }, + [SSOErrorCode.INVALID_USER_INFO]: { + message: 'Invalid user information received from SSO provider', + userMessage: 'Unable to retrieve your profile information. Please try again or contact support.', + statusCode: 400 + }, + [SSOErrorCode.USER_CREATION_FAILED]: { + message: 'Failed to create user account from SSO information', + userMessage: 'Unable to create your account. Please contact your administrator.', + statusCode: 500 + }, + [SSOErrorCode.ORGANIZATION_NOT_FOUND]: { + message: 'Organization not found or inactive', + userMessage: 'Organization not found. Please verify the login URL and try again.', + statusCode: 404 + }, + [SSOErrorCode.INSUFFICIENT_PERMISSIONS]: { + message: 'User does not have sufficient permissions', + userMessage: 'You do not have permission to perform this action.', + statusCode: 403 + }, + [SSOErrorCode.INVALID_ORGANIZATION_ACCESS]: { + message: 'User does not have access to this organization', + userMessage: 'You do not have access to this organization. Please contact your administrator.', + statusCode: 403 + } +}; + +export function createSSOError( + code: SSOErrorCode, + context?: Record +): SSOError { + const errorConfig = SSOErrorMessages[code]; + if (!errorConfig) { + return new SSOError( + code, + 'Unknown SSO error occurred', + 'An unexpected error occurred. Please try again.', + 500, + context + ); + } + + return new SSOError( + code, + errorConfig.message, + errorConfig.userMessage, + errorConfig.statusCode, + context + ); +} \ No newline at end of file diff --git a/Servers/auth/utils/SSOStateToken.ts b/Servers/auth/utils/SSOStateToken.ts new file mode 100644 index 000000000..ad7611fc8 --- /dev/null +++ b/Servers/auth/utils/SSOStateToken.ts @@ -0,0 +1,138 @@ +import crypto from 'crypto'; +import jwt from 'jsonwebtoken'; +import { SSOStateToken } from '../interfaces/ISSOProvider'; +import { SSOError, SSOErrorCode, createSSOError } from './SSOErrors'; + +const STATE_TOKEN_SECRET = process.env.SSO_STATE_SECRET || process.env.JWT_SECRET; +const STATE_TOKEN_EXPIRY = 10 * 60 * 1000; // 10 minutes + +if (!STATE_TOKEN_SECRET) { + throw new Error('SSO_STATE_SECRET or JWT_SECRET environment variable is required'); +} + +export class SSOStateTokenManager { + /** + * Generate a secure state token for SSO authentication + */ + static generateStateToken(organizationId: string, providerId: number): string { + const nonce = crypto.randomBytes(32).toString('hex'); + const timestamp = Date.now(); + const expiresAt = timestamp + STATE_TOKEN_EXPIRY; + + const payload: SSOStateToken = { + organizationId, + providerId, + nonce, + timestamp, + expiresAt + }; + + return jwt.sign(payload, STATE_TOKEN_SECRET!, { + algorithm: 'HS256', + expiresIn: '10m', + issuer: 'verifywise-sso', + audience: `org-${organizationId}` + }); + } + + /** + * Validate and decode a state token + */ + static validateStateToken( + token: string, + expectedOrganizationId: string, + expectedProviderId: number + ): SSOStateToken { + if (!token) { + throw createSSOError(SSOErrorCode.INVALID_STATE_TOKEN, { + reason: 'Missing state token' + }); + } + + try { + const decoded = jwt.verify(token, STATE_TOKEN_SECRET!, { + algorithms: ['HS256'], + issuer: 'verifywise-sso', + audience: `org-${expectedOrganizationId}` + }) as SSOStateToken; + + // Additional validation checks + if (decoded.organizationId !== expectedOrganizationId) { + throw createSSOError(SSOErrorCode.INVALID_STATE_TOKEN, { + reason: 'Organization ID mismatch', + expected: expectedOrganizationId, + received: decoded.organizationId + }); + } + + if (decoded.providerId !== expectedProviderId) { + throw createSSOError(SSOErrorCode.INVALID_STATE_TOKEN, { + reason: 'Provider ID mismatch', + expected: expectedProviderId, + received: decoded.providerId + }); + } + + // Check manual expiry (belt and suspenders with JWT exp) + if (Date.now() > decoded.expiresAt) { + throw createSSOError(SSOErrorCode.EXPIRED_STATE_TOKEN, { + expiresAt: new Date(decoded.expiresAt).toISOString(), + currentTime: new Date().toISOString() + }); + } + + return decoded; + } catch (error) { + if (error instanceof SSOError) { + throw error; + } + + if (error instanceof jwt.TokenExpiredError) { + throw createSSOError(SSOErrorCode.EXPIRED_STATE_TOKEN, { + expiredAt: error.expiredAt?.toISOString(), + currentTime: new Date().toISOString() + }); + } + + if (error instanceof jwt.JsonWebTokenError) { + throw createSSOError(SSOErrorCode.INVALID_STATE_TOKEN, { + reason: 'Invalid JWT format', + jwtError: error.message + }); + } + + throw createSSOError(SSOErrorCode.INVALID_STATE_TOKEN, { + reason: 'Unknown token validation error', + error: error instanceof Error ? error.message : 'Unknown error' + }); + } + } + + /** + * Generate a secure nonce for additional CSRF protection + */ + static generateNonce(): string { + return crypto.randomBytes(32).toString('hex'); + } + + /** + * Validate a nonce matches the expected value + */ + static validateNonce(received: string, expected: string): boolean { + if (!received || !expected) { + return false; + } + + // Use crypto.timingSafeEqual to prevent timing attacks + const receivedBuffer = Buffer.from(received, 'hex'); + const expectedBuffer = Buffer.from(expected, 'hex'); + + if (receivedBuffer.length !== expectedBuffer.length) { + return false; + } + + return crypto.timingSafeEqual(receivedBuffer, expectedBuffer); + } +} + +export default SSOStateTokenManager; \ No newline at end of file diff --git a/Servers/auth/utils/SSOValidation.ts b/Servers/auth/utils/SSOValidation.ts new file mode 100644 index 000000000..179998c1c --- /dev/null +++ b/Servers/auth/utils/SSOValidation.ts @@ -0,0 +1,351 @@ +import { ValidationResult, ValidationError } from '../interfaces/ISSOProvider'; +import { SSOError, SSOErrorCode, createSSOError } from './SSOErrors'; + +export class SSOValidation { + /** + * Validate organization ID parameter + */ + static validateOrganizationId(organizationId: any): number { + if (!organizationId) { + throw createSSOError(SSOErrorCode.INVALID_ORGANIZATION_ACCESS, { + reason: 'Missing organization ID' + }); + } + + const parsedId = parseInt(organizationId, 10); + if (isNaN(parsedId) || parsedId <= 0) { + throw createSSOError(SSOErrorCode.INVALID_ORGANIZATION_ACCESS, { + reason: 'Invalid organization ID format', + received: organizationId + }); + } + + return parsedId; + } + + /** + * Validate provider ID parameter + */ + static validateProviderId(providerId: any): number { + if (!providerId) { + throw createSSOError(SSOErrorCode.INVALID_CONFIGURATION, { + reason: 'Missing provider ID' + }); + } + + const parsedId = parseInt(providerId, 10); + if (isNaN(parsedId) || parsedId <= 0) { + throw createSSOError(SSOErrorCode.INVALID_CONFIGURATION, { + reason: 'Invalid provider ID format', + received: providerId + }); + } + + return parsedId; + } + + /** + * Validate email format and domain + */ + static validateEmail(email: string): ValidationResult { + const errors: ValidationError[] = []; + + if (!email) { + errors.push({ + field: 'email', + message: 'Email is required', + code: 'EMAIL_REQUIRED' + }); + return { isValid: false, errors }; + } + + // Basic email format validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + errors.push({ + field: 'email', + message: 'Invalid email format', + code: 'INVALID_EMAIL_FORMAT' + }); + } + + // Check for suspicious patterns + if (email.length > 320) { // RFC 5321 limit + errors.push({ + field: 'email', + message: 'Email address is too long', + code: 'EMAIL_TOO_LONG' + }); + } + + if (email.includes('..')) { + errors.push({ + field: 'email', + message: 'Email contains consecutive dots', + code: 'INVALID_EMAIL_PATTERN' + }); + } + + return { isValid: errors.length === 0, errors }; + } + + /** + * Validate email domain against allowlist + */ + static validateEmailDomain(email: string, allowedDomains: string[]): boolean { + if (!allowedDomains || allowedDomains.length === 0) { + return true; // No restrictions + } + + const domain = email.split('@')[1]?.toLowerCase(); + if (!domain) { + return false; + } + + return allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase().trim(); + + // Support wildcard subdomains (e.g., *.company.com) + if (normalizedAllowed.startsWith('*.')) { + const baseDomain = normalizedAllowed.substring(2); + return domain === baseDomain || domain.endsWith('.' + baseDomain); + } + + return domain === normalizedAllowed; + }); + } + + /** + * Validate user name fields + */ + static validateUserName(name: string, fieldName: string): ValidationResult { + const errors: ValidationError[] = []; + + if (!name || name.trim().length === 0) { + errors.push({ + field: fieldName, + message: `${fieldName} is required`, + code: 'NAME_REQUIRED' + }); + return { isValid: false, errors }; + } + + const trimmedName = name.trim(); + + if (trimmedName.length < 1) { + errors.push({ + field: fieldName, + message: `${fieldName} must be at least 1 character long`, + code: 'NAME_TOO_SHORT' + }); + } + + if (trimmedName.length > 100) { + errors.push({ + field: fieldName, + message: `${fieldName} must be less than 100 characters`, + code: 'NAME_TOO_LONG' + }); + } + + // Check for suspicious patterns + const suspiciousPattern = /[<>{}[\]\\\/\x00-\x1f\x7f]/; + if (suspiciousPattern.test(trimmedName)) { + errors.push({ + field: fieldName, + message: `${fieldName} contains invalid characters`, + code: 'INVALID_NAME_CHARACTERS' + }); + } + + return { isValid: errors.length === 0, errors }; + } + + /** + * Validate authorization code from SSO provider + */ + static validateAuthCode(code: any): string { + if (!code) { + throw createSSOError(SSOErrorCode.INVALID_AUTH_CODE, { + reason: 'Missing authorization code' + }); + } + + if (typeof code !== 'string') { + throw createSSOError(SSOErrorCode.INVALID_AUTH_CODE, { + reason: 'Authorization code must be a string', + received: typeof code + }); + } + + const trimmedCode = code.trim(); + if (trimmedCode.length === 0) { + throw createSSOError(SSOErrorCode.INVALID_AUTH_CODE, { + reason: 'Empty authorization code' + }); + } + + // Basic sanity checks for authorization code + if (trimmedCode.length < 10 || trimmedCode.length > 2000) { + throw createSSOError(SSOErrorCode.INVALID_AUTH_CODE, { + reason: 'Authorization code length is suspicious', + length: trimmedCode.length + }); + } + + return trimmedCode; + } + + /** + * Validate Azure AD configuration + */ + static validateAzureAdConfiguration(config: Record): ValidationResult { + const errors: ValidationError[] = []; + + // Required fields + const requiredFields = [ + 'azure_client_id', + 'azure_client_secret', + 'azure_tenant_id' + ]; + + for (const field of requiredFields) { + if (!config[field] || typeof config[field] !== 'string' || config[field].trim().length === 0) { + errors.push({ + field, + message: `${field} is required and must be a non-empty string`, + code: 'REQUIRED_FIELD_MISSING' + }); + } + } + + // Validate Azure tenant ID format (GUID) + if (config.azure_tenant_id) { + const guidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (!guidRegex.test(config.azure_tenant_id)) { + errors.push({ + field: 'azure_tenant_id', + message: 'Azure tenant ID must be a valid GUID format', + code: 'INVALID_TENANT_ID_FORMAT' + }); + } + } + + // Validate Azure client ID format (GUID) + if (config.azure_client_id) { + const guidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (!guidRegex.test(config.azure_client_id)) { + errors.push({ + field: 'azure_client_id', + message: 'Azure client ID must be a valid GUID format', + code: 'INVALID_CLIENT_ID_FORMAT' + }); + } + } + + // Validate cloud environment + if (config.azure_cloud_environment) { + const validEnvironments = ['public', 'government', 'china', 'germany']; + if (!validEnvironments.includes(config.azure_cloud_environment)) { + errors.push({ + field: 'azure_cloud_environment', + message: 'Invalid Azure cloud environment', + code: 'INVALID_CLOUD_ENVIRONMENT' + }); + } + } + + return { isValid: errors.length === 0, errors }; + } + + /** + * Sanitize display name from SSO provider + */ + static sanitizeDisplayName(name: string): string { + if (!name) return ''; + + return name + .trim() + .replace(/[<>{}[\]\\\/\x00-\x1f\x7f]/g, '') // Remove dangerous characters + .substring(0, 100); // Limit length + } + + /** + * Validate role ID + */ + static validateRoleId(roleId: any): number { + if (roleId === undefined || roleId === null) { + throw createSSOError(SSOErrorCode.INVALID_CONFIGURATION, { + reason: 'Role ID is required' + }); + } + + const parsedRoleId = parseInt(roleId, 10); + if (isNaN(parsedRoleId) || parsedRoleId <= 0) { + throw createSSOError(SSOErrorCode.INVALID_CONFIGURATION, { + reason: 'Invalid role ID format', + received: roleId + }); + } + + return parsedRoleId; + } + + /** + * Validate allowed domains list + */ + static validateAllowedDomains(domains: any): ValidationResult { + const errors: ValidationError[] = []; + + if (domains === null || domains === undefined) { + return { isValid: true, errors }; // Null/undefined is valid (no restrictions) + } + + if (!Array.isArray(domains)) { + errors.push({ + field: 'allowed_domains', + message: 'Allowed domains must be an array', + code: 'INVALID_DOMAINS_FORMAT' + }); + return { isValid: false, errors }; + } + + for (let i = 0; i < domains.length; i++) { + const domain = domains[i]; + + if (typeof domain !== 'string') { + errors.push({ + field: `allowed_domains[${i}]`, + message: 'Domain must be a string', + code: 'INVALID_DOMAIN_TYPE' + }); + continue; + } + + const trimmedDomain = domain.trim().toLowerCase(); + + if (trimmedDomain.length === 0) { + errors.push({ + field: `allowed_domains[${i}]`, + message: 'Domain cannot be empty', + code: 'EMPTY_DOMAIN' + }); + continue; + } + + // Basic domain format validation + const domainRegex = /^(\*\.)?[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; + if (!domainRegex.test(trimmedDomain)) { + errors.push({ + field: `allowed_domains[${i}]`, + message: 'Invalid domain format', + code: 'INVALID_DOMAIN_FORMAT' + }); + } + } + + return { isValid: errors.length === 0, errors }; + } +} + +export default SSOValidation; \ No newline at end of file diff --git a/Servers/config/constants.js b/Servers/config/constants.js index 3013869c4..35af38d6e 100644 --- a/Servers/config/constants.js +++ b/Servers/config/constants.js @@ -1,3 +1,3 @@ -const DEFAULT_FRONTEND_URL = "http://localhost:8082"; +const DEFAULT_FRONTEND_URL = "http://localhost:8082,http://localhost:5175,http://localhost:5173,http://localhost:3000"; export const frontEndUrl = process.env.FRONTEND_URL || DEFAULT_FRONTEND_URL; \ No newline at end of file diff --git a/Servers/controllers/ssoAuth.ctrl.ts b/Servers/controllers/ssoAuth.ctrl.ts new file mode 100644 index 000000000..805afc633 --- /dev/null +++ b/Servers/controllers/ssoAuth.ctrl.ts @@ -0,0 +1,1032 @@ +import { Request, Response } from 'express'; +import '../types/express'; +import { ConfidentialClientApplication } from '@azure/msal-node'; +import { SSOConfigurationModel } from '../domain.layer/models/sso/ssoConfiguration.model'; +import { UserModel } from '../domain.layer/models/user/user.model'; +import { OrganizationModel } from '../domain.layer/models/organization/organization.model'; +import { SSOStateTokenManager } from '../utils/sso-state-token.utils'; +import { SSOAuditLogger } from '../utils/sso-audit-logger.utils'; +import { SSOErrorHandler, SSOErrorCodes } from '../utils/sso-error-handler.utils'; +import * as jwt from 'jsonwebtoken'; +import { Op } from 'sequelize'; + +/** + * @fileoverview SSO Authentication Controller for Azure AD (Entra ID) Integration + * + * This controller manages the complete OAuth 2.0 authorization code flow with Azure Active Directory. + * It handles user authentication, organization-specific SSO configurations, and secure token management + * for multi-tenant applications requiring enterprise identity integration. + * + * Key Features: + * - Organization-specific SSO configuration management + * - Secure state token validation (CSRF protection) + * - Azure AD OAuth 2.0 flow implementation + * - Automatic user provisioning and organization mapping + * - Comprehensive audit logging for security monitoring + * - Error handling with detailed security event tracking + * + * Security Considerations: + * - All SSO configurations use encrypted client secrets + * - State tokens provide CSRF protection during OAuth flow + * - Audit logs track all authentication attempts and failures + * - Tenant isolation ensures organization data separation + * + * @author VerifyWise Development Team + * @since 2024-09-28 + * @version 1.0.0 + */ + +// Configuration Constants +const JWT_EXPIRY = process.env.SSO_JWT_EXPIRY || '24h'; +const JWT_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours in milliseconds +const ROLE_MAP = new Map([ + [1, "Admin"], + [2, "Reviewer"], + [3, "Editor"], + [4, "Auditor"] +]); + +/** + * Initiates Azure AD SSO authentication flow for an organization + * + * This function handles the first step of the OAuth 2.0 authorization code flow by: + * 1. Validating the organization's SSO configuration + * 2. Creating a secure MSAL client with encrypted credentials + * 3. Generating CSRF-protected state tokens + * 4. Redirecting users to Azure AD for authentication + * + * @async + * @function initiateSSOLogin + * @param {Request} req - Express request object + * @param {string} req.params.organizationId - Organization ID for SSO configuration lookup + * @param {Response} res - Express response object + * + * @returns {Promise} JSON response with Azure AD authorization URL or error + * + * @throws {404} SSO not configured or enabled for organization + * @throws {500} SSO configuration errors (decryption failures, invalid Azure config) + * + * @security + * - Validates organization-specific SSO configuration + * - Uses encrypted client secrets for Azure AD authentication + * - Generates cryptographically secure state tokens for CSRF protection + * - Logs all authentication attempts for security monitoring + * + * @example + * GET /api/sso-auth/123/login + * Response: { + * "success": true, + * "data": { + * "authUrl": "https://login.microsoftonline.com/tenant-id/oauth2/v2.0/authorize?..." + * } + * } + * + * @see {@link SSOConfigurationModel} For SSO configuration management + * @see {@link SSOStateTokenManager} For secure state token generation + * @see {@link SSOAuditLogger} For security event logging + */ +export const initiateSSOLogin = async (req: Request, res: Response) => { + try { + const { organizationId } = req.params; + + // Step 1: Retrieve and validate organization's SSO configuration + // Only active SSO configurations are considered for authentication + const ssoConfig = await SSOConfigurationModel.findOne({ + where: { + organization_id: organizationId, + is_enabled: true // Ensure SSO is actively enabled + } + }); + + if (!ssoConfig) { + // Log security event for monitoring unauthorized SSO attempts + SSOAuditLogger.logAuthenticationFailure(req, organizationId, 'SSO not configured or enabled'); + return res.status(404).json({ + success: false, + error: 'SSO is not configured or enabled for this organization' + }); + } + + // Step 2: Decrypt client secret for Azure AD authentication + // Client secrets are encrypted at rest for security + const clientSecret = ssoConfig.getDecryptedSecret(); + if (!clientSecret) { + // Critical security failure - unable to access authentication credentials + SSOAuditLogger.logAuthenticationFailure(req, organizationId, 'SSO configuration error - failed to decrypt client secret'); + return res.status(500).json({ + success: false, + error: 'SSO configuration error' + }); + } + + // Step 3: Extract and validate Azure AD configuration parameters + // This includes client_id, tenant_id, and other Azure-specific settings + const azureConfig = ssoConfig.getAzureAdConfig(); + if (!azureConfig) { + SSOAuditLogger.logAuthenticationFailure(req, organizationId, 'Invalid Azure AD configuration'); + return res.status(500).json({ + success: false, + error: 'Invalid SSO configuration' + }); + } + + // Step 4: Configure Microsoft Authentication Library (MSAL) client + // MSAL handles the OAuth 2.0 flow with Azure AD + const msalConfig = { + auth: { + clientId: azureConfig.client_id, // Azure AD application ID + clientSecret: clientSecret, // Decrypted client secret + authority: `${ssoConfig.getAzureADBaseUrl()}/${azureConfig.tenant_id}` // Tenant-specific authority URL + } + }; + + const cca = new ConfidentialClientApplication(msalConfig); + + // Step 5: Generate secure state token for CSRF protection + // State token links the request with the callback to prevent CSRF attacks + const secureState = SSOStateTokenManager.generateStateToken(organizationId); + + // Step 6: Configure OAuth 2.0 authorization parameters + const authCodeUrlParameters = { + scopes: ['openid', 'profile', 'email'], // Minimal required scopes for user identification + redirectUri: `${process.env.BACKEND_URL || 'http://localhost:3000'}/api/sso-auth/${organizationId}/callback`, + state: secureState // CSRF protection token + }; + + try { + // Step 7: Generate Azure AD authorization URL + // This URL redirects users to Azure AD for authentication + const authUrl = await cca.getAuthCodeUrl(authCodeUrlParameters); + + // Step 8: Log successful SSO initiation for security monitoring + SSOAuditLogger.logLoginInitiation(req, organizationId, true); + + return res.json({ + success: true, + authUrl: authUrl + }); + } catch (msalError) { + console.error('MSAL error getting auth URL:', msalError); + SSOAuditLogger.logLoginInitiation(req, organizationId, false, 'MSAL error generating authorization URL'); + return res.status(500).json({ + success: false, + error: 'Failed to generate authorization URL' + }); + } + } catch (error) { + console.error('Error initiating SSO login:', error); + const { organizationId } = req.params; + SSOAuditLogger.logLoginInitiation(req, organizationId, false, 'Unexpected error during login initiation'); + return res.status(500).json({ + success: false, + error: 'Failed to initiate SSO login' + }); + } +}; + +/** + * Handles Azure AD OAuth callback and completes user authentication + * + * This function processes the OAuth 2.0 authorization code flow callback from Azure AD: + * 1. Validates CSRF state tokens for security + * 2. Exchanges authorization code for access tokens + * 3. Extracts user information from Azure AD response + * 4. Creates or updates user accounts with SSO information + * 5. Generates application JWT tokens for session management + * + * @async + * @function handleSSOCallback + * @param {Request} req - Express request object + * @param {string} req.params.organizationId - Organization ID from URL + * @param {string} req.query.code - Authorization code from Azure AD + * @param {string} req.query.state - State token for CSRF protection + * @param {string} req.query.error - Optional error from Azure AD + * @param {Response} res - Express response object + * + * @returns {Promise} Redirect to frontend with authentication result + * + * @security + * - Validates state tokens to prevent CSRF attacks + * - Uses encrypted client secrets for token exchange + * - Logs all authentication attempts for security monitoring + * - Handles Azure AD errors gracefully with appropriate redirects + * + * @example + * GET /api/sso-auth/123/callback?code=auth_code&state=secure_token + * Redirects to: http://frontend.com/dashboard (success) + * Redirects to: http://frontend.com/login?error=sso_failed (failure) + * + * @see {@link SSOStateTokenManager.validateStateToken} For CSRF protection validation + * @see {@link ConfidentialClientApplication.acquireTokenByCode} For token exchange + */ +export const handleSSOCallback = async (req: Request, res: Response) => { + try { + const { organizationId } = req.params; + const { code, state, error: authError } = req.query; + + // Step 1: Check for Azure AD authentication errors + // Azure AD redirects here with error parameter if authentication fails + if (authError) { + console.error('Azure AD authentication error:', authError); + return res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:3001'}/login?error=sso_failed`); + } + + // Step 2: Validate CSRF state token for security + // This prevents cross-site request forgery attacks during OAuth flow + try { + const validatedState = SSOStateTokenManager.validateStateToken(state as string, organizationId); + // SECURITY: Only log sensitive information in development environment + if (process.env.NODE_ENV !== 'production') { + console.log(`Valid state token for organization ${organizationId}, nonce: ${validatedState.nonce}`); + } + } catch (error) { + // SECURITY: Limit error exposure in production for security + if (process.env.NODE_ENV !== 'production') { + console.error('State token validation failed:', error); + } else { + console.error('State token validation failed for organization:', organizationId); + } + return res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:3001'}/login?error=invalid_state`); + } + + // Step 3: Validate authorization code presence + // Authorization code is required to exchange for access tokens + if (!code) { + return res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:3001'}/login?error=no_auth_code`); + } + + // Step 4: Retrieve SSO configuration for token exchange + // Same organization validation as in initiation step + const ssoConfig = await SSOConfigurationModel.findOne({ + where: { + organization_id: organizationId, + is_enabled: true + } + }); + + if (!ssoConfig) { + return res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:3001'}/login?error=sso_not_configured`); + } + + // Step 5: Decrypt client secret for Azure AD token exchange + const clientSecret = ssoConfig.getDecryptedSecret(); + if (!clientSecret) { + return res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:3001'}/login?error=sso_config_error`); + } + + // Step 6: Extract Azure AD configuration for MSAL client + const azureConfig = ssoConfig.getAzureAdConfig(); + if (!azureConfig) { + return res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:3001'}/login?error=sso_config_error`); + } + + // Create MSAL client configuration + const msalConfig = { + auth: { + clientId: azureConfig.client_id, + clientSecret: clientSecret, + authority: `${ssoConfig.getAzureADBaseUrl()}/${azureConfig.tenant_id}` + } + }; + + const cca = new ConfidentialClientApplication(msalConfig); + + // Token request configuration + const tokenRequest = { + code: code as string, + scopes: ['openid', 'profile', 'email'], + redirectUri: `${process.env.BACKEND_URL || 'http://localhost:3000'}/api/sso-auth/${organizationId}/callback` + }; + + try { + // Exchange authorization code for tokens + const response = await cca.acquireTokenByCode(tokenRequest); + + if (!response) { + return res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:3001'}/login?error=token_exchange_failed`); + } + + // Extract user information from the token + const userInfo = response.account; + if (!userInfo || !userInfo.username) { + return res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:3001'}/login?error=no_user_info`); + } + + // Extract and validate user information from Azure AD + const email = userInfo.username; // In Azure AD, username is typically the email + + // SECURITY: Validate email format before using + if (!email || typeof email !== 'string' || !email.includes('@') || email.length > 320) { + console.error('Invalid email received from Azure AD:', { email: email ? '[REDACTED]' : 'null' }); + return res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:3001'}/login?error=invalid_email`); + } + + // SECURITY: Safely extract Azure Object ID with validation + let azureObjectId = null; + if (userInfo.homeAccountId && typeof userInfo.homeAccountId === 'string') { + const accountParts = userInfo.homeAccountId.split('.'); + if (accountParts.length >= 1 && accountParts[0].length > 0) { + // Validate object ID format (should be GUID-like) + const objectIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (objectIdPattern.test(accountParts[0])) { + azureObjectId = accountParts[0]; + } + } + } + + // Fall back to localAccountId if homeAccountId is invalid + if (!azureObjectId && userInfo.localAccountId && typeof userInfo.localAccountId === 'string') { + const objectIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (objectIdPattern.test(userInfo.localAccountId)) { + azureObjectId = userInfo.localAccountId; + } + } + + // Use email as fallback identifier if Azure Object ID is invalid + if (!azureObjectId) { + console.warn('No valid Azure Object ID found, using email as identifier for user:', email.split('@')[0] + '@[DOMAIN]'); + azureObjectId = email; // Fallback to email + } + + // Validate that the organization exists and is active + const organization = await OrganizationModel.findByPk(organizationId); + if (!organization) { + return res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:3001'}/login?error=invalid_organization`); + } + + // Find or create user in our system + let user = await UserModel.findOne({ + where: { + email: email, + organization_id: organizationId + } + }); + + if (!user) { + // SECURITY: Validate email domain against organization's allowlist + if (!ssoConfig.isEmailDomainAllowed(email)) { + console.warn(`SSO login denied: Email domain not allowed for ${email} in organization ${organizationId}`); + SSOAuditLogger.logDomainValidationFailure(req, organizationId, email, ssoConfig.allowed_domains || []); + return res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:3001'}/login?error=email_domain_not_allowed`); + } + + // SECURITY: Safely extract and validate name information from Azure AD + let firstName = 'Unknown'; + let lastName = 'User'; + + if (userInfo.name && typeof userInfo.name === 'string' && userInfo.name.trim().length > 0) { + const nameParts = userInfo.name.trim().split(' '); + + // Sanitize first name + if (nameParts[0] && nameParts[0].length > 0) { + firstName = nameParts[0].replace(/[<>{}[\]\\\/\x00-\x1f\x7f]/g, '').substring(0, 50); + } + + // Sanitize last name + if (nameParts.length > 1) { + const lastNamePart = nameParts.slice(1).join(' '); + if (lastNamePart.length > 0) { + lastName = lastNamePart.replace(/[<>{}[\]\\\/\x00-\x1f\x7f]/g, '').substring(0, 50); + } + } + } + + // Ensure we have valid names + if (!firstName || firstName.trim().length === 0) firstName = 'Unknown'; + if (!lastName || lastName.trim().length === 0) lastName = 'User'; + + if (firstName === 'Unknown' && lastName === 'User') { + console.warn(`SSO user ${email.split('@')[0]}@[DOMAIN] has minimal profile information from Azure AD`); + } + + // SECURITY: Use organization-configured default role instead of hardcoded value + const defaultRoleId = ssoConfig.getDefaultRoleId(); + + // Create new user with validation + try { + user = await UserModel.create({ + email: email, + name: firstName, + surname: lastName, + organization_id: parseInt(organizationId), + role_id: defaultRoleId, // Use configured default role from SSO settings + sso_enabled: true, + azure_ad_object_id: azureObjectId, + sso_last_login: new Date(), + password_hash: 'SSO_USER', // Placeholder since SSO users don't have passwords + is_demo: false + } as any); + + // SECURITY: Mask email in production logs + const maskedEmail = process.env.NODE_ENV === 'production' + ? email.split('@')[0] + '@[DOMAIN]' + : email; + console.log(`Created new SSO user: ${maskedEmail} for organization ${organizationId}`); + } catch (createError) { + console.error('Failed to create SSO user:', createError); + return res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:3001'}/login?error=user_creation_failed`); + } + } else { + // Update existing user's SSO fields and last login + await user.update({ + sso_enabled: true, + azure_ad_object_id: azureObjectId, + sso_last_login: new Date() + }); + + // SECURITY: Mask email in production logs + const maskedEmail = process.env.NODE_ENV === 'production' + ? email.split('@')[0] + '@[DOMAIN]' + : email; + console.log(`Updated SSO login for existing user: ${maskedEmail}`); + } + + // Generate JWT token for our application + const jwtPayload = { + userId: user.id, + email: user.email, + organizationId: user.organization_id, + role: ROLE_MAP.get(user.role_id!) || "Reviewer", // Default to Reviewer if unknown role + ssoEnabled: true + }; + + const token = jwt.sign(jwtPayload, process.env.JWT_SECRET as string, { expiresIn: JWT_EXPIRY }); + + // Set secure httpOnly cookie instead of URL parameter for security + const isProduction = process.env.NODE_ENV === 'production'; + res.cookie('auth_token', token, { + httpOnly: true, + secure: isProduction, // HTTPS in production + sameSite: 'lax', + maxAge: JWT_EXPIRY_MS, // Use configurable expiry + domain: isProduction ? process.env.COOKIE_DOMAIN : undefined + }); + + // Redirect to dashboard without token in URL + return res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:3001'}/dashboard?sso=success`); + + } catch (msalError) { + // Use enhanced MSAL error handling + const { userMessage, errorCode, shouldRedirect } = SSOErrorHandler.handleMSALError(msalError, 'token exchange'); + + SSOErrorHandler.logSecurityEvent('sso_token_exchange', false, { + organizationId, + ipAddress: req.ip, + userAgent: req.get('user-agent'), + error: errorCode + }); + + const redirectUrl = SSOErrorHandler.createErrorRedirectUrl( + process.env.FRONTEND_URL || 'http://localhost:3001', + errorCode, + userMessage + ); + + return res.redirect(redirectUrl); + } + } catch (error) { + const { organizationId } = req.params; + + // Enhanced error logging and handling + SSOErrorHandler.logSecurityEvent('sso_callback', false, { + organizationId, + ipAddress: req.ip, + userAgent: req.get('user-agent'), + error: (error as Error)?.message || 'Unknown callback error' + }); + + // Handle database connection errors specifically + if ((error as any)?.name?.includes('Sequelize')) { + const redirectUrl = SSOErrorHandler.createErrorRedirectUrl( + process.env.FRONTEND_URL || 'http://localhost:3001', + SSOErrorCodes.DATABASE_ERROR, + 'Service temporarily unavailable. Please try again later.' + ); + return res.redirect(redirectUrl); + } + + // Generic internal error + const redirectUrl = SSOErrorHandler.createErrorRedirectUrl( + process.env.FRONTEND_URL || 'http://localhost:3001', + SSOErrorCodes.INTERNAL_ERROR, + 'Authentication failed. Please try again.' + ); + + return res.redirect(redirectUrl); + } +}; + +/** + * Get SSO login URL for organization + */ +export const getSSOLoginUrl = async (req: Request, res: Response) => { + try { + const { organizationId } = req.params; + + // Check if SSO is enabled for this organization + const ssoConfig = await SSOConfigurationModel.findOne({ + where: { + organization_id: organizationId, + is_enabled: true + } + }); + + if (!ssoConfig) { + return res.status(404).json({ + success: false, + error: 'SSO is not enabled for this organization' + }); + } + + return res.json({ + success: true, + data: { + ssoEnabled: true, + loginUrl: `/api/sso-auth/${organizationId}/login` + } + }); + } catch (error) { + console.error('Error getting SSO login URL:', error); + return res.status(500).json({ + success: false, + error: 'Failed to get SSO login URL' + }); + } +}; + +/** + * Check SSO availability for organization ID or domain + * GET /api/sso-auth/check-availability?organizationId={id}&domain={domain} + */ +export const checkSSOAvailability = async (req: Request, res: Response) => { + try { + const { organizationId, domain } = req.query; + + // Input validation + if (!organizationId && !domain) { + return res.status(400).json({ + available: false, + error: 'Either organizationId or domain parameter is required' + }); + } + + let organization = null; + + // Find organization by ID or domain + if (organizationId) { + // Validate organization ID format + if (!/^\d+$/.test(organizationId as string)) { + return res.status(400).json({ + available: false, + error: 'Invalid organization ID format' + }); + } + + organization = await OrganizationModel.findOne({ + where: { id: parseInt(organizationId as string) } + }); + } else if (domain) { + // Validate domain format + const domainRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; + if (!domainRegex.test(domain as string)) { + return res.status(400).json({ + available: false, + error: 'Invalid domain format' + }); + } + + // Find organization by domain in their email domains + // This assumes organizations have associated email domains + // You may need to adjust this query based on your organization model + organization = await OrganizationModel.findOne({ + where: { + // This might need adjustment based on how domains are stored + // For now, we'll use a simple name-based lookup + name: { + [Op.iLike]: `%${domain}%` + } + } + }); + } + + if (!organization) { + return res.status(404).json({ + available: false, + error: 'Organization not found' + }); + } + + // Check if organization has SSO enabled + const ssoConfig = await SSOConfigurationModel.findOne({ + where: { + organization_id: organization.id, + is_enabled: true + } + }); + + if (!ssoConfig) { + return res.status(200).json({ + available: false, + organizationId: organization.id, + organizationName: organization.name, + message: 'SSO not configured for this organization' + }); + } + + // Return SSO availability info + return res.status(200).json({ + available: true, + organizationId: organization.id, + organizationName: organization.name, + providerType: 'azure_ad', + loginUrl: `/api/sso-auth/${organization.id}/login` + }); + + } catch (error) { + console.error('Error checking SSO availability:', error); + return res.status(500).json({ + available: false, + error: 'Failed to check SSO availability' + }); + } +}; + +/** + * Get organization SSO configuration details + * GET /api/sso-auth/:organizationId/config + */ +/** + * Check user's organization and SSO availability by email + * GET /api/sso-auth/check-user-organization?email={email} + */ +export const checkUserOrganization = async (req: Request, res: Response) => { + try { + const { email } = req.query; + + // Enhanced email validation + if (!email || typeof email !== 'string') { + return SSOErrorHandler.handleValidationError( + res, + ['Email parameter is required'], + 'Email is required' + ); + } + + // Basic email format validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email) || email.length > 320) { + return SSOErrorHandler.handleValidationError( + res, + ['Invalid email format or email too long (max 320 characters)'], + 'Invalid email format' + ); + } + + // Find user by email + const user = await UserModel.findOne({ + where: { email: email.toLowerCase().trim() }, + attributes: ['id', 'email', 'organization_id', 'sso_enabled'] + }); + + if (!user) { + // For new users, try to find an organization by email domain + // that has SSO enabled and allows this email domain + const emailDomain = email.split('@')[1]?.toLowerCase(); + + if (emailDomain) { + // Find organizations with SSO configurations that allow this email domain + const ssoConfigs = await SSOConfigurationModel.findAll({ + where: { + is_enabled: true + }, + include: [{ + model: OrganizationModel, + as: 'organization', + attributes: ['id', 'name'] + }], + attributes: ['organization_id', 'allowed_domains', 'auth_method_policy', 'azure_tenant_id', 'azure_client_id', 'cloud_environment'] + }); + + // Check if any SSO configuration allows this email domain + for (const ssoConfig of ssoConfigs) { + if (ssoConfig.isEmailDomainAllowed(email)) { + const organization = (ssoConfig as any).organization; + return res.status(200).json({ + success: true, + data: { + userExists: false, + hasOrganization: true, + ssoAvailable: true, + canCreateUser: true, + organization: { + id: organization.id, + name: organization.name + }, + sso: { + tenantId: ssoConfig.getAzureAdConfig()?.tenant_id || null, + loginUrl: `/api/sso-auth/${organization.id}/login` + }, + authMethodPolicy: ssoConfig.auth_method_policy, + preferredAuthMethod: 'sso', + message: 'New user can be created via SSO for this organization' + } + }); + } + } + } + + return res.status(200).json({ + success: true, + data: { + userExists: false, + hasOrganization: false, + ssoAvailable: false, + authMethodPolicy: 'both', + message: 'User not found and email domain not allowed for any SSO organization' + } + }); + } + + // Check if user has an organization + if (!user.organization_id) { + return res.status(200).json({ + success: true, + data: { + userExists: true, + hasOrganization: false, + ssoAvailable: false, + authMethodPolicy: 'both', + message: 'User not associated with any organization' + } + }); + } + + // Fetch organization and SSO configuration for existing user + const organization = await OrganizationModel.findByPk(user.organization_id, { + attributes: ['id', 'name'] + }); + + if (!organization) { + return res.status(200).json({ + success: true, + data: { + userExists: true, + hasOrganization: false, + ssoAvailable: false, + authMethodPolicy: 'both', + message: 'User organization not found' + } + }); + } + + const ssoConfig = await SSOConfigurationModel.findOne({ + where: { + organization_id: user.organization_id, + is_enabled: true + }, + attributes: ['azure_tenant_id', 'azure_client_id', 'cloud_environment', 'is_enabled', 'auth_method_policy'] + }); + + const ssoAvailable = !!(ssoConfig && ssoConfig.is_enabled); + + res.status(200).json({ + success: true, + data: { + userExists: true, + hasOrganization: true, + ssoAvailable, + organization: { + id: organization.id, + name: organization.name + }, + sso: ssoAvailable ? { + tenantId: ssoConfig.getAzureAdConfig()?.tenant_id || null, + loginUrl: `/api/sso-auth/${organization.id}/login` + } : null, + authMethodPolicy: ssoConfig ? ssoConfig.auth_method_policy : 'both', + preferredAuthMethod: ssoAvailable && user.sso_enabled ? 'sso' : 'password' + } + }); + + } catch (error) { + // Use enhanced error handling for database operations + return SSOErrorHandler.handleDatabaseError( + res, + error, + 'user organization lookup' + ); + } +}; + +/** + * Get available SSO providers for login page + * GET /api/sso-auth/available-providers + */ +export const getAvailableSSOProviders = async (req: Request, res: Response) => { + try { + // Find all organizations with enabled SSO configurations + const ssoConfigs = await SSOConfigurationModel.findAll({ + where: { + is_enabled: true + }, + include: [{ + model: OrganizationModel, + as: 'organization', + attributes: ['id', 'name'] + }], + attributes: ['organization_id', 'auth_method_policy'] + }); + + // Group by provider type and return available providers + const providers = ssoConfigs.map(config => ({ + organizationId: config.organization_id, + organizationName: (config as any).organization?.name || 'Unknown', + providerType: 'azure_ad', + authMethodPolicy: config.auth_method_policy, + loginUrl: `/api/sso-auth/${config.organization_id}/login` + })); + + res.status(200).json({ + success: true, + providers, + hasAvailableSSO: providers.length > 0 + }); + + } catch (error) { + console.error('Error getting available SSO providers:', error); + res.status(500).json({ + success: false, + error: 'Failed to get available SSO providers' + }); + } +}; + +/** + * Discover organization for new user by email domain + * GET /api/sso-auth/discover-organization?email={email} + */ +export const discoverOrganizationForNewUser = async (req: Request, res: Response) => { + try { + const { email } = req.query; + + if (!email || typeof email !== 'string') { + return res.status(400).json({ + success: false, + error: 'Email is required' + }); + } + + // Validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return res.status(400).json({ + success: false, + error: 'Invalid email format' + }); + } + + const emailDomain = email.split('@')[1]?.toLowerCase(); + if (!emailDomain) { + return res.status(400).json({ + success: false, + error: 'Could not extract domain from email' + }); + } + + // Find organizations with SSO configurations that allow this email domain + const ssoConfigs = await SSOConfigurationModel.findAll({ + where: { + is_enabled: true + }, + include: [{ + model: OrganizationModel, + as: 'organization', + attributes: ['id', 'name'] + }], + attributes: ['organization_id', 'allowed_domains', 'auth_method_policy', 'provider_config'] + }); + + const matchingOrganizations = []; + + // Check if any SSO configuration allows this email domain + for (const ssoConfig of ssoConfigs) { + if (ssoConfig.isEmailDomainAllowed(email)) { + const organization = (ssoConfig as any).organization; + const azureConfig = ssoConfig.getAzureAdConfig(); + + matchingOrganizations.push({ + organizationId: organization.id, + organizationName: organization.name, + authMethodPolicy: ssoConfig.auth_method_policy, + ssoLoginUrl: `/api/sso-auth/${organization.id}/login`, + tenantId: azureConfig?.tenant_id || null, + allowedDomains: ssoConfig.allowed_domains + }); + } + } + + if (matchingOrganizations.length === 0) { + return res.status(200).json({ + success: true, + data: { + found: false, + emailDomain, + message: 'No organizations found that allow this email domain for SSO' + } + }); + } + + // If multiple organizations match, return all of them + // Frontend can present options to user or use business logic to select + return res.status(200).json({ + success: true, + data: { + found: true, + emailDomain, + organizations: matchingOrganizations, + recommendedAction: matchingOrganizations.length === 1 + ? 'auto_redirect' + : 'show_selection', + message: matchingOrganizations.length === 1 + ? `Found organization: ${matchingOrganizations[0].organizationName}` + : `Found ${matchingOrganizations.length} organizations that allow this email domain` + } + }); + + } catch (error) { + console.error('Error discovering organization for new user:', error); + return res.status(500).json({ + success: false, + error: 'Failed to discover organization for email' + }); + } +}; + +/** + * Get organization SSO configuration details + * GET /api/sso-auth/:organizationId/config + */ +export const getOrganizationSSOConfig = async (req: Request, res: Response) => { + try { + const { organizationId } = req.params; + + // Validate organization ID + if (!/^\d+$/.test(organizationId)) { + return res.status(400).json({ + success: false, + error: 'Invalid organization ID format' + }); + } + + // Find organization + const organization = await OrganizationModel.findOne({ + where: { id: parseInt(organizationId) } + }); + + if (!organization) { + return res.status(404).json({ + success: false, + error: 'Organization not found' + }); + } + + // Get SSO configuration (Azure AD provider) + const ssoConfig = await SSOConfigurationModel.findOne({ + where: { + organization_id: parseInt(organizationId), + + } + }); + + if (!ssoConfig) { + return res.status(404).json({ + success: false, + error: 'SSO configuration not found for this organization' + }); + } + + // Return safe configuration data (no secrets) + return res.status(200).json({ + success: true, + config: { + organizationId: ssoConfig.organization_id, + isEnabled: ssoConfig.is_enabled, + provider: 'azure_ad', + cloudEnvironment: ssoConfig.getAzureAdConfig()?.cloud_environment || 'AzurePublic', + allowedDomains: ssoConfig.allowed_domains || [], + defaultRoleId: ssoConfig.default_role_id || 2, + loginUrl: ssoConfig.is_enabled ? `/api/sso-auth/${organizationId}/login` : null, + createdAt: ssoConfig.created_at, + updatedAt: ssoConfig.updated_at, + organizationName: organization.name + } + }); + + } catch (error) { + console.error('Error getting organization SSO config:', error); + return res.status(500).json({ + success: false, + error: 'Failed to get SSO configuration' + }); + } +}; \ No newline at end of file diff --git a/Servers/controllers/ssoConfiguration.ctrl.ts b/Servers/controllers/ssoConfiguration.ctrl.ts new file mode 100644 index 000000000..09011b15b --- /dev/null +++ b/Servers/controllers/ssoConfiguration.ctrl.ts @@ -0,0 +1,830 @@ +/** + * @fileoverview SSO Configuration Management Controller + * + * This controller handles comprehensive CRUD operations for Azure AD Single Sign-On + * configurations, providing secure organization-scoped configuration management, + * validation, testing, and administrative controls. + * + * Key Features: + * - Complete CRUD operations for SSO configurations + * - Administrative access control with organization isolation + * - Comprehensive validation and testing endpoints + * - Secure client secret handling with encryption + * - Transaction-based operations for data consistency + * - Enhanced error handling with detailed feedback + * + * Security Features: + * - Organization-scoped access control + * - Admin-only operations for configuration changes + * - Client secret encryption and secure storage + * - Comprehensive input validation + * - Database transaction rollback on errors + * + * @author VerifyWise Development Team + * @since 2024-09-28 + * @version 1.0.0 + * @see {@link https://docs.microsoft.com/en-us/azure/active-directory/develop/} Azure AD Documentation + */ + +import { Request, Response } from 'express'; +import '../types/express'; +import { SSOConfigurationModel, IAzureAdConfig } from '../domain.layer/models/sso/ssoConfiguration.model'; +import { decryptSecret } from '../utils/sso-encryption.utils'; +import { SSOErrorHandler, SSOErrorCodes } from '../utils/sso-error-handler.utils'; +import { SSOConfigValidator } from '../utils/sso-config-validator.utils'; +import { sequelize } from '../database/db'; + +/** + * Retrieves Azure AD SSO configuration for an organization + * + * Fetches the complete SSO configuration for the specified organization, + * including Azure AD settings, enabled status, and authentication policies. + * Client secrets are never returned for security reasons. + * + * @async + * @function getSSOConfiguration + * @param {Request} req - Express request object containing organization ID in params + * @param {Response} res - Express response object + * @returns {Promise} JSON response with configuration data or error + * + * @security + * - Requires admin role within the target organization + * - Organization-scoped access control prevents cross-tenant access + * - Client secrets are excluded from response for security + * + * @response_format + * Success: { success: true, data: { exists: true, azure_tenant_id, azure_client_id, ... } } + * Not Found: { success: true, data: { exists: false, is_enabled: false } } + * Error: { success: false, error: string } + * + * @example + * ```typescript + * // GET /api/sso-configuration/123 + * // Returns: + * { + * "success": true, + * "data": { + * "exists": true, + * "azure_tenant_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + * "azure_client_id": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy", + * "cloud_environment": "AzurePublic", + * "is_enabled": true, + * "auth_method_policy": "both" + * } + * } + * ``` + */ +export const getSSOConfiguration = async (req: Request, res: Response) => { + try { + const organizationId = req.params.organizationId; + + // Verify user belongs to organization and is admin + if (req.organizationId !== parseInt(organizationId)) { + return res.status(403).json({ + success: false, + error: 'Access denied to this organization' + }); + } + + if (req.role !== 'Admin') { + return res.status(403).json({ + success: false, + error: 'Admin access required' + }); + } + + // Find SSO configuration for the organization + const ssoConfig = await SSOConfigurationModel.findOne({ + where: { + organization_id: organizationId + } + }); + + if (!ssoConfig) { + return res.json({ + success: true, + data: { + exists: false, + is_enabled: false + } + }); + } + + // Extract Azure AD configuration from provider_config + const azureConfig = ssoConfig.getAzureAdConfig(); + + if (!azureConfig) { + return res.status(500).json({ + success: false, + error: 'Invalid Azure AD configuration' + }); + } + + // Return configuration without client secret + return res.json({ + success: true, + data: { + exists: true, + azure_tenant_id: azureConfig.tenant_id, + azure_client_id: azureConfig.client_id, + cloud_environment: azureConfig.cloud_environment, + is_enabled: ssoConfig.is_enabled, + auth_method_policy: ssoConfig.auth_method_policy, + created_at: ssoConfig.created_at, + updated_at: ssoConfig.updated_at + } + }); + + } catch (error) { + console.error('Error fetching SSO configuration:', error); + return res.status(500).json({ + success: false, + error: 'Failed to fetch SSO configuration' + }); + } +}; + +/** + * Creates or updates Azure AD SSO configuration for an organization + * + * Handles both creation of new SSO configurations and updates to existing ones. + * Performs comprehensive validation, encrypts client secrets, and maintains + * transaction integrity throughout the operation. + * + * @async + * @function createOrUpdateSSOConfiguration + * @param {Request} req - Express request object with organization ID and SSO configuration data + * @param {Response} res - Express response object + * @returns {Promise} JSON response with operation result and configuration data + * + * @request_body + * - azure_tenant_id: string (required, GUID format) + * - azure_client_id: string (required, GUID format) + * - azure_client_secret: string (required, min 10 chars) + * - cloud_environment: 'AzurePublic' | 'AzureGovernment' (default: 'AzurePublic') + * - auth_method_policy: 'sso_only' | 'password_only' | 'both' (default: 'both') + * + * @security + * - Requires admin role within the target organization + * - Comprehensive input validation using SSOConfigValidator + * - Client secret encryption before database storage + * - Database transactions with automatic rollback on errors + * - New configurations are created in disabled state for safety + * + * @validation + * - GUID format validation for Azure AD tenant and client IDs + * - Client secret strength requirements + * - Cloud environment and authentication policy validation + * - Domain validation if allowed_domains are specified + * + * @example + * ```typescript + * // POST /api/sso-configuration/123 + * // Body: + * { + * "azure_tenant_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + * "azure_client_id": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy", + * "azure_client_secret": "secure_client_secret", + * "cloud_environment": "AzurePublic", + * "auth_method_policy": "both" + * } + * + * // Response: + * { + * "success": true, + * "message": "SSO configuration created successfully", + * "data": { + * "azure_tenant_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + * "azure_client_id": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy", + * "cloud_environment": "AzurePublic", + * "is_enabled": false, + * "auth_method_policy": "both" + * } + * } + * ``` + */ +export const createOrUpdateSSOConfiguration = async (req: Request, res: Response) => { + const transaction = await sequelize.transaction(); + + try { + const organizationId = req.params.organizationId; + const { + azure_tenant_id, + azure_client_id, + azure_client_secret, + cloud_environment = 'AzurePublic', + auth_method_policy = 'both' + } = req.body; + + // Verify user belongs to organization and is admin + if (req.organizationId !== parseInt(organizationId)) { + await transaction.rollback(); + return res.status(403).json({ + success: false, + error: 'Access denied to this organization' + }); + } + + if (req.role !== 'Admin') { + await transaction.rollback(); + return res.status(403).json({ + success: false, + error: 'Admin access required' + }); + } + + // Comprehensive validation using the validator utility + const validationResult = await SSOConfigValidator.validateSSOConfiguration({ + azure_tenant_id, + azure_client_id, + azure_client_secret, + cloud_environment, + auth_method_policy + }); + + if (!validationResult.isValid) { + await transaction.rollback(); + return SSOErrorHandler.handleValidationError( + res, + validationResult.errors, + 'Invalid SSO configuration' + ); + } + + // Log warnings if any + if (validationResult.warnings.length > 0) { + console.warn('SSO Configuration Warnings:', { + organizationId, + warnings: validationResult.warnings + }); + } + + // Create Azure AD configuration object + const azureConfig: IAzureAdConfig = { + tenant_id: azure_tenant_id, + client_id: azure_client_id, + client_secret: azure_client_secret, + cloud_environment: cloud_environment as 'AzurePublic' | 'AzureGovernment' + }; + + // Check if configuration exists + const existingConfig = await SSOConfigurationModel.findOne({ + where: { + organization_id: organizationId + }, + transaction + }); + + if (existingConfig) { + // Update existing configuration + existingConfig.azure_tenant_id = azureConfig.tenant_id; + existingConfig.azure_client_id = azureConfig.client_id; + existingConfig.azure_client_secret = azureConfig.client_secret; + existingConfig.cloud_environment = azureConfig.cloud_environment; + existingConfig.auth_method_policy = auth_method_policy; + await existingConfig.save({ transaction }); + + await transaction.commit(); + + const updatedAzureConfig = existingConfig.getAzureAdConfig(); + + return res.json({ + success: true, + message: 'SSO configuration updated successfully', + data: { + azure_tenant_id: updatedAzureConfig?.tenant_id, + azure_client_id: updatedAzureConfig?.client_id, + cloud_environment: updatedAzureConfig?.cloud_environment, + is_enabled: existingConfig.is_enabled, + auth_method_policy: existingConfig.auth_method_policy + } + }); + } else { + // Create new configuration + const newConfig = await SSOConfigurationModel.create({ + organization_id: parseInt(organizationId), + azure_tenant_id: azureConfig.tenant_id, + azure_client_id: azureConfig.client_id, + azure_client_secret: azureConfig.client_secret, + cloud_environment: azureConfig.cloud_environment, + auth_method_policy, + is_enabled: false // Always start with SSO disabled + } as any, { transaction }); + + await transaction.commit(); + + const newAzureConfig = newConfig.getAzureAdConfig(); + + return res.status(201).json({ + success: true, + message: 'SSO configuration created successfully', + data: { + azure_tenant_id: newAzureConfig?.tenant_id, + azure_client_id: newAzureConfig?.client_id, + cloud_environment: newAzureConfig?.cloud_environment, + is_enabled: newConfig.is_enabled, + auth_method_policy: newConfig.auth_method_policy + } + }); + } + + } catch (error) { + await transaction.rollback(); + + // Use enhanced database error handling + return SSOErrorHandler.handleDatabaseError( + res, + error, + 'SSO configuration creation/update' + ); + } +}; + +/** + * Delete SSO configuration + */ +export const deleteSSOConfiguration = async (req: Request, res: Response) => { + const transaction = await sequelize.transaction(); + + try { + const organizationId = req.params.organizationId; + + // Verify user belongs to organization and is admin + if (req.organizationId !== parseInt(organizationId)) { + await transaction.rollback(); + return res.status(403).json({ + success: false, + error: 'Access denied to this organization' + }); + } + + if (req.role !== 'Admin') { + await transaction.rollback(); + return res.status(403).json({ + success: false, + error: 'Admin access required' + }); + } + + // Find and delete configuration + const deletedCount = await SSOConfigurationModel.destroy({ + where: { + organization_id: organizationId + }, + transaction + }); + + await transaction.commit(); + + if (deletedCount === 0) { + return res.status(404).json({ + success: false, + error: 'SSO configuration not found' + }); + } + + return res.json({ + success: true, + message: 'SSO configuration deleted successfully' + }); + + } catch (error) { + await transaction.rollback(); + console.error('Error deleting SSO configuration:', error); + return res.status(500).json({ + success: false, + error: 'Failed to delete SSO configuration' + }); + } +}; + +/** + * Enable SSO for organization + */ +export const enableSSO = async (req: Request, res: Response) => { + try { + const organizationId = req.params.organizationId; + + // Verify user belongs to organization and is admin + if (req.organizationId !== parseInt(organizationId)) { + return res.status(403).json({ + success: false, + error: 'Access denied to this organization' + }); + } + + if (req.role !== 'Admin') { + return res.status(403).json({ + success: false, + error: 'Admin access required' + }); + } + + // Find configuration + const ssoConfig = await SSOConfigurationModel.findOne({ + where: { + organization_id: organizationId + } + }); + + if (!ssoConfig) { + return res.status(404).json({ + success: false, + error: 'SSO configuration not found. Please create configuration first.' + }); + } + + // Validate configuration before enabling + await ssoConfig.validateConfiguration(); + + // Enable SSO + ssoConfig.is_enabled = true; + await ssoConfig.save(); + + const azureConfig = ssoConfig.getAzureAdConfig(); + + return res.json({ + success: true, + message: 'SSO enabled successfully', + data: { + azure_tenant_id: azureConfig?.tenant_id, + cloud_environment: azureConfig?.cloud_environment, + is_enabled: ssoConfig.is_enabled, + auth_method_policy: ssoConfig.auth_method_policy + } + }); + + } catch (error) { + console.error('Error enabling SSO:', error); + + // Handle validation errors + if ((error as any).message && typeof (error as any).message === 'string') { + return res.status(400).json({ + success: false, + error: (error as any).message + }); + } + + return res.status(500).json({ + success: false, + error: 'Failed to enable SSO' + }); + } +}; + +/** + * Validates Azure AD SSO configuration without saving to database + * + * Performs comprehensive validation of SSO configuration parameters + * without persisting the data. Useful for frontend validation feedback + * and pre-save configuration testing. + * + * @async + * @function validateSSOConfiguration + * @param {Request} req - Express request object with organization ID and configuration to validate + * @param {Response} res - Express response object + * @returns {Promise} JSON response with detailed validation results + * + * @endpoint POST /api/sso-configuration/:organizationId/validate + * + * @request_body + * - azure_tenant_id: string (required, GUID format) + * - azure_client_id: string (required, GUID format) + * - azure_client_secret: string (required, strength validation) + * - cloud_environment: 'AzurePublic' | 'AzureGovernment' (default: 'AzurePublic') + * - auth_method_policy: 'sso_only' | 'password_only' | 'both' (default: 'both') + * - allowed_domains: string[] (optional, domain format validation) + * + * @security + * - Requires admin role within the target organization + * - No data persistence - validation only + * - Uses SSOConfigValidator for comprehensive checks + * + * @response_format + * ```typescript + * { + * success: true, + * validation: { + * isValid: boolean, + * errors: string[], + * warnings: string[] + * }, + * message: string + * } + * ``` + * + * @example + * ```typescript + * // POST /api/sso-configuration/123/validate + * // Body: { azure_tenant_id: "invalid-guid", ... } + * // Response: + * { + * "success": true, + * "validation": { + * "isValid": false, + * "errors": ["Azure tenant ID must be a valid GUID format"], + * "warnings": [] + * }, + * "message": "SSO configuration has validation errors" + * } + * ``` + */ +export const validateSSOConfiguration = async (req: Request, res: Response) => { + try { + const organizationId = req.params.organizationId; + const { + azure_tenant_id, + azure_client_id, + azure_client_secret, + cloud_environment = 'AzurePublic', + auth_method_policy = 'both', + allowed_domains + } = req.body; + + // Verify user belongs to organization and is admin + if (req.organizationId !== parseInt(organizationId)) { + return SSOErrorHandler.handleAuthError( + res, + new Error('Access denied'), + 'Access denied to this organization', + 403 + ); + } + + if (req.role !== 'Admin') { + return SSOErrorHandler.handleAuthError( + res, + new Error('Admin required'), + 'Admin access required', + 403 + ); + } + + // Comprehensive validation using the validator utility + const validationResult = await SSOConfigValidator.validateSSOConfiguration({ + azure_tenant_id, + azure_client_id, + azure_client_secret, + cloud_environment, + auth_method_policy, + allowed_domains + }); + + // Return validation results + return res.json({ + success: true, + validation: { + isValid: validationResult.isValid, + errors: validationResult.errors, + warnings: validationResult.warnings + }, + message: validationResult.isValid + ? 'SSO configuration is valid' + : 'SSO configuration has validation errors' + }); + + } catch (error) { + return SSOErrorHandler.handleInternalError( + res, + error, + 'SSO configuration validation' + ); + } +}; + +/** + * Tests Azure AD SSO configuration connectivity and validity + * + * Performs live testing of SSO configuration by attempting to create + * an MSAL client and validate connectivity to Azure AD endpoints. + * Provides immediate feedback on configuration correctness. + * + * @async + * @function testSSOConfiguration + * @param {Request} req - Express request object with organization ID and configuration to test + * @param {Response} res - Express response object + * @returns {Promise} JSON response with test results and connectivity status + * + * @endpoint POST /api/sso-configuration/:organizationId/test + * + * @request_body + * - azure_tenant_id: string (required, GUID format) + * - azure_client_id: string (required, GUID format) + * - azure_client_secret: string (required) + * - cloud_environment: 'AzurePublic' | 'AzureGovernment' (default: 'AzurePublic') + * + * @security + * - Requires admin role within the target organization + * - No data persistence - testing only + * - Uses actual MSAL client creation for validation + * - Enhanced error handling for MSAL-specific issues + * + * @testing_process + * 1. Validates configuration format and requirements + * 2. Constructs appropriate Azure AD authority URL + * 3. Attempts MSAL ConfidentialClientApplication creation + * 4. Verifies client initialization success + * 5. Returns detailed test results with connectivity status + * + * @response_format + * ```typescript + * { + * success: boolean, + * testPassed: boolean, + * message?: string, + * error?: string, + * errorCode?: string, + * details: { + * authority?: string, + * clientConfigured?: boolean, + * warnings?: string[] + * } + * } + * ``` + * + * @example + * ```typescript + * // POST /api/sso-configuration/123/test + * // Body: { azure_tenant_id: "valid-guid", azure_client_id: "valid-guid", ... } + * // Success Response: + * { + * "success": true, + * "testPassed": true, + * "message": "SSO configuration test passed", + * "details": { + * "authority": "https://login.microsoftonline.com/tenant-id", + * "clientConfigured": true, + * "warnings": [] + * } + * } + * + * // Failure Response: + * { + * "success": false, + * "testPassed": false, + * "error": "MSAL client configuration failed", + * "errorCode": "INVALID_CLIENT_CONFIG", + * "details": ["MSAL client configuration failed. Please verify your Azure AD application settings."] + * } + * ``` + */ +export const testSSOConfiguration = async (req: Request, res: Response) => { + try { + const organizationId = req.params.organizationId; + const { + azure_tenant_id, + azure_client_id, + azure_client_secret, + cloud_environment = 'AzurePublic' + } = req.body; + + // Verify user belongs to organization and is admin + if (req.organizationId !== parseInt(organizationId)) { + return SSOErrorHandler.handleAuthError( + res, + new Error('Access denied'), + 'Access denied to this organization', + 403 + ); + } + + if (req.role !== 'Admin') { + return SSOErrorHandler.handleAuthError( + res, + new Error('Admin required'), + 'Admin access required', + 403 + ); + } + + // Validate basic configuration first + const validationResult = await SSOConfigValidator.validateAzureADConfig({ + tenant_id: azure_tenant_id, + client_id: azure_client_id, + client_secret: azure_client_secret, + cloud_environment: cloud_environment as 'AzurePublic' | 'AzureGovernment' + }); + + if (!validationResult.isValid) { + return res.status(400).json({ + success: false, + testPassed: false, + error: 'Configuration validation failed', + details: validationResult.errors + }); + } + + // Test MSAL client creation and authority access + try { + const { ConfidentialClientApplication } = await import('@azure/msal-node'); + + const authorityBase = cloud_environment === 'AzureGovernment' + ? 'https://login.microsoftonline.us' + : 'https://login.microsoftonline.com'; + + const authority = `${authorityBase}/${azure_tenant_id}`; + + const msalConfig = { + auth: { + clientId: azure_client_id, + clientSecret: azure_client_secret, + authority: authority + } + }; + + const cca = new ConfidentialClientApplication(msalConfig); + + // Basic connectivity test - if we can create the client, the configuration is syntactically valid + if (!cca) { + throw new Error('Failed to initialize MSAL client'); + } + + return res.json({ + success: true, + testPassed: true, + message: 'SSO configuration test passed', + details: { + authority: authority, + clientConfigured: true, + warnings: validationResult.warnings + } + }); + + } catch (msalError) { + const { userMessage, errorCode } = SSOErrorHandler.handleMSALError(msalError, 'configuration test'); + + return res.status(400).json({ + success: false, + testPassed: false, + error: userMessage, + errorCode: errorCode, + details: ['MSAL client configuration failed. Please verify your Azure AD application settings.'] + }); + } + + } catch (error) { + return SSOErrorHandler.handleInternalError( + res, + error, + 'SSO configuration testing' + ); + } +}; + +/** + * Disable SSO for organization + */ +export const disableSSO = async (req: Request, res: Response) => { + try { + const organizationId = req.params.organizationId; + + // Verify user belongs to organization and is admin + if (req.organizationId !== parseInt(organizationId)) { + return res.status(403).json({ + success: false, + error: 'Access denied to this organization' + }); + } + + if (req.role !== 'Admin') { + return res.status(403).json({ + success: false, + error: 'Admin access required' + }); + } + + // Find configuration + const ssoConfig = await SSOConfigurationModel.findOne({ + where: { + organization_id: organizationId + } + }); + + if (!ssoConfig) { + return res.status(404).json({ + success: false, + error: 'SSO configuration not found' + }); + } + + // Disable SSO + ssoConfig.is_enabled = false; + await ssoConfig.save(); + + return res.json({ + success: true, + message: 'SSO disabled successfully', + data: { + is_enabled: ssoConfig.is_enabled, + auth_method_policy: ssoConfig.auth_method_policy + } + }); + + } catch (error) { + console.error('Error disabling SSO:', error); + return res.status(500).json({ + success: false, + error: 'Failed to disable SSO' + }); + } +}; \ No newline at end of file diff --git a/Servers/database/db.ts b/Servers/database/db.ts index 9834845a0..12bc50b88 100644 --- a/Servers/database/db.ts +++ b/Servers/database/db.ts @@ -63,6 +63,7 @@ import { SubscriptionModel } from "../domain.layer/models/subscriptions/subscrip import { TasksModel } from "../domain.layer/models/tasks/tasks.model"; import { TaskAssigneesModel } from "../domain.layer/models/taskAssignees/taskAssignees.model"; import { SlackWebhookModel } from "../domain.layer/models/slackNotification/slackWebhook.model"; +import { SSOConfigurationModel } from "../domain.layer/models/sso/ssoConfiguration.model"; dotenv.config(); @@ -135,6 +136,7 @@ const sequelize = new Sequelize(conf.database!, conf.username!, conf.password, { TasksModel, TaskAssigneesModel, SlackWebhookModel, + SSOConfigurationModel, ], }) as Sequelize; diff --git a/Servers/database/migrations/20250928173544-create-sso-configuration-table.js b/Servers/database/migrations/20250928173544-create-sso-configuration-table.js new file mode 100644 index 000000000..de624d048 --- /dev/null +++ b/Servers/database/migrations/20250928173544-create-sso-configuration-table.js @@ -0,0 +1,38 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.sequelize.query(` + -- Create the sso_configurations table + CREATE TABLE sso_configurations ( + organization_id INTEGER PRIMARY KEY, + azure_tenant_id VARCHAR(255) NOT NULL, + azure_client_id VARCHAR(255) NOT NULL, + azure_client_secret TEXT NOT NULL, + cloud_environment VARCHAR(50) DEFAULT 'AzurePublic', + is_enabled BOOLEAN DEFAULT false, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE, + CONSTRAINT cloud_env_check CHECK (cloud_environment IN ('AzurePublic', 'AzureGovernment')) + ); + + -- Create trigger to auto-update updated_at + CREATE TRIGGER set_updated_at_sso_configurations + BEFORE UPDATE ON sso_configurations + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + + -- Create index for quick lookups + CREATE INDEX idx_sso_config_enabled ON sso_configurations(is_enabled); + `); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.sequelize.query(` + DROP TRIGGER IF EXISTS set_updated_at_sso_configurations ON sso_configurations; + DROP INDEX IF EXISTS idx_sso_config_enabled; + DROP TABLE IF EXISTS sso_configurations; + `); + } +}; \ No newline at end of file diff --git a/Servers/database/migrations/20250928173604-add-sso-fields-to-users.js b/Servers/database/migrations/20250928173604-add-sso-fields-to-users.js new file mode 100644 index 000000000..b90a3cbab --- /dev/null +++ b/Servers/database/migrations/20250928173604-add-sso-fields-to-users.js @@ -0,0 +1,33 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.sequelize.query(` + -- Add SSO fields to users table + ALTER TABLE users + ADD COLUMN IF NOT EXISTS sso_enabled BOOLEAN DEFAULT false, + ADD COLUMN IF NOT EXISTS azure_ad_object_id VARCHAR(255), + ADD COLUMN IF NOT EXISTS sso_last_login TIMESTAMP; + + -- Create index for Azure AD object ID lookups + CREATE INDEX IF NOT EXISTS idx_users_azure_ad_object_id ON users(azure_ad_object_id); + + -- Create composite index for SSO lookups + CREATE INDEX IF NOT EXISTS idx_users_sso_enabled_org ON users(sso_enabled, organization_id); + `); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.sequelize.query(` + -- Remove indexes + DROP INDEX IF EXISTS idx_users_azure_ad_object_id; + DROP INDEX IF EXISTS idx_users_sso_enabled_org; + + -- Remove SSO columns from users table + ALTER TABLE users + DROP COLUMN IF EXISTS sso_enabled, + DROP COLUMN IF EXISTS azure_ad_object_id, + DROP COLUMN IF EXISTS sso_last_login; + `); + } +}; diff --git a/Servers/database/migrations/20250928190000-refactor-sso-for-multi-provider.js b/Servers/database/migrations/20250928190000-refactor-sso-for-multi-provider.js new file mode 100644 index 000000000..a2cf29a6c --- /dev/null +++ b/Servers/database/migrations/20250928190000-refactor-sso-for-multi-provider.js @@ -0,0 +1,331 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + // 1. Create sso_providers table + await queryInterface.createTable('sso_providers', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false + }, + name: { + type: Sequelize.STRING(50), + allowNull: false, + unique: true, + comment: 'Provider identifier (azure-ad, google, okta, etc.)' + }, + display_name: { + type: Sequelize.STRING(100), + allowNull: false, + comment: 'Human-readable provider name' + }, + is_active: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true, + comment: 'Whether this provider type is available for configuration' + }, + created_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('now') + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('now') + } + }, { transaction }); + + // 2. Insert default providers + await queryInterface.bulkInsert('sso_providers', [ + { + name: 'azure-ad', + display_name: 'Microsoft Azure Active Directory', + is_active: true, + created_at: new Date(), + updated_at: new Date() + }, + { + name: 'google', + display_name: 'Google Workspace', + is_active: false, // Not implemented yet + created_at: new Date(), + updated_at: new Date() + }, + { + name: 'okta', + display_name: 'Okta', + is_active: false, // Not implemented yet + created_at: new Date(), + updated_at: new Date() + }, + { + name: 'saml', + display_name: 'SAML 2.0', + is_active: false, // Not implemented yet + created_at: new Date(), + updated_at: new Date() + } + ], { transaction }); + + // 3. Create new sso_configurations table with proper structure + await queryInterface.createTable('sso_configurations_new', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false + }, + organization_id: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'organizations', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + provider_id: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'sso_providers', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'RESTRICT' + }, + provider_config: { + type: Sequelize.JSONB, + allowNull: false, + comment: 'Encrypted provider-specific configuration' + }, + is_enabled: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false + }, + allowed_domains: { + type: Sequelize.ARRAY(Sequelize.STRING), + allowNull: true, + comment: 'List of allowed email domains for this SSO configuration' + }, + default_role_id: { + type: Sequelize.INTEGER, + allowNull: true, + references: { + model: 'roles', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL', + comment: 'Default role assigned to new users from this SSO provider' + }, + created_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('now') + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('now') + } + }, { transaction }); + + // 4. Migrate existing Azure AD configurations + const existingConfigs = await queryInterface.sequelize.query( + 'SELECT * FROM sso_configurations', + { type: queryInterface.sequelize.QueryTypes.SELECT, transaction } + ); + + if (existingConfigs.length > 0) { + // Get Azure AD provider ID + const [azureAdProvider] = await queryInterface.sequelize.query( + "SELECT id FROM sso_providers WHERE name = 'azure-ad'", + { type: queryInterface.sequelize.QueryTypes.SELECT, transaction } + ); + + const azureAdProviderId = azureAdProvider.id; + + // Migrate each existing configuration + for (const config of existingConfigs) { + const providerConfig = { + azure_client_id: config.azure_client_id, + azure_client_secret: config.azure_client_secret_encrypted, + azure_tenant_id: config.azure_tenant_id, + azure_cloud_environment: config.azure_cloud_environment || 'public' + }; + + await queryInterface.bulkInsert('sso_configurations_new', [{ + organization_id: config.organization_id, + provider_id: azureAdProviderId, + provider_config: JSON.stringify(providerConfig), + is_enabled: config.is_enabled, + allowed_domains: null, // Will be set up separately + default_role_id: 2, // Default to Reviewer role + created_at: config.created_at || new Date(), + updated_at: config.updated_at || new Date() + }], { transaction }); + } + } + + // 5. Drop old table and rename new one + await queryInterface.dropTable('sso_configurations', { transaction }); + await queryInterface.renameTable('sso_configurations_new', 'sso_configurations', { transaction }); + + // 6. Add indexes for performance + await queryInterface.addIndex('sso_configurations', + ['organization_id', 'provider_id'], + { + unique: true, + name: 'idx_sso_config_org_provider', + transaction + } + ); + + await queryInterface.addIndex('sso_configurations', + ['is_enabled', 'provider_id'], + { + name: 'idx_sso_config_enabled_provider', + transaction + } + ); + + await queryInterface.addIndex('sso_providers', + ['name'], + { + unique: true, + name: 'idx_sso_providers_name', + transaction + } + ); + + // 7. Update users table to include provider_id for tracking + await queryInterface.addColumn('users', 'sso_provider_id', { + type: Sequelize.INTEGER, + allowNull: true, + references: { + model: 'sso_providers', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL', + comment: 'SSO provider used for this user account' + }, { transaction }); + + // 8. Update existing SSO users to use Azure AD provider + if (existingConfigs.length > 0) { + await queryInterface.sequelize.query( + `UPDATE users SET sso_provider_id = (SELECT id FROM sso_providers WHERE name = 'azure-ad') + WHERE sso_enabled = true`, + { transaction } + ); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + // 1. Remove provider_id column from users + await queryInterface.removeColumn('users', 'sso_provider_id', { transaction }); + + // 2. Create old sso_configurations table structure + await queryInterface.createTable('sso_configurations_old', { + organization_id: { + type: Sequelize.INTEGER, + primaryKey: true, + allowNull: false, + references: { + model: 'organizations', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + azure_client_id: { + type: Sequelize.STRING(255), + allowNull: false + }, + azure_client_secret_encrypted: { + type: Sequelize.TEXT, + allowNull: false + }, + azure_tenant_id: { + type: Sequelize.STRING(255), + allowNull: false + }, + azure_cloud_environment: { + type: Sequelize.ENUM('public', 'government', 'china', 'germany'), + allowNull: false, + defaultValue: 'public' + }, + is_enabled: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false + }, + created_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('now') + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('now') + } + }, { transaction }); + + // 3. Migrate Azure AD configurations back + const newConfigs = await queryInterface.sequelize.query( + `SELECT sc.*, sp.name as provider_name + FROM sso_configurations sc + JOIN sso_providers sp ON sc.provider_id = sp.id + WHERE sp.name = 'azure-ad'`, + { type: queryInterface.sequelize.QueryTypes.SELECT, transaction } + ); + + for (const config of newConfigs) { + const providerConfig = JSON.parse(config.provider_config); + + await queryInterface.bulkInsert('sso_configurations_old', [{ + organization_id: config.organization_id, + azure_client_id: providerConfig.azure_client_id, + azure_client_secret_encrypted: providerConfig.azure_client_secret, + azure_tenant_id: providerConfig.azure_tenant_id, + azure_cloud_environment: providerConfig.azure_cloud_environment, + is_enabled: config.is_enabled, + created_at: config.created_at, + updated_at: config.updated_at + }], { transaction }); + } + + // 4. Drop new tables + await queryInterface.dropTable('sso_configurations', { transaction }); + await queryInterface.dropTable('sso_providers', { transaction }); + + // 5. Rename old table back + await queryInterface.renameTable('sso_configurations_old', 'sso_configurations', { transaction }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; \ No newline at end of file diff --git a/Servers/database/migrations/20250928190152-create-unified-sso-configuration-table.js b/Servers/database/migrations/20250928190152-create-unified-sso-configuration-table.js new file mode 100644 index 000000000..99c405979 --- /dev/null +++ b/Servers/database/migrations/20250928190152-create-unified-sso-configuration-table.js @@ -0,0 +1,164 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + // Create the unified_sso_configurations table + await queryInterface.createTable('unified_sso_configurations', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + organization_id: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'organizations', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + provider_id: { + type: Sequelize.STRING(100), + allowNull: false, + comment: 'Unique identifier for this provider instance within the organization' + }, + provider_type: { + type: Sequelize.ENUM('azure_ad', 'google', 'saml', 'okta', 'ping_identity'), + allowNull: false, + comment: 'Type of SSO provider' + }, + provider_name: { + type: Sequelize.STRING(255), + allowNull: false, + comment: 'Human-readable name for this provider instance' + }, + client_id: { + type: Sequelize.STRING(255), + allowNull: false, + comment: 'OAuth/OIDC Client ID' + }, + client_secret: { + type: Sequelize.TEXT, + allowNull: false, + comment: 'OAuth/OIDC Client Secret (encrypted)' + }, + cloud_environment: { + type: Sequelize.ENUM( + 'azure_public', 'azure_government', + 'google_public', + 'public', 'government', 'private' + ), + allowNull: false, + defaultValue: 'public', + comment: 'Cloud environment for the provider' + }, + is_enabled: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + comment: 'Whether this provider is enabled' + }, + is_primary: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + comment: 'Whether this is the primary SSO provider for the organization' + }, + provider_config: { + type: Sequelize.TEXT, + allowNull: true, + comment: 'Provider-specific configuration (encrypted JSON)' + }, + allowed_domains: { + type: Sequelize.ARRAY(Sequelize.STRING), + allowNull: true, + comment: 'List of allowed email domains for this SSO configuration. NULL means no restrictions.' + }, + default_role_id: { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: 2, + comment: 'Default role ID assigned to new users created via SSO. Defaults to Reviewer (ID: 2).' + }, + scopes: { + type: Sequelize.ARRAY(Sequelize.STRING), + allowNull: true, + defaultValue: ['openid', 'profile', 'email'], + comment: 'OAuth/OIDC scopes to request' + }, + redirect_uri: { + type: Sequelize.STRING(500), + allowNull: true, + comment: 'OAuth/OIDC redirect URI' + }, + created_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') + }, + last_used_at: { + type: Sequelize.DATE, + allowNull: true, + comment: 'Timestamp of when this provider was last used for authentication' + } + }); + + // Create indexes for better performance + await queryInterface.addIndex('unified_sso_configurations', { + fields: ['organization_id', 'provider_id'], + unique: true, + name: 'unified_sso_configurations_org_provider_unique' + }); + + await queryInterface.addIndex('unified_sso_configurations', { + fields: ['organization_id', 'provider_type'], + name: 'unified_sso_configurations_org_type_idx' + }); + + await queryInterface.addIndex('unified_sso_configurations', { + fields: ['organization_id', 'is_enabled'], + name: 'unified_sso_configurations_org_enabled_idx' + }); + + await queryInterface.addIndex('unified_sso_configurations', { + fields: ['organization_id', 'is_primary'], + name: 'unified_sso_configurations_org_primary_idx' + }); + + await queryInterface.addIndex('unified_sso_configurations', { + fields: ['provider_type'], + name: 'unified_sso_configurations_provider_type_idx' + }); + + await queryInterface.addIndex('unified_sso_configurations', { + fields: ['last_used_at'], + name: 'unified_sso_configurations_last_used_idx' + }); + }, + + async down(queryInterface, Sequelize) { + // Drop indexes first + await queryInterface.removeIndex('unified_sso_configurations', 'unified_sso_configurations_org_provider_unique'); + await queryInterface.removeIndex('unified_sso_configurations', 'unified_sso_configurations_org_type_idx'); + await queryInterface.removeIndex('unified_sso_configurations', 'unified_sso_configurations_org_enabled_idx'); + await queryInterface.removeIndex('unified_sso_configurations', 'unified_sso_configurations_org_primary_idx'); + await queryInterface.removeIndex('unified_sso_configurations', 'unified_sso_configurations_provider_type_idx'); + await queryInterface.removeIndex('unified_sso_configurations', 'unified_sso_configurations_last_used_idx'); + + // Drop the table + await queryInterface.dropTable('unified_sso_configurations'); + + // Drop the ENUMs (Note: this might affect other tables using the same ENUM) + // await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_unified_sso_configurations_provider_type";'); + // await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_unified_sso_configurations_cloud_environment";'); + } +}; \ No newline at end of file diff --git a/Servers/database/migrations/20250928195000-add-security-fields-to-sso-configuration.js b/Servers/database/migrations/20250928195000-add-security-fields-to-sso-configuration.js new file mode 100644 index 000000000..0330d65e6 --- /dev/null +++ b/Servers/database/migrations/20250928195000-add-security-fields-to-sso-configuration.js @@ -0,0 +1,74 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + // Check if the table exists before modifying it + const tableExists = await queryInterface.showAllTables().then(tables => + tables.includes('sso_configurations') + ); + + if (!tableExists) { + console.log('sso_configurations table does not exist, skipping migration'); + await transaction.commit(); + return; + } + + // Check if columns already exist before adding them + const tableDescription = await queryInterface.describeTable('sso_configurations', { transaction }); + + // Add allowed_domains field if it doesn't exist + if (!tableDescription.allowed_domains) { + await queryInterface.addColumn('sso_configurations', 'allowed_domains', { + type: Sequelize.ARRAY(Sequelize.STRING), + allowNull: true, + comment: 'List of allowed email domains for this SSO configuration. NULL means no restrictions.' + }, { transaction }); + } + + // Add default_role_id field if it doesn't exist + if (!tableDescription.default_role_id) { + await queryInterface.addColumn('sso_configurations', 'default_role_id', { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: 2, // Default to Reviewer role + references: { + model: 'roles', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL', + comment: 'Default role ID assigned to new users created via SSO. Defaults to Reviewer (ID: 2).' + }, { transaction }); + + // Add index for performance only if column was added + await queryInterface.addIndex('sso_configurations', ['default_role_id'], { + name: 'idx_sso_config_default_role', + transaction + }); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + await queryInterface.removeIndex('sso_configurations', 'idx_sso_config_default_role', { transaction }); + await queryInterface.removeColumn('sso_configurations', 'default_role_id', { transaction }); + await queryInterface.removeColumn('sso_configurations', 'allowed_domains', { transaction }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; \ No newline at end of file diff --git a/Servers/database/migrations/20250928215432-add-auth-method-policy-to-sso-configurations.js b/Servers/database/migrations/20250928215432-add-auth-method-policy-to-sso-configurations.js new file mode 100644 index 000000000..85b9da4a7 --- /dev/null +++ b/Servers/database/migrations/20250928215432-add-auth-method-policy-to-sso-configurations.js @@ -0,0 +1,23 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up (queryInterface, Sequelize) { + await queryInterface.addColumn('sso_configurations', 'auth_method_policy', { + type: Sequelize.STRING(20), + allowNull: false, + defaultValue: 'both', + validate: { + isIn: { + args: [['sso_only', 'password_only', 'both']], + msg: 'Auth method policy must be one of: sso_only, password_only, both' + } + }, + comment: 'Controls which authentication methods are allowed for this organization: sso_only, password_only, or both' + }); + }, + + async down (queryInterface, Sequelize) { + await queryInterface.removeColumn('sso_configurations', 'auth_method_policy'); + } +}; diff --git a/Servers/docs/azure-ad-sso-setup.md b/Servers/docs/azure-ad-sso-setup.md new file mode 100644 index 000000000..1337807ac --- /dev/null +++ b/Servers/docs/azure-ad-sso-setup.md @@ -0,0 +1,355 @@ +# Azure AD SSO Setup Guide + +This guide walks you through setting up Single Sign-On (SSO) with Azure Active Directory (Azure AD/Entra ID) for VerifyWise. + +## Table of Contents + +1. [Prerequisites](#prerequisites) +2. [Azure AD Application Setup](#azure-ad-application-setup) +3. [VerifyWise Configuration](#verifywise-configuration) +4. [Testing the Integration](#testing-the-integration) +5. [Troubleshooting](#troubleshooting) +6. [Security Considerations](#security-considerations) + +## Prerequisites + +Before setting up Azure AD SSO, ensure you have: + +- **Azure AD tenant** with administrator access +- **VerifyWise organization admin** access +- Access to **Azure Portal** (portal.azure.com) +- Basic understanding of **OAuth 2.0/OpenID Connect** + +## Azure AD Application Setup + +### Step 1: Create Azure AD Application + +1. **Navigate to Azure Portal** + - Go to [portal.azure.com](https://portal.azure.com) + - Sign in with your Azure AD administrator account + +2. **Access Azure Active Directory** + - Search for "Azure Active Directory" in the top search bar + - Select "Azure Active Directory" from the results + +3. **Register a New Application** + - In the left menu, click **"App registrations"** + - Click **"+ New registration"** + - Fill in the application details: + + ``` + Name: VerifyWise SSO + Supported account types: Accounts in this organizational directory only + Redirect URI: Web + Redirect URI Value: https://your-verifywise-domain.com/api/auth/azure/callback + ``` + +4. **Note Application Details** + After registration, copy these values (you'll need them later): + - **Application (client) ID**: `12345678-1234-1234-1234-123456789abc` + - **Directory (tenant) ID**: `87654321-4321-4321-4321-cba987654321` + +### Step 2: Configure Application Settings + +1. **Create Client Secret** + - In your app registration, go to **"Certificates & secrets"** + - Click **"+ New client secret"** + - Add description: `VerifyWise SSO Secret` + - Set expiration: **24 months** (recommended) + - Click **"Add"** + - **⚠️ Important**: Copy the secret value immediately - you won't see it again! + +2. **Configure API Permissions** + - Go to **"API permissions"** + - Click **"+ Add a permission"** + - Select **"Microsoft Graph"** + - Choose **"Delegated permissions"** + - Add these permissions: + - `openid` (Sign in and read user profile) + - `profile` (View users' basic profile) + - `email` (View users' email address) + - `User.Read` (Sign in and read user profile) + +3. **Grant Admin Consent** + - Click **"Grant admin consent for [Your Organization]"** + - Confirm by clicking **"Yes"** + +### Step 3: Configure Authentication + +1. **Set Redirect URIs** + - Go to **"Authentication"** + - Under **"Web"** platform, ensure you have: + ``` + https://your-verifywise-domain.com/api/auth/azure/callback + ``` + - Add logout URL (optional): + ``` + https://your-verifywise-domain.com/logout + ``` + +2. **Configure Token Settings** + - Under **"Implicit grant and hybrid flows"**: + - ✅ **ID tokens** (used for hybrid flows) + - Under **"Advanced settings"**: + - ✅ **Allow public client flows**: No + +## VerifyWise Configuration + +### Step 1: Access SSO Configuration + +1. **Login to VerifyWise** as an organization administrator +2. **Navigate to Organization Settings** → **SSO Configuration** +3. **Select Azure AD** as the SSO provider + +### Step 2: Configure Azure AD Settings + +Fill in the Azure AD configuration form with the values from your Azure AD app registration: + +```json +{ + "azure_tenant_id": "87654321-4321-4321-4321-cba987654321", + "azure_client_id": "12345678-1234-1234-1234-123456789abc", + "azure_client_secret": "your-client-secret-value", + "cloud_environment": "AzurePublic", + "auth_method_policy": "both" +} +``` + +#### Configuration Fields Explained + +| Field | Description | Example | +|-------|-------------|---------| +| `azure_tenant_id` | Your Azure AD tenant/directory ID | `87654321-4321-4321-4321-cba987654321` | +| `azure_client_id` | Your Azure AD application/client ID | `12345678-1234-1234-1234-123456789abc` | +| `azure_client_secret` | Your Azure AD application client secret | `ABC123xyz789...` | +| `cloud_environment` | Azure cloud environment | `AzurePublic` or `AzureGovernment` | +| `auth_method_policy` | Authentication policy | `sso_only`, `password_only`, or `both` | + +#### Authentication Policies + +- **`sso_only`**: Users can only authenticate via Azure AD SSO +- **`password_only`**: Users can only use password authentication (not recommended) +- **`both`**: Users can choose between SSO and password authentication (recommended for migration) + +### Step 3: Validate Configuration + +1. **Click "Validate Configuration"** + - This checks if your Azure AD settings are correctly formatted + - Verifies tenant ID and client ID are valid GUIDs + - Validates client secret strength + +2. **Click "Test Connection"** + - This attempts to create an MSAL client with your configuration + - Verifies that the Azure AD application can be reached + - Tests the authority URL construction + +### Step 4: Enable SSO + +1. **Review Configuration Summary** +2. **Click "Enable SSO"** +3. **Confirm the action** + +⚠️ **Important**: Test SSO thoroughly before enabling `sso_only` mode to avoid locking out users. + +## Testing the Integration + +### Step 1: Test SSO Login Flow + +1. **Open a new incognito/private browser window** +2. **Navigate to VerifyWise login page** +3. **Click "Sign in with Azure AD"** +4. **Complete Azure AD authentication** +5. **Verify successful login to VerifyWise** + +### Step 2: Test User Provisioning + +When a user logs in via SSO for the first time: + +1. **New User Creation**: VerifyWise automatically creates a user account +2. **Profile Information**: Email, name populated from Azure AD +3. **Organization Assignment**: User assigned to the correct organization +4. **Role Assignment**: User gets default role (typically "User") + +### Step 3: Verify API Endpoints + +Test the SSO API endpoints using curl or Postman: + +```bash +# Test configuration retrieval +curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + https://your-domain.com/api/sso-configuration/YOUR_ORG_ID + +# Test configuration validation +curl -X POST \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"azure_tenant_id":"...","azure_client_id":"...","azure_client_secret":"..."}' \ + https://your-domain.com/api/sso-configuration/YOUR_ORG_ID/validate +``` + +## Troubleshooting + +### Common Issues + +#### 1. **"Invalid tenant ID" Error** + +**Symptoms**: Error during configuration validation +**Cause**: Incorrect tenant ID format or value +**Solution**: +- Verify tenant ID is a valid GUID format: `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` +- Check Azure Portal → Azure AD → Overview → Tenant ID +- Ensure no extra spaces or characters + +#### 2. **"Invalid client ID" Error** + +**Symptoms**: Authentication fails with client ID error +**Cause**: Incorrect application ID +**Solution**: +- Verify client ID in Azure Portal → App registrations → Your App → Overview +- Ensure ID matches exactly (case-sensitive) + +#### 3. **"Client Secret Invalid" Error** + +**Symptoms**: Authentication fails during token exchange +**Cause**: Expired or incorrect client secret +**Solution**: +- Generate new client secret in Azure Portal +- Update VerifyWise configuration with new secret +- Ensure secret hasn't expired + +#### 4. **"Access Denied" Error** + +**Symptoms**: Users can't authenticate via SSO +**Cause**: Missing permissions or consent +**Solution**: +- Verify API permissions are granted +- Ensure admin consent is provided +- Check user has access to the Azure AD application + +#### 5. **"Redirect URI Mismatch" Error** + +**Symptoms**: OAuth error during authentication +**Cause**: Redirect URI doesn't match Azure AD configuration +**Solution**: +- Verify redirect URI in Azure Portal matches your VerifyWise domain +- Ensure HTTPS is used (not HTTP) +- Check for extra slashes or typos + +### Debug Mode + +Enable debug logging to troubleshoot issues: + +```bash +# Set environment variable for detailed SSO logging +export SSO_DEBUG=true +npm run start +``` + +Check logs for detailed error information: + +```bash +# Check SSO-specific logs +grep "SSO" logs/application.log +``` + +### Validation Errors + +The SSO validator provides detailed error messages: + +```json +{ + "success": false, + "validation": { + "isValid": false, + "errors": [ + "Tenant ID must be a valid GUID format", + "Client Secret is too short" + ], + "warnings": [ + "Client Secret contains unusual characters" + ] + } +} +``` + +## Security Considerations + +### Best Practices + +1. **Client Secret Management** + - Store client secrets securely (environment variables, key vault) + - Rotate secrets regularly (every 6-12 months) + - Never commit secrets to version control + - Use strong, unique secrets + +2. **Network Security** + - Always use HTTPS for redirect URIs + - Implement proper firewall rules + - Consider IP whitelisting for admin access + +3. **User Access Control** + - Regularly review user access in Azure AD + - Implement conditional access policies + - Use multi-factor authentication (MFA) + - Monitor sign-in logs + +4. **Application Security** + - Keep VerifyWise updated with latest security patches + - Regularly review SSO audit logs + - Implement session timeout policies + - Use secure session management + +### Compliance Considerations + +- **Data Residency**: Ensure Azure AD tenant location meets compliance requirements +- **Audit Logging**: Enable comprehensive audit logging for SSO events +- **Access Reviews**: Implement regular access reviews for SSO users +- **Data Protection**: Follow GDPR/CCPA guidelines for user data handling + +### Monitoring and Alerting + +Set up monitoring for: + +- **Failed SSO authentication attempts** +- **Configuration changes** +- **Token exchange failures** +- **Unusual access patterns** + +## Support + +### Documentation Links + +- [Microsoft Azure AD Documentation](https://docs.microsoft.com/en-us/azure/active-directory/) +- [MSAL Node.js Documentation](https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-node-api-ref) +- [OAuth 2.0 and OpenID Connect](https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-protocols) + +### Getting Help + +For VerifyWise SSO issues: + +1. **Check this documentation** for common solutions +2. **Review application logs** for detailed error messages +3. **Use the built-in validation tools** in VerifyWise admin panel +4. **Contact VerifyWise support** with configuration details and error logs + +### Useful Azure AD PowerShell Commands + +```powershell +# Get tenant information +Get-AzureADTenantDetail + +# List all app registrations +Get-AzureADApplication + +# Get specific app registration +Get-AzureADApplication -Filter "DisplayName eq 'VerifyWise SSO'" + +# Check app permissions +Get-AzureADServicePrincipal -Filter "DisplayName eq 'VerifyWise SSO'" | Get-AzureADServicePrincipalOAuth2PermissionGrant +``` + +--- + +**Last Updated**: 2025-01-28 +**Version**: 1.0 +**Tested with**: Azure AD/Entra ID, VerifyWise v2.x \ No newline at end of file diff --git a/Servers/domain.layer/models/sso/ssoConfiguration.model.ts b/Servers/domain.layer/models/sso/ssoConfiguration.model.ts new file mode 100644 index 000000000..602596df9 --- /dev/null +++ b/Servers/domain.layer/models/sso/ssoConfiguration.model.ts @@ -0,0 +1,687 @@ +/** + * @fileoverview SSO Configuration Model for Azure AD (Entra ID) Integration + * + * This module defines the database model and business logic for managing + * Azure Active Directory Single Sign-On configurations in a multi-tenant + * environment. It provides secure storage, validation, and domain-specific + * operations for Azure AD SSO settings. + * + * Security Features: + * - Automatic client secret encryption using AES-256-GCM + * - Timing-safe domain validation to prevent timing attacks + * - Secure credential storage with encrypted-at-rest secrets + * - Multi-tenant isolation with organization-scoped configurations + * - GUID validation for Azure AD identifiers + * + * Azure AD Integration: + * - Support for Azure Public and Government clouds + * - Configurable authentication method policies + * - Domain-based access control for SSO users + * - Default role assignment for new SSO users + * + * @author VerifyWise Development Team + * @since 2024-09-28 + * @version 1.0.0 + * @see {@link https://docs.microsoft.com/en-us/azure/active-directory/develop/} Azure AD Developer Documentation + */ + +import { Column, DataType, Model, Table, ForeignKey, BelongsTo } from "sequelize-typescript"; +import { OrganizationModel } from "../organization/organization.model"; +import { encryptSecret, decryptSecret, isEncrypted } from "../../../utils/sso-encryption.utils"; +import * as crypto from 'crypto'; + +/** + * Interface defining the structure of SSO configuration data + * + * This interface represents the complete set of Azure AD SSO configuration + * parameters required for establishing secure authentication flows in a + * multi-tenant environment. + * + * @interface ISSOConfiguration + * @since 1.0.0 + * + * @example + * ```typescript + * const config: ISSOConfiguration = { + * organization_id: 123, + * azure_tenant_id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + * azure_client_id: 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy', + * azure_client_secret: 'encrypted_secret_value', + * cloud_environment: 'AzurePublic', + * is_enabled: true, + * auth_method_policy: 'both' + * }; + * ``` + */ +export interface ISSOConfiguration { + /** Organization identifier (Foreign Key to organizations table) */ + organization_id: number; + + /** Azure AD Tenant ID in GUID format (identifies the Azure AD directory) */ + azure_tenant_id: string; + + /** Azure AD Application Client ID in GUID format (identifies the registered app) */ + azure_client_id: string; + + /** Azure AD Client Secret (stored encrypted for security) */ + azure_client_secret: string; + + /** Azure cloud environment for region-specific endpoints */ + cloud_environment: 'AzurePublic' | 'AzureGovernment'; + + /** Whether SSO authentication is enabled for this organization */ + is_enabled: boolean; + + /** Authentication method policy controlling allowed login methods */ + auth_method_policy: 'sso_only' | 'password_only' | 'both'; + + /** Timestamp when the configuration was created */ + created_at?: Date; + + /** Timestamp when the configuration was last updated */ + updated_at?: Date; +} + +/** + * Interface for Azure AD configuration used by MSAL authentication library + * + * This interface provides a clean abstraction of Azure AD configuration + * parameters specifically formatted for use with Microsoft Authentication + * Library (MSAL) and other Azure AD integration libraries. + * + * @interface IAzureAdConfig + * @since 1.0.0 + * + * @example + * ```typescript + * const msalConfig: IAzureAdConfig = { + * tenant_id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + * client_id: 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy', + * client_secret: 'decrypted_secret_value', + * cloud_environment: 'AzurePublic', + * redirect_uri: 'https://app.verifywise.com/auth/callback' + * }; + * ``` + */ +export interface IAzureAdConfig { + /** Azure AD Tenant ID (directory identifier) */ + tenant_id: string; + + /** Azure AD Application Client ID (app registration identifier) */ + client_id: string; + + /** Azure AD Client Secret (decrypted for use with MSAL) */ + client_secret: string; + + /** Azure cloud environment determining authentication endpoints */ + cloud_environment: 'AzurePublic' | 'AzureGovernment'; + + /** Optional redirect URI for OAuth callback (defaults to configured value) */ + redirect_uri?: string; +} + +/** + * SSO Configuration Database Model + * + * Sequelize model for managing Azure AD Single Sign-On configurations + * with comprehensive security features, validation, and multi-tenant support. + * This model handles secure storage of Azure AD credentials and provides + * business logic for SSO authentication flows. + * + * Key Features: + * - Automatic client secret encryption/decryption + * - Domain-based access control with timing-safe validation + * - Multi-cloud Azure environment support + * - Configurable authentication policies + * - GUID validation for Azure AD identifiers + * - Default role assignment for new SSO users + * + * Security Considerations: + * - All client secrets are encrypted at rest using AES-256-GCM + * - Domain validation uses constant-time comparison to prevent timing attacks + * - Supports wildcard domain matching (*.company.com) + * - Validates Azure AD GUID formats for tenant and client IDs + * + * @class SSOConfigurationModel + * @extends {Model} + * @implements {ISSOConfiguration} + * @since 1.0.0 + * + * @example + * ```typescript + * // Create new SSO configuration + * const ssoConfig = await SSOConfigurationModel.create({ + * organization_id: 123, + * azure_tenant_id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + * azure_client_id: 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy', + * azure_client_secret: 'plain_text_secret', // Will be automatically encrypted + * cloud_environment: 'AzurePublic', + * is_enabled: true, + * auth_method_policy: 'both' + * }); + * + * // Validate configuration + * await ssoConfig.validateConfiguration(); + * + * // Get decrypted secret for MSAL usage + * const azureConfig = ssoConfig.getAzureAdConfig(); + * ``` + */ +@Table({ + tableName: "sso_configurations", + timestamps: true, + underscored: true, + createdAt: 'created_at', + updatedAt: 'updated_at' +}) +export class SSOConfigurationModel + extends Model + implements ISSOConfiguration { + + @Column({ + type: DataType.INTEGER, + primaryKey: true, + allowNull: false, + }) + @ForeignKey(() => OrganizationModel) + organization_id!: number; + + @Column({ + type: DataType.STRING(255), + allowNull: false, + }) + azure_tenant_id!: string; + + @Column({ + type: DataType.STRING(255), + allowNull: false, + }) + azure_client_id!: string; + + @Column({ + type: DataType.TEXT, + allowNull: false, + set(this: SSOConfigurationModel, value: string) { + // Encrypt the secret before storing if it's not already encrypted + if (value && !isEncrypted(value)) { + this.setDataValue('azure_client_secret', encryptSecret(value)); + } else { + this.setDataValue('azure_client_secret', value); + } + } + }) + azure_client_secret!: string; + + @Column({ + type: DataType.STRING(50), + allowNull: false, + defaultValue: 'AzurePublic', + validate: { + isIn: { + args: [['AzurePublic', 'AzureGovernment']], + msg: 'Cloud environment must be either AzurePublic or AzureGovernment' + } + } + }) + cloud_environment!: 'AzurePublic' | 'AzureGovernment'; + + @Column({ + type: DataType.BOOLEAN, + allowNull: false, + defaultValue: false + }) + is_enabled!: boolean; + + @Column({ + type: DataType.STRING(20), + allowNull: false, + defaultValue: 'both', + validate: { + isIn: { + args: [['sso_only', 'password_only', 'both']], + msg: 'Auth method policy must be one of: sso_only, password_only, both' + } + }, + comment: 'Controls which authentication methods are allowed for this organization' + }) + auth_method_policy!: 'sso_only' | 'password_only' | 'both'; + + @Column({ + type: DataType.DATE, + allowNull: false, + defaultValue: DataType.NOW + }) + created_at!: Date; + + @Column({ + type: DataType.DATE, + allowNull: false, + defaultValue: DataType.NOW + }) + updated_at!: Date; + + @Column({ + type: DataType.ARRAY(DataType.STRING), + allowNull: true, + comment: 'List of allowed email domains for this SSO configuration. NULL means no restrictions.' + }) + allowed_domains?: string[]; + + @Column({ + type: DataType.INTEGER, + allowNull: true, + defaultValue: 2, + comment: 'Default role ID assigned to new users created via SSO. Defaults to Reviewer (ID: 2).' + }) + default_role_id?: number; + + @BelongsTo(() => OrganizationModel) + organization!: OrganizationModel; + + /** + * Retrieves the decrypted Azure AD client secret for authentication + * + * Safely decrypts the stored client secret for use in Azure AD authentication flows. + * The secret is automatically encrypted when stored and must be decrypted for use + * with MSAL and other Azure AD libraries. + * + * @returns {string} Decrypted client secret or empty string if decryption fails + * + * @security + * - Handles decryption errors gracefully without exposing sensitive information + * - Returns empty string on failure to prevent authentication with invalid credentials + * - Logs errors for debugging while protecting secret content + * + * @example + * ```typescript + * const ssoConfig = await SSOConfigurationModel.findOne({ + * where: { organization_id: 123 } + * }); + * + * const clientSecret = ssoConfig.getDecryptedSecret(); + * if (clientSecret) { + * // Use with MSAL or Azure AD libraries + * const azureConfig = { client_secret: clientSecret }; + * } + * ``` + */ + public getDecryptedSecret(): string { + if (!this.azure_client_secret) return ''; + try { + return decryptSecret(this.azure_client_secret); + } catch (error) { + console.error('Failed to decrypt client secret:', error); + return ''; + } + } + + /** + * Sets and encrypts Azure AD client secret for secure storage + * + * Encrypts the provided plain text client secret using AES-256-GCM encryption + * before storing it in the database. This method provides a secure way to + * update client secrets without exposing them in plain text. + * + * @param {string} plainTextSecret - The plain text Azure AD client secret to encrypt and store + * + * @security + * - Immediately encrypts the secret using industry-standard AES-256-GCM + * - Overwrites the plain text value to prevent memory exposure + * - Uses dedicated encryption utilities for consistent security practices + * + * @example + * ```typescript + * const ssoConfig = await SSOConfigurationModel.findOne({ + * where: { organization_id: 123 } + * }); + * + * // Update client secret securely + * ssoConfig.setClientSecret('new_client_secret_from_azure'); + * await ssoConfig.save(); + * ``` + */ + public setClientSecret(plainTextSecret: string): void { + this.azure_client_secret = encryptSecret(plainTextSecret); + } + + /** + * Retrieves the Azure AD authentication base URL for the configured cloud environment + * + * Returns the appropriate Azure AD login endpoint based on the cloud environment + * configuration. This ensures proper routing to the correct Azure AD instance + * for authentication requests. + * + * @returns {string} Azure AD base URL for authentication endpoints + * - Azure Public Cloud: https://login.microsoftonline.com + * - Azure Government Cloud: https://login.microsoftonline.us + * + * @example + * ```typescript + * const ssoConfig = await SSOConfigurationModel.findOne({ + * where: { organization_id: 123 } + * }); + * + * const baseUrl = ssoConfig.getAzureADBaseUrl(); + * const authUrl = `${baseUrl}/${ssoConfig.azure_tenant_id}/oauth2/v2.0/authorize`; + * ``` + */ + public getAzureADBaseUrl(): string { + return this.cloud_environment === 'AzureGovernment' + ? 'https://login.microsoftonline.us' + : 'https://login.microsoftonline.com'; + } + + /** + * Retrieves the Microsoft Graph API base URL for the configured cloud environment + * + * Returns the appropriate Microsoft Graph API endpoint based on the cloud + * environment configuration. This ensures API calls are routed to the correct + * Graph instance for user profile and directory operations. + * + * @returns {string} Microsoft Graph API base URL + * - Azure Public Cloud: https://graph.microsoft.com + * - Azure Government Cloud: https://graph.microsoft.us + * + * @example + * ```typescript + * const ssoConfig = await SSOConfigurationModel.findOne({ + * where: { organization_id: 123 } + * }); + * + * const graphUrl = ssoConfig.getGraphApiUrl(); + * const userProfileUrl = `${graphUrl}/v1.0/me`; + * ``` + */ + public getGraphApiUrl(): string { + return this.cloud_environment === 'AzureGovernment' + ? 'https://graph.microsoft.us' + : 'https://graph.microsoft.com'; + } + + /** + * Validates if an email domain is allowed for SSO authentication + * + * Performs security-hardened domain validation against the configured allowed + * domains list. Supports both exact domain matching and wildcard subdomain + * patterns (*.company.com) while preventing timing-based attacks through + * constant-time comparisons. + * + * @param {string} email - Email address to validate domain for + * @returns {boolean} True if domain is allowed, false otherwise + * + * @security + * - Uses constant-time comparison to prevent timing attacks + * - Handles wildcard domain patterns securely + * - Falls back to regular comparison if timing-safe comparison fails + * - Normalizes domains to lowercase for consistent comparison + * + * @features + * - No restrictions if allowed_domains is null/empty (returns true) + * - Supports wildcard patterns (*.example.com matches sub.example.com) + * - Exact domain matching (example.com matches user@example.com) + * - Case-insensitive domain comparison + * + * @example + * ```typescript + * const ssoConfig = new SSOConfigurationModel(); + * ssoConfig.setAllowedDomains(['company.com', '*.subsidiary.com']); + * + * console.log(ssoConfig.isEmailDomainAllowed('user@company.com')); // true + * console.log(ssoConfig.isEmailDomainAllowed('user@sub.subsidiary.com')); // true + * console.log(ssoConfig.isEmailDomainAllowed('user@external.com')); // false + * ``` + */ + public isEmailDomainAllowed(email: string): boolean { + if (!this.allowed_domains || this.allowed_domains.length === 0) { + return true; // No restrictions + } + + const domain = email.split('@')[1]?.toLowerCase(); + if (!domain) { + return false; + } + + // Use constant-time comparison to prevent timing attacks + let isAllowed = false; + + for (const allowedDomain of this.allowed_domains) { + const normalizedAllowed = allowedDomain.toLowerCase().trim(); + let domainMatches = false; + + // Support wildcard subdomains (e.g., *.company.com) + if (normalizedAllowed.startsWith('*.')) { + const baseDomain = normalizedAllowed.substring(2); + + // Prepare comparison strings with consistent length + const exactMatch = domain; + const wildcardMatch = domain.endsWith('.' + baseDomain) ? domain : ''; + const baseMatch = domain === baseDomain ? domain : ''; + + // Use constant-time comparison for all possibilities + try { + const exactBuffer = Buffer.from(exactMatch.padEnd(64, '\0')); + const baseBuffer = Buffer.from(baseMatch.padEnd(64, '\0')); + const wildcardBuffer = Buffer.from(wildcardMatch.padEnd(64, '\0')); + const baseDomainBuffer = Buffer.from(baseDomain.padEnd(64, '\0')); + + // Check exact match with base domain + const exactMatchResult = exactBuffer.length === baseDomainBuffer.length && + crypto.timingSafeEqual(exactBuffer.subarray(0, baseDomain.length), baseDomainBuffer.subarray(0, baseDomain.length)); + + // Check wildcard match (domain ends with .baseDomain) + const wildcardMatchResult = wildcardMatch.length > 0 && wildcardBuffer.length >= baseDomainBuffer.length; + + domainMatches = exactMatchResult || wildcardMatchResult; + } catch (error) { + // Fall back to regular comparison if timing-safe comparison fails + domainMatches = domain === baseDomain || domain.endsWith('.' + baseDomain); + } + } else { + // Regular domain comparison with constant-time comparison + try { + const domainBuffer = Buffer.from(domain.padEnd(64, '\0')); + const allowedBuffer = Buffer.from(normalizedAllowed.padEnd(64, '\0')); + + domainMatches = domain.length === normalizedAllowed.length && + crypto.timingSafeEqual(domainBuffer.subarray(0, domain.length), allowedBuffer.subarray(0, normalizedAllowed.length)); + } catch (error) { + // Fall back to regular comparison if timing-safe comparison fails + domainMatches = domain === normalizedAllowed; + } + } + + // Use bitwise OR to avoid short-circuiting (constant-time) + isAllowed = isAllowed || domainMatches; + } + + return isAllowed; + } + + /** + * Retrieves the default role ID assigned to new SSO users + * + * Returns the configured default role ID that will be assigned to users + * created through SSO authentication. This provides consistent role assignment + * for new users joining through Azure AD SSO. + * + * @returns {number} Role ID to assign to new SSO users (defaults to 2 - Reviewer) + * + * @see Role mappings: + * - 1: Admin + * - 2: Reviewer (default) + * - 3: Editor + * - 4: Auditor + * + * @example + * ```typescript + * const ssoConfig = await SSOConfigurationModel.findOne({ + * where: { organization_id: 123 } + * }); + * + * const roleId = ssoConfig.getDefaultRoleId(); + * // Use roleId when creating new SSO user accounts + * const newUser = await UserModel.create({ + * role_id: roleId, + * // ... other user properties + * }); + * ``` + */ + public getDefaultRoleId(): number { + return this.default_role_id || 2; // Default to Reviewer role (ID: 2) + } + + /** + * Sets allowed email domains with validation and normalization + * + * Configures the list of email domains that are permitted for SSO authentication. + * Performs validation and normalization of domain formats, including support + * for wildcard subdomain patterns. Invalid domains are filtered out automatically. + * + * @param {string[]} domains - Array of domain strings to allow for SSO + * Supports formats: 'company.com', '*.subsidiary.com' + * + * @validation + * - Trims whitespace and converts to lowercase + * - Validates domain format using RFC-compliant regex + * - Supports wildcard patterns (*.domain.com) + * - Filters out invalid domain formats + * - Sets to undefined if no valid domains remain + * + * @example + * ```typescript + * const ssoConfig = new SSOConfigurationModel(); + * + * // Set multiple domains with wildcard support + * ssoConfig.setAllowedDomains([ + * 'company.com', + * '*.subsidiary.com', + * 'partner.org' + * ]); + * + * // Clear domain restrictions (allow all) + * ssoConfig.setAllowedDomains([]); + * ``` + */ + public setAllowedDomains(domains: string[]): void { + if (!domains || domains.length === 0) { + this.allowed_domains = undefined; + return; + } + + // Basic validation and normalization + const validDomains = domains + .map(domain => domain.trim().toLowerCase()) + .filter(domain => { + // Basic domain format validation + const domainRegex = /^(\*\.)?[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; + return domain.length > 0 && domainRegex.test(domain); + }); + + this.allowed_domains = validDomains.length > 0 ? validDomains : undefined; + } + + /** + * Retrieves Azure AD configuration object formatted for MSAL integration + * + * Creates a clean configuration object containing all necessary Azure AD + * parameters for use with Microsoft Authentication Library (MSAL) and other + * Azure AD integration libraries. Automatically decrypts the client secret + * for immediate use. + * + * @returns {IAzureAdConfig} Complete Azure AD configuration object + * + * @security + * - Automatically decrypts client secret for library usage + * - Provides configuration in format expected by MSAL library + * - Maintains cloud environment context for proper endpoint routing + * + * @example + * ```typescript + * const ssoConfig = await SSOConfigurationModel.findOne({ + * where: { organization_id: 123 } + * }); + * + * // Get configuration for MSAL + * const azureConfig = ssoConfig.getAzureAdConfig(); + * + * // Use with MSAL Node library + * const msalInstance = new ConfidentialClientApplication({ + * auth: { + * clientId: azureConfig.client_id, + * clientSecret: azureConfig.client_secret, + * authority: `${ssoConfig.getAzureADBaseUrl()}/${azureConfig.tenant_id}` + * } + * }); + * ``` + */ + public getAzureAdConfig(): IAzureAdConfig { + return { + tenant_id: this.azure_tenant_id, + client_id: this.azure_client_id, + client_secret: this.getDecryptedSecret(), + cloud_environment: this.cloud_environment + }; + } + + /** + * Validates the SSO configuration before saving to database + * + * Performs comprehensive validation of all Azure AD configuration parameters + * to ensure they meet security and format requirements. This method should be + * called before saving the configuration to prevent invalid settings. + * + * @async + * @throws {Error} If any validation requirements are not met + * + * @validation_rules + * - Azure AD tenant_id must be present and in valid GUID format + * - Azure AD client_id must be present and in valid GUID format + * - Azure AD client_secret must be present and non-empty + * - Default role ID must be valid (1-4) if specified + * - Cloud environment must be valid Azure environment + * + * @example + * ```typescript + * const ssoConfig = new SSOConfigurationModel({ + * organization_id: 123, + * azure_tenant_id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + * azure_client_id: 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy', + * azure_client_secret: 'valid_secret', + * cloud_environment: 'AzurePublic', + * default_role_id: 2 + * }); + * + * try { + * await ssoConfig.validateConfiguration(); + * await ssoConfig.save(); // Safe to save + * } catch (error) { + * console.error('Configuration validation failed:', error.message); + * } + * ``` + */ + public async validateConfiguration(): Promise { + // Validate Azure AD fields + if (!this.azure_tenant_id || !this.azure_client_id || !this.azure_client_secret) { + throw new Error('Azure AD configuration is incomplete. tenant_id, client_id, and client_secret are required.'); + } + + // Validate GUID format for tenant_id and client_id + const guidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + + if (!guidRegex.test(this.azure_tenant_id)) { + throw new Error('Azure tenant ID must be a valid GUID format.'); + } + + if (!guidRegex.test(this.azure_client_id)) { + throw new Error('Azure client ID must be a valid GUID format.'); + } + + // Validate default_role_id exists + if (this.default_role_id) { + // In a real implementation, you'd check if the role exists in the database + if (this.default_role_id < 1 || this.default_role_id > 4) { + throw new Error('Invalid default role ID. Must be between 1-4.'); + } + } + } +} \ No newline at end of file diff --git a/Servers/domain.layer/models/sso/ssoProvider.model.ts b/Servers/domain.layer/models/sso/ssoProvider.model.ts new file mode 100644 index 000000000..b15a9a0af --- /dev/null +++ b/Servers/domain.layer/models/sso/ssoProvider.model.ts @@ -0,0 +1,151 @@ +/** + * Represents available SSO providers in the system. + * + * @type SSOProvider + * + * @property {number} id - Primary key + * @property {string} name - Unique provider name (e.g., 'azure-ad', 'google') + * @property {string} display_name - Human-readable provider name + * @property {boolean} is_active - Whether this provider is available for use + * @property {Date} created_at - Creation timestamp + * @property {Date} updated_at - Last update timestamp + */ + +import { Column, DataType, Model, Table, PrimaryKey, AutoIncrement } from "sequelize-typescript"; + +export interface ISSOProvider { + id?: number; + name: string; + display_name: string; + is_active: boolean; + created_at?: Date; + updated_at?: Date; +} + +@Table({ + tableName: "sso_providers", + timestamps: true, + underscored: true, + createdAt: 'created_at', + updatedAt: 'updated_at' +}) +export class SSOProviderModel + extends Model + implements ISSOProvider { + + @PrimaryKey + @AutoIncrement + @Column({ + type: DataType.INTEGER, + allowNull: false, + }) + id!: number; + + @Column({ + type: DataType.STRING(50), + allowNull: false, + unique: true, + comment: 'Unique identifier for the provider (e.g., azure-ad, google)' + }) + name!: string; + + @Column({ + type: DataType.STRING(100), + allowNull: false, + comment: 'Human-readable name for the provider' + }) + display_name!: string; + + @Column({ + type: DataType.BOOLEAN, + allowNull: false, + defaultValue: true, + comment: 'Whether this provider is available for configuration' + }) + is_active!: boolean; + + @Column({ + type: DataType.DATE, + allowNull: false, + defaultValue: DataType.NOW + }) + created_at!: Date; + + @Column({ + type: DataType.DATE, + allowNull: false, + defaultValue: DataType.NOW + }) + updated_at!: Date; + + + /** + * Check if this provider is Azure AD + */ + public isAzureAD(): boolean { + return this.name === 'azure-ad'; + } + + /** + * Check if this provider is Google + */ + public isGoogle(): boolean { + return this.name === 'google'; + } + + /** + * Check if this provider is SAML + */ + public isSAML(): boolean { + return this.name === 'saml'; + } + + /** + * Check if this provider is Okta + */ + public isOkta(): boolean { + return this.name === 'okta'; + } + + /** + * Get the provider type for configuration purposes + */ + public getProviderType(): string { + return this.name; + } + + /** + * Get configuration schema for this provider + */ + public getConfigurationSchema(): object { + switch (this.name) { + case 'azure-ad': + return { + tenant_id: { required: true, type: 'string', format: 'uuid' }, + client_id: { required: true, type: 'string', format: 'uuid' }, + client_secret: { required: true, type: 'string', sensitive: true }, + cloud_environment: { + required: false, + type: 'string', + enum: ['AzurePublic', 'AzureGovernment'], + default: 'AzurePublic' + } + }; + case 'google': + return { + client_id: { required: true, type: 'string' }, + client_secret: { required: true, type: 'string', sensitive: true }, + hosted_domain: { required: false, type: 'string' } + }; + case 'saml': + return { + entity_id: { required: true, type: 'string' }, + sso_url: { required: true, type: 'string', format: 'url' }, + certificate: { required: true, type: 'string' }, + sign_request: { required: false, type: 'boolean', default: false } + }; + default: + return {}; + } + } +} \ No newline at end of file diff --git a/Servers/domain.layer/models/sso/unified-sso-configuration.model.ts b/Servers/domain.layer/models/sso/unified-sso-configuration.model.ts new file mode 100644 index 000000000..063c8705d --- /dev/null +++ b/Servers/domain.layer/models/sso/unified-sso-configuration.model.ts @@ -0,0 +1,577 @@ +/** + * Unified SSO Configuration Model + * + * Replaces the original Azure AD-specific model with a flexible, multi-provider + * configuration model that can support Azure AD, Google, SAML, and other providers. + */ + +import { Column, DataType, Model, Table, ForeignKey, BelongsTo, Index } from "sequelize-typescript"; +import { OrganizationModel } from "../organization/organization.model"; +import { encryptSecret, decryptSecret, isEncrypted } from "../../../utils/sso-encryption.utils"; +import { + SSOProviderType, + SSOProviderConfig, + CloudEnvironment +} from "../../../interfaces/sso-provider.interface"; +import crypto from 'crypto'; + +export interface IUnifiedSSOConfiguration { + id?: number; + organization_id: number; + provider_id: string; // Unique identifier for this provider instance + provider_type: SSOProviderType; + provider_name: string; // Human-readable name + + // Core configuration + client_id: string; + client_secret: string; // Encrypted + cloud_environment: CloudEnvironment; + is_enabled: boolean; + is_primary: boolean; // Whether this is the primary SSO provider for the org + + // Provider-specific configuration (JSON, encrypted) + provider_config: string; // Encrypted JSON containing provider-specific settings + + // Security settings + allowed_domains?: string[]; + default_role_id?: number; + + // OAuth/OIDC settings + scopes?: string[]; + redirect_uri?: string; + + // Timestamps + created_at?: Date; + updated_at?: Date; + last_used_at?: Date; +} + +@Table({ + tableName: "unified_sso_configurations", + timestamps: true, + underscored: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + indexes: [ + { + unique: true, + fields: ['organization_id', 'provider_id'] + }, + { + fields: ['organization_id', 'provider_type'] + }, + { + fields: ['organization_id', 'is_enabled'] + }, + { + fields: ['organization_id', 'is_primary'] + } + ] +}) +export class UnifiedSSOConfigurationModel + extends Model + implements IUnifiedSSOConfiguration { + + @Column({ + type: DataType.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }) + id!: number; + + @Column({ + type: DataType.INTEGER, + allowNull: false, + }) + @ForeignKey(() => OrganizationModel) + @Index + organization_id!: number; + + @Column({ + type: DataType.STRING(100), + allowNull: false, + comment: 'Unique identifier for this provider instance within the organization' + }) + @Index + provider_id!: string; + + @Column({ + type: DataType.ENUM(...Object.values(SSOProviderType)), + allowNull: false, + comment: 'Type of SSO provider (azure_ad, google, saml, etc.)' + }) + @Index + provider_type!: SSOProviderType; + + @Column({ + type: DataType.STRING(255), + allowNull: false, + comment: 'Human-readable name for this provider instance' + }) + provider_name!: string; + + @Column({ + type: DataType.STRING(255), + allowNull: false, + comment: 'OAuth/OIDC Client ID' + }) + client_id!: string; + + @Column({ + type: DataType.TEXT, + allowNull: false, + comment: 'OAuth/OIDC Client Secret (encrypted)', + set(this: UnifiedSSOConfigurationModel, value: string) { + // Encrypt the secret before storing if it's not already encrypted + if (value && !isEncrypted(value)) { + this.setDataValue('client_secret', encryptSecret(value)); + } else { + this.setDataValue('client_secret', value); + } + } + }) + client_secret!: string; + + @Column({ + type: DataType.ENUM(...Object.values(CloudEnvironment)), + allowNull: false, + defaultValue: CloudEnvironment.PUBLIC, + comment: 'Cloud environment for the provider' + }) + cloud_environment!: CloudEnvironment; + + @Column({ + type: DataType.BOOLEAN, + allowNull: false, + defaultValue: false, + comment: 'Whether this provider is enabled' + }) + @Index + is_enabled!: boolean; + + @Column({ + type: DataType.BOOLEAN, + allowNull: false, + defaultValue: false, + comment: 'Whether this is the primary SSO provider for the organization' + }) + @Index + is_primary!: boolean; + + @Column({ + type: DataType.TEXT, + allowNull: true, + comment: 'Provider-specific configuration (encrypted JSON)', + set(this: UnifiedSSOConfigurationModel, value: any) { + if (value) { + const jsonString = typeof value === 'string' ? value : JSON.stringify(value); + if (!isEncrypted(jsonString)) { + this.setDataValue('provider_config', encryptSecret(jsonString)); + } else { + this.setDataValue('provider_config', jsonString); + } + } else { + this.setDataValue('provider_config', ''); + } + } + }) + provider_config!: string; + + @Column({ + type: DataType.ARRAY(DataType.STRING), + allowNull: true, + comment: 'List of allowed email domains for this SSO configuration. NULL means no restrictions.' + }) + allowed_domains?: string[]; + + @Column({ + type: DataType.INTEGER, + allowNull: true, + defaultValue: 2, + comment: 'Default role ID assigned to new users created via SSO. Defaults to Reviewer (ID: 2).' + }) + default_role_id?: number; + + @Column({ + type: DataType.ARRAY(DataType.STRING), + allowNull: true, + defaultValue: ['openid', 'profile', 'email'], + comment: 'OAuth/OIDC scopes to request' + }) + scopes?: string[]; + + @Column({ + type: DataType.STRING(500), + allowNull: true, + comment: 'OAuth/OIDC redirect URI' + }) + redirect_uri?: string; + + @Column({ + type: DataType.DATE, + allowNull: false, + defaultValue: DataType.NOW + }) + created_at!: Date; + + @Column({ + type: DataType.DATE, + allowNull: false, + defaultValue: DataType.NOW + }) + updated_at!: Date; + + @Column({ + type: DataType.DATE, + allowNull: true, + comment: 'Timestamp of when this provider was last used for authentication' + }) + last_used_at?: Date; + + @BelongsTo(() => OrganizationModel) + organization!: OrganizationModel; + + /** + * Get decrypted client secret + */ + public getDecryptedSecret(): string { + if (!this.client_secret) return ''; + try { + return decryptSecret(this.client_secret); + } catch (error) { + console.error('Failed to decrypt client secret:', error); + return ''; + } + } + + /** + * Set and encrypt client secret + */ + public setClientSecret(plainTextSecret: string): void { + this.client_secret = encryptSecret(plainTextSecret); + } + + /** + * Get decrypted provider-specific configuration + */ + public getProviderConfig(): T | null { + if (!this.provider_config) return null; + try { + const decryptedConfig = decryptSecret(this.provider_config); + return JSON.parse(decryptedConfig) as T; + } catch (error) { + console.error('Failed to decrypt provider configuration:', error); + return null; + } + } + + /** + * Set and encrypt provider-specific configuration + */ + public setProviderConfig(config: any): void { + const jsonString = typeof config === 'string' ? config : JSON.stringify(config); + this.provider_config = encryptSecret(jsonString); + } + + /** + * Get base URLs for provider based on cloud environment + */ + public getProviderUrls(): { authUrl?: string; tokenUrl?: string; userInfoUrl?: string; logoutUrl?: string } { + switch (this.provider_type) { + case SSOProviderType.AZURE_AD: + return this.getAzureADUrls(); + case SSOProviderType.GOOGLE: + return this.getGoogleUrls(); + case SSOProviderType.SAML: + return this.getSAMLUrls(); + default: + return {}; + } + } + + /** + * Get Azure AD URLs based on cloud environment + */ + private getAzureADUrls(): { authUrl?: string; tokenUrl?: string; userInfoUrl?: string; logoutUrl?: string } { + const baseUrl = this.cloud_environment === CloudEnvironment.AZURE_GOVERNMENT + ? 'https://login.microsoftonline.us' + : 'https://login.microsoftonline.com'; + + const graphUrl = this.cloud_environment === CloudEnvironment.AZURE_GOVERNMENT + ? 'https://graph.microsoft.us' + : 'https://graph.microsoft.com'; + + const config = this.getProviderConfig<{ tenantId?: string }>(); + const tenantId = config?.tenantId || 'common'; + + return { + authUrl: `${baseUrl}/${tenantId}/oauth2/v2.0/authorize`, + tokenUrl: `${baseUrl}/${tenantId}/oauth2/v2.0/token`, + userInfoUrl: `${graphUrl}/v1.0/me`, + logoutUrl: `${baseUrl}/${tenantId}/oauth2/v2.0/logout` + }; + } + + /** + * Get Google URLs + */ + private getGoogleUrls(): { authUrl?: string; tokenUrl?: string; userInfoUrl?: string; logoutUrl?: string } { + return { + authUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenUrl: 'https://oauth2.googleapis.com/token', + userInfoUrl: 'https://www.googleapis.com/oauth2/v2/userinfo', + logoutUrl: 'https://accounts.google.com/logout' + }; + } + + /** + * Get SAML URLs from provider configuration + */ + private getSAMLUrls(): { authUrl?: string; tokenUrl?: string; userInfoUrl?: string; logoutUrl?: string } { + const config = this.getProviderConfig<{ + ssoUrl?: string; + sloUrl?: string; + metadataUrl?: string; + }>(); + + return { + authUrl: config?.ssoUrl, + logoutUrl: config?.sloUrl, + userInfoUrl: config?.metadataUrl + }; + } + + /** + * Check if email domain is allowed for this SSO configuration + * Uses constant-time comparison to prevent timing attacks + */ + public isEmailDomainAllowed(email: string): boolean { + if (!this.allowed_domains || this.allowed_domains.length === 0) { + return true; // No restrictions + } + + const domain = email.split('@')[1]?.toLowerCase(); + if (!domain) { + return false; + } + + // Use constant-time comparison to prevent timing attacks + let isAllowed = false; + + for (const allowedDomain of this.allowed_domains) { + const normalizedAllowed = allowedDomain.toLowerCase().trim(); + let domainMatches = false; + + // Support wildcard subdomains (e.g., *.company.com) + if (normalizedAllowed.startsWith('*.')) { + const baseDomain = normalizedAllowed.substring(2); + + try { + const exactBuffer = Buffer.from(domain.padEnd(64, '\0')); + const baseDomainBuffer = Buffer.from(baseDomain.padEnd(64, '\0')); + + // Check exact match with base domain + const exactMatchResult = domain.length === baseDomain.length && + crypto.timingSafeEqual(exactBuffer.subarray(0, baseDomain.length), baseDomainBuffer.subarray(0, baseDomain.length)); + + // Check wildcard match (domain ends with .baseDomain) + const wildcardMatchResult = domain.endsWith('.' + baseDomain); + + domainMatches = exactMatchResult || wildcardMatchResult; + } catch (error) { + // Fall back to regular comparison if timing-safe comparison fails + domainMatches = domain === baseDomain || domain.endsWith('.' + baseDomain); + } + } else { + // Regular domain comparison with constant-time comparison + try { + const domainBuffer = Buffer.from(domain.padEnd(64, '\0')); + const allowedBuffer = Buffer.from(normalizedAllowed.padEnd(64, '\0')); + + domainMatches = domain.length === normalizedAllowed.length && + crypto.timingSafeEqual(domainBuffer.subarray(0, domain.length), allowedBuffer.subarray(0, normalizedAllowed.length)); + } catch (error) { + // Fall back to regular comparison if timing-safe comparison fails + domainMatches = domain === normalizedAllowed; + } + } + + // Use bitwise OR with numeric conversion for true constant-time operation + isAllowed = Boolean((isAllowed ? 1 : 0) | (domainMatches ? 1 : 0)); + } + + return isAllowed; + } + + /** + * Get the default role ID for new SSO users + */ + public getDefaultRoleId(): number { + return this.default_role_id || 2; // Default to Reviewer role (ID: 2) + } + + /** + * Convert to provider configuration format + */ + public toProviderConfig(): SSOProviderConfig { + const providerSpecificConfig = this.getProviderConfig() || {}; + + return { + providerId: this.provider_id, + providerType: this.provider_type, + organizationId: this.organization_id, + isEnabled: this.is_enabled, + cloudEnvironment: this.cloud_environment, + clientId: this.client_id, + clientSecret: this.getDecryptedSecret(), + allowedDomains: this.allowed_domains, + defaultRoleId: this.default_role_id, + scopes: this.scopes, + customParameters: {}, + createdAt: this.created_at, + updatedAt: this.updated_at, + ...providerSpecificConfig + }; + } + + /** + * Create from provider configuration format + */ + public static fromProviderConfig(config: SSOProviderConfig): Partial { + const { + providerId, + providerType, + organizationId, + isEnabled, + cloudEnvironment, + clientId, + clientSecret, + allowedDomains, + defaultRoleId, + scopes, + customParameters, + ...providerSpecificConfig + } = config; + + return { + provider_id: providerId, + provider_type: providerType, + organization_id: organizationId, + is_enabled: isEnabled, + cloud_environment: cloudEnvironment, + client_id: clientId, + client_secret: clientSecret, + allowed_domains: allowedDomains, + default_role_id: defaultRoleId, + scopes: scopes, + provider_config: JSON.stringify({ + ...providerSpecificConfig, + customParameters + }), + provider_name: `${providerType}_${providerId}` + }; + } + + /** + * Validate the configuration before saving + */ + public async validateConfiguration(): Promise { + // Validate provider type + if (!Object.values(SSOProviderType).includes(this.provider_type)) { + throw new Error(`Invalid provider type: ${this.provider_type}`); + } + + // Validate required fields + if (!this.client_id || !this.client_secret) { + throw new Error('Client ID and Client Secret are required.'); + } + + // Provider-specific validation + switch (this.provider_type) { + case SSOProviderType.AZURE_AD: + await this.validateAzureADConfig(); + break; + case SSOProviderType.GOOGLE: + await this.validateGoogleConfig(); + break; + case SSOProviderType.SAML: + await this.validateSAMLConfig(); + break; + } + + // Validate default_role_id exists + if (this.default_role_id) { + if (this.default_role_id < 1 || this.default_role_id > 4) { + throw new Error('Invalid default role ID. Must be between 1-4.'); + } + } + } + + /** + * Validate Azure AD specific configuration + */ + private async validateAzureADConfig(): Promise { + const config = this.getProviderConfig<{ tenantId?: string }>(); + + if (!config?.tenantId) { + throw new Error('Azure AD Tenant ID is required.'); + } + + // Validate GUID format for tenant_id and client_id + const guidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + + if (!guidRegex.test(config.tenantId)) { + throw new Error('Azure tenant ID must be a valid GUID format.'); + } + + if (!guidRegex.test(this.client_id)) { + throw new Error('Azure client ID must be a valid GUID format.'); + } + + if (this.cloud_environment !== CloudEnvironment.AZURE_PUBLIC && + this.cloud_environment !== CloudEnvironment.AZURE_GOVERNMENT) { + throw new Error('Cloud environment must be azure_public or azure_government for Azure AD.'); + } + } + + /** + * Validate Google specific configuration + */ + private async validateGoogleConfig(): Promise { + // Google-specific validation logic + if (this.cloud_environment !== CloudEnvironment.GOOGLE_PUBLIC && + this.cloud_environment !== CloudEnvironment.PUBLIC) { + throw new Error('Cloud environment must be google_public or public for Google.'); + } + } + + /** + * Validate SAML specific configuration + */ + private async validateSAMLConfig(): Promise { + const config = this.getProviderConfig<{ + ssoUrl?: string; + metadataUrl?: string; + entityId?: string; + }>(); + + if (!config?.ssoUrl && !config?.metadataUrl) { + throw new Error('SAML SSO URL or Metadata URL is required.'); + } + + if (!config?.entityId) { + throw new Error('SAML Entity ID is required.'); + } + } + + /** + * Update last used timestamp + */ + public async markAsUsed(): Promise { + this.last_used_at = new Date(); + await this.save(); + } +} + +export default UnifiedSSOConfigurationModel; \ No newline at end of file diff --git a/Servers/domain.layer/models/user/user.model.ts b/Servers/domain.layer/models/user/user.model.ts index baa64e0c7..d12b21dd6 100644 --- a/Servers/domain.layer/models/user/user.model.ts +++ b/Servers/domain.layer/models/user/user.model.ts @@ -77,6 +77,25 @@ export class UserModel extends Model { }) organization_id?: number; + @Column({ + type: DataType.BOOLEAN, + allowNull: false, + defaultValue: false, + }) + sso_enabled?: boolean; + + @Column({ + type: DataType.STRING(255), + allowNull: true, + }) + azure_ad_object_id?: string; + + @Column({ + type: DataType.DATE, + allowNull: true, + }) + sso_last_login?: Date; + static async createNewUser( name: string, surname: string, diff --git a/Servers/factories/sso-provider.factory.ts b/Servers/factories/sso-provider.factory.ts new file mode 100644 index 000000000..41f266cf5 --- /dev/null +++ b/Servers/factories/sso-provider.factory.ts @@ -0,0 +1,599 @@ +/** + * @fileoverview SSO Provider Factory Implementation + * + * Comprehensive factory implementation using the Factory and Singleton patterns + * for creating, managing, and orchestrating multiple SSO provider instances. + * Provides a centralized registry for all supported SSO providers with + * configuration validation, health checking, and resilient initialization. + * + * This factory enables: + * - Dynamic provider registration and lifecycle management + * - Type-safe provider creation with configuration validation + * - Resilient initialization with retry logic and error handling + * - Provider health monitoring and status reporting + * - Batch operations for multi-provider environments + * - Configuration templates for rapid provider setup + * + * Architecture Patterns: + * - Factory Pattern: Creates provider instances based on configuration + * - Singleton Pattern: Ensures single factory instance across application + * - Registry Pattern: Maintains central provider type registration + * - Template Method: Provides configuration templates for providers + * - Strategy Pattern: Allows different provider implementations + * + * Key Features: + * - Provider registry with enable/disable capabilities + * - Configuration validation before provider creation + * - Exponential backoff retry logic for resilient initialization + * - Comprehensive error categorization and handling + * - Health checking for all registered providers + * - Batch provider creation with error collection + * + * Security Features: + * - Configuration validation prevents invalid provider creation + * - Error handling prevents sensitive information disclosure + * - Provider isolation through instance creation + * - Secure configuration templates with appropriate defaults + * + * @author VerifyWise Development Team + * @since 2024-09-28 + * @version 1.0.0 + * @see {@link ISSOProviderFactory} Factory interface specification + * @see {@link BaseSSOProvider} Base provider implementation + * + * @module factories/sso-provider + */ + +import { + ISSOProvider, + ISSOProviderFactory, + SSOProviderType, + SSOProviderConfig, + SSOError, + SSOErrorType +} from '../interfaces/sso-provider.interface'; + +// Provider implementations (will be created) +import { AzureADSSOProvider } from '../providers/azure-ad-sso.provider'; +// import { GoogleSSOProvider } from '../providers/google-sso.provider'; +// import { SAMLSSOProvider } from '../providers/saml-sso.provider'; + +/** + * Provider constructor interface for type-safe instantiation + * + * Defines the constructor signature required for all SSO provider classes + * to ensure consistent instantiation patterns across different providers. + * + * @interface ProviderConstructor + */ +interface ProviderConstructor { + new (providerId: string): ISSOProvider; +} + +/** + * Provider registry entry containing metadata and constructor + * + * Registry entry structure that stores provider metadata, capabilities, + * and constructor reference for factory-based provider instantiation. + * + * @interface ProviderRegistryEntry + * @property {ProviderConstructor} constructor - Provider class constructor + * @property {boolean} isEnabled - Whether provider is available for use + * @property {string} description - Human-readable provider description + * @property {string[]} supportedEnvironments - Supported cloud environments + */ +interface ProviderRegistryEntry { + constructor: ProviderConstructor; + isEnabled: boolean; + description: string; + supportedEnvironments: string[]; +} + +/** + * SSO Provider Factory implementation using Singleton and Factory patterns + * + * Centralized factory for creating and managing SSO provider instances across + * the application. Implements singleton pattern to ensure consistent provider + * registry and factory methods throughout the application lifecycle. + * + * Features: + * - Singleton pattern for application-wide factory consistency + * - Provider registry with dynamic registration and management + * - Type-safe provider creation with comprehensive validation + * - Resilient initialization with exponential backoff retry logic + * - Batch operations for multi-provider environments + * - Health checking and monitoring capabilities + * + * @class SSOProviderFactory + * @implements {ISSOProviderFactory} + * @singleton + * + * @example + * ```typescript + * // Get factory instance + * const factory = SSOProviderFactory.getInstance(); + * + * // Create Azure AD provider + * const azureProvider = await factory.createProvider({ + * providerType: SSOProviderType.AZURE_AD, + * providerId: 'azure-main', + * organizationId: 123, + * clientId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + * clientSecret: 'encrypted_secret', + * tenantId: 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy', + * isEnabled: true, + * cloudEnvironment: CloudEnvironment.AZURE_PUBLIC + * }); + * + * // Validate configuration before creation + * const validation = await factory.validateProviderConfig(config); + * if (!validation.valid) { + * console.error('Configuration errors:', validation.errors); + * } + * ``` + */ +export class SSOProviderFactory implements ISSOProviderFactory { + /** Singleton instance */ + private static instance: SSOProviderFactory; + + /** Provider registry mapping provider types to their implementations */ + private providerRegistry: Map = new Map(); + + /** + * Singleton pattern - get factory instance + */ + public static getInstance(): SSOProviderFactory { + if (!SSOProviderFactory.instance) { + SSOProviderFactory.instance = new SSOProviderFactory(); + SSOProviderFactory.instance.registerDefaultProviders(); + } + return SSOProviderFactory.instance; + } + + /** + * Private constructor for singleton pattern + */ + private constructor() {} + + /** + * Register default providers + */ + private registerDefaultProviders(): void { + // Register Azure AD provider + this.registerProvider(SSOProviderType.AZURE_AD, { + constructor: AzureADSSOProvider, + isEnabled: true, + description: 'Microsoft Azure Active Directory SSO Provider', + supportedEnvironments: ['azure_public', 'azure_government'] + }); + + // Future providers can be registered here + /* + this.registerProvider(SSOProviderType.GOOGLE, { + constructor: GoogleSSOProvider, + isEnabled: false, // Disabled until implemented + description: 'Google Workspace SSO Provider', + supportedEnvironments: ['google_public'] + }); + + this.registerProvider(SSOProviderType.SAML, { + constructor: SAMLSSOProvider, + isEnabled: false, // Disabled until implemented + description: 'Generic SAML 2.0 SSO Provider', + supportedEnvironments: ['public', 'private'] + }); + */ + } + + /** + * Register a new provider type + */ + public registerProvider(providerType: SSOProviderType, entry: ProviderRegistryEntry): void { + this.providerRegistry.set(providerType, entry); + } + + /** + * Unregister a provider type + */ + public unregisterProvider(providerType: SSOProviderType): void { + this.providerRegistry.delete(providerType); + } + + /** + * Creates a fully initialized SSO provider instance from configuration + * + * Main factory method that creates, validates, and initializes SSO provider + * instances. Includes comprehensive error handling, retry logic, and + * validation to ensure robust provider creation. + * + * @async + * @param {SSOProviderConfig} config - Complete provider configuration + * @returns {Promise} Fully initialized provider instance + * @throws {SSOError} Configuration, network, or provider-specific errors + * + * @process + * 1. Validates provider type support and configuration + * 2. Creates provider instance using registered constructor + * 3. Initializes provider with retry logic for resilience + * 4. Returns ready-to-use provider instance + * + * @example + * ```typescript + * try { + * const provider = await factory.createProvider({ + * providerType: SSOProviderType.AZURE_AD, + * providerId: 'azure-primary', + * organizationId: 123, + * clientId: 'client-id-here', + * clientSecret: 'encrypted-secret', + * tenantId: 'tenant-id-here', + * isEnabled: true, + * cloudEnvironment: CloudEnvironment.AZURE_PUBLIC + * }); + * + * // Provider is ready for authentication + * const loginUrl = await provider.getLoginUrl(req, '123'); + * } catch (error) { + * if (error instanceof SSOError) { + * console.error(`SSO Error: ${error.message} (${error.errorType})`); + * } + * } + * ``` + */ + async createProvider(config: SSOProviderConfig): Promise { + // Validate provider type + if (!this.isProviderSupported(config.providerType)) { + throw new SSOError( + SSOErrorType.CONFIGURATION_ERROR, + `Unsupported provider type: ${config.providerType}`, + config.providerType + ); + } + + const registryEntry = this.providerRegistry.get(config.providerType); + if (!registryEntry) { + throw new SSOError( + SSOErrorType.CONFIGURATION_ERROR, + `Provider type not found in registry: ${config.providerType}`, + config.providerType + ); + } + + if (!registryEntry.isEnabled) { + throw new SSOError( + SSOErrorType.CONFIGURATION_ERROR, + `Provider type is disabled: ${config.providerType}`, + config.providerType + ); + } + + // Validate provider ID + if (!config.providerId || config.providerId.trim().length === 0) { + throw new SSOError( + SSOErrorType.CONFIGURATION_ERROR, + 'Provider ID is required', + config.providerType + ); + } + + try { + // Create provider instance + const provider = new registryEntry.constructor(config.providerId); + + // Initialize provider with configuration with retry logic + await this.initializeProviderWithRetry(provider, config); + + return provider; + } catch (error) { + // Differentiate between configuration errors and provider instantiation failures + if (error instanceof SSOError) { + // Re-throw SSO errors with additional context + throw new SSOError( + error.errorType, + `Provider factory error: ${error.message}`, + config.providerType, + error.originalError + ); + } + + // Handle specific error types + if (error instanceof TypeError) { + throw new SSOError( + SSOErrorType.CONFIGURATION_ERROR, + `Provider constructor error: ${error.message}`, + config.providerType, + error + ); + } + + if (error instanceof ReferenceError) { + throw new SSOError( + SSOErrorType.CONFIGURATION_ERROR, + `Provider dependency error: ${error.message}`, + config.providerType, + error + ); + } + + // Network or external service errors (transient) + if (error instanceof Error && ( + error.message.includes('ECONNREFUSED') || + error.message.includes('ENOTFOUND') || + error.message.includes('timeout') + )) { + throw new SSOError( + SSOErrorType.NETWORK_ERROR, + `Provider initialization network error: ${error.message}`, + config.providerType, + error + ); + } + + // Generic error fallback + throw new SSOError( + SSOErrorType.PROVIDER_ERROR, + `Failed to create provider instance: ${error instanceof Error ? error.message : 'Unknown error'}`, + config.providerType, + error instanceof Error ? error : undefined + ); + } + } + + /** + * Get supported provider types + */ + getSupportedProviders(): SSOProviderType[] { + return Array.from(this.providerRegistry.keys()).filter( + providerType => this.providerRegistry.get(providerType)?.isEnabled === true + ); + } + + /** + * Validate provider type support + */ + isProviderSupported(providerType: SSOProviderType): boolean { + const entry = this.providerRegistry.get(providerType); + return entry !== undefined && entry.isEnabled; + } + + /** + * Get provider information + */ + getProviderInfo(providerType: SSOProviderType): ProviderRegistryEntry | null { + return this.providerRegistry.get(providerType) || null; + } + + /** + * Get all provider information + */ + getAllProviderInfo(): Map { + return new Map(this.providerRegistry); + } + + /** + * Enable a provider type + */ + enableProvider(providerType: SSOProviderType): void { + const entry = this.providerRegistry.get(providerType); + if (entry) { + entry.isEnabled = true; + } + } + + /** + * Disable a provider type + */ + disableProvider(providerType: SSOProviderType): void { + const entry = this.providerRegistry.get(providerType); + if (entry) { + entry.isEnabled = false; + } + } + + /** + * Validate provider configuration without creating instance + */ + async validateProviderConfig(config: Partial): Promise<{ valid: boolean; errors: string[] }> { + if (!config.providerType) { + return { + valid: false, + errors: ['Provider type is required'] + }; + } + + if (!this.isProviderSupported(config.providerType)) { + return { + valid: false, + errors: [`Unsupported provider type: ${config.providerType}`] + }; + } + + const registryEntry = this.providerRegistry.get(config.providerType); + if (!registryEntry) { + return { + valid: false, + errors: [`Provider type not found: ${config.providerType}`] + }; + } + + try { + // Create temporary provider instance for validation + const tempProviderId = `temp-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + const tempProvider = new registryEntry.constructor(tempProviderId); + + // Use provider's validation method + return await tempProvider.validateConfig(config); + } catch (error) { + return { + valid: false, + errors: [`Validation error: ${error instanceof Error ? error.message : 'Unknown error'}`] + }; + } + } + + /** + * Get default configuration template for a provider type + */ + getProviderConfigTemplate(providerType: SSOProviderType): Partial | null { + if (!this.isProviderSupported(providerType)) { + return null; + } + + const baseTemplate: Partial = { + providerType, + isEnabled: false, + allowedDomains: [], + defaultRoleId: 2, // Reviewer role + scopes: [] + }; + + // Provider-specific templates + switch (providerType) { + case SSOProviderType.AZURE_AD: + return { + ...baseTemplate, + cloudEnvironment: 'azure_public' as any, + scopes: ['openid', 'profile', 'email'], + customParameters: {} + }; + + case SSOProviderType.GOOGLE: + return { + ...baseTemplate, + cloudEnvironment: 'google_public' as any, + scopes: ['openid', 'profile', 'email'], + customParameters: {} + }; + + case SSOProviderType.SAML: + return { + ...baseTemplate, + cloudEnvironment: 'public' as any, + customParameters: { + 'SignRequests': 'true', + 'WantAssertionsSigned': 'true' + } + }; + + default: + return baseTemplate; + } + } + + /** + * Batch create multiple providers + */ + async createProviders(configs: SSOProviderConfig[]): Promise<{ + providers: ISSOProvider[]; + errors: { config: SSOProviderConfig; error: SSOError }[]; + }> { + const providers: ISSOProvider[] = []; + const errors: { config: SSOProviderConfig; error: SSOError }[] = []; + + for (const config of configs) { + try { + const provider = await this.createProvider(config); + providers.push(provider); + } catch (error) { + errors.push({ + config, + error: error instanceof SSOError ? error : new SSOError( + SSOErrorType.CONFIGURATION_ERROR, + `Failed to create provider: ${error instanceof Error ? error.message : 'Unknown error'}`, + config.providerType + ) + }); + } + } + + return { providers, errors }; + } + + /** + * Initialize provider with retry logic for resilient startup + */ + private async initializeProviderWithRetry( + provider: ISSOProvider, + config: SSOProviderConfig, + maxRetries: number = 3, + baseDelayMs: number = 1000 + ): Promise { + let lastError: Error | undefined; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + await provider.initialize(config); + return; // Success - exit retry loop + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + // Don't retry configuration errors - they won't be fixed by retrying + if (error instanceof SSOError && error.errorType === SSOErrorType.CONFIGURATION_ERROR) { + throw error; + } + + // If this is the last attempt, throw the error + if (attempt === maxRetries) { + throw new SSOError( + SSOErrorType.PROVIDER_ERROR, + `Provider initialization failed after ${maxRetries} attempts: ${lastError.message}`, + config.providerType, + lastError + ); + } + + // Exponential backoff with jitter for subsequent attempts + const delay = baseDelayMs * Math.pow(2, attempt - 1); + const jitter = Math.random() * 0.1 * delay; // Add up to 10% jitter + const totalDelay = delay + jitter; + + console.warn( + `SSO Provider initialization attempt ${attempt}/${maxRetries} failed for ${config.providerType}: ${lastError.message}. Retrying in ${Math.round(totalDelay)}ms...` + ); + + await new Promise(resolve => setTimeout(resolve, totalDelay)); + } + } + } + + /** + * Health check for all registered providers + */ + async healthCheckProviders(): Promise> { + const results = new Map(); + + for (const [providerType, entry] of this.providerRegistry) { + if (!entry.isEnabled) { + results.set(providerType, { + healthy: false, + message: 'Provider disabled' + }); + continue; + } + + try { + // Create a temporary provider instance for health check + const tempProviderId = `health-check-${Date.now()}`; + const tempProvider = new entry.constructor(tempProviderId); + + // Basic connectivity check without full initialization + results.set(providerType, { + healthy: true, + message: 'Provider available' + }); + } catch (error) { + results.set(providerType, { + healthy: false, + message: `Provider error: ${error instanceof Error ? error.message : 'Unknown error'}` + }); + } + } + + return results; + } +} + +// Export singleton instance +export const ssoProviderFactory = SSOProviderFactory.getInstance(); + +export default SSOProviderFactory; \ No newline at end of file diff --git a/Servers/index.ts b/Servers/index.ts index 891ecf267..557cf4c10 100644 --- a/Servers/index.ts +++ b/Servers/index.ts @@ -39,10 +39,14 @@ import subscriptionRoutes from "./routes/subscription.route"; import autoDriverRoutes from "./routes/autoDriver.route"; import taskRoutes from "./routes/task.route"; import slackWebhookRoutes from "./routes/slackWebhook.route"; +import ssoConfigurationRoutes from "./routes/ssoConfiguration.route"; +import ssoAuthRoutes from "./routes/ssoAuth.route"; +import ssoHealthRoutes from "./routes/sso-health.route"; import swaggerUi from "swagger-ui-express"; import YAML from "yamljs"; import { parseOrigins, testOrigin } from "./utils/parseOrigins.utils"; import { frontEndUrl } from "./config/constants"; +import { SSOEnvironmentValidator } from "./utils/sso-env-validator.utils"; const swaggerDoc = YAML.load("./swagger.yaml"); @@ -57,6 +61,15 @@ const host = process.env.HOST || DEFAULT_HOST; const port = parseInt(portString, 10); // Convert to number try { + // Validate SSO environment variables at startup + console.log('🔍 Validating SSO environment configuration...'); + SSOEnvironmentValidator.validateOrThrow(); + + // Display environment summary for debugging + if (process.env.NODE_ENV !== 'production') { + console.log('🔧 SSO Environment Summary:', SSOEnvironmentValidator.getEnvironmentSummary()); + } + // (async () => { // await checkAndCreateTables(); // })(); @@ -127,6 +140,9 @@ try { app.use("/api/docs", swaggerUi.serve, swaggerUi.setup(swaggerDoc)); app.use("/api/policies", policyRoutes); app.use("/api/slackWebhooks", slackWebhookRoutes); + app.use("/api/sso-configuration", ssoConfigurationRoutes); + app.use("/api/sso-auth", ssoAuthRoutes); + app.use("/api/sso-health", ssoHealthRoutes); app.listen(port, () => { console.log(`Server running on port http://${host}:${port}/`); diff --git a/Servers/interfaces/sso-provider.interface.ts b/Servers/interfaces/sso-provider.interface.ts new file mode 100644 index 000000000..299f29245 --- /dev/null +++ b/Servers/interfaces/sso-provider.interface.ts @@ -0,0 +1,499 @@ +/** + * @fileoverview SSO Provider Interface Definitions + * + * Comprehensive interface definitions for implementing multiple SSO providers + * in a unified, extensible architecture. Provides standardized contracts for + * Azure AD, Google, SAML, OIDC, and other authentication providers while + * maintaining provider-specific flexibility and security requirements. + * + * This interface system enables: + * - Unified API across different SSO providers + * - Type-safe provider implementations + * - Standardized error handling and result structures + * - Extensible configuration for new providers + * - Multi-tenant provider management + * - Provider-agnostic business logic + * + * Architecture Pattern: + * - ISSOProvider: Core interface for individual provider implementations + * - ISSOProviderFactory: Factory pattern for provider instantiation + * - ISSOManager: High-level orchestration of multiple providers + * - Standardized data structures for interoperability + * - Comprehensive error handling with typed error categories + * + * Security Features: + * - Encrypted configuration storage + * - Domain-based access control + * - CSRF protection with state tokens + * - PKCE support for OAuth 2.1 compliance + * - Comprehensive audit logging integration + * + * Usage Patterns: + * - Plugin-based provider architecture + * - Factory pattern for provider creation + * - Manager pattern for multi-provider orchestration + * - Event-driven audit logging + * - Configuration validation and testing + * + * @author VerifyWise Development Team + * @since 2024-09-28 + * @version 1.0.0 + * @see {@link https://tools.ietf.org/html/rfc6749} OAuth 2.0 Authorization Framework + * @see {@link https://openid.net/connect/} OpenID Connect Specification + * @see {@link https://docs.oasis-open.org/security/saml/} SAML 2.0 Specification + * + * @module interfaces/sso-provider + */ + +import { Request, Response } from 'express'; + +/** + * Supported SSO provider types for multi-provider authentication + * + * Enumeration of officially supported SSO providers in the system. + * Each provider type corresponds to a specific authentication protocol + * and implementation with provider-specific configuration requirements. + * + * @enum {string} SSOProviderType + * @readonly + * + * @example + * ```typescript + * // Check provider type in configuration + * if (config.providerType === SSOProviderType.AZURE_AD) { + * // Azure AD specific handling + * validateAzureADConfig(config); + * } + * ``` + */ +export enum SSOProviderType { + /** Microsoft Azure Active Directory (Entra ID) - OAuth 2.0/OpenID Connect */ + AZURE_AD = 'azure_ad', + + /** Google Workspace/Identity - OAuth 2.0/OpenID Connect */ + GOOGLE = 'google', + + /** Security Assertion Markup Language 2.0 */ + SAML = 'saml', + + /** Okta Identity Platform - OIDC/SAML */ + OKTA = 'okta', + + /** Ping Identity Platform - OIDC/SAML */ + PING_IDENTITY = 'ping_identity' +} + +/** + * Cloud environment types for different providers + */ +export enum CloudEnvironment { + // Azure environments + AZURE_PUBLIC = 'azure_public', + AZURE_GOVERNMENT = 'azure_government', + + // Google environments + GOOGLE_PUBLIC = 'google_public', + + // Generic environments + PUBLIC = 'public', + GOVERNMENT = 'government', + PRIVATE = 'private' +} + +/** + * Standardized user information returned from all SSO providers + * + * Normalized user data structure that abstracts provider-specific user information + * into a consistent format for application use. Ensures compatibility across + * different SSO providers while preserving provider-specific details. + * + * @interface SSOUserInfo + * @property {string} email - User's primary email address (normalized to lowercase) + * @property {string} firstName - User's first/given name + * @property {string} lastName - User's last/family name + * @property {string} [displayName] - User's preferred display name + * @property {string} providerId - Unique identifier from provider (Azure Object ID, Google ID, etc.) + * @property {SSOProviderType} providerType - Source provider type for correlation + * @property {string[]} [groups] - User groups/roles from provider for authorization + * @property {Record} [additionalClaims] - Provider-specific claims and attributes + * + * @example + * ```typescript + * const userInfo: SSOUserInfo = { + * email: 'john.doe@company.com', + * firstName: 'John', + * lastName: 'Doe', + * displayName: 'John Doe', + * providerId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + * providerType: SSOProviderType.AZURE_AD, + * groups: ['Developers', 'Admin'], + * additionalClaims: { + * department: 'Engineering', + * jobTitle: 'Senior Developer' + * } + * }; + * ``` + */ +export interface SSOUserInfo { + email: string; + firstName: string; + lastName: string; + displayName?: string; + providerId: string; // Unique ID from the provider (Azure Object ID, Google ID, etc.) + providerType: SSOProviderType; + groups?: string[]; // User groups/roles from provider + additionalClaims?: Record; // Provider-specific claims +} + +/** + * SSO provider configuration interface + */ +export interface SSOProviderConfig { + providerId: string; // Unique identifier for this provider instance + providerType: SSOProviderType; + organizationId: number; + isEnabled: boolean; + cloudEnvironment: CloudEnvironment; + + // Provider-specific configuration (encrypted) + clientId: string; + clientSecret: string; + + // Optional provider-specific fields + tenantId?: string; // Azure AD + domain?: string; // Google Workspace, SAML + metadataUrl?: string; // SAML + issuer?: string; // SAML, OIDC + + // Security settings + allowedDomains?: string[]; + defaultRoleId?: number; + + // Provider-specific settings + scopes?: string[]; + customParameters?: Record; + + // Timestamps + createdAt: Date; + updatedAt: Date; +} + +/** + * Authentication result from provider + */ +export interface SSOAuthResult { + success: boolean; + userInfo?: SSOUserInfo; + error?: string; + errorCode?: string; + redirectUrl?: string; // For multi-step flows + additionalData?: Record; +} + +/** + * Login initiation result + */ +export interface SSOLoginResult { + success: boolean; + authUrl?: string; + error?: string; + state?: string; // CSRF protection token + codeVerifier?: string; // PKCE for OAuth 2.1 +} + +/** + * Core SSO provider interface that all SSO providers must implement + * + * Defines the standardized contract for all SSO provider implementations, + * ensuring consistent behavior across different authentication providers + * while maintaining flexibility for provider-specific features. + * + * This interface enables: + * - Unified authentication flow across providers + * - Type-safe provider implementations + * - Consistent error handling and validation + * - Provider-agnostic business logic + * - Extensible configuration and features + * + * @interface ISSOProvider + * + * @example + * ```typescript + * class AzureADProvider implements ISSOProvider { + * readonly providerType = SSOProviderType.AZURE_AD; + * readonly providerId = 'azure-ad-main'; + * + * async initialize(config: SSOProviderConfig): Promise { + * // Initialize Azure AD MSAL client + * } + * + * async getLoginUrl(req: Request, orgId: string): Promise { + * // Generate Azure AD authorization URL + * } + * // ... implement other methods + * } + * ``` + */ +export interface ISSOProvider { + /** + * Provider type identification (immutable) + * + * Identifies the specific SSO provider type this implementation handles. + * Used for provider routing and type-specific logic. + */ + readonly providerType: SSOProviderType; + + /** + * Unique provider instance identifier (immutable) + * + * Distinguishes between multiple instances of the same provider type + * within an organization (e.g., multiple Azure AD tenants). + */ + readonly providerId: string; + + /** + * Initializes the provider with organization-specific configuration + * + * Sets up the provider instance with encrypted configuration data, + * validates settings, and prepares the provider for authentication operations. + * Must be called before any other provider methods. + * + * @param {SSOProviderConfig} config - Complete provider configuration + * @throws {SSOError} If configuration is invalid or initialization fails + * @returns {Promise} Resolves when initialization is complete + */ + initialize(config: SSOProviderConfig): Promise; + + /** + * Validates provider configuration without initializing + * + * Performs comprehensive validation of provider configuration including + * format validation, security checks, and connectivity testing. + * Used for configuration validation in admin interfaces. + * + * @param {Partial} config - Configuration to validate + * @returns {Promise<{valid: boolean; errors: string[]}>} Validation result with detailed errors + */ + validateConfig(config: Partial): Promise<{ valid: boolean; errors: string[] }>; + + /** + * Generates provider-specific login URL for SSO initiation + * + * Creates the authorization URL that redirects users to the SSO provider + * for authentication. Includes CSRF protection and provider-specific parameters. + * + * @param {Request} req - HTTP request for metadata extraction + * @param {string} organizationId - Organization identifier for multi-tenancy + * @param {Record} [additionalParams] - Provider-specific parameters + * @returns {Promise} Login URL and security tokens + */ + getLoginUrl(req: Request, organizationId: string, additionalParams?: Record): Promise; + + /** + * Handles callback from SSO provider after user authentication + * + * Processes the OAuth callback with authorization code or SAML response, + * validates state tokens, exchanges codes for user information, and + * returns standardized authentication result. + * + * @param {Request} req - HTTP request containing callback parameters + * @param {string} organizationId - Organization identifier for validation + * @returns {Promise} Authentication result with user information + */ + handleCallback(req: Request, organizationId: string): Promise; + + /** + * Exchanges authorization code for user information + * + * Performs the OAuth token exchange process, validating the authorization + * code and state token, then retrieving user profile information from + * the provider's user info endpoint. + * + * @param {string} authCode - Authorization code from provider callback + * @param {string} state - State token for CSRF validation + * @returns {Promise} User information and authentication status + */ + exchangeCodeForUser(authCode: string, state: string): Promise; + + /** + * Validates if user's email domain is allowed by provider configuration + * + * Checks the user's email domain against the organization's allowed domains + * configuration to enforce domain-based access control policies. + * + * @param {string} email - User email address to validate + * @returns {Promise} True if domain is allowed, false otherwise + */ + isEmailDomainAllowed(email: string): Promise; + + /** + * Returns provider-specific metadata URLs for external reference + * + * Provides the provider's well-known endpoints for debugging, monitoring, + * and integration purposes. URLs vary by provider type and configuration. + * + * @returns {Object} Provider metadata URLs + * @returns {string} [returns.authUrl] - Authorization endpoint URL + * @returns {string} [returns.tokenUrl] - Token exchange endpoint URL + * @returns {string} [returns.userInfoUrl] - User information endpoint URL + * @returns {string} [returns.logoutUrl] - Logout endpoint URL + */ + getMetadataUrls(): { authUrl?: string; tokenUrl?: string; userInfoUrl?: string; logoutUrl?: string }; + + /** + * Handles provider-specific logout process (optional) + * + * Initiates logout with the SSO provider, potentially redirecting to + * provider logout page for complete session termination. + * + * @param {Request} req - HTTP request for metadata extraction + * @param {string} organizationId - Organization identifier + * @returns {Promise<{success: boolean; logoutUrl?: string}>} Logout result with optional redirect URL + */ + handleLogout?(req: Request, organizationId: string): Promise<{ success: boolean; logoutUrl?: string }>; + + /** + * Validates provider-specific tokens (optional) + * + * Validates access tokens or ID tokens for token refresh scenarios + * or direct token validation without full authentication flow. + * + * @param {string} token - Provider token to validate + * @returns {Promise<{valid: boolean; userInfo?: SSOUserInfo}>} Validation result with user info + */ + validateToken?(token: string): Promise<{ valid: boolean; userInfo?: SSOUserInfo }>; + + /** + * Retrieves user groups/roles from provider (optional) + * + * Fetches user's group memberships or role assignments from the provider + * for authorization and role-based access control. + * + * @param {string} userId - Provider-specific user identifier + * @returns {Promise} Array of group/role names + */ + getUserGroups?(userId: string): Promise; + + /** + * Performs provider health check and connectivity test + * + * Validates provider connectivity, configuration health, and service + * availability for monitoring and diagnostics purposes. + * + * @returns {Promise<{healthy: boolean; message?: string}>} Health status with optional details + */ + healthCheck(): Promise<{ healthy: boolean; message?: string }>; +} + +/** + * Provider factory interface for creating provider instances + */ +export interface ISSOProviderFactory { + /** + * Create a provider instance + */ + createProvider(config: SSOProviderConfig): Promise; + + /** + * Get supported provider types + */ + getSupportedProviders(): SSOProviderType[]; + + /** + * Validate provider type support + */ + isProviderSupported(providerType: SSOProviderType): boolean; +} + +/** + * SSO manager interface for orchestrating multiple providers + */ +export interface ISSOManager { + /** + * Register a new SSO provider for an organization + */ + registerProvider(config: SSOProviderConfig): Promise<{ success: boolean; error?: string }>; + + /** + * Get provider for organization + */ + getProvider(organizationId: number, providerType?: SSOProviderType): Promise; + + /** + * Get all providers for organization + */ + getProviders(organizationId: number): Promise; + + /** + * Update provider configuration + */ + updateProvider(organizationId: number, providerId: string, config: Partial): Promise<{ success: boolean; error?: string }>; + + /** + * Remove provider + */ + removeProvider(organizationId: number, providerId: string): Promise<{ success: boolean; error?: string }>; + + /** + * Get login URL for primary provider + */ + getLoginUrl(req: Request, organizationId: number, providerType?: SSOProviderType): Promise; + + /** + * Handle callback from any provider + */ + handleCallback(req: Request, organizationId: number, providerType: SSOProviderType): Promise; + + /** + * Check if SSO is enabled for organization + */ + isSSOEnabled(organizationId: number): Promise; +} + +/** + * Error types for SSO operations + */ +export enum SSOErrorType { + CONFIGURATION_ERROR = 'configuration_error', + AUTHENTICATION_FAILED = 'authentication_failed', + INVALID_TOKEN = 'invalid_token', + INVALID_STATE = 'invalid_state', + DOMAIN_NOT_ALLOWED = 'domain_not_allowed', + PROVIDER_ERROR = 'provider_error', + NETWORK_ERROR = 'network_error', + RATE_LIMIT_EXCEEDED = 'rate_limit_exceeded' +} + +/** + * Custom SSO error class + */ +export class SSOError extends Error { + constructor( + public readonly errorType: SSOErrorType, + public readonly message: string, + public readonly providerType?: SSOProviderType, + public readonly originalError?: Error + ) { + super(message); + this.name = 'SSOError'; + } +} + +/** + * SSO event types for audit logging + */ +export enum SSOEventType { + LOGIN_INITIATED = 'login_initiated', + LOGIN_SUCCESS = 'login_success', + LOGIN_FAILED = 'login_failed', + CALLBACK_PROCESSED = 'callback_processed', + USER_CREATED = 'user_created', + USER_UPDATED = 'user_updated', + CONFIGURATION_UPDATED = 'configuration_updated', + PROVIDER_ENABLED = 'provider_enabled', + PROVIDER_DISABLED = 'provider_disabled', + DOMAIN_VALIDATION_FAILED = 'domain_validation_failed', + TOKEN_EXCHANGE_FAILED = 'token_exchange_failed' +} + +export default ISSOProvider; \ No newline at end of file diff --git a/Servers/middleware/auth.middleware.ts b/Servers/middleware/auth.middleware.ts index 3ab20a4e9..eb77bd379 100644 --- a/Servers/middleware/auth.middleware.ts +++ b/Servers/middleware/auth.middleware.ts @@ -17,7 +17,15 @@ const authenticateJWT = async ( res: Response, next: NextFunction ): Promise => { - const token = req.headers.authorization?.split(" ")[1]; + // Check for token in Authorization header (regular auth) or cookies (SSO auth) + let token = req.headers.authorization?.split(" ")[1]; + let isFromCookie = false; + + // If no token in header, check httpOnly cookie (SSO authentication) + if (!token && req.cookies?.auth_token) { + token = req.cookies.auth_token; + isFromCookie = true; + } if (!token) { return res.status(400).json( @@ -70,6 +78,7 @@ const authenticateJWT = async ( req.role = decoded.roleName; req.tenantId = decoded.tenantId; req.organizationId = decoded.organizationId; + req.ssoEnabled = decoded.ssoEnabled || false; // Initialize AsyncLocalStorage context here asyncLocalStorage.run({ userId: decoded.id }, () => { diff --git a/Servers/middleware/rateLimiting.middleware.ts b/Servers/middleware/rateLimiting.middleware.ts new file mode 100644 index 000000000..ebe1c192f --- /dev/null +++ b/Servers/middleware/rateLimiting.middleware.ts @@ -0,0 +1,166 @@ +import rateLimit from 'express-rate-limit'; +import { Request, Response } from 'express'; +import { SSOAuditLogger } from '../utils/sso-audit-logger.utils'; + +/** + * Rate limiting middleware for SSO authentication endpoints + * Implements multiple tiers of rate limiting for different endpoints + */ + +/** + * Strict rate limiting for SSO login initiation + * Prevents brute force attacks on SSO endpoints + */ +export const ssoLoginRateLimit = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 10, // Limit each IP to 10 requests per windowMs for login initiation + message: { + success: false, + error: 'Too many SSO login attempts. Please try again in 15 minutes.', + retryAfter: 15 * 60 * 1000 + }, + standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers + legacyHeaders: false, // Disable the `X-RateLimit-*` headers + keyGenerator: (req: Request, defaultGenerator: any) => { + // Use default generator for proper IPv6 handling, then customize with organization + const organizationId = req.params.organizationId || 'unknown'; + const baseKey = defaultGenerator(req); + return `sso_login:${baseKey}:${organizationId}`; + }, + handler: (req: Request, res: Response) => { + const organizationId = req.params.organizationId || 'unknown'; + console.warn(`SSO login rate limit exceeded for IP ${req.ip} and organization ${organizationId}`); + + // Audit log the rate limit violation + SSOAuditLogger.logRateLimitExceeded(req, organizationId, 'login'); + + res.status(429).json({ + success: false, + error: 'Too many SSO login attempts. Please try again in 15 minutes.', + retryAfter: 15 * 60 * 1000 + }); + } +}); + +/** + * Very strict rate limiting for SSO callback endpoint + * Callbacks should be less frequent than login initiations + */ +export const ssoCallbackRateLimit = rateLimit({ + windowMs: 5 * 60 * 1000, // 5 minutes + max: 5, // Limit each IP to 5 requests per windowMs for callbacks + message: { + success: false, + error: 'Too many SSO callback attempts. Please try again in 5 minutes.', + retryAfter: 5 * 60 * 1000 + }, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req: Request, defaultGenerator: any) => { + const organizationId = req.params.organizationId || 'unknown'; + const baseKey = defaultGenerator(req); + return `sso_callback:${baseKey}:${organizationId}`; + }, + handler: (req: Request, res: Response) => { + const organizationId = req.params.organizationId || 'unknown'; + console.warn(`SSO callback rate limit exceeded for IP ${req.ip} and organization ${organizationId}`); + + // Audit log the rate limit violation + SSOAuditLogger.logRateLimitExceeded(req, organizationId, 'callback'); + + // Redirect to frontend with error instead of JSON response + const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3001'; + res.redirect(`${frontendUrl}/login?error=rate_limit_exceeded`); + } +}); + +/** + * Moderate rate limiting for SSO configuration endpoints + * Administrative endpoints need protection but higher limits + */ +export const ssoConfigRateLimit = rateLimit({ + windowMs: 10 * 60 * 1000, // 10 minutes + max: 30, // Limit each IP to 30 requests per windowMs for config operations + message: { + success: false, + error: 'Too many SSO configuration requests. Please try again in 10 minutes.', + retryAfter: 10 * 60 * 1000 + }, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req: Request, defaultGenerator: any) => { + // Include user ID if available for authenticated requests + const userId = (req as any).user?.userId || 'anonymous'; + const baseKey = defaultGenerator(req); + return `sso_config:${baseKey}:${userId}`; + }, + handler: (req: Request, res: Response) => { + const userId = (req as any).user?.userId || 'anonymous'; + const organizationId = req.params.organizationId || 'unknown'; + console.warn(`SSO config rate limit exceeded for IP ${req.ip} and user ${userId}`); + + // Audit log the rate limit violation + SSOAuditLogger.logRateLimitExceeded(req, organizationId, 'configuration'); + + res.status(429).json({ + success: false, + error: 'Too many SSO configuration requests. Please try again in 10 minutes.', + retryAfter: 10 * 60 * 1000 + }); + } +}); + +/** + * General rate limiting for all SSO-related endpoints + * Fallback protection for any SSO endpoint + */ +export const generalSsoRateLimit = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 50, // Limit each IP to 50 requests per windowMs for general SSO operations + message: { + success: false, + error: 'Too many SSO requests. Please try again later.', + retryAfter: 15 * 60 * 1000 + }, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req: Request, defaultGenerator: any) => { + const baseKey = defaultGenerator(req); + return `sso_general:${baseKey}`; + }, + handler: (req: Request, res: Response) => { + const organizationId = req.params.organizationId || 'unknown'; + console.warn(`General SSO rate limit exceeded for IP ${req.ip}`); + + // Audit log the rate limit violation + SSOAuditLogger.logRateLimitExceeded(req, organizationId, 'general'); + + res.status(429).json({ + success: false, + error: 'Too many SSO requests. Please try again later.', + retryAfter: 15 * 60 * 1000 + }); + } +}); + +/** + * Rate limiting configuration for Redis store (optional) + * Uncomment and configure if Redis is available + */ +/* +import RedisStore from 'rate-limit-redis'; +import { createClient } from 'redis'; + +const redisClient = createClient({ + url: process.env.REDIS_URL || 'redis://localhost:6379' +}); + +export const ssoLoginRateLimitRedis = rateLimit({ + store: new RedisStore({ + sendCommand: (...args: string[]) => redisClient.sendCommand(args), + }), + windowMs: 15 * 60 * 1000, + max: 10, + // ... other options +}); +*/ \ No newline at end of file diff --git a/Servers/package-lock.json b/Servers/package-lock.json index a7e77c0a2..5acb069a4 100644 --- a/Servers/package-lock.json +++ b/Servers/package-lock.json @@ -10,7 +10,9 @@ "license": "ISC", "dependencies": { "@awaismirza/bypass-cors": "^1.1.2", + "@azure/msal-node": "^3.8.0", "@slack/web-api": "^7.10.0", + "@types/express-rate-limit": "^5.1.3", "@types/passport-jwt": "^4.0.1", "bcrypt": "^5.1.1", "bull": "^4.16.5", @@ -19,7 +21,9 @@ "crypto": "^1.0.1", "dotenv": "^16.4.5", "express": "^4.21.2", + "express-rate-limit": "^8.1.0", "express-validator": "^7.2.1", + "google-auth-library": "^10.3.0", "helmet": "^8.0.0", "html-to-docx": "^1.8.0", "http-proxy-middleware": "^3.0.5", @@ -54,12 +58,14 @@ "@types/node": "^22.15.33", "@types/nodemailer": "^6.4.17", "@types/pg": "^8.11.10", + "@types/supertest": "^6.0.3", "@types/swagger-ui-express": "^4.1.6", "@types/winston": "^2.4.4", "@types/yamljs": "^0.2.34", "jest": "^30.0.2", "npm-run-all": "^4.1.5", "sequelize-cli": "^6.6.2", + "supertest": "^7.1.4", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", "ts-jest": "^29.4.0", @@ -151,6 +157,29 @@ "bypass-cors": "bin/index.js" } }, + "node_modules/@azure/msal-common": { + "version": "15.13.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.13.0.tgz", + "integrity": "sha512-8oF6nj02qX7eE/6+wFT5NluXRHc05AgdCC3fJnkjiJooq8u7BcLmxaYYSwc2AfEkWRMRi6Eyvvbeqk4U4412Ag==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-3.8.0.tgz", + "integrity": "sha512-23BXm82Mp5XnRhrcd4mrHa0xuUNRp96ivu3nRatrfdAqjoeWAGyD0eEAafxAOHAEWWmdlyFK4ELFcdziXyw2sA==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "15.13.0", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -1886,6 +1915,19 @@ "@tybys/wasm-util": "^0.9.0" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@one-ini/wasm": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", @@ -1979,6 +2021,16 @@ "node": ">=8.0" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", + "integrity": "sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2293,6 +2345,13 @@ "@types/express": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cors": { "version": "2.8.17", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", @@ -2321,6 +2380,15 @@ "@types/serve-static": "*" } }, + "node_modules/@types/express-rate-limit": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-rate-limit/-/express-rate-limit-5.1.3.tgz", + "integrity": "sha512-H+TYy3K53uPU2TqPGFYaiWc2xJV6+bIFkDd/Ma2/h67Pa6ARk9kWE0p/K9OH1Okm0et9Sfm66fmXoAxsH2PHXg==", + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/express-serve-static-core": { "version": "4.19.5", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.5.tgz", @@ -2409,6 +2477,13 @@ "@types/node": "*" } }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -2600,6 +2675,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", + "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/swagger-ui-express": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.6.tgz", @@ -3191,6 +3290,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -3410,6 +3516,26 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/bcrypt": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", @@ -3423,6 +3549,15 @@ "node": ">= 10.0.0" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -4012,6 +4147,16 @@ "node": ">= 6" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -4110,6 +4255,13 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -4209,6 +4361,15 @@ "node": ">=0.12" } }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -4394,6 +4555,17 @@ "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", "license": "MIT" }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -5091,6 +5263,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.1.0.tgz", + "integrity": "sha512-4nLnATuKupnmwqiJc27b4dCFmB/T60ExgmtDD7waf4LdrbJ8CPZzZRHYErDYNhoz+ql8fUdYwM/opf90PoPAQA==", + "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express-validator": { "version": "7.2.1", "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.1.tgz", @@ -5112,6 +5302,12 @@ "type": "^2.7.2" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-content-type-parse": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz", @@ -5187,6 +5383,13 @@ "node": ">=6" } }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-uri": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.4.0.tgz", @@ -5257,6 +5460,29 @@ "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==" }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-stream-rotator": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/file-stream-rotator/-/file-stream-rotator-0.6.1.tgz", @@ -5440,6 +5666,36 @@ "node": ">= 6" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -5579,6 +5835,97 @@ "node": ">=10" } }, + "node_modules/gaxios": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.2.tgz", + "integrity": "sha512-/Szrn8nr+2TsQT1Gp8iIe/BEytJmbyfrbFh419DfGQSkEgNEhbPi7JRJuughjkTzPWgU9gBQf5AVu3DbHt0OXA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gaxios/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/gaxios/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/gaxios/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/gcp-metadata": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-7.0.1.tgz", + "integrity": "sha512-UcO3kefx6dCcZkgcTGgVOTFb7b1LlQ02hY1omMjjrrBzkajRMCFgYOjs7J71WqnuG1k2b+9ppGL7FsOfhZMQKQ==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -5753,6 +6100,54 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/google-auth-library": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.3.0.tgz", + "integrity": "sha512-ylSE3RlCRZfZB56PFJSfUCuiuPq83Fx8hqu1KPWGK8FVdSaxlp/qkeMMX/DT/18xkwXIHvXEXkZsljRwfrdEfQ==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^7.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library/node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/google-auth-library/node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.1.tgz", + "integrity": "sha512-rcX58I7nqpu4mbKztFeOAObbomBbHU2oIb/d3tJfF3dizGSApqtSwYJigGCooHdnMyQBIw8BrWyK96w3YXgr6A==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -5770,6 +6165,40 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, + "node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gtoken/node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/gtoken/node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -6358,6 +6787,15 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -8474,6 +8912,15 @@ "node": ">=6" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-parse-better-errors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", @@ -9765,6 +10212,26 @@ "node": ">=6.0.0" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -12188,6 +12655,79 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/superagent": { + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.3.tgz", + "integrity": "sha512-y/hkYGeXAj7wUMjxRbB21g/l6aAEituGXM9Rwl4o20+SX3e8YOSV6BxFXl+dL3Uk0mjSL3kCbNkwURm8/gEDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.4", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.11.2" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.1.4.tgz", + "integrity": "sha512-tjLPs7dVyqgItVFirHYqe2T+MfWc2VOBQ8QFKKbWTA3PU7liZR8zoSpAi/C1k1ilm9RsXIKYf197oap9wXGVYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "methods": "^1.1.2", + "superagent": "^10.2.3" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -13106,6 +13646,15 @@ "node": ">=4.0.0" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/Servers/package.json b/Servers/package.json index 96483b5f7..9c7168fc0 100644 --- a/Servers/package.json +++ b/Servers/package.json @@ -17,7 +17,9 @@ "description": "", "dependencies": { "@awaismirza/bypass-cors": "^1.1.2", + "@azure/msal-node": "^3.8.0", "@slack/web-api": "^7.10.0", + "@types/express-rate-limit": "^5.1.3", "@types/passport-jwt": "^4.0.1", "bcrypt": "^5.1.1", "bull": "^4.16.5", @@ -26,7 +28,9 @@ "crypto": "^1.0.1", "dotenv": "^16.4.5", "express": "^4.21.2", + "express-rate-limit": "^8.1.0", "express-validator": "^7.2.1", + "google-auth-library": "^10.3.0", "helmet": "^8.0.0", "html-to-docx": "^1.8.0", "http-proxy-middleware": "^3.0.5", @@ -61,12 +65,14 @@ "@types/node": "^22.15.33", "@types/nodemailer": "^6.4.17", "@types/pg": "^8.11.10", + "@types/supertest": "^6.0.3", "@types/swagger-ui-express": "^4.1.6", "@types/winston": "^2.4.4", "@types/yamljs": "^0.2.34", "jest": "^30.0.2", "npm-run-all": "^4.1.5", "sequelize-cli": "^6.6.2", + "supertest": "^7.1.4", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", "ts-jest": "^29.4.0", diff --git a/Servers/providers/azure-ad-sso.provider.ts b/Servers/providers/azure-ad-sso.provider.ts new file mode 100644 index 000000000..ad4d6dffc --- /dev/null +++ b/Servers/providers/azure-ad-sso.provider.ts @@ -0,0 +1,971 @@ +/** + * @fileoverview Azure AD (Entra ID) SSO Provider Implementation + * + * This module provides a comprehensive Azure Active Directory integration for Single Sign-On (SSO) + * authentication. It implements the unified SSO provider interface to support multi-cloud + * environments and various Azure AD configurations. + * + * Key Features: + * - Full OAuth 2.0 Authorization Code Flow implementation + * - Support for Azure Public Cloud and Azure Government Cloud + * - Rate limiting and security validation + * - Automatic user provisioning and attribute mapping + * - Comprehensive error handling and audit logging + * - CSRF protection with secure state token management + * + * Architecture: + * - Extends BaseSSOProvider for consistent behavior across providers + * - Uses Microsoft Authentication Library (MSAL) for Azure AD integration + * - Implements tenant-specific configuration and validation + * - Provides both login initiation and callback handling + * + * Security Features: + * - Client secret encryption and secure storage + * - GUID validation for Azure AD identifiers + * - Rate limiting to prevent abuse + * - Comprehensive audit logging for security monitoring + * - State token validation for CSRF protection + * + * @author VerifyWise Development Team + * @since 2024-09-28 + * @version 1.0.0 + * @see {@link https://docs.microsoft.com/en-us/azure/active-directory/develop/} Azure AD Developer Documentation + */ + +import { Request } from 'express'; +import { ConfidentialClientApplication } from '@azure/msal-node'; +import { + SSOProviderType, + SSOProviderConfig, + SSOLoginResult, + SSOAuthResult, + SSOUserInfo, + SSOError, + SSOErrorType, + CloudEnvironment +} from '../interfaces/sso-provider.interface'; +import { BaseSSOProvider } from '../abstracts/base-sso-provider.abstract'; +import { SSOStateTokenManager } from '../utils/sso-state-token.utils'; + +/** + * Azure AD specific configuration interface + * + * Extends the base SSO provider configuration with Azure AD-specific parameters + * required for proper authentication and tenant isolation. + * + * @interface AzureADConfig + * @extends {SSOProviderConfig} + */ +interface AzureADConfig extends SSOProviderConfig { + /** Azure AD tenant identifier (GUID format) */ + tenantId: string; + /** Azure cloud environment for government or public cloud deployment */ + cloudEnvironment: CloudEnvironment.AZURE_PUBLIC | CloudEnvironment.AZURE_GOVERNMENT; +} + +/** + * Azure AD SSO Provider implementation + * + * Provides complete Azure Active Directory (Entra ID) Single Sign-On integration + * with support for multiple cloud environments and comprehensive security features. + * + * This class handles: + * - OAuth 2.0 authorization code flow with Azure AD + * - MSAL (Microsoft Authentication Library) integration + * - Tenant-specific authentication and user provisioning + * - Rate limiting and security validation + * - Error handling and audit logging + * + * @class AzureADSSOProvider + * @extends {BaseSSOProvider} + * + * @example + * ```typescript + * const provider = new AzureADSSOProvider('azure-ad-org-123'); + * await provider.initialize({ + * clientId: 'app-client-id', + * clientSecret: 'encrypted-secret', + * tenantId: 'tenant-guid', + * cloudEnvironment: CloudEnvironment.AZURE_PUBLIC + * }); + * const loginUrl = await provider.getLoginUrl(req, organizationId); + * ``` + */ +export class AzureADSSOProvider extends BaseSSOProvider { + /** Microsoft Authentication Library client for Azure AD integration */ + private msalClient?: ConfidentialClientApplication; + + /** Azure AD-specific configuration including tenant and cloud settings */ + private azureConfig?: AzureADConfig; + + /** + * Creates a new Azure AD SSO provider instance + * + * @param {string} providerId - Unique identifier for this provider instance + */ + constructor(providerId: string) { + super(SSOProviderType.AZURE_AD, providerId); + } + + /** + * Provider-specific initialization logic for Azure AD + * + * Configures the Microsoft Authentication Library (MSAL) client with Azure AD-specific + * settings including tenant authority URLs and authentication parameters. + * + * @protected + * @async + * @param {SSOProviderConfig} config - Azure AD configuration parameters + * @throws {Error} If MSAL client initialization fails + */ + protected async onInitialize(config: SSOProviderConfig): Promise { + this.azureConfig = config as AzureADConfig; + + // Configure MSAL client for Azure AD authentication + // Authority URL determines the Azure AD endpoint for tenant-specific authentication + const msalConfig = { + auth: { + clientId: config.clientId, // Azure AD Application (client) ID + clientSecret: config.clientSecret, // Application secret for confidential client flow + authority: `${this.getAzureADBaseUrl()}/${config.tenantId}` // Tenant-specific authority URL + } + }; + + // Initialize MSAL confidential client for server-side OAuth flow + this.msalClient = new ConfidentialClientApplication(msalConfig); + } + + /** + * Azure AD-specific configuration validation + * + * Validates Azure AD configuration parameters including GUID formats for tenant ID + * and client ID, as well as cloud environment settings. + * + * @protected + * @async + * @param {Partial} config - Configuration to validate + * @returns {Promise} Array of validation error messages + */ + protected async validateProviderSpecificConfig(config: Partial): Promise { + const errors: string[] = []; + + // Validate Azure AD Tenant ID (required) + if (!config.tenantId) { + errors.push('Azure AD Tenant ID is required'); + } else { + // Azure AD tenant IDs must be in GUID format (RFC 4122) + const guidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (!guidRegex.test(config.tenantId)) { + errors.push('Azure AD Tenant ID must be a valid GUID format'); + } + } + + // Validate Azure AD Client ID format (if provided) + if (config.clientId) { + // Azure AD application IDs must also be in GUID format + const guidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (!guidRegex.test(config.clientId)) { + errors.push('Azure AD Client ID must be a valid GUID format'); + } + } + + if (config.cloudEnvironment && + config.cloudEnvironment !== CloudEnvironment.AZURE_PUBLIC && + config.cloudEnvironment !== CloudEnvironment.AZURE_GOVERNMENT) { + errors.push('Cloud environment must be azure_public or azure_government for Azure AD'); + } + + return errors; + } + + /** + * Generates Azure AD login URL for SSO initiation + * + * Creates an OAuth 2.0 authorization URL for Azure AD authentication with comprehensive + * security validation including rate limiting, input validation, and CSRF protection. + * + * @async + * @param {Request} req - Express request object for rate limiting and logging + * @param {string} organizationId - Organization identifier for tenant isolation + * @param {Record} [additionalParams] - Optional additional OAuth parameters + * @returns {Promise} Login result containing authorization URL + * + * @throws {SSOError} RATE_LIMIT_EXCEEDED - When rate limits are exceeded + * @throws {SSOError} CONFIGURATION_ERROR - When organization ID is invalid + * @throws {SSOError} PROVIDER_ERROR - When Azure AD URL generation fails + * + * @example + * ```typescript + * const result = await provider.getLoginUrl(req, '123'); + * // Redirect user to result.authUrl for Azure AD authentication + * ``` + */ + async getLoginUrl(req: Request, organizationId: string, additionalParams?: Record): Promise { + this.ensureInitialized(); + + // Rate limiting check + const rateLimitResult = await this.checkRateLimit(req, 'login'); + if (!rateLimitResult.allowed) { + throw new SSOError( + SSOErrorType.RATE_LIMIT_EXCEEDED, + `Rate limit exceeded. Try again in ${rateLimitResult.retryAfter} seconds.`, + this.providerType + ); + } + + // Enhanced input validation + if (!organizationId || typeof organizationId !== 'string') { + throw new SSOError( + SSOErrorType.CONFIGURATION_ERROR, + 'Organization ID must be a non-empty string', + this.providerType + ); + } + + // Validate organization ID format (should be numeric string for VerifyWise) + if (!/^\d+$/.test(organizationId.trim())) { + throw new SSOError( + SSOErrorType.CONFIGURATION_ERROR, + 'Organization ID must be a valid numeric identifier', + this.providerType + ); + } + + // Validate additional parameters if provided + if (additionalParams) { + for (const [key, value] of Object.entries(additionalParams)) { + if (typeof key !== 'string' || typeof value !== 'string') { + throw new SSOError( + SSOErrorType.CONFIGURATION_ERROR, + 'Additional parameters must be string key-value pairs', + this.providerType + ); + } + + // Sanitize parameter values + if (key.length > 100 || value.length > 500) { + throw new SSOError( + SSOErrorType.CONFIGURATION_ERROR, + 'Parameter keys and values exceed maximum length', + this.providerType + ); + } + + // Check for dangerous characters + if (/[<>{}[\]\\\/\x00-\x1f\x7f]/.test(key) || /[<>{}[\]\\\/\x00-\x1f\x7f]/.test(value)) { + throw new SSOError( + SSOErrorType.CONFIGURATION_ERROR, + 'Parameter keys and values contain invalid characters', + this.providerType + ); + } + } + } + + try { + // Generate secure state token with CSRF protection + const secureState = SSOStateTokenManager.generateStateToken(organizationId); + + // Validate required environment variables + const backendUrl = process.env.BACKEND_URL; + if (!backendUrl) { + throw new SSOError( + SSOErrorType.CONFIGURATION_ERROR, + 'BACKEND_URL environment variable is required for SSO operation', + this.providerType + ); + } + + // Define the authorization URL parameters + const authCodeUrlParameters = { + scopes: this.azureConfig?.scopes || ['openid', 'profile', 'email'], + redirectUri: `${backendUrl}/api/sso-auth/${organizationId}/callback`, + state: secureState, + ...additionalParams + }; + + // Get authorization URL from MSAL + const authUrl = await this.msalClient!.getAuthCodeUrl(authCodeUrlParameters); + + // Log successful login initiation + this.logAuditEvent(req, 'login_initiated', true); + + return { + success: true, + authUrl: authUrl, + state: secureState + }; + } catch (error) { + this.logAuditEvent(req, 'login_initiated', false, undefined, 'Failed to generate authorization URL'); + throw this.handleProviderError(error, 'login URL generation'); + } + } + + /** + * Handle callback from SSO provider + */ + async handleCallback(req: Request, organizationId: string): Promise { + this.ensureInitialized(); + + // Rate limiting check + const rateLimitResult = await this.checkRateLimit(req, 'callback'); + if (!rateLimitResult.allowed) { + throw new SSOError( + SSOErrorType.RATE_LIMIT_EXCEEDED, + `Rate limit exceeded. Try again in ${rateLimitResult.retryAfter} seconds.`, + this.providerType + ); + } + + // Enhanced input validation for organizationId + if (!organizationId || typeof organizationId !== 'string') { + this.logAuditEvent(req, 'authentication_failure', false, undefined, 'Invalid organization ID format'); + return { + success: false, + error: 'Invalid organization ID', + errorCode: 'invalid_organization_id' + }; + } + + // Validate organization ID format + if (!/^\d+$/.test(organizationId.trim())) { + this.logAuditEvent(req, 'authentication_failure', false, undefined, 'Organization ID format validation failed'); + return { + success: false, + error: 'Invalid organization ID format', + errorCode: 'invalid_organization_id' + }; + } + + const { code, state, error: authError } = req.query; + + // Enhanced query parameter validation + if (code && typeof code !== 'string') { + this.logAuditEvent(req, 'authentication_failure', false, undefined, 'Invalid authorization code type'); + return { + success: false, + error: 'Invalid authorization code format', + errorCode: 'invalid_auth_code' + }; + } + + if (state && typeof state !== 'string') { + this.logAuditEvent(req, 'authentication_failure', false, undefined, 'Invalid state parameter type'); + return { + success: false, + error: 'Invalid state parameter format', + errorCode: 'invalid_state' + }; + } + + if (authError && typeof authError !== 'string') { + this.logAuditEvent(req, 'authentication_failure', false, undefined, 'Invalid error parameter type'); + return { + success: false, + error: 'Invalid error parameter format', + errorCode: 'invalid_error_param' + }; + } + + // Validate code length and format if present + if (code && (code.length < 10 || code.length > 2048)) { + this.logAuditEvent(req, 'authentication_failure', false, undefined, 'Authorization code length validation failed'); + return { + success: false, + error: 'Invalid authorization code length', + errorCode: 'invalid_auth_code' + }; + } + + // Validate state parameter length if present + if (state && (state.length < 10 || state.length > 1024)) { + this.logAuditEvent(req, 'authentication_failure', false, undefined, 'State parameter length validation failed'); + return { + success: false, + error: 'Invalid state parameter length', + errorCode: 'invalid_state' + }; + } + + // Check if Azure AD returned an error + if (authError) { + this.logAuditEvent(req, 'authentication_failure', false, undefined, `Azure AD error: ${authError}`); + return { + success: false, + error: 'Azure AD authentication error', + errorCode: 'azure_ad_error' + }; + } + + // Validate state token + try { + SSOStateTokenManager.validateStateToken(state as string, organizationId); + } catch (error) { + this.logAuditEvent(req, 'authentication_failure', false, undefined, 'State token validation failed'); + return { + success: false, + error: 'Invalid state token', + errorCode: 'invalid_state' + }; + } + + if (!code) { + this.logAuditEvent(req, 'authentication_failure', false, undefined, 'No authorization code received'); + return { + success: false, + error: 'No authorization code received', + errorCode: 'no_auth_code' + }; + } + + return this.exchangeCodeForUser(code as string, state as string); + } + + /** + * Exchange authorization code for user information + */ + async exchangeCodeForUser(authCode: string, state: string): Promise { + this.ensureInitialized(); + + // Enhanced input validation for authorization code + if (!authCode || typeof authCode !== 'string') { + throw new SSOError( + SSOErrorType.AUTHENTICATION_FAILED, + 'Authorization code must be a non-empty string', + this.providerType + ); + } + + // Validate authorization code format and length + if (authCode.length < 10 || authCode.length > 2048) { + throw new SSOError( + SSOErrorType.AUTHENTICATION_FAILED, + 'Authorization code has invalid length', + this.providerType + ); + } + + // Check for dangerous characters in authorization code + if (/[\x00-\x1f\x7f<>{}[\]\\]/.test(authCode)) { + throw new SSOError( + SSOErrorType.AUTHENTICATION_FAILED, + 'Authorization code contains invalid characters', + this.providerType + ); + } + + // Enhanced input validation for state parameter + if (!state || typeof state !== 'string') { + throw new SSOError( + SSOErrorType.AUTHENTICATION_FAILED, + 'State parameter must be a non-empty string', + this.providerType + ); + } + + // Validate state parameter length + if (state.length < 10 || state.length > 1024) { + throw new SSOError( + SSOErrorType.AUTHENTICATION_FAILED, + 'State parameter has invalid length', + this.providerType + ); + } + + try { + // Validate required environment variables + const backendUrl = process.env.BACKEND_URL; + if (!backendUrl) { + throw new SSOError( + SSOErrorType.CONFIGURATION_ERROR, + 'BACKEND_URL environment variable is required for token exchange', + this.providerType + ); + } + + // Token request configuration + const tokenRequest = { + code: authCode, + scopes: this.azureConfig?.scopes || ['openid', 'profile', 'email'], + redirectUri: `${backendUrl}/api/sso-auth/${this.azureConfig?.organizationId}/callback` + }; + + // Exchange authorization code for tokens + const response = await this.msalClient!.acquireTokenByCode(tokenRequest); + + if (!response) { + throw new SSOError(SSOErrorType.AUTHENTICATION_FAILED, 'Token exchange failed - no response', this.providerType); + } + + // Extract user information from the token + const userInfo = response.account; + if (!userInfo || !userInfo.username) { + throw new SSOError(SSOErrorType.AUTHENTICATION_FAILED, 'No user information in token response', this.providerType); + } + + // Normalize user information + const normalizedUserInfo = this.normalizeUserInfo(userInfo); + + // Validate email domain + const domainAllowed = await this.isEmailDomainAllowed(normalizedUserInfo.email); + if (!domainAllowed) { + throw new SSOError(SSOErrorType.DOMAIN_NOT_ALLOWED, 'Email domain not allowed', this.providerType); + } + + return { + success: true, + userInfo: normalizedUserInfo + }; + } catch (error) { + if (error instanceof SSOError) { + throw error; + } + throw this.handleProviderError(error, 'token exchange'); + } + } + + /** + * Get provider-specific metadata URLs + */ + getMetadataUrls(): { authUrl?: string; tokenUrl?: string; userInfoUrl?: string; logoutUrl?: string } { + const baseUrl = this.getAzureADBaseUrl(); + const tenantId = this.azureConfig?.tenantId || 'common'; + + return { + authUrl: `${baseUrl}/${tenantId}/oauth2/v2.0/authorize`, + tokenUrl: `${baseUrl}/${tenantId}/oauth2/v2.0/token`, + userInfoUrl: `${this.getGraphApiUrl()}/v1.0/me`, + logoutUrl: `${baseUrl}/${tenantId}/oauth2/v2.0/logout` + }; + } + + /** + * Provider-specific health check implementation + */ + protected async performHealthCheck(): Promise<{ healthy: boolean; message?: string }> { + try { + if (!this.msalClient) { + return { + healthy: false, + message: 'MSAL client not initialized' + }; + } + + // Test connectivity to Azure AD discovery endpoint + const metadataUrls = this.getMetadataUrls(); + + // Basic configuration validation + if (!this.azureConfig?.tenantId || !this.azureConfig?.clientId) { + return { + healthy: false, + message: 'Missing required Azure AD configuration' + }; + } + + return { + healthy: true, + message: 'Azure AD provider healthy' + }; + } catch (error) { + return { + healthy: false, + message: `Health check failed: ${error instanceof Error ? error.message : 'Unknown error'}` + }; + } + } + + /** + * Normalize user information from Azure AD format + */ + protected normalizeUserInfo(azureUserData: any): SSOUserInfo { + // Extract email - in Azure AD, username is typically the email + const email = azureUserData.username; + if (!this.isValidEmail(email)) { + throw new SSOError(SSOErrorType.AUTHENTICATION_FAILED, 'Invalid email format from Azure AD', this.providerType); + } + + // Extract Azure Object ID + let azureObjectId = ''; + if (azureUserData.homeAccountId && typeof azureUserData.homeAccountId === 'string') { + const accountParts = azureUserData.homeAccountId.split('.'); + if (accountParts.length >= 1 && accountParts[0].length > 0) { + // Validate object ID format (should be GUID-like) + const objectIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (objectIdPattern.test(accountParts[0])) { + azureObjectId = accountParts[0]; + } + } + } + + // Fall back to localAccountId if homeAccountId is invalid + if (!azureObjectId && azureUserData.localAccountId && typeof azureUserData.localAccountId === 'string') { + const objectIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (objectIdPattern.test(azureUserData.localAccountId)) { + azureObjectId = azureUserData.localAccountId; + } + } + + // Use email as fallback identifier if Azure Object ID is invalid + if (!azureObjectId) { + azureObjectId = email; + } + + // Extract and sanitize name information + let firstName = 'Unknown'; + let lastName = 'User'; + let displayName = ''; + + if (azureUserData.name && typeof azureUserData.name === 'string' && azureUserData.name.trim().length > 0) { + const nameParts = azureUserData.name.trim().split(' '); + displayName = azureUserData.name.trim(); + + // Sanitize first name + if (nameParts[0] && nameParts[0].length > 0) { + firstName = this.sanitizeName(nameParts[0]); + } + + // Sanitize last name + if (nameParts.length > 1) { + const lastNamePart = nameParts.slice(1).join(' '); + if (lastNamePart.length > 0) { + lastName = this.sanitizeName(lastNamePart); + } + } + } + + // Ensure we have valid names + if (!firstName || firstName.trim().length === 0) firstName = 'Unknown'; + if (!lastName || lastName.trim().length === 0) lastName = 'User'; + if (!displayName) displayName = `${firstName} ${lastName}`; + + return { + email, + firstName, + lastName, + displayName, + providerId: azureObjectId, + providerType: SSOProviderType.AZURE_AD, + additionalClaims: { + azureObjectId, + homeAccountId: azureUserData.homeAccountId, + localAccountId: azureUserData.localAccountId, + tenantId: azureUserData.tenantId || this.azureConfig?.tenantId + } + }; + } + + /** + * Get Azure AD login URL based on cloud environment + */ + private getAzureADBaseUrl(): string { + return this.azureConfig?.cloudEnvironment === CloudEnvironment.AZURE_GOVERNMENT + ? 'https://login.microsoftonline.us' + : 'https://login.microsoftonline.com'; + } + + /** + * Get Microsoft Graph API URL based on cloud environment + */ + private getGraphApiUrl(): string { + return this.azureConfig?.cloudEnvironment === CloudEnvironment.AZURE_GOVERNMENT + ? 'https://graph.microsoft.us' + : 'https://graph.microsoft.com'; + } + + /** + * Handle provider-specific logout (optional implementation) + */ + async handleLogout(req: Request, organizationId: string): Promise<{ success: boolean; logoutUrl?: string }> { + const metadataUrls = this.getMetadataUrls(); + + return { + success: true, + logoutUrl: metadataUrls.logoutUrl + }; + } + + /** + * Validate Azure AD JWT token + */ + async validateToken(token: string): Promise<{ valid: boolean; userInfo?: SSOUserInfo }> { + try { + // Enhanced input validation for token + if (!token || typeof token !== 'string') { + throw new SSOError( + SSOErrorType.AUTHENTICATION_FAILED, + 'Token must be a non-empty string', + this.providerType + ); + } + + // Validate token format (JWT has 3 parts separated by dots) + const tokenParts = token.split('.'); + if (tokenParts.length !== 3) { + throw new SSOError( + SSOErrorType.AUTHENTICATION_FAILED, + 'Invalid JWT token format', + this.providerType + ); + } + + // Check token length (reasonable bounds for JWT) + if (token.length < 100 || token.length > 8192) { + throw new SSOError( + SSOErrorType.AUTHENTICATION_FAILED, + 'Token length is outside acceptable range', + this.providerType + ); + } + + // Check for dangerous characters + if (/[\x00-\x1f\x7f<>{}[\]\\]/.test(token)) { + throw new SSOError( + SSOErrorType.AUTHENTICATION_FAILED, + 'Token contains invalid characters', + this.providerType + ); + } + + this.ensureInitialized(); + + // Use MSAL to validate the token + // Note: MSAL primarily handles authentication flows, not token validation + // For production, you would typically validate against Azure AD's jwks_uri + + // For now, we'll implement basic JWT structure validation + // and defer to the authentication flow for full validation + + try { + // Decode JWT header and payload for basic validation + const header = JSON.parse(Buffer.from(tokenParts[0], 'base64url').toString()); + const payload = JSON.parse(Buffer.from(tokenParts[1], 'base64url').toString()); + + // Basic JWT header validation + if (!header.alg || !header.typ || header.typ !== 'JWT') { + throw new SSOError( + SSOErrorType.AUTHENTICATION_FAILED, + 'Invalid JWT header structure', + this.providerType + ); + } + + // Basic payload validation + if (!payload.sub || !payload.iss || !payload.aud || !payload.exp) { + throw new SSOError( + SSOErrorType.AUTHENTICATION_FAILED, + 'JWT missing required claims', + this.providerType + ); + } + + // Check token expiration + const now = Math.floor(Date.now() / 1000); + if (payload.exp <= now) { + throw new SSOError( + SSOErrorType.AUTHENTICATION_FAILED, + 'JWT token has expired', + this.providerType + ); + } + + // Check if token is not used before its valid time + if (payload.nbf && payload.nbf > now) { + throw new SSOError( + SSOErrorType.AUTHENTICATION_FAILED, + 'JWT token is not yet valid', + this.providerType + ); + } + + // Validate issuer is from Azure AD + const validIssuers = [ + `https://login.microsoftonline.com/${this.azureConfig?.tenantId}/v2.0`, + `https://login.microsoftonline.us/${this.azureConfig?.tenantId}/v2.0`, // Government cloud + 'https://sts.windows.net/' // Alternative issuer format + ]; + + const isValidIssuer = validIssuers.some(validIssuer => + payload.iss.startsWith(validIssuer) || payload.iss.includes(this.azureConfig?.tenantId || '') + ); + + if (!isValidIssuer) { + throw new SSOError( + SSOErrorType.AUTHENTICATION_FAILED, + 'JWT token issuer is not trusted', + this.providerType + ); + } + + // Validate audience matches our application + const expectedAudience = this.azureConfig?.clientId; + if (expectedAudience && payload.aud !== expectedAudience) { + throw new SSOError( + SSOErrorType.AUTHENTICATION_FAILED, + 'JWT token audience mismatch', + this.providerType + ); + } + + // Extract user information from token claims + const userInfo: SSOUserInfo = { + email: payload.email || payload.preferred_username || payload.upn || '', + firstName: payload.given_name || 'Unknown', + lastName: payload.family_name || 'User', + displayName: payload.name || `${payload.given_name || 'Unknown'} ${payload.family_name || 'User'}`, + providerId: payload.sub || payload.oid || payload.email || '', + providerType: SSOProviderType.AZURE_AD, + additionalClaims: { + azureObjectId: payload.oid || payload.sub, + tenantId: payload.tid || this.azureConfig?.tenantId, + roles: payload.roles || [], + groups: payload.groups || [], + tokenId: payload.jti, + authTime: payload.auth_time, + sessionId: payload.sid + } + }; + + // Validate extracted email + if (!this.isValidEmail(userInfo.email)) { + throw new SSOError( + SSOErrorType.AUTHENTICATION_FAILED, + 'Invalid email in JWT token', + this.providerType + ); + } + + // Sanitize names + userInfo.firstName = this.sanitizeName(userInfo.firstName); + userInfo.lastName = this.sanitizeName(userInfo.lastName); + userInfo.displayName = this.sanitizeName(userInfo.displayName || `${userInfo.firstName} ${userInfo.lastName}`); + + return { + valid: true, + userInfo + }; + + } catch (decodeError) { + throw new SSOError( + SSOErrorType.AUTHENTICATION_FAILED, + 'Failed to decode JWT token', + this.providerType + ); + } + + } catch (error) { + // Log validation failure for security monitoring + console.warn('JWT token validation failed:', error instanceof Error ? error.message : 'Unknown error'); + + if (error instanceof SSOError) { + throw error; + } + + return { + valid: false + }; + } + } + + /** + * Enhanced sanitize user name fields with additional security checks for Azure AD + */ + protected sanitizeName(name: string): string { + if (!name || typeof name !== 'string') { + return ''; + } + + // Remove dangerous characters and normalize Unicode + let sanitized = name + .normalize('NFD') // Normalize Unicode decomposition + .replace(/[\u0300-\u036f]/g, '') // Remove diacritics/combining marks + .replace(/[<>{}[\]\\\/\x00-\x1f\x7f\u00a0\u2000-\u200f\u2028-\u202f\u205f-\u206f]/g, '') // Remove dangerous chars + .replace(/\s+/g, ' ') // Normalize whitespace + .substring(0, 50) // Limit length + .trim(); + + // Additional security: reject names that are only special characters or too short + if (sanitized.length < 1 || /^[^\w\s\-'.]+$/.test(sanitized)) { + return ''; + } + + // Prevent injection attempts + if (/(?:javascript|data|vbscript|on\w+):/i.test(sanitized)) { + return ''; + } + + return sanitized; + } + + /** + * Enhanced email validation with additional security checks for Azure AD + */ + protected isValidEmail(email: string): boolean { + if (!email || typeof email !== 'string') { + return false; + } + + // Length checks + if (email.length < 3 || email.length > 320) { + return false; + } + + // Basic email format validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return false; + } + + // Additional security checks + const localPart = email.split('@')[0]; + const domain = email.split('@')[1]; + + // Check for dangerous characters in local part + if (/[<>{}[\]\\\/\x00-\x1f\x7f]/.test(localPart)) { + return false; + } + + // Check for dangerous characters in domain + if (/[<>{}[\]\\\/\x00-\x1f\x7f]/.test(domain)) { + return false; + } + + // Prevent obvious injection attempts + if (/(?:javascript|data|vbscript|on\w+):/i.test(email)) { + return false; + } + + // Validate domain has at least one dot and proper structure + if (!domain.includes('.') || domain.startsWith('.') || domain.endsWith('.')) { + return false; + } + + return true; + } + + /** + * Get user groups from Azure AD (optional implementation) + */ + async getUserGroups(userId: string): Promise { + try { + // Enhanced input validation for user ID + if (!userId || typeof userId !== 'string') { + throw new Error('User ID must be a non-empty string'); + } + + // Validate GUID format for Azure Object ID + const guidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (!guidRegex.test(userId)) { + throw new Error('User ID must be a valid GUID format'); + } + + // This would make a call to Microsoft Graph API to get user groups + // For now, return empty array + return []; + } catch (error) { + console.error('Failed to get user groups from Azure AD:', error); + return []; + } + } +} + +export default AzureADSSOProvider; \ No newline at end of file diff --git a/Servers/routes/sso-health.route.ts b/Servers/routes/sso-health.route.ts new file mode 100644 index 000000000..71d169557 --- /dev/null +++ b/Servers/routes/sso-health.route.ts @@ -0,0 +1,423 @@ +/** + * SSO Health Check Routes + * + * Provides comprehensive health monitoring endpoints for the SSO system. + * Includes checks for Redis connectivity, provider status, rate limiting, + * and overall system health for production monitoring. + */ + +import express from 'express'; +import { Request, Response } from 'express'; +import { getRedisRateLimiter } from '../utils/redis-rate-limiter.utils'; +import { ssoProviderFactory } from '../factories/sso-provider.factory'; +import { SSOEnvironmentValidator } from '../utils/sso-env-validator.utils'; +import { SSOProviderType } from '../interfaces/sso-provider.interface'; + +const router = express.Router(); + +interface HealthCheckResult { + status: 'healthy' | 'degraded' | 'unhealthy'; + timestamp: string; + version?: string; + checks: { + [key: string]: { + status: 'pass' | 'fail' | 'warn'; + message?: string; + responseTime?: number; + details?: any; + }; + }; +} + +/** + * Basic health check endpoint + * GET /api/sso-health/ + */ +router.get('/', async (req: Request, res: Response) => { + const result: HealthCheckResult = { + status: 'healthy', + timestamp: new Date().toISOString(), + version: process.env.npm_package_version || '1.0.0', + checks: {} + }; + + try { + // Environment validation check + const envResult = SSOEnvironmentValidator.validateEnvironment(); + result.checks.environment = { + status: envResult.valid ? 'pass' : 'fail', + message: envResult.valid ? 'Environment configuration valid' : 'Environment validation failed', + details: { + errors: envResult.errors, + warnings: envResult.warnings + } + }; + + if (!envResult.valid) { + result.status = 'unhealthy'; + } else if (envResult.warnings.length > 0) { + result.status = 'degraded'; + } + + res.status(result.status === 'healthy' ? 200 : result.status === 'degraded' ? 200 : 503) + .json(result); + + } catch (error) { + result.status = 'unhealthy'; + result.checks.system = { + status: 'fail', + message: `Health check failed: ${error instanceof Error ? error.message : 'Unknown error'}` + }; + + res.status(503).json(result); + } +}); + +/** + * Detailed health check endpoint + * GET /api/sso-health/detailed + */ +router.get('/detailed', async (req: Request, res: Response) => { + const startTime = Date.now(); + const result: HealthCheckResult = { + status: 'healthy', + timestamp: new Date().toISOString(), + version: process.env.npm_package_version || '1.0.0', + checks: {} + }; + + let hasFailures = false; + let hasWarnings = false; + + try { + // 1. Environment validation + const envStart = Date.now(); + try { + const envResult = SSOEnvironmentValidator.validateEnvironment(); + result.checks.environment = { + status: envResult.valid ? 'pass' : 'fail', + responseTime: Date.now() - envStart, + message: envResult.valid ? 'Environment configuration valid' : 'Environment validation failed', + details: { + errors: envResult.errors, + warnings: envResult.warnings, + redisConfigured: SSOEnvironmentValidator.isRedisConfigured(), + isProduction: SSOEnvironmentValidator.isProduction() + } + }; + + if (!envResult.valid) hasFailures = true; + if (envResult.warnings.length > 0) hasWarnings = true; + } catch (error) { + result.checks.environment = { + status: 'fail', + responseTime: Date.now() - envStart, + message: `Environment check failed: ${error instanceof Error ? error.message : 'Unknown error'}` + }; + hasFailures = true; + } + + // 2. Redis connectivity check + const redisStart = Date.now(); + try { + const rateLimiter = getRedisRateLimiter(); + const redisHealth = await rateLimiter.healthCheck(); + result.checks.redis = { + status: redisHealth.healthy ? 'pass' : 'fail', + responseTime: Date.now() - redisStart, + message: redisHealth.message || (redisHealth.healthy ? 'Redis connection healthy' : 'Redis connection failed') + }; + + if (!redisHealth.healthy) hasFailures = true; + } catch (error) { + result.checks.redis = { + status: 'fail', + responseTime: Date.now() - redisStart, + message: `Redis check failed: ${error instanceof Error ? error.message : 'Unknown error'}` + }; + hasFailures = true; + } + + // 3. Rate limiting functionality check + const rateLimitStart = Date.now(); + try { + const rateLimiter = getRedisRateLimiter(); + // Test rate limiting with a dummy request + const testResult = await rateLimiter.getRateLimitStatus(req, 'login', 'test'); + + result.checks.rateLimiting = { + status: 'pass', + responseTime: Date.now() - rateLimitStart, + message: 'Rate limiting functional', + details: { + testAttempts: testResult.attempts, + remaining: testResult.remaining, + blocked: testResult.blocked + } + }; + } catch (error) { + result.checks.rateLimiting = { + status: 'warn', + responseTime: Date.now() - rateLimitStart, + message: `Rate limiting check failed: ${error instanceof Error ? error.message : 'Unknown error'}` + }; + hasWarnings = true; + } + + // 4. SSO Provider factory health + const factoryStart = Date.now(); + try { + const supportedProviders = ssoProviderFactory.getSupportedProviders(); + const providerHealth = await ssoProviderFactory.healthCheckProviders(); + + const healthyProviders = Array.from(providerHealth.entries()).filter(([_, health]) => health.healthy).length; + const totalProviders = providerHealth.size; + + result.checks.ssoProviders = { + status: healthyProviders === totalProviders ? 'pass' : (healthyProviders > 0 ? 'warn' : 'fail'), + responseTime: Date.now() - factoryStart, + message: `${healthyProviders}/${totalProviders} providers healthy`, + details: { + supportedProviders, + providerStatus: Object.fromEntries(providerHealth) + } + }; + + if (healthyProviders === 0) hasFailures = true; + else if (healthyProviders < totalProviders) hasWarnings = true; + } catch (error) { + result.checks.ssoProviders = { + status: 'fail', + responseTime: Date.now() - factoryStart, + message: `Provider factory check failed: ${error instanceof Error ? error.message : 'Unknown error'}` + }; + hasFailures = true; + } + + // 5. Memory and performance metrics + const memoryStart = Date.now(); + try { + const memUsage = process.memoryUsage(); + const uptime = process.uptime(); + + // Convert bytes to MB for readability + const memoryMB = { + rss: Math.round(memUsage.rss / 1024 / 1024), + heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024), + heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024), + external: Math.round(memUsage.external / 1024 / 1024) + }; + + // Warn if memory usage is high + const highMemoryThreshold = 500; // MB + const isHighMemory = memoryMB.heapUsed > highMemoryThreshold; + + result.checks.performance = { + status: isHighMemory ? 'warn' : 'pass', + responseTime: Date.now() - memoryStart, + message: isHighMemory ? 'High memory usage detected' : 'Performance metrics normal', + details: { + memory: memoryMB, + uptimeSeconds: Math.round(uptime), + uptimeHuman: `${Math.floor(uptime / 3600)}h ${Math.floor((uptime % 3600) / 60)}m ${Math.floor(uptime % 60)}s` + } + }; + + if (isHighMemory) hasWarnings = true; + } catch (error) { + result.checks.performance = { + status: 'warn', + responseTime: Date.now() - memoryStart, + message: `Performance check failed: ${error instanceof Error ? error.message : 'Unknown error'}` + }; + hasWarnings = true; + } + + // Determine overall status + if (hasFailures) { + result.status = 'unhealthy'; + } else if (hasWarnings) { + result.status = 'degraded'; + } + + const totalResponseTime = Date.now() - startTime; + result.checks.overall = { + status: result.status === 'healthy' ? 'pass' : (result.status === 'degraded' ? 'warn' : 'fail'), + responseTime: totalResponseTime, + message: `Health check completed in ${totalResponseTime}ms` + }; + + // Set appropriate HTTP status code + const statusCode = result.status === 'healthy' ? 200 : (result.status === 'degraded' ? 200 : 503); + res.status(statusCode).json(result); + + } catch (error) { + result.status = 'unhealthy'; + result.checks.system = { + status: 'fail', + responseTime: Date.now() - startTime, + message: `Health check system error: ${error instanceof Error ? error.message : 'Unknown error'}` + }; + + res.status(503).json(result); + } +}); + +/** + * Redis-specific health check + * GET /api/sso-health/redis + */ +router.get('/redis', async (req: Request, res: Response) => { + try { + const rateLimiter = getRedisRateLimiter(); + const health = await rateLimiter.healthCheck(); + + res.status(health.healthy ? 200 : 503).json({ + status: health.healthy ? 'healthy' : 'unhealthy', + timestamp: new Date().toISOString(), + redis: health + }); + } catch (error) { + res.status(503).json({ + status: 'unhealthy', + timestamp: new Date().toISOString(), + error: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +/** + * SSO Providers health check + * GET /api/sso-health/providers + */ +router.get('/providers', async (req: Request, res: Response) => { + try { + const providerHealth = await ssoProviderFactory.healthCheckProviders(); + const supportedProviders = ssoProviderFactory.getSupportedProviders(); + + const healthyCount = Array.from(providerHealth.values()).filter(h => h.healthy).length; + const isHealthy = healthyCount === providerHealth.size; + + res.status(isHealthy ? 200 : 207).json({ + status: isHealthy ? 'healthy' : 'partial', + timestamp: new Date().toISOString(), + summary: { + total: providerHealth.size, + healthy: healthyCount, + supported: supportedProviders + }, + providers: Object.fromEntries(providerHealth) + }); + } catch (error) { + res.status(503).json({ + status: 'unhealthy', + timestamp: new Date().toISOString(), + error: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +/** + * Rate limiting status check + * GET /api/sso-health/rate-limiting + */ +router.get('/rate-limiting', async (req: Request, res: Response) => { + try { + const rateLimiter = getRedisRateLimiter(); + + // Test all operation types + const operations: Array<'login' | 'callback' | 'token'> = ['login', 'callback', 'token']; + const results: Record = {}; + + for (const operation of operations) { + try { + const status = await rateLimiter.getRateLimitStatus(req, operation, 'test'); + results[operation] = { + status: 'operational', + ...status + }; + } catch (error) { + results[operation] = { + status: 'error', + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } + + const allOperational = Object.values(results).every(r => r.status === 'operational'); + + res.status(allOperational ? 200 : 207).json({ + status: allOperational ? 'healthy' : 'partial', + timestamp: new Date().toISOString(), + rateLimiting: results + }); + } catch (error) { + res.status(503).json({ + status: 'unhealthy', + timestamp: new Date().toISOString(), + error: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +/** + * Readiness probe (for Kubernetes) + * GET /api/sso-health/ready + */ +router.get('/ready', async (req: Request, res: Response) => { + try { + // Check critical dependencies only + const envResult = SSOEnvironmentValidator.validateEnvironment(); + + if (!envResult.valid) { + return res.status(503).json({ + ready: false, + timestamp: new Date().toISOString(), + reason: 'Environment validation failed', + errors: envResult.errors + }); + } + + // Quick Redis check + const rateLimiter = getRedisRateLimiter(); + const redisHealth = await rateLimiter.healthCheck(); + + if (!redisHealth.healthy) { + return res.status(503).json({ + ready: false, + timestamp: new Date().toISOString(), + reason: 'Redis not available', + message: redisHealth.message + }); + } + + res.status(200).json({ + ready: true, + timestamp: new Date().toISOString(), + message: 'SSO system ready' + }); + + } catch (error) { + res.status(503).json({ + ready: false, + timestamp: new Date().toISOString(), + error: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +/** + * Liveness probe (for Kubernetes) + * GET /api/sso-health/live + */ +router.get('/live', (req: Request, res: Response) => { + // Simple liveness check - just verify the process is running + res.status(200).json({ + alive: true, + timestamp: new Date().toISOString(), + uptime: process.uptime(), + pid: process.pid + }); +}); + +export default router; \ No newline at end of file diff --git a/Servers/routes/ssoAuth.route.ts b/Servers/routes/ssoAuth.route.ts new file mode 100644 index 000000000..1bca87ea5 --- /dev/null +++ b/Servers/routes/ssoAuth.route.ts @@ -0,0 +1,161 @@ +/** + * @fileoverview SSO Authentication Routes + * + * Express router providing comprehensive endpoints for Azure AD Single Sign-On + * authentication flows. Handles the complete OAuth 2.0 authorization code flow + * including login initiation, callback processing, and organization discovery. + * + * This router is responsible for public-facing SSO endpoints that don't require + * pre-authentication, making it the entry point for users to authenticate via + * Azure AD SSO. + * + * Route Categories: + * - Public availability checks for SSO features + * - Organization discovery based on user email + * - OAuth flow initiation and callback handling + * - SSO provider information and configuration + * + * Security Features: + * - Rate limiting on all endpoints to prevent abuse + * - Organization-scoped access for security isolation + * - CSRF protection via state tokens in OAuth flow + * - Public routes designed for unauthenticated access + * + * @author VerifyWise Development Team + * @since 2024-09-28 + * @version 1.0.0 + * @see {@link https://tools.ietf.org/html/rfc6749} OAuth 2.0 Authorization Framework + * @see {@link https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow} Azure AD OAuth Flow + * + * @module routes/ssoAuth.route + */ + +import express from "express"; +const router = express.Router(); + +import { + initiateSSOLogin, + handleSSOCallback, + getSSOLoginUrl, + checkSSOAvailability, + getOrganizationSSOConfig, + checkUserOrganization, + getAvailableSSOProviders, +} from "../controllers/ssoAuth.ctrl"; + +import { + ssoLoginRateLimit, + ssoCallbackRateLimit, + generalSsoRateLimit, +} from "../middleware/rateLimiting.middleware"; + +/** + * @route GET /api/sso-auth/check-availability + * @description Checks if SSO functionality is available in the system + * @access Public (no authentication required) + * @rateLimit generalSsoRateLimit + * @returns {Object} { available: boolean, providers: string[] } + * + * @example + * GET /api/sso-auth/check-availability + * Response: { "available": true, "providers": ["azure-ad"] } + */ +router.get("/check-availability", generalSsoRateLimit, checkSSOAvailability); + +/** + * @route GET /api/sso-auth/available-providers + * @description Retrieves list of available SSO providers for the login interface + * @access Public (no authentication required) + * @rateLimit generalSsoRateLimit + * @returns {Object} Array of SSO provider configurations for frontend display + * + * @example + * GET /api/sso-auth/available-providers + * Response: [{ "id": "azure-ad", "name": "Microsoft", "enabled": true }] + */ +router.get("/available-providers", generalSsoRateLimit, getAvailableSSOProviders); + +/** + * @route GET /api/sso-auth/check-user-organization?email=user@domain.com + * @description Discovers user's organization and SSO availability based on email domain + * @access Public (no authentication required) + * @rateLimit generalSsoRateLimit + * @param {string} email - User email address for organization discovery + * @returns {Object} Organization information and SSO configuration status + * + * @example + * GET /api/sso-auth/check-user-organization?email=user@company.com + * Response: { "organizationId": 123, "ssoEnabled": true, "provider": "azure-ad" } + */ +router.get("/check-user-organization", generalSsoRateLimit, checkUserOrganization); + +/** + * @route GET /api/sso-auth/:organizationId/config + * @description Retrieves public SSO configuration for an organization + * @access Public (no authentication required) + * @rateLimit generalSsoRateLimit + * @param {string} organizationId - Organization identifier + * @returns {Object} Public SSO configuration (excludes sensitive data) + * + * @example + * GET /api/sso-auth/123/config + * Response: { "enabled": true, "provider": "azure-ad", "loginUrl": "/sso-auth/123/login" } + */ +router.get("/:organizationId/config", generalSsoRateLimit, getOrganizationSSOConfig); + +/** + * @route GET /api/sso-auth/:organizationId/login + * @description Initiates Azure AD OAuth 2.0 authorization code flow + * @access Public (no authentication required) + * @rateLimit ssoLoginRateLimit (stricter limits for login attempts) + * @param {string} organizationId - Organization identifier for SSO configuration + * @returns {Redirect} Redirects user to Azure AD authorization endpoint + * + * @security + * - Generates cryptographically secure state token for CSRF protection + * - Validates organization SSO configuration before redirect + * - Includes proper OAuth 2.0 scopes for user profile access + * + * @example + * GET /api/sso-auth/123/login + * Response: 302 Redirect to https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize?... + */ +router.get("/:organizationId/login", ssoLoginRateLimit, initiateSSOLogin); + +/** + * @route GET /api/sso-auth/:organizationId/callback?code=...&state=... + * @description Handles Azure AD OAuth 2.0 authorization callback + * @access Public (no authentication required) + * @rateLimit ssoCallbackRateLimit (stricter limits for callback processing) + * @param {string} organizationId - Organization identifier + * @param {string} code - Authorization code from Azure AD (query parameter) + * @param {string} state - State token for CSRF validation (query parameter) + * @returns {Redirect} Redirects to application with authentication cookie + * + * @security + * - Validates state token to prevent CSRF attacks + * - Exchanges authorization code for access token securely + * - Creates secure HTTP-only authentication cookie + * - Validates user organization membership + * + * @example + * GET /api/sso-auth/123/callback?code=abc123&state=xyz789 + * Response: 302 Redirect to frontend dashboard with auth cookie set + */ +router.get("/:organizationId/callback", ssoCallbackRateLimit, handleSSOCallback); + +/** + * @route GET /api/sso-auth/:organizationId/info + * @description Retrieves SSO login information and URLs for an organization + * @access Public (no authentication required) + * @rateLimit generalSsoRateLimit + * @param {string} organizationId - Organization identifier + * @returns {Object} SSO login URLs and configuration information + * + * @example + * GET /api/sso-auth/123/info + * Response: { "loginUrl": "/api/sso-auth/123/login", "provider": "azure-ad", "enabled": true } + */ +router.get("/:organizationId/info", generalSsoRateLimit, getSSOLoginUrl); + +export default router; \ No newline at end of file diff --git a/Servers/routes/ssoConfiguration.route.ts b/Servers/routes/ssoConfiguration.route.ts new file mode 100644 index 000000000..3e39ff59e --- /dev/null +++ b/Servers/routes/ssoConfiguration.route.ts @@ -0,0 +1,247 @@ +/** + * @fileoverview SSO Configuration Management Routes + * + * Express router providing comprehensive CRUD operations for Azure AD Single Sign-On + * configurations. These routes handle administrative operations for SSO setup, + * validation, testing, and management within organizations. + * + * This router focuses on authenticated administrative operations for SSO configuration + * management, requiring proper authentication and admin privileges for all endpoints. + * + * Route Categories: + * - CRUD operations for SSO configurations + * - SSO enable/disable controls for organizations + * - Configuration validation and testing endpoints + * - Administrative access with organization isolation + * + * Security Features: + * - All routes require JWT authentication via authenticateJWT middleware + * - Admin-only access for configuration modifications + * - Organization-scoped access control preventing cross-tenant operations + * - Input validation and sanitization for all configuration parameters + * - Secure client secret handling with encryption + * + * @author VerifyWise Development Team + * @since 2024-09-28 + * @version 1.0.0 + * @see {@link https://docs.microsoft.com/en-us/azure/active-directory/develop/} Azure AD Developer Documentation + * + * @module routes/sso-configuration.route + */ + +import express from "express"; +const router = express.Router(); + +import { + getSSOConfiguration, + createOrUpdateSSOConfiguration, + deleteSSOConfiguration, + enableSSO, + disableSSO, + validateSSOConfiguration, + testSSOConfiguration, +} from "../controllers/ssoConfiguration.ctrl"; + +import authenticateJWT from "../middleware/auth.middleware"; + +/** + * @route GET /api/sso-configuration/:organizationId + * @description Retrieves complete SSO configuration for an organization + * @access Private (requires JWT authentication + Admin role) + * @middleware authenticateJWT - Validates JWT token and admin permissions + * @param {string} organizationId - Organization identifier (URL parameter) + * @returns {Object} Complete SSO configuration (excludes client secrets) + * + * @security + * - Requires admin role within the target organization + * - Client secrets are never returned in response + * - Organization-scoped access prevents cross-tenant data access + * + * @example + * GET /api/sso-configuration/123 + * Headers: { Authorization: "Bearer jwt_token" } + * Response: { + * "success": true, + * "data": { + * "exists": true, + * "azure_tenant_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + * "azure_client_id": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy", + * "cloud_environment": "AzurePublic", + * "is_enabled": true, + * "auth_method_policy": "both" + * } + * } + */ +router.get("/:organizationId", authenticateJWT, getSSOConfiguration); + +/** + * @route POST /api/sso-configuration/:organizationId + * @description Creates new SSO configuration for an organization + * @access Private (requires JWT authentication + Admin role) + * @middleware authenticateJWT - Validates JWT token and admin permissions + * @param {string} organizationId - Organization identifier (URL parameter) + * @param {Object} body - SSO configuration data + * @returns {Object} Created configuration (excludes client secrets) + * + * @body_parameters + * - azure_tenant_id: string (required, GUID format) + * - azure_client_id: string (required, GUID format) + * - azure_client_secret: string (required, min 10 chars) + * - cloud_environment: 'AzurePublic' | 'AzureGovernment' + * - auth_method_policy: 'sso_only' | 'password_only' | 'both' + * + * @example + * POST /api/sso-configuration/123 + * Headers: { Authorization: "Bearer jwt_token" } + * Body: { + * "azure_tenant_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + * "azure_client_id": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy", + * "azure_client_secret": "secure_client_secret", + * "cloud_environment": "AzurePublic", + * "auth_method_policy": "both" + * } + */ +router.post("/:organizationId", authenticateJWT, createOrUpdateSSOConfiguration); + +/** + * @route PUT /api/sso-configuration/:organizationId + * @description Updates existing SSO configuration for an organization + * @access Private (requires JWT authentication + Admin role) + * @middleware authenticateJWT - Validates JWT token and admin permissions + * @param {string} organizationId - Organization identifier (URL parameter) + * @param {Object} body - Updated SSO configuration data + * @returns {Object} Updated configuration (excludes client secrets) + * + * @note Uses the same handler as POST for create-or-update functionality + */ +router.put("/:organizationId", authenticateJWT, createOrUpdateSSOConfiguration); + +/** + * @route DELETE /api/sso-configuration/:organizationId + * @description Permanently deletes SSO configuration for an organization + * @access Private (requires JWT authentication + Admin role) + * @middleware authenticateJWT - Validates JWT token and admin permissions + * @param {string} organizationId - Organization identifier (URL parameter) + * @returns {Object} Deletion confirmation + * + * @security + * - Permanently removes encrypted client secrets + * - Disables SSO authentication immediately + * - Cannot be undone - requires complete reconfiguration + * + * @example + * DELETE /api/sso-configuration/123 + * Headers: { Authorization: "Bearer jwt_token" } + * Response: { "success": true, "message": "SSO configuration deleted successfully" } + */ +router.delete("/:organizationId", authenticateJWT, deleteSSOConfiguration); + +/** + * @route POST /api/sso-configuration/:organizationId/enable + * @description Enables SSO authentication for an organization + * @access Private (requires JWT authentication + Admin role) + * @middleware authenticateJWT - Validates JWT token and admin permissions + * @param {string} organizationId - Organization identifier (URL parameter) + * @returns {Object} Enable operation result with updated status + * + * @prerequisites + * - Valid SSO configuration must exist + * - Configuration must pass validation checks + * - Azure AD connectivity must be verified + * + * @example + * POST /api/sso-configuration/123/enable + * Headers: { Authorization: "Bearer jwt_token" } + * Response: { + * "success": true, + * "message": "SSO enabled successfully", + * "data": { "is_enabled": true, "azure_tenant_id": "...", "auth_method_policy": "both" } + * } + */ +router.post("/:organizationId/enable", authenticateJWT, enableSSO); + +/** + * @route POST /api/sso-configuration/:organizationId/disable + * @description Disables SSO authentication for an organization + * @access Private (requires JWT authentication + Admin role) + * @middleware authenticateJWT - Validates JWT token and admin permissions + * @param {string} organizationId - Organization identifier (URL parameter) + * @returns {Object} Disable operation result with updated status + * + * @note Configuration is preserved, only the enabled status is changed + * + * @example + * POST /api/sso-configuration/123/disable + * Headers: { Authorization: "Bearer jwt_token" } + * Response: { + * "success": true, + * "message": "SSO disabled successfully", + * "data": { "is_enabled": false, "auth_method_policy": "both" } + * } + */ +router.post("/:organizationId/disable", authenticateJWT, disableSSO); + +/** + * @route POST /api/sso-configuration/:organizationId/validate + * @description Validates SSO configuration without saving to database + * @access Private (requires JWT authentication + Admin role) + * @middleware authenticateJWT - Validates JWT token and admin permissions + * @param {string} organizationId - Organization identifier (URL parameter) + * @param {Object} body - SSO configuration to validate + * @returns {Object} Detailed validation results with errors and warnings + * + * @validation_checks + * - GUID format validation for Azure AD identifiers + * - Client secret strength requirements + * - Domain format validation (if provided) + * - Cloud environment and authentication policy validation + * + * @example + * POST /api/sso-configuration/123/validate + * Headers: { Authorization: "Bearer jwt_token" } + * Body: { "azure_tenant_id": "invalid-format", ... } + * Response: { + * "success": true, + * "validation": { + * "isValid": false, + * "errors": ["Azure tenant ID must be a valid GUID format"], + * "warnings": [] + * }, + * "message": "SSO configuration has validation errors" + * } + */ +router.post("/:organizationId/validate", authenticateJWT, validateSSOConfiguration); + +/** + * @route POST /api/sso-configuration/:organizationId/test + * @description Tests SSO configuration connectivity with Azure AD + * @access Private (requires JWT authentication + Admin role) + * @middleware authenticateJWT - Validates JWT token and admin permissions + * @param {string} organizationId - Organization identifier (URL parameter) + * @param {Object} body - SSO configuration to test + * @returns {Object} Test results with connectivity status and details + * + * @testing_process + * - Validates configuration format + * - Attempts MSAL client creation + * - Tests Azure AD authority connectivity + * - Verifies endpoint accessibility + * + * @example + * POST /api/sso-configuration/123/test + * Headers: { Authorization: "Bearer jwt_token" } + * Body: { "azure_tenant_id": "valid-guid", "azure_client_id": "valid-guid", ... } + * Response: { + * "success": true, + * "testPassed": true, + * "message": "SSO configuration test passed", + * "details": { + * "authority": "https://login.microsoftonline.com/tenant-id", + * "clientConfigured": true, + * "warnings": [] + * } + * } + */ +router.post("/:organizationId/test", authenticateJWT, testSSOConfiguration); + +export default router; \ No newline at end of file diff --git a/Servers/types/express.d.ts b/Servers/types/express.d.ts index 161d9bd27..e4c898a4f 100644 --- a/Servers/types/express.d.ts +++ b/Servers/types/express.d.ts @@ -4,7 +4,8 @@ declare module 'express' { interface Request { userId?: number; role?: string; - tenantId?: string + tenantId?: string; organizationId?: number; + ssoEnabled?: boolean; } } \ No newline at end of file diff --git a/Servers/utils/redis-rate-limiter.utils.ts b/Servers/utils/redis-rate-limiter.utils.ts new file mode 100644 index 000000000..387859f44 --- /dev/null +++ b/Servers/utils/redis-rate-limiter.utils.ts @@ -0,0 +1,340 @@ +/** + * Redis-based Rate Limiter for SSO Operations + * + * Provides distributed rate limiting that works across multiple server instances + * and persists across server restarts. Uses Redis for storage with automatic + * cleanup and configurable limits per operation type. + */ + +import Redis from 'ioredis'; +import crypto from 'crypto'; +import { Request } from 'express'; + +interface RateLimitConfig { + windowMs: number; + maxAttempts: number; + blockDurationMs: number; +} + +interface RateLimitResult { + allowed: boolean; + retryAfter?: number; + attempts?: number; + remaining?: number; +} + +export class RedisRateLimiter { + private redis: Redis; + private keyPrefix: string; + + // Rate limiting configurations for different SSO operations + private static readonly configs: Record = { + login: { windowMs: 15 * 60 * 1000, maxAttempts: 10, blockDurationMs: 30 * 60 * 1000 }, // 10 attempts per 15 min, block 30 min + callback: { windowMs: 5 * 60 * 1000, maxAttempts: 20, blockDurationMs: 15 * 60 * 1000 }, // 20 attempts per 5 min, block 15 min + token: { windowMs: 10 * 60 * 1000, maxAttempts: 15, blockDurationMs: 20 * 60 * 1000 } // 15 attempts per 10 min, block 20 min + }; + + constructor(redisClient?: Redis, keyPrefix: string = 'sso_rate_limit') { + this.keyPrefix = keyPrefix; + + if (redisClient) { + this.redis = redisClient; + } else { + // Create Redis client with fallback to environment variables + const redisUrl = process.env.REDIS_URL || process.env.REDIS_CONNECTION_STRING; + + if (redisUrl) { + this.redis = new Redis(redisUrl, { + maxRetriesPerRequest: 3, + lazyConnect: true, + family: 4, // IPv4 + }); + } else { + // Fallback to localhost Redis + this.redis = new Redis({ + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT || '6379'), + password: process.env.REDIS_PASSWORD, + db: parseInt(process.env.REDIS_DB || '0'), + maxRetriesPerRequest: 3, + lazyConnect: true, + family: 4, + }); + } + } + + // Handle Redis connection events (set up for all Redis clients) + this.redis.on('error', (error) => { + console.error('Redis rate limiter connection error:', error); + }); + + this.redis.on('connect', () => { + console.log('Redis rate limiter connected successfully'); + }); + } + + /** + * Check rate limiting for a specific operation + */ + async checkRateLimit( + req: Request, + operation: 'login' | 'callback' | 'token', + providerType: string + ): Promise { + const config = RedisRateLimiter.configs[operation]; + if (!config) { + throw new Error(`Unknown operation type: ${operation}`); + } + + const clientId = this.getClientIdentifier(req); + const key = `${this.keyPrefix}:${providerType}:${operation}:${clientId}`; + const blockKey = `${key}:blocked`; + const now = Date.now(); + + try { + // Check if currently blocked + const blockedUntil = await this.redis.get(blockKey); + if (blockedUntil && now < parseInt(blockedUntil)) { + const retryAfter = Math.ceil((parseInt(blockedUntil) - now) / 1000); + return { allowed: false, retryAfter }; + } + + // Use Redis pipeline for atomic operations + const pipeline = this.redis.pipeline(); + + // Get current attempt count and timestamp + pipeline.hgetall(key); + + const results = await pipeline.exec(); + const data = results?.[0]?.[1] as Record || {}; + + const attempts = parseInt(data.attempts || '0'); + const firstAttempt = parseInt(data.firstAttempt || now.toString()); + + // Check if window has expired + if (now - firstAttempt > config.windowMs) { + // Reset window + await this.redis.hmset(key, { + attempts: '1', + firstAttempt: now.toString() + }); + await this.redis.expire(key, Math.ceil(config.windowMs / 1000)); + + return { + allowed: true, + attempts: 1, + remaining: config.maxAttempts - 1 + }; + } + + // Increment attempts + const newAttempts = attempts + 1; + + if (newAttempts > config.maxAttempts) { + // Block the client + const blockUntil = now + config.blockDurationMs; + await this.redis.setex(blockKey, Math.ceil(config.blockDurationMs / 1000), blockUntil.toString()); + + const retryAfter = Math.ceil(config.blockDurationMs / 1000); + return { allowed: false, retryAfter, attempts: newAttempts }; + } + + // Update attempt count + await this.redis.hmset(key, { + attempts: newAttempts.toString(), + firstAttempt: firstAttempt.toString() + }); + await this.redis.expire(key, Math.ceil(config.windowMs / 1000)); + + return { + allowed: true, + attempts: newAttempts, + remaining: config.maxAttempts - newAttempts + }; + + } catch (error) { + console.error('Redis rate limiter error:', error); + + // Fail open - allow the request if Redis is down + // In production, you might want to fail closed instead + return { allowed: true }; + } + } + + /** + * Get client identifier for rate limiting + */ + private getClientIdentifier(req: Request): string { + // Use IP + User-Agent for better identification while preserving privacy + const forwarded = req.headers['x-forwarded-for'] as string; + const ip = forwarded ? forwarded.split(',')[0].trim() : req.socket.remoteAddress || 'unknown'; + const userAgent = req.headers['user-agent'] || 'unknown'; + + // Create a hash for privacy + return crypto.createHash('sha256') + .update(`${ip}-${userAgent}`) + .digest('hex') + .substring(0, 16); // Truncate for storage efficiency + } + + /** + * Reset rate limit for a specific client and operation (for testing/admin purposes) + */ + async resetRateLimit( + req: Request, + operation: 'login' | 'callback' | 'token', + providerType: string + ): Promise { + const clientId = this.getClientIdentifier(req); + const key = `${this.keyPrefix}:${providerType}:${operation}:${clientId}`; + const blockKey = `${key}:blocked`; + + try { + await this.redis.del(key, blockKey); + } catch (error) { + console.error('Failed to reset rate limit:', error); + throw error; + } + } + + /** + * Get rate limit status for a client without incrementing + */ + async getRateLimitStatus( + req: Request, + operation: 'login' | 'callback' | 'token', + providerType: string + ): Promise<{ attempts: number; remaining: number; blocked: boolean; blockUntil?: number }> { + const config = RedisRateLimiter.configs[operation]; + const clientId = this.getClientIdentifier(req); + const key = `${this.keyPrefix}:${providerType}:${operation}:${clientId}`; + const blockKey = `${key}:blocked`; + const now = Date.now(); + + try { + const [data, blockedUntil] = await Promise.all([ + this.redis.hgetall(key), + this.redis.get(blockKey) + ]); + + const attempts = parseInt(data.attempts || '0'); + const firstAttempt = parseInt(data.firstAttempt || now.toString()); + + // Check if blocked + if (blockedUntil && now < parseInt(blockedUntil)) { + return { + attempts, + remaining: 0, + blocked: true, + blockUntil: parseInt(blockedUntil) + }; + } + + // Check if window has expired + if (now - firstAttempt > config.windowMs) { + return { + attempts: 0, + remaining: config.maxAttempts, + blocked: false + }; + } + + return { + attempts, + remaining: Math.max(0, config.maxAttempts - attempts), + blocked: false + }; + } catch (error) { + console.error('Failed to get rate limit status:', error); + return { + attempts: 0, + remaining: config.maxAttempts, + blocked: false + }; + } + } + + /** + * Clean up expired rate limit entries (called periodically) + */ + async cleanup(): Promise { + try { + const pattern = `${this.keyPrefix}:*`; + const keys = await this.redis.keys(pattern); + + if (keys.length === 0) { + return 0; + } + + // Redis will automatically expire keys, but this helps with cleanup + // of any keys that might not have TTL set properly + let cleaned = 0; + const now = Date.now(); + + for (const key of keys) { + const ttl = await this.redis.ttl(key); + if (ttl === -1) { + // Key exists but has no TTL - set a reasonable TTL + await this.redis.expire(key, 3600); // 1 hour default TTL + cleaned++; + } + } + + return cleaned; + } catch (error) { + console.error('Failed to cleanup rate limit entries:', error); + return 0; + } + } + + /** + * Get rate limiter health status + */ + async healthCheck(): Promise<{ healthy: boolean; message?: string }> { + try { + const start = Date.now(); + await this.redis.ping(); + const latency = Date.now() - start; + + if (latency > 100) { + return { + healthy: true, + message: `Redis responding but slow (${latency}ms latency)` + }; + } + + return { + healthy: true, + message: `Redis healthy (${latency}ms latency)` + }; + } catch (error) { + return { + healthy: false, + message: `Redis connection failed: ${error instanceof Error ? error.message : 'Unknown error'}` + }; + } + } + + /** + * Close Redis connection + */ + async close(): Promise { + try { + await this.redis.quit(); + } catch (error) { + console.error('Error closing Redis connection:', error); + } + } +} + +// Create singleton instance +let rateLimiterInstance: RedisRateLimiter | null = null; + +export function getRedisRateLimiter(): RedisRateLimiter { + if (!rateLimiterInstance) { + rateLimiterInstance = new RedisRateLimiter(); + } + return rateLimiterInstance; +} + +export default RedisRateLimiter; \ No newline at end of file diff --git a/Servers/utils/sso-audit-logger.utils.ts b/Servers/utils/sso-audit-logger.utils.ts new file mode 100644 index 000000000..ce8fc3f5c --- /dev/null +++ b/Servers/utils/sso-audit-logger.utils.ts @@ -0,0 +1,577 @@ +/** + * @fileoverview SSO Security Audit Logger Utilities + * + * Comprehensive audit logging system for Azure AD Single Sign-On security events. + * Provides structured logging with security level classification, data masking, + * and comprehensive event tracking for compliance and security monitoring. + * + * This utility ensures that all SSO-related security events are properly logged + * with appropriate detail levels for: + * - Security incident investigation and forensics + * - Compliance auditing (SOC 2, ISO 27001, etc.) + * - Performance monitoring and analytics + * - Threat detection and security monitoring + * + * Security Features: + * - Automatic data masking for PII (email, IP addresses) in production + * - Security level classification (LOW, MEDIUM, HIGH, CRITICAL) + * - Structured logging with consistent schema for SIEM integration + * - Request metadata extraction for forensic analysis + * - Color-coded console output for development debugging + * - Winston integration for enterprise logging infrastructure + * + * Event Categories: + * - Authentication events (login initiation, success, failure) + * - Configuration changes (create, update, delete, enable/disable) + * - Security violations (CSRF attempts, domain validation failures) + * - Rate limiting events and suspicious activity detection + * - Azure AD integration events (token exchange, callback processing) + * + * Compliance Features: + * - Immutable audit trail with timestamps + * - User identification and session tracking + * - IP address and user agent logging for forensics + * - Error message logging for troubleshooting + * - Additional metadata for context preservation + * + * @author VerifyWise Development Team + * @since 2024-09-28 + * @version 1.0.0 + * @see {@link https://owasp.org/www-project-application-security-verification-standard/} OWASP ASVS Logging Requirements + * @see {@link https://www.nist.gov/privacy-framework} NIST Privacy Framework + * + * @module utils/sso-audit-logger + */ + +import logger from '../utils/logger/fileLogger'; +import { Request } from 'express'; + +/** + * SSO audit event structure for comprehensive security logging + * + * Defines the standardized schema for all SSO security events, ensuring + * consistent audit trail format for compliance and security monitoring. + * All events follow this structure for SIEM integration and forensic analysis. + * + * @interface SSOAuditEvent + * @property {string} event_type - Specific type of SSO event (e.g., 'SSO_LOGIN_INITIATION') + * @property {string} organization_id - Organization identifier for multi-tenant isolation + * @property {string} [user_id] - Internal user ID if user is authenticated + * @property {string} [user_email] - User email address (masked in production) + * @property {string} ip_address - Client IP address (partially masked in production) + * @property {string} [user_agent] - Client user agent string for device identification + * @property {string} [session_id] - Session identifier for request correlation + * @property {string} [azure_object_id] - Azure AD object ID for user correlation + * @property {boolean} success - Whether the operation succeeded or failed + * @property {string} [error_message] - Error description if operation failed + * @property {'LOW'|'MEDIUM'|'HIGH'|'CRITICAL'} security_level - Security risk classification + * @property {Record} [additional_data] - Event-specific metadata + * @property {string} timestamp - ISO 8601 timestamp of the event + * + * @example + * ```typescript + * const auditEvent: SSOAuditEvent = { + * event_type: 'SSO_USER_AUTHENTICATED', + * organization_id: '123', + * user_id: '456', + * user_email: 'user@company.com', + * ip_address: '192.168.1.100', + * user_agent: 'Mozilla/5.0...', + * azure_object_id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + * success: true, + * security_level: 'LOW', + * timestamp: '2024-09-28T10:30:00.000Z' + * }; + * ``` + */ +export interface SSOAuditEvent { + event_type: string; + organization_id: string; + user_id?: string; + user_email?: string; + ip_address: string; + user_agent?: string; + session_id?: string; + azure_object_id?: string; + success: boolean; + error_message?: string; + security_level: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'; + additional_data?: Record; + timestamp: string; +} + +/** + * SSO Security Audit Logger Class + * + * Comprehensive audit logging system for SSO security events with automatic + * data masking, security level classification, and structured event tracking. + * Provides standardized logging methods for all SSO operations. + * + * Key Features: + * - Automatic PII masking in production environments + * - Security level classification for event prioritization + * - Structured JSON logging for SIEM integration + * - Request metadata extraction for forensic analysis + * - Color-coded console output for development debugging + * - Winston logger integration for enterprise logging + * + * Security Levels: + * - LOW: Normal operations (successful login, token refresh) + * - MEDIUM: Important events (callback processing, new user creation) + * - HIGH: Security concerns (authentication failures, config changes) + * - CRITICAL: Security violations (CSRF attempts, unauthorized access) + * + * @class SSOAuditLogger + * @static + * + * @example + * ```typescript + * // Log successful authentication + * SSOAuditLogger.logSuccessfulAuthentication( + * req, '123', '456', 'user@company.com', 'azure-obj-id' + * ); + * + * // Log security violation + * SSOAuditLogger.logSecurityViolation( + * req, '123', 'CSRF_ATTEMPT', 'Invalid state token detected' + * ); + * ``` + */ +export class SSOAuditLogger { + /** + * Extracts relevant request metadata for comprehensive audit logging + * + * Gathers forensic information from HTTP requests including client identification, + * session tracking, and network information for security investigation purposes. + * Handles missing metadata gracefully with fallback values. + * + * @private + * @static + * @param {Request} req - Express request object + * @returns {Object} Request metadata for audit logging + * @returns {string} returns.ip_address - Client IP address (with fallbacks) + * @returns {string} [returns.user_agent] - Client user agent string + * @returns {string} [returns.session_id] - Session identifier if available + * + * @security + * - IP address detection with multiple fallback sources + * - Safe type casting for session ID extraction + * - Graceful handling of missing headers + * + * @example + * ```typescript + * const metadata = SSOAuditLogger.extractRequestMetadata(req); + * // Returns: { ip_address: '192.168.1.100', user_agent: 'Mozilla/5.0...', session_id: 'sess_123' } + * ``` + */ + private static extractRequestMetadata(req: Request): { + ip_address: string; + user_agent?: string; + session_id?: string; + } { + return { + ip_address: req.ip || req.connection.remoteAddress || 'unknown', + user_agent: req.get('User-Agent'), + session_id: (req as any).sessionID, + }; + } + + /** + * Core audit logging function + */ + private static logAuditEvent(event: SSOAuditEvent): void { + const maskedEvent = this.maskSensitiveData(event); + + // Log to Winston with structured format + logger.info(`SSO_AUDIT: ${JSON.stringify(maskedEvent)}`, { + component: 'SSO_AUDIT', + security_level: event.security_level, + event_type: event.event_type, + organization_id: event.organization_id, + timestamp: event.timestamp + }); + + // Console log for development with color coding + if (process.env.NODE_ENV !== 'production') { + const color = this.getLogColor(event.security_level); + console.log(`${color}[SSO_AUDIT] ${event.security_level}: ${event.event_type}\x1b[0m`); + console.log(JSON.stringify(maskedEvent, null, 2)); + } + } + + /** + * Get console color for log level + */ + private static getLogColor(level: string): string { + switch (level) { + case 'CRITICAL': return '\x1b[41m'; // Red background + case 'HIGH': return '\x1b[31m'; // Red text + case 'MEDIUM': return '\x1b[33m'; // Yellow text + case 'LOW': return '\x1b[36m'; // Cyan text + default: return '\x1b[0m'; // Reset + } + } + + /** + * Mask sensitive data in audit logs + */ + private static maskSensitiveData(event: SSOAuditEvent): SSOAuditEvent { + const masked = { ...event }; + + // Mask email address in production + if (process.env.NODE_ENV === 'production' && masked.user_email) { + const parts = masked.user_email.split('@'); + if (parts.length === 2) { + masked.user_email = `${parts[0]}@[DOMAIN]`; + } + } + + // Mask IP address in production (keep first 3 octets) + if (process.env.NODE_ENV === 'production' && masked.ip_address && masked.ip_address.includes('.')) { + const parts = masked.ip_address.split('.'); + if (parts.length === 4) { + masked.ip_address = `${parts[0]}.${parts[1]}.${parts[2]}.xxx`; + } + } + + return masked; + } + + /** + * Logs SSO login initiation attempts for security monitoring + * + * Records when users begin the SSO authentication process, providing early + * detection of authentication patterns and potential security issues. + * Essential for monitoring login frequency and detecting unusual activity. + * + * @static + * @param {Request} req - Express request object for metadata extraction + * @param {string} organizationId - Organization identifier for multi-tenant tracking + * @param {boolean} success - Whether login initiation was successful + * @param {string} [error] - Error message if initiation failed + * @returns {void} + * + * @security_level + * - LOW: Successful login initiation (normal operation) + * - MEDIUM: Failed login initiation (potential configuration issues) + * + * @example + * ```typescript + * // Successful login initiation + * SSOAuditLogger.logLoginInitiation(req, '123', true); + * + * // Failed login initiation + * SSOAuditLogger.logLoginInitiation(req, '123', false, 'SSO not configured'); + * ``` + */ + static logLoginInitiation(req: Request, organizationId: string, success: boolean, error?: string): void { + const event: SSOAuditEvent = { + event_type: 'SSO_LOGIN_INITIATION', + organization_id: organizationId, + success, + error_message: error, + security_level: success ? 'LOW' : 'MEDIUM', + timestamp: new Date().toISOString(), + ...this.extractRequestMetadata(req) + }; + + this.logAuditEvent(event); + } + + /** + * Log SSO callback processing + */ + static logCallbackProcessing( + req: Request, + organizationId: string, + userEmail?: string, + azureObjectId?: string, + success: boolean = true, + error?: string + ): void { + const event: SSOAuditEvent = { + event_type: 'SSO_CALLBACK_PROCESSING', + organization_id: organizationId, + user_email: userEmail, + azure_object_id: azureObjectId, + success, + error_message: error, + security_level: success ? 'MEDIUM' : 'HIGH', + timestamp: new Date().toISOString(), + ...this.extractRequestMetadata(req) + }; + + this.logAuditEvent(event); + } + + /** + * Logs successful SSO authentication events with user details + * + * Records completed SSO authentication including user identification and + * whether this represents a new user creation. Critical for security + * monitoring, user onboarding tracking, and access pattern analysis. + * + * @static + * @param {Request} req - Express request object for metadata extraction + * @param {string} organizationId - Organization identifier for multi-tenant tracking + * @param {string} userId - Internal user ID from the application database + * @param {string} userEmail - User email address (will be masked in production) + * @param {string} azureObjectId - Azure AD object ID for correlation + * @param {boolean} [isNewUser=false] - Whether this is a newly created user account + * @returns {void} + * + * @security_level + * - LOW: Existing user authentication (normal operation) + * - MEDIUM: New user creation (elevated monitoring for onboarding) + * + * @audit_value + * - Tracks successful authentication for access pattern analysis + * - Correlates application and Azure AD user identities + * - Monitors new user onboarding through SSO + * - Provides session correlation for security investigation + * + * @example + * ```typescript + * // Existing user authentication + * SSOAuditLogger.logSuccessfulAuthentication( + * req, '123', '456', 'user@company.com', 'azure-object-id-here' + * ); + * + * // New user creation through SSO + * SSOAuditLogger.logSuccessfulAuthentication( + * req, '123', '789', 'newuser@company.com', 'azure-object-id-new', true + * ); + * ``` + */ + static logSuccessfulAuthentication( + req: Request, + organizationId: string, + userId: string, + userEmail: string, + azureObjectId: string, + isNewUser: boolean = false + ): void { + const event: SSOAuditEvent = { + event_type: isNewUser ? 'SSO_USER_CREATED' : 'SSO_USER_AUTHENTICATED', + organization_id: organizationId, + user_id: userId, + user_email: userEmail, + azure_object_id: azureObjectId, + success: true, + security_level: isNewUser ? 'MEDIUM' : 'LOW', + additional_data: { is_new_user: isNewUser }, + timestamp: new Date().toISOString(), + ...this.extractRequestMetadata(req) + }; + + this.logAuditEvent(event); + } + + /** + * Log SSO authentication failure + */ + static logAuthenticationFailure( + req: Request, + organizationId: string, + reason: string, + userEmail?: string + ): void { + const event: SSOAuditEvent = { + event_type: 'SSO_AUTHENTICATION_FAILURE', + organization_id: organizationId, + user_email: userEmail, + success: false, + error_message: reason, + security_level: 'HIGH', + timestamp: new Date().toISOString(), + ...this.extractRequestMetadata(req) + }; + + this.logAuditEvent(event); + } + + /** + * Log SSO configuration changes + */ + static logConfigurationChange( + req: Request, + organizationId: string, + adminUserId: string, + changeType: 'CREATE' | 'UPDATE' | 'DELETE' | 'ENABLE' | 'DISABLE', + changedFields?: string[] + ): void { + const event: SSOAuditEvent = { + event_type: `SSO_CONFIG_${changeType}`, + organization_id: organizationId, + user_id: adminUserId, + success: true, + security_level: 'HIGH', + additional_data: { + change_type: changeType, + changed_fields: changedFields + }, + timestamp: new Date().toISOString(), + ...this.extractRequestMetadata(req) + }; + + this.logAuditEvent(event); + } + + /** + * Logs security violations and suspicious activities with CRITICAL priority + * + * Records high-priority security events including CSRF attempts, unauthorized + * access, and other suspicious activities. These logs trigger immediate alerts + * and require investigation for potential security incidents. + * + * @static + * @param {Request} req - Express request object for metadata extraction + * @param {string} organizationId - Organization identifier for multi-tenant tracking + * @param {string} violationType - Type of security violation (e.g., 'CSRF_ATTEMPT') + * @param {string} description - Detailed description of the security violation + * @param {string} [userEmail] - User email if known (will be masked in production) + * @returns {void} + * + * @security_level CRITICAL - Requires immediate attention and investigation + * + * @violation_types + * - CSRF_ATTEMPT: Invalid state token or cross-site request forgery + * - UNAUTHORIZED_ACCESS: Access attempt without proper authorization + * - SUSPICIOUS_LOGIN_PATTERN: Unusual login behavior or frequency + * - CONFIGURATION_TAMPERING: Unauthorized SSO configuration changes + * - TOKEN_MANIPULATION: Invalid or tampered authentication tokens + * + * @example + * ```typescript + * // Log CSRF attempt + * SSOAuditLogger.logSecurityViolation( + * req, '123', 'CSRF_ATTEMPT', 'Invalid state token detected', 'attacker@evil.com' + * ); + * + * // Log unauthorized access attempt + * SSOAuditLogger.logSecurityViolation( + * req, '123', 'UNAUTHORIZED_ACCESS', 'Access attempt to admin endpoint without authorization' + * ); + * ``` + */ + static logSecurityViolation( + req: Request, + organizationId: string, + violationType: string, + description: string, + userEmail?: string + ): void { + const event: SSOAuditEvent = { + event_type: 'SSO_SECURITY_VIOLATION', + organization_id: organizationId, + user_email: userEmail, + success: false, + error_message: description, + security_level: 'CRITICAL', + additional_data: { + violation_type: violationType, + description + }, + timestamp: new Date().toISOString(), + ...this.extractRequestMetadata(req) + }; + + this.logAuditEvent(event); + } + + /** + * Log rate limiting events + */ + static logRateLimitExceeded(req: Request, organizationId: string, endpointType: string): void { + const event: SSOAuditEvent = { + event_type: 'SSO_RATE_LIMIT_EXCEEDED', + organization_id: organizationId, + success: false, + error_message: `Rate limit exceeded for ${endpointType}`, + security_level: 'MEDIUM', + additional_data: { + endpoint_type: endpointType, + rate_limit_exceeded: true + }, + timestamp: new Date().toISOString(), + ...this.extractRequestMetadata(req) + }; + + this.logAuditEvent(event); + } + + /** + * Log state token validation failures (CSRF attempts) + */ + static logStateTokenFailure(req: Request, organizationId: string, reason: string): void { + const event: SSOAuditEvent = { + event_type: 'SSO_STATE_TOKEN_FAILURE', + organization_id: organizationId, + success: false, + error_message: reason, + security_level: 'CRITICAL', + additional_data: { + csrf_protection: true, + validation_failure: reason + }, + timestamp: new Date().toISOString(), + ...this.extractRequestMetadata(req) + }; + + this.logAuditEvent(event); + } + + /** + * Log domain validation failures + */ + static logDomainValidationFailure( + req: Request, + organizationId: string, + userEmail: string, + allowedDomains: string[] + ): void { + const event: SSOAuditEvent = { + event_type: 'SSO_DOMAIN_VALIDATION_FAILURE', + organization_id: organizationId, + user_email: userEmail, + success: false, + error_message: 'Email domain not allowed for SSO', + security_level: 'HIGH', + additional_data: { + allowed_domains: allowedDomains, + user_domain: userEmail.split('@')[1] + }, + timestamp: new Date().toISOString(), + ...this.extractRequestMetadata(req) + }; + + this.logAuditEvent(event); + } + + /** + * Log Azure AD token exchange events + */ + static logTokenExchange( + req: Request, + organizationId: string, + success: boolean, + error?: string + ): void { + const event: SSOAuditEvent = { + event_type: 'SSO_TOKEN_EXCHANGE', + organization_id: organizationId, + success, + error_message: error, + security_level: success ? 'LOW' : 'HIGH', + additional_data: { + azure_ad_integration: true + }, + timestamp: new Date().toISOString(), + ...this.extractRequestMetadata(req) + }; + + this.logAuditEvent(event); + } +} + +export default SSOAuditLogger; \ No newline at end of file diff --git a/Servers/utils/sso-config-validator.utils.ts b/Servers/utils/sso-config-validator.utils.ts new file mode 100644 index 000000000..16ba0e1e7 --- /dev/null +++ b/Servers/utils/sso-config-validator.utils.ts @@ -0,0 +1,649 @@ +/** + * @fileoverview SSO Configuration Validator Utilities + * + * Comprehensive validation system for Azure AD Single Sign-On configurations. + * Provides multi-layered validation including format validation, security checks, + * MSAL configuration testing, and organizational readiness assessment. + * + * This utility ensures that SSO configurations are: + * - Correctly formatted according to Azure AD requirements + * - Secure and follow best practices + * - Functionally valid through MSAL integration testing + * - Appropriate for the organization's current state + * + * Validation Layers: + * 1. Format Validation - GUID patterns, string formats, enum values + * 2. Security Validation - Weak password detection, placeholder identification + * 3. Business Logic Validation - Policy combinations, domain restrictions + * 4. Technical Validation - MSAL client creation, Azure AD connectivity + * 5. Organizational Validation - User readiness, migration considerations + * + * Security Features: + * - Detects common weak passwords and placeholders + * - Validates Azure AD GUID formats to prevent injection + * - Warns about insecure authentication policies + * - Identifies potentially problematic email domain configurations + * - Provides organizational readiness assessment for SSO migration + * + * Error Categorization: + * - Errors: Configuration issues that prevent SSO functionality + * - Warnings: Potential security or usability concerns that should be addressed + * + * @author VerifyWise Development Team + * @since 2024-09-28 + * @version 1.0.0 + * @see {@link https://docs.microsoft.com/en-us/azure/active-directory/develop/} Azure AD Developer Documentation + * @see {@link https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-overview} MSAL Documentation + * + * @module utils/sso-config-validator + */ + +import { ConfidentialClientApplication } from '@azure/msal-node'; +import { SSOErrorHandler, SSOErrorCodes } from './sso-error-handler.utils'; + +/** + * Standardized validation result interface + * + * Provides structured validation feedback with clear separation between + * blocking errors and advisory warnings for SSO configuration validation. + * + * @interface ValidationResult + * @property {boolean} isValid - Whether validation passed (no errors) + * @property {string[]} errors - Blocking issues that prevent SSO functionality + * @property {string[]} warnings - Advisory issues that should be addressed + * + * @example + * ```typescript + * const result = await SSOConfigValidator.validateAzureADConfig(config); + * if (!result.isValid) { + * console.error('Validation failed:', result.errors); + * } + * if (result.warnings.length > 0) { + * console.warn('Validation warnings:', result.warnings); + * } + * ``` + */ +export interface ValidationResult { + isValid: boolean; + errors: string[]; + warnings: string[]; +} + +/** + * Azure AD configuration interface for validation + * + * Defines the structure of Azure AD configuration data required + * for comprehensive SSO validation including MSAL testing. + * + * @interface AzureADValidationConfig + * @property {string} tenant_id - Azure AD tenant identifier (GUID format) + * @property {string} client_id - Azure AD application client identifier (GUID format) + * @property {string} client_secret - Azure AD application client secret + * @property {'AzurePublic' | 'AzureGovernment'} cloud_environment - Azure cloud environment + * + * @example + * ```typescript + * const config: AzureADValidationConfig = { + * tenant_id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + * client_id: 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy', + * client_secret: 'secure_client_secret_from_azure', + * cloud_environment: 'AzurePublic' + * }; + * ``` + */ +export interface AzureADValidationConfig { + tenant_id: string; + client_id: string; + client_secret: string; + cloud_environment: 'AzurePublic' | 'AzureGovernment'; +} + +/** + * SSO Configuration Validator Class + * + * Comprehensive validation system for Azure AD SSO configurations with multi-layered + * validation approach. Provides static methods for validating different aspects of + * SSO configuration from basic format validation to complex organizational readiness. + * + * Key Features: + * - Multi-layer validation (format, security, business logic, technical, organizational) + * - Azure AD GUID format validation with security checks + * - Client secret strength validation and placeholder detection + * - MSAL configuration testing for technical validation + * - Organizational readiness assessment for SSO migration + * - Comprehensive error and warning categorization + * + * Validation Philosophy: + * - Errors block SSO functionality and must be fixed + * - Warnings indicate potential issues but don't prevent configuration + * - All validations return structured ValidationResult for consistent handling + * - Security-first approach with placeholder and weak password detection + * + * @class SSOConfigValidator + * @static + * + * @example + * ```typescript + * // Validate complete SSO configuration + * const result = await SSOConfigValidator.validateSSOConfiguration({ + * azure_tenant_id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + * azure_client_id: 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy', + * azure_client_secret: 'secure_secret_from_azure', + * cloud_environment: 'AzurePublic', + * auth_method_policy: 'both' + * }); + * + * if (!result.isValid) { + * console.error('Configuration errors:', result.errors); + * } + * ``` + */ +export class SSOConfigValidator { + /** Azure AD tenant ID GUID pattern (RFC 4122 compliant) */ + private static readonly AZURE_TENANT_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + + /** Azure AD client ID GUID pattern (RFC 4122 compliant) */ + private static readonly AZURE_CLIENT_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + + /** Email domain validation pattern (RFC 1035 compliant) */ + private static readonly EMAIL_DOMAIN_PATTERN = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; + + /** + * Validates complete Azure AD SSO configuration with comprehensive multi-layer validation + * + * Performs comprehensive validation of Azure AD configuration including format validation, + * security checks, and MSAL integration testing. This is the primary validation method + * for Azure AD configurations and should be used before saving configurations. + * + * @static + * @async + * @param {AzureADValidationConfig} config - Azure AD configuration to validate + * @returns {Promise} Comprehensive validation result with errors and warnings + * + * @validation_layers + * 1. Tenant ID validation (GUID format, placeholder detection) + * 2. Client ID validation (GUID format, placeholder detection) + * 3. Client secret validation (strength, length, placeholder detection) + * 4. Cloud environment validation (supported environments) + * 5. MSAL configuration testing (client creation, authority validation) + * + * @example + * ```typescript + * const config: AzureADValidationConfig = { + * tenant_id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + * client_id: 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy', + * client_secret: 'secure_client_secret_from_azure', + * cloud_environment: 'AzurePublic' + * }; + * + * const result = await SSOConfigValidator.validateAzureADConfig(config); + * if (!result.isValid) { + * console.error('Azure AD configuration errors:', result.errors); + * // Handle configuration errors before proceeding + * } + * if (result.warnings.length > 0) { + * console.warn('Configuration warnings:', result.warnings); + * // Consider addressing warnings for better security + * } + * ``` + */ + static async validateAzureADConfig(config: AzureADValidationConfig): Promise { + const errors: string[] = []; + const warnings: string[] = []; + + // Validate tenant ID + const tenantValidation = this.validateTenantId(config.tenant_id); + if (!tenantValidation.isValid) { + errors.push(...tenantValidation.errors); + } + warnings.push(...tenantValidation.warnings); + + // Validate client ID + const clientIdValidation = this.validateClientId(config.client_id); + if (!clientIdValidation.isValid) { + errors.push(...clientIdValidation.errors); + } + warnings.push(...clientIdValidation.warnings); + + // Validate client secret + const secretValidation = this.validateClientSecret(config.client_secret); + if (!secretValidation.isValid) { + errors.push(...secretValidation.errors); + } + warnings.push(...secretValidation.warnings); + + // Validate cloud environment + const cloudValidation = this.validateCloudEnvironment(config.cloud_environment); + if (!cloudValidation.isValid) { + errors.push(...cloudValidation.errors); + } + + // Test MSAL configuration if basic validation passes + if (errors.length === 0) { + const msalValidation = await this.validateMSALConfiguration(config); + if (!msalValidation.isValid) { + errors.push(...msalValidation.errors); + } + warnings.push(...msalValidation.warnings); + } + + return { + isValid: errors.length === 0, + errors, + warnings + }; + } + + /** + * Validates Azure AD Tenant ID format and detects common issues + * + * Performs comprehensive validation of Azure AD tenant ID including GUID format + * validation and detection of common placeholder or test values that would + * prevent successful Azure AD authentication. + * + * @static + * @param {string} tenantId - Azure AD tenant ID to validate + * @returns {ValidationResult} Validation result with specific tenant ID errors + * + * @validation_checks + * - Required field validation + * - GUID format validation (RFC 4122) + * - Placeholder value detection (common test GUIDs) + * - Reserved Azure AD values detection (common, organizations, consumers) + * + * @example + * ```typescript + * const result = SSOConfigValidator.validateTenantId('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'); + * if (!result.isValid) { + * console.error('Invalid tenant ID:', result.errors); + * } + * ``` + */ + static validateTenantId(tenantId: string): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + if (!tenantId || typeof tenantId !== 'string') { + errors.push('Tenant ID is required'); + return { isValid: false, errors, warnings }; + } + + // Check GUID format + if (!this.AZURE_TENANT_ID_PATTERN.test(tenantId)) { + errors.push('Tenant ID must be a valid GUID format (e.g., 12345678-1234-1234-1234-123456789abc)'); + } + + // Check for common test/invalid tenant IDs + const invalidTenants = [ + '00000000-0000-0000-0000-000000000000', + '11111111-1111-1111-1111-111111111111', + 'common', + 'organizations', + 'consumers' + ]; + + if (invalidTenants.includes(tenantId.toLowerCase())) { + errors.push('Tenant ID appears to be a placeholder or invalid. Please use your actual Azure AD tenant ID.'); + } + + return { + isValid: errors.length === 0, + errors, + warnings + }; + } + + /** + * Validate Azure AD Application (Client) ID format + */ + static validateClientId(clientId: string): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + if (!clientId || typeof clientId !== 'string') { + errors.push('Client ID is required'); + return { isValid: false, errors, warnings }; + } + + // Check GUID format + if (!this.AZURE_CLIENT_ID_PATTERN.test(clientId)) { + errors.push('Client ID must be a valid GUID format (e.g., 12345678-1234-1234-1234-123456789abc)'); + } + + // Check for common test/invalid client IDs + if (clientId === '00000000-0000-0000-0000-000000000000') { + errors.push('Client ID appears to be a placeholder. Please use your actual Azure AD application client ID.'); + } + + return { + isValid: errors.length === 0, + errors, + warnings + }; + } + + /** + * Validate Azure AD Client Secret strength and format + */ + static validateClientSecret(clientSecret: string): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + if (!clientSecret || typeof clientSecret !== 'string') { + errors.push('Client Secret is required'); + return { isValid: false, errors, warnings }; + } + + // Check minimum length + if (clientSecret.length < 8) { + errors.push('Client Secret must be at least 8 characters long'); + } + + // Check maximum length (Azure AD secrets are typically 40-50 characters) + if (clientSecret.length > 200) { + errors.push('Client Secret is unusually long. Please verify it is correct.'); + } + + // Check for common weak secrets + const weakSecrets = [ + 'password', + '12345678', + 'secret123', + 'mysecret', + 'client_secret' + ]; + + if (weakSecrets.includes(clientSecret.toLowerCase())) { + errors.push('Client Secret appears to be a common weak password. Please use the actual Azure AD client secret.'); + } + + // Check for placeholder patterns + if (/^(your|my|test|demo|sample)[_-]?(secret|password|key)/i.test(clientSecret)) { + errors.push('Client Secret appears to be a placeholder. Please use your actual Azure AD client secret.'); + } + + // Warn about secret format + if (!/^[A-Za-z0-9._~-]+$/.test(clientSecret)) { + warnings.push('Client Secret contains unusual characters. Ensure it is correctly copied from Azure AD.'); + } + + return { + isValid: errors.length === 0, + errors, + warnings + }; + } + + /** + * Validate Azure Cloud Environment + */ + static validateCloudEnvironment(cloudEnvironment: string): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + const validEnvironments = ['AzurePublic', 'AzureGovernment']; + + if (!cloudEnvironment || typeof cloudEnvironment !== 'string') { + errors.push('Cloud Environment is required'); + return { isValid: false, errors, warnings }; + } + + if (!validEnvironments.includes(cloudEnvironment)) { + errors.push(`Cloud Environment must be one of: ${validEnvironments.join(', ')}`); + } + + return { + isValid: errors.length === 0, + errors, + warnings + }; + } + + /** + * Validate authentication method policy + */ + static validateAuthMethodPolicy(policy: string): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + const validPolicies = ['sso_only', 'password_only', 'both']; + + if (!policy || typeof policy !== 'string') { + errors.push('Authentication method policy is required'); + return { isValid: false, errors, warnings }; + } + + if (!validPolicies.includes(policy)) { + errors.push(`Authentication method policy must be one of: ${validPolicies.join(', ')}`); + } + + // Security warnings + if (policy === 'password_only') { + warnings.push('Password-only authentication is less secure than SSO. Consider using "both" or "sso_only".'); + } + + return { + isValid: errors.length === 0, + errors, + warnings + }; + } + + /** + * Validate email domains list + */ + static validateEmailDomains(domains: string[]): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + if (!Array.isArray(domains)) { + errors.push('Email domains must be an array'); + return { isValid: false, errors, warnings }; + } + + if (domains.length === 0) { + warnings.push('No email domains specified. SSO will be available to all email addresses.'); + return { isValid: true, errors, warnings }; + } + + for (const domain of domains) { + if (!domain || typeof domain !== 'string') { + errors.push('Each email domain must be a non-empty string'); + continue; + } + + // Remove leading @ if present + const cleanDomain = domain.startsWith('@') ? domain.slice(1) : domain; + + if (!this.EMAIL_DOMAIN_PATTERN.test(cleanDomain)) { + errors.push(`Invalid email domain format: ${domain}`); + } + + // Warn about common free email providers + const freeProviders = ['gmail.com', 'yahoo.com', 'hotmail.com', 'outlook.com', 'live.com']; + if (freeProviders.includes(cleanDomain.toLowerCase())) { + warnings.push(`Email domain "${cleanDomain}" is a free email provider. Consider restricting to your organization's domain.`); + } + } + + return { + isValid: errors.length === 0, + errors, + warnings + }; + } + + /** + * Test MSAL configuration by attempting to create a client + */ + private static async validateMSALConfiguration(config: AzureADValidationConfig): Promise { + const errors: string[] = []; + const warnings: string[] = []; + + try { + // Get the correct authority URL based on cloud environment + const authorityBase = config.cloud_environment === 'AzureGovernment' + ? 'https://login.microsoftonline.us' + : 'https://login.microsoftonline.com'; + + const authority = `${authorityBase}/${config.tenant_id}`; + + // Create MSAL configuration + const msalConfig = { + auth: { + clientId: config.client_id, + clientSecret: config.client_secret, + authority: authority + } + }; + + // Test MSAL client creation + const cca = new ConfidentialClientApplication(msalConfig); + + // Basic validation - if we can create the client, the configuration is syntactically valid + if (!cca) { + errors.push('Failed to initialize MSAL client with provided configuration'); + } + + // Note: We don't make actual network calls here as that would require + // additional permissions and might be blocked by firewalls in some environments + + } catch (error) { + const errorMessage = (error as Error)?.message || 'Unknown MSAL configuration error'; + + if (errorMessage.includes('Invalid tenant')) { + errors.push('Invalid tenant ID - Azure AD cannot find a tenant with this ID'); + } else if (errorMessage.includes('Invalid client')) { + errors.push('Invalid client ID - Application not found in Azure AD tenant'); + } else if (errorMessage.includes('authority')) { + errors.push('Invalid authority URL - Check tenant ID and cloud environment'); + } else { + errors.push(`MSAL configuration error: ${errorMessage}`); + } + } + + return { + isValid: errors.length === 0, + errors, + warnings + }; + } + + /** + * Validates complete SSO configuration object with all components + * + * Master validation method that validates all aspects of an SSO configuration + * including Azure AD settings, authentication policies, and domain restrictions. + * This is the recommended method for validating SSO configurations before + * saving to the database or enabling SSO for an organization. + * + * @static + * @async + * @param {Object} config - Complete SSO configuration object + * @param {string} config.azure_tenant_id - Azure AD tenant ID (GUID format) + * @param {string} config.azure_client_id - Azure AD client ID (GUID format) + * @param {string} config.azure_client_secret - Azure AD client secret + * @param {string} config.cloud_environment - Azure cloud environment + * @param {string} config.auth_method_policy - Authentication method policy + * @param {string[]} [config.allowed_domains] - Optional allowed email domains + * @returns {Promise} Comprehensive validation result + * + * @validation_scope + * - Complete Azure AD configuration validation + * - Authentication method policy validation + * - Email domain restrictions validation (if provided) + * - Cross-component validation for policy consistency + * + * @example + * ```typescript + * const ssoConfig = { + * azure_tenant_id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + * azure_client_id: 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy', + * azure_client_secret: 'secure_secret_from_azure', + * cloud_environment: 'AzurePublic', + * auth_method_policy: 'both', + * allowed_domains: ['company.com', 'subsidiary.com'] + * }; + * + * const result = await SSOConfigValidator.validateSSOConfiguration(ssoConfig); + * if (result.isValid) { + * // Save configuration to database + * await saveSSOConfiguration(ssoConfig); + * } else { + * // Display errors to user + * showValidationErrors(result.errors); + * } + * ``` + */ + static async validateSSOConfiguration(config: { + azure_tenant_id: string; + azure_client_id: string; + azure_client_secret: string; + cloud_environment: string; + auth_method_policy: string; + allowed_domains?: string[]; + }): Promise { + const errors: string[] = []; + const warnings: string[] = []; + + // Validate Azure AD configuration + const azureValidation = await this.validateAzureADConfig({ + tenant_id: config.azure_tenant_id, + client_id: config.azure_client_id, + client_secret: config.azure_client_secret, + cloud_environment: config.cloud_environment as 'AzurePublic' | 'AzureGovernment' + }); + + errors.push(...azureValidation.errors); + warnings.push(...azureValidation.warnings); + + // Validate authentication method policy + const policyValidation = this.validateAuthMethodPolicy(config.auth_method_policy); + errors.push(...policyValidation.errors); + warnings.push(...policyValidation.warnings); + + // Validate email domains if provided + if (config.allowed_domains) { + const domainsValidation = this.validateEmailDomains(config.allowed_domains); + errors.push(...domainsValidation.errors); + warnings.push(...domainsValidation.warnings); + } + + return { + isValid: errors.length === 0, + errors, + warnings + }; + } + + /** + * Validate organization's readiness for SSO + */ + static validateOrganizationReadiness(organizationData: { + hasUsers: boolean; + userCount: number; + hasAdminUsers: boolean; + hasExistingAuth: boolean; + }): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + if (!organizationData.hasUsers) { + warnings.push('Organization has no users. Add users before enabling SSO.'); + } + + if (!organizationData.hasAdminUsers) { + errors.push('Organization must have at least one admin user before enabling SSO.'); + } + + if (organizationData.userCount > 100 && organizationData.hasExistingAuth) { + warnings.push(`Organization has ${organizationData.userCount} users with existing authentication. Plan the SSO migration carefully to avoid user access issues.`); + } + + return { + isValid: errors.length === 0, + errors, + warnings + }; + } +} \ No newline at end of file diff --git a/Servers/utils/sso-encryption.utils.ts b/Servers/utils/sso-encryption.utils.ts new file mode 100644 index 000000000..460dd8f14 --- /dev/null +++ b/Servers/utils/sso-encryption.utils.ts @@ -0,0 +1,223 @@ +/** + * @fileoverview SSO Secret Encryption Utilities + * + * Provides secure encryption and decryption utilities for Azure AD client secrets + * and other sensitive SSO configuration data. Implements industry-standard AES-256-CBC + * encryption with proper initialization vector (IV) handling for maximum security. + * + * Security Features: + * - AES-256-CBC encryption with cryptographically secure random IVs + * - Environment-based encryption key management with validation + * - Secure key validation to prevent weak or default keys + * - Proper error handling without exposing sensitive information + * - Format validation for encrypted data integrity + * + * Key Management: + * - Requires SSO_ENCRYPTION_KEY environment variable (32 characters) + * - Validates key strength and rejects common weak patterns + * - Uses separate encryption key from main JWT secret for security isolation + * - Supports key rotation through environment variable updates + * + * Data Format: + * - Encrypted format: "iv:encryptedData" (hex-encoded) + * - IV is generated randomly for each encryption operation + * - IV is stored with encrypted data for proper decryption + * - Format validation ensures data integrity and prevents corruption + * + * @author VerifyWise Development Team + * @since 2024-09-28 + * @version 1.0.0 + * @see {@link https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.197.pdf} AES Specification + * @see {@link https://tools.ietf.org/html/rfc3602} AES-CBC Cipher Algorithm + */ + +import * as crypto from 'crypto'; + +const algorithm = 'aes-256-cbc'; +const getEncryptionKey = () => { + const key = process.env.SSO_ENCRYPTION_KEY; + + if (!key) { + throw new Error('SSO_ENCRYPTION_KEY environment variable is required for SSO functionality. Please set a 32-character encryption key.'); + } + + if (key.length !== 32) { + throw new Error(`SSO_ENCRYPTION_KEY must be exactly 32 characters. Current length: ${key.length}`); + } + + // Validate that key is not a weak/default pattern + if (key.includes('default') || key === '0'.repeat(32) || key === '1'.repeat(32)) { + throw new Error('SSO_ENCRYPTION_KEY appears to be a default or weak key. Please use a cryptographically secure 32-character key.'); + } + + return Buffer.from(key); +}; + +/** + * Encrypts Azure AD client secrets using AES-256-CBC encryption + * + * Securely encrypts sensitive SSO configuration data using industry-standard + * AES-256-CBC encryption with a cryptographically secure random initialization + * vector (IV) for each encryption operation. + * + * @function encryptSecret + * @param {string} text - Plain text Azure AD client secret to encrypt + * @returns {string} Encrypted string in format "iv:encryptedData" (hex-encoded) + * + * @security + * - Uses AES-256-CBC with 128-bit randomly generated IV + * - IV is prepended to encrypted data for secure decryption + * - No key reuse - fresh IV for every encryption operation + * - Proper error handling without exposing sensitive information + * + * @encryption_process + * 1. Generate cryptographically secure 16-byte random IV + * 2. Create AES-256-CBC cipher with validated encryption key + * 3. Encrypt plaintext using cipher with UTF-8 input encoding + * 4. Convert IV and encrypted data to hex strings + * 5. Return formatted string: "iv:encryptedData" + * + * @throws {Error} Failed to encrypt secret (without exposing details) + * + * @example + * ```typescript + * const clientSecret = "abc123_secure_azure_secret"; + * const encrypted = encryptSecret(clientSecret); + * console.log(encrypted); // "a1b2c3d4...ef:9f8e7d6c..." + * + * // Store encrypted value in database + * ssoConfig.azure_client_secret = encrypted; + * ``` + * + * @since 1.0.0 + */ +export function encryptSecret(text: string): string { + try { + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv(algorithm, getEncryptionKey(), iv); + + let encrypted = cipher.update(text, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + // Store IV with encrypted data for decryption + return iv.toString('hex') + ':' + encrypted; + } catch (error) { + console.error('Encryption error:', error); + throw new Error('Failed to encrypt secret'); + } +} + +/** + * Decrypts Azure AD client secrets using AES-256-CBC decryption + * + * Securely decrypts encrypted SSO configuration data that was encrypted + * using the encryptSecret function. Validates format and extracts IV + * for proper decryption with AES-256-CBC algorithm. + * + * @function decryptSecret + * @param {string} encryptedText - Encrypted string in format "iv:encryptedData" (hex-encoded) + * @returns {string} Decrypted plain text Azure AD client secret + * + * @security + * - Validates encrypted data format before decryption attempt + * - Uses IV extracted from encrypted data for proper decryption + * - Proper error handling without exposing sensitive information + * - Validates hex encoding format for data integrity + * + * @decryption_process + * 1. Parse encrypted string to extract IV and encrypted data + * 2. Validate format matches expected "iv:encryptedData" pattern + * 3. Convert hex-encoded IV and data back to binary format + * 4. Create AES-256-CBC decipher with validated encryption key and IV + * 5. Decrypt data and return UTF-8 encoded plain text + * + * @throws {Error} Failed to decrypt secret (without exposing details) + * @throws {Error} Invalid encrypted format (if format validation fails) + * + * @example + * ```typescript + * const encryptedSecret = "a1b2c3d4...ef:9f8e7d6c..."; + * const decrypted = decryptSecret(encryptedSecret); + * console.log(decrypted); // "abc123_secure_azure_secret" + * + * // Use decrypted secret with MSAL + * const msalConfig = { + * auth: { + * clientSecret: decrypted + * } + * }; + * ``` + * + * @since 1.0.0 + */ +export function decryptSecret(encryptedText: string): string { + try { + const parts = encryptedText.split(':'); + if (parts.length !== 2) { + throw new Error('Invalid encrypted format'); + } + + const iv = Buffer.from(parts[0], 'hex'); + const encryptedData = parts[1]; + + const decipher = crypto.createDecipheriv(algorithm, getEncryptionKey(), iv); + + let decrypted = decipher.update(encryptedData, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + + return decrypted; + } catch (error) { + console.error('Decryption error:', error); + throw new Error('Failed to decrypt secret'); + } +} + +/** + * Validates if a string is encrypted in the expected format + * + * Performs format validation to determine if a string is encrypted using + * the encryptSecret function format. Useful for conditional encryption + * and preventing double-encryption of already encrypted data. + * + * @function isEncrypted + * @param {string} text - String to validate for encryption format + * @returns {boolean} True if string matches encrypted format, false otherwise + * + * @validation_checks + * - Validates "iv:encryptedData" format (exactly 2 parts separated by colon) + * - Verifies both IV and encrypted data are valid hexadecimal strings + * - Does not validate actual decryptability (format check only) + * - Safe to use on any string input without security implications + * + * @format_requirements + * - Must contain exactly one colon separator + * - IV part must be valid hexadecimal (32 characters for 16-byte IV) + * - Encrypted data part must be valid hexadecimal + * - Case-insensitive hex validation (accepts A-F and a-f) + * + * @example + * ```typescript + * const plainSecret = "my_azure_client_secret"; + * const encryptedSecret = "a1b2c3d4e5f6...89:9f8e7d6c5b4a...21"; + * + * console.log(isEncrypted(plainSecret)); // false + * console.log(isEncrypted(encryptedSecret)); // true + * console.log(isEncrypted("invalid:format")); // false (not hex) + * console.log(isEncrypted("no_colon")); // false (no colon) + * + * // Use in conditional encryption + * if (!isEncrypted(secretValue)) { + * secretValue = encryptSecret(secretValue); + * } + * ``` + * + * @since 1.0.0 + */ +export function isEncrypted(text: string): boolean { + const parts = text.split(':'); + if (parts.length !== 2) return false; + + // Check if IV and encrypted data are valid hex strings + const hexRegex = /^[0-9a-fA-F]+$/; + return hexRegex.test(parts[0]) && hexRegex.test(parts[1]); +} \ No newline at end of file diff --git a/Servers/utils/sso-env-validator.utils.ts b/Servers/utils/sso-env-validator.utils.ts new file mode 100644 index 000000000..7890d06d1 --- /dev/null +++ b/Servers/utils/sso-env-validator.utils.ts @@ -0,0 +1,507 @@ +/** + * @fileoverview SSO Environment Variable Validator Utilities + * + * Comprehensive environment validation system for Azure AD Single Sign-On configuration. + * Validates all required environment variables, performs security checks, and ensures + * proper configuration at application startup to prevent runtime failures. + * + * This utility provides: + * - Required environment variable validation + * - Security-focused validation for secrets and URLs + * - Production vs development environment checks + * - Redis configuration validation for rate limiting + * - Environment summary generation for debugging + * - Clear error messaging for configuration issues + * + * Validation Categories: + * 1. Required Variables - Essential for SSO functionality + * 2. Conditional Variables - Required based on configuration choices + * 3. Security Variables - Secrets requiring strength validation + * 4. Environment-Specific - Production vs development requirements + * 5. Integration Variables - External service configurations + * + * Security Features: + * - Secret strength validation (length, entropy, weak password detection) + * - URL security validation (HTTPS in production, localhost detection) + * - Separate secret validation for JWT and SSO state tokens + * - Production environment hardening checks + * - Sensitive data masking in environment summaries + * + * Usage Patterns: + * - Application startup validation with validateOrThrow() + * - Runtime configuration checks with validateEnvironment() + * - Development debugging with getEnvironmentSummary() + * - Feature availability checks (isRedisConfigured, isProduction) + * + * @author VerifyWise Development Team + * @since 2024-09-28 + * @version 1.0.0 + * @see {@link https://12factor.net/config} The Twelve-Factor App Configuration + * @see {@link https://owasp.org/www-project-application-security-verification-standard/} OWASP ASVS Configuration Requirements + * + * @module utils/sso-env-validator + */ + +/** + * Validation result interface for environment variable validation + * + * Provides structured feedback about environment configuration validation + * with clear separation between blocking errors and advisory warnings. + * + * @interface ValidationResult + * @property {boolean} valid - Whether validation passed with no errors + * @property {string[]} errors - Blocking configuration issues that prevent startup + * @property {string[]} warnings - Advisory issues that should be addressed + */ +interface ValidationResult { + valid: boolean; + errors: string[]; + warnings: string[]; +} + +/** + * Environment configuration interface for SSO-related variables + * + * Defines the structure of environment variables used by the SSO system, + * including required secrets, URLs, and Redis configuration for rate limiting. + * + * @interface EnvironmentConfig + * @property {string} [SSO_STATE_SECRET] - Secret for signing OAuth state tokens (32+ chars) + * @property {string} [BACKEND_URL] - Backend server URL for redirects and callbacks + * @property {string} [REDIS_URL] - Complete Redis connection URL + * @property {string} [REDIS_CONNECTION_STRING] - Alternative Redis connection string + * @property {string} [REDIS_HOST] - Redis server hostname + * @property {string} [REDIS_PORT] - Redis server port number + * @property {string} [REDIS_PASSWORD] - Redis authentication password + * @property {string} [REDIS_DB] - Redis database number (0-15) + * @property {string} [JWT_SECRET] - Secret for signing JWT tokens (32+ chars) + * @property {string} [NODE_ENV] - Environment type (development, production, etc.) + */ +interface EnvironmentConfig { + // Core SSO configuration + SSO_STATE_SECRET?: string; + BACKEND_URL?: string; + + // Redis configuration for rate limiting + REDIS_URL?: string; + REDIS_CONNECTION_STRING?: string; + REDIS_HOST?: string; + REDIS_PORT?: string; + REDIS_PASSWORD?: string; + REDIS_DB?: string; + + // JWT configuration + JWT_SECRET?: string; + + // Environment type + NODE_ENV?: string; +} + +/** + * SSO Environment Variable Validator Class + * + * Comprehensive validation system for environment variables required by the SSO system. + * Performs startup validation, security checks, and provides configuration debugging + * utilities to ensure proper SSO functionality across all environments. + * + * Key Features: + * - Required variable validation with clear error messages + * - Security-focused secret validation (length, entropy, weak passwords) + * - Production environment hardening checks + * - Redis configuration validation for rate limiting + * - Environment-specific warnings and recommendations + * - Sensitive data masking for debugging and logging + * + * Validation Philosophy: + * - Fail fast at startup if critical configuration is missing + * - Provide clear, actionable error messages for developers + * - Separate blocking errors from advisory warnings + * - Enforce security best practices for production environments + * - Support flexible Redis configuration options + * + * @class SSOEnvironmentValidator + * @static + * + * @example + * ```typescript + * // Validate environment at application startup + * try { + * SSOEnvironmentValidator.validateOrThrow(); + * console.log('Environment validation passed'); + * } catch (error) { + * console.error('Environment validation failed:', error.message); + * process.exit(1); + * } + * + * // Check specific configurations + * if (SSOEnvironmentValidator.isRedisConfigured()) { + * console.log('Rate limiting is available'); + * } + * ``` + */ +export class SSOEnvironmentValidator { + /** Environment variables required for basic SSO functionality */ + private static readonly REQUIRED_VARS = [ + 'SSO_STATE_SECRET', + 'BACKEND_URL', + 'JWT_SECRET' + ]; + + /** Conditional variable groups where at least one is required */ + private static readonly CONDITIONAL_VARS = { + // If no REDIS_URL, then REDIS_HOST is required + REDIS_CONDITIONAL: ['REDIS_URL', 'REDIS_CONNECTION_STRING', 'REDIS_HOST'] + }; + + /** Variables containing sensitive information requiring security validation */ + private static readonly SECRET_VARS = [ + 'SSO_STATE_SECRET', + 'JWT_SECRET', + 'REDIS_PASSWORD' + ]; + + /** + * Validates all SSO-related environment variables comprehensively + * + * Performs complete validation of environment configuration including required + * variables, conditional dependencies, security checks, and environment-specific + * requirements. Returns detailed feedback for both blocking errors and warnings. + * + * @static + * @returns {ValidationResult} Comprehensive validation result with errors and warnings + * + * @validation_steps + * 1. Required variable presence validation + * 2. Variable-specific format and security validation + * 3. Conditional dependency checking (Redis configuration) + * 4. Security validation (secret strength, URL security) + * 5. Environment-specific warnings generation + * + * @example + * ```typescript + * const result = SSOEnvironmentValidator.validateEnvironment(); + * if (!result.valid) { + * console.error('Configuration errors:', result.errors); + * // Fix configuration before proceeding + * } + * if (result.warnings.length > 0) { + * console.warn('Configuration warnings:', result.warnings); + * // Consider addressing warnings for better security + * } + * ``` + */ + static validateEnvironment(): ValidationResult { + const env = process.env as EnvironmentConfig; + const errors: string[] = []; + const warnings: string[] = []; + + // Check required variables + for (const varName of this.REQUIRED_VARS) { + const value = env[varName as keyof EnvironmentConfig]; + + if (!value) { + errors.push(`Missing required environment variable: ${varName}`); + continue; + } + + // Validate specific variables + const validationError = this.validateSpecificVariable(varName, value); + if (validationError) { + errors.push(validationError); + } + } + + // Check conditional variables (Redis configuration) + const hasRedisUrl = env.REDIS_URL || env.REDIS_CONNECTION_STRING; + const hasRedisHost = env.REDIS_HOST; + + if (!hasRedisUrl && !hasRedisHost) { + errors.push( + 'Redis configuration required: Either REDIS_URL/REDIS_CONNECTION_STRING or REDIS_HOST must be provided' + ); + } + + // Validate Redis configuration if provided + if (hasRedisHost) { + const redisPort = env.REDIS_PORT; + if (redisPort && !/^\d+$/.test(redisPort)) { + errors.push('REDIS_PORT must be a valid port number'); + } + + const redisDb = env.REDIS_DB; + if (redisDb && !/^\d+$/.test(redisDb)) { + errors.push('REDIS_DB must be a valid database number (0-15)'); + } + } + + // Security validations + const securityErrors = this.validateSecurity(env); + errors.push(...securityErrors); + + // Environment-specific warnings + const envWarnings = this.generateWarnings(env); + warnings.push(...envWarnings); + + return { + valid: errors.length === 0, + errors, + warnings + }; + } + + /** + * Validate specific environment variables + */ + private static validateSpecificVariable(name: string, value: string): string | null { + switch (name) { + case 'SSO_STATE_SECRET': + return this.validateSecret(name, value, 32); + + case 'JWT_SECRET': + return this.validateSecret(name, value, 32); + + case 'BACKEND_URL': + return this.validateUrl(name, value); + + default: + return null; + } + } + + /** + * Validate secret values (length, complexity, etc.) + */ + private static validateSecret(name: string, value: string, minLength: number): string | null { + if (value.length < minLength) { + return `${name} must be at least ${minLength} characters long`; + } + + // Check for common weak secrets + const weakSecrets = [ + 'secret', + 'password', + 'changeme', + 'default', + '123456', + 'qwerty', + value.toLowerCase() === name.toLowerCase() + ]; + + if (weakSecrets.some(weak => value.toLowerCase().includes(weak as string))) { + return `${name} appears to use a weak or default value`; + } + + // Check for sufficient entropy (basic check) + const uniqueChars = new Set(value).size; + if (uniqueChars < Math.min(16, value.length * 0.5)) { + return `${name} may have insufficient entropy (too many repeated characters)`; + } + + return null; + } + + /** + * Validate URL format and security + */ + private static validateUrl(name: string, value: string): string | null { + try { + const url = new URL(value); + + // Ensure HTTPS in production + if (process.env.NODE_ENV === 'production' && url.protocol !== 'https:') { + return `${name} must use HTTPS in production environment`; + } + + // Check for suspicious URLs + if (url.hostname === 'localhost' && process.env.NODE_ENV === 'production') { + return `${name} should not use localhost in production`; + } + + // Validate port if specified + if (url.port && !/^\d+$/.test(url.port)) { + return `${name} contains invalid port number`; + } + + return null; + } catch (error) { + return `${name} is not a valid URL format`; + } + } + + /** + * Security-focused validations + */ + private static validateSecurity(env: EnvironmentConfig): string[] { + const errors: string[] = []; + + // Ensure SSO_STATE_SECRET is different from JWT_SECRET + if (env.SSO_STATE_SECRET && env.JWT_SECRET) { + if (env.SSO_STATE_SECRET === env.JWT_SECRET) { + errors.push('SSO_STATE_SECRET and JWT_SECRET must be different for security'); + } + } + + // Check for hardcoded localhost URLs in production + if (env.NODE_ENV === 'production') { + const urlVars = ['BACKEND_URL', 'REDIS_URL']; + + for (const varName of urlVars) { + const value = env[varName as keyof EnvironmentConfig]; + if (value && value.includes('localhost')) { + errors.push(`${varName} should not contain localhost in production`); + } + } + } + + return errors; + } + + /** + * Generate environment-specific warnings + */ + private static generateWarnings(env: EnvironmentConfig): string[] { + const warnings: string[] = []; + + // Development environment warnings + if (env.NODE_ENV === 'development') { + if (!env.REDIS_URL && !env.REDIS_HOST) { + warnings.push('No Redis configuration found - rate limiting will be disabled'); + } + } + + // Production environment warnings + if (env.NODE_ENV === 'production') { + if (!env.REDIS_PASSWORD) { + warnings.push('REDIS_PASSWORD not set - consider using authentication in production'); + } + + // Check for weak secret lengths in production + for (const secretVar of this.SECRET_VARS) { + const value = env[secretVar as keyof EnvironmentConfig]; + if (value && value.length < 64) { + warnings.push(`${secretVar} is shorter than recommended 64 characters for production`); + } + } + } + + return warnings; + } + + /** + * Validates environment configuration and throws descriptive error if invalid + * + * Convenience method for application startup validation that performs complete + * environment validation and throws a detailed error message if any blocking + * issues are found. Logs warnings for non-blocking issues but allows startup. + * + * @static + * @throws {Error} Detailed error message with all configuration issues + * @returns {void} + * + * @usage_pattern + * - Call at application startup before initializing SSO functionality + * - Use in server.js or app.js initialization sequence + * - Ensures SSO system won't fail at runtime due to configuration + * + * @example + * ```typescript + * // In application startup (server.js) + * try { + * SSOEnvironmentValidator.validateOrThrow(); + * console.log('✅ Environment validation passed'); + * + * // Continue with SSO initialization + * initializeSSO(); + * } catch (error) { + * console.error('❌ Environment validation failed:', error.message); + * process.exit(1); + * } + * ``` + */ + static validateOrThrow(): void { + const result = this.validateEnvironment(); + + if (!result.valid) { + const errorMessage = [ + 'SSO Environment Validation Failed:', + '', + 'Errors:', + ...result.errors.map(error => ` - ${error}`), + '', + 'Please fix these environment variable issues before starting the application.' + ].join('\n'); + + throw new Error(errorMessage); + } + + // Log warnings if any + if (result.warnings.length > 0) { + console.warn('SSO Environment Warnings:'); + result.warnings.forEach(warning => { + console.warn(` - ${warning}`); + }); + console.warn(''); + } + + console.log('✅ SSO environment validation passed'); + } + + /** + * Get masked environment summary for logging + */ + static getEnvironmentSummary(): Record { + const env = process.env as EnvironmentConfig; + const summary: Record = {}; + + // Show non-sensitive variables + const safeVars = ['NODE_ENV', 'REDIS_HOST', 'REDIS_PORT', 'REDIS_DB']; + + for (const varName of safeVars) { + const value = env[varName as keyof EnvironmentConfig]; + if (value) { + summary[varName] = value; + } + } + + // Show masked sensitive variables + for (const secretVar of this.SECRET_VARS) { + const value = env[secretVar as keyof EnvironmentConfig]; + if (value) { + summary[secretVar] = `***${value.slice(-4)}`; // Show last 4 characters + } + } + + // Show masked URLs + const urlVars = ['BACKEND_URL', 'REDIS_URL']; + for (const urlVar of urlVars) { + const value = env[urlVar as keyof EnvironmentConfig]; + if (value) { + try { + const url = new URL(value); + summary[urlVar] = `${url.protocol}//${url.hostname}${url.port ? ':' + url.port : ''}/**`; + } catch { + summary[urlVar] = 'Invalid URL'; + } + } + } + + return summary; + } + + /** + * Check if Redis is properly configured + */ + static isRedisConfigured(): boolean { + const env = process.env as EnvironmentConfig; + return !!(env.REDIS_URL || env.REDIS_CONNECTION_STRING || env.REDIS_HOST); + } + + /** + * Check if running in production mode + */ + static isProduction(): boolean { + return process.env.NODE_ENV === 'production'; + } +} + +export default SSOEnvironmentValidator; \ No newline at end of file diff --git a/Servers/utils/sso-error-handler.utils.ts b/Servers/utils/sso-error-handler.utils.ts new file mode 100644 index 000000000..75faafe05 --- /dev/null +++ b/Servers/utils/sso-error-handler.utils.ts @@ -0,0 +1,613 @@ +/** + * @fileoverview SSO Error Handler Utilities + * + * Comprehensive error handling system for Azure AD Single Sign-On operations. + * Provides standardized error responses, security-aware messaging, audit logging, + * and specialized MSAL (Microsoft Authentication Library) error categorization. + * + * This utility centralizes error handling across the SSO system to ensure: + * - Consistent error response formats for API endpoints + * - Security-conscious error messaging that doesn't expose sensitive information + * - Comprehensive audit trails for security monitoring + * - Developer-friendly error categorization and debugging + * - Proper HTTP status codes aligned with OAuth 2.0 and REST standards + * + * Security Features: + * - Sanitized error messages prevent information disclosure + * - Email masking in logs for privacy protection + * - Security event logging for audit and monitoring + * - MSAL-specific error handling with redirect guidance + * - Database error categorization with appropriate user messaging + * + * Error Categories: + * - Configuration errors (invalid/missing SSO setup) + * - Authentication errors (credential validation, token exchange) + * - Authorization errors (access denied, insufficient permissions) + * - User management errors (creation, validation, domain restrictions) + * - System errors (database, external services, validation) + * + * @author VerifyWise Development Team + * @since 2024-09-28 + * @version 1.0.0 + * @see {@link https://tools.ietf.org/html/rfc6749} OAuth 2.0 Error Responses + * @see {@link https://docs.microsoft.com/en-us/azure/active-directory/develop/reference-aadsts-error-codes} Azure AD Error Codes + * + * @module utils/sso-error-handler + */ + +import { Request, Response } from 'express'; + +/** + * Standardized SSO error response interface + * + * Ensures consistent error response format across all SSO endpoints, + * providing structured error information for frontend handling and debugging. + * + * @interface SSOErrorResponse + * @property {false} success - Always false to indicate error state + * @property {string} error - Human-readable error message for display + * @property {string} [errorCode] - Machine-readable error code for programmatic handling + * @property {string[]} [details] - Additional error details (e.g., validation errors) + * @property {string} timestamp - ISO timestamp of error occurrence + * @property {string} [requestId] - Unique request identifier for tracing + * + * @example + * ```json + * { + * "success": false, + * "error": "Invalid Azure tenant ID format", + * "errorCode": "VALIDATION_ERROR", + * "details": ["Tenant ID must be a valid GUID"], + * "timestamp": "2024-09-28T10:30:00.000Z", + * "requestId": "req_abc123" + * } + * ``` + */ +export interface SSOErrorResponse { + success: false; + error: string; + errorCode?: string; + details?: string[]; + timestamp: string; + requestId?: string; +} + +/** + * Comprehensive SSO error codes for systematic error categorization + * + * Provides machine-readable error codes that enable: + * - Programmatic error handling in frontend applications + * - Systematic logging and monitoring + * - Error analytics and debugging + * - Consistent error responses across all SSO endpoints + * + * Error categories are organized by functional area for easy maintenance + * and ensure proper HTTP status code mapping. + * + * @enum {string} SSOErrorCodes + * + * @example + * ```typescript + * // In controller error handling + * return SSOErrorHandler.handleValidationError( + * res, + * ['Invalid tenant ID format'], + * 'Configuration validation failed' + * ); + * ``` + */ +export enum SSOErrorCodes { + // Configuration Errors (HTTP 400/500) + /** SSO configuration is invalid or malformed */ + INVALID_SSO_CONFIG = 'INVALID_SSO_CONFIG', + /** SSO configuration not found for organization */ + MISSING_SSO_CONFIG = 'MISSING_SSO_CONFIG', + /** Client secret encryption/decryption failed */ + ENCRYPTION_ERROR = 'ENCRYPTION_ERROR', + + // Authentication Errors (HTTP 401) + /** User credentials are invalid or expired */ + INVALID_CREDENTIALS = 'INVALID_CREDENTIALS', + /** OAuth token exchange with Azure AD failed */ + TOKEN_EXCHANGE_FAILED = 'TOKEN_EXCHANGE_FAILED', + /** OAuth state token validation failed (CSRF protection) */ + INVALID_STATE_TOKEN = 'INVALID_STATE_TOKEN', + + // Authorization Errors (HTTP 403/404) + /** User access denied by Azure AD or organization policy */ + ACCESS_DENIED = 'ACCESS_DENIED', + /** User lacks required permissions for operation */ + INSUFFICIENT_PERMISSIONS = 'INSUFFICIENT_PERMISSIONS', + /** Organization not found or inaccessible */ + ORG_NOT_FOUND = 'ORG_NOT_FOUND', + + // User Management Errors (HTTP 400/409) + /** Failed to create user account after SSO authentication */ + USER_CREATION_FAILED = 'USER_CREATION_FAILED', + /** User not found in system or Azure AD */ + USER_NOT_FOUND = 'USER_NOT_FOUND', + /** User email domain not allowed by organization policy */ + EMAIL_DOMAIN_NOT_ALLOWED = 'EMAIL_DOMAIN_NOT_ALLOWED', + + // System Errors (HTTP 500/503) + /** Database operation failed */ + DATABASE_ERROR = 'DATABASE_ERROR', + /** External service (Azure AD) unavailable or returned error */ + EXTERNAL_SERVICE_ERROR = 'EXTERNAL_SERVICE_ERROR', + /** Request validation failed */ + VALIDATION_ERROR = 'VALIDATION_ERROR', + /** Unexpected internal server error */ + INTERNAL_ERROR = 'INTERNAL_ERROR' +} + +/** + * SSO Error Handler Class + * + * Central error handling utility for all SSO operations. Provides consistent + * error responses, security-aware messaging, and comprehensive logging. + * All methods are static for easy access throughout the SSO system. + * + * Key Features: + * - Standardized error response format across all endpoints + * - Security-conscious error messaging (no sensitive data exposure) + * - MSAL-specific error categorization and handling + * - Database error classification with appropriate HTTP status codes + * - Comprehensive audit logging for security monitoring + * - Email masking for privacy protection in logs + * + * @class SSOErrorHandler + * @static + * + * @example + * ```typescript + * // In SSO controller + * try { + * await performSSOOperation(); + * } catch (error) { + * return SSOErrorHandler.handleInternalError(res, error, 'SSO authentication'); + * } + * ``` + */ +export class SSOErrorHandler { + /** + * Creates a standardized error response object + * + * Ensures consistent error structure across all SSO endpoints, + * making it easier for frontend applications to handle errors + * and for developers to debug issues. + * + * @static + * @param {string} error - Human-readable error message for display + * @param {SSOErrorCodes} [errorCode] - Machine-readable error code + * @param {string[]} [details] - Additional error details (e.g., validation errors) + * @param {string} [requestId] - Unique request identifier for tracing + * @returns {SSOErrorResponse} Standardized error response object + * + * @example + * ```typescript + * const errorResponse = SSOErrorHandler.createErrorResponse( + * 'Invalid Azure tenant ID format', + * SSOErrorCodes.VALIDATION_ERROR, + * ['Tenant ID must be a valid GUID'], + * 'req_abc123' + * ); + * ``` + */ + static createErrorResponse( + error: string, + errorCode?: SSOErrorCodes, + details?: string[], + requestId?: string + ): SSOErrorResponse { + return { + success: false, + error, + errorCode, + details, + timestamp: new Date().toISOString(), + requestId + }; + } + + /** + * Handles SSO configuration-related errors + * + * Processes errors related to SSO setup, validation, or configuration + * issues. Provides appropriate user messaging while logging detailed + * error information for debugging. + * + * @static + * @param {Response} res - Express response object + * @param {any} originalError - Original error object with details + * @param {string} [userMessage='SSO configuration error'] - User-friendly error message + * @param {SSOErrorCodes} [errorCode=INVALID_SSO_CONFIG] - Error code for categorization + * @returns {Response} Express response with error details + * + * @security + * - Sanitizes error messages to prevent sensitive information disclosure + * - Logs full error details only in development environment + * - Returns appropriate HTTP 500 status for configuration errors + * + * @example + * ```typescript + * try { + * await validateSSOConfig(config); + * } catch (error) { + * return SSOErrorHandler.handleConfigurationError( + * res, + * error, + * 'Invalid Azure AD configuration', + * SSOErrorCodes.INVALID_SSO_CONFIG + * ); + * } + * ``` + */ + static handleConfigurationError( + res: Response, + originalError: any, + userMessage: string = 'SSO configuration error', + errorCode: SSOErrorCodes = SSOErrorCodes.INVALID_SSO_CONFIG + ): Response { + console.error('SSO Configuration Error:', { + message: originalError?.message || 'Unknown error', + stack: process.env.NODE_ENV !== 'production' ? originalError?.stack : undefined, + errorCode + }); + + return res.status(500).json( + this.createErrorResponse(userMessage, errorCode) + ); + } + + /** + * Handles database-related errors with appropriate status codes + * + * Processes database errors from Sequelize ORM operations, categorizing them + * by error type and providing appropriate HTTP status codes and user messages. + * Includes special handling for connection, validation, and constraint errors. + * + * @static + * @param {Response} res - Express response object + * @param {any} originalError - Sequelize or database error object + * @param {string} [operation='database operation'] - Description of the operation that failed + * @returns {Response} Express response with appropriate status code and error details + * + * @error_handling + * - SequelizeConnectionError → 503 Service Unavailable + * - SequelizeValidationError → 400 Bad Request (with detailed validation errors) + * - SequelizeUniqueConstraintError → 409 Conflict + * - Other database errors → 500 Internal Server Error + * + * @example + * ```typescript + * try { + * await SSOConfiguration.create(configData); + * } catch (error) { + * return SSOErrorHandler.handleDatabaseError(res, error, 'SSO configuration creation'); + * } + * ``` + */ + static handleDatabaseError( + res: Response, + originalError: any, + operation: string = 'database operation' + ): Response { + console.error(`Database Error during ${operation}:`, { + message: originalError?.message || 'Unknown database error', + code: originalError?.code, + stack: process.env.NODE_ENV !== 'production' ? originalError?.stack : undefined + }); + + // Check for specific database error types + let userMessage = `Failed to complete ${operation}`; + let statusCode = 500; + + if (originalError?.name === 'SequelizeConnectionError') { + userMessage = 'Service temporarily unavailable. Please try again later.'; + statusCode = 503; + } else if (originalError?.name === 'SequelizeValidationError') { + userMessage = 'Invalid data provided'; + statusCode = 400; + + return res.status(statusCode).json( + this.createErrorResponse( + userMessage, + SSOErrorCodes.VALIDATION_ERROR, + originalError.errors?.map((e: any) => e.message) || [] + ) + ); + } else if (originalError?.name === 'SequelizeUniqueConstraintError') { + userMessage = 'Resource already exists'; + statusCode = 409; + } + + return res.status(statusCode).json( + this.createErrorResponse(userMessage, SSOErrorCodes.DATABASE_ERROR) + ); + } + + /** + * Handles Microsoft Authentication Library (MSAL) specific errors + * + * Processes MSAL errors from Azure AD authentication operations, providing + * appropriate user messaging and categorization. Returns structured error + * information that helps determine if user should be redirected to retry authentication. + * + * @static + * @param {any} originalError - MSAL error object from Azure AD operations + * @param {string} [operation='Azure AD authentication'] - Description of the operation that failed + * @returns {Object} Error handling result with user message, error code, and redirect guidance + * @returns {string} returns.userMessage - User-friendly error message + * @returns {SSOErrorCodes} returns.errorCode - Categorized error code + * @returns {boolean} returns.shouldRedirect - Whether user should be redirected to retry + * + * @msal_error_categories + * - invalid_grant/expired_token → Token refresh required + * - invalid_client/unauthorized_client → Configuration error + * - access_denied/consent_required → Permission issues + * - interaction_required → Additional authentication needed + * + * @example + * ```typescript + * try { + * const token = await msalClient.acquireTokenSilent(request); + * } catch (msalError) { + * const { userMessage, errorCode, shouldRedirect } = + * SSOErrorHandler.handleMSALError(msalError, 'token acquisition'); + * + * if (shouldRedirect) { + * return res.redirect('/sso/login'); + * } + * return res.status(401).json({ error: userMessage, errorCode }); + * } + * ``` + */ + static handleMSALError( + originalError: any, + operation: string = 'Azure AD authentication' + ): { userMessage: string; errorCode: SSOErrorCodes; shouldRedirect: boolean } { + console.error(`MSAL Error during ${operation}:`, { + errorCode: originalError?.errorCode, + errorMessage: originalError?.errorMessage, + subError: originalError?.subError, + correlationId: originalError?.correlationId + }); + + // Categorize MSAL errors + const errorCode = originalError?.errorCode || originalError?.code || 'unknown'; + + switch (errorCode) { + case 'invalid_grant': + case 'authorization_pending': + case 'expired_token': + return { + userMessage: 'Authentication session expired. Please try again.', + errorCode: SSOErrorCodes.TOKEN_EXCHANGE_FAILED, + shouldRedirect: true + }; + + case 'invalid_client': + case 'unauthorized_client': + return { + userMessage: 'SSO configuration error. Please contact your administrator.', + errorCode: SSOErrorCodes.INVALID_SSO_CONFIG, + shouldRedirect: true + }; + + case 'access_denied': + case 'consent_required': + return { + userMessage: 'Access denied. You may not have permission to access this application.', + errorCode: SSOErrorCodes.ACCESS_DENIED, + shouldRedirect: true + }; + + case 'interaction_required': + return { + userMessage: 'Additional authentication required. Please try again.', + errorCode: SSOErrorCodes.TOKEN_EXCHANGE_FAILED, + shouldRedirect: true + }; + + default: + return { + userMessage: 'Authentication failed. Please try again.', + errorCode: SSOErrorCodes.EXTERNAL_SERVICE_ERROR, + shouldRedirect: true + }; + } + } + + /** + * Handle authentication/authorization errors + */ + static handleAuthError( + res: Response, + originalError: any, + userMessage: string = 'Authentication failed', + statusCode: number = 401 + ): Response { + console.error('Authentication Error:', { + message: originalError?.message || userMessage, + stack: process.env.NODE_ENV !== 'production' ? originalError?.stack : undefined + }); + + const errorCode = statusCode === 401 + ? SSOErrorCodes.INVALID_CREDENTIALS + : SSOErrorCodes.ACCESS_DENIED; + + return res.status(statusCode).json( + this.createErrorResponse(userMessage, errorCode) + ); + } + + /** + * Handle validation errors with detailed feedback + */ + static handleValidationError( + res: Response, + validationErrors: string[], + userMessage: string = 'Invalid input provided' + ): Response { + console.warn('Validation Error:', { + message: userMessage, + errors: validationErrors + }); + + return res.status(400).json( + this.createErrorResponse( + userMessage, + SSOErrorCodes.VALIDATION_ERROR, + validationErrors + ) + ); + } + + /** + * Handle generic internal errors + */ + static handleInternalError( + res: Response, + originalError: any, + operation: string = 'operation', + userMessage?: string + ): Response { + console.error(`Internal Error during ${operation}:`, { + message: originalError?.message || 'Unknown error', + stack: process.env.NODE_ENV !== 'production' ? originalError?.stack : undefined + }); + + return res.status(500).json( + this.createErrorResponse( + userMessage || `Failed to complete ${operation}`, + SSOErrorCodes.INTERNAL_ERROR + ) + ); + } + + /** + * Create redirect URL with error information for frontend + */ + static createErrorRedirectUrl( + baseUrl: string, + errorCode: SSOErrorCodes, + userMessage?: string + ): string { + const params = new URLSearchParams({ + error: errorCode, + ...(userMessage && { message: userMessage }) + }); + + return `${baseUrl}/login?${params.toString()}`; + } + + /** + * Logs security-sensitive SSO operations for audit and monitoring + * + * Creates structured audit logs for security events during SSO operations. + * Masks sensitive information like email addresses while preserving necessary + * details for security monitoring and compliance purposes. + * + * @static + * @param {string} operation - Description of the security operation performed + * @param {boolean} success - Whether the operation succeeded or failed + * @param {Object} details - Security event details + * @param {number} [details.userId] - User ID if authenticated + * @param {string} [details.organizationId] - Organization ID for multi-tenant tracking + * @param {string} [details.email] - User email (will be masked in logs) + * @param {string} [details.ipAddress] - Client IP address for security tracking + * @param {string} [details.userAgent] - Client user agent for device tracking + * @param {string} [details.error] - Error message if operation failed + * @returns {void} + * + * @security + * - Email addresses are automatically masked (e.g., "j***@example.com") + * - Full stack traces only logged in development environment + * - Successful events logged as INFO, failures as WARN for monitoring + * - Includes ISO timestamp for precise audit trail + * + * @example + * ```typescript + * // Log successful SSO login + * SSOErrorHandler.logSecurityEvent('sso_login', true, { + * userId: 123, + * organizationId: 'org-456', + * email: 'user@company.com', + * ipAddress: req.ip, + * userAgent: req.get('User-Agent') + * }); + * + * // Log failed authentication attempt + * SSOErrorHandler.logSecurityEvent('sso_login_failed', false, { + * email: 'attacker@malicious.com', + * ipAddress: req.ip, + * error: 'Invalid state token' + * }); + * ``` + */ + static logSecurityEvent( + operation: string, + success: boolean, + details: { + userId?: number; + organizationId?: string; + email?: string; + ipAddress?: string; + userAgent?: string; + error?: string; + } + ): void { + const logEntry = { + timestamp: new Date().toISOString(), + operation, + success, + userId: details.userId, + organizationId: details.organizationId, + email: details.email ? this.maskEmail(details.email) : undefined, + ipAddress: details.ipAddress, + userAgent: details.userAgent, + ...(details.error && { error: details.error }) + }; + + if (success) { + console.info('SSO Security Event:', logEntry); + } else { + console.warn('SSO Security Event (Failed):', logEntry); + } + } + + /** + * Masks email addresses for privacy-compliant logging + * + * Converts email addresses to a masked format for audit logs while + * preserving domain information for security analysis. Ensures compliance + * with privacy regulations while maintaining useful security information. + * + * @private + * @static + * @param {string} email - Email address to mask + * @returns {string} Masked email in format "f***@domain.com" + * + * @masking_pattern + * - Shows first character of local part + asterisks for remaining characters + * - Preserves full domain for security domain analysis + * - Returns '[INVALID_EMAIL]' for malformed email addresses + * + * @example + * ```typescript + * maskEmail('john.doe@company.com') // Returns: 'j*******@company.com' + * maskEmail('a@test.org') // Returns: 'a@test.org' + * maskEmail('invalid-email') // Returns: '[INVALID_EMAIL]' + * ``` + */ + private static maskEmail(email: string): string { + const [local, domain] = email.split('@'); + if (!domain) return '[INVALID_EMAIL]'; + + const maskedLocal = local.length > 1 + ? local[0] + '*'.repeat(local.length - 1) + : '*'; + + return `${maskedLocal}@${domain}`; + } +} \ No newline at end of file diff --git a/Servers/utils/sso-state-token.utils.ts b/Servers/utils/sso-state-token.utils.ts new file mode 100644 index 000000000..36ce764cc --- /dev/null +++ b/Servers/utils/sso-state-token.utils.ts @@ -0,0 +1,229 @@ +/** + * @fileoverview SSO State Token Management Utilities + * + * This module provides secure state token management for OAuth 2.0 flows, specifically + * designed to prevent Cross-Site Request Forgery (CSRF) attacks during SSO authentication. + * + * Security Features: + * - Cryptographically secure random nonce generation + * - JWT-based state tokens with organization-specific audience validation + * - Separate signing secret from main application JWT secret + * - Timing-safe comparison to prevent timing attacks + * - Automatic token expiration (10 minutes) + * - Organization ID validation for multi-tenant isolation + * + * OAuth 2.0 Security: + * State tokens are a critical security component in OAuth flows. They serve as: + * - CSRF protection by linking authorization requests with callbacks + * - Session binding to prevent session fixation attacks + * - Organization isolation in multi-tenant environments + * + * @author VerifyWise Development Team + * @since 2024-09-28 + * @version 1.0.0 + * @see {@link https://tools.ietf.org/html/rfc6749#section-10.12} OAuth 2.0 CSRF Protection + */ + +import * as crypto from 'crypto'; +import * as jwt from 'jsonwebtoken'; + +/** Secret key for signing SSO state tokens (separate from main JWT secret for security) */ +const STATE_TOKEN_SECRET = process.env.SSO_STATE_SECRET; + +/** State token expiration time in milliseconds (10 minutes) */ +const STATE_TOKEN_EXPIRY = 10 * 60 * 1000; + +// Critical security check: Ensure state token secret is configured +if (!STATE_TOKEN_SECRET) { + throw new Error('SSO_STATE_SECRET environment variable is required for SSO functionality. This must be separate from JWT_SECRET for security reasons.'); +} + +/** + * State token payload structure for OAuth 2.0 CSRF protection + * + * @interface StateTokenPayload + */ +interface StateTokenPayload { + /** Organization ID for multi-tenant isolation */ + organizationId: string; + /** Cryptographically secure random nonce for additional entropy */ + nonce: string; + /** Token creation timestamp for audit purposes */ + timestamp: number; + /** Token expiration timestamp for manual validation */ + expiresAt: number; +} + +/** + * SSO State Token Manager + * + * Provides secure state token generation and validation for OAuth 2.0 flows. + * State tokens are essential for preventing CSRF attacks during SSO authentication + * by ensuring that authorization callbacks correspond to legitimate requests. + * + * Key Security Features: + * - JWT-based tokens with HMAC-SHA256 signing + * - Organization-specific audience validation + * - Cryptographically secure nonce generation + * - Timing-safe comparison to prevent timing attacks + * - Automatic expiration handling + * + * @class SSOStateTokenManager + * @static + * + * @example + * ```typescript + * // Generate state token for OAuth initiation + * const stateToken = SSOStateTokenManager.generateStateToken('123'); + * + * // Validate state token in OAuth callback + * const payload = SSOStateTokenManager.validateStateToken(stateToken, '123'); + * console.log('Validated nonce:', payload.nonce); + * ``` + */ +export class SSOStateTokenManager { + /** + * Generates a secure state token for OAuth 2.0 CSRF protection + * + * Creates a JWT-based state token containing organization ID and cryptographic nonce + * for secure OAuth flow validation. The token is signed with a separate secret and + * includes audience validation for multi-tenant isolation. + * + * @static + * @param {string} organizationId - Organization identifier for tenant isolation + * @returns {string} Signed JWT state token for OAuth authorization request + * + * @security + * - Uses cryptographically secure random nonce (256 bits) + * - JWT signed with HMAC-SHA256 using dedicated secret + * - Organization-specific audience claim for tenant isolation + * - Automatic expiration after 10 minutes + * + * @example + * ```typescript + * const stateToken = SSOStateTokenManager.generateStateToken('123'); + * // Use stateToken in OAuth authorization URL + * ``` + */ + static generateStateToken(organizationId: string): string { + // Generate cryptographically secure 256-bit nonce for CSRF protection + const nonce = crypto.randomBytes(32).toString('hex'); + const timestamp = Date.now(); + const expiresAt = timestamp + STATE_TOKEN_EXPIRY; + + // Create token payload with organization and timing information + const payload: StateTokenPayload = { + organizationId, // For multi-tenant validation + nonce, // Cryptographic entropy + timestamp, // Creation time for audit + expiresAt // Manual expiry check + }; + + // Sign JWT with dedicated SSO secret and organization-specific audience + return jwt.sign(payload, STATE_TOKEN_SECRET!, { + algorithm: 'HS256', // HMAC-SHA256 for symmetric signing + expiresIn: '10m', // 10-minute expiration + issuer: 'verifywise-sso', // Token issuer identification + audience: `org-${organizationId}` // Organization-specific audience + }); + } + + /** + * Validates and decodes an OAuth 2.0 state token + * + * Performs comprehensive validation of state tokens received in OAuth callbacks, + * including signature verification, expiration checks, and organization validation + * to prevent CSRF attacks and ensure proper tenant isolation. + * + * @static + * @param {string} token - JWT state token from OAuth callback + * @param {string} expectedOrganizationId - Expected organization ID for validation + * @returns {StateTokenPayload} Decoded and validated token payload + * + * @throws {Error} Missing state token + * @throws {Error} Token expired (automatic JWT validation) + * @throws {Error} Invalid token signature or format + * @throws {Error} Organization ID mismatch + * + * @security + * - Verifies JWT signature with dedicated SSO secret + * - Validates issuer and audience claims + * - Checks organization ID for tenant isolation + * - Automatic expiration handling via JWT library + * + * @example + * ```typescript + * try { + * const payload = SSOStateTokenManager.validateStateToken(token, '123'); + * console.log('Valid token for org:', payload.organizationId); + * } catch (error) { + * console.error('Invalid state token:', error.message); + * } + * ``` + */ + static validateStateToken( + token: string, + expectedOrganizationId: string + ): StateTokenPayload { + if (!token) { + throw new Error('Missing state token'); + } + + try { + // Verify JWT signature and validate claims + const decoded = jwt.verify(token, STATE_TOKEN_SECRET!, { + algorithms: ['HS256'], // Only allow HMAC-SHA256 + issuer: 'verifywise-sso', // Verify token issuer + audience: `org-${expectedOrganizationId}` // Organization-specific audience + }) as StateTokenPayload; + + // Additional security check: Validate organization ID for tenant isolation + if (decoded.organizationId !== expectedOrganizationId) { + throw new Error(`Organization ID mismatch: expected ${expectedOrganizationId}, got ${decoded.organizationId}`); + } + + // Note: JWT expiry is automatically handled by jwt.verify() above + // Manual expiry check removed to prevent race conditions between validation and use + + return decoded; + } catch (error) { + if (error instanceof jwt.TokenExpiredError) { + throw new Error(`State token expired at ${error.expiredAt?.toISOString()}`); + } + + if (error instanceof jwt.JsonWebTokenError) { + throw new Error(`Invalid state token: ${error.message}`); + } + + throw error; // Re-throw if it's already our custom error + } + } + + /** + * Generate a secure nonce for additional CSRF protection + */ + static generateNonce(): string { + return crypto.randomBytes(32).toString('hex'); + } + + /** + * Validate a nonce matches the expected value + */ + static validateNonce(received: string, expected: string): boolean { + if (!received || !expected) { + return false; + } + + // Use crypto.timingSafeEqual to prevent timing attacks + const receivedBuffer = Buffer.from(received, 'hex'); + const expectedBuffer = Buffer.from(expected, 'hex'); + + if (receivedBuffer.length !== expectedBuffer.length) { + return false; + } + + return crypto.timingSafeEqual(receivedBuffer, expectedBuffer); + } +} + +export default SSOStateTokenManager; \ No newline at end of file