scimgateway
Version:
Using SCIM protocol as a gateway for user provisioning to other endpoints
1,186 lines (1,097 loc) • 75.1 kB
text/typescript
// =================================================================================
// File: plugin-ldap.ts
//
// Author: Jarle Elshaug
//
// Purpose: General ldap plugin having plugin-ldap.json configured for Active Directory.
// Using endpointMapper for attribute flexibility. Includes some special logic
// for Active Directory attributes like userAccountControl, unicodePW
// and objectSid/objectGUID (mS-DS-ConsistencyGuid).
// e.g., using objectGUID for common identifier (userName/externalId)
//
// {
// "map": {
// "user": {
// "dn": {
// "mapTo": "id",
// "type": "string"
// },
// "objectGUID": { // Entra ID: immutableId
// "mapTo": "userName", // Entra ID: Matching precedence 1
// "type": "string"
// },
// "userPrincipalName": {
// "mapTo": "externalId", // Entra ID: Matching precedence 2
// "type": "string"
// },
// ...
// },
// "group": {
// "dn": {
// "mapTo": "id", // or "id,externalId" - then skip both "objectGUID" and "cn" - Entra ID: Matching precedence 1 = externalId, which is linked to a custom extension attribute having dn
// "type": "string"
// },
// "objectGUID": { // Entra ID: immutableId
// "mapTo": "externalId", // Entra ID: Matching precedence 1
// "type": "string"
// },
// "cn": {
// "mapTo": "displayName", // Entra ID: Matching precedence 2
// "type": "string"
// },
// ...
// }
// }
//
//
//
// Additional user/group filtering for restricting scope may be configured in endpoint.entity.xxx.ldap e.g:
// {
// ...
// "userFilter": "(memberOf=CN=grp1,OU=Groups,DC=test,DC=com)(!(memberOf=CN=Domain Admins,CN=Users,DC=test,DC=com))",
// "groupFilter": "(!(cn=grp2))",
// ...
// }
//
// Configuration isOpenLdap true/false decides whether or not OpenLDAP Foundation protocol should
// be used for national characters and special characters in DN.
// For Active Directory, default isOpenLdap=false should be used
//
// Configuration allowModifyDN=true allows DN being changed based on modified mapping or namingAttribute
//
// Note, using Bun version < 1.2.5 and ldaps/TLS, environment must be set before started e.g.:
// export NODE_EXTRA_CA_CERTS=/package-path/config/certs/ca.pem
// or
// export NODE_TLS_REJECT_UNAUTHORIZED=0
//
// Bun version >= 1.2.5 supports configuration
// "tls": {
// "ca": ca-file-name, // located in confg/certs
// "rejectUnauthorized": true
// }
//
// Attributes according to map definition in the configuration file plugin-ldap.json:
//
// GlobalUser Template Scim Endpoint
// -----------------------------------------------------------------------------------------------
// User name %AC% userName sAMAccountName
// id dn
// Suspended - active userAccountControl
// Password %P% password unicodePwd
// First Name %UF% name.givenName givenName
// Last Name %UL% name.familyName sn
// Full Name %UN% name.formatted name
// Job title %UT% title title
// groups.value memberOf
// Emails emails.work.value mail
// Phones phoneNumbers.home.value homePhone
// phoneNumbers.work.value mobile
// Addresses addresses.work.postalCode postalCode
// addresses.work.streetAddress streetAddress
// addresses.work.locality l
// addresses.work.region st
// addresses.work.country co
// Entitlements (for general purposes) entitlements.description.value description
// entitlements.lastLogonTimestamp.value lastLogonTimestamp
// entitlements.homeDirectory.value homeDirectory
// entitlements.homeDrive.value homeDrive
// entitlements.telephoneNumber.value telephoneNumber
// entitlements.physicalDeliveryOfficeName.value physicalDeliveryOfficeName
// createUser override userBase entitlements.userBase.value N/A
//
// Groups:
// id dn
// displayName cn
// members.value member
//
// =================================================================================
import ldap from 'ldapjs'
// @ts-expect-error missing type definitions
import { BerReader } from '@ldapjs/asn1'
import fs from 'node:fs'
// start - mandatory plugin initialization
import { ScimGateway } from 'scimgateway'
const scimgateway = new ScimGateway()
const config = scimgateway.getConfig()
scimgateway.authPassThroughAllowed = false
// end - mandatory plugin initialization
// =================================================
// getUsers
// =================================================
scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
//
// "getObj" = { attribute: <>, operator: <>, value: <>, rawFilter: <>, startIndex: <>, count: <> }
// rawFilter is always included when filtering
// attribute, operator and value are included when requesting unique object or simpel filtering
// See comments in the "mandatory if-else logic - start"
//
// "attributes" is array of attributes to be returned - if empty, all supported attributes should be returned
// Should normally return all supported user attributes having id and userName as mandatory
// id and userName are most often considered as "the same" having value = <UserID>
// Note, the value of returned 'id' will be used as 'id' in modifyUser and deleteUser
// scimgateway will automatically filter response according to the attributes list
//
const action = 'getUsers'
scimgateway.logDebug(baseEntity, `handling ${action} getObj=${getObj ? JSON.stringify(getObj) : ''} attributes=${attributes} passThrough=${ctx ? 'true' : 'false'}`)
if (!config.entity[baseEntity]) throw new Error(`unsupported baseEntity: ${baseEntity}`)
const result: any = {
Resources: [],
totalResults: null,
}
attributes = [] // ignore original, all attributes needed because of object meta.version and ETag
if (attributes.length === 0) {
for (const key in config.map.user) {
attributes = [...attributes, ...config.map.user[key].mapTo.split(',')]
}
}
const [attrs] = scimgateway.endpointMapper('outbound', attributes, config.map.user) // SCIM/CustomSCIM => endpoint attribute naming
const method = 'search'
const scope = 'sub'
let base = config.entity[baseEntity].ldap.userBase
let ldapOptions: Record<string, any>
// start mandatory if-else logic
if (getObj.operator) {
if (getObj.operator === 'eq' && ['id', 'userName', 'externalId'].includes(getObj.attribute)) {
// mandatory - unique filtering - single unique user to be returned - correspond to getUser() in versions < 4.x.x
if (getObj.attribute === 'id') { // lookup using dn
base = getObj.value
ldapOptions = {
attributes: attrs,
}
} else {
// search instead of lookup
const [userAttr, err] = scimgateway.endpointMapper('outbound', getObj.attribute, config.map.user) // e.g. 'userName' => 'sAMAccountName'
if (err) throw new Error(`${action} error: ${err.message}`)
const filter = createAndFilter(baseEntity, 'user', [{ attribute: userAttr, value: getObj.value }])
ldapOptions = {
filter,
scope: 'sub',
attributes: attrs,
}
}
ldapOptions.paged = false
} else if (getObj.operator === 'eq' && getObj.attribute === 'group.value') {
// optional - only used when groups are member of users, not default behavior - correspond to getGroupUsers() in versions < 4.x.x
throw new Error(`${action} error: not supporting groups member of user filtering: ${getObj.rawFilter}`)
} else {
// optional - simpel filtering
if (getObj.operator === 'eq') {
const [filterAttr, err] = scimgateway.endpointMapper('outbound', getObj.attribute, config.map.user)
if (err) throw new Error(`${action} error: ${err.message}`)
const filter = createAndFilter(baseEntity, 'user', [{ attribute: filterAttr, value: getObj.value }])
ldapOptions = {
filter,
scope,
attributes: attrs,
}
} else {
throw new Error(`${action} error: not supporting simpel filtering: ${getObj.rawFilter}`)
}
}
} else if (getObj.rawFilter) {
// optional - advanced filtering having and/or/not - use getObj.rawFilter
throw new Error(`${action} error: not supporting advanced filtering: ${getObj.rawFilter}`)
} else {
// mandatory - no filtering (!getObj.operator && !getObj.rawFilter) - all users to be returned - correspond to exploreUsers() in versions < 4.x.x
const filter = createAndFilter(baseEntity, 'user', [{}])
ldapOptions = {
filter,
scope,
attributes: attrs,
}
}
// end mandatory if-else logic
if (!ldapOptions) throw new Error(`${action} error: mandatory if-else logic not fully implemented`)
try {
const users: any = await doRequest(baseEntity, method, base, ldapOptions, ctx) // ignoring SCIM paging startIndex/count - get all
result.totalResults = users.length
result.Resources = await Promise.all(users.map(async (user: any) => { // Promise.all because of async map
// endpoint spesific attribute handling
// "active" must be handled separate
if (user.userAccountControl !== undefined) { // SCIM "active" - Active Directory
const userAccountControl = user.userAccountControl// ACCOUNTDISABLE 0x0002
if ((userAccountControl & 0x0002) === 0x0002) user.userAccountControl = false
else user.userAccountControl = true
}
const scimObj = scimgateway.endpointMapper('inbound', user, config.map.user)[0] // endpoint attribute naming => SCIM
return scimObj
}))
} catch (err: any) {
throw new Error(`${action} error: ${err.message}`)
}
return result
}
// =================================================
// createUser
// =================================================
scimgateway.createUser = async (baseEntity, userObj, ctx) => {
const action = 'createUser'
scimgateway.logDebug(baseEntity, `handling ${action} userObj=${JSON.stringify(userObj)} passThrough=${ctx ? 'true' : 'false'}`)
let userBase = null
if (userObj.entitlements && userObj.entitlements.userbase) { // override default userBase (type userbase will be lowercase)
if (userObj.entitlements.userbase.value) {
userBase = userObj.entitlements.userbase.value // temporary and not in config.map, will not be sent to endpoint
}
}
if (!userBase) userBase = config.entity[baseEntity].ldap.userBase
// convert SCIM attributes to endpoint attributes according to config.map
const [endpointObj] = scimgateway.endpointMapper('outbound', userObj, config.map.user) // use [endpointObj, err] and if err, throw error to catch non supported attributes
// endpoint spesific attribute handling
if (endpointObj.sAMAccountName !== undefined) { // Active Directory
const illegalCharsRegex = /["\/\\\[\]:;\|=,\+\*\?<>\u0000-\u001F]/
if (illegalCharsRegex.test(endpointObj.sAMAccountName)) {
throw new Error(`${action} error: sAMAccountName '${endpointObj.sAMAccountName}' contains disallowed characters: " / \\ [ ] : ; | = , + * ? < > or control characters`)
}
const chars = Array.from(endpointObj.sAMAccountName) // handles unicode correctly
if (chars.length > 20) {
throw new Error(`${action} error: sAMAccountName '${endpointObj.sAMAccountName}' exceeds maximum length of 20 characters`)
}
const userAccountControl = 512 // NORMAL_ACCOUNT
endpointObj.userAccountControl = userAccountControl ^ 0x0002 // disable user (will be enabled if password provided)
if (!endpointObj.userPrincipalName) {
if (endpointObj.mail) endpointObj.userPrincipalName = endpointObj.mail
else endpointObj.userPrincipalName = endpointObj.sAMAccountName
}
userObj.userName = endpointObj.sAMAccountName // ensure user dn based on sAMAccountName
}
if (endpointObj.unicodePwd) { // Active Directory - SCIM "password" - UTF16LE encoded password in quotes
let pwd = ''
const str = `"${endpointObj.unicodePwd}"`
for (let i = 0; i < str.length; i++) {
pwd += String.fromCharCode(str.charCodeAt(i) & 0xFF, (str.charCodeAt(i) >>> 8) & 0xFF)
}
endpointObj.unicodePwd = pwd
const userAccountControl = 512 // NORMAL_ACCOUNT
endpointObj.userAccountControl = userAccountControl & (~0x0002) // enable user
endpointObj.pwdLastSet = 0 // user must change password on next logon
}
// endpointObj.objectClass is mandatory and must must match your ldap schema
endpointObj.objectClass = config.entity[baseEntity].ldap.userObjectClasses // Active Directory: ["user", "person", "organizationalPerson", "top"]
let base = endpointObj.dn || ''
if (!base) { // default
const [userNamingAttr, scimAttr] = getNamingAttribute(baseEntity, 'user') // ['CN', 'userName']
const arr = scimAttr.split('.')
if (arr.length < 2) {
if (!userObj[scimAttr]) throw new Error(`${action} error: configuration namingAttribute mapTo SCIM attribute '${scimAttr}', but attribute not included in request`)
base = `${userNamingAttr}=${userObj[scimAttr]},${userBase}`
} else {
if (!userObj[arr[0]][arr[1]]) throw new Error(`${action} error: configuration namingAttribute mapTo SCIM attribute '${scimAttr}', but attribute not included in request`)
base = `${userNamingAttr}=${userObj[arr[0]][arr[1]]},${userBase}`
}
}
const method = 'add'
const ldapOptions = endpointObj
try {
await doRequest(baseEntity, method, base, ldapOptions, ctx)
return null
} catch (err: any) {
const newErr = new Error(`${action} error: ${err.message}`)
if (newErr.message.includes('Entry Already Exists') || newErr.message.includes('ENTRY_EXISTS')) newErr.name += '#409' // customErrCode
throw newErr
}
}
// =================================================
// deleteUser
// =================================================
scimgateway.deleteUser = async (baseEntity, id, ctx) => {
const action = 'deleteUser'
scimgateway.logDebug(baseEntity, `handling ${action} id=${id} passThrough=${ctx ? 'true' : 'false'}`)
const method = 'del'
let base = id // dn
const ldapOptions = {}
try {
await doRequest(baseEntity, method, base, ldapOptions, ctx)
return null
} catch (err: any) {
throw new Error(`${action} error: ${err.message}`)
}
}
// =================================================
// modifyUser
// =================================================
scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
const action = 'modifyUser'
scimgateway.logDebug(baseEntity, `handling ${action} id=${id} attrObj=${JSON.stringify(attrObj)} passThrough=${ctx ? 'true' : 'false'}`)
// groups must be handled separate - using group member of user and not user member of group
if (attrObj.groups) { // not supported by AD - will fail (not allowing updating users memberOf attribute, must update group instead of user)
const groups = attrObj.groups
delete attrObj.groups // make sure to be removed from attrObj
const [groupsAttr] = scimgateway.endpointMapper('outbound', 'groups.value', config.map.user)
const grp: any = { add: {}, remove: {} }
grp.add[groupsAttr] = []
grp.remove[groupsAttr] = []
for (let i = 0; i < groups.length; i++) {
const el = groups[i]
if (el.operation && el.operation === 'delete') { // delete from users group attribute
grp.remove[groupsAttr].push(el.value)
} else { // add to users group attribute
grp.add[groupsAttr].push(el.value)
}
}
const method = 'modify'
let base = id // dn
try {
if (grp.add[groupsAttr].length > 0) {
const ldapOptions = {
operation: 'add',
modification: grp.add,
}
await doRequest(baseEntity, method, base, ldapOptions, ctx)
}
if (grp.remove[groupsAttr].length > 0) {
const ldapOptions = {
operation: 'delete',
modification: grp.remove,
}
await doRequest(baseEntity, method, base, ldapOptions, ctx)
}
} catch (err: any) {
throw new Error(`${action} error: ${err.message}`)
}
}
if (Object.keys(attrObj).length < 1) return null // only groups included
// convert SCIM attributes to endpoint attributes according to config.map
const [endpointObj] = scimgateway.endpointMapper('outbound', attrObj, config.map.user)
// endpoint spesific attribute handling
if (endpointObj.userAccountControl !== undefined) { // SCIM "active" - Active Directory
// can't use getUser because there is "active" logic overriding original userAccountControl that we want
// const usr = await scimgateway.getUser(baseEntity, { filter: 'id', identifier: id }, 'active', ctx)
const activeAttr = 'userAccountControl'
const method = 'search'
let base = id // dn
const ldapOptions: any = {
attributes: activeAttr,
}
const users: any = await doRequest(baseEntity, method, base, ldapOptions, ctx)
if (users.length === 0) throw new Error(`${action} error: ${id} not found`)
else if (users.length > 1) throw new Error(`${action} error: ${ldapOptions.filter} returned more than one user for ${id}`)
const usr = users[0]
let userAccountControl
if (usr.userAccountControl) userAccountControl = usr.userAccountControl // ACCOUNTDISABLE 0x0002
else throw new Error(`${action} error: did not retrieve any value for attribute "${activeAttr}/active"`)
if (attrObj.active === false) userAccountControl = userAccountControl ^ 0x0002 // disable user
else userAccountControl = userAccountControl = userAccountControl & (~(0x0002 + 0x0010)) // enable user, also turn off any LOCKOUT 0x0010
endpointObj.userAccountControl = userAccountControl // now converted from active (true/false) to userAccountControl
}
if (endpointObj.unicodePwd) { // SCIM "password" - Active Directory - UTF16LE encoded password in quotes
let pwd = ''
const str = `"${endpointObj.unicodePwd}"`
for (let i = 0; i < str.length; i++) {
pwd += String.fromCharCode(str.charCodeAt(i) & 0xFF, (str.charCodeAt(i) >>> 8) & 0xFF)
}
endpointObj.unicodePwd = pwd
}
const method = 'modify'
let base = id // dn
if (Object.keys(endpointObj).length < 1) return null
const ldapOptions = {
operation: 'replace',
modification: endpointObj,
}
try {
const newDN = checkIfNewDN(baseEntity, base, 'user', attrObj, endpointObj)
await doRequest(baseEntity, method, base, ldapOptions, ctx)
if (!newDN) return null
// modify DN
await doRequest(baseEntity, 'modifyDN', base, { modification: { newDN } }, ctx)
// clean up zoombie group members and use the new user DN incase not handled by ldap server
const [memberAttr] = scimgateway.endpointMapper('outbound', 'members.value', config.map.group)
if (memberAttr) {
const grp: any = { add: {}, remove: {} }
grp.add[memberAttr] = []
grp.remove[memberAttr] = []
let r
try {
const ob = { attribute: 'members.value', operator: 'eq', value: base } // base is old DN
const attributes = ['id', 'displayName']
r = await scimgateway.getGroups(baseEntity, ob, attributes, ctx)
} catch (err) { } // ignore errors incase method not implemented
if (r && r.Resources && Array.isArray(r.Resources) && r.Resources.length > 0) {
for (let i = 0; i < r.Resources.length; i++) {
if (!r.Resources[i].id) continue
const grpId = decodeURIComponent(r.Resources[i].id)
grp.remove[memberAttr] = [base]
grp.add[memberAttr] = [newDN]
await Promise.all([
doRequest(baseEntity, method, grpId, { operation: 'add', modification: grp.add }, ctx),
doRequest(baseEntity, method, grpId, { operation: 'delete', modification: grp.remove }, ctx),
])
}
}
// return full user object to avoid scimgateway doing same getUser() using original id/dn that now will fail
const getObj = { attribute: 'id', operator: 'eq', value: newDN }
const res = await scimgateway.getUsers(baseEntity, getObj, [], ctx)
return res
}
return null
} catch (err: any) {
throw new Error(`${action} error: ${err.message}`)
}
}
// =================================================
// getGroups
// =================================================
scimgateway.getGroups = async (baseEntity, getObj, attributes, ctx) => {
//
// "getObj" = { attribute: <>, operator: <>, value: <>, rawFilter: <>, startIndex: <>, count: <> }
// rawFilter is always included when filtering
// attribute, operator and value are included when requesting unique object or simpel filtering
// See comments in the "mandatory if-else logic - start"
//
// "attributes" is array of attributes to be returned - if empty, all supported attributes should be returned
// Should normally return all supported group attributes having id, displayName and members as mandatory
// id and displayName are most often considered as "the same" having value = <GroupName>
// Note, the value of returned 'id' will be used as 'id' in modifyGroup and deleteGroup
// scimgateway will automatically filter response according to the attributes list
//
const action = 'getGroups'
scimgateway.logDebug(baseEntity, `handling ${action} getObj=${getObj ? JSON.stringify(getObj) : ''} attributes=${attributes} passThrough=${ctx ? 'true' : 'false'}`)
if (!config.entity[baseEntity]) throw new Error(`unsupported baseEntity: ${baseEntity}`)
const result: any = {
Resources: [],
totalResults: null,
}
if (!config?.map?.group || !config.entity[baseEntity]?.ldap?.groupBase) { // not using groups
scimgateway.logDebug(baseEntity, `${action} skip group handling - missing configuration endpoint.map.group or groupBase`)
return result
}
attributes = [] // ignore original, all attributes needed because of object meta.version and ETag
if (attributes.length === 0) {
for (const key in config.map.group) {
if (config.map.group[key].mapTo) {
attributes = [...attributes, ...config.map.group[key].mapTo.split(',')]
}
}
}
let [attrs] = scimgateway.endpointMapper('outbound', attributes, config.map.group) // SCIM/CustomSCIM => endpoint attribute naming
const method = 'search'
const scope = 'sub'
let base = config.entity[baseEntity].ldap.groupBase
let ldapOptions
// mandatory if-else logic - start
if (getObj.operator) {
if (getObj.operator === 'eq' && ['id', 'displayName', 'externalId'].includes(getObj.attribute)) {
// mandatory - unique filtering - single unique user to be returned - correspond to getUser() in versions < 4.x.x
if (getObj.attribute === 'id') { // lookup using dn
base = getObj.value
ldapOptions = {
attributes: attrs,
}
} else {
const [groupIdAttr, err] = scimgateway.endpointMapper('outbound', getObj.attribute, config.map.group)
if (err) throw new Error(`${action} error: ${err.message}`)
// search instead of lookup
const filter = createAndFilter(baseEntity, 'group', [{ attribute: groupIdAttr, value: getObj.value }])
ldapOptions = {
filter,
scope,
attributes: attrs,
}
}
} else if (getObj.operator === 'eq' && getObj.attribute === 'members.value') {
// mandatory - return all groups the user 'id' (getObj.value) is member of - correspond to getGroupMembers() in versions < 4.x.x
// Resources = [{ id: <id-group>> , displayName: <displayName-group>, members [{value: <id-user>}] }]
ldapOptions = 'getMemberOfGroups'
} else {
// optional - simpel filtering
if (getObj.operator === 'eq') {
const [filterAttr, err] = scimgateway.endpointMapper('outbound', getObj.attribute, config.map.group)
if (err) throw new Error(`${action} error: ${err.message}`)
const filter = createAndFilter(baseEntity, 'group', [{ attribute: filterAttr, value: getObj.value }])
ldapOptions = {
filter,
scope,
attributes: attrs,
}
} else {
throw new Error(`${action} error: not supporting simpel filtering: ${getObj.rawFilter}`)
}
}
} else if (getObj.rawFilter) {
// optional - advanced filtering having and/or/not - use getObj.rawFilter
throw new Error(`${action} error: not supporting advanced filtering: ${getObj.rawFilter}`)
} else {
// mandatory - no filtering (!getObj.operator && !getObj.rawFilter) - all groups to be returned - correspond to exploreGroups() in versions < 4.x.x
const filter = createAndFilter(baseEntity, 'group', [{}])
ldapOptions = {
filter,
scope,
attributes: attrs,
}
}
// mandatory if-else logic - end
if (!ldapOptions) throw new Error(`${action} error: mandatory if-else logic not fully implemented`)
try {
if (ldapOptions === 'getMemberOfGroups') result.Resources = await getMemberOfGroups(baseEntity, getObj.value, ctx)
else {
const groups: any = await doRequest(baseEntity, method, base, ldapOptions, ctx)
result.Resources = await Promise.all(groups.map(async (group: any) => { // Promise.all because of async map
return scimgateway.endpointMapper('inbound', group, config.map.group)[0] // endpoint attribute naming => SCIM
}))
}
result.totalResults = result.Resources.length
return result
} catch (err: any) {
throw new Error(`${action} error: ${err.message}`)
}
}
// =================================================
// createGroup
// =================================================
scimgateway.createGroup = async (baseEntity, groupObj, ctx) => {
const action = 'createGroup'
scimgateway.logDebug(baseEntity, `handling ${action} groupObj=${JSON.stringify(groupObj)} passThrough=${ctx ? 'true' : 'false'}`)
if (!config.map.group) throw new Error(`${action} error: missing configuration endpoint.map.group`)
const groupBase = config.entity[baseEntity].ldap.groupBase
// convert SCIM attributes to endpoint attributes according to config.map
const [endpointObj] = scimgateway.endpointMapper('outbound', groupObj, config.map.group)
// endpointObj.objectClass is mandatory and must must match your ldap schema
endpointObj.objectClass = config.entity[baseEntity].ldap.groupObjectClasses // Active Directory: ["group"]
let base = endpointObj.dn || ''
if (!base) { // default
const [groupNamingAttr, scimAttr] = getNamingAttribute(baseEntity, 'group') // ['CN', 'displayName']
const arr = scimAttr.split('.')
if (arr.length < 2) {
if (!groupObj[scimAttr]) throw new Error(`${action} error: configuration namingAttribute mapTo SCIM attribute '${scimAttr}', but attribute not included in request`)
base = `${groupNamingAttr}=${groupObj[scimAttr]},${groupBase}`
} else {
if (!groupObj[arr[0]][arr[1]]) throw new Error(`${action} error: configuration namingAttribute mapTo SCIM attribute '${scimAttr}', but attribute not included in request`)
base = `${groupNamingAttr}=${groupObj[arr[0]][arr[1]]},${groupBase}`
}
}
const method = 'add'
const ldapOptions = endpointObj
try {
await doRequest(baseEntity, method, base, ldapOptions, ctx)
return null
} catch (err: any) {
const newErr = new Error(`${action} error: ${err.message}`)
if (newErr.message.includes('ENTRY_EXISTS')) newErr.name += '#409' // customErrCode
throw newErr
}
}
// =================================================
// deleteGroup
// =================================================
scimgateway.deleteGroup = async (baseEntity, id, ctx) => {
const action = 'deleteGroup'
scimgateway.logDebug(baseEntity, `handling ${action} id=${id} passThrough=${ctx ? 'true' : 'false'}`)
if (!config.map.group) throw new Error(`${action} error: missing configuration endpoint.map.group`)
const method = 'del'
let base = id // dn
const ldapOptions = {}
try {
await doRequest(baseEntity, method, base, ldapOptions, ctx)
return null
} catch (err: any) {
throw new Error(`${action} error: ${err.message}`)
}
}
// =================================================
// modifyGroup
// =================================================
scimgateway.modifyGroup = async (baseEntity, id, attrObj, ctx) => {
const action = 'modifyGroup'
scimgateway.logDebug(baseEntity, `handling ${action} id=${id} attrObj=${JSON.stringify(attrObj)} passThrough=${ctx ? 'true' : 'false'}`)
if (!config.map.group) throw new Error(`${action} error: missing configuration endpoint.map.group`)
if (attrObj.members && !Array.isArray(attrObj.members)) {
throw new Error(`${action} error: ${JSON.stringify(attrObj)} - correct syntax is { "members": [...] }`)
}
const [memberAttr] = scimgateway.endpointMapper('outbound', 'members.value', config.map.group)
if (!memberAttr && attrObj.members) throw new Error(`${action} error: missing attribute mapping configuration for group members`)
const grp: any = { add: {}, remove: {} }
grp.add[memberAttr] = []
grp.remove[memberAttr] = []
for (let i = 0; i < attrObj?.members?.length; i++) {
const el = attrObj.members[i]
const dnObj = ldapEscDn(config.entity[baseEntity].ldap.isOpenLdap, el.value)
el.value = dnObj.toString()
if (el.operation && el.operation === 'delete') { // delete member from group
grp.remove[memberAttr].push(el.value) // endpointMapper returns URI encoded id because some IdP's don't encode id used in GET url e.g. Symantec/Broadcom/CA
} else { // add member to group
grp.add[memberAttr].push(el.value)
}
}
const method = 'modify'
let base = id // dn
try {
delete attrObj.members
const [endpointObj] = scimgateway.endpointMapper('outbound', attrObj, config.map.group)
const newDN = checkIfNewDN(baseEntity, base, 'group', attrObj, endpointObj)
if (newDN && config.entity[baseEntity].ldap.allowModifyDN) {
await doRequest(baseEntity, 'modifyDN', base, { modification: { newDN } }, ctx)
const getObj = { attribute: 'id', operator: 'eq', value: newDN.toString() }
const res = await scimgateway.getGroups(baseEntity, getObj, [], ctx)
return res // return full group object to avoid scimgateway doing same getUser() using original id/dn that now will fail
}
if (Object.keys(endpointObj).length > 0) {
const ldapOptions = {
operation: 'replace',
modification: endpointObj,
}
await doRequest(baseEntity, method, base, ldapOptions, ctx)
}
if (grp.add[memberAttr].length > 0) {
const ldapOptions = { // using ldap lookup (dn) instead of search
operation: 'add',
modification: grp.add,
}
await doRequest(baseEntity, method, base, ldapOptions, ctx)
}
if (grp.remove[memberAttr].length > 0) {
const ldapOptions = { // using ldap lookup (dn) instead of search
operation: 'delete',
modification: grp.remove,
}
await doRequest(baseEntity, method, base, ldapOptions, ctx)
}
return null
} catch (err: any) {
throw new Error(`${action} error: ${err.message}`)
}
}
// =================================================
// helpers
// =================================================
const _serviceClient: Record<string, any> = {}
//
// createAndFilter creates AndFilter object to be used as filter instead of standard string filter
// Using AndFilter object for eliminating internal ldapjs escaping problems related to values with some
// combinations of parentheses e.g. ab(c)d
// guidType undefined/base64/dn/dashed - if objectGUID/mS-DS-ConsistencyGuid included default is raw base64, but also support using dn or dashed 8-4-4-12
// return [filter, guidType]
const createAndFilter = (baseEntity: string, type: string, arrObj: any) => {
const objFilters: ldap.PresenceFilter[] | ldap.SubstringFilter[] = []
if (!Array.isArray(arrObj)) arrObj = []
arrObj.forEach((obj: any) => {
if (Object.keys(obj).length === 0) return
// objectGUID (mS-DS-ConsistencyGuid) and objectSid must be converted
if ((obj.attribute === 'objectGUID' || obj.attribute === 'mS-DS-ConsistencyGuid') && obj.value && obj.value !== '*') {
if (isBase64(obj.value)) obj.value = Buffer.from(obj.value, 'base64') // raw binary
} else if (obj.attribute === 'objectSid' && obj.value && obj.value !== '*') {
const s = convertStringToSid(obj.value)
if (s) obj.value = Buffer.from(obj.value, 'utf-8')
}
// add arrObj
if (obj.value.indexOf('*') > -1) { // SubstringFilter or PresenceFilter
const arr = obj.value.split('*')
if (arr.length === 2 && !arr[0] && !arr[1]) { // cn=*
const f = new ldap.PresenceFilter({ attribute: obj.attribute })
objFilters.push(f)
} else { // cn=ab*cd*e
const fObj: any = {
attribute: obj.attribute,
}
const arrAny: any = []
fObj.initial = arr[0]
if (!fObj.initial) delete fObj.initial
for (let i = 1; i < arr.length - 1; i++) {
arrAny.push(arr[i])
}
fObj.any = arrAny
if (arr[arr.length - 1]) {
fObj.final = arr[arr.length - 1]
}
const f = new ldap.SubstringFilter(fObj)
objFilters.push(f)
}
} else { // EqualityFilter cn=abc
const f = new ldap.EqualityFilter({ attribute: obj.attribute, value: obj.value })
objFilters.push(f)
}
})
// add from configuration objectClass and userFiter/groupFilter
switch (type) {
case 'user':
for (let i = 0; i < config.entity[baseEntity].ldap.userObjectClasses.length; i++) {
const f = new ldap.EqualityFilter({ attribute: 'objectClass', value: config.entity[baseEntity].ldap.userObjectClasses[i] })
objFilters.push(f)
}
if (config.entity[baseEntity].ldap.userFilter) {
try {
const uf = ldap.parseFilter(config.entity[baseEntity].ldap.userFilter)
objFilters.push(uf)
} catch (err: any) {
throw new Error(`configuration ldap.userFilter: ${config.entity[baseEntity].ldap.userFilter} - parseFilter error: ${err.message}`)
}
}
break
case 'group':
for (let i = 0; i < config.entity[baseEntity].ldap.groupObjectClasses.length; i++) {
const f = new ldap.EqualityFilter({ attribute: 'objectClass', value: config.entity[baseEntity].ldap.groupObjectClasses[i] })
objFilters.push(f)
if (config.entity[baseEntity].ldap.groupFilter) {
try {
const gf = ldap.parseFilter(config.entity[baseEntity].ldap.groupFilter)
objFilters.push(gf)
} catch (err: any) {
throw new Error(`configuration ldap.groupFilter: ${config.entity[baseEntity].ldap.groupFilter} - parseFilter error: ${err.message}`)
}
}
}
break
}
// put all into AndFilter
return new ldap.AndFilter({
filters: [
...objFilters,
],
})
}
//
// convertSidToString converts hex encoded object SID to a string
// e.g.
// input: 0105000000000005150000002ec85f9ed78d59fa176c9e9c7a040000
// output: S-1-5-21-2657077294-4200173015-2627628055-1146
// ref: https://gist.github.com/Krizzzn/0ae47f280cca9749c67759a9adedc015
//
const pad = function (s: any) { if (s.length < 2) { return `0${s}` } else { return s } }
const convertSidToString = (buf: any) => {
let asc: any, end: any
let i: number
if (buf == null) { return null }
const version = buf[0]
const subAuthorityCount = buf[1]
const identifierAuthority = parseInt(((() => {
const result: any = []
for (i = 2; i <= 7; i++) {
result.push(buf[i].toString(16))
}
return result
})()).join(''), 16)
let sidString = `S-${version}-${identifierAuthority}`
try {
for (i = 0, end = subAuthorityCount - 1, asc = end >= 0; asc ? i <= end : i >= end; asc ? i++ : i--) {
const subAuthOffset = i * 4
const tmp
= pad(buf[11 + subAuthOffset].toString(16))
+ pad(buf[10 + subAuthOffset].toString(16))
+ pad(buf[9 + subAuthOffset].toString(16))
+ pad(buf[8 + subAuthOffset].toString(16))
sidString += `-${parseInt(tmp, 16)}`
}
} catch (err) {
return null
}
return sidString
}
//
// convertStringToSid converts SID string to hex encoded object SID
// e.g.
// input: S-1-5-21-2127521184-1604012920-1887927527-72713
// output: 010500000000000515000000a065cf7e784b9b5fe77c8770091c0100
// ref: https://devblogs.microsoft.com/oldnewthing/20040315-00/?p=40253
//
const convertStringToSid = (sidStr: string) => {
const arr = sidStr.split('-')
if (arr.length !== 8) return null
try {
const b0 = 0x0100000000000000n // S-1 = 01
const b1 = 0x0005000000000000n // seven dashes, seven minus two = 5
const b2 = BigInt(arr[2]) // 0x5n
const b02 = b0 | b1 | b2 // big-endian
const bufBE = Buffer.alloc(8)
bufBE.writeBigUInt64BE(b02, 0)
let res = bufBE.toString('hex') // 0105000000000005
// rest is little-endian
const bufLE = Buffer.alloc(4)
for (let i = 3; i < arr.length; i++) {
const val = parseInt(arr[i], 10 >>> 0) // int32 to unsigned int
bufLE.writeUInt32LE(val, 0)
res += bufLE.toString('hex')
}
return res
} catch (err) {
return null
}
}
//
// getMemberOfGroups returns all groups the user is member of
// [{ id: <id-group>> , displayName: <displayName-group>, members [{value: <id-user>}] }]
//
const getMemberOfGroups = async (baseEntity: string, id: string, ctx: any) => {
const _action = 'getMemberOfGroups'
if (!config.map.group) throw new Error('missing configuration endpoint.map.group') // not using groups
let idDn = id
const attributes = ['id', 'displayName']
const [attrs, err] = scimgateway.endpointMapper('outbound', attributes, config.map.group) // SCIM/CustomSCIM => endpoint attribute naming
if (err) throw err
const [memberAttr, err1] = scimgateway.endpointMapper('outbound', 'members.value', config.map.group)
if (err1) throw err1
const method = 'search'
const scope = 'sub'
const base = config.entity[baseEntity].ldap.groupBase
const filter = createAndFilter(baseEntity, 'group', [{ attribute: memberAttr, value: idDn }])
const ldapOptions = {
filter,
scope,
attributes: attrs,
}
try {
const groups: any = await doRequest(baseEntity, method, base, ldapOptions, ctx)
return groups.map((grp: any) => {
return { // { id: <id-group>> , displayName: <displayName-group>, members [{value: <id-user>}] }
id: encodeURIComponent(grp[attrs[0]]), // not mandatory, but included anyhow
displayName: grp[attrs[1]], // displayName is mandatory
members: [{ value: encodeURIComponent(id) }], // only includes current user
}
})
} catch (err) {
const newErr = err
throw newErr
}
}
//
// ldapEscDn will escape DN according to the LDAP standard adjusted to ldapjs behavior
// using OpenLDAP, DN must be escaped - national characters and special ldap characters
// using Active Directory (none OpenLDAP), DN should not be escaped, but DN retrieved from AD is character escaped
//
const ldapEscDn = (isOpenLdap: any, str: string): ldap.DN => {
if (typeof str !== 'string' || str.length < 1) return new ldap.DN()
if (!isOpenLdap && str.indexOf('\\') > 0) {
const conv = str.replace(/\\([0-9A-Fa-f]{2})/g, (_, hex) => {
const intAscii = parseInt(hex, 16)
if (intAscii > 128) { // extended ascii - will be unescaped by decodeURIComponent
return '%' + hex
} else { // use character escape
return '\\' + String.fromCharCode(intAscii)
}
})
str = decodeURIComponent(conv)
str = str.replace(/\\/g, '') // lower ascii may be character escaped e.g. 'cn=Kürt\, Lastname' - see below comma logic
}
const arr = str.split(',')
for (let i = 0; i < arr.length; i++) { // CN=Firstname, Lastname,OU=...
if (!arr[i]) { // value having comma only
if (arr[i - 1].charAt(arr[i - 1].length - 1) === '\\') {
arr[i - 1] = arr[i - 1].substring(0, arr[i - 1].length - 1)
}
if (isOpenLdap) arr[i - 1] += '\\,'
else arr[i - 1] += ','
arr.splice(i, 1)
i -= 1
continue
}
const a = arr[i].split('=')
while (a.length > 2) { // 'uid=Firstname \= Lastname'
a[1] += '=' + a[2]
a.splice(2, 1)
}
if (a.length < 2 && i > 0) { // value having comma and content
if (arr[i - 1].charAt(arr[i - 1].length - 1) === '\\') {
arr[i - 1] = arr[i - 1].substring(0, arr[i - 1].length - 1)
}
if (isOpenLdap) arr[i - 1] += `\\,${ldapEsc(a[0])}`
else arr[i - 1] += `,${a[0]}`
arr.splice(i, 1)
i -= 1
continue
} else {
if (isOpenLdap) arr[i] = `${a[0]}=${ldapEsc(a[1])}`
else arr[i] = `${a[0]}=${a[1]}`
}
if (i > 0) break // only escape logic on first, assume sub OU's are correct
}
// Using dn object instead of string to ensure all escaping will be OK e.g. 'uid=test \= test,dc=example,dc=com'
// For non OpenLdap e.g., Active Directory, we must include BER encoding
const dn = new ldap.DN()
for (let i = 0; i < arr.length; i++) {
const a = arr[i].split('=') // cn=Kürt
while (a.length > 2) {
a[1] += '=' + a[2]
a.splice(2, 1)
}
if (a.length === 2) {
if (i === 0 && !isOpenLdap) {
const ua = new Uint8Array(Buffer.from(a[1], 'utf-8'))
const buf = Buffer.from(new Uint8Array([4, ua.length, ...ua]))
const rdn: any = {}
rdn[a[0]] = new BerReader(buf)
dn.push(new ldap.RDN(rdn))
// new BerReader(Buffer.from([0x04, 0x05, 0x4B, 0xc3, 0xbc, 0x72, 0x74])) // Kürt
// the leading 04 is the tag for "octet string" and the following 05 is the length in bytes of the string.
} else {
const rdn: any = {}
rdn[a[0]] = a[1]
dn.push(new ldap.RDN(rdn))
}
} else {
throw new Error('ldapEscDn() invalid DN: ' + str)
}
}
return dn
}
//
// ldapEsc will character escape str according to OpenLDAP DN standard
// Hex encoded escaping (extended and unicode ascii) is not included because
// automatically handled by ldapjs when not using BER encoded DN
//
const ldapEsc = (str: any) => {
if (!str) return str
let newStr = ''
for (let i = 0; i < str.length; i++) {
let c = str[i]
let isEsc = false
if (i > 0 && str[i - 1] === '\\') isEsc = true
switch (c) {
case ',':
if (isEsc) c = ','
else c = '\\,'
break
case ';':
if (isEsc) c = ';'
else c = '\\;'
break
case '+':
if (isEsc) c = '+'
else c = '\\+'
break
case '<':
if (isEsc) c = '<'
else c = '\\<'
break
case '>':
if (isEsc) c = '>'
else c = '\\>'
break
case '=':
if (isEsc) c = '='
else c = '\\='
break
case '"':
if (isEsc) c = '"'
else c = '\\"'
break
case '(':
if (isEsc) c = '('
else c = '\\('
break
case ')':
if (isEsc) c = ')'
else c = '\\)'
break
case '#':
if (isEsc) c = '#'
else c = '\\#'
break
}
newStr += c
}
return newStr
}
//
// berDecodeDn decodes a BER string part of type DN
// OU=#04057573657273,OU=abc,... => OU=users,OU=abc,...
// only using BER on first part of dn
// Having BER decoding for Active Directory, but not for OpenLDAP
//
const berDecodeDn = (dn: any) => {
if (Object.prototype.toString.call(dn) !== '[object LdapDn]') return dn // OpenLDAP
const str = dn.toString()
if (str.indexOf('#') < 1) return str
const arr = str.split('#')
if (arr.length === 2) {
const a = arr[1].split(',')
if (a.length > 1) {
const berStr = a[0].substring(4)
let decoded = ''
let c = ''
if (berStr.length % 2 === 0) {
for (let i = 0; i < berStr.length; i++) {
c += berStr[i]
if (c.length === 2) {
const intAscii = parseInt(c, 16)
decoded += String.fromCharCode(intAscii)
c = ''
}
}
}
if (decoded.length > 0) {
// convert any extended ascii to utf8
const isoBytes = Uint8Array.from(decoded, c => c.charCodeAt(0))
decoded = new TextDecoder('utf-8').decode(isoBytes)
decoded = decoded.replace(/,/g, '\\,')
a.splice(0, 1) // remove element 0 from array
return `${arr[0]}${decoded},${a.join(',')}` // OU=users,OU=abc
}
}
}
return str
}
const getNamingAttribute = (baseEntity: string, type: string) => {
let arr
switch (type) {
case 'user':
arr = config.entity[baseEntity]?.ldap?.namingAttribute?.user
break
case 'group':
arr = config.entity[baseEntity]?.ldap?.namingAttribute?.group
break
default:
throw new Error(`getNamingAttribute error: invalid type ${type}`)
}
if (!Array.isArray(arr) || arr.length !== 1) throw new Error(`configuration missing namingAttribute definition for ${type}`)
return [arr[0].attribute, arr[0].mapTo]
}
const checkIfNewDN = (baseEntity: string, base: any, type: string, obj: any, endpointObj: any) => {
if (config.entity[baseEntity].ldap.allowModifyDN !== true) return ''
if (typeof obj !== 'object' || Object.keys(obj).length < 1) return ''
if (typeof endpointObj !== 'object' || Object.keys(endpointObj).length < 1) return ''
if (endpointObj.dn && endpointObj.dn.toLowerCase() !== base.toLowerCase()) return endpointObj.dn // special
const namingAttr = base.split('=')[0].toLowerCase() // cn
let scimAttr = ''
if (endpointObj[namingAttr]) { // naming attribute can't be modified, have to use modifyDN()
delete endpointObj[namingAttr] // modifying original ldapOptions
if (config.map[type] && config.map[type][namingAttr]) {
scimAttr = config.map[type][namingAttr].mapTo
}
if (!config.entity[baseEntity].ldap.allowModifyDN) {
throw new Error(`changing ldap Naming Attribute ${namingAttr}/${scimAttr} requires configuration ldap.allowModifyDN=true`)
}
}
if (!scimAttr) { // check if namingAttr is defined as namingAttribute configuration having linked scimAttr
const [nAttr, sAttr] = getNamingAttribute(baseEntity, type) // ['cn', 'userName']
if (namingAttr === nAttr) scimAttr = sAttr
}
if (!scimAttr) return ''
// find and return the new DN
let newNamingValue
const arr = scimAttr.split('.')
if (arr.length < 2) {
if (obj[scimAttr]) newNamingValue = obj[scimAttr]
} else {
if (obj[arr[0]] && obj[arr[0]][arr[1]]) newNamingValue = obj[arr[0]][arr[1]]
}
if (!newNamingValue) return ''
const re = '^([a-zA-Z]+=)(.*?)(?=,[a-zA-Z]+=|$)(.*)$'
const rePattern = new RegExp(re, 'i')
const a = base.match(rePattern)
/*
a[1] 'CN='
a[2] '<value>'
a[3] '<rest> e.g.,: ,OU=mycompany,OU=com'
*/
if (a.length !== 4) return ''
if (a[1].toLowerCase() !== namingAttr.toLowerCase() + '=') return ''
if (a[2] === newNamingValue) return ''
let newDN = a[1] + newNamingValue + a[3]
return newDN
}
//
// getCtxAuth returns username/secret from ctx header when using Auth PassThrough
//
const getCtxAuth = (ctx: any) => {
if (!ctx?.request?.header?.authorization) return []
const [authType, authToken] = (ctx.request.header.authorization || '').split(' ') // [0] = 'Basic' or 'Bearer'
let username, password
if (authType === 'Basic') [username, password] = (Buffer.from(authToken, 'base64').toString() || '').split(':')
if (username) return [username, password] // basic auth
else return [undefined, authToken] // bearer auth
}
//
// getServiceClient returns LDAP client used by doRequest
//
const getServiceClient = async (baseEntity: string, ctx: any) => {
const action = 'getServiceClient'
if (!config.entity[baseEntity].baseUrl) config.entity[baseEntity].baseUrl = config.entity[baseEntity].baseUrls[0] // failover logic also updates baseUrl
if (!_serviceClient