Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
ba9e162
Add extensions.gallery.authProvider policy, marketplace scope, and co…
mcumming Jul 7, 2026
3435ee8
Add Entra ID eligibility check to the gallery manifest service
mcumming Jul 7, 2026
7fb68cf
Add provider-aware marketplace sign-in and access-denied UX
mcumming Jul 7, 2026
bebf7f5
Add microsoft to trustedExtensionAuthAccess
mcumming Jul 7, 2026
0bfd24a
Add unit tests for marketplace provider routing and eligibility
mcumming Jul 7, 2026
b58ca78
Harden Entra marketplace access: cache scoping, race guards, error ha…
mcumming Jul 10, 2026
282c38d
Address Copilot PR review: policy export, cross-account leak, layerin…
mcumming Jul 10, 2026
1047d20
Add RFC 9728 Protected Resource Metadata discovery for the marketplace
mcumming Jul 14, 2026
cfaa026
Negotiate a resource-scoped token on a gated Microsoft service index
mcumming Jul 14, 2026
c9fcc37
Attach the negotiated marketplace token to gallery API requests
mcumming Jul 14, 2026
516783f
Thread the negotiated marketplace token to the shared process
mcumming Jul 14, 2026
c93ef87
Attach the negotiated marketplace token to extension resource requests
mcumming Jul 14, 2026
8578eba
Acquire the resource-scoped token on interactive marketplace sign-in
mcumming Jul 14, 2026
1249328
Check marketplace eligibility only on first sign-in or account change
mcumming Jul 14, 2026
d708ccf
Downgrade the expected negotiation 401 from error to trace
mcumming Jul 14, 2026
893adfc
Authenticate GitHub marketplace API requests via RFC 8693 token exchange
mcumming Jul 14, 2026
fdc8c4f
Add tests for GitHub marketplace RFC 8693 token-exchange auth
mcumming Jul 14, 2026
9639deb
Strip the Authorization header on cross-origin request redirects
mcumming Jul 15, 2026
39f4a31
Validate the authorization server metadata issuer (RFC 8414 3)
mcumming Jul 15, 2026
ee1bbca
Harden GitHub marketplace token refresh, cancellation, and eligibility
mcumming Jul 15, 2026
540018c
Resolve redirect location before following and strip Cookie on cross-…
mcumming Jul 16, 2026
e85cad8
Scope RFC 8414 issuer validation to the marketplace and recheck cance…
mcumming Jul 16, 2026
216b120
Propagate in-place re-minted marketplace tokens to the shared process…
mcumming Jul 16, 2026
6ba78a9
Treat 401/404 entitlements (null) as indeterminate, not a durable mar…
mcumming Jul 16, 2026
e8889cc
Gate marketplace teardown on GitHub account identity change
mcumming Jul 16, 2026
9dfae0c
Proactively re-mint the negotiated GitHub marketplace token before it…
mcumming Jul 16, 2026
7c41b64
Thread a currency check into the marketplace token exchange
mcumming Jul 16, 2026
ede9adc
Honor followRedirects:0 in the renderer fetch request path
mcumming Jul 16, 2026
cd48515
Fix dropped proactive-refresh timer on soft GitHub token failures
mcumming Jul 16, 2026
1abc7a3
Tear down live marketplace on config change even if restart is declined
mcumming Jul 16, 2026
d7cafad
Refresh negotiated GitHub token before short/expired lifetimes elapse
mcumming Jul 16, 2026
cfb2b99
Gate untrusted resource_metadata challenge hint against SSRF
mcumming Jul 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions build/lib/policies/policyData.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion product.json
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,8 @@
],
"github-enterprise": [
"GitHub.copilot-chat"
]
],
"microsoft": []
},
"onboardingKeymaps": [
{
Expand Down
22 changes: 21 additions & 1 deletion src/vs/base/common/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions src/vs/base/common/product.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions src/vs/base/parts/request/common/requestImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
41 changes: 41 additions & 0 deletions src/vs/base/parts/request/test/electron-main/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>((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']);
Expand Down Expand Up @@ -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();
});
77 changes: 77 additions & 0 deletions src/vs/base/test/common/oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading
Loading