Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Support OTP fetching from config.otpUrl #176

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@ 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') || {};

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);
Expand Down
6 changes: 6 additions & 0 deletions lib/definitions/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
12 changes: 10 additions & 2 deletions lib/publish.js
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -16,9 +17,16 @@ 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) {
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);
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}
Expand Down
5 changes: 3 additions & 2 deletions lib/verify-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ const VALIDATORS = {
npmPublish: isBoolean,
tarballDir: isNonEmptyString,
pkgRoot: isNonEmptyString,
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})]
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
27 changes: 27 additions & 0 deletions test/helpers/otp-server.js
Original file line number Diff line number Diff line change
@@ -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};
33 changes: 33 additions & 0 deletions test/integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"}] */

Expand All @@ -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 => {
Expand All @@ -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 => {
Expand Down Expand Up @@ -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;
Expand Down
24 changes: 22 additions & 2 deletions test/verify-config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand All @@ -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;
Expand Down