@keepsolutions/scimgateway
Version:
Using SCIM protocol as a gateway for user provisioning to other endpoints
423 lines (379 loc) • 15.6 kB
JavaScript
// =================================================================================
// File: plugin-api.js
//
// Author: Jarle Elshaug
//
// Purpose: Demonstrate scimgateway api functionality by using a REST based plugin
// Using /api ScimGateway transfer "as is" to plugin and returns plugin result by adding
// {"meta": {"result": "success"}}
// or
// {"meta": {"result": "error"}}
//
// This plugin becomes what you it to be
//
// Test prereq: Internet connection towards baseUrl defined for testing purpose (http://fakerestapi.azurewebsites.net)
//
// Supported by scimgateway:
// GET /api
// GET /api?queries
// GET /api/{id}
// POST /api + body
// PUT /api/{id} + body
// PATCH /api/{id} + body
// DELETE /api/{id}
//
// =================================================================================
const http = require('http')
const https = require('https')
const HttpsProxyAgent = require('https-proxy-agent')
const URL = require('url').URL
const querystring = require('querystring')
// mandatory plugin initialization - start
const path = require('path')
let ScimGateway = null
try {
ScimGateway = require('scimgateway')
} catch (err) {
ScimGateway = require('./scimgateway')
}
const scimgateway = new ScimGateway()
const pluginName = path.basename(__filename, '.js')
const configDir = path.join(__dirname, '..', 'config')
const configFile = path.join(`${configDir}`, `${pluginName}.json`)
let config = require(configFile).endpoint
config = scimgateway.processExtConfig(pluginName, config) // add any external config process.env and process.file
// mandatory plugin initialization - end
const _serviceClient = {}
// =================================================
// postApi
// =================================================
//
// example:
// post http://localhost:8890/api
// body = {"eventName":"AssignAccessRoleEvent","subjectName":"RACF_System-B","userID":"peter01"}
//
scimgateway.postApi = async (baseEntity, apiObj) => {
const action = 'postApi'
scimgateway.logger.debug(`${pluginName} handling "${action}" apiObj=${JSON.stringify(apiObj)}`)
if (!apiObj.eventName || !apiObj.subjectName || !apiObj.userID) {
const err = new Error('Unsupported POST content')
throw err
}
const method = 'POST'
const path = '/api/v1/Books'
const body = {
ID: 1,
Title: apiObj.eventName,
Description: apiObj.subjectName,
Excerpt: apiObj.userID
}
try {
const response = await doRequest(baseEntity, method, path, body)
return response.body
} catch (err) {
const newErr = err
throw newErr
}
}
// =================================================
// putApi
// =================================================
//
// example:
// put http://localhost:8890/api/1
// body = {"eventName":"AssignAccessRoleEvent","subjectName":"RACF_System-B","userID":"peter01"}
//
scimgateway.putApi = async (baseEntity, id, apiObj) => {
const action = 'putApi'
scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" id=${id} apiObj=${JSON.stringify(apiObj)}`)
if (!apiObj.eventName || !apiObj.subjectName || !apiObj.userID) {
const err = new Error('Unsupported PUT content')
throw err
}
const method = 'PUT'
const path = `/api/v1/Books/${id}`
const body = {
ID: id,
Title: apiObj.eventName,
Description: apiObj.subjectName,
Excerpt: apiObj.userID
}
try {
const response = await doRequest(baseEntity, method, path, body)
return response.body
} catch (err) {
const newErr = err
throw newErr
}
}
// =================================================
// patchApi
// =================================================
//
// example:
// patch http://localhost:8890/api/1
// body = {"eventName":"AssignAccessRoleEvent","subjectName":"RACF_System-B","userID":"peter01"}
//
scimgateway.patchApi = async (baseEntity, id, apiObj) => {
const action = 'patchApi'
scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" id=${id} apiObj=${JSON.stringify(apiObj)}`)
if (!apiObj.eventName && !apiObj.subjectName && !apiObj.userID) {
const err = new Error('Unsupported PATCH content')
throw err
}
const method = 'PATCH'
const path = `/api/v1/Books/${id}`
const body = { ID: id }
if (apiObj.eventName) body.Title = apiObj.eventName
if (apiObj.subjectName) body.Description = apiObj.subjectName
if (apiObj.userID) body.Excerpt = apiObj.userID
try { // note, Books example do not support patch
const response = await doRequest(baseEntity, method, path, body)
return response.body
} catch (err) {
const newErr = err
throw newErr
}
}
// =================================================
// getApi
// =================================================
//
// examples:
// get http://localhost:8890/api
// get http://localhost:8890/api/1
// get http://localhost:8890/api?queries
//
scimgateway.getApi = async (baseEntity, id, apiQuery, apiObj) => {
const action = 'getApi'
scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" id=${id} apiQuery=${JSON.stringify(apiQuery)} apiObj=${JSON.stringify(apiObj)}`)
try {
const method = 'GET'
if (id) {
const path = `/api/v1/Books/${id}`
const body = null
const response = await doRequest(baseEntity, method, path, body)
return response.body
} else {
const path = '/api/Books'
const body = null
if (apiQuery) {} // some logic here
const response = await doRequest(baseEntity, method, path, body)
return response.body
}
} catch (err) {
const newErr = err
throw newErr
}
}
// =================================================
// deleteApi
// =================================================
//
// example:
// delete http://localhost:8890/api/1
//
scimgateway.deleteApi = async (baseEntity, id) => {
const action = 'deleteApi'
scimgateway.logger.debug(`${pluginName}[${baseEntity}] handling "${action}" id=${id}`)
const method = 'DELETE'
const path = `/api/v1/Books/${id}`
const body = null
try {
const response = await doRequest(baseEntity, method, path, body)
return response.body
} catch (err) {
const newErr = err
throw newErr
}
}
// =================================================
// helpers
// =================================================
//
// getServiceClient - returns options needed for connection parameters
//
// path = e.g. "/xxx/yyy", then using host/port/protocol based on config baseUrls[0]
// auth automatically added and failover according to baseUrls array
//
// path = url e.g. "http(s)://<host>:<port>/xxx/yyy", then using the url host/port/protocol
// opt (options) may be needed e.g {auth: {username: "username", password: "password"} }
//
const getServiceClient = async (baseEntity, method, path, opt) => {
const action = 'getServiceClient'
let urlObj
if (!path) path = ''
try {
urlObj = new URL(path)
} catch (err) {
//
// path (no url) - default approach and client will be cached based on config
//
if (_serviceClient[baseEntity]) { // serviceClient already exist
scimgateway.logger.debug(`${pluginName}[${baseEntity}] ${action}: Using existing client`)
} else {
scimgateway.logger.debug(`${pluginName}[${baseEntity}] ${action}: Client have to be created`)
let client = null
if (config.entity && config.entity[baseEntity]) client = config.entity[baseEntity]
if (!client) {
const err = new Error(`Base URL have baseEntity=${baseEntity}, and configuration file ${pluginName}.json is missing required baseEntity configuration for ${baseEntity}`)
throw err
}
urlObj = new URL(config.entity[baseEntity].baseUrls[0])
const param = {
baseUrl: config.entity[baseEntity].baseUrls[0],
options: {
json: true, // json-object response instead of string
headers: {
'Content-Type': 'application/json',
Authorization: 'Basic ' + Buffer.from(`${config.entity[baseEntity].username}:${scimgateway.getPassword(`endpoint.entity.${baseEntity}.password`, configFile)}`).toString('base64')
},
host: urlObj.hostname,
port: urlObj.port, // null if https and 443 defined in url
protocol: urlObj.protocol, // http: or https:
rejectUnauthorized: false // accepts self-siged certificates
// 'method' and 'path' added at the end
}
}
// proxy
if (config.entity[baseEntity].proxy && config.entity[baseEntity].proxy.host) {
const agent = new HttpsProxyAgent(config.entity[baseEntity].proxy.host)
param.options.agent = agent // proxy
if (config.entity[baseEntity].proxy.username && config.entity[baseEntity].proxy.password) {
param.options.headers['Proxy-Authorization'] = 'Basic ' + Buffer.from(`${config.entity[baseEntity].proxy.username}:${scimgateway.getPassword(`endpoint.entity.${baseEntity}.proxy.password`, configFile)}`).toString('base64') // using proxy with auth
}
}
if (!_serviceClient[baseEntity]) _serviceClient[baseEntity] = {}
_serviceClient[baseEntity] = param // serviceClient created
}
const cli = scimgateway.copyObj(_serviceClient[baseEntity]) // client ready
// failover support
path = _serviceClient[baseEntity].baseUrl + path
urlObj = new URL(path)
cli.options.host = urlObj.hostname
cli.options.port = urlObj.port
cli.options.protocol = urlObj.protocol
// adding none static
cli.options.method = method
cli.options.path = `${urlObj.pathname}${urlObj.search}`
if (opt) cli.options = scimgateway.extendObj(cli.options, opt) // merge with argument options
return cli // final client
}
//
// url path - none config based and used as is (no cache)
//
scimgateway.logger.debug(`${pluginName}[${baseEntity}] ${action}: Using none config based client`)
let options = {
json: true,
headers: {
'Content-Type': 'application/json'
},
host: urlObj.hostname,
port: urlObj.port,
protocol: urlObj.protocol,
method: method,
path: urlObj.pathname
}
// proxy
if (config.entity[baseEntity].proxy && config.entity[baseEntity].proxy.host) {
const agent = new HttpsProxyAgent(config.entity[baseEntity].proxy.host)
options.agent = agent // proxy
if (config.entity[baseEntity].proxy.username && config.entity[baseEntity].proxy.password) {
options.headers['Proxy-Authorization'] = 'Basic ' + Buffer.from(`${config.entity[baseEntity].proxy.username}:${scimgateway.getPassword(`endpoint.entity.${baseEntity}.proxy.password`, configFile)}`).toString('base64') // using proxy with auth
}
}
// merge any argument options - support basic auth using {auth: {username: "username", password: "password"} }
if (opt) {
const o = scimgateway.copyObj(opt)
if (o.auth) {
options.headers.Authorization = 'Basic ' + Buffer.from(`${o.auth.username}:${o.auth.password}`).toString('base64')
delete o.auth
}
options = scimgateway.extendObj(options, o)
}
const cli = {}
cli.options = options
return cli // final client
}
const updateServiceClient = (baseEntity, obj) => {
if (_serviceClient[baseEntity]) _serviceClient[baseEntity] = scimgateway.extendObj(_serviceClient[baseEntity], obj) // merge with argument options
}
//
// doRequest - execute REST service
//
const doRequest = async (baseEntity, method, path, body, opt, retryCount) => {
try {
const cli = await getServiceClient(baseEntity, method, path, opt)
const options = cli.options
const result = await new Promise((resolve, reject) => {
let dataString = ''
if (body) {
if (options.headers['Content-Type'].toLowerCase() === 'application/x-www-form-urlencoded') {
if (typeof data === 'string') dataString = body
else dataString = querystring.stringify(body) // JSON to query string syntax + URL encoded
} else dataString = JSON.stringify(body)
options.headers['Content-Length'] = Buffer.byteLength(dataString, 'utf8')
}
const reqType = (options.protocol.toLowerCase() === 'https:') ? https.request : http.request
const req = reqType(options, (res) => {
const { statusCode, statusMessage } = res // solving parallel problem (const + don't use res.statusCode)
let responseString = ''
res.setEncoding('utf-8')
res.on('data', (chunk) => {
responseString += chunk
})
res.on('end', () => {
const response = {
statusCode: statusCode,
statusMessage: statusMessage,
body: null
}
try {
if (responseString) response.body = JSON.parse(responseString)
} catch (err) { response.body = responseString }
if (statusCode < 200 || statusCode > 299) reject(new Error(JSON.stringify(response)))
resolve(response)
})
}) // req
req.on('socket', (socket) => {
socket.setTimeout(60000) // connect and wait timeout => socket hang up
socket.on('timeout', function () { req.abort() })
})
req.on('error', (error) => { // also catching req.abort
req.end()
reject(error)
})
if (dataString) req.write(dataString)
req.end()
}) // Promise
scimgateway.logger.debug(`${pluginName}[${baseEntity}] doRequest ${method} ${options.protocol}//${options.host}${(options.port ? `:${options.port}` : '')}${path} Body = ${JSON.stringify(body)} Response = ${JSON.stringify(result)}`)
return result
} catch (err) { // includes failover/retry logic based on config baseUrls array
scimgateway.logger.error(`${pluginName}[${baseEntity}] doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
if (!retryCount) retryCount = 0
let urlObj
try { urlObj = new URL(path) } catch (err) {}
if (!urlObj && (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND')) {
if (retryCount < config.entity[baseEntity].baseUrls.length) {
retryCount++
updateServiceClient(baseEntity, { baseUrl: config.entity[baseEntity].baseUrls[retryCount - 1] })
scimgateway.logger.debug(`${pluginName}[${baseEntity}] ${(config.entity[baseEntity].baseUrls.length > 1) ? 'failover ' : ''}retry[${retryCount}] using baseUrl = ${_serviceClient[baseEntity].baseUrl}`)
const ret = await doRequest(baseEntity, method, path, body, opt, retryCount) // retry
return ret // problem fixed
} else {
const newerr = new Error(err.message)
newerr.message = newerr.message.replace('ECONNREFUSED', 'UnableConnectingService') // avoid returning ECONNREFUSED error
newerr.message = newerr.message.replace('ENOTFOUND', 'UnableConnectingHost') // avoid returning ENOTFOUND error
throw newerr
}
} else throw err // CA IM retries getUser failure once (retry 6 times on ECONNREFUSED)
}
} // doRequest
//
// Cleanup on exit
//
process.on('SIGTERM', () => { // kill
})
process.on('SIGINT', () => { // Ctrl+C
})