Skip to content

Commit 1a134d7

Browse files
mcummingCopilot
andcommitted
Harden Entra marketplace access: cache scoping, race guards, error handling
Address rubber-duck review findings on the Entra ID marketplace path: - Scope the cached access verdict to the marketplace it was computed against (authProvider + accountId + serviceUrl), rejecting stale caches on any mismatch. - Guard cache application and background validation with a monotonic epoch so a session/account/config change mid-validation supersedes an in-flight result. - Register session/account listeners before applying the cache, and the config listener before initial validation, closing startup TOCTOU windows. - Route transient auth-service and marketplace-fetch failures to Unreachable instead of leaving a configured marketplace on a blank Unavailable view. - Split 401 (missing/expired token -> RequiresSignIn, not cached) from 403 (durable denial -> AccessDenied, cached ineligible). - Never follow redirects on token-bearing requests; only send the Entra token to an HTTPS same-origin target; reject non-2xx and non-manifest 200 responses before parsing. - Restore the galleryservice:custom:marketplace telemetry on the GitHub path and drop the unused server-provided eligibility reason from persisted cache. Expand unit coverage to 45 tests across provider routing, eligibility, caching, error classification, and the epoch race paths. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 06027d3 commit 1a134d7

7 files changed

Lines changed: 1150 additions & 143 deletions

File tree

