Skip to content

Commit

Permalink
fix(plugin-npm-cli): fix login with Verdaccio (#5983)
Browse files Browse the repository at this point in the history
**What's the problem this PR addresses?**

This commit fixes `yarn npm login` when the remote registry is
Verdaccio.

- Closes #1044
- Closes #1848
- Closes verdaccio/verdaccio#1737

...

**How did you fix it?**

When a user already exists, the registry replies with `409 Conflict`.
The official npm client then retrieves the latest user state and inserts
a revision, using HTTP basic authentication. This step was missing, and
this commits adds it.

The change was tested to work with a private Verdaccio registry. It
should now be as reliable as the official npm client.

...

**Checklist**
<!--- Don't worry if you miss something, chores are automatically
tested. -->
<!--- This checklist exists to help you remember doing the chores when
you submit a PR. -->
<!--- Put an `x` in all the boxes that apply. -->
- [x] I have read the [Contributing
Guide](https://yarnpkg.com/advanced/contributing).

<!-- See
https://yarnpkg.com/advanced/contributing#preparing-your-pr-to-be-released
for more details. -->
<!-- Check with `yarn version check` and fix with `yarn version check
-i` -->
- [x] I have set the packages that need to be released for my changes to
be effective.

<!-- The "Testing chores" workflow validates that your PR follows our
guidelines. -->
<!-- If it doesn't pass, click on it to see details as to what your PR
might be missing. -->
- [x] I will check that all automated PR checks pass before the PR gets
reviewed.
  • Loading branch information
demurgos authored Nov 28, 2023
1 parent 017b94a commit db6210f
Show file tree
Hide file tree
Showing 2 changed files with 102 additions and 20 deletions.
23 changes: 23 additions & 0 deletions .yarn/versions/89f35c90.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
releases:
"@yarnpkg/cli": patch
"@yarnpkg/plugin-npm-cli": patch

declined:
- "@yarnpkg/plugin-compat"
- "@yarnpkg/plugin-constraints"
- "@yarnpkg/plugin-dlx"
- "@yarnpkg/plugin-essentials"
- "@yarnpkg/plugin-init"
- "@yarnpkg/plugin-interactive-tools"
- "@yarnpkg/plugin-nm"
- "@yarnpkg/plugin-pack"
- "@yarnpkg/plugin-patch"
- "@yarnpkg/plugin-pnp"
- "@yarnpkg/plugin-pnpm"
- "@yarnpkg/plugin-stage"
- "@yarnpkg/plugin-typescript"
- "@yarnpkg/plugin-version"
- "@yarnpkg/plugin-workspace-tools"
- "@yarnpkg/builder"
- "@yarnpkg/core"
- "@yarnpkg/doctor"
99 changes: 79 additions & 20 deletions packages/plugin-npm-cli/sources/commands/npm/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,17 +69,9 @@ export default class NpmLoginCommand extends BaseCommand {
stdout: this.context.stdout as NodeJS.WriteStream,
});

const url = `/-/user/org.couchdb.user:${encodeURIComponent(credentials.name)}`;
const token = await registerOrLogin(registry, credentials, configuration);

const response = await npmHttpUtils.put(url, credentials, {
attemptedAs: credentials.name,
configuration,
registry,
jsonResponse: true,
authType: npmHttpUtils.AuthType.NO_AUTH,
}) as any;

await setAuthToken(registry, response.token, {alwaysAuth: this.alwaysAuth, scope: this.scope});
await setAuthToken(registry, token, {alwaysAuth: this.alwaysAuth, scope: this.scope});
return report.reportInfo(MessageName.UNNAMED, `Successfully logged in`);
});

