Skip to content

fix: restore OTP and webauth flows broken by libnpmpublish migration#10834

Closed
Copilot wants to merge 3 commits into
mainfrom
copilot/resolve-issue-10591-comment
Closed

fix: restore OTP and webauth flows broken by libnpmpublish migration#10834
Copilot wants to merge 3 commits into
mainfrom
copilot/resolve-issue-10591-comment

Conversation

Copilot AI commented Mar 2, 2026

Copy link
Copy Markdown
Contributor

Switching from npm publish to libnpmpublish in #10591 silently dropped OTP handling — npm publish caught EOTP errors internally, but libnpmpublish throws them and pnpm had no handler.

Changes

  • src/otp.ts (new): Extracts all OTP handling into a dedicated module following the dependency injection pattern of oidc/{authToken,idToken,provenance}.ts:
    • OtpContext — interface with all injectable dependencies: publish (libnpmpublish), fetch (webauth polling with OtpWebAuthFetchOptions), enquirer (OTP input via OtpEnquirer), globalInfo, Date (timeout), setTimeout (polling interval), process (TTY check)
    • SHARED_CONTEXT — re-exported from src/utils/shared-context.ts (see below)
    • publishWithOtpHandling() — main function, context defaults to SHARED_CONTEXT:
      • Classic OTP (EOTP without authUrl/doneUrl): prompts the user via enquirer and retries by calling context.publish directly with the OTP
      • Webauth / modern OTP (EOTP with authUrl + doneUrl): prints the auth URL along with a scannable QR code (via qrcode-terminal), polls doneUrl at 1-second intervals with a 5-minute timeout (respecting Retry-After headers), then retries with the returned token
      • OTP handling is only triggered in interactive sessions (checked via context.process.stdin.isTTY && context.process.stdout.isTTY); throws OtpNonInteractiveError if not interactive
      • If the registry sends EOTP a second time after an OTP was already submitted, throws OtpSecondChallengeError with a user-facing message describing unexpected registry behavior
    • OtpWebAuthTimeoutError — exported error class with readonly endTime, startTime, and timeout fields
    • OtpNonInteractiveError — exported error class thrown when OTP is required but pnpm is not running in an interactive terminal
    • OtpSecondChallengeError — exported error class thrown when the registry unexpectedly requests OTP a second time
    • OtpEnquirer / OtpEnquirerOptions / OtpEnquirerResponse — injectable interfaces for the enquirer prompt dependency
    • OtpWebAuthFetchOptions — fetch options interface (method, retry, timeout) aligned with the OIDC fetch pattern
    • generateQrCode() — helper that synchronously generates a small QR code string via qrcode-terminal for display alongside the auth URL
  • src/utils/shared-context.ts (new, replaces src/oidc/utils/shared-context.ts): Merged shared context file combining OIDC and OTP real implementations. The SHARED_CONTEXT type is AuthTokenContext & IdTokenContext & ProvenanceContext & OtpContext. Adds enquirer, publish (with a PublishWithExportedManifest type alias bridging the outdated @types/libnpmpublish manifest type), and setTimeout to the existing OIDC dependencies. All OIDC files now import from this merged location.
  • src/oidc/utils/shared-context.ts (removed): Replaced by src/utils/shared-context.ts.
  • publishPackedPkg.ts: Imports publishWithOtpHandling from ./otp.js and calls it instead of publish directly. Removes the ManifestFromOutdatedDefinition cast (now handled by the type alias in shared-context.ts). Adds webauth-enabling publish options: headers (npm-auth-type: web, npm-command: publish), authType: 'web', and npmCommand: 'publish'.
  • cspell.json: Added eotp, qrcode, and webauth to the custom word list.
  • .changeset/tangy-pans-pull.md: Updated to document the new OTP prompt and QR code behaviors.
  • New dependencies: qrcode-terminal and @types/qrcode-terminal added to the catalog and releasing/commands/package.json.

The DI structure makes the OTP flow unit-testable without a real registry — all external dependencies can be mocked via the context parameter:

