Skip to content

Authenticate marketplace API requests via RFC 9728 PRM negotiation - #2

Closed
mcumming wants to merge 8 commits into
mcumming-entra-id-vss-marketplace-reviewfrom
entra-marketplace-pr2-prm-auth
Closed

Authenticate marketplace API requests via RFC 9728 PRM negotiation#2
mcumming wants to merge 8 commits into
mcumming-entra-id-vss-marketplace-reviewfrom
entra-marketplace-pr2-prm-auth

Conversation

@mcumming

Copy link
Copy Markdown
Owner

Stacked on #1 (base branch mcumming-entra-id-vss-marketplace-review). This PR contains only the PR2 commits; review it after / on top of PR1.

Addresses microsoft#325412

Summary

PR1 established Entra ID sign-in as a licensing signal for a Private Marketplace (index negotiation + eligibility check). PR2 makes the marketplace's API requests actually authenticate against an [Authorize]-gated marketplace by negotiating a resource-scoped bearer token via RFC 9728 Protected Resource Metadata (PRM) discovery and threading that token to every process/surface that talks to the marketplace.

Discovery is driven by the marketplace's well-known /.well-known/oauth-protected-resource document (CORS-readable) rather than the WWW-Authenticate challenge header, which the renderer's cross-origin index fetch usually cannot read. The negotiated token is bound to the advertised authorization server and resource scopes (RFC 8707), set only on the eligible→Available transition, and cleared on every non-Available transition so it can never outlive its access.

Commits (reviewer-friendly, buildable at each step)

  1. Add RFC 9728 Protected Resource Metadata discovery — reusable discoverMarketplaceProtectedResource helper + types.
  2. Negotiate a resource-scoped token on a gated Microsoft service index — 401 → PRM discovery → silent token acquisition → retry; exposed via getAccessToken(). (includes tests: gated negotiation, CORS-stripped 401, open-index no-token, negotiated-but-forbidden 403).
  3. Attach the token to gallery API requests — extensionquery, asset & VSIX downloads, behind a same-secure-origin guard.
  4. Thread the token to the shared process — so getManifest / VSIX download (which never negotiate) authenticate; pushed over the manifest channel.
  5. Attach the token to extension resource requests — README images, web README/CHANGELOG.
  6. Acquire the token on interactive marketplace sign-in — avoids a second silent negotiation round-trip.
  7. Check eligibility only on first sign-in or account change — skip the redundant per-refresh eligibility POST; revoke prior authorization on account switch.
  8. Downgrade the expected negotiation 401 from error to trace — the 401 handshake is expected, not a failure.

Testing

  • Unit tests in extensionGalleryManifestService.test.ts cover the negotiation paths (commit 2).
  • typecheck-client ✅ and valid-layers-check ✅.
  • Manually validated against a local Private Marketplace with Entra auth enabled (index negotiation, eligibility, gallery + resource token threading).

Notes

  • For an open marketplace getAccessToken() returns undefined and all requests stay anonymous — no behavior change.
  • Token is only ever attached to same-secure-origin targets; never leaked cross-origin or over cleartext.

