feat(config): add support for token helper (#4163)

* feat(config): add support for token helper

Use the new interface in `pnpm/credentials-by-uri` for supporting token
helpers. A token helper is an executable, set in the user's `.npmrc`
which outputs an auth token. This can be used in situations where the
`authToken` is not a constant value, but is something that refreshes
regularly, where a script or other tool can use an existing refresh
token to obtain a new access token.

The configuration for the path to the helper must be an absolute path,
with no arguments. In order to be secure, it is _only_ permitted to set
this value in the user `.npmrc`, otherwise a project could place a value
in a project local `.npmrc` and run arbitrary executables.

A similar feature is available in many similar tools. The implementation
in `credentials-by-uri` is modelled after the `vault` (vaultproject.io)
implementation - https://github.com/hashicorp/vault/blob/main/command/token/helper_external.go

* test: fix

* docs: add changesets

Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
Dave Brotherstone
2021-12-30 22:16:06 +01:00
committed by GitHub
parent 6e4becd997
commit a6cf11cb77
31 changed files with 126 additions and 9 deletions

View File

@@ -0,0 +1,12 @@
---
"@pnpm/client": minor
"@pnpm/plugin-commands-installation": minor
"@pnpm/plugin-commands-listing": minor
"@pnpm/plugin-commands-outdated": minor
"@pnpm/plugin-commands-publishing": minor
"@pnpm/plugin-commands-rebuild": minor
"@pnpm/plugin-commands-store": minor
"@pnpm/store-connection-manager": minor
---
New optional setting added: userConfig. userConfig may contain token helpers.

View File

@@ -0,0 +1,31 @@
---
"pnpm": minor
---
Add support for token helper, a command line tool to obtain a token.
A token helper is an executable, set in the user's `.npmrc` which
outputs an auth token. This can be used in situations where the
authToken is not a constant value, but is something that refreshes
regularly, where a script or other tool can use an existing refresh
token to obtain a new access token.
The configuration for the path to the helper must be an absolute path,
with no arguments. In order to be secure, it is only permitted to set
this value in the user `.npmrc`, otherwise a project could place a value
in a project local `.npmrc` and run arbitrary executables.
Usage example:
```ini
; Setting a token helper for the default registry
tokenHelper=/home/ivan/token-generator
; Setting a token helper for the specified registry
//registry.corp.com:tokenHelper=/home/ivan/token-generator
```
Related PRs:
- [pnpm/credentials-by-uri#2](https://github.com/pnpm/credentials-by-uri/pull/2)
- [#4163](https://github.com/pnpm/pnpm/pull/4163)

View File

@@ -0,0 +1,5 @@
---
"@pnpm/config": minor
---
`userConfig` added to the config object, which contain only the settings set in the user's home config file.

View File

@@ -38,7 +38,7 @@
"@pnpm/git-fetcher": "workspace:4.1.13",
"@pnpm/resolver-base": "workspace:8.1.4",
"@pnpm/tarball-fetcher": "workspace:9.3.14",
"credentials-by-uri": "^2.0.0",
"credentials-by-uri": "^2.1.0",
"mem": "^8.0.0"
},
"devDependencies": {

View File

@@ -17,11 +17,12 @@ export type ClientOptions = {
retry?: RetryTimeoutOptions
timeout?: number
userAgent?: string
userConfig?: Record<string, string>
} & ResolverFactoryOptions & AgentOptions
export default function (opts: ClientOptions) {
const fetchFromRegistry = createFetchFromRegistry(opts)
const getCredentials = mem((registry: string) => getCredentialsByURI(opts.authConfig, registry))
const getCredentials = mem((registry: string) => getCredentialsByURI(opts.authConfig, registry, opts.userConfig))
return {
fetchers: createFetchers(fetchFromRegistry, getCredentials, opts),
resolve: createResolve(fetchFromRegistry, getCredentials, opts),

View File

@@ -143,6 +143,7 @@ export interface Config {
changedFilesIgnorePattern?: string[]
extendNodePath?: boolean
rootProjectManifest?: ProjectManifest
userConfig: Record<string, string>
}
export interface ConfigWithDeprecatedSettings extends Config {

View File

@@ -463,6 +463,9 @@ export default async (
pnpmConfig.noProxy = pnpmConfig['noproxy'] ?? getProcessEnv('no_proxy')
}
pnpmConfig.enablePnp = pnpmConfig['nodeLinker'] === 'pnp'
if (!pnpmConfig.userConfig) {
pnpmConfig.userConfig = npmConfig.sources.user?.data
}
if (opts.checkUnknownSetting) {
const settingKeys = Object.keys({

View File

@@ -4,6 +4,7 @@ import path from 'path'
import getConfig from '@pnpm/config'
import PnpmError from '@pnpm/error'
import prepare, { prepareEmpty } from '@pnpm/prepare'
import loadNpmConf from '@zkochan/npm-conf'
import symlinkDir from 'symlink-dir'
@@ -725,3 +726,35 @@ test('getConfig() converts noproxy to noProxy', async () => {
})
expect(config.noProxy).toBe('www.foo.com')
})
test('getConfig() returns the userconfig', async () => {
prepareEmpty()
await fs.mkdir('user-home')
await fs.writeFile(path.resolve('user-home', '.npmrc'), 'registry = https://registry.example.test', 'utf-8')
loadNpmConf.defaults.userconfig = path.resolve('user-home', '.npmrc')
const { config } = await getConfig({
cliOptions: {},
packageManager: {
name: 'pnpm',
version: '1.0.0',
},
})
expect(config.userConfig).toEqual({ registry: 'https://registry.example.test' })
})
test('getConfig() returns the userconfig even when overridden locally', async () => {
prepareEmpty()
await fs.mkdir('user-home')
await fs.writeFile(path.resolve('user-home', '.npmrc'), 'registry = https://registry.example.test', 'utf-8')
loadNpmConf.defaults.userconfig = path.resolve('user-home', '.npmrc')
await fs.writeFile('.npmrc', 'registry = https://project-local.example.test', 'utf-8')
const { config } = await getConfig({
cliOptions: {},
packageManager: {
name: 'pnpm',
version: '1.0.0',
},
})
expect(config.registry).toEqual('https://project-local.example.test')
expect(config.userConfig).toEqual({ registry: 'https://registry.example.test' })
})

View File

@@ -31,6 +31,7 @@ const DEFAULT_OPTIONS = {
},
sort: true,
storeDir: path.join(tmp, 'store'),
userConfig: {},
workspaceConcurrency: 1,
}

View File

@@ -27,6 +27,7 @@ const DEFAULT_OPTIONS = {
default: REGISTRY_URL,
},
sort: true,
userConfig: {},
workspaceConcurrency: 1,
}

View File

@@ -31,6 +31,7 @@ const DEFAULT_OPTIONS = {
},
sort: true,
storeDir: path.join(tmp, 'store'),
userConfig: {},
workspaceConcurrency: 1,
}

View File

@@ -39,6 +39,7 @@ const DEFAULT_OPTS = {
storeDir: path.join(TMP, 'store'),
strictSsl: false,
userAgent: 'pnpm',
userConfig: {},
useRunningStoreServer: false,
useStoreServer: false,
}

View File

@@ -39,6 +39,7 @@ const DEFAULT_OPTS = {
storeDir: path.join(TMP, 'store'),
strictSsl: false,
userAgent: 'pnpm',
userConfig: {},
useRunningStoreServer: false,
useStoreServer: false,
}

View File

@@ -29,6 +29,7 @@ const DEFAULT_OPTIONS = {
},
sort: true,
storeDir: path.join(TMP, 'store'),
userConfig: {},
workspaceConcurrency: 1,
}

View File

@@ -26,6 +26,7 @@ const DEFAULT_OPTIONS = {
default: REGISTRY_URL,
},
sort: true,
userConfig: {},
workspaceConcurrency: 1,
}

View File

@@ -39,6 +39,7 @@ const DEFAULT_OPTIONS = {
default: REGISTRY_URL,
},
sort: true,
userConfig: {},
workspaceConcurrency: 1,
}

View File

@@ -42,6 +42,7 @@ export const DEFAULT_OPTS = {
storeDir: '../store',
strictSsl: false,
userAgent: 'pnpm',
userConfig: {},
useRunningStoreServer: false,
useStoreServer: false,
workspaceConcurrency: 4,

View File

@@ -41,6 +41,7 @@ export const DEFAULT_OPTS = {
storeDir: '../store',
strictSsl: false,
userAgent: 'pnpm',
userConfig: {},
useRunningStoreServer: false,
useStoreServer: false,
workspaceConcurrency: 4,

View File

@@ -152,7 +152,7 @@ export type OutdatedCommandOptions = {
| 'strictSsl'
| 'tag'
| 'userAgent'
>
> & Partial<Pick<Config, 'userConfig'>>
export async function handler (
opts: OutdatedCommandOptions,

View File

@@ -33,6 +33,7 @@ const OUTDATED_OPTIONS = {
strictSsl: false,
tag: 'latest',
userAgent: '',
userConfig: {},
}
test('pnpm outdated: show details', async () => {

View File

@@ -44,6 +44,7 @@ export const DEFAULT_OPTS = {
strictSsl: false,
tag: 'latest',
userAgent: 'pnpm',
userConfig: {},
useRunningStoreServer: false,
useStoreServer: false,
workspaceConcurrency: 4,

View File

@@ -42,6 +42,7 @@ Partial<Pick<Config,
| 'selectedProjectsGraph'
| 'strictSsl'
| 'userAgent'
| 'userConfig'
| 'verifyStoreIntegrity'
>> & {
access?: 'public' | 'restricted'
@@ -58,6 +59,7 @@ export default async function (
const resolve = createResolver({
...opts,
authConfig: opts.rawConfig,
userConfig: opts.userConfig,
retry: {
factor: opts.fetchRetryFactor,
maxTimeout: opts.fetchRetryMaxtimeout,

View File

@@ -41,6 +41,7 @@ export const DEFAULT_OPTS = {
cacheDir: '../cache',
strictSsl: false,
userAgent: 'pnpm',
userConfig: {},
useRunningStoreServer: false,
useStoreServer: false,
workspaceConcurrency: 4,

View File

@@ -27,6 +27,7 @@ export interface StrictRebuildOptions {
development: boolean
optional: boolean
rawConfig: object
userConfig: Record<string, string>
userAgent: string
packageManager: {
name: string

View File

@@ -41,6 +41,7 @@ export const DEFAULT_OPTS = {
storeDir: '../store',
strictSsl: false,
userAgent: 'pnpm',
userConfig: {},
useRunningStoreServer: false,
useStoreServer: false,
workspaceConcurrency: 4,

View File

@@ -21,6 +21,7 @@ test('pnpm store add express@4.16.3', async () => {
},
registries: { default: `http://localhost:${REGISTRY_MOCK_PORT}/` },
storeDir,
userConfig: {},
}, ['add', 'express@4.16.3'])
const { cafsHas } = assertStore(path.join(storeDir, STORE_VERSION))
@@ -44,6 +45,7 @@ test('pnpm store add scoped package that uses not the standard registry', async
default: 'https://registry.npmjs.org/',
},
storeDir,
userConfig: {},
}, ['add', '@foo/no-deps@1.0.0'])
const { cafsHas } = assertStore(path.join(storeDir, STORE_VERSION))
@@ -70,6 +72,7 @@ test('should fail if some packages can not be added', async () => {
default: 'https://registry.npmjs.org/',
},
storeDir,
userConfig: {},
}, ['add', '@pnpm/this-does-not-exist'])
} catch (e: any) { // eslint-disable-line
thrown = true

View File

@@ -17,6 +17,7 @@ test('CLI prints the current store path', async () => {
},
registries: { default: REGISTRY },
storeDir: '/home/example/.pnpm-store',
userConfig: {},
}, ['path'])
const expectedStorePath = os.platform() === 'win32'

View File

@@ -34,6 +34,7 @@ test('remove unreferenced packages', async () => {
registries: { default: REGISTRY },
reporter,
storeDir,
userConfig: {},
}, ['prune'])
expect(reporter).toBeCalledWith(
@@ -55,6 +56,7 @@ test('remove unreferenced packages', async () => {
registries: { default: REGISTRY },
reporter,
storeDir,
userConfig: {},
}, ['prune'])
expect(reporter).not.toBeCalledWith(
@@ -87,6 +89,7 @@ test.skip('remove packages that are used by project that no longer exist', async
registries: { default: REGISTRY },
reporter,
storeDir,
userConfig: {},
}, ['prune'])
expect(reporter).toBeCalledWith(
@@ -129,6 +132,7 @@ test('keep dependencies used by others', async () => {
},
registries: { default: REGISTRY },
storeDir,
userConfig: {},
}, ['prune'])
await project.storeHasNot('camelcase-keys', '3.0.0')
@@ -151,6 +155,7 @@ test('keep dependency used by package', async () => {
},
registries: { default: REGISTRY },
storeDir,
userConfig: {},
}, ['prune'])
await project.storeHas('is-positive', '3.1.0')
@@ -171,6 +176,7 @@ test('prune will skip scanning non-directory in storeDir', async () => {
},
registries: { default: REGISTRY },
storeDir,
userConfig: {},
}, ['prune'])
})
@@ -195,6 +201,7 @@ test('prune does not fail if the store contains an unexpected directory', async
registries: { default: REGISTRY },
reporter,
storeDir,
userConfig: {},
}, ['prune'])
expect(reporter).toBeCalledWith(

View File

@@ -30,6 +30,7 @@ test('CLI fails when store status finds modified packages', async () => {
},
registries: { default: REGISTRY },
storeDir,
userConfig: {},
}, ['status'])
} catch (_err: any) { // eslint-disable-line
err = _err
@@ -70,5 +71,6 @@ test('CLI does not fail when store status does not find modified packages', asyn
},
registries: { default: REGISTRY },
storeDir,
userConfig: {},
}, ['status'])
})

View File

@@ -38,12 +38,13 @@ export type CreateNewStoreControllerOptions = CreateResolverOptions & Pick<Confi
| 'verifyStoreIntegrity'
> & {
ignoreFile?: (filename: string) => boolean
}
} & Partial<Pick<Config, 'userConfig'>>
export default async (
opts: CreateNewStoreControllerOptions
) => {
const { resolve, fetchers } = createClient({
userConfig: opts.userConfig,
authConfig: opts.rawConfig,
ca: opts.ca,
cacheDir: opts.cacheDir,

10
pnpm-lock.yaml generated
View File

@@ -296,7 +296,7 @@ importers:
'@pnpm/logger': ^4.0.0
'@pnpm/resolver-base': workspace:8.1.4
'@pnpm/tarball-fetcher': workspace:9.3.14
credentials-by-uri: ^2.0.0
credentials-by-uri: ^2.1.0
mem: ^8.0.0
dependencies:
'@pnpm/default-resolver': link:../default-resolver
@@ -306,7 +306,7 @@ importers:
'@pnpm/git-fetcher': link:../git-fetcher
'@pnpm/resolver-base': link:../resolver-base
'@pnpm/tarball-fetcher': link:../tarball-fetcher
credentials-by-uri: 2.0.0
credentials-by-uri: 2.1.0
mem: 8.1.1
devDependencies:
'@pnpm/client': 'link:'
@@ -4772,7 +4772,6 @@ packages:
/@pnpm/error/2.0.0:
resolution: {integrity: sha512-mgj4h0LWGpDPZwsEH75VFQhr2Njut3PcaCQatERIoO3zmKGqCsLfla9cWYH9+zn0fcwnKhnJ+FBzoiY2LhnCtw==}
engines: {node: '>=12.17'}
dev: true
/@pnpm/exec/2.0.0:
resolution: {integrity: sha512-b5ALfWEOFQprWKntN7MF8XWCyslBk2c8u20GEDcDDQOs6c0HyHlWxX5lig8riQKdS000U6YyS4L4b32NOleXAQ==}
@@ -7602,10 +7601,11 @@ packages:
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
dev: true
/credentials-by-uri/2.0.0:
resolution: {integrity: sha512-HptfRWLKfaeewvzKPybPFsR8TRbLSsu5MvHQLc6FWqvIS1CqFHze+IteKQlpLXzx4KwSteZa0MdsN/jEYESVXA==}
/credentials-by-uri/2.1.0:
resolution: {integrity: sha512-Ia57VrZYcs4YTPUB4Pirjf3MYsSdc7mAYGC99lUKii0KsohmZgpPvVOeqW1NWBCRg3HVrLOtSreLqkmFIUi8WQ==}
engines: {node: '>=10'}
dependencies:
'@pnpm/error': 2.0.0
nerf-dart: 1.0.0
dev: false