const context: OtpContext = {
  ...SHARED_CONTEXT,
  process: { stdin: { isTTY: true }, stdout: { isTTY: true } },
  publish: jest.fn(async () => { throw Object.assign(new Error('EOTP'), { code: 'EOTP' }) }),
  enquirer: { prompt: jest.fn(async () => ({ otp: '123456' })) },
  setTimeout: jest.fn((cb) => cb()),
}
const response = await publishWithOtpHandling({ context, manifest, tarballData, publishOptions })

Tests

  • test/publish/otp.test.ts (new): Unit tests covering both classic OTP and webauth flows, including: successful publish without OTP, non-OTP error passthrough, non-interactive terminal rejection, classic OTP prompt and retry, second challenge error, empty/undefined OTP re-throw, webauth polling with token retrieval, Retry-After header handling, fetch error resilience, non-ok response resilience, JSON parse error resilience, and 5-minute timeout enforcement.

Note

Verdaccio (used by @pnpm/registry-mock) does not implement the OTP challenge flow, so integration tests for this feature are not possible via the standard test registry. Unit tests with dependency injection are the appropriate testing strategy.


💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

Copilot AI changed the title [WIP] Fix issue raised in PR 10591 discussion fix: restore OTP and webauth flows broken by libnpmpublish migration Mar 2, 2026
@KSXGitHub

Copy link
Copy Markdown
Contributor

@copilot Answer this question:

I have noticed that this PR lack integration tests. I wonder if integration tests would be possible.

The integration tests of pnpm rely on @pnpm/registry-mock which relies on verdaccio.

Does Verdaccio provide OTP protocol?

If Verdaccio provides OTP, does @pnpm/registry-mock need to be edited to support it? If @pnpm/registry-mock don't need to be edited, what changes in the test code of pnpm would need to be made?

If Verdaccio doesn't provide OTP, we probably would have to rely on dependency injection and mocking like #10591 combined with real testing.

Important

Answer this question only. Do not make change to the code.

Copilot AI commented Mar 2, 2026

Copy link
Copy Markdown
Contributor Author

@copilot Answer this question:

I have noticed that this PR lack integration tests. I wonder if integration tests would be possible.
...

After a thorough search through Verdaccio's source code (both the verdaccio@6.2.7 package in node_modules and the verdaccio-htpasswd auth plugin), Verdaccio does not support OTP/2FA. There is no OTP-related code in its publish endpoint, user authentication, or any other part of its codebase. Verdaccio is a lightweight private npm proxy and deliberately does not implement the OTP challenge flow that the real npmjs.org registry uses (i.e., the www-authenticate: otp header that triggers EOTP in npm-registry-fetch).

Since Verdaccio doesn't support OTP, @pnpm/registry-mock does not need to be edited — the limitation is deeper, in the registry itself.

Conclusion: integration tests via Verdaccio are not possible for OTP/webauth flows.

The right approach is unit tests with dependency injection, similar to how oidcAuthToken.test.ts, oidcIdToken.test.ts, and oidcProvenance.test.ts are structured in this PR. To make publishWithOtpHandling properly unit-testable, the following would need to be injectable (currently hardcoded):

  1. The publish() function from libnpmpublish — so tests can throw an EOTP error on demand
  2. process.stdin.isTTY / process.stdout.isTTY — so tests can simulate TTY mode
  3. enquirer.prompt — so tests can mock the OTP prompt input
  4. fetch inside pollWebAuthDone — so tests can mock the doneUrl polling

