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 CHANGED
@@ -16,7 +16,8 @@ Validated through IdP's:
16
16
 
17
17
  Latest news:
18
18
 
19
- - New 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
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 could do user management by using REST based [SCIM](http://www.simplecloud.info/) 1.1 or 2.0 protocol. Gateway will translate 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
+ 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 restful = require('./lib/plugin-restful')
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
 
@@ -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": {
@@ -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": {
@@ -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": {
@@ -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": [
@@ -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": {
@@ -53,6 +53,14 @@
53
53
  "readOnly": false,
54
54
  "baseEntities": []
55
55
  }
56
+ ],
57
+ "bearerOAuth": [
58
+ {
59
+ "client_id": null,
60
+ "client_secret": null,
61
+ "readOnly": false,
62
+ "baseEntities": []
63
+ }
56
64
  ]
57
65
  },
58
66
  "certificate": {
@@ -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": {
@@ -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": {
@@ -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": {
@@ -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
 
@@ -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 unauth = (ctx) => {
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' || jwt.decode(authToken)) resolve(false) // bearer token auth not used
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([unauth(ctx), basic(baseEntity, ctx.request.method, authType, authToken), bearerToken(baseEntity, ctx.request.method, authType, authToken), bearerJwtAzure(baseEntity, ctx, next, authType, authToken), bearerJwt(baseEntity, ctx.request.method, authType, authToken)]).catch((err) => { throw (err) })
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', 'Basic realm=""')
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
- ctx.set('WWW-Authenticate', 'Basic realm=""')
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
- return resolve(next())
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.toLowerCase() === key.toLowerCase()) {
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)) strArr = str
1836
- else strArr = str.split(',').map(item => item.trim())
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 === attr) {
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
- dotNewObj[mapTo] = dotObj[key] // {"active": {"mapTo": "accountEnabled"} => str.replace("accountEnabled", "active")
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.0.1",
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",