toolfunnel 0.5.0 → 0.7.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.
Files changed (72) hide show
  1. package/README.md +235 -167
  2. package/SECURITY.md +84 -0
  3. package/bin/toolfunnel.js +451 -147
  4. package/docs/KNOWN_BUGS.md +80 -33
  5. package/docs/MANUAL.pdf +0 -0
  6. package/docs/design.md +84 -0
  7. package/docs/packaging.md +115 -115
  8. package/hooks/hooks.manifest.json +27 -27
  9. package/hooks/scripts/example-audit.js +21 -21
  10. package/hooks/scripts/example-deny-dangerous.js +39 -31
  11. package/mcp/expose.example.json +30 -30
  12. package/mcp/servers/mock-modern/server.js +147 -0
  13. package/mcp/servers/mock-upstream/server.js +222 -12
  14. package/package.json +67 -65
  15. package/src/auth/config.js +174 -174
  16. package/src/auth/install.js +181 -181
  17. package/src/auth/resource-server.js +353 -344
  18. package/src/core/child-reaper.js +61 -0
  19. package/src/core/config-home.js +134 -134
  20. package/src/core/events.js +105 -105
  21. package/src/core/hook-engine.js +329 -275
  22. package/src/core/hook-loader.js +824 -791
  23. package/src/core/hook-runner.js +566 -346
  24. package/src/core/logger.js +297 -171
  25. package/src/core/matcher.js +100 -51
  26. package/src/core/metrics.js +75 -75
  27. package/src/core/rename-migration.js +163 -0
  28. package/src/core/requires.js +138 -138
  29. package/src/core/server-config.js +98 -77
  30. package/src/extend/add-hook.md +152 -142
  31. package/src/extend/add-mcp.md +119 -121
  32. package/src/extend/configure.md +88 -0
  33. package/src/extend/create-tool.md +168 -152
  34. package/src/extend/howto.js +102 -98
  35. package/src/extend/package.md +190 -187
  36. package/src/extend/wrap.md +99 -0
  37. package/src/mcp/aggregator.js +1674 -807
  38. package/src/mcp/expose-store.js +577 -430
  39. package/src/mcp/gated-run.js +210 -194
  40. package/src/mcp/http-transport.js +1563 -1012
  41. package/src/mcp/mcp-client.js +1070 -530
  42. package/src/mcp/modern.js +406 -0
  43. package/src/mcp/protocol.js +468 -466
  44. package/src/mcp/server.js +3112 -1405
  45. package/src/tools/registry.js +661 -513
  46. package/src/tools/tool-state.js +170 -104
  47. package/src/ui/public/app.js +1187 -1055
  48. package/src/ui/public/index.html +286 -249
  49. package/src/ui/public/styles.css +535 -535
  50. package/src/ui/server.js +1616 -1190
  51. package/tools/scripts/base64.js +139 -139
  52. package/tools/scripts/danger.js +61 -61
  53. package/tools/scripts/echo.js +39 -39
  54. package/tools/scripts/hash.js +101 -101
  55. package/tools/scripts/json.js +109 -109
  56. package/tools/scripts/text-stats.js +131 -131
  57. package/tools/scripts/tf-env.js +43 -43
  58. package/tools/scripts/tf-hook-add.js +151 -151
  59. package/tools/scripts/tf-hook-set.js +120 -112
  60. package/tools/scripts/tf-list.js +192 -192
  61. package/tools/scripts/tf-log.js +86 -86
  62. package/tools/scripts/tf-mcp-add.js +117 -117
  63. package/tools/scripts/tf-mcp-set.js +121 -102
  64. package/tools/scripts/tf-pack.js +237 -237
  65. package/tools/scripts/tf-tool-add.js +134 -129
  66. package/tools/scripts/tf-tool-set.js +142 -142
  67. package/tools/scripts/tf-wrap.js +103 -0
  68. package/tools/scripts/uuid.js +102 -102
  69. package/tools/tools.register.json +14 -3
  70. package/src/core/tool-registry.js +0 -180
  71. package/src/packages/packager.js +0 -715
  72. package/src/tools/drift.js +0 -138
