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