toolfunnel 0.4.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.
Files changed (78) hide show
  1. package/README.md +102 -213
  2. package/bin/toolfunnel.js +372 -147
  3. package/docs/KNOWN_BUGS.md +99 -225
  4. package/docs/MANUAL.pdf +0 -0
  5. package/docs/design.md +70 -0
  6. package/docs/packaging.md +115 -115
  7. package/hooks/hooks.manifest.json +27 -27
  8. package/hooks/scripts/example-audit.js +21 -21
  9. package/hooks/scripts/example-deny-dangerous.js +31 -31
  10. package/mcp/expose.example.json +30 -30
  11. package/mcp/servers/mock-modern/server.js +147 -0
  12. package/mcp/servers/mock-upstream/server.js +192 -12
  13. package/package.json +1 -1
  14. package/src/auth/config.js +174 -174
  15. package/src/auth/install.js +181 -181
  16. package/src/auth/resource-server.js +344 -344
  17. package/src/core/config-home.js +134 -134
  18. package/src/core/events.js +105 -105
  19. package/src/core/hook-engine.js +275 -275
  20. package/src/core/hook-loader.js +791 -791
  21. package/src/core/hook-runner.js +346 -346
  22. package/src/core/logger.js +171 -171
  23. package/src/core/matcher.js +51 -51
  24. package/src/core/metrics.js +75 -75
  25. package/src/core/requires.js +138 -138
  26. package/src/core/server-config.js +98 -77
  27. package/src/core/tool-registry.js +180 -180
  28. package/src/extend/add-hook.md +152 -142
  29. package/src/extend/add-mcp.md +122 -121
  30. package/src/extend/configure.md +87 -0
  31. package/src/extend/create-tool.md +168 -152
  32. package/src/extend/howto.js +102 -98
  33. package/src/extend/package.md +187 -187
  34. package/src/extend/wrap.md +99 -0
  35. package/src/mcp/aggregator.js +1246 -807
  36. package/src/mcp/expose-store.js +460 -430
  37. package/src/mcp/gated-run.js +201 -194
  38. package/src/mcp/http-transport.js +1311 -981
  39. package/src/mcp/mcp-client.js +907 -530
  40. package/src/mcp/modern.js +372 -0
  41. package/src/mcp/protocol.js +468 -466
  42. package/src/mcp/server.js +2688 -1322
  43. package/src/packages/packager.js +715 -715
  44. package/src/tools/drift.js +138 -138
  45. package/src/tools/registry.js +513 -511
  46. package/src/tools/tool-state.js +170 -104
  47. package/src/ui/public/app.js +1174 -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 +1348 -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 +112 -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 +227 -216
  70. package/docs/admin-ui.md +0 -404
  71. package/docs/configuration.md +0 -402
  72. package/docs/hooks-and-gating.md +0 -428
  73. package/docs/logging.md +0 -223
  74. package/docs/management.md +0 -429
  75. package/docs/oauth.md +0 -103
  76. package/docs/releasing.md +0 -31
  77. package/docs/setup.md +0 -352
  78. package/docs/tools-and-upstreams.md +0 -469
@@ -1,344 +1,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
- };
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
+ };