一、问题现象
前端项目在 Docker 构建阶段执行 npm ci 安装私有依赖包(私有 scope @scope/*,
托管在内网 Artifactory 上)时失败,报认证错误:
npm error code E401
npm error Incorrect or missing password.
...
process "/bin/sh -c npm config set //<ARTIFACTORY_HOST>/.../<PRIVATE_NPM_REPO>/:_authToken
$(cat /run/secrets/artifactory-token) && npm ci ..." did not complete successfully: exit code: 1
迷惑点:报错是 Incorrect or missing password,看起来像 token 无效。但实际上
token 是有效且有下载权限的——问题出在「怎么把 token 交给 npm」,而不是 token 本身。
二、认证机制背景
安装私有包需要三件事同时正确:
- 一个有效的 token:属于某个对私有仓库有「读/下载」权限的账号。
- 把 token 交给 npm 的正确方式:让 npm 发出的每个请求都带上 token。
- 正确的配置语法:npm 对「在哪里、怎么写」token 很敏感。
关键技术点:npm 会发两种请求
| 请求类型 | 使用的 URL | 说明 |
|---|---|---|
| 元数据(metadata) | scope registry 的 URL | 查询包信息 |
| 压缩包(tarball) | package-lock.json 里记录的绝对 URL |
下载实际文件 |
token 必须对两种请求都生效。本次问题的核心正是:某种配置方式没能把 token
应用到 tarball 请求上。
三、排查:逐一排除常见原因
3.1 token 是否失效?
用 curl 手动带上 token 直接请求失败的那个 tarball:
curl -s -o /dev/null -w "%{http_code}\n" \
-H "Authorization: Bearer <TOKEN>" \
"https://<ARTIFACTORY_HOST>/artifactory/api/npm/<PRIVATE_NPM_REPO>/@scope/private-ui-lib/-/@scope/private-ui-lib-<VER>.tgz"
结果 HTTP 200(成功)。说明 token 和文件都没问题。
3.2 是否权限问题?
排查早期,CI 账号对该仓库返回 404。Artifactory 的特性:token 有效但无读权限
时,会故意返回 404(而非 403),以免暴露资源是否存在。管理员补齐权限后,同样请求
返回 200。权限问题排除。
小结:
401= 「密码不对 / 没带密码」;404= 「可能没权限」或「根本没带认证」。
同样是构建失败,根因可能完全不同。
3.3 是否缓存问题?
是,而且严重干扰了早期判断。 npm 会把下载结果(甚至失败响应)缓存在本地~/.npm/_cacache。早期用错误 token 时缓存了 401/404 的失败响应,之后即使换上
正确 token,仍可能命中旧的失败缓存,导致结果「时好时坏、自相矛盾」。
此外还发现一个隐藏污染源:命令执行的当前目录若在某个项目内,npm 会向上查找并读取
该项目的 .npmrc(其中可能含过期 token)当作配置。
四、受控实验
为得到可信结论,在严格隔离下重跑了完整矩阵:2 种方式 × 3 个项目 × 2 个账号 = 12 次。
4.1 对比的两种方式
方式 A —— 用 npm config set 设置 token(部分项目的 Dockerfile 现状):
RUN \
npm config set //<ARTIFACTORY_HOST>/.../<PRIVATE_NPM_REPO>/:_authToken $(cat /run/secrets/artifactory-token) && \
npm ci
方式 B —— 把 token 写进 .npmrc,通过环境变量传入(修复方案):
RUN \
export ARTIFACTORY_TOKEN=$(cat /run/secrets/artifactory-token) && \
npm ci
且 .npmrc 中写(注意是 = 等号):
//<ARTIFACTORY_HOST>/.../<PRIVATE_NPM_REPO>/:_authToken=${ARTIFACTORY_TOKEN}
4.2 隔离规则(早期结论为何不可信)
| 规则 | 不做会怎样 |
|---|---|
每轮物理删缓存 rm -rf ~/.npm/_cacache ~/.npm/_logs(光 npm cache clean 不够) |
早期缓存的 401/404 失败响应被重放,结果变随机 |
在 /tmp 的独立目录里跑,不在任何项目内 |
npm 向上读到「隔壁项目」的 .npmrc(含旧 token)当配置 |
每轮 unset ARTIFACTORY_TOKEN |
shell 里残留的旧值被误用 |
用各项目自己真实的 package.json/package-lock.json/.npmrc |
「空格 vs 等号」差异只有用真实文件才暴露 |
4.3 结果(方式对比:前端A 与 前端C)
说明:完整矩阵共 12 次。本节为了干净地只对比「方式 A vs B」这一个变量,
仅展示.npmrc本身写法正确(用=)的两个项目——前端A、前端C。前端B 因为还
掺入了「空格 vs 等号」这第二个变量,会干扰方式对比,其数据单独放在第五节分析。
数据没有省略,只是按「展示哪个变量」分组。
这两个项目的 .npmrc 本身写法正确(用 =),所以唯一变量是「方式」:
| 项目 | 方式 | 账号1 | 账号2 |
|---|---|---|---|
| 前端A | A(npm config set) | ❌ E401 | ❌ E401 |
| 前端A | B(export + .npmrc) | ✅ 成功 | ✅ 成功 |
| 前端C | A(npm config set) | ❌ E401 | ❌ E401 |
| 前端C | B(export + .npmrc) | ✅ 成功 | ✅ 成功 |
结论:方式 A 必然 E401,方式 B 成功;换账号结论不变 → 不是 token 问题,是方式问题。
4.4 复现命令
TOKEN="<你的有效TOKEN>"
# 第0步:先独立验证token(把token/权限问题和npm配置问题分开)
curl -s -o /dev/null -w "token check: HTTP %{http_code}\n" \
-H "Authorization: Bearer $TOKEN" \
"https://<ARTIFACTORY_HOST>/artifactory/api/npm/<PRIVATE_NPM_REPO>/@scope/private-ui-lib/-/@scope/private-ui-lib-<VER>.tgz"
# 期望 200。若 404=账号无权限;若 401=token本身无效。
# 可复用的测试函数(内置全部隔离规则)
probe() {
method="$1"; name="$2"; dir="$3"
cd /tmp && rm -rf "run_$name" && mkdir "run_$name" && cd "run_$name"
cp "$dir/package.json" . ; cp "$dir/package-lock.json" . ; cp "$dir/.npmrc" .
rm -rf ~/.npm/_cacache ~/.npm/_logs 2>/dev/null
rm -f ~/.npmrc 2>/dev/null
if [ "$method" = "export" ]; then
env -u ARTIFACTORY_TOKEN sh -c "export ARTIFACTORY_TOKEN=$TOKEN && npm ci --quiet" > /tmp/out_$name.txt 2>&1
else
env -u ARTIFACTORY_TOKEN sh -c "
npm config set //<ARTIFACTORY_HOST>/artifactory/api/npm/<PRIVATE_NPM_REPO>/:_authToken $TOKEN
npm ci --quiet
" > /tmp/out_$name.txt 2>&1
rm -f ~/.npmrc
fi
echo "[$method | $name] exit=$? :: $(grep -oE 'E401|E404|added [0-9]+ packages' /tmp/out_$name.txt | head -1)"
}
# 运行方式对比(换第二个 TOKEN 再跑一遍,可验证与账号无关)
probe export projA_B <前端A项目目录> # 期望:added N packages
probe configset projA_A <前端A项目目录> # 期望:E401
probe export projC_B <前端C项目目录> # 期望:added N packages
probe configset projC_A <前端C项目目录> # 期望:E401
五、第二个独立缺陷:.npmrc 里等号写成了空格
在测试中发现另一个完全独立的问题,只在「前端B」上出现:它的 .npmrc 用的是
空格,而不是等号。
| 项目 | .npmrc 的 authToken 行 |
|
|---|---|---|
| 前端B | ...:_authToken ${ARTIFACTORY_TOKEN} |
← 空格(错误) |
| 前端A | ...:_authToken=${ARTIFACTORY_TOKEN} |
← 等号(正确) |
| 前端C | ...:_authToken=${ARTIFACTORY_TOKEN} |
← 等号(正确) |
5.1 为什么一个字符就坏事
.npmrc 是 key = value 格式。用空格时,npm 不会展开 ${ARTIFACTORY_TOKEN}
这个占位符,token 变成空值 → 请求以匿名发出 → Artifactory 对私有包返回
404(不是 401)。
这解释了为什么有时 401、有时 404:
- 401 = 带了 token 但不被接受(方式 A 的缺陷)
- 404 = 根本没带 token,匿名请求(空格的缺陷)
5.2 一个字符的证明
同项目、同 token、同方式 B,只改空格/等号:
[equals] exit=0 :: added N packages # 等号 -> 成功
[space] exit=1 :: E404 # 空格 -> 404
快速自查:
grep '_authToken' <项目>/.npmrc
# 坏 : ...:_authToken ${ARTIFACTORY_TOKEN} (空格 -> E404)
# 好 : ...:_authToken=${ARTIFACTORY_TOKEN} (等号 -> 正常)
六、两个缺陷分别影响什么环境
缺陷1:Dockerfile 用 npm config set |
缺陷2:.npmrc 里是空格 |
|
|---|---|---|
| 报错 | E401 |
E404 |
| 影响 | Docker 镜像构建(CI 和本地 docker build) |
开发者本地 npm install(以及用方式B的构建) |
| 不影响 | 开发者本地安装(本地读 .npmrc,不读 Dockerfile) |
用方式A的构建(它绕开了 .npmrc) |
| 何时触发 | 只有当构建的 npm ci 那层真正重跑时(缓存失效) |
立即、每次,只要依赖 ${ARTIFACTORY_TOKEN} 占位符 |
各项目状态
| 项目 | 本地 npm install(读 .npmrc) |
Docker/CI 构建(读 Dockerfile) |
|---|---|---|
| 前端A | ✅ 正常(.npmrc 用 =) |
✅ 正常(已改成方式 B) |
| 前端C | ✅ 正常(.npmrc 用 =) |
⚠️ 潜在故障(方式 A;靠缓存苟活) |
| 前端B | ❌ 当前就坏(.npmrc 空格 → E404) |
⚠️ 潜在故障(方式 A;靠缓存苟活) |
七、根因与修复
根因(两个独立的配置问题,都和 token/Artifactory/包本身无关):
- 缺陷1:
npm config set ...:_authToken设的 token 不会被 npm 应用到package-lock.json里私有包的绝对 tarball URL → 下载匿名 → E401(影响构建)。 - 缺陷2:
.npmrc里用空格导致${ARTIFACTORY_TOKEN}不展开 → token 为空
→ 匿名 → E404(影响本地安装)。
统一修复方案(方式 B):
.npmrc用等号声明://<ARTIFACTORY_HOST>/.../<PRIVATE_NPM_REPO>/:_authToken=${ARTIFACTORY_TOKEN}- Dockerfile 改成
export ARTIFACTORY_TOKEN=$(cat /run/secrets/...) && npm ci,
由.npmrc负责把 token 应用到所有请求。
CI 流水线脚本无需改动:它通过构建密钥(BuildKit secret)传 token,secret 的
id 不变,Dockerfile 内部怎么用 secret 对流水线透明。
八、通俗解释(大白话版)
想象你要去一个会员制仓库取两样东西:先在前台查「有没有货」(元数据),再去
货架搬「实际的箱子」(压缩包)。进门都要出示会员卡(token)。
缺陷1(E401):你用的方式只在前台查货时出示了卡,搬箱子时忘了出示,
于是保安把你拦下——「密码不对」。箱子的地址是写死在清单(lock 文件)上的绝对地址,
偏偏这种出卡方式对这个地址不生效。缺陷2(E404):你的会员卡号写在一张纸条上,但纸条格式写错了(该用等号却用了空格),
结果卡号没被读出来,等于你压根没带卡。保安假装「没这东西」把你打发走——这就是 404。
为什么有人用着没事? 因为仓库门口有个寄存柜(缓存)。如果你要的箱子之前
已经有人取过、放在寄存柜里,你直接拿就行,不用再出示会员卡。所以配置虽然有毛病,
只要寄存柜里恰好有货,就「看起来正常」。一旦寄存柜清空(换机器、清缓存、新同事第一次装),
毛病立刻暴露。
正确做法:把会员卡号用正确格式写在随身证件里(.npmrc 用等号 + 环境变量),
这样无论查货还是搬箱子,每次都会自动出示——两个毛病一起根治。