@sirmrmarty/n8n-nodes-tmux-orchestrator
Version:
n8n nodes for orchestrating Claude AI agents through tmux sessions
635 lines (558 loc) • 17.4 kB
text/typescript
/**
* Authentication Layer for Critical Operations
* Provides secure authentication and authorization for sensitive operations
*/
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import { CommandSanitizer } from './commandSanitizer';
import { resourceManager } from './resourceManager';
import { stateManager } from './threadSafeState';
export interface AuthenticationCredentials {
userId: string;
apiKey: string;
sessionToken?: string;
permissions: string[];
expiresAt: number;
}
export interface AuthenticationResult {
authenticated: boolean;
authorized: boolean;
userId?: string;
permissions?: string[];
reason?: string;
sessionToken?: string;
}
export interface OperationContext {
operation: string;
resourceId?: string;
requesterId?: string;
timestamp: number;
metadata?: Record<string, any>;
}
export class AuthenticationLayer {
private static readonly SESSION_DURATION = 2 * 60 * 60 * 1000; // 2 hours
private static readonly API_KEY_LENGTH = 64; // 64 chars hex
private static readonly SESSION_TOKEN_LENGTH = 32; // 32 chars hex
private static readonly MAX_SESSIONS_PER_USER = 5;
private static readonly AUTH_CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
// Secure storage for active sessions
private static activeSessions = stateManager.getMap<string, AuthenticationCredentials>('activeSessions');
// Authentication cache to reduce overhead
private static authCache = stateManager.getMap<string, { result: AuthenticationResult; cachedAt: number }>('authCache');
// Critical operations that require authentication
private static readonly CRITICAL_OPERATIONS = new Set([
'createProject',
'killSession',
'executeScheduledProject',
'addTeamMember',
'removeTeamMember',
'blockCommit',
'approveCommit',
'generateQAKeyPair',
'registerQAEngineer',
'createCryptographicQAApproval'
]);
// Permission levels for different operations
private static readonly OPERATION_PERMISSIONS = {
'createProject': ['project.create', 'admin'],
'killSession': ['session.kill', 'admin'],
'executeScheduledProject': ['project.execute', 'admin'],
'addTeamMember': ['team.manage', 'admin'],
'removeTeamMember': ['team.manage', 'admin'],
'blockCommit': ['qa.block', 'qa.engineer', 'admin'],
'approveCommit': ['qa.approve', 'qa.engineer', 'admin'],
'generateQAKeyPair': ['qa.keys', 'admin'],
'registerQAEngineer': ['qa.register', 'admin'],
'createCryptographicQAApproval': ['qa.approve', 'qa.engineer', 'admin']
};
/**
* Generate a new API key for a user
*/
public static generateApiKey(): string {
return crypto.randomBytes(this.API_KEY_LENGTH / 2).toString('hex');
}
/**
* Generate a secure session token
*/
private static generateSessionToken(): string {
return crypto.randomBytes(this.SESSION_TOKEN_LENGTH / 2).toString('hex');
}
/**
* Create authentication credentials for a user
*/
public static async createUserCredentials(
userId: string,
permissions: string[]
): Promise<{ apiKey: string; userId: string; permissions: string[] }> {
// Validate inputs
if (!userId || typeof userId !== 'string') {
throw new Error('Invalid user ID');
}
const userValidation = CommandSanitizer.validateProjectName(userId); // Reuse project name validation
if (!userValidation.isValid) {
throw new Error(`Invalid user ID format: ${userValidation.errors.join(', ')}`);
}
if (!Array.isArray(permissions)) {
throw new Error('Permissions must be an array');
}
// Generate secure API key
const apiKey = this.generateApiKey();
// Store credentials securely
const credentialsDir = '/var/n8n/auth';
try {
await fs.promises.access(credentialsDir);
} catch {
await fs.promises.mkdir(credentialsDir, { recursive: true, mode: 0o700 });
}
const credentialsPath = path.join(credentialsDir, `${userId}.json`);
const credentials = {
userId,
apiKeyHash: crypto.createHash('sha256').update(apiKey).digest('hex'),
permissions,
createdAt: Date.now(),
lastUsed: null
};
await fs.promises.writeFile(credentialsPath, JSON.stringify(credentials, null, 2), { mode: 0o600 });
return { apiKey, userId, permissions };
}
/**
* Authenticate a user with API key
*/
public static async authenticateUser(
userId: string,
apiKey: string
): Promise<AuthenticationResult> {
try {
// Input validation
if (!userId || !apiKey) {
return {
authenticated: false,
authorized: false,
reason: 'Missing credentials'
};
}
// Check cache first
const cacheKey = `${userId}:${crypto.createHash('sha256').update(apiKey).digest('hex').substring(0, 16)}`;
const cached = this.authCache.safeGet(cacheKey);
if (cached && (Date.now() - cached.cachedAt) < this.AUTH_CACHE_DURATION) {
return cached.result;
}
// Load user credentials
const credentialsDir = '/var/n8n/auth';
const credentialsPath = path.join(credentialsDir, `${userId}.json`);
try {
await fs.promises.access(credentialsPath);
} catch {
const result = {
authenticated: false,
authorized: false,
reason: 'User not found'
};
await this.authCache.safeSet(cacheKey, { result, cachedAt: Date.now() });
return result;
}
const credentials = JSON.parse(await fs.promises.readFile(credentialsPath, 'utf8'));
// Verify API key
const providedKeyHash = crypto.createHash('sha256').update(apiKey).digest('hex');
if (!crypto.timingSafeEqual(
Buffer.from(credentials.apiKeyHash, 'hex'),
Buffer.from(providedKeyHash, 'hex')
)) {
const result = {
authenticated: false,
authorized: false,
reason: 'Invalid API key'
};
try {
await this.authCache.safeSet(cacheKey, { result, cachedAt: Date.now() });
} catch (error) {
console.warn('Failed to cache auth result:', error.message);
}
return result;
}
// Generate session token
const sessionToken = this.generateSessionToken();
const expiresAt = Date.now() + this.SESSION_DURATION;
// Clean up old sessions for this user
await this.cleanupUserSessions(userId);
// Store active session
const sessionCredentials: AuthenticationCredentials = {
userId,
apiKey,
sessionToken,
permissions: credentials.permissions,
expiresAt
};
try {
await this.activeSessions.safeSet(sessionToken, sessionCredentials);
} catch (error) {
console.error('Failed to store active session:', error.message);
throw new Error('Failed to create session');
}
// Update last used timestamp
credentials.lastUsed = Date.now();
await fs.promises.writeFile(credentialsPath, JSON.stringify(credentials, null, 2), { mode: 0o600 });
const result = {
authenticated: true,
authorized: true,
userId,
permissions: credentials.permissions,
sessionToken
};
try {
await this.authCache.safeSet(cacheKey, { result, cachedAt: Date.now() });
} catch (error) {
console.warn('Failed to cache auth result:', error.message);
}
return result;
} catch (error) {
return {
authenticated: false,
authorized: false,
reason: `Authentication error: ${error.message}`
};
}
}
/**
* Authenticate using session token
*/
public static authenticateSession(sessionToken: string): AuthenticationResult {
try {
if (!sessionToken) {
return {
authenticated: false,
authorized: false,
reason: 'Missing session token'
};
}
const session = this.activeSessions.safeGet(sessionToken);
if (!session) {
return {
authenticated: false,
authorized: false,
reason: 'Invalid session token'
};
}
// Check expiration
if (Date.now() > session.expiresAt) {
// Note: We can't await in this sync method, so we fire and forget
this.activeSessions.safeDelete(sessionToken).catch(error => {
console.warn('Failed to delete expired session:', error.message);
});
return {
authenticated: false,
authorized: false,
reason: 'Session expired'
};
}
return {
authenticated: true,
authorized: true,
userId: session.userId,
permissions: session.permissions,
sessionToken
};
} catch (error) {
return {
authenticated: false,
authorized: false,
reason: `Session authentication error: ${error.message}`
};
}
}
/**
* Authorize an operation for a user
*/
public static authorizeOperation(
operation: string,
userPermissions: string[]
): { authorized: boolean; reason?: string } {
try {
// Check if operation requires authentication
if (!this.CRITICAL_OPERATIONS.has(operation)) {
return { authorized: true }; // Non-critical operations allowed
}
// Get required permissions for operation
const requiredPermissions = this.OPERATION_PERMISSIONS[operation];
if (!requiredPermissions) {
return { authorized: false, reason: 'Unknown operation' };
}
// Check if user has any of the required permissions
const hasPermission = requiredPermissions.some(perm =>
userPermissions.includes(perm)
);
if (!hasPermission) {
return {
authorized: false,
reason: `Missing required permissions: ${requiredPermissions.join(' or ')}`
};
}
return { authorized: true };
} catch (error) {
return {
authorized: false,
reason: `Authorization error: ${error.message}`
};
}
}
/**
* Complete authentication and authorization check
*/
public static async authenticateAndAuthorize(
operation: string,
credentials: { userId?: string; apiKey?: string; sessionToken?: string },
context?: OperationContext
): Promise<AuthenticationResult> {
try {
let authResult: AuthenticationResult;
// Try session authentication first
if (credentials.sessionToken) {
authResult = this.authenticateSession(credentials.sessionToken);
}
// Fall back to API key authentication
else if (credentials.userId && credentials.apiKey) {
authResult = await this.authenticateUser(credentials.userId, credentials.apiKey);
}
else {
return {
authenticated: false,
authorized: false,
reason: 'No valid credentials provided'
};
}
// If authentication failed, return early
if (!authResult.authenticated) {
return authResult;
}
// Check authorization for the operation
const authzResult = this.authorizeOperation(operation, authResult.permissions || []);
if (!authzResult.authorized) {
return {
authenticated: true,
authorized: false,
userId: authResult.userId,
permissions: authResult.permissions,
reason: authzResult.reason
};
}
// Log the operation for audit trail
if (context) {
await this.logOperation(authResult.userId!, operation, context);
}
return authResult;
} catch (error) {
return {
authenticated: false,
authorized: false,
reason: `Authentication/authorization error: ${error.message}`
};
}
}
/**
* Log operation for audit trail
*/
private static async logOperation(
userId: string,
operation: string,
context: OperationContext
): Promise<void> {
try {
const logDir = '/var/log/n8n/auth';
try {
await fs.promises.access(logDir);
} catch {
await fs.promises.mkdir(logDir, { recursive: true, mode: 0o700 });
}
const logEntry = {
timestamp: new Date().toISOString(),
userId,
operation,
context: {
resourceId: context.resourceId,
requesterId: context.requesterId,
metadata: context.metadata
},
auditHash: ''
};
// Generate audit hash
logEntry.auditHash = crypto
.createHash('sha256')
.update(JSON.stringify(logEntry, Object.keys(logEntry).sort()))
.digest('hex');
const logPath = path.join(logDir, `operations_${new Date().toISOString().split('T')[0]}.log`);
await fs.promises.appendFile(logPath, JSON.stringify(logEntry) + '\n', { mode: 0o600 });
} catch (error) {
console.warn('Failed to log operation:', error.message);
}
}
/**
* Clean up expired sessions and old user sessions
*/
private static async cleanupUserSessions(userId: string): Promise<void> {
try {
const userSessions = this.activeSessions.safeEntries()
.filter(([_, session]) => session.userId === userId);
// Remove expired sessions
for (const [token, session] of userSessions) {
if (Date.now() > session.expiresAt) {
try {
await this.activeSessions.safeDelete(token);
} catch (error) {
console.warn(`Failed to delete expired session ${token}:`, error.message);
}
}
}
// Limit sessions per user
const validUserSessions = this.activeSessions.safeEntries()
.filter(([_, session]) => session.userId === userId)
.sort((a, b) => b[1].expiresAt - a[1].expiresAt);
if (validUserSessions.length >= this.MAX_SESSIONS_PER_USER) {
// Remove oldest sessions
const toRemove = validUserSessions.slice(this.MAX_SESSIONS_PER_USER - 1);
for (const [token] of toRemove) {
try {
await this.activeSessions.safeDelete(token);
} catch (error) {
console.warn(`Failed to delete old session ${token}:`, error.message);
}
}
}
} catch (error) {
console.error('Failed to cleanup user sessions:', error.message);
}
}
/**
* Revoke a session
*/
public static async revokeSession(sessionToken: string): Promise<boolean> {
try {
return await this.activeSessions.safeDelete(sessionToken);
} catch (error) {
console.error('Failed to revoke session:', error.message);
return false;
}
}
/**
* Revoke all sessions for a user
*/
public static async revokeUserSessions(userId: string): Promise<number> {
let revokedCount = 0;
try {
const entries = this.activeSessions.safeEntries();
for (const [token, session] of entries) {
if (session.userId === userId) {
try {
await this.activeSessions.safeDelete(token);
revokedCount++;
} catch (error) {
console.warn(`Failed to revoke session ${token}:`, error.message);
}
}
}
} catch (error) {
console.error('Failed to revoke user sessions:', error.message);
}
return revokedCount;
}
/**
* Get statistics about active sessions
*/
public static getSessionStats(): {
activeSessions: number;
uniqueUsers: number;
expiringSoon: number;
} {
const now = Date.now();
const fiveMinutes = 5 * 60 * 1000;
const uniqueUsers = new Set();
let expiringSoon = 0;
for (const session of this.activeSessions.safeValues()) {
if (now < session.expiresAt) {
uniqueUsers.add(session.userId);
if (session.expiresAt - now < fiveMinutes) {
expiringSoon++;
}
}
}
return {
activeSessions: this.activeSessions.safeSize(),
uniqueUsers: uniqueUsers.size,
expiringSoon
};
}
/**
* Initialize authentication system (setup default admin if needed)
*/
public static async initialize(): Promise<void> {
try {
// Ensure auth directory exists
const authDir = '/var/n8n/auth';
try {
await fs.promises.access(authDir);
} catch {
await fs.promises.mkdir(authDir, { recursive: true, mode: 0o700 });
}
// Check if any admin exists
const files = await fs.promises.readdir(authDir);
const hasAdmin = await Promise.all(files.map(async file => {
try {
const credPath = path.join(authDir, file);
const creds = JSON.parse(await fs.promises.readFile(credPath, 'utf8'));
return creds.permissions && creds.permissions.includes('admin');
} catch {
return false;
}
})).then(results => results.some(Boolean));
// Create default admin if none exists
if (!hasAdmin) {
const defaultAdminId = 'system_admin';
const defaultPermissions = ['admin'];
const { apiKey } = await this.createUserCredentials(defaultAdminId, defaultPermissions);
console.log(`Created default admin credentials:`);
console.log(`User ID: ${defaultAdminId}`);
console.log(`API Key: ${apiKey}`);
console.log(`Permissions: ${defaultPermissions.join(', ')}`);
console.log(`Store these credentials securely!`);
}
// Schedule periodic cleanup
resourceManager.createInterval(async () => {
await this.performPeriodicCleanup();
}, 10 * 60 * 1000, 'Authentication periodic cleanup');
} catch (error) {
console.warn('Failed to initialize authentication system:', error.message);
}
}
/**
* Perform periodic cleanup of expired sessions and cache
*/
private static async performPeriodicCleanup(): Promise<void> {
const now = Date.now();
try {
// Clean expired sessions
const sessionEntries = this.activeSessions.safeEntries();
for (const [token, session] of sessionEntries) {
if (now > session.expiresAt) {
try {
await this.activeSessions.safeDelete(token);
} catch (error) {
console.warn(`Failed to delete expired session ${token}:`, error.message);
}
}
}
// Clean expired cache entries
const cacheEntries = this.authCache.safeEntries();
for (const [key, cached] of cacheEntries) {
if (now - cached.cachedAt > this.AUTH_CACHE_DURATION) {
try {
await this.authCache.safeDelete(key);
} catch (error) {
console.warn(`Failed to delete expired cache entry ${key}:`, error.message);
}
}
}
} catch (error) {
console.error('Failed to perform periodic cleanup:', error.message);
}
}
}