151 lines
4.0 KiB
TypeScript
151 lines
4.0 KiB
TypeScript
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;
|
||
};
|