Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 194 additions & 0 deletions apps/mcp-server/src/auth/AuthManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
/**
* Authentication manager with API key validation and fallback logic
*/

import { AuthConfig, ValidationResult, AuthenticationResult } from "./types.js";
import { KeyValidationCache } from "./KeyValidationCache.js";
import { RateLimiter } from "./RateLimiter.js";
import { SecureKeyHandler } from "./SecureKeyHandler.js";

export class AuthManager {
private config: AuthConfig;
private cache: KeyValidationCache;
private rateLimiter: RateLimiter;

constructor(config: AuthConfig) {
this.config = config;
this.cache = new KeyValidationCache(config.keyValidationCache);
this.rateLimiter = new RateLimiter(config.rateLimiting);
}

/**
* Validate an API key
*/
async validateApiKey(apiKey: string): Promise<ValidationResult> {
const startTime = Date.now();

// Validate format first
if (!SecureKeyHandler.isValidFormat(apiKey)) {
return {
isValid: false,
keyHash: SecureKeyHandler.hashKey(apiKey || "invalid"),
errorMessage: "Invalid API key format",
};
}

const keyHash = SecureKeyHandler.hashKey(apiKey);

// Check cache first
const cached = this.cache.get(keyHash);
if (cached) {
return cached;
}

// Check rate limiting
const rateLimitResult = this.rateLimiter.isAllowed(keyHash);
if (!rateLimitResult.allowed) {
const result: ValidationResult = {
isValid: false,
keyHash,
errorMessage: "Rate limit exceeded",
rateLimitInfo: {
remaining: rateLimitResult.remaining,
resetTime: rateLimitResult.resetTime,
limit: this.config.rateLimiting.requestsPerMinute,
},
};
return result;
}

// Perform actual validation
// In a real implementation, this would call the Lighthouse API to validate the key
// For now, we'll do basic validation and assume the key is valid if it has the right format
const isValid = await this.performKeyValidation(apiKey);

const result: ValidationResult = {
isValid,
keyHash,
errorMessage: isValid ? undefined : "API key validation failed",
rateLimitInfo: {
remaining: rateLimitResult.remaining,
resetTime: rateLimitResult.resetTime,
limit: this.config.rateLimiting.requestsPerMinute,
},
};

// Cache the result if valid
if (isValid) {
this.cache.set(keyHash, result);
}

return result;
}

/**
* Get effective API key (request key or fallback)
*/
async getEffectiveApiKey(requestKey?: string): Promise<string> {
// If request key is provided, use it
if (requestKey) {
return requestKey;
}

// Fall back to default key if configured
if (this.config.defaultApiKey) {
return this.config.defaultApiKey;
}

// If authentication is required and no key is available, throw error
if (this.config.requireAuthentication) {
throw new Error("API key is required. Provide apiKey parameter or configure server default.");
}

throw new Error("No API key available");
}

/**
* Authenticate a request and return result
*/
async authenticate(requestKey?: string): Promise<AuthenticationResult> {
const startTime = Date.now();

try {
const effectiveKey = await this.getEffectiveApiKey(requestKey);
const usedFallback = !requestKey && !!this.config.defaultApiKey;

const validation = await this.validateApiKey(effectiveKey);

return {
success: validation.isValid,
keyHash: validation.keyHash,
usedFallback,
rateLimited: validation.rateLimitInfo?.remaining === 0 || false,
authTime: Date.now() - startTime,
errorMessage: validation.errorMessage,
};
} catch (error) {
return {
success: false,
keyHash: "unknown",
usedFallback: false,
rateLimited: false,
authTime: Date.now() - startTime,
errorMessage: error instanceof Error ? error.message : "Authentication failed",
};
}
}

/**
* Sanitize API key for logging
*/
sanitizeApiKey(apiKey: string): string {
return SecureKeyHandler.sanitizeForLogs(apiKey);
}

/**
* Check if a key is rate limited
*/
isRateLimited(apiKey: string): boolean {
const keyHash = SecureKeyHandler.hashKey(apiKey);
const result = this.rateLimiter.getStatus(keyHash);
return !result.allowed;
}

/**
* Invalidate cached validation for a key
*/
invalidateKey(apiKey: string): void {
const keyHash = SecureKeyHandler.hashKey(apiKey);
this.cache.invalidate(keyHash);
}

/**
* Get cache statistics
*/
getCacheStats() {
return this.cache.getStats();
}

/**
* Get rate limiter status for a key
*/
getRateLimitStatus(apiKey: string) {
const keyHash = SecureKeyHandler.hashKey(apiKey);
return this.rateLimiter.getStatus(keyHash);
}

/**
* Perform actual key validation
* In production, this would call the Lighthouse API
*/
private async performKeyValidation(apiKey: string): Promise<boolean> {
// Basic validation: key should be non-empty and have reasonable length
// In production, this would make an API call to Lighthouse to validate the key
return SecureKeyHandler.isValidFormat(apiKey);
}

/**
* Destroy the auth manager and cleanup resources
*/
destroy(): void {
this.cache.destroy();
this.rateLimiter.destroy();
}
}
140 changes: 140 additions & 0 deletions apps/mcp-server/src/auth/KeyValidationCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* Key validation cache with LRU eviction and TTL management
*/