mcumming and others added 8 commits July 14, 2026 10:18
Introduce `discoverMarketplaceProtectedResource` and supporting types/URIs that
read a marketplace's well-known `/.well-known/oauth-protected-resource` document
(RFC 9728): the advertised `resource`, `authorization_servers` and
`scopes_supported`. Discovery is driven by the metadata body (CORS-readable)
rather than the `WWW-Authenticate` challenge header, which the renderer's
cross-origin index fetch usually cannot read. This is the reusable primitive the
window process uses to negotiate a resource-scoped token for a gated index.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
When the marketplace service index is `[Authorize]`-gated it answers the initial
(plain sign-in token) read with a 401. `fetchServiceIndexNegotiated` now discovers
the marketplace's Protected Resource Metadata (RFC 9728), silently acquires a token
bound to the advertised authorization server and resource scopes (RFC 8707) and
retries the index once with that token. The negotiated token is captured in
`negotiatedAccessToken` (set only on the eligible -> Available transition, cleared
on every non-Available transition via `update(null, ...)`) and exposed through a
new `getAccessToken()` so protected marketplace requests can authenticate. The
`WWW-Authenticate` challenge is captured as a best-effort hint but discovery does
not depend on it. Includes tests for gated-index negotiation, a CORS-stripped 401
(no challenge header), the open-index no-token case, and a negotiated-but-forbidden
retry (403 -> AccessDenied).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Thread the resource-scoped token (from `IExtensionGalleryManifestService.getAccessToken`)
onto the marketplace requests the gallery service initiates - extensionquery, asset
and VSIX downloads - via `getMarketplaceAuthorizationHeader`. The token is only
attached to same-secure-origin targets (`isSameSecureOrigin` guard) so it is never
leaked cross-origin or over cleartext. For an open marketplace `getAccessToken`
returns undefined and requests stay anonymous.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The shared process and remote server never negotiate the resource-scoped token
themselves, so protected requests they initiate - extension `getManifest` and VSIX
download - would be anonymous and rejected with 401. Push the token over the manifest
channel alongside the manifest (`setExtensionGalleryManifest(manifest, accessToken)`)
and expose it via the IPC service's `getAccessToken`. The token is coherent with the
manifest: a null manifest always arrives with an undefined token, so a stale token
can never outlive its access.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The extension resource loader fetches marketplace-hosted assets (README images,
web README/CHANGELOG). `getExtensionGalleryRequestHeaders` now merges the
negotiated token onto those requests behind the same same-origin guard, so
resources on a gated marketplace load instead of 401ing. Anonymous for an open
marketplace.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
When the user signs in from the marketplace-access welcome view, request the
resource-scoped scopes so the first post-sign-in validation already holds a token
the gated index accepts, avoiding a second silent negotiation round-trip.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
`onDidChangeSessions('microsoft')` also fires on routine token refreshes for the
same account; previously each one cleared the cache and forced a redundant
eligibility POST (and a manifest flash). Now the handler just re-validates, and
`handleMicrosoftAccess` resolves the current account and skips the eligibility POST
when a durable verdict for that account+marketplace is already cached - it still
(re)negotiates the service-index token for the session. A verdict for a different
account (account switch) revokes the prior authorization before the async round-trip
so a transient failure cannot preserve it.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A gated service index answers the initial read with a 401 + `WWW-Authenticate` as
the first step of RFC 9728 negotiation - an expected handshake, not a failure.
Logging it at `error` surfaced a spurious "Error retrieving extension gallery
manifest". Log `MarketplaceAuthRequiredError` outcomes at `trace` and reserve
`error` for genuinely unexpected failures.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@mcumming mcumming changed the title Authenticate marketplace API requests via RFC 9728 PRM negotiation (PR2) Authenticate marketplace API requests via RFC 9728 PRM negotiation Jul 14, 2026
@mcumming

Copy link
Copy Markdown
Owner Author

Opened in the wrong repo (fork). Re-created against microsoft/vscode, stacked on microsoft#325331.

@mcumming mcumming closed this Jul 14, 2026
mcumming pushed a commit that referenced this pull request Jul 21, 2026
…st is enabled (microsoft#326086)

* Default editor and panel chat to Agent Host Copilot when the agent host is enabled

Builds on the chat.editor.preferCopilotHarness behavior to make Agent Host
Copilot the computed default chat provider for both editor and panel chat
whenever the agent host is enabled, so first-time users land on Copilot instead
of Local. chat.editor.preferCopilotHarness stays scoped to the one-time
Local -> Copilot migration only.

- Thread an agentHostEnabled flag (from IAgentHostEnablementService) through the
  default-session-type resolution and its callers.
- Keep Local visible and selectable; honor explicit and remembered selections.
- New Local Chat opens a local session directly: it cancels the in-flight
  default-provider resolution (which would otherwise block on agent host
  activation) so the local request wins immediately.
- Open a Local chat first in all chat participant API tests, since chat
  participants are a Local-harness feature.
- Add a smoketest.openLocalChat command for Local panel smoke scenarios; the
  sandbox reopen path reveals the existing local session to avoid a focus race.
- Register an IAgentHostEnablementService stub in the component fixtures.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* chat: honor resolved session type when starting a new chat

Fixes the New Chat drop bug (review comment #2 on microsoft#326086): when the
agent host is enabled the computed default is a non-local harness, so the
editor clear path recomputed that default and dropped explicit or
preserved local requests.

- clearChatSessionPreservingType now branches on the resolved session
  type for the sidebar (non-local -> loadSession, local ->
  startNewLocalSession) so a generic New Chat from a Local panel
  preserves Local, consistent with contributed panels.
- The resolved type is threaded through IChatWidget.clear ->
  viewOptions.clear -> chatEditor.clear -> clearChatEditor so the editor
  opens a session of that type instead of recomputing the default. This
  restores explicit "New Local Chat" from a non-local editor.
- clearChatEditor applies an explicit target type directly and keeps its
  swap-aware default only for direct (untargeted) calls.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* chat: cancel the previous applyModel resolution before replacing it

Assigning a new CancellationTokenSource to the MutableDisposable only
disposes the previous source, and disposing a CancellationTokenSource
does not cancel it. So a re-entrant applyModel() (view render, switch
session) left the prior in-flight resolution running, racing to call
showModel with a stale result. Cancel the previous source explicitly
before replacing it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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.

1 participant