Skip to content

Commit

Permalink
fix(plugin-npm-cli): fix login with Verdaccio
Browse files Browse the repository at this point in the history
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 #1044
- Closes #1848
- Closes verdaccio/verdaccio#1737
  • Loading branch information
demurgos committed Nov 19, 2023
1 parent 017b94a commit f77f75e
Show file tree
Hide file tree
Showing 2 changed files with 110 additions and 20 deletions.
32 changes: 32 additions & 0 deletions .yarn/versions/89f35c90.yml
Original file line number Diff line number Diff line change
@@ -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
98 changes: 78 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,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<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,
// 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 +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<Credentials> {
report.reportInfo(MessageName.UNNAMED, `Logging in to ${formatUtils.pretty(configuration, registry, formatUtils.Type.URL)}`);

let isToken = false;
Expand All @@ -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<Credentials>([{
type: `input`,
name: `username`,
name: `name`,
message: `Username:`,
required: true,
onCancel: () => process.exit(130),
Expand All @@ -170,8 +231,5 @@ async function getCredentials({configuration, registry, report, stdin, stdout}:

report.reportSeparator();

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

0 comments on commit f77f75e

Please sign in to comment.