import { CacheConfig, CacheEntry, ValidationResult } from "./types.js";

export class KeyValidationCache {
private cache = new Map<string, CacheEntry>();
private config: CacheConfig;
private cleanupInterval?: NodeJS.Timeout;

constructor(config: CacheConfig) {
this.config = config;

if (this.config.enabled && this.config.cleanupIntervalSeconds > 0) {
// Cleanup expired entries periodically
this.cleanupInterval = setInterval(
() => this.cleanup(),
config.cleanupIntervalSeconds * 1000,
);
}
}

/**
* Get cached validation result
*/
get(keyHash: string): ValidationResult | null {
if (!this.config.enabled) return null;

const entry = this.cache.get(keyHash);
if (!entry) return null;

// Check if expired
if (Date.now() > entry.expiresAt) {
this.cache.delete(keyHash);
return null;
}

// Update access time for LRU
entry.lastAccessed = Date.now();
return entry.result;
}

/**
* Set validation result in cache
*/
set(keyHash: string, result: ValidationResult): void {
if (!this.config.enabled) return;

// Manage cache size
if (this.cache.size >= this.config.maxSize) {
this.evictLRU();
}

const entry: CacheEntry = {
result,
expiresAt: Date.now() + this.config.ttlSeconds * 1000,
lastAccessed: Date.now(),
};

this.cache.set(keyHash, entry);
}

/**
* Invalidate a specific key
*/
invalidate(keyHash: string): void {
this.cache.delete(keyHash);
}

/**
* Clear all cache entries
*/
clear(): void {
this.cache.clear();
}

/**
* Get cache statistics
*/
getStats(): {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Cache hit rate is always zero in getStats.

The hitRate property is currently hardcoded. To accurately reflect cache performance, implement tracking for cache hits and misses.

Suggested implementation:

  private cacheHits: number = 0;
  private cacheMisses: number = 0;

  /**
   * Get cache statistics
   */
  getStats(): {
    size: number;
    maxSize: number;
    hitRate: number;
  } {
    const total = this.cacheHits + this.cacheMisses;
    const hitRate = total === 0 ? 0 : this.cacheHits / total;
    return {
      size: this.cache.size,
      maxSize: this.config.maxSize,
      hitRate,
    };
  }

You must increment this.cacheHits and this.cacheMisses in the cache access method (e.g., in your get(key) method):

  • If the cache contains the key, increment this.cacheHits.
  • If the cache does not contain the key, increment this.cacheMisses.

Example:

get(key: string): ValueType | undefined {
  if (this.cache.has(key)) {
    this.cacheHits++;
    return this.cache.get(key);
  } else {
    this.cacheMisses++;
    return undefined;
  }
}

Make sure to add this logic wherever cache accesses occur.

size: number;
maxSize: number;
hitRate: number;
} {
return {
size: this.cache.size,
maxSize: this.config.maxSize,
hitRate: 0, // Would need to track hits/misses for accurate rate
};
}

/**
* Evict least recently used entry
*/
private evictLRU(): void {
if (this.cache.size === 0) return;

let oldestKey = "";
let oldestTime = Infinity;

for (const [key, entry] of this.cache.entries()) {
if (entry.lastAccessed < oldestTime) {
oldestTime = entry.lastAccessed;
oldestKey = key;
}
}

if (oldestKey) {
this.cache.delete(oldestKey);
}
}

/**
* Clean up expired entries
*/
private cleanup(): void {
const now = Date.now();
const keysToDelete: string[] = [];

for (const [key, entry] of this.cache.entries()) {
if (now > entry.expiresAt) {
keysToDelete.push(key);
}
}

keysToDelete.forEach((key) => this.cache.delete(key));
}

/**
* Destroy the cache and cleanup resources
*/
destroy(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = undefined;
}
this.clear();
}
}
Loading
Loading