From 63b5094915bfa85a9de849950fe0b7c749218ad6 Mon Sep 17 00:00:00 2001
From: kroonprins <kroonprins@users.noreply.github.com>
Date: Tue, 24 Mar 2020 23:08:00 +0100
Subject: [PATCH] feat(azure): support Azure DevOps Server authentication
 methods (#5602)

* feat(azure): support Azure DevOps Server authentication methods

* feat(azure): support Azure DevOps Server authentication methods

Co-authored-by: Jamie Magee <JamieMagee@users.noreply.github.com>
---
 .../azure-got-wrapper.spec.ts.snap            | 141 +++++++++++++++++-
 .../__snapshots__/azure-helper.spec.ts.snap   |  18 +++
 lib/platform/azure/azure-got-wrapper.spec.ts  |  48 +++++-
 lib/platform/azure/azure-got-wrapper.ts       |  16 +-
 lib/platform/azure/azure-helper.spec.ts       |  20 +++
 lib/platform/azure/azure-helper.ts            |  21 +++
 lib/platform/azure/index.spec.ts              |  20 ++-
 lib/platform/azure/index.ts                   |  11 +-
 lib/platform/git/storage.spec.ts              |   9 ++
 lib/platform/git/storage.ts                   |   9 +-
 lib/workers/global/index.ts                   |   2 +-
 11 files changed, 293 insertions(+), 22 deletions(-)

diff --git a/lib/platform/azure/__snapshots__/azure-got-wrapper.spec.ts.snap b/lib/platform/azure/__snapshots__/azure-got-wrapper.spec.ts.snap
index 5a6987453c..035875bc53 100644
--- a/lib/platform/azure/__snapshots__/azure-got-wrapper.spec.ts.snap
+++ b/lib/platform/azure/__snapshots__/azure-got-wrapper.spec.ts.snap
@@ -1,9 +1,9 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`platform/azure/azure-got-wrapper gitApi should set token and endpoint 1`] = `
+exports[`platform/azure/azure-got-wrapper gitApi should set personal access token and endpoint 1`] = `
 WebApi {
   "authHandler": PersonalAccessTokenCredentialHandler {
-    "token": "token",
+    "token": "1234567890123456789012345678901234567890123456789012",
   },
   "isNoProxyHost": [Function],
   "options": Object {
@@ -23,7 +23,7 @@ WebApi {
       "_socketTimeout": undefined,
       "handlers": Array [
         PersonalAccessTokenCredentialHandler {
-          "token": "token",
+          "token": "1234567890123456789012345678901234567890123456789012",
         },
       ],
       "requestOptions": Object {
@@ -31,12 +31,12 @@ WebApi {
       },
     },
   },
-  "serverUrl": "https://dev.azure.com/renovate12345",
+  "serverUrl": "https://dev.azure.com/renovate1",
   "vsoClient": VsoClient {
     "_initializationPromise": Promise {},
     "_locationsByAreaPromises": Object {},
-    "basePath": "/renovate12345",
-    "baseUrl": "https://dev.azure.com/renovate12345",
+    "basePath": "/renovate1",
+    "baseUrl": "https://dev.azure.com/renovate1",
     "restClient": RestClient {
       "client": HttpClient {
         "_allowRedirects": true,
@@ -51,6 +51,69 @@ WebApi {
         "_socketTimeout": undefined,
         "handlers": Array [
           PersonalAccessTokenCredentialHandler {
+            "token": "1234567890123456789012345678901234567890123456789012",
+          },
+        ],
+        "requestOptions": Object {
+          "ignoreSslError": false,
+        },
+      },
+    },
+  },
+}
+`;
+
+exports[`platform/azure/azure-got-wrapper gitApi should set bearer token and endpoint 1`] = `
+WebApi {
+  "authHandler": BearerCredentialHandler {
+    "token": "token",
+  },
+  "isNoProxyHost": [Function],
+  "options": Object {
+    "ignoreSslError": false,
+  },
+  "rest": RestClient {
+    "client": HttpClient {
+      "_allowRedirects": true,
+      "_allowRetries": false,
+      "_certConfig": undefined,
+      "_disposed": false,
+      "_httpProxy": undefined,
+      "_ignoreSslError": false,
+      "_keepAlive": false,
+      "_maxRedirects": 50,
+      "_maxRetries": 1,
+      "_socketTimeout": undefined,
+      "handlers": Array [
+        BearerCredentialHandler {
+          "token": "token",
+        },
+      ],
+      "requestOptions": Object {
+        "ignoreSslError": false,
+      },
+    },
+  },
+  "serverUrl": "https://dev.azure.com/renovate2",
+  "vsoClient": VsoClient {
+    "_initializationPromise": Promise {},
+    "_locationsByAreaPromises": Object {},
+    "basePath": "/renovate2",
+    "baseUrl": "https://dev.azure.com/renovate2",
+    "restClient": RestClient {
+      "client": HttpClient {
+        "_allowRedirects": true,
+        "_allowRetries": false,
+        "_certConfig": undefined,
+        "_disposed": false,
+        "_httpProxy": undefined,
+        "_ignoreSslError": false,
+        "_keepAlive": false,
+        "_maxRedirects": 50,
+        "_maxRetries": 1,
+        "_socketTimeout": undefined,
+        "handlers": Array [
+          BearerCredentialHandler {
             "token": "token",
           },
         ],
@@ -62,3 +125,69 @@ WebApi {
   },
 }
 `;
+
+exports[`platform/azure/azure-got-wrapper gitApi should set password and endpoint 1`] = `
+WebApi {
+  "authHandler": BasicCredentialHandler {
+    "password": "pass",
+    "username": "user",
+  },
+  "isNoProxyHost": [Function],
+  "options": Object {
+    "ignoreSslError": false,
+  },
+  "rest": RestClient {
+    "client": HttpClient {
+      "_allowRedirects": true,
+      "_allowRetries": false,
+      "_certConfig": undefined,
+      "_disposed": false,
+      "_httpProxy": undefined,
+      "_ignoreSslError": false,
+      "_keepAlive": false,
+      "_maxRedirects": 50,
+      "_maxRetries": 1,
+      "_socketTimeout": undefined,
+      "handlers": Array [
+        BasicCredentialHandler {
+          "password": "pass",
+          "username": "user",
+        },
+      ],
+      "requestOptions": Object {
+        "ignoreSslError": false,
+      },
+    },
+  },
+  "serverUrl": "https://dev.azure.com/renovate3",
+  "vsoClient": VsoClient {
+    "_initializationPromise": Promise {},
+    "_locationsByAreaPromises": Object {},
+    "basePath": "/renovate3",
+    "baseUrl": "https://dev.azure.com/renovate3",
+    "restClient": RestClient {
+      "client": HttpClient {
+        "_allowRedirects": true,
+        "_allowRetries": false,
+        "_certConfig": undefined,
+        "_disposed": false,
+        "_httpProxy": undefined,
+        "_ignoreSslError": false,
+        "_keepAlive": false,
+        "_maxRedirects": 50,
+        "_maxRetries": 1,
+        "_socketTimeout": undefined,
+        "handlers": Array [
+          BasicCredentialHandler {
+            "password": "pass",
+            "username": "user",
+          },
+        ],
+        "requestOptions": Object {
+          "ignoreSslError": false,
+        },
+      },
+    },
+  },
+}
+`;
diff --git a/lib/platform/azure/__snapshots__/azure-helper.spec.ts.snap b/lib/platform/azure/__snapshots__/azure-helper.spec.ts.snap
index bac3928c71..26d0c17e4f 100644
--- a/lib/platform/azure/__snapshots__/azure-helper.spec.ts.snap
+++ b/lib/platform/azure/__snapshots__/azure-helper.spec.ts.snap
@@ -1,5 +1,23 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
+exports[`platform/azure/helpers getStorageExtraCloneOpts should configure basic auth 1`] = `
+Object {
+  "--config": "http.extraheader=AUTHORIZATION: basic dXNlcjpwYXNz",
+}
+`;
+
+exports[`platform/azure/helpers getStorageExtraCloneOpts should configure personal access token 1`] = `
+Object {
+  "--config": "http.extraheader=AUTHORIZATION: basic OjEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=",
+}
+`;
+
+exports[`platform/azure/helpers getStorageExtraCloneOpts should configure bearer token 1`] = `
+Object {
+  "--config": "http.extraheader=AUTHORIZATION: bearer token",
+}
+`;
+
 exports[`platform/azure/helpers getAzureBranchObj should be the branch object formated 1`] = `
 Object {
   "name": "refs/heads/branchName",
diff --git a/lib/platform/azure/azure-got-wrapper.spec.ts b/lib/platform/azure/azure-got-wrapper.spec.ts
index a6b4d0f8ad..23cf907ed0 100644
--- a/lib/platform/azure/azure-got-wrapper.spec.ts
+++ b/lib/platform/azure/azure-got-wrapper.spec.ts
@@ -12,18 +12,52 @@ describe('platform/azure/azure-got-wrapper', () => {
   });
 
   describe('gitApi', () => {
-    it('should throw an error if no token is provided', () => {
-      expect(azure.gitApi).toThrow('No token found for azure');
-      expect(azure.coreApi).toThrow('No token found for azure');
-      expect(azure.policyApi).toThrow('No token found for azure');
+    it('should throw an error if no config found', () => {
+      expect(azure.gitApi).toThrow('No config found for azure');
+      expect(azure.coreApi).toThrow('No config found for azure');
+      expect(azure.policyApi).toThrow('No config found for azure');
     });
-    it('should set token and endpoint', () => {
+    it('should set personal access token and endpoint', () => {
+      hostRules.add({
+        hostType: PLATFORM_TYPE_AZURE,
+        token: '1234567890123456789012345678901234567890123456789012',
+        baseUrl: 'https://dev.azure.com/renovate1',
+      });
+      azure.setEndpoint('https://dev.azure.com/renovate1');
+
+      const res = azure.azureObj();
+
+      delete res.rest.client.userAgent;
+      delete res.vsoClient.restClient.client.userAgent;
+
+      // We will track if the lib azure-devops-node-api change
+      expect(res).toMatchSnapshot();
+    });
+    it('should set bearer token and endpoint', () => {
       hostRules.add({
         hostType: PLATFORM_TYPE_AZURE,
         token: 'token',
-        baseUrl: 'https://dev.azure.com/renovate12345',
+        baseUrl: 'https://dev.azure.com/renovate2',
+      });
+      azure.setEndpoint('https://dev.azure.com/renovate2');
+
+      const res = azure.azureObj();
+
+      delete res.rest.client.userAgent;
+      delete res.vsoClient.restClient.client.userAgent;
+
+      // We will track if the lib azure-devops-node-api change
+      expect(res).toMatchSnapshot();
+    });
+
+    it('should set password and endpoint', () => {
+      hostRules.add({
+        hostType: PLATFORM_TYPE_AZURE,
+        username: 'user',
+        password: 'pass',
+        baseUrl: 'https://dev.azure.com/renovate3',
       });
-      azure.setEndpoint('https://dev.azure.com/renovate12345');
+      azure.setEndpoint('https://dev.azure.com/renovate3');
 
       const res = azure.azureObj();
 
diff --git a/lib/platform/azure/azure-got-wrapper.ts b/lib/platform/azure/azure-got-wrapper.ts
index 1a2b50040d..fec7a3ec65 100644
--- a/lib/platform/azure/azure-got-wrapper.ts
+++ b/lib/platform/azure/azure-got-wrapper.ts
@@ -2,18 +2,28 @@ import * as azure from 'azure-devops-node-api';
 import { IGitApi } from 'azure-devops-node-api/GitApi';
 import { ICoreApi } from 'azure-devops-node-api/CoreApi';
 import { IPolicyApi } from 'azure-devops-node-api/PolicyApi';
+import { getHandlerFromToken, getBasicHandler } from 'azure-devops-node-api';
+import { IRequestHandler } from 'azure-devops-node-api/interfaces/common/VsoBaseInterfaces';
 import * as hostRules from '../../util/host-rules';
 import { PLATFORM_TYPE_AZURE } from '../../constants/platforms';
+import { HostRule } from '../../types';
 
 const hostType = PLATFORM_TYPE_AZURE;
 let endpoint: string;
 
+function getAuthenticationHandler(config: HostRule): IRequestHandler {
+  if (!config.token && config.username && config.password) {
+    return getBasicHandler(config.username, config.password);
+  }
+  return getHandlerFromToken(config.token);
+}
+
 export function azureObj(): azure.WebApi {
   const config = hostRules.find({ hostType, url: endpoint });
-  if (!(config && config.token)) {
-    throw new Error(`No token found for azure`);
+  if (!config.token && !(config.username && config.password)) {
+    throw new Error(`No config found for azure`);
   }
-  const authHandler = azure.getPersonalAccessTokenHandler(config.token);
+  const authHandler = getAuthenticationHandler(config);
   return new azure.WebApi(endpoint, authHandler);
 }
 
diff --git a/lib/platform/azure/azure-helper.spec.ts b/lib/platform/azure/azure-helper.spec.ts
index fbdc3c6b79..e812179c3e 100644
--- a/lib/platform/azure/azure-helper.spec.ts
+++ b/lib/platform/azure/azure-helper.spec.ts
@@ -13,6 +13,26 @@ describe('platform/azure/helpers', () => {
     azureApi = require('./azure-got-wrapper');
   });
 
+  describe('getStorageExtraCloneOpts', () => {
+    it('should configure basic auth', () => {
+      const res = azureHelper.getStorageExtraCloneOpts({
+        username: 'user',
+        password: 'pass',
+      });
+      expect(res).toMatchSnapshot();
+    });
+    it('should configure personal access token', () => {
+      const res = azureHelper.getStorageExtraCloneOpts({
+        token: '1234567890123456789012345678901234567890123456789012',
+      });
+      expect(res).toMatchSnapshot();
+    });
+    it('should configure bearer token', () => {
+      const res = azureHelper.getStorageExtraCloneOpts({ token: 'token' });
+      expect(res).toMatchSnapshot();
+    });
+  });
+
   describe('getNewBranchName', () => {
     it('should add refs/heads', () => {
       const res = azureHelper.getNewBranchName('testBB');
diff --git a/lib/platform/azure/azure-helper.ts b/lib/platform/azure/azure-helper.ts
index 1732baed5d..ca42e32127 100644
--- a/lib/platform/azure/azure-helper.ts
+++ b/lib/platform/azure/azure-helper.ts
@@ -5,6 +5,7 @@ import {
   GitPullRequest,
 } from 'azure-devops-node-api/interfaces/GitInterfaces';
 
+import { Options } from 'simple-git/promise';
 import * as azureApi from './azure-got-wrapper';
 import { logger } from '../../logger';
 import { Pr } from '../common';
@@ -13,9 +14,29 @@ import {
   PR_STATE_MERGED,
   PR_STATE_OPEN,
 } from '../../constants/pull-requests';
+import { HostRule } from '../../types';
 
 const mergePolicyGuid = 'fa4e907d-c16b-4a4c-9dfa-4916e5d171ab'; // Magic GUID for merge strategy policy configurations
 
+function toBase64(from: string): string {
+  return Buffer.from(from).toString('base64');
+}
+
+export function getStorageExtraCloneOpts(config: HostRule): Options {
+  let header: string;
+  const headerName = 'AUTHORIZATION';
+  if (!config.token && config.username && config.password) {
+    header = `${headerName}: basic ${toBase64(
+      `${config.username}:${config.password}`
+    )}`;
+  } else if (config.token.length !== 52) {
+    header = `${headerName}: bearer ${config.token}`;
+  } else {
+    header = `${headerName}: basic ${toBase64(`:${config.token}`)}`;
+  }
+  return { '--config': `http.extraheader=${header}` };
+}
+
 export function getNewBranchName(branchName?: string): string {
   if (branchName && !branchName.startsWith('refs/heads/')) {
     return `refs/heads/${branchName}`;
diff --git a/lib/platform/azure/index.spec.ts b/lib/platform/azure/index.spec.ts
index 6c652fdb52..821e103aef 100644
--- a/lib/platform/azure/index.spec.ts
+++ b/lib/platform/azure/index.spec.ts
@@ -82,7 +82,7 @@ describe('platform/azure', () => {
       expect.assertions(1);
       expect(() => azure.initPlatform({})).toThrow();
     });
-    it('should throw if no token', () => {
+    it('should throw if no token nor a username and password', () => {
       expect.assertions(1);
       expect(() =>
         azure.initPlatform({
@@ -90,6 +90,24 @@ describe('platform/azure', () => {
         })
       ).toThrow();
     });
+    it('should throw if a username but no password', () => {
+      expect.assertions(1);
+      expect(() =>
+        azure.initPlatform({
+          endpoint: 'https://dev.azure.com/renovate12345',
+          username: 'user',
+        })
+      ).toThrow();
+    });
+    it('should throw if a password but no username', () => {
+      expect.assertions(1);
+      expect(() =>
+        azure.initPlatform({
+          endpoint: 'https://dev.azure.com/renovate12345',
+          password: 'pass',
+        })
+      ).toThrow();
+    });
     it('should init', async () => {
       expect(
         await azure.initPlatform({
diff --git a/lib/platform/azure/index.ts b/lib/platform/azure/index.ts
index dfc322b133..dff1cea09e 100644
--- a/lib/platform/azure/index.ts
+++ b/lib/platform/azure/index.ts
@@ -58,12 +58,16 @@ const defaults: any = {
 export function initPlatform({
   endpoint,
   token,
+  username,
+  password,
 }: RenovateConfig): Promise<PlatformConfig> {
   if (!endpoint) {
     throw new Error('Init: You must configure an Azure DevOps endpoint');
   }
-  if (!token) {
-    throw new Error('Init: You must configure an Azure DevOps token');
+  if (!token && !(username && password)) {
+    throw new Error(
+      'Init: You must configure an Azure DevOps token, or a username and password'
+    );
   }
   // TODO: Add a connection check that endpoint/token combination are valid
   const res = {
@@ -149,12 +153,13 @@ export async function initRepo({
     url: defaults.endpoint,
   });
   const url =
-    defaults.endpoint.replace('https://', `https://token:${opts.token}@`) +
+    defaults.endpoint +
     `${encodeURIComponent(projectName)}/_git/${encodeURIComponent(repoName)}`;
   await config.storage.initRepo({
     ...config,
     localDir,
     url,
+    extraCloneOpts: azureHelper.getStorageExtraCloneOpts(opts),
   });
   const repoConfig: RepoConfig = {
     baseBranch: config.baseBranch,
diff --git a/lib/platform/git/storage.spec.ts b/lib/platform/git/storage.spec.ts
index be30d0b260..bac0b0c7c8 100644
--- a/lib/platform/git/storage.spec.ts
+++ b/lib/platform/git/storage.spec.ts
@@ -54,6 +54,9 @@ describe('platform/git/storage', () => {
     await git.initRepo({
       localDir: tmpDir.path,
       url: origin.path,
+      extraCloneOpts: {
+        '--config': 'extra.clone.config=test-extra-config-value',
+      },
     });
   });
 
@@ -400,5 +403,11 @@ describe('platform/git/storage', () => {
       expect(await fs.exists(tmpDir.path + '/.gitmodules')).toBeTruthy();
       await repo.reset(['--hard', 'HEAD^']);
     });
+
+    it('should use extra clone configuration', async () => {
+      const repo = Git(tmpDir.path).silent(true);
+      const res = (await repo.raw(['config', 'extra.clone.config'])).trim();
+      expect(res).toBe('test-extra-config-value');
+    });
   });
 });
diff --git a/lib/platform/git/storage.ts b/lib/platform/git/storage.ts
index 5da5d66f70..eebd206a56 100644
--- a/lib/platform/git/storage.ts
+++ b/lib/platform/git/storage.ts
@@ -39,6 +39,7 @@ interface StorageConfig {
   baseBranch?: string;
   url: string;
   gitPrivateKey?: string;
+  extraCloneOpts?: Git.Options;
 }
 
 interface LocalConfig extends StorageConfig {
@@ -183,7 +184,13 @@ export class Storage {
       const cloneStart = process.hrtime();
       try {
         // clone only the default branch
-        await this._git.clone(config.url, '.', ['--depth=2']);
+        let opts = ['--depth=2'];
+        if (config.extraCloneOpts) {
+          opts = opts.concat(
+            Object.entries(config.extraCloneOpts).map(e => `${e[0]}=${e[1]}`)
+          );
+        }
+        await this._git.clone(config.url, '.', opts);
       } catch (err) /* istanbul ignore next */ {
         logger.debug({ err }, 'git clone error');
         throw new Error(PLATFORM_FAILURE);
diff --git a/lib/workers/global/index.ts b/lib/workers/global/index.ts
index cefca1a89e..17cb888219 100644
--- a/lib/workers/global/index.ts
+++ b/lib/workers/global/index.ts
@@ -85,7 +85,7 @@ export async function start(): Promise<0 | 1> {
       await repositoryWorker.renovateRepository(repoConfig);
     }
     setMeta({});
-    logger.debug(`Renovate existing successfully`);
+    logger.debug(`Renovate exiting successfully`);
   } catch (err) /* istanbul ignore next */ {
     if (err.message.startsWith('Init: ')) {
       logger.fatal(err.message.substring(6));
-- 
GitLab