Skip to content
Draft
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
ba9e162
Add extensions.gallery.authProvider policy, marketplace scope, and co…
mcumming Jul 7, 2026
3435ee8
Add Entra ID eligibility check to the gallery manifest service
mcumming Jul 7, 2026
7fb68cf
Add provider-aware marketplace sign-in and access-denied UX
mcumming Jul 7, 2026
bebf7f5
Add microsoft to trustedExtensionAuthAccess
mcumming Jul 7, 2026
0bfd24a
Add unit tests for marketplace provider routing and eligibility
mcumming Jul 7, 2026
b58ca78
Harden Entra marketplace access: cache scoping, race guards, error ha…
mcumming Jul 10, 2026
282c38d
Address Copilot PR review: policy export, cross-account leak, layerin…
mcumming Jul 10, 2026
1047d20
Add RFC 9728 Protected Resource Metadata discovery for the marketplace
mcumming Jul 14, 2026
cfaa026
Negotiate a resource-scoped token on a gated Microsoft service index
mcumming Jul 14, 2026
c9fcc37
Attach the negotiated marketplace token to gallery API requests
mcumming Jul 14, 2026
516783f
Thread the negotiated marketplace token to the shared process
mcumming Jul 14, 2026
c93ef87
Attach the negotiated marketplace token to extension resource requests
mcumming Jul 14, 2026
8578eba
Acquire the resource-scoped token on interactive marketplace sign-in
mcumming Jul 14, 2026
1249328
Check marketplace eligibility only on first sign-in or account change
mcumming Jul 14, 2026
d708ccf
Downgrade the expected negotiation 401 from error to trace
mcumming Jul 14, 2026
893adfc
Authenticate GitHub marketplace API requests via RFC 8693 token exchange
mcumming Jul 14, 2026
fdc8c4f
Add tests for GitHub marketplace RFC 8693 token-exchange auth
mcumming Jul 14, 2026
9639deb
Strip the Authorization header on cross-origin request redirects
mcumming Jul 15, 2026
39f4a31
Validate the authorization server metadata issuer (RFC 8414 3)
mcumming Jul 15, 2026
ee1bbca
Harden GitHub marketplace token refresh, cancellation, and eligibility
mcumming Jul 15, 2026
540018c
Resolve redirect location before following and strip Cookie on cross-…
mcumming Jul 16, 2026
e85cad8
Scope RFC 8414 issuer validation to the marketplace and recheck cance…
mcumming Jul 16, 2026
216b120
Propagate in-place re-minted marketplace tokens to the shared process…
mcumming Jul 16, 2026
6ba78a9
Treat 401/404 entitlements (null) as indeterminate, not a durable mar…
mcumming Jul 16, 2026
e8889cc
Gate marketplace teardown on GitHub account identity change
mcumming Jul 16, 2026
9dfae0c
Proactively re-mint the negotiated GitHub marketplace token before it…
mcumming Jul 16, 2026
7c41b64
Thread a currency check into the marketplace token exchange
mcumming Jul 16, 2026
ede9adc
Honor followRedirects:0 in the renderer fetch request path
mcumming Jul 16, 2026
cd48515
Fix dropped proactive-refresh timer on soft GitHub token failures
mcumming Jul 16, 2026
1abc7a3
Tear down live marketplace on config change even if restart is declined
mcumming Jul 16, 2026
d7cafad
Refresh negotiated GitHub token before short/expired lifetimes elapse
mcumming Jul 16, 2026
cfb2b99
Gate untrusted resource_metadata challenge hint against SSRF
mcumming Jul 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions build/lib/policies/policyData.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,35 @@
"default": "",
"included": false
},
{
"key": "extensions.gallery.authProvider",
"name": "ExtensionGalleryAuthProvider",
"category": "Extensions",
"minimumVersion": "1.121",
"localization": {
"description": {
"key": "extensions.gallery.authProvider",
"value": "Configure the authentication provider for the Extensions Marketplace"
},
"enumDescriptions": [
{
"key": "extensions.gallery.authProvider.github",
"value": "Authenticate to the Extensions Marketplace using GitHub."
},
{
"key": "extensions.gallery.authProvider.microsoft",
"value": "Authenticate to the Extensions Marketplace using a Microsoft (Entra ID) account."
}
]
},
"type": "string",
"default": "",
"enum": [
"github",
"microsoft"
],
"included": false
},
{
"key": "extensions.allowed",
"name": "AllowedExtensions",
Expand Down
3 changes: 2 additions & 1 deletion product.json
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,8 @@
],
"github-enterprise": [
"GitHub.copilot-chat"
]
],
"microsoft": []
},
"onboardingKeymaps": [
{
Expand Down
9 changes: 9 additions & 0 deletions src/vs/base/common/product.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,15 @@ export interface IProductConfiguration {

readonly agentSdks?: { readonly [packageId: string]: IAgentSdkProductConfig };

/**
* Hard gate for the Entra ID (Microsoft) authentication path of the Extensions
* Marketplace. When falsy, the `extensions.gallery.authProvider: microsoft`
* setting is ignored and the GitHub/default auth path is used instead. This keeps
* the Entra path dormant on builds where the Private Marketplace has not yet been
* publicly released, independent of any admin policy configuration.
*/
readonly enableExtensionGalleryEntraAuth?: boolean;

readonly mcpGallery?: {
readonly serviceUrl: string;
readonly itemWebUrl: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,20 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { CancellationToken } from '../../../base/common/cancellation.js';
import { Event } from '../../../base/common/event.js';
import { AUTH_SCOPE_SEPARATOR, fetchAuthorizationServerMetadata, fetchResourceMetadata, GRANT_TYPE_TOKEN_EXCHANGE, IAuthorizationTokenResponse, isAuthorizationTokenResponse, parseWWWAuthenticateHeader, TOKEN_TYPE_ACCESS_TOKEN } from '../../../base/common/oauth.js';
import { createDecorator } from '../../instantiation/common/instantiation.js';
import { RawContextKey } from '../../contextkey/common/contextkey.js';
import { asJson, asText, IRequestService } from '../../request/common/request.js';

/**
* Context key exposing the effective Marketplace authentication provider (e.g. `github` or
* `microsoft`) for `when`-clause driven welcome content. Defined here in the platform layer so
* both the workbench service that sets it and the Extensions contribution that reads it can
* depend on it without a service-to-contribution dependency.
*/
export const CONTEXT_MARKETPLACE_AUTH_PROVIDER = new RawContextKey<string>('marketplaceAuthProvider', '');

export const enum ExtensionGalleryResourceType {
ExtensionQueryService = 'ExtensionQueryService',
Expand All @@ -15,6 +27,7 @@ export const enum ExtensionGalleryResourceType {
ExtensionRatingViewUri = 'ExtensionRatingViewUriTemplate',
ExtensionResourceUri = 'ExtensionResourceUriTemplate',
ContactSupportUri = 'ContactSupportUri',
EligibilityService = 'EligibilityService',
}

export const enum Flag {
Expand Down Expand Up @@ -68,7 +81,21 @@ export const enum ExtensionGalleryManifestStatus {
Available = 'available',
RequiresSignIn = 'requiresSignIn',
AccessDenied = 'accessDenied',
Unavailable = 'unavailable'
Unavailable = 'unavailable',
/**
* A marketplace is configured, and the user is (or is presumed) eligible, but its
* gallery manifest could not be fetched — a transient network/server error. Unlike
* {@link Unavailable} (which also means "no gallery configured"), this state is only
* ever set after a failed fetch of a configured marketplace, so it is safe to surface
* an informative message without affecting builds that have no gallery at all.
*/
Unreachable = 'unreachable',
/**
* The marketplace is configured for Microsoft (Entra ID) authentication, but the
* gallery manifest does not advertise an EligibilityService resource. Access is
* refused (no silent fallback to another provider) until the server is corrected.
*/
Misconfigured = 'misconfigured'
}

export const IExtensionGalleryManifestService = createDecorator<IExtensionGalleryManifestService>('IExtensionGalleryManifestService');
Expand All @@ -80,6 +107,16 @@ export interface IExtensionGalleryManifestService {
readonly onDidChangeExtensionGalleryManifestStatus: Event<ExtensionGalleryManifestStatus>;
readonly onDidChangeExtensionGalleryManifest: Event<IExtensionGalleryManifest | null>;
getExtensionGalleryManifest(): Promise<IExtensionGalleryManifest | null>;

/**
* Returns the bearer token to attach to authenticated marketplace API requests
* (e.g. `extensionquery`, asset download), or `undefined` when the marketplace does
* not require authentication. When the marketplace service index is `[Authorize]`-gated,
* this is a resource-scoped token negotiated via RFC 9728 Protected Resource Metadata.
* Consumers MUST only attach the token to requests whose target is same-origin HTTPS with
* the marketplace service index to avoid leaking it to foreign or cleartext endpoints.
*/
getAccessToken(): Promise<string | undefined>;
}

export function getExtensionGalleryManifestResourceUri(manifest: IExtensionGalleryManifest, type: string): string | undefined {
Expand All @@ -98,3 +135,160 @@ export function getExtensionGalleryManifestResourceUri(manifest: IExtensionGalle
}

export const ExtensionGalleryServiceUrlConfigKey = 'extensions.gallery.serviceUrl';

export const ExtensionGalleryAuthProviderConfigKey = 'extensions.gallery.authProvider';

/**
* Scopes requested when signing in with Microsoft (Entra ID) to establish the
* user's identity for the Private Marketplace eligibility check.
*
* Only standard OpenID Connect sign-in scopes are requested — enough to obtain a
* Microsoft session that identifies the user. This intentionally does NOT request a
* resource-scoped token (e.g. `api://<client-id>/access_as_user`) up front. When the
* marketplace service index is `[Authorize]`-gated, a resource-bound token is instead
* negotiated on demand from the server's `WWW-Authenticate` challenge (Protected
* Resource Metadata, RFC 9728); these scopes serve as the fallback sign-in scopes for
* that negotiation.
*/
export const PRIVATE_MARKETPLACE_SCOPES: string[] = ['openid', 'profile', 'email', 'offline_access'];

/**
* The subset of RFC 9728 Protected Resource Metadata the marketplace negotiation needs: the
* authorization server to acquire a token from and the resource-scoped scopes to request.
*/
export interface IMarketplaceProtectedResource {
/** The authorization server (`authorization_servers[0]`) to acquire the resource token from. */
readonly authorizationServer: string;
/** The resource-scoped scopes (`scopes_supported`) to request for the marketplace resource. */
readonly scopes: readonly string[];
/** The protected resource identifier (`resource`) the metadata describes. */
readonly resource: string;
}

/**
* Discovers the marketplace's Protected Resource Metadata (RFC 9728) so a resource-scoped token
* can be minted for an `[Authorize]`-gated marketplace index.
*
* Discovery is driven by the well-known endpoint (`/.well-known/oauth-protected-resource`) rather
* than the server's `WWW-Authenticate` challenge: `WWW-Authenticate` is not a CORS-safelisted
* response header, so the renderer's cross-origin index fetch frequently cannot read it. The
* metadata *body*, however, is CORS-readable, so well-known discovery is robust where the challenge
* header is not. The optional `wwwAuthenticate` string is used only as a best-effort hint for the
* explicit `resource_metadata` URL when the header happens to be readable.
*
* Returns `undefined` (never throws) when discovery fails or the metadata omits an authorization
* server, so callers can fall back to their existing sign-in / unreachable handling.
*/
export async function discoverMarketplaceProtectedResource(
requestService: IRequestService,
serviceIndexUrl: string,
wwwAuthenticate: string | undefined,
token: CancellationToken,
): Promise<IMarketplaceProtectedResource | undefined> {
let resourceMetadataUrl: string | undefined;
if (wwwAuthenticate) {
for (const challenge of parseWWWAuthenticateHeader(wwwAuthenticate)) {
if (challenge.scheme.toLowerCase() === 'bearer' && challenge.params.resource_metadata) {
resourceMetadataUrl = challenge.params.resource_metadata;
break;
}
}
}
const fetcher = async (input: string, init: { method: string; headers: Record<string, string> }) => {
const context = await requestService.request({ type: init.method, url: input, headers: init.headers, callSite: 'extensionGalleryManifest.discoverMarketplaceProtectedResource' }, token);
return {
status: context.res.statusCode ?? 0,
statusText: '',
json: async (): Promise<unknown> => await asJson(context),
text: async (): Promise<string> => (await asText(context)) ?? '',
};
};
try {
const { metadata } = await fetchResourceMetadata(serviceIndexUrl, resourceMetadataUrl, { fetch: fetcher });
const authorizationServer = metadata.authorization_servers?.[0];
if (!authorizationServer) {
return undefined;
}
return {
authorizationServer,
scopes: metadata.scopes_supported ?? [],
resource: metadata.resource,
};
} catch {
return undefined;
}
}

/**
* Exchanges an authentication provider's access token (e.g. a GitHub session token the client
* already holds) for a first-party, audience-bound marketplace access token, using the RFC 8693
* token-exchange grant at the marketplace's advertised authorization server.
*
* This is the GitHub auth-enabled scheme's token acquisition. Unlike the Entra scheme — where the
* Microsoft/MSAL provider mints a resource-scoped token directly via `getSessions(...,
* { authorizationServer })` — VS Code's GitHub provider has no resource-token support, so the
* marketplace's embedded Authorization Server performs a token exchange: it converts the caller's
* GitHub token into an `at+jwt` bound to the marketplace resource (`aud = resource`).
*
* The raw `subjectToken` is transmitted ONLY to the advertised authorization server's token
* endpoint — never to the resource server. Both the authorization server and its discovered token
* endpoint are validated with `isSafeTarget` (fail-closed) before the token is sent, so a
* compromised or misconfigured PRM cannot exfiltrate the GitHub token to a foreign/cleartext
* origin. Returns `undefined` (never throws) when discovery or the exchange fails, so callers fall
* back to their existing sign-in handling.
*/
export async function exchangeMarketplaceResourceToken(
requestService: IRequestService,
protectedResource: IMarketplaceProtectedResource,
subjectToken: string,
isSafeTarget: (targetUrl: string) => boolean,
token: CancellationToken,
): Promise<string | undefined> {
if (!isSafeTarget(protectedResource.authorizationServer)) {
return undefined;
}
const fetcher = async (input: string, init: { method: string; headers: Record<string, string> }) => {
const context = await requestService.request({ type: init.method, url: input, headers: init.headers, callSite: 'extensionGalleryManifest.exchangeMarketplaceResourceToken' }, token);
return {
status: context.res.statusCode ?? 0,
statusText: '',
json: async (): Promise<unknown> => await asJson(context),
text: async (): Promise<string> => (await asText(context)) ?? '',
};
};
try {
const { metadata } = await fetchAuthorizationServerMetadata(protectedResource.authorizationServer, { fetch: fetcher });
const tokenEndpoint = metadata.token_endpoint;
if (!tokenEndpoint || !isSafeTarget(tokenEndpoint)) {
return undefined;
}
const body = new URLSearchParams();
body.append('grant_type', GRANT_TYPE_TOKEN_EXCHANGE);
body.append('subject_token', subjectToken);
body.append('subject_token_type', TOKEN_TYPE_ACCESS_TOKEN);
body.append('resource', protectedResource.resource);
if (protectedResource.scopes.length) {
body.append('scope', protectedResource.scopes.join(AUTH_SCOPE_SEPARATOR));
}
const context = await requestService.request({
type: 'POST',
url: tokenEndpoint,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
data: body.toString(),
callSite: 'extensionGalleryManifest.exchangeMarketplaceResourceToken',
// A bearer/subject token is in the body; never follow redirects so the request service
// can't forward it to a (possibly cross-origin) redirect target and leak it.
followRedirects: 0,
}, token);
if (context.res.statusCode !== 200) {
return undefined;
}
const response = await asJson<IAuthorizationTokenResponse>(context);
if (response && isAuthorizationTokenResponse(response) && response.access_token) {
return response.access_token;
}
return undefined;
} catch {
return undefined;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ export class ExtensionGalleryManifestService extends Disposable implements IExte
super();
}

/**
* The default marketplace is open (no authentication). Authenticated marketplaces are
* handled by the workbench override, which negotiates and returns a resource-scoped token.
*/
async getAccessToken(): Promise<string | undefined> {
return undefined;
}

async getExtensionGalleryManifest(): Promise<IExtensionGalleryManifest | null> {
const extensionsGallery = this.productService.extensionsGallery as ExtensionGalleryConfig | undefined;
if (!extensionsGallery?.serviceUrl) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export class ExtensionGalleryManifestIPCService extends ExtensionGalleryManifest
override readonly onDidChangeExtensionGalleryManifestStatus = this._onDidChangeExtensionGalleryManifestStatus.event;

private _extensionGalleryManifest: IExtensionGalleryManifest | null | undefined;
private _accessToken: string | undefined;
private readonly barrier = new Barrier();

override get extensionGalleryManifestStatus(): ExtensionGalleryManifestStatus {
Expand All @@ -39,7 +40,7 @@ export class ExtensionGalleryManifestIPCService extends ExtensionGalleryManifest
// eslint-disable-next-line @typescript-eslint/no-explicit-any
call: async (context: any, command: string, args?: any): Promise<any> => {
switch (command) {
case 'setExtensionGalleryManifest': return Promise.resolve(this.setExtensionGalleryManifest(args[0]));
case 'setExtensionGalleryManifest': return Promise.resolve(this.setExtensionGalleryManifest(args[0], args[1]));
}
throw new Error('Invalid call');
}
Expand All @@ -51,9 +52,26 @@ export class ExtensionGalleryManifestIPCService extends ExtensionGalleryManifest
return this._extensionGalleryManifest ?? null;
}

private setExtensionGalleryManifest(manifest: IExtensionGalleryManifest | null): void {
/**
* Returns the resource-scoped bearer token negotiated by the window process for a
* `[Authorize]`-gated marketplace, or `undefined` for an open marketplace. The token is pushed
* over the channel alongside the manifest (see {@link setExtensionGalleryManifest}); this
* process (shared process / remote server) never negotiates it itself, so protected marketplace
* requests it initiates — extension `getManifest`, VSIX download — would otherwise be anonymous
* and rejected with 401.
*/
override async getAccessToken(): Promise<string | undefined> {
await this.barrier.wait();
return this._accessToken;
}

private setExtensionGalleryManifest(manifest: IExtensionGalleryManifest | null, accessToken?: string): void {
this.logService.trace(`[Marketplace] Setting manifest ${manifest ? 'available' : 'unavailable'}`);
this._extensionGalleryManifest = manifest;
// The token is coherent with the manifest: the window sets it before an eligible→Available
// transition and clears it on every non-Available transition, so a null manifest always
// arrives with an undefined token and a stale token can never outlive its access.
this._accessToken = accessToken;
this._onDidChangeExtensionGalleryManifest.fire(manifest);
this._onDidChangeExtensionGalleryManifestStatus.fire(this.extensionGalleryManifestStatus);
this.barrier.open();
Expand Down
Loading
Loading