diff --git a/src/vs/platform/extensionManagement/common/extensionGalleryManifest.ts b/src/vs/platform/extensionManagement/common/extensionGalleryManifest.ts index 02fc94c325c9d3..68b7cb90dca56a 100644 --- a/src/vs/platform/extensionManagement/common/extensionGalleryManifest.ts +++ b/src/vs/platform/extensionManagement/common/extensionGalleryManifest.ts @@ -80,6 +80,13 @@ export interface IExtensionGalleryManifestService { readonly onDidChangeExtensionGalleryManifestStatus: Event; readonly onDidChangeExtensionGalleryManifest: Event; getExtensionGalleryManifest(): Promise; + + /** + * 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>; } export function getExtensionGalleryManifestResourceUri(manifest: IExtensionGalleryManifest, type: string): string | undefined { @@ -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[]; +} diff --git a/src/vs/platform/extensionManagement/common/extensionGalleryManifestService.ts b/src/vs/platform/extensionManagement/common/extensionGalleryManifestService.ts index 53be55bdffd20f..d81929789792c9 100644 --- a/src/vs/platform/extensionManagement/common/extensionGalleryManifestService.ts +++ b/src/vs/platform/extensionManagement/common/extensionGalleryManifestService.ts @@ -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'; @@ -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> { + 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 { const extensionsGallery = this.productService.extensionsGallery as ExtensionGalleryConfig | undefined; if (!extensionsGallery?.serviceUrl) { diff --git a/src/vs/platform/extensionManagement/common/extensionGalleryManifestServiceIpc.ts b/src/vs/platform/extensionManagement/common/extensionGalleryManifestServiceIpc.ts index 9417508a9b8421..643b82d6880810 100644 --- a/src/vs/platform/extensionManagement/common/extensionGalleryManifestServiceIpc.ts +++ b/src/vs/platform/extensionManagement/common/extensionGalleryManifestServiceIpc.ts @@ -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 => { 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'); } @@ -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> { + 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(); diff --git a/src/vs/platform/extensionManagement/common/extensionGalleryService.ts b/src/vs/platform/extensionManagement/common/extensionGalleryService.ts index b00f8d12879a83..d334a65c223191 100644 --- a/src/vs/platform/extensionManagement/common/extensionGalleryService.ts +++ b/src/vs/platform/extensionManagement/common/extensionGalleryService.ts @@ -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', @@ -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', @@ -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', @@ -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 { @@ -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); } } @@ -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); diff --git a/src/vs/platform/extensionManagement/test/common/extensionGalleryManifestServiceIpc.test.ts b/src/vs/platform/extensionManagement/test/common/extensionGalleryManifestServiceIpc.test.ts new file mode 100644 index 00000000000000..fd960816d3055e --- /dev/null +++ b/src/vs/platform/extensionManagement/test/common/extensionGalleryManifestServiceIpc.test.ts @@ -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 } { + let channel: IServerChannel | undefined; + const server: IChannelServer = { + registerChannel: (_name: string, serverChannel: IServerChannel) => { 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`), {}); + }); +}); diff --git a/src/vs/platform/extensionManagement/test/common/extensionGalleryService.test.ts b/src/vs/platform/extensionManagement/test/common/extensionGalleryService.test.ts index 6eff766ec04b46..e564c0083a0738 100644 --- a/src/vs/platform/extensionManagement/test/common/extensionGalleryService.test.ts +++ b/src/vs/platform/extensionManagement/test/common/extensionGalleryService.test.ts @@ -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> => ({}) }; } diff --git a/src/vs/platform/extensionResourceLoader/browser/extensionResourceLoaderService.ts b/src/vs/platform/extensionResourceLoader/browser/extensionResourceLoaderService.ts index 921674ef18e4bc..05358567d8a591 100644 --- a/src/vs/platform/extensionResourceLoader/browser/extensionResourceLoaderService.ts +++ b/src/vs/platform/extensionResourceLoader/browser/extensionResourceLoaderService.ts @@ -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 */ } diff --git a/src/vs/platform/extensionResourceLoader/common/extensionResourceLoader.ts b/src/vs/platform/extensionResourceLoader/common/extensionResourceLoader.ts index 28a25db97f004f..21aedde2cf7ad6 100644 --- a/src/vs/platform/extensionResourceLoader/common/extensionResourceLoader.ts +++ b/src/vs/platform/extensionResourceLoader/common/extensionResourceLoader.ts @@ -132,7 +132,7 @@ export abstract class AbstractExtensionResourceLoaderService extends Disposable return !!this._extensionGalleryAuthority && this._extensionGalleryAuthority === this._getExtensionGalleryAuthority(uri); } - protected async getExtensionGalleryRequestHeaders(): Promise> { + protected async getExtensionGalleryRequestHeaders(resource: URI): Promise> { const headers: Record = { 'X-Client-Name': `${this._productService.applicationName}${isWeb ? '-web' : ''}`, 'X-Client-Version': this._productService.version @@ -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; } diff --git a/src/vs/platform/extensionResourceLoader/common/extensionResourceLoaderService.ts b/src/vs/platform/extensionResourceLoader/common/extensionResourceLoaderService.ts index 638db3465469ce..21126f1454c15b 100644 --- a/src/vs/platform/extensionResourceLoader/common/extensionResourceLoaderService.ts +++ b/src/vs/platform/extensionResourceLoader/common/extensionResourceLoaderService.ts @@ -33,7 +33,7 @@ export class ExtensionResourceLoaderService extends AbstractExtensionResourceLoa async readExtensionResource(uri: URI): Promise { 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)) || ''; } diff --git a/src/vs/workbench/services/extensionManagement/common/extensionGalleryAccount.ts b/src/vs/workbench/services/extensionManagement/common/extensionGalleryAccount.ts index fd0d8b5dbfd1d8..2e196e824349cf 100644 --- a/src/vs/workbench/services/extensionManagement/common/extensionGalleryAccount.ts +++ b/src/vs/workbench/services/extensionManagement/common/extensionGalleryAccount.ts @@ -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. */ @@ -30,8 +31,11 @@ export interface IExtensionGalleryAccountProvider { readonly onDidChangeAccountStatus: Event; readonly onDidChangeAccount: Event; - /** Never prompts. Check {@link accountStatus} for whether the account may actually be used. */ - getAccount(): Promise; + /** + * 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; /** Interactive. The provider owns account selection and how the session is obtained. */ signIn(): Promise; @@ -39,7 +43,7 @@ export interface IExtensionGalleryAccountProvider { export const IExtensionGalleryAccountService = createDecorator('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; @@ -47,8 +51,11 @@ export interface IExtensionGalleryAccountService { readonly onDidChangeAccountStatus: Event; readonly onDidChangeAccount: Event; - /** Never prompts. Check {@link accountStatus} for whether the account may actually be used. */ - getAccount(): Promise; + /** + * 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; /** Interactive sign-in for whichever provider the deployment configured. */ signIn(): Promise; diff --git a/src/vs/workbench/services/extensionManagement/electron-browser/extensionGalleryAccountService.ts b/src/vs/workbench/services/extensionManagement/electron-browser/extensionGalleryAccountService.ts index e9a2dec3d3c7a2..92d91ac2223346 100644 --- a/src/vs/workbench/services/extensionManagement/electron-browser/extensionGalleryAccountService.ts +++ b/src/vs/workbench/services/extensionManagement/electron-browser/extensionGalleryAccountService.ts @@ -7,10 +7,11 @@ import { IDefaultAccount } from '../../../../base/common/defaultAccount.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { getClaimsFromJWT } from '../../../../base/common/oauth.js'; +import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; -import { ExtensionGalleryAuthProviderConfigKey } from '../../../../platform/extensionManagement/common/extensionGalleryManifest.js'; +import { ExtensionGalleryAuthProviderConfigKey, IMarketplaceProtectedResource } from '../../../../platform/extensionManagement/common/extensionGalleryManifest.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -78,9 +79,9 @@ abstract class AbstractGalleryAccountProvider extends Disposable implements IExt super(); } - async getAccount(): Promise { + async getAccount(protectedResource?: IMarketplaceProtectedResource): Promise { try { - return await this.doGetAccount(); + return await this.doGetAccount(protectedResource); } catch (error) { // Distinct from "no account" so the caller does not demand sign-in for a transient failure. this.logService.error('[Marketplace] Unable to resolve the marketplace account', error); @@ -89,7 +90,7 @@ abstract class AbstractGalleryAccountProvider extends Disposable implements IExt } } - protected abstract doGetAccount(): Promise; + protected abstract doGetAccount(protectedResource?: IMarketplaceProtectedResource): Promise; abstract signIn(): Promise; @@ -122,6 +123,7 @@ export class GitHubGalleryAccountProvider extends AbstractGalleryAccountProvider } protected override async doGetAccount(): Promise { + // Entitlement here is the account's SKU, and this path carries no bearer to scope. const account = await this.defaultAccountService.getDefaultAccount(); if (!account) { this.setAccountStatus(ExtensionGalleryAccountStatus.SignedOut); @@ -178,8 +180,15 @@ export class MicrosoftGalleryAccountProvider extends AbstractGalleryAccountProvi return this.productService.extensionsGallery?.accessScopes; } - protected override async doGetAccount(): Promise { - const session = await this.getSession(); + /** The resource the marketplace last advertised, if it gates its requests at all. */ + private protectedResource: IMarketplaceProtectedResource | undefined; + + protected override async doGetAccount(protectedResource?: IMarketplaceProtectedResource): Promise { + if (protectedResource) { + // Remembered so an interactive sign-in can consent to the same resource. + this.protectedResource = protectedResource; + } + const session = await this.getSession(protectedResource); if (!session) { this.setAccountStatus(ExtensionGalleryAccountStatus.SignedOut); return undefined; @@ -209,9 +218,10 @@ export class MicrosoftGalleryAccountProvider extends AbstractGalleryAccountProvi /** * Anchored to the remembered account rather than an arbitrary `sessions[0]`. Several accounts - * with no preference returns `undefined` rather than guessing. Never prompts. + * with no preference returns `undefined` rather than guessing. Never prompts. The account is + * resolved before any resource token, so that token is minted for the identity the user chose. */ - private async getSession(): Promise { + private async getSession(protectedResource?: IMarketplaceProtectedResource): Promise { const scopes = this.scopes; if (!scopes) { this.logService.error('[Marketplace] extensionsGallery.accessScopes is not configured — the Microsoft marketplace path cannot request a session.'); @@ -221,6 +231,37 @@ export class MicrosoftGalleryAccountProvider extends AbstractGalleryAccountProvi if (sessions.length === 0) { return undefined; } + const session = this.pickSession(sessions); + if (!session || !protectedResource) { + return session; + } + return await this.getResourceSession(session, protectedResource) ?? session; + } + + /** + * Requests the resource-scoped session (RFC 8707) for the already-chosen account. The + * marketplace names its own authorization server, so it is the authentication provider that + * decides whether that server is one it will mint for — an unsupported one throws, and is + * treated here as simply having no session. + */ + private async getResourceSession(session: AuthenticationSession, protectedResource: IMarketplaceProtectedResource): Promise { + const resourceScopes = protectedResource.scopes.length ? [...protectedResource.scopes] : this.scopes; + if (!resourceScopes) { + return undefined; + } + try { + const sessions = await this.authenticationService.getSessions('microsoft', resourceScopes, { + account: session.account, + authorizationServer: URI.parse(protectedResource.authorizationServer) + }); + return sessions.at(0); + } catch (error) { + this.logService.error('[Marketplace] Unable to acquire a resource-scoped marketplace token', error); + return undefined; + } + } + + private pickSession(sessions: readonly AuthenticationSession[]): AuthenticationSession | undefined { const preferredId = this.readPreferredAccountId(); if (preferredId) { const remembered = sessions.find(session => session.account.id === preferredId); @@ -252,6 +293,7 @@ export class MicrosoftGalleryAccountProvider extends AbstractGalleryAccountProvi } const session = await this.authenticationService.createSession('microsoft', scopes, account ? { account } : undefined); this.storePreferredAccountId(session.account.id); + await this.consentToProtectedResource(session); }; const accounts = await this.authenticationService.getAccounts('microsoft'); @@ -275,6 +317,33 @@ export class MicrosoftGalleryAccountProvider extends AbstractGalleryAccountProvi await chooseAccount(pick.account); } + /** + * Consents to the marketplace's resource while the user is already signing in. The access check + * cannot prompt, so without this a user whose tenant has not pre-consented would authenticate + * and still be sent back to the sign-in prompt. Tries silently first; failure is not fatal. + */ + private async consentToProtectedResource(session: AuthenticationSession): Promise { + const protectedResource = this.protectedResource; + if (!protectedResource) { + return; + } + if (await this.getResourceSession(session, protectedResource)) { + return; + } + const resourceScopes = protectedResource.scopes.length ? [...protectedResource.scopes] : this.scopes; + if (!resourceScopes) { + return; + } + try { + await this.authenticationService.createSession('microsoft', resourceScopes, { + account: session.account, + authorizationServer: URI.parse(protectedResource.authorizationServer) + }); + } catch (error) { + this.logService.error('[Marketplace] Unable to consent to the marketplace resource during sign-in', error); + } + } + private readPreferredAccountId(): string | undefined { const raw = this.storageService.get(PREFERRED_ACCOUNT_KEY, StorageScope.APPLICATION); if (!raw) { @@ -334,8 +403,8 @@ export class ExtensionGalleryAccountService extends Disposable implements IExten this._onDidChangeAccount.fire(); } - async getAccount(): Promise { - return this.provider?.getAccount(); + async getAccount(protectedResource?: IMarketplaceProtectedResource): Promise { + return this.provider?.getAccount(protectedResource); } async signIn(): Promise { diff --git a/src/vs/workbench/services/extensionManagement/electron-browser/extensionGalleryManifestService.ts b/src/vs/workbench/services/extensionManagement/electron-browser/extensionGalleryManifestService.ts index 2b440fb44a8f31..e54813aa732484 100644 --- a/src/vs/workbench/services/extensionManagement/electron-browser/extensionGalleryManifestService.ts +++ b/src/vs/workbench/services/extensionManagement/electron-browser/extensionGalleryManifestService.ts @@ -5,11 +5,12 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Emitter } from '../../../../base/common/event.js'; +import { fetchResourceMetadata, parseWWWAuthenticateHeader } from '../../../../base/common/oauth.js'; import { IHeaders } from '../../../../base/parts/request/common/request.js'; import { localize } from '../../../../nls.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; -import { IExtensionGalleryManifestService, IExtensionGalleryManifest, ExtensionGalleryServiceUrlConfigKey, ExtensionGalleryAuthProviderConfigKey, ExtensionGalleryManifestStatus } from '../../../../platform/extensionManagement/common/extensionGalleryManifest.js'; +import { IExtensionGalleryManifestService, IExtensionGalleryManifest, ExtensionGalleryServiceUrlConfigKey, ExtensionGalleryAuthProviderConfigKey, ExtensionGalleryManifestStatus, IMarketplaceProtectedResource } from '../../../../platform/extensionManagement/common/extensionGalleryManifest.js'; import { ExtensionGalleryManifestService } from '../../../../platform/extensionManagement/common/extensionGalleryManifestService.js'; import { resolveMarketplaceHeaders } from '../../../../platform/externalServices/common/marketplace.js'; import { IFileService } from '../../../../platform/files/common/files.js'; @@ -17,13 +18,19 @@ import { InstantiationType, registerSingleton } from '../../../../platform/insta import { ISharedProcessService } from '../../../../platform/ipc/electron-browser/services.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; -import { asJson, IRequestService } from '../../../../platform/request/common/request.js'; +import { asJson, asText, IRequestService } from '../../../../platform/request/common/request.js'; import { IStorageService } from '../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IRemoteAgentService } from '../../remote/common/remoteAgentService.js'; import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { IHostService } from '../../host/browser/host.js'; -import { ExtensionGalleryAccountStatus, IExtensionGalleryAccountService } from '../common/extensionGalleryAccount.js'; +import { ExtensionGalleryAccountStatus, IExtensionGalleryAccount, IExtensionGalleryAccountService } from '../common/extensionGalleryAccount.js'; + +class MarketplaceAuthRequiredError extends Error { + constructor(readonly wwwAuthenticate: string | undefined) { + super('Marketplace requires authentication.'); + } +} export class WorkbenchExtensionGalleryManifestService extends ExtensionGalleryManifestService implements IExtensionGalleryManifestService { @@ -70,7 +77,8 @@ export class WorkbenchExtensionGalleryManifestService extends ExtensionGalleryMa } const updateChannels = (manifest: IExtensionGalleryManifest | null) => { this.logService.trace(`[Marketplace] Updating channels with manifest ${manifest ? 'available' : 'unavailable'}`); - channels.forEach(channel => channel.call('setExtensionGalleryManifest', [manifest])); + // The shared process and remote server never negotiate a token themselves. + channels.forEach(channel => channel.call('setExtensionGalleryManifest', [manifest, this.marketplaceAccessToken, this.marketplaceServiceIndexUrl])); }; this.getExtensionGalleryManifest().then(manifest => { if (this._store.isDisposed) { @@ -149,7 +157,7 @@ export class WorkbenchExtensionGalleryManifestService extends ExtensionGalleryMa case ExtensionGalleryAccountStatus.Eligible: try { - const manifest = await this.getExtensionGalleryManifestFromServiceUrl(configuredServiceUrl); + const manifest = await this.fetchManifestWithAccess(configuredServiceUrl, account); this.update(manifest); this.telemetryService.publicLog2< {}, @@ -158,6 +166,15 @@ export class WorkbenchExtensionGalleryManifestService extends ExtensionGalleryMa comment: 'Reports when a user successfully accesses a custom marketplace'; }>('galleryservice:custom:marketplace'); } catch (error) { + // A marketplace still asking to authenticate is not a verdict about the + // account. Send the user back to sign-in, where consent for the marketplace's + // resource can be granted, rather than telling them to contact an + // administrator about a state they can resolve themselves. + if (error instanceof MarketplaceAuthRequiredError) { + this.logService.debug('[Marketplace] Marketplace still requires authentication after negotiation'); + this.update(null, ExtensionGalleryManifestStatus.RequiresSignIn); + return; + } this.logService.error('[Marketplace] Error fetching manifest from custom marketplace', error); this.update(null, ExtensionGalleryManifestStatus.AccessDenied); } @@ -169,8 +186,87 @@ export class WorkbenchExtensionGalleryManifestService extends ExtensionGalleryMa } } + /** + * Reads the service index with the bearer the account already carries, and — when the + * marketplace refuses it — negotiates one minted for the marketplace itself and reads again. + * The gate is discovered, not configured, so a marketplace that accepts what it is given is + * never asked what a token for it should look like. + */ + private async fetchManifestWithAccess(configuredServiceUrl: string, account: IExtensionGalleryAccount): Promise { + this.marketplaceServiceIndexUrl = configuredServiceUrl; + try { + const manifest = await this.getExtensionGalleryManifestFromServiceUrl(configuredServiceUrl, account.accessToken); + this.marketplaceAccessToken = account.accessToken; + return manifest; + } catch (error) { + if (!(error instanceof MarketplaceAuthRequiredError)) { + throw error; + } + this.logService.trace('[Marketplace] Service index requires authentication, negotiating a resource-scoped token'); + + const protectedResource = await this.discoverProtectedResource(configuredServiceUrl, error.wwwAuthenticate); + if (!protectedResource) { + throw error; + } + + const negotiated = await this.galleryAccountService.getAccount(protectedResource); + if (!negotiated?.accessToken || negotiated.accessToken === account.accessToken) { + // Nothing new to present: retrying would repeat the request the marketplace rejected. + throw error; + } + + const manifest = await this.getExtensionGalleryManifestFromServiceUrl(configuredServiceUrl, negotiated.accessToken); + this.marketplaceAccessToken = negotiated.accessToken; + return manifest; + } + } + + /** + * Reads the marketplace's Protected Resource Metadata (RFC 9728), or `undefined` when it + * advertises none. + * + * Discovery goes to the well-known endpoint rather than the `WWW-Authenticate` challenge because + * that header is not CORS-safelisted, so this cross-origin fetch usually cannot read it; the + * challenge is only a hint for an explicit `resource_metadata` URL. + */ + private async discoverProtectedResource(serviceIndexUrl: string, wwwAuthenticate: string | undefined): Promise { + 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 }) => { + const context = await this.requestService.request({ type: init.method, url: input, headers: init.headers, callSite: 'extensionGalleryManifestService.discoverProtectedResource' }, CancellationToken.None); + return { + status: context.res.statusCode ?? 0, + statusText: '', + json: async (): Promise => await asJson(context), + text: async (): Promise => (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 ?? [] }; + } catch { + return undefined; + } + } + private update(manifest: IExtensionGalleryManifest | null, status?: ExtensionGalleryManifestStatus): void { this.logService.debug(`[Marketplace] Updating manifest ${manifest ? 'available' : 'unavailable'}`); + if (!manifest) { + // Retracting the marketplace retracts the right to speak to it. + this.marketplaceAccessToken = undefined; + this.marketplaceServiceIndexUrl = undefined; + } if (this.extensionGalleryManifest !== manifest) { this.extensionGalleryManifest = manifest; this._onDidChangeExtensionGalleryManifest.fire(manifest); @@ -195,13 +291,16 @@ export class WorkbenchExtensionGalleryManifestService extends ExtensionGalleryMa } } - private async getExtensionGalleryManifestFromServiceUrl(url: string): Promise { + private async getExtensionGalleryManifestFromServiceUrl(url: string, accessToken?: string): Promise { const commonHeaders = await this.commonHeadersPromise; - const headers = { + const headers: IHeaders = { ...commonHeaders, 'Content-Type': 'application/json', 'Accept-Encoding': 'gzip', }; + if (accessToken) { + headers['Authorization'] = `Bearer ${accessToken}`; + } try { const context = await this.requestService.request({ @@ -211,6 +310,13 @@ export class WorkbenchExtensionGalleryManifestService extends ExtensionGalleryMa callSite: 'extensionGalleryManifestService.fetchManifest' }, CancellationToken.None); + // The expected first exchange with a gated marketplace, not a failure. + const statusCode = context.res.statusCode; + if (statusCode === 401 || statusCode === 403) { + const challenge = context.res.headers?.['www-authenticate']; + throw new MarketplaceAuthRequiredError(Array.isArray(challenge) ? challenge[0] : challenge); + } + const extensionGalleryManifest = await asJson(context); if (!extensionGalleryManifest) { @@ -219,6 +325,9 @@ export class WorkbenchExtensionGalleryManifestService extends ExtensionGalleryMa return extensionGalleryManifest; } catch (error) { + if (error instanceof MarketplaceAuthRequiredError) { + throw error; + } this.logService.error('[Marketplace] Error retrieving extension gallery manifest', error); throw error; } diff --git a/src/vs/workbench/services/extensionManagement/test/electron-browser/extensionGalleryManifestService.test.ts b/src/vs/workbench/services/extensionManagement/test/electron-browser/extensionGalleryManifestService.test.ts index 738046264b3415..f1f012fe3a1af7 100644 --- a/src/vs/workbench/services/extensionManagement/test/electron-browser/extensionGalleryManifestService.test.ts +++ b/src/vs/workbench/services/extensionManagement/test/electron-browser/extensionGalleryManifestService.test.ts @@ -27,7 +27,7 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../../pla import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { NullTelemetryServiceShape } from '../../../../../platform/telemetry/common/telemetryUtils.js'; import { IConfirmation, IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; -import { AuthenticationSession, AuthenticationSessionsChangeEvent, IAuthenticationService } from '../../../authentication/common/authentication.js'; +import { AuthenticationSession, AuthenticationSessionsChangeEvent, IAuthenticationGetSessionsOptions, IAuthenticationService } from '../../../authentication/common/authentication.js'; import { IHostService } from '../../../host/browser/host.js'; import { IRemoteAgentService } from '../../../remote/common/remoteAgentService.js'; import { WorkbenchExtensionGalleryManifestService } from '../../electron-browser/extensionGalleryManifestService.js'; @@ -860,4 +860,161 @@ suite('WorkbenchExtensionGalleryManifestService', () => { assert.strictEqual(customMarketplaceCount(), 0); assert.deepStrictEqual(authCheckedEvents(), []); }); + + // --- Gated service index (RFC 9728 / RFC 8707) --- + + /** + * Answers the marketplace's protected resource metadata, and gates the index on the + * resource-scoped bearer so a test fails if the sign-in token is presented instead. + */ + function gatedMarketplace(resourceToken: string, metadata?: object): (options: IRequestOptions) => IRequestContext { + return options => { + if (options.url?.includes('/.well-known/oauth-protected-resource')) { + return metadata + ? mockResponse(200, metadata) + : mockResponse(404, { error: 'not_found' }); + } + return options.headers?.['Authorization'] === `Bearer ${resourceToken}` + ? mockResponse(200, createGalleryManifest()) + : mockResponse(401, { message: 'authentication required' }); + }; + } + + const MARKETPLACE_URL = 'https://marketplace.example.com'; + + const PROTECTED_RESOURCE_METADATA = { + resource: 'https://marketplace.example.com', + authorization_servers: ['https://login.example.com/common'], + scopes_supported: ['api://marketplace.example.com/.default'], + }; + /** Mints `resource-token` only for a request that names the advertised authorization server. */ + function stubResourceScopedAuthentication(): void { + instantiationService.stub(IAuthenticationService, new class extends mock() { + override readonly onDidChangeSessions = onDidChangeSessions.event; + override async getSessions(providerId: string, _scopes?: readonly string[], options?: IAuthenticationGetSessionsOptions) { + if (providerId !== 'microsoft') { + return []; + } + return options?.authorizationServer ? [createMicrosoftSession('resource-token')] : microsoftSessions; + } + override async createSession() { return createMicrosoftSession(); } + }()); + } + + test('Microsoft — gated index negotiates a resource-scoped token and becomes Available', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + microsoftSessions = [createMicrosoftSession('signin-token')]; + stubResourceScopedAuthentication(); + // The sign-in token identifies the user but is not minted for the marketplace, so the index + // rejects it; only the token acquired against the advertised authorization server is accepted. + requestHandler = gatedMarketplace('resource-token', PROTECTED_RESOURCE_METADATA); + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Available); + assert.deepStrictEqual(await service.getAuthorizationHeaders(MARKETPLACE_URL), { Authorization: 'Bearer resource-token' }); + }); + + test('Microsoft — an index that accepts the sign-in token needs no negotiation', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + microsoftSessions = [createMicrosoftSession('signin-token')]; + let wellKnownRequests = 0; + requestHandler = options => { + if (options.url?.includes('/.well-known/oauth-protected-resource')) { + wellKnownRequests++; + } + return mockResponse(200, createGalleryManifest()); + }; + + const service = createService(); + await service.getExtensionGalleryManifest(); + + // Nothing gated the read, so the marketplace is never asked what a token for it looks like. + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Available); + assert.strictEqual(wellKnownRequests, 0); + // The bearer the index accepted is the one carried forward. + assert.deepStrictEqual(await service.getAuthorizationHeaders(MARKETPLACE_URL), { Authorization: 'Bearer signin-token' }); + }); + + test('Microsoft — gated index advertising no protected resource → RequiresSignIn without a token', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + microsoftSessions = [createMicrosoftSession('signin-token')]; + stubResourceScopedAuthentication(); + // The index gates every read but publishes no metadata, so no token can be minted for it. + requestHandler = gatedMarketplace('resource-token'); + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.RequiresSignIn); + assert.deepStrictEqual(await service.getAuthorizationHeaders(MARKETPLACE_URL), {}); + }); + + test('Microsoft — a marketplace that still refuses the negotiated identity asks to sign in, not an administrator', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + microsoftSessions = [createMicrosoftSession('signin-token')]; + // The marketplace advertises its resource, but no resource-scoped session can be acquired + // silently — the tenant has not consented yet, and the access check cannot prompt. That is + // resolvable by signing in again, so it must not be reported as a denied account: the + // access-denied view only says "contact your administrator" and offers no way to act. + instantiationService.stub(IAuthenticationService, new class extends mock() { + override readonly onDidChangeSessions = onDidChangeSessions.event; + override async getSessions(providerId: string, _scopes?: readonly string[], options?: IAuthenticationGetSessionsOptions) { + if (providerId !== 'microsoft' || options?.authorizationServer) { + return []; + } + return microsoftSessions; + } + override async createSession() { return createMicrosoftSession(); } + }()); + requestHandler = gatedMarketplace('resource-token', PROTECTED_RESOURCE_METADATA); + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.RequiresSignIn); + assert.deepStrictEqual(await service.getAuthorizationHeaders(MARKETPLACE_URL), {}); + }); + + test('Microsoft — a token that the marketplace stops accepting is not retained', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + microsoftSessions = [createMicrosoftSession('signin-token')]; + stubResourceScopedAuthentication(); + requestHandler = gatedMarketplace('resource-token', PROTECTED_RESOURCE_METADATA); + + const service = createService(); + await service.getExtensionGalleryManifest(); + assert.deepStrictEqual(await service.getAuthorizationHeaders(MARKETPLACE_URL), { Authorization: 'Bearer resource-token' }); + + // The account goes away; the marketplace is retracted and the bearer must not outlive it. + microsoftSessions = []; + onDidChangeSessions.fire({ providerId: 'microsoft', label: 'Microsoft', event: { added: [], removed: [], changed: [] } }); + await new Promise(resolve => setTimeout(resolve, 0)); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.RequiresSignIn); + assert.deepStrictEqual(await service.getAuthorizationHeaders(MARKETPLACE_URL), {}); + }); + + test('Microsoft — the token is withheld from every origin but the marketplace', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + microsoftSessions = [createMicrosoftSession('signin-token')]; + stubResourceScopedAuthentication(); + requestHandler = gatedMarketplace('resource-token', PROTECTED_RESOURCE_METADATA); + + const service = createService(); + await service.getExtensionGalleryManifest(); + + // An upstreamed extension's assets are served by the public marketplace, and a private + // marketplace's bearer must never reach it. + assert.deepStrictEqual(await service.getAuthorizationHeaders('https://marketplace.visualstudio.com/_apis/public/gallery/x.vsix'), {}); + // A neighbour sharing the parent domain is still a different origin. + assert.deepStrictEqual(await service.getAuthorizationHeaders('https://assets.example.com/icon.png'), {}); + // Same host over cleartext must not carry it either. + assert.deepStrictEqual(await service.getAuthorizationHeaders('http://marketplace.example.com/icon.png'), {}); + // Anything unparseable fails closed. + assert.deepStrictEqual(await service.getAuthorizationHeaders('not a url'), {}); + // The marketplace's own origin still gets it, on any path. + assert.deepStrictEqual(await service.getAuthorizationHeaders(`${MARKETPLACE_URL}/vscode/publisher/name/latest`), { Authorization: 'Bearer resource-token' }); + }); });