‎build/lib/policies/policyData.jsonc‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@
8787
"key": "extensions.gallery.authProvider",
8888
"name": "ExtensionGalleryAuthProvider",
8989
"category": "Extensions",
90-
"minimumVersion": "1.99",
90+
"minimumVersion": "1.121",
9191
"localization": {
9292
"description": {
9393
"key": "extensions.gallery.authProvider",

‎src/vs/base/common/product.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,15 @@ export interface IProductConfiguration {
118118
readonly accessSKUs?: string[];
119119
};
120120

121+
/**
122+
* Hard gate for the Entra ID (Microsoft) authentication path of the Extensions
123+
* Marketplace. When falsy, the `extensions.gallery.authProvider: microsoft`
124+
* setting is ignored and the GitHub/default auth path is used instead. This keeps
125+
* the Entra path dormant on builds where the Private Marketplace has not yet been
126+
* publicly released, independent of any admin policy configuration.
127+
*/
128+
readonly enableExtensionGalleryEntraAuth?: boolean;
129+
121130
readonly mcpGallery?: {
122131
readonly serviceUrl: string;
123132
readonly itemWebUrl: string;

‎src/vs/platform/extensionManagement/common/extensionGalleryManifest.ts‎

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,21 @@ export const enum ExtensionGalleryManifestStatus {
6969
Available = 'available',
7070
RequiresSignIn = 'requiresSignIn',
7171
AccessDenied = 'accessDenied',
72-
Unavailable = 'unavailable'
72+
Unavailable = 'unavailable',
73+
/**
74+
* A marketplace is configured, and the user is (or is presumed) eligible, but its
75+
* gallery manifest could not be fetched — a transient network/server error. Unlike
76+
* {@link Unavailable} (which also means "no gallery configured"), this state is only
77+
* ever set after a failed fetch of a configured marketplace, so it is safe to surface
78+
* an informative message without affecting builds that have no gallery at all.
79+
*/
80+
Unreachable = 'unreachable',
81+
/**
82+
* The marketplace is configured for Microsoft (Entra ID) authentication, but the
83+
* gallery manifest does not advertise an EligibilityService resource. Access is
84+
* refused (no silent fallback to another provider) until the server is corrected.
85+
*/
86+
Misconfigured = 'misconfigured'
7387
}
7488

7589
export const IExtensionGalleryManifestService = createDecorator<IExtensionGalleryManifestService>('IExtensionGalleryManifestService');
@@ -103,13 +117,13 @@ export const ExtensionGalleryServiceUrlConfigKey = 'extensions.gallery.serviceUr
103117
export const ExtensionGalleryAuthProviderConfigKey = 'extensions.gallery.authProvider';
104118

105119
/**
106-
* Scope for requesting tokens against the Private Marketplace's own Entra app registration.
107-
* Produces tokens with `aud = api://{private-marketplace-client-id}`, matching the server's
108-
* `Marketplace:Authorization:Entra:Audience` config.
120+
* Scopes requested when signing in with Microsoft (Entra ID) to establish the
121+
* user's identity for the Private Marketplace eligibility check.
109122
*
110-
* Do NOT use `https://marketplace.visualstudio.com/.default` — that produces
111-
* `aud: marketplace.visualstudio.com` and will be rejected with 401.
112-
*
113-
* `{private-marketplace-client-id}` is the Client ID from the deployment-time app registration.
123+
* Only standard OpenID Connect sign-in scopes are requested — enough to obtain a
124+
* Microsoft session that identifies the user. This intentionally does NOT request a
125+
* resource-scoped token (e.g. `api://<client-id>/access_as_user`). Acquiring resource
126+
* tokens for Private Marketplace API calls, per the server's Protected Resource
127+
* Metadata (RFC 9728), is deferred to a follow-up change.
114128
*/
115-
export const PRIVATE_MARKETPLACE_SCOPE = 'api://{private-marketplace-client-id}/access_as_user';
129+
export const PRIVATE_MARKETPLACE_SCOPES: string[] = ['openid', 'profile', 'email', 'offline_access'];

‎src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { CommandsRegistry, ICommandService } from '../../../../platform/commands
2525
import { Extensions as ConfigurationExtensions, ConfigurationScope, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js';
2626
import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js';
2727
import { IDialogService, IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js';
28-
import { ExtensionGalleryManifestStatus, ExtensionGalleryResourceType, ExtensionGalleryAuthProviderConfigKey, ExtensionGalleryServiceUrlConfigKey, getExtensionGalleryManifestResourceUri, IExtensionGalleryManifest, IExtensionGalleryManifestService, PRIVATE_MARKETPLACE_SCOPE } from '../../../../platform/extensionManagement/common/extensionGalleryManifest.js';
28+
import { ExtensionGalleryManifestStatus, ExtensionGalleryResourceType, ExtensionGalleryAuthProviderConfigKey, ExtensionGalleryServiceUrlConfigKey, getExtensionGalleryManifestResourceUri, IExtensionGalleryManifest, IExtensionGalleryManifestService, PRIVATE_MARKETPLACE_SCOPES } from '../../../../platform/extensionManagement/common/extensionGalleryManifest.js';
2929
import { EXTENSION_INSTALL_SOURCE_CONTEXT, ExtensionInstallSource, ExtensionRequestsTimeoutConfigKey, ExtensionsLocalizedLabel, FilterType, IExtensionGalleryService, IExtensionManagementService, PreferencesLocalizedLabel, SortBy, VerifyExtensionSignatureConfigKey } from '../../../../platform/extensionManagement/common/extensionManagement.js';
3030
import { areSameExtensions, getIdAndVersion } from '../../../../platform/extensionManagement/common/extensionManagementUtil.js';
3131
import { ExtensionStorageService } from '../../../../platform/extensionManagement/common/extensionStorage.js';
@@ -330,10 +330,12 @@ Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration)
330330
},
331331
[ExtensionGalleryAuthProviderConfigKey]: {
332332
type: 'string',
333-
enum: ['github', 'microsoft'],
334-
enumDescriptions: [
333+
enum: product.enableExtensionGalleryEntraAuth ? ['github', 'microsoft'] : ['github'],
334+
enumDescriptions: product.enableExtensionGalleryEntraAuth ? [
335335
localize('extensions.gallery.authProvider.github', "Authenticate to the Extensions Marketplace using GitHub."),
336336
localize('extensions.gallery.authProvider.microsoft', "Authenticate to the Extensions Marketplace using a Microsoft (Entra ID) account."),
337+
] : [
338+
localize('extensions.gallery.authProvider.github', "Authenticate to the Extensions Marketplace using GitHub."),
337339
],
338340
description: localize('extensions.gallery.authProvider', "Configure the authentication provider for the Extensions Marketplace"),
339341
default: '',
@@ -342,7 +344,7 @@ Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration)
342344
policy: {
343345
name: 'ExtensionGalleryAuthProvider',
344346
category: PolicyCategory.Extensions,
345-
minimumVersion: '1.99',
347+
minimumVersion: '1.121',
346348
localization: {
347349
description: {
348350
key: 'extensions.gallery.authProvider',
@@ -2132,13 +2134,14 @@ registerAction2(class ExtensionsGallerySignInAction extends Action2 {
21322134
}
21332135
async run(accessor: ServicesAccessor): Promise<void> {
21342136
const configurationService = accessor.get(IConfigurationService);
2137+
const productService = accessor.get(IProductService);
21352138
const authProvider = configurationService.getValue<string>(ExtensionGalleryAuthProviderConfigKey);
21362139

2137-
if (authProvider === 'microsoft') {
2140+
if (authProvider === 'microsoft' && productService.enableExtensionGalleryEntraAuth) {
21382141
const authenticationService = accessor.get(IAuthenticationService);
21392142
await authenticationService.createSession(
21402143
'microsoft',
2141-
[PRIVATE_MARKETPLACE_SCOPE]);
2144+
PRIVATE_MARKETPLACE_SCOPES);
21422145
} else {
21432146
const commandService = accessor.get(ICommandService);
21442147
await commandService.executeCommand(DEFAULT_ACCOUNT_SIGN_IN_COMMAND);

‎src/vs/workbench/contrib/extensions/browser/extensionsViewlet.ts‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,12 @@ export class ExtensionsViewletViewsContribution extends Disposable implements IW
142142
ContextKeyExpr.or(
143143
ContextKeyExpr.has('searchMarketplaceExtensions'), ContextKeyExpr.and(DefaultViewsContext)
144144
),
145-
ContextKeyExpr.or(CONTEXT_EXTENSIONS_GALLERY_STATUS.isEqualTo(ExtensionGalleryManifestStatus.RequiresSignIn), CONTEXT_EXTENSIONS_GALLERY_STATUS.isEqualTo(ExtensionGalleryManifestStatus.AccessDenied))
145+
ContextKeyExpr.or(
146+
CONTEXT_EXTENSIONS_GALLERY_STATUS.isEqualTo(ExtensionGalleryManifestStatus.RequiresSignIn),
147+
CONTEXT_EXTENSIONS_GALLERY_STATUS.isEqualTo(ExtensionGalleryManifestStatus.AccessDenied),
148+
CONTEXT_EXTENSIONS_GALLERY_STATUS.isEqualTo(ExtensionGalleryManifestStatus.Misconfigured),
149+
CONTEXT_EXTENSIONS_GALLERY_STATUS.isEqualTo(ExtensionGalleryManifestStatus.Unreachable)
150+
)
146151
),
147152
order: -1,
148153
});
@@ -187,6 +192,16 @@ export class ExtensionsViewletViewsContribution extends Disposable implements IW
187192
)
188193
)
189194
});
195+
196+
viewRegistry.registerViewWelcomeContent('workbench.views.extensions.marketplaceAccess', {
197+
content: localize('marketplace misconfigured', "The Extensions Marketplace is not configured correctly and cannot be reached. Please contact your administrator."),
198+
when: CONTEXT_EXTENSIONS_GALLERY_STATUS.isEqualTo(ExtensionGalleryManifestStatus.Misconfigured)
199+
});
200+
201+
viewRegistry.registerViewWelcomeContent('workbench.views.extensions.marketplaceAccess', {
202+
content: localize('marketplace unreachable', "The Extensions Marketplace is currently unavailable. Check your network connection and [try again]({0}).", `command:workbench.action.reloadWindow`),
203+
when: CONTEXT_EXTENSIONS_GALLERY_STATUS.isEqualTo(ExtensionGalleryManifestStatus.Unreachable)
204+
});
190205
}
191206

192207
private createDefaultExtensionsViewDescriptors(): IViewDescriptor[] {
@@ -1153,6 +1168,12 @@ export class ExtensionMarketplaceStatusUpdater extends Disposable implements IWo
11531168
case ExtensionGalleryManifestStatus.AccessDenied:
11541169
badge = new WarningBadge(() => localize('accessDenied', "Access denied to marketplace"));
11551170
break;
1171+
case ExtensionGalleryManifestStatus.Misconfigured:
1172+
badge = new WarningBadge(() => localize('marketplaceMisconfigured', "Marketplace is misconfigured"));
1173+
break;
1174+
case ExtensionGalleryManifestStatus.Unreachable:
1175+
badge = new WarningBadge(() => localize('marketplaceUnreachable', "Marketplace is currently unavailable"));
1176+
break;
11561177
}
11571178

11581179
if (badge) {

0 commit comments

Comments
 (0)