mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-28 09:55:39 -04:00
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.
450 lines
17 KiB
TypeScript
450 lines
17 KiB
TypeScript
import { afterEach, beforeEach, expect, test } from '@jest/globals'
|
|
import { createFetchFromRegistry } from '@pnpm/network.fetch'
|
|
import { createNpmResolutionVerifier } from '@pnpm/resolving.npm-resolver'
|
|
import type { Resolution } from '@pnpm/resolving.resolver-base'
|
|
import type { Registries } from '@pnpm/types'
|
|
import { temporaryDirectory } from 'tempy'
|
|
|
|
import { getMockAgent, setupMockAgent, teardownMockAgent } from './utils/index.js'
|
|
|
|
const registries: Registries = {
|
|
default: 'https://registry.npmjs.org/',
|
|
}
|
|
|
|
const fetchFromRegistry = createFetchFromRegistry({})
|
|
const getAuthHeaderValueByURI = (): undefined => undefined
|
|
|
|
function makeVerifierOpts (overrides: Partial<Parameters<typeof createNpmResolutionVerifier>[0]> = {}): Parameters<typeof createNpmResolutionVerifier>[0] {
|
|
return {
|
|
registries,
|
|
fetchOpts: {
|
|
fetch: fetchFromRegistry,
|
|
retry: { retries: 0 },
|
|
timeout: 60_000,
|
|
fetchWarnTimeoutMs: 10_000,
|
|
},
|
|
getAuthHeaderValueByURI,
|
|
cacheDir: temporaryDirectory(),
|
|
now: Date.UTC(2026, 0, 1),
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function makeTarballResolution (name: string, version: string): Resolution {
|
|
return {
|
|
integrity: 'sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==',
|
|
tarball: `https://registry.npmjs.org/${name}/-/${name}-${version}.tgz`,
|
|
} as unknown as Resolution
|
|
}
|
|
|
|
afterEach(async () => {
|
|
await teardownMockAgent()
|
|
})
|
|
|
|
beforeEach(async () => {
|
|
await setupMockAgent()
|
|
})
|
|
|
|
test('createNpmResolutionVerifier() still verifies tarball URLs when no age/trust policy is active', async () => {
|
|
// The tarball-URL binding is unconditional, so the verifier exists even
|
|
// with no minimumReleaseAge/trustPolicy configured.
|
|
const meta = {
|
|
name: 'aged-pkg',
|
|
'dist-tags': { latest: '1.0.0' },
|
|
versions: {
|
|
'1.0.0': {
|
|
name: 'aged-pkg',
|
|
version: '1.0.0',
|
|
dist: { tarball: 'https://registry.npmjs.org/aged-pkg/-/aged-pkg-1.0.0.tgz', shasum: 'aa' },
|
|
},
|
|
},
|
|
modified: '2020-01-01T00:00:00.000Z',
|
|
}
|
|
const pool = getMockAgent().get('https://registry.npmjs.org')
|
|
pool.intercept({ path: '/aged-pkg', method: 'GET' }).reply(200, meta).persist()
|
|
|
|
const verifier = createNpmResolutionVerifier(makeVerifierOpts())
|
|
expect(verifier).toBeDefined()
|
|
const result = await verifier.verify(
|
|
{
|
|
integrity: FAKE_INTEGRITY,
|
|
tarball: 'https://attacker.example/aged-pkg-1.0.0.tgz',
|
|
} as unknown as Resolution,
|
|
{ name: 'aged-pkg', version: '1.0.0' }
|
|
)
|
|
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.
|
|
// This is exactly the case the resolver-time trustChecks unit tests
|
|
// cover, but routed through the lockfile verifier. The verifier must
|
|
// not pass simply because 0.0.2 has *some* attestation.
|
|
const meta = {
|
|
name: 'demo',
|
|
'dist-tags': { latest: '0.0.2' },
|
|
versions: {
|
|
'0.0.1': {
|
|
name: 'demo',
|
|
version: '0.0.1',
|
|
dist: {
|
|
tarball: 'https://registry.npmjs.org/demo/-/demo-0.0.1.tgz',
|
|
shasum: 'aa',
|
|
attestations: { provenance: { predicateType: 'https://example.org/p' } },
|
|
},
|
|
_npmUser: { trustedPublisher: { id: 'gha', oidcConfigId: 'cfg' } },
|
|
},
|
|
'0.0.2': {
|
|
name: 'demo',
|
|
version: '0.0.2',
|
|
dist: {
|
|
tarball: 'https://registry.npmjs.org/demo/-/demo-0.0.2.tgz',
|
|
shasum: 'bb',
|
|
attestations: { provenance: { predicateType: 'https://example.org/p' } },
|
|
},
|
|
},
|
|
},
|
|
time: {
|
|
'0.0.1': '2025-01-01T00:00:00.000Z',
|
|
'0.0.2': '2025-06-01T00:00:00.000Z',
|
|
},
|
|
modified: '2025-06-01T00:00:00.000Z',
|
|
}
|
|
const pool = getMockAgent().get('https://registry.npmjs.org')
|
|
pool.intercept({ path: '/demo', method: 'GET' }).reply(200, meta).persist()
|
|
|
|
const verifier = createNpmResolutionVerifier(makeVerifierOpts({
|
|
trustPolicy: 'no-downgrade',
|
|
}))
|
|
expect(verifier).toBeDefined()
|
|
|
|
const result = await verifier.verify(makeTarballResolution('demo', '0.0.2'), { name: 'demo', version: '0.0.2' })
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
code: 'TRUST_DOWNGRADE',
|
|
})
|
|
})
|
|
|
|
test('createNpmResolutionVerifier() passes a same-evidence-level version', async () => {
|
|
// 0.0.1 had provenance, 0.0.2 still has provenance → no downgrade.
|
|
// Verifies the trust check isn't over-aggressive for stable evidence.
|
|
const meta = {
|
|
name: 'demo',
|
|
'dist-tags': { latest: '0.0.2' },
|
|
versions: {
|
|
'0.0.1': {
|
|
name: 'demo',
|
|
version: '0.0.1',
|
|
dist: {
|
|
tarball: 'https://registry.npmjs.org/demo/-/demo-0.0.1.tgz',
|
|
shasum: 'aa',
|
|
attestations: { provenance: { predicateType: 'https://example.org/p1' } },
|
|
},
|
|
},
|
|
'0.0.2': {
|
|
name: 'demo',
|
|
version: '0.0.2',
|
|
dist: {
|
|
tarball: 'https://registry.npmjs.org/demo/-/demo-0.0.2.tgz',
|
|
shasum: 'bb',
|
|
attestations: { provenance: { predicateType: 'https://example.org/p2' } },
|
|
},
|
|
},
|
|
},
|
|
time: {
|
|
'0.0.1': '2025-01-01T00:00:00.000Z',
|
|
'0.0.2': '2025-06-01T00:00:00.000Z',
|
|
},
|
|
modified: '2025-06-01T00:00:00.000Z',
|
|
}
|
|
const pool = getMockAgent().get('https://registry.npmjs.org')
|
|
pool.intercept({ path: '/demo', method: 'GET' }).reply(200, meta).persist()
|
|
|
|
const verifier = createNpmResolutionVerifier(makeVerifierOpts({
|
|
trustPolicy: 'no-downgrade',
|
|
}))
|
|
const result = await verifier.verify(makeTarballResolution('demo', '0.0.2'), { name: 'demo', version: '0.0.2' })
|
|
expect(result).toEqual({ ok: true })
|
|
})
|
|
|
|
test('createNpmResolutionVerifier() abbreviated shortcut requires the pinned version to be in metadata', async () => {
|
|
// Package's `modified` is well before the cutoff (default 1-day window
|
|
// means modified=2010 is fine), but `0.0.2` was unpublished and is no
|
|
// longer in `versions`. The shortcut must NOT return the package-level
|
|
// `modified` for that version — that would be a fail-open on a
|
|
// missing pin. The verifier should fall through to the deeper layers
|
|
// and end up reporting a violation (no source could surface the time).
|
|
const abbreviatedMeta = {
|
|
name: 'unpublished-pkg',
|
|
'dist-tags': {},
|
|
versions: {
|
|
'0.0.1': {
|
|
name: 'unpublished-pkg',
|
|
version: '0.0.1',
|
|
dist: { tarball: 'https://registry.npmjs.org/unpublished-pkg/-/unpublished-pkg-0.0.1.tgz', shasum: 'aa' },
|
|
},
|
|
},
|
|
modified: '2010-01-01T00:00:00.000Z',
|
|
}
|
|
const fullMeta = {
|
|
...abbreviatedMeta,
|
|
time: { '0.0.1': '2010-01-01T00:00:00.000Z' },
|
|
}
|
|
const pool = getMockAgent().get('https://registry.npmjs.org')
|
|
pool.intercept({ path: '/unpublished-pkg', method: 'GET' }).reply(200, abbreviatedMeta).persist()
|
|
pool.intercept({ path: '/-/npm/v1/attestations/unpublished-pkg@0.0.2', method: 'GET' }).reply(404, {}).persist()
|
|
|
|
const verifier = createNpmResolutionVerifier(makeVerifierOpts({
|
|
minimumReleaseAge: 1440, // 1 day
|
|
}))
|
|
// Registry-style resolution (no explicit tarball URL) so the entry
|
|
// exercises the age check's abbreviated shortcut rather than the
|
|
// tarball-URL binding (which would fail closed on the missing version
|
|
// first).
|
|
const result = await verifier.verify(
|
|
{ integrity: FAKE_INTEGRITY } as unknown as Resolution,
|
|
{ name: 'unpublished-pkg', version: '0.0.2' }
|
|
)
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
code: 'MINIMUM_RELEASE_AGE_VIOLATION',
|
|
})
|
|
|
|
// Sanity check: the unrelated full meta isn't used here because the
|
|
// abbreviated shortcut won't fire (version missing), and the deeper
|
|
// layers also have no entry for 0.0.2. Keep `fullMeta` in scope so
|
|
// future test additions can wire it in without redefining.
|
|
expect(fullMeta.versions['0.0.1'].version).toBe('0.0.1')
|
|
})
|
|
|
|
test('createNpmResolutionVerifier() ignoreMissingTimeField passes the entry when no source surfaces a timestamp', async () => {
|
|
// Mirrors the resolver-side `pickMatchingVersionFinal` warn-and-skip
|
|
// behavior: when the registry strips the per-version `time` field and
|
|
// the user has opted into `minimumReleaseAgeIgnoreMissingTime`, the
|
|
// verifier shouldn't be stricter than fresh resolution.
|
|
const abbreviatedMeta = {
|
|
name: 'time-free-pkg',
|
|
'dist-tags': {},
|
|
versions: {
|
|
'1.0.0': {
|
|
name: 'time-free-pkg',
|
|
version: '1.0.0',
|
|
dist: { tarball: 'https://registry.npmjs.org/time-free-pkg/-/time-free-pkg-1.0.0.tgz', shasum: 'aa' },
|
|
},
|
|
},
|
|
modified: '2010-01-01T00:00:00.000Z',
|
|
}
|
|
const pool = getMockAgent().get('https://registry.npmjs.org')
|
|
// Full meta also lacks `time`, so no layer surfaces a publish timestamp.
|
|
pool.intercept({ path: '/time-free-pkg', method: 'GET' }).reply(200, abbreviatedMeta).persist()
|
|
pool.intercept({ path: '/-/npm/v1/attestations/time-free-pkg@1.0.0', method: 'GET' }).reply(404, {}).persist()
|
|
|
|
const verifier = createNpmResolutionVerifier(makeVerifierOpts({
|
|
minimumReleaseAge: 1440,
|
|
ignoreMissingTimeField: true,
|
|
}))
|
|
const result = await verifier.verify(
|
|
makeTarballResolution('time-free-pkg', '1.0.0'),
|
|
{ name: 'time-free-pkg', version: '1.0.0' }
|
|
)
|
|
expect(result).toEqual({ ok: true })
|
|
})
|
|
|
|
test('createNpmResolutionVerifier() skips file: tarball resolutions', async () => {
|
|
const verifier = createNpmResolutionVerifier(makeVerifierOpts({
|
|
minimumReleaseAge: 1440,
|
|
}))
|
|
const result = await verifier.verify(
|
|
{
|
|
integrity: 'sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==',
|
|
tarball: 'file:vendor/types__my-cool-lib-v1.0.0.tgz',
|
|
} as unknown as Resolution,
|
|
{ name: '@types/my-cool-lib', version: '1.0.0' }
|
|
)
|
|
expect(result).toEqual({ ok: true })
|
|
})
|
|
|
|
const FAKE_INTEGRITY = 'sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=='
|
|
|
|
test('createNpmResolutionVerifier() flags a lockfile tarball URL that does not match the registry metadata', async () => {
|
|
// The version is old enough to clear minimumReleaseAge, but the lockfile
|
|
// pins a tarball URL on a host the registry never published to. A tampered
|
|
// lockfile could pair an aged, trusted name@version with attacker-hosted
|
|
// bytes; the verifier must reject the entry before the age check passes it.
|
|
const meta = {
|
|
name: 'aged-pkg',
|
|
'dist-tags': { latest: '1.0.0' },
|
|
versions: {
|
|
'1.0.0': {
|
|
name: 'aged-pkg',
|
|
version: '1.0.0',
|
|
dist: { tarball: 'https://registry.npmjs.org/aged-pkg/-/aged-pkg-1.0.0.tgz', shasum: 'aa' },
|
|
},
|
|
},
|
|
time: { '1.0.0': '2020-01-01T00:00:00.000Z' },
|
|
modified: '2020-01-01T00:00:00.000Z',
|
|
}
|
|
const pool = getMockAgent().get('https://registry.npmjs.org')
|
|
pool.intercept({ path: '/aged-pkg', method: 'GET' }).reply(200, meta).persist()
|
|
|
|
const verifier = createNpmResolutionVerifier(makeVerifierOpts({
|
|
minimumReleaseAge: 1440,
|
|
}))
|
|
const result = await verifier.verify(
|
|
{
|
|
integrity: FAKE_INTEGRITY,
|
|
tarball: 'https://attacker.example/aged-pkg-1.0.0.tgz',
|
|
} as unknown as Resolution,
|
|
{ name: 'aged-pkg', version: '1.0.0' }
|
|
)
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
code: 'TARBALL_URL_MISMATCH',
|
|
})
|
|
})
|
|
|
|
test('createNpmResolutionVerifier() accepts a non-standard tarball URL that matches the registry metadata', async () => {
|
|
// npm Enterprise / GitHub Packages serve tarballs from a path the default
|
|
// URL template can't reconstruct, so the lockfile keeps the URL. As long
|
|
// as it's the URL the registry's own metadata lists, it's legitimate.
|
|
const meta = {
|
|
name: 'enterprise-pkg',
|
|
'dist-tags': { latest: '1.0.0' },
|
|
versions: {
|
|
'1.0.0': {
|
|
name: 'enterprise-pkg',
|
|
version: '1.0.0',
|
|
dist: { tarball: 'https://registry.npmjs.org/enterprise-pkg/download/enterprise-pkg-1.0.0.tgz', shasum: 'aa' },
|
|
},
|
|
},
|
|
time: { '1.0.0': '2020-01-01T00:00:00.000Z' },
|
|
modified: '2020-01-01T00:00:00.000Z',
|
|
}
|
|
const pool = getMockAgent().get('https://registry.npmjs.org')
|
|
pool.intercept({ path: '/enterprise-pkg', method: 'GET' }).reply(200, meta).persist()
|
|
|
|
const verifier = createNpmResolutionVerifier(makeVerifierOpts({
|
|
minimumReleaseAge: 1440,
|
|
}))
|
|
const result = await verifier.verify(
|
|
{
|
|
integrity: FAKE_INTEGRITY,
|
|
tarball: 'https://registry.npmjs.org/enterprise-pkg/download/enterprise-pkg-1.0.0.tgz',
|
|
} as unknown as Resolution,
|
|
{ name: 'enterprise-pkg', version: '1.0.0' }
|
|
)
|
|
expect(result).toEqual({ ok: true })
|
|
})
|
|
|
|
test('createNpmResolutionVerifier() treats a default-port / scheme difference as a match', async () => {
|
|
// The lockfile URL and the registry metadata differ only by an explicit
|
|
// default port and the http/https scheme — benign normalizations, not
|
|
// tampering — so `sameTarballUrl` must canonicalize them away.
|
|
const meta = {
|
|
name: 'aged-pkg',
|
|
'dist-tags': { latest: '1.0.0' },
|
|
versions: {
|
|
'1.0.0': {
|
|
name: 'aged-pkg',
|
|
version: '1.0.0',
|
|
dist: { tarball: 'http://registry.npmjs.org:80/aged-pkg/-/aged-pkg-1.0.0.tgz', shasum: 'aa' },
|
|
},
|
|
},
|
|
time: { '1.0.0': '2020-01-01T00:00:00.000Z' },
|
|
modified: '2020-01-01T00:00:00.000Z',
|
|
}
|
|
const pool = getMockAgent().get('https://registry.npmjs.org')
|
|
pool.intercept({ path: '/aged-pkg', method: 'GET' }).reply(200, meta).persist()
|
|
|
|
const verifier = createNpmResolutionVerifier(makeVerifierOpts({
|
|
minimumReleaseAge: 1440,
|
|
}))
|
|
const result = await verifier.verify(
|
|
{
|
|
integrity: FAKE_INTEGRITY,
|
|
tarball: 'https://registry.npmjs.org/aged-pkg/-/aged-pkg-1.0.0.tgz',
|
|
} as unknown as Resolution,
|
|
{ name: 'aged-pkg', version: '1.0.0' }
|
|
)
|
|
expect(result).toEqual({ ok: true })
|
|
})
|
|
|
|
test('createNpmResolutionVerifier() skips URL-keyed tarball deps even when they carry a semver version', async () => {
|
|
// A remote `https:` tarball dependency keeps a semver `version` copied from
|
|
// its manifest, but its lockfile key is the URL (nonSemverVersion). It is a
|
|
// deliberate non-registry dep: neither the release-age policy nor the
|
|
// registry tarball-URL binding applies, and no registry lookup should fire.
|
|
const verifier = createNpmResolutionVerifier(makeVerifierOpts({
|
|
minimumReleaseAge: 1440,
|
|
}))
|
|
const result = await verifier.verify(
|
|
{
|
|
integrity: FAKE_INTEGRITY,
|
|
tarball: 'https://example.com/foo-1.0.0.tgz',
|
|
} as unknown as Resolution,
|
|
{ name: 'foo', version: '1.0.0', nonSemverVersion: 'https://example.com/foo-1.0.0.tgz' }
|
|
)
|
|
expect(result).toEqual({ ok: true })
|
|
})
|
|
|
|
test('createNpmResolutionVerifier() canTrustPastCheck rejects when the trust-exclude list shrinks', () => {
|
|
const verifier = createNpmResolutionVerifier(makeVerifierOpts({
|
|
trustPolicy: 'no-downgrade',
|
|
trustPolicyExclude: ['foo'],
|
|
}))
|
|
// Same policy → trust.
|
|
expect(verifier.canTrustPastCheck({
|
|
tarballUrlBinding: true,
|
|
minimumReleaseAge: 0,
|
|
minimumReleaseAgeExclude: [],
|
|
trustPolicy: 'no-downgrade',
|
|
trustPolicyExclude: ['foo'],
|
|
trustPolicyIgnoreAfter: null,
|
|
})).toBe(true)
|
|
// Cached run had a wider exclude list (today's is stricter) → invalidate.
|
|
expect(verifier.canTrustPastCheck({
|
|
tarballUrlBinding: true,
|
|
minimumReleaseAge: 0,
|
|
minimumReleaseAgeExclude: [],
|
|
trustPolicy: 'no-downgrade',
|
|
trustPolicyExclude: ['foo', 'bar'],
|
|
trustPolicyIgnoreAfter: null,
|
|
})).toBe(false)
|
|
})
|