soajs.controller 4.3.24 → 4.3.26

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/docs/mcp.md CHANGED
@@ -36,8 +36,8 @@ after 30 seconds.
36
36
 
37
37
  The ACL the gateway computes for `/soajs/acl`: the user's ACL and packages, else the tenant
38
38
  application's, else the package's. For each service, the latest allowed version; a restricted
39
- service shows only the APIs its ACL lists, per method. To give agents a different list from your
40
- apps, give the agent its own application, key and package in the tenant.
39
+ service shows only the APIs its ACL lists, per method, and none when it lists none. To give agents
40
+ a different list from your apps, give the agent its own application, key and package in the tenant.
41
41
 
42
42
  A tool call is checked again by the gateway like any API call, so a tool shown by mistake is still
43
43
  refused.
@@ -63,6 +63,54 @@ Off unless enabled, in the `gateway` custom registry (`registry.custom.gateway.v
63
63
  | `ttl` | `300000` | How long the tools of a service are cached, ms |
64
64
  | `timeout` | `10000` | Fetching tools and calling a tool, ms |
65
65
  | `maxTools` | `300` | At most this many tools in a list |
66
+ | `auth` | — | The authorization flow, see below; without it, clients send the key and token themselves |
67
+
68
+ ### Authorization (`mcp.auth`)
69
+
70
+ With `auth`, an MCP client connects with only the URL and the MCP authorization flow (OAuth 2.1,
71
+ PKCE). The gateway is the protected resource; the authorization server (consent page, codes, tokens)
72
+ is yours, named by its endpoints.
73
+
74
+ ```json
75
+ "auth": {
76
+ "byProduct": {
77
+ "AGENTS": {
78
+ "authorization_endpoint": "https://portal.example.com/agents/authorize",
79
+ "token_endpoint": "https://api.example.com/agents/oauth/token?key=<key>",
80
+ "registration_endpoint": "https://api.example.com/agents/oauth/register?key=<key>"
81
+ }
82
+ },
83
+ "byHost": {
84
+ "api.client.example.com": { "authorization_endpoint": "...", "token_endpoint": "...", "registration_endpoint": "..." }
85
+ },
86
+ "default": { "authorization_endpoint": "...", "token_endpoint": "...", "registration_endpoint": "..." }
87
+ }
88
+ ```
89
+
90
+ - **Which endpoints:**
91
+ - `byProduct`, by the product of the key on the request (header or `?key=`);
92
+ - else `byHost`, by the host the request came in on (`X-Forwarded-Host`, else `Host`);
93
+ - else `default`.
94
+ - An entry needs `authorization_endpoint` and `token_endpoint`; `registration_endpoint` is optional.
95
+ - **No token, or an unknown or expired one:** HTTP 401 with
96
+ `WWW-Authenticate: Bearer resource_metadata="<origin>/.well-known/oauth-protected-resource/soajs/mcp"`,
97
+ plus `?key=` when the client's URL had one. If nothing matches the request, the answer is as without
98
+ `auth`.
99
+ - **`GET /.well-known/oauth-protected-resource[/soajs/mcp]`:** `resource`, `authorization_servers`
100
+ (the issuer `<origin>/soajs/mcp/auth/<product/CODE|host|default>`) and `bearer_methods_supported`.
101
+ - **`GET /.well-known/oauth-authorization-server/<issuer path>`** (also `openid-configuration`):
102
+ - the issuer and the entry's endpoints;
103
+ - `code` with PKCE `S256`;
104
+ - grant `authorization_code`;
105
+ - token endpoint auth `none`.
106
+ - **The key from the token.** A client sends no key header. When the token is restricted to one key
107
+ (`user.restrictedTo.key`):
108
+ - `/soajs/mcp` without a key uses that key;
109
+ - with a key of the same product (a product URL), the token's key replaces it;
110
+ - a key of another product is kept, and the token is refused on it (147).
111
+ - **The issued token:** the token endpoint should return one restricted to the agent's tenant,
112
+ product, package and key, e.g. from soajs.oauth `POST /restricted/token/guest`. The gateway then
113
+ checks it on every call like any restricted token.
66
114
 
67
115
  ## Protocol
68
116
 
@@ -91,8 +139,20 @@ streaming. `GET` answers 405.
91
139
 
92
140
  The arguments are split back into the path, the query string and the body, and the call goes to
93
141
  `http://127.0.0.1:<gateway port>/<service>/v<version><path>` with the caller's `key`,
94
- `authorization` / `access_token`, `device-id`, `user-agent`, `accept-language` and
95
- `idempotency-key` headers and `x-forwarded-for`.
142
+ `authorization` / `access_token`, `device-id`, `user-agent` and `accept-language` headers and
143
+ `x-forwarded-for`.
144
+
145
+ **Idempotency.** An `Idempotency-Key` header on the MCP request is not forwarded: it is the same for
146
+ every call of a connection, so the API would answer every later call with the first call's response.
147
+ Instead:
148
+
149
+ - A tool whose API has idempotency on (`registry.custom.gateway.value.idempotency`, by service and
150
+ API) takes an optional `_idempotencyKey` argument: a UUID v4 that the agent sends again when it
151
+ retries the same call, so the call runs once. A value that isn't a UUID v4 is a tool error, and
152
+ nothing is called.
153
+ - A POST, PUT or DELETE without one gets a new key, so an API that requires a key works, and two
154
+ calls are never taken for one.
155
+ - An API with its own `_idempotencyKey` field keeps it as a field.
96
156
 
97
157
  - **Success:** `content` has the response `data` as JSON text, `structuredContent` the same when it
98
158
  is an object.
@@ -136,5 +196,6 @@ URL.
136
196
  | File | Role |
137
197
  |------|------|
138
198
  | `mw/soajsRoutes/mcp/index.js` | The route: JSON-RPC handling |
139
- | `mw/soajsRoutes/mcp/tools.js` | Tools: fetch, cache, ACL filter, naming, the call |
199
+ | `mw/soajsRoutes/mcp/tools.js` | Tools: fetch, cache, ACL filter, naming, idempotency, the call |
200
+ | `mw/soajsRoutes/mcp/auth.js` | Authorization: the 401, the well-known metadata, the key from the token |
140
201
  | `mw/soajsRoutes/lib/acl.js` | The ACL computation shared with `/soajs/acl` |
@@ -222,4 +222,6 @@ module.exports = function (configuration) {
222
222
 
223
223
  // Export model for external use
224
224
  module.exports.model = model;
225
+ module.exports.matchesApi = matchesApi;
226
+ module.exports.isValidUUIDv4 = isValidUUIDv4;
225
227
  module.exports.unlock = unlock;
@@ -16,10 +16,12 @@ const coreLibs = require("soajs.core.libs");
16
16
  *
17
17
  * finalACL is, per service, the latest allowed version and, for a restricted service, the allowed
18
18
  * apis per method: {"urac": {"version": "3", "get": ["/user"]}}. A service that is not restricted
19
- * has only its version: all its apis are allowed.
19
+ * has only its version: all its apis are allowed. A restricted service that lists no apis also has
20
+ * only its version, so restricted says, per service, whether its latest allowed version is
21
+ * restricted.
20
22
  *
21
23
  * @param req
22
- * @returns {{acl: object|null, finalACL: object|null, packages: Array|null}}
24
+ * @returns {{acl: object|null, finalACL: object|null, restricted: object, packages: Array|null}}
23
25
  */
24
26
  function computeACL(req) {
25
27
  let ACL = null;
@@ -42,6 +44,7 @@ function computeACL(req) {
42
44
  ACL = coreLibs.utils.cloneObj(req.soajs.controller.serviceParams.packObj.acl);
43
45
  }
44
46
  let finalACL = null;
47
+ let restricted = {};
45
48
  if (ACL) {
46
49
  finalACL = {};
47
50
  for (let s in ACL) {
@@ -58,7 +61,8 @@ function computeACL(req) {
58
61
  finalACL[s] = {
59
62
  "version": v
60
63
  };
61
- if (sv.apisPermission && sv.apisPermission === "restricted") {
64
+ restricted[s] = !!(sv.apisPermission && sv.apisPermission === "restricted");
65
+ if (restricted[s]) {
62
66
  for (let m in sv) {
63
67
  if (sv.hasOwnProperty(m)) {
64
68
  if (m !== "access" && m !== "apisPermission") {
@@ -91,6 +95,7 @@ function computeACL(req) {
91
95
  return {
92
96
  "acl": ACL,
93
97
  "finalACL": finalACL,
98
+ "restricted": restricted,
94
99
  "packages": ALLOWED_PACKAGES
95
100
  };
96
101
  }
@@ -0,0 +1,331 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @license
5
+ * Copyright SOAJS All Rights Reserved.
6
+ *
7
+ * Use of this source code is governed by an Apache license that can be
8
+ * found in the LICENSE file at the root of this repository
9
+ */
10
+
11
+ /**
12
+ * The MCP authorization flow (OAuth 2.1, RFC 9728, RFC 8414) for /soajs/mcp, on only when
13
+ * registry.custom.gateway.value.mcp.auth is set. The gateway is the protected resource: it answers a
14
+ * call without a valid token with 401 and the address of its metadata, and serves the metadata. The
15
+ * authorization server (consent page, codes, PKCE, tokens) is not the gateway, the metadata points
16
+ * at the endpoints the configuration names.
17
+ *
18
+ * Which endpoints: by the product of the key on the request (auth.byProduct), else by the host the
19
+ * request came in on (auth.byHost), else auth.default.
20
+ *
21
+ * An MCP client sends the token only, no key. A token restricted to one key (user.restrictedTo.key)
22
+ * brings it: /soajs/mcp without a key uses it, and with a key of the same product the token's key
23
+ * replaces it.
24
+ */
25
+
26
+ const get = (p, o) => p.reduce((xs, x) => (xs && xs[x]) ? xs[x] : null, o);
27
+
28
+ const MCP_PATH = "/soajs/mcp";
29
+ const ISSUER_PATH = MCP_PATH + "/auth/";
30
+ const PRM_PATH = "/.well-known/oauth-protected-resource";
31
+ const AS_PATHS = ["/.well-known/oauth-authorization-server", "/.well-known/openid-configuration"];
32
+
33
+ /**
34
+ * The auth configuration: registry.custom.gateway.value.mcp.auth, when mcp is enabled.
35
+ */
36
+ function getAuthConfig(req) {
37
+ let mcp = get(["soajs", "registry", "custom", "gateway", "value", "mcp"], req);
38
+ if (!mcp || mcp.enabled !== true || !mcp.auth || typeof mcp.auth !== "object") {
39
+ return null;
40
+ }
41
+ return mcp.auth;
42
+ }
43
+
44
+ /**
45
+ * An entry is usable when it names the authorization and token endpoints.
46
+ */
47
+ function usable(entry) {
48
+ return !!entry && typeof entry === "object" &&
49
+ typeof entry.authorization_endpoint === "string" && entry.authorization_endpoint !== "" &&
50
+ typeof entry.token_endpoint === "string" && entry.token_endpoint !== "";
51
+ }
52
+
53
+ function firstValue(header) {
54
+ return (typeof header === "string") ? header.split(",")[0].trim() : null;
55
+ }
56
+
57
+ /**
58
+ * The host the request came in on, and the origin the client sees.
59
+ */
60
+ function requestHost(req) {
61
+ return (firstValue(req.headers["x-forwarded-host"]) || req.headers.host || "").toLowerCase();
62
+ }
63
+
64
+ function requestOrigin(req) {
65
+ let proto = firstValue(req.headers["x-forwarded-proto"]);
66
+ if (!proto) {
67
+ proto = (req.socket && req.socket.encrypted) ? "https" : "http";
68
+ }
69
+ return proto + "://" + requestHost(req);
70
+ }
71
+
72
+ function ownEntry(map, name) {
73
+ if (!map || typeof map !== "object" || !name || !Object.hasOwnProperty.call(map, name)) {
74
+ return null;
75
+ }
76
+ return usable(map[name]) ? map[name] : null;
77
+ }
78
+
79
+ /**
80
+ * The entry for a request: by the product of its key, by its host, the default.
81
+ *
82
+ * @param cb (match) match: {id, entry} or null
83
+ */
84
+ function resolveEntry(req, provision, auth, key, cb) {
85
+ let byHost = () => {
86
+ let entry = ownEntry(auth.byHost, requestHost(req));
87
+ if (entry) {
88
+ return cb({"id": "host", "entry": entry});
89
+ }
90
+ if (usable(auth.default)) {
91
+ return cb({"id": "default", "entry": auth.default});
92
+ }
93
+ return cb(null);
94
+ };
95
+ if (!key || !auth.byProduct) {
96
+ return byHost();
97
+ }
98
+ provision.getExternalKeyData(key, get(["soajs", "registry", "serviceConfig", "key"], req), (err, keyObj) => {
99
+ let product = get(["application", "product"], keyObj);
100
+ let entry = ownEntry(auth.byProduct, product);
101
+ if (entry) {
102
+ return cb({"id": "product/" + product, "entry": entry});
103
+ }
104
+ return byHost();
105
+ });
106
+ }
107
+
108
+ /**
109
+ * The entry an issuer id names, as resolveEntry gave it.
110
+ */
111
+ function entryById(req, auth, id) {
112
+ if (id === "host") {
113
+ return ownEntry(auth.byHost, requestHost(req));
114
+ }
115
+ if (id === "default") {
116
+ return usable(auth.default) ? auth.default : null;
117
+ }
118
+ if (id.indexOf("product/") === 0) {
119
+ return ownEntry(auth.byProduct, id.substring("product/".length));
120
+ }
121
+ return null;
122
+ }
123
+
124
+ function issuer(req, id) {
125
+ return requestOrigin(req) + ISSUER_PATH + id;
126
+ }
127
+
128
+ function sendJSON(res, status, body, headers) {
129
+ let text = JSON.stringify(body);
130
+ res.writeHead(status, Object.assign({
131
+ "Content-Type": "application/json",
132
+ "Content-Length": Buffer.byteLength(text),
133
+ "Cache-Control": "no-store"
134
+ }, headers || {}));
135
+ res.end(text);
136
+ }
137
+
138
+ /**
139
+ * 401 with the address of the protected resource metadata. A key the client put in the url stays
140
+ * in it, the metadata is then chosen by the same product.
141
+ */
142
+ function challenge(req, res, queryKey, invalidToken) {
143
+ let metadata = requestOrigin(req) + PRM_PATH + MCP_PATH + (queryKey ? "?key=" + encodeURIComponent(queryKey) : "");
144
+ let value = 'Bearer resource_metadata="' + metadata + '"';
145
+ if (invalidToken) {
146
+ value += ', error="invalid_token"';
147
+ }
148
+ sendJSON(res, 401, {
149
+ "error": invalidToken ? "invalid_token" : "unauthorized",
150
+ "error_description": invalidToken ? "The access token is invalid or has expired." : "Authorization required."
151
+ }, {
152
+ "WWW-Authenticate": value,
153
+ "Access-Control-Expose-Headers": "WWW-Authenticate"
154
+ });
155
+ }
156
+
157
+ function queryOf(url) {
158
+ try {
159
+ return new URL(url, "http://localhost").searchParams;
160
+ } catch (e) {
161
+ return new URLSearchParams();
162
+ }
163
+ }
164
+
165
+ function pathOf(url) {
166
+ let i = url.indexOf("?");
167
+ return (i === -1) ? url : url.substring(0, i);
168
+ }
169
+
170
+ /**
171
+ * The token of a request: the access_token header or query, or Authorization: Bearer.
172
+ */
173
+ function tokenOf(req) {
174
+ if (req.query && req.query.access_token) {
175
+ return req.query.access_token;
176
+ }
177
+ if (req.headers.access_token) {
178
+ return req.headers.access_token;
179
+ }
180
+ let matches = (req.headers.authorization || "").match(/^Bearer\s+(\S+)/i);
181
+ return matches ? matches[1] : null;
182
+ }
183
+
184
+ /**
185
+ * The key a token is restricted to, when it is exactly one.
186
+ */
187
+ function tokenKey(record) {
188
+ let key = get(["user", "restrictedTo", "key"], record);
189
+ if (typeof key === "string" && key !== "") {
190
+ return key;
191
+ }
192
+ if (Array.isArray(key) && key.length === 1 && typeof key[0] === "string" && key[0] !== "") {
193
+ return key[0];
194
+ }
195
+ return null;
196
+ }
197
+
198
+ /**
199
+ * GET /.well-known/oauth-protected-resource[/soajs/mcp] and
200
+ * GET /.well-known/{oauth-authorization-server|openid-configuration}/soajs/mcp/auth/<id>,
201
+ * before the url mw, which knows no service named .well-known.
202
+ */
203
+ function wellKnown(configuration) {
204
+ let provision = configuration.provision;
205
+
206
+ return (req, res, next) => {
207
+ let path = pathOf(req.url || "");
208
+ if (path.indexOf("/.well-known/") !== 0) {
209
+ return next();
210
+ }
211
+ let auth = getAuthConfig(req);
212
+ if (!auth || (req.method !== "GET" && req.method !== "HEAD")) {
213
+ return next();
214
+ }
215
+ if (path === PRM_PATH || path === PRM_PATH + MCP_PATH) {
216
+ let queryKey = queryOf(req.url).get("key");
217
+ return resolveEntry(req, provision, auth, queryKey, (match) => {
218
+ if (!match) {
219
+ return next();
220
+ }
221
+ return sendJSON(res, 200, {
222
+ "resource": requestOrigin(req) + MCP_PATH + (queryKey ? "?key=" + encodeURIComponent(queryKey) : ""),
223
+ "authorization_servers": [issuer(req, match.id)],
224
+ "bearer_methods_supported": ["header"]
225
+ });
226
+ });
227
+ }
228
+ for (let i = 0; i < AS_PATHS.length; i++) {
229
+ let prefix = AS_PATHS[i] + ISSUER_PATH;
230
+ if (path.indexOf(prefix) === 0) {
231
+ let id = decodeURIComponent(path.substring(prefix.length));
232
+ let entry = entryById(req, auth, id);
233
+ if (!entry) {
234
+ return next();
235
+ }
236
+ let metadata = {
237
+ "issuer": issuer(req, id),
238
+ "authorization_endpoint": entry.authorization_endpoint,
239
+ "token_endpoint": entry.token_endpoint,
240
+ "response_types_supported": ["code"],
241
+ "grant_types_supported": ["authorization_code"],
242
+ "code_challenge_methods_supported": ["S256"],
243
+ "token_endpoint_auth_methods_supported": ["none"]
244
+ };
245
+ if (typeof entry.registration_endpoint === "string" && entry.registration_endpoint !== "") {
246
+ metadata.registration_endpoint = entry.registration_endpoint;
247
+ }
248
+ return sendJSON(res, 200, metadata);
249
+ }
250
+ }
251
+ return next();
252
+ };
253
+ }
254
+
255
+ /**
256
+ * /soajs/mcp, after the url mw and before the key mw: the 401 when the caller must authorize, and
257
+ * the key from a token restricted to one key.
258
+ */
259
+ function authorize(configuration) {
260
+ let provision = configuration.provision;
261
+
262
+ return (req, res, next) => {
263
+ let info = get(["soajs", "controller", "serviceParams", "serviceInfo"], req);
264
+ if (!info || info[1] !== "soajs" || info[2] !== "mcp") {
265
+ return next();
266
+ }
267
+ let auth = getAuthConfig(req);
268
+ let mcp = get(["soajs", "registry", "custom", "gateway", "value", "mcp"], req);
269
+ if (!mcp || mcp.enabled !== true) {
270
+ return next();
271
+ }
272
+ let queryKey = (req.query && req.query.key) || null;
273
+ let askToAuthorize = (invalidToken) => {
274
+ if (!auth) {
275
+ return next();
276
+ }
277
+ resolveEntry(req, provision, auth, req.headers.key, (match) => {
278
+ if (!match) {
279
+ return next();
280
+ }
281
+ return challenge(req, res, queryKey, invalidToken);
282
+ });
283
+ };
284
+
285
+ let token = tokenOf(req);
286
+ if (!token) {
287
+ return askToAuthorize(false);
288
+ }
289
+ provision.oauthModel.getAccessToken(token, (err, record) => {
290
+ if (err) {
291
+ req.soajs.log.error("MCP: unable to read the access token: " + err.message);
292
+ return next();
293
+ }
294
+ if (!record || (record.expires && new Date(record.expires).getTime() < Date.now())) {
295
+ return askToAuthorize(true);
296
+ }
297
+ let restrictedKey = tokenKey(record);
298
+ if (!restrictedKey || req.headers.key === restrictedKey) {
299
+ return next();
300
+ }
301
+ if (!req.headers.key) {
302
+ req.headers.key = restrictedKey;
303
+ return next();
304
+ }
305
+ //NOTE: the url names a product, the token a key of it: the token's key. A key of another
306
+ // product is left as is, the oauth mw refuses the token on it (147).
307
+ let keyConfig = get(["soajs", "registry", "serviceConfig", "key"], req);
308
+ provision.getExternalKeyData(req.headers.key, keyConfig, (err, given) => {
309
+ provision.getExternalKeyData(restrictedKey, keyConfig, (err, own) => {
310
+ let givenProduct = get(["application", "product"], given);
311
+ let ownProduct = get(["application", "product"], own);
312
+ if (givenProduct && givenProduct === ownProduct) {
313
+ req.headers.key = restrictedKey;
314
+ }
315
+ return next();
316
+ });
317
+ });
318
+ });
319
+ };
320
+ }
321
+
322
+ module.exports = {
323
+ getAuthConfig,
324
+ resolveEntry,
325
+ entryById,
326
+ requestOrigin,
327
+ tokenOf,
328
+ tokenKey,
329
+ wellKnown,
330
+ authorize
331
+ };
@@ -18,10 +18,12 @@
18
18
  */
19
19
 
20
20
  const http = require("http");
21
+ const crypto = require("crypto");
21
22
  const yaml = require("js-yaml");
22
23
  const coreLibs = require("soajs.core.libs");
23
24
 
24
25
  const aclLib = require("../lib/acl.js");
26
+ const idempotency = require("../../idempotency/index.js");
25
27
 
26
28
  const get = (p, o) => p.reduce((xs, x) => (xs && xs[x]) ? xs[x] : null, o);
27
29
 
@@ -37,7 +39,13 @@ const NAME_MAX = 64;
37
39
  // service@version -> {expires, tools}
38
40
  let cache = {};
39
41
 
40
- const HEADERS_TO_FORWARD = ['key', 'authorization', 'access_token', 'device-id', 'user-agent', 'accept-language', 'idempotency-key'];
42
+ //NOTE: no idempotency-key, a header on the mcp request is the same for every tool call of the
43
+ // connection, the api would replay the first answer to every later call. A tool call carries
44
+ // its own key, see IDEMPOTENCY_ARG.
45
+ const HEADERS_TO_FORWARD = ['key', 'authorization', 'access_token', 'device-id', 'user-agent', 'accept-language'];
46
+
47
+ // the argument a tool call gives its idempotency key in, on the tools whose api has idempotency on
48
+ const IDEMPOTENCY_ARG = "_idempotencyKey";
41
49
 
42
50
  /**
43
51
  * The mcp configuration: registry.custom.gateway.value.mcp
@@ -224,11 +232,10 @@ function toolName(serviceName, tool, taken) {
224
232
  }
225
233
 
226
234
  /**
227
- * Whether the caller's ACL allows the api: a restricted service lists its apis per method.
235
+ * Whether the caller's ACL allows the api: a restricted service allows only the apis it lists per
236
+ * method, none when it lists none.
228
237
  */
229
- function allowed(serviceACL, tool) {
230
- let methods = ["get", "post", "put", "delete", "del"];
231
- let restricted = methods.some((m) => Array.isArray(serviceACL[m]));
238
+ function allowed(serviceACL, tool, restricted) {
232
239
  if (!restricted) {
233
240
  return true;
234
241
  }
@@ -236,6 +243,33 @@ function allowed(serviceACL, tool) {
236
243
  return Array.isArray(list) && list.indexOf(tool.v) !== -1;
237
244
  }
238
245
 
246
+ /**
247
+ * Whether the gateway applies idempotency to the api of a tool, as mw/idempotency decides it for
248
+ * the api call: registry.custom.gateway.value.idempotency, by service and api. A get never.
249
+ */
250
+ function idempotent(req, serviceName, tool) {
251
+ if (tool.m === "get") {
252
+ return false;
253
+ }
254
+ let services = get(["soajs", "registry", "custom", "gateway", "value", "idempotency", "services"], req);
255
+ let serviceConfig = services ? services[serviceName] : null;
256
+ if (!serviceConfig || !serviceConfig.enabled) {
257
+ return false;
258
+ }
259
+ if (serviceConfig.apis && !idempotency.matchesApi(tool.m.toUpperCase(), tool.v, serviceConfig.apis)) {
260
+ return false;
261
+ }
262
+ return true;
263
+ }
264
+
265
+ /**
266
+ * Whether a tool call can carry its idempotency key in IDEMPOTENCY_ARG: not a get, and not an
267
+ * api with a field of that name.
268
+ */
269
+ function takesIdempotencyArg(tool) {
270
+ return tool.m !== "get" && !Object.hasOwnProperty.call(tool.fields || {}, IDEMPOTENCY_ARG);
271
+ }
272
+
239
273
  /**
240
274
  * The tools the caller may use.
241
275
  *
@@ -253,12 +287,22 @@ function listTools(req, configuration, config) {
253
287
  let taken = {};
254
288
  services.forEach((serviceName, i) => {
255
289
  lists[i].forEach((tool) => {
256
- if (tools.length >= config.maxTools || !allowed(finalACL[serviceName], tool)) {
290
+ if (tools.length >= config.maxTools || !allowed(finalACL[serviceName], tool, computed.restricted[serviceName])) {
257
291
  return;
258
292
  }
259
293
  let name = toolName(serviceName, tool, taken);
260
294
  let description = tool.l || (tool.m.toUpperCase() + " " + tool.v);
261
295
  let inputSchema = (tool.inputSchema && tool.inputSchema.type === "object") ? tool.inputSchema : {"type": "object", "properties": {}};
296
+ if (takesIdempotencyArg(tool) && idempotent(req, serviceName, tool)) {
297
+ //NOTE: copies, the tool is shared through the cache
298
+ let properties = Object.assign({}, inputSchema.properties || {});
299
+ properties[IDEMPOTENCY_ARG] = {
300
+ "type": "string",
301
+ "format": "uuid",
302
+ "description": "Optional UUID v4. Send the same value when retrying this exact call, so it runs once."
303
+ };
304
+ inputSchema = Object.assign({}, inputSchema, {"properties": properties});
305
+ }
262
306
  tools.push({
263
307
  "name": name,
264
308
  "title": description,
@@ -372,6 +416,22 @@ function callTool(req, configuration, config, entry, args) {
372
416
  headers[h] = req.headers[h];
373
417
  }
374
418
  });
419
+ if (takesIdempotencyArg(entry.tool)) {
420
+ //NOTE: a write without a key gets a new one: an api that requires a key works, and two
421
+ // calls are never taken for one. Only a key the agent sends again makes a retry run once.
422
+ let given = (args && typeof args === "object" && !Array.isArray(args)) ? args[IDEMPOTENCY_ARG] : undefined;
423
+ if (given !== undefined && given !== null && given !== "") {
424
+ if (typeof given !== "string" || !idempotency.isValidUUIDv4(given)) {
425
+ return Promise.resolve({
426
+ "content": [{"type": "text", "text": IDEMPOTENCY_ARG + " must be a UUID v4."}],
427
+ "isError": true
428
+ });
429
+ }
430
+ headers["idempotency-key"] = given;
431
+ } else {
432
+ headers["idempotency-key"] = crypto.randomUUID();
433
+ }
434
+ }
375
435
  if (!headers.access_token && req.query && req.query.access_token) {
376
436
  headers.access_token = req.query.access_token;
377
437
  }
@@ -428,6 +488,8 @@ module.exports = {
428
488
  serviceTools,
429
489
  toolName,
430
490
  allowed,
491
+ idempotent,
492
+ takesIdempotencyArg,
431
493
  listTools,
432
494
  buildCall,
433
495
  callTool,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "soajs.controller",
3
3
  "description": "soajs multi tenant API gateway",
4
- "version": "4.3.24",
4
+ "version": "4.3.26",
5
5
  "author": {
6
6
  "name": "soajs team",
7
7
  "email": "team@soajs.org"
@@ -33,6 +33,7 @@ const awareness_mw = require('../mw/awareness/index');
33
33
  const awarenessEnv_mw = require("../mw/awarenessEnv/index");
34
34
  const url_mw = require("../mw/url/index");
35
35
  const key_mw = require("../mw/key/index");
36
+ const mcpAuth_mw = require("../mw/soajsRoutes/mcp/auth");
36
37
  const keyACL_mw = require("../mw/keyACL/index");
37
38
  const gotoService_mw = require("../mw/gotoService/index");
38
39
  const mt_mw = require("../mw/mt/index");
@@ -96,6 +97,9 @@ Controller.prototype.init = function (callback) {
96
97
  app.use(ip2ban_mw());
97
98
  app.use(maintenanceMode_mw()); //NOTE: this is for global maintenance mode
98
99
 
100
+ //NOTE: before the url mw, which knows no service named .well-known
101
+ app.use(mcpAuth_mw.wellKnown({ "provision": provision }));
102
+
99
103
  app.use(url_mw({ "core": core }));
100
104
 
101
105
  log.info("Loading Provision ...");
@@ -141,6 +145,9 @@ Controller.prototype.init = function (callback) {
141
145
 
142
146
  // app.use(url_mw({ "core": core }));
143
147
 
148
+ //NOTE: before the key mw, a token restricted to one key brings the key of /soajs/mcp
149
+ app.use(mcpAuth_mw.authorize({ "provision": provision }));
150
+
144
151
  app.use(key_mw({ "provision": provision, "regEnvironment": regEnvironment }));
145
152
 
146
153
  //TODO: add maintenance by tenant here req.soajs.controller.serviceParams.keyObj.config
@@ -38,6 +38,7 @@ describe("Starting Gateway Unit test", () => {
38
38
  require("./mw/soajs/index.js");
39
39
  require("./mw/soajsRoutes/keyACL.js");
40
40
  require("./mw/soajsRoutes/mcp.js");
41
+ require("./mw/soajsRoutes/mcpAuth.js");
41
42
  require("./mw/traffic/index.js");
42
43
  require("./mw/idempotency/index.js");
43
44
  require("./mw/idempotency/memory.js");
@@ -216,14 +216,35 @@ describe("Unit test for: mw - soajsRoutes mcp", () => {
216
216
 
217
217
  describe("allowed", () => {
218
218
  it("a service that is not restricted allows all its apis", () => {
219
- assert.ok(tools.allowed({version: "1"}, {m: "delete", v: "/x"}));
219
+ assert.ok(tools.allowed({version: "1"}, {m: "delete", v: "/x"}, false));
220
220
  });
221
221
  it("a restricted service allows the apis it lists", () => {
222
222
  let acl = {version: "1", get: ["/users"], del: ["/user/:id"]};
223
- assert.ok(tools.allowed(acl, {m: "get", v: "/users"}));
224
- assert.ok(!tools.allowed(acl, {m: "get", v: "/user/:id"}));
225
- assert.ok(tools.allowed(acl, {m: "delete", v: "/user/:id"}));
226
- assert.ok(!tools.allowed(acl, {m: "post", v: "/users"}));
223
+ assert.ok(tools.allowed(acl, {m: "get", v: "/users"}, true));
224
+ assert.ok(!tools.allowed(acl, {m: "get", v: "/user/:id"}, true));
225
+ assert.ok(tools.allowed(acl, {m: "delete", v: "/user/:id"}, true));
226
+ assert.ok(!tools.allowed(acl, {m: "post", v: "/users"}, true));
227
+ });
228
+ it("a restricted service that lists no apis allows none", () => {
229
+ assert.ok(!tools.allowed({version: "1"}, {m: "get", v: "/users"}, true));
230
+ });
231
+ });
232
+
233
+ describe("idempotent", () => {
234
+ let reqWith = (idempotency) => {
235
+ let req = makeReq();
236
+ req.soajs.registry.custom.gateway.value.idempotency = idempotency;
237
+ return req;
238
+ };
239
+ it("off without configuration, for a get, a disabled service or an api not listed", () => {
240
+ assert.strictEqual(tools.idempotent(makeReq(), "urac", coreTools[1]), false);
241
+ assert.strictEqual(tools.idempotent(reqWith({services: {urac: {enabled: true}}}), "urac", coreTools[0]), false);
242
+ assert.strictEqual(tools.idempotent(reqWith({services: {urac: {enabled: false}}}), "urac", coreTools[1]), false);
243
+ assert.strictEqual(tools.idempotent(reqWith({services: {urac: {enabled: true, apis: ["DELETE /user/:id"]}}}), "urac", coreTools[1]), false);
244
+ });
245
+ it("on for every write of an enabled service, or the apis it lists", () => {
246
+ assert.strictEqual(tools.idempotent(reqWith({services: {urac: {enabled: true}}}), "urac", coreTools[1]), true);
247
+ assert.strictEqual(tools.idempotent(reqWith({services: {urac: {enabled: true, apis: ["POST /user/:id"]}}}), "urac", coreTools[1]), true);
227
248
  });
228
249
  });
229
250
 
@@ -342,6 +363,32 @@ describe("Unit test for: mw - soajsRoutes mcp", () => {
342
363
  }).catch(done);
343
364
  });
344
365
 
366
+ it("listTools: the tools whose api has idempotency on take _idempotencyKey, the cache is not changed", (done) => {
367
+ let req = makeReq({services: services(), acl: {urac: {"1": {access: true}}}});
368
+ req.soajs.registry.custom.gateway.value.idempotency = {services: {urac: {enabled: true, apis: ["POST /user/:id"]}}};
369
+ tools.listTools(req, configuration, config).then((list) => {
370
+ let byRoute = {};
371
+ list.tools.forEach((t) => {
372
+ byRoute[t.description.split("[urac: ")[1]] = t;
373
+ });
374
+ let post = byRoute["POST /user/:id]"];
375
+ assert.deepStrictEqual(post.inputSchema.properties._idempotencyKey.type, "string");
376
+ assert.deepStrictEqual(post.inputSchema.required, ["id"]);
377
+ assert.ok(!byRoute["DELETE /user/:id]"].inputSchema.properties._idempotencyKey);
378
+ assert.ok(!byRoute["GET /users]"].inputSchema.properties._idempotencyKey);
379
+ assert.ok(!coreTools[1].inputSchema.properties._idempotencyKey);
380
+ done();
381
+ }).catch(done);
382
+ });
383
+
384
+ it("listTools: a restricted service that lists no apis has no tools", (done) => {
385
+ let acl = {urac: {"1": {access: true, apisPermission: "restricted", post: {apis: {}}}}};
386
+ tools.listTools(makeReq({services: services(), acl: acl}), configuration, config).then((list) => {
387
+ assert.deepStrictEqual(list.tools, []);
388
+ done();
389
+ }).catch(done);
390
+ });
391
+
345
392
  describe("callTool", () => {
346
393
  let gateway = null;
347
394
  let gatewayPort = null;
@@ -379,7 +426,9 @@ describe("Unit test for: mw - soajsRoutes mcp", () => {
379
426
  assert.strictEqual(received.headers.authorization, "Bearer abc");
380
427
  assert.strictEqual(received.headers.access_token, "tok");
381
428
  assert.strictEqual(received.headers["device-id"], "d1");
382
- assert.strictEqual(received.headers["idempotency-key"], "k1");
429
+ // the mcp request's own key is not forwarded, a write gets a new one
430
+ assert.notStrictEqual(received.headers["idempotency-key"], "k1");
431
+ assert.ok(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(received.headers["idempotency-key"]));
383
432
  assert.strictEqual(received.headers["x-forwarded-for"], "10.0.0.5");
384
433
  assert.ok(!received.headers.cookie);
385
434
  assert.deepStrictEqual(result, {
@@ -391,6 +440,41 @@ describe("Unit test for: mw - soajsRoutes mcp", () => {
391
440
  }).catch(done);
392
441
  });
393
442
 
443
+ it("idempotency: a key the agent gives is sent, a bad one is refused, a get has none", (done) => {
444
+ reply = {status: 200, body: {result: true, data: {}}};
445
+ let key = "3b241101-e2bb-4255-8caf-4136c566a962";
446
+ received = null;
447
+ tools.callTool(makeReq({gatewayPort: gatewayPort}), configuration, config, entry, {id: "u1", _idempotencyKey: key}).then(() => {
448
+ assert.strictEqual(received.headers["idempotency-key"], key);
449
+ // the key is not an api field, it is not in the body
450
+ assert.strictEqual(received.body, "");
451
+ received = null;
452
+ return tools.callTool(makeReq({gatewayPort: gatewayPort}), configuration, config, entry, {id: "u1", _idempotencyKey: "k1"});
453
+ }).then((result) => {
454
+ assert.deepStrictEqual(result, {"content": [{"type": "text", "text": "_idempotencyKey must be a UUID v4."}], "isError": true});
455
+ assert.strictEqual(received, null);
456
+ return tools.callTool(makeReq({gatewayPort: gatewayPort, headers: {"idempotency-key": key}}), configuration, config, {service: "urac", version: "1", tool: coreTools[0]}, {});
457
+ }).then(() => {
458
+ assert.ok(!received.headers["idempotency-key"]);
459
+ done();
460
+ }).catch(done);
461
+ });
462
+
463
+ it("idempotency: an api with its own _idempotencyKey field keeps it", (done) => {
464
+ reply = {status: 200, body: {result: true, data: {}}};
465
+ let tool = {
466
+ "m": "post", "v": "/thing", "l": "Thing",
467
+ "inputSchema": {"type": "object", "properties": {"_idempotencyKey": {"type": "string"}}},
468
+ "fields": {"_idempotencyKey": {"in": "body", "path": ["_idempotencyKey"]}}
469
+ };
470
+ tools.callTool(makeReq({gatewayPort: gatewayPort}), configuration, config, {service: "urac", version: "1", tool: tool}, {_idempotencyKey: "mine"}).then((result) => {
471
+ assert.strictEqual(result.isError, false);
472
+ assert.ok(!received.headers["idempotency-key"]);
473
+ assert.deepStrictEqual(JSON.parse(received.body), {_idempotencyKey: "mine"});
474
+ done();
475
+ }).catch(done);
476
+ });
477
+
394
478
  it("a get has no body, an array answer has no structuredContent", (done) => {
395
479
  reply = {status: 200, body: {result: true, data: [1, 2]}};
396
480
  let req = makeReq({gatewayPort: gatewayPort});
@@ -568,5 +652,16 @@ describe("Unit test for: mw - soajsRoutes mcp", () => {
568
652
  req = makeReq({acl: {urac: {"1": {access: true, apisPermission: "restricted", get: [{group: "a", apis: {"/x": {}}}, {group: "b", apis: {"/y": {}, "/x": {}}}]}}}});
569
653
  assert.deepStrictEqual(aclLib.computeACL(req).finalACL, {"urac": {"version": "1", "get": ["/x", "/y"]}});
570
654
  });
655
+ it("restricted follows the latest allowed version", () => {
656
+ let req = makeReq({
657
+ acl: {
658
+ urac: {"1": {access: true, apisPermission: "restricted", get: {apis: {"/x": {}}}}, "2": {access: true}},
659
+ oauth: {"1": {access: true, apisPermission: "restricted", post: {apis: {}}}}
660
+ }
661
+ });
662
+ let computed = aclLib.computeACL(req);
663
+ assert.deepStrictEqual(computed.finalACL, {"urac": {"version": "2"}, "oauth": {"version": "1"}});
664
+ assert.deepStrictEqual(computed.restricted, {"urac": false, "oauth": true});
665
+ });
571
666
  });
572
667
  });
@@ -0,0 +1,280 @@
1
+ "use strict";
2
+
3
+ const helper = require("../../../helper.js");
4
+ const auth = helper.requireModule('./mw/soajsRoutes/mcp/auth');
5
+ const assert = require('assert');
6
+
7
+ const KEYS = {
8
+ "agentsKey": {application: {product: "AGENTS", package: "AGENTS_ROLE"}},
9
+ "agentKey": {application: {product: "AGENTS", package: "AGENTS_ROLE"}},
10
+ "appKey": {application: {product: "AVAPP", package: "AVAPP_DEFAULT"}}
11
+ };
12
+
13
+ const TOKENS = {
14
+ "agentToken": {user: {restrictedTo: {tenant: "t1", key: "agentKey"}}, expires: new Date(Date.now() + 60000)},
15
+ "listToken": {user: {restrictedTo: {tenant: "t1", key: ["agentKey"]}}, expires: new Date(Date.now() + 60000)},
16
+ "userToken": {user: {}, expires: new Date(Date.now() + 60000)},
17
+ "oldToken": {user: {restrictedTo: {key: "agentKey"}}, expires: new Date(Date.now() - 1000)}
18
+ };
19
+
20
+ const provision = {
21
+ getExternalKeyData: (key, keyConfig, cb) => {
22
+ return KEYS[key] ? cb(null, KEYS[key]) : cb(new Error("no key"), null);
23
+ },
24
+ oauthModel: {
25
+ getAccessToken: (token, cb) => {
26
+ if (token === "broken") {
27
+ return cb(new Error("db down"));
28
+ }
29
+ return cb(null, TOKENS[token] || null);
30
+ }
31
+ }
32
+ };
33
+
34
+ const ENTRY = (name) => {
35
+ return {
36
+ "authorization_endpoint": "https://portal." + name + "/agents/authorize",
37
+ "token_endpoint": "https://api." + name + "/agents/oauth/token?key=agentsKey",
38
+ "registration_endpoint": "https://api." + name + "/agents/oauth/register?key=agentsKey"
39
+ };
40
+ };
41
+
42
+ const AUTH = {
43
+ "byProduct": {"AGENTS": ENTRY("agents")},
44
+ "byHost": {"api.client.example": ENTRY("client")},
45
+ "default": ENTRY("default")
46
+ };
47
+
48
+ let makeReq = (opts) => {
49
+ opts = opts || {};
50
+ let mcp = (opts.mcp === undefined) ? {enabled: true, auth: AUTH} : opts.mcp;
51
+ let req = {
52
+ method: opts.method || "POST",
53
+ url: opts.url || "/soajs/mcp",
54
+ headers: Object.assign({host: "api.connect.example", "x-forwarded-proto": "https"}, opts.headers || {}),
55
+ query: opts.query || {},
56
+ soajs: {
57
+ log: {error: () => {
58
+ }},
59
+ registry: {
60
+ serviceConfig: {key: {}},
61
+ custom: {gateway: {value: {mcp: mcp}}}
62
+ },
63
+ controller: {
64
+ serviceParams: {serviceInfo: opts.serviceInfo || ["", "soajs", "mcp"]}
65
+ }
66
+ }
67
+ };
68
+ return req;
69
+ };
70
+
71
+ /**
72
+ * Runs a mw: cb(null) when it calls next, cb(answer) when it answers {status, headers, body}.
73
+ */
74
+ let run = (mw, req, cb) => {
75
+ let res = {
76
+ writeHead: (status, headers) => {
77
+ res.status = status;
78
+ res.headers = headers;
79
+ },
80
+ end: (text) => {
81
+ cb({status: res.status, headers: res.headers, body: JSON.parse(text)});
82
+ }
83
+ };
84
+ mw(req, res, () => cb(null));
85
+ };
86
+
87
+ describe("Unit test for: mw - soajsRoutes mcp auth", () => {
88
+
89
+ describe("well-known metadata", () => {
90
+ let mw = auth.wellKnown({provision: provision});
91
+
92
+ it("not a well-known path, mcp off or no auth: next", (done) => {
93
+ run(mw, makeReq({method: "GET", url: "/urac/user"}), (answer) => {
94
+ assert.strictEqual(answer, null);
95
+ run(mw, makeReq({method: "GET", url: "/.well-known/oauth-protected-resource", mcp: {enabled: true}}), (answer) => {
96
+ assert.strictEqual(answer, null);
97
+ run(mw, makeReq({method: "GET", url: "/.well-known/oauth-protected-resource", mcp: {enabled: false, auth: AUTH}}), (answer) => {
98
+ assert.strictEqual(answer, null);
99
+ done();
100
+ });
101
+ });
102
+ });
103
+ });
104
+
105
+ it("protected resource: by the product of the key in the url", (done) => {
106
+ run(mw, makeReq({method: "GET", url: "/.well-known/oauth-protected-resource/soajs/mcp?key=agentsKey"}), (answer) => {
107
+ assert.strictEqual(answer.status, 200);
108
+ assert.deepStrictEqual(answer.body, {
109
+ "resource": "https://api.connect.example/soajs/mcp?key=agentsKey",
110
+ "authorization_servers": ["https://api.connect.example/soajs/mcp/auth/product/AGENTS"],
111
+ "bearer_methods_supported": ["header"]
112
+ });
113
+ done();
114
+ });
115
+ });
116
+
117
+ it("protected resource: by host, then the default, a product with no entry falls back", (done) => {
118
+ run(mw, makeReq({method: "GET", url: "/.well-known/oauth-protected-resource", headers: {"x-forwarded-host": "api.client.example, proxy"}}), (answer) => {
119
+ assert.deepStrictEqual(answer.body.authorization_servers, ["https://api.client.example/soajs/mcp/auth/host"]);
120
+ assert.strictEqual(answer.body.resource, "https://api.client.example/soajs/mcp");
121
+ run(mw, makeReq({method: "GET", url: "/.well-known/oauth-protected-resource/soajs/mcp?key=appKey"}), (answer) => {
122
+ assert.deepStrictEqual(answer.body.authorization_servers, ["https://api.connect.example/soajs/mcp/auth/default"]);
123
+ done();
124
+ });
125
+ });
126
+ });
127
+
128
+ it("protected resource: nothing configured for the request: next", (done) => {
129
+ run(mw, makeReq({method: "GET", url: "/.well-known/oauth-protected-resource", mcp: {enabled: true, auth: {byHost: {"other": ENTRY("x")}}}}), (answer) => {
130
+ assert.strictEqual(answer, null);
131
+ done();
132
+ });
133
+ });
134
+
135
+ it("authorization server: the entry the issuer names, in both well-known forms", (done) => {
136
+ run(mw, makeReq({method: "GET", url: "/.well-known/oauth-authorization-server/soajs/mcp/auth/product/AGENTS"}), (answer) => {
137
+ assert.strictEqual(answer.status, 200);
138
+ assert.deepStrictEqual(answer.body, {
139
+ "issuer": "https://api.connect.example/soajs/mcp/auth/product/AGENTS",
140
+ "authorization_endpoint": "https://portal.agents/agents/authorize",
141
+ "token_endpoint": "https://api.agents/agents/oauth/token?key=agentsKey",
142
+ "response_types_supported": ["code"],
143
+ "grant_types_supported": ["authorization_code"],
144
+ "code_challenge_methods_supported": ["S256"],
145
+ "token_endpoint_auth_methods_supported": ["none"],
146
+ "registration_endpoint": "https://api.agents/agents/oauth/register?key=agentsKey"
147
+ });
148
+ run(mw, makeReq({method: "GET", url: "/.well-known/openid-configuration/soajs/mcp/auth/default"}), (answer) => {
149
+ assert.strictEqual(answer.body.authorization_endpoint, "https://portal.default/agents/authorize");
150
+ done();
151
+ });
152
+ });
153
+ });
154
+
155
+ it("authorization server: an unknown issuer, an inherited name or a post: next", (done) => {
156
+ run(mw, makeReq({method: "GET", url: "/.well-known/oauth-authorization-server/soajs/mcp/auth/product/OTHER"}), (answer) => {
157
+ assert.strictEqual(answer, null);
158
+ run(mw, makeReq({method: "GET", url: "/.well-known/oauth-authorization-server/soajs/mcp/auth/product/constructor"}), (answer) => {
159
+ assert.strictEqual(answer, null);
160
+ run(mw, makeReq({method: "POST", url: "/.well-known/oauth-authorization-server/soajs/mcp/auth/default"}), (answer) => {
161
+ assert.strictEqual(answer, null);
162
+ done();
163
+ });
164
+ });
165
+ });
166
+ });
167
+
168
+ it("an entry without the token endpoint is not used", (done) => {
169
+ let mcp = {enabled: true, auth: {default: {authorization_endpoint: "https://p/a"}}};
170
+ run(mw, makeReq({method: "GET", url: "/.well-known/oauth-protected-resource", mcp: mcp}), (answer) => {
171
+ assert.strictEqual(answer, null);
172
+ done();
173
+ });
174
+ });
175
+ });
176
+
177
+ describe("authorize", () => {
178
+ let mw = auth.authorize({provision: provision});
179
+
180
+ it("another route, or mcp off: next, untouched", (done) => {
181
+ let req = makeReq({serviceInfo: ["", "urac", "user"]});
182
+ run(mw, req, (answer) => {
183
+ assert.strictEqual(answer, null);
184
+ run(mw, makeReq({mcp: {enabled: false, auth: AUTH}}), (answer) => {
185
+ assert.strictEqual(answer, null);
186
+ done();
187
+ });
188
+ });
189
+ });
190
+
191
+ it("no token and no auth configured: next, as before", (done) => {
192
+ run(mw, makeReq({mcp: {enabled: true}}), (answer) => {
193
+ assert.strictEqual(answer, null);
194
+ done();
195
+ });
196
+ });
197
+
198
+ it("no token: 401 with the metadata address, the url key kept", (done) => {
199
+ run(mw, makeReq({headers: {key: "agentsKey"}, query: {key: "agentsKey"}}), (answer) => {
200
+ assert.strictEqual(answer.status, 401);
201
+ assert.strictEqual(answer.headers["WWW-Authenticate"],
202
+ 'Bearer resource_metadata="https://api.connect.example/.well-known/oauth-protected-resource/soajs/mcp?key=agentsKey"');
203
+ assert.strictEqual(answer.body.error, "unauthorized");
204
+ run(mw, makeReq(), (answer) => {
205
+ assert.strictEqual(answer.headers["WWW-Authenticate"],
206
+ 'Bearer resource_metadata="https://api.connect.example/.well-known/oauth-protected-resource/soajs/mcp"');
207
+ done();
208
+ });
209
+ });
210
+ });
211
+
212
+ it("an unknown or expired token: 401 invalid_token", (done) => {
213
+ run(mw, makeReq({headers: {authorization: "Bearer nope"}}), (answer) => {
214
+ assert.strictEqual(answer.status, 401);
215
+ assert.ok(answer.headers["WWW-Authenticate"].indexOf('error="invalid_token"') !== -1);
216
+ run(mw, makeReq({headers: {access_token: "oldToken"}}), (answer) => {
217
+ assert.strictEqual(answer.body.error, "invalid_token");
218
+ done();
219
+ });
220
+ });
221
+ });
222
+
223
+ it("the token store fails: next, the oauth mw decides", (done) => {
224
+ run(mw, makeReq({query: {access_token: "broken"}}), (answer) => {
225
+ assert.strictEqual(answer, null);
226
+ done();
227
+ });
228
+ });
229
+
230
+ it("no key: the key the token is restricted to, a one-key list too", (done) => {
231
+ let req = makeReq({headers: {authorization: "Bearer agentToken"}});
232
+ run(mw, req, (answer) => {
233
+ assert.strictEqual(answer, null);
234
+ assert.strictEqual(req.headers.key, "agentKey");
235
+ let req2 = makeReq({headers: {authorization: "Bearer listToken"}});
236
+ run(mw, req2, () => {
237
+ assert.strictEqual(req2.headers.key, "agentKey");
238
+ done();
239
+ });
240
+ });
241
+ });
242
+
243
+ it("a key of the same product is replaced, of another product kept", (done) => {
244
+ let req = makeReq({headers: {authorization: "Bearer agentToken", key: "agentsKey"}});
245
+ run(mw, req, (answer) => {
246
+ assert.strictEqual(answer, null);
247
+ assert.strictEqual(req.headers.key, "agentKey");
248
+ let req2 = makeReq({headers: {authorization: "Bearer agentToken", key: "appKey"}});
249
+ run(mw, req2, () => {
250
+ assert.strictEqual(req2.headers.key, "appKey");
251
+ done();
252
+ });
253
+ });
254
+ });
255
+
256
+ it("a token with no restricted key leaves the request as it is", (done) => {
257
+ let req = makeReq({headers: {authorization: "Bearer userToken"}});
258
+ run(mw, req, (answer) => {
259
+ assert.strictEqual(answer, null);
260
+ assert.ok(!req.headers.key);
261
+ done();
262
+ });
263
+ });
264
+ });
265
+
266
+ describe("helpers", () => {
267
+ it("tokenKey: one key only", () => {
268
+ assert.strictEqual(auth.tokenKey({user: {restrictedTo: {key: "a"}}}), "a");
269
+ assert.strictEqual(auth.tokenKey({user: {restrictedTo: {key: ["a"]}}}), "a");
270
+ assert.strictEqual(auth.tokenKey({user: {restrictedTo: {key: ["a", "b"]}}}), null);
271
+ assert.strictEqual(auth.tokenKey({user: {}}), null);
272
+ assert.strictEqual(auth.tokenKey(null), null);
273
+ });
274
+ it("requestOrigin: forwarded proto and host, else the connection", () => {
275
+ let req = makeReq({headers: {"x-forwarded-proto": undefined}});
276
+ req.socket = {encrypted: false};
277
+ assert.strictEqual(auth.requestOrigin(req), "http://api.connect.example");
278
+ });
279
+ });
280
+ });