soajs.controller 4.3.25 → 4.3.27

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
@@ -63,6 +63,57 @@ 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
+ - **`<origin>`** below is the scheme and host the request came in on: `X-Forwarded-Proto` and
96
+ `X-Forwarded-Host` when set. Behind a proxy that does not pass the scheme on, set
97
+ `"scheme": "https"` in `auth`; the host still comes from the request.
98
+ - **No token, or an unknown or expired one:** HTTP 401 with
99
+ `WWW-Authenticate: Bearer resource_metadata="<origin>/.well-known/oauth-protected-resource/soajs/mcp"`,
100
+ plus `?key=` when the client's URL had one. If nothing matches the request, the answer is as without
101
+ `auth`.
102
+ - **`GET /.well-known/oauth-protected-resource[/soajs/mcp]`:** `resource`, `authorization_servers`
103
+ (the issuer `<origin>/soajs/mcp/auth/<product/CODE|host|default>`) and `bearer_methods_supported`.
104
+ - **`GET /.well-known/oauth-authorization-server/<issuer path>`** (also `openid-configuration`):
105
+ - the issuer and the entry's endpoints;
106
+ - `code` with PKCE `S256`;
107
+ - grant `authorization_code`;
108
+ - token endpoint auth `none`.
109
+ - **The key from the token.** A client sends no key header. When the token is restricted to one key
110
+ (`user.restrictedTo.key`):
111
+ - `/soajs/mcp` without a key uses that key;
112
+ - with a key of the same product (a product URL), the token's key replaces it;
113
+ - a key of another product is kept, and the token is refused on it (147).
114
+ - **The issued token:** the token endpoint should return one restricted to the agent's tenant,
115
+ product, package and key, e.g. from soajs.oauth `POST /restricted/token/guest`. The gateway then
116
+ checks it on every call like any restricted token.
66
117
 
67
118
  ## Protocol
68
119
 
@@ -91,8 +142,20 @@ streaming. `GET` answers 405.
91
142
 
92
143
  The arguments are split back into the path, the query string and the body, and the call goes to
93
144
  `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`.
145
+ `authorization` / `access_token`, `device-id`, `user-agent` and `accept-language` headers and
146
+ `x-forwarded-for`.
147
+
148
+ **Idempotency.** An `Idempotency-Key` header on the MCP request is not forwarded: it is the same for
149
+ every call of a connection, so the API would answer every later call with the first call's response.
150
+ Instead:
151
+
152
+ - A tool whose API has idempotency on (`registry.custom.gateway.value.idempotency`, by service and
153
+ API) takes an optional `_idempotencyKey` argument: a UUID v4 that the agent sends again when it
154
+ retries the same call, so the call runs once. A value that isn't a UUID v4 is a tool error, and
155
+ nothing is called.
156
+ - A POST, PUT or DELETE without one gets a new key, so an API that requires a key works, and two
157
+ calls are never taken for one.
158
+ - An API with its own `_idempotencyKey` field keeps it as a field.
96
159
 
97
160
  - **Success:** `content` has the response `data` as JSON text, `structuredContent` the same when it
98
161
  is an object.
@@ -136,5 +199,6 @@ URL.
136
199
  | File | Role |
137
200
  |------|------|
138
201
  | `mw/soajsRoutes/mcp/index.js` | The route: JSON-RPC handling |
