Skip to content

Commit b65d527

Browse files
authored
Implements npm web login support (#6981)
## What's the problem this PR addresses? Npm plans to fully remove the `/-/user/org.couchdb.user:<name>` endpoint. Since we use this endpoint to login, we need to implement support for the newer `/-/v1/login` endpoint. ## How did you fix it? A new option, `--web-login`, enables the web login flow (and fallbacks to the password flow if the web flow isn't supported). This flag is automatically enabled for the npm registry. This is technically a breaking change, but since it's the registry that plans to make that change I think it's ok. ## 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.
1 parent 0b3462a commit b65d527

File tree

2 files changed

+156
-13
lines changed

2 files changed

+156
-13
lines changed

.yarn/versions/e692e68e.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
releases:
2+
"@yarnpkg/cli": minor
3+
"@yarnpkg/plugin-npm-cli": minor
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: 133 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import {BaseCommand, openWorkspace} from '@yarnpkg/cli';
2-
import {Configuration, MessageName, Report, miscUtils, formatUtils} from '@yarnpkg/core';
3-
import {StreamReport} from '@yarnpkg/core';
4-
import {PortablePath} from '@yarnpkg/fslib';
5-
import {npmConfigUtils, npmHttpUtils} from '@yarnpkg/plugin-npm';
6-
import {Command, Option, Usage} from 'clipanion';
7-
import {prompt} from 'enquirer';
1+
import {BaseCommand, openWorkspace} from '@yarnpkg/cli';
2+
import {Configuration, MessageName, Report, miscUtils, formatUtils, nodeUtils, httpUtils} from '@yarnpkg/core';
3+
import {StreamReport} from '@yarnpkg/core';
4+
import {PortablePath} from '@yarnpkg/fslib';
5+
import {npmConfigUtils, npmHttpUtils} from '@yarnpkg/plugin-npm';
6+
import {Command, Option, Usage} from 'clipanion';
7+
import {prompt} from 'enquirer';
88

99
// eslint-disable-next-line arca/no-default-export
1010
export default class NpmLoginCommand extends BaseCommand {
@@ -46,6 +46,10 @@ export default class NpmLoginCommand extends BaseCommand {
4646
description: `Set the npmAlwaysAuth configuration`,
4747
});
4848

49+
webLogin = Option.Boolean(`--web-login`, {
50+
description: `Enable web login`,
51+
});
52+
4953
async execute() {
5054
const configuration = await Configuration.find(this.context.cwd, this.context.plugins);
5155

@@ -61,16 +65,15 @@ export default class NpmLoginCommand extends BaseCommand {
6165
stdout: this.context.stdout,
6266
includeFooter: false,
6367
}, async report => {
64-
const credentials = await getCredentials({
65-
configuration,
68+
const token = await performAuthentication({
6669
registry,
70+
configuration,
6771
report,
72+
webLogin: this.webLogin,
6873
stdin: this.context.stdin as NodeJS.ReadStream,
6974
stdout: this.context.stdout as NodeJS.WriteStream,
7075
});
7176

72-
const token = await registerOrLogin(registry, credentials, configuration);
73-
7477
await setAuthToken(registry, token, {alwaysAuth: this.alwaysAuth, scope: this.scope});
7578
return report.reportInfo(MessageName.UNNAMED, `Successfully logged in`);
7679
});
@@ -92,10 +95,119 @@ export async function getRegistry({scope, publish, configuration, cwd}: {scope?:
9295
return npmConfigUtils.getDefaultRegistry({configuration});
9396
}
9497

98+
type NpmWebLoginInitResponse = {
99+
loginUrl: string;
100+
doneUrl: string;
101+
};
102+
103+
async function webLoginInit(registry: string, configuration: Configuration): Promise<NpmWebLoginInitResponse | null> {
104+
let response: any;
105+
try {
106+
response = await npmHttpUtils.post(`/-/v1/login`, null, {
107+
configuration,
108+
registry,
109+
authType: npmHttpUtils.AuthType.NO_AUTH,
110+
jsonResponse: true,
111+
headers: {
112+
[`npm-auth-type`]: `web`,
113+
},
114+
});
115+
} catch {
116+
return null;
117+
}
118+
119+
return response;
120+
}
121+
122+
type NpmWebLoginCheckResponse =
123+
| {type: `success`, token: string}
124+
| {type: `waiting`, sleep: number};
125+
126+
async function webLoginCheck(doneUrl: string, configuration: Configuration): Promise<NpmWebLoginCheckResponse | null> {
127+
const response = await httpUtils.request(doneUrl, null, {
128+
configuration,
129+
jsonResponse: true,
130+
});
131+
132+
if (response.statusCode === 202) {
133+
const retryAfter = response.headers[`retry-after`] ?? `1`;
134+
return {type: `waiting`, sleep: parseInt(retryAfter, 10)};
135+
}
136+
137+
if (response.statusCode === 200)
138+
return {type: `success`, token: response.body.token};
139+
140+
return null;
141+
}
142+
143+
async function loginViaWeb({registry, configuration, report}: CredentialOptions): Promise<string | null> {
144+
const loginResponse = await webLoginInit(registry, configuration);
145+
if (!loginResponse)
146+
return null;
147+
148+
if (nodeUtils.openUrl) {
149+
report.reportInfo(MessageName.UNNAMED, `Starting the web login process...`);
150+
report.reportSeparator();
151+
152+
const {openNow} = await prompt<{openNow: boolean}>({
153+
type: `confirm`,
154+
name: `openNow`,
155+
message: `Do you want to try to open your browser now?`,
156+
required: true,
157+
initial: true,
158+
onCancel: () => process.exit(130),
159+
});
160+
161+
report.reportSeparator();
162+
163+
if (!openNow || !await nodeUtils.openUrl(loginResponse.loginUrl)) {
164+
report.reportWarning(MessageName.UNNAMED, `We failed to automatically open the url; you'll have to open it yourself in your browser of choice:`);
165+
report.reportWarning(MessageName.UNNAMED, formatUtils.pretty(configuration, loginResponse.loginUrl, formatUtils.Type.URL));
166+
report.reportSeparator();
167+
}
168+
}
169+
170+
while (true) {
171+
const sleepDuration = await webLoginCheck(loginResponse.doneUrl, configuration);
172+
if (sleepDuration === null)
173+
return null;
174+
175+
if (sleepDuration.type === `waiting`) {
176+
await new Promise(resolve => setTimeout(resolve, sleepDuration.sleep * 1000));
177+
} else {
178+
return sleepDuration.token;
179+
}
180+
}
181+
}
182+
183+
const WEB_LOGIN_REGISTRIES = [
184+
`https://registry.yarnpkg.com`,
185+
`https://registry.npmjs.org`,
186+
];
187+
188+
async function performAuthentication(opts: CredentialOptions & {webLogin?: boolean}): Promise<string> {
189+
if (opts.webLogin ?? WEB_LOGIN_REGISTRIES.includes(opts.registry)) {
190+
const webToken = await loginViaWeb(opts);
191+
if (webToken !== null) {
192+
return webToken;
193+
}
194+
}
195+
196+
return await loginOrRegisterViaPassword(opts);
197+
}
198+
95199
/**
96200
* Register a new user, or login if the user already exists
97201
*/
98-
async function registerOrLogin(registry: string, credentials: Credentials, configuration: Configuration): Promise<string> {
202+
async function loginOrRegisterViaPassword({registry, configuration, report, stdin, stdout}: CredentialOptions): Promise<string> {
203+
const credentials = await getCredentials({
204+
configuration,
205+
registry,
206+
report,
207+
stdin,
208+
stdout,
209+
});
210+
99211
// Registration and login are both handled as a `put` by npm. Npm uses a lax
100212
// endpoint as of 2023-11 where there are no conflicts if the user already
101213
// exists, but some registries such as Verdaccio are stricter and return a
@@ -193,7 +305,15 @@ interface Credentials {
193305
password: string;
194306
}
195307

196-
async function getCredentials({configuration, registry, report, stdin, stdout}: {configuration: Configuration, registry: string, report: Report, stdin: NodeJS.ReadStream, stdout: NodeJS.WriteStream}): Promise<Credentials> {
308+
interface CredentialOptions {
309+
configuration: Configuration;
310+
registry: string;
311+
report: Report;
312+
stdin: NodeJS.ReadStream;
313+
stdout: NodeJS.WriteStream;
314+
}
315+
316+
async function getCredentials({configuration, registry, report, stdin, stdout}: CredentialOptions): Promise<Credentials> {
197317
report.reportInfo(MessageName.UNNAMED, `Logging in to ${formatUtils.pretty(configuration, registry, formatUtils.Type.URL)}`);
198318

199319
let isToken = false;

0 commit comments

Comments
 (0)