scimgateway
Version:
Using SCIM protocol as a gateway for user provisioning to other endpoints
944 lines (888 loc) • 46.6 kB
text/typescript
// =================================================================================
// File: helper-rest.ts
//
// Author: Jarle Elshaug
//
// Purpose: HelperRest class for executing REST calls supporting various auth types
// Plugins may use this class: import { HelperRest } from 'scimgateway'
// =================================================================================
import { HttpsProxyAgent } from 'https-proxy-agent'
import { URL } from 'url'
import { Buffer } from 'node:buffer'
import { samlAssertion } from './samlAssertion.ts'
import * as jsonwebtoken from 'jsonwebtoken'
import fs from 'node:fs'
import querystring from 'querystring'
import * as utils from './utils.ts'
/**
* HelperRest includes function doRequest() for executing REST calls
*/
export class HelperRest {
private lock = new utils.Lock()
private _serviceClient: Record<string, any> = {}
private config_entity: any
private scimgateway: any
private idleTimeout: number
private graphUrl = 'https://graph.microsoft.com/beta' // beta instead of 'v1.0' gives all user attributes when no $select
private googleUrl = 'https://www.googleapis.com'
constructor(scimgateway: any, optionalEntities?: Record<string, any>) {
if (!scimgateway || !scimgateway.gwName) throw new Error('HelperRest initialization error: argument scimgateway is not of type ScimGateway')
this.scimgateway = scimgateway
this.idleTimeout = (scimgateway as any)?.config?.scimgateway.idleTimeout || 120
this.idleTimeout = this.idleTimeout - 1
if (optionalEntities && optionalEntities.entity) this.config_entity = utils.copyObj(optionalEntities.entity)
else this.config_entity = utils.copyObj(scimgateway.getConfig())?.entity
let entityFound = false
let connectionFound = false
for (const baseEntity in this.config_entity) {
entityFound = true
if (this.config_entity[baseEntity]?.connection) {
connectionFound = true
const type = this.config_entity[baseEntity].connection?.auth?.type
if (type === 'oauthJwtBearer' || type === 'oauth') {
// set default baseUrls for Entra ID and Google if not already defined
if (this.config_entity[baseEntity]?.connection?.auth?.options?.tenantIdGUID) { // Entra ID, setting baseUrls to graph
if (!this.config_entity[baseEntity].connection.baseUrls) {
this.config_entity[baseEntity].connection.baseUrls = [this.graphUrl]
} else if (this.config_entity[baseEntity].connection.baseUrls?.length < 1) {
this.config_entity[baseEntity].connection.baseUrls = [this.graphUrl]
}
} else if (this.config_entity[baseEntity]?.connection?.auth?.options?.serviceAccountKeyFile) { // Google, setting baseUrls to googleapis
if (!this.config_entity[baseEntity].connection.baseUrls) {
this.config_entity[baseEntity].connection.baseUrls = [this.googleUrl]
} else if (this.config_entity[baseEntity].connection.baseUrls?.length < 1) {
this.config_entity[baseEntity].connection.baseUrls = [this.googleUrl]
}
}
}
}
}
let errMsg = ''
if (!entityFound) errMsg = 'HelperRest initialization error: missing configuration \'endpoint.entity.<name>\''
else if (!connectionFound) errMsg = 'HelperRest initialization error: missing configuration \'endpoint.entity.<name>.connection\''
if (errMsg) this.scimgateway.logError('undefined', errMsg)
}
/**
* getAccessToken returns oauth accesstoken object
* @param baseEntity
* @param ctx
* @returns oauth accesstoken object
*/
public async getAccessToken(baseEntity: string, ctx?: Record<string, any> | undefined) { // public in case token is needed for other logic e.g. sending mail
await this.lock.acquire()
const d = Math.floor(Date.now() / 1000) // seconds (unix time)
if (this._serviceClient[baseEntity] && this._serviceClient[baseEntity].accessToken
&& (this._serviceClient[baseEntity].accessToken.validTo >= d + 30)) { // avoid simultaneously token requests
this.lock.release()
return this._serviceClient[baseEntity].accessToken
}
const action = 'getAccessToken'
const serviceAccountKeyFile = this.config_entity[baseEntity]?.connection?.auth?.options?.serviceAccountKeyFile
const tenantIdGUID = this.config_entity[baseEntity]?.connection?.auth?.options?.tenantIdGUID
let tokenUrl: string
let form: Record<string, any>
let resource = ''
try {
const urlObj = new URL(this.config_entity[baseEntity].connection.baseUrls[0])
resource = urlObj.origin
} catch (err) { void 0 }
if (tenantIdGUID) {
tokenUrl = `https://login.microsoftonline.com/${tenantIdGUID}/oauth2/v2.0/token`
if (resource) this.config_entity[baseEntity].connection.auth.options.scope = resource + '/.default' // "https://graph.microsoft.com/.default"
} else tokenUrl = this.config_entity[baseEntity].connection.auth.options.tokenUrl
try {
switch (this.config_entity[baseEntity]?.connection?.auth?.type) {
case 'oauth':
form = {
grant_type: 'client_credentials',
client_id: this.config_entity[baseEntity].connection.auth.options.clientId,
client_secret: this.config_entity[baseEntity].connection.auth.options.clientSecret,
}
if (this.config_entity[baseEntity].connection.auth.options.scope) form.scope = this.config_entity[baseEntity].connection.auth.options.scope // required using Entra ID /oauth2/v2.0/token
if (this.config_entity[baseEntity].connection.auth.options.resource) resource = this.config_entity[baseEntity].connection.auth.options.resource // required using Entra ID /oauth2/token
break
case 'token':
tokenUrl = this.config_entity[baseEntity].connection.auth.options.tokenUrl
form = { // example username/password in body
username: this.config_entity[baseEntity].connection.auth.options.username,
password: this.config_entity[baseEntity].connection.auth.options.password,
}
break
case 'oauthSamlBearer':
tokenUrl = this.config_entity[baseEntity].connection.auth.options.tokenUrl
const context = null
const cert = fs.readFileSync(this.config_entity[baseEntity].connection.auth.options.tls.cert).toString()
const key = fs.readFileSync(this.config_entity[baseEntity].connection.auth.options.tls.key).toString()
const tokenEndpoint = tokenUrl
const delay = 1
// mandatory: clientId, companyId and nameId
const clientId = this.config_entity[baseEntity].connection.auth.options.samlPayload.clientId
const companyId = this.config_entity[baseEntity].connection.auth.options.samlPayload.companyId
const nameId = this.config_entity[baseEntity].connection.auth.options.samlPayload.nameId
const userIdentifierFormat = this.config_entity[baseEntity].connection.auth.options.samlPayload.userIdentifierFormat || 'userName'
const lifetime = this.config_entity[baseEntity].connection.auth.options.samlPayload.lifetime || 3600
const issuer = this.config_entity[baseEntity].connection.auth.options.samlPayload.clientId || `https://scimgateway.${this.scimgateway.pluginName}.com`
const audience = this.config_entity[baseEntity].connection.auth.options.samlPayload.audience || `scimgateway/${this.scimgateway.pluginName}`
form = {
token_url: tokenUrl,
grant_type: 'urn:ietf:params:oauth:grant-type:saml2-bearer',
client_id: clientId,
company_id: companyId,
assertion: await samlAssertion.run(context, cert, key, issuer, lifetime, clientId, nameId, userIdentifierFormat, tokenEndpoint, audience, delay),
}
break
case 'oauthJwtBearer':
let jwtClaims: jsonwebtoken.JwtPayload | Record<string, any> = {}
let jwtOpts: jsonwebtoken.SignOptions = {}
if (tenantIdGUID) { // Microsoft Entra ID
if (!this.config_entity[baseEntity]?.connection?.auth?.options?.tls?.cert) {
throw new Error(`auth type '${this.config_entity[baseEntity]?.connection?.auth?.type}' - missing options.tls.key/cert configuration`)
}
let privateKey = this.config_entity[baseEntity]?.connection?.auth?.options?.tls?._key || ''
let cert = this.config_entity[baseEntity]?.connection?.auth?.options?.tls?._cert || ''
if (!privateKey || !cert) {
privateKey = fs.readFileSync(this.config_entity[baseEntity].connection.auth.options.tls.key, 'utf-8') || ''
cert = fs.readFileSync(this.config_entity[baseEntity].connection.auth.options.tls.cert, 'utf-8') || ''
if (privateKey) this.config_entity[baseEntity].connection.auth.options.tls._key = privateKey
if (cert) this.config_entity[baseEntity].connection.auth.options.tls._cert = cert
}
if (!privateKey || !cert) {
throw new Error(`auth type '${this.config_entity[baseEntity]?.connection?.auth?.type}' - missing options.tls.key/cert file content`)
}
const jwtPayload: jsonwebtoken.JwtPayload = {
sub: this.config_entity[baseEntity]?.connection?.auth?.options?.clientId,
iss: this.config_entity[baseEntity]?.connection?.auth?.options?.clientId,
aud: `https://login.microsoftonline.com/${tenantIdGUID}/v2.0`,
iat: Math.floor(Date.now() / 1000) - 60,
exp: Math.floor(Date.now() / 1000) + 3600,
jti: crypto.randomUUID(),
nbf: Math.floor(Date.now() / 1000) - 60,
}
jwtClaims = {
...jwtPayload,
}
const base64Thumbprint = utils.getBase64CertificateThumbprint(cert, 'sha1') // xt5=>sha1, x5t#S256=>sha256
jwtOpts = {
algorithm: 'RS256',
header: {
typ: 'JWT',
alg: 'RS256',
x5t: base64Thumbprint,
},
}
/* Microsoft recommended modern x5t#S256 does not work using self-signed certificate
const base64Thumbprint = utils.getBase64CertificateThumbprint(cert, 'sha256')
jwtOpts = {
algorithm: 'PS256',
header: {
'typ': 'JWT',
'alg': 'PS256',
'x5t#S256': base64Thumbprint,
},
}
*/
form = {
grant_type: 'client_credentials',
scope: this.config_entity[baseEntity].connection.auth.options.scope, // "https://graph.microsoft.com/.default"
client_id: this.config_entity[baseEntity]?.connection?.auth?.options?.clientId,
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
client_assertion: jsonwebtoken.sign(jwtClaims, privateKey, jwtOpts),
}
} else if (serviceAccountKeyFile) { // Google - using Service Account key json-file
if (!this.config_entity[baseEntity]?.connection?.auth?.options?.jwtPayload?.scope || !this.config_entity[baseEntity]?.connection?.auth?.options?.jwtPayload?.subject) {
const err = new Error(`auth type '${this.config_entity[baseEntity]?.connection?.auth?.type}' - using auth.options 'serviceAccountKeyFile' requires mandatory configuration entity.${baseEntity}.connection.auth.options.jwtPayload.scope/subject`)
throw err
}
let gkey: Record<string, any> = this.config_entity[baseEntity]?.connection?.auth?.options?._gkey
if (!gkey) {
gkey = await (async () => {
try {
const jsonObject = await import(serviceAccountKeyFile, { assert: { type: 'json' } })
return jsonObject.default // access the object via the `default` property
} catch (err: any) {
throw new Error(`auth type '${this.config_entity[baseEntity]?.connection?.auth?.type}' - serviceAccountKeyFile error: ${err.message}`)
}
})()
this.config_entity[baseEntity].connection.auth.options._gkey = gkey
}
tokenUrl = gkey.token_uri // https://oauth2.googleapis.com/token
const privateKey = gkey.private_key
const jwtPayload: jsonwebtoken.JwtPayload = {
sub: this.config_entity[baseEntity]?.connection?.auth?.options?.jwtPayload?.subject, // gmail sender mail-address: noreply@mycompany.com
iss: gkey.client_email, // service account email/user
aud: gkey.token_uri,
iat: Math.floor(Date.now() / 1000) - 60, // issued at
exp: Math.floor(Date.now() / 1000) + 3600, // expiration time
}
jwtClaims = {
...jwtPayload,
scope: this.config_entity[baseEntity]?.connection?.auth?.options?.jwtPayload?.scope, // https://www.googleapis.com/auth/gmail.send
}
jwtOpts = {
algorithm: 'RS256',
header: {
typ: 'JWT',
alg: 'RS256',
kid: gkey.client_id,
},
}
form = {
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: jsonwebtoken.sign(jwtClaims, privateKey, jwtOpts),
}
} else {
// standard JWT - requires all configuation: tokenUrl, jwtPayload and tls.key
if (!this.config_entity[baseEntity]?.connection?.auth?.options?.tokenUrl
|| !this.config_entity[baseEntity]?.connection?.auth?.options?.jwtPayload
|| typeof this.config_entity[baseEntity]?.connection?.auth?.options?.jwtPayload !== 'object') {
throw new Error(`auth.type '${this.config_entity[baseEntity]?.connection?.auth?.type}' (no tenantIdGUID/serviceAccountKeyFile using raw) - missing configuration entity.${baseEntity}.connection.auth.options.tokenUrl/jwtPayload`)
}
if (!this.config_entity[baseEntity]?.connection?.auth?.options?.tls?.key) {
throw new Error(`auth type '${this.config_entity[baseEntity]?.connection?.auth?.type}' (no tenantIdGUID/serviceAccountKeyFile using raw) - missing options.tls.key configuration`)
}
tokenUrl = this.config_entity[baseEntity].connection.auth.options.tokenUrl
let privateKey = this.config_entity[baseEntity]?.connection?.auth?.options?.tls?._key || ''
if (!privateKey) {
privateKey = fs.readFileSync(this.config_entity[baseEntity].connection.auth.options.tls.key, 'utf-8') || ''
if (privateKey) this.config_entity[baseEntity].connection.auth.options.tls._key = privateKey
}
let jwtPayload = this.config_entity[baseEntity].connection.auth.options.jwtPayload
if (!jwtPayload.iat) jwtPayload.iat = Math.floor(Date.now() / 1000) - 60
if (!jwtPayload.exp) jwtPayload.exp = Math.floor(Date.now() / 1000) + 3600
jwtClaims = {
...jwtPayload,
}
jwtOpts = {
algorithm: 'RS256',
header: {
typ: 'JWT',
alg: 'RS256',
},
}
form = {
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: jsonwebtoken.sign(jwtClaims, privateKey, jwtOpts),
}
}
break
default:
throw new Error(`getAccessToken() none supported entity.${baseEntity}.connection.auth.type: '${this.config_entity[baseEntity]?.connection?.auth?.type}'`)
}
if (!tokenUrl) {
throw new Error(`auth type '${this.config_entity[baseEntity]?.connection?.auth?.type}' - missing tokenUrl`)
}
this.scimgateway.logDebug(baseEntity, `${action}: Retrieving accesstoken`)
const method = 'POST'
let connOpt: any = {}
if (this.config_entity[baseEntity].connection.options && typeof this.config_entity[baseEntity].connection.options === 'object') {
connOpt = utils.copyObj(this.config_entity[baseEntity].connection.options)
}
if (!connOpt.headers) connOpt.headers = {}
connOpt.headers['Content-Type'] = 'application/x-www-form-urlencoded' // body must be query string formatted (no JSON)
const response = await this.doRequest(baseEntity, method, tokenUrl, form, ctx, connOpt)
if (!response.body) {
const err = new Error(`[${action}] No data retrieved from: ${method} ${tokenUrl}`)
throw (err)
}
const jbody = response.body
if (jbody.error) {
const err = new Error(`[${action}] Error message: ${jbody.error_description}`)
throw (err)
}
if (this.config_entity[baseEntity]?.connection?.auth?.type === 'token') { // in case response using token instead of access_token
if (jbody.token) jbody.access_token = jbody.token
else if (jbody.accessToken) jbody.access_token = jbody.accessToken
}
if (!jbody.access_token) {
const err = new Error(`[${action}] Error message: Retrieved invalid token response`)
throw (err)
}
const d = Math.floor(Date.now() / 1000) // seconds (unix time)
jbody.validTo = d + parseInt(jbody.expires_in) // instead of using expires_on (clock may not be in sync with NTP, AAD default expires_in = 3600 seconds)
this.lock.release()
return jbody
} catch (err) {
this.lock.release()
throw (err)
}
}
/**
* getServiceClient creates and return client.options on first call, successive calls returns already existing client.options
* @param baseEntity baseEntity
* @param method GET/PATCH/PUT/DELETE
* @param path e.g., /Users having baseUrl from configuration added, or full url e.g. https://mycompany.com/Users
* @param opt optional, connection options
* @param ctx optional, ctx included if using Auth PassThrough
* @returns client.options needed for connect
*/
private async getServiceClient(baseEntity: string, method: string, path: string, opt?: any, ctx?: any) {
const action = 'getServiceClient'
let urlObj: any
if (!path) path = ''
try {
urlObj = new URL(path)
} catch (err) {
//
// path (no url) - default approach and client will be cached based on config
//
if (this._serviceClient[baseEntity]) { // serviceClient already exist - token specific
this.scimgateway.logDebug(baseEntity, `${action}: Using existing client`)
if (this._serviceClient[baseEntity].accessToken) {
// check if token refresh is needed when using oauth
const d = Math.floor(Date.now() / 1000) // seconds (unix time)
if (this._serviceClient[baseEntity].accessToken.validTo < d + 30) { // less than 30 sec before token expiration
this.scimgateway.logDebug(baseEntity, `${action}: Accesstoken about to expire in ${this._serviceClient[baseEntity].accessToken.validTo - d} seconds`)
try {
const accessToken = await this.getAccessToken(baseEntity, ctx)
this._serviceClient[baseEntity].accessToken = accessToken
this._serviceClient[baseEntity].options.headers['Authorization'] = ` Bearer ${accessToken.access_token}`
} catch (err) {
delete this._serviceClient[baseEntity]
const newErr = err
throw newErr
}
}
}
} else {
this.scimgateway.logDebug(baseEntity, `${action}: Client have to be created`)
let client = null
if (this.config_entity && this.config_entity[baseEntity]) client = this.config_entity[baseEntity]
if (!client) {
const err = new Error(`unsupported baseEntity: ${baseEntity}`)
throw err
}
if (!this.config_entity[baseEntity]?.connection?.baseUrls || !Array.isArray(this.config_entity[baseEntity].connection.baseUrls) || this.config_entity[baseEntity].connection.baseUrls.length < 1) {
const err = new Error(`missing configuration entity.${baseEntity}.connection.baseUrls`)
throw err
}
urlObj = new URL(this.config_entity[baseEntity].connection.baseUrls[0])
const param: any = {
baseUrl: this.config_entity[baseEntity].connection.baseUrls[0],
options: {
json: true, // json-object response instead of string
headers: {
Accept: 'application/json',
},
host: urlObj.hostname,
port: urlObj.port, // null if https and 443 defined in url
protocol: urlObj.protocol, // http: or https:
// 'method' and 'path' added at the end
},
}
// Support no auth, header based auth (e.g., config {"options":{"headers":{"APIkey":"123"}}}),
// basicAuth, bearerAuth, oauth, tokenAuth, oauthSamlBearer, oauthJwtBearer and auth PassTrough using request header authorization
let orgConnection: any
if (opt?.connection) { // allow overriding/extending configuration connection by caller argument opt.connection
let org = this.config_entity[baseEntity]?.connection
orgConnection = utils.copyObj(org)
if (!org) org = {}
org = utils.extendObj(org, opt.connection)
}
// may use configuration type='oauth' and auto corrected to 'oauthJwtBearer'
if (this.config_entity[baseEntity]?.connection?.auth?.type == 'oauth') {
if (this.config_entity[baseEntity].connection.auth?.options?.tenantIdGUID) {
if (this.config_entity[baseEntity].connection.auth.options?.tls?.cert
&& this.config_entity[baseEntity].connection.auth.options?.tls?.key
&& this.config_entity[baseEntity].connection.auth.options.clientId
) this.config_entity[baseEntity].connection.auth.type = 'oauthJwtBearer'
} else if (this.config_entity[baseEntity]?.connection?.auth?.options?.serviceAccountKeyFile) {
this.config_entity[baseEntity].connection.auth.type = 'oauthJwtBearer'
}
}
switch (this.config_entity[baseEntity]?.connection?.auth?.type) {
case 'basic':
if (!this.config_entity[baseEntity]?.connection?.auth?.options?.username || !this.config_entity[baseEntity]?.connection?.auth?.options?.password) {
const err = new Error(`auth.type 'basic' - missing configuration entity.${baseEntity}.connection.auth.options.username/password`)
throw err
}
param.options.headers['Authorization'] = 'Basic ' + Buffer.from(`${this.config_entity[baseEntity].connection.auth.options.username}:${this.config_entity[baseEntity].connection.auth.options.password}`).toString('base64')
break
case 'oauth':
if (!this.config_entity[baseEntity]?.connection?.auth?.options?.clientId || !this.config_entity[baseEntity]?.connection?.auth?.options?.clientSecret) {
const err = new Error(`auth.type 'oauth' - missing configuration entity.${baseEntity}.connection.auth.options.clientId/clientSecret`)
throw err
}
param.accessToken = await this.getAccessToken(baseEntity, ctx)
param.options.headers['Authorization'] = `Bearer ${param.accessToken.access_token}`
break
case 'token':
if (!this.config_entity[baseEntity]?.connection?.auth?.options?.tokenUrl || !this.config_entity[baseEntity]?.connection?.auth?.options?.password) {
const err = new Error(`missing configuration entity.${baseEntity}.connection.auth.options.tokenUrl/password`)
throw err
}
param.accessToken = await this.getAccessToken(baseEntity, ctx)
param.options.headers['Authorization'] = `Bearer ${param.accessToken.access_token}`
break
case 'bearer':
if (!this.config_entity[baseEntity]?.connection?.auth?.options?.token) {
const err = new Error(`missing configuration entity.${baseEntity}.connection.auth.options.token`)
throw err
}
param.options.headers['Authorization'] = 'Bearer ' + Buffer.from(this.config_entity[baseEntity].connection.auth.options.token).toString('base64')
break
case 'oauthSamlBearer':
if (!this.config_entity[baseEntity]?.connection?.auth?.options?.samlPayload?.clientId || !this.config_entity[baseEntity]?.connection?.auth?.options?.samlPayload?.companyId
|| !this.config_entity[baseEntity]?.connection?.auth?.options?.tls?.key) {
const err = new Error(`auth.type 'oauthSamlBearer' - missing configuration entity.${baseEntity}.connection.auth.options.tls and/or options.samlPayload.clientId/companyId`)
throw err
}
param.accessToken = await this.getAccessToken(baseEntity, ctx)
param.options.headers['Authorization'] = `Bearer ${param.accessToken.access_token}`
break
case 'oauthJwtBearer':
// auth.options.tenantIdGUID => Microsoft Entra ID
// auth.options.serviceAccountKeyFile => Google Service Account
// also support custom using tokenUrl/jwtPayload
param.accessToken = await this.getAccessToken(baseEntity, ctx)
param.options.headers['Authorization'] = `Bearer ${param.accessToken.access_token}`
break
default:
// no auth or PassTrough
}
if (orgConnection) {
this.config_entity[baseEntity].connection = orgConnection // reset back to original
if (opt?.connection) delete opt.connection
}
// proxy
if (this.config_entity[baseEntity]?.connection?.proxy?.host) {
const agent = new HttpsProxyAgent(this.config_entity[baseEntity].connection.proxy.host)
param.options.agent = agent // proxy
if (this.config_entity[baseEntity].connection.proxy.username && this.config_entity[baseEntity].connection.proxy.password) {
param.options.headers['Proxy-Authorization'] = 'Basic ' + Buffer.from(`${this.config_entity[baseEntity].connection.proxy.username}:${this.config_entity[baseEntity].connection.proxy.password}`).toString('base64') // using proxy with auth
}
}
if (this.config_entity[baseEntity]?.connection?.options) { // http connect options
const connOpt: any = utils.copyObj(this.config_entity[baseEntity].connection.options)
try {
// using fs.readFileSync().toString() instead of Bun.file().text() for nodejs compability
if (connOpt?.tls?.key) connOpt.tls.key = fs.readFileSync(connOpt.tls.key).toString()
if (connOpt?.tls?.cert) connOpt.tls.cert = fs.readFileSync(connOpt.tls.cert).toString()
if (connOpt?.tls?.ca) connOpt.tls.ca = [fs.readFileSync(connOpt.tls.ca).toString()]
} catch (err: any) {
throw new Error(`tls configuration error: ${err.message}`)
}
if (connOpt.tls && Object.prototype.hasOwnProperty.call(connOpt.tls, 'rejectUnauthorized')) {
if (connOpt.tls.rejectUnauthorized !== false && connOpt.tls.rejectUnauthorized !== true) {
delete connOpt.tls.rejectUnauthorized
}
}
// currently nodejs do not support fetch using tls options
// connOpt.agent = new Agent({key/cert/ca/rejectUnauthorized: <>})
// for tls and nodejs, environment must instead be used and set before started, e.g.,:
// export NODE_EXTRA_CA_CERTS=/plugin-path/config/certs/ca.pem
// export NODE_TLS_REJECT_UNAUTHORIZED=0
param.options = utils.extendObj(param.options, connOpt)
}
if (!this._serviceClient[baseEntity]) this._serviceClient[baseEntity] = {}
this._serviceClient[baseEntity] = param // serviceClient created
// OData support
this._serviceClient[baseEntity].nextLink = {} // OData pagination (Entra ID)
this._serviceClient[baseEntity].nextLink.users = null
this._serviceClient[baseEntity].nextLink.groups = null
}
if (ctx?.headers?.get) { // Auth PassThrough using ctx header
this._serviceClient[baseEntity].options.headers['Authorization'] = ctx.headers.get('authorization')
}
const cli: any = utils.copyObj(this._serviceClient[baseEntity]) // client ready
// failover support
path = this._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) {
if (opt?.connection) delete opt.connection // only used for internal connection options
cli.options = utils.extendObj(cli.options, opt) // merge with argument options
}
return cli // final client
}
//
// url path - none config based (enpoint.entity) and used as is (no cache)
//
this.scimgateway.logDebug(baseEntity, `${action}: Using raw client`)
let options: any = {
json: true,
headers: {
Accept: 'application/json',
},
host: urlObj.hostname,
port: urlObj.port,
protocol: urlObj.protocol,
method: method,
path: urlObj.pathname + urlObj.search,
}
// proxy
if (this.config_entity[baseEntity]?.connection?.proxy?.host) {
const agent = new HttpsProxyAgent(this.config_entity[baseEntity].connection.proxy.host)
options.agent = agent // proxy
if (this.config_entity[baseEntity].connection.proxy.username && this.config_entity[baseEntity].connection.proxy.password) {
options.headers['Proxy-Authorization'] = 'Basic ' + Buffer.from(`${this.config_entity[baseEntity].connection.proxy.username}:${this.config_entity[baseEntity].connection.proxy.password}`).toString('base64') // using proxy with auth
}
}
// merge any argument options - support basic auth using {auth: {username: "username", password: "password"} }
if (opt) {
const o: any = utils.copyObj(opt)
if (o.auth) {
options.headers['Authorization'] = 'Basic ' + Buffer.from(`${o.auth.username}:${o.auth.password}`).toString('base64')
delete o.auth
}
options = utils.extendObj(options, o)
}
const cli: any = {}
cli.options = options
return cli // final client
}
/**
* updateServiceClient merges obj with _serviceClient
* @param baseEntity
* @param obj
*/
private updateServiceClient(baseEntity: string, obj: any) {
if (this._serviceClient[baseEntity]) this._serviceClient[baseEntity] = utils.extendObj(this._serviceClient[baseEntity], obj)
}
/**
* doRequestHandler executes REST request and returns response
* started by public doRequest() and includes param retryCount
* @param baseEntity baseEntity
* @param method GET, PATCH, PUT, DELETE
* @param path path e.g., /Users (baseUrls configuration will automatically be included) or use full url e.g., https://my-company.com/Users
* @param body body
* @param ctx ctx when using Auth PassThrough
* @param opt web-standard fetch client options, e.g., options not defined as general options in configuration file
* @param retryCount internal use only - internal counter for retry and failover logic to other baseUrls defined
**/
private async doRequestHandler(baseEntity: string, method: string, path: string, body?: any, ctx?: any, opt?: any, retryCount?: number): Promise<any> {
let retryAfter = 0
try {
const cli = await this.getServiceClient(baseEntity, method, path, opt, ctx)
const options = cli.options
let dataString = ''
if (body) {
if (options.headers['Content-Type']) {
const type: string = options.headers['Content-Type'].toLowerCase().trim()
if (type.startsWith('application/x-www-form-urlencoded')) {
if (typeof body === 'string') dataString = body
else dataString = querystring.stringify(body) // JSON to query string syntax + URL encoded
} else {
if (typeof body === 'string') dataString = body
else dataString = JSON.stringify(body)
}
} else {
options.headers['Content-Type'] = 'application/json; charset=utf-8'
if (typeof body === 'string') dataString = body
else dataString = JSON.stringify(body)
}
options.headers['Content-Length'] = Buffer.byteLength(dataString, 'utf8')
options.body = dataString
} else delete options.headers['Content-Type']
const controller = new AbortController()
const signal = controller.signal
const timeout = setTimeout(() => controller.abort(), options.abortTimeout ? options.abortTimeout * 1000 : this.idleTimeout * 1000) // 120 seconds default abort timeout
options.signal = signal
const url = `${options.protocol}//${options.host}${options.port ? ':' + options.port : ''}${options.path}`
// execute request
const f = await fetch(url, options)
clearTimeout(timeout)
if (!f.status) throw new Error('response missing statusCode header')
const result: any = {
statusCode: f.status,
statusMessage: f.statusText,
body: null,
}
const contentType = f.headers.get('content-type')
if (contentType) {
if (contentType.includes('json')) result.body = await f.json()
else {
result.body = await f.text()
try {
result.body = JSON.parse(result)
} catch (err) { void 0 }
}
}
if (f.status < 200 || f.status > 299) {
if (f.status === 429) { // throttle
const v = f.headers.get('retry-after')
if (v) retryAfter = parseInt(v, 10) + 1
else retryAfter = 10
}
throw new Error(JSON.stringify(result))
}
this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${options.protocol}//${options.host}${(options.port ? `:${options.port}` : '')}${options.path} Body = ${JSON.stringify(body)} Response = ${JSON.stringify(result)}`)
if (result.body && typeof result.body === 'object' && result.body['@odata.nextLink']) { // {"@odata.nextLink": "https://graph.microsoft.com/beta/users?$top=100&$skiptoken=xxx"}
// OData paging
const nextUrl = result.body['@odata.nextLink'].split('?')[1] // keep search query
const arr = result['@odata.nextLink'].split('?')[0].split('/')
const objType = (arr[arr.length - 1]) // users
let startIndexNext = ''
if (this._serviceClient[baseEntity].nextLink[objType]) {
for (const k in this._serviceClient[baseEntity].nextLink[objType]) {
if (this._serviceClient[baseEntity].nextLink[objType][k] === nextUrl) return result // repetive startIndex=1
startIndexNext = k
break
}
}
const a = result.body['@odata.nextLink'].split('top=')
let top = '0'
if (a.length > 1) {
top = a[1].split('&')[0]
}
if (!startIndexNext) startIndexNext = (Number(top) + 1).toString()
else startIndexNext = (Number(startIndexNext) + Number(top) + 1).toString()
// reset and set new nextLink
this._serviceClient[baseEntity].nextLink[objType] = {}
this._serviceClient[baseEntity].nextLink[objType][startIndexNext] = nextUrl
}
return result
} catch (err: any) { // includes failover/retry logic based on config baseUrls array
let statusCode
try { statusCode = JSON.parse(err.message).statusCode } catch (e) { void 0 }
if (err.message.includes('ratelimit')) { // have seen throttling not follow standard 429/retry-after, but instead using 500 and error message only
if (!retryAfter) retryAfter = 60
}
if (!retryCount) retryCount = 0
let urlObj
try { urlObj = new URL(path) } catch (err) { void 0 }
let isServiceClient = !urlObj && this._serviceClient[baseEntity] && !this.lock.isLocked() // !isLocked to avoid retry ongoing doRequest with failing getAccessToken()
let oAuthTokeErr = statusCode === 401 && this.config_entity[baseEntity].connection?.auth?.type && this.config_entity[baseEntity].connection.auth.type.startsWith('oauth')
if (isServiceClient && (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND' || err.code === 'ABORT_ERR' || err.code === 'ETIMEDOUT' || oAuthTokeErr || retryAfter)) {
this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
if (retryAfter) {
this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${path} throttle/ratelimit error - awaiting ${retryAfter} seconds before automatic retry`)
await new Promise(resolve => setTimeout(function () {
resolve(null)
}, retryAfter * 1000))
}
if (retryCount < this.config_entity[baseEntity].connection.baseUrls.length) {
retryCount++
this.updateServiceClient(baseEntity, { baseUrl: this.config_entity[baseEntity].connection.baseUrls[retryCount - 1] })
this.scimgateway.logDebug(baseEntity, `${(this.config_entity[baseEntity].connection.baseUrls.length > 1) ? 'failover ' : ''}retry[${retryCount}] using baseUrl = ${this._serviceClient[baseEntity].baseUrl}`)
if (oAuthTokeErr) {
delete this._serviceClient[baseEntity] // ensure new getAccessToken request - token used should not have been expired, but rejected for other reason e.g. token server restart and no persistent token store?
}
const ret = await this.doRequestHandler(baseEntity, method, path, body, ctx, opt, retryCount) // retry
return ret // problem fixed
} else {
if (statusCode === 404) { // not logged as error e.g. getUser-manager
this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
} else this.scimgateway.logError(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
throw err
}
} else {
if (statusCode === 404) { // not logged as error e.g. getUser-manager
this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
} else this.scimgateway.logError(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
if (statusCode === 401) delete this._serviceClient[baseEntity]
throw err
}
}
}
/**
* doRequest executes REST request and return response
* @param baseEntity baseEntity
* @param method GET, PATCH, PUT, DELETE
* @param path path e.g., /Users (baseUrls configuration will be used), optional use full url e.g., https://my-company.com/Users
* @param body optional, body
* @param ctx optional, ctx when using Auth PassThrough
* @param opt optional, web-standard fetch client options, e.g., using custom options not defined as general options in configuration file
* @remarks
* configuration file description
* ```
* {
* "scimgateway": { ... }
* "endpoint": {
* "entity": {
* "undefined": {
* "connection": {
* "baseUrls": [ // ignored when using option tenantIdGUID
* "<baseUrl>", // "https://host1.company.com:8880",
* "<baseUrl2>" // optional using several baseUrls for failover
* ],
* "auth": {
* "type": "<type>",
* "options": { <auth.options> }
* },
* "options": { <connection.options> }
* "proxy": {
* "host": "<host>", // http://proxy-host:1234
* "username": "<username>", // username if authentication is required
* "password": "<password>" // password if authentication is required
* }
* }
* }
* }
* }
* }
* ```
* type defines authentication being used
* if type not defined, no authentication used
* valid type is: `basic`, `oauth`, `token`, `bearer`, `oauthSamlBearer` or `oauthJwtBearer`
*
* for each valid type there are different auth.options
*
* type=**"basic"** having auth.options:
* ```
* {
* "options": {
* "username": "<username>",
* "password": "<password>"
* }
* }
* ```
*
* type=**"oauth"** having auth.options:
* ```
* {
* "options": {
* "tenantIdGUID": "<Entra ID tenantIdGUID", // Entra ID authentication - if baseUrls not defined, baseUrls automatically set to [https://graph.microsoft.com/beta]
* "tokenUrl": "<tokenUrl>", // must be set if not using tenantIdGUID
* "clientId": "<clientId>",
* "clientSecret": "<clientSecret>"
* }
* }
* ```
*
* type=**"token"** having auth.options:
* ```
* {
* "options": {
* "tokenUrl": "<url for requesting token">
* "username": "<user name for token request>"
* "password": "<password for token request>"
* }
* }
* ```
*
* type=**"bearer"** having auth.options:
* ```
* {
* "options": {
* "token": "<bearer token to be used">
* }
* }
* ```
*
* type=**"oauthSamlBearer"** having auth.options:
* ```
* {
* "options": {
* "tokenUrl": "<tokenUrl>",
* "samlPayload": {
* "clientId": "<clientId>",
* "companyId": "<companyId>",
* "nameId": "<nameId>",
* "lifetime": "<optional>"
* "issuer": "<optional>",
* "userIdentifierFormat": "<optional>",
* "audience": "<optional>"
* },
* "tls": {
* "key": "<key-file-name>", // location: config/certs
* "cert": "<cert-file-name>", // location: config/certs
* }
* }
* }
* ```
*
* type=**"oauthJwtBearer"** having auth.options:
* ```
* // Microsoft Entra ID
* {
* "options": {
* "tenantIdGUID": "<Entra ID tenantIdGUID", // Entra ID authentication, if baseUrls not defined, baseUrls automatically set to [https://graph.microsoft.com/beta]
* "clientId": "<clientId>",
* "tls": { // files located in ./config/certs
* "key": "key.pem",
* "cert": "cert.pem"
* }
* }
* }
*
* // Google Cloud Platform - GCP
* {
* "options": {
* "serviceAccountKeyFile": "<Google Service Account key file name>", // located in ./config/certs. If baseUrls not defined, baseUrls automatically set to [https://www.googleapis.com]
* "scope": "<jwt-scope>",
* "subject": "<jwt-subject>
* }
* }
*
* // General JWT API
* {
* "options": {
* "tokenUrl": "<tokenUrl",
* "tls": {
* "key": "<signing-key-file-name>" // key.pem file located in ./config/certs
* },
* "jwtPayload": {
* "sub": "<subject>",
* "iss": "<issuer>",
* "aud": "<audience>",
* ...
* }
* }
* }
* ```
*
* **connection.options** can be set according to web-standard fetch client options
* examples:
* ```
* {
* "options": {
* "tls": {
* "key": "<key-file-name>", // location: config/certs
* "cert": "<cert-file-name>", // location: config/certs
* "ca": "<ca-file-name>", // location: config/certs
* "rejectUnauthorized": <true/false>
* },
* "headers": {
* "<header1>", "<key1>",
* "<header2>", "<key2>"
* }
* }
* }
* ```
*
**/
public async doRequest(baseEntity: string, method: string, path: string, body?: any, ctx?: any, opt?: any) {
return await this.doRequestHandler(baseEntity, method, path, body, ctx, opt)
}
/**
* nextLinkPaging returns paging url when using OData e.g., Entra ID
* @param baseEntity baseEntity
* @param objType e.g., 'users' or 'groups', a type that corresponds with what's being used by endpoint url request
* @param startIndex SCIM startIndex paramenter
* @returns paging url to be used
**/
public nextLinkPaging(baseEntity: string, objType: string, startIndex: number) {
objType = objType.toLowerCase() // users or groups
let nextPath = ''
if (!startIndex || !this._serviceClient[baseEntity]) return ''
if (startIndex < 2) {
if (this._serviceClient[baseEntity].nextLink[objType]) {
this._serviceClient[baseEntity].nextLink[objType] = null
}
return ''
}
if (this._serviceClient[baseEntity].nextLink[objType]) {
if (this._serviceClient[baseEntity].nextLink[objType][startIndex]) {
nextPath = `/users?${this._serviceClient[baseEntity].nextLink[objType][startIndex]}`
} else {
this._serviceClient[baseEntity].nextLink[objType] = null
return ''
}
} else {
return ''
}
return nextPath
}
/**
* getGraphUrl returns Microsoft Graph API url used for Entra ID
* @returns Microsoft Graph API url
**/
public getGraphUrl(): string {
return this.graphUrl
}
} // class HelperRest