diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index 258ac8ccc426c7..56e2581ef16444 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -779,6 +779,35 @@ "default": 2, "included": true }, + { + "key": "extensions.gallery.authProvider", + "name": "ExtensionGalleryAuthProvider", + "category": "Extensions", + "minimumVersion": "1.133", + "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.gallery.serviceUrl", "name": "ExtensionGalleryServiceUrl", diff --git a/product.json b/product.json index 395cc7ede0dc2a..9e556d01c926ae 100644 --- a/product.json +++ b/product.json @@ -156,7 +156,8 @@ ], "github-enterprise": [ "GitHub.copilot-chat" - ] + ], + "microsoft": [] }, "onboardingKeymaps": [ { diff --git a/src/vs/base/common/product.ts b/src/vs/base/common/product.ts index 46881e33054155..0fbb1fe49b25e5 100644 --- a/src/vs/base/common/product.ts +++ b/src/vs/base/common/product.ts @@ -152,6 +152,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 dictationRuntime?: IDictationRuntimeProductConfig; readonly mcpGallery?: { diff --git a/src/vs/platform/extensionManagement/common/extensionGalleryManifest.ts b/src/vs/platform/extensionManagement/common/extensionGalleryManifest.ts index cef5e2af703b2f..3efb922558e418 100644 --- a/src/vs/platform/extensionManagement/common/extensionGalleryManifest.ts +++ b/src/vs/platform/extensionManagement/common/extensionGalleryManifest.ts @@ -5,6 +5,15 @@ import { Event } from '../../../base/common/event.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { RawContextKey } from '../../contextkey/common/contextkey.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('marketplaceAuthProvider', ''); export const enum ExtensionGalleryResourceType { ExtensionQueryService = 'ExtensionQueryService', @@ -15,6 +24,7 @@ export const enum ExtensionGalleryResourceType { ExtensionRatingViewUri = 'ExtensionRatingViewUriTemplate', ExtensionResourceUri = 'ExtensionResourceUriTemplate', ContactSupportUri = 'ContactSupportUri', + EligibilityService = 'EligibilityService', } export const enum Flag { @@ -68,7 +78,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'); @@ -98,3 +122,17 @@ 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:///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. + */ +export const PRIVATE_MARKETPLACE_SCOPES: string[] = ['openid', 'profile', 'email', 'offline_access']; diff --git a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts index b04aae0a2ff0e4..a35ea7a1e17c9b 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts @@ -24,7 +24,7 @@ import { CommandsRegistry, ICommandService } from '../../../../platform/commands import { Extensions as ConfigurationExtensions, ConfigurationScope, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { IDialogService, IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; -import { ExtensionGalleryManifestStatus, ExtensionGalleryResourceType, ExtensionGalleryServiceUrlConfigKey, getExtensionGalleryManifestResourceUri, IExtensionGalleryManifest, IExtensionGalleryManifestService } from '../../../../platform/extensionManagement/common/extensionGalleryManifest.js'; +import { ExtensionGalleryManifestStatus, ExtensionGalleryResourceType, ExtensionGalleryAuthProviderConfigKey, ExtensionGalleryServiceUrlConfigKey, getExtensionGalleryManifestResourceUri, IExtensionGalleryManifest, IExtensionGalleryManifestService, PRIVATE_MARKETPLACE_SCOPES } from '../../../../platform/extensionManagement/common/extensionGalleryManifest.js'; import { EXTENSION_INSTALL_SOURCE_CONTEXT, ExtensionInstallSource, ExtensionRequestsTimeoutConfigKey, ExtensionsLocalizedLabel, FilterType, IExtensionGalleryService, IExtensionManagementService, PreferencesLocalizedLabel, SortBy, VerifyExtensionSignatureConfigKey } from '../../../../platform/extensionManagement/common/extensionManagement.js'; import { areSameExtensions, getIdAndVersion } from '../../../../platform/extensionManagement/common/extensionManagementUtil.js'; import { ExtensionStorageService } from '../../../../platform/extensionManagement/common/extensionStorage.js'; @@ -69,6 +69,8 @@ import { IWebview } from '../../webview/browser/webview.js'; import { Query } from '../common/extensionQuery.js'; import { AutoRestartConfigurationKey, AutoUpdateConfigurationKey, CONTEXT_EXTENSIONS_GALLERY_STATUS, CONTEXT_HAS_GALLERY, DefaultViewsContext, ExtensionEditorTab, ExtensionRuntimeActionType, EXTENSIONS_CATEGORY, extensionsFilterSubMenu, extensionsSearchActionsMenu, HasOutdatedExtensionsContext, IExtensionArg, IExtensionsViewPaneContainer, IExtensionsWorkbenchService, INSTALL_ACTIONS_GROUP, INSTALL_EXTENSION_FROM_VSIX_COMMAND_ID, IWorkspaceRecommendedExtensionsView, LIST_WORKSPACE_UNSUPPORTED_EXTENSIONS_COMMAND_ID, OUTDATED_EXTENSIONS_VIEW_ID, SELECT_INSTALL_VSIX_EXTENSION_COMMAND_ID, THEME_ACTIONS_GROUP, TOGGLE_IGNORE_EXTENSION_ACTION_ID, UPDATE_ACTIONS_GROUP, VIEWLET_ID, WORKSPACE_RECOMMENDATIONS_VIEW_ID } from '../common/extensions.js'; import { ExtensionsConfigurationSchema, ExtensionsConfigurationSchemaId } from '../common/extensionsFileTemplate.js'; +import { IAuthenticationService } from '../../../services/authentication/common/authentication.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ExtensionsInput } from '../common/extensionsInput.js'; import { KeymapExtensions } from '../common/extensionsUtils.js'; import { SearchExtensionsTool, SearchExtensionsToolData } from '../common/searchExtensionsTool.js'; @@ -360,6 +362,39 @@ Registry.as(ConfigurationExtensions.Configuration) } }, }, + [ExtensionGalleryAuthProviderConfigKey]: { + type: 'string', + enum: ['github', 'microsoft'], + enumDescriptions: [ + localize('extensions.gallery.authProvider.github', "Authenticate to the Extensions Marketplace using GitHub."), + localize('extensions.gallery.authProvider.microsoft', "Authenticate to the Extensions Marketplace using a Microsoft (Entra ID) account."), + ], + description: localize('extensions.gallery.authProvider', "Configure the authentication provider for the Extensions Marketplace"), + default: '', + scope: ConfigurationScope.APPLICATION, + included: false, + policy: { + name: 'ExtensionGalleryAuthProvider', + category: PolicyCategory.Extensions, + minimumVersion: '1.133', + localization: { + description: { + key: 'extensions.gallery.authProvider', + value: localize('extensions.gallery.authProvider', "Configure the authentication provider for the Extensions Marketplace"), + }, + enumDescriptions: [ + { + key: 'extensions.gallery.authProvider.github', + value: localize('extensions.gallery.authProvider.github', "Authenticate to the Extensions Marketplace using GitHub."), + }, + { + key: 'extensions.gallery.authProvider.microsoft', + value: localize('extensions.gallery.authProvider.microsoft', "Authenticate to the Extensions Marketplace using a Microsoft (Entra ID) account."), + }, + ] + } + }, + }, 'extensions.supportNodeGlobalNavigator': { type: 'boolean', description: localize('extensionsSupportNodeGlobalNavigator', "When enabled, Node.js navigator object is exposed on the global scope."), @@ -2118,12 +2153,27 @@ registerAction2(class ExtensionsGallerySignInAction extends Action2 { title: localize2('signInToMarketplace', 'Sign in to access Extensions Marketplace'), menu: { id: MenuId.AccountsContext, - when: CONTEXT_EXTENSIONS_GALLERY_STATUS.isEqualTo(ExtensionGalleryManifestStatus.RequiresSignIn) + when: ContextKeyExpr.or( + CONTEXT_EXTENSIONS_GALLERY_STATUS.isEqualTo(ExtensionGalleryManifestStatus.RequiresSignIn), + CONTEXT_EXTENSIONS_GALLERY_STATUS.isEqualTo(ExtensionGalleryManifestStatus.AccessDenied), + ) }, }); } - run(accessor: ServicesAccessor): Promise { - return accessor.get(ICommandService).executeCommand(DEFAULT_ACCOUNT_SIGN_IN_COMMAND); + async run(accessor: ServicesAccessor): Promise { + const configurationService = accessor.get(IConfigurationService); + const productService = accessor.get(IProductService); + const authProvider = configurationService.getValue(ExtensionGalleryAuthProviderConfigKey); + + if (authProvider === 'microsoft' && productService.enableExtensionGalleryEntraAuth) { + const authenticationService = accessor.get(IAuthenticationService); + await authenticationService.createSession( + 'microsoft', + PRIVATE_MARKETPLACE_SCOPES); + } else { + const commandService = accessor.get(ICommandService); + await commandService.executeCommand(DEFAULT_ACCOUNT_SIGN_IN_COMMAND); + } } }); diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsViewlet.ts b/src/vs/workbench/contrib/extensions/browser/extensionsViewlet.ts index ced3d32a8bcc3d..6a558253e67aba 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsViewlet.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsViewlet.ts @@ -19,7 +19,7 @@ import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IExtensionService } from '../../../services/extensions/common/extensions.js'; -import { IExtensionsWorkbenchService, IExtensionsViewPaneContainer, VIEWLET_ID, CloseExtensionDetailsOnViewChangeKey, INSTALL_EXTENSION_FROM_VSIX_COMMAND_ID, WORKSPACE_RECOMMENDATIONS_VIEW_ID, AutoCheckUpdatesConfigurationKey, OUTDATED_EXTENSIONS_VIEW_ID, CONTEXT_HAS_GALLERY, extensionsSearchActionsMenu, AutoRestartConfigurationKey, ExtensionRuntimeActionType, SearchMcpServersContext, SearchAgentPluginsContext, DefaultViewsContext, CONTEXT_EXTENSIONS_GALLERY_STATUS } from '../common/extensions.js'; +import { IExtensionsWorkbenchService, IExtensionsViewPaneContainer, VIEWLET_ID, CloseExtensionDetailsOnViewChangeKey, INSTALL_EXTENSION_FROM_VSIX_COMMAND_ID, WORKSPACE_RECOMMENDATIONS_VIEW_ID, AutoCheckUpdatesConfigurationKey, OUTDATED_EXTENSIONS_VIEW_ID, CONTEXT_HAS_GALLERY, extensionsSearchActionsMenu, AutoRestartConfigurationKey, ExtensionRuntimeActionType, SearchMcpServersContext, SearchAgentPluginsContext, DefaultViewsContext, CONTEXT_EXTENSIONS_GALLERY_STATUS, CONTEXT_MARKETPLACE_AUTH_PROVIDER } from '../common/extensions.js'; import { InstallLocalExtensionsInRemoteAction, InstallRemoteExtensionsInLocalAction } from './extensionsActions.js'; import { IExtensionManagementService, ILocalExtension } from '../../../../platform/extensionManagement/common/extensionManagement.js'; import { IWorkbenchExtensionEnablementService, IExtensionManagementServerService, IExtensionManagementServer } from '../../../services/extensionManagement/common/extensionManagement.js'; @@ -69,7 +69,6 @@ import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js import { KeyCode } from '../../../../base/common/keyCodes.js'; import { IExtensionGalleryManifest, IExtensionGalleryManifestService, ExtensionGalleryManifestStatus } from '../../../../platform/extensionManagement/common/extensionGalleryManifest.js'; import { URI } from '../../../../base/common/uri.js'; -import { DEFAULT_ACCOUNT_SIGN_IN_COMMAND } from '../../../services/accounts/browser/defaultAccount.js'; export const ExtensionsSortByContext = new RawContextKey('extensionsSortByValue', ''); export const SearchMarketplaceExtensionsContext = new RawContextKey('searchMarketplaceExtensions', false); @@ -146,7 +145,12 @@ export class ExtensionsViewletViewsContribution extends Disposable implements IW ContextKeyExpr.or( ContextKeyExpr.has('searchMarketplaceExtensions'), ContextKeyExpr.and(DefaultViewsContext) ), - ContextKeyExpr.or(CONTEXT_EXTENSIONS_GALLERY_STATUS.isEqualTo(ExtensionGalleryManifestStatus.RequiresSignIn), CONTEXT_EXTENSIONS_GALLERY_STATUS.isEqualTo(ExtensionGalleryManifestStatus.AccessDenied)) + ContextKeyExpr.or( + CONTEXT_EXTENSIONS_GALLERY_STATUS.isEqualTo(ExtensionGalleryManifestStatus.RequiresSignIn), + CONTEXT_EXTENSIONS_GALLERY_STATUS.isEqualTo(ExtensionGalleryManifestStatus.AccessDenied), + CONTEXT_EXTENSIONS_GALLERY_STATUS.isEqualTo(ExtensionGalleryManifestStatus.Misconfigured), + CONTEXT_EXTENSIONS_GALLERY_STATUS.isEqualTo(ExtensionGalleryManifestStatus.Unreachable) + ) ), order: -1, }); @@ -155,13 +159,51 @@ export class ExtensionsViewletViewsContribution extends Disposable implements IW viewRegistry.registerViews(viewDescriptors, this.container); viewRegistry.registerViewWelcomeContent('workbench.views.extensions.marketplaceAccess', { - content: localize('sign in', "[Sign in to access Extensions Marketplace]({0})", `command:${DEFAULT_ACCOUNT_SIGN_IN_COMMAND}`), - when: CONTEXT_EXTENSIONS_GALLERY_STATUS.isEqualTo(ExtensionGalleryManifestStatus.RequiresSignIn) + content: localize('sign in microsoft', "[Sign in with your Microsoft account]({0}) to access the Extensions Marketplace.", `command:workbench.extensions.actions.gallery.signIn`), + when: ContextKeyExpr.and( + CONTEXT_EXTENSIONS_GALLERY_STATUS.isEqualTo(ExtensionGalleryManifestStatus.RequiresSignIn), + CONTEXT_MARKETPLACE_AUTH_PROVIDER.isEqualTo('microsoft') + ) }); viewRegistry.registerViewWelcomeContent('workbench.views.extensions.marketplaceAccess', { - content: localize('access denied', "Your account does not have access to the Extensions Marketplace. Please contact your administrator."), - when: CONTEXT_EXTENSIONS_GALLERY_STATUS.isEqualTo(ExtensionGalleryManifestStatus.AccessDenied) + content: localize('sign in github', "[Sign in with GitHub]({0}) to access the Extensions Marketplace.", `command:workbench.extensions.actions.gallery.signIn`), + when: ContextKeyExpr.and( + CONTEXT_EXTENSIONS_GALLERY_STATUS.isEqualTo(ExtensionGalleryManifestStatus.RequiresSignIn), + ContextKeyExpr.or( + CONTEXT_MARKETPLACE_AUTH_PROVIDER.isEqualTo('github'), + ContextKeyExpr.not('marketplaceAuthProvider') + ) + ) + }); + + viewRegistry.registerViewWelcomeContent('workbench.views.extensions.marketplaceAccess', { + content: localize('access denied microsoft', "Your Microsoft account does not have access to the Extensions Marketplace. Please contact your administrator."), + when: ContextKeyExpr.and( + CONTEXT_EXTENSIONS_GALLERY_STATUS.isEqualTo(ExtensionGalleryManifestStatus.AccessDenied), + CONTEXT_MARKETPLACE_AUTH_PROVIDER.isEqualTo('microsoft') + ) + }); + + viewRegistry.registerViewWelcomeContent('workbench.views.extensions.marketplaceAccess', { + content: localize('access denied github', "Your account does not have access to the Extensions Marketplace. Please contact your administrator."), + when: ContextKeyExpr.and( + CONTEXT_EXTENSIONS_GALLERY_STATUS.isEqualTo(ExtensionGalleryManifestStatus.AccessDenied), + ContextKeyExpr.or( + CONTEXT_MARKETPLACE_AUTH_PROVIDER.isEqualTo('github'), + ContextKeyExpr.not('marketplaceAuthProvider') + ) + ) + }); + + viewRegistry.registerViewWelcomeContent('workbench.views.extensions.marketplaceAccess', { + content: localize('marketplace misconfigured', "The Extensions Marketplace is not configured correctly and cannot be reached. Please contact your administrator."), + when: CONTEXT_EXTENSIONS_GALLERY_STATUS.isEqualTo(ExtensionGalleryManifestStatus.Misconfigured) + }); + + viewRegistry.registerViewWelcomeContent('workbench.views.extensions.marketplaceAccess', { + content: localize('marketplace unreachable', "The Extensions Marketplace is currently unavailable. Check your network connection and [try again]({0}).", `command:workbench.action.reloadWindow`), + when: CONTEXT_EXTENSIONS_GALLERY_STATUS.isEqualTo(ExtensionGalleryManifestStatus.Unreachable) }); } @@ -1161,6 +1203,12 @@ export class ExtensionMarketplaceStatusUpdater extends Disposable implements IWo case ExtensionGalleryManifestStatus.AccessDenied: badge = new WarningBadge(() => localize('accessDenied', "Access denied to marketplace")); break; + case ExtensionGalleryManifestStatus.Misconfigured: + badge = new WarningBadge(() => localize('marketplaceMisconfigured', "Marketplace is misconfigured")); + break; + case ExtensionGalleryManifestStatus.Unreachable: + badge = new WarningBadge(() => localize('marketplaceUnreachable', "Marketplace is currently unavailable")); + break; } if (badge) { diff --git a/src/vs/workbench/contrib/extensions/common/extensions.ts b/src/vs/workbench/contrib/extensions/common/extensions.ts index 8357f20a07cd91..7933492d821967 100644 --- a/src/vs/workbench/contrib/extensions/common/extensions.ts +++ b/src/vs/workbench/contrib/extensions/common/extensions.ts @@ -267,6 +267,9 @@ export const ExtensionResultsListFocused = new RawContextKey('extension export const SearchMcpServersContext = new RawContextKey('searchMcpServers', false); export const SearchAgentPluginsContext = new RawContextKey('searchAgentPlugins', false); +// Marketplace Eligibility Context Keys +export { CONTEXT_MARKETPLACE_AUTH_PROVIDER } from '../../../../platform/extensionManagement/common/extensionGalleryManifest.js'; + // Context Menu Groups export const THEME_ACTIONS_GROUP = '_theme_'; export const INSTALL_ACTIONS_GROUP = '0_install'; diff --git a/src/vs/workbench/services/extensionManagement/electron-browser/extensionGalleryAccess.ts b/src/vs/workbench/services/extensionManagement/electron-browser/extensionGalleryAccess.ts new file mode 100644 index 00000000000000..2197e4a25c02af --- /dev/null +++ b/src/vs/workbench/services/extensionManagement/electron-browser/extensionGalleryAccess.ts @@ -0,0 +1,155 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * 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 { IDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IExtensionGalleryManifest, ExtensionGalleryManifestStatus } from '../../../../platform/extensionManagement/common/extensionGalleryManifest.js'; + +/** + * Identifies which authentication provider gates Private Marketplace access. + */ +export type ExtensionGalleryAccessProviderId = 'github' | 'microsoft'; + +/** + * A persisted access verdict for a single account against a single marketplace. + */ +export interface ICachedAccess { + authProvider: ExtensionGalleryAccessProviderId; + accountId: string; + eligible: boolean; + /** + * The `extensions.gallery.serviceUrl` the verdict was computed against. A verdict is scoped + * to a specific marketplace (the eligibility endpoint is discovered per-marketplace), so a + * cache written for one service URL must never be applied after the admin points the client + * at a different marketplace. + */ + serviceUrl: string; +} + +/** + * Thrown by the service-index (gallery manifest) fetch when the request is rejected for + * authentication/authorization reasons (HTTP 401/403). The service index MAY be protected + * at the administrator's discretion, so this is kept distinct from transient/network + * failures: callers on the Entra path use it to decide whether to prompt for sign-in + * (no token was presented) or to treat the identity as denied (a token was rejected), + * rather than mislabeling an auth-gated index as "unreachable". + */ +export class MarketplaceAuthRequiredError extends Error { + constructor(readonly statusCode: number) { + super(`Extension gallery request requires authentication (status ${statusCode}).`); + } +} + +/** + * Sink through which the access validator publishes the outcome of an access validation back to + * its host (the `WorkbenchExtensionGalleryManifestService`). The validator owns the "which account + * may access which marketplace" decision; the host owns the resulting manifest/status state and + * its change events. Keeping this contract narrow lets the validator drive status transitions + * without reaching into the service's manifest fields. + */ +export interface IExtensionGalleryAccessSink { + /** + * The current manifest status. The validator reads this to preserve an already-`Available` + * marketplace across transient failures instead of flashing an error state. + */ + getStatus(): ExtensionGalleryManifestStatus; + + /** + * Publishes a new manifest (or `null` when access is revoked/denied) and, optionally, an + * explicit status. When `status` is omitted the host derives it from `manifest` presence. + */ + update(manifest: IExtensionGalleryManifest | null, status?: ExtensionGalleryManifestStatus): void; +} + +/** + * The result of resolving the account currently signed in for a provider, WITHOUT prompting. + * `'account'` carries the account id (plus a session token for providers that present one), + * `'none'` means the provider responded but no account is present (durable), and `'error'` + * means the lookup failed (transient — callers must not invalidate the cache). + */ +export type AccountResolution = + | { kind: 'account'; accountId: string; token?: string } + | { kind: 'none' } + | { kind: 'error' }; + +/** + * The provider-agnostic machinery an {@link IExtensionGalleryAccessProvider} needs from the + * validator core: the status sink, the shared service-index fetch, and the access cache. Exposed + * as a narrow interface so provider strategies depend on behaviour, not on the concrete validator, + * keeping the module graph acyclic. + */ +export interface IExtensionGalleryAccessCore { + /** The status sink shared by the host service. */ + readonly sink: IExtensionGalleryAccessSink; + + /** + * Fetches and validates the service index (gallery manifest) at `serviceUrl`, optionally + * presenting `accessToken` so a gated index is readable. Throws {@link MarketplaceAuthRequiredError} + * on 401/403 and a generic error on any other non-2xx/malformed response. + */ + fetchServiceIndex(serviceUrl: string, token: CancellationToken, accessToken?: string): Promise; + + /** Persists an access verdict. */ + cacheAccess(data: ICachedAccess): void; + + /** Clears any persisted access verdict. */ + clearCache(): void; +} + +/** + * A per-auth-provider access strategy. Each provider knows how to resolve the current account for + * its identity system and how to validate that account's Private Marketplace access, driving the + * shared status sink through the injected {@link IExtensionGalleryAccessCore}. The validator selects + * exactly one provider based on the effective auth provider and re-validates when + * {@link onDidChangeAccount} fires. + */ +export interface IExtensionGalleryAccessProvider extends IDisposable { + /** The auth provider this strategy implements. */ + readonly id: ExtensionGalleryAccessProviderId; + + /** + * Fires when the signed-in account/session for this provider changes, so the validator can + * clear the cache, revoke the previously authorized manifest, and re-validate. + */ + readonly onDidChangeAccount: Event; + + /** + * Resolves the account currently signed in for this provider, WITHOUT prompting. Used to gate + * a cached verdict against the identity it was written for. + */ + resolveCurrentAccount(): Promise; + + /** + * Validates the current account's access to the marketplace at `serviceUrl`, driving the + * status sink. MUST check `token.isCancellationRequested` immediately before every mutation of + * status/cache/manifest so a superseded validation cannot commit a stale verdict. + */ + validate(serviceUrl: string, token: CancellationToken): Promise; +} + +/** + * Guards bearer-token transport. A token must only ever be attached to a request whose target is + * (a) HTTPS and (b) same-origin as the admin-configured service index URL. This prevents a + * compromised or misconfigured gallery manifest from redirecting a resource URL (e.g. the + * EligibilityService) at a foreign or cleartext endpoint and exfiltrating the token. Returns false + * on any parse failure so callers fail closed. + */ +export function isSafeTokenTarget(targetUrl: string, baseUrl: string): boolean { + let target: URI; + let base: URI; + try { + target = URI.parse(targetUrl, true); + base = URI.parse(baseUrl, true); + } catch { + return false; + } + if (target.scheme !== 'https') { + return false; + } + // Same-origin: scheme + authority (host:port) must match exactly. + return target.scheme === base.scheme && target.authority.toLowerCase() === base.authority.toLowerCase(); +} diff --git a/src/vs/workbench/services/extensionManagement/electron-browser/extensionGalleryAccessProviders.ts b/src/vs/workbench/services/extensionManagement/electron-browser/extensionGalleryAccessProviders.ts new file mode 100644 index 00000000000000..35b9452a1fc750 --- /dev/null +++ b/src/vs/workbench/services/extensionManagement/electron-browser/extensionGalleryAccessProviders.ts @@ -0,0 +1,407 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { IDefaultAccount } from '../../../../base/common/defaultAccount.js'; +import { Event } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { IExtensionGalleryManifest, ExtensionGalleryManifestStatus, ExtensionGalleryResourceType, getExtensionGalleryManifestResourceUri, PRIVATE_MARKETPLACE_SCOPES } from '../../../../platform/extensionManagement/common/extensionGalleryManifest.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 { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; +import { AuthenticationSession, IAuthenticationService } from '../../authentication/common/authentication.js'; +import { AccountResolution, IExtensionGalleryAccessCore, IExtensionGalleryAccessProvider, isSafeTokenTarget, MarketplaceAuthRequiredError } from './extensionGalleryAccess.js'; + +interface IEligibilityResponse { + readonly accountType?: 'Entra' | 'MSA'; + readonly eligible?: boolean; + readonly reason?: string; +} + +type MarketplaceAuthEvent = { + authProvider: string; + eligible: boolean; +}; + +type MarketplaceAuthClassification = { + authProvider: { + classification: 'SystemMetaData'; + purpose: 'FeatureInsight'; + comment: 'The auth provider used (github, microsoft).'; + }; + eligible: { + classification: 'SystemMetaData'; + purpose: 'FeatureInsight'; + isMeasurement: true; + comment: 'Whether the user was granted marketplace access.'; + }; + owner: 'sandy081'; + comment: 'Reports marketplace authentication results for enterprise marketplace access.'; +}; + +/** + * GitHub access strategy: validates Private Marketplace access from the ambient + * {@link IDefaultAccountService} account and its entitlement/enterprise flags. This is the default + * provider when `extensions.gallery.authProvider` is unset or `github`. + */ +export class ExtensionGalleryGitHubAccessProvider extends Disposable implements IExtensionGalleryAccessProvider { + + readonly id = 'github' as const; + readonly onDidChangeAccount: Event; + + constructor( + private readonly _core: IExtensionGalleryAccessCore, + @IProductService private readonly productService: IProductService, + @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, + @ITelemetryService private readonly telemetryService: ITelemetryService, + @ILogService private readonly logService: ILogService, + ) { + super(); + this.onDidChangeAccount = Event.map(this.defaultAccountService.onDidChangeDefaultAccount, () => undefined, this._store); + } + + async resolveCurrentAccount(): Promise { + try { + const account = await this.defaultAccountService.getDefaultAccount(); + return account ? { kind: 'account', accountId: account.accountName } : { kind: 'none' }; + } catch { + return { kind: 'error' }; + } + } + + async validate(configuredServiceUrl: string, token: CancellationToken): Promise { + try { + const account = await this.defaultAccountService.getDefaultAccount(); + if (token.isCancellationRequested) { + // A newer validation superseded this one while we awaited — discard. + return; + } + if (!account) { + // Auth service responded: no account → invalidate cache + this._core.clearCache(); + this._core.sink.update(null, ExtensionGalleryManifestStatus.RequiresSignIn); + } else if (!this.checkAccess(account)) { + // Auth service responded: account exists but ineligible → cache the result + this._core.cacheAccess({ authProvider: 'github', accountId: account.accountName, eligible: false, serviceUrl: configuredServiceUrl }); + this._core.sink.update(null, ExtensionGalleryManifestStatus.AccessDenied); + } else if (this._core.sink.getStatus() !== ExtensionGalleryManifestStatus.Available) { + try { + const manifest = await this._core.fetchServiceIndex(configuredServiceUrl, token); + if (token.isCancellationRequested) { + return; + } + this._core.cacheAccess({ authProvider: 'github', accountId: account.accountName, eligible: true, serviceUrl: configuredServiceUrl }); + this._core.sink.update(manifest); + this.telemetryService.publicLog2< + {}, + { + owner: 'sandy081'; + comment: 'Reports when a user successfully accesses a custom marketplace'; + }>('galleryservice:custom:marketplace'); + } catch (error) { + if (token.isCancellationRequested) { + return; + } + // Eligible, but the marketplace manifest could not be fetched — the + // marketplace is currently unreachable. Preserve cache; surface a message. + this.logService.error('[Marketplace] Failed to fetch gallery manifest (GitHub path)', error); + this._core.sink.update(null, ExtensionGalleryManifestStatus.Unreachable); + } + } + } catch (error) { + if (token.isCancellationRequested) { + return; + } + this.logService.error('[Marketplace] Error in GitHub access check', error); + // Network/transient error resolving the account — never invalidate cache. Unless we + // already have a working manifest to keep showing, surface an "unreachable" message so + // a configured marketplace isn't left on a blank (Unavailable) view with no explanation. + if (this._core.sink.getStatus() !== ExtensionGalleryManifestStatus.Available) { + this._core.sink.update(null, ExtensionGalleryManifestStatus.Unreachable); + } + } + } + + private checkAccess(account: IDefaultAccount): boolean { + if (account.entitlementsData?.access_type_sku + && this.productService.extensionsGallery?.accessSKUs?.includes( + account.entitlementsData.access_type_sku)) { + return true; + } + return account.enterprise; + } +} + +/** + * Microsoft (Entra ID / VSS) access strategy: acquires an existing Microsoft session silently, + * fetches the (possibly auth-gated) service index presenting the token, discovers the marketplace's + * EligibilityService, and checks eligibility there. There is deliberately NO fallback to GitHub: + * once an administrator configures `microsoft`, a server that does not advertise an + * EligibilityService is treated as misconfigured rather than silently downgraded. + */ +export class ExtensionGalleryMicrosoftAccessProvider extends Disposable implements IExtensionGalleryAccessProvider { + + static readonly MICROSOFT_AUTH_SCOPES = PRIVATE_MARKETPLACE_SCOPES; + + readonly id = 'microsoft' as const; + readonly onDidChangeAccount: Event; + + constructor( + private readonly _core: IExtensionGalleryAccessCore, + @IAuthenticationService private readonly authenticationService: IAuthenticationService, + @IRequestService private readonly requestService: IRequestService, + @ITelemetryService private readonly telemetryService: ITelemetryService, + @ILogService private readonly logService: ILogService, + ) { + super(); + this.onDidChangeAccount = Event.map( + Event.filter(this.authenticationService.onDidChangeSessions, e => e.providerId === 'microsoft', this._store), + () => undefined, + this._store); + } + + async resolveCurrentAccount(): Promise { + try { + const sessions = await this.authenticationService.getSessions( + 'microsoft', + ExtensionGalleryMicrosoftAccessProvider.MICROSOFT_AUTH_SCOPES); + const session = sessions[0]; + return session + ? { kind: 'account', accountId: session.account.id, token: session.accessToken } + : { kind: 'none' }; + } catch { + return { kind: 'error' }; + } + } + + async validate(configuredServiceUrl: string, token: CancellationToken): Promise { + // Acquire an existing Microsoft session first. `getSessions` reads existing sessions + // silently and never prompts for sign-in. + let sessions: readonly AuthenticationSession[]; + try { + sessions = await this.authenticationService.getSessions( + 'microsoft', + ExtensionGalleryMicrosoftAccessProvider.MICROSOFT_AUTH_SCOPES); + } catch (error) { + if (token.isCancellationRequested) { + return; + } + // Auth service unavailable — transient error, never invalidate cache. Unless we + // already have a working manifest to keep showing, surface an "unreachable" message so + // a configured marketplace isn't left on a blank (Unavailable) view with no explanation. + this.logService.error('[Marketplace] Error getting Microsoft sessions', error); + if (this._core.sink.getStatus() !== ExtensionGalleryManifestStatus.Available) { + this._core.sink.update(null, ExtensionGalleryManifestStatus.Unreachable); + } + return; + } + if (token.isCancellationRequested) { + // A newer validation superseded this one while we awaited — discard. + return; + } + const session = sessions[0]; + + if (!session) { + // No token. When 'microsoft' is configured the service index MAY itself be + // auth-gated (admin's discretion), so an anonymous probe would, at best, return + // a guaranteed 401. Rather than issue that certain-to-fail request, go straight + // to sign-in. Eligibility and any misconfiguration/unreachable state are only + // evaluated once we can present a token (on the post-sign-in re-validation + // triggered by onDidChangeSessions). There is deliberately NO fallback to GitHub. + this._core.clearCache(); + this._core.sink.update(null, ExtensionGalleryManifestStatus.RequiresSignIn); + return; + } + + // We have a token — fetch the service index (presenting the token so a gated index is + // readable), then discover the eligibility endpoint from it. The manifest is carried + // forward to `applyEligibilityResult` so it is fetched exactly once: this keeps the + // 401/403 vs transient classification below the single source of truth for the index + // fetch outcome. + if (!isSafeTokenTarget(configuredServiceUrl, configuredServiceUrl)) { + // We will not attach a bearer token to a non-HTTPS service index. Without a token + // a gated index is unreadable, so this deployment is misconfigured for Entra auth. + this.logService.error('[Marketplace] Refusing to send the Microsoft token to a non-HTTPS service index URL — the marketplace is misconfigured for Entra auth.'); + this._core.clearCache(); + this._core.sink.update(null, ExtensionGalleryManifestStatus.Misconfigured); + return; + } + let manifest: IExtensionGalleryManifest; + try { + manifest = await this._core.fetchServiceIndex(configuredServiceUrl, token, session.accessToken); + } catch (error) { + if (token.isCancellationRequested) { + return; + } + if (error instanceof MarketplaceAuthRequiredError) { + if (error.statusCode === 403) { + // 403: the token is accepted but this identity is forbidden from reading + // the service index — a durable denial. Cache it so we don't re-probe. + this._core.cacheAccess({ authProvider: 'microsoft', accountId: session.account.id, eligible: false, serviceUrl: configuredServiceUrl }); + this._core.sink.update(null, ExtensionGalleryManifestStatus.AccessDenied); + } else { + // 401: we presented a Microsoft token and it was rejected at the auth layer. + // The user is already signed in, so routing back to RequiresSignIn would just + // re-prompt the same account for the same (rejected) token — an endless loop + // that never tells the user what is wrong. Surface AccessDenied instead so the + // condition is communicated (this matches MarketplaceAuthRequiredError's + // documented contract: a rejected token is a denial, not a sign-in prompt). + // We do NOT cache a negative verdict: unlike a 403 a 401 is not a durable + // per-identity denial, so a later config/account/session change re-evaluates + // cleanly. + this._core.clearCache(); + this._core.sink.update(null, ExtensionGalleryManifestStatus.AccessDenied); + } + return; + } + // Transient error fetching the manifest — the marketplace is currently + // unreachable. Preserve cache and, unless we already have a working manifest + // to keep showing, surface an "unreachable" message. + this.logService.error('[Marketplace] Error fetching the service index', error); + if (this._core.sink.getStatus() !== ExtensionGalleryManifestStatus.Available) { + this._core.sink.update(null, ExtensionGalleryManifestStatus.Unreachable); + } + return; + } + + if (token.isCancellationRequested) { + return; + } + + const eligibilityUrl = getExtensionGalleryManifestResourceUri(manifest, ExtensionGalleryResourceType.EligibilityService); + if (!eligibilityUrl) { + // Definitive: the manifest was fetched but advertises no EligibilityService. + this.logService.error('[Marketplace] authProvider is "microsoft" but the gallery manifest does not advertise an EligibilityService resource — the marketplace is misconfigured for Entra auth.'); + this._core.clearCache(); + this._core.sink.update(null, ExtensionGalleryManifestStatus.Misconfigured); + return; + } + + if (!isSafeTokenTarget(eligibilityUrl, configuredServiceUrl)) { + // The manifest-advertised eligibility endpoint is not same-origin HTTPS with the + // admin-configured service index. Sending the Microsoft token there would risk + // leaking it to a foreign or cleartext origin (e.g. a compromised/misconfigured + // manifest), so refuse and treat the deployment as misconfigured. + this.logService.error('[Marketplace] The EligibilityService URL is not same-origin HTTPS with the configured service index — refusing to transmit the Microsoft token. The marketplace is misconfigured for Entra auth.'); + this._core.clearCache(); + this._core.sink.update(null, ExtensionGalleryManifestStatus.Misconfigured); + return; + } + + // Check eligibility via server + try { + const result = await this.checkMicrosoftEligibility(eligibilityUrl, session.accessToken, token); + if (token.isCancellationRequested) { + return; + } + // Server responded with 200 — this is a definitive result, cache it. Note we do NOT + // persist the server-provided `reason` string: it is not used for any UI/gating + // decision and could carry account/tenant diagnostic text, so keeping it out of + // application storage avoids persisting unnecessary PII at rest. + this._core.cacheAccess({ + authProvider: 'microsoft', + accountId: session.account.id, + eligible: result.eligible, + serviceUrl: configuredServiceUrl, + }); + this.telemetryService.publicLog2( + 'marketplace:auth:checked', + { + authProvider: 'microsoft', + eligible: result.eligible, + } + ); + this.applyEligibilityResult(result, manifest); + } catch (error) { + if (token.isCancellationRequested) { + // A newer validation superseded this one while we awaited — discard. + return; + } + if (error instanceof MarketplaceAuthRequiredError) { + if (error.statusCode === 403) { + // 403: the token is accepted but this identity is forbidden by the + // eligibility service — a durable denial. Cache it so we don't re-probe. + this._core.cacheAccess({ authProvider: 'microsoft', accountId: session.account.id, eligible: false, serviceUrl: configuredServiceUrl }); + this._core.sink.update(null, ExtensionGalleryManifestStatus.AccessDenied); + } else { + // 401: the Microsoft token was rejected at the eligibility endpoint. As with + // the service-index fetch above, the user is already signed in, so re-prompting + // for sign-in would loop on the same rejected token without ever explaining the + // condition. Surface AccessDenied so it is communicated. We do NOT cache a + // negative verdict: unlike a 403 a 401 is not a durable per-identity denial, so + // a later config/account/session change re-evaluates cleanly. + this._core.clearCache(); + this._core.sink.update(null, ExtensionGalleryManifestStatus.AccessDenied); + } + return; + } + this.logService.error('[Marketplace] Error checking Microsoft eligibility', error); + // Network/5xx/malformed response at the eligibility endpoint — never invalidate the + // cache. We could not obtain a definitive verdict, so unless we already have a working + // manifest to keep showing, surface an "unreachable" message rather than leaving the + // user on a blank (Unavailable) marketplace with no explanation. + if (this._core.sink.getStatus() !== ExtensionGalleryManifestStatus.Available) { + this._core.sink.update(null, ExtensionGalleryManifestStatus.Unreachable); + } + } + } + + /** + * Applies a definitive (200) eligibility verdict using the already-fetched service index + * manifest. No further network request is made here — the manifest was validated during + * discovery, so an eligible user is taken straight to `Available`. + */ + private applyEligibilityResult(result: { eligible: boolean; reason?: string }, manifest: IExtensionGalleryManifest): void { + if (result.eligible) { + if (this._core.sink.getStatus() !== ExtensionGalleryManifestStatus.Available) { + this._core.sink.update(manifest); + } + } else { + this._core.sink.update(null, ExtensionGalleryManifestStatus.AccessDenied); + } + } + + private async checkMicrosoftEligibility( + url: string, token: string, cancellationToken: CancellationToken + ): Promise<{ eligible: boolean; reason?: string }> { + const context = await this.requestService.request({ + type: 'POST', + url, + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + callSite: 'extensionGalleryManifestService.checkMicrosoftEligibility', + // A bearer token is attached, so never follow redirects: the request service would + // forward the Authorization header to the (possibly cross-origin) redirect target and + // leak the token. A 3xx is treated as a non-200 error below. + followRedirects: 0, + }, cancellationToken); + + if (context.res.statusCode !== 200) { + if (context.res.statusCode === 401 || context.res.statusCode === 403) { + // Auth-specific outcome at the eligibility endpoint. Surface the status code + // so the caller can distinguish 401 (token missing/expired/wrong-audience — + // re-auth may fix it) from 403 (token accepted but identity forbidden — a + // durable denial), mirroring the service-index fetch classification. + throw new MarketplaceAuthRequiredError(context.res.statusCode); + } + // Any other non-200 is NOT a definitive eligibility result — throw a generic + // error so callers treat it as transient/server error and don't cache it. + throw new Error(`Eligibility endpoint returned status ${context.res.statusCode}`); + } + + const response = await asJson(context); + if (!response || typeof response.eligible !== 'boolean') { + // A 200 with a missing/non-boolean `eligible` is not a definitive verdict — a server + // contract drift must not be coerced into a durable allow/deny. Throw so the caller + // treats it as transient and never caches it. + throw new Error('Eligibility endpoint returned a malformed response'); + } + return { eligible: response.eligible, reason: response.reason }; + } +} diff --git a/src/vs/workbench/services/extensionManagement/electron-browser/extensionGalleryAccessValidator.ts b/src/vs/workbench/services/extensionManagement/electron-browser/extensionGalleryAccessValidator.ts new file mode 100644 index 00000000000000..3d206dfba460d0 --- /dev/null +++ b/src/vs/workbench/services/extensionManagement/electron-browser/extensionGalleryAccessValidator.ts @@ -0,0 +1,409 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { IHeaders } from '../../../../base/parts/request/common/request.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; +import { IExtensionGalleryManifest, ExtensionGalleryAuthProviderConfigKey, ExtensionGalleryManifestStatus } from '../../../../platform/extensionManagement/common/extensionGalleryManifest.js'; +import { resolveMarketplaceHeaders } from '../../../../platform/externalServices/common/marketplace.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.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 { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { IExtensionGalleryAccessCore, IExtensionGalleryAccessProvider, IExtensionGalleryAccessSink, ICachedAccess, isSafeTokenTarget, MarketplaceAuthRequiredError } from './extensionGalleryAccess.js'; +import { ExtensionGalleryGitHubAccessProvider, ExtensionGalleryMicrosoftAccessProvider } from './extensionGalleryAccessProviders.js'; + +/** + * Validates Private Marketplace access for the current account and drives the resulting + * manifest/status through an {@link IExtensionGalleryAccessSink}. + * + * Supersession (sign-out, account/session switch, or a marketplace/provider config change) + * is handled with a per-validation {@link CancellationTokenSource} held in a + * {@link MutableDisposable}: starting a new validation cancels the previous one, so a stale + * in-flight eligibility check observes `token.isCancellationRequested` and does not mutate + * status/cache/manifest. This closes a time-of-check/time-of-use window where a superseded + * validation could restore access for an account that is no longer current. + */ +export class ExtensionGalleryAccessValidator extends Disposable implements IExtensionGalleryAccessCore { + + private static readonly CACHED_ACCESS_KEY = 'marketplace.cachedAccess'; + + private readonly commonHeadersPromise: Promise; + + // Cancellation source for the in-flight access validation. Starting a new validation (via + // `beginValidation`) cancels the previous one, so a superseded validation's late-arriving + // async continuation observes `token.isCancellationRequested` and does not mutate status/ + // cache/manifest. This closes a time-of-check/time-of-use window where a stale in-flight + // eligibility check could restore access for an account that is no longer current (after + // sign-out, an account switch, or a config change). + private readonly _validationTokenSource = this._register(new MutableDisposable()); + + // The access-provider strategy for the effective auth provider, created lazily by + // `resolveAccessStrategy` (post-construction) so injecting IAuthenticationService into the + // Microsoft provider does not reintroduce the host-service construction cycle. + private _provider: IExtensionGalleryAccessProvider | undefined; + + constructor( + private readonly _sink: IExtensionGalleryAccessSink, + @IProductService private readonly productService: IProductService, + @IEnvironmentService environmentService: IEnvironmentService, + @IFileService fileService: IFileService, + @ITelemetryService private readonly telemetryService: ITelemetryService, + @IStorageService private readonly storageService: IStorageService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IRequestService private readonly requestService: IRequestService, + @ILogService private readonly logService: ILogService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super(); + this.commonHeadersPromise = resolveMarketplaceHeaders( + this.productService.version, + this.productService, + environmentService, + this.configurationService, + fileService, + this.storageService, + this.telemetryService); + } + + get sink(): IExtensionGalleryAccessSink { + return this._sink; + } + + /** + * Resolves the effective marketplace auth provider, applying the Entra (microsoft) + * product gate. When `product.enableExtensionGalleryEntraAuth` is falsy, a configured + * `microsoft` provider is downgraded to the GitHub/default provider so the Entra path + * stays dormant until the Private Marketplace is publicly released. + */ + getEffectiveAuthProvider(): string { + const configuredAuthProvider = this.configurationService.getValue(ExtensionGalleryAuthProviderConfigKey); + if (configuredAuthProvider === 'microsoft' && !this.productService.enableExtensionGalleryEntraAuth) { + return 'github'; + } + return configuredAuthProvider || 'github'; + } + + /** + * Establishes access for the configured Private Marketplace: registers provider/session + * change listeners, applies any cancellation-guarded cached verdict for a fast startup, then + * validates the current account (foreground when there was no cache, background otherwise). + */ + async initialize(configuredServiceUrl: string): Promise { + // 1. Resolve the auth strategy FIRST so provider/session change listeners are active + // before we apply any cached verdict. This lets a mid-application account/session + // switch supersede the cache via the cancellation-token guard in applyCachedAccess, + // rather than racing an unguarded cache application. resolveAccessStrategy only + // registers listeners and returns the validate function — it performs no auth calls + // itself, so this reordering does not change when the network is first touched. + const validateAccess = await this.resolveAccessStrategy(configuredServiceUrl); + + // 2. Apply cache immediately (cancellation-guarded) before awaiting foreground validation. + const cached = this.getCachedAccess(configuredServiceUrl); + if (cached) { + this.logService.debug('[Marketplace] Applying cached access result on startup'); + await this.applyCachedAccess(cached, configuredServiceUrl, this.beginValidation()); + } + + // 3. Validate (foreground if no cache, background if cache was applied) + if (cached) { + validateAccess(); + } else { + await validateAccess(); + } + } + + /** + * Selects the access-provider strategy for the effective auth provider, subscribes to its + * account/session changes for re-validation, and returns a function that validates current + * access. There is deliberately NO fallback between providers: once an administrator has + * configured 'microsoft', a server that does not advertise an EligibilityService is treated + * as misconfigured rather than silently downgraded to GitHub. + */ + private async resolveAccessStrategy(configuredServiceUrl: string): Promise<() => Promise> { + const provider = this._provider = this.createProvider(this.getEffectiveAuthProvider()); + const validate = () => provider.validate(configuredServiceUrl, this.beginValidation()); + this._register(provider.onDidChangeAccount(() => { + this.clearCache(); + // Revoke the manifest that was authorized for the previous account/session before + // revalidating. Without this, the active status stays `Available`, and if the new + // account's validation hits a transient index/eligibility failure the catch paths + // preserve `Available` — leaking the prior account's authorization to the new + // (possibly ineligible) account. + this._sink.update(null); + validate(); + })); + return validate; + } + + /** + * Instantiates the access-provider strategy for the effective auth provider, passing `this` + * as the shared {@link IExtensionGalleryAccessCore}. Created lazily (post-construction, from + * `initialize`) so injecting IAuthenticationService into the Microsoft provider does not + * reintroduce the host-service construction cycle that eager DI would. + */ + private createProvider(authProvider: string): IExtensionGalleryAccessProvider { + const provider = authProvider === 'microsoft' + ? this.instantiationService.createInstance(ExtensionGalleryMicrosoftAccessProvider, this) + : this.instantiationService.createInstance(ExtensionGalleryGitHubAccessProvider, this); + return this._register(provider); + } + + /** + * Begins a new access-validation generation and returns its cancellation token, cancelling + * any previously started validation. Long-running validations MUST check + * `token.isCancellationRequested` immediately before every mutation of status/cache/manifest + * and bail when it is set, so a superseded validation cannot commit a stale verdict. + */ + private beginValidation(): CancellationToken { + // `MutableDisposable` disposes the previous source on assignment, but + // `CancellationTokenSource.dispose()` does not cancel — cancel explicitly so any in-flight + // continuation (and threaded request) is superseded before the old source is disposed. + this._validationTokenSource.value?.cancel(); + const source = new CancellationTokenSource(); + this._validationTokenSource.value = source; + return source.token; + } + + /** + * Cancels any in-flight validation without starting a new one, so a late-arriving result + * cannot mutate status/cache/manifest. Used when a config change supersedes the current + * marketplace/provider (the restart prompt is dismissable, so the process may keep running). + */ + cancel(): void { + this._validationTokenSource.value?.cancel(); + this._validationTokenSource.clear(); + } + + // --- Access caching (provider-agnostic) --- + + private getCachedAccess(configuredServiceUrl: string): ICachedAccess | null { + const raw = this.storageService.get( + ExtensionGalleryAccessValidator.CACHED_ACCESS_KEY, + StorageScope.APPLICATION); + if (!raw) { return null; } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + // Corrupt cache entry — drop it so a bad value can't wedge startup. + this.clearCache(); + return null; + } + if (!this.isValidCachedAccess(parsed)) { + // Unexpected shape (e.g. written by an incompatible/older build that predates a + // cache-schema field) — don't trust it. + this.clearCache(); + return null; + } + // The cached verdict is an authorization input, so only trust it for the provider + // that is currently in effect. A cache written under a different provider (e.g. the + // admin switched `extensions.gallery.authProvider`, or the Entra product gate flipped) + // must not grant access here; drop it and let foreground validation re-establish + // access for the effective provider. Cross-account staleness within the same provider + // is corrected by the background re-validation that runs on every startup and by the + // onDidChangeSessions/onDidChangeDefaultAccount handlers which clear the cache. + if (parsed.authProvider !== this.getEffectiveAuthProvider()) { + this.clearCache(); + return null; + } + // The verdict is also scoped to the marketplace it was computed against. If the admin + // has pointed the client at a different `extensions.gallery.serviceUrl` since the cache + // was written, the eligibility verdict for the previous marketplace does not apply — + // drop it so a stale verdict can't briefly grant access to a different marketplace. + if (parsed.serviceUrl !== configuredServiceUrl) { + this.clearCache(); + return null; + } + return parsed; + } + + private isValidCachedAccess(value: unknown): value is ICachedAccess { + if (!value || typeof value !== 'object') { + return false; + } + const candidate = value as Partial; + return (candidate.authProvider === 'github' || candidate.authProvider === 'microsoft') + && typeof candidate.accountId === 'string' + && typeof candidate.eligible === 'boolean' + && typeof candidate.serviceUrl === 'string'; + } + + private async applyCachedAccess(cached: ICachedAccess, configuredServiceUrl: string, token: CancellationToken): Promise { + // A cached verdict is an authorization input, so only trust it for the account it was + // written for. Resolve the current account (silently) and require it to match before + // applying an eligible cache — otherwise a stale cross-account entry could briefly grant + // access at cold start before background validation corrects it. + const current = await this._provider!.resolveCurrentAccount(); + if (token.isCancellationRequested) { + // A newer validation (e.g. an account/session change that fired while we resolved the + // account) superseded this cache application — let it own the outcome and don't touch + // status or cache here. + return; + } + if (current.kind === 'error') { + // Could not determine the current account (transient auth failure). Don't grant access + // from an unverifiable cache, but don't invalidate it either — background validation + // will retry. Surface "unreachable" so the marketplace isn't left blank. + if (this._sink.getStatus() !== ExtensionGalleryManifestStatus.Available) { + this._sink.update(null, ExtensionGalleryManifestStatus.Unreachable); + } + return; + } + if (current.kind === 'none' || current.accountId !== cached.accountId) { + // No account, or a different account than the cache was written for — drop it and let + // foreground/background validation re-establish access for the current identity. + this.clearCache(); + return; + } + + if (!cached.eligible) { + this._sink.update(null, ExtensionGalleryManifestStatus.AccessDenied); + return; + } + + // Eligible for the current account — fetch the manifest to render Available. If the + // provider resolved a session token (Microsoft), attach it so a gated index is readable, + // applying the same-origin token-transport guard first; providers that resolve no token + // (GitHub) fetch anonymously. + let accessToken: string | undefined; + if (current.token) { + if (!isSafeTokenTarget(configuredServiceUrl, configuredServiceUrl)) { + this._sink.update(null, ExtensionGalleryManifestStatus.Misconfigured); + return; + } + accessToken = current.token; + } + try { + const manifest = await this.fetchServiceIndex(configuredServiceUrl, token, accessToken); + if (token.isCancellationRequested) { + // A newer validation superseded this cache application while we fetched the + // manifest — do not apply a manifest for a possibly-stale account. + return; + } + this._sink.update(manifest); + } catch (error) { + if (token.isCancellationRequested) { + return; + } + if (error instanceof MarketplaceAuthRequiredError) { + // The cached token was rejected/expired at cold start. Leave the definitive + // classification (RequiresSignIn vs AccessDenied) to the background validation that + // runs right after; don't flash a misleading Unreachable. + return; + } + this.logService.error('[Marketplace] Error fetching manifest from cached access', error); + // Transient failure fetching the manifest — don't invalidate cache. Background + // validation will retry; surface an "unreachable" message in the meantime unless we + // already have a working manifest to keep showing. + if (this._sink.getStatus() !== ExtensionGalleryManifestStatus.Available) { + this._sink.update(null, ExtensionGalleryManifestStatus.Unreachable); + } + } + } + + cacheAccess(data: ICachedAccess): void { + this.storageService.store( + ExtensionGalleryAccessValidator.CACHED_ACCESS_KEY, + JSON.stringify(data), + StorageScope.APPLICATION, + StorageTarget.MACHINE); + this.logService.debug('[Marketplace] Cached access result:', data.authProvider, data.eligible); + } + + /** + * Clears any persisted access verdict. Exposed so a marketplace/provider config change can + * drop a now-irrelevant cache while it supersedes the in-flight validation. + */ + clearCache(): void { + this.storageService.remove( + ExtensionGalleryAccessValidator.CACHED_ACCESS_KEY, + StorageScope.APPLICATION); + this.logService.debug('[Marketplace] Cleared cached access'); + } + + async fetchServiceIndex(url: string, token: CancellationToken, accessToken?: string): Promise { + const commonHeaders = await this.commonHeadersPromise; + const headers: IHeaders = { + ...commonHeaders, + 'Content-Type': 'application/json', + 'Accept-Encoding': 'gzip', + }; + // The service index MAY be protected (admin's discretion). Present a bearer token + // when we have one; it is harmless on a public index and required on a gated one. + if (accessToken) { + headers['Authorization'] = `Bearer ${accessToken}`; + } + + try { + const context = await this.requestService.request({ + type: 'GET', + url, + headers, + // When a bearer token is attached, never follow redirects — the request service + // would forward the Authorization header to the redirect target (possibly a + // different origin) and leak the token. Anonymous fetches may still redirect. + followRedirects: accessToken ? 0 : undefined, + callSite: 'extensionGalleryManifestService.fetchManifest' + }, token); + + if (context.res.statusCode === 401 || context.res.statusCode === 403) { + // The service index is auth-gated and this request was not authorized. + // Surface a typed error so the Entra path can prompt for sign-in (or treat a + // rejected token as denied) rather than mislabeling it as unreachable. + throw new MarketplaceAuthRequiredError(context.res.statusCode); + } + + if (context.res.statusCode && (context.res.statusCode < 200 || context.res.statusCode >= 300)) { + // Any other non-2xx (404/5xx/…) is an error, not a manifest. Reject before + // parsing so a JSON error body can never be mistaken for a valid service index. + throw new Error(`Service index returned status ${context.res.statusCode}`); + } + + const extensionGalleryManifest = await asJson(context); + + if (!extensionGalleryManifest) { + throw new Error('Unable to retrieve extension gallery manifest.'); + } + + if (!Array.isArray(extensionGalleryManifest.resources)) { + // A 200 whose body is valid JSON but not a service index (e.g. a server error + // object, or an HTML/JSON captive-portal page) must not be treated as a + // manifest — `resources` is required to discover gallery endpoints (including + // the EligibilityService). Reject here so callers classify it as a failed + // fetch, rather than letting resource-URI discovery throw on a non-iterable + // `resources` outside this try/catch. + throw new Error('Service index response is not a valid extension gallery manifest.'); + } + + if (!extensionGalleryManifest.resources.every(resource => resource && typeof resource.id === 'string' && typeof resource.type === 'string')) { + // `resources` is an array but at least one entry is malformed (missing/non-string + // `id` or `type`). `getExtensionGalleryManifestResourceUri` calls `resource.type.split()` + // outside this fetch's try/catch during endpoint discovery, so an undefined `type` + // would throw there and reject initialization instead of being classified as a failed + // fetch. Reject here so the caller surfaces `Unreachable`. + throw new Error('Service index response contains malformed extension gallery resources.'); + } + + return extensionGalleryManifest; + } catch (error) { + if (error instanceof MarketplaceAuthRequiredError) { + // Not a failure: an auth-gated service index rejected an unauthenticated (or + // stale-token) request. Callers translate this into a RequiresSignIn/AccessDenied + // state and the workbench surfaces the corresponding sign-in affordance, so logging + // it at `error` would misrepresent the normal "not signed in yet" flow as a fault. + this.logService.trace('[Marketplace] Extension gallery manifest requires authentication', error.statusCode); + } else { + this.logService.error('[Marketplace] Error retrieving extension gallery manifest', error); + } + throw error; + } + } +} diff --git a/src/vs/workbench/services/extensionManagement/electron-browser/extensionGalleryManifestService.ts b/src/vs/workbench/services/extensionManagement/electron-browser/extensionGalleryManifestService.ts index d670862d736e8b..3fc59264306f59 100644 --- a/src/vs/workbench/services/extensionManagement/electron-browser/extensionGalleryManifestService.ts +++ b/src/vs/workbench/services/extensionManagement/electron-browser/extensionGalleryManifestService.ts @@ -3,32 +3,25 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Emitter } from '../../../../base/common/event.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, ExtensionGalleryManifestStatus } from '../../../../platform/extensionManagement/common/extensionGalleryManifest.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IExtensionGalleryManifestService, IExtensionGalleryManifest, ExtensionGalleryServiceUrlConfigKey, ExtensionGalleryAuthProviderConfigKey, ExtensionGalleryManifestStatus, CONTEXT_MARKETPLACE_AUTH_PROVIDER } 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'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; 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 { IStorageService } from '../../../../platform/storage/common/storage.js'; -import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; -import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; import { IRemoteAgentService } from '../../remote/common/remoteAgentService.js'; import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { IHostService } from '../../host/browser/host.js'; -import { IDefaultAccount } from '../../../../base/common/defaultAccount.js'; +import { IExtensionGalleryAccessSink } from './extensionGalleryAccess.js'; +import { ExtensionGalleryAccessValidator } from './extensionGalleryAccessValidator.js'; export class WorkbenchExtensionGalleryManifestService extends ExtensionGalleryManifestService implements IExtensionGalleryManifestService { - private readonly commonHeadersPromise: Promise; private extensionGalleryManifest: IExtensionGalleryManifest | null = null; private _onDidChangeExtensionGalleryManifest = this._register(new Emitter()); @@ -39,30 +32,34 @@ export class WorkbenchExtensionGalleryManifestService extends ExtensionGalleryMa private _onDidChangeExtensionGalleryManifestStatus = this._register(new Emitter()); override readonly onDidChangeExtensionGalleryManifestStatus = this._onDidChangeExtensionGalleryManifestStatus.event; + // Owns Private Marketplace access validation (eligibility checks, verdict caching, and the + // cancellation-token supersession guard). This service retains only the resulting manifest/ + // status state; the validator publishes outcomes back through the sink below. + private readonly accessValidator: ExtensionGalleryAccessValidator; + constructor( @IProductService productService: IProductService, - @IEnvironmentService environmentService: IEnvironmentService, - @IFileService fileService: IFileService, - @ITelemetryService private readonly telemetryService: ITelemetryService, - @IStorageService storageService: IStorageService, @IRemoteAgentService remoteAgentService: IRemoteAgentService, @ISharedProcessService sharedProcessService: ISharedProcessService, @IConfigurationService private readonly configurationService: IConfigurationService, - @IRequestService private readonly requestService: IRequestService, - @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, @ILogService private readonly logService: ILogService, @IDialogService private readonly dialogService: IDialogService, @IHostService private readonly hostService: IHostService, + @IInstantiationService instantiationService: IInstantiationService, + @IContextKeyService contextKeyService: IContextKeyService, ) { super(productService); - this.commonHeadersPromise = resolveMarketplaceHeaders( - productService.version, - productService, - environmentService, - configurationService, - fileService, - storageService, - telemetryService); + + const sink: IExtensionGalleryAccessSink = { + getStatus: () => this.currentStatus, + update: (manifest, status) => this.update(manifest, status), + }; + this.accessValidator = this._register(instantiationService.createInstance(ExtensionGalleryAccessValidator, sink)); + + // Set the auth provider context key for UX. The Entra (microsoft) path is gated + // behind a product flag; when it is off, coerce to the GitHub/default provider so + // the UI never advertises Microsoft sign-in. + CONTEXT_MARKETPLACE_AUTH_PROVIDER.bindTo(contextKeyService).set(this.accessValidator.getEffectiveAuthProvider()); const channels = [sharedProcessService.getChannel('extensionGalleryManifest')]; const remoteConnection = remoteAgentService.getConnection(); @@ -73,13 +70,24 @@ export class WorkbenchExtensionGalleryManifestService extends ExtensionGalleryMa this.logService.trace(`[Marketplace] Updating channels with manifest ${manifest ? 'available' : 'unavailable'}`); channels.forEach(channel => channel.call('setExtensionGalleryManifest', [manifest])); }; - this.getExtensionGalleryManifest().then(manifest => { + // Defer the initial manifest bootstrap to a microtask so this service is fully + // constructed and cached in the DI container before it runs. The Entra (microsoft) + // access path resolves IAuthenticationService, whose dependency graph transitively + // re-enters this service; kicking the bootstrap off synchronously from the + // constructor would resolve IAuthenticationService mid-construction and throw + // "RECURSIVELY instantiating service 'IAuthenticationService'", corrupting the + // container and breaking workbench startup. + Promise.resolve().then(() => this.getExtensionGalleryManifest()).then(manifest => { if (this._store.isDisposed) { this.logService.trace('[Marketplace] Store is already disposed, skipping channel initialization'); return; } updateChannels(manifest); this._register(this.onDidChangeExtensionGalleryManifest(manifest => updateChannels(manifest))); + }).catch(error => { + // The deferred bootstrap must never surface as an unhandled rejection — any + // failure here already results in an appropriate manifest status, so just log. + this.logService.error('[Marketplace] Error during initial gallery manifest bootstrap', error); }); } @@ -98,49 +106,34 @@ export class WorkbenchExtensionGalleryManifestService extends ExtensionGalleryMa return; } + // Register the configuration listener BEFORE running the initial validation so a + // serviceUrl/provider change that lands during a slow startup validation is observed + // (it cancels the in-flight validation to supersede its result and clears the cache) rather + // than being missed while we await initialization. + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(ExtensionGalleryServiceUrlConfigKey) + || e.affectsConfiguration(ExtensionGalleryAuthProviderConfigKey)) { + // Supersede any in-flight background validation for the previous + // marketplace/provider so its late-arriving result cannot re-populate the + // cache we are about to clear (the restart prompt is dismissable, so the + // process may keep running). + this.accessValidator.cancel(); + this.accessValidator.clearCache(); + this.requestRestart(); + } + })); + const configuredServiceUrl = this.configurationService.getValue(ExtensionGalleryServiceUrlConfigKey); if (configuredServiceUrl) { this.logService.trace('[Marketplace] Private marketplace configured, checking access and fetching manifest', configuredServiceUrl); - await this.handleDefaultAccountAccess(configuredServiceUrl); - this._register(this.defaultAccountService.onDidChangeDefaultAccount(() => this.handleDefaultAccountAccess(configuredServiceUrl))); + await this.accessValidator.initialize(configuredServiceUrl); } else { const defaultExtensionGalleryManifest = await super.getExtensionGalleryManifest(); this.update(defaultExtensionGalleryManifest); } - - this._register(this.configurationService.onDidChangeConfiguration(e => { - if (!e.affectsConfiguration(ExtensionGalleryServiceUrlConfigKey)) { - return; - } - this.requestRestart(); - })); } - private async handleDefaultAccountAccess(configuredServiceUrl: string): Promise { - const account = await this.defaultAccountService.getDefaultAccount(); - - if (!account) { - this.logService.debug('[Marketplace] Enterprise marketplace configured but user not signed in'); - this.update(null, ExtensionGalleryManifestStatus.RequiresSignIn); - } else if (!this.checkAccess(account)) { - this.logService.debug('[Marketplace] User signed in but lacks access to enterprise marketplace'); - this.update(null, ExtensionGalleryManifestStatus.AccessDenied); - } else if (this.currentStatus !== ExtensionGalleryManifestStatus.Available) { - try { - const manifest = await this.getExtensionGalleryManifestFromServiceUrl(configuredServiceUrl); - this.update(manifest); - this.telemetryService.publicLog2< - {}, - { - owner: 'sandy081'; - comment: 'Reports when a user successfully accesses a custom marketplace'; - }>('galleryservice:custom:marketplace'); - } catch (error) { - this.logService.error('[Marketplace] Error retrieving enterprise gallery manifest', error); - this.update(null, ExtensionGalleryManifestStatus.AccessDenied); - } - } - } + // --- Status management --- private update(manifest: IExtensionGalleryManifest | null, status?: ExtensionGalleryManifestStatus): void { this.logService.debug(`[Marketplace] Updating manifest ${manifest ? 'available' : 'unavailable'}`); @@ -158,16 +151,6 @@ export class WorkbenchExtensionGalleryManifestService extends ExtensionGalleryMa } } - private checkAccess(account: IDefaultAccount): boolean { - this.logService.debug('[Marketplace] Checking Account SKU access for configured gallery', account.entitlementsData?.access_type_sku); - if (account.entitlementsData?.access_type_sku && this.productService.extensionsGallery?.accessSKUs?.includes(account.entitlementsData.access_type_sku)) { - this.logService.debug('[Marketplace] Account has access to configured gallery'); - return true; - } - this.logService.debug('[Marketplace] Checking enterprise account access for configured gallery', account.enterprise); - return account.enterprise; - } - private async requestRestart(): Promise { const confirmation = await this.dialogService.confirm({ message: localize('extensionGalleryManifestService.accountChange', "{0} is now configured to a different Marketplace. Please restart to apply the changes.", this.productService.nameLong), @@ -177,35 +160,6 @@ export class WorkbenchExtensionGalleryManifestService extends ExtensionGalleryMa return this.hostService.restart(); } } - - private async getExtensionGalleryManifestFromServiceUrl(url: string): Promise { - const commonHeaders = await this.commonHeadersPromise; - const headers = { - ...commonHeaders, - 'Content-Type': 'application/json', - 'Accept-Encoding': 'gzip', - }; - - try { - const context = await this.requestService.request({ - type: 'GET', - url, - headers, - callSite: 'extensionGalleryManifestService.fetchManifest' - }, CancellationToken.None); - - const extensionGalleryManifest = await asJson(context); - - if (!extensionGalleryManifest) { - throw new Error('Unable to retrieve extension gallery manifest.'); - } - - return extensionGalleryManifest; - } catch (error) { - this.logService.error('[Marketplace] Error retrieving extension gallery manifest', error); - throw error; - } - } } registerSingleton(IExtensionGalleryManifestService, WorkbenchExtensionGalleryManifestService, InstantiationType.Eager); 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 new file mode 100644 index 00000000000000..48ace4a8cca200 --- /dev/null +++ b/src/vs/workbench/services/extensionManagement/test/electron-browser/extensionGalleryManifestService.test.ts @@ -0,0 +1,1091 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { bufferToStream, VSBuffer } from '../../../../../base/common/buffer.js'; +import { IDefaultAccount, IEntitlementsData } from '../../../../../base/common/defaultAccount.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { IRequestContext, IRequestOptions } from '../../../../../base/parts/request/common/request.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IConfigurationChangeEvent, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; +import { IEnvironmentService } from '../../../../../platform/environment/common/environment.js'; +import { ExtensionGalleryManifestStatus, ExtensionGalleryAuthProviderConfigKey, ExtensionGalleryServiceUrlConfigKey } from '../../../../../platform/extensionManagement/common/extensionGalleryManifest.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ISharedProcessService } from '../../../../../platform/ipc/electron-browser/services.js'; +import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IRequestService } from '../../../../../platform/request/common/request.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { NullTelemetryService } from '../../../../../platform/telemetry/common/telemetryUtils.js'; +import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { AuthenticationSession, AuthenticationSessionsChangeEvent, 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'; + +function mockResponse(statusCode: number, body: object): IRequestContext { + return { + res: { headers: {}, statusCode }, + stream: bufferToStream(VSBuffer.fromString(JSON.stringify(body))), + }; +} + +function createDefaultAccount(overrides: Partial = {}): IDefaultAccount { + return { + authenticationProvider: { id: 'github', name: 'GitHub', enterprise: false }, + accountName: 'testuser', + sessionId: 'session-1', + enterprise: false, + entitlementsData: undefined, + ...overrides, + }; +} + +function createMicrosoftSession(accessToken = 'ms-token'): AuthenticationSession { + return { + id: 'ms-session-1', + accessToken, + account: { id: 'ms-account-1', label: 'user@contoso.com' }, + scopes: ['openid', 'profile', 'email', 'offline_access'], + }; +} + +// Gallery manifest response stub +function createGalleryManifest(includeEligibility = false) { + return { + version: '1.0', + resources: includeEligibility + ? [{ id: 'https://marketplace.example.com/_apis/public/gallery/eligibility', type: 'EligibilityService' }] + : [], + }; +} + +suite('WorkbenchExtensionGalleryManifestService', () => { + + const disposableStore = ensureNoDisposablesAreLeakedInTestSuite(); + + let instantiationService: TestInstantiationService; + let onDidChangeDefaultAccount: Emitter; + let onDidChangeSessions: Emitter<{ providerId: string; label: string; event: AuthenticationSessionsChangeEvent }>; + let requestHandler: (options: IRequestOptions) => IRequestContext | Promise; + let defaultAccount: IDefaultAccount | null; + let microsoftSessions: AuthenticationSession[]; + let configurationService: TestConfigurationService; + let storageData: Map; + let entraAuthEnabled: boolean; + + setup(() => { + defaultAccount = null; + microsoftSessions = []; + requestHandler = () => mockResponse(200, createGalleryManifest()); + storageData = new Map(); + entraAuthEnabled = true; + + onDidChangeDefaultAccount = disposableStore.add(new Emitter()); + onDidChangeSessions = disposableStore.add(new Emitter<{ providerId: string; label: string; event: AuthenticationSessionsChangeEvent }>()); + + configurationService = new TestConfigurationService({ + [ExtensionGalleryServiceUrlConfigKey]: 'https://marketplace.example.com', + }); + + instantiationService = disposableStore.add(new TestInstantiationService()); + + instantiationService.stub(IProductService, { + version: '1.0.0', + extensionsGallery: { + serviceUrl: 'https://default-marketplace.example.com', + controlUrl: '', + extensionUrlTemplate: '', + resourceUrlTemplate: '', + nlsBaseUrl: '', + accessSKUs: ['copilot_business'], + }, + nameLong: 'VS Code Test', + get enableExtensionGalleryEntraAuth() { return entraAuthEnabled; }, + }); + + instantiationService.stub(IEnvironmentService, new class extends mock() { + }()); + + instantiationService.stub(IFileService, new class extends mock() { + }()); + + instantiationService.stub(ITelemetryService, NullTelemetryService); + + instantiationService.stub(IStorageService, new class extends mock() { + override get(key: string, _scope: StorageScope, fallbackValue: string): string; + override get(key: string, _scope: StorageScope, fallbackValue?: string): string | undefined; + override get(key: string, _scope: StorageScope, fallbackValue?: string): string | undefined { + return storageData.get(key) ?? fallbackValue; + } + override store(key: string, value: string, _scope: StorageScope, _target: StorageTarget): void { + storageData.set(key, value); + } + override remove(key: string, _scope: StorageScope): void { + storageData.delete(key); + } + }()); + + instantiationService.stub(IRemoteAgentService, new class extends mock() { + override getConnection() { return null; } + }()); + + instantiationService.stub(ISharedProcessService, new class extends mock() { + override getChannel(_channelName: string): any { + return { + call: () => Promise.resolve(), + listen: () => Event.None, + }; + } + }()); + + instantiationService.stub(IConfigurationService, configurationService); + + instantiationService.stub(IRequestService, new class extends mock() { + override async request(options: IRequestOptions) { + return requestHandler(options); + } + }()); + + instantiationService.stub(IDefaultAccountService, new class extends mock() { + override readonly onDidChangeDefaultAccount = onDidChangeDefaultAccount.event; + override async getDefaultAccount() { return defaultAccount; } + }()); + + instantiationService.stub(ILogService, new NullLogService()); + + instantiationService.stub(IDialogService, new class extends mock() { + override async confirm() { return { confirmed: false }; } + }()); + + instantiationService.stub(IHostService, new class extends mock() { + override async restart() { } + }()); + + instantiationService.stub(IAuthenticationService, new class extends mock() { + override readonly onDidChangeSessions = onDidChangeSessions.event; + override async getSessions(providerId: string) { + if (providerId === 'microsoft') { + return microsoftSessions; + } + return []; + } + override async createSession(providerId: string) { + return createMicrosoftSession(); + } + }()); + + instantiationService.stub(IContextKeyService, disposableStore.add(new MockContextKeyService())); + }); + + function createService(): WorkbenchExtensionGalleryManifestService { + return disposableStore.add(instantiationService.createInstance(WorkbenchExtensionGalleryManifestService)); + } + + // --- Provider routing --- + + test('GitHub provider — enterprise account → Available', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'github'); + defaultAccount = createDefaultAccount({ enterprise: true }); + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Available); + }); + + test('GitHub provider — no account → RequiresSignIn', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'github'); + defaultAccount = null; + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.RequiresSignIn); + }); + + test('GitHub provider — non-enterprise account without SKU → AccessDenied', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'github'); + defaultAccount = createDefaultAccount({ enterprise: false }); + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.AccessDenied); + }); + + test('GitHub provider — account with matching SKU → Available', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'github'); + defaultAccount = createDefaultAccount({ + enterprise: false, + entitlementsData: { access_type_sku: 'copilot_business' } as IEntitlementsData, + }); + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Available); + }); + + test('default (no authProvider) — uses GitHub path', async () => { + // No authProvider config set + defaultAccount = createDefaultAccount({ enterprise: true }); + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Available); + }); + + test('Microsoft provider — no session → RequiresSignIn', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + microsoftSessions = []; + requestHandler = () => mockResponse(200, createGalleryManifest(true)); + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.RequiresSignIn); + }); + + test('Microsoft provider — eligible session → Available', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + microsoftSessions = [createMicrosoftSession()]; + requestHandler = (options) => { + if (options.url?.includes('eligibility')) { + return mockResponse(200, { eligible: true, reason: 'EntraID' }); + } + return mockResponse(200, createGalleryManifest(true)); + }; + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Available); + }); + + test('Microsoft provider — ineligible session → AccessDenied', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + microsoftSessions = [createMicrosoftSession()]; + requestHandler = (options) => { + if (options.url?.includes('eligibility')) { + return mockResponse(200, { eligible: false, reason: 'MSA without VSS' }); + } + return mockResponse(200, createGalleryManifest(true)); + }; + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.AccessDenied); + }); + + test('Microsoft provider — no EligibilityService in manifest → Misconfigured (no GitHub fallback)', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + microsoftSessions = [createMicrosoftSession()]; + defaultAccount = createDefaultAccount({ enterprise: true }); + // Manifest has no EligibilityService resource + requestHandler = () => mockResponse(200, createGalleryManifest(false)); + + const service = createService(); + await service.getExtensionGalleryManifest(); + + // The admin explicitly configured microsoft — refuse access, do NOT fall back to GitHub + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Misconfigured); + }); + + test('Microsoft provider — cross-origin EligibilityService URL → Misconfigured (token not sent)', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + microsoftSessions = [createMicrosoftSession()]; + let eligibilityCalled = false; + requestHandler = (options) => { + if (options.url?.includes('eligibility')) { + eligibilityCalled = true; + return mockResponse(200, { eligible: true }); + } + // Manifest points the eligibility endpoint at a foreign origin. + return mockResponse(200, { version: '1.0', resources: [{ id: 'https://evil.example.com/eligibility', type: 'EligibilityService' }] }); + }; + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Misconfigured); + assert.strictEqual(eligibilityCalled, false); + }); + + test('Microsoft provider — cleartext (http) EligibilityService URL → Misconfigured (token not sent)', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + microsoftSessions = [createMicrosoftSession()]; + let eligibilityCalled = false; + requestHandler = (options) => { + if (options.url?.includes('eligibility')) { + eligibilityCalled = true; + return mockResponse(200, { eligible: true }); + } + // Manifest points the eligibility endpoint at a cleartext (http) endpoint. + return mockResponse(200, { version: '1.0', resources: [{ id: 'http://marketplace.example.com/eligibility', type: 'EligibilityService' }] }); + }; + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Misconfigured); + assert.strictEqual(eligibilityCalled, false); + }); + + test('Microsoft provider — non-HTTPS service index URL → Misconfigured (no request issued)', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + configurationService.setUserConfiguration(ExtensionGalleryServiceUrlConfigKey, 'http://marketplace.example.com'); + microsoftSessions = [createMicrosoftSession()]; + let requestIssued = false; + requestHandler = () => { + requestIssued = true; + return mockResponse(200, createGalleryManifest(true)); + }; + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Misconfigured); + assert.strictEqual(requestIssued, false); + }); + + test('Microsoft provider — service index returns 500 with JSON body → Unreachable (not parsed as manifest)', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + microsoftSessions = [createMicrosoftSession()]; + // A 5xx error body is valid JSON and truthy; it must be rejected outright rather than + // mistaken for a manifest (which would otherwise land in Misconfigured). + requestHandler = () => mockResponse(500, { error: 'internal' }); + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Unreachable); + }); + + test('Microsoft provider — manifest fetch fails, no cache → Unreachable', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + microsoftSessions = [createMicrosoftSession()]; + // Manifest discovery fails transiently (network error) + requestHandler = () => { throw new Error('network down'); }; + + const service = createService(); + await service.getExtensionGalleryManifest(); + + // A configured marketplace whose manifest can't be fetched is Unreachable so the + // UI can surface a message (distinct from the initial no-gallery Unavailable). + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Unreachable); + }); + + test('Microsoft provider — no session → RequiresSignIn without probing the service index', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + microsoftSessions = []; + // When 'microsoft' is configured and there is no session, we must NOT issue an + // anonymous request to the (possibly auth-gated) service index — that request is a + // guaranteed 401. We go straight to sign-in and only touch the index once a token + // exists. Assert no request was made. + let indexRequests = 0; + requestHandler = () => { + indexRequests++; + return mockResponse(401, { message: 'auth required' }); + }; + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.RequiresSignIn); + assert.strictEqual(indexRequests, 0); + }); + + test('Microsoft provider — auth-gated service index, session token presented → Available', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + microsoftSessions = [createMicrosoftSession()]; + // The index is gated: it returns 401 for anonymous reads but 200 once a bearer token + // is presented. This asserts the token is actually threaded into the manifest fetch. + requestHandler = (options) => { + if (options.url?.includes('eligibility')) { + return mockResponse(200, { eligible: true, reason: 'EntraID' }); + } + const hasAuth = !!(options.headers && options.headers['Authorization']); + return hasAuth + ? mockResponse(200, createGalleryManifest(true)) + : mockResponse(401, { message: 'auth required' }); + }; + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Available); + }); + + test('Microsoft provider — auth-gated service index, token forbidden (403) → AccessDenied', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + microsoftSessions = [createMicrosoftSession()]; + // A token is presented but the server returns 403 — the identity is accepted but + // forbidden from reading the index. This is a durable denial and is cached. + requestHandler = () => mockResponse(403, { message: 'forbidden' }); + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.AccessDenied); + const cached = storageData.get('marketplace.cachedAccess'); + assert.ok(cached); + const parsed = JSON.parse(cached); + assert.strictEqual(parsed.authProvider, 'microsoft'); + assert.strictEqual(parsed.accountId, 'ms-account-1'); + assert.strictEqual(parsed.eligible, false); + assert.strictEqual(parsed.serviceUrl, 'https://marketplace.example.com'); + }); + + test('Microsoft provider — service index returns a non-manifest 200 → Unreachable (not a crash, not cached)', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + microsoftSessions = [createMicrosoftSession()]; + // A 200 whose JSON body has no `resources` array must be rejected before eligibility + // discovery — otherwise resource-URI lookup throws on a non-iterable and escapes the + // fetch try/catch. It is a failed fetch, so it surfaces Unreachable and is not cached. + requestHandler = (options) => { + if (options.url?.includes('eligibility')) { + return mockResponse(200, { eligible: true }); + } + return mockResponse(200, { error: 'not a manifest' }); + }; + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Unreachable); + assert.ok(!storageData.has('marketplace.cachedAccess')); + }); + + test('Microsoft provider — service index 200 with malformed resources → Unreachable (not a crash, not cached)', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + microsoftSessions = [createMicrosoftSession()]; + // `resources` is an array but an entry is missing `id`/`type`. Endpoint discovery calls + // `resource.type.split()` outside the fetch try/catch, so an undefined `type` would throw + // there and reject initialization. The response must instead be rejected as a failed + // fetch → Unreachable, and never cached. + requestHandler = (options) => { + if (options.url?.includes('eligibility')) { + return mockResponse(200, { eligible: true }); + } + return mockResponse(200, { version: '1.0.0', resources: [{}] }); + }; + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Unreachable); + assert.ok(!storageData.has('marketplace.cachedAccess')); + }); + + test('Microsoft provider — auth-gated service index, token rejected (401) → AccessDenied (not cached)', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + microsoftSessions = [createMicrosoftSession()]; + // A token is presented but the server returns 401 — the user is already signed in, so + // re-prompting for sign-in would loop on the same rejected token without ever explaining + // the condition. Surface AccessDenied instead. Unlike a 403, a 401 is not a durable + // per-identity denial, so we must NOT cache a negative result. + requestHandler = () => mockResponse(401, { message: 'auth required' }); + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.AccessDenied); + assert.ok(!storageData.has('marketplace.cachedAccess')); + }); + + test('Microsoft provider — eligibility endpoint forbids (403) → AccessDenied (cached ineligible)', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + microsoftSessions = [createMicrosoftSession()]; + // The service index is readable with the token, but the eligibility endpoint returns + // 403 — the identity is accepted yet not entitled. This is a durable denial and is + // cached so we don't re-probe on every startup. + requestHandler = (options) => { + if (options.url?.includes('eligibility')) { + return mockResponse(403, { message: 'forbidden' }); + } + return mockResponse(200, createGalleryManifest(true)); + }; + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.AccessDenied); + assert.strictEqual(JSON.parse(storageData.get('marketplace.cachedAccess')!).eligible, false); + }); + + test('Microsoft provider — eligibility endpoint rejects token (401) → AccessDenied (not cached)', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + microsoftSessions = [createMicrosoftSession()]; + // The index is readable, but the eligibility endpoint returns 401 — the user is already + // signed in, so re-prompting for sign-in would loop on the same rejected token. Surface + // AccessDenied so the condition is communicated. Unlike a 403, a 401 is not a durable + // per-identity denial, so we must NOT cache it. + requestHandler = (options) => { + if (options.url?.includes('eligibility')) { + return mockResponse(401, { message: 'auth required' }); + } + return mockResponse(200, createGalleryManifest(true)); + }; + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.AccessDenied); + assert.ok(!storageData.has('marketplace.cachedAccess')); + }); + + test('Microsoft provider — stale validation superseded by sign-out does not restore access', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + microsoftSessions = [createMicrosoftSession()]; + + // Hold the eligibility POST open so the first (epoch 1) validation parks mid-flight + // after it has already read a valid session and a well-formed index. + let releaseEligibility!: (v: IRequestContext) => void; + const eligibilityGate = new Promise(resolve => { releaseEligibility = resolve; }); + requestHandler = (options) => { + if (options.url?.includes('eligibility')) { + return eligibilityGate; + } + return mockResponse(200, createGalleryManifest(true)); + }; + + const service = createService(); + const inflight = service.getExtensionGalleryManifest(); + + // Let the first validation advance to the point where it awaits the eligibility POST. + await new Promise(resolve => setTimeout(resolve, 0)); + + // The user signs out — this supersedes the in-flight validation (epoch bumps). + microsoftSessions = []; + onDidChangeSessions.fire({ providerId: 'microsoft', label: 'Microsoft', event: { added: [], removed: [], changed: [] } }); + + // The superseding validation resolves to RequiresSignIn. + await new Promise(resolve => setTimeout(resolve, 0)); + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.RequiresSignIn); + + // The stale eligibility POST finally returns "eligible" — it must be discarded. + releaseEligibility(mockResponse(200, { eligible: true, reason: 'EntraID' })); + await inflight.catch(() => { }); + await new Promise(resolve => setTimeout(resolve, 0)); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.RequiresSignIn); + }); + + test('Microsoft provider — stale validation superseded by config change does not re-cache access', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + microsoftSessions = [createMicrosoftSession()]; + // Seed an eligible cache so startup applies it (Available) and kicks off a background + // re-validation that we can park mid-flight. + storageData.set('marketplace.cachedAccess', JSON.stringify({ + authProvider: 'microsoft', + accountId: 'ms-account-1', + eligible: true, + serviceUrl: 'https://marketplace.example.com', + })); + + // Hold the eligibility POST open so the background (epoch 1) validation parks after it + // has already read a session and a well-formed index. + let releaseEligibility!: (v: IRequestContext) => void; + const eligibilityGate = new Promise(resolve => { releaseEligibility = resolve; }); + requestHandler = (options) => { + if (options.url?.includes('eligibility')) { + return eligibilityGate; + } + return mockResponse(200, createGalleryManifest(true)); + }; + + const service = createService(); + await service.getExtensionGalleryManifest(); + + // Let the background validation advance to the point where it awaits the eligibility POST. + await new Promise(resolve => setTimeout(resolve, 0)); + + // The marketplace/auth-provider configuration changes — this clears the cache and asks + // the user to restart (which they may decline). It must also supersede the in-flight + // validation so its late result cannot re-populate the cache we just cleared. + configurationService.onDidChangeConfigurationEmitter.fire({ affectsConfiguration: () => true } as unknown as IConfigurationChangeEvent); + await new Promise(resolve => setTimeout(resolve, 0)); + assert.ok(!storageData.has('marketplace.cachedAccess')); + + // The stale eligibility POST finally returns "eligible" — it must be discarded and must + // NOT re-write the cache. + releaseEligibility(mockResponse(200, { eligible: true, reason: 'EntraID' })); + await new Promise(resolve => setTimeout(resolve, 0)); + + assert.ok(!storageData.has('marketplace.cachedAccess')); + }); + + test('GitHub provider — eligible account, manifest fetch fails → Unreachable', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'github'); + defaultAccount = createDefaultAccount({ enterprise: true }); + requestHandler = () => { throw new Error('network down'); }; + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Unreachable); + }); + + test('Microsoft provider — Entra auth product-gated off → uses GitHub path', async () => { + entraAuthEnabled = false; + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + microsoftSessions = [createMicrosoftSession()]; + defaultAccount = createDefaultAccount({ enterprise: true }); + // Manifest advertises EligibilityService, but the Entra path is gated off. + requestHandler = () => mockResponse(200, createGalleryManifest(true)); + + const service = createService(); + await service.getExtensionGalleryManifest(); + + // Entra path is skipped → GitHub path with enterprise account → Available + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Available); + }); + + // --- Cache behavior --- + + test('cache hit on startup — eligible result applied immediately', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'github'); + defaultAccount = createDefaultAccount({ enterprise: true }); + storageData.set('marketplace.cachedAccess', JSON.stringify({ + authProvider: 'github', + accountId: 'testuser', + eligible: true, + serviceUrl: 'https://marketplace.example.com', + })); + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Available); + }); + + test('cache hit on startup — ineligible result applied', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'github'); + defaultAccount = null; + storageData.set('marketplace.cachedAccess', JSON.stringify({ + authProvider: 'github', + accountId: 'testuser', + eligible: false, + serviceUrl: 'https://marketplace.example.com', + })); + + const service = createService(); + await service.getExtensionGalleryManifest(); + + // The cache was written for account 'testuser' but there is no current account, so the + // cache is not trusted (dropped). Background github validation then sees no account and + // lands on RequiresSignIn. + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.RequiresSignIn); + }); + + test('cache for a different account of the same provider is dropped, not applied', async () => { + // The cache says the CURRENT provider's user is eligible, but it was written for a + // different account id than the one now signed in. A cached verdict is an authorization + // input scoped to an account, so it must not grant (or deny) access to a different + // account — it is dropped and fresh validation runs for the current identity. + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + microsoftSessions = [createMicrosoftSession()]; // account id 'ms-account-1' + storageData.set('marketplace.cachedAccess', JSON.stringify({ + authProvider: 'microsoft', + accountId: 'ms-account-2', // different account + eligible: true, + serviceUrl: 'https://marketplace.example.com', + })); + requestHandler = (options) => { + if (options.url?.includes('eligibility')) { + return mockResponse(200, { eligible: true, reason: 'EntraID' }); + } + return mockResponse(200, createGalleryManifest(true)); + }; + + const service = createService(); + await service.getExtensionGalleryManifest(); + // Background validation for the current account runs fire-and-forget; give it a tick. + await new Promise(resolve => setTimeout(resolve, 0)); + + // Background validation re-establishes access for the CURRENT account (ms-account-1), + // writing a fresh cache for it — proving the stale ms-account-2 entry was not applied. + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Available); + const cached = storageData.get('marketplace.cachedAccess'); + assert.ok(cached); + assert.strictEqual(JSON.parse(cached).accountId, 'ms-account-1'); + }); + + test('cache written for a different serviceUrl is dropped, not applied', async () => { + // A verdict is scoped to the marketplace it was computed against. Only the serviceUrl + // differs here (same provider + account), isolating service-URL scoping from account + // matching. The current account is NOT eligible, so if the stale eligible cache were + // wrongly trusted we'd see Available instead of AccessDenied. + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'github'); + defaultAccount = createDefaultAccount(); // testuser, not enterprise → ineligible + storageData.set('marketplace.cachedAccess', JSON.stringify({ + authProvider: 'github', + accountId: 'testuser', + eligible: true, + serviceUrl: 'https://old-marketplace.example.com', // differs from the configured URL + })); + + const service = createService(); + await service.getExtensionGalleryManifest(); + + // Stale-URL cache dropped; fresh validation for the current marketplace denies access. + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.AccessDenied); + const cached = storageData.get('marketplace.cachedAccess'); + assert.ok(cached); + const parsed = JSON.parse(cached); + assert.strictEqual(parsed.eligible, false); + assert.strictEqual(parsed.serviceUrl, 'https://marketplace.example.com'); + }); + + test('session change during cached manifest fetch does not apply a stale manifest', async () => { + // The cache is applied with an epoch guard while listeners are already active. If the + // signed-in session changes while the cached manifest fetch is in flight, the stale + // fetch result must be discarded rather than applied for the previous account. + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + microsoftSessions = [createMicrosoftSession()]; + storageData.set('marketplace.cachedAccess', JSON.stringify({ + authProvider: 'microsoft', + accountId: 'ms-account-1', + eligible: true, + serviceUrl: 'https://marketplace.example.com', + })); + + // Park the cached-path index fetch so applyCachedAccess suspends mid-fetch. + let releaseIndex!: (v: IRequestContext) => void; + const indexGate = new Promise(resolve => { releaseIndex = resolve; }); + requestHandler = (options) => { + if (options.url?.includes('eligibility')) { + return mockResponse(200, { eligible: true }); + } + return indexGate; + }; + + const service = createService(); + const inflight = service.getExtensionGalleryManifest(); + + // Let cache application advance to the parked index fetch. + await new Promise(resolve => setTimeout(resolve, 0)); + + // The user signs out while the cached fetch is parked — this supersedes it (epoch bumps). + microsoftSessions = []; + onDidChangeSessions.fire({ providerId: 'microsoft', label: 'Microsoft', event: { added: [], removed: [], changed: [] } }); + await new Promise(resolve => setTimeout(resolve, 0)); + + // Release the stale cached index fetch; its manifest must be discarded, not applied. + releaseIndex(mockResponse(200, createGalleryManifest(true))); + await inflight.catch(() => { }); + await new Promise(resolve => setTimeout(resolve, 0)); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.RequiresSignIn); + }); + + test('cache from a different provider is dropped, not trusted', async () => { + // The cache says the user is microsoft-eligible, but the effective provider is now + // github with no account. The stale microsoft cache is an authorization input for a + // different provider and must not grant access — it is dropped and github validation + // runs, ending at RequiresSignIn. + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'github'); + defaultAccount = null; + storageData.set('marketplace.cachedAccess', JSON.stringify({ + authProvider: 'microsoft', + accountId: 'ms-account-1', + eligible: true, + serviceUrl: 'https://marketplace.example.com', + })); + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.RequiresSignIn); + assert.ok(!storageData.has('marketplace.cachedAccess')); + }); + + test('malformed cache entry is dropped without throwing', async () => { + // A cache entry with an unexpected shape (e.g. written by an incompatible build) must + // be discarded rather than trusted or allowed to crash startup. + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'github'); + defaultAccount = null; + storageData.set('marketplace.cachedAccess', JSON.stringify({ unexpected: 'shape' })); + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.RequiresSignIn); + assert.ok(!storageData.has('marketplace.cachedAccess')); + }); + + test('Microsoft — eligibility server error (500), no cache → Unreachable', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + microsoftSessions = [createMicrosoftSession()]; + requestHandler = (options) => { + if (options.url?.includes('eligibility')) { + return mockResponse(500, { error: 'Internal Server Error' }); + } + return mockResponse(200, createGalleryManifest(true)); + }; + + const service = createService(); + await service.getExtensionGalleryManifest(); + + // The service index was reachable but the eligibility verdict could not be obtained and + // there is no cache to fall back on — surface an "unreachable" message instead of leaving + // a blank (Unavailable) marketplace. + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Unreachable); + }); + + test('Microsoft — server error (500), with cache → cache preserved', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + microsoftSessions = [createMicrosoftSession()]; + storageData.set('marketplace.cachedAccess', JSON.stringify({ + authProvider: 'microsoft', + accountId: 'ms-account-1', + eligible: true, + serviceUrl: 'https://marketplace.example.com', + })); + requestHandler = (options) => { + if (options.url?.includes('eligibility')) { + return mockResponse(500, { error: 'Internal Server Error' }); + } + return mockResponse(200, createGalleryManifest(true)); + }; + + const service = createService(); + await service.getExtensionGalleryManifest(); + + // Cache was applied on startup (Available), server error doesn't invalidate + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Available); + assert.ok(storageData.has('marketplace.cachedAccess')); + }); + + test('cache NOT invalidated when getSessions throws', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + storageData.set('marketplace.cachedAccess', JSON.stringify({ + authProvider: 'microsoft', + accountId: 'ms-account-1', + eligible: true, + serviceUrl: 'https://marketplace.example.com', + })); + requestHandler = () => mockResponse(200, createGalleryManifest(true)); + + // Override auth service to throw + instantiationService.stub(IAuthenticationService, new class extends mock() { + override readonly onDidChangeSessions = onDidChangeSessions.event; + override async getSessions(): Promise { + throw new Error('Auth service unavailable'); + } + }()); + + const service = createService(); + await service.getExtensionGalleryManifest(); + + // The current account can't be resolved (transient auth failure), so the eligible cache + // can't be verified for the current identity and must not be applied — but it is also NOT + // invalidated. The user sees "unreachable" while the cache is retained for a later retry. + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Unreachable); + assert.ok(storageData.has('marketplace.cachedAccess')); + }); + + test('GitHub — result is cached after successful check', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'github'); + defaultAccount = createDefaultAccount({ enterprise: true }); + + const service = createService(); + await service.getExtensionGalleryManifest(); + + const cached = storageData.get('marketplace.cachedAccess'); + assert.ok(cached); + const parsed = JSON.parse(cached); + assert.strictEqual(parsed.authProvider, 'github'); + assert.strictEqual(parsed.eligible, true); + }); + + test('Microsoft — result is cached after successful eligibility check', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + microsoftSessions = [createMicrosoftSession()]; + requestHandler = (options) => { + if (options.url?.includes('eligibility')) { + return mockResponse(200, { eligible: true, reason: 'EntraID' }); + } + return mockResponse(200, createGalleryManifest(true)); + }; + + const service = createService(); + await service.getExtensionGalleryManifest(); + + const cached = storageData.get('marketplace.cachedAccess'); + assert.ok(cached); + const parsed = JSON.parse(cached); + assert.strictEqual(parsed.authProvider, 'microsoft'); + assert.strictEqual(parsed.eligible, true); + // The server-provided `reason` must NOT be persisted (avoids unnecessary PII at rest). + assert.strictEqual(parsed.reason, undefined); + }); + + test('Microsoft — malformed eligibility 200 (no boolean) is treated as transient, not cached', async () => { + // A 200 whose body lacks a boolean `eligible` field is a server contract drift, not a + // definitive allow/deny. It must not be coerced into a durable verdict or cached. + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + microsoftSessions = [createMicrosoftSession()]; + requestHandler = (options) => { + if (options.url?.includes('eligibility')) { + return mockResponse(200, { notEligibleField: true }); + } + return mockResponse(200, createGalleryManifest(true)); + }; + + const service = createService(); + await service.getExtensionGalleryManifest(); + + // No definitive verdict + no cache → Unreachable, and nothing is cached. + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Unreachable); + assert.ok(!storageData.has('marketplace.cachedAccess')); + }); + + test('corrupt (non-JSON) cache entry is dropped without throwing', async () => { + // A cache value that isn't valid JSON (e.g. truncated/corrupt storage) must be discarded + // rather than crash startup with a parse error. + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'github'); + defaultAccount = null; + storageData.set('marketplace.cachedAccess', '{not valid json'); + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.RequiresSignIn); + assert.ok(!storageData.has('marketplace.cachedAccess')); + }); + + test('Microsoft — token-bearing requests disable redirect following (no header leak)', async () => { + // A bearer token must never be forwarded across a redirect (the request service would + // re-send the Authorization header to the redirect target). Both the token-bearing index + // fetch and the eligibility POST must set followRedirects: 0. + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + microsoftSessions = [createMicrosoftSession()]; + const seen: { url: string; followRedirects: number | undefined }[] = []; + requestHandler = (options) => { + seen.push({ url: options.url ?? '', followRedirects: options.followRedirects }); + if (options.url?.includes('eligibility')) { + return mockResponse(200, { eligible: true, reason: 'EntraID' }); + } + return mockResponse(200, createGalleryManifest(true)); + }; + + const service = createService(); + await service.getExtensionGalleryManifest(); + + const indexReq = seen.find(r => !r.url.includes('eligibility')); + const eligReq = seen.find(r => r.url.includes('eligibility')); + assert.strictEqual(indexReq?.followRedirects, 0); + assert.strictEqual(eligReq?.followRedirects, 0); + }); + + test('Microsoft — getSessions throws with no cache → Unreachable (not silent Unavailable)', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + instantiationService.stub(IAuthenticationService, new class extends mock() { + override readonly onDidChangeSessions = onDidChangeSessions.event; + override async getSessions(): Promise { + throw new Error('Auth service unavailable'); + } + }()); + + const service = createService(); + await service.getExtensionGalleryManifest(); + + // No cache to fall back on and the account couldn't be resolved — the configured + // marketplace must show "unreachable" rather than a blank (Unavailable) view. + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Unreachable); + }); + + test('GitHub — getDefaultAccount throws with no cache → Unreachable (not silent Unavailable)', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'github'); + instantiationService.stub(IDefaultAccountService, new class extends mock() { + override readonly onDidChangeDefaultAccount = onDidChangeDefaultAccount.event; + override async getDefaultAccount(): Promise { + throw new Error('Account service unavailable'); + } + }()); + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Unreachable); + }); + + // --- Cache invalidation --- + + test('cache invalidated on onDidChangeSessions for microsoft provider', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + microsoftSessions = [createMicrosoftSession()]; + requestHandler = (options) => { + if (options.url?.includes('eligibility')) { + return mockResponse(200, { eligible: true, reason: 'EntraID' }); + } + return mockResponse(200, createGalleryManifest(true)); + }; + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.ok(storageData.has('marketplace.cachedAccess')); + + // Simulate session change — should clear cache + microsoftSessions = []; + onDidChangeSessions.fire({ + providerId: 'microsoft', + label: 'Microsoft', + event: { added: undefined, removed: undefined, changed: undefined }, + }); + + // Wait for async handler + await new Promise(resolve => setTimeout(resolve, 0)); + + // Cache should be cleared + assert.ok(!storageData.has('marketplace.cachedAccess')); + }); + + test('cache invalidated on onDidChangeDefaultAccount for github provider', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'github'); + defaultAccount = createDefaultAccount({ enterprise: true }); + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.ok(storageData.has('marketplace.cachedAccess')); + + // Simulate account change — should clear cache and re-evaluate + defaultAccount = null; + onDidChangeDefaultAccount.fire(null); + + // Wait for async handler + await new Promise(resolve => setTimeout(resolve, 0)); + + // Cache should be cleared (account is null) + assert.ok(!storageData.has('marketplace.cachedAccess')); + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.RequiresSignIn); + }); + + // --- No configuredServiceUrl --- + + test('no configuredServiceUrl — uses default gallery manifest', async () => { + configurationService.setUserConfiguration(ExtensionGalleryServiceUrlConfigKey, ''); + + const service = createService(); + await service.getExtensionGalleryManifest(); + + // With no configured marketplace serviceUrl the Entra/private-marketplace path is never + // engaged; the base class falls back to the product's default gallery → Available. + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Available); + }); +});