Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat(publish): disable legacy verifyAccess behavior by default #3249

Merged
merged 8 commits into from Jul 22, 2022
19 changes: 10 additions & 9 deletions commands/publish/README.md
Expand Up @@ -26,8 +26,6 @@ During all publish operations, appropriate [lifecycle scripts](#lifecycle-script

Check out [Per-Package Configuration](#per-package-configuration) for more details about publishing scoped packages, custom registries, and custom dist-tags.

> If you're using [npm automation access token](https://docs.npmjs.com/creating-and-viewing-access-tokens#creating-access-tokens) please remember to [disable lerna access verification feature](#--no-verify-access). Automation token doesn't grant permissions needed for the verification to be successful. [Click here to read more about this issue](https://github.com/lerna/lerna/issues/2788).

## Positionals

### bump `from-git`
Expand Down Expand Up @@ -59,7 +57,7 @@ This is useful when a previous `lerna publish` failed to publish all packages to
- [`--legacy-auth`](#--legacy-auth)
- [`--no-git-reset`](#--no-git-reset)
- [`--no-granular-pathspec`](#--no-granular-pathspec)
- [`--no-verify-access`](#--no-verify-access)
- [`--verify-access`](#--verify-access)
- [`--otp`](#--otp)
- [`--preid`](#--preid)
- [`--pre-dist-tag <tag>`](#--pre-dist-tag-tag)
Expand Down Expand Up @@ -199,15 +197,14 @@ This option makes the most sense configured in lerna.json, as you really don't w

The root-level configuration is intentional, as this also covers the [identically-named option in `lerna version`](https://github.com/lerna/lerna/tree/main/commands/version#--no-granular-pathspec).

### `--no-verify-access`

By default, `lerna` will verify the logged-in npm user's access to the packages about to be published. Passing this flag will disable that check.
### `--verify-access`

If you are using a third-party registry that does not support `npm access ls-packages`, you will need to pass this flag (or set `command.publish.verifyAccess` to `false` in lerna.json).
By default, `lerna` will not verify the logged-in npm user's access to the packages about to be published. Passing this flag will cause `lerna` to proactively perform this verification before it attempts to publish any packages.
JamesHenry marked this conversation as resolved.
Show resolved Hide resolved

> Please use with caution
You should NOT use this option if:

> For the time being, use this flag/option always when you're handling NPM authorization with the use of [automation access token](https://docs.npmjs.com/creating-and-viewing-access-tokens#creating-access-tokens). [Click here to read more about this issue](https://github.com/lerna/lerna/issues/2788).
1. You are using a third-party registry that does not support `npm access ls-packages`
2. You are using an authentication token without read access, such as a [npm automation access token](https://docs.npmjs.com/creating-and-viewing-access-tokens#creating-access-tokens)

### `--otp`

Expand Down Expand Up @@ -297,6 +294,10 @@ Useful in [Continuous integration (CI)](https://en.wikipedia.org/wiki/Continuous

## Deprecated Options

### `--no-verify-access`

Access verification is now off by default, so `--no-verify-access` is not needed. To opt-in to access verification, use [`--verify-access`](#--verify-access).
JamesHenry marked this conversation as resolved.
Show resolved Hide resolved

### `--skip-npm`

Call [`lerna version`](https://github.com/lerna/lerna/tree/main/commands/version#readme) directly, instead.
Expand Down
17 changes: 17 additions & 0 deletions commands/publish/__tests__/get-npm-username.test.js
Expand Up @@ -68,6 +68,23 @@ describe("getNpmUsername", () => {
expect(console.error).toHaveBeenCalledWith("third-party whoami fail");
});

test("logs failure message when npm returns forbidden response", async () => {
fetch.json.mockImplementationOnce(() => {
const err = new Error("npm profile fail due to insufficient permissions");

err.code = "E403";

return Promise.reject(err);
});

const opts = { registry: "https://registry.npmjs.org/" };

await expect(getNpmUsername(opts)).rejects.toThrow(
"Access verification failed. Ensure that your npm access token has both read and write access, or remove the verifyAccess option to skip this verification. Note that npm automation tokens do NOT have read access (https://docs.npmjs.com/creating-and-viewing-access-tokens)."
);
expect(console.error).toHaveBeenCalledWith("npm profile fail due to insufficient permissions");
});

test("allows third-party registries to fail with a stern warning", async () => {
fetch.json.mockImplementationOnce(() => {
const err = new Error("many third-party registries do not support npm whoami");
Expand Down
106 changes: 87 additions & 19 deletions commands/publish/__tests__/publish-command.test.js
Expand Up @@ -124,23 +124,8 @@ Map {
expect(npmDistTag.remove).not.toHaveBeenCalled();
expect(npmDistTag.add).not.toHaveBeenCalled();

expect(getNpmUsername).toHaveBeenCalled();
expect(getNpmUsername).toHaveBeenLastCalledWith(
expect.objectContaining({ registry: "https://registry.npmjs.org/" })
);

expect(verifyNpmPackageAccess).toHaveBeenCalled();
expect(verifyNpmPackageAccess).toHaveBeenLastCalledWith(
expect.any(Array),
"lerna-test",
expect.objectContaining({ registry: "https://registry.npmjs.org/" })
);

expect(getTwoFactorAuthRequired).toHaveBeenCalled();
expect(getTwoFactorAuthRequired).toHaveBeenLastCalledWith(
// extra insurance that @lerna/npm-conf is defaulting things correctly
expect.objectContaining({ otp: undefined })
);
expect(getNpmUsername).not.toHaveBeenCalled();
expect(verifyNpmPackageAccess).not.toHaveBeenCalled();

expect(gitCheckout).toHaveBeenCalledWith(
// the list of changed files has been asserted many times already
Expand Down Expand Up @@ -228,12 +213,12 @@ Map {
expect(getOneTimePassword).not.toHaveBeenCalled();
});

it("prompts for OTP when option missing and account-level 2FA enabled", async () => {
it("prompts for OTP when option missing, account-level 2FA enabled, and verify access is true", async () => {
const testDir = await initFixture("normal");

getTwoFactorAuthRequired.mockResolvedValueOnce(true);

await lernaPublish(testDir)();
await lernaPublish(testDir)("--verify-access", true);

expect(npmPublish).toHaveBeenCalledWith(
expect.objectContaining({ name: "package-1" }),
Expand All @@ -243,6 +228,22 @@ Map {
);
expect(getOneTimePassword).toHaveBeenLastCalledWith("Enter OTP:");
});

it("defers OTP prompt when option missing, account-level 2FA enabled, and verify access is not true", async () => {
const testDir = await initFixture("normal");

getTwoFactorAuthRequired.mockResolvedValueOnce(true);

await lernaPublish(testDir)();

expect(npmPublish).toHaveBeenCalledWith(
expect.objectContaining({ name: "package-1" }),
"/TEMP_DIR/package-1-MOCKED.tgz",
expect.objectContaining({ otp: undefined }),
expect.objectContaining({ otp: undefined })
);
expect(getOneTimePassword).not.toHaveBeenCalled();
});
});

describe("--legacy-auth", () => {
Expand Down Expand Up @@ -306,7 +307,74 @@ Map {
});
});

describe("--verify-access", () => {
it("publishes packages after verifying the user's access to each package", async () => {
const testDir = await initFixture("normal");

await lernaPublish(testDir)("--verify-access");

expect(promptConfirmation).toHaveBeenLastCalledWith("Are you sure you want to publish these packages?");
expect(packDirectory.registry).toMatchInlineSnapshot(`
Set {
"package-1",
"package-3",
"package-4",
"package-2",
}
`);
expect(npmPublish.registry).toMatchInlineSnapshot(`
Map {
"package-1" => "latest",
"package-3" => "latest",
"package-4" => "latest",
"package-2" => "latest",
}
`);
expect(npmPublish.order()).toEqual([
"package-1",
"package-3",
"package-4",
"package-2",
// package-5 is private
]);
expect(npmDistTag.remove).not.toHaveBeenCalled();
expect(npmDistTag.add).not.toHaveBeenCalled();

expect(getNpmUsername).toHaveBeenCalled();
expect(getNpmUsername).toHaveBeenLastCalledWith(
expect.objectContaining({ registry: "https://registry.npmjs.org/" })
);

expect(verifyNpmPackageAccess).toHaveBeenCalled();
expect(verifyNpmPackageAccess).toHaveBeenLastCalledWith(
expect.any(Array),
"lerna-test",
expect.objectContaining({ registry: "https://registry.npmjs.org/" })
);

expect(getTwoFactorAuthRequired).toHaveBeenCalled();
expect(getTwoFactorAuthRequired).toHaveBeenLastCalledWith(expect.objectContaining({ otp: undefined }));

expect(gitCheckout).toHaveBeenCalledWith(
expect.any(Array),
{ granularPathspec: true },
{ cwd: testDir }
);
});
});

describe("--no-verify-access", () => {
it("shows warning that this is the default behavior and that this option is no longer needed", async () => {
const cwd = await initFixture("normal");

await lernaPublish(cwd)("--no-verify-access");

const logMessages = loggingOutput("warn");
expect(logMessages).toContain(
"--verify-access=false and --no-verify-access are no longer needed, because the legacy preemptive access verification is now disabled by default. Requests will fail with appropriate errors when not authorized correctly."
);
});

it("skips package access verification", async () => {
const cwd = await initFixture("normal");

Expand Down
9 changes: 2 additions & 7 deletions commands/publish/command.js
Expand Up @@ -102,19 +102,14 @@ exports.builder = (yargs) => {
type: "boolean",
},
"no-verify-access": {
// proxy for --verify-access
describe: "Do not verify package read-write access for current npm user.",
type: "boolean",
},
"verify-access": {
// proxy for --no-verify-access
hidden: true,
describe: "Verify package read-write access for current npm user.",
type: "boolean",
},
// y: {
// describe: "Skip all confirmation prompts.",
// alias: "yes",
// type: "boolean",
// },
};

composeVersionOptions(yargs);
Expand Down
10 changes: 9 additions & 1 deletion commands/publish/index.js
Expand Up @@ -82,10 +82,11 @@ class PublishCommand extends Command {

// inverted boolean options are only respected if prefixed with `--no-`, e.g. `--no-verify-access`
this.gitReset = gitReset !== false;
this.verifyAccess = verifyAccess !== false;

// consumed by npm-registry-fetch (via libnpmpublish)
this.npmSession = crypto.randomBytes(8).toString("hex");

this.verifyAccess = verifyAccess;
}

get userAgent() {
Expand All @@ -94,6 +95,13 @@ class PublishCommand extends Command {
}

initialize() {
if (this.options.verifyAccess === false) {
this.logger.warn(
"verify-access",
"--verify-access=false and --no-verify-access are no longer needed, because the legacy preemptive access verification is now disabled by default. Requests will fail with appropriate errors when not authorized correctly."
);
}

if (this.options.skipNpm) {
// TODO: remove in next major release
this.logger.warn("deprecated", "Instead of --skip-npm, call `lerna version` directly");
Expand Down
7 changes: 7 additions & 0 deletions commands/publish/lib/get-npm-username.js
Expand Up @@ -54,6 +54,13 @@ function getNpmUsername(options) {
opts.log.resume();

if (opts.registry === "https://registry.npmjs.org/") {
if (err.code === "E403") {
throw new ValidationError(
"ENEEDAUTH",
"Access verification failed. Ensure that your npm access token has both read and write access, or remove the verifyAccess option to skip this verification. Note that npm automation tokens do NOT have read access (https://docs.npmjs.com/creating-and-viewing-access-tokens)."
);
}

throw new ValidationError("EWHOAMI", "Authentication error. Use `npm whoami` to troubleshoot.");
}

Expand Down