diff --git a/backend/src/plugins/auth.ts b/backend/src/plugins/auth.ts index 8f05bcb..8a69e1b 100644 --- a/backend/src/plugins/auth.ts +++ b/backend/src/plugins/auth.ts @@ -2,16 +2,67 @@ import { FastifyInstance } from "fastify" import fp from "fastify-plugin" import jwt from "jsonwebtoken" import { secrets } from "../utils/secrets" +import { createHash } from "node:crypto" import { authUserRoles, authRolePermissions, authUsers, + m2mApiKeys, } from "../../db/schema" import { eq, and, inArray } from "drizzle-orm" export default fp(async (server: FastifyInstance) => { + const hashApiKey = (apiKey: string) => + createHash("sha256").update(apiKey, "utf8").digest("hex") + + const isMcpRoute = (url: string) => + url === "/mcp" || + url.startsWith("/mcp/") || + url === "/api/mcp" || + url.startsWith("/api/mcp/") + + const authenticateMcpApiKey = async (apiKey: string) => { + if (!apiKey.startsWith("fedeo_mcp_")) return false + + const keyHash = hashApiKey(apiKey) + + const rows = await server.db + .select({ + id: m2mApiKeys.id, + tenantId: m2mApiKeys.tenantId, + userId: m2mApiKeys.userId, + active: m2mApiKeys.active, + expiresAt: m2mApiKeys.expiresAt, + userEmail: authUsers.email, + isAdmin: authUsers.is_admin, + }) + .from(m2mApiKeys) + .innerJoin(authUsers, eq(authUsers.id, m2mApiKeys.userId)) + .where(and( + eq(m2mApiKeys.keyHash, keyHash), + eq(m2mApiKeys.active, true) + )) + .limit(1) + + const key = rows[0] + if (!key) return false + if (key.expiresAt && new Date(key.expiresAt).getTime() < Date.now()) return false + + await server.db + .update(m2mApiKeys) + .set({ lastUsedAt: new Date(), updatedAt: new Date() }) + .where(eq(m2mApiKeys.id, key.id)) + + return { + user_id: key.userId, + email: key.userEmail, + tenant_id: key.tenantId, + is_admin: Boolean(key.isAdmin), + } + } + server.addHook("preHandler", async (req, reply) => { // 1️⃣ Token aus Header oder Cookie lesen const cookieToken = req.cookies?.token @@ -30,11 +81,31 @@ export default fp(async (server: FastifyInstance) => { } try { - // 2️⃣ JWT verifizieren - const payload = jwt.verify(token, secrets.JWT_SECRET!) as { + // 2️⃣ JWT verifizieren oder für MCP dauerhaften Token akzeptieren + let payload: { user_id: string email: string tenant_id: number | null + is_admin?: boolean + } + + try { + payload = jwt.verify(token, secrets.JWT_SECRET!) as { + user_id: string + email: string + tenant_id: number | null + } + } catch (jwtError) { + const mcpPayload = isMcpRoute(req.url) + ? await authenticateMcpApiKey(token) + : false + + if (!mcpPayload) { + throw jwtError + } + + payload = mcpPayload + ;(req as any).mcpTokenAuth = true } if (!payload?.user_id) { @@ -44,15 +115,19 @@ export default fp(async (server: FastifyInstance) => { // Payload an Request hängen req.user = payload - const [currentUser] = await server.db - .select({ - is_admin: authUsers.is_admin, - }) - .from(authUsers) - .where(eq(authUsers.id, payload.user_id)) - .limit(1) + if (typeof payload.is_admin === "boolean") { + req.user.is_admin = payload.is_admin + } else { + const [currentUser] = await server.db + .select({ + is_admin: authUsers.is_admin, + }) + .from(authUsers) + .where(eq(authUsers.id, payload.user_id)) + .limit(1) - req.user.is_admin = Boolean(currentUser?.is_admin) + req.user.is_admin = Boolean(currentUser?.is_admin) + } // Multi-Tenant Modus ohne ausgewählten Tenant → keine Rollenprüfung if (!req.user.tenant_id) { diff --git a/backend/src/routes/mcp.ts b/backend/src/routes/mcp.ts index 94f06e1..cbda01e 100644 --- a/backend/src/routes/mcp.ts +++ b/backend/src/routes/mcp.ts @@ -1,4 +1,7 @@ import { FastifyInstance } from "fastify" +import { and, desc, eq } from "drizzle-orm" +import { createHash, randomBytes } from "node:crypto" +import { m2mApiKeys } from "../../db/schema" import { assertToolPermission, createMcpContext } from "../mcp/authz" import { mcpToolMap, mcpTools } from "../mcp/registry" import { asToolError, asToolResult } from "../mcp/result" @@ -38,7 +41,160 @@ function selectProtocolVersion(clientVersion?: string) { return SUPPORTED_PROTOCOL_VERSIONS[0] } +const hashApiKey = (apiKey: string) => + createHash("sha256").update(apiKey, "utf8").digest("hex") + +const createMcpToken = () => `fedeo_mcp_${randomBytes(32).toString("base64url")}` + +const requireMcpTokenManagementPermission = (req: any) => { + if (req.mcpTokenAuth) { + throw Object.assign(new Error("MCP Tokens können nur mit einer Benutzersitzung verwaltet werden"), { statusCode: 403 }) + } + + if (req.user?.is_admin) return + if (typeof req.hasPermission === "function" && req.hasPermission("mcp.tokens.write")) return + + throw Object.assign(new Error("Fehlende Berechtigung: mcp.tokens.write"), { statusCode: 403 }) +} + export default async function mcpRoutes(server: FastifyInstance) { + server.get("/mcp/tokens", async (req, reply) => { + try { + requireMcpTokenManagementPermission(req) + + if (!req.user?.tenant_id) { + return reply.code(400).send({ error: "No tenant selected" }) + } + + const rows = await server.db + .select({ + id: m2mApiKeys.id, + createdAt: m2mApiKeys.createdAt, + updatedAt: m2mApiKeys.updatedAt, + name: m2mApiKeys.name, + keyPrefix: m2mApiKeys.keyPrefix, + active: m2mApiKeys.active, + lastUsedAt: m2mApiKeys.lastUsedAt, + expiresAt: m2mApiKeys.expiresAt, + userId: m2mApiKeys.userId, + createdBy: m2mApiKeys.createdBy, + }) + .from(m2mApiKeys) + .where(and( + eq(m2mApiKeys.tenantId, req.user.tenant_id) + )) + .orderBy(desc(m2mApiKeys.createdAt)) + + return { rows: rows.filter((row) => row.keyPrefix.startsWith("fedeo_mcp_")) } + } catch (error) { + const statusCode = (error as any)?.statusCode || 500 + return reply.code(statusCode).send({ error: error instanceof Error ? error.message : "Internal Server Error" }) + } + }) + + server.post("/mcp/tokens", async (req, reply) => { + try { + requireMcpTokenManagementPermission(req) + + if (!req.user?.tenant_id || !req.user?.user_id) { + return reply.code(400).send({ error: "No tenant selected" }) + } + + const body = req.body as { + name?: string + expiresAt?: string | null + } + + const token = createMcpToken() + const expiresAt = body?.expiresAt ? new Date(body.expiresAt) : null + + if (expiresAt && Number.isNaN(expiresAt.getTime())) { + return reply.code(400).send({ error: "expiresAt must be a valid date" }) + } + + const [created] = await server.db + .insert(m2mApiKeys) + .values({ + tenantId: req.user.tenant_id, + userId: req.user.user_id, + createdBy: req.user.user_id, + name: body?.name?.trim() || "FEDEO MCP Token", + keyPrefix: token.slice(0, 20), + keyHash: hashApiKey(token), + active: true, + expiresAt, + }) + .returning({ + id: m2mApiKeys.id, + createdAt: m2mApiKeys.createdAt, + name: m2mApiKeys.name, + keyPrefix: m2mApiKeys.keyPrefix, + active: m2mApiKeys.active, + expiresAt: m2mApiKeys.expiresAt, + }) + + return { + token, + tokenType: "Bearer", + note: "Token wird nur einmal angezeigt. Bitte direkt in Codex oder einem Secret Store hinterlegen.", + record: created, + } + } catch (error) { + const statusCode = (error as any)?.statusCode || 500 + return reply.code(statusCode).send({ error: error instanceof Error ? error.message : "Internal Server Error" }) + } + }) + + server.delete("/mcp/tokens/:id", async (req, reply) => { + try { + requireMcpTokenManagementPermission(req) + + if (!req.user?.tenant_id || !req.user?.user_id) { + return reply.code(400).send({ error: "No tenant selected" }) + } + + const { id } = req.params as { id: string } + + const [existing] = await server.db + .select({ + id: m2mApiKeys.id, + keyPrefix: m2mApiKeys.keyPrefix, + }) + .from(m2mApiKeys) + .where(and( + eq(m2mApiKeys.id, id), + eq(m2mApiKeys.tenantId, req.user.tenant_id) + )) + .limit(1) + + if (!existing || !existing.keyPrefix.startsWith("fedeo_mcp_")) { + return reply.code(404).send({ error: "MCP token not found" }) + } + + const [updated] = await server.db + .update(m2mApiKeys) + .set({ + active: false, + updatedAt: new Date(), + }) + .where(and( + eq(m2mApiKeys.id, id), + eq(m2mApiKeys.tenantId, req.user.tenant_id) + )) + .returning({ + id: m2mApiKeys.id, + name: m2mApiKeys.name, + keyPrefix: m2mApiKeys.keyPrefix, + active: m2mApiKeys.active, + }) + + return { token: updated } + } catch (error) { + const statusCode = (error as any)?.statusCode || 500 + return reply.code(statusCode).send({ error: error instanceof Error ? error.message : "Internal Server Error" }) + } + }) + server.post("/mcp", async (req, reply) => { const body = req.body as JsonRpcRequest | JsonRpcRequest[] const requests = Array.isArray(body) ? body : [body] @@ -141,4 +297,3 @@ export default async function mcpRoutes(server: FastifyInstance) { }) }) } -