Skip to content

Authenticate Entra ID marketplace API requests via RFC 9728 PRM negotiation#325804

Open
mcumming wants to merge 15 commits into
microsoft:mainfrom
mcumming:entra-marketplace-pr2-prm-auth
Open

Authenticate Entra ID marketplace API requests via RFC 9728 PRM negotiation#325804
mcumming wants to merge 15 commits into
microsoft:mainfrom
mcumming:entra-marketplace-pr2-prm-auth

Conversation

@mcumming

@mcumming mcumming commented Jul 14, 2026

Copy link
Copy Markdown

Scope: Microsoft Entra ID only. This PR implements RFC 9728 PRM negotiation and resource-scoped token threading for the Entra / Microsoft provider. The GitHub auth-enabled path called for in #325412 — RFC 8693 token exchange at the deployment's embedded Authorization Server, onDidChangeSessions('github') invalidation, and the GitHub test scenarios — is intentionally deferred to a separate follow-up PR. The GitHub path here remains unchanged from #325331 (anonymous index fetch + client-side checkAccess()).

Builds on #325331, which adds Microsoft Entra ID sign-in as a licensing signal for a Private Marketplace. This PR depends on it, so the commit range below currently includes #325331's commits and will reduce to just the negotiation work once #325331 merges.

Partially addresses #325412 (Entra path only; the GitHub auth-enabled path is tracked for a separate PR).

Summary

