mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-27 17:35:30 -04:00
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:
34
.changeset/scoped-registry-auth.md
Normal file
34
.changeset/scoped-registry-auth.md
Normal 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
1
Cargo.lock
generated
@@ -4104,6 +4104,7 @@ dependencies = [
|
||||
"pacquet-config",
|
||||
"pacquet-lockfile",
|
||||
"pacquet-lockfile-verification",
|
||||
"pacquet-network",
|
||||
"pacquet-testing-utils",
|
||||
"pnpr",
|
||||
"reqwest 0.13.4",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -143,7 +143,7 @@ describe('login', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('should persist a scope→registry 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)
|
||||
}
|
||||
|
||||
@@ -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)?$/
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
2
deps/compliance/commands/test/audit/index.ts
vendored
2
deps/compliance/commands/test/audit/index.ts
vendored
@@ -280,7 +280,7 @@ describe('plugin-commands-audit', () => {
|
||||
dir: hasVulnerabilitiesDir,
|
||||
rootProjectManifestDir: hasVulnerabilitiesDir,
|
||||
configByUri: {
|
||||
'//audit.registry/': { creds: { authToken: '123' } },
|
||||
'//audit.registry/': { '@': { authToken: '123' } },
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ export async function fetchPackageInfo (
|
||||
packageName,
|
||||
{
|
||||
registry,
|
||||
authHeaderValue: getAuthHeader(registry),
|
||||
authHeaderValue: getAuthHeader(registry, { pkgName: packageName }),
|
||||
fullMetadata: true,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -71,7 +71,7 @@ export function createTarballFetcher (
|
||||
async function fetchFromTarball (
|
||||
ctx: {
|
||||
download: DownloadFunction
|
||||
getAuthHeaderByURI: (registry: string) => string | undefined
|
||||
getAuthHeaderByURI: GetAuthHeader
|
||||
offline?: boolean
|
||||
storeIndex: StoreIndex
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(®istry.url(), deps([("@pnpm.e2e/needs-auth", "1.0.0")]));
|
||||
let mut auth = BTreeMap::new();
|
||||
auth.insert(nerf_key(®istry.url()), format!("Bearer {token}"));
|
||||
opts.auth_headers = auth;
|
||||
let registry_key = nerf_key(®istry.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(®istry.url(), deps([("@pnpm.e2e/needs-auth", "1.0.0")]));
|
||||
let mut auth = BTreeMap::new();
|
||||
auth.insert(nerf_key(®istry.url()), format!("Bearer {token}"));
|
||||
resolve_opts.auth_headers = auth;
|
||||
let registry_key = nerf_key(®istry.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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(®istry)),
|
||||
"Bearer scoped-token".to_owned(),
|
||||
)],
|
||||
None,
|
||||
);
|
||||
let opts = FetchFullMetadataOptions {
|
||||
registry: ®istry,
|
||||
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`
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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' })
|
||||
|
||||
Reference in New Issue
Block a user