feat: allow loading certificates from scoped cert, ca and key (#10230)

* feat: allow loading certificates from `cert`, `ca` and `key`

These properties are supported in .npmrc, but get ignored by pnpm, this will make pnpm read
and use them as well.

* refactor: getNetworkConfigs.ts

* docs: update changesets

* fix: issues

* docs: update changesets

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
Bart Riepe
2025-11-29 19:37:57 +09:00
committed by Zoltan Kochan
parent 82e2c30484
commit b0ec709fa5
4 changed files with 77 additions and 14 deletions

View File

@@ -0,0 +1,10 @@
---
"@pnpm/config": minor
"pnpm": minor
---
Allow loading certificates from `cert`, `ca`, and `key` for specific registry URLs. E.g., `//registry.example.com/:ca=-----BEGIN CERTIFICATE-----...`. Previously this was only working via `certfile`, `cafile`, and `keyfile`.
These properties are supported in `.npmrc`, but were ignored by pnpm, this will make pnpm read and use them as well.
Related PR: [#10230](https://github.com/pnpm/pnpm/pull/10230).

View File

@@ -17,8 +17,11 @@ const RAW_AUTH_CFG_KEYS = [
] satisfies Array<keyof typeof types>
const RAW_AUTH_CFG_KEY_SUFFIXES = [
':ca',
':cafile',
':cert',
':certfile',
':key',
':keyfile',
':registry',
':tokenHelper',

View File

@@ -8,29 +8,47 @@ export interface GetNetworkConfigsResult {
}
export function getNetworkConfigs (rawConfig: Record<string, object>): GetNetworkConfigsResult {
// Get all the auth options that have :certfile or :keyfile in their name
// Get all the auth options that have SSL certificate data or file references
const sslConfigs: Record<string, SslConfig> = {}
const registries: Record<string, string> = {}
for (const [configKey, value] of Object.entries(rawConfig)) {
if (configKey[0] === '@' && configKey.endsWith(':registry')) {
registries[configKey.slice(0, configKey.indexOf(':'))] = normalizeRegistryUrl(value as unknown as string)
} else if (configKey.includes(':certfile') || configKey.includes(':keyfile') || configKey.includes(':cafile')) {
// Split by '/:' because the registry may contain a port
const registry = configKey.split('/:')[0] + '/'
if (!sslConfigs[registry]) {
sslConfigs[registry] = { cert: '', key: '' }
}
if (configKey.includes(':certfile')) {
sslConfigs[registry].cert = fs.readFileSync(value as unknown as string, 'utf8')
} else if (configKey.includes(':keyfile')) {
sslConfigs[registry].key = fs.readFileSync(value as unknown as string, 'utf8')
} else if (configKey.includes(':cafile')) {
sslConfigs[registry].ca = fs.readFileSync(value as unknown as string, 'utf8')
}
continue
}
const parsed = tryParseSslSetting(configKey)
if (!parsed) continue
const { registry, sslConfigKey, isFile } = parsed
if (!sslConfigs[registry]) {
sslConfigs[registry] = { cert: '', key: '' }
}
sslConfigs[registry][sslConfigKey] = isFile
? fs.readFileSync(value as unknown as string, 'utf8')
: (value as unknown as string).replace(/\\n/g, '\n')
}
return {
registries,
sslConfigs,
}
}
const SSL_SUFFIX_RE = /:(?<id>cert|key|ca)(?<kind>file)?$/
interface ParsedSslSetting {
registry: string
sslConfigKey: keyof SslConfig
isFile: boolean
}
function tryParseSslSetting (key: string): ParsedSslSetting | null {
const match = key.match(SSL_SUFFIX_RE)
if (!match?.groups) {
return null
}
const registry = key.slice(0, match.index!) // already includes the trailing slash
const sslConfigKey = match.groups.id as keyof SslConfig
const isFile = Boolean(match.groups.kind)
return { registry, sslConfigKey, isFile }
}

View File

@@ -842,6 +842,38 @@ test('getConfig() should read cafile', async () => {
-----END CERTIFICATE-----`])
})
test('getConfig() should read inline SSL certificates from .npmrc', async () => {
prepareEmpty()
// These are written to .npmrc with literal \n strings
const inlineCa = '-----BEGIN CERTIFICATE-----\\nMIIFNzCCAx+gAwIBAgIQNB613yRzpKtDztlXiHmOGDANBgkqhkiG9w0BAQsFADAR\\n-----END CERTIFICATE-----'
const inlineCert = '-----BEGIN CERTIFICATE-----\\nMIIClientCert\\n-----END CERTIFICATE-----'
const inlineKey = '-----BEGIN PRIVATE KEY-----\\nMIIClientKey\\n-----END PRIVATE KEY-----'
const npmrc = [
'//registry.example.com/:ca=' + inlineCa,
'//registry.example.com/:cert=' + inlineCert,
'//registry.example.com/:key=' + inlineKey,
].join('\n')
fs.writeFileSync('.npmrc', npmrc, 'utf8')
const { config } = await getConfig({
cliOptions: {},
packageManager: {
name: 'pnpm',
version: '1.0.0',
},
})
// After processing, \n should be converted to actual newlines
expect(config.sslConfigs).toBeDefined()
expect(config.sslConfigs['//registry.example.com/']).toStrictEqual({
ca: inlineCa.replace(/\\n/g, '\n'),
cert: inlineCert.replace(/\\n/g, '\n'),
key: inlineKey.replace(/\\n/g, '\n'),
})
})
test('respect mergeGitBranchLockfilesBranchPattern', async () => {
{
const { config } = await getConfig({