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 verdaccio/verdaccio#1737
  • Loading branch information
demurgos committed Nov 19, 2023
1 parent 017b94a commit c9c019d
Show file tree
Hide file tree
Showing 2 changed files with 77 additions and 20 deletions.
2 changes: 2 additions & 0 deletions .yarn/versions/4a08cf09.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
releases:
"@yarnpkg/plugin-npm-cli": patch
95 changes: 75 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,70 @@ 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.ALWAYS_AUTH,
ident: `${credentials.name}:${credentials.password}`};

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

Check failure on line 143 in packages/plugin-npm-cli/sources/commands/npm/login.ts

View workflow job for this annotation

GitHub Actions / Testing chores

Argument of type '{ authType: npmHttpUtils.AuthType; ident: string; attemptedAs: string; configuration: Configuration; registry: string; jsonResponse: boolean; }' is not assignable to parameter of type 'Options'.

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

Check failure on line 154 in packages/plugin-npm-cli/sources/commands/npm/login.ts

View workflow job for this annotation

GitHub Actions / Testing chores

Argument of type '{ authType: npmHttpUtils.AuthType; ident: string; attemptedAs: string; configuration: Configuration; registry: string; jsonResponse: boolean; }' is not assignable to parameter of type 'Options & { attemptedAs?: string | undefined; }'.

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 +184,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 +208,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 +228,5 @@ async function getCredentials({configuration, registry, report, stdin, stdout}:

report.reportSeparator();

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

0 comments on commit c9c019d

Please sign in to comment.