Skip to content

Commit 615c669

Browse files
authored
feat: support URL-scoped registry auth via npm_config_// and pnpm_config_// env vars (#12338)
* feat: support URL-scoped registry auth via npm_config_// and pnpm_config_// env vars Adds a file-free way to configure registry authentication, e.g. npm_config_//registry.npmjs.org/:_authToken=<token> pnpm_config_//registry.npmjs.org/:_authToken=<token> These are host-scoped by construction — the registry the value applies to is encoded in the (trusted) variable name — so they cannot be redirected to another host by repository-controlled config. The env value is trusted: it overrides a project/workspace .npmrc but is still overridden by CLI options. pnpm_config_ wins over npm_config_ for the same key. * feat(pacquet): support URL-scoped registry auth via npm_config_// and pnpm_config_// env vars Pacquet parity for the same feature on the JS side: read URL-scoped registry credentials from npm_config_//… and pnpm_config_//… environment variables (e.g. npm_config_//registry.npmjs.org/:_authToken=<token>). These are trusted (sourced from the environment, not the repository) and host-scoped by construction, so they sit at the top of the .npmrc precedence chain — above the project .npmrc. pnpm_config_ wins over npm_config_ for the same key. Adds an EnvVar::vars() enumeration capability (default empty, so existing fakes keep compiling; production providers override it). * fix(pacquet): avoid Unicode ellipsis in a line comment (dylint) * fix: exclude tokenHelper from URL-scoped env auth; add case-insensitive tests Address review feedback on #12338: - A `//host/:tokenHelper` env var would land in authConfig but trip the TOKEN_HELPER_IN_PROJECT_CONFIG guard (which only trusts the user .npmrc), incorrectly failing. tokenHelper names an executable, so it is now excluded from the env-scoped layer entirely. - Add tests for case-insensitive prefix matching and the tokenHelper exclusion. - Add a 'text' language hint to the changeset's fenced block (MD040). * fix(pacquet): avoid panics on non-UTF-8 / non-ASCII env var names Address CodeRabbit review on the pacquet env-auth code: - EnvVar::vars() used std::env::vars(), which panics if any env var name or value is not valid UTF-8. Iterate vars_os() and skip non-UTF-8 entries, matching var()'s .ok() behavior. (SystemEnv and Host.) - parse_url_scoped_env_name sliced with name[..prefix.len()], which panics when the byte index lands inside a multi-byte char. Use boundary-checked name.get(..) instead. - Add a regression test with non-ASCII env var names. * test: cover env-auth precedence and pacquet end-to-end wiring Fill the coverage gaps in the URL-scoped env-auth feature: - JS: assert a CLI-provided //host/:_authToken still beats the same env var (workspace < env < CLI), and that non-token cred fields work while a non-URL-scoped env key is ignored. - pacquet: add end-to-end tests through the full config load — that a npm_config_//… var is honored and outranks a project .npmrc token for the same host, and that the prefix is matched case-insensitively. FakeEnv now enumerates via vars() so the env-scoped reader sees the fixture.
1 parent 84bb4b1 commit 615c669

9 files changed

Lines changed: 429 additions & 5 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@pnpm/config.reader": minor
3+
"pnpm": minor
4+
---
5+
6+
Added support for configuring URL-scoped registry settings through `npm_config_//…` and `pnpm_config_//…` environment variables, for example:
7+
8+
```text
9+
npm_config_//registry.npmjs.org/:_authToken=<token>
10+
pnpm_config_//registry.npmjs.org/:_authToken=<token>
11+
```
12+
13+
This provides a file-free way to supply registry authentication. Because the registry a value applies to is encoded in the (trusted) environment variable name, it is host-scoped by construction and cannot be redirected to another registry by repository-controlled config. The environment value is treated as trusted config: it takes precedence over a project/workspace `.npmrc` but is still overridden by command-line options. When the same key is provided through both prefixes, `pnpm_config_` wins.

