diff --git a/extensions/microsoft-authentication/src/node/authProvider.ts b/extensions/microsoft-authentication/src/node/authProvider.ts index cc8eb2bc5c7..0a352c8eb86 100644 --- a/extensions/microsoft-authentication/src/node/authProvider.ts +++ b/extensions/microsoft-authentication/src/node/authProvider.ts @@ -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'; @@ -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) { + // 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; + } + 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(); + } + }; + + const flows = getMsalFlows({ + extensionHost: typeof navigator === 'undefined' + ? this._context.extension.extensionKind === ExtensionKind.UI ? ExtensionHost.Local : ExtensionHost.Remote + : ExtensionHost.WebWorker, + }); + + let lastError: Error | undefined; + for (const flow of flows) { + if (flow !== flows[0]) { + try { + await promptToContinue(flow.label); + } finally { + this._telemetryReporter.sendLoginFailedEvent(); + } + } + try { + const result = await flow.trigger({ + cachedPca, + scopes: scopeData.scopesToSend, + loginHint: options.account?.label, + 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) { + lastError = e; + if (e instanceof ServerError || (e as ClientAuthError)?.errorCode === ClientAuthErrorCodes.userCanceled) { this._telemetryReporter.sendLoginFailedEvent(); throw e; } - } - // 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; - } - - // The user closed the modal window - if ((e as ClientAuthError).errorCode === ClientAuthErrorCodes.userCanceled) { - this._telemetryReporter.sendLoginFailedEvent(); - throw e; - } - - // 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); - try { - const windowHandle = window.nativeHandle ? Buffer.from(window.nativeHandle) : undefined; - result = await cachedPca.acquireTokenInteractive({ - openBrowser: (url: string) => loopbackClient.openBrowser(url), - scopes: scopeData.scopesToSend, - loopbackClient, - loginHint: options.account?.label, - prompt: options.account?.label ? undefined : 'select_account', - windowHandle - }); - } catch (e) { - 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 { diff --git a/extensions/microsoft-authentication/src/node/cachedPublicClientApplication.ts b/extensions/microsoft-authentication/src/node/cachedPublicClientApplication.ts index 0f27c2c0e4d..616b3e1120d 100644 --- a/extensions/microsoft-authentication/src/node/cachedPublicClientApplication.ts +++ b/extensions/microsoft-authentication/src/node/cachedPublicClientApplication.ts @@ -133,11 +133,11 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica cancellable: true, title: l10n.t('Signing in to Microsoft...') }, - (_process, token) => raceCancellationAndTimeoutError( - this._sequencer.queue(() => this._pca.acquireTokenInteractive(request)), + (_process, token) => this._sequencer.queue(() => raceCancellationAndTimeoutError( + this._pca.acquireTokenInteractive(request), token, 1000 * 60 * 5 - ) + )) ); // this._setupRefresh(result); if (this._isBrokerAvailable) { diff --git a/extensions/microsoft-authentication/src/node/flows.ts b/extensions/microsoft-authentication/src/node/flows.ts new file mode 100644 index 00000000000..3e1d8c513f0 --- /dev/null +++ b/extensions/microsoft-authentication/src/node/flows.ts @@ -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; +} + +class DefaultLoopbackFlow implements IMsalFlow { + label = 'default'; + options: IMsalFlowOptions = { + supportsRemoteExtensionHost: true, + supportsWebWorkerExtensionHost: true + }; + + async trigger({ cachedPca, scopes, loginHint, windowHandle, logger }: IMsalFlowTriggerOptions): Promise { + 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 { + 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; + }); +} diff --git a/src/vs/base/browser/ui/tree/abstractTree.ts b/src/vs/base/browser/ui/tree/abstractTree.ts index 09ce418bda9..cff0e6614d3 100644 --- a/src/vs/base/browser/ui/tree/abstractTree.ts +++ b/src/vs/base/browser/ui/tree/abstractTree.ts @@ -947,7 +947,7 @@ interface IAbstractFindControllerOptions extends IFindWidgetOptions { showNotFoundMessage?: boolean; } -interface IFindControllerOptions extends IAbstractFindControllerOptions { +export interface IFindControllerOptions extends IAbstractFindControllerOptions { defaultFindMode?: TreeFindMode; defaultFindMatchType?: TreeFindMatchType; } diff --git a/src/vs/base/browser/ui/tree/asyncDataTree.ts b/src/vs/base/browser/ui/tree/asyncDataTree.ts index dd6059051f0..f6c47bb9283 100644 --- a/src/vs/base/browser/ui/tree/asyncDataTree.ts +++ b/src/vs/base/browser/ui/tree/asyncDataTree.ts @@ -7,7 +7,7 @@ import { IDragAndDropData } from '../../dnd.js'; import { IIdentityProvider, IKeyboardNavigationLabelProvider, IListDragAndDrop, IListDragOverReaction, IListVirtualDelegate } from '../list/list.js'; import { ElementsDragAndDropData, ListViewTargetSector } from '../list/listView.js'; import { IListStyles } from '../list/listWidget.js'; -import { ComposedTreeDelegate, TreeFindMode as TreeFindMode, IAbstractTreeOptions, IAbstractTreeOptionsUpdate, TreeFindMatchType, AbstractTreePart, LabelFuzzyScore, FindFilter, FindController, ITreeFindToggleChangeEvent } from './abstractTree.js'; +import { ComposedTreeDelegate, TreeFindMode as TreeFindMode, IAbstractTreeOptions, IAbstractTreeOptionsUpdate, TreeFindMatchType, AbstractTreePart, LabelFuzzyScore, FindFilter, FindController, ITreeFindToggleChangeEvent, IFindControllerOptions } from './abstractTree.js'; import { ICompressedTreeElement, ICompressedTreeNode } from './compressedObjectTreeModel.js'; import { getVisibleState, isFilterResult } from './indexTreeModel.js'; import { CompressibleObjectTree, ICompressibleKeyboardNavigationLabelProvider, ICompressibleObjectTreeOptions, ICompressibleTreeRenderer, IObjectTreeOptions, IObjectTreeSetChildrenOptions, ObjectTree } from './objectTree.js'; @@ -629,7 +629,12 @@ export class AsyncDataTree implements IDisposable this.tree.onDidChangeCollapseState(this._onDidChangeCollapseState, this, this.disposables); if (asyncFindEnabled) { - const findOptions = { styles: options.findWidgetStyles, showNotFoundMessage: options.showNotFoundMessage }; + const findOptions: IFindControllerOptions = { + styles: options.findWidgetStyles, + showNotFoundMessage: options.showNotFoundMessage, + defaultFindMatchType: options.defaultFindMatchType, + defaultFindMode: options.defaultFindMode, + }; this.findController = this.disposables.add(new AsyncFindController(this.tree, options.findProvider!, findFilter!, this.tree.options.contextViewProvider!, findOptions)); this.focusNavigationFilter = node => this.findController!.shouldFocusWhenNavigating(node); @@ -657,8 +662,18 @@ export class AsyncDataTree implements IDisposable return new ObjectTree(user, container, objectTreeDelegate, objectTreeRenderers, objectTreeOptions); } - updateOptions(options: IAsyncDataTreeOptionsUpdate = {}): void { - this.tree.updateOptions(options); + updateOptions(optionsUpdate: IAsyncDataTreeOptionsUpdate = {}): void { + if (this.findController) { + if (optionsUpdate.defaultFindMode !== undefined) { + this.findController.mode = optionsUpdate.defaultFindMode; + } + + if (optionsUpdate.defaultFindMatchType !== undefined) { + this.findController.matchType = optionsUpdate.defaultFindMatchType; + } + } + + this.tree.updateOptions(optionsUpdate); } get options(): IAsyncDataTreeOptions { @@ -1513,10 +1528,6 @@ export class CompressibleAsyncDataTree extends As }; } - override updateOptions(options: ICompressibleAsyncDataTreeOptionsUpdate = {}): void { - this.tree.updateOptions(options); - } - override getViewState(): IAsyncDataTreeViewState { if (!this.identityProvider) { throw new TreeError(this.user, 'Can\'t get tree view state without an identity provider'); diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/chatEdit/notebookSynchronizerService.ts b/src/vs/workbench/contrib/notebook/browser/contrib/chatEdit/notebookSynchronizerService.ts index 1e4cf151fae..9788828cf87 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/chatEdit/notebookSynchronizerService.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/chatEdit/notebookSynchronizerService.ts @@ -28,7 +28,6 @@ class NotebookSynchronizerSaveParticipant extends NotebookSaveParticipant { } override async participate(workingCopy: IStoredFileWorkingCopy, context: IStoredFileWorkingCopySaveParticipantContext, progress: IProgress, token: CancellationToken): Promise { - console.log('notebook synchronizer participate'); const session = this._chatEditingService.currentEditingSessionObs.get(); if (!session) {