Without those injections, the logic can only be exercised end-to-end against a real registry that actually challenges with OTP.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • https://api.github.com/repos/verdaccio/verdaccio/contents/packages
    • Triggering command: /usr/bin/curl curl -sL REDACTED grep -l mock" __/workspace-with-circular-peers/addons/addon14/package/package.json nfig/composer/vendor/bin/grep mock" ackage.json rep grep -l mock" -frozen-lockfile/package.json de/node/bin/grep mock" package.json nfig/composer/vendor/bin/grep grep (http block)
  • https://api.github.com/repos/verdaccio/verdaccio/contents/packages/plugins
    • Triggering command: /usr/bin/curl curl -sL REDACTED grep -l mock" h-circular-peers/modules/module1/package.json /home/REDACTED/work/_temp/ghcca-node/node/bin/grep mock" json ep grep -l mock" __/workspace-with-circular-peers/addons/addon14/package/package.json tnet/tools/grep mock" kage.json /home/REDACTED/wor"@pnpm/registry-mock" grep (http block)
  • https://api.github.com/repos/verdaccio/verdaccio/search/code
    • Triggering command: /usr/bin/curl curl -sL REDACTED,file grep -l mock" h-circular-peers/modules/module3/package.json (http block)
  • unpkg.com
    • Triggering command: /usr/bin/curl curl -s REDACTED grep -l mock" __/workspace-with-circular-peers/addons/addon1/package/package.json rep mock" ckage.json de/node/bin/grep"@pnpm/registry-mock" grep -l mock" __/workspace-with-circular-peers/addons/addon8/test-app/package.json nfig/composer/vendor/bin/grep mock" bar/package.json--norc rep grep (dns block)
  • verdaccio.org
    • Triggering command: /usr/bin/curl curl -sL REDACTED grep -l mock" h-circular-peers/modules/module3/package.json de/node/bin/grep mock" n/package.json nfig/composer/ve"@pnpm/registry-mock" grep -l mock" __/workspace-with-circular-peers/addons/addon1/test-app/package.json rep mock" re/packages/is-p-l de/node/bin/grep"@pnpm/registry-mock" grep (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

@KSXGitHub

Copy link
Copy Markdown
Contributor

@copilot

Create a file at releasing/plugin-commands-publishing/src/otp.ts.

This file immitates the dependency injection pattern found in oidc/{authToken,idToken,provenance}.ts.

Copilot AI commented Mar 2, 2026

Copy link
Copy Markdown
Contributor Author

@copilot

Create a file at releasing/plugin-commands-publishing/src/otp.ts.
...

Done in commit 5cbcb0d. The new src/otp.ts follows the same DI pattern as the OIDC files:

  • OtpContext — interface with injectable deps: publish, fetch (webauth polling), prompt (OTP input), globalInfo, Date (timeout), delay, isInteractive
  • OtpParams{ context?, manifest, publishOptions, tarballData }
  • publishWithOtpHandling — main function, context defaults to SHARED_CONTEXT
  • SHARED_CONTEXT — real implementations (libnpmpublish publish, @pnpm/fetch, enquirer, etc.)
  • OtpWebAuthTimeoutError — exported error class

publishPackedPkg.ts now imports publishWithOtpHandling from ./otp.js and handles the returned OtpPublishResponse (success logging / createFailedToPublishError) directly.

A test can inject all dependencies without any registry or real TTY:

const context: OtpContext = {
  ...SHARED_CONTEXT,
  isInteractive: true,
  publish: jest.fn(async () => { throw Object.assign(new Error('EOTP'), { code: 'EOTP' }) }),
  prompt: jest.fn(async () => '123456'),
}
const response = await publishWithOtpHandling({ context, manifest: {}, tarballData, publishOptions })

Comment thread releasing/plugin-commands-publishing/src/otp.ts Outdated
Comment thread releasing/plugin-commands-publishing/src/otp.ts Outdated
Comment thread releasing/plugin-commands-publishing/src/otp.ts Outdated
Comment thread releasing/plugin-commands-publishing/src/otp.ts Outdated
Comment thread releasing/plugin-commands-publishing/src/otp.ts Outdated
Comment thread releasing/plugin-commands-publishing/src/otp.ts Outdated
Comment thread releasing/plugin-commands-publishing/src/otp.ts Outdated
Comment thread releasing/plugin-commands-publishing/src/otp.ts Outdated
Comment thread releasing/plugin-commands-publishing/src/otp.ts Outdated
Comment thread releasing/plugin-commands-publishing/src/otp.ts Outdated
Copilot stopped work on behalf of KSXGitHub due to an error March 2, 2026 19:43
@KSXGitHub

Copy link
Copy Markdown
Contributor

@copilot Can you address the comments and questions and suggestions I have just made?

Copilot AI commented Mar 2, 2026

Copy link
Copy Markdown
Contributor Author

@copilot Can you address the comments and questions and suggestions I have just made?

Working through all the review comments now. For the isInteractive question: no is-interactive or is-tty library exists in the catalog, so I'm going with Scenario 2 — replacing isInteractive: boolean with process: Record<'stdin' | 'stdout', { isTTY?: boolean }> in the context.

@KSXGitHub KSXGitHub left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread releasing/plugin-commands-publishing/src/otp.ts Outdated
Comment thread releasing/plugin-commands-publishing/src/otp.ts Outdated
Comment thread releasing/plugin-commands-publishing/src/otp.ts Outdated
Comment thread releasing/plugin-commands-publishing/src/otp.ts Outdated
Comment thread releasing/plugin-commands-publishing/src/otp.ts Outdated
Comment thread releasing/plugin-commands-publishing/src/otp.ts Outdated
Comment thread releasing/plugin-commands-publishing/src/otp.ts Outdated

@KSXGitHub KSXGitHub left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread releasing/commands/src/publish/otp.ts
Comment thread releasing/plugin-commands-publishing/src/otp.ts Outdated
Comment thread releasing/plugin-commands-publishing/src/otp.ts Outdated
Comment thread releasing/plugin-commands-publishing/src/otp.ts Outdated
Comment thread releasing/plugin-commands-publishing/src/otp.ts Outdated
export const SHARED_CONTEXT: OtpContext = {
Date,
delay: (ms) => new Promise<void>(resolve => setTimeout(resolve, ms)),
enquirer: enquirer as unknown as OtpEnquirer,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot What was the actual function signature of the actual enquirer btw? I may be able to suggest you the way to fix this signature without type cast.

Important

This is a question, just answer, don't code.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The actual enquirer default export is an Enquirer class instance. Its prompt signature is approximately:

prompt<T extends object = object>(questions: PromptOptions | PromptOptions[]): Promise<T>

It takes a generic options object and returns Promise<T> where T defaults to object — it never returns undefined. The cast as unknown as OtpEnquirer is needed because:

  1. The parameter type PromptOptions doesn't overlap with our specific OtpEnquirerOptions
  2. The return type Promise<T> is incompatible with Promise<OtpEnquirerResponse | undefined> (notably the | undefined)

If we dropped | undefined from OtpEnquirerResponse | undefined, a two-step cast with a compatible intermediate type might be possible, but the PromptOptions vs OtpEnquirerOptions parameter mismatch still requires as unknown as the bridge.

Copilot AI commented Mar 2, 2026

Copy link
Copy Markdown
Contributor Author

@copilot Answer this question:

I have noticed that this PR lack integration tests. I wonder if integration tests would be possible.
...

Verdaccio does not support OTP/2FA — there is no OTP-related code in its publish endpoint or authentication. Integration tests via Verdaccio are not possible for OTP/webauth flows. The right approach is unit tests with dependency injection, using the OtpContext interface to mock all external dependencies (publish, enquirer, fetch, process, setTimeout) without a real registry.

@copilot

Create a file at releasing/plugin-commands-publishing/src/otp.ts.
...

src/otp.ts was created in commit 5cbcb0d and has since been refined through multiple review rounds. The current state in 00c05a7 includes OtpContext, SHARED_CONTEXT (via utils/shared-context.ts), publishWithOtpHandling, and the three error classes OtpWebAuthTimeoutError, OtpNonInteractiveError, and OtpSecondChallengeError.

@copilot Can you address the comments and questions and suggestions I have just made?

All comments addressed in 00c05a7.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Restores publish-time OTP handling (classic OTP, webauth/passkey flow, and npm-notice messaging) that was lost when migrating from npm publish to libnpmpublish, and centralizes the implementation behind a DI-friendly context to enable unit testing.

Changes:

  • Add OTP handling module (src/otp.ts) with classic OTP prompt retry, webauth URL + doneUrl polling, and npm-notice display (incl. QR codes), plus unit tests.
  • Consolidate OIDC + OTP shared runtime dependencies into a new src/utils/shared-context.ts, updating OIDC modules accordingly.
  • Route publishPackedPkg() through OTP handling and send npm-style publish headers (npm-auth-type, npm-command) plus corresponding options.

Reviewed changes

Copilot reviewed 14 out of 15 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
releasing/plugin-commands-publishing/src/otp.ts New OTP/webauth handling module (DI-based), QR output, and new error types.
releasing/plugin-commands-publishing/test/otp.test.ts Unit tests covering OTP success, non-interactive behavior, npm-notice, and webauth polling.
releasing/plugin-commands-publishing/src/utils/shared-context.ts New merged shared context providing real implementations for OIDC + OTP flows.
releasing/plugin-commands-publishing/src/oidc/utils/shared-context.ts Removes old OIDC-only shared context file (replaced by merged location).
releasing/plugin-commands-publishing/src/oidc/authToken.ts Updates SHARED_CONTEXT import path to merged shared context.
releasing/plugin-commands-publishing/src/oidc/idToken.ts Updates SHARED_CONTEXT import path to merged shared context.
releasing/plugin-commands-publishing/src/oidc/provenance.ts Updates SHARED_CONTEXT import path to merged shared context.
releasing/plugin-commands-publishing/src/publishPackedPkg.ts Publishes via publishWithOtpHandling() and adds npm publish headers/options.
releasing/plugin-commands-publishing/package.json Adds qrcode-terminal runtime dep and its types.
pnpm-workspace.yaml Adds catalog entries for qrcode-terminal and @types/qrcode-terminal.
pnpm-lock.yaml Locks new qrcode-terminal and @types/qrcode-terminal dependencies.
cspell.json Adds OTP-related words to dictionary.
.husky/pre-push Adds shebang and pnpm presence check (but currently suppresses failures).
.husky/commit-msg Adds shebang and pnpm presence check (but currently suppresses failures).
.changeset/fix-webauth-otp-publish.md Changeset documenting OTP/webauth publish behavior and headers.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment thread .husky/pre-push Outdated
Comment thread .husky/commit-msg Outdated
Comment thread releasing/plugin-commands-publishing/src/otp.ts Outdated
Comment thread releasing/plugin-commands-publishing/src/otp.ts Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 14 changed files in this pull request and generated 2 comments.

Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment thread releasing/plugin-commands-publishing/src/otp.ts Outdated
Comment thread .changeset/fix-webauth-otp-publish.md Outdated
@KSXGitHub

This comment was marked as resolved.

Comment thread releasing/commands/src/publish/otp.ts Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 13 changed files in this pull request and generated 4 comments.

Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment thread .changeset/tangy-pans-pull.md Outdated
Comment thread releasing/commands/src/publish/otp.ts Outdated
Comment thread releasing/commands/src/publish/otp.ts
Comment thread releasing/commands/src/publish/otp.ts
@KSXGitHub KSXGitHub marked this pull request as ready for review March 18, 2026 18:38
@KSXGitHub KSXGitHub requested review from Copilot and zkochan March 18, 2026 18:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 13 changed files in this pull request and generated 1 comment.

Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment thread releasing/commands/src/publish/otp.ts
Add OTP handling to `pnpm publish` with support for:
- Classic OTP prompt (manual code entry)
- Web-based authentication flow with QR code display
- Configurable npm-auth-type header signaling web auth support

Extract OTP logic into a dedicated module with dependency injection
for testability. Consolidate shared context for OIDC and OTP flows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@zkochan zkochan force-pushed the copilot/resolve-issue-10591-comment branch from 3d6a247 to 5bec7c8 Compare March 18, 2026 21:15
zkochan pushed a commit that referenced this pull request Mar 18, 2026
Add OTP handling to `pnpm publish` with support for:
- Classic OTP prompt (manual code entry)
- Web-based authentication flow with QR code display and doneUrl polling
- `npm-auth-type: web` header to signal web auth support to the registry

Extract OTP logic into a dedicated `otp.ts` module with dependency
injection for testability. Consolidate shared context for OIDC and OTP.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@zkochan

zkochan commented Mar 18, 2026

Copy link
Copy Markdown
Member

Couldn't rebase this pr properly for some reason. Had to create a new one: #11019

@zkochan zkochan closed this Mar 18, 2026
zkochan added a commit that referenced this pull request Mar 18, 2026
* feat(publish): handle OTP and web-based authentication flows (#10834)

Add OTP handling to `pnpm publish` with support for:
- Classic OTP prompt (manual code entry)
- Web-based authentication flow with QR code display and doneUrl polling
- `npm-auth-type: web` header to signal web auth support to the registry

Extract OTP logic into a dedicated `otp.ts` module with dependency
injection for testability. Consolidate shared context for OIDC and OTP.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: KSXGitHub <11488886+KSXGitHub@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@zkochan zkochan deleted the copilot/resolve-issue-10591-comment branch May 4, 2026 22:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants