MCP um dauerhafte tenantgebundene Tokens erweitern
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 23s
Build and Push Docker Images / build-frontend (push) Successful in 1m4s
Build and Push Docker Images / build-docs (push) Successful in 15s

This commit is contained in:
2026-05-12 17:39:20 +02:00
parent 252021acee
commit 4e49dd18a1
2 changed files with 241 additions and 11 deletions

View File

@@ -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) {

View File

@@ -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) {
})
})
}