diff --git a/README.md b/README.md index 6a7c1ac..2c64044 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,9 @@ GOOGLE_CLIENT_SECRET="your-google-client-secret" # Redis (optional, for SSE transport support) REDIS_URL="rediss://user:pass@host:6379" + +# Admin access (for analytics dashboard) +ADMIN_EMAIL="admin@gmail.com" ``` #### Setting up Google OAuth: @@ -89,6 +92,29 @@ pnpm prisma generate pnpm prisma db push ``` +#### Database Schema Overview + +The application uses PostgreSQL with the following main components: + +**Core OAuth 2.1 Tables:** +- `User` - User accounts with Google OAuth authentication +- `Account` - OAuth provider account linkages +- `Session` - User session management +- `Client` - Registered OAuth clients with PKCE support +- `AccessToken`, `AuthCode`, `RefreshToken` - OAuth 2.1 tokens + +**Analytics & Security Tables:** +- `AnalyticsRequest` - Request tracking with **14-day TTL** +- `AnalyticsSecurity` - Security events with **14-day TTL** +- `MCPServer` - MCP server registry + +**Data Retention (TTL - Time To Live):** +- Analytics data automatically expires after **14 days** +- Database-level TTL using PostgreSQL `INTERVAL` defaults +- Automatic cleanup via API endpoint `/api/cleanup` +- Manual cleanup: `POST /api/cleanup` +- Status monitoring: `GET /api/cleanup` + ### 5. Start Development Server ```bash @@ -184,6 +210,9 @@ Add to your `settings.json`: - **Authorization**: `GET /oauth/authorize` - **MCP HTTP**: `GET|POST /mcp/mcp` - **MCP SSE**: `GET /mcp/sse` +- **Data Cleanup**: `POST /api/cleanup` - Manually trigger TTL cleanup +- **Cleanup Status**: `GET /api/cleanup` - Check TTL status and expired records +- **Security Analytics**: Analytics dashboard with real-time threat detection ## Project Structure @@ -198,8 +227,11 @@ app/ └── prisma.ts # Prisma client prisma/ -├── schema.prisma # Database schema +├── schema.prisma # Database schema with TTL support └── migrations/ # Database migrations + +generated/ +└── prisma/ # Generated Prisma client (custom location) ``` ## Development Scripts @@ -223,8 +255,157 @@ pnpm prisma studio # Open Prisma Studio # Database management brew services start postgresql@14 # Start PostgreSQL brew services stop postgresql@14 # Stop PostgreSQL + +# Data cleanup (TTL management) +curl -X POST http://localhost:3000/api/cleanup # Manual cleanup +curl http://localhost:3000/api/cleanup # Check cleanup status +``` + +## Security Monitoring & Threat Detection + +This MCP OAuth server implements comprehensive security monitoring based on OAuth 2.1 and MCP specification requirements. The system automatically detects and records security threats in real-time. + +### **OAuth & MCP Security Threats Detected** + +#### **Critical Threats (Risk Score 90-95)** + +**1. Token Audience Validation Failures** +- **Detection**: Access tokens used across multiple MCP server boundaries +- **Based on**: RFC 8707 Resource Indicators, RFC 9728 Protected Resource Metadata +- **Risk**: Token reuse attacks, confused deputy vulnerabilities +- **Recorded**: MCP server IDs, endpoints, token usage patterns + +**2. PKCE Bypass Attempts** +- **Detection**: OAuth authorization code flows without PKCE protection +- **Based on**: OAuth 2.1 Section 7.5.2 mandatory PKCE requirements +- **Risk**: Authorization code interception attacks +- **Recorded**: Grant types, PKCE usage patterns, client behaviors + +**3. OAuth Privilege Escalation** +- **Detection**: Users requesting elevated scopes beyond historical patterns +- **Based on**: OAuth scope analysis and permission escalation patterns +- **Risk**: Unauthorized access to sensitive resources +- **Recorded**: Current vs historical scopes, elevated scope patterns, user behavior + +**4. Token Passthrough Violations** +- **Detection**: Rapid cross-service access suggesting token forwarding +- **Based on**: MCP security best practices against confused deputy attacks +- **Risk**: Tokens used for unintended services +- **Recorded**: Cross-server access patterns, timing analysis, resource boundaries + +#### **High Risk Threats (Risk Score 70-85)** + +**5. Missing Resource Parameters** +- **Detection**: OAuth requests without proper resource identification +- **Based on**: RFC 8707 Resource Indicators requirements +- **Risk**: Tokens not bound to intended resources +- **Recorded**: Missing redirect URIs, grant type violations + +**6. Scope Explosion Attacks** +- **Detection**: Unusual number of new OAuth scopes requested simultaneously +- **Based on**: Permission escalation analysis +- **Risk**: Bulk privilege escalation attempts +- **Recorded**: New scope counts, scope patterns, user history + +#### **Medium Risk Threats (Risk Score 50-70)** + +**7. Rate Limiting Violations** +- **Detection**: >30 requests per minute from single IP +- **Risk**: DoS attacks, API abuse +- **Recorded**: Request rates, IP patterns, endpoint targeting + +**8. Brute Force Attempts** +- **Detection**: Multiple authentication failures from same IP +- **Risk**: Credential guessing attacks +- **Recorded**: Failure counts, IP addresses, timing patterns + +**9. Suspicious User Agents** +- **Detection**: Non-human user agents (bots, scrapers) without MCP identification +- **Risk**: Automated attacks, scraping attempts +- **Recorded**: User agent strings, access patterns + +**10. Token Reuse Detection** +- **Detection**: Same token used from different IP addresses within short timeframe +- **Risk**: Credential theft, session hijacking +- **Recorded**: IP changes, timing analysis, token patterns + +### **Data Recording & Analytics** + +#### **Analytics Tables Structure** + +**AnalyticsRequest Table:** +```sql +- timestamp, endpoint, method, statusCode, responseTime +- userId, clientId, mcpServerId (foreign keys) +- OAuth-specific: oauthGrantType, tokenScopes, usePKCE, redirectUri +- MCP-specific: mcpMethod, toolName +- Security context: ipAddress, userAgent, organization, ssoProvider +- Geographic: country, city (async populated) +- TTL: expiresAt (14-day auto-cleanup) ``` +**AnalyticsSecurity Table:** +```sql +- timestamp, eventType (enum), severity, riskScore (0-100) +- userId, clientId, mcpServerId (foreign keys) +- Context: ipAddress, userAgent, endpoint, organization +- Incident management: resolved, resolvedAt, resolvedBy +- Details: JSON field with structured threat data +- TTL: expiresAt (14-day auto-cleanup) +``` + +#### **Security Event Types** +- `AUTH_FAILURE` - Authentication failures +- `INVALID_TOKEN` - Token validation failures +- `SUSPICIOUS_ACTIVITY` - Anomalous behavior patterns +- `RATE_LIMIT_EXCEEDED` - API rate limit violations +- `UNAUTHORIZED_ACCESS` - Access control violations +- `TOKEN_REUSE` - Token reuse across IPs +- `UNUSUAL_LOCATION` - Geographic anomalies +- `PRIVILEGE_ESCALATION` - Scope/permission escalation +- `MALFORMED_REQUEST` - Malformed OAuth/MCP requests +- `BRUTE_FORCE_ATTEMPT` - Credential brute force +- `OAUTH_INVALID_CLIENT` - Invalid OAuth clients +- `OAUTH_INVALID_GRANT` - Invalid OAuth grants +- `OAUTH_INVALID_SCOPE` - Invalid OAuth scopes + +#### **Real-Time Threat Analysis** + +**Risk Scoring Algorithm:** +- **Critical (90-100)**: Immediate security response required +- **High (70-89)**: Investigation and monitoring needed +- **Medium (50-69)**: Automated monitoring and logging +- **Low (20-49)**: Basic logging only +- **Informational (0-19)**: Filtered out of security analytics + +**Filtering & Display:** +- Security dashboard shows only meaningful threats (risk score ≥ 50) +- Privilege escalations filtered to high-risk only (≥ 70) +- Organization-level aggregation for enterprise visibility +- 14-day retention with automatic cleanup + +### **Compliance & Standards** + +The security monitoring system ensures compliance with: +- **OAuth 2.1** IETF Draft (draft-ietf-oauth-v2-1-12) +- **RFC 8707** Resource Indicators for OAuth 2.0 +- **RFC 9728** OAuth 2.0 Protected Resource Metadata +- **RFC 8414** OAuth 2.0 Authorization Server Metadata +- **MCP Security Best Practices** (2025-06-18 specification) + +### **Enterprise Integration** + +**SIEM Integration Ready:** +- Structured JSON logging for security events +- Risk scoring for automated response systems +- Geographic and organizational context +- Real-time alerting for critical events + +**Monitoring Endpoints:** +- `/api/cleanup` - Security data management +- Analytics dashboard - Real-time threat visualization +- Prisma Studio - Direct database investigation + ## Deployment to Vercel 1. Push your code to GitHub @@ -240,6 +421,7 @@ Make sure to set these in your Vercel dashboard: - `GOOGLE_CLIENT_ID` - `GOOGLE_CLIENT_SECRET` - `REDIS_URL` (optional, for SSE support) +- `ADMIN_EMAIL` (required for analytics dashboard access) ## Contributing diff --git a/app/analytics/analytics-client.tsx b/app/analytics/analytics-client.tsx new file mode 100644 index 0000000..343582c --- /dev/null +++ b/app/analytics/analytics-client.tsx @@ -0,0 +1,533 @@ +"use client" + +import { useState, useEffect, useCallback } from "react" +import { Button } from "@/components/ui/button" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible" +import { + SecurityPanel, + LoadingSkeleton, + DashboardHeader, + OAuthOverview, + OAuthClientActivity, + TokenExpiration, + ToolUsagePanel, + GrantTypeChart, + ToolResponseAreaChart +} from "@/components/analytics" +import { + BarChart3, + Activity, + AlertTriangle, + Zap, + ChevronDown, + ChevronUp, + Shield, + TrendingUp, + Users +} from "lucide-react" + +export default function AnalyticsClient() { + const [data, setData] = useState<{ + performance?: { + totalRequests: number + avgResponseTime: number + p95ResponseTime: number + errorRate: number + } + oauth?: { + totalUsers: number + activeUsers: number + totalClients: number + activeClients: number + activeTokens: number + recentAuthorizations: number + tokenRefreshRate: number + pkceAdoption: number + userActivity?: string + clientActivity?: string + clients?: { + name: string + clientId: string + uniqueUsers: number + activeTokens: number + recentRequests: number + lastActivity: string + userNames: string + status: string + }[] + expiringTokens?: { + clientName: string + tokenCount: number + hoursUntilExpiry: number + }[] + grantTypes?: { + type: string + count: number + percentage: number + }[] + } + toolUsage?: { + tools: { + toolName: string + mcpMethod: string + usageCount: number + uniqueUsers: number + avgResponseTime?: number + }[] + geographic: { + country: string + city?: string + count: number + percentage: number + }[] + timeSeries?: { + hour: string + toolName: string + avgResponseTime: number + callCount: number + p95ResponseTime: number + p50ResponseTime: number + }[] + totalCalls: number + activeUsers: number + } + topEndpoints?: { endpoint: string; count: number }[] + geography?: { country: string; count: number }[] + security?: { + eventCount: number + events?: { timestamp: string; eventType: string; ipAddress: string; clientId?: string; details: string }[] + eventsByOrganization?: { + organization: string + eventType: string + severity: string + eventCount: number + avgRiskScore: number + }[] + privilegeEscalations?: { + userName: string + userEmail: string + eventType: string + riskScore: number + timestamp: string + details: Record + }[] + totalEvents: number + criticalEvents: number + highRiskEvents: number + resolvedEvents: number + averageRiskScore: number + } + enterprise?: { + usersByMCPServer?: { userName: string; userEmail: string; mcpServerName: string; mcpServerIdentifier: string }[] + toolUsage?: { toolName: string; mcpMethod: string; usageCount: number; uniqueUsers: number }[] + } + lastUpdated?: string + timeRange?: string + } | null>(null) + + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [timeRange, setTimeRange] = useState("24") + const [refreshing, setRefreshing] = useState(false) + const [generatingEvents, setGeneratingEvents] = useState(false) + const [expandedSections, setExpandedSections] = useState({ + oauth: true, + security: false, + tools: false + }) + + const fetchAnalytics = useCallback(async (showRefreshLoader = false) => { + try { + if (showRefreshLoader) { + setRefreshing(true) + } else { + setLoading(true) + } + const response = await fetch(`/api/analytics?hours=${timeRange}`, { + credentials: 'include' + }) + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || "Failed to fetch analytics") + } + const analyticsData = await response.json() + setData(analyticsData) + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred") + } finally { + setLoading(false) + setRefreshing(false) + } + }, [timeRange]) + + const generateTestEvents = async () => { + try { + setGeneratingEvents(true) + // Use the SecurityMonitor directly for more realistic threat detection + const response = await fetch("/api/analytics/generate-threats", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + runDetection: true, + mockScenarios: [ + "privilege_escalation", + "token_reuse", + "rate_limit_exceeded", + "oauth_pkce_bypass" + ] + }), + }) + + if (!response.ok) { + throw new Error("Failed to generate threats") + } + + const result = await response.json() + alert(`Generated ${result.threatsDetected || 0} realistic security threats using SecurityMonitor! Refresh to see them.`) + } catch (error) { + console.error("Failed to generate test events:", error) + alert("Failed to generate test events") + } finally { + setGeneratingEvents(false) + } + } + + useEffect(() => { + fetchAnalytics() + }, [timeRange, fetchAnalytics]) + + + const getHealthStatus = () => { + if (!data?.performance && !data?.oauth) return { status: "unknown", color: "bg-muted" } + + let criticalIssues = 0 + let warningIssues = 0 + + // Check performance health + if (data?.performance) { + const { errorRate, avgResponseTime } = data.performance + if (errorRate > 5 || avgResponseTime > 1000) criticalIssues++ + else if (errorRate > 1 || avgResponseTime > 500) warningIssues++ + } + + + // Check PKCE adoption + if (data?.oauth && data.oauth.pkceAdoption < 50) warningIssues++ + + if (criticalIssues > 0) return { status: "critical", color: "bg-destructive" } + if (warningIssues > 0) return { status: "warning", color: "bg-secondary-300" } + return { status: "healthy", color: "bg-primary-300" } + } + + if (loading) { + return + } + + if (error) { + return ( +
+
+ +
+

Dashboard Error

+

{error}

+
+ +
+
+ ) + } + + if (!data) { + return ( +
+
+ +
+

No Data Available

+

No analytics data found for the selected period.

+
+
+
+ ) + } + + const healthStatus = getHealthStatus() + + const toggleSection = (section: keyof typeof expandedSections) => { + setExpandedSections(prev => ({ + ...prev, + [section]: !prev[section] + })) + } + + const getAlertSummary = () => { + const alerts = [] + + + if (data?.performance) { + const { errorRate, avgResponseTime } = data.performance + if (errorRate > 5) alerts.push({ type: 'critical', message: `${errorRate}% error rate` }) + if (avgResponseTime > 1000) alerts.push({ type: 'warning', message: `${avgResponseTime}ms avg response time` }) + } + + if (data?.oauth && data.oauth.pkceAdoption < 50) { + alerts.push({ type: 'warning', message: `${data.oauth.pkceAdoption}% PKCE adoption` }) + } + + return alerts + } + + return ( +
+ {/* Dashboard Header */} + + + + + + + +
+ + {/* Priority Alert Bar */} + {data && getAlertSummary().length > 0 && ( + + +
+ + Active Alerts +
+
+ +
+ {getAlertSummary().map((alert, index) => ( + + {alert.message} + + ))} +
+
+
+ )} + + {/* Section 1: OAuth Information */} + toggleSection('oauth')}> + + + +
+
+
+ +
+
+ OAuth Metrics + Authentication and client management +
+
+
+ {data?.oauth && ( + + {data.oauth.activeUsers} active users + + )} + {expandedSections.oauth ? ( + + ) : ( + + )} +
+
+
+
+ + + {data?.oauth && ( + + )} +
+ + + {data?.oauth?.grantTypes && data.oauth.grantTypes.length > 0 && ( + + )} +
+
+
+
+
+ + {/* Section 2: Security */} + toggleSection('security')}> + + + +
+
+
+ +
+
+ Security Overview + Critical security events and monitoring +
+
+
+ {data?.security && ( + + {data.security.totalEvents} events + + )} + {expandedSections.security ? ( + + ) : ( + + )} +
+
+
+
+ + + {/* Real-time Security Analytics Panel with comprehensive threat detection */} + + + +
+
+ + {/* Section 3: MCP Tools & Performance */} + toggleSection('tools')}> + + + +
+
+
+ +
+
+ Performance & Tools + Tool usage and response metrics +
+
+
+ {data?.toolUsage && ( + + {data.toolUsage.totalCalls} calls + + )} + {expandedSections.tools ? ( + + ) : ( + + )} +
+
+
+
+ + + {data?.toolUsage && ( +
+ {/* Tool Usage Panel - Handles overview, tools list, and geographic data */} + + + {/* Time Series Charts */} + {data.toolUsage.timeSeries && data.toolUsage.timeSeries.length > 0 && ( +
+ + +
+ )} +
+ )} +
+
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/app/analytics/page.tsx b/app/analytics/page.tsx index aaadf7b..ab04cf3 100644 --- a/app/analytics/page.tsx +++ b/app/analytics/page.tsx @@ -1,309 +1,52 @@ -"use client" - -import { useState, useEffect, useCallback } from "react" +import { auth } from '@/app/auth' import { Button } from "@/components/ui/button" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { - MetricCard, - DataTable, - SecurityPanel, - LoadingSkeleton, - DashboardHeader, - StatusBadge -} from "@/components/analytics" -import { - BarChart3, - Globe, - Users, - Activity, - Clock, - AlertTriangle, - CheckCircle, - TrendingUp, - Server, - Zap, -} from "lucide-react" - -export default function AnalyticsPage() { - const [data, setData] = useState<{ - performance?: { - totalRequests: number - avgResponseTime: number - p95ResponseTime: number - errorRate: number - } - topEndpoints?: { endpoint: string; count: number }[] - geography?: { country: string; count: number }[] - security?: { - eventCount: number - events?: { timestamp: string; eventType: string; ipAddress: string; clientId?: string; details: string }[] - byOrganization?: { - organization: string - eventType: string - severity: string - eventCount: number - avgRiskScore: number - }[] - privilegeEscalations?: { - userName: string - userEmail: string - eventType: string - riskScore: number - timestamp: string - details: Record - }[] - } - enterprise?: { - usersByMCPServer?: { userName: string; userEmail: string; mcpServerName: string; mcpServerIdentifier: string }[] - toolUsage?: { toolName: string; mcpMethod: string; usageCount: number; uniqueUsers: number }[] - } - lastUpdated?: string - timeRange?: string - } | null>(null) - - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - const [timeRange, setTimeRange] = useState("24") - - const fetchAnalytics = useCallback(async () => { - try { - setLoading(true) - const response = await fetch(`/api/analytics?hours=${timeRange}`) - if (!response.ok) { - throw new Error("Failed to fetch analytics") - } - const analyticsData = await response.json() - setData(analyticsData) - } catch (err) { - setError(err instanceof Error ? err.message : "An error occurred") - } finally { - setLoading(false) - } - }, [timeRange]) - - const generateTestEvents = async () => { - try { - const eventTypes = ["AUTH_FAILURE", "INVALID_TOKEN", "SUSPICIOUS_ACTIVITY", "RATE_LIMIT_EXCEEDED"] - for (const eventType of eventTypes) { - await fetch("/api/test/security-events", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ eventType, count: 2 }), - }) - } - alert("Test security events generated! Refresh data in 30 seconds to see them.") - } catch (error) { - console.error("Failed to generate test events:", error) - alert("Failed to generate test events") - } - } - - useEffect(() => { - fetchAnalytics() - }, [timeRange, fetchAnalytics]) +import { AlertTriangle } from "lucide-react" +import AnalyticsClient from './analytics-client' +import { redirect } from 'next/navigation' +export default async function AnalyticsPage() { + const session = await auth() - const getHealthStatus = () => { - if (!data?.performance) return { status: "unknown", color: "bg-muted" } - const { errorRate, avgResponseTime } = data.performance - if (errorRate > 5 || avgResponseTime > 1000) return { status: "critical", color: "bg-destructive" } - if (errorRate > 1 || avgResponseTime > 500) return { status: "warning", color: "bg-yellow-500" } - return { status: "healthy", color: "bg-green-500" } + // Check if user is authenticated + if (!session || !session.user) { + redirect('/api/auth/signin') } - if (loading) { - return - } - - if (error) { + // Check if user is admin + const adminEmail = process.env.ADMIN_EMAIL + const userEmail = session.user.email + + if (!adminEmail) { return (
-

Dashboard Error

-

{error}

+

Configuration Error

+

Admin email not configured. Please contact your system administrator.

-
) } - if (!data) { + if (userEmail !== adminEmail) { return (
-
- +
+
-

No Data Available

-

No analytics data found for the selected period.

+

Access Denied

+

You don't have permission to access this page. Admin access required.

+
) } - const healthStatus = getHealthStatus() - - return ( -
- {/* Dashboard Header */} - - - - - - - - -
- - {/* Performance Overview */} - {data.performance && ( -
-

- Performance Overview -

-
- - - - 5 ? AlertTriangle : CheckCircle} - variant="secondary" - change={data.performance.errorRate > 5 ? "+2.1%" : "-0.5%"} - changeType={data.performance.errorRate > 5 ? "negative" : "positive"} - subtitle="error percentage" - /> -
-
- )} - - {/* Main Content Grid */} -
- {/* Left Column - Endpoints & Geography */} -
- {/* Top Endpoints */} - {data.topEndpoints && data.topEndpoints.length > 0 && ( - ({ - primary: endpoint.endpoint, - value: endpoint.count - }))} - emptyMessage="No endpoint data available" - /> - )} - - {/* Geographic Distribution */} - {data.geography && data.geography.length > 0 && ( - ({ - primary: country.country, - value: country.count - }))} - emptyMessage="No geographic data available" - /> - )} -
- - {/* Middle Column - Enterprise Analytics */} -
- - - {/* Tool Usage */} - {data.enterprise?.toolUsage && data.enterprise.toolUsage.length > 0 && ( - ({ - primary: tool.toolName, - secondary: tool.mcpMethod, - value: `${tool.usageCount} calls`, - badge: `${tool.uniqueUsers} users` - }))} - emptyMessage="No tool usage data available" - maxItems={4} - /> - )} -
- - {/* Right Column - Security */} -
- {data.security && ( - - )} -
-
-
-
- ) + // User is authenticated and is admin, render the analytics dashboard + return } diff --git a/app/api/analytics/generate-threats/route.ts b/app/api/analytics/generate-threats/route.ts new file mode 100644 index 0000000..3d03cb4 --- /dev/null +++ b/app/api/analytics/generate-threats/route.ts @@ -0,0 +1,191 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { securityMonitor } from '@/lib/security-monitor'; +import { prisma } from '@/app/prisma'; + +export async function POST(request: NextRequest) { + try { + const { runDetection, mockScenarios } = await request.json(); + + if (!runDetection) { + return NextResponse.json({ + error: 'Detection not enabled' + }, { status: 400 }); + } + + // Get real database entities for realistic context + const recentUser = await prisma.user.findFirst({ + select: { id: true } + }); + + const recentClient = await prisma.client.findFirst({ + orderBy: { createdAt: 'desc' }, + select: { id: true } + }); + + const recentMCPServer = await prisma.mCPServer.findFirst({ + select: { id: true } + }); + + // Create realistic security context for threat detection + const mockContext = { + userId: recentUser?.id, + clientId: recentClient?.id, + ipAddress: request.headers.get('x-forwarded-for') || '192.168.1.100', + userAgent: request.headers.get('user-agent') || 'curl/7.68.0', // Suspicious user agent + endpoint: '/mcp/sse', + organization: 'Test Organization', + ssoProvider: 'google', + mcpServerId: recentMCPServer?.id + }; + + let totalThreats = 0; + + // Run different threat scenarios based on mockScenarios + for (const scenario of mockScenarios || []) { + const contextForScenario = { ...mockContext }; + + switch (scenario) { + case 'privilege_escalation': + // Create analytics requests with escalating scopes to trigger privilege escalation detection + if (mockContext.userId) { + await prisma.analyticsRequest.createMany({ + data: [ + { + timestamp: new Date(Date.now() - 5 * 60 * 1000), // 5 minutes ago + endpoint: '/oauth/token', + method: 'POST', + statusCode: 200, + responseTime: 150, + userId: mockContext.userId, + clientId: mockContext.clientId, + mcpServerId: mockContext.mcpServerId, + ipAddress: mockContext.ipAddress, + userAgent: mockContext.userAgent, + scopes: ['read', 'write'] // Historical scopes + }, + { + timestamp: new Date(), // Now with elevated scopes + endpoint: '/oauth/token', + method: 'POST', + statusCode: 200, + responseTime: 180, + userId: mockContext.userId, + clientId: mockContext.clientId, + mcpServerId: mockContext.mcpServerId, + ipAddress: mockContext.ipAddress, + userAgent: mockContext.userAgent, + scopes: ['read', 'write', 'admin', 'delete'] // New elevated scopes + } + ] + }); + } + break; + + case 'token_reuse': + // Create requests from different IPs to trigger token reuse detection + if (mockContext.userId) { + await prisma.analyticsRequest.createMany({ + data: [ + { + timestamp: new Date(Date.now() - 2 * 60 * 1000), // 2 minutes ago + endpoint: '/mcp/sse', + method: 'POST', + statusCode: 200, + responseTime: 120, + userId: mockContext.userId, + clientId: mockContext.clientId, + mcpServerId: mockContext.mcpServerId, + ipAddress: '10.0.0.50', // Different IP + userAgent: mockContext.userAgent, + scopes: ['read'] + } + ] + }); + } + contextForScenario.ipAddress = '192.168.1.100'; // Current IP different from above + break; + + case 'rate_limit_exceeded': + // Create many recent requests to trigger rate limiting + if (mockContext.userId) { + const requests = Array.from({ length: 35 }, (_, i) => ({ + timestamp: new Date(Date.now() - (35 - i) * 1000), // Spread over last 35 seconds + endpoint: '/mcp/sse', + method: 'POST', + statusCode: 200, + responseTime: 100 + Math.random() * 50, + userId: mockContext.userId, + clientId: mockContext.clientId, + mcpServerId: mockContext.mcpServerId, + ipAddress: mockContext.ipAddress, + userAgent: mockContext.userAgent, + scopes: ['read'] + })); + + await prisma.analyticsRequest.createMany({ data: requests }); + } + break; + + case 'oauth_pkce_bypass': + // Create OAuth requests without PKCE to trigger bypass detection + if (mockContext.userId) { + await prisma.analyticsRequest.createMany({ + data: Array.from({ length: 3 }, (_, i) => ({ + timestamp: new Date(Date.now() - (3 - i) * 60 * 1000), + endpoint: '/oauth/authorize', + method: 'POST', + statusCode: 200, + responseTime: 200, + userId: mockContext.userId, + clientId: mockContext.clientId, + mcpServerId: mockContext.mcpServerId, + ipAddress: mockContext.ipAddress, + userAgent: mockContext.userAgent, + oauthGrantType: 'authorization_code', + usePKCE: false, // PKCE bypass + scopes: ['read'] + })) + }); + } + break; + } + + // Run SecurityMonitor threat detection with the configured context + const detectedThreats = await securityMonitor.detectThreats(contextForScenario, request); + + // Log the detected threats + if (detectedThreats.length > 0) { + await securityMonitor.logSecurityEvents(detectedThreats, contextForScenario); + totalThreats += detectedThreats.length; + } + } + + return NextResponse.json({ + success: true, + message: `SecurityMonitor detected and logged ${totalThreats} realistic threats`, + threatsDetected: totalThreats, + scenarios: mockScenarios + }); + + } catch (error) { + console.error('SecurityMonitor threat generation error:', error); + return NextResponse.json({ + error: 'Failed to generate threats using SecurityMonitor', + details: error instanceof Error ? error.message : 'Unknown error' + }, { status: 500 }); + } +} + +export async function GET() { + return NextResponse.json({ + message: 'SecurityMonitor Threat Generator API', + usage: 'POST with { "runDetection": true, "mockScenarios": ["privilege_escalation", "token_reuse"] }', + availableScenarios: [ + 'privilege_escalation', // OAuth scope escalation detection + 'token_reuse', // Cross-IP token usage detection + 'rate_limit_exceeded', // API abuse detection + 'oauth_pkce_bypass' // PKCE bypass detection + ], + description: 'Uses real SecurityMonitor detection logic with mock data scenarios' + }); +} \ No newline at end of file diff --git a/app/api/analytics/route.ts b/app/api/analytics/route.ts index 1373fbb..0d513b9 100644 --- a/app/api/analytics/route.ts +++ b/app/api/analytics/route.ts @@ -1,9 +1,10 @@ import { NextRequest, NextResponse } from 'next/server'; -import { analyticsDB } from '@/lib/analytics-db'; +import { analyticsDB, getSecurityAnalytics } from '@/lib/analytics-db'; import { auth } from '@/app/auth'; export async function GET(request: NextRequest) { try { + // console.log('Request:', request); // Authentication check const session = await auth(); if (!session) { @@ -14,7 +15,7 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const hours = parseInt(searchParams.get('hours') || '24'); - return getEnhancedAnalytics(hours); + return await getEnhancedAnalytics(hours); } catch (error) { console.error('Analytics API error:', error); return NextResponse.json( @@ -32,36 +33,64 @@ async function getEnhancedAnalytics(hours = 24) { return NextResponse.json({ error: 'Hours must be between 1 and 168' }, { status: 400 }); } + // Calculate date range for security analytics + const startDate = new Date(Date.now() - hours * 60 * 60 * 1000); + const endDate = new Date(); + // Efficient parallel queries for enhanced analytics const [ performance, endpoints, geography, - securityEvents, + securityAnalytics, usersByMCP, - securityByOrg, - privilegeEscalations, - toolUsage + toolUsage, + toolGeography, + toolResponseTimeSeries, + oauthMetrics, + oauthClientActivity, + expiringTokens, + grantTypeDistribution, + oauthSecurityEvents ] = await Promise.all([ analyticsDB.getPerformanceMetrics(hours), analyticsDB.getTopEndpoints(hours, 10), analyticsDB.getGeographyStats(hours), - analyticsDB.getSecurityEvents(hours), + getSecurityAnalytics(startDate, endDate), analyticsDB.getUsersByMCPServer(hours), - analyticsDB.getSecurityEventsByOrganization(hours), - analyticsDB.getUserPrivilegeEscalations(168), // Last 7 days - analyticsDB.getMCPToolUsage(hours) + analyticsDB.getMCPToolUsage(hours), + analyticsDB.getToolGeographyStats(hours), + analyticsDB.getToolResponseTimeTimeSeries(hours), + analyticsDB.getOAuthMetrics(hours), + analyticsDB.getOAuthClientActivity(hours, 6), + analyticsDB.getExpiringTokens(24), + analyticsDB.getGrantTypeDistribution(hours), + analyticsDB.getOAuthSecurityEvents(hours) ]); const data = { performance, topEndpoints: endpoints, geography, + oauth: { + ...oauthMetrics, + clients: oauthClientActivity, + expiringTokens, + grantTypes: grantTypeDistribution + }, + oauthSecurity: oauthSecurityEvents, + toolUsage: { + tools: toolUsage, + geographic: toolGeography, + timeSeries: toolResponseTimeSeries, + totalCalls: toolUsage.reduce((sum, tool) => sum + tool.usageCount, 0), + activeUsers: toolUsage.reduce((sum, tool) => sum + tool.uniqueUsers, 0) + }, security: { - events: securityEvents, - eventCount: securityEvents.length, - byOrganization: securityByOrg, - privilegeEscalations + ...securityAnalytics, + // Include basic security events for the SecurityPanel component + events: await analyticsDB.getSecurityEvents(hours), + eventCount: securityAnalytics.totalEvents }, enterprise: { usersByMCPServer: usersByMCP, diff --git a/app/api/analytics/security/route.ts b/app/api/analytics/security/route.ts index 67be4ec..6ce7f22 100644 --- a/app/api/analytics/security/route.ts +++ b/app/api/analytics/security/route.ts @@ -1,5 +1,37 @@ import { NextRequest, NextResponse } from 'next/server'; -import { analyticsDB } from '@/lib/analytics-db'; +import { analyticsDB, getSecurityAnalytics } from '@/lib/analytics-db'; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const days = parseInt(searchParams.get('days') || '30'); + + // Calculate date range + const endDate = new Date(); + const startDate = new Date(endDate.getTime() - days * 24 * 60 * 60 * 1000); + + const analytics = await getSecurityAnalytics(startDate, endDate); + + return NextResponse.json({ + success: true, + data: analytics, + timeRange: { + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + days + } + }); + } catch (error) { + console.error('Security analytics API error:', error); + return NextResponse.json( + { + error: 'Failed to fetch security analytics', + details: error instanceof Error ? error.message : 'Unknown error' + }, + { status: 500 } + ); + } +} export async function POST(request: NextRequest) { try { diff --git a/app/api/cleanup/route.ts b/app/api/cleanup/route.ts new file mode 100644 index 0000000..f25a363 --- /dev/null +++ b/app/api/cleanup/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from 'next/server'; +import { cleanupExpiredData, getTTLStatus } from '@/app/prisma'; + +export async function POST() { + try { + const result = await cleanupExpiredData(); + + if (result.success) { + return NextResponse.json({ + message: 'Cleanup completed successfully', + deletedCount: result.deletedCount + }); + } else { + return NextResponse.json( + { error: 'Cleanup failed', details: result.error }, + { status: 500 } + ); + } + } catch (error) { + return NextResponse.json( + { error: 'Internal server error', details: (error as Error).message }, + { status: 500 } + ); + } +} + +export async function GET() { + try { + const status = await getTTLStatus(); + + if (status.error) { + return NextResponse.json( + { error: 'Failed to get TTL status', details: status.error }, + { status: 500 } + ); + } + + return NextResponse.json({ + message: 'TTL status retrieved successfully', + data: status + }); + } catch (error) { + return NextResponse.json( + { error: 'Internal server error', details: (error as Error).message }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/oauth/register/route.ts b/app/api/oauth/register/route.ts index 7da1fb4..31027c1 100644 --- a/app/api/oauth/register/route.ts +++ b/app/api/oauth/register/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; import { prisma } from '@/app/prisma'; import { randomBytes } from 'crypto'; +import { analyticsDB } from '@/lib/analytics-db'; export async function POST(request: NextRequest) { const body = await request.json(); @@ -33,6 +34,29 @@ export async function POST(request: NextRequest) { }, }); + // Log client registration analytics + try { + const ip = request.headers.get('x-forwarded-for')?.split(',')[0] || + request.headers.get('x-real-ip') || + '127.0.0.1'; + + await analyticsDB.logRequest({ + timestamp: new Date(), + endpoint: '/api/oauth/register', + method: request.method, + statusCode: 200, + responseTime: 0, + clientId: newClient.id, + ipAddress: ip, + userAgent: request.headers.get('user-agent') || '', + oauthGrantType: 'client_registration', + tokenScopes: [], + redirectUri: redirect_uris[0] // First redirect URI + }); + } catch (analyticsError) { + console.warn('Failed to log client registration analytics:', analyticsError); + } + const response = NextResponse.json({ client_id: newClient.clientId, client_secret: clientSecret, // This is the only time the secret is sent diff --git a/app/api/oauth/token/route.ts b/app/api/oauth/token/route.ts index 9e6af79..bb6d7fa 100644 --- a/app/api/oauth/token/route.ts +++ b/app/api/oauth/token/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; import { prisma } from '@/app/prisma'; import { randomBytes } from 'crypto'; +import { analyticsDB } from '@/lib/analytics-db'; // Type for client object interface ClientType { @@ -19,6 +20,33 @@ function getCorsHeaders() { }; } +// Helper function to log OAuth analytics +async function logOAuthAnalytics(request: NextRequest, grantType: string, clientId: string, userId?: string, scopes?: string[], usePKCE?: boolean, redirectUri?: string) { + try { + const ip = request.headers.get('x-forwarded-for')?.split(',')[0] || + request.headers.get('x-real-ip') || + '127.0.0.1'; + + await analyticsDB.logRequest({ + timestamp: new Date(), + endpoint: '/api/oauth/token', + method: request.method, + statusCode: 200, + responseTime: 0, // Will be updated by middleware + clientId, + userId, + ipAddress: ip, + userAgent: request.headers.get('user-agent') || '', + oauthGrantType: grantType, + tokenScopes: scopes || [], + usePKCE, + redirectUri + }); + } catch (error) { + console.warn('Failed to log OAuth analytics:', error); + } +} + // Helper function to create access and refresh tokens async function createTokens(clientId: string, userId: string, resource?: string) { const accessToken = randomBytes(32).toString('hex'); @@ -58,6 +86,7 @@ async function createTokens(clientId: string, userId: string, resource?: string) // Handle refresh token grant async function handleRefreshTokenGrant( + request: NextRequest, refreshTokenValue: string, client: ClientType, clientSecret: string | undefined, @@ -118,6 +147,9 @@ async function handleRefreshTokenGrant( // Create new tokens const tokens = await createTokens(client.id, refreshTokenRecord.userId, tokenResource); + // Log analytics for refresh token grant + await logOAuthAnalytics(request, 'refresh_token', client.id, refreshTokenRecord.userId); + console.log('[RefreshToken] Tokens refreshed successfully'); return NextResponse.json(tokens, { headers: getCorsHeaders() }); } @@ -185,7 +217,7 @@ export async function POST(request: NextRequest) { // Handle refresh token grant if (grant_type === 'refresh_token') { - return await handleRefreshTokenGrant(refresh_token!, client, client_secret ?? undefined, resource); + return await handleRefreshTokenGrant(request, refresh_token!, client, client_secret ?? undefined, resource); } // Continue with authorization code grant (existing logic) @@ -259,6 +291,17 @@ export async function POST(request: NextRequest) { resource || authCode.resource || undefined ); + // Log analytics for authorization code grant + await logOAuthAnalytics( + request, + 'authorization_code', + client.id, + authCode.userId, + [], // Scopes would be extracted from auth code in full implementation + !!authCode.codeChallenge, + authCode.redirectUri + ); + console.log("Access token and refresh token created."); return NextResponse.json(tokens, { diff --git a/app/api/test/security-events/route.ts b/app/api/test/security-events/route.ts index 190dd01..3ac2a8c 100644 --- a/app/api/test/security-events/route.ts +++ b/app/api/test/security-events/route.ts @@ -6,17 +6,18 @@ export async function POST(request: NextRequest) { try { const { eventType, count = 1 } = await request.json(); + // Only include event types that we actually detect in security-monitor.ts const validEventTypes = [ - 'AUTH_FAILURE', - 'INVALID_TOKEN', - 'SUSPICIOUS_ACTIVITY', - 'RATE_LIMIT_EXCEEDED', - 'UNAUTHORIZED_ACCESS', - 'TOKEN_REUSE', - 'UNUSUAL_LOCATION', - 'PRIVILEGE_ESCALATION', - 'MALFORMED_REQUEST', - 'BRUTE_FORCE_ATTEMPT' + 'AUTH_FAILURE', // logAuthFailure() + 'INVALID_TOKEN', // logInvalidToken() & checkTokenAudienceViolation() + 'SUSPICIOUS_ACTIVITY', // checkSuspiciousUserAgent() & checkTokenPassthrough() + 'RATE_LIMIT_EXCEEDED', // checkRateLimit() + 'TOKEN_REUSE', // checkTokenReuse() + 'UNUSUAL_LOCATION', // checkUnusualLocation() (currently disabled) + 'PRIVILEGE_ESCALATION', // checkPrivilegeEscalation() + 'BRUTE_FORCE_ATTEMPT', // checkBruteForce() + 'OAUTH_INVALID_CLIENT', // checkPKCEBypass() + 'OAUTH_INVALID_GRANT' // checkMissingResourceParameter() ]; if (!validEventTypes.includes(eventType)) { @@ -54,13 +55,14 @@ export async function POST(request: NextRequest) { const events = []; for (let i = 0; i < count; i++) { const mockEvent = { - eventType: eventType as 'AUTH_FAILURE' | 'INVALID_TOKEN' | 'SUSPICIOUS_ACTIVITY' | 'RATE_LIMIT_EXCEEDED' | 'UNAUTHORIZED_ACCESS' | 'TOKEN_REUSE' | 'UNUSUAL_LOCATION' | 'PRIVILEGE_ESCALATION' | 'MALFORMED_REQUEST' | 'BRUTE_FORCE_ATTEMPT', + eventType: eventType as 'AUTH_FAILURE' | 'INVALID_TOKEN' | 'SUSPICIOUS_ACTIVITY' | 'RATE_LIMIT_EXCEEDED' | 'TOKEN_REUSE' | 'UNUSUAL_LOCATION' | 'PRIVILEGE_ESCALATION' | 'BRUTE_FORCE_ATTEMPT' | 'OAUTH_INVALID_CLIENT' | 'OAUTH_INVALID_GRANT', severity: getSeverityForEventType(eventType), details: { test: true, eventNumber: i + 1, timestamp: new Date(), - description: getDescriptionForEventType(eventType) + description: getDescriptionForEventType(eventType), + ...getRealisticDetailsForEventType(eventType, i) }, riskScore: getRiskScoreForEventType(eventType) }; @@ -87,16 +89,19 @@ export async function POST(request: NextRequest) { function getSeverityForEventType(eventType: string): 'low' | 'medium' | 'high' | 'critical' { switch (eventType) { case 'PRIVILEGE_ESCALATION': - case 'BRUTE_FORCE_ATTEMPT': + case 'OAUTH_INVALID_CLIENT': // PKCE bypass - critical OAuth 2.1 violation return 'critical'; case 'TOKEN_REUSE': - case 'UNUSUAL_LOCATION': - case 'UNAUTHORIZED_ACCESS': + case 'BRUTE_FORCE_ATTEMPT': + case 'OAUTH_INVALID_GRANT': // Missing resource parameter - high risk return 'high'; case 'RATE_LIMIT_EXCEEDED': - case 'INVALID_TOKEN': + case 'INVALID_TOKEN': // Token audience violations case 'AUTH_FAILURE': + case 'UNUSUAL_LOCATION': return 'medium'; + case 'SUSPICIOUS_ACTIVITY': // User agent detection & token passthrough + return 'low'; default: return 'low'; } @@ -104,33 +109,138 @@ function getSeverityForEventType(eventType: string): 'low' | 'medium' | 'high' | function getRiskScoreForEventType(eventType: string): number { switch (eventType) { - case 'PRIVILEGE_ESCALATION': return 95; - case 'BRUTE_FORCE_ATTEMPT': return 90; - case 'TOKEN_REUSE': return 85; - case 'UNUSUAL_LOCATION': return 70; - case 'UNAUTHORIZED_ACCESS': return 75; - case 'RATE_LIMIT_EXCEEDED': return 60; - case 'INVALID_TOKEN': return 50; - case 'AUTH_FAILURE': return 45; - case 'SUSPICIOUS_ACTIVITY': return 40; - case 'MALFORMED_REQUEST': return 30; + case 'PRIVILEGE_ESCALATION': return 95; // Critical - elevated scope access + case 'INVALID_TOKEN': return 95; // Critical - token audience violations + case 'OAUTH_INVALID_CLIENT': return 90; // Critical - PKCE bypass + case 'BRUTE_FORCE_ATTEMPT': return 90; // High - credential attacks + case 'TOKEN_REUSE': return 85; // High - token theft + case 'SUSPICIOUS_ACTIVITY': return 85; // High - token passthrough (when from that) + case 'OAUTH_INVALID_GRANT': return 80; // High - missing resource parameter + case 'UNUSUAL_LOCATION': return 60; // Medium - geographic anomaly (disabled) + case 'RATE_LIMIT_EXCEEDED': return 60; // Medium - API abuse + case 'AUTH_FAILURE': return 50; // Medium - auth issues default: return 25; } } function getDescriptionForEventType(eventType: string): string { switch (eventType) { - case 'AUTH_FAILURE': return 'Test authentication failure event'; - case 'INVALID_TOKEN': return 'Test invalid token usage'; - case 'SUSPICIOUS_ACTIVITY': return 'Test suspicious activity detection'; - case 'RATE_LIMIT_EXCEEDED': return 'Test rate limiting trigger'; - case 'UNAUTHORIZED_ACCESS': return 'Test unauthorized access attempt'; - case 'TOKEN_REUSE': return 'Test token reuse from different IP'; - case 'UNUSUAL_LOCATION': return 'Test unusual geographic location'; - case 'PRIVILEGE_ESCALATION': return 'Test privilege escalation attempt'; - case 'MALFORMED_REQUEST': return 'Test malformed request detection'; - case 'BRUTE_FORCE_ATTEMPT': return 'Test brute force attack simulation'; - default: return 'Test security event'; + case 'AUTH_FAILURE': return 'Authentication failure event - failed login attempt'; + case 'INVALID_TOKEN': return 'Token audience validation failure - token used across MCP server boundaries'; + case 'SUSPICIOUS_ACTIVITY': return 'Suspicious user agent detected or rapid cross-server access pattern (token passthrough)'; + case 'RATE_LIMIT_EXCEEDED': return 'Rate limit exceeded - more than 30 requests per minute from single IP'; + case 'TOKEN_REUSE': return 'Token reuse detection - same token used from different IP addresses'; + case 'UNUSUAL_LOCATION': return 'Unusual geographic location access (currently disabled in demo)'; + case 'PRIVILEGE_ESCALATION': return 'OAuth privilege escalation - user requesting elevated scopes beyond historical patterns'; + case 'BRUTE_FORCE_ATTEMPT': return 'Brute force attack - multiple authentication failures from same IP'; + case 'OAUTH_INVALID_CLIENT': return 'OAuth PKCE bypass attempt - authorization code flow without PKCE protection'; + case 'OAUTH_INVALID_GRANT': return 'OAuth missing resource parameter - requests without proper redirect URI/resource identification'; + default: return 'Security event'; + } +} + +function getRealisticDetailsForEventType(eventType: string, index: number): Record { + const baseIP = `192.168.1.${100 + index}`; + const userAgents = [ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'curl/7.68.0', + 'python-requests/2.28.1', + 'PostmanRuntime/7.29.2' + ]; + + switch (eventType) { + case 'AUTH_FAILURE': + return { + reason: 'Invalid credentials provided', + attemptedUsername: `user${index}@example.com`, + failureCount: Math.floor(Math.random() * 5) + 1 + }; + + case 'INVALID_TOKEN': + return { + mcpServers: [`server-${index}-a`, `server-${index}-b`], + currentEndpoint: '/mcp/sse', + reason: 'Access token used across multiple MCP server boundaries (audience violation)' + }; + + case 'SUSPICIOUS_ACTIVITY': + return Math.random() > 0.5 ? { + // User agent detection + userAgent: userAgents[1], // curl + detectedPattern: 'curl', + reason: 'Non-human user agent detected' + } : { + // Token passthrough + serverChanges: [ + { fromServer: `server-${index}-1`, toServer: `server-${index}-2`, timeDiffMs: 2000 } + ], + reason: 'Rapid cross-MCP server access pattern suggests potential token passthrough' + }; + + case 'RATE_LIMIT_EXCEEDED': + return { + requestCount: 35 + Math.floor(Math.random() * 10), + limit: 30, + timeWindow: 60000, + endpoint: '/mcp/sse' + }; + + case 'TOKEN_REUSE': + return { + currentIP: baseIP, + previousIP: `10.0.0.${50 + index}`, + timeDifference: Math.floor(Math.random() * 300000), // Up to 5 minutes + reason: 'Same token used from different IP addresses' + }; + + case 'UNUSUAL_LOCATION': + return { + currentLocation: { country: 'Unknown' }, + previousLocations: [ + { country: 'United States', city: 'San Francisco' }, + { country: 'United States', city: 'New York' } + ], + reason: 'Access from unusual geographic location' + }; + + case 'PRIVILEGE_ESCALATION': + return Math.random() > 0.5 ? { + // Elevated scopes + currentScopes: ['read', 'write', 'admin', 'delete'], + newElevatedScopes: ['admin', 'delete'], + historicalScopeCount: 2, + reason: 'User attempting to access elevated OAuth scopes not previously granted' + } : { + // Scope explosion + currentScopes: ['read', 'write', 'manage', 'config', 'system'], + newScopes: ['manage', 'config', 'system'], + historicalScopeCount: 2, + reason: 'Unusual number of new OAuth scopes requested' + }; + + case 'BRUTE_FORCE_ATTEMPT': + return { + failedAttempts: 5 + Math.floor(Math.random() * 5), + timeWindow: '15 minutes', + reason: 'Multiple authentication failures from same IP' + }; + + case 'OAUTH_INVALID_CLIENT': + return { + nonPKCECount: Math.floor(Math.random() * 3) + 1, + totalRequests: Math.floor(Math.random() * 5) + 2, + reason: 'Authorization code flow without PKCE protection (OAuth 2.1 violation)' + }; + + case 'OAUTH_INVALID_GRANT': + return { + missingRedirectCount: Math.floor(Math.random() * 2) + 1, + grantTypes: ['authorization_code', 'refresh_token'][Math.floor(Math.random() * 2)], + reason: 'OAuth requests missing required redirect URI (potential resource parameter missing)' + }; + + default: + return {}; } } @@ -139,16 +249,16 @@ export async function GET() { message: 'Security Event Generator API', usage: 'POST with { "eventType": "AUTH_FAILURE", "count": 5 }', availableEventTypes: [ - 'AUTH_FAILURE', - 'INVALID_TOKEN', - 'SUSPICIOUS_ACTIVITY', - 'RATE_LIMIT_EXCEEDED', - 'UNAUTHORIZED_ACCESS', - 'TOKEN_REUSE', - 'UNUSUAL_LOCATION', - 'PRIVILEGE_ESCALATION', - 'MALFORMED_REQUEST', - 'BRUTE_FORCE_ATTEMPT' + 'AUTH_FAILURE', // Authentication failures + 'INVALID_TOKEN', // Token audience violations (critical) + 'SUSPICIOUS_ACTIVITY', // User agent detection & token passthrough + 'RATE_LIMIT_EXCEEDED', // API abuse detection + 'TOKEN_REUSE', // Cross-IP token usage + 'UNUSUAL_LOCATION', // Geographic anomalies (disabled) + 'PRIVILEGE_ESCALATION', // OAuth scope escalation (critical) + 'BRUTE_FORCE_ATTEMPT', // Credential attacks + 'OAUTH_INVALID_CLIENT', // PKCE bypass attempts (critical) + 'OAUTH_INVALID_GRANT' // Missing resource parameters ] }); } \ No newline at end of file diff --git a/app/globals.css b/app/globals.css index c041672..048f0d2 100644 --- a/app/globals.css +++ b/app/globals.css @@ -32,6 +32,10 @@ --secondary-foreground: oklch(1 0 0); --destructive: oklch(0.58 0.245 27); --ring: var(--primary-600); + --popover: var(--background); + --popover-foreground: var(--foreground); + --accent: var(--muted); + --accent-foreground: var(--foreground); /* Layout */ --radius: 0.5rem; @@ -45,6 +49,10 @@ --border: var(--base-800); --input: var(--base-800); --destructive: oklch(0.70 0.191 22); + --popover: var(--background); + --popover-foreground: var(--foreground); + --accent: var(--muted); + --accent-foreground: var(--foreground); } @theme { @@ -78,6 +86,10 @@ --color-secondary-foreground: var(--secondary-foreground); --color-destructive: var(--destructive); --color-ring: var(--ring); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); --radius-sm: calc(var(--radius) - 2px); --radius-md: var(--radius); diff --git a/app/mcp/[transport]/route.ts b/app/mcp/[transport]/route.ts index 2517de7..e6ea07f 100644 --- a/app/mcp/[transport]/route.ts +++ b/app/mcp/[transport]/route.ts @@ -180,6 +180,7 @@ async function logEnhancedAnalytics( toolName = params.name; } } + console.log('[MCP Analytics] Extracted:', { mcpMethod, toolName, hasParams: !!requestBody.params }); } // Get or create MCP server registration @@ -255,8 +256,8 @@ async function logEnhancedAnalytics( 'Content-Type': 'application/json', }, body: JSON.stringify(analyticsData) - }).catch(() => { - // Silent fail - analytics shouldn't break MCP requests + }).catch((error) => { + console.warn('Analytics collection failed:', error, 'Data:', analyticsData); }); } catch (error) { diff --git a/app/oauth/authorize/page.tsx b/app/oauth/authorize/page.tsx index 395003d..e6c786a 100644 --- a/app/oauth/authorize/page.tsx +++ b/app/oauth/authorize/page.tsx @@ -162,28 +162,29 @@ export default async function AuthorizePage({ } return ( -
-
-

- Authorize Application -

-
-

- The application{' '} - {client.name} is - requesting access to your account. -

-

- Do you want to grant access? -

-
-
-
+
+
+
+
+

+ Authorize Application +

+

+ The application{' '} + {client.name} is + requesting access to your account. +

+

+ Do you want to grant access? +

+
+ + @@ -191,13 +192,13 @@ export default async function AuthorizePage({ type="submit" name="consent" value="deny" - className="px-4 py-2 bg-gray-200 text-gray-800 rounded-md hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-opacity-50" + className="w-full border border-border text-foreground hover:bg-muted px-4 py-2 rounded-md focus:outline-none focus:ring-2 focus:ring-border focus:ring-opacity-50 transition-colors" > Deny -
- + +
-
+
); } diff --git a/app/page.tsx b/app/page.tsx index 9e76e1b..c158f58 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -5,6 +5,10 @@ import { BarChart3, LogOut, LogIn } from "lucide-react"; export default async function Home() { const session = await auth(); + + // Check if user is admin + const adminEmail = process.env.ADMIN_EMAIL; + const isAdmin = session?.user?.email === adminEmail; return (
@@ -17,15 +21,23 @@ export default async function Home() {
- - - + {isAdmin ? ( + + + + ) : ( +
+

+ Analytics dashboard is only available to administrators. +

+
+ )}
{ 'use server'; await signOut(); }}> - diff --git a/app/prisma.ts b/app/prisma.ts index 41bd73a..3cd3cd5 100644 --- a/app/prisma.ts +++ b/app/prisma.ts @@ -17,6 +17,84 @@ const prisma = global.prisma || new PrismaClient({ if (process.env.NODE_ENV !== 'production') global.prisma = prisma; +// TTL Cleanup function - removes data older than 14 days +export async function cleanupExpiredData() { + const now = new Date(); + + try { + const result = await prisma.$transaction([ + // Cleanup expired analytics requests + prisma.analyticsRequest.deleteMany({ + where: { expiresAt: { lt: now } } + }), + + // Cleanup expired security events + prisma.analyticsSecurity.deleteMany({ + where: { expiresAt: { lt: now } } + }), + + // Cleanup expired OAuth tokens + prisma.accessToken.deleteMany({ + where: { expiresAt: { lt: now } } + }), + + prisma.authCode.deleteMany({ + where: { expiresAt: { lt: now } } + }), + + prisma.refreshToken.deleteMany({ + where: { expiresAt: { lt: now } } + }) + ]); + + const totalDeleted = result.reduce((sum, res) => sum + res.count, 0); + + console.log(`TTL Cleanup completed: ${totalDeleted} records deleted`); + return { success: true, deletedCount: totalDeleted }; + } catch (error) { + console.error('TTL Cleanup failed:', error); + return { success: false, error: (error as Error).message }; + } +} + +// Get TTL status for monitoring +export async function getTTLStatus() { + const now = new Date(); + const oneDayFromNow = new Date(now.getTime() + 24 * 60 * 60 * 1000); + + try { + const [expiredAnalytics, expiringSoonAnalytics, expiredSecurity, expiringSoonSecurity] = await Promise.all([ + prisma.analyticsRequest.count({ + where: { expiresAt: { lt: now } } + }), + prisma.analyticsRequest.count({ + where: { expiresAt: { lt: oneDayFromNow, gte: now } } + }), + prisma.analyticsSecurity.count({ + where: { expiresAt: { lt: now } } + }), + prisma.analyticsSecurity.count({ + where: { expiresAt: { lt: oneDayFromNow, gte: now } } + }) + ]); + + return { + analyticsRequests: { + expired: expiredAnalytics, + expiringSoon: expiringSoonAnalytics + }, + securityEvents: { + expired: expiredSecurity, + expiringSoon: expiringSoonSecurity + }, + timestamp: now + }; + } catch (error) { + console.error('Failed to get TTL status:', error); + return { error: (error as Error).message }; + } +} + export { prisma }; declare global { diff --git a/components/analytics/analytics-pie-chart.tsx b/components/analytics/analytics-pie-chart.tsx new file mode 100644 index 0000000..929bd50 --- /dev/null +++ b/components/analytics/analytics-pie-chart.tsx @@ -0,0 +1,119 @@ +"use client" + +import type React from "react" +import { Pie, PieChart, Cell } from "recharts" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + ChartConfig, + ChartContainer, + ChartLegend, +} from "@/components/ui/chart" + +interface PieChartData { + name: string + value: number + percentage?: number + fill?: string +} + +interface AnalyticsPieChartProps { + title: string + description?: string + data: PieChartData[] + dataKey?: string + nameKey?: string + className?: string + config?: ChartConfig + icon?: React.ComponentType<{ className?: string }> +} + +// Default color palette using your globals.css colors +const defaultColors = [ + "var(--primary-600)", // Main primary color + "var(--secondary-600)", // Secondary color + "var(--primary-300)", // Lighter primary + "var(--secondary-300)", // Lighter secondary + "var(--primary-800)", // Darker primary + "var(--secondary-800)", // Darker secondary + "var(--base-600)", // Neutral + "var(--base-400)" // Light neutral +] + +export function AnalyticsPieChart({ + title, + description, + data, + dataKey = "value", + nameKey = "name", + className, + config, + icon: Icon +}: AnalyticsPieChartProps) { + + // Add colors to data if not provided + const chartData = data.map((item, index) => ({ + ...item, + fill: item.fill || defaultColors[index % defaultColors.length] + })) + + // Generate config if not provided + const chartConfig = config || chartData.reduce((acc, item, index) => { + const key = item.name.toLowerCase().replace(/[^a-z0-9]/g, '') + acc[key] = { + label: item.name, + color: item.fill || defaultColors[index % defaultColors.length] + } + return acc + }, {} as ChartConfig) + + return ( + + +
+
+ {title} + {description && ( + {description} + )} +
+ {/* Icon Container - Design System: spacing.3, borderRadius.xl, semantic colors */} + {Icon && ( + + )} +
+
+ + + + + {chartData.map((entry, index) => ( + + ))} + + + + + +
+ ) +} \ No newline at end of file diff --git a/components/analytics/dashboard-header.tsx b/components/analytics/dashboard-header.tsx index 049a4b7..2d3ad6f 100644 --- a/components/analytics/dashboard-header.tsx +++ b/components/analytics/dashboard-header.tsx @@ -20,31 +20,19 @@ interface DashboardHeaderProps { className?: string } -function getStatusColor(status: string) { - switch (status) { - case "healthy": - return "bg-green-500" - case "warning": - return "bg-yellow-500" - case "critical": - return "bg-destructive" - default: - return "bg-base-400" - } -} function getStatusBadge(status: string) { const baseClasses = "inline-flex items-center px-3 py-1 rounded-full text-sm font-medium" switch (status) { case "healthy": - return cn(baseClasses, "bg-green-500 text-white") + return cn(baseClasses, "bg-primary-600 text-primary-foreground") case "warning": - return cn(baseClasses, "bg-yellow-500 text-white") + return cn(baseClasses, "bg-secondary-600 text-secondary-foreground") case "critical": - return cn(baseClasses, "bg-destructive text-white") + return cn(baseClasses, "bg-destructive text-primary-foreground") default: - return cn(baseClasses, "bg-base-400 text-white") + return cn(baseClasses, "bg-base-400 text-primary-foreground") } } @@ -53,7 +41,6 @@ export function DashboardHeader({ subtitle, lastUpdated, timeRange, - status = "unknown", serverName, serverUrl, children, @@ -67,17 +54,17 @@ export function DashboardHeader({ )} role="banner" > -
-
-
- {/* Title - Design System: fontSize.3xl, fontWeight.bold, color.foreground */} -

+
+
+
+ {/* Title - Design System: fontSize.2xl, fontWeight.bold, color.foreground */} +

{title}

{/* Subtitle */} {subtitle && ( -

+

{subtitle}

)} @@ -86,14 +73,14 @@ export function DashboardHeader({ {/* Status and Meta Information */}
{/* Last Updated */} {lastUpdated && (
-