Skip to main content
Glama

DollhouseMCP

by DollhouseMCP
tokenManager.tsโ€ข19.5 kB
/** * Secure GitHub token management and validation */ import { logger } from '../utils/logger.js'; import { RateLimiter } from '../utils/RateLimiter.js'; import { SecurityError } from './errors.js'; import * as crypto from 'crypto'; import * as fs from 'fs/promises'; import * as path from 'path'; import { homedir } from 'os'; import { SecurityMonitor } from './securityMonitor.js'; import { UnicodeValidator } from './validators/unicodeValidator.js'; export interface TokenScopes { required: string[]; optional?: string[]; } export interface TokenValidationResult { isValid: boolean; scopes?: string[]; rateLimit?: { remaining: number; resetTime: Date; }; rateLimitExceeded?: boolean; retryAfterMs?: number; error?: string; } /** * Secure GitHub token manager with validation and protection */ export class TokenManager { private static readonly GITHUB_TOKEN_PATTERNS = { // More flexible patterns - accept any content after the prefix PERSONAL_ACCESS_TOKEN: /^ghp_.+$/, // Personal access tokens FINE_GRAINED_PAT: /^github_pat_.+$/, // Fine-grained personal access tokens INSTALLATION_TOKEN: /^ghs_.+$/, // GitHub App installation tokens USER_ACCESS_TOKEN: /^ghu_.+$/, // GitHub App user-to-server tokens REFRESH_TOKEN: /^ghr_.+$/, // Refresh tokens OAUTH_ACCESS_TOKEN: /^gho_.+$/, // OAuth device flow tokens // Generic pattern to catch ALL GitHub tokens (gh + any letter + underscore + anything) GENERIC_GITHUB_TOKEN: /^gh[a-z]_.+$/i // Catch-all for any gh*_ pattern }; // Secure storage configuration private static readonly TOKEN_DIR = path.join(homedir(), '.dollhouse', '.auth'); private static readonly TOKEN_FILE = 'github_token.enc'; private static readonly ALGORITHM = 'aes-256-gcm'; private static readonly KEY_LENGTH = 32; private static readonly IV_LENGTH = 16; private static readonly TAG_LENGTH = 16; private static readonly SALT_LENGTH = 32; private static readonly ITERATIONS = 100000; // Rate limiter for token validation operations - prevents brute force attacks private static tokenValidationLimiter: RateLimiter | null = null; /** * Get or create the token validation rate limiter * Prevents brute force token validation attacks */ private static getTokenValidationLimiter(): RateLimiter { if (!this.tokenValidationLimiter) { this.tokenValidationLimiter = this.createTokenValidationLimiter(); } return this.tokenValidationLimiter; } /** * Create a rate limiter specifically for token validation * Conservative limits to prevent abuse while allowing legitimate usage */ static createTokenValidationLimiter(): RateLimiter { return new RateLimiter({ maxRequests: 10, // 10 validation attempts windowMs: 60 * 60 * 1000, // per hour minDelayMs: 5 * 1000 // 5 seconds minimum between attempts }); } /** * Reset the token validation rate limiter * Useful for testing or manual intervention */ static resetTokenValidationLimiter(): void { this.tokenValidationLimiter?.reset(); } /** * Validate GitHub token format */ static validateTokenFormat(token: string): boolean { if (!token || typeof token !== 'string') { return false; } // Check against all known GitHub token patterns return Object.values(this.GITHUB_TOKEN_PATTERNS).some(pattern => pattern.test(token) ); } /** * Get GitHub token from environment with validation */ static getGitHubToken(): string | null { const token = process.env.GITHUB_TOKEN; if (!token) { logger.debug('No GitHub token found in environment'); return null; } if (!this.validateTokenFormat(token)) { logger.warn('Invalid GitHub token format detected', { tokenPrefix: this.getTokenPrefix(token), length: token.length }); return null; } logger.debug('Valid GitHub token found', { tokenType: this.getTokenType(token), tokenPrefix: this.getTokenPrefix(token) }); return token; } /** * Redact token for safe logging */ static redactToken(token: string): string { if (!token || token.length < 8) { return '[REDACTED]'; } return token.substring(0, 4) + '...' + token.substring(token.length - 4); } /** * Get token type from format */ static getTokenType(token: string): string { // Check fine-grained PAT first since it's more specific if (this.GITHUB_TOKEN_PATTERNS.FINE_GRAINED_PAT.test(token)) { return 'Fine-grained Personal Access Token'; } if (this.GITHUB_TOKEN_PATTERNS.PERSONAL_ACCESS_TOKEN.test(token)) { return 'Personal Access Token'; } if (this.GITHUB_TOKEN_PATTERNS.INSTALLATION_TOKEN.test(token)) { return 'Installation Token'; } if (this.GITHUB_TOKEN_PATTERNS.USER_ACCESS_TOKEN.test(token)) { return 'User Access Token'; } if (this.GITHUB_TOKEN_PATTERNS.REFRESH_TOKEN.test(token)) { return 'Refresh Token'; } if (this.GITHUB_TOKEN_PATTERNS.OAUTH_ACCESS_TOKEN.test(token)) { return 'OAuth Access Token'; } // Check generic pattern last if (this.GITHUB_TOKEN_PATTERNS.GENERIC_GITHUB_TOKEN.test(token)) { return 'GitHub Token'; } return 'Unknown'; } /** * Get safe token prefix for logging */ static getTokenPrefix(token: string): string { if (!token || token.length < 4) { return '[INVALID]'; } return token.substring(0, 4) + '...'; } /** * Validate token scopes via GitHub API */ static async validateTokenScopes( token: string, requiredScopes: TokenScopes ): Promise<TokenValidationResult> { // Validate token format before consuming rate limit if (!this.validateTokenFormat(token)) { return { isValid: false, error: 'Invalid token format' }; } // Check rate limit before making API call const rateLimiter = this.getTokenValidationLimiter(); const rateLimitStatus = rateLimiter.checkLimit(); if (!rateLimitStatus.allowed) { logger.warn('Token validation rate limit exceeded', { tokenPrefix: this.getTokenPrefix(token), retryAfterMs: rateLimitStatus.retryAfterMs, remainingTokens: rateLimitStatus.remainingTokens }); throw new SecurityError( `Token validation rate limit exceeded. Please retry in ${Math.ceil((rateLimitStatus.retryAfterMs || 0) / 1000)} seconds.`, 'RATE_LIMIT_EXCEEDED' ); } try { // Consume rate limit token for this validation attempt rateLimiter.consumeToken(); // Make a test API call to check token validity and scopes const response = await fetch('https://api.github.com/user', { headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'DollhouseMCP/1.0' } }); const rateLimitRemaining = Number.parseInt(response.headers.get('x-ratelimit-remaining') || '0'); const rateLimitReset = Number.parseInt(response.headers.get('x-ratelimit-reset') || '0'); if (!response.ok) { const error = `GitHub API error: ${response.status} ${response.statusText}`; logger.warn('Token validation failed', { status: response.status, tokenPrefix: this.getTokenPrefix(token) }); return { isValid: false, error: error }; } // Extract scopes from response headers const scopesHeader = response.headers.get('x-oauth-scopes') || ''; const tokenScopes = scopesHeader.split(',').map(s => s.trim()).filter(s => s); // Check if required scopes are present const hasRequiredScopes = requiredScopes.required.every(scope => tokenScopes.includes(scope) ); if (!hasRequiredScopes) { const missingScopes = requiredScopes.required.filter(scope => !tokenScopes.includes(scope) ); logger.warn('Token missing required scopes', { tokenPrefix: this.getTokenPrefix(token), missingScopes: missingScopes, currentScopes: tokenScopes }); return { isValid: false, scopes: tokenScopes, error: `Missing required scopes: ${missingScopes.join(', ')}` }; } logger.info('Token validation successful', { tokenType: this.getTokenType(token), tokenPrefix: this.getTokenPrefix(token), scopes: tokenScopes, rateLimitRemaining: rateLimitRemaining }); return { isValid: true, scopes: tokenScopes, rateLimit: { remaining: rateLimitRemaining, resetTime: new Date(rateLimitReset * 1000) } }; } catch (error) { // Handle SecurityError (including rate limit errors) separately if (error instanceof SecurityError && error.code === 'RATE_LIMIT_EXCEEDED') { const currentStatus = rateLimiter.checkLimit(); return { isValid: false, rateLimitExceeded: true, retryAfterMs: currentStatus.retryAfterMs, error: error.message }; } const errorMessage = error instanceof Error ? error.message : 'Unknown error'; logger.error('Token validation error', { error: errorMessage, tokenPrefix: this.getTokenPrefix(token) }); return { isValid: false, error: `Validation error: ${errorMessage}` }; } } /** * Create safe error message without token exposure */ static createSafeErrorMessage(error: string, token?: string): string { // Remove any potential token data from error messages // Using word boundaries to avoid over-matching let safeMessage = error .replaceAll(/\bghp_\S+/g, '[REDACTED_PAT]') .replaceAll(/\bgithub_pat_\S+/g, '[REDACTED_FINE_PAT]') .replaceAll(/\bghs_\S+/g, '[REDACTED_INSTALL]') .replaceAll(/\bghu_\S+/g, '[REDACTED_USER]') .replaceAll(/\bghr_\S+/g, '[REDACTED_REFRESH]') .replaceAll(/\bgho_\S+/g, '[REDACTED_OAUTH]') .replaceAll(/\bgh[a-z]_\S+/gi, '[REDACTED_TOKEN]'); // Catch any other gh*_ pattern if (token) { const tokenPrefix = this.getTokenPrefix(token); safeMessage += ` (Token: ${tokenPrefix})`; } return safeMessage; } /** * Get minimum required scopes for different operations * * NOTE: The 'marketplace' scope identifier is kept for backward compatibility * with existing token validations. This is an internal scope name and does not * affect user-facing functionality. (PR #280) */ static getRequiredScopes(operation: 'read' | 'write' | 'marketplace' | 'collection' | 'gist'): TokenScopes { switch (operation) { case 'read': return { required: ['public_repo'], // OAuth tokens use 'public_repo' not 'repo' optional: ['user:email'] }; case 'write': return { required: ['public_repo'], // OAuth tokens use 'public_repo' not 'repo' optional: ['user:email'] }; case 'marketplace': // Internal scope name kept for compatibility (PR #280) case 'collection': // New preferred name return { required: ['public_repo'], // OAuth tokens use 'public_repo' not 'repo' optional: ['user:email'] }; case 'gist': return { required: ['gist'], optional: ['user:email'] }; default: return { required: ['public_repo'] // OAuth tokens use 'public_repo' not 'repo' }; } } /** * Check if token has sufficient permissions for operation * * NOTE: The 'marketplace' operation type is kept for backward compatibility. * This is called internally when accessing collection features. (PR #280) */ static async ensureTokenPermissions( operation: 'read' | 'write' | 'marketplace' | 'collection' | 'gist' ): Promise<TokenValidationResult> { const token = this.getGitHubToken(); if (!token) { return { isValid: false, error: 'No GitHub token available' }; } const requiredScopes = this.getRequiredScopes(operation); return this.validateTokenScopes(token, requiredScopes); } /** * Derive encryption key from a passphrase */ private static deriveKey(passphrase: string, salt: Buffer): Buffer { return crypto.pbkdf2Sync(passphrase, salt, this.ITERATIONS, this.KEY_LENGTH, 'sha256'); } /** * Get machine-specific passphrase for encryption * Uses a combination of machine ID and user info for uniqueness */ private static getMachinePassphrase(): string { // Use a combination of hostname, username, and a fixed app identifier const hostname = crypto.createHash('sha256').update(homedir()).digest('hex').substring(0, 16); const username = crypto.createHash('sha256').update(process.env.USER || 'default').digest('hex').substring(0, 16); const appId = 'DollhouseMCP-TokenStore-v1'; return `${appId}-${hostname}-${username}`; } /** * Store GitHub token securely to file */ static async storeGitHubToken(token: string): Promise<void> { try { // Validate token format first if (!this.validateTokenFormat(token)) { throw new SecurityError('Invalid token format'); } // Normalize and validate token const validation = UnicodeValidator.normalize(token); if (!validation.isValid) { throw new SecurityError('Token contains invalid characters'); } // Ensure directory exists await fs.mkdir(this.TOKEN_DIR, { recursive: true, mode: 0o700 }); // Generate encryption components const salt = crypto.randomBytes(this.SALT_LENGTH); const iv = crypto.randomBytes(this.IV_LENGTH); const passphrase = this.getMachinePassphrase(); const key = this.deriveKey(passphrase, salt); // Encrypt token const cipher = crypto.createCipheriv(this.ALGORITHM, key, iv); const encrypted = Buffer.concat([ cipher.update(validation.normalizedContent, 'utf8'), cipher.final() ]); const tag = cipher.getAuthTag(); // Create storage format: salt + iv + tag + encrypted const stored = Buffer.concat([salt, iv, tag, encrypted]); // Write to file with restricted permissions const tokenPath = path.join(this.TOKEN_DIR, this.TOKEN_FILE); await fs.writeFile(tokenPath, stored, { mode: 0o600 }); // Log security event SecurityMonitor.logSecurityEvent({ type: 'TOKEN_VALIDATION_SUCCESS', severity: 'LOW', source: 'TokenManager.storeGitHubToken', details: 'GitHub token stored securely', metadata: { tokenType: this.getTokenType(token), tokenPrefix: this.getTokenPrefix(token) } }); logger.info('GitHub token stored securely'); } catch (error) { SecurityMonitor.logSecurityEvent({ type: 'TOKEN_VALIDATION_FAILURE', severity: 'MEDIUM', source: 'TokenManager.storeGitHubToken', details: `Failed to store GitHub token: ${error instanceof Error ? error.message : 'Unknown error'}` }); throw new SecurityError(`Failed to store token: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Retrieve GitHub token from secure storage */ static async retrieveGitHubToken(): Promise<string | null> { try { const tokenPath = path.join(this.TOKEN_DIR, this.TOKEN_FILE); // Check if file exists try { await fs.access(tokenPath); } catch { // No stored token return null; } // Read encrypted data const stored = await fs.readFile(tokenPath); // Extract components const salt = stored.subarray(0, this.SALT_LENGTH); const iv = stored.subarray(this.SALT_LENGTH, this.SALT_LENGTH + this.IV_LENGTH); const tag = stored.subarray(this.SALT_LENGTH + this.IV_LENGTH, this.SALT_LENGTH + this.IV_LENGTH + this.TAG_LENGTH); const encrypted = stored.subarray(this.SALT_LENGTH + this.IV_LENGTH + this.TAG_LENGTH); // Derive decryption key const passphrase = this.getMachinePassphrase(); const key = this.deriveKey(passphrase, salt); // Decrypt token const decipher = crypto.createDecipheriv(this.ALGORITHM, key, iv); decipher.setAuthTag(tag); const decrypted = Buffer.concat([ decipher.update(encrypted), decipher.final() ]).toString('utf8'); // Validate decrypted token if (!this.validateTokenFormat(decrypted)) { SecurityMonitor.logSecurityEvent({ type: 'TOKEN_VALIDATION_FAILURE', severity: 'HIGH', source: 'TokenManager.retrieveGitHubToken', details: 'Decrypted token has invalid format' }); return null; } SecurityMonitor.logSecurityEvent({ type: 'TOKEN_VALIDATION_SUCCESS', severity: 'LOW', source: 'TokenManager.retrieveGitHubToken', details: 'GitHub token retrieved from secure storage', metadata: { tokenType: this.getTokenType(decrypted), tokenPrefix: this.getTokenPrefix(decrypted) } }); return decrypted; } catch (error) { SecurityMonitor.logSecurityEvent({ type: 'TOKEN_VALIDATION_FAILURE', severity: 'MEDIUM', source: 'TokenManager.retrieveGitHubToken', details: `Failed to retrieve GitHub token: ${error instanceof Error ? error.message : 'Unknown error'}` }); logger.debug('Failed to retrieve stored token', { error }); return null; } } /** * Remove stored GitHub token */ static async removeStoredToken(): Promise<void> { try { const tokenPath = path.join(this.TOKEN_DIR, this.TOKEN_FILE); // Check if file exists before attempting deletion try { await fs.access(tokenPath); await fs.unlink(tokenPath); SecurityMonitor.logSecurityEvent({ type: 'TOKEN_CACHE_CLEARED', severity: 'LOW', source: 'TokenManager.removeStoredToken', details: 'GitHub token removed from secure storage' }); logger.info('Stored GitHub token removed'); } catch (error) { // File doesn't exist or couldn't be deleted logger.debug('No stored token to remove'); } } catch (error) { SecurityMonitor.logSecurityEvent({ type: 'TOKEN_CACHE_CLEARED', severity: 'LOW', source: 'TokenManager.removeStoredToken', details: `Failed to remove stored token: ${error instanceof Error ? error.message : 'Unknown error'}` }); logger.warn('Failed to remove stored token', { error }); } } /** * Get GitHub token from environment or secure storage * Updated to check secure storage if environment variable not set */ static async getGitHubTokenAsync(): Promise<string | null> { // First check environment variable const envToken = this.getGitHubToken(); if (envToken) { return envToken; } // Fall back to secure storage return this.retrieveGitHubToken(); } }

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/DollhouseMCP/DollhouseMCP'

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