@keepsolutions/scimgateway
Version:
Using SCIM protocol as a gateway for user provisioning to other endpoints
1,072 lines (988 loc) • 55.9 kB
JavaScript
// =====================================================================================================================
// File: plugin-azure-ad.js
//
// Author: Jarle Elshaug
//
// Purpose: Azure AD provisioning including licenses e.g. O365
//
// Prereq: Azure AD configuration:
// Application key defined (clientsecret)
// plugin-azure-ad.json configured with corresponding clientid and clientsecret
// Application permission "Windows Azure Active Directory" - all "Application Permissions"
// Application must be member of "User Account Administrator" (powershell import-Module MSOnline)
//
// Notes: For CA Provisioning - Use ConnectorXpress, import metafile
// "node_modules\scimgateway\resources\Azure - ScimGateway.xml" for creating endpoint
//
// Using "Custom SCIM" attributes defined in scimgateway.endpointMap
// Some functionality will also work using standard SCIM
// You could also use your own version of endpointMap
//
// /User SCIM (custom) Endpoint (AAD)
// --------------------------------------------------------------------------------------------
// User Principal Name userName userPrincipalName
// Id id id
// Suspended active accountEnabled
// Password passwordProfile.password passwordProfile.password
// First Name name.givenName givenName
// Last Name name.familyName surname
// Fullname displayName displayName
// E-mail mail mail
// Mobile Number mobilePhone mobilePhone
// Phone Number businessPhones businessPhones
// Manager Id manager.managerId manager
// City city city
// Country country country
// Department department department
// Job Title jobTitle jobTitle
// Postal Code postalCode postalCode
// State or Locality state state
// Street Address streetAddress streetAddress
// Mail Nick Name mailNickname mailNickname
// Force Change Password Next Login passwordProfile.forceChangePasswordNextSignIn passwordProfile.forceChangePasswordNextSignIn
// onPremises Immutable ID onPremisesImmutableId onPremisesImmutableId
// onPremises Synchronization Enabled onPremisesSyncEnabled onPremisesSyncEnabled
// User Type userType userType
// Password Policies passwordPolicies passwordPolicies
// Preferred Language preferredLanguage preferredLanguage
// Usage Location usageLocation usageLocation
// Office Location officeLocation officeLocation
// Proxy Addresses proxyAddresses.value proxyAddresses
// License servicePlan.value servicePlan
// Groups groups - virtual readOnly N/A
//
// /Group SCIM (custom) Endpoint (AAD)
// --------------------------------------------------------------------------------------------
// Name displayName displayName
// Id id id
// Members members members
//
// /servicePlan SCIM (custom) Endpoint (AAD)
// --------------------------------------------------------------------------------------------
// Service Plan Name servicePlanName servicePlanName
// SKU ID skuId skuId
// SKU Part Number skuPartNumber skuPartNumber
//
// =====================================================================================================================
'use strict'
const http = require('http')
const https = require('https')
const HttpsProxyAgent = require('https-proxy-agent')
const URL = require('url').URL
const querystring = require('querystring')
// mandatory plugin initialization - start
const path = require('path')
let ScimGateway = null
try {
ScimGateway = require('scimgateway')
} catch (err) {
ScimGateway = require('./scimgateway')
}
const scimgateway = new ScimGateway()
const pluginName = path.basename(__filename, '.js')
const configDir = path.join(__dirname, '..', 'config')
const configFile = path.join(`${configDir}`, `${pluginName}.json`)
let config = require(configFile).endpoint
config = scimgateway.processExtConfig(pluginName, config) // add any external config process.env and process.file
// mandatory plugin initialization - end
const graphv1 = 'https://graph.microsoft.com/v1.0'
const _serviceClient = {}
const lock = new scimgateway.Lock()
// =================================================
// exploreUsers
// =================================================
scimgateway.exploreUsers = async (baseEntity, attributes, startIndex, count) => {
const action = 'exploreUsers'
scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" attributes=${attributes} startIndex=${startIndex} count=${count}`)
const ret = { // itemsPerPage will be set by scimgateway
Resources: [],
totalResults: null
}
let arrAttr = []
if (attributes) arrAttr = attributes.split(',')
await getServiceClient(baseEntity) // because need to make sure having _serviceClient with Azure paging - @odata.nextLink
if (!_serviceClient[baseEntity].nextLink.users.skiptoken && startIndex && startIndex > 1) return (ret) // break endless fake-totalresult paging loop
if (_serviceClient[baseEntity].nextLink.users.skiptoken && startIndex && startIndex < 2) _serviceClient[baseEntity].nextLink.users.skiptoken = null // should not occure
const method = 'GET'
let path = null
if (_serviceClient[baseEntity].nextLink.users.skiptoken) { // paging
path = `/users?${_serviceClient[baseEntity].nextLink.users.skiptoken}`
} else {
path = `/users?$top=${(!count || count > 999) ? 999 : count}` // paging not supported using filter (Azure default page=100, max=999)
}
const body = null
try {
const response = await doRequest(baseEntity, method, path, body)
if (!response.body.value) {
const err = new Error(`${action}: Got empty response on request`)
throw (err)
}
for (let i = 0; i < response.body.value.length; ++i) { // only limited set of attributes - for all attributes to be included see getUser
const [scimUser] = scimgateway.endpointMapper('inbound', response.body.value[i], scimgateway.endpointMap.microsoftGraphUser) // endpoint => SCIM/CustomSCIM attribute standard
if (scimUser && typeof scimUser === 'object' && Object.keys(scimUser).length > 0) {
if (!attributes) ret.Resources.push(scimUser)
else { // include only attributes specified
const user = {}
for (let i = 0; i < arrAttr.length; i++) {
const key = arrAttr[i].split('.')[0] // title => title, name.familyName => name
if (scimUser[key]) user[key] = scimUser[key]
}
ret.Resources.push(user)
}
}
}
if (response.body['@odata.nextLink']) _serviceClient[baseEntity].nextLink.users.skiptoken = response.body['@odata.nextLink'].split('?')[1] // paging keep search query
else _serviceClient[baseEntity].nextLink.users.skiptoken = null
if (!startIndex && !count) ret.totalResults = response.body.value.length // client request without paging
else ret.totalResults = 99999999 // faking to ensure we get a new paging request - don't know the total numbers of users - metadata directoryObject collections are not countable
return (ret)
} catch (err) {
const newErr = err
throw newErr
}
}
// =================================================
// exploreGroups
// =================================================
scimgateway.exploreGroups = async (baseEntity, attributes, startIndex, count) => {
const action = 'exploreGroups'
scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" attributes=${attributes} startIndex=${startIndex} count=${count}`)
const ret = { // itemsPerPage will be set by scimgateway
Resources: [],
totalResults: null
}
let includeMembers = false
if (!attributes || attributes.includes('members')) includeMembers = true
await getServiceClient(baseEntity) // because need to make sure having _serviceClient with Azure paging - @odata.nextLink
if (!_serviceClient[baseEntity].nextLink.groups.skiptoken && startIndex && startIndex > 1) return (ret) // break endless fake-totalresult paging loop
if (_serviceClient[baseEntity].nextLink.groups.skiptoken && startIndex && startIndex < 2) _serviceClient[baseEntity].nextLink.groups.skiptoken = null // should not occure
const method = 'GET'
let path = null
if (_serviceClient[baseEntity].nextLink.groups.skiptoken) { // paging
path = `/groups?${_serviceClient[baseEntity].nextLink.groups.skiptoken}`
} else {
path = `/groups?$top=${(!count || count > 999) ? 999 : count}` // paging not supported using filter (Azure default page=100, max=999)
}
const body = null
try {
const response = await doRequest(baseEntity, method, path, body)
if (!response.body.value) {
const err = new Error(`${action}: Got empty response on request`)
throw (err)
}
for (let i = 0; i < response.body.value.length; ++i) {
const [scimGroup] = scimgateway.endpointMapper('inbound', response.body.value[i], scimgateway.endpointMap.microsoftGraphGroup) // endpoint => SCIM/CustomSCIM attribute standard
if (scimGroup && typeof scimGroup === 'object' && Object.keys(scimGroup).length > 0) {
if (includeMembers) {
const method = 'GET'
const path = `/groups/${scimGroup.id}/members?$select=id,userPrincipalName`
const body = null
const response = await doRequest(baseEntity, method, path, body)
if (!response.body.value && !Array.isArray(response.body.value)) {
const err = new Error(`${action}: Got empty response on REST request`)
throw (err)
} else { // include group members
scimGroup.members = []
response.body.value.forEach(function (el) {
scimGroup.members.push({ value: el.id, display: el.userPrincipalName })
})
}
}
ret.Resources.push(scimGroup)
}
}
if (response.body['@odata.nextLink']) _serviceClient[baseEntity].nextLink.groups.skiptoken = response.body['@odata.nextLink'].split('?')[1] // paging keep search query
else _serviceClient[baseEntity].nextLink.groups.skiptoken = null
if (!startIndex && !count) ret.totalResults = response.body.value.length // client request without paging
else ret.totalResults = 99999999 // faking to ensure we get a new paging request - don't know the total numbers of groups - metadata directoryObject collections are not countable
return (ret) // all explored groups in page of result
} catch (err) {
const newErr = err
throw newErr
}
}
// =================================================
// getUser
// =================================================
scimgateway.getUser = async (baseEntity, getObj, attributes) => {
// getObj = { filter: <filterAttribute>, identifier: <identifier> }
// e.g: getObj = { filter: 'userName', identifier: 'bjensen'}
// filter: userName and id must be supported
// (they are most often considered as "the same" where identifier = UserID )
// Note, the value of id attribute returned will be used by modifyUser and deleteUser
// attributes: if not blank, attributes listed should be returned
// Should normally return all supported user attributes having id and userName as mandatory
// SCIM Gateway will automatically filter response according to the attributes list
const action = 'getUser'
scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" ${getObj.filter}=${getObj.identifier} attributes=${attributes}`)
if (getObj.filter === 'manager.managerId') {
scimgateway.logger.debug(`${pluginName}[${baseEntity}] ignoring returning all users having ${getObj.filter}=${getObj.identifier} - AAD will do manager attribute cleanup on deletion of manager`)
const arr = []
return arr
}
if (getObj.filter !== 'userName' && getObj.filter !== 'externalId' && getObj.filter !== 'id') {
throw new Error(`plugin do not support handling "${action}" ${getObj.filter}`)
}
if (!attributes) attributes = 'attributes=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'
const user = () => {
return new Promise(async (resolve, reject) => {
const [parsedAttr] = scimgateway.endpointMapper('outbound', attributes, scimgateway.endpointMap.microsoftGraphUser) // SCIM/CustomSCIM => endpoint attribute standard
const method = 'GET'
const path = `/users/${querystring.escape(getObj.identifier)}?$select=${parsedAttr}` // #EXT# need url encoding e.g myaccount_outlook.com#EXT#@mycompany.onmicrosoft.com
const body = null
try {
const response = await doRequest(baseEntity, method, path, body)
const userObj = response.body
if (!userObj) {
const err = new Error('Got empty response when retrieving data for ' + getObj.identifier)
return reject(err)
}
resolve(userObj)
} catch (err) {
return reject(err)
}
})
}
const manager = () => {
return new Promise(async (resolve, reject) => {
if (attributes.indexOf('manager.managerId') < 0) return resolve(null) // request without manager
const method = 'GET'
const path = `/users/${querystring.escape(getObj.identifier)}/manager?$select=id`
const body = null
try {
const response = await doRequest(baseEntity, method, path, body)
if (!response.body.id) {
const err = new Error('Manager id not found when retrieving manager for ' + getObj.identifier)
return reject(err)
} else resolve({ manager: response.body.id })
} catch (err) {
let statusCode
try { statusCode = JSON.parse(err.message).statusCode } catch (e) {}
if (statusCode === 404) return resolve(null) // no manager attribute set on Azure user object (doReqest not logging 404 as error)
return reject(err)
}
})
}
const license = () => {
return new Promise(async (resolve, reject) => {
if (attributes.indexOf('servicePlan.value') < 0) return resolve(null) // licenses not requested
const method = 'GET'
const path = `/users/${querystring.escape(getObj.identifier)}/licenseDetails`
const body = null
const retObj = { servicePlan: [] }
try {
const response = await doRequest(baseEntity, method, path, body)
if (!response.body.value) {
const err = new Error('No content for license information ' + getObj.identifier)
return reject(err)
} else {
if (response.body.value.length < 1) return resolve(null) // User with no licenses
for (let i = 0; i < response.body.value.length; i++) {
const skuPartNumber = response.body.value[i].skuPartNumber
for (let index = 0; index < response.body.value[i].servicePlans.length; index++) {
if (response.body.value[i].servicePlans[index].provisioningStatus === 'Success' ||
response.body.value[i].servicePlans[index].provisioningStatus === 'PendingInput') {
const servicePlan = { value: `${skuPartNumber}::${response.body.value[i].servicePlans[index].servicePlanName}` }
retObj.servicePlan.push(servicePlan)
}
}
}
}
resolve(retObj)
} catch (err) {
let statusCode
try { statusCode = JSON.parse(err.message).statusCode } catch (e) {}
if (statusCode === 404) return resolve(null) // user have no plans
return reject(err)
}
})
}
return Promise.all([user(), manager(), license()])
.then((results) => {
let retObj = {}
for (const i in results) { // merge async.parallell results to one
retObj = Object.assign(retObj, results[i])
}
const [obj] = scimgateway.endpointMapper('inbound', retObj, scimgateway.endpointMap.microsoftGraphUser) // endpoint => SCIM/CustomSCIM attribute standard
return obj
})
.catch((err) => {
if (err.message.includes('empty response')) return (null) // no user found
else throw (err)
})
}
// =================================================
// createUser
// =================================================
scimgateway.createUser = async (baseEntity, userObj) => {
const action = 'createUser'
scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" userObj=${JSON.stringify(userObj)}`)
const attrObj = {}
if (userObj.servicePlan) {
attrObj.servicePlan = userObj.servicePlan // will be included in a modifyuser
delete userObj.servicePlan
}
const method = 'POST'
const path = '/users'
const [body] = scimgateway.endpointMapper('outbound', userObj, scimgateway.endpointMap.microsoftGraphUser)
try {
await doRequest(baseEntity, method, path, body)
if (attrObj.servicePlan) {
await scimgateway.modifyUser(baseEntity, userObj.userName, attrObj)
return null
} else return (null)
} catch (err) {
const newErr = new Error(err.message)
if (newErr.message.includes('userPrincipalName already exists')) newErr.name = 'DuplicateKeyError' // gives scimgateway statuscode 409 instead of default 500
throw (newErr)
}
}
// =================================================
// deleteUser
// =================================================
scimgateway.deleteUser = async (baseEntity, id) => {
const action = 'deleteUser'
scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" id=${id}`)
const method = 'DELETE'
const path = `/Users/${id}`
const body = null
try {
await doRequest(baseEntity, method, path, body)
return (null)
} catch (err) {
const newErr = err
throw newErr
}
}
// =================================================
// modifyUser
// =================================================
scimgateway.modifyUser = async (baseEntity, id, attrObj) => {
const action = 'modifyUser'
scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" id=${id} attrObj=${JSON.stringify(attrObj)}`)
const arrLicAdd = []
const arrLicDel = []
if (Array.isArray(attrObj.servicePlan)) {
attrObj.servicePlan.forEach(function (el) {
if (el.operation && el.operation === 'delete') { // delete license { servicePlan: [ { operation: 'delete', value: 'O365_BUSINESS::OFFICE_BUSINESS' } ] }
arrLicDel.push(el.value)
} else if (el.value) { // add license { servicePlan: [ { value: 'O365_BUSINESS::OFFICE_BUSINESS' } ] }
arrLicAdd.push(el.value)
}
})
delete attrObj.servicePlan
}
const [parsedAttrObj] = scimgateway.endpointMapper('outbound', attrObj, scimgateway.endpointMap.microsoftGraphUser) // SCIM/CustomSCIM => endpoint attribute standard
if (parsedAttrObj instanceof Error) throw (parsedAttrObj) // error object
const objManager = {}
if (parsedAttrObj.manager) { // new manager
objManager.manager = JSON.parse(JSON.stringify(parsedAttrObj.manager))
delete parsedAttrObj.manager
} else if (parsedAttrObj.manager === null) { // delete manager
objManager.manager = null
delete parsedAttrObj.manager
}
const profile = () => { // patch
return new Promise(async (resolve, reject) => {
if (JSON.stringify(parsedAttrObj) === '{}') return resolve(null)
const method = 'PATCH'
const path = `/users/${id}`
try {
await doRequest(baseEntity, method, path, parsedAttrObj)
resolve(null)
} catch (err) {
return reject(err)
}
})
}
const manager = () => {
return new Promise(async (resolve, reject) => {
let method = null
let path = null
let body = null
if (objManager.manager) { // new manager
method = 'PUT'
path = `/users/${id}/manager/$ref`
body = { '@odata.id': `${graphv1}/users/${objManager.manager}` }
} else if (objManager.manager === null) { // delete manager
method = 'DELETE'
path = `/users/${id}/manager/$ref`
body = null
} else return resolve(null)
try {
await doRequest(baseEntity, method, path, body)
resolve(null)
} catch (err) {
return reject(err)
}
})
}
const license = () => {
return new Promise(async (resolve, reject) => {
if (arrLicAdd.length < 1 && arrLicDel.length < 1) return resolve(null) // no licenses to update
// currentLic
let method = 'GET'
let path = `/users/${querystring.escape(id)}/licenseDetails`
const currentLic = {}
try { // build currentLic
let response
try {
response = await doRequest(baseEntity, method, path, null)
} catch (err) {
let statusCode
try { statusCode = JSON.parse(err.message).statusCode } catch (e) {}
if (statusCode === 404) return resolve(null) // no licenseDetails
throw err
}
if (!response.body.value) {
const err = new Error('No content for license information for user with id ' + id)
return reject(err)
}
if (response.body.value.length > 0) {
for (let i = 0; i < response.body.value.length; i++) { // currentLic = {skuId: [servicePlanId]}
if (!currentLic[response.body.value[i].skuId]) currentLic[response.body.value[i].skuId] = []
for (let index = 0; index < response.body.value[i].servicePlans.length; index++) {
if (response.body.value[i].servicePlans[index].servicePlanName &&
(response.body.value[i].servicePlans[index].provisioningStatus === 'Success' ||
response.body.value[i].servicePlans[index].provisioningStatus === 'PendingInput')) {
currentLic[response.body.value[i].skuId].push(response.body.value[i].servicePlans[index].servicePlanId)
}
}
}
}
// availableLic
method = 'GET'
path = '/subscribedSkus'
const availableLic = {}
const addLic = {}
const removeLic = {}
response = await doRequest(baseEntity, method, path, null)
if (!response.body.value) {
const err = new Error(`${action}: Got empty response on REST request`)
return reject(err)
}
for (let i = 0; i < response.body.value.length; i++) { // availableLic = {skuId: [servicePlanId]}
if (!availableLic[response.body.value[i].skuId]) availableLic[response.body.value[i].skuId] = []
for (let index = 0; index < response.body.value[i].servicePlans.length; index++) {
if (response.body.value[i].servicePlans[index].servicePlanName &&
(response.body.value[i].servicePlans[index].provisioningStatus === 'Success' ||
response.body.value[i].servicePlans[index].provisioningStatus === 'PendingInput')) {
availableLic[response.body.value[i].skuId].push(response.body.value[i].servicePlans[index].servicePlanId)
}
}
// addLic/removeLic based on arrAdd/arrRemove
for (let j = 0; j < arrLicAdd.length; j++) { // add licenses
const arrAdd = arrLicAdd[j].split('::')
if (arrAdd.length !== 2) {
const err = new Error(`${action}: License/ServicePart name must be on format skuPartNumber::servicePlanName `)
return reject(err)
}
if (response.body.value[i].skuPartNumber === arrAdd[0]) { // addLic = {skuId: [servicePlanId]}
const add = response.body.value[i].servicePlans.find(function (el) {
return (el.servicePlanName === arrAdd[1])
})
if (add) {
if (!addLic[response.body.value[i].skuId]) addLic[response.body.value[i].skuId] = []
addLic[response.body.value[i].skuId].push(add.servicePlanId)
}
}
}
for (let j = 0; j < arrLicDel.length; j++) { // delete licenses
const arrDel = arrLicDel[j].split('::')
if (arrDel.length !== 2) {
const err = new Error(`${action}: License/ServicePart name must be on format skuPartNumber::servicePlanName `)
return reject(err)
}
if (response.body.value[i].skuPartNumber === arrDel[0]) {
const del = response.body.value[i].servicePlans.find(function (el) {
return (el.servicePlanName === arrDel[1])
})
if (del) {
if (!removeLic[response.body.value[i].skuId]) removeLic[response.body.value[i].skuId] = []
removeLic[response.body.value[i].skuId].push(del.servicePlanId)
}
}
}
}
// disabledPlan = availableLic - currentLic
const disabledPlans = {}
for (const key in currentLic) {
disabledPlans[key] = availableLic[key]
for (let j = 0; j < currentLic[key].length; j++) {
for (let k = 0; k < disabledPlans[key].length; k++) {
if (disabledPlans[key][k] === currentLic[key][j]) disabledPlans[key].splice(k, 1) // delete
}
}
}
// merge disablePlan with addLic/removeLic
for (const key in addLic) {
if (!disabledPlans[key]) disabledPlans[key] = availableLic[key] // disable all
for (let j = 0; j < addLic[key].length; j++) {
for (let k = 0; k < disabledPlans[key].length; k++) {
if (disabledPlans[key][k] === addLic[key][j]) disabledPlans[key].splice(k, 1) // delete
}
}
}
for (const key in removeLic) {
for (let j = 0; j < removeLic[key].length; j++) {
disabledPlans[key].push(removeLic[key][j])
}
}
// prepare for update
const lic = {
addLicenses: [],
removeLicenses: []
}
for (const key in disabledPlans) {
if (addLic[key] || removeLic[key]) lic.addLicenses.push({ skuId: key, disabledPlans: disabledPlans[key] })
}
// Update with added/removed licenses
method = 'POST'
path = `/users/${id}/assignLicense`
const body = lic
await doRequest(baseEntity, method, path, body)
resolve(null)
} catch (err) {
return reject(err)
}
})
}
return Promise.all([profile(), manager(), license()])
.then((result) => { return (null) })
.catch((err) => { throw (err) })
}
// =================================================
// getGroup
// =================================================
scimgateway.getGroup = async (baseEntity, getObj, attributes) => {
// getObj = { filter: <filterAttribute>, identifier: <identifier> }
// e.g: getObj = { filter: 'displayName', identifier: 'GroupA' }
// filter: displayName and id must be supported
// (they are most often considered as "the same" where identifier = GroupName)
// Note, the value of id attribute returned will be used by deleteGroup, getGroupMembers and modifyGroup
// attributes: if not blank, attributes listed should be returned
// Should normally return all supported group attributes having id, displayName and members as mandatory
// members may be skipped if attributes is not blank and do not contain members or members.value
const action = 'getGroup'
scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" ${getObj.filter}=${getObj.identifier} attributes=${attributes}`)
if (getObj.filter !== 'displayName' && getObj.filter !== 'id') {
throw new Error(`plugin do not support handling "${action}" ${getObj.filter}`)
}
try {
let includeMembers = false
if (!attributes || attributes.includes('members')) includeMembers = true
const [parsedAttr] = scimgateway.endpointMapper('outbound', attributes, scimgateway.endpointMap.microsoftGraphGroup) // SCIM/CustomSCIM => endpoint attribute standard
let path
if (getObj.filter === 'id') {
if (parsedAttr) path = `/groups/${getObj.identifier}?$select=${parsedAttr}`
else path = `/groups/${getObj.identifier}`
} else path = `/groups?$filter=${getObj.filter} eq '${getObj.identifier}'&$select=${parsedAttr},id`
const method = 'GET'
const body = null
const response = await doRequest(baseEntity, method, path, body)
let retObj
if (!response.body) throw new Error(`${action}: Got empty or invalid response on REST request`)
if (response.body.value) {
if (!Array.isArray(response.body.value) || response.body.value.length !== 1) {
throw new Error(`${action}: Got empty or invalid response on REST request`)
}
const obj = response.body.value[0]
const [ret] = scimgateway.endpointMapper('inbound', obj, scimgateway.endpointMap.microsoftGraphGroup) // endpoint => SCIM/CustomSCIM attribute Standard
retObj = ret
} else {
const obj = response.body
const [ret] = scimgateway.endpointMapper('inbound', obj, scimgateway.endpointMap.microsoftGraphGroup)
retObj = ret
}
if (includeMembers) {
let idGroup = retObj.id
if (getObj.filter === 'id') idGroup = getObj.identifier
const method = 'GET'
let path = `/groups/${idGroup}/members?$select=id,userPrincipalName`
const body = null
retObj.members = []
do {
const response = await doRequest(baseEntity, method, path, body)
if (!response.body.value && !Array.isArray(response.body.value)) {
const err = new Error(`${action}: Got empty response on REST request`)
throw (err)
} else { // include group members
response.body.value.forEach(function (el) {
retObj.members.push({ value: el.id, display: el.userPrincipalName })
})
}
path = null
if (response.body['@odata.nextLink']) path = `/groups/${idGroup}/members?` + response.body['@odata.nextLink'].split('?')[1] // paging keep search query
} while (path) // paging
}
return retObj
} catch (err) {
if (err.message.includes('empty or invalid response')) return (null) // no group found
else throw err
}
}
// =================================================
// getGroupMembers
// =================================================
scimgateway.getGroupMembers = async (baseEntity, id, attributes) => {
// return all groups the user is member of having attributes included e.g: members.value,id,displayName
// method used when "users member of group", if used - getUser must treat user attribute groups as virtual readOnly attribute
// "users member of group" is SCIM default and this method should normally have some logic
const action = 'getGroupMembers'
scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" user id=${id} attributes=${attributes}`)
const arrRet = []
const method = 'GET'
// const path = `/users/${id}/memberOf?$select=id,displayName` // includes all e.g. microsoft.graph.group and microsoft.graph.directoryRole
const path = `/users/${id}/memberOf/microsoft.graph.group?$select=id,displayName`
const body = null
try {
const response = await doRequest(baseEntity, method, path, body)
if (!response.body.value) {
const err = new Error(`${action}: Got empty response on REST request`)
throw (err)
}
response.body.value.forEach(function (el) {
const userGroup = {
id: el.id, // id is mandatory
displayName: el.displayName, // displayName is mandatory
members: [{ value: id }] // only includes current user
}
arrRet.push(userGroup) // { id: <id-group>> , displayName: <displayName-group>, members [{value: <id-user>}] }
})
return arrRet
} catch (err) {
const newErr = err
throw newErr
}
}
// =================================================
// getServicePlanMembers
// =================================================
scimgateway.getServicePlanMembers = async (baseEntity, id, attributes) => { // not in use
const action = 'getServicePlanMembers'
scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" user id=${id} attributes=${attributes}`)
const arrRet = []
return arrRet
}
// =================================================
// getGroupUsers
// =================================================
scimgateway.getGroupUsers = async (baseEntity, id, attributes) => { // not in use
// return array of all users that is member of this group id having attributes included e.g: groups.value,userName
// method used when "group member of users", if used - getGroup must treat group attribute members as virtual readOnly attribute
const action = 'getGroupUsers'
scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" id=${id} attributes=${attributes}`)
const arrRet = []
return arrRet
}
// =================================================
// createGroup
// =================================================
scimgateway.createGroup = async (baseEntity, groupObj) => {
const action = 'createGroup'
scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" groupObj=${JSON.stringify(groupObj)}`)
const body = { displayName: groupObj.displayName }
body.mailNickName = groupObj.displayName
body.mailEnabled = false
body.securityEnabled = true
const method = 'POST'
const path = '/Groups'
try {
await doRequest(baseEntity, method, path, body)
return null
} catch (err) {
const newErr = err
throw newErr
}
}
// =================================================
// deleteGroup
// =================================================
scimgateway.deleteGroup = async (baseEntity, id) => {
const action = 'deleteGroup'
scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" id=${id}`)
// if supporting delete group we need some endpoint logic here
const err = new Error(`Delete group is not supported by ${pluginName}`)
throw (err)
}
// =================================================
// modifyGroup
// =================================================
scimgateway.modifyGroup = async (baseEntity, id, attrObj) => {
const action = 'modifyGroup'
scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" id=${id} attrObj=${JSON.stringify(attrObj)}`)
if (!attrObj.members) {
throw new Error(`plugin handling "${action}" only supports modification of members`)
}
if (!Array.isArray(attrObj.members)) {
throw new Error(`plugin handling "${action}" error: ${JSON.stringify(attrObj)} - correct syntax is { "members": [...] }`)
}
const arrGrpAdd = []
const arrGrpDel = []
attrObj.members.forEach(function (el) {
if (el.operation && el.operation === 'delete') { // delete member from group e.g {"members":[{"operation":"delete","value":"bjensen"}]}
arrGrpDel.push(el.value)
} else if (el.value) { // add member to group {"members":[{value":"bjensen"}]}
arrGrpAdd.push(el.value)
}
})
const addGrps = () => { // add groups
return new Promise(async (resolve, reject) => {
if (arrGrpAdd.length < 1) return resolve(null)
const method = 'POST'
const path = `/groups/${id}/members/$ref`
for (let i = 0, len = arrGrpAdd.length; i < len; i++) {
const body = { '@odata.id': `${graphv1}/directoryObjects/${arrGrpAdd[i]}` }
try {
await doRequest(baseEntity, method, path, body)
if (i === len - 1) resolve(null) // loop completed
} catch (err) {
return reject(err)
}
}
})
}
const removeGrps = () => { // remove groups
return new Promise(async (resolve, reject) => {
if (arrGrpDel.length < 1) return resolve(null)
const method = 'DELETE'
const body = null
for (let i = 0, len = arrGrpDel.length; i < len; i++) {
const path = `/groups/${id}/members/${arrGrpDel[i]}/$ref`
try {
await doRequest(baseEntity, method, path, body)
if (i === len - 1) resolve(null) // loop completed
} catch (err) {
return reject(err)
}
}
})
}
return Promise.all([addGrps(), removeGrps()])
.then((res) => { return res })
.catch((err) => { throw (err) })
}
// =================================================
// exploreServicePlans
// =================================================
scimgateway.exploreServicePlans = async (baseEntity, attributes, startIndex, count) => {
const action = 'exploreServicePlans'
scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" attributes=${attributes} startIndex=${startIndex} count=${count}`)
const ret = { // itemsPerPage will be set by scimgateway
Resources: [],
totalResults: null
}
const method = 'GET'
const path = '/subscribedSkus' // paging not supported
const body = null
try {
const response = await doRequest(baseEntity, method, path, body)
if (!response.body.value) {
const err = new Error(`${action}: Got empty response on REST request`)
throw (err)
}
for (let i = 0; i < response.body.value.length; i++) {
const skuPartNumber = response.body.value[i].skuPartNumber
for (let index = 0; index < response.body.value[i].servicePlans.length; index++) {
if (response.body.value[i].servicePlans[index].servicePlanName && response.body.value[i].servicePlans[index].provisioningStatus === 'Success') {
const scimPlan = {
servicePlanName: `${skuPartNumber}::${response.body.value[i].servicePlans[index].servicePlanName}`
}
ret.Resources.push(scimPlan)
}
}
}
ret.totalResults = response.body.value.length
return ret // all explored plans
} catch (err) {
const newErr = err
throw newErr
}
}
// =================================================
// getServicePlan
// =================================================
scimgateway.getServicePlan = async (baseEntity, getObj, attributes) => {
const action = 'geServicePlan'
scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" ${getObj.filter}=${getObj.identifier} attributes=${attributes}`)
if (getObj.filter !== 'servicePlanName' && getObj.filter !== 'id') {
throw new Error(`plugin do not support handling "${action}" ${getObj.filter}`)
}
if (attributes === 'servicePlanName') return { servicePlanName: getObj.identifier }
const arrOutbound = (scimgateway.endpointMapper('outbound', attributes, scimgateway.endpointMap.microsoftGraphLicenseDetails)[0]).split(',')
const arrInbound = (scimgateway.endpointMapper('inbound', attributes, scimgateway.endpointMap.microsoftGraphLicenseDetails)[0]).split(',')
const method = 'GET'
const path = '/subscribedSkus'
const body = null
try {
const response = await doRequest(baseEntity, method, path, body)
if (!response.body.value) {
const err = new Error(`${action}: Got empty response on REST request`)
throw (err)
}
const arr = getObj.identifier.split('::') // servicePlaneName
const skuPartNumber = arr[0]
const plan = arr[1]
const ret = {}
for (let i = 0; i < response.body.value.length; i++) {
if (response.body.value[i].skuPartNumber !== skuPartNumber) continue
for (let index = 0; index < response.body.value[i].servicePlans.length; index++) {
if (response.body.value[i].servicePlans[index].servicePlanName === plan) {
ret.servicePlanName = `${skuPartNumber}::${response.body.value[i].servicePlans[index].servicePlanName}`
ret.id = response.body.value[i].servicePlans[index].servicePlanId
for (let j = 0; j < arrInbound.length; j++) { // skuPartNumber, skuId, servicePlanName, servicePlanId
if (arrInbound[j] !== 'servicePlanName' && arrInbound[j] !== 'id') ret[arrInbound[j]] = response.body.value[i][arrOutbound[j]]
}
i = response.body.value.length
break
}
}
}
return ret
} catch (err) {
const newErr = err
throw newErr
}
}
// =================================================
// helpers
// =================================================
//
// getServiceClient - returns options needed for connection parameters
//
// path = e.g. "/xxx/yyy", then using host/port/protocol based on config baseUrls[0]
// auth automatically added and failover according to baseUrls array
//
// path = url e.g. "http(s)://<host>:<port>/xxx/yyy", then using the url host/port/protocol
// opt (options) may be needed e.g {auth: {username: "username", password: "password"} }
//
const getServiceClient = async (baseEntity, method, path, opt) => {
const action = 'getServiceClient'
let urlObj
if (!path) path = ''
try {
urlObj = new URL(path)
} catch (err) {
//
// path (no url) - default approach and client will be cached based on config
//
if (_serviceClient[baseEntity] && _serviceClient[baseEntity].accessToken) { // serviceClient already exist - Azure plugin specific
scimgateway.logger.debug(`${pluginName}[${baseEntity}] ${action}: Using existing client`)
// check if token refresh is needed
const d = new Date() / 1000 // seconds (unix time)
if (_serviceClient[baseEntity].accessToken.validTo < d + 30) { // less than 30 sec before token expiration
scimgateway.logger.debug(`${pluginName}[${baseEntity}] ${action}: Accesstoken about to expire in ${_serviceClient[baseEntity].accessToken.validTo - d} seconds`)
try {
const accessToken = await getAccessToken(baseEntity)
_serviceClient[baseEntity].accessToken = accessToken
_serviceClient[baseEntity].options.headers.Authorization = ` Bearer ${accessToken.access_token}`
} catch (err) {
const newErr = err
throw newErr
}
}
} else {
scimgateway.logger.debug(`${pluginName}[${baseEntity}] ${action}: Client have to be created`)
let client = null
if (config.entity && config.entity[baseEntity]) client = config.entity[baseEntity]
if (!client) {
const err = new Error(`Base URL have baseEntity=${baseEntity}, and configuration file ${pluginName}.json is missing required baseEntity configuration for ${baseEntity}`)
throw err
}
// Azure plugin specific
const accessToken = await getAccessToken(baseEntity)
if (!config.entity[baseEntity].baseUrls) config.entity[baseEntity].baseUrls = [graphv1] // Azure plugin avoid config file and keep baseUrls logic
urlObj = new URL(config.entity[baseEntity].baseUrls[0])
const param = {
baseUrl: config.entity[baseEntity].baseUrls[0],
accessToken: accessToken, // Azure plugin specific
options: {
json: true, // json-object response instead of string
headers: {
'Content-Type': 'application/json',
Authorization: ` Bearer ${accessToken.access_token}`
},
host: urlObj.hostname,
port: urlObj.port, // null if https and 443 defined in url
protocol: urlObj.protocol // http: or https:
// 'method' and 'path' added at the end
}
}
// proxy
if (config.entity[baseEntity].proxy && config.entity[baseEntity].proxy.host) {
const agent = new HttpsProxyAgent(config.entity[baseEntity].proxy.host)
param.options.agent = agent // proxy
if (config.entity[baseEntity].proxy.username && config.entity[baseEntity].proxy.password) {
param.options.headers['Proxy-Authorization'] = 'Basic ' + Buffer.from(`${config.entity[baseEntity].proxy.username}:${scimgateway.getPassword(`endpoint.entity.${baseEntity}.proxy.password`, configFile)}`).toString('base64') // using proxy with auth
}
}
if (!_serviceClient[baseEntity]) _serviceClient[baseEntity] = {}
_serviceClient[baseEntity] = param // serviceClient created
// Azure plugin specific
_serviceClient[baseEntity].nextLink = {}
_serviceClient[baseEntity].nextLink.users = { skiptoken: null } // Azure users pagination
_serviceClient[baseEntity].nextLink.groups = { skiptoken: null } // Azure groups pagination
}
const cli = scimgateway.copyObj(_serviceClient[baseEntity]) // client ready
// failover support
path = _serviceClient[baseEntity].baseUrl + path
urlObj = new URL(path)
cli.options.host = urlObj.hostname
cli.options.port = urlObj.port
cli.options.protocol = urlObj.protocol
// adding none static
cli.options.method = method
cli.options.path = `${urlObj.pathname}${urlObj.search}`
if (opt) cli.options = scimgateway.extendObj(cli.options, opt) // merge with argument options
return cli // final client
}
//
// url path - none config based and used as is (no cache)
//
scimgateway.logger.debug(`${pluginName}[${baseEntity}] ${action}: Using none config based client`)
let options = {
json: true,
headers: {
'Content-Type': 'application/json'
},
host: urlObj.hostname,
port: urlObj.port,
protocol: urlObj.protocol,
method: method,
path: urlObj.pathname
}
// proxy
if (config.entity[baseEntity].proxy && config.entity[baseEntity].proxy.host) {
const agent = new HttpsProxyAgent(config.entity[baseEntity].proxy.host)
options.agent = agent // proxy
if (config.entity[baseEntity].proxy.username && config.entity[baseEntity].proxy.password) {
options.headers['Proxy-Authorization'] = 'Basic ' + Buffer.from(`${config.entity[baseEntity].proxy.username}:${scimgateway.getPassword(`endpoint.entity.${baseEntity}.proxy.password`, configFile)}`).toString('base64') // using proxy with auth
}
}
// merge any argument options - support basic auth using {auth: {username: "username", password: "password"} }
if (opt) {
const o = scimgateway.copyObj(opt)
if (o.auth) {
options.headers.Authorization = 'Basic ' + Buffer.from(`${o.auth.username}:${o.auth.password}`).toString('base64')
delete o.auth
}
options = scimgateway.extendObj(options, o)
}
const cli = {}
cli.options = options
return cli // final client
}
const updateServiceClient = (baseEntity, obj) => {
if (_serviceClient[baseEntity]) _serviceClient[baseEntity] = scimgateway.extendObj(_serviceClient[baseEntity], obj) // merge with argument options
}
//
// doRequest - execute REST service
//
const doRequest = async (baseEntity, method, path, body, opt, retryCount) => {
try {
const cli = await getServiceClient(baseEntity, method, path, opt)
const options = cli.options
const result = await new Promise((resolve, reject) => {
let dataString = ''
if (body) {
if (options.headers['Content-Type'].toLowerCase() === 'application/x-www-form-urlencoded')