From 99f9dc607aeb596bb29bb1c2389cf15a51934337 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Mon, 13 Jul 2026 18:48:25 +0200 Subject: [PATCH] Extract proxy auth support --- CHANGELOG.md | 3 + azure-pipelines/publish.yml | 9 +- package-lock.json | 4 +- package.json | 2 +- src/index.ts | 96 +++++++++++++++++++ .../createProxyAuthorizationLookup.test.ts | 66 +++++++++++++ tests/test-client/src/proxy.test.ts | 74 +++----------- 7 files changed, 188 insertions(+), 66 deletions(-) create mode 100644 tests/src/createProxyAuthorizationLookup.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 78440b9..25096e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ # Change Log Notable changes will be documented here. +## [0.44.0] +- Add `createProxyAuthorizationLookup` for reusable Kerberos and Basic proxy authentication handling. + ## [0.43.0] - Add `resolveProxyByURL` to `createProxyResolver`, returning the resolved proxy `url`, `type` (`DIRECT`/`PROXY`/`HTTP`/`HTTPS`/`SOCKS`/`SOCKS5`/`SOCKS4`/`EMPTY`/`UNRECOGNIZED`) and `source` (`localhost`/`noProxyConfig`/`noProxyEnv`/`setting`/`env`/`remote`/`system_cached`/`system`/`fallback`). `getProxyURLFromResolverResult` now also returns the resolved `type`. The existing `resolveProxyURL` is unchanged. diff --git a/azure-pipelines/publish.yml b/azure-pipelines/publish.yml index 26a3e70..faf198c 100644 --- a/azure-pipelines/publish.yml +++ b/azure-pipelines/publish.yml @@ -16,7 +16,11 @@ resources: parameters: - name: publishPackage - displayName: 🚀 Publish vscode-proxy-agent + displayName: 🚀 Publish vscode-proxy-agent to npm + type: boolean + default: false + - name: publishPackageToAzureArtifacts + displayName: 🚀 Publish vscode-proxy-agent to Azure Artifacts type: boolean default: false @@ -33,4 +37,5 @@ extends: displayName: Compile testPlatforms: {} - publishPackage: ${{ parameters.publishPackage }} \ No newline at end of file + publishPackage: ${{ parameters.publishPackage }} + publishPackageToAzureArtifacts: ${{ parameters.publishPackageToAzureArtifacts }} diff --git a/package-lock.json b/package-lock.json index 568029a..e160a59 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vscode/proxy-agent", - "version": "0.42.0", + "version": "0.44.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@vscode/proxy-agent", - "version": "0.42.0", + "version": "0.44.0", "license": "MIT", "dependencies": { "@tootallnate/once": "^3.0.0", diff --git a/package.json b/package.json index 400f23b..4846a29 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vscode/proxy-agent", - "version": "0.43.0", + "version": "0.44.0", "description": "NodeJS http(s) agent implementation for VS Code", "main": "out/index.js", "types": "out/index.d.ts", diff --git a/src/index.ts b/src/index.ts index c460300..d018f54 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,6 +54,33 @@ const maxCacheEntries = 5000; // Cache can grow twice that much due to 'oldCache export type LookupProxyAuthorization = (proxyURL: string, proxyAuthenticate: string | string[] | undefined, state: Record) => Promise; +export interface ProxyAuthorizationInfo { + scheme: 'basic'; + host: string; + port: number; + realm: string; + isProxy: true; + attempt: number; +} + +export interface ProxyAuthorizationCredentials { + username: string; + password: string; +} + +export interface ProxyAuthorizationLookupParams { + log: Log; + lookupKerberosAuthorization?(proxyURL: string): Promise; + lookupAuthorization?(authInfo: ProxyAuthorizationInfo): Promise; + onDidRequestAuthentication?(authenticationChallenges: string[]): void; +} + +interface ProxyAuthorizationState { + kerberosRequested?: boolean; + basicAuthCacheUsed?: boolean; + basicAuthAttempt?: number; +} + export interface Log { trace(message: string, ...args: any[]): void; debug(message: string, ...args: any[]): void; @@ -82,6 +109,75 @@ export interface ProxyAgentParams { env: NodeJS.ProcessEnv; } +export function createProxyAuthorizationLookup(params: ProxyAuthorizationLookupParams): LookupProxyAuthorization { + const proxyAuthenticateCache: Record = {}; + const basicAuthCache: Record = {}; + + return async (proxyURL, proxyAuthenticate, state: ProxyAuthorizationState): Promise => { + proxyURL = proxyURL.replace(/\/+$/, ''); + const cached = proxyAuthenticateCache[proxyURL]; + if (proxyAuthenticate) { + proxyAuthenticateCache[proxyURL] = proxyAuthenticate; + } + params.log.trace('ProxyResolver#lookupProxyAuthorization callback', `proxyURL:${proxyURL}`, `proxyAuthenticate:${proxyAuthenticate}`, `proxyAuthenticateCache:${cached}`); + const header = proxyAuthenticate || cached; + const authenticate = Array.isArray(header) ? header : typeof header === 'string' ? [header] : []; + params.onDidRequestAuthentication?.(authenticate); + + if (params.lookupKerberosAuthorization && authenticate.some(value => /^(Negotiate|Kerberos)( |$)/i.test(value)) && !state.kerberosRequested) { + state.kerberosRequested = true; + try { + const authorization = await params.lookupKerberosAuthorization(proxyURL); + if (authorization) { + return authorization; + } + } catch (error) { + params.log.debug('ProxyResolver#lookupProxyAuthorization Kerberos authentication failed', error); + } + } + + const basicAuthHeader = authenticate.find(value => /^Basic( |$)/i.test(value)); + if (params.lookupAuthorization && basicAuthHeader) { + try { + const cachedAuth = basicAuthCache[proxyURL]; + if (cachedAuth) { + if (state.basicAuthCacheUsed) { + params.log.debug('ProxyResolver#lookupProxyAuthorization Basic authentication deleting cached credentials', `proxyURL:${proxyURL}`); + delete basicAuthCache[proxyURL]; + } else { + params.log.debug('ProxyResolver#lookupProxyAuthorization Basic authentication using cached credentials', `proxyURL:${proxyURL}`); + state.basicAuthCacheUsed = true; + return cachedAuth; + } + } + + state.basicAuthAttempt = (state.basicAuthAttempt || 0) + 1; + const realm = / realm="([^"]+)"/i.exec(basicAuthHeader)?.[1]; + params.log.debug('ProxyResolver#lookupProxyAuthorization Basic authentication lookup', `proxyURL:${proxyURL}`, `realm:${realm}`); + const url = new URL(proxyURL); + const credentials = await params.lookupAuthorization({ + scheme: 'basic', + host: url.hostname, + port: Number(url.port), + realm: realm || '', + isProxy: true, + attempt: state.basicAuthAttempt, + }); + if (credentials) { + params.log.debug('ProxyResolver#lookupProxyAuthorization Basic authentication received credentials', `proxyURL:${proxyURL}`, `realm:${realm}`); + const authorization = 'Basic ' + Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64'); + basicAuthCache[proxyURL] = authorization; + return authorization; + } + params.log.debug('ProxyResolver#lookupProxyAuthorization Basic authentication received no credentials', `proxyURL:${proxyURL}`, `realm:${realm}`); + } catch (error) { + params.log.error('ProxyResolver#lookupProxyAuthorization Basic authentication failed', error); + } + } + return undefined; + }; +} + export type { ProxyResolveType } from './agent'; /** diff --git a/tests/src/createProxyAuthorizationLookup.test.ts b/tests/src/createProxyAuthorizationLookup.test.ts new file mode 100644 index 0000000..9370d83 --- /dev/null +++ b/tests/src/createProxyAuthorizationLookup.test.ts @@ -0,0 +1,66 @@ +import * as assert from 'assert'; +import { createProxyAuthorizationLookup, Log, ProxyAuthorizationInfo } from '../../src/index'; + +const log: Log = { + trace() { }, + debug() { }, + info() { }, + warn() { }, + error() { }, +}; + +describe('createProxyAuthorizationLookup', function () { + it('looks up Kerberos authorization once per connection', async function () { + let lookupCount = 0; + const lookup = createProxyAuthorizationLookup({ + log, + lookupKerberosAuthorization: async proxyURL => { + lookupCount++; + assert.strictEqual(proxyURL, 'http://proxy.example:8080'); + return 'Negotiate token'; + }, + }); + const state = {}; + + assert.strictEqual(await lookup('http://proxy.example:8080/', 'Negotiate', state), 'Negotiate token'); + assert.strictEqual(await lookup('http://proxy.example:8080/', 'Negotiate', state), undefined); + assert.strictEqual(lookupCount, 1); + }); + + it('caches Basic credentials and retries rejected credentials', async function () { + const authInfos: ProxyAuthorizationInfo[] = []; + const lookup = createProxyAuthorizationLookup({ + log, + lookupAuthorization: async authInfo => { + authInfos.push(authInfo); + return { username: 'user', password: `password-${authInfo.attempt}` }; + }, + }); + + const firstState = {}; + assert.strictEqual(await lookup('http://proxy.example:8080/', 'Basic realm="proxy realm"', firstState), `Basic ${Buffer.from('user:password-1').toString('base64')}`); + + const secondState = {}; + assert.strictEqual(await lookup('http://proxy.example:8080', undefined, secondState), `Basic ${Buffer.from('user:password-1').toString('base64')}`); + assert.strictEqual(await lookup('http://proxy.example:8080', 'Basic realm="proxy realm"', secondState), `Basic ${Buffer.from('user:password-1').toString('base64')}`); + assert.strictEqual(await lookup('http://proxy.example:8080', 'Basic realm="proxy realm"', secondState), `Basic ${Buffer.from('user:password-2').toString('base64')}`); + + assert.deepStrictEqual(authInfos, [ + { scheme: 'basic', host: 'proxy.example', port: 8080, realm: 'proxy realm', isProxy: true, attempt: 1 }, + { scheme: 'basic', host: 'proxy.example', port: 8080, realm: 'proxy realm', isProxy: true, attempt: 1 }, + { scheme: 'basic', host: 'proxy.example', port: 8080, realm: 'proxy realm', isProxy: true, attempt: 2 }, + ]); + }); + + it('reports requested authentication challenges', async function () { + const authenticationChallenges: string[][] = []; + const lookup = createProxyAuthorizationLookup({ + log, + onDidRequestAuthentication: challenges => authenticationChallenges.push(challenges), + }); + + await lookup('http://proxy.example:8080', ['Negotiate', 'Basic realm="proxy"'], {}); + + assert.deepStrictEqual(authenticationChallenges, [['Negotiate', 'Basic realm="proxy"']]); + }); +}); \ No newline at end of file diff --git a/tests/test-client/src/proxy.test.ts b/tests/test-client/src/proxy.test.ts index 3cbfea3..370902f 100644 --- a/tests/test-client/src/proxy.test.ts +++ b/tests/test-client/src/proxy.test.ts @@ -232,7 +232,8 @@ describe('Proxied client', function () { it('should work with kerberos', function () { this.timeout(10000); - const proxyAuthenticateCache = {}; + const log = { ...console, trace: console.log }; + const lookupProxyAuthorization = createKerberosProxyAuthorizationLookup(log); return testRequest(https, { hostname: 'test-https-server', path: '/test-path', @@ -242,8 +243,7 @@ describe('Proxied client', function () { if (proxyAuthenticate) { assert.strictEqual(proxyAuthenticate, 'Negotiate'); } - const log = { ...console, trace: console.log }; - return lookupProxyAuthorization(log, log, proxyAuthenticateCache, true, proxyURL, proxyAuthenticate, state); + return lookupProxyAuthorization(proxyURL, proxyAuthenticate, state); }, }), ca, @@ -252,7 +252,8 @@ describe('Proxied client', function () { it('should work with kerberos (fetch)', async function () { this.timeout(10000); - const proxyAuthenticateCache = {}; + const log = { ...console, trace: console.log }; + const lookupProxyAuthorization = createKerberosProxyAuthorizationLookup(log); const params: vpa.ProxyAgentParams = { ...directProxyAgentParamsV1, resolveProxy: async () => 'PROXY test-http-kerberos-proxy:80', @@ -261,8 +262,7 @@ describe('Proxied client', function () { if (proxyAuthenticate) { assert.strictEqual(proxyAuthenticate, 'Negotiate'); } - const log = { ...console, trace: console.log }; - return lookupProxyAuthorization(log, log, proxyAuthenticateCache, true, proxyURL, proxyAuthenticate, state); + return lookupProxyAuthorization(proxyURL, proxyAuthenticate, state); }, }; const { resolveProxyURL } = vpa.createProxyResolver(params); @@ -496,65 +496,17 @@ describe('Proxied client', function () { }); }); -// From microsoft/vscode's proxyResolver.ts: -async function lookupProxyAuthorization( - extHostLogService: Console, - mainThreadTelemetry: Console, - // configProvider: ExtHostConfigProvider, - proxyAuthenticateCache: Record, - isRemote: boolean, - proxyURL: string, - proxyAuthenticate: string | string[] | undefined, - state: { kerberosRequested?: boolean } -): Promise { - const cached = proxyAuthenticateCache[proxyURL]; - if (proxyAuthenticate) { - proxyAuthenticateCache[proxyURL] = proxyAuthenticate; - } - extHostLogService.trace('ProxyResolver#lookupProxyAuthorization callback', `proxyURL:${proxyURL}`, `proxyAuthenticate:${proxyAuthenticate}`, `proxyAuthenticateCache:${cached}`); - const header = proxyAuthenticate || cached; - const authenticate = Array.isArray(header) ? header : typeof header === 'string' ? [header] : []; - sendTelemetry(mainThreadTelemetry, authenticate, isRemote); - if (authenticate.some(a => /^(Negotiate|Kerberos)( |$)/i.test(a)) && !state.kerberosRequested) { - try { - state.kerberosRequested = true; +function createKerberosProxyAuthorizationLookup(log: vpa.Log): vpa.LookupProxyAuthorization { + return vpa.createProxyAuthorizationLookup({ + log, + lookupKerberosAuthorization: async proxyURL => { const kerberos = await import('kerberos'); const url = new URL(proxyURL); - const spn = /* configProvider.getConfiguration('http').get('proxyKerberosServicePrincipal') - || */ (process.platform === 'win32' ? `HTTP/${url.hostname}` : `HTTP@${url.hostname}`); - extHostLogService.debug('ProxyResolver#lookupProxyAuthorization Kerberos authentication lookup', `proxyURL:${proxyURL}`, `spn:${spn}`); + const spn = process.platform === 'win32' ? `HTTP/${url.hostname}` : `HTTP@${url.hostname}`; + log.debug('ProxyResolver#lookupProxyAuthorization Kerberos authentication lookup', `proxyURL:${proxyURL}`, `spn:${spn}`); const client = await kerberos.initializeClient(spn); const response = await client.step(''); return 'Negotiate ' + response; - } catch (err) { - extHostLogService.error('ProxyResolver#lookupProxyAuthorization Kerberos authentication failed', err); - } - } - return undefined; -} - -type ProxyAuthenticationClassification = { - owner: 'chrmarti'; - comment: 'Data about proxy authentication requests'; - authenticationType: { classification: 'PublicNonPersonalData'; purpose: 'FeatureInsight'; comment: 'Type of the authentication requested' }; - extensionHostType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Type of the extension host' }; -}; - -type ProxyAuthenticationEvent = { - authenticationType: string; - extensionHostType: string; -}; - -let telemetrySent = false; - -function sendTelemetry(mainThreadTelemetry: Console, authenticate: string[], isRemote: boolean) { - if (telemetrySent || !authenticate.length) { - return; - } - telemetrySent = true; - - mainThreadTelemetry.log('proxyAuthenticationRequest', { - authenticationType: authenticate.map(a => a.split(' ')[0]).join(','), - extensionHostType: isRemote ? 'remote' : 'local', + }, }); }