@deadragdoll/tellymcp 0.0.12 → 0.0.14

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.
Files changed (83) hide show
  1. package/.env.example.client +5 -1
  2. package/.env.example.gateway +16 -1
  3. package/CHANGELOG.md +30 -0
  4. package/README-ru.md +33 -0
  5. package/README.md +33 -0
  6. package/TOOLS.md +384 -9
  7. package/config/templates/env.both.template +15 -2
  8. package/config/templates/env.client.template +5 -2
  9. package/config/templates/env.gateway.template +15 -2
  10. package/dist/cli.js +137 -2
  11. package/dist/services/features/telegram-mcp/browser.service.js +56 -1
  12. package/dist/services/features/telegram-mcp/file-content.service.js +94 -0
  13. package/dist/services/features/telegram-mcp/gateway-delivery.service.js +5 -1
  14. package/dist/services/features/telegram-mcp/gateway-socket.service.js +43 -11
  15. package/dist/services/features/telegram-mcp/mcp-http.service.js +1 -0
  16. package/dist/services/features/telegram-mcp/mcp-server.service.js +30 -0
  17. package/dist/services/features/telegram-mcp/src/app/bootstrap/runtime.js +23 -1
  18. package/dist/services/features/telegram-mcp/src/app/config/env.js +132 -2
  19. package/dist/services/features/telegram-mcp/src/app/http.js +139 -99
  20. package/dist/services/features/telegram-mcp/src/app/oauthFacade.js +642 -0
  21. package/dist/services/features/telegram-mcp/src/app/webapp/assets.js +151 -170
  22. package/dist/services/features/telegram-mcp/src/app/webapp/auth.js +96 -99
  23. package/dist/services/features/telegram-mcp/src/entities/request/model/schema.js +205 -17
  24. package/dist/services/features/telegram-mcp/src/features/browser/model/browserAttachActiveTabTool.js +28 -0
  25. package/dist/services/features/telegram-mcp/src/features/browser/model/browserAttachTabTool.js +28 -0
  26. package/dist/services/features/telegram-mcp/src/features/browser/model/browserClickTool.js +1 -1
  27. package/dist/services/features/telegram-mcp/src/features/browser/model/browserDetachTabTool.js +28 -0
  28. package/dist/services/features/telegram-mcp/src/features/browser/model/browserDomTool.js +1 -1
  29. package/dist/services/features/telegram-mcp/src/features/browser/model/browserFillTool.js +1 -1
  30. package/dist/services/features/telegram-mcp/src/features/browser/model/browserInjectScriptTool.js +28 -0
  31. package/dist/services/features/telegram-mcp/src/features/browser/model/browserListAttachedInstancesTool.js +33 -0
  32. package/dist/services/features/telegram-mcp/src/features/browser/model/browserListTabsTool.js +33 -0
  33. package/dist/services/features/telegram-mcp/src/features/browser/model/browserPressTool.js +1 -1
  34. package/dist/services/features/telegram-mcp/src/features/browser/model/browserRecordingStartTool.js +28 -0
  35. package/dist/services/features/telegram-mcp/src/features/browser/model/browserRecordingStatusTool.js +28 -0
  36. package/dist/services/features/telegram-mcp/src/features/browser/model/browserRecordingStopTool.js +28 -0
  37. package/dist/services/features/telegram-mcp/src/features/browser/model/browserScreenshotTool.js +1 -1
  38. package/dist/services/features/telegram-mcp/src/features/browser/model/browserService.js +890 -23
  39. package/dist/services/features/telegram-mcp/src/features/browser-attach/model/browserRecordingBundle.js +536 -0
  40. package/dist/services/features/telegram-mcp/src/features/browser-attach/model/firefoxAttachRegistry.js +80 -0
  41. package/dist/services/features/telegram-mcp/src/features/browser-attach/model/firefoxAttachServer.js +736 -0
  42. package/dist/services/features/telegram-mcp/src/features/browser-attach/model/types.js +188 -0
  43. package/dist/services/features/telegram-mcp/src/features/collaboration/model/collaborationService.js +2 -0
  44. package/dist/services/features/telegram-mcp/src/features/collaboration/model/sendPartnerFileService.js +6 -3
  45. package/dist/services/features/telegram-mcp/src/features/distributed-client/model/gatewayClientAccess.js +4 -1
  46. package/dist/services/features/telegram-mcp/src/features/distributed-gateway/model/gatewayHttpService.js +33 -35
  47. package/dist/services/features/telegram-mcp/src/features/file-content/model/getFileListTool.js +33 -0
  48. package/dist/services/features/telegram-mcp/src/features/file-content/model/getFileService.js +327 -0
  49. package/dist/services/features/telegram-mcp/src/features/file-content/model/getFileTool.js +81 -0
  50. package/dist/services/features/telegram-mcp/src/features/file-content/model/temporaryFileLinkStore.js +307 -0
  51. package/dist/services/features/telegram-mcp/src/features/file-content/model/workspaceFilePolicy.js +115 -0
  52. package/dist/services/features/telegram-mcp/src/features/notify/model/notifyService.js +5 -1
  53. package/dist/services/features/telegram-mcp/src/shared/integrations/redis/stateStore.js +28 -0
  54. package/dist/services/features/telegram-mcp/src/shared/integrations/telegram/transport.js +3 -0
  55. package/dist/services/features/telegram-mcp/src/shared/integrations/telegram/transportFileHandoffActions.js +6 -3
  56. package/dist/services/features/telegram-mcp/src/shared/integrations/terminal/client.js +29 -6
  57. package/dist/services/features/telegram-mcp/src/shared/integrations/terminal/ptyRegistry.js +100 -2
  58. package/dist/services/features/telegram-mcp/src/shared/lib/bodyLimits.js +63 -0
  59. package/dist/services/features/telegram-mcp/src/shared/lib/gatewayAuth.js +13 -0
  60. package/dist/services/features/telegram-mcp/src/shared/lib/time/localTimestamp.js +21 -0
  61. package/docs/CHAT_CONNECTOR.md +134 -0
  62. package/docs/STANDALONE-ru.md +55 -6
  63. package/docs/STANDALONE.md +55 -6
  64. package/package.json +10 -5
  65. package/packages/chrome-attach-extension/dist/background.js +1735 -0
  66. package/packages/chrome-attach-extension/dist/icon.svg +6 -0
  67. package/packages/chrome-attach-extension/dist/manifest.json +37 -0
  68. package/packages/chrome-attach-extension/dist/options.html +312 -0
  69. package/packages/chrome-attach-extension/dist/options.js +606 -0
  70. package/packages/chrome-attach-extension/dist/popup.html +93 -0
  71. package/packages/chrome-attach-extension/dist/popup.js +79 -0
  72. package/packages/chrome-attach-extension/dist/recorder-content.js +96 -0
  73. package/packages/chrome-attach-extension/dist/recorder-page.js +282 -0
  74. package/packages/firefox-attach-extension/README.md +13 -0
  75. package/packages/firefox-attach-extension/dist/background.js +1622 -0
  76. package/packages/firefox-attach-extension/dist/icon.svg +6 -0
  77. package/packages/firefox-attach-extension/dist/manifest.json +44 -0
  78. package/packages/firefox-attach-extension/dist/options.html +312 -0
  79. package/packages/firefox-attach-extension/dist/options.js +540 -0
  80. package/packages/firefox-attach-extension/dist/popup.html +93 -0
  81. package/packages/firefox-attach-extension/dist/popup.js +64 -0
  82. package/packages/firefox-attach-extension/dist/recorder-content.js +90 -0
  83. package/packages/firefox-attach-extension/dist/recorder-page.js +318 -0
