diff --git a/.changeset/scoped-registry-auth.md b/.changeset/scoped-registry-auth.md new file mode 100644 index 0000000000..48a0eb64b2 --- /dev/null +++ b/.changeset/scoped-registry-auth.md @@ -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. diff --git a/Cargo.lock b/Cargo.lock index 7e4c93c062..ee8cbabf05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4104,6 +4104,7 @@ dependencies = [ "pacquet-config", "pacquet-lockfile", "pacquet-lockfile-verification", + "pacquet-network", "pacquet-testing-utils", "pnpr", "reqwest 0.13.4", diff --git a/auth/commands/src/login.ts b/auth/commands/src/login.ts index 5cc9781966..552c87f42b 100644 --- a/auth/commands/src/login.ts +++ b/auth/commands/src/login.ts @@ -51,7 +51,7 @@ export function help (): string { name: '--registry ', }, { - 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 ', }, ], @@ -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 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() diff --git a/auth/commands/test/login.test.ts b/auth/commands/test/login.test.ts index 22aac41f2b..3e150bf88c 100644 --- a/auth/commands/test/login.test.ts +++ b/auth/commands/test/login.test.ts @@ -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 = {} 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 = {} + 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) } diff --git a/config/reader/src/getNetworkConfigs.ts b/config/reader/src/getNetworkConfigs.ts index 36aeb7aaba..1aec6bae97 100644 --- a/config/reader/src/getNetworkConfigs.ts +++ b/config/reader/src/getNetworkConfigs.ts @@ -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): NetworkConfigs { - const rawCredsMap: Record = {} + const rawCredsMap: Record> = {} const registries: Record = {} const networkConfigs: NetworkConfigs = { registries } for (const [configKey, value] of Object.entries(rawConfig)) { @@ -22,9 +22,10 @@ export function getNetworkConfigs (rawConfig: Record): 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): 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 = { 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 = {}): Record { + const scopedCreds: Record = {} + 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 = /:(?cert|key|ca)(?file)?$/ diff --git a/config/reader/test/getNetworkConfigs.test.ts b/config/reader/test/getNetworkConfigs.test.ts index 1b09ca6859..6e35a2208c 100644 --- a/config/reader/test/getNetworkConfigs.test.ts +++ b/config/reader/test/getNetworkConfigs.test.ts @@ -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', diff --git a/config/reader/test/index.ts b/config/reader/test/index.ts index b45905bb12..75f804d656 100644 --- a/config/reader/test/index.ts +++ b/config/reader/test/index.ts @@ -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() }) diff --git a/core/types/src/misc.ts b/core/types/src/misc.ts index 7495bc9e9e..98a89e96a9 100644 --- a/core/types/src/misc.ts +++ b/core/types/src/misc.ts @@ -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 } diff --git a/deps/compliance/commands/test/audit/index.ts b/deps/compliance/commands/test/audit/index.ts index 7984fbc1da..4a8eb51ea1 100644 --- a/deps/compliance/commands/test/audit/index.ts +++ b/deps/compliance/commands/test/audit/index.ts @@ -280,7 +280,7 @@ describe('plugin-commands-audit', () => { dir: hasVulnerabilitiesDir, rootProjectManifestDir: hasVulnerabilitiesDir, configByUri: { - '//audit.registry/': { creds: { authToken: '123' } }, + '//audit.registry/': { '@': { authToken: '123' } }, }, }) diff --git a/deps/inspection/commands/src/fetchPackageInfo.ts b/deps/inspection/commands/src/fetchPackageInfo.ts index e08d5e0f73..ed3fa9af8f 100644 --- a/deps/inspection/commands/src/fetchPackageInfo.ts +++ b/deps/inspection/commands/src/fetchPackageInfo.ts @@ -68,7 +68,7 @@ export async function fetchPackageInfo ( packageName, { registry, - authHeaderValue: getAuthHeader(registry), + authHeaderValue: getAuthHeader(registry, { pkgName: packageName }), fullMetadata: true, } ) diff --git a/fetching/tarball-fetcher/src/index.ts b/fetching/tarball-fetcher/src/index.ts index 6c8231e878..be48502641 100644 --- a/fetching/tarball-fetcher/src/index.ts +++ b/fetching/tarball-fetcher/src/index.ts @@ -71,7 +71,7 @@ export function createTarballFetcher ( async function fetchFromTarball ( ctx: { download: DownloadFunction - getAuthHeaderByURI: (registry: string) => string | undefined + getAuthHeaderByURI: GetAuthHeader offline?: boolean storeIndex: StoreIndex }, diff --git a/fetching/tarball-fetcher/src/remoteTarballFetcher.ts b/fetching/tarball-fetcher/src/remoteTarballFetcher.ts index 085c7df0bc..ac47af79a7 100644 --- a/fetching/tarball-fetcher/src/remoteTarballFetcher.ts +++ b/fetching/tarball-fetcher/src/remoteTarballFetcher.ts @@ -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 + pkg?: FetchOptions['pkg'] +} & Pick export type DownloadFunction = (url: string, opts: DownloadOptions) => Promise @@ -64,7 +65,7 @@ export function createDownloader ( const fetchMinSpeedKiBps = gotOpts.fetchMinSpeedKiBps ?? 50 // 50 KiB/s return async function download (url: string, opts: DownloadOptions): Promise { - const authHeaderValue = opts.getAuthHeaderByURI(url) + const authHeaderValue = opts.getAuthHeaderByURI(url, { pkgName: opts.pkg?.name }) const op = retry.operation(retryOpts) diff --git a/fetching/tarball-fetcher/test/fetch.ts b/fetching/tarball-fetcher/test/fetch.ts index 63ac6a306c..51a66dc6ac 100644 --- a/fetching/tarball-fetcher/test/fetch.ts +++ b/fetching/tarball-fetcher/test/fetch.ts @@ -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() } diff --git a/fetching/types/src/index.ts b/fetching/types/src/index.ts index 3b0798a28f..e8b0cb17db 100644 --- a/fetching/types/src/index.ts +++ b/fetching/types/src/index.ts @@ -20,4 +20,8 @@ export type FetchFromRegistry = ( } ) => Promise -export type GetAuthHeader = (uri: string) => string | undefined +export interface GetAuthHeaderOptions { + pkgName?: string +} + +export type GetAuthHeader = (uri: string, opts?: GetAuthHeaderOptions) => string | undefined diff --git a/installing/deps-installer/src/install/index.ts b/installing/deps-installer/src/install/index.ts index f8ec551fe0..d4414cdf62 100644 --- a/installing/deps-installer/src/install/index.ts +++ b/installing/deps-installer/src/install/index.ts @@ -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, diff --git a/installing/deps-installer/test/install/auth.ts b/installing/deps-installer/test/install/auth.ts index 8b37d0a4f3..8a1fdca35d 100644 --- a/installing/deps-installer/test/install/auth.ts +++ b/installing/deps-installer/test/install/auth.ts @@ -21,7 +21,7 @@ test('a package that need authentication', async () => { }) let configByUri: Record = { - [`//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 = { - [`//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 = { - [`//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 = { - [`//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 = { - [`//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 = { - [`//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: { diff --git a/network/auth-header/src/getAuthHeadersFromConfig.ts b/network/auth-header/src/getAuthHeadersFromConfig.ts index 01390ddb76..2dcf5c404d 100644 --- a/network/auth-header/src/getAuthHeadersFromConfig.ts +++ b/network/auth-header/src/getAuthHeadersFromConfig.ts @@ -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 + scopedAuthHeaderValueByURI: Record> +} + +export type AuthHeadersByScope = Record> export function getAuthHeadersFromCreds ( configByUri: Record -): Record { - const authHeaderValueByURI: Record = {} +): 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 { diff --git a/network/auth-header/src/index.ts b/network/auth-header/src/index.ts index d0abddd2f4..4228587b02 100644 --- a/network/auth-header/src/index.ts +++ b/network/auth-header/src/index.ts @@ -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 +} + +interface ScopedAuthHeaderLookup { + authHeaderValueByURI: Record + maxParts: number +} export function createGetAuthHeaderByURI ( configByUri: Record -): (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, maxParts: number, uri: string): string | undefined { +function getScopedAuthHeaderValueByScope ( + authHeaders: Record> +): Record { + const result: Record = {} + 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, 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, 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}`) diff --git a/network/auth-header/test/getAuthHeaderByURI.ts b/network/auth-header/test/getAuthHeaderByURI.ts index 08d99e66df..9d8e6cc3a5 100644 --- a/network/auth-header/test/getAuthHeaderByURI.ts +++ b/network/auth-header/test/getAuthHeaderByURI.ts @@ -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')) +}) diff --git a/network/auth-header/test/getAuthHeadersFromConfig.test.ts b/network/auth-header/test/getAuthHeadersFromConfig.test.ts index 87b74881b7..1dd61fb3bc 100644 --- a/network/auth-header/test/getAuthHeadersFromConfig.test.ts +++ b/network/auth-header/test/getAuthHeadersFromConfig.test.ts @@ -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', + }, + }) }) }) - diff --git a/pacquet/crates/cli/src/cli_args/install.rs b/pacquet/crates/cli/src/cli_args/install.rs index 49377f70a3..5cde83a5bc 100644 --- a/pacquet/crates/cli/src/cli_args/install.rs +++ b/pacquet/crates/cli/src/cli_args/install.rs @@ -600,12 +600,7 @@ async fn install_via_pnpr( // 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 diff --git a/pacquet/crates/config/src/lib.rs b/pacquet/crates/config/src/lib.rs index 8224d45da7..dd97635009 100644 --- a/pacquet/crates/config/src/lib.rs +++ b/pacquet/crates/config/src/lib.rs @@ -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::(); - (!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 diff --git a/pacquet/crates/config/src/npmrc_auth.rs b/pacquet/crates/config/src/npmrc_auth.rs index a5bce2bf79..a386d8794c 100644 --- a/pacquet/crates/config/src/npmrc_auth.rs +++ b/pacquet/crates/config/src/npmrc_auth.rs @@ -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, + /// Per-registry creds keyed as `[registry_uri][scope]`. The `@` + /// scope stores registry-wide credentials. + pub creds_by_scope_by_uri: HashMap>, /// `${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 = 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> = + 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) { + 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) { + 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 diff --git a/pacquet/crates/config/src/npmrc_auth/tests.rs b/pacquet/crates/config/src/npmrc_auth/tests.rs index 3536ba545c..5b7e1f1714 100644 --- a/pacquet/crates/config/src/npmrc_auth/tests.rs +++ b/pacquet/crates/config/src/npmrc_auth/tests.rs @@ -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> { + 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> { + 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::(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::(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::(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::(ini, Path::new("")).apply_to::(&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::(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::::new()); } @@ -316,10 +390,7 @@ fn env_replace_failure_warns_and_drops_unresolved_to_empty() { // literal placeholder. See . let ini = "//reg.com/:_authToken=${MISSING}\n"; let auth = NpmrcAuth::from_ini::(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::(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::(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::::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::(); - 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::(); - 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::(); - 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::(); - 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::(); - assert!(auth.creds_by_uri.is_empty()); + assert!(auth.creds_by_scope_by_uri.is_empty()); } diff --git a/pacquet/crates/network/src/auth.rs b/pacquet/crates/network/src/auth.rs index 3408c069d7..6701e6b164 100644 --- a/pacquet/crates/network/src/auth.rs +++ b/pacquet/crates/network/src/auth.rs @@ -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>; /// 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, + /// 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>, /// 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, } 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) -> 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) -> Self { + let mut by_uri = HashMap::new(); + let mut scoped_by_uri: HashMap> = 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 { - 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, + scoped_by_uri: HashMap>, + ) -> Self { + let by_uri: HashMap = + by_uri.into_iter().map(|(uri, value)| (normalize_auth_key(uri), value)).collect(); + let mut scoped_by_scope: HashMap> = HashMap::new(); + let mut max_scoped_parts_by_scope: HashMap = 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> = 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 { + 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 { // 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. diff --git a/pacquet/crates/network/src/auth/tests.rs b/pacquet/crates/network/src/auth/tests.rs index b3f36189de..8406546b8a 100644 --- a/pacquet/crates/network/src/auth/tests.rs +++ b/pacquet/crates/network/src/auth/tests.rs @@ -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( diff --git a/pacquet/crates/network/src/lib.rs b/pacquet/crates/network/src/lib.rs index e00c9b581d..a45a4227b3 100644 --- a/pacquet/crates/network/src/lib.rs +++ b/pacquet/crates/network/src/lib.rs @@ -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}; diff --git a/pacquet/crates/pnpr-client/Cargo.toml b/pacquet/crates/pnpr-client/Cargo.toml index 8af11a9383..d9588b3fa7 100644 --- a/pacquet/crates/pnpr-client/Cargo.toml +++ b/pacquet/crates/pnpr-client/Cargo.toml @@ -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 } diff --git a/pacquet/crates/pnpr-client/src/lib.rs b/pacquet/crates/pnpr-client/src/lib.rs index 0327902d52..e0cd9eff52 100644 --- a/pacquet/crates/pnpr-client/src/lib.rs +++ b/pacquet/crates/pnpr-client/src/lib.rs @@ -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, pub overrides: Option, pub lockfile: Lockfile, diff --git a/pacquet/crates/pnpr-client/tests/integration.rs b/pacquet/crates/pnpr-client/tests/integration.rs index 5229955d69..5c57967a0d 100644 --- a/pacquet/crates/pnpr-client/tests/integration.rs +++ b/pacquet/crates/pnpr-client/tests/integration.rs @@ -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(entries: [(&str, &str); COUNT]) -> BTreeMap(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 diff --git a/pacquet/crates/registry/src/package.rs b/pacquet/crates/registry/src/package.rs index 612f47785d..aac8330314 100644 --- a/pacquet/crates/registry/src/package.rs +++ b/pacquet/crates/registry/src/package.rs @@ -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 diff --git a/pacquet/crates/registry/src/package_version.rs b/pacquet/crates/registry/src/package_version.rs index cdce3918e2..3b90f76b03 100644 --- a/pacquet/crates/registry/src/package_version.rs +++ b/pacquet/crates/registry/src/package_version.rs @@ -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 diff --git a/pacquet/crates/resolving-npm-resolver/src/fetch_attestation_published_at.rs b/pacquet/crates/resolving-npm-resolver/src/fetch_attestation_published_at.rs index fd159bafe7..25b41ea267 100644 --- a/pacquet/crates/resolving-npm-resolver/src/fetch_attestation_published_at.rs +++ b/pacquet/crates/resolving-npm-resolver/src/fetch_attestation_published_at.rs @@ -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 { diff --git a/pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata.rs b/pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata.rs index 7253c6173a..e38ff772a7 100644 --- a/pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata.rs +++ b/pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata.rs @@ -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 { diff --git a/pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata/tests.rs b/pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata/tests.rs index af234f9a66..3ec4d4c9c2 100644 --- a/pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata/tests.rs +++ b/pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata/tests.rs @@ -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` diff --git a/pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata_cached.rs b/pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata_cached.rs index 6e7043ca56..e9bb847d34 100644 --- a/pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata_cached.rs +++ b/pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata_cached.rs @@ -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() { diff --git a/pacquet/crates/tarball/src/lib.rs b/pacquet/crates/tarball/src/lib.rs index 78ff943c83..22dd22b627 100644 --- a/pacquet/crates/tarball/src/lib.rs +++ b/pacquet/crates/tarball/src/lib.rs @@ -1568,7 +1568,7 @@ async fn fetch_and_extract_once( // 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( // 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); } diff --git a/pacquet/crates/tarball/src/tests.rs b/pacquet/crates/tarball/src/tests.rs index 76023e43f2..4a6d21e9db 100644 --- a/pacquet/crates/tarball/src/tests.rs +++ b/pacquet/crates/tarball/src/tests.rs @@ -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::( + &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 diff --git a/pnpm/src/switchCliVersion.test.ts b/pnpm/src/switchCliVersion.test.ts index 7e6749f315..fb4f3e4e99 100644 --- a/pnpm/src/switchCliVersion.test.ts +++ b/pnpm/src/switchCliVersion.test.ts @@ -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', diff --git a/pnpm/src/syncEnvLockfile.test.ts b/pnpm/src/syncEnvLockfile.test.ts index 4f5e8c4bcb..4659c50ddd 100644 --- a/pnpm/src/syncEnvLockfile.test.ts +++ b/pnpm/src/syncEnvLockfile.test.ts @@ -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', diff --git a/pnpr/client/src/resolveViaPnprServer.ts b/pnpr/client/src/resolveViaPnprServer.ts index 0de77d0702..7b735403c5 100644 --- a/pnpr/client/src/resolveViaPnprServer.ts +++ b/pnpr/client/src/resolveViaPnprServer.ts @@ -8,6 +8,8 @@ import type { LockfileFile, LockfileObject } from '@pnpm/lockfile.types' import type { ResponseMetadata } from './protocol.js' +export type AuthHeadersByScope = Record> + export interface PnprProject { /** Relative dir within the workspace (e.g. "." or "packages/foo") */ dir: string @@ -36,10 +38,11 @@ export interface ResolveViaPnprServerOptions { namedRegistries?: Record /** * 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 + authHeaders?: AuthHeadersByScope /** * `Authorization` for the pnpr server's own URL (`undefined` if none): * identifies the caller to pnpr's gate. diff --git a/pnpr/crates/pnpr/src/resolver.rs b/pnpr/crates/pnpr/src/resolver.rs index f5a94e20fd..6a8bccc3f3 100644 --- a/pnpr/crates/pnpr/src/resolver.rs +++ b/pnpr/crates/pnpr/src/resolver.rs @@ -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 diff --git a/pnpr/crates/pnpr/src/resolver/protocol.rs b/pnpr/crates/pnpr/src/resolver/protocol.rs index c9f0b2eeb2..ed362e8f52 100644 --- a/pnpr/crates/pnpr/src/resolver/protocol.rs +++ b/pnpr/crates/pnpr/src/resolver/protocol.rs @@ -3,6 +3,7 @@ use std::collections::BTreeMap; +use pacquet_network::AuthHeadersByScope; use serde::Deserialize; pub type DepMap = BTreeMap; @@ -52,12 +53,12 @@ pub struct ResolveRequest { #[serde(default)] pub named_registries: BTreeMap, /// 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, + 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. diff --git a/registry-access/commands/src/deprecation/common.ts b/registry-access/commands/src/deprecation/common.ts index a76e0bbe83..5d32084db7 100644 --- a/registry-access/commands/src/deprecation/common.ts +++ b/registry-access/commands/src/deprecation/common.ts @@ -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 diff --git a/registry-access/commands/src/distTag.ts b/registry-access/commands/src/distTag.ts index 2a029705ae..d87d3aec01 100644 --- a/registry-access/commands/src/distTag.ts +++ b/registry-access/commands/src/distTag.ts @@ -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 | 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 { diff --git a/registry-access/commands/src/owner.ts b/registry-access/commands/src/owner.ts index 0a1046782a..0e5f8185a3 100644 --- a/registry-access/commands/src/owner.ts +++ b/registry-access/commands/src/owner.ts @@ -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 | 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}`) -} \ No newline at end of file +} diff --git a/registry-access/commands/src/star/common.ts b/registry-access/commands/src/star/common.ts index a417295317..64579155b8 100644 --- a/registry-access/commands/src/star/common.ts +++ b/registry-access/commands/src/star/common.ts @@ -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 | undefined, - registryUrl: string + registryUrl: string, + packageName?: string ): string | undefined { const getAuthHeader = createGetAuthHeaderByURI(configByUri ?? {}) - return getAuthHeader(registryUrl) + return getAuthHeader(registryUrl, packageName ? { pkgName: packageName } : undefined) } diff --git a/registry-access/commands/src/unpublish.ts b/registry-access/commands/src/unpublish.ts index f257383467..583566e6e4 100644 --- a/registry-access/commands/src/unpublish.ts +++ b/registry-access/commands/src/unpublish.ts @@ -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 diff --git a/registry-access/commands/test/deprecate.ts b/registry-access/commands/test/deprecate.ts index ae2d4735b7..8a36e2944b 100644 --- a/registry-access/commands/test/deprecate.ts +++ b/registry-access/commands/test/deprecate.ts @@ -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 }, }, } diff --git a/registry-access/commands/test/dist-tag.ts b/registry-access/commands/test/dist-tag.ts index 5fee46410f..b1a0637724 100644 --- a/registry-access/commands/test/dist-tag.ts +++ b/registry-access/commands/test/dist-tag.ts @@ -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)['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') diff --git a/registry-access/commands/test/star.ts b/registry-access/commands/test/star.ts index ccd21bb20a..9a6ce72722 100644 --- a/registry-access/commands/test/star.ts +++ b/registry-access/commands/test/star.ts @@ -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' }, }, } diff --git a/registry-access/commands/test/unpublish.ts b/registry-access/commands/test/unpublish.ts index a8ea224b16..a5587e3b62 100644 --- a/registry-access/commands/test/unpublish.ts +++ b/registry-access/commands/test/unpublish.ts @@ -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 }, }, } diff --git a/registry-access/commands/test/whoami.ts b/registry-access/commands/test/whoami.ts index dae09069fb..21b060e45b 100644 --- a/registry-access/commands/test/whoami.ts +++ b/registry-access/commands/test/whoami.ts @@ -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' }, }, } diff --git a/releasing/commands/src/publish/publishPackedPkg.ts b/releasing/commands/src/publish/publishPackedPkg.ts index 603f05e76a..02688ed02b 100644 --- a/releasing/commands/src/publish/publishPackedPkg.ts +++ b/releasing/commands/src/publish/publishPackedPkg.ts @@ -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, } } diff --git a/releasing/commands/src/stage/context.ts b/releasing/commands/src/stage/context.ts index 7dc80e378e..a40050bfae 100644 --- a/releasing/commands/src/stage/context.ts +++ b/releasing/commands/src/stage/context.ts @@ -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), } } diff --git a/releasing/commands/test/publish/batchPublish.test.ts b/releasing/commands/test/publish/batchPublish.test.ts index c7df0fac7f..20da8e790d 100644 --- a/releasing/commands/test/publish/batchPublish.test.ts +++ b/releasing/commands/test/publish/batchPublish.test.ts @@ -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(), diff --git a/releasing/commands/test/publish/publish.ts b/releasing/commands/test/publish/publish.ts index d9e12684aa..508f78a8c2 100644 --- a/releasing/commands/test/publish/publish.ts +++ b/releasing/commands/test/publish/publish.ts @@ -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(), diff --git a/releasing/commands/test/publish/publishConfigAccess.test.ts b/releasing/commands/test/publish/publishConfigAccess.test.ts index 398c836b41..2e765e5eb7 100644 --- a/releasing/commands/test/publish/publishConfigAccess.test.ts +++ b/releasing/commands/test/publish/publishConfigAccess.test.ts @@ -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') + }) +}) diff --git a/releasing/commands/test/publish/recursivePublish.ts b/releasing/commands/test/publish/recursivePublish.ts index 4c9efd7352..6eedeabbbe 100644 --- a/releasing/commands/test/publish/recursivePublish.ts +++ b/releasing/commands/test/publish/recursivePublish.ts @@ -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 }, }, } diff --git a/releasing/commands/test/stage.test.ts b/releasing/commands/test/stage.test.ts index 5e671ebd24..c3e1eeb3a9 100644 --- a/releasing/commands/test/stage.test.ts +++ b/releasing/commands/test/stage.test.ts @@ -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) => { diff --git a/resolving/npm-resolver/src/createNpmResolutionVerifier.ts b/resolving/npm-resolver/src/createNpmResolutionVerifier.ts index 615fb66e86..197f5e9731 100644 --- a/resolving/npm-resolver/src/createNpmResolutionVerifier.ts +++ b/resolving/npm-resolver/src/createNpmResolutionVerifier.ts @@ -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 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) diff --git a/resolving/npm-resolver/src/index.ts b/resolving/npm-resolver/src/index.ts index 973376b75d..1f2e6db171 100644 --- a/resolving/npm-resolver/src/index.ts +++ b/resolving/npm-resolver/src/index.ts @@ -361,7 +361,7 @@ function createResolveLatest ( export interface ResolveFromNpmContext { pickPackage: (spec: RegistryPackageSpec, opts: PickPackageOptions) => ReturnType - getAuthHeaderValueByURI: (registry: string) => string | undefined + getAuthHeaderValueByURI: GetAuthHeader registries: Registries namedRegistries: Record namedRegistryNames: ReadonlySet @@ -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, diff --git a/resolving/npm-resolver/test/createNpmResolutionVerifier.test.ts b/resolving/npm-resolver/test/createNpmResolutionVerifier.test.ts index d6a6c3db34..87c5d5e4cf 100644 --- a/resolving/npm-resolver/test/createNpmResolutionVerifier.test.ts +++ b/resolving/npm-resolver/test/createNpmResolutionVerifier.test.ts @@ -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. diff --git a/resolving/npm-resolver/test/index.ts b/resolving/npm-resolver/test/index.ts index 3731572791..ee3250d03c 100644 --- a/resolving/npm-resolver/test/index.ts +++ b/resolving/npm-resolver/test/index.ts @@ -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' })