@wukongcrm/mcp-server 0.1.3 → 0.2.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/dist/oauth.js ADDED
@@ -0,0 +1,446 @@
1
+ import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, randomUUID } from "node:crypto";
2
+ import { AccessDeniedError, InvalidGrantError, InvalidScopeError, InvalidTargetError, InvalidTokenError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
3
+ import { CrmClient } from "./client.js";
4
+ import { FileOAuthState, RedisOAuthState } from "./oauth-state.js";
5
+ import { CRM_READ_SCOPE, OAUTH_SCOPES, OFFLINE_ACCESS_SCOPE } from "./server.js";
6
+ export class WukongOAuthProvider {
7
+ clientsStore;
8
+ resourceUrl;
9
+ crmBaseUrl;
10
+ fetchImpl;
11
+ crmBindingAuthorizeUrl;
12
+ crmBindingExchangeUrl;
13
+ crmBindingCallbackUrl;
14
+ crmBindingClientId;
15
+ crmBindingClientSecret;
16
+ encryptionKey;
17
+ accessTokenTtlSeconds;
18
+ refreshTokenTtlSeconds;
19
+ constructor(options) {
20
+ if (options.secret.length < 32) {
21
+ throw new Error("MCP_OAUTH_SECRET 至少需要 32 个字符。");
22
+ }
23
+ this.resourceUrl = normalizeResourceUrl(options.resourceUrl);
24
+ this.crmBaseUrl = options.crmBaseUrl;
25
+ this.fetchImpl = options.fetchImpl;
26
+ this.crmBindingAuthorizeUrl = normalizeBindingEndpoint(options.crmBindingAuthorizeUrl, "CRM_BINDING_AUTHORIZE_URL");
27
+ this.crmBindingExchangeUrl = normalizeBindingEndpoint(options.crmBindingExchangeUrl, "CRM_BINDING_EXCHANGE_URL", { allowHttp: options.crmBindingExchangeAllowHttp });
28
+ this.crmBindingCallbackUrl = normalizeBindingEndpoint(options.crmBindingCallbackUrl, "CRM_BINDING_CALLBACK_URL");
29
+ if (!options.crmBindingClientId.trim()) {
30
+ throw new Error("CRM_BINDING_CLIENT_ID 不能为空。");
31
+ }
32
+ if (options.crmBindingClientSecret.length < 32) {
33
+ throw new Error("CRM_BINDING_CLIENT_SECRET 至少需要 32 个字符。");
34
+ }
35
+ this.crmBindingClientId = options.crmBindingClientId.trim();
36
+ this.crmBindingClientSecret = options.crmBindingClientSecret;
37
+ this.encryptionKey = createHash("sha256").update(options.secret, "utf8").digest();
38
+ this.accessTokenTtlSeconds = options.accessTokenTtlSeconds ?? 60 * 60;
39
+ this.refreshTokenTtlSeconds = options.refreshTokenTtlSeconds ?? 30 * 24 * 60 * 60;
40
+ this.clientsStore = options.stateStore ?? (options.redisUrl
41
+ ? new RedisOAuthState({ url: options.redisUrl, keyPrefix: options.redisKeyPrefix })
42
+ : new FileOAuthState(options.dataDirectory));
43
+ }
44
+ async initialize() {
45
+ await this.clientsStore.initialize();
46
+ }
47
+ async close() {
48
+ await this.clientsStore.close();
49
+ }
50
+ async authorize(client, params, res) {
51
+ const resource = normalizeResourceUrl(params.resource ?? this.resourceUrl);
52
+ this.assertResource(resource);
53
+ const scopes = normalizeScopes(params.scopes);
54
+ const requestId = randomUUID();
55
+ const pending = {
56
+ client,
57
+ params,
58
+ resource,
59
+ scopes,
60
+ expiresAt: Date.now() + 5 * 60 * 1000
61
+ };
62
+ await this.clientsStore.setPendingAuthorization(requestId, this.encryptTransientState("pending", serializePendingAuthorization(pending)), pending.expiresAt);
63
+ const authorizationUrl = new URL(this.crmBindingAuthorizeUrl.href);
64
+ authorizationUrl.searchParams.set("client_id", this.crmBindingClientId);
65
+ authorizationUrl.searchParams.set("redirect_uri", this.crmBindingCallbackUrl.href);
66
+ authorizationUrl.searchParams.set("state", requestId);
67
+ res.setHeader("Cache-Control", "no-store");
68
+ res.setHeader("Pragma", "no-cache");
69
+ res.redirect(302, authorizationUrl.href);
70
+ }
71
+ async completeBindingAuthorization(requestId, bindingCode) {
72
+ const pendingValue = await this.clientsStore.getPendingAuthorization(requestId);
73
+ const pending = pendingValue
74
+ ? deserializePendingAuthorization(this.decryptTransientState("pending", pendingValue))
75
+ : undefined;
76
+ if (!pending) {
77
+ throw new AccessDeniedError("授权请求已失效,请返回 ChatGPT 重新授权。");
78
+ }
79
+ const normalizedCode = bindingCode.trim();
80
+ if (!normalizedCode || normalizedCode.length > 256) {
81
+ throw new AccessDeniedError("72CRM 返回的绑定码无效。");
82
+ }
83
+ const normalizedApiKey = await this.exchangeBindingCode(normalizedCode);
84
+ const crmClient = new CrmClient({
85
+ baseUrl: this.crmBaseUrl,
86
+ apiKey: normalizedApiKey,
87
+ fetchImpl: this.fetchImpl
88
+ });
89
+ const status = await crmClient.authStatus();
90
+ if (!status.ok) {
91
+ throw new AccessDeniedError("72CRM 静默生成的 API Key 验证失败,请重新授权。");
92
+ }
93
+ const code = randomUUID();
94
+ const codeData = {
95
+ ...pending,
96
+ expiresAt: Date.now() + 5 * 60 * 1000,
97
+ apiKey: normalizedApiKey
98
+ };
99
+ await this.clientsStore.setAuthorizationCode(code, this.encryptTransientState("code", serializeAuthorizationCode(codeData)), codeData.expiresAt);
100
+ await this.clientsStore.deletePendingAuthorization(requestId);
101
+ const target = new URL(pending.params.redirectUri);
102
+ target.searchParams.set("code", code);
103
+ if (pending.params.state) {
104
+ target.searchParams.set("state", pending.params.state);
105
+ }
106
+ return target.toString();
107
+ }
108
+ async exchangeBindingCode(bindingCode) {
109
+ const timestamp = Math.floor(Date.now() / 1000).toString();
110
+ const message = `${this.crmBindingClientId}\n${timestamp}\n${bindingCode}`;
111
+ const signature = createHmac("sha256", this.crmBindingClientSecret)
112
+ .update(message, "utf8")
113
+ .digest("base64url");
114
+ let response;
115
+ try {
116
+ response = await (this.fetchImpl ?? fetch)(this.crmBindingExchangeUrl, {
117
+ method: "POST",
118
+ headers: {
119
+ "Content-Type": "application/json",
120
+ "X-MCP-Client-Id": this.crmBindingClientId,
121
+ "X-MCP-Timestamp": timestamp,
122
+ "X-MCP-Signature": signature
123
+ },
124
+ body: JSON.stringify({ code: bindingCode })
125
+ });
126
+ }
127
+ catch (error) {
128
+ throw new Error(`72CRM 绑定交换请求失败(${this.crmBindingExchangeUrl.origin}):${describeFetchError(error)}`, { cause: error });
129
+ }
130
+ if (!response.ok) {
131
+ throw new AccessDeniedError("无法从 72CRM 完成静默凭证交换。");
132
+ }
133
+ const result = (await response.json());
134
+ const apiKey = result.code === 0 ? result.data?.apiKey?.trim() : undefined;
135
+ if (!apiKey || apiKey.length > 4096) {
136
+ throw new AccessDeniedError("72CRM 返回的静默凭证无效。");
137
+ }
138
+ return apiKey;
139
+ }
140
+ async challengeForAuthorizationCode(client, authorizationCode) {
141
+ const codeData = await this.getAuthorizationCode(client, authorizationCode);
142
+ return codeData.params.codeChallenge;
143
+ }
144
+ async exchangeAuthorizationCode(client, authorizationCode, _codeVerifier, redirectUri, resource) {
145
+ const codeData = await this.getAuthorizationCode(client, authorizationCode);
146
+ if (redirectUri && redirectUri !== codeData.params.redirectUri) {
147
+ throw new InvalidGrantError("redirect_uri 与授权请求不一致。");
148
+ }
149
+ if (resource && normalizeResourceUrl(resource).href !== codeData.resource.href) {
150
+ throw new InvalidTargetError("resource 与授权请求不一致。");
151
+ }
152
+ const consumedCodeData = await this.consumeAuthorizationCode(client, authorizationCode);
153
+ return this.issueTokens(consumedCodeData.apiKey, client.client_id, consumedCodeData.scopes, consumedCodeData.resource);
154
+ }
155
+ async exchangeRefreshToken(client, refreshToken, scopes, resource) {
156
+ const payload = await this.decodeAndVerifyToken(refreshToken, "refresh");
157
+ if (payload.clientId !== client.client_id) {
158
+ throw new InvalidGrantError("刷新令牌不属于当前客户端。");
159
+ }
160
+ const requestedResource = normalizeResourceUrl(resource ?? new URL(payload.resource));
161
+ if (requestedResource.href !== payload.resource) {
162
+ throw new InvalidTargetError("resource 与刷新令牌不一致。");
163
+ }
164
+ const requestedScopes = scopes?.length ? normalizeScopes(scopes) : payload.scopes;
165
+ if (requestedScopes.some((scope) => !payload.scopes.includes(scope))) {
166
+ throw new InvalidScopeError("刷新请求不能扩大原授权范围。");
167
+ }
168
+ await this.clientsStore.revoke(payload.jti, payload.expiresAt);
169
+ return this.issueTokens(payload.apiKey, client.client_id, requestedScopes, requestedResource);
170
+ }
171
+ async verifyAccessToken(token) {
172
+ const payload = await this.decodeAndVerifyToken(token, "access");
173
+ this.assertResource(new URL(payload.resource));
174
+ return {
175
+ token,
176
+ clientId: payload.clientId,
177
+ scopes: payload.scopes,
178
+ expiresAt: payload.expiresAt,
179
+ resource: new URL(payload.resource),
180
+ extra: {
181
+ crmApiKey: payload.apiKey
182
+ }
183
+ };
184
+ }
185
+ async revokeToken(client, request) {
186
+ try {
187
+ const payload = this.decryptToken(request.token);
188
+ if (payload.clientId === client.client_id) {
189
+ await this.clientsStore.revoke(payload.jti, payload.expiresAt);
190
+ }
191
+ }
192
+ catch {
193
+ // RFC 7009 requires an invalid token to produce the same successful response.
194
+ }
195
+ }
196
+ async getAuthorizationCode(client, authorizationCode) {
197
+ const value = await this.clientsStore.getAuthorizationCode(authorizationCode);
198
+ const codeData = value
199
+ ? deserializeAuthorizationCode(this.decryptTransientState("code", value))
200
+ : undefined;
201
+ if (!codeData || codeData.client.client_id !== client.client_id) {
202
+ throw new InvalidGrantError("授权码无效或已过期。");
203
+ }
204
+ return codeData;
205
+ }
206
+ async consumeAuthorizationCode(client, authorizationCode) {
207
+ const value = await this.clientsStore.consumeAuthorizationCode(authorizationCode);
208
+ const codeData = value
209
+ ? deserializeAuthorizationCode(this.decryptTransientState("code", value))
210
+ : undefined;
211
+ if (!codeData || codeData.client.client_id !== client.client_id) {
212
+ throw new InvalidGrantError("授权码无效、已过期或已被使用。");
213
+ }
214
+ return codeData;
215
+ }
216
+ issueTokens(apiKey, clientId, scopes, resource) {
217
+ const now = Math.floor(Date.now() / 1000);
218
+ const accessPayload = {
219
+ version: 1,
220
+ type: "access",
221
+ apiKey,
222
+ clientId,
223
+ scopes,
224
+ resource: resource.href,
225
+ issuedAt: now,
226
+ expiresAt: now + this.accessTokenTtlSeconds,
227
+ jti: randomUUID()
228
+ };
229
+ const tokens = {
230
+ access_token: this.encryptToken(accessPayload),
231
+ token_type: "Bearer",
232
+ expires_in: this.accessTokenTtlSeconds,
233
+ scope: scopes.join(" ")
234
+ };
235
+ if (scopes.includes(OFFLINE_ACCESS_SCOPE)) {
236
+ tokens.refresh_token = this.encryptToken({
237
+ ...accessPayload,
238
+ type: "refresh",
239
+ expiresAt: now + this.refreshTokenTtlSeconds,
240
+ jti: randomUUID()
241
+ });
242
+ }
243
+ return tokens;
244
+ }
245
+ async decodeAndVerifyToken(token, expectedType) {
246
+ let payload;
247
+ try {
248
+ payload = this.decryptToken(token);
249
+ }
250
+ catch {
251
+ throw new InvalidTokenError("Invalid token.");
252
+ }
253
+ const now = Math.floor(Date.now() / 1000);
254
+ if (payload.type !== expectedType || payload.expiresAt <= now) {
255
+ throw new InvalidTokenError("Invalid or expired token.");
256
+ }
257
+ if (await this.clientsStore.isRevoked(payload.jti)) {
258
+ throw new InvalidTokenError("Token has been revoked.");
259
+ }
260
+ return payload;
261
+ }
262
+ encryptToken(payload) {
263
+ const iv = randomBytes(12);
264
+ const cipher = createCipheriv("aes-256-gcm", this.encryptionKey, iv);
265
+ const encrypted = Buffer.concat([
266
+ cipher.update(JSON.stringify(payload), "utf8"),
267
+ cipher.final()
268
+ ]);
269
+ const tag = cipher.getAuthTag();
270
+ return ["wkm1", iv.toString("base64url"), encrypted.toString("base64url"), tag.toString("base64url")].join(".");
271
+ }
272
+ decryptToken(token) {
273
+ const payload = this.decryptValue("wkm1", token);
274
+ if (payload.version !== 1 ||
275
+ (payload.type !== "access" && payload.type !== "refresh") ||
276
+ typeof payload.apiKey !== "string" ||
277
+ typeof payload.clientId !== "string" ||
278
+ !Array.isArray(payload.scopes) ||
279
+ typeof payload.resource !== "string" ||
280
+ typeof payload.issuedAt !== "number" ||
281
+ typeof payload.expiresAt !== "number" ||
282
+ typeof payload.jti !== "string") {
283
+ throw new Error("Invalid token payload");
284
+ }
285
+ return payload;
286
+ }
287
+ encryptTransientState(type, data) {
288
+ return this.encryptValue("wks1", { version: 1, type, data });
289
+ }
290
+ decryptTransientState(type, value) {
291
+ const decoded = this.decryptValue("wks1", value);
292
+ if (!decoded || typeof decoded !== "object") {
293
+ throw new Error("Invalid OAuth transient state payload");
294
+ }
295
+ const payload = decoded;
296
+ if (payload.version !== 1 || payload.type !== type) {
297
+ throw new Error("Invalid OAuth transient state payload");
298
+ }
299
+ return payload.data;
300
+ }
301
+ encryptValue(prefix, value) {
302
+ const iv = randomBytes(12);
303
+ const cipher = createCipheriv("aes-256-gcm", this.encryptionKey, iv);
304
+ const encrypted = Buffer.concat([
305
+ cipher.update(JSON.stringify(value), "utf8"),
306
+ cipher.final()
307
+ ]);
308
+ const tag = cipher.getAuthTag();
309
+ return [prefix, iv.toString("base64url"), encrypted.toString("base64url"), tag.toString("base64url")].join(".");
310
+ }
311
+ decryptValue(prefix, value) {
312
+ const [actualPrefix, ivText, encryptedText, tagText, extraPart] = value.split(".");
313
+ if (actualPrefix !== prefix || !ivText || !encryptedText || !tagText || extraPart) {
314
+ throw new Error("Malformed encrypted value");
315
+ }
316
+ const decipher = createDecipheriv("aes-256-gcm", this.encryptionKey, Buffer.from(ivText, "base64url"));
317
+ decipher.setAuthTag(Buffer.from(tagText, "base64url"));
318
+ const plaintext = Buffer.concat([
319
+ decipher.update(Buffer.from(encryptedText, "base64url")),
320
+ decipher.final()
321
+ ]).toString("utf8");
322
+ return JSON.parse(plaintext);
323
+ }
324
+ assertResource(resource) {
325
+ if (normalizeResourceUrl(resource).href !== this.resourceUrl.href) {
326
+ throw new InvalidTargetError("请求的 resource 不是当前 MCP 服务。");
327
+ }
328
+ }
329
+ }
330
+ function serializePendingAuthorization(pending) {
331
+ return {
332
+ client: pending.client,
333
+ params: {
334
+ state: pending.params.state,
335
+ scopes: pending.params.scopes,
336
+ codeChallenge: pending.params.codeChallenge,
337
+ redirectUri: pending.params.redirectUri,
338
+ resource: pending.params.resource?.href
339
+ },
340
+ resource: pending.resource.href,
341
+ scopes: pending.scopes,
342
+ expiresAt: pending.expiresAt
343
+ };
344
+ }
345
+ function serializeAuthorizationCode(codeData) {
346
+ return {
347
+ ...serializePendingAuthorization(codeData),
348
+ apiKey: codeData.apiKey
349
+ };
350
+ }
351
+ function deserializePendingAuthorization(value) {
352
+ if (!isRecord(value) || !isRecord(value.client) || !isRecord(value.params)) {
353
+ throw new Error("Invalid OAuth pending authorization state");
354
+ }
355
+ const client = value.client;
356
+ const params = value.params;
357
+ if (typeof client.client_id !== "string" ||
358
+ !isStringArray(client.redirect_uris) ||
359
+ typeof params.codeChallenge !== "string" ||
360
+ typeof params.redirectUri !== "string" ||
361
+ (params.state !== undefined && typeof params.state !== "string") ||
362
+ (params.scopes !== undefined && !isStringArray(params.scopes)) ||
363
+ (params.resource !== undefined && typeof params.resource !== "string") ||
364
+ typeof value.resource !== "string" ||
365
+ !isStringArray(value.scopes) ||
366
+ typeof value.expiresAt !== "number" ||
367
+ !Number.isFinite(value.expiresAt) ||
368
+ value.expiresAt <= Date.now()) {
369
+ throw new Error("Invalid or expired OAuth pending authorization state");
370
+ }
371
+ return {
372
+ client: value.client,
373
+ params: {
374
+ state: params.state,
375
+ scopes: params.scopes,
376
+ codeChallenge: params.codeChallenge,
377
+ redirectUri: params.redirectUri,
378
+ resource: params.resource === undefined ? undefined : new URL(params.resource)
379
+ },
380
+ resource: new URL(value.resource),
381
+ scopes: value.scopes,
382
+ expiresAt: value.expiresAt
383
+ };
384
+ }
385
+ function deserializeAuthorizationCode(value) {
386
+ if (!isRecord(value) || typeof value.apiKey !== "string" || !value.apiKey) {
387
+ throw new Error("Invalid OAuth authorization code state");
388
+ }
389
+ return {
390
+ ...deserializePendingAuthorization(value),
391
+ apiKey: value.apiKey
392
+ };
393
+ }
394
+ function isRecord(value) {
395
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
396
+ }
397
+ function isStringArray(value) {
398
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
399
+ }
400
+ function normalizeResourceUrl(url) {
401
+ const normalized = new URL(url.href);
402
+ normalized.hash = "";
403
+ return normalized;
404
+ }
405
+ function normalizeBindingEndpoint(url, name, options = {}) {
406
+ const normalized = new URL(url.href);
407
+ const isLocalHttp = normalized.protocol === "http:" &&
408
+ (normalized.hostname === "localhost" || normalized.hostname === "127.0.0.1");
409
+ const isExplicitlyAllowedHttp = normalized.protocol === "http:" && options.allowHttp === true;
410
+ if (normalized.protocol !== "https:" && !isLocalHttp && !isExplicitlyAllowedHttp) {
411
+ const exchangeHint = name === "CRM_BINDING_EXCHANGE_URL"
412
+ ? ";可信内网 HTTP 可同时设置 CRM_BINDING_EXCHANGE_ALLOW_HTTP=true"
413
+ : "";
414
+ throw new Error(`${name} 必须使用 HTTPS(本机 localhost 调试除外${exchangeHint})。`);
415
+ }
416
+ if (normalized.username || normalized.password || normalized.search || normalized.hash) {
417
+ throw new Error(`${name} 不能包含用户信息、查询参数或片段。`);
418
+ }
419
+ return normalized;
420
+ }
421
+ function describeFetchError(error) {
422
+ const details = [];
423
+ let current = error;
424
+ for (let depth = 0; depth < 3 && current; depth++) {
425
+ if (current instanceof Error && current.message) {
426
+ details.push(current.message);
427
+ }
428
+ if (typeof current === "object" && "code" in current) {
429
+ const code = Reflect.get(current, "code");
430
+ if (typeof code === "string" && code) {
431
+ details.push(code);
432
+ }
433
+ }
434
+ current = current instanceof Error ? current.cause : undefined;
435
+ }
436
+ return [...new Set(details)].join("; ") || "unknown network error";
437
+ }
438
+ function normalizeScopes(scopes) {
439
+ const requested = scopes?.length ? [...new Set(scopes)] : [CRM_READ_SCOPE];
440
+ for (const scope of requested) {
441
+ if (!OAUTH_SCOPES.includes(scope)) {
442
+ throw new InvalidScopeError(`不支持的授权范围:${scope}`);
443
+ }
444
+ }
445
+ return requested;
446
+ }
package/dist/server.d.ts CHANGED
@@ -1,3 +1,14 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { CrmClientConfig } from "./client.js";
3
- export declare function createWukongMcpServer(config?: CrmClientConfig): McpServer;
3
+ export declare const CRM_READ_SCOPE = "crm.read";
4
+ export declare const CRM_WRITE_SCOPE = "crm.write";
5
+ export declare const OFFLINE_ACCESS_SCOPE = "offline_access";
6
+ export declare const MCP_RESOURCE_SCOPES: readonly ["crm.read", "crm.write"];
7
+ export declare const OAUTH_SCOPES: readonly ["crm.read", "crm.write", "offline_access"];
8
+ export interface WukongMcpServerConfig extends CrmClientConfig {
9
+ oauth?: {
10
+ scopes: string[];
11
+ resourceMetadataUrl: string;
12
+ };
13
+ }
14
+ export declare function createWukongMcpServer(config?: WukongMcpServerConfig): McpServer;