soajs.controller 4.3.14 → 4.3.16
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/configuration-custom-registry.md +84 -0
- package/docs/middleware/mt.md +1 -0
- package/docs/middleware/oauth.md +55 -0
- package/lib/membership.js +74 -0
- package/mw/mt/index.js +12 -0
- package/mw/mt/urac.js +7 -0
- package/mw/oauth/index.js +78 -0
- package/package.json +6 -6
- package/test/unit/index.js +1 -0
- package/test/unit/lib/membership.js +146 -0
- package/test/unit/mw/oauth/index.js +225 -0
|
@@ -27,6 +27,9 @@ registry.custom
|
|
|
27
27
|
│ │ └── [service] # Service-specific settings
|
|
28
28
|
│ ├── maintenanceMode # Gateway maintenance mode
|
|
29
29
|
│ ├── lastSeen # User activity tracking
|
|
30
|
+
│ ├── membership # Membership on the urac object, by product
|
|
31
|
+
│ │ ├── active # Whether membership is resolved at all
|
|
32
|
+
│ │ └── [main product] # Its sub products
|
|
30
33
|
│ ├── oauth
|
|
31
34
|
│ │ └── deviceIdCheck # Turn the deviceId check off
|
|
32
35
|
│ └── gotoService
|
|
@@ -494,6 +497,86 @@ is read by the oauth middleware itself.
|
|
|
494
497
|
|
|
495
498
|
---
|
|
496
499
|
|
|
500
|
+
## 14. Membership
|
|
501
|
+
|
|
502
|
+
**Path:** `registry.custom.gateway.value.membership`
|
|
503
|
+
|
|
504
|
+
**Middleware:** `mw/mt/index.js`, resolved by `lib/membership.js`
|
|
505
|
+
|
|
506
|
+
Puts the caller's membership on the urac object of every call, so a service can read it
|
|
507
|
+
without a lookup.
|
|
508
|
+
|
|
509
|
+
A membership is held per main product on the user record in the urac `users` collection,
|
|
510
|
+
and only its name is stored. What the name grants is resolved from the catalog, it is
|
|
511
|
+
never copied onto the user:
|
|
512
|
+
|
|
513
|
+
```javascript
|
|
514
|
+
{
|
|
515
|
+
"memberships": [
|
|
516
|
+
{ "product": "AVAPP", "membership": "whale" }
|
|
517
|
+
]
|
|
518
|
+
}
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
The gateway already resolves both halves of the match on every call. The access token
|
|
522
|
+
gives the user record, which carries `memberships`, and the API key gives the product the
|
|
523
|
+
call is made under. Resolution is the match between them: if the product of the request
|
|
524
|
+
is a configured main product or one of its sub products, and the user holds an entry for
|
|
525
|
+
that main product, the membership name is injected.
|
|
526
|
+
|
|
527
|
+
```javascript
|
|
528
|
+
{
|
|
529
|
+
"membership": {
|
|
530
|
+
"active": true, // Resolve membership at all
|
|
531
|
+
"AVAPP": ["CLUBS", "CORPS", "SECRT", "GRPCH"] // Main product and its sub products
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
| Field | Type | Default | Description |
|
|
537
|
+
|-------|------|---------|-------------|
|
|
538
|
+
| `active` | boolean | `false` | Whether the gateway resolves membership at all |
|
|
539
|
+
| `[main product]` | string[] | - | A product a membership is held in, valued by the sub products that resolve to it |
|
|
540
|
+
|
|
541
|
+
Every key other than `active` is read as a main product, so more than one can be
|
|
542
|
+
configured and they stay independent. A main product always resolves to itself, listing
|
|
543
|
+
sub products is optional.
|
|
544
|
+
|
|
545
|
+
### What the service sees
|
|
546
|
+
|
|
547
|
+
```javascript
|
|
548
|
+
req.soajs.urac.membership // "whale"
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
Only the resolved name travels. The raw `memberships` array is never injected, so a
|
|
552
|
+
service cannot read a membership held for a product the request was not made under.
|
|
553
|
+
|
|
554
|
+
### It fails closed
|
|
555
|
+
|
|
556
|
+
Nothing is injected, and no service sees a membership, when any of these hold:
|
|
557
|
+
|
|
558
|
+
| | |
|
|
559
|
+
|---|---|
|
|
560
|
+
| the block is absent, or `active` is not `true` | membership is off |
|
|
561
|
+
| no main product is configured | nothing is resolvable, equivalent to off |
|
|
562
|
+
| the request product is neither a main product nor a sub product | out of scope |
|
|
563
|
+
| the user holds no entry for the resolved main product | no membership |
|
|
564
|
+
| the service does not request urac (`urac: false`) | there is no urac object to put it on |
|
|
565
|
+
|
|
566
|
+
Product codes are matched exactly. They are uppercase by convention, and normalizing
|
|
567
|
+
here would hide a miscased entry in the registry rather than surface it.
|
|
568
|
+
|
|
569
|
+
### Freshness
|
|
570
|
+
|
|
571
|
+
The user record behind this comes from the access token when
|
|
572
|
+
`serviceConfig.oauth.getUserFromToken` is on, when the login mode is `oauth`, or when
|
|
573
|
+
roaming. In those cases `memberships` is a snapshot taken at login, and a membership
|
|
574
|
+
changed afterwards is not seen until a new token is issued. Refreshing does not help,
|
|
575
|
+
the refresh grant copies the stored user forward unchanged. Otherwise the record is
|
|
576
|
+
loaded from urac per request and the membership is current.
|
|
577
|
+
|
|
578
|
+
---
|
|
579
|
+
|
|
497
580
|
## Complete Configuration Example
|
|
498
581
|
|
|
499
582
|
```javascript
|
|
@@ -615,6 +698,7 @@ Custom registry configurations are processed in this order:
|
|
|
615
698
|
| 5 | gotoService/redirectToService | `gotoService.monitor` |
|
|
616
699
|
| 6 | mt (security checks) | `mt.whitelist`, `oauth.value.pinWrapper`, `oauth.value.pinWhitelist` |
|
|
617
700
|
| 6a | oauth (called from mt) | `oauth.deviceIdCheck` |
|
|
701
|
+
| 6b | mt (inject object) | `membership` |
|
|
618
702
|
| 7 | idempotency | `idempotency.model`, `idempotency.services.[name]` |
|
|
619
703
|
| 8 | traffic | `traffic.model`, `traffic.throttling` |
|
|
620
704
|
| 9 | cache | `cache.model`, `cache.defaultTTL`, `cache.services.[name]` |
|
package/docs/middleware/mt.md
CHANGED
|
@@ -515,6 +515,7 @@ The `soajsinjectobj` header contains:
|
|
|
515
515
|
"lastName": "Doe",
|
|
516
516
|
"email": "user@example.com",
|
|
517
517
|
"groups": ["group1"],
|
|
518
|
+
"membership": "whale", // If resolved, see custom registry `membership`
|
|
518
519
|
"tenant": { "id": "...", "code": "..." },
|
|
519
520
|
"profile": { ... }, // If urac_Profile enabled
|
|
520
521
|
"acl": { ... }, // If urac_ACL enabled
|
package/docs/middleware/oauth.md
CHANGED
|
@@ -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
|
|
|
@@ -0,0 +1,74 @@
|
|
|
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
|
+
* Resolve the membership the caller holds for the product this request is made under.
|
|
13
|
+
*
|
|
14
|
+
* A membership is held per main product on the user record, and only its name is
|
|
15
|
+
* stored. What the name grants is resolved from the catalog, it is never copied
|
|
16
|
+
* onto the user:
|
|
17
|
+
*
|
|
18
|
+
* "memberships": [{ "product": "AVAPP", "membership": "whale" }]
|
|
19
|
+
*
|
|
20
|
+
* A request carries the product of the key it was made with. That product is either
|
|
21
|
+
* the main product itself or one of its sub products, and both resolve to the main
|
|
22
|
+
* product, so a call made under CLUBS reads the AVAPP membership.
|
|
23
|
+
*
|
|
24
|
+
* Configured at registry.custom.gateway.value.membership, keyed by main product:
|
|
25
|
+
*
|
|
26
|
+
* {
|
|
27
|
+
* "active": true,
|
|
28
|
+
* "AVAPP": ["CLUBS", "CORPS", "SECRT", "GRPCH"]
|
|
29
|
+
* }
|
|
30
|
+
*
|
|
31
|
+
* NOTE: this fails closed. An absent or inactive block, an unconfigured product, or
|
|
32
|
+
* a user with no entry for the resolved main product all resolve to null, and
|
|
33
|
+
* nothing is injected, so no service sees a membership it should not.
|
|
34
|
+
*
|
|
35
|
+
* @param config the custom registry membership block
|
|
36
|
+
* @param product the product the request is made under, ex: keyObj.application.product
|
|
37
|
+
* @param memberships the memberships array off the user record
|
|
38
|
+
* @returns {string|null} the membership name, or null when it does not resolve
|
|
39
|
+
*/
|
|
40
|
+
function resolveMembership(config, product, memberships) {
|
|
41
|
+
if (!config || !config.active) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
if (!product || !Array.isArray(memberships) || memberships.length === 0) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
//NOTE: product codes are matched exactly, they are uppercase by convention and
|
|
49
|
+
// normalizing here would hide a miscased entry in the registry
|
|
50
|
+
let mainProduct = null;
|
|
51
|
+
let configuredProducts = Object.keys(config).filter((key) => {
|
|
52
|
+
return key !== "active";
|
|
53
|
+
});
|
|
54
|
+
for (let i = 0; i < configuredProducts.length; i++) {
|
|
55
|
+
let key = configuredProducts[i];
|
|
56
|
+
if (key === product || (Array.isArray(config[key]) && config[key].includes(product))) {
|
|
57
|
+
mainProduct = key;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (!mainProduct) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (let i = 0; i < memberships.length; i++) {
|
|
66
|
+
let entry = memberships[i];
|
|
67
|
+
if (entry && entry.product === mainProduct && entry.membership) {
|
|
68
|
+
return entry.membership;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { resolveMembership };
|
package/mw/mt/index.js
CHANGED
|
@@ -14,6 +14,9 @@ const utils = require("./utils");
|
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
const { encodeHeaderValue } = require("../../lib/header.js");
|
|
17
|
+
const { resolveMembership } = require("../../lib/membership.js");
|
|
18
|
+
|
|
19
|
+
const get = (p, o) => p.reduce((xs, x) => (xs && xs[x]) ? xs[x] : null, o);
|
|
17
20
|
|
|
18
21
|
/**
|
|
19
22
|
*
|
|
@@ -266,6 +269,15 @@ module.exports = (configuration) => {
|
|
|
266
269
|
}
|
|
267
270
|
};
|
|
268
271
|
|
|
272
|
+
//NOTE: only the resolved name travels, never the raw per product
|
|
273
|
+
// array, so a service cannot read a membership held for
|
|
274
|
+
// a product this request was not made under
|
|
275
|
+
let membershipConfig = get(["soajs", "registry", "custom", "gateway", "value", "membership"], req);
|
|
276
|
+
let membership = resolveMembership(membershipConfig, keyObj.application.product, uracObj.memberships);
|
|
277
|
+
if (membership) {
|
|
278
|
+
injectObj.urac.membership = membership;
|
|
279
|
+
}
|
|
280
|
+
|
|
269
281
|
injectObj.param = injectObj.param || {};
|
|
270
282
|
injectObj.param.urac_Profile = serviceParam.urac_Profile;
|
|
271
283
|
injectObj.param.urac_ACL = serviceParam.urac_ACL;
|
package/mw/mt/urac.js
CHANGED
|
@@ -167,6 +167,13 @@ Urac.prototype.getProfile = function (_ALL) {
|
|
|
167
167
|
"tenant": _self.userRecord.tenant
|
|
168
168
|
};
|
|
169
169
|
|
|
170
|
+
//NOTE: the raw per product array. The gateway resolves the one entry that applies
|
|
171
|
+
// to this request and injects only that, see lib/membership.js. Set only when
|
|
172
|
+
// the record carries it, so a user with no memberships keeps the shape it had.
|
|
173
|
+
if (_self.userRecord.memberships) {
|
|
174
|
+
urac.memberships = _self.userRecord.memberships;
|
|
175
|
+
}
|
|
176
|
+
|
|
170
177
|
if (_self.userRecord.socialLogin) {
|
|
171
178
|
urac.socialLogin = {
|
|
172
179
|
"strategy": _self.userRecord.socialLogin.strategy,
|
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.
|
|
4
|
+
"version": "4.3.16",
|
|
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.
|
|
26
|
+
"lodash": "4.18.1",
|
|
27
27
|
"merge": "2.1.1",
|
|
28
|
-
"netmask": "2.
|
|
28
|
+
"netmask": "2.1.1",
|
|
29
29
|
"oauth2-server": "2.4.1",
|
|
30
|
-
"path-to-regexp": "8.4.
|
|
30
|
+
"path-to-regexp": "8.4.2",
|
|
31
31
|
"soajs.core.libs": "1.2.2",
|
|
32
|
-
"soajs.core.modules": "5.2.
|
|
33
|
-
"soajs.urac.driver": "3.2.
|
|
32
|
+
"soajs.core.modules": "5.2.21",
|
|
33
|
+
"soajs.urac.driver": "3.2.17",
|
|
34
34
|
"useragent": "2.3.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
package/test/unit/index.js
CHANGED
|
@@ -0,0 +1,146 @@
|
|
|
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
|
+
const assert = require('assert');
|
|
12
|
+
const helper = require("../../helper.js");
|
|
13
|
+
|
|
14
|
+
const { resolveMembership } = helper.requireModule('./lib/membership.js');
|
|
15
|
+
|
|
16
|
+
describe("Unit test for: lib - membership", () => {
|
|
17
|
+
|
|
18
|
+
const CONFIG = {
|
|
19
|
+
"active": true,
|
|
20
|
+
"AVAPP": ["CLUBS", "CORPS", "SECRT", "GRPCH"]
|
|
21
|
+
};
|
|
22
|
+
const MEMBERSHIPS = [{ "product": "AVAPP", "membership": "whale" }];
|
|
23
|
+
|
|
24
|
+
describe("resolving", () => {
|
|
25
|
+
|
|
26
|
+
it("resolves when the request is made under the main product", (done) => {
|
|
27
|
+
assert.strictEqual(resolveMembership(CONFIG, "AVAPP", MEMBERSHIPS), "whale");
|
|
28
|
+
done();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("resolves when the request is made under a sub product", (done) => {
|
|
32
|
+
["CLUBS", "CORPS", "SECRT", "GRPCH"].forEach((sub) => {
|
|
33
|
+
assert.strictEqual(resolveMembership(CONFIG, sub, MEMBERSHIPS), "whale", "failed for " + sub);
|
|
34
|
+
});
|
|
35
|
+
done();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("resolves the main product itself when it has no sub products", (done) => {
|
|
39
|
+
assert.strictEqual(resolveMembership({ "active": true, "AVAPP": [] }, "AVAPP", MEMBERSHIPS), "whale");
|
|
40
|
+
done();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("picks the entry for the resolved main product, not the first one", (done) => {
|
|
44
|
+
let memberships = [
|
|
45
|
+
{ "product": "OTHER", "membership": "minnow" },
|
|
46
|
+
{ "product": "AVAPP", "membership": "whale" }
|
|
47
|
+
];
|
|
48
|
+
assert.strictEqual(resolveMembership(CONFIG, "CLUBS", memberships), "whale");
|
|
49
|
+
done();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("keeps several main products independent", (done) => {
|
|
53
|
+
let config = {
|
|
54
|
+
"active": true,
|
|
55
|
+
"AVAPP": ["CLUBS"],
|
|
56
|
+
"BVAPP": ["BOATS"]
|
|
57
|
+
};
|
|
58
|
+
let memberships = [
|
|
59
|
+
{ "product": "AVAPP", "membership": "whale" },
|
|
60
|
+
{ "product": "BVAPP", "membership": "dolphin" }
|
|
61
|
+
];
|
|
62
|
+
assert.strictEqual(resolveMembership(config, "CLUBS", memberships), "whale");
|
|
63
|
+
assert.strictEqual(resolveMembership(config, "BOATS", memberships), "dolphin");
|
|
64
|
+
done();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("failing closed", () => {
|
|
69
|
+
|
|
70
|
+
it("resolves nothing when the block is absent", (done) => {
|
|
71
|
+
assert.strictEqual(resolveMembership(null, "AVAPP", MEMBERSHIPS), null);
|
|
72
|
+
assert.strictEqual(resolveMembership(undefined, "AVAPP", MEMBERSHIPS), null);
|
|
73
|
+
done();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("resolves nothing when active is not set", (done) => {
|
|
77
|
+
assert.strictEqual(resolveMembership({ "AVAPP": ["CLUBS"] }, "AVAPP", MEMBERSHIPS), null);
|
|
78
|
+
done();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("resolves nothing when active is false", (done) => {
|
|
82
|
+
assert.strictEqual(resolveMembership({ "active": false, "AVAPP": ["CLUBS"] }, "CLUBS", MEMBERSHIPS), null);
|
|
83
|
+
done();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("resolves nothing when no product is configured", (done) => {
|
|
87
|
+
assert.strictEqual(resolveMembership({ "active": true }, "AVAPP", MEMBERSHIPS), null);
|
|
88
|
+
done();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("resolves nothing for a product that is neither main nor sub", (done) => {
|
|
92
|
+
assert.strictEqual(resolveMembership(CONFIG, "UNRELATED", MEMBERSHIPS), null);
|
|
93
|
+
done();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("resolves nothing when the user holds no entry for the main product", (done) => {
|
|
97
|
+
let memberships = [{ "product": "OTHER", "membership": "minnow" }];
|
|
98
|
+
assert.strictEqual(resolveMembership(CONFIG, "CLUBS", memberships), null);
|
|
99
|
+
done();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("resolves nothing when the user has no memberships", (done) => {
|
|
103
|
+
assert.strictEqual(resolveMembership(CONFIG, "AVAPP", null), null);
|
|
104
|
+
assert.strictEqual(resolveMembership(CONFIG, "AVAPP", []), null);
|
|
105
|
+
assert.strictEqual(resolveMembership(CONFIG, "AVAPP", undefined), null);
|
|
106
|
+
done();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("resolves nothing when the request carries no product", (done) => {
|
|
110
|
+
assert.strictEqual(resolveMembership(CONFIG, null, MEMBERSHIPS), null);
|
|
111
|
+
done();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("matches product codes exactly, a miscased code does not resolve", (done) => {
|
|
115
|
+
assert.strictEqual(resolveMembership(CONFIG, "clubs", MEMBERSHIPS), null);
|
|
116
|
+
assert.strictEqual(resolveMembership(CONFIG, "AVAPP", [{ "product": "avapp", "membership": "whale" }]), null);
|
|
117
|
+
done();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("malformed input", () => {
|
|
122
|
+
|
|
123
|
+
it("survives a memberships array holding junk", (done) => {
|
|
124
|
+
let memberships = [null, "whale", { "product": "AVAPP" }, { "membership": "whale" }];
|
|
125
|
+
assert.strictEqual(resolveMembership(CONFIG, "AVAPP", memberships), null);
|
|
126
|
+
done();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("skips an entry with no membership name and keeps looking", (done) => {
|
|
130
|
+
let memberships = [{ "product": "AVAPP" }, { "product": "AVAPP", "membership": "whale" }];
|
|
131
|
+
assert.strictEqual(resolveMembership(CONFIG, "AVAPP", memberships), "whale");
|
|
132
|
+
done();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("survives a sub product list that is not an array", (done) => {
|
|
136
|
+
assert.strictEqual(resolveMembership({ "active": true, "AVAPP": "CLUBS" }, "CLUBS", MEMBERSHIPS), null);
|
|
137
|
+
assert.strictEqual(resolveMembership({ "active": true, "AVAPP": "CLUBS" }, "AVAPP", MEMBERSHIPS), "whale");
|
|
138
|
+
done();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("does not treat active as a product", (done) => {
|
|
142
|
+
assert.strictEqual(resolveMembership({ "active": true }, "active", [{ "product": "active", "membership": "x" }]), null);
|
|
143
|
+
done();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -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
|
+
});
|