@@ -0,0 +1,642 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createOAuthFacade = createOAuthFacade;
4
+ const node_crypto_1 = require("node:crypto");
5
+ const OAUTH_FORM_LIMIT_BYTES = 64 * 1024;
6
+ const FAILED_MAGIC_TOKEN_WINDOW_MS = 60_000;
7
+ const MAX_FAILED_MAGIC_TOKEN_ATTEMPTS = 5;
8
+ function base64UrlJson(value) {
9
+ return Buffer.from(JSON.stringify(value)).toString("base64url");
10
+ }
11
+ function hash(value) {
12
+ return (0, node_crypto_1.createHash)("sha256").update(value).digest();
13
+ }
14
+ function safeEqual(left, right) {
15
+ return (0, node_crypto_1.timingSafeEqual)(hash(left), hash(right));
16
+ }
17
+ function escapeHtml(value) {
18
+ return value
19
+ .replaceAll("&", "&")
20
+ .replaceAll("<", "&lt;")
21
+ .replaceAll(">", "&gt;")
22
+ .replaceAll('"', "&quot;")
23
+ .replaceAll("'", "&#39;");
24
+ }
25
+ function writeJson(res, statusCode, payload) {
26
+ res.statusCode = statusCode;
27
+ res.setHeader("content-type", "application/json; charset=utf-8");
28
+ res.end(JSON.stringify(payload));
29
+ }
30
+ function writeOAuthJson(res, statusCode, payload) {
31
+ res.setHeader("cache-control", "no-store");
32
+ res.setHeader("pragma", "no-cache");
33
+ writeJson(res, statusCode, payload);
34
+ }
35
+ function writeText(res, statusCode, message) {
36
+ res.statusCode = statusCode;
37
+ res.setHeader("content-type", "text/plain; charset=utf-8");
38
+ res.end(message);
39
+ }
40
+ function writeHtml(res, statusCode, html) {
41
+ res.statusCode = statusCode;
42
+ res.setHeader("content-type", "text/html; charset=utf-8");
43
+ res.setHeader("cache-control", "no-store");
44
+ res.setHeader("content-security-policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'");
45
+ res.setHeader("x-content-type-options", "nosniff");
46
+ res.end(html);
47
+ }
48
+ function readHeader(req, headerName) {
49
+ const value = req.headers[headerName];
50
+ return Array.isArray(value) ? value[0] : value;
51
+ }
52
+ function paramsFromRecord(value) {
53
+ const params = new URLSearchParams();
54
+ for (const [key, entry] of Object.entries(value)) {
55
+ if (typeof entry === "string") {
56
+ params.set(key, entry);
57
+ }
58
+ else if (typeof entry === "number" || typeof entry === "boolean") {
59
+ params.set(key, String(entry));
60
+ }
61
+ else if (Array.isArray(entry) && typeof entry[0] === "string") {
62
+ params.set(key, entry[0]);
63
+ }
64
+ }
65
+ return params;
66
+ }
67
+ function assertFormSize(params) {
68
+ if (Buffer.byteLength(params.toString()) > OAUTH_FORM_LIMIT_BYTES) {
69
+ throw new Error("form_too_large");
70
+ }
71
+ return params;
72
+ }
73
+ async function readFormBody(req) {
74
+ const requestWithParsedBody = req;
75
+ if (requestWithParsedBody.$params) {
76
+ return assertFormSize(paramsFromRecord(requestWithParsedBody.$params));
77
+ }
78
+ if (requestWithParsedBody.body &&
79
+ typeof requestWithParsedBody.body === "object") {
80
+ return assertFormSize(paramsFromRecord(requestWithParsedBody.body));
81
+ }
82
+ if (typeof requestWithParsedBody.body === "string") {
83
+ if (Buffer.byteLength(requestWithParsedBody.body) > OAUTH_FORM_LIMIT_BYTES) {
84
+ throw new Error("form_too_large");
85
+ }
86
+ return assertFormSize(new URLSearchParams(requestWithParsedBody.body));
87
+ }
88
+ const declaredLength = Number(readHeader(req, "content-length"));
89
+ if (Number.isFinite(declaredLength) &&
90
+ declaredLength > OAUTH_FORM_LIMIT_BYTES) {
91
+ throw new Error("form_too_large");
92
+ }
93
+ const chunks = [];
94
+ let totalBytes = 0;
95
+ for await (const chunk of req) {
96
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
97
+ totalBytes += buffer.byteLength;
98
+ if (totalBytes > OAUTH_FORM_LIMIT_BYTES) {
99
+ throw new Error("form_too_large");
100
+ }
101
+ chunks.push(buffer);
102
+ }
103
+ return assertFormSize(new URLSearchParams(Buffer.concat(chunks).toString("utf8")));
104
+ }
105
+ function renderAuthorizationForm(action, request, errorMessage) {
106
+ const hidden = [
107
+ ["response_type", "code"],
108
+ ["client_id", request.clientId],
109
+ ["redirect_uri", request.redirectUri],
110
+ ["scope", request.scope],
111
+ ["state", request.state],
112
+ ["code_challenge", request.codeChallenge],
113
+ ["code_challenge_method", "S256"],
114
+ ["resource", request.resource],
115
+ ]
116
+ .map(([name, value]) => `<input type="hidden" name="${name}" value="${escapeHtml(value ?? "")}">`)
117
+ .join("\n");
118
+ return `<!doctype html>
119
+ <html lang="en">
120
+ <head>
121
+ <meta charset="utf-8">
122
+ <meta name="viewport" content="width=device-width, initial-scale=1">
123
+ <title>Authorize TellyMCP</title>
124
+ <style>
125
+ :root { color-scheme: light dark; font-family: system-ui, sans-serif; }
126
+ body { display: grid; min-height: 100vh; margin: 0; place-items: center; background: #111827; color: #f9fafb; }
127
+ main { width: min(92vw, 28rem); box-sizing: border-box; padding: 2rem; border: 1px solid #374151; border-radius: 1rem; background: #1f2937; }
128
+ h1 { margin-top: 0; font-size: 1.5rem; }
129
+ p { color: #d1d5db; line-height: 1.5; }
130
+ .error { color: #fca5a5; }
131
+ label { display: block; margin: 1.25rem 0 .5rem; }
132
+ input[type=password] { width: 100%; box-sizing: border-box; padding: .75rem; border: 1px solid #4b5563; border-radius: .5rem; font: inherit; }
133
+ button { width: 100%; margin-top: 1rem; padding: .8rem; border: 0; border-radius: .5rem; background: #2563eb; color: white; font: inherit; font-weight: 600; cursor: pointer; }
134
+ </style>
135
+ </head>
136
+ <body>
137
+ <main>
138
+ <h1>Authorize TellyMCP</h1>
139
+ <p>Enter the private connector token to allow this chat client to use TellyMCP.</p>
140
+ ${errorMessage ? `<p class="error">${escapeHtml(errorMessage)}</p>` : ""}
141
+ <form method="post" action="${escapeHtml(action)}" autocomplete="off">
142
+ ${hidden}
143
+ <label for="magic_token">Private connector token</label>
144
+ <input id="magic_token" name="magic_token" type="password" required autofocus>
145
+ <button type="submit">Authorize</button>
146
+ </form>
147
+ </main>
148
+ </body>
149
+ </html>`;
150
+ }
151
+ function normalizeUrl(value) {
152
+ return value.replace(/\/+$/u, "");
153
+ }
154
+ function resolvePrivateKey(config) {
155
+ if (config.privateKeyPem) {
156
+ return {
157
+ key: (0, node_crypto_1.createPrivateKey)(config.privateKeyPem),
158
+ ephemeral: false,
159
+ };
160
+ }
161
+ return {
162
+ key: (0, node_crypto_1.generateKeyPairSync)("rsa", { modulusLength: 2048 }).privateKey,
163
+ ephemeral: true,
164
+ };
165
+ }
166
+ function parseJwtJson(value) {
167
+ return JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
168
+ }
169
+ function createOAuthFacade(config, mcpPath, logger) {
170
+ const publicUrl = normalizeUrl(config.publicUrl);
171
+ const issuer = normalizeUrl(config.issuer);
172
+ const audience = normalizeUrl(config.audience);
173
+ const normalizedMcpPath = mcpPath.startsWith("/") ? mcpPath : `/${mcpPath}`;
174
+ const mcpResource = `${publicUrl}${normalizedMcpPath}`;
175
+ const allowedResources = new Set([publicUrl, audience, mcpResource]);
176
+ const scope = config.scopes.join(" ");
177
+ const requiredScopes = new Set(config.scopes);
178
+ const { key: privateKey, ephemeral } = resolvePrivateKey(config);
179
+ const publicKey = (0, node_crypto_1.createPublicKey)(privateKey);
180
+ const exportedJwk = publicKey.export({ format: "jwk" });
181
+ const jwk = {
182
+ ...exportedJwk,
183
+ kid: config.keyId,
184
+ use: "sig",
185
+ alg: "RS256",
186
+ };
187
+ const authorizationCodes = new Map();
188
+ const failedAttempts = new Map();
189
+ const issuerPath = new URL(issuer).pathname.replace(/\/+$/u, "");
190
+ const mcpResourcePath = new URL(mcpResource).pathname;
191
+ const authorizationServerInsertionPath = `/.well-known/oauth-authorization-server${issuerPath === "/" ? "" : issuerPath}`;
192
+ const openIdInsertionPath = `/.well-known/openid-configuration${issuerPath === "/" ? "" : issuerPath}`;
193
+ const protectedResourceInsertionPath = `/.well-known/oauth-protected-resource${mcpResourcePath}`;
194
+ const authorizeEndpoint = `${issuer}/oauth/authorize`;
195
+ const tokenEndpoint = `${issuer}/oauth/token`;
196
+ const jwksUri = `${issuer}/.well-known/jwks.json`;
197
+ const protectedResourceMetadataUrl = `${publicUrl}/.well-known/oauth-protected-resource${normalizedMcpPath}`;
198
+ const authorizationServerMetadata = {
199
+ issuer,
200
+ authorization_endpoint: authorizeEndpoint,
201
+ token_endpoint: tokenEndpoint,
202
+ jwks_uri: jwksUri,
203
+ response_types_supported: ["code"],
204
+ grant_types_supported: ["authorization_code"],
205
+ code_challenge_methods_supported: ["S256"],
206
+ token_endpoint_auth_methods_supported: config.clientSecret
207
+ ? ["client_secret_post", "client_secret_basic"]
208
+ : ["none"],
209
+ scopes_supported: config.scopes,
210
+ };
211
+ const purgeExpiredCodes = (nowMs) => {
212
+ for (const [code, record] of authorizationCodes) {
213
+ if (record.expiresAtMs <= nowMs) {
214
+ authorizationCodes.delete(code);
215
+ }
216
+ }
217
+ };
218
+ const parseAuthorizationRequest = (params) => {
219
+ if (params.get("response_type") !== "code") {
220
+ return { ok: false, error: "response_type must be code" };
221
+ }
222
+ if (params.get("code_challenge_method") !== "S256") {
223
+ return { ok: false, error: "code_challenge_method must be S256" };
224
+ }
225
+ const clientId = params.get("client_id")?.trim() ?? "";
226
+ const redirectUri = params.get("redirect_uri")?.trim() ?? "";
227
+ const requestedScope = params.get("scope")?.trim() ?? scope;
228
+ const state = params.get("state") ?? "";
229
+ const codeChallenge = params.get("code_challenge")?.trim() ?? "";
230
+ const resource = normalizeUrl(params.get("resource")?.trim() || audience);
231
+ if (!clientId || !redirectUri || !codeChallenge) {
232
+ return { ok: false, error: "Missing required OAuth parameters" };
233
+ }
234
+ if (!/^[A-Za-z0-9_-]{43,128}$/u.test(codeChallenge)) {
235
+ return { ok: false, error: "Invalid PKCE code challenge" };
236
+ }
237
+ if (config.clientId && clientId !== config.clientId) {
238
+ return { ok: false, error: "Unknown OAuth client" };
239
+ }
240
+ try {
241
+ const parsedRedirect = new URL(redirectUri);
242
+ if (parsedRedirect.hash) {
243
+ return { ok: false, error: "Redirect URI must not contain a fragment" };
244
+ }
245
+ }
246
+ catch {
247
+ return { ok: false, error: "Invalid redirect URI" };
248
+ }
249
+ if (config.allowedRedirectUris.length > 0 &&
250
+ !config.allowedRedirectUris.includes(redirectUri)) {
251
+ return { ok: false, error: "Redirect URI is not allowed" };
252
+ }
253
+ if (!allowedResources.has(resource)) {
254
+ return { ok: false, error: "Invalid OAuth resource" };
255
+ }
256
+ const requestedScopes = requestedScope.split(/\s+/u).filter(Boolean);
257
+ if (requestedScopes.some((requested) => !requiredScopes.has(requested))) {
258
+ return { ok: false, error: "Unsupported OAuth scope" };
259
+ }
260
+ return {
261
+ ok: true,
262
+ value: {
263
+ clientId,
264
+ redirectUri,
265
+ scope: requestedScope,
266
+ state,
267
+ codeChallenge,
268
+ resource,
269
+ },
270
+ };
271
+ };
272
+ const isMagicTokenValid = (candidate) => {
273
+ if (config.magicToken) {
274
+ return safeEqual(candidate, config.magicToken);
275
+ }
276
+ if (!config.magicTokenHash) {
277
+ return false;
278
+ }
279
+ const expected = Buffer.from(config.magicTokenHash.slice("sha256:".length), "hex");
280
+ return (0, node_crypto_1.timingSafeEqual)(hash(candidate), expected);
281
+ };
282
+ const failedAttemptKey = (req) => req.socket?.remoteAddress ?? "unknown";
283
+ const isRateLimited = (key, nowMs) => {
284
+ const state = failedAttempts.get(key);
285
+ if (!state) {
286
+ return false;
287
+ }
288
+ if (nowMs - state.windowStartedAtMs >= FAILED_MAGIC_TOKEN_WINDOW_MS) {
289
+ failedAttempts.delete(key);
290
+ return false;
291
+ }
292
+ return state.count >= MAX_FAILED_MAGIC_TOKEN_ATTEMPTS;
293
+ };
294
+ const recordFailedAttempt = (key, nowMs) => {
295
+ const state = failedAttempts.get(key);
296
+ if (!state ||
297
+ nowMs - state.windowStartedAtMs >= FAILED_MAGIC_TOKEN_WINDOW_MS) {
298
+ failedAttempts.set(key, { count: 1, windowStartedAtMs: nowMs });
299
+ return;
300
+ }
301
+ state.count += 1;
302
+ };
303
+ const purgeExpiredFailedAttempts = (nowMs) => {
304
+ for (const [key, state] of failedAttempts) {
305
+ if (nowMs - state.windowStartedAtMs >= FAILED_MAGIC_TOKEN_WINDOW_MS) {
306
+ failedAttempts.delete(key);
307
+ }
308
+ }
309
+ };
310
+ const authenticateTokenClient = (req, params) => {
311
+ const formClientId = params.get("client_id")?.trim() ?? "";
312
+ if (!config.clientSecret) {
313
+ return formClientId || null;
314
+ }
315
+ const authorization = readHeader(req, "authorization");
316
+ if (authorization?.startsWith("Basic ")) {
317
+ try {
318
+ const decoded = Buffer.from(authorization.slice("Basic ".length), "base64").toString("utf8");
319
+ const separator = decoded.indexOf(":");
320
+ if (separator < 0) {
321
+ return null;
322
+ }
323
+ const basicClientId = decoded.slice(0, separator);
324
+ const basicSecret = decoded.slice(separator + 1);
325
+ if (basicClientId === config.clientId &&
326
+ safeEqual(basicSecret, config.clientSecret)) {
327
+ return basicClientId;
328
+ }
329
+ }
330
+ catch {
331
+ return null;
332
+ }
333
+ return null;
334
+ }
335
+ const formSecret = params.get("client_secret") ?? "";
336
+ if (formClientId === config.clientId &&
337
+ safeEqual(formSecret, config.clientSecret)) {
338
+ return formClientId;
339
+ }
340
+ return null;
341
+ };
342
+ const issueAccessToken = (record) => {
343
+ const header = base64UrlJson({
344
+ alg: "RS256",
345
+ typ: "JWT",
346
+ kid: config.keyId,
347
+ });
348
+ const claims = {
349
+ iss: issuer,
350
+ aud: record.resource,
351
+ sub: "tellymcp-chat-connector",
352
+ client_id: record.clientId,
353
+ scope,
354
+ iat: Math.floor(Date.now() / 1000),
355
+ jti: (0, node_crypto_1.randomUUID)(),
356
+ };
357
+ const payload = base64UrlJson(claims);
358
+ const signingInput = `${header}.${payload}`;
359
+ const signature = (0, node_crypto_1.sign)("RSA-SHA256", Buffer.from(signingInput), privateKey).toString("base64url");
360
+ return `${signingInput}.${signature}`;
361
+ };
362
+ const verifyAccessToken = (token) => {
363
+ const reject = (reason) => {
364
+ logger?.warn("OAuth access token rejected", { reason });
365
+ return null;
366
+ };
367
+ try {
368
+ const parts = token.split(".");
369
+ if (parts.length !== 3) {
370
+ return reject("malformed_jwt");
371
+ }
372
+ const [encodedHeader, encodedPayload, encodedSignature] = parts;
373
+ if (!encodedHeader || !encodedPayload || !encodedSignature) {
374
+ return reject("malformed_jwt");
375
+ }
376
+ const header = parseJwtJson(encodedHeader);
377
+ const claims = parseJwtJson(encodedPayload);
378
+ if (!header ||
379
+ typeof header !== "object" ||
380
+ Reflect.get(header, "alg") !== "RS256" ||
381
+ Reflect.get(header, "kid") !== config.keyId ||
382
+ !claims ||
383
+ typeof claims !== "object") {
384
+ return reject("invalid_jwt_header_or_payload");
385
+ }
386
+ if (!(0, node_crypto_1.verify)("RSA-SHA256", Buffer.from(`${encodedHeader}.${encodedPayload}`), publicKey, Buffer.from(encodedSignature, "base64url"))) {
387
+ return reject("invalid_signature");
388
+ }
389
+ const candidate = claims;
390
+ if (candidate.iss !== issuer ||
391
+ typeof candidate.aud !== "string" ||
392
+ !allowedResources.has(candidate.aud) ||
393
+ typeof candidate.sub !== "string" ||
394
+ typeof candidate.client_id !== "string" ||
395
+ typeof candidate.scope !== "string" ||
396
+ typeof candidate.iat !== "number" ||
397
+ typeof candidate.jti !== "string" ||
398
+ candidate.iat > Math.floor(Date.now() / 1000) + 60) {
399
+ return reject("invalid_claims");
400
+ }
401
+ if (config.clientId && candidate.client_id !== config.clientId) {
402
+ return reject("client_id_mismatch");
403
+ }
404
+ const tokenScopes = new Set(candidate.scope.split(/\s+/u).filter(Boolean));
405
+ if ([...requiredScopes].some((required) => !tokenScopes.has(required))) {
406
+ return reject("missing_required_scope");
407
+ }
408
+ logger?.info("OAuth access token accepted", {
409
+ clientId: candidate.client_id,
410
+ audience: candidate.aud,
411
+ scopes: candidate.scope,
412
+ });
413
+ return candidate;
414
+ }
415
+ catch (error) {
416
+ return reject(error instanceof Error
417
+ ? `verification_error:${error.name}`
418
+ : "verification_error");
419
+ }
420
+ };
421
+ const handleRequest = async (req, res, pathname) => {
422
+ const method = req.method ?? "GET";
423
+ if (pathname === "/.well-known/oauth-protected-resource" ||
424
+ pathname === "/.well-known/oauth-protected-resource/mcp" ||
425
+ pathname === protectedResourceInsertionPath) {
426
+ if (method !== "GET") {
427
+ writeText(res, 405, "Method not allowed");
428
+ return true;
429
+ }
430
+ const resource = pathname === "/.well-known/oauth-protected-resource"
431
+ ? audience
432
+ : mcpResource;
433
+ logger?.info("OAuth protected-resource metadata served", {
434
+ path: pathname,
435
+ resource,
436
+ });
437
+ writeJson(res, 200, {
438
+ resource,
439
+ authorization_servers: [issuer],
440
+ scopes_supported: config.scopes,
441
+ });
442
+ return true;
443
+ }
444
+ if (pathname === "/.well-known/oauth-authorization-server" ||
445
+ pathname === authorizationServerInsertionPath ||
446
+ pathname === openIdInsertionPath) {
447
+ if (method !== "GET") {
448
+ writeText(res, 405, "Method not allowed");
449
+ return true;
450
+ }
451
+ logger?.info("OAuth authorization-server metadata served", {
452
+ path: pathname,
453
+ issuer,
454
+ clientAuthentication: config.clientSecret ? "confidential" : "public",
455
+ });
456
+ writeJson(res, 200, authorizationServerMetadata);
457
+ return true;
458
+ }
459
+ if (pathname === "/.well-known/jwks.json") {
460
+ if (method !== "GET") {
461
+ writeText(res, 405, "Method not allowed");
462
+ return true;
463
+ }
464
+ logger?.info("OAuth JWKS served", { keyId: config.keyId });
465
+ writeJson(res, 200, { keys: [jwk] });
466
+ return true;
467
+ }
468
+ if (pathname === "/oauth/authorize") {
469
+ if (method !== "GET" && method !== "POST") {
470
+ writeText(res, 405, "Method not allowed");
471
+ return true;
472
+ }
473
+ let params;
474
+ try {
475
+ params =
476
+ method === "GET"
477
+ ? new URL(req.url ?? authorizeEndpoint, authorizeEndpoint)
478
+ .searchParams
479
+ : await readFormBody(req);
480
+ }
481
+ catch {
482
+ writeText(res, 413, "OAuth form is too large");
483
+ return true;
484
+ }
485
+ const parsed = parseAuthorizationRequest(params);
486
+ if (!parsed.ok) {
487
+ logger?.warn("OAuth authorization request rejected", {
488
+ method,
489
+ reason: parsed.error,
490
+ });
491
+ writeText(res, 400, parsed.error);
492
+ return true;
493
+ }
494
+ if (method === "GET") {
495
+ logger?.info("OAuth authorization form opened", {
496
+ clientId: parsed.value.clientId,
497
+ redirectUri: parsed.value.redirectUri,
498
+ resource: parsed.value.resource,
499
+ requestedScope: parsed.value.scope,
500
+ });
501
+ writeHtml(res, 200, renderAuthorizationForm(authorizeEndpoint, parsed.value));
502
+ return true;
503
+ }
504
+ const nowMs = Date.now();
505
+ purgeExpiredFailedAttempts(nowMs);
506
+ const attemptKey = failedAttemptKey(req);
507
+ if (isRateLimited(attemptKey, nowMs)) {
508
+ logger?.warn("OAuth magic-token attempt rate-limited", {
509
+ clientId: parsed.value.clientId,
510
+ remoteAddress: attemptKey,
511
+ });
512
+ res.setHeader("retry-after", "60");
513
+ writeHtml(res, 429, renderAuthorizationForm(authorizeEndpoint, parsed.value, "Authorization failed. Try again later."));
514
+ return true;
515
+ }
516
+ const magicToken = params.get("magic_token") ?? "";
517
+ if (!isMagicTokenValid(magicToken)) {
518
+ recordFailedAttempt(attemptKey, nowMs);
519
+ logger?.warn("OAuth magic token rejected", {
520
+ clientId: parsed.value.clientId,
521
+ remoteAddress: attemptKey,
522
+ });
523
+ writeHtml(res, 401, renderAuthorizationForm(authorizeEndpoint, parsed.value, "Invalid connector token."));
524
+ return true;
525
+ }
526
+ failedAttempts.delete(attemptKey);
527
+ purgeExpiredCodes(nowMs);
528
+ const code = (0, node_crypto_1.randomBytes)(32).toString("base64url");
529
+ authorizationCodes.set(code, {
530
+ ...parsed.value,
531
+ expiresAtMs: nowMs + config.authCodeTtlSeconds * 1000,
532
+ });
533
+ logger?.info("OAuth authorization code issued", {
534
+ clientId: parsed.value.clientId,
535
+ redirectUri: parsed.value.redirectUri,
536
+ resource: parsed.value.resource,
537
+ grantedScopes: scope,
538
+ expiresInSeconds: config.authCodeTtlSeconds,
539
+ });
540
+ const redirect = new URL(parsed.value.redirectUri);
541
+ redirect.searchParams.set("code", code);
542
+ if (parsed.value.state) {
543
+ redirect.searchParams.set("state", parsed.value.state);
544
+ }
545
+ res.statusCode = 302;
546
+ res.setHeader("location", redirect.toString());
547
+ res.setHeader("cache-control", "no-store");
548
+ res.end();
549
+ return true;
550
+ }
551
+ if (pathname === "/oauth/token") {
552
+ if (method !== "POST") {
553
+ writeText(res, 405, "Method not allowed");
554
+ return true;
555
+ }
556
+ let params;
557
+ try {
558
+ params = await readFormBody(req);
559
+ }
560
+ catch {
561
+ writeOAuthJson(res, 413, { error: "invalid_request" });
562
+ return true;
563
+ }
564
+ if (params.get("grant_type") !== "authorization_code") {
565
+ logger?.warn("OAuth token request rejected", {
566
+ reason: "unsupported_grant_type",
567
+ });
568
+ writeOAuthJson(res, 400, { error: "unsupported_grant_type" });
569
+ return true;
570
+ }
571
+ const authenticatedClientId = authenticateTokenClient(req, params);
572
+ if (!authenticatedClientId) {
573
+ logger?.warn("OAuth token request rejected", {
574
+ reason: "invalid_client",
575
+ clientId: params.get("client_id")?.trim() || null,
576
+ authenticationMethod: readHeader(req, "authorization")?.startsWith("Basic ")
577
+ ? "client_secret_basic"
578
+ : "client_secret_post",
579
+ });
580
+ if (config.clientSecret) {
581
+ res.setHeader("www-authenticate", 'Basic realm="oauth/token"');
582
+ }
583
+ writeOAuthJson(res, 401, { error: "invalid_client" });
584
+ return true;
585
+ }
586
+ const code = params.get("code") ?? "";
587
+ const record = authorizationCodes.get(code);
588
+ const nowMs = Date.now();
589
+ purgeExpiredCodes(nowMs);
590
+ if (!record || record.expiresAtMs <= nowMs) {
591
+ authorizationCodes.delete(code);
592
+ logger?.warn("OAuth token request rejected", {
593
+ reason: "unknown_or_expired_code",
594
+ clientId: authenticatedClientId,
595
+ });
596
+ writeOAuthJson(res, 400, { error: "invalid_grant" });
597
+ return true;
598
+ }
599
+ const redirectUri = params.get("redirect_uri") ?? "";
600
+ const resource = normalizeUrl(params.get("resource") || record.resource);
601
+ const codeVerifier = params.get("code_verifier") ?? "";
602
+ const verifierChallenge = (0, node_crypto_1.createHash)("sha256")
603
+ .update(codeVerifier)
604
+ .digest("base64url");
605
+ if (authenticatedClientId !== record.clientId ||
606
+ redirectUri !== record.redirectUri ||
607
+ resource !== record.resource ||
608
+ !/^[A-Za-z0-9._~-]{43,128}$/u.test(codeVerifier) ||
609
+ !safeEqual(verifierChallenge, record.codeChallenge)) {
610
+ logger?.warn("OAuth token request rejected", {
611
+ reason: "authorization_code_binding_or_pkce_mismatch",
612
+ clientId: authenticatedClientId,
613
+ redirectUriMatches: redirectUri === record.redirectUri,
614
+ resourceMatches: resource === record.resource,
615
+ });
616
+ writeOAuthJson(res, 400, { error: "invalid_grant" });
617
+ return true;
618
+ }
619
+ authorizationCodes.delete(code);
620
+ logger?.info("OAuth access token issued", {
621
+ clientId: record.clientId,
622
+ audience: record.resource,
623
+ grantedScopes: scope,
624
+ });
625
+ writeOAuthJson(res, 200, {
626
+ access_token: issueAccessToken(record),
627
+ token_type: "Bearer",
628
+ scope,
629
+ });
630
+ return true;
631
+ }
632
+ return false;
633
+ };
634
+ return {
635
+ ephemeralSigningKey: ephemeral,
636
+ handleRequest,
637
+ verifyAccessToken,
638
+ writeMcpChallenge(res) {
639
+ res.setHeader("www-authenticate", `Bearer resource_metadata="${protectedResourceMetadataUrl}", scope="${scope}"`);
640
+ },
641
+ };
642
+ }