diff --git a/config/custom-environment-variables.yml b/config/custom-environment-variables.yml index 098d3fe0a86fa17ab294d98b5ab1f05541a4f211..468c4bf5de7a17d35ab74727df36925d88c117e7 100644 --- a/config/custom-environment-variables.yml +++ b/config/custom-environment-variables.yml @@ -25,11 +25,27 @@ public: dir: 'PERSISTENCE_DIR' services: + bitbucketServer: + authorizedOrigins: 'BITBUCKET_SERVER_ORIGINS' + drone: + authorizedOrigins: 'DRONE_ORIGINS' github: baseUri: 'GITHUB_URL' debug: enabled: 'GITHUB_DEBUG_ENABLED' intervalSeconds: 'GITHUB_DEBUG_INTERVAL_SECONDS' + jenkins: + authorizedOrigins: 'JENKINS_ORIGINS' + jira: + authorizedOrigins: 'JIRA_ORIGINS' + nexus: + authorizedOrigins: 'NEXUS_ORIGINS' + npm: + authorizedOrigins: 'NPM_ORIGINS' + sonar: + authorizedOrigins: 'SONAR_ORIGINS' + teamcity: + authorizedOrigins: 'TEAMCITY_ORIGINS' trace: 'TRACE_SERVICES' profiling: @@ -46,6 +62,10 @@ private: azure_devops_token: 'AZURE_DEVOPS_TOKEN' bintray_user: 'BINTRAY_USER' bintray_apikey: 'BINTRAY_API_KEY' + bitbucket_username: 'BITBUCKET_USER' + bitbucket_password: 'BITBUCKET_PASS' + bitbucket_server_username: 'BITBUCKET_SERVER_USER' + bitbucket_server_password: 'BITBUCKET_SERVER_PASS' drone_token: 'DRONE_TOKEN' gh_client_id: 'GH_CLIENT_ID' gh_client_secret: 'GH_CLIENT_SECRET' @@ -63,6 +83,8 @@ private: sl_insight_userUuid: 'SL_INSIGHT_USER_UUID' sl_insight_apiToken: 'SL_INSIGHT_API_TOKEN' sonarqube_token: 'SONARQUBE_TOKEN' + teamcity_user: 'TEAMCITY_USER' + teamcity_pass: 'TEAMCITY_PASS' twitch_client_id: 'TWITCH_CLIENT_ID' twitch_client_secret: 'TWITCH_CLIENT_SECRET' wheelmap_token: 'WHEELMAP_TOKEN' diff --git a/core/base-service/auth-helper.js b/core/base-service/auth-helper.js index bb61cd01d544ed69a25692efdeb71588371bebdf..6dedf1ba9e11e3bb864af4c7cd44cc52aa9fc72f 100644 --- a/core/base-service/auth-helper.js +++ b/core/base-service/auth-helper.js @@ -1,34 +1,68 @@ 'use strict' +const { URL } = require('url') +const { InvalidParameter } = require('./errors') + class AuthHelper { constructor( { userKey, passKey, + authorizedOrigins, + serviceKey, isRequired = false, defaultToEmptyStringForUser = false, }, - privateConfig + config ) { if (!userKey && !passKey) { throw Error('Expected userKey or passKey to be set') } + if (!authorizedOrigins && !serviceKey) { + throw Error('Expected authorizedOrigins or serviceKey to be set') + } + this._userKey = userKey this._passKey = passKey if (userKey) { - this.user = privateConfig[userKey] + this._user = config.private[userKey] } else { - this.user = defaultToEmptyStringForUser ? '' : undefined + this._user = defaultToEmptyStringForUser ? '' : undefined } - this.pass = passKey ? privateConfig[passKey] : undefined + this._pass = passKey ? config.private[passKey] : undefined this.isRequired = isRequired + + if (serviceKey !== undefined && !(serviceKey in config.public.services)) { + // Keep this as its own error, as it's useful to the programmer as they're + // getting auth set up. + throw Error(`Service key ${serviceKey} was missing from config schema`) + } + + let requireStrictSsl, requireStrictSslToAuthenticate + if (serviceKey === undefined) { + requireStrictSsl = true + requireStrictSslToAuthenticate = true + } else { + ;({ + authorizedOrigins, + requireStrictSsl = true, + requireStrictSslToAuthenticate = true, + } = config.public.services[serviceKey]) + } + if (!Array.isArray(authorizedOrigins)) { + throw Error('Expected authorizedOrigins to be an array of origins') + } + this._authorizedOrigins = authorizedOrigins + this._requireStrictSsl = requireStrictSsl + this._requireStrictSslToAuthenticate = requireStrictSslToAuthenticate } get isConfigured() { return ( - (this._userKey ? Boolean(this.user) : true) && - (this._passKey ? Boolean(this.pass) : true) + this._authorizedOrigins.length > 0 && + (this._userKey ? Boolean(this._user) : true) && + (this._passKey ? Boolean(this._pass) : true) ) } @@ -36,20 +70,135 @@ class AuthHelper { if (this.isRequired) { return this.isConfigured } else { - const configIsEmpty = !this.user && !this.pass + const configIsEmpty = !this._user && !this._pass return this.isConfigured || configIsEmpty } } - get basicAuth() { - const { user, pass } = this + static _isInsecureSslRequest({ options = {} }) { + const { strictSSL = true } = options + return strictSSL !== true + } + + enforceStrictSsl({ options = {} }) { + if ( + this._requireStrictSsl && + this.constructor._isInsecureSslRequest({ options }) + ) { + throw new InvalidParameter({ prettyMessage: 'strict ssl is required' }) + } + } + + shouldAuthenticateRequest({ url, options = {} }) { + let parsed + try { + parsed = new URL(url) + } catch (e) { + throw new InvalidParameter({ prettyMessage: 'invalid url parameter' }) + } + + const { protocol, host } = parsed + const origin = `${protocol}//${host}` + const originViolation = !this._authorizedOrigins.includes(origin) + + const strictSslCheckViolation = + this._requireStrictSslToAuthenticate && + this.constructor._isInsecureSslRequest({ options }) + + return this.isConfigured && !originViolation && !strictSslCheckViolation + } + + get _basicAuth() { + const { _user: user, _pass: pass } = this return this.isConfigured ? { user, pass } : undefined } - get bearerAuthHeader() { - const { pass } = this + /* + * Helper function for `withBasicAuth()` and friends. + */ + _withAnyAuth(requestParams, mergeAuthFn) { + this.enforceStrictSsl(requestParams) + + const shouldAuthenticate = this.shouldAuthenticateRequest(requestParams) + if (this.isRequired && !shouldAuthenticate) { + throw new InvalidParameter({ + prettyMessage: 'requested origin not authorized', + }) + } + + return shouldAuthenticate ? mergeAuthFn(requestParams) : requestParams + } + + static _mergeAuth(requestParams, auth) { + const { options, ...rest } = requestParams + return { + options: { + auth, + ...options, + }, + ...rest, + } + } + + withBasicAuth(requestParams) { + return this._withAnyAuth(requestParams, requestParams => + this.constructor._mergeAuth(requestParams, this._basicAuth) + ) + } + + get _bearerAuthHeader() { + const { _pass: pass } = this return this.isConfigured ? { Authorization: `Bearer ${pass}` } : undefined } + + static _mergeHeaders(requestParams, headers) { + const { + options: { headers: existingHeaders, ...restOptions } = {}, + ...rest + } = requestParams + return { + options: { + headers: { + ...existingHeaders, + ...headers, + }, + ...restOptions, + }, + ...rest, + } + } + + withBearerAuthHeader(requestParams) { + return this._withAnyAuth(requestParams, requestParams => + this.constructor._mergeHeaders(requestParams, this._bearerAuthHeader) + ) + } + + static _mergeQueryParams(requestParams, query) { + const { + options: { qs: existingQuery, ...restOptions } = {}, + ...rest + } = requestParams + return { + options: { + qs: { + ...existingQuery, + ...query, + }, + ...restOptions, + }, + ...rest, + } + } + + withQueryStringAuth({ userKey, passKey }, requestParams) { + return this._withAnyAuth(requestParams, requestParams => + this.constructor._mergeQueryParams(requestParams, { + ...(userKey ? { [userKey]: this._user } : undefined), + ...(passKey ? { [passKey]: this._pass } : undefined), + }) + ) + } } module.exports = { AuthHelper } diff --git a/core/base-service/auth-helper.spec.js b/core/base-service/auth-helper.spec.js index cd66066bcf0feba0e3a3476cb320bebd712a7b67..bec321dde1cf0c52445e02f0c19d3955bba1cf34 100644 --- a/core/base-service/auth-helper.spec.js +++ b/core/base-service/auth-helper.spec.js @@ -3,18 +3,42 @@ const { expect } = require('chai') const { test, given, forCases } = require('sazerac') const { AuthHelper } = require('./auth-helper') +const { InvalidParameter } = require('./errors') describe('AuthHelper', function() { - it('throws without userKey or passKey', function() { - expect(() => new AuthHelper({}, {})).to.throw( - Error, - 'Expected userKey or passKey to be set' - ) + describe('constructor checks', function() { + it('throws without userKey or passKey', function() { + expect(() => new AuthHelper({}, {})).to.throw( + Error, + 'Expected userKey or passKey to be set' + ) + }) + it('throws without serviceKey or authorizedOrigins', function() { + expect( + () => new AuthHelper({ userKey: 'myci_user', passKey: 'myci_pass' }, {}) + ).to.throw(Error, 'Expected authorizedOrigins or serviceKey to be set') + }) + it('throws when authorizedOrigins is not an array', function() { + expect( + () => + new AuthHelper( + { + userKey: 'myci_user', + passKey: 'myci_pass', + authorizedOrigins: true, + }, + { private: {} } + ) + ).to.throw(Error, 'Expected authorizedOrigins to be an array of origins') + }) }) describe('isValid', function() { function validate(config, privateConfig) { - return new AuthHelper(config, privateConfig).isValid + return new AuthHelper( + { authorizedOrigins: ['https://example.test'], ...config }, + { private: privateConfig } + ).isValid } test(validate, () => { forCases([ @@ -65,9 +89,12 @@ describe('AuthHelper', function() { }) }) - describe('basicAuth', function() { + describe('_basicAuth', function() { function validate(config, privateConfig) { - return new AuthHelper(config, privateConfig).basicAuth + return new AuthHelper( + { authorizedOrigins: ['https://example.test'], ...config }, + { private: privateConfig } + )._basicAuth } test(validate, () => { forCases([ @@ -100,4 +127,250 @@ describe('AuthHelper', function() { }) }) }) + + describe('_isInsecureSslRequest', function() { + test(AuthHelper._isInsecureSslRequest, () => { + forCases([ + given({ url: 'http://example.test' }), + given({ url: 'http://example.test', options: {} }), + given({ url: 'http://example.test', options: { strictSSL: true } }), + given({ + url: 'http://example.test', + options: { strictSSL: undefined }, + }), + ]).expect(false) + given({ + url: 'http://example.test', + options: { strictSSL: false }, + }).expect(true) + }) + }) + + describe('enforceStrictSsl', function() { + const authConfig = { + userKey: 'myci_user', + passKey: 'myci_pass', + serviceKey: 'myci', + } + + context('by default', function() { + const authHelper = new AuthHelper(authConfig, { + public: { + services: { myci: { authorizedOrigins: ['http://myci.test'] } }, + }, + private: { myci_user: 'admin', myci_pass: 'abc123' }, + }) + it('does not throw for secure requests', function() { + expect(() => authHelper.enforceStrictSsl({})).not.to.throw() + }) + it('throws for insecure requests', function() { + expect(() => + authHelper.enforceStrictSsl({ options: { strictSSL: false } }) + ).to.throw(InvalidParameter) + }) + }) + + context("when strict SSL isn't required", function() { + const authHelper = new AuthHelper(authConfig, { + public: { + services: { + myci: { + authorizedOrigins: ['http://myci.test'], + requireStrictSsl: false, + }, + }, + }, + private: { myci_user: 'admin', myci_pass: 'abc123' }, + }) + it('does not throw for secure requests', function() { + expect(() => authHelper.enforceStrictSsl({})).not.to.throw() + }) + it('does not throw for insecure requests', function() { + expect(() => + authHelper.enforceStrictSsl({ options: { strictSSL: false } }) + ).not.to.throw() + }) + }) + }) + + describe('shouldAuthenticateRequest', function() { + const authConfig = { + userKey: 'myci_user', + passKey: 'myci_pass', + serviceKey: 'myci', + } + + context('by default', function() { + const authHelper = new AuthHelper(authConfig, { + public: { + services: { + myci: { + authorizedOrigins: ['https://myci.test'], + }, + }, + }, + private: { myci_user: 'admin', myci_pass: 'abc123' }, + }) + const shouldAuthenticateRequest = requestOptions => + authHelper.shouldAuthenticateRequest(requestOptions) + describe('a secure request to an authorized origin', function() { + test(shouldAuthenticateRequest, () => { + given({ url: 'https://myci.test/api' }).expect(true) + }) + }) + describe('an insecure request', function() { + test(shouldAuthenticateRequest, () => { + given({ + url: 'https://myci.test/api', + options: { strictSSL: false }, + }).expect(false) + }) + }) + describe('a request to an unauthorized origin', function() { + test(shouldAuthenticateRequest, () => { + forCases([ + given({ url: 'http://myci.test/api' }), + given({ url: 'https://myci.test:12345/api' }), + given({ url: 'https://other.test/api' }), + ]).expect(false) + }) + }) + }) + + context('when auth over insecure SSL is allowed', function() { + const authHelper = new AuthHelper(authConfig, { + public: { + services: { + myci: { + authorizedOrigins: ['https://myci.test'], + requireStrictSslToAuthenticate: false, + }, + }, + }, + private: { myci_user: 'admin', myci_pass: 'abc123' }, + }) + const shouldAuthenticateRequest = requestOptions => + authHelper.shouldAuthenticateRequest(requestOptions) + describe('a secure request to an authorized origin', function() { + test(shouldAuthenticateRequest, () => { + given({ url: 'https://myci.test' }).expect(true) + }) + }) + describe('an insecure request', function() { + test(shouldAuthenticateRequest, () => { + given({ + url: 'https://myci.test', + options: { strictSSL: false }, + }).expect(true) + }) + }) + describe('a request to an unauthorized origin', function() { + test(shouldAuthenticateRequest, () => { + forCases([ + given({ url: 'http://myci.test' }), + given({ url: 'https://myci.test:12345/' }), + given({ url: 'https://other.test' }), + ]).expect(false) + }) + }) + }) + + context('when the service is partly configured', function() { + const authHelper = new AuthHelper(authConfig, { + public: { + services: { + myci: { + authorizedOrigins: ['https://myci.test'], + requireStrictSslToAuthenticate: false, + }, + }, + }, + private: { myci_user: 'admin' }, + }) + const shouldAuthenticateRequest = requestOptions => + authHelper.shouldAuthenticateRequest(requestOptions) + describe('a secure request to an authorized origin', function() { + test(shouldAuthenticateRequest, () => { + given({ url: 'https://myci.test' }).expect(false) + }) + }) + }) + }) + + describe('withBasicAuth', function() { + const authHelper = new AuthHelper( + { + userKey: 'myci_user', + passKey: 'myci_pass', + serviceKey: 'myci', + }, + { + public: { + services: { + myci: { + authorizedOrigins: ['https://myci.test'], + }, + }, + }, + private: { myci_user: 'admin', myci_pass: 'abc123' }, + } + ) + const withBasicAuth = requestOptions => + authHelper.withBasicAuth(requestOptions) + + describe('authenticates a secure request to an authorized origin', function() { + test(withBasicAuth, () => { + given({ + url: 'https://myci.test/api', + }).expect({ + url: 'https://myci.test/api', + options: { + auth: { user: 'admin', pass: 'abc123' }, + }, + }) + given({ + url: 'https://myci.test/api', + options: { + headers: { Accept: 'application/json' }, + }, + }).expect({ + url: 'https://myci.test/api', + options: { + headers: { Accept: 'application/json' }, + auth: { user: 'admin', pass: 'abc123' }, + }, + }) + }) + }) + + describe('does not authenticate a request to an unauthorized origin', function() { + test(withBasicAuth, () => { + given({ + url: 'https://other.test/api', + }).expect({ + url: 'https://other.test/api', + }) + given({ + url: 'https://other.test/api', + options: { + headers: { Accept: 'application/json' }, + }, + }).expect({ + url: 'https://other.test/api', + options: { + headers: { Accept: 'application/json' }, + }, + }) + }) + }) + + describe('throws on an insecure SSL request', function() { + expect(() => + withBasicAuth({ + url: 'https://myci.test/api', + options: { strictSSL: false }, + }) + ).to.throw(InvalidParameter) + }) + }) }) diff --git a/core/base-service/base.js b/core/base-service/base.js index 2d4dfdb644571acd29bee0f9af52a8060c876717..db671e1c1dd6405e407a0991e5440d576a7d2494 100644 --- a/core/base-service/base.js +++ b/core/base-service/base.js @@ -359,9 +359,7 @@ class BaseService { // Like the service instance, the auth helper could be reused for each request. // However, moving its instantiation to `register()` makes `invoke()` harder // to test. - const authHelper = this.auth - ? new AuthHelper(this.auth, config.private) - : undefined + const authHelper = this.auth ? new AuthHelper(this.auth, config) : undefined const serviceInstance = new this({ ...context, authHelper }, config) diff --git a/core/base-service/base.spec.js b/core/base-service/base.spec.js index 06d9cfe0d3f66e4a53ef3d6974fe7777b9faa2f7..ae4879630819c355f2abb8591aeaecc6905032ca 100644 --- a/core/base-service/base.spec.js +++ b/core/base-service/base.spec.js @@ -74,7 +74,13 @@ class DummyServiceWithServiceResponseSizeMetricEnabled extends DummyService { } describe('BaseService', function() { - const defaultConfig = { handleInternalErrors: false, private: {} } + const defaultConfig = { + public: { + handleInternalErrors: false, + services: {}, + }, + private: {}, + } it('Invokes the handler as expected', async function() { expect( @@ -564,13 +570,14 @@ describe('BaseService', function() { static get auth() { return { passKey: 'myci_pass', + serviceKey: 'myci', isRequired: true, } } async handle() { return { - message: `The CI password is ${this.authHelper.pass}`, + message: `The CI password is ${this.authHelper._pass}`, } } } @@ -579,7 +586,13 @@ describe('BaseService', function() { expect( await AuthService.invoke( {}, - { defaultConfig, private: { myci_pass: 'abc123' } }, + { + public: { + ...defaultConfig.public, + services: { myci: { authorizedOrigins: ['https://myci.test'] } }, + }, + private: { myci_pass: 'abc123' }, + }, { namedParamA: 'bar.bar.bar' } ) ).to.deep.equal({ message: 'The CI password is abc123' }) @@ -587,9 +600,19 @@ describe('BaseService', function() { it('when auth is not configured properly, invoke() returns inacessible', async function() { expect( - await AuthService.invoke({}, defaultConfig, { - namedParamA: 'bar.bar.bar', - }) + await AuthService.invoke( + {}, + { + public: { + ...defaultConfig.public, + services: { myci: { authorizedOrigins: ['https://myci.test'] } }, + }, + private: {}, + }, + { + namedParamA: 'bar.bar.bar', + } + ) ).to.deep.equal({ color: 'lightgray', isError: true, diff --git a/core/base-service/legacy-request-handler.js b/core/base-service/legacy-request-handler.js index 91e2d394d774f805495c195534194ea48a735f61..f14ff236a72e2a6ef6be45d3634706d45237e26a 100644 --- a/core/base-service/legacy-request-handler.js +++ b/core/base-service/legacy-request-handler.js @@ -13,6 +13,8 @@ const { makeSend } = require('./legacy-result-sender') const LruCache = require('./lru-cache') const coalesceBadge = require('./coalesce-badge') +const userAgent = 'Shields.io/2003a' + // We avoid calling the vendor's server for computation of the information in a // number of badges. const minAccuracy = 0.75 @@ -204,8 +206,7 @@ function handleRequest(cacheHeaderConfig, handlerOptions) { options = uri } options.headers = options.headers || {} - options.headers['User-Agent'] = - options.headers['User-Agent'] || 'Shields.io' + options.headers['User-Agent'] = userAgent let bufferLength = 0 const r = request(options, (err, res, body) => { @@ -294,4 +295,5 @@ module.exports = { clearRequestCache, // Expose for testing. _requestCache: requestCache, + userAgent, } diff --git a/core/server/server.js b/core/server/server.js index b87dbd6ddb11b88a488a1e2e4b64d7fd672f8f89..b4843e420bd2bea0d610903759dbeb14779362f3 100644 --- a/core/server/server.js +++ b/core/server/server.js @@ -5,9 +5,10 @@ const path = require('path') const url = require('url') +const { URL } = url const bytes = require('bytes') -const Joi = require('@hapi/joi') const Camp = require('camp') +const originalJoi = require('@hapi/joi') const makeBadge = require('../../gh-badges/lib/make-badge') const GithubConstellation = require('../../services/github/github-constellation') const suggest = require('../../services/suggest') @@ -23,8 +24,44 @@ const log = require('./log') const sysMonitor = require('./monitor') const PrometheusMetrics = require('./prometheus-metrics') +const Joi = originalJoi + .extend(base => ({ + type: 'arrayFromString', + base: base.array(), + coerce: (value, state, options) => ({ + value: typeof value === 'string' ? value.split(' ') : value, + }), + })) + .extend(base => ({ + type: 'string', + base: base.string(), + messages: { + 'string.origin': + 'needs to be an origin string, e.g. https://host.domain with optional port and no trailing slash', + }, + rules: { + origin: { + validate(value, helpers) { + let origin + try { + ;({ origin } = new URL(value)) + } catch (e) {} + if (origin !== undefined && origin === value) { + return value + } else { + return helpers.error('string.origin') + } + }, + }, + }, + })) + const optionalUrl = Joi.string().uri({ scheme: ['http', 'https'] }) const requiredUrl = optionalUrl.required() +const origins = Joi.arrayFromString().items(Joi.string().origin()) +const defaultService = Joi.object({ authorizedOrigins: origins }).default({ + authorizedOrigins: [], +}) const publicConfigSchema = Joi.object({ bind: { @@ -61,7 +98,9 @@ const publicConfigSchema = Joi.object({ persistence: { dir: Joi.string().required(), }, - services: { + services: Joi.object({ + bitbucketServer: defaultService, + drone: defaultService, github: { baseUri: requiredUrl, debug: { @@ -72,8 +111,18 @@ const publicConfigSchema = Joi.object({ .required(), }, }, + jira: defaultService, + jenkins: Joi.object({ + authorizedOrigins: origins, + requireStrictSsl: Joi.boolean(), + requireStrictSslToAuthenticate: Joi.boolean(), + }).default({ authorizedOrigins: [] }), + nexus: defaultService, + npm: defaultService, + sonar: defaultService, + teamcity: defaultService, trace: Joi.boolean().required(), - }, + }).required(), profiling: { makeBadge: Joi.boolean().required(), }, @@ -296,6 +345,7 @@ class Server { fetchLimitBytes: bytes(config.public.fetchLimit), rasterUrl: config.public.rasterUrl, private: config.private, + public: config.public, } ) ) diff --git a/core/server/server.spec.js b/core/server/server.spec.js index 7150b7e36d8e50fa2f6900312eed3d3e678fcb29..177b12f5c7ee229c815bb65ed720584aad83f415 100644 --- a/core/server/server.spec.js +++ b/core/server/server.spec.js @@ -65,7 +65,7 @@ describe('The server', function() { it('should produce json badges', async function() { const { statusCode, body, headers } = await got( - `${baseUrl}npm/v/express.json` + `${baseUrl}twitter/follow/_Pyves.json` ) expect(statusCode).to.equal(200) expect(headers['content-type']).to.equal('application/json') diff --git a/doc/server-secrets.md b/doc/server-secrets.md index aeffa08f79ef43e8f9a48d33699830c5eccf74c7..1f75a12d027de4e13db7d2e0f175a96a0be91077 100644 --- a/doc/server-secrets.md +++ b/doc/server-secrets.md @@ -10,14 +10,19 @@ There are two ways of setting secrets: environment. ```sh -GH_TOKEN=... +DRONE_TOKEN=... +DRONE_ORIGINS="https://drone.example.com" ``` 2. Via checked-in `config/local.yml`: ```yml +public: + services: + drone: + authorizedOrigins: ['https://drone.example.com'] private: - gh_token: '...' + drone_token: '...' ``` For more complex scenarios, configuration files can cascade. See the [node-config documentation][] @@ -25,9 +30,43 @@ for details. [node-config documentation]: https://github.com/lorenwest/node-config/wiki/Configuration-Files -## Azure DevOps +## Authorized origins -- `AZURE_DEVOPS_TOKEN` (yml: `azure_devops_token`) +Several of the badges provided by Shields allow users to specify the target +URL/server of the upstream instance to use via a query parameter in the badge URL +(e.g. https://img.shields.io/nexus/s/com.google.guava/guava?server=https%3A%2F%2Foss.sonatype.org). +This supports scenarios where your users may need badges from multiple upstream +targets, for example if you have more than one Nexus server. + +Accordingly, if you configure credentials for one of these services with your +self-hosted Shields instance, you must also specifically authorize the hosts +to which the credentials are allowed to be sent. If your self-hosted Shields +instance then receives a badge request for a target that does not match any +of the authorized origins, one of two things will happen: + +- if credentials are required for the targeted service, Shields will render + an error badge. +- if credentials are optional for the targeted service, Shields will attempt + the request, but without sending any credentials. + +When setting authorized origins through an environment variable, use a space +to separate multiple origins. Note that failing to define authorized origins +for a service will default to an empty list, i.e. no authorized origins. + +It is highly recommended to use `https` origins with valid SSL, to avoid the +possibility of exposing your credentials, for example through DNS-based attacks. + +It is also recommended to use tokens for a service account having +[the fewest privileges needed][polp] for fetching the relevant status +information. + +[polp]: https://en.wikipedia.org/wiki/Principle_of_least_privilege + +## Services + +### Azure DevOps + +- `AZURE_DEVOPS_TOKEN` (yml: `private.azure_devops_token`) An Azure DevOps Token (PAT) is required for accessing [private Azure DevOps projects][ado project visibility]. @@ -41,24 +80,45 @@ An Azure DevOps Token (PAT) is required for accessing [private Azure DevOps proj [ado personal access tokens]: https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=vsts#create-personal-access-tokens-to-authenticate-access [ado token scopes]: https://docs.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/oauth?view=vsts#scopes -## Bintray +### Bintray -- `BINTRAY_USER` (yml: `bintray_user`) -- `BINTRAY_API_KEY` (yml: `bintray_apikey`) +- `BINTRAY_USER` (yml: `private.bintray_user`) +- `BINTRAY_API_KEY` (yml: `private.bintray_apikey`) The bintray API [requires authentication](https://bintray.com/docs/api/#_authentication) Create an account and obtain a token from the user profile page. -## Drone +### Bitbucket (Cloud) + +- `BITBUCKET_USER` (yml: `private.bitbucket_username`) +- `BITBUCKET_PASS` (yml: `private.bitbucket_password`) + +Bitbucket badges use basic auth. Provide a username and password to give your +self-hosted Shields installation access to private repositories hosted on bitbucket.org. + +### Bitbucket Server + +- `BITBUCKET_SERVER_ORIGINS` (yml: `public.services.bitbucketServer.authorizedOrigins`) +- `BITBUCKET_SERVER_USER` (yml: `private.bitbucket_server_username`) +- `BITBUCKET_SERVER_PASS` (yml: `private.bitbucket_server_password`) + +Bitbucket badges use basic auth. Provide a username and password to give your +self-hosted Shields installation access to a private Bitbucket Server instance. + +### Drone -- `DRONE_TOKEN` (yml: `drone_token`) +- `DRONE_ORIGINS` (yml: `public.services.drone.authorizedOrigins`) +- `DRONE_TOKEN` (yml: `private.drone_token`) -The self-hosted Drone API [requires authentication](https://0-8-0.docs.drone.io/api-authentication/) -Login to your Drone instance and obtain a token from the user profile page. +The self-hosted Drone API [requires authentication][drone auth]. Log in to your +Drone instance and obtain a token from the user profile page. -## GitHub +[drone auth]: https://0-8-0.docs.drone.io/api-authentication/ -- `GH_TOKEN` (yml: `gh_token`) +### GitHub + +- `GITHUB_URL` (yml: `public.services.github.baseUri`) +- `GH_TOKEN` (yml: `private.gh_token`) Because of Github rate limits, you will need to provide a token, or else badges will stop working once you hit 60 requests per hour, the @@ -72,76 +132,85 @@ will have access to your private repositories. When a `gh_token` is specified, it is used in place of the Shields token rotation logic. +`GITHUB_URL` can be used to optionally send all the GitHub requests to a +GitHub Enterprise server. This can be done in conjunction with setting a +token, though it's not required. + [github rate limit]: https://developer.github.com/v3/#rate-limiting [personal access tokens]: https://github.com/settings/tokens -- `GH_CLIENT_ID` (yml: `gh_client_id`) -- `GH_CLIENT_SECRET` (yml: `gh_client_secret`) +- `GH_CLIENT_ID` (yml: `private.gh_client_id`) +- `GH_CLIENT_SECRET` (yml: `private.gh_client_secret`) These settings are used by shields.io for GitHub OAuth app authorization but will not be necessary for most self-hosted installations. See [production-hosting.md](./production-hosting.md). -## Jenkins CI +### Jenkins CI -- `JENKINS_USER` (yml: `jenkins_user`) -- `JENKINS_PASS` (yml: `jenkins_pass`) +- `JENKINS_ORIGINS` (yml: `public.services.jenkins.authorizedOrigins`) +- `JENKINS_USER` (yml: `private.jenkins_user`) +- `JENKINS_PASS` (yml: `private.jenkins_pass`) Provide a username and password to give your self-hosted Shields installation access to a private Jenkins CI instance. -## JIRA +### Jira -- `JIRA_USER` (yml: `jira_user`) -- `JIRA_PASS` (yml: `jira_pass`) +- `JIRA_ORIGINS` (yml: `public.services.jira.authorizedOrigins`) +- `JIRA_USER` (yml: `private.jira_user`) +- `JIRA_PASS` (yml: `private.jira_pass`) Provide a username and password to give your self-hosted Shields installation access to a private JIRA instance. -## Nexus +### Nexus -- `NEXUS_USER` (yml: `nexus_user`) -- `NEXUS_PASS` (yml: `nexus_pass`) +- `NEXUS_ORIGINS` (yml: `public.services.nexus.authorizedOrigins`) +- `NEXUS_USER` (yml: `private.nexus_user`) +- `NEXUS_PASS` (yml: `private.nexus_pass`) Provide a username and password to give your self-hosted Shields installation access to your private nexus repositories. -## NPM +### npm -- `NPM_TOKEN` (yml: `npm_token`) +- `NPM_ORIGINS` (yml: `public.services.npm.authorizedOrigins`) +- `NPM_TOKEN` (yml: `private.npm_token`) [Generate an npm token][npm token] to give your self-hosted Shields installation access to private npm packages [npm token]: https://docs.npmjs.com/getting-started/working_with_tokens -## Sentry - -- `SENTRY_DSN` (yml: `sentry_dsn`) - -A [Sentry DSN](https://docs.sentry.io/error-reporting/quickstart/?platform=javascript#configure-the-dsn) -may be used to send error reports from your installation to -[Sentry.io](http://sentry.io/). For more info, see the -[self hosting docs](https://github.com/badges/shields/blob/master/doc/self-hosting.md#sentry). +### SymfonyInsight (formerly Sensiolabs) -## SymfonyInsight (formerly Sensiolabs) - -- `SL_INSIGHT_USER_UUID` (yml: `sl_insight_userUuid`) -- `SL_INSIGHT_API_TOKEN` (yml: `sl_insight_apiToken`) +- `SL_INSIGHT_USER_UUID` (yml: `private.sl_insight_userUuid`) +- `SL_INSIGHT_API_TOKEN` (yml: `private.sl_insight_apiToken`) The SymfonyInsight API requires authentication. To obtain a token, Create an account, sign in and obtain a uuid and token from your [account page](https://insight.sensiolabs.com/account). -## SonarQube +### SonarQube -- `SONARQUBE_TOKEN` (yml: `sonarqube_token`) +- `SONAR_ORIGINS` (yml: `public.services.sonar.authorizedOrigins`) +- `SONARQUBE_TOKEN` (yml: `private.sonarqube_token`) [Generate a token](https://docs.sonarqube.org/latest/user-guide/user-token/) to give your self-hosted Shields installation access to a private SonarQube instance or private project on a public instance. -## Twitch +### TeamCity + +- `TEAMCITY_ORIGINS` (yml: `public.services.teamcity.authorizedOrigins`) +- `TEAMCITY_USER` (yml: `private.teamcity_user`) +- `TEAMCITY_PASS` (yml: `private.teamcity_pass`) + +Provide a username and password to give your self-hosted Shields installation +access to your private nexus repositories. + +### Twitch - `TWITCH_CLIENT_ID` (yml: `twitch_client_id`) - `TWITCH_CLIENT_SECRET` (yml: `twitch_client_secret`) @@ -149,12 +218,23 @@ private SonarQube instance or private project on a public instance. Register an application in the [Twitch developer console](https://dev.twitch.tv/console) in order to obtain a client id and a client secret for making Twitch API calls. -## Wheelmap +### Wheelmap -- `WHEELMAP_TOKEN` (yml: `wheelmap_token`) +- `WHEELMAP_TOKEN` (yml: `private.wheelmap_token`) The wheelmap API requires authentication. To obtain a token, Create an account, [sign in][wheelmap token] and use the _Authentication Token_ displayed on your profile page. [wheelmap token]: http://classic.wheelmap.org/en/users/sign_in + +## Error reporting + +- `SENTRY_DSN` (yml: `private.sentry_dsn`) + +A [Sentry DSN][] may be used to send error reports from your installation to +[Sentry.io][]. For more info, see the [self hosting docs][]. + +[sentry dsn]: https://docs.sentry.io/error-reporting/quickstart/?platform=javascript#configure-the-dsn +[sentry.io]: http://sentry.io/ +[self hosting docs]: https://github.com/badges/shields/blob/master/doc/self-hosting.md#sentry diff --git a/services/azure-devops/azure-devops-base.js b/services/azure-devops/azure-devops-base.js index 396698181f1e5a745100619cdf71cb6effd6590e..904db4c28bea74963138d5838bd02e070affda14 100644 --- a/services/azure-devops/azure-devops-base.js +++ b/services/azure-devops/azure-devops-base.js @@ -18,17 +18,20 @@ module.exports = class AzureDevOpsBase extends BaseJsonService { static get auth() { return { passKey: 'azure_devops_token', + authorizedOrigins: ['https://dev.azure.com'], defaultToEmptyStringForUser: true, } } async fetch({ url, options, schema, errorMessages }) { - return this._requestJson({ - schema, - url, - options, - errorMessages, - }) + return this._requestJson( + this.authHelper.withBasicAuth({ + schema, + url, + options, + errorMessages, + }) + ) } async getLatestCompletedBuildId( @@ -36,7 +39,6 @@ module.exports = class AzureDevOpsBase extends BaseJsonService { project, definitionId, branch, - auth, errorMessages ) { // Microsoft documentation: https://docs.microsoft.com/en-us/rest/api/azure/devops/build/builds/list?view=azure-devops-rest-5.0 @@ -48,7 +50,6 @@ module.exports = class AzureDevOpsBase extends BaseJsonService { statusFilter: 'completed', 'api-version': '5.0-preview.4', }, - auth, } if (branch) { diff --git a/services/azure-devops/azure-devops-coverage.service.js b/services/azure-devops/azure-devops-coverage.service.js index c084b5c6fe2323428b30cc871840811be52615a8..97cc0596bec6ac1cb6a45c1960b6fb882f038a1b 100644 --- a/services/azure-devops/azure-devops-coverage.service.js +++ b/services/azure-devops/azure-devops-coverage.service.js @@ -100,7 +100,6 @@ module.exports = class AzureDevOpsCoverage extends AzureDevOpsBase { } async handle({ organization, project, definitionId, branch }) { - const auth = this.authHelper.basicAuth const errorMessages = { 404: 'build pipeline or coverage not found', } @@ -109,7 +108,6 @@ module.exports = class AzureDevOpsCoverage extends AzureDevOpsBase { project, definitionId, branch, - auth, errorMessages ) // Microsoft documentation: https://docs.microsoft.com/en-us/rest/api/azure/devops/test/code%20coverage/get%20build%20code%20coverage?view=azure-devops-rest-5.0 @@ -119,7 +117,6 @@ module.exports = class AzureDevOpsCoverage extends AzureDevOpsBase { buildId, 'api-version': '5.0-preview.1', }, - auth, } const json = await this.fetch({ url, diff --git a/services/azure-devops/azure-devops-tests.service.js b/services/azure-devops/azure-devops-tests.service.js index e0ee802cb296e060e3ba63a7c077d65196aa8782..6a4471b9b1d5c97c22e1ca110b122f4ea19d53b8 100644 --- a/services/azure-devops/azure-devops-tests.service.js +++ b/services/azure-devops/azure-devops-tests.service.js @@ -191,7 +191,6 @@ module.exports = class AzureDevOpsTests extends AzureDevOpsBase { skipped_label: skippedLabel, } ) { - const auth = this.authHelper.basicAuth const errorMessages = { 404: 'build pipeline or test result summary not found', } @@ -200,7 +199,6 @@ module.exports = class AzureDevOpsTests extends AzureDevOpsBase { project, definitionId, branch, - auth, errorMessages ) @@ -210,7 +208,6 @@ module.exports = class AzureDevOpsTests extends AzureDevOpsBase { url: `https://dev.azure.com/${organization}/${project}/_apis/test/ResultSummaryByBuild`, options: { qs: { buildId }, - auth, }, schema: buildTestResultSummarySchema, errorMessages, diff --git a/services/bintray/bintray.service.js b/services/bintray/bintray.service.js index a32cc34de40d3a24c050a50fa286625551f8da09..fc5c97b5cbd06d178ff019b33c308358c2b4155a 100644 --- a/services/bintray/bintray.service.js +++ b/services/bintray/bintray.service.js @@ -23,7 +23,11 @@ module.exports = class Bintray extends BaseJsonService { } static get auth() { - return { userKey: 'bintray_user', passKey: 'bintray_apikey' } + return { + userKey: 'bintray_user', + passKey: 'bintray_apikey', + authorizedOrigins: ['https://bintray.com'], + } } static get examples() { @@ -46,11 +50,12 @@ module.exports = class Bintray extends BaseJsonService { async fetch({ subject, repo, packageName }) { // https://bintray.com/docs/api/#_get_version - return this._requestJson({ - schema, - url: `https://bintray.com/api/v1/packages/${subject}/${repo}/${packageName}/versions/_latest`, - options: { auth: this.authHelper.basicAuth }, - }) + return this._requestJson( + this.authHelper.withBasicAuth({ + schema, + url: `https://bintray.com/api/v1/packages/${subject}/${repo}/${packageName}/versions/_latest`, + }) + ) } async handle({ subject, repo, packageName }) { diff --git a/services/bintray/bintray.spec.js b/services/bintray/bintray.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..2bb79cd71a84fea8b503d6fcf5d59492fefcff9d --- /dev/null +++ b/services/bintray/bintray.spec.js @@ -0,0 +1,46 @@ +'use strict' + +const { expect } = require('chai') +const nock = require('nock') +const { cleanUpNockAfterEach, defaultContext } = require('../test-helpers') +const Bintray = require('./bintray.service') + +describe('Bintray', function() { + describe('auth', function() { + cleanUpNockAfterEach() + + const user = 'admin' + const pass = 'password' + const config = { + private: { + bintray_user: user, + bintray_apikey: pass, + }, + } + + it('sends the auth information as configured', async function() { + const scope = nock('https://bintray.com') + .get('/api/v1/packages/asciidoctor/maven/asciidoctorj/versions/_latest') + // This ensures that the expected credentials are actually being sent with the HTTP request. + // Without this the request wouldn't match and the test would fail. + .basicAuth({ user, pass }) + .reply(200, { + name: '1.5.7', + }) + + expect( + await Bintray.invoke(defaultContext, config, { + subject: 'asciidoctor', + repo: 'maven', + packageName: 'asciidoctorj', + }) + ).to.deep.equal({ + label: undefined, + message: 'v1.5.7', + color: 'blue', + }) + + scope.done() + }) + }) +}) diff --git a/services/bitbucket/bitbucket-pull-request.service.js b/services/bitbucket/bitbucket-pull-request.service.js index 1061984ac67cec194d6dc2b9de6b12042b2e16c6..9f932aeb6edfc70fe242e9b1e11cf065d9e4091d 100644 --- a/services/bitbucket/bitbucket-pull-request.service.js +++ b/services/bitbucket/bitbucket-pull-request.service.js @@ -81,46 +81,48 @@ function pullRequestClassGenerator(raw) { { userKey: 'bitbucket_username', passKey: 'bitbucket_password', + authorizedOrigins: ['https://bitbucket.org'], }, - config.private + config ) this.bitbucketServerAuthHelper = new AuthHelper( { userKey: 'bitbucket_server_username', passKey: 'bitbucket_server_password', + serviceKey: 'bitbucketServer', }, - config.private + config ) } async fetchCloud({ user, repo }) { - return this._requestJson({ - url: `https://bitbucket.org/api/2.0/repositories/${user}/${repo}/pullrequests/`, - schema, - options: { - qs: { state: 'OPEN', limit: 0 }, - auth: this.bitbucketAuthHelper.basicAuth, - }, - errorMessages, - }) + return this._requestJson( + this.bitbucketAuthHelper.withBasicAuth({ + url: `https://bitbucket.org/api/2.0/repositories/${user}/${repo}/pullrequests/`, + schema, + options: { qs: { state: 'OPEN', limit: 0 } }, + errorMessages, + }) + ) } // https://docs.atlassian.com/bitbucket-server/rest/5.16.0/bitbucket-rest.html#idm46229602363312 async fetchServer({ server, user, repo }) { - return this._requestJson({ - url: `${server}/rest/api/1.0/projects/${user}/repos/${repo}/pull-requests`, - schema, - options: { - qs: { - state: 'OPEN', - limit: 100, - withProperties: false, - withAttributes: false, + return this._requestJson( + this.bitbucketServerAuthHelper.withBasicAuth({ + url: `${server}/rest/api/1.0/projects/${user}/repos/${repo}/pull-requests`, + schema, + options: { + qs: { + state: 'OPEN', + limit: 100, + withProperties: false, + withAttributes: false, + }, }, - auth: this.bitbucketServerAuthHelper.basicAuth, - }, - errorMessages, - }) + errorMessages, + }) + ) } async fetch({ server, user, repo }) { diff --git a/services/bitbucket/bitbucket-pull-request.spec.js b/services/bitbucket/bitbucket-pull-request.spec.js index 3cf8aecf05da6d34943f774ff780ea42bd49fdc7..fce1ed1546b0ff38473a47e01d50a74428678c23 100644 --- a/services/bitbucket/bitbucket-pull-request.spec.js +++ b/services/bitbucket/bitbucket-pull-request.spec.js @@ -21,6 +21,13 @@ describe('BitbucketPullRequest', function() { await BitbucketPullRequest.invoke( defaultContext, { + public: { + services: { + bitbucketServer: { + authorizedOrigins: [], + }, + }, + }, private: { bitbucket_username: user, bitbucket_password: pass }, }, { user: 'atlassian', repo: 'python-bitbucket' } @@ -43,6 +50,13 @@ describe('BitbucketPullRequest', function() { await BitbucketPullRequest.invoke( defaultContext, { + public: { + services: { + bitbucketServer: { + authorizedOrigins: ['https://bitbucket.example.test'], + }, + }, + }, private: { bitbucket_server_username: user, bitbucket_server_password: pass, diff --git a/services/drone/drone-build.service.js b/services/drone/drone-build.service.js index 85b09f84fbf3e8814c5b55968384ec13705a5d97..b89fe14a50bff627caff56ca9e851ff5b712fac8 100644 --- a/services/drone/drone-build.service.js +++ b/services/drone/drone-build.service.js @@ -5,7 +5,7 @@ const { isBuildStatus, renderBuildStatusBadge } = require('../build-status') const { optionalUrl } = require('../validators') const { BaseJsonService } = require('..') -const DroneBuildSchema = Joi.object({ +const schema = Joi.object({ status: Joi.alternatives() .try(isBuildStatus, Joi.equal('none'), Joi.equal('killed')) .required(), @@ -29,7 +29,7 @@ module.exports = class DroneBuild extends BaseJsonService { } static get auth() { - return { passKey: 'drone_token' } + return { passKey: 'drone_token', serviceKey: 'drone' } } static get examples() { @@ -83,24 +83,19 @@ module.exports = class DroneBuild extends BaseJsonService { } } - async handle({ user, repo, branch }, { server }) { - const options = { - qs: { - ref: branch ? `refs/heads/${branch}` : undefined, - }, - headers: this.authHelper.bearerAuthHeader, - } - if (!server) { - server = 'https://cloud.drone.io' - } - const json = await this._requestJson({ - options, - schema: DroneBuildSchema, - url: `${server}/api/repos/${user}/${repo}/builds/latest`, - errorMessages: { - 401: 'repo not found or not authorized', - }, - }) + async handle({ user, repo, branch }, { server = 'https://cloud.drone.io' }) { + const json = await this._requestJson( + this.authHelper.withBearerAuthHeader({ + schema, + url: `${server}/api/repos/${user}/${repo}/builds/latest`, + options: { + qs: { ref: branch ? `refs/heads/${branch}` : undefined }, + }, + errorMessages: { + 401: 'repo not found or not authorized', + }, + }) + ) return renderBuildStatusBadge({ status: json.status }) } } diff --git a/services/drone/drone-build.spec.js b/services/drone/drone-build.spec.js index 41537d050ab730e18e6627fa0f60ee6522636160..06837dbad1f287d7eeba14f08c57726d17da17c9 100644 --- a/services/drone/drone-build.spec.js +++ b/services/drone/drone-build.spec.js @@ -21,7 +21,16 @@ describe('DroneBuild', function() { await DroneBuild.invoke( defaultContext, { - private: { drone_token: token }, + public: { + services: { + drone: { + authorizedOrigins: ['https://cloud.drone.io'], + }, + }, + }, + private: { + drone_token: token, + }, }, { user: 'atlassian', repo: 'python-bitbucket' } ) diff --git a/services/github/auth/acceptor.js b/services/github/auth/acceptor.js index b2a9d93e2add6c1d23609dc50c38026955eec84c..9b5ae42f3376244f95960d5bf4cc6f271a8d6c34 100644 --- a/services/github/auth/acceptor.js +++ b/services/github/auth/acceptor.js @@ -2,6 +2,9 @@ const queryString = require('query-string') const request = require('request') +const { + userAgent, +} = require('../../../core/base-service/legacy-request-handler') const log = require('../../../core/server/log') const secretIsValid = require('../../../core/server/secret-is-valid') const serverSecrets = require('../../../lib/server-secrets') @@ -50,7 +53,11 @@ function setRoutes({ server, authHelper, onTokenAccepted }) { server.route(/^\/github-auth$/, (data, match, end, ask) => { ask.res.statusCode = 302 // Found. const query = queryString.stringify({ - client_id: authHelper.user, + // TODO The `_user` property bypasses security checks in AuthHelper. + // (e.g: enforceStrictSsl and shouldAuthenticateRequest). + // Do not use it elsewhere. It would be better to clean this up so + // it's not setting a bad example. + client_id: authHelper._user, redirect_uri: `${baseUrl}/github-auth/done`, }) ask.res.setHeader( @@ -71,11 +78,15 @@ function setRoutes({ server, authHelper, onTokenAccepted }) { method: 'POST', headers: { 'Content-type': 'application/x-www-form-urlencoded;charset=UTF-8', - 'User-Agent': 'Shields.io', + 'User-Agent': userAgent, }, form: queryString.stringify({ - client_id: authHelper.user, - client_secret: authHelper.pass, + // TODO The `_user` and `_pass` properties bypass security checks in + // AuthHelper (e.g: enforceStrictSsl and shouldAuthenticateRequest). + // Do not use them elsewhere. It would be better to clean + // this up so it's not setting a bad example. + client_id: authHelper._user, + client_secret: authHelper._pass, code: data.code, }), } diff --git a/services/github/auth/acceptor.spec.js b/services/github/auth/acceptor.spec.js index b9917e69874e8d1934b85c04ee8ac5ff5b250138..844f76eed7fe80301bbfebed07c6863128069035 100644 --- a/services/github/auth/acceptor.spec.js +++ b/services/github/auth/acceptor.spec.js @@ -16,7 +16,7 @@ const fakeShieldsSecret = 'letmeinplz' describe('Github token acceptor', function() { const oauthHelper = GithubConstellation._createOauthHelper({ - gh_client_id: fakeClientId, + private: { gh_client_id: fakeClientId }, }) before(function() { // Make sure properties exist. diff --git a/services/github/github-api-provider.js b/services/github/github-api-provider.js index b4be59a634f1fb6376c2490861ad7413734df272..632965bddaf01cc60549363f8f07274e115ddf96 100644 --- a/services/github/github-api-provider.js +++ b/services/github/github-api-provider.js @@ -3,6 +3,7 @@ const Joi = require('@hapi/joi') const log = require('../../core/server/log') const { TokenPool } = require('../../core/token-pooling/token-pool') +const { userAgent } = require('../../core/base-service/legacy-request-handler') const { nonNegativeInteger } = require('../validators') const headerSchema = Joi.object({ @@ -184,7 +185,7 @@ class GithubApiProvider { url, baseUrl, headers: { - 'User-Agent': 'Shields.io', + 'User-Agent': userAgent, Accept: 'application/vnd.github.v3+json', Authorization: `token ${tokenString}`, }, diff --git a/services/github/github-constellation.js b/services/github/github-constellation.js index f914ea02bb51966fa3086134354bb00b51272d78..a9754bc6bf4262db6b288244fd31a5555efae787 100644 --- a/services/github/github-constellation.js +++ b/services/github/github-constellation.js @@ -13,14 +13,15 @@ const { setRoutes: setAcceptorRoutes } = require('./auth/acceptor') // Convenience class with all the stuff related to the Github API and its // authorization tokens, to simplify server initialization. class GithubConstellation { - static _createOauthHelper(privateConfig) { + static _createOauthHelper(config) { return new AuthHelper( { userKey: 'gh_client_id', passKey: 'gh_client_secret', + authorizedOrigins: ['https://api.github.com'], isRequired: true, }, - privateConfig + config ) } @@ -54,7 +55,7 @@ class GithubConstellation { onTokenInvalidated: tokenString => this.onTokenInvalidated(tokenString), }) - this.oauthHelper = this.constructor._createOauthHelper(config.private) + this.oauthHelper = this.constructor._createOauthHelper(config) } scheduleDebugLogging() { diff --git a/services/jenkins/jenkins-base.js b/services/jenkins/jenkins-base.js index 58c9e682c987ae11d9df76e39a8cfbc38a747c3c..c2d1e5b3b0c36c92bd42011da5e4994b38f29d49 100644 --- a/services/jenkins/jenkins-base.js +++ b/services/jenkins/jenkins-base.js @@ -7,6 +7,7 @@ module.exports = class JenkinsBase extends BaseJsonService { return { userKey: 'jenkins_user', passKey: 'jenkins_pass', + serviceKey: 'jenkins', } } @@ -17,15 +18,16 @@ module.exports = class JenkinsBase extends BaseJsonService { errorMessages = { 404: 'instance or job not found' }, disableStrictSSL, }) { - return this._requestJson({ - url, - options: { - qs, - strictSSL: disableStrictSSL === undefined, - auth: this.authHelper.basicAuth, - }, - schema, - errorMessages, - }) + return this._requestJson( + this.authHelper.withBasicAuth({ + url, + options: { + qs, + strictSSL: disableStrictSSL === undefined, + }, + schema, + errorMessages, + }) + ) } } diff --git a/services/jenkins/jenkins-build.spec.js b/services/jenkins/jenkins-build.spec.js index 225290c94b455c66c23a99d77b475c75c02b5426..411436a8a671c83f4196c3bd1cc980c52cb37cbb 100644 --- a/services/jenkins/jenkins-build.spec.js +++ b/services/jenkins/jenkins-build.spec.js @@ -63,7 +63,19 @@ describe('JenkinsBuild', function() { const user = 'admin' const pass = 'password' - const config = { private: { jenkins_user: user, jenkins_pass: pass } } + const config = { + public: { + services: { + jenkins: { + authorizedOrigins: ['https://jenkins.ubuntu.com'], + }, + }, + }, + private: { + jenkins_user: user, + jenkins_pass: pass, + }, + } it('sends the auth information as configured', async function() { const scope = nock('https://jenkins.ubuntu.com') diff --git a/services/jira/jira-common.js b/services/jira/jira-common.js index 9f51ce3fd683409015258ac6d34afe74886c8c98..e8fa67915915c78f875556a68163f20b3f658977 100644 --- a/services/jira/jira-common.js +++ b/services/jira/jira-common.js @@ -3,6 +3,7 @@ const authConfig = { userKey: 'jira_user', passKey: 'jira_pass', + serviceKey: 'jira', } module.exports = { authConfig } diff --git a/services/jira/jira-issue.service.js b/services/jira/jira-issue.service.js index 1ba8b98ad6468c27fe3c413eaa07879ff07a578c..b1eeea4a5138404779bdea0d873d2dbabf07646f 100644 --- a/services/jira/jira-issue.service.js +++ b/services/jira/jira-issue.service.js @@ -83,12 +83,13 @@ module.exports = class JiraIssue extends BaseJsonService { async handle({ issueKey }, { baseUrl }) { // Atlassian Documentation: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-api-2-issue-issueIdOrKey-get - const json = await this._requestJson({ - schema, - url: `${baseUrl}/rest/api/2/issue/${encodeURIComponent(issueKey)}`, - options: { auth: this.authHelper.basicAuth }, - errorMessages: { 404: 'issue not found' }, - }) + const json = await this._requestJson( + this.authHelper.withBasicAuth({ + schema, + url: `${baseUrl}/rest/api/2/issue/${encodeURIComponent(issueKey)}`, + errorMessages: { 404: 'issue not found' }, + }) + ) const issueStatus = json.fields.status const statusName = issueStatus.name diff --git a/services/jira/jira-issue.spec.js b/services/jira/jira-issue.spec.js index 26ecbc1d9b5f2ece47d38eea135712be7c51bc9a..df5c5e7c86b48c329964fb61cd0ef5d47343f218 100644 --- a/services/jira/jira-issue.spec.js +++ b/services/jira/jira-issue.spec.js @@ -4,13 +4,13 @@ const { expect } = require('chai') const nock = require('nock') const { cleanUpNockAfterEach, defaultContext } = require('../test-helpers') const JiraIssue = require('./jira-issue.service') -const { user, pass, config } = require('./jira-test-helpers') +const { user, pass, host, config } = require('./jira-test-helpers') describe('JiraIssue', function() { cleanUpNockAfterEach() it('sends the auth information as configured', async function() { - const scope = nock('https://myprivatejira.test') + const scope = nock(`https://${host}`) .get(`/rest/api/2/issue/${encodeURIComponent('secure-234')}`) // This ensures that the expected credentials are actually being sent with the HTTP request. // Without this the request wouldn't match and the test would fail. @@ -24,7 +24,7 @@ describe('JiraIssue', function() { { issueKey: 'secure-234', }, - { baseUrl: 'https://myprivatejira.test' } + { baseUrl: `https://${host}` } ) ).to.deep.equal({ label: 'secure-234', diff --git a/services/jira/jira-sprint.service.js b/services/jira/jira-sprint.service.js index 5fe1f48bc1c832be6fefa119314f9a3929f36f91..e7863b24b1f08dfe132e0caae47b43338174ccea 100644 --- a/services/jira/jira-sprint.service.js +++ b/services/jira/jira-sprint.service.js @@ -93,22 +93,23 @@ module.exports = class JiraSprint extends BaseJsonService { // Atlassian Documentation: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-group-Search // There are other sprint-specific APIs but those require authentication. The search API // allows us to get the needed data without being forced to authenticate. - const json = await this._requestJson({ - url: `${baseUrl}/rest/api/2/search`, - schema, - options: { - qs: { - jql: `sprint=${sprintId} AND type IN (Bug,Improvement,Story,"Technical task")`, - fields: 'resolution', - maxResults: 500, + const json = await this._requestJson( + this.authHelper.withBasicAuth({ + url: `${baseUrl}/rest/api/2/search`, + schema, + options: { + qs: { + jql: `sprint=${sprintId} AND type IN (Bug,Improvement,Story,"Technical task")`, + fields: 'resolution', + maxResults: 500, + }, }, - auth: this.authHelper.basicAuth, - }, - errorMessages: { - 400: 'sprint not found', - 404: 'sprint not found', - }, - }) + errorMessages: { + 400: 'sprint not found', + 404: 'sprint not found', + }, + }) + ) const numTotalIssues = json.total const numCompletedIssues = json.issues.filter(issue => { diff --git a/services/jira/jira-sprint.spec.js b/services/jira/jira-sprint.spec.js index 080a305f6c35590fad213f7e1f65be4c1bc5e8cf..d9696f3e8b780031a08da1f35e4f8e441e6d939c 100644 --- a/services/jira/jira-sprint.spec.js +++ b/services/jira/jira-sprint.spec.js @@ -7,6 +7,7 @@ const JiraSprint = require('./jira-sprint.service') const { user, pass, + host, config, sprintId, sprintQueryString, @@ -16,7 +17,7 @@ describe('JiraSprint', function() { cleanUpNockAfterEach() it('sends the auth information as configured', async function() { - const scope = nock('https://myprivatejira.test') + const scope = nock(`https://${host}`) .get('/jira/rest/api/2/search') .query(sprintQueryString) // This ensures that the expected credentials are actually being sent with the HTTP request. @@ -37,7 +38,7 @@ describe('JiraSprint', function() { { sprintId, }, - { baseUrl: 'https://myprivatejira.test/jira' } + { baseUrl: `https://${host}/jira` } ) ).to.deep.equal({ label: 'completion', diff --git a/services/jira/jira-test-helpers.js b/services/jira/jira-test-helpers.js index ad31e8d2286c3346f114c980714db6ca08696b41..11c01cff674c9dddebaed52303c986f1d59a03bb 100644 --- a/services/jira/jira-test-helpers.js +++ b/services/jira/jira-test-helpers.js @@ -9,12 +9,23 @@ const sprintQueryString = { const user = 'admin' const pass = 'password' -const config = { private: { jira_user: user, jira_pass: pass } } +const host = 'myprivatejira.test' +const config = { + public: { + services: { + jira: { + authorizedOrigins: [`https://${host}`], + }, + }, + }, + private: { jira_user: user, jira_pass: pass }, +} module.exports = { sprintId, sprintQueryString, user, pass, + host, config, } diff --git a/services/nexus/nexus.service.js b/services/nexus/nexus.service.js index 462bbb9c925437b4b19b13facb2bb73d0c9f215b..b95362d73fbab7aef657191add1332e052e6c5f8 100644 --- a/services/nexus/nexus.service.js +++ b/services/nexus/nexus.service.js @@ -67,7 +67,7 @@ module.exports = class Nexus extends BaseJsonService { } static get auth() { - return { userKey: 'nexus_user', passKey: 'nexus_pass' } + return { userKey: 'nexus_user', passKey: 'nexus_pass', serviceKey: 'nexus' } } static get examples() { @@ -223,14 +223,16 @@ module.exports = class Nexus extends BaseJsonService { this.addQueryParamsToQueryString({ qs, queryOpt }) } - const json = await this._requestJson({ - schema, - url, - options: { qs, auth: this.authHelper.basicAuth }, - errorMessages: { - 404: 'artifact not found', - }, - }) + const json = await this._requestJson( + this.authHelper.withBasicAuth({ + schema, + url, + options: { qs }, + errorMessages: { + 404: 'artifact not found', + }, + }) + ) return { actualNexusVersion: '2', json } } @@ -262,14 +264,16 @@ module.exports = class Nexus extends BaseJsonService { server.slice(-1) === '/' ? '' : '/' }service/rest/v1/search` - const json = await this._requestJson({ - schema: nexus3SearchApiSchema, - url, - options: { qs, auth: this.authHelper.basicAuth }, - errorMessages: { - 404: 'artifact not found', - }, - }) + const json = await this._requestJson( + this.authHelper.withBasicAuth({ + schema: nexus3SearchApiSchema, + url, + options: { qs }, + errorMessages: { + 404: 'artifact not found', + }, + }) + ) return { actualNexusVersion: '3', json } } diff --git a/services/nexus/nexus.spec.js b/services/nexus/nexus.spec.js index 3b478495d2c90ef3f24978e0d5d7207f344756a7..64556b16b25b1dfb67d7f5575ab98cd1ae09dd0f 100644 --- a/services/nexus/nexus.spec.js +++ b/services/nexus/nexus.spec.js @@ -119,11 +119,23 @@ describe('Nexus', function() { const user = 'admin' const pass = 'password' - const config = { private: { nexus_user: user, nexus_pass: pass } } + const config = { + public: { + services: { + nexus: { + authorizedOrigins: ['https://repository.jboss.org'], + }, + }, + }, + private: { + nexus_user: user, + nexus_pass: pass, + }, + } it('sends the auth information as configured', async function() { - const scope = nock('https://repository.jboss.org/nexus') - .get('/service/local/lucene/search') + const scope = nock('https://repository.jboss.org') + .get('/nexus/service/local/lucene/search') .query({ g: 'jboss', a: 'jboss-client' }) // This ensures that the expected credentials are actually being sent with the HTTP request. // Without this the request wouldn't match and the test would fail. diff --git a/services/npm/npm-base.js b/services/npm/npm-base.js index 8809b8bd2adde4d0ae92acf3cb7aed901a0134a3..bded61a3910e10f3899b1ade5ea3a3408646b887 100644 --- a/services/npm/npm-base.js +++ b/services/npm/npm-base.js @@ -42,7 +42,7 @@ const queryParamSchema = Joi.object({ // of a package. module.exports = class NpmBase extends BaseJsonService { static get auth() { - return { passKey: 'npm_token' } + return { passKey: 'npm_token', serviceKey: 'npm' } } static buildRoute(base, { withTag } = {}) { @@ -81,17 +81,18 @@ module.exports = class NpmBase extends BaseJsonService { } async _requestJson(data) { - return super._requestJson({ - ...data, - options: { - headers: { - // Use a custom Accept header because of this bug: - // <https://github.com/npm/npmjs.org/issues/163> - Accept: '*/*', - ...this.authHelper.bearerAuthHeader, + return super._requestJson( + this.authHelper.withBearerAuthHeader({ + ...data, + options: { + headers: { + // Use a custom Accept header because of this bug: + // <https://github.com/npm/npmjs.org/issues/163> + Accept: '*/*', + }, }, - }, - }) + }) + ) } async fetchPackageData({ registryUrl, scope, packageName, tag }) { diff --git a/services/npm/npm-base.spec.js b/services/npm/npm-base.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..45a0e3c8e55d41e2bac8cf7c16485714cbf9539c --- /dev/null +++ b/services/npm/npm-base.spec.js @@ -0,0 +1,46 @@ +'use strict' + +const { expect } = require('chai') +const nock = require('nock') +const { cleanUpNockAfterEach, defaultContext } = require('../test-helpers') +// use NPM Version as an example implementation of NpmBase for this test +const NpmVersion = require('./npm-version.service') + +describe('npm', function() { + describe('auth', function() { + it('sends the auth information as configured', async function() { + cleanUpNockAfterEach() + + const token = 'abc123' + + const scope = nock('https://registry.npmjs.org', { + reqheaders: { Accept: '*/*', Authorization: `Bearer ${token}` }, + }) + .get('/-/package/npm/dist-tags') + .reply(200, { latest: '0.1.0' }) + + const config = { + public: { + services: { + npm: { + authorizedOrigins: ['https://registry.npmjs.org'], + }, + }, + }, + private: { + npm_token: token, + }, + } + + expect( + await NpmVersion.invoke(defaultContext, config, { packageName: 'npm' }) + ).to.deep.equal({ + color: 'orange', + label: undefined, + message: 'v0.1.0', + }) + + scope.done() + }) + }) +}) diff --git a/services/sonar/sonar-base.js b/services/sonar/sonar-base.js index 05d0a4ed6623958dc7b1f51deafb56e99fe729b9..9e9bceb476fe97b8d801c0fa158cbf2b76130ac4 100644 --- a/services/sonar/sonar-base.js +++ b/services/sonar/sonar-base.js @@ -53,7 +53,7 @@ const legacySchema = Joi.array() module.exports = class SonarBase extends BaseJsonService { static get auth() { - return { userKey: 'sonarqube_token' } + return { userKey: 'sonarqube_token', serviceKey: 'sonar' } } async fetch({ sonarVersion, server, component, metricName }) { @@ -78,17 +78,16 @@ module.exports = class SonarBase extends BaseJsonService { } } - return this._requestJson({ - schema, - url, - options: { - qs, - auth: this.authHelper.basicAuth, - }, - errorMessages: { - 404: 'component or metric not found, or legacy API not supported', - }, - }) + return this._requestJson( + this.authHelper.withBasicAuth({ + schema, + url, + options: { qs }, + errorMessages: { + 404: 'component or metric not found, or legacy API not supported', + }, + }) + ) } transform({ json, sonarVersion }) { diff --git a/services/sonar/sonar-fortify-rating.spec.js b/services/sonar/sonar-fortify-rating.spec.js index ac8c9b3296e24c659f741a66b6139a8e678a1687..90b5b11a80b50b9894da57d18a552d0c374f87fc 100644 --- a/services/sonar/sonar-fortify-rating.spec.js +++ b/services/sonar/sonar-fortify-rating.spec.js @@ -6,7 +6,16 @@ const { cleanUpNockAfterEach, defaultContext } = require('../test-helpers') const SonarFortifyRating = require('./sonar-fortify-rating.service') const token = 'abc123def456' -const config = { private: { sonarqube_token: token } } +const config = { + public: { + services: { + sonar: { authorizedOrigins: ['http://sonar.petalslink.com'] }, + }, + }, + private: { + sonarqube_token: token, + }, +} describe('SonarFortifyRating', function() { cleanUpNockAfterEach() diff --git a/services/symfony/symfony-insight-base.js b/services/symfony/symfony-insight-base.js index a51f7fffe0afcaeb764aecbb17187098a73f31e7..91cb91b9f2d79d768558d9ebce53576e8d97cfdd 100644 --- a/services/symfony/symfony-insight-base.js +++ b/services/symfony/symfony-insight-base.js @@ -54,6 +54,7 @@ class SymfonyInsightBase extends BaseXmlService { return { userKey: 'sl_insight_userUuid', passKey: 'sl_insight_apiToken', + authorizedOrigins: ['https://insight.symfony.com'], isRequired: true, } } @@ -65,24 +66,23 @@ class SymfonyInsightBase extends BaseXmlService { } async fetch({ projectUuid }) { - return this._requestXml({ - schema, - url: `https://insight.symfony.com/api/projects/${projectUuid}`, - options: { - headers: { - Accept: 'application/vnd.com.sensiolabs.insight+xml', + return this._requestXml( + this.authHelper.withBasicAuth({ + schema, + url: `https://insight.symfony.com/api/projects/${projectUuid}`, + options: { + headers: { Accept: 'application/vnd.com.sensiolabs.insight+xml' }, }, - auth: this.authHelper.basicAuth, - }, - errorMessages: { - 401: 'not authorized to access project', - 404: 'project not found', - }, - parserOptions: { - attributeNamePrefix: '', - ignoreAttributes: false, - }, - }) + errorMessages: { + 401: 'not authorized to access project', + 404: 'project not found', + }, + parserOptions: { + attributeNamePrefix: '', + ignoreAttributes: false, + }, + }) + ) } transform({ data }) { diff --git a/services/symfony/symfony-test-helpers.js b/services/symfony/symfony-test-helpers.js index 9b2c09baf44e1effc3c135adaeb552546482a572..1f9bd0f670e2d1f304ad8a3b645b966cb912cebe 100644 --- a/services/symfony/symfony-test-helpers.js +++ b/services/symfony/symfony-test-helpers.js @@ -81,6 +81,7 @@ const multipleViolations = createMockResponse({ const user = 'admin' const token = 'password' const config = { + public: { services: {} }, private: { sl_insight_userUuid: user, sl_insight_apiToken: token, diff --git a/services/teamcity/teamcity-base.js b/services/teamcity/teamcity-base.js index eab3a26bcc60f7abeacd3125857861c2fdb60f57..e280235d20da825f4670483a890c1b229838180b 100644 --- a/services/teamcity/teamcity-base.js +++ b/services/teamcity/teamcity-base.js @@ -4,24 +4,27 @@ const { BaseJsonService } = require('..') module.exports = class TeamCityBase extends BaseJsonService { static get auth() { - return { userKey: 'teamcity_user', passKey: 'teamcity_pass' } + return { + userKey: 'teamcity_user', + passKey: 'teamcity_pass', + serviceKey: 'teamcity', + } } async fetch({ url, schema, qs = {}, errorMessages = {} }) { // JetBrains API Auth Docs: https://confluence.jetbrains.com/display/TCD18/REST+API#RESTAPI-RESTAuthentication const options = { qs } - const auth = this.authHelper.basicAuth - if (auth) { - options.auth = auth - } else { + if (!this.authHelper.isConfigured) { qs.guest = 1 } - return this._requestJson({ - url, - schema, - options, - errorMessages: { 404: 'build not found', ...errorMessages }, - }) + return this._requestJson( + this.authHelper.withBasicAuth({ + url, + schema, + options, + errorMessages: { 404: 'build not found', ...errorMessages }, + }) + ) } } diff --git a/services/teamcity/teamcity-build.spec.js b/services/teamcity/teamcity-build.spec.js index 3c48470e4e4b4aa686eaad5ce03e870507eb84d2..06aee3668f8dbdb050774ebe3632d9bc050e8030 100644 --- a/services/teamcity/teamcity-build.spec.js +++ b/services/teamcity/teamcity-build.spec.js @@ -4,13 +4,13 @@ const { expect } = require('chai') const nock = require('nock') const { cleanUpNockAfterEach, defaultContext } = require('../test-helpers') const TeamCityBuild = require('./teamcity-build.service') -const { user, pass, config } = require('./teamcity-test-helpers') +const { user, pass, host, config } = require('./teamcity-test-helpers') describe('TeamCityBuild', function() { cleanUpNockAfterEach() it('sends the auth information as configured', async function() { - const scope = nock('https://mycompany.teamcity.com') + const scope = nock(`https://${host}`) .get(`/app/rest/builds/${encodeURIComponent('buildType:(id:bt678)')}`) // This ensures that the expected credentials are actually being sent with the HTTP request. // Without this the request wouldn't match and the test would fail. @@ -29,7 +29,7 @@ describe('TeamCityBuild', function() { verbosity: 'e', buildId: 'bt678', }, - { server: 'https://mycompany.teamcity.com' } + { server: `https://${host}` } ) ).to.deep.equal({ message: 'tests failed: 1 (1 new), passed: 50246, ignored: 1, muted: 12', diff --git a/services/teamcity/teamcity-coverage.spec.js b/services/teamcity/teamcity-coverage.spec.js index 05081ce81d51e077f15115396986400dd93cf09b..c193caede9c090b4fce358cea9f7e63348145533 100644 --- a/services/teamcity/teamcity-coverage.spec.js +++ b/services/teamcity/teamcity-coverage.spec.js @@ -4,13 +4,13 @@ const { expect } = require('chai') const nock = require('nock') const { cleanUpNockAfterEach, defaultContext } = require('../test-helpers') const TeamCityCoverage = require('./teamcity-coverage.service') -const { user, pass, config } = require('./teamcity-test-helpers') +const { user, pass, host, config } = require('./teamcity-test-helpers') describe('TeamCityCoverage', function() { cleanUpNockAfterEach() it('sends the auth information as configured', async function() { - const scope = nock('https://mycompany.teamcity.com') + const scope = nock(`https://${host}`) .get( `/app/rest/builds/${encodeURIComponent( 'buildType:(id:bt678)' diff --git a/services/teamcity/teamcity-test-helpers.js b/services/teamcity/teamcity-test-helpers.js index 47cfe75a1df64e540048f6ee510444a4011cccac..21d7a8b3ad89c7f5b2fcb1d999022e1ca6c25414 100644 --- a/services/teamcity/teamcity-test-helpers.js +++ b/services/teamcity/teamcity-test-helpers.js @@ -2,10 +2,24 @@ const user = 'admin' const pass = 'password' -const config = { private: { teamcity_user: user, teamcity_pass: pass } } +const host = 'mycompany.teamcity.com' +const config = { + public: { + services: { + teamcity: { + authorizedOrigins: [`https://${host}`], + }, + }, + }, + private: { + teamcity_user: user, + teamcity_pass: pass, + }, +} module.exports = { user, pass, + host, config, } diff --git a/services/wheelmap/wheelmap.service.js b/services/wheelmap/wheelmap.service.js index b3702f0a9aba87399c5f59fefcb6f0ca1e1fe631..e9158ad5d88de72b9d39afe65b6bfeb2d9883d5f 100644 --- a/services/wheelmap/wheelmap.service.js +++ b/services/wheelmap/wheelmap.service.js @@ -22,7 +22,11 @@ module.exports = class Wheelmap extends BaseJsonService { } static get auth() { - return { passKey: 'wheelmap_token', isRequired: true } + return { + passKey: 'wheelmap_token', + authorizedOrigins: ['https://wheelmap.org'], + isRequired: true, + } } static get examples() { @@ -52,15 +56,19 @@ module.exports = class Wheelmap extends BaseJsonService { } async fetch({ nodeId }) { - return this._requestJson({ - schema, - url: `https://wheelmap.org/api/nodes/${nodeId}`, - options: { qs: { api_key: this.authHelper.pass } }, - errorMessages: { - 401: 'invalid token', - 404: 'node not found', - }, - }) + return this._requestJson( + this.authHelper.withQueryStringAuth( + { passKey: 'api_key' }, + { + schema, + url: `https://wheelmap.org/api/nodes/${nodeId}`, + errorMessages: { + 401: 'invalid token', + 404: 'node not found', + }, + } + ) + ) } async handle({ nodeId }) {