diff --git a/package.json b/package.json index c26bdad..5dbdd6c 100644 --- a/package.json +++ b/package.json @@ -41,17 +41,21 @@ "license": "ISC", "devDependencies": { "@types/axios": "^0.14.0", + "@types/express": "^4.17.13", "@types/form-data": "^2.5.0", "@types/jest": "^27.0.1", "@types/mime": "^2.0.3", + "@types/pem": "^1.9.6", "@types/tough-cookie": "^4.0.1", "copyfiles": "^2.4.1", "cp": "^0.2.0", "dotenv": "^10.0.0", + "express": "^4.17.1", "jest": "^27.2.0", "jest-extended": "^0.11.5", "node-polyfill-webpack-plugin": "^1.1.4", "path": "^0.12.7", + "pem": "^1.14.4", "process": "^0.11.10", "rimraf": "^3.0.2", "semantic-release": "^17.4.7", diff --git a/sasjs-tests/src/testSuites/Basic.ts b/sasjs-tests/src/testSuites/Basic.ts index d3e9f21..38582b7 100644 --- a/sasjs-tests/src/testSuites/Basic.ts +++ b/sasjs-tests/src/testSuites/Basic.ts @@ -13,7 +13,6 @@ const defaultConfig: SASjsConfig = { debug: false, contextName: 'SAS Job Execution compute context', useComputeApi: false, - allowInsecureRequests: false, loginMechanism: LoginMechanism.Default } diff --git a/src/SAS9ApiClient.ts b/src/SAS9ApiClient.ts index c7e171c..b3f31a0 100644 --- a/src/SAS9ApiClient.ts +++ b/src/SAS9ApiClient.ts @@ -1,3 +1,4 @@ +import * as https from 'https' import { generateTimestamp } from '@sasjs/utils/time' import * as NodeFormData from 'form-data' import { Sas9RequestClient } from './request/Sas9RequestClient' @@ -13,10 +14,10 @@ export class SAS9ApiClient { constructor( private serverUrl: string, private jobsPath: string, - allowInsecureRequests: boolean + httpsAgentOptions?: https.AgentOptions ) { if (serverUrl) isUrl(serverUrl) - this.requestClient = new Sas9RequestClient(serverUrl, allowInsecureRequests) + this.requestClient = new Sas9RequestClient(serverUrl, httpsAgentOptions) } /** diff --git a/src/SASjs.ts b/src/SASjs.ts index 2a3a12f..5c78bee 100644 --- a/src/SASjs.ts +++ b/src/SASjs.ts @@ -40,7 +40,6 @@ const defaultConfig: SASjsConfig = { debug: false, contextName: 'SAS Job Execution compute context', useComputeApi: null, - allowInsecureRequests: false, loginMechanism: LoginMechanism.Default } @@ -62,7 +61,7 @@ export default class SASjs { private jesJobExecutor: JobExecutor | null = null private sas9JobExecutor: JobExecutor | null = null - constructor(config?: any) { + constructor(config?: Partial) { this.sasjsConfig = { ...defaultConfig, ...config @@ -797,7 +796,7 @@ export default class SASjs { sasApiClient = new SAS9ApiClient( serverUrl, this.jobsPath, - this.sasjsConfig.allowInsecureRequests + this.sasjsConfig.httpsAgentOptions ) } } else { @@ -964,12 +963,12 @@ export default class SASjs { if (!this.requestClient) { this.requestClient = new RequestClient( this.sasjsConfig.serverUrl, - this.sasjsConfig.allowInsecureRequests + this.sasjsConfig.httpsAgentOptions ) } else { this.requestClient.setConfig( this.sasjsConfig.serverUrl, - this.sasjsConfig.allowInsecureRequests + this.sasjsConfig.httpsAgentOptions ) } @@ -1010,7 +1009,7 @@ export default class SASjs { this.sas9ApiClient = new SAS9ApiClient( this.sasjsConfig.serverUrl, this.jobsPath, - this.sasjsConfig.allowInsecureRequests + this.sasjsConfig.httpsAgentOptions ) } } @@ -1045,7 +1044,7 @@ export default class SASjs { this.sasjsConfig.serverUrl, this.sasjsConfig.serverType!, this.jobsPath, - this.sasjsConfig.allowInsecureRequests + this.sasjsConfig.httpsAgentOptions ) this.computeJobExecutor = new ComputeJobExecutor( diff --git a/src/auth/getAccessToken.ts b/src/auth/getAccessToken.ts index d51833e..f42e442 100644 --- a/src/auth/getAccessToken.ts +++ b/src/auth/getAccessToken.ts @@ -46,7 +46,7 @@ export async function getAccessToken( ) .then((res) => res.result as SasAuthResponse) .catch((err) => { - throw prefixMessage(err, 'Error while getting access token') + throw prefixMessage(err, 'Error while getting access token. ') }) return authResponse diff --git a/src/job-execution/Sas9JobExecutor.ts b/src/job-execution/Sas9JobExecutor.ts index 892be05..7325bdf 100644 --- a/src/job-execution/Sas9JobExecutor.ts +++ b/src/job-execution/Sas9JobExecutor.ts @@ -1,3 +1,4 @@ +import * as https from 'https' import { ServerType } from '@sasjs/utils/types' import * as NodeFormData from 'form-data' import { ErrorResponse } from '../types/errors' @@ -17,10 +18,10 @@ export class Sas9JobExecutor extends BaseJobExecutor { serverUrl: string, serverType: ServerType, private jobsPath: string, - allowInsecureRequests: boolean + httpsAgentOptions?: https.AgentOptions ) { super(serverUrl, serverType) - this.requestClient = new Sas9RequestClient(serverUrl, allowInsecureRequests) + this.requestClient = new Sas9RequestClient(serverUrl, httpsAgentOptions) } async execute(sasJob: string, data: any, config: any) { diff --git a/src/request/RequestClient.ts b/src/request/RequestClient.ts index d987019..f7caedb 100644 --- a/src/request/RequestClient.ts +++ b/src/request/RequestClient.ts @@ -1,4 +1,5 @@ -import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios' +import { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios' +import * as https from 'https' import { CsrfToken } from '..' import { isAuthorizeFormRequired, isLogInRequired } from '../auth' import { @@ -12,7 +13,11 @@ import { SASjsRequest } from '../types' import { parseWeboutResponse } from '../utils/parseWeboutResponse' import { prefixMessage } from '@sasjs/utils/error' import { SAS9AuthError } from '../types/errors/SAS9AuthError' -import { parseGeneratedCode, parseSourceCode } from '../utils' +import { + parseGeneratedCode, + parseSourceCode, + createAxiosInstance +} from '../utils' export interface HttpClient { get( @@ -54,12 +59,15 @@ export class RequestClient implements HttpClient { protected fileUploadCsrfToken: CsrfToken | undefined protected httpClient!: AxiosInstance - constructor(protected baseUrl: string, allowInsecure = false) { - this.createHttpClient(baseUrl, allowInsecure) + constructor( + protected baseUrl: string, + httpsAgentOptions?: https.AgentOptions + ) { + this.createHttpClient(baseUrl, httpsAgentOptions) } - public setConfig(baseUrl: string, allowInsecure = false) { - this.createHttpClient(baseUrl, allowInsecure) + public setConfig(baseUrl: string, httpsAgentOptions?: https.AgentOptions) { + this.createHttpClient(baseUrl, httpsAgentOptions) } public getCsrfToken(type: 'general' | 'file' = 'general') { @@ -511,20 +519,15 @@ export class RequestClient implements HttpClient { return responseToReturn } - private createHttpClient(baseUrl: string, allowInsecure = false) { - const https = require('https') - if (allowInsecure && https.Agent) { - this.httpClient = axios.create({ - baseURL: baseUrl, - httpsAgent: new https.Agent({ - rejectUnauthorized: !allowInsecure - }) - }) - } else { - this.httpClient = axios.create({ - baseURL: baseUrl - }) - } + private createHttpClient( + baseUrl: string, + httpsAgentOptions?: https.AgentOptions + ) { + const httpsAgent = httpsAgentOptions + ? new https.Agent(httpsAgentOptions) + : undefined + + this.httpClient = createAxiosInstance(baseUrl, httpsAgent) this.httpClient.defaults.validateStatus = (status) => status >= 200 && status < 401 diff --git a/src/request/Sas9RequestClient.ts b/src/request/Sas9RequestClient.ts index 5fb4750..f5575c5 100644 --- a/src/request/Sas9RequestClient.ts +++ b/src/request/Sas9RequestClient.ts @@ -1,3 +1,4 @@ +import * as https from 'https' import { AxiosRequestConfig } from 'axios' import axiosCookieJarSupport from 'axios-cookiejar-support' import * as tough from 'tough-cookie' @@ -9,8 +10,8 @@ import { RequestClient, throwIfError } from './RequestClient' * Handles redirects and cookie management. */ export class Sas9RequestClient extends RequestClient { - constructor(baseUrl: string, allowInsecure = false) { - super(baseUrl, allowInsecure) + constructor(baseUrl: string, httpsAgentOptions?: https.AgentOptions) { + super(baseUrl, httpsAgentOptions) this.httpClient.defaults.maxRedirects = 0 this.httpClient.defaults.validateStatus = (status) => status >= 200 && status < 303 diff --git a/src/test/RequestClient.spec.ts b/src/test/RequestClient.spec.ts new file mode 100644 index 0000000..961043c --- /dev/null +++ b/src/test/RequestClient.spec.ts @@ -0,0 +1,167 @@ +import * as pem from 'pem' +import * as http from 'http' +import * as https from 'https' +import { app, mockedAuthResponse } from './SAS_server_app' +import { ServerType } from '@sasjs/utils' +import SASjs from '../SASjs' +import * as axiosModules from '../utils/createAxiosInstance' + +const axiosActual = jest.requireActual('axios') + +jest + .spyOn(axiosModules, 'createAxiosInstance') + .mockImplementation((baseURL: string, httpsAgent?: https.Agent) => + axiosActual.create({ baseURL, httpsAgent }) + ) + +const PORT = 8000 +const SERVER_URL = `https://localhost:${PORT}/` + +const ERROR_MESSAGES = { + selfSigned: 'self signed certificate', + CCA: 'unable to verify the first certificate' +} + +describe('RequestClient', () => { + let server: http.Server + + const adapter = new SASjs({ + serverUrl: `http://localhost:${PORT}/`, + serverType: ServerType.SasViya + }) + + beforeAll(async () => { + await new Promise((resolve: any, reject: any) => { + server = app + .listen(PORT, () => resolve()) + .on('error', (err: any) => reject(err)) + }) + }) + + afterAll(() => { + server.close() + }) + + it('should response the POST method', async () => { + const authResponse = await adapter.getAccessToken( + 'clientId', + 'clientSecret', + 'authCode' + ) + + expect(authResponse.access_token).toBe(mockedAuthResponse.access_token) + }) + + it('should response the POST method with Unauthorized', async () => { + await expect( + adapter.getAccessToken('clientId', 'clientSecret', 'incorrect') + ).rejects.toThrow( + 'Error while getting access token. Request failed with status code 401' + ) + }) +}) + +describe('RequestClient - Self Signed Server', () => { + let adapter: SASjs + + let httpsServer: https.Server + let sslConfig: pem.CertificateCreationResult + + beforeAll(async () => { + ;({ httpsServer, keys: sslConfig } = await setupSelfSignedServer()) + await new Promise((resolve: any, reject: any) => { + httpsServer + .listen(PORT, () => resolve()) + .on('error', (err: any) => reject(err)) + }) + + adapter = new SASjs({ + serverUrl: SERVER_URL, + serverType: ServerType.SasViya, + httpsAgentOptions: { ca: [sslConfig.certificate] } + }) + }) + + afterAll(() => { + httpsServer.close() + }) + + it('should throw error for not providing certificate', async () => { + const adapterWithoutCertificate = new SASjs({ + serverUrl: SERVER_URL, + serverType: ServerType.SasViya + }) + + await expect( + adapterWithoutCertificate.getAccessToken( + 'clientId', + 'clientSecret', + 'authCode' + ) + ).rejects.toThrow( + `Error while getting access token. ${ERROR_MESSAGES.selfSigned}` + ) + }) + + it('should response the POST method using insecure flag', async () => { + const adapterAllowInsecure = new SASjs({ + serverUrl: SERVER_URL, + serverType: ServerType.SasViya, + httpsAgentOptions: { rejectUnauthorized: false } + }) + + const authResponse = await adapterAllowInsecure.getAccessToken( + 'clientId', + 'clientSecret', + 'authCode' + ) + + expect(authResponse.access_token).toBe(mockedAuthResponse.access_token) + }) + + it('should response the POST method', async () => { + const authResponse = await adapter.getAccessToken( + 'clientId', + 'clientSecret', + 'authCode' + ) + + expect(authResponse.access_token).toBe(mockedAuthResponse.access_token) + }) + + it('should response the POST method with Unauthorized', async () => { + await expect( + adapter.getAccessToken('clientId', 'clientSecret', 'incorrect') + ).rejects.toThrow( + 'Error while getting access token. Request failed with status code 401' + ) + }) +}) + +const setupSelfSignedServer = async (): Promise<{ + httpsServer: https.Server + keys: pem.CertificateCreationResult +}> => { + return await new Promise(async (resolve) => { + const keys = await createCertificate() + + const httpsServer = https.createServer( + { key: keys.clientKey, cert: keys.certificate }, + app + ) + + resolve({ httpsServer, keys }) + }) +} + +const createCertificate = async (): Promise => { + return await new Promise((resolve, reject) => { + pem.createCertificate( + { days: 1, selfSigned: true }, + (error: any, keys: pem.CertificateCreationResult) => { + if (error) reject(false) + resolve(keys) + } + ) + }) +} diff --git a/src/test/SAS_server_app.ts b/src/test/SAS_server_app.ts new file mode 100644 index 0000000..e4cff7a --- /dev/null +++ b/src/test/SAS_server_app.ts @@ -0,0 +1,38 @@ +import express = require('express') + +export const app = express() + +export const mockedAuthResponse = { + access_token: 'access_token', + token_type: 'bearer', + id_token: 'id_token', + refresh_token: 'refresh_token', + expires_in: 43199, + scope: 'openid', + jti: 'jti' +} + +app.get('/', function (req: any, res: any) { + res.send('Hello World') +}) + +app.post('/SASLogon/oauth/token', function (req: any, res: any) { + let valid = true + // capture the encoded form data + req.on('data', (data: any) => { + const resData = data.toString() + + if (resData.includes('incorrect')) valid = false + }) + + // send a response when finished reading + // the encoded form data + req.on('end', () => { + if (valid) res.status(200).send(mockedAuthResponse) + else + res.status(401).send({ + error: 'unauthorized', + error_description: 'Bad credentials' + }) + }) +}) diff --git a/src/test/SessionManager.spec.ts b/src/test/SessionManager.spec.ts index ace8526..d37305b 100644 --- a/src/test/SessionManager.spec.ts +++ b/src/test/SessionManager.spec.ts @@ -1,6 +1,5 @@ import { SessionManager } from '../SessionManager' import { RequestClient } from '../request/RequestClient' -import { NoSessionStateError } from '../types/errors' import * as dotenv from 'dotenv' import axios from 'axios' import { Logger, LogLevel } from '@sasjs/utils' diff --git a/src/types/SASjsConfig.ts b/src/types/SASjsConfig.ts index b562dcb..af26204 100644 --- a/src/types/SASjsConfig.ts +++ b/src/types/SASjsConfig.ts @@ -1,3 +1,4 @@ +import * as https from 'https' import { ServerType } from '@sasjs/utils/types' /** @@ -54,11 +55,11 @@ export class SASjsConfig { */ useComputeApi: boolean | null = null /** - * Defaults to `false`. - * When set to `true`, the adapter will allow requests to SAS servers that use a self-signed SSL certificate. - * Changing this setting is not recommended. + * Optional settings to configure HTTPS Agent. + * By providing `key`, `cert`, `ca` to connect with server + * Other options can be set `rejectUnauthorized` and `requestCert` */ - allowInsecureRequests = false + httpsAgentOptions?: https.AgentOptions /** * Supported login mechanisms are - Redirected and Default */ diff --git a/src/utils/createAxiosInstance.ts b/src/utils/createAxiosInstance.ts new file mode 100644 index 0000000..d129a01 --- /dev/null +++ b/src/utils/createAxiosInstance.ts @@ -0,0 +1,7 @@ +import axios from 'axios' +import * as https from 'https' + +export const createAxiosInstance = ( + baseURL: string, + httpsAgent?: https.Agent +) => axios.create({ baseURL, httpsAgent }) diff --git a/src/utils/index.ts b/src/utils/index.ts index 14def48..065dd87 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,6 +1,7 @@ export * from './asyncForEach' export * from './compareTimestamps' export * from './convertToCsv' +export * from './createAxiosInstance' export * from './delay' export * from './isNode' export * from './isRelativePath'