Files
pnpm/network/fetch/src/fetchFromRegistry.ts
Zoltan Kochan b1fa2d5979 fix(dist-tag): support npm's web-based 2FA flow (#11998)
* fix(dist-tag): open the browser for npm web 2FA when --otp is absent

Without `--otp`, `pnpm dist-tag add` (and `rm`) failed against
npmjs.org with `[ERR_PNPM_UNAUTHORIZED] You must be logged in to set
dist-tag … "You must provide a one-time pass. Upgrade your client to
npm@latest in order to use 2FA."` — the browser never opened. The
fallback "upgrade your client" message is what npmjs.org returns when
the client doesn't announce `npm-auth-type: web`; without that header
the server skips the web challenge and tells the user to install a
newer npm. `--otp=<6-digit code>` already worked because the OTP went
out in `npm-otp` directly.

Send `npm-auth-type: web` on dist-tag writes when no `--otp` is
given, surface 401 responses carrying `authUrl`/`doneUrl` (or the
legacy "one-time pass" text) as `SyntheticOtpError`, and wrap the
call in the existing `withOtpHandling` helper (already used by
`pnpm publish`), which opens the browser, polls the done URL, and
retries with the resulting token as `npm-otp` while keeping
`npm-auth-type: web` in place.

Drive-by cleanup in `@pnpm/network.fetch`: the abbreviated-metadata
`Accept` header is no longer attached to non-GET requests, matching
`npm-registry-fetch`'s behavior.

* fix(dist-tag): default authType to 'web'; inherit network config in OTP context

Two review fixes:

- `setDistTag` documented `authType` as defaulting to `'web'` but only
  sent the `npm-auth-type` header when the field was explicitly passed.
  Always send the header, defaulting to `'web'`.

- `OTP_CONTEXT` used a module-level `createFetchFromRegistry({})`, so
  the `withOtpHandling` doneUrl poll ignored the command's proxy / TLS
  / `configByUri` config. Build the OTP context per call from the
  command's `opts` instead.

Also rename `toOtpOrUnauthorizedError` → `parseAuthError` and drop the
spurious `async` (the body string is already awaited at the call site).
2026-05-28 12:24:29 +02:00

177 lines
5.8 KiB
TypeScript

