scimgateway 6.1.3 → 6.1.4
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 +9 -1
- package/config/plugin-entra-id.json +9 -1
- package/lib/helper-rest.ts +75 -60
- package/lib/plugin-entra-id.ts +44 -24
- package/lib/plugin-scim.ts +8 -8
- package/lib/scimgateway.ts +44 -44
- package/lib/utils-scim.ts +30 -6
- package/lib/utils.ts +0 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
Latest news:
|
|
19
19
|
|
|
20
20
|
- Bun binary build is now supported, allowing SCIM Gateway to be compiled into a single executable binary for simplified deployment and execution. SCIM Gateway can now run as an ES module (TypeScript) in Node.js.
|
|
21
|
-
- Major release **v6.0.0** introduces changes to API method
|
|
21
|
+
- Major release **v6.0.0** introduces changes to API method responses (not SCIM-related) and a new method `publicApi()` for handling public path `/pub/api` requests with no authentication required. In addition, the configuration option `bearerJwtAzure.tenantIdGUID` has been replaced by `bearerJwt.azureTenantId`. See the version history for details.
|
|
22
22
|
- Support for Entra ID [Federated Identity Credentials](https://learn.microsoft.com/en-us/graph/api/resources/federatedidentitycredentials-overview?view=graph-rest-1.0) has been added through internal JWKS (JSON Web Key Set), allowing SCIM Gateway to access Microsoft Entra–protected resources without the need to manage secrets
|
|
23
23
|
- External JWKS (JSON Web Key Set) is now supported by JWT authentication, allowing external applications to access SCIM Gateway without the need to manage secrets
|
|
24
24
|
- [Azure Relay](https://learn.microsoft.com/en-us/azure/azure-relay/relay-what-is-it) is now supported for secure and hassle-free outbound-only communication — with just one minute of configuration
|
|
@@ -1303,6 +1303,14 @@ MIT © [Jarle Elshaug](https://www.elshaug.xyz)
|
|
|
1303
1303
|
|
|
1304
1304
|
## Change log
|
|
1305
1305
|
|
|
1306
|
+
### v6.1.4
|
|
1307
|
+
|
|
1308
|
+
[Fixed]
|
|
1309
|
+
|
|
1310
|
+
- plugin-entra-id, OData paging was not working, so some users/groups/members might be missing
|
|
1311
|
+
- helper-rest, OData paging
|
|
1312
|
+
- user’s group membership did not iterate through paging and may be incomplete
|
|
1313
|
+
|
|
1306
1314
|
### v6.1.3
|
|
1307
1315
|
|
|
1308
1316
|
[Fixed]
|
|
@@ -195,6 +195,10 @@
|
|
|
195
195
|
"mapTo": "companyName",
|
|
196
196
|
"type": "string"
|
|
197
197
|
},
|
|
198
|
+
"employeeHireDate": {
|
|
199
|
+
"mapTo": "employeeHireDate",
|
|
200
|
+
"type": "string"
|
|
201
|
+
},
|
|
198
202
|
"employeeOrgData.costCenter": {
|
|
199
203
|
"mapTo": "employeeOrgData.costCenter",
|
|
200
204
|
"type": "string"
|
|
@@ -239,6 +243,10 @@
|
|
|
239
243
|
"type": "array",
|
|
240
244
|
"typeInbound": "string"
|
|
241
245
|
},
|
|
246
|
+
"faxNumber": {
|
|
247
|
+
"mapTo": "faxNumber",
|
|
248
|
+
"type": "string"
|
|
249
|
+
},
|
|
242
250
|
"country": {
|
|
243
251
|
"mapTo": "country",
|
|
244
252
|
"type": "string"
|
|
@@ -298,7 +306,7 @@
|
|
|
298
306
|
"type": "string"
|
|
299
307
|
},
|
|
300
308
|
"displayName": {
|
|
301
|
-
"mapTo": "displayName
|
|
309
|
+
"mapTo": "displayName",
|
|
302
310
|
"type": "string"
|
|
303
311
|
},
|
|
304
312
|
"securityEnabled": {
|
package/lib/helper-rest.ts
CHANGED
|
@@ -65,10 +65,9 @@ export class HelperRest {
|
|
|
65
65
|
* getAccessToken returns oauth accesstoken object
|
|
66
66
|
* @param baseEntity
|
|
67
67
|
* @param connectionObj endpoint.entity.baseEntity.connection
|
|
68
|
-
* @param ctx
|
|
69
68
|
* @returns { access_token: 'xxx', token_type: 'Bearer/Basic', validTo: 'xxx' }
|
|
70
69
|
*/
|
|
71
|
-
public async getAccessToken(baseEntity: string, connectionObj: Record<string, any
|
|
70
|
+
public async getAccessToken(baseEntity: string, connectionObj: Record<string, any>) { // public in case token is needed for other logic e.g. sending mail
|
|
72
71
|
await this.lock.acquire()
|
|
73
72
|
const d = Math.floor(Date.now() / 1000) // seconds (unix time)
|
|
74
73
|
if (this._serviceClient[baseEntity]?.accessToken?.validTo >= d + 30) { // avoid simultaneously token requests
|
|
@@ -229,7 +228,7 @@ export class HelperRest {
|
|
|
229
228
|
if (!this.scimgateway.jwk[kid]) {
|
|
230
229
|
this.scimgateway.jwk[kid] = { publicKey, privateKey }
|
|
231
230
|
const ttl = 5 * 60
|
|
232
|
-
|
|
231
|
+
; (async () => {
|
|
233
232
|
setTimeout(async () => {
|
|
234
233
|
delete this.scimgateway.jwk[kid]
|
|
235
234
|
}, ttl * 1000)
|
|
@@ -393,7 +392,7 @@ export class HelperRest {
|
|
|
393
392
|
if (!connOpt.headers) connOpt.headers = {}
|
|
394
393
|
connOpt.headers['Content-Type'] = 'application/x-www-form-urlencoded' // body must be query string formatted (no JSON)
|
|
395
394
|
|
|
396
|
-
const response = await this.doRequest(baseEntity, method, tokenUrl, form,
|
|
395
|
+
const response = await this.doRequest(baseEntity, method, tokenUrl, form, undefined, connOpt)
|
|
397
396
|
if (!response.body) {
|
|
398
397
|
const err = new Error(`[${action}] No data retrieved from: ${method} ${tokenUrl}`)
|
|
399
398
|
throw (err)
|
|
@@ -452,7 +451,7 @@ export class HelperRest {
|
|
|
452
451
|
if (this._serviceClient[baseEntity].accessToken.validTo < d + 30) { // less than 30 sec before token expiration
|
|
453
452
|
this.scimgateway.logDebug(baseEntity, `${action}: Accesstoken about to expire in ${this._serviceClient[baseEntity].accessToken.validTo - d} seconds`)
|
|
454
453
|
try {
|
|
455
|
-
const accessToken = await this.getAccessToken(baseEntity, connectionObj
|
|
454
|
+
const accessToken = await this.getAccessToken(baseEntity, connectionObj)
|
|
456
455
|
this._serviceClient[baseEntity].accessToken = accessToken
|
|
457
456
|
this._serviceClient[baseEntity].options.headers['Authorization'] = `${accessToken.token_type} ${accessToken.access_token}`
|
|
458
457
|
} catch (err) {
|
|
@@ -512,7 +511,7 @@ export class HelperRest {
|
|
|
512
511
|
}
|
|
513
512
|
}
|
|
514
513
|
|
|
515
|
-
param.accessToken = await this.getAccessToken(baseEntity, connectionObj
|
|
514
|
+
param.accessToken = await this.getAccessToken(baseEntity, connectionObj)
|
|
516
515
|
if (param.accessToken?.access_token && param.accessToken?.token_type) {
|
|
517
516
|
param.options.headers['Authorization'] = `${param.accessToken.token_type} ${param.accessToken.access_token}`
|
|
518
517
|
} else { // no auth or PassTrough
|
|
@@ -561,8 +560,6 @@ export class HelperRest {
|
|
|
561
560
|
|
|
562
561
|
// OData support
|
|
563
562
|
this._serviceClient[baseEntity].nextLink = {} // OData pagination (Entra ID)
|
|
564
|
-
this._serviceClient[baseEntity].nextLink.users = null
|
|
565
|
-
this._serviceClient[baseEntity].nextLink.groups = null
|
|
566
563
|
}
|
|
567
564
|
|
|
568
565
|
if (ctx?.headers?.get) { // Auth PassThrough using ctx header
|
|
@@ -679,7 +676,35 @@ export class HelperRest {
|
|
|
679
676
|
options.body = dataString
|
|
680
677
|
} else if (options.headers) delete options.headers['Content-Type']
|
|
681
678
|
|
|
682
|
-
|
|
679
|
+
let url = `${options.protocol}//${options.host}${options.port ? ':' + options.port : ''}${options.path}`
|
|
680
|
+
if (url.includes('$count=true') && url.includes('graph.microsoft.com')) {
|
|
681
|
+
options.headers['ConsistencyLevel'] = 'eventual'
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
if (this._serviceClient[baseEntity]?.nextLink[url]) {
|
|
685
|
+
if (ctx?.paging?.startIndex && ctx.paging.startIndex > 1) {
|
|
686
|
+
if (ctx.paging.startIndex === this._serviceClient[baseEntity]?.nextLink[url].startIndex) {
|
|
687
|
+
url = this._serviceClient[baseEntity]?.nextLink[url]['@odata.nextLink']
|
|
688
|
+
} else {
|
|
689
|
+
if (!ctx) ctx = {}
|
|
690
|
+
if (!ctx.paging) ctx.paging = {}
|
|
691
|
+
if (this._serviceClient[baseEntity]?.nextLink[url].totalResults
|
|
692
|
+
&& ctx.paging.startIndex > this._serviceClient[baseEntity]?.nextLink[url].totalResults) {
|
|
693
|
+
ctx.paging.totalResults = this._serviceClient[baseEntity]?.nextLink[url].totalResults
|
|
694
|
+
return { body: { value: [] } }
|
|
695
|
+
} else {
|
|
696
|
+
// reset the paging cursor - none expected startIndex sequence, using default none paged url
|
|
697
|
+
ctx.paging.startIndex = 1 // caller should check and return this new startIndex in final response
|
|
698
|
+
delete this._serviceClient[baseEntity].nextLink[url]
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
} else {
|
|
703
|
+
if (ctx?.paging?.startIndex > 1 && !this._serviceClient[baseEntity]?.nextLink[url]) { // no previous paging and invalid startIndex
|
|
704
|
+
ctx.paging.totalResults = ctx.paging.startIndex - 1
|
|
705
|
+
return { body: { value: [] } }
|
|
706
|
+
}
|
|
707
|
+
}
|
|
683
708
|
|
|
684
709
|
// execute request
|
|
685
710
|
const f = await fetch(url, options)
|
|
@@ -708,30 +733,50 @@ export class HelperRest {
|
|
|
708
733
|
throw new Error(JSON.stringify(result))
|
|
709
734
|
}
|
|
710
735
|
this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${options.protocol}//${options.host}${(options.port ? `:${options.port}` : '')}${options.path} Body = ${JSON.stringify(body)} Response = ${JSON.stringify(result)}`)
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
736
|
+
|
|
737
|
+
// OData paging logic
|
|
738
|
+
// client prerequisite for enabling doRequest() OData paging support (see plugin-entra-id):
|
|
739
|
+
// let paging = { startIndex: getObj.startIndex }
|
|
740
|
+
// if (!ctx) ctx = { paging }
|
|
741
|
+
// else ctx.paging = paging
|
|
742
|
+
if (result.body && typeof result.body === 'object') {
|
|
743
|
+
if (result.body['@odata.nextLink']) { // {"@odata.nextLink": "https://graph.microsoft.com/beta/users?$top=100&$skiptoken=xxx"}
|
|
744
|
+
if (!ctx) ctx = {}
|
|
745
|
+
if (!ctx.paging) ctx.paging = {}
|
|
746
|
+
const nextLinkBase = decodeURIComponent(result.body['@odata.nextLink'].substring(0, result.body['@odata.nextLink'].indexOf('$skiptoken') - 1))
|
|
747
|
+
const count = result.body['@odata.count']
|
|
748
|
+
if (count !== undefined) {
|
|
749
|
+
ctx.paging.totalResults = count
|
|
750
|
+
}
|
|
751
|
+
let totalResults = ctx.paging.totalResults
|
|
752
|
+
if (!totalResults) totalResults = (this._serviceClient[baseEntity].nextLink[nextLinkBase]?.totalResults)
|
|
753
|
+
let isCount = this._serviceClient[baseEntity].nextLink[nextLinkBase]?.isCount || count !== undefined
|
|
754
|
+
const itemsPerPage = result.body.value.length
|
|
755
|
+
this._serviceClient[baseEntity].nextLink[nextLinkBase] = {}
|
|
756
|
+
this._serviceClient[baseEntity].nextLink[nextLinkBase]['startIndex'] = ctx.paging.startIndex ? ctx.paging.startIndex + itemsPerPage : itemsPerPage + 1
|
|
757
|
+
this._serviceClient[baseEntity].nextLink[nextLinkBase]['@odata.nextLink'] = result.body['@odata.nextLink']
|
|
758
|
+
this._serviceClient[baseEntity].nextLink[nextLinkBase]['isCount'] = isCount
|
|
759
|
+
if (isCount) {
|
|
760
|
+
this._serviceClient[baseEntity].nextLink[nextLinkBase]['totalResults'] = totalResults // count=true ignored when using nextLink
|
|
761
|
+
ctx.paging.totalResults = totalResults
|
|
762
|
+
} else {
|
|
763
|
+
const totalResults = ctx.paging.startIndex - 1 + (itemsPerPage * 2) // ensure new client paging
|
|
764
|
+
this._serviceClient[baseEntity].nextLink[nextLinkBase]['totalResults'] = totalResults
|
|
765
|
+
ctx.paging.totalResults = totalResults
|
|
766
|
+
}
|
|
767
|
+
} else { // no more paging
|
|
768
|
+
const linkBase = decodeURIComponent(url.substring(0, url.indexOf('$skiptoken') - 1))
|
|
769
|
+
if (ctx?.paging?.startIndex && ctx.paging.startIndex > 1 && this._serviceClient[baseEntity]?.nextLink[linkBase]) {
|
|
770
|
+
if (!this._serviceClient[baseEntity]?.nextLink[linkBase].isCount) { // final no count page
|
|
771
|
+
const itemsPerPage = result.body.value.length
|
|
772
|
+
const totalResults = ctx.paging.startIndex - 1 + itemsPerPage
|
|
773
|
+
this._serviceClient[baseEntity].nextLink[linkBase]['totalResults'] = totalResults
|
|
774
|
+
ctx.paging.totalResults = totalResults
|
|
775
|
+
}
|
|
722
776
|
}
|
|
723
777
|
}
|
|
724
|
-
const a = result.body['@odata.nextLink'].split('top=')
|
|
725
|
-
let top = '0'
|
|
726
|
-
if (a.length > 1) {
|
|
727
|
-
top = a[1].split('&')[0]
|
|
728
|
-
}
|
|
729
|
-
if (!startIndexNext) startIndexNext = (Number(top) + 1).toString()
|
|
730
|
-
else startIndexNext = (Number(startIndexNext) + Number(top) + 1).toString()
|
|
731
|
-
// reset and set new nextLink
|
|
732
|
-
this._serviceClient[baseEntity].nextLink[objType] = {}
|
|
733
|
-
this._serviceClient[baseEntity].nextLink[objType][startIndexNext] = nextUrl
|
|
734
778
|
}
|
|
779
|
+
|
|
735
780
|
return result
|
|
736
781
|
} finally {
|
|
737
782
|
clearTimeout(timeout)
|
|
@@ -976,36 +1021,6 @@ export class HelperRest {
|
|
|
976
1021
|
return await this.doRequestHandler(baseEntity, method, path, body, ctx, opt)
|
|
977
1022
|
}
|
|
978
1023
|
|
|
979
|
-
/**
|
|
980
|
-
* nextLinkPaging returns paging url when using OData e.g., Entra ID
|
|
981
|
-
* @param baseEntity baseEntity
|
|
982
|
-
* @param objType e.g., 'users' or 'groups', a type that corresponds with what's being used by endpoint url request
|
|
983
|
-
* @param startIndex SCIM startIndex paramenter
|
|
984
|
-
* @returns paging url to be used
|
|
985
|
-
**/
|
|
986
|
-
public nextLinkPaging(baseEntity: string, objType: string, startIndex: number) {
|
|
987
|
-
objType = objType.toLowerCase() // users or groups
|
|
988
|
-
let nextPath = ''
|
|
989
|
-
if (!startIndex || !this._serviceClient[baseEntity]) return ''
|
|
990
|
-
if (startIndex < 2) {
|
|
991
|
-
if (this._serviceClient[baseEntity].nextLink[objType]) {
|
|
992
|
-
this._serviceClient[baseEntity].nextLink[objType] = null
|
|
993
|
-
}
|
|
994
|
-
return ''
|
|
995
|
-
}
|
|
996
|
-
if (this._serviceClient[baseEntity].nextLink[objType]) {
|
|
997
|
-
if (this._serviceClient[baseEntity].nextLink[objType][startIndex]) {
|
|
998
|
-
nextPath = `/users?${this._serviceClient[baseEntity].nextLink[objType][startIndex]}`
|
|
999
|
-
} else {
|
|
1000
|
-
this._serviceClient[baseEntity].nextLink[objType] = null
|
|
1001
|
-
return ''
|
|
1002
|
-
}
|
|
1003
|
-
} else {
|
|
1004
|
-
return ''
|
|
1005
|
-
}
|
|
1006
|
-
return nextPath
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
1024
|
/**
|
|
1010
1025
|
* getGraphUrl returns Microsoft Graph API url used for Entra ID
|
|
1011
1026
|
* @returns Microsoft Graph API url
|
package/lib/plugin-entra-id.ts
CHANGED
|
@@ -101,9 +101,17 @@ if (config.map) { // having licensDetails map here instead of config file
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
const userAttributes: string[] = []
|
|
104
|
-
for (const key in config.map.user) { // userAttributes = ['country', 'preferredLanguage', 'mail', 'city', 'displayName', 'postalCode', 'jobTitle', 'businessPhones', 'onPremisesSyncEnabled', 'officeLocation', 'name.givenName', 'passwordPolicies', 'id', 'state', 'department', 'mailNickname', 'manager.managerId', 'active', 'userName', 'name.familyName', 'proxyAddresses.value', 'servicePlan.value', 'mobilePhone', 'streetAddress', 'onPremisesImmutableId', 'userType', 'usageLocation']
|
|
104
|
+
for (const key in config.map.user) { // userAttributes = ['id', 'country', 'preferredLanguage', 'mail', 'city', 'displayName', 'postalCode', 'jobTitle', 'businessPhones', 'onPremisesSyncEnabled', 'officeLocation', 'name.givenName', 'passwordPolicies', 'id', 'state', 'department', 'mailNickname', 'manager.managerId', 'active', 'userName', 'name.familyName', 'proxyAddresses.value', 'servicePlan.value', 'mobilePhone', 'streetAddress', 'onPremisesImmutableId', 'userType', 'usageLocation']
|
|
105
105
|
if (config.map.user[key].mapTo) userAttributes.push(config.map.user[key].mapTo)
|
|
106
106
|
}
|
|
107
|
+
if (!userAttributes.includes('id')) userAttributes.push('id')
|
|
108
|
+
|
|
109
|
+
const groupAttributes: string[] = []
|
|
110
|
+
for (const key in config.map.group) { // groupAttributes = ['id', 'displayName', 'securityEnabled', 'mailEnabled']
|
|
111
|
+
if (config.map.group[key].mapTo) groupAttributes.push(config.map.group[key].mapTo)
|
|
112
|
+
}
|
|
113
|
+
if (!groupAttributes.includes('id')) groupAttributes.push('id')
|
|
114
|
+
if (!groupAttributes.includes('members.value')) groupAttributes.push('members.value')
|
|
107
115
|
|
|
108
116
|
// =================================================
|
|
109
117
|
// getUsers
|
|
@@ -150,18 +158,17 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
|
|
|
150
158
|
throw new Error(`${action} error: not supporting advanced filtering: ${getObj.rawFilter}`)
|
|
151
159
|
} else {
|
|
152
160
|
// mandatory - no filtering (!getObj.operator && !getObj.rawFilter) - all users to be returned - correspond to exploreUsers() in versions < 4.x.x
|
|
153
|
-
|
|
154
|
-
path = helper.nextLinkPaging(baseEntity, 'users', getObj.startIndex)
|
|
155
|
-
if (!path) return ret
|
|
156
|
-
} else {
|
|
157
|
-
getObj.count = (!getObj.count || getObj.count > 999) ? 999 : getObj.count
|
|
158
|
-
path = `/users?$top=${getObj.count}` // paging not supported using filter (Entra ID default page=100, max=999)
|
|
159
|
-
}
|
|
161
|
+
path = `/users?$top=${getObj.count}&$count=true`
|
|
160
162
|
}
|
|
161
163
|
// mandatory if-else logic - end
|
|
162
164
|
|
|
163
165
|
if (!path) throw new Error(`${action} error: mandatory if-else logic not fully implemented`)
|
|
164
166
|
|
|
167
|
+
// enable doRequest() OData paging support
|
|
168
|
+
let paging = { startIndex: getObj.startIndex }
|
|
169
|
+
if (!ctx) ctx = { paging }
|
|
170
|
+
else ctx.paging = paging
|
|
171
|
+
|
|
165
172
|
try {
|
|
166
173
|
let response: any
|
|
167
174
|
if (path === 'getUser') { // special
|
|
@@ -176,10 +183,16 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
|
|
|
176
183
|
const [scimObj] = scimgateway.endpointMapper('inbound', response.body.value[i], config.map.user) // endpoint => SCIM/CustomSCIM attribute standard
|
|
177
184
|
if (scimObj && typeof scimObj === 'object' && Object.keys(scimObj).length > 0) ret.Resources.push(scimObj)
|
|
178
185
|
}
|
|
179
|
-
|
|
186
|
+
|
|
187
|
+
if (getObj.startIndex !== ctx.paging.startIndex) { // changed by doRequest()
|
|
188
|
+
ret.startIndex = ctx.paging.startIndex
|
|
189
|
+
}
|
|
190
|
+
if (ctx.paging.totalResults) ret.totalResults = ctx.paging.totalResults // set by doRequest()
|
|
180
191
|
else ret.totalResults = getObj.startIndex ? getObj.startIndex - 1 + response.body.value.length : response.body.value.length
|
|
192
|
+
|
|
181
193
|
return (ret)
|
|
182
194
|
} catch (err: any) {
|
|
195
|
+
if (err.message.includes('Request_ResourceNotFound')) return { Resources: [] }
|
|
183
196
|
throw new Error(`${action} error: ${err.message}`)
|
|
184
197
|
}
|
|
185
198
|
}
|
|
@@ -329,9 +342,7 @@ scimgateway.getGroups = async (baseEntity, getObj, attributes, ctx) => {
|
|
|
329
342
|
totalResults: null,
|
|
330
343
|
}
|
|
331
344
|
|
|
332
|
-
if (attributes.length
|
|
333
|
-
if (!attributes.includes('id')) attributes.push('id')
|
|
334
|
-
|
|
345
|
+
if (attributes.length === 0) attributes = groupAttributes
|
|
335
346
|
let includeMembers = false
|
|
336
347
|
if (attributes.includes('members.value') || attributes.includes('members')) {
|
|
337
348
|
includeMembers = true
|
|
@@ -356,9 +367,7 @@ scimgateway.getGroups = async (baseEntity, getObj, attributes, ctx) => {
|
|
|
356
367
|
} else if (getObj.operator === 'eq' && getObj.attribute === 'members.value') {
|
|
357
368
|
// mandatory - return all groups the user 'id' (getObj.value) is member of - correspond to getGroupMembers() in versions < 4.x.x
|
|
358
369
|
// Resources = [{ id: <id-group>> , displayName: <displayName-group>, members [{value: <id-user>}] }]
|
|
359
|
-
|
|
360
|
-
// path = `/users/${getObj.value}/memberOf/microsoft.graph.group?$select=id,displayName&$expand=members($select=id,displayName)`
|
|
361
|
-
path = `/users/${getObj.value}/memberOf/microsoft.graph.group?$select=id,displayName`
|
|
370
|
+
path = `/users/${getObj.value}/memberOf/microsoft.graph.group?$top=${getObj.count}&$count=true&select=id,displayName`
|
|
362
371
|
} else {
|
|
363
372
|
// optional - simpel filtering
|
|
364
373
|
throw new Error(`${action} error: not supporting simpel filtering: ${getObj.rawFilter}`)
|
|
@@ -368,16 +377,18 @@ scimgateway.getGroups = async (baseEntity, getObj, attributes, ctx) => {
|
|
|
368
377
|
throw new Error(`${action} error: not supporting advanced filtering: ${getObj.rawFilter}`)
|
|
369
378
|
} else {
|
|
370
379
|
// mandatory - no filtering (!getObj.operator && !getObj.rawFilter) - all groups to be returned - correspond to exploreGroups() in versions < 4.x.x
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
getObj.count = 999
|
|
374
|
-
if (includeMembers) path = `/groups?$top=${getObj.count}&$select=${attrs.join()}&$expand=members($select=id,displayName)`
|
|
375
|
-
else path = `/groups?$top=${getObj.count}&$select=${attrs.join()}`
|
|
380
|
+
if (includeMembers) path = `/groups?$top=${getObj.count}&$count=true&$select=${attrs.join()}&$expand=members($select=id,displayName)`
|
|
381
|
+
else path = `/groups?$top=${getObj.count}&$count=true&$select=${attrs.join()}`
|
|
376
382
|
}
|
|
377
383
|
// mandatory if-else logic - end
|
|
378
384
|
|
|
379
385
|
if (!path) throw new Error(`${action} error: mandatory if-else logic not fully implemented`)
|
|
380
386
|
|
|
387
|
+
// enable doRequest() OData paging support
|
|
388
|
+
let paging = { startIndex: getObj.startIndex }
|
|
389
|
+
if (!ctx) ctx = { paging }
|
|
390
|
+
else ctx.paging = paging
|
|
391
|
+
|
|
381
392
|
try {
|
|
382
393
|
let response = await helper.doRequest(baseEntity, method, path, body, ctx)
|
|
383
394
|
if (!response.body) {
|
|
@@ -411,12 +422,15 @@ scimgateway.getGroups = async (baseEntity, getObj, attributes, ctx) => {
|
|
|
411
422
|
}
|
|
412
423
|
}
|
|
413
424
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
425
|
+
if (getObj.startIndex !== ctx.paging.startIndex) { // changed by doRequest()
|
|
426
|
+
ret.startIndex = ctx.paging.startIndex
|
|
427
|
+
}
|
|
428
|
+
if (ctx.paging.totalResults) ret.totalResults = ctx.paging.totalResults // set by doRequest()
|
|
429
|
+
else ret.totalResults = getObj.startIndex ? getObj.startIndex - 1 + response.body.value.length : response.body.value.length
|
|
417
430
|
|
|
418
431
|
return (ret)
|
|
419
432
|
} catch (err: any) {
|
|
433
|
+
if (err.message.includes('Request_ResourceNotFound')) return { Resources: [] }
|
|
420
434
|
throw new Error(`${action} error: ${err.message}`)
|
|
421
435
|
}
|
|
422
436
|
}
|
|
@@ -427,6 +441,7 @@ scimgateway.getGroups = async (baseEntity, getObj, attributes, ctx) => {
|
|
|
427
441
|
scimgateway.createGroup = async (baseEntity, groupObj, ctx) => {
|
|
428
442
|
const action = 'createGroup'
|
|
429
443
|
scimgateway.logDebug(baseEntity, `handling ${action} groupObj=${JSON.stringify(groupObj)} passThrough=${ctx ? 'true' : 'false'}`)
|
|
444
|
+
|
|
430
445
|
const body: any = { displayName: groupObj.displayName }
|
|
431
446
|
body.mailNickName = groupObj.displayName
|
|
432
447
|
body.mailEnabled = false
|
|
@@ -454,7 +469,12 @@ scimgateway.createGroup = async (baseEntity, groupObj, ctx) => {
|
|
|
454
469
|
scimgateway.deleteGroup = async (baseEntity, id, ctx) => {
|
|
455
470
|
const action = 'deleteGroup'
|
|
456
471
|
scimgateway.logDebug(baseEntity, `handling ${action} id=${id} passThrough=${ctx ? 'true' : 'false'}`)
|
|
457
|
-
|
|
472
|
+
|
|
473
|
+
const method = 'DELETE'
|
|
474
|
+
const path = `/groups/${id}`
|
|
475
|
+
const body = null
|
|
476
|
+
|
|
477
|
+
await helper.doRequest(baseEntity, method, path, body, ctx)
|
|
458
478
|
}
|
|
459
479
|
|
|
460
480
|
// =================================================
|
package/lib/plugin-scim.ts
CHANGED
|
@@ -87,10 +87,10 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
|
|
|
87
87
|
else if (typeof (response.body) === 'object' && Object.keys(response.body).length > 0) responseArr = [response.body]
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
// no paging support
|
|
91
|
+
if (!getObj.startIndex) getObj.startIndex = 1
|
|
92
|
+
if (!getObj.count) getObj.count = responseArr.length
|
|
93
|
+
if (getObj.count > responseArr.length) getObj.count = responseArr.length
|
|
94
94
|
|
|
95
95
|
for (let i = 0; i < responseArr.length && (i + 1 - getObj.startIndex) < getObj.count; ++i) {
|
|
96
96
|
const userObj: any = responseArr[i]
|
|
@@ -340,10 +340,10 @@ scimgateway.getGroups = async (baseEntity, getObj, attributes, ctx) => {
|
|
|
340
340
|
else if (typeof (response.body) === 'object' && Object.keys(response.body).length > 0) responseArr = [response.body]
|
|
341
341
|
}
|
|
342
342
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
343
|
+
// no paging support
|
|
344
|
+
if (!getObj.startIndex) getObj.startIndex = 1
|
|
345
|
+
if (!getObj.count) getObj.count = responseArr.length
|
|
346
|
+
if (getObj.count > responseArr.length) getObj.count = responseArr.length
|
|
347
347
|
|
|
348
348
|
for (let i = 0; i < responseArr.length && (i + 1 - getObj.startIndex) < getObj.count; ++i) {
|
|
349
349
|
const groupObj = responseArr[i]
|
package/lib/scimgateway.ts
CHANGED
|
@@ -854,7 +854,7 @@ export class ScimGateway {
|
|
|
854
854
|
const getHandlerSchemas = async (ctx: Context) => {
|
|
855
855
|
let tx = this.scimDef.Schemas
|
|
856
856
|
tx = utilsScim.addResources(tx, undefined, undefined, undefined)
|
|
857
|
-
tx = utilsScim.
|
|
857
|
+
tx = utilsScim.addSchemasStripAttr(tx, isScimv2)
|
|
858
858
|
ctx.response.body = JSON.stringify(tx)
|
|
859
859
|
}
|
|
860
860
|
funcHandler.getHandlerSchemas = getHandlerSchemas
|
|
@@ -1208,7 +1208,7 @@ export class ScimGateway {
|
|
|
1208
1208
|
}
|
|
1209
1209
|
|
|
1210
1210
|
scimdata = utils.stripObj(obj, ctx.query.attributes, ctx.query.excludedAttributes)
|
|
1211
|
-
scimdata = utilsScim.
|
|
1211
|
+
scimdata = utilsScim.addSchemasStripAttr(scimdata, isScimv2, handle.description)
|
|
1212
1212
|
|
|
1213
1213
|
if (!this.config.scimgateway.scim.skipMetaLocation) {
|
|
1214
1214
|
const location = ctx.origin + ctx.path
|
|
@@ -1365,10 +1365,8 @@ export class ScimGateway {
|
|
|
1365
1365
|
if (getObj.operator === 'eq' && ['id', 'userName', 'externalId', 'displayName', 'members.value'].includes(getObj.attribute)) info = ` ${getObj.attribute}=${getObj.value}`
|
|
1366
1366
|
logger.debug(`${gwName} [Get ${handle.description}s]${info}`, { baseEntity: ctx?.routeObj?.baseEntity })
|
|
1367
1367
|
try {
|
|
1368
|
-
getObj.startIndex = ctx.query.startIndex ? parseInt(ctx.query.startIndex) :
|
|
1369
|
-
getObj.count = ctx.query.count ? parseInt(ctx.query.count) :
|
|
1370
|
-
if (getObj.startIndex && !getObj.count) getObj.count = 200 // defaults to 200 (plugin may override)
|
|
1371
|
-
if (getObj.count && !getObj.startIndex) getObj.startIndex = 1
|
|
1368
|
+
getObj.startIndex = ctx.query.startIndex ? parseInt(ctx.query.startIndex, 10) : 1
|
|
1369
|
+
getObj.count = ctx.query.count ? parseInt(ctx.query.count, 10) : 200 // defaults to 200 (plugin may override)
|
|
1372
1370
|
|
|
1373
1371
|
let res: any
|
|
1374
1372
|
const obj: any = utils.copyObj(getObj)
|
|
@@ -1435,16 +1433,17 @@ export class ScimGateway {
|
|
|
1435
1433
|
}
|
|
1436
1434
|
}
|
|
1437
1435
|
}
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1436
|
+
|
|
1437
|
+
let location: string | undefined = ctx.origin + ctx.path
|
|
1438
|
+
if (this.config.scimgateway.scim.skipMetaLocation) location = undefined
|
|
1439
|
+
else if (ctx.query.excludedAttributes && ctx.query.excludedAttributes.includes('meta')) location = undefined
|
|
1440
|
+
|
|
1441
|
+
let scimdata = utilsScim.addResources(res, ctx.query.startIndex, ctx.query.sortBy, ctx.query.sortOrder)
|
|
1442
|
+
scimdata = utilsScim.addSchemasStripAttr(scimdata, isScimv2, handle.description, ctx.query.attributes, ctx.query.excludedAttributes, location)
|
|
1443
|
+
if (getObj.count === 0) {
|
|
1444
|
+
scimdata.Resources = []
|
|
1445
|
+
scimdata.itemsPerPage = 0
|
|
1446
|
+
// keep totalResults
|
|
1448
1447
|
}
|
|
1449
1448
|
|
|
1450
1449
|
if (scimdata.Resources.length === 1) {
|
|
@@ -1454,16 +1453,6 @@ export class ScimGateway {
|
|
|
1454
1453
|
else if (obj.displayName) ctx.target = obj.displayName
|
|
1455
1454
|
}
|
|
1456
1455
|
|
|
1457
|
-
let location: string | undefined = ctx.origin + ctx.path
|
|
1458
|
-
if (this.config.scimgateway.scim.skipMetaLocation) location = undefined
|
|
1459
|
-
else if (ctx.query.excludedAttributes && ctx.query.excludedAttributes.includes('meta')) location = undefined
|
|
1460
|
-
for (let i = 0; i < scimdata.Resources.length; i++) {
|
|
1461
|
-
utils.getEtag(scimdata.Resources[i])
|
|
1462
|
-
scimdata.Resources[i] = utils.stripObj(scimdata.Resources[i], ctx.query.attributes, ctx.query.excludedAttributes)
|
|
1463
|
-
}
|
|
1464
|
-
scimdata = utilsScim.addResources(scimdata, ctx.query.startIndex, ctx.query.sortBy, ctx.query.sortOrder)
|
|
1465
|
-
scimdata = utilsScim.addSchemas(scimdata, isScimv2, handle.description, location)
|
|
1466
|
-
|
|
1467
1456
|
ctx.response.body = JSON.stringify(scimdata)
|
|
1468
1457
|
} catch (err: any) {
|
|
1469
1458
|
if (isScimv2) ctx.response.status = 400
|
|
@@ -1593,7 +1582,7 @@ export class ScimGateway {
|
|
|
1593
1582
|
if (!jsonBody.meta) jsonBody.meta = {}
|
|
1594
1583
|
jsonBody.meta.location = location
|
|
1595
1584
|
}
|
|
1596
|
-
jsonBody = utilsScim.
|
|
1585
|
+
jsonBody = utilsScim.addSchemasStripAttr(jsonBody, isScimv2, handle.description)
|
|
1597
1586
|
if (eTag) ctx.response.headers.set('ETag', eTag)
|
|
1598
1587
|
if (jsonBody?.meta?.location) ctx.response.headers.set('Location', jsonBody.meta.location)
|
|
1599
1588
|
ctx.response.status = 201
|
|
@@ -1731,7 +1720,7 @@ export class ScimGateway {
|
|
|
1731
1720
|
}
|
|
1732
1721
|
|
|
1733
1722
|
scimres = utils.stripObj(userObj, ctx.query.attributes, ctx.query.excludedAttributes)
|
|
1734
|
-
scimres = utilsScim.
|
|
1723
|
+
scimres = utilsScim.addSchemasStripAttr(scimres, isScimv2, handle.description)
|
|
1735
1724
|
if (eTag) ctx.response.headers.set('ETag', eTag)
|
|
1736
1725
|
if (scimres?.meta?.location) ctx.response.headers.set('Location', scimres.meta.location)
|
|
1737
1726
|
ctx.response.status = 200
|
|
@@ -2560,23 +2549,34 @@ export class ScimGateway {
|
|
|
2560
2549
|
if (typeof (this as any)[handler.groups.getMethod] !== 'function') return groups // method not implemented
|
|
2561
2550
|
if (this.config.scimgateway.scim.groupMemberOfUser) return groups // only support user member of group
|
|
2562
2551
|
let res: any
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2552
|
+
const ob: Record<string, any> = { attribute: 'members.value', operator: 'eq', value: decodeURIComponent(id) }
|
|
2553
|
+
const attributes = ['id', 'displayName']
|
|
2554
|
+
const count = 200
|
|
2555
|
+
let startIndex = 1
|
|
2556
|
+
let nextStartIndex = 1
|
|
2557
|
+
do {
|
|
2558
|
+
try {
|
|
2559
|
+
logger.debug(`${gwName} calling ${handler.groups.getMethod} - groups to be included`, { baseEntity })
|
|
2560
|
+
startIndex = nextStartIndex
|
|
2561
|
+
ob.startIndex = startIndex
|
|
2562
|
+
ob.count = count
|
|
2563
|
+
res = await (this as any)[handler.groups.getMethod](baseEntity, ob, attributes, ctxPassThrough)
|
|
2564
|
+
} catch (err) { void 0 }
|
|
2565
|
+
if (res && res.Resources) {
|
|
2566
|
+
if (Array.isArray(res.Resources) && res.Resources.length > 0) {
|
|
2567
|
+
for (let i = 0; i < res.Resources.length; i++) {
|
|
2568
|
+
if (!res.Resources[i].id) continue
|
|
2569
|
+
const el: any = {}
|
|
2570
|
+
el.value = res.Resources[i].id
|
|
2571
|
+
if (res.Resources[i].displayName) el.display = res.Resources[i].displayName
|
|
2572
|
+
if (isScimv2) el.type = 'direct'
|
|
2573
|
+
else el.type = { value: 'direct' }
|
|
2574
|
+
groups.push(el) // { "value": "Admins", "display": "Admins", "type": "direct"}
|
|
2575
|
+
}
|
|
2576
|
+
nextStartIndex = utilsScim.getNextStartIndex(res.totalResults, startIndex, res.Resources.length)
|
|
2577
|
+
}
|
|
2578
2578
|
}
|
|
2579
|
-
}
|
|
2579
|
+
} while (nextStartIndex > startIndex)
|
|
2580
2580
|
return groups
|
|
2581
2581
|
}
|
|
2582
2582
|
this.getMemberOf = getMemberOf
|
package/lib/utils-scim.ts
CHANGED
|
@@ -515,8 +515,7 @@ export function endpointMapper(direction: string, parseObj: any, mapObj: any) {
|
|
|
515
515
|
const keyRoot = key2.split('.').slice(0, -1).join('.') // xx.yy.mapTo => xx.yy
|
|
516
516
|
if (dotMap[`${keyRoot}.type`] === 'array' && arrIndex >= 0) {
|
|
517
517
|
dotNewObj[`${keyRoot}.${arrIndex}`] = dotParse[keyOrg] // servicePlan.0.value => servicePlan.0 and groups[0].value => memberOf.0
|
|
518
|
-
}
|
|
519
|
-
dotNewObj[keyRoot] = dotParse[key] // {"accountEnabled": {"mapTo": "active"} => str.replace("accountEnabled", "active")
|
|
518
|
+
} else dotNewObj[keyRoot] = dotParse[key] // {"accountEnabled": {"mapTo": "active"} => str.replace("accountEnabled", "active")
|
|
520
519
|
break
|
|
521
520
|
}
|
|
522
521
|
}
|
|
@@ -768,14 +767,25 @@ export function addResources(data: any, startIndex?: string, sortBy?: string, so
|
|
|
768
767
|
if (Array.isArray(data)) res.Resources = data
|
|
769
768
|
else if (data.Resources) {
|
|
770
769
|
res.Resources = data.Resources
|
|
771
|
-
res.totalResults = data.totalResults
|
|
772
770
|
} else res.Resources.push(data)
|
|
773
771
|
|
|
772
|
+
if (Object.hasOwn(data, 'totalResults')) res.totalResults = data.totalResults
|
|
773
|
+
if (data.startIndex) {
|
|
774
|
+
res.startIndex = data.startIndex
|
|
775
|
+
} else if (startIndex) {
|
|
776
|
+
res.startIndex = parseInt(startIndex, 10)
|
|
777
|
+
} else {
|
|
778
|
+
res.startIndex = 1
|
|
779
|
+
}
|
|
780
|
+
|
|
774
781
|
// pagination
|
|
775
782
|
if (!res.totalResults) res.totalResults = res.Resources.length // Specifies the total number of results matching the Consumer query
|
|
776
783
|
res.itemsPerPage = res.Resources.length // Specifies the number of search results returned in a query response page
|
|
777
|
-
|
|
778
|
-
|
|
784
|
+
|
|
785
|
+
if (!res.startIndex || isNaN(res.startIndex)) {
|
|
786
|
+
if (startIndex) res.startIndex = parseInt(startIndex) // The 1-based index of the first result in the current set of search results
|
|
787
|
+
else res.startIndex = 1
|
|
788
|
+
}
|
|
779
789
|
if (res.startIndex > res.totalResults) { // invalid paging request
|
|
780
790
|
res.Resources = []
|
|
781
791
|
res.itemsPerPage = 0
|
|
@@ -785,7 +795,7 @@ export function addResources(data: any, startIndex?: string, sortBy?: string, so
|
|
|
785
795
|
return res
|
|
786
796
|
}
|
|
787
797
|
|
|
788
|
-
export function
|
|
798
|
+
export function addSchemasStripAttr(data: Record<string, any>, isScimv2: boolean, type?: string, attributes?: string, excludedAttributes?: string, location?: string) {
|
|
789
799
|
if (!type) {
|
|
790
800
|
if (isScimv2) data.schemas = ['urn:ietf:params:scim:api:messages:2.0:ListResponse']
|
|
791
801
|
else data.schemas = ['urn:scim:schemas:core:1.0']
|
|
@@ -796,6 +806,9 @@ export function addSchemas(data: Record<string, any>, isScimv2: boolean, type?:
|
|
|
796
806
|
if (isScimv2) data.schemas = ['urn:ietf:params:scim:api:messages:2.0:ListResponse']
|
|
797
807
|
else data.schemas = ['urn:scim:schemas:core:1.0']
|
|
798
808
|
for (let i = 0; i < data.Resources.length; i++) {
|
|
809
|
+
utils.getEtag(data.Resources[i])
|
|
810
|
+
data.Resources[i] = utils.stripObj(data.Resources[i], attributes, excludedAttributes)
|
|
811
|
+
|
|
799
812
|
if (isScimv2) { // scim v2 add schemas/resourceType on each element
|
|
800
813
|
if (type === 'User') {
|
|
801
814
|
const val = 'urn:ietf:params:scim:schemas:core:2.0:User'
|
|
@@ -1062,3 +1075,14 @@ export function loadScimDef(version: ScimVersion, customPath?: string): any {
|
|
|
1062
1075
|
}
|
|
1063
1076
|
return v === '1.1' ? scimdefV1Default : scimdefV2Default
|
|
1064
1077
|
}
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* returns next pagination startIndex
|
|
1081
|
+
* pagination should stop when nextStartIndex <= startIndex
|
|
1082
|
+
*/
|
|
1083
|
+
export function getNextStartIndex(totalResults: number, startIndex: number, itemsPerPage: number): number {
|
|
1084
|
+
if (totalResults === undefined || startIndex == undefined || itemsPerPage === undefined) return startIndex
|
|
1085
|
+
const nextStartIndex = startIndex + itemsPerPage
|
|
1086
|
+
if (nextStartIndex >= totalResults) return startIndex
|
|
1087
|
+
return nextStartIndex
|
|
1088
|
+
}
|
package/lib/utils.ts
CHANGED
package/package.json
CHANGED