#325331 established Entra ID sign-in as a licensing signal for a Private Marketplace (index negotiation + eligibility check). This PR 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 — for the Entra/Microsoft provider.

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 (the negotiation work; #325331's commits also appear in the range until it merges)

  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.

Follow-up (separate PR)

  • GitHub auth-ENABLED path: RFC 8693 token exchange (raw GitHub token never reaches the resource server), client-side checkAccess() retained, no /eligibility POST.
  • onDidChangeSessions('github') invalidation for the auth-enabled path.
  • GitHub test-matrix scenarios (index 401 / no session / session + checkAccess).

Testing

  • Unit tests in extensionGalleryManifestService.test.ts cover the Entra 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 15 commits July 10, 2026 13:19
…ntext key

Introduce the `extensions.gallery.authProvider` policy that selects which
identity provider (github or microsoft) gates Private Marketplace access, and
register it in the exported policy data. Add the marketplace auth-provider
context key and the Entra ID resource scope constant used to acquire a
Private Marketplace-audienced token.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Resolve the marketplace access strategy in the workbench gallery manifest
service: cache-first startup, provider-routed access handling, Microsoft
eligibility probing against the eligibility resource from the gallery
manifest, the GitHub DefaultAccount path, the marketplace auth-provider
context key, and access telemetry.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Surface a provider-aware sign-in prompt and access-denied state in the
extensions viewlet, driven by the marketplace auth-provider context key so
the correct identity provider is presented to the user.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Allow the built-in extensions gallery to silently use Microsoft (Entra ID)
authentication sessions for Private Marketplace access.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Cover provider selection, cache-first startup, Microsoft eligibility
handling, and the GitHub access path in the gallery manifest service.

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

Address rubber-duck review findings on the Entra ID marketplace path:

- Scope the cached access verdict to the marketplace it was computed against
  (authProvider + accountId + serviceUrl), rejecting stale caches on any mismatch.
- Guard cache application and background validation with a monotonic epoch so a
  session/account/config change mid-validation supersedes an in-flight result.
- Register session/account listeners before applying the cache, and the config
  listener before initial validation, closing startup TOCTOU windows.
- Route transient auth-service and marketplace-fetch failures to Unreachable
  instead of leaving a configured marketplace on a blank Unavailable view.
- Split 401 (missing/expired token -> RequiresSignIn, not cached) from 403
  (durable denial -> AccessDenied, cached ineligible).
- Never follow redirects on token-bearing requests; only send the Entra token to
  an HTTPS same-origin target; reject non-2xx and non-manifest 200 responses
  before parsing.
- Restore the galleryservice:custom:marketplace telemetry on the GitHub path and
  drop the unused server-provided eligibility reason from persisted cache.

Expand unit coverage to 45 tests across provider routing, eligibility, caching,
error classification, and the epoch race paths.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…g, resource validation, UX copy

- Policy: make the `extensions.gallery.authProvider` schema enum and enumDescriptions
  unconditional (`github`, `microsoft`). Gating the enum on the Entra product flag left the
  policy metadata exporting two enum descriptions against a single-value enum, which fails the
  policy-artifact generator's equal-length requirement on a clean export. The Entra gate is
  already enforced at runtime in getEffectiveAuthProvider(), and the setting is hidden
  (included: false), so this advertises nothing new in the UI.

- Cross-account authorization leak: on Microsoft session change and GitHub default-account
  change, revoke the active manifest (drop `Available`) before revalidating. Previously the
  active status stayed `Available`, so a transient index/eligibility failure on the new account
  preserved the prior account's access.

- Layering: move CONTEXT_MARKETPLACE_AUTH_PROVIDER down to the platform extensionGalleryManifest
  module so the workbench service no longer imports from a workbench/contrib module. The
  Extensions contribution re-exports it for existing consumers.

- Resource validation: reject a 200 service index whose `resources` entries are malformed
  (missing string `id`/`type`), not just a non-array `resources`. Endpoint discovery calls
  `resource.type.split()` outside the fetch try/catch, so an undefined `type` would crash
  initialization instead of surfacing `Unreachable`.

- UX: make the Microsoft AccessDenied welcome message generic. A bare 403 gives no typed reason,
  so asserting that an Entra ID account or Visual Studio Subscription is required could tell an
  already-signed-in user to obtain access they already have.

Adds a unit test covering the malformed-resources -> Unreachable path. All 47 gallery tests pass;
typecheck-client and valid-layers-check are clean.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
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>
Copilot AI review requested due to automatic review settings July 14, 2026 14:35

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

Implements authenticated access to Private Marketplaces by discovering RFC 9728 Protected Resource Metadata (PRM) and negotiating a resource-scoped bearer token, then threading that token through all VS Code surfaces that perform marketplace requests (workbench, shared process/remote, gallery API, and extension resource loading).

Changes:

  • Add PRM discovery + resource-scoped token negotiation for auth-gated marketplace service indexes, exposing the negotiated token via IExtensionGalleryManifestService.getAccessToken().
  • Attach the negotiated token to marketplace API/asset requests and extension resource requests, guarded by same-secure-origin checks to prevent token leakage.
  • Extend marketplace status modeling + UX (new statuses, provider-aware sign-in) and add unit tests covering negotiation/error/cache paths.
Show a summary per file
File Description
src/vs/workbench/services/extensionManagement/test/electron-browser/extensionGalleryManifestService.test.ts Adds unit coverage for Microsoft/GitHub routing, negotiation (401→PRM), caching, and status transitions.
src/vs/workbench/services/extensionManagement/electron-browser/extensionGalleryManifestService.ts Implements negotiation, caching, eligibility checks, token threading to IPC channels, and new status handling.
src/vs/workbench/contrib/extensions/common/extensions.ts Re-exports the marketplace auth-provider context key for workbench contributions.
src/vs/workbench/contrib/extensions/browser/extensionsViewlet.ts Updates welcome content and badges for new marketplace statuses and provider-specific sign-in messaging.
src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts Registers extensions.gallery.authProvider setting + policy, and updates the marketplace sign-in action to support Microsoft/PRM consent.
src/vs/platform/extensionResourceLoader/common/extensionResourceLoaderService.ts Passes resource URI into header computation for authenticated marketplace resource requests.
src/vs/platform/extensionResourceLoader/common/extensionResourceLoader.ts Adds guarded Authorization header attachment for extension resource (README/etc.) fetches.
src/vs/platform/extensionResourceLoader/browser/extensionResourceLoaderService.ts Mirrors resource-aware header computation for browser fetch paths.
src/vs/platform/extensionManagement/common/extensionGalleryService.ts Attaches negotiated bearer token to extensionquery, stats/control, and asset download requests (same-secure-origin guarded).
src/vs/platform/extensionManagement/common/extensionGalleryManifestServiceIpc.ts Threads negotiated access token over the manifest IPC channel and exposes getAccessToken() in non-window processes.
src/vs/platform/extensionManagement/common/extensionGalleryManifestService.ts Adds a default getAccessToken() implementation returning undefined for open marketplaces.
src/vs/platform/extensionManagement/common/extensionGalleryManifest.ts Introduces provider context key, new statuses, new config key/scopes, and PRM discovery helper/types.
src/vs/base/common/product.ts Adds enableExtensionGalleryEntraAuth product gate for Microsoft/Entra marketplace auth path.
product.json Extends product data to include a microsoft entry under the relevant auth access structure.
build/lib/policies/policyData.jsonc Updates generated policy catalog to include ExtensionGalleryAuthProvider.

Review details

  • Files reviewed: 15/15 changed files
  • Comments generated: 1
  • Review effort level: Low

Comment on lines +384 to +387
name: 'ExtensionGalleryAuthProvider',
category: PolicyCategory.Extensions,
minimumVersion: '1.121',
localization: {
@mcumming mcumming changed the title Authenticate marketplace API requests via RFC 9728 PRM negotiation Authenticate Entra ID marketplace API requests via RFC 9728 PRM negotiation Jul 14, 2026
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.

3 participants