import { URL } from 'node:url'
import type { FetchFromRegistry } from '@pnpm/fetching.types'
import type { RegistryConfig } from '@pnpm/types'
import { type ClientCertificates, type DispatcherOptions, getDispatcher } from './dispatcher.js'
import { fetch, isRedirect, type RequestInit } from './fetch.js'
const USER_AGENT = 'pnpm' // or maybe make it `${pkg.name}/${pkg.version} (+https://npm.im/${pkg.name})`
const FULL_DOC = 'application/json'
const ACCEPT_FULL_DOC = `${FULL_DOC}; q=1.0, */*`
const ABBREVIATED_DOC = 'application/vnd.npm.install-v1+json'
const ACCEPT_ABBREVIATED_DOC = `${ABBREVIATED_DOC}; q=1.0, ${FULL_DOC}; q=0.8, */*`
const MAX_FOLLOWED_REDIRECTS = 20
export interface FetchWithDispatcherOptions extends RequestInit {
dispatcherOptions: DispatcherOptions
}
export function fetchWithDispatcher (url: string | URL, opts: FetchWithDispatcherOptions): Promise<Response> {
const dispatcher = getDispatcher(url.toString(), {
...opts.dispatcherOptions,
strictSsl: opts.dispatcherOptions.strictSsl ?? true,
})
return fetch(url, {
...opts,
dispatcher,
})
}
export interface CreateDispatchedFetchOptions extends DispatcherOptions {
/**
* Per-registry config (TLS, auth, etc.). When set, the matching TLS entries
* are automatically extracted into `clientCertificates` so callers don't
* have to do it themselves.
*/
configByUri?: Record<string, RegistryConfig>
}
/**
* Returns a {@link fetch} pre-bound to the given dispatcher options, so callers
* that need a fetch function (rather than a one-shot call) can route their
* requests through the configured proxy / TLS / local-address settings.
*/
export function createDispatchedFetch (opts: CreateDispatchedFetchOptions): (url: string | URL, opts?: RequestInit) => Promise<Response> {
const dispatcherOptions: DispatcherOptions = {
...opts,
clientCertificates: opts.clientCertificates ?? extractTlsConfigs(opts.configByUri),
}
return (url, fetchOpts) => fetchWithDispatcher(url, { ...fetchOpts, dispatcherOptions })
}
export type { DispatcherOptions }
export interface CreateFetchFromRegistryOptions extends DispatcherOptions {
userAgent?: string
configByUri?: Record<string, RegistryConfig>
}
export function createFetchFromRegistry (defaultOpts: CreateFetchFromRegistryOptions): FetchFromRegistry {
const clientCertificates = extractTlsConfigs(defaultOpts.configByUri)
return async (url, opts): Promise<Response> => {
const headers: Record<string, string> = {
'user-agent': USER_AGENT,
...getHeaders({
auth: opts?.authHeaderValue,
fullMetadata: opts?.fullMetadata,
method: opts?.method,
userAgent: defaultOpts.userAgent,
}),
}
if (opts?.ifNoneMatch) {
headers['if-none-match'] = opts.ifNoneMatch
}
if (opts?.ifModifiedSince) {
headers['if-modified-since'] = opts.ifModifiedSince
}
// Merge caller-provided headers (e.g. content-type, npm-otp) on top
if (opts?.headers) {
const optsHeaders = opts.headers instanceof Headers
? Object.fromEntries(opts.headers.entries())
: Array.isArray(opts.headers)
? Object.fromEntries(opts.headers)
: opts.headers
Object.assign(headers, optsHeaders)
}
let redirects = 0
let urlObject = new URL(url)
const originalHost = urlObject.host
/* eslint-disable no-await-in-loop */
while (true) {
const dispatcherOptions: DispatcherOptions = {
...defaultOpts,
...opts,
strictSsl: defaultOpts.strictSsl ?? true,
clientCertificates,
}
const response = await fetchWithDispatcher(urlObject, {
dispatcherOptions,
body: opts?.body,
// if verifying integrity, native fetch must not decompress
headers,
method: opts?.method,
redirect: 'manual',
retry: opts?.retry,
timeout: opts?.timeout ?? 60000,
})
if (!isRedirect(response.status) || redirects >= MAX_FOLLOWED_REDIRECTS) {
return response
}
redirects++
// This is a workaround to remove authorization headers on redirect.
// Related pnpm issue: https://github.com/pnpm/pnpm/issues/1815
urlObject = resolveRedirectUrl(response, urlObject)
if (!headers['authorization'] || originalHost === urlObject.host) continue
delete headers.authorization
}
/* eslint-enable no-await-in-loop */
}
}
interface Headers {
accept?: string
authorization?: string
'user-agent'?: string
}
function getHeaders (
opts: {
auth?: string
fullMetadata?: boolean
method?: string
userAgent?: string
}
): Headers {
const headers: Headers = {}
// The abbreviated/full-metadata Accept header is meaningful only on package
// metadata reads. Setting it on writes (PUT/POST/DELETE) breaks npmjs.org's
// dist-tag endpoint, which rejects the request with a generic 400.
if (!opts.method || opts.method === 'GET' || opts.method === 'HEAD') {
headers.accept = opts.fullMetadata === true ? ACCEPT_FULL_DOC : ACCEPT_ABBREVIATED_DOC
}
if (opts.auth) {
headers['authorization'] = opts.auth
}
if (opts.userAgent) {
headers['user-agent'] = opts.userAgent
}
return headers
}
function extractTlsConfigs (configByUri?: Record<string, RegistryConfig>): ClientCertificates | undefined {
if (!configByUri) return undefined
let result: ClientCertificates | undefined
for (const [uri, config] of Object.entries(configByUri)) {
if (config.tls) {
result ??= {}
result[uri] = config.tls
}
}
return result
}
function resolveRedirectUrl (response: Response, currentUrl: URL): URL {
const location = response.headers.get('location')
if (!location) {
throw new Error(`Redirect location header missing for ${currentUrl.toString()}`)
}
return new URL(location, currentUrl)
}