From 0ef8eec8a014471e3fbb35ff31b67936c25d73fc Mon Sep 17 00:00:00 2001 From: Aaron Bockelie Date: Tue, 15 Jul 2025 18:11:57 -0500 Subject: [PATCH] feat: Add API key authentication for enhanced security MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Generate secure API key on first plugin load - Implement HTTP Basic Auth middleware for all endpoints - Add authentication settings UI with copy/regenerate functionality - Update configuration examples to include auth credentials - Maintain backward compatibility (auth optional if no key set) - Closes #9 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- package.json | 2 +- src/main.ts | 81 ++++++++++++++++++++++++++++++++++++++++++++--- src/mcp-server.ts | 34 ++++++++++++++++++++ styles.css | 16 ++++++++++ 4 files changed, 128 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index eb8d17e..37e6ace 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-mcp-plugin", - "version": "0.5.15", + "version": "0.5.16", "description": "Semantic MCP server plugin providing AI tools with direct Obsidian vault access via HTTP transport", "main": "main.js", "scripts": { diff --git a/src/main.ts b/src/main.ts index 1403e00..13002c7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,7 @@ import { App, Plugin, PluginSettingTab, Setting, Notice } from 'obsidian'; import { MCPHttpServer } from './mcp-server'; import { getVersion } from './version'; import { Debug } from './utils/debug'; +import { randomBytes } from 'crypto'; interface MCPPluginSettings { httpEnabled: boolean; @@ -11,6 +12,7 @@ interface MCPPluginSettings { autoDetectPortConflicts: boolean; enableConcurrentSessions: boolean; maxConcurrentConnections: number; + apiKey: string; } const DEFAULT_SETTINGS: MCPPluginSettings = { @@ -20,7 +22,8 @@ const DEFAULT_SETTINGS: MCPPluginSettings = { showConnectionStatus: true, autoDetectPortConflicts: true, enableConcurrentSessions: false, // Disabled by default for backward compatibility - maxConcurrentConnections: 32 + maxConcurrentConnections: 32, + apiKey: '' // Will be generated on first load }; export default class ObsidianMCPPlugin extends Plugin { @@ -186,6 +189,19 @@ export default class ObsidianMCPPlugin extends Plugin { async loadSettings() { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + + // Generate API key on first load if not present + if (!this.settings.apiKey) { + this.settings.apiKey = this.generateApiKey(); + await this.saveSettings(); + Debug.log('🔐 Generated new API key for authentication'); + } + } + + public generateApiKey(): string { + // Generate a secure random API key + const bytes = randomBytes(32); + return bytes.toString('base64url'); } async saveSettings() { @@ -380,6 +396,9 @@ class MCPSettingTab extends PluginSettingTab { // Server Configuration Section this.createServerConfigSection(containerEl); + // Authentication Section + this.createAuthenticationSection(containerEl); + // UI Options Section this.createUIOptionsSection(containerEl); @@ -522,6 +541,59 @@ class MCPSettingTab extends PluginSettingTab { await this.plugin.saveSettings(); })); } + + private createAuthenticationSection(containerEl: HTMLElement): void { + containerEl.createEl('h3', {text: 'Authentication'}); + + new Setting(containerEl) + .setName('API Key') + .setDesc('Secure API key for authenticating MCP clients') + .addText(text => { + const input = text + .setPlaceholder('API key will be shown here') + .setValue(this.plugin.settings.apiKey) + .setDisabled(true); + + // Make the text input wider to accommodate the key + input.inputEl.style.width = '300px'; + input.inputEl.style.fontFamily = 'monospace'; + + // Add a class for styling + input.inputEl.classList.add('mcp-api-key-input'); + + return input; + }) + .addButton(button => button + .setButtonText('Copy') + .setTooltip('Copy API key to clipboard') + .onClick(async () => { + await navigator.clipboard.writeText(this.plugin.settings.apiKey); + new Notice('API key copied to clipboard'); + })) + .addButton(button => button + .setButtonText('Regenerate') + .setTooltip('Generate a new API key') + .setWarning() + .onClick(async () => { + // Show confirmation dialog + const confirmed = confirm('Are you sure you want to regenerate the API key? This will invalidate the current key and require updating all MCP clients.'); + + if (confirmed) { + this.plugin.settings.apiKey = this.plugin.generateApiKey(); + await this.plugin.saveSettings(); + new Notice('API key regenerated. Update your MCP clients with the new key.'); + this.display(); // Refresh the settings display + } + })); + + // Add a note about security + const securityNote = containerEl.createEl('p', { + text: 'Note: The API key is stored in the plugin settings file. Anyone with access to your vault can read it.', + cls: 'setting-item-description' + }); + securityNote.style.marginTop = '-10px'; + securityNote.style.marginBottom = '20px'; + } private createUIOptionsSection(containerEl: HTMLElement): void { containerEl.createEl('h3', {text: 'Interface Options'}); @@ -611,7 +683,8 @@ class MCPSettingTab extends PluginSettingTab { const commandExample = info.createDiv('protocol-command-example'); const codeEl = commandExample.createEl('code'); codeEl.classList.add('mcp-code-block'); - codeEl.textContent = `claude mcp add obsidian http://localhost:${this.plugin.settings.httpPort}/mcp --transport http`; + const apiKey = this.plugin.settings.apiKey; + codeEl.textContent = `claude mcp add obsidian http://obsidian:${apiKey}@localhost:${this.plugin.settings.httpPort}/mcp --transport http`; info.createEl('h4', {text: 'Client Configuration (Claude Desktop, Cline, etc.)'}); const desktopDesc = info.createEl('p', { @@ -629,7 +702,7 @@ class MCPSettingTab extends PluginSettingTab { "obsidian": { "transport": { "type": "http", - "url": `http://localhost:${this.plugin.settings.httpPort}/mcp` + "url": `http://obsidian:${this.plugin.settings.apiKey}@localhost:${this.plugin.settings.httpPort}/mcp` } } } @@ -654,7 +727,7 @@ class MCPSettingTab extends PluginSettingTab { "command": "npx", "args": [ "mcp-remote", - `http://localhost:${this.plugin.settings.httpPort}/mcp` + `http://obsidian:${this.plugin.settings.apiKey}@localhost:${this.plugin.settings.httpPort}/mcp` ] } } diff --git a/src/mcp-server.ts b/src/mcp-server.ts index 2d36be0..55340dd 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -254,6 +254,40 @@ export class MCPHttpServer { // JSON body parser this.app.use(express.json()); + + // Authentication middleware - check API key + this.app.use((req, res, next) => { + // Skip auth for OPTIONS requests (CORS preflight) + if (req.method === 'OPTIONS') { + return next(); + } + + const apiKey = this.plugin?.settings?.apiKey; + if (!apiKey) { + // No API key configured, allow access (backward compatibility) + return next(); + } + + // Check Authorization header for Basic Auth + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Basic ')) { + res.status(401).json({ error: 'Authentication required' }); + return; + } + + // Decode Basic Auth credentials + const base64Credentials = authHeader.slice(6); + const credentials = Buffer.from(base64Credentials, 'base64').toString('utf8'); + const [username, password] = credentials.split(':'); + + // Username can be anything (we use 'obsidian'), password must match API key + if (password !== apiKey) { + res.status(401).json({ error: 'Invalid API key' }); + return; + } + + next(); + }); // Request logging for debugging this.app.use((req, res, next) => { diff --git a/styles.css b/styles.css index 70c6881..1fbd3d8 100644 --- a/styles.css +++ b/styles.css @@ -134,4 +134,20 @@ .mcp-config-path ul { font-size: 12px; +} + +/* API Key Input */ +.mcp-api-key-input { + user-select: text !important; + -webkit-user-select: text !important; + -moz-user-select: text !important; + -ms-user-select: text !important; + cursor: text !important; +} + +/* Make disabled input more readable */ +.mcp-api-key-input:disabled { + background-color: var(--background-secondary); + color: var(--text-normal); + opacity: 1; } \ No newline at end of file