toolfunnel 0.5.0 → 0.6.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 +231 -167
- package/bin/toolfunnel.js +372 -147
- package/docs/KNOWN_BUGS.md +99 -33
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +70 -0
- package/docs/packaging.md +115 -115
- package/hooks/hooks.manifest.json +27 -27
- package/hooks/scripts/example-audit.js +21 -21
- package/hooks/scripts/example-deny-dangerous.js +31 -31
- package/mcp/expose.example.json +30 -30
- package/mcp/servers/mock-modern/server.js +147 -0
- package/mcp/servers/mock-upstream/server.js +192 -12
- package/package.json +1 -1
- package/src/auth/config.js +174 -174
- package/src/auth/install.js +181 -181
- package/src/auth/resource-server.js +344 -344
- package/src/core/config-home.js +134 -134
- package/src/core/events.js +105 -105
- package/src/core/hook-engine.js +275 -275
- package/src/core/hook-loader.js +791 -791
- package/src/core/hook-runner.js +346 -346
- package/src/core/logger.js +171 -171
- package/src/core/matcher.js +51 -51
- package/src/core/metrics.js +75 -75
- package/src/core/requires.js +138 -138
- package/src/core/server-config.js +98 -77
- package/src/core/tool-registry.js +180 -180
- package/src/extend/add-hook.md +152 -142
- package/src/extend/add-mcp.md +122 -121
- package/src/extend/configure.md +87 -0
- package/src/extend/create-tool.md +168 -152
- package/src/extend/howto.js +102 -98
- package/src/extend/package.md +187 -187
- package/src/extend/wrap.md +99 -0
- package/src/mcp/aggregator.js +1246 -807
- package/src/mcp/expose-store.js +460 -430
- package/src/mcp/gated-run.js +201 -194
- package/src/mcp/http-transport.js +1311 -1012
- package/src/mcp/mcp-client.js +907 -530
- package/src/mcp/modern.js +372 -0
- package/src/mcp/protocol.js +468 -466
- package/src/mcp/server.js +2688 -1405
- package/src/packages/packager.js +715 -715
- package/src/tools/drift.js +138 -138
- package/src/tools/registry.js +513 -513
- package/src/tools/tool-state.js +170 -104
- package/src/ui/public/app.js +1174 -1055
- package/src/ui/public/index.html +286 -249
- package/src/ui/public/styles.css +535 -535
- package/src/ui/server.js +1348 -1190
- package/tools/scripts/base64.js +139 -139
- package/tools/scripts/danger.js +61 -61
- package/tools/scripts/echo.js +39 -39
- package/tools/scripts/hash.js +101 -101
- package/tools/scripts/json.js +109 -109
- package/tools/scripts/text-stats.js +131 -131
- package/tools/scripts/tf-env.js +43 -43
- package/tools/scripts/tf-hook-add.js +151 -151
- package/tools/scripts/tf-hook-set.js +112 -112
- package/tools/scripts/tf-list.js +192 -192
- package/tools/scripts/tf-log.js +86 -86
- package/tools/scripts/tf-mcp-add.js +117 -117
- package/tools/scripts/tf-mcp-set.js +121 -102
- package/tools/scripts/tf-pack.js +237 -237
- package/tools/scripts/tf-tool-add.js +134 -129
- package/tools/scripts/tf-tool-set.js +142 -142
- package/tools/scripts/tf-wrap.js +103 -0
- package/tools/scripts/uuid.js +102 -102
- package/tools/tools.register.json +227 -216
|
@@ -1,344 +1,344 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* auth/resource-server.js
|
|
5
|
-
*
|
|
6
|
-
* This is the ONLY module that touches `jose`, and it does so with a LAZY require so the core
|
|
7
|
-
* gateway keeps `dependencies: {}` (zero runtime deps). `jose` is installed on demand
|
|
8
|
-
* `toolfunnel install-oauth` or the admin-UI button (see src/auth/install.js)
|
|
9
|
-
* only when auth is enabled. If it is not installed, every entry point fails CLOSED with a clear,
|
|
10
|
-
* actionable message; it never silently allows a request through.
|
|
11
|
-
*
|
|
12
|
-
* Why a library and not hand-rolled node:crypto: validating a JWT securely is not the signature
|
|
13
|
-
* math (node:crypto has that)
|
|
14
|
-
* decisions are (1) PINNING the accepted algorithm so an attacker can't downgrade RS256
|
|
15
|
-
* forge a token by signing with the public key as an HMAC secret, (2) rejecting `alg:none`, (3)
|
|
16
|
-
* ignoring attacker-controlled header params (jku/jwk/x5u), (4) validating iss/aud/exp/nbf, and (5)
|
|
17
|
-
* JWKS caching + key rotation. `jose` encodes all of these into its API; we MUST drive that API
|
|
18
|
-
* correctly
|
|
19
|
-
* confused-deputy defence). `jose` v5's `require` build uses Node's native crypto (Node ≥18, no
|
|
20
|
-
* Web-Crypto-global dependency), which is why it is the pinned major for this CommonJS project.
|
|
21
|
-
*
|
|
22
|
-
* NOTHING here throws out of validate()
|
|
23
|
-
* result, not an exception. CommonJS only; Node built-ins (node:url, global fetch) + lazy jose.
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* The PINNED jose version installed on demand. A caret range so patch/minor security fixes flow,
|
|
28
|
-
* but the major (v5
|
|
29
|
-
* Node ≥22 require-of-ESM floor incompatible with this project's `engines: >=18`) is fixed.
|
|
30
|
-
*/
|
|
31
|
-
const JOSE_PIN = '^5.10.0';
|
|
32
|
-
|
|
33
|
-
/** Lazy jose handle: undefined = not yet attempted, null = attempted + absent, object = loaded. */
|
|
34
|
-
let _jose = undefined;
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* loadJose
|
|
38
|
-
* NEVER throws (a missing/incompatible jose returns null; the caller fails closed with a message).
|
|
39
|
-
* @returns {object|null}
|
|
40
|
-
*/
|
|
41
|
-
function loadJose() {
|
|
42
|
-
if (_jose !== undefined) return _jose;
|
|
43
|
-
try {
|
|
44
|
-
// eslint-disable-next-line global-require
|
|
45
|
-
_jose = require('jose');
|
|
46
|
-
} catch (_e) {
|
|
47
|
-
_jose = null;
|
|
48
|
-
}
|
|
49
|
-
return _jose;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Is jose importable in this runtime? (installed AND loadable as CommonJS). */
|
|
53
|
-
function isJoseInstalled() {
|
|
54
|
-
return loadJose() != null;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** Reset the lazy cache
|
|
58
|
-
function _resetJoseCache() {
|
|
59
|
-
_jose = undefined;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Build the value of a WWW-Authenticate response header for the Bearer scheme. Per RFC 6750 +
|
|
64
|
-
* RFC 9728: a bare challenge (no credentials) omits the error code; an invalid/insufficient token
|
|
65
|
-
* carries error= and error_description=. `resource_metadata` points the client at the protected-
|
|
66
|
-
* resource-metadata document so it can discover the authorization server (RFC 9728).
|
|
67
|
-
*
|
|
68
|
-
* @param {{ resourceMetadataUrl?:string, error?:string, description?:string }} o
|
|
69
|
-
* @returns {string}
|
|
70
|
-
*/
|
|
71
|
-
function buildWwwAuthenticate(o) {
|
|
72
|
-
const parts = [];
|
|
73
|
-
if (o && o.resourceMetadataUrl) parts.push(`resource_metadata="${o.resourceMetadataUrl}"`);
|
|
74
|
-
if (o && o.error) parts.push(`error="${o.error}"`);
|
|
75
|
-
if (o && o.description) parts.push(`error_description="${String(o.description).replace(/"/g, "'")}"`);
|
|
76
|
-
return parts.length ? `Bearer ${parts.join(', ')}` : 'Bearer';
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Extract the bearer token from an Authorization header. Returns the token string, or null if the
|
|
81
|
-
* header is absent/malformed/not the Bearer scheme. Exactly one whitespace-delimited token after a
|
|
82
|
-
* case-insensitive "Bearer" is accepted.
|
|
83
|
-
* @param {string|undefined} authorizationHeader
|
|
84
|
-
* @returns {string|null}
|
|
85
|
-
*/
|
|
86
|
-
function extractBearer(authorizationHeader) {
|
|
87
|
-
if (typeof authorizationHeader !== 'string') return null;
|
|
88
|
-
const m = /^Bearer[ \t]+([^\s]+)[ \t]*$/i.exec(authorizationHeader.trim());
|
|
89
|
-
return m ? m[1] : null;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Resolve the JWKS URI: prefer an explicit cfg.jwksUri, else discover it from the issuer via OIDC
|
|
94
|
-
* Discovery (RFC 8414 / OpenID Connect Discovery). Tries the OIDC well-known first, then the OAuth
|
|
95
|
-
* Authorization Server Metadata well-known. Bounded by a timeout. Returns the URL string or throws
|
|
96
|
-
* a plain Error (the caller turns a throw into a fail-closed unauthorized result).
|
|
97
|
-
*
|
|
98
|
-
* @param {object} cfg the resolved auth config
|
|
99
|
-
* @param {number} [timeoutMs]
|
|
100
|
-
* @returns {Promise<string>}
|
|
101
|
-
*/
|
|
102
|
-
async function resolveJwksUri(cfg, timeoutMs) {
|
|
103
|
-
if (cfg.jwksUri) return cfg.jwksUri;
|
|
104
|
-
if (!cfg.issuer) throw new Error('no jwksUri and no issuer to discover one from');
|
|
105
|
-
|
|
106
|
-
const base = cfg.issuer.replace(/\/+$/, '');
|
|
107
|
-
const candidates = [
|
|
108
|
-
`${base}/.well-known/openid-configuration`,
|
|
109
|
-
`${base}/.well-known/oauth-authorization-server`,
|
|
110
|
-
];
|
|
111
|
-
let lastErr = null;
|
|
112
|
-
for (const url of candidates) {
|
|
113
|
-
try {
|
|
114
|
-
const doc = await fetchJson(url, timeoutMs);
|
|
115
|
-
if (doc && typeof doc.jwks_uri === 'string' && doc.jwks_uri.length > 0) {
|
|
116
|
-
// ORIGIN-PIN the discovered value. The ISSUER is operator-trusted config, but the discovery
|
|
117
|
-
// DOCUMENT arrives over the network
|
|
118
|
-
// foreign origin (key-substitution + SSRF-adjacent surface). Cross-origin JWKS hosting is
|
|
119
|
-
// real (e.g. Google's issuer and JWKS live on different hosts), so the escape hatch is an
|
|
120
|
-
// EXPLICIT trust statement: set "jwksUri" in the auth config and discovery is skipped.
|
|
121
|
-
let sameOrigin = false;
|
|
122
|
-
try {
|
|
123
|
-
sameOrigin = new URL(doc.jwks_uri).origin === new URL(cfg.issuer).origin;
|
|
124
|
-
} catch (_e) {
|
|
125
|
-
/* malformed URL on either side
|
|
126
|
-
}
|
|
127
|
-
if (!sameOrigin) {
|
|
128
|
-
throw new Error(
|
|
129
|
-
`discovered jwks_uri "${doc.jwks_uri}" is not on the issuer origin
|
|
130
|
-
'cross-origin discovery result. If your IdP intentionally hosts JWKS on a separate ' +
|
|
131
|
-
'origin, state that trust explicitly by setting "jwksUri" in auth/auth.config.json.'
|
|
132
|
-
);
|
|
133
|
-
}
|
|
134
|
-
return doc.jwks_uri;
|
|
135
|
-
}
|
|
136
|
-
lastErr = new Error(`discovery doc at ${url} has no jwks_uri`);
|
|
137
|
-
} catch (e) {
|
|
138
|
-
lastErr = e;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
throw lastErr || new Error('OIDC discovery failed');
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* GET + parse JSON with a hard timeout. Throws on non-2xx, network error, bad JSON
|
|
146
|
-
* discovery metadata must come from the exact well-known URL derived from the trusted issuer
|
|
147
|
-
* (following a redirect would let a bounced endpoint serve the document from anywhere, hollowing
|
|
148
|
-
* out the origin pin above). Only discovery uses this helper; the JWKS fetch itself is jose's.
|
|
149
|
-
*/
|
|
150
|
-
async function fetchJson(url, timeoutMs) {
|
|
151
|
-
const ac = new AbortController();
|
|
152
|
-
const ms = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 10000;
|
|
153
|
-
const t = setTimeout(() => ac.abort(), ms);
|
|
154
|
-
try {
|
|
155
|
-
const res = await fetch(url, { signal: ac.signal, redirect: 'error' });
|
|
156
|
-
if (!res || !res.ok) throw new Error(`GET ${url} -> HTTP ${res ? res.status : 'no-response'}`);
|
|
157
|
-
return await res.json();
|
|
158
|
-
} finally {
|
|
159
|
-
clearTimeout(t);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Collect the scopes carried by a verified token: the space-delimited `scope` claim (RFC 8693) or
|
|
165
|
-
* an array `scp` claim (some issuers). Returns a Set of scope strings.
|
|
166
|
-
* @param {object} claims
|
|
167
|
-
* @returns {Set<string>}
|
|
168
|
-
*/
|
|
169
|
-
function scopesOf(claims) {
|
|
170
|
-
const out = new Set();
|
|
171
|
-
if (claims && typeof claims.scope === 'string') {
|
|
172
|
-
for (const s of claims.scope.split(/\s+/)) if (s) out.add(s);
|
|
173
|
-
}
|
|
174
|
-
if (claims && Array.isArray(claims.scp)) {
|
|
175
|
-
for (const s of claims.scp) if (typeof s === 'string' && s) out.add(s);
|
|
176
|
-
}
|
|
177
|
-
return out;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* createValidator
|
|
182
|
-
*
|
|
183
|
-
* @param {object} cfg the resolved config (see auth/config.js getConfig()).
|
|
184
|
-
* @param {object} [opts]
|
|
185
|
-
* @param {string} [opts.resourceMetadataUrl] the absolute URL of this gateway's protected-
|
|
186
|
-
* resource-metadata document, embedded in 401
|
|
187
|
-
* WWW-Authenticate challenges (RFC 9728).
|
|
188
|
-
* @param {number} [opts.discoveryTimeoutMs] OIDC discovery + JWKS fetch timeout (default 10s).
|
|
189
|
-
* @param {Function} [opts.log] optional (msg)=>void for server-side diagnostics
|
|
190
|
-
* (the real reason is logged, NOT leaked to clients).
|
|
191
|
-
* @returns {{ validate:(authHeader:string|undefined)=>Promise<object>, unauthorized:Function,
|
|
192
|
-
* jwksReady:()=>boolean }}
|
|
193
|
-
*/
|
|
194
|
-
function createValidator(cfg, opts) {
|
|
195
|
-
const o = opts || {};
|
|
196
|
-
const resourceMetadataUrl = typeof o.resourceMetadataUrl === 'string' ? o.resourceMetadataUrl : '';
|
|
197
|
-
const log = typeof o.log === 'function' ? o.log : () => {};
|
|
198
|
-
const discoveryTimeoutMs = Number.isFinite(o.discoveryTimeoutMs) ? o.discoveryTimeoutMs : 10000;
|
|
199
|
-
|
|
200
|
-
// The JWKS resolver is built ONCE, lazily, on first validate
|
|
201
|
-
// createRemoteJWKSet owns the HTTP fetch, in-memory cache, cooldown, and refetch-on-unknown-kid
|
|
202
|
-
// (key rotation)
|
|
203
|
-
// transient discovery outage self-heals.
|
|
204
|
-
let jwks = null;
|
|
205
|
-
let jwksBuildPromise = null;
|
|
206
|
-
|
|
207
|
-
function unauthorized(status, error, description, logReason) {
|
|
208
|
-
if (logReason) log(`auth reject: ${logReason}`);
|
|
209
|
-
return {
|
|
210
|
-
ok: false,
|
|
211
|
-
status: status || 401,
|
|
212
|
-
error: error || 'invalid_token',
|
|
213
|
-
errorDescription: description || '',
|
|
214
|
-
wwwAuthenticate: buildWwwAuthenticate({ resourceMetadataUrl, error, description }),
|
|
215
|
-
};
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
async function ensureJwks() {
|
|
219
|
-
if (jwks) return jwks;
|
|
220
|
-
if (!jwksBuildPromise) {
|
|
221
|
-
jwksBuildPromise = (async () => {
|
|
222
|
-
const jose = loadJose();
|
|
223
|
-
if (!jose) throw new Error('jose is not installed');
|
|
224
|
-
const uri = await resolveJwksUri(cfg, discoveryTimeoutMs);
|
|
225
|
-
// cooldownDuration throttles refetches on unknown-kid; cacheMaxAge bounds key staleness.
|
|
226
|
-
return jose.createRemoteJWKSet(new URL(uri), { cooldownDuration: 30000, cacheMaxAge: 600000 });
|
|
227
|
-
})().catch((e) => {
|
|
228
|
-
jwksBuildPromise = null; // allow a retry on the next request (self-heal)
|
|
229
|
-
throw e;
|
|
230
|
-
});
|
|
231
|
-
}
|
|
232
|
-
jwks = await jwksBuildPromise;
|
|
233
|
-
return jwks;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
async function validate(authorizationHeader) {
|
|
237
|
-
// Fail closed if the dependency or configuration is not coherent
|
|
238
|
-
const jose = loadJose();
|
|
239
|
-
if (!jose) {
|
|
240
|
-
return unauthorized(500, 'server_error', 'authentication unavailable', 'jose not installed');
|
|
241
|
-
}
|
|
242
|
-
if (!cfg.audience) {
|
|
243
|
-
// Without an audience we cannot bind the token to THIS resource (RFC 8707)
|
|
244
|
-
// than validate an unbound token (the confused-deputy hole). Belt-and-braces: the transport
|
|
245
|
-
// refuses to start in this state, but a request must still never slip through.
|
|
246
|
-
return unauthorized(500, 'server_error', 'authentication misconfigured', 'no audience configured');
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
const token = extractBearer(authorizationHeader);
|
|
250
|
-
if (!token) {
|
|
251
|
-
// No credentials
|
|
252
|
-
return unauthorized(401, '', '', 'missing or malformed Authorization header');
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
let resolver;
|
|
256
|
-
try {
|
|
257
|
-
resolver = await ensureJwks();
|
|
258
|
-
} catch (e) {
|
|
259
|
-
// Cannot reach/parse the key source
|
|
260
|
-
return unauthorized(401, 'invalid_token', 'unable to verify token', `jwks unavailable: ${(e && e.message) || e}`);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
let result;
|
|
264
|
-
try {
|
|
265
|
-
// The security-critical call. algorithms PINS the allowlist (blocks alg-confusion + alg:none);
|
|
266
|
-
// issuer + audience are ENFORCED by jose; clockTolerance bounds exp/nbf skew. We never read the
|
|
267
|
-
// token header to choose the algorithm
|
|
268
|
-
result = await jose.jwtVerify(token, resolver, {
|
|
269
|
-
issuer: cfg.issuer || undefined,
|
|
270
|
-
audience: cfg.audience,
|
|
271
|
-
algorithms: cfg.algorithms,
|
|
272
|
-
clockTolerance: cfg.clockToleranceSec,
|
|
273
|
-
// Require `exp` to be PRESENT
|
|
274
|
-
// token that omits exp would be accepted as never-expiring. RFC 9068 access tokens carry
|
|
275
|
-
// exp; a resource server should reject one that does not.
|
|
276
|
-
requiredClaims: ['exp'],
|
|
277
|
-
});
|
|
278
|
-
} catch (e) {
|
|
279
|
-
return unauthorized(401, 'invalid_token', 'token verification failed', `jwtVerify: ${(e && e.code) || (e && e.message) || e}`);
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
const claims = result && result.payload ? result.payload : {};
|
|
283
|
-
|
|
284
|
-
// Optional scope enforcement
|
|
285
|
-
if (Array.isArray(cfg.requiredScopes) && cfg.requiredScopes.length > 0) {
|
|
286
|
-
const have = scopesOf(claims);
|
|
287
|
-
const missing = cfg.requiredScopes.filter((s) => !have.has(s));
|
|
288
|
-
if (missing.length > 0) {
|
|
289
|
-
return {
|
|
290
|
-
ok: false,
|
|
291
|
-
status: 403,
|
|
292
|
-
error: 'insufficient_scope',
|
|
293
|
-
errorDescription: `missing scope: ${missing.join(' ')}`,
|
|
294
|
-
wwwAuthenticate: buildWwwAuthenticate({
|
|
295
|
-
resourceMetadataUrl,
|
|
296
|
-
error: 'insufficient_scope',
|
|
297
|
-
description: `missing scope: ${missing.join(' ')}`,
|
|
298
|
-
}),
|
|
299
|
-
};
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
return {
|
|
304
|
-
ok: true,
|
|
305
|
-
sub: typeof claims.sub === 'string' ? claims.sub : undefined,
|
|
306
|
-
claims,
|
|
307
|
-
scopes: Array.from(scopesOf(claims)),
|
|
308
|
-
};
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
return { validate, unauthorized, jwksReady: () => jwks != null };
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/**
|
|
315
|
-
* protectedResourceMetadata
|
|
316
|
-
* Served (unauthenticated) at /.well-known/oauth-protected-resource so a client can discover which
|
|
317
|
-
* authorization server issues tokens for this resource. NEVER throws.
|
|
318
|
-
* @param {object} cfg the resolved auth config
|
|
319
|
-
* @returns {{ resource:string, authorization_servers:string[], bearer_methods_supported:string[],
|
|
320
|
-
* scopes_supported?:string[] }}
|
|
321
|
-
*/
|
|
322
|
-
function protectedResourceMetadata(cfg) {
|
|
323
|
-
const doc = {
|
|
324
|
-
resource: (cfg && cfg.audience) || '',
|
|
325
|
-
authorization_servers: cfg && cfg.issuer ? [cfg.issuer] : [],
|
|
326
|
-
bearer_methods_supported: ['header'],
|
|
327
|
-
};
|
|
328
|
-
if (cfg && Array.isArray(cfg.requiredScopes) && cfg.requiredScopes.length > 0) {
|
|
329
|
-
doc.scopes_supported = cfg.requiredScopes.slice();
|
|
330
|
-
}
|
|
331
|
-
return doc;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
module.exports = {
|
|
335
|
-
JOSE_PIN,
|
|
336
|
-
isJoseInstalled,
|
|
337
|
-
createValidator,
|
|
338
|
-
protectedResourceMetadata,
|
|
339
|
-
buildWwwAuthenticate,
|
|
340
|
-
extractBearer,
|
|
341
|
-
scopesOf,
|
|
342
|
-
resolveJwksUri,
|
|
343
|
-
_resetJoseCache,
|
|
344
|
-
};
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* auth/resource-server.js - OPTIONAL OAuth 2.1 resource-server token validation.
|
|
5
|
+
*
|
|
6
|
+
* This is the ONLY module that touches `jose`, and it does so with a LAZY require so the core
|
|
7
|
+
* gateway keeps `dependencies: {}` (zero runtime deps). `jose` is installed on demand - the CLI
|
|
8
|
+
* `toolfunnel install-oauth` or the admin-UI button (see src/auth/install.js) - and pulled in here
|
|
9
|
+
* only when auth is enabled. If it is not installed, every entry point fails CLOSED with a clear,
|
|
10
|
+
* actionable message; it never silently allows a request through.
|
|
11
|
+
*
|
|
12
|
+
* Why a library and not hand-rolled node:crypto: validating a JWT securely is not the signature
|
|
13
|
+
* math (node:crypto has that) - it is the protocol discipline around it. The dangerous, CVE-prone
|
|
14
|
+
* decisions are (1) PINNING the accepted algorithm so an attacker can't downgrade RS256->HS256 and
|
|
15
|
+
* forge a token by signing with the public key as an HMAC secret, (2) rejecting `alg:none`, (3)
|
|
16
|
+
* ignoring attacker-controlled header params (jku/jwk/x5u), (4) validating iss/aud/exp/nbf, and (5)
|
|
17
|
+
* JWKS caching + key rotation. `jose` encodes all of these into its API; we MUST drive that API
|
|
18
|
+
* correctly - above all, ALWAYS pass the `algorithms` allowlist and the `audience` (the RFC 8707
|
|
19
|
+
* confused-deputy defence). `jose` v5's `require` build uses Node's native crypto (Node ≥18, no
|
|
20
|
+
* Web-Crypto-global dependency), which is why it is the pinned major for this CommonJS project.
|
|
21
|
+
*
|
|
22
|
+
* NOTHING here throws out of validate() - a verification failure is a structured unauthorized
|
|
23
|
+
* result, not an exception. CommonJS only; Node built-ins (node:url, global fetch) + lazy jose.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The PINNED jose version installed on demand. A caret range so patch/minor security fixes flow,
|
|
28
|
+
* but the major (v5 - the CommonJS, Node-native-crypto build; v6 is ESM-only and would force a
|
|
29
|
+
* Node ≥22 require-of-ESM floor incompatible with this project's `engines: >=18`) is fixed.
|
|
30
|
+
*/
|
|
31
|
+
const JOSE_PIN = '^5.10.0';
|
|
32
|
+
|
|
33
|
+
/** Lazy jose handle: undefined = not yet attempted, null = attempted + absent, object = loaded. */
|
|
34
|
+
let _jose = undefined;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* loadJose - lazily require('jose'), cached. Returns the module or null if it is not installed.
|
|
38
|
+
* NEVER throws (a missing/incompatible jose returns null; the caller fails closed with a message).
|
|
39
|
+
* @returns {object|null}
|
|
40
|
+
*/
|
|
41
|
+
function loadJose() {
|
|
42
|
+
if (_jose !== undefined) return _jose;
|
|
43
|
+
try {
|
|
44
|
+
// eslint-disable-next-line global-require
|
|
45
|
+
_jose = require('jose');
|
|
46
|
+
} catch (_e) {
|
|
47
|
+
_jose = null;
|
|
48
|
+
}
|
|
49
|
+
return _jose;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Is jose importable in this runtime? (installed AND loadable as CommonJS). */
|
|
53
|
+
function isJoseInstalled() {
|
|
54
|
+
return loadJose() != null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Reset the lazy cache - used by tests and by the UI right after an on-demand install. */
|
|
58
|
+
function _resetJoseCache() {
|
|
59
|
+
_jose = undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build the value of a WWW-Authenticate response header for the Bearer scheme. Per RFC 6750 +
|
|
64
|
+
* RFC 9728: a bare challenge (no credentials) omits the error code; an invalid/insufficient token
|
|
65
|
+
* carries error= and error_description=. `resource_metadata` points the client at the protected-
|
|
66
|
+
* resource-metadata document so it can discover the authorization server (RFC 9728).
|
|
67
|
+
*
|
|
68
|
+
* @param {{ resourceMetadataUrl?:string, error?:string, description?:string }} o
|
|
69
|
+
* @returns {string}
|
|
70
|
+
*/
|
|
71
|
+
function buildWwwAuthenticate(o) {
|
|
72
|
+
const parts = [];
|
|
73
|
+
if (o && o.resourceMetadataUrl) parts.push(`resource_metadata="${o.resourceMetadataUrl}"`);
|
|
74
|
+
if (o && o.error) parts.push(`error="${o.error}"`);
|
|
75
|
+
if (o && o.description) parts.push(`error_description="${String(o.description).replace(/"/g, "'")}"`);
|
|
76
|
+
return parts.length ? `Bearer ${parts.join(', ')}` : 'Bearer';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Extract the bearer token from an Authorization header. Returns the token string, or null if the
|
|
81
|
+
* header is absent/malformed/not the Bearer scheme. Exactly one whitespace-delimited token after a
|
|
82
|
+
* case-insensitive "Bearer" is accepted.
|
|
83
|
+
* @param {string|undefined} authorizationHeader
|
|
84
|
+
* @returns {string|null}
|
|
85
|
+
*/
|
|
86
|
+
function extractBearer(authorizationHeader) {
|
|
87
|
+
if (typeof authorizationHeader !== 'string') return null;
|
|
88
|
+
const m = /^Bearer[ \t]+([^\s]+)[ \t]*$/i.exec(authorizationHeader.trim());
|
|
89
|
+
return m ? m[1] : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Resolve the JWKS URI: prefer an explicit cfg.jwksUri, else discover it from the issuer via OIDC
|
|
94
|
+
* Discovery (RFC 8414 / OpenID Connect Discovery). Tries the OIDC well-known first, then the OAuth
|
|
95
|
+
* Authorization Server Metadata well-known. Bounded by a timeout. Returns the URL string or throws
|
|
96
|
+
* a plain Error (the caller turns a throw into a fail-closed unauthorized result).
|
|
97
|
+
*
|
|
98
|
+
* @param {object} cfg the resolved auth config
|
|
99
|
+
* @param {number} [timeoutMs]
|
|
100
|
+
* @returns {Promise<string>}
|
|
101
|
+
*/
|
|
102
|
+
async function resolveJwksUri(cfg, timeoutMs) {
|
|
103
|
+
if (cfg.jwksUri) return cfg.jwksUri;
|
|
104
|
+
if (!cfg.issuer) throw new Error('no jwksUri and no issuer to discover one from');
|
|
105
|
+
|
|
106
|
+
const base = cfg.issuer.replace(/\/+$/, '');
|
|
107
|
+
const candidates = [
|
|
108
|
+
`${base}/.well-known/openid-configuration`,
|
|
109
|
+
`${base}/.well-known/oauth-authorization-server`,
|
|
110
|
+
];
|
|
111
|
+
let lastErr = null;
|
|
112
|
+
for (const url of candidates) {
|
|
113
|
+
try {
|
|
114
|
+
const doc = await fetchJson(url, timeoutMs);
|
|
115
|
+
if (doc && typeof doc.jwks_uri === 'string' && doc.jwks_uri.length > 0) {
|
|
116
|
+
// ORIGIN-PIN the discovered value. The ISSUER is operator-trusted config, but the discovery
|
|
117
|
+
// DOCUMENT arrives over the network - it must not be able to point key fetching at a
|
|
118
|
+
// foreign origin (key-substitution + SSRF-adjacent surface). Cross-origin JWKS hosting is
|
|
119
|
+
// real (e.g. Google's issuer and JWKS live on different hosts), so the escape hatch is an
|
|
120
|
+
// EXPLICIT trust statement: set "jwksUri" in the auth config and discovery is skipped.
|
|
121
|
+
let sameOrigin = false;
|
|
122
|
+
try {
|
|
123
|
+
sameOrigin = new URL(doc.jwks_uri).origin === new URL(cfg.issuer).origin;
|
|
124
|
+
} catch (_e) {
|
|
125
|
+
/* malformed URL on either side -> treat as cross-origin -> refuse */
|
|
126
|
+
}
|
|
127
|
+
if (!sameOrigin) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`discovered jwks_uri "${doc.jwks_uri}" is not on the issuer origin - refusing the ` +
|
|
130
|
+
'cross-origin discovery result. If your IdP intentionally hosts JWKS on a separate ' +
|
|
131
|
+
'origin, state that trust explicitly by setting "jwksUri" in auth/auth.config.json.'
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return doc.jwks_uri;
|
|
135
|
+
}
|
|
136
|
+
lastErr = new Error(`discovery doc at ${url} has no jwks_uri`);
|
|
137
|
+
} catch (e) {
|
|
138
|
+
lastErr = e;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
throw lastErr || new Error('OIDC discovery failed');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* GET + parse JSON with a hard timeout. Throws on non-2xx, network error, bad JSON - or a REDIRECT:
|
|
146
|
+
* discovery metadata must come from the exact well-known URL derived from the trusted issuer
|
|
147
|
+
* (following a redirect would let a bounced endpoint serve the document from anywhere, hollowing
|
|
148
|
+
* out the origin pin above). Only discovery uses this helper; the JWKS fetch itself is jose's.
|
|
149
|
+
*/
|
|
150
|
+
async function fetchJson(url, timeoutMs) {
|
|
151
|
+
const ac = new AbortController();
|
|
152
|
+
const ms = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 10000;
|
|
153
|
+
const t = setTimeout(() => ac.abort(), ms);
|
|
154
|
+
try {
|
|
155
|
+
const res = await fetch(url, { signal: ac.signal, redirect: 'error' });
|
|
156
|
+
if (!res || !res.ok) throw new Error(`GET ${url} -> HTTP ${res ? res.status : 'no-response'}`);
|
|
157
|
+
return await res.json();
|
|
158
|
+
} finally {
|
|
159
|
+
clearTimeout(t);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Collect the scopes carried by a verified token: the space-delimited `scope` claim (RFC 8693) or
|
|
165
|
+
* an array `scp` claim (some issuers). Returns a Set of scope strings.
|
|
166
|
+
* @param {object} claims
|
|
167
|
+
* @returns {Set<string>}
|
|
168
|
+
*/
|
|
169
|
+
function scopesOf(claims) {
|
|
170
|
+
const out = new Set();
|
|
171
|
+
if (claims && typeof claims.scope === 'string') {
|
|
172
|
+
for (const s of claims.scope.split(/\s+/)) if (s) out.add(s);
|
|
173
|
+
}
|
|
174
|
+
if (claims && Array.isArray(claims.scp)) {
|
|
175
|
+
for (const s of claims.scp) if (typeof s === 'string' && s) out.add(s);
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* createValidator - build a token validator from a resolved auth config.
|
|
182
|
+
*
|
|
183
|
+
* @param {object} cfg the resolved config (see auth/config.js getConfig()).
|
|
184
|
+
* @param {object} [opts]
|
|
185
|
+
* @param {string} [opts.resourceMetadataUrl] the absolute URL of this gateway's protected-
|
|
186
|
+
* resource-metadata document, embedded in 401
|
|
187
|
+
* WWW-Authenticate challenges (RFC 9728).
|
|
188
|
+
* @param {number} [opts.discoveryTimeoutMs] OIDC discovery + JWKS fetch timeout (default 10s).
|
|
189
|
+
* @param {Function} [opts.log] optional (msg)=>void for server-side diagnostics
|
|
190
|
+
* (the real reason is logged, NOT leaked to clients).
|
|
191
|
+
* @returns {{ validate:(authHeader:string|undefined)=>Promise<object>, unauthorized:Function,
|
|
192
|
+
* jwksReady:()=>boolean }}
|
|
193
|
+
*/
|
|
194
|
+
function createValidator(cfg, opts) {
|
|
195
|
+
const o = opts || {};
|
|
196
|
+
const resourceMetadataUrl = typeof o.resourceMetadataUrl === 'string' ? o.resourceMetadataUrl : '';
|
|
197
|
+
const log = typeof o.log === 'function' ? o.log : () => {};
|
|
198
|
+
const discoveryTimeoutMs = Number.isFinite(o.discoveryTimeoutMs) ? o.discoveryTimeoutMs : 10000;
|
|
199
|
+
|
|
200
|
+
// The JWKS resolver is built ONCE, lazily, on first validate - memoised across calls. jose's
|
|
201
|
+
// createRemoteJWKSet owns the HTTP fetch, in-memory cache, cooldown, and refetch-on-unknown-kid
|
|
202
|
+
// (key rotation) - we do not hand-roll any of that. A build failure is recomputed next call so a
|
|
203
|
+
// transient discovery outage self-heals.
|
|
204
|
+
let jwks = null;
|
|
205
|
+
let jwksBuildPromise = null;
|
|
206
|
+
|
|
207
|
+
function unauthorized(status, error, description, logReason) {
|
|
208
|
+
if (logReason) log(`auth reject: ${logReason}`);
|
|
209
|
+
return {
|
|
210
|
+
ok: false,
|
|
211
|
+
status: status || 401,
|
|
212
|
+
error: error || 'invalid_token',
|
|
213
|
+
errorDescription: description || '',
|
|
214
|
+
wwwAuthenticate: buildWwwAuthenticate({ resourceMetadataUrl, error, description }),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function ensureJwks() {
|
|
219
|
+
if (jwks) return jwks;
|
|
220
|
+
if (!jwksBuildPromise) {
|
|
221
|
+
jwksBuildPromise = (async () => {
|
|
222
|
+
const jose = loadJose();
|
|
223
|
+
if (!jose) throw new Error('jose is not installed');
|
|
224
|
+
const uri = await resolveJwksUri(cfg, discoveryTimeoutMs);
|
|
225
|
+
// cooldownDuration throttles refetches on unknown-kid; cacheMaxAge bounds key staleness.
|
|
226
|
+
return jose.createRemoteJWKSet(new URL(uri), { cooldownDuration: 30000, cacheMaxAge: 600000 });
|
|
227
|
+
})().catch((e) => {
|
|
228
|
+
jwksBuildPromise = null; // allow a retry on the next request (self-heal)
|
|
229
|
+
throw e;
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
jwks = await jwksBuildPromise;
|
|
233
|
+
return jwks;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function validate(authorizationHeader) {
|
|
237
|
+
// Fail closed if the dependency or configuration is not coherent - never allow on misconfig.
|
|
238
|
+
const jose = loadJose();
|
|
239
|
+
if (!jose) {
|
|
240
|
+
return unauthorized(500, 'server_error', 'authentication unavailable', 'jose not installed');
|
|
241
|
+
}
|
|
242
|
+
if (!cfg.audience) {
|
|
243
|
+
// Without an audience we cannot bind the token to THIS resource (RFC 8707) - refuse rather
|
|
244
|
+
// than validate an unbound token (the confused-deputy hole). Belt-and-braces: the transport
|
|
245
|
+
// refuses to start in this state, but a request must still never slip through.
|
|
246
|
+
return unauthorized(500, 'server_error', 'authentication misconfigured', 'no audience configured');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const token = extractBearer(authorizationHeader);
|
|
250
|
+
if (!token) {
|
|
251
|
+
// No credentials -> a BARE challenge (no error code), the canonical "authenticate, please".
|
|
252
|
+
return unauthorized(401, '', '', 'missing or malformed Authorization header');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
let resolver;
|
|
256
|
+
try {
|
|
257
|
+
resolver = await ensureJwks();
|
|
258
|
+
} catch (e) {
|
|
259
|
+
// Cannot reach/parse the key source -> cannot verify -> reject (do not leak the URL/reason).
|
|
260
|
+
return unauthorized(401, 'invalid_token', 'unable to verify token', `jwks unavailable: ${(e && e.message) || e}`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
let result;
|
|
264
|
+
try {
|
|
265
|
+
// The security-critical call. algorithms PINS the allowlist (blocks alg-confusion + alg:none);
|
|
266
|
+
// issuer + audience are ENFORCED by jose; clockTolerance bounds exp/nbf skew. We never read the
|
|
267
|
+
// token header to choose the algorithm - the allowlist is fixed from config.
|
|
268
|
+
result = await jose.jwtVerify(token, resolver, {
|
|
269
|
+
issuer: cfg.issuer || undefined,
|
|
270
|
+
audience: cfg.audience,
|
|
271
|
+
algorithms: cfg.algorithms,
|
|
272
|
+
clockTolerance: cfg.clockToleranceSec,
|
|
273
|
+
// Require `exp` to be PRESENT - jose only checks exp/nbf when present, so without this a
|
|
274
|
+
// token that omits exp would be accepted as never-expiring. RFC 9068 access tokens carry
|
|
275
|
+
// exp; a resource server should reject one that does not.
|
|
276
|
+
requiredClaims: ['exp'],
|
|
277
|
+
});
|
|
278
|
+
} catch (e) {
|
|
279
|
+
return unauthorized(401, 'invalid_token', 'token verification failed', `jwtVerify: ${(e && e.code) || (e && e.message) || e}`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const claims = result && result.payload ? result.payload : {};
|
|
283
|
+
|
|
284
|
+
// Optional scope enforcement - every required scope must be present, else 403 insufficient_scope.
|
|
285
|
+
if (Array.isArray(cfg.requiredScopes) && cfg.requiredScopes.length > 0) {
|
|
286
|
+
const have = scopesOf(claims);
|
|
287
|
+
const missing = cfg.requiredScopes.filter((s) => !have.has(s));
|
|
288
|
+
if (missing.length > 0) {
|
|
289
|
+
return {
|
|
290
|
+
ok: false,
|
|
291
|
+
status: 403,
|
|
292
|
+
error: 'insufficient_scope',
|
|
293
|
+
errorDescription: `missing scope: ${missing.join(' ')}`,
|
|
294
|
+
wwwAuthenticate: buildWwwAuthenticate({
|
|
295
|
+
resourceMetadataUrl,
|
|
296
|
+
error: 'insufficient_scope',
|
|
297
|
+
description: `missing scope: ${missing.join(' ')}`,
|
|
298
|
+
}),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
ok: true,
|
|
305
|
+
sub: typeof claims.sub === 'string' ? claims.sub : undefined,
|
|
306
|
+
claims,
|
|
307
|
+
scopes: Array.from(scopesOf(claims)),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return { validate, unauthorized, jwksReady: () => jwks != null };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* protectedResourceMetadata - the RFC 9728 Protected Resource Metadata document for this gateway.
|
|
316
|
+
* Served (unauthenticated) at /.well-known/oauth-protected-resource so a client can discover which
|
|
317
|
+
* authorization server issues tokens for this resource. NEVER throws.
|
|
318
|
+
* @param {object} cfg the resolved auth config
|
|
319
|
+
* @returns {{ resource:string, authorization_servers:string[], bearer_methods_supported:string[],
|
|
320
|
+
* scopes_supported?:string[] }}
|
|
321
|
+
*/
|
|
322
|
+
function protectedResourceMetadata(cfg) {
|
|
323
|
+
const doc = {
|
|
324
|
+
resource: (cfg && cfg.audience) || '',
|
|
325
|
+
authorization_servers: cfg && cfg.issuer ? [cfg.issuer] : [],
|
|
326
|
+
bearer_methods_supported: ['header'],
|
|
327
|
+
};
|
|
328
|
+
if (cfg && Array.isArray(cfg.requiredScopes) && cfg.requiredScopes.length > 0) {
|
|
329
|
+
doc.scopes_supported = cfg.requiredScopes.slice();
|
|
330
|
+
}
|
|
331
|
+
return doc;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
module.exports = {
|
|
335
|
+
JOSE_PIN,
|
|
336
|
+
isJoseInstalled,
|
|
337
|
+
createValidator,
|
|
338
|
+
protectedResourceMetadata,
|
|
339
|
+
buildWwwAuthenticate,
|
|
340
|
+
extractBearer,
|
|
341
|
+
scopesOf,
|
|
342
|
+
resolveJwksUri,
|
|
343
|
+
_resetJoseCache,
|
|
344
|
+
};
|