From 97b53cf3186c5618deca6eea7586fc3b7fb6ad58 Mon Sep 17 00:00:00 2001 From: heunghingwan Date: Mon, 15 Jun 2026 01:51:24 +0800 Subject: [PATCH 1/2] fix(config): normalize registry URL trailing slash in nerfDart to prevent scope key mismatch --- workspaces/config/lib/index.js | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/workspaces/config/lib/index.js b/workspaces/config/lib/index.js index 4121c2a7a3840..2c3a5ecf4c18d 100644 --- a/workspaces/config/lib/index.js +++ b/workspaces/config/lib/index.js @@ -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') @@ -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)) { @@ -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) @@ -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 @@ -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. From 357ae2ce638867f808e65776fe06149b8d71ab80 Mon Sep 17 00:00:00 2001 From: heunghingwan Date: Sat, 20 Jun 2026 02:48:49 +0800 Subject: [PATCH 2/2] fix(config): backtrack nerf dart path in getCredentialsByURI and normalize logout registry - Add #findAuthNerf to getCredentialsByURI, walking up the nerf dart path like npm-registry-fetch's regFromURI so legacy credentials stored at a less specific key (e.g. //host/ for a registry configured as https://host/npm) are still resolved after trailing-slash normalization. - Normalize the trailing slash on the registry URL in logout before getAuth, npmFetch, and clearCredentialsByURI so logout finds the token at //host/npm/ instead of throwing ENEEDAUTH and leaving it in .npmrc. - Export nerfDartURI from @npmcli/config and add tests for its trailing-slash behavior, getCredentialsByURI backtracking, and the logout no-slash case. --- lib/commands/logout.js | 20 ++++++++- test/lib/commands/logout.js | 28 ++++++++++++ workspaces/config/lib/index.js | 78 +++++++++++++++++++++++---------- workspaces/config/test/index.js | 61 ++++++++++++++++++++++++++ 4 files changed, 164 insertions(+), 23 deletions(-) diff --git a/lib/commands/logout.js b/lib/commands/logout.js index dc5a0dfda0e98..8888a0e6a0803 100644 --- a/lib/commands/logout.js +++ b/lib/commands/logout.js @@ -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' @@ -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) diff --git a/test/lib/commands/logout.js b/test/lib/commands/logout.js index b18b84ca48325..d511cac8cc2e2 100644 --- a/test/lib/commands/logout.js +++ b/test/lib/commands/logout.js @@ -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: { diff --git a/workspaces/config/lib/index.js b/workspaces/config/lib/index.js index 2c3a5ecf4c18d..ea6d33e32e355 100644 --- a/workspaces/config/lib/index.js +++ b/workspaces/config/lib/index.js @@ -985,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. @@ -1125,3 +1158,4 @@ const getTypesFromDefinitions = (definitions) => { module.exports = Config module.exports.getTypesFromDefinitions = getTypesFromDefinitions +module.exports.nerfDartURI = nerfDartURI diff --git a/workspaces/config/test/index.js b/workspaces/config/test/index.js index 60941c7760985..d3da9213e8957 100644 --- a/workspaces/config/test/index.js +++ b/workspaces/config/test/index.js @@ -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 => {