Expand All @@ -100,6 +92,74 @@ export async function getRegistry({scope, publish, configuration, cwd}: {scope?:
return npmConfigUtils.getDefaultRegistry({configuration});
}

/**
* Register a new user, or login if the user already exists
*/
async function registerOrLogin(registry: string, credentials: Credentials, configuration: Configuration): Promise<string> {
// Registration and login are both handled as a `put` by npm. Npm uses a lax
// endpoint as of 2023-11 where there are no conflicts if the user already
// exists, but some registries such as Verdaccio are stricter and return a
// `409 Conflict` status code for existing users. In this case, the client
// should put a user revision for this specific session (with basic HTTP
// auth).
//
// The code below is based on the logic from the npm client.
// <https://github.com/npm/npm-profile/blob/30097a5eef4239399b964c2efc121e64e75ecaf5/lib/index.js#L156>.
const userUrl = `/-/user/org.couchdb.user:${encodeURIComponent(credentials.name)}`;

const body: Record<string, unknown> = {
_id: `org.couchdb.user:${credentials.name}`,
name: credentials.name,
password: credentials.password,
type: `user`,
roles: [],
date: new Date().toISOString(),
};

const userOptions = {
attemptedAs: credentials.name,
configuration,
registry,
jsonResponse: true,
authType: npmHttpUtils.AuthType.NO_AUTH,
};

try {
const response = await npmHttpUtils.put(userUrl, body, userOptions) as any;
return response.token;
} catch (error) {
const isConflict = error.originalError?.name === `HTTPError` && error.originalError?.response.statusCode === 409;
if (!isConflict) {
throw error;
}
}

// At this point we did a first request but got a `409 Conflict`. Retrieve
// the latest state and put a new revision.
const revOptions = {
...userOptions,
authType: npmHttpUtils.AuthType.NO_AUTH,
headers: {
authorization: `Basic ${Buffer.from(`${credentials.name}:${credentials.password}`).toString(`base64`)}`,
},
};

const user = await npmHttpUtils.get(userUrl, revOptions);

// Update the request body to include the latest fields (such as `_rev`) and
// the latest `roles` value.
for (const [k, v] of Object.entries(user)) {
if (!body[k] || k === `roles`) {
body[k] = v;
}
}

const revisionUrl = `${userUrl}/-rev/${body._rev}`;
const response = await npmHttpUtils.put(revisionUrl, body, revOptions) as any;

return response.token;
}

async function setAuthToken(registry: string, npmAuthToken: string, {alwaysAuth, scope}: {alwaysAuth?: boolean, scope?: string}) {
const makeUpdater = (entryName: string) => (unknownStore: unknown) => {
const store = miscUtils.isIndexableObject(unknownStore)
Expand Down Expand Up @@ -128,7 +188,12 @@ async function setAuthToken(registry: string, npmAuthToken: string, {alwaysAuth,
return await Configuration.updateHomeConfiguration(update);
}

async function getCredentials({configuration, registry, report, stdin, stdout}: {configuration: Configuration, registry: string, report: Report, stdin: NodeJS.ReadStream, stdout: NodeJS.WriteStream}) {
interface Credentials {
name: string;
password: string;
}

async function getCredentials({configuration, registry, report, stdin, stdout}: {configuration: Configuration, registry: string, report: Report, stdin: NodeJS.ReadStream, stdout: NodeJS.WriteStream}): Promise<Credentials> {
report.reportInfo(MessageName.UNNAMED, `Logging in to ${formatUtils.pretty(configuration, registry, formatUtils.Type.URL)}`);

let isToken = false;
Expand All @@ -147,12 +212,9 @@ async function getCredentials({configuration, registry, report, stdin, stdout}:
};
}

const {username, password} = await prompt<{
username: string;
password: string;
}>([{
const credentials = await prompt<Credentials>([{
type: `input`,
name: `username`,
name: `name`,
message: `Username:`,
required: true,
onCancel: () => process.exit(130),
Expand All @@ -170,8 +232,5 @@ async function getCredentials({configuration, registry, report, stdin, stdout}:

report.reportSeparator();

return {
name: username,
password,
};
return credentials;
}

0 comments on commit db6210f

Please sign in to comment.