Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
13 changes: 13 additions & 0 deletions docs/pm/npmrc.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,19 @@ The equivalent `bunfig.toml` option is to add a key in [`install.scopes`](/runti
myorg = { url = "http://localhost:4873/", username = "myusername", password = "$NPM_PASSWORD" }
```

#### Tarballs served from a different host than the registry

Bun only forwards a registry's `Authorization` header to tarball downloads from the same origin as that registry. If your registry serves package manifests from one host and tarballs from a separate authenticated host (for example, Artifactory or Nexus with a CDN), add an auth entry for the tarball host too:

```ini .npmrc icon="npm"
@myorg:registry=https://packages.example.com/npm/
//packages.example.com/npm/:_authToken=${NPM_TOKEN}
# dist.tarball points at a separate authenticated host:
//cdn.example.com/:_authToken=${NPM_TOKEN}
```

Bun looks up tarball auth by the host and path prefix of the download URL, the same way npm does.

### `link-workspace-packages`: Control workspace package installation

Controls how workspace packages are installed when available locally:
Expand Down
114 changes: 62 additions & 52 deletions src/ini/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1683,12 +1683,14 @@ mod draft {

for conf_item in configs.iter() {
let conf_item_url = URL::parse(&conf_item.registry_url);
let mut matched_any_registry = false;

if bun_core::without_trailing_slash(&default_registry_host)
== bun_core::without_trailing_slash(conf_item_url.host)
&& bun_core::without_trailing_slash(&default_registry_pathname)
== bun_core::without_trailing_slash(conf_item_url.pathname)
{
matched_any_registry = true;
// Apply config to default registry
let v: &mut NpmRegistry = 'brk: {
if let Some(r) = install.default_registry.as_mut() {
Expand All @@ -1707,32 +1709,7 @@ mod draft {
install.default_registry.as_mut().unwrap()
};

match conf_item.optname {
ConfigOpt::_AuthToken => {
if let Some(x) = conf_item.dupe_value_decoded(log, source)? {
v.token = x;
}
}
ConfigOpt::Username => {
if let Some(x) = conf_item.dupe_value_decoded(log, source)? {
v.username = x;
}
}
ConfigOpt::_Password => {
if let Some(x) = conf_item.dupe_value_decoded(log, source)? {
v.password = x;
}
}
ConfigOpt::_Auth => {
handle_auth(v, conf_item, log, source)?;
}
ConfigOpt::Email => {
if let Some(x) = conf_item.dupe_value_decoded(log, source)? {
v.email = x;
}
}
ConfigOpt::Certfile | ConfigOpt::Keyfile => unreachable!(),
}
apply_config_opt(v, conf_item, log, source)?;
}

// `keys()`/`values_mut()` on the same map alias; since
Expand All @@ -1758,37 +1735,35 @@ mod draft {
continue;
}
}
matched_any_registry = true;
// Apply config to scoped registry
match conf_item.optname {
ConfigOpt::_AuthToken => {
if let Some(x) = conf_item.dupe_value_decoded(log, source)? {
v.token = x;
}
}
ConfigOpt::Username => {
if let Some(x) = conf_item.dupe_value_decoded(log, source)? {
v.username = x;
}
}
ConfigOpt::_Password => {
if let Some(x) = conf_item.dupe_value_decoded(log, source)? {
v.password = x;
}
}
ConfigOpt::_Auth => {
handle_auth(v, conf_item, log, source)?;
}
ConfigOpt::Email => {
if let Some(x) = conf_item.dupe_value_decoded(log, source)? {
v.email = x;
}
}
ConfigOpt::Certfile | ConfigOpt::Keyfile => unreachable!(),
}
apply_config_opt(v, conf_item, log, source)?;
// We have to keep going as it could match multiple scopes
continue;
}
}

if !matched_any_registry {
// `//host/path/:*=` entry that matches neither the default
// nor any scoped registry. npm looks up auth by the URL
// being fetched, so such an entry can still apply to a
// tarball download whose `dist.tarball` points at this
// origin. Group by `.npmrc` URL so multiple options for the
// same host accumulate into one `NpmRegistry`.
let v: &mut NpmRegistry = 'brk: {
for entry in install.tarball_url_auth.iter_mut() {
if *entry.url == *conf_item.registry_url {
break 'brk entry;
}
}
install.tarball_url_auth.push(NpmRegistry {
url: Box::<[u8]>::from(&*conf_item.registry_url),
..Default::default()
});
install.tarball_url_auth.last_mut().unwrap()
};
apply_config_opt(v, conf_item, log, source)?;
}
Comment thread
robobun marked this conversation as resolved.
}

