diff --git a/.changeset/extract-otp-handling.md b/.changeset/extract-otp-handling.md new file mode 100644 index 0000000000..b34b0f6135 --- /dev/null +++ b/.changeset/extract-otp-handling.md @@ -0,0 +1,6 @@ +--- +"@pnpm/network.web-auth": major +"@pnpm/releasing.commands": patch +--- + +Create `@pnpm/network.web-auth`. diff --git a/.changeset/implement-pnpm-login.md b/.changeset/implement-pnpm-login.md new file mode 100644 index 0000000000..a034f28387 --- /dev/null +++ b/.changeset/implement-pnpm-login.md @@ -0,0 +1,6 @@ +--- +"@pnpm/auth.commands": minor +"pnpm": minor +--- + +Added `pnpm login` command for authenticating with npm registries. Supports web-based login (with QR code) and classic username/password login as a fallback. The `adduser` command is aliased to `login`. diff --git a/.gitignore b/.gitignore index 272f8beeea..a786c8cff3 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ _docpress lib dist tsconfig.tsbuildinfo +tsconfig.lint.tsbuildinfo test.lib/ # Visual Studio Code configs diff --git a/auth/commands/package.json b/auth/commands/package.json new file mode 100644 index 0000000000..f69f6c23cd --- /dev/null +++ b/auth/commands/package.json @@ -0,0 +1,60 @@ +{ + "name": "@pnpm/auth.commands", + "version": "1000.0.0", + "description": "Commands for authentication with npm registries", + "keywords": [ + "pnpm", + "pnpm11", + "auth" + ], + "license": "MIT", + "funding": "https://opencollective.com/pnpm", + "repository": "https://github.com/pnpm/pnpm/tree/main/auth/commands", + "homepage": "https://github.com/pnpm/pnpm/tree/main/auth/commands#readme", + "bugs": { + "url": "https://github.com/pnpm/pnpm/issues" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": "./lib/index.js" + }, + "files": [ + "lib", + "!*.map" + ], + "scripts": { + "lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"", + "test": "pnpm run compile && pnpm run .test", + "prepublishOnly": "pnpm run compile", + "compile": "tsgo --build && pnpm run lint --fix", + ".test": "cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169\" jest" + }, + "dependencies": { + "@pnpm/cli.utils": "workspace:*", + "@pnpm/config.reader": "workspace:*", + "@pnpm/error": "workspace:*", + "@pnpm/network.fetch": "workspace:*", + "@pnpm/network.web-auth": "workspace:*", + "enquirer": "catalog:", + "normalize-registry-url": "catalog:", + "read-ini-file": "catalog:", + "render-help": "catalog:", + "write-ini-file": "catalog:" + }, + "peerDependencies": { + "@pnpm/logger": "catalog:" + }, + "devDependencies": { + "@jest/globals": "catalog:", + "@pnpm/auth.commands": "workspace:*", + "@pnpm/logger": "workspace:*" + }, + "engines": { + "node": ">=22.13" + }, + "jest": { + "preset": "@pnpm/jest-config" + } +} diff --git a/auth/commands/src/index.ts b/auth/commands/src/index.ts new file mode 100644 index 0000000000..aeaa1e520d --- /dev/null +++ b/auth/commands/src/index.ts @@ -0,0 +1,3 @@ +import * as login from './login.js' + +export { login } diff --git a/auth/commands/src/login.ts b/auth/commands/src/login.ts new file mode 100644 index 0000000000..634eab0320 --- /dev/null +++ b/auth/commands/src/login.ts @@ -0,0 +1,401 @@ +import path from 'node:path' +import util from 'node:util' + +import { docsUrl } from '@pnpm/cli.utils' +import { type Config, types as allTypes } from '@pnpm/config.reader' +import { PnpmError } from '@pnpm/error' +import { globalInfo, globalWarn } from '@pnpm/logger' +import { fetch } from '@pnpm/network.fetch' +import { + generateQrCode, + pollForWebAuthToken, + SyntheticOtpError, + type WebAuthFetchOptions, + withOtpHandling, +} from '@pnpm/network.web-auth' +import enquirer from 'enquirer' +import normalizeRegistryUrl from 'normalize-registry-url' +import { readIniFile } from 'read-ini-file' +import { renderHelp } from 'render-help' +import { writeIniFile } from 'write-ini-file' + +export function rcOptionsTypes (): Record { + return { registry: allTypes.registry } +} + +export function cliOptionsTypes (): Record { + return { + ...rcOptionsTypes(), + } +} + +export const commandNames = ['login', 'adduser'] + +export function help (): string { + return renderHelp({ + description: 'Log in to an npm registry.', + descriptionLists: [ + { + title: 'Options', + list: [ + { + description: 'The registry to log in to', + name: '--registry ', + }, + ], + }, + ], + url: docsUrl('login'), + usages: ['pnpm login [--registry ]'], + }) +} + +export type LoginCommandOptions = Pick & { + registry?: string +} + +export async function handler ( + opts: LoginCommandOptions +): Promise { + return login({ opts }) +} + +export interface LoginDate { + now: () => number +} + +export interface LoginEnquirer { + prompt: (options: LoginEnquirerOptions) => Promise> +} + +export interface LoginEnquirerOptions { + message: string + name: string + type: string +} + +export interface LoginFetchResponse { + ok: boolean + status: number + json: () => Promise + text: () => Promise + headers: LoginFetchResponseHeaders +} + +export interface LoginFetchResponseHeaders { + get: (name: string) => string | null +} + +export interface LoginFetchOptions { + method?: string + headers?: { + accept: 'application/json' + 'content-type': 'application/json' + + // Q: Why does pnpm send this header unconditionally? + // A: This header doesn't say "I prefer web-based authentication"; + // it only says "I am capable of web-based authentication". + // The npm CLI does the same: + // + 'npm-auth-type': 'web' + + 'npm-otp'?: string + } + body?: string + retry?: { + factor?: number + maxTimeout?: number + minTimeout?: number + randomize?: boolean + retries?: number + } + timeout?: number +} + +export interface LoginContext { + Date: LoginDate + setTimeout: (cb: () => void, ms: number) => void + enquirer: LoginEnquirer + fetch: (url: string, options?: LoginFetchOptions) => Promise + globalInfo: (message: string) => void + globalWarn: (message: string) => void + process: Record<'stdin' | 'stdout', { isTTY?: boolean }> + readIniFile: (configPath: string) => Promise + writeIniFile: (configPath: string, settings: Record) => Promise +} + +export const DEFAULT_CONTEXT: LoginContext = { + Date, + setTimeout, + enquirer, + fetch, + globalInfo, + globalWarn, + process, + readIniFile, + writeIniFile, +} + +export interface LoginParams { + context?: LoginContext + opts: LoginCommandOptions +} + +export async function login ({ context = DEFAULT_CONTEXT, opts }: LoginParams): Promise { + const { + process, + readIniFile, + writeIniFile, + } = context + + const registry = normalizeRegistryUrl(opts.registry ?? 'https://registry.npmjs.org/') + + if (!process.stdin.isTTY || !process.stdout.isTTY) { + throw new LoginNonInteractiveError() + } + + const fetchOptions: WebAuthFetchOptions = { + method: 'GET', + retry: { + factor: opts.fetchRetryFactor, + maxTimeout: opts.fetchRetryMaxtimeout, + minTimeout: opts.fetchRetryMintimeout, + retries: opts.fetchRetries, + }, + timeout: opts.fetchTimeout, + } + + // Try web-based login first, fall back to classic login + let token: string + try { + token = await webLogin({ context, fetchOptions, registry }) + } catch (err) { + if (err instanceof WebLoginError && (err.httpStatus === 404 || err.httpStatus === 405)) { + token = await classicLogin({ context, fetchOptions, registry }) + } else { + throw err + } + } + + const configPath = path.join(opts.configDir, 'rc') + const settings = await safeReadIniFile(readIniFile, configPath) as Record + const registryConfigKey = getRegistryConfigKey(registry) + settings[`${registryConfigKey}:_authToken`] = token + await writeIniFile(configPath, settings) + + return `Logged in on ${registry}` +} + +interface WebLoginParams { + context: Pick + fetchOptions: WebAuthFetchOptions + registry: string +} + +async function webLogin ({ + context, + fetchOptions, + registry, +}: WebLoginParams): Promise { + const { + fetch, + globalInfo, + } = context + + const loginUrl = new URL('-/v1/login', registry).href + + const response = await fetch(loginUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json', + 'npm-auth-type': 'web', + }, + body: JSON.stringify({}), + }) + + if (!response.ok) { + const text = await response.text() + throw new WebLoginError(response.status, text) + } + + const body = await response.json() as { loginUrl?: string, doneUrl?: string } + + if (!body.loginUrl || !body.doneUrl) { + throw new LoginInvalidResponseError() + } + + const qrCode = generateQrCode(body.loginUrl) + globalInfo(`Authenticate your account at:\n${body.loginUrl}\n\n${qrCode}`) + + return pollForWebAuthToken({ context, doneUrl: body.doneUrl, fetchOptions }) +} + +interface ClassicLoginParams { + context: Pick + fetchOptions: WebAuthFetchOptions + registry: string +} + +async function classicLogin ({ + context, + fetchOptions, + registry, +}: ClassicLoginParams): Promise { + const { enquirer, fetch, globalInfo, globalWarn } = context + + const { username } = await enquirer.prompt({ + message: 'Username:', + name: 'username', + type: 'input', + }) + const { password } = await enquirer.prompt({ + message: 'Password:', + name: 'password', + type: 'password', + }) + const { email } = await enquirer.prompt({ + message: 'Email (this IS public):', + name: 'email', + type: 'input', + }) + + if (!username || !password || !email) { + throw new LoginMissingCredentialsError() + } + + const loginUrl = new URL(`-/user/org.couchdb.user:${encodeURIComponent(username)}`, registry).href + + const token = await withOtpHandling({ + context, + fetchOptions, + operation: async (otp?: string) => { + const response = await fetch(loginUrl, { + method: 'PUT', + headers: { + 'content-type': 'application/json', + accept: 'application/json', + 'npm-auth-type': 'web', + // Conditionally include npm-otp: some HTTP implementations coerce + // `undefined` to the string "undefined", which would send a bad header + // on the initial attempt (before OTP is known). + ...(otp != null ? { 'npm-otp': otp } : {}), + }, + body: JSON.stringify({ + _id: `org.couchdb.user:${username}`, + name: username, + password, + email, + type: 'user', + }), + }) + + if (!response.ok) { + await throwIfOtpRequired(globalWarn, response) + const text = await response.text() + throw new ClassicLoginError(response.status, text) + } + + const body = await response.json() as { token?: string } + + if (!body.token) { + throw new LoginNoTokenError() + } + + return body.token + }, + }) + + globalInfo(`Logged in as ${username}`) + + return token +} + +/** + * Inspects a non-ok HTTP response for OTP requirements and throws an EOTP + * error when detected. This mirrors the behaviour of npm-registry-fetch, + * which checks the `www-authenticate` header for one-time password indicators. + */ +async function throwIfOtpRequired (globalWarn: LoginContext['globalWarn'], response: LoginFetchResponse): Promise { + if (response.status !== 401) return + + const wwwAuth = response.headers.get('www-authenticate') + if (!wwwAuth?.includes('otp')) return + + let body: unknown + try { + body = await response.json() + } catch {} + + throw SyntheticOtpError.fromUnknownBody(globalWarn, body) +} + +function getRegistryConfigKey (registryUrl: string): string { + const url = new URL(registryUrl) + return `//${url.host}${url.pathname}` +} + +async function safeReadIniFile ( + readIniFile: LoginContext['readIniFile'], + configPath: string +): Promise { + try { + return await readIniFile(configPath) + } catch (err: unknown) { + if (util.types.isNativeError(err) && 'code' in err && err.code === 'ENOENT') return {} + throw err + } +} + +class LoginNonInteractiveError extends PnpmError { + constructor () { + super('LOGIN_NON_INTERACTIVE', 'The login command requires an interactive terminal') + } +} + +class LoginInvalidResponseError extends PnpmError { + constructor () { + super('LOGIN_INVALID_RESPONSE', 'The registry returned an invalid response for web-based login') + } +} + +class LoginMissingCredentialsError extends PnpmError { + constructor () { + super('LOGIN_MISSING_CREDENTIALS', 'Username, password, and email are all required') + } +} + +class LoginNoTokenError extends PnpmError { + constructor () { + super('LOGIN_NO_TOKEN', 'The registry did not return an authentication token') + } +} + +class ClassicLoginError extends PnpmError { + readonly httpStatus: number + readonly responseText: string + constructor (httpStatus: number, responseText: string) { + super('LOGIN_FAILED', `Login failed (HTTP ${httpStatus}): ${responseText}`) + this.httpStatus = httpStatus + this.responseText = responseText + } +} + +class WebLoginError extends PnpmError { + readonly httpStatus: number + readonly responseText: string + constructor (httpStatus: number, responseText: string) { + super('WEB_LOGIN_FAILED', `Web-based login failed (HTTP ${httpStatus}): ${responseText}`) + this.httpStatus = httpStatus + this.responseText = responseText + } +} diff --git a/auth/commands/test/login.test.ts b/auth/commands/test/login.test.ts new file mode 100644 index 0000000000..bb6de3ccc6 --- /dev/null +++ b/auth/commands/test/login.test.ts @@ -0,0 +1,564 @@ +import path from 'node:path' + +import { jest } from '@jest/globals' + +import { login, type LoginContext, type LoginFetchResponse } from '../src/login.js' + +const TEST_CONTEXT: LoginContext = { + Date: { now: () => 0 }, + setTimeout: cb => { + cb() + }, + enquirer: { prompt: async () => { + throw new Error('Unexpected call to enquirer.prompt') + } }, + fetch: async url => { + throw new Error(`Unexpected call to fetch: ${url}`) + }, + globalInfo: message => { + throw new Error(`Unexpected call to globalInfo: ${message}`) + }, + globalWarn: message => { + throw new Error(`Unexpected call to globalWarn: ${message}`) + }, + process: { + stdin: { isTTY: true }, + stdout: { isTTY: true }, + }, + readIniFile: async path => { + throw new Error(`Unexpected call to readIniFile: ${path}`) + }, + writeIniFile: async path => { + throw new Error(`Unexpected call to writeIniFile: ${path}`) + }, +} + +const createMockResponse = (init: { + ok: boolean + status: number + json?: unknown + text?: string + headers?: LoginFetchResponse['headers'] +}): LoginFetchResponse => { + let bodyConsumed = false + return { + ok: init.ok, + status: init.status, + json: async () => { + if (bodyConsumed) throw new Error('Unexpected double consumption of response body') + bodyConsumed = true + return init.json ?? {} + }, + text: async () => { + if (bodyConsumed) throw new Error('Unexpected double consumption of response body') + bodyConsumed = true + return init.text ?? '' + }, + headers: init.headers ?? { + get: name => { + throw new Error(`Unexpected call to headers.get: ${name}`) + }, + }, + } +} + +const createMockContext = (overrides?: Partial): LoginContext => ({ + ...TEST_CONTEXT, + ...overrides, +}) + +describe('login', () => { + it('should throw in non-interactive terminal', async () => { + const context = createMockContext({ + process: { + stdin: { isTTY: false }, + stdout: { isTTY: true }, + }, + }) + const opts = { configDir: '/mock/config', dir: '/mock', rawConfig: {} } + const promise = login({ context, opts }) + await expect(promise).rejects.toHaveProperty(['code'], 'ERR_PNPM_LOGIN_NON_INTERACTIVE') + await expect(promise).rejects.toHaveProperty(['message'], 'The login command requires an interactive terminal') + }) + + it('should use web login when registry supports it', async () => { + const fetchedUrls: string[] = [] + const globalInfo = jest.fn() + let savedPath = '' + let savedSettings: Record = {} + const context = createMockContext({ + globalInfo, + readIniFile: async () => ({}), + writeIniFile: async (configPath, settings) => { + savedPath = configPath + savedSettings = settings + }, + fetch: async url => { + fetchedUrls.push(url) + if (url === 'https://example.com/npm/-/v1/login') { + return createMockResponse({ + ok: true, + status: 200, + json: { + loginUrl: 'https://example.com/auth/login', + doneUrl: 'https://example.com/auth/done', + }, + }) + } + if (url === 'https://example.com/auth/done') { + return createMockResponse({ + ok: true, + status: 200, + json: { token: 'web-auth-token-123' }, + }) + } + throw new Error(`Unexpected call to fetch: ${url}`) + }, + }) + const opts = { configDir: '/custom/config', dir: '/mock', rawConfig: {}, registry: 'https://example.com/npm/' } + const result = await login({ context, opts }) + expect(result).toBe('Logged in on https://example.com/npm/') + expect(fetchedUrls[0]).toBe('https://example.com/npm/-/v1/login') + expect(savedPath).toBe(path.join('/custom/config', 'rc')) + expect(savedSettings).toMatchObject({ + '//example.com/npm/:_authToken': 'web-auth-token-123', + }) + expect(globalInfo.mock.calls).toEqual([[expect.stringContaining('https://example.com/auth/login')]]) + }) + + it('should fall back to classic login when web login returns 404', async () => { + const fetchedUrls: string[] = [] + const globalInfo = jest.fn() + let savedPath = '' + let savedSettings: Record = {} + const context = createMockContext({ + globalInfo, + readIniFile: async () => ({}), + writeIniFile: async (configPath, settings) => { + savedPath = configPath + savedSettings = settings + }, + fetch: async url => { + fetchedUrls.push(url) + if (url === 'https://example.org/-/v1/login') { + return createMockResponse({ + ok: false, + status: 404, + text: 'Not Found', + }) + } + if (url === 'https://example.org/-/user/org.couchdb.user:john') { + return createMockResponse({ + ok: true, + status: 201, + json: { ok: true, token: 'classic-token-456' }, + }) + } + throw new Error(`Unexpected call to fetch: ${url}`) + }, + enquirer: { + prompt: async (opts: { message: string, name: string, type: string }): Promise> => { + if (opts.name === 'username') return { username: 'john' } + if (opts.name === 'password') return { password: 'secret' } + if (opts.name === 'email') return { email: 'john@example.com' } + throw new Error(`Unexpected call to enquirer.prompt: ${opts.name}`) + }, + }, + }) + const opts = { configDir: '/other/config', dir: '/mock', rawConfig: {}, registry: 'https://example.org' } + const result = await login({ context, opts }) + expect(result).toBe('Logged in on https://example.org/') + expect(fetchedUrls[0]).toBe('https://example.org/-/v1/login') + expect(fetchedUrls[1]).toBe('https://example.org/-/user/org.couchdb.user:john') + expect(savedPath).toBe(path.join('/other/config', 'rc')) + expect(savedSettings).toMatchObject({ + '//example.org/:_authToken': 'classic-token-456', + }) + expect(globalInfo.mock.calls).toEqual([['Logged in as john']]) + }) + + it('should handle classic OTP challenge during login', async () => { + let putCallCount = 0 + const globalInfo = jest.fn() + const context = createMockContext({ + globalInfo, + readIniFile: async () => ({}), + writeIniFile: async () => {}, + fetch: async (url, options) => { + if (url === 'https://example.org/-/v1/login') { + return createMockResponse({ + ok: false, + status: 404, + text: 'Not Found', + }) + } + if (url === 'https://example.org/-/user/org.couchdb.user:alice') { + putCallCount++ + if (putCallCount === 1) { + return createMockResponse({ + ok: false, + status: 401, + json: { error: 'otp required' }, + text: 'OTP required', + headers: { get: (name: string) => name === 'www-authenticate' ? 'OTP otp' : null }, + }) + } + expect(options?.headers?.['npm-otp']).toBe('999999') + return createMockResponse({ + ok: true, + status: 201, + json: { ok: true, token: 'otp-token-789' }, + }) + } + throw new Error(`Unexpected call to fetch: ${url}`) + }, + enquirer: { + prompt: async (opts: { message: string, name: string, type: string }): Promise> => { + if (opts.name === 'username') return { username: 'alice' } + if (opts.name === 'password') return { password: 'pass' } + if (opts.name === 'email') return { email: 'alice@example.com' } + if (opts.name === 'otp') return { otp: '999999' } + throw new Error(`Unexpected call to enquirer.prompt: ${opts.name}`) + }, + }, + }) + const opts = { configDir: '/otp/config', dir: '/mock', rawConfig: {}, registry: 'https://example.org' } + const result = await login({ context, opts }) + expect(result).toBe('Logged in on https://example.org/') + expect(putCallCount).toBe(2) + expect(globalInfo.mock.calls).toEqual([['Logged in as alice']]) + }) + + it('should handle webauth OTP challenge during login', async () => { + let putCallCount = 0 + let pollCallCount = 0 + const globalInfo = jest.fn() + const context = createMockContext({ + globalInfo, + readIniFile: async () => ({}), + writeIniFile: async () => {}, + fetch: async (url, options) => { + if (url === 'https://example.org/-/v1/login') { + return createMockResponse({ + ok: false, + status: 404, + text: 'Not Found', + }) + } + if (url === 'https://example.org/-/user/org.couchdb.user:bob') { + putCallCount++ + if (putCallCount === 1) { + return createMockResponse({ + ok: false, + status: 401, + json: { + authUrl: 'https://example.org/auth/web', + doneUrl: 'https://example.org/auth/web/done', + }, + headers: { get: (name: string) => name === 'www-authenticate' ? 'OTP otp' : null }, + }) + } + expect(options?.headers?.['npm-otp']).toBe('web-tok') + return createMockResponse({ + ok: true, + status: 201, + json: { ok: true, token: 'final-token' }, + }) + } + if (url === 'https://example.org/auth/web/done') { + pollCallCount++ + return createMockResponse({ + ok: true, + status: 200, + json: { token: 'web-tok' }, + }) + } + throw new Error(`Unexpected call to fetch: ${url}`) + }, + enquirer: { + prompt: async (opts: { message: string, name: string, type: string }): Promise> => { + if (opts.name === 'username') return { username: 'bob' } + if (opts.name === 'password') return { password: 'pass' } + if (opts.name === 'email') return { email: 'bob@example.com' } + throw new Error(`Unexpected call to enquirer.prompt: ${opts.name}`) + }, + }, + }) + const opts = { configDir: '/otp/config', dir: '/mock', rawConfig: {}, registry: 'https://example.org' } + const result = await login({ context, opts }) + expect(result).toBe('Logged in on https://example.org/') + expect(putCallCount).toBe(2) + expect(pollCallCount).toBe(1) + expect(globalInfo.mock.calls).toContainEqual([expect.stringMatching(/(?:^|\s)https:\/\/example\.org\/auth\/web(?:\s|$)/)]) + }) + + it('should not trigger OTP for non-401 errors', async () => { + const context = createMockContext({ + readIniFile: async () => ({}), + writeIniFile: async () => {}, + fetch: async url => { + if (url === 'https://example.org/-/v1/login') { + return createMockResponse({ + ok: false, + status: 404, + text: 'Not Found', + }) + } + // Return 403 (not 401) — should not trigger OTP + return createMockResponse({ + ok: false, + status: 403, + text: 'Forbidden', + }) + }, + enquirer: { + prompt: async (opts: { message: string, name: string, type: string }): Promise> => { + if (opts.name === 'username') return { username: 'alice' } + if (opts.name === 'password') return { password: 'pass' } + if (opts.name === 'email') return { email: 'alice@example.com' } + throw new Error(`Unexpected call to enquirer.prompt: ${opts.name}`) + }, + }, + }) + const opts = { configDir: '/otp/config', dir: '/mock', rawConfig: {}, registry: 'https://example.org' } + const promise = login({ context, opts }) + await expect(promise).rejects.toHaveProperty(['code'], 'ERR_PNPM_LOGIN_FAILED') + await expect(promise).rejects.toHaveProperty(['message'], 'Login failed (HTTP 403): Forbidden') + }) + + it('should throw when username is empty in classic login', async () => { + const context = createMockContext({ + readIniFile: async () => ({}), + writeIniFile: async () => {}, + fetch: async url => { + if (url === 'https://example.org/-/v1/login') { + return createMockResponse({ + ok: false, + status: 404, + text: 'Not Found', + }) + } + throw new Error(`Unexpected call to fetch: ${url}`) + }, + enquirer: { + prompt: async (opts: { message: string, name: string, type: string }): Promise> => { + if (opts.name === 'username') return { username: '' } + if (opts.name === 'password') return { password: 'pass' } + if (opts.name === 'email') return { email: 'a@b.com' } + throw new Error(`Unexpected call to enquirer.prompt: ${opts.name}`) + }, + }, + }) + const opts = { configDir: '/mock/config', dir: '/mock', rawConfig: {}, registry: 'https://example.org' } + const promise = login({ context, opts }) + await expect(promise).rejects.toHaveProperty(['code'], 'ERR_PNPM_LOGIN_MISSING_CREDENTIALS') + await expect(promise).rejects.toHaveProperty(['message'], 'Username, password, and email are all required') + }) + + it('should throw when classic login returns no token', async () => { + const context = createMockContext({ + readIniFile: async () => ({}), + writeIniFile: async () => {}, + fetch: async url => { + if (url === 'https://example.org/-/v1/login') { + return createMockResponse({ + ok: false, + status: 404, + text: 'Not Found', + }) + } + if (url === 'https://example.org/-/user/org.couchdb.user:alice') { + return createMockResponse({ + ok: true, + status: 201, + json: { ok: true }, + }) + } + throw new Error(`Unexpected call to fetch: ${url}`) + }, + enquirer: { + prompt: async (opts: { message: string, name: string, type: string }): Promise> => { + if (opts.name === 'username') return { username: 'alice' } + if (opts.name === 'password') return { password: 'pass' } + if (opts.name === 'email') return { email: 'alice@example.com' } + throw new Error(`Unexpected call to enquirer.prompt: ${opts.name}`) + }, + }, + }) + const opts = { configDir: '/mock/config', dir: '/mock', rawConfig: {}, registry: 'https://example.org' } + const promise = login({ context, opts }) + await expect(promise).rejects.toHaveProperty(['code'], 'ERR_PNPM_LOGIN_NO_TOKEN') + await expect(promise).rejects.toHaveProperty(['message'], 'The registry did not return an authentication token') + }) + + it('should throw when web login returns invalid response (missing loginUrl/doneUrl)', async () => { + const context = createMockContext({ + readIniFile: async () => ({}), + writeIniFile: async () => {}, + fetch: async url => { + if (url === 'https://example.org/-/v1/login') { + return createMockResponse({ + ok: true, + status: 200, + json: { loginUrl: 'https://example.org/auth' }, + }) + } + throw new Error(`Unexpected call to fetch: ${url}`) + }, + }) + const opts = { configDir: '/mock/config', dir: '/mock', rawConfig: {}, registry: 'https://example.org' } + const promise = login({ context, opts }) + await expect(promise).rejects.toHaveProperty(['code'], 'ERR_PNPM_LOGIN_INVALID_RESPONSE') + await expect(promise).rejects.toHaveProperty(['message'], 'The registry returned an invalid response for web-based login') + }) + + it('should fall back to classic login when web login returns 405', async () => { + let savedSettings: Record = {} + const globalInfo = jest.fn() + const context = createMockContext({ + globalInfo, + readIniFile: async () => ({}), + writeIniFile: async (_configPath, settings) => { + savedSettings = settings + }, + fetch: async url => { + if (url === 'https://example.org/-/v1/login') { + return createMockResponse({ + ok: false, + status: 405, + text: 'Method Not Allowed', + }) + } + if (url === 'https://example.org/-/user/org.couchdb.user:jane') { + return createMockResponse({ + ok: true, + status: 201, + json: { ok: true, token: 'token-405' }, + }) + } + throw new Error(`Unexpected call to fetch: ${url}`) + }, + enquirer: { + prompt: async (opts: { message: string, name: string, type: string }): Promise> => { + if (opts.name === 'username') return { username: 'jane' } + if (opts.name === 'password') return { password: 'pass' } + if (opts.name === 'email') return { email: 'jane@example.com' } + throw new Error(`Unexpected call to enquirer.prompt: ${opts.name}`) + }, + }, + }) + const opts = { configDir: '/mock/config', dir: '/mock', rawConfig: {}, registry: 'https://example.org' } + const result = await login({ context, opts }) + expect(result).toBe('Logged in on https://example.org/') + expect(savedSettings).toMatchObject({ + '//example.org/:_authToken': 'token-405', + }) + expect(globalInfo).toHaveBeenCalledWith('Logged in as jane') + }) + + it('should not trigger OTP for 401 without www-authenticate otp header', async () => { + const context = createMockContext({ + readIniFile: async () => ({}), + writeIniFile: async () => {}, + fetch: async url => { + if (url === 'https://example.org/-/v1/login') { + return createMockResponse({ + ok: false, + status: 404, + text: 'Not Found', + }) + } + // Return 401 but without www-authenticate: otp header + return createMockResponse({ + ok: false, + status: 401, + text: 'Unauthorized', + headers: { get: () => null }, + }) + }, + enquirer: { + prompt: async (opts: { message: string, name: string, type: string }): Promise> => { + if (opts.name === 'username') return { username: 'alice' } + if (opts.name === 'password') return { password: 'pass' } + if (opts.name === 'email') return { email: 'alice@example.com' } + throw new Error(`Unexpected call to enquirer.prompt: ${opts.name}`) + }, + }, + }) + const opts = { configDir: '/otp/config', dir: '/mock', rawConfig: {}, registry: 'https://example.org' } + const promise = login({ context, opts }) + await expect(promise).rejects.toHaveProperty(['code'], 'ERR_PNPM_LOGIN_FAILED') + await expect(promise).rejects.toHaveProperty(['message'], 'Login failed (HTTP 401): Unauthorized') + }) + + it('should succeed when config file does not exist (ENOENT)', async () => { + let savedSettings: Record = {} + const globalInfo = jest.fn() + const context = createMockContext({ + globalInfo, + readIniFile: async () => { + throw Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' }) + }, + writeIniFile: async (_configPath, settings) => { + savedSettings = settings + }, + fetch: async url => { + if (url === 'https://example.org/-/v1/login') { + return createMockResponse({ + ok: true, + status: 200, + json: { + loginUrl: 'https://example.org/auth/login', + doneUrl: 'https://example.org/auth/done', + }, + }) + } + return createMockResponse({ + ok: true, + status: 200, + json: { token: 'new-token' }, + }) + }, + }) + const opts = { configDir: '/nonexistent/config', dir: '/mock', rawConfig: {}, registry: 'https://example.org' } + const result = await login({ context, opts }) + expect(result).toBe('Logged in on https://example.org/') + expect(savedSettings).toMatchObject({ + '//example.org/:_authToken': 'new-token', + }) + expect(globalInfo).toHaveBeenCalledWith(expect.stringContaining('https://example.org/auth/login')) + }) + + it('should propagate non-ENOENT errors from readIniFile', async () => { + const globalInfo = jest.fn() + const context = createMockContext({ + globalInfo, + readIniFile: async () => { + throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }) + }, + writeIniFile: async () => {}, + fetch: async url => { + if (url === 'https://example.org/-/v1/login') { + return createMockResponse({ + ok: true, + status: 200, + json: { + loginUrl: 'https://example.org/auth/login', + doneUrl: 'https://example.org/auth/done', + }, + }) + } + return createMockResponse({ + ok: true, + status: 200, + json: { token: 'tok' }, + }) + }, + }) + const opts = { configDir: '/broken/config', dir: '/mock', rawConfig: {}, registry: 'https://example.org' } + const promise = login({ context, opts }) + await expect(promise).rejects.toHaveProperty(['code'], 'EACCES') + await expect(promise).rejects.toHaveProperty(['message'], 'EACCES: permission denied') + expect(globalInfo.mock.calls).toEqual([[expect.stringContaining('https://example.org/auth/login')]]) + }) +}) diff --git a/auth/commands/test/tsconfig.json b/auth/commands/test/tsconfig.json new file mode 100644 index 0000000000..67ce5e1d0e --- /dev/null +++ b/auth/commands/test/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "../node_modules/.test.lib", + "rootDir": "..", + "isolatedModules": true + }, + "include": [ + "**/*.ts", + "../../../__typings__/**/*.d.ts" + ], + "references": [ + { + "path": ".." + } + ] +} diff --git a/auth/commands/tsconfig.json b/auth/commands/tsconfig.json new file mode 100644 index 0000000000..dcfd90dfc1 --- /dev/null +++ b/auth/commands/tsconfig.json @@ -0,0 +1,31 @@ +{ + "extends": "@pnpm/tsconfig", + "compilerOptions": { + "outDir": "lib", + "rootDir": "src" + }, + "include": [ + "src/**/*.ts", + "../../__typings__/**/*.d.ts" + ], + "references": [ + { + "path": "../../cli/utils" + }, + { + "path": "../../config/reader" + }, + { + "path": "../../core/error" + }, + { + "path": "../../core/logger" + }, + { + "path": "../../network/fetch" + }, + { + "path": "../../network/web-auth" + } + ] +} diff --git a/auth/commands/tsconfig.lint.json b/auth/commands/tsconfig.lint.json new file mode 100644 index 0000000000..1bbe711971 --- /dev/null +++ b/auth/commands/tsconfig.lint.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "src/**/*.ts", + "test/**/*.ts", + "../../__typings__/**/*.d.ts" + ] +} diff --git a/network/web-auth/package.json b/network/web-auth/package.json new file mode 100644 index 0000000000..975525cb12 --- /dev/null +++ b/network/web-auth/package.json @@ -0,0 +1,49 @@ +{ + "name": "@pnpm/network.web-auth", + "version": "1000.0.0", + "description": "Web-based authentication flow with QR code display and token polling", + "keywords": [ + "pnpm", + "pnpm11", + "auth" + ], + "license": "MIT", + "funding": "https://opencollective.com/pnpm", + "repository": "https://github.com/pnpm/pnpm/tree/main/network/web-auth", + "homepage": "https://github.com/pnpm/pnpm/tree/main/network/web-auth#readme", + "bugs": { + "url": "https://github.com/pnpm/pnpm/issues" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": "./lib/index.js" + }, + "files": [ + "lib", + "!*.map" + ], + "scripts": { + "lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"", + "test": "pnpm run compile && pnpm run .test", + "prepublishOnly": "pnpm run compile", + "compile": "tsgo --build && pnpm run lint --fix", + ".test": "cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169\" jest" + }, + "dependencies": { + "@pnpm/error": "workspace:*", + "qrcode-terminal": "catalog:" + }, + "devDependencies": { + "@jest/globals": "catalog:", + "@pnpm/network.web-auth": "workspace:*", + "@types/qrcode-terminal": "catalog:" + }, + "engines": { + "node": ">=22.13" + }, + "jest": { + "preset": "@pnpm/jest-config" + } +} diff --git a/network/web-auth/src/WebAuthTimeoutError.ts b/network/web-auth/src/WebAuthTimeoutError.ts new file mode 100644 index 0000000000..8021e6c172 --- /dev/null +++ b/network/web-auth/src/WebAuthTimeoutError.ts @@ -0,0 +1,15 @@ +import { PnpmError } from '@pnpm/error' + +export class WebAuthTimeoutError extends PnpmError { + readonly endTime: number + readonly startTime: number + readonly timeout: number + constructor (endTime: number, startTime: number, timeout: number) { + super('WEBAUTH_TIMEOUT', 'Web-based authentication timed out before it could be completed', { + hint: 'Re-run this command and complete the authentication step in your browser before the time limit is reached', + }) + this.endTime = endTime + this.startTime = startTime + this.timeout = timeout + } +} diff --git a/network/web-auth/src/generateQrCode.ts b/network/web-auth/src/generateQrCode.ts new file mode 100644 index 0000000000..ba91558b7d --- /dev/null +++ b/network/web-auth/src/generateQrCode.ts @@ -0,0 +1,11 @@ +import qrcodeTerminal from 'qrcode-terminal' + +export function generateQrCode (text: string): string { + let qrCode: string | undefined + qrcodeTerminal.generate(text, { small: true }, (code: string) => { + qrCode = code + }) + if (qrCode != null) return qrCode + /* istanbul ignore next */ + throw new Error('we were expecting qrcode-terminal to be fully synchronous, but it fails to execute the callback') +} diff --git a/network/web-auth/src/index.ts b/network/web-auth/src/index.ts new file mode 100644 index 0000000000..668a716fcd --- /dev/null +++ b/network/web-auth/src/index.ts @@ -0,0 +1,22 @@ +export { generateQrCode } from './generateQrCode.js' +export { + pollForWebAuthToken, + type PollForWebAuthTokenParams, + type WebAuthContext, + type WebAuthFetchOptions, + type WebAuthFetchResponse, + type WebAuthFetchResponseHeaders, +} from './pollForWebAuthToken.js' +export { WebAuthTimeoutError } from './WebAuthTimeoutError.js' +export { + isOtpError, + type OtpContext, + type OtpEnquirer, + type OtpHandlingParams, + OtpNonInteractiveError, + type OtpPromptOptions, + type OtpPromptResponse, + OtpSecondChallengeError, + SyntheticOtpError, + withOtpHandling, +} from './withOtpHandling.js' diff --git a/network/web-auth/src/pollForWebAuthToken.ts b/network/web-auth/src/pollForWebAuthToken.ts new file mode 100644 index 0000000000..a717d72b1b --- /dev/null +++ b/network/web-auth/src/pollForWebAuthToken.ts @@ -0,0 +1,108 @@ +import { WebAuthTimeoutError } from './WebAuthTimeoutError.js' + +export interface WebAuthFetchOptions { + method: 'GET' + retry?: { + factor?: number + maxTimeout?: number + minTimeout?: number + randomize?: boolean + retries?: number + } + timeout?: number +} + +export interface WebAuthFetchResponseHeaders { + get: (name: string) => string | null +} + +export interface WebAuthFetchResponse { + readonly headers: WebAuthFetchResponseHeaders + readonly json: () => Promise + readonly ok: boolean + readonly status: number +} + +export interface WebAuthContext { + Date: { now: () => number } + setTimeout: (cb: () => void, ms: number) => void + fetch: (url: string, options: WebAuthFetchOptions) => Promise +} + +export interface PollForWebAuthTokenParams { + context: WebAuthContext + doneUrl: string + fetchOptions: WebAuthFetchOptions + timeoutMs?: number +} + +/** + * Polls a registry's "done" URL until an authentication token is returned. + * + * The caller is responsible for displaying the authentication URL (and optional + * QR code) to the user before calling this function. + * + * @returns The authentication token string. + * + * @throws {@link WebAuthTimeoutError} if the timeout is exceeded. + */ +export async function pollForWebAuthToken ({ + context: { Date, fetch, setTimeout }, + doneUrl, + fetchOptions, + timeoutMs = 5 * 60 * 1000, +}: PollForWebAuthTokenParams): Promise { + const startTime = Date.now() + const pollIntervalMs = 1000 + + while (true) { + const now = Date.now() + if (now - startTime > timeoutMs) { + throw new WebAuthTimeoutError(now, startTime, timeoutMs) + } + // eslint-disable-next-line no-await-in-loop + await new Promise(resolve => setTimeout(resolve, pollIntervalMs)) + let response: WebAuthFetchResponse + try { + // eslint-disable-next-line no-await-in-loop + response = await fetch(doneUrl, fetchOptions) + } catch { + continue + } + + if (!response.ok) continue + + if (response.status === 202) { + // Registry is still waiting for authentication. + // Respect Retry-After header if present by waiting the additional time + // beyond the default poll interval already elapsed above, but do not + // exceed the overall timeout. + const retryAfterSeconds = Number(response.headers.get('retry-after')) + if (Number.isFinite(retryAfterSeconds)) { + const additionalMs = retryAfterSeconds * 1000 - pollIntervalMs + if (additionalMs > 0) { + const nowAfterPoll = Date.now() + const remainingMs = timeoutMs - (nowAfterPoll - startTime) + if (remainingMs <= 0) { + throw new WebAuthTimeoutError(nowAfterPoll, startTime, timeoutMs) + } + const sleepMs = Math.min(additionalMs, remainingMs) + // eslint-disable-next-line no-await-in-loop + await new Promise(resolve => setTimeout(resolve, sleepMs)) + } + } + continue + } + + let body: { token?: string } + try { + // eslint-disable-next-line no-await-in-loop + body = await response.json() as { token?: string } + } catch { + continue + } + if (body.token) { + return body.token + } + } +} diff --git a/network/web-auth/src/withOtpHandling.ts b/network/web-auth/src/withOtpHandling.ts new file mode 100644 index 0000000000..60c086fb3c --- /dev/null +++ b/network/web-auth/src/withOtpHandling.ts @@ -0,0 +1,183 @@ +import { PnpmError } from '@pnpm/error' + +import { generateQrCode } from './generateQrCode.js' +import type { WebAuthFetchOptions, WebAuthFetchResponse } from './pollForWebAuthToken.js' +import { pollForWebAuthToken } from './pollForWebAuthToken.js' + +export interface OtpEnquirer { + prompt: (options: OtpPromptOptions) => Promise +} + +export interface OtpPromptOptions { + message: string + name: 'otp' + type: 'input' +} + +export interface OtpPromptResponse { + otp?: string +} + +interface OtpDate { + now: () => number +} + +export interface OtpContext { + Date: OtpDate + setTimeout: (cb: () => void, ms: number) => void + enquirer: OtpEnquirer + fetch: (url: string, options: WebAuthFetchOptions) => Promise + globalInfo: (message: string) => void + globalWarn: (message: string) => void + process: Record<'stdin' | 'stdout', { isTTY?: boolean }> +} + +interface OtpErrorBody { + authUrl?: string + doneUrl?: string +} + +interface OtpError { + code: string + body?: OtpErrorBody +} + +export const isOtpError = (error: unknown): error is OtpError => + error != null && + typeof error === 'object' && + 'code' in error && + error.code === 'EOTP' + +export interface OtpHandlingParams { + context: OtpContext + fetchOptions: WebAuthFetchOptions + operation: (otp?: string) => Promise +} + +/** + * Wraps an operation with OTP (one-time password) challenge handling. + * + * When the operation throws an error with `code: 'EOTP'`, this function: + * 1. Uses the web-based authentication flow if the error body contains + * `authUrl` and `doneUrl`. + * 2. Falls back to prompting the user for a classic OTP code. + * 3. Retries the operation with the obtained OTP. + * + * @throws {@link OtpNonInteractiveError} if OTP is required but the terminal is not interactive. + * @throws {@link OtpSecondChallengeError} if the registry requests OTP a second time after one was submitted. + * @throws the original error if OTP handling is not applicable. + * + * @see https://github.com/npm/cli/blob/7d900c46/lib/utils/otplease.js for npm's implementation. + */ +export async function withOtpHandling ({ + context, + fetchOptions, + operation, +}: OtpHandlingParams): Promise { + const { + enquirer, + globalInfo, + process, + } = context + + try { + return await operation() + } catch (error) { + if (!isOtpError(error)) throw error + if (!process.stdin.isTTY || !process.stdout.isTTY) { + throw new OtpNonInteractiveError() + } + + let otp: string | undefined + + if (error.body?.authUrl && error.body?.doneUrl) { + const qrCode = generateQrCode(error.body.authUrl) + globalInfo(`Authenticate your account at:\n${error.body.authUrl}\n\n${qrCode}`) + otp = await pollForWebAuthToken({ + context, + doneUrl: error.body.doneUrl, + fetchOptions, + }) + } else { + const enquirerResponse = await enquirer.prompt({ + message: 'This operation requires a one-time password.\nEnter OTP:', + name: 'otp', + type: 'input', + }) + + // Use || (not ??) so that empty-string input is treated as "no OTP provided" + otp = enquirerResponse?.otp || undefined + } + + if (otp != null) { + try { + return await operation(otp) + } catch (retryError) { + if (isOtpError(retryError)) { + throw new OtpSecondChallengeError() + } + + throw retryError + } + } + + throw error + } +} + +/** + * Synthetic instance of {@link OtpError} meant to be thrown by the callbacks of {@link withOtpHandling} + * and caught and handled by {@link withOtpHandling}. + */ +export class SyntheticOtpError extends Error implements OtpError { + readonly code = 'EOTP' + readonly body?: OtpErrorBody + + constructor (body: OtpErrorBody | undefined) { + super('This error was meant to be caught by `withOtpHandling`, not to propagate to other parts of the code') + this.body = body + } + + static fromUnknownBody (globalWarn: OtpContext['globalWarn'], body: unknown): SyntheticOtpError { + if (body == null || typeof body !== 'object') { + return new SyntheticOtpError(undefined) + } + + let authUrl: string | undefined + let doneUrl: string | undefined + + if ('authUrl' in body) { + if (typeof body.authUrl === 'string') { + authUrl = body.authUrl + } else { + globalWarn(`OTP error body: authUrl has type ${typeof body.authUrl}, expected string`) + } + } + + if ('doneUrl' in body) { + if (typeof body.doneUrl === 'string') { + doneUrl = body.doneUrl + } else { + globalWarn(`OTP error body: doneUrl has type ${typeof body.doneUrl}, expected string`) + } + } + + return new SyntheticOtpError({ authUrl, doneUrl }) + } +} + +export class OtpNonInteractiveError extends PnpmError { + constructor () { + super('OTP_NON_INTERACTIVE', 'The registry requires additional authentication, but pnpm is not running in an interactive terminal', { + hint: 'Re-run this command in an interactive terminal to complete authentication, or provide the --otp option if you are using a classic one-time password (OTP)', + }) + } +} + +export class OtpSecondChallengeError extends PnpmError { + constructor () { + super('OTP_SECOND_CHALLENGE', 'The registry requested a one-time password (OTP) a second time after one was already provided', { + hint: 'This is unexpected behavior from the registry. Try the command again later and, if the issue persists, verify that your registry supports OTP-based authentication or contact the registry administrator.', + }) + } +} diff --git a/network/web-auth/test/WebAuthTimeoutError.test.ts b/network/web-auth/test/WebAuthTimeoutError.test.ts new file mode 100644 index 0000000000..5654a39267 --- /dev/null +++ b/network/web-auth/test/WebAuthTimeoutError.test.ts @@ -0,0 +1,23 @@ +import { WebAuthTimeoutError } from '@pnpm/network.web-auth' + +describe('WebAuthTimeoutError', () => { + it('stores endTime, startTime, and timeout', () => { + const err = new WebAuthTimeoutError(310_000, 10_000, 300_000) + expect(err).toMatchObject({ endTime: 310_000, startTime: 10_000, timeout: 300_000 }) + }) + + it('has ERR_PNPM_WEBAUTH_TIMEOUT code', () => { + const err = new WebAuthTimeoutError(0, 0, 0) + expect(err.code).toBe('ERR_PNPM_WEBAUTH_TIMEOUT') + }) + + it('includes a hint about re-running the command', () => { + const err = new WebAuthTimeoutError(0, 0, 0) + expect(err.hint).toMatch(/Re-run/) + }) + + it('has a descriptive message', () => { + const err = new WebAuthTimeoutError(0, 0, 0) + expect(err.message).toMatch(/timed out/) + }) +}) diff --git a/network/web-auth/test/generateQrCode.test.ts b/network/web-auth/test/generateQrCode.test.ts new file mode 100644 index 0000000000..2d50d64517 --- /dev/null +++ b/network/web-auth/test/generateQrCode.test.ts @@ -0,0 +1,15 @@ +import { generateQrCode } from '@pnpm/network.web-auth' + +describe('generateQrCode', () => { + it('returns a non-empty string', () => { + const qr = generateQrCode('https://example.com') + expect(qr).toEqual(expect.any(String)) + expect(qr.length).toBeGreaterThan(0) + }) + + it('produces different output for different inputs', () => { + const qr1 = generateQrCode('https://example.com/a') + const qr2 = generateQrCode('https://example.com/b') + expect(qr1).not.toBe(qr2) + }) +}) diff --git a/network/web-auth/test/pollForWebAuthToken.test.ts b/network/web-auth/test/pollForWebAuthToken.test.ts new file mode 100644 index 0000000000..81da44e816 --- /dev/null +++ b/network/web-auth/test/pollForWebAuthToken.test.ts @@ -0,0 +1,500 @@ +import { + pollForWebAuthToken, + type WebAuthContext, + type WebAuthFetchOptions, + type WebAuthFetchResponse, + WebAuthTimeoutError, +} from '@pnpm/network.web-auth' + +function createMockResponse (init: { + ok: boolean + status: number + json?: unknown + headers?: WebAuthFetchResponse['headers'] +}): WebAuthFetchResponse { + let bodyConsumed = false + return { + ok: init.ok, + status: init.status, + json: async () => { + if (bodyConsumed) throw new Error('Unexpected double consumption of response body') + bodyConsumed = true + return init.json ?? {} + }, + headers: init.headers ?? { + get: name => { + throw new Error(`Unexpected call to headers.get: ${name}`) + }, + }, + } +} + +const createMockContext = (overrides?: Partial): WebAuthContext => ({ + Date: { now: () => 0 }, + setTimeout: (cb: () => void) => cb(), + fetch: async () => createMockResponse({ + ok: false, + status: 404, + }), + ...overrides, +}) + +const fetchOptions: WebAuthFetchOptions = { method: 'GET' } + +describe('pollForWebAuthToken', () => { + it('returns token when doneUrl responds with 200 and token', async () => { + let fetchCallCount = 0 + const context = createMockContext({ + fetch: async (): Promise => { + fetchCallCount++ + if (fetchCallCount < 3) { + return createMockResponse({ + ok: true, + status: 202, + headers: { get: () => '1' }, + }) + } + return createMockResponse({ + ok: true, + status: 200, + json: { token: 'web-token-123' }, + }) + }, + }) + const token = await pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions }) + expect(token).toBe('web-token-123') + expect(fetchCallCount).toBe(3) + }) + + it('passes doneUrl and fetchOptions to fetch', async () => { + const capturedArgs: Array<{ url: string, options: WebAuthFetchOptions }> = [] + const opts: WebAuthFetchOptions = { + method: 'GET', + timeout: 5000, + retry: { retries: 3 }, + } + const context = createMockContext({ + fetch: async (url: string, options: WebAuthFetchOptions): Promise => { + capturedArgs.push({ url, options }) + return createMockResponse({ + ok: true, + status: 200, + json: { token: 'tok' }, + }) + }, + }) + await pollForWebAuthToken({ context, doneUrl: 'https://registry.example.com/done', fetchOptions: opts }) + expect(capturedArgs).toEqual([{ url: 'https://registry.example.com/done', options: opts }]) + }) + + it('respects Retry-After header when polling', async () => { + const setTimeoutDelays: number[] = [] + let fetchCallCount = 0 + const context = createMockContext({ + setTimeout: (cb: () => void, ms: number) => { + setTimeoutDelays.push(ms) + cb() + }, + fetch: async (): Promise => { + fetchCallCount++ + if (fetchCallCount === 1) { + return createMockResponse({ + ok: true, + status: 202, + headers: { get: (name: string) => name === 'retry-after' ? '5' : null }, + }) + } + return createMockResponse({ + ok: true, + status: 200, + json: { token: 'tok' }, + }) + }, + }) + await pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions }) + // First setTimeout is the default 1s poll interval, + // second is the additional delay (5s Retry-After minus the 1s already waited), + // third is the default 1s poll interval for the next iteration. + expect(setTimeoutDelays).toStrictEqual([1000, 4000, 1000]) + }) + + it('ignores Retry-After when value is not a finite number', async () => { + const setTimeoutDelays: number[] = [] + let fetchCallCount = 0 + const context = createMockContext({ + setTimeout: (cb: () => void, ms: number) => { + setTimeoutDelays.push(ms) + cb() + }, + fetch: async (): Promise => { + fetchCallCount++ + if (fetchCallCount === 1) { + return createMockResponse({ + ok: true, + status: 202, + headers: { get: () => 'not-a-number' }, + }) + } + return createMockResponse({ + ok: true, + status: 200, + json: { token: 'tok' }, + }) + }, + }) + await pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions }) + // Only the default 1s poll intervals, no additional Retry-After delay. + expect(setTimeoutDelays).toStrictEqual([1000, 1000]) + }) + + it('ignores Retry-After when value is null', async () => { + const setTimeoutDelays: number[] = [] + let fetchCallCount = 0 + const context = createMockContext({ + setTimeout: (cb: () => void, ms: number) => { + setTimeoutDelays.push(ms) + cb() + }, + fetch: async (): Promise => { + fetchCallCount++ + if (fetchCallCount === 1) { + return createMockResponse({ + ok: true, + status: 202, + headers: { get: () => null }, + }) + } + return createMockResponse({ + ok: true, + status: 200, + json: { token: 'tok' }, + }) + }, + }) + await pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions }) + expect(setTimeoutDelays).toStrictEqual([1000, 1000]) + }) + + it('skips additional delay when Retry-After is less than poll interval', async () => { + const setTimeoutDelays: number[] = [] + let fetchCallCount = 0 + const context = createMockContext({ + setTimeout: (cb: () => void, ms: number) => { + setTimeoutDelays.push(ms) + cb() + }, + fetch: async (): Promise => { + fetchCallCount++ + if (fetchCallCount === 1) { + return createMockResponse({ + ok: true, + status: 202, + headers: { get: (name: string) => name === 'retry-after' ? '0.5' : null }, + }) + } + return createMockResponse({ + ok: true, + status: 200, + json: { token: 'tok' }, + }) + }, + }) + await pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions }) + // Retry-After of 0.5s (500ms) is less than the 1s poll interval already waited, + // so no additional delay is added. + expect(setTimeoutDelays).toStrictEqual([1000, 1000]) + }) + + it('caps Retry-After additional delay to remaining timeout', async () => { + let time = 0 + const setTimeoutDelays: number[] = [] + const context = createMockContext({ + Date: { now: () => time }, + setTimeout: (cb: () => void, ms: number) => { + setTimeoutDelays.push(ms) + time += ms + cb() + }, + fetch: async (): Promise => createMockResponse({ + ok: true, + status: 202, + json: { token: 'tok' }, + headers: { get: (name: string) => name === 'retry-after' ? '60' : null }, + }), + }) + // Use a 10s timeout so the 60s Retry-After gets capped. + await expect(pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions, timeoutMs: 10_000 })) + .rejects.toBeInstanceOf(WebAuthTimeoutError) + // The first delay is the 1s poll interval. The additional delay from + // Retry-After (59s) should be capped to the remaining timeout (~9s). + expect(setTimeoutDelays[0]).toBe(1000) + expect(setTimeoutDelays[1]).toBeLessThanOrEqual(9000) + }) + + it('throws WebAuthTimeoutError when timeout expires during Retry-After wait', async () => { + let time = 0 + const timeoutMs = 5000 + const context = createMockContext({ + Date: { + now: () => time, + }, + setTimeout: (cb: () => void, ms: number) => { + time += ms + cb() + }, + fetch: async (): Promise => { + // After the 1s poll interval, time is 1000. + // Remaining is 4000. Retry-After is 100s, so additional is 99000, + // capped to 4000. After that wait, time = 5000, which equals timeout. + // Next iteration: now - startTime > timeoutMs → throw. + return createMockResponse({ + ok: true, + status: 202, + headers: { get: (name: string) => name === 'retry-after' ? '100' : null }, + }) + }, + }) + await expect(pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions, timeoutMs })) + .rejects.toMatchObject({ timeout: timeoutMs }) + }) + + it('continues polling when fetch throws', async () => { + let fetchCallCount = 0 + const context = createMockContext({ + fetch: async (): Promise => { + fetchCallCount++ + if (fetchCallCount === 1) { + throw new Error('network failure') + } + return createMockResponse({ + ok: true, + status: 200, + json: { token: 'tok' }, + }) + }, + }) + const token = await pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions }) + expect(token).toBe('tok') + expect(fetchCallCount).toBe(2) + }) + + it('continues polling when response is not ok', async () => { + let fetchCallCount = 0 + const context = createMockContext({ + fetch: async (): Promise => { + fetchCallCount++ + if (fetchCallCount === 1) { + return createMockResponse({ + ok: false, + status: 404, + }) + } + return createMockResponse({ + ok: true, + status: 200, + json: { token: 'tok' }, + }) + }, + }) + const token = await pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions }) + expect(token).toBe('tok') + expect(fetchCallCount).toBe(2) + }) + + it('continues polling when response.json() throws', async () => { + let fetchCallCount = 0 + const context = createMockContext({ + fetch: async (): Promise => { + fetchCallCount++ + if (fetchCallCount === 1) { + return { + headers: { get: () => null }, + json: async () => { + throw new Error('invalid json') + }, + ok: true, + status: 200, + } + } + return createMockResponse({ + ok: true, + status: 200, + json: { token: 'tok' }, + }) + }, + }) + const token = await pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions }) + expect(token).toBe('tok') + expect(fetchCallCount).toBe(2) + }) + + it('continues polling when response body has no token', async () => { + let fetchCallCount = 0 + const context = createMockContext({ + fetch: async (): Promise => { + fetchCallCount++ + if (fetchCallCount === 1) { + return createMockResponse({ + ok: true, + status: 200, + json: { something: 'else' }, + }) + } + return createMockResponse({ + ok: true, + status: 200, + json: { token: 'tok' }, + }) + }, + }) + const token = await pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions }) + expect(token).toBe('tok') + expect(fetchCallCount).toBe(2) + }) + + it('continues polling when token is empty string', async () => { + let fetchCallCount = 0 + const context = createMockContext({ + fetch: async (): Promise => { + fetchCallCount++ + if (fetchCallCount === 1) { + return createMockResponse({ + ok: true, + status: 200, + json: { token: '' }, + }) + } + return createMockResponse({ + ok: true, + status: 200, + json: { token: 'real-tok' }, + }) + }, + }) + const token = await pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions }) + expect(token).toBe('real-tok') + expect(fetchCallCount).toBe(2) + }) + + it('throws WebAuthTimeoutError after timeout', async () => { + let time = 0 + const context = createMockContext({ + Date: { now: () => time }, + setTimeout: (cb: () => void) => { + time += 6 * 60 * 1000 // Jump past timeout + cb() + }, + fetch: async (): Promise => createMockResponse({ + ok: true, + status: 202, + headers: { get: () => null }, + }), + }) + await expect(pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions })) + .rejects.toBeInstanceOf(WebAuthTimeoutError) + }) + + it('uses custom timeout value', async () => { + let time = 0 + const customTimeoutMs = 3000 + const context = createMockContext({ + Date: { now: () => time }, + setTimeout: (cb: () => void) => { + time += 2000 + cb() + }, + fetch: async (): Promise => createMockResponse({ + ok: true, + status: 202, + headers: { get: () => null }, + }), + }) + await expect(pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions, timeoutMs: customTimeoutMs })) + .rejects.toMatchObject({ timeout: customTimeoutMs }) + }) + + it('recovers after multiple consecutive fetch errors', async () => { + let fetchCallCount = 0 + const context = createMockContext({ + fetch: async (): Promise => { + fetchCallCount++ + if (fetchCallCount <= 5) { + throw new Error(`failure #${fetchCallCount}`) + } + return createMockResponse({ + ok: true, + status: 200, + json: { token: 'recovered' }, + }) + }, + }) + const token = await pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions }) + expect(token).toBe('recovered') + expect(fetchCallCount).toBe(6) + }) + + it('waits pollIntervalMs before each fetch call', async () => { + const setTimeoutDelays: number[] = [] + let fetchCallCount = 0 + const context = createMockContext({ + setTimeout: (cb: () => void, ms: number) => { + setTimeoutDelays.push(ms) + cb() + }, + fetch: async (): Promise => { + fetchCallCount++ + if (fetchCallCount < 4) { + return createMockResponse({ + ok: true, + status: 202, + headers: { get: () => null }, + }) + } + return createMockResponse({ + ok: true, + status: 200, + json: { token: 'tok' }, + }) + }, + }) + await pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions }) + // Each iteration waits 1000ms before fetching. + expect(setTimeoutDelays).toStrictEqual([1000, 1000, 1000, 1000]) + }) + + it('throws WebAuthTimeoutError immediately when remaining time is zero during Retry-After', async () => { + let time = 0 + const timeoutMs = 2000 + let fetchCallCount = 0 + const context = createMockContext({ + Date: { now: () => time }, + setTimeout: (cb: () => void, ms: number) => { + time += ms + cb() + }, + fetch: async (): Promise => { + fetchCallCount++ + if (fetchCallCount === 1) { + // After poll interval (1s), time = 1000, remaining = 1000. + // Retry-After = 10s → additional = 9000 > remaining. + // Capped to remaining (1000). After that wait, time = 2000. + return createMockResponse({ + ok: true, + status: 202, + headers: { get: (name: string) => name === 'retry-after' ? '10' : null }, + }) + } + // This second fetch still returns 202, but the next timeout check + // should trigger the error since time (2000) - start (0) = 2000 > 2000? No, it's equal. + // Actually the condition is `>` so 2000 > 2000 is false. So it waits another 1s, then 3000 > 2000 is true. + return createMockResponse({ + ok: true, + status: 202, + headers: { get: () => null }, + }) + }, + }) + await expect(pollForWebAuthToken({ context, doneUrl: 'https://registry.npmjs.org/auth/done', fetchOptions, timeoutMs })) + .rejects.toMatchObject({ timeout: timeoutMs }) + }) +}) diff --git a/network/web-auth/test/tsconfig.json b/network/web-auth/test/tsconfig.json new file mode 100644 index 0000000000..67ce5e1d0e --- /dev/null +++ b/network/web-auth/test/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "../node_modules/.test.lib", + "rootDir": "..", + "isolatedModules": true + }, + "include": [ + "**/*.ts", + "../../../__typings__/**/*.d.ts" + ], + "references": [ + { + "path": ".." + } + ] +} diff --git a/network/web-auth/test/withOtpHandling.test.ts b/network/web-auth/test/withOtpHandling.test.ts new file mode 100644 index 0000000000..454ed63cca --- /dev/null +++ b/network/web-auth/test/withOtpHandling.test.ts @@ -0,0 +1,389 @@ +import { jest } from '@jest/globals' +import { + type OtpContext, + OtpNonInteractiveError, + OtpSecondChallengeError, + SyntheticOtpError, + type WebAuthFetchOptions, + type WebAuthFetchResponse, + WebAuthTimeoutError, + withOtpHandling, +} from '@pnpm/network.web-auth' + +function createMockResponse (init: { + ok: boolean + status: number + json?: unknown + headers?: WebAuthFetchResponse['headers'] +}): WebAuthFetchResponse { + let bodyConsumed = false + return { + ok: init.ok, + status: init.status, + json: async () => { + if (bodyConsumed) throw new Error('Unexpected double consumption of response body') + bodyConsumed = true + return init.json ?? {} + }, + headers: init.headers ?? { + get: name => { + throw new Error(`Unexpected call to headers.get: ${name}`) + }, + }, + } +} + +const createOtpMockContext = (overrides?: Partial): OtpContext => ({ + Date: { now: () => 0 }, + setTimeout: (cb: () => void) => cb(), + enquirer: { prompt: async () => ({ otp: '123456' }) }, + fetch: async () => createMockResponse({ + ok: false, + status: 404, + }), + globalInfo: msg => { + throw new Error(`Unexpected call to globalInfo: ${msg}`) + }, + globalWarn: msg => { + throw new Error(`Unexpected call to globalWarn: ${msg}`) + }, + process: { + stdin: { isTTY: true }, + stdout: { isTTY: true }, + }, + ...overrides, +}) + +const fetchOptions: WebAuthFetchOptions = { method: 'GET' } + +describe('withOtpHandling', () => { + it('returns the result when the operation succeeds without OTP', async () => { + const context = createOtpMockContext() + const result = await withOtpHandling({ context, fetchOptions, operation: async () => 'success' }) + expect(result).toBe('success') + }) + + it('throws non-OTP errors as-is', async () => { + const error = new Error('network error') + const context = createOtpMockContext() + await expect(withOtpHandling({ context, fetchOptions, operation: async () => { + throw error + } })) + .rejects.toBe(error) + }) + + it('throws OtpNonInteractiveError when terminal is not interactive', async () => { + const context = createOtpMockContext({ + process: { + stdin: { isTTY: false }, + stdout: { isTTY: true }, + }, + }) + const operation = async () => { + throw Object.assign(new Error('otp'), { code: 'EOTP' }) + } + await expect(withOtpHandling({ context, fetchOptions, operation })) + .rejects.toBeInstanceOf(OtpNonInteractiveError) + }) + + it('throws OtpNonInteractiveError when stdout is not interactive', async () => { + const context = createOtpMockContext({ + process: { + stdin: { isTTY: true }, + stdout: { isTTY: false }, + }, + }) + const operation = async () => { + throw Object.assign(new Error('otp'), { code: 'EOTP' }) + } + await expect(withOtpHandling({ context, fetchOptions, operation })) + .rejects.toBeInstanceOf(OtpNonInteractiveError) + }) + + describe('classic OTP flow', () => { + it('prompts for OTP and retries operation', async () => { + let callCount = 0 + const context = createOtpMockContext({ + enquirer: { prompt: async () => ({ otp: '654321' }) }, + }) + const result = await withOtpHandling({ + context, + fetchOptions, + operation: async otp => { + callCount++ + if (callCount === 1) { + throw Object.assign(new Error('otp'), { code: 'EOTP' }) + } + expect(otp).toBe('654321') + return 'ok' + }, + }) + expect(result).toBe('ok') + expect(callCount).toBe(2) + }) + + it('throws OtpSecondChallengeError if retry also requires OTP', async () => { + const context = createOtpMockContext() + const operation = async () => { + throw Object.assign(new Error('otp'), { code: 'EOTP' }) + } + await expect(withOtpHandling({ context, fetchOptions, operation })) + .rejects.toBeInstanceOf(OtpSecondChallengeError) + }) + + it('throws non-OTP errors from the retry as-is', async () => { + let callCount = 0 + const retryError = new Error('server error') + const context = createOtpMockContext() + await expect(withOtpHandling({ + context, + fetchOptions, + operation: async () => { + callCount++ + if (callCount === 1) { + throw Object.assign(new Error('otp'), { code: 'EOTP' }) + } + throw retryError + }, + })).rejects.toBe(retryError) + }) + + it('re-throws the original OTP error when enquirer returns no OTP', async () => { + const context = createOtpMockContext({ + enquirer: { prompt: async () => ({ otp: '' }) }, + }) + await expect(withOtpHandling({ + context, + fetchOptions, + operation: async () => { + throw Object.assign(new Error('otp'), { code: 'EOTP' }) + }, + })).rejects.toMatchObject({ code: 'EOTP' }) + }) + + it('re-throws the original OTP error when enquirer returns undefined', async () => { + const context = createOtpMockContext({ + enquirer: { prompt: async () => undefined }, + }) + await expect(withOtpHandling({ + context, + fetchOptions, + operation: async () => { + throw Object.assign(new Error('otp'), { code: 'EOTP' }) + }, + })).rejects.toMatchObject({ code: 'EOTP' }) + }) + }) + + describe('webauth flow', () => { + it('polls doneUrl and uses returned token', async () => { + let operationCallCount = 0 + let fetchCallCount = 0 + const globalInfo = jest.fn() + const context = createOtpMockContext({ + globalInfo, + fetch: async (): Promise => { + fetchCallCount++ + if (fetchCallCount < 3) { + return createMockResponse({ + ok: true, + status: 202, + headers: { get: () => '1' }, + }) + } + return createMockResponse({ + ok: true, + status: 200, + json: { token: 'web-token-123' }, + }) + }, + }) + const result = await withOtpHandling({ + context, + fetchOptions, + operation: async otp => { + operationCallCount++ + if (operationCallCount === 1) { + throw Object.assign(new Error('otp'), { + code: 'EOTP', + body: { + authUrl: 'https://registry.npmjs.org/auth/abc', + doneUrl: 'https://registry.npmjs.org/auth/abc/done', + }, + }) + } + expect(otp).toBe('web-token-123') + return 'published' + }, + }) + expect(result).toBe('published') + expect(operationCallCount).toBe(2) + expect(fetchCallCount).toBe(3) + expect(globalInfo.mock.calls).toEqual([[expect.stringContaining('https://registry.npmjs.org/auth/abc')]]) + }) + + it('falls back to classic prompt when only authUrl is present (no doneUrl)', async () => { + let callCount = 0 + const context = createOtpMockContext({ + enquirer: { prompt: async () => ({ otp: 'manual-code' }) }, + }) + const result = await withOtpHandling({ + context, + fetchOptions, + operation: async otp => { + callCount++ + if (callCount === 1) { + throw Object.assign(new Error('otp'), { + code: 'EOTP', + body: { authUrl: 'https://registry.npmjs.org/auth/abc' }, + }) + } + expect(otp).toBe('manual-code') + return 'done' + }, + }) + expect(result).toBe('done') + }) + + it('falls back to classic prompt when only doneUrl is present (no authUrl)', async () => { + let callCount = 0 + const context = createOtpMockContext({ + enquirer: { prompt: async () => ({ otp: 'manual-code' }) }, + }) + const result = await withOtpHandling({ + context, + fetchOptions, + operation: async otp => { + callCount++ + if (callCount === 1) { + throw Object.assign(new Error('otp'), { + code: 'EOTP', + body: { doneUrl: 'https://registry.npmjs.org/auth/abc/done' }, + }) + } + expect(otp).toBe('manual-code') + return 'done' + }, + }) + expect(result).toBe('done') + }) + + it('throws WebAuthTimeoutError when webauth polling times out', async () => { + let time = 0 + const globalInfo = jest.fn() + const context = createOtpMockContext({ + globalInfo, + Date: { now: () => time }, + setTimeout: (cb: () => void) => { + time += 6 * 60 * 1000 + cb() + }, + fetch: async (): Promise => createMockResponse({ + ok: true, + status: 202, + headers: { get: () => null }, + }), + }) + let called = false + await expect(withOtpHandling({ + context, + fetchOptions, + operation: async () => { + if (!called) { + called = true + throw Object.assign(new Error('otp'), { + code: 'EOTP', + body: { + authUrl: 'https://registry.npmjs.org/auth/abc', + doneUrl: 'https://registry.npmjs.org/auth/abc/done', + }, + }) + } + throw new Error('Unexpected second call to operation') + }, + })).rejects.toBeInstanceOf(WebAuthTimeoutError) + expect(globalInfo).toHaveBeenCalledWith(expect.stringContaining('https://registry.npmjs.org/auth/abc')) + }) + }) +}) + +describe('SyntheticOtpError', () => { + it('has EOTP code', () => { + const err = new SyntheticOtpError({ authUrl: 'https://example.com/auth', doneUrl: 'https://example.com/done' }) + expect(err.code).toBe('EOTP') + }) + + it('stores body', () => { + const body = { authUrl: 'https://example.com/auth', doneUrl: 'https://example.com/done' } + const err = new SyntheticOtpError(body) + expect(err.body).toEqual(body) + }) +}) + +describe('SyntheticOtpError.fromUnknownBody', () => { + const unexpectedWarn = (msg: string) => { + throw new Error(`Unexpected call to globalWarn: ${msg}`) + } + + it('extracts valid string authUrl and doneUrl', () => { + const err = SyntheticOtpError.fromUnknownBody(unexpectedWarn, { + authUrl: 'https://example.com/auth', + doneUrl: 'https://example.com/done', + }) + expect(err.body).toEqual({ + authUrl: 'https://example.com/auth', + doneUrl: 'https://example.com/done', + }) + }) + + it('returns undefined body when body is null', () => { + const err = SyntheticOtpError.fromUnknownBody(unexpectedWarn, null) + expect(err.body).toBeUndefined() + }) + + it('returns undefined body when body is not an object', () => { + const err = SyntheticOtpError.fromUnknownBody(unexpectedWarn, 'not an object') + expect(err.body).toBeUndefined() + }) + + it('warns when authUrl has wrong type', () => { + const globalWarn = jest.fn() + const err = SyntheticOtpError.fromUnknownBody(globalWarn, { + authUrl: 123, + doneUrl: 'https://example.com/done', + }) + expect(globalWarn.mock.calls).toEqual([[expect.stringContaining('authUrl')]]) + expect(err.body?.authUrl).toBeUndefined() + expect(err.body?.doneUrl).toBe('https://example.com/done') + }) + + it('warns when doneUrl has wrong type', () => { + const globalWarn = jest.fn() + const err = SyntheticOtpError.fromUnknownBody(globalWarn, { + authUrl: 'https://example.com/auth', + doneUrl: true, + }) + expect(globalWarn.mock.calls).toEqual([[expect.stringContaining('doneUrl')]]) + expect(err.body?.authUrl).toBe('https://example.com/auth') + expect(err.body?.doneUrl).toBeUndefined() + }) + + it('warns for both when both have wrong types', () => { + const globalWarn = jest.fn() + const err = SyntheticOtpError.fromUnknownBody(globalWarn, { + authUrl: 42, + doneUrl: false, + }) + expect(globalWarn.mock.calls).toEqual([ + [expect.stringContaining('authUrl')], + [expect.stringContaining('doneUrl')], + ]) + expect(err.body?.authUrl).toBeUndefined() + expect(err.body?.doneUrl).toBeUndefined() + }) + + it('returns empty body when body has no authUrl or doneUrl', () => { + const err = SyntheticOtpError.fromUnknownBody(unexpectedWarn, { something: 'else' }) + expect(err.body).toEqual({}) + }) +}) diff --git a/network/web-auth/tsconfig.json b/network/web-auth/tsconfig.json new file mode 100644 index 0000000000..5e5ef544a0 --- /dev/null +++ b/network/web-auth/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "@pnpm/tsconfig", + "compilerOptions": { + "outDir": "lib", + "rootDir": "src" + }, + "include": [ + "src/**/*.ts", + "../../__typings__/**/*.d.ts" + ], + "references": [ + { + "path": "../../core/error" + } + ] +} diff --git a/network/web-auth/tsconfig.lint.json b/network/web-auth/tsconfig.lint.json new file mode 100644 index 0000000000..1bbe711971 --- /dev/null +++ b/network/web-auth/tsconfig.lint.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "src/**/*.ts", + "test/**/*.ts", + "../../__typings__/**/*.d.ts" + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8ce727b12..a7f673f978 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1411,6 +1411,49 @@ importers: specifier: workspace:* version: 'link:' + auth/commands: + dependencies: + '@pnpm/cli.utils': + specifier: workspace:* + version: link:../../cli/utils + '@pnpm/config.reader': + specifier: workspace:* + version: link:../../config/reader + '@pnpm/error': + specifier: workspace:* + version: link:../../core/error + '@pnpm/network.fetch': + specifier: workspace:* + version: link:../../network/fetch + '@pnpm/network.web-auth': + specifier: workspace:* + version: link:../../network/web-auth + enquirer: + specifier: 'catalog:' + version: 2.4.1 + normalize-registry-url: + specifier: 'catalog:' + version: 2.0.1 + read-ini-file: + specifier: 'catalog:' + version: 5.0.0 + render-help: + specifier: 'catalog:' + version: 2.0.0 + write-ini-file: + specifier: 'catalog:' + version: 5.0.0 + devDependencies: + '@jest/globals': + specifier: 'catalog:' + version: 30.3.0 + '@pnpm/auth.commands': + specifier: workspace:* + version: 'link:' + '@pnpm/logger': + specifier: workspace:* + version: link:../../core/logger + bins/linker: dependencies: '@pnpm/bins.resolver': @@ -6809,6 +6852,25 @@ importers: specifier: 'catalog:' version: 3.0.0 + network/web-auth: + dependencies: + '@pnpm/error': + specifier: workspace:* + version: link:../../core/error + qrcode-terminal: + specifier: 'catalog:' + version: 0.12.0 + devDependencies: + '@jest/globals': + specifier: 'catalog:' + version: 30.3.0 + '@pnpm/network.web-auth': + specifier: workspace:* + version: 'link:' + '@types/qrcode-terminal': + specifier: 'catalog:' + version: 0.12.2 + object/key-sorting: dependencies: '@pnpm/util.lex-comparator': @@ -7098,6 +7160,9 @@ importers: '@pnpm/assert-project': specifier: workspace:* version: link:../__utils__/assert-project + '@pnpm/auth.commands': + specifier: workspace:* + version: link:../auth/commands '@pnpm/building.commands': specifier: workspace:* version: link:../building/commands @@ -7551,6 +7616,9 @@ importers: '@pnpm/network.git-utils': specifier: workspace:* version: link:../../network/git-utils + '@pnpm/network.web-auth': + specifier: workspace:* + version: link:../../network/web-auth '@pnpm/releasing.exportable-manifest': specifier: workspace:* version: link:../exportable-manifest @@ -7599,9 +7667,6 @@ importers: p-limit: specifier: 'catalog:' version: 7.3.0 - qrcode-terminal: - specifier: 'catalog:' - version: 0.12.0 ramda: specifier: 'catalog:' version: '@pnpm/ramda@0.28.1' @@ -7675,9 +7740,6 @@ importers: '@types/proxyquire': specifier: 'catalog:' version: 1.3.31 - '@types/qrcode-terminal': - specifier: 'catalog:' - version: 0.12.2 '@types/ramda': specifier: 'catalog:' version: 0.31.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c7c5f5bd36..1598b5ddb2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ packages: - __typings__ - __utils__/* - '!__utils__/build-artifacts' + - auth/* - building/* - cache/* - catalogs/* diff --git a/pnpm/package.json b/pnpm/package.json index dc816fcdb0..77aaefb4be 100644 --- a/pnpm/package.json +++ b/pnpm/package.json @@ -78,6 +78,7 @@ "devDependencies": { "@jest/globals": "catalog:", "@pnpm/assert-project": "workspace:*", + "@pnpm/auth.commands": "workspace:*", "@pnpm/building.commands": "workspace:*", "@pnpm/byline": "catalog:", "@pnpm/cache.commands": "workspace:*", diff --git a/pnpm/src/cmd/index.ts b/pnpm/src/cmd/index.ts index fce8d53645..e7807deb2f 100644 --- a/pnpm/src/cmd/index.ts +++ b/pnpm/src/cmd/index.ts @@ -1,3 +1,4 @@ +import { login } from '@pnpm/auth.commands' import { approveBuilds, ignoredBuilds, rebuild } from '@pnpm/building.commands' import { cache } from '@pnpm/cache.commands' import type { CommandHandlerMap, CompletionFunc } from '@pnpm/cli.command' @@ -140,6 +141,7 @@ const commands: CommandDefinition[] = [ installTest, link, list, + login, ll, licenses, outdated, diff --git a/pnpm/src/cmd/notImplemented.ts b/pnpm/src/cmd/notImplemented.ts index 1a535017f2..53bce4cc55 100644 --- a/pnpm/src/cmd/notImplemented.ts +++ b/pnpm/src/cmd/notImplemented.ts @@ -4,7 +4,6 @@ import type { CommandDefinition } from './index.js' const NOT_IMPLEMENTED_COMMANDS = [ 'access', - 'adduser', 'bugs', 'deprecate', 'dist-tag', @@ -14,7 +13,6 @@ const NOT_IMPLEMENTED_COMMANDS = [ 'home', 'info', 'issues', - 'login', 'logout', 'owner', 'ping', diff --git a/pnpm/tsconfig.json b/pnpm/tsconfig.json index 6c7bfa5a26..c5599b3209 100644 --- a/pnpm/tsconfig.json +++ b/pnpm/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../__utils__/test-ipc-server" }, + { + "path": "../auth/commands" + }, { "path": "../building/commands" }, diff --git a/releasing/commands/package.json b/releasing/commands/package.json index f7df4796b7..a5da64248f 100644 --- a/releasing/commands/package.json +++ b/releasing/commands/package.json @@ -53,6 +53,7 @@ "@pnpm/lockfile.types": "workspace:*", "@pnpm/network.fetch": "workspace:*", "@pnpm/network.git-utils": "workspace:*", + "@pnpm/network.web-auth": "workspace:*", "@pnpm/releasing.exportable-manifest": "workspace:*", "@pnpm/resolving.resolver-base": "workspace:*", "@pnpm/types": "workspace:*", @@ -69,7 +70,6 @@ "normalize-registry-url": "catalog:", "p-filter": "catalog:", "p-limit": "catalog:", - "qrcode-terminal": "catalog:", "ramda": "catalog:", "realpath-missing": "catalog:", "render-help": "catalog:", @@ -99,7 +99,6 @@ "@types/is-windows": "catalog:", "@types/libnpmpublish": "catalog:", "@types/proxyquire": "catalog:", - "@types/qrcode-terminal": "catalog:", "@types/ramda": "catalog:", "@types/semver": "catalog:", "@types/tar": "catalog:", diff --git a/releasing/commands/src/publish/otp.ts b/releasing/commands/src/publish/otp.ts index 6e7f43b68e..88dce5258c 100644 --- a/releasing/commands/src/publish/otp.ts +++ b/releasing/commands/src/publish/otp.ts @@ -1,33 +1,13 @@ -import { PnpmError } from '@pnpm/error' +import { + type OtpContext as BaseOtpContext, + type WebAuthFetchOptions, + withOtpHandling, +} from '@pnpm/network.web-auth' import type { ExportedManifest } from '@pnpm/releasing.exportable-manifest' import type { PublishOptions } from 'libnpmpublish' -import qrcodeTerminal from 'qrcode-terminal' import { SHARED_CONTEXT } from './utils/shared-context.js' -export interface OtpWebAuthFetchOptions { - method: 'GET' - retry?: { - factor?: number - maxTimeout?: number - minTimeout?: number - randomize?: boolean - retries?: number - } - timeout?: number -} - -export interface OtpWebAuthFetchResponseHeaders { - get: (this: this, name: 'retry-after') => string | null -} - -export interface OtpWebAuthFetchResponse { - readonly headers: OtpWebAuthFetchResponseHeaders - readonly json: (this: this) => Promise - readonly ok: boolean - readonly status: number -} - export interface OtpPublishResponse { readonly ok: boolean readonly status: number @@ -35,37 +15,13 @@ export interface OtpPublishResponse { readonly text: () => Promise } -export interface OtpEnquirer { - prompt: (this: this, options: OtpEnquirerOptions) => Promise -} - -export interface OtpEnquirerOptions { - message: string - name: 'otp' - type: 'input' -} - -export interface OtpEnquirerResponse { - otp?: string -} - export type OtpPublishFn = ( manifest: ExportedManifest, tarballData: Buffer, options: PublishOptions ) => Promise -export interface OtpDate { - now: () => number -} - -export interface OtpContext { - Date: OtpDate - setTimeout: (cb: () => void, ms: number) => void - enquirer: OtpEnquirer - fetch: (url: string, options: OtpWebAuthFetchOptions) => Promise - globalInfo: (message: string) => void - process: Record<'stdin' | 'stdout', { isTTY?: boolean }> +export interface OtpContext extends BaseOtpContext { publish: OtpPublishFn } @@ -76,211 +32,40 @@ export interface OtpParams { tarballData: Buffer } -export { SHARED_CONTEXT } - -interface OtpErrorBody { - authUrl?: string - doneUrl?: string -} - -interface OtpErrorHeaders { - 'www-authenticate'?: string[] -} - -interface OtpError { - code: string - body?: OtpErrorBody - headers?: OtpErrorHeaders -} - -const isOtpError = (error: unknown): error is OtpError => - error != null && - typeof error === 'object' && - 'code' in error && - error.code === 'EOTP' - /** * Publish a package, handling OTP challenges: * - Web based authentication flow (authUrl/doneUrl in error body with doneUrl polling) * - Classic OTP prompt (manual code entry) * - * @throws {@link OtpWebAuthTimeoutError} if the webauth browser flow times out. - * @throws {@link OtpNonInteractiveError} if OTP is required but the terminal is not interactive. - * @throws {@link OtpSecondChallengeError} if the registry requests OTP a second time after one was submitted. - * @throws the original error if OTP handling is not applicable. - * * @see https://github.com/npm/cli/blob/7d900c46/lib/utils/otplease.js for npm's implementation. * @see https://github.com/npm/npm-profile/blob/main/lib/index.js for the webauth polling flow. */ export async function publishWithOtpHandling ({ - context: { - Date, - setTimeout, - enquirer, - fetch, - globalInfo, - process, - publish, - } = SHARED_CONTEXT, + context = SHARED_CONTEXT, manifest, publishOptions, tarballData, }: OtpParams): Promise { - let response: OtpPublishResponse + const { publish } = context - try { - response = await publish(manifest, tarballData, publishOptions) - } catch (error) { - if (!isOtpError(error)) throw error - if (!process.stdin.isTTY || !process.stdout.isTTY) { - throw new OtpNonInteractiveError() - } - - const fetchOptions: OtpWebAuthFetchOptions = { - method: 'GET', - retry: { - factor: publishOptions.fetchRetryFactor, - maxTimeout: publishOptions.fetchRetryMaxtimeout, - minTimeout: publishOptions.fetchRetryMintimeout, - retries: publishOptions.fetchRetries, - }, - timeout: publishOptions.timeout, - } - - let otp: string | undefined - - if (error.body?.authUrl && error.body?.doneUrl) { - otp = await webAuthOtp(error.body.authUrl, error.body.doneUrl, { Date, setTimeout, fetch, globalInfo }, fetchOptions) - } else { - const enquirerResponse = await enquirer.prompt({ - message: 'This operation requires a one-time password.\nEnter OTP:', - name: 'otp', - type: 'input', - }) - - // Use || (not ??) so that empty-string input is treated as "no OTP provided" - otp = enquirerResponse?.otp || undefined - } - - if (otp != null) { - try { - return await publish(manifest, tarballData, { ...publishOptions, otp }) - } catch (retryError) { - if (isOtpError(retryError)) { - throw new OtpSecondChallengeError() - } - - throw retryError - } - } - - throw error + const fetchOptions: WebAuthFetchOptions = { + method: 'GET', + retry: { + factor: publishOptions.fetchRetryFactor, + maxTimeout: publishOptions.fetchRetryMaxtimeout, + minTimeout: publishOptions.fetchRetryMintimeout, + retries: publishOptions.fetchRetries, + }, + timeout: publishOptions.timeout, } - return response -} - -async function webAuthOtp ( - authUrl: string, - doneUrl: string, - { Date, setTimeout, fetch, globalInfo }: Pick, - fetchOptions: OtpWebAuthFetchOptions -): Promise { - const qrCode = generateQrCode(authUrl) - globalInfo(`Authenticate your account at:\n${authUrl}\n\n${qrCode}`) - const startTime = Date.now() - const timeout = 5 * 60 * 1000 // 5 minutes - - const pollIntervalMs = 1000 - - while (true) { - const now = Date.now() - if (now - startTime > timeout) { - throw new OtpWebAuthTimeoutError(now, startTime, timeout) - } - // eslint-disable-next-line no-await-in-loop - await new Promise(resolve => setTimeout(resolve, pollIntervalMs)) - let response: OtpWebAuthFetchResponse - try { - // eslint-disable-next-line no-await-in-loop - response = await fetch(doneUrl, fetchOptions) - } catch { - continue - } - - if (!response.ok) continue - - if (response.status === 202) { - // Registry is still waiting for authentication. - // Respect Retry-After header if present by waiting the additional time - // beyond the default poll interval already elapsed above, but do not - // exceed the overall timeout. - const retryAfterSeconds = Number(response.headers.get('retry-after')) - if (Number.isFinite(retryAfterSeconds)) { - const additionalMs = retryAfterSeconds * 1000 - pollIntervalMs - if (additionalMs > 0) { - const nowAfterPoll = Date.now() - const remainingMs = timeout - (nowAfterPoll - startTime) - if (remainingMs <= 0) { - throw new OtpWebAuthTimeoutError(nowAfterPoll, startTime, timeout) - } - const sleepMs = Math.min(additionalMs, remainingMs) - // eslint-disable-next-line no-await-in-loop - await new Promise(resolve => setTimeout(resolve, sleepMs)) - } - } - continue - } - - let body: { token?: string } - try { - // eslint-disable-next-line no-await-in-loop - body = await response.json() as { token?: string } - } catch { - continue - } - if (body.token) { - return body.token - } - } -} - -function generateQrCode (text: string): string { - let qrCode: string | undefined - qrcodeTerminal.generate(text, { small: true }, (code: string) => { - qrCode = code + return withOtpHandling({ + context, + fetchOptions, + // When otp is undefined (first attempt), { ...publishOptions, otp } adds + // otp: undefined to the options. This is safe because libnpmpublish treats + // undefined the same as absent (unlike HTTP headers, where undefined gets + // coerced to the string "undefined"). + operation: otp => publish(manifest, tarballData, { ...publishOptions, otp }), }) - if (qrCode != null) return qrCode - /* istanbul ignore next */ - throw new Error('we were expecting qrcode-terminal to be fully synchronous, but it fails to execute the callback') -} - -export class OtpWebAuthTimeoutError extends PnpmError { - readonly endTime: number - readonly startTime: number - readonly timeout: number - constructor (endTime: number, startTime: number, timeout: number) { - super('WEBAUTH_TIMEOUT', 'Web-based authentication timed out before it could be completed', { - hint: 'Re-run this command and complete the authentication step in your browser before the time limit is reached', - }) - this.endTime = endTime - this.startTime = startTime - this.timeout = timeout - } -} - -export class OtpNonInteractiveError extends PnpmError { - constructor () { - super('OTP_NON_INTERACTIVE', 'The registry requires additional authentication, but pnpm is not running in an interactive terminal', { - hint: 'Re-run this command in an interactive terminal to complete authentication, or provide the --otp option if you are using a classic one-time password (OTP)', - }) - } -} - -export class OtpSecondChallengeError extends PnpmError { - constructor () { - super('OTP_SECOND_CHALLENGE', 'The registry requested a one-time password (OTP) a second time after one was already provided', { - hint: 'This is unexpected behavior from the registry. Try the command again later and, if the issue persists, verify that your registry supports OTP-based authentication or contact the registry administrator.', - }) - } } diff --git a/releasing/commands/src/publish/utils/shared-context.ts b/releasing/commands/src/publish/utils/shared-context.ts index de90073140..ea7e97656b 100644 --- a/releasing/commands/src/publish/utils/shared-context.ts +++ b/releasing/commands/src/publish/utils/shared-context.ts @@ -1,4 +1,4 @@ -import { globalInfo } from '@pnpm/logger' +import { globalInfo, globalWarn } from '@pnpm/logger' import { fetch } from '@pnpm/network.fetch' import type { ExportedManifest } from '@pnpm/releasing.exportable-manifest' import ciInfo from 'ci-info' @@ -32,6 +32,7 @@ export const SHARED_CONTEXT: SharedContext = { enquirer, fetch, globalInfo, + globalWarn, process, publish, setTimeout, diff --git a/releasing/commands/test/publish/otp.test.ts b/releasing/commands/test/publish/otp.test.ts index 7843713b9e..5252db45ea 100644 --- a/releasing/commands/test/publish/otp.test.ts +++ b/releasing/commands/test/publish/otp.test.ts @@ -1,10 +1,14 @@ +import { jest } from '@jest/globals' +import { + OtpNonInteractiveError, + OtpSecondChallengeError, + type WebAuthFetchResponse, + WebAuthTimeoutError, +} from '@pnpm/network.web-auth' + import { type OtpContext, - OtpNonInteractiveError, type OtpPublishResponse, - OtpSecondChallengeError, - type OtpWebAuthFetchResponse, - OtpWebAuthTimeoutError, publishWithOtpHandling, } from '../../src/publish/otp.js' @@ -23,7 +27,12 @@ function createMockContext (overrides?: Partial): OtpContext { ok: false, status: 404, }), - globalInfo: () => {}, + globalInfo: msg => { + throw new Error(`Unexpected call to globalInfo: ${msg}`) + }, + globalWarn: msg => { + throw new Error(`Unexpected call to globalWarn: ${msg}`) + }, process: { stdin: { isTTY: true }, stdout: { isTTY: true } }, publish: async () => createOkResponse(), ...overrides, @@ -138,7 +147,9 @@ describe('publishWithOtpHandling', () => { it('polls doneUrl and uses returned token', async () => { let publishCallCount = 0 let fetchCallCount = 0 + const globalInfo = jest.fn() const context = createMockContext({ + globalInfo, publish: async (_m, _t, opts) => { publishCallCount++ if (publishCallCount === 1) { @@ -153,7 +164,7 @@ describe('publishWithOtpHandling', () => { expect(opts.otp).toBe('web-token-123') return createOkResponse() }, - fetch: async (): Promise => { + fetch: async (): Promise => { fetchCallCount++ if (fetchCallCount < 3) { return { @@ -175,12 +186,15 @@ describe('publishWithOtpHandling', () => { expect(result.ok).toBe(true) expect(publishCallCount).toBe(2) expect(fetchCallCount).toBe(3) + expect(globalInfo).toHaveBeenCalledWith(expect.stringContaining('https://registry.npmjs.org/auth/abc')) }) it('respects Retry-After header when polling', async () => { const setTimeoutDelays: number[] = [] let fetchCallCount = 0 + const globalInfo = jest.fn() const context = createMockContext({ + globalInfo, publish: async () => { if (fetchCallCount === 0) { throw Object.assign(new Error('otp'), { @@ -197,7 +211,7 @@ describe('publishWithOtpHandling', () => { setTimeoutDelays.push(ms) cb() }, - fetch: async (): Promise => { + fetch: async (): Promise => { fetchCallCount++ if (fetchCallCount === 1) { return { @@ -220,12 +234,15 @@ describe('publishWithOtpHandling', () => { // second is the additional delay (5s Retry-After minus the 1s already waited), // third is the default 1s poll interval for the next iteration. expect(setTimeoutDelays).toStrictEqual([1000, 4000, 1000]) + expect(globalInfo).toHaveBeenCalledWith(expect.stringContaining('https://registry.npmjs.org/auth/abc')) }) it('continues polling when fetch throws', async () => { let publishCallCount = 0 let fetchCallCount = 0 + const globalInfo = jest.fn() const context = createMockContext({ + globalInfo, publish: async (_m, _t, opts) => { publishCallCount++ if (publishCallCount === 1) { @@ -240,7 +257,7 @@ describe('publishWithOtpHandling', () => { expect(opts.otp).toBe('tok') return createOkResponse() }, - fetch: async (): Promise => { + fetch: async (): Promise => { fetchCallCount++ if (fetchCallCount === 1) { throw new Error('network failure') @@ -256,12 +273,15 @@ describe('publishWithOtpHandling', () => { const result = await publishWithOtpHandling({ context, manifest, publishOptions, tarballData }) expect(result.ok).toBe(true) expect(fetchCallCount).toBe(2) + expect(globalInfo).toHaveBeenCalledWith(expect.stringContaining('https://registry.npmjs.org/auth/abc')) }) it('continues polling when response is not ok', async () => { let publishCallCount = 0 let fetchCallCount = 0 + const globalInfo = jest.fn() const context = createMockContext({ + globalInfo, publish: async (_m, _t, opts) => { publishCallCount++ if (publishCallCount === 1) { @@ -276,7 +296,7 @@ describe('publishWithOtpHandling', () => { expect(opts.otp).toBe('tok') return createOkResponse() }, - fetch: async (): Promise => { + fetch: async (): Promise => { fetchCallCount++ if (fetchCallCount === 1) { return { @@ -297,12 +317,15 @@ describe('publishWithOtpHandling', () => { const result = await publishWithOtpHandling({ context, manifest, publishOptions, tarballData }) expect(result.ok).toBe(true) expect(fetchCallCount).toBe(2) + expect(globalInfo).toHaveBeenCalledWith(expect.stringContaining('https://registry.npmjs.org/auth/abc')) }) it('continues polling when response.json() throws', async () => { let publishCallCount = 0 let fetchCallCount = 0 + const globalInfo = jest.fn() const context = createMockContext({ + globalInfo, publish: async (_m, _t, opts) => { publishCallCount++ if (publishCallCount === 1) { @@ -317,7 +340,7 @@ describe('publishWithOtpHandling', () => { expect(opts.otp).toBe('tok') return createOkResponse() }, - fetch: async (): Promise => { + fetch: async (): Promise => { fetchCallCount++ if (fetchCallCount === 1) { return { @@ -340,11 +363,14 @@ describe('publishWithOtpHandling', () => { const result = await publishWithOtpHandling({ context, manifest, publishOptions, tarballData }) expect(result.ok).toBe(true) expect(fetchCallCount).toBe(2) + expect(globalInfo).toHaveBeenCalledWith(expect.stringContaining('https://registry.npmjs.org/auth/abc')) }) - it('throws OtpWebAuthTimeoutError after 5 minutes', async () => { + it('throws WebAuthTimeoutError after 5 minutes', async () => { let time = 0 + const globalInfo = jest.fn() const context = createMockContext({ + globalInfo, Date: { now: () => time }, publish: async () => { throw Object.assign(new Error('otp'), { @@ -359,7 +385,7 @@ describe('publishWithOtpHandling', () => { time += 6 * 60 * 1000 // Jump past timeout cb() }, - fetch: async (): Promise => ({ + fetch: async (): Promise => ({ headers: { get: () => null }, json: async () => ({}), ok: true, @@ -367,7 +393,8 @@ describe('publishWithOtpHandling', () => { }), }) await expect(publishWithOtpHandling({ context, manifest, publishOptions, tarballData })) - .rejects.toBeInstanceOf(OtpWebAuthTimeoutError) + .rejects.toBeInstanceOf(WebAuthTimeoutError) + expect(globalInfo).toHaveBeenCalledWith(expect.stringContaining('https://registry.npmjs.org/auth/abc')) }) }) }) diff --git a/releasing/commands/tsconfig.json b/releasing/commands/tsconfig.json index 90927814fd..d4a61d3699 100644 --- a/releasing/commands/tsconfig.json +++ b/releasing/commands/tsconfig.json @@ -96,6 +96,9 @@ { "path": "../../network/git-utils" }, + { + "path": "../../network/web-auth" + }, { "path": "../../resolving/resolver-base" },