scimgateway 4.0.1 → 4.1.2
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/README.md +70 -3
- package/config/plugin-api.json +8 -0
- package/config/plugin-azure-ad.json +8 -0
- package/config/plugin-forwardinc.json +8 -0
- package/config/plugin-ldap.json +10 -0
- package/config/plugin-loki.json +8 -0
- package/config/plugin-mongodb.json +8 -0
- package/config/plugin-mssql.json +8 -0
- package/config/plugin-saphana.json +8 -0
- package/config/plugin-scim.json +8 -0
- package/lib/plugin-ldap.js +11 -0
- package/lib/scimgateway.js +223 -41
- package/lib/utils.js +37 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,7 +16,8 @@ Validated through IdP's:
|
|
|
16
16
|
|
|
17
17
|
Latest news:
|
|
18
18
|
|
|
19
|
-
-
|
|
19
|
+
- Supporting OAuth Client Credentials authentication
|
|
20
|
+
- Major version v4.0.0. getUsers() and getGroups() replacing some deprecated methods. No limitations on filtering/sorting. Admin user access can be limited to specific baseEntities. New MongoDB plugin
|
|
20
21
|
- ipAllowList for restricting access to allowlisted IP addresses or subnets e.g. Azure AD IP-range
|
|
21
22
|
- General LDAP plugin configured for Active Directory
|
|
22
23
|
- [PlugSSO](https://elshaug.xyz/docs/plugsso) using SCIM Gateway
|
|
@@ -30,7 +31,7 @@ Latest news:
|
|
|
30
31
|
|
|
31
32
|
## Overview
|
|
32
33
|
|
|
33
|
-
With SCIM Gateway we
|
|
34
|
+
With SCIM Gateway we can manage users and groups by using REST based [SCIM](http://www.simplecloud.info/) 1.1 or 2.0 protocol. Gateway translates incoming SCIM requests and expose CRUD functionality (create, read, update and delete user/group) towards destinations using endpoint specific protocols. In other words, none SCIM-endpoints will become SCIM-endpoints. Gateway do not require SCIM to be used, it's also an API Gateway that could be used for other things than user provisioning.
|
|
34
35
|
|
|
35
36
|
SCIM Gateway is a standalone product, however this document shows how the gateway could be used by products like Symatec/Broadcom/CA Identity Manager.
|
|
36
37
|
|
|
@@ -184,7 +185,7 @@ When maintaining a set of modifications it useful to disable the postinstall ope
|
|
|
184
185
|
|
|
185
186
|
const loki = require('./lib/plugin-loki')
|
|
186
187
|
// const mongodb = require('./lib/plugin-mongodb')
|
|
187
|
-
// const
|
|
188
|
+
// const scim = require('./lib/plugin-scim')
|
|
188
189
|
// const forwardinc = require('./lib/plugin-forwardinc')
|
|
189
190
|
// const mssql = require('./lib/plugin-mssql')
|
|
190
191
|
// const saphana = require('./lib/plugin-saphana') // prereq: npm install hdb
|
|
@@ -249,6 +250,14 @@ Below shows an example of config\plugin-saphana.json
|
|
|
249
250
|
"readOnly": false,
|
|
250
251
|
"baseEntities": []
|
|
251
252
|
}
|
|
253
|
+
],
|
|
254
|
+
"bearerOAuth": [
|
|
255
|
+
{
|
|
256
|
+
"client_id": null,
|
|
257
|
+
"client_secret": null,
|
|
258
|
+
"readOnly": false,
|
|
259
|
+
"baseEntities": []
|
|
260
|
+
}
|
|
252
261
|
]
|
|
253
262
|
},
|
|
254
263
|
"certificate": {
|
|
@@ -336,6 +345,8 @@ Definitions in `endpoint` object are customized according to our plugin code. Pl
|
|
|
336
345
|
|
|
337
346
|
- **auth.bearerJwt** - Array of one or more standard JWT objects. Using **secret** or **publicKey** for signature verification. publicKey should be set to the filename of public key or certificate pem-file located in `<package-root>\config\certs`. Clear text secret will become encrypted when gateway is started. **options.issuer** is mandatory. Other options may also be included according to jsonwebtoken npm package definition.
|
|
338
347
|
|
|
348
|
+
- **auth.bearerOAuth** - Array of one or more Client Credentials OAuth configuration objects. **`client_id`** and **`client_secret`** are mandatory. client_secret value will become encrypted when gateway is started. OAuth token request url is **/oauth/token** e.g. http://localhost:8880/oauth/token
|
|
349
|
+
|
|
339
350
|
- **certificate** - If not using SSL/TLS certificate, set "key", "cert" and "ca" to **null**. When using SSL/TLS, "key" and "cert" have to be defined with the filename corresponding to the primary-key and public-certificate. Both files must be located in the `<package-root>\config\certs` directory e.g:
|
|
340
351
|
|
|
341
352
|
"certificate": {
|
|
@@ -1128,6 +1139,62 @@ MIT © [Jarle Elshaug](https://www.elshaug.xyz)
|
|
|
1128
1139
|
|
|
1129
1140
|
## Change log
|
|
1130
1141
|
|
|
1142
|
+
### v4.1.2
|
|
1143
|
+
[Added]
|
|
1144
|
+
|
|
1145
|
+
- endpointMapper supporting one to many mappings using a comma separated list of attributes in the `mapTo`
|
|
1146
|
+
|
|
1147
|
+
Configuration example:
|
|
1148
|
+
|
|
1149
|
+
"map": {
|
|
1150
|
+
"user": {
|
|
1151
|
+
"PersonnelNumber": {
|
|
1152
|
+
"mapTo": "id,userName",
|
|
1153
|
+
"type": "string"
|
|
1154
|
+
},
|
|
1155
|
+
...
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
### v4.1.1
|
|
1161
|
+
[Added]
|
|
1162
|
+
|
|
1163
|
+
- plugin-ldap support userFilter/groupFilter configuration for restricting scope
|
|
1164
|
+
|
|
1165
|
+
Configuration example:
|
|
1166
|
+
|
|
1167
|
+
{
|
|
1168
|
+
...
|
|
1169
|
+
"userFilter": "(memberOf=CN=grp1,OU=Groups,DC=test,DC=com)(!(memberOf=CN=Domain Admins,CN=Users,DC=test,DC=com))",
|
|
1170
|
+
"groupFilter": "(!(cn=grp2))",
|
|
1171
|
+
...
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
### v4.1.0
|
|
1175
|
+
[Added]
|
|
1176
|
+
|
|
1177
|
+
- Supporting OAuth Client Credentials authentication
|
|
1178
|
+
|
|
1179
|
+
Configuration example:
|
|
1180
|
+
|
|
1181
|
+
"bearerOAuth": [
|
|
1182
|
+
{
|
|
1183
|
+
"client_id": "my_client_id",
|
|
1184
|
+
"client_secret": "my_client_secret",
|
|
1185
|
+
"readOnly": false,
|
|
1186
|
+
"baseEntities": []
|
|
1187
|
+
}
|
|
1188
|
+
]
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
In example above, client using SCIM Gateway must have OAuth configuration:
|
|
1192
|
+
|
|
1193
|
+
client_id = my_client_id
|
|
1194
|
+
client_secret = my_client_secret
|
|
1195
|
+
token request url = http(s)://<host>:<port>/oauth/token
|
|
1196
|
+
|
|
1197
|
+
|
|
1131
1198
|
### v4.0.1
|
|
1132
1199
|
[Added]
|
|
1133
1200
|
|
package/config/plugin-api.json
CHANGED
package/config/plugin-ldap.json
CHANGED
|
@@ -47,6 +47,14 @@
|
|
|
47
47
|
"readOnly": false,
|
|
48
48
|
"baseEntities": []
|
|
49
49
|
}
|
|
50
|
+
],
|
|
51
|
+
"bearerOAuth": [
|
|
52
|
+
{
|
|
53
|
+
"client_id": null,
|
|
54
|
+
"client_secret": null,
|
|
55
|
+
"readOnly": false,
|
|
56
|
+
"baseEntities": []
|
|
57
|
+
}
|
|
50
58
|
]
|
|
51
59
|
},
|
|
52
60
|
"certificate": {
|
|
@@ -86,6 +94,8 @@
|
|
|
86
94
|
"ldap": {
|
|
87
95
|
"userBase": "CN=Users,DC=test,DC=com",
|
|
88
96
|
"groupBase": "OU=Groups,DC=test,DC=com",
|
|
97
|
+
"userFilter": null,
|
|
98
|
+
"groupFilter": null,
|
|
89
99
|
"userNamingAttr": "CN",
|
|
90
100
|
"groupNamingAttr": "CN",
|
|
91
101
|
"userObjectClasses": [
|
package/config/plugin-loki.json
CHANGED
package/config/plugin-mssql.json
CHANGED
package/config/plugin-scim.json
CHANGED
package/lib/plugin-ldap.js
CHANGED
|
@@ -23,6 +23,13 @@
|
|
|
23
23
|
// "type": "string"
|
|
24
24
|
// }
|
|
25
25
|
//
|
|
26
|
+
// Additional user/group filtering for restricting scope may be configured in endpoint.entity.xxx.ldap e.g:
|
|
27
|
+
// {
|
|
28
|
+
// ...
|
|
29
|
+
// "userFilter": "(memberOf=CN=grp1,OU=Groups,DC=test,DC=com)(!(memberOf=CN=Domain Admins,CN=Users,DC=test,DC=com))",
|
|
30
|
+
// "groupFilter": "(!(cn=grp2))",
|
|
31
|
+
// ...
|
|
32
|
+
// }
|
|
26
33
|
//
|
|
27
34
|
// Attributes according to map definition in the configuration file plugin-ldap.json:
|
|
28
35
|
//
|
|
@@ -190,6 +197,7 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes) => {
|
|
|
190
197
|
scope: scope,
|
|
191
198
|
attributes: attrs
|
|
192
199
|
}
|
|
200
|
+
if (config.entity[baseEntity].ldap.userFilter) ldapOptions.filter += config.entity[baseEntity].ldap.userFilter
|
|
193
201
|
}
|
|
194
202
|
}
|
|
195
203
|
} else if (getObj.operator === 'eq' && getObj.attribute === 'group.value') {
|
|
@@ -209,6 +217,7 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes) => {
|
|
|
209
217
|
scope: scope,
|
|
210
218
|
attributes: attrs
|
|
211
219
|
}
|
|
220
|
+
if (config.entity[baseEntity].ldap.userFilter) ldapOptions.filter += config.entity[baseEntity].ldap.userFilter
|
|
212
221
|
}
|
|
213
222
|
// end mandatory if-else logic
|
|
214
223
|
|
|
@@ -558,6 +567,7 @@ scimgateway.getGroups = async (baseEntity, getObj, attributes) => {
|
|
|
558
567
|
scope: scope,
|
|
559
568
|
attributes: attrs
|
|
560
569
|
}
|
|
570
|
+
if (config.entity[baseEntity].ldap.groupFilter) ldapOptions.filter += config.entity[baseEntity].ldap.groupFilter
|
|
561
571
|
}
|
|
562
572
|
}
|
|
563
573
|
} else if (getObj.operator === 'eq' && getObj.attribute === 'members.value') {
|
|
@@ -578,6 +588,7 @@ scimgateway.getGroups = async (baseEntity, getObj, attributes) => {
|
|
|
578
588
|
scope: scope,
|
|
579
589
|
attributes: attrs
|
|
580
590
|
}
|
|
591
|
+
if (config.entity[baseEntity].ldap.groupFilter) ldapOptions.filter += config.entity[baseEntity].ldap.groupFilter
|
|
581
592
|
}
|
|
582
593
|
// mandatory if-else logic - end
|
|
583
594
|
|
package/lib/scimgateway.js
CHANGED
|
@@ -27,7 +27,6 @@ const utils = require('../lib/utils')
|
|
|
27
27
|
const endpointMap = require('../lib/endpointMap')
|
|
28
28
|
const countries = require('../lib/countries')
|
|
29
29
|
const { createChecker } = require('is-in-subnet')
|
|
30
|
-
// const { get } = require('superagent')
|
|
31
30
|
|
|
32
31
|
/**
|
|
33
32
|
* @constructor
|
|
@@ -54,6 +53,7 @@ const ScimGateway = function () {
|
|
|
54
53
|
this.notValidAttributes = notValidAttributes // exposed to plugin-code
|
|
55
54
|
let pwErrCount = 0
|
|
56
55
|
let requestCounter = 0
|
|
56
|
+
const oAuthTokenExpire = 3600 // seconds
|
|
57
57
|
let isMailLock = false
|
|
58
58
|
let ipAllowListChecker
|
|
59
59
|
let scimDef
|
|
@@ -74,6 +74,8 @@ const ScimGateway = function () {
|
|
|
74
74
|
if (!config.auth.bearerToken) config.auth.bearerToken = []
|
|
75
75
|
if (!config.auth.bearerJwt) config.auth.bearerJwt = []
|
|
76
76
|
if (!config.auth.bearerJwtAzure) config.auth.bearerJwtAzure = []
|
|
77
|
+
if (!config.auth.bearerOAuth) config.auth.bearerOAuth = []
|
|
78
|
+
config.auth.oauthTokenStore = {} // oauth token store
|
|
77
79
|
if (!config.certificate) config.certificate = {}
|
|
78
80
|
if (!config.certificate.pfx) config.certificate.pfx = {}
|
|
79
81
|
if (!config.emailOnError) config.emailOnError = {}
|
|
@@ -110,6 +112,7 @@ const ScimGateway = function () {
|
|
|
110
112
|
let foundBearerToken = false
|
|
111
113
|
let foundBearerJwtAzure = false
|
|
112
114
|
let foundBearerJwt = false
|
|
115
|
+
let foundBearerOAuth = false
|
|
113
116
|
let pwPfxPassword
|
|
114
117
|
|
|
115
118
|
if (Array.isArray(config.auth.basic)) {
|
|
@@ -204,6 +207,20 @@ const ScimGateway = function () {
|
|
|
204
207
|
if (!foundBearerJwt) config.auth.bearerJwt = []
|
|
205
208
|
}
|
|
206
209
|
|
|
210
|
+
if (Array.isArray(config.auth.bearerOAuth)) {
|
|
211
|
+
const arr = config.auth.bearerOAuth
|
|
212
|
+
for (let i = 0; i < arr.length; i++) {
|
|
213
|
+
try {
|
|
214
|
+
if (arr[i].client_secret) arr[i].client_secret = ScimGateway.prototype.getPassword(`scimgateway.auth.bearerOAuth[${i}].client_secret`, configFile)
|
|
215
|
+
} catch (err) {
|
|
216
|
+
logger.error(`${gwName}[${pluginName}] getPassword error: ${err.message}`)
|
|
217
|
+
throw err // above logger.error included because this unhanledExcepton will be handled by winston and may fail with an other internal winston error e.g. related to memoryUsage collection logic when running in unikernel
|
|
218
|
+
}
|
|
219
|
+
if (arr[i].client_secret && arr[i].client_id) foundBearerOAuth = true
|
|
220
|
+
}
|
|
221
|
+
if (!foundBearerOAuth) config.auth.bearerOAuth = []
|
|
222
|
+
}
|
|
223
|
+
|
|
207
224
|
if (config.certificate.pfx.password) pwPfxPassword = ScimGateway.prototype.getPassword('scimgateway.certificate.pfx.password', configFile)
|
|
208
225
|
if (config.emailOnError.smtp.password) config.emailOnError.smtp.password = ScimGateway.prototype.getPassword('scimgateway.emailOnError.smtp.password', configFile)
|
|
209
226
|
|
|
@@ -299,31 +316,25 @@ const ScimGateway = function () {
|
|
|
299
316
|
}
|
|
300
317
|
|
|
301
318
|
// start auth methods - used by auth
|
|
302
|
-
const
|
|
303
|
-
return new Promise((resolve, reject) => {
|
|
304
|
-
if (ctx.url === '/ping' || ctx.url === '/favicon.ico') resolve(true) // ping - no auth
|
|
305
|
-
else resolve(false)
|
|
306
|
-
})
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
const basic = (baseEntity, method, authType, authToken) => {
|
|
319
|
+
const basic = (baseEntity, method, authType, authToken, url) => {
|
|
310
320
|
return new Promise((resolve, reject) => { // basic auth
|
|
321
|
+
if (url === '/ping' || url.endsWith('/oauth/token') || url === '/favicon.ico') resolve(true) // no auth
|
|
311
322
|
if (authType !== 'Basic') resolve(false)
|
|
312
323
|
if (!foundBasic) resolve(false) // not configured
|
|
313
324
|
const [userName, userPassword] = (Buffer.from(authToken, 'base64').toString() || '').split(':')
|
|
314
325
|
if (!userName || !userPassword) {
|
|
315
|
-
reject(new Error(`authentication failed for user ${userName}`))
|
|
326
|
+
return reject(new Error(`authentication failed for user ${userName}`))
|
|
316
327
|
}
|
|
317
328
|
const arr = config.auth.basic
|
|
318
329
|
for (let i = 0; i < arr.length; i++) {
|
|
319
330
|
if (arr[i].username === userName && arr[i].password === userPassword) { // authentication OK
|
|
320
331
|
if (arr[i].baseEntities) {
|
|
321
332
|
if (Array.isArray(arr[i].baseEntities) && arr[i].baseEntities.length > 0) {
|
|
322
|
-
if (!baseEntity) reject(new Error(`baseEntity=${baseEntity} not allowed for user ${arr[i].username} according to basic configuration baseEntitites=${arr[i].baseEntities}`))
|
|
323
|
-
if (!arr[i].baseEntities.includes(baseEntity)) reject(new Error(`baseEntity=${baseEntity} not allowed for user ${arr[i].username} according to basic configuration baseEntitites=${arr[i].baseEntities}`))
|
|
333
|
+
if (!baseEntity) return reject(new Error(`baseEntity=${baseEntity} not allowed for user ${arr[i].username} according to basic configuration baseEntitites=${arr[i].baseEntities}`))
|
|
334
|
+
if (!arr[i].baseEntities.includes(baseEntity)) return reject(new Error(`baseEntity=${baseEntity} not allowed for user ${arr[i].username} according to basic configuration baseEntitites=${arr[i].baseEntities}`))
|
|
324
335
|
}
|
|
325
336
|
}
|
|
326
|
-
if (arr[i].readOnly === true && method !== 'GET') reject(new Error(`only allowing readOnly for user ${arr[i].username} according to basic configuration readOnly=true`))
|
|
337
|
+
if (arr[i].readOnly === true && method !== 'GET') return reject(new Error(`only allowing readOnly for user ${arr[i].username} according to basic configuration readOnly=true`))
|
|
327
338
|
return resolve(true)
|
|
328
339
|
}
|
|
329
340
|
}
|
|
@@ -333,18 +344,18 @@ const ScimGateway = function () {
|
|
|
333
344
|
|
|
334
345
|
const bearerToken = (baseEntity, method, authType, authToken) => {
|
|
335
346
|
return new Promise((resolve, reject) => { // bearer token
|
|
336
|
-
if (authType !== 'Bearer' ||
|
|
347
|
+
if (authType !== 'Bearer' || !authToken) resolve(false)
|
|
337
348
|
if (!foundBearerToken || !authToken) resolve(false)
|
|
338
349
|
const arr = config.auth.bearerToken
|
|
339
350
|
for (let i = 0; i < arr.length; i++) {
|
|
340
351
|
if (arr[i].token === authToken) { // authentication OK
|
|
341
352
|
if (arr[i].baseEntities) {
|
|
342
353
|
if (Array.isArray(arr[i].baseEntities) && arr[i].baseEntities.length > 0) {
|
|
343
|
-
if (!baseEntity) reject(new Error(`baseEntity=${baseEntity} not allowed for this bearerToken according to bearerToken configuration baseEntitites=${arr[i].baseEntities}`))
|
|
344
|
-
if (!arr[i].baseEntities.includes(baseEntity)) reject(new Error(`baseEntity=${baseEntity} not allowed for this bearerToken according to bearerToken configuration baseEntitites=${arr[i].baseEntities}`))
|
|
354
|
+
if (!baseEntity) return reject(new Error(`baseEntity=${baseEntity} not allowed for this bearerToken according to bearerToken configuration baseEntitites=${arr[i].baseEntities}`))
|
|
355
|
+
if (!arr[i].baseEntities.includes(baseEntity)) return reject(new Error(`baseEntity=${baseEntity} not allowed for this bearerToken according to bearerToken configuration baseEntitites=${arr[i].baseEntities}`))
|
|
345
356
|
}
|
|
346
357
|
}
|
|
347
|
-
if (arr[i].readOnly === true && method !== 'GET') reject(new Error('only allowing readOnly for this bearerToken according to bearerToken configuration readOnly=true'))
|
|
358
|
+
if (arr[i].readOnly === true && method !== 'GET') return reject(new Error('only allowing readOnly for this bearerToken according to bearerToken configuration readOnly=true'))
|
|
348
359
|
return resolve(true)
|
|
349
360
|
}
|
|
350
361
|
}
|
|
@@ -360,18 +371,18 @@ const ScimGateway = function () {
|
|
|
360
371
|
if (!payload.iss) resolve(false)
|
|
361
372
|
if (payload.iss.indexOf('https://sts.windows.net') !== 0) resolve(false)
|
|
362
373
|
passport.authenticate('oauth-bearer', { session: false }, (err, user, info) => {
|
|
363
|
-
if (err) { reject(err) }
|
|
374
|
+
if (err) { return reject(err) }
|
|
364
375
|
if (user) { // authenticated OK
|
|
365
376
|
const arr = config.auth.bearerJwtAzure
|
|
366
377
|
for (let i = 0; i < arr.length; i++) {
|
|
367
378
|
if (arr[i].tenantIdGUID && payload.iss.includes(arr[i].tenantIdGUID)) {
|
|
368
379
|
if (arr[i].baseEntities) {
|
|
369
380
|
if (Array.isArray(arr[i].baseEntities) && arr[i].baseEntities.length > 0) {
|
|
370
|
-
if (!baseEntity) reject(new Error(`baseEntity=${baseEntity} not allowed for user ${arr[i].tenantIdGUID} according to bearerJwtAzure configuration baseEntitites=${arr[i].baseEntities}`))
|
|
371
|
-
if (!arr[i].baseEntities.includes(baseEntity)) reject(new Error(`baseEntity=${baseEntity} not allowed for user ${arr[i].tenantIdGUID} according to bearerJwtAzure configuration baseEntitites=${arr[i].baseEntities}`))
|
|
381
|
+
if (!baseEntity) return reject(new Error(`baseEntity=${baseEntity} not allowed for user ${arr[i].tenantIdGUID} according to bearerJwtAzure configuration baseEntitites=${arr[i].baseEntities}`))
|
|
382
|
+
if (!arr[i].baseEntities.includes(baseEntity)) return reject(new Error(`baseEntity=${baseEntity} not allowed for user ${arr[i].tenantIdGUID} according to bearerJwtAzure configuration baseEntitites=${arr[i].baseEntities}`))
|
|
372
383
|
}
|
|
373
384
|
}
|
|
374
|
-
if (arr[i].readOnly === true && ctx.request.method !== 'GET') reject(new Error(`only allowing readOnly for user ${arr[i].tenantIdGUID} according to bearerJwtAzure configuration readOnly=true`))
|
|
385
|
+
if (arr[i].readOnly === true && ctx.request.method !== 'GET') return reject(new Error(`only allowing readOnly for user ${arr[i].tenantIdGUID} according to bearerJwtAzure configuration readOnly=true`))
|
|
375
386
|
}
|
|
376
387
|
}
|
|
377
388
|
resolve(true)
|
|
@@ -414,6 +425,54 @@ const ScimGateway = function () {
|
|
|
414
425
|
}
|
|
415
426
|
throw new Error('JWT authentication failed')
|
|
416
427
|
}
|
|
428
|
+
|
|
429
|
+
const bearerOAuth = (baseEntity, method, authType, authToken) => {
|
|
430
|
+
return new Promise((resolve, reject) => { // bearer token
|
|
431
|
+
if (authType !== 'Bearer' || !authToken) resolve(false)
|
|
432
|
+
if (!foundBearerOAuth || !authToken) resolve(false)
|
|
433
|
+
// config.auth.oauthTokenStore is autmatically generated by token create having syntax:
|
|
434
|
+
// { config.auth.oauthTokenStore: <token>: { expireDate: <timestamp>, readOnly: <copy-from-config>, baseEntities: [ <copy-from-config> ], isTokenRequested: true }}
|
|
435
|
+
const arr = config.auth.bearerOAuth
|
|
436
|
+
if (config.auth.oauthTokenStore[authToken]) { // authentication OK
|
|
437
|
+
const tokenObj = config.auth.oauthTokenStore[authToken]
|
|
438
|
+
if (Date.now() > tokenObj.expireDate) {
|
|
439
|
+
delete config.auth.oauthTokenStore[authToken]
|
|
440
|
+
const err = new Error('OAuth access token expired')
|
|
441
|
+
err.token_error = 'invalid_token'
|
|
442
|
+
err.token_error_description = 'The access token expired'
|
|
443
|
+
return reject(err)
|
|
444
|
+
}
|
|
445
|
+
if (tokenObj.baseEntities) {
|
|
446
|
+
if (Array.isArray(tokenObj.baseEntities) && tokenObj.baseEntities.length > 0) {
|
|
447
|
+
if (!baseEntity) return reject(new Error(`baseEntity=${baseEntity} not allowed for this bearerOAuth according to bearerOAuth configuration baseEntitites=${tokenObj.baseEntities}`))
|
|
448
|
+
if (!tokenObj.baseEntities.includes(baseEntity)) return reject(new Error(`baseEntity=${baseEntity} not allowed for this bearerOAuth according to bearerOAuth configuration baseEntitites=${tokenObj.baseEntities}`))
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
if (tokenObj.readOnly === true && method !== 'GET') return reject(new Error('only allowing readOnly for this bearerOAuth according to bearerOAuth configuration readOnly=true'))
|
|
452
|
+
return resolve(true)
|
|
453
|
+
} else {
|
|
454
|
+
for (let i = 0; i < arr.length; i++) { // resolve if token memory store have been cleared becuase of a gateway restart
|
|
455
|
+
if (utils.getEncrypted(authToken, arr[i].client_secret) === 'token' && !arr[i].isTokenRequested) {
|
|
456
|
+
arr[i].isTokenRequested = true // flagged as true to not allow repeated resolvements becuase token will also be cleared when expired
|
|
457
|
+
const baseEntities = utils.copyObj(arr[i].baseEntities)
|
|
458
|
+
let expires
|
|
459
|
+
let readOnly = false
|
|
460
|
+
if (arr[i].readOnly && arr[i].readOnly === true) readOnly = true
|
|
461
|
+
if (arr[i].expires_in && !isNaN(arr[i].expires_in)) expires = arr[i].expires_in
|
|
462
|
+
else expires = oAuthTokenExpire
|
|
463
|
+
config.auth.oauthTokenStore[authToken] = {
|
|
464
|
+
expireDate: Date.now() + expires * 1000,
|
|
465
|
+
readOnly: readOnly,
|
|
466
|
+
baseEntities: baseEntities
|
|
467
|
+
}
|
|
468
|
+
return resolve(true)
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
reject(new Error('OAuth authentication failed'))
|
|
473
|
+
})
|
|
474
|
+
}
|
|
475
|
+
|
|
417
476
|
// end auth methods - used by auth
|
|
418
477
|
|
|
419
478
|
const auth = async (ctx, next) => { // authentication/authorization
|
|
@@ -425,7 +484,13 @@ const ScimGateway = function () {
|
|
|
425
484
|
if (!['Users', 'Groups', 'Schemas', 'ServiceProviderConfigs', 'scim'].includes(entity)) baseEntity = entity
|
|
426
485
|
}
|
|
427
486
|
try { // authenticate
|
|
428
|
-
const arrResolve = await Promise.all([
|
|
487
|
+
const arrResolve = await Promise.all([
|
|
488
|
+
basic(baseEntity, ctx.request.method, authType, authToken, ctx.url),
|
|
489
|
+
bearerToken(baseEntity, ctx.request.method, authType, authToken),
|
|
490
|
+
bearerJwtAzure(baseEntity, ctx, next, authType, authToken),
|
|
491
|
+
bearerJwt(baseEntity, ctx.request.method, authType, authToken),
|
|
492
|
+
bearerOAuth(baseEntity, ctx.request.method, authType, authToken)])
|
|
493
|
+
.catch((err) => { throw (err) })
|
|
429
494
|
for (const i in arrResolve) {
|
|
430
495
|
if (arrResolve[i]) {
|
|
431
496
|
ctx.set('Content-Type', 'application/scim+json; charset=utf-8') // IE don't support JSON content to be shown in browser, pre IE/Edge versions did not support 'application/scim+json'
|
|
@@ -440,23 +505,37 @@ const ScimGateway = function () {
|
|
|
440
505
|
logger.debug(`${gwName}[${pluginName}] request authToken = ${authToken}`)
|
|
441
506
|
logger.debug(`${gwName}[${pluginName}] request jwt.decode(authToken) = ${JSON.stringify(jwt.decode(authToken))}`)
|
|
442
507
|
}
|
|
443
|
-
ctx.set('WWW-Authenticate', '
|
|
508
|
+
if (authType === 'Bearer') ctx.set('WWW-Authenticate', 'Bearer realm=""')
|
|
509
|
+
else ctx.set('WWW-Authenticate', 'Basic realm=""')
|
|
510
|
+
ctx.set('Content-Type', 'application/json; charset=utf-8')
|
|
444
511
|
ctx.status = 401
|
|
445
|
-
ctx.body = 'Access denied'
|
|
512
|
+
ctx.body = { error: 'Access denied' }
|
|
446
513
|
if (ctx.url !== '/favicon.ico') logger.error(`${gwName}[${pluginName}] ${err.message}`)
|
|
447
514
|
} catch (err) {
|
|
448
|
-
|
|
515
|
+
const body = {}
|
|
516
|
+
if (authType === 'Bearer') {
|
|
517
|
+
let str = 'realm=""'
|
|
518
|
+
if (err.token_error) {
|
|
519
|
+
str += `, error="${err.token_error}"`
|
|
520
|
+
body.error = err.token_error
|
|
521
|
+
}
|
|
522
|
+
if (err.token_error_description) {
|
|
523
|
+
str += `, error_description="${err.token_error_description}"`
|
|
524
|
+
body.error_description = err.token_error_description
|
|
525
|
+
}
|
|
526
|
+
ctx.set('WWW-Authenticate', `Bearer ${str}`)
|
|
527
|
+
} else ctx.set('WWW-Authenticate', 'Basic realm=""')
|
|
528
|
+
ctx.set('Content-Type', 'application/json; charset=utf-8')
|
|
529
|
+
ctx.status = 401
|
|
530
|
+
if (Object.keys(body).length > 0) ctx.body = body
|
|
531
|
+
else ctx.body = { error: 'Access denied' }
|
|
449
532
|
if (pwErrCount < 3) {
|
|
450
533
|
pwErrCount += 1
|
|
451
|
-
ctx.status = 401
|
|
452
|
-
ctx.body = 'Access denied'
|
|
453
534
|
logger.error(`${gwName}[${pluginName}] ${ctx.url} ${err.message}`)
|
|
454
535
|
} else { // delay brute force attempts
|
|
455
536
|
logger.error(`${gwName}[${pluginName}] ${ctx.url} ${err.message} => delaying response with 2 minutes to prevent brute force`)
|
|
456
537
|
return new Promise((resolve) => {
|
|
457
538
|
setTimeout(() => {
|
|
458
|
-
ctx.status = 401
|
|
459
|
-
ctx.body = 'Access denied'
|
|
460
539
|
resolve(ctx)
|
|
461
540
|
}, 1000 * 60 * 2)
|
|
462
541
|
})
|
|
@@ -468,9 +547,8 @@ const ScimGateway = function () {
|
|
|
468
547
|
return new Promise((resolve) => {
|
|
469
548
|
if (ctx.request.length) { // body is included - invalid content-type gives empty body (koa-bodyparser)
|
|
470
549
|
const contentType = ctx.request.type.toLowerCase()
|
|
471
|
-
if (contentType === 'application/json' || contentType === 'application/scim+json')
|
|
472
|
-
|
|
473
|
-
}
|
|
550
|
+
if (contentType === 'application/json' || contentType === 'application/scim+json') return resolve(next())
|
|
551
|
+
if (ctx.url.endsWith('/oauth/token')) return resolve(next())
|
|
474
552
|
ctx.status = 415
|
|
475
553
|
ctx.body = 'Content-Type header must be \'application/json\' or \'application/scim+json\''
|
|
476
554
|
return resolve(ctx)
|
|
@@ -499,8 +577,9 @@ const ScimGateway = function () {
|
|
|
499
577
|
// There is no return value, if there were it would be ignored
|
|
500
578
|
app.use(logResult)
|
|
501
579
|
app.use(bodyParser({ // parsed body store in ctx.request.body
|
|
502
|
-
enableTypes: ['json'],
|
|
503
|
-
extendTypes: { json: ['application/scim+json', 'text/plain'] }
|
|
580
|
+
enableTypes: ['json', 'form'],
|
|
581
|
+
extendTypes: { json: ['application/scim+json', 'text/plain'] },
|
|
582
|
+
formTypes: { form: ['application/x-www-form-urlencoded'] }
|
|
504
583
|
}))
|
|
505
584
|
app.use(ipAllowList)
|
|
506
585
|
app.use(auth) // authentication before routes
|
|
@@ -542,6 +621,103 @@ const ScimGateway = function () {
|
|
|
542
621
|
ctx.body = tx
|
|
543
622
|
})
|
|
544
623
|
|
|
624
|
+
// oauth token request
|
|
625
|
+
router.post(['/(|scim/)oauth/token', '/:baseEntity/(|scim/)oauth/token'], async (ctx) => {
|
|
626
|
+
logger.debug(`${gwName}[${pluginName}] [oauth] token request`)
|
|
627
|
+
if (!foundBearerOAuth) {
|
|
628
|
+
logger.error(`${gwName}[${pluginName}] [oauth] token request, but plugin is missing config.auth.bearerOAuth configuration`)
|
|
629
|
+
ctx.status = 500
|
|
630
|
+
return
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const jsonBody = ctx.request.body
|
|
634
|
+
const [authType, authToken] = (ctx.request.header.authorization || '').split(' ') // [0] = 'Basic'
|
|
635
|
+
if (authType === 'Basic') { // id and secret may be in authorization header if not already included in body
|
|
636
|
+
const [id, secret] = (Buffer.from(authToken, 'base64').toString() || '').split(':')
|
|
637
|
+
if (jsonBody.grant_type && id && secret) {
|
|
638
|
+
if (jsonBody.grant_type === 'client_credentials' || jsonBody.grant_type === 'refresh_token') { // don't use refresh_token but allowing as type
|
|
639
|
+
jsonBody.client_id = id
|
|
640
|
+
jsonBody.client_secret = secret
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
let expires
|
|
646
|
+
let token
|
|
647
|
+
let readOnly = false
|
|
648
|
+
let baseEntities
|
|
649
|
+
let err
|
|
650
|
+
let errDescr
|
|
651
|
+
if (!jsonBody.grant_type || (jsonBody.grant_type !== 'client_credentials' && jsonBody.grant_type !== 'refresh_token')) {
|
|
652
|
+
err = 'invalid_request'
|
|
653
|
+
errDescr = 'request type must be Client Credentials (grant_type=client_credentials)'
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (!err) {
|
|
657
|
+
const arr = config.auth.bearerOAuth
|
|
658
|
+
for (let i = 0; i < arr.length; i++) {
|
|
659
|
+
if (!arr[i].client_id || !arr[i].client_secret) continue
|
|
660
|
+
if (arr[i].client_id === jsonBody.client_id && arr[i].client_secret === jsonBody.client_secret) { // authentication OK
|
|
661
|
+
token = utils.getEncrypted('token', jsonBody.client_secret) // client_secret as seed
|
|
662
|
+
baseEntities = utils.copyObj(arr[i].baseEntities)
|
|
663
|
+
if (arr[i].readOnly && arr[i].readOnly === true) readOnly = true
|
|
664
|
+
if (arr[i].expires_in && !isNaN(arr[i].expires_in)) expires = arr[i].expires_in
|
|
665
|
+
else expires = oAuthTokenExpire
|
|
666
|
+
arr[i].isTokenRequested = true
|
|
667
|
+
break
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
if (!token) {
|
|
671
|
+
err = 'invalid_client'
|
|
672
|
+
errDescr = 'incorrect or missing client_id/client_secret'
|
|
673
|
+
if (pwErrCount < 3) {
|
|
674
|
+
pwErrCount += 1
|
|
675
|
+
} else { // delay brute force attempts
|
|
676
|
+
logger.error(`${gwName}[${pluginName}] [oauth] ${ctx.url} ${errDescr} => delaying response with 2 minutes to prevent brute force`)
|
|
677
|
+
return new Promise((resolve) => {
|
|
678
|
+
setTimeout(() => {
|
|
679
|
+
resolve(ctx)
|
|
680
|
+
}, 1000 * 60 * 2)
|
|
681
|
+
})
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
if (err) {
|
|
687
|
+
logger.error(`${gwName}[${pluginName}] [oauth] token request client_id: ${jsonBody ? jsonBody.client_id : ''} error: ${errDescr}`)
|
|
688
|
+
ctx.status = 400
|
|
689
|
+
ctx.body = {
|
|
690
|
+
error: err,
|
|
691
|
+
error_description: errDescr
|
|
692
|
+
}
|
|
693
|
+
return
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const dtNow = Date.now()
|
|
697
|
+
for (const i in config.auth.oauthTokenStore) { // cleanup any expired tokens
|
|
698
|
+
const tokenObj = config.auth.oauthTokenStore[i]
|
|
699
|
+
if (dtNow > tokenObj.expireDate) {
|
|
700
|
+
delete config.auth.oauthTokenStore[i]
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
config.auth.oauthTokenStore[token] = { // update token store
|
|
705
|
+
expireDate: dtNow + expires * 1000, // 1 hour
|
|
706
|
+
readOnly: readOnly,
|
|
707
|
+
baseEntities: baseEntities
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const tx = {
|
|
711
|
+
access_token: token,
|
|
712
|
+
token_type: 'Bearer',
|
|
713
|
+
expires_in: expires,
|
|
714
|
+
refresh_token: token // ignored by scimgateway, but maybe used by client
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
ctx.set('Cache-Control', 'no-store')
|
|
718
|
+
ctx.body = tx
|
|
719
|
+
})
|
|
720
|
+
|
|
545
721
|
router.get(['/(|scim/)Schemas/:id', '/:baseEntity/(|scim/)Schemas/:id'], async (ctx) => { // e.g /Schemas/Users | Groups | ServiceProviderConfigs
|
|
546
722
|
let schemaName = ctx.params.id
|
|
547
723
|
if (schemaName.substr(schemaName.length - 1) === 's') schemaName = schemaName.substr(0, schemaName.length - 1)
|
|
@@ -1818,7 +1994,7 @@ ScimGateway.prototype.endpointMapper = function endpointMapper (direction, parse
|
|
|
1818
1994
|
}
|
|
1819
1995
|
}
|
|
1820
1996
|
for (const key2 in mapObj) {
|
|
1821
|
-
if (mapObj[key2].mapTo.
|
|
1997
|
+
if (mapObj[key2].mapTo.split(',').map(item => item.trim().toLowerCase()).includes(key.toLowerCase())) {
|
|
1822
1998
|
found = true
|
|
1823
1999
|
if (mapObj[key2].type === 'array' && arrIndex && arrIndex >= 0) {
|
|
1824
2000
|
dotNewObj[`${key2}.${arrIndex}`] = dotObj[keyOrg] // servicePlan.0.value => servicePlan.0 and groups[0].value => memberOf.0
|
|
@@ -1831,16 +2007,19 @@ ScimGateway.prototype.endpointMapper = function endpointMapper (direction, parse
|
|
|
1831
2007
|
}
|
|
1832
2008
|
} else { // string (get)
|
|
1833
2009
|
const resArr = []
|
|
1834
|
-
let strArr
|
|
1835
|
-
if (Array.isArray(str))
|
|
1836
|
-
|
|
2010
|
+
let strArr = []
|
|
2011
|
+
if (Array.isArray(str)) {
|
|
2012
|
+
for (let i = 0; i < str.length; i++) {
|
|
2013
|
+
strArr = strArr.concat(str[i].split(',').map(item => item.trim())) // supports "id,userName" e.g. {"mapTo": "id,userName"}
|
|
2014
|
+
}
|
|
2015
|
+
} else strArr = str.split(',').map(item => item.trim())
|
|
1837
2016
|
for (let i = 0; i < strArr.length; i++) {
|
|
1838
2017
|
const attr = strArr[i]
|
|
1839
2018
|
let found = false
|
|
1840
2019
|
for (const key in mapObj) {
|
|
1841
|
-
if (mapObj[key].mapTo
|
|
2020
|
+
if (mapObj[key].mapTo && mapObj[key].mapTo.split(',').map(item => item.trim()).includes(attr)) { // supports { "mapTo": "userName,id" }
|
|
1842
2021
|
found = true
|
|
1843
|
-
resArr.push(key)
|
|
2022
|
+
if (!resArr.includes(key)) resArr.push(key)
|
|
1844
2023
|
break
|
|
1845
2024
|
} else if (attr === 'roles' && mapObj[key].mapTo === 'roles.value') { // allow get using attribute roles - convert to correct roles.value
|
|
1846
2025
|
found = true
|
|
@@ -1922,7 +2101,10 @@ ScimGateway.prototype.endpointMapper = function endpointMapper (direction, parse
|
|
|
1922
2101
|
mapTo = mapTo.replace('.', '##') // only first occurence
|
|
1923
2102
|
noneCore = true
|
|
1924
2103
|
}
|
|
1925
|
-
|
|
2104
|
+
const arrMapTo = mapTo.split(',').map(item => item.trim()) // supports {"mapTo": "id,userName"}
|
|
2105
|
+
for (let i = 0; i < arrMapTo.length; i++) {
|
|
2106
|
+
dotNewObj[arrMapTo[i]] = dotObj[key] // {"active": {"mapTo": "accountEnabled"} => str.replace("accountEnabled", "active")
|
|
2107
|
+
}
|
|
1926
2108
|
}
|
|
1927
2109
|
let mapTo = mapObj[key].mapTo
|
|
1928
2110
|
if (mapTo.startsWith('urn:')) {
|
package/lib/utils.js
CHANGED
|
@@ -336,3 +336,40 @@ module.exports.sortByKey = (key, order = 'ascending') => {
|
|
|
336
336
|
)
|
|
337
337
|
}
|
|
338
338
|
}
|
|
339
|
+
|
|
340
|
+
// getEncrypted returns encrypted or cleartext secret
|
|
341
|
+
// same as getPassword method, but seed passed as argument and not using json configuration file
|
|
342
|
+
// if pw is cleartext, return encrypted secret
|
|
343
|
+
// if pw is encrypted, return cleartext secret
|
|
344
|
+
module.exports.getEncrypted = function (pw, seed) {
|
|
345
|
+
if (!pw || !seed) return undefined
|
|
346
|
+
const ivLength = 16
|
|
347
|
+
if (seed.length < ivLength) {
|
|
348
|
+
const addStr = 'aB1cD2eF3gH4iJ5kL7'
|
|
349
|
+
const diff = ivLength - seed.length
|
|
350
|
+
if (diff > 0) seed += addStr.substring(0, diff)
|
|
351
|
+
}
|
|
352
|
+
if (pw) {
|
|
353
|
+
try { // decrypt
|
|
354
|
+
const pwencr = Buffer.from(pw, 'base64').toString('utf8')
|
|
355
|
+
const textParts = pwencr.split(':')
|
|
356
|
+
const iv = Buffer.from(textParts.shift(), 'hex')
|
|
357
|
+
const encryptedText = Buffer.from(textParts.join(':'), 'hex')
|
|
358
|
+
const decipher = crypto.createDecipheriv('aes-128-cbc', Buffer.from(seed.substr(-ivLength)), iv)
|
|
359
|
+
let pwclear = decipher.update(encryptedText)
|
|
360
|
+
pwclear = Buffer.concat([pwclear, decipher.final()])
|
|
361
|
+
pwclear = pwclear.toString()
|
|
362
|
+
return pwclear
|
|
363
|
+
} catch (err) { // password considered as cleartext and needs to be encrypted
|
|
364
|
+
const pwclear = pw
|
|
365
|
+
const iv = crypto.randomBytes(ivLength)
|
|
366
|
+
const cipher = crypto.createCipheriv('aes-128-cbc', Buffer.from(seed.substr(-ivLength), 'utf8'), iv)
|
|
367
|
+
let encrypted = cipher.update(pwclear)
|
|
368
|
+
encrypted = Buffer.concat([encrypted, cipher.final()])
|
|
369
|
+
let pwencr = iv.toString('hex') + ':' + encrypted.toString('hex')
|
|
370
|
+
pwencr = Buffer.from(pwencr).toString('base64')
|
|
371
|
+
return pwencr
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return undefined
|
|
375
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scimgateway",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.1.2",
|
|
4
4
|
"description": "Using SCIM protocol as a gateway for user provisioning to other endpoints",
|
|
5
5
|
"author": "Jarle Elshaug <jarle.elshaug@gmail.com> (https://elshaug.xyz)",
|
|
6
6
|
"homepage": "https://elshaug.xyz",
|