mirror of
https://github.com/ckaczor/azuredatastudio.git
synced 2026-03-28 15:50:29 -04:00
VSCode serialization changed rejection handling to only serialize errors, which caused things to break - Changed to return either account info or a cancel message in the resolve - Rewrote to use promises. Tracking how to return canceled through 4+ thenables was way trickier than just using a promise - Updated unit tests to handle new scenario - Tested integration tests, realized they a) didn't run and b) didn't passed. - Added vscode dev dependency to fix run issue - Fixed tests to account for behavior changes in tree state.
446 lines
16 KiB
TypeScript
446 lines
16 KiB
TypeScript
/*---------------------------------------------------------------------------------------------
|
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
|
*--------------------------------------------------------------------------------------------*/
|
|
|
|
import * as adal from 'adal-node';
|
|
import * as azdata from 'azdata';
|
|
import * as request from 'request';
|
|
import * as nls from 'vscode-nls';
|
|
import * as vscode from 'vscode';
|
|
import * as url from 'url';
|
|
import {
|
|
AzureAccount,
|
|
AzureAccountProviderMetadata,
|
|
AzureAccountSecurityTokenCollection,
|
|
Tenant
|
|
} from './interfaces';
|
|
import TokenCache from './tokenCache';
|
|
|
|
const localize = nls.loadMessageBundle();
|
|
|
|
export class AzureAccountProvider implements azdata.AccountProvider {
|
|
// CONSTANTS ///////////////////////////////////////////////////////////
|
|
private static WorkSchoolAccountType: string = 'work_school';
|
|
private static MicrosoftAccountType: string = 'microsoft';
|
|
private static AadCommonTenant: string = 'common';
|
|
|
|
// MEMBER VARIABLES ////////////////////////////////////////////////////
|
|
private _autoOAuthCancelled: boolean;
|
|
private _commonAuthorityUrl: string;
|
|
private _inProgressAutoOAuth: InProgressAutoOAuth;
|
|
private _isInitialized: boolean;
|
|
|
|
constructor(private _metadata: AzureAccountProviderMetadata, private _tokenCache: TokenCache) {
|
|
this._autoOAuthCancelled = false;
|
|
this._inProgressAutoOAuth = null;
|
|
this._isInitialized = false;
|
|
|
|
this._commonAuthorityUrl = url.resolve(this._metadata.settings.host, AzureAccountProvider.AadCommonTenant);
|
|
}
|
|
|
|
// PUBLIC METHODS //////////////////////////////////////////////////////
|
|
public autoOAuthCancelled(): Thenable<void> {
|
|
return this.doIfInitialized(() => this.cancelAutoOAuth());
|
|
}
|
|
|
|
/**
|
|
* Clears all tokens that belong to the given account from the token cache
|
|
* @param accountKey Key identifying the account to delete tokens for
|
|
* @returns Promise to clear requested tokens from the token cache
|
|
*/
|
|
public clear(accountKey: azdata.AccountKey): Thenable<void> {
|
|
return this.doIfInitialized(() => this.clearAccountTokens(accountKey));
|
|
}
|
|
|
|
/**
|
|
* Clears the entire token cache. Invoked by command palette action.
|
|
* @returns Promise to clear the token cache
|
|
*/
|
|
public clearTokenCache(): Thenable<void> {
|
|
return this._tokenCache.clear();
|
|
}
|
|
|
|
public getSecurityToken(account: AzureAccount, resource: azdata.AzureResource): Thenable<AzureAccountSecurityTokenCollection> {
|
|
return this.doIfInitialized(() => this.getAccessTokens(account, resource));
|
|
}
|
|
|
|
public initialize(restoredAccounts: azdata.Account[]): Thenable<azdata.Account[]> {
|
|
let self = this;
|
|
|
|
let rehydrationTasks: Thenable<azdata.Account>[] = [];
|
|
for (let account of restoredAccounts) {
|
|
// Purge any invalid accounts
|
|
if (!account) {
|
|
continue;
|
|
}
|
|
|
|
// Refresh the contextual logo based on whether the account is a MS account
|
|
account.displayInfo.accountType = account.properties.isMsAccount
|
|
? AzureAccountProvider.MicrosoftAccountType
|
|
: AzureAccountProvider.WorkSchoolAccountType;
|
|
|
|
// Attempt to get fresh tokens. If this fails then the account is stale.
|
|
// NOTE: Based on ADAL implementation, getting tokens should use the refresh token if necessary
|
|
let task = this.getAccessTokens(account, azdata.AzureResource.ResourceManagement)
|
|
.then(
|
|
() => {
|
|
return account;
|
|
},
|
|
() => {
|
|
account.isStale = true;
|
|
return account;
|
|
}
|
|
);
|
|
rehydrationTasks.push(task);
|
|
}
|
|
|
|
// Collect the rehydration tasks and mark the provider as initialized
|
|
return Promise.all(rehydrationTasks)
|
|
.then(accounts => {
|
|
self._isInitialized = true;
|
|
return accounts;
|
|
});
|
|
}
|
|
|
|
public prompt(): Thenable<AzureAccount | azdata.PromptFailedResult> {
|
|
return this.doIfInitialized(() => this.signIn(true));
|
|
}
|
|
|
|
public refresh(account: AzureAccount): Thenable<AzureAccount | azdata.PromptFailedResult> {
|
|
return this.doIfInitialized(() => this.signIn(false));
|
|
}
|
|
|
|
// PRIVATE METHODS /////////////////////////////////////////////////////
|
|
private cancelAutoOAuth(): Promise<void> {
|
|
let self = this;
|
|
|
|
if (!this._inProgressAutoOAuth) {
|
|
console.warn('Attempted to cancel auto OAuth when auto OAuth is not in progress!');
|
|
return Promise.resolve();
|
|
}
|
|
|
|
// Indicate oauth was cancelled by the user
|
|
let inProgress = self._inProgressAutoOAuth;
|
|
self._autoOAuthCancelled = true;
|
|
self._inProgressAutoOAuth = null;
|
|
|
|
// Use the auth context that was originally used to open the polling request, and cancel the polling
|
|
let context = inProgress.context;
|
|
context.cancelRequestToGetTokenWithDeviceCode(inProgress.userCodeInfo, err => {
|
|
// Callback is only called in failure scenarios.
|
|
if (err) {
|
|
console.warn(`Error while cancelling auto OAuth: ${err}`);
|
|
}
|
|
});
|
|
|
|
return Promise.resolve();
|
|
}
|
|
|
|
private async clearAccountTokens(accountKey: azdata.AccountKey): Promise<void> {
|
|
// Put together a query to look up any tokens associated with the account key
|
|
let query = <adal.TokenResponse>{ userId: accountKey.accountId };
|
|
|
|
// 1) Look up the tokens associated with the query
|
|
// 2) Remove them
|
|
let results = await this._tokenCache.findThenable(query);
|
|
this._tokenCache.removeThenable(results);
|
|
}
|
|
|
|
private doIfInitialized<T>(op: () => Promise<T>): Promise<T> {
|
|
return this._isInitialized
|
|
? op()
|
|
: Promise.reject(localize('accountProviderNotInitialized', 'Account provider not initialized, cannot perform action'));
|
|
}
|
|
|
|
private getAccessTokens(account: AzureAccount, resource: azdata.AzureResource): Promise<AzureAccountSecurityTokenCollection> {
|
|
let self = this;
|
|
|
|
const resourceIdMap = new Map<azdata.AzureResource, string>([
|
|
[azdata.AzureResource.ResourceManagement, self._metadata.settings.armResource.id],
|
|
[azdata.AzureResource.Sql, self._metadata.settings.sqlResource.id]
|
|
]);
|
|
|
|
let accessTokenPromises: Thenable<void>[] = [];
|
|
let tokenCollection: AzureAccountSecurityTokenCollection = {};
|
|
for (let tenant of account.properties.tenants) {
|
|
let promise = new Promise<void>((resolve, reject) => {
|
|
let authorityUrl = url.resolve(self._metadata.settings.host, tenant.id);
|
|
let context = new adal.AuthenticationContext(authorityUrl, null, self._tokenCache);
|
|
|
|
context.acquireToken(
|
|
resourceIdMap.get(resource),
|
|
tenant.userId,
|
|
self._metadata.settings.clientId,
|
|
(error: Error, response: adal.TokenResponse | adal.ErrorResponse) => {
|
|
// Handle errors first
|
|
if (error) {
|
|
// TODO: We'll assume for now that the account is stale, though that might not be accurate
|
|
account.isStale = true;
|
|
azdata.accounts.accountUpdated(account);
|
|
|
|
reject(error);
|
|
return;
|
|
}
|
|
|
|
// We know that the response was not an error
|
|
let tokenResponse = <adal.TokenResponse>response;
|
|
|
|
// Generate a token object and add it to the collection
|
|
tokenCollection[tenant.id] = {
|
|
expiresOn: tokenResponse.expiresOn,
|
|
resource: tokenResponse.resource,
|
|
token: tokenResponse.accessToken,
|
|
tokenType: tokenResponse.tokenType
|
|
};
|
|
resolve();
|
|
}
|
|
);
|
|
});
|
|
accessTokenPromises.push(promise);
|
|
}
|
|
|
|
// Wait until all the tokens have been acquired then return the collection
|
|
return Promise.all(accessTokenPromises)
|
|
.then(() => tokenCollection);
|
|
}
|
|
|
|
private getDeviceLoginUserCode(): Thenable<InProgressAutoOAuth> {
|
|
let self = this;
|
|
|
|
// Create authentication context and acquire user code
|
|
return new Promise<InProgressAutoOAuth>((resolve, reject) => {
|
|
let context = new adal.AuthenticationContext(self._commonAuthorityUrl, null, self._tokenCache);
|
|
context.acquireUserCode(self._metadata.settings.signInResourceId, self._metadata.settings.clientId, vscode.env.language,
|
|
(err, response) => {
|
|
if (err) {
|
|
reject(err);
|
|
} else {
|
|
let result: InProgressAutoOAuth = {
|
|
context: context,
|
|
userCodeInfo: response
|
|
};
|
|
|
|
resolve(result);
|
|
}
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
private getDeviceLoginToken(oAuth: InProgressAutoOAuth, isAddAccount: boolean): Thenable<adal.TokenResponse | azdata.PromptFailedResult> {
|
|
let self = this;
|
|
|
|
// 1) Open the auto OAuth dialog
|
|
// 2) Begin the acquiring token polling
|
|
// 3) When that completes via callback, close the auto oauth
|
|
let title = isAddAccount ?
|
|
localize('addAccount', 'Add {0} account', self._metadata.displayName) :
|
|
localize('refreshAccount', 'Refresh {0} account', self._metadata.displayName);
|
|
return azdata.accounts.beginAutoOAuthDeviceCode(self._metadata.id, title, oAuth.userCodeInfo.message, oAuth.userCodeInfo.userCode, oAuth.userCodeInfo.verificationUrl)
|
|
.then(() => {
|
|
return new Promise<adal.TokenResponse | azdata.PromptFailedResult>((resolve, reject) => {
|
|
let context = oAuth.context;
|
|
context.acquireTokenWithDeviceCode(self._metadata.settings.signInResourceId, self._metadata.settings.clientId, oAuth.userCodeInfo,
|
|
(err, response) => {
|
|
if (err) {
|
|
if (self._autoOAuthCancelled) {
|
|
let result: azdata.PromptFailedResult = { canceled: true };
|
|
// Auto OAuth was cancelled by the user, indicate this with the error we return
|
|
resolve(result);
|
|
} else {
|
|
// Auto OAuth failed for some other reason
|
|
azdata.accounts.endAutoOAuthDeviceCode();
|
|
reject(err);
|
|
}
|
|
} else {
|
|
azdata.accounts.endAutoOAuthDeviceCode();
|
|
resolve(<adal.TokenResponse>response);
|
|
}
|
|
|
|
}
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
private getTenants(userId: string, homeTenant: string): Thenable<Tenant[]> {
|
|
let self = this;
|
|
|
|
// 1) Get a token we can use for looking up the tenant IDs
|
|
// 2) Send a request to the ARM endpoint (the root management API) to get the list of tenant IDs
|
|
// 3) For all the tenants
|
|
// b) Get a token we can use for the AAD Graph API
|
|
// a) Get the display name of the tenant
|
|
// c) create a tenant object
|
|
// 4) Sort to make sure the "home tenant" is the first tenant on the list
|
|
return this.getToken(userId, AzureAccountProvider.AadCommonTenant, this._metadata.settings.armResource.id)
|
|
.then((armToken: adal.TokenResponse) => {
|
|
let tenantUri = url.resolve(self._metadata.settings.armResource.endpoint, 'tenants?api-version=2015-01-01');
|
|
return self.makeWebRequest(armToken, tenantUri);
|
|
})
|
|
.then((tenantResponse: any[]) => {
|
|
let promises: Thenable<Tenant>[] = tenantResponse.map(value => {
|
|
return self.getToken(userId, value.tenantId, self._metadata.settings.graphResource.id)
|
|
.then((graphToken: adal.TokenResponse) => {
|
|
let tenantDetailsUri = url.resolve(self._metadata.settings.graphResource.endpoint, value.tenantId + '/');
|
|
tenantDetailsUri = url.resolve(tenantDetailsUri, 'tenantDetails?api-version=2013-04-05');
|
|
return self.makeWebRequest(graphToken, tenantDetailsUri);
|
|
})
|
|
.then((tenantDetails: any) => {
|
|
return <Tenant>{
|
|
id: value.tenantId,
|
|
userId: userId,
|
|
displayName: tenantDetails.length && tenantDetails[0].displayName
|
|
? tenantDetails[0].displayName
|
|
: localize('azureWorkAccountDisplayName', 'Work or school account')
|
|
};
|
|
});
|
|
});
|
|
|
|
return Promise.all(promises);
|
|
})
|
|
.then((tenants: Tenant[]) => {
|
|
let homeTenantIndex = tenants.findIndex(tenant => tenant.id === homeTenant);
|
|
if (homeTenantIndex >= 0) {
|
|
let homeTenant = tenants.splice(homeTenantIndex, 1);
|
|
tenants.unshift(homeTenant[0]);
|
|
}
|
|
return tenants;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Retrieves a token for the given user ID for the specific tenant ID. If the token can, it
|
|
* will be retrieved from the cache as per the ADAL API. AFAIK, the ADAL API will also utilize
|
|
* the refresh token if there aren't any unexpired tokens to use.
|
|
* @param userId ID of the user to get a token for
|
|
* @param tenantId Tenant to get the token for
|
|
* @param resourceId ID of the resource the token will be good for
|
|
* @returns Promise to return a token. Rejected if retrieving the token fails.
|
|
*/
|
|
private getToken(userId: string, tenantId: string, resourceId: string): Thenable<adal.TokenResponse> {
|
|
let self = this;
|
|
|
|
return new Promise<adal.TokenResponse>((resolve, reject) => {
|
|
let authorityUrl = url.resolve(self._metadata.settings.host, tenantId);
|
|
let context = new adal.AuthenticationContext(authorityUrl, null, self._tokenCache);
|
|
context.acquireToken(resourceId, userId, self._metadata.settings.clientId,
|
|
(error: Error, response: adal.TokenResponse | adal.ErrorResponse) => {
|
|
if (error) {
|
|
reject(error);
|
|
} else {
|
|
resolve(<adal.TokenResponse>response);
|
|
}
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Performs a web request using the provided bearer token
|
|
* @param accessToken Bearer token for accessing the provided URI
|
|
* @param uri URI to access
|
|
* @returns Promise to return the deserialized body of the request. Rejected if error occurred.
|
|
*/
|
|
private makeWebRequest(accessToken: adal.TokenResponse, uri: string): Thenable<any> {
|
|
return new Promise<any>((resolve, reject) => {
|
|
// Setup parameters for the request
|
|
// NOTE: setting json true means the returned object will be deserialized
|
|
let params = {
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${accessToken.accessToken}`
|
|
},
|
|
json: true
|
|
};
|
|
|
|
// Setup the callback to resolve/reject this promise
|
|
const callback: request.RequestCallback = (error, response, body: { error: any; value: any; }) => {
|
|
if (error || body.error) {
|
|
reject(error || JSON.stringify(body.error));
|
|
} else {
|
|
resolve(body.value);
|
|
}
|
|
};
|
|
|
|
// Make the request
|
|
request.get(uri, params, callback);
|
|
});
|
|
}
|
|
|
|
private isPromptFailed(value: adal.TokenResponse | azdata.PromptFailedResult): value is azdata.PromptFailedResult {
|
|
return value && (<azdata.PromptFailedResult>value).canceled;
|
|
}
|
|
|
|
private async signIn(isAddAccount: boolean): Promise<AzureAccount | azdata.PromptFailedResult> {
|
|
// 1) Get the user code for this login
|
|
// 2) Get an access token from the device code
|
|
// 3) Get the list of tenants
|
|
// 4) Generate the AzureAccount object and return it
|
|
let tokenResponse: adal.TokenResponse = null;
|
|
let result: InProgressAutoOAuth = await this.getDeviceLoginUserCode();
|
|
this._autoOAuthCancelled = false;
|
|
this._inProgressAutoOAuth = result;
|
|
let response: adal.TokenResponse | azdata.PromptFailedResult = await this.getDeviceLoginToken(this._inProgressAutoOAuth, isAddAccount);
|
|
if (this.isPromptFailed(response)) {
|
|
return response;
|
|
}
|
|
tokenResponse = response;
|
|
this._autoOAuthCancelled = false;
|
|
this._inProgressAutoOAuth = null;
|
|
let tenants: Tenant[] = await this.getTenants(tokenResponse.userId, tokenResponse.userId);
|
|
// Figure out where we're getting the identity from
|
|
let identityProvider = tokenResponse.identityProvider;
|
|
if (identityProvider) {
|
|
identityProvider = identityProvider.toLowerCase();
|
|
}
|
|
|
|
// Determine if this is a microsoft account
|
|
let msa = identityProvider && (
|
|
identityProvider.indexOf('live.com') !== -1 ||
|
|
identityProvider.indexOf('live-int.com') !== -1 ||
|
|
identityProvider.indexOf('f8cdef31-a31e-4b4a-93e4-5f571e91255a') !== -1 ||
|
|
identityProvider.indexOf('ea8a4392-515e-481f-879e-6571ff2a8a36') !== -1);
|
|
|
|
// Calculate the display name for the user
|
|
let displayName = (tokenResponse.givenName && tokenResponse.familyName)
|
|
? `${tokenResponse.givenName} ${tokenResponse.familyName}`
|
|
: tokenResponse.userId;
|
|
|
|
// Calculate the home tenant display name to use for the contextual display name
|
|
let contextualDisplayName = msa
|
|
? localize('microsoftAccountDisplayName', 'Microsoft Account')
|
|
: tenants[0].displayName;
|
|
|
|
// Calculate the account type
|
|
let accountType = msa
|
|
? AzureAccountProvider.MicrosoftAccountType
|
|
: AzureAccountProvider.WorkSchoolAccountType;
|
|
|
|
return <AzureAccount>{
|
|
key: {
|
|
providerId: this._metadata.id,
|
|
accountId: tokenResponse.userId
|
|
},
|
|
name: tokenResponse.userId,
|
|
displayInfo: {
|
|
accountType: accountType,
|
|
userId: tokenResponse.userId,
|
|
contextualDisplayName: contextualDisplayName,
|
|
displayName: displayName
|
|
},
|
|
properties: {
|
|
isMsAccount: msa,
|
|
tenants: tenants
|
|
},
|
|
isStale: false
|
|
};
|
|
}
|
|
}
|
|
|
|
interface InProgressAutoOAuth {
|
|
context: adal.AuthenticationContext;
|
|
userCodeInfo: adal.UserCodeInfo;
|
|
}
|