scimgateway
Version:
Using SCIM protocol as a gateway for user provisioning to other endpoints
925 lines (885 loc) • 211 kB
text/typescript
// =================================================================================
// 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,