scimgateway
Version:
Using SCIM protocol as a gateway for user provisioning to other endpoints
882 lines (840 loc) • 158 kB
text/typescript
// =================================================================================
// File: scimgateway.js
//
// Author: Jarle Elshaug
//
// Purpose: Started by endpoint plugin
// Listens and replies on incoming SCIM requests
// Optional SCIM Stream subscriber/publisher
// =================================================================================
import { createServer as httpCreateServer } from 'node:http'
import { createServer as httpsCreateServer } from 'node:https'
import { type IncomingMessage, type ServerResponse } from 'node:http'
import { createChecker } from 'is-in-subnet'
import { BearerStrategy, type IBearerStrategyOptionWithRequest } from 'passport-azure-ad'
import { fileURLToPath } from 'node:url'
import { Logger } from './logger.ts'
import passport from 'passport'
import dot from 'dot-object'
import nodemailer from 'nodemailer'
import fs from 'node:fs'
import path from 'node:path'
import * as jwt from 'jsonwebtoken'
import * as utils from './utils.ts'
import * as utilsScim from './utils-scim.ts'
import * as stream from './scim-stream.js'
export * from './helper-rest.ts'
import { HelperRest } from './helper-rest.ts'
export class ScimGateway {
private config: any
private logger: any
private gwName: string
private scimDef: any
private countries: any
private multiValueTypes: any
private getMemberOf: any
private getAppRoles: any
private pub: any
// @ts-expect-error: has no initializer
private helperRest: HelperRest
/** pluginName is the name of plugin e.g., plugin-loki */
readonly pluginName: string
/** configDir is full path to plugin ./config directory */
readonly configDir: string
/** configFile is full path to plugin configuration file */
readonly configFile: string
/**
* authPassThroughAllowed can be set by plugin for enabling Auth PassThrough
* Set to true will allow plugin to pass the ctx.request.headers.authorization as authorization
* header in the communication with endpoint
*/
authPassThroughAllowed: boolean
//
// plugin methods
//
/**
* getUsers method is defined at the plugin and should return users from endpoint according to getObj (rawFilter) and attributes parameter - if getObj.operator and getObj.rawFilter not defined, all users should be returned
* @param baseEntity used for multi tenant or multi endpoint support, either "undefined" or set by request url e.g., http://localhost:8880/loki2/Users gives baseEntity=loki2
* @param getObj
* ```
* {
* "attribute": "<>",
* "operator": "<>",
* "value": "<>",
* "rawFilter": "<>",
* "startIndex": <undefined | number>,
* "count": <undefined | number>
* }
* ```
* **attribute**, **operator** and **value** are included when using "simpel filtering", e.g.: `{ "attribute": "userName", "operator": "eq", "value": "bjensen" }`
* **rawFilter** is original query filter e.g., `{ "rawFilter": "userName eq \"bjensen\"" }`
* **startIndex** paging, is the beginning index and count for the resources on the page
* **count** paging, is the desired maximum number of query results per page
* @param attributes array of attributes to be returned - if empty, all supported attributes should be returned. All attributes may also be returned regardless of attributes parameter, scimgateway will do final filtering
* @param ctx if plugin authPassThroughAllowed is set to true, ctx contains authorization header `{ "headers": { "authorization": "<value>" } }` that can be used in the communication with endpoint, something that is included when using HelperRest
* @returns
* ```
* {
* Resources: [<list of user objects>],
* totalResults: <null | number> // number is total number of endpoint objects when using paging (startIndex/count) - if unknown, we might set a high number to ensure getting new paging request (scimgateway have logic for final page)
* }
* ```
* could return all supported attributes having **id** and **userName** as mandatory, scimgateway will do final filtering e.g.:
* ```
* {
* Resources: [
* {"id": "bjensen", "userName": "bjensen"},
* {"id":"jsmith", "userName":"jsmith"}
* ]
* }
* ```
* @remarks if all attributes are supposed to be returned (or should include groups) and returned result do not include user groups,
* scimgateway will do additional getGroups() request for each user object for including groups. If groups are not supported or we do
* not want getGroups() requests, user object should include `{ "groups": [] }`
* @remarks the value of returned 'id' will be used as 'id' in modifyUser and deleteUser
*/
getUsers!: (baseEntity: string, getObj: Record<string, any>, attributes: Array<string>, ctx?: undefined | Record<string, any>) => any
/**
* createUser method is defined at the plugin and should create user at endpoint
* @param baseEntity used for multi tenant or multi endpoint support, either "undefined" or set by request url e.g., http://localhost:8880/loki2/Users gives baseEntity=loki2
* @param userObj
*
* ```
* {
* "userName": "<unique on both IdP and endpoint>", // userName or externalId always included
* "<attribute>": <value>,
* ...
* }
* ```
* @param ctx if plugin authPassThroughAllowed is set to true, ctx contains authorization header `{ "headers": { "authorization": "<value>" } }` that can be used in the communication with endpoint, something that is included when using HelperRest
* @returns
* {
* "id": "<unique endpoint id>" // if id not included or not returning an object, scimgateway will do an additional getUsers() for retrieving user's id
* }
* ```
* @remarks
* ```js
* catch (err: any) {
* const newErr = new Error(`${action} error: ${err.message}`)
* if (err.message && err.message.startsWith('Duplicate key')) {
* newErr.name += '#409' // customErrorCode
* }
* throw newErr
* }
* ```
* if user already exist, an error should be thrown that includes suffix `#<code>` to the err.name having `<code>` set to 409 that indicates duplicate key
*/
createUser!: (baseEntity: string, userObj: Record<string, any>, ctx?: undefined | Record<string, any>) => any
/**
* deleteUser method is defined at the plugin and should delete user at endpoint
* @param baseEntity used for multi tenant or multi endpoint support, either "undefined" or set by request url e.g., http://localhost:8880/loki2/Users gives baseEntity=loki2
* @param id unique user id at endpoint
* @param ctx if plugin authPassThroughAllowed is set to true, ctx contains authorization header `{ "headers": { "authorization": "<value>" } }` that can be used in the communication with endpoint, something that is included when using HelperRest
* @returns null | throw error
*/
deleteUser!: (baseEntity: string, id: string, ctx?: undefined | Record<string, any>) => any
/**
* modifyUser method is defined at the plugin and should modify user at endpoint based on attrObj parameter
* @param baseEntity used for multi tenant or multi endpoint support, either "undefined" or set by request url e.g., http://localhost:8880/loki2/Users gives baseEntity=loki2
* @param id unique user id at endpoint
* @param attrObj object having user attributes to be modified
* @param ctx if plugin authPassThroughAllowed is set to true, ctx contains authorization header `{ "headers": { "authorization": "<value>" } }` that can be used in the communication with endpoint, something that is included when using HelperRest
* @returns null | throw error
*/
modifyUser!: (baseEntity: string, id: string, attrObj: Record<string, any>, ctx?: undefined | Record<string, any>) => any
/**
* getGroups method is defined at the plugin and should return groups from endpoint according to getObj (rawFilter) and attributes parameter - if getObj.operator and getObj.rawFilter not defined, all groups should be returned
* @param baseEntity used for multi tenant or multi endpoint support, either "undefined" or set by request url e.g., http://localhost:8880/loki2/Groups gives baseEntity=loki2
* @param getObj
* ```
* {
* "attribute": "<>",
* "operator": "<>",
* "value": "<>",
* "rawFilter": "<>",
* "startIndex": <undefined | number>,
* "count": <undefined | number>
* }
* ```
* **attribute**, **operator** and **value** are included when using "simpel filtering", e.g.: `{ "attribute": "displayName", "operator": "eq", "value": "Admins" }`
* **rawFilter** is original query filter e.g., `{ "rawFilter": "displayName eq \"Admins\"" }`
* **startIndex** paging, is the beginning index and count for the resources on the page
* **count** paging, is the desired maximum number of query results per page
* @param attributes array of attributes to be returned - if empty, all supported attributes should be returned. All attributes may also be returned regardless of attributes parameter, scimgateway will do final filtering
* @param ctx if plugin authPassThroughAllowed is set to true, ctx contains authorization header `{ "headers": { "authorization": "<value>" } }` that can be used in the communication with endpoint, something that is included when using HelperRest
* @returns
* ```
* {
* Resources: [<list of group objects>],
* totalResults: <null | number> // number is total number of endpoint objects when using paging (startIndex/count) - if unknown, we might set a high number to ensure getting new paging request (scimgateway have logic for final page)
* }
* ```
* could return all supported attributes having **id** and **displayName** as mandatory, scimgateway will do final filtering e.g.:
* ```
* {
* Resources: [
* {"id": "Admins", "displayName": "Admins","members":[{"value":"bjensen"}]},
* {"id":"Employees", "userName":"Employees","members":[{"value":"jsmith"}]}
* ]
* }
* ```
* @remarks the value of returned 'id' will be used as 'id' in modifyGroup and deleteGroup
*/
getGroups!: (baseEntity: string, getObj: Record<string, any>, attributes: Array<string>, ctx?: undefined | Record<string, any>) => any
/**
* createGroup method is defined at the plugin and should create group at endpoint
* @param baseEntity used for multi tenant or multi endpoint support, either "undefined" or set by request url e.g., http://localhost:8880/loki2/Users gives baseEntity=loki2
* @param userObj
*
* ```
* {
* "displayName": "<unique on both IdP and endpoint>", // displayName always included
* "<attribute>": <value>,
* ...
* }
* ```
* @param ctx if plugin authPassThroughAllowed is set to true, ctx contains authorization header `{ "headers": { "authorization": "<value>" } }` that can be used in the communication with endpoint, something that is included when using HelperRest
* @returns
* {
* "id": "<unique endpoint id>" // if id not included or not returning an object, scimgateway will do an additional getGroups() for retrieving group id
* }
* ```
* @remarks
* ```js
* catch (err: any) {
* const newErr = new Error(`${action} error: ${err.message}`)
* if (err.message && err.message.startsWith('Duplicate key')) {
* newErr.name += '#409' // customErrorCode
* }
* throw newErr
* }
* ```
* if group already exist, an error should be thrown that includes suffix `#<code>` to the err.name having `<code>` set to 409 that indicates duplicate key
*/
createGroup!: (baseEntity: string, groupObj: Record<string, any>, ctx?: undefined | Record<string, any>) => any
/**
* deleteGroup method is defined at the plugin and should should delete group at endpoint
* @param baseEntity used for multi tenant or multi endpoint support, either "undefined" or set by request url e.g., http://localhost:8880/loki2/Users gives baseEntity=loki2
* @param id unique group id at endpoint
* @param ctx if plugin authPassThroughAllowed is set to true, ctx contains authorization header `{ "headers": { "authorization": "<value>" } }` that can be used in the communication with endpoint, something that is included when using HelperRest
* @returns null | throw error
*/
deleteGroup!: (baseEntity: string, id: string, ctx?: undefined | Record<string, any>) => any
/**
* modifyGroup method is defined at the plugin and should modify group at endpoint based on attrObj parameter
* @param baseEntity used for multi tenant or multi endpoint support, either "undefined" or set by request url e.g., http://localhost:8880/loki2/Users gives baseEntity=loki2
* @param id unique user id at endpoint
* @param attrObj
* ```
* {
* "members": [
* { "value": "jsmith" }, // user having id=jsmith should be assigned to group
* {"operation":"delete","value":"bjensen"} // user having id=bjensen shoud be revoked from group
* ]
* }
* ```
* attrObj contains group attributes to be modified
* @param ctx if plugin authPassThroughAllowed is set to true, ctx contains authorization header `{ "headers": { "authorization": "<value>" } }` that can be used in the communication with endpoint, something that is included when using HelperRest
* @returns null | throw error
*/
modifyGroup!: (baseEntity: string, id: string, attrObj: Record<string, any>, ctx?: undefined | Record<string, any>) => any
/** getServicePlans is used by plugin-entra for retrieving Entra ID license plans */
getServicePlans!: (baseEntity: string, getObj: Record<string, any>, attributes: Array<string>, ctx?: undefined | Record<string, any>) => any
/**
* postApi method is defined at the plugin and should handle incoming `"POST /api"` for creating an object and should be used according to your needs
* @param baseEntity used for multi tenant or multi endpoint support, either "undefined" or set by request url e.g., http://localhost:8880/loki2/Users gives baseEntity=loki2
* @param apiObj is POST body and contains object to be created
* @param ctx if plugin authPassThroughAllowed is set to true, ctx contains authorization header `{ "headers": { "authorization": "<value>" } }` that can be used in the communication with endpoint, something that is included when using HelperRest
* @returns according to your needs
* @example
* POST http://localhost:8890/api
* body = {"title":"BMW X5","price":58}
*/
postApi!: (baseEntity: string, apiObj: any, ctx?: undefined | Record<string, any>) => any
/**
* putApi method is defined at the plugin and should handle incoming `"PUT /api/<id>"` for replacing an object and should be used according to your needs
* @param baseEntity used for multi tenant or multi endpoint support, either "undefined" or set by request url e.g., http://localhost:8880/loki2/Users gives baseEntity=loki2
* @param id unique object id
* @param apiObj is PUT body and contains the new replaced object
* @param ctx if plugin authPassThroughAllowed is set to true, ctx contains authorization header `{ "headers": { "authorization": "<value>" } }` that can be used in the communication with endpoint, something that is included when using HelperRest
* @returns according to your needs
* @example
* PUT http://localhost:8890/api/100
* body = {"title":"BMW X1","price":21}
*/
putApi!: (baseEntity: string, id: string, apiObj: any, ctx?: undefined | Record<string, any>) => any
/**
* patchApi method is defined at the plugin and should handle incoming `"PATCH /api/<id>"` for modifying an object and should be used according to your needs
* @param baseEntity used for multi tenant or multi endpoint support, either "undefined" or set by request url e.g., http://localhost:8880/loki2/Users gives baseEntity=loki2
* @param id unique object id
* @param apiObj is PATCH body and contains attributes to be modified
* @param ctx if plugin authPassThroughAllowed is set to true, ctx contains authorization header `{ "headers": { "authorization": "<value>" } }` that can be used in the communication with endpoint, something that is included when using HelperRest
* @returns according to your needs
* @example
* PATCH http://localhost:8890/api/100
* body = {"title":"BMW X3"}
*/
patchApi!: (baseEntity: string, id: string, apiObj: any, ctx?: undefined | Record<string, any>) => any
/**
* getApi method is defined at the plugin and should handle incoming `"GET /api/<query>"` for retrieving one or more objects and should be used according to your needs
* @param baseEntity used for multi tenant or multi endpoint support, either "undefined" or set by request url e.g., http://localhost:8880/loki2/Users gives baseEntity=loki2
* @param id <undefined | unique object id> // if undefined all objects should be retrived
* @param apiQuery is url querystring
* @param ctx if plugin authPassThroughAllowed is set to true, ctx contains authorization header `{ "headers": { "authorization": "<value>" } }` that can be used in the communication with endpoint, something that is included when using HelperRest
* @returns according to your needs
* @examples
* GET http://localhost:8890/api
* GET http://localhost:8890/api/100
*/
getApi!: (baseEntity: string, id: string, apiQuery: any, apiObj: any, ctx?: undefined | Record<string, any>) => any
/**
* deleteApi method is defined at the plugin and should handle incoming `"DELETE /api/<id>"` for deleting an objects and should be used according to your needs
* @param baseEntity used for multi tenant or multi endpoint support, either "undefined" or set by request url e.g., http://localhost:8880/loki2/Users gives baseEntity=loki2
* @param id unique object id
* @param ctx if plugin authPassThroughAllowed is set to true, ctx contains authorization header `{ "headers": { "authorization": "<value>" } }` that can be used in the communication with endpoint, something that is included when using HelperRest
* @returns according to your needs
* @example
* DELETE http://localhost:8890/api/100
*/
deleteApi!: (baseEntity: string, id: string, ctx?: undefined | Record<string, any>) => any
constructor() {
const funcHandler: any = {}
let requester: string = ''
{
let _prepareStackTrace = Error.prepareStackTrace
Error.prepareStackTrace = (_, stack) => {
return stack.map((callSite) => {
return callSite.getFileName()
})
}
const e = new Error()
requester = e.stack?.[1] || ''
try { // node.js using url-path win: file:///path - linux: file://path
requester = fileURLToPath(requester)
} catch (err) { void 0 }
Error.prepareStackTrace = _prepareStackTrace
}
let pluginName = path.basename(requester)
pluginName = pluginName.substring(0, pluginName.lastIndexOf('.')) || pluginName
let pluginDir = path.dirname(requester)
let configDir = path.join(pluginDir, '..', 'config')
if (pluginDir.includes('BUN/root')) {
// running compiled binary, binary name will be pluginName
// bun build index.ts --target bun --compile --outfile plugin-xxx
// we then need: ./plugin-xxx and ./config/plugin-xxx.json
pluginDir = '.' // only support running binary in current directory (path to binary can't be found)
configDir = './config'
}
const configFile = path.join(`${configDir}`, `${pluginName}.json`) // config name prefix same as pluging name prefix
const gwName = path.basename(fileURLToPath(import.meta.url)).split('.')[0] // prefix of current file - using fileURLToPath because using "__filename" is not supported by nodejs typescript
const gwPath = path.dirname(fileURLToPath(import.meta.url))
const logDir = path.join(pluginDir, '..', 'logs')
this.config = {}
// exposed outside class
this.pluginName = pluginName
this.configDir = configDir
this.configFile = configFile
this.countries = (() => {
try {
return JSON.parse(fs.readFileSync(path.join(gwPath, 'countries.json')).toString())
} catch (err) {
return []
}
})()
let found: Record<string, any> = {}
let configErr: any
try {
this.config = JSON.parse(fs.readFileSync(configFile, 'utf-8'))
found = this.processConfig()
} catch (err) { configErr = err }
const logger = new Logger(
pluginName,
{
type: 'console',
level: 'info', // will be set according to config during startup
customMasking: this.config?.scimgateway?.log?.customMasking,
colorize: this.config?.scimgateway?.log?.colorize,
},
{
type: 'file',
level: this.config?.scimgateway?.log?.loglevel?.file,
customMasking: this.config?.scimgateway?.log?.customMasking,
logDir,
logFileName: pluginName + '.log',
maxSize: this.config?.scimgateway?.log?.maxSize,
maxFiles: this.config?.scimgateway?.log?.maxFiles,
},
)
if (configErr) {
logger.error(`${gwName}[${pluginName}] ${configErr.message}`)
logger.error(`${gwName}[${pluginName}] stopping...`)
throw (new Error('Using exception to stop further asynchronous code execution (ensure synchronous logger flush to logfile and exit program), please ignore this one...'))
}
this.logger = logger
// exposed to plugin
this.gwName = gwName
this.pluginName = pluginName
this.configDir = configDir
this.configFile = configFile
this.authPassThroughAllowed = false // set to true by plugin if using Auth PassThrough
const oAuthTokenExpire = 3600 // seconds
let pwErrCount = 0
let isMailLock = false
let ipAllowListChecker: any
let server: any
if (!this.config) this.config = {}
if (!this.config.scimgateway.scim) this.config.scimgateway.scim = {}
if (!this.config.scimgateway.log) this.config.scimgateway.log = {}
if (!this.config.scimgateway.log.loglevel) this.config.scimgateway.log.loglevel = {}
if (!this.config.scimgateway.auth) this.config.scimgateway.auth = {}
if (!this.config.scimgateway.auth.basic) this.config.scimgateway.auth.basic = []
if (!this.config.scimgateway.auth.bearerToken) this.config.scimgateway.auth.bearerToken = []
if (!this.config.scimgateway.auth.bearerJwt) this.config.scimgateway.auth.bearerJwt = []
if (!this.config.scimgateway.auth.bearerJwtAzure) this.config.scimgateway.auth.bearerJwtAzure = []
if (!this.config.scimgateway.auth.bearerOAuth) this.config.scimgateway.auth.bearerOAuth = []
if (!this.config.scimgateway.auth.passThrough) this.config.scimgateway.auth.passThrough = {}
this.config.scimgateway.auth.oauthTokenStore = {}
if (!this.config.scimgateway.certificate) this.config.scimgateway.certificate = {}
if (!this.config.scimgateway.certificate.pfx) this.config.scimgateway.certificate.pfx = {}
if (!this.config.scimgateway.email) this.config.scimgateway.email = {}
if (!this.config.scimgateway.email.auth) this.config.scimgateway.email.auth = {}
if (!this.config.scimgateway.email.auth.options) this.config.scimgateway.email.auth.options = {}
if (!this.config.scimgateway.email.emailOnError) this.config.scimgateway.email.emailOnError = {}
if (!this.config.scimgateway.email.emailOnError) this.config.scimgateway.email.proxy = {}
if (!this.config.scimgateway.stream) this.config.scimgateway.stream = {}
if (!this.config.scimgateway.stream.subscriber) this.config.scimgateway.stream.subscriber = {}
if (!this.config.scimgateway.stream.publisher) this.config.scimgateway.stream.publisher = {}
// start - legacy support
if (this.config.scimgateway?.emailOnError?.smtp?.host) {
this.config.scimgateway.email.auth.options.host = this.config.scimgateway.emailOnError.smtp.host
}
if (this.config.scimgateway?.emailOnError?.smtp?.port) {
this.config.scimgateway.email.auth.options.port = this.config.scimgateway.emailOnError.smtp.port
}
if (this.config.scimgateway?.emailOnError?.smtp?.proxy) {
this.config.scimgateway.email.proxy = this.config.scimgateway.emailOnError.smtp.proxy
}
if (this.config.scimgateway?.emailOnError?.smtp?.username) {
this.config.scimgateway.email.emailOnError.from = this.config.scimgateway.emailOnError.smtp.username
this.config.scimgateway.email.auth.options.username = this.config.scimgateway.emailOnError.smtp.username
}
if (this.config.scimgateway?.emailOnError?.smtp?.password) {
this.config.scimgateway.email.auth.options.password = this.config.scimgateway.emailOnError.smtp.password
this.config.scimgateway.email.auth.type = 'smtp'
}
if (this.config.scimgateway?.emailOnError?.smtp?.enabled) {
this.config.scimgateway.email.emailOnError.enabled = this.config.scimgateway.emailOnError.smtp.enabled
}
if (this.config.scimgateway?.emailOnError?.smtp?.sendInterval) {
this.config.scimgateway.email.emailOnError.sendInterval = this.config.scimgateway.emailOnError.smtp.sendInterval
}
if (this.config.scimgateway?.emailOnError?.smtp?.subject) {
this.config.scimgateway.email.emailOnError.subject = this.config.scimgateway.emailOnError.smtp.subject
}
if (this.config.scimgateway?.emailOnError?.smtp?.to) {
this.config.scimgateway.email.emailOnError.to = this.config.scimgateway.emailOnError.smtp.to
}
if (this.config.scimgateway?.emailOnError?.smtp?.cc) {
this.config.scimgateway.email.emailOnError.cc = this.config.scimgateway.emailOnError.smtp.cc
}
// end - legacy support
if (this.config.scimgateway.ipAllowList && Array.isArray(this.config.scimgateway.ipAllowList) && this.config.scimgateway.ipAllowList.length > 0) {
ipAllowListChecker = createChecker(this.config.scimgateway.ipAllowList)
}
const handler: { [key: string]: any } = {}
handler.Users = handler.users = {
description: 'User',
getMethod: 'getUsers',
modifyMethod: 'modifyUser',
createMethod: 'createUser',
deleteMethod: 'deleteUser',
}
handler.Groups = handler.groups = {
description: 'Group',
getMethod: 'getGroups',
modifyMethod: 'modifyGroup',
createMethod: 'createGroup',
deleteMethod: 'deleteGroup',
}
handler.servicePlans = handler.serviceplans = { // plugin-entra
description: 'ServicePlan',
getMethod: 'getServicePlans',
}
handler.AppRoles = handler.approles = { // scim-stream
description: 'AppRoles',
getMethod: 'getAppRoles',
}
/** handlers supported url paths */
const handlers = ['users', 'groups', 'serviceplans', 'approles', 'api', 'schemas', 'serviceproviderconfig', 'serviceproviderconfigs', 'logger']
try {
if (!fs.existsSync(configDir + '/wsdls')) fs.mkdirSync(configDir + '/wsdls')
if (!fs.existsSync(configDir + '/certs')) fs.mkdirSync(configDir + '/certs')
if (!fs.existsSync(configDir + '/schemas')) fs.mkdirSync(configDir + '/schemas')
} catch (err) { void 0 }
let isScimv2 = false
if (this.config.scimgateway.scim.version === '2.0' || this.config.scimgateway.scim.version === 2) {
this.scimDef = (() => {
try {
return JSON.parse(fs.readFileSync(path.join(pluginDir, 'scimdef-v2.json')).toString()) // using custom
} catch (err) {
return JSON.parse(fs.readFileSync(path.join(gwPath, 'scimdef-v2.json')).toString())
}
})()
isScimv2 = true
} else {
this.scimDef = (() => {
try {
return JSON.parse(fs.readFileSync(path.join(pluginDir, 'scimdef-v1.json')).toString()) // using custom
} catch (err) {
return JSON.parse(fs.readFileSync(path.join(gwPath, 'scimdef-v1.json')).toString())
}
})()
}
if (this.config.scimgateway.scim.customSchema) { // legacy - merge plugin custom schema extension into core schemas
let custom
try {
custom = JSON.parse(fs.readFileSync(`${configDir}/schemas/${this.config.scimgateway.scim.customSchema}`, 'utf8'))
} catch (err: any) {
throw new Error(`failed reading file defined in configuration "scim.customSchema": ${err.message}`)
}
if (!Array.isArray(custom)) custom = [custom]
const schemas = ['User', 'Group']
let customMerged = false
for (let i = 0; i < schemas.length; i++) {
const schema = this.scimDef.Schemas.Resources.find((el: Record<string, any>) => el.name === schemas[i])
const customSchema = custom.find((el: Record<string, any>) => el.name === schemas[i])
if (schema && customSchema && Array.isArray(customSchema.attributes)) {
const arr1 = schema.attributes // core:1.0/2.0 schema
const arr2 = customSchema.attributes
schema.attributes = arr2.filter((arr2Obj: Record<string, any>) => { // only merge attributes (objects) having unique name into core schema
if (!arr1.some((arr1Obj: Record<string, any>) => arr1Obj.name === arr2Obj.name)) {
customMerged = true
if (!isScimv2) arr2Obj.schema = 'urn:scim:schemas:core:1.0'
return arr2Obj
}
return undefined
}).concat(arr1)
}
}
if (!customMerged) {
const err = [
'No custom SCIM schema attributes have been merged. Make sure using correct format e.g. ',
'[{"name": "User", "attributes" : [...]}]. ',
'Also make sure attribute names in attributes array do not conflict with core:1.0/2.0 SCIM attribute names',
].join()
throw new Error(err)
}
}
// multiValueTypes array contains attributes that will be used by "type converted objects" logic
// groups, roles, and members are excluded
// default: ['emails','phoneNumbers','ims','photos','addresses','entitlements','x509Certificates']
// configuration skipTypeConvert = true disables logic by empty multiValueTypes array
if (this.config.scimgateway.scim.skipTypeConvert === true) this.multiValueTypes = []
else {
this.multiValueTypes = utilsScim.getMultivalueTypes('User', this.scimDef) // not icluding 'Group' => 'members' are excluded
for (let i = 0; i < this.multiValueTypes.length; i++) {
if (this.multiValueTypes[i] === 'groups' || this.multiValueTypes[i] === 'roles' || this.multiValueTypes[i] === 'members') {
this.multiValueTypes.splice(i, 1) // delete
i -= 1
}
}
}
const logResult = async (ctx: Context) => {
if (ctx.path === '/ping' || ctx.path === '/favicon.ico') return
const ellapsed = performance.now() - ctx.perfStart
let userName
const [authType, authToken] = (ctx.request.headers.get('authorization') || '').split(' ') // [0] = 'Basic' or 'Bearer'
if (authType === 'Basic') [userName] = (Buffer.from(authToken, 'base64').toString() || '').split(':')
if (!userName && authType === 'Bearer') userName = 'token'
let outbound = ctx.response.body
if (typeof outbound === 'string' && outbound.length > 1000) {
outbound = outbound.slice(0, 1000)
outbound += '...truncated because of length'
}
if (ctx.response.status && (ctx.response.status < 200 || ctx.response.status > 299)) {
if (ctx.response.status === 404) logger.warn(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] ${ellapsed} ${ctx.ip} ${userName} ${ctx.response.status} ${ctx.request.method} ${ctx.request.url} Inbound=${JSON.stringify(ctx.request.body)} Outbound=${outbound}`)
else logger.error(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] ${ellapsed} ${ctx.ip} ${userName} ${ctx.response.status} ${ctx.request.method} ${ctx.request.url} Inbound=${JSON.stringify(ctx.request.body)} Outbound=${outbound}`)
} else logger.info(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] ${ellapsed} ${ctx.ip} ${ctx.response.status} ${userName} ${ctx.request.method} ${ctx.request.url} Inbound=${JSON.stringify(ctx.request.body)} Outbound=${outbound}`)
}
// start auth methods - used by auth
const basic = async (baseEntity: string, method: string, authType: string, authToken: string, path: string): Promise<boolean> => {
return await new Promise((resolve, reject) => { // basic auth
if (authType !== 'Basic') resolve(false)
if (!found.Basic) resolve(false)
if (found.PassThrough && this.authPassThroughAllowed && !path.endsWith('/logger')) resolve(false) // Auth PassThrough browser logon dialog support
const [userName, userPassword] = (Buffer.from(authToken, 'base64').toString() || '').split(':')
if (!userName || !userPassword) {
return reject(new Error(`authentication failed for user ${userName}`))
}
const arr = this.config.scimgateway.auth.basic
for (let i = 0; i < arr.length; i++) {
if (arr[i].username === userName && arr[i].password === userPassword) { // authentication OK
if (arr[i].baseEntities) {
if (Array.isArray(arr[i].baseEntities) && arr[i].baseEntities.length > 0) {
if (!baseEntity) return reject(new Error(`baseEntity=${baseEntity} not allowed for user ${arr[i].username} according to basic configuration baseEntitites=${arr[i].baseEntities}`))
if (!arr[i].baseEntities.includes(baseEntity)) return reject(new Error(`baseEntity=${baseEntity} not allowed for user ${arr[i].username} according to basic configuration baseEntitites=${arr[i].baseEntities}`))
}
}
if (arr[i].readOnly === true && method !== 'GET') return reject(new Error(`only allowing readOnly for user ${arr[i].username} according to basic configuration readOnly=true`))
return resolve(true)
}
}
reject(new Error(`authentication failed for user ${userName}`))
})
}
const bearerToken = async (baseEntity: string, method: string, authType: string, authToken: string): Promise<boolean> => {
return await new Promise((resolve, reject) => { // bearer token
if (authType !== 'Bearer' || !authToken) resolve(false)
if (!found.BearerToken) resolve(false)
const arr = this.config.scimgateway.auth.bearerToken
for (let i = 0; i < arr.length; i++) {
if (arr[i].token === authToken) { // authentication OK
if (arr[i].baseEntities) {
if (Array.isArray(arr[i].baseEntities) && arr[i].baseEntities.length > 0) {
if (!baseEntity) return reject(new Error(`baseEntity=${baseEntity} not allowed for this bearerToken according to bearerToken configuration baseEntitites=${arr[i].baseEntities}`))
if (!arr[i].baseEntities.includes(baseEntity)) return reject(new Error(`baseEntity=${baseEntity} not allowed for this bearerToken according to bearerToken configuration baseEntitites=${arr[i].baseEntities}`))
}
}
if (arr[i].readOnly === true && method !== 'GET') return reject(new Error('only allowing readOnly for this bearerToken according to bearerToken configuration readOnly=true'))
return resolve(true)
}
}
reject(new Error('bearerToken authentication failed'))
})
}
const bearerJwtAzure = async (baseEntity: string, method: string, authType: string, authToken: string): Promise<boolean> => {
return await new Promise((resolve, reject) => {
if (authType !== 'Bearer' || !found.BearerJwtAzure) resolve(false) // no azure bearer token
const jtoken: any = jwt.decode(authToken, { complete: true })
if (jtoken == null) resolve(false)
else if (!jtoken.payload['iss']) resolve(false)
if (jtoken?.payload['iss'].indexOf('https://sts.windows.net') !== 0) resolve(false)
const req = { headers: { authorization: `${authType} ${authToken}` } } // Node.js http.createServer type IncomingMessage - header supported by passport
passport.authenticate('oauth-bearer', { session: false }, (err: any, user: any, info: any) => {
if (err) { return reject(err) }
if (user) { // authenticated OK
const arr = this.config.scimgateway.auth.bearerJwtAzure
for (let i = 0; i < arr.length; i++) {
if (arr[i].tenantIdGUID && jtoken?.payload['iss'].includes(arr[i].tenantIdGUID)) {
if (arr[i].baseEntities) {
if (Array.isArray(arr[i].baseEntities) && arr[i].baseEntities.length > 0) {
if (!baseEntity) return reject(new Error(`baseEntity=${baseEntity} not allowed for user ${arr[i].tenantIdGUID} according to bearerJwtAzure configuration baseEntitites=${arr[i].baseEntities}`))
if (!arr[i].baseEntities.includes(baseEntity)) return reject(new Error(`baseEntity=${baseEntity} not allowed for user ${arr[i].tenantIdGUID} according to bearerJwtAzure configuration baseEntitites=${arr[i].baseEntities}`))
}
}
if (arr[i].readOnly === true && method !== 'GET') return reject(new Error(`only allowing readOnly for user ${arr[i].tenantIdGUID} according to bearerJwtAzure configuration readOnly=true`))
}
}
resolve(true)
} else reject(new Error(`Azure JWT authorization failed: ${info}`))
})(req)
})
}
const jwtVerify = async (baseEntity: string, method: string, el: Record<string, any>, authToken: string) => { // used by bearerJwt
return await new Promise((resolve) => {
jwt.verify(authToken, (el.secret) ? el.secret : el.publicKeyContent, el.options, (err) => {
if (err != null) resolve(false)
else {
if (el.baseEntities) {
if (Array.isArray(el.baseEntities) && el.baseEntities.length > 0) {
if (!baseEntity) return resolve(false)
if (!el.baseEntities.includes(baseEntity)) return resolve(false)
}
}
if (el.readOnly === true && method !== 'GET') return resolve(false)
resolve(true) // authorization OK
}
})
})
}
const bearerJwt = async (baseEntity: string, method: string, authType: string, authToken: string): Promise<boolean> => {
if (authType !== 'Bearer' || !found.BearerJwt) return false // no standard jwt bearer token
const jtoken: any = jwt.decode(authToken, { complete: true })
if (jtoken == null) return false
if (jtoken?.payload['iss'] && jtoken?.payload['iss'].indexOf('https://sts.windows.net') === 0) return false // azure - handled by bearerJwtAzure
const promises: any = []
const arr = this.config.scimgateway.auth.bearerJwt
for (let i = 0; i < arr.length; i++) {
promises.push(jwtVerify(baseEntity, method, arr[i], authToken))
}
const arrResolve = await Promise.all(promises).catch((err) => { throw (err) })
for (const i in arrResolve) {
if (arrResolve[i]) return true
}
throw new Error('JWT authentication failed')
}
const bearerOAuth = async (baseEntity: string, method: string, authType: string, authToken: string): Promise<boolean> => {
return await new Promise((resolve, reject) => { // bearer token
if (authType !== 'Bearer' || !authToken) resolve(false)
if (!found.BearerOAuth || !authToken) resolve(false)
// this.config.scimgateway.auth.oauthTokenStore is autmatically generated by token create having syntax:
// { this.config.scimgateway.auth.oauthTokenStore: <token>: { expireDate: <timestamp>, readOnly: <copy-from-config>, baseEntities: [ <copy-from-config> ], isTokenRequested: true }}
const arr = this.config.scimgateway.auth.bearerOAuth
if (this.config.scimgateway.auth.oauthTokenStore[authToken]) { // authentication OK
const tokenObj = this.config.scimgateway.auth.oauthTokenStore[authToken]
if (Date.now() > tokenObj.expireDate) {
delete this.config.scimgateway.auth.oauthTokenStore[authToken]
const err = new Error('OAuth access token expired')
err.name = 'invalid_token'
return reject(err)
}
if (tokenObj.baseEntities) {
if (Array.isArray(tokenObj.baseEntities) && tokenObj.baseEntities.length > 0) {
if (!tokenObj.baseEntities.includes(baseEntity)) return reject(new Error(`baseEntity=${baseEntity} not allowed for this bearerOAuth according to bearerOAuth configuration baseEntitites=${tokenObj.baseEntities}`))
}
}
if (tokenObj.readOnly === true && method !== 'GET') return reject(new Error('only allowing readOnly for this bearerOAuth according to bearerOAuth configuration readOnly=true'))
return resolve(true)
} else {
for (let i = 0; i < arr.length; i++) { // resolve if token memory store have been cleared because of a gateway restart
if (arr[i].isTokenRequested || !arr[i].clientSecret) continue
if (arr[i].baseEntities && Array.isArray(arr[i].baseEntities) && arr[i].baseEntities.length > 0) {
if (!arr[i].baseEntities.includes(baseEntity)) continue
}
if (utils.getEncrypted(authToken, arr[i].clientSecret) === arr[i].clientSecret) {
arr[i].isTokenRequested = true // flagged as true to not allow repeated resolvements because token will also be cleared when expired
const baseEntities = utils.copyObj(arr[i].baseEntities)
let expires
let readOnly = false
if (arr[i].readOnly && arr[i].readOnly === true) readOnly = true
if (arr[i].expires_in && !isNaN(arr[i].expires_in)) expires = arr[i].expires_in
else expires = oAuthTokenExpire
this.config.scimgateway.auth.oauthTokenStore[authToken] = {
expireDate: Date.now() + expires * 1000,
readOnly,
baseEntities,
}
return resolve(true)
}
}
}
reject(new Error('OAuth authentication failed'))
})
}
const authPassThrough = async (baseEntity: string, method: string, authType: string, authToken: string, path: string): Promise<boolean> => {
if (!found.PassThrough || !this.authPassThroughAllowed || path.endsWith('/logger')) return false
if (!authToken) return false
if (authType === 'Basic') {
const [userName, userPassword] = (Buffer.from(authToken, 'base64').toString() || '').split(':')
if (!userName || !userPassword) return false
}
const obj = this.config.scimgateway.auth.passThrough
if (obj.baseEntities) {
if (Array.isArray(obj.baseEntities) && obj.baseEntities.length > 0) {
if (!baseEntity || !obj.baseEntities.includes(baseEntity)) throw new Error(`baseEntity=${baseEntity} not allowed for passThrough according to passThrough configuration baseEntitites=${obj.baseEntities}`)
}
}
if (obj.readOnly === true && method !== 'GET') throw new Error('only allowing readOnly for passThrough according to passThrough configuration readOnly=true')
return true
}
// end auth methods - used by auth
const isAuthorized = async (ctx: Context): Promise<boolean> => { // authentication/authorization
const [authType, authToken] = (ctx.request.headers.get('authorization') || '').split(' ') // [0] = 'Basic' or 'Bearer'
try { // authenticate
const arrResolve = await Promise.all([
basic(ctx.routeObj.baseEntity, ctx.request.method, authType, authToken, ctx.path),
bearerToken(ctx.routeObj.baseEntity, ctx.request.method, authType, authToken),
bearerJwtAzure(ctx.routeObj.baseEntity, ctx.request.method, authType, authToken),
bearerJwt(ctx.routeObj.baseEntity, ctx.request.method, authType, authToken),
bearerOAuth(ctx.routeObj.baseEntity, ctx.request.method, authType, authToken),
authPassThrough(ctx.routeObj.baseEntity, ctx.request.method, authType, authToken, ctx.path),
])
.catch((err) => { throw (err) })
for (const i in arrResolve) {
if (arrResolve[i]) return true // auth OK - continue with routes
}
// all false - invalid auth method or missing pluging config
let err: Error
if (authType.length < 1) err = new Error(`${ctx.request.url} request is missing authentication information`)
else {
err = new Error(`${ctx.request.url} request having unsupported authentication or plugin configuration is missing`)
logger.debug(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] request authToken = ${authToken}`)
logger.debug(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] request jwt.decode(authToken) = ${JSON.stringify(jwt.decode(authToken))}`)
}
if (authType === 'Bearer') ctx.response.headers.set('WWW-Authenticate', 'Bearer realm=""')
else if (found.Basic) ctx.response.headers.set('WWW-Authenticate', 'Basic realm=""')
if (ctx.request.url !== '/favicon.ico') logger.error(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] ${err.message}`)
return false
} catch (err: any) {
if (authType === 'Bearer') {
let str = 'realm=""'
if (err?.name === 'invalid_token') {
str += `, error="${err.name}"`
if (err.message) {
str += `, error_description="${err.message}"`
const errMsg = {
error: err.name,
error_description: err.message,
}
ctx.response.body = JSON.stringify(errMsg)
}
}
ctx.response.headers.set('WWW-Authenticate', `Bearer ${str}`)
} else ctx.response.headers.set('WWW-Authenticate', 'Basic realm=""')
if (pwErrCount < 3) {
pwErrCount += 1
logger.error(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] ${ctx.request.url} ${err.message}`)
} else { // delay brute force attempts
logger.error(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] ${ctx.request.url} ${err.message} => delaying response with 2 minutes to prevent brute force`)
await new Promise((resolve) => {
setTimeout(() => { resolve(null) }, 1000 * 60 * 2)
})
}
return false
}
return false
}
const ipAllowList = (ipAddr: string): boolean => {
if (ipAllowListChecker === undefined) return true
if (ipAllowListChecker(ipAddr) === true) return true // if proxy, prereq: request includes header X-Forwarded-For
logger.debug(`${gwName}[${pluginName}] client ip ${ipAddr} not in ipAllowList`)
return false
}
const getHandlerSchemas = async (ctx: Context) => {
let tx = this.scimDef.Schemas
tx = utilsScim.addResources(tx, undefined, undefined, undefined)
tx = utilsScim.addSchemas(tx, isScimv2, undefined, undefined)
ctx.response.body = JSON.stringify(tx)
}
funcHandler.getHandlerSchemas = getHandlerSchemas
// scimv1 = GET /ServiceProviderConfigs, scimv2 GET /ServiceProviderConfig
const getHandlerServiceProviderConfig = async (ctx: Context) => {
const tx = this.scimDef.ServiceProviderConfigs
if (!this.config.scimgateway.scim.skipMetaLocation) {
const location = ctx.origin + ctx.path
if (tx.meta) tx.meta.location = location
else {
tx.meta = {}
tx.meta.location = location
}
}
ctx.response.body = JSON.stringify(tx)
}
funcHandler.getHandlerServiceProviderConfig = getHandlerServiceProviderConfig
// getHandlerLogger implements SSE based online publisher for log events
const getHandlerLogger = async (ctx: Context) => {
const levelInt = logger.levelToInt(this.config?.scimgateway?.log?.loglevel?.push || 'info')
const encoder = new TextEncoder()
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(`\u200B`))
const sub = async (msgObj: Record<string, any>) => {
if (logger.levelToInt(msgObj.level) < levelInt) return
controller.enqueue(encoder.encode(`${JSON.stringify(msgObj)}\n`))
}
logger.subscribe(sub)
const keepAliveInterval = setInterval(() => {
controller.enqueue(encoder.encode(`\u200B`)) // invisible keep-alive
}, 10000)
const cleanup = () => {
clearInterval(keepAliveInterval)
logger.unsubscribe(sub)
controller.close()
}
ctx.request.signal.onabort = cleanup // Bun
ctx.request?.raw?.socket?.on('close', cleanup) // Node detect when the client disconnects
},
}),
{
status: 200,