New azure authentication experience (#8483)

* Changes

* Work in progress

* Authenticate with azure

* enbable national clouds and initialization

* Add support for tenants

* Finish up account work

* Finish up azure auth

* Don't allow prompt if we're not initialized

* Shut down server

* Remove trailing comma

* encode uri component

* ignore errors

* Address comments on github

* Fix issues and disable feature without env var

* Don't encode the nonce

* Only use env variables to disable the new sign in

* Show more user friendly messages to users
This commit is contained in:
Amir Omidi
2019-11-27 12:33:08 -08:00
committed by GitHub
parent 3135b8525b
commit 5235a1d029
13 changed files with 531 additions and 69 deletions

View File

@@ -432,6 +432,7 @@ export class AzureAccountProvider implements azdata.AccountProvider {
displayName: displayName
},
properties: {
providerSettings: this._metadata,
isMsAccount: msa,
tenants: tenants
},

View File

@@ -0,0 +1,448 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as azdata from 'azdata';
import * as vscode from 'vscode';
import * as http from 'http';
import * as url from 'url';
import * as crypto from 'crypto';
import * as nls from 'vscode-nls';
import * as request from 'request';
import {
AzureAccount,
AzureAccountProviderMetadata,
AzureAccountSecurityTokenCollection,
AzureAccountSecurityToken,
Tenant,
} from './interfaces';
import TokenCache from './tokenCache';
import { AddressInfo } from 'net';
import { AuthenticationContext, TokenResponse, ErrorResponse } from 'adal-node';
import { promisify } from 'util';
import * as events from 'events';
const localize = nls.loadMessageBundle();
const notInitalizedMessage = localize('accountProviderNotInitialized', "Account provider not initialized, cannot perform action");
export class AzureAccountProvider implements azdata.AccountProvider {
private static AzureAccountAuthenticatedEvent: string = 'AzureAccountAuthenticated';
private static WorkSchoolAccountType: string = 'work_school';
private static MicrosoftAccountType: string = 'microsoft';
private static AadCommonTenant: string = 'common';
private static eventEmitter = new events.EventEmitter();
private static redirectUrlAAD = 'https://vscode-redirect.azurewebsites.net/';
private commonAuthorityUrl: string;
private isInitialized: boolean = false;
constructor(private metadata: AzureAccountProviderMetadata, private _tokenCache: TokenCache) {
this.commonAuthorityUrl = url.resolve(this.metadata.settings.host, AzureAccountProvider.AadCommonTenant);
// Temporary override
this.metadata.settings.clientId = 'aebc6443-996d-45c2-90f0-388ff96faa56';
}
// interface method
initialize(storedAccounts: azdata.Account[]): Thenable<azdata.Account[]> {
return this._initialize(storedAccounts);
}
private async _initialize(storedAccounts: azdata.Account[]): Promise<azdata.Account[]> {
for (let account of storedAccounts) {
try {
await this.getAccessTokens(account, azdata.AzureResource.ResourceManagement);
} catch (e) {
console.error(`Refreshing account ${account.displayInfo} failed - ${e}`);
account.isStale = true;
azdata.accounts.accountUpdated(account);
}
}
this.isInitialized = true;
return storedAccounts;
}
private async getToken(userId: string, tenantId: string, resourceId: string): Promise<TokenResponse> {
let authorityUrl = url.resolve(this.metadata.settings.host, tenantId);
const context = new AuthenticationContext(authorityUrl, null, this._tokenCache);
const acquireToken = promisify(context.acquireToken).bind(context);
let response: (TokenResponse | ErrorResponse) = await acquireToken(resourceId, userId, this.metadata.settings.clientId);
if (response.error) {
throw new Error(`Response contained error ${response}`);
}
response = response as TokenResponse;
context.cache.add([response], (err, result) => {
console.log(err, result);
});
return response;
}
private async getAccessTokens(account: azdata.Account, resource: azdata.AzureResource): Promise<AzureAccountSecurityTokenCollection> {
const resourceIdMap = new Map<azdata.AzureResource, string>([
[azdata.AzureResource.ResourceManagement, this.metadata.settings.armResource.id],
[azdata.AzureResource.Sql, this.metadata.settings.sqlResource.id]
]);
const tenantRefreshPromises: Promise<{ tenantId: any, securityToken: AzureAccountSecurityToken }>[] = [];
const tokenCollection: AzureAccountSecurityTokenCollection = {};
for (let tenant of account.properties.tenants) {
const promise = new Promise<{ tenantId: any, securityToken: AzureAccountSecurityToken }>(async (resolve, reject) => {
try {
let response = await this.getToken(tenant.userId, tenant.id, resourceIdMap.get(resource));
resolve({
tenantId: tenant.id,
securityToken: {
expiresOn: response.expiresOn,
resource: response.resource,
token: response.accessToken,
tokenType: response.tokenType
} as AzureAccountSecurityToken,
});
} catch (ex) {
reject(ex);
}
});
tenantRefreshPromises.push(promise);
}
const refreshedTenants = await Promise.all(tenantRefreshPromises);
refreshedTenants.forEach((refreshed) => {
tokenCollection[refreshed.tenantId] = refreshed.securityToken;
});
return tokenCollection;
}
// interface method
getSecurityToken(account: azdata.Account, resource: azdata.AzureResource): Thenable<{}> {
return this._getSecurityToken(account, resource);
}
private async _getSecurityToken(account: azdata.Account, resource: azdata.AzureResource): Promise<{}> {
return this.getAccessTokens(account, resource);
}
// interface method
prompt(): Thenable<azdata.Account | azdata.PromptFailedResult> {
return this._prompt();
}
private async _prompt(): Promise<azdata.Account | azdata.PromptFailedResult> {
if (this.isInitialized === false) {
vscode.window.showInformationMessage(notInitalizedMessage);
return { canceled: false };
}
const pathMappings = new Map<string, (req: http.IncomingMessage, res: http.ServerResponse, reqUrl: url.UrlWithParsedQuery) => void>();
const nonce = crypto.randomBytes(16).toString('base64');
const server = this.createAuthServer(pathMappings);
const port = await this.listenToServer(server);
try {
const authUrl = this.createAuthUrl(
this.metadata.settings.host,
AzureAccountProvider.redirectUrlAAD,
this.metadata.settings.clientId,
this.metadata.settings.signInResourceId,
AzureAccountProvider.AadCommonTenant,
`${port},${encodeURIComponent(nonce)}`
);
this.addServerPaths(pathMappings, nonce, authUrl);
const accountAuthenticatedPromise = new Promise<AzureAccount>((resolve, reject) => {
AzureAccountProvider.eventEmitter.on(AzureAccountProvider.AzureAccountAuthenticatedEvent, ({ account, error }) => {
if (error) {
return reject(error);
}
return resolve(account);
});
});
const urlToOpen = `http://localhost:${port}/signin?nonce=${encodeURIComponent(nonce)}`;
vscode.env.openExternal(vscode.Uri.parse(urlToOpen));
const account = await accountAuthenticatedPromise;
return account;
} finally {
server.close();
}
}
private addServerPaths(
pathMappings: Map<string, (req: http.IncomingMessage, res: http.ServerResponse, reqUrl: url.UrlWithParsedQuery) => void>,
nonce: string,
authUrl: string) {
const initialSignIn = ((req: http.IncomingMessage, res: http.ServerResponse, reqUrl: url.UrlWithParsedQuery) => {
const receivedNonce = (reqUrl.query.nonce as string || '').replace(/ /g, '+');
if (receivedNonce !== nonce) {
res.writeHead(400, { 'content-type': 'text/html' });
res.write(localize('azureAuth.nonceError', "Authentication failed due to a nonce mismatch, please close ADS and try again."));
res.end();
return;
}
res.writeHead(302, { Location: authUrl });
res.end();
});
const callback = ((req: http.IncomingMessage, res: http.ServerResponse, reqUrl: url.UrlWithParsedQuery) => {
const state = reqUrl.query.state as string ?? '';
const code = reqUrl.query.code as string ?? '';
const stateSplit = state.split(',');
if (stateSplit.length !== 2) {
res.writeHead(400, { 'content-type': 'text/html' });
res.write(localize('azureAuth.stateError', "Authentication failed due to a state mismatch, please close ADS and try again."));
res.end();
return;
}
if (stateSplit[1] !== nonce) {
res.writeHead(400, { 'content-type': 'text/html' });
res.write(localize('azureAuth.nonceError', "Authentication failed due to a nonce mismatch, please close ADS and try again."));
res.end();
return;
}
res.writeHead(200, { 'content-type': 'text/html' });
res.write(localize('azureAuth.authSuccessful', "Authentication was successful, you can now close this page."));
res.end();
this.handleAuthentication(code).catch(console.error);
});
pathMappings.set('/signin', initialSignIn);
pathMappings.set('/callback', callback);
}
private async makeWebRequest(accessToken: TokenResponse, uri: string): Promise<any> {
const params = {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken.accessToken}`
},
json: true
};
return new Promise((resolve, reject) => {
request.get(uri, params, (error: any, response: request.Response, body: any) => {
const err = error ?? body.error;
if (err) {
return reject(err);
}
return resolve(body.value);
});
});
}
private async getTenants(userId: string, homeTenant: string): Promise<Tenant[]> {
const armToken = await this.getToken(userId, AzureAccountProvider.AadCommonTenant, this.metadata.settings.armResource.id);
const tenantUri = url.resolve(this.metadata.settings.armResource.endpoint, 'tenants?api-version=2015-01-01');
const armWebResponse: any[] = await this.makeWebRequest(armToken, tenantUri);
const promises = armWebResponse.map(async (value: { tenantId: string }) => {
const graphToken = await this.getToken(userId, value.tenantId, this.metadata.settings.graphResource.id);
let tenantDetailsUri = url.resolve(this.metadata.settings.graphResource.endpoint, value.tenantId + '/');
tenantDetailsUri = url.resolve(tenantDetailsUri, 'tenantDetails?api-version=2013-04-05');
const tenantDetails: any[] = await this.makeWebRequest(graphToken, tenantDetailsUri);
return {
id: value.tenantId,
userId: userId,
displayName: tenantDetailsUri.length && tenantDetails[0].displayName
? tenantDetails[0].displayName
: localize('azureWorkAccountDisplayName', "Work or school account")
} as Tenant;
});
const tenants = await Promise.all(promises);
const homeTenantIndex = tenants.findIndex(tenant => tenant.id === homeTenant);
if (homeTenantIndex >= 0) {
const homeTenant = tenants.splice(homeTenantIndex, 1);
tenants.unshift(homeTenant[0]);
}
return tenants;
}
/**
* Authenticates an azure account and then emits an event
* @param code Code from authenticating
*/
private async handleAuthentication(code: string): Promise<void> {
const token = await this.getTokenWithAuthCode(code, AzureAccountProvider.redirectUrlAAD);
const tenants = await this.getTenants(token.userId, token.userId);
let identityProvider = token.identityProvider;
if (identityProvider) {
identityProvider = identityProvider.toLowerCase();
}
// Determine if this is a microsoft account
let msa = identityProvider && (
identityProvider.indexOf('live.com') !== -1 || // lgtm [js/incomplete-url-substring-sanitization]
identityProvider.indexOf('live-int.com') !== -1 || // lgtm [js/incomplete-url-substring-sanitization]
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 = (token.givenName && token.familyName)
? `${token.givenName} ${token.familyName}`
: token.userId;
// Calculate the home tenant display name to use for the contextual display name
let contextualDisplayName = msa
? localize('microsoftAccountDisplayName', "Microsoft Account")
: tenants[0].displayName;
let accountType = msa
? AzureAccountProvider.MicrosoftAccountType
: AzureAccountProvider.WorkSchoolAccountType;
const account = {
key: {
providerId: this.metadata.id,
accountId: token.userId
},
name: token.userId,
displayInfo: {
accountType: accountType,
userId: token.userId,
contextualDisplayName: contextualDisplayName,
displayName: displayName
},
properties: {
providerSettings: this.metadata,
isMsAccount: msa,
tenants,
},
isStale: false
} as AzureAccount;
AzureAccountProvider.eventEmitter.emit(AzureAccountProvider.AzureAccountAuthenticatedEvent, { account });
}
private async getTokenWithAuthCode(code: string, redirectUrl: string): Promise<TokenResponse> {
const context = new AuthenticationContext(this.commonAuthorityUrl, null, this._tokenCache);
const acquireToken = promisify(context.acquireTokenWithAuthorizationCode).bind(context);
let token = await acquireToken(code, redirectUrl, this.metadata.settings.signInResourceId, this.metadata.settings.clientId, undefined);
if (token.error) {
throw new Error(`${token.error} - ${token.errorDescription}`);
}
token = token as TokenResponse;
token._clientId = this.metadata.settings.clientId;
token._authority = this.commonAuthorityUrl;
token.isMRRT = true;
context.cache.add([token], (err, result) => {
console.log(err, result);
});
return token;
}
private createAuthUrl(baseHost: string, redirectUri: string, clientId: string, resource: string, tenant: string, nonce: string): string {
return `${baseHost}${encodeURIComponent(tenant)}/oauth2/authorize?response_type=code&client_id=${encodeURIComponent(clientId)}&redirect_uri=${encodeURIComponent(redirectUri)}&state=${nonce}&resource=${encodeURIComponent(resource)}&prompt=select_account`;
}
private createAuthServer(pathMappings: Map<string, (req: http.IncomingMessage, res: http.ServerResponse, reqUrl: url.UrlWithParsedQuery) => void>) {
const server = http.createServer((req, res) => {
// Parse URL and the query string
const reqUrl = url.parse(req.url, true);
const method = pathMappings.get(reqUrl.pathname);
if (method) {
method(req, res, reqUrl);
} else {
console.error('undefined request ', reqUrl, req);
}
});
return server;
}
/**
* Actually starts listening for the server - returns the port the server is listening on
* @param server http.Server
*/
private async listenToServer(server: http.Server): Promise<number> {
let portTimer: NodeJS.Timer;
const cancelPortTimer = (() => {
clearTimeout(portTimer);
});
const port = new Promise<number>((resolve, reject) => {
// If no port for 5 seconds, reject it.
portTimer = setTimeout(() => {
reject(new Error('Timeout waiting for port'));
}, 5000);
server.on('listening', () => {
const address = server.address() as AddressInfo;
if (address!.port === undefined) {
reject(new Error('Port was not defined'));
}
resolve(address.port);
});
server.on('error', err => {
reject(err);
});
server.on('close', () => {
reject(new Error('Closed'));
});
server.listen(0, '127.0.0.1');
});
const portValue = await port;
cancelPortTimer();
return portValue;
}
// interface method
refresh(account: azdata.Account): Thenable<azdata.Account | azdata.PromptFailedResult> {
return this._refresh(account);
}
private async _refresh(account: azdata.Account): Promise<azdata.Account | azdata.PromptFailedResult> {
return this.prompt();
}
// interface method
clear(accountKey: azdata.AccountKey): Thenable<void> {
return this._clear(accountKey);
}
private async _clear(accountKey: azdata.AccountKey): Promise<void> {
// Put together a query to look up any tokens associated with the account key
let query = { userId: accountKey.accountId } as TokenResponse;
// 1) Look up the tokens associated with the query
// 2) Remove them
let results = await this._tokenCache.findThenable(query);
this._tokenCache.removeThenable(results);
}
// interface method
autoOAuthCancelled(): Thenable<void> {
return this._autoOAuthCancelled();
}
private async _autoOAuthCancelled(): Promise<void> {
// I don't think we need this?
throw new Error('Method not implemented.');
}
}

View File

@@ -3,8 +3,6 @@
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
'use strict';
import * as constants from '../constants';
import * as azdata from 'azdata';
import * as events from 'events';
@@ -13,7 +11,8 @@ import * as path from 'path';
import * as vscode from 'vscode';
import CredentialServiceTokenCache from './tokenCache';
import providerSettings from './providerSettings';
import { AzureAccountProvider } from './azureAccountProvider';
import { AzureAccountProvider as AzureAccountProviderDeprecated } from './azureAccountProvider';
import { AzureAccountProvider as AzureAccountProvider } from './azureAccountProvider2';
import { AzureAccountProviderMetadata, ProviderSettings } from './interfaces';
let localize = nls.loadMessageBundle();
@@ -26,7 +25,7 @@ export class AzureAccountProviderService implements vscode.Disposable {
// MEMBER VARIABLES ////////////////////////////////////////////////////////
private _accountDisposals: { [accountProviderId: string]: vscode.Disposable };
private _accountProviders: { [accountProviderId: string]: AzureAccountProvider };
private _accountProviders: { [accountProviderId: string]: azdata.AccountProvider };
private _credentialProvider: azdata.CredentialProvider;
private _configChangePromiseChain: Thenable<void>;
private _currentConfig: vscode.WorkspaceConfiguration;
@@ -69,10 +68,11 @@ export class AzureAccountProviderService implements vscode.Disposable {
// PRIVATE HELPERS /////////////////////////////////////////////////////
private onClearTokenCache(): Thenable<void> {
let self = this;
// let self = this;
let promises: Thenable<void>[] = providerSettings.map(provider => {
return self._accountProviders[provider.metadata.id].clearTokenCache();
// return self._accountProviders[provider.metadata.id].clearTokenCache();
return Promise.resolve();
});
return Promise.all(promises)
@@ -129,14 +129,23 @@ export class AzureAccountProviderService implements vscode.Disposable {
}
private registerAccountProvider(provider: ProviderSettings): Thenable<void> {
let self = this;
return new Promise((resolve, reject) => {
try {
//let config = vscode.workspace.getConfiguration(AzureAccountProviderService.ConfigurationSection);
let tokenCacheKey = `azureTokenCache-${provider.metadata.id}`;
let tokenCachePath = path.join(this._userStoragePath, tokenCacheKey);
let tokenCache = new CredentialServiceTokenCache(self._credentialProvider, tokenCacheKey, tokenCachePath);
let accountProvider = new AzureAccountProvider(<AzureAccountProviderMetadata>provider.metadata, tokenCache);
let accountProvider: azdata.AccountProvider;
if (/*config.get('useNewSignInExperience') === true && */ Boolean(process.env['NEW_SIGN_IN_EXPERIENCE']) === true) {
accountProvider = new AzureAccountProvider(provider.metadata as AzureAccountProviderMetadata, tokenCache);
} else {
accountProvider = new AzureAccountProviderDeprecated(provider.metadata as AzureAccountProviderMetadata, tokenCache);
}
self._accountProviders[provider.metadata.id] = accountProvider;
self._accountDisposals[provider.metadata.id] = azdata.accounts.registerAccountProvider(provider.metadata, accountProvider);
resolve();

View File

@@ -127,6 +127,7 @@ export interface AzureAccountProviderMetadata extends azdata.AccountProviderMeta
* Properties specific to an Azure account
*/
interface AzureAccountProperties {
providerSettings: AzureAccountProviderMetadata;
/**
* Whether or not the account is a Microsoft account
*/
@@ -151,7 +152,7 @@ export interface AzureAccount extends azdata.Account {
/**
* Token returned from a request for an access token
*/
interface AzureAccountSecurityToken {
export interface AzureAccountSecurityToken {
/**
* Access token, itself
*/

View File

@@ -3,8 +3,6 @@
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
'use strict';
import * as nls from 'vscode-nls';
import { ProviderSettings } from './interfaces';
@@ -40,11 +38,11 @@ const publicAzureSettings: ProviderSettings = {
}
};
/* Leaving for reference
const usGovAzureSettings: ProviderSettings = {
configKey: 'enableUsGovCloud',
metadata: {
displayName: localize('usGovCloudDisplayName', 'Azure (US Government)'),
displayName: localize('usGovCloudDisplayName', "Azure (US Government)"),
id: 'usGovAzureCloud',
settings: {
host: 'https://login.microsoftonline.com/',
@@ -63,32 +61,11 @@ const usGovAzureSettings: ProviderSettings = {
}
};
const chinaAzureSettings: ProviderSettings = {
configKey: 'enableChinaCloud',
metadata: {
displayName: localize('chinaCloudDisplayName', 'Azure (China)'),
id: 'chinaAzureCloud',
settings: {
host: 'https://login.chinacloudapi.cn/',
clientId: 'TBD',
signInResourceId: 'https://management.core.chinacloudapi.cn/',
graphResource: {
id: 'https://graph.chinacloudapi.cn/',
endpoint: 'https://graph.chinacloudapi.cn'
},
armResource: {
id: 'https://management.core.chinacloudapi.cn/',
endpoint: 'https://managemement.chinacloudapi.net'
},
redirectUri: 'http://localhost/redirect'
}
}
};
const germanyAzureSettings: ProviderSettings = {
configKey: 'enableGermanyCloud',
metadata: {
displayName: localize('germanyCloud', 'Azure (Germany)'),
displayName: localize('germanyCloud', "Azure (Germany)"),
id: 'germanyAzureCloud',
settings: {
host: 'https://login.microsoftazure.de/',
@@ -106,7 +83,27 @@ const germanyAzureSettings: ProviderSettings = {
}
}
};
*/
// TODO: Enable China, Germany, and US Gov clouds: (#3031)
export default <ProviderSettings[]>[publicAzureSettings, /*chinaAzureSettings, germanyAzureSettings, usGovAzureSettings*/];
const chinaAzureSettings: ProviderSettings = {
configKey: 'enableChinaCloud',
metadata: {
displayName: localize('chinaCloudDisplayName', "Azure (China)"),
id: 'chinaAzureCloud',
settings: {
host: 'https://login.chinacloudapi.cn/',
clientId: 'TBD',
signInResourceId: 'https://management.core.chinacloudapi.cn/',
graphResource: {
id: 'https://graph.chinacloudapi.cn/',
endpoint: 'https://graph.chinacloudapi.cn'
},
armResource: {
id: 'https://management.core.chinacloudapi.cn/',
endpoint: 'https://managemement.chinacloudapi.net'
},
redirectUri: 'http://localhost/redirect'
}
}
};
const allSettings = [publicAzureSettings, usGovAzureSettings, germanyAzureSettings, chinaAzureSettings];
export default allSettings;

View File

@@ -76,20 +76,22 @@ export default class TokenCache implements adal.TokenCache {
public find(query: any, callback: (error: Error, results: any[]) => void): void {
let self = this;
this.doOperation(() => {
return self.readCache()
.then(cache => {
return cache.filter(
entry => TokenCache.findByPartial(entry, query)
);
})
.then(
results => callback(null, results),
(err) => callback(err, null)
);
this.doOperation(async () => {
try {
const cache = await self.readCache();
const filtered = cache.filter(entry => {
return TokenCache.findByPartial(entry, query);
});
callback(null, filtered);
} catch (ex) {
console.log(ex);
callback(ex, null);
}
});
}
/**
* Wrapper to make callback-based find method into a thenable method
* @param query Partial object to use to look up tokens. Ideally should be partial of adal.TokenResponse

View File

@@ -4,6 +4,7 @@
*--------------------------------------------------------------------------------------------*/
/// <reference path='../../../../src/vs/vscode.d.ts'/>
/// <reference path='../../../../src/vs/vscode.proposed.d.ts'/>
/// <reference path='../../../../src/sql/azdata.d.ts'/>
/// <reference path='../../../../src/sql/azdata.proposed.d.ts'/>
/// <reference types='@types/node'/>