Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,13 @@ export interface IExtensionGalleryManifestService {
readonly onDidChangeExtensionGalleryManifestStatus: Event<ExtensionGalleryManifestStatus>;
readonly onDidChangeExtensionGalleryManifest: Event<IExtensionGalleryManifest | null>;
getExtensionGalleryManifest(): Promise<IExtensionGalleryManifest | null>;

/**
* Headers authenticating a request to `targetUrl`, empty when the marketplace does not gate its
* requests. Resolved here rather than by callers so the rule that decides which origins may
* receive the marketplace bearer has one implementation.
*/
getAuthorizationHeaders(targetUrl: string): Promise<Record<string, string>>;
}

export function getExtensionGalleryManifestResourceUri(manifest: IExtensionGalleryManifest, type: string): string | undefined {
Expand All @@ -100,3 +107,9 @@ export function getExtensionGalleryManifestResourceUri(manifest: IExtensionGalle
export const ExtensionGalleryServiceUrlConfigKey = 'extensions.gallery.serviceUrl';

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

/** The subset of RFC 9728 Protected Resource Metadata the marketplace negotiation needs. */
export interface IMarketplaceProtectedResource {
readonly authorizationServer: string;
readonly scopes: readonly string[];
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import { Event } from '../../../base/common/event.js';
import { Disposable } from '../../../base/common/lifecycle.js';
import { URI } from '../../../base/common/uri.js';
import { IProductService } from '../../product/common/productService.js';
import { ExtensionGalleryResourceType, Flag, IExtensionGalleryManifest, IExtensionGalleryManifestService, ExtensionGalleryManifestStatus } from './extensionGalleryManifest.js';
import { FilterType, SortBy } from './extensionManagement.js';
Expand Down Expand Up @@ -35,6 +36,44 @@ export class ExtensionGalleryManifestService extends Disposable implements IExte
super();
}

/**
* Credentials for the marketplace this implementation fronts, set by subclasses that negotiate
* or are handed them. Absent for the default marketplace, which gates nothing.
*/
protected marketplaceAccessToken: string | undefined;
protected marketplaceServiceIndexUrl: string | undefined;

/**
* The bearer is attached ONLY to an `https` request to the same origin as the service index —
* the endpoint that demanded it and that the token was minted for. A marketplace may serve
* assets from elsewhere (upstreamed extensions come from the public marketplace), and those
* requests must stay anonymous. Fails closed on anything not verifiably that origin.
*/
async getAuthorizationHeaders(targetUrl: string): Promise<Record<string, string>> {
const serviceIndexUrl = this.marketplaceServiceIndexUrl;
if (!this.marketplaceAccessToken || !serviceIndexUrl || !this.isSameSecureOrigin(targetUrl, serviceIndexUrl)) {
return {};
}
return { Authorization: `Bearer ${this.marketplaceAccessToken}` };
}

/**
* Deliberately stricter than the neighbouring gallery-resource check, which matches on the
* parent domain: a marketplace can share its parent domain with unrelated tenants, and matching
* on it would hand them the bearer.
*/
private 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;
}
}

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 @@ -39,7 +39,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], args[2]));
}
throw new Error('Invalid call');
}
Expand All @@ -51,9 +51,18 @@ export class ExtensionGalleryManifestIPCService extends ExtensionGalleryManifest
return this._extensionGalleryManifest ?? null;
}

private setExtensionGalleryManifest(manifest: IExtensionGalleryManifest | null): void {
override async getAuthorizationHeaders(targetUrl: string): Promise<Record<string, string>> {
await this.barrier.wait();
return super.getAuthorizationHeaders(targetUrl);
}

private setExtensionGalleryManifest(manifest: IExtensionGalleryManifest | null, accessToken?: string, serviceIndexUrl?: string): void {
this.logService.trace(`[Marketplace] Setting manifest ${manifest ? 'available' : 'unavailable'}`);
this._extensionGalleryManifest = manifest;
// This process never negotiates a token itself; it applies the one the window negotiated to
// the marketplace requests it initiates — extension `getManifest`, VSIX download.
this.marketplaceAccessToken = accessToken;
this.marketplaceServiceIndexUrl = serviceIndexUrl;
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 @@ -1421,8 +1421,10 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle
});