config/reader/src/loadNpmrcFiles.ts

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { npmDefaults } from './npmDefaults.js'
1313
export interface NpmrcConfigResult {
1414
/**
1515
* Merged auth/registry config from all sources.
16-
* Priority (lowest to highest): builtin < defaults < user < auth.ini < workspace < CLI
16+
* Priority (lowest to highest): builtin < defaults < user < auth.ini < workspace < env (//-scoped) < CLI
1717
*/
1818
mergedConfig: Record<string, unknown>
1919
/** Raw config suitable for pnpmConfig.authConfig (filtered through pickIniConfig by consumer) */
@@ -85,6 +85,13 @@ export function loadNpmrcConfig (opts: LoadNpmrcConfigOpts): NpmrcConfigResult {
8585
// We clone first to avoid mutating the caller's cliOptions object.
8686
const cliOptions = rescopeUnscopedCreds({ ...opts.cliOptions }, '<command line>', warnings)
8787

88+
// URL-scoped auth/registry settings supplied via `npm_config_//…` and
89+
// `pnpm_config_//…` environment variables. The registry a credential is
90+
// bound to is encoded in the (trusted) variable name, so unlike a project
91+
// `.npmrc` these cannot be redirected to another host by the repository —
92+
// making them a safe, file-free way to configure registry authentication.
93+
const envScopedConfig = readUrlScopedEnvConfig(env)
94+
8895
// Read pnpm builtin rc + inline defaults
8996
const pnpmBuiltinConfig: Record<string, unknown> = {
9097
...readAndFilterNpmrc(
@@ -107,18 +114,20 @@ export function loadNpmrcConfig (opts: LoadNpmrcConfigOpts): NpmrcConfigResult {
107114
])
108115

109116
// Merge all sources (lowest to highest priority):
110-
// builtin < defaults < user < auth.ini < workspace < CLI
117+
// builtin < defaults < user < auth.ini < workspace < env (//-scoped) < CLI
111118
const mergedConfig: Record<string, unknown> = {}
112-
for (const source of [pnpmBuiltinConfig, opts.defaultOptions, userConfig, pnpmAuthConfig, workspaceNpmrc, cliOptions]) {
119+
for (const source of [pnpmBuiltinConfig, opts.defaultOptions, userConfig, pnpmAuthConfig, workspaceNpmrc, envScopedConfig, cliOptions]) {
113120
for (const [key, value] of Object.entries(source)) {
114121
if (isNpmrcReadableKey(key)) {
115122
mergedConfig[key] = value
116123
}
117124
}
118125
}
119126

127+
// The env-scoped config is trusted (it comes from the environment, not the
128+
// repository), so it is included here while the workspace .npmrc is not.
120129
const trustedConfig: Record<string, unknown> = {}
121-
for (const source of [pnpmBuiltinConfig, opts.defaultOptions, userConfig, pnpmAuthConfig, cliOptions]) {
130+
for (const source of [pnpmBuiltinConfig, opts.defaultOptions, userConfig, pnpmAuthConfig, envScopedConfig, cliOptions]) {
122131
for (const [key, value] of Object.entries(source)) {
123132
if (isNpmrcReadableKey(key)) {
124133
trustedConfig[key] = value
@@ -133,6 +142,7 @@ export function loadNpmrcConfig (opts: LoadNpmrcConfigOpts): NpmrcConfigResult {
133142
...userConfig,
134143
...pnpmAuthConfig,
135144
...workspaceNpmrc,
145+
...envScopedConfig,
136146
...cliOptions,
137147
}
138148

@@ -147,6 +157,41 @@ export function loadNpmrcConfig (opts: LoadNpmrcConfigOpts): NpmrcConfigResult {
147157
}
148158
}
149159

160+
// Matches `npm_config_//…` and `pnpm_config_//…` env var names. The prefix is
161+
// matched case-insensitively (as npm does), but the captured key keeps its
162+
// original case because URL-scoped keys are case-sensitive (e.g. `:_authToken`).
163+
const URL_SCOPED_ENV_RE = /^p?npm_config_(\/\/.+)$/i
164+
165+
// Collect URL-scoped settings (keys beginning with `//host…`, such as
166+
// `//registry.npmjs.org/:_authToken`) from `npm_config_//…` and `pnpm_config_//…`
167+
// environment variables. These are host-scoped by construction — the registry
168+
// the value applies to is part of the variable name — so they are safe to honor
169+
// from the trusted environment without a config file. When the same key is set
170+
// through both prefixes, `pnpm_config_` wins.
171+
//
172+
// An empty value is treated as unset, matching how pnpm reads its other env
173+
// config (`readEnvVar`'s `!== ''` filter) and npm's own `npm_config_*` handling.
174+
function readUrlScopedEnvConfig (env: Record<string, string | undefined>): Record<string, unknown> {
175+
const npmScoped: Record<string, string> = {}
176+
const pnpmScoped: Record<string, string> = {}
177+
for (const envKey of Object.keys(env)) {
178+
const value = env[envKey]
179+
if (value == null || value === '') continue
180+
const match = URL_SCOPED_ENV_RE.exec(envKey)
181+
if (match == null) continue
182+
const key = match[1]
183+
// `tokenHelper` names an executable pnpm runs. It is only allowed from a
184+
// user-level config file (enforced by the TOKEN_HELPER_IN_PROJECT_CONFIG
185+
// check in index.ts, which validates against the user `.npmrc`). The env
186+
// layer isn't that file, so honoring `//host/:tokenHelper` here would
187+
// trip that guard — never admit it.
188+
if (key.endsWith(':tokenHelper')) continue
189+
const target = envKey.slice(0, 5).toLowerCase() === 'pnpm_' ? pnpmScoped : npmScoped
190+
target[key] = value
191+
}
192+
return { ...npmScoped, ...pnpmScoped }
193+
}
194+
150195
// Per-registry rc keys that, when written without a `//host/` prefix, fall
151196
// through to whatever default registry the merged config settles on. We
152197
// rewrite each such key to its URL-scoped form at load time, pinning it to

config/reader/test/index.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -980,6 +980,142 @@ test('auth tokens from pnpm auth file override ~/.npmrc', async () => {
980980
}
981981
})
982982

983+
test('reads URL-scoped auth from npm_config_// environment variables', async () => {
984+
prepareEmpty()
985+
986+
const { config } = await getConfig({
987+
cliOptions: {},
988+
env: {
989+
...env,
990+
'npm_config_//env-test.example/:_authToken': 'npm-env-token',
991+
},
992+
packageManager: { name: 'pnpm', version: '1.0.0' },
993+
})
994+
995+
expect(config.authConfig['//env-test.example/:_authToken']).toBe('npm-env-token')
996+
})
997+
998+
test('reads URL-scoped auth from pnpm_config_// environment variables', async () => {
999+
prepareEmpty()
1000+
1001+
const { config } = await getConfig({
1002+
cliOptions: {},
1003+
env: {
1004+
...env,
1005+
'pnpm_config_//env-test.example/:_authToken': 'pnpm-env-token',
1006+
},
1007+
packageManager: { name: 'pnpm', version: '1.0.0' },
1008+
})
1009+
1010+
expect(config.authConfig['//env-test.example/:_authToken']).toBe('pnpm-env-token')
1011+
})
1012+
1013+
test('pnpm_config_// takes precedence over npm_config_// for the same key', async () => {
1014+
prepareEmpty()
1015+
1016+
const { config } = await getConfig({
1017+
cliOptions: {},
1018+
env: {
1019+
...env,
1020+
'npm_config_//env-test.example/:_authToken': 'npm-env-token',
1021+
'pnpm_config_//env-test.example/:_authToken': 'pnpm-env-token',
1022+
},
1023+
packageManager: { name: 'pnpm', version: '1.0.0' },
1024+
})
1025+
1026+
expect(config.authConfig['//env-test.example/:_authToken']).toBe('pnpm-env-token')
1027+
})
1028+
1029+
test('the npm_config_// / pnpm_config_// prefix is matched case-insensitively', async () => {
1030+
prepareEmpty()
1031+
1032+
const { config } = await getConfig({
1033+
cliOptions: {},
1034+
env: {
1035+
...env,
1036+
// Upper-case npm prefix and mixed-case pnpm prefix; the latter (pnpm) wins.
1037+
'NPM_CONFIG_//env-test.example/:_authToken': 'npm-upper-token',
1038+
'PnPm_Config_//env-test.example/:_authToken': 'pnpm-mixed-token',
1039+
},
1040+
packageManager: { name: 'pnpm', version: '1.0.0' },
1041+
})
1042+
1043+
expect(config.authConfig['//env-test.example/:_authToken']).toBe('pnpm-mixed-token')
1044+
})
1045+
1046+
test('a tokenHelper set via a URL-scoped env var is not honored (no project-config error)', async () => {
1047+
prepareEmpty()
1048+
1049+
// tokenHelper executes a binary and is only valid from a user-level config
1050+
// file; the env layer must drop it rather than trip the project-config guard.
1051+
const { config } = await getConfig({
1052+
cliOptions: {},
1053+
env: {
1054+
...env,
1055+
'npm_config_//env-test.example/:tokenHelper': '/bin/echo',
1056+
},
1057+
packageManager: { name: 'pnpm', version: '1.0.0' },
1058+
})
1059+
1060+
expect(config.authConfig['//env-test.example/:tokenHelper']).toBeUndefined()
1061+
})
1062+
1063+
test('URL-scoped auth from the environment overrides a project .npmrc for the same host', async () => {
1064+
prepareEmpty()
1065+
1066+
// The repository ships a literal token for the host; the trusted env value must win.
1067+
fs.writeFileSync('.npmrc', '//env-test.example/:_authToken=workspace-token', 'utf8')
1068+
1069+
const { config } = await getConfig({
1070+
cliOptions: {},
1071+
env: {
1072+
...env,
1073+
'npm_config_//env-test.example/:_authToken': 'env-token',
1074+
},
1075+
packageManager: { name: 'pnpm', version: '1.0.0' },
1076+
})
1077+
1078+
expect(config.authConfig['//env-test.example/:_authToken']).toBe('env-token')
1079+
})
1080+
1081+
test('a CLI-provided URL-scoped auth token overrides the same env var', async () => {
1082+
prepareEmpty()
1083+
1084+
// Precedence is workspace < env < CLI; an explicit CLI value must still win.
1085+
const { config } = await getConfig({
1086+
cliOptions: {
1087+
'//env-test.example/:_authToken': 'cli-token',
1088+
},
1089+
env: {
1090+
...env,
1091+
'npm_config_//env-test.example/:_authToken': 'env-token',
1092+
},
1093+
packageManager: { name: 'pnpm', version: '1.0.0' },
1094+
})
1095+
1096+
expect(config.authConfig['//env-test.example/:_authToken']).toBe('cli-token')
1097+
})
1098+
1099+
test('URL-scoped env vars honor non-token credential fields and ignore non-URL keys', async () => {
1100+
prepareEmpty()
1101+
1102+
const { config } = await getConfig({
1103+
cliOptions: {},
1104+
env: {
1105+
...env,
1106+
'npm_config_//env-test.example/:username': 'env-user',
1107+
'npm_config_//env-test.example/:_password': 'ZW52LXBhc3M=', // base64, value is opaque to pnpm
1108+
// A non-URL-scoped key must not be imported by the //-scoped env reader.
1109+
'npm_config_always-auth': 'true',
1110+
},
1111+
packageManager: { name: 'pnpm', version: '1.0.0' },
1112+
})
1113+
1114+
expect(config.authConfig['//env-test.example/:username']).toBe('env-user')
1115+
expect(config.authConfig['//env-test.example/:_password']).toBe('ZW52LXBhc3M=')
1116+
expect(config.authConfig['always-auth']).toBeUndefined()
1117+
})
1118+
9831119
test('workspace .npmrc overrides pnpm auth file', async () => {
9841120
prepareEmpty()
9851121

pacquet/crates/config/src/api.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,14 @@ impl EnvVar for Host {
114114
fn var(name: &str) -> Option<String> {
115115
std::env::var(name).ok()
116116
}
117+
118+
fn vars() -> Vec<(String, String)> {
119+
// `std::env::vars()` panics on non-UTF-8 entries; iterate the
120+
// OsString form and skip those, matching `var`'s `.ok()` behavior.
121+
std::env::vars_os()
122+
.filter_map(|(name, value)| Some((name.into_string().ok()?, value.into_string().ok()?)))
123+
.collect()
124+
}
117125
}
118126

119127
impl EnvVarOs for Host {

pacquet/crates/config/src/lib.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1752,10 +1752,22 @@ impl Config {
17521752
}),
17531753
};
17541754

1755+
// URL-scoped credentials from `npm_config_//...` / `pnpm_config_//...`
1756+
// environment variables. These are trusted (they come from the
1757+
// environment, not the repository) and host-scoped by construction, so
1758+
// they sit at the top of the precedence chain — above the project
1759+
// `.npmrc` — mirroring the env-over-workspace ordering in pnpm's
1760+
// [`loadNpmrcFiles.ts`](https://github.com/pnpm/pnpm/blob/main/config/reader/src/loadNpmrcFiles.ts).
1761+
let env_scoped_source = {
1762+
let auth = crate::npmrc_auth::NpmrcAuth::from_url_scoped_env::<Sys>();
1763+
(!auth.creds_by_uri.is_empty()).then_some(auth)
1764+
};
1765+
17551766
// Fold high-priority-first: the first present source is the
17561767
// base, each lower source fills the gaps it left
17571768
// ([`NpmrcAuth::merge_under`]).
1758-
let mut sources = [project_source, auth_ini_source, user_source].into_iter().flatten();
1769+
let mut sources =
1770+
[env_scoped_source, project_source, auth_ini_source, user_source].into_iter().flatten();
17591771
let mut npmrc_auth = sources.next().unwrap_or_default();
17601772
for lower in sources {
17611773
npmrc_auth.merge_under(lower);

pacquet/crates/config/src/npmrc_auth.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,48 @@ impl NpmrcAuth {
164164
)
165165
}
166166

167+
/// Collect URL-scoped registry credentials supplied through
168+
/// `npm_config_//…` and `pnpm_config_//…` environment variables, e.g.
169+
/// `npm_config_//registry.npmjs.org/:_authToken=<token>`.
170+
///
171+
/// The registry a credential applies to is encoded in the (trusted)
172+
/// variable name, so — unlike a project `.npmrc` — these cannot be
173+
/// redirected to another host by repository-controlled config. That makes
174+
/// them a safe, file-free way to configure registry auth. The prefix is
175+
/// matched case-insensitively (as npm does); the remainder keeps its case
176+
/// because credential keys are case-sensitive (`:_authToken`). When the
177+
/// same key is set through both prefixes, `pnpm_config_` wins.
178+
///
179+
/// Only the four credential fields (`_authToken`, `_auth`, `username`,
180+
/// `_password`) are honored — the same set [`split_creds_key`] recognises.
181+
/// Values are used verbatim (no `${VAR}` re-expansion): they already come
182+
/// resolved from the environment.
183+
pub fn from_url_scoped_env<Sys: EnvVar>() -> Self {
184+
// Merge into one map keyed by the URL-scoped key so each key is applied
185+
// once. `pnpm_config_` is extended last so it wins over `npm_config_`.
186+
let mut npm_scoped: HashMap<String, String> = HashMap::new();
187+
let mut pnpm_scoped: HashMap<String, String> = HashMap::new();
188+
for (name, value) in Sys::vars() {
189+
if value.is_empty() {
190+
continue;
191+
}
192+
if let Some((is_pnpm, key)) = parse_url_scoped_env_name(&name) {
193+
let target = if is_pnpm { &mut pnpm_scoped } else { &mut npm_scoped };
194+
target.insert(key.to_owned(), value);
195+
}
196+
}
197+
npm_scoped.extend(pnpm_scoped);
198+
199+
let mut auth = NpmrcAuth::default();
200+
for (key, value) in npm_scoped {
201+
if let Some((uri, suffix)) = split_creds_key(&key) {
202+
let entry = auth.creds_by_uri.entry(uri.to_owned()).or_default();
203+
apply_creds_field(entry, suffix, value);
204+
}
205+
}
206+
auth
207+
}
208+
167209
/// Parse an `.npmrc` file's contents and pick out the auth/network keys.
168210
/// Unknown keys are silently dropped. `${VAR}` placeholders inside keys
169211
/// and values are resolved via the [`EnvVar`] capability; unresolved
@@ -785,6 +827,26 @@ fn is_auth_value_key(key: &str) -> bool {
785827
|| split_inline_identity_key(key).is_some()
786828
}
787829

830+
/// Match a `npm_config_//…` / `pnpm_config_//…` environment variable name.
831+
/// Returns `(is_pnpm, key)` where `key` is the URL-scoped remainder
832+
/// (e.g. `//registry.npmjs.org/:_authToken`) with its case preserved.
833+
/// The prefix is matched case-insensitively, mirroring npm. `None` for any
834+
/// name that isn't one of these prefixes followed by `//`.
835+
fn parse_url_scoped_env_name(name: &str) -> Option<(bool, &str)> {
836+
for (prefix, is_pnpm) in [("pnpm_config_", true), ("npm_config_", false)] {
837+
// Slice with `get` rather than `name[..prefix.len()]`: a byte index
838+
// landing inside a multi-byte char (a non-ASCII env var name) would
839+
// otherwise panic before the prefix check can reject it.
840+
let (Some(head), Some(rest)) = (name.get(..prefix.len()), name.get(prefix.len()..)) else {
841+
continue;
842+
};
843+
if head.eq_ignore_ascii_case(prefix) && rest.starts_with("//") {
844+
return Some((is_pnpm, rest));
845+
}
846+
}
847+
None
848+
}
849+
788850
fn split_creds_key(key: &str) -> Option<(&str, &str)> {
789851
if !key.starts_with("//") {
790852
return None;

0 commit comments

Comments
 (0)