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
1 change: 1 addition & 0 deletions e2e/npm_translate_lock_dynamic_auth/.bazelignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
node_modules
1 change: 1 addition & 0 deletions e2e/npm_translate_lock_dynamic_auth/.bazelrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
try-import %workspace%/../../.aspect/workflows/bazelrc
1 change: 1 addition & 0 deletions e2e/npm_translate_lock_dynamic_auth/.bazelversion
4 changes: 4 additions & 0 deletions e2e/npm_translate_lock_dynamic_auth/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
hoist=false

# This will be replaced by test script with dynamic token
_authToken=${ASPECT_NPM_AUTH_TOKEN}
11 changes: 11 additions & 0 deletions e2e/npm_translate_lock_dynamic_auth/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
load("@bazel_skylib//rules:build_test.bzl", "build_test")
load("@npm//:defs.bzl", "npm_link_all_packages")

npm_link_all_packages(name = "node_modules")

build_test(
name = "test",
targets = [
":node_modules",
],
)
27 changes: 27 additions & 0 deletions e2e/npm_translate_lock_dynamic_auth/MODULE.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
bazel_dep(name = "aspect_rules_js", version = "0.0.0", dev_dependency = True)
local_path_override(
module_name = "aspect_rules_js",
path = "../..",
)

bazel_dep(name = "bazel_skylib", version = "1.5.0", dev_dependency = True)

pnpm = use_extension("@aspect_rules_js//npm:extensions.bzl", "pnpm")
pnpm.pnpm(
name = "pnpm",
)
use_repo(pnpm, "pnpm", "pnpm__links")

npm = use_extension(
"@aspect_rules_js//npm:extensions.bzl",
"npm",
dev_dependency = True,
)
npm.npm_translate_lock(
name = "npm",
data = ["//:package.json"],
pnpm_lock = "//:pnpm-lock.yaml",
use_home_npmrc = True,
verify_node_modules_ignored = "//:.bazelignore",
)
use_repo(npm, "npm")
7 changes: 7 additions & 0 deletions e2e/npm_translate_lock_dynamic_auth/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Dynamic .npmrc authentication integration test

Tests that authentication tokens are read dynamically from `.npmrc` on each download,
not cached statically. Critical for short-lived credentials like AWS CodeArtifact tokens.

Auth token with permission to pull packages from `@aspect-test` scope must be set in
`ASPECT_NPM_AUTH_TOKEN` environment variable for this e2e test to pass.
1 change: 1 addition & 0 deletions e2e/npm_translate_lock_dynamic_auth/WORKSPACE
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Marker file for Bazel
2 changes: 2 additions & 0 deletions e2e/npm_translate_lock_dynamic_auth/WORKSPACE.bzlmod
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# This file marks the root of the Bazel workspace.
# See MODULE.bazel for external dependencies setup with bzlmod.
7 changes: 7 additions & 0 deletions e2e/npm_translate_lock_dynamic_auth/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "npm-translate-lock-dynamic-auth-test",
"version": "0.0.0",
"dependencies": {
"@aspect-test/a": "5.0.0"
}
}
23 changes: 23 additions & 0 deletions e2e/npm_translate_lock_dynamic_auth/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions e2e/npm_translate_lock_dynamic_auth/pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
packages: []
97 changes: 97 additions & 0 deletions e2e/npm_translate_lock_dynamic_auth/test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
set -o errexit -o nounset -o pipefail

BZLMOD_FLAG="${BZLMOD_FLAG:---enable_bzlmod=1}"

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'

echo -e "${BLUE}================================================================${NC}"
echo -e "${BLUE}Testing Dynamic .npmrc Authentication${NC}"
echo -e "${BLUE}================================================================${NC}"
echo ""

if [ -z "${ASPECT_NPM_AUTH_TOKEN:-}" ]; then
echo -e "${YELLOW}Skipping test: ASPECT_NPM_AUTH_TOKEN not set${NC}"
exit 0
fi

if [ -f ~/.npmrc ]; then
cp ~/.npmrc ~/.npmrc.backup
HAS_BACKUP=true
else
HAS_BACKUP=false
fi

cleanup() {
echo ""
echo -e "${YELLOW}Cleaning up...${NC}"
if [ "$HAS_BACKUP" = true ]; then
mv ~/.npmrc.backup ~/.npmrc
echo -e "${GREEN}Restored ~/.npmrc${NC}"
elif [ -f ~/.npmrc.test ]; then
rm ~/.npmrc
echo -e "${GREEN}Removed test ~/.npmrc${NC}"
fi
}

trap cleanup EXIT

echo -e "${BLUE}Test 1: Fetch with valid token${NC}"
cat >~/.npmrc <<EOF
_authToken=${ASPECT_NPM_AUTH_TOKEN}
EOF
touch ~/.npmrc.test