139
- | `mw/soajsRoutes/mcp/tools.js` | Tools: fetch, cache, ACL filter, naming, the call |
202
+ | `mw/soajsRoutes/mcp/tools.js` | Tools: fetch, cache, ACL filter, naming, idempotency, the call |
203
+ | `mw/soajsRoutes/mcp/auth.js` | Authorization: the 401, the well-known metadata, the key from the token |
140
204
  | `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;
@@ -0,0 +1,340 @@
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. The addresses the gateway gives out use the
20
+ * request's host and auth.scheme, else the scheme the request came in with.
21
+ *
22
+ * An MCP client sends the token only, no key. A token restricted to one key (user.restrictedTo.key)
23
+ * brings it: /soajs/mcp without a key uses it, and with a key of the same product the token's key
24
+ * replaces it.
25
+ */
26
+
27
+ const get = (p, o) => p.reduce((xs, x) => (xs && xs[x]) ? xs[x] : null, o);
28
+
29
+ const MCP_PATH = "/soajs/mcp";
30
+ const ISSUER_PATH = MCP_PATH + "/auth/";
31
+ const PRM_PATH = "/.well-known/oauth-protected-resource";
32
+ const AS_PATHS = ["/.well-known/oauth-authorization-server", "/.well-known/openid-configuration"];
33
+
34
+ /**
35
+ * The auth configuration: registry.custom.gateway.value.mcp.auth, when mcp is enabled.
36
+ */
37
+ function getAuthConfig(req) {
38
+ let mcp = get(["soajs", "registry", "custom", "gateway", "value", "mcp"], req);
39
+ if (!mcp || mcp.enabled !== true || !mcp.auth || typeof mcp.auth !== "object") {
40
+ return null;
41
+ }
42
+ return mcp.auth;
43
+ }
44
+
45
+ /**
46
+ * An entry is usable when it names the authorization and token endpoints.
47
+ */
48
+ function usable(entry) {
49
+ return !!entry && typeof entry === "object" &&
50
+ typeof entry.authorization_endpoint === "string" && entry.authorization_endpoint !== "" &&
51
+ typeof entry.token_endpoint === "string" && entry.token_endpoint !== "";
52
+ }
53
+
54
+ function firstValue(header) {
55
+ return (typeof header === "string") ? header.split(",")[0].trim() : null;
56
+ }
57
+
58
+ /**
59
+ * The host the request came in on, and the origin the client sees.
60
+ */
61
+ function requestHost(req) {
62
+ return (firstValue(req.headers["x-forwarded-host"]) || req.headers.host || "").toLowerCase();
63
+ }
64
+
65
+ /**
66
+ * The scheme is auth.scheme when set, for a proxy that does not pass X-Forwarded-Proto on, else
67
+ * X-Forwarded-Proto, else the connection's.
68
+ */
69
+ function requestOrigin(req) {
70
+ let auth = getAuthConfig(req);
71
+ let proto = (auth && (auth.scheme === "https" || auth.scheme === "http")) ? auth.scheme : null;
72
+ if (!proto) {
73
+ proto = firstValue(req.headers["x-forwarded-proto"]);
74
+ }
75
+ if (!proto) {
76
+ proto = (req.socket && req.socket.encrypted) ? "https" : "http";
77
+ }
78
+ return proto + "://" + requestHost(req);
79
+ }
80
+
81
+ function ownEntry(map, name) {
82
+ if (!map || typeof map !== "object" || !name || !Object.hasOwnProperty.call(map, name)) {
83
+ return null;
84
+ }
85
+ return usable(map[name]) ? map[name] : null;
86
+ }
87
+
88
+ /**
89
+ * The entry for a request: by the product of its key, by its host, the default.
90
+ *
91
+ * @param cb (match) match: {id, entry} or null
92
+ */
93
+ function resolveEntry(req, provision, auth, key, cb) {
94
+ let byHost = () => {
95
+ let entry = ownEntry(auth.byHost, requestHost(req));
96
+ if (entry) {
97
+ return cb({"id": "host", "entry": entry});
98
+ }
99
+ if (usable(auth.default)) {
100
+ return cb({"id": "default", "entry": auth.default});
101
+ }
102
+ return cb(null);
103
+ };
104
+ if (!key || !auth.byProduct) {
105
+ return byHost();
106
+ }
107
+ provision.getExternalKeyData(key, get(["soajs", "registry", "serviceConfig", "key"], req), (err, keyObj) => {
108
+ let product = get(["application", "product"], keyObj);
109
+ let entry = ownEntry(auth.byProduct, product);
110
+ if (entry) {
111
+ return cb({"id": "product/" + product, "entry": entry});
112
+ }
113
+ return byHost();
114
+ });
115
+ }
116
+
117
+ /**
118
+ * The entry an issuer id names, as resolveEntry gave it.
119
+ */
120
+ function entryById(req, auth, id) {
121
+ if (id === "host") {
122
+ return ownEntry(auth.byHost, requestHost(req));
123
+ }
124
+ if (id === "default") {
125
+ return usable(auth.default) ? auth.default : null;
126
+ }
127
+ if (id.indexOf("product/") === 0) {
128
+ return ownEntry(auth.byProduct, id.substring("product/".length));
129
+ }
130
+ return null;
131
+ }
132
+
133
+ function issuer(req, id) {
134
+ return requestOrigin(req) + ISSUER_PATH + id;
135
+ }
136
+
137
+ function sendJSON(res, status, body, headers) {
138
+ let text = JSON.stringify(body);
139
+ res.writeHead(status, Object.assign({
140
+ "Content-Type": "application/json",
141
+ "Content-Length": Buffer.byteLength(text),
142
+ "Cache-Control": "no-store"
143
+ }, headers || {}));
144
+ res.end(text);
145
+ }
146
+
147
+ /**
148
+ * 401 with the address of the protected resource metadata. A key the client put in the url stays
149
+ * in it, the metadata is then chosen by the same product.
150
+ */
151
+ function challenge(req, res, queryKey, invalidToken) {
152
+ let metadata = requestOrigin(req) + PRM_PATH + MCP_PATH + (queryKey ? "?key=" + encodeURIComponent(queryKey) : "");
153
+ let value = 'Bearer resource_metadata="' + metadata + '"';
154
+ if (invalidToken) {
155
+ value += ', error="invalid_token"';
156
+ }
157
+ sendJSON(res, 401, {
158
+ "error": invalidToken ? "invalid_token" : "unauthorized",
159
+ "error_description": invalidToken ? "The access token is invalid or has expired." : "Authorization required."
160
+ }, {
161
+ "WWW-Authenticate": value,
162
+ "Access-Control-Expose-Headers": "WWW-Authenticate"
163
+ });
164
+ }
165
+
166
+ function queryOf(url) {
167
+ try {
168
+ return new URL(url, "http://localhost").searchParams;
169
+ } catch (e) {
170
+ return new URLSearchParams();
171
+ }
172
+ }
173
+
174
+ function pathOf(url) {
175
+ let i = url.indexOf("?");
176
+ return (i === -1) ? url : url.substring(0, i);
177
+ }
178
+
179
+ /**
180
+ * The token of a request: the access_token header or query, or Authorization: Bearer.
181
+ */
182
+ function tokenOf(req) {
183
+ if (req.query && req.query.access_token) {
184
+ return req.query.access_token;
185
+ }
186
+ if (req.headers.access_token) {
187
+ return req.headers.access_token;
188
+ }
189
+ let matches = (req.headers.authorization || "").match(/^Bearer\s+(\S+)/i);
190
+ return matches ? matches[1] : null;
191
+ }
192
+
193
+ /**
194
+ * The key a token is restricted to, when it is exactly one.
195
+ */
196
+ function tokenKey(record) {
197
+ let key = get(["user", "restrictedTo", "key"], record);
198
+ if (typeof key === "string" && key !== "") {
199
+ return key;
200
+ }
201
+ if (Array.isArray(key) && key.length === 1 && typeof key[0] === "string" && key[0] !== "") {
202
+ return key[0];
203
+ }
204
+ return null;
205
+ }
206
+
207
+ /**
208
+ * GET /.well-known/oauth-protected-resource[/soajs/mcp] and
209
+ * GET /.well-known/{oauth-authorization-server|openid-configuration}/soajs/mcp/auth/<id>,
210
+ * before the url mw, which knows no service named .well-known.
211
+ */
212
+ function wellKnown(configuration) {
213
+ let provision = configuration.provision;
214
+
215
+ return (req, res, next) => {
216
+ let path = pathOf(req.url || "");
217
+ if (path.indexOf("/.well-known/") !== 0) {
218
+ return next();
219
+ }
220
+ let auth = getAuthConfig(req);
221
+ if (!auth || (req.method !== "GET" && req.method !== "HEAD")) {
222
+ return next();
223
+ }
224
+ if (path === PRM_PATH || path === PRM_PATH + MCP_PATH) {
225
+ let queryKey = queryOf(req.url).get("key");
226
+ return resolveEntry(req, provision, auth, queryKey, (match) => {
227
+ if (!match) {
228
+ return next();
229
+ }
230
+ return sendJSON(res, 200, {
231
+ "resource": requestOrigin(req) + MCP_PATH + (queryKey ? "?key=" + encodeURIComponent(queryKey) : ""),
232
+ "authorization_servers": [issuer(req, match.id)],
233
+ "bearer_methods_supported": ["header"]
234
+ });
235
+ });
236
+ }
237
+ for (let i = 0; i < AS_PATHS.length; i++) {
238
+ let prefix = AS_PATHS[i] + ISSUER_PATH;
239
+ if (path.indexOf(prefix) === 0) {
240
+ let id = decodeURIComponent(path.substring(prefix.length));
241
+ let entry = entryById(req, auth, id);
242
+ if (!entry) {
243
+ return next();
244
+ }
245
+ let metadata = {
246
+ "issuer": issuer(req, id),
247
+ "authorization_endpoint": entry.authorization_endpoint,
248
+ "token_endpoint": entry.token_endpoint,
249
+ "response_types_supported": ["code"],
250
+ "grant_types_supported": ["authorization_code"],
251
+ "code_challenge_methods_supported": ["S256"],
252
+ "token_endpoint_auth_methods_supported": ["none"]
253
+ };
254
+ if (typeof entry.registration_endpoint === "string" && entry.registration_endpoint !== "") {
255
+ metadata.registration_endpoint = entry.registration_endpoint;
256
+ }
257
+ return sendJSON(res, 200, metadata);
258
+ }
259
+ }
260
+ return next();
261
+ };
262
+ }
263
+
264
+ /**
265
+ * /soajs/mcp, after the url mw and before the key mw: the 401 when the caller must authorize, and
266
+ * the key from a token restricted to one key.
267
+ */
268
+ function authorize(configuration) {
269
+ let provision = configuration.provision;
270
+
271
+ return (req, res, next) => {
272
+ let info = get(["soajs", "controller", "serviceParams", "serviceInfo"], req);
273
+ if (!info || info[1] !== "soajs" || info[2] !== "mcp") {
274
+ return next();
275
+ }
276
+ let auth = getAuthConfig(req);
277
+ let mcp = get(["soajs", "registry", "custom", "gateway", "value", "mcp"], req);
278
+ if (!mcp || mcp.enabled !== true) {
279
+ return next();
280
+ }
281
+ let queryKey = (req.query && req.query.key) || null;
282
+ let askToAuthorize = (invalidToken) => {
283
+ if (!auth) {
284
+ return next();
285
+ }
286
+ resolveEntry(req, provision, auth, req.headers.key, (match) => {
287
+ if (!match) {
288
+ return next();
289
+ }
290
+ return challenge(req, res, queryKey, invalidToken);
291
+ });
292
+ };
293
+
294
+ let token = tokenOf(req);
295
+ if (!token) {
296
+ return askToAuthorize(false);
297
+ }
298
+ provision.oauthModel.getAccessToken(token, (err, record) => {
299
+ if (err) {
300
+ req.soajs.log.error("MCP: unable to read the access token: " + err.message);
301
+ return next();
302
+ }
303
+ if (!record || (record.expires && new Date(record.expires).getTime() < Date.now())) {
304
+ return askToAuthorize(true);
305
+ }
306
+ let restrictedKey = tokenKey(record);
307
+ if (!restrictedKey || req.headers.key === restrictedKey) {
308
+ return next();
309
+ }
310
+ if (!req.headers.key) {
311
+ req.headers.key = restrictedKey;
312
+ return next();
313
+ }
314
+ //NOTE: the url names a product, the token a key of it: the token's key. A key of another
315
+ // product is left as is, the oauth mw refuses the token on it (147).
316
+ let keyConfig = get(["soajs", "registry", "serviceConfig", "key"], req);
317
+ provision.getExternalKeyData(req.headers.key, keyConfig, (err, given) => {
318
+ provision.getExternalKeyData(restrictedKey, keyConfig, (err, own) => {
319
+ let givenProduct = get(["application", "product"], given);
320
+ let ownProduct = get(["application", "product"], own);
321
+ if (givenProduct && givenProduct === ownProduct) {
322
+ req.headers.key = restrictedKey;
323
+ }
324
+ return next();
325
+ });
326
+ });
327
+ });
328
+ };
329
+ }
330
+
331
+ module.exports = {
332
+ getAuthConfig,
333
+ resolveEntry,
334
+ entryById,
335
+ requestOrigin,
336
+ tokenOf,
337
+ tokenKey,
338
+ wellKnown,
339
+ authorize
340
+ };
@@ -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
@@ -235,6 +243,33 @@ function allowed(serviceACL, tool, restricted) {
235
243
  return Array.isArray(list) && list.indexOf(tool.v) !== -1;
236
244
  }
237
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
+
238
273
  /**
239
274
  * The tools the caller may use.
240
275
  *
@@ -258,6 +293,16 @@ function listTools(req, configuration, config) {
258
293
  let name = toolName(serviceName, tool, taken);
259
294
  let description = tool.l || (tool.m.toUpperCase() + " " + tool.v);
260
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
+ }
261
306
  tools.push({
262
307
  "name": name,
263
308
  "title": description,
@@ -371,6 +416,22 @@ function callTool(req, configuration, config, entry, args) {
371
416
  headers[h] = req.headers[h];
372
417
  }
373
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
+ }
374
435
  if (!headers.access_token && req.query && req.query.access_token) {
375
436
  headers.access_token = req.query.access_token;
376
437
  }
@@ -427,6 +488,8 @@ module.exports = {
427
488
  serviceTools,
428
489
  toolName,
429
490
  allowed,
491
+ idempotent,
492
+ takesIdempotencyArg,
430
493
  listTools,
431
494
  buildCall,
432
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.25",
4
+ "version": "4.3.27",
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");
@@ -230,6 +230,24 @@ describe("Unit test for: mw - soajsRoutes mcp", () => {
230
230
  });
231
231
  });
232
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);
248
+ });
249
+ });
250
+
233
251
  describe("buildCall", () => {
234
252
  it("path params, query and nested body", () => {
235
253
  let call = tools.buildCall(coreTools[1], {id: "a b/c", name: "Ann", other: 1});
@@ -345,6 +363,24 @@ describe("Unit test for: mw - soajsRoutes mcp", () => {
345
363
  }).catch(done);
346
364
  });
347
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
+
348
384
  it("listTools: a restricted service that lists no apis has no tools", (done) => {
349
385
  let acl = {urac: {"1": {access: true, apisPermission: "restricted", post: {apis: {}}}}};
350
386
  tools.listTools(makeReq({services: services(), acl: acl}), configuration, config).then((list) => {
@@ -390,7 +426,9 @@ describe("Unit test for: mw - soajsRoutes mcp", () => {
390
426
  assert.strictEqual(received.headers.authorization, "Bearer abc");
391
427
  assert.strictEqual(received.headers.access_token, "tok");
392
428
  assert.strictEqual(received.headers["device-id"], "d1");
393
- 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"]));
394
432
  assert.strictEqual(received.headers["x-forwarded-for"], "10.0.0.5");
395
433
  assert.ok(!received.headers.cookie);
396
434
  assert.deepStrictEqual(result, {
@@ -402,6 +440,41 @@ describe("Unit test for: mw - soajsRoutes mcp", () => {
402
440
  }).catch(done);
403
441
  });
404
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
+
405
478
  it("a get has no body, an array answer has no structuredContent", (done) => {
406
479
  reply = {status: 200, body: {result: true, data: [1, 2]}};
407
480
  let req = makeReq({gatewayPort: gatewayPort});
@@ -0,0 +1,302 @@
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
+ it("requestOrigin: auth.scheme wins over the forwarded proto, the host stays", () => {
280
+ let req = makeReq({headers: {"x-forwarded-proto": "http"}, mcp: {enabled: true, auth: Object.assign({scheme: "https"}, AUTH)}});
281
+ assert.strictEqual(auth.requestOrigin(req), "https://api.connect.example");
282
+ req = makeReq({headers: {"x-forwarded-proto": "http"}, mcp: {enabled: true, auth: Object.assign({scheme: "ftp"}, AUTH)}});
283
+ assert.strictEqual(auth.requestOrigin(req), "http://api.connect.example");
284
+ });
285
+ it("auth.scheme in the metadata and the 401", (done) => {
286
+ let mcp = {enabled: true, auth: Object.assign({scheme: "https"}, AUTH)};
287
+ let mw = auth.wellKnown({provision: provision});
288
+ run(mw, makeReq({method: "GET", url: "/.well-known/oauth-protected-resource", mcp: mcp, headers: {"x-forwarded-proto": "http"}}), (answer) => {
289
+ assert.strictEqual(answer.body.resource, "https://api.connect.example/soajs/mcp");
290
+ assert.deepStrictEqual(answer.body.authorization_servers, ["https://api.connect.example/soajs/mcp/auth/default"]);
291
+ run(mw, makeReq({method: "GET", url: "/.well-known/oauth-authorization-server/soajs/mcp/auth/default", mcp: mcp, headers: {"x-forwarded-proto": "http"}}), (answer) => {
292
+ assert.strictEqual(answer.body.issuer, "https://api.connect.example/soajs/mcp/auth/default");
293
+ run(auth.authorize({provision: provision}), makeReq({mcp: mcp, headers: {"x-forwarded-proto": "http"}}), (answer) => {
294
+ assert.strictEqual(answer.headers["WWW-Authenticate"],
295
+ 'Bearer resource_metadata="https://api.connect.example/.well-known/oauth-protected-resource/soajs/mcp"');
296
+ done();
297
+ });
298
+ });
299
+ });
300
+ });
301
+ });
302
+ });