feat: pnpm login (#11094)

* refactor: extract web auth QR code and polling into @pnpm/network.web-auth

Extract generateQrCode() and pollForWebAuthToken() from releasing/commands
into a new shared package so that both `pnpm publish` and the upcoming
`pnpm login` can reuse the web-based authentication flow with QR code
display and doneUrl polling.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* feat: implement `pnpm login` command

Add `pnpm login` (and `pnpm adduser` alias) for authenticating with npm
registries. The command:

- Tries web-based login first (POST /-/v1/login), displaying a QR code
  and polling for the token using @pnpm/network.web-auth
- Falls back to classic username/password/email login (PUT /-/user/
  org.couchdb.user:<username>) when web login is not supported (404/405)
- Saves the received auth token to the user's global rc file

Also fixes a tsgo build issue in releasing/commands where
OtpWebAuthFetchOptions was used as a local type alias but was only
available as a re-exported name.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* fix: resolve spellcheck issues in login test

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* fix: correct alphabetical ordering for meta-updater

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* chore: add meta-updater generated tsconfig files

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* fix: add explicit return type to prompt mock for tsgo compatibility

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* fix: use @pnpm/network.fetch instead of globalThis.fetch

Switch from globalThis.fetch to fetchWithAgent from @pnpm/network.fetch
so that pnpm login respects proxy settings (httpProxy/httpsProxy/noProxy),
custom SSL certificates (ca/cert/key), strictSsl, and retry configuration.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* refactor: improve login fetch types and use URL constructor

- Type LoginContext.fetch using WebAuthFetchOptions/WebAuthFetchResponse
  from @pnpm/network.web-auth, extended with text() and wider method
- Replace regex-based URL construction with new URL() constructor
- Remove redundant LoginFetchInit type

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* refactor: match publish pattern for dependency injection

- Static DEFAULT_CONTEXT constant instead of createDefaultContext factory
- context = DEFAULT_CONTEXT default parameter instead of context?: Partial
- Destructure context in function signatures for natural calling
- Use plain fetch from @pnpm/network.fetch (like SHARED_CONTEXT in publish)
- Context contains only side-effect functions and modules, not config

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* refactor: use typeof fetch instead of custom fetch types

Remove LoginFetchOptions and LoginFetchResponse. Type LoginContext.fetch
as typeof fetch from @pnpm/network.fetch directly, eliminating all casts.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* fix: remove placeholder username from login success message

Web login doesn't return a username, so just report the registry.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* refactor: use tempDir from @pnpm/prepare instead of manual tmp dirs

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* chore: update tsconfig references for @pnpm/prepare

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* refactor: inject readSettings/writeSettings for fully pure tests

Add readSettings and writeSettings to LoginContext so tests need no
filesystem side effects. Remove @pnpm/prepare devDependency.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* refactor: remove DEFAULT_CONTEXT from tests, use pure test context

Tests now construct their own TEST_CONTEXT with all no-op mocks,
eliminating any reliance on real side-effectful functions.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* test: use distinct opts per test, assert URLs and config paths

Each test now uses a different registry and configDir to verify URL
construction, config key generation, and save path are correct for
non-default options.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* test: throw on unexpected mock calls instead of silent fallbacks

All mock functions in TEST_CONTEXT now throw on unexpected calls,
ensuring tests fail loudly if the code makes unanticipated side effects.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* test: use IANA-reserved example.com domains in test URLs

Replace custom.registry.io and private.reg.co with example.com and
example.org (RFC 2606 reserved) to prevent domain squatting risks.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* test: use deterministic Date mock instead of native Date

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* test: assert globalInfo calls, throw on unexpected ones

Default globalInfo in TEST_CONTEXT now throws. Each test overrides it
to capture messages and asserts the expected output.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* fix: use inferred type for fetch url parameter in tests

Drop explicit `string` annotation so the parameter matches the
`RequestInfo` type expected by the fetch signature.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* fix: resolve type errors in login test mock fetch

Use mockResponse helper with `as any` cast to satisfy the Response
type, and String(url) for RequestInfo-to-string conversion.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* chore: add tsconfig.lint.tsbuildinfo to .gitignore

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* refactor: replace typeof fetch with explicit LoginFetchResponse/LoginFetchOptions types

Derive the fetch signature from actual call-site usage instead of
coupling to the concrete @pnpm/network.fetch type. This lets test
mocks return plain objects without casts.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* chore: gitignore generated pn/pnpx/pnx artifacts

These files are created by setup.js during preinstall and should not
be tracked.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* refactor: remove unnecessary backwards-compat aliases from otp.ts

Remove Otp-prefixed re-exports (OtpWebAuthFetchOptions,
OtpWebAuthFetchResponse, OtpWebAuthTimeoutError) that only existed as
backwards-compatibility shims. Update the test to import directly from
@pnpm/network.web-auth. Restore the named OtpDate interface that was
unnecessarily inlined.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* test(web-auth): add comprehensive unit tests for @pnpm/network.web-auth

Add dependency-injected unit tests covering:
- WebAuthTimeoutError: properties, code, hint, message
- generateQrCode: basic output and input differentiation
- pollForWebAuthToken: happy path, fetch argument passing,
  Retry-After handling (valid, non-finite, null, sub-interval,
  capped to remaining timeout, timeout during retry wait),
  error recovery (fetch throws, non-ok response, json parse error,
  missing token, empty token, multiple consecutive errors),
  custom timeout, poll interval timing

All tests use fake Date.now() and setTimeout — no real timers or
side effects.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* fix(web-auth): fix TS2339 compile errors in test assertions

Replace `.catch((e: WebAuthTimeoutError) => e)` pattern with
`rejects.toMatchObject()` to avoid `string | WebAuthTimeoutError`
union type issue when accessing `.timeout` property.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* feat(web-auth,login): extract shared OTP handling and add OTP support to login

- Create `withOtpHandling<T>()` in `@pnpm/network.web-auth` that wraps
  any operation with EOTP challenge detection, web auth flow, and
  classic OTP prompting.
- Refactor `publishWithOtpHandling` to delegate to the shared function.
- Add OTP handling to `pnpm login`'s classic (CouchDB) login flow:
  detects 401 + `www-authenticate: otp` header and retries with the
  OTP code (or web auth token) in the `npm-otp` header.
- Remove overly strict `this: this` constraints from WebAuthFetchResponse
  interfaces to improve cross-package type compatibility.
- Add 13 unit tests for `withOtpHandling` (classic + webauth flows).
- Add 4 login OTP tests (classic OTP, webauth OTP, non-401, non-otp 401).

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* fix(login): use word-boundary regex for URL assertion in test

Replace `m.includes(url)` with a regex that checks the URL is
bounded by whitespace or string boundaries, addressing the CodeQL
"incomplete URL substring sanitization" finding.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* refactor(login): use toContainEqual + stringMatching for URL assertion

Replace manual `.some()` with Jest's `toContainEqual(expect.stringMatching(...))`
for better error messages on failure.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* refactor(web-auth): use expect.any(String) instead of typeof check

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* refactor(web-auth): consolidate multi-property assertions

Use toMatchObject and toEqual instead of separate per-property expects.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* docs: explain why npm-auth-type header is sent unconditionally

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* refactor: remove unused re-exports and add missing test coverage

Remove dead re-exports of OtpHandlingPromptOptions and
OtpHandlingPromptResponse from releasing/commands/src/publish/otp.ts.

Add tests for:
- LOGIN_MISSING_CREDENTIALS (empty username in classic login)
- LOGIN_NO_TOKEN (registry returns success without token)
- LOGIN_INVALID_RESPONSE (web login returns incomplete response)
- isWebLoginNotSupported with 405 status code

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* refactor(login): rename readSettings/writeSettings to safeReadIniFile/writeIniFile

Use the actual function names in the LoginContext interface instead of
abstract names, matching the implementations they wrap.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* refactor(otp): remove unnecessary re-exports from otp.ts

OtpNonInteractiveError, OtpSecondChallengeError, and OtpHandlingEnquirer
were re-exported only for the test file, which can import them directly
from @pnpm/network.web-auth.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* refactor(otp): remove unused SHARED_CONTEXT re-export

All consumers already import SHARED_CONTEXT directly from
./utils/shared-context.js, making this re-export dead code.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* refactor(login): extract LoginDate and LoginEnquirer interfaces

Extract named interfaces for the Date and enquirer members of
LoginContext instead of inlining their types.

https://claude.ai/code/session_01YHYqGAAmZ1a9XMWoV7nG4S

* refactor: stop renaming

Claude Code Web didn't rename them thoroughly, so I had to do it myself

* docs: correct the lines

Why did Claude Code Web misaligned?

* refactor: strictly type `LoginFetchOptions.headers`

* docs: remove redundant comments

* refactor: inline `npm-otp`

* refactor: inline `headers`

* feat: add `WebLoginError.responseText`

* refactor: rename `statusCode` into `httpStatus`

* refactor(login): extract ClassicLoginError subclass from PnpmError

Extract the LOGIN_FAILED error into a dedicated ClassicLoginError class
with httpStatus and responseText properties, matching the WebLoginError
pattern.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* refactor: remove unnecessary import

* docs(changeset): correct a changeset

* docs(changeset): re-add `releasing.commands`

* refactor(web-auth): split monolithic test file into per-module files

Split index.test.ts into four files matching the source structure:
- WebAuthTimeoutError.test.ts
- generateQrCode.test.ts
- pollForWebAuthToken.test.ts
- withOtpHandling.test.ts

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* refactor: remove unnecessary `as const`

* refactor: remove unnecessary `as const`

* chore: undo Claude's BS

* refactor: extract `LoginEnquirerOptions`

* refactor: move types closer to their usesites

* refactor: remove simple type alias

* fix: type errors

* refactor(login): inject readIniFile instead of safeReadIniFile in context

The context object should only contain external dependencies. safeReadIniFile
is a local wrapper, not an external dependency, so inject readIniFile (from
read-ini-file) instead and pass it to safeReadIniFile as a parameter.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* test(login): add coverage for safeReadIniFile ENOENT handling

Test that login succeeds with empty settings when the config file does
not exist (ENOENT), and that non-ENOENT errors (e.g. EACCES) are
properly propagated.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* refactor: fix ugliness

* refactor: just pass context object

* refactor: destructure `context`

* refactor: pass the `context` object

* refactor: destructure `context`

* refactor: pass `context` object directly

* refactor: remove unnecessary parenthesis

* fix: remove unused import

* refactor: remove unnecessary parentheses from single-param arrows in tests

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* refactor: extract `LoginFetchResponseHeaders`

* fix(login): remove inline default from --registry option description

No other pnpm command includes "(default: ...)" in option descriptions.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* refactor(tests): enforce realistic mock response behavior

- Add createMockResponse helpers that enforce single body consumption
  (calling text() or json() twice, or both, throws an error)
- Default headers.get to throwing on unexpected calls, forcing tests
  to explicitly provide headers when the code under test reads them
- Replace all inline response objects with createMockResponse calls

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* fix: formatting

* refactor: reuse

* docs: clarify what the error is actually about

* docs: consistent error message

* refactor: use consistent error message convention in test mocks

Capitalize and use "Unexpected call to <thing>" pattern instead of
AI-generated "unexpected X call" messages.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* refactor: expand inline process mock objects to multi-line

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* refactor(login): extract PnpmError subclasses and use stricter test assertions

Extract LoginNonInteractiveError, LoginInvalidResponseError,
LoginMissingCredentialsError, and LoginNoTokenError subclasses instead
of throwing PnpmError directly.

Update test assertions to use the const promise pattern with
toHaveProperty checks on both code and message, matching the
convention used elsewhere in the codebase.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* refactor: undo ai's nonsensical deletion

* refactor: simplify

* refactor: rename OtpHandling* types to Otp* for brevity

OtpHandlingContext → OtpContext
OtpHandlingEnquirer → OtpEnquirer
OtpHandlingPromptOptions → OtpPromptOptions
OtpHandlingPromptResponse → OtpPromptResponse

The OtpHandling prefix was named after the function (withOtpHandling)
rather than the domain concept.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* refactor: extract `OtpDate`

* refactor: reuse

* fix: eslint

* refactor: add OtpRequiredError with body validation and globalWarn

- Add OtpRequiredError class with static fromUnknown() that validates
  the EOTP error body shape and returns either a validated error or an
  OtpBodyWarning when fields have unexpected types
- Add globalWarn to OtpContext so withOtpHandling can warn on bad body
  shapes instead of silently dropping them
- Update throwIfOtpRequired in login.ts to pass raw body through so
  validation happens in withOtpHandling via fromUnknown
- Add tests for bad body shapes (wrong types for authUrl/doneUrl)
- Add tests for OtpRequiredError.fromUnknown
- Propagate globalWarn through LoginContext, DEFAULT_CONTEXT,
  SHARED_CONTEXT, and all test mocks

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* docs: remove misleading comment from throwIfOtpRequired

The comment referenced downstream machinery (OtpRequiredError.fromUnknown)
that the reader shouldn't need to know about at this call site.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* refactor: replace Object.assign hack with OtpRequiredError in throwIfOtpRequired

throwIfOtpRequired now validates the raw response body via
OtpRequiredError.fromUnknown and throws a proper OtpRequiredError
instead of monkey-patching properties onto a plain Error.

withOtpHandling skips re-validation when the caught error is already
an OtpRequiredError instance.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* chore(git): revert an imperfect fix

This reverts commit f91efc1d9e.

* chore(git): revert would-be irrelevant change

This reverts commit 646c09cc66.

* chore(git): revert an imperfect fix

This reverts commit 45ff1ca601.

* refactor: replace Object.assign hack with ArtificialOtpError

Add ArtificialOtpError class that implements OtpError and validates
unknown body shapes via fromUnknownBody static method, warning on
unexpected types instead of silently dropping them.

Add globalWarn to OtpContext and propagate through LoginContext,
DEFAULT_CONTEXT, SHARED_CONTEXT, and all test mocks.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* refactor: rename ArtificialOtpError to SyntheticOtpError

"Synthetic" better conveys that the error is programmatically
constructed from raw data, not that it's fake.

Also fix grammatical error in JSDoc ("meant to thrown" → "meant to be thrown").

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* fix: eslint

Claude Code Web got it wrong this time

(or maybe because it inherited from my sketch diff? I'm not sure)

* fix: eslint

Ah! I got it. Claude Code Web was at fault here: It renamed "artificial"
to "synthetic" without re-ordering

Dumb AI!

* fix: formatting

Once again caused by Claude Code.

Anyway,

The exact equivalent refactor should have been `void warnings.push(msg)`,
if you really want to be pedantic, that is.

TypeScript, however, allows a `void` function to return any type. Reason
being that they shall all be discarded anyway.

* refactor: remove unnecessary re-assignment

* test: remove unnecessary assertion

* refactor: make default globalInfo and globalWarn mocks throw on unexpected calls

Replace no-op defaults with throwing mocks in createOtpMockContext
and createMockContext. Tests that expect these to be called now
explicitly override them.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* refactor: use toEqual with stringContaining for array assertions

Replace toHaveLength + indexed toContain pairs with single
toEqual([expect.stringContaining(...)]) assertions.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* refactor: replace globalInfo no-ops with jest.fn() and add assertions

For error tests: remove globalInfo override entirely, letting the
default throwing mock catch unexpected calls.

For success tests: use jest.fn() and assert globalInfo was called
with the expected arguments.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* refactor: replace manual array collectors with jest.fn()

Replace infoMessages/warnings arrays and push callbacks with
jest.fn() and assertions on .mock.calls. This is more idiomatic
and eliminates the boilerplate array + push pattern.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* refactor: replace remaining globalInfo no-ops with jest.fn() in otp.test.ts

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* fix(test): throw on unexpected second call instead of returning 'never'

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* fix(test): add missing globalInfo assertion in classic OTP test

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* fix(test): add missing globalInfo assertion in otp webauth polling test

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* fix(test): add @jest/globals import for jest.fn()

jest is not a global in ESM mode (--experimental-vm-modules).
Add import { jest } from '@jest/globals' to all test files using
jest.fn(), and add @jest/globals devDependency to network/web-auth.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* chore(deps): update lockfile

* fix: eslint

* fix(test): add globalInfo mock to EACCES readIniFile test

The test triggers web login (which calls globalInfo with the QR code)
before reaching readIniFile. Without a globalInfo override, the
default throwing mock causes the test to fail at the wrong point.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* fix(test): add missing globalInfo assertion in EACCES readIniFile test

Extract inline jest.fn() to const and assert it was called with
the web login QR code URL.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* refactor: convert functions with 3+ args to params objects

Per the style guide: "Functions should have no more than two or three
arguments. If a function needs more parameters, use a single options
object instead."

- withOtpHandling(operation, context, fetchOptions) → withOtpHandling({ operation, context, fetchOptions })
- pollForWebAuthToken(doneUrl, context, fetchOptions, timeoutMs) → pollForWebAuthToken({ doneUrl, context, fetchOptions, timeoutMs })
- webLogin(registry, fetchOptions, context) → webLogin({ registry, fetchOptions, context })
- classicLogin(registry, context, fetchOptions) → classicLogin({ registry, context, fetchOptions })

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* refactor: sort params object properties alphabetically

Sort interface properties, function signature destructuring, and
call site arguments in alphabetical order to match the convention
used by publishWithOtpHandling.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* refactor: adopt otp.test.ts patterns in login and web-auth tests

- Build context and opts as separate variables, then call login/
  withOtpHandling/pollForWebAuthToken on a clean line
- Add createMockContext to login.test.ts
- Convert createMockContext to arrow functions (single return
  expression), keep createMockResponse as function declaration
  (has local state)

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* fix: eslint

* refactor: inline the one-off function

* fix(login): avoid sending 'npm-otp: undefined' header on initial request

When otp is undefined (first attempt before OTP challenge), the header
'npm-otp': undefined could be coerced to the string "undefined" by
some HTTP implementations. Use conditional spread instead.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* docs(login): explain why npm-otp header is conditionally spread

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* docs(otp): explain why otp: undefined is safe in publishOptions spread

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* fix(test): use path.join in assertions for Windows compatibility

path.join produces backslashes on Windows, so hardcoded forward-slash
paths in assertions fail on Windows CI.

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

* fix: import order — standard library before external deps

https://claude.ai/code/session_0191GhgPWiD5TroLMoXAmkaZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Khải
2026-03-27 18:00:22 +07:00
committed by GitHub
parent b09ae0a9bc
commit d4a1d734b6
34 changed files with 2601 additions and 264 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/network.web-auth": major
"@pnpm/releasing.commands": patch
---
Create `@pnpm/network.web-auth`.

View File

@@ -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`.

1
.gitignore vendored
View File

@@ -18,6 +18,7 @@ _docpress
lib
dist
tsconfig.tsbuildinfo
tsconfig.lint.tsbuildinfo
test.lib/
# Visual Studio Code configs

View File

@@ -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"
}
}

View File

@@ -0,0 +1,3 @@
import * as login from './login.js'
export { login }

401
auth/commands/src/login.ts Normal file
View File

@@ -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<string, unknown> {
return { registry: allTypes.registry }
}
export function cliOptionsTypes (): Record<string, unknown> {
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>',
},
],
},
],
url: docsUrl('login'),
usages: ['pnpm login [--registry <url>]'],
})
}
export type LoginCommandOptions = Pick<Config,
| 'configDir'
| 'dir'
| 'fetchRetries'
| 'fetchRetryFactor'
| 'fetchRetryMaxtimeout'
| 'fetchRetryMintimeout'
| 'fetchTimeout'
| 'rawConfig'
> & {
registry?: string
}
export async function handler (
opts: LoginCommandOptions
): Promise<string> {
return login({ opts })
}
export interface LoginDate {
now: () => number
}
export interface LoginEnquirer {
prompt: (options: LoginEnquirerOptions) => Promise<Record<string, string>>
}
export interface LoginEnquirerOptions {
message: string
name: string
type: string
}
export interface LoginFetchResponse {
ok: boolean
status: number
json: () => Promise<unknown>
text: () => Promise<string>
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:
// <https://github.com/npm/npm-registry-fetch/blob/844230f/lib/index.js#L196-L198>
'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<LoginFetchResponse>
globalInfo: (message: string) => void
globalWarn: (message: string) => void
process: Record<'stdin' | 'stdout', { isTTY?: boolean }>
readIniFile: (configPath: string) => Promise<object>
writeIniFile: (configPath: string, settings: Record<string, unknown>) => Promise<void>
}
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<string> {
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<string, unknown>
const registryConfigKey = getRegistryConfigKey(registry)
settings[`${registryConfigKey}:_authToken`] = token
await writeIniFile(configPath, settings)
return `Logged in on ${registry}`
}
interface WebLoginParams {
context: Pick<LoginContext, 'Date' | 'setTimeout' | 'fetch' | 'globalInfo'>
fetchOptions: WebAuthFetchOptions
registry: string
}
async function webLogin ({
context,
fetchOptions,
registry,
}: WebLoginParams): Promise<string> {
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<LoginContext, 'Date' | 'setTimeout' | 'enquirer' | 'fetch' | 'globalInfo' | 'globalWarn' | 'process'>
fetchOptions: WebAuthFetchOptions
registry: string
}
async function classicLogin ({
context,
fetchOptions,
registry,
}: ClassicLoginParams): Promise<string> {
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<void> {
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<object> {
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
}
}

View File

@@ -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>): 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<string, unknown> = {}
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<string, unknown> = {}
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<Record<string, string>> => {
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<Record<string, string>> => {
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<Record<string, string>> => {
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<Record<string, string>> => {
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<Record<string, string>> => {
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<Record<string, string>> => {
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<string, unknown> = {}
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<Record<string, string>> => {
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<Record<string, string>> => {
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<string, unknown> = {}
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')]])
})
})

View File

@@ -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": ".."
}
]
}

View File

@@ -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"
}
]
}

View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"include": [
"src/**/*.ts",
"test/**/*.ts",
"../../__typings__/**/*.d.ts"
]
}

View File

@@ -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"
}
}

View File

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

View File

@@ -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')
}

View File

@@ -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'

View File

@@ -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<unknown>
readonly ok: boolean
readonly status: number
}
export interface WebAuthContext {
Date: { now: () => number }
setTimeout: (cb: () => void, ms: number) => void
fetch: (url: string, options: WebAuthFetchOptions) => Promise<WebAuthFetchResponse>
}
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<string> {
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<void>(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<void>(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
}
}
}

View File

@@ -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<OtpPromptResponse | undefined>
}
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<WebAuthFetchResponse>
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<T> {
context: OtpContext
fetchOptions: WebAuthFetchOptions
operation: (otp?: string) => Promise<T>
}
/**
* 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<T> ({
context,
fetchOptions,
operation,
}: OtpHandlingParams<T>): Promise<T> {
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.',
})
}
}

View File

@@ -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/)
})
})

View File

@@ -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)
})
})

View File

@@ -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>): 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<WebAuthFetchResponse> => {
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<WebAuthFetchResponse> => {
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<WebAuthFetchResponse> => {
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<WebAuthFetchResponse> => {
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<WebAuthFetchResponse> => {
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<WebAuthFetchResponse> => {
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<WebAuthFetchResponse> => 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<WebAuthFetchResponse> => {
// 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<WebAuthFetchResponse> => {
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<WebAuthFetchResponse> => {
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<WebAuthFetchResponse> => {
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<WebAuthFetchResponse> => {
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<WebAuthFetchResponse> => {
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<WebAuthFetchResponse> => 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<WebAuthFetchResponse> => 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<WebAuthFetchResponse> => {
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<WebAuthFetchResponse> => {
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<WebAuthFetchResponse> => {
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 })
})
})

View File

@@ -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": ".."
}
]
}

View File

@@ -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>): 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<WebAuthFetchResponse> => {
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<WebAuthFetchResponse> => 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({})
})
})

View File

@@ -0,0 +1,16 @@
{
"extends": "@pnpm/tsconfig",
"compilerOptions": {
"outDir": "lib",
"rootDir": "src"
},
"include": [
"src/**/*.ts",
"../../__typings__/**/*.d.ts"
],
"references": [
{
"path": "../../core/error"
}
]
}

View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"include": [
"src/**/*.ts",
"test/**/*.ts",
"../../__typings__/**/*.d.ts"
]
}

74
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -4,6 +4,7 @@ packages:
- __typings__
- __utils__/*
- '!__utils__/build-artifacts'
- auth/*
- building/*
- cache/*
- catalogs/*

View File

@@ -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:*",

View File

@@ -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,

View File

@@ -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',

View File

@@ -23,6 +23,9 @@
{
"path": "../__utils__/test-ipc-server"
},
{
"path": "../auth/commands"
},
{
"path": "../building/commands"
},

View File

@@ -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:",

View File

@@ -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<unknown>
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<string>
}
export interface OtpEnquirer {
prompt: (this: this, options: OtpEnquirerOptions) => Promise<OtpEnquirerResponse | undefined>
}
export interface OtpEnquirerOptions {
message: string
name: 'otp'
type: 'input'
}
export interface OtpEnquirerResponse {
otp?: string
}
export type OtpPublishFn = (
manifest: ExportedManifest,
tarballData: Buffer,
options: PublishOptions
) => Promise<OtpPublishResponse>
export interface OtpDate {
now: () => number
}
export interface OtpContext {
Date: OtpDate
setTimeout: (cb: () => void, ms: number) => void
enquirer: OtpEnquirer
fetch: (url: string, options: OtpWebAuthFetchOptions) => Promise<OtpWebAuthFetchResponse>
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<OtpPublishResponse> {
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<OtpContext, 'Date' | 'setTimeout' | 'fetch' | 'globalInfo'>,
fetchOptions: OtpWebAuthFetchOptions
): Promise<string> {
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<void>(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<void>(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.',
})
}
}

View File

@@ -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,

View File

@@ -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>): 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<OtpWebAuthFetchResponse> => {
fetch: async (): Promise<WebAuthFetchResponse> => {
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<OtpWebAuthFetchResponse> => {
fetch: async (): Promise<WebAuthFetchResponse> => {
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<OtpWebAuthFetchResponse> => {
fetch: async (): Promise<WebAuthFetchResponse> => {
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<OtpWebAuthFetchResponse> => {
fetch: async (): Promise<WebAuthFetchResponse> => {
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<OtpWebAuthFetchResponse> => {
fetch: async (): Promise<WebAuthFetchResponse> => {
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<OtpWebAuthFetchResponse> => ({
fetch: async (): Promise<WebAuthFetchResponse> => ({
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'))
})
})
})

View File

@@ -96,6 +96,9 @@
{
"path": "../../network/git-utils"
},
{
"path": "../../network/web-auth"
},
{
"path": "../../resolving/resolver-base"
},