Skip to content

Commit

Permalink
feat(publish): Add OTP prompt during publish (#2084)
Browse files Browse the repository at this point in the history
Fixes #1091
  • Loading branch information
rbuckton authored and evocateur committed May 13, 2019
1 parent 98e77cf commit c56bda1
Show file tree
Hide file tree
Showing 10 changed files with 399 additions and 5 deletions.
6 changes: 4 additions & 2 deletions commands/publish/__tests__/publish-command.test.js
Expand Up @@ -176,7 +176,8 @@ Map {
expect(npmPublish).toHaveBeenCalledWith(
expect.objectContaining({ name: "package-1" }),
"/TEMP_DIR/package-1-MOCKED.tgz",
expect.objectContaining({ registry })
expect.objectContaining({ registry }),
expect.objectContaining({ otp: undefined })
);
});

Expand All @@ -189,7 +190,8 @@ Map {
expect(npmPublish).toHaveBeenCalledWith(
expect.objectContaining({ name: "package-1" }),
"/TEMP_DIR/package-1-MOCKED.tgz",
expect.objectContaining({ registry: "https://registry.npmjs.org/" })
expect.objectContaining({ registry: "https://registry.npmjs.org/" }),
expect.objectContaining({ otp: undefined })
);

const logMessages = loggingOutput("warn");
Expand Down
5 changes: 4 additions & 1 deletion commands/publish/index.js
Expand Up @@ -92,6 +92,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 @@ -647,7 +649,8 @@ class PublishCommand extends Command {
const preDistTag = this.getPreDistTag(pkg);
const tag = !this.options.tempTag && preDistTag ? preDistTag : opts.tag;
const pkgOpts = Object.assign({}, opts, { tag });
return pulseTillDone(npmPublish(pkg, pkg.packed.tarFilePath, pkgOpts)).then(() => {

return pulseTillDone(npmPublish(pkg, pkg.packed.tarFilePath, pkgOpts, 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.
228 changes: 228 additions & 0 deletions core/otplease/__tests__/otplease.test.js
@@ -0,0 +1,228 @@
"use strict";

jest.mock("@lerna/prompt");

// mocked modules
const prompt = require("@lerna/prompt");

// file under test
const otplease = require("..");

// global mock setup
prompt.input.mockResolvedValue("123456");

describe("@lerna/otplease", () => {
const stdinIsTTY = process.stdin.isTTY;
const stdoutIsTTY = process.stdout.isTTY;

beforeEach(() => {
process.stdin.isTTY = true;
process.stdout.isTTY = true;
});

afterEach(() => {
process.stdin.isTTY = stdinIsTTY;
process.stdout.isTTY = stdoutIsTTY;
});

it("no error", async () => {
const obj = {};
const fn = jest.fn(() => obj);
const result = await otplease(fn, {});

expect(fn).toHaveBeenCalled();
expect(prompt.input).not.toHaveBeenCalled();
expect(result).toBe(obj);
});

it("request otp", async () => {
const obj = {};
const fn = jest.fn(makeTestCallback("123456", obj));
const result = await otplease(fn, {});

expect(fn).toHaveBeenCalledTimes(2);
expect(prompt.input).toHaveBeenCalled();
expect(result).toBe(obj);
});

it("request otp updates cache", async () => {
const otpCache = { otp: undefined };
const obj = {};
const fn = jest.fn(makeTestCallback("123456", obj));

const result = await otplease(fn, {}, otpCache);
expect(fn).toHaveBeenCalledTimes(2);
expect(prompt.input).toHaveBeenCalled();
expect(result).toBe(obj);
expect(otpCache.otp).toBe("123456");
});

it("uses cache if opts does not have own otp", async () => {
const otpCache = { otp: "654321" };
const obj = {};
const fn = jest.fn(makeTestCallback("654321", obj));
const result = await otplease(fn, {}, otpCache);

expect(fn).toHaveBeenCalledTimes(1);
expect(prompt.input).not.toHaveBeenCalled();
expect(result).toBe(obj);
expect(otpCache.otp).toBe("654321");
});

it("uses explicit otp regardless of cache value", async () => {
const otpCache = { otp: "654321" };
const obj = {};
const fn = jest.fn(makeTestCallback("987654", obj));
const result = await otplease(fn, { otp: "987654" }, otpCache);

expect(fn).toHaveBeenCalledTimes(1);
expect(prompt.input).not.toHaveBeenCalled();
expect(result).toBe(obj);
// do not replace cache
expect(otpCache.otp).toBe("654321");
});

it("using cache updated in a different task", async () => {
const otpCache = { otp: undefined };
const obj = {};
const fn = jest.fn(makeTestCallback("654321", obj));

// 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.
const result = await otplease(fn, {}, otpCache);
expect(fn).toHaveBeenCalledTimes(2);
expect(prompt.input).not.toHaveBeenCalled();
expect(result).toBe(obj);
});

it("semaphore prevents overlapping requests for OTP", async () => {
const otpCache = { otp: undefined };

// 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);

const [res1, res2] = await Promise.all([p1, p2]);

expect(fn1).toHaveBeenCalledTimes(2);
expect(fn2).toHaveBeenCalledTimes(2);
// only prompt once for the two concurrent requests
expect(prompt.input).toHaveBeenCalledTimes(1);
expect(res1).toBe(obj1);
expect(res2).toBe(obj2);
});

it("strips whitespace from OTP prompt value", async () => {
prompt.input.mockImplementationOnce((msg, opts) => Promise.resolve(opts.filter(" 121212 ")));

const obj = {};
const fn = jest.fn(makeTestCallback("121212", obj));
const result = await otplease(fn, {});

expect(result).toBe(obj);
});

it("validates OTP prompt response", async () => {
prompt.input.mockImplementationOnce((msg, opts) =>
Promise.resolve(opts.validate("i am the very model of a modern major general"))
);

const obj = {};
const fn = jest.fn(makeTestCallback("343434", obj));

try {
await otplease(fn, {});
} catch (err) {
expect(err.message).toMatch("Must be a valid one-time-password");
}

expect.hasAssertions();
});

it("rejects prompt errors", async () => {
prompt.input.mockImplementationOnce(() => Promise.reject(new Error("poopypants")));

const obj = {};
const fn = jest.fn(makeTestCallback("343434", obj));

try {
await otplease(fn, {});
} catch (err) {
expect(err.message).toMatch("poopypants");
}

expect.hasAssertions();
});

it("re-throws non-EOTP errors", async () => {
const fn = jest.fn(() => {
const err = new Error("not found");
err.code = "E404";
throw err;
});

try {
await otplease(fn, {});
} catch (err) {
expect(err.message).toMatch("not found");
}

expect.hasAssertions();
});

it("re-throws E401 errors that do not contain 'one-time pass' in the body", async () => {
const fn = jest.fn(() => {
const err = new Error("auth required");
err.body = "random arbitrary noise";
err.code = "E401";
throw err;
});

try {
await otplease(fn, {});
} catch (err) {
expect(err.message).toMatch("auth required");
}

expect.hasAssertions();
});

it.each([["stdin"], ["stdout"]])("re-throws EOTP error when %s is not a TTY", async pipe => {
const fn = jest.fn(() => {
const err = new Error(`non-interactive ${pipe}`);
err.code = "EOTP";
throw err;
});

process[pipe].isTTY = false;

try {
await otplease(fn);
} catch (err) {
expect(err.message).toBe(`non-interactive ${pipe}`);
}

expect.hasAssertions();
});
});

function makeTestCallback(otp, result) {
return opts => {
if (opts.otp !== otp) {
const err = new Error(`oops, received otp ${opts.otp}`);
err.code = "EOTP";
throw err;
}
return result;
};
}
103 changes: 103 additions & 0 deletions core/otplease/otplease.js
@@ -0,0 +1,103 @@
"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(release => {
this._resolve = release;
});
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;
// istanbul ignore else
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);
}
return getOneTimePassword()
.then(
otp => {
// update the otp and release the lock so that waiting
// callers can see the updated otp.
if (otpCache != null) {
// eslint-disable-next-line no-param-reassign
otpCache.otp = otp;
}
semaphore.release();
return otp;
},
promptError => {
// release the lock and reject the promise.
semaphore.release();
return Promise.reject(promptError);
}
)
.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:", {
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",
});
}

0 comments on commit c56bda1

Please sign in to comment.