fix: support scope-specific registry auth tokens (#12392)

pnpm can now use different auth tokens for different package scopes, even when those scopes use the same registry URL.

Previously, auth was selected only by registry URL. If `@org-a` and `@org-b` both used `https://npm.pkg.github.com/`, they had to share the same token. This caused problems for registries that issue tokens per organization or per scope.

Configure a scope-specific token by adding the package scope after the registry URL in the auth key:

```ini
@org-a:registry=https://npm.pkg.github.com/
@org-b:registry=https://npm.pkg.github.com/

//npm.pkg.github.com/:@org-a:_authToken=${ORG_A_TOKEN}
//npm.pkg.github.com/:@org-b:_authToken=${ORG_B_TOKEN}

//npm.pkg.github.com/:_authToken=${FALLBACK_TOKEN}
```

`pnpm login --registry=https://npm.pkg.github.com --scope=@org-a` writes the token to the same scope-specific auth key.

When installing or publishing `@org-a/*`, pnpm uses `ORG_A_TOKEN`. For `@org-b/*`, pnpm uses `ORG_B_TOKEN`. Packages without a matching scope continue to use the registry-wide fallback token.
This commit is contained in:
Zoltan Kochan
2026-06-14 11:43:30 +02:00
committed by GitHub
parent 2da044434e
commit 681b593eb2
64 changed files with 1361 additions and 286 deletions

View File

@@ -0,0 +1,34 @@
---
"@pnpm/auth.commands": patch
"@pnpm/config.reader": patch
"@pnpm/fetching.tarball-fetcher": patch
"@pnpm/fetching.types": patch
"@pnpm/installing.deps-installer": patch
"@pnpm/network.auth-header": patch
"@pnpm/pnpr.client": patch
"@pnpm/releasing.commands": patch
"@pnpm/resolving.default-resolver": patch
"@pnpm/resolving.npm-resolver": patch
"@pnpm/types": patch
"pnpm": patch
---
pnpm can now use different auth tokens for different package scopes, even when those scopes use the same registry URL.
Previously, auth was selected only by registry URL. If `@org-a` and `@org-b` both used `https://npm.pkg.github.com/`, they had to share the same token. This caused problems for registries that issue tokens per organization or per scope.
Configure a scope-specific token by adding the package scope after the registry URL in the auth key:
```ini
@org-a:registry=https://npm.pkg.github.com/
@org-b:registry=https://npm.pkg.github.com/
//npm.pkg.github.com/:@org-a:_authToken=${ORG_A_TOKEN}
//npm.pkg.github.com/:@org-b:_authToken=${ORG_B_TOKEN}
//npm.pkg.github.com/:_authToken=${FALLBACK_TOKEN}
```
`pnpm login --registry=https://npm.pkg.github.com --scope=@org-a` writes the token to the same scope-specific auth key.
When installing or publishing `@org-a/*`, pnpm uses `ORG_A_TOKEN`. For `@org-b/*`, pnpm uses `ORG_B_TOKEN`. Packages without a matching scope continue to use the registry-wide fallback token.

1
Cargo.lock generated
View File

@@ -4104,6 +4104,7 @@ dependencies = [
"pacquet-config",
"pacquet-lockfile",
"pacquet-lockfile-verification",
"pacquet-network",
"pacquet-testing-utils",
"pnpr",
"reqwest 0.13.4",

View File

@@ -51,7 +51,7 @@ export function help (): string {
name: '--registry <url>',
},
{
description: 'Associate an operation with a scope for a scoped registry. The scope-to-registry mapping is recorded so future installs in the same scope use the chosen registry.',
description: 'Associate the login token with a package scope and record the scope-to-registry mapping.',
name: '--scope <scope>',
},
],
@@ -193,11 +193,9 @@ export async function login ({ context = DEFAULT_CONTEXT, opts }: LoginParams):
const configPath = path.join(opts.configDir, 'auth.ini')
const settings = await safeReadIniFile(readIniFile, configPath) as Record<string, unknown>
const registryConfigKey = getRegistryConfigKey(registry)
settings[`${registryConfigKey}:_authToken`] = token
// Persist the scope → registry mapping next to the auth token so subsequent
// installs for `@scope/*` packages route to this registry. `auth.ini` is
// already an allowed source of `@scope:registry=` (see config/reader).
const scopeKey = normalizeScope(opts.scope)
const authConfigKey = scopeKey == null ? registryConfigKey : `${registryConfigKey}:${scopeKey}`
settings[`${authConfigKey}:_authToken`] = token
if (scopeKey != null) {
settings[`${scopeKey}:registry`] = registry
}
@@ -206,9 +204,6 @@ export async function login ({ context = DEFAULT_CONTEXT, opts }: LoginParams):
return `Logged in on ${registry}`
}
// `--scope foo` and `--scope @foo` should both produce `@foo`. Empty / blank
// values are treated as unset so accidental whitespace doesn't write a broken
// `@:registry=` entry.
function normalizeScope (scope: string | undefined): string | undefined {
if (scope == null) return undefined
const trimmed = scope.trim()

View File

@@ -143,7 +143,7 @@ describe('login', () => {
])
})
it('should persist a scoperegistry mapping when --scope is provided', async () => {
it('should persist a scoped auth token and scope registry mapping when --scope is provided', async () => {
let savedSettings: Record<string, unknown> = {}
const context = createMockContext({
globalInfo: jest.fn(),
@@ -172,14 +172,53 @@ describe('login', () => {
throw new Error(`Unexpected call to fetch: ${url}`)
},
})
// `--scope my-org` (no `@`) should be normalized to `@my-org` when written.
const opts = { configDir: '/mock/config', dir: '/mock', authConfig: {}, registry: 'https://my-org.example', scope: 'my-org' }
const result = await login({ context, opts })
expect(result).toBe('Logged in on https://my-org.example/')
expect(savedSettings).toMatchObject({
'//my-org.example/:_authToken': 'scoped-token',
'//my-org.example/:@my-org:_authToken': 'scoped-token',
'@my-org:registry': 'https://my-org.example/',
})
expect(savedSettings['//my-org.example/:_authToken']).toBeUndefined()
})
it('should persist scoped auth tokens under path registries', async () => {
let savedSettings: Record<string, unknown> = {}
const context = createMockContext({
globalInfo: jest.fn(),
readIniFile: async () => ({}),
writeIniFile: async (_configPath, settings) => {
savedSettings = settings
},
fetch: async url => {
if (url === 'https://example.com/npm/-/v1/login') {
return createMockResponse({
ok: true,
status: 200,
json: {
loginUrl: 'https://example.com/auth/login',
doneUrl: 'https://example.com/auth/done',
},
})
}
if (url === 'https://example.com/auth/done') {
return createMockResponse({
ok: true,
status: 200,
json: { token: 'path-scoped-token' },
})
}
throw new Error(`Unexpected call to fetch: ${url}`)
},
})
const opts = { configDir: '/mock/config', dir: '/mock', authConfig: {}, registry: 'https://example.com/npm/', scope: '@team' }
const result = await login({ context, opts })
expect(result).toBe('Logged in on https://example.com/npm/')
expect(savedSettings).toMatchObject({
'//example.com/npm/:@team:_authToken': 'path-scoped-token',
'@team:registry': 'https://example.com/npm/',
})
expect(savedSettings['//example.com/npm/:_authToken']).toBeUndefined()
})
it('should accept --scope with a leading @ and not double-prefix', async () => {
@@ -206,6 +245,8 @@ describe('login', () => {
})
const opts = { configDir: '/mock/config', dir: '/mock', authConfig: {}, registry: 'https://my-org.example', scope: '@my-org' }
await login({ context, opts })
expect(savedSettings['//my-org.example/:@my-org:_authToken']).toBe('tok')
expect(savedSettings['//my-org.example/:_authToken']).toBeUndefined()
expect(savedSettings['@my-org:registry']).toBe('https://my-org.example/')
expect(savedSettings['@@my-org:registry']).toBeUndefined()
})
@@ -234,7 +275,7 @@ describe('login', () => {
})
const opts = { configDir: '/mock/config', dir: '/mock', authConfig: {}, registry: 'https://example.com' }
await login({ context, opts })
// No `@…:registry` key should be added when scope isn't passed.
expect(savedSettings['//example.com/:_authToken']).toBe('tok')
for (const key of Object.keys(savedSettings)) {
expect(key.startsWith('@')).toBe(false)
}

View File

@@ -1,6 +1,6 @@
import fs from 'node:fs'
import type { Creds, RegistryConfig } from '@pnpm/types'
import { type Creds, DEFAULT_REGISTRY_SCOPE, type RegistryConfig } from '@pnpm/types'
import normalizeRegistryUrl from 'normalize-registry-url'
import { parseCreds, type RawCreds } from './parseCreds.js'
@@ -11,7 +11,7 @@ export interface NetworkConfigs {
}
export function getNetworkConfigs (rawConfig: Record<string, unknown>): NetworkConfigs {
const rawCredsMap: Record<string, RawCreds> = {}
const rawCredsMap: Record<string, Record<string, RawCreds>> = {}
const registries: Record<string, string> = {}
const networkConfigs: NetworkConfigs = { registries }
for (const [configKey, value] of Object.entries(rawConfig)) {
@@ -22,9 +22,10 @@ export function getNetworkConfigs (rawConfig: Record<string, unknown>): NetworkC
const parsedCreds = tryParseCredsKey(configKey)
if (parsedCreds) {
const { credsField, registry } = parsedCreds
const { credsField, registry, scope } = parsedCreds
rawCredsMap[registry] ??= {}
rawCredsMap[registry][credsField] = value as string
rawCredsMap[registry][scope ?? DEFAULT_REGISTRY_SCOPE] ??= {}
rawCredsMap[registry][scope ?? DEFAULT_REGISTRY_SCOPE][credsField] = value as string
continue
}
@@ -41,11 +42,11 @@ export function getNetworkConfigs (rawConfig: Record<string, unknown>): NetworkC
}
for (const uri in rawCredsMap) {
const creds = parseCreds(rawCredsMap[uri])
if (creds) {
const scopedCreds = getScopedCreds(rawCredsMap[uri])
if (Object.keys(scopedCreds).length > 0) {
networkConfigs.configByUri ??= {}
networkConfigs.configByUri[uri] ??= {}
networkConfigs.configByUri[uri].creds = creds
Object.assign(networkConfigs.configByUri[uri], scopedCreds)
}
}
@@ -75,6 +76,7 @@ const AUTH_SUFFIX_KEY_MAP: Record<string, keyof RawCreds> = {
interface ParsedCredsKey {
registry: string
scope?: string
credsField: keyof RawCreds
}
@@ -88,7 +90,57 @@ function tryParseCredsKey (key: string): ParsedCredsKey | undefined {
if (!credsField) {
throw new Error(`Unexpected key: ${match.groups.key}`)
}
return { registry, credsField }
return { ...splitScopeFromRegistry(registry), credsField }
}
function getScopedCreds (rawCredsByScope: Record<string, RawCreds> = {}): Record<string, Creds> {
const scopedCreds: Record<string, Creds> = {}
for (const [scope, rawCreds] of Object.entries(rawCredsByScope)) {
const creds = parseCreds(rawCreds)
if (creds) {
scopedCreds[scope] = creds
}
}
return scopedCreds
}
function splitScopeFromRegistry (registry: string): { registry: string, scope?: string } {
const colonScope = splitScopeFromRegistryByColon(registry)
if (colonScope) return colonScope
return splitScopeFromRegistryByPath(registry)
}
function splitScopeFromRegistryByColon (registry: string): { registry: string, scope: string } | undefined {
if (!registry.startsWith('//')) return undefined
const scopeSeparatorIndex = registry.lastIndexOf(':@')
if (scopeSeparatorIndex === -1) return undefined
const scope = registry.slice(scopeSeparatorIndex + 1)
if (!isPackageScope(scope)) return undefined
return {
registry: normalizeRegistryKey(registry.slice(0, scopeSeparatorIndex)),
scope,
}
}
function splitScopeFromRegistryByPath (registry: string): { registry: string, scope?: string } {
if (!registry.startsWith('//')) return { registry }
const trimmed = registry.endsWith('/') ? registry.slice(0, -1) : registry
const lastSlashIndex = trimmed.lastIndexOf('/')
if (lastSlashIndex === -1) return { registry }
const scope = trimmed.slice(lastSlashIndex + 1)
if (!isPackageScope(scope)) return { registry }
return {
registry: trimmed.slice(0, lastSlashIndex + 1),
scope,
}
}
function isPackageScope (scope: string): boolean {
return scope.startsWith('@') && scope.length > 1 && !scope.includes('/') && !scope.includes(':')
}
function normalizeRegistryKey (registry: string): string {
return registry.endsWith('/') ? registry : `${registry}/`
}
const SSL_SUFFIX_RE = /:(?<id>cert|key|ca)(?<kind>file)?$/

View File

@@ -88,7 +88,7 @@ test('auth and tls combined', () => {
},
configByUri: {
'//example.com/foo': {
creds: { authToken: 'example auth token' },
'@': { authToken: 'example auth token' },
},
},
} as NetworkConfigs)
@@ -102,7 +102,7 @@ test('auth and tls combined', () => {
},
configByUri: {
'//example.com/foo': {
creds: {
'@': {
basicAuth: {
username: 'foo',
password: 'bar',
@@ -122,7 +122,7 @@ test('auth and tls combined', () => {
},
configByUri: {
'//example.com/foo': {
creds: {
'@': {
basicAuth: {
username: 'foo',
password: 'bar',
@@ -141,7 +141,7 @@ test('auth and tls combined', () => {
},
configByUri: {
'//example.com/foo': {
creds: { tokenHelper: ['node', './my-token-helper.cjs'] },
'@': { tokenHelper: ['node', './my-token-helper.cjs'] },
},
},
} as NetworkConfigs)
@@ -154,13 +154,57 @@ test('auth and tls combined', () => {
registries: {},
configByUri: {
'//example.com/foo': {
creds: { authToken: 'token' },
'@': { authToken: 'token' },
tls: { cert: 'some-cert', key: 'some-key' },
},
},
} as NetworkConfigs)
})
test('package-scope auth is grouped under the registry URI', () => {
expect(getNetworkConfigs({
'//npm.pkg.github.com/:_authToken': 'registry-token',
'//npm.pkg.github.com/:@orgA:_authToken': 'org-a-token',
'//npm.pkg.github.com/:@orgB:_authToken': 'org-b-token',
'//reg.com/npm/:@orgA:_authToken': 'org-a-path-token',
'//localhost:4873/:@orgC:_authToken': 'org-c-port-token',
})).toStrictEqual({
registries: {},
configByUri: {
'//npm.pkg.github.com/': {
'@': { authToken: 'registry-token' },
'@orgA': { authToken: 'org-a-token' },
'@orgB': { authToken: 'org-b-token' },
},
'//reg.com/npm/': {
'@orgA': { authToken: 'org-a-path-token' },
},
'//localhost:4873/': {
'@orgC': { authToken: 'org-c-port-token' },
},
},
} as NetworkConfigs)
})
test('slash package-scope auth is grouped under the registry URI', () => {
expect(getNetworkConfigs({
'//npm.pkg.github.com/@orgA:_authToken': 'org-a-token',
'//npm.pkg.github.com/@orgB/:_authToken': 'org-b-token',
'//reg.com/npm/@orgA:_authToken': 'org-a-path-token',
})).toStrictEqual({
registries: {},
configByUri: {
'//npm.pkg.github.com/': {
'@orgA': { authToken: 'org-a-token' },
'@orgB': { authToken: 'org-b-token' },
},
'//reg.com/npm/': {
'@orgA': { authToken: 'org-a-path-token' },
},
},
} as NetworkConfigs)
})
test('unsupported key', () => {
expect(getNetworkConfigs({
'@foo:registry': 'https://example.com/foo',

View File

@@ -724,7 +724,7 @@ test('project .npmrc does not expand env variables in auth values', async () =>
expect(serializedAuthConfig).not.toContain('secret-user')
expect(serializedAuthConfig).not.toContain('secret-cert')
expect(serializedAuthConfig).not.toContain('secret-key')
expect(config.configByUri?.['//attacker.example/']?.creds).toBeUndefined()
expect(config.configByUri?.['//attacker.example/']?.['@']).toBeUndefined()
expect(config.configByUri?.['//attacker.example/']?.tls).toBeUndefined()
expect(warnings).toEqual(expect.arrayContaining([
expect.stringContaining('Ignored project-level auth setting "//attacker.example/:_authToken"'),
@@ -905,11 +905,11 @@ test('package manager bootstrap registries ignore project workspace registries',
expect(config.httpsProxy).toBe('http://project-proxy.example.com:8080')
expect(config.strictSsl).toBe(false)
expect(config.configByUri).toMatchObject({
'//project.example.com/': { creds: { authToken: 'project-token' } },
'//project.example.com/': { '@': { authToken: 'project-token' } },
})
expect(config.packageManagerNetworkConfig).toMatchObject({
configByUri: {
'//trusted.example.com/': { creds: { authToken: 'trusted-token' } },
'//trusted.example.com/': { '@': { authToken: 'trusted-token' } },
},
httpProxy: 'http://trusted-env-proxy.example.com:8080',
httpsProxy: 'http://trusted-env-proxy.example.com:8080',
@@ -1311,7 +1311,7 @@ describe('unscoped credentials are pinned to the registry declared in their sour
})
expect(config.configByUri).toMatchObject({
'//trusted.example.com/': { creds: { authToken: 'user-secret' } },
'//trusted.example.com/': { '@': { authToken: 'user-secret' } },
})
expect(config.configByUri['//attacker.example.com/']).toBeUndefined()
})
@@ -1329,7 +1329,7 @@ describe('unscoped credentials are pinned to the registry declared in their sour
})
expect(config.configByUri).toMatchObject({
'//trusted.example.com/': { creds: { basicAuth: { username: 'user', password: 'pass' } } },
'//trusted.example.com/': { '@': { basicAuth: { username: 'user', password: 'pass' } } },
})
expect(config.configByUri['//attacker.example.com/']).toBeUndefined()
})
@@ -1347,7 +1347,7 @@ describe('unscoped credentials are pinned to the registry declared in their sour
})
expect(config.configByUri).toMatchObject({
'//trusted.example.com/': { creds: { basicAuth: { username: 'alice', password: 'pass' } } },
'//trusted.example.com/': { '@': { basicAuth: { username: 'alice', password: 'pass' } } },
})
expect(config.configByUri['//attacker.example.com/']).toBeUndefined()
})
@@ -1373,7 +1373,7 @@ describe('unscoped credentials are pinned to the registry declared in their sour
})
expect(config.configByUri).toMatchObject({
'//registry.npmjs.org/': { creds: { authToken: 'user-secret' } },
'//registry.npmjs.org/': { '@': { authToken: 'user-secret' } },
})
expect(config.configByUri['//attacker.example.com/']).toBeUndefined()
expect(config.configByUri['//trusted.example.com/']).toBeUndefined()
@@ -1390,7 +1390,7 @@ describe('unscoped credentials are pinned to the registry declared in their sour
})
expect(config.configByUri).toMatchObject({
'//trusted.example.com/': { creds: { authToken: 'user-secret' } },
'//trusted.example.com/': { '@': { authToken: 'user-secret' } },
})
})
@@ -1406,7 +1406,7 @@ describe('unscoped credentials are pinned to the registry declared in their sour
})
expect(config.configByUri).toMatchObject({
'//workspace.example.com/': { creds: { authToken: 'workspace-token' } },
'//workspace.example.com/': { '@': { authToken: 'workspace-token' } },
})
})
@@ -1426,7 +1426,7 @@ describe('unscoped credentials are pinned to the registry declared in their sour
})
expect(config.configByUri).toMatchObject({
'//trusted.example.com/': { creds: { authToken: 'user-secret' } },
'//trusted.example.com/': { '@': { authToken: 'user-secret' } },
})
// URL-scoped tokens should NOT trigger the deprecation warning.
expect(warnings.join('\n')).not.toMatch(/deprecated/i)
@@ -1446,7 +1446,7 @@ describe('unscoped credentials are pinned to the registry declared in their sour
// The token rescoped to the npmjs default when the user file was read.
expect(config.configByUri).toMatchObject({
'//registry.npmjs.org/': { creds: { authToken: 'user-secret' } },
'//registry.npmjs.org/': { '@': { authToken: 'user-secret' } },
})
expect(config.configByUri['//attacker.example.com/']).toBeUndefined()
})

View File

@@ -28,6 +28,8 @@ export interface BasicAuth {
/** Parsed value of `tokenHelper` of each registry in the rc file. */
export type TokenHelper = [string, ...string[]]
export const DEFAULT_REGISTRY_SCOPE = '@'
/** Per-registry authentication credentials. */
export interface Creds {
/** Parsed value of `_auth` of each registry in the rc file. */
@@ -50,7 +52,7 @@ export interface TlsConfig {
/** Per-registry configuration (credentials + TLS). */
export interface RegistryConfig {
creds?: Creds
[scope: `@${string}`]: Creds | undefined
tls?: TlsConfig
}

View File

@@ -280,7 +280,7 @@ describe('plugin-commands-audit', () => {
dir: hasVulnerabilitiesDir,
rootProjectManifestDir: hasVulnerabilitiesDir,
configByUri: {
'//audit.registry/': { creds: { authToken: '123' } },
'//audit.registry/': { '@': { authToken: '123' } },
},
})

View File

@@ -68,7 +68,7 @@ export async function fetchPackageInfo (
packageName,
{
registry,
authHeaderValue: getAuthHeader(registry),
authHeaderValue: getAuthHeader(registry, { pkgName: packageName }),
fullMetadata: true,
}
)

View File

@@ -71,7 +71,7 @@ export function createTarballFetcher (
async function fetchFromTarball (
ctx: {
download: DownloadFunction
getAuthHeaderByURI: (registry: string) => string | undefined
getAuthHeaderByURI: GetAuthHeader
offline?: boolean
storeIndex: StoreIndex
},

View File

@@ -4,7 +4,7 @@ import util from 'node:util'
import { requestRetryLogger } from '@pnpm/core-loggers'
import { FetchError } from '@pnpm/error'
import type { FetchOptions, FetchResult } from '@pnpm/fetching.fetcher-base'
import type { FetchFromRegistry } from '@pnpm/fetching.types'
import type { FetchFromRegistry, GetAuthHeader } from '@pnpm/fetching.types'
import { globalWarn } from '@pnpm/logger'
import type { Cafs } from '@pnpm/store.cafs-types'
import type { StoreIndex } from '@pnpm/store.index'
@@ -21,14 +21,15 @@ export interface HttpResponse {
}
export type DownloadOptions = {
getAuthHeaderByURI: (registry: string) => string | undefined
getAuthHeaderByURI: GetAuthHeader
cafs: Cafs
registry?: string
onStart?: (totalSize: number | null, attempt: number) => void
onProgress?: (downloaded: number) => void
integrity?: string
storeIndex: StoreIndex
} & Pick<FetchOptions, 'pkg' | 'appendManifest' | 'readManifest' | 'filesIndexFile' | 'ignoreFilePattern'>
pkg?: FetchOptions['pkg']
} & Pick<FetchOptions, 'appendManifest' | 'readManifest' | 'filesIndexFile' | 'ignoreFilePattern'>
export type DownloadFunction = (url: string, opts: DownloadOptions) => Promise<FetchResult>
@@ -64,7 +65,7 @@ export function createDownloader (
const fetchMinSpeedKiBps = gotOpts.fetchMinSpeedKiBps ?? 50 // 50 KiB/s
return async function download (url: string, opts: DownloadOptions): Promise<FetchResult> {
const authHeaderValue = opts.getAuthHeaderByURI(url)
const authHeaderValue = opts.getAuthHeaderByURI(url, { pkgName: opts.pkg?.name })
const op = retry.operation(retryOpts)

View File

@@ -25,6 +25,7 @@ jest.unstable_mockModule('@pnpm/logger', async () => {
const { globalWarn } = await import('@pnpm/logger')
const {
createTarballFetcher,
createDownloader,
BadTarballError,
TarballIntegrityError,
} = await import('@pnpm/fetching.tarball-fetcher')
@@ -541,6 +542,97 @@ test('accessing private packages', async () => {
expect(index).toBeTruthy()
})
test('passes package name to auth header lookup when fetching tarballs', async () => {
const tarballContent = fs.readFileSync(tarballPath)
const mockPool = mockAgent.get(registry)
mockPool.intercept({
path: '/download/pkg.tgz',
method: 'GET',
headers: {
authorization: 'Bearer scoped-token',
},
}).reply(200, tarballContent, {
headers: { 'Content-Length': tarballSize.toString() },
})
process.chdir(temporaryDirectory())
const calls: Array<{ uri: string, pkgName?: string }> = []
const getScopedAuthHeader = (uri: string, opts?: { pkgName?: string }): string | undefined => {
calls.push({ uri, pkgName: opts?.pkgName })
return opts?.pkgName === '@org/pkg' ? 'Bearer scoped-token' : undefined
}
const fetch = createTarballFetcher(fetchFromRegistry, getScopedAuthHeader, {
storeIndex,
retry: {
maxTimeout: 100,
minTimeout: 0,
retries: 1,
},
})
const resolution = {
integrity: tarballIntegrity,
registry: `${registry}/`,
tarball: `${registry}/download/pkg.tgz`,
}
const index = await fetch.remoteTarball(cafs, resolution, {
filesIndexFile,
lockfileDir: process.cwd(),
pkg: {
name: '@org/pkg',
version: '1.0.0',
},
})
expect(index).toBeTruthy()
expect(calls).toContainEqual({ uri: resolution.tarball, pkgName: '@org/pkg' })
})
test('does not require package name for tarball auth lookup', async () => {
const tarballContent = fs.readFileSync(tarballPath)
const mockPool = mockAgent.get(registry)
mockPool.intercept({
path: '/download/pkg-without-name.tgz',
method: 'GET',
}).reply(200, tarballContent, {
headers: { 'Content-Length': tarballSize.toString() },
})
process.chdir(temporaryDirectory())
const calls: Array<{ uri: string, pkgName?: string }> = []
const getAuthHeaderByURI = (uri: string, opts?: { pkgName?: string }): string | undefined => {
calls.push({ uri, pkgName: opts?.pkgName })
return undefined
}
const download = createDownloader(fetchFromRegistry, {
retry: {
maxTimeout: 100,
minTimeout: 0,
retries: 1,
},
})
const resolution = {
integrity: tarballIntegrity,
tarball: `${registry}/download/pkg-without-name.tgz`,
}
const index = await download(resolution.tarball, {
getAuthHeaderByURI,
cafs,
storeIndex,
filesIndexFile,
integrity: resolution.integrity,
})
expect(index).toBeTruthy()
expect(calls).toContainEqual({ uri: resolution.tarball, pkgName: undefined })
})
async function getFileIntegrity (filename: string) {
return (await ssri.fromStream(fs.createReadStream(filename))).toString()
}

View File

@@ -20,4 +20,8 @@ export type FetchFromRegistry = (
}
) => Promise<Response>
export type GetAuthHeader = (uri: string) => string | undefined
export interface GetAuthHeaderOptions {
pkgName?: string
}
export type GetAuthHeader = (uri: string, opts?: GetAuthHeaderOptions) => string | undefined

View File

@@ -2409,7 +2409,7 @@ async function installViaPnprServer (
)
}
const { resolveViaPnprServer } = await import('@pnpm/pnpr.client')
const { createGetAuthHeaderByURI, getAuthHeadersFromCreds } = await import('@pnpm/network.auth-header')
const { createGetAuthHeaderByURI, getAuthHeadersByScope, getAuthHeadersFromCreds } = await import('@pnpm/network.auth-header')
// Forward the whole credential map (the registries a graph touches
// aren't known up front), so the server attaches the right token per
@@ -2451,7 +2451,7 @@ async function installViaPnprServer (
projects: projectsList,
registry: opts.registries?.default,
namedRegistries: opts.namedRegistries,
authHeaders: forwardedAuthHeaders,
authHeaders: getAuthHeadersByScope(forwardedAuthHeaders),
authorization: pnprAuthorization,
overrides: opts.overrides,
minimumReleaseAge: opts.minimumReleaseAge,

View File

@@ -21,7 +21,7 @@ test('a package that need authentication', async () => {
})
let configByUri: Record<string, RegistryConfig> = {
[`//localhost:${REGISTRY_MOCK_PORT}/`]: { creds: { authToken: data.token } },
[`//localhost:${REGISTRY_MOCK_PORT}/`]: { '@': { authToken: data.token } },
}
const { updatedManifest: manifest } = await addDependenciesToPackage({}, ['@pnpm.e2e/needs-auth'], testDefaults({}, {
configByUri,
@@ -37,7 +37,7 @@ test('a package that need authentication', async () => {
rimrafSync(path.join('..', '.store'))
configByUri = {
[`//localhost:${REGISTRY_MOCK_PORT}/`]: { creds: { authToken: data.token } },
[`//localhost:${REGISTRY_MOCK_PORT}/`]: { '@': { authToken: data.token } },
}
await addDependenciesToPackage(manifest, ['@pnpm.e2e/needs-auth'], testDefaults({}, {
configByUri,
@@ -59,7 +59,7 @@ test('installing a package that need authentication, using password', async () =
})
const configByUri: Record<string, RegistryConfig> = {
[`//localhost:${REGISTRY_MOCK_PORT}/`]: { creds: { basicAuth: { username: 'foo', password: 'bar' } } },
[`//localhost:${REGISTRY_MOCK_PORT}/`]: { '@': { basicAuth: { username: 'foo', password: 'bar' } } },
}
await addDependenciesToPackage({}, ['@pnpm.e2e/needs-auth'], testDefaults({}, {
configByUri,
@@ -80,7 +80,7 @@ test('a scoped package that need authentication specific to scope', async () =>
})
const configByUri: Record<string, RegistryConfig> = {
[`//localhost:${REGISTRY_MOCK_PORT}/`]: { creds: { authToken: data.token } },
[`//localhost:${REGISTRY_MOCK_PORT}/`]: { '@': { authToken: data.token } },
}
let opts = testDefaults({
registries: {
@@ -128,7 +128,7 @@ test('a scoped package that need legacy authentication specific to scope', async
})
const configByUri: Record<string, RegistryConfig> = {
[`//localhost:${REGISTRY_MOCK_PORT}/`]: { creds: { basicAuth: { username: 'foo', password: 'bar' } } },
[`//localhost:${REGISTRY_MOCK_PORT}/`]: { '@': { basicAuth: { username: 'foo', password: 'bar' } } },
}
let opts = testDefaults({
registries: {
@@ -176,7 +176,7 @@ skipOnNode17('a package that need authentication reuses authorization tokens for
})
const configByUri: Record<string, RegistryConfig> = {
[`//127.0.0.1:${REGISTRY_MOCK_PORT}/`]: { creds: { authToken: data.token } },
[`//127.0.0.1:${REGISTRY_MOCK_PORT}/`]: { '@': { authToken: data.token } },
}
await addDependenciesToPackage({}, ['@pnpm.e2e/needs-auth'], testDefaults({
registries: {
@@ -202,7 +202,7 @@ skipOnNode17('a package that need authentication reuses authorization tokens for
})
const configByUri: Record<string, RegistryConfig> = {
[`//127.0.0.1:${REGISTRY_MOCK_PORT}/`]: { creds: { authToken: data.token } },
[`//127.0.0.1:${REGISTRY_MOCK_PORT}/`]: { '@': { authToken: data.token } },
}
let opts = testDefaults({
registries: {

View File

@@ -1,19 +1,63 @@
import { spawnSync } from 'node:child_process'
import { PnpmError } from '@pnpm/error'
import type { Creds, RegistryConfig, TokenHelper } from '@pnpm/types'
import { type Creds, DEFAULT_REGISTRY_SCOPE, type RegistryConfig, type TokenHelper } from '@pnpm/types'
export interface AuthHeaders {
authHeaderValueByURI: Record<string, string>
scopedAuthHeaderValueByURI: Record<string, Record<string, string>>
}
export type AuthHeadersByScope = Record<string, Record<string, string>>
export function getAuthHeadersFromCreds (
configByUri: Record<string, RegistryConfig>
): Record<string, string> {
const authHeaderValueByURI: Record<string, string> = {}
): AuthHeaders {
const authHeaders: AuthHeaders = {
authHeaderValueByURI: {},
scopedAuthHeaderValueByURI: {},
}
for (const [uri, registryConfig] of Object.entries(configByUri)) {
const header = credsToHeader(registryConfig.creds)
const normalizedUri = normalizeAuthKey(uri)
const header = credsToHeader(registryConfig[DEFAULT_REGISTRY_SCOPE])
if (header) {
authHeaderValueByURI[uri] = header
authHeaders.authHeaderValueByURI[normalizedUri] = header
}
for (const scope of getRegistryScopes(registryConfig)) {
if (scope === DEFAULT_REGISTRY_SCOPE) continue
const scopedCreds = registryConfig[scope]
const scopedHeader = credsToHeader(scopedCreds)
if (scopedHeader) {
authHeaders.scopedAuthHeaderValueByURI[normalizedUri] ??= {}
authHeaders.scopedAuthHeaderValueByURI[normalizedUri][scope] = scopedHeader
}
}
}
return authHeaderValueByURI
return authHeaders
}
export function getAuthHeadersByScope (authHeaders: AuthHeaders): AuthHeadersByScope {
const result: AuthHeadersByScope = {}
for (const [registryURI, authHeader] of Object.entries(authHeaders.authHeaderValueByURI)) {
result[registryURI] ??= {}
result[registryURI][DEFAULT_REGISTRY_SCOPE] = authHeader
}
for (const [registryURI, scopedAuthHeaders] of Object.entries(authHeaders.scopedAuthHeaderValueByURI)) {
result[registryURI] ??= {}
for (const [scope, authHeader] of Object.entries(scopedAuthHeaders)) {
result[registryURI][scope] = authHeader
}
}
return result
}
function getRegistryScopes (registryConfig: RegistryConfig): Array<`@${string}`> {
return Object.keys(registryConfig).filter((scope): scope is `@${string}` => scope.startsWith('@'))
}
function normalizeAuthKey (uri: string): string {
if (!uri) return uri
return uri.endsWith('/') ? uri : `${uri}/`
}
function credsToHeader (creds?: Creds): string | undefined {

View File

@@ -1,20 +1,38 @@
import { nerfDart } from '@pnpm/config.nerf-dart'
import type { RegistryConfig } from '@pnpm/types'
import { getAuthHeadersFromCreds } from './getAuthHeadersFromConfig.js'
import { type AuthHeaders, type AuthHeadersByScope, getAuthHeadersByScope, getAuthHeadersFromCreds } from './getAuthHeadersFromConfig.js'
import { removePort } from './helpers/removePort.js'
// Re-exported so callers that need the whole nerf-darted-URI → header map
// (e.g. forwarding every registry credential to the pnpr install
// accelerator) can build it without re-implementing `credsToHeader`.
export { getAuthHeadersFromCreds }
// Re-exported so callers can build the same URL/scoped credential lookup
// without re-implementing `credsToHeader`.
export { type AuthHeaders, type AuthHeadersByScope, getAuthHeadersByScope, getAuthHeadersFromCreds }
interface GetAuthHeaderOptions {
pkgName?: string
}
interface AuthHeaderLookup {
maxParts: number
scopedAuthHeaderValueByScope: Record<string, ScopedAuthHeaderLookup>
}
interface ScopedAuthHeaderLookup {
authHeaderValueByURI: Record<string, string>
maxParts: number
}
export function createGetAuthHeaderByURI (
configByUri: Record<string, RegistryConfig>
): (uri: string) => string | undefined {
): (uri: string, opts?: GetAuthHeaderOptions) => string | undefined {
const authHeaders = getAuthHeadersFromCreds(configByUri)
if (Object.keys(authHeaders).length === 0) return (uri: string) => basicAuth(new URL(uri))
return getAuthHeaderByURI.bind(null, authHeaders, getMaxParts(Object.keys(authHeaders)))
const registryURIs = Object.keys(authHeaders.authHeaderValueByURI)
const scopedAuthHeaderValueByScope = getScopedAuthHeaderValueByScope(authHeaders.scopedAuthHeaderValueByURI)
if (registryURIs.length === 0 && Object.keys(scopedAuthHeaderValueByScope).length === 0) return (uri: string) => basicAuth(new URL(uri))
return getAuthHeaderByURI.bind(null, authHeaders, {
maxParts: getMaxParts(registryURIs),
scopedAuthHeaderValueByScope,
})
}
function getMaxParts (uris: string[]): number {
@@ -24,13 +42,49 @@ function getMaxParts (uris: string[]): number {
}, 0)
}
function getAuthHeaderByURI (authHeaders: Record<string, string>, maxParts: number, uri: string): string | undefined {
function getScopedAuthHeaderValueByScope (
authHeaders: Record<string, Record<string, string>>
): Record<string, ScopedAuthHeaderLookup> {
const result: Record<string, ScopedAuthHeaderLookup> = {}
for (const [uri, scopedAuthHeaders] of Object.entries(authHeaders)) {
const parts = uri.split('/').length
for (const [scope, authHeader] of Object.entries(scopedAuthHeaders)) {
const scopedAuthHeaderLookup = result[scope] ??= {
authHeaderValueByURI: {},
maxParts: 0,
}
scopedAuthHeaderLookup.authHeaderValueByURI[uri] = authHeader
if (parts > scopedAuthHeaderLookup.maxParts) {
scopedAuthHeaderLookup.maxParts = parts
}
}
}
return result
}
function getAuthHeaderByURI (
authHeaders: AuthHeaders,
lookup: AuthHeaderLookup,
uri: string,
opts?: GetAuthHeaderOptions
): string | undefined {
if (!uri.endsWith('/')) {
uri += '/'
}
const parsedUri = new URL(uri)
const basic = basicAuth(parsedUri)
if (basic) return basic
const scope = getScope(opts?.pkgName)
const scopedAuthHeaderLookup = scope ? lookup.scopedAuthHeaderValueByScope[scope] : undefined
if (scopedAuthHeaderLookup) {
const scopedAuth = getAuthHeaderByNerfedURI(scopedAuthHeaderLookup.authHeaderValueByURI, scopedAuthHeaderLookup.maxParts, uri)
if (scopedAuth) return scopedAuth
}
return getAuthHeaderByNerfedURI(authHeaders.authHeaderValueByURI, lookup.maxParts, uri)
}
function getAuthHeaderByNerfedURI (authHeaders: Record<string, string>, maxParts: number, uri: string): string | undefined {
const parsedUri = new URL(uri)
const nerfed = nerfDart(uri)
const parts = nerfed.split('/')
for (let i = Math.min(parts.length, maxParts) - 1; i >= 3; i--) {
@@ -39,11 +93,18 @@ function getAuthHeaderByURI (authHeaders: Record<string, string>, maxParts: numb
}
const urlWithoutPort = removePort(parsedUri)
if (urlWithoutPort !== uri) {
return getAuthHeaderByURI(authHeaders, maxParts, urlWithoutPort)
return getAuthHeaderByNerfedURI(authHeaders, maxParts, urlWithoutPort)
}
return undefined
}
function getScope (pkgName: string | undefined): string | undefined {
if (!pkgName?.startsWith('@')) return undefined
const index = pkgName.indexOf('/')
if (index <= 1) return undefined
return pkgName.slice(0, index)
}
function basicAuth (uri: URL): string | undefined {
if (!uri.username && !uri.password) return undefined
const auth64 = btoa(`${uri.username}:${uri.password}`)

View File

@@ -2,10 +2,10 @@ import { expect, test } from '@jest/globals'
import { createGetAuthHeaderByURI } from '@pnpm/network.auth-header'
const configByUri = {
'//reg.com/': { creds: { authToken: 'abc123' } },
'//reg.co/tarballs/': { creds: { authToken: 'xxx' } },
'//reg.gg:8888/': { creds: { authToken: '0000' } },
'//custom.domain.com/artifactory/api/npm/npm-virtual/': { creds: { authToken: 'xyz' } },
'//reg.com/': { '@': { authToken: 'abc123' } },
'//reg.co/tarballs/': { '@': { authToken: 'xxx' } },
'//reg.gg:8888/': { '@': { authToken: '0000' } },
'//custom.domain.com/artifactory/api/npm/npm-virtual/': { '@': { authToken: 'xyz' } },
}
test('getAuthHeaderByURI()', () => {
@@ -48,7 +48,7 @@ test('getAuthHeaderByURI() https port 443 checks', () => {
test('getAuthHeaderByURI() when default ports are specified', () => {
const getAuthHeaderByURI = createGetAuthHeaderByURI({
'//reg.com/': { creds: { authToken: 'abc123' } },
'//reg.com/': { '@': { authToken: 'abc123' } },
})
expect(getAuthHeaderByURI('https://reg.com:443/')).toBe('Bearer abc123')
expect(getAuthHeaderByURI('http://reg.com:80/')).toBe('Bearer abc123')
@@ -60,7 +60,7 @@ test('returns undefined when the auth header is not found', () => {
test('getAuthHeaderByURI() when the registry has pathnames', () => {
const getAuthHeaderByURI = createGetAuthHeaderByURI({
'//npm.pkg.github.com/pnpm/': { creds: { authToken: 'abc123' } },
'//npm.pkg.github.com/pnpm/': { '@': { authToken: 'abc123' } },
})
expect(getAuthHeaderByURI('https://npm.pkg.github.com/pnpm')).toBe('Bearer abc123')
expect(getAuthHeaderByURI('https://npm.pkg.github.com/pnpm/')).toBe('Bearer abc123')
@@ -71,7 +71,43 @@ test('getAuthHeaderByURI() when the registry has pathnames', () => {
test('getAuthHeaderByURI() with basic auth via basicAuth', () => {
const getAuthHeaderByURI = createGetAuthHeaderByURI({
'//reg.com/': { creds: { basicAuth: { username: 'user', password: 'pass' } } },
'//reg.com/': { '@': { basicAuth: { username: 'user', password: 'pass' } } },
})
expect(getAuthHeaderByURI('https://reg.com/')).toBe('Basic ' + btoa('user:pass'))
})
test('getAuthHeaderByURI() prefers package scope auth over registry auth', () => {
const getAuthHeaderByURI = createGetAuthHeaderByURI({
'//npm.pkg.github.com/': {
'@': { authToken: 'registry-token' },
'@orgA': { authToken: 'org-a-token' },
'@orgB': { authToken: 'org-b-token' },
},
})
expect(getAuthHeaderByURI('https://npm.pkg.github.com/', { pkgName: '@orgA/pkg' })).toBe('Bearer org-a-token')
expect(getAuthHeaderByURI('https://npm.pkg.github.com/', { pkgName: '@orgB/pkg' })).toBe('Bearer org-b-token')
expect(getAuthHeaderByURI('https://npm.pkg.github.com/', { pkgName: '@orgC/pkg' })).toBe('Bearer registry-token')
expect(getAuthHeaderByURI('https://npm.pkg.github.com/', { pkgName: 'pkg' })).toBe('Bearer registry-token')
expect(getAuthHeaderByURI('https://npm.pkg.github.com/download/pkg.tgz', { pkgName: '@orgA/pkg' })).toBe('Bearer org-a-token')
})
test('getAuthHeaderByURI() keeps registry path when matching package scope auth', () => {
const getAuthHeaderByURI = createGetAuthHeaderByURI({
'//reg.com/npm/': {
'@': { authToken: 'registry-token' },
'@orgA': { authToken: 'org-a-token' },
},
})
expect(getAuthHeaderByURI('https://reg.com/npm/', { pkgName: '@orgA/pkg' })).toBe('Bearer org-a-token')
expect(getAuthHeaderByURI('https://reg.com/npm/pkg/-/pkg-1.0.0.tgz', { pkgName: '@orgA/pkg' })).toBe('Bearer org-a-token')
expect(getAuthHeaderByURI('https://reg.com/npm/', { pkgName: '@orgB/pkg' })).toBe('Bearer registry-token')
})
test('getAuthHeaderByURI() basic auth in URL overrides package scope auth', () => {
const getAuthHeaderByURI = createGetAuthHeaderByURI({
'//reg.com/': {
'@orgA': { authToken: 'org-a-token' },
},
})
expect(getAuthHeaderByURI('https://user:secret@reg.com/', { pkgName: '@orgA/pkg' })).toBe('Basic ' + btoa('user:secret'))
})

View File

@@ -3,7 +3,7 @@ import path from 'node:path'
import { describe, expect, it } from '@jest/globals'
import { getAuthHeadersFromCreds } from '../src/getAuthHeadersFromConfig.js'
import { getAuthHeadersByScope, getAuthHeadersFromCreds } from '../src/getAuthHeadersFromConfig.js'
const osTokenHelper = {
linux: path.join(import.meta.dirname, 'utils/test-exec.js'),
@@ -31,51 +31,101 @@ const osFamily = os.platform() === 'win32' ? 'win32' : 'linux'
describe('getAuthHeadersFromCreds()', () => {
it('should convert auth token to Bearer header', () => {
const result = getAuthHeadersFromCreds({
'//registry.npmjs.org/': { creds: { authToken: 'abc123' } },
'//registry.hu/': { creds: { authToken: 'def456' } },
'//registry.npmjs.org/': { '@': { authToken: 'abc123' } },
'//registry.hu/': { '@': { authToken: 'def456' } },
})
expect(result).toStrictEqual({
'//registry.npmjs.org/': 'Bearer abc123',
'//registry.hu/': 'Bearer def456',
authHeaderValueByURI: {
'//registry.npmjs.org/': 'Bearer abc123',
'//registry.hu/': 'Bearer def456',
},
scopedAuthHeaderValueByURI: {},
})
})
it('should convert basicAuth to Basic header', () => {
const result = getAuthHeadersFromCreds({
'//registry.foobar.eu/': { creds: { basicAuth: { username: 'foobar', password: 'foobar' } } },
'//registry.foobar.eu/': { '@': { basicAuth: { username: 'foobar', password: 'foobar' } } },
})
expect(result).toStrictEqual({
'//registry.foobar.eu/': 'Basic Zm9vYmFyOmZvb2Jhcg==',
authHeaderValueByURI: {
'//registry.foobar.eu/': 'Basic Zm9vYmFyOmZvb2Jhcg==',
},
scopedAuthHeaderValueByURI: {},
})
})
it('should execute tokenHelper', () => {
const result = getAuthHeadersFromCreds({
'//registry.foobar.eu/': { creds: { tokenHelper: [osTokenHelper[osFamily]] } },
'//registry.foobar.eu/': { '@': { tokenHelper: [osTokenHelper[osFamily]] } },
})
expect(result).toStrictEqual({
'//registry.foobar.eu/': 'Bearer token-from-spawn',
authHeaderValueByURI: {
'//registry.foobar.eu/': 'Bearer token-from-spawn',
},
scopedAuthHeaderValueByURI: {},
})
})
it('should prepend Bearer to raw token from tokenHelper', () => {
const result = getAuthHeadersFromCreds({
'//registry.foobar.eu/': { creds: { tokenHelper: [osRawTokenHelper[osFamily]] } },
'//registry.foobar.eu/': { '@': { tokenHelper: [osRawTokenHelper[osFamily]] } },
})
expect(result).toStrictEqual({
'//registry.foobar.eu/': 'Bearer raw-token-no-scheme',
authHeaderValueByURI: {
'//registry.foobar.eu/': 'Bearer raw-token-no-scheme',
},
scopedAuthHeaderValueByURI: {},
})
})
it('should throw an error if the token helper fails', () => {
expect(() => getAuthHeadersFromCreds({
'//reg.com/': { creds: { tokenHelper: [osErrorTokenHelper[osFamily]] } },
'//reg.com/': { '@': { tokenHelper: [osErrorTokenHelper[osFamily]] } },
})).toThrow('Exit code')
})
it('should throw an error if the token helper returns an empty token', () => {
expect(() => getAuthHeadersFromCreds({
'//reg.com/': { creds: { tokenHelper: [osEmptyTokenHelper[osFamily]] } },
'//reg.com/': { '@': { tokenHelper: [osEmptyTokenHelper[osFamily]] } },
})).toThrow('returned an empty token')
})
it('should return empty object when no auth infos', () => {
const result = getAuthHeadersFromCreds({})
expect(result).toStrictEqual({})
expect(result).toStrictEqual({
authHeaderValueByURI: {},
scopedAuthHeaderValueByURI: {},
})
})
it('should store package scope auth by registry URI and scope', () => {
const result = getAuthHeadersFromCreds({
'//npm.pkg.github.com/': {
'@': { authToken: 'registry-token' },
'@orgA': { authToken: 'org-a-token' },
'@orgB': { authToken: 'org-b-token' },
},
'//reg.com/npm/': {
'@orgA': { authToken: 'org-a-path-token' },
},
})
expect(result).toStrictEqual({
authHeaderValueByURI: {
'//npm.pkg.github.com/': 'Bearer registry-token',
},
scopedAuthHeaderValueByURI: {
'//npm.pkg.github.com/': {
'@orgA': 'Bearer org-a-token',
'@orgB': 'Bearer org-b-token',
},
'//reg.com/npm/': {
'@orgA': 'Bearer org-a-path-token',
},
},
})
expect(getAuthHeadersByScope(result)).toStrictEqual({
'//npm.pkg.github.com/': {
'@': 'Bearer registry-token',
'@orgA': 'Bearer org-a-token',
'@orgB': 'Bearer org-b-token',
},
'//reg.com/npm/': {
'@orgA': 'Bearer org-a-path-token',
},
})
})
})

View File

@@ -600,12 +600,7 @@ async fn install_via_pnpr<Reporter: self::Reporter + 'static>(
// Forward the whole credential map: the registries a graph
// touches aren't known up front (scope-routed or tarball-URL
// sub-deps), so the server attaches the right token per URL.
auth_headers: state
.config
.auth_headers
.entries()
.map(|(uri, value)| (uri.to_string(), value.to_string()))
.collect(),
auth_headers: state.config.auth_headers.to_by_scope(),
authorization: state.config.auth_headers.for_url(pnpr_server),
overrides,
lockfile: state

View File

@@ -1796,7 +1796,7 @@ impl Config {
// [`loadNpmrcFiles.ts`](https://github.com/pnpm/pnpm/blob/main/config/reader/src/loadNpmrcFiles.ts).
let env_scoped_source = {
let auth = crate::npmrc_auth::NpmrcAuth::from_url_scoped_env::<Sys>();
(!auth.creds_by_uri.is_empty()).then_some(auth)
(!auth.creds_by_scope_by_uri.is_empty()).then_some(auth)
};
// Fold high-priority-first: the first present source is the

View File

@@ -1,6 +1,8 @@
use crate::{Config, api::EnvVar};
use pacquet_env_replace::env_replace_lossy;
use pacquet_network::{AuthHeaders, NoProxySetting, PerRegistryTls, RegistryTls, base64_encode};
use pacquet_network::{
AuthHeaders, DEFAULT_REGISTRY_SCOPE, NoProxySetting, PerRegistryTls, RegistryTls, base64_encode,
};
use std::{
collections::{BTreeMap, HashMap},
path::{Path, PathBuf},
@@ -48,11 +50,9 @@ pub(crate) struct NpmrcAuth {
/// `username=…` / `_password=…` without a leading `//host/`).
/// Applied to whichever URI the resolved `registry` points at.
pub default_creds: RawCreds,
/// Per-URI creds, keyed by the literal `.npmrc` key prefix
/// (`//host[:port]/path/`). The map is preserved verbatim through
/// to [`AuthHeaders`] construction so the lookup keys stay
/// byte-equivalent to upstream.
pub creds_by_uri: HashMap<String, RawCreds>,
/// Per-registry creds keyed as `[registry_uri][scope]`. The `@`
/// scope stores registry-wide credentials.
pub creds_by_scope_by_uri: HashMap<String, HashMap<String, RawCreds>>,
/// `${VAR}` placeholders that could not be resolved while parsing.
/// Surfaced as warnings; `pnpm` does the same in
/// [`substituteEnv`](https://github.com/pnpm/pnpm/blob/601317e7a3/config/reader/src/loadNpmrcFiles.ts#L156-L162).
@@ -200,7 +200,7 @@ impl NpmrcAuth {
let mut auth = NpmrcAuth::default();
for (key, value) in npm_scoped {
if let Some((uri, suffix)) = split_creds_key(&key) {
let entry = auth.creds_by_uri.entry(uri.to_owned()).or_default();
let entry = auth.creds_entry_mut(uri);
apply_creds_field(entry, suffix, value);
}
}
@@ -366,7 +366,7 @@ impl NpmrcAuth {
}
if let Some((uri, suffix)) = split_creds_key(&key) {
let entry = auth.creds_by_uri.entry(uri.to_owned()).or_default();
let entry = auth.creds_entry_mut(uri);
apply_creds_field(entry, suffix, value);
continue;
}
@@ -512,25 +512,30 @@ impl NpmrcAuth {
/// [`getAuthHeadersFromCreds`](https://github.com/pnpm/pnpm/blob/601317e7a3/network/auth-header/src/getAuthHeadersFromConfig.ts).
pub fn build_auth_headers(self, config: &mut Config) {
let mut auth_header_by_uri: HashMap<String, String> = HashMap::new();
for (uri, raw) in self.creds_by_uri {
if let Some(header) = creds_to_header(&raw) {
auth_header_by_uri.insert(uri, header);
let mut auth_header_by_scope_by_uri: HashMap<String, HashMap<String, String>> =
HashMap::new();
for (uri, raw_by_scope) in self.creds_by_scope_by_uri {
for (scope, raw) in raw_by_scope {
if let Some(header) = creds_to_header(&raw) {
if scope == DEFAULT_REGISTRY_SCOPE {
auth_header_by_uri.insert(uri.clone(), header);
} else {
auth_header_by_scope_by_uri
.entry(uri.clone())
.or_default()
.insert(scope, header);
}
}
}
}
// Default-registry creds are passed through with an empty-string
// key, matching upstream's
// [`getAuthHeadersFromCreds`](https://github.com/pnpm/pnpm/blob/601317e7a3/network/auth-header/src/getAuthHeadersFromConfig.ts)
// where `configByUri['']` holds default creds and is re-keyed
// onto `defaultRegistry` by the constructor.
// [`AuthHeaders::from_creds_map`] does the nerf-darting.
if !self.default_creds.is_empty()
&& let Some(header) = creds_to_header(&self.default_creds)
{
auth_header_by_uri.insert(String::new(), header);
auth_header_by_uri.insert(pacquet_network::nerf_dart(&config.registry), header);
}
config.auth_headers =
Arc::new(AuthHeaders::from_creds_map(auth_header_by_uri, Some(&config.registry)));
Arc::new(AuthHeaders::from_parts(auth_header_by_uri, auth_header_by_scope_by_uri));
}
/// Pin this source file's **unscoped** credentials (`_authToken`,
@@ -538,7 +543,7 @@ impl NpmrcAuth {
/// registry declared in this same file — or the npmjs default
/// ([`DEFAULT_REGISTRY`]) when the file has no `registry=` of its
/// own — by nerf-darting that registry into a per-URI key and moving
/// the values onto [`Self::creds_by_uri`] / [`Self::tls_by_uri`].
/// the values onto [`Self::creds_by_scope_by_uri`] / [`Self::tls_by_uri`].
///
/// This is the security boundary ported from pnpm's
/// [`rescopeUnscopedCreds`](https://github.com/pnpm/pnpm/blob/1819226b51/config/reader/src/loadNpmrcFiles.ts):
@@ -593,7 +598,12 @@ impl NpmrcAuth {
}
// An explicitly URL-scoped value for the same key wins, so
// the rescoped unscoped value only fills the gaps.
self.creds_by_uri.entry(key.clone()).or_default().fill_from(taken);
self.creds_by_scope_by_uri
.entry(key.clone())
.or_default()
.entry(DEFAULT_REGISTRY_SCOPE.to_owned())
.or_default()
.fill_from(taken);
}
if has_identity {
let entry = self.tls_by_uri.entry(key.clone()).or_default();
@@ -644,8 +654,11 @@ impl NpmrcAuth {
self.strict_ssl = self.strict_ssl.take().or(lower.strict_ssl);
self.local_address = self.local_address.take().or(lower.local_address);
for (uri, creds) in lower.creds_by_uri {
self.creds_by_uri.entry(uri).or_default().fill_from(creds);
for (uri, lower_by_scope) in lower.creds_by_scope_by_uri {
let by_scope = self.creds_by_scope_by_uri.entry(uri).or_default();
for (scope, creds) in lower_by_scope {
by_scope.entry(scope).or_default().fill_from(creds);
}
}
for (uri, tls) in lower.tls_by_uri {
let entry = self.tls_by_uri.entry(uri).or_default();
@@ -679,6 +692,15 @@ impl NpmrcAuth {
self.apply_tls_and_local_address(config);
self.build_auth_headers(config);
}
fn creds_entry_mut(&mut self, uri: &str) -> &mut RawCreds {
let (registry_uri, scope) = split_scope_from_uri(uri);
self.creds_by_scope_by_uri
.entry(registry_uri)
.or_default()
.entry(scope.unwrap_or_else(|| DEFAULT_REGISTRY_SCOPE.to_owned()))
.or_default()
}
}
/// Normalize a registry URL the way pnpm's `normalizeRegistryUrl` does
@@ -874,6 +896,45 @@ fn split_creds_key(key: &str) -> Option<(&str, &str)> {
None
}
fn split_scope_from_uri(uri: &str) -> (String, Option<String>) {
if let Some((registry_uri, scope)) = split_scope_from_uri_by_colon(uri) {
return (normalize_registry_key(registry_uri), Some(scope.to_owned()));
}
split_scope_from_uri_by_path(uri)
}
fn split_scope_from_uri_by_colon(uri: &str) -> Option<(&str, &str)> {
if !uri.starts_with("//") {
return None;
}
let scope_separator_index = uri.rfind(":@")?;
let scope = &uri[scope_separator_index + 1..];
if !is_package_scope(scope) {
return None;
}
Some((&uri[..scope_separator_index], scope))
}
fn split_scope_from_uri_by_path(uri: &str) -> (String, Option<String>) {
let trimmed = uri.strip_suffix('/').unwrap_or(uri);
let Some(last_slash_index) = trimmed.rfind('/') else {
return (uri.to_owned(), None);
};
let scope = &trimmed[last_slash_index + 1..];
if !is_package_scope(scope) {
return (uri.to_owned(), None);
}
(trimmed[..=last_slash_index].to_owned(), Some(scope.to_owned()))
}
fn is_package_scope(scope: &str) -> bool {
scope.starts_with('@') && scope.len() > 1 && !scope.contains('/') && !scope.contains(':')
}
fn normalize_registry_key(registry: &str) -> String {
if registry.ends_with('/') { registry.to_owned() } else { format!("{registry}/") }
}
fn apply_creds_field(creds: &mut RawCreds, field: &str, value: String) {
// The catch-all swallows arbitrary `.npmrc` keys that don't map to
// a credential field. Examples: a top-level `store-dir=` line, or

View File

@@ -1,10 +1,12 @@
use std::path::Path;
use super::{EnvVar, NpmrcAuth, RawCreds, base64_decode, base64_encode};
use crate::Config;
use pacquet_network::NoProxySetting;
use pacquet_network::{DEFAULT_REGISTRY_SCOPE, NoProxySetting};
use pretty_assertions::assert_eq;
use crate::Config;
use super::{EnvVar, NpmrcAuth, RawCreds, base64_decode, base64_encode};
/// Generate a per-test unit struct implementing [`EnvVar`] from a
/// `&[(&str, &str)]` literal — saves each cascade test from spelling
/// out an `impl EnvVar` block. Avoids touching the real process
@@ -34,6 +36,20 @@ impl EnvVar for NoEnv {
}
}
fn default_auth_token<'a>(auth: &'a NpmrcAuth, uri: &str) -> Option<Option<&'a str>> {
auth.creds_by_scope_by_uri
.get(uri)
.and_then(|creds_by_scope| creds_by_scope.get(DEFAULT_REGISTRY_SCOPE))
.map(|creds| creds.auth_token.as_deref())
}
fn scoped_auth_token<'a>(auth: &'a NpmrcAuth, uri: &str, scope: &str) -> Option<Option<&'a str>> {
auth.creds_by_scope_by_uri
.get(uri)
.and_then(|creds_by_scope| creds_by_scope.get(scope))
.map(|creds| creds.auth_token.as_deref())
}
#[test]
fn picks_up_registry_and_normalises_trailing_slash() {
let ini = "registry=https://r.example\n";
@@ -130,11 +146,75 @@ fn ignores_malformed_lines() {
fn parses_per_registry_auth_token() {
let ini = "//npm.pkg.github.com/pnpm/:_authToken=ghp_xxx\n";
let auth = NpmrcAuth::from_ini::<NoEnv>(ini, Path::new(""));
assert_eq!(default_auth_token(&auth, "//npm.pkg.github.com/pnpm/"), Some(Some("ghp_xxx")));
}
#[test]
fn parses_package_scope_auth_under_registry_uri() {
let ini = "\
//npm.pkg.github.com/:_authToken=registry-token
//npm.pkg.github.com/:@orgA:_authToken=org-a-token
//npm.pkg.github.com/:@orgB:_authToken=org-b-token
//reg.com/npm/:@orgA:_authToken=org-a-path-token
//localhost:4873/:@orgC:_authToken=org-c-port-token
";
let auth = NpmrcAuth::from_ini::<NoEnv>(ini, Path::new(""));
assert_eq!(default_auth_token(&auth, "//npm.pkg.github.com/"), Some(Some("registry-token")));
assert_eq!(
auth.creds_by_uri
.get("//npm.pkg.github.com/pnpm/")
.map(|creds| creds.auth_token.as_deref()),
Some(Some("ghp_xxx")),
scoped_auth_token(&auth, "//npm.pkg.github.com/", "@orgA"),
Some(Some("org-a-token")),
);
assert_eq!(
scoped_auth_token(&auth, "//npm.pkg.github.com/", "@orgB"),
Some(Some("org-b-token")),
);
assert_eq!(scoped_auth_token(&auth, "//reg.com/npm/", "@orgA"), Some(Some("org-a-path-token")));
assert_eq!(
scoped_auth_token(&auth, "//localhost:4873/", "@orgC"),
Some(Some("org-c-port-token")),
);
}
#[test]
fn parses_slash_package_scope_auth_under_registry_uri() {
let ini = "\
//npm.pkg.github.com/@orgA:_authToken=org-a-token
//npm.pkg.github.com/@orgB/:_authToken=org-b-token
//reg.com/npm/@orgA:_authToken=org-a-path-token
";
let auth = NpmrcAuth::from_ini::<NoEnv>(ini, Path::new(""));
assert_eq!(
scoped_auth_token(&auth, "//npm.pkg.github.com/", "@orgA"),
Some(Some("org-a-token")),
);
assert_eq!(
scoped_auth_token(&auth, "//npm.pkg.github.com/", "@orgB"),
Some(Some("org-b-token")),
);
assert_eq!(scoped_auth_token(&auth, "//reg.com/npm/", "@orgA"), Some(Some("org-a-path-token")));
}
#[test]
fn package_scope_auth_from_npmrc_wins_over_registry_auth() {
let ini = "\
//npm.pkg.github.com/:_authToken=registry-token
//npm.pkg.github.com/:@orgA:_authToken=org-a-token
";
let mut config = Config::new();
NpmrcAuth::from_ini::<NoEnv>(ini, Path::new("")).apply_to::<NoEnv>(&mut config);
assert_eq!(
config
.auth_headers
.for_url_with_package("https://npm.pkg.github.com/pkg", Some("@orgA/pkg"))
.as_deref(),
Some("Bearer org-a-token"),
);
assert_eq!(
config
.auth_headers
.for_url_with_package("https://npm.pkg.github.com/pkg", Some("@orgB/pkg"))
.as_deref(),
Some("Bearer registry-token"),
);
}
@@ -162,10 +242,7 @@ fn env_replace_substitutes_token() {
}
let ini = "//reg.com/:_authToken=${TOKEN}\n";
let auth = NpmrcAuth::from_ini::<EnvWithToken>(ini, Path::new(""));
assert_eq!(
auth.creds_by_uri.get("//reg.com/").map(|creds| creds.auth_token.as_deref()),
Some(Some("abc123")),
);
assert_eq!(default_auth_token(&auth, "//reg.com/"), Some(Some("abc123")));
}
#[test]
@@ -219,7 +296,7 @@ fn project_ini_ignores_env_placeholders_in_url_scoped_keys() {
Path::new(""),
);
assert!(auth.creds_by_uri.is_empty());
assert!(auth.creds_by_scope_by_uri.is_empty());
assert!(
auth.warnings
.iter()
@@ -255,7 +332,7 @@ key=${KEY}
Path::new(""),
);
assert!(auth.creds_by_uri.is_empty());
assert!(auth.creds_by_scope_by_uri.is_empty());
assert!(auth.tls_by_uri.is_empty());
assert_eq!(auth.default_creds.auth_token, None);
assert_eq!(auth.default_creds.username, None);
@@ -279,10 +356,7 @@ fn project_ini_keeps_literal_dollar_brace_fragments() {
Path::new(""),
);
assert_eq!(
auth.creds_by_uri.get("//attacker.example/").map(|creds| creds.auth_token.as_deref()),
Some(Some("literal${token")),
);
assert_eq!(default_auth_token(&auth, "//attacker.example/"), Some(Some("literal${token")));
assert_eq!(auth.warnings, Vec::<String>::new());
}
@@ -316,10 +390,7 @@ fn env_replace_failure_warns_and_drops_unresolved_to_empty() {
// literal placeholder. See <https://github.com/pnpm/pnpm/issues/11513>.
let ini = "//reg.com/:_authToken=${MISSING}\n";
let auth = NpmrcAuth::from_ini::<NoEnv>(ini, Path::new(""));
assert_eq!(
auth.creds_by_uri.get("//reg.com/").map(|creds| creds.auth_token.as_deref()),
Some(Some("")),
);
assert_eq!(default_auth_token(&auth, "//reg.com/"), Some(Some("")));
assert_eq!(auth.warnings.len(), 1);
assert!(auth.warnings[0].contains("${MISSING}"));
}
@@ -338,10 +409,7 @@ fn env_replace_failure_preserves_resolved_and_default_placeholders() {
}
let ini = "//reg.com/:_authToken=${SET}-${UNSET}-${DEFAULTED:-fallback}\n";
let auth = NpmrcAuth::from_ini::<EnvWithSet>(ini, Path::new(""));
assert_eq!(
auth.creds_by_uri.get("//reg.com/").map(|creds| creds.auth_token.as_deref()),
Some(Some("AAA--fallback")),
);
assert_eq!(default_auth_token(&auth, "//reg.com/"), Some(Some("AAA--fallback")));
assert_eq!(auth.warnings.len(), 1);
assert!(auth.warnings[0].contains("${UNSET}"));
}
@@ -466,7 +534,7 @@ fn per_registry_username_password_apply_through_build_auth_headers() {
fn unknown_per_registry_suffix_is_silently_dropped() {
let ini = "//reg.example/:registry=https://other.example/\n";
let auth = NpmrcAuth::from_ini::<NoEnv>(ini, Path::new(""));
assert!(auth.creds_by_uri.is_empty());
assert!(auth.creds_by_scope_by_uri.is_empty());
assert_eq!(auth.default_creds, RawCreds::default());
assert_eq!(auth.warnings, Vec::<String>::new());
}
@@ -1088,10 +1156,7 @@ macro_rules! static_env_with_vars {
fn url_scoped_env_reads_npm_config_auth_token() {
static_env_with_vars!(Env, &[("npm_config_//registry.npmjs.org/:_authToken", "npm-env-token")]);
let auth = NpmrcAuth::from_url_scoped_env::<Env>();
assert_eq!(
auth.creds_by_uri.get("//registry.npmjs.org/").map(|creds| creds.auth_token.as_deref()),
Some(Some("npm-env-token")),
);
assert_eq!(default_auth_token(&auth, "//registry.npmjs.org/"), Some(Some("npm-env-token")));
}
#[test]
@@ -1101,10 +1166,7 @@ fn url_scoped_env_reads_pnpm_config_auth_token() {
&[("pnpm_config_//registry.npmjs.org/:_authToken", "pnpm-env-token")]
);
let auth = NpmrcAuth::from_url_scoped_env::<Env>();
assert_eq!(
auth.creds_by_uri.get("//registry.npmjs.org/").map(|creds| creds.auth_token.as_deref()),
Some(Some("pnpm-env-token")),
);
assert_eq!(default_auth_token(&auth, "//registry.npmjs.org/"), Some(Some("pnpm-env-token")));
}
#[test]
@@ -1117,10 +1179,7 @@ fn url_scoped_env_pnpm_prefix_wins_over_npm() {
]
);
let auth = NpmrcAuth::from_url_scoped_env::<Env>();
assert_eq!(
auth.creds_by_uri.get("//registry.npmjs.org/").map(|creds| creds.auth_token.as_deref()),
Some(Some("pnpm-env-token")),
);
assert_eq!(default_auth_token(&auth, "//registry.npmjs.org/"), Some(Some("pnpm-env-token")));
}
#[test]
@@ -1137,7 +1196,7 @@ fn url_scoped_env_ignores_non_url_and_empty_values() {
]
);
let auth = NpmrcAuth::from_url_scoped_env::<Env>();
assert!(auth.creds_by_uri.is_empty());
assert!(auth.creds_by_scope_by_uri.is_empty());
assert!(auth.registry.is_none());
}
@@ -1153,5 +1212,5 @@ fn url_scoped_env_ignores_non_ascii_names_without_panicking() {
]
);
let auth = NpmrcAuth::from_url_scoped_env::<Env>();
assert!(auth.creds_by_uri.is_empty());
assert!(auth.creds_by_scope_by_uri.is_empty());
}

View File

@@ -17,24 +17,36 @@
//! key at that host or prefix in `.npmrc`; if it redirects across
//! hosts, no header is attached, matching upstream.
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap};
pub const DEFAULT_REGISTRY_SCOPE: &str = "@";
pub type AuthHeadersByScope = BTreeMap<String, BTreeMap<String, String>>;
/// Bag of `Authorization` header values keyed by the nerf-darted form
/// of each registry URL. Pacquet builds one of these from the parsed
/// `.npmrc` and shares it across every HTTP call made during install.
///
/// Construct via [`AuthHeaders::from_creds_map`], [`AuthHeaders::from_map`],
/// or [`AuthHeaders::default`] (empty). Look up via [`AuthHeaders::for_url`].
/// Construct via [`AuthHeaders::from_parts`], [`AuthHeaders::from_creds_map`],
/// [`AuthHeaders::from_map`], or [`AuthHeaders::default`] (empty). Look up via
/// [`AuthHeaders::for_url`].
#[derive(Debug, Default, Clone)]
pub struct AuthHeaders {
/// Keys are the nerf-darted form (`//host[:port]/path/`). Values
/// are ready-to-send header values like `Bearer abc123` or
/// `Basic Zm9vOmJhcg==`.
by_uri: HashMap<String, String>,
/// Package-scope credentials keyed as
/// `scoped_by_scope[scope][registry_uri]`, where `registry_uri` is
/// the nerf-darted registry URL without the trailing scope segment.
scoped_by_scope: HashMap<String, HashMap<String, String>>,
/// The longest key in `by_uri` measured in `/`-separated parts. The
/// lookup walks from this depth down to 3 (the `//host/` floor),
/// matching pnpm's `getMaxParts` precomputation.
max_parts: usize,
/// The longest registry key per package scope, measured the same
/// way as `max_parts`.
max_scoped_parts_by_scope: HashMap<String, usize>,
}
impl AuthHeaders {
@@ -70,7 +82,7 @@ impl AuthHeaders {
if raw_uri.is_empty() {
default_header = Some(header_value);
} else {
by_uri.insert(raw_uri, header_value);
by_uri.insert(normalize_auth_key(raw_uri), header_value);
}
}
if let Some(header) = default_header {
@@ -83,16 +95,86 @@ impl AuthHeaders {
/// Each key must already be in nerf-darted form
/// (`//host[:port]/path/`).
#[must_use]
pub fn from_map(by_uri: HashMap<String, String>) -> Self {
let max_parts = by_uri.keys().map(|key| key.split('/').count()).max().unwrap_or(0);
AuthHeaders { by_uri, max_parts }
pub fn from_map(headers: HashMap<String, String>) -> Self {
let mut by_uri = HashMap::new();
let mut scoped_by_uri: HashMap<String, HashMap<String, String>> = HashMap::new();
for (uri, value) in headers {
let uri = normalize_auth_key(uri);
if let Some((registry_uri, scope)) = split_scoped_auth_key(&uri) {
scoped_by_uri.entry(registry_uri).or_default().insert(scope, value);
} else {
by_uri.insert(uri, value);
}
}
Self::from_parts(by_uri, scoped_by_uri)
}
/// The `(nerf_darted_uri, header_value)` pairs backing this lookup, so
/// a caller can forward the whole set to another process (the pnpr
/// resolver) and rebuild it with [`Self::from_map`].
pub fn entries(&self) -> impl Iterator<Item = (&str, &str)> {
self.by_uri.iter().map(|(uri, value)| (uri.as_str(), value.as_str()))
/// Build an [`AuthHeaders`] from already-structured registry and
/// package-scope header maps.
#[must_use]
pub fn from_parts(
by_uri: HashMap<String, String>,
scoped_by_uri: HashMap<String, HashMap<String, String>>,
) -> Self {
let by_uri: HashMap<String, String> =
by_uri.into_iter().map(|(uri, value)| (normalize_auth_key(uri), value)).collect();
let mut scoped_by_scope: HashMap<String, HashMap<String, String>> = HashMap::new();
let mut max_scoped_parts_by_scope: HashMap<String, usize> = HashMap::new();
for (uri, scoped) in scoped_by_uri {
let uri = normalize_auth_key(uri);
let parts = uri.split('/').count();
for (scope, value) in scoped {
max_scoped_parts_by_scope
.entry(scope.clone())
.and_modify(|max| *max = (*max).max(parts))
.or_insert(parts);
scoped_by_scope.entry(scope).or_default().insert(uri.clone(), value);
}
}
let max_parts = by_uri.keys().map(|key| key.split('/').count()).max().unwrap_or(0);
AuthHeaders { by_uri, scoped_by_scope, max_parts, max_scoped_parts_by_scope }
}
/// Build an [`AuthHeaders`] from the structured pnpr wire shape:
/// `auth_headers[registry_uri][scope]`. The `@` scope stores
/// registry-wide auth.
#[must_use]
pub fn from_by_scope(headers: AuthHeadersByScope) -> Self {
let mut by_uri = HashMap::new();
let mut scoped_by_uri: HashMap<String, HashMap<String, String>> = HashMap::new();
for (uri, headers_by_scope) in headers {
let uri = normalize_auth_key(uri);
for (scope, value) in headers_by_scope {
if scope == DEFAULT_REGISTRY_SCOPE {
by_uri.insert(uri.clone(), value);
} else {
scoped_by_uri.entry(uri.clone()).or_default().insert(scope, value);
}
}
}
Self::from_parts(by_uri, scoped_by_uri)
}
/// The structured `auth_headers[registry_uri][scope]` map backing
/// this lookup, suitable for forwarding to a pnpr resolver.
#[must_use]
pub fn to_by_scope(&self) -> AuthHeadersByScope {
let mut result = AuthHeadersByScope::new();
for (uri, value) in &self.by_uri {
result
.entry(uri.clone())
.or_default()
.insert(DEFAULT_REGISTRY_SCOPE.to_owned(), value.clone());
}
for (scope, scoped_by_uri) in &self.scoped_by_scope {
for (registry_uri, value) in scoped_by_uri {
result
.entry(registry_uri.clone())
.or_default()
.insert(scope.clone(), value.clone());
}
}
result
}
/// Resolve an `Authorization` header for `url`, mirroring pnpm's
@@ -107,7 +189,15 @@ impl AuthHeaders {
/// [`removePort`](https://github.com/pnpm/pnpm/blob/601317e7a3/network/auth-header/src/helpers/removePort.ts),
/// which strips *any* port (not just protocol defaults) and
/// retries iff the URL changed.
#[must_use]
pub fn for_url(&self, url: &str) -> Option<String> {
self.for_url_with_package(url, None)
}
/// Resolve an `Authorization` header for `url`, preferring
/// package-scope credentials when `pkg_name` is scoped.
#[must_use]
pub fn for_url_with_package(&self, url: &str, pkg_name: Option<&str>) -> Option<String> {
// Append a trailing `/` first, matching pnpm's lookup which
// does the same before parsing. Without this, a URL like
// `https://npm.pkg.github.com/pnpm` (registry without
@@ -126,6 +216,17 @@ impl AuthHeaders {
if let Some(basic) = parsed.basic_auth_header() {
return Some(basic);
}
if let Some(scope) = package_scope(pkg_name) {
if let Some(value) = self.lookup_scope_by_nerf(&parsed, scope) {
return Some(value.to_owned());
}
if parsed.port.is_some() {
let stripped = parsed.with_port_stripped();
if let Some(value) = self.lookup_scope_by_nerf(&stripped, scope) {
return Some(value.to_owned());
}
}
}
if let Some(value) = self.lookup_by_nerf(&parsed) {
return Some(value.to_owned());
}
@@ -136,6 +237,21 @@ impl AuthHeaders {
None
}
fn lookup_scope_by_nerf(&self, parsed: &ParsedUrl<'_>, scope: &str) -> Option<&str> {
let scoped_by_uri = self.scoped_by_scope.get(scope)?;
let max_scoped_parts = self.max_scoped_parts_by_scope.get(scope).copied()?;
let nerfed = parsed.nerf_dart();
let parts: Vec<&str> = nerfed.split('/').collect();
let upper = parts.len().min(max_scoped_parts);
for i in (3..upper).rev() {
let key = format!("{}/", parts[..i].join("/"));
if let Some(value) = scoped_by_uri.get(&key) {
return Some(value.as_str());
}
}
None
}
fn lookup_by_nerf(&self, parsed: &ParsedUrl<'_>) -> Option<&str> {
if self.by_uri.is_empty() {
return None;
@@ -162,6 +278,48 @@ impl AuthHeaders {
}
}
fn normalize_auth_key(mut uri: String) -> String {
if !uri.is_empty() && !uri.ends_with('/') {
uri.push('/');
}
uri
}
fn split_scoped_auth_key(uri: &str) -> Option<(String, String)> {
let trimmed = uri.strip_suffix('/').unwrap_or(uri);
if let Some(scope_separator_index) = trimmed.rfind(":@") {
let scope = &trimmed[scope_separator_index + 1..];
if is_package_scope(scope) {
return Some((
normalize_auth_key(trimmed[..scope_separator_index].to_owned()),
scope.to_owned(),
));
}
}
let last_slash_index = trimmed.rfind('/')?;
let scope = &trimmed[last_slash_index + 1..];
if !is_package_scope(scope) {
return None;
}
Some((trimmed[..=last_slash_index].to_owned(), scope.to_owned()))
}
fn is_package_scope(scope: &str) -> bool {
scope.starts_with('@') && scope.len() > 1 && !scope.contains('/') && !scope.contains(':')
}
fn package_scope(pkg_name: Option<&str>) -> Option<&str> {
let pkg_name = pkg_name?;
if !pkg_name.starts_with('@') {
return None;
}
let (scope, name) = pkg_name.split_once('/')?;
if scope.len() <= 1 || name.is_empty() {
return None;
}
Some(scope)
}
/// Strip protocol, query string, fragment, basic-auth, and any
/// trailing characters past the path's final `/`, returning the
/// canonical "nerf-darted" form npm uses as `.npmrc` keys.

View File

@@ -1,4 +1,4 @@
use super::{AuthHeaders, base64_encode, nerf_dart};
use super::{AuthHeaders, DEFAULT_REGISTRY_SCOPE, base64_encode, nerf_dart};
use pretty_assertions::assert_eq;
fn build(entries: &[(&str, &str)]) -> AuthHeaders {
@@ -133,6 +133,123 @@ fn registry_with_pathname_matches_metadata_and_tarballs() {
);
}
#[test]
fn package_scope_auth_wins_over_registry_auth() {
let headers = build(&[
("//npm.pkg.github.com/", "Bearer registry-token"),
("//npm.pkg.github.com/:@orgA", "Bearer org-a-token"),
("//npm.pkg.github.com/:@orgB", "Bearer org-b-token"),
]);
assert_eq!(
headers.for_url_with_package("https://npm.pkg.github.com/", Some("@orgA/pkg")).as_deref(),
Some("Bearer org-a-token"),
);
assert_eq!(
headers.for_url_with_package("https://npm.pkg.github.com/", Some("@orgB/pkg")).as_deref(),
Some("Bearer org-b-token"),
);
assert_eq!(
headers.for_url_with_package("https://npm.pkg.github.com/", Some("@orgC/pkg")).as_deref(),
Some("Bearer registry-token"),
);
assert_eq!(
headers.for_url_with_package("https://npm.pkg.github.com/", Some("pkg")).as_deref(),
Some("Bearer registry-token"),
);
assert_eq!(
headers
.for_url_with_package("https://npm.pkg.github.com/download/pkg.tgz", Some("@orgA/pkg"))
.as_deref(),
Some("Bearer org-a-token"),
);
}
#[test]
fn slash_package_scope_auth_wins_over_registry_auth() {
let headers = build(&[
("//npm.pkg.github.com/", "Bearer registry-token"),
("//npm.pkg.github.com/@orgA", "Bearer org-a-token"),
("//npm.pkg.github.com/@orgB/", "Bearer org-b-token"),
]);
assert_eq!(
headers.for_url_with_package("https://npm.pkg.github.com/", Some("@orgA/pkg")).as_deref(),
Some("Bearer org-a-token"),
);
assert_eq!(
headers.for_url_with_package("https://npm.pkg.github.com/", Some("@orgB/pkg")).as_deref(),
Some("Bearer org-b-token"),
);
}
#[test]
fn package_scope_auth_keeps_registry_path() {
let headers = build(&[
("//reg.com/npm/", "Bearer registry-token"),
("//reg.com/npm/:@orgA", "Bearer org-a-token"),
]);
assert_eq!(
headers.for_url_with_package("https://reg.com/npm/", Some("@orgA/pkg")).as_deref(),
Some("Bearer org-a-token"),
);
assert_eq!(
headers
.for_url_with_package("https://reg.com/npm/pkg/-/pkg-1.0.0.tgz", Some("@orgA/pkg"))
.as_deref(),
Some("Bearer org-a-token"),
);
assert_eq!(
headers.for_url_with_package("https://reg.com/npm/", Some("@orgB/pkg")).as_deref(),
Some("Bearer registry-token"),
);
}
#[test]
fn entries_round_trip_package_scope_auth() {
let headers = build(&[
("//npm.pkg.github.com/", "Bearer registry-token"),
("//npm.pkg.github.com/:@orgA", "Bearer org-a-token"),
("//reg.com/npm/:@orgA", "Bearer org-a-path-token"),
]);
let by_scope = headers.to_by_scope();
assert_eq!(
by_scope
.get("//npm.pkg.github.com/")
.and_then(|scope_headers| scope_headers.get(DEFAULT_REGISTRY_SCOPE))
.map(String::as_str),
Some("Bearer registry-token"),
);
assert_eq!(
by_scope
.get("//npm.pkg.github.com/")
.and_then(|scope_headers| scope_headers.get("@orgA"))
.map(String::as_str),
Some("Bearer org-a-token"),
);
assert_eq!(
by_scope
.get("//reg.com/npm/")
.and_then(|scope_headers| scope_headers.get("@orgA"))
.map(String::as_str),
Some("Bearer org-a-path-token"),
);
let round_tripped = AuthHeaders::from_by_scope(by_scope);
assert_eq!(
round_tripped
.for_url_with_package("https://reg.com/npm/pkg/-/pkg-1.0.0.tgz", Some("@orgA/pkg"))
.as_deref(),
Some("Bearer org-a-path-token"),
);
}
#[test]
fn basic_auth_in_url_wins_over_package_scope_auth() {
let headers = build(&[("//reg.com/:@orgA", "Bearer org-a-token")]);
let header =
headers.for_url_with_package("https://user:secret@reg.com/", Some("@orgA/pkg")).unwrap();
assert_eq!(header, format!("Basic {}", base64_encode("user:secret")));
}
#[test]
fn default_registry_creds_apply_to_npmjs_when_unspecified() {
let headers = AuthHeaders::from_creds_map(

View File

@@ -6,7 +6,7 @@ mod retry;
mod tests;
mod tls;
pub use auth::{AuthHeaders, base64_encode, nerf_dart};
pub use auth::{AuthHeaders, AuthHeadersByScope, DEFAULT_REGISTRY_SCOPE, base64_encode, nerf_dart};
pub use proxy::{NoProxySetting, ProxyConfig, ProxyError};
pub use retry::{RetryOpts, send_with_retry, should_retry_status};
pub use tls::{PerRegistryTls, RegistryTls, TlsConfig, TlsError};

View File

@@ -14,6 +14,7 @@ path = "src/lib.rs"
pacquet-config = { workspace = true }
pacquet-lockfile = { workspace = true }
pacquet-lockfile-verification = { workspace = true }
pacquet-network = { workspace = true }
derive_more = { workspace = true }
futures-util = { workspace = true }
reqwest = { workspace = true }

View File

@@ -21,6 +21,7 @@ use futures_util::StreamExt as _;
use pacquet_config::TrustPolicy;
use pacquet_lockfile::Lockfile;
use pacquet_lockfile_verification::{RenderedViolation, VerifyError};
use pacquet_network::AuthHeadersByScope;
use reqwest::Client;
use serde::Deserialize;
@@ -46,9 +47,9 @@ pub struct ResolveOptions {
/// The client's named-registry aliases.
pub named_registries: DepMap,
/// The caller's forwarded upstream credentials, keyed by nerf-darted
/// registry URI, so the server resolves private content as the
/// caller. Distinct from [`Self::authorization`] (pnpr identity).
pub auth_headers: DepMap,
/// registry URI and package scope. The `@` scope stores registry-wide
/// auth. Distinct from [`Self::authorization`] (pnpr identity).
pub auth_headers: AuthHeadersByScope,
/// `Authorization` for the pnpr server's own URL (`None` if it needs
/// none): identifies the caller to pnpr. Distinct from the upstream
/// creds in [`Self::auth_headers`].
@@ -88,7 +89,7 @@ pub struct ResolveOptions {
pub struct VerifyLockfileOptions {
pub registry: String,
pub named_registries: DepMap,
pub auth_headers: DepMap,
pub auth_headers: AuthHeadersByScope,
pub authorization: Option<String>,
pub overrides: Option<serde_json::Value>,
pub lockfile: Lockfile,

View File

@@ -14,6 +14,7 @@ use std::{
time::Duration,
};
use pacquet_network::{AuthHeadersByScope, DEFAULT_REGISTRY_SCOPE};
use pacquet_pnpr_client::{PnprClient, PnprClientError, ResolveOptions, VerifyLockfileOptions};
use pacquet_testing_utils::registry::TestRegistry;
use tempfile::TempDir;
@@ -51,6 +52,14 @@ fn deps<const COUNT: usize>(entries: [(&str, &str); COUNT]) -> BTreeMap<String,
entries.into_iter().map(|(name, range)| (name.to_string(), range.to_string())).collect()
}
fn auth_headers<const COUNT: usize>(entries: [(&str, &str, &str); COUNT]) -> AuthHeadersByScope {
let mut result = AuthHeadersByScope::new();
for (uri, scope, value) in entries {
result.entry(uri.to_string()).or_default().insert(scope.to_string(), value.to_string());
}
result
}
/// The nerf-darted key (`//host[:port]/path/`) a forwarded credential for
/// `url` is keyed by, mirroring `AuthHeaders`' lookup on the server —
/// keeping any registry path prefix so the key isn't wrong for one.
@@ -113,7 +122,7 @@ async fn forwards_credentials_and_the_identity_header() {
.mock("POST", "/v1/resolve")
.match_header("authorization", "Bearer pnpr-token")
.match_body(mockito::Matcher::PartialJsonString(
r#"{"authHeaders":{"//npm.acme.test/":"Bearer upstream-token"}}"#.to_string(),
r#"{"authHeaders":{"//npm.acme.test/":{"@":"Bearer upstream-token"}}}"#.to_string(),
))
.with_status(500)
.with_body("stop")
@@ -123,7 +132,8 @@ async fn forwards_credentials_and_the_identity_header() {
let client = PnprClient::new(format!("{}/", server.url()));
let mut opts = options("https://npm.acme.test/", deps([("@acme/foo", "1.0.0")]));
opts.auth_headers = deps([("//npm.acme.test/", "Bearer upstream-token")]);
opts.auth_headers =
auth_headers([("//npm.acme.test/", DEFAULT_REGISTRY_SCOPE, "Bearer upstream-token")]);
opts.authorization = Some("Bearer pnpr-token".to_string());
let result = client.resolve(opts).await;
@@ -144,9 +154,10 @@ async fn a_forwarded_credential_resolves_a_private_package() {
let client = PnprClient::new(pnpr_url);
let mut opts = options(&registry.url(), deps([("@pnpm.e2e/needs-auth", "1.0.0")]));
let mut auth = BTreeMap::new();
auth.insert(nerf_key(&registry.url()), format!("Bearer {token}"));
opts.auth_headers = auth;
let registry_key = nerf_key(&registry.url());
let bearer = format!("Bearer {token}");
opts.auth_headers =
auth_headers([(registry_key.as_str(), DEFAULT_REGISTRY_SCOPE, bearer.as_str())]);
let outcome = client.resolve(opts).await.expect("forwarded credential should resolve it");
let packages = outcome.lockfile.packages.as_ref().expect("lockfile has packages");
@@ -367,9 +378,10 @@ async fn verify_lockfile_endpoint_forwards_credentials() {
let (resolve_pnpr_url, _resolve_storage) = start_pnpr().await;
let mut resolve_opts = options(&registry.url(), deps([("@pnpm.e2e/needs-auth", "1.0.0")]));
let mut auth = BTreeMap::new();
auth.insert(nerf_key(&registry.url()), format!("Bearer {token}"));
resolve_opts.auth_headers = auth;
let registry_key = nerf_key(&registry.url());
let bearer = format!("Bearer {token}");
resolve_opts.auth_headers =
auth_headers([(registry_key.as_str(), DEFAULT_REGISTRY_SCOPE, bearer.as_str())]);
let first = PnprClient::new(resolve_pnpr_url)
.resolve(resolve_opts.clone())
.await

View File

@@ -129,7 +129,7 @@ impl Package {
// [`resolving/npm-resolver/src/fetch.ts`](https://github.com/pnpm/pnpm/blob/601317e7a3/resolving/npm-resolver/src/fetch.ts):
// resolve the per-URL `Authorization` value before issuing the
// request and attach it when present.
if let Some(value) = auth_headers.for_url(&url) {
if let Some(value) = auth_headers.for_url_with_package(&url, Some(name)) {
request = request.header("authorization", value);
}
request

View File

@@ -267,7 +267,7 @@ impl PackageVersion {
);
// Same auth flow as `Package::fetch_from_registry`. See the
// doc comment there.
if let Some(value) = auth_headers.for_url(&url) {
if let Some(value) = auth_headers.for_url_with_package(&url, Some(name)) {
request = request.header("authorization", value);
}
request

View File

@@ -55,7 +55,7 @@ pub async fn fetch_attestation_published_at(
let registry = opts.registry.trim_end_matches('/');
let url = format!("{registry}/-/npm/v1/attestations/{pkg_name}@{version}");
let mut request = opts.http_client.acquire_for_url(&url).await.get(&url);
if let Some(value) = opts.auth_headers.for_url(&url) {
if let Some(value) = opts.auth_headers.for_url_with_package(&url, Some(pkg_name)) {
request = request.header("authorization", value);
}
let response = match request.send().await {

View File

@@ -111,7 +111,7 @@ pub async fn fetch_full_metadata(
let accept = if opts.full_metadata { ACCEPT_FULL_DOC } else { ACCEPT_ABBREVIATED_DOC };
let (client, response) = send_with_retry(opts.http_client, &url, opts.retry_opts, |client| {
let mut request = client.get(&url).header(header::ACCEPT, accept);
if let Some(value) = opts.auth_headers.for_url(&url) {
if let Some(value) = opts.auth_headers.for_url_with_package(&url, Some(pkg_name)) {
request = request.header(header::AUTHORIZATION, value);
}
if let Some(etag) = opts.etag {

View File

@@ -98,6 +98,60 @@ async fn fetch_full_metadata_targets_full_endpoint_with_auth() {
mock.assert_async().await;
}
#[tokio::test]
async fn fetch_full_metadata_uses_package_scope_auth() {
let mut server = mockito::Server::new_async().await;
let body = r#"{
"name": "@scope/pkg",
"dist-tags": { "latest": "1.0.0" },
"modified": "2025-01-15T12:00:00.000Z",
"versions": {
"1.0.0": {
"name": "@scope/pkg",
"version": "1.0.0",
"dist": {
"shasum": "0000000000000000000000000000000000000000",
"tarball": "https://registry/@scope/pkg-1.0.0.tgz"
}
}
}
}"#;
let mock = server
.mock("GET", "/@scope%2Fpkg")
.match_header("authorization", "Bearer scoped-token")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(body)
.expect(1)
.create_async()
.await;
let registry = format!("{}/", server.url());
let http_client = ThrottledClient::default();
let auth_headers = AuthHeaders::from_creds_map(
[(
format!("{}@scope", pacquet_network::nerf_dart(&registry)),
"Bearer scoped-token".to_owned(),
)],
None,
);
let opts = FetchFullMetadataOptions {
registry: &registry,
http_client: &http_client,
auth_headers: &auth_headers,
full_metadata: false,
etag: None,
modified: None,
retry_opts: no_retry_opts(),
};
let pkg = expect_modified(
fetch_full_metadata("@scope/pkg", &opts).await.expect("server returns 200"),
);
assert_eq!(pkg.name, "@scope/pkg");
mock.assert_async().await;
}
/// A 5xx response propagates as a [`super::FetchMetadataError::Network`]
/// rather than panicking or silently returning a default-valued
/// `Package`. Mirrors upstream's `fetchFullMetadataCached`

View File

@@ -131,7 +131,7 @@ pub async fn fetch_full_metadata_cached(
let accept = if opts.full_metadata { ACCEPT_FULL_DOC } else { ACCEPT_ABBREVIATED_DOC };
let (client, response) = send_with_retry(opts.http_client, &url, opts.retry_opts, |client| {
let mut request = client.get(&url).header(header::ACCEPT, accept);
if let Some(value) = opts.auth_headers.for_url(&url) {
if let Some(value) = opts.auth_headers.for_url_with_package(&url, Some(pkg_name)) {
request = request.header(header::AUTHORIZATION, value);
}
if let Some(headers) = cache_headers.as_ref() {

View File

@@ -1568,7 +1568,7 @@ async fn fetch_and_extract_once<Reporter: self::Reporter>(
// resolve the per-URL auth header and attach it. Tarball hosts that
// differ from the metadata host still pick up the header keyed at
// the registry's nerf-darted URI.
if let Some(value) = auth_headers.for_url(package_url) {
if let Some(value) = auth_headers.for_url_with_package(package_url, Some(package_id)) {
request = request.header("authorization", value);
}
@@ -2437,7 +2437,7 @@ async fn fetch_and_extract_zip_once<Reporter: self::Reporter>(
// would 401 without this. Keeps parity with pnpm's binary
// fetcher which goes through the same `fetchFromRegistry` /
// auth-header plumbing.
if let Some(value) = auth_headers.for_url(package_url) {
if let Some(value) = auth_headers.for_url_with_package(package_url, Some(package_id)) {
request = request.header("authorization", value);
}

View File

@@ -1638,6 +1638,48 @@ async fn fetch_attaches_authorization_header_when_creds_match_tarball_url() {
drop(store_dir_keep);
}
#[tokio::test]
async fn fetch_attaches_authorization_header_when_scope_creds_match_package_id() {
let (store_dir_keep, store_path) = tempdir_with_leaked_path();
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/pkg.tgz")
.match_header("authorization", "Bearer scoped-token")
.with_status(200)
.with_body(FASTIFY_ERROR_TARBALL)
.expect(1)
.create_async()
.await;
let url = format!("{}/pkg.tgz", server.url());
let client = ThrottledClient::default();
let pkg_integrity = integrity(FASTIFY_ERROR_INTEGRITY);
let registry_key = format!("{}@scope", pacquet_network::nerf_dart(&server.url()));
let auth_headers =
AuthHeaders::from_creds_map([(registry_key, "Bearer scoped-token".to_owned())], None);
let (_integrity, cas_paths, _idx) = fetch_and_extract_with_retry::<SilentReporter>(
&client,
&url,
Some(&pkg_integrity),
None,
0,
"@scope/test-pkg@1.0.0",
"",
store_path,
fast_retry_opts(),
&auth_headers,
None,
None,
)
.await
.expect("server should accept the request once the scoped bearer header is attached");
assert!(cas_paths.contains_key("package.json"));
mock.assert_async().await;
drop(store_dir_keep);
}
/// The retry loop must re-attach the `Authorization` header on every
/// attempt, not just the first. A regression that read `auth_headers`
/// once outside the loop would pass the single-attempt test

View File

@@ -106,9 +106,7 @@ test('switchCliVersion uses trusted package-manager registries instead of projec
}
const packageManagerNetworkConfig = {
configByUri: {
'//trusted.example.com/': {
creds: { authToken: 'trusted-token' },
},
'//trusted.example.com/': { '@': { authToken: 'trusted-token' } },
},
httpProxy: 'http://trusted-http-proxy.example.com:8080',
httpsProxy: 'http://trusted-https-proxy.example.com:8080',
@@ -117,9 +115,7 @@ test('switchCliVersion uses trusted package-manager registries instead of projec
}
const config = {
configByUri: {
'//project.example.com/': {
creds: { authToken: 'project-token' },
},
'//project.example.com/': { '@': { authToken: 'project-token' } },
},
httpProxy: 'http://project-http-proxy.example.com:8080',
httpsProxy: 'http://project-https-proxy.example.com:8080',
@@ -172,9 +168,7 @@ test('switchCliVersion defaults package-manager registries to npmjs instead of p
}
const config = {
configByUri: {
'//project.example.com/': {
creds: { authToken: 'project-token' },
},
'//project.example.com/': { '@': { authToken: 'project-token' } },
},
httpProxy: 'http://project-http-proxy.example.com:8080',
httpsProxy: 'http://project-https-proxy.example.com:8080',

View File

@@ -147,9 +147,7 @@ test('uses trusted package-manager registries instead of project registries', as
}
const packageManagerNetworkConfig = {
configByUri: {
'//trusted.example.com/': {
creds: { authToken: 'trusted-token' },
},
'//trusted.example.com/': { '@': { authToken: 'trusted-token' } },
},
httpProxy: 'http://trusted-http-proxy.example.com:8080',
httpsProxy: 'http://trusted-https-proxy.example.com:8080',
@@ -159,9 +157,7 @@ test('uses trusted package-manager registries instead of project registries', as
await syncEnvLockfile({
configByUri: {
'//project.example.com/': {
creds: { authToken: 'project-token' },
},
'//project.example.com/': { '@': { authToken: 'project-token' } },
},
httpProxy: 'http://project-http-proxy.example.com:8080',
httpsProxy: 'http://project-https-proxy.example.com:8080',
@@ -199,9 +195,7 @@ test('defaults package-manager registries to npmjs instead of project registries
await syncEnvLockfile({
configByUri: {
'//project.example.com/': {
creds: { authToken: 'project-token' },
},
'//project.example.com/': { '@': { authToken: 'project-token' } },
},
httpProxy: 'http://project-http-proxy.example.com:8080',
httpsProxy: 'http://project-https-proxy.example.com:8080',

View File

@@ -8,6 +8,8 @@ import type { LockfileFile, LockfileObject } from '@pnpm/lockfile.types'
import type { ResponseMetadata } from './protocol.js'
export type AuthHeadersByScope = Record<string, Record<string, string>>
export interface PnprProject {
/** Relative dir within the workspace (e.g. "." or "packages/foo") */
dir: string
@@ -36,10 +38,11 @@ export interface ResolveViaPnprServerOptions {
namedRegistries?: Record<string, string>
/**
* The caller's forwarded upstream credentials, keyed by nerf-darted
* registry URI, so the server resolves private content as the
* caller. Distinct from `authorization` (pnpr identity).
* registry URI and package scope, so the server resolves private
* content as the caller. The `@` scope stores registry-wide auth.
* Distinct from `authorization` (pnpr identity).
*/
authHeaders?: Record<string, string>
authHeaders?: AuthHeadersByScope
/**
* `Authorization` for the pnpr server's own URL (`undefined` if none):
* identifies the caller to pnpr's gate.

View File

@@ -219,9 +219,7 @@ pub(crate) async fn handle_resolve(runtime: &Resolver, body: Bytes) -> Response
// The caller's forwarded upstream credentials, threaded through
// resolve/verify but kept out of the interned `config` so it never
// leaks a `&'static Config` per user.
let request_auth = Arc::new(AuthHeaders::from_map(
request.auth_headers.iter().map(|(uri, value)| (uri.clone(), value.clone())).collect(),
));
let request_auth = Arc::new(AuthHeaders::from_by_scope(request.auth_headers.clone()));
// Verify the *input* lockfile under the client's policy before any
// package is streamed ([pnpm/pnpm#12139](https://github.com/pnpm/pnpm/issues/12139)).
@@ -317,9 +315,7 @@ pub(crate) async fn handle_verify_lockfile(runtime: &Resolver, body: Bytes) -> R
}
let config = runtime.config_for(&request);
let request_auth = Arc::new(AuthHeaders::from_map(
request.auth_headers.iter().map(|(uri, value)| (uri.clone(), value.clone())).collect(),
));
let request_auth = Arc::new(AuthHeaders::from_by_scope(request.auth_headers.clone()));
match verify_input_lockfile(runtime, config, &request_auth, input_lockfile).await {
// The dist stats the verifier observed feed `/v1/resolve`'s sized

View File

@@ -3,6 +3,7 @@
use std::collections::BTreeMap;
use pacquet_network::AuthHeadersByScope;
use serde::Deserialize;
pub type DepMap = BTreeMap<String, String>;
@@ -52,12 +53,12 @@ pub struct ResolveRequest {
#[serde(default)]
pub named_registries: BTreeMap<String, String>,
/// The caller's forwarded upstream credentials so the server resolves
/// and fetches private content as the caller. Keyed by nerf-darted
/// registry URI with ready-to-send values, the shape
/// [`pacquet_network::AuthHeaders::from_map`] consumes. Distinct from
/// the request's HTTP `Authorization` header (pnpr identity).
/// and fetches private content as the caller. Keyed as
/// `auth_headers[registry_uri][scope]`; the `@` scope stores
/// registry-wide auth. Distinct from the request's HTTP
/// `Authorization` header (pnpr identity).
#[serde(default)]
pub auth_headers: BTreeMap<String, String>,
pub auth_headers: AuthHeadersByScope,
/// The client's `overrides` (selector -> spec), applied at resolve
/// time. Kept as raw JSON; reconstructed into pacquet's override map
/// server-side.

View File

@@ -40,7 +40,7 @@ export async function updateDeprecation (
const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri ?? {})
const authHeader = getAuthHeader(registryUrl)
const authHeader = getAuthHeader(registryUrl, { pkgName: packageName })
const packageUrl = new URL(npa(packageName).escapedName, registryUrl).href

View File

@@ -115,7 +115,7 @@ async function distTagLs (
const packageName = params[0]
const registryUrl = pickRegistryForPackage(opts.registries ?? { default: 'https://registry.npmjs.org/' }, packageName)
const authHeader = getAuthHeaderForRegistry(opts.configByUri, registryUrl)
const authHeader = getAuthHeaderForRegistry(opts.configByUri, registryUrl, packageName)
const fetchFromRegistry = createFetchFromRegistry(opts)
const distTags = await fetchDistTags(packageName, registryUrl, fetchFromRegistry, authHeader)
@@ -148,7 +148,7 @@ async function distTagAdd (
const tag = params[1] ?? 'latest'
const registryUrl = pickRegistryForPackage(opts.registries ?? { default: 'https://registry.npmjs.org/' }, packageName)
const authHeader = getAuthHeaderForRegistry(opts.configByUri, registryUrl)
const authHeader = getAuthHeaderForRegistry(opts.configByUri, registryUrl, packageName)
const fetchFromRegistry = createFetchFromRegistry(opts)
const cliOtp = opts.cliOptions?.otp
const authType = cliOtp ? 'legacy' : 'web'
@@ -187,7 +187,7 @@ async function distTagRm (
}
const registryUrl = pickRegistryForPackage(opts.registries ?? { default: 'https://registry.npmjs.org/' }, packageName)
const authHeader = getAuthHeaderForRegistry(opts.configByUri, registryUrl)
const authHeader = getAuthHeaderForRegistry(opts.configByUri, registryUrl, packageName)
const fetchFromRegistry = createFetchFromRegistry(opts)
const cliOtp = opts.cliOptions?.otp
@@ -275,10 +275,11 @@ function parseAuthError (body: string, action: string): Error {
function getAuthHeaderForRegistry (
configByUri: Record<string, RegistryConfig> | undefined,
registryUrl: string
registryUrl: string,
packageName: string
): string | undefined {
const getAuthHeader = createGetAuthHeaderByURI(configByUri ?? {})
return getAuthHeader(registryUrl)
return getAuthHeader(registryUrl, { pkgName: packageName })
}
function getDistTagUrl (packageName: string, registryUrl: string, tag: string): string {

View File

@@ -100,7 +100,7 @@ async function ownerLs (
const { name: packageName, escapedName } = parsePackageSpec(params[0])
const registryUrl = pickRegistryForPackage(opts.registries ?? { default: 'https://registry.npmjs.org/' }, packageName)
const authHeader = getAuthHeaderForRegistry(opts.configByUri, registryUrl)
const authHeader = getAuthHeaderForRegistry(opts.configByUri, registryUrl, packageName)
const fetchFromRegistry = createFetchFromRegistry(opts)
const owners = await fetchOwners(packageName, escapedName, registryUrl, fetchFromRegistry, authHeader)
@@ -124,7 +124,7 @@ async function ownerAdd (
const owner = params[1]
const registryUrl = pickRegistryForPackage(opts.registries ?? { default: 'https://registry.npmjs.org/' }, packageName)
const authHeader = getAuthHeaderForRegistry(opts.configByUri, registryUrl)
const authHeader = getAuthHeaderForRegistry(opts.configByUri, registryUrl, packageName)
const fetchFromRegistry = createFetchFromRegistry(opts)
const otp = opts.cliOptions?.otp
@@ -158,7 +158,7 @@ async function ownerRm (
const owner = params[1]
const registryUrl = pickRegistryForPackage(opts.registries ?? { default: 'https://registry.npmjs.org/' }, packageName)
const authHeader = getAuthHeaderForRegistry(opts.configByUri, registryUrl)
const authHeader = getAuthHeaderForRegistry(opts.configByUri, registryUrl, packageName)
const fetchFromRegistry = createFetchFromRegistry(opts)
const otp = opts.cliOptions?.otp
@@ -180,10 +180,11 @@ async function ownerRm (
function getAuthHeaderForRegistry (
configByUri: Record<string, RegistryConfig> | undefined,
registryUrl: string
registryUrl: string,
packageName: string
): string | undefined {
const getAuthHeader = createGetAuthHeaderByURI(configByUri ?? {})
return getAuthHeader(registryUrl)
return getAuthHeader(registryUrl, { pkgName: packageName })
}
function getOwnerUrl (escapedName: string, registryUrl: string, owner?: string): string {
@@ -233,4 +234,4 @@ async function throwRegistryError (response: Response, action: string): Promise<
throw new PnpmError('PACKAGE_NOT_FOUND', `Package not found in registry. ${errorBody}`)
}
throw new PnpmError('REGISTRY_ERROR', `Failed to ${action} package: ${response.status} ${response.statusText}. ${errorBody}`)
}
}

View File

@@ -38,7 +38,7 @@ export async function performStarAction (opts: StarOptions, { packageName, star
const registryUrl = normalizeRegistryUrl(
pickRegistryForPackage(opts.registries ?? { default: 'https://registry.npmjs.org/' }, packageName)
)
const authHeader = getAuthHeaderForRegistry(opts.configByUri, registryUrl)
const authHeader = getAuthHeaderForRegistry(opts.configByUri, registryUrl, packageName)
const action = star ? 'star' : 'unstar'
if (!authHeader) {
throw new PnpmError('STAR_UNAUTHORIZED', `You must be logged in to ${action} packages`)
@@ -131,8 +131,9 @@ async function performLegacyStarAction (args: LegacyStarActionArgs): Promise<voi
export function getAuthHeaderForRegistry (
configByUri: Record<string, RegistryConfig> | undefined,
registryUrl: string
registryUrl: string,
packageName?: string
): string | undefined {
const getAuthHeader = createGetAuthHeaderByURI(configByUri ?? {})
return getAuthHeader(registryUrl)
return getAuthHeader(registryUrl, packageName ? { pkgName: packageName } : undefined)
}

View File

@@ -106,7 +106,7 @@ async function unpublishPackage (
const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri ?? {})
const authHeader = getAuthHeader(registryUrl)
const authHeader = getAuthHeader(registryUrl, { pkgName: packageName })
const packageUrl = new URL(npa(packageName).escapedName, registryUrl).href

View File

@@ -15,9 +15,7 @@ const REGISTRY = `http://localhost:${REGISTRY_MOCK_PORT}`
const CONFIG_BY_URI = {
[`//localhost:${REGISTRY_MOCK_PORT}/`]: {
creds: {
basicAuth: REGISTRY_MOCK_CREDENTIALS,
},
'@': { basicAuth: REGISTRY_MOCK_CREDENTIALS },
},
}

View File

@@ -3,6 +3,7 @@ import { prepare } from '@pnpm/prepare'
import { distTag } from '@pnpm/registry-access.commands'
import { publish } from '@pnpm/releasing.commands'
import { DEFAULT_OPTS as BASE_OPTS } from '@pnpm/testing.command-defaults'
import { getMockAgent, setupMockAgent, teardownMockAgent } from '@pnpm/testing.mock-agent'
import { REGISTRY_MOCK_CREDENTIALS, REGISTRY_MOCK_PORT } from '@pnpm/testing.registry-mock'
const DEFAULT_OPTS = {
@@ -12,9 +13,7 @@ const DEFAULT_OPTS = {
const CONFIG_BY_URI = {
[`//localhost:${REGISTRY_MOCK_PORT}/`]: {
creds: {
basicAuth: REGISTRY_MOCK_CREDENTIALS,
},
'@': { basicAuth: REGISTRY_MOCK_CREDENTIALS },
},
}
@@ -45,6 +44,39 @@ test('dist-tag ls: should list dist-tags', async () => {
expect(result).toContain('latest: 1.0.0')
})
test('dist-tag ls: should use package-scoped auth', async () => {
await setupMockAgent()
try {
const mockPool = getMockAgent().get('https://registry.example.com')
const encodedName = '@scope%2f' + 'pkg'
mockPool.intercept({
method: 'GET',
path: `/-/package/${encodedName}/dist-tags`,
}).reply(({ headers }) => {
expect((headers as Record<string, string>)['authorization']).toBe('Bearer scoped-token')
return {
statusCode: 200,
data: JSON.stringify({ latest: '1.0.0' }),
}
})
const result = await distTag.handler({
cliOptions: {},
configByUri: {
'//registry.example.com/': {
'@': { authToken: 'default-token' },
'@scope': { authToken: 'scoped-token' },
},
},
registries: { default: 'https://registry.example.com/' },
}, ['ls', '@scope/pkg'])
expect(result).toBe('latest: 1.0.0')
} finally {
await teardownMockAgent()
}
})
test('dist-tag ls: should list dist-tags without subcommand', async () => {
const pkgName = 'test-dist-tag-ls-default'
await publishVersion(pkgName, '1.0.0')

View File

@@ -7,9 +7,7 @@ const REGISTRY = 'https://registry.npmjs.org'
const REGISTRY_URL = `${REGISTRY}/`
const CONFIG_BY_URI = {
'//registry.npmjs.org/': {
creds: {
authToken: 'test-token',
},
'@': { authToken: 'test-token' },
},
}

View File

@@ -15,9 +15,7 @@ const REGISTRY = `http://localhost:${REGISTRY_MOCK_PORT}`
const CONFIG_BY_URI = {
[`//localhost:${REGISTRY_MOCK_PORT}/`]: {
creds: {
basicAuth: REGISTRY_MOCK_CREDENTIALS,
},
'@': { basicAuth: REGISTRY_MOCK_CREDENTIALS },
},
}

View File

@@ -8,9 +8,7 @@ const REGISTRY_URL = `${REGISTRY}/`
const AUTH_HEADER = 'Bearer test-token'
const CONFIG_BY_URI = {
'//registry.npmjs.org/': {
creds: {
authToken: 'test-token',
},
'@': { authToken: 'test-token' },
},
}

View File

@@ -5,7 +5,7 @@ import { PnpmError } from '@pnpm/error'
import { globalInfo, globalWarn } from '@pnpm/logger'
import { createDispatchedFetch } from '@pnpm/network.fetch'
import type { ExportedManifest } from '@pnpm/releasing.exportable-manifest'
import type { Creds, RegistryConfig } from '@pnpm/types'
import { type Creds, DEFAULT_REGISTRY_SCOPE, type RegistryConfig } from '@pnpm/types'
import type { PublishOptions } from 'libnpmpublish'
import { createPublishSummary, type PublishSummary } from '../tarball/publishSummary.js'
@@ -121,7 +121,8 @@ export async function createPublishOptions (
? manifest.publishConfig.registry
: undefined
const { registry, config } = findRegistryInfo(manifest, options, publishConfigRegistry)
const { creds, tls } = config ?? {}
const tls = config?.tls
const creds = config?.[DEFAULT_REGISTRY_SCOPE]
const publishConfigAccess = manifest.publishConfig?.access
const access = options.access ?? (isPublishAccess(publishConfigAccess) ? publishConfigAccess : null)
@@ -237,20 +238,25 @@ export function findRegistryInfo (
longestConfigKey: initialRegistryConfigKey,
} = supportedRegistryInfo
const credsScope: `@${string}` = registryName === 'default' ? DEFAULT_REGISTRY_SCOPE : registryName as `@${string}`
let creds: Creds | undefined
let tls: RegistryConfig['tls'] = {}
for (const registryConfigKey of allRegistryConfigKeys(initialRegistryConfigKey)) {
const entry = configByUri[registryConfigKey]
if (!entry) continue
// Auth from longer path collectively overrides shorter path
creds ??= entry.creds
creds ??= entry[credsScope] ?? entry[DEFAULT_REGISTRY_SCOPE]
// TLS from longer path individually overrides shorter path
tls = { ...entry.tls, ...tls }
}
const config: RegistryConfig = { tls }
if (creds) {
config[DEFAULT_REGISTRY_SCOPE] = creds
}
return {
registry,
config: { creds, tls },
config,
}
}

View File

@@ -27,7 +27,7 @@ export function createStageContext (opts: StageOptions, packageName?: string): S
return {
opts,
registry,
authHeaderValue: getAuthHeaderByUri(registry),
authHeaderValue: packageName ? getAuthHeaderByUri(registry, { pkgName: packageName }) : getAuthHeaderByUri(registry),
fetchFromRegistry: createFetchFromRegistry(opts),
}
}

View File

@@ -85,7 +85,7 @@ function batchPublishOpts () {
batch: true,
configByUri: {
[registry.url.replace(/^http:/, '')]: {
creds: { authToken: 'test-token' },
'@': { authToken: 'test-token' },
},
},
dir: process.cwd(),
@@ -208,7 +208,7 @@ test('batch publish against a real pnpr registry publishes every package', async
batch: true,
configByUri: {
[`//localhost:${REGISTRY_MOCK_PORT}/`]: {
creds: { basicAuth: REGISTRY_MOCK_CREDENTIALS },
'@': { basicAuth: REGISTRY_MOCK_CREDENTIALS },
},
},
dir: process.cwd(),

View File

@@ -20,9 +20,7 @@ const skipOnWindowsCI = isCI && isWindows() ? test.skip : test
const CONFIG_BY_URI = {
[`//localhost:${REGISTRY_MOCK_PORT}/`]: {
creds: {
basicAuth: REGISTRY_MOCK_CREDENTIALS,
},
'@': { basicAuth: REGISTRY_MOCK_CREDENTIALS },
},
}
const pnpmBin = path.join(import.meta.dirname, '../../../../pnpm/bin/pnpm.mjs')
@@ -983,9 +981,7 @@ test('publish: use basic token helper for authentication', async () => {
argv: { original: ['publish'] },
configByUri: {
[`//localhost:${REGISTRY_MOCK_PORT}/`]: {
creds: {
tokenHelper: [tokenHelper],
},
'@': { tokenHelper: [tokenHelper] },
},
},
dir: process.cwd(),
@@ -1012,9 +1008,7 @@ test('publish: use bearer token helper for authentication', async () => {
argv: { original: ['publish'] },
configByUri: {
[`//localhost:${REGISTRY_MOCK_PORT}/`]: {
creds: {
tokenHelper: [tokenHelper],
},
'@': { tokenHelper: [tokenHelper] },
},
},
dir: process.cwd(),

View File

@@ -65,3 +65,23 @@ describe('createPublishOptions: access', () => {
expect(opts.access).toBeNull()
})
})
describe('createPublishOptions: auth', () => {
test('prefers package-scoped credentials over registry-wide credentials', async () => {
const opts = await createPublishOptions(
{ name: '@scope/pkg', version: '1.0.0' },
{
...baseOpts(),
configByUri: {
'//registry.npmjs.org/': {
'@': { authToken: 'default-token' },
'@scope': { authToken: 'scoped-token' },
},
},
},
{ oidc: false }
)
expect(opts.token).toBe('scoped-token')
})
})

View File

@@ -16,9 +16,7 @@ import { checkPkgExists, DEFAULT_OPTS } from './utils/index.js'
const CONFIG_BY_URI = {
[`//localhost:${REGISTRY_MOCK_PORT}/`]: {
creds: {
basicAuth: REGISTRY_MOCK_CREDENTIALS,
},
'@': { basicAuth: REGISTRY_MOCK_CREDENTIALS },
},
}

View File

@@ -117,6 +117,30 @@ describe('stage command', () => {
.rejects.toThrow('Version specifiers are not supported for listing staged packages')
})
test('stage list uses package-scoped auth for package filters', async () => {
const registry = await createRegistry((request) => {
expect(headerValue(request.headers.authorization)).toBe('Bearer scoped-token')
return { body: { items: [], page: 0, perPage: 100, total: 0 } }
})
try {
const registryUrl = new URL(registry.url)
const result = await stage.handler({
...stageOpts(registry.url),
argv: { original: ['stage', 'list'] },
configByUri: {
[`//${registryUrl.host}/`]: {
'@': { authToken: 'default-token' },
'@scope': { authToken: 'scoped-token' },
},
},
}, ['list', '@scope/example-package'])
expect(result).toBe('No staged versions of package name "@scope/example-package".')
} finally {
await registry.close()
}
})
test('stage approve and reject send configured OTP', async () => {
const seen: Array<{ authType: string | undefined, method: string, npmCommand: string | undefined, otp: string | undefined, pathname: string }> = []
const registry = await createRegistry((request) => {

View File

@@ -2,6 +2,7 @@ import { pickRegistryForPackage } from '@pnpm/config.pick-registry-for-package'
import { createPackageVersionPolicy } from '@pnpm/config.version-policy'
import { FULL_META_DIR } from '@pnpm/constants'
import { PnpmError } from '@pnpm/error'
import type { GetAuthHeader } from '@pnpm/fetching.types'
import type { PackageInRegistry, PackageMeta } from '@pnpm/resolving.registry.types'
import type {
Resolution,
@@ -80,7 +81,7 @@ export interface CreateNpmResolutionVerifierOptions {
* hide the publish timestamp.
*/
fetchOpts: FetchMetadataFromFromRegistryOptions
getAuthHeaderValueByURI: (registry: string) => string | undefined
getAuthHeaderValueByURI: GetAuthHeader
cacheDir?: FetchFullMetadataCachedOptions['cacheDir']
/**
* Per-install LRU shared with the npm resolver's `pickPackage`
@@ -487,7 +488,7 @@ function fetchFullMetaForTrust (
// workspaces OOMs CI runners with a 2GB heap (see #11860).
cachedPromise = fetchFullMetadataCached(context.fetchOpts, name, {
registry,
authHeaderValue: context.getAuthHeaderValueByURI(registry),
authHeaderValue: context.getAuthHeaderValueByURI(registry, { pkgName: name }),
cacheDir: context.cacheDir,
}).then(projectTrustMeta)
}
@@ -553,7 +554,7 @@ type PublishedAtTimeMap = Record<string, string | undefined>
interface PublishedAtLookupContext {
fetchOpts: FetchMetadataFromFromRegistryOptions
getAuthHeaderValueByURI: (registry: string) => string | undefined
getAuthHeaderValueByURI: GetAuthHeader
cacheDir?: string
/**
* The `minimumReleaseAge` cutoff converted to a unix-ms epoch. A
@@ -664,7 +665,7 @@ async function resolvePublishedAt (
const attestationTime = await fetchAttestationPublishedAt(context.fetchOpts, name, version, {
registry,
authHeaderValue: context.getAuthHeaderValueByURI(registry),
authHeaderValue: context.getAuthHeaderValueByURI(registry, { pkgName: name }),
})
if (attestationTime != null) return attestationTime
@@ -729,7 +730,7 @@ function fetchAbbreviatedMeta (
} else {
cachedPromise = fetchAbbreviatedMetadataCached(context.fetchOpts, name, {
registry,
authHeaderValue: context.getAuthHeaderValueByURI(registry),
authHeaderValue: context.getAuthHeaderValueByURI(registry, { pkgName: name }),
cacheDir: context.cacheDir,
}).then(projectAbbreviatedMeta, () => undefined)
}
@@ -841,7 +842,7 @@ function fetchFullMetaTime (
if (cachedPromise == null) {
cachedPromise = fetchFullMetadataCached(context.fetchOpts, name, {
registry,
authHeaderValue: context.getAuthHeaderValueByURI(registry),
authHeaderValue: context.getAuthHeaderValueByURI(registry, { pkgName: name }),
cacheDir: context.cacheDir,
}).then((meta) => meta.time)
context.fullMetaCache.set(cacheKey, cachedPromise)

View File

@@ -361,7 +361,7 @@ function createResolveLatest (
export interface ResolveFromNpmContext {
pickPackage: (spec: RegistryPackageSpec, opts: PickPackageOptions) => ReturnType<typeof pickPackage>
getAuthHeaderValueByURI: (registry: string) => string | undefined
getAuthHeaderValueByURI: GetAuthHeader
registries: Registries
namedRegistries: Record<string, string>
namedRegistryNames: ReadonlySet<string>
@@ -492,7 +492,7 @@ async function resolveNpm (
}
}
const authHeaderValue = ctx.getAuthHeaderValueByURI(registry)
const authHeaderValue = ctx.getAuthHeaderValueByURI(registry, { pkgName: spec.name })
let pickResult!: { meta: PackageMeta, pickedPackage: PackageInRegistry | null }
try {
pickResult = await ctx.pickPackage(spec, {
@@ -730,7 +730,7 @@ async function pickFromSimpleRegistry (
publishedAt?: string
policyViolation?: ResolutionPolicyViolation
}> {
const authHeaderValue = ctx.getAuthHeaderValueByURI(registry)
const authHeaderValue = ctx.getAuthHeaderValueByURI(registry, { pkgName: spec.name })
const { meta, pickedPackage } = await ctx.pickPackage(spec, {
pickLowestVersion: opts.pickLowestVersion,
publishedBy: opts.publishedBy,

View File

@@ -75,6 +75,41 @@ test('createNpmResolutionVerifier() still verifies tarball URLs when no age/trus
expect(result).toMatchObject({ ok: false, code: 'TARBALL_URL_MISMATCH' })
})
test('createNpmResolutionVerifier() passes package name to auth header lookup', async () => {
const tarball = 'https://registry.npmjs.org/@scope/pkg/-/pkg-1.0.0.tgz'
const meta = {
name: '@scope/pkg',
'dist-tags': { latest: '1.0.0' },
versions: {
'1.0.0': {
name: '@scope/pkg',
version: '1.0.0',
dist: { tarball, shasum: 'aa' },
},
},
modified: '2020-01-01T00:00:00.000Z',
}
const pool = getMockAgent().get('https://registry.npmjs.org')
pool.intercept({
path: `/@scope${'%2F'}pkg`,
method: 'GET',
headers: { authorization: 'Bearer scoped-token' },
}).reply(200, meta).persist()
const calls: Array<{ uri: string, pkgName?: string }> = []
const scopedGetAuthHeader = (uri: string, opts?: { pkgName?: string }): string | undefined => {
calls.push({ uri, pkgName: opts?.pkgName })
return opts?.pkgName === '@scope/pkg' ? 'Bearer scoped-token' : undefined
}
const verifier = createNpmResolutionVerifier(makeVerifierOpts({ getAuthHeaderValueByURI: scopedGetAuthHeader }))
const result = await verifier.verify(
{ integrity: FAKE_INTEGRITY, tarball } as unknown as Resolution,
{ name: '@scope/pkg', version: '1.0.0' }
)
expect(result).toStrictEqual({ ok: true })
expect(calls).toContainEqual({ uri: registries.default, pkgName: '@scope/pkg' })
})
test('createNpmResolutionVerifier() flags a trustedPublisher → provenance downgrade', async () => {
// 0.0.1 was published by a trustedPublisher with provenance → rank 2.
// 0.0.2 is provenance-only (rank 1, weaker) → downgrade vs 0.0.1.

View File

@@ -320,6 +320,31 @@ test('can resolve aliased scoped dependency', async () => {
expect(resolveResult!.id).toBe('@sindresorhus/is@0.6.0')
})
test('resolveFromNpm() passes package name to auth header lookup', async () => {
getMockAgent().get(registries.default.replace(/\/$/, ''))
.intercept({
path: '/@sindresorhus%2Fis',
method: 'GET',
headers: { authorization: 'Bearer scoped-token' },
})
.reply(200, sindresorhusIsMeta)
const calls: Array<{ uri: string, pkgName?: string }> = []
const scopedGetAuthHeader = (uri: string, opts?: { pkgName?: string }): string | undefined => {
calls.push({ uri, pkgName: opts?.pkgName })
return opts?.pkgName === '@sindresorhus/is' ? 'Bearer scoped-token' : undefined
}
const { resolveFromNpm } = createNpmResolver(fetch, scopedGetAuthHeader, {
storeDir: temporaryDirectory(),
cacheDir: temporaryDirectory(),
registries,
})
const resolveResult = await resolveFromNpm({ alias: 'is', bareSpecifier: 'npm:@sindresorhus/is@0.6.0' }, {})
expect(resolveResult!.id).toBe('@sindresorhus/is@0.6.0')
expect(calls).toContainEqual({ uri: registries.default, pkgName: '@sindresorhus/is' })
})
test('can resolve aliased scoped dependency w/o version specifier', async () => {
getMockAgent().get(registries.default.replace(/\/$/, ''))
.intercept({ path: '/@sindresorhus%2Fis', method: 'GET' })