Skip to content

Commit

Permalink
Adopt concept of flows in Microsoft Auth (#237006)
Browse files Browse the repository at this point in the history
And only use Loopback flow when not running in Remote Extension Host.
  • Loading branch information
TylerLeonhardt authored Dec 27, 2024
1 parent 7da68c0 commit 0a66dc3
Show file tree
Hide file tree
Showing 2 changed files with 159 additions and 71 deletions.
125 changes: 54 additions & 71 deletions extensions/microsoft-authentication/src/node/authProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,17 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { AccountInfo, AuthenticationResult, ClientAuthError, ClientAuthErrorCodes, ServerError } from '@azure/msal-node';
import { AuthenticationGetSessionOptions, AuthenticationProvider, AuthenticationProviderAuthenticationSessionsChangeEvent, AuthenticationProviderSessionOptions, AuthenticationSession, AuthenticationSessionAccountInformation, CancellationError, env, EventEmitter, ExtensionContext, l10n, LogOutputChannel, Uri, window } from 'vscode';
import { AuthenticationGetSessionOptions, AuthenticationProvider, AuthenticationProviderAuthenticationSessionsChangeEvent, AuthenticationProviderSessionOptions, AuthenticationSession, AuthenticationSessionAccountInformation, CancellationError, EventEmitter, ExtensionContext, ExtensionKind, l10n, LogOutputChannel, window } from 'vscode';
import { Environment } from '@azure/ms-rest-azure-env';
import { CachedPublicClientApplicationManager } from './publicClientCache';
import { UriHandlerLoopbackClient } from '../common/loopbackClientAndOpener';
import { UriEventHandler } from '../UriEventHandler';
import { ICachedPublicClientApplication } from '../common/publicClientCache';
import { MicrosoftAccountType, MicrosoftAuthenticationTelemetryReporter } from '../common/telemetryReporter';
import { loopbackTemplate } from './loopbackTemplate';
import { ScopeData } from '../common/scopeData';
import { EventBufferer } from '../common/event';
import { BetterTokenStorage } from '../betterSecretStorage';
import { IStoredSession } from '../AADHelper';
import { ExtensionHost, getMsalFlows } from './flows';

const redirectUri = 'https://vscode.dev/redirect';
const MSA_TID = '9188040d-6c67-4c5b-b112-36a304b66dad';
Expand Down Expand Up @@ -187,86 +186,70 @@ export class MsalAuthProvider implements AuthenticationProvider {

this._logger.info('[createSession]', `[${scopeData.scopeStr}]`, 'starting');
const cachedPca = await this.getOrCreatePublicClientApplication(scopeData.clientId, scopeData.tenant);
let result: AuthenticationResult | undefined;

try {
const windowHandle = window.nativeHandle ? Buffer.from(window.nativeHandle) : undefined;
result = await cachedPca.acquireTokenInteractive({
openBrowser: async (url: string) => { await env.openExternal(Uri.parse(url)); },
scopes: scopeData.scopesToSend,
// The logic for rendering one or the other of these templates is in the
// template itself, so we pass the same one for both.
successTemplate: loopbackTemplate,
errorTemplate: loopbackTemplate,
// Pass the label of the account to the login hint so that we prefer signing in to that account
loginHint: options.account?.label,
// If we aren't logging in to a specific account, then we can use the prompt to make sure they get
// the option to choose a different account.
prompt: options.account?.label ? undefined : 'select_account',
windowHandle
});
} catch (e) {
if (e instanceof CancellationError) {
const yes = l10n.t('Yes');
const result = await window.showErrorMessage(
l10n.t('Having trouble logging in?'),
{
modal: true,
detail: l10n.t('Would you like to try a different way to sign in to your Microsoft account? ({0})', 'protocol handler')
},
yes
);
if (!result) {
this._telemetryReporter.sendLoginFailedEvent();
throw e;
}

// Used for showing a friendlier message to the user when the explicitly cancel a flow.
let userCancelled: boolean | undefined;
const yes = l10n.t('Yes');
const no = l10n.t('No');
const promptToContinue = async (mode: string) => {
if (userCancelled === undefined) {
// We haven't had a failure yet so wait to prompt
return;
}
// This error comes from the backend and is likely not due to the user's machine
// failing to open a port or something local that would require us to try the
// URL handler loopback client.
if (e instanceof ServerError) {
this._telemetryReporter.sendLoginFailedEvent();
throw e;
const message = userCancelled
? l10n.t('Having trouble logging in? Would you like to try a different way? ({0})', mode)
: l10n.t('You have not yet finished authorizing this extension to use your Microsoft Account. Would you like to try a different way? ({0})', mode);
const result = await window.showWarningMessage(message, yes, no);
if (result !== yes) {
throw new CancellationError();
}
};

// The user closed the modal window
if ((e as ClientAuthError).errorCode === ClientAuthErrorCodes.userCanceled) {
this._telemetryReporter.sendLoginFailedEvent();
throw e;
}
const flows = getMsalFlows({
extensionHost: typeof navigator === 'undefined'
? this._context.extension.extensionKind === ExtensionKind.UI ? ExtensionHost.Local : ExtensionHost.Remote
: ExtensionHost.WebWorker,
});

// The user wants to try the loopback client or we got an error likely due to spinning up the server
const loopbackClient = new UriHandlerLoopbackClient(this._uriHandler, redirectUri, this._logger);
let lastError: Error | undefined;
for (const flow of flows) {
if (flow !== flows[0]) {
try {
await promptToContinue(flow.label);
} finally {
this._telemetryReporter.sendLoginFailedEvent();
}
}
try {
const windowHandle = window.nativeHandle ? Buffer.from(window.nativeHandle) : undefined;
result = await cachedPca.acquireTokenInteractive({
openBrowser: (url: string) => loopbackClient.openBrowser(url),
const result = await flow.trigger({
cachedPca,
scopes: scopeData.scopesToSend,
loopbackClient,
loginHint: options.account?.label,
prompt: options.account?.label ? undefined : 'select_account',
windowHandle
windowHandle: window.nativeHandle ? Buffer.from(window.nativeHandle) : undefined,
logger: this._logger,
uriHandler: this._uriHandler
});

const session = this.sessionFromAuthenticationResult(result, scopeData.originalScopes);
this._telemetryReporter.sendLoginEvent(session.scopes);
this._logger.info('[createSession]', `[${scopeData.scopeStr}]`, 'returned session');
this._onDidChangeSessionsEmitter.fire({ added: [session], changed: [], removed: [] });
return session;
} catch (e) {
this._telemetryReporter.sendLoginFailedEvent();
throw e;
lastError = e;
if (e instanceof ServerError || (e as ClientAuthError)?.errorCode === ClientAuthErrorCodes.userCanceled) {
this._telemetryReporter.sendLoginFailedEvent();
throw e;
}
// Continue to next flow
if (e instanceof CancellationError) {
userCancelled = true;
}
}
}

if (!result) {
this._telemetryReporter.sendLoginFailedEvent();
throw new Error('No result returned from MSAL');
}

const session = this.sessionFromAuthenticationResult(result, scopeData.originalScopes);
this._telemetryReporter.sendLoginEvent(session.scopes);
this._logger.info('[createSession]', `[${scopeData.scopeStr}]`, 'returned session');
// This is the only scenario in which we need to fire the _onDidChangeSessionsEmitter out of band...
// the badge flow (when the client passes no options in to getSession) will only remove a badge if a session
// was created that _matches the scopes_ that that badge requests. See `onDidChangeSessions` for more info.
// TODO: This should really be fixed in Core.
this._onDidChangeSessionsEmitter.fire({ added: [session], changed: [], removed: [] });
return session;
this._telemetryReporter.sendLoginFailedEvent();
throw lastError ?? new Error('No auth flow succeeded');
}

async removeSession(sessionId: string): Promise<void> {
Expand Down
105 changes: 105 additions & 0 deletions extensions/microsoft-authentication/src/node/flows.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { AuthenticationResult } from '@azure/msal-node';
import { Uri, LogOutputChannel, env } from 'vscode';
import { ICachedPublicClientApplication } from '../common/publicClientCache';
import { UriHandlerLoopbackClient } from '../common/loopbackClientAndOpener';
import { UriEventHandler } from '../UriEventHandler';
import { loopbackTemplate } from './loopbackTemplate';

const redirectUri = 'https://vscode.dev/redirect';

export const enum ExtensionHost {
WebWorker,
Remote,
Local
}

interface IMsalFlowOptions {
supportsRemoteExtensionHost: boolean;
supportsWebWorkerExtensionHost: boolean;
}

interface IMsalFlowTriggerOptions {
cachedPca: ICachedPublicClientApplication;
scopes: string[];
loginHint?: string;
windowHandle?: Buffer;
logger: LogOutputChannel;
uriHandler: UriEventHandler;
}

interface IMsalFlow {
readonly label: string;
readonly options: IMsalFlowOptions;
trigger(options: IMsalFlowTriggerOptions): Promise<AuthenticationResult>;
}

class DefaultLoopbackFlow implements IMsalFlow {
label = 'default';
options: IMsalFlowOptions = {
supportsRemoteExtensionHost: true,
supportsWebWorkerExtensionHost: true
};

async trigger({ cachedPca, scopes, loginHint, windowHandle, logger }: IMsalFlowTriggerOptions): Promise<AuthenticationResult> {
logger.info('Trying default msal flow...');
return await cachedPca.acquireTokenInteractive({
openBrowser: async (url: string) => { await env.openExternal(Uri.parse(url)); },
scopes,
successTemplate: loopbackTemplate,
errorTemplate: loopbackTemplate,
loginHint,
prompt: loginHint ? undefined : 'select_account',
windowHandle
});
}
}

class UrlHandlerFlow implements IMsalFlow {
label = 'protocol handler';
options: IMsalFlowOptions = {
supportsRemoteExtensionHost: false,
supportsWebWorkerExtensionHost: false
};

async trigger({ cachedPca, scopes, loginHint, windowHandle, logger, uriHandler }: IMsalFlowTriggerOptions): Promise<AuthenticationResult> {
logger.info('Trying protocol handler flow...');
const loopbackClient = new UriHandlerLoopbackClient(uriHandler, redirectUri, logger);
return await cachedPca.acquireTokenInteractive({
openBrowser: (url: string) => loopbackClient.openBrowser(url),
scopes,
loopbackClient,
loginHint,
prompt: loginHint ? undefined : 'select_account',
windowHandle
});
}
}

const allFlows: IMsalFlow[] = [
new DefaultLoopbackFlow(),
new UrlHandlerFlow()
];

export interface IMsalFlowQuery {
extensionHost: ExtensionHost;
}

export function getMsalFlows(query: IMsalFlowQuery): IMsalFlow[] {
return allFlows.filter(flow => {
let useFlow: boolean = true;
switch (query.extensionHost) {
case ExtensionHost.Remote:
useFlow &&= flow.options.supportsRemoteExtensionHost;
break;
case ExtensionHost.WebWorker:
useFlow &&= flow.options.supportsWebWorkerExtensionHost;
break;
}
return useFlow;
});
}

0 comments on commit 0a66dc3

Please sign in to comment.