Skip to main content
Glama
orneryd

M.I.M.I.R - Multi-agent Intelligent Memory & Insight Repository

by orneryd
passport.ts16.6 kB
// Load environment variables first import dotenv from 'dotenv'; dotenv.config(); import crypto from 'crypto'; import passport from 'passport'; import { Strategy as LocalStrategy } from 'passport-local'; import { Strategy as OAuth2Strategy } from 'passport-oauth2'; import { createSecureFetchOptions, validateOAuthTokenFormat, validateOAuthUserinfoUrl } from '../utils/fetch-helper.js'; import { getOAuthTimeout } from './oauth-constants.js'; // Development: Local username/password (configurable via env vars) // Supports multiple dev users with different roles for RBAC testing // Format: MIMIR_DEV_USER_<NAME>=username:password:role1,role2,role3 if (process.env.MIMIR_ENABLE_SECURITY === 'true') { // Parse all MIMIR_DEV_USER_* environment variables const devUsers: Array<{ username: string; password: string; roles: string[]; id: string }> = []; Object.keys(process.env).forEach(key => { if (key.startsWith('MIMIR_DEV_USER_')) { const value = process.env[key]; if (value) { const [username, password, rolesStr] = value.split(':'); if (username && password) { const roles = rolesStr ? rolesStr.split(',').map(r => r.trim()) : ['viewer']; const userId = key.replace('MIMIR_DEV_USER_', '').toLowerCase(); devUsers.push({ username, password, roles, id: userId }); console.log(`[Auth] Dev user registered: ${username} with roles [${roles.join(', ')}]`); } } } }); // Fallback: If no MIMIR_DEV_USER_* vars, check legacy MIMIR_DEV_USERNAME/PASSWORD if (devUsers.length === 0 && process.env.MIMIR_DEV_USERNAME && process.env.MIMIR_DEV_PASSWORD) { devUsers.push({ username: process.env.MIMIR_DEV_USERNAME, password: process.env.MIMIR_DEV_PASSWORD, roles: ['admin'], id: 'legacy-admin' }); console.log(`[Auth] Legacy dev user registered: ${process.env.MIMIR_DEV_USERNAME} with roles [admin]`); } if (devUsers.length > 0) { passport.use(new LocalStrategy((username, password, done) => { // Find matching dev user const user = devUsers.find(u => u.username === username && u.password === password); if (user) { return done(null, { id: user.id, email: `${username}@localhost`, roles: user.roles, username: user.username }); } return done(null, false, { message: 'Invalid credentials' }); })); } } // Production: OAuth if (process.env.MIMIR_ENABLE_SECURITY === 'true' && process.env.MIMIR_AUTH_PROVIDER) { console.log(`[Auth] OAuth enabled with provider: ${process.env.MIMIR_AUTH_PROVIDER}`); // OAuth endpoint URLs - MUST be explicitly configured (no hardcoded paths) const authorizationURL = process.env.MIMIR_OAUTH_AUTHORIZATION_URL; const tokenURL = process.env.MIMIR_OAUTH_TOKEN_URL; if (!authorizationURL || !tokenURL) { throw new Error( 'OAuth configuration incomplete: MIMIR_OAUTH_AUTHORIZATION_URL and MIMIR_OAUTH_TOKEN_URL are required. ' + 'Do not use hardcoded paths - each provider has different endpoints:\n' + ' - Okta: /oauth2/v1/authorize, /oauth2/v1/token\n' + ' - Auth0: /authorize, /oauth/token\n' + ' - Azure AD: /oauth2/v2.0/authorize, /oauth2/v2.0/token\n' + ' - Google: /o/oauth2/v2/auth, /token\n' + 'See docs/security/README.md for provider-specific examples.' ); } console.log(`[Auth] Authorization URL: ${authorizationURL}`); console.log(`[Auth] Token URL: ${tokenURL}`); /** * Custom state store for OAuth with proper CSRF protection * * Stores OAuth state parameters in memory with automatic expiration * for CSRF validation. Implements singleton pattern to prevent memory * leaks during hot reload scenarios. * * **Security Features**: * - Cryptographically secure random state generation * - Automatic expiration (10 minutes) * - One-time use tokens (deleted after verification) * - Periodic cleanup of expired states * * @example * // Singleton usage (internal) * const store = SecureStateStore.getInstance(); * * // Store state before OAuth redirect * store.store(req, (err, state) => { * if (!err) { * console.log('State generated:', state); * } * }); * * // Verify state after OAuth callback * store.verify(req, state, (err, valid) => { * if (valid) { * console.log('State validated successfully'); * } * }); */ class SecureStateStore { private states: Map<string, { timestamp: number; vscodeData?: any }> = new Map(); private readonly STATE_EXPIRY_MS = 10 * 60 * 1000; // 10 minutes private cleanupTimer: NodeJS.Timeout | null = null; private static instance: SecureStateStore | null = null; constructor() { // Clean up expired states every minute // Use unref() to allow process to exit cleanly and prevent memory leak this.cleanupTimer = setInterval(() => this.cleanupExpiredStates(), 60 * 1000); this.cleanupTimer.unref(); } /** * Get singleton instance of SecureStateStore * * Ensures only one instance exists to prevent memory leaks * during development hot reload scenarios. * * @returns Singleton SecureStateStore instance * * @example * const store = SecureStateStore.getInstance(); * console.log('Using singleton state store'); */ static getInstance(): SecureStateStore { if (!SecureStateStore.instance) { SecureStateStore.instance = new SecureStateStore(); } return SecureStateStore.instance; } /** * Destroy singleton instance and cleanup resources * * Called during hot reload or graceful shutdown to prevent * memory leaks and cleanup interval timers. * * @example * // Cleanup on shutdown * SecureStateStore.destroyInstance(); * console.log('State store cleaned up'); */ static destroyInstance() { if (SecureStateStore.instance) { SecureStateStore.instance.destroy(); SecureStateStore.instance = null; } } private cleanupExpiredStates() { const now = Date.now(); let cleanedCount = 0; for (const [state, data] of this.states.entries()) { if (now - data.timestamp > this.STATE_EXPIRY_MS) { this.states.delete(state); cleanedCount++; } } if (cleanedCount > 0) { console.log(`[OAuth] Cleaned up ${cleanedCount} expired state(s)`); } } /** * Cleanup instance resources * * Clears the cleanup interval timer and all stored states. * Called by destroyInstance() or during testing. * * @example * const store = new SecureStateStore(); * store.destroy(); * console.log('Resources released'); */ destroy() { if (this.cleanupTimer) { clearInterval(this.cleanupTimer); this.cleanupTimer = null; } this.states.clear(); } /** * Generate and store OAuth state parameter * * Creates a cryptographically secure random state token and stores * it with timestamp for later verification. Supports both passport * signatures: store(req, callback) and store(req, meta, callback). * * @param req - Express request object * @param callbackOrMeta - Callback function or metadata object * @param maybeCallback - Optional callback if meta provided * * @example * // Used internally by passport-oauth2 * store.store(req, (err, state) => { * if (!err) { * console.log('Generated state:', state.substring(0, 8) + '...'); * // Redirect to OAuth provider with state parameter * } * }); */ store(req: any, callbackOrMeta: any, maybeCallback?: any) { // Handle both signatures: store(req, callback) and store(req, meta, callback) const callback = maybeCallback || callbackOrMeta; // Generate cryptographically secure random state const state = crypto.randomBytes(32).toString('hex'); // Check if this is a VSCode redirect request const vscodeState = (req as any)._vscodeState; // Store state with timestamp for validation this.states.set(state, { timestamp: Date.now(), vscodeData: vscodeState }); console.log(`[OAuth] Generated state: ${state.substring(0, 8)}... (expires in ${this.STATE_EXPIRY_MS / 1000}s)`); callback(null, state); } /** * Verify OAuth state parameter for CSRF protection * * Validates that the state parameter matches a previously stored * state and hasn't expired. Supports both passport signatures: * verify(req, state, callback) and verify(req, state, meta, callback). * * **Security Checks**: * - State must exist in store * - State must not be expired (10 min TTL) * - State is deleted after verification (one-time use) * * @param req - Express request object * @param state - State parameter from OAuth callback * @param callbackOrMeta - Callback function or metadata object * @param maybeCallback - Optional callback if meta provided * * @example * // Used internally by passport-oauth2 * store.verify(req, stateFromCallback, (err, valid) => { * if (valid) { * console.log('CSRF check passed'); * // Continue with OAuth flow * } else { * console.error('CSRF attack detected:', err.message); * } * }); */ verify(req: any, state: string, callbackOrMeta: any, maybeCallback?: any) { // Handle both signatures: verify(req, state, callback) and verify(req, state, meta, callback) const callback = maybeCallback || callbackOrMeta; if (!state) { console.error('[OAuth] CSRF: No state parameter provided'); return callback(new Error('Missing state parameter'), false); } const storedState = this.states.get(state); if (!storedState) { console.error(`[OAuth] CSRF: Invalid or expired state: ${state.substring(0, 8)}...`); return callback(new Error('Invalid or expired state parameter'), false); } // Check if state has expired const now = Date.now(); if (now - storedState.timestamp > this.STATE_EXPIRY_MS) { console.error(`[OAuth] CSRF: Expired state: ${state.substring(0, 8)}...`); this.states.delete(state); return callback(new Error('Expired state parameter'), false); } // State is valid - remove it (one-time use) this.states.delete(state); // If this was a VSCode redirect, restore the data if (storedState.vscodeData) { (req as any)._vscodeState = storedState.vscodeData; } console.log(`[OAuth] State validated successfully: ${state.substring(0, 8)}...`); callback(null, true); } } // Store class reference globally for cleanup (global as any).__SecureStateStore = SecureStateStore; // Use singleton instance to prevent memory leaks during hot reload const stateStore = SecureStateStore.getInstance(); passport.use('oauth', new OAuth2Strategy({ authorizationURL: authorizationURL, tokenURL: tokenURL, clientID: process.env.MIMIR_OAUTH_CLIENT_ID!, clientSecret: process.env.MIMIR_OAUTH_CLIENT_SECRET!, callbackURL: process.env.MIMIR_OAUTH_CALLBACK_URL!, store: stateStore, passReqToCallback: false, }, async (accessToken: string, refreshToken: string, profile: any, done: any) => { try { // Fetch user profile from userinfo endpoint - MUST be explicitly configured const userinfoURL = process.env.MIMIR_OAUTH_USERINFO_URL; if (!userinfoURL) { return done(new Error( 'MIMIR_OAUTH_USERINFO_URL is required. Each provider has different userinfo endpoints:\n' + ' - Okta: https://your-domain.okta.com/oauth2/v1/userinfo\n' + ' - Auth0: https://your-domain.auth0.com/userinfo\n' + ' - Azure AD: https://graph.microsoft.com/oidc/userinfo\n' + ' - Google: https://openidconnect.googleapis.com/v1/userinfo' )); } // SECURITY: Validate access token format to prevent SSRF and injection attacks try { validateOAuthTokenFormat(accessToken); } catch (validationError: any) { console.error('[OAuth] Invalid access token format:', validationError.message); return done(new Error('Invalid access token format')); } // SECURITY: Validate userinfo URL to prevent SSRF attacks try { validateOAuthUserinfoUrl(userinfoURL); } catch (validationError: any) { console.error('[OAuth] Invalid userinfo URL:', validationError.message); return done(new Error('Invalid OAuth configuration')); } // Configure timeout for userinfo fetch (default 10s, configurable via env) const timeoutMs = getOAuthTimeout(); const fetchOptions = createSecureFetchOptions( userinfoURL, { headers: { 'Authorization': `Bearer ${accessToken}` } }, undefined, // no API key env var timeoutMs // explicit timeout ); console.log(`[OAuth] Fetching userinfo from ${userinfoURL} (timeout: ${timeoutMs}ms)`); const response = await fetch(userinfoURL, fetchOptions); if (!response.ok) { const errorMsg = `Failed to fetch user profile: ${response.status} ${response.statusText}`; console.error(`[OAuth] ${errorMsg}`); return done(new Error(errorMsg)); } const userProfile = await response.json(); // Extract roles from profile (configurable claim path) const roles = userProfile.roles || userProfile.groups || []; const user = { id: userProfile.sub || userProfile.id || userProfile.email, email: userProfile.email, username: userProfile.preferred_username || userProfile.username || userProfile.email, roles: Array.isArray(roles) ? roles : [roles], // Preserve original profile for custom claim extraction ...userProfile }; console.log(`[OAuth] User profile fetched successfully: ${user.email}`); // Pass access token as authInfo so it's available in the callback route return done(null, user, { accessToken }); } catch (error: any) { // Handle timeout specifically if (error.name === 'AbortError') { const timeoutMsg = `OAuth userinfo request timed out after ${getOAuthTimeout()}ms`; console.error(`[OAuth] ${timeoutMsg}`); return done(new Error(timeoutMsg)); } // Handle other fetch errors console.error('[OAuth] Error fetching user profile:', error.message || error); return done(error); } })); } // Serialize user to session passport.serializeUser((user: any, done) => done(null, user)); passport.deserializeUser((user: any, done) => done(null, user)); // Export cleanup function for graceful shutdown and hot reload scenarios export function cleanupSecureStateStore() { if (process.env.MIMIR_ENABLE_SECURITY === 'true' && process.env.MIMIR_AUTH_PROVIDER) { console.log('[Auth] Cleaning up SecureStateStore'); // The SecureStateStore class is defined inside the OAuth if block // We need to access it through a module-level reference if ((global as any).__SecureStateStore) { (global as any).__SecureStateStore.destroyInstance(); } } } // Graceful shutdown handler for development hot reload // Cleans up the SecureStateStore singleton to prevent memory leaks if (process.env.NODE_ENV === 'development') { // Store reference to cleanup function for hot reload if ((global as any).__mimirPassportCleanup) { // Clean up previous instance before creating new one (global as any).__mimirPassportCleanup(); } // Register new cleanup function (global as any).__mimirPassportCleanup = cleanupSecureStateStore; } // Process-level cleanup handlers process.on('SIGTERM', () => { console.log('[Auth] SIGTERM received, cleaning up SecureStateStore'); cleanupSecureStateStore(); }); process.on('SIGINT', () => { console.log('[Auth] SIGINT received, cleaning up SecureStateStore'); cleanupSecureStateStore(); }); export default passport;

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/orneryd/Mimir'

If you have feedback or need assistance with the MCP directory API, please join our Discord server