import type { NextFunction, Request, Response } from "express"; import { config } from "../config.js"; import { logger } from "../logger.js"; export type AgentAuthContext = { accessToken: string; userId: string; keycloakSub: string; username: string; role: string; isSuperuser: boolean; projectId: string; network: string; projectRole: string; tokenExpiresAt?: string; }; type AgentAuthContextResponse = { user_id?: unknown; keycloak_sub?: unknown; username?: unknown; role?: unknown; is_superuser?: unknown; project_id?: unknown; network?: unknown; project_role?: unknown; token_expires_at?: unknown; }; declare global { namespace Express { interface Request { agentAuth?: AgentAuthContext; } } } export const extractBearerToken = (authorization?: string) => { const value = authorization?.trim(); if (!value) { return ""; } return value.replace(/^Bearer\s+/i, "").trim(); }; const normalizeAgentAuthContext = ( payload: AgentAuthContextResponse, accessToken: string, ): AgentAuthContext | null => { if ( typeof payload.user_id !== "string" || typeof payload.keycloak_sub !== "string" || typeof payload.username !== "string" || typeof payload.role !== "string" || typeof payload.is_superuser !== "boolean" || typeof payload.project_id !== "string" || typeof payload.network !== "string" || typeof payload.project_role !== "string" ) { return null; } return { accessToken, userId: payload.user_id, keycloakSub: payload.keycloak_sub, username: payload.username, role: payload.role, isSuperuser: payload.is_superuser, projectId: payload.project_id, network: payload.network, projectRole: payload.project_role, tokenExpiresAt: typeof payload.token_expires_at === "string" ? payload.token_expires_at : undefined, }; }; // Agent API 使用 TJWater 后端专用认证上下文:后端负责校验 Keycloak token、 // metadata 用户状态和项目 membership,Agent 只信任后端返回的 user/project。 export const requireAgentAuth = async ( req: Request, res: Response, next: NextFunction, ) => { const token = extractBearerToken(req.header("authorization")); if (!token) { res.status(401).json({ message: "authorization token is required" }); return; } const projectId = req.header("x-project-id")?.trim(); if (!projectId) { res.status(400).json({ message: "x-project-id is required" }); return; } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), config.AGENT_AUTH_TIMEOUT_MS); try { const response = await fetch(new URL("/api/v1/agent/auth/context", config.TJWATER_API_BASE_URL), { method: "GET", headers: { Accept: "application/json", Authorization: `Bearer ${token}`, "X-Project-Id": projectId, ...(req.header("x-trace-id") ? { "X-Trace-Id": req.header("x-trace-id") as string } : {}), }, signal: controller.signal, }); if (response.ok) { const context = normalizeAgentAuthContext( (await response.json()) as AgentAuthContextResponse, token, ); if (!context) { res.status(502).json({ message: "invalid authentication context" }); return; } req.agentAuth = context; next(); return; } const detail = await response.text(); res.status(response.status === 403 ? 403 : 401).json({ message: response.status === 403 ? "forbidden" : "unauthorized", detail: detail || undefined, }); } catch (error) { const detail = error instanceof Error ? error.message : String(error); logger.warn({ err: error }, "agent auth validation failed"); res.status(503).json({ message: "authentication service unavailable", detail, }); } finally { clearTimeout(timer); } }; export const getAgentAuthContext = (req: Request) => { if (!req.agentAuth) { throw new Error("agent auth context is missing"); } return req.agentAuth; };