drop(url_map);
Expand Down Expand Up @@ -1923,6 +1898,41 @@ mod draft {
})
}

fn apply_config_opt(
v: &mut NpmRegistry,
conf_item: &ConfigItem,
log: &mut Log,
source: &Source,
) -> OOM<()> {
match conf_item.optname {
ConfigOpt::_AuthToken => {
if let Some(x) = conf_item.dupe_value_decoded(log, source)? {
v.token = x;
}
}
ConfigOpt::Username => {
if let Some(x) = conf_item.dupe_value_decoded(log, source)? {
v.username = x;
}
}
ConfigOpt::_Password => {
if let Some(x) = conf_item.dupe_value_decoded(log, source)? {
v.password = x;
}
}
ConfigOpt::_Auth => {
handle_auth(v, conf_item, log, source)?;
}
ConfigOpt::Email => {
if let Some(x) = conf_item.dupe_value_decoded(log, source)? {
v.email = x;
}
}
ConfigOpt::Certfile | ConfigOpt::Keyfile => unreachable!(),
}
Ok(())
}

fn handle_auth(
v: &mut NpmRegistry,
conf_item: &ConfigItem,
Expand Down
73 changes: 62 additions & 11 deletions src/install/NetworkTask.rs
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,42 @@
header_builder.append("npm-auth-type", "legacy");
}

