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 +67 -3
- package/mw/idempotency/index.js +2 -0
- package/mw/soajsRoutes/mcp/auth.js +340 -0
- package/mw/soajsRoutes/mcp/tools.js +64 -1
- package/package.json +1 -1
- package/server/controller.js +7 -0
- package/test/unit/index.js +1 -0
- package/test/unit/mw/soajsRoutes/mcp.js +74 -1
- package/test/unit/mw/soajsRoutes/mcpAuth.js +302 -0
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
|
|
95
|
-
`
|
|
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` |
|
package/mw/idempotency/index.js
CHANGED
|
@@ -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
|
-
|
|
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
package/server/controller.js
CHANGED
|
@@ -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
|
package/test/unit/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|