From f77f75eb51a88790f68a79f5cfcb11a5835d8e64 Mon Sep 17 00:00:00 2001 From: Charles Samborski Date: Sun, 19 Nov 2023 02:15:26 +0100 Subject: [PATCH] fix(plugin-npm-cli): fix login with Verdaccio This commit fixes `yarn npm login` when the remote registry is Verdaccio. 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. - Closes yarnpkg/berry#1044 - Closes yarnpkg/berry#1848 - Closes verdaccio/verdaccio#1737 --- .yarn/versions/89f35c90.yml | 32 ++++++ .../sources/commands/npm/login.ts | 98 +++++++++++++++---- 2 files changed, 110 insertions(+), 20 deletions(-) create mode 100644 .yarn/versions/89f35c90.yml diff --git a/.yarn/versions/89f35c90.yml b/.yarn/versions/89f35c90.yml new file mode 100644 index 000000000000..11300a475dea --- /dev/null +++ b/.yarn/versions/89f35c90.yml @@ -0,0 +1,32 @@ +releases: + "@yarnpkg/builder": patch + "@yarnpkg/cli": patch + "@yarnpkg/core": patch + "@yarnpkg/doctor": patch + "@yarnpkg/extensions": patch + "@yarnpkg/nm": patch + "@yarnpkg/plugin-compat": patch + "@yarnpkg/plugin-constraints": patch + "@yarnpkg/plugin-dlx": patch + "@yarnpkg/plugin-essentials": patch + "@yarnpkg/plugin-exec": patch + "@yarnpkg/plugin-file": patch + "@yarnpkg/plugin-git": patch + "@yarnpkg/plugin-github": patch + "@yarnpkg/plugin-http": patch + "@yarnpkg/plugin-init": patch + "@yarnpkg/plugin-interactive-tools": patch + "@yarnpkg/plugin-link": patch + "@yarnpkg/plugin-nm": patch + "@yarnpkg/plugin-npm": patch + "@yarnpkg/plugin-npm-cli": patch + "@yarnpkg/plugin-pack": patch + "@yarnpkg/plugin-patch": patch + "@yarnpkg/plugin-pnp": patch + "@yarnpkg/plugin-pnpm": patch + "@yarnpkg/plugin-stage": patch + "@yarnpkg/plugin-typescript": patch + "@yarnpkg/plugin-version": patch + "@yarnpkg/plugin-workspace-tools": patch + "@yarnpkg/pnpify": patch + "@yarnpkg/sdks": patch diff --git a/packages/plugin-npm-cli/sources/commands/npm/login.ts b/packages/plugin-npm-cli/sources/commands/npm/login.ts index b8e1b71db3d9..89c3046e9823 100644 --- a/packages/plugin-npm-cli/sources/commands/npm/login.ts +++ b/packages/plugin-npm-cli/sources/commands/npm/login.ts @@ -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`); }); @@ -100,6 +92,73 @@ export async function getRegistry({scope, publish, configuration, cwd}: {scope?: return npmConfigUtils.getDefaultRegistry({configuration}); } +/** + * Create a new, or login if the user already exists + */ +async function registerOrLogin(registry: string, credentials: Credentials, configuration: Configuration): Promise { + // 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, + // 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. + // . + const userUrl = `/-/user/org.couchdb.user:${encodeURIComponent(credentials.name)}`; + + const body: Record = { + _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) @@ -128,7 +187,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 { report.reportInfo(MessageName.UNNAMED, `Logging in to ${formatUtils.pretty(configuration, registry, formatUtils.Type.URL)}`); let isToken = false; @@ -147,12 +211,9 @@ async function getCredentials({configuration, registry, report, stdin, stdout}: }; } - const {username, password} = await prompt<{ - username: string; - password: string; - }>([{ + const credentials = await prompt([{ type: `input`, - name: `username`, + name: `name`, message: `Username:`, required: true, onCancel: () => process.exit(130), @@ -170,8 +231,5 @@ async function getCredentials({configuration, registry, report, stdin, stdout}: report.reportSeparator(); - return { - name: username, - password, - }; + return credentials; }