toolfunnel 0.3.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/LICENSE +21 -0
- package/README.md +324 -0
- package/bin/toolfunnel.js +112 -0
- package/docs/admin-ui.md +404 -0
- package/docs/configuration.md +402 -0
- package/docs/hooks-and-gating.md +428 -0
- package/docs/logging.md +223 -0
- package/docs/management.md +429 -0
- package/docs/oauth.md +103 -0
- package/docs/setup.md +352 -0
- package/docs/tools-and-upstreams.md +469 -0
- package/hooks/hooks.manifest.json +27 -0
- package/hooks/scripts/.gitkeep +3 -0
- package/hooks/scripts/example-audit.js +21 -0
- package/hooks/scripts/example-deny-dangerous.js +31 -0
- package/mcp/expose.example.json +30 -0
- package/mcp/servers/mock-upstream/server.js +170 -0
- package/package.json +63 -0
- package/src/auth/config.js +172 -0
- package/src/auth/install.js +181 -0
- package/src/auth/resource-server.js +319 -0
- package/src/core/events.js +105 -0
- package/src/core/hook-engine.js +275 -0
- package/src/core/hook-loader.js +791 -0
- package/src/core/hook-runner.js +346 -0
- package/src/core/logger.js +169 -0
- package/src/core/matcher.js +51 -0
- package/src/core/metrics.js +75 -0
- package/src/core/tool-registry.js +180 -0
- package/src/extend/add-hook.md +142 -0
- package/src/extend/add-mcp.md +121 -0
- package/src/extend/create-tool.md +132 -0
- package/src/extend/howto.js +98 -0
- package/src/extend/package.md +154 -0
- package/src/mcp/aggregator.js +802 -0
- package/src/mcp/expose-store.js +430 -0
- package/src/mcp/gated-run.js +194 -0
- package/src/mcp/http-transport.js +962 -0
- package/src/mcp/mcp-client.js +524 -0
- package/src/mcp/protocol.js +466 -0
- package/src/mcp/server.js +1240 -0
- package/src/packages/packager.js +713 -0
- package/src/tools/drift.js +138 -0
- package/src/tools/registry.js +468 -0
- package/src/tools/tool-state.js +104 -0
- package/src/ui/public/app.js +1055 -0
- package/src/ui/public/index.html +249 -0
- package/src/ui/public/logo.jpg +0 -0
- package/src/ui/public/styles.css +535 -0
- package/src/ui/server.js +1144 -0
- package/tools/scripts/base64.js +139 -0
- package/tools/scripts/danger.js +61 -0
- package/tools/scripts/echo.js +39 -0
- package/tools/scripts/hash.js +101 -0
- package/tools/scripts/json.js +109 -0
- package/tools/scripts/text-stats.js +131 -0
- package/tools/scripts/tf-hook-add.js +152 -0
- package/tools/scripts/tf-hook-set.js +113 -0
- package/tools/scripts/tf-list.js +195 -0
- package/tools/scripts/tf-log.js +83 -0
- package/tools/scripts/tf-mcp-add.js +119 -0
- package/tools/scripts/tf-mcp-set.js +104 -0
- package/tools/scripts/tf-tool-add.js +129 -0
- package/tools/scripts/tf-tool-set.js +148 -0
- package/tools/scripts/uuid.js +102 -0
- package/tools/tools.register.json +171 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* mcp/servers/mock-upstream/server.js
|
|
6
|
+
*
|
|
7
|
+
* A tiny, ZERO-DEPENDENCY stdio MCP server bundled inside ToolFunnel's own `mcp/` tree so it
|
|
8
|
+
* travels with the install (the "toolbox" stays self-contained and portable). It exists to
|
|
9
|
+
* demonstrate ToolFunnel attaching and forwarding (proxying) an upstream MCP: attach it as an
|
|
10
|
+
* upstream, expose its tools, and watch ToolFunnel forward calls to it through the policy gate.
|
|
11
|
+
*
|
|
12
|
+
* Attach it with a RELATIVE arg path (the aggregator resolves it against the gateway root and
|
|
13
|
+
* the isolation guard requires it to stay inside the tree):
|
|
14
|
+
* upstream: { id: "mock", command: "node", args: ["mcp/servers/mock-upstream/server.js"] }
|
|
15
|
+
*
|
|
16
|
+
* Protocol: JSON-RPC 2.0 over stdio with NEWLINE-DELIMITED framing — one compact JSON object per
|
|
17
|
+
* line, "\n"-terminated, the MCP stdio transport every real SDK-based server speaks. We read lines
|
|
18
|
+
* on stdin and answer in kind on stdout. (This fixture used to parse LSP Content-Length framing,
|
|
19
|
+
* matching an old client bug; both were corrected together so the mock represents a REAL server.)
|
|
20
|
+
*
|
|
21
|
+
* Tools exposed (real upstream names):
|
|
22
|
+
* - ping -> "pong"
|
|
23
|
+
* - add { a, b } -> a + b
|
|
24
|
+
* - echo { text } -> text
|
|
25
|
+
*
|
|
26
|
+
* Replies to `initialize`, ignores `notifications/initialized`, answers `tools/list` and
|
|
27
|
+
* `tools/call`, never crashes on a bad frame, and exits cleanly on stdin EOF.
|
|
28
|
+
*
|
|
29
|
+
* Node built-ins only. CommonJS.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const PROTOCOL_VERSION = '2024-11-05';
|
|
33
|
+
const SERVER_INFO = { name: 'mock-upstream', version: '1.0.0' };
|
|
34
|
+
|
|
35
|
+
const TOOLS = [
|
|
36
|
+
{
|
|
37
|
+
name: 'ping',
|
|
38
|
+
description: 'Health check — returns the string "pong".',
|
|
39
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'add',
|
|
43
|
+
description: 'Add two numbers and return the sum.',
|
|
44
|
+
inputSchema: {
|
|
45
|
+
type: 'object',
|
|
46
|
+
properties: { a: { type: 'number' }, b: { type: 'number' } },
|
|
47
|
+
required: ['a', 'b'],
|
|
48
|
+
additionalProperties: false,
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'echo',
|
|
53
|
+
description: 'Echo back the supplied text.',
|
|
54
|
+
inputSchema: {
|
|
55
|
+
type: 'object',
|
|
56
|
+
properties: { text: { type: 'string' } },
|
|
57
|
+
required: ['text'],
|
|
58
|
+
additionalProperties: false,
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: 'crash',
|
|
63
|
+
description: 'Exit the process immediately (simulates an upstream crash — for reconnect testing).',
|
|
64
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
/** Write one JSON-RPC message as a newline-delimited line — the MCP stdio framing. */
|
|
69
|
+
function writeMessage(msg) {
|
|
70
|
+
process.stdout.write(JSON.stringify(msg) + '\n');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Standard MCP tools/call result envelope. */
|
|
74
|
+
function textResult(text, isError) {
|
|
75
|
+
return { content: [{ type: 'text', text: String(text) }], isError: isError === true };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Dispatch one parsed JSON-RPC message. Notifications (no id) get no reply. */
|
|
79
|
+
function handleMessage(msg) {
|
|
80
|
+
if (!msg || typeof msg !== 'object') return;
|
|
81
|
+
const id = msg.id;
|
|
82
|
+
const method = msg.method;
|
|
83
|
+
const params = msg.params || {};
|
|
84
|
+
|
|
85
|
+
// Notifications carry no id and must never be answered.
|
|
86
|
+
if (id === undefined || id === null) return;
|
|
87
|
+
|
|
88
|
+
switch (method) {
|
|
89
|
+
case 'initialize':
|
|
90
|
+
return writeMessage({
|
|
91
|
+
jsonrpc: '2.0',
|
|
92
|
+
id,
|
|
93
|
+
result: {
|
|
94
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
95
|
+
serverInfo: SERVER_INFO,
|
|
96
|
+
capabilities: { tools: {} },
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
case 'tools/list':
|
|
101
|
+
return writeMessage({ jsonrpc: '2.0', id, result: { tools: TOOLS } });
|
|
102
|
+
|
|
103
|
+
case 'tools/call': {
|
|
104
|
+
const name = params.name;
|
|
105
|
+
const args = params.arguments || {};
|
|
106
|
+
try {
|
|
107
|
+
if (name === 'ping') {
|
|
108
|
+
return writeMessage({ jsonrpc: '2.0', id, result: textResult('pong') });
|
|
109
|
+
}
|
|
110
|
+
if (name === 'add') {
|
|
111
|
+
const a = Number(args.a);
|
|
112
|
+
const b = Number(args.b);
|
|
113
|
+
if (!isFinite(a) || !isFinite(b)) {
|
|
114
|
+
return writeMessage({ jsonrpc: '2.0', id, result: textResult('add requires numeric a and b', true) });
|
|
115
|
+
}
|
|
116
|
+
return writeMessage({ jsonrpc: '2.0', id, result: textResult(String(a + b)) });
|
|
117
|
+
}
|
|
118
|
+
if (name === 'echo') {
|
|
119
|
+
const text = typeof args.text === 'string' ? args.text : JSON.stringify(args.text);
|
|
120
|
+
return writeMessage({ jsonrpc: '2.0', id, result: textResult(text) });
|
|
121
|
+
}
|
|
122
|
+
if (name === 'crash') {
|
|
123
|
+
// Simulate an upstream crash: exit WITHOUT replying. The gateway's in-flight request
|
|
124
|
+
// rejects and its McpClient.onClose fires → the aggregator schedules a background
|
|
125
|
+
// reconnect. Exercised by reconnect.test.js.
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
return writeMessage({ jsonrpc: '2.0', id, result: textResult('unknown tool: ' + name, true) });
|
|
129
|
+
} catch (err) {
|
|
130
|
+
return writeMessage({ jsonrpc: '2.0', id, result: textResult('error: ' + ((err && err.message) || err), true) });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
default:
|
|
135
|
+
// Unknown method -> a well-formed JSON-RPC method-not-found error.
|
|
136
|
+
return writeMessage({
|
|
137
|
+
jsonrpc: '2.0',
|
|
138
|
+
id,
|
|
139
|
+
error: { code: -32601, message: 'Method not found: ' + method },
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── Newline-delimited JSON reader over stdin (the MCP stdio framing) ─────────────────
|
|
145
|
+
let buffer = '';
|
|
146
|
+
|
|
147
|
+
process.stdin.on('data', (chunk) => {
|
|
148
|
+
buffer += chunk.toString('utf8');
|
|
149
|
+
// Drain every complete line currently buffered (one JSON-RPC message per line).
|
|
150
|
+
let nl;
|
|
151
|
+
while ((nl = buffer.indexOf('\n')) !== -1) {
|
|
152
|
+
const line = buffer.slice(0, nl).replace(/\r$/, '').trim();
|
|
153
|
+
buffer = buffer.slice(nl + 1);
|
|
154
|
+
if (line.length === 0) continue; // blank separator line — ignore
|
|
155
|
+
let msg;
|
|
156
|
+
try {
|
|
157
|
+
msg = JSON.parse(line);
|
|
158
|
+
} catch (_e) {
|
|
159
|
+
continue; // skip an unparseable line rather than crash
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
handleMessage(msg);
|
|
163
|
+
} catch (_e) {
|
|
164
|
+
/* a handler must never take the server down */
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
process.stdin.on('end', () => process.exit(0));
|
|
170
|
+
process.stdin.resume();
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "toolfunnel",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "A zero-dependency MCP gateway: host your own tools, forward and curate tools from other MCP servers, expose them leanly to cut agent token cost, and gate every call through your own policy hooks before it runs.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"mcp",
|
|
7
|
+
"model-context-protocol",
|
|
8
|
+
"mcp-gateway",
|
|
9
|
+
"mcp-proxy",
|
|
10
|
+
"mcp-server",
|
|
11
|
+
"ai",
|
|
12
|
+
"agents",
|
|
13
|
+
"llm",
|
|
14
|
+
"tools",
|
|
15
|
+
"claude",
|
|
16
|
+
"anthropic",
|
|
17
|
+
"aggregator",
|
|
18
|
+
"token-efficiency"
|
|
19
|
+
],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"type": "commonjs",
|
|
22
|
+
"main": "src/mcp/server.js",
|
|
23
|
+
"bin": {
|
|
24
|
+
"toolfunnel": "bin/toolfunnel.js"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"start": "node bin/toolfunnel.js",
|
|
31
|
+
"http": "node bin/toolfunnel.js --http",
|
|
32
|
+
"install-oauth": "node bin/toolfunnel.js install-oauth",
|
|
33
|
+
"test": "node test/run-all.js",
|
|
34
|
+
"test:integration": "node test/integration-real-mcp.test.js && node test/integration-http-client.test.js"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {},
|
|
37
|
+
"author": "rendeverance",
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/Rendeverance/toolfunnel.git"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/Rendeverance/toolfunnel#readme",
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/Rendeverance/toolfunnel/issues"
|
|
45
|
+
},
|
|
46
|
+
"files": [
|
|
47
|
+
"bin/",
|
|
48
|
+
"src/",
|
|
49
|
+
"tools/tools.register.json",
|
|
50
|
+
"tools/scripts/",
|
|
51
|
+
"mcp/expose.example.json",
|
|
52
|
+
"mcp/servers/",
|
|
53
|
+
"hooks/hooks.manifest.json",
|
|
54
|
+
"hooks/scripts/",
|
|
55
|
+
"docs/",
|
|
56
|
+
"README.md",
|
|
57
|
+
"LICENSE"
|
|
58
|
+
],
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
61
|
+
"jose": "^5.10.0"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* auth/config.js — the toggle + settings for OPTIONAL OAuth 2.1 resource-server validation.
|
|
5
|
+
*
|
|
6
|
+
* DEFAULT OFF (the gateway is loopback-only and unauthenticated until you turn this on). Nothing
|
|
7
|
+
* is read or required unless auth has been explicitly enabled via setConfig(). A MISSING config
|
|
8
|
+
* file means disabled — the safe default — so a fresh checkout authenticates nothing and pulls in
|
|
9
|
+
* no dependency.
|
|
10
|
+
*
|
|
11
|
+
* Design rules (mirrors src/core/logger.js — the proven config pattern):
|
|
12
|
+
* - Zero RUNTIME dependencies. Node built-ins only (fs, path). The jose library is pulled in ONLY
|
|
13
|
+
* by the validator (src/auth/resource-server.js), ONLY when auth is enabled, and is installed
|
|
14
|
+
* on demand (CLI `install-oauth` / the UI button). This module never touches jose.
|
|
15
|
+
* - Config is read FRESH on every call (no caching), so a toggle/edit takes effect for the very
|
|
16
|
+
* next request without a reconnect or restart — exactly like the logger + tool-state overlays.
|
|
17
|
+
* - Nothing here throws. A bad/unreadable/malformed config degrades to the safe default (disabled)
|
|
18
|
+
* so a misconfiguration can never crash the transport.
|
|
19
|
+
*
|
|
20
|
+
* Config file: <root>/auth/auth.config.json (NOT created until setConfig() writes it)
|
|
21
|
+
* {
|
|
22
|
+
* "enabled": false,
|
|
23
|
+
* "issuer": "https://auth.example.com", // the OAuth 2.1 / OIDC authorization server
|
|
24
|
+
* "audience": "https://gateway.example.com", // THIS gateway's resource URI — RFC 8707 audience
|
|
25
|
+
* // binding; the confused-deputy defence
|
|
26
|
+
* "jwksUri": "https://auth.example.com/.well-known/jwks.json", // explicit JWKS; else derived
|
|
27
|
+
* // from the issuer via OIDC discovery
|
|
28
|
+
* "algorithms": ["RS256", "ES256"], // the PINNED signature-algorithm allowlist
|
|
29
|
+
* "requiredScopes": [], // optional — every listed scope must be present
|
|
30
|
+
* "clockToleranceSec": 30 // exp/nbf skew tolerance (seconds)
|
|
31
|
+
* }
|
|
32
|
+
*
|
|
33
|
+
* CommonJS only.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const fs = require('node:fs');
|
|
37
|
+
const path = require('node:path');
|
|
38
|
+
|
|
39
|
+
/** Repo root: <root>/src/auth/config.js -> two dirs up. */
|
|
40
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
41
|
+
|
|
42
|
+
/** The toggle/config file. NOT created until setConfig() writes it. */
|
|
43
|
+
const CONFIG_PATH = path.join(ROOT, 'auth', 'auth.config.json');
|
|
44
|
+
|
|
45
|
+
/** Safe defaults — used whenever the config file is absent or unreadable. */
|
|
46
|
+
const DEFAULTS = Object.freeze({
|
|
47
|
+
enabled: false,
|
|
48
|
+
issuer: '',
|
|
49
|
+
audience: '',
|
|
50
|
+
jwksUri: '',
|
|
51
|
+
algorithms: ['RS256', 'ES256'],
|
|
52
|
+
requiredScopes: [],
|
|
53
|
+
clockToleranceSec: 30,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/** A string field, defaulting to '' when not a non-empty string. */
|
|
57
|
+
function str(v, dflt) {
|
|
58
|
+
return typeof v === 'string' && v.length > 0 ? v : dflt;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A string[] field — keep only non-empty strings; fall back to `dflt` when not a usable array. */
|
|
62
|
+
function strArray(v, dflt) {
|
|
63
|
+
if (!Array.isArray(v)) return dflt.slice();
|
|
64
|
+
const out = v.filter((x) => typeof x === 'string' && x.length > 0);
|
|
65
|
+
return out.length > 0 ? out : dflt.slice();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* getConfig — the resolved auth config. Reads the file fresh; a missing/unreadable/malformed file
|
|
70
|
+
* resolves to the safe defaults (disabled). NEVER throws.
|
|
71
|
+
*
|
|
72
|
+
* @returns {{ enabled:boolean, issuer:string, audience:string, jwksUri:string,
|
|
73
|
+
* algorithms:string[], requiredScopes:string[], clockToleranceSec:number }}
|
|
74
|
+
*/
|
|
75
|
+
function getConfig() {
|
|
76
|
+
try {
|
|
77
|
+
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
|
78
|
+
const parsed = JSON.parse(raw);
|
|
79
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
80
|
+
return cloneDefaults();
|
|
81
|
+
}
|
|
82
|
+
const clock = Number(parsed.clockToleranceSec);
|
|
83
|
+
return {
|
|
84
|
+
enabled: parsed.enabled === true,
|
|
85
|
+
issuer: str(parsed.issuer, DEFAULTS.issuer),
|
|
86
|
+
audience: str(parsed.audience, DEFAULTS.audience),
|
|
87
|
+
jwksUri: str(parsed.jwksUri, DEFAULTS.jwksUri),
|
|
88
|
+
algorithms: strArray(parsed.algorithms, DEFAULTS.algorithms),
|
|
89
|
+
requiredScopes: strArray(parsed.requiredScopes, DEFAULTS.requiredScopes),
|
|
90
|
+
// Clamp to a sane, non-negative tolerance; a >5min skew signals a clock problem, not validity.
|
|
91
|
+
clockToleranceSec:
|
|
92
|
+
Number.isFinite(clock) && clock >= 0 ? Math.min(Math.floor(clock), 300) : DEFAULTS.clockToleranceSec,
|
|
93
|
+
};
|
|
94
|
+
} catch (_err) {
|
|
95
|
+
// Missing file = disabled (the safe default); also covers unreadable/malformed.
|
|
96
|
+
return cloneDefaults();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A fresh, mutation-safe copy of the defaults (arrays cloned so a caller can't poison the frozen one). */
|
|
101
|
+
function cloneDefaults() {
|
|
102
|
+
return {
|
|
103
|
+
enabled: DEFAULTS.enabled,
|
|
104
|
+
issuer: DEFAULTS.issuer,
|
|
105
|
+
audience: DEFAULTS.audience,
|
|
106
|
+
jwksUri: DEFAULTS.jwksUri,
|
|
107
|
+
algorithms: DEFAULTS.algorithms.slice(),
|
|
108
|
+
requiredScopes: DEFAULTS.requiredScopes.slice(),
|
|
109
|
+
clockToleranceSec: DEFAULTS.clockToleranceSec,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* configError — a human-readable reason the CURRENT config could not gate requests, or null if it
|
|
115
|
+
* is coherent enough to enforce. Used by the transport + UI to fail CLOSED with a clear message
|
|
116
|
+
* rather than silently mis-validating. Only meaningful when `enabled` is true.
|
|
117
|
+
*
|
|
118
|
+
* Enforcement requires: an issuer, an audience (the RFC 8707 confused-deputy defence — a gateway
|
|
119
|
+
* that does not bind tokens to its own resource URI is exploitable), at least one pinned algorithm,
|
|
120
|
+
* and a key source (explicit jwksUri OR an issuer to derive one from via OIDC discovery).
|
|
121
|
+
*
|
|
122
|
+
* @param {object} [cfg] defaults to getConfig()
|
|
123
|
+
* @returns {string|null}
|
|
124
|
+
*/
|
|
125
|
+
function configError(cfg) {
|
|
126
|
+
const c = cfg || getConfig();
|
|
127
|
+
if (!c.enabled) return null; // disabled → nothing to enforce, no error
|
|
128
|
+
if (!c.issuer) return 'auth enabled but "issuer" is not set';
|
|
129
|
+
if (!c.audience) return 'auth enabled but "audience" (this gateway\'s resource URI) is not set — required to bind tokens (RFC 8707)';
|
|
130
|
+
if (!Array.isArray(c.algorithms) || c.algorithms.length === 0) return 'auth enabled but no signature "algorithms" are pinned';
|
|
131
|
+
// (A key source is always available: issuer is mandatory above, and jwksUri is derived from it
|
|
132
|
+
// via OIDC discovery when not given — so no separate jwksUri-or-issuer check is needed here.)
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* setConfig — atomically merge a patch into auth/auth.config.json (temp + rename). Merges with the
|
|
138
|
+
* current resolved config so a partial patch (e.g. { enabled: true }) preserves the other fields.
|
|
139
|
+
* Creates the auth/ dir and the file if absent — this is the ONLY function that creates it.
|
|
140
|
+
*
|
|
141
|
+
* @param {object} patch any subset of the config fields
|
|
142
|
+
* @returns {object} the merged, written config
|
|
143
|
+
*/
|
|
144
|
+
function setConfig(patch) {
|
|
145
|
+
const current = getConfig();
|
|
146
|
+
const p = patch && typeof patch === 'object' && !Array.isArray(patch) ? patch : {};
|
|
147
|
+
|
|
148
|
+
const next = {
|
|
149
|
+
enabled: typeof p.enabled === 'boolean' ? p.enabled : current.enabled,
|
|
150
|
+
issuer: typeof p.issuer === 'string' ? p.issuer : current.issuer,
|
|
151
|
+
audience: typeof p.audience === 'string' ? p.audience : current.audience,
|
|
152
|
+
jwksUri: typeof p.jwksUri === 'string' ? p.jwksUri : current.jwksUri,
|
|
153
|
+
algorithms: Array.isArray(p.algorithms) ? strArray(p.algorithms, current.algorithms) : current.algorithms,
|
|
154
|
+
requiredScopes: Array.isArray(p.requiredScopes)
|
|
155
|
+
? p.requiredScopes.filter((x) => typeof x === 'string' && x.length > 0)
|
|
156
|
+
: current.requiredScopes,
|
|
157
|
+
clockToleranceSec:
|
|
158
|
+
Number.isFinite(Number(p.clockToleranceSec)) && Number(p.clockToleranceSec) >= 0
|
|
159
|
+
? Math.min(Math.floor(Number(p.clockToleranceSec)), 300)
|
|
160
|
+
: current.clockToleranceSec,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
|
|
164
|
+
// Atomic write: unique temp file in the same dir, then rename over the target.
|
|
165
|
+
const tmp = CONFIG_PATH + '.' + process.pid + '.' + Date.now() + '.tmp';
|
|
166
|
+
fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n');
|
|
167
|
+
fs.renameSync(tmp, CONFIG_PATH);
|
|
168
|
+
|
|
169
|
+
return next;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
module.exports = { getConfig, setConfig, configError, CONFIG_PATH, DEFAULTS };
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* auth/install.js — install the OPTIONAL OAuth dependency (`jose`) on demand.
|
|
5
|
+
*
|
|
6
|
+
* The whole point of ToolFunnel's zero-dependency stance is that a default install pulls NOTHING.
|
|
7
|
+
* OAuth is opt-in, so its one library is installed only when a user decides they need it — via the
|
|
8
|
+
* CLI (`toolfunnel install-oauth`) or the admin-UI button, both of which call installJose() here.
|
|
9
|
+
*
|
|
10
|
+
* It shells out to the user's own `npm` to install the PINNED jose version into the gateway
|
|
11
|
+
* package's own node_modules (resolved from this file's location, NOT process.cwd(), so it lands
|
|
12
|
+
* beside the engine wherever ToolFunnel is installed). It uses `--no-save` — the goal is simply to
|
|
13
|
+
* make `require('jose')` resolvable; there is no manifest to update, and this keeps ToolFunnel's
|
|
14
|
+
* own `dependencies: {}` untouched in every install shape.
|
|
15
|
+
*
|
|
16
|
+
* WINDOWS NOTE: do NOT spawn `npm.cmd`. Since the CVE-2024-27980 mitigation, spawning a `.cmd`
|
|
17
|
+
* without a shell throws `EINVAL`; and spawning it WITH a shell is unreliable on portable Node
|
|
18
|
+
* installs — the `npm.cmd` shim misresolves its own path under cmd.exe ("Cannot find module
|
|
19
|
+
* …\node_modules\npm\bin\npm-cli.js"). The robust, cross-platform approach is to run npm's JS entry
|
|
20
|
+
* (`npm-cli.js`, located beside the running node binary) with `process.execPath` directly —
|
|
21
|
+
* `shell:false`, no shim, no PATH lookup, no cwd sensitivity. We fall back to a shell-spawned `npm`
|
|
22
|
+
* only if `npm-cli.js` can't be located; the spec is allowlist-validated so that fallback can't inject.
|
|
23
|
+
*
|
|
24
|
+
* NEVER throws — it resolves to a structured { ok, code, stdout, stderr, message } so the CLI/UI can
|
|
25
|
+
* report the outcome cleanly even when npm is missing or the network is down.
|
|
26
|
+
*
|
|
27
|
+
* CommonJS only; Node built-ins (child_process, path, fs) + the pin from resource-server.js.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const path = require('node:path');
|
|
31
|
+
const fs = require('node:fs');
|
|
32
|
+
const { spawn } = require('node:child_process');
|
|
33
|
+
|
|
34
|
+
const { JOSE_PIN, isJoseInstalled, _resetJoseCache } = require('./resource-server');
|
|
35
|
+
|
|
36
|
+
/** The gateway package root: <root>/src/auth/install.js -> two dirs up. npm runs here so jose lands
|
|
37
|
+
* in the engine's own node_modules regardless of the caller's cwd. */
|
|
38
|
+
const PKG_ROOT = path.resolve(__dirname, '..', '..');
|
|
39
|
+
|
|
40
|
+
/** npm is `npm.cmd` on Windows, `npm` elsewhere. We spawn it as a child rather than require()-ing. */
|
|
41
|
+
const IS_WIN = process.platform === 'win32';
|
|
42
|
+
const NPM_BIN = IS_WIN ? 'npm.cmd' : 'npm';
|
|
43
|
+
|
|
44
|
+
/** A strict allowlist for an npm version spec. Covers what a single-package install actually uses —
|
|
45
|
+
* `name@<exact|caret|tilde|x-range|dist-tag>` and scoped names — and NOTHING a shell could act on, so
|
|
46
|
+
* the fallback shell path can never inject. Comparator/whitespace ranges (`>=1 <2`, `1 || 2`) and the
|
|
47
|
+
* `*` wildcard are deliberately OUT: `> < = | *` and spaces are shell-meaningful. This is fine — the
|
|
48
|
+
* gateway only ever installs the caret-pinned `jose@^5.10.0`. Anything outside the set is refused
|
|
49
|
+
* before npm is ever invoked. */
|
|
50
|
+
const SPEC_RE = /^[A-Za-z0-9@._/^~+-]+$/;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Locate npm's JS entry (`npm-cli.js`) so it can be run with `node` directly, bypassing the
|
|
54
|
+
* `npm.cmd`/`npm` shim. Checks the layouts beside the running node binary (Windows/portable, then
|
|
55
|
+
* POSIX), then the Windows per-user global. Returns the absolute path, or null if not found.
|
|
56
|
+
* @returns {string|null}
|
|
57
|
+
*/
|
|
58
|
+
function findNpmCli() {
|
|
59
|
+
const dir = path.dirname(process.execPath);
|
|
60
|
+
const candidates = [
|
|
61
|
+
path.join(dir, 'node_modules', 'npm', 'bin', 'npm-cli.js'), // Windows / portable
|
|
62
|
+
path.join(dir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'), // POSIX (/usr/bin -> /usr/lib)
|
|
63
|
+
];
|
|
64
|
+
if (process.env.APPDATA) {
|
|
65
|
+
candidates.push(path.join(process.env.APPDATA, 'npm', 'node_modules', 'npm', 'bin', 'npm-cli.js'));
|
|
66
|
+
}
|
|
67
|
+
for (const c of candidates) {
|
|
68
|
+
try { if (fs.existsSync(c)) return c; } catch (_e) { /* ignore */ }
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* installJose — run `npm install jose@<PIN>` in the gateway package root. Resolves a structured
|
|
75
|
+
* result; never rejects. A 5-minute hard timeout guards against a hung registry.
|
|
76
|
+
*
|
|
77
|
+
* @param {object} [opts]
|
|
78
|
+
* @param {number} [opts.timeoutMs] default 300000 (5 min).
|
|
79
|
+
* @param {string} [opts.spec] override the install spec (tests). Default `jose@<JOSE_PIN>`.
|
|
80
|
+
* @returns {Promise<{ ok:boolean, code:(number|null), stdout:string, stderr:string, message:string }>}
|
|
81
|
+
*/
|
|
82
|
+
function installJose(opts) {
|
|
83
|
+
const o = opts || {};
|
|
84
|
+
const spec = typeof o.spec === 'string' && o.spec.length > 0 ? o.spec : `jose@${JOSE_PIN}`;
|
|
85
|
+
const timeoutMs = Number.isFinite(o.timeoutMs) && o.timeoutMs > 0 ? o.timeoutMs : 300000;
|
|
86
|
+
|
|
87
|
+
if (isJoseInstalled()) {
|
|
88
|
+
return Promise.resolve({ ok: true, code: 0, stdout: '', stderr: '', message: `jose already installed (${spec})` });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Refuse a spec carrying anything outside the strict allowlist BEFORE any shell is involved.
|
|
92
|
+
if (!SPEC_RE.test(spec)) {
|
|
93
|
+
return Promise.resolve({ ok: false, code: null, stdout: '', stderr: '', message: `refusing to install: invalid package spec "${spec}"` });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return new Promise((resolve) => {
|
|
97
|
+
let child;
|
|
98
|
+
let stdout = '';
|
|
99
|
+
let stderr = '';
|
|
100
|
+
let settled = false;
|
|
101
|
+
const done = (result) => {
|
|
102
|
+
if (settled) return;
|
|
103
|
+
settled = true;
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
// On ANY failure, attach the guaranteed-correct manual fallback. Auto-install can't be made
|
|
106
|
+
// universal — npm-cli.js sits in different places relative to `node` across install methods
|
|
107
|
+
// (Homebrew/Volta/Docker/odd nvm). But the user definitely has a working npm (they used it to
|
|
108
|
+
// install/run the gateway), so a copyable command they run in their OWN shell always works.
|
|
109
|
+
// The UI + CLI surface result.message, so a failed auto-install becomes an actionable next
|
|
110
|
+
// step rather than a dead end.
|
|
111
|
+
if (result && result.ok !== true) {
|
|
112
|
+
result.manualCommand = 'npm install jose@' + JOSE_PIN;
|
|
113
|
+
result.cwd = PKG_ROOT;
|
|
114
|
+
if (typeof result.message === 'string' && result.message.indexOf('manually') === -1) {
|
|
115
|
+
result.message += ` — or install it manually: run \`${result.manualCommand}\` in ${PKG_ROOT}`;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
resolve(result);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const timer = setTimeout(() => {
|
|
122
|
+
try { if (child) child.kill('SIGKILL'); } catch (_e) { /* ignore */ }
|
|
123
|
+
done({ ok: false, code: null, stdout, stderr, message: `npm install timed out after ${timeoutMs}ms` });
|
|
124
|
+
}, timeoutMs);
|
|
125
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const npmCli = findNpmCli();
|
|
129
|
+
if (npmCli) {
|
|
130
|
+
// PREFERRED: run npm's JS entry with the current node binary — no shell, no .cmd shim, no
|
|
131
|
+
// PATH/cwd sensitivity. This is what actually works on a portable/Windows Node install.
|
|
132
|
+
child = spawn(process.execPath, [npmCli, 'install', spec, '--no-save'], {
|
|
133
|
+
cwd: PKG_ROOT,
|
|
134
|
+
env: process.env,
|
|
135
|
+
windowsHide: true,
|
|
136
|
+
shell: false,
|
|
137
|
+
});
|
|
138
|
+
} else if (IS_WIN) {
|
|
139
|
+
// Fallback (npm-cli.js not found): shell out to npm.cmd. The spec is allowlist-validated
|
|
140
|
+
// above and double-quoted so cmd.exe can't act on the `^` in a caret range or inject.
|
|
141
|
+
child = spawn(`"${NPM_BIN}" install "${spec}" --no-save`, {
|
|
142
|
+
cwd: PKG_ROOT,
|
|
143
|
+
env: process.env,
|
|
144
|
+
windowsHide: true,
|
|
145
|
+
shell: true,
|
|
146
|
+
});
|
|
147
|
+
} else {
|
|
148
|
+
child = spawn(NPM_BIN, ['install', spec, '--no-save'], {
|
|
149
|
+
cwd: PKG_ROOT,
|
|
150
|
+
env: process.env,
|
|
151
|
+
windowsHide: true,
|
|
152
|
+
shell: false,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
} catch (e) {
|
|
156
|
+
return done({ ok: false, code: null, stdout, stderr, message: `failed to spawn npm: ${(e && e.message) || e}` });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (child.stdout) child.stdout.on('data', (d) => { stdout += d.toString(); });
|
|
160
|
+
if (child.stderr) child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
161
|
+
child.on('error', (e) => {
|
|
162
|
+
done({ ok: false, code: null, stdout, stderr, message: `npm not runnable: ${(e && e.message) || e}` });
|
|
163
|
+
});
|
|
164
|
+
child.on('close', (code) => {
|
|
165
|
+
// Clear the lazy cache so a freshly-installed jose is picked up WITHOUT a process restart.
|
|
166
|
+
try { _resetJoseCache(); } catch (_e) { /* ignore */ }
|
|
167
|
+
const ok = code === 0 && isJoseInstalled();
|
|
168
|
+
done({
|
|
169
|
+
ok,
|
|
170
|
+
code,
|
|
171
|
+
stdout,
|
|
172
|
+
stderr,
|
|
173
|
+
message: ok
|
|
174
|
+
? `installed ${spec}`
|
|
175
|
+
: `npm install exited ${code}${isJoseInstalled() ? '' : ' (jose still not loadable)'}`,
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = { installJose, findNpmCli, SPEC_RE, PKG_ROOT, NPM_BIN };
|