Files
FEDEO/backend/src/plugins/auth.ts
florianfederspiel 4e49dd18a1
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
MCP um dauerhafte tenantgebundene Tokens erweitern
2026-05-12 17:39:20 +02:00

211 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
const authHeader = req.headers.authorization
const headerToken =
authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null
const token =
headerToken && headerToken.length > 10
? headerToken
: cookieToken || null
if (!token) {
return reply.code(401).send({ error: "Authentication required" })
}
try {
// 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) {
return reply.code(401).send({ error: "Invalid token" })
}
// Payload an Request hängen
req.user = payload
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)
}
// Multi-Tenant Modus ohne ausgewählten Tenant → keine Rollenprüfung
if (!req.user.tenant_id) {
return
}
const tenantId = req.user.tenant_id
const userId = req.user.user_id
// --------------------------------------------------------
// 3⃣ Rollen des Nutzers im Tenant holen
// --------------------------------------------------------
const roleRows = await server.db
.select({
role_id: authUserRoles.role_id,
})
.from(authUserRoles)
.where(
and(
eq(authUserRoles.user_id, userId),
eq(authUserRoles.tenant_id, tenantId)
)
)
if (roleRows.length === 0) {
if (req.user.is_admin) {
req.role = ""
req.permissions = []
req.hasPermission = () => false
return
}
return reply
.code(403)
.send({ error: "No role assigned for this tenant" })
}
const roleIds = Array.from(new Set(roleRows.map((role) => role.role_id)))
// --------------------------------------------------------
// 4⃣ Berechtigungen der Rollen laden
// --------------------------------------------------------
const permissionRows = await server.db
.select()
.from(authRolePermissions)
.where(inArray(authRolePermissions.role_id, roleIds))
const permissions = Array.from(new Set(permissionRows.map((p) => p.permission)))
// --------------------------------------------------------
// 5⃣ An Request hängen für spätere Nutzung
// --------------------------------------------------------
req.role = roleIds[0]
req.permissions = permissions
req.hasPermission = (perm: string) => permissions.includes(perm)
} catch (err) {
console.error("JWT verification error:", err)
return reply.code(401).send({ error: "Invalid or expired token" })
}
})
})
// ---------------------------------------------------------------------------
// Fastify TypeScript Erweiterungen
// ---------------------------------------------------------------------------
declare module "fastify" {
interface FastifyRequest {
user: {
user_id: string
email: string
tenant_id: number | null
is_admin?: boolean
}
role: string
permissions: string[]
hasPermission: (permission: string) => boolean
}
}