mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-02 21:15:27 -04:00
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).
This commit is contained in:
8
.changeset/fix-dist-tag-web-otp.md
Normal file
8
.changeset/fix-dist-tag-web-otp.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
"@pnpm/registry-access.client": minor
|
||||
"@pnpm/registry-access.commands": patch
|
||||
"@pnpm/network.fetch": patch
|
||||
"pnpm": patch
|
||||
---
|
||||
|
||||
Fix `pnpm dist-tag add` and `pnpm dist-tag rm` against npmjs.org failing without `--otp` 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."`. pnpm now sends `npm-auth-type: web` on dist-tag writes and surfaces the resulting OTP challenge through the existing browser-based 2FA flow (the same `withOtpHandling` helper used by `pnpm publish`), so the browser opens, the user authenticates, and the dist-tag is set on retry. `--otp=<code>` continues to work via the classic flow.
|
||||
@@ -68,6 +68,7 @@ export function createFetchFromRegistry (defaultOpts: CreateFetchFromRegistryOpt
|
||||
...getHeaders({
|
||||
auth: opts?.authHeaderValue,
|
||||
fullMetadata: opts?.fullMetadata,
|
||||
method: opts?.method,
|
||||
userAgent: defaultOpts.userAgent,
|
||||
}),
|
||||
}
|
||||
@@ -125,7 +126,7 @@ export function createFetchFromRegistry (defaultOpts: CreateFetchFromRegistryOpt
|
||||
}
|
||||
|
||||
interface Headers {
|
||||
accept: string
|
||||
accept?: string
|
||||
authorization?: string
|
||||
'user-agent'?: string
|
||||
}
|
||||
@@ -134,11 +135,16 @@ function getHeaders (
|
||||
opts: {
|
||||
auth?: string
|
||||
fullMetadata?: boolean
|
||||
method?: string
|
||||
userAgent?: string
|
||||
}
|
||||
): Headers {
|
||||
const headers: { accept: string, authorization?: string, 'user-agent'?: string } = {
|
||||
accept: opts.fullMetadata === true ? ACCEPT_FULL_DOC : ACCEPT_ABBREVIATED_DOC,
|
||||
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
|
||||
|
||||
@@ -311,6 +311,52 @@ test('createDispatchedFetch returns a fetch bound to the given dispatcher option
|
||||
}
|
||||
})
|
||||
|
||||
test('abbreviated metadata Accept header is not sent on write requests', async () => {
|
||||
const receivedHeaders = await new Promise<http.IncomingHttpHeaders>((resolve, reject) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
resolve(req.headers)
|
||||
res.writeHead(200, { 'content-type': 'application/json' })
|
||||
res.end('{"ok":true}')
|
||||
})
|
||||
server.listen(0, () => {
|
||||
const { port } = server.address() as { port: number }
|
||||
const fetchFromRegistry = createFetchFromRegistry({})
|
||||
fetchFromRegistry(`http://127.0.0.1:${port}/-/package/pnpm/dist-tags/latest-10`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify('10.34.0'),
|
||||
}).then(
|
||||
(res) => res.text().then(() => server.close()),
|
||||
(err) => {
|
||||
server.close(); reject(err)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
expect(receivedHeaders.accept).not.toContain('application/vnd.npm.install-v1+json')
|
||||
})
|
||||
|
||||
test('abbreviated metadata Accept header is sent on GET requests', async () => {
|
||||
const receivedHeaders = await new Promise<http.IncomingHttpHeaders>((resolve, reject) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
resolve(req.headers)
|
||||
res.writeHead(200, { 'content-type': 'application/json' })
|
||||
res.end('{"ok":true}')
|
||||
})
|
||||
server.listen(0, () => {
|
||||
const { port } = server.address() as { port: number }
|
||||
const fetchFromRegistry = createFetchFromRegistry({})
|
||||
fetchFromRegistry(`http://127.0.0.1:${port}/test`).then(
|
||||
(res) => res.text().then(() => server.close()),
|
||||
(err) => {
|
||||
server.close(); reject(err)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
expect(receivedHeaders.accept).toBe('application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*')
|
||||
})
|
||||
|
||||
test('sec-fetch-* headers are stripped from requests', async () => {
|
||||
const receivedHeaders = await new Promise<http.IncomingHttpHeaders>((resolve, reject) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
|
||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@@ -8171,6 +8171,9 @@ importers:
|
||||
'@pnpm/network.fetch':
|
||||
specifier: workspace:*
|
||||
version: link:../../network/fetch
|
||||
'@pnpm/network.web-auth':
|
||||
specifier: workspace:*
|
||||
version: link:../../network/web-auth
|
||||
'@pnpm/npm-package-arg':
|
||||
specifier: 'catalog:'
|
||||
version: 2.0.0
|
||||
@@ -8199,6 +8202,9 @@ importers:
|
||||
'@pnpm/network.fetch':
|
||||
specifier: workspace:*
|
||||
version: link:../../network/fetch
|
||||
'@pnpm/network.web-auth':
|
||||
specifier: workspace:*
|
||||
version: link:../../network/web-auth
|
||||
'@pnpm/npm-package-arg':
|
||||
specifier: 'catalog:'
|
||||
version: 2.0.0
|
||||
@@ -8214,6 +8220,9 @@ importers:
|
||||
chalk:
|
||||
specifier: 'catalog:'
|
||||
version: 5.6.2
|
||||
enquirer:
|
||||
specifier: 'catalog:'
|
||||
version: 2.4.1
|
||||
ramda:
|
||||
specifier: 'catalog:'
|
||||
version: '@pnpm/ramda@0.28.1'
|
||||
@@ -8227,6 +8236,9 @@ importers:
|
||||
'@jest/globals':
|
||||
specifier: 'catalog:'
|
||||
version: 30.3.0
|
||||
'@pnpm/logger':
|
||||
specifier: workspace:*
|
||||
version: link:../../core/logger
|
||||
'@pnpm/prepare':
|
||||
specifier: workspace:*
|
||||
version: link:../../__utils__/prepare
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"dependencies": {
|
||||
"@pnpm/error": "workspace:*",
|
||||
"@pnpm/network.fetch": "workspace:*",
|
||||
"@pnpm/network.web-auth": "workspace:*",
|
||||
"@pnpm/npm-package-arg": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { PnpmError } from '@pnpm/error'
|
||||
import type { FetchFromRegistry } from '@pnpm/network.fetch'
|
||||
import { SyntheticOtpError } from '@pnpm/network.web-auth'
|
||||
import npa from '@pnpm/npm-package-arg'
|
||||
|
||||
export type AuthType = 'web' | 'legacy'
|
||||
|
||||
export interface SetDistTagOptions {
|
||||
packageName: string
|
||||
version: string
|
||||
@@ -9,6 +12,12 @@ export interface SetDistTagOptions {
|
||||
registryUrl: string
|
||||
fetchFromRegistry: FetchFromRegistry
|
||||
authHeader?: string
|
||||
/** Mirrors npm CLI's `auth-type` flat option: `'web'` (default) opts into the
|
||||
* web-OTP challenge, `'legacy'` is set automatically when the user passes
|
||||
* `--otp` so a classic 6-digit code can be sent. */
|
||||
authType?: AuthType
|
||||
/** OTP token to send as `npm-otp`. May be a classic 6-digit code (legacy) or
|
||||
* the 64-character token returned by the web flow. */
|
||||
otp?: string
|
||||
}
|
||||
|
||||
@@ -20,18 +29,41 @@ export async function setDistTag (opts: SetDistTagOptions): Promise<void> {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'npm-auth-type': opts.authType ?? 'web',
|
||||
...(opts.otp ? { 'npm-otp': opts.otp } : {}),
|
||||
},
|
||||
body: JSON.stringify(opts.version),
|
||||
})
|
||||
if (response.ok) return
|
||||
const body = await response.text()
|
||||
const action = `set dist-tag "${opts.distTag}" on`
|
||||
if (response.status === 401) {
|
||||
throw new PnpmError('UNAUTHORIZED', `You must be logged in to ${action} packages. ${body}`)
|
||||
throw parseAuthError(body, opts.distTag)
|
||||
}
|
||||
const action = `set dist-tag "${opts.distTag}" on`
|
||||
if (response.status === 403) {
|
||||
throw new PnpmError('FORBIDDEN', `You do not have permission to ${action} this package. ${body}`)
|
||||
}
|
||||
throw new PnpmError('REGISTRY_ERROR', `Failed to ${action} package: ${response.status} ${response.statusText}. ${body}`)
|
||||
}
|
||||
|
||||
function parseAuthError (body: string, distTag: string): Error {
|
||||
const parsed = tryParseJson(body)
|
||||
if (parsed != null && typeof parsed === 'object' && 'authUrl' in parsed && 'doneUrl' in parsed) {
|
||||
return new SyntheticOtpError({
|
||||
authUrl: typeof parsed.authUrl === 'string' ? parsed.authUrl : undefined,
|
||||
doneUrl: typeof parsed.doneUrl === 'string' ? parsed.doneUrl : undefined,
|
||||
})
|
||||
}
|
||||
if (/one-time pass/i.test(body)) {
|
||||
return new SyntheticOtpError(undefined)
|
||||
}
|
||||
return new PnpmError('UNAUTHORIZED', `You must be logged in to set dist-tag "${distTag}" on packages. ${body}`)
|
||||
}
|
||||
|
||||
function tryParseJson (body: string): unknown {
|
||||
try {
|
||||
return JSON.parse(body)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../network/fetch"
|
||||
},
|
||||
{
|
||||
"path": "../../network/web-auth"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -38,17 +38,23 @@
|
||||
"@pnpm/error": "workspace:*",
|
||||
"@pnpm/network.auth-header": "workspace:*",
|
||||
"@pnpm/network.fetch": "workspace:*",
|
||||
"@pnpm/network.web-auth": "workspace:*",
|
||||
"@pnpm/npm-package-arg": "catalog:",
|
||||
"@pnpm/registry-access.client": "workspace:*",
|
||||
"@pnpm/resolving.registry.types": "workspace:*",
|
||||
"@pnpm/types": "workspace:*",
|
||||
"chalk": "catalog:",
|
||||
"enquirer": "catalog:",
|
||||
"ramda": "catalog:",
|
||||
"render-help": "catalog:",
|
||||
"semver": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@pnpm/logger": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "catalog:",
|
||||
"@pnpm/logger": "workspace:*",
|
||||
"@pnpm/prepare": "workspace:*",
|
||||
"@pnpm/registry-access.commands": "workspace:*",
|
||||
"@pnpm/registry-mock": "catalog:",
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import readline from 'node:readline'
|
||||
|
||||
import { docsUrl } from '@pnpm/cli.utils'
|
||||
import { pickRegistryForPackage } from '@pnpm/config.pick-registry-for-package'
|
||||
import { PnpmError } from '@pnpm/error'
|
||||
import { globalInfo, globalWarn } from '@pnpm/logger'
|
||||
import { createGetAuthHeaderByURI } from '@pnpm/network.auth-header'
|
||||
import { createFetchFromRegistry, type CreateFetchFromRegistryOptions, type FetchFromRegistry } from '@pnpm/network.fetch'
|
||||
import {
|
||||
type OtpContext,
|
||||
SyntheticOtpError,
|
||||
type WebAuthFetchOptions,
|
||||
withOtpHandling,
|
||||
} from '@pnpm/network.web-auth'
|
||||
import npa from '@pnpm/npm-package-arg'
|
||||
import { setDistTag } from '@pnpm/registry-access.client'
|
||||
import type { Registries, RegistryConfig } from '@pnpm/types'
|
||||
import enquirer from 'enquirer'
|
||||
import { renderHelp } from 'render-help'
|
||||
import semver from 'semver'
|
||||
|
||||
@@ -140,16 +150,22 @@ async function distTagAdd (
|
||||
const registryUrl = pickRegistryForPackage(opts.registries ?? { default: 'https://registry.npmjs.org/' }, packageName)
|
||||
const authHeader = getAuthHeaderForRegistry(opts.configByUri, registryUrl)
|
||||
const fetchFromRegistry = createFetchFromRegistry(opts)
|
||||
const otp = opts.cliOptions?.otp
|
||||
const cliOtp = opts.cliOptions?.otp
|
||||
const authType = cliOtp ? 'legacy' : 'web'
|
||||
|
||||
await setDistTag({
|
||||
packageName,
|
||||
version,
|
||||
distTag: tag,
|
||||
registryUrl,
|
||||
authHeader,
|
||||
fetchFromRegistry,
|
||||
otp,
|
||||
await withOtpHandling({
|
||||
context: createOtpContext(opts),
|
||||
fetchOptions: WEB_AUTH_FETCH_OPTIONS,
|
||||
operation: (otp) => setDistTag({
|
||||
packageName,
|
||||
version,
|
||||
distTag: tag,
|
||||
registryUrl,
|
||||
authHeader,
|
||||
authType,
|
||||
fetchFromRegistry,
|
||||
otp: otp ?? cliOtp,
|
||||
}),
|
||||
})
|
||||
|
||||
return `+${tag}: ${packageName}@${version}`
|
||||
@@ -173,7 +189,7 @@ async function distTagRm (
|
||||
const registryUrl = pickRegistryForPackage(opts.registries ?? { default: 'https://registry.npmjs.org/' }, packageName)
|
||||
const authHeader = getAuthHeaderForRegistry(opts.configByUri, registryUrl)
|
||||
const fetchFromRegistry = createFetchFromRegistry(opts)
|
||||
const otp = opts.cliOptions?.otp
|
||||
const cliOtp = opts.cliOptions?.otp
|
||||
|
||||
// First check the tag exists
|
||||
const distTags = await fetchDistTags(packageName, registryUrl, fetchFromRegistry, authHeader)
|
||||
@@ -182,19 +198,79 @@ async function distTagRm (
|
||||
}
|
||||
|
||||
const distTagUrl = getDistTagUrl(packageName, registryUrl, tag)
|
||||
const authType: 'web' | 'legacy' = cliOtp ? 'legacy' : 'web'
|
||||
|
||||
await withOtpHandling({
|
||||
context: createOtpContext(opts),
|
||||
fetchOptions: WEB_AUTH_FETCH_OPTIONS,
|
||||
operation: (otp) => deleteDistTag({
|
||||
authHeader,
|
||||
authType,
|
||||
distTagUrl,
|
||||
fetchFromRegistry,
|
||||
otp: otp ?? cliOtp,
|
||||
tag,
|
||||
}),
|
||||
})
|
||||
|
||||
return `-${tag}: ${packageName}@${distTags[tag]}`
|
||||
}
|
||||
|
||||
interface DeleteDistTagParams {
|
||||
authHeader: string | undefined
|
||||
authType: 'web' | 'legacy'
|
||||
distTagUrl: string
|
||||
fetchFromRegistry: FetchFromRegistry
|
||||
otp: string | undefined
|
||||
tag: string
|
||||
}
|
||||
|
||||
async function deleteDistTag ({
|
||||
authHeader,
|
||||
authType,
|
||||
distTagUrl,
|
||||
fetchFromRegistry,
|
||||
otp,
|
||||
tag,
|
||||
}: DeleteDistTagParams): Promise<void> {
|
||||
const response = await fetchFromRegistry(distTagUrl, {
|
||||
authHeaderValue: authHeader,
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'npm-auth-type': authType,
|
||||
...(otp ? { 'npm-otp': otp } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
await throwRegistryError(response, `remove dist-tag "${tag}" from`)
|
||||
if (response.ok) return
|
||||
const body = await response.text()
|
||||
const action = `remove dist-tag "${tag}" from`
|
||||
if (response.status === 401) {
|
||||
throw parseAuthError(body, action)
|
||||
}
|
||||
if (response.status === 403) {
|
||||
throw new PnpmError('FORBIDDEN', `You do not have permission to ${action} this package. ${body}`)
|
||||
}
|
||||
throw new PnpmError('REGISTRY_ERROR', `Failed to ${action} package: ${response.status} ${response.statusText}. ${body}`)
|
||||
}
|
||||
|
||||
return `-${tag}: ${packageName}@${distTags[tag]}`
|
||||
function parseAuthError (body: string, action: string): Error {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(body)
|
||||
} catch {
|
||||
parsed = undefined
|
||||
}
|
||||
if (parsed != null && typeof parsed === 'object' && 'authUrl' in parsed && 'doneUrl' in parsed) {
|
||||
return new SyntheticOtpError({
|
||||
authUrl: typeof parsed.authUrl === 'string' ? parsed.authUrl : undefined,
|
||||
doneUrl: typeof parsed.doneUrl === 'string' ? parsed.doneUrl : undefined,
|
||||
})
|
||||
}
|
||||
if (/one-time pass/i.test(body)) {
|
||||
return new SyntheticOtpError(undefined)
|
||||
}
|
||||
return new PnpmError('UNAUTHORIZED', `You must be logged in to ${action} packages. ${body}`)
|
||||
}
|
||||
|
||||
function getAuthHeaderForRegistry (
|
||||
@@ -232,13 +308,22 @@ async function fetchDistTags (
|
||||
return await response.json() as Record<string, string>
|
||||
}
|
||||
|
||||
async function throwRegistryError (response: Response, action: string): Promise<never> {
|
||||
const errorBody = await response.text()
|
||||
if (response.status === 401) {
|
||||
throw new PnpmError('UNAUTHORIZED', `You must be logged in to ${action} packages. ${errorBody}`)
|
||||
}
|
||||
if (response.status === 403) {
|
||||
throw new PnpmError('FORBIDDEN', `You do not have permission to ${action} this package. ${errorBody}`)
|
||||
}
|
||||
throw new PnpmError('REGISTRY_ERROR', `Failed to ${action} package: ${response.status} ${response.statusText}. ${errorBody}`)
|
||||
const WEB_AUTH_FETCH_OPTIONS: WebAuthFetchOptions = {
|
||||
method: 'GET',
|
||||
}
|
||||
|
||||
// `withOtpHandling` polls `doneUrl` through `context.fetch`, so it must inherit
|
||||
// the command's proxy/TLS/`configByUri` config — otherwise the write succeeds
|
||||
// but the web-auth retry fails in custom-network environments.
|
||||
function createOtpContext (opts: CreateFetchFromRegistryOptions): OtpContext {
|
||||
return {
|
||||
Date,
|
||||
createReadlineInterface: readline.createInterface.bind(null, { input: process.stdin }),
|
||||
enquirer,
|
||||
fetch: createFetchFromRegistry(opts),
|
||||
globalInfo,
|
||||
globalWarn,
|
||||
process,
|
||||
setTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
150
registry-access/commands/test/setDistTag.ts
Normal file
150
registry-access/commands/test/setDistTag.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from '@jest/globals'
|
||||
import { createFetchFromRegistry } from '@pnpm/network.fetch'
|
||||
import { SyntheticOtpError } from '@pnpm/network.web-auth'
|
||||
import { setDistTag } from '@pnpm/registry-access.client'
|
||||
import { getMockAgent, setupMockAgent, teardownMockAgent } from '@pnpm/testing.mock-agent'
|
||||
|
||||
const REGISTRY_URL = 'https://registry.npmjs.org/'
|
||||
const PUT_PATH = /^\/-\/package\/pnpm\/dist-tags\/latest-10$/
|
||||
|
||||
describe('setDistTag', () => {
|
||||
beforeEach(async () => {
|
||||
await setupMockAgent()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownMockAgent()
|
||||
})
|
||||
|
||||
it('sends npm-auth-type: web when authType=web and no OTP yet', async () => {
|
||||
let capturedHeaders: Record<string, string | string[] | undefined> = {}
|
||||
getMockAgent().get('https://registry.npmjs.org').intercept({
|
||||
method: 'PUT',
|
||||
path: PUT_PATH,
|
||||
}).reply(({ headers }) => {
|
||||
capturedHeaders = headers as typeof capturedHeaders
|
||||
return { statusCode: 200, data: {} }
|
||||
})
|
||||
|
||||
await setDistTag({
|
||||
packageName: 'pnpm',
|
||||
version: '10.34.0',
|
||||
distTag: 'latest-10',
|
||||
registryUrl: REGISTRY_URL,
|
||||
fetchFromRegistry: createFetchFromRegistry({}),
|
||||
authType: 'web',
|
||||
})
|
||||
|
||||
expect(capturedHeaders['npm-auth-type']).toBe('web')
|
||||
expect(capturedHeaders['npm-otp']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps npm-auth-type: web alongside npm-otp on the web-flow retry', async () => {
|
||||
let capturedHeaders: Record<string, string | string[] | undefined> = {}
|
||||
getMockAgent().get('https://registry.npmjs.org').intercept({
|
||||
method: 'PUT',
|
||||
path: PUT_PATH,
|
||||
}).reply(({ headers }) => {
|
||||
capturedHeaders = headers as typeof capturedHeaders
|
||||
return { statusCode: 200, data: {} }
|
||||
})
|
||||
|
||||
await setDistTag({
|
||||
packageName: 'pnpm',
|
||||
version: '10.34.0',
|
||||
distTag: 'latest-10',
|
||||
registryUrl: REGISTRY_URL,
|
||||
fetchFromRegistry: createFetchFromRegistry({}),
|
||||
authType: 'web',
|
||||
otp: 'a'.repeat(64),
|
||||
})
|
||||
|
||||
expect(capturedHeaders['npm-auth-type']).toBe('web')
|
||||
expect(capturedHeaders['npm-otp']).toBe('a'.repeat(64))
|
||||
})
|
||||
|
||||
it('sends authType=legacy and npm-otp when the user passed --otp', async () => {
|
||||
let capturedHeaders: Record<string, string | string[] | undefined> = {}
|
||||
getMockAgent().get('https://registry.npmjs.org').intercept({
|
||||
method: 'PUT',
|
||||
path: PUT_PATH,
|
||||
}).reply(({ headers }) => {
|
||||
capturedHeaders = headers as typeof capturedHeaders
|
||||
return { statusCode: 200, data: {} }
|
||||
})
|
||||
|
||||
await setDistTag({
|
||||
packageName: 'pnpm',
|
||||
version: '10.34.0',
|
||||
distTag: 'latest-10',
|
||||
registryUrl: REGISTRY_URL,
|
||||
fetchFromRegistry: createFetchFromRegistry({}),
|
||||
authType: 'legacy',
|
||||
otp: '123456',
|
||||
})
|
||||
|
||||
expect(capturedHeaders['npm-auth-type']).toBe('legacy')
|
||||
expect(capturedHeaders['npm-otp']).toBe('123456')
|
||||
})
|
||||
|
||||
it('throws SyntheticOtpError carrying authUrl/doneUrl when the 401 body has them', async () => {
|
||||
getMockAgent().get('https://registry.npmjs.org').intercept({
|
||||
method: 'PUT',
|
||||
path: PUT_PATH,
|
||||
}).reply(401, {
|
||||
authUrl: 'https://www.npmjs.com/login?next=/-/v1/done?sessionId=abc',
|
||||
doneUrl: 'https://registry.npmjs.org/-/v1/done?sessionId=abc',
|
||||
})
|
||||
|
||||
let caught: unknown
|
||||
try {
|
||||
await setDistTag({
|
||||
packageName: 'pnpm',
|
||||
version: '10.34.0',
|
||||
distTag: 'latest-10',
|
||||
registryUrl: REGISTRY_URL,
|
||||
fetchFromRegistry: createFetchFromRegistry({}),
|
||||
})
|
||||
} catch (err) {
|
||||
caught = err
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SyntheticOtpError)
|
||||
expect((caught as SyntheticOtpError).body).toEqual({
|
||||
authUrl: 'https://www.npmjs.com/login?next=/-/v1/done?sessionId=abc',
|
||||
doneUrl: 'https://registry.npmjs.org/-/v1/done?sessionId=abc',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws SyntheticOtpError without body when the 401 mentions "one-time pass"', async () => {
|
||||
getMockAgent().get('https://registry.npmjs.org').intercept({
|
||||
method: 'PUT',
|
||||
path: PUT_PATH,
|
||||
}).reply(
|
||||
401,
|
||||
'"You must provide a one-time pass. Upgrade your client to npm@latest in order to use 2FA."'
|
||||
)
|
||||
|
||||
await expect(setDistTag({
|
||||
packageName: 'pnpm',
|
||||
version: '10.34.0',
|
||||
distTag: 'latest-10',
|
||||
registryUrl: REGISTRY_URL,
|
||||
fetchFromRegistry: createFetchFromRegistry({}),
|
||||
})).rejects.toBeInstanceOf(SyntheticOtpError)
|
||||
})
|
||||
|
||||
it('throws UNAUTHORIZED PnpmError on a 401 that is not an OTP challenge', async () => {
|
||||
getMockAgent().get('https://registry.npmjs.org').intercept({
|
||||
method: 'PUT',
|
||||
path: PUT_PATH,
|
||||
}).reply(401, 'Bad token')
|
||||
|
||||
await expect(setDistTag({
|
||||
packageName: 'pnpm',
|
||||
version: '10.34.0',
|
||||
distTag: 'latest-10',
|
||||
registryUrl: REGISTRY_URL,
|
||||
fetchFromRegistry: createFetchFromRegistry({}),
|
||||
})).rejects.toMatchObject({ code: 'ERR_PNPM_UNAUTHORIZED' })
|
||||
})
|
||||
})
|
||||
@@ -24,6 +24,9 @@
|
||||
{
|
||||
"path": "../../core/error"
|
||||
},
|
||||
{
|
||||
"path": "../../core/logger"
|
||||
},
|
||||
{
|
||||
"path": "../../core/types"
|
||||
},
|
||||
@@ -33,6 +36,9 @@
|
||||
{
|
||||
"path": "../../network/fetch"
|
||||
},
|
||||
{
|
||||
"path": "../../network/web-auth"
|
||||
},
|
||||
{
|
||||
"path": "../../releasing/commands"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user