const commonHeaders = await this.commonHeadersPromise;
const authHeader = await this.extensionGalleryManifestService.getAuthorizationHeaders(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 @@ -1560,8 +1562,10 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle

try {
const commonHeaders = await this.commonHeadersPromise;
const authHeader = await this.extensionGalleryManifestService.getAuthorizationHeaders(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 @@ -1665,7 +1669,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.extensionGalleryManifestService.getAuthorizationHeaders(url);
const headers = { ...commonHeaders, ...authHeader, Accept };
try {
await this.requestService.request({
type: 'POST',
Expand Down Expand Up @@ -1862,7 +1867,9 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle

const url = asset.uri;
const fallbackUrl = asset.fallbackUri;
const firstOptions = { ...options, url, timeout: this.getRequestTimeout(), callSite };
// The primary and fallback URLs can differ in origin, so the guard is evaluated for each.
const primaryAuthHeader = await this.extensionGalleryManifestService.getAuthorizationHeaders(url);
const firstOptions = { ...options, headers: { ...headers, ...primaryAuthHeader }, url, timeout: this.getRequestTimeout(), callSite };

let context;
try {
Expand Down Expand Up @@ -1908,7 +1915,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 = await this.extensionGalleryManifestService.getAuthorizationHeaders(fallbackUrl);
const fallbackOptions = { ...options, headers: { ...headers, ...fallbackAuthHeader }, url: fallbackUrl, timeout: this.getRequestTimeout(), callSite: `${callSite}.fallback` };
return this.requestService.request(fallbackOptions, token);
}
}
Expand All @@ -1924,9 +1932,11 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle
return { malicious: [], deprecated: {}, search: [], autoUpdate: {} };
}

const authHeader = await this.extensionGalleryManifestService.getAuthorizationHeaders(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
@@ -0,0 +1,83 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import assert from 'assert';
import { IChannelServer, IServerChannel } from '../../../../base/parts/ipc/common/ipc.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { ExtensionGalleryManifestIPCService } from '../../common/extensionGalleryManifestServiceIpc.js';
import { ExtensionGalleryResourceType, IExtensionGalleryManifest } from '../../common/extensionGalleryManifest.js';
import { NullLogService } from '../../../log/common/log.js';
import { IProductService } from '../../../product/common/productService.js';

suite('ExtensionGalleryManifestIPCService', () => {

const disposableStore = ensureNoDisposablesAreLeakedInTestSuite();

const MARKETPLACE_URL = 'https://marketplace.example.com';

const manifest: IExtensionGalleryManifest = {
version: '1.0',
resources: [{ id: `${MARKETPLACE_URL}/extensionquery`, type: ExtensionGalleryResourceType.ExtensionQueryService }],
capabilities: { extensionQuery: {} }
};

/**
* Stands in for the window process, which is the only one that negotiates with the marketplace.
* The channel is the sole route by which this process learns what it may authenticate with, so
* these tests pin the shape of that call.
*/
function createService(): { service: ExtensionGalleryManifestIPCService; push: (...args: unknown[]) => Promise<void> } {
let channel: IServerChannel<unknown> | undefined;
const server: IChannelServer<unknown> = {
registerChannel: (_name: string, serverChannel: IServerChannel<unknown>) => { channel = serverChannel; }
};

const service = disposableStore.add(new ExtensionGalleryManifestIPCService(
server,
new NullLogService(),
{ extensionsGallery: undefined } as IProductService
));
return {
service,
push: async (...args: unknown[]) => { await channel!.call(undefined, 'setExtensionGalleryManifest', args); }
};
}

test('a pushed token authenticates the marketplace it was pushed for', async () => {
const { service, push } = createService();

await push(manifest, 'resource-token', MARKETPLACE_URL);

assert.deepStrictEqual(await service.getAuthorizationHeaders(`${MARKETPLACE_URL}/vsix`), { Authorization: 'Bearer resource-token' });
});

test('a pushed token is withheld from every other origin', async () => {
const { service, push } = createService();

await push(manifest, 'resource-token', MARKETPLACE_URL);

// Upstreamed extensions are downloaded from the public marketplace, which must never be
// handed a private marketplace's bearer.
assert.deepStrictEqual(await service.getAuthorizationHeaders('https://marketplace.visualstudio.com/x.vsix'), {});
assert.deepStrictEqual(await service.getAuthorizationHeaders('http://marketplace.example.com/x.vsix'), {});
});

test('an open marketplace pushes no token and authenticates nothing', async () => {
const { service, push } = createService();

await push(manifest, undefined, undefined);

assert.deepStrictEqual(await service.getAuthorizationHeaders(`${MARKETPLACE_URL}/vsix`), {});
});

test('retracting the marketplace retracts what it could be reached with', async () => {
const { service, push } = createService();
await push(manifest, 'resource-token', MARKETPLACE_URL);

await push(null, undefined, undefined);

assert.deepStrictEqual(await service.getAuthorizationHeaders(`${MARKETPLACE_URL}/vsix`), {});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,8 @@ function createExtensionGalleryManifestService(): IExtensionGalleryManifestServi
extensionGalleryManifestStatus: ExtensionGalleryManifestStatus.Available,
onDidChangeExtensionGalleryManifestStatus: Event.None,
onDidChangeExtensionGalleryManifest: Event.None,
getExtensionGalleryManifest: async () => extensionGalleryManifest
getExtensionGalleryManifest: async () => extensionGalleryManifest,
getAuthorizationHeaders: async (): Promise<Record<string, string>> => ({})
};
}

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,6 +143,7 @@ export abstract class AbstractExtensionResourceLoaderService extends Disposable
if (this._productService.commit) {
headers['X-Client-Commit'] = this._productService.commit;
}
Object.assign(headers, await this._extensionGalleryManifestService.getAuthorizationHeaders(resource.toString(true)));
return headers;
}

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
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*--------------------------------------------------------------------------------------------*/

import { Event } from '../../../../base/common/event.js';
import { IMarketplaceProtectedResource } from '../../../../platform/extensionManagement/common/extensionGalleryManifest.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';

/** `accessToken` is only carried when the provider authenticates with a bearer. */
Expand All @@ -30,25 +31,31 @@ export interface IExtensionGalleryAccountProvider {
readonly onDidChangeAccountStatus: Event<ExtensionGalleryAccountStatus>;
readonly onDidChangeAccount: Event<void>;

/** Never prompts. Check {@link accountStatus} for whether the account may actually be used. */
getAccount(): Promise<IExtensionGalleryAccount | undefined>;
/**
* Never prompts. Check {@link accountStatus} for whether the account may actually be used.
* With `protectedResource`, the returned `accessToken` is scoped to that resource.
*/
getAccount(protectedResource?: IMarketplaceProtectedResource): Promise<IExtensionGalleryAccount | undefined>;

/** Interactive. The provider owns account selection and how the session is obtained. */
signIn(): Promise<void>;
}

export const IExtensionGalleryAccountService = createDecorator<IExtensionGalleryAccountService>('extensionGalleryAccountService');

/** Identity and entitlement for the Private Marketplace. Knows nothing about URLs or HTTP. */
/** Identity and entitlement for the Private Marketplace. Makes no marketplace requests of its own. */
export interface IExtensionGalleryAccountService {
readonly _serviceBrand: undefined;

readonly accountStatus: ExtensionGalleryAccountStatus;
readonly onDidChangeAccountStatus: Event<ExtensionGalleryAccountStatus>;
readonly onDidChangeAccount: Event<void>;

/** Never prompts. Check {@link accountStatus} for whether the account may actually be used. */
getAccount(): Promise<IExtensionGalleryAccount | undefined>;
/**
* Never prompts. Check {@link accountStatus} for whether the account may actually be used.
* With `protectedResource`, the returned `accessToken` is scoped to that resource.
*/
getAccount(protectedResource?: IMarketplaceProtectedResource): Promise<IExtensionGalleryAccount | undefined>;

/** Interactive sign-in for whichever provider the deployment configured. */
signIn(): Promise<void>;
Expand Down
Loading
Loading