diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 5a57002..5a44334 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,6 +1,7 @@ import type { Handle } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit'; import { validateSession, isSetupComplete } from '$lib/server/auth.js'; +import { validateApiKey, getUserForApiKey } from '$lib/server/api-keys.js'; const PUBLIC_PATHS = [ '/login', @@ -21,6 +22,38 @@ export const handle: Handle = async ({ event, resolve }) => { return resolve(event); } + // MCP endpoint: authenticate via Bearer token (API key) + if (pathname.startsWith('/api/mcp')) { + const authHeader = event.request.headers.get('authorization'); + const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null; + + if (!token) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } + + const { valid, userId } = validateApiKey(token); + if (!valid || !userId) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } + + const user = getUserForApiKey(userId); + if (!user) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } + + event.locals.user = user; + return resolve(event); + } + // Check if setup is complete const setupDone = await isSetupComplete(); if (!setupDone) { diff --git a/src/lib/server/api-keys.test.ts b/src/lib/server/api-keys.test.ts new file mode 100644 index 0000000..aa32000 --- /dev/null +++ b/src/lib/server/api-keys.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createTestDb } from './db/test-helpers.js'; +import { generateApiKey, validateApiKey, listApiKeys, deleteApiKey } from './api-keys.js'; +import { users } from './db/schema.js'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import type * as schema from './db/schema.js'; + +describe('API Keys', () => { + let db: BetterSQLite3Database; + let userId: number; + + beforeEach(() => { + ({ db } = createTestDb()); + // Create a test user + db.insert(users) + .values({ email: 'test@example.com', displayName: 'Test', createdAt: new Date() }) + .run(); + userId = 1; + }); + + describe('generateApiKey', () => { + it('returns key with crumbs_ prefix', () => { + const result = generateApiKey('Test Key', userId, db); + expect(result.key).toMatch(/^crumbs_[a-f0-9]{64}$/); + expect(result.name).toBe('Test Key'); + expect(result.id).toBeTruthy(); + expect(result.keyPrefix).toBe(result.key.slice(0, 12)); + }); + + it('generates unique keys each time', () => { + const key1 = generateApiKey('Key 1', userId, db); + const key2 = generateApiKey('Key 2', userId, db); + expect(key1.key).not.toBe(key2.key); + expect(key1.id).not.toBe(key2.id); + }); + }); + + describe('validateApiKey', () => { + it('returns valid with userId for a valid key', () => { + const { key } = generateApiKey('Test Key', userId, db); + const result = validateApiKey(key, db); + expect(result.valid).toBe(true); + expect(result.userId).toBe(userId); + }); + + it('returns invalid for an invalid key', () => { + const result = validateApiKey('crumbs_invalid', db); + expect(result.valid).toBe(false); + expect(result.userId).toBeNull(); + }); + + it('returns invalid for empty string', () => { + expect(validateApiKey('', db).valid).toBe(false); + }); + + it('returns invalid for key without prefix', () => { + expect(validateApiKey('no_prefix_here', db).valid).toBe(false); + }); + + it('updates lastUsedAt on validation', () => { + const { key, id } = generateApiKey('Test Key', userId, db); + const before = listApiKeys(userId, db).find((k) => k.id === id); + expect(before?.lastUsedAt).toBeNull(); + + validateApiKey(key, db); + + const after = listApiKeys(userId, db).find((k) => k.id === id); + expect(after?.lastUsedAt).toBeInstanceOf(Date); + }); + }); + + describe('listApiKeys', () => { + it('returns empty array when no keys exist', () => { + expect(listApiKeys(userId, db)).toEqual([]); + }); + + it('returns keys without hash', () => { + generateApiKey('Key 1', userId, db); + generateApiKey('Key 2', userId, db); + + const keys = listApiKeys(userId, db); + expect(keys).toHaveLength(2); + expect(keys[0]).toHaveProperty('name'); + expect(keys[0]).toHaveProperty('keyPrefix'); + expect(keys[0]).toHaveProperty('createdAt'); + expect(keys[0]).not.toHaveProperty('keyHash'); + }); + + it('only returns keys for the specified user', () => { + db.insert(users) + .values({ email: 'other@example.com', displayName: 'Other', createdAt: new Date() }) + .run(); + const otherUserId = 2; + + generateApiKey('My Key', userId, db); + generateApiKey('Other Key', otherUserId, db); + + expect(listApiKeys(userId, db)).toHaveLength(1); + expect(listApiKeys(otherUserId, db)).toHaveLength(1); + }); + }); + + describe('deleteApiKey', () => { + it('deletes an existing key and returns true', () => { + const { id, key } = generateApiKey('Test Key', userId, db); + expect(deleteApiKey(id, userId, db)).toBe(true); + expect(validateApiKey(key, db).valid).toBe(false); + expect(listApiKeys(userId, db)).toHaveLength(0); + }); + + it('returns false for non-existent key', () => { + expect(deleteApiKey('nonexistent', userId, db)).toBe(false); + }); + + it('cannot delete another user\'s key', () => { + db.insert(users) + .values({ email: 'other@example.com', displayName: 'Other', createdAt: new Date() }) + .run(); + const otherUserId = 2; + + const { id } = generateApiKey('Other Key', otherUserId, db); + expect(deleteApiKey(id, userId, db)).toBe(false); + }); + }); +}); diff --git a/src/lib/server/api-keys.ts b/src/lib/server/api-keys.ts new file mode 100644 index 0000000..69cb2df --- /dev/null +++ b/src/lib/server/api-keys.ts @@ -0,0 +1,105 @@ +import { db } from './db/index.js'; +import { apiKeys, users } from './db/schema.js'; +import { eq } from 'drizzle-orm'; +import { randomBytes, createHash } from 'crypto'; +import { v4 as uuidv4 } from 'uuid'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import type * as schema from './db/schema.js'; + +type Db = BetterSQLite3Database; + +const PREFIX = 'crumbs_'; + +function hashKey(key: string): string { + return createHash('sha256').update(key).digest('hex'); +} + +export function generateApiKey( + name: string, + userId: number, + database: Db = db +): { id: string; key: string; name: string; keyPrefix: string } { + const id = uuidv4(); + const raw = randomBytes(32).toString('hex'); + const key = `${PREFIX}${raw}`; + const keyHash = hashKey(key); + const keyPrefix = key.slice(0, 12); + + database + .insert(apiKeys) + .values({ + id, + userId, + name, + keyHash, + keyPrefix, + createdAt: new Date() + }) + .run(); + + return { id, key, name, keyPrefix }; +} + +export function validateApiKey( + key: string, + database: Db = db +): { valid: boolean; userId: number | null } { + if (!key || !key.startsWith(PREFIX)) return { valid: false, userId: null }; + + const keyHash = hashKey(key); + const row = database.select().from(apiKeys).where(eq(apiKeys.keyHash, keyHash)).get(); + if (!row) return { valid: false, userId: null }; + + database + .update(apiKeys) + .set({ lastUsedAt: new Date() }) + .where(eq(apiKeys.id, row.id)) + .run(); + + return { valid: true, userId: row.userId }; +} + +export function getUserForApiKey( + userId: number, + database: Db = db +): App.Locals['user'] | null { + const user = database + .select({ + id: users.id, + email: users.email, + displayName: users.displayName, + role: users.role + }) + .from(users) + .where(eq(users.id, userId)) + .get(); + + return user ? (user as App.Locals['user']) : null; +} + +export function listApiKeys(userId: number, database: Db = db) { + return database + .select({ + id: apiKeys.id, + name: apiKeys.name, + keyPrefix: apiKeys.keyPrefix, + createdAt: apiKeys.createdAt, + lastUsedAt: apiKeys.lastUsedAt + }) + .from(apiKeys) + .where(eq(apiKeys.userId, userId)) + .all(); +} + +export function deleteApiKey(id: string, userId: number, database: Db = db): boolean { + const existing = database + .select() + .from(apiKeys) + .where(eq(apiKeys.id, id)) + .get(); + + if (!existing || existing.userId !== userId) return false; + + const result = database.delete(apiKeys).where(eq(apiKeys.id, id)).run(); + return result.changes > 0; +}