Adopt concept of flows in Microsoft Auth (#237006)
And only use Loopback flow when not running in Remote Extension Host.pull/226074/head^2
parent
7da68c033c
commit
0a66dc39ff
|
@ -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<void> {
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
}
|
Loading…
Reference in New Issue