Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
* 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 { fetchResourceMetadata, parseWWWAuthenticateHeader } 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
Expand Down Expand Up @@ -104,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 Down Expand Up @@ -131,8 +144,77 @@ export const ExtensionGalleryAuthProviderConfigKey = 'extensions.gallery.authPro
*
* 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`). Acquiring resource
* tokens for Private Marketplace API calls, per the server's Protected Resource
* Metadata (RFC 9728), is deferred to a follow-up change.
* 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;
}
}
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
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,46 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle
return this.extensionGalleryManifestService.extensionGalleryManifestStatus === ExtensionGalleryManifestStatus.Available;
}

/**
* Returns an `Authorization` header for an authenticated marketplace API request, or an empty
* object when no token applies. The negotiated bearer token (see
* {@link IExtensionGalleryManifestService.getAccessToken}) is attached ONLY when `targetUrl` is
* same-origin HTTPS with the marketplace's `extensionquery` service endpoint — this prevents the
* resource-scoped token from leaking to foreign origins (e.g. asset/statistics endpoints hosted
* on a third-party CDN, or a cleartext URL from a tampered manifest).
*
* @param manifest the current gallery manifest (source of the marketplace origin)
* @param targetUrl the absolute URL the request will be sent to
*/
private async getMarketplaceAuthorizationHeader(manifest: IExtensionGalleryManifest, targetUrl: string): Promise<IHeaders> {
const token = await this.extensionGalleryManifestService.getAccessToken();
if (!token) {
return {};
}
const marketplaceApi = getExtensionGalleryManifestResourceUri(manifest, ExtensionGalleryResourceType.ExtensionQueryService);
if (!marketplaceApi || !AbstractExtensionGalleryService.isSameSecureOrigin(targetUrl, marketplaceApi)) {
return {};
}
return { Authorization: `Bearer ${token}` };
}

/**
* True when both URLs are `https:` and share the same origin (scheme + authority). Fails closed:
* any parse error or scheme mismatch returns false so a token is never attached to an
* unverifiable or cleartext target.
*/
private static isSameSecureOrigin(targetUrl: string, baseUrl: string): boolean {
try {
const target = URI.parse(targetUrl);
const base = URI.parse(baseUrl);
return target.scheme === 'https'
&& base.scheme === 'https'
&& target.authority.toLowerCase() === base.authority.toLowerCase();
} catch {
return false;
}
}

getExtensions(extensionInfos: ReadonlyArray<IExtensionInfo>, token: CancellationToken): Promise<IGalleryExtension[]>;
getExtensions(extensionInfos: ReadonlyArray<IExtensionInfo>, options: IExtensionQueryOptions, token: CancellationToken): Promise<IGalleryExtension[]>;
async getExtensions(extensionInfos: ReadonlyArray<IExtensionInfo>, arg1: CancellationToken | IExtensionQueryOptions, arg2?: CancellationToken): Promise<IGalleryExtension[]> {
Expand Down Expand Up @@ -1417,8 +1457,10 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle
});

const commonHeaders = await this.commonHeadersPromise;
const authHeader = await this.getMarketplaceAuthorizationHeader(extensionGalleryManifest, extensionsQueryApi);
const headers = {
...commonHeaders,
...authHeader,
'Content-Type': 'application/json',
'Accept': 'application/json;api-version=3.0-preview.1',
'Accept-Encoding': 'gzip',
Expand Down Expand Up @@ -1571,9 +1613,12 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle
const stopWatch = new StopWatch();

try {
const manifest = await this.extensionGalleryManifestService.getExtensionGalleryManifest();
const commonHeaders = await this.commonHeadersPromise;
const authHeader = manifest ? await this.getMarketplaceAuthorizationHeader(manifest, uri.toString(true)) : {};
const headers = {
...commonHeaders,
...authHeader,
'Content-Type': 'application/json',
'Accept': 'application/json;api-version=7.2-preview',
'Accept-Encoding': 'gzip',
Expand Down Expand Up @@ -1677,7 +1722,8 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle

const Accept = '*/*;api-version=4.0-preview.1';
const commonHeaders = await this.commonHeadersPromise;
const headers = { ...commonHeaders, Accept };
const authHeader = await this.getMarketplaceAuthorizationHeader(manifest, url);
const headers = { ...commonHeaders, ...authHeader, Accept };
try {
await this.requestService.request({
type: 'POST',
Expand Down Expand Up @@ -1874,7 +1920,12 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle

const url = asset.uri;
const fallbackUrl = asset.fallbackUri;
const firstOptions = { ...options, url, timeout: this.getRequestTimeout(), callSite };
// The negotiated token is only attached when the asset is served from the marketplace's own
// (same) secure origin. Assets on a third-party CDN (a different origin) get no token. The
// primary and fallback URLs can differ in origin, so evaluate the guard independently for each.
const manifest = await this.extensionGalleryManifestService.getExtensionGalleryManifest();
const primaryAuthHeader = manifest ? await this.getMarketplaceAuthorizationHeader(manifest, url) : {};
const firstOptions = { ...options, headers: { ...headers, ...primaryAuthHeader }, url, timeout: this.getRequestTimeout(), callSite };

let context;
try {
Expand Down Expand Up @@ -1920,7 +1971,8 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle
endToEndId: this.getHeaderValue(context?.res.headers, END_END_ID_HEADER_NAME),
});

const fallbackOptions = { ...options, url: fallbackUrl, timeout: this.getRequestTimeout(), callSite: `${callSite}.fallback` };
const fallbackAuthHeader = manifest ? await this.getMarketplaceAuthorizationHeader(manifest, fallbackUrl) : {};
const fallbackOptions = { ...options, headers: { ...headers, ...fallbackAuthHeader }, url: fallbackUrl, timeout: this.getRequestTimeout(), callSite: `${callSite}.fallback` };
return this.requestService.request(fallbackOptions, token);
}
}
Expand All @@ -1936,9 +1988,11 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle
return { malicious: [], deprecated: {}, search: [], autoUpdate: {} };
}

const authHeader = await this.getMarketplaceAuthorizationHeader(manifest, this.extensionsControlUrl);
const context = await this.requestService.request({
type: 'GET',
url: this.extensionsControlUrl,
headers: authHeader,
timeout: this.getRequestTimeout(),
callSite: 'extensionGalleryService.getExtensionsControlManifest'
}, CancellationToken.None);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ class ExtensionResourceLoaderService extends AbstractExtensionResourceLoaderServ

const requestInit: RequestInit = {};
if (await this.isExtensionGalleryResource(uri)) {
requestInit.headers = await this.getExtensionGalleryRequestHeaders();
requestInit.headers = await this.getExtensionGalleryRequestHeaders(uri);
requestInit.mode = 'cors'; /* set mode to cors so that above headers are always passed */
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export abstract class AbstractExtensionResourceLoaderService extends Disposable
return !!this._extensionGalleryAuthority && this._extensionGalleryAuthority === this._getExtensionGalleryAuthority(uri);
}

protected async getExtensionGalleryRequestHeaders(): Promise<Record<string, string>> {
protected async getExtensionGalleryRequestHeaders(resource?: URI): Promise<Record<string, string>> {
const headers: Record<string, string> = {
'X-Client-Name': `${this._productService.applicationName}${isWeb ? '-web' : ''}`,
'X-Client-Version': this._productService.version
Expand All @@ -143,9 +143,44 @@ export abstract class AbstractExtensionResourceLoaderService extends Disposable
if (this._productService.commit) {
headers['X-Client-Commit'] = this._productService.commit;
}
if (resource) {
Object.assign(headers, await this.getMarketplaceAuthorizationHeader(resource));
}
return headers;
}

/**
* Returns an `Authorization` header for a `[Authorize]`-gated marketplace resource request, or an
* empty object when no token applies. The negotiated bearer token (see
* {@link IExtensionGalleryManifestService.getAccessToken}) is attached ONLY when `resource` is
* same-origin HTTPS with the marketplace's `extensionquery` service endpoint — this prevents the
* resource-scoped token from leaking to a foreign origin (e.g. a third-party resource CDN whose
* URL was advertised by the gallery manifest, or a cleartext URL). Mirrors the guard applied to
* gallery API/asset requests in `ExtensionGalleryService`.
*/
private async getMarketplaceAuthorizationHeader(resource: URI): Promise<Record<string, string>> {
const token = await this._extensionGalleryManifestService.getAccessToken();
if (!token) {
return {};
}
const manifest = await this._extensionGalleryManifestService.getExtensionGalleryManifest();
const marketplaceApi = manifest ? getExtensionGalleryManifestResourceUri(manifest, ExtensionGalleryResourceType.ExtensionQueryService) : undefined;
if (!marketplaceApi || !AbstractExtensionResourceLoaderService.isSameSecureOrigin(resource, URI.parse(marketplaceApi))) {
return {};
}
return { Authorization: `Bearer ${token}` };
}

/**
* True when both URIs are `https:` and share the same origin (scheme + authority). Fails closed:
* a scheme mismatch returns false so a token is never attached to a cleartext target.
*/
private static isSameSecureOrigin(target: URI, base: URI): boolean {
return target.scheme === 'https'
&& base.scheme === 'https'
&& target.authority.toLowerCase() === base.authority.toLowerCase();
}

private _serviceMachineIdPromise: Promise<string> | undefined;
private _getServiceMachineId(): Promise<string> {
if (!this._serviceMachineIdPromise) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export class ExtensionResourceLoaderService extends AbstractExtensionResourceLoa

async readExtensionResource(uri: URI): Promise<string> {
if (await this.isExtensionGalleryResource(uri)) {
const headers = await this.getExtensionGalleryRequestHeaders();
const headers = await this.getExtensionGalleryRequestHeaders(uri);
const requestContext = await this._requestService.request({ url: uri.toString(), headers, callSite: 'extensionResourceLoader.readExtensionResource' }, CancellationToken.None);
return (await asTextOrError(requestContext)) || '';
}
Expand Down
Loading
Loading