From b31950db399950bbc72a62f8e34035e9ac578dd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominykas=20Blyz=CC=8Ce=CC=87?= Date: Fri, 28 Jun 2019 23:46:56 +0300 Subject: [PATCH 1/4] feat: support OTP fetching from config.otpUrl --- lib/publish.js | 10 ++++++++-- lib/verify-config.js | 1 + package.json | 1 + 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/publish.js b/lib/publish.js index a824f1e0..69fbdc6c 100644 --- a/lib/publish.js +++ b/lib/publish.js @@ -1,9 +1,10 @@ const path = require('path'); const execa = require('execa'); +const fetch = require('node-fetch'); const getRegistry = require('./get-registry'); const getReleaseInfo = require('./get-release-info'); -module.exports = async ({npmPublish, pkgRoot}, pkg, context) => { +module.exports = async ({npmPublish, pkgRoot, otpUrl}, pkg, context) => { const { cwd, env, @@ -16,9 +17,14 @@ module.exports = async ({npmPublish, pkgRoot}, pkg, context) => { if (npmPublish !== false && pkg.private !== true) { const basePath = pkgRoot ? path.resolve(cwd, pkgRoot) : cwd; const registry = getRegistry(pkg, context); + let otpArgs = []; + if (otpUrl) { + const res = await fetch(otpUrl); + otpArgs = ['--otp', await res.text()]; + } logger.log('Publishing version %s to npm registry', version); - const result = execa('npm', ['publish', basePath, '--registry', registry], {cwd, env}); + const result = execa('npm', ['publish', basePath, '--registry', registry, ...otpArgs], {cwd, env}); result.stdout.pipe( stdout, {end: false} diff --git a/lib/verify-config.js b/lib/verify-config.js index c92bd931..603554dc 100644 --- a/lib/verify-config.js +++ b/lib/verify-config.js @@ -7,6 +7,7 @@ const VALIDATORS = { npmPublish: isBoolean, tarballDir: isNonEmptyString, pkgRoot: isNonEmptyString, + otpUrl: isNonEmptyString, }; module.exports = ({npmPublish, tarballDir, pkgRoot}) => { diff --git a/package.json b/package.json index 01caed4a..dca08efd 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "fs-extra": "^8.0.0", "lodash": "^4.17.4", "nerf-dart": "^1.0.0", + "node-fetch": "^2.6.0", "normalize-url": "^4.0.0", "npm": "^6.8.0", "rc": "^1.2.8", From a70b5a52bdbe9d6ac60534424e8ce39df36d5e9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominykas=20Blyz=CC=8Ce=CC=87?= Date: Wed, 17 Jul 2019 00:16:55 +0300 Subject: [PATCH 2/4] test: add coverage for the otpUrl case Note: this does not yet assert that the OTP was actually sent over to npm. --- lib/publish.js | 2 ++ test/helpers/otp-server.js | 27 +++++++++++++++++++++++++++ test/integration.test.js | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 test/helpers/otp-server.js diff --git a/lib/publish.js b/lib/publish.js index 69fbdc6c..92d8d7bc 100644 --- a/lib/publish.js +++ b/lib/publish.js @@ -19,8 +19,10 @@ module.exports = async ({npmPublish, pkgRoot, otpUrl}, pkg, context) => { const registry = getRegistry(pkg, context); let otpArgs = []; if (otpUrl) { + logger.log('Retrieving OTP from a remote URL...'); const res = await fetch(otpUrl); otpArgs = ['--otp', await res.text()]; + logger.log('OTP received'); } logger.log('Publishing version %s to npm registry', version); diff --git a/test/helpers/otp-server.js b/test/helpers/otp-server.js new file mode 100644 index 00000000..545473b4 --- /dev/null +++ b/test/helpers/otp-server.js @@ -0,0 +1,27 @@ +import http from 'http'; + +let server; + +async function start() { + server = http.createServer((req, res) => { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.end('this-is-my-otp'); + }); + + return new Promise(resolve => { + server.listen(0, '127.0.0.1', () => resolve(server)); + }); +} + +async function stop() { + return new Promise(resolve => { + server.close(() => resolve()); + }); +} + +function address() { + return server.address(); +} + +export default {start, stop, address}; diff --git a/test/integration.test.js b/test/integration.test.js index 8e278a48..91c8d6fe 100644 --- a/test/integration.test.js +++ b/test/integration.test.js @@ -7,6 +7,7 @@ import tempy from 'tempy'; import clearModule from 'clear-module'; import {WritableStreamBuffer} from 'stream-buffers'; import npmRegistry from './helpers/npm-registry'; +import otpServer from './helpers/otp-server'; /* eslint camelcase: ["error", {properties: "never"}] */ @@ -23,6 +24,8 @@ const testEnv = { test.before(async () => { // Start the local NPM registry await npmRegistry.start(); + // Stop the test OTP server + await otpServer.start(); }); test.beforeEach(t => { @@ -39,6 +42,8 @@ test.beforeEach(t => { test.after.always(async () => { // Stop the local NPM registry await npmRegistry.stop(); + // Stop the test OTP server + await otpServer.start(); }); test('Skip npm auth verification if "npmPublish" is false', async t => { @@ -285,6 +290,34 @@ test('Publish the package on a dist-tag', async t => { t.is((await execa('npm', ['view', pkg.name, 'version'], {cwd, env: testEnv})).stdout, '1.0.0'); }); +test('Publish the package with OTP', async t => { + const cwd = tempy.directory(); + const env = npmRegistry.authEnv; + const pkg = {name: 'publish-otp', version: '0.0.0', publishConfig: {registry: npmRegistry.url}}; + await outputJson(path.resolve(cwd, 'package.json'), pkg); + + const {port, address} = otpServer.address(); + const result = await t.context.m.publish( + { + otpUrl: `http://${address}:${port}/otp`, + }, + { + cwd, + env, + options: {}, + stdout: t.context.stdout, + stderr: t.context.stderr, + logger: t.context.logger, + nextRelease: {version: '1.0.0'}, + } + ); + + t.deepEqual(result, {name: 'npm package (@latest dist-tag)', url: undefined}); + t.is((await readJson(path.resolve(cwd, 'package.json'))).version, '1.0.0'); + t.false(await pathExists(path.resolve(cwd, `${pkg.name}-1.0.0.tgz`))); + t.is((await execa('npm', ['view', pkg.name, 'version'], {cwd, env: testEnv})).stdout, '1.0.0'); +}); + test('Publish the package from a sub-directory', async t => { const cwd = tempy.directory(); const env = npmRegistry.authEnv; From 04d64ffc0bd884361107a8ae346d6692e32b7d98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominykas=20Blyz=CC=8Ce=CC=87?= Date: Wed, 17 Jul 2019 00:44:57 +0300 Subject: [PATCH 3/4] docs: add `otpUrl` notes --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 13db8d53..ba44576f 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ The npm authentication configuration is **required** and can be set via [environ Both the [token](https://docs.npmjs.com/getting-started/working_with_tokens) and the legacy (`username`, `password` and `email`) authentication are supported. It is recommended to use the [token](https://docs.npmjs.com/getting-started/working_with_tokens) authentication. The legacy authentication is supported as the alternative npm registries [Artifactory](https://www.jfrog.com/open-source/#os-arti) and [npm-registry-couchapp](https://github.com/npm/npm-registry-couchapp) only supports that form of authentication. -**Note**: Only the `auth-only` [level of npm two-factor authentication](https://docs.npmjs.com/getting-started/using-two-factor-authentication#levels-of-authentication) is supported, **semantic-release** will not work with the default `auth-and-writes` level. +**Note**: You either have to use the `auth-only` [level of npm two-factor authentication](https://docs.npmjs.com/getting-started/using-two-factor-authentication#levels-of-authentication), or set up a server, which can return a one time password via a URL and provide it via an `otpUrl` configuration option. To ensure the second factor principle is kept, the server should only respond with the one time password after a human confirmation. ### Environment variables @@ -63,6 +63,7 @@ Use either `NPM_TOKEN` for token authentication or `NPM_USERNAME`, `NPM_PASSWORD | `npmPublish` | Whether to publish the `npm` package to the registry. If `false` the `package.json` version will still be updated. | `false` if the `package.json` [private](https://docs.npmjs.com/files/package.json#private) property is `true`, `true` otherwise. | | `pkgRoot` | Directory path to publish. | `.` | | `tarballDir` | Directory path in which to write the the package tarball. If `false` the tarball is not be kept on the file system. | `false` | +| `otpUrl` | A URL which returns a one time [2FA password for npm](https://docs.npmjs.com/about-two-factor-authentication). | `undefined` | **Note**: The `pkgRoot` directory must contains a `package.json`. The version will be updated only in the `package.json` and `npm-shrinkwrap.json` within the `pkgRoot` directory. From b76b32e77bdb18e3bd25b25b5852d4ab777ba342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominykas=20Blyz=CC=8Ce=CC=87?= Date: Wed, 17 Jul 2019 00:50:39 +0300 Subject: [PATCH 4/4] fix: correctly verify otpUrl config option --- index.js | 3 ++- lib/definitions/errors.js | 6 ++++++ lib/verify-config.js | 4 ++-- test/verify-config.test.js | 24 ++++++++++++++++++++++-- 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/index.js b/index.js index 5200dc18..5d168467 100644 --- a/index.js +++ b/index.js @@ -11,7 +11,7 @@ let verified; let prepared; async function verifyConditions(pluginConfig, context) { - // If the npm publish plugin is used and has `npmPublish`, `tarballDir` or `pkgRoot` configured, validate them now in order to prevent any release if the configuration is wrong + // If the npm publish plugin is used and has `npmPublish`, `tarballDir`, `pkgRoot` or `otpUrl` configured, validate them now in order to prevent any release if the configuration is wrong if (context.options.publish) { const publishPlugin = castArray(context.options.publish).find(config => config.path && config.path === '@semantic-release/npm') || {}; @@ -19,6 +19,7 @@ async function verifyConditions(pluginConfig, context) { pluginConfig.npmPublish = defaultTo(pluginConfig.npmPublish, publishPlugin.npmPublish); pluginConfig.tarballDir = defaultTo(pluginConfig.tarballDir, publishPlugin.tarballDir); pluginConfig.pkgRoot = defaultTo(pluginConfig.pkgRoot, publishPlugin.pkgRoot); + pluginConfig.otpUrl = defaultTo(pluginConfig.otpUrl, publishPlugin.otpUrl); } const errors = verifyNpmConfig(pluginConfig); diff --git a/lib/definitions/errors.js b/lib/definitions/errors.js index 10a70523..572e01b3 100644 --- a/lib/definitions/errors.js +++ b/lib/definitions/errors.js @@ -21,6 +21,12 @@ Your configuration for the \`tarballDir\` option is \`${tarballDir}\`.`, details: `The [pkgRoot option](${linkify('README.md#pkgroot')}) option, if defined, must be a \`String\`. Your configuration for the \`pkgRoot\` option is \`${pkgRoot}\`.`, + }), + EINVALIDOTPURL: ({otpUrl}) => ({ + message: 'Invalid `otpUrl` option.', + details: `The [otpUrl option](${linkify('README.md#otpurl')}) option, if defined, must be a \`String\`. + +Your configuration for the \`otpUrl\` option is \`${otpUrl}\`.`, }), ENONPMTOKEN: ({registry}) => ({ message: 'No npm token specified.', diff --git a/lib/verify-config.js b/lib/verify-config.js index 603554dc..c2eea4c8 100644 --- a/lib/verify-config.js +++ b/lib/verify-config.js @@ -10,8 +10,8 @@ const VALIDATORS = { otpUrl: isNonEmptyString, }; -module.exports = ({npmPublish, tarballDir, pkgRoot}) => { - const errors = Object.entries({npmPublish, tarballDir, pkgRoot}).reduce( +module.exports = ({npmPublish, tarballDir, pkgRoot, otpUrl}) => { + const errors = Object.entries({npmPublish, tarballDir, pkgRoot, otpUrl}).reduce( (errors, [option, value]) => !isNil(value) && !VALIDATORS[option](value) ? [...errors, getError(`EINVALID${option.toUpperCase()}`, {[option]: value})] diff --git a/test/verify-config.test.js b/test/verify-config.test.js index d0fe4163..433862b9 100644 --- a/test/verify-config.test.js +++ b/test/verify-config.test.js @@ -8,8 +8,20 @@ test.beforeEach(t => { t.context.logger = {log: t.context.log}; }); -test('Verify "npmPublish", "tarballDir" and "pkgRoot" options', async t => { - t.deepEqual(await verify({npmPublish: true, tarballDir: 'release', pkgRoot: 'dist'}, {}, t.context.logger), []); +test('Verify "npmPublish", "tarballDir", "pkgRoot" and "otpUrl" options', async t => { + t.deepEqual( + await verify( + { + npmPublish: true, + tarballDir: 'release', + pkgRoot: 'dist', + otpUrl: 'http://0.0.0.0/my-otp-provider', + }, + {}, + t.context.logger + ), + [] + ); }); test('Return SemanticReleaseError if "npmPublish" option is not a Boolean', async t => { @@ -36,6 +48,14 @@ test('Return SemanticReleaseError if "pkgRoot" option is not a String', async t t.is(error.code, 'EINVALIDPKGROOT'); }); +test('Return SemanticReleaseError if "otpUrl" option is not a String', async t => { + const otpUrl = 42; + const [error] = await verify({otpUrl}, {}, t.context.logger); + + t.is(error.name, 'SemanticReleaseError'); + t.is(error.code, 'EINVALIDOTPURL'); +}); + test('Return SemanticReleaseError Array if multiple config are invalid', async t => { const npmPublish = 42; const tarballDir = 42;