@ardennguyen/zalo-agent-cli 2.2.2 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/package.json +1 -1
- package/src/commands/conv.js +8 -1
- package/src/commands/listen.js +144 -42
- package/src/commands/login.js +16 -3
- package/src/commands/mcp.js +335 -77
- package/src/commands/profile.js +6 -2
- package/src/commands/sync.js +3 -1
- package/src/core/accounts.js +20 -0
- package/src/core/daemon-channel.js +419 -2
- package/src/core/daemon-sync.js +13 -2
- package/src/core/db.js +60 -3
- package/src/core/live-store.js +58 -1
- package/src/core/sync-v2/index.js +27 -3
- package/src/index.js +3 -0
- package/src/mcp/mcp-config.js +110 -8
- package/src/mcp/mcp-http-transport.js +529 -14
- package/src/mcp/mcp-tools.js +234 -3
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Express server wrapping MCP StreamableHTTP transport with bearer token auth.
|
|
2
|
+
* Express server wrapping MCP StreamableHTTP transport with bearer token and OAuth 2.0 auth.
|
|
3
3
|
* Uses stateless mode — each POST /mcp request creates a fresh server+transport.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import express from "express";
|
|
7
|
-
import
|
|
7
|
+
import https from "https";
|
|
8
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "crypto";
|
|
8
9
|
import { readFileSync } from "fs";
|
|
9
10
|
import { fileURLToPath } from "url";
|
|
10
11
|
import { dirname, join } from "path";
|
|
@@ -14,34 +15,525 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
|
|
|
14
15
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
16
|
const pkg = JSON.parse(readFileSync(join(__dirname, "../../package.json"), "utf8"));
|
|
16
17
|
|
|
18
|
+
const ACCESS_TOKEN_TTL_SEC = 30 * 24 * 3600; // 30 days
|
|
19
|
+
const REFRESH_TOKEN_TTL_SEC = 365 * 24 * 3600; // 365 days
|
|
20
|
+
const AUTH_CODE_TTL_SEC = 600; // 10 minutes
|
|
21
|
+
const DEFAULT_SCOPES = ["mcp:tools", "read", "write", "ACCESS_VIEW_MANAGE_MCP_CONTENT"];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Constant-time string comparison.
|
|
25
|
+
* @param {string} a
|
|
26
|
+
* @param {string} b
|
|
27
|
+
* @returns {boolean}
|
|
28
|
+
*/
|
|
29
|
+
function safeEqual(a, b) {
|
|
30
|
+
if (typeof a !== "string" || typeof b !== "string") return false;
|
|
31
|
+
const bufA = Buffer.from(a, "utf8");
|
|
32
|
+
const bufB = Buffer.from(b, "utf8");
|
|
33
|
+
return bufA.length === bufB.length && timingSafeEqual(bufA, bufB);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Sign a compact JSON payload with HMAC-SHA256 (`<prefix><payloadB64Url>.<sigB64Url>`).
|
|
38
|
+
* @param {string} prefix
|
|
39
|
+
* @param {object} payload
|
|
40
|
+
* @param {string} secret
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
function signOAuthToken(prefix, payload, secret) {
|
|
44
|
+
const body = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
|
45
|
+
const sig = createHmac("sha256", secret).update(`${prefix}${body}`).digest("base64url");
|
|
46
|
+
return `${prefix}${body}.${sig}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Verify a compact HMAC-SHA256 token and check its `exp` claim (epoch seconds).
|
|
51
|
+
* @param {string} token
|
|
52
|
+
* @param {string} expectedPrefix
|
|
53
|
+
* @param {string} secret
|
|
54
|
+
* @returns {object|null}
|
|
55
|
+
*/
|
|
56
|
+
function verifyOAuthToken(token, expectedPrefix, secret) {
|
|
57
|
+
if (typeof token !== "string" || !token.startsWith(expectedPrefix)) return null;
|
|
58
|
+
const rest = token.slice(expectedPrefix.length);
|
|
59
|
+
const dot = rest.indexOf(".");
|
|
60
|
+
if (dot <= 0) return null;
|
|
61
|
+
const body = rest.slice(0, dot);
|
|
62
|
+
const sig = rest.slice(dot + 1);
|
|
63
|
+
const expectedSig = createHmac("sha256", secret).update(`${expectedPrefix}${body}`).digest("base64url");
|
|
64
|
+
if (!safeEqual(sig, expectedSig)) return null;
|
|
65
|
+
try {
|
|
66
|
+
const parsed = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
|
|
67
|
+
const now = Math.floor(Date.now() / 1000);
|
|
68
|
+
if (typeof parsed.exp !== "number" || parsed.exp < now) return null;
|
|
69
|
+
return parsed;
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Extract OAuth client credentials from `Authorization: Basic ...` (`client_secret_basic`)
|
|
77
|
+
* or request body (`client_secret_post`).
|
|
78
|
+
* @param {import("express").Request} req
|
|
79
|
+
* @returns {{ clientId: string, clientSecret: string }}
|
|
80
|
+
*/
|
|
81
|
+
function extractClientCredentials(req) {
|
|
82
|
+
const authHeader = req.headers.authorization || "";
|
|
83
|
+
if (/^basic\s+/i.test(authHeader)) {
|
|
84
|
+
try {
|
|
85
|
+
const decoded = Buffer.from(authHeader.slice(6).trim(), "base64").toString("utf8");
|
|
86
|
+
const colon = decoded.indexOf(":");
|
|
87
|
+
if (colon >= 0) {
|
|
88
|
+
const rawId = decoded.slice(0, colon);
|
|
89
|
+
const rawSecret = decoded.slice(colon + 1);
|
|
90
|
+
try {
|
|
91
|
+
return {
|
|
92
|
+
clientId: decodeURIComponent(rawId),
|
|
93
|
+
clientSecret: decodeURIComponent(rawSecret),
|
|
94
|
+
};
|
|
95
|
+
} catch {
|
|
96
|
+
return { clientId: rawId, clientSecret: rawSecret };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
/* fall through to body */
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
clientId: typeof req.body?.client_id === "string" ? req.body.client_id : "",
|
|
105
|
+
clientSecret: typeof req.body?.client_secret === "string" ? req.body.client_secret : "",
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
17
109
|
/**
|
|
18
|
-
*
|
|
110
|
+
* Verify PKCE `code_verifier` against stored `code_challenge` and `code_challenge_method`.
|
|
111
|
+
* @param {string} verifier
|
|
112
|
+
* @param {string} challenge
|
|
113
|
+
* @param {string} [method="S256"]
|
|
114
|
+
* @returns {boolean}
|
|
115
|
+
*/
|
|
116
|
+
function verifyPkce(verifier, challenge, method = "S256") {
|
|
117
|
+
if (typeof verifier !== "string" || !verifier) return false;
|
|
118
|
+
if (method.toUpperCase() === "PLAIN") {
|
|
119
|
+
return safeEqual(verifier, challenge);
|
|
120
|
+
}
|
|
121
|
+
const computed = createHash("sha256").update(verifier, "ascii").digest("base64url");
|
|
122
|
+
return safeEqual(computed, challenge);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Resolve the external origin (`https://host:port`) for OAuth metadata & `WWW-Authenticate` headers.
|
|
127
|
+
* @param {import("express").Request} req
|
|
128
|
+
* @param {string|null} configuredIssuer
|
|
129
|
+
* @param {boolean} isTls
|
|
130
|
+
* @returns {string}
|
|
131
|
+
*/
|
|
132
|
+
function resolveIssuerOrigin(req, configuredIssuer, isTls) {
|
|
133
|
+
if (configuredIssuer) return configuredIssuer.replace(/\/+$/, "");
|
|
134
|
+
const protoHeader = req.headers["x-forwarded-proto"];
|
|
135
|
+
const proto =
|
|
136
|
+
(typeof protoHeader === "string" && protoHeader.split(",")[0].trim()) ||
|
|
137
|
+
(isTls || req.secure ? "https" : req.protocol || "http");
|
|
138
|
+
const hostHeader = req.headers["x-forwarded-host"] || req.headers.host || "127.0.0.1";
|
|
139
|
+
const host = typeof hostHeader === "string" ? hostHeader.split(",")[0].trim() : "127.0.0.1";
|
|
140
|
+
return `${proto}://${host}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Create HTTP or HTTPS MCP server with optional bearer token and OAuth 2.0 auth.
|
|
19
145
|
* @param {Function} registerToolsFn - Registers tools on a McpServer instance
|
|
20
146
|
* @param {object} deps - { api, buffer, filter, config, nameCache, accountDir, stageLock, syncJobs }
|
|
21
147
|
* @param {number} port
|
|
22
|
-
* @param {string|null} authToken - Bearer token (null = no auth)
|
|
23
|
-
* @
|
|
148
|
+
* @param {string|null} authToken - Bearer token (null = no auth unless oauth.clientSecret or oauth.clients is set)
|
|
149
|
+
* @param {string} [host="127.0.0.1"] - Bind address
|
|
150
|
+
* @param {{cert: Buffer|string, key: Buffer|string}|null} [tls=null] - TLS cert and private key for HTTPS
|
|
151
|
+
* @param {{clientId?: string|null, clientSecret?: string|null, issuerUrl?: string|null, clients?: Array<{clientId: string, clientSecret: string}>}|null} [oauth=null]
|
|
152
|
+
* @returns {import("http").Server | import("https").Server}
|
|
24
153
|
*/
|
|
25
|
-
export function createHTTPServer(registerToolsFn, deps, port, authToken, host = "127.0.0.1") {
|
|
154
|
+
export function createHTTPServer(registerToolsFn, deps, port, authToken, host = "127.0.0.1", tls = null, oauth = null) {
|
|
26
155
|
const app = express();
|
|
156
|
+
const isTls = Boolean(tls?.cert && tls?.key);
|
|
157
|
+
const configuredClientId = oauth?.clientId || "zalo-mcp";
|
|
158
|
+
const configuredIssuer = oauth?.issuerUrl || null;
|
|
159
|
+
|
|
160
|
+
// Static OAuth clients map: clientId -> clientSecret (supports primary + oauth.clients[] for multi-consumer CRM/AI)
|
|
161
|
+
const configuredClients = new Map();
|
|
162
|
+
if (oauth?.clientSecret || authToken) {
|
|
163
|
+
configuredClients.set(configuredClientId, oauth?.clientSecret || authToken);
|
|
164
|
+
}
|
|
165
|
+
if (Array.isArray(oauth?.clients)) {
|
|
166
|
+
for (const entry of oauth.clients) {
|
|
167
|
+
const cid = typeof entry?.clientId === "string" ? entry.clientId.trim() : "";
|
|
168
|
+
const csec = typeof entry?.clientSecret === "string" ? entry.clientSecret.trim() : "";
|
|
169
|
+
if (cid && csec) {
|
|
170
|
+
configuredClients.set(cid, csec);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const signingSecret =
|
|
175
|
+
oauth?.clientSecret ||
|
|
176
|
+
authToken ||
|
|
177
|
+
(configuredClients.size > 0 ? configuredClients.values().next().value : null);
|
|
178
|
+
|
|
179
|
+
// Single-use authorization code replay cache (jti -> expiry epoch seconds)
|
|
180
|
+
const redeemedCodes = new Map();
|
|
181
|
+
// Bounded dynamic client registrations (RFC 7591)
|
|
182
|
+
const dynamicClients = new Map();
|
|
183
|
+
|
|
184
|
+
// CORS headers for browser-based MCP clients and OAuth discovery
|
|
185
|
+
app.use((req, res, next) => {
|
|
186
|
+
const remoteIp = req.socket?.remoteAddress || "";
|
|
187
|
+
const isLoopbackWait =
|
|
188
|
+
req.path === "/events/wait" &&
|
|
189
|
+
(remoteIp === "127.0.0.1" || remoteIp === "::1" || remoteIp === "::ffff:127.0.0.1");
|
|
190
|
+
if (!isLoopbackWait) {
|
|
191
|
+
res.on("finish", () => {
|
|
192
|
+
const ua = String(req.headers["user-agent"] || "-").slice(0, 80);
|
|
193
|
+
const h = String(req.headers.host || "-");
|
|
194
|
+
console.error(
|
|
195
|
+
`[mcp-http] ${req.method} ${req.originalUrl} -> ${res.statusCode} (host=${h} ip=${remoteIp} ua="${ua}")`,
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
200
|
+
res.setHeader(
|
|
201
|
+
"Access-Control-Allow-Headers",
|
|
202
|
+
"Authorization, Content-Type, Accept, Mcp-Session-Id, MCP-Protocol-Version",
|
|
203
|
+
);
|
|
204
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
205
|
+
res.setHeader("Access-Control-Expose-Headers", "WWW-Authenticate, Mcp-Session-Id");
|
|
206
|
+
if (req.method === "OPTIONS") {
|
|
207
|
+
return res.status(204).end();
|
|
208
|
+
}
|
|
209
|
+
next();
|
|
210
|
+
});
|
|
211
|
+
|
|
27
212
|
app.use(express.json());
|
|
213
|
+
app.use(express.urlencoded({ extended: false }));
|
|
214
|
+
|
|
215
|
+
// ── OAuth 2.0 / 2.1 Discovery & Authorization Endpoints ────────────
|
|
216
|
+
if (signingSecret) {
|
|
217
|
+
// RFC 9728 Protected Resource Metadata
|
|
218
|
+
const handleProtectedResourceMetadata = (req, res) => {
|
|
219
|
+
const origin = resolveIssuerOrigin(req, configuredIssuer, isTls);
|
|
220
|
+
res.json({
|
|
221
|
+
resource: `${origin}/mcp`,
|
|
222
|
+
authorization_servers: [origin],
|
|
223
|
+
bearer_methods_supported: ["header"],
|
|
224
|
+
scopes_supported: DEFAULT_SCOPES,
|
|
225
|
+
resource_name: "Zalo Agent MCP",
|
|
226
|
+
});
|
|
227
|
+
};
|
|
228
|
+
app.get("/.well-known/oauth-protected-resource", handleProtectedResourceMetadata);
|
|
229
|
+
app.get("/.well-known/oauth-protected-resource/mcp", handleProtectedResourceMetadata);
|
|
230
|
+
|
|
231
|
+
// RFC 8414 OAuth Authorization Server Metadata (+ OIDC discovery alias)
|
|
232
|
+
const handleAuthServerMetadata = (req, res) => {
|
|
233
|
+
const origin = resolveIssuerOrigin(req, configuredIssuer, isTls);
|
|
234
|
+
res.json({
|
|
235
|
+
issuer: origin,
|
|
236
|
+
authorization_endpoint: `${origin}/authorize`,
|
|
237
|
+
token_endpoint: `${origin}/token`,
|
|
238
|
+
registration_endpoint: `${origin}/register`,
|
|
239
|
+
revocation_endpoint: `${origin}/revoke`,
|
|
240
|
+
response_types_supported: ["code"],
|
|
241
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
242
|
+
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic", "none"],
|
|
243
|
+
code_challenge_methods_supported: ["S256"],
|
|
244
|
+
scopes_supported: DEFAULT_SCOPES,
|
|
245
|
+
resource_indicators_supported: true,
|
|
246
|
+
});
|
|
247
|
+
};
|
|
248
|
+
app.get("/.well-known/oauth-authorization-server", handleAuthServerMetadata);
|
|
249
|
+
app.get("/.well-known/openid-configuration", handleAuthServerMetadata);
|
|
28
250
|
|
|
29
|
-
|
|
30
|
-
|
|
251
|
+
// RFC 7591 Dynamic Client Registration
|
|
252
|
+
app.post("/register", (req, res) => {
|
|
253
|
+
res.setHeader("Cache-Control", "no-store");
|
|
254
|
+
const redirectUris = Array.isArray(req.body?.redirect_uris) ? req.body.redirect_uris : [];
|
|
255
|
+
const authMethod = req.body?.token_endpoint_auth_method || "client_secret_post";
|
|
256
|
+
const issuedAt = Math.floor(Date.now() / 1000);
|
|
257
|
+
const dynId = `dyn_${randomBytes(12).toString("hex")}`;
|
|
258
|
+
const dynSecret = authMethod === "none" ? undefined : signingSecret;
|
|
259
|
+
if (dynamicClients.size >= 200) {
|
|
260
|
+
const oldest = dynamicClients.keys().next().value;
|
|
261
|
+
if (oldest) dynamicClients.delete(oldest);
|
|
262
|
+
}
|
|
263
|
+
dynamicClients.set(dynId, {
|
|
264
|
+
clientId: dynId,
|
|
265
|
+
authMethod,
|
|
266
|
+
redirectUris,
|
|
267
|
+
});
|
|
268
|
+
res.status(201).json({
|
|
269
|
+
client_id: dynId,
|
|
270
|
+
...(dynSecret ? { client_secret: dynSecret, client_secret_expires_at: 0 } : {}),
|
|
271
|
+
client_id_issued_at: issuedAt,
|
|
272
|
+
redirect_uris: redirectUris,
|
|
273
|
+
token_endpoint_auth_method: authMethod,
|
|
274
|
+
grant_types: req.body?.grant_types || ["authorization_code", "refresh_token"],
|
|
275
|
+
response_types: req.body?.response_types || ["code"],
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// OAuth 2.0 Authorization Endpoint (GET & POST)
|
|
280
|
+
const handleAuthorize = (req, res) => {
|
|
281
|
+
res.setHeader("Cache-Control", "no-store");
|
|
282
|
+
const params = req.method === "POST" ? { ...req.query, ...req.body } : req.query;
|
|
283
|
+
const responseType = String(params.response_type || "code");
|
|
284
|
+
const clientId = String(params.client_id || "").trim();
|
|
285
|
+
const redirectUri = String(params.redirect_uri || "").trim();
|
|
286
|
+
const state = params.state !== undefined ? String(params.state) : undefined;
|
|
287
|
+
const scope = typeof params.scope === "string" && params.scope.trim() ? params.scope.trim() : "mcp:tools";
|
|
288
|
+
const codeChallenge =
|
|
289
|
+
typeof params.code_challenge === "string" && params.code_challenge.trim()
|
|
290
|
+
? params.code_challenge.trim()
|
|
291
|
+
: null;
|
|
292
|
+
const codeChallengeMethod =
|
|
293
|
+
typeof params.code_challenge_method === "string" && params.code_challenge_method.trim()
|
|
294
|
+
? params.code_challenge_method.trim()
|
|
295
|
+
: "S256";
|
|
296
|
+
|
|
297
|
+
if (!redirectUri || !URL.canParse(redirectUri)) {
|
|
298
|
+
return res.status(400).json({
|
|
299
|
+
error: "invalid_request",
|
|
300
|
+
error_description: "A valid redirect_uri is required",
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
if (!clientId || (!configuredClients.has(clientId) && !dynamicClients.has(clientId))) {
|
|
304
|
+
return res.status(400).json({
|
|
305
|
+
error: "invalid_client",
|
|
306
|
+
error_description: "Unknown client_id",
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
if (responseType !== "code") {
|
|
310
|
+
const errUrl = new URL(redirectUri);
|
|
311
|
+
errUrl.searchParams.set("error", "unsupported_response_type");
|
|
312
|
+
if (state !== undefined) errUrl.searchParams.set("state", state);
|
|
313
|
+
return res.redirect(302, errUrl.href);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const now = Math.floor(Date.now() / 1000);
|
|
317
|
+
const code = signOAuthToken(
|
|
318
|
+
"zmcp_code_",
|
|
319
|
+
{
|
|
320
|
+
typ: "code",
|
|
321
|
+
cid: clientId,
|
|
322
|
+
ruri: redirectUri,
|
|
323
|
+
cc: codeChallenge,
|
|
324
|
+
ccm: codeChallengeMethod,
|
|
325
|
+
scp: scope,
|
|
326
|
+
exp: now + AUTH_CODE_TTL_SEC,
|
|
327
|
+
jti: randomBytes(12).toString("hex"),
|
|
328
|
+
},
|
|
329
|
+
signingSecret,
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
const target = new URL(redirectUri);
|
|
333
|
+
target.searchParams.set("code", code);
|
|
334
|
+
if (state !== undefined) target.searchParams.set("state", state);
|
|
335
|
+
return res.redirect(302, target.href);
|
|
336
|
+
};
|
|
337
|
+
app.get("/authorize", handleAuthorize);
|
|
338
|
+
app.post("/authorize", handleAuthorize);
|
|
339
|
+
|
|
340
|
+
// OAuth 2.0 Token Endpoint
|
|
341
|
+
app.post("/token", (req, res) => {
|
|
342
|
+
res.setHeader("Cache-Control", "no-store");
|
|
343
|
+
const grantType = String(req.body?.grant_type || "").trim();
|
|
344
|
+
const { clientId, clientSecret } = extractClientCredentials(req);
|
|
345
|
+
|
|
346
|
+
const issueTokens = (cid, scope = "mcp:tools") => {
|
|
347
|
+
const now = Math.floor(Date.now() / 1000);
|
|
348
|
+
const accessToken = signOAuthToken(
|
|
349
|
+
"zmcp_at_",
|
|
350
|
+
{
|
|
351
|
+
typ: "at",
|
|
352
|
+
cid,
|
|
353
|
+
scp: scope,
|
|
354
|
+
exp: now + ACCESS_TOKEN_TTL_SEC,
|
|
355
|
+
jti: randomBytes(8).toString("hex"),
|
|
356
|
+
},
|
|
357
|
+
signingSecret,
|
|
358
|
+
);
|
|
359
|
+
const refreshToken = signOAuthToken(
|
|
360
|
+
"zmcp_rt_",
|
|
361
|
+
{
|
|
362
|
+
typ: "rt",
|
|
363
|
+
cid,
|
|
364
|
+
scp: scope,
|
|
365
|
+
exp: now + REFRESH_TOKEN_TTL_SEC,
|
|
366
|
+
jti: randomBytes(8).toString("hex"),
|
|
367
|
+
},
|
|
368
|
+
signingSecret,
|
|
369
|
+
);
|
|
370
|
+
return {
|
|
371
|
+
access_token: accessToken,
|
|
372
|
+
token_type: "Bearer",
|
|
373
|
+
expires_in: ACCESS_TOKEN_TTL_SEC,
|
|
374
|
+
refresh_token: refreshToken,
|
|
375
|
+
scope,
|
|
376
|
+
};
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const isValidSecretFor = (cid) => {
|
|
380
|
+
if (!clientSecret) return false;
|
|
381
|
+
if (cid && configuredClients.has(cid)) {
|
|
382
|
+
const expected = configuredClients.get(cid);
|
|
383
|
+
return (
|
|
384
|
+
safeEqual(clientSecret, expected) || Boolean(authToken && safeEqual(clientSecret, authToken))
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
return (
|
|
388
|
+
safeEqual(clientSecret, signingSecret) || Boolean(authToken && safeEqual(clientSecret, authToken))
|
|
389
|
+
);
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
if (grantType === "authorization_code") {
|
|
393
|
+
const rawCode = String(req.body?.code || "").trim();
|
|
394
|
+
const codePayload = verifyOAuthToken(rawCode, "zmcp_code_", signingSecret);
|
|
395
|
+
if (!codePayload || codePayload.typ !== "code") {
|
|
396
|
+
return res.status(400).json({
|
|
397
|
+
error: "invalid_grant",
|
|
398
|
+
error_description: "Invalid or expired authorization code",
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Prune expired redeemed codes and enforce single use
|
|
403
|
+
const now = Math.floor(Date.now() / 1000);
|
|
404
|
+
for (const [k, exp] of redeemedCodes) {
|
|
405
|
+
if (exp < now) redeemedCodes.delete(k);
|
|
406
|
+
}
|
|
407
|
+
if (redeemedCodes.has(codePayload.jti)) {
|
|
408
|
+
return res.status(400).json({
|
|
409
|
+
error: "invalid_grant",
|
|
410
|
+
error_description: "Authorization code has already been used",
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const effectiveClientId = clientId || codePayload.cid;
|
|
415
|
+
if (effectiveClientId !== codePayload.cid) {
|
|
416
|
+
return res.status(400).json({
|
|
417
|
+
error: "invalid_grant",
|
|
418
|
+
error_description: "client_id does not match authorization code",
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const dynClient = dynamicClients.get(effectiveClientId);
|
|
423
|
+
const isPublicDynClient = dynClient?.authMethod === "none" && Boolean(codePayload.cc);
|
|
424
|
+
if (!isValidSecretFor(effectiveClientId) && !isPublicDynClient) {
|
|
425
|
+
return res.status(401).json({
|
|
426
|
+
error: "invalid_client",
|
|
427
|
+
error_description: "Invalid client_secret",
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (req.body?.redirect_uri && req.body.redirect_uri !== codePayload.ruri) {
|
|
432
|
+
return res.status(400).json({
|
|
433
|
+
error: "invalid_grant",
|
|
434
|
+
error_description: "redirect_uri mismatch",
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (codePayload.cc) {
|
|
439
|
+
const verifier = String(req.body?.code_verifier || "");
|
|
440
|
+
if (!verifyPkce(verifier, codePayload.cc, codePayload.ccm)) {
|
|
441
|
+
return res.status(400).json({
|
|
442
|
+
error: "invalid_grant",
|
|
443
|
+
error_description: "PKCE code_verifier verification failed",
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
redeemedCodes.set(codePayload.jti, codePayload.exp);
|
|
449
|
+
return res.status(200).json(issueTokens(effectiveClientId, codePayload.scp));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (grantType === "refresh_token") {
|
|
453
|
+
const rawRefresh = String(req.body?.refresh_token || "").trim();
|
|
454
|
+
const rtPayload = verifyOAuthToken(rawRefresh, "zmcp_rt_", signingSecret);
|
|
455
|
+
if (!rtPayload || rtPayload.typ !== "rt") {
|
|
456
|
+
return res.status(400).json({
|
|
457
|
+
error: "invalid_grant",
|
|
458
|
+
error_description: "Invalid or expired refresh_token",
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
const effectiveClientId = clientId || rtPayload.cid;
|
|
462
|
+
const dynClient = dynamicClients.get(effectiveClientId);
|
|
463
|
+
if (!isValidSecretFor(effectiveClientId) && dynClient?.authMethod !== "none") {
|
|
464
|
+
return res.status(401).json({
|
|
465
|
+
error: "invalid_client",
|
|
466
|
+
error_description: "Invalid client_secret",
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
return res.status(200).json(issueTokens(rtPayload.cid, rtPayload.scp));
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (grantType === "client_credentials") {
|
|
473
|
+
if (clientId && !configuredClients.has(clientId) && !dynamicClients.has(clientId)) {
|
|
474
|
+
return res.status(401).json({
|
|
475
|
+
error: "invalid_client",
|
|
476
|
+
error_description: "Invalid client_id",
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
const targetClientId = clientId || configuredClientId;
|
|
480
|
+
if (!isValidSecretFor(targetClientId)) {
|
|
481
|
+
return res.status(401).json({
|
|
482
|
+
error: "invalid_client",
|
|
483
|
+
error_description: "Invalid client_secret",
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
const scope =
|
|
487
|
+
typeof req.body?.scope === "string" && req.body.scope.trim() ? req.body.scope.trim() : "mcp:tools";
|
|
488
|
+
return res.status(200).json(issueTokens(targetClientId, scope));
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return res.status(400).json({
|
|
492
|
+
error: "unsupported_grant_type",
|
|
493
|
+
error_description: "Supported grant_types: authorization_code, refresh_token, client_credentials",
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// RFC 7009 Token Revocation Endpoint
|
|
498
|
+
app.post("/revoke", (_req, res) => {
|
|
499
|
+
res.status(200).json({});
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ── Bearer / OAuth2 Auth Middleware — skips /health ─────────────────
|
|
504
|
+
if (signingSecret) {
|
|
31
505
|
app.use((req, res, next) => {
|
|
32
506
|
if (req.path === "/health") return next();
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
507
|
+
const authHeader = req.headers.authorization || "";
|
|
508
|
+
const token = /^bearer\s+/i.test(authHeader) ? authHeader.slice(7).trim() : "";
|
|
509
|
+
|
|
510
|
+
let matchesStaticToken =
|
|
511
|
+
Boolean(token) && ((authToken && safeEqual(token, authToken)) || safeEqual(token, signingSecret));
|
|
512
|
+
if (!matchesStaticToken && token) {
|
|
513
|
+
for (const sec of configuredClients.values()) {
|
|
514
|
+
if (safeEqual(token, sec)) {
|
|
515
|
+
matchesStaticToken = true;
|
|
516
|
+
break;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
const oauthClaims =
|
|
521
|
+
!matchesStaticToken && token ? verifyOAuthToken(token, "zmcp_at_", signingSecret) : null;
|
|
522
|
+
|
|
523
|
+
if (!matchesStaticToken && !oauthClaims) {
|
|
524
|
+
const origin = resolveIssuerOrigin(req, configuredIssuer, isTls);
|
|
525
|
+
const wwwAuth = token
|
|
526
|
+
? `Bearer error="invalid_token", error_description="Unauthorized", resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp"`
|
|
527
|
+
: `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp"`;
|
|
528
|
+
res.setHeader("WWW-Authenticate", wwwAuth);
|
|
37
529
|
return res.status(401).json({ error: "Unauthorized" });
|
|
38
530
|
}
|
|
39
531
|
next();
|
|
40
532
|
});
|
|
41
533
|
}
|
|
42
534
|
|
|
43
|
-
// MCP endpoint — stateless, fresh server+transport per request
|
|
44
|
-
app.
|
|
535
|
+
// MCP endpoint — stateless, fresh server+transport per request (POST, GET, DELETE per Streamable HTTP spec)
|
|
536
|
+
app.all("/mcp", async (req, res) => {
|
|
45
537
|
try {
|
|
46
538
|
const server = new McpServer({ name: "zalo-agent", version: pkg.version });
|
|
47
539
|
registerToolsFn(
|
|
@@ -128,6 +620,29 @@ export function createHTTPServer(registerToolsFn, deps, port, authToken, host =
|
|
|
128
620
|
});
|
|
129
621
|
});
|
|
130
622
|
|
|
623
|
+
if (isTls) {
|
|
624
|
+
const primary = https.createServer({ cert: tls.cert, key: tls.key }, app).listen(port, host, () => {
|
|
625
|
+
console.error(`MCP HTTPS server listening on ${host}:${port}`);
|
|
626
|
+
});
|
|
627
|
+
if (port !== 0 && port !== 443) {
|
|
628
|
+
const tls443 = https.createServer({ cert: tls.cert, key: tls.key }, app);
|
|
629
|
+
tls443.on("error", () => {
|
|
630
|
+
/* port 443 already in use or restricted — ignore */
|
|
631
|
+
});
|
|
632
|
+
tls443.listen(443, host, () => {
|
|
633
|
+
console.error(`MCP HTTPS server also listening on ${host}:443`);
|
|
634
|
+
});
|
|
635
|
+
primary.on("close", () => {
|
|
636
|
+
try {
|
|
637
|
+
tls443.close();
|
|
638
|
+
} catch {
|
|
639
|
+
/* ignore */
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
return primary;
|
|
644
|
+
}
|
|
645
|
+
|
|
131
646
|
return app.listen(port, host, () => {
|
|
132
647
|
console.error(`MCP HTTP server listening on ${host}:${port}`);
|
|
133
648
|
});
|