Skip to content

Commit db3cd21

Browse files
demurgosmerceyz
authored andcommitted
fix(plugin-npm-cli): fix login with Verdaccio (#5983)
**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. (cherry picked from commit db6210f)
1 parent 07d90c4 commit db3cd21

File tree

2 files changed

+102
-20
lines changed

2 files changed

+102
-20
lines changed

.yarn/versions/89f35c90.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
releases:
2+
"@yarnpkg/cli": patch
3+
"@yarnpkg/plugin-npm-cli": patch
4+
5+
declined:
6+
- "@yarnpkg/plugin-compat"
7+
- "@yarnpkg/plugin-constraints"
8+
- "@yarnpkg/plugin-dlx"
9+
- "@yarnpkg/plugin-essentials"
10+
- "@yarnpkg/plugin-init"
11+
- "@yarnpkg/plugin-interactive-tools"
12+
- "@yarnpkg/plugin-nm"
13+
- "@yarnpkg/plugin-pack"
14+
- "@yarnpkg/plugin-patch"
15+
- "@yarnpkg/plugin-pnp"
16+
- "@yarnpkg/plugin-pnpm"
17+
- "@yarnpkg/plugin-stage"
18+
- "@yarnpkg/plugin-typescript"
19+
- "@yarnpkg/plugin-version"
20+
- "@yarnpkg/plugin-workspace-tools"
21+
- "@yarnpkg/builder"
22+
- "@yarnpkg/core"
23+
- "@yarnpkg/doctor"

packages/plugin-npm-cli/sources/commands/npm/login.ts

Lines changed: 79 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -69,17 +69,9 @@ export default class NpmLoginCommand extends BaseCommand {
6969
stdout: this.context.stdout as NodeJS.WriteStream,
7070
});
7171

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

74-
const response = await npmHttpUtils.put(url, credentials, {
75-
attemptedAs: credentials.name,
76-
configuration,
77-
registry,
78-
jsonResponse: true,
79-
authType: npmHttpUtils.AuthType.NO_AUTH,
80-
}) as any;
81-
82-
await setAuthToken(registry, response.token, {alwaysAuth: this.alwaysAuth, scope: this.scope});
74+
await setAuthToken(registry, token, {alwaysAuth: this.alwaysAuth, scope: this.scope});
8375
return report.reportInfo(MessageName.UNNAMED, `Successfully logged in`);
8476
});
8577

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

95+
/**
96+
* Register a new user, or login if the user already exists
97+
*/
98+
async function registerOrLogin(registry: string, credentials: Credentials, configuration: Configuration): Promise<string> {
99+
// Registration and login are both handled as a `put` by npm. Npm uses a lax
100+
// endpoint as of 2023-11 where there are no conflicts if the user already
101+
// exists, but some registries such as Verdaccio are stricter and return a
102+
// `409 Conflict` status code for existing users. In this case, the client
103+
// should put a user revision for this specific session (with basic HTTP
104+
// auth).
105+
//
106+
// The code below is based on the logic from the npm client.
107+
// <https://github.com/npm/npm-profile/blob/30097a5eef4239399b964c2efc121e64e75ecaf5/lib/index.js#L156>.
108+
const userUrl = `/-/user/org.couchdb.user:${encodeURIComponent(credentials.name)}`;
109+
110+
const body: Record<string, unknown> = {
111+
_id: `org.couchdb.user:${credentials.name}`,
112+
name: credentials.name,
113+
password: credentials.password,
114+
type: `user`,
115+
roles: [],
116+
date: new Date().toISOString(),
117+
};
118+
119+
const userOptions = {
120+
attemptedAs: credentials.name,
121+
configuration,
122+
registry,
123+
jsonResponse: true,
124+
authType: npmHttpUtils.AuthType.NO_AUTH,
125+
};
126+
127+
try {
128+
const response = await npmHttpUtils.put(userUrl, body, userOptions) as any;
129+
return response.token;
130+
} catch (error) {
131+
const isConflict = error.originalError?.name === `HTTPError` && error.originalError?.response.statusCode === 409;
132+
if (!isConflict) {
133+
throw error;
134+
}
135+
}
136+
137+
// At this point we did a first request but got a `409 Conflict`. Retrieve
138+
// the latest state and put a new revision.
139+
const revOptions = {
140+
...userOptions,
141+
authType: npmHttpUtils.AuthType.NO_AUTH,
142+
headers: {
143+
authorization: `Basic ${Buffer.from(`${credentials.name}:${credentials.password}`).toString(`base64`)}`,
144+
},
145+
};
146+
147+
const user = await npmHttpUtils.get(userUrl, revOptions);
148+
149+
// Update the request body to include the latest fields (such as `_rev`) and
150+
// the latest `roles` value.
151+
for (const [k, v] of Object.entries(user)) {
152+
if (!body[k] || k === `roles`) {
153+
body[k] = v;
154+
}
155+
}
156+
157+
const revisionUrl = `${userUrl}/-rev/${body._rev}`;
158+
const response = await npmHttpUtils.put(revisionUrl, body, revOptions) as any;
159+
160+
return response.token;
161+
}
162+
103163
async function setAuthToken(registry: string, npmAuthToken: string, {alwaysAuth, scope}: {alwaysAuth?: boolean, scope?: string}) {
104164
const makeUpdater = (entryName: string) => (unknownStore: unknown) => {
105165
const store = miscUtils.isIndexableObject(unknownStore)
@@ -128,7 +188,12 @@ async function setAuthToken(registry: string, npmAuthToken: string, {alwaysAuth,
128188
return await Configuration.updateHomeConfiguration(update);
129189
}
130190

131-
async function getCredentials({configuration, registry, report, stdin, stdout}: {configuration: Configuration, registry: string, report: Report, stdin: NodeJS.ReadStream, stdout: NodeJS.WriteStream}) {
191+
interface Credentials {
192+
name: string;
193+
password: string;
194+
}
195+
196+
async function getCredentials({configuration, registry, report, stdin, stdout}: {configuration: Configuration, registry: string, report: Report, stdin: NodeJS.ReadStream, stdout: NodeJS.WriteStream}): Promise<Credentials> {
132197
report.reportInfo(MessageName.UNNAMED, `Logging in to ${formatUtils.pretty(configuration, registry, formatUtils.Type.URL)}`);
133198

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

150-
const {username, password} = await prompt<{
151-
username: string;
152-
password: string;
153-
}>([{
215+
const credentials = await prompt<Credentials>([{
154216
type: `input`,
155-
name: `username`,
217+
name: `name`,
156218
message: `Username:`,
157219
required: true,
158220
onCancel: () => process.exit(130),
@@ -170,8 +232,5 @@ async function getCredentials({configuration, registry, report, stdin, stdout}:
170232

171233
report.reportSeparator();
172234

173-
return {
174-
name: username,
175-
password,
176-
};
235+
return credentials;
177236
}

0 commit comments

Comments
 (0)