diff --git a/docs/docs/user-guide/using-proxy-url.mdx b/docs/docs/user-guide/using-proxy-url.mdx new file mode 100644 index 00000000000..b2888bd5daa --- /dev/null +++ b/docs/docs/user-guide/using-proxy-url.mdx @@ -0,0 +1,17 @@ +--- +title: Use proxy url +sidebar_position: 15 +--- + +# Configure CLI for Microsoft 365 to use a proxy + +If you are behind a corporate proxy, you'll be able to use the CLI for Microsoft 365 when the environment variable `HTTP_PROXY` or `HTTPS_PROXY` is set. + +## Understanding Proxy URL + +When using a proxy, it's important to understand the different parts of a proxy URL. A proxy URL typically consists of the following elements: + +- **protocol**: The protocol used by the proxy server, such as `HTTP`, `HTTPS`, or `SOCKS` +- **username and password**: if the proxy server requires authentication, you will need to provide a username and password +- **host**: the hostname or IP address of the proxy server +- **port number**: the port number on which the proxy server is listening. Defaults to 443 for the `HTTPS` protocol, otherwise it defaults to 80 when not provided diff --git a/src/Auth.ts b/src/Auth.ts index 37bf656b932..ee47575a8e6 100644 --- a/src/Auth.ts +++ b/src/Auth.ts @@ -328,7 +328,8 @@ export class Auth { }, piiLoggingEnabled: false, logLevel: debug ? LogLevel.Verbose : LogLevel.Error - } + }, + proxyUrl: process.env.HTTP_PROXY || process.env.HTTPS_PROXY } }; } diff --git a/src/appInsights.spec.ts b/src/appInsights.spec.ts index a16698863e9..e223bc10204 100644 --- a/src/appInsights.spec.ts +++ b/src/appInsights.spec.ts @@ -43,4 +43,20 @@ describe('appInsights', () => { const i: any = await import(`./appInsights.js#${Math.random()}`); assert(i.default.commonProperties.env === 'docker'); }); + + it('sets proxyHttpUrl in the telemetry', async () => { + const proxyHttpUrl = 'http://username:password@proxy.contoso.com:8080'; + sinon.stub(process, 'env').value({ 'HTTP_PROXY': proxyHttpUrl }); + + const i: any = await import(`./appInsights.js#${Math.random()}`); + assert(i.default.config.proxyHttpUrl === proxyHttpUrl); + }); + + it('sets proxyHttpsUrl in the telemetry', async () => { + const proxyHttpsUrl = 'https://username:password@proxy.contoso.com:8080'; + sinon.stub(process, 'env').value({ 'HTTPS_PROXY': proxyHttpsUrl }); + + const i: any = await import(`./appInsights.js#${Math.random()}`); + assert(i.default.config.proxyHttpsUrl === proxyHttpsUrl); + }); }); \ No newline at end of file diff --git a/src/appInsights.ts b/src/appInsights.ts index 710623228d9..8e6d0f23f9f 100644 --- a/src/appInsights.ts +++ b/src/appInsights.ts @@ -26,6 +26,9 @@ appInsightsClient.commonProperties = { ci: Boolean(process.env.CI).toString() }; +appInsightsClient.config.proxyHttpUrl = process.env.HTTP_PROXY ?? ''; +appInsightsClient.config.proxyHttpsUrl = process.env.HTTPS_PROXY ?? ''; + appInsightsClient.context.tags['ai.cloud.roleInstance'] = crypto.createHash('sha256').update(appInsightsClient.context.tags['ai.cloud.roleInstance']).digest('hex'); delete appInsightsClient.context.tags['ai.cloud.role']; delete appInsightsClient.context.tags['ai.cloud.roleName']; diff --git a/src/request.spec.ts b/src/request.spec.ts index 6724589580a..c65945d5bee 100644 --- a/src/request.spec.ts +++ b/src/request.spec.ts @@ -25,6 +25,7 @@ describe('Request', () => { afterEach(() => { _request.debug = false; sinonUtil.restore([ + process.env, global.setTimeout, https.request, (_request as any).req, @@ -394,6 +395,106 @@ describe('Request', () => { }); }); + it('returns response of a successful GET request, with a proxy url', (done) => { + let outcome = false; + sinon.stub(process, 'env').value({ 'HTTPS_PROXY': 'http://proxy.contoso.com:8080' }); + + sinon.stub(_request as any, 'req').callsFake((options) => { + _options = options as CliRequestOptions; + if (_options.proxy && _options.proxy.host === 'proxy.contoso.com' && _options.proxy.port === 8080 && _options.proxy.protocol === 'http') { + outcome = true; + } + return Promise.resolve({ data: {} }); + }); + + _request + .get({ + url: 'https://contoso.sharepoint.com/' + }) + .then(() => { + assert(outcome); + done(); + }, (err) => { + done(err); + }); + }); + + it('returns response of a successful GET request, with a proxy url and defaults port to 80', (done) => { + let outcome = false; + sinon.stub(process, 'env').value({ 'HTTPS_PROXY': 'http://proxy.contoso.com' }); + + sinon.stub(_request as any, 'req').callsFake((options) => { + _options = options as CliRequestOptions; + if (_options.proxy && _options.proxy.host === 'proxy.contoso.com' && _options.proxy.port === 80 && _options.proxy.protocol === 'http') { + outcome = true; + } + return Promise.resolve({ data: {} }); + }); + + _request + .get({ + url: 'https://contoso.sharepoint.com/' + }) + .then(() => { + assert(outcome); + done(); + }, (err) => { + done(err); + }); + }); + + it('returns response of a successful GET request, with a proxy url and defaults port to 443', (done) => { + let outcome = false; + sinon.stub(process, 'env').value({ 'HTTPS_PROXY': 'https://proxy.contoso.com' }); + + sinon.stub(_request as any, 'req').callsFake((options) => { + _options = options as CliRequestOptions; + if (_options.proxy && _options.proxy.host === 'proxy.contoso.com' && _options.proxy.port === 443 && _options.proxy.protocol === 'http') { + outcome = true; + } + return Promise.resolve({ data: {} }); + }); + + _request + .get({ + url: 'https://contoso.sharepoint.com/' + }) + .then(() => { + assert(outcome); + done(); + }, (err) => { + done(err); + }); + }); + + it('returns response of a successful GET request, with a proxy url with username and password', (done) => { + let outcome = false; + sinon.stub(process, 'env').value({ 'HTTPS_PROXY': 'http://username:password@proxy.contoso.com:8080' }); + + sinon.stub(_request as any, 'req').callsFake((options) => { + _options = options as CliRequestOptions; + if (_options.proxy && _options.proxy.host === 'proxy.contoso.com' + && _options.proxy.port === 8080 + && _options.proxy.protocol === 'http' + && _options.proxy.auth?.username === 'username' + && _options.proxy.auth?.password === 'password') { + outcome = true; + } + return Promise.resolve({ data: {} }); + }); + + _request + .get({ + url: 'https://contoso.sharepoint.com/' + }) + .then(() => { + assert(outcome); + done(); + }, (err) => { + done(err); + }); + }); + it('correctly handles failed GET request', (cb) => { sinon.stub(_request as any, 'req').callsFake(options => { _options = options as CliRequestOptions; diff --git a/src/request.ts b/src/request.ts index 6ed2dfa783a..2840f6fa0b3 100644 --- a/src/request.ts +++ b/src/request.ts @@ -1,4 +1,4 @@ -import Axios, { AxiosError, AxiosInstance, AxiosPromise, AxiosRequestConfig, AxiosResponse } from 'axios'; +import Axios, { AxiosError, AxiosInstance, AxiosPromise, AxiosProxyConfig, AxiosRequestConfig, AxiosResponse } from 'axios'; import { Stream } from 'stream'; import auth, { Auth, CloudType } from './Auth.js'; import { Logger } from './cli/Logger.js'; @@ -183,6 +183,11 @@ class Request { options.headers.authorization = `Bearer ${accessToken}`; } } + + const proxyUrl = process.env.HTTP_PROXY || process.env.HTTPS_PROXY; + if (proxyUrl) { + options.proxy = this.createProxyConfigFromUrl(proxyUrl); + } return this.req(options); }) .then((res: any): void => { @@ -231,6 +236,19 @@ class Request { const cloudUrl: string = Auth.getEndpointForResource(hostname, cloudType); options.url = options.url!.replace(hostname, cloudUrl); } + + private createProxyConfigFromUrl(url: string): AxiosProxyConfig { + const parsedUrl = new URL(url); + const port = parsedUrl.port || (url.toLowerCase().startsWith('https') ? 443 : 80); + let authObject = null; + if (parsedUrl.username && parsedUrl.password) { + authObject = { + username: parsedUrl.username, + password: parsedUrl.password + }; + } + return { host: parsedUrl.hostname, port: Number(port), protocol: 'http', ...(authObject && { auth: authObject }) }; + } } export default new Request(); diff --git a/src/settingsNames.ts b/src/settingsNames.ts index fdfc012d58a..27af1505b95 100644 --- a/src/settingsNames.ts +++ b/src/settingsNames.ts @@ -14,6 +14,7 @@ const settingsNames = { printErrorsAsPlainText: 'printErrorsAsPlainText', prompt: 'prompt', promptListPageSize: 'promptListPageSize', + proxyUrl: 'proxyUrl', showHelpOnFailure: 'showHelpOnFailure', showSpinner: 'showSpinner' }; diff --git a/src/telemetryRunner.ts b/src/telemetryRunner.ts index 4f1f0337fda..11454a37412 100644 --- a/src/telemetryRunner.ts +++ b/src/telemetryRunner.ts @@ -26,4 +26,4 @@ try { } appInsights.flush(); } -catch { } +catch { } \ No newline at end of file