@@ -1,344 +1,353 @@
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
- };
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 untrusted token 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 caller-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
+ if (!cfg.issuer) {
249
+ // Symmetric with the audience guard: a blank issuer would make jwtVerify's `issuer` undefined,
250
+ // silently DISABLING issuer binding so a token from any authorization server would validate.
251
+ // configError refuses this at start-up, but the UI can persist an enabled+blank-issuer config
252
+ // that takes effect on the next request with no restart - so fail closed here too.
253
+ return unauthorized(500, 'server_error', 'authentication misconfigured', 'no issuer configured');
254
+ }
255
+
256
+ const token = extractBearer(authorizationHeader);
257
+ if (!token) {
258
+ // No credentials -> a BARE challenge (no error code), the canonical "authenticate, please".
259
+ return unauthorized(401, '', '', 'missing or malformed Authorization header');
260
+ }
261
+
262
+ let resolver;
263
+ try {
264
+ resolver = await ensureJwks();
265
+ } catch (e) {
266
+ // Cannot reach/parse the key source -> cannot verify -> reject (do not leak the URL/reason).
267
+ return unauthorized(401, 'invalid_token', 'unable to verify token', `jwks unavailable: ${(e && e.message) || e}`);
268
+ }
269
+
270
+ let result;
271
+ try {
272
+ // The security-critical call. algorithms PINS the allowlist (blocks alg-confusion + alg:none);
273
+ // issuer + audience are ENFORCED by jose; clockTolerance bounds exp/nbf skew. We never read the
274
+ // token header to choose the algorithm - the allowlist is fixed from config.
275
+ result = await jose.jwtVerify(token, resolver, {
276
+ // The guard above guarantees a non-empty issuer here; pass it straight through. The old
277
+ // `cfg.issuer || undefined` was the silent-skip footgun - undefined tells jose NOT to check.
278
+ issuer: cfg.issuer,
279
+ audience: cfg.audience,
280
+ algorithms: cfg.algorithms,
281
+ clockTolerance: cfg.clockToleranceSec,
282
+ // Require `exp` to be PRESENT - jose only checks exp/nbf when present, so without this a
283
+ // token that omits exp would be accepted as never-expiring. RFC 9068 access tokens carry
284
+ // exp; a resource server should reject one that does not.
285
+ requiredClaims: ['exp'],
286
+ });
287
+ } catch (e) {
288
+ return unauthorized(401, 'invalid_token', 'token verification failed', `jwtVerify: ${(e && e.code) || (e && e.message) || e}`);
289
+ }
290
+
291
+ const claims = result && result.payload ? result.payload : {};
292
+
293
+ // Optional scope enforcement - every required scope must be present, else 403 insufficient_scope.
294
+ if (Array.isArray(cfg.requiredScopes) && cfg.requiredScopes.length > 0) {
295
+ const have = scopesOf(claims);
296
+ const missing = cfg.requiredScopes.filter((s) => !have.has(s));
297
+ if (missing.length > 0) {
298
+ return {
299
+ ok: false,
300
+ status: 403,
301
+ error: 'insufficient_scope',
302
+ errorDescription: `missing scope: ${missing.join(' ')}`,
303
+ wwwAuthenticate: buildWwwAuthenticate({
304
+ resourceMetadataUrl,
305
+ error: 'insufficient_scope',
306
+ description: `missing scope: ${missing.join(' ')}`,
307
+ }),
308
+ };
309
+ }
310
+ }
311
+
312
+ return {
313
+ ok: true,
314
+ sub: typeof claims.sub === 'string' ? claims.sub : undefined,
315
+ claims,
316
+ scopes: Array.from(scopesOf(claims)),
317
+ };
318
+ }
319
+
320
+ return { validate, unauthorized, jwksReady: () => jwks != null };
321
+ }
322
+
323
+ /**
324
+ * protectedResourceMetadata - the RFC 9728 Protected Resource Metadata document for this gateway.
325
+ * Served (unauthenticated) at /.well-known/oauth-protected-resource so a client can discover which
326
+ * authorization server issues tokens for this resource. NEVER throws.
327
+ * @param {object} cfg the resolved auth config
328
+ * @returns {{ resource:string, authorization_servers:string[], bearer_methods_supported:string[],
329
+ * scopes_supported?:string[] }}
330
+ */
331
+ function protectedResourceMetadata(cfg) {
332
+ const doc = {
333
+ resource: (cfg && cfg.audience) || '',
334
+ authorization_servers: cfg && cfg.issuer ? [cfg.issuer] : [],
335
+ bearer_methods_supported: ['header'],
336
+ };
337
+ if (cfg && Array.isArray(cfg.requiredScopes) && cfg.requiredScopes.length > 0) {
338
+ doc.scopes_supported = cfg.requiredScopes.slice();
339
+ }
340
+ return doc;
341
+ }
342
+
343
+ module.exports = {
344
+ JOSE_PIN,
345
+ isJoseInstalled,
346
+ createValidator,
347
+ protectedResourceMetadata,
348
+ buildWwwAuthenticate,
349
+ extractBearer,
350
+ scopesOf,
351
+ resolveJwksUri,
352
+ _resetJoseCache,
353
+ };