Improvements in access token flows (#17670)

This commit is contained in:
Cheena Malhotra 2023-05-03 09:28:42 -07:00 коммит произвёл GitHub
Родитель e033fbfc00
Коммит e7a978d143
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
8 изменённых файлов: 185 добавлений и 73 удалений

Просмотреть файл

@ -134,6 +134,9 @@
<trans-unit id="azureConsentDialogBody">
<source xml:lang="en">Your tenant '{0} ({1})' requires you to re-authenticate again to access {2} resources. Press Open to start the authentication process.</source>
</trans-unit>
<trans-unit id="azureConsentDialogBodyAccount">
<source xml:lang="en">Your account needs re-authentication to access {0} resources. Press Open to start the authentication process.</source>
</trans-unit>
<trans-unit id="azureMicrosoftCorpAccount">
<source xml:lang="en">Microsoft Corp</source>
</trans-unit>

Просмотреть файл

@ -3,6 +3,8 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { ITenant } from '../models/contracts/azure';
export const serviceName = 'Code';
export const httpConfigSectionName = 'http';
@ -47,6 +49,41 @@ export const azureTenantConfigSection = azureSection + '.' + tenantSection + '.'
export const oldMsalCacheFileName = 'azureTokenCacheMsal-azure_publicCloud';
/////// MSAL ERROR CODES, ref: https://learn.microsoft.com/en-us/azure/active-directory/develop/reference-aadsts-error-codes
/**
* The refresh token has expired or is invalid due to sign-in frequency checks by conditional access.
* The token was issued on {issueDate} and the maximum allowed lifetime for this request is {time}.
*/
export const AADSTS70043 = 'AADSTS70043';
/**
* FreshTokenNeeded - The provided grant has expired due to it being revoked, and a fresh auth token is needed.
* Either an admin or a user revoked the tokens for this user, causing subsequent token refreshes to fail and
* require reauthentication. Have the user sign in again.
*/
export const AADSTS50173 = 'AADSTS50173';
/**
* User account 'user@domain.com' from identity provider {IdentityProviderURL} does not exist in tenant {ResourceTenantName}.
* This error occurs when account is authenticated without a tenant id, which happens when tenant Id is not available in connection profile.
* We have the user sign in again when this error occurs.
*/
export const AADSTS50020 = 'AADSTS50020';
/**
* Error thrown from STS - indicates user account not found in MSAL cache.
* We request user to sign in again.
*/
export const mdsUserAccountNotFound = `User account '{0}' not found in MSAL cache, please add linked account or refresh account credentials.`;
/**
* Error thrown from STS - indicates user account info not received from connection profile.
* This is possible when account info is not available when populating user's preferred name in connection profile.
* We request user to sign in again, to refresh their account credentials.
*/
export const mdsUserAccountNotReceived = 'User account not received.';
/**
* This error is thrown by MSAL when user account is not received in silent authentication request.
* Thrown by TS layer, indicates user account hint not provided. We request user to reauthenticate when this error occurs.
*/
export const noAccountInSilentRequestError = 'no_account_in_silent_request';
/** MSAL Account version */
export const accountVersion = '2.0';
@ -59,6 +96,15 @@ export const s256CodeChallengeMethod = 'S256';
export const selectAccount = 'select_account';
export const commonTenant: ITenant = {
id: 'common',
displayName: 'common'
};
export const organizationTenant: ITenant = {
id: 'organizations',
displayName: 'organizations'
};
/**
* Account issuer as received from access token
*/

Просмотреть файл

@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------------*/
import { Resource } from '@azure/arm-resources';
import { AccountInfo, AuthenticationResult, InteractionRequiredAuthError, PublicClientApplication, SilentFlowRequest } from '@azure/msal-node';
import { AccountInfo, AuthError, AuthenticationResult, InteractionRequiredAuthError, PublicClientApplication, SilentFlowRequest } from '@azure/msal-node';
import * as url from 'url';
import * as vscode from 'vscode';
import * as LocalizedConstants from '../../constants/localizedConstants';
@ -21,8 +21,6 @@ import { HttpClient } from './httpClient';
// tslint:disable:no-null-keyword
export abstract class MsalAzureAuth {
protected readonly loginEndpointUrl: string;
public readonly commonTenant: ITenant;
public readonly organizationTenant: ITenant;
protected readonly redirectUri: string;
protected readonly scopes: string[];
protected readonly scopesString: string;
@ -39,14 +37,6 @@ export abstract class MsalAzureAuth {
protected readonly logger: Logger
) {
this.loginEndpointUrl = this.providerSettings.loginEndpoint ?? 'https://login.microsoftonline.com/';
this.commonTenant = {
id: 'common',
displayName: 'common'
};
this.organizationTenant = {
id: 'organizations',
displayName: 'organizations'
};
// Use localhost for MSAL instead of this.providerSettings.redirectUri (kept as-is for ADAL only);
this.redirectUri = 'http://localhost';
this.clientId = this.providerSettings.clientId;
@ -62,7 +52,7 @@ export abstract class MsalAzureAuth {
if (!this.providerSettings.resources.windowsManagementResource) {
throw new Error(Utils.formatString(LocalizedConstants.azureNoMicrosoftResource, this.providerSettings.displayName));
}
const result = await this.login(this.organizationTenant);
const result = await this.login(Constants.organizationTenant);
loginComplete = result.authComplete;
if (!result?.response || !result.response?.account) {
this.logger.error(`Authentication failed: ${loginComplete}`);
@ -150,7 +140,7 @@ export abstract class MsalAzureAuth {
return await this.clientApplication.acquireTokenSilent(tokenRequest);
} catch (e) {
this.logger.error('Failed to acquireTokenSilent', e);
if (e instanceof InteractionRequiredAuthError) {
if (e instanceof AuthError && this.accountNeedsRefresh(e)) {
// build refresh token request
const tenant: ITenant = {
id: tenantId,
@ -158,13 +148,26 @@ export abstract class MsalAzureAuth {
};
return this.handleInteractionRequired(tenant, settings);
} else if (e.name === 'ClientAuthError') {
this.logger.error(e.message);
this.logger.verbose('[ClientAuthError] Failed to silently acquire token');
}
this.logger.error(`Failed to silently acquire token, not InteractionRequiredAuthError: ${e.message}`);
throw e;
}
}
/**
* Determines whether the account needs to be refreshed based on received error instance
* and STS error codes from errorMessage.
* @param error AuthError instance
*/
private accountNeedsRefresh(error: AuthError): boolean {
return error instanceof InteractionRequiredAuthError
|| error.errorMessage.includes(Constants.AADSTS70043)
|| error.errorMessage.includes(Constants.AADSTS50020)
|| error.errorMessage.includes(Constants.AADSTS50173);
}
public async refreshAccessToken(account: IAccount, tenantId: string, settings: IAADResource): Promise<IAccount | undefined> {
if (account) {
try {
@ -329,7 +332,9 @@ export abstract class MsalAzureAuth {
}
};
const messageBody = Utils.formatString(LocalizedConstants.azureConsentDialogBody, tenant.displayName, tenant.id, settings.id);
const messageBody = (tenant.id === Constants.organizationTenant.id)
? Utils.formatString(LocalizedConstants.azureConsentDialogBody, tenant.displayName, tenant.id, settings.id)
: Utils.formatString(LocalizedConstants.azureConsentDialogBodyAccount, settings.id);
const result = await vscode.window.showInformationMessage(messageBody, { modal: true }, openItem, closeItem, dontAskAgainItem);
if (result?.action) {
@ -362,7 +367,7 @@ export abstract class MsalAzureAuth {
const name = tokenClaims.name ?? tokenClaims.preferred_username ?? tokenClaims.email ?? tokenClaims.unique_name;
const email = tokenClaims.preferred_username ?? tokenClaims.email ?? tokenClaims.unique_name;
let owningTenant: ITenant = this.commonTenant; // default to common tenant
let owningTenant: ITenant = Constants.commonTenant; // default to common tenant
// Read more about tid > https://learn.microsoft.com/azure/active-directory/develop/id-tokens
if (tokenClaims.tid) {

Просмотреть файл

@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { ILoggerCallback, LogLevel as MsalLogLevel } from '@azure/msal-common';
import { ClientAuthError, ILoggerCallback, LogLevel as MsalLogLevel } from '@azure/msal-common';
import { Configuration, PublicClientApplication } from '@azure/msal-node';
import * as Constants from '../../constants/constants';
import * as LocalizedConstants from '../../constants/localizedConstants';
@ -19,7 +19,7 @@ import { MsalAzureDeviceCode } from './msalAzureDeviceCode';
import { MsalCachePluginProvider } from './msalCachePlugin';
import { promises as fsPromises } from 'fs';
import * as path from 'path';
import { oldMsalCacheFileName } from '../constants';
import * as AzureConstants from '../constants';
export class MsalAzureController extends AzureController {
private _authMappings = new Map<AzureAuthType, MsalAzureAuth>();
@ -69,7 +69,7 @@ export class MsalAzureController extends AzureController {
* Clears old cache file that is no longer needed on system.
*/
private async clearOldCacheIfExists(): Promise<void> {
let filePath = path.join(await this.findOrMakeStoragePath(), oldMsalCacheFileName);
let filePath = path.join(await this.findOrMakeStoragePath(), AzureConstants.oldMsalCacheFileName);
try {
await fsPromises.access(filePath);
await fsPromises.rm(filePath);
@ -134,9 +134,10 @@ export class MsalAzureController extends AzureController {
public async refreshAccessToken(account: IAccount, accountStore: AccountStore, tenantId: string | undefined,
settings: IAADResource): Promise<IToken | undefined> {
let newAccount: IAccount;
try {
let azureAuth = await this.getAzureAuthInstance(getAzureActiveDirectoryConfig());
let newAccount = await azureAuth!.refreshAccessToken(account, 'organizations',
newAccount = await azureAuth!.refreshAccessToken(account, AzureConstants.organizationTenant.id,
this._providerSettings.resources.windowsManagementResource);
if (newAccount!.isStale === true) {
@ -145,10 +146,26 @@ export class MsalAzureController extends AzureController {
await accountStore.addAccount(newAccount!);
return await this.getAccountSecurityToken(
account, tenantId!, settings
account, tenantId ?? account.properties.owningTenant.id, settings
);
} catch (ex) {
this._vscodeWrapper.showErrorMessage(ex);
if (ex instanceof ClientAuthError && ex.errorCode === AzureConstants.noAccountInSilentRequestError) {
try {
// Account needs re-authentication
newAccount = await this.login(account.properties.azureAuthType);
if (newAccount!.isStale === true) {
return undefined;
}
await accountStore.addAccount(newAccount!);
return await this.getAccountSecurityToken(
account, tenantId ?? account.properties.owningTenant.id, settings
);
} catch (ex) {
this._vscodeWrapper.showErrorMessage(ex);
}
} else {
this._vscodeWrapper.showErrorMessage(ex);
}
}
}

Просмотреть файл

@ -122,8 +122,7 @@ export class MsalCachePluginProvider {
retryAttempt++;
this._logger.verbose(`MsalCachePlugin: Failed to acquire lock on cache file. Retrying in ${retryWait} ms.`);
// tslint:disable:no-empty
setTimeout(() => { }, retryWait);
await new Promise(resolve => setTimeout(() => resolve, retryWait));
}
}
}

Просмотреть файл

@ -799,12 +799,12 @@ export default class ConnectionManager {
} else {
throw new Error(LocalizedConstants.cannotConnect);
}
// Always set username
connectionCreds.user = account.displayInfo.displayName;
connectionCreds.email = account.displayInfo.email;
profile.user = account.displayInfo.displayName;
profile.email = account.displayInfo.email;
if (!this.azureController.isSqlAuthProviderEnabled()) {
if (account) {
// Always set username
connectionCreds.user = account.displayInfo.displayName;
connectionCreds.email = account.displayInfo.email;
profile.user = account.displayInfo.displayName;
profile.email = account.displayInfo.email;
let azureAccountToken = await this.azureController.refreshAccessToken(account!,
this.accountStore, profile.tenantId, providerSettings.resources.databaseResource!);
if (!azureAccountToken) {
@ -821,6 +821,8 @@ export default class ConnectionManager {
connectionCreds.azureAccountToken = azureAccountToken.token;
connectionCreds.expiresOn = azureAccountToken.expiresOn;
}
} else {
throw new Error(LocalizedConstants.msgAccountNotFound);
}
}
}

Просмотреть файл

@ -231,6 +231,10 @@ export function isSameProfile(currentProfile: IConnectionProfile, expectedProfil
} else if (currentProfile.connectionString || expectedProfile.connectionString) {
// If either profile uses connection strings, compare them directly
return currentProfile.connectionString === expectedProfile.connectionString;
} else if (currentProfile.authenticationType === Constants.azureMfa && expectedProfile.authenticationType === Constants.azureMfa) {
return expectedProfile.server === currentProfile.server
&& isSameDatabase(expectedProfile.database, currentProfile.database)
&& isSameAccountKey(expectedProfile.accountId, currentProfile.accountId);
}
return expectedProfile.server === currentProfile.server
&& isSameDatabase(expectedProfile.database, currentProfile.database)
@ -249,14 +253,20 @@ export function isSameProfile(currentProfile: IConnectionProfile, expectedProfil
*/
export function isSameConnection(conn: IConnectionInfo, expectedConn: IConnectionInfo): boolean {
return (conn.connectionString || expectedConn.connectionString) ? conn.connectionString === expectedConn.connectionString :
expectedConn.server === conn.server
&& isSameDatabase(expectedConn.database, conn.database)
&& isSameAuthenticationType(expectedConn.authenticationType, conn.authenticationType)
&& (conn.authenticationType === Constants.sqlAuthentication ?
conn.user === expectedConn.user :
isEmpty(conn.user) === isEmpty(expectedConn.user))
&& (<IConnectionProfile>conn).savePassword ===
(<IConnectionProfile>expectedConn).savePassword;
// Azure MFA connections
((expectedConn.authenticationType === Constants.azureMfa && conn.authenticationType === Constants.azureMfa)
? expectedConn.server === conn.server
&& isSameDatabase(expectedConn.database, conn.database)
&& isSameAccountKey(expectedConn.accountId, conn.accountId)
// Not Azure MFA connections
: expectedConn.server === conn.server
&& isSameDatabase(expectedConn.database, conn.database)
&& isSameAuthenticationType(expectedConn.authenticationType, conn.authenticationType)
&& (conn.authenticationType === Constants.sqlAuthentication ?
conn.user === expectedConn.user :
isEmpty(conn.user) === isEmpty(expectedConn.user))
&& (<IConnectionProfile>conn).savePassword ===
(<IConnectionProfile>expectedConn).savePassword);
}
/**

Просмотреть файл

@ -26,6 +26,8 @@ import { ConnectionCredentials } from '../models/connectionCredentials';
import { ConnectionProfile } from '../models/connectionProfile';
import providerSettings from '../azure/providerSettings';
import { IConnectionInfo } from 'vscode-mssql';
import { IAccount } from '../models/contracts/azure';
import * as AzureConstants from '../azure/constants';
function getParentNode(node: TreeNodeType): TreeNodeInfo {
node = node.parentNode;
@ -133,18 +135,16 @@ export class ObjectExplorerService {
if (errorNumber === Constants.errorSSLCertificateValidationFailed) {
self._connectionManager.showInstructionTextAsWarning(self._currentNode.connectionInfo,
async updatedProfile => {
self.currentNode.connectionInfo = updatedProfile;
self.updateNode(self._currentNode);
let fileUri = ObjectExplorerUtils.getNodeUri(self._currentNode);
if (await self._connectionManager.connectionStore.saveProfile(updatedProfile as IConnectionProfile)) {
const res = await self._connectionManager.connect(fileUri, updatedProfile);
if (await self._connectionManager.handleConnectionResult(res, fileUri, updatedProfile)) {
self.refreshNode(self._currentNode);
}
} else {
self._connectionManager.vscodeWrapper.showErrorMessage(LocalizedConstants.msgPromptProfileUpdateFailed);
}
self.reconnectProfile(self._currentNode, updatedProfile);
});
} else if (self._currentNode.connectionInfo.authenticationType === Constants.azureMfa
&& self.needsAccountRefresh(result, self._currentNode.connectionInfo.user)) {
let profile = self._currentNode.connectionInfo;
let account = this._connectionManager.accountStore.getAccount(profile.accountId);
await this.refreshAccount(account, profile);
// Do not await when performing reconnect to allow
// OE node to expand after connection is established.
this.reconnectProfile(self._currentNode, profile);
} else {
self._connectionManager.vscodeWrapper.showErrorMessage(error);
}
@ -169,6 +169,29 @@ export class ObjectExplorerService {
return handler;
}
private async reconnectProfile(node: TreeNodeInfo, profile: IConnectionInfo): Promise<void> {
node.connectionInfo = profile;
this.updateNode(node);
let fileUri = ObjectExplorerUtils.getNodeUri(node);
if (await this._connectionManager.connectionStore.saveProfile(profile as IConnectionProfile)) {
const res = await this._connectionManager.connect(fileUri, profile);
if (await this._connectionManager.handleConnectionResult(res, fileUri, profile)) {
this.refreshNode(node);
}
} else {
this._connectionManager.vscodeWrapper.showErrorMessage(LocalizedConstants.msgPromptProfileUpdateFailed);
}
}
private needsAccountRefresh(result: SessionCreatedParameters, username: string): boolean {
let email = username?.includes(' - ') ? username.substring(username.indexOf('-') + 2) : username;
return result.errorMessage.includes(AzureConstants.AADSTS70043)
|| result.errorMessage.includes(AzureConstants.AADSTS50173)
|| result.errorMessage.includes(AzureConstants.AADSTS50020)
|| result.errorMessage.includes(AzureConstants.mdsUserAccountNotReceived)
|| result.errorMessage.includes(Utils.formatString(AzureConstants.mdsUserAccountNotFound, email));
}
private getParentFromExpandParams(params: ExpandParams): TreeNodeInfo | undefined {
for (let key of this._expandParamsToTreeNodeInfoMap.keys()) {
if (key.sessionId === params.sessionId &&
@ -458,38 +481,20 @@ export class ObjectExplorerService {
} else if (connectionCredentials.authenticationType === Constants.azureMfa) {
let azureController = this._connectionManager.azureController;
let account = this._connectionManager.accountStore.getAccount(connectionCredentials.accountId);
let profile = new ConnectionProfile(connectionCredentials);
let needsRefresh: boolean = false;
if (azureController.isSqlAuthProviderEnabled()) {
this._client.logger.verbose('SQL Authentication provider is enabled for Azure MFA connections, skipping token acquiry in extension.');
if (!account) {
needsRefresh = true;
} else if (azureController.isSqlAuthProviderEnabled()) {
connectionCredentials.user = account.displayInfo.displayName;
connectionCredentials.email = account.displayInfo.email;
// Update profile after updating user/email
await this._connectionManager.connectionUI.saveProfile(connectionCredentials as IConnectionProfile);
if (!azureController.isAccountInCache(account)) {
needsRefresh = true;
}
}
if (!connectionCredentials.azureAccountToken && (!azureController.isSqlAuthProviderEnabled() || needsRefresh)) {
let azureAccountToken = await azureController.refreshAccessToken(
account, this._connectionManager.accountStore, connectionCredentials.tenantId, providerSettings.resources.databaseResource);
if (!azureAccountToken) {
this._client.logger.verbose('Access token could not be refreshed for connection profile.');
let errorMessage = LocalizedConstants.msgAccountRefreshFailed;
await this._connectionManager.vscodeWrapper.showErrorMessage(
errorMessage, LocalizedConstants.refreshTokenLabel).then(async result => {
if (result === LocalizedConstants.refreshTokenLabel) {
let updatedProfile = await azureController.populateAccountProperties(
profile, this._connectionManager.accountStore, providerSettings.resources.databaseResource);
connectionCredentials.azureAccountToken = updatedProfile.azureAccountToken;
connectionCredentials.expiresOn = updatedProfile.expiresOn;
} else {
this._client.logger.error('Credentials not refreshed by user.');
return undefined;
}
});
} else {
connectionCredentials.azureAccountToken = azureAccountToken.token;
connectionCredentials.expiresOn = azureAccountToken.expiresOn;
}
this.refreshAccount(account, connectionCredentials);
}
}
}
@ -510,6 +515,31 @@ export class ObjectExplorerService {
}
}
private async refreshAccount(account: IAccount, connectionCredentials: ConnectionCredentials): Promise<void> {
let azureController = this._connectionManager.azureController;
let profile = new ConnectionProfile(connectionCredentials);
let azureAccountToken = await azureController.refreshAccessToken(
account, this._connectionManager.accountStore, connectionCredentials.tenantId, providerSettings.resources.databaseResource);
if (!azureAccountToken) {
this._client.logger.verbose('Access token could not be refreshed for connection profile.');
let errorMessage = LocalizedConstants.msgAccountRefreshFailed;
await this._connectionManager.vscodeWrapper.showErrorMessage(
errorMessage, LocalizedConstants.refreshTokenLabel).then(async result => {
if (result === LocalizedConstants.refreshTokenLabel) {
let updatedProfile = await azureController.populateAccountProperties(
profile, this._connectionManager.accountStore, providerSettings.resources.databaseResource);
connectionCredentials.azureAccountToken = updatedProfile.azureAccountToken;
connectionCredentials.expiresOn = updatedProfile.expiresOn;
} else {
this._client.logger.error('Credentials not refreshed by user.');
return undefined;
}
});
} else {
connectionCredentials.azureAccountToken = azureAccountToken.token;
connectionCredentials.expiresOn = azureAccountToken.expiresOn;
}
}
public getConnectionCredentials(sessionId: string): IConnectionInfo {
if (this._sessionIdToConnectionCredentialsMap.has(sessionId)) {
return this._sessionIdToConnectionCredentialsMap.get(sessionId);