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
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
64 changes: 36 additions & 28 deletions sources/httpUtils.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,33 @@
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<Response> {
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;
try {
response = await globalThis.fetch(input, {
...init,
headers,
redirect: `manual`,
});
} catch (error) {
throw new Error(
Expand All @@ -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(
Expand Down
119 changes: 119 additions & 0 deletions sources/npmConfigUtils.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>) {
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<string | undefined> {
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<string, string | undefined>();
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;
}
12 changes: 1 addition & 11 deletions sources/npmRegistryUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}: {
Expand Down
27 changes: 27 additions & 0 deletions tests/_registryServer.mjs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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});
}

Expand Down
Loading