Skip to content

Commit cc11cc1

Browse files
authored
feat(auth) load/send based on URI, not registry
BREAKING CHANGE: removes the alwaysAuth config This refactors the handling of auth in the new context where npm/cli will _only_ ever send authorization in such a way that any authorization-related options are scoped to a given sanitized registry host and path. This fixes a troubling situation where a user has intentionally logged into two separate registries, one which serves package distribution tarballs, and the other which has packuments pointing to it. For example, a request to `https://registry.internal/private-thing` might return this packument: ```json { "name": "private-thing", "dist-tags": { "latest": "1.0.0" }, "versions": { "1.0.0": { "name": "private-thing", "version": "1.0.0", "dist": { "tarball": "https://tarballs.internal/private-thing-1.0.0.tgz" } } } } ``` If the user has properly logged into both `https://registry.internal` and `https://tarballs.internal` (or at least placed an auth or bearer token in their `.npmrc` for each), then this should work properly when they run: ``` npm install private-thing --registry=https://registry.internal ``` However, previously the auth for `https://tarballs.internal` would never be loaded, because it was being loaded based solely on the _registry_ URI, regardless of the actual URI being fetched, and then only _sent_ when the hostname matches, and so the request for the tarball would fail. BREAKING CHANGES: * The `alwaysAuth` config is no longer relevant. If we have authentication information for a given URI, we send it when making the request to that URI. This was previously being done based on the presence of a `scope` parameter or a scoped `spec` option. However, that is not reliable, as users can depend directly on tarball URLs, and other parts of the system may have reason to make requests to the registry in question without a specific dependency spec in mind (for example, to fetch a packument for the purpose of calculating metavulnerabilities during an `npm audit` operation.) * A top level `_auth`, `_authToken`, `username`, `_password`, or `password` option is no longer respected if not scoped to a given registry URL. This functionality was removed from npm/cli as of version 7, due to the risk of sending credentials to an incorrect host by mistake. The npm cli will automatically scope these top-level configs to the default registry option and save them back to the `.npmrc` file the first time they are encountered, so users generally see no disruption, and this change only affects non-npm users of npm-registry-fetch. * Add 'name' to HttpError classes, canonicalize tap usage * Suggest fix for case broken by removing alwaysAuth Since we no longer send auth to different registry hosts when alwaysAuth is set, attempt to get the scoped registry auth when an un-authenticated request fails. If that scoped registry auth exists, then direct the user to a web page that tells them how to fix the problem.
1 parent d77b165 commit cc11cc1

File tree

9 files changed

+481
-280
lines changed

9 files changed

+481
-280
lines changed

auth.js

Lines changed: 84 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,94 @@
11
'use strict'
2+
const npa = require('npm-package-arg')
23

3-
const defaultOpts = require('./default-opts.js')
4-
const url = require('url')
4+
// Find the longest registry key that is used for some kind of auth
5+
// in the options.
6+
const regKeyFromURI = (uri, opts) => {
7+
const parsed = new URL(uri)
8+
// try to find a config key indicating we have auth for this registry
9+
// can be one of :_authToken, :_auth, or :_password and :username
10+
// We walk up the "path" until we're left with just //<host>[:<port>],
11+
// stopping when we reach '//'.
12+
let regKey = `//${parsed.host}${parsed.pathname}`
13+
while (regKey.length > '//'.length) {
14+
// got some auth for this URI
15+
if (hasAuth(regKey, opts))
16+
return regKey
517

6-
module.exports = getAuth
7-
function getAuth (registry, opts_ = {}) {
8-
if (!registry)
9-
throw new Error('registry is required')
10-
const opts = opts_.forceAuth ? opts_.forceAuth : { ...defaultOpts, ...opts_ }
11-
const AUTH = {}
12-
const regKey = registry && registryKey(registry)
13-
const doKey = (key, alias) => addKey(opts, AUTH, regKey, key, alias)
14-
doKey('token')
15-
doKey('_authToken', 'token')
16-
doKey('username')
17-
doKey('password')
18-
doKey('_password', 'password')
19-
doKey('email')
20-
doKey('_auth')
21-
doKey('otp')
22-
doKey('always-auth', 'alwaysAuth')
23-
if (AUTH.password)
24-
AUTH.password = Buffer.from(AUTH.password, 'base64').toString('utf8')
25-
26-
if (AUTH._auth && !(AUTH.username && AUTH.password)) {
27-
let auth = Buffer.from(AUTH._auth, 'base64').toString()
28-
auth = auth.split(':')
29-
AUTH.username = auth.shift()
30-
AUTH.password = auth.join(':')
18+
// can be either //host/some/path/:_auth or //host/some/path:_auth
19+
// walk up by removing EITHER what's after the slash OR the slash itself
20+
regKey = regKey.replace(/([^/]+|\/)$/, '')
3121
}
32-
AUTH.alwaysAuth = AUTH.alwaysAuth === 'false' ? false : !!AUTH.alwaysAuth
33-
return AUTH
3422
}
3523

36-
function addKey (opts, obj, scope, key, objKey) {
37-
if (opts[key])
38-
obj[objKey || key] = opts[key]
24+
const hasAuth = (regKey, opts) => (
25+
opts[`${regKey}:_authToken`] ||
26+
opts[`${regKey}:_auth`] ||
27+
opts[`${regKey}:username`] && opts[`${regKey}:_password`]
28+
)
3929

40-
if (scope && opts[`${scope}:${key}`])
41-
obj[objKey || key] = opts[`${scope}:${key}`]
42-
}
30+
const getAuth = (uri, opts = {}) => {
31+
const { forceAuth } = opts
32+
if (!uri)
33+
throw new Error('URI is required')
34+
const regKey = regKeyFromURI(uri, forceAuth || opts)
35+
36+
// we are only allowed to use what's in forceAuth if specified
37+
if (forceAuth && !regKey) {
38+
return new Auth({
39+
scopeAuthKey: null,
40+
token: forceAuth._authToken,
41+
username: forceAuth.username,
42+
password: forceAuth._password || forceAuth.password,
43+
auth: forceAuth._auth || forceAuth.auth,
44+
})
45+
}
46+
47+
// no auth for this URI
48+
if (!regKey && opts.spec) {
49+
// If making a tarball request to a different base URI than the
50+
// registry where we logged in, but the same auth SHOULD be sent
51+
// to that artifact host, then we track where it was coming in from,
52+
// and warn the user if we get a 4xx error on it.
53+
const { spec } = opts
54+
const { scope: specScope, subSpec } = npa(spec)
55+
const subSpecScope = subSpec && subSpec.scope
56+
const scope = subSpec ? subSpecScope : specScope
57+
const scopeReg = scope && opts[`${scope}:registry`]
58+
const scopeAuthKey = scopeReg && regKeyFromURI(scopeReg, opts)
59+
return new Auth({ scopeAuthKey })
60+
}
4361

44-
// Called a nerf dart in the main codebase. Used as a "safe"
45-
// key when fetching registry info from config.
46-
function registryKey (registry) {
47-
const parsed = new url.URL(registry)
48-
const formatted = url.format({
49-
protocol: parsed.protocol,
50-
host: parsed.host,
51-
pathname: parsed.pathname,
52-
slashes: true,
62+
const {
63+
[`${regKey}:_authToken`]: token,
64+
[`${regKey}:username`]: username,
65+
[`${regKey}:_password`]: password,
66+
[`${regKey}:_auth`]: auth,
67+
} = opts
68+
69+
return new Auth({
70+
scopeAuthKey: null,
71+
token,
72+
auth,
73+
username,
74+
password,
5375
})
54-
return url.format(new url.URL('.', formatted)).replace(/^[^:]+:/, '')
5576
}
77+
78+
class Auth {
79+
constructor ({ token, auth, username, password, scopeAuthKey }) {
80+
this.scopeAuthKey = scopeAuthKey
81+
this.token = null
82+
this.auth = null
83+
if (token)
84+
this.token = token
85+
else if (auth)
86+
this.auth = auth
87+
else if (username && password) {
88+
const p = Buffer.from(password, 'base64').toString('utf8')
89+
this.auth = Buffer.from(`${username}:${p}`, 'utf8').toString('base64')
90+
}
91+
}
92+
}
93+
94+
module.exports = getAuth

check-response.js

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,25 @@ const LRU = require('lru-cache')
55
const { Response } = require('minipass-fetch')
66
const defaultOpts = require('./default-opts.js')
77

8-
module.exports = checkResponse
9-
function checkResponse (method, res, registry, startTime, opts_ = {}) {
10-
const opts = { ...defaultOpts, ...opts_ }
8+
const checkResponse = async ({ method, uri, res, registry, startTime, auth, opts }) => {
9+
opts = { ...defaultOpts, ...opts }
1110
if (res.headers.has('npm-notice') && !res.headers.has('x-local-cache'))
1211
opts.log.notice('', res.headers.get('npm-notice'))
1312

1413
checkWarnings(res, registry, opts)
1514
if (res.status >= 400) {
1615
logRequest(method, res, startTime, opts)
16+
if (auth && auth.scopeAuthKey && !auth.token && !auth.auth) {
17+
// we didn't have auth for THIS request, but we do have auth for
18+
// requests to the registry indicated by the spec's scope value.
19+
// Warn the user.
20+
opts.log.warn('registry', `No auth for URI, but auth present for scoped registry.
21+
22+
URI: ${uri}
23+
Scoped Registry Key: ${auth.scopeAuthKey}
24+
25+
More info here: https://github.com/npm/cli/wiki/No-auth-for-URI,-but-auth-present-for-scoped-registry`)
26+
}
1727
return checkErrors(method, res, startTime, opts)
1828
} else {
1929
res.body.on('end', () => logRequest(method, res, startTime, opts))
@@ -24,6 +34,7 @@ function checkResponse (method, res, registry, startTime, opts_ = {}) {
2434
return res
2535
}
2636
}
37+
module.exports = checkResponse
2738

2839
function logRequest (method, res, startTime, opts) {
2940
const elapsedTime = Date.now() - startTime

errors.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ function packageName (href) {
2222
class HttpErrorBase extends Error {
2323
constructor (method, res, body, spec) {
2424
super()
25+
this.name = this.constructor.name
2526
this.headers = res.headers.raw()
2627
this.statusCode = res.status
2728
this.code = `E${res.status}`

index.js

Lines changed: 32 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -27,26 +27,32 @@ function regFetch (uri, /* istanbul ignore next */ opts_ = {}) {
2727
...defaultOpts,
2828
...opts_,
2929
}
30-
const registry = opts.registry = (
31-
(opts.spec && pickRegistry(opts.spec, opts)) ||
32-
opts.registry ||
33-
/* istanbul ignore next */
34-
'https://registry.npmjs.org/'
35-
)
36-
37-
if (!urlIsValid(uri)) {
30+
31+
// if we did not get a fully qualified URI, then we look at the registry
32+
// config or relevant scope to resolve it.
33+
const uriValid = urlIsValid(uri)
34+
let registry = opts.registry || defaultOpts.registry
35+
if (!uriValid) {
36+
registry = opts.registry = (
37+
(opts.spec && pickRegistry(opts.spec, opts)) ||
38+
opts.registry ||
39+
registry
40+
)
3841
uri = `${
3942
registry.trim().replace(/\/?$/g, '')
4043
}/${
4144
uri.trim().replace(/^\//, '')
4245
}`
46+
// asserts that this is now valid
47+
new url.URL(uri)
4348
}
4449

4550
const method = opts.method || 'GET'
4651

4752
// through that takes into account the scope, the prefix of `uri`, etc
4853
const startTime = Date.now()
49-
const headers = getHeaders(registry, uri, opts)
54+
const auth = getAuth(uri, opts)
55+
const headers = getHeaders(uri, auth, opts)
5056
let body = opts.body
5157
const bodyIsStream = Minipass.isStream(body)
5258
const bodyIsPromise = body &&
@@ -117,9 +123,15 @@ function regFetch (uri, /* istanbul ignore next */ opts_ = {}) {
117123
},
118124
strictSSL: opts.strictSSL,
119125
timeout: opts.timeout || 30 * 1000,
120-
}).then(res => checkResponse(
121-
method, res, registry, startTime, opts
122-
))
126+
}).then(res => checkResponse({
127+
method,
128+
uri,
129+
res,
130+
registry,
131+
startTime,
132+
auth,
133+
opts,
134+
}))
123135

124136
return Promise.resolve(body).then(doFetch)
125137
}
@@ -151,7 +163,7 @@ function pickRegistry (spec, opts = {}) {
151163
registry = opts[opts.scope.replace(/^@?/, '@') + ':registry']
152164

153165
if (!registry)
154-
registry = opts.registry || 'https://registry.npmjs.org/'
166+
registry = opts.registry || defaultOpts.registry
155167

156168
return registry
157169
}
@@ -163,7 +175,7 @@ function getCacheMode (opts) {
163175
: 'default'
164176
}
165177

166-
function getHeaders (registry, uri, opts) {
178+
function getHeaders (uri, auth, opts) {
167179
const headers = Object.assign({
168180
'npm-in-ci': !!opts.isFromCI,
169181
'user-agent': opts.userAgent,
@@ -178,25 +190,15 @@ function getHeaders (registry, uri, opts) {
178190
if (opts.npmCommand)
179191
headers['npm-command'] = opts.npmCommand
180192

181-
const auth = getAuth(registry, opts)
182193
// If a tarball is hosted on a different place than the manifest, only send
183194
// credentials on `alwaysAuth`
184-
const shouldAuth = (
185-
auth.alwaysAuth ||
186-
new url.URL(uri).host === new url.URL(registry).host
187-
)
188-
if (shouldAuth && auth.token)
195+
if (auth.token)
189196
headers.authorization = `Bearer ${auth.token}`
190-
else if (shouldAuth && auth.username && auth.password) {
191-
const encoded = Buffer.from(
192-
`${auth.username}:${auth.password}`, 'utf8'
193-
).toString('base64')
194-
headers.authorization = `Basic ${encoded}`
195-
} else if (shouldAuth && auth._auth)
196-
headers.authorization = `Basic ${auth._auth}`
197-
198-
if (shouldAuth && auth.otp)
199-
headers['npm-otp'] = auth.otp
197+
else if (auth.auth)
198+
headers.authorization = `Basic ${auth.auth}`
199+
200+
if (opts.otp)
201+
headers['npm-otp'] = opts.otp
200202

201203
return headers
202204
}

0 commit comments

Comments
 (0)