mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-28 12:33:35 +01:00
Adopt the MSAL broker to talk to the OS for Microsoft auth (#233739)
This adopts the `NativeBrokerPlugin` provided by `@azure/msal-node-extensions` to provide the ability to use auth state from the OS, and show native auth dialogs instead of going to the browser.
This has several pieces:
* The adoption of the broker in the microsoft-authentication extension:
* Adding `NativeBrokerPlugin` to our PCAs
* Using the proposed handle API to pass the native window handle down to MSAL calls (btw, this API will change in a follow up PR)
* Adopting an AccountAccess layer to handle:
* giving the user control of which accounts VS Code uses
* an eventing layer so that auth state can be updated across multiple windows
* Getting the extension to build properly and only build what it really needs. This required several package.json/webpack hacks:
* Use a fake keytar since we don't use the feature in `@azure/msal-node-extensions` that uses keytar
* Use a fake dpapi layer since we don't use the feature in `@azure/msal-node-extensions` that uses it
* Ensure the msal runtime `.node` and `.dll` files are included in the bundle
* Get the VS Code build to allow a native node module in an extension: by having a list of native extensions that will be built in the "ci" part of the build - in other words when VS Code is building on the target platform
There are a couple of followups:
* Refactor the `handle` API to handle (heh) Auxiliary Windows https://github.com/microsoft/vscode/issues/233106
* Separate the call to `acquireTokenSilent` and `acquireTokenInteractive` and all the usage of this native node module into a separate process or maybe in Core... we'll see. Something to experiment with after we have something working. NEEDS FOLLOW UP ISSUE
Fixes https://github.com/microsoft/vscode/issues/229431
This commit is contained in:
committed by
GitHub
parent
681164aaaa
commit
305134296c
106
extensions/microsoft-authentication/src/common/accountAccess.ts
Normal file
106
extensions/microsoft-authentication/src/common/accountAccess.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Disposable, Event, EventEmitter, SecretStorage } from 'vscode';
|
||||
import { AccountInfo } from '@azure/msal-node';
|
||||
|
||||
interface IAccountAccess {
|
||||
onDidAccountAccessChange: Event<void>;
|
||||
isAllowedAccess(account: AccountInfo): boolean;
|
||||
setAllowedAccess(account: AccountInfo, allowed: boolean): void;
|
||||
}
|
||||
|
||||
export class ScopedAccountAccess implements IAccountAccess {
|
||||
private readonly _onDidAccountAccessChangeEmitter = new EventEmitter<void>();
|
||||
readonly onDidAccountAccessChange = this._onDidAccountAccessChangeEmitter.event;
|
||||
|
||||
private readonly _accountAccessSecretStorage: AccountAccessSecretStorage;
|
||||
|
||||
private value = new Array<string>();
|
||||
|
||||
constructor(
|
||||
private readonly _secretStorage: SecretStorage,
|
||||
private readonly _cloudName: string,
|
||||
private readonly _clientId: string,
|
||||
private readonly _authority: string
|
||||
) {
|
||||
this._accountAccessSecretStorage = new AccountAccessSecretStorage(this._secretStorage, this._cloudName, this._clientId, this._authority);
|
||||
this._accountAccessSecretStorage.onDidChange(() => this.update());
|
||||
}
|
||||
|
||||
initialize() {
|
||||
return this.update();
|
||||
}
|
||||
|
||||
isAllowedAccess(account: AccountInfo): boolean {
|
||||
return this.value.includes(account.homeAccountId);
|
||||
}
|
||||
|
||||
async setAllowedAccess(account: AccountInfo, allowed: boolean): Promise<void> {
|
||||
if (allowed) {
|
||||
if (this.value.includes(account.homeAccountId)) {
|
||||
return;
|
||||
}
|
||||
await this._accountAccessSecretStorage.store([...this.value, account.homeAccountId]);
|
||||
return;
|
||||
}
|
||||
await this._accountAccessSecretStorage.store(this.value.filter(id => id !== account.homeAccountId));
|
||||
}
|
||||
|
||||
private async update() {
|
||||
const current = new Set(this.value);
|
||||
const value = await this._accountAccessSecretStorage.get();
|
||||
|
||||
this.value = value ?? [];
|
||||
if (current.size !== this.value.length || !this.value.every(id => current.has(id))) {
|
||||
this._onDidAccountAccessChangeEmitter.fire();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class AccountAccessSecretStorage {
|
||||
private _disposable: Disposable;
|
||||
|
||||
private readonly _onDidChangeEmitter = new EventEmitter<void>;
|
||||
readonly onDidChange: Event<void> = this._onDidChangeEmitter.event;
|
||||
|
||||
private readonly _key = `accounts-${this._cloudName}-${this._clientId}-${this._authority}`;
|
||||
|
||||
constructor(
|
||||
private readonly _secretStorage: SecretStorage,
|
||||
private readonly _cloudName: string,
|
||||
private readonly _clientId: string,
|
||||
private readonly _authority: string
|
||||
) {
|
||||
this._disposable = Disposable.from(
|
||||
this._onDidChangeEmitter,
|
||||
this._secretStorage.onDidChange(e => {
|
||||
if (e.key === this._key) {
|
||||
this._onDidChangeEmitter.fire();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async get(): Promise<string[] | undefined> {
|
||||
const value = await this._secretStorage.get(this._key);
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
return JSON.parse(value);
|
||||
}
|
||||
|
||||
store(value: string[]): Thenable<void> {
|
||||
return this._secretStorage.store(this._key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
delete(): Thenable<void> {
|
||||
return this._secretStorage.delete(this._key);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._disposable.dispose();
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,7 @@ export class MsalAuthProvider implements AuthenticationProvider {
|
||||
let result: AuthenticationResult | undefined;
|
||||
|
||||
try {
|
||||
const windowHandle = env.handle ? Buffer.from(env.handle, 'base64') : undefined;
|
||||
result = await cachedPca.acquireTokenInteractive({
|
||||
openBrowser: async (url: string) => { await env.openExternal(Uri.parse(url)); },
|
||||
scopes: scopeData.scopesToSend,
|
||||
@@ -167,7 +168,8 @@ export class MsalAuthProvider implements AuthenticationProvider {
|
||||
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'
|
||||
prompt: options.account?.label ? undefined : 'select_account',
|
||||
windowHandle
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof CancellationError) {
|
||||
@@ -196,12 +198,14 @@ export class MsalAuthProvider implements AuthenticationProvider {
|
||||
// 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 = env.handle ? Buffer.from(env.handle) : 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'
|
||||
prompt: options.account?.label ? undefined : 'select_account',
|
||||
windowHandle
|
||||
});
|
||||
} catch (e) {
|
||||
this._telemetryReporter.sendLoginFailedEvent();
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { PublicClientApplication, AccountInfo, Configuration, SilentFlowRequest, AuthenticationResult, InteractiveRequest, LogLevel } from '@azure/msal-node';
|
||||
import { NativeBrokerPlugin } from '@azure/msal-node-extensions';
|
||||
import { Disposable, Memento, SecretStorage, LogOutputChannel, window, ProgressLocation, l10n, EventEmitter } from 'vscode';
|
||||
import { Delayer, raceCancellationAndTimeoutError } from '../common/async';
|
||||
import { SecretStorageCachePlugin } from '../common/cachePlugin';
|
||||
import { MsalLoggerOptions } from '../common/loggerOptions';
|
||||
import { ICachedPublicClientApplication } from '../common/publicClientCache';
|
||||
import { ScopedAccountAccess } from '../common/accountAccess';
|
||||
|
||||
export class CachedPublicClientApplication implements ICachedPublicClientApplication {
|
||||
private _pca: PublicClientApplication;
|
||||
@@ -24,19 +26,24 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica
|
||||
// Include the prefix as a differentiator to other secrets
|
||||
`pca:${JSON.stringify({ clientId: this._clientId, authority: this._authority })}`
|
||||
);
|
||||
private readonly _accountAccess = new ScopedAccountAccess(this._secretStorage, this._cloudName, this._clientId, this._authority);
|
||||
private readonly _config: Configuration = {
|
||||
auth: { clientId: this._clientId, authority: this._authority },
|
||||
system: {
|
||||
loggerOptions: {
|
||||
correlationId: `${this._clientId}] [${this._authority}`,
|
||||
loggerCallback: (level, message, containsPii) => this._loggerOptions.loggerCallback(level, message, containsPii),
|
||||
logLevel: LogLevel.Trace
|
||||
logLevel: LogLevel.Info
|
||||
}
|
||||
},
|
||||
broker: {
|
||||
nativeBrokerPlugin: new NativeBrokerPlugin()
|
||||
},
|
||||
cache: {
|
||||
cachePlugin: this._secretStorageCachePlugin
|
||||
}
|
||||
};
|
||||
private readonly _isBrokerAvailable = this._config.broker?.nativeBrokerPlugin?.isBrokerAvailable ?? false;
|
||||
|
||||
/**
|
||||
* We keep track of the last time an account was removed so we can recreate the PCA if we detect that an account was removed.
|
||||
@@ -59,6 +66,7 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica
|
||||
constructor(
|
||||
private readonly _clientId: string,
|
||||
private readonly _authority: string,
|
||||
private readonly _cloudName: string,
|
||||
private readonly _globalMemento: Memento,
|
||||
private readonly _secretStorage: SecretStorage,
|
||||
private readonly _logger: LogOutputChannel
|
||||
@@ -76,8 +84,11 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica
|
||||
get clientId(): string { return this._clientId; }
|
||||
get authority(): string { return this._authority; }
|
||||
|
||||
initialize(): Promise<void> {
|
||||
return this._update();
|
||||
async initialize(): Promise<void> {
|
||||
if (this._isBrokerAvailable) {
|
||||
await this._accountAccess.initialize();
|
||||
}
|
||||
await this._update();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
@@ -88,7 +99,7 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica
|
||||
this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${this._authority}] [${request.scopes.join(' ')}] [${request.account.username}] starting...`);
|
||||
const result = await this._sequencer.queue(() => this._pca.acquireTokenSilent(request));
|
||||
this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${this._authority}] [${request.scopes.join(' ')}] [${request.account.username}] got result`);
|
||||
if (result.account && !result.fromCache) {
|
||||
if (result.account && !result.fromCache && this._verifyIfUsingBroker(result)) {
|
||||
this._logger.debug(`[acquireTokenSilent] [${this._clientId}] [${this._authority}] [${request.scopes.join(' ')}] [${request.account.username}] firing event due to change`);
|
||||
this._setupRefresh(result);
|
||||
this._onDidAccountsChangeEmitter.fire({ added: [], changed: [result.account], deleted: [] });
|
||||
@@ -111,18 +122,48 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica
|
||||
)
|
||||
);
|
||||
this._setupRefresh(result);
|
||||
if (this._isBrokerAvailable) {
|
||||
await this._accountAccess.setAllowedAccess(result.account!, true);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
removeAccount(account: AccountInfo): Promise<void> {
|
||||
this._globalMemento.update(`lastRemoval:${this._clientId}:${this._authority}`, new Date());
|
||||
if (this._isBrokerAvailable) {
|
||||
return this._accountAccess.setAllowedAccess(account, false);
|
||||
}
|
||||
return this._pca.getTokenCache().removeAccount(account);
|
||||
}
|
||||
|
||||
private _registerOnSecretStorageChanged() {
|
||||
if (this._isBrokerAvailable) {
|
||||
return this._accountAccess.onDidAccountAccessChange(() => this._update());
|
||||
}
|
||||
return this._secretStorageCachePlugin.onDidChange(() => this._update());
|
||||
}
|
||||
|
||||
private _lastSeen = new Map<string, number>();
|
||||
private _verifyIfUsingBroker(result: AuthenticationResult): boolean {
|
||||
// If we're not brokering, we don't need to verify the date
|
||||
// the cache check will be sufficient
|
||||
if (!result.fromNativeBroker) {
|
||||
return true;
|
||||
}
|
||||
const key = result.account!.homeAccountId;
|
||||
const lastSeen = this._lastSeen.get(key);
|
||||
const lastTimeAuthed = result.account!.idTokenClaims!.iat!;
|
||||
if (!lastSeen) {
|
||||
this._lastSeen.set(key, lastTimeAuthed);
|
||||
return true;
|
||||
}
|
||||
if (lastSeen === lastTimeAuthed) {
|
||||
return false;
|
||||
}
|
||||
this._lastSeen.set(key, lastTimeAuthed);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async _update() {
|
||||
const before = this._accounts;
|
||||
this._logger.debug(`[update] [${this._clientId}] [${this._authority}] CachedPublicClientApplication update before: ${before.length}`);
|
||||
@@ -134,7 +175,10 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica
|
||||
this._lastCreated = new Date();
|
||||
}
|
||||
|
||||
const after = await this._pca.getAllAccounts();
|
||||
let after = await this._pca.getAllAccounts();
|
||||
if (this._isBrokerAvailable) {
|
||||
after = after.filter(a => this._accountAccess.isAllowedAccess(a));
|
||||
}
|
||||
this._accounts = after;
|
||||
this._logger.debug(`[update] [${this._clientId}] [${this._authority}] CachedPublicClientApplication update after: ${after.length}`);
|
||||
|
||||
@@ -167,8 +211,7 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica
|
||||
this._logger.debug(`[_setupRefresh] [${this._clientId}] [${this._authority}] [${scopes.join(' ')}] [${account.username}] timeToRefresh: ${timeToRefresh}`);
|
||||
this._refreshDelayer.trigger(
|
||||
key,
|
||||
// This may need the redirectUri when we switch to the broker
|
||||
() => this.acquireTokenSilent({ account, scopes, redirectUri: undefined, forceRefresh: true }),
|
||||
() => this.acquireTokenSilent({ account, scopes, redirectUri: 'https://vscode.dev/redirect', forceRefresh: true }),
|
||||
timeToRefresh > 0 ? timeToRefresh : 0
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,9 +28,9 @@ export class CachedPublicClientApplicationManager implements ICachedPublicClient
|
||||
private readonly _globalMemento: Memento,
|
||||
private readonly _secretStorage: SecretStorage,
|
||||
private readonly _logger: LogOutputChannel,
|
||||
cloudName: string
|
||||
private readonly _cloudName: string
|
||||
) {
|
||||
this._pcasSecretStorage = new PublicClientApplicationsSecretStorage(_secretStorage, cloudName);
|
||||
this._pcasSecretStorage = new PublicClientApplicationsSecretStorage(_secretStorage, _cloudName);
|
||||
this._disposable = Disposable.from(
|
||||
this._pcasSecretStorage,
|
||||
this._registerSecretStorageHandler(),
|
||||
@@ -111,7 +111,7 @@ export class CachedPublicClientApplicationManager implements ICachedPublicClient
|
||||
}
|
||||
|
||||
private async _doCreatePublicClientApplication(clientId: string, authority: string, pcasKey: string) {
|
||||
const pca = new CachedPublicClientApplication(clientId, authority, this._globalMemento, this._secretStorage, this._logger);
|
||||
const pca = new CachedPublicClientApplication(clientId, authority, this._cloudName, this._globalMemento, this._secretStorage, this._logger);
|
||||
this._pcas.set(pcasKey, pca);
|
||||
const disposable = Disposable.from(
|
||||
pca,
|
||||
@@ -160,11 +160,7 @@ export class CachedPublicClientApplicationManager implements ICachedPublicClient
|
||||
// Handle the deleted ones
|
||||
for (const pcaKey of this._pcas.keys()) {
|
||||
if (!pcaKeysFromStorage.delete(pcaKey)) {
|
||||
// This PCA has been removed in another window
|
||||
this._pcaDisposables.get(pcaKey)?.dispose();
|
||||
this._pcaDisposables.delete(pcaKey);
|
||||
this._pcas.delete(pcaKey);
|
||||
this._logger.debug(`[_handleSecretStorageChange] Disposed PCA that was deleted in another window: ${pcaKey}`);
|
||||
this._logger.debug(`[_handleSecretStorageChange] PCA was deleted in another window: ${pcaKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user