diff --git a/e2e/npm_translate_lock_dynamic_auth/.bazelignore b/e2e/npm_translate_lock_dynamic_auth/.bazelignore new file mode 100644 index 000000000..3c3629e64 --- /dev/null +++ b/e2e/npm_translate_lock_dynamic_auth/.bazelignore @@ -0,0 +1 @@ +node_modules diff --git a/e2e/npm_translate_lock_dynamic_auth/.bazelrc b/e2e/npm_translate_lock_dynamic_auth/.bazelrc new file mode 100644 index 000000000..952f35413 --- /dev/null +++ b/e2e/npm_translate_lock_dynamic_auth/.bazelrc @@ -0,0 +1 @@ +try-import %workspace%/../../.aspect/workflows/bazelrc diff --git a/e2e/npm_translate_lock_dynamic_auth/.bazelversion b/e2e/npm_translate_lock_dynamic_auth/.bazelversion new file mode 120000 index 000000000..96cf94962 --- /dev/null +++ b/e2e/npm_translate_lock_dynamic_auth/.bazelversion @@ -0,0 +1 @@ +../../.bazelversion \ No newline at end of file diff --git a/e2e/npm_translate_lock_dynamic_auth/.npmrc b/e2e/npm_translate_lock_dynamic_auth/.npmrc new file mode 100644 index 000000000..71e849efa --- /dev/null +++ b/e2e/npm_translate_lock_dynamic_auth/.npmrc @@ -0,0 +1,4 @@ +hoist=false + +# This will be replaced by test script with dynamic token +_authToken=${ASPECT_NPM_AUTH_TOKEN} diff --git a/e2e/npm_translate_lock_dynamic_auth/BUILD.bazel b/e2e/npm_translate_lock_dynamic_auth/BUILD.bazel new file mode 100644 index 000000000..7691ba4fd --- /dev/null +++ b/e2e/npm_translate_lock_dynamic_auth/BUILD.bazel @@ -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", + ], +) diff --git a/e2e/npm_translate_lock_dynamic_auth/MODULE.bazel b/e2e/npm_translate_lock_dynamic_auth/MODULE.bazel new file mode 100644 index 000000000..5b79733ab --- /dev/null +++ b/e2e/npm_translate_lock_dynamic_auth/MODULE.bazel @@ -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") diff --git a/e2e/npm_translate_lock_dynamic_auth/README.md b/e2e/npm_translate_lock_dynamic_auth/README.md new file mode 100644 index 000000000..0ffb4798e --- /dev/null +++ b/e2e/npm_translate_lock_dynamic_auth/README.md @@ -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. diff --git a/e2e/npm_translate_lock_dynamic_auth/WORKSPACE b/e2e/npm_translate_lock_dynamic_auth/WORKSPACE new file mode 100644 index 000000000..6f00479c0 --- /dev/null +++ b/e2e/npm_translate_lock_dynamic_auth/WORKSPACE @@ -0,0 +1 @@ +# Marker file for Bazel diff --git a/e2e/npm_translate_lock_dynamic_auth/WORKSPACE.bzlmod b/e2e/npm_translate_lock_dynamic_auth/WORKSPACE.bzlmod new file mode 100644 index 000000000..ed18ad434 --- /dev/null +++ b/e2e/npm_translate_lock_dynamic_auth/WORKSPACE.bzlmod @@ -0,0 +1,2 @@ +# This file marks the root of the Bazel workspace. +# See MODULE.bazel for external dependencies setup with bzlmod. diff --git a/e2e/npm_translate_lock_dynamic_auth/package.json b/e2e/npm_translate_lock_dynamic_auth/package.json new file mode 100644 index 000000000..02b3ad779 --- /dev/null +++ b/e2e/npm_translate_lock_dynamic_auth/package.json @@ -0,0 +1,7 @@ +{ + "name": "npm-translate-lock-dynamic-auth-test", + "version": "0.0.0", + "dependencies": { + "@aspect-test/a": "5.0.0" + } +} diff --git a/e2e/npm_translate_lock_dynamic_auth/pnpm-lock.yaml b/e2e/npm_translate_lock_dynamic_auth/pnpm-lock.yaml new file mode 100644 index 000000000..b7d00ea31 --- /dev/null +++ b/e2e/npm_translate_lock_dynamic_auth/pnpm-lock.yaml @@ -0,0 +1,23 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@aspect-test/a': + specifier: 5.0.0 + version: 5.0.0 + +packages: + + '@aspect-test/a@5.0.0': + resolution: {integrity: sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=, tarball: https://registry.npmjs.org/@aspect-test/a/-/a-5.0.0.tgz} + engines: {node: '>=16.0.0'} + +snapshots: + + '@aspect-test/a@5.0.0': {} diff --git a/e2e/npm_translate_lock_dynamic_auth/pnpm-workspace.yaml b/e2e/npm_translate_lock_dynamic_auth/pnpm-workspace.yaml new file mode 100644 index 000000000..3334c0e43 --- /dev/null +++ b/e2e/npm_translate_lock_dynamic_auth/pnpm-workspace.yaml @@ -0,0 +1 @@ +packages: [] diff --git a/e2e/npm_translate_lock_dynamic_auth/test.sh b/e2e/npm_translate_lock_dynamic_auth/test.sh new file mode 100755 index 000000000..da487c2fa --- /dev/null +++ b/e2e/npm_translate_lock_dynamic_auth/test.sh @@ -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 <&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 <&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 <&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}" diff --git a/npm/private/npm_import.bzl b/npm/private/npm_import.bzl index d4360be35..a4f93d454 100644 --- a/npm/private/npm_import.bzl +++ b/npm/private/npm_import.bzl @@ -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") @@ -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 { + url: { + "type": "pattern", + "pattern": "Bearer ", + "password": auth_info["bearer"], + }, + } + elif "basic" in auth_info: + return { + url: { + "type": "pattern", + "pattern": "Basic ", + "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": rctx.attr.npm_auth, - }, - } - auth_count += 1 - if rctx.attr.npm_auth_basic: - auth = { - download_url: { - "type": "pattern", - "pattern": "Basic ", - "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": rctx.attr.npm_auth, + }, + } + auth_count += 1 + if rctx.attr.npm_auth_basic: + auth = { + download_url: { + "type": "pattern", + "pattern": "Basic ", + "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, @@ -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(), @@ -1022,6 +1083,8 @@ def npm_import( npm_auth_basic = "", npm_auth_username = "", npm_auth_password = "", + npmrc = None, + use_home_npmrc = None, bins = {}, dev = False, exclude_package_contents = [], @@ -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 . @@ -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) diff --git a/npm/private/test/BUILD.bazel b/npm/private/test/BUILD.bazel index 934de82a2..775046aa5 100644 --- a/npm/private/test/BUILD.bazel +++ b/npm/private/test/BUILD.bazel @@ -4,6 +4,7 @@ load("@npm//:defs.bzl", "npm_link_all_packages") load("@rules_shell//shell:sh_test.bzl", "sh_test") load(":generated_pkg_json_test.bzl", "generated_pkg_json_test") load(":npm_auth_test.bzl", "npm_auth_test_suite") +load(":npm_import_auth_test.bzl", "npm_import_auth_test_suite") load(":npm_package_visibility_test.bzl", "npm_package_visibility_tests") load(":npmrc_test.bzl", "npmrc_tests") load(":parse_pnpm_lock_tests.bzl", "parse_pnpm_lock_tests") @@ -33,6 +34,8 @@ npm_package_visibility_tests(name = "test_npm_package_visibility") npm_auth_test_suite() +npm_import_auth_test_suite() + write_source_files( name = "write_npm_translate_lock", files = { diff --git a/npm/private/test/npm_import_auth_test.bzl b/npm/private/test/npm_import_auth_test.bzl new file mode 100644 index 000000000..9aa4719f9 --- /dev/null +++ b/npm/private/test/npm_import_auth_test.bzl @@ -0,0 +1,183 @@ +"""Unit tests for npm_import dynamic auth""" + +load("@bazel_skylib//lib:partial.bzl", "partial") +load("@bazel_skylib//lib:unittest.bzl", "asserts", "unittest") + +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 { + url: { + "type": "pattern", + "pattern": "Bearer ", + "password": auth_info["bearer"], + }, + } + elif "basic" in auth_info: + return { + url: { + "type": "pattern", + "pattern": "Basic ", + "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 _no_auth_test_impl(ctx): + env = unittest.begin(ctx) + + asserts.equals( + env, + {}, + _get_auth_from_url( + "https://registry.npmjs.org/foo/-/foo-1.0.0.tgz", + {}, + ), + ) + + return unittest.end(env) + +def _bearer_token_test_impl(ctx): + env = unittest.begin(ctx) + + url = "https://example.codeartifact.us-east-1.amazonaws.com/npm/repo/foo/-/foo-1.0.0.tgz" + npm_auth = { + "example.codeartifact.us-east-1.amazonaws.com": { + "bearer": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", + }, + } + + asserts.equals( + env, + { + url: { + "type": "pattern", + "pattern": "Bearer ", + "password": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", + }, + }, + _get_auth_from_url(url, npm_auth), + ) + + return unittest.end(env) + +def _basic_auth_test_impl(ctx): + env = unittest.begin(ctx) + + url = "https://npm.pkg.github.com/@scope/package/-/package-1.0.0.tgz" + npm_auth = { + "npm.pkg.github.com": { + "basic": "dXNlcm5hbWU6cGFzc3dvcmQ=", + }, + } + + asserts.equals( + env, + { + url: { + "type": "pattern", + "pattern": "Basic ", + "password": "dXNlcm5hbWU6cGFzc3dvcmQ=", + }, + }, + _get_auth_from_url(url, npm_auth), + ) + + return unittest.end(env) + +def _username_password_test_impl(ctx): + env = unittest.begin(ctx) + + url = "https://registry.example.com/foo/-/foo-1.0.0.tgz" + npm_auth = { + "registry.example.com": { + "username": "testuser", + "password": "testpass", + }, + } + + asserts.equals( + env, + { + url: { + "type": "basic", + "login": "testuser", + "password": "testpass", + }, + }, + _get_auth_from_url(url, npm_auth), + ) + + return unittest.end(env) + +def _multiple_registries_test_impl(ctx): + env = unittest.begin(ctx) + + npm_auth = { + "registry1.com": { + "bearer": "token1", + }, + "registry2.com": { + "bearer": "token2", + }, + } + + url1 = "https://registry1.com/foo/-/foo-1.0.0.tgz" + asserts.equals( + env, + { + url1: { + "type": "pattern", + "pattern": "Bearer ", + "password": "token1", + }, + }, + _get_auth_from_url(url1, npm_auth), + ) + + url2 = "https://registry2.com/bar/-/bar-2.0.0.tgz" + asserts.equals( + env, + { + url2: { + "type": "pattern", + "pattern": "Bearer ", + "password": "token2", + }, + }, + _get_auth_from_url(url2, npm_auth), + ) + + url3 = "https://registry3.com/baz/-/baz-3.0.0.tgz" + asserts.equals( + env, + {}, + _get_auth_from_url(url3, npm_auth), + ) + + return unittest.end(env) + +no_auth_test = unittest.make(_no_auth_test_impl) +bearer_token_test = unittest.make(_bearer_token_test_impl) +basic_auth_test = unittest.make(_basic_auth_test_impl) +username_password_test = unittest.make(_username_password_test_impl) +multiple_registries_test = unittest.make(_multiple_registries_test_impl) + +def npm_import_auth_test_suite(): + unittest.suite( + "npm_import_auth_tests", + partial.make(no_auth_test, timeout = "short"), + partial.make(bearer_token_test, timeout = "short"), + partial.make(basic_auth_test, timeout = "short"), + partial.make(username_password_test, timeout = "short"), + partial.make(multiple_registries_test, timeout = "short"), + )