From 5b57509d7b60c8a08b893871b23381fe8e6fd208 Mon Sep 17 00:00:00 2001 From: Aschenbath Date: Tue, 20 Oct 2026 15:54:44 +0800 Subject: [PATCH] feat: reuse registry-scoped npmrc authentication --- README.md | 15 +- sources/httpUtils.ts | 64 ++++---- sources/npmConfigUtils.ts | 119 ++++++++++++++ sources/npmRegistryUtils.ts | 12 +- tests/_registryServer.mjs | 27 +++ tests/httpUtils.test.ts | 289 +++++++++++++++++++++++++++++++++ tests/main.test.ts | 2 +- tests/npmRegistryUtils.test.ts | 55 ------- tests/recordRequests.js | 5 +- 9 files changed, 491 insertions(+), 97 deletions(-) create mode 100644 sources/npmConfigUtils.ts create mode 100644 tests/httpUtils.test.ts diff --git a/README.md b/README.md index e2adeb949..911850145 100644 --- a/README.md +++ b/README.md @@ -352,12 +352,15 @@ same major line. Should you need to upgrade to a new major, use an explicit package managers from npm. Default value is `https://registry.npmjs.org` - `COREPACK_NPM_TOKEN` sets a Bearer token authorization header when connecting - to a npm type registry. + to the registry configured by `COREPACK_NPM_REGISTRY`. It takes precedence + over Basic authentication and `.npmrc` credentials. - `COREPACK_NPM_USERNAME` and `COREPACK_NPM_PASSWORD` to set a Basic authorization header when connecting to a npm type registry. Note that both environment variables are required and as plain text. If you want to send an empty password, explicitly set `COREPACK_NPM_PASSWORD` to an empty string. + These credentials take precedence over `.npmrc` credentials. Both token and + Basic credentials are restricted to the configured registry's origin and path. - `COREPACK_ON_UNVERIFIED_DOWNLOAD` can be set to: - `warn` (case insensitive): attempting to download an unsigned version without @@ -379,6 +382,16 @@ same major line. Should you need to upgrade to a new major, use an explicit instruct Corepack to skip integrity checks, or to a JSON string containing custom keys. +Corepack also reads registry-scoped authentication from the project's `.npmrc` +and the user's `~/.npmrc` (or `npm_config_userconfig` / `NPM_CONFIG_USERCONFIG`). +Project settings override user settings; the most specific matching registry +path is used. Supported settings are `_authToken`, `_auth`, and `username` with +a base64-encoded `_password`, scoped like `//registry.example.com/npm/:_authToken`. +Values may reference environment variables with `${VARIABLE_NAME}`. These +credentials apply to both package metadata and tarball downloads, including +redirects, only within their registry host, port, and path. Unscoped credentials +are ignored. + ## Troubleshooting The environment variable `DEBUG` can be set to `corepack` to enable additional debug logging. diff --git a/sources/httpUtils.ts b/sources/httpUtils.ts index 487443a8e..1966ba45a 100644 --- a/sources/httpUtils.ts +++ b/sources/httpUtils.ts @@ -1,39 +1,25 @@ -import assert from 'assert'; -import {UsageError} from 'clipanion'; -import {once} from 'events'; -import {stderr, stdin} from 'process'; -import {Readable} from 'stream'; +import assert from 'assert'; +import {UsageError} from 'clipanion'; +import {once} from 'events'; +import {stderr, stdin} from 'process'; +import {Readable} from 'stream'; -import {DEFAULT_NPM_REGISTRY_URL} from './npmRegistryUtils.ts'; +import {getAuthentication, matchesRegistry} from './npmConfigUtils.ts'; -async function fetch(input: string | URL, init?: RequestInit) { +async function fetch(input: string | URL, init?: RequestInit, redirects = 0, authorizationScope?: URL): Promise { if (process.env.COREPACK_ENABLE_NETWORK === `0`) throw new UsageError(`Network access disabled by the environment; can't reach ${input}`); - if (typeof input === `string`) - input = new URL(input); + input = new URL(input); + authorizationScope ??= new URL(`.`, input); let headers = init?.headers; - const username: string | undefined = input.username || process.env.COREPACK_NPM_USERNAME; - const password: string | undefined = input.password || process.env.COREPACK_NPM_PASSWORD; - - if (username || password) { - headers = { - ...headers, - authorization: `Basic ${Buffer.from(`${username}:${password}`).toString(`base64`)}`, - }; - - input.username = input.password = ``; - } - - const registry = process.env.COREPACK_NPM_TOKEN && new URL(process.env.COREPACK_NPM_REGISTRY || DEFAULT_NPM_REGISTRY_URL); - - if (registry && input.origin === registry.origin) { - headers = { - ...headers, - authorization: `Bearer ${process.env.COREPACK_NPM_TOKEN}`, - }; + const authorization = await getAuthentication(input); + input.username = input.password = ``; + if (authorization !== undefined) { + headers = new Headers(headers); + headers.set(`authorization`, authorization); } let response; @@ -41,6 +27,7 @@ async function fetch(input: string | URL, init?: RequestInit) { response = await globalThis.fetch(input, { ...init, headers, + redirect: `manual`, }); } catch (error) { throw new Error( @@ -49,6 +36,27 @@ async function fetch(input: string | URL, init?: RequestInit) { ); } + if ([301, 302, 303, 307, 308].includes(response.status) && response.headers.has(`location`) && init?.redirect !== `manual`) { + await response.arrayBuffer(); + if (init?.redirect === `error`) + throw new Error(`Unexpected redirect when performing the request to ${input}`); + if (redirects === 20) + throw new Error(`Too many redirects when performing the request to ${input}`); + const target = new URL(response.headers.get(`location`)!, input); + const redirectHeaders = new Headers(init?.headers); + if (!matchesRegistry(target, authorizationScope)) + redirectHeaders.delete(`authorization`); + + const method = init?.method?.toUpperCase() ?? `GET`; + if (((response.status === 301 || response.status === 302) && method === `POST`) || (response.status === 303 && method !== `GET` && method !== `HEAD`)) { + init = {...init, method: `GET`, body: undefined}; + for (const header of [`content-encoding`, `content-language`, `content-location`, `content-type`, `content-length`]) { + redirectHeaders.delete(header); + } + } + return fetch(target, {...init, headers: redirectHeaders}, redirects + 1, authorizationScope); + } + if (!response.ok) { await response.arrayBuffer(); throw new Error( diff --git a/sources/npmConfigUtils.ts b/sources/npmConfigUtils.ts new file mode 100644 index 000000000..eb6e260c1 --- /dev/null +++ b/sources/npmConfigUtils.ts @@ -0,0 +1,119 @@ +import {Buffer} from 'node:buffer'; +import {readFile, stat} from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; + +import {isNodeError} from './nodeUtils.ts'; +import {DEFAULT_NPM_REGISTRY_URL} from './npmRegistryUtils.ts'; + +export function matchesRegistry(url: URL, registry: URL) { + const pathname = `${registry.pathname.replace(/\/+$/, ``)}/`; + return url.origin === registry.origin && (url.pathname === pathname.slice(0, -1) || url.pathname.startsWith(pathname)); +} + +async function readConfig(filename: string) { + try { + return await readFile(filename, `utf8`); + } catch (error) { + if (isNodeError(error) && (error.code === `ENOENT` || error.code === `ENOTDIR`)) + return ``; + throw error; + } +} + +async function findProjectConfig() { + // Dependencies must not supply credentials for the enclosing project. + let cwd = process.cwd().split(/[\\/]node_modules(?:[\\/]|$)/)[0]; + while (true) { + const config = await readConfig(path.join(cwd, `.npmrc`)); + if (config) + return config; + + try { + await stat(path.join(cwd, `package.json`)); + return ``; + } catch (error) { + if (!isNodeError(error) || (error.code !== `ENOENT` && error.code !== `ENOTDIR`)) { + throw error; + } + } + + const parent = path.dirname(cwd); + if (parent === cwd) + return ``; + cwd = parent; + } +} + +function parseConfig(content: string, config: Map) { + for (const line of content.split(/\r?\n/)) { + const match = line.match(/^\s*(\/\/.+\/:(_authToken|_auth|username|_password))\s*=\s*(.*?)\s*$/); + if (!match) + continue; + + let value = match[3]; + if ((value.startsWith(`"`) && value.endsWith(`"`)) || (value.startsWith(`'`) && value.endsWith(`'`))) + value = value.slice(1, -1); + else + value = value.replace(/\s+[;#].*$/, ``).trim(); + + let unresolved = false; + value = value.replace(/(\\*)\$\{([^}]+)\}/g, (match, escapes: string, name: string) => { + if (escapes.length % 2 === 1) + return match.slice(1); + const replacement = process.env[name]; + if (replacement === undefined) + unresolved = true; + return escapes + (replacement ?? ``); + }); + config.set(match[1], unresolved ? undefined : value); + } +} + +export async function getAuthentication(url: URL): Promise { + const registry = new URL(process.env.COREPACK_NPM_REGISTRY || DEFAULT_NPM_REGISTRY_URL); + if (matchesRegistry(url, registry)) { + if (`COREPACK_NPM_TOKEN` in process.env) + return `Bearer ${process.env.COREPACK_NPM_TOKEN}`; + if (`COREPACK_NPM_USERNAME` in process.env && `COREPACK_NPM_PASSWORD` in process.env) { + return `Basic ${Buffer.from(`${process.env.COREPACK_NPM_USERNAME}:${process.env.COREPACK_NPM_PASSWORD}`).toString(`base64`)}`; + } + } + + if (url.username || url.password) + return `Basic ${Buffer.from(`${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`).toString(`base64`)}`; + + if (matchesRegistry(url, registry) && (registry.username || registry.password)) + return `Basic ${Buffer.from(`${decodeURIComponent(registry.username)}:${decodeURIComponent(registry.password)}`).toString(`base64`)}`; + + const config = new Map(); + const userConfig = process.env.npm_config_userconfig || process.env.NPM_CONFIG_USERCONFIG || path.join(os.homedir(), `.npmrc`); + parseConfig(await readConfig(userConfig), config); + parseConfig(await findProjectConfig(), config); + + const scopes = new Set(Array.from(config.keys(), key => key.slice(0, key.lastIndexOf(`:`)))); + for (const scope of [...scopes].sort((a, b) => b.length - a.length)) { + let scopedRegistry; + try { + scopedRegistry = new URL(`${url.protocol}${scope}`); + } catch { + continue; + } + if (scopedRegistry.username || scopedRegistry.password || scopedRegistry.search || scopedRegistry.hash || !matchesRegistry(url, scopedRegistry)) + continue; + + const token = config.get(`${scope}:_authToken`); + if (token) + return `Bearer ${token}`; + const auth = config.get(`${scope}:_auth`); + if (auth) + return `Basic ${auth}`; + const username = config.get(`${scope}:username`); + const password = config.get(`${scope}:_password`); + if (username !== undefined && password !== undefined) { + return `Basic ${Buffer.from(`${username}:${Buffer.from(password, `base64`).toString(`utf8`)}`).toString(`base64`)}`; + } + } + return undefined; +} diff --git a/sources/npmRegistryUtils.ts b/sources/npmRegistryUtils.ts index 706046be2..2b79f517b 100644 --- a/sources/npmRegistryUtils.ts +++ b/sources/npmRegistryUtils.ts @@ -22,17 +22,7 @@ export async function fetchAsJson(packageName: string, version?: string) { if (process.env.COREPACK_ENABLE_NETWORK === `0`) throw new UsageError(`Network access disabled by the environment; can't reach npm repository ${npmRegistryUrl}`); - const headers = {...DEFAULT_HEADERS}; - - if (`COREPACK_NPM_TOKEN` in process.env) { - headers.authorization = `Bearer ${process.env.COREPACK_NPM_TOKEN}`; - } else if (`COREPACK_NPM_USERNAME` in process.env - && `COREPACK_NPM_PASSWORD` in process.env) { - const encodedCreds = Buffer.from(`${process.env.COREPACK_NPM_USERNAME}:${process.env.COREPACK_NPM_PASSWORD}`, `utf8`).toString(`base64`); - headers.authorization = `Basic ${encodedCreds}`; - } - - return httpUtils.fetchAsJson(`${npmRegistryUrl}/${packageName}${version ? `/${version}` : ``}`, {headers}); + return httpUtils.fetchAsJson(`${npmRegistryUrl}/${packageName}${version ? `/${version}` : ``}`, {headers: DEFAULT_HEADERS}); } export async function verifySignature({signatures, integrity, packageName, version}: { diff --git a/tests/_registryServer.mjs b/tests/_registryServer.mjs index cb4553d04..b80d33b8a 100644 --- a/tests/_registryServer.mjs +++ b/tests/_registryServer.mjs @@ -1,7 +1,9 @@ import {createHash, createSign, generateKeyPairSync} from 'node:crypto'; import {once} from 'node:events'; +import {writeFile} from 'node:fs/promises'; import {createServer} from 'node:http'; import {connect} from 'node:net'; +import path from 'node:path'; import {gzipSync} from 'node:zlib'; let privateKey, keyid; @@ -113,6 +115,14 @@ const TOKEN_MOCK = `SOME_DUMMY_VALUE`; const server = createServer((req, res) => { res.setHeader(`connection`, `close`); + if (process.env.AUTH_TYPE.startsWith(`NPMRC_`)) { + if (!req.url.startsWith(`/npm/`)) { + res.writeHead(404).end(`Not Found`); + return; + } + req.url = req.url.slice(`/npm`.length); + } + const auth = req.headers.authorization; if ( @@ -224,6 +234,23 @@ switch (process.env.AUTH_TYPE) { process.env.COREPACK_NPM_PASSWORD = `pass`; break; + case `NPMRC_PROJECT_TOKEN`: + case `NPMRC_USER_TOKEN`: + case `NPMRC_PROJECT_BASIC`: + case `NPMRC_USER_BASIC`: { + process.env.COREPACK_NPM_REGISTRY = `http://${address.includes(`:`) ? `[${address}]` : address}:${port}/npm`; + const registryUrl = new URL(process.env.COREPACK_NPM_REGISTRY); + const registryScope = `//${registryUrl.host}${registryUrl.pathname}/`; + const filename = process.env.AUTH_TYPE.includes(`USER`) ? path.join(process.cwd(), `user.npmrc`) : path.join(process.cwd(), `.npmrc`); + if (process.env.AUTH_TYPE.includes(`USER`)) + process.env.npm_config_userconfig = filename; + process.env.TEST_NPM_TOKEN = TOKEN_MOCK; + await writeFile(filename, process.env.AUTH_TYPE.endsWith(`TOKEN`) ? + `${registryScope}:_authToken=\${TEST_NPM_TOKEN}\n` : + `${registryScope}:username=user\n${registryScope}:_password=cGFzcw==\n`); + break; + } + default: throw new Error(`Invalid AUTH_TYPE in env`, {cause: process.env.AUTH_TYPE}); } diff --git a/tests/httpUtils.test.ts b/tests/httpUtils.test.ts new file mode 100644 index 000000000..83a925f1d --- /dev/null +++ b/tests/httpUtils.test.ts @@ -0,0 +1,289 @@ +import {Buffer} from 'node:buffer'; +import {mkdtemp, mkdir, rm, writeFile} from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +import {fetchAsJson, fetchUrlStream} from '../sources/httpUtils.ts'; +import {fetchAsJson as fetchMetadata} from '../sources/npmRegistryUtils.ts'; + +describe(`HTTP registry authentication`, () => { + let fixture: string; + const request = vi.fn(); + + beforeEach(async () => { + fixture = await mkdtemp(path.join(__dirname, `.npmrc-auth-`)); + const project = path.join(fixture, `project`); + await mkdir(project); + await writeFile(path.join(project, `package.json`), `{}`); + process.env.npm_config_userconfig = path.join(fixture, `user.npmrc`); + process.env.COREPACK_NPM_REGISTRY = `https://registry.example.com/npm/`; + vi.spyOn(process, `cwd`).mockReturnValue(project); + request.mockReset().mockImplementation(async () => new Response(`{}`)); + vi.stubGlobal(`fetch`, request); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + await rm(fixture, {recursive: true, force: true}); + }); + + async function writeProjectConfig(content: string) { + await writeFile(path.join(fixture, `project`, `.npmrc`), content); + } + + function authorization(index = 0) { + return new Headers(request.mock.calls[index][1]?.headers).get(`authorization`); + } + + it.each([`metadata`, `tarball`])(`reads a user token for %s requests`, async kind => { + await writeFile(process.env.npm_config_userconfig!, `//registry.example.com/npm/:_authToken=user-token\n`); + + if (kind === `metadata`) { + await fetchMetadata(`pnpm`, `latest`); + expect(new Headers(request.mock.calls[0][1]?.headers).get(`accept`)).toContain(`application/vnd.npm.install-v1+json`); + } else { + const stream = await fetchUrlStream(`https://registry.example.com/npm/pnpm/-/pnpm.tgz`); + for await (const chunk of stream) { + expect(chunk.toString()).toBe(`{}`); + } + } + + expect(authorization()).toBe(`Bearer user-token`); + }); + + it(`reads the uppercase user config override`, async () => { + process.env.NPM_CONFIG_USERCONFIG = process.env.npm_config_userconfig; + delete process.env.npm_config_userconfig; + await writeFile(process.env.NPM_CONFIG_USERCONFIG!, `//registry.example.com/npm/:_authToken=user-token`); + await fetchMetadata(`pnpm`); + expect(authorization()).toBe(`Bearer user-token`); + }); + + it(`lets project config override user config from a project subdirectory`, async () => { + await writeFile(process.env.npm_config_userconfig!, `//registry.example.com/npm/:_authToken=user-token`); + await writeProjectConfig(`//registry.example.com/npm/:_authToken=project-token`); + vi.mocked(process.cwd).mockReturnValue(path.join(fixture, `project`, `src`)); + await fetchMetadata(`pnpm`); + expect(authorization()).toBe(`Bearer project-token`); + }); + + it(`ignores dependency config and uses the enclosing project`, async () => { + const dependency = path.join(fixture, `project`, `node_modules`, `dependency`); + await mkdir(dependency, {recursive: true}); + await writeFile(path.join(dependency, `.npmrc`), `//registry.example.com/npm/:_authToken=dependency-token`); + await writeProjectConfig(`//registry.example.com/npm/:_authToken=project-token`); + vi.mocked(process.cwd).mockReturnValue(dependency); + await fetchMetadata(`pnpm`); + expect(authorization()).toBe(`Bearer project-token`); + }); + + it(`stops searching at a project boundary`, async () => { + await writeFile(path.join(fixture, `.npmrc`), `//registry.example.com/npm/:_authToken=parent-token`); + await fetchMetadata(`pnpm`); + expect(authorization()).toBeNull(); + }); + + it(`uses the default user config in the home directory`, async () => { + delete process.env.npm_config_userconfig; + delete process.env.NPM_CONFIG_USERCONFIG; + vi.spyOn(os, `homedir`).mockReturnValue(fixture); + await writeFile(path.join(fixture, `.npmrc`), `//registry.example.com/npm/:_authToken=home-token`); + await fetchMetadata(`pnpm`); + expect(authorization()).toBe(`Bearer home-token`); + }); + + it(`prefers the most specific registry path`, async () => { + await writeProjectConfig(`//registry.example.com/npm/private/:_authToken=private-token\n//registry.example.com/npm/:_authToken=registry-token`); + await fetchAsJson(`https://registry.example.com/npm/private/pnpm`); + await fetchMetadata(`pnpm`); + expect(authorization()).toBe(`Bearer private-token`); + expect(authorization(1)).toBe(`Bearer registry-token`); + }); + + it.each([ + [`_auth=dXNlcjpwYXNz`, `Basic dXNlcjpwYXNz`], + [`username=user\n//registry.example.com/npm/:_password=cGFzcw==`, `Basic dXNlcjpwYXNz`], + [`username=user\n//registry.example.com/npm/:_password=`, `Basic dXNlcjo=`], + ])(`supports npmrc Basic authentication: %s`, async (config, expected) => { + await writeProjectConfig(`//registry.example.com/npm/:${config}`); + await fetchMetadata(`pnpm`); + expect(authorization()).toBe(expected); + }); + + it(`interpolates environment variables in quoted tokens and Basic credentials`, async () => { + process.env.TEST_NPM_TOKEN = `test-token`; + process.env.TEST_NPM_USERNAME = `user`; + process.env.TEST_NPM_PASSWORD = `cGFzcw==`; + await writeProjectConfig(`; comment\n//registry.example.com/npm/:_authToken="\${TEST_NPM_TOKEN}"\n//registry.example.com/basic/:username=\${TEST_NPM_USERNAME}\n//registry.example.com/basic/:_password=\${TEST_NPM_PASSWORD} # comment`); + await fetchMetadata(`pnpm`); + await fetchAsJson(`https://registry.example.com/basic/pnpm`); + expect(authorization()).toBe(`Bearer test-token`); + expect(authorization(1)).toBe(`Basic dXNlcjpwYXNz`); + }); + + it(`does not send unresolved environment variables or unscoped credentials`, async () => { + delete process.env.TEST_MISSING_NPM_TOKEN; + await writeProjectConfig(`_authToken=global-token\n_auth=dXNlcjpwYXNz\nusername=user\n_password=cGFzcw==\n//registry.example.com/npm/:_authToken=\${TEST_MISSING_NPM_TOKEN}`); + await fetchMetadata(`pnpm`); + expect(authorization()).toBeNull(); + }); + + it(`requires both npmrc username and password`, async () => { + await writeProjectConfig(`//registry.example.com/npm/:username=user`); + await fetchMetadata(`pnpm`); + expect(authorization()).toBeNull(); + }); + + it(`ignores malformed registry scopes`, async () => { + await writeProjectConfig(`//user:pass@registry.example.com/npm/:_authToken=invalid-token\n//registry.example.com/npm/?query/:_authToken=invalid-token\n//registry.example.com/npm/#fragment/:_authToken=invalid-token\n//[invalid/npm/:_authToken=invalid-token`); + await fetchMetadata(`pnpm`); + expect(authorization()).toBeNull(); + }); + + it.each([`token`, `Basic`])(`prefers explicit %s credentials over npmrc`, async kind => { + await writeProjectConfig(`//registry.example.com/npm/:_authToken=project-token`); + process.env.COREPACK_NPM_USERNAME = `user`; + process.env.COREPACK_NPM_PASSWORD = `pass`; + if (kind === `token`) + process.env.COREPACK_NPM_TOKEN = `explicit-token`; + await fetchMetadata(`pnpm`); + await fetchUrlStream(`https://registry.example.com/npm/pnpm.tgz`); + expect(authorization()).toBe(kind === `token` ? `Bearer explicit-token` : `Basic dXNlcjpwYXNz`); + expect(authorization(1)).toBe(authorization()); + }); + + it(`allows an explicitly empty Basic password`, async () => { + process.env.COREPACK_NPM_USERNAME = `user`; + process.env.COREPACK_NPM_PASSWORD = ``; + await fetchMetadata(`pnpm`); + expect(authorization()).toBe(`Basic dXNlcjo=`); + }); + + it(`falls back to npmrc when explicit Basic credentials are incomplete`, async () => { + process.env.COREPACK_NPM_USERNAME = `user`; + await writeProjectConfig(`//registry.example.com/npm/:_authToken=project-token`); + await fetchMetadata(`pnpm`); + expect(authorization()).toBe(`Bearer project-token`); + }); + + it.each([`npmrc`, `token`, `Basic`, `registry URL`])(`restricts %s credentials to the registry host, port, and path`, async kind => { + if (kind === `npmrc`) { + await writeProjectConfig(`//registry.example.com/npm/:_authToken=project-token`); + } else if (kind === `token`) { + process.env.COREPACK_NPM_TOKEN = `explicit-token`; + } else if (kind === `Basic`) { + process.env.COREPACK_NPM_USERNAME = `user`; + process.env.COREPACK_NPM_PASSWORD = `pass`; + } else { + process.env.COREPACK_NPM_REGISTRY = `https://user:pass@registry.example.com/npm/`; + } + + for (const url of [ + `https://other.example.com/npm/pnpm`, + `https://registry.example.com.evil.example/npm/pnpm`, + `https://registry.example.com:8443/npm/pnpm`, + `https://registry.example.com/other/pnpm`, + `https://registry.example.com/npm-other/pnpm`, + `https://registry.example.com/npm/../other/pnpm`, + ]) { + await fetchAsJson(url); + expect(authorization(request.mock.calls.length - 1)).toBeNull(); + } + await fetchMetadata(`pnpm`); + expect(authorization(request.mock.calls.length - 1)).not.toBeNull(); + }); + + it.each([`token`, `Basic`])(`does not send explicit %s credentials over another protocol`, async kind => { + if (kind === `token`) { + process.env.COREPACK_NPM_TOKEN = `explicit-token`; + } else { + process.env.COREPACK_NPM_USERNAME = `user`; + process.env.COREPACK_NPM_PASSWORD = `pass`; + } + await fetchAsJson(`http://registry.example.com/npm/pnpm`); + expect(authorization()).toBeNull(); + }); + + it(`decodes URL credentials without mutating the caller's URL`, async () => { + const url = new URL(`https://user%40example.com:p%40ss@other.example.com/package`); + await fetchAsJson(url); + expect(authorization()).toBe(`Basic ${Buffer.from(`user@example.com:p@ss`).toString(`base64`)}`); + expect(url.username).toBe(`user%40example.com`); + expect((request.mock.calls[0][0] as URL).username).toBe(``); + }); + + it(`preserves headers supplied as a Headers object`, async () => { + process.env.COREPACK_NPM_TOKEN = `explicit-token`; + await fetchAsJson(`https://registry.example.com/npm/pnpm`, {headers: new Headers({accept: `application/json`})}); + expect(new Headers(request.mock.calls[0][1]?.headers).get(`accept`)).toBe(`application/json`); + expect(authorization()).toBe(`Bearer explicit-token`); + }); + + for (const kind of [`npmrc`, `token`, `Basic`, `registry URL`]) { + it.each([ + `/npm/pnpm.tgz`, + `/other/pnpm.tgz`, + `/npm-other/pnpm.tgz`, + `https://other.example.com/npm/pnpm.tgz`, + `https://registry.example.com:8443/npm/pnpm.tgz`, + ])(`re-evaluates ${kind} credentials on a redirect to %s`, async location => { + if (kind === `npmrc`) { + await writeProjectConfig(`//registry.example.com/npm/:_authToken=project-token`); + } else if (kind === `token`) { + process.env.COREPACK_NPM_TOKEN = `explicit-token`; + } else if (kind === `Basic`) { + process.env.COREPACK_NPM_USERNAME = `user`; + process.env.COREPACK_NPM_PASSWORD = `pass`; + } else { + process.env.COREPACK_NPM_REGISTRY = `https://user:pass@registry.example.com/npm/`; + } + request.mockResolvedValueOnce(new Response(null, {status: 302, headers: {location}})); + await fetchUrlStream(`https://registry.example.com/npm/pnpm`); + expect(request.mock.calls[0][1]?.redirect).toBe(`manual`); + expect(authorization()).not.toBeNull(); + expect(authorization(1)).toBe(location === `/npm/pnpm.tgz` ? authorization() : null); + }); + } + + it(`does not broaden supplied authorization scope across redirects`, async () => { + request.mockResolvedValueOnce(new Response(null, {status: 302, headers: {location: `/npm/private`}})); + request.mockResolvedValueOnce(new Response(null, {status: 302, headers: {location: `/npm/public/pnpm.tgz`}})); + await fetchUrlStream(`https://registry.example.com/npm/private/pnpm`, {headers: {authorization: `Bearer supplied-token`}}); + expect(authorization()).toBe(`Bearer supplied-token`); + expect(authorization(1)).toBe(`Bearer supplied-token`); + expect(authorization(2)).toBeNull(); + }); + + it.each([301, 302, 303])(`switches POST to GET on a %s redirect`, async status => { + request.mockResolvedValueOnce(new Response(null, {status, headers: {location: `/npm/pnpm.tgz`}})); + await fetchAsJson(`https://registry.example.com/npm/pnpm`, {method: `POST`, body: `payload`, headers: {accept: `application/json`, 'content-type': `text/plain`}}); + expect(request.mock.calls[1][1]).toMatchObject({method: `GET`, body: undefined}); + const headers = new Headers(request.mock.calls[1][1]?.headers); + expect(headers.get(`content-type`)).toBeNull(); + expect(headers.get(`accept`)).toBe(`application/json`); + }); + + it.each([307, 308])(`preserves POST and its body on a %s redirect`, async status => { + request.mockResolvedValueOnce(new Response(null, {status, headers: {location: `/npm/pnpm.tgz`}})); + await fetchAsJson(`https://registry.example.com/npm/pnpm`, {method: `POST`, body: `payload`, headers: {'content-type': `text/plain`}}); + expect(request.mock.calls[1][1]).toMatchObject({method: `POST`, body: `payload`}); + expect(new Headers(request.mock.calls[1][1]?.headers).get(`content-type`)).toBe(`text/plain`); + }); + + it(`selects the target registry's own credentials on a redirect`, async () => { + await writeProjectConfig(`//registry.example.com/npm/:_authToken=project-token\n//other.example.com/npm/:_authToken=other-token`); + request.mockResolvedValueOnce(new Response(null, {status: 302, headers: {location: `https://other.example.com/npm/pnpm.tgz`}})); + await fetchUrlStream(`https://registry.example.com/npm/pnpm`); + expect(authorization()).toBe(`Bearer project-token`); + expect(authorization(1)).toBe(`Bearer other-token`); + }); + + it(`limits redirect loops`, async () => { + request.mockImplementation(async () => new Response(null, {status: 302, headers: {location: `/npm/pnpm`}})); + await expect(fetchMetadata(`pnpm`)).rejects.toThrow(`Too many redirects`); + expect(request).toHaveBeenCalledTimes(21); + }); +}); diff --git a/tests/main.test.ts b/tests/main.test.ts index 4bb7f40f4..0d4cb201b 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -1872,7 +1872,7 @@ describe(`downloads not pinned by a hash`, () => { }); }); -for (const authType of [`COREPACK_NPM_REGISTRY`, `COREPACK_NPM_TOKEN`, `COREPACK_NPM_PASSWORD`, `PROXY`]) { +for (const authType of [`COREPACK_NPM_REGISTRY`, `COREPACK_NPM_TOKEN`, `COREPACK_NPM_PASSWORD`, `PROXY`, `NPMRC_PROJECT_TOKEN`, `NPMRC_USER_TOKEN`, `NPMRC_PROJECT_BASIC`, `NPMRC_USER_BASIC`]) { describe(`custom registry with auth ${authType}`, () => { beforeEach(() => { if (authType === `PROXY`) { diff --git a/tests/npmRegistryUtils.test.ts b/tests/npmRegistryUtils.test.ts index b9d9cb9cf..164eeb045 100644 --- a/tests/npmRegistryUtils.test.ts +++ b/tests/npmRegistryUtils.test.ts @@ -45,61 +45,6 @@ describe(`npm registry utils fetchAsJson`, () => { expect(httpFetchAsJson).toBeCalled(); expect(httpFetchAsJson).lastCalledWith(`https://registry.example.org/package-name`, {headers: DEFAULT_HEADERS}); }); - - it(`adds authorization header with bearer token if COREPACK_NPM_TOKEN is set`, async () => { - // `process.env` is reset after each tests in setupTests.js. - process.env.COREPACK_NPM_TOKEN = `foo`; - - await fetchAsJson(`package-name`); - - expect(httpFetchAsJson).toBeCalled(); - expect(httpFetchAsJson).lastCalledWith(`${DEFAULT_NPM_REGISTRY_URL}/package-name`, {headers: { - ...DEFAULT_HEADERS, - authorization: `Bearer ${process.env.COREPACK_NPM_TOKEN}`, - }}); - }); - - it(`only adds authorization header with bearer token if COREPACK_NPM_TOKEN and COREPACK_NPM_USERNAME are set`, async () => { - // `process.env` is reset after each tests in setupTests.js. - process.env.COREPACK_NPM_TOKEN = `foo`; - process.env.COREPACK_NPM_USERNAME = `bar`; - process.env.COREPACK_NPM_PASSWORD = `foobar`; - - await fetchAsJson(`package-name`); - - expect(httpFetchAsJson).toBeCalled(); - expect(httpFetchAsJson).lastCalledWith(`${DEFAULT_NPM_REGISTRY_URL}/package-name`, {headers: { - ...DEFAULT_HEADERS, - authorization: `Bearer ${process.env.COREPACK_NPM_TOKEN}`, - }}); - }); - - - it(`adds authorization header with basic auth if COREPACK_NPM_USERNAME and COREPACK_NPM_PASSWORD are set`, async () => { - // `process.env` is reset after each tests in setupTests.js. - process.env.COREPACK_NPM_USERNAME = `foo`; - process.env.COREPACK_NPM_PASSWORD = `bar`; - - const encodedCreds = Buffer.from(`${process.env.COREPACK_NPM_USERNAME}:${process.env.COREPACK_NPM_PASSWORD}`, `utf8`).toString(`base64`); - - await fetchAsJson(`package-name`); - - expect(httpFetchAsJson).toBeCalled(); - expect(httpFetchAsJson).lastCalledWith(`${DEFAULT_NPM_REGISTRY_URL}/package-name`, {headers: { - ...DEFAULT_HEADERS, - authorization: `Basic ${encodedCreds}`, - }}); - }); - - it(`does not add authorization header if COREPACK_NPM_USERNAME is set and COREPACK_NPM_PASSWORD is not.`, async () => { - // `process.env` is reset after each tests in setupTests.js. - process.env.COREPACK_NPM_USERNAME = `foo`; - - await fetchAsJson(`package-name`); - - expect(httpFetchAsJson).toBeCalled(); - expect(httpFetchAsJson).lastCalledWith(`${DEFAULT_NPM_REGISTRY_URL}/package-name`, {headers: DEFAULT_HEADERS}); - }); }); describe(`npm registry utils verifySignature`, () => { diff --git a/tests/recordRequests.js b/tests/recordRequests.js index e9bfd7a8e..ce52c70cb 100644 --- a/tests/recordRequests.js +++ b/tests/recordRequests.js @@ -29,6 +29,9 @@ function getRequestHash(input, init) { if (init[key] === undefined) continue; switch (key) { + case `redirect`: + if (init[key] !== `manual`) throw new Error(`Hashing for redirect mode "${init[key]}" not implemented`); + break; case `headers`: hash.update(`${JSON.stringify(Object.fromEntries(new Headers(init.headers || {})))}\0`); break; @@ -49,7 +52,7 @@ if (process.env.NOCK_ENV === `record`) { const data = await response.arrayBuffer(); const minimalHeaders = new Headers(); - for (const headerName of [`content-type`, `content-length`]) { + for (const headerName of [`content-type`, `content-length`, `location`]) { const headerValue = response.headers.get(headerName); if (headerValue != null) { minimalHeaders.set(headerName, headerValue);