// 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;