Skip to content

Commit

Permalink
Add OTP prompt during publish
Browse files Browse the repository at this point in the history
  • Loading branch information
rbuckton committed May 12, 2019
1 parent e32da08 commit 4eb1684
Show file tree
Hide file tree
Showing 7 changed files with 264 additions and 4 deletions.
4 changes: 3 additions & 1 deletion commands/publish/index.js
Expand Up @@ -91,6 +91,8 @@ class PublishCommand extends Command {
this.logger.verbose("session", npmSession);
this.logger.verbose("user-agent", userAgent);

// cache to hold a one-time-password across publishes
this.otpCache = { otp: undefined };
this.conf = npmConf({
lernaCommand: "publish",
npmSession,
Expand Down Expand Up @@ -643,7 +645,7 @@ class PublishCommand extends Command {
const mapper = pPipe(
[
pkg =>
pulseTillDone(npmPublish(pkg, pkg.packed.tarFilePath, opts)).then(() => {
pulseTillDone(npmPublish(pkg, pkg.packed.tarFilePath, opts, this.otpCache)).then(() => {
tracker.success("published", pkg.name, pkg.version);
tracker.completeWork(1);

Expand Down
7 changes: 7 additions & 0 deletions core/otplease/README.md
@@ -0,0 +1,7 @@
# `@lerna/otplease`

> Prompt for OTP when wrapped Promise fails
## Usage

This is an internal lerna library, you probably shouldn't use it directly.
118 changes: 118 additions & 0 deletions core/otplease/__tests__/otplease.test.js
@@ -0,0 +1,118 @@
jest.mock("@lerna/prompt");
const otplease = require("..")
const prompt = require("@lerna/prompt");

describe("@lerna/otplease", () => {
let savedIsTTY;
beforeAll(() => {
savedIsTTY = [process.stdin.isTTY, process.stdout.isTTY];
process.stdin.isTTY = true;
process.stdout.isTTY = true;
})
afterAll(() => {
process.stdin.isTTY = savedIsTTY[0];
process.stdout.isTTY = savedIsTTY[1];
});

it("no error", () => {
const obj = {};
const fn = jest.fn(() => obj);
prompt.input.mockResolvedValue("123456");
return otplease(fn, {}).then(result => {
expect(fn).toBeCalled();
expect(prompt.input).not.toBeCalled();
expect(result).toBe(obj);
});
});
it("request otp", () => {
const obj = {};
const fn = jest.fn(makeTestCallback("123456", obj));
prompt.input.mockResolvedValue("123456");
return otplease(fn, {}).then(result => {
expect(fn).toBeCalledTimes(2);
expect(prompt.input).toBeCalled();
expect(result).toBe(obj);
});
});
it("request otp updates cache", () => {
const otpCache = { otp: undefined };
const obj = {};
const fn = jest.fn(makeTestCallback("123456", obj));
prompt.input.mockResolvedValue("123456");
return otplease(fn, {}, otpCache).then(result => {
expect(fn).toBeCalledTimes(2);
expect(prompt.input).toBeCalled();
expect(result).toBe(obj);
expect(otpCache.otp).toBe("123456");
});
});
it("uses cache if opts does not have own otp", () => {
const otpCache = { otp: "654321" };
const obj = {};
const fn = jest.fn(makeTestCallback("654321", obj));
prompt.input.mockResolvedValue("123456");
return otplease(fn, {}, otpCache).then(result => {
expect(fn).toBeCalledTimes(1);
expect(prompt.input).not.toBeCalled();
expect(result).toBe(obj);
expect(otpCache.otp).toBe("654321");
});
});
it("uses explicit otp regardless of cache value", () => {
const otpCache = { otp: "654321" };
const obj = {};
const fn = jest.fn(makeTestCallback("987654", obj));
prompt.input.mockResolvedValue("123456");
return otplease(fn, { otp: "987654" }, otpCache).then(result => {
expect(fn).toBeCalledTimes(1);
expect(prompt.input).not.toBeCalled();
expect(result).toBe(obj);
expect(otpCache.otp).toBe("654321"); // do not replace cache
});
});
it("using cache updated in a different task", () => {
const otpCache = { otp: undefined };
const obj = {};
const fn = jest.fn(makeTestCallback("654321", obj));
prompt.input.mockResolvedValue("123456");

// enqueue a promise resolution to update the otp at the start of the next turn.
Promise.resolve().then(() => { otpCache.otp = "654321"; });

// start intial otplease call, 'catch' will happen in next turn *after* the cache is set.
return otplease(fn, {}, otpCache).then(result => {
expect(fn).toBeCalledTimes(2);
expect(prompt.input).not.toBeCalled();
expect(result).toBe(obj);
});
});
it("semaphore prevents overlapping requests for OTP", () => {
const otpCache = { otp: undefined };
prompt.input.mockResolvedValue("123456");

// overlapped calls to otplease that share an otpCache should
// result in the user only being prompted *once* for an OTP.
const obj1 = {};
const fn1 = jest.fn(makeTestCallback("123456", obj1));
const p1 = otplease(fn1, {}, otpCache);

const obj2 = {};
const fn2 = jest.fn(makeTestCallback("123456", obj2));
const p2 = otplease(fn2, {}, otpCache);

return Promise.all([p1, p2]).then(res => {
expect(fn1).toBeCalledTimes(2);
expect(fn2).toBeCalledTimes(2);
expect(prompt.input).toBeCalledTimes(1); // only called once for the two concurrent requests
expect(res[0]).toBe(obj1);
expect(res[1]).toBe(obj2);
});
});
})

function makeTestCallback(otp, result) {
return opts => {
if (opts.otp !== otp) throw { code: "EOTP" };
return result;
};
}
93 changes: 93 additions & 0 deletions core/otplease/otplease.js
@@ -0,0 +1,93 @@
'use strict'

const figgyPudding = require('figgy-pudding')
const prompt = require('@lerna/prompt')

const OtpPleaseConfig = figgyPudding({
otp: {}
})

// basic single-entry semaphore
const semaphore = {
wait() {
return new Promise(resolve => {
if (!this._promise) {
// not waiting, block other callers until 'release' is called.
this._promise = new Promise(resolve => this._resolve = resolve);
resolve();
} else {
// wait for 'release' to be called and try to lock the semaphore again.
resolve(this._promise.then(() => this.wait()));
}
});
},
release() {
const resolve = this._resolve;
if (resolve) {
this._resolve = undefined;
this._promise = undefined;
// notify waiters that the semaphore has been released.
resolve();
}
}
};

module.exports = otplease
function otplease(fn, _opts, otpCache) {
// NOTE: do not use 'otpCache' as a figgy-pudding provider directly as the
// otp value could change between async wait points.
const opts = OtpPleaseConfig(Object.assign({}, otpCache), _opts)
return attempt(fn, opts, otpCache);
}


function attempt(fn, opts, otpCache) {
return new Promise(resolve => {
resolve(fn(opts))
}).catch(err => {
if (err.code !== 'EOTP' && !(err.code === 'E401' && /one-time pass/.test(err.body))) {
throw err
} else if (!process.stdin.isTTY || !process.stdout.isTTY) {
throw err
} else {
// check the cache in case a concurrent caller has already updated the otp.
if (otpCache != null && otpCache.otp != null && otpCache.otp !== opts.otp) {
return attempt(fn, opts.concat(otpCache), otpCache)
}
// only allow one getOneTimePassword attempt at a time to reuse the value
// from the preceeding prompt
return semaphore.wait().then(() => {
// check the cache again in case a previous waiter already updated it.
if (otpCache != null && otpCache.otp != null && otpCache.otp !== opts.otp) {
semaphore.release();
return attempt(fn, opts.concat({ otp: otpCache.otp }), otpCache)
} else {
return getOneTimePassword().then(otp => {
// update the otp and release the lock so that waiting
// callers can see the updated otp.
if (otpCache != null) otpCache.otp = otp;
semaphore.release();
return otp;
}, err => {
// release the lock and reject the promise.
semaphore.release();
return Promise.reject(err);
}).then(otp => {
return fn(opts.concat({ otp }));
});
}
})
}
})
}

function getOneTimePassword() {
// Logic taken from npm internals: https://git.io/fNoMe
return prompt.input('This operation requires a one-time password.\nEnter OTP:', {
filter: otp => otp.replace(/\s+/g, ""),
validate: otp =>
(otp && /^[\d ]+$|^[A-Fa-f0-9]{64,64}$/.test(otp)) ||
"Must be a valid one-time-password. " +
"See https://docs.npmjs.com/getting-started/using-two-factor-authentication",
});
}
38 changes: 38 additions & 0 deletions core/otplease/package.json
@@ -0,0 +1,38 @@
{
"name": "@lerna/otplease",
"version": "3.13.0",
"description": "Prompt for OTP when wrapped Promise fails",
"keywords": [
"lerna",
"utils"
],
"homepage": "https://github.com/lerna/lerna/tree/master/core/otplease#readme",
"license": "MIT",
"author": {
"name": "Daniel Stockman",
"url": "https://github.com/evocateur"
},
"files": [
"otplease.js"
],
"main": "otplease.js",
"engines": {
"node": ">= 6.9.0"
},
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/lerna/lerna.git",
"directory": "utils/output"
},
"scripts": {
"test": "echo \"Run tests from root\" && exit 1"
},
"dependencies": {
"@lerna/prompt": "file:../prompt",
"figgy-pudding": "^3.5.1"
}
}

7 changes: 4 additions & 3 deletions utils/npm-publish/npm-publish.js
Expand Up @@ -8,6 +8,7 @@ const readJSON = require("read-package-json");
const figgyPudding = require("figgy-pudding");
const runLifecycle = require("@lerna/run-lifecycle");
const npa = require("npm-package-arg");
const otplease = require("@lerna/otplease");

module.exports = npmPublish;

Expand All @@ -30,7 +31,7 @@ const PublishConfig = figgyPudding(
}
);

function npmPublish(pkg, tarFilePath, _opts) {
function npmPublish(pkg, tarFilePath, _opts, otpCache) {
const { scope } = npa(pkg.name);
// pass only the package scope to libnpmpublish
const opts = PublishConfig(_opts, {
Expand All @@ -56,7 +57,7 @@ function npmPublish(pkg, tarFilePath, _opts) {
manifest.publishConfig.tag = opts.tag;
}

return publish(manifest, tarData, opts).catch(err => {
return otplease(opts => publish(manifest, tarData, opts), opts, otpCache).catch(err => {
opts.log.silly("", err);
opts.log.error(err.code, (err.body && err.body.error) || err.message);

Expand All @@ -76,4 +77,4 @@ function npmPublish(pkg, tarFilePath, _opts) {
chain = chain.then(() => runLifecycle(pkg, "postpublish", opts));

return chain;
}
}
1 change: 1 addition & 0 deletions utils/npm-publish/package.json
Expand Up @@ -32,6 +32,7 @@
},
"dependencies": {
"@lerna/run-lifecycle": "file:../run-lifecycle",
"@lerna/otplease": "file:../../core/otplease",
"figgy-pudding": "^3.5.1",
"fs-extra": "^7.0.0",
"libnpmpublish": "^1.1.1",
Expand Down

0 comments on commit 4eb1684

Please sign in to comment.