Skip to content
Closed
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
2 changes: 1 addition & 1 deletion src/install/npm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -478,7 +478,7 @@ pub mod registry {
registry.password = env.get_auto(&registry.password).into();

if !registry.username.is_empty()
&& !registry.password.is_empty()
&& (!registry.password.is_empty() || registry.credentials_from_url)
&& auth.is_empty()
{
let combo_len = registry.username.len() + registry.password.len() + 1;
Expand Down
17 changes: 13 additions & 4 deletions src/options_types/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,19 +150,28 @@ pub mod api {
pub token: Box<[u8]>,
/// email
pub email: Box<[u8]>,
/// Set by `from_url` when the credentials were written into the URL.
/// `Scope::from_api` takes those as complete as written: `http://user@host/`
/// authenticates as `user` with an empty password (what npm sends for that
/// URL), whereas a `username` configured on its own (`.npmrc` `username`
/// without `_password`, bunfig `username` without `password`) sends nothing.
pub credentials_from_url: bool,
}

impl NpmRegistry {
pub fn from_url(str: &[u8]) -> NpmRegistry {
let url = bun_url::URL::parse(str);
let mut registry = NpmRegistry::default();

if url.username.is_empty() && !url.password.is_empty() {
registry.token = Box::from(url.password);
registry.url = url.href_without_auth();
} else if !url.username.is_empty() && !url.password.is_empty() {
if !url.username.is_empty() {
registry.username = Box::from(url.username);
registry.password = Box::from(url.password);
registry.credentials_from_url = true;
registry.url = url.href_without_auth();
} else if !url.password.is_empty() {
// `http://:token@host/`
registry.token = Box::from(url.password);
registry.credentials_from_url = true;
registry.url = url.href_without_auth();
} else {
// Do not include a trailing slash. There might be parameters at the end.
Expand Down
26 changes: 26 additions & 0 deletions test/cli/install/bun-publish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -875,6 +875,32 @@ describe.concurrent("credentials in the registry url", () => {
expect(exitCode).toBe(0);
});

// `http://user@host/` is `user` with an empty password (npm publishes with `Basic base64("user:")`);
// bun used to stop with "missing authentication". Spelled `user:@` because `user@host:port` does not
// get through bun's URL parser yet (#16181); both reach the registry config as a username without a password.
test("--registry with user:@ publishes with Basic auth and an empty password", async () => {
using mock = registryMock();
const packageDir = await packageDirFor("userinfo-no-password-pkg");

const { out, err, exitCode } = await publish(
env,
packageDir,
"--registry",
`http://pubuser:@localhost:${mock.port}/`,
);
expect(err).not.toContain("error:");
expect(out).toContain(`Registry: http://localhost:${mock.port}/\n`);
expect(out).toContain(" + userinfo-no-password-pkg@1.0.0");
expect(mock.requests).toEqual([
{
method: "PUT",
pathname: "/userinfo-no-password-pkg",
authorization: `Basic ${Buffer.from("pubuser:").toString("base64")}`,
},
]);
expect(exitCode).toBe(0);
});

test.each(["npm_config_registry", "NPM_CONFIG_REGISTRY", "BUN_CONFIG_REGISTRY"])(
"%s with user:pass@ publishes with Basic auth",
async key => {
Expand Down
80 changes: 80 additions & 0 deletions test/cli/install/config-precedence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,86 @@ describe.concurrent("bun install config precedence", () => {
expect(exitCode).toBe(0);
});

// A username written into the URL without a password is Basic auth with an empty password, which is
// what npm sends for `http://user@host/`; bun used to leave the userinfo in the URL and send nothing.
// The URL is spelled `user:@host:port` because `user@host:port` does not get through bun's URL parser
// yet (#16181); both spellings reach the registry config as a username with an empty password. The
// verdaccio behind the capturing registry accepts any credentials for packages readable by everyone.
const emptyPasswordAuth = `Basic ${Buffer.from("config-precedence:").toString("base64")}`;
const userOnlyRegistrySources: [
source: string,
configure: (url: string) => {
files?: Record<string, string>;
args?: string[];
env?: Record<string, string>;
dependency?: string;
},
][] = [
["bunfig registry string", url => ({ files: { "project/bunfig.toml": bunfig({ registry: url }) } })],
["bunfig registry object", url => ({ files: { "project/bunfig.toml": bunfig({ registry: { url } }) } })],
[
"bunfig scoped registry",
url => ({ files: { "project/bunfig.toml": bunfig({ scopes: { types: url } }) }, dependency: "@types/no-deps" }),
],
[".npmrc registry=", url => ({ files: { "project/.npmrc": `registry=${url}\n` } })],
[
".npmrc @scope:registry=",
url => ({ files: { "project/.npmrc": `@types:registry=${url}\n` }, dependency: "@types/no-deps" }),
],
["--registry", url => ({ args: ["--registry", url] })],
["BUN_CONFIG_REGISTRY", url => ({ env: { BUN_CONFIG_REGISTRY: url } })],
];

test.each(userOnlyRegistrySources)(
"%s with user:@ in the URL sends Basic auth with an empty password",
async (_, configure) => {
using capture = capturingRegistry();
const {
files = {},
args = [],
env = {},
dependency = "no-deps",
} = configure(withUserinfo(capture, "config-precedence:"));
using dir = tempDir("config-precedence", {
...files,
"project/package.json": packageJson({ [dependency]: "1.0.0" }),
});
const { stderr, exitCode } = await install(String(dir), args, env);
expect(stderr).not.toContain("error:");
expect(new Set(capture.authorizations)).toStrictEqual(new Set([emptyPasswordAuth]));
expect(installed(String(dir), ...dependency.split("/"))).toBe(true);
expect(exitCode).toBe(0);
},
);

// Unlike a username written into the URL, a username configured on its own is not a credential: npm
// only uses the `username` / `_password` pair when both are set.
test("bunfig registry object with a username key but no password sends no credentials", async () => {
using capture = capturingRegistry();
using dir = tempDir("config-precedence", {
"project/bunfig.toml": bunfig({ registry: { url: capture.url, username: "config-precedence" } }),
"project/package.json": packageJson({ "no-deps": "1.0.0" }),
});
const { stderr, exitCode } = await install(String(dir));
expect(stderr).not.toContain("error:");
expect(new Set(capture.authorizations)).toStrictEqual(new Set([null]));
expect(installed(String(dir), "no-deps")).toBe(true);
expect(exitCode).toBe(0);
});

test(".npmrc :username= without :_password= sends no credentials", async () => {
using capture = capturingRegistry();
using dir = tempDir("config-precedence", {
"project/.npmrc": `registry=${capture.url}\n//localhost:${capture.port}/:username=config-precedence\n`,
"project/package.json": packageJson({ "no-deps": "1.0.0" }),
});
const { stderr, exitCode } = await install(String(dir));
expect(stderr).not.toContain("error:");
expect(new Set(capture.authorizations)).toStrictEqual(new Set([null]));
expect(installed(String(dir), "no-deps")).toBe(true);
expect(exitCode).toBe(0);
});

test("user:pass@ in --registry replaces the .npmrc _authToken for the same host", async () => {
using capture = capturingRegistry();
using dir = tempDir("config-precedence", {
Expand Down
32 changes: 32 additions & 0 deletions test/cli/install/npmrc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,38 @@ registry=https://somehost.com/org1/npm/registry/
});
});

describe("credentials written into the registry= URL", () => {
// `http://user@host/` is `user` with an empty password (what `new URL()` reports and what npm
// authenticates with); it used to be left inside the URL like a URL without credentials.
test.each([
["user:pass@ is split into a username and a password", "http://carol:s3cret@example.com/npm/", "carol", "s3cret"],
["user@ is a username with an empty password", "http://carol@example.com/npm/", "carol", ""],
["user:@ is a username with an empty password", "http://carol:@example.com/npm/", "carol", ""],
])("%s", (_, registryUrl, username, password) => {
expect(loadNpmrc(`registry=${registryUrl}\n`)).toEqual({
default_registry_url: "http://example.com/npm/",
default_registry_token: "",
default_registry_username: username,
default_registry_password: password,
default_registry_email: "",
});
});

test("a password without a username is a token", () => {
expect(loadNpmrc(`registry=http://:s3cret@example.com/npm/\n`)).toEqual({
default_registry_url: "http://example.com/npm/",
default_registry_token: "s3cret",
default_registry_username: "",
default_registry_password: "",
default_registry_email: "",
});
});

test("a URL without credentials is kept as written", () => {
expect(loadNpmrc(`registry=http://example.com/npm\n`).default_registry_url).toBe("http://example.com/npm");
});
});

it("does not print an undecodable _password value", async () => {
const secret = "s!ecret!pass";
using dir = tempDir("npmrc-password-decode", {
Expand Down
Loading