UNPKG

@stackmemoryai/stackmemory

Version:

Project-scoped memory for AI coding tools. Durable context across sessions with MCP integration, frames, smart retrieval, Claude Code skills, and automatic hooks.

529 lines (528 loc) 16.1 kB
import { fileURLToPath as __fileURLToPath } from 'url'; import { dirname as __pathDirname } from 'path'; const __filename = __fileURLToPath(import.meta.url); const __dirname = __pathDirname(__filename); import jwt from "jsonwebtoken"; import jwksRsa from "jwks-rsa"; import { RateLimiterRedis } from "rate-limiter-flexible"; import Redis from "ioredis"; import BetterSqlite3 from "better-sqlite3"; import { logger } from "../../core/monitoring/logger.js"; import { metrics } from "../../core/monitoring/metrics.js"; import { getUserModel } from "../../models/user.model.js"; function getEnv(key, defaultValue) { const value = process.env[key]; if (value === void 0) { if (defaultValue !== void 0) return defaultValue; throw new Error(`Environment variable ${key} is required`); } return value; } function getOptionalEnv(key) { return process.env[key]; } class AuthMiddleware { constructor(config) { this.config = config; this.redis = new Redis(config.redisUrl); const dbPath = config.dbPath || process.env["STACKMEMORY_DB"] || ".stackmemory/auth.db"; this.db = new BetterSqlite3(dbPath); this.userModel = getUserModel(this.db); this.jwksClient = jwksRsa({ jwksUri: `https://${config.auth0Domain}/.well-known/jwks.json`, cache: true, cacheMaxAge: 6e5, // 10 minutes rateLimit: true, jwksRequestsPerMinute: 5 }); this.initializeRateLimiters(); this.setupTokenBlacklistSync(); } jwksClient; redis; rateLimiters; blacklistedTokens = /* @__PURE__ */ new Set(); userModel; db; mockUser; mockUserInitializing = false; initializeRateLimiters() { this.rateLimiters = /* @__PURE__ */ new Map([ [ "free", new RateLimiterRedis({ storeClient: this.redis, keyPrefix: "rl:free", points: 100, // requests duration: 900, // per 15 minutes blockDuration: 900 // block for 15 minutes }) ], [ "pro", new RateLimiterRedis({ storeClient: this.redis, keyPrefix: "rl:pro", points: 1e3, duration: 900, blockDuration: 300 }) ], [ "enterprise", new RateLimiterRedis({ storeClient: this.redis, keyPrefix: "rl:enterprise", points: 1e4, duration: 900, blockDuration: 60 }) ] ]); this.rateLimiters.set( "auth", new RateLimiterRedis({ storeClient: this.redis, keyPrefix: "rl:auth", points: 10, // Only 10 auth attempts duration: 900, blockDuration: 3600 // Block for 1 hour on excessive auth attempts }) ); } setupTokenBlacklistSync() { const subscriber = new Redis(this.config.redisUrl); subscriber.subscribe("token:revoked"); subscriber.on("message", (channel, token) => { if (channel === "token:revoked") { this.blacklistedTokens.add(token); if (this.blacklistedTokens.size > 1e4) { this.blacklistedTokens.clear(); } } }); } async getSigningKey(kid) { return new Promise((resolve, reject) => { this.jwksClient.getSigningKey(kid, (err, key) => { if (err) { reject(err); } else { const signingKey = key?.getPublicKey(); if (!signingKey) { reject(new Error("No signing key found")); } else { resolve(signingKey); } } }); }); } /** * Main authentication middleware */ authenticate = async (req, res, next) => { const startTime = Date.now(); try { if (req.path === "/health" || req.path === "/metrics") { return next(); } if (this.config.bypassAuth && process.env["NODE_ENV"] === "development") { req.user = this.getMockUser(); return next(); } const token = this.extractToken(req); const apiKey = this.extractApiKey(req); if (!token && !apiKey) { metrics.increment("auth.missing_credentials"); return res.status(401).json({ error: "Authentication required", code: "MISSING_CREDENTIALS" }); } if (apiKey) { const user2 = await this.userModel.validateApiKey(apiKey); if (!user2) { metrics.increment("auth.invalid_api_key"); return res.status(401).json({ error: "Invalid API key", code: "INVALID_API_KEY" }); } req.user = { id: user2.id, sub: user2.sub, email: user2.email, name: user2.name, picture: user2.avatar, tier: user2.tier, permissions: user2.permissions, organizations: user2.organizations.map((org) => org.id), metadata: { ...user2.metadata, authMethod: "api_key" } }; metrics.increment("auth.api_key_success"); await metrics.timing("auth.api_key_duration", Date.now() - startTime); return next(); } if (token && this.blacklistedTokens.has(token)) { metrics.increment("auth.blacklisted_token"); return res.status(401).json({ error: "Token has been revoked", code: "TOKEN_REVOKED" }); } if (!token) { return res.status(401).json({ error: "No token provided", code: "NO_TOKEN" }); } const decoded = jwt.decode(token, { complete: true }); if (!decoded) { metrics.increment("auth.invalid_token"); return res.status(401).json({ error: "Invalid token format", code: "INVALID_TOKEN" }); } const signingKey = await this.getSigningKey(decoded.header.kid); const verified = jwt.verify(token, signingKey, { algorithms: ["RS256"], audience: this.config.auth0Audience, issuer: `https://${this.config.auth0Domain}/` }); const user = await this.loadUser(verified.sub, verified); if (!user) { metrics.increment("auth.user_not_found"); return res.status(403).json({ error: "User not found", code: "USER_NOT_FOUND" }); } if (user.metadata?.suspended) { metrics.increment("auth.user_suspended"); return res.status(403).json({ error: "Account suspended", code: "ACCOUNT_SUSPENDED" }); } const rateLimiter = this.rateLimiters.get(user.tier) || this.rateLimiters.get("free"); try { const rateLimitRes = await rateLimiter.consume(user.id); req.rateLimitInfo = rateLimitRes; res.setHeader("X-RateLimit-Limit", rateLimiter.points.toString()); res.setHeader( "X-RateLimit-Remaining", rateLimitRes.remainingPoints.toString() ); res.setHeader( "X-RateLimit-Reset", new Date(Date.now() + rateLimitRes.msBeforeNext).toISOString() ); } catch (rateLimitError) { metrics.increment("auth.rate_limited"); res.setHeader( "Retry-After", Math.round(rateLimitError.msBeforeNext / 1e3).toString() ); return res.status(429).json({ error: "Too many requests", code: "RATE_LIMITED", retryAfter: rateLimitError.msBeforeNext }); } req.user = user; metrics.increment("auth.success", { tier: user.tier }); metrics.timing("auth.duration", Date.now() - startTime); logger.info("Authentication successful", { userId: user.id, tier: user.tier, path: req.path }); next(); } catch (error) { metrics.increment("auth.error"); logger.error("Authentication error", error); if (error.name === "TokenExpiredError") { return res.status(401).json({ error: "Token expired", code: "TOKEN_EXPIRED" }); } if (error.name === "JsonWebTokenError") { return res.status(401).json({ error: "Invalid token", code: "INVALID_TOKEN" }); } res.status(500).json({ error: "Authentication failed", code: "AUTH_ERROR" }); } }; /** * WebSocket authentication handler */ authenticateWebSocket = async (token) => { try { const decoded = jwt.decode(token, { complete: true }); if (!decoded || this.blacklistedTokens.has(token)) { return null; } const signingKey = await this.getSigningKey(decoded.header.kid); const verified = jwt.verify(token, signingKey, { algorithms: ["RS256"], audience: this.config.auth0Audience, issuer: `https://${this.config.auth0Domain}/` }); return await this.loadUser(verified.sub, verified); } catch (error) { logger.error( "WebSocket authentication failed", error instanceof Error ? error : void 0 ); return null; } }; /** * Permission checking middleware */ requirePermission = (permission) => { return (req, res, next) => { if (!req.user) { return res.status(401).json({ error: "Authentication required", code: "NOT_AUTHENTICATED" }); } if (!req.user.permissions.includes(permission)) { metrics.increment("auth.permission_denied", { permission }); return res.status(403).json({ error: "Insufficient permissions", code: "PERMISSION_DENIED", required: permission }); } return next(); }; }; /** * Organization access middleware */ requireOrganization = (req, res, next) => { const orgId = req.params.orgId || req.query.orgId; if (!req.user || !orgId) { return res.status(401).json({ error: "Authentication required", code: "NOT_AUTHENTICATED" }); } if (!req.user.organizations?.includes(orgId)) { return res.status(403).json({ error: "Organization access denied", code: "ORG_ACCESS_DENIED" }); } return next(); }; extractApiKey(req) { const authHeader = req.headers.authorization; if (authHeader?.startsWith("Bearer sk-")) { return authHeader.substring(7); } const apiKeyHeader = req.headers["x-api-key"]; if (apiKeyHeader?.startsWith("sk-")) { return apiKeyHeader; } return null; } extractToken(req) { const authHeader = req.headers.authorization; if (authHeader?.startsWith("Bearer ") && !authHeader.startsWith("Bearer sk-")) { return authHeader.substring(7); } return req.cookies?.access_token || null; } async loadUser(sub, tokenPayload) { const cached = await this.redis.get(`user:${sub}`); if (cached) { const cachedUser = JSON.parse(cached); this.userModel.updateLastLogin(cachedUser.id).catch((err) => logger.error("Failed to update last login", err)); return cachedUser; } let dbUser = await this.userModel.findUserBySub(sub); if (!dbUser && tokenPayload) { dbUser = await this.userModel.createUser({ sub, email: tokenPayload.email || `${sub}@auth.local`, name: tokenPayload.name, avatar: tokenPayload.picture, tier: this.determineTier(tokenPayload), permissions: this.determinePermissions(tokenPayload), organizations: this.extractOrganizations(tokenPayload), metadata: { auth0: tokenPayload, signupSource: "auth0", createdVia: "auth-middleware" } }); logger.info("Auto-created user from auth token", { sub, email: dbUser.email }); } if (!dbUser) { return null; } await this.userModel.updateLastLogin(dbUser.id); const user = { id: dbUser.id, sub: dbUser.sub, email: dbUser.email, name: dbUser.name, picture: dbUser.avatar, tier: dbUser.tier, permissions: dbUser.permissions, organizations: dbUser.organizations.map((org) => org.id), metadata: dbUser.metadata }; await this.redis.setex(`user:${sub}`, 300, JSON.stringify(user)); return user; } determineTier(tokenPayload) { if (tokenPayload["https://stackmemory.ai/tier"]) { return tokenPayload["https://stackmemory.ai/tier"]; } if (tokenPayload.subscription?.plan) { const plan = tokenPayload.subscription.plan.toLowerCase(); if (plan.includes("enterprise")) return "enterprise"; if (plan.includes("pro") || plan.includes("premium")) return "pro"; } return "free"; } determinePermissions(tokenPayload) { const permissions = ["read", "write"]; if (tokenPayload["https://stackmemory.ai/permissions"]) { return tokenPayload["https://stackmemory.ai/permissions"]; } if (tokenPayload.permissions && Array.isArray(tokenPayload.permissions)) { return tokenPayload.permissions; } if (tokenPayload.roles && Array.isArray(tokenPayload.roles)) { if (tokenPayload.roles.includes("admin")) { permissions.push("admin", "delete"); } if (tokenPayload.roles.includes("moderator")) { permissions.push("moderate"); } } return permissions; } extractOrganizations(tokenPayload) { const orgs = []; if (tokenPayload["https://stackmemory.ai/organizations"]) { return tokenPayload["https://stackmemory.ai/organizations"]; } if (tokenPayload.org_id) { orgs.push({ id: tokenPayload.org_id, name: tokenPayload.org_name || tokenPayload.org_id, role: tokenPayload.org_role || "member" }); } return orgs; } async initializeMockUser() { const mockSub = "dev-sub"; let dbUser = await this.userModel.findUserBySub(mockSub); if (!dbUser) { dbUser = await this.userModel.createUser({ sub: mockSub, email: "dev@stackmemory.local", name: "Development User", tier: "enterprise", permissions: ["read", "write", "admin", "delete"], organizations: [ { id: "dev-org", name: "Development Organization", role: "admin" } ], metadata: { isDevelopmentUser: true, createdAt: (/* @__PURE__ */ new Date()).toISOString() } }); logger.info("Created development mock user"); } return { id: dbUser.id, sub: dbUser.sub, email: dbUser.email, name: dbUser.name, picture: dbUser.avatar, tier: dbUser.tier, permissions: dbUser.permissions, organizations: dbUser.organizations.map((org) => org.id), metadata: dbUser.metadata }; } getMockUser() { if (this.mockUser) { return this.mockUser; } if (!this.mockUserInitializing) { this.mockUserInitializing = true; this.initializeMockUser().then((user) => { this.mockUser = user; this.mockUserInitializing = false; logger.info("Mock user initialized and cached"); }).catch((err) => { logger.error("Failed to initialize mock user", err); this.mockUserInitializing = false; }); } return { id: "temp-dev-user-id", sub: "dev-sub", email: "dev@stackmemory.local", name: "Development User", tier: "enterprise", permissions: ["read", "write", "admin", "delete"], organizations: ["dev-org"], metadata: { temporary: true } }; } /** * Revoke a token (add to blacklist) */ async revokeToken(token) { this.blacklistedTokens.add(token); await this.redis.publish("token:revoked", token); const decoded = jwt.decode(token); if (decoded?.exp) { const ttl = decoded.exp - Math.floor(Date.now() / 1e3); if (ttl > 0) { await this.redis.setex(`blacklist:${token}`, ttl, "1"); } } } /** * Cleanup resources */ async close() { await this.redis.quit(); } } export { AuthMiddleware }; //# sourceMappingURL=auth-middleware.js.map