Enable Microsoft Entra ID sign-in for Private Marketplace access - #325331
Enable Microsoft Entra ID sign-in for Private Marketplace access#325331Michael Cummings (MSFT) (mcumming) wants to merge 43 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds provider-aware Private Marketplace authentication using Microsoft Entra ID, including eligibility validation, secure token transport, caching, status UX, and policy configuration.
Changes:
- Adds Microsoft session and eligibility-service authentication.
- Adds provider-aware sign-in, marketplace statuses, and error UX.
- Adds configuration policy, product gating, and unit coverage.
Show a summary per file
| File | Description |
|---|---|
extensionGalleryManifestService.test.ts |
Tests routing, eligibility, caching, and races. |
extensionGalleryManifestService.ts |
Implements authentication and eligibility flow. |
extensions.ts |
Defines marketplace provider context. |
extensionsViewlet.ts |
Adds status-specific marketplace UX. |
extensions.contribution.ts |
Registers policy and sign-in action. |
extensionGalleryManifest.ts |
Adds statuses, resource type, and scopes. |
product.ts |
Defines the Entra product gate. |
product.json |
Adds Microsoft authentication-provider metadata. |
policyData.jsonc |
Exports the new enterprise policy. |
Review details
- Files reviewed: 9/9 changed files
- Comments generated: 8
- Review effort level: Medium
1a134d7 to
282c38d
Compare
|
Please consider writing a TPI for us to validate these changes during testing https://github.com/microsoft/vscode/wiki/Writing-Test-Plan-Items |
|
External contributors will be blocked from updating policy. After this lands, follow up with adding back the policy: #328982 |
…ntext key Introduce the `extensions.gallery.authProvider` policy that selects which identity provider (github or microsoft) gates Private Marketplace access, and register it in the exported policy data. Add the marketplace auth-provider context key and the Entra ID resource scope constant used to acquire a Private Marketplace-audienced token. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Resolve the marketplace access strategy in the workbench gallery manifest service: cache-first startup, provider-routed access handling, Microsoft eligibility probing against the eligibility resource from the gallery manifest, the GitHub DefaultAccount path, the marketplace auth-provider context key, and access telemetry. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Surface a provider-aware sign-in prompt and access-denied state in the extensions viewlet, driven by the marketplace auth-provider context key so the correct identity provider is presented to the user. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Allow the built-in extensions gallery to silently use Microsoft (Entra ID) authentication sessions for Private Marketplace access. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Cover provider selection, cache-first startup, Microsoft eligibility handling, and the GitHub access path in the gallery manifest service. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ndling 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>
…g, resource validation, UX copy - Policy: make the `extensions.gallery.authProvider` schema enum and enumDescriptions unconditional (`github`, `microsoft`). Gating the enum on the Entra product flag left the policy metadata exporting two enum descriptions against a single-value enum, which fails the policy-artifact generator's equal-length requirement on a clean export. The Entra gate is already enforced at runtime in getEffectiveAuthProvider(), and the setting is hidden (included: false), so this advertises nothing new in the UI. - Cross-account authorization leak: on Microsoft session change and GitHub default-account change, revoke the active manifest (drop `Available`) before revalidating. Previously the active status stayed `Available`, so a transient index/eligibility failure on the new account preserved the prior account's access. - Layering: move CONTEXT_MARKETPLACE_AUTH_PROVIDER down to the platform extensionGalleryManifest module so the workbench service no longer imports from a workbench/contrib module. The Extensions contribution re-exports it for existing consumers. - Resource validation: reject a 200 service index whose `resources` entries are malformed (missing string `id`/`type`), not just a non-array `resources`. Endpoint discovery calls `resource.type.split()` outside the fetch try/catch, so an undefined `type` would crash initialization instead of surfacing `Unreachable`. - UX: make the Microsoft AccessDenied welcome message generic. A bare 403 gives no typed reason, so asserting that an Entra ID account or Visual Studio Subscription is required could tell an already-signed-in user to obtain access they already have. Adds a unit test covering the malformed-resources -> Unreachable path. All 47 gallery tests pass; typecheck-client and valid-layers-check are clean. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Replace the two `as any` casts flagged by the local/code-no-any-casts ESLint rule that failed hygiene: complete the stubbed IProductService.extensionsGallery so it satisfies Partial<IProductService> without a cast, and cast the entitlements literal to IEntitlementsData. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The class carried a "Service" suffix but is a plain createInstance helper owned by ExtensionGalleryAccountService (an owner-scoped memo cache), not a DI-registered service. Rename the class to ExtensionGalleryServiceIndexFetcher and the field indexService -> serviceIndexFetcher so the name no longer implies a service registration it does not have, per reviewer feedback (either register it properly or rename it). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: fe244b45-6362-4563-880d-fa3d9a27a07c
Sandeep Somavarapu (sandy081)
left a comment
There was a problem hiding this comment.
Here's my first turn of comments. Since its a big PR - I would like to go service by service to keep services clean
| // Defer to a microtask so this service is cached in the DI container before the Entra path | ||
| // resolves IAuthenticationService: resolving it mid-construction throws "RECURSIVELY | ||
| // instantiating service 'IAuthenticationService'" and breaks 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 => { | ||
| this.logService.error('[Marketplace] Error during initial gallery manifest bootstrap', error); | ||
| }); |
There was a problem hiding this comment.
May I know what you are trying to change here? What does the comment mean? Is this needed for this PR?
There was a problem hiding this comment.
Fair question — and it's stale. Removed in cec950d.
It was needed when it was added (bd44656). At that point this service performed the Entra access check itself and resolved authentication through a lazy getter:
this.instantiationService.invokeFunction(accessor => accessor.get(IAuthenticationService))Because the constructor called getExtensionGalleryManifest() synchronously, the Microsoft path hit that getter mid-construction and threw RECURSIVELY instantiating service 'IAuthenticationService', which locked the workbench on startup. Deferring the bootstrap to a microtask let the DI container finish caching this service first.
That is no longer how it works. af73002 moved access resolution into ExtensionGalleryAccountService and dropped authentication from that service graph — IAuthenticationService is now handed over after startup by ExtensionGalleryAccountAuthenticationContribution (WorkbenchPhase.AfterRestored), and the account service reports "no account" until then. Nothing reachable from this constructor can resolve authentication anymore, so the deferral was dead code.
Verified rather than assumed: launched a configured private marketplace (extensions.gallery.serviceUrl + authProvider: microsoft) with and without the deferral on a clean build. Both start normally, no recursion error, identical [Marketplace] trace output.
The same commit also corrects the comment above galleryAccountService, which was stale in the same way — it described an IAuthenticationService dependency that no longer exists and pointed at the deferral removed here.
🤖 This reply was drafted by an AI agent on behalf of Michael Cummings (MSFT) (@mcumming).
| this._validationTokenSource.value?.cancel(); | ||
| this._validationTokenSource.clear(); | ||
| } | ||
|
|
There was a problem hiding this comment.
This service seems to be doing too much. It should use marketplace account service and ask for a vlaid account it can use for private marketplace. All access / entitlement checks should be done by the marketplace account service. Also there should be an event on the account service when account has changed.
There was a problem hiding this comment.
Agreed. Restructured in bbfd837.
The manifest service was orchestrating access resolution rather than consuming it — driving the cached-then-live resolution, owning validation generations and cancellation tokens, and reaching in to clear caches. That's all in the account service now; the manifest service only maps verdict → ExtensionGalleryManifestStatus.
Entitlement checks were already in the account service — what leaked was the orchestration around them. The account-change event existed but was Event<void>, which is what pushed generations onto the host; it's now onDidChangeAccess: Event<IExtensionGalleryAccessVerdict> and carries the result.
readonly onDidChangeAccess: Event<IExtensionGalleryAccessVerdict>;
resolveAccess(configuredServiceUrl: string): Promise<IExtensionGalleryAccessVerdict>;
setPreferredAccount(accountId: string): void;
reset(): void;
connectAuthentication(authenticationService: IAuthenticationService): void;getAccount / getCachedAccess / clearCache are private, invalidateServiceIndexCache is gone, and callers no longer pass CancellationTokens. 272 → 203 lines, with the access-validation section down from ~125 to 40. No behaviour change; existing tests pass.
Testing against a live private marketplace also turned up a regression, fixed separately:
- 7c18968 — non-2xx responses reported only a status code, so a marketplace that explains the rejection in its body looked like an unreachable network. Body now included.
- 8da36d1 — a marketplace refusing the client outright (mine returns 400 "Only VS Code clients version 1.104.2 or later are allowed") was classified as transient and shown as "check your network connection".
mainreports AccessDenied for a failed fetch, so this branch was regressing the guidance. Non-401/403 4xx now maps to a denial; 5xx and network failures stay Unreachable.
🤖 This reply was drafted by an AI agent on behalf of Michael Cummings (MSFT) (@mcumming).
…vice The deferral guarded against "RECURSIVELY instantiating service 'IAuthenticationService'" as introduced in bd44656, when this service resolved authentication itself through instantiationService.invokeFunction while still constructing. af73002 moved access resolution into ExtensionGalleryAccountService and removed authentication from that service graph: IAuthenticationService is now handed over after startup by ExtensionGalleryAccountAuthenticationContribution (WorkbenchPhase.AfterRestored), and the account service reports "no account" until then. Nothing reachable from this constructor can resolve authentication anymore, so the deferral was dead code. Verified by launching a configured private marketplace with and without the deferral on a clean build: both start normally, with no recursion error and identical [Marketplace] trace output. Also corrects the galleryAccountService comment, which described an IAuthenticationService dependency that no longer exists and pointed at the deferral removed here. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4e27c63d-f29e-4ee0-a324-8adb523f5155
The manifest service was orchestrating access validation rather than consuming it: it drove the two-phase cached-then-live resolution, owned the validation generations and cancellation tokens, and reached into the account service to clear caches and invalidate the memoized service index. Roughly 125 of its 272 lines were access machinery, and four account-service internals had to be public for it. Move all of that behind the account service. It now resolves access itself (cache first, then re-validating in the background) and publishes the outcome as a verdict, so the manifest service only maps verdict to ExtensionGalleryManifestStatus. Interface changes: - add onDidChangeAccess: Event<IExtensionGalleryAccessVerdict> and resolveAccess(serviceUrl), which needs no CancellationToken from the caller - add reset() for the configuration-change path - getAccount and getCachedAccess become private; clearCache becomes private; invalidateServiceIndexCache is deleted (unused once the caller moved) No behaviour change: verdict classification, the "never downgrade an already Available marketplace" rule, cancellation semantics and cache lifetimes are preserved. extensionGalleryManifestService.ts drops from 272 to 203 lines and its access-validation section from ~125 lines to a 40-line mapping. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4e27c63d-f29e-4ee0-a324-8adb523f5155
| // Resolves which account may access the Private Marketplace and owns the durable verdict + | ||
| // in-process service-index caches. Injected as a Delayed singleton, so the proxy only | ||
| // instantiates the real service on first non-event access. It does not depend on | ||
| // IAuthenticationService — that is connected post-startup by | ||
| // ExtensionGalleryAccountAuthenticationContribution — so constructing it here cannot re-enter | ||
| // this service. | ||
|
|
||
| // Set once the private-marketplace path activates the account service; guards the config-change | ||
| // handler below from instantiating it when no private marketplace was ever configured. |
There was a problem hiding this comment.
please reduce verbose comments
There was a problem hiding this comment.
Applied across the PR, not just this file — 61ac56e, −107 comment lines over all seven files it touches.
I kept rationale only where the code can't show it: the service DI cycle, why an ineligible account is still returned, why only a 403 is persisted, and why a bearer is confined to a same-origin HTTPS target. Everything that restated the code is gone.
🤖 This reply was drafted by an AI agent on behalf of Michael Cummings (MSFT) (@mcumming).
| // Supersede any in-flight validation so its late result cannot repopulate the cache we | ||
| // clear here (the restart prompt is dismissable, so the process may keep running). | ||
| if (this.galleryAccountServiceActive) { | ||
| this.galleryAccountService.reset(); |
There was a problem hiding this comment.
Why is this reset needed? What is the scenario?
There was a problem hiding this comment.
Scenario: extensions.gallery.serviceUrl or authProvider changes at runtime. That prompts for a restart, but the prompt is dismissable — so the process can keep running against the old configuration. Without superseding, an in-flight resolution can land afterwards and publish a manifest for the marketplace we just moved away from.
reset() is gone in 082b6c7. Most of what it did belongs to the manifest service now (cancel the resolution, drop the memoized index, clear the cached verdict), so it is explicit at the config-change site instead of a method on the account service.
Testing this also turned up a real bug: the config-change listener was registered after the initial resolution, so with a slow index fetch a change during startup was missed entirely. Fixed in the same commit.
🤖 This reply was drafted by an AI agent on behalf of Michael Cummings (MSFT) (@mcumming).
| this.authProvider = getEffectiveAuthProvider(configurationService.getValue<string>(ExtensionGalleryAuthProviderConfigKey), !!productService.enableExtensionGalleryEntraAuth); | ||
| CONTEXT_MARKETPLACE_AUTH_PROVIDER.bindTo(contextKeyService).set(this.authProvider); |
There was a problem hiding this comment.
Updating this context key should be moved to marketplace account service.
There was a problem hiding this comment.
Moved in 082b6c7. It also removes a duplicate getEffectiveAuthProvider call — both services were computing the effective provider independently; now only the account service does.
🤖 This reply was drafted by an AI agent on behalf of Michael Cummings (MSFT) (@mcumming).
| if (this.currentStatus === ExtensionGalleryManifestStatus.Available) { | ||
| return; | ||
| } | ||
| this.renderAvailable(verdict.manifest); |
There was a problem hiding this comment.
Marketplace account service should just provide account verdict and should not return manfiest. Updating the manifest should be done by the manifest service itself
There was a problem hiding this comment.
Agreed, fixed in 082b6c7. The account service no longer fetches the service index or returns a manifest; it reports accountStatus and hands back the account (with its token on the Entra path). The manifest service does the fetching and publishing.
One shape note: getAccount() returns the signed-in account even when it isn't entitled, so the caller can scope a durable denial to that account id. accountStatus is what says whether it may be used — the bearer is withheld when ineligible.
🤖 This reply was drafted by an AI agent on behalf of Michael Cummings (MSFT) (@mcumming).
| // Registered before the initial resolution below: that resolution may await a slow network | ||
| // call, and a sign-out/switch during that window needs a live listener to supersede it. | ||
| this._register(this.galleryAccountService.onDidChangeAccess(verdict => this.applyVerdict(verdict))); | ||
| this.applyVerdict(await this.galleryAccountService.resolveAccess(configuredServiceUrl)); |
There was a problem hiding this comment.
Why does account service need service URL for?
There was a problem hiding this comment.
It doesn't — that was a consequence of it fetching the index. Entitlement is decided client-side (accessSKUs/enterprise flag for GitHub, the token's tid claim for Entra) and never used the URL. Removed in 082b6c7.
The one thing that genuinely was URL-scoped is the durable access verdict, which must not be reused after an admin repoints the client at a different marketplace. Rather than push that into either service, it now lives in a small ExtensionGalleryAccessCache that owns the storage key and the scoping rules.
🤖 This reply was drafted by an AI agent on behalf of Michael Cummings (MSFT) (@mcumming).
| export interface IExtensionGalleryAccountService { | ||
| readonly _serviceBrand: undefined; | ||
|
|
||
| /** | ||
| * Fires whenever the resolved access verdict changes — because the underlying account changed, | ||
| * or because a background re-validation superseded the verdict returned by {@link resolveAccess}. | ||
| */ | ||
| readonly onDidChangeAccess: Event<IExtensionGalleryAccessVerdict>; | ||
|
|
||
| /** | ||
| * Resolves whether the current account may use the Private Marketplace at `configuredServiceUrl`. | ||
| * | ||
| * Applies any durable cached verdict first so startup can render without a network round-trip, | ||
| * then re-validates. When the cache produced the returned verdict the re-validation runs in the | ||
| * background and any change is published via {@link onDidChangeAccess}; otherwise the returned | ||
| * promise already reflects live validation. | ||
| * | ||
| * Supersession, cancellation and cache lifetime are owned entirely by this service: callers never | ||
| * need to pass a {@link CancellationToken} or invalidate caches themselves. | ||
| */ | ||
| resolveAccess(configuredServiceUrl: string): Promise<IExtensionGalleryAccessVerdict>; | ||
|
|
||
| /** | ||
| * Remembers `accountId` as the account the user settled on for the Private Marketplace, so | ||
| * session selection is grounded to it across restarts when the Microsoft provider has several | ||
| * signed-in accounts. Scoped to the effective auth provider; call after an explicit account | ||
| * choice during sign-in. | ||
| */ | ||
| setPreferredAccount(accountId: string): void; | ||
|
|
||
| /** | ||
| * Cancels any in-flight validation and drops every cached verdict and memoized service index. | ||
| * Called when the marketplace configuration changes, so a late result from the previous | ||
| * configuration can never repopulate the cache or publish a stale verdict. | ||
| */ | ||
| reset(): void; | ||
|
|
||
| /** | ||
| * Supplies the {@link IAuthenticationService} the Microsoft path needs to resolve sessions. This | ||
| * is an initialization API rather than a constructor dependency to avoid a service DI cycle | ||
| * (see the class doc): the authentication graph transitively depends on the extension gallery / | ||
| * manifest chain that depends back on this service. Called once, post-startup, by | ||
| * {@link ExtensionGalleryAccountAuthenticationContribution} (orchestrator wiring). Idempotent; | ||
| * before it runs the Microsoft path reports "no account", and connecting re-signals | ||
| * {@link onDidChangeAccount} so any verdict resolved in that window is re-validated. | ||
| */ | ||
| connectAuthentication(authenticationService: IAuthenticationService): void; | ||
| } |
There was a problem hiding this comment.
Why do we need these many methods in this service? I would expect just following
- accountStatus
- onDidChangeAccountStatus
- getAccount
- onDidChangeAccount
There was a problem hiding this comment.
Trimmed to identity and entitlement — 805 lines down to 438. serviceUrl, the cancellation token, index fetching, storage, and the manifest itself all moved out.
What's left is accountStatus + onDidChangeAccountStatus, getAccount() + onDidChangeAccount, and setPreferredAccount/connectAuthentication. The last two would be private if the sign-in command and the auth contribution didn't call them from outside.
Two deviations worth flagging:
accountStatushas a fourth state,Unknown, for a transient auth failure. Without it a network blip is indistinguishable from a realSignedOutand the caller drops the cached verdict.getAccount()still returns the account when it's ineligible (with no bearer token). The caller needs the identity to scope a durable denial to it.
Happy to collapse either if you'd rather.
🤖 This reply was drafted by an AI agent on behalf of Michael Cummings (MSFT) (@mcumming).
A non-2xx service index response was reported as only a status code, so a marketplace that rejects the client and explains why in the body was indistinguishable from an unreachable network. The workbench surfaces such a failure as "The Extensions Marketplace is currently unavailable. Check your network connection", which sends the user looking in the wrong place. Append a best-effort, truncated response body to the error so the reason reaches the log. For example a marketplace enforcing a minimum client version now reports: Service index returned status 400: Access denied: Only VS Code clients version 1.104.2 or later are allowed. Diagnostics only: the status mapping is unchanged, and reading the body never throws so it cannot mask the status we already have. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4e27c63d-f29e-4ee0-a324-8adb523f5155
…twork With no session the service index is never probed, so a failing marketplace cannot turn RequiresSignIn into Unreachable and leave the user with a "check your network connection" message and a reload link instead of a sign-in affordance. That invariant was untested for the post-startup re-validation path, which runs when authentication connects and re-signals an account change. Adds a test covering that sequence: no session, an index that would reject the client, and a session-change event after the initial resolution. Asserts the status stays RequiresSignIn and that no index request is made. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4e27c63d-f29e-4ee0-a324-8adb523f5155
A marketplace can refuse the client outright — for example one enforcing a
minimum supported VS Code version replies 400 "Only VS Code clients version
1.104.2 or later are allowed". That is durable: retrying cannot help.
Such a response was classified as a transient failure and surfaced as
"The Extensions Marketplace is currently unavailable. Check your network
connection", which points the user at something that is not the problem. On main
any failed fetch of a configured marketplace reports AccessDenied ("please
contact your administrator"), so this was also a regression in what the user is
told.
Classify a non-401/403 4xx as a new MarketplaceClientRejectedError and map it to
a denial, restoring the message main gives. 5xx and network failures stay
transient and continue to report Unreachable. The denial is deliberately not
cached: it belongs to the client, not the account, and can change on upgrade.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4e27c63d-f29e-4ee0-a324-8adb523f5155
…ce the rest
Review feedback on the previous split: the account service should answer whether
there is a usable account, and nothing else. It was still fetching the service
index, so its verdict carried a manifest and it needed the marketplace URL —
neither of which is its concern.
Account service now resolves identity and entitlement only:
readonly accountStatus: ExtensionGalleryAccountStatus;
readonly onDidChangeAccountStatus: Event<ExtensionGalleryAccountStatus>;
getAccount(): Promise<IExtensionGalleryAccount | undefined>;
readonly onDidChangeAccount: Event<void>;
setPreferredAccount(accountId: string): void;
connectAuthentication(authenticationService: IAuthenticationService): void;
It no longer takes a serviceUrl or a CancellationToken, returns no manifest, and
owns no index cache. getAccount returns the signed-in account even when it is not
entitled, so callers can scope a durable denial to it; accountStatus says whether
it may be used. The marketplace auth-provider context key moves here too, which
also removes a second call to getEffectiveAuthProvider.
The manifest service now owns the marketplace side: the serviceUrl, the
non-HTTPS token-target check, the index fetch, resolution generations, and the
mapping from outcome to ExtensionGalleryManifestStatus.
The durable access verdict moves to a new ExtensionGalleryAccessCache rather than
into either service, so neither carries storage plumbing and the scoping rules —
a verdict is only honoured for the account, marketplace and auth provider it was
written for — live in one testable place. It resolves the effective auth provider
itself via getEffectiveAuthProvider, a pure function, rather than requiring a new
member on the account service.
Two ordering bugs surfaced while testing this and are fixed here: the
configuration-change listener was registered after the initial resolution, so a
change during a slow index fetch was missed entirely; and a transient failure to
resolve the account discarded the cached verdict that a later retry needs.
Behaviour is otherwise unchanged, verified against a deployed private marketplace
and by the existing suite.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4e27c63d-f29e-4ee0-a324-8adb523f5155
Review feedback: keep code comments minimal. The marketplace files carried long explanatory blocks that restated what the code already says. Trimmed every file this PR touches, keeping rationale only where the code cannot show it — the service DI cycle, why an ineligible account is still returned, why only a 403 is persisted, why a bearer is confined to a same-origin HTTPS target. Net 107 lines removed with no functional change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4e27c63d-f29e-4ee0-a324-8adb523f5155
|
|
||
|
|
||
| // Fetches and memoizes the service index for the configured marketplace. | ||
| private readonly serviceIndexFetcher: ExtensionGalleryServiceIndexFetcher; |
There was a problem hiding this comment.
Why do we need a separate class to get service manifest. Retain the old method
There was a problem hiding this comment.
The class existed only to memoize fetched indexes, and that memo could never be hit — resolve() called invalidate() immediately before the single getServiceIndex() call. With no state left it goes back to a private getExtensionGalleryManifestFromServiceUrl, and the file is deleted.
Carried over: the bearer header, followRedirects: 0 when a token is attached, and 401/403 typed apart from other 4xx.
🤖 This reply was drafted by an AI agent on behalf of Michael Cummings (MSFT) (@mcumming).
| private readonly serviceIndexFetcher: ExtensionGalleryServiceIndexFetcher; | ||
|
|
||
| // Durable "was this account allowed here?" verdicts, scoped to account + marketplace. | ||
| private readonly accessCache: ExtensionGalleryAccessCache; |
There was a problem hiding this comment.
Why do you need this access cache and what is it serving?
There was a problem hiding this comment.
Removed, along with its storage key.
It wasn't serving much: write(…, true) was never read — the only read compared === false. And the false written for a client-side ineligible account outlived the condition it recorded. Grant that same account entitlement and the stale denial short-circuited, leaving them AccessDenied until they signed out. There is now a test for that case; it failed before this change and passes after.
The only real benefit left was skipping a re-probe after a server 403, which isn't worth a storage key.
🤖 This reply was drafted by an AI agent on behalf of Michael Cummings (MSFT) (@mcumming).
| this.beginResolution(); | ||
| this.serviceIndexFetcher.invalidate(); | ||
| this.accessCache.clear(); | ||
| this.requestRestart(); |
There was a problem hiding this comment.
why are you trying to change this restart behaviour? Can this be left as before?
There was a problem hiding this comment.
Yes — restored to main's version: registered after the initial resolution, and it only calls requestRestart(). The one addition is ExtensionGalleryAuthProviderConfigKey in the condition, since changing provider changes which identity gates access. The teardown went away with the memo and the cache.
One thing I did keep: the account listener is still registered before the first resolution. Moving it after (matching main) broke a test — a sign-out while the index fetch is in flight has no listener to supersede it, so the status never leaves Unavailable.
🤖 This reply was drafted by an AI agent on behalf of Michael Cummings (MSFT) (@mcumming).
| // Registered before the initial resolution for the same reason: a sign-out or account | ||
| // switch mid-flight needs a live listener to supersede it. | ||
| this._register(this.galleryAccountService.onDidChangeAccount(() => this.resolve(configuredServiceUrl))); |
There was a problem hiding this comment.
please avoid verbose comments
There was a problem hiding this comment.
These files were 24% comment lines against 4% in the surrounding extensionManagement code. They are now 8%, and what remains explains a constraint rather than restating the code.
This block is gone with the teardown.
🤖 This reply was drafted by an AI agent on behalf of Michael Cummings (MSFT) (@mcumming).
| this.update(null, ExtensionGalleryManifestStatus.AccessDenied); | ||
| return; | ||
| } | ||
| if (account.accessToken && !isSafeTokenTarget(configuredServiceUrl, configuredServiceUrl)) { |
There was a problem hiding this comment.
What is the purpose of isSafeTokenTarget method? Can you please explain what scenarios we are trying to handle by checking this?
There was a problem hiding this comment.
The intent was to never attach a bearer to a URL that isn't HTTPS and isn't the admin-configured origin, so a tampered service index couldn't redirect the token somewhere else.
It didn't do that. The one call site passed the same URL for both arguments, so the same-origin half was always true and only the HTTPS check ran — on a URL we already control. Redirects are handled by followRedirects: 0 on the authenticated request.
Dropped for an inline HTTPS guard. MarketplaceMisconfiguredError beside it was never thrown — gone too.
🤖 This reply was drafted by an AI agent on behalf of Michael Cummings (MSFT) (@mcumming).
| // --- Status management --- | ||
|
|
||
| /** Publishes the manifest and reports successful custom-marketplace access (any auth provider). */ | ||
| private renderAvailable(manifest: IExtensionGalleryManifest): void { |
There was a problem hiding this comment.
this method name seems misleading - please rename it appropriately
There was a problem hiding this comment.
Renamed to setAvailable — it publishes and logs telemetry, renders nothing.
🤖 This reply was drafted by an AI agent on behalf of Michael Cummings (MSFT) (@mcumming).
| // Fired for every successfully accessed serviceUrl-configured marketplace regardless of auth | ||
| // provider; the github/microsoft distinction is tracked separately by 'marketplace:auth:checked'. | ||
| this.telemetryService.publicLog2< |
There was a problem hiding this comment.
please reduce noisy comments
There was a problem hiding this comment.
Same pass as the other thread — 24% down to 8%, against 4% in the surrounding code.
This one is gone; the telemetry event name already says it.
🤖 This reply was drafted by an AI agent on behalf of Michael Cummings (MSFT) (@mcumming).
The index fetcher only existed to hold a memo that could never be hit: resolve() invalidated it immediately before its single read. With no state left it returns to a private method on the manifest service. The durable access cache is removed. Its `true` verdict was never read, and the `false` written for a client-side ineligible account outlived the condition it recorded - an account later granted entitlement stayed AccessDenied until sign-out. Covered by a new regression test. isSafeTokenTarget was called with the same URL for both arguments, so only its HTTPS check ever ran; it becomes an inline guard. Redirects are already handled by followRedirects: 0. MarketplaceMisconfiguredError was never thrown and is gone. The configuration listener returns to its shape on main, with the auth provider key added. renderAvailable becomes setAvailable. Comment volume across these files drops from 24% to 8%, against 4% in the surrounding extensionManagement code. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4e27c63d-f29e-4ee0-a324-8adb523f5155
A private marketplace is account-scoped, but the success path skipped publishing whenever the status was already Available. Switching to a different eligible account therefore fetched that account's catalog and then discarded it, leaving the previous account's in place until restart. The manifest is now published on every success; the custom-marketplace telemetry still fires only on the transition into Available. Removing the access cache orphaned IExtensionGalleryAccount.id - it existed only to scope a cached verdict to an account - so it goes, along with the comment describing the verdict it scoped. That removal also took with it the only test asserting that an already-available marketplace survives a transient failure. Restored for both the fetch and the account-resolution paths. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4e27c63d-f29e-4ee0-a324-8adb523f5155
|
Michael Cummings (MSFT) (@mcumming) I pushed a change to |
Takes c41e451 as-is, with two changes that restore main's outcome: A failed manifest fetch is reported as denied, as on main, rather than asking an already-signed-in user to sign in. This also keeps the minimum-client-version rejection reading the way it does today. A transient failure to resolve the account no longer retracts a marketplace the user already has. On main that throw rejects the promise and no status is published, so an available marketplace survives; here the account service catches it, reports Unknown, and returned undefined would otherwise be read as a sign-out. Authentication moves to the follow-up PR: the bearer on the service index, the HTTPS guard, redirect suppression, and 401/403 typing all go, along with the Unreachable and Misconfigured statuses that only existed to describe them, and their welcome content and badges. Two defects that also reproduce on main are now separate PRs - microsoft#331800 (a sign-out during an in-flight fetch) and microsoft#331804 (a 200 carrying any JSON accepted as a service index) - so the tests covering them move there. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4e27c63d-f29e-4ee0-a324-8adb523f5155
extensions.gallery.authProvider is now the only switch for the Microsoft path. Also removes extensionGalleryAccess.ts, whose remaining exports were already orphaned by the manifest service adoption in e61ce45. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4e27c63d-f29e-4ee0-a324-8adb523f5155
| /** A configured marketplace could not be reached — transient, unlike {@link Unavailable}. */ | ||
| Unreachable = 'unreachable', | ||
| /** The deployment cannot work as configured — e.g. a non-HTTPS service index under Entra auth. */ | ||
| Misconfigured = 'misconfigured' |
There was a problem hiding this comment.
Lets not introduce more status code unless necessay
There was a problem hiding this comment.
Already done — Unreachable and Misconfigured came out in e61ce45, so the enum is back to the four values on main. The UI sites that switched on them went with it.
🤖 This reply was drafted by an AI agent on behalf of Michael Cummings (MSFT) (@mcumming).
| export const ExtensionGalleryAuthProviderConfigKey = 'extensions.gallery.authProvider'; | ||
|
|
||
| /** Standard OpenID Connect scopes — enough to identify the user for the eligibility check. */ | ||
| export const PRIVATE_MARKETPLACE_SCOPES: string[] = ['openid', 'profile', 'email', 'offline_access']; |
There was a problem hiding this comment.
move this scopes to product.json just like scopes for github
There was a problem hiding this comment.
Done in a65242b — extensionsGallery.accessScopes, next to accessSKUs.
Followed defaultChatAgent.providerScopes exactly: no in-source fallback, so the product file is the only source. If a deployment turns on the Microsoft path without configuring scopes it now reports no account, rather than requesting a session it can't use.
These are plain OIDC scopes — we take the auth provider's default client id and organizations tenant, so no VSCODE_* overrides are involved. That's all the eligibility check needs: an ID token carrying a tid claim.
One asymmetry worth naming: providerScopes is required in the type, accessScopes can't be — extensionsGallery is itself optional, and requiring it would force scopes on GitHub-path deployments where they're meaningless. The fail-closed check covers that gap.
🤖 This reply was drafted by an AI agent on behalf of Michael Cummings (MSFT) (@mcumming).
| 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') |
There was a problem hiding this comment.
Avoid hardcoding provider ids. They should be read only at one place which should be gallery accout service. The sign in button should just say Sign In - why does user has to know if it is github or microsoft if they have to sign in anyway - the sign in page anyway shows right
There was a problem hiding this comment.
Done in 3973ede — back to a single status-gated welcome entry, same shape as main.
You were right that the fork bought nothing: both blocks already invoked the same command and differed only in the label. Collapsing them left CONTEXT_MARKETPLACE_AUTH_PROVIDER with no consumers, so the context key is deleted too. The browser layer no longer references either provider id, and it also stopped importing DEFAULT_ACCOUNT_SIGN_IN_COMMAND.
🤖 This reply was drafted by an AI agent on behalf of Michael Cummings (MSFT) (@mcumming).
| * Interactive Microsoft sign-in, registered in the Electron layer and invoked by id from the | ||
| * browser-layer action so it need not cross the layer boundary. | ||
| */ | ||
| export const ExtensionGalleryMicrosoftSignInCommandId = 'workbench.extensions.marketplace.signInWithMicrosoft'; |
There was a problem hiding this comment.
THere should be just one command for sign in and account service should handle signing in to microsoft or github
There was a problem hiding this comment.
Done in 3973ede. ExtensionGalleryMicrosoftSignInCommandId is gone along with its CommandsRegistry.registerCommand, and the action is now just:
await accessor.get(IExtensionGalleryAccountService).signIn();GitHub's signIn() delegates to defaultAccountService.signIn(); the Microsoft provider owns its account quick-pick. To make that callable I moved the service interface to services/extensionManagement/common/extensionGalleryAccount.ts, mirroring how IDefaultAccountService splits interface from implementation — the command id only existed because the browser layer couldn't reach electron-browser.
🤖 This reply was drafted by an AI agent on behalf of Michael Cummings (MSFT) (@mcumming).
| const MSA_TENANT_ID = '9188040d-6c67-4c5b-b112-36a304b66dad'; | ||
| const MSA_PASSTHROUGH_TENANT_ID = 'f8cdef31-a31e-4b4a-93e4-5f571e91255a'; |
There was a problem hiding this comment.
Why are these hardcoded here? They should be read from product.json.
I would leave Tyler to review getting and checking microsoft account.
There was a problem hiding this comment.
Two things to weigh before moving these, then happy either way.
The same two GUIDs are hardcoded in extensions/microsoft-authentication/src/common/scopeData.ts and cli/src/auth.rs — fixed Microsoft identity values rather than per-deployment config, which is why I matched that.
More importantly, this check decides whether the access token is released, so reading it from product.json would need an explicit closed default: absent values would otherwise make every account look eligible.
Probably the more useful precedent: scopeData.ts shows the customization path for Microsoft auth is the magic scope entries (VSCODE_CLIENT_ID:, VSCODE_TENANT:) rather than product.json, and DEFAULT_TENANT is already organizations, which excludes personal accounts. So this check is defence-in-depth against a session created elsewhere with common, not the only line.
🤖 This reply was drafted by an AI agent on behalf of Michael Cummings (MSFT) (@mcumming).
There was a problem hiding this comment.
I would leave this to TylerLeonhardt
There was a problem hiding this comment.
These values are well-known and never change. This is totally valid for detecting "not personal microsoft accounts"
|
|
||
| // A constructor dependency here would form a DI cycle: this → auth → extensionService → gallery | ||
| // → manifest → this, which aborts startup. | ||
| private authenticationService: IAuthenticationService | undefined; |
There was a problem hiding this comment.
I do not like this pattern. Instead check DefaultAccountProviderContribution how we handle this cyclic dependencies
There was a problem hiding this comment.
Done in 3973ede — followed that pattern.
connectAuthentication is gone. There's now an IExtensionGalleryAccountProvider with two implementations, and a BlockStartup contribution builds the configured one via createInstance and calls setAccountProvider(...). The service holds no authentication dependency at all now rather than receiving it late, which is strictly better than what I had.
Two things fell out: setPreferredAccount was only ever called by the old sign-in command, so it's private now and off the service interface; and onDidChangeAccountStatus is load-bearing, carrying status from provider to service.
One behaviour change worth flagging: the GitHub path used to resolve at service construction and now also arrives via the contribution. I used BlockStartup to match DefaultAccountProviderContribution, and setAccountProvider fires onDidChangeAccount so anything that resolved early re-resolves.
🤖 This reply was drafted by an AI agent on behalf of Michael Cummings (MSFT) (@mcumming).
| } | ||
|
|
||
| /** Entitlement decided locally from the token's tenant claim. The bearer travels with it. */ | ||
| private async getMicrosoftAccount(): Promise<IExtensionGalleryAccount | undefined> { |
There was a problem hiding this comment.
I would request to get review from Tyler for this.
Adds extensionsGallery.accessScopes and drops the hardcoded PRIVATE_MARKETPLACE_SCOPES, following defaultChatAgent.providerScopes. Session lookup and interactive sign-in resolve the scopes through one accessor so they cannot drift. A deployment that enables the Microsoft path without configuring scopes now reports no account instead of requesting a session with scopes it did not ask for. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4e27c63d-f29e-4ee0-a324-8adb523f5155
Follows DefaultAccountProvider: the auth-dependent half becomes an IExtensionGalleryAccountProvider that a workbench contribution builds and hands to the service, so the service no longer takes IAuthenticationService at all and connectAuthentication is gone. Sign-in is now a single signIn() on the service, which removes the provider-specific command id and the cross-layer invoke-by-string. The extensions view welcome content collapses back to one status-gated entry labelled Sign In, leaving CONTEXT_MARKETPLACE_AUTH_PROVIDER with no consumers. The service interface moves to common/ so the browser layer can call it directly. Both desktop entry points import the electron-browser module explicitly: it is now only reachable for its registerSingleton side effect, and without that the renderer fails to start. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4e27c63d-f29e-4ee0-a324-8adb523f5155
|
Changes look good to me. Thanks for incorporating all feedback. Please wait for TylerLeonhardt 's feedback on Microsoft account provider implementation and then we are good to merge. Thanks |
| await chooseAccount(pick.account); | ||
| } | ||
|
|
||
| private readPreferredAccountId(): string | undefined { |
There was a problem hiding this comment.
Sandeep Somavarapu (@sandy081) you don't wanna align this with the account Settings Sync might be using?
| @IExtensionGalleryAccountService accountService: IExtensionGalleryAccountService, | ||
| ) { | ||
| super(); | ||
| const authProvider: ExtensionGalleryAccessProviderId = configurationService.getValue<string>(ExtensionGalleryAuthProviderConfigKey) === 'microsoft' ? 'microsoft' : 'github'; |
There was a problem hiding this comment.
And if this value changes?
There was a problem hiding this comment.
Fixed, now requests a restart on change.
The provider is selected once at startup, so changing extensions.gallery.authProvider mid-session had no effect and gave no indication that it had not been applied. The sibling serviceUrl setting already prompts; this reuses that listener and dialog rather than rebuilding the provider live. Each setting keeps its own message: serviceUrl still reports a different Marketplace, and the auth change reports a configuration change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4e27c63d-f29e-4ee0-a324-8adb523f5155
Addresses #280376 — Enable VS Code sign-in with Microsoft Entra ID to connect to Private Marketplace.
Why
Today a configured Private Marketplace (
extensions.gallery.serviceUrl) is gated only through the GitHub/default account path (enterprise flag or entitlement SKU). Customers who manage identity in Microsoft Entra ID have no way to sign in and be authorized against their marketplace. This change makes marketplace access provider-aware and adds a new Microsoft/Entra path with server-enforced eligibility, so an Entra-signed-in user who is verified as eligible gets the configured marketplace as their active Extension Gallery.What changes
The new path is entirely behind a product flag (
product.json→enableExtensionGalleryEntraAuth) and a per-marketplace setting/policy (extensions.gallery.authProvider, valuesgithub|microsoft). When the flag is off,microsoftis coerced back togithub, so existing behavior is untouched.extensionGalleryManifestService.ts):getEffectiveAuthProvider()selects the access strategy. The GitHub path is preserved as-is; a new Microsoft path acquires an existing Entra session silently (getSessions, never prompts), reads the service index, discovers the manifest-advertised EligibilityService endpoint, and POSTs the token to it. The server's boolean verdict decides access.extensionsViewlet.ts,extensions.contribution.ts,extensions.ts): provider-aware welcome view and activity badge for the new marketplace states, plus a provider-routed sign-in command (microsoft→ EntracreateSession, otherwise the existing default-account sign-in). ACONTEXT_MARKETPLACE_AUTH_PROVIDERcontext key backs the routing.extensionGalleryManifest.ts): addsMisconfiguredandUnreachablestates (alongsideRequiresSignIn/AccessDenied), theEligibilityServiceresource type,PRIVATE_MARKETPLACE_SCOPES, theauthProviderconfig key, and a typedMarketplaceAuthRequiredError.policyData.jsonc,product.ts,product.json): registers theauthProvideradmin policy and addsmicrosofttotrustedExtensionAuthAccess.Security and correctness
isSafeTokenTarget), so a compromised/misconfigured manifest can't redirect the token to a foreign or cleartext origin.Authorizationheader across hops).RequiresSignIn, never cached; 403 (identity accepted but forbidden) →AccessDenied, cached as ineligible.authProvider+accountId+serviceUrland dropped on any mismatch, so a stale allow/deny can't leak across accounts or marketplaces.await, so a session/account/config change mid-validation supersedes an in-flight result instead of racing it.Unreachablerather than leaving a configured marketplace on a blank view or throwing.Testing
extensionGalleryManifestService.test.tscovering provider routing, the eligibility handshake, cache scoping/invalidation, 401/403/5xx/malformed classification, and the epoch race paths.npm run compile-check-ts-native, the gallery unit suite, andnpm run valid-layers-checkall pass.