soajs.controller 4.3.15 → 4.3.17

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.
@@ -70,6 +70,13 @@ Uses oauth2-server library for token validation.
70
70
  ├─── Mismatch ──▶ Error 156
71
71
  │
72
72
  ▼
73
+ ┌─────────────────────────┐
74
+ │ restrictedTo check │ token user.restrictedTo vs the request
75
+ └────────┬────────────────┘
76
+ │
77
+ ├─── Mismatch ──▶ Error 147
78
+ │
79
+ ▼
73
80
  next()
74
81
  ```
75
82
 
@@ -107,6 +114,53 @@ Read from `registry.custom.gateway.value.oauth.deviceIdCheck`. Only the boolean
107
114
  turns it off, any other value leaves it on, so a missing or malformed entry keeps the
108
115
  check running.
109
116
 
117
+ #### restrictedTo check
118
+
119
+ A minted token can carry a `restrictedTo` object under `user`, scoping it to where it may be
120
+ used. It is checked on the record `authorise()` already fetched, so it costs no extra database
121
+ call.
122
+
123
+ ```json
124
+ {
125
+ "restrictedTo": {
126
+ "tenant": "<tenant id>",
127
+ "product": "PRODWEB"
128
+ }
129
+ }
130
+ ```
131
+
132
+ Only the keys present are checked. **A token with no `restrictedTo` is not checked at all**,
133
+ so every token issued before restricted tokens existed behaves exactly as before. Each key
134
+ takes a string or an array of strings.
135
+
136
+ There is no registry switch for this check, unlike `deviceIdCheck` above. Writing a
137
+ restriction onto a token is the opt in, and once it is there it is always enforced.
138
+
139
+ | key | compared against |
140
+ |-----|------------------|
141
+ | `tenant` | `req.soajs.tenant.id` |
142
+ | `product` | `req.soajs.tenant.application.product` |
143
+ | `package` | `req.soajs.tenant.application.package` |
144
+ | `key` | `req.soajs.tenant.key.eKey` |
145
+ | `env` | the gateway environment, matched case insensitively |
146
+ | `agent` | the `user-agent` request header |
147
+
148
+ The first five come from the ext key on the request, which `mw/mt` resolves before oauth runs.
149
+ So a token minted for one tenant and product is refused on any other key, which is the point.
150
+
151
+ `agent` is an exact match, and this is the only place agent is ever enforced. The login and
152
+ refresh flows do not check it, so setting it affects nothing but the token that carries it.
153
+ Use it only on short lived tokens, a user-agent string changes whenever the browser updates.
154
+
155
+ This check is only available for type 2, a type 0 JWT carries no stored token record.
156
+
157
+ ##### Failing closed
158
+
159
+ A service with `extKeyRequired` off gives the request no key, so there is nothing to compare
160
+ against. A token carrying `restrictedTo` is **denied** in that case rather than allowed
161
+ through, otherwise a restricted token could escape its restriction by targeting a keyless
162
+ service.
163
+
110
164
  ### Type 0: JWT
111
165
 
112
166
  Uses jsonwebtoken library for JWT validation.
@@ -199,6 +253,7 @@ Default OAuth service configuration:
199
253
  | Code | Description |
200
254
  |------|-------------|
201
255
  | 143 | Invalid or missing JWT token |
256
+ | 147 | Token restriction mismatch, the token's restrictedTo does not allow this request |
202
257
  | 156 | Device forbidden, the deviceId on the token does not match the device-id header |
203
258
  | OAuth2Error | Various OAuth2 errors (invalid_token, expired, etc.) |
204
259
 
package/mw/mt/lib.js CHANGED
@@ -45,7 +45,7 @@ module.exports = {
45
45
  async.eachOfSeries(service, function (version, versionValue, versionCall) {
46
46
  if (!acl.acl[serviceName][versionValue]) {
47
47
  acl.acl[serviceName][versionValue] = _.cloneDeep(version);
48
- return serviceCall();
48
+ return versionCall();
49
49
  }
50
50
  if (version.apisPermission === "restricted") {
51
51
  if (acl.acl[serviceName] && acl.acl[serviceName][versionValue] && acl.acl[serviceName][versionValue].apisPermission && acl.acl[serviceName][versionValue].apisPermission === "restricted") {
@@ -89,7 +89,7 @@ module.exports = {
89
89
  async.eachOf(service, function (version, versionValue, versionCall) {
90
90
  if (!acl.acl_all_env[envName][serviceName][versionValue]) {
91
91
  acl.acl_all_env[envName][serviceName][versionValue] = _.cloneDeep(version);
92
- return serviceCall();
92
+ return versionCall();
93
93
  }
94
94
  if (version.apisPermission === "restricted") {
95
95
  if (acl.acl_all_env[envName][serviceName][versionValue].apisPermission && acl.acl_all_env[envName][serviceName][versionValue].apisPermission === "restricted") {
package/mw/oauth/index.js CHANGED
@@ -13,6 +13,10 @@
13
13
  * @param configuration
14
14
  * @returns {Function}
15
15
  */
16
+ //NOTE: the token env is stamped the same way at soajs.core/provision/mongo.js, so the
17
+ // restrictedTo env constraint is compared against the identical expression.
18
+ let regEnvironment = (process.env.SOAJS_ENV || "dev").toLowerCase();
19
+
16
20
  module.exports = (configuration) => {
17
21
 
18
22
  configuration.soajs.oauthService = configuration.soajs.param.oauthService || {};
@@ -81,6 +85,77 @@ module.exports = (configuration) => {
81
85
  return req.oauth.bearerToken.user.deviceId === req.get('device-id');
82
86
  };
83
87
 
88
+ /**
89
+ * Matches one constraint, a string or an array of strings, against the request value.
90
+ *
91
+ * @param allowed {String|Array} what the token allows
92
+ * @param actual {String} what the request carries
93
+ * @param ignoreCase {Boolean} used for env, where the registry casing is not guaranteed
94
+ * @returns {boolean} true when the request value is allowed
95
+ */
96
+ let valueMatch = (allowed, actual, ignoreCase) => {
97
+ if (!actual) {
98
+ return false;
99
+ }
100
+ if (ignoreCase) {
101
+ let lower = actual.toLowerCase();
102
+ if (Array.isArray(allowed)) {
103
+ return allowed.some((one) => {
104
+ return one && one.toLowerCase() === lower;
105
+ });
106
+ }
107
+ return !!allowed && allowed.toLowerCase() === lower;
108
+ }
109
+ if (Array.isArray(allowed)) {
110
+ return allowed.includes(actual);
111
+ }
112
+ return allowed === actual;
113
+ };
114
+
115
+ /**
116
+ * Enforces the restrictedTo object carried on the access token record.
117
+ *
118
+ * NOTE: only the keys present on restrictedTo are checked, a token with no restrictedTo is
119
+ * not checked at all. that is the opt in, there is no registry switch, a restriction
120
+ * written on a token is always enforced.
121
+ *
122
+ * NOTE: it fails closed. a restricted token reaching a service with extKeyRequired off has
123
+ * no key to be compared against, it must not gain access that way.
124
+ *
125
+ * @param req
126
+ * @returns {boolean} true when the request is allowed to proceed
127
+ */
128
+ let restrictedToMatch = (req) => {
129
+ if (!req.oauth || !req.oauth.bearerToken || !req.oauth.bearerToken.user || !req.oauth.bearerToken.user.restrictedTo) {
130
+ return true;
131
+ }
132
+ let restrictedTo = req.oauth.bearerToken.user.restrictedTo;
133
+
134
+ if (!req.soajs.tenant || !req.soajs.tenant.application) {
135
+ req.soajs.log.debug("Access denied, restricted token on a request with no key");
136
+ return false;
137
+ }
138
+
139
+ let actual = {
140
+ "tenant": req.soajs.tenant.id,
141
+ "product": req.soajs.tenant.application.product,
142
+ "package": req.soajs.tenant.application.package,
143
+ "key": req.soajs.tenant.key ? req.soajs.tenant.key.eKey : null,
144
+ "env": regEnvironment,
145
+ "agent": req.get('user-agent')
146
+ };
147
+
148
+ for (let field in actual) {
149
+ if (Object.hasOwnProperty.call(actual, field) && restrictedTo[field]) {
150
+ if (!valueMatch(restrictedTo[field], actual[field], "env" === field)) {
151
+ req.soajs.log.debug("Access denied, token restriction mismatch on [" + field + "]");
152
+ return false;
153
+ }
154
+ }
155
+ }
156
+ return true;
157
+ };
158
+
84
159
  return (req, res, next) => {
85
160
 
86
161
  let oauthType = 2;
@@ -103,6 +178,9 @@ module.exports = (configuration) => {
103
178
  req.soajs.log.debug("Access denied, deviceId mismatch [deviceId: " + req.get('device-id') + "]");
104
179
  return next(156);
105
180
  }
181
+ if (!restrictedToMatch(req)) {
182
+ return next(147);
183
+ }
106
184
  return next();
107
185
  });
108
186
  } else {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "soajs.controller",
3
3
  "description": "soajs multi tenant API gateway",
4
- "version": "4.3.15",
4
+ "version": "4.3.17",
5
5
  "author": {
6
6
  "name": "soajs team",
7
7
  "email": "team@soajs.org"
@@ -23,14 +23,14 @@
23
23
  "http-proxy": "1.18.1",
24
24
  "jsonwebtoken": "9.0.3",
25
25
  "kubernetes-client": "9.0.0",
26
- "lodash": "4.17.23",
26
+ "lodash": "4.18.1",
27
27
  "merge": "2.1.1",
28
- "netmask": "2.0.2",
28
+ "netmask": "2.1.1",
29
29
  "oauth2-server": "2.4.1",
30
- "path-to-regexp": "8.4.0",
30
+ "path-to-regexp": "8.4.2",
31
31
  "soajs.core.libs": "1.2.2",
32
- "soajs.core.modules": "5.2.20",
33
- "soajs.urac.driver": "3.2.16",
32
+ "soajs.core.modules": "5.2.21",
33
+ "soajs.urac.driver": "3.2.18",
34
34
  "useragent": "2.3.0"
35
35
  },
36
36
  "devDependencies": {
@@ -804,6 +804,28 @@ describe("Unit test for: mw - mt lib", () => {
804
804
  done();
805
805
  });
806
806
  });
807
+ it("testing mergeACLArray keeps every new version of a service", (done) => {
808
+ let acl = [
809
+ { "acl": { "orders": { "1": { "access": true } } } },
810
+ { "acl": { "orders": { "2": { "access": true }, "3": { "access": false } }, "billing": { "1": { "access": true } } } }
811
+ ];
812
+ lib.mergeACLArray(acl, (err, result) => {
813
+ assert.deepStrictEqual(Object.keys(result.acl.orders).sort(), ["1", "2", "3"]);
814
+ assert.deepStrictEqual(result.acl.orders["3"], { "access": false });
815
+ assert.ok(result.acl.billing["1"]);
816
+ done();
817
+ });
818
+ });
819
+ it("testing mergeACLArray all env with several new versions of a service", (done) => {
820
+ let acl = [
821
+ { "acl_all_env": { "dev": { "orders": { "1": { "access": true } } } } },
822
+ { "acl_all_env": { "dev": { "orders": { "2": { "access": true }, "3": { "access": false } } } } }
823
+ ];
824
+ lib.mergeACLArray(acl, (err, result) => {
825
+ assert.deepStrictEqual(Object.keys(result.acl_all_env.dev.orders).sort(), ["1", "2", "3"]);
826
+ done();
827
+ });
828
+ });
807
829
  it("testing mergeACLArray empty array", (done) => {
808
830
  let acl = [];
809
831
  lib.mergeACLArray(acl, (err, result) => {
@@ -267,3 +267,228 @@ describe("Unit test for: mw - oauth deviceId check", () => {
267
267
  });
268
268
  });
269
269
  });
270
+
271
+ describe("Unit test for: mw - oauth restrictedTo check", () => {
272
+
273
+ // builds a configuration whose model returns an access token carrying the given restrictedTo
274
+ let buildConfiguration = (restrictedTo) => {
275
+ let user = {"id": "1"};
276
+ if (restrictedTo !== undefined) {
277
+ user.restrictedTo = restrictedTo;
278
+ }
279
+ return {
280
+ "soajs": {
281
+ "param": {}
282
+ },
283
+ "serviceConfig": {
284
+ "oauth": {
285
+ grants: ["password", "refresh_token"],
286
+ debug: false,
287
+ accessTokenLifetime: 7200,
288
+ refreshTokenLifetime: 1209600
289
+ }
290
+ },
291
+ "model": {
292
+ "getAccessToken": (bearerToken, cb) => {
293
+ return cb(null, {
294
+ "token": bearerToken,
295
+ "expires": new Date(new Date().getTime() + 3600000),
296
+ "user": user
297
+ });
298
+ }
299
+ }
300
+ };
301
+ };
302
+
303
+ // oauthType 2 so that the access token is fetched from the model
304
+ // tenant mirrors what mw/mt sets from the ext key before oauth runs
305
+ let buildReq = (agent) => {
306
+ return {
307
+ "soajs": {
308
+ "log": {
309
+ "debug": () => {
310
+ }
311
+ },
312
+ "tenant": {
313
+ "id": "tenantB",
314
+ "key": {
315
+ "eKey": "theWebAppExtKey"
316
+ },
317
+ "application": {
318
+ "product": "PRODWEB",
319
+ "package": "PRODWEB_BASIC"
320
+ }
321
+ },
322
+ "tenantOauth": {
323
+ "type": 2
324
+ },
325
+ "servicesConfig": {},
326
+ "registry": {
327
+ "serviceConfig": {
328
+ "oauth": {
329
+ "type": 2
330
+ }
331
+ }
332
+ }
333
+ },
334
+ "query": {},
335
+ "body": {},
336
+ "get": (what) => {
337
+ if ('Authorization' === what) {
338
+ return "Bearer anAccessToken";
339
+ }
340
+ if ('user-agent' === what) {
341
+ return agent;
342
+ }
343
+ return undefined;
344
+ }
345
+ };
346
+ };
347
+ let res = {};
348
+
349
+ it("test oauth MW - no restrictedTo, token is not checked", (done) => {
350
+ let functionMw = mw(buildConfiguration(undefined));
351
+ functionMw(buildReq(), res, (error) => {
352
+ assert.ifError(error);
353
+ done();
354
+ });
355
+ });
356
+
357
+ it("test oauth MW - tenant and product match", (done) => {
358
+ let functionMw = mw(buildConfiguration({"tenant": "tenantB", "product": "PRODWEB"}));
359
+ functionMw(buildReq(), res, (error) => {
360
+ assert.ifError(error);
361
+ done();
362
+ });
363
+ });
364
+
365
+ it("test oauth MW - tenant does not match", (done) => {
366
+ let functionMw = mw(buildConfiguration({"tenant": "tenantA"}));
367
+ functionMw(buildReq(), res, (error) => {
368
+ assert.deepStrictEqual(error, 147);
369
+ done();
370
+ });
371
+ });
372
+
373
+ it("test oauth MW - product does not match", (done) => {
374
+ let functionMw = mw(buildConfiguration({"product": "PRODMOBILE"}));
375
+ functionMw(buildReq(), res, (error) => {
376
+ assert.deepStrictEqual(error, 147);
377
+ done();
378
+ });
379
+ });
380
+
381
+ it("test oauth MW - package and ext key match", (done) => {
382
+ let functionMw = mw(buildConfiguration({"package": "PRODWEB_BASIC", "key": "theWebAppExtKey"}));
383
+ functionMw(buildReq(), res, (error) => {
384
+ assert.ifError(error);
385
+ done();
386
+ });
387
+ });
388
+
389
+ it("test oauth MW - ext key does not match", (done) => {
390
+ let functionMw = mw(buildConfiguration({"key": "someOtherExtKey"}));
391
+ functionMw(buildReq(), res, (error) => {
392
+ assert.deepStrictEqual(error, 147);
393
+ done();
394
+ });
395
+ });
396
+
397
+ it("test oauth MW - array of allowed products, one matches", (done) => {
398
+ let functionMw = mw(buildConfiguration({"product": ["PRODMOBILE", "PRODWEB"]}));
399
+ functionMw(buildReq(), res, (error) => {
400
+ assert.ifError(error);
401
+ done();
402
+ });
403
+ });
404
+
405
+ it("test oauth MW - array of allowed products, none matches", (done) => {
406
+ let functionMw = mw(buildConfiguration({"product": ["PRODMOBILE", "PRODOTHER"]}));
407
+ functionMw(buildReq(), res, (error) => {
408
+ assert.deepStrictEqual(error, 147);
409
+ done();
410
+ });
411
+ });
412
+
413
+ it("test oauth MW - several constraints, the last one fails", (done) => {
414
+ let functionMw = mw(buildConfiguration({
415
+ "tenant": "tenantB",
416
+ "product": "PRODWEB",
417
+ "key": "someOtherExtKey"
418
+ }));
419
+ functionMw(buildReq(), res, (error) => {
420
+ assert.deepStrictEqual(error, 147);
421
+ done();
422
+ });
423
+ });
424
+
425
+ it("test oauth MW - env matches whatever the gateway runs as, case insensitive", (done) => {
426
+ let current = (process.env.SOAJS_ENV || "dev").toLowerCase();
427
+ let functionMw = mw(buildConfiguration({"env": current.toUpperCase()}));
428
+ functionMw(buildReq(), res, (error) => {
429
+ assert.ifError(error);
430
+ done();
431
+ });
432
+ });
433
+
434
+ it("test oauth MW - env does not match", (done) => {
435
+ let functionMw = mw(buildConfiguration({"env": "anEnvThatIsNotRunning"}));
436
+ functionMw(buildReq(), res, (error) => {
437
+ assert.deepStrictEqual(error, 147);
438
+ done();
439
+ });
440
+ });
441
+
442
+ it("test oauth MW - agent matches", (done) => {
443
+ let functionMw = mw(buildConfiguration({"agent": "Mozilla/5.0 theBrowser"}));
444
+ functionMw(buildReq("Mozilla/5.0 theBrowser"), res, (error) => {
445
+ assert.ifError(error);
446
+ done();
447
+ });
448
+ });
449
+
450
+ it("test oauth MW - agent does not match", (done) => {
451
+ let functionMw = mw(buildConfiguration({"agent": "Mozilla/5.0 theBrowser"}));
452
+ functionMw(buildReq("Mozilla/5.0 anotherBrowser"), res, (error) => {
453
+ assert.deepStrictEqual(error, 147);
454
+ done();
455
+ });
456
+ });
457
+
458
+ it("test oauth MW - agent constrained but request sends none, denied", (done) => {
459
+ let functionMw = mw(buildConfiguration({"agent": "Mozilla/5.0 theBrowser"}));
460
+ functionMw(buildReq(undefined), res, (error) => {
461
+ assert.deepStrictEqual(error, 147);
462
+ done();
463
+ });
464
+ });
465
+
466
+ it("test oauth MW - fails closed when the request carries no key", (done) => {
467
+ let functionMw = mw(buildConfiguration({"product": "PRODWEB"}));
468
+ let req = buildReq();
469
+ delete req.soajs.tenant;
470
+ functionMw(req, res, (error) => {
471
+ assert.deepStrictEqual(error, 147);
472
+ done();
473
+ });
474
+ });
475
+
476
+ it("test oauth MW - a restriction on the token cannot be switched off at the registry", (done) => {
477
+ let functionMw = mw(buildConfiguration({"tenant": "tenantA"}));
478
+ let req = buildReq();
479
+ req.soajs.registry.custom = {
480
+ "gateway": {
481
+ "value": {
482
+ "oauth": {
483
+ "restrictedToCheck": false,
484
+ "deviceIdCheck": false
485
+ }
486
+ }
487
+ }
488
+ };
489
+ functionMw(req, res, (error) => {
490
+ assert.deepStrictEqual(error, 147);
491
+ done();
492
+ });
493
+ });
494
+ });