UNPKG

scimgateway

Version:

Using SCIM protocol as a gateway for user provisioning to other endpoints

882 lines (840 loc) 158 kB
// ================================================================================= // 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,