UNPKG

scimgateway

Version:

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

925 lines (885 loc) 211 kB
// ================================================================================= // File: scimgateway.ts // // 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 { createPublicKey } from 'node:crypto' import { createChecker } from 'is-in-subnet' import { fileURLToPath } from 'node:url' import { Logger } from './logger.ts' import { HelperRest } from './helper-rest.ts' import dot from 'dot-object' import nodemailer from 'nodemailer' import fs from 'node:fs' import path from 'node:path' import * as jose from 'jose' 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' // @ts-expect-error: cannot find declaration import hycoPkg from 'hyco-https' export class ScimGateway { private config: any private logger: any private gwName: string private scimDef: any private jwk: any private multiValueTypes: any private getMemberOf: any private getAppRoles: any private pub: any // @ts-expect-error: has no initializer private helperRest: HelperRest /** scimgateway lib directory */ readonly gwDir: string /** plugin lib directory */ readonly pluginDir: string /** 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 /** * pluginAndOrFilterEnabled can be set to 'true' by the plugin for letting the plugin handle query filtering that includes simple `and`/`or` logic instead of default handled by scimgateway * */ pluginAndOrFilterEnabled: 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: 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: 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 /** getEntitlements returns endpoint supported entitlements - e.g., plugin-entra-id returns available Entra tenant licenses as entitlements */ getEntitlements!: (baseEntity: string, getObj: Record<string, any>, attributes: Array<string>, ctx?: undefined | Record<string, any>) => any /** getRoles returns endpoint supported roles - e.g., plugin-entra-id returns Entra permanent and eligible roles */ getRoles!: (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 body 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, body: 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 body 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, body: 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 body 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, body: 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 query 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, query: Record<string, any> | undefined, 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 /** * publicApi method is defined at the plugin and should handle all incoming methods for the public path `/pub/api` - note, there are no authentication for this path * @param baseEntity will always be `pub` * @param method GET/POST/PATCH/PUT/DELETE * @param id unique object id for methods having id else undefined * @param query query object if exists else undefined * @param apiObj body * @returns according to your needs * @example * PATCH http://localhost:8890/pub/api/100 * body = {"title":"BMW X3"} */ publicApi!: (baseEntity: string, method: string, id: string | undefined, query: Record<string, any> | undefined, apiObj: any, 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') let gwName = path.basename(fileURLToPath(import.meta.url)).split('.')[0] // prefix of current file - using fileURLToPath because using "__filename" is not supported by nodejs typescript if (pluginDir.includes('$bunfs/root')) { // running compiled binary - binary prefix name must match the config prefix name located in the config folder in the same directory as the binary. // bun build --compile ./lib/plugin-xxx.ts --target=bun-darwin-arm64 --outfile ./build/plugin-xxx pluginDir = '.' // only support running binary in current directory configDir = './config' gwName = 'scimgateway' } const configFile = path.join(configDir, `${pluginName}.json`) // config name prefix same as pluging name prefix this.config = {} // exposed outside class this.gwName = gwName this.gwDir = path.dirname(fileURLToPath(import.meta.url)) this.pluginDir = pluginDir this.pluginName = pluginName this.configDir = configDir this.configFile = configFile this.authPassThroughAllowed = false // set to true by plugin if using Auth PassThrough this.pluginAndOrFilterEnabled = false // set to true by plugin if plugin handle simple and/or query filter 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 } let logDir: string if (pluginDir === '.') logDir = 'logs' // running bun compiled binary else logDir = this.config?.scimgateway?.log?.logDirectory || path.join(pluginDir, '..', 'logs') 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} ${configErr.message}`) logger.error(`${gwName} 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 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.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.azureRelay) this.config.scimgateway.azureRelay = {} 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.Entitlements = handler.entitlements = { description: 'Entitlement', getMethod: 'getEntitlements', } handler.Roles = handler.roles = { description: 'Role', getMethod: 'getRoles', } handler.AppRoles = handler.approles = { // scim-stream description: 'AppRole', getMethod: 'getAppRoles', } /** handlers supported url paths */ const handlers = ['users', 'groups', 'bulk', 'entitlements', 'roles', 'approles', 'api', 'schemas', 'resourcetypes', 'serviceproviderconfig', 'serviceproviderconfigs', 'oauth', '.well-known', '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 = utilsScim.loadScimDef('2.0', pluginDir) isScimv2 = true } else { this.scimDef = utilsScim.loadScimDef('1.1', pluginDir) } const isScimv2Initial = isScimv2 // 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' || ctx.path.startsWith('/apple-touch-icon')) 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.includes('"Resources":') && outbound.length > 1500) { try { const o = JSON.parse(outbound) if (o?.Resources?.length > 1) { o.Resources = [o.Resources[0]] o.Resources.push({ loggerComment: '===OBJECTS TRUNCATED BECAUSE OF LOG LENGTH===' }) outbound = JSON.stringify(o) } } catch (err) { } } const logEvent = { baseEntity: ctx?.routeObj?.baseEntity, durationMs: ellapsed, clientIp: ctx.ip, issuer: userName, target: ctx.target, // userName/displayName status: ctx.response.status, method: ctx.request.method, url: ctx.request.url, requestBody: JSON.stringify(ctx.request.body), responseBody: outbound, } let msg = utils.statusText(logEvent.status) if (ctx.response.status && ctx.response.status > 399) { try { const o = JSON.parse(ctx.response.body as string ?? '') if (o.detail) msg = o.detail else if (o.Errors && Array.isArray(o.Errors) && o.Errors[0]?.description) msg = o.Errors[0].description } catch (err) { } if (ctx.response.status === 401 && !ctx.request.headers.has('authorization')) { logger.warn(msg, logEvent) } else if (ctx.response.status === 404) { logger.warn(msg, logEvent) } else if (ctx.response.status === 412) { logger.info(msg, logEvent) } else logger.error(msg, logEvent) } else { logger.info(msg, logEvent) } } // start auth methods - used by auth const basic = async (baseEntity: string, method: string, authType: string, authToken: string): Promise<boolean> => { return await new Promise((resolve, reject) => { // basic auth if (!found.Basic) return resolve(false) if (authType !== 'Basic' || !authToken) return resolve(false) const [userName, userPassword] = (Buffer.from(authToken, 'base64').toString() ?? '').split(':') if (!userName || !userPassword) return resolve(false) 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 (!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) } } resolve(false) }) } const bearerToken = async (baseEntity: string, method: string, authType: string, authToken: string): Promise<boolean> => { return await new Promise((resolve, reject) => { // bearer token if (!found.BearerToken) return resolve(false) if (authType !== 'Bearer' || !authToken) return 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 (!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 according to bearerToken configuration readOnly=true')) return resolve(true) } } resolve(false) }) } const jwtVerify = async (baseEntity: string, method: string, el: Record<string, any>, authToken: string): Promise<boolean> => { // used by bearerJwt try { if (el.azureTenantId) { el.wellKnownUri = `https://login.microsoftonline.com/${el.azureTenantId}/.well-known/openid-configuration` el.customOptions = { tid: el.azureTenantId, appid: '00000014-0000-0000-c000-000000000000', // Well known appid: Microsoft.Azure.SyncFabric aud: [ // Appid used for SCIM provisioning for non-gallery applications. See changes introduced, in reverse cronological order: // - https://github.com/MicrosoftDocs/azure-docs/commit/f6997c0952d2ad4f33ce7f5339eeb83c21b51f1e // - https://github.com/MicrosoftDocs/azure-docs/commit/64525fea0675a73b2e6b8fe42fbd03ee568cadfc '8adf8e6e-67b2-4cf2-a259-e3dc5476c621', // Well known appid: Issued for accessing Windows Azure Active Directory Graph Webservice '00000002-0000-0000-c000-000000000000', ], } } if (el.wellKnownUri) { if (!el.jwks) { if (!this.helperRest) this.helperRest = this.newHelperRest() let res try { // get issuer and jwks_uri from well-knonw uri res = await this.helperRest.doRequest('undefined', 'GET', el.wellKnownUri) } catch (err: any) { throw new Error(`JWKS wellKnownUri=${el.wellKnownUri} error: ${err.message}`) } if (!res?.body) throw new Error(`JWKS wellKnownUri=${el.wellKnownUri} error: response missing data`) const issuer = res.body.issuer const jwks_uri = res.body.jwks_uri if (!issuer || !jwks_uri) { throw new Error(`JWKS wellKnownUri=${el.wellKnownUri} error: found issuer=${issuer} and jwks_uri=${jwks_uri} - both should be found`) } if (!el.options) el.options = {} el.options.issuer = issuer el.jwks = jose.createRemoteJWKSet(new URL(jwks_uri)) // will automatically reload the JWKS when verification fails due to an unknown kid } const { payload } = await jose.jwtVerify(authToken, el.jwks, el.options) if (!payload || Object.keys(payload).length < 1) throw new Error('incorrect verification response') if (el.customOptions) { // verify non-standard JWT claims for (const key in el.customOptions) { if (!el.customOptions[key]) continue if (Array.isArray(el.customOptions[key])) { if (!el.customOptions[key].includes(payload[key])) throw new Error(`${el.azureTenantId ? 'azureTenantId ' : ''}verification of claim '${key}' failed`) } else { if (payload[key] !== el.customOptions[key]) throw new Error(`${el.azureTenantId ? 'azureTenantId ' : ''}verification of claim '${key}' failed`) } } } } else { if (el.secret && !el.secretEncoded) { el.secretEncoded = new TextEncoder().encode(el.secret) if (!el.options) el.options = {} el.options.algorithms = ['HS256', 'HS384', 'HS512'] // symmetric algorithms when using secret } await jose.jwtVerify(authToken, (el.secretEncoded) ? el.secretEncoded : el.publicKeyObj, el.options) } if (Array.isArray(el?.baseEntities) && el.baseEntities.length > 0) { if (!el.baseEntities.includes(baseEntity)) return false } return true // authorization OK } catch (err: any) { throw new Error(`JWT error: ${err.message}`) } } const bearerJwt = async (baseEntity: string, method: string, authType: string, authToken: string): Promise<boolean> => { if (!found.BearerJwt) return false if (authType !== 'Bearer' || !authToken) return false let payload try { payload = jose.decodeJwt(authToken) if (!payload) return false } catch (err: any) { return false } if (found.BearerOAuth) { const a = this.config.scimgateway.auth.bearerOAuth const confObjs = a.filter((o: any) => o.clientId === payload.aud) if (confObjs.length > 0) return false // jwt handled by bearerOauth } const errs: Array<string> = [] const arr = this.config.scimgateway.auth.bearerJwt for (let i = 0; i < arr.length; i++) { try { if (await jwtVerify(baseEntity, method, arr[i], authToken) === true) { if (arr[i].readOnly === true && method !== 'GET') throw new Error('only allowing readOnly according to bearerJwt configuration readOnly=true') return true } } catch (err: any) { errs.push(err.message) } } if (errs.length > 0) throw new Error(errs.join(' == NextConfigValidation ==> ')) return false } const bearerOAuth = async (baseEntity: string, method: string, authType: string, authToken: string): Promise<boolean> => { return await new Promise(async (resolve, reject) => { // bearer token if (!found.BearerOAuth) return resolve(false) if (authType !== 'Bearer' || !authToken) return 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 }} let payload try { payload = jose.decodeJwt(authToken) if (!payload || payload.iss !== 'SCIM Gateway' || !payload.aud || !payload.sub) return resolve(false) } catch (err: any) { return resolve(false) } const arr = this.config.scimgateway.auth.bearerOAuth const confObjs = arr.filter((o: any) => o.clientId === payload.aud) if (confObjs.length !== 1) return resolve(false) try { await jose.jwtVerify(authToken, new TextEncoder().encode(confObjs[0].clientSecret), { algorithms: ['HS256'] }) authToken = payload.sub } catch (err: any) { return resolve(false) } 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 according to bearerOAuth configuration baseEntitites=${tokenObj.baseEntities}`)) } } if (tokenObj.readOnly === true && method !== 'GET') return reject(new Error('only allowing readOnly 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 = structuredClone(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) } } } resolve(false) }) } 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 (!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 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' let arrResolve: boolean[] = [] try { // authenticate arrResolve = await Promise.all([ basic(ctx.routeObj.baseEntity, ctx.request.method, authType, authToken), bearerToken(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: 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 (err.message?.includes('only allowing readOnly')) { ctx.response.status = 405 } logger.error(`${gwName} ${err.message}`) return false } for (const i in arrResolve) { if (arrResolve[i] === true) return true // auth OK - continue with routes } // all auth validations failed if (!authToken) { if (found.Basic && ctx.request.headers.has('sec-fetch-dest')) ctx.response.headers.set('www-authenticate', 'Basic realm=""') return false } if (authType === 'Bearer') ctx.response.headers.set('www-authenticate', 'Bearer realm=""') else ctx.response.headers.set('www-authenticate', 'Basic realm=""') if (pwErrCount < 3) pwErrCount += 1 else { // delay brute force attempts const delay = (this.config.scimgateway.idleTimeout || 120) - 5 logger.error(`${gwName} ${ctx.request.url} => max authentication failures reached, delaying response with ${delay} seconds to prevent brute force`, { baseEntity: ctx?.routeObj?.baseEntity }) await new Promise((resolve) => { setTimeout(() => { resolve(null) }, 1000 * delay) }) } 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 return false } const getHandlerSchemas = async (ctx: Context) => { let tx = structuredClone(this.scimDef.Schemas) if (this.config.endpoint?.map) { // endpointMapper being used // Schemas returned should instead reflect what is defined in the plugin config file // For AI Agent MCP tools, the 'x-agent-schema' attribute can be used to enhance their functionality or provide additional context when processing SCIM requests - see plugin-entra-id.json for example usage. const map = this.config.endpoint.map const updateSchema = (resourceName: string, mapSection: any) => { if (!mapSection) return const resource = tx.Resources.find((r: any) => r.name === resourceName) const scimResource = this.scimDef.Schemas.Resources.find((r: any) => r.name === resourceName) if (!resource) return const isV1 = (resource.schema === 'urn:scim:schemas:core:1.0') ? true : false const newAttributes: any[] = [] const complexAttrs: Record<string, any> = {} const typeDone: Record<string, string> = {} for (const key in mapSection) { const item = mapSection[key] if (!item.mapTo && key === 'x-agent-schema') { resource['x-agent-schema'] = JSON.stringify(item) // top level schema update continue } if (!item.mapTo || item.mapTo === 'id') continue const parts = item.mapTo.split('.') if (parts.length === 1) { const org = scimResource.attributes.find((r: any) => r.name === item.mapTo) let attr: any if (org) { // reusing original SCIM definition attr = structuredClone(org) if (item.subAttributes && Array.isArray(item.subAttributes) && attr?.subAttributes && Array.isArray(attr.subAttributes)) { // any configuration subAttributes takes precidence for (const el of item.subAttributes) { if (typeof el !== 'object' || !el.name) continue const existingSub = attr.subAttributes.find((sa: any) => sa.name === el.name) if (existingSub) { if (el.description) existingSub.description = el.description if (el.mutability) existingSub.mutability = el.mutability if (el.canonicalValues) existingSub.canonicalValues = el.canonicalValues } else { const newSub: Record<string, any> = { name: el.name, type: el.type ?? 'string', multiValued: el.mulitvalue ?? false, description: el.description ?? '', required: false, caseExact: false,