/// Look up a `.npmrc` `//host/path/:*=` auth entry for a tarball URL. The
/// entry's host (including port) must match exactly; its pathname must be a
/// path prefix of the tarball pathname (npm walks the target path upward until
/// a matching key is found). Returns the entry with the longest matching
/// pathname so more specific entries win.
fn tarball_url_auth_for<'a>(
entries: &'a [npm::registry::Scope],
tarball: &URL<'_>,
) -> Option<&'a npm::registry::Scope> {
let tarball_host = strings::without_trailing_slash(tarball.host);
let tarball_path = tarball.pathname;
let mut best: Option<(&'a npm::registry::Scope, usize)> = None;
for entry in entries {
let entry_url = entry.url.url();
if strings::without_trailing_slash(entry_url.host) != tarball_host {
continue;
}

Check warning on line 394 in src/install/NetworkTask.rs

View check run for this annotation

Claude / Claude Code Review

tarball_url_auth_for does not normalize default ports

nit: this host comparison doesn't normalize default ports the way the same-origin check just below does (lines 852-854, and its comment at 830-838). If a registry emits `dist.tarball` as `https://cdn.example.com:443/...`, a `.npmrc` `//cdn.example.com/:_authToken=` entry won't match (npm's WHATWG-URL nerf-dart does). Edge case with a trivial workaround (add `:443` to the `.npmrc` key), so fine as a follow-up — just flagging since the neighboring comment calls out this exact scenario.
Comment thread
robobun marked this conversation as resolved.
let entry_path = strings::without_trailing_slash(entry_url.pathname);
// `without_trailing_slash` keeps a lone `/`; treat it as the root
// prefix that matches every path.
let is_prefix = entry_path.is_empty()
|| entry_path == b"/"
|| (tarball_path.len() >= entry_path.len()
&& tarball_path[..entry_path.len()] == *entry_path
&& (tarball_path.len() == entry_path.len()
|| tarball_path[entry_path.len()] == b'/'));
if !is_prefix {
continue;
}
if best.is_none_or(|(_, len)| entry_path.len() > len) {
best = Some((entry, entry_path.len()));
}
}
best.map(|(s, _)| s)
}

fn count_auth(header_builder: &mut HeaderBuilder, scope: &npm::registry::Scope) {
if !scope.token.is_empty() {
header_builder.count("Authorization", "");
Expand Down Expand Up @@ -802,28 +838,43 @@
// registries emit `dist.tarball` URLs with the default port spelled
// out; without normalization those installs lose the `Authorization`
// header and fail with 401.
let send_auth = matches!(authorization, Authorization::AllowAuthorization) && {
let tarball = URL::parse(&self.url_buf);
let registry = scope.url.url();
tarball.protocol == registry.protocol
&& tarball.hostname == registry.hostname
&& tarball.get_port_auto() == registry.get_port_auto()
};
//
// When the origin does NOT match, fall back to `.npmrc` `//host/:*=`
// entries the user configured explicitly for the tarball host
// (`options.tarball_url_auth`), so registries that serve tarballs from
// a separate authenticated origin have a config escape hatch. This is
// how `npm-registry-fetch` resolves auth: by the URL being fetched,
// not by the package scope.
let auth_scope: Option<&npm::registry::Scope> =
if matches!(authorization, Authorization::AllowAuthorization) {
let tarball = URL::parse(&self.url_buf);
let registry = scope.url.url();
if tarball.protocol == registry.protocol
&& tarball.hostname == registry.hostname
&& tarball.get_port_auto() == registry.get_port_auto()
{
Some(scope)
} else {
tarball_url_auth_for(&pm.options.tarball_url_auth, &tarball)
}
} else {

Check warning on line 860 in src/install/NetworkTask.rs

View check run for this annotation

Claude / Claude Code Review

NoAuthorization branch now sends tarball_url_auth (untested)

The `Authorization::NoAuthorization` branch (direct-URL tarball dependencies like `"pkg": "https://host/foo.tgz"` in package.json) now also consults `tarball_url_auth` and can send a `//host/:_authToken=` credential where before it sent none. That's almost certainly the right behavior (npm-registry-fetch resolves auth by fetch URL for direct tarballs too), but both new tests only exercise the `AllowAuthorization` path — worth adding a test for a direct-URL tarball dependency with a matching `.np
Comment thread
robobun marked this conversation as resolved.
tarball_url_auth_for(&pm.options.tarball_url_auth, &URL::parse(&self.url_buf))
};

self.response_buffer = MutableString::init_empty();

let mut header_builder = HeaderBuilder::default();
let mut header_buf: &'static [u8] = b"";

if send_auth {
count_auth(&mut header_builder, scope);
if let Some(auth_scope) = auth_scope {
count_auth(&mut header_builder, auth_scope);
}

if header_builder.header_count > 0 {
header_builder.allocate()?;

if send_auth {
append_auth(&mut header_builder, scope);
if let Some(auth_scope) = auth_scope {
append_auth(&mut header_builder, auth_scope);
}

// SAFETY: `written_slice()` is the safe (ptr,len) accessor; only the
Expand Down
10 changes: 10 additions & 0 deletions src/install/PackageManager/PackageManagerOptions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ pub struct Options {
pub scope: Npm::registry::Scope,

pub registries: Npm::registry::Map,
/// `.npmrc` `//host/path/:*=` auth entries that do not match any
/// configured registry. Consulted by tarball downloads when
/// `dist.tarball` points at a different origin than the registry.
pub tarball_url_auth: Vec<Npm::registry::Scope>,
pub cache_directory: &'static [u8],
pub enable: Enable,
pub do_: Do,
Expand Down Expand Up @@ -104,6 +108,7 @@ impl Default for Options {
// Always assigned in `load()` before read.
scope: Npm::registry::Scope::default(),
registries: Npm::registry::Map::default(),
tarball_url_auth: Vec::new(),
cache_directory: b"",
enable: Enable::default(),
do_: Do::default(),
Expand Down Expand Up @@ -435,6 +440,11 @@ impl Options {
}
}

for registry_ in &config.tarball_url_auth {
self.tarball_url_auth
.push(Npm::registry::Scope::from_api(b"", registry_.clone(), env)?);
}

if let Some(ca) = &config.ca {
match ca {
Api::Ca::List(ca_list) => {
Expand Down
7 changes: 7 additions & 0 deletions src/options_types/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,13 @@ pub mod api {
pub default_registry: Option<NpmRegistry>,
/// scoped
pub scoped: Option<NpmRegistryMap>,
/// `.npmrc` `//host/path/:_authToken=`-style entries whose URL does not
/// match the default registry or any scoped registry. Consulted by
/// tarball downloads when `dist.tarball` points at a host other than
/// the configured registry origin (Artifactory/Nexus with a separate
/// tarball/CDN host). `NpmRegistry::url` holds the raw `.npmrc` URL
/// part (no protocol, e.g. `cdn.example.com/`).
pub tarball_url_auth: Vec<NpmRegistry>,
/// lockfile_path
pub lockfile_path: Option<Box<[u8]>>,
/// save_lockfile_path
Expand Down
Loading
Loading