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 BrotherstoneandZoltan Kochan authored and GitHub committed 2021-12-30 23:16:06 +02:00
1 parent 6e4becd997
commit a6cf11cb77
31 files changed
+126 -9

No files matched your search

+12
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.
+31
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)
+5
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.
+1 -1
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": {
+2 -1
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),
+1
View File
@@ -143,6 +143,7 @@ export interface Config {
changedFilesIgnorePattern?: string[]
extendNodePath?: boolean
rootProjectManifest?: ProjectManifest
userConfig: Record<string, string>
}
export interface ConfigWithDeprecatedSettings extends Config {
+3
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({
+33
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' })
})
@@ -31,6 +31,7 @@ const DEFAULT_OPTIONS = {
},
sort: true,
storeDir: path.join(tmp, 'store'),
userConfig: {},
workspaceConcurrency: 1,
}
@@ -27,6 +27,7 @@ const DEFAULT_OPTIONS = {
default: REGISTRY_URL,
},
sort: true,
userConfig: {},
workspaceConcurrency: 1,
}
@@ -31,6 +31,7 @@ const DEFAULT_OPTIONS = {
},
sort: true,
storeDir: path.join(tmp, 'store'),
userConfig: {},
workspaceConcurrency: 1,
}
@@ -39,6 +39,7 @@ const DEFAULT_OPTS = {
storeDir: path.join(TMP, 'store'),
strictSsl: false,
userAgent: 'pnpm',
userConfig: {},
useRunningStoreServer: false,
useStoreServer: false,
}
@@ -39,6 +39,7 @@ const DEFAULT_OPTS = {
storeDir: path.join(TMP, 'store'),
strictSsl: false,
userAgent: 'pnpm',
userConfig: {},
useRunningStoreServer: false,
useStoreServer: false,
}
@@ -29,6 +29,7 @@ const DEFAULT_OPTIONS = {
},
sort: true,
storeDir: path.join(TMP, 'store'),
userConfig: {},
workspaceConcurrency: 1,
}
@@ -26,6 +26,7 @@ const DEFAULT_OPTIONS = {
default: REGISTRY_URL,
},
sort: true,
userConfig: {},
workspaceConcurrency: 1,
}
@@ -39,6 +39,7 @@ const DEFAULT_OPTIONS = {
default: REGISTRY_URL,
},
sort: true,
userConfig: {},
workspaceConcurrency: 1,
}
@@ -42,6 +42,7 @@ export const DEFAULT_OPTS = {
storeDir: '../store',
strictSsl: false,
userAgent: 'pnpm',
userConfig: {},
useRunningStoreServer: false,
useStoreServer: false,
workspaceConcurrency: 4,
@@ -41,6 +41,7 @@ export const DEFAULT_OPTS = {
storeDir: '../store',
strictSsl: false,
userAgent: 'pnpm',
userConfig: {},
useRunningStoreServer: false,
useStoreServer: false,
workspaceConcurrency: 4,
@@ -152,7 +152,7 @@ export type OutdatedCommandOptions = {
| 'strictSsl'
| 'tag'
| 'userAgent'
>
> & Partial<Pick<Config, 'userConfig'>>
export async function handler (
opts: OutdatedCommandOptions,
@@ -33,6 +33,7 @@ const OUTDATED_OPTIONS = {
strictSsl: false,
tag: 'latest',
userAgent: '',
userConfig: {},
}
test('pnpm outdated: show details', async () => {
@@ -44,6 +44,7 @@ export const DEFAULT_OPTS = {
strictSsl: false,
tag: 'latest',
userAgent: 'pnpm',
userConfig: {},
useRunningStoreServer: false,
useStoreServer: false,
workspaceConcurrency: 4,
@@ -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,
@@ -41,6 +41,7 @@ export const DEFAULT_OPTS = {
cacheDir: '../cache',
strictSsl: false,
userAgent: 'pnpm',
userConfig: {},
useRunningStoreServer: false,
useStoreServer: false,
workspaceConcurrency: 4,
@@ -27,6 +27,7 @@ export interface StrictRebuildOptions {
development: boolean
optional: boolean
rawConfig: object
userConfig: Record<string, string>
userAgent: string
packageManager: {
name: string
@@ -41,6 +41,7 @@ export const DEFAULT_OPTS = {
storeDir: '../store',
strictSsl: false,
userAgent: 'pnpm',
userConfig: {},
useRunningStoreServer: false,
useStoreServer: false,
workspaceConcurrency: 4,
@@ -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
@@ -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'
@@ -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(
@@ -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'])
})
@@ -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,
+5 -5
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