Add device code flow when not brokered (#270453)

fixes https://github.com/microsoft/vscode/issues/270452
This commit is contained in:
Tyler James Leonhardt
2025-10-08 16:49:23 -07:00
committed by GitHub
parent f0e1593e4f
commit d751a3d55f
7 changed files with 223 additions and 26 deletions

View File

@@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { AccountInfo, AuthenticationResult, ClientAuthError, ClientAuthErrorCodes, ServerError, SilentFlowRequest } from '@azure/msal-node';
import { AuthenticationChallenge, AuthenticationConstraint, AuthenticationGetSessionOptions, AuthenticationProvider, AuthenticationProviderAuthenticationSessionsChangeEvent, AuthenticationProviderSessionOptions, AuthenticationSession, AuthenticationSessionAccountInformation, CancellationError, EventEmitter, ExtensionContext, ExtensionKind, l10n, LogOutputChannel, Uri, window } from 'vscode';
import { AuthenticationChallenge, AuthenticationConstraint, AuthenticationGetSessionOptions, AuthenticationProvider, AuthenticationProviderAuthenticationSessionsChangeEvent, AuthenticationProviderSessionOptions, AuthenticationSession, AuthenticationSessionAccountInformation, CancellationError, env, EventEmitter, ExtensionContext, ExtensionKind, l10n, LogOutputChannel, Uri, window } from 'vscode';
import { Environment } from '@azure/ms-rest-azure-env';
import { CachedPublicClientApplicationManager } from './publicClientCache';
import { UriEventHandler } from '../UriEventHandler';
@@ -16,7 +16,7 @@ import { IStoredSession } from '../AADHelper';
import { ExtensionHost, getMsalFlows } from './flows';
import { base64Decode } from './buffer';
import { Config } from '../common/config';
import { DEFAULT_REDIRECT_URI } from '../common/env';
import { isSupportedClient } from '../common/env';
const MSA_TID = '9188040d-6c67-4c5b-b112-36a304b66dad';
const MSA_PASSTHRU_TID = 'f8cdef31-a31e-4b4a-93e4-5f571e91255a';
@@ -210,10 +210,12 @@ export class MsalAuthProvider implements AuthenticationProvider {
};
const isNodeEnvironment = typeof process !== 'undefined' && typeof process?.versions?.node === 'string';
const callbackUri = await env.asExternalUri(Uri.parse(`${env.uriScheme}://vscode.microsoft-authentication`));
const flows = getMsalFlows({
extensionHost: isNodeEnvironment
? this._context.extension.extensionKind === ExtensionKind.UI ? ExtensionHost.Local : ExtensionHost.Remote
: ExtensionHost.WebWorker,
supportedClient: isSupportedClient(callbackUri),
isBrokerSupported: cachedPca.isBrokerAvailable
});
@@ -235,7 +237,8 @@ export class MsalAuthProvider implements AuthenticationProvider {
loginHint: options.account?.label,
windowHandle: window.nativeHandle ? Buffer.from(window.nativeHandle) : undefined,
logger: this._logger,
uriHandler: this._uriHandler
uriHandler: this._uriHandler,
callbackUri
});
const session = this.sessionFromAuthenticationResult(result, scopeData.originalScopes);
@@ -346,11 +349,13 @@ export class MsalAuthProvider implements AuthenticationProvider {
};
const isNodeEnvironment = typeof process !== 'undefined' && typeof process?.versions?.node === 'string';
const callbackUri = await env.asExternalUri(Uri.parse(`${env.uriScheme}://vscode.microsoft-authentication`));
const flows = getMsalFlows({
extensionHost: isNodeEnvironment
? this._context.extension.extensionKind === ExtensionKind.UI ? ExtensionHost.Local : ExtensionHost.Remote
: ExtensionHost.WebWorker,
isBrokerSupported: cachedPca.isBrokerAvailable
isBrokerSupported: cachedPca.isBrokerAvailable,
supportedClient: isSupportedClient(callbackUri)
});
const authority = new URL(scopeData.tenant, this._env.activeDirectoryEndpointUrl).toString();
@@ -373,7 +378,8 @@ export class MsalAuthProvider implements AuthenticationProvider {
windowHandle: window.nativeHandle ? Buffer.from(window.nativeHandle) : undefined,
logger: this._logger,
uriHandler: this._uriHandler,
claims: scopeData.claims
claims: scopeData.claims,
callbackUri
};
const result = await flow.trigger(authRequest);