bazel clean --expunge "$BZLMOD_FLAG"
if bazel fetch "$BZLMOD_FLAG" //... 2>&1 | tee /tmp/fetch.log; then
echo -e "${GREEN}✓ Fetch with valid token succeeded${NC}"
else
echo -e "${RED}✗ Fetch with valid token failed${NC}"
cat /tmp/fetch.log
exit 1
fi
echo ""

echo -e "${BLUE}Test 2: Fetch with broken token (empty repository cache)${NC}"
cat >~/.npmrc <<EOF
_authToken=BROKEN_TOKEN_SHOULD_CAUSE_401
EOF

if bazel fetch "$BZLMOD_FLAG" --repository_cache= //... 2>&1 | tee /tmp/fetch_broken.log; then
if grep -qi "401\|unauthorized" /tmp/fetch_broken.log; then
echo -e "${GREEN}✓✓✓ Got 401 with broken token - dynamic auth confirmed!${NC}"
else
echo -e "${RED}✗ Fetch succeeded with broken token (auth is cached, not dynamic!)${NC}"
exit 1
fi
else
if grep -qi "401\|unauthorized" /tmp/fetch_broken.log; then
echo -e "${GREEN}✓✓✓ Got 401 with broken token - dynamic auth confirmed!${NC}"
else
echo -e "${RED}✗ Fetch failed but not with 401${NC}"
tail -20 /tmp/fetch_broken.log
exit 1
fi
fi
echo ""

echo -e "${BLUE}Test 3: Fetch with restored valid token${NC}"
cat >~/.npmrc <<EOF
_authToken=${ASPECT_NPM_AUTH_TOKEN}
EOF

if bazel fetch "$BZLMOD_FLAG" --repository_cache= //... 2>&1; then
echo -e "${GREEN}✓ Fetch with restored token succeeded${NC}"
else
echo -e "${RED}✗ Fetch with restored token failed${NC}"
exit 1
fi
echo ""

echo -e "${GREEN}================================================================${NC}"
echo -e "${GREEN}✅ All dynamic auth tests passed!${NC}"
echo -e "${GREEN}================================================================${NC}"
145 changes: 107 additions & 38 deletions npm/private/npm_import.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ load(
load(":npm_link_package_store.bzl", "npm_link_package_store")
load(":npm_package_internal.bzl", "npm_package_internal")
load(":npm_package_store_internal.bzl", _npm_package_store = "npm_package_store_internal")
load(":npm_translate_lock_helpers.bzl", npm_translate_lock_helpers = "helpers")
load(":npmrc.bzl", "parse_npmrc")
load(":starlark_codegen_utils.bzl", "starlark_codegen_utils")
load(":utils.bzl", "utils")

Expand Down Expand Up @@ -513,47 +515,104 @@ def _fetch_git_repository(rctx):
if not rctx.delete(git_metadata_folder):
fail("Failed to delete .git folder in %s" % str(git_repo.directory))

def _get_auth_from_url(url, npm_auth_dict):
for registry, auth_info in npm_auth_dict.items():
if registry in url:
if "bearer" in auth_info:
return {
Comment on lines +518 to +522

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prefer registry-specific auth over first .npmrc entry

The new _get_auth_from_url helper returns the first registry entry whose key is a substring of the URL, so a global _authToken entry (key "") in ~/.npmrc will always match and short‑circuit even when a scoped registry token is present. In that common setup, downloads for scoped registries will use the global token and get 401s instead of the registry‑specific credentials, which regresses the longest‑prefix selection previously used when static auth was passed to the rule. Consider skipping the empty registry until no specific match exists or applying the same longest‑match logic as _select_npm_auth.

Useful? React with 👍 / 👎.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems correct and an easy test+fix?

url: {
"type": "pattern",
"pattern": "Bearer <password>",
"password": auth_info["bearer"],
},
}
elif "basic" in auth_info:
return {
url: {
"type": "pattern",
"pattern": "Basic <password>",
"password": auth_info["basic"],
},
}
elif "username" in auth_info and "password" in auth_info:
return {
url: {
"type": "basic",
"login": auth_info["username"],
"password": auth_info["password"],
},
}
return {}

def _read_npmrc_auth(rctx):
npm_auth = {}

if rctx.attr.npmrc:
npmrc = parse_npmrc(rctx.read(rctx.attr.npmrc))
(_, repo_auth) = npm_translate_lock_helpers.get_npm_auth(
npmrc,
str(rctx.path(rctx.attr.npmrc)),
rctx.os.environ,
)
npm_auth.update(repo_auth)

if rctx.attr.use_home_npmrc:
home_directory = repo_utils.get_home_directory(rctx)
if home_directory:
home_npmrc_path = "{}/{}".format(home_directory, ".npmrc")
home_npmrc = parse_npmrc(rctx.read(home_npmrc_path))
(_, home_auth) = npm_translate_lock_helpers.get_npm_auth(
home_npmrc,
home_npmrc_path,
rctx.os.environ,
)
npm_auth.update(home_auth)

return npm_auth

def _download_and_extract_archive(rctx, package_json_only):
download_url = rctx.attr.url if rctx.attr.url else utils.npm_registry_download_url(rctx.attr.package, rctx.attr.version, {}, utils.default_registry())

auth = {}

if rctx.attr.npm_auth_username or rctx.attr.npm_auth_password:
if not rctx.attr.npm_auth_username:
fail("'npm_auth_password' was provided without 'npm_auth_username'")
if not rctx.attr.npm_auth_password:
fail("'npm_auth_username' was provided without 'npm_auth_password'")

auth_count = 0
if rctx.attr.npm_auth:
auth = {
download_url: {
"type": "pattern",
"pattern": "Bearer <password>",
"password": rctx.attr.npm_auth,
},
}
auth_count += 1
if rctx.attr.npm_auth_basic:
auth = {
download_url: {
"type": "pattern",
"pattern": "Basic <password>",
"password": rctx.attr.npm_auth_basic,
},
}
auth_count += 1
if rctx.attr.npm_auth_username and rctx.attr.npm_auth_password:
auth = {
download_url: {
"type": "basic",
"login": rctx.attr.npm_auth_username,
"password": rctx.attr.npm_auth_password,
},
}
auth_count += 1
if auth_count > 1:
fail("expected only one of 'npm_auth', `npm_auth_basic` or 'npm_auth_username' and 'npm_auth_password' to be set")
npm_auth_dict = _read_npmrc_auth(rctx)
auth = _get_auth_from_url(download_url, npm_auth_dict)

if not auth:
if rctx.attr.npm_auth_username or rctx.attr.npm_auth_password:
if not rctx.attr.npm_auth_username:
fail("'npm_auth_password' was provided without 'npm_auth_username'")
if not rctx.attr.npm_auth_password:
fail("'npm_auth_username' was provided without 'npm_auth_password'")

auth_count = 0
if rctx.attr.npm_auth:
auth = {
download_url: {
"type": "pattern",
"pattern": "Bearer <password>",
"password": rctx.attr.npm_auth,
},
}
auth_count += 1
if rctx.attr.npm_auth_basic:
auth = {
download_url: {
"type": "pattern",
"pattern": "Basic <password>",
"password": rctx.attr.npm_auth_basic,
},
}
auth_count += 1
if rctx.attr.npm_auth_username and rctx.attr.npm_auth_password:
auth = {
download_url: {
"type": "basic",
"login": rctx.attr.npm_auth_username,
"password": rctx.attr.npm_auth_password,
},
}
auth_count += 1
if auth_count > 1:
fail("expected only one of 'npm_auth', `npm_auth_basic` or 'npm_auth_username' and 'npm_auth_password' to be set")

rctx.download(
output = _TARBALL_FILENAME,
Expand Down Expand Up @@ -955,6 +1014,8 @@ _ATTRS = _COMMON_ATTRS | {
"npm_auth_basic": attr.string(),
"npm_auth_password": attr.string(),
"npm_auth_username": attr.string(),
"npmrc": attr.label(),
"use_home_npmrc": attr.bool(),
"patch_tool": attr.label(),
"patch_args": attr.string_list(),
"patches": attr.label_list(),
Expand Down Expand Up @@ -1022,6 +1083,8 @@ def npm_import(
npm_auth_basic = "",
npm_auth_username = "",
npm_auth_password = "",
npmrc = None,
Copy link
Member

@jbedard jbedard Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see these being specified anywhere?

use_home_npmrc = None,
bins = {},
dev = False,
exclude_package_contents = [],
Expand Down Expand Up @@ -1260,6 +1323,10 @@ def npm_import(

npm_auth_password: Auth password to authenticate with npm. When using Basic authentication.

npmrc: Label to an .npmrc file for authentication. Supports environment variable expansion.

use_home_npmrc: Use ~/.npmrc for authentication.

extra_build_content: Additional content to append on the generated BUILD file at the root of
the created repository, either as a string or a list of lines similar to
<https://github.com/bazelbuild/bazel-skylib/blob/main/docs/write_file_doc.md>.
Expand Down Expand Up @@ -1328,6 +1395,8 @@ def npm_import(
npm_auth_basic = npm_auth_basic,
npm_auth_username = npm_auth_username,
npm_auth_password = npm_auth_password,
npmrc = npmrc,
use_home_npmrc = use_home_npmrc,
lifecycle_hooks = lifecycle_hooks,
extra_build_content = (
extra_build_content if type(extra_build_content) == "string" else "\n".join(extra_build_content)
Expand Down
Loading
Loading