diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index f82aff27a42d7d..a7d5bbb007f052 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -173,6 +173,35 @@ "default": "", "included": false }, + { + "key": "extensions.gallery.authProvider", + "name": "ExtensionGalleryAuthProvider", + "category": "Extensions", + "minimumVersion": "1.121", + "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.allowed", "name": "AllowedExtensions", diff --git a/product.json b/product.json index 15f5796e3ac4d4..8dd795f0b8c35b 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/oauth.ts b/src/vs/base/common/oauth.ts index 3e4d6133360a9e..a99551ac53b2e7 100644 --- a/src/vs/base/common/oauth.ts +++ b/src/vs/base/common/oauth.ts @@ -1296,6 +1296,14 @@ export interface IFetchAuthorizationServerMetadataOptions { * Optional custom fetch implementation (defaults to global fetch) */ fetch?: IFetcher; + /** + * When `true`, enforce RFC 8414 §3: the `issuer` in the returned metadata must be identical to + * the requested authorization server identifier. Defaults to `false` because multi-tenant + * providers (e.g. Microsoft Entra `/common`) legitimately return a templated, per-tenant issuer + * that differs from the requested identifier. Enable it only for callers that discover a single + * concrete authorization server (e.g. a marketplace protected resource). + */ + validateIssuer?: boolean; } /** Helper to try parsing the response as authorization server metadata */ @@ -1351,7 +1359,8 @@ export async function fetchAuthorizationServerMetadata( ): Promise<{ metadata: IAuthorizationServerMetadata; discoveryUrl: string; errors: Error[] }> { const { additionalHeaders = {}, - fetch: fetchImpl = fetch + fetch: fetchImpl = fetch, + validateIssuer = false } = options; const authorizationServerUrl = new URL(authorizationServer); @@ -1370,6 +1379,17 @@ export async function fetchAuthorizationServerMetadata( }); const metadata = await tryParseAuthServerMetadata(rawResponse); if (metadata) { + // RFC 8414 §3: when opted in, the metadata `issuer` MUST be identical (exact match — + // no trailing-slash normalization) to the authorization server identifier used to + // build the discovery URL. This closes a mix-up / spoofing vector where a compromised + // or misconfigured well-known endpoint returns metadata (token and authorization + // endpoints) bound to a *different* issuer. Fail closed: treat a mismatch as if no + // metadata was found so the remaining discovery URLs are tried and, if none match, + // the caller sees an error rather than silently trusting them. + if (validateIssuer && metadata.issuer !== authorizationServer) { + errors.push(new Error(`Authorization server metadata issuer '${metadata.issuer}' does not match the requested authorization server '${authorizationServer}' (RFC 8414 §3)`)); + return undefined; + } return metadata; } // No metadata found, collect error from response diff --git a/src/vs/base/common/product.ts b/src/vs/base/common/product.ts index 5af093a6519bac..b25af3d9df5b7b 100644 --- a/src/vs/base/common/product.ts +++ b/src/vs/base/common/product.ts @@ -139,6 +139,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 mcpGallery?: { readonly serviceUrl: string; readonly itemWebUrl: string; diff --git a/src/vs/base/parts/request/common/requestImpl.ts b/src/vs/base/parts/request/common/requestImpl.ts index 17f80575ef5e23..45c2e938e2228d 100644 --- a/src/vs/base/parts/request/common/requestImpl.ts +++ b/src/vs/base/parts/request/common/requestImpl.ts @@ -30,6 +30,13 @@ export async function request(options: IRequestOptions, token: CancellationToken if (options.disableCache) { fetchInit.cache = 'no-store'; } + if (options.followRedirects === 0) { + // A request may carry a bearer/subject token in its body or headers. Refuse to follow + // redirects so `fetch` can't replay it to a (possibly cross-origin) redirect target and + // leak it (form-urlencoded bodies are CORS "simple requests", so a 307/308 would resend + // the body). An opaque redirect surfaces as status 0, which callers treat as a failure. + fetchInit.redirect = 'manual'; + } const res = await fetch(options.url || '', fetchInit); return { res: { diff --git a/src/vs/base/parts/request/test/electron-main/request.test.ts b/src/vs/base/parts/request/test/electron-main/request.test.ts index e1d88f1583e8ef..2e875396a7a689 100644 --- a/src/vs/base/parts/request/test/electron-main/request.test.ts +++ b/src/vs/base/parts/request/test/electron-main/request.test.ts @@ -17,14 +17,26 @@ suite('Request', () => { let port: number; let server: http.Server; + let redirectTargetHits: number; setup(async () => { + redirectTargetHits = 0; const http = await import('http'); port = await new Promise((resolvePort, rejectPort) => { server = http.createServer((req, res) => { if (req.url === '/noreply') { return; // never respond } + if (req.url === '/redirect') { + // 307 preserves the method and body on the follow-up request (unlike 301/302/303). + res.statusCode = 307; + res.setHeader('location', '/redirect-target'); + res.end(); + return; + } + if (req.url === '/redirect-target') { + redirectTargetHits++; + } res.setHeader('Content-Type', 'application/json'); if (req.headers['echo-header']) { res.setHeader('echo-header', req.headers['echo-header']); @@ -122,5 +134,34 @@ suite('Request', () => { }); }); + test('follows a 307 redirect by default, replaying the POST body to the target', async () => { + const context = await request({ + type: 'POST', + url: `http://127.0.0.1:${port}/redirect`, + data: 'post-payload', + callSite: 'request.test.redirect.follow' + }, CancellationToken.None); + assert.strictEqual(context.res.statusCode, 200); + const body = JSON.parse((await streamToBuffer(context.stream)).toString()); + assert.deepStrictEqual( + { hits: redirectTargetHits, method: body.method, url: body.url, data: body.data }, + { hits: 1, method: 'POST', url: '/redirect-target', data: 'post-payload' } + ); + }); + + test('does not follow redirects when followRedirects is 0 (no body replay)', async () => { + const context = await request({ + type: 'POST', + url: `http://127.0.0.1:${port}/redirect`, + data: 'post-payload', + followRedirects: 0, + callSite: 'request.test.redirect.manual' + }, CancellationToken.None); + assert.deepStrictEqual( + { hits: redirectTargetHits, followed: context.res.statusCode === 200 }, + { hits: 0, followed: false } + ); + }); + ensureNoDisposablesAreLeakedInTestSuite(); }); diff --git a/src/vs/base/test/common/oauth.test.ts b/src/vs/base/test/common/oauth.test.ts index fdfaf682a96efd..9f4b8eeafddaee 100644 --- a/src/vs/base/test/common/oauth.test.ts +++ b/src/vs/base/test/common/oauth.test.ts @@ -2255,6 +2255,83 @@ suite('OAuth', () => { const headers = fetchStub.firstCall.args[1].headers; assert.strictEqual(headers['Accept'], 'application/json'); }); + + test('should reject metadata whose issuer does not match the requested authorization server (RFC 8414 §3)', async () => { + const authorizationServer = 'https://auth.example.com/tenant'; + // A well-known endpoint that serves metadata bound to a different issuer must not be + // trusted, even when the JSON is otherwise a valid authorization-server metadata document. + const mismatchedMetadata: IAuthorizationServerMetadata = { + issuer: 'https://evil.example.com/tenant', + authorization_endpoint: 'https://evil.example.com/tenant/authorize', + token_endpoint: 'https://evil.example.com/tenant/token', + response_types_supported: ['code'] + }; + + fetchStub.resolves({ + status: 200, + json: async () => mismatchedMetadata, + text: async () => JSON.stringify(mismatchedMetadata), + statusText: 'OK' + }); + + await assert.rejects( + async () => fetchAuthorizationServerMetadata(authorizationServer, { fetch: fetchStub, validateIssuer: true }), + (error: any) => { + assert.ok(error instanceof AggregateError, 'Should be an AggregateError'); + assert.ok( + error.errors.some((err: Error) => /does not match the requested authorization server/.test(err.message)), + 'Should report the issuer mismatch' + ); + return true; + } + ); + // All three discovery URLs are attempted; none is trusted. + assert.strictEqual(fetchStub.callCount, 3); + }); + + test('accepts a templated multi-tenant issuer by default (validateIssuer off)', async () => { + // Microsoft Entra `/common` returns a per-tenant issuer that does not match the requested + // `/common` identifier. Without opting in, discovery must NOT reject it — otherwise MCP, + // XAA, agentHost and mainThreadAuthentication break for multi-tenant sign-in. + const authorizationServer = 'https://login.microsoftonline.com/common/v2.0'; + const tenantMetadata: IAuthorizationServerMetadata = { + issuer: 'https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0', + response_types_supported: ['code'] + }; + + fetchStub.resolves({ + status: 200, + json: async () => tenantMetadata, + text: async () => JSON.stringify(tenantMetadata), + statusText: 'OK' + }); + + const result = await fetchAuthorizationServerMetadata(authorizationServer, { fetch: fetchStub }); + + assert.deepStrictEqual(result.metadata, tenantMetadata); + assert.strictEqual(fetchStub.callCount, 1); + }); + + test('with validateIssuer, rejects an issuer that differs only by a trailing slash (exact match)', async () => { + const authorizationServer = 'https://auth.example.com/tenant'; + const trailingSlashMetadata: IAuthorizationServerMetadata = { + issuer: 'https://auth.example.com/tenant/', + response_types_supported: ['code'] + }; + + fetchStub.resolves({ + status: 200, + json: async () => trailingSlashMetadata, + text: async () => JSON.stringify(trailingSlashMetadata), + statusText: 'OK' + }); + + await assert.rejects( + async () => fetchAuthorizationServerMetadata(authorizationServer, { fetch: fetchStub, validateIssuer: true }), + (error: any) => error instanceof AggregateError && error.errors.some((err: Error) => /does not match the requested authorization server/.test(err.message)) + ); + assert.strictEqual(fetchStub.callCount, 3); + }); }); suite('Cross App Access (ID-JAG) wire format', () => { diff --git a/src/vs/platform/extensionManagement/common/extensionGalleryManifest.ts b/src/vs/platform/extensionManagement/common/extensionGalleryManifest.ts index cef5e2af703b2f..aaf3e058837d23 100644 --- a/src/vs/platform/extensionManagement/common/extensionGalleryManifest.ts +++ b/src/vs/platform/extensionManagement/common/extensionGalleryManifest.ts @@ -3,8 +3,20 @@ * 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 { AUTH_SCOPE_SEPARATOR, fetchAuthorizationServerMetadata, fetchResourceMetadata, GRANT_TYPE_TOKEN_EXCHANGE, IAuthorizationTokenResponse, isAuthorizationTokenResponse, parseWWWAuthenticateHeader, TOKEN_TYPE_ACCESS_TOKEN } from '../../../base/common/oauth.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { RawContextKey } from '../../contextkey/common/contextkey.js'; +import { asJson, asText, IRequestService } from '../../request/common/request.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 +27,7 @@ export const enum ExtensionGalleryResourceType { ExtensionRatingViewUri = 'ExtensionRatingViewUriTemplate', ExtensionResourceUri = 'ExtensionResourceUriTemplate', ContactSupportUri = 'ContactSupportUri', + EligibilityService = 'EligibilityService', } export const enum Flag { @@ -68,7 +81,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'); @@ -80,6 +107,16 @@ export interface IExtensionGalleryManifestService { readonly onDidChangeExtensionGalleryManifestStatus: Event; readonly onDidChangeExtensionGalleryManifest: Event; getExtensionGalleryManifest(): Promise; + + /** + * Returns the bearer token to attach to authenticated marketplace API requests + * (e.g. `extensionquery`, asset download), or `undefined` when the marketplace does + * not require authentication. When the marketplace service index is `[Authorize]`-gated, + * this is a resource-scoped token negotiated via RFC 9728 Protected Resource Metadata. + * Consumers MUST only attach the token to requests whose target is same-origin HTTPS with + * the marketplace service index to avoid leaking it to foreign or cleartext endpoints. + */ + getAccessToken(): Promise; } export function getExtensionGalleryManifestResourceUri(manifest: IExtensionGalleryManifest, type: string): string | undefined { @@ -98,3 +135,202 @@ 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`) up front. When the + * marketplace service index is `[Authorize]`-gated, a resource-bound token is instead + * negotiated on demand from the server's `WWW-Authenticate` challenge (Protected + * Resource Metadata, RFC 9728); these scopes serve as the fallback sign-in scopes for + * that negotiation. + */ +export const PRIVATE_MARKETPLACE_SCOPES: string[] = ['openid', 'profile', 'email', 'offline_access']; + +/** + * The subset of RFC 9728 Protected Resource Metadata the marketplace negotiation needs: the + * authorization server to acquire a token from and the resource-scoped scopes to request. + */ +export interface IMarketplaceProtectedResource { + /** The authorization server (`authorization_servers[0]`) to acquire the resource token from. */ + readonly authorizationServer: string; + /** The resource-scoped scopes (`scopes_supported`) to request for the marketplace resource. */ + readonly scopes: readonly string[]; + /** The protected resource identifier (`resource`) the metadata describes. */ + readonly resource: string; +} + +/** + * True when `candidate` is an HTTPS URL on the same origin as `base`. Fail-closed on any parse + * error. Used to gate the untrusted `resource_metadata` URL carried by a server `WWW-Authenticate` + * challenge before it is fetched, so a compromised or malicious marketplace index cannot point + * Protected Resource Metadata discovery at a foreign origin (SSRF) or a cleartext endpoint. + */ +function isSameOriginHttpsUrl(candidate: string, base: string): boolean { + try { + const candidateUrl = new URL(candidate); + const baseUrl = new URL(base); + return candidateUrl.protocol === 'https:' && candidateUrl.origin === baseUrl.origin; + } catch { + return false; + } +} + +/** + * Discovers the marketplace's Protected Resource Metadata (RFC 9728) so a resource-scoped token + * can be minted for an `[Authorize]`-gated marketplace index. + * + * Discovery is driven by the well-known endpoint (`/.well-known/oauth-protected-resource`) rather + * than the server's `WWW-Authenticate` challenge: `WWW-Authenticate` is not a CORS-safelisted + * response header, so the renderer's cross-origin index fetch frequently cannot read it. The + * metadata *body*, however, is CORS-readable, so well-known discovery is robust where the challenge + * header is not. The optional `wwwAuthenticate` string is used only as a best-effort hint for the + * explicit `resource_metadata` URL when the header happens to be readable. + * + * The `resource_metadata` hint is server-controlled and therefore untrusted: it is honored only + * when it is same-origin HTTPS with `serviceIndexUrl`. A cross-origin or cleartext hint is ignored + * (discovery falls back to the well-known endpoint derived from the trusted service index origin), + * so a compromised index cannot steer PRM discovery to an attacker-controlled URL. + * + * Returns `undefined` (never throws) when discovery fails or the metadata omits an authorization + * server, so callers can fall back to their existing sign-in / unreachable handling. + */ +export async function discoverMarketplaceProtectedResource( + requestService: IRequestService, + serviceIndexUrl: string, + wwwAuthenticate: string | undefined, + token: CancellationToken, +): Promise { + let resourceMetadataUrl: string | undefined; + if (wwwAuthenticate) { + for (const challenge of parseWWWAuthenticateHeader(wwwAuthenticate)) { + if (challenge.scheme.toLowerCase() === 'bearer' && challenge.params.resource_metadata) { + resourceMetadataUrl = challenge.params.resource_metadata; + break; + } + } + } + if (resourceMetadataUrl && !isSameOriginHttpsUrl(resourceMetadataUrl, serviceIndexUrl)) { + // The hint came from the server's WWW-Authenticate challenge and is not same-origin HTTPS + // with the configured service index — a compromised/malicious index could use it to steer + // PRM discovery at a foreign origin (SSRF) or a cleartext endpoint. Drop it and fall back to + // well-known discovery, which derives the PRM URL from the trusted service index origin. + resourceMetadataUrl = undefined; + } + const fetcher = async (input: string, init: { method: string; headers: Record }) => { + const context = await requestService.request({ type: init.method, url: input, headers: init.headers, callSite: 'extensionGalleryManifest.discoverMarketplaceProtectedResource' }, token); + return { + status: context.res.statusCode ?? 0, + statusText: '', + json: async (): Promise => await asJson(context), + text: async (): Promise => (await asText(context)) ?? '', + }; + }; + try { + const { metadata } = await fetchResourceMetadata(serviceIndexUrl, resourceMetadataUrl, { fetch: fetcher }); + const authorizationServer = metadata.authorization_servers?.[0]; + if (!authorizationServer) { + return undefined; + } + return { + authorizationServer, + scopes: metadata.scopes_supported ?? [], + resource: metadata.resource, + }; + } catch { + return undefined; + } +} + +/** + * Exchanges an authentication provider's access token (e.g. a GitHub session token the client + * already holds) for a first-party, audience-bound marketplace access token, using the RFC 8693 + * token-exchange grant at the marketplace's advertised authorization server. + * + * This is the GitHub auth-enabled scheme's token acquisition. Unlike the Entra scheme — where the + * Microsoft/MSAL provider mints a resource-scoped token directly via `getSessions(..., + * { authorizationServer })` — VS Code's GitHub provider has no resource-token support, so the + * marketplace's embedded Authorization Server performs a token exchange: it converts the caller's + * GitHub token into an `at+jwt` bound to the marketplace resource (`aud = resource`). + * + * The raw `subjectToken` is transmitted ONLY to the advertised authorization server's token + * endpoint — never to the resource server. Both the authorization server and its discovered token + * endpoint are validated with `isSafeTarget` (fail-closed) before the token is sent, so a + * compromised or misconfigured PRM cannot exfiltrate the GitHub token to a foreign/cleartext + * origin. On success returns the minted `accessToken` together with its advertised lifetime + * (`expiresInSeconds`, from the token response's `expires_in`) so the caller can schedule a + * proactive re-mint before it expires. Returns `undefined` (never throws) when discovery or the + * exchange fails, so callers fall back to their existing sign-in handling. + * + * `isCurrent` lets the caller abort just before the subject token is POSTed if its validation has + * been superseded (e.g. a sign-out / account switch while the authorization-server metadata GET was + * in flight). The desktop caller drives currentness this way because it passes + * `CancellationToken.None` — so this predicate, not `token`, is what actually guards that path. + */ +export async function exchangeMarketplaceResourceToken( + requestService: IRequestService, + protectedResource: IMarketplaceProtectedResource, + subjectToken: string, + isSafeTarget: (targetUrl: string) => boolean, + token: CancellationToken, + isCurrent: () => boolean = () => true, +): Promise<{ accessToken: string; expiresInSeconds?: number } | undefined> { + if (!isSafeTarget(protectedResource.authorizationServer)) { + return undefined; + } + const fetcher = async (input: string, init: { method: string; headers: Record }) => { + const context = await requestService.request({ type: init.method, url: input, headers: init.headers, callSite: 'extensionGalleryManifest.exchangeMarketplaceResourceToken' }, token); + return { + status: context.res.statusCode ?? 0, + statusText: '', + json: async (): Promise => await asJson(context), + text: async (): Promise => (await asText(context)) ?? '', + }; + }; + try { + const { metadata } = await fetchAuthorizationServerMetadata(protectedResource.authorizationServer, { fetch: fetcher, validateIssuer: true }); + const tokenEndpoint = metadata.token_endpoint; + if (!tokenEndpoint || !isSafeTarget(tokenEndpoint)) { + return undefined; + } + // A sign-out / account switch can occur while the AS-metadata GET above is in flight. If the + // caller cancelled — or its validation has been superseded (`isCurrent()` is false) — do not + // POST the (now potentially revoked) subject token. + if (token.isCancellationRequested || !isCurrent()) { + return undefined; + } + const body = new URLSearchParams(); + body.append('grant_type', GRANT_TYPE_TOKEN_EXCHANGE); + body.append('subject_token', subjectToken); + body.append('subject_token_type', TOKEN_TYPE_ACCESS_TOKEN); + body.append('resource', protectedResource.resource); + if (protectedResource.scopes.length) { + body.append('scope', protectedResource.scopes.join(AUTH_SCOPE_SEPARATOR)); + } + const context = await requestService.request({ + type: 'POST', + url: tokenEndpoint, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + data: body.toString(), + callSite: 'extensionGalleryManifest.exchangeMarketplaceResourceToken', + // A bearer/subject token is in the body; never follow redirects so the request service + // can't forward it to a (possibly cross-origin) redirect target and leak it. + followRedirects: 0, + }, token); + if (context.res.statusCode !== 200) { + return undefined; + } + const response = await asJson(context); + if (response && isAuthorizationTokenResponse(response) && response.access_token) { + return { accessToken: response.access_token, expiresInSeconds: response.expires_in }; + } + return undefined; + } catch { + return undefined; + } +} diff --git a/src/vs/platform/extensionManagement/common/extensionGalleryManifestService.ts b/src/vs/platform/extensionManagement/common/extensionGalleryManifestService.ts index 53be55bdffd20f..7269e3562d0eb6 100644 --- a/src/vs/platform/extensionManagement/common/extensionGalleryManifestService.ts +++ b/src/vs/platform/extensionManagement/common/extensionGalleryManifestService.ts @@ -35,6 +35,14 @@ export class ExtensionGalleryManifestService extends Disposable implements IExte super(); } + /** + * The default marketplace is open (no authentication). Authenticated marketplaces are + * handled by the workbench override, which negotiates and returns a resource-scoped token. + */ + async getAccessToken(): Promise { + return undefined; + } + async getExtensionGalleryManifest(): Promise { const extensionsGallery = this.productService.extensionsGallery as ExtensionGalleryConfig | undefined; if (!extensionsGallery?.serviceUrl) { diff --git a/src/vs/platform/extensionManagement/common/extensionGalleryManifestServiceIpc.ts b/src/vs/platform/extensionManagement/common/extensionGalleryManifestServiceIpc.ts index 9417508a9b8421..4740b7049bfdff 100644 --- a/src/vs/platform/extensionManagement/common/extensionGalleryManifestServiceIpc.ts +++ b/src/vs/platform/extensionManagement/common/extensionGalleryManifestServiceIpc.ts @@ -22,6 +22,7 @@ export class ExtensionGalleryManifestIPCService extends ExtensionGalleryManifest override readonly onDidChangeExtensionGalleryManifestStatus = this._onDidChangeExtensionGalleryManifestStatus.event; private _extensionGalleryManifest: IExtensionGalleryManifest | null | undefined; + private _accessToken: string | undefined; private readonly barrier = new Barrier(); override get extensionGalleryManifestStatus(): ExtensionGalleryManifestStatus { @@ -39,7 +40,8 @@ export class ExtensionGalleryManifestIPCService extends ExtensionGalleryManifest // eslint-disable-next-line @typescript-eslint/no-explicit-any call: async (context: any, command: string, args?: any): Promise => { switch (command) { - case 'setExtensionGalleryManifest': return Promise.resolve(this.setExtensionGalleryManifest(args[0])); + case 'setExtensionGalleryManifest': return Promise.resolve(this.setExtensionGalleryManifest(args[0], args[1])); + case 'setAccessToken': return Promise.resolve(this.setAccessToken(args[0])); } throw new Error('Invalid call'); } @@ -51,12 +53,42 @@ export class ExtensionGalleryManifestIPCService extends ExtensionGalleryManifest return this._extensionGalleryManifest ?? null; } - private setExtensionGalleryManifest(manifest: IExtensionGalleryManifest | null): void { + /** + * Returns the resource-scoped bearer token negotiated by the window process for a + * `[Authorize]`-gated marketplace, or `undefined` for an open marketplace. The token is pushed + * over the channel alongside the manifest (see {@link setExtensionGalleryManifest}); this + * process (shared process / remote server) never negotiates it itself, so protected marketplace + * requests it initiates — extension `getManifest`, VSIX download — would otherwise be anonymous + * and rejected with 401. + */ + override async getAccessToken(): Promise { + await this.barrier.wait(); + return this._accessToken; + } + + private setExtensionGalleryManifest(manifest: IExtensionGalleryManifest | null, accessToken?: string): void { this.logService.trace(`[Marketplace] Setting manifest ${manifest ? 'available' : 'unavailable'}`); this._extensionGalleryManifest = manifest; + // The token is coherent with the manifest: the window sets it before an eligible→Available + // transition and clears it on every non-Available transition, so a null manifest always + // arrives with an undefined token and a stale token can never outlive its access. + this._accessToken = accessToken; this._onDidChangeExtensionGalleryManifest.fire(manifest); this._onDidChangeExtensionGalleryManifestStatus.fire(this.extensionGalleryManifestStatus); this.barrier.open(); } + /** + * Updates only the negotiated bearer token in place, without touching the manifest or firing + * its change events. The window process calls this when it re-negotiates the resource-scoped + * token for an already-Available marketplace (e.g. a GitHub session refresh or a proactive + * pre-expiry refresh): the manifest itself is unchanged, so republishing it would be wrong, but + * this process (shared process / remote server) must still receive the fresh token so its + * protected requests — extension `getManifest`, VSIX download — keep succeeding instead of + * failing with 401 once the previous token expires. + */ + private setAccessToken(accessToken: string | undefined): void { + this._accessToken = accessToken; + } + } diff --git a/src/vs/platform/extensionManagement/common/extensionGalleryService.ts b/src/vs/platform/extensionManagement/common/extensionGalleryService.ts index a48e462942083d..01524704c1f32c 100644 --- a/src/vs/platform/extensionManagement/common/extensionGalleryService.ts +++ b/src/vs/platform/extensionManagement/common/extensionGalleryService.ts @@ -638,6 +638,46 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle return this.extensionGalleryManifestService.extensionGalleryManifestStatus === ExtensionGalleryManifestStatus.Available; } + /** + * Returns an `Authorization` header for an authenticated marketplace API request, or an empty + * object when no token applies. The negotiated bearer token (see + * {@link IExtensionGalleryManifestService.getAccessToken}) is attached ONLY when `targetUrl` is + * same-origin HTTPS with the marketplace's `extensionquery` service endpoint — this prevents the + * resource-scoped token from leaking to foreign origins (e.g. asset/statistics endpoints hosted + * on a third-party CDN, or a cleartext URL from a tampered manifest). + * + * @param manifest the current gallery manifest (source of the marketplace origin) + * @param targetUrl the absolute URL the request will be sent to + */ + private async getMarketplaceAuthorizationHeader(manifest: IExtensionGalleryManifest, targetUrl: string): Promise { + const token = await this.extensionGalleryManifestService.getAccessToken(); + if (!token) { + return {}; + } + const marketplaceApi = getExtensionGalleryManifestResourceUri(manifest, ExtensionGalleryResourceType.ExtensionQueryService); + if (!marketplaceApi || !AbstractExtensionGalleryService.isSameSecureOrigin(targetUrl, marketplaceApi)) { + return {}; + } + return { Authorization: `Bearer ${token}` }; + } + + /** + * True when both URLs are `https:` and share the same origin (scheme + authority). Fails closed: + * any parse error or scheme mismatch returns false so a token is never attached to an + * unverifiable or cleartext target. + */ + private static isSameSecureOrigin(targetUrl: string, baseUrl: string): boolean { + try { + const target = URI.parse(targetUrl); + const base = URI.parse(baseUrl); + return target.scheme === 'https' + && base.scheme === 'https' + && target.authority.toLowerCase() === base.authority.toLowerCase(); + } catch { + return false; + } + } + getExtensions(extensionInfos: ReadonlyArray, token: CancellationToken): Promise; getExtensions(extensionInfos: ReadonlyArray, options: IExtensionQueryOptions, token: CancellationToken): Promise; async getExtensions(extensionInfos: ReadonlyArray, arg1: CancellationToken | IExtensionQueryOptions, arg2?: CancellationToken): Promise { @@ -1417,8 +1457,10 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle }); const commonHeaders = await this.commonHeadersPromise; + const authHeader = await this.getMarketplaceAuthorizationHeader(extensionGalleryManifest, extensionsQueryApi); const headers = { ...commonHeaders, + ...authHeader, 'Content-Type': 'application/json', 'Accept': 'application/json;api-version=3.0-preview.1', 'Accept-Encoding': 'gzip', @@ -1571,9 +1613,12 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle const stopWatch = new StopWatch(); try { + const manifest = await this.extensionGalleryManifestService.getExtensionGalleryManifest(); const commonHeaders = await this.commonHeadersPromise; + const authHeader = manifest ? await this.getMarketplaceAuthorizationHeader(manifest, uri.toString(true)) : {}; const headers = { ...commonHeaders, + ...authHeader, 'Content-Type': 'application/json', 'Accept': 'application/json;api-version=7.2-preview', 'Accept-Encoding': 'gzip', @@ -1677,7 +1722,8 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle const Accept = '*/*;api-version=4.0-preview.1'; const commonHeaders = await this.commonHeadersPromise; - const headers = { ...commonHeaders, Accept }; + const authHeader = await this.getMarketplaceAuthorizationHeader(manifest, url); + const headers = { ...commonHeaders, ...authHeader, Accept }; try { await this.requestService.request({ type: 'POST', @@ -1874,7 +1920,12 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle const url = asset.uri; const fallbackUrl = asset.fallbackUri; - const firstOptions = { ...options, url, timeout: this.getRequestTimeout(), callSite }; + // The negotiated token is only attached when the asset is served from the marketplace's own + // (same) secure origin. Assets on a third-party CDN (a different origin) get no token. The + // primary and fallback URLs can differ in origin, so evaluate the guard independently for each. + const manifest = await this.extensionGalleryManifestService.getExtensionGalleryManifest(); + const primaryAuthHeader = manifest ? await this.getMarketplaceAuthorizationHeader(manifest, url) : {}; + const firstOptions = { ...options, headers: { ...headers, ...primaryAuthHeader }, url, timeout: this.getRequestTimeout(), callSite }; let context; try { @@ -1920,7 +1971,8 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle endToEndId: this.getHeaderValue(context?.res.headers, END_END_ID_HEADER_NAME), }); - const fallbackOptions = { ...options, url: fallbackUrl, timeout: this.getRequestTimeout(), callSite: `${callSite}.fallback` }; + const fallbackAuthHeader = manifest ? await this.getMarketplaceAuthorizationHeader(manifest, fallbackUrl) : {}; + const fallbackOptions = { ...options, headers: { ...headers, ...fallbackAuthHeader }, url: fallbackUrl, timeout: this.getRequestTimeout(), callSite: `${callSite}.fallback` }; return this.requestService.request(fallbackOptions, token); } } @@ -1936,9 +1988,11 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle return { malicious: [], deprecated: {}, search: [], autoUpdate: {} }; } + const authHeader = await this.getMarketplaceAuthorizationHeader(manifest, this.extensionsControlUrl); const context = await this.requestService.request({ type: 'GET', url: this.extensionsControlUrl, + headers: authHeader, timeout: this.getRequestTimeout(), callSite: 'extensionGalleryService.getExtensionsControlManifest' }, CancellationToken.None); diff --git a/src/vs/platform/extensionManagement/test/common/extensionGalleryManifest.test.ts b/src/vs/platform/extensionManagement/test/common/extensionGalleryManifest.test.ts new file mode 100644 index 00000000000000..0ece2bb7f54f05 --- /dev/null +++ b/src/vs/platform/extensionManagement/test/common/extensionGalleryManifest.test.ts @@ -0,0 +1,177 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { IRequestContext, IRequestOptions } from '../../../../base/parts/request/common/request.js'; +import { IRequestService } from '../../../request/common/request.js'; +import { discoverMarketplaceProtectedResource, exchangeMarketplaceResourceToken, IMarketplaceProtectedResource } from '../../common/extensionGalleryManifest.js'; + +function jsonContext(statusCode: number, body: unknown): IRequestContext { + return { + res: { statusCode, headers: {} }, + stream: bufferToStream(VSBuffer.fromString(JSON.stringify(body))) + }; +} + +class TestRequestService extends mock() { + readonly calls: { type: string; url: string }[] = []; + constructor(private readonly handler: (options: IRequestOptions) => IRequestContext) { + super(); + } + override async request(options: IRequestOptions, _token: CancellationToken): Promise { + this.calls.push({ type: options.type ?? 'GET', url: options.url ?? '' }); + return this.handler(options); + } + private gets(): number { return this.calls.filter(c => c.type === 'GET').length; } + private posts(): number { return this.calls.filter(c => c.type === 'POST').length; } + get getCount(): number { return this.gets(); } + get postCount(): number { return this.posts(); } + get requestedUrls(): string[] { return this.calls.map(c => c.url); } +} + +suite('exchangeMarketplaceResourceToken', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + const authorizationServer = 'https://as.example.com'; + const protectedResource: IMarketplaceProtectedResource = { + authorizationServer, + resource: 'https://marketplace.example.com', + scopes: [] + }; + + test('does not POST the token exchange when cancelled during authorization-server discovery', async () => { + const cts = store.add(new CancellationTokenSource()); + const metadata = { issuer: authorizationServer, token_endpoint: `${authorizationServer}/token`, response_types_supported: ['code'] }; + const service = new TestRequestService(options => { + if ((options.type ?? 'GET') === 'GET') { + // Simulate a sign-out / account switch happening while the discovery GET is in flight. + cts.cancel(); + return jsonContext(200, metadata); + } + return jsonContext(200, { access_token: 'exchanged' }); + }); + + const result = await exchangeMarketplaceResourceToken(service, protectedResource, 'subject', () => true, cts.token); + + assert.deepStrictEqual( + { result, gets: service.getCount, posts: service.postCount }, + { result: undefined, gets: 1, posts: 0 } + ); + }); + + test('does not POST the token exchange when the caller is superseded during authorization-server discovery', async () => { + // The production caller passes CancellationToken.None and instead drives currentness via + // `isCurrent`; verify that predicate alone suppresses the subject-token POST. + let current = true; + const metadata = { issuer: authorizationServer, token_endpoint: `${authorizationServer}/token`, response_types_supported: ['code'] }; + const service = new TestRequestService(options => { + if ((options.type ?? 'GET') === 'GET') { + // Simulate a sign-out / account switch (validation epoch moves) mid-discovery. + current = false; + return jsonContext(200, metadata); + } + return jsonContext(200, { access_token: 'exchanged' }); + }); + + const result = await exchangeMarketplaceResourceToken(service, protectedResource, 'subject', () => true, CancellationToken.None, () => current); + + assert.deepStrictEqual( + { result, gets: service.getCount, posts: service.postCount }, + { result: undefined, gets: 1, posts: 0 } + ); + }); + + test('does not exchange when the discovered issuer does not match the authorization server', async () => { + const cts = store.add(new CancellationTokenSource()); + const mismatched = { issuer: 'https://evil.example.com', token_endpoint: 'https://evil.example.com/token', response_types_supported: ['code'] }; + const service = new TestRequestService(options => { + if ((options.type ?? 'GET') === 'GET') { + return jsonContext(200, mismatched); + } + return jsonContext(200, { access_token: 'exchanged' }); + }); + + const result = await exchangeMarketplaceResourceToken(service, protectedResource, 'subject', () => true, cts.token); + + assert.deepStrictEqual( + { result, posts: service.postCount }, + { result: undefined, posts: 0 } + ); + }); + + test('returns the exchanged token and its advertised lifetime on success', async () => { + const cts = store.add(new CancellationTokenSource()); + const metadata = { issuer: authorizationServer, token_endpoint: `${authorizationServer}/token`, response_types_supported: ['code'] }; + const service = new TestRequestService(options => { + if ((options.type ?? 'GET') === 'GET') { + return jsonContext(200, metadata); + } + return jsonContext(200, { access_token: 'exchanged', token_type: 'Bearer', expires_in: 1800 }); + }); + + const result = await exchangeMarketplaceResourceToken(service, protectedResource, 'subject', () => true, cts.token); + + assert.deepStrictEqual( + { result, posts: service.postCount }, + { result: { accessToken: 'exchanged', expiresInSeconds: 1800 }, posts: 1 } + ); + }); +}); + +suite('discoverMarketplaceProtectedResource', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const serviceIndexUrl = 'https://marketplace.example.com'; + const wellKnownUrl = 'https://marketplace.example.com/.well-known/oauth-protected-resource'; + const protectedResourceMetadata = { + resource: serviceIndexUrl, + authorization_servers: ['https://as.example.com'], + scopes_supported: ['access_as_user'], + }; + + test('ignores a cross-origin resource_metadata challenge hint and falls back to same-origin well-known discovery', async () => { + // A malicious/compromised index could advertise an attacker-controlled metadata URL in its + // WWW-Authenticate challenge; it must never be fetched (SSRF), and discovery must fall back + // to the well-known endpoint derived from the trusted service index origin. + const challenge = 'bearer resource_metadata="https://attacker.example.com/.well-known/oauth-protected-resource"'; + const service = new TestRequestService(() => jsonContext(200, protectedResourceMetadata)); + + const result = await discoverMarketplaceProtectedResource(service, serviceIndexUrl, challenge, CancellationToken.None); + + assert.deepStrictEqual( + { authorizationServer: result?.authorizationServer, requestedUrls: service.requestedUrls }, + { authorizationServer: 'https://as.example.com', requestedUrls: [wellKnownUrl] } + ); + }); + + test('ignores a cleartext resource_metadata challenge hint and falls back to same-origin well-known discovery', async () => { + const challenge = 'bearer resource_metadata="http://marketplace.example.com/.well-known/oauth-protected-resource"'; + const service = new TestRequestService(() => jsonContext(200, protectedResourceMetadata)); + + const result = await discoverMarketplaceProtectedResource(service, serviceIndexUrl, challenge, CancellationToken.None); + + assert.deepStrictEqual( + { authorizationServer: result?.authorizationServer, requestedUrls: service.requestedUrls }, + { authorizationServer: 'https://as.example.com', requestedUrls: [wellKnownUrl] } + ); + }); + + test('honors a same-origin HTTPS resource_metadata challenge hint', async () => { + const sameOriginMetadataUrl = 'https://marketplace.example.com/custom/oauth-protected-resource'; + const challenge = `bearer resource_metadata="${sameOriginMetadataUrl}"`; + const service = new TestRequestService(() => jsonContext(200, protectedResourceMetadata)); + + const result = await discoverMarketplaceProtectedResource(service, serviceIndexUrl, challenge, CancellationToken.None); + + assert.deepStrictEqual( + { authorizationServer: result?.authorizationServer, requestedUrls: service.requestedUrls }, + { authorizationServer: 'https://as.example.com', requestedUrls: [sameOriginMetadataUrl] } + ); + }); +}); diff --git a/src/vs/platform/extensionResourceLoader/browser/extensionResourceLoaderService.ts b/src/vs/platform/extensionResourceLoader/browser/extensionResourceLoaderService.ts index 921674ef18e4bc..05358567d8a591 100644 --- a/src/vs/platform/extensionResourceLoader/browser/extensionResourceLoaderService.ts +++ b/src/vs/platform/extensionResourceLoader/browser/extensionResourceLoaderService.ts @@ -41,7 +41,7 @@ class ExtensionResourceLoaderService extends AbstractExtensionResourceLoaderServ const requestInit: RequestInit = {}; if (await this.isExtensionGalleryResource(uri)) { - requestInit.headers = await this.getExtensionGalleryRequestHeaders(); + requestInit.headers = await this.getExtensionGalleryRequestHeaders(uri); requestInit.mode = 'cors'; /* set mode to cors so that above headers are always passed */ } diff --git a/src/vs/platform/extensionResourceLoader/common/extensionResourceLoader.ts b/src/vs/platform/extensionResourceLoader/common/extensionResourceLoader.ts index 28a25db97f004f..b364201fe5819e 100644 --- a/src/vs/platform/extensionResourceLoader/common/extensionResourceLoader.ts +++ b/src/vs/platform/extensionResourceLoader/common/extensionResourceLoader.ts @@ -132,7 +132,7 @@ export abstract class AbstractExtensionResourceLoaderService extends Disposable return !!this._extensionGalleryAuthority && this._extensionGalleryAuthority === this._getExtensionGalleryAuthority(uri); } - protected async getExtensionGalleryRequestHeaders(): Promise> { + protected async getExtensionGalleryRequestHeaders(resource?: URI): Promise> { const headers: Record = { 'X-Client-Name': `${this._productService.applicationName}${isWeb ? '-web' : ''}`, 'X-Client-Version': this._productService.version @@ -143,9 +143,44 @@ export abstract class AbstractExtensionResourceLoaderService extends Disposable if (this._productService.commit) { headers['X-Client-Commit'] = this._productService.commit; } + if (resource) { + Object.assign(headers, await this.getMarketplaceAuthorizationHeader(resource)); + } return headers; } + /** + * Returns an `Authorization` header for a `[Authorize]`-gated marketplace resource request, or an + * empty object when no token applies. The negotiated bearer token (see + * {@link IExtensionGalleryManifestService.getAccessToken}) is attached ONLY when `resource` is + * same-origin HTTPS with the marketplace's `extensionquery` service endpoint — this prevents the + * resource-scoped token from leaking to a foreign origin (e.g. a third-party resource CDN whose + * URL was advertised by the gallery manifest, or a cleartext URL). Mirrors the guard applied to + * gallery API/asset requests in `ExtensionGalleryService`. + */ + private async getMarketplaceAuthorizationHeader(resource: URI): Promise> { + const token = await this._extensionGalleryManifestService.getAccessToken(); + if (!token) { + return {}; + } + const manifest = await this._extensionGalleryManifestService.getExtensionGalleryManifest(); + const marketplaceApi = manifest ? getExtensionGalleryManifestResourceUri(manifest, ExtensionGalleryResourceType.ExtensionQueryService) : undefined; + if (!marketplaceApi || !AbstractExtensionResourceLoaderService.isSameSecureOrigin(resource, URI.parse(marketplaceApi))) { + return {}; + } + return { Authorization: `Bearer ${token}` }; + } + + /** + * True when both URIs are `https:` and share the same origin (scheme + authority). Fails closed: + * a scheme mismatch returns false so a token is never attached to a cleartext target. + */ + private static isSameSecureOrigin(target: URI, base: URI): boolean { + return target.scheme === 'https' + && base.scheme === 'https' + && target.authority.toLowerCase() === base.authority.toLowerCase(); + } + private _serviceMachineIdPromise: Promise | undefined; private _getServiceMachineId(): Promise { if (!this._serviceMachineIdPromise) { diff --git a/src/vs/platform/extensionResourceLoader/common/extensionResourceLoaderService.ts b/src/vs/platform/extensionResourceLoader/common/extensionResourceLoaderService.ts index 638db3465469ce..21126f1454c15b 100644 --- a/src/vs/platform/extensionResourceLoader/common/extensionResourceLoaderService.ts +++ b/src/vs/platform/extensionResourceLoader/common/extensionResourceLoaderService.ts @@ -33,7 +33,7 @@ export class ExtensionResourceLoaderService extends AbstractExtensionResourceLoa async readExtensionResource(uri: URI): Promise { if (await this.isExtensionGalleryResource(uri)) { - const headers = await this.getExtensionGalleryRequestHeaders(); + const headers = await this.getExtensionGalleryRequestHeaders(uri); const requestContext = await this._requestService.request({ url: uri.toString(), headers, callSite: 'extensionResourceLoader.readExtensionResource' }, CancellationToken.None); return (await asTextOrError(requestContext)) || ''; } diff --git a/src/vs/platform/request/node/requestService.ts b/src/vs/platform/request/node/requestService.ts index 452097590904fb..eec70f9afcd355 100644 --- a/src/vs/platform/request/node/requestService.ts +++ b/src/vs/platform/request/node/requestService.ts @@ -12,7 +12,7 @@ import { CancellationToken } from '../../../base/common/cancellation.js'; import { CancellationError, getErrorMessage } from '../../../base/common/errors.js'; import * as streams from '../../../base/common/stream.js'; import { isBoolean, isNumber } from '../../../base/common/types.js'; -import { IRequestContext, IRequestOptions } from '../../../base/parts/request/common/request.js'; +import { IHeaders, IRequestContext, IRequestOptions } from '../../../base/parts/request/common/request.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { INativeEnvironmentService } from '../../environment/common/environment.js'; import { getResolvedShellEnv } from '../../shell/node/shellEnv.js'; @@ -172,6 +172,58 @@ async function getNodeRequest(options: IRequestOptions): Promise { const maxRetries = 3; let lastError: Error | undefined; @@ -225,11 +277,30 @@ async function nodeRequestAttempt(options: NodeRequestOptions, token: Cancellati const req = rawRequest(opts, (res: http.IncomingMessage) => { const followRedirects: number = isNumber(options.followRedirects) ? options.followRedirects : 3; - if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && followRedirects > 0 && res.headers['location']) { + const location = res.headers['location']; + // Resolve the redirect target ONCE against the current URL. `resolveRedirectTarget` + // returns `undefined` for an unparseable or non-HTTP(S) `location`, in which case we do + // NOT follow it and fall through to surface the 3xx response as-is (fails closed: no + // credentials are sent to a target we could not verify). + const redirectTarget = (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && followRedirects > 0 && location) + ? resolveRedirectTarget(options.url!, location) + : undefined; + if (redirectTarget) { + // On a cross-origin redirect, never forward origin credentials to the new host: an + // `Authorization` bearer/basic secret (and any explicit `Cookie`) is bound to the + // original origin (this mirrors WHATWG fetch, which strips these on cross-origin + // redirects, and curl, which does not resend credentials to a different host). + // Same-origin redirects keep the headers so authenticated flows that legitimately + // redirect within one origin work. The follow-up request uses the resolved absolute + // URL the origin check was made against — never the raw `location` header. + const crossOrigin = redirectTarget.crossOrigin; nodeRequest({ ...options, - url: res.headers['location'], - followRedirects: followRedirects - 1 + url: redirectTarget.url, + followRedirects: followRedirects - 1, + headers: crossOrigin ? stripOriginCredentialHeaders(options.headers) : options.headers, + user: crossOrigin ? undefined : options.user, + password: crossOrigin ? undefined : options.password }, token).then(resolve, reject); } else { let stream: streams.ReadableStreamEvents = res; diff --git a/src/vs/platform/request/test/node/requestService.test.ts b/src/vs/platform/request/test/node/requestService.test.ts index dfab989d694ab6..218d0e331594a4 100644 --- a/src/vs/platform/request/test/node/requestService.test.ts +++ b/src/vs/platform/request/test/node/requestService.test.ts @@ -10,6 +10,7 @@ import { IRawRequestFunction, lookupKerberosAuthorization, nodeRequest } from '. import { isWindows } from '../../../../base/common/platform.js'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { CancellationError } from '../../../../base/common/errors.js'; +import { IHeaders } from '../../../../base/parts/request/common/request.js'; suite('Request Service', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -320,4 +321,109 @@ suite('Request Service', () => { assert.strictEqual(attemptCount, 1, 'PATCH request should not have been retried'); }); + + // Redirect handling for a mock request that returns a 3xx with a `location`, then a 200. Each + // hop records the request headers AND the resolved target (protocol/host/port/path) derived + // from the options the request stack computed, so tests can assert the follow-up request used + // the URL resolved against the current URL — not the raw `location` header. + interface ICapturedRedirectRequest { + headers: IHeaders | undefined; + protocol: string | undefined; + hostname: string | undefined; + port: number | undefined; + path: string | undefined; + } + const redirectingRawRequest = (captured: ICapturedRedirectRequest[], location: string): IRawRequestFunction => { + return ((opts: any, callback: (res: any) => void) => { + captured.push({ headers: opts.headers, protocol: opts.protocol, hostname: opts.hostname, port: opts.port, path: opts.path }); + // Choose the response by how many requests have been made so far (via the shared + // `captured` array) rather than a closure-local counter: the redirect follow re-invokes + // `getRawRequest` and builds a fresh closure per hop, so a local counter would reset and + // redirect forever. + const res = captured.length === 1 + ? { statusCode: 302, headers: { location }, on: () => { }, pipe: () => ({ on: () => { } }) } + : { statusCode: 200, headers: {}, on: () => { }, pipe: () => ({ on: () => { } }) }; + const mockReq: any = { + on: () => { }, + end: () => { setTimeout(() => callback(res), 0); }, + abort: () => { }, + setTimeout: () => { } + }; + return mockReq; + }) as unknown as IRawRequestFunction; + }; + + test('strips origin credential headers when following a cross-origin redirect', async () => { + const captured: ICapturedRedirectRequest[] = []; + await nodeRequest({ + url: 'https://market.example.com/api/asset', + type: 'GET', + headers: { Authorization: 'auth-value-1', Cookie: 'sid=abc', 'Proxy-Authorization': 'proxy-value', 'X-Other': 'keep' }, + getRawRequest: () => redirectingRawRequest(captured, 'https://cdn.other.example/asset.vsix'), + callSite: 'requestService.test.redirectCrossOrigin' + }, CancellationToken.None); + + assert.strictEqual(captured.length, 2, 'Expected an initial request and one redirect'); + assert.strictEqual(captured[0]?.headers?.['Authorization'], 'auth-value-1', 'Initial request should carry Authorization'); + assert.strictEqual(captured[1]?.protocol, 'https:', 'Redirect must be followed to the resolved target'); + assert.strictEqual(captured[1]?.hostname, 'cdn.other.example', 'Redirect must go to the resolved cross-origin host, not the original host'); + assert.strictEqual(captured[1]?.headers?.['Authorization'], undefined, 'Cross-origin redirect must not forward Authorization'); + assert.strictEqual(captured[1]?.headers?.['Cookie'], undefined, 'Cross-origin redirect must not forward Cookie'); + assert.strictEqual(captured[1]?.headers?.['Proxy-Authorization'], 'proxy-value', 'Proxy-Authorization is bound to the proxy, not the origin, and is preserved'); + assert.strictEqual(captured[1]?.headers?.['X-Other'], 'keep', 'Non-credential headers are preserved across the redirect'); + }); + + test('preserves origin credential headers across a same-origin (relative) redirect', async () => { + const captured: ICapturedRedirectRequest[] = []; + await nodeRequest({ + url: 'https://market.example.com/api/asset', + type: 'GET', + headers: { Authorization: 'auth-value-1', Cookie: 'sid=abc' }, + // Relative location resolves against the current URL → same origin → headers kept. The + // follow-up must request the RESOLVED absolute URL (host + resolved path), not the raw + // relative `location` (which the request stack could not fetch on its own). + getRawRequest: () => redirectingRawRequest(captured, '/api/asset/v2'), + callSite: 'requestService.test.redirectSameOrigin' + }, CancellationToken.None); + + assert.strictEqual(captured.length, 2, 'Expected an initial request and one redirect'); + assert.strictEqual(captured[1]?.hostname, 'market.example.com', 'Relative redirect resolves against the current origin'); + assert.strictEqual(captured[1]?.path, '/api/asset/v2', 'Relative redirect resolves the path against the current URL'); + assert.strictEqual(captured[1]?.headers?.['Authorization'], 'auth-value-1', 'Same-origin redirect keeps the Authorization header'); + assert.strictEqual(captured[1]?.headers?.['Cookie'], 'sid=abc', 'Same-origin redirect keeps the Cookie header'); + }); + + test('strips origin credentials when a protocol-relative redirect changes origin', async () => { + const captured: ICapturedRedirectRequest[] = []; + await nodeRequest({ + // A protocol-relative `//host` location must resolve using the current scheme AND be + // recognized as cross-origin; a naive raw-header follow would mis-handle host/scheme. + url: 'https://market.example.com/api/asset', + type: 'GET', + headers: { Authorization: 'auth-value-1' }, + getRawRequest: () => redirectingRawRequest(captured, '//cdn.other.example/asset.vsix'), + callSite: 'requestService.test.redirectProtocolRelative' + }, CancellationToken.None); + + assert.strictEqual(captured.length, 2, 'Expected an initial request and one redirect'); + assert.strictEqual(captured[1]?.protocol, 'https:', 'Protocol-relative redirect adopts the current scheme'); + assert.strictEqual(captured[1]?.hostname, 'cdn.other.example', 'Protocol-relative redirect resolves to the new host'); + assert.strictEqual(captured[1]?.headers?.['Authorization'], undefined, 'Protocol-relative cross-origin redirect must not forward Authorization'); + }); + + test('does not follow a redirect to a non-HTTP(S) scheme', async () => { + const captured: ICapturedRedirectRequest[] = []; + const context = await nodeRequest({ + url: 'https://market.example.com/api/asset', + type: 'GET', + headers: { Authorization: 'auth-value-1' }, + // A `Location` on a foreign scheme must never be fetched by the HTTP stack; the 3xx is + // surfaced as the final response instead. + getRawRequest: () => redirectingRawRequest(captured, 'file:///etc/passwd'), + callSite: 'requestService.test.redirectNonHttp' + }, CancellationToken.None); + + assert.strictEqual(captured.length, 1, 'A non-HTTP(S) redirect target must not be followed'); + assert.strictEqual(context.res.statusCode, 302, 'The 3xx response is surfaced as the final response'); + }); }); diff --git a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts index 489257f34083ed..26bd1acfca4e9a 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, discoverMarketplaceProtectedResource } 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,9 @@ 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 { IRequestService } from '../../../../platform/request/common/request.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'; @@ -359,6 +362,46 @@ Registry.as(ConfigurationExtensions.Configuration) } }, }, + [ExtensionGalleryAuthProviderConfigKey]: { + type: 'string', + // The enum and its descriptions are intentionally NOT gated on + // `product.enableExtensionGalleryEntraAuth`: the policy metadata below always + // exports both enum descriptions, and the policy-artifact generator requires the + // schema `enum` and `enumDescriptions` to have equal length, so gating the enum + // would make a clean policy export invalid. The Entra product gate is enforced at + // runtime in `getEffectiveAuthProvider()` instead, and this setting is hidden + // (`included: false`), so listing `microsoft` here does not advertise it in the UI. + 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.121', + 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."), @@ -2116,12 +2159,44 @@ 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); + // Establish the identity session used for the eligibility check. + await authenticationService.createSession( + 'microsoft', + PRIVATE_MARKETPLACE_SCOPES); + // If the marketplace service index is itself Entra-gated, additionally acquire a + // resource-scoped token now (interactive consent). The silent negotiation on the + // re-validation cannot prompt, so first-time consent must happen here or the user + // would be returned to the sign-in prompt after authenticating. + const serviceUrl = configurationService.getValue(ExtensionGalleryServiceUrlConfigKey); + if (serviceUrl) { + const requestService = accessor.get(IRequestService); + const protectedResource = await discoverMarketplaceProtectedResource(requestService, serviceUrl, undefined, CancellationToken.None); + if (protectedResource) { + const scopes = protectedResource.scopes.length ? protectedResource.scopes : PRIVATE_MARKETPLACE_SCOPES; + await authenticationService.createSession( + 'microsoft', + [...scopes], + { authorizationServer: URI.parse(protectedResource.authorizationServer) }); + } + } + } 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/extensionGalleryManifestService.ts b/src/vs/workbench/services/extensionManagement/electron-browser/extensionGalleryManifestService.ts index d670862d736e8b..ac2e66c912c395 100644 --- a/src/vs/workbench/services/extensionManagement/electron-browser/extensionGalleryManifestService.ts +++ b/src/vs/workbench/services/extensionManagement/electron-browser/extensionGalleryManifestService.ts @@ -4,30 +4,146 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { disposableTimeout } from '../../../../base/common/async.js'; +import { CancellationError } from '../../../../base/common/errors.js'; +import { IDefaultAccount } from '../../../../base/common/defaultAccount.js'; import { Emitter } from '../../../../base/common/event.js'; +import { IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; import { IHeaders } from '../../../../base/parts/request/common/request.js'; +import { IChannel } from '../../../../base/parts/ipc/common/ipc.js'; import { localize } from '../../../../nls.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; -import { IExtensionGalleryManifestService, IExtensionGalleryManifest, ExtensionGalleryServiceUrlConfigKey, ExtensionGalleryManifestStatus } from '../../../../platform/extensionManagement/common/extensionGalleryManifest.js'; +import { IExtensionGalleryManifestService, IExtensionGalleryManifest, ExtensionGalleryServiceUrlConfigKey, ExtensionGalleryAuthProviderConfigKey, ExtensionGalleryManifestStatus, ExtensionGalleryResourceType, getExtensionGalleryManifestResourceUri, PRIVATE_MARKETPLACE_SCOPES, CONTEXT_MARKETPLACE_AUTH_PROVIDER, discoverMarketplaceProtectedResource, exchangeMarketplaceResourceToken, IMarketplaceProtectedResource } from '../../../../platform/extensionManagement/common/extensionGalleryManifest.js'; import { ExtensionGalleryManifestService } from '../../../../platform/extensionManagement/common/extensionGalleryManifestService.js'; import { resolveMarketplaceHeaders } from '../../../../platform/externalServices/common/marketplace.js'; import { IFileService } from '../../../../platform/files/common/files.js'; 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 { IStorageService, StorageScope, StorageTarget } 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 { AuthenticationSession, IAuthenticationService } from '../../authentication/common/authentication.js'; + +interface ICachedAccess { + authProvider: 'github' | 'microsoft'; + 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; +} + +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.'; +}; + +/** + * 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". + */ +class MarketplaceAuthRequiredError extends Error { + constructor( + readonly statusCode: number, + /** + * The raw `WWW-Authenticate` challenge header returned alongside a 401, when present. + * RFC 9728 negotiation reads the `resource_metadata` (and optionally `scope`) parameters + * from this challenge to discover the marketplace's Protected Resource Metadata and the + * resource-scoped token to acquire. Absent on 403 (the identity is refused, not + * un-authenticated) and on servers that omit the header. + */ + readonly wwwAuthenticate?: string, + ) { + super(`Extension gallery request requires authentication (status ${statusCode}).`); + } +} + +/** + * Reads a response header case-insensitively. HTTP header names are case-insensitive + * (RFC 7230 §3.2) and different transports normalize casing differently (Node lowercases, + * others preserve the wire casing), so a fixed-case lookup on `WWW-Authenticate` is unsafe. + */ +function getResponseHeader(headers: IHeaders | undefined, name: string): string | undefined { + if (!headers) { + return undefined; + } + const lowerName = name.toLowerCase(); + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === lowerName) { + const value = headers[key]; + // A header may be delivered as a single string or, if repeated, an array of values. + // RFC 7235 permits multiple challenges in a single `WWW-Authenticate` value, so join + // repeated headers with commas to reconstruct the full challenge list for the parser. + return Array.isArray(value) ? value.join(', ') : value; + } + } + return undefined; +} export class WorkbenchExtensionGalleryManifestService extends ExtensionGalleryManifestService implements IExtensionGalleryManifestService { + private static readonly MICROSOFT_AUTH_SCOPES = PRIVATE_MARKETPLACE_SCOPES; + private static readonly CACHED_ACCESS_KEY = 'marketplace.cachedAccess'; + + // Proactive-refresh schedule for the negotiated GitHub resource token. The exchanged token + // carries an `expires_in`; we re-mint at a fraction of its lifetime so a fresh token is in + // place before the current one expires (the marketplace never sees an expired bearer). When + // the server omits `expires_in` we fall back to a conservative default lifetime rather than + // leaving the token to expire silently. + private static readonly GITHUB_TOKEN_REFRESH_FRACTION = 0.75; + private static readonly GITHUB_TOKEN_DEFAULT_LIFETIME_SECONDS = 3600; + // Small hot-loop floor for the proactive re-mint delay. It is NOT a "typical" refresh interval: + // a genuinely short advertised lifetime (e.g. `expires_in: 30`) must refresh at a fraction of + // THAT lifetime (before it expires), not be clamped up past the expiry. The floor only stops a + // pathological near-zero lifetime from busy-spinning the exchange. + private static readonly GITHUB_TOKEN_MIN_REFRESH_MS = 5_000; + private static readonly GITHUB_TOKEN_MAX_REFRESH_MS = 6 * 60 * 60 * 1000; + // Capped exponential backoff used when a proactive re-mint fails: rather than giving up (which + // would wedge the marketplace until a window reload), we keep retrying so the token self-heals + // once connectivity/identity is restored. + private static readonly GITHUB_TOKEN_RETRY_MIN_MS = 30_000; + private static readonly GITHUB_TOKEN_RETRY_MAX_MS = 5 * 60 * 1000; + private readonly commonHeadersPromise: Promise; private extensionGalleryManifest: IExtensionGalleryManifest | null = null; @@ -39,12 +155,51 @@ export class WorkbenchExtensionGalleryManifestService extends ExtensionGalleryMa private _onDidChangeExtensionGalleryManifestStatus = this._register(new Emitter()); override readonly onDidChangeExtensionGalleryManifestStatus = this._onDidChangeExtensionGalleryManifestStatus.event; + // Monotonic counter used to discard results from superseded validations. Each call to a + // `validate` closure captures the current epoch (via ++this.validationEpoch); any async + // continuation that later finds the epoch has advanced MUST NOT mutate status/cache/ + // manifest, because a newer validation (e.g. triggered by sign-out, an account switch, or + // a config change) has taken over. 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. + private validationEpoch = 0; + + // The resource-scoped bearer token negotiated (RFC 9728) when the marketplace service index + // is `[Authorize]`-gated. It is set only when access was actually negotiated via a + // `WWW-Authenticate` challenge AND the user is eligible/Available, and is exposed via + // `getAccessToken()` so the gallery service can authenticate protected marketplace requests + // (extensionquery, asset download). It is cleared whenever access is revoked — every + // non-Available transition routes through `update(null, …)`, which resets it — so a stale + // token can never survive a sign-out, account switch, or config change. + private negotiatedAccessToken: string | undefined; + + // IPC channels to the shared process and (optionally) the remote server. The manifest and the + // negotiated resource token are pushed over these so those processes — which never negotiate a + // token themselves — can authenticate the protected marketplace requests they initiate + // (extension getManifest, VSIX download). + private readonly channels: IChannel[] = []; + + // Identity (`provider:session:accountName`) of the account the most recent GitHub validation + // resolved, or `undefined` when none. Deliberately excludes volatile fields (token info, + // entitlements) so `onDidChangeDefaultAccount` — which also fires on routine same-account data + // refreshes — can distinguish a genuine account switch/sign-out (tear down + revalidate) from a + // same-account refresh (revalidate in place, no flash, cache preserved). + private currentGitHubAccountIdentity: string | undefined; + + // Timer that proactively re-mints the negotiated GitHub resource token before it expires (see + // the GITHUB_TOKEN_* constants). Armed on every successful negotiation and re-armed after each + // refresh; cleared at the single teardown choke point in `update(null)` so it never outlives an + // Available session. `gitHubTokenRefreshBackoffMs` tracks the current retry backoff (0 while + // healthy) so a run of failed re-mints escalates its delay up to the retry cap. + private readonly gitHubTokenRefreshTimer = this._register(new MutableDisposable()); + private gitHubTokenRefreshBackoffMs = 0; + constructor( @IProductService productService: IProductService, @IEnvironmentService environmentService: IEnvironmentService, @IFileService fileService: IFileService, @ITelemetryService private readonly telemetryService: ITelemetryService, - @IStorageService storageService: IStorageService, + @IStorageService private readonly storageService: IStorageService, @IRemoteAgentService remoteAgentService: IRemoteAgentService, @ISharedProcessService sharedProcessService: ISharedProcessService, @IConfigurationService private readonly configurationService: IConfigurationService, @@ -53,6 +208,8 @@ export class WorkbenchExtensionGalleryManifestService extends ExtensionGalleryMa @ILogService private readonly logService: ILogService, @IDialogService private readonly dialogService: IDialogService, @IHostService private readonly hostService: IHostService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IContextKeyService contextKeyService: IContextKeyService, ) { super(productService); this.commonHeadersPromise = resolveMarketplaceHeaders( @@ -64,25 +221,69 @@ export class WorkbenchExtensionGalleryManifestService extends ExtensionGalleryMa storageService, telemetryService); + // 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.getEffectiveAuthProvider()); + const channels = [sharedProcessService.getChannel('extensionGalleryManifest')]; const remoteConnection = remoteAgentService.getConnection(); if (remoteConnection) { channels.push(remoteConnection.getChannel('extensionGalleryManifest')); } + this.channels.push(...channels); const updateChannels = (manifest: IExtensionGalleryManifest | null) => { this.logService.trace(`[Marketplace] Updating channels with manifest ${manifest ? 'available' : 'unavailable'}`); - channels.forEach(channel => channel.call('setExtensionGalleryManifest', [manifest])); + // Push the negotiated resource token alongside the manifest so the shared process and + // remote server (which never negotiate it themselves) can authenticate the protected + // marketplace requests they initiate — extension getManifest and VSIX download. The + // token is coherent with the manifest here: it is set before the eligible→Available + // transition and cleared on every non-Available transition, so a null manifest always + // carries an undefined token. + this.channels.forEach(channel => channel.call('setExtensionGalleryManifest', [manifest, this.negotiatedAccessToken])); }; - 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); }); } + /** + * Pushes a freshly re-negotiated resource token to the shared process and remote server WITHOUT + * republishing the (unchanged) manifest. Used by the in-place background refreshes — GitHub + * session rotation and proactive pre-expiry refresh — where the manifest itself does not change + * but those processes must still receive the new token so their protected marketplace requests + * keep succeeding once the previous token expires. The window's own getAccessToken() reads + * this.negotiatedAccessToken directly, so it needs no channel round-trip. + */ + private updateNegotiatedAccessToken(token: string | undefined): void { + this.negotiatedAccessToken = token; + this.channels.forEach(channel => channel.call('setAccessToken', [token])); + } + + // Lazily resolved to break a service dependency cycle: eager construction of this + // service must not pull in IAuthenticationService (-> IExtensionService -> gallery), + // so it is resolved on first asynchronous use instead of via constructor injection. + private _authenticationService: IAuthenticationService | undefined; + private get authenticationService(): IAuthenticationService { + return this._authenticationService ??= this.instantiationService.invokeFunction(accessor => accessor.get(IAuthenticationService)); + } + private extensionGalleryManifestPromise: Promise | undefined; override async getExtensionGalleryManifest(): Promise { if (!this.extensionGalleryManifestPromise) { @@ -92,42 +293,280 @@ export class WorkbenchExtensionGalleryManifestService extends ExtensionGalleryMa return this.extensionGalleryManifest; } + /** + * Returns the resource-scoped bearer token negotiated for a `[Authorize]`-gated marketplace, + * or `undefined` for an open marketplace. Ensures the manifest resolution has completed first + * so the token reflects the current access state. + */ + override async getAccessToken(): Promise { + await this.getExtensionGalleryManifest(); + return this.negotiatedAccessToken; + } + private async doGetExtensionGalleryManifest(): Promise { const defaultServiceUrl = this.productService.extensionsGallery?.serviceUrl; if (!defaultServiceUrl) { 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 bumps the epoch to supersede the in-flight 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.validationEpoch++; + this.clearCachedAccess(); + // The restart prompt is dismissable, so tear down the live marketplace now instead + // of leaving the previous config's manifest, negotiated token, and proactive-refresh + // timer running against a serviceUrl/provider the admin just abandoned. Routing + // through update(null) drops the token and cancels the timer at the single teardown + // choke point; the marketplace goes Unavailable until the (pending) restart re-runs + // validation for the new config. + this.update(null); + 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.initializePrivateMarketplace(configuredServiceUrl); } else { const defaultExtensionGalleryManifest = await super.getExtensionGalleryManifest(); this.update(defaultExtensionGalleryManifest); } + } - this._register(this.configurationService.onDidChangeConfiguration(e => { - if (!e.affectsConfiguration(ExtensionGalleryServiceUrlConfigKey)) { + private async initializePrivateMarketplace(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 validationEpoch 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 (epoch-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.validationEpoch); + } + + // 3. Validate (foreground if no cache, background if cache was applied) + if (cached) { + validateAccess(); + } else { + await validateAccess(); + } + } + + /** + * 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. + */ + private getEffectiveAuthProvider(): string { + const configuredAuthProvider = this.configurationService.getValue(ExtensionGalleryAuthProviderConfigKey); + if (configuredAuthProvider === 'microsoft' && !this.productService.enableExtensionGalleryEntraAuth) { + return 'github'; + } + return configuredAuthProvider || 'github'; + } + + /** + * Resolves the effective auth provider, registers event subscriptions for + * re-validation, and returns a function that validates current access. + * + * When the effective provider is 'microsoft', eligibility discovery and + * validation happen inside {@link handleMicrosoftAccess}. There is deliberately + * NO fallback to GitHub: once an administrator has explicitly configured + * 'microsoft', a server that does not advertise an EligibilityService is treated + * as misconfigured rather than silently downgraded. + */ + private async resolveAccessStrategy(configuredServiceUrl: string): Promise<() => Promise> { + const configuredAuthProvider = this.getEffectiveAuthProvider(); + + if (configuredAuthProvider === 'microsoft') { + const validate = () => this.handleMicrosoftAccess(configuredServiceUrl, ++this.validationEpoch); + this._register(this.authenticationService.onDidChangeSessions(e => { + if (e.providerId === 'microsoft') { + // Re-validate on any Microsoft session change, but deliberately do NOT clear the + // cache or revoke the manifest here. `onDidChangeSessions` also fires on routine + // token refreshes for the SAME account, and unconditionally clearing would force + // a redundant eligibility round-trip (and a brief manifest flash) on every + // refresh. handleMicrosoftAccess resolves the current account and, only when it + // actually differs from the cached verdict (or no verdict is cached), revokes the + // prior authorization and re-checks eligibility. + validate(); + } + })); + return validate; + } + + // Default: GitHub + const validate = () => this.handleGitHubAccess(configuredServiceUrl, ++this.validationEpoch); + this._register(this.defaultAccountService.onDidChangeDefaultAccount(account => { + const newIdentity = account ? WorkbenchExtensionGalleryManifestService.getAccountIdentity(account) : undefined; + if (newIdentity !== this.currentGitHubAccountIdentity) { + // The account identity actually changed (switch) or disappeared (sign-out): the + // previously authorized marketplace no longer applies. Drop its cache and revoke the + // manifest before revalidating (see the Microsoft path above) so a transient failure + // resolving the new account cannot preserve the old account's `Available` status. + this.clearCachedAccess(); + this.update(null); + } + // Same identity → only the account DATA changed (token/entitlement refresh). Do NOT flash + // the marketplace to Unavailable or drop the cache; just revalidate in place. If the + // refreshed entitlements changed eligibility, handleGitHubAccess moves to the right state. + validate(); + })); + this._register(this.authenticationService.onDidChangeSessions(e => { + if (e.providerId !== 'github' && e.providerId !== 'github-enterprise') { return; } - this.requestRestart(); + // Auth-enabled GitHub scheme: the marketplace resource token is minted (RFC 8693) from + // the GitHub session token, so a session change (refresh, re-consent, sign-out/in) can + // leave the previously negotiated token stale. Both the default 'github' provider and the + // 'github-enterprise' provider back the default account, so listen to either. + if (this.currentStatus === ExtensionGalleryManifestStatus.Available && this.negotiatedAccessToken) { + // The marketplace is already live on a gated index. Re-mint the resource token IN + // PLACE so a rotated GitHub session token doesn't leave us stuck on a stale token + // once it expires. This deliberately bypasses handleGitHubAccess (whose negotiation + // is gated on `currentStatus !== Available`) and never re-publishes the manifest (no + // view flash) nor tears down access on a failed refresh. Genuine account/entitlement + // changes and sign-out arrive via onDidChangeDefaultAccount above, which clears the + // cache and revalidates. + this.refreshNegotiatedGitHubToken(configuredServiceUrl, ++this.validationEpoch); + } else { + // Not yet Available (or an open, tokenless index): re-validate. As on the Microsoft + // path, do NOT clear the cache or revoke the manifest here — onDidChangeSessions also + // fires on routine token refreshes, and unconditionally clearing would force a + // redundant negotiation (and a brief manifest flash). handleGitHubAccess re-checks + // access and only re-negotiates. (Auth-disabled deployments simply re-confirm the + // open index — cheap and harmless.) + validate(); + } })); + return validate; } - private async handleDefaultAccountAccess(configuredServiceUrl: string): Promise { - const account = await this.defaultAccountService.getDefaultAccount(); + // --- GitHub access (existing DefaultAccountService-based check) --- - 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); + private async handleGitHubAccess(configuredServiceUrl: string, epoch: number): Promise { + try { + const account = await this.defaultAccountService.getDefaultAccount(); + if (this.validationEpoch !== epoch) { + // A newer validation superseded this one while we awaited — discard. + return; + } + // Record the resolved account identity so onDidChangeDefaultAccount can tell a genuine + // switch/sign-out from a same-account data refresh. Set before branching so it is + // captured even when we no-op below (already Available). + this.currentGitHubAccountIdentity = account ? WorkbenchExtensionGalleryManifestService.getAccountIdentity(account) : undefined; + const eligibility = account ? this.checkAccess(account) : 'ineligible'; + if (!account) { + // Auth service responded: no account → invalidate cache + this.clearCachedAccess(); + this.update(null, ExtensionGalleryManifestStatus.RequiresSignIn); + } else if (eligibility === 'ineligible') { + // Auth service responded: account exists but ineligible → cache the result + this.cacheAccess({ authProvider: 'github', accountId: account.accountName, eligible: false, serviceUrl: configuredServiceUrl }); + this.update(null, ExtensionGalleryManifestStatus.AccessDenied); + } else if (eligibility === 'unknown') { + // The account is signed in but we could NOT determine SKU eligibility — the + // entitlements endpoint was unreachable, returned an indeterminate response, or + // returned a 401/404 (token expired/revoked, or the account lacks the scope to query + // it). None of these is a definitive "ineligible" verdict. Never turn a + // transient/indeterminate condition into a cached denial, and never tear down a + // marketplace that is already Available; only surface a retryable "unreachable" + // message otherwise. + if (this.currentStatus !== ExtensionGalleryManifestStatus.Available) { + this.update(null, ExtensionGalleryManifestStatus.Unreachable); + } + } else if (this.currentStatus !== ExtensionGalleryManifestStatus.Available) { + // The account is eligible (client-side `checkAccess`). Fetch the service index. If + // the marketplace's index is `[Authorize]`-gated (RFC 9728 `401`), negotiate a + // resource-scoped token by exchanging the user's existing GitHub session token at + // the marketplace's advertised authorization server (RFC 8693), then retry the + // index presenting that token. + // + // Unlike the Entra scheme, GitHub deployments render NO server-side eligibility + // verdict — there is no `/eligibility` POST and the `EligibilityService` resource is + // absent from a GitHub-scheme service index. Access is decided entirely by the + // `checkAccess` gate above; the server-side token merely authenticates the API. + const subjectToken = await this.resolveGitHubSubjectToken(account); + if (this.validationEpoch !== epoch) { + return; + } + let manifest: IExtensionGalleryManifest; + let indexToken: string | undefined; + let indexWasNegotiated = false; + let indexExpiresInSeconds: number | undefined; + try { + const negotiated = await this.fetchServiceIndexNegotiated( + configuredServiceUrl, + // Probe the index anonymously first: a default (auth-disabled) GitHub + // deployment serves an open index and needs no token. Only a gated index + // (`401`) triggers the token exchange below. + undefined, + protectedResource => this.acquireGitHubResourceToken(configuredServiceUrl, protectedResource, subjectToken, () => this.validationEpoch === epoch), + () => this.validationEpoch === epoch, + ); + manifest = negotiated.manifest; + indexToken = negotiated.token; + indexWasNegotiated = negotiated.negotiated; + indexExpiresInSeconds = negotiated.expiresInSeconds; + } catch (error) { + if (this.validationEpoch !== epoch) { + 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.cacheAccess({ authProvider: 'github', accountId: account.accountName, eligible: false, serviceUrl: configuredServiceUrl }); + this.update(null, ExtensionGalleryManifestStatus.AccessDenied); + } else { + // 401: no token could satisfy the gated index (no GitHub session, the + // token exchange could not complete, or the minted token was rejected). + // This is NOT a durable "ineligible" verdict — re-authentication may fix + // it — so do not cache a negative result; ask the user to (re-)sign in. + this.clearCachedAccess(); + this.update(null, ExtensionGalleryManifestStatus.RequiresSignIn); + } + return; + } + // Eligible, but the marketplace manifest could not be fetched — the marketplace + // is currently unreachable. Preserve cache; surface a message. (We are already + // inside the `currentStatus !== Available` branch, so no extra guard is needed.) + this.logService.error('[Marketplace] Failed to fetch gallery manifest (GitHub path)', error); + this.update(null, ExtensionGalleryManifestStatus.Unreachable); + return; + } + if (this.validationEpoch !== epoch) { + return; + } + this.cacheAccess({ authProvider: 'github', accountId: account.accountName, eligible: true, serviceUrl: configuredServiceUrl }); + // The index was gated and we negotiated a resource-scoped token for it: expose that + // token (via getAccessToken) for protected marketplace API requests. On the open- + // index path (indexWasNegotiated === false) the marketplace needs no bearer, so + // leave the token cleared. Any later non-Available transition routes through + // update(null, …) and clears it. + if (indexWasNegotiated && indexToken) { + this.negotiatedAccessToken = indexToken; + // Proactively re-mint the resource token before it expires so the marketplace is + // never wedged by a silently expired bearer. A failed re-mint self-heals via a + // capped-backoff retry (no window reload needed). + this.scheduleGitHubTokenRefresh(configuredServiceUrl, indexExpiresInSeconds); + } this.update(manifest); this.telemetryService.publicLog2< {}, @@ -135,15 +574,854 @@ export class WorkbenchExtensionGalleryManifestService extends ExtensionGalleryMa 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); + } + } catch (error) { + if (this.validationEpoch !== epoch) { + 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.currentStatus !== ExtensionGalleryManifestStatus.Available) { + this.update(null, ExtensionGalleryManifestStatus.Unreachable); + } + } + } + + /** + * Re-mints the resource-scoped marketplace token for the GitHub scheme WITHOUT disturbing an + * already-`Available` marketplace. Invoked when a GitHub session change fires while access is + * live: the resource token is derived (RFC 8693) from the GitHub session token, so a rotated + * subject token can leave the previously negotiated token stale. Unlike {@link handleGitHubAccess} + * this never re-publishes the manifest (avoiding a view flash) and never tears down access on a + * failed refresh — the existing token may still be valid, and a transient refresh failure must + * not break a working marketplace. Fully epoch-guarded so a concurrent sign-out/account-switch + * (which routes through the account listener) wins. Sign-out and entitlement changes are handled + * by onDidChangeDefaultAccount, so a now-ineligible/absent account simply skips the refresh here. + */ + private async refreshNegotiatedGitHubToken(configuredServiceUrl: string, epoch: number): Promise { + try { + const account = await this.defaultAccountService.getDefaultAccount(); + if (this.validationEpoch !== epoch) { + // Superseded by a concurrent validation (session/account/config change); it now owns + // the refresh schedule, so leave the timer to that owner. + return; + } + if (!account || this.checkAccess(account) !== 'eligible') { + // Still our epoch, but the account is transiently absent or its entitlements are + // indeterminate (`checkAccess` -> 'unknown'). Sign-out and durable ineligibility are + // driven by onDidChangeDefaultAccount; treat this as a soft blip and re-arm on the + // backoff (while access is still live) rather than dropping the fired one-shot timer + // and letting the token silently expire. + this.rearmGitHubTokenRefreshAfterFailureIfLive(configuredServiceUrl, epoch); + return; + } + const subjectToken = await this.resolveGitHubSubjectToken(account); + if (this.validationEpoch !== epoch) { + return; + } + const negotiated = await this.fetchServiceIndexNegotiated( + configuredServiceUrl, + undefined, + protectedResource => this.acquireGitHubResourceToken(configuredServiceUrl, protectedResource, subjectToken, () => this.validationEpoch === epoch), + () => this.validationEpoch === epoch, + ); + if (this.validationEpoch !== epoch) { + return; + } + // Only adopt a freshly negotiated (gated-index) token; on an open index there is nothing + // to refresh. A failed refresh throws and is handled below, leaving the current token + // intact. + if (negotiated.negotiated && negotiated.token) { + this.updateNegotiatedAccessToken(negotiated.token); + // Success: reset the failure backoff and schedule the next proactive re-mint from + // the freshly advertised lifetime. + this.scheduleGitHubTokenRefresh(configuredServiceUrl, negotiated.expiresInSeconds); + } else { + // The index no longer negotiates a token (e.g. an admin reopened a previously gated + // index): drop the now-stale resource token — clearing it also propagates the removal + // to the shared process — and stop the proactive schedule, since an open index needs + // no bearer. + this.updateNegotiatedAccessToken(undefined); + } + } catch (error) { + // A failed background token refresh must NOT break a working marketplace — keep the + // current token/status and re-arm on the capped backoff so the re-mint self-heals once + // connectivity/identity is restored (guarded to the still-live case). + this.logService.trace('[Marketplace] Background refresh of the negotiated GitHub marketplace token failed; will retry', error); + this.rearmGitHubTokenRefreshAfterFailureIfLive(configuredServiceUrl, epoch); + } + } + + /** + * Schedules the next PROACTIVE re-mint of the negotiated GitHub resource token, timed at a + * fraction ({@link GITHUB_TOKEN_REFRESH_FRACTION}) of the token's advertised lifetime so a fresh + * token is in place before the current one expires. Resets the failure backoff — this is the + * healthy path. When the server omits `expires_in`, a conservative default lifetime is used so + * the token is still refreshed rather than left to expire silently. Replacing the + * {@link MutableDisposable} value disposes any previously armed timer. + */ + private scheduleGitHubTokenRefresh(configuredServiceUrl: string, expiresInSeconds: number | undefined): void { + this.gitHubTokenRefreshBackoffMs = 0; + this.armGitHubTokenRefresh(configuredServiceUrl, WorkbenchExtensionGalleryManifestService.computeRefreshDelay(expiresInSeconds)); + } + + /** + * Computes the proactive re-mint delay from the token's advertised lifetime, distinguishing an + * ABSENT lifetime from a PRESENT-but-invalid one: + * - `expires_in` omitted (`undefined`) → the server didn't say; assume a conservative default + * lifetime and refresh at {@link GITHUB_TOKEN_REFRESH_FRACTION} of it. + * - `expires_in` present and a finite positive number → refresh at that fraction of it (a short + * lifetime therefore refreshes SOON, before it expires — it is not clamped up past expiry). + * - `expires_in` present but non-positive or non-finite → the advertised token is already + * expired/bogus; refresh almost immediately (the small hot-loop floor) rather than trusting a + * full default hour. + * The result is clamped to [{@link GITHUB_TOKEN_MIN_REFRESH_MS}, {@link GITHUB_TOKEN_MAX_REFRESH_MS}]. + */ + private static computeRefreshDelay(expiresInSeconds: number | undefined): number { + let lifetimeMs: number; + if (expiresInSeconds === undefined) { + lifetimeMs = WorkbenchExtensionGalleryManifestService.GITHUB_TOKEN_DEFAULT_LIFETIME_SECONDS * 1000; + } else if (Number.isFinite(expiresInSeconds) && expiresInSeconds > 0) { + lifetimeMs = expiresInSeconds * 1000; + } else { + lifetimeMs = 0; + } + return Math.min( + WorkbenchExtensionGalleryManifestService.GITHUB_TOKEN_MAX_REFRESH_MS, + Math.max( + WorkbenchExtensionGalleryManifestService.GITHUB_TOKEN_MIN_REFRESH_MS, + Math.floor(lifetimeMs * WorkbenchExtensionGalleryManifestService.GITHUB_TOKEN_REFRESH_FRACTION), + ), + ); + } + + /** + * Re-arms the proactive refresh after a failed re-mint using a capped exponential backoff + * (30s → doubling → 5m), so a transient outage or a briefly-unavailable identity keeps being + * retried instead of permanently wedging the marketplace. + */ + private rearmGitHubTokenRefreshAfterFailure(configuredServiceUrl: string): void { + const next = this.gitHubTokenRefreshBackoffMs === 0 + ? WorkbenchExtensionGalleryManifestService.GITHUB_TOKEN_RETRY_MIN_MS + : Math.min(this.gitHubTokenRefreshBackoffMs * 2, WorkbenchExtensionGalleryManifestService.GITHUB_TOKEN_RETRY_MAX_MS); + this.gitHubTokenRefreshBackoffMs = next; + this.armGitHubTokenRefresh(configuredServiceUrl, next); + } + + /** + * Re-arms the proactive refresh backoff after a SOFT failure (a transient identity/entitlement + * blip or a failed re-mint) but ONLY while this validation still owns a live, token-backed + * marketplace: the epoch is unchanged (a concurrent sign-out/account switch bumps it and disposes + * the timer via `update(null)`, and must win), the marketplace is still `Available`, and a + * negotiated token is still present (an open index / torn-down state needs no refresh). This is + * what keeps a fired one-shot timer from being silently dropped while access is still live. + */ + private rearmGitHubTokenRefreshAfterFailureIfLive(configuredServiceUrl: string, epoch: number): void { + if (this.validationEpoch === epoch + && this.currentStatus === ExtensionGalleryManifestStatus.Available + && this.negotiatedAccessToken) { + this.rearmGitHubTokenRefreshAfterFailure(configuredServiceUrl); + } + } + + /** + * Arms the single proactive-refresh timer. The timer fires with a freshly bumped validation + * epoch (matching the reactive session-change refresh) so a concurrent stale validation is + * superseded; a sign-out/account switch that clears access first will have disposed this timer + * via `update(null)`. + */ + private armGitHubTokenRefresh(configuredServiceUrl: string, delayMs: number): void { + this.gitHubTokenRefreshTimer.value = this.scheduleGitHubTokenRefreshTimeout( + () => this.refreshNegotiatedGitHubToken(configuredServiceUrl, ++this.validationEpoch), + delayMs, + ); + } + + /** + * Seam over `disposableTimeout` so tests can drive the proactive-refresh schedule + * deterministically (capturing the delay and firing the callback on demand) without waiting on + * real wall-clock timers. Production uses the real timer. + */ + protected scheduleGitHubTokenRefreshTimeout(handler: () => void, delayMs: number): IDisposable { + return disposableTimeout(handler, delayMs); + } + + private checkAccess(account: IDefaultAccount): 'eligible' | 'ineligible' | 'unknown' { + // Enterprise accounts are eligible for the private marketplace independent of any SKU. + if (account.enterprise) { + return 'eligible'; + } + // entitlementsData tri-state (see IDefaultAccount): a resolved object carries the account's + // SKUs. Both `null` and `undefined` are indeterminate, NOT a durable "ineligible" verdict: + // `null` means the entitlements endpoint returned 401 (token expired/revoked) or 404 (the + // account lacks the scope to query it — the service pretends the endpoint is absent), and + // `undefined` means the endpoint was unreachable or returned an otherwise indeterminate + // response. None of these can decide eligibility, so all resolve to `unknown` — the caller + // must NOT turn a transient/indeterminate condition into a durable, cached denial. Only a + // resolved object whose SKU is absent from `accessSKUs` is a definitive `ineligible`. + if (account.entitlementsData === undefined || account.entitlementsData === null) { + return 'unknown'; + } + if (account.entitlementsData?.access_type_sku + && this.productService.extensionsGallery?.accessSKUs?.includes( + account.entitlementsData.access_type_sku)) { + return 'eligible'; + } + return 'ineligible'; + } + + // --- Microsoft access (new Entra ID / VSS eligibility check) --- + + private async handleMicrosoftAccess(configuredServiceUrl: string, epoch: number): 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', + WorkbenchExtensionGalleryManifestService.MICROSOFT_AUTH_SCOPES); + } catch (error) { + if (this.validationEpoch !== epoch) { + 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.currentStatus !== ExtensionGalleryManifestStatus.Available) { + this.update(null, ExtensionGalleryManifestStatus.Unreachable); + } + return; + } + if (this.validationEpoch !== epoch) { + // 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.clearCachedAccess(); + this.update(null, ExtensionGalleryManifestStatus.RequiresSignIn); + return; + } + + // Whether we already hold a durable eligibility verdict for THIS account against THIS + // marketplace. `getCachedAccess` returns the verdict only when it was written under the + // currently-effective provider and for the configured serviceUrl (dropping it otherwise), + // so a same-account match here means "same provider + same marketplace + same account". + // When present we skip the eligibility POST below — it is only needed on first sign-in or + // when the account changes. We still (re)negotiate the service index token so a gated + // marketplace's resource-scoped token is refreshed for this session. + const cached = this.getCachedAccess(configuredServiceUrl); + const matchedVerdict = cached && cached.accountId === session.account.id ? cached : undefined; + if (!matchedVerdict && this.currentStatus === ExtensionGalleryManifestStatus.Available) { + // No verdict for the current account, yet the marketplace is still showing Available + // for a previous account (e.g. an account switch). Revoke that authorization NOW, + // before the async negotiation/eligibility round-trip, so a transient failure below + // cannot preserve the prior account's manifest — the "unreachable" catch paths only + // overwrite the status when it is not already Available. + this.update(null); + } + + // 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 (!WorkbenchExtensionGalleryManifestService.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.clearCachedAccess(); + this.update(null, ExtensionGalleryManifestStatus.Misconfigured); + return; + } + let manifest: IExtensionGalleryManifest; + // The token that successfully reads the service index. Starts as the initially-acquired + // (OpenID) session token; if the index is auth-gated and returns an RFC 9728 challenge, + // `fetchServiceIndexNegotiated` upgrades this to a resource-scoped token, which is then + // reused for the protected eligibility POST below. + let indexToken: string = session.accessToken; + // Whether the index was actually behind an RFC 9728 challenge (i.e. `indexToken` is a + // resource-scoped token that protected marketplace API requests must present), as opposed + // to an open index read with the plain sign-in token. + let indexWasNegotiated = false; + try { + const negotiated = await this.fetchServiceIndexNegotiated( + configuredServiceUrl, + session.accessToken, + async protectedResource => { + const resourceSession = await this.acquireResourceToken( + 'microsoft', + protectedResource, + WorkbenchExtensionGalleryManifestService.MICROSOFT_AUTH_SCOPES, + ); + // The Microsoft/MSAL provider owns refresh of its own sessions (via getSessions + + // onDidChangeSessions), so no proactive expiry scheduling is needed here — leave + // `expiresInSeconds` undefined. + return resourceSession ? { token: resourceSession.accessToken } : undefined; + }, + () => this.validationEpoch === epoch, + ); + manifest = negotiated.manifest; + // `negotiated.token` is only undefined when the initial token was undefined; on the + // Microsoft path we always start with the signed-in session token, so fall back to it. + indexToken = negotiated.token ?? session.accessToken; + indexWasNegotiated = negotiated.negotiated; + } catch (error) { + if (this.validationEpoch !== epoch) { + 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.cacheAccess({ authProvider: 'microsoft', accountId: session.account.id, eligible: false, serviceUrl: configuredServiceUrl }); + this.update(null, ExtensionGalleryManifestStatus.AccessDenied); + } else { + // 401: the token was missing/expired/invalid (e.g. wrong audience). This + // is NOT a durable "ineligible" verdict — re-authentication may fix it — + // so do not cache a negative result; ask the user to (re-)sign in. A fresh + // sign-in fires onDidChangeSessions and re-runs this check with a new token. + this.clearCachedAccess(); + this.update(null, ExtensionGalleryManifestStatus.RequiresSignIn); + } + 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.currentStatus !== ExtensionGalleryManifestStatus.Available) { + this.update(null, ExtensionGalleryManifestStatus.Unreachable); + } + return; + } + + if (this.validationEpoch !== epoch) { + return; + } + + // The service index read above succeeded with the current session's token, so the token + // is valid. If we already hold a durable verdict for this account+marketplace, apply it + // without re-POSTing eligibility — that check is only needed on first sign-in or when the + // account changes (both of which arrive with no matching cache). The negotiation above + // refreshed the resource-scoped token for this session; expose it when the index was gated + // and the user is eligible. Verdict invalidation for a rejected/expired token is handled by + // the negotiation catch above (401 → RequiresSignIn, 403 → AccessDenied) before we get here. + if (matchedVerdict) { + if (matchedVerdict.eligible) { + if (indexWasNegotiated) { + this.updateNegotiatedAccessToken(indexToken); + } + this.applyEligibilityResult({ eligible: true }, manifest); + } else { this.update(null, ExtensionGalleryManifestStatus.AccessDenied); } + 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.clearCachedAccess(); + this.update(null, ExtensionGalleryManifestStatus.Misconfigured); + return; + } + + if (!WorkbenchExtensionGalleryManifestService.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.clearCachedAccess(); + this.update(null, ExtensionGalleryManifestStatus.Misconfigured); + return; + } + + // Check eligibility via server + try { + const result = await this.checkMicrosoftEligibility(eligibilityUrl, indexToken); + if (this.validationEpoch !== epoch) { + 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.cacheAccess({ + authProvider: 'microsoft', + accountId: session.account.id, + eligible: result.eligible, + serviceUrl: configuredServiceUrl, + }); + this.telemetryService.publicLog2( + 'marketplace:auth:checked', + { + authProvider: 'microsoft', + eligible: result.eligible, + } + ); + // The index was gated and we negotiated a resource-scoped token for it: expose that + // token (via getAccessToken) for protected marketplace API requests when the user is + // eligible. On the open-index path (indexWasNegotiated === false) the marketplace + // needs no bearer, so leave the token cleared. Any later non-Available transition + // routes through update(null, …) and clears it. + if (indexWasNegotiated && result.eligible) { + this.negotiatedAccessToken = indexToken; + } + this.applyEligibilityResult(result, manifest); + } catch (error) { + if (this.validationEpoch !== epoch) { + // 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.cacheAccess({ authProvider: 'microsoft', accountId: session.account.id, eligible: false, serviceUrl: configuredServiceUrl }); + this.update(null, ExtensionGalleryManifestStatus.AccessDenied); + } else { + // 401: the token was missing/expired/invalid (e.g. wrong audience) at the + // eligibility endpoint. This is NOT a durable "ineligible" verdict — + // re-authentication may fix it — so do not cache a negative result; ask + // the user to (re-)sign in. A fresh sign-in re-runs this check. + this.clearCachedAccess(); + this.update(null, ExtensionGalleryManifestStatus.RequiresSignIn); + } + 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.currentStatus !== ExtensionGalleryManifestStatus.Available) { + this.update(null, ExtensionGalleryManifestStatus.Unreachable); + } + } + } + + /** + * Fetches the service index, negotiating a resource-scoped bearer token if the marketplace's + * index is protected (RFC 9728). It first presents `initialToken`; if the marketplace responds + * `401`, the marketplace's Protected Resource Metadata is discovered via its well-known endpoint + * (RFC 9728) and a token bound to the advertised authorization server + resource scopes is + * acquired silently (RFC 8707), then the index fetch is retried once with that token. + * + * Discovery deliberately does NOT depend on the `WWW-Authenticate` challenge header: that header + * is not CORS-safelisted, so the renderer's cross-origin index fetch usually cannot read it. The + * challenge is passed only as a best-effort hint for the explicit metadata URL. + * + * Returns the manifest together with the token that successfully read the index, so callers can + * reuse it for subsequent protected requests (e.g. the Microsoft eligibility POST). Any auth + * failure that negotiation cannot resolve (no metadata, no session obtainable, or a `401`/`403` + * on the retry) propagates as a {@link MarketplaceAuthRequiredError} for the caller to classify. + */ + private async fetchServiceIndexNegotiated( + configuredServiceUrl: string, + initialToken: string | undefined, + acquireToken: (protectedResource: IMarketplaceProtectedResource) => Promise<{ token: string; expiresInSeconds?: number } | undefined>, + isCurrent: () => boolean = () => true, + ): Promise<{ manifest: IExtensionGalleryManifest; token: string | undefined; negotiated: boolean; expiresInSeconds?: number }> { + try { + const manifest = await this.getExtensionGalleryManifestFromServiceUrl(configuredServiceUrl, initialToken); + return { manifest, token: initialToken, negotiated: false }; + } catch (error) { + // Only a 401 (index is auth-gated) is negotiable. A 403 (identity refused) or any other + // error is not — let it propagate so the caller applies its existing classification. + if (!(error instanceof MarketplaceAuthRequiredError) || error.statusCode !== 401) { + throw error; + } + const protectedResource = await discoverMarketplaceProtectedResource( + this.requestService, + configuredServiceUrl, + error.wwwAuthenticate, + CancellationToken.None, + ); + if (!protectedResource) { + // The index is gated but exposes no Protected Resource Metadata we can act on — + // re-throw the original 401 so the caller prompts for sign-in. + throw error; + } + if (!isCurrent()) { + // A sign-out/account-switch superseded this negotiation while we discovered the + // protected resource. Do NOT proceed to the token exchange: it would transmit a + // now-stale subject token (e.g. the just-revoked GitHub session) to the marketplace's + // authorization server. Bail; the caller's epoch guard discards this cancellation. + throw new CancellationError(); + } + const acquired = await acquireToken(protectedResource); + if (!acquired) { + // No resource-scoped token could be obtained silently (e.g. consent not yet + // granted, or a token exchange that could not complete) — re-throw the original + // 401 so the caller prompts for sign-in rather than mislabeling it. + throw error; + } + if (!isCurrent()) { + // Superseded during token acquisition — do not retry the index with a token minted + // for an identity that may no longer be current. + throw new CancellationError(); + } + const manifest = await this.getExtensionGalleryManifestFromServiceUrl(configuredServiceUrl, acquired.token); + return { manifest, token: acquired.token, negotiated: true, expiresInSeconds: acquired.expiresInSeconds }; } } + /** + * Silently acquires a resource-scoped bearer token bound to the marketplace's advertised + * authorization server (RFC 8707). `getSessions` never prompts, so this returns `undefined` + * when no consented session exists yet, leaving interactive acquisition to the sign-in action. + * The advertised authorization server is validated against the provider's configured globs by + * the authentication service; a mismatch throws, which we treat as "no session". + */ + private async acquireResourceToken( + providerId: string, + protectedResource: { authorizationServer: string; scopes: readonly string[] }, + fallbackScopes: readonly string[], + ): Promise { + const scopes = protectedResource.scopes.length ? protectedResource.scopes : fallbackScopes; + try { + const sessions = await this.authenticationService.getSessions( + providerId, + [...scopes], + { authorizationServer: URI.parse(protectedResource.authorizationServer) }, + ); + return sessions[0]; + } catch (error) { + this.logService.error('[Marketplace] Error acquiring resource-scoped marketplace token', error); + return undefined; + } + } + + /** + * Resolves the raw GitHub session access token backing the current default account, used as the + * `subject_token` for the marketplace token exchange. `getSessions` reads existing sessions + * silently (never prompts). Returns `undefined` when the session can't be found or the lookup + * fails, in which case the gated-index negotiation falls back to a sign-in prompt. + */ + private async resolveGitHubSubjectToken(account: IDefaultAccount): Promise { + try { + const sessions = await this.authenticationService.getSessions(account.authenticationProvider.id); + return sessions.find(session => session.id === account.sessionId)?.accessToken; + } catch (error) { + this.logService.error('[Marketplace] Error resolving the GitHub session token for the marketplace token exchange', error); + return undefined; + } + } + + /** + * Acquires a resource-scoped marketplace token for the GitHub scheme by exchanging the user's + * existing GitHub session token at the marketplace's advertised authorization server (RFC 8693). + * VS Code's GitHub provider has no resource-token support, so — unlike the Microsoft/MSAL path + * ({@link acquireResourceToken}) — the token is minted by the marketplace's embedded + * authorization server. The raw GitHub token is only ever sent to a target that passes the + * same-origin HTTPS `isSafeTokenTarget` guard (relative to the admin-configured service index). + * Returns `undefined` when no GitHub token is available or the exchange fails. + */ + private async acquireGitHubResourceToken( + configuredServiceUrl: string, + protectedResource: IMarketplaceProtectedResource, + subjectToken: string | undefined, + isCurrent: () => boolean, + ): Promise<{ token: string; expiresInSeconds?: number } | undefined> { + if (!subjectToken) { + return undefined; + } + const exchanged = await exchangeMarketplaceResourceToken( + this.requestService, + protectedResource, + subjectToken, + targetUrl => WorkbenchExtensionGalleryManifestService.isSafeTokenTarget(targetUrl, configuredServiceUrl), + CancellationToken.None, + isCurrent, + ); + return exchanged ? { token: exchanged.accessToken, expiresInSeconds: exchanged.expiresInSeconds } : undefined; + } + + /** + * 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.currentStatus !== ExtensionGalleryManifestStatus.Available) { + this.update(manifest); + } + } else { + this.update(null, ExtensionGalleryManifestStatus.AccessDenied); + } + } + + private async checkMicrosoftEligibility( + url: string, token: string + ): 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.None); + + 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 }; + } + + /** + * A stable identity string for a default account: `provider:session:accountName`. Deliberately + * excludes volatile data (token info, entitlements) so a routine data refresh that fires + * `onDidChangeDefaultAccount` for the SAME account is not mistaken for an account switch. + */ + private static getAccountIdentity(account: IDefaultAccount): string { + return `${account.authenticationProvider.id}:${account.sessionId}:${account.accountName}`; + } + + /** + * Guards bearer-token transport. A Microsoft 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. + */ + private static 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(); + } + + // --- Access caching (provider-agnostic) --- + + private getCachedAccess(configuredServiceUrl: string): ICachedAccess | null { + const raw = this.storageService.get( + WorkbenchExtensionGalleryManifestService.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.clearCachedAccess(); + 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.clearCachedAccess(); + 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.clearCachedAccess(); + 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.clearCachedAccess(); + 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'; + } + + /** + * Resolves the account currently signed in for the given provider, WITHOUT prompting. + * Returns `'account'` (with the account id, plus the session token for microsoft) when an + * account is present, `'none'` when the provider responded but there is no account (durable), + * and `'error'` when the lookup failed (transient — callers must not invalidate the cache). + */ + private async resolveCurrentAccount(authProvider: string): Promise<{ kind: 'account'; accountId: string; token?: string } | { kind: 'none' } | { kind: 'error' }> { + if (authProvider === 'microsoft') { + try { + const sessions = await this.authenticationService.getSessions( + 'microsoft', + WorkbenchExtensionGalleryManifestService.MICROSOFT_AUTH_SCOPES); + const session = sessions[0]; + return session + ? { kind: 'account', accountId: session.account.id, token: session.accessToken } + : { kind: 'none' }; + } catch { + return { kind: 'error' }; + } + } + try { + const account = await this.defaultAccountService.getDefaultAccount(); + return account ? { kind: 'account', accountId: account.accountName } : { kind: 'none' }; + } catch { + return { kind: 'error' }; + } + } + + private async applyCachedAccess(cached: ICachedAccess, configuredServiceUrl: string, epoch: number): 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.resolveCurrentAccount(cached.authProvider); + if (this.validationEpoch !== epoch) { + // 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.currentStatus !== ExtensionGalleryManifestStatus.Available) { + this.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.clearCachedAccess(); + return; + } + + if (!cached.eligible) { + this.update(null, ExtensionGalleryManifestStatus.AccessDenied); + return; + } + + // Eligible for the current account — fetch the manifest to render Available. On the + // Microsoft path present the session token so a gated index is readable, applying the + // same-origin token-transport guard first. + let accessToken: string | undefined; + if (cached.authProvider === 'microsoft') { + if (!WorkbenchExtensionGalleryManifestService.isSafeTokenTarget(configuredServiceUrl, configuredServiceUrl)) { + this.update(null, ExtensionGalleryManifestStatus.Misconfigured); + return; + } + accessToken = current.token; + } + try { + const manifest = await this.getExtensionGalleryManifestFromServiceUrl(configuredServiceUrl, accessToken); + if (this.validationEpoch !== epoch) { + // A newer validation superseded this cache application while we fetched the + // manifest — do not apply a manifest for a possibly-stale account. + return; + } + this.update(manifest); + } catch (error) { + if (this.validationEpoch !== epoch) { + 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.currentStatus !== ExtensionGalleryManifestStatus.Available) { + this.update(null, ExtensionGalleryManifestStatus.Unreachable); + } + } + } + + private cacheAccess(data: ICachedAccess): void { + this.storageService.store( + WorkbenchExtensionGalleryManifestService.CACHED_ACCESS_KEY, + JSON.stringify(data), + StorageScope.APPLICATION, + StorageTarget.MACHINE); + this.logService.debug('[Marketplace] Cached access result:', data.authProvider, data.eligible); + } + + private clearCachedAccess(): void { + this.storageService.remove( + WorkbenchExtensionGalleryManifestService.CACHED_ACCESS_KEY, + StorageScope.APPLICATION); + this.logService.debug('[Marketplace] Cleared cached access'); + } + + // --- Status management --- + private update(manifest: IExtensionGalleryManifest | null, status?: ExtensionGalleryManifestStatus): void { this.logService.debug(`[Marketplace] Updating manifest ${manifest ? 'available' : 'unavailable'}`); + if (!manifest) { + // Any transition to a non-Available state (sign-out, account switch, config change, + // access denied, unreachable, …) routes through here with a null manifest. Drop the + // negotiated resource token so it can never outlive the access that produced it; the + // eligible→Available path re-sets it after a successful negotiation. Cancel the pending + // proactive re-mint (and reset its backoff) for the same reason — a fresh negotiation + // re-arms it. This is the single teardown choke point for the refresh timer. + this.negotiatedAccessToken = undefined; + this.gitHubTokenRefreshTimer.clear(); + this.gitHubTokenRefreshBackoffMs = 0; + } if (this.extensionGalleryManifest !== manifest) { this.extensionGalleryManifest = manifest; this._onDidChangeExtensionGalleryManifest.fire(manifest); @@ -158,16 +1436,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), @@ -178,31 +1446,88 @@ export class WorkbenchExtensionGalleryManifestService extends ExtensionGalleryMa } } - private async getExtensionGalleryManifestFromServiceUrl(url: string): Promise { + private async getExtensionGalleryManifestFromServiceUrl(url: string, accessToken?: string): Promise { const commonHeaders = await this.commonHeadersPromise; - const headers = { + const headers: IHeaders = { ...commonHeaders, 'Content-Type': 'application/json', 'Accept-Encoding': 'gzip', }; + // 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' }, CancellationToken.None); + 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. Capture + // the `WWW-Authenticate` challenge (present on a 401) so RFC 9728 negotiation + // can discover the Protected Resource Metadata and resource-scoped token. + throw new MarketplaceAuthRequiredError( + context.res.statusCode, + getResponseHeader(context.res.headers, 'WWW-Authenticate'), + ); + } + + 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) { - this.logService.error('[Marketplace] Error retrieving extension gallery manifest', error); + // A 401/403 here is an expected step of RFC 9728 negotiation, not a failure: a gated + // service index answers the initial (anonymous or plain-token) read with a + // `WWW-Authenticate` challenge, which the caller uses to discover the Protected + // Resource Metadata and negotiate a resource-scoped token before retrying. Logging it + // at `error` surfaces a spurious "Error retrieving extension gallery manifest" for a + // normal handshake, so downgrade auth-required outcomes to `trace` and reserve + // `error` for genuinely unexpected failures. + if (error instanceof MarketplaceAuthRequiredError) { + this.logService.trace('[Marketplace] Service index requires authentication (status', error.statusCode, ') — RFC 9728 negotiation will handle it'); + } else { + this.logService.error('[Marketplace] Error retrieving extension gallery manifest', error); + } throw error; } } diff --git a/src/vs/workbench/services/extensionManagement/test/electron-browser/extensionGalleryManifestService.test.ts b/src/vs/workbench/services/extensionManagement/test/electron-browser/extensionGalleryManifestService.test.ts new file mode 100644 index 00000000000000..9fee3f54ff47b6 --- /dev/null +++ b/src/vs/workbench/services/extensionManagement/test/electron-browser/extensionGalleryManifestService.test.ts @@ -0,0 +1,1961 @@ +/*--------------------------------------------------------------------------------------------- + * 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 } from '../../../../../base/common/defaultAccount.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { IHeaders, IRequestContext, IRequestOptions } from '../../../../../base/parts/request/common/request.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { IDisposable, toDisposable } from '../../../../../base/common/lifecycle.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, headers: IHeaders = {}): IRequestContext { + return { + res: { headers, statusCode }, + stream: bufferToStream(VSBuffer.fromString(JSON.stringify(body))), + }; +} + +/** + * Test subclass that intercepts the proactive GitHub-token refresh timer so tests can assert the + * scheduled delay and fire the refresh deterministically, without waiting on real wall-clock + * timers. Each captured entry is disposed (its `disposed` flag flips) when the timer is replaced + * (re-scheduled) or when the service is torn down. + */ +class TestableGalleryManifestService extends WorkbenchExtensionGalleryManifestService { + readonly scheduledRefreshes: Array<{ delayMs: number; fire: () => void; disposed: boolean }> = []; + + get pendingRefreshes(): Array<{ delayMs: number; fire: () => void; disposed: boolean }> { + return this.scheduledRefreshes.filter(entry => !entry.disposed); + } + + protected override scheduleGitHubTokenRefreshTimeout(handler: () => void, delayMs: number): IDisposable { + const entry = { delayMs, fire: handler, disposed: false }; + this.scheduledRefreshes.push(entry); + return toDisposable(() => { entry.disposed = true; }); + } +} + +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'], + }; +} + +// A GitHub session whose id matches the default account's `sessionId` so the service can resolve +// its access token as the RFC 8693 `subject_token` for the marketplace token exchange. +function createGitHubSession(accessToken = 'gh-subject-token'): AuthenticationSession { + return { + id: 'session-1', + accessToken, + account: { id: 'gh-account-1', label: 'testuser' }, + scopes: [], + }; +} + +// RFC 9728 Protected Resource Metadata for the GitHub auth-enabled scheme. The advertised +// authorization server is the marketplace's own embedded AS (same origin as the service URL) so it +// passes the same-origin HTTPS token-target guard; the exchange scope is `access_as_user`. +function createGitHubProtectedResourceMetadata() { + return { + resource: 'https://marketplace.example.com', + authorization_servers: ['https://marketplace.example.com'], + scopes_supported: ['access_as_user'], + }; +} + +// RFC 8414 Authorization Server Metadata for the marketplace's embedded AS, advertising the +// token-exchange endpoint used by the GitHub scheme. +function createAuthorizationServerMetadata() { + return { + issuer: 'https://marketplace.example.com', + token_endpoint: 'https://marketplace.example.com/connect/token', + }; +} + +// True when a request targets the marketplace AS's RFC 8414 metadata endpoint. +function isAuthorizationServerMetadataRequest(url: string | undefined): boolean { + return !!url?.includes('/.well-known/oauth-authorization-server'); +} + +// True when a request targets the marketplace AS's token-exchange endpoint. +function isTokenExchangeRequest(url: string | undefined): boolean { + return !!url?.includes('/connect/token'); +} + +// 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' }] + : [], + }; +} + +// RFC 9728 Protected Resource Metadata stub served from the marketplace's well-known endpoint. +// `resource` must equal the configured service URL's origin for discovery validation to pass. +function createProtectedResourceMetadata() { + return { + resource: 'https://marketplace.example.com', + authorization_servers: ['https://login.microsoftonline.com/test-tenant/v2.0'], + scopes_supported: ['api://test-client-id/access_as_user'], + }; +} + +// True when a request targets the marketplace's well-known Protected Resource Metadata endpoint. +function isProtectedResourceMetadataRequest(url: string | undefined): boolean { + return !!url?.includes('/.well-known/oauth-protected-resource'); +} + +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 microsoftResourceSessions: AuthenticationSession[] | undefined; + let githubSessions: AuthenticationSession[]; + let githubEnterpriseSessions: AuthenticationSession[]; + let configurationService: TestConfigurationService; + let storageData: Map; + let entraAuthEnabled: boolean; + let channelCalls: Array<{ command: string; args: unknown }>; + + setup(() => { + defaultAccount = null; + microsoftSessions = []; + microsoftResourceSessions = undefined; + githubSessions = []; + githubEnterpriseSessions = []; + requestHandler = () => mockResponse(200, createGalleryManifest()); + storageData = new Map(); + entraAuthEnabled = true; + channelCalls = []; + + 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', + accessSKUs: ['copilot_business'], + }, + nameLong: 'VS Code Test', + get enableExtensionGalleryEntraAuth() { return entraAuthEnabled; }, + } as any); + + 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: (command: string, args?: unknown) => { channelCalls.push({ command, args }); return 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, _scopes?: readonly string[], options?: { authorizationServer?: URI }) { + if (providerId === 'microsoft') { + // A resource-scoped request (RFC 8707: getSessions carrying an authorizationServer + // discovered from well-known PRM) yields the resource-scoped session when the test + // provides one; otherwise fall back to the base (OpenID) sessions. + if (options?.authorizationServer) { + return microsoftResourceSessions ?? microsoftSessions; + } + return microsoftSessions; + } + if (providerId === 'github') { + return githubSessions; + } + if (providerId === 'github-enterprise') { + return githubEnterpriseSessions; + } + 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'); + // Resolved entitlements that do NOT carry a marketplace SKU — a definitive, cacheable + // "ineligible" verdict (distinct from `undefined`, which means indeterminate; see below). + defaultAccount = createDefaultAccount({ enterprise: false, entitlementsData: { access_type_sku: 'copilot_free' } as any }); + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.AccessDenied); + }); + + test('GitHub provider — indeterminate entitlements (endpoint unreachable) → Unreachable, not cached', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'github'); + // `entitlementsData: undefined` means the entitlements endpoint was unreachable or returned + // an indeterminate response — we genuinely cannot decide eligibility. This must NOT be turned + // into a durable, cached denial (a transient outage would otherwise lock the user out until + // the cache is cleared); it surfaces a retryable Unreachable message instead. + defaultAccount = createDefaultAccount({ enterprise: false, entitlementsData: undefined }); + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Unreachable); + assert.ok(!storageData.has('marketplace.cachedAccess')); + }); + + test('GitHub provider — entitlements 401/404 (null) → Unreachable, not a cached denial', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'github'); + // `entitlementsData: null` means the entitlements endpoint returned 401 (token + // expired/revoked) or 404 (the account lacks the scope to query it). Neither is a definitive + // "ineligible" verdict — re-authentication or a scope grant can recover — so it must resolve + // to `unknown` and surface a retryable Unreachable message rather than a durable, cached + // AccessDenied that would lock the user out until the cache is cleared. + defaultAccount = createDefaultAccount({ enterprise: false, entitlementsData: null }); + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Unreachable); + assert.ok(!storageData.has('marketplace.cachedAccess')); + }); + + test('GitHub provider — account with matching SKU → Available', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'github'); + defaultAccount = createDefaultAccount({ + enterprise: false, + entitlementsData: { access_type_sku: 'copilot_business' } as any, + }); + + 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); + // No RFC 9728 challenge was issued (the index accepted the initial token directly), so no + // resource-scoped token was negotiated — getAccessToken stays undefined. A resource token is + // only exposed when the index challenges and negotiation upgrades the token (covered below). + assert.strictEqual(await service.getAccessToken(), undefined); + }); + + 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) → RequiresSignIn (not cached)', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + microsoftSessions = [createMicrosoftSession()]; + // A token is presented but the server returns 401 — the token was missing/expired/ + // invalid (e.g. wrong audience). This is NOT a durable "ineligible" verdict, so we + // ask the user to re-authenticate and must NOT cache a negative result. + requestHandler = () => mockResponse(401, { message: 'auth required' }); + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.RequiresSignIn); + assert.ok(!storageData.has('marketplace.cachedAccess')); + }); + + test('Microsoft provider — auth-gated index → well-known PRM discovery negotiates resource-scoped token → Available', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + // The initially-acquired (OpenID) token is not resource-scoped; the resource-scoped token + // is obtained by discovering the marketplace's well-known Protected Resource Metadata and + // acquiring a token bound to the advertised authorization server. + microsoftSessions = [createMicrosoftSession('ms-openid-token')]; + microsoftResourceSessions = [createMicrosoftSession('ms-resource-token')]; + const challenge = 'Bearer realm="marketplace", resource_metadata="https://marketplace.example.com/.well-known/oauth-protected-resource"'; + let eligibilityAuthHeader: string | undefined; + requestHandler = (options) => { + if (isProtectedResourceMetadataRequest(options.url)) { + return mockResponse(200, createProtectedResourceMetadata()); + } + const auth = options.headers?.['Authorization'] as string | undefined; + if (options.url?.includes('eligibility')) { + eligibilityAuthHeader = auth; + return mockResponse(200, { eligible: true }); + } + // The service index only accepts the resource-scoped token; the OpenID token is + // challenged with a 401 carrying the RFC 9728 resource_metadata pointer. + if (auth === 'Bearer ms-resource-token') { + return mockResponse(200, createGalleryManifest(true)); + } + return mockResponse(401, { message: 'auth required' }, { 'WWW-Authenticate': challenge }); + }; + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Available); + // The negotiated resource-scoped token — not the initial OpenID token — is reused for the + // protected eligibility POST. + assert.strictEqual(eligibilityAuthHeader, 'Bearer ms-resource-token'); + // The negotiated resource-scoped token is exposed for authenticating protected marketplace + // API requests (extensionquery, asset download). + assert.strictEqual(await service.getAccessToken(), 'ms-resource-token'); + }); + + test('Microsoft provider — auth-gated index 401 with NO WWW-Authenticate header (CORS-stripped) → well-known PRM discovery still negotiates → Available', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + // Regression: the renderer's cross-origin index fetch usually cannot read the + // WWW-Authenticate challenge header (it is not CORS-safelisted). Negotiation must not + // depend on it — discovery is driven by the well-known Protected Resource Metadata body, + // which IS CORS-readable. Here the 401 carries no challenge header at all. + microsoftSessions = [createMicrosoftSession('ms-openid-token')]; + microsoftResourceSessions = [createMicrosoftSession('ms-resource-token')]; + requestHandler = (options) => { + if (isProtectedResourceMetadataRequest(options.url)) { + return mockResponse(200, createProtectedResourceMetadata()); + } + const auth = options.headers?.['Authorization'] as string | undefined; + if (options.url?.includes('eligibility')) { + return mockResponse(200, { eligible: true }); + } + // The index only accepts the resource-scoped token; the OpenID token is rejected with + // a bare 401 (no WWW-Authenticate header). + if (auth?.includes('ms-resource-token')) { + return mockResponse(200, createGalleryManifest(true)); + } + return mockResponse(401, { message: 'auth required' }); + }; + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Available); + assert.strictEqual(await service.getAccessToken(), 'ms-resource-token'); + }); + + test('Microsoft provider — negotiated token still forbidden on retry (403) → AccessDenied (cached ineligible)', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + microsoftSessions = [createMicrosoftSession('ms-openid-token')]; + microsoftResourceSessions = [createMicrosoftSession('ms-resource-token')]; + const challenge = 'Bearer resource_metadata="https://marketplace.example.com/.well-known/oauth-protected-resource"'; + // The challenge is negotiated and a resource-scoped token acquired, but the identity is + // still forbidden from the index (403) — a durable denial that is cached. + requestHandler = (options) => { + if (isProtectedResourceMetadataRequest(options.url)) { + return mockResponse(200, createProtectedResourceMetadata()); + } + const auth = options.headers?.['Authorization'] as string | undefined; + if (auth === 'Bearer ms-resource-token') { + return mockResponse(403, { message: 'forbidden' }); + } + return mockResponse(401, { message: 'auth required' }, { 'WWW-Authenticate': challenge }); + }; + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.AccessDenied); + assert.strictEqual(JSON.parse(storageData.get('marketplace.cachedAccess')!).eligible, false); + }); + + // --- GitHub auth-enabled scheme (RFC 8693 token exchange) --- + + test('GitHub provider — auth-disabled (open index) → Available, no bearer, no token exchange', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'github'); + defaultAccount = createDefaultAccount({ enterprise: true }); + githubSessions = [createGitHubSession()]; + let exchangeRequests = 0; + let indexPresentedToken = false; + requestHandler = (options) => { + if (isTokenExchangeRequest(options.url)) { + exchangeRequests++; + return mockResponse(200, { access_token: 'gh-resource-token', token_type: 'Bearer' }); + } + if (options.headers?.['Authorization']) { + indexPresentedToken = true; + } + // Default deployment: the index is open and served anonymously (200). + return mockResponse(200, createGalleryManifest()); + }; + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Available); + // An open index needs no bearer: no token is negotiated, exposed, or exchanged. + assert.strictEqual(await service.getAccessToken(), undefined); + assert.strictEqual(exchangeRequests, 0); + assert.strictEqual(indexPresentedToken, false); + }); + + test('GitHub provider — auth-gated index → PRM discovery + RFC 8693 token exchange → Available with negotiated token', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'github'); + defaultAccount = createDefaultAccount({ enterprise: true }); + githubSessions = [createGitHubSession('gh-subject-token')]; + const challenge = 'Bearer realm="marketplace", resource_metadata="https://marketplace.example.com/.well-known/oauth-protected-resource"'; + let exchangeBody: string | undefined; + let eligibilityPosts = 0; + requestHandler = (options) => { + if (isProtectedResourceMetadataRequest(options.url)) { + return mockResponse(200, createGitHubProtectedResourceMetadata()); + } + if (isAuthorizationServerMetadataRequest(options.url)) { + return mockResponse(200, createAuthorizationServerMetadata()); + } + if (isTokenExchangeRequest(options.url)) { + // The GitHub session token is exchanged at the marketplace AS for a resource-bound + // token; the raw GitHub token is only ever sent here, never to the resource server. + exchangeBody = options.data as string | undefined; + return mockResponse(200, { access_token: 'gh-resource-token', token_type: 'Bearer' }); + } + if (options.url?.includes('eligibility')) { + eligibilityPosts++; + return mockResponse(200, { eligible: true }); + } + const auth = options.headers?.['Authorization'] as string | undefined; + // The gated index only accepts the negotiated resource token; the anonymous probe is + // challenged with a 401 carrying the RFC 9728 resource_metadata pointer. + if (auth === 'Bearer gh-resource-token') { + return mockResponse(200, createGalleryManifest()); + } + return mockResponse(401, { message: 'auth required' }, { 'WWW-Authenticate': challenge }); + }; + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Available); + // The negotiated resource token is exposed for protected marketplace API requests. + assert.strictEqual(await service.getAccessToken(), 'gh-resource-token'); + // The exchange carried the RFC 8693 grant with the GitHub session token as the subject. + const params = new URLSearchParams(exchangeBody ?? ''); + assert.strictEqual(params.get('grant_type'), 'urn:ietf:params:oauth:grant-type:token-exchange'); + assert.strictEqual(params.get('subject_token'), 'gh-subject-token'); + assert.strictEqual(params.get('subject_token_type'), 'urn:ietf:params:oauth:token-type:access_token'); + assert.strictEqual(params.get('resource'), 'https://marketplace.example.com'); + assert.strictEqual(params.get('scope'), 'access_as_user'); + // The GitHub scheme renders no server-side eligibility verdict: no /eligibility POST. + assert.strictEqual(eligibilityPosts, 0); + }); + + test('GitHub provider — auth-gated index but no GitHub session (no subject token) → RequiresSignIn', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'github'); + defaultAccount = createDefaultAccount({ enterprise: true }); + // No session backing the default account → no subject token can be resolved for the + // exchange, so negotiation cannot complete and the user is asked to (re-)sign in. + githubSessions = []; + const challenge = 'Bearer realm="marketplace", resource_metadata="https://marketplace.example.com/.well-known/oauth-protected-resource"'; + let exchangeRequests = 0; + requestHandler = (options) => { + if (isProtectedResourceMetadataRequest(options.url)) { + return mockResponse(200, createGitHubProtectedResourceMetadata()); + } + if (isTokenExchangeRequest(options.url)) { + exchangeRequests++; + return mockResponse(200, { access_token: 'gh-resource-token', token_type: 'Bearer' }); + } + return mockResponse(401, { message: 'auth required' }, { 'WWW-Authenticate': challenge }); + }; + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.RequiresSignIn); + // With no subject token there is nothing to exchange — the AS is never contacted. + assert.strictEqual(exchangeRequests, 0); + // A 401 is not a durable verdict, so no negative result is cached. + assert.strictEqual(storageData.get('marketplace.cachedAccess'), undefined); + }); + + test('GitHub provider — negotiated token still forbidden on retry (403) → AccessDenied (cached ineligible)', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'github'); + defaultAccount = createDefaultAccount({ enterprise: true }); + githubSessions = [createGitHubSession()]; + const challenge = 'Bearer realm="marketplace", resource_metadata="https://marketplace.example.com/.well-known/oauth-protected-resource"'; + // The exchange succeeds and a resource token is minted, but the identity is still + // forbidden from the index (403) — a durable denial that is cached. + requestHandler = (options) => { + if (isProtectedResourceMetadataRequest(options.url)) { + return mockResponse(200, createGitHubProtectedResourceMetadata()); + } + if (isAuthorizationServerMetadataRequest(options.url)) { + return mockResponse(200, createAuthorizationServerMetadata()); + } + if (isTokenExchangeRequest(options.url)) { + return mockResponse(200, { access_token: 'gh-resource-token', token_type: 'Bearer' }); + } + const auth = options.headers?.['Authorization'] as string | undefined; + if (auth === 'Bearer gh-resource-token') { + return mockResponse(403, { message: 'forbidden' }); + } + return mockResponse(401, { message: 'auth required' }, { 'WWW-Authenticate': challenge }); + }; + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.AccessDenied); + assert.strictEqual(JSON.parse(storageData.get('marketplace.cachedAccess')!).eligible, false); + }); + + test('GitHub provider — token exchange fails (AS rejects) → RequiresSignIn (not cached)', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'github'); + defaultAccount = createDefaultAccount({ enterprise: true }); + githubSessions = [createGitHubSession()]; + const challenge = 'Bearer realm="marketplace", resource_metadata="https://marketplace.example.com/.well-known/oauth-protected-resource"'; + // The AS rejects the exchange (e.g. the GitHub token is not accepted upstream). No resource + // token can be obtained silently, so — like the no-session case — negotiation re-throws the + // original 401 and the user is asked to (re-)sign in. This is not a durable verdict. + requestHandler = (options) => { + if (isProtectedResourceMetadataRequest(options.url)) { + return mockResponse(200, createGitHubProtectedResourceMetadata()); + } + if (isAuthorizationServerMetadataRequest(options.url)) { + return mockResponse(200, createAuthorizationServerMetadata()); + } + if (isTokenExchangeRequest(options.url)) { + return mockResponse(400, { error: 'invalid_grant' }); + } + return mockResponse(401, { message: 'auth required' }, { 'WWW-Authenticate': challenge }); + }; + + const service = createService(); + await service.getExtensionGalleryManifest(); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.RequiresSignIn); + assert.strictEqual(await service.getAccessToken(), undefined); + assert.strictEqual(storageData.get('marketplace.cachedAccess'), undefined); + }); + + test('GitHub provider — auth-enabled: onDidChangeSessions(github) recovers from RequiresSignIn once a session appears', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'github'); + defaultAccount = createDefaultAccount({ enterprise: true }); + // Start with no GitHub session: the gated index cannot be negotiated (no subject token), + // so the initial validation lands on RequiresSignIn. + githubSessions = []; + const challenge = 'Bearer realm="marketplace", resource_metadata="https://marketplace.example.com/.well-known/oauth-protected-resource"'; + requestHandler = (options) => { + if (isProtectedResourceMetadataRequest(options.url)) { + return mockResponse(200, createGitHubProtectedResourceMetadata()); + } + if (isAuthorizationServerMetadataRequest(options.url)) { + return mockResponse(200, createAuthorizationServerMetadata()); + } + if (isTokenExchangeRequest(options.url)) { + return mockResponse(200, { access_token: 'gh-resource-token', token_type: 'Bearer' }); + } + const auth = options.headers?.['Authorization'] as string | undefined; + if (auth === 'Bearer gh-resource-token') { + return mockResponse(200, createGalleryManifest()); + } + return mockResponse(401, { message: 'auth required' }, { 'WWW-Authenticate': challenge }); + }; + + const service = createService(); + await service.getExtensionGalleryManifest(); + // Read into a local so the assert's assertion signature narrows the local rather than the + // (readonly) getter — otherwise the narrowing would poison every later status comparison. + const statusBeforeSignIn = service.extensionGalleryManifestStatus; + assert.strictEqual(statusBeforeSignIn, ExtensionGalleryManifestStatus.RequiresSignIn); + + // The user signs in: a GitHub session appears and fires onDidChangeSessions('github'). + // Re-validation negotiates a resource token and the marketplace becomes Available. + githubSessions = [createGitHubSession()]; + onDidChangeSessions.fire({ providerId: 'github', label: 'GitHub', event: { added: [], removed: [], changed: [] } }); + // The re-negotiation chain (checkAccess → subject-token → PRM → AS metadata → exchange → + // index) spans several async hops; poll until access is established. + for (let i = 0; i < 50 && service.extensionGalleryManifestStatus !== ExtensionGalleryManifestStatus.Available; i++) { + await new Promise(resolve => setTimeout(resolve, 0)); + } + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Available); + assert.strictEqual(await service.getAccessToken(), 'gh-resource-token'); + }); + + test('GitHub provider — auth-enabled: a session refresh while Available re-mints the negotiated token in place', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'github'); + defaultAccount = createDefaultAccount({ enterprise: true }); + githubSessions = [createGitHubSession('gh-subject-token')]; + const challenge = 'Bearer realm="marketplace", resource_metadata="https://marketplace.example.com/.well-known/oauth-protected-resource"'; + let exchanges = 0; + requestHandler = (options) => { + if (isProtectedResourceMetadataRequest(options.url)) { + return mockResponse(200, createGitHubProtectedResourceMetadata()); + } + if (isAuthorizationServerMetadataRequest(options.url)) { + return mockResponse(200, createAuthorizationServerMetadata()); + } + if (isTokenExchangeRequest(options.url)) { + // Each negotiation mints a distinct token so the re-mint is observable. + return mockResponse(200, { access_token: `gh-resource-token-${++exchanges}`, token_type: 'Bearer' }); + } + const auth = options.headers?.['Authorization'] as string | undefined; + if (auth?.startsWith('Bearer gh-resource-token')) { + return mockResponse(200, createGalleryManifest()); + } + return mockResponse(401, { message: 'auth required' }, { 'WWW-Authenticate': challenge }); + }; + + const service = createService(); + await service.getExtensionGalleryManifest(); + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Available); + assert.strictEqual(await service.getAccessToken(), 'gh-resource-token-1'); + + // A routine GitHub token refresh fires onDidChangeSessions('github') while the marketplace is + // already Available. Because the resource token is derived (RFC 8693) from the GitHub session + // token, it is re-minted IN PLACE — the marketplace never leaves Available (no view flash). + onDidChangeSessions.fire({ providerId: 'github', label: 'GitHub', event: { added: [], removed: [], changed: [] } }); + for (let i = 0; i < 50 && await service.getAccessToken() === 'gh-resource-token-1'; i++) { + await new Promise(resolve => setTimeout(resolve, 0)); + } + + assert.strictEqual(await service.getAccessToken(), 'gh-resource-token-2'); + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Available); + }); + + test('GitHub Enterprise provider — auth-enabled: a session refresh while Available re-mints the token in place and propagates it over the channel', async () => { + // The default account can be backed by the 'github-enterprise' provider, not just 'github'. + // A GHE session refresh must trigger the same in-place re-mint (finding #4c) AND push the + // fresh token to the shared process over the channel so its protected requests keep working + // once the previous token expires (finding #3). + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'github'); + defaultAccount = createDefaultAccount({ authenticationProvider: { id: 'github-enterprise', name: 'GHE', enterprise: true }, enterprise: true }); + githubEnterpriseSessions = [createGitHubSession('ghe-subject-token')]; + let exchanges = 0; + requestHandler = (options) => { + if (isProtectedResourceMetadataRequest(options.url)) { + return mockResponse(200, createGitHubProtectedResourceMetadata()); + } + if (isAuthorizationServerMetadataRequest(options.url)) { + return mockResponse(200, createAuthorizationServerMetadata()); + } + if (isTokenExchangeRequest(options.url)) { + // Each negotiation mints a distinct token so the re-mint is observable. + return mockResponse(200, { access_token: `ghe-resource-token-${++exchanges}`, token_type: 'Bearer' }); + } + // The gated index only yields the manifest once a negotiated token is presented; the + // anonymous probe 401s (no WWW-Authenticate header needed — discovery falls back to + // well-known PRM). + if (options.headers?.['Authorization']) { + return mockResponse(200, createGalleryManifest()); + } + return mockResponse(401, { message: 'auth required' }); + }; + + const service = createService(); + await service.getExtensionGalleryManifest(); + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Available); + assert.strictEqual(await service.getAccessToken(), 'ghe-resource-token-1'); + + // A routine GHE token refresh fires onDidChangeSessions('github-enterprise') while Available. + onDidChangeSessions.fire({ providerId: 'github-enterprise', label: 'GHE', event: { added: [], removed: [], changed: [] } }); + for (let i = 0; i < 50 && await service.getAccessToken() === 'ghe-resource-token-1'; i++) { + await new Promise(resolve => setTimeout(resolve, 0)); + } + + assert.strictEqual(await service.getAccessToken(), 'ghe-resource-token-2'); + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Available); + // The re-minted token was pushed to the shared process WITHOUT republishing the manifest. + assert.ok(channelCalls.some(c => c.command === 'setAccessToken' && (c.args as unknown[])?.[0] === 'ghe-resource-token-2'), 'expected setAccessToken channel call carrying the re-minted token'); + }); + + test('GitHub provider — auth-enabled: a failed token refresh while Available keeps the working marketplace', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'github'); + defaultAccount = createDefaultAccount({ enterprise: true }); + githubSessions = [createGitHubSession('gh-subject-token')]; + const challenge = 'Bearer realm="marketplace", resource_metadata="https://marketplace.example.com/.well-known/oauth-protected-resource"'; + let exchanges = 0; + requestHandler = (options) => { + if (isProtectedResourceMetadataRequest(options.url)) { + return mockResponse(200, createGitHubProtectedResourceMetadata()); + } + if (isAuthorizationServerMetadataRequest(options.url)) { + return mockResponse(200, createAuthorizationServerMetadata()); + } + if (isTokenExchangeRequest(options.url)) { + // The first negotiation succeeds; every later refresh exchange is rejected. + exchanges++; + return exchanges === 1 + ? mockResponse(200, { access_token: 'gh-resource-token', token_type: 'Bearer' }) + : mockResponse(400, { error: 'invalid_grant' }); + } + const auth = options.headers?.['Authorization'] as string | undefined; + if (auth === 'Bearer gh-resource-token') { + return mockResponse(200, createGalleryManifest()); + } + return mockResponse(401, { message: 'auth required' }, { 'WWW-Authenticate': challenge }); + }; + + const service = createService(); + await service.getExtensionGalleryManifest(); + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Available); + assert.strictEqual(await service.getAccessToken(), 'gh-resource-token'); + + // A GitHub session refresh triggers a background re-mint that fails (the AS rejects the + // exchange). A failed refresh must NOT tear down the working marketplace: the existing token + // may still be valid, so status and token are preserved. + onDidChangeSessions.fire({ providerId: 'github', label: 'GitHub', event: { added: [], removed: [], changed: [] } }); + for (let i = 0; i < 50 && exchanges < 2; i++) { + await new Promise(resolve => setTimeout(resolve, 0)); + } + assert.ok(exchanges >= 2, 'the refresh attempted a fresh token exchange'); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Available); + assert.strictEqual(await service.getAccessToken(), 'gh-resource-token'); + }); + + test('GitHub provider — auth-enabled: the negotiated token is proactively re-minted before it expires and re-scheduled', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'github'); + defaultAccount = createDefaultAccount({ enterprise: true }); + githubSessions = [createGitHubSession('gh-subject-token')]; + let exchanges = 0; + requestHandler = (options) => { + if (isProtectedResourceMetadataRequest(options.url)) { + return mockResponse(200, createGitHubProtectedResourceMetadata()); + } + if (isAuthorizationServerMetadataRequest(options.url)) { + return mockResponse(200, createAuthorizationServerMetadata()); + } + if (isTokenExchangeRequest(options.url)) { + // Advertise a finite lifetime so a proactive re-mint is scheduled at a fraction of it. + return mockResponse(200, { access_token: `gh-resource-token-${++exchanges}`, token_type: 'Bearer', expires_in: 1800 }); + } + // The gated index only yields the manifest once a negotiated token is presented; the + // anonymous probe 401s (discovery falls back to well-known PRM, no challenge header). + if (options.headers?.['Authorization']) { + return mockResponse(200, createGalleryManifest()); + } + return mockResponse(401, { message: 'auth required' }); + }; + + const service = disposableStore.add(instantiationService.createInstance(TestableGalleryManifestService)); + await service.getExtensionGalleryManifest(); + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Available); + assert.strictEqual(await service.getAccessToken(), 'gh-resource-token-1'); + + // A proactive refresh is scheduled at 75% of the advertised 1800s lifetime. + assert.deepStrictEqual(service.pendingRefreshes.map(r => r.delayMs), [1_350_000]); + + // Firing it re-mints the token in place, pushes it over the channel, and re-schedules — all + // without leaving Available (no view flash). + service.pendingRefreshes[0].fire(); + for (let i = 0; i < 50 && await service.getAccessToken() === 'gh-resource-token-1'; i++) { + await new Promise(resolve => setTimeout(resolve, 0)); + } + assert.strictEqual(await service.getAccessToken(), 'gh-resource-token-2'); + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Available); + assert.ok(channelCalls.some(c => c.command === 'setAccessToken' && (c.args as unknown[])?.[0] === 'gh-resource-token-2'), 'expected setAccessToken channel push for the re-minted token'); + assert.deepStrictEqual(service.pendingRefreshes.map(r => r.delayMs), [1_350_000]); + }); + + test('GitHub provider — auth-enabled: a short advertised token lifetime refreshes before it expires (not clamped to a coarse floor)', async () => { + // A genuinely short `expires_in` must schedule the re-mint at a fraction of THAT lifetime + // (before the token expires), not be clamped up to a coarse minimum that would fire after + // the token has already died. 30s * 0.75 = 22.5s. + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'github'); + defaultAccount = createDefaultAccount({ enterprise: true }); + githubSessions = [createGitHubSession('gh-subject-token')]; + requestHandler = (options) => { + if (isProtectedResourceMetadataRequest(options.url)) { + return mockResponse(200, createGitHubProtectedResourceMetadata()); + } + if (isAuthorizationServerMetadataRequest(options.url)) { + return mockResponse(200, createAuthorizationServerMetadata()); + } + if (isTokenExchangeRequest(options.url)) { + return mockResponse(200, { access_token: 'gh-resource-token', token_type: 'Bearer', expires_in: 30 }); + } + if (options.headers?.['Authorization']) { + return mockResponse(200, createGalleryManifest()); + } + return mockResponse(401, { message: 'auth required' }); + }; + + const service = disposableStore.add(instantiationService.createInstance(TestableGalleryManifestService)); + await service.getExtensionGalleryManifest(); + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Available); + assert.deepStrictEqual(service.pendingRefreshes.map(r => r.delayMs), [22_500]); + }); + + test('GitHub provider — auth-enabled: a present-but-invalid expires_in refreshes almost immediately, not after a default hour', async () => { + // `expires_in: 0` (or negative / non-finite) is an ALREADY-expired advertisement, distinct + // from an OMITTED `expires_in`. It must re-mint on the small hot-loop floor (5s), never treat + // the token as if it lived the conservative default hour (which would schedule ~45m out). + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'github'); + defaultAccount = createDefaultAccount({ enterprise: true }); + githubSessions = [createGitHubSession('gh-subject-token')]; + requestHandler = (options) => { + if (isProtectedResourceMetadataRequest(options.url)) { + return mockResponse(200, createGitHubProtectedResourceMetadata()); + } + if (isAuthorizationServerMetadataRequest(options.url)) { + return mockResponse(200, createAuthorizationServerMetadata()); + } + if (isTokenExchangeRequest(options.url)) { + return mockResponse(200, { access_token: 'gh-resource-token', token_type: 'Bearer', expires_in: 0 }); + } + if (options.headers?.['Authorization']) { + return mockResponse(200, createGalleryManifest()); + } + return mockResponse(401, { message: 'auth required' }); + }; + + const service = disposableStore.add(instantiationService.createInstance(TestableGalleryManifestService)); + await service.getExtensionGalleryManifest(); + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Available); + assert.deepStrictEqual(service.pendingRefreshes.map(r => r.delayMs), [5_000]); + }); + + test('GitHub provider — auth-enabled: a failed proactive re-mint keeps access and retries on a capped backoff', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'github'); + defaultAccount = createDefaultAccount({ enterprise: true }); + githubSessions = [createGitHubSession('gh-subject-token')]; + let exchanges = 0; + requestHandler = (options) => { + if (isProtectedResourceMetadataRequest(options.url)) { + return mockResponse(200, createGitHubProtectedResourceMetadata()); + } + if (isAuthorizationServerMetadataRequest(options.url)) { + return mockResponse(200, createAuthorizationServerMetadata()); + } + if (isTokenExchangeRequest(options.url)) { + // The initial negotiation succeeds; the proactive refresh exchange fails. + exchanges++; + return exchanges === 1 + ? mockResponse(200, { access_token: 'gh-resource-token', token_type: 'Bearer', expires_in: 1800 }) + : mockResponse(500, { message: 'exchange failed' }); + } + if (options.headers?.['Authorization']) { + return mockResponse(200, createGalleryManifest()); + } + return mockResponse(401, { message: 'auth required' }); + }; + + const service = disposableStore.add(instantiationService.createInstance(TestableGalleryManifestService)); + await service.getExtensionGalleryManifest(); + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Available); + assert.strictEqual(await service.getAccessToken(), 'gh-resource-token'); + assert.deepStrictEqual(service.pendingRefreshes.map(r => r.delayMs), [1_350_000]); + + // The proactive re-mint fails: access is preserved (the current token may still be valid) and + // the refresh re-arms on the retry backoff rather than giving up (which would wedge the + // marketplace until a window reload). + service.pendingRefreshes[0].fire(); + for (let i = 0; i < 100 && service.pendingRefreshes[0]?.delayMs !== 30_000; i++) { + await new Promise(resolve => setTimeout(resolve, 0)); + } + assert.ok(exchanges >= 2, 'the proactive refresh attempted a fresh exchange'); + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Available); + assert.strictEqual(await service.getAccessToken(), 'gh-resource-token'); + assert.deepStrictEqual(service.pendingRefreshes.map(r => r.delayMs), [30_000]); + }); + + test('GitHub provider — auth-enabled: an indeterminate entitlement blip re-arms the proactive refresh instead of dropping it', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'github'); + defaultAccount = createDefaultAccount({ enterprise: true }); + githubSessions = [createGitHubSession('gh-subject-token')]; + requestHandler = (options) => { + if (isProtectedResourceMetadataRequest(options.url)) { + return mockResponse(200, createGitHubProtectedResourceMetadata()); + } + if (isAuthorizationServerMetadataRequest(options.url)) { + return mockResponse(200, createAuthorizationServerMetadata()); + } + if (isTokenExchangeRequest(options.url)) { + return mockResponse(200, { access_token: 'gh-resource-token', token_type: 'Bearer', expires_in: 1800 }); + } + if (options.headers?.['Authorization']) { + return mockResponse(200, createGalleryManifest()); + } + return mockResponse(401, { message: 'auth required' }); + }; + + const service = disposableStore.add(instantiationService.createInstance(TestableGalleryManifestService)); + await service.getExtensionGalleryManifest(); + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Available); + assert.deepStrictEqual(service.pendingRefreshes.map(r => r.delayMs), [1_350_000]); + + // A transient blip re-resolves the account with indeterminate entitlements (checkAccess -> + // 'unknown') WITHOUT a sign-out event, so the epoch is unchanged and the marketplace stays + // Available. The fired one-shot timer must be re-armed on the retry backoff, not dropped — + // dropping it would let the token silently expire and wedge the marketplace until a reload. + defaultAccount = createDefaultAccount({ enterprise: false, entitlementsData: null }); + service.pendingRefreshes[0].fire(); + for (let i = 0; i < 100 && service.pendingRefreshes[0]?.delayMs !== 30_000; i++) { + await new Promise(resolve => setTimeout(resolve, 0)); + } + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Available); + assert.strictEqual(await service.getAccessToken(), 'gh-resource-token'); + assert.deepStrictEqual(service.pendingRefreshes.map(r => r.delayMs), [30_000]); + }); + + test('GitHub provider — sign-out during PRM discovery cancels the token exchange (stale subject token not sent)', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'github'); + defaultAccount = createDefaultAccount({ enterprise: true }); + githubSessions = [createGitHubSession('gh-subject-token')]; + const challenge = 'Bearer realm="marketplace", resource_metadata="https://marketplace.example.com/.well-known/oauth-protected-resource"'; + let exchanges = 0; + // Hold PRM discovery open so the (epoch 1) negotiation parks after the anonymous 401 but + // BEFORE the RFC 8693 token exchange. + let releasePrm!: (v: IRequestContext) => void; + const prmGate = new Promise(resolve => { releasePrm = resolve; }); + requestHandler = (options) => { + if (isProtectedResourceMetadataRequest(options.url)) { + return prmGate; + } + if (isAuthorizationServerMetadataRequest(options.url)) { + return mockResponse(200, createAuthorizationServerMetadata()); + } + if (isTokenExchangeRequest(options.url)) { + exchanges++; + return mockResponse(200, { access_token: 'gh-resource-token', token_type: 'Bearer' }); + } + return mockResponse(401, { message: 'auth required' }, { 'WWW-Authenticate': challenge }); + }; + + const service = createService(); + const inflight = service.getExtensionGalleryManifest(); + // Let the negotiation advance to the point where it awaits PRM discovery. + await new Promise(resolve => setTimeout(resolve, 0)); + + // The user signs out mid-negotiation: the default account disappears and the epoch bumps. + defaultAccount = null; + onDidChangeDefaultAccount.fire(null); + await new Promise(resolve => setTimeout(resolve, 0)); + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.RequiresSignIn); + + // PRM finally resolves. The negotiation must observe the supersession and abort BEFORE the + // exchange — the now-revoked GitHub subject token must never be POSTed to the marketplace AS. + releasePrm(mockResponse(200, createGitHubProtectedResourceMetadata())); + await inflight.catch(() => { }); + await new Promise(resolve => setTimeout(resolve, 0)); + + assert.strictEqual(exchanges, 0); + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.RequiresSignIn); + }); + + 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) → RequiresSignIn (not cached)', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'microsoft'); + microsoftSessions = [createMicrosoftSession()]; + // The index is readable, but the eligibility endpoint returns 401 — the token was + // missing/expired/wrong-audience for that endpoint. This is NOT a durable verdict + // (re-auth may fix it), so we ask the user to (re-)sign in and 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.RequiresSignIn); + 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 — config change tears down the live marketplace even if the restart is declined', async () => { + // A serviceUrl/authProvider change prompts a (dismissable) restart. If the user declines, + // the previous config's manifest, negotiated resource token, and proactive-refresh timer + // must not keep running against the abandoned marketplace — the config listener routes + // through update(null) to tear them down immediately. + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'github'); + defaultAccount = createDefaultAccount({ enterprise: true }); + githubSessions = [createGitHubSession('gh-subject-token')]; + requestHandler = (options) => { + if (isProtectedResourceMetadataRequest(options.url)) { + return mockResponse(200, createGitHubProtectedResourceMetadata()); + } + if (isAuthorizationServerMetadataRequest(options.url)) { + return mockResponse(200, createAuthorizationServerMetadata()); + } + if (isTokenExchangeRequest(options.url)) { + return mockResponse(200, { access_token: 'gh-resource-token', token_type: 'Bearer', expires_in: 1800 }); + } + // Gated index: only yields the manifest once a negotiated token is presented. + if (options.headers?.['Authorization']) { + return mockResponse(200, createGalleryManifest()); + } + return mockResponse(401, { message: 'auth required' }); + }; + + const service = disposableStore.add(instantiationService.createInstance(TestableGalleryManifestService)); + await service.getExtensionGalleryManifest(); + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Available); + assert.strictEqual(await service.getAccessToken(), 'gh-resource-token'); + assert.ok(service.pendingRefreshes.length >= 1, 'a proactive refresh timer is armed while Available'); + + // The marketplace configuration changes; the restart is only prompted (dismissable). + configurationService.onDidChangeConfigurationEmitter.fire({ affectsConfiguration: () => true } as unknown as IConfigurationChangeEvent); + + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Unavailable); + assert.strictEqual(await service.getAccessToken(), undefined); + assert.strictEqual(service.pendingRefreshes.length, 0, 'the proactive refresh timer is cancelled on teardown'); + }); + + 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'); + // Resolved entitlements without a marketplace SKU → a definitive "ineligible" verdict, so + // fresh validation for the current marketplace denies access (rather than `unknown`, which + // `entitlementsData: undefined` would yield and would leave the status indeterminate). + defaultAccount = createDefaultAccount({ entitlementsData: { access_type_sku: 'copilot_free' } as any }); + 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); + }); + + test('same-account refresh (unchanged identity) does not flash or drop cache', async () => { + configurationService.setUserConfiguration(ExtensionGalleryAuthProviderConfigKey, 'github'); + defaultAccount = createDefaultAccount({ enterprise: true }); + + const service = createService(); + await service.getExtensionGalleryManifest(); + + // Eligible enterprise account → Available + eligible cache written. + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Available); + assert.ok(storageData.has('marketplace.cachedAccess')); + + // Record every status transition emitted from here on. + const statuses: ExtensionGalleryManifestStatus[] = []; + disposableStore.add(service.onDidChangeExtensionGalleryManifestStatus(status => statuses.push(status))); + + // A routine account DATA refresh (e.g. a rotated session token or re-fetched entitlements) + // fires onDidChangeDefaultAccount with the SAME identity (provider + session + accountName) + // but a fresh account object. This must NOT tear down the live marketplace. + defaultAccount = createDefaultAccount({ enterprise: true, entitlementsData: null }); + onDidChangeDefaultAccount.fire(defaultAccount); + await new Promise(resolve => setTimeout(resolve, 0)); + + // No teardown flash to Unavailable, the eligible cache is preserved, and the manifest stays + // Available. + assert.ok(!statuses.includes(ExtensionGalleryManifestStatus.Unavailable), 'marketplace flashed on a same-account refresh'); + assert.strictEqual(service.extensionGalleryManifestStatus, ExtensionGalleryManifestStatus.Available); + assert.ok(storageData.has('marketplace.cachedAccess')); + }); + + // --- 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); + }); +});