import { Database } from "bun:sqlite"; import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; const db = new Database(process.env.DATABASE_PATH || "./canvas-mcp.db"); // In-memory cache for verified API keys interface ApiKeyCacheEntry { userId: number; verifiedAt: number; } const apiKeyCache = new Map(); const CACHE_TTL = parseInt(process.env.API_KEY_CACHE_TTL || "900000", 10); // 15 minutes default // Cache cleanup interval (runs every 5 minutes) setInterval( () => { const now = Date.now(); for (const [key, entry] of apiKeyCache.entries()) { if (now - entry.verifiedAt > CACHE_TTL) { apiKeyCache.delete(key); } } }, 5 * 60 * 1000, ); // Initialize database schema db.exec(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, canvas_user_id TEXT, canvas_domain TEXT, email TEXT, canvas_access_token TEXT, canvas_refresh_token TEXT, mcp_api_key TEXT UNIQUE, created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), last_used_at INTEGER, token_expires_at INTEGER ); CREATE TABLE IF NOT EXISTS usage_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, endpoint TEXT NOT NULL, timestamp INTEGER NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ); CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, user_id INTEGER, canvas_domain TEXT NOT NULL, state TEXT, api_key TEXT, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS magic_links ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL, token TEXT UNIQUE NOT NULL, expires_at INTEGER NOT NULL, used INTEGER DEFAULT 0, created_at INTEGER DEFAULT (unixepoch() * 1000) ); CREATE TABLE IF NOT EXISTS auth_codes ( code TEXT PRIMARY KEY, user_id INTEGER NOT NULL, client_id TEXT NOT NULL, redirect_uri TEXT NOT NULL, code_challenge TEXT NOT NULL, code_challenge_method TEXT NOT NULL, scope TEXT NOT NULL, expires_at INTEGER NOT NULL, created_at INTEGER DEFAULT (unixepoch() * 1000), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS oauth_tokens ( token TEXT PRIMARY KEY, user_id INTEGER NOT NULL, scope TEXT NOT NULL, expires_at INTEGER NOT NULL, created_at INTEGER DEFAULT (unixepoch() * 1000), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_users_api_key ON users(mcp_api_key); CREATE INDEX IF NOT EXISTS idx_users_canvas_id ON users(canvas_user_id); CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); CREATE INDEX IF NOT EXISTS idx_usage_logs_user_id ON usage_logs(user_id); CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at); CREATE INDEX IF NOT EXISTS idx_magic_links_token ON magic_links(token); CREATE INDEX IF NOT EXISTS idx_magic_links_email ON magic_links(email); CREATE INDEX IF NOT EXISTS idx_auth_codes_code ON auth_codes(code); CREATE INDEX IF NOT EXISTS idx_oauth_tokens_token ON oauth_tokens(token); `); // Encryption utilities const ENCRYPTION_KEY = Buffer.from(process.env.ENCRYPTION_KEY || "", "base64"); const ALGORITHM = "aes-256-gcm"; function encrypt(text: string): string { const iv = randomBytes(16); const cipher = createCipheriv(ALGORITHM, ENCRYPTION_KEY, iv); let encrypted = cipher.update(text, "utf8", "hex"); encrypted += cipher.final("hex"); const authTag = cipher.getAuthTag(); // Return: iv:authTag:encrypted return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`; } function decrypt(encryptedData: string): string { const [ivHex, authTagHex, encrypted] = encryptedData.split(":"); const iv = Buffer.from(ivHex, "hex"); const authTag = Buffer.from(authTagHex, "hex"); const decipher = createDecipheriv(ALGORITHM, ENCRYPTION_KEY, iv); decipher.setAuthTag(authTag); let decrypted = decipher.update(encrypted, "hex", "utf8"); decrypted += decipher.final("utf8"); return decrypted; } // Generate secure API key function generateApiKey(): string { return `cmcp_${randomBytes(32).toString("base64url")}`; } // Hash API key for storage async function hashApiKey(apiKey: string): Promise { return await Bun.password.hash(apiKey, { algorithm: "argon2id", memoryCost: 19456, timeCost: 2, }); } // Verify API key async function verifyApiKey(apiKey: string, hash: string): Promise { return await Bun.password.verify(apiKey, hash); } export interface User { id: number; canvas_user_id: string; canvas_domain: string; email?: string; canvas_access_token: string; canvas_refresh_token?: string; mcp_api_key: string; created_at: number; last_used_at?: number; token_expires_at?: number; } export const DB = { // Raw database access raw: db, // Create or update user after OAuth async createOrUpdateUser(data: { canvas_user_id: string; canvas_domain: string; email?: string; canvas_access_token: string; canvas_refresh_token?: string; token_expires_at?: number; }): Promise<{ user: User; apiKey: string | null; isNewUser: boolean }> { const encryptedToken = encrypt(data.canvas_access_token); const encryptedRefreshToken = data.canvas_refresh_token ? encrypt(data.canvas_refresh_token) : null; // Check if user exists by canvas_user_id or email let existing = db .query("SELECT * FROM users WHERE canvas_user_id = ?") .get(data.canvas_user_id) as User | null; // If not found by canvas_user_id, check by email (for magic link users) if (!existing && data.email) { existing = db .query("SELECT * FROM users WHERE email = ? AND canvas_user_id IS NULL") .get(data.email) as User | null; } if (existing) { // Check if user needs an API key (magic link users) let apiKey: string | null = null; let hashedApiKey = existing.mcp_api_key; if (!hashedApiKey) { // Generate API key for magic link users connecting Canvas for first time apiKey = generateApiKey(); hashedApiKey = await hashApiKey(apiKey); } // Update existing user (might not have canvas_user_id if from magic link) db.run( `UPDATE users SET canvas_user_id = ?, canvas_domain = ?, canvas_access_token = ?, canvas_refresh_token = ?, token_expires_at = ?, last_used_at = ?, mcp_api_key = ? WHERE id = ?`, [ data.canvas_user_id, data.canvas_domain, encryptedToken, encryptedRefreshToken, data.token_expires_at, Date.now(), hashedApiKey, existing.id, ], ); const user = db .query("SELECT * FROM users WHERE id = ?") .get(existing.id) as User; // Return API key only if we just generated it (for magic link users) const isNewUser = apiKey !== null; return { user, apiKey, isNewUser }; } else { // Create new user with API key const apiKey = generateApiKey(); const hashedApiKey = await hashApiKey(apiKey); const result = db.run( `INSERT INTO users ( canvas_user_id, canvas_domain, email, canvas_access_token, canvas_refresh_token, mcp_api_key, created_at, token_expires_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [ data.canvas_user_id, data.canvas_domain, data.email, encryptedToken, encryptedRefreshToken, hashedApiKey, Date.now(), data.token_expires_at, ], ); const user = db .query("SELECT * FROM users WHERE id = ?") .get(result.lastInsertRowid) as User; return { user, apiKey, isNewUser: true }; } }, // Get user by API key (with caching for performance) async getUserByApiKey(apiKey: string): Promise { // Check cache first for O(1) lookup const cached = apiKeyCache.get(apiKey); if (cached && Date.now() - cached.verifiedAt < CACHE_TTL) { // Cache hit - fast path const user = db .query("SELECT * FROM users WHERE id = ?") .get(cached.userId) as User | null; if (user) { return user; } // User was deleted - invalidate cache entry apiKeyCache.delete(apiKey); } // Cache miss - perform full verification (slow path) const users = db .query("SELECT * FROM users WHERE mcp_api_key IS NOT NULL") .all() as User[]; for (const user of users) { if (await verifyApiKey(apiKey, user.mcp_api_key)) { // Cache the verified key for future requests apiKeyCache.set(apiKey, { userId: user.id, verifiedAt: Date.now(), }); return user; } } return null; }, // Get user by Canvas user ID getUserByCanvasId(canvas_user_id: string): User | null { return db .query("SELECT * FROM users WHERE canvas_user_id = ?") .get(canvas_user_id) as User | null; }, // Get decrypted Canvas token for user getCanvasToken(user: User): string { return decrypt(user.canvas_access_token); }, // Get decrypted refresh token getRefreshToken(user: User): string | null { return user.canvas_refresh_token ? decrypt(user.canvas_refresh_token) : null; }, // Log API usage logUsage(userId: number, endpoint: string) { db.run( "INSERT INTO usage_logs (user_id, endpoint, timestamp) VALUES (?, ?, ?)", [userId, endpoint, Date.now()], ); }, // Get usage stats for user getUsageStats(userId: number, since?: number) { const query = since ? "SELECT * FROM usage_logs WHERE user_id = ? AND timestamp >= ?" : "SELECT * FROM usage_logs WHERE user_id = ?"; const params = since ? [userId, since] : [userId]; return db.query(query).all(...params); }, // Update last used timestamp updateLastUsed(userId: number) { db.run("UPDATE users SET last_used_at = ? WHERE id = ?", [ Date.now(), userId, ]); }, // Regenerate API key async regenerateApiKey(userId: number): Promise { // Invalidate all cached entries for this user for (const [key, entry] of apiKeyCache.entries()) { if (entry.userId === userId) { apiKeyCache.delete(key); } } const newApiKey = generateApiKey(); const hashedApiKey = await hashApiKey(newApiKey); db.run("UPDATE users SET mcp_api_key = ? WHERE id = ?", [ hashedApiKey, userId, ]); return newApiKey; }, // Session management createSession( sessionId: string, data: { user_id?: number; canvas_domain: string; state: string; api_key?: string; maxAge: number; // in seconds }, ) { const now = Date.now(); db.run( `INSERT INTO sessions (id, user_id, canvas_domain, state, api_key, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, [ sessionId, data.user_id || null, data.canvas_domain, data.state, data.api_key || null, now, now + data.maxAge * 1000, ], ); }, getSession(sessionId: string) { return db .query("SELECT * FROM sessions WHERE id = ? AND expires_at > ?") .get(sessionId, Date.now()) as any; }, updateSession( sessionId: string, data: Partial<{ user_id: number; api_key: string }>, ) { const updates: string[] = []; const values: any[] = []; if (data.user_id !== undefined) { updates.push("user_id = ?"); values.push(data.user_id); } if (data.api_key !== undefined) { updates.push("api_key = ?"); values.push(data.api_key); } if (updates.length > 0) { values.push(sessionId); db.run(`UPDATE sessions SET ${updates.join(", ")} WHERE id = ?`, values); } }, deleteSession(sessionId: string) { db.run("DELETE FROM sessions WHERE id = ?", [sessionId]); }, clearApiKeyFromSession(sessionId: string) { db.run("UPDATE sessions SET api_key = NULL WHERE id = ?", [sessionId]); }, // Get session by API key (for MCP authentication) getSessionByToken(token: string) { return db .query("SELECT * FROM sessions WHERE api_key = ? AND expires_at > ?") .get(token, Date.now()) as any; }, // Magic link authentication createMagicLink(email: string, token: string, expiresAt: number) { return db.run( "INSERT INTO magic_links (email, token, expires_at) VALUES (?, ?, ?)", [email, token, expiresAt], ); }, getMagicLink(token: string) { return db .query( "SELECT * FROM magic_links WHERE token = ? AND expires_at > ? AND used = 0", ) .get(token, Date.now()) as any; }, markMagicLinkUsed(token: string) { return db.run("UPDATE magic_links SET used = 1 WHERE token = ?", [token]); }, getUserByEmail(email: string) { return db.query("SELECT * FROM users WHERE email = ?").get(email) as any; }, // Update user with Canvas credentials (for magic link users) async updateUserCanvas( userId: number, canvasUserId: string, canvasDomain: string, canvasToken: string, ): Promise<{ apiKey: string | null }> { const existing = db .query("SELECT * FROM users WHERE id = ?") .get(userId) as User | null; if (!existing) { throw new Error("User not found"); } const encryptedToken = encrypt(canvasToken); // Generate API key if user doesn't have one let apiKey: string | null = null; let hashedApiKey = existing.mcp_api_key; if (!hashedApiKey) { apiKey = generateApiKey(); hashedApiKey = await hashApiKey(apiKey); } // Update user with Canvas credentials db.run( `UPDATE users SET canvas_user_id = ?, canvas_domain = ?, canvas_access_token = ?, mcp_api_key = ?, last_used_at = ? WHERE id = ?`, [ canvasUserId, canvasDomain, encryptedToken, hashedApiKey, Date.now(), userId, ], ); return { apiKey }; }, // Rate limiting for magic links canSendMagicLink(email: string, cooldownMs: number = 60000): boolean { // Check if a magic link was sent recently (within cooldown period) const recent = db .query( "SELECT * FROM magic_links WHERE email = ? AND created_at > ? ORDER BY created_at DESC LIMIT 1", ) .get(email, Date.now() - cooldownMs) as any; return !recent; }, getLastMagicLinkTime(email: string): number | null { const recent = db .query( "SELECT created_at FROM magic_links WHERE email = ? ORDER BY created_at DESC LIMIT 1", ) .get(email) as any; return recent ? recent.created_at : null; }, // OAuth tokens createOAuthToken( userId: number, scope: string, expiresIn: number = 86400000, ): string { const token = generateApiKey(); // Reuse the API key generator const expiresAt = Date.now() + expiresIn; db.run( "INSERT INTO oauth_tokens (token, user_id, scope, expires_at) VALUES (?, ?, ?, ?)", [token, userId, scope, expiresAt], ); return token; }, getUserByOAuthToken(token: string): User | null { const tokenData = db .query("SELECT * FROM oauth_tokens WHERE token = ? AND expires_at > ?") .get(token, Date.now()) as any; if (!tokenData) { return null; } return db .query("SELECT * FROM users WHERE id = ?") .get(tokenData.user_id) as User | null; }, // Cache management utilities clearApiKeyCache() { apiKeyCache.clear(); }, invalidateUserCache(userId: number) { for (const [key, entry] of apiKeyCache.entries()) { if (entry.userId === userId) { apiKeyCache.delete(key); } } }, getApiKeyCacheStats() { return { size: apiKeyCache.size, ttl: CACHE_TTL, }; }, // Background cleanup operations (run these periodically, not on request path) cleanupExpiredSessions(): number { const result = db.run("DELETE FROM sessions WHERE expires_at < ?", [ Date.now(), ]); return result.changes; }, cleanupExpiredMagicLinks(): number { const result = db.run("DELETE FROM magic_links WHERE expires_at < ?", [ Date.now(), ]); return result.changes; }, cleanupExpiredAuthCodes(): number { const result = db.run("DELETE FROM auth_codes WHERE expires_at < ?", [ Date.now(), ]); return result.changes; }, cleanupExpiredOAuthTokens(): number { const result = db.run("DELETE FROM oauth_tokens WHERE expires_at < ?", [ Date.now(), ]); return result.changes; }, // Clean up old usage logs (keep last 90 days) cleanupOldUsageLogs(retentionDays: number = 90): number { const cutoffTime = Date.now() - retentionDays * 24 * 60 * 60 * 1000; const result = db.run("DELETE FROM usage_logs WHERE timestamp < ?", [ cutoffTime, ]); return result.changes; }, // Run all cleanup operations runAllCleanups(): { [key: string]: number } { const results = { sessions: this.cleanupExpiredSessions(), magicLinks: this.cleanupExpiredMagicLinks(), authCodes: this.cleanupExpiredAuthCodes(), oauthTokens: this.cleanupExpiredOAuthTokens(), usageLogs: this.cleanupOldUsageLogs(), }; return results; }, }; export default DB;