UNPKG

@jovian/type-tools

Version:

TypeTools is a Typescript library for providing extensible tooling runtime validations and type helpers.

1,346 lines (1,282 loc) 55.4 kB
/* * Copyright 2014-2021 Jovian, all rights reserved. */ import * as express from 'express'; import { completeConfig } from '../../src/common/util/config.util'; import { proxyParameterFunctionToNull } from '../../src/common/util/convenience/dev.null.proxy'; import { Class, configBoolean } from '../../src/type-transform'; import { AsyncWorkerClient } from '../proc/async.worker.proc'; import { SecureChannel, SecureHandshake } from '../secure-channel/secure-channel'; import { ServerConstDataGlobal } from './http.shim.global.conf'; import { HttpCode, HttpMethod, httpRest } from './http.models'; import { SecureChannelWorkerClient } from './http.shim.worker.security'; import * as defaultConfig from './http.shim.default.config.json'; import * as defaultGlobalConfig from './http.shim.global.conf.json'; import * as axios from 'axios'; import { errorResult, GenericResult, ok, passthru, promise, Promise2, PromUtil, Result, ReturnCodeFamily } from '../../src/common/globals.ix'; import { SecretManager } from '../secret-resoluton/secret-resolver'; import { DestorClient, getDestorClient } from './destor/destor.client'; import * as url from 'url'; import { AccessHeaderObject, SecureChannelPayload, SecureChannelPeer, SecureChannelTypes } from '../../src/common/security/security.common'; import { ProcessExit } from '../proc/process.exit.handler'; import ExpressLib from 'express'; enum HttpShimCodeEnum { ACCESSOR_HEADER_NOT_FOUND, ACCESSOR_BAD_FORMAT, NO_ACCESSOR, ENCRYPTED_OP_NO_SECURE_PAYLOAD, ENCRYPTED_OP_PATH_NOT_FOUND, ENCRYPTED_OP_METHOD_NOT_FOUND, ENCRYPTED_OP_NON_JSON_PAYLOAD, SECURE_CHANNEL_NOT_FOUND, AUTH_HEADER_NOT_FOUND, AUTH_HEADER_NOT_VALID, AUTH_HEADER_SIGNED_BUT_PUBLIC_KEY_NOT_FOUND, AUTH_HEADER_SIGNED_NO_ROLES_MAP, AUTH_HEADER_SIGNED_ROLE_UNAUTHORZIED_FOR_API, AUTH_HEADER_SIGNED_BUT_API_DENIES_ALL, } export const HttpShimCode = ReturnCodeFamily('HttpShimCode', HttpShimCodeEnum); export enum ReqProcessor { AUTH = 'AUTH', BASIC = 'BASIC', DECRYPT = `DECRYPT`, ENCRYPT = `ENCRYPT`, } export interface ServerStartOptions { port: number; } interface HttpParams { [paramName: string]: any; } type PropType<TObj, TProp extends keyof TObj> = TObj[TProp]; export type ParamDef<T extends {[key: string]: {type: string, default: any, required?: boolean | Class<any>;}}> = { [key in keyof T]: PropType<T[key], 'default'> } export type HttpOpType<T extends { params: {[key: string]: {type: string, default: any, required?: boolean | Class<any>;}}, returns: {type: string, default: any}}> = HttpOp<ParamDef<T['params']>, T['returns']['default']>; export class HttpApiOptions<Params = HttpParams, Returns = any> { rootMount?: configBoolean; rootVersionMount?: configBoolean; pre?: string[]; post?: string[]; } export type HttpApiRoleAccess<RoleBook, Params = HttpParams> = { [key in keyof RoleBook]?: configBoolean | Class<any> | { [param in keyof Params]?: ValueConstraintRules }; } export type HttpOpParamType = ( 'string' | 'string-base64' | 'string-bigint' | 'number' | 'boolean' | 'configBoolean' | 'array' | 'object' ); export class HttpServerShimApi<Params = HttpParams, Returns = any> extends HttpApiOptions<Params, Returns> { class: Class<any>; className: string; server?: HttpServerShim; path = ''; apiPath?: string; apiVersion?: string; public?: boolean; fullpath?: string = ''; method = HttpMethod.GET; handlerName?: string; parameters?: { [paramName in keyof Params]: HttpOpParamType }; preDefaultProcesserAdded?: boolean; postDefaultProcesserAdded?: boolean; registered?: boolean; } export enum HttpBaseLib { EXPRESS = 'EXPRESS', } export interface HttpShimPublicInfo<RoleBook = any> { tokenRequired: boolean; accessorRequired: boolean; secureChannelScheme: SecureChannelTypes; secureChannelPublicKey: string; secureChannelStrict: boolean; secureChannelRequired: boolean; apiPathList: string[]; apiInterface: {[methodName: string]: HttpApiRoleAccess<RoleBook>}; } export type ValueConstraint = [ 'is' | 'exactly' | 'pattern' | 'startsWith', string | number | boolean ]; export type ValueConstraintRules = (ValueConstraint | 'OR' | 'AND' | '(' | ')')[]; export interface HttpServerShimConfig { name?: string; env?: string; type: HttpBaseLib | string; scopeName?: string; debug?: { showErrorStack?: boolean; }; cache?: { defaultCacheParser?: CacheParser; }; security?: { noauth?: boolean; token?: { required?: boolean; value: string; role: string; } userToken?: { required?: boolean; map: {[token: string]: { user: string; role: string; }}; }; accessor?: { required?: boolean; baseToken?: string; baseTokenBuffer?: Buffer; timeHashed?: boolean; timeWindow?: number; role?: string; }; secureChannel?: { required?: boolean; enabled?: boolean; strict?: boolean; encryption?: SecureChannelTypes; publicKey?: string; signingKey?: string; }; }; workers?: { secureChannelWorkers?: { initialCount?: number; } }; startOptions?: ServerStartOptions; skipConfigSecretResolution?: boolean; skipAuthServerResolution?: boolean; } export function isClass(target) { return !!target.prototype && !!target.constructor.name; } function methodsRegister<Params = any, RoleBook = any>(httpMethods: HttpMethod[], path: string, apiOptions?: HttpApiOptions<RoleBook>) { path = path.replace(/\/\//g, '/'); return (target: HttpServerShim<RoleBook>, propertyKey: string, descriptor: PropertyDescriptor) => { for (const httpMethod of httpMethods) { const apiKey = `${httpMethod} ${path}`; const methodApi: HttpServerShimApi<Params> = { class: target.constructor as any, className: target.constructor.name, method: httpMethod, path, handlerName: propertyKey }; if (apiOptions) { Object.assign(methodApi, apiOptions); } if (!target.apiMap) { target.apiMap = {}; } target.apiMap[apiKey] = methodApi; if (!target.apiRegistrations) { target.apiRegistrations = []; } target.addRegistration(methodApi); } }; } type Tail<T extends any[]> = ((...t: T)=>void) extends ((h: any, ...r: infer R)=>void) ? R : never; type Last<T extends any[]> = T[Exclude<keyof T, keyof Tail<T>>]; type HttpMethodRegistration = <Params = HttpParams>(path: string, apiOptions?: HttpApiOptions<Params, any>) => (target: HttpServerShim<any>, propertyKey: string, descriptor: PropertyDescriptor) => void; type HttpMethodsRegistration = <Params = HttpParams>(methods: HttpMethod[], path: string, apiOptions?: HttpApiOptions<Params, any>) => (target: HttpServerShim<any>, propertyKey: string, descriptor: PropertyDescriptor) => void; /** * HTTP api registration decorator */ export class HTTP { static GET = (<Params = HttpParams>(path: string, apiOptions?: HttpApiOptions<Params>) => { return methodsRegister([HttpMethod.GET], path, apiOptions); }) as (HttpMethodRegistration & Class<any>); static POST = (<Params = HttpParams>(path: string, apiOptions?: HttpApiOptions<Params>) => { return methodsRegister([HttpMethod.POST], path, apiOptions); }) as (HttpMethodRegistration & Class<any>); static PATCH = (<Params = HttpParams>(path: string, apiOptions?: HttpApiOptions<Params>) => { return methodsRegister([HttpMethod.PATCH], path, apiOptions); }) as (HttpMethodRegistration & Class<any>); static DELETE = (<Params = HttpParams>(path: string, apiOptions?: HttpApiOptions<Params>) => { return methodsRegister([HttpMethod.DELETE], path, apiOptions); }) as (HttpMethodRegistration & Class<any>); static METHODS = (<Params = HttpParams>(methods: HttpMethod[], path: string, apiOptions?: HttpApiOptions<Params>) => { return methodsRegister(methods, path, apiOptions); }) as (HttpMethodsRegistration & Class<any>); static ACCESS = <RoleBook extends {[roleName: string]: any} = any>(access: HttpApiRoleAccess<RoleBook> | 'allow-all' | 'deny-all') => { return (target: HttpServerShim<RoleBook>, propertyKey: string, descriptor: PropertyDescriptor) => { if (typeof access === 'string') { const strAccess = access; access = {}; Object.defineProperty(access, strAccess, { value: true }); } Object.defineProperty(access, 'class', { value: target.constructor}); target.addAccessRule(propertyKey as any, access as HttpApiRoleAccess<RoleBook, HttpParams>); }; }; static ACL = this.ACCESS; static SHIM = { ROOT_API_PROXY_REQUEST: '/proxy-request', ROOT_API_PUBLIC_INFO: '/public-info', ROOT_API_NEW_CHANNEL: '/secure-channel', ROOT_API_SECURE_API: '/secure-api', }; static STATUS = HttpCode; }; export class HttpServerShim<RoleRubric = any> { config: HttpServerShimConfig; configGlobal: ServerConstDataGlobal; configResolutionPromise: Promise<any>; publicInfo: any = {}; publicInfoString: string = ''; baseApp: any; authServers: {[url: string]: { type: 'jwt' | '4q_stamp'; publicKey: string; token?: string}} = {}; apiPath: string = 'api'; apiVersion: string = 'v1'; apiRegistrations: HttpServerShimApi[]; apiAccess: {[methodName: string]: HttpApiRoleAccess<RoleRubric>}; apiMap: {[key: string]: HttpServerShimApi; }; apiPathList: string[] = []; apiPathIface: {[mathodAndPath: string]: { method: string; path: string; handlerName: string; description: string; params: HttpParams; returns: any; acl: HttpApiRoleAccess<RoleRubric, HttpParams> }} = {}; rolebook: RoleRubric | {[roleName: string]: any}; destor: DestorClient; destorPromise: Promise2<DestorClient>; pathTree: {[key: string]: any; } = {}; preHandler: PreHandler; postHandler: PostHandler; defaultProcessors: ReqProcessor[] = []; proxyRequest = { enabled: false, requestCheckers: [], } as { enabled: boolean, requestCheckers?: ((params: {[paramName: string]: any}) => Promise2<{ allowed: boolean, message?: string}>)[] }; secureChannels: {[channelId: string]: SecureChannel} = {}; workerFleet: { [workerFleetClassName: string]: { workers: AsyncWorkerClient[]; } } = {}; cacheData: {[key: string]: CacheEntry} = {}; extData: any; state = { activePort: 0, closed: false, started: false, apiRegistered: false, apiRegisterStack: null, closingPromise: null as Promise<any>, }; baseLibData = { express: { server: null, }, }; constructor(config: HttpServerShimConfig, globalConf?: ServerConstDataGlobal, beforeSuper?: () => any) { if (beforeSuper) { beforeSuper(); } this.configGlobal = completeConfig(globalConf ? globalConf : {}, defaultGlobalConfig); this.config = this.normalizeServerConfig(config); this.preHandler = new PreHandler(); this.postHandler = new PostHandler(); this.configResolutionPromise = this.configResolution(); this.setBaseLayer(); if (!this.config.name) { this.config.name = 'unnamed-server'; } if (!this.config.env) { this.config.env = 'test'; } ProcessExit.addHandler(e => { this.close(); }); } async configResolution() { if (!this.config.skipConfigSecretResolution) { const destor = await this.getDestorClient(); this.config = await SecretManager.resolve(this.config, destor); if (!this.config.skipAuthServerResolution) { this.authServers = await SecretManager.resolve('<config.authServers>', destor) as any as typeof this.authServers; } } if (this.config.security.secureChannel.enabled && this.config.security.secureChannel.signingKey) { const channelKey = this.config.security.secureChannel.signingKey; if (!this.config.security.secureChannel.publicKey && channelKey && !channelKey.startsWith('<')) { this.config.security.secureChannel.publicKey = SecureHandshake.getPublicKeyFrom(channelKey); } for (let i = 0; i < this.config.workers.secureChannelWorkers.initialCount; ++i) { this.addWorker(SecureChannelWorkerClient, { workerId: i, scopeName: this.config.scopeName, signingKey: channelKey, }); } } this.configResolutionPromise = null; this.afterConfigResolution(); } registerApis() { if (this.state.apiRegistered) { throw new Error(`Cannot register apis twice; already registered from ${this.state.apiRegisterStack}`); } this.state.apiRegistered = true; this.state.apiRegisterStack = new Error().stack; for (const api of this.apiRegistrations) { if (this instanceof api.class){ this.register(api); } } } normalizeServerConfig(config: HttpServerShimConfig) { if (!config.scopeName) { config.scopeName = `httpshim;pid=${process.pid}`; } const newConfig = completeConfig<HttpServerShimConfig>(config, defaultConfig as any); newConfig.debug.showErrorStack = true; return newConfig; } addDefaultProcessor(...processors: ReqProcessor[]) { if (this.state.apiRegistered) { throw new Error(`addDefaultProcessor must be called before api registration`); } for (const proc of processors) { this.defaultProcessors.push(proc); } } cacheDefine<T = any>(init?: Partial<CacheDef<T>>) { if (this.cacheData[init.path]) { throw new Error(`Cache path '${init.path}' is already defined.`); } const def = new CacheDef<T>(init); this.cacheData[def.path] = new CacheEntry<T>({ value: null, hits: 0, version: 0, def, }); return def; } addWorker<T extends AsyncWorkerClient>(workerClass: Class<T>, workerData?: {[key: string]: any; }) { if (!workerData) { workerData = {}; } if (!this.workerFleet[workerClass.name]) { this.workerFleet[workerClass.name] = { workers: [] }; } const workersReg = this.workerFleet[workerClass.name]; const worker = new workerClass(workerData); workersReg.workers.push(worker); return worker; } pickWorker<T extends AsyncWorkerClient>(workerClass: Class<T>): T { if (!this.workerFleet[workerClass.name]) { return proxyParameterFunctionToNull; } const workers = this.workerFleet[workerClass.name].workers; if (workers.length === 0) { return proxyParameterFunctionToNull; } return this.workerFleet[workerClass.name].workers[0] as T; } setBaseLayer() { switch (this.config.type) { case HttpBaseLib.EXPRESS: this.baseApp = (express.default as any)(); const secOptions = this.configGlobal.http.securityHeaders; if (secOptions.profile === 'allow-all') { this.baseApp.use((req, res, next) => { if (secOptions.allowRequestOrigin) { res.header('Access-Control-Allow-Origin', secOptions.allowRequestOrigin); } if (secOptions.allowRequestHeaders) { res.header('Access-Control-Allow-Headers', secOptions.allowRequestOrigin); } if (req.method === 'OPTIONS') { res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS'); return res.end(); } next(); }); } break; } } setFinalLayer() { switch (this.config.type) { case HttpBaseLib.EXPRESS: // TODO break; } } async getDestorClient() { if (this.destor) { return this.destor; } if (this.destorPromise) { return await this.destorPromise; } this.destorPromise = getDestorClient(); this.destor = await this.destorPromise; } @HTTP.GET('/test-roles-api', { rootMount: true }) @HTTP.ACCESS({ ADMIN: true, NO_AUTH: true }) async getRoles(op: HttpOp<{}>) { return op.res.returnJsonPreserialized(''); } @HTTP.GET(HTTP.SHIM.ROOT_API_PUBLIC_INFO, { rootMount: true }) async getServerPublicInfo(op: HttpOp<{}>) { return op.res.returnJsonPreserialized(this.publicInfoString); } @HTTP.GET(HTTP.SHIM.ROOT_API_NEW_CHANNEL, { rootMount: true }) async newSecureChannel(op: HttpOp) { const accessInfoResult = this.checkAccessor(op, true); if (accessInfoResult.bad) { return op.raise(accessInfoResult, HTTP.STATUS.BAD_REQUEST); } const accessInfo = accessInfoResult.data; const peerInfo: SecureChannelPeer = { ecdhPublicKey: Buffer.from(accessInfo.channelPublicKey, 'base64'), iden: null, data: null, }; const channel = await this.pickWorker(SecureChannelWorkerClient).newChannel(peerInfo); channel.signing = { type: '4Q', public: this.config.security.secureChannel.publicKey, private: this.config.security.secureChannel.signingKey, }; this.secureChannels[channel.peerInfo.ecdhPublicKey.toString('base64')] = channel; const secureChannelResponseResult = channel.getSecureChannelResponse(); if (secureChannelResponseResult.bad) { return op.raise(secureChannelResponseResult, HTTP.STATUS.UNAUTHORIZED); } return op.res.returnJson(secureChannelResponseResult.data); } @HTTP.METHODS(httpRest, HTTP.SHIM.ROOT_API_SECURE_API, { rootMount: true }) async encryptedOperation(op: HttpOp<{}>, skipRunning = false) { const api = op.req.decryptedApiTarget; if (!skipRunning) { await this[api.handlerName](op); } // await api.handler(op); } @HTTP.GET(HTTP.SHIM.ROOT_API_PROXY_REQUEST, { rootMount: true }) async proxyRequestOperation(op: HttpOp) { if (this.proxyRequest.enabled) { return op.res.returnNotOk(500, `Proxy request not enabled`); } if (this.proxyRequest.requestCheckers?.length) { for (const checker of this.proxyRequest.requestCheckers) { const { allowed, message } = await checker(op.req.params); if (!allowed) { return op.res.returnNotOk(500, `Proxy request not allowed: ${message}`); } } } const paramsCopy = JSON.parse(JSON.stringify(op.req.params)); const url = paramsCopy.__url; const method: HttpMethod = paramsCopy.__method ? paramsCopy.__method : HttpMethod.GET; const timeout = paramsCopy.__timeout ? paramsCopy.__timeout : 7000; const headers = paramsCopy.__headers ? paramsCopy.__headers : ''; if (paramsCopy.__url) { delete paramsCopy.__url; } if (paramsCopy.__method) { delete paramsCopy.__method; } if (paramsCopy.__headers) { delete paramsCopy.__headers; } if (paramsCopy.__timeout) { delete paramsCopy.__timeout; } if (paramsCopy.__enc) { delete paramsCopy.__enc; } const newHeaders: {[headerName: string]: string} = {}; for (const headerName of headers.split(',')) { const headerValue = op.req.getHeader(headerName); if (headerValue) { newHeaders[headerName] = headerValue; } } const reqOpts = { timeout, headers: newHeaders, params: paramsCopy, }; let proxyRequestFunction: <T = any, R = axios.AxiosResponse<T>>(url: string, config?: axios.AxiosRequestConfig) => Promise<R>; switch (method) { case 'GET': { proxyRequestFunction = axios.default.get; break; } case 'PUT': { proxyRequestFunction = axios.default.put; break; } case 'POST': { proxyRequestFunction = axios.default.post; break; } case 'PATCH': { proxyRequestFunction = axios.default.patch; break; } case 'DELETE': { proxyRequestFunction = axios.default.delete; break; } } op.waitFor(resolve => { proxyRequestFunction.apply(axios.default, [url, reqOpts]).then(res => { if (typeof res.data === 'string') { op.res.returnJson({ message: res.data }); } else { op.res.returnJson(res.data); } resolve(); }).catch(e => { const res = e.response; if (res) { op.res.returnNotOk(res.status, `Proxy request failed: ${res.data}`); } else { op.res.returnNotOk(500, `Proxy request failed: ${e.message}`); } resolve(); }); }); } addAccessRule(memberMethodName: keyof typeof this, access: HttpApiRoleAccess<RoleRubric>) { if (!this.apiAccess) { this.apiAccess = { }; } const memberMethodName2 = memberMethodName as string; if (!this[memberMethodName2]) { throw new Error(`Cannot defined roles for non-existing class method '${memberMethodName2}'`); } this.apiAccess[memberMethodName2 as any] = access; } addRegistration(api: HttpServerShimApi) { if (!this.apiRegistrations) { this.apiRegistrations = []; } this.apiRegistrations.push(api); } register(api: HttpServerShimApi) { const apiVersion = api.apiVersion ? api.apiVersion : this.apiVersion; const apiPath = api.apiPath ? api.apiPath : this.apiPath; const finalMountPath = api.rootMount ? '' : `/${apiPath}/${apiVersion}`; const fullpath = `${finalMountPath}/${api.path}`.replace(/\/\//g, '/'); api.fullpath = fullpath; this.pathResolve(fullpath, api); const apiKey = `${api.method} ${api.fullpath}`; this.apiPathList.push(apiKey); const iface = this[api.handlerName + '_iface']; if (iface) { iface.consumed = 1; this.apiPathIface[apiKey] = { method: api.method, path: api.path, handlerName: api.handlerName, description: iface.description ? iface.description : '', params: Object.keys(iface.params).map(paramName => { const paramInfo = iface.params[paramName]; return { required: paramInfo.required ? true : false, type: paramInfo.type }; }), returns: iface.returns.type, acl: null, }; setImmediate(() => { this.apiPathIface[apiKey].acl = this.apiAccess[api.handlerName] ? this.apiAccess[api.handlerName] : null; }); } if (!api.pre) { api.pre = []; } if (!api.preDefaultProcesserAdded) { api.pre = [...this.defaultProcessors, ...api.pre]; api.pre = api.pre.filter((a, i) => api.pre.indexOf(a) === i); api.preDefaultProcesserAdded = true; } switch (this.config.type) { case HttpBaseLib.EXPRESS: switch (api.method) { case HttpMethod.GET: return this.baseApp.get(fullpath, expressHandler(this, api)); case HttpMethod.POST: return this.baseApp.post(fullpath, expressHandler(this, api)); case HttpMethod.PUT: return this.baseApp.put(fullpath, expressHandler(this, api)); case HttpMethod.PATCH: return this.baseApp.patch(fullpath, expressHandler(this, api)); case HttpMethod.DELETE: return this.baseApp.delete(fullpath, expressHandler(this, api)); } break; } console.error(`unmatched api`, api); } beforeStart() {} afterStart() {} afterConfigResolution() {} beforeStop() {} afterStop() {} addPublicInfo(info: {[infoKey: string]: any}) { Object.assign(this.publicInfo, info); } start(options?: ServerStartOptions) { return promise(async (resolve, reject) => { if (this.state.started) { return resolve(); } this.state.started = true; if (this.configResolutionPromise) { await this.configResolutionPromise; } if (!options) { options = this.config.startOptions; } if (!options) { return reject(new Error(`Cannot start server without start options.`)); } this.addPublicInfo({ tokenRequired: this.config.security.token.required, accessorRequired: this.config.security.accessor.required, secureChannelScheme: this.config.security.secureChannel.encryption, secureChannelPublicKey: this.config.security.secureChannel.publicKey, secureChannelStrict: this.config.security.secureChannel.strict, secureChannelRequired: this.config.security.secureChannel.required, apiPathList: this.apiPathList, apiInterface: this.apiPathIface } as HttpShimPublicInfo<RoleRubric>); this.apiRegistrations = this.apiRegistrations.filter(api => this instanceof api.class); const newAccess = {}; Object.keys(this.apiAccess).forEach(handlerName => { if (this instanceof this.apiAccess[handlerName]['class']) { newAccess[handlerName] = this.apiAccess[handlerName]; } }) this.apiAccess = newAccess; this.registerApis(); this.publicInfoString = JSON.stringify(this.publicInfo, null, 4); switch (this.config.type) { case HttpBaseLib.EXPRESS: try { this.beforeStart(); } catch (e) { console.error(e); } try { const app = this.baseApp as ExpressLib.Express; this.baseLibData.express.server = app.listen(options.port, () => { this.state.activePort = options.port; resolve(); try { this.afterStart(); } catch (e) { console.error(e); } }); } catch (e) { return reject(e); } break; } }); } close() { if (this.state.closingPromise) { return this.state.closingPromise; } this.state.closed = true; switch (this.config.type) { case HttpBaseLib.EXPRESS: this.state.closingPromise = promise(async resolve => { const proms: Promise<any>[] = []; try { this.beforeStop(); } catch (e) { console.error(e); } try { this.baseLibData.express.server.close(); } catch (e) { console.error(e); } try { proms.push(this.destroyAllWorkers()); } catch (e) { console.error(e); } try { this.afterStop(); } catch (e) { console.error(e); } await PromUtil.allSettled(proms); resolve(); }); break; } return this.state.closingPromise; } async stamp(payload?: string | Buffer, encoding: BufferEncoding = 'ascii') { if (!payload) { payload = SecureHandshake.timeAuth(); } let payloadB64: string; if (typeof payload === 'string') { payloadB64 = Buffer.from(payload, encoding).toString('base64'); } else { payloadB64 = payload.toString('base64'); } const sig = await this.pickWorker(SecureChannelWorkerClient).signMessage(payloadB64); return { payload: payloadB64, sig }; } prepareEncryptedOperation(op: HttpOp): Result<HttpServerShimApi> { if (op.req.decryptedApiTarget) { return ok(op.req.decryptedApiTarget); } const decryptResult = this.getDecryptedPayload(op); if (decryptResult.bad) { return op.raise(decryptResult, HTTP.STATUS.UNAUTHORIZED); } if (!op.req.decryptedPayloadObject) { return op.raise(HttpCode.BAD_REQUEST, `ENCRYPTED_OP_NON_JSON_PAYLOAD`, `Supplied secure payload is not JSON format`); } const args = op.req.decryptedPayloadObject as { id: string; path: string; body: any; headers?: {[name:string]: string} }; const resolved = this.pathResolve(args.path); if (!resolved) { return op.raise(HttpCode.NOT_FOUND, `ENCRYPTED_OP_PATH_NOT_FOUND`, `Encrypted access to unknown path: '${args.path}'`); } const api = resolved.methods[op.method]; if (!api) { return op.raise(HttpCode.NOT_FOUND, `ENCRYPTED_OP_METHOD_NOT_FOUND`, `Method ${op.method} not found for '${api.fullpath}'`); } if (args.headers) { Object.assign(op.req.headers, args); } op.params = op.req.params; const pathQueryParams = url.parse(args.path, true).query; if (Object.keys(resolved.params).length > 0) { Object.assign(op.req.params, resolved.params); } if (Object.keys(pathQueryParams).length > 0) { Object.assign(op.req.params, pathQueryParams); } if (args.body) { try { const data = JSON.parse(args.body); op.req.data = data; if (data && typeof data === 'object' && !Array.isArray(data)) { Object.assign(op.req.params, data); } } catch (e) { // non JSON body, ignore } } op.req.decryptedApiTarget = api; return ok(api); } checkAccessor<Params = HttpParams, Returns = any>(op: HttpOp<Params, Returns>, forceVerify = false): Result<Partial<AccessHeaderObject> & Partial<{accessor: string, t: number, channelPublicKey?: string}>> { const authorizationHeader = op.req.getHeader('Accessor'); const accessorConf = this.config.security.accessor; if (accessorConf.required || forceVerify) { if (!authorizationHeader) { return op.raise(HttpCode.UNAUTHORIZED, `ACCESSOR_HEADER_NOT_FOUND`, `Accessor header does not exist`); } } else { return ok({ accessor: null, t: 0, channelPublicKey: '' }); } const authInfo = SecureHandshake.parseAuthHeader(authorizationHeader); const accessorExpression = authInfo.accessorExpression; const timeWindow = this.config.security.accessor.timeWindow; if (!accessorConf.baseTokenBuffer) { accessorConf.baseTokenBuffer = Buffer.from(accessorConf.baseToken, 'ascii'); } const accessDataResult = SecureHandshake.verifyAccessor(accessorExpression, accessorConf.baseTokenBuffer, timeWindow); if (accessDataResult.bad) { return op.raise(accessDataResult, HttpCode.UNAUTHORIZED); } return ok({ ...accessDataResult.data, channelPublicKey: authInfo.peerEcdhPublicKey }); } getSecureChannel<Params = HttpParams, Returns = any>(op: HttpOp<Params, Returns>) { const accessInfoResult = this.checkAccessor(op, true); if (accessInfoResult.bad) { return op.raise(accessInfoResult, HTTP.STATUS.BAD_REQUEST); } const channelId = accessInfoResult.data.channelPublicKey; const channel = this.secureChannels[channelId]; if (!channel) { return op.raise(HttpCode.UNAUTHORIZED, `SECURE_CHANNEL_NOT_FOUND`, `secure channel not found: ${channelId}`); } op.secureChannel = channel; return ok(channel); } getDecryptedPayload<Params = HttpParams, Returns = any>(op: HttpOp<Params, Returns>) { if (op.req.decryptedPayload) { return ok(op.req.decryptedPayload); } const channelResult = this.getSecureChannel(op); if (channelResult.bad) { return op.raise(channelResult, HTTP.STATUS.UNAUTHORIZED); } const channel = channelResult.data; const payload: SecureChannelPayload = channel.parseWrappedPayloadBase64(op.req.encryptedPayload); if (!payload || !payload.__scp) { return op.raise(HttpCode.BAD_REQUEST, 'ENCRYPTED_OP_NO_SECURE_PAYLOAD', 'Secure payload not found'); } op.req.decryptedPayload = channel.decryptSecureChannelPayloadIntoString(payload); if (isJsonString(op.req.decryptedPayload)) { op.req.decryptedPayloadObject = JSON.parse(op.req.decryptedPayload); } return ok(op.req.decryptedPayload); } handlePre<Params = HttpParams, Returns = any>(op: HttpOp<Params, Returns>) { return promise(async resolve => { let allPassed = true; if (op.api.pre?.length > 0) { for (const preType of op.api.pre) { const preFunc = this.preHandler.byType[preType]; if (!preFunc) { continue; } const passed = await preFunc.apply(this.preHandler, [op]); if (!passed) { allPassed = false; break; } } } resolve(allPassed); }); } handlePost<Params = HttpParams, Returns = any>(op: HttpOp<Params, Returns>) { return promise(async resolve => { let allPassed = true; if (op.api.post) { for (const postType of op.api.post) { const postFunc = this.postHandler.byType[postType]; if (!postFunc) { continue; } const passed = await postFunc.apply(this.postHandler, [op]); if (!passed) { allPassed = false; break; } } } resolve(allPassed); }); } private pathResolve(path: string, newApi: HttpServerShimApi = null): HttpPathResolution { const paths = path.split('/'); if (paths[0] === '') { paths.shift(); } const paramCollector = {}; let node = this.pathTree; for (const pathSlot of paths) { const slot = decodeURIComponent(pathSlot.split('?')[0].split('#')[0]); if (slot === '__apidef__') { return null; } const isParam = slot.startsWith(':'); if (node[slot]) { node = node[slot]; continue; } const paramDef = node['?param-name?']; if (paramDef) { if (newApi && isParam && paramDef.slot !== slot) { throw new Error(`Cannot register a parameter slot ${slot}, ` + `parameter ${paramDef.slot} has been registered by ${paramDef.registeredPath}`); } paramCollector[paramDef.name] = slot; node = paramDef.nextNode; continue; } if (newApi) { const nextNode = {}; if (isParam) { node['?param-name?'] = { nextNode, slot, name: slot.substr(1), registeredPath: path }; } node[slot] = nextNode; node = node[slot]; } else { return null; } } if (!node) { return null; } if (newApi) { if (node.__apidef__ && node.__apidef__.methods[newApi.method]) { throw new Error(`Cannot register api at ${newApi.method} ${path}, another api is already registered`); } if (!node.__apidef__) { node.__apidef__ = { type: 'api', path, registeredPath: path, methods: {}, params: {}, } as HttpPathResolution; } node.__apidef__.methods[newApi.method] = newApi; return node.__apidef__; } const registeredDef = node.__apidef__ as HttpPathResolution; if (!registeredDef) { return null; } return { type: 'api', path, methods: registeredDef.methods, registeredPath: registeredDef.registeredPath, params: paramCollector, } as HttpPathResolution; } private destroyAllWorkers() { const proms: Promise<any>[] = []; for (const workerClass of Object.keys(this.workerFleet)) { const fleet = this.workerFleet[workerClass]; for (const worker of fleet.workers) { const terminationProm = worker.terminate(); proms.push(terminationProm); ProcessExit.gracefulExitPromises.push(terminationProm); } } this.workerFleet = {}; return PromUtil.allSettled(proms); } } export class HttpRequest<Params = HttpParams, Returns = any> { op: HttpOp<Params, Returns>; res: HttpResponse<Params, Returns>; data: any; body: string = null; bodyRaw: Buffer = null; headers: {[headerName: string]: string} = {}; params: Params; encryptedPayload: string; decryptedPayload: string; decryptedPayloadObject: object | any[]; decryptedApiTarget: HttpServerShimApi<HttpParams, any>; t = Date.now(); constructor(op: HttpOp<Params, Returns>) { this.op = op; } getHeader(headerName: string): string { switch (this.op.server.config.type) { case HttpBaseLib.EXPRESS: return this.op.oriReq.header(headerName); default: return null; } } } export class HttpResponse<Params = HttpParams, Returns = any> { op: HttpOp<Params, Returns>; req: HttpRequest<Params, Returns>; headers: {[headerName: string]: string} = {}; t = -1; dt = -1; ended = false; output = []; endingPayload: string | Buffer = ''; endingPayloadRaw: string | Buffer = ''; statusCode: number = 200; appErrorCode: number | string = 'GENERIC_ERROR'; returnValue?: Returns; private onends: (() => any)[] = []; constructor(op: HttpOp<Params, Returns>) { this.op = op; } get onend() { return this.onends; } send(payload: string) { if (this.ended) { return; } this.op.oriRes.send(payload); this.output.push(payload); return this; } end(payload: string, returnValue?: Returns) { if (this.ended) { return; } this.ended = true; this.t = Date.now(); this.dt = this.t - this.req.t; for (const onend of this.onends) { try { if (onend) { onend(); } } catch (e) {} } this.endingPayload = payload; this.output.push(payload); if (returnValue !== undefined) { this.returnValue = returnValue; } return this; } status(num: number) { this.statusCode = num; return this; } returnCached(code: number, cached: string) { this.statusCode = code; return this.end(cached); } returnNotOk(code: number, message: any = '') { let statusName = 'unclassified_server_error'; switch (code) { case 400: statusName = 'bad_request'; break; case 401: statusName = 'unauthorized'; break; case 404: statusName = 'not_found'; break; case 500: statusName = 'internal_server_error'; break; } const resObj: any = {status: statusName, message }; if (!message && this.op.errors.length > 0) { const e = this.op.errors[0].e; message = e.message; if (this.op.server.config.debug.showErrorStack) { resObj.stackTrace = e.stack; } } this.statusCode = code; return this.end(JSON.stringify(resObj)); } okJsonPreserialized(serial: string) { return `{"status":"ok","result":${serial}}`; } okJsonString(obj: any) { return JSON.stringify({ status: 'ok', result: obj }); } returnJsonPreserialized(serialized: string, original?: Returns) { this.end(`{"status":"ok","result":${serialized}}`); return original; } returnJson(obj: Returns) { this.end(JSON.stringify({ status: 'ok', result: obj }), obj); return obj; } } export class HttpPathResolution { type: 'api' | 'resource'; path: string; methods: {[method: string]: HttpServerShimApi}; registeredPath: string; params: {[paramName: string]: string}; } export interface ErrorObject { op: HttpOp; t: number; e: Error; errorMessage: string; httpStatusCode: number; appErrorCode: number | string; } export class HttpOp<Params = HttpParams, Returns = any> { method: HttpMethod; params: Params; req: HttpRequest<Params, Returns>; res: HttpResponse<Params, Returns>; error: ErrorObject = null; errors: ErrorObject[] = []; secureChannel: SecureChannel; cache: HttpCacheOp<Params, Returns>; pendingSequential: Promise<any>[] = []; pendingParallel: Promise<any>[] = []; user: { username: string; publicKeys: string[]; roles: string[]; rolesApplicable: string[]; }; fromInternal: boolean; constructor( public server: HttpServerShim, public api: HttpServerShimApi<Params, Returns>, public oriReq: any = null, public oriRes: any = null, ) { this.params = {} as any; this.req = new HttpRequest<Params, Returns>(this); this.req.params = this.params; this.res = new HttpResponse<Params, Returns>(this); this.res.req = this.req; this.req.res = this.res; this.cache = new HttpCacheOp<Params, Returns>(this); } raise(result: Result, statusCode?: number): Result; raise(error: Error, statusCode?: number): Result; raise(statusCode: number, errorCode: keyof typeof HttpShimCodeEnum, message?: string): Result; raise(...args): Result { if (typeof args[0] === 'number') { const [ statusCode, errorCode, message ] = args as [number, keyof typeof HttpShimCodeEnum, string]; if (!this.res.ended) { this.res.returnNotOk(statusCode, message); } return HttpShimCode.error(errorCode, message, { statusCode }); } else { if (args[0] instanceof Error) { args[0] = errorResult(args[0]); } let [ result, statusCode ] = args as [ Result, number ]; if (result.ok) { if (!statusCode) { statusCode = result.statusCode ? result.statusCode : 200; } if (!this.res.ended) { this.res.status(statusCode).returnJson(result.data); } } else { if (!statusCode) { statusCode = result.statusCode ? result.statusCode : 500; } if (!this.res.ended) { this.res.returnNotOk(statusCode, result.message); } } return result; } } returnJson(obj: Returns) { let status = 'ok'; let result = obj; if (obj && (obj as any).isResultKind) { const res = (obj as any as Result); if (res.bad) { status = 'error'; result = res.message as any; } else { result = res.data; } if (res.statusCode) { this.res.status(res.statusCode); } } if (status === 'error') { return this.res.end(JSON.stringify({ status, message: result, server: this.server.config.name }), obj); } else { return this.res.end(JSON.stringify({ status, result, server: this.server.config.name }), obj); } } setResponse(endingPayload?: string | Buffer) { if (endingPayload) { this.res.endingPayload = endingPayload; } } addSequentialProcess(proc: Promise<any>) { this.pendingSequential.push(proc); return proc; } waitFor(resolver: (resolve) => void) { const proc = new Promise(resolver); this.pendingSequential.push(proc); return proc; } async run(fromInternal = false) { this.fromInternal = fromInternal; const preRes = await this.server.handlePre(this); if (preRes) { await this.server[this.api.handlerName](this); for (const prom of this.pendingSequential) { await Promise.resolve(prom); } } await this.server.handlePost(this); if (this.secureChannel) { this.res.endingPayloadRaw = this.res.endingPayload; this.res.endingPayload = JSON.stringify({ status: 'ok', format: 'json', encrypted: true, payload: this.secureChannel.createWrappedPayload(this.res.endingPayload), }); } this.finish(); } private finish(): null { switch (this.server.config.type) { case HttpBaseLib.EXPRESS: this.oriRes.status(this.res.statusCode).end(this.res.endingPayload); return null; default: throw new Error(`Unknown base http library type: ${this.server.config.type}`); } } } export class PreHandler { byType: {[preType: string]: (op: HttpOp) => Promise<boolean> } = {}; constructor() { this.byType = { [ReqProcessor.DECRYPT]: this.optionalDecrypt, [ReqProcessor.AUTH]: this.auth, [ReqProcessor.BASIC]: this.basic, }; } async auth(op: HttpOp) { return promise<boolean>(async resolve => { const srvConfig = op.server.config; if (srvConfig.security.noauth) { return resolve(true); } switch (srvConfig.type) { case HttpBaseLib.EXPRESS: const authData: string = op.oriReq.headers.authorization; const apiRoleBook = op.server.apiAccess[op.api.handlerName]; if (!op.fromInternal && apiRoleBook?.['deny-all']) { op.returnJson(HttpShimCode.error('AUTH_HEADER_SIGNED_BUT_API_DENIES_ALL', `API '${op.api.method} ${op.api.fullpath}' is set to deny-all`)); } if (authData) { if (authData.startsWith('Bearer ')) { // bearer token scheme const headerText = authData.split('Bearer ')[1]; if (headerText.startsWith('SIGNED.')) { const [ signedType, scheme, payloadBase64, sigBase64, publicKey ] = headerText.split('.'); if (scheme === 'ECC_4Q') { let found = false; for (const authServerKey of Object.keys(op.server.authServers)) { if (op.server.authServers[authServerKey].publicKey === publicKey) { found = true; break; } } if (!found) { op.returnJson(HttpShimCode.error('AUTH_HEADER_SIGNED_BUT_PUBLIC_KEY_NOT_FOUND')); return resolve(false); } const verifyResult = SecureHandshake.verifyStamp({ payload: payloadBase64, sig: sigBase64 }, publicKey); if (verifyResult.bad) { op.returnJson(verifyResult); return resolve(false); } try { const roleData = JSON.parse(Buffer.from(payloadBase64, 'base64').toString('utf8')); op.user = { username: roleData.name, publicKeys: roleData.publicKey, roles: roleData.server[op.server.config.name], rolesApplicable: null, }; let targetRoleKey: string[]; if (apiRoleBook && !apiRoleBook['allow-all']) { targetRoleKey = Object.keys(apiRoleBook); const rolesApplicable: string[] = []; for (const role of op.user.roles) { if (apiRoleBook[role]) { rolesApplicable.push(role); } } op.user.rolesApplicable = rolesApplicable; if (!op.user.rolesApplicable?.length) { op.returnJson(HttpShimCode.error('AUTH_HEADER_SIGNED_ROLE_UNAUTHORZIED_FOR_API', `API '${op.api.method} ${op.api.fullpath}' as user '${op.user.username}' with roles [${op.user.roles.join(', ')}] ` + `has no authorizable match for the API requiring [${targetRoleKey.join(', ')}]`)); return resolve(false); } } } catch (e) { op.returnJson(HttpShimCode.error('AUTH_HEADER_SIGNED_NO_ROLES_MAP')); return resolve(false); } return resolve(true); } } else if (headerText.startsWith('SYMNT_HASH.')) { } } op.returnJson(HttpShimCode.error('AUTH_HEADER_NOT_VALID')); return resolve(false); } else { op.returnJson(HttpShimCode.error('AUTH_HEADER_NOT_FOUND')); return resolve(false); } break; } }); } async basic(op: HttpOp) { return promise<boolean>(async resolve => { switch (op.server.config.type) { case HttpBaseLib.EXPRESS: op.oriRes.header('Content-Type', 'application/json'); // op.oriRes.header('Access-Control-Allow-Origin', '*'); // op.oriRes.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept'); let errored = false; const chunks: Buffer[] = []; op.oriReq.on('data', chunk => { chunks.push(chunk); }); op.oriReq.on('end', () => { try { op.req.params = op.oriReq.params ? op.oriReq.params : {}; let queryParamNames; if (op.oriReq.query && (queryParamNames = Object.keys(op.oriReq.query)).length > 0) { for (const queryParamName of queryParamNames) { op.req.params[queryParamName] = op.oriReq.query[queryParamName]; } } op.req.bodyRaw = Buffer.concat(chunks); const bod = op.req.body = op.req.bodyRaw.toString(); if (op.oriReq.headers['encrypted-api']) { op.req.encryptedPayload = bod; const prepareResult = op.server.prepareEncryptedOperation(op); if (prepareResult.bad) { op.raise(prepareResult); return resolve(false); } } else { op.params = op.req.params; op.req.body = op.req.bodyRaw.toString(); if (isJsonString(op.req.body)) { try { op.req.data = JSON.parse(op.req.body); if (typeof op.req.data === 'object' && !Array.isArray(op.req.data)) { Object.assign(op.req.params, op.req.data); } } catch (e) { console.error('BAD_JSON', e); } } } resolve(true); } catch (e) { console.error(e); } }); op.oriReq.on('error', e => { console.error(e); errored = true; resolve(false); }); break; } }); } async optionalDecrypt(op: HttpOp) { return new Promise<boolean>(resolve => { }); } } export class PostHandler { byType: {[postType: string]: (op: HttpOp) => Promise<boolean> } = {}; constructor() { this.byType = { [ReqProcessor.BASIC]: this.basic, [ReqProcessor.ENCRYPT]: this.optionalEncrypt, }; } async basic(op: HttpOp) { return new Promise<boolean>(resolve => { switch (op.server.config.type) { case HttpBaseLib.EXPRESS: resolve(true); break; } }); } async optionalEncrypt(op: HttpOp) { return new Promise<boolean>(resolve => { switch (op.server.config.type) { case HttpBaseLib.EXPRESS: resolve(true); break; } }); } } export enum CacheParser { JSON = 'JSON' } export class CacheDef<T = any> { path: string; class: Class<T>; keys: { name: string; type: 'param' | 'fixed'; }[] = null; keysExceptLast: { name: string; type: 'param' | 'fixed'; }[] = null; lastKey: { name: string; type: 'param' | 'fixed'; } = null; serializer: CacheParser; maxOld: number = 0; matchExactly: boolean = false; defStack: string = ''; constructor(init?: Partial<CacheDef<T>>) { if (init) { Object.assign(this, init); } if (this.path.indexOf('/') >= 0) { this.keys = []; const keys = this.path.split('/').slice(1); for (const keyname of keys) { if (keyname.startsWith(':')) { this.keys.push({ name: keyname.split(':')[1], type: 'param' }); } else { this.keys.push({ name: keyname, type: 'fixed' }); } } this.lastKey = this.keys[this.keys.length - 1]; this.keysExceptLast = this.keys.slice(0, -1); } if (!this.serializer) { this.serializer = CacheParser.JSON; } } } export interface CacheAccessOption { version?: number | string; pathParams?: {[name: string]: string}; serializ