Skip to content

Commit

Permalink
Proxy Basic Auth
Browse files Browse the repository at this point in the history
  • Loading branch information
chrmarti committed Jul 4, 2024
1 parent 6eaf648 commit 82104a3
Show file tree
Hide file tree
Showing 16 changed files with 173 additions and 73 deletions.
7 changes: 5 additions & 2 deletions src/vs/code/electron-main/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { getDelayedChannel, ProxyChannel, StaticRouter } from 'vs/base/parts/ipc
import { Server as ElectronIPCServer } from 'vs/base/parts/ipc/electron-main/ipc.electron';
import { Client as MessagePortClient } from 'vs/base/parts/ipc/electron-main/ipc.mp';
import { Server as NodeIPCServer } from 'vs/base/parts/ipc/node/ipc.net';
import { ProxyAuthHandler } from 'vs/code/electron-main/auth';
import { IProxyAuthService, ProxyAuthService } from 'vs/platform/native/electron-main/auth';
import { localize } from 'vs/nls';
import { IBackupMainService } from 'vs/platform/backup/electron-main/backup';
import { BackupMainService } from 'vs/platform/backup/electron-main/backupMainService';
Expand Down Expand Up @@ -586,7 +586,7 @@ export class CodeApplication extends Disposable {
const appInstantiationService = await this.initServices(machineId, sqmId, devDeviceId, sharedProcessReady);

// Auth Handler
this._register(appInstantiationService.createInstance(ProxyAuthHandler));
appInstantiationService.invokeFunction(accessor => accessor.get(IProxyAuthService));

// Transient profiles handler
this._register(appInstantiationService.createInstance(UserDataProfilesHandler));
Expand Down Expand Up @@ -1094,6 +1094,9 @@ export class CodeApplication extends Disposable {
// Utility Process Worker
services.set(IUtilityProcessWorkerMainService, new SyncDescriptor(UtilityProcessWorkerMainService, undefined, true));

// Proxy Auth
services.set(IProxyAuthService, new SyncDescriptor(ProxyAuthService));

// Init services that require it
await Promises.settled([
backupMainService.initialize(),
Expand Down
2 changes: 2 additions & 0 deletions src/vs/platform/native/common/native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { ISerializableCommandAction } from 'vs/platform/action/common/action';
import { INativeOpenDialogOptions } from 'vs/platform/dialogs/common/dialogs';
import { createDecorator } from 'vs/platform/instantiation/common/instantiation';
import { IV8Profile } from 'vs/platform/profiling/common/profiling';
import { AuthInfo, Credentials } from 'vs/platform/request/common/request';
import { IPartsSplash } from 'vs/platform/theme/common/themeService';
import { IColorScheme, IOpenedAuxiliaryWindow, IOpenedMainWindow, IOpenEmptyWindowOptions, IOpenWindowOptions, IPoint, IRectangle, IWindowOpenable } from 'vs/platform/window/common/window';

Expand Down Expand Up @@ -184,6 +185,7 @@ export interface ICommonNativeHostService {

// Connectivity
resolveProxy(url: string): Promise<string | undefined>;
lookupAuthorization(authInfo: AuthInfo): Promise<Credentials | undefined>;
loadCertificates(): Promise<string[]>;
findFreePort(startPort: number, giveUpAfter: number, timeout: number, stride?: number): Promise<number>;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { app, AuthenticationResponseDetails, AuthInfo, Event as ElectronEvent, WebContents } from 'electron';
import { app, AuthenticationResponseDetails, AuthInfo as ElectronAuthInfo, Event as ElectronEvent } from 'electron';
import { CancellationToken } from 'vs/base/common/cancellation';
import { Event } from 'vs/base/common/event';
import { hash } from 'vs/base/common/hash';
import { Disposable } from 'vs/base/common/lifecycle';
import { generateUuid } from 'vs/base/common/uuid';
import { IEncryptionMainService } from 'vs/platform/encryption/common/encryptionService';
import { createDecorator } from 'vs/platform/instantiation/common/instantiation';
import { ILogService } from 'vs/platform/log/common/log';
import { AuthInfo, Credentials } from 'vs/platform/request/common/request';
import { StorageScope, StorageTarget } from 'vs/platform/storage/common/storage';
import { IApplicationStorageMainService } from 'vs/platform/storage/electron-main/storageMainService';
import { IWindowsMainService } from 'vs/platform/windows/electron-main/windows';
Expand All @@ -20,49 +22,29 @@ interface ElectronAuthenticationResponseDetails extends AuthenticationResponseDe
}

type LoginEvent = {
event: ElectronEvent;
event?: ElectronEvent;
authInfo: AuthInfo;
req: ElectronAuthenticationResponseDetails;

callback: (username?: string, password?: string) => void;
callback?: (username?: string, password?: string) => void;
};

type Credentials = {
username: string;
password: string;
};
export const IProxyAuthService = createDecorator<IProxyAuthService>('proxyAuthService');

enum ProxyAuthState {

/**
* Initial state: we will try to use stored credentials
* first to reply to the auth challenge.
*/
Initial = 1,

/**
* We used stored credentials and are still challenged,
* so we will show a login dialog next.
*/
StoredCredentialsUsed,

/**
* Finally, if we showed a login dialog already, we will
* not show any more login dialogs until restart to reduce
* the UI noise.
*/
LoginDialogShown
export interface IProxyAuthService {
lookupAuthorization(authInfo: AuthInfo): Promise<Credentials | undefined>;
}

export class ProxyAuthHandler extends Disposable {
export class ProxyAuthService extends Disposable implements IProxyAuthService {

declare readonly _serviceBrand: undefined;

private readonly PROXY_CREDENTIALS_SERVICE_KEY = 'proxy-credentials://';

private pendingProxyResolve: Promise<Credentials | undefined> | undefined = undefined;
private pendingProxyResolves = new Map<string, Promise<Credentials | undefined>>();
private currentDialog: Promise<Credentials | undefined> | undefined = undefined;

private state = ProxyAuthState.Initial;
private cancelledAuthInfoHashes = new Set<string>();

private sessionCredentials: Credentials | undefined = undefined;
private sessionCredentials = new Map<string, Credentials | undefined>();

constructor(
@ILogService private readonly logService: ILogService,
Expand All @@ -76,39 +58,45 @@ export class ProxyAuthHandler extends Disposable {
}

private registerListeners(): void {
const onLogin = Event.fromNodeEventEmitter<LoginEvent>(app, 'login', (event: ElectronEvent, webContents: WebContents, req: ElectronAuthenticationResponseDetails, authInfo: AuthInfo, callback) => ({ event, webContents, req, authInfo, callback }));
const onLogin = Event.fromNodeEventEmitter<LoginEvent>(app, 'login', (event: ElectronEvent, req: ElectronAuthenticationResponseDetails, authInfo: ElectronAuthInfo, callback) => ({ event, authInfo: { ...authInfo, attempt: req.firstAuthAttempt ? 1 : 2 }, callback } satisfies LoginEvent));
this._register(onLogin(this.onLogin, this));
}

private async onLogin({ event, authInfo, req, callback }: LoginEvent): Promise<void> {
async lookupAuthorization(authInfo: AuthInfo): Promise<Credentials | undefined> {
return this.onLogin({ authInfo });
}

private async onLogin({ event, authInfo, callback }: LoginEvent): Promise<Credentials | undefined> {
if (!authInfo.isProxy) {
return; // only for proxy
}

if (!this.pendingProxyResolve && this.state === ProxyAuthState.LoginDialogShown && req.firstAuthAttempt) {
this.logService.trace('auth#onLogin (proxy) - exit - proxy dialog already shown');

return; // only one dialog per session at max (except when firstAuthAttempt: false which indicates a login problem)
}

// Signal we handle this event on our own, otherwise
// Electron will ignore our provided credentials.
event.preventDefault();
event?.preventDefault();

// Compute a hash over the authentication info to be used
// with the credentials store to return the right credentials
// given the properties of the auth request
// (see https://github.com/microsoft/vscode/issues/109497)
const authInfoHash = String(hash({ scheme: authInfo.scheme, host: authInfo.host, port: authInfo.port }));

let credentials: Credentials | undefined = undefined;
if (!this.pendingProxyResolve) {
let pendingProxyResolve = this.pendingProxyResolves.get(authInfoHash);
if (!pendingProxyResolve) {
this.logService.trace('auth#onLogin (proxy) - no pending proxy handling found, starting new');

this.pendingProxyResolve = this.resolveProxyCredentials(authInfo);
pendingProxyResolve = this.resolveProxyCredentials(authInfo, authInfoHash);
this.pendingProxyResolves.set(authInfoHash, pendingProxyResolve);
try {
credentials = await this.pendingProxyResolve;
credentials = await pendingProxyResolve;
} finally {
this.pendingProxyResolve = undefined;
this.pendingProxyResolves.delete(authInfoHash);
}
} else {
this.logService.trace('auth#onLogin (proxy) - pending proxy handling found');

credentials = await this.pendingProxyResolve;
credentials = await pendingProxyResolve;
}

// According to Electron docs, it is fine to call back without
Expand All @@ -118,14 +106,15 @@ export class ProxyAuthHandler extends Disposable {
// > If `callback` is called without a username or password, the authentication
// > request will be cancelled and the authentication error will be returned to the
// > page.
callback(credentials?.username, credentials?.password);
callback?.(credentials?.username, credentials?.password);
return credentials;
}

private async resolveProxyCredentials(authInfo: AuthInfo): Promise<Credentials | undefined> {
private async resolveProxyCredentials(authInfo: AuthInfo, authInfoHash: string): Promise<Credentials | undefined> {
this.logService.trace('auth#resolveProxyCredentials (proxy) - enter');

try {
const credentials = await this.doResolveProxyCredentials(authInfo);
const credentials = await this.doResolveProxyCredentials(authInfo, authInfoHash);
if (credentials) {
this.logService.trace('auth#resolveProxyCredentials (proxy) - got credentials');

Expand All @@ -140,14 +129,18 @@ export class ProxyAuthHandler extends Disposable {
return undefined;
}

private async doResolveProxyCredentials(authInfo: AuthInfo): Promise<Credentials | undefined> {
private async doResolveProxyCredentials(authInfo: AuthInfo, authInfoHash: string): Promise<Credentials | undefined> {
this.logService.trace('auth#doResolveProxyCredentials - enter', authInfo);

// Compute a hash over the authentication info to be used
// with the credentials store to return the right credentials
// given the properties of the auth request
// (see https://github.com/microsoft/vscode/issues/109497)
const authInfoHash = String(hash({ scheme: authInfo.scheme, host: authInfo.host, port: authInfo.port }));
// Reply with session credentials unless we used them already.
// In that case we need to show a login dialog again because
// they seem invalid.
if (authInfo.attempt === 1 && this.sessionCredentials.has(authInfoHash)) {
this.logService.trace('auth#doResolveProxyCredentials (proxy) - exit - found session credentials to use');

const { username, password } = this.sessionCredentials.get(authInfoHash)!;
return { username, password };
}

let storedUsername: string | undefined;
let storedPassword: string | undefined;
Expand All @@ -166,13 +159,32 @@ export class ProxyAuthHandler extends Disposable {
// Reply with stored credentials unless we used them already.
// In that case we need to show a login dialog again because
// they seem invalid.
if (this.state !== ProxyAuthState.StoredCredentialsUsed && typeof storedUsername === 'string' && typeof storedPassword === 'string') {
if (authInfo.attempt === 1 && typeof storedUsername === 'string' && typeof storedPassword === 'string') {
this.logService.trace('auth#doResolveProxyCredentials (proxy) - exit - found stored credentials to use');
this.state = ProxyAuthState.StoredCredentialsUsed;

this.sessionCredentials.set(authInfoHash, { username: storedUsername, password: storedPassword });
return { username: storedUsername, password: storedPassword };
}

const previousDialog = this.currentDialog;
const currentDialog = this.currentDialog = (async () => {
await previousDialog;
const credentials = await this.showProxyCredentialsDialog(authInfo, authInfoHash, storedUsername, storedPassword);
if (this.currentDialog === currentDialog!) {
this.currentDialog = undefined;
}
return credentials;
})();
return currentDialog;
}

private async showProxyCredentialsDialog(authInfo: AuthInfo, authInfoHash: string, storedUsername: string | undefined, storedPassword: string | undefined): Promise<Credentials | undefined> {
if (this.cancelledAuthInfoHashes.has(authInfoHash)) {
this.logService.trace('auth#doResolveProxyCredentials (proxy) - exit - login dialog was cancelled before, not showing again');

return undefined;
}

// Find suitable window to show dialog: prefer to show it in the
// active window because any other network request will wait on
// the credentials and we want the user to present the dialog.
Expand All @@ -186,14 +198,14 @@ export class ProxyAuthHandler extends Disposable {
this.logService.trace(`auth#doResolveProxyCredentials (proxy) - asking window ${window.id} to handle proxy login`);

// Open proxy dialog
const sessionCredentials = this.sessionCredentials.get(authInfoHash);
const payload = {
authInfo,
username: this.sessionCredentials?.username ?? storedUsername, // prefer to show already used username (if any) over stored
password: this.sessionCredentials?.password ?? storedPassword, // prefer to show already used password (if any) over stored
username: sessionCredentials?.username ?? storedUsername, // prefer to show already used username (if any) over stored
password: sessionCredentials?.password ?? storedPassword, // prefer to show already used password (if any) over stored
replyChannel: `vscode:proxyAuthResponse:${generateUuid()}`
};
window.sendWhenReady('vscode:openProxyAuthenticationDialog', CancellationToken.None, payload);
this.state = ProxyAuthState.LoginDialogShown;

// Handle reply
const loginDialogCredentials = await new Promise<Credentials | undefined>(resolve => {
Expand Down Expand Up @@ -229,6 +241,7 @@ export class ProxyAuthHandler extends Disposable {

// We did not get any credentials from the window (e.g. cancelled)
else {
this.cancelledAuthInfoHashes.add(authInfoHash);
resolve(undefined);
}
}
Expand All @@ -240,7 +253,7 @@ export class ProxyAuthHandler extends Disposable {
// Remember credentials for the session in case
// the credentials are wrong and we show the dialog
// again
this.sessionCredentials = loginDialogCredentials;
this.sessionCredentials.set(authInfoHash, loginDialogCredentials);

return loginDialogCredentials;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ import { IV8Profile } from 'vs/platform/profiling/common/profiling';
import { IAuxiliaryWindowsMainService } from 'vs/platform/auxiliaryWindow/electron-main/auxiliaryWindows';
import { IAuxiliaryWindow } from 'vs/platform/auxiliaryWindow/electron-main/auxiliaryWindow';
import { CancellationError } from 'vs/base/common/errors';
import { IProxyAuthService } from 'vs/platform/native/electron-main/auth';
import { AuthInfo, Credentials } from 'vs/platform/request/common/request';

export interface INativeHostMainService extends AddFirstParameterToFunctions<ICommonNativeHostService, Promise<unknown> /* only methods, not events */, number | undefined /* window ID */> { }

Expand All @@ -62,7 +64,8 @@ export class NativeHostMainService extends Disposable implements INativeHostMain
@ILogService private readonly logService: ILogService,
@IProductService private readonly productService: IProductService,
@IThemeMainService private readonly themeMainService: IThemeMainService,
@IWorkspacesManagementMainService private readonly workspacesManagementMainService: IWorkspacesManagementMainService
@IWorkspacesManagementMainService private readonly workspacesManagementMainService: IWorkspacesManagementMainService,
@IProxyAuthService private readonly proxyAuthService: IProxyAuthService
) {
super();
}
Expand Down Expand Up @@ -772,6 +775,10 @@ export class NativeHostMainService extends Disposable implements INativeHostMain
return session?.resolveProxy(url);
}

async lookupAuthorization(_windowId: number | undefined, authInfo: AuthInfo): Promise<Credentials | undefined> {
return this.proxyAuthService.lookupAuthorization(authInfo);
}

async loadCertificates(_windowId: number | undefined): Promise<string[]> {
const proxyAgent = await import('@vscode/proxy-agent');
return proxyAgent.loadSystemCertificates({ log: this.logService });
Expand Down
6 changes: 5 additions & 1 deletion src/vs/platform/request/browser/requestService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { request } from 'vs/base/parts/request/browser/request';
import { IRequestContext, IRequestOptions } from 'vs/base/parts/request/common/request';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { ILoggerService } from 'vs/platform/log/common/log';
import { AbstractRequestService, IRequestService } from 'vs/platform/request/common/request';
import { AbstractRequestService, AuthInfo, Credentials, IRequestService } from 'vs/platform/request/common/request';

/**
* This service exposes the `request` API, while using the global
Expand Down Expand Up @@ -36,6 +36,10 @@ export class RequestService extends AbstractRequestService implements IRequestSe
return undefined; // not implemented in the web
}

async lookupAuthorization(authInfo: AuthInfo): Promise<Credentials | undefined> {
return undefined; // not implemented in the web
}

async loadCertificates(): Promise<string[]> {
return []; // not implemented in the web
}
Expand Down
16 changes: 16 additions & 0 deletions src/vs/platform/request/common/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,27 @@ import { Registry } from 'vs/platform/registry/common/platform';

export const IRequestService = createDecorator<IRequestService>('requestService');

export interface AuthInfo {
isProxy: boolean;
scheme: string;
host: string;
port: number;
realm: string;
attempt: number;
}

export interface Credentials {
username: string;
password: string;
}

export interface IRequestService {
readonly _serviceBrand: undefined;

request(options: IRequestOptions, token: CancellationToken): Promise<IRequestContext>;

resolveProxy(url: string): Promise<string | undefined>;
lookupAuthorization(authInfo: AuthInfo): Promise<Credentials | undefined>;
loadCertificates(): Promise<string[]>;
}

Expand Down Expand Up @@ -80,6 +95,7 @@ export abstract class AbstractRequestService extends Disposable implements IRequ

abstract request(options: IRequestOptions, token: CancellationToken): Promise<IRequestContext>;
abstract resolveProxy(url: string): Promise<string | undefined>;
abstract lookupAuthorization(authInfo: AuthInfo): Promise<Credentials | undefined>;
abstract loadCertificates(): Promise<string[]>;
}

Expand Down
Loading

0 comments on commit 82104a3

Please sign in to comment.