From 0f1b31d45d87b901c3773c027f5ae4d577a97e5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Nison?= Date: Tue, 18 Nov 2025 21:04:57 +0100 Subject: [PATCH 1/3] Implements npm web login support --- .../sources/commands/npm/login.ts | 126 ++++++++++++++++-- 1 file changed, 113 insertions(+), 13 deletions(-) diff --git a/packages/plugin-npm-cli/sources/commands/npm/login.ts b/packages/plugin-npm-cli/sources/commands/npm/login.ts index b999b23bea70..562593b34f48 100644 --- a/packages/plugin-npm-cli/sources/commands/npm/login.ts +++ b/packages/plugin-npm-cli/sources/commands/npm/login.ts @@ -1,10 +1,10 @@ -import {BaseCommand, openWorkspace} from '@yarnpkg/cli'; -import {Configuration, MessageName, Report, miscUtils, formatUtils} from '@yarnpkg/core'; -import {StreamReport} from '@yarnpkg/core'; -import {PortablePath} from '@yarnpkg/fslib'; -import {npmConfigUtils, npmHttpUtils} from '@yarnpkg/plugin-npm'; -import {Command, Option, Usage} from 'clipanion'; -import {prompt} from 'enquirer'; +import {BaseCommand, openWorkspace} from '@yarnpkg/cli'; +import {Configuration, MessageName, Report, miscUtils, formatUtils, nodeUtils, httpUtils} from '@yarnpkg/core'; +import {StreamReport} from '@yarnpkg/core'; +import {PortablePath} from '@yarnpkg/fslib'; +import {npmConfigUtils, npmHttpUtils} from '@yarnpkg/plugin-npm'; +import {Command, Option, Usage} from 'clipanion'; +import {prompt} from 'enquirer'; // eslint-disable-next-line arca/no-default-export export default class NpmLoginCommand extends BaseCommand { @@ -61,16 +61,14 @@ export default class NpmLoginCommand extends BaseCommand { stdout: this.context.stdout, includeFooter: false, }, async report => { - const credentials = await getCredentials({ - configuration, + const token = await registerOrLogin({ registry, + configuration, report, stdin: this.context.stdin as NodeJS.ReadStream, stdout: this.context.stdout as NodeJS.WriteStream, }); - const token = await registerOrLogin(registry, credentials, configuration); - await setAuthToken(registry, token, {alwaysAuth: this.alwaysAuth, scope: this.scope}); return report.reportInfo(MessageName.UNNAMED, `Successfully logged in`); }); @@ -92,10 +90,104 @@ export async function getRegistry({scope, publish, configuration, cwd}: {scope?: return npmConfigUtils.getDefaultRegistry({configuration}); } +type NpmWebLoginInitResponse = { + loginUrl: string; + doneUrl: string; +}; + +async function webLoginInit(registry: string, configuration: Configuration): Promise { + let response: any; + try { + response = await npmHttpUtils.post(`/-/v1/login`, null, { + configuration, + registry, + authType: npmHttpUtils.AuthType.NO_AUTH, + jsonResponse: true, + headers: { + [`npm-auth-type`]: `web`, + }, + }); + } catch { + return null; + } + + return response; +} + +type NpmWebLoginCheckResponse = + | {type: `success`, token: string} + | {type: `waiting`, sleep: number}; + +async function webLoginCheck(doneUrl: string, configuration: Configuration): Promise { + const response = await httpUtils.request(doneUrl, null, { + configuration, + jsonResponse: true, + }); + + if (response.statusCode === 202) { + const retryAfter = response.headers[`retry-after`] ?? `1`; + return {type: `waiting`, sleep: parseInt(retryAfter, 10)}; + } + + if (response.statusCode === 200) + return {type: `success`, token: response.body.token}; + + return null; +} + +async function loginViaWeb(registry: string, configuration: Configuration, report: Report): Promise { + const loginResponse = await webLoginInit(registry, configuration); + if (!loginResponse) + return null; + + if (nodeUtils.openUrl) { + const {openNow} = await prompt<{openNow: boolean}>({ + type: `confirm`, + name: `openNow`, + message: `Do you want to try to open this url now?`, + required: true, + initial: true, + onCancel: () => process.exit(130), + }); + + if (openNow) { + report.reportSeparator(); + + if (!await nodeUtils.openUrl(loginResponse.loginUrl)) { + report.reportWarning(MessageName.UNNAMED, `We failed to automatically open the url; you'll have to open it yourself in your browser of choice.`); + } + } + } + + while (true) { + const sleepDuration = await webLoginCheck(loginResponse.doneUrl, configuration); + if (sleepDuration === null) + return null; + + if (sleepDuration.type === `waiting`) { + await new Promise(resolve => setTimeout(resolve, sleepDuration.sleep * 1000)); + } else { + return sleepDuration.token; + } + } +} + /** * Register a new user, or login if the user already exists */ -async function registerOrLogin(registry: string, credentials: Credentials, configuration: Configuration): Promise { +async function registerOrLogin({registry, configuration, report, stdin, stdout}: CredentialOptions): Promise { + const webToken = await loginViaWeb(registry, configuration, report); + if (webToken !== null) + return webToken; + + const credentials = await getCredentials({ + configuration, + registry, + report, + stdin, + stdout, + }); + // 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 // exists, but some registries such as Verdaccio are stricter and return a @@ -193,7 +285,15 @@ interface Credentials { password: string; } -async function getCredentials({configuration, registry, report, stdin, stdout}: {configuration: Configuration, registry: string, report: Report, stdin: NodeJS.ReadStream, stdout: NodeJS.WriteStream}): Promise { +interface CredentialOptions { + configuration: Configuration; + registry: string; + report: Report; + stdin: NodeJS.ReadStream; + stdout: NodeJS.WriteStream; +} + +async function getCredentials({configuration, registry, report, stdin, stdout}: CredentialOptions): Promise { report.reportInfo(MessageName.UNNAMED, `Logging in to ${formatUtils.pretty(configuration, registry, formatUtils.Type.URL)}`); let isToken = false; From 3a6d359254de29e759c69c4d90caf050bef83ab8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Nison?= Date: Tue, 18 Nov 2025 21:23:12 +0100 Subject: [PATCH 2/3] Tweaks the option --- .../sources/commands/npm/login.ts | 46 +++++++++++++------ 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/packages/plugin-npm-cli/sources/commands/npm/login.ts b/packages/plugin-npm-cli/sources/commands/npm/login.ts index 562593b34f48..449586991f5c 100644 --- a/packages/plugin-npm-cli/sources/commands/npm/login.ts +++ b/packages/plugin-npm-cli/sources/commands/npm/login.ts @@ -46,6 +46,10 @@ export default class NpmLoginCommand extends BaseCommand { description: `Set the npmAlwaysAuth configuration`, }); + webLogin = Option.Boolean(`--web-login`, { + description: `Enable web login`, + }); + async execute() { const configuration = await Configuration.find(this.context.cwd, this.context.plugins); @@ -61,10 +65,11 @@ export default class NpmLoginCommand extends BaseCommand { stdout: this.context.stdout, includeFooter: false, }, async report => { - const token = await registerOrLogin({ + const token = await performAuthentication({ registry, configuration, report, + webLogin: this.webLogin, stdin: this.context.stdin as NodeJS.ReadStream, stdout: this.context.stdout as NodeJS.WriteStream, }); @@ -135,27 +140,30 @@ async function webLoginCheck(doneUrl: string, configuration: Configuration): Pro return null; } -async function loginViaWeb(registry: string, configuration: Configuration, report: Report): Promise { +async function loginViaWeb({registry, configuration, report}: CredentialOptions): Promise { const loginResponse = await webLoginInit(registry, configuration); if (!loginResponse) return null; if (nodeUtils.openUrl) { + report.reportInfo(MessageName.UNNAMED, `Starting the web login process...`); + report.reportSeparator(); + const {openNow} = await prompt<{openNow: boolean}>({ type: `confirm`, name: `openNow`, - message: `Do you want to try to open this url now?`, + message: `Do you want to try to open your browser now?`, required: true, initial: true, onCancel: () => process.exit(130), }); - if (openNow) { - report.reportSeparator(); + report.reportSeparator(); - if (!await nodeUtils.openUrl(loginResponse.loginUrl)) { - report.reportWarning(MessageName.UNNAMED, `We failed to automatically open the url; you'll have to open it yourself in your browser of choice.`); - } + if (!openNow || !await nodeUtils.openUrl(loginResponse.loginUrl)) { + report.reportWarning(MessageName.UNNAMED, `We failed to automatically open the url; you'll have to open it yourself in your browser of choice:`); + report.reportWarning(MessageName.UNNAMED, formatUtils.pretty(configuration, loginResponse.loginUrl, formatUtils.Type.URL)); + report.reportSeparator(); } } @@ -172,14 +180,26 @@ async function loginViaWeb(registry: string, configuration: Configuration, repor } } +const WEB_LOGIN_REGISTRIES = [ + `https://registry.yarnpkg.com`, + `https://registry.npmjs.org`, +]; + +async function performAuthentication(opts: CredentialOptions & {webLogin?: boolean}): Promise { + if (opts.webLogin ?? WEB_LOGIN_REGISTRIES.includes(opts.registry)) { + const webToken = await loginViaWeb(opts); + if (webToken !== null) { + return webToken; + } + } + + return await loginOrRegisterViaPassword(opts); +} + /** * Register a new user, or login if the user already exists */ -async function registerOrLogin({registry, configuration, report, stdin, stdout}: CredentialOptions): Promise { - const webToken = await loginViaWeb(registry, configuration, report); - if (webToken !== null) - return webToken; - +async function loginOrRegisterViaPassword({registry, configuration, report, stdin, stdout}: CredentialOptions): Promise { const credentials = await getCredentials({ configuration, registry, From 7523becca5ee8a5a7e2fb6830669071b269a4d16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Nison?= Date: Wed, 19 Nov 2025 00:41:00 +0100 Subject: [PATCH 3/3] Versions --- .yarn/versions/e692e68e.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .yarn/versions/e692e68e.yml diff --git a/.yarn/versions/e692e68e.yml b/.yarn/versions/e692e68e.yml new file mode 100644 index 000000000000..9304d6695851 --- /dev/null +++ b/.yarn/versions/e692e68e.yml @@ -0,0 +1,23 @@ +releases: + "@yarnpkg/cli": minor + "@yarnpkg/plugin-npm-cli": minor + +declined: + - "@yarnpkg/plugin-compat" + - "@yarnpkg/plugin-constraints" + - "@yarnpkg/plugin-dlx" + - "@yarnpkg/plugin-essentials" + - "@yarnpkg/plugin-init" + - "@yarnpkg/plugin-interactive-tools" + - "@yarnpkg/plugin-nm" + - "@yarnpkg/plugin-pack" + - "@yarnpkg/plugin-patch" + - "@yarnpkg/plugin-pnp" + - "@yarnpkg/plugin-pnpm" + - "@yarnpkg/plugin-stage" + - "@yarnpkg/plugin-typescript" + - "@yarnpkg/plugin-version" + - "@yarnpkg/plugin-workspace-tools" + - "@yarnpkg/builder" + - "@yarnpkg/core" + - "@yarnpkg/doctor"