From 00897fc513621b4bbe46c0bcb30c1e2fa94e8efd Mon Sep 17 00:00:00 2001 From: Christopher Suh Date: Thu, 23 Mar 2023 13:47:25 -0700 Subject: [PATCH] Replace Axios calls with HttpClient (#22417) * replace axios calls with httpClient * add latest files * fix url --- extensions/azurecore/package.json | 1 + .../src/account-provider/auths/azureAuth.ts | 15 +- .../src/account-provider/auths/httpClient.ts | 370 ++++++++++++++++++ .../account-provider/auths/networkUtils.ts | 43 ++ extensions/azurecore/src/constants.ts | 3 + extensions/azurecore/src/proxy.ts | 61 +++ extensions/azurecore/src/utils.ts | 34 ++ 7 files changed, 525 insertions(+), 2 deletions(-) create mode 100644 extensions/azurecore/src/account-provider/auths/httpClient.ts create mode 100644 extensions/azurecore/src/account-provider/auths/networkUtils.ts create mode 100644 extensions/azurecore/src/proxy.ts diff --git a/extensions/azurecore/package.json b/extensions/azurecore/package.json index af9c3bf25f..04688bfe7d 100644 --- a/extensions/azurecore/package.json +++ b/extensions/azurecore/package.json @@ -350,6 +350,7 @@ "dependencies": { "@azure/arm-resourcegraph": "^4.0.0", "@azure/arm-subscriptions": "^3.0.0", + "@azure/msal-common": "^11.0.0", "@azure/msal-node": "^1.16.0", "@azure/storage-blob": "^12.6.0", "axios": "^0.27.2", diff --git a/extensions/azurecore/src/account-provider/auths/azureAuth.ts b/extensions/azurecore/src/account-provider/auths/azureAuth.ts index 4d61a01f71..a96ca1b9f8 100644 --- a/extensions/azurecore/src/account-provider/auths/azureAuth.ts +++ b/extensions/azurecore/src/account-provider/auths/azureAuth.ts @@ -25,6 +25,8 @@ import { Logger } from '../../utils/Logger'; import * as qs from 'qs'; import { AzureAuthError } from './azureAuthError'; import { AccountInfo, AuthenticationResult, InteractionRequiredAuthError, PublicClientApplication } from '@azure/msal-node'; +import { HttpClient } from './httpClient'; +import { getProxyEnabledHttpClient } from '../../utils'; const localize = nls.loadMessageBundle(); @@ -38,6 +40,7 @@ export abstract class AzureAuth implements vscode.Disposable { protected readonly scopesString: string; protected readonly clientId: string; protected readonly resources: Resource[]; + protected readonly httpClient: HttpClient; private _authLibrary: string | undefined; constructor( @@ -94,6 +97,7 @@ export abstract class AzureAuth implements vscode.Disposable { this.scopes = [...this.metadata.settings.scopes]; this.scopesString = this.scopes.join(' '); + this.httpClient = getProxyEnabledHttpClient(); } public async startLogin(): Promise { @@ -473,8 +477,15 @@ export abstract class AzureAuth implements vscode.Disposable { try { Logger.verbose('Fetching tenants with uri {0}', tenantUri); let tenantList: string[] = []; - const tenantResponse = await this.makeGetRequest(tenantUri, token); - const data = tenantResponse.data; + + const tenantResponse = await this.httpClient.sendGetRequestAsync(tenantUri, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + } + }); + + const data = tenantResponse.body; if (data.error) { Logger.error(`Error fetching tenants :${data.error.code} - ${data.error.message}`); throw new Error(`${data.error.code} - ${data.error.message}`); diff --git a/extensions/azurecore/src/account-provider/auths/httpClient.ts b/extensions/azurecore/src/account-provider/auths/httpClient.ts new file mode 100644 index 0000000000..d1553579ec --- /dev/null +++ b/extensions/azurecore/src/account-provider/auths/httpClient.ts @@ -0,0 +1,370 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { INetworkModule, NetworkRequestOptions, NetworkResponse } from '@azure/msal-common'; +import * as http from 'http'; +import * as https from 'https'; +import { NetworkUtils } from './networkUtils'; + +/** + * http methods + */ +export enum HttpMethod { + GET = 'get', + POST = 'post' +} + +export enum HttpStatus { + SUCCESS_RANGE_START = 200, + SUCCESS_RANGE_END = 299, + REDIRECT = 302, + CLIENT_ERROR_RANGE_START = 400, + CLIENT_ERROR_RANGE_END = 499, + SERVER_ERROR_RANGE_START = 500, + SERVER_ERROR_RANGE_END = 599 +} + +export enum ProxyStatus { + SUCCESS_RANGE_START = 200, + SUCCESS_RANGE_END = 299, + SERVER_ERROR = 500 +} + +/** + * This class implements the API for network requests. + */ +export class HttpClient implements INetworkModule { + private proxyUrl: string; + private customAgentOptions: http.AgentOptions | https.AgentOptions; + static readonly AUTHORIZATION_PENDING: string = 'authorization_pending'; + + constructor( + proxyUrl?: string, + customAgentOptions?: http.AgentOptions | https.AgentOptions + ) { + this.proxyUrl = proxyUrl || ''; + this.customAgentOptions = customAgentOptions || {}; + } + + /** + * Http Get request + * @param url + * @param options + */ + async sendGetRequestAsync( + url: string, + options?: NetworkRequestOptions + ): Promise> { + if (this.proxyUrl) { + return networkRequestViaProxy(url, this.proxyUrl, HttpMethod.GET, options, this.customAgentOptions as http.AgentOptions); + } else { + return networkRequestViaHttps(url, HttpMethod.GET, options, this.customAgentOptions as https.AgentOptions); + } + } + + /** + * Http Post request + * @param url + * @param options + */ + async sendPostRequestAsync( + url: string, + options?: NetworkRequestOptions, + cancellationToken?: number + ): Promise> { + if (this.proxyUrl) { + return networkRequestViaProxy(url, this.proxyUrl, HttpMethod.POST, options, this.customAgentOptions as http.AgentOptions, cancellationToken); + } else { + return networkRequestViaHttps(url, HttpMethod.POST, options, this.customAgentOptions as https.AgentOptions, cancellationToken); + } + } +} + +const networkRequestViaProxy = ( + destinationUrlString: string, + proxyUrlString: string, + httpMethod: string, + options?: NetworkRequestOptions, + agentOptions?: http.AgentOptions, + timeout?: number +): Promise> => { + const destinationUrl = new URL(destinationUrlString); + const proxyUrl = new URL(proxyUrlString); + + // 'method: connect' must be used to establish a connection to the proxy + const headers = options?.headers || {} as Record; + const tunnelRequestOptions: https.RequestOptions = { + host: proxyUrl.hostname, + port: proxyUrl.port, + method: 'CONNECT', + path: destinationUrl.hostname, + headers: headers + }; + + if (timeout) { + tunnelRequestOptions.timeout = timeout; + } + + if (agentOptions && Object.keys(agentOptions).length) { + tunnelRequestOptions.agent = new http.Agent(agentOptions); + } + + // compose a request string for the socket + let postRequestStringContent: string = ''; + if (httpMethod === HttpMethod.POST) { + const body = options?.body || ''; + postRequestStringContent = + 'Content-Type: application/x-www-form-urlencoded\r\n' + + `Content-Length: ${body.length}\r\n` + + `\r\n${body}`; + } + const outgoingRequestString = `${httpMethod.toUpperCase()} ${destinationUrl.href} HTTP/1.1\r\n` + + `Host: ${destinationUrl.host}\r\n` + + 'Connection: close\r\n' + + postRequestStringContent + + '\r\n'; + + return new Promise>(((resolve, reject) => { + const request = http.request(tunnelRequestOptions); + + if (tunnelRequestOptions.timeout) { + request.on('timeout', () => { + request.destroy(); + reject(new Error('Request time out')); + }); + } + + request.end(); + + // establish connection to the proxy + request.on('connect', (response, socket) => { + const proxyStatusCode = response?.statusCode || ProxyStatus.SERVER_ERROR; + if ((proxyStatusCode < ProxyStatus.SUCCESS_RANGE_START) || (proxyStatusCode > ProxyStatus.SUCCESS_RANGE_END)) { + request.destroy(); + socket.destroy(); + reject(new Error(`Error connecting to proxy. Http status code: ${response.statusCode}. Http status message: ${response?.statusMessage || 'Unknown'}`)); + } + if (tunnelRequestOptions.timeout) { + socket.setTimeout(tunnelRequestOptions.timeout); + socket.on('timeout', () => { + request.destroy(); + socket.destroy(); + reject(new Error('Request time out')); + }); + } + + // make a request over an HTTP tunnel + socket.write(outgoingRequestString); + + const data: Buffer[] = []; + socket.on('data', (chunk) => { + data.push(chunk); + }); + + socket.on('end', () => { + // combine all received buffer streams into one buffer, and then into a string + const dataString = Buffer.concat([...data]).toString(); + + // separate each line into it's own entry in an arry + const dataStringArray = dataString.split('\r\n'); + // the first entry will contain the statusCode and statusMessage + const httpStatusCode = parseInt(dataStringArray[0].split(' ')[1], undefined); + // remove 'HTTP/1.1' and the status code to get the status message + const statusMessage = dataStringArray[0].split(' ').slice(2).join(' '); + // the last entry will contain the body + const body = dataStringArray[dataStringArray.length - 1]; + + // everything in between the first and last entries are the headers + const headersArray = dataStringArray.slice(1, dataStringArray.length - 2); + + // build an object out of all the headers + const entries = new Map(); + headersArray.forEach((header) => { + /** + * the header might look like 'Content-Length: 1531', but that is just a string + * it needs to be converted to a key/value pair + * split the string at the first instance of ':' + * there may be more than one ':' if the value of the header is supposed to be a JSON object + */ + const headerKeyValue = header.split(new RegExp(/:\s(.*)/s)); + const headerKey = headerKeyValue[0]; + let headerValue = headerKeyValue[1]; + + // check if the value of the header is supposed to be a JSON object + try { + const object = JSON.parse(headerValue); + + // if it is, then convert it from a string to a JSON object + if (object && (typeof object === 'object')) { + headerValue = object; + } + } catch (e) { + // otherwise, leave it as a string + } + + entries.set(headerKey, headerValue); + }); + + const parsedHeaders = Object.fromEntries(entries) as Record; + const networkResponse = NetworkUtils.getNetworkResponse( + parsedHeaders, + parseBody(httpStatusCode, statusMessage, parsedHeaders, body) as T, + httpStatusCode + ); + + + if (((httpStatusCode < HttpStatus.SUCCESS_RANGE_START) || (httpStatusCode > HttpStatus.SUCCESS_RANGE_END)) && + // do not destroy the request for the device code flow + // @ts-ignore + networkResponse.body['error'] !== HttpClient.AUTHORIZATION_PENDING) { + request.destroy(); + } + resolve(networkResponse); + }); + + socket.on('error', (chunk) => { + request.destroy(); + socket.destroy(); + reject(new Error(chunk.toString())); + }); + }); + + request.on('error', (chunk) => { + request.destroy(); + reject(new Error(chunk.toString())); + }); + })); +}; + +const networkRequestViaHttps = ( + urlString: string, + httpMethod: string, + options?: NetworkRequestOptions, + agentOptions?: https.AgentOptions, + timeout?: number +): Promise> => { + const isPostRequest = httpMethod === HttpMethod.POST; + const body: string = options?.body || ''; + const url = new URL(urlString); + const optionHeaders = options?.headers || {} as Record; + let customOptions: https.RequestOptions = { + method: httpMethod, + headers: optionHeaders, + ...NetworkUtils.urlToHttpOptions(url) + }; + + if (timeout) { + customOptions.timeout = timeout; + } + + if (agentOptions && Object.keys(agentOptions).length) { + customOptions.agent = new https.Agent(agentOptions); + } + + if (isPostRequest) { + // needed for post request to work + customOptions.headers = { + ...customOptions.headers, + 'Content-Length': body.length + }; + } + + return new Promise>((resolve, reject) => { + const request = https.request(customOptions); + + if (timeout) { + request.on('timeout', () => { + request.destroy(); + reject(new Error('Request time out')); + }); + } + + if (isPostRequest) { + request.write(body); + } + + request.end(); + + request.on('response', (response) => { + const headers = response.headers; + const statusCode = response.statusCode as number; + const statusMessage = response.statusMessage; + + const data: Buffer[] = []; + response.on('data', (chunk) => { + data.push(chunk); + }); + + response.on('end', () => { + // combine all received buffer streams into one buffer, and then into a string + const dataBody = Buffer.concat([...data]).toString(); + + const parsedHeaders = headers as Record; + const networkResponse = NetworkUtils.getNetworkResponse( + parsedHeaders, + parseBody(statusCode, statusMessage, parsedHeaders, dataBody) as T, + statusCode + ); + + if (((statusCode < HttpStatus.SUCCESS_RANGE_START) || (statusCode > HttpStatus.SUCCESS_RANGE_END)) && + // do not destroy the request for the device code flow + // @ts-ignore + networkResponse.body['error'] !== HttpClient.AUTHORIZATION_PENDING) { + request.destroy(); + } + resolve(networkResponse); + }); + }); + + request.on('error', (chunk) => { + request.destroy(); + reject(new Error(chunk.toString())); + }); + }); +}; + +/** + * Check if extra parsing is needed on the repsonse from the server + * @param statusCode {number} the status code of the response from the server + * @param statusMessage {string | undefined} the status message of the response from the server + * @param headers {Record} the headers of the response from the server + * @param body {string} the body from the response of the server + * @returns JSON parsed body or error object + */ +const parseBody = (statusCode: number, statusMessage: string | undefined, headers: Record, body: string) => { + /* + * Informational responses (100 - 199) + * Successful responses (200 - 299) + * Redirection messages (300 - 399) + * Client error responses (400 - 499) + * Server error responses (500 - 599) + */ + + let parsedBody; + try { + parsedBody = JSON.parse(body); + } catch (error) { + let errorType; + let errorDescriptionHelper; + if ((statusCode >= HttpStatus.CLIENT_ERROR_RANGE_START) && (statusCode <= HttpStatus.CLIENT_ERROR_RANGE_END)) { + errorType = 'client_error'; + errorDescriptionHelper = 'A client'; + } else if ((statusCode >= HttpStatus.SERVER_ERROR_RANGE_START) && (statusCode <= HttpStatus.SERVER_ERROR_RANGE_END)) { + errorType = 'server_error'; + errorDescriptionHelper = 'A server'; + } else { + errorType = 'unknown_error'; + errorDescriptionHelper = 'An unknown'; + } + + parsedBody = { + error: errorType, + error_description: `${errorDescriptionHelper} error occured.\nHttp status code: ${statusCode}\nHttp status message: ${statusMessage || 'Unknown'}\nHeaders: ${JSON.stringify(headers)}` + }; + } + + return parsedBody; +}; diff --git a/extensions/azurecore/src/account-provider/auths/networkUtils.ts b/extensions/azurecore/src/account-provider/auths/networkUtils.ts new file mode 100644 index 0000000000..2ba57d1e1e --- /dev/null +++ b/extensions/azurecore/src/account-provider/auths/networkUtils.ts @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { NetworkResponse } from '@azure/msal-common'; +import * as https from 'https'; + +export class NetworkUtils { + static getNetworkResponse(headers: Record, body: Body, statusCode: number): NetworkResponse { + return { + headers: headers, + body: body, + status: statusCode + }; + } + + /* + * Utility function that converts a URL object into an ordinary options object as expected by the + * http.request and https.request APIs. + */ + static urlToHttpOptions(url: URL): https.RequestOptions { + const options: https.RequestOptions & Partial> = { + protocol: url.protocol, + hostname: url.hostname && url.hostname.startsWith('[') ? + url.hostname.slice(1, -1) : + url.hostname, + hash: url.hash, + search: url.search, + pathname: url.pathname, + path: `${url.pathname || ''}${url.search || ''}`, + href: url.href + }; + if (url.port !== '') { + options.port = Number(url.port); + } + if (url.username || url.password) { + options.auth = `${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`; + } + return options; + } +} + diff --git a/extensions/azurecore/src/constants.ts b/extensions/azurecore/src/constants.ts index 38857189bd..b3221f1032 100644 --- a/extensions/azurecore/src/constants.ts +++ b/extensions/azurecore/src/constants.ts @@ -46,6 +46,9 @@ export const AccountVersion = '2.0'; export const Bearer = 'Bearer'; +/** HTTP Client */ +export const httpConfigSectionName = 'http'; + /** * Use SHA-256 algorithm */ diff --git a/extensions/azurecore/src/proxy.ts b/extensions/azurecore/src/proxy.ts new file mode 100644 index 0000000000..63f59346a6 --- /dev/null +++ b/extensions/azurecore/src/proxy.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { HttpProxyAgent, HttpProxyAgentOptions } from 'http-proxy-agent'; +import { HttpsProxyAgent, HttpsProxyAgentOptions } from 'https-proxy-agent'; +import { parse as parseUrl, Url } from 'url'; + +function getSystemProxyURL(requestURL: Url): string | undefined { + if (requestURL.protocol === 'http:') { + return process.env.HTTP_PROXY || process.env.http_proxy || undefined; + } else if (requestURL.protocol === 'https:') { + return process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy || undefined; + } + + return undefined; +} + +export function isBoolean(obj: any): obj is boolean { + return obj === true || obj === false; +} + +/* + * Returns the proxy agent using the proxy url in the parameters or the system proxy. Returns null if no proxy found + */ +export function getProxyAgent(requestURL: Url, proxy?: string, strictSSL?: boolean): HttpsProxyAgent | HttpProxyAgent | undefined { + const proxyURL = proxy || getSystemProxyURL(requestURL); + if (!proxyURL) { + return undefined; + } + const proxyEndpoint = parseUrl(proxyURL); + const opts = getProxyAgentOptions(requestURL, proxy, strictSSL); + return proxyEndpoint.protocol === 'https:' ? new HttpsProxyAgent(opts as HttpsProxyAgentOptions) : new HttpProxyAgent(opts as HttpProxyAgentOptions); +} + +/* + * Returns the proxy agent using the proxy url in the parameters or the system proxy. Returns null if no proxy found + */ +export function getProxyAgentOptions(requestURL: Url, proxy?: string, strictSSL?: boolean): HttpsProxyAgentOptions | HttpProxyAgentOptions | undefined { + const proxyURL = proxy || getSystemProxyURL(requestURL); + + if (!proxyURL) { + return undefined; + } + + const proxyEndpoint = parseUrl(proxyURL); + + if (!/^https?:$/.test(proxyEndpoint.protocol!)) { + return undefined; + } + + const opts: HttpsProxyAgentOptions | HttpProxyAgentOptions = { + host: proxyEndpoint.hostname, + port: Number(proxyEndpoint.port), + auth: proxyEndpoint.auth, + rejectUnauthorized: isBoolean(strictSSL) ? strictSSL : true + }; + + return opts; +} diff --git a/extensions/azurecore/src/utils.ts b/extensions/azurecore/src/utils.ts index 1f0041beb1..3a6a332848 100644 --- a/extensions/azurecore/src/utils.ts +++ b/extensions/azurecore/src/utils.ts @@ -4,8 +4,19 @@ *--------------------------------------------------------------------------------------------*/ import * as loc from './localizedConstants'; +import * as vscode from 'vscode'; +import * as constants from './constants'; + import { AzureRegion, azureResource } from 'azurecore'; import { AppContext } from './appContext'; +import { HttpClient } from './account-provider/auths/httpClient'; +import { parse } from 'url'; +import { getProxyAgentOptions } from './proxy'; +import { HttpsProxyAgentOptions } from 'https-proxy-agent'; + +const configProxy = 'proxy'; +const configProxyStrictSSL = 'proxyStrictSSL'; +const configProxyAuthorization = 'proxyAuthorization'; /** * Converts a region value (@see AzureRegion) into the localized Display Name @@ -125,6 +136,9 @@ export function getResourceTypeDisplayName(type: string): string { } return type; } +function getHttpConfiguration(): vscode.WorkspaceConfiguration { + return vscode.workspace.getConfiguration(constants.httpConfigSectionName); +} export function getResourceTypeIcon(appContext: AppContext, type: string): string { switch (type) { @@ -145,3 +159,23 @@ export function getResourceTypeIcon(appContext: AppContext, type: string): strin } return ''; } + + +export function getProxyEnabledHttpClient(): HttpClient { + const proxy = getHttpConfiguration().get(configProxy); + const strictSSL = getHttpConfiguration().get(configProxyStrictSSL, true); + const authorization = getHttpConfiguration().get(configProxyAuthorization); + + const url = parse(proxy); + let agentOptions = getProxyAgentOptions(url, proxy, strictSSL); + + if (authorization && url.protocol === 'https:') { + let httpsAgentOptions = agentOptions as HttpsProxyAgentOptions; + httpsAgentOptions!.headers = Object.assign(httpsAgentOptions!.headers || {}, { + 'Proxy-Authorization': authorization + }); + agentOptions = httpsAgentOptions; + } + + return new HttpClient(proxy, agentOptions); +}