mirror of
https://github.com/pnpm/pnpm.git
synced 2026-04-27 18:46:18 -04:00
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 commitf91efc1d9e. * chore(git): revert would-be irrelevant change This reverts commit646c09cc66. * chore(git): revert an imperfect fix This reverts commit45ff1ca601. * 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:
6
.changeset/extract-otp-handling.md
Normal file
6
.changeset/extract-otp-handling.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@pnpm/network.web-auth": major
|
||||
"@pnpm/releasing.commands": patch
|
||||
---
|
||||
|
||||
Create `@pnpm/network.web-auth`.
|
||||
6
.changeset/implement-pnpm-login.md
Normal file
6
.changeset/implement-pnpm-login.md
Normal 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
1
.gitignore
vendored
@@ -18,6 +18,7 @@ _docpress
|
||||
lib
|
||||
dist
|
||||
tsconfig.tsbuildinfo
|
||||
tsconfig.lint.tsbuildinfo
|
||||
test.lib/
|
||||
|
||||
# Visual Studio Code configs
|
||||
|
||||
60
auth/commands/package.json
Normal file
60
auth/commands/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
3
auth/commands/src/index.ts
Normal file
3
auth/commands/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import * as login from './login.js'
|
||||
|
||||
export { login }
|
||||
401
auth/commands/src/login.ts
Normal file
401
auth/commands/src/login.ts
Normal 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
|
||||
}
|
||||
}
|
||||
564
auth/commands/test/login.test.ts
Normal file
564
auth/commands/test/login.test.ts
Normal 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')]])
|
||||
})
|
||||
})
|
||||
18
auth/commands/test/tsconfig.json
Normal file
18
auth/commands/test/tsconfig.json
Normal 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": ".."
|
||||
}
|
||||
]
|
||||
}
|
||||
31
auth/commands/tsconfig.json
Normal file
31
auth/commands/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
8
auth/commands/tsconfig.lint.json
Normal file
8
auth/commands/tsconfig.lint.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"test/**/*.ts",
|
||||
"../../__typings__/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
49
network/web-auth/package.json
Normal file
49
network/web-auth/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
15
network/web-auth/src/WebAuthTimeoutError.ts
Normal file
15
network/web-auth/src/WebAuthTimeoutError.ts
Normal 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
|
||||
}
|
||||
}
|
||||
11
network/web-auth/src/generateQrCode.ts
Normal file
11
network/web-auth/src/generateQrCode.ts
Normal 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')
|
||||
}
|
||||
22
network/web-auth/src/index.ts
Normal file
22
network/web-auth/src/index.ts
Normal 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'
|
||||
108
network/web-auth/src/pollForWebAuthToken.ts
Normal file
108
network/web-auth/src/pollForWebAuthToken.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
183
network/web-auth/src/withOtpHandling.ts
Normal file
183
network/web-auth/src/withOtpHandling.ts
Normal 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.',
|
||||
})
|
||||
}
|
||||
}
|
||||
23
network/web-auth/test/WebAuthTimeoutError.test.ts
Normal file
23
network/web-auth/test/WebAuthTimeoutError.test.ts
Normal 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/)
|
||||
})
|
||||
})
|
||||
15
network/web-auth/test/generateQrCode.test.ts
Normal file
15
network/web-auth/test/generateQrCode.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
500
network/web-auth/test/pollForWebAuthToken.test.ts
Normal file
500
network/web-auth/test/pollForWebAuthToken.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
18
network/web-auth/test/tsconfig.json
Normal file
18
network/web-auth/test/tsconfig.json
Normal 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": ".."
|
||||
}
|
||||
]
|
||||
}
|
||||
389
network/web-auth/test/withOtpHandling.test.ts
Normal file
389
network/web-auth/test/withOtpHandling.test.ts
Normal 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({})
|
||||
})
|
||||
})
|
||||
16
network/web-auth/tsconfig.json
Normal file
16
network/web-auth/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "@pnpm/tsconfig",
|
||||
"compilerOptions": {
|
||||
"outDir": "lib",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"../../__typings__/**/*.d.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../core/error"
|
||||
}
|
||||
]
|
||||
}
|
||||
8
network/web-auth/tsconfig.lint.json
Normal file
8
network/web-auth/tsconfig.lint.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"test/**/*.ts",
|
||||
"../../__typings__/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
74
pnpm-lock.yaml
generated
74
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -4,6 +4,7 @@ packages:
|
||||
- __typings__
|
||||
- __utils__/*
|
||||
- '!__utils__/build-artifacts'
|
||||
- auth/*
|
||||
- building/*
|
||||
- cache/*
|
||||
- catalogs/*
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
{
|
||||
"path": "../__utils__/test-ipc-server"
|
||||
},
|
||||
{
|
||||
"path": "../auth/commands"
|
||||
},
|
||||
{
|
||||
"path": "../building/commands"
|
||||
},
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -96,6 +96,9 @@
|
||||
{
|
||||
"path": "../../network/git-utils"
|
||||
},
|
||||
{
|
||||
"path": "../../network/web-auth"
|
||||
},
|
||||
{
|
||||
"path": "../../resolving/resolver-base"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user