Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .yarn/versions/e692e68e.yml
Original file line number Diff line number Diff line change
@@ -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"
146 changes: 133 additions & 13 deletions packages/plugin-npm-cli/sources/commands/npm/login.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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);

Expand All @@ -61,16 +65,15 @@ export default class NpmLoginCommand extends BaseCommand {
stdout: this.context.stdout,
includeFooter: false,
}, async report => {
const credentials = await getCredentials({
configuration,
const token = await performAuthentication({
registry,
configuration,
report,
webLogin: this.webLogin,
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`);
});
Expand All @@ -92,10 +95,119 @@ 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<NpmWebLoginInitResponse | null> {
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<NpmWebLoginCheckResponse | null> {
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, configuration, report}: CredentialOptions): Promise<string | null> {
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 your browser now?`,
required: true,
initial: true,
onCancel: () => process.exit(130),
});

report.reportSeparator();

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();
}
}

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

const WEB_LOGIN_REGISTRIES = [
`https://registry.yarnpkg.com`,
`https://registry.npmjs.org`,
];

async function performAuthentication(opts: CredentialOptions & {webLogin?: boolean}): Promise<string> {
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: string, credentials: Credentials, configuration: Configuration): Promise<string> {
async function loginOrRegisterViaPassword({registry, configuration, report, stdin, stdout}: CredentialOptions): Promise<string> {
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
Expand Down Expand Up @@ -193,7 +305,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<Credentials> {
interface CredentialOptions {
configuration: Configuration;
registry: string;
report: Report;
stdin: NodeJS.ReadStream;
stdout: NodeJS.WriteStream;
}

async function getCredentials({configuration, registry, report, stdin, stdout}: CredentialOptions): Promise<Credentials> {
report.reportInfo(MessageName.UNNAMED, `Logging in to ${formatUtils.pretty(configuration, registry, formatUtils.Type.URL)}`);

let isToken = false;
Expand Down