Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion lib/commands/logout.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,24 @@ const { getAuth } = npmFetch
const { log } = require('proc-log')
const BaseCommand = require('../base-cmd.js')

// Ensure the registry URL ends with / so it nerfs to the same key config writes
// credentials under. npm-registry-fetch's getAuth backtracks up the nerf dart
// path but does not itself normalize trailing slashes, so a registry configured
// as https://host/npm would otherwise resolve to //host/npm and never reach the
// //host/npm/ key where the token lives — leaving logout unable to find auth
// (ENEEDAUTH) and the token stranded in .npmrc.
const withTrailingSlash = (uri) => {
try {
const parsed = new URL(uri)
if (!parsed.pathname.endsWith('/')) {
parsed.pathname += '/'
}
return parsed.href
} catch {
return uri
}
}

class Logout extends BaseCommand {
static description = 'Log out of the registry'
static name = 'logout'
Expand All @@ -15,7 +33,7 @@ class Logout extends BaseCommand {
const registry = this.npm.config.get('registry')
const scope = this.npm.config.get('scope')
const regRef = scope ? `${scope}:registry` : 'registry'
const reg = this.npm.config.get(regRef) || registry
const reg = withTrailingSlash(this.npm.config.get(regRef) || registry)

const auth = getAuth(reg, this.npm.flatOptions)

Expand Down
28 changes: 28 additions & 0 deletions test/lib/commands/logout.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,34 @@ t.test('ignore invalid scoped registry config', async t => {
t.equal(userRc.trim(), 'fund=true')
})

t.test('token logout - registry configured without a trailing slash', async t => {
// The registry is configured WITHOUT a trailing slash and the token lives at
// the normalized //host/npm/ key. Without normalizing the slash before
// getAuth, logout backtracks //host/npm -> //host and never reaches
// //host/npm/, throwing ENEEDAUTH and leaving the token in .npmrc.
const registryUrl = 'https://registry.example.com/npm'
const { npm, home, logs } = await loadMockNpm(t, {
config: { registry: registryUrl },
homeDir: {
'.npmrc': [
'//registry.example.com/npm/:_authToken=@foo/',
'fund=true',
].join('\n'),
},
})

const mockRegistry = new MockRegistry({ tap: t, registry: registryUrl })
mockRegistry.logout('@foo/')
await npm.exec('logout', [])
t.equal(
logs.verbose.byTitle('logout')[0],
'logout clearing token for https://registry.example.com/npm/',
'should log the normalized registry url'
)
const userRc = await fs.readFile(join(home, '.npmrc'), 'utf-8')
t.equal(userRc.trim(), 'fund=true', 'removes the token from .npmrc')
})

t.test('token logout - project config', async t => {
const { npm, home, logs, prefix } = await loadMockNpm(t, {
homeDir: {
Expand Down
99 changes: 72 additions & 27 deletions workspaces/config/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@ const hasOwnProperty = (obj, key) =>

const typeDefs = require('./type-defs.js')
const nerfDart = require('./nerf-dart.js')
// Ensure the URL ends with / so nerfDart treats the last path component as a directory,
// not a file. Without this, e.g. 'https://host/npm' resolves to '//host/' instead of
// '//host/npm/', because URL resolution of '.' against a non-trailing-slash path
// discards the last segment.
const nerfDartURI = (uri) => {
const parsed = new URL(uri)
if (!parsed.pathname.endsWith('/')) {
parsed.pathname += '/'
}
return nerfDart(parsed.href)
}
const envReplace = require('./env-replace.js')
const parseField = require('./parse-field.js')
const setEnvs = require('./set-envs.js')
Expand Down Expand Up @@ -437,7 +448,7 @@ class Config {
// NOTE we pull registry without restricting to the current 'where' because we want to
// suggest scoping things to the registry they would be applied to, which is the default
// regardless of where it was defined
const nerfedReg = nerfDart(this.get('registry'))
const nerfedReg = nerfDartURI(this.get('registry'))
// keys that should be nerfed but currently are not
for (const key of ['_auth', '_authToken', 'username', '_password']) {
if (this.get(key, entryWhere)) {
Expand Down Expand Up @@ -895,8 +906,8 @@ class Config {
}

clearCredentialsByURI (uri, level = 'user') {
const nerfed = nerfDart(uri)
const def = nerfDart(this.get('registry'))
const nerfed = nerfDartURI(uri)
const def = nerfDartURI(this.get('registry'))
if (def === nerfed) {
this.delete(`-authtoken`, level)
this.delete(`_authToken`, level)
Expand All @@ -916,7 +927,7 @@ class Config {
}

setCredentialsByURI (uri, { token, username, password, certfile, keyfile }) {
const nerfed = nerfDart(uri)
const nerfed = nerfDartURI(uri)

// field that hasn't been used as documented for a LONG time,
// and as of npm 7.10.0, isn't used at all. We just always
Expand Down Expand Up @@ -955,7 +966,7 @@ class Config {

// this has to be a bit more complicated to support legacy data of all forms
getCredentialsByURI (uri) {
const nerfed = nerfDart(uri)
const nerfed = nerfDartURI(uri)
const creds = {}

// email is handled differently, it used to always be nerfed and now it never should be.
Expand All @@ -974,36 +985,69 @@ class Config {
// cert/key may be used in conjunction with other credentials, thus no `return`
}

const tokenReg = this.get(`${nerfed}:_authToken`)
if (tokenReg) {
creds.token = tokenReg
return creds
}
// Auth is looked up via #findAuthNerf, which walks up the nerf dart path so
// credentials stored under a less specific key are still located. This
// matters now that registry URLs are normalized with a trailing slash:
// tokens written at //host/ (by pre-normalization npm, for a registry
// configured as https://host/npm) would otherwise be missed when reading
// https://host/npm, which nerfs to //host/npm/. Mirrors the backtracking
// npm-registry-fetch performs in regFromURI.
const authKey = this.#findAuthNerf(nerfed)
if (authKey) {
const tokenReg = this.get(`${authKey}:_authToken`)
if (tokenReg) {
creds.token = tokenReg
return creds
}

const userReg = this.get(`${nerfed}:username`)
const passReg = this.get(`${nerfed}:_password`)
if (userReg && passReg) {
creds.username = userReg
creds.password = Buffer.from(passReg, 'base64').toString('utf8')
const auth = `${creds.username}:${creds.password}`
creds.auth = Buffer.from(auth, 'utf8').toString('base64')
return creds
}
const userReg = this.get(`${authKey}:username`)
const passReg = this.get(`${authKey}:_password`)
if (userReg && passReg) {
creds.username = userReg
creds.password = Buffer.from(passReg, 'base64').toString('utf8')
const auth = `${creds.username}:${creds.password}`
creds.auth = Buffer.from(auth, 'utf8').toString('base64')
return creds
}

const authReg = this.get(`${nerfed}:_auth`)
if (authReg) {
const authDecode = Buffer.from(authReg, 'base64').toString('utf8')
const authSplit = authDecode.split(':')
creds.username = authSplit.shift()
creds.password = authSplit.join(':')
creds.auth = authReg
return creds
const authReg = this.get(`${authKey}:_auth`)
if (authReg) {
const authDecode = Buffer.from(authReg, 'base64').toString('utf8')
const authSplit = authDecode.split(':')
creds.username = authSplit.shift()
creds.password = authSplit.join(':')
creds.auth = authReg
return creds
}
}

// at this point, nothing else is usable so just return what we do have
return creds
}

// Returns the deepest nerf dart key at or above `nerfed` that has any usable
// auth (token, username+_password, or _auth), or null if none is found. Walks
// up the path one segment at a time, stopping once only //host remains. This
// matches the path-walking backtracking npm-registry-fetch performs in
// regFromURI, so legacy credentials stored under a less specific key are still
// resolved after registry trailing-slash normalization.
#findAuthNerf (nerfed) {
let regKey = nerfed
while (regKey.length > '//'.length) {
if (
this.get(`${regKey}:_authToken`) ||
(this.get(`${regKey}:username`) && this.get(`${regKey}:_password`)) ||
this.get(`${regKey}:_auth`)
) {
return regKey
}
// drop EITHER the trailing segment or the trailing slash, so both
// //host/path/ and //host/path collapse to //host/ on the next pass
regKey = regKey.replace(/([^/]+|\/)$/, '')
}
return null
}

// set up the environment object we have with npm_config_* environs
// for all configs that are different from their default values, and
// set EDITOR and HOME.
Expand Down Expand Up @@ -1114,3 +1158,4 @@ const getTypesFromDefinitions = (definitions) => {

module.exports = Config
module.exports.getTypesFromDefinitions = getTypesFromDefinitions
module.exports.nerfDartURI = nerfDartURI
61 changes: 61 additions & 0 deletions workspaces/config/test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -900,6 +900,67 @@ always-auth = true`,
t.end()
})

t.test('nerfDartURI normalizes a trailing slash', t => {
const { nerfDartURI } = Config
// without normalization 'https://host/npm' nerfs to //host/ (the trailing
// path segment is treated as a filename and dropped); nerfDartURI keeps it
const cases = [
['https://registry.example/', '//registry.example/'],
['https://registry.example', '//registry.example/'],
['https://host/npm', '//host/npm/'],
['https://host/npm/', '//host/npm/'],
['https://host/a/b/c', '//host/a/b/c/'],
['https://host/a/b/c/', '//host/a/b/c/'],
['https://host:8080/path', '//host:8080/path/'],
]
t.plan(cases.length)
for (const [input, expected] of cases) {
t.equal(nerfDartURI(input), expected, input)
}
})

t.test('getCredentialsByURI backtracks up the nerf dart path', async t => {
// Registry is configured WITHOUT a trailing slash. Pre-normalization npm
// nerfed it to //host/ and wrote the token there; nerfDartURI now nerfs it
// to //host/path/, so getCredentialsByURI must walk back up to //host/ to
// find the legacy token instead of returning empty creds.
const path = t.testdir({
'.npmrc': `//host/:_authToken = legacy-token
//host/path/:_authToken = scoped-token
//deep/a/b/:_authToken = deep-token
`,
})
const c = new Config({
npmPath: path,
shorthands,
definitions,
nerfDarts,
env: { HOME: path },
argv: ['node', 'file', '--registry', 'https://host/path/'],
})
await c.load()

// exact key wins
t.equal(c.getCredentialsByURI('https://host/path').token, 'scoped-token',
'exact path key wins over legacy parent')

// legacy token at //host/ found via backtracking for a deeper uri
t.equal(c.getCredentialsByURI('https://host/path/sub').token, 'scoped-token',
'scoped token found by walking up one level')

// registry with no path resolves to the //host/ legacy token
t.equal(c.getCredentialsByURI('https://host/other').token, 'legacy-token',
'legacy //host/ token found by walking back to host root')

// deep path: //deep/a/b/ token found when querying a child of it
t.equal(c.getCredentialsByURI('https://deep/a/b/c/d').token, 'deep-token',
'deep token found by walking up multiple levels')

// nothing stored under this host at all -> empty creds
t.strictSame(c.getCredentialsByURI('https://nowhere/'), {}, 'no creds returns empty')
t.end()
})

t.test('finding the global prefix', t => {
const npmPath = __dirname
t.test('load from PREFIX env', t => {
Expand Down