okta-mcp-server
Version:
Model Context Protocol (MCP) server for Okta API operations with support for bulk operations and caching
298 lines • 11.8 kB
JavaScript
import { logger } from '../../utils/logger.js';
import { buildQueryParams } from './utils.js';
/**
* Common event types for filtering
*/
export const EVENT_TYPES = {
// User events
USER_LOGIN: 'user.session.start',
USER_LOGOUT: 'user.session.end',
USER_CREATED: 'user.lifecycle.create',
USER_ACTIVATED: 'user.lifecycle.activate',
USER_DEACTIVATED: 'user.lifecycle.deactivate',
USER_SUSPENDED: 'user.lifecycle.suspend',
USER_UNSUSPENDED: 'user.lifecycle.unsuspend',
USER_DELETED: 'user.lifecycle.delete',
USER_PASSWORD_CHANGED: 'user.account.update_password',
USER_PASSWORD_RESET: 'user.account.reset_password',
USER_MFA_ENROLLED: 'user.mfa.factor.enroll',
USER_MFA_CHALLENGED: 'user.mfa.factor.verify',
// Application events
APP_CREATED: 'application.lifecycle.create',
APP_UPDATED: 'application.lifecycle.update',
APP_DELETED: 'application.lifecycle.delete',
APP_USER_ASSIGNED: 'application.user_membership.add',
APP_USER_REMOVED: 'application.user_membership.remove',
// Group events
GROUP_CREATED: 'group.lifecycle.create',
GROUP_UPDATED: 'group.lifecycle.update',
GROUP_DELETED: 'group.lifecycle.delete',
GROUP_USER_ADDED: 'group.user_membership.add',
GROUP_USER_REMOVED: 'group.user_membership.remove',
// Policy events
POLICY_CREATED: 'policy.lifecycle.create',
POLICY_UPDATED: 'policy.lifecycle.update',
POLICY_DELETED: 'policy.lifecycle.delete',
POLICY_ACTIVATED: 'policy.lifecycle.activate',
POLICY_DEACTIVATED: 'policy.lifecycle.deactivate',
// Security events
SECURITY_THREAT_DETECTED: 'security.threat.detected',
SECURITY_SUSPICIOUS_ACTIVITY: 'security.suspicious_activity',
SECURITY_RATE_LIMIT: 'security.rate_limit.violation',
// Admin events
ADMIN_ACCESS: 'admin.access',
ADMIN_PRIVILEGE_GRANT: 'admin.privilege.grant',
ADMIN_PRIVILEGE_REVOKE: 'admin.privilege.revoke',
};
/**
* Parses the URI to extract query parameters for events
*/
function parseEventsUri(uri) {
const url = new URL(uri, 'okta://');
const params = url.searchParams;
return {
since: params.get('since') || undefined,
until: params.get('until') || undefined,
filter: params.get('filter') || undefined,
q: params.get('q') || undefined,
limit: params.get('limit') ? parseInt(params.get('limit'), 10) : 100,
sortOrder: params.get('sortOrder') || 'DESCENDING',
after: params.get('after') || undefined,
eventType: params.getAll('eventType').length > 0 ? params.getAll('eventType') : undefined,
severity: params.getAll('severity').length > 0 ? params.getAll('severity') : undefined,
actorId: params.get('actorId') || undefined,
targetId: params.get('targetId') || undefined,
outcome: params.get('outcome') || undefined,
};
}
/**
* Build SCIM filter from options
*/
function buildEventFilter(options) {
const filters = [];
if (options.eventType && options.eventType.length > 0) {
const eventTypeFilter = options.eventType.map((type) => `eventType eq "${type}"`).join(' or ');
filters.push(`(${eventTypeFilter})`);
}
if (options.severity && options.severity.length > 0) {
const severityFilter = options.severity.map((sev) => `severity eq "${sev}"`).join(' or ');
filters.push(`(${severityFilter})`);
}
if (options.actorId) {
filters.push(`actor.id eq "${options.actorId}"`);
}
if (options.targetId) {
filters.push(`target.id eq "${options.targetId}"`);
}
if (options.outcome) {
filters.push(`outcome.result eq "${options.outcome}"`);
}
// Combine with existing filter if provided
if (options.filter) {
filters.push(`(${options.filter})`);
}
return filters.length > 0 ? filters.join(' and ') : undefined;
}
/**
* Async generator for streaming audit events efficiently
*/
async function* streamEvents(okta, options) {
const pageSize = Math.min(options.limit || 100, 1000);
let after = options.after;
let totalFetched = 0;
const maxToFetch = options.limit || Infinity;
// Default to last 7 days if no time range specified
const since = options.since || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
const until = options.until || new Date().toISOString();
// Build the combined filter
const filter = buildEventFilter(options);
try {
while (totalFetched < maxToFetch) {
const currentPageSize = Math.min(pageSize, maxToFetch - totalFetched);
logger.debug(`Fetching events page with size ${currentPageSize}, after: ${after || 'start'}`);
const response = await okta.getLogs(buildQueryParams({
since,
until,
filter,
q: options.q,
limit: currentPageSize,
sortOrder: options.sortOrder,
after,
}));
const events = response.data;
// Yield the current page of events
yield events;
totalFetched += events.length;
// Check if there are more pages
if (!response.headers?.link || events.length < currentPageSize) {
break;
}
// Parse the 'after' cursor from the Link header
const linkHeader = response.headers.link;
const afterMatch = linkHeader.match(/after=([^&>]+)/);
if (!afterMatch) {
break;
}
after = afterMatch[1];
}
}
catch (error) {
logger.error('Error streaming events:', error);
throw error;
}
}
/**
* Handle audit events resource with advanced filtering and analytics
*/
export default async function handleEventsResource(uri, container) {
const okta = container.resolve('okta');
const options = parseEventsUri(uri);
logger.info(`Handling events resource with options:`, options);
try {
// Check if this is an analytics request
if (uri.includes('analytics=true')) {
return handleEventAnalytics(uri, container, options);
}
// For regular event retrieval, fetch the first page
const firstPageSize = Math.min(options.limit || 100, 1000);
const since = options.since || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
const until = options.until || new Date().toISOString();
const filter = buildEventFilter(options);
const firstPageResponse = await okta.getLogs(buildQueryParams({
since,
until,
filter,
q: options.q,
limit: firstPageSize,
sortOrder: options.sortOrder,
after: options.after,
}));
const events = firstPageResponse.data;
// Build the response content
const content = {
uri,
mimeType: 'application/json',
text: JSON.stringify({
events,
metadata: {
count: events.length,
timeRange: {
since,
until,
},
filters: {
filter: options.filter,
q: options.q,
eventType: options.eventType,
severity: options.severity,
actorId: options.actorId,
targetId: options.targetId,
outcome: options.outcome,
sortOrder: options.sortOrder,
},
commonEventTypes: EVENT_TYPES,
},
}, null, 2),
};
// TODO: Add support for pagination and analytics links when MCP supports it
return content;
}
catch (error) {
logger.error('Failed to handle events resource:', error);
throw error;
}
}
/**
* Handle event analytics for security and compliance reporting
*/
async function handleEventAnalytics(uri, container, options) {
const analytics = {
totalEvents: 0,
eventTypes: {},
severityBreakdown: {},
actorBreakdown: {},
targetBreakdown: {},
outcomeBreakdown: {},
timeSeriesData: [],
topFailedLogins: [],
suspiciousActivities: [],
};
const failedLogins = new Map();
// Stream through all events for analytics
for await (const eventBatch of streamEvents(container.resolve('okta'), options)) {
for (const event of eventBatch) {
analytics.totalEvents++;
// Count by event type
analytics.eventTypes[event.eventType] = (analytics.eventTypes[event.eventType] || 0) + 1;
// Count by severity
analytics.severityBreakdown[event.severity] =
(analytics.severityBreakdown[event.severity] || 0) + 1;
// Count by actor
if (event.actor) {
const actorId = event.actor.id;
if (!analytics.actorBreakdown[actorId]) {
analytics.actorBreakdown[actorId] = { count: 0 };
if (event.actor.displayName) {
analytics.actorBreakdown[actorId].displayName = event.actor.displayName;
}
}
analytics.actorBreakdown[actorId].count++;
}
// Count by target
if (event.target && event.target.length > 0) {
for (const target of event.target) {
const targetId = target.id;
if (!analytics.targetBreakdown[targetId]) {
analytics.targetBreakdown[targetId] = { count: 0, type: target.type };
}
analytics.targetBreakdown[targetId].count++;
}
}
// Count by outcome
if (event.outcome) {
analytics.outcomeBreakdown[event.outcome.result] =
(analytics.outcomeBreakdown[event.outcome.result] || 0) + 1;
}
// Track failed logins
if (event.eventType === 'user.session.start' && event.outcome?.result === 'FAILURE') {
const actorId = event.actor?.id || 'unknown';
const existing = failedLogins.get(actorId) || { count: 0, lastAttempt: '' };
existing.count++;
existing.lastAttempt = event.published;
failedLogins.set(actorId, existing);
}
// Detect suspicious activities
if (event.severity === 'WARN' ||
event.severity === 'ERROR' ||
event.eventType.includes('security') ||
event.eventType.includes('threat')) {
analytics.suspiciousActivities.push(event);
}
}
}
// Prepare top failed logins
analytics.topFailedLogins = Array.from(failedLogins.entries())
.map(([actor, data]) => ({ actor, ...data }))
.sort((a, b) => b.count - a.count)
.slice(0, 10);
// Limit suspicious activities to top 20
analytics.suspiciousActivities = analytics.suspiciousActivities.slice(0, 20);
return {
uri,
mimeType: 'application/json',
text: JSON.stringify(analytics, null, 2),
};
}
/**
* Stream all events efficiently for bulk operations
* This is an additional export for programmatic use
*/
export async function* streamAllEvents(container, options = {}) {
const okta = container.resolve('okta');
for await (const eventBatch of streamEvents(okta, options)) {
for (const event of eventBatch) {
yield event;
}
}
}
//# sourceMappingURL=events.js.map