Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
9 changes: 7 additions & 2 deletions azure-pipelines/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -33,4 +37,5 @@ extends:
displayName: Compile

testPlatforms: {}
publishPackage: ${{ parameters.publishPackage }}
publishPackage: ${{ parameters.publishPackage }}
publishPackageToAzureArtifacts: ${{ parameters.publishPackageToAzureArtifacts }}
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
96 changes: 96 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>) => Promise<string | undefined>;

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<string | undefined>;
lookupAuthorization?(authInfo: ProxyAuthorizationInfo): Promise<ProxyAuthorizationCredentials | undefined>;
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;
Expand Down Expand Up @@ -82,6 +109,75 @@ export interface ProxyAgentParams {
env: NodeJS.ProcessEnv;
}

export function createProxyAuthorizationLookup(params: ProxyAuthorizationLookupParams): LookupProxyAuthorization {
const proxyAuthenticateCache: Record<string, string | string[] | undefined> = {};
const basicAuthCache: Record<string, string | undefined> = {};

return async (proxyURL, proxyAuthenticate, state: ProxyAuthorizationState): Promise<string | undefined> => {
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';

/**
Expand Down
66 changes: 66 additions & 0 deletions tests/src/createProxyAuthorizationLookup.test.ts
Original file line number Diff line number Diff line change
@@ -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"']]);
});
});
74 changes: 13 additions & 61 deletions tests/test-client/src/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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,
Expand All @@ -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',
Expand All @@ -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);
Expand Down Expand Up @@ -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<string, string | string[] | undefined>,
isRemote: boolean,
proxyURL: string,
proxyAuthenticate: string | string[] | undefined,
state: { kerberosRequested?: boolean }
): Promise<string | undefined> {
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<string>('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',
},
});
}
Loading