From 203e9c8d0ad9af02fea0a8bfccb1791a5cd244bf Mon Sep 17 00:00:00 2001 From: Keith Robertson Date: Wed, 14 Feb 2018 17:45:38 -0800 Subject: [PATCH] Add support for token based auth to services other than VSTS This fixes #4956 --- Tasks/Common/npm-common/npmregistry.ts | 77 +++++++-- Tasks/Common/npm-common/package-lock.json | 49 ++++++ Tasks/Common/npm-common/package.json | 1 + Tasks/Npm/Tests/L0.ts | 196 ++++++++++++++++++++++ Tasks/Npm/package.json | 2 +- Tasks/Npm/task.json | 2 +- Tasks/Npm/task.loc.json | 4 +- 7 files changed, 312 insertions(+), 19 deletions(-) diff --git a/Tasks/Common/npm-common/npmregistry.ts b/Tasks/Common/npm-common/npmregistry.ts index 948ce5ee44e2..489d397cac1b 100644 --- a/Tasks/Common/npm-common/npmregistry.ts +++ b/Tasks/Common/npm-common/npmregistry.ts @@ -1,6 +1,7 @@ import * as os from 'os'; import * as tl from 'vsts-task-lib/task'; -import * as url from 'url'; +import * as URL from 'url'; +import * as ipaddress from 'ip-address'; import { NormalizeRegistry } from './npmrcparser'; import * as util from './util'; @@ -23,11 +24,16 @@ export class NpmRegistry implements INpmRegistry { } public static FromServiceEndpoint(endpointId: string, authOnly?: boolean): NpmRegistry { - let email: string; - let username: string; - let password: string; + let lineEnd = os.EOL; let endpointAuth: tl.EndpointAuthorization; let url: string; + let nerfed: string; + let auth: string; + let username: string; + let password: string; + let email: string; + let password64: string; + let isVstsTokenAuth: boolean = false; try { endpointAuth = tl.getEndpointAuthorization(endpointId, false); } catch (exception) { @@ -35,7 +41,22 @@ export class NpmRegistry implements INpmRegistry { } try { + let collectionUrl = tl.getVariable("System.TeamFoundationCollectionUri"); + let collectionUrlDomain = NpmRegistry.ParseHostnameForDomain(URL.parse(collectionUrl).hostname); url = NormalizeRegistry(tl.getEndpointUrl(endpointId, false)); + let epUrlDomain = NpmRegistry.ParseHostnameForDomain(URL.parse(url).hostname); + + // To the reader, this could be optimized here but it is broken out for readability + if (endpointAuth.scheme === 'Token'){ + if (collectionUrlDomain === epUrlDomain){ + // Same domain _OR_ matching IP addrs. Use PAT+Basic + isVstsTokenAuth = true; + } else if (epUrlDomain.toUpperCase() === 'VISUALSTUDIO.COM'){ + // If the endpoint is VSTS, full stop. Use PAT+Basic + isVstsTokenAuth = true; + } + } + nerfed = util.toNerfDart(url); } catch (exception) { throw new Error(tl.loc('ServiceEndpointUrlNotDefined')); } @@ -45,23 +66,32 @@ export class NpmRegistry implements INpmRegistry { username = endpointAuth.parameters['username']; password = endpointAuth.parameters['password']; email = username; // npm needs an email to be set in order to publish, this is ignored on npmjs + password64 = (new Buffer(password).toString('base64')); + + auth = nerfed + ":username=" + username + lineEnd; + auth += nerfed + ":_password=" + password64 + lineEnd; + auth += nerfed + ":email=" + email + lineEnd; break; case 'Token': - email = 'VssEmail'; - username = 'VssToken'; - password = endpointAuth.parameters['apitoken']; + let apitoken = endpointAuth.parameters['apitoken']; + if (!isVstsTokenAuth){ + // Use Bearer auth as it was intended. + auth = nerfed + ":_authToken=" + apitoken + lineEnd; + }else{ + // VSTS does not support PATs+Bearer only JWTs+Bearer + email = 'VssEmail'; + username = 'VssToken'; + password64 = (new Buffer(apitoken).toString('base64')); + console.log("##vso[task.setvariable variable=" + endpointId + "BASE64_PASSWORD;issecret=true;]" + password64); + + auth = nerfed + ":username=" + username + lineEnd; + auth += nerfed + ":_password=" + password64 + lineEnd; + auth += nerfed + ":email=" + email + lineEnd; + } break; } - let lineEnd = os.EOL; - let nerfed = util.toNerfDart(url); - let password64 = (new Buffer(password).toString('base64')); - console.log("##vso[task.setvariable variable=" + endpointId + "BASE64_PASSWORD;issecret=true;]" + password64); - let auth = nerfed + ":username=" + username + lineEnd; - auth += nerfed + ":_password=" + password64 + lineEnd; - auth += nerfed + ":email=" + email + lineEnd; auth += nerfed + ":always-auth=true"; - return new NpmRegistry(url, auth, authOnly); } @@ -72,4 +102,21 @@ export class NpmRegistry implements INpmRegistry { return new NpmRegistry(url, auth, authOnly); } + + /** + * Helper function to return the domain name given a hostname. + * @param hostname This should be the output from: url.parse('http://myserver.example.com').hostname + * @returns Given a FQDN the domain name will be returned. Given a hostname the + * hostname will be return. Given an IP address, the IP will be returned. + */ + private static ParseHostnameForDomain(hostname: string): string { + if (hostname && + (!new ipaddress.Address6(hostname).isValid() && + !new ipaddress.Address4(hostname).isValid())) { + // We know we have a non-null string that is not an IP addr + let hnAry = hostname.split('.'); + return hnAry.slice(-2).join('.') + } + return hostname; + } } diff --git a/Tasks/Common/npm-common/package-lock.json b/Tasks/Common/npm-common/package-lock.json index aaf9292a2e3b..61850ebb4b0c 100644 --- a/Tasks/Common/npm-common/package-lock.json +++ b/Tasks/Common/npm-common/package-lock.json @@ -28,6 +28,50 @@ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==" }, + "ip-address": { + "version": "5.8.9", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-5.8.9.tgz", + "integrity": "sha512-7ay355oMN34iXhET1BmCJVsHjOTSItEEIIpOs38qUC23AIhOy+xIPnkrTuEFjeLMrTJ7m8KMXWgWfy/2Vn9sDw==", + "requires": { + "jsbn": "1.1.0", + "lodash.find": "4.6.0", + "lodash.max": "4.0.1", + "lodash.merge": "4.6.1", + "lodash.padstart": "4.6.1", + "lodash.repeat": "4.1.0", + "sprintf-js": "1.1.0" + } + }, + "jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha1-sBMHyym2GKHtJux56RH4A8TaAEA=" + }, + "lodash.find": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.find/-/lodash.find-4.6.0.tgz", + "integrity": "sha1-ywcE1Hq3F4n/oN6Ll92Sb7iLE7E=" + }, + "lodash.max": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.max/-/lodash.max-4.0.1.tgz", + "integrity": "sha1-hzVWbGGLNan3YFILSHrnllivE2o=" + }, + "lodash.merge": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.1.tgz", + "integrity": "sha512-AOYza4+Hf5z1/0Hztxpm2/xiPZgi/cjMqdnKTUWTBSKchJlxXXuUSxCCl8rJlf4g6yww/j6mA8nC8Hw/EZWxKQ==" + }, + "lodash.padstart": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/lodash.padstart/-/lodash.padstart-4.6.1.tgz", + "integrity": "sha1-0uPuv/DZ05rVD1y9G1KnvOa7YRs=" + }, + "lodash.repeat": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/lodash.repeat/-/lodash.repeat-4.1.0.tgz", + "integrity": "sha1-/H3oEx2MisB+S0n3T/6CnR8r7EQ=" + }, "minimatch": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", @@ -56,6 +100,11 @@ "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.3.0.tgz", "integrity": "sha1-NZbmMHp4FUT1kfN9phg2DzHbV7E=" }, + "sprintf-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.0.tgz", + "integrity": "sha1-z/yvcC2vZeo5u04PorKZzsGhvkY=" + }, "tunnel": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.4.tgz", diff --git a/Tasks/Common/npm-common/package.json b/Tasks/Common/npm-common/package.json index 5acab560cc14..a278af1acc4c 100644 --- a/Tasks/Common/npm-common/package.json +++ b/Tasks/Common/npm-common/package.json @@ -14,6 +14,7 @@ "dependencies": { "ini": "^1.3.4", "q": "^1.5.0", + "ip-address": "^5.8.9", "vsts-task-lib": "2.0.6", "vso-node-api": "^6.2.2-preview" } diff --git a/Tasks/Npm/Tests/L0.ts b/Tasks/Npm/Tests/L0.ts index 894541e393e0..7d8ada81b6c8 100644 --- a/Tasks/Npm/Tests/L0.ts +++ b/Tasks/Npm/Tests/L0.ts @@ -8,6 +8,12 @@ import * as ttm from 'vsts-task-lib/mock-test'; import { NpmMockHelper } from './NpmMockHelper'; +const BASIC_AUTH_PAT_PASSWD_REGEX = /\/\/.*\/:_password=.*/g; +const BEARER_AUTH_REGEX = /\/\/.*\/:_authToken=AUTHTOKEN.*/g; +const BASIC_AUTH_PAT_EML_REGEX = /\/\/.*\/:email=VssEmail.*/g; +const BASIC_AUTH_PAT_USERNAME_REGEX = /\/\/.*\/:username=VssToken.*/g; +const AWLAYS_AUTH_REGEX = /\/\/.*\/:always-auth=true.*/g; + describe('Npm Task', function () { before(() => { mockery.enable({ @@ -319,4 +325,194 @@ describe('Npm Task', function () { }); }); }); + + it('does Basic auth for hosted when service endpoint auth is Token and endpoint is in the .visualstudio.com domain', + (done: MochaDone) => { + // Scenario: Cross account on visualstudio.com + let mockTask = { + getVariable: (v) => { + if (v === 'System.TeamFoundationCollectionUri') { + return 'http://example.visualstudio.com'; + } + }, + getEndpointAuthorization: (id, optional) => { + return { scheme: 'Token', parameters: { 'apitoken': 'AUTHTOKEN' } }; + }, + getEndpointUrl: (id, optional) => { + return 'http://serviceendpoint.visualstudio.com'; + } + }; + mockery.registerMock('vsts-task-lib/task', mockTask); + const npmregistry = require("npm-common/npmregistry"); + let registry = npmregistry.NpmRegistry.FromServiceEndpoint('endpointId'); + + assert(registry.auth.match(BASIC_AUTH_PAT_PASSWD_REGEX), `Auth must contain a password. Auth is: (${registry.auth})`); + assert(registry.auth.match(BASIC_AUTH_PAT_EML_REGEX), `Auth must contain a email. Auth is: (${registry.auth})`); + assert(registry.auth.match(BASIC_AUTH_PAT_USERNAME_REGEX), `Auth must contain a email. Auth is: (${registry.auth})`); + assert(registry.auth.match(AWLAYS_AUTH_REGEX), `Auth must contain always-auth. Auth is: (${registry.auth})`); + + done(); + }); + + it('does Bearer auth for hosted when service endpoint auth is Token and endpoint is 3rd party', (done: MochaDone) => { + // Scenario: User is connecting to a non-visualstudio.com registry + let mockTask = { + getVariable: (v) => { + if (v === 'System.TeamFoundationCollectionUri') { + return 'http://example.visualstudio.com'; + } + }, + getEndpointAuthorization: (id, optional) => { + return { scheme: 'Token', parameters: { 'apitoken': 'AUTHTOKEN' } }; + }, + getEndpointUrl: (id, optional) => { + return 'http://somepublicrepo.contoso.com:8080/some/random/path'; + } + }; + mockery.registerMock('vsts-task-lib/task', mockTask); + const npmregistry = require("npm-common/npmregistry"); + let registry = npmregistry.NpmRegistry.FromServiceEndpoint('endpointId'); + + assert(registry.auth.match(BEARER_AUTH_REGEX), `Auth must contain _authToken. Auth is: (${registry.auth})`); + assert(registry.auth.match(AWLAYS_AUTH_REGEX), `Auth must contain always-auth. Auth is: (${registry.auth})`); + + done(); + }); + + it('does Basic auth for onprem when service endpoint auth is Token and the endpoint is in the same domain', (done: MochaDone) => { + // Scenario: onprem server A registry/feed in to onprem server B within same domain + let mockTask = { + getVariable: (v) => { + if (v === 'System.TeamFoundationCollectionUri') { + // Any collectionuri not ending in .visualstudio.com is onprem + return 'http://mytfsserver.example.com'; + } + }, + getEndpointAuthorization: (id, optional) => { + return { scheme: 'Token', parameters: { 'apitoken': 'AUTHTOKEN' } }; + }, + getEndpointUrl: (id, optional) => { + return 'http://serviceendpoint.visualstudio.com'; + } + }; + mockery.registerMock('vsts-task-lib/task', mockTask); + const npmregistry = require("npm-common/npmregistry"); + let registry = npmregistry.NpmRegistry.FromServiceEndpoint('endpointId'); + + assert(registry.auth.match(BASIC_AUTH_PAT_PASSWD_REGEX), `Auth must contain a password. Auth is: (${registry.auth})`); + assert(registry.auth.match(BASIC_AUTH_PAT_EML_REGEX), `Auth must contain a email. Auth is: (${registry.auth})`); + assert(registry.auth.match(BASIC_AUTH_PAT_USERNAME_REGEX), `Auth must contain a email. Auth is: (${registry.auth})`); + assert(registry.auth.match(AWLAYS_AUTH_REGEX), `Auth must contain always-auth. Auth is: (${registry.auth})`); + + done(); + }); + + it('does Bearer auth for onprem when service endpoint auth is Token and the endpoint is 3rd party', (done: MochaDone) => { + // Scenario: Onprem connecting to a 3rd party registry. + let mockTask = { + getVariable: (v) => { + if (v === 'System.TeamFoundationCollectionUri') { + return 'http://mytfsserver.example.com'; + } + }, + getEndpointAuthorization: (id, optional) => { + return { scheme: 'Token', parameters: { 'apitoken': 'AUTHTOKEN' } }; + }, + getEndpointUrl: (id, optional) => { + return 'http://somepublicrepo.contoso.com:8080/some/random/path'; + } + }; + mockery.registerMock('vsts-task-lib/task', mockTask); + const npmregistry = require("npm-common/npmregistry"); + let registry = npmregistry.NpmRegistry.FromServiceEndpoint('endpointId'); + + assert(registry.auth.match(BEARER_AUTH_REGEX), `Auth must contain _authToken. Auth is: (${registry.auth})`); + assert(registry.auth.match(AWLAYS_AUTH_REGEX), `Auth must contain always-auth. Auth is: (${registry.auth})`); + + done(); + }); + + it('does Bearer auth for onprem when service endpoint auth is Token and the endpoint is an IP addr', (done: MochaDone) => { + // Scenario: Onprem and user supplied an IP for the endpoint. We must assume that it is a 3rd party repo + // and, as such, will use bearer auth. + let mockTask = { + getVariable: (v) => { + if (v === 'System.TeamFoundationCollectionUri') { + return 'http://mytfsserver.example.com'; + } + }, + getEndpointAuthorization: (id, optional) => { + return { scheme: 'Token', parameters: { 'apitoken': 'AUTHTOKEN' } }; + }, + getEndpointUrl: (id, optional) => { + return 'http://10.10.10.10:8080/some/random/path'; + } + }; + mockery.registerMock('vsts-task-lib/task', mockTask); + const npmregistry = require("npm-common/npmregistry"); + let registry = npmregistry.NpmRegistry.FromServiceEndpoint('endpointId'); + + assert(registry.auth.match(BEARER_AUTH_REGEX), `Auth must contain _authToken. Auth is: (${registry.auth})`); + assert(registry.auth.match(AWLAYS_AUTH_REGEX), `Auth must contain always-auth. Auth is: (${registry.auth})`); + + done(); + }); + + it('does Basic auth for onprem when service endpoint auth is Token and the TFS server and EP have the same IP', (done: MochaDone) => { + // Scenario: Onprem and user supplied an IP for the endpoint and the TeamFoundationCollectionUri is a _matching_ IP + let mockTask = { + getVariable: (v) => { + if (v === 'System.TeamFoundationCollectionUri') { + return 'http://10.10.10.10:8080/'; + } + }, + getEndpointAuthorization: (id, optional) => { + return { scheme: 'Token', parameters: { 'apitoken': 'AUTHTOKEN' } }; + }, + getEndpointUrl: (id, optional) => { + return 'http://10.10.10.10:8080/some/random/path'; + } + }; + mockery.registerMock('vsts-task-lib/task', mockTask); + const npmregistry = require("npm-common/npmregistry"); + let registry = npmregistry.NpmRegistry.FromServiceEndpoint('endpointId'); + + assert(registry.auth.match(BASIC_AUTH_PAT_PASSWD_REGEX), `Auth must contain a password. Auth is: (${registry.auth})`); + assert(registry.auth.match(BASIC_AUTH_PAT_EML_REGEX), `Auth must contain a email. Auth is: (${registry.auth})`); + assert(registry.auth.match(BASIC_AUTH_PAT_USERNAME_REGEX), `Auth must contain a email. Auth is: (${registry.auth})`); + assert(registry.auth.match(AWLAYS_AUTH_REGEX), `Auth must contain always-auth. Auth is: (${registry.auth})`); + + done(); + }); + + it('does Basic auth for onprem when service endpoint auth is Token and the TFS server and EP have the same IP', (done: MochaDone) => { + // Scenario: Onprem and user supplied an IP for the endpoint and the TeamFoundationCollectionUri is a _matching_ IP + let mockTask = { + getVariable: (v) => { + if (v === 'System.TeamFoundationCollectionUri') { + return 'http://mytfsserver.example.com'; + } + }, + getEndpointAuthorization: (id, optional) => { + return { scheme: 'UsernamePassword', parameters: { 'username': 'USERNAME', 'password': 'PASSWORD' } }; + }, + getEndpointUrl: (id, optional) => { + return 'http://somepublicrepo.contoso.com:8080/some/random/path'; + } + }; + mockery.registerMock('vsts-task-lib/task', mockTask); + const npmregistry = require("npm-common/npmregistry"); + let registry = npmregistry.NpmRegistry.FromServiceEndpoint('endpointId'); + + const BASIC_AUTH_PASSWD_REGEX = /\/\/.*\/:_password=PASSWORD.*/g; + assert(registry.auth.match(BASIC_AUTH_PAT_PASSWD_REGEX), `Auth must contain a password. Auth is: (${registry.auth})`); + const BASIC_AUTH_PAT_EML_REGEX = /\/\/.*\/:email=USERNAME.*/g; + assert(registry.auth.match(BASIC_AUTH_PAT_EML_REGEX), `Auth must contain a email. Auth is: (${registry.auth})`); + const BASIC_AUTH_PAT_USERNAME_REGEX = /\/\/.*\/:username=USERNAME.*/g; + assert(registry.auth.match(BASIC_AUTH_PAT_USERNAME_REGEX), `Auth must contain a email. Auth is: (${registry.auth})`); + assert(registry.auth.match(AWLAYS_AUTH_REGEX), `Auth must contain always-auth. Auth is: (${registry.auth})`); + + done(); + }); + }); diff --git a/Tasks/Npm/package.json b/Tasks/Npm/package.json index f97f06df6ed8..f47ce651ff48 100644 --- a/Tasks/Npm/package.json +++ b/Tasks/Npm/package.json @@ -1,6 +1,6 @@ { "name": "vsts-npm-task", - "version": "1.0.10", + "version": "1.0.12", "description": "VSTS NPM Task", "main": "npmtask.js", "scripts": { diff --git a/Tasks/Npm/task.json b/Tasks/Npm/task.json index 6a108b8a246e..9df441b62d72 100644 --- a/Tasks/Npm/task.json +++ b/Tasks/Npm/task.json @@ -9,7 +9,7 @@ "version": { "Major": 1, "Minor": 0, - "Patch": 11 + "Patch": 12 }, "runsOn": [ "Agent", diff --git a/Tasks/Npm/task.loc.json b/Tasks/Npm/task.loc.json index e2f454816624..f7f6d64990cc 100644 --- a/Tasks/Npm/task.loc.json +++ b/Tasks/Npm/task.loc.json @@ -9,7 +9,7 @@ "version": { "Major": 1, "Minor": 0, - "Patch": 11 + "Patch": 12 }, "runsOn": [ "Agent", @@ -185,4 +185,4 @@ "RestoringProjectNpmrc": "ms-resource:loc.messages.RestoringProjectNpmrc", "WorkingDirectoryNotDirectory": "ms-resource:loc.messages.WorkingDirectoryNotDirectory" } -} \ No newline at end of file +}