View File

@@ -3,10 +3,10 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { PublicClientApplication, AccountInfo, SilentFlowRequest, AuthenticationResult, InteractiveRequest, LogLevel, RefreshTokenRequest, BrokerOptions } from '@azure/msal-node';
import { PublicClientApplication, AccountInfo, SilentFlowRequest, AuthenticationResult, InteractiveRequest, LogLevel, RefreshTokenRequest, BrokerOptions, DeviceCodeRequest } from '@azure/msal-node';
import { NativeBrokerPlugin } from '@azure/msal-node-extensions';
import { Disposable, SecretStorage, LogOutputChannel, window, ProgressLocation, l10n, EventEmitter, workspace } from 'vscode';
import { raceCancellationAndTimeoutError } from '../common/async';
import { Disposable, SecretStorage, LogOutputChannel, window, ProgressLocation, l10n, EventEmitter, workspace, env, Uri, UIKind } from 'vscode';
import { DeferredPromise, raceCancellationAndTimeoutError } from '../common/async';
import { SecretStorageCachePlugin } from '../common/cachePlugin';
import { MsalLoggerOptions } from '../common/loggerOptions';
import { ICachedPublicClientApplication } from '../common/publicClientCache';
@@ -53,6 +53,8 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica
let broker: BrokerOptions | undefined;
if (process.platform !== 'win32' && process.platform !== 'darwin') {
this._logger.info(`[${this._clientId}] Native Broker is only available on Windows and macOS`);
} else if (env.uiKind === UIKind.Web) {
this._logger.info(`[${this._clientId}] Native Broker is not available in web UI`);
} else if (workspace.getConfiguration('microsoft-authentication').get<'msal' | 'msal-no-broker'>('implementation') === 'msal-no-broker') {
this._logger.info(`[${this._clientId}] Native Broker disabled via settings`);
} else {
@@ -228,6 +230,81 @@ export class CachedPublicClientApplication implements ICachedPublicClientApplica
return result;
}
async acquireTokenByDeviceCode(request: Omit<DeviceCodeRequest, 'deviceCodeCallback'>): Promise<AuthenticationResult | null> {
this._logger.debug(`[acquireTokenByDeviceCode] [${this._clientId}] [${request.authority}] [${request.scopes.join(' ')}]`);
const result = await this._sequencer.queue(async () => {
const deferredPromise = new DeferredPromise<AuthenticationResult | null>();
const result = await Promise.race([
this._pca.acquireTokenByDeviceCode({
...request,
deviceCodeCallback: (response) => void this._deviceCodeCallback(response, deferredPromise)
}),
deferredPromise.p
]);
await deferredPromise.complete(result);
// Force an update so that the account cache is updated.
// TODO:@TylerLeonhardt The problem is, we use the sequencer for
// change events but we _don't_ use it for the accounts cache.
// We should probably use it for the accounts cache as well.
await this._update();
return result;
});
if (result) {
if (this.isBrokerAvailable && result.account) {
await this._accountAccess.setAllowedAccess(result.account, true);
}
}
return result;
}
private async _deviceCodeCallback(
// MSAL doesn't expose this type...
response: Parameters<DeviceCodeRequest['deviceCodeCallback']>[0],
deferredPromise: DeferredPromise<AuthenticationResult | null>
): Promise<void> {
const button = l10n.t('Copy & Continue to Microsoft');
const modalResult = await window.showInformationMessage(
l10n.t({ message: 'Your Code: {0}', args: [response.userCode], comment: ['The {0} will be a code, e.g. 123-456'] }),
{
modal: true,
detail: l10n.t('To finish authenticating, navigate to Microsoft and paste in the above one-time code.')
}, button);
if (modalResult !== button) {
this._logger.debug(`[deviceCodeCallback] [${this._clientId}] User cancelled the device code flow.`);
deferredPromise.cancel();
return;
}
await env.clipboard.writeText(response.userCode);
await env.openExternal(Uri.parse(response.verificationUri));
await window.withProgress<void>({
location: ProgressLocation.Notification,
cancellable: true,
title: l10n.t({
message: 'Open [{0}]({0}) in a new tab and paste your one-time code: {1}',
args: [response.verificationUri, response.userCode],
comment: [
'The [{0}]({0}) will be a url and the {1} will be a code, e.g. 123456',
'{Locked="[{0}]({0})"}'
]
})
}, async (_, token) => {
const disposable = token.onCancellationRequested(() => {
this._logger.debug(`[deviceCodeCallback] [${this._clientId}] Device code flow cancelled by user.`);
deferredPromise.cancel();
});
try {
await deferredPromise.p;
this._logger.debug(`[deviceCodeCallback] [${this._clientId}] Device code flow completed successfully.`);
} catch (error) {
// Ignore errors here, they are handled at a higher scope
} finally {
disposable.dispose();
}
});
}
removeAccount(account: AccountInfo): Promise<void> {
if (this.isBrokerAvailable) {
return this._accountAccess.setAllowedAccess(account, false);

View File

@@ -22,12 +22,15 @@ export const enum ExtensionHost {
interface IMsalFlowOptions {
supportsRemoteExtensionHost: boolean;
supportsWebWorkerExtensionHost: boolean;
supportsUnsupportedClient: boolean;
supportsBroker: boolean;
}
interface IMsalFlowTriggerOptions {
cachedPca: ICachedPublicClientApplication;
authority: string;
scopes: string[];
callbackUri: Uri;
loginHint?: string;
windowHandle?: Buffer;
logger: LogOutputChannel;
@@ -45,7 +48,9 @@ class DefaultLoopbackFlow implements IMsalFlow {
label = 'default';
options: IMsalFlowOptions = {
supportsRemoteExtensionHost: false,
supportsWebWorkerExtensionHost: false
supportsWebWorkerExtensionHost: false,
supportsUnsupportedClient: true,
supportsBroker: true
};
async trigger({ cachedPca, authority, scopes, claims, loginHint, windowHandle, logger }: IMsalFlowTriggerOptions): Promise<AuthenticationResult> {
@@ -73,12 +78,14 @@ class UrlHandlerFlow implements IMsalFlow {
label = 'protocol handler';
options: IMsalFlowOptions = {
supportsRemoteExtensionHost: true,
supportsWebWorkerExtensionHost: false
supportsWebWorkerExtensionHost: false,
supportsUnsupportedClient: false,
supportsBroker: false
};
async trigger({ cachedPca, authority, scopes, claims, loginHint, windowHandle, logger, uriHandler }: IMsalFlowTriggerOptions): Promise<AuthenticationResult> {
async trigger({ cachedPca, authority, scopes, claims, loginHint, windowHandle, logger, uriHandler, callbackUri }: IMsalFlowTriggerOptions): Promise<AuthenticationResult> {
logger.info('Trying protocol handler flow...');
const loopbackClient = new UriHandlerLoopbackClient(uriHandler, DEFAULT_REDIRECT_URI, logger);
const loopbackClient = new UriHandlerLoopbackClient(uriHandler, DEFAULT_REDIRECT_URI, callbackUri, logger);
let redirectUri: string | undefined;
if (cachedPca.isBrokerAvailable && process.platform === 'darwin') {
redirectUri = Config.macOSBrokerRedirectUri;
@@ -97,13 +104,34 @@ class UrlHandlerFlow implements IMsalFlow {
}
}
class DeviceCodeFlow implements IMsalFlow {
label = 'device code';
options: IMsalFlowOptions = {
supportsRemoteExtensionHost: true,
supportsWebWorkerExtensionHost: false,
supportsUnsupportedClient: true,
supportsBroker: false
};
async trigger({ cachedPca, authority, scopes, claims, logger }: IMsalFlowTriggerOptions): Promise<AuthenticationResult> {
logger.info('Trying device code flow...');
const result = await cachedPca.acquireTokenByDeviceCode({ scopes, authority, claims });
if (!result) {
throw new Error('Device code flow did not return a result');
}
return result;
}
}
const allFlows: IMsalFlow[] = [
new DefaultLoopbackFlow(),
new UrlHandlerFlow()
new UrlHandlerFlow(),
new DeviceCodeFlow()
];
export interface IMsalFlowQuery {
extensionHost: ExtensionHost;
supportedClient: boolean;
isBrokerSupported: boolean;
}
@@ -119,12 +147,10 @@ export function getMsalFlows(query: IMsalFlowQuery): IMsalFlow[] {
useFlow &&= flow.options.supportsWebWorkerExtensionHost;
break;
}
useFlow &&= flow.options.supportsBroker || !query.isBrokerSupported;
useFlow &&= flow.options.supportsUnsupportedClient || query.supportedClient;
if (useFlow) {
flows.push(flow);
if (query.isBrokerSupported) {
// If broker is supported, only use the first valid flow
return flows;
}
}
}
return flows;

View File

@@ -0,0 +1,89 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as assert from 'assert';
import { getMsalFlows, ExtensionHost, IMsalFlowQuery } from '../flows';
suite('getMsalFlows', () => {
test('should return all flows for local extension host with supported client and no broker', () => {
const query: IMsalFlowQuery = {
extensionHost: ExtensionHost.Local,
supportedClient: true,
isBrokerSupported: false
};
const flows = getMsalFlows(query);
assert.strictEqual(flows.length, 3);
assert.strictEqual(flows[0].label, 'default');
assert.strictEqual(flows[1].label, 'protocol handler');
assert.strictEqual(flows[2].label, 'device code');
});
test('should return only default flow for local extension host with supported client and broker', () => {
const query: IMsalFlowQuery = {
extensionHost: ExtensionHost.Local,
supportedClient: true,
isBrokerSupported: true
};
const flows = getMsalFlows(query);
assert.strictEqual(flows.length, 1);
assert.strictEqual(flows[0].label, 'default');
});
test('should return protocol handler and device code flows for remote extension host with supported client and no broker', () => {
const query: IMsalFlowQuery = {
extensionHost: ExtensionHost.Remote,
supportedClient: true,
isBrokerSupported: false
};
const flows = getMsalFlows(query);
assert.strictEqual(flows.length, 2);
assert.strictEqual(flows[0].label, 'protocol handler');
assert.strictEqual(flows[1].label, 'device code');
});
test('should return no flows for web worker extension host', () => {
const query: IMsalFlowQuery = {
extensionHost: ExtensionHost.WebWorker,
supportedClient: true,
isBrokerSupported: false
};
const flows = getMsalFlows(query);
assert.strictEqual(flows.length, 0);
});
test('should return only default and device code flows for local extension host with unsupported client and no broker', () => {
const query: IMsalFlowQuery = {
extensionHost: ExtensionHost.Local,
supportedClient: false,
isBrokerSupported: false
};
const flows = getMsalFlows(query);
assert.strictEqual(flows.length, 2);
assert.strictEqual(flows[0].label, 'default');
assert.strictEqual(flows[1].label, 'device code');
});
test('should return only device code flow for remote extension host with unsupported client and no broker', () => {
const query: IMsalFlowQuery = {
extensionHost: ExtensionHost.Remote,
supportedClient: false,
isBrokerSupported: false
};
const flows = getMsalFlows(query);
assert.strictEqual(flows.length, 1);
assert.strictEqual(flows[0].label, 'device code');
});
test('should return default flow for local extension host with unsupported client and broker', () => {
const query: IMsalFlowQuery = {
extensionHost: ExtensionHost.Local,
supportedClient: false,
isBrokerSupported: true
};
const flows = getMsalFlows(query);
assert.strictEqual(flows.length, 1);
assert.strictEqual(flows[0].label, 'default');
});
});