New Azure Auth Provider (#9664)

* Fixes how azure auth is handled on the azure pane

* New auth provider

* Add externals

* Feedback

* Change azdata

* Fix other issues

* Review feedback

* Feedback

* Feedback updates

* Move AKV to azdata.d.ts

* Fix yarn.lock

* Update third party notices
This commit is contained in:
Amir Omidi
2020-03-23 12:39:25 -07:00
committed by GitHub
parent c35221c076
commit c15ac474d7
19 changed files with 1929 additions and 1052 deletions

View File

@@ -0,0 +1,504 @@
/*---------------------------------------------------------------------------------------------
* 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 nls from 'vscode-nls';
import axios, { AxiosResponse } from 'axios';
import * as qs from 'qs';
import * as url from 'url';
import {
AzureAccountProviderMetadata,
Tenant,
AzureAccount,
Resource,
AzureAuthType,
Subscription
} from '../interfaces';
import { SimpleTokenCache } from '../simpleTokenCache';
import { MemoryDatabase } from '../utils/memoryDatabase';
const localize = nls.loadMessageBundle();
export interface AccountKey {
/**
* Account Key - uniquely identifies an account
*/
key: string
}
export interface AccessToken extends AccountKey {
/**
* Access Token
*/
token: string;
}
export interface RefreshToken extends AccountKey {
/**
* Refresh Token
*/
token: string;
/**
* Account Key
*/
key: string
}
export interface TokenResponse {
[tenantId: string]: Token
}
export interface Token extends AccountKey {
/**
* Access token
*/
token: string;
/**
* TokenType
*/
tokenType: string;
}
export interface TokenClaims { // https://docs.microsoft.com/en-us/azure/active-directory/develop/id-tokens
aud: string;
iss: string;
iat: number;
idp: string,
nbf: number;
exp: number;
c_hash: string;
at_hash: string;
aio: string;
preferred_username: string;
email: string;
name: string;
nonce: string;
oid: string;
roles: string[];
rh: string;
sub: string;
tid: string;
unique_name: string;
uti: string;
ver: string;
}
export type TokenRefreshResponse = { accessToken: AccessToken, refreshToken: RefreshToken, tokenClaims: TokenClaims };
export abstract class AzureAuth {
protected readonly memdb = new MemoryDatabase();
protected readonly WorkSchoolAccountType: string = 'work_school';
protected readonly MicrosoftAccountType: string = 'microsoft';
protected readonly loginEndpointUrl: string;
protected readonly commonTenant: string;
protected readonly redirectUri: string;
protected readonly scopes: string[];
protected readonly scopesString: string;
protected readonly clientId: string;
protected readonly resources: Resource[];
constructor(
protected readonly metadata: AzureAccountProviderMetadata,
protected readonly tokenCache: SimpleTokenCache,
protected readonly context: vscode.ExtensionContext,
protected readonly authType: AzureAuthType,
public readonly userFriendlyName: string
) {
this.loginEndpointUrl = this.metadata.settings.host;
this.commonTenant = 'common';
this.redirectUri = this.metadata.settings.redirectUri;
this.clientId = this.metadata.settings.clientId;
this.resources = [
this.metadata.settings.armResource, this.metadata.settings.sqlResource,
this.metadata.settings.graphResource, this.metadata.settings.ossRdbmsResource
];
this.scopes = [...this.metadata.settings.scopes];
this.scopesString = this.scopes.join(' ');
}
public abstract async login(): Promise<AzureAccount | azdata.PromptFailedResult>;
public abstract async autoOAuthCancelled(): Promise<void>;
public async refreshAccess(account: azdata.Account): Promise<azdata.Account> {
const response = await this.getCachedToken(account.key);
if (!response) {
account.isStale = true;
return account;
}
const refreshToken = response.refreshToken;
if (!refreshToken || !refreshToken.key) {
account.isStale = true;
return account;
}
try {
await this.refreshAccessToken(account.key, refreshToken);
} catch (ex) {
if (ex.message) {
await vscode.window.showErrorMessage(ex.message);
}
console.log(ex);
}
return account;
}
public async getSecurityToken(account: azdata.Account, azureResource: azdata.AzureResource): Promise<TokenResponse | undefined> {
const resource = this.resources.find(s => s.azureResourceId === azureResource);
if (!resource) {
return undefined;
}
const azureAccount = account as AzureAccount;
const response: TokenResponse = {};
for (const tenant of azureAccount.properties.tenants) {
let cachedTokens = await this.getCachedToken(account.key, resource.id, tenant.id);
// Check expiration
if (cachedTokens) {
const expiresOn = Number(this.memdb.get(this.createMemdbString(account.key.accountId, tenant.id, resource.id)));
const currentTime = new Date().getTime() / 1000;
if (!Number.isNaN(expiresOn)) {
const remainingTime = expiresOn - currentTime;
const fiveMinutes = 5 * 60;
// If the remaining time is less than five minutes, assume the token has expired. It's too close to expiration to be meaningful.
if (remainingTime < fiveMinutes) {
cachedTokens = undefined;
}
} else {
// No expiration date, assume expired.
cachedTokens = undefined;
console.info('Assuming expired token due to no expiration date - this is expected on first launch.');
}
}
// Refresh
if (!cachedTokens) {
const baseToken = await this.getCachedToken(account.key);
if (!baseToken) {
return undefined;
}
await this.refreshAccessToken(account.key, baseToken.refreshToken, tenant, resource);
cachedTokens = await this.getCachedToken(account.key, resource.id, tenant.id);
if (!cachedTokens) {
return undefined;
}
}
const { accessToken } = cachedTokens;
response[tenant.id] = {
token: accessToken.token,
key: accessToken.key,
tokenType: 'Bearer'
};
}
if (azureAccount.properties.subscriptions) {
azureAccount.properties.subscriptions.forEach(subscription => {
response[subscription.id] = {
...response[subscription.tenantId]
};
});
}
return response;
}
public async clearCredentials(account: azdata.AccountKey): Promise<void> {
try {
return this.deleteAccountCache(account);
} catch (ex) {
const msg = localize('azure.cacheErrrorRemove', "Error when removing your account from the cache.");
vscode.window.showErrorMessage(msg);
console.error('Error when removing tokens.', ex);
}
}
protected toBase64UrlEncoding(base64string: string): string {
return base64string.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); // Need to use base64url encoding
}
protected async makePostRequest(uri: string, postData: { [key: string]: string }) {
const config = {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
};
return axios.post(uri, qs.stringify(postData), config);
}
protected async makeGetRequest(token: string, uri: string): Promise<AxiosResponse<any>> {
const config = {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
};
return axios.get(uri, config);
}
protected async getTenants(token: AccessToken): Promise<Tenant[]> {
interface TenantResponse { // https://docs.microsoft.com/en-us/rest/api/resources/tenants/list
id: string
tenantId: string
displayName?: string
tenantCategory?: string
}
const tenantUri = url.resolve(this.metadata.settings.armResource.endpoint, 'tenants?api-version=2019-11-01');
try {
const tenantResponse = await this.makeGetRequest(token.token, tenantUri);
const tenants: Tenant[] = tenantResponse.data.value.map((tenantInfo: TenantResponse) => {
return {
id: tenantInfo.tenantId,
displayName: tenantInfo.displayName ? tenantInfo.displayName : localize('azureWorkAccountDisplayName', "Work or school account"),
userId: token.key,
tenantCategory: tenantInfo.tenantCategory
} as Tenant;
});
const homeTenantIndex = tenants.findIndex(tenant => tenant.tenantCategory === 'Home');
if (homeTenantIndex >= 0) {
const homeTenant = tenants.splice(homeTenantIndex, 1);
tenants.unshift(homeTenant[0]);
}
return tenants;
} catch (ex) {
console.log(ex);
throw new Error('Error retreiving tenant information');
}
}
protected async getSubscriptions(account: AzureAccount): Promise<Subscription[]> {
interface SubscriptionResponse { // https://docs.microsoft.com/en-us/rest/api/resources/subscriptions/list
subscriptionId: string
tenantId: string
displayName: string
}
const allSubs: Subscription[] = [];
const tokens = await this.getSecurityToken(account, azdata.AzureResource.ResourceManagement);
for (const tenant of account.properties.tenants) {
const token = tokens[tenant.id];
const subscriptionUri = url.resolve(this.metadata.settings.armResource.endpoint, 'subscriptions?api-version=2019-11-01');
try {
const subscriptionResponse = await this.makeGetRequest(token.token, subscriptionUri);
const subscriptions: Subscription[] = subscriptionResponse.data.value.map((subscriptionInfo: SubscriptionResponse) => {
return {
id: subscriptionInfo.subscriptionId,
displayName: subscriptionInfo.displayName,
tenantId: subscriptionInfo.tenantId
} as Subscription;
});
allSubs.push(...subscriptions);
} catch (ex) {
console.log(ex);
throw new Error('Error retreiving subscription information');
}
}
return allSubs;
}
protected async getToken(postData: { [key: string]: string }, tenant = this.commonTenant, resourceId: string = ''): Promise<TokenRefreshResponse | undefined> {
try {
const tokenUrl = `${this.loginEndpointUrl}${tenant}/oauth2/token`;
const tokenResponse = await this.makePostRequest(tokenUrl, postData);
const tokenClaims = this.getTokenClaims(tokenResponse.data.access_token);
const accessToken: AccessToken = {
token: tokenResponse.data.access_token,
key: tokenClaims.email || tokenClaims.unique_name || tokenClaims.name,
};
this.memdb.set(this.createMemdbString(accessToken.key, tenant, resourceId), tokenResponse.data.expires_on);
const refreshToken: RefreshToken = {
token: tokenResponse.data.refresh_token,
key: accessToken.key
};
return { accessToken, refreshToken, tokenClaims };
} catch (err) {
console.dir(err);
const msg = localize('azure.noToken', "Retrieving the token failed.");
vscode.window.showErrorMessage(msg);
throw new Error(err);
}
}
protected getTokenClaims(accessToken: string): TokenClaims | undefined {
try {
const split = accessToken.split('.');
return JSON.parse(Buffer.from(split[1], 'base64').toString('binary'));
} catch (ex) {
throw new Error('Unable to read token claims: ' + JSON.stringify(ex));
}
}
private async refreshAccessToken(account: azdata.AccountKey, rt: RefreshToken, tenant?: Tenant, resource?: Resource): Promise<void> {
const postData: { [key: string]: string } = {
grant_type: 'refresh_token',
refresh_token: rt.token,
client_id: this.clientId,
tenant: this.commonTenant,
};
if (resource) {
postData.resource = resource.endpoint;
}
const { accessToken, refreshToken } = await this.getToken(postData, tenant?.id, resource?.id);
if (!accessToken || !refreshToken) {
console.log('Access or refresh token were undefined');
const msg = localize('azure.refreshTokenError', "Error when refreshing your account.");
throw new Error(msg);
}
return this.setCachedToken(account, accessToken, refreshToken, resource?.id, tenant?.id);
}
public async setCachedToken(account: azdata.AccountKey, accessToken: AccessToken, refreshToken: RefreshToken, resourceId?: string, tenantId?: string): Promise<void> {
const msg = localize('azure.cacheErrorAdd', "Error when adding your account to the cache.");
resourceId = resourceId ?? '';
tenantId = tenantId ?? '';
if (!accessToken || !accessToken.token || !refreshToken.token || !accessToken.key) {
throw new Error(msg);
}
try {
await this.tokenCache.saveCredential(`${account.accountId}_access_${resourceId}_${tenantId}`, JSON.stringify(accessToken));
await this.tokenCache.saveCredential(`${account.accountId}_refresh_${resourceId}_${tenantId}`, JSON.stringify(refreshToken));
} catch (ex) {
console.error('Error when storing tokens.', ex);
throw new Error(msg);
}
}
public async getCachedToken(account: azdata.AccountKey, resourceId?: string, tenantId?: string): Promise<{ accessToken: AccessToken, refreshToken: RefreshToken } | undefined> {
resourceId = resourceId ?? '';
tenantId = tenantId ?? '';
let accessToken: AccessToken;
let refreshToken: RefreshToken;
try {
accessToken = JSON.parse(await this.tokenCache.getCredential(`${account.accountId}_access_${resourceId}_${tenantId}`));
refreshToken = JSON.parse(await this.tokenCache.getCredential(`${account.accountId}_refresh_${resourceId}_${tenantId}`));
} catch (ex) {
return undefined;
}
if (!accessToken || !refreshToken) {
return undefined;
}
if (!refreshToken.token || !refreshToken.key) {
return undefined;
}
if (!accessToken.token || !accessToken.key) {
return undefined;
}
return {
accessToken,
refreshToken
};
}
public createMemdbString(accountKey: string, tenantId: string, resourceId: string): string {
return `${accountKey}_${tenantId}_${resourceId}`;
}
public async deleteAccountCache(account: azdata.AccountKey): Promise<void> {
const results = await this.tokenCache.findCredentials(account.accountId);
for (let { account } of results) {
await this.tokenCache.clearCredential(account);
}
}
public async deleteAllCache(): Promise<void> {
const results = await this.tokenCache.findCredentials('');
for (let { account } of results) {
await this.tokenCache.clearCredential(account);
}
}
public createAccount(tokenClaims: TokenClaims, key: string, tenants: Tenant[]): AzureAccount {
// Determine if this is a microsoft account
let accountIssuer = 'unknown';
if (tokenClaims.iss === 'https://sts.windows.net/72f988bf-86f1-41af-91ab-2d7cd011db47/') {
accountIssuer = 'corp';
}
if (tokenClaims?.idp === 'live.com') {
accountIssuer = 'msft';
}
const displayName = tokenClaims.name ?? tokenClaims.email ?? tokenClaims.unique_name;
let contextualDisplayName: string;
switch (accountIssuer) {
case 'corp':
contextualDisplayName = localize('azure.microsoftCorpAccount', "Microsoft Corp");
break;
case 'msft':
contextualDisplayName = localize('azure.microsoftAccountDisplayName', 'Microsoft Account');
break;
default:
contextualDisplayName = displayName;
}
let accountType = accountIssuer === 'msft'
? this.MicrosoftAccountType
: this.WorkSchoolAccountType;
const account = {
key: {
providerId: this.metadata.id,
accountId: key
},
name: key,
displayInfo: {
accountType: accountType,
userId: key,
contextualDisplayName: contextualDisplayName,
displayName
},
properties: {
providerSettings: this.metadata,
isMsAccount: accountIssuer === 'msft',
tenants,
azureAuthType: this.authType
},
isStale: false
} as AzureAccount;
return account;
}
}

View File

@@ -0,0 +1,230 @@
/*---------------------------------------------------------------------------------------------
* 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 crypto from 'crypto';
import * as nls from 'vscode-nls';
import { promises as fs } from 'fs';
import * as path from 'path';
import * as http from 'http';
import * as qs from 'qs';
import {
AzureAuth,
AccessToken,
RefreshToken,
TokenClaims,
TokenRefreshResponse,
} from './azureAuth';
import {
AzureAccountProviderMetadata,
AzureAuthType,
Deferred
} from '../interfaces';
import { SimpleWebServer } from '../utils/simpleWebServer';
import { SimpleTokenCache } from '../simpleTokenCache';
const localize = nls.loadMessageBundle();
export class AzureAuthCodeGrant extends AzureAuth {
private static readonly USER_FRIENDLY_NAME: string = localize('azure.azureAuthCodeGrantName', "Azure Auth Code Grant");
private server: SimpleWebServer;
constructor(metadata: AzureAccountProviderMetadata,
tokenCache: SimpleTokenCache,
context: vscode.ExtensionContext) {
super(metadata, tokenCache, context, AzureAuthType.AuthCodeGrant, AzureAuthCodeGrant.USER_FRIENDLY_NAME);
}
public async autoOAuthCancelled(): Promise<void> {
return this.server.shutdown();
}
public async login(): Promise<azdata.Account | azdata.PromptFailedResult> {
let authCompleteDeferred: Deferred<void>;
let authCompletePromise = new Promise<void>((resolve, reject) => authCompleteDeferred = { resolve, reject });
this.server = new SimpleWebServer();
const nonce = crypto.randomBytes(16).toString('base64');
let serverPort: string;
try {
serverPort = await this.server.startup();
} catch (err) {
const msg = localize('azure.serverCouldNotStart', 'Server could not start. This could be a permissions error or an incompatibility on your system. You can try enabling device code authentication from settings.');
await vscode.window.showErrorMessage(msg);
console.dir(err);
return { canceled: false } as azdata.PromptFailedResult;
}
// The login code to use
let loginUrl: string;
let codeVerifier: string;
{
codeVerifier = this.toBase64UrlEncoding(crypto.randomBytes(32).toString('base64'));
const state = `${serverPort},${encodeURIComponent(nonce)}`;
const codeChallenge = this.toBase64UrlEncoding(crypto.createHash('sha256').update(codeVerifier).digest('base64'));
const loginQuery = {
response_type: 'code',
response_mode: 'query',
client_id: this.clientId,
redirect_uri: this.redirectUri,
state,
prompt: 'select_account',
code_challenge_method: 'S256',
code_challenge: codeChallenge,
resource: this.metadata.settings.signInResourceId
};
loginUrl = `${this.loginEndpointUrl}${this.commonTenant}/oauth2/authorize?${qs.stringify(loginQuery)}`;
}
await vscode.env.openExternal(vscode.Uri.parse(`http://localhost:${serverPort}/signin?nonce=${encodeURIComponent(nonce)}`));
const authenticatedCode = await this.addServerListeners(this.server, nonce, loginUrl, authCompletePromise);
let tokenClaims: TokenClaims;
let accessToken: AccessToken;
let refreshToken: RefreshToken;
try {
const { accessToken: at, refreshToken: rt, tokenClaims: tc } = await this.getTokenWithAuthCode(authenticatedCode, codeVerifier, this.redirectUri);
tokenClaims = tc;
accessToken = at;
refreshToken = rt;
} catch (ex) {
if (ex.msg) {
await vscode.window.showErrorMessage(ex.msg);
}
console.log(ex);
}
if (!accessToken) {
const msg = localize('azure.tokenFail', "Failure when retreiving tokens.");
authCompleteDeferred.reject(new Error(msg));
throw Error('Failure when retreiving tokens');
}
const tenants = await this.getTenants(accessToken);
try {
await this.setCachedToken({ accountId: accessToken.key, providerId: this.metadata.id }, accessToken, refreshToken);
} catch (ex) {
console.log(ex);
if (ex.msg) {
await vscode.window.showErrorMessage(ex.msg);
authCompleteDeferred.reject(ex);
} else {
authCompleteDeferred.reject(new Error('There was an issue when storing the cache.'));
}
return { canceled: false } as azdata.PromptFailedResult;
}
const account = this.createAccount(tokenClaims, accessToken.key, tenants);
const subscriptions = await this.getSubscriptions(account);
account.properties.subscriptions = subscriptions;
authCompleteDeferred.resolve();
return account;
}
private async addServerListeners(server: SimpleWebServer, nonce: string, loginUrl: string, authComplete: Promise<void>): Promise<string> {
const mediaPath = path.join(this.context.extensionPath, 'media');
// Utility function
const sendFile = async (res: http.ServerResponse, filePath: string, contentType: string): Promise<void> => {
let fileContents;
try {
fileContents = await fs.readFile(filePath);
} catch (ex) {
console.error(ex);
res.writeHead(400);
res.end();
return;
}
res.writeHead(200, {
'Content-Length': fileContents.length,
'Content-Type': contentType
});
res.end(fileContents);
};
server.on('/landing.css', (req, reqUrl, res) => {
sendFile(res, path.join(mediaPath, 'landing.css'), 'text/css; charset=utf-8').catch(console.error);
});
server.on('/SignIn.svg', (req, reqUrl, res) => {
sendFile(res, path.join(mediaPath, 'SignIn.svg'), 'image/svg+xml').catch(console.error);
});
server.on('/signin', (req, reqUrl, res) => {
let receivedNonce: string = reqUrl.query.nonce as string;
receivedNonce = receivedNonce.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 Azure Data Studio and try again."));
res.end();
console.error('nonce no match', receivedNonce, nonce);
return;
}
res.writeHead(302, { Location: loginUrl });
res.end();
});
return new Promise<string>((resolve, reject) => {
server.on('/callback', (req, reqUrl, res) => {
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();
reject(new Error('State mismatch'));
return;
}
if (stateSplit[1] !== encodeURIComponent(nonce)) {
res.writeHead(400, { 'content-type': 'text/html' });
res.write(localize('azureAuth.nonceError', "Authentication failed due to a nonce mismatch, please close Azure Data Studio and try again."));
res.end();
reject(new Error('Nonce mismatch'));
return;
}
resolve(code);
authComplete.then(() => {
sendFile(res, path.join(mediaPath, 'landing.html'), 'text/html; charset=utf-8').catch(console.error);
}, (ex: Error) => {
res.writeHead(400, { 'content-type': 'text/html' });
res.write(ex.message);
res.end();
});
});
});
}
private async getTokenWithAuthCode(authCode: string, codeVerifier: string, redirectUri: string): Promise<TokenRefreshResponse | undefined> {
const postData = {
grant_type: 'authorization_code',
code: authCode,
client_id: this.clientId,
code_verifier: codeVerifier,
redirect_uri: redirectUri,
resource: this.metadata.settings.signInResourceId
};
return this.getToken(postData);
}
}

View File

@@ -0,0 +1,159 @@
/*---------------------------------------------------------------------------------------------
* 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 nls from 'vscode-nls';
import {
AzureAuth,
TokenClaims,
AccessToken,
RefreshToken
} from './azureAuth';
import {
AzureAccountProviderMetadata,
AzureAccount,
AzureAuthType,
// Tenant,
// Subscription
} from '../interfaces';
import { SimpleTokenCache } from '../simpleTokenCache';
const localize = nls.loadMessageBundle();
interface DeviceCodeLogin { // https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-device-code
device_code: string,
expires_in: number;
interval: number;
message: string;
user_code: string;
verification_url: string
}
interface DeviceCodeLoginResult {
token_type: string,
scope: string,
expires_in: number,
access_token: string,
refresh_token: string,
}
export class AzureDeviceCode extends AzureAuth {
private static readonly USER_FRIENDLY_NAME: string = localize('azure.azureDeviceCodeAuth', "Azure Device Code");
private readonly pageTitle: string;
constructor(metadata: AzureAccountProviderMetadata,
tokenCache: SimpleTokenCache,
context: vscode.ExtensionContext) {
super(metadata, tokenCache, context, AzureAuthType.AuthCodeGrant, AzureDeviceCode.USER_FRIENDLY_NAME);
this.pageTitle = localize('addAccount', "Add {0} account", this.metadata.displayName);
}
public async login(): Promise<AzureAccount | azdata.PromptFailedResult> {
try {
const uri = `${this.loginEndpointUrl}/${this.commonTenant}/oauth2/devicecode`;
const postResult = await this.makePostRequest(uri, {
client_id: this.clientId,
resource: this.metadata.settings.signInResourceId
});
const initialDeviceLogin: DeviceCodeLogin = postResult.data;
await azdata.accounts.beginAutoOAuthDeviceCode(this.metadata.id, this.pageTitle, initialDeviceLogin.message, initialDeviceLogin.user_code, initialDeviceLogin.verification_url);
const finalDeviceLogin = await this.setupPolling(initialDeviceLogin);
let tokenClaims: TokenClaims;
let accessToken: AccessToken;
let refreshToken: RefreshToken;
// let tenants: Tenant[];
// let subscriptions: Subscription[];
tokenClaims = this.getTokenClaims(finalDeviceLogin.access_token);
accessToken = {
token: finalDeviceLogin.access_token,
key: tokenClaims.email || tokenClaims.unique_name || tokenClaims.name,
};
refreshToken = {
token: finalDeviceLogin.refresh_token,
key: accessToken.key,
};
await this.setCachedToken({ accountId: accessToken.key, providerId: this.metadata.id }, accessToken, refreshToken);
const tenants = await this.getTenants(accessToken);
const account = this.createAccount(tokenClaims, accessToken.key, tenants);
const subscriptions = await this.getSubscriptions(account);
account.properties.subscriptions = subscriptions;
return account;
} catch (ex) {
console.log(ex);
if (ex.msg) {
vscode.window.showErrorMessage(ex.msg);
}
return { canceled: false };
} finally {
azdata.accounts.endAutoOAuthDeviceCode();
}
}
private setupPolling(info: DeviceCodeLogin): Promise<DeviceCodeLoginResult> {
const timeoutMessage = localize('azure.timeoutDeviceCode', 'Timed out when waiting for device code login.');
const fiveMinutes = 5 * 60 * 1000;
return new Promise<DeviceCodeLoginResult | undefined>((resolve, reject) => {
let timeout: NodeJS.Timer;
const timer = setInterval(async () => {
const x = await this.checkForResult(info);
if (!x.access_token) {
return;
}
clearTimeout(timeout);
clearInterval(timer);
resolve(x);
}, info.interval * 1000);
timeout = setTimeout(() => {
clearInterval(timer);
reject(new Error(timeoutMessage));
}, fiveMinutes);
});
}
private async checkForResult(info: DeviceCodeLogin): Promise<DeviceCodeLoginResult> {
const msg = localize('azure.deviceCodeCheckFail', "Error encountered when trying to check for login results");
try {
const uri = `${this.loginEndpointUrl}/${this.commonTenant}/oauth2/token`;
const postData = {
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
client_id: this.clientId,
tenant: this.commonTenant,
code: info.device_code
};
const postResult = await this.makePostRequest(uri, postData);
const result: DeviceCodeLoginResult = postResult.data;
return result;
} catch (ex) {
console.log(ex);
throw new Error(msg);
}
}
public async autoOAuthCancelled(): Promise<void> {
return azdata.accounts.endAutoOAuthDeviceCode();
}
}

View File

@@ -3,446 +3,163 @@
* 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 * as nls from 'vscode-nls';
import {
AzureAccount,
AzureAccountProviderMetadata,
AzureAccountSecurityTokenCollection,
Tenant
AzureAuthType,
Deferred
} from './interfaces';
import TokenCache from './tokenCache';
import { SimpleTokenCache } from './simpleTokenCache';
import { AzureAuth, TokenResponse } from './auths/azureAuth';
import { AzureAuthCodeGrant } from './auths/azureAuthCodeGrant';
import { AzureDeviceCode } from './auths/azureDeviceCode';
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';
private static readonly CONFIGURATION_SECTION = 'accounts.azure.auth';
private readonly authMappings = new Map<AzureAuthType, AzureAuth>();
private initComplete: Deferred<void>;
private initCompletePromise: Promise<void> = new Promise<void>((resolve, reject) => this.initComplete = { resolve, reject });
// 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}`);
constructor(
metadata: AzureAccountProviderMetadata,
tokenCache: SimpleTokenCache,
context: vscode.ExtensionContext
) {
vscode.workspace.onDidChangeConfiguration((changeEvent) => {
const impact = changeEvent.affectsConfiguration(AzureAccountProvider.CONFIGURATION_SECTION);
if (impact === true) {
this.handleAuthMapping(metadata, tokenCache, context);
}
});
this.handleAuthMapping(metadata, tokenCache, context);
}
clearTokenCache(): Thenable<void> {
return this.getAuthMethod().deleteAllCache();
}
private handleAuthMapping(metadata: AzureAccountProviderMetadata, tokenCache: SimpleTokenCache, context: vscode.ExtensionContext) {
this.authMappings.clear();
const configuration = vscode.workspace.getConfiguration(AzureAccountProvider.CONFIGURATION_SECTION);
const codeGrantMethod: boolean = configuration.get('codeGrant');
const deviceCodeMethod: boolean = configuration.get('deviceCode');
if (codeGrantMethod === true) {
this.authMappings.set(AzureAuthType.AuthCodeGrant, new AzureAuthCodeGrant(metadata, tokenCache, context));
}
if (deviceCodeMethod === true) {
this.authMappings.set(AzureAuthType.DeviceCode, new AzureDeviceCode(metadata, tokenCache, context));
}
}
private getAuthMethod(account?: azdata.Account): AzureAuth {
if (this.authMappings.size === 1) {
return this.authMappings.values().next().value;
}
const authType: AzureAuthType = account?.properties?.azureAuthType;
if (authType) {
return this.authMappings.get(authType);
} else {
return this.authMappings.get(AzureAuthType.AuthCodeGrant);
}
}
initialize(storedAccounts: azdata.Account[]): Thenable<azdata.Account[]> {
return this._initialize(storedAccounts);
}
private async _initialize(storedAccounts: azdata.Account[]): Promise<azdata.Account[]> {
const accounts: azdata.Account[] = [];
for (let account of storedAccounts) {
const azureAuth = this.getAuthMethod(account);
if (!azureAuth) {
account.isStale = true;
accounts.push(account);
} else {
accounts.push(await azureAuth.refreshAccess(account));
}
}
this.initComplete.resolve();
return accounts;
}
getSecurityToken(account: azdata.Account, resource: azdata.AzureResource): Thenable<TokenResponse | undefined> {
return this._getSecurityToken(account, resource);
}
private async _getSecurityToken(account: azdata.Account, resource: azdata.AzureResource): Promise<TokenResponse | undefined> {
await this.initCompletePromise;
const azureAuth = this.getAuthMethod(undefined);
return azureAuth?.getSecurityToken(account, resource);
}
prompt(): Thenable<azdata.Account | azdata.PromptFailedResult> {
return this._prompt();
}
private async _prompt(): Promise<azdata.Account | azdata.PromptFailedResult> {
const noAuthSelected = localize('azure.NoAuthMethod.Selected', "No Azure auth method selected. You must select what method of authentication you want to use.");
const noAuthAvailable = localize('azure.NoAuthMethod.Available', "No Azure auth method available. You must enable the auth methods in ADS configuration.");
await this.initCompletePromise;
class Option implements vscode.QuickPickItem {
public readonly label: string;
constructor(public readonly azureAuth: AzureAuth) {
this.label = azureAuth.userFriendlyName;
}
}
if (this.authMappings.size === 0) {
console.log('No auth method was enabled.');
await vscode.window.showErrorMessage(noAuthAvailable);
return { canceled: true };
}
if (this.authMappings.size === 1) {
return this.getAuthMethod(undefined).login();
}
const options: Option[] = [];
this.authMappings.forEach((azureAuth) => {
options.push(new Option(azureAuth));
});
const pick = await vscode.window.showQuickPick(options, { canPickMany: false });
if (!pick) {
console.log('No auth method was selected.');
await vscode.window.showErrorMessage(noAuthSelected);
return { canceled: true };
}
return pick.azureAuth.login();
}
refresh(account: azdata.Account): Thenable<azdata.Account | azdata.PromptFailedResult> {
return this.prompt();
}
clear(accountKey: azdata.AccountKey): Thenable<void> {
return this._clear(accountKey);
}
private async _clear(accountKey: azdata.AccountKey): Promise<void> {
await this.initCompletePromise;
await this.getAuthMethod(undefined)?.clearCredentials(accountKey);
}
autoOAuthCancelled(): Thenable<void> {
this.authMappings.forEach(val => val.autoOAuthCancelled());
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],
[azdata.AzureResource.OssRdbms, self._metadata.settings.ossRdbmsResource.id],
[azdata.AzureResource.AzureKeyVault, self._metadata.settings.azureKeyVaultResource.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: {
providerSettings: this._metadata,
isMsAccount: msa,
tenants: tenants
},
isStale: false
};
}
}
interface InProgressAutoOAuth {
context: adal.AuthenticationContext;
userCodeInfo: adal.UserCodeInfo;
}

View File

@@ -1,509 +0,0 @@
/*---------------------------------------------------------------------------------------------
* 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';
import { promises as fs } from 'fs';
import * as path from 'path';
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, private _context: vscode.ExtensionContext) {
this.commonAuthorityUrl = url.resolve(this.metadata.settings.host, AzureAccountProvider.AadCommonTenant);
}
// interface method
clearTokenCache(): Thenable<void> {
return this._tokenCache.clear();
}
// 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) => {
if (err || !result) {
const msg = localize('azure.tokenCacheFail', "Unexpected error adding token to cache: {0}", err.message);
vscode.window.showErrorMessage(msg);
console.log(err);
}
});
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],
[azdata.AzureResource.OssRdbms, this.metadata.settings.ossRdbmsResource.id],
[azdata.AzureResource.AzureKeyVault, this.metadata.settings.azureKeyVaultResource.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 mediaPath = path.join(this._context.extensionPath, 'media');
// Utility function
const sendFile = async (res: http.ServerResponse, filePath: string, contentType: string): Promise<void> => {
let fileContents;
try {
fileContents = await fs.readFile(filePath);
} catch (ex) {
console.error(ex);
res.writeHead(200);
res.end();
return;
}
res.writeHead(200, {
'Content-Length': fileContents.length,
'Content-Type': contentType
});
res.end(fileContents);
};
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 authCallback = (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;
}
sendFile(res, path.join(mediaPath, 'landing.html'), 'text/html; charset=utf-8').catch(console.error);
this.handleAuthentication(code).catch((e) => console.error(e));
};
const css = (req: http.IncomingMessage, res: http.ServerResponse, reqUrl: url.UrlWithParsedQuery) => {
sendFile(res, path.join(mediaPath, 'landing.css'), 'text/css; charset=utf-8').catch(console.error);
};
const svg = (req: http.IncomingMessage, res: http.ServerResponse, reqUrl: url.UrlWithParsedQuery) => {
sendFile(res, path.join(mediaPath, 'SignIn.svg'), 'image/svg+xml').catch(console.error);
};
pathMappings.set('/signin', initialSignIn);
pathMappings.set('/callback', authCallback);
pathMappings.set('/landing.css', css);
pathMappings.set('/SignIn.svg', svg);
}
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 }) => {
let graphToken: TokenResponse;
try {
graphToken = await this.getToken(userId, value.tenantId, this.metadata.settings.graphResource.id);
} catch (ex) {
const msg = localize('azure.authFail', "Your authentication to the tenant {0} failed: {1}", value.tenantId, ex);
vscode.window.showErrorMessage(msg);
console.log(msg);
return undefined;
}
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: tenantDetails.length > 0 && tenantDetails[0].displayName
? tenantDetails[0].displayName
: localize('azureWorkAccountDisplayName', "Work or school account")
} as Tenant;
});
let tenants = await Promise.all(promises);
tenants = tenants.filter(t => t !== undefined);
if (tenants.length === 0) {
const msg = localize('azure.noTenants', "Failed to add account. No Azure tenants.");
vscode.window.showErrorMessage(msg);
throw new Error(msg);
}
if (homeTenant) {
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> {
let token: TokenResponse;
token = await this.getTokenWithAuthCode(code, AzureAccountProvider.redirectUrlAAD);
const tenants = await this.getTenants(token.userId, token.tenantId);
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.log('undefined request ', reqUrl.pathname, 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

@@ -6,11 +6,10 @@
import * as azdata from 'azdata';
import * as events from 'events';
import * as nls from 'vscode-nls';
import * as path from 'path';
import * as vscode from 'vscode';
import CredentialServiceTokenCache from './tokenCache';
import { SimpleTokenCache } from './simpleTokenCache';
import providerSettings from './providerSettings';
import { AzureAccountProvider as AzureAccountProvider } from './azureAccountProvider2';
import { AzureAccountProvider as AzureAccountProvider } from './azureAccountProvider';
import { AzureAccountProviderMetadata, ProviderSettings } from './interfaces';
import * as loc from '../localizedConstants';
@@ -19,7 +18,7 @@ let localize = nls.loadMessageBundle();
export class AzureAccountProviderService implements vscode.Disposable {
// CONSTANTS ///////////////////////////////////////////////////////////////
private static CommandClearTokenCache = 'accounts.clearTokenCache';
private static ConfigurationSection = 'accounts.azure';
private static ConfigurationSection = 'accounts.azure.cloud';
private static CredentialNamespace = 'azureAccountProviderCredentials';
// MEMBER VARIABLES ////////////////////////////////////////////////////////
@@ -55,10 +54,13 @@ export class AzureAccountProviderService implements vscode.Disposable {
// 2c) Perform an initial config change handling
return azdata.credentials.getProvider(AzureAccountProviderService.CredentialNamespace)
.then(credProvider => {
self._credentialProvider = credProvider;
this._credentialProvider = credProvider;
self._context.subscriptions.push(vscode.workspace.onDidChangeConfiguration(self.onDidChangeConfiguration, self));
self.onDidChangeConfiguration();
this._context.subscriptions.push(vscode.workspace.onDidChangeConfiguration(() => {
this._configChangePromiseChain = this.onDidChangeConfiguration();
}, this));
this._configChangePromiseChain = this.onDidChangeConfiguration();
return true;
});
}
@@ -85,82 +87,64 @@ export class AzureAccountProviderService implements vscode.Disposable {
});
}
private onDidChangeConfiguration(): void {
let self = this;
private async onDidChangeConfiguration(): Promise<void> {
// Add a new change processing onto the existing promise change
this._configChangePromiseChain = this._configChangePromiseChain.then(() => {
// Grab the stored config and the latest config
let newConfig = vscode.workspace.getConfiguration(AzureAccountProviderService.ConfigurationSection);
let oldConfig = self._currentConfig;
self._currentConfig = newConfig;
await this._configChangePromiseChain;
// Grab the stored config and the latest config
let newConfig = vscode.workspace.getConfiguration(AzureAccountProviderService.ConfigurationSection);
let oldConfig = this._currentConfig;
this._currentConfig = newConfig;
// Determine what providers need to be changed
let providerChanges: Thenable<void>[] = [];
for (let provider of providerSettings) {
// If the old config doesn't exist, then assume everything was disabled
// There will always be a new config value
let oldConfigValue = oldConfig
? oldConfig.get<boolean>(provider.configKey)
: false;
let newConfigValue = newConfig.get<boolean>(provider.configKey);
// Determine what providers need to be changed
let providerChanges: Promise<void>[] = [];
for (let provider of providerSettings) {
// If the old config doesn't exist, then assume everything was disabled
// There will always be a new config value
let oldConfigValue = oldConfig
? oldConfig.get<boolean>(provider.configKey)
: false;
let newConfigValue = newConfig.get<boolean>(provider.configKey);
// Case 1: Provider config has not changed - do nothing
if (oldConfigValue === newConfigValue) {
continue;
}
// Case 2: Provider was enabled and is now disabled - unregister provider
if (oldConfigValue && !newConfigValue) {
providerChanges.push(self.unregisterAccountProvider(provider));
}
// Case 3: Provider was disabled and is now enabled - register provider
if (!oldConfigValue && newConfigValue) {
providerChanges.push(self.registerAccountProvider(provider));
}
// Case 1: Provider config has not changed - do nothing
if (oldConfigValue === newConfigValue) {
continue;
}
// Process all the changes before continuing
return Promise.all(providerChanges);
}).then(null, () => { return Promise.resolve(); });
// Case 2: Provider was enabled and is now disabled - unregister provider
if (oldConfigValue && !newConfigValue) {
providerChanges.push(this.unregisterAccountProvider(provider));
}
// Case 3: Provider was disabled and is now enabled - register provider
if (!oldConfigValue && newConfigValue) {
providerChanges.push(this.registerAccountProvider(provider));
}
}
// Process all the changes before continuing
await Promise.all(providerChanges);
}
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(provider.metadata as AzureAccountProviderMetadata, tokenCache, this._context);
self._accountProviders[provider.metadata.id] = accountProvider;
self._accountDisposals[provider.metadata.id] = azdata.accounts.registerAccountProvider(provider.metadata, accountProvider);
resolve();
} catch (e) {
console.error(`Failed to register account provider: ${e}`);
reject(e);
}
});
private async registerAccountProvider(provider: ProviderSettings): Promise<void> {
try {
let tokenCacheKey = `azureTokenCache-${provider.metadata.id}`;
let simpleTokenCache = new SimpleTokenCache(tokenCacheKey, this._userStoragePath, false, this._credentialProvider);
await simpleTokenCache.init();
let accountProvider = new AzureAccountProvider(provider.metadata as AzureAccountProviderMetadata, simpleTokenCache, this._context);
this._accountProviders[provider.metadata.id] = accountProvider;
this._accountDisposals[provider.metadata.id] = azdata.accounts.registerAccountProvider(provider.metadata, accountProvider);
} catch (e) {
console.error(`Failed to register account provider: ${e}`);
}
}
private unregisterAccountProvider(provider: ProviderSettings): Thenable<void> {
let self = this;
return new Promise((resolve, reject) => {
try {
self._accountDisposals[provider.metadata.id].dispose();
delete self._accountProviders[provider.metadata.id];
delete self._accountDisposals[provider.metadata.id];
resolve();
} catch (e) {
console.error(`Failed to unregister account provider: ${e}`);
reject(e);
}
});
private async unregisterAccountProvider(provider: ProviderSettings): Promise<void> {
try {
this._accountDisposals[provider.metadata.id].dispose();
delete this._accountProviders[provider.metadata.id];
delete this._accountDisposals[provider.metadata.id];
} catch (e) {
console.error(`Failed to unregister account provider: ${e}`);
}
}
}

View File

@@ -23,12 +23,17 @@ export interface Tenant {
* Identifier of the user in the tenant
*/
userId: string;
/**
* The category the user has set their tenant to (e.g. Home Tenant)
*/
tenantCategory: string;
}
/**
* Represents a resource exposed by an Azure Active Directory
*/
interface Resource {
export interface Resource {
/**
* Identifier of the resource
*/
@@ -38,6 +43,11 @@ interface Resource {
* Endpoint url used to access the resource
*/
endpoint: string;
/**
* Resource ID for azdata
*/
azureResourceId?: azdata.AzureResource
}
/**
@@ -101,6 +111,8 @@ interface Settings {
* Redirect URI that is used to signify the end of the interactive aspect of sign it
*/
redirectUri?: string;
scopes?: string[]
}
/**
@@ -128,10 +140,20 @@ export interface AzureAccountProviderMetadata extends azdata.AccountProviderMeta
settings: Settings;
}
export enum AzureAuthType {
AuthCodeGrant = 0,
DeviceCode = 1
}
/**
* Properties specific to an Azure account
*/
interface AzureAccountProperties {
/**
* Auth type of azure used to authenticate this account.
*/
azureAuthType?: AzureAuthType
providerSettings: AzureAccountProviderMetadata;
/**
* Whether or not the account is a Microsoft account
@@ -142,6 +164,17 @@ interface AzureAccountProperties {
* A list of tenants (aka directories) that the account belongs to
*/
tenants: Tenant[];
/**
* A list of subscriptions the user belongs to
*/
subscriptions?: Subscription[];
}
export interface Subscription {
id: string,
tenantId: string,
displayName: string
}
/**
@@ -184,3 +217,8 @@ export interface AzureAccountSecurityToken {
* an access token. The list of tenants correspond to the tenants in the account properties.
*/
export type AzureAccountSecurityTokenCollection = { [tenantId: string]: AzureAccountSecurityToken };
export interface Deferred<T> {
resolve: (result: T | Promise<T>) => void;
reject: (reason: any) => void;
}

View File

@@ -5,6 +5,7 @@
import * as nls from 'vscode-nls';
import { ProviderSettings } from './interfaces';
import { AzureResource } from 'azdata';
const localize = nls.loadMessageBundle();
@@ -12,32 +13,40 @@ const publicAzureSettings: ProviderSettings = {
configKey: 'enablePublicCloud',
metadata: {
displayName: localize('publicCloudDisplayName', "Azure"),
id: 'azurePublicCloud',
id: 'azure_publicCloud',
settings: {
host: 'https://login.microsoftonline.com/',
clientId: 'a69788c6-1d43-44ed-9ca3-b83e194da255',
signInResourceId: 'https://management.core.windows.net/',
graphResource: {
id: 'https://graph.windows.net/',
endpoint: 'https://graph.windows.net'
id: 'graph',
endpoint: 'https://graph.microsoft.com',
azureResourceId: AzureResource.Graph
},
armResource: {
id: 'https://management.core.windows.net/',
endpoint: 'https://management.azure.com'
id: 'arm',
endpoint: 'https://management.azure.com',
azureResourceId: AzureResource.ResourceManagement
},
sqlResource: {
id: 'https://database.windows.net/',
endpoint: 'https://database.windows.net'
id: 'sql',
endpoint: 'https://database.windows.net',
azureResourceId: AzureResource.Sql
},
ossRdbmsResource: {
id: 'https://ossrdbms-aad.database.windows.net',
endpoint: 'https://ossrdbms-aad.database.windows.net'
id: 'ossrdbms',
endpoint: 'https://ossrdbms-aad.database.windows.net',
azureResourceId: AzureResource.OssRdbms
},
azureKeyVaultResource: {
id: 'https://vault.azure.net',
endpoint: 'https://vault.azure.net'
},
redirectUri: 'http://localhost/redirect'
redirectUri: 'https://vscode-redirect.azurewebsites.net/',
scopes: [
'openid', 'email', 'profile', 'offline_access',
'https://management.azure.com/user_impersonation',
]
}
}
};
@@ -47,24 +56,40 @@ const usGovAzureSettings: ProviderSettings = {
configKey: 'enableUsGovCloud',
metadata: {
displayName: localize('usGovCloudDisplayName', "Azure (US Government)"),
id: 'usGovAzureCloud',
id: 'azure_usGovtCloud',
settings: {
host: 'https://login.microsoftonline.us',
clientId: 'TBD',
host: 'https://login.microsoftonline.us/',
clientId: 'a69788c6-1d43-44ed-9ca3-b83e194da255',
signInResourceId: 'https://management.core.usgovcloudapi.net/',
graphResource: {
id: 'https://graph.usgovcloudapi.net/',
endpoint: 'https://graph.usgovcloudapi.net'
id: 'graph',
endpoint: 'https://graph.windows.net',
azureResourceId: AzureResource.Graph
},
armResource: {
id: 'https://management.core.usgovcloudapi.net/',
endpoint: 'https://management.usgovcloudapi.net'
id: 'arm',
endpoint: 'https://management.usgovcloudapi.net',
azureResourceId: AzureResource.ResourceManagement
},
sqlResource: {
id: 'sql',
endpoint: 'https://database.usgovcloudapi.net',
azureResourceId: AzureResource.Sql
},
ossRdbmsResource: {
id: 'ossrdbms',
endpoint: 'https://ossrdbms-aad.database.usgovcloudapi.net',
azureResourceId: AzureResource.OssRdbms
},
azureKeyVaultResource: {
id: 'https://vault.usgovcloudapi.net',
endpoint: 'https://vault.usgovcloudapi.net'
},
redirectUri: 'http://localhost/redirect'
redirectUri: 'https://vscode-redirect.azurewebsites.net/',
scopes: [
'openid', 'email', 'profile', 'offline_access',
'https://management.usgovcloudapi.net/user_impersonation'
]
}
}
};
@@ -74,10 +99,10 @@ const germanyAzureSettings: ProviderSettings = {
configKey: 'enableGermanyCloud',
metadata: {
displayName: localize('germanyCloud', "Azure (Germany)"),
id: 'germanyAzureCloud',
id: 'azure_germanyCloud',
settings: {
host: 'https://login.microsoftazure.de/',
clientId: 'TBD',
clientId: 'a69788c6-1d43-44ed-9ca3-b83e194da255',
signInResourceId: 'https://management.core.cloudapi.de/',
graphResource: {
id: 'https://graph.cloudapi.de/',
@@ -91,7 +116,7 @@ const germanyAzureSettings: ProviderSettings = {
id: 'https://vault.microsoftazure.de',
endpoint: 'https://vault.microsoftazure.de'
},
redirectUri: 'http://localhost/redirect'
redirectUri: 'https://vscode-redirect.azurewebsites.net/'
}
}
};
@@ -100,10 +125,10 @@ const chinaAzureSettings: ProviderSettings = {
configKey: 'enableChinaCloud',
metadata: {
displayName: localize('chinaCloudDisplayName', "Azure (China)"),
id: 'chinaAzureCloud',
id: 'azure_chinaCloud',
settings: {
host: 'https://login.chinacloudapi.cn/',
clientId: 'TBD',
clientId: 'a69788c6-1d43-44ed-9ca3-b83e194da255',
signInResourceId: 'https://management.core.chinacloudapi.cn/',
graphResource: {
id: 'https://graph.chinacloudapi.cn/',
@@ -117,7 +142,8 @@ const chinaAzureSettings: ProviderSettings = {
id: 'https://vault.azure.cn',
endpoint: 'https://vault.azure.cn'
},
redirectUri: 'http://localhost/redirect'
redirectUri: 'https://vscode-redirect.azurewebsites.net/'
}
}
};

View File

@@ -0,0 +1,187 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as keytarType from 'keytar';
import { join, parse } from 'path';
import { FileDatabase } from './utils/fileDatabase';
import * as crypto from 'crypto';
import * as azdata from 'azdata';
function getSystemKeytar(): Keytar | undefined {
try {
return require('keytar');
} catch (err) {
console.log(err);
}
return undefined;
}
export type MultipleAccountsResponse = { account: string, password: string }[];
const separator = '§';
async function getFileKeytar(filePath: string, credentialService: azdata.CredentialProvider): Promise<Keytar | undefined> {
const fileName = parse(filePath).base;
const iv = await credentialService.readCredential(`${fileName}-iv`);
const key = await credentialService.readCredential(`${fileName}-key`);
let ivBuffer: Buffer;
let keyBuffer: Buffer;
if (!iv?.password || !key?.password) {
ivBuffer = crypto.randomBytes(16);
keyBuffer = crypto.randomBytes(32);
try {
await credentialService.saveCredential(`${fileName}-iv`, ivBuffer.toString('hex'));
await credentialService.saveCredential(`${fileName}-key`, keyBuffer.toString('hex'));
} catch (ex) {
console.log(ex);
}
} else {
ivBuffer = Buffer.from(iv.password, 'hex');
keyBuffer = Buffer.from(key.password, 'hex');
}
const fileSaver = async (content: string): Promise<string> => {
const cipherIv = crypto.createCipheriv('aes-256-gcm', keyBuffer, ivBuffer);
return `${cipherIv.update(content, 'utf8', 'hex')}${cipherIv.final('hex')}%${cipherIv.getAuthTag().toString('hex')}`;
};
const fileOpener = async (content: string): Promise<string> => {
const decipherIv = crypto.createDecipheriv('aes-256-gcm', keyBuffer, ivBuffer);
const split = content.split('%');
if (split.length !== 2) {
throw new Error('File didn\'t contain the auth tag.');
}
decipherIv.setAuthTag(Buffer.from(split[1], 'hex'));
return `${decipherIv.update(split[0], 'hex', 'utf8')}${decipherIv.final('utf8')}`;
};
const db = new FileDatabase(filePath, fileOpener, fileSaver);
await db.initialize();
const fileKeytar: Keytar = {
async getPassword(service: string, account: string): Promise<string> {
return db.get(`${service}${separator}${account}`);
},
async setPassword(service: string, account: string, password: string): Promise<void> {
await db.set(`${service}${separator}${account}`, password);
},
async deletePassword(service: string, account: string): Promise<boolean> {
await db.delete(`${service}${separator}${account}`);
return true;
},
async getPasswords(service: string): Promise<MultipleAccountsResponse> {
const result = db.getPrefix(`${service}`);
if (!result) {
return [];
}
return result.map(({ key, value }) => {
return {
account: key.split(separator)[1],
password: value
};
});
}
};
return fileKeytar;
}
export type Keytar = {
getPassword: typeof keytarType['getPassword'];
setPassword: typeof keytarType['setPassword'];
deletePassword: typeof keytarType['deletePassword'];
getPasswords: (service: string) => Promise<MultipleAccountsResponse>;
findCredentials?: typeof keytarType['findCredentials'];
};
export class SimpleTokenCache {
private keytar: Keytar;
constructor(
private serviceName: string,
private readonly userStoragePath: string,
private readonly forceFileStorage: boolean = false,
private readonly credentialService: azdata.CredentialProvider,
) {
}
async init(): Promise<void> {
this.serviceName = this.serviceName.replace(/-/g, '_');
let keytar: Keytar;
if (this.forceFileStorage === false) {
keytar = getSystemKeytar();
// Override how findCredentials works
keytar.getPasswords = async (service: string): Promise<MultipleAccountsResponse> => {
const [serviceName, accountPrefix] = service.split(separator);
if (serviceName === undefined || accountPrefix === undefined) {
throw new Error('Service did not have seperator: ' + service);
}
const results = await keytar.findCredentials(serviceName);
return results.filter(({ account }) => {
return account.startsWith(accountPrefix);
});
};
}
if (!keytar) {
keytar = await getFileKeytar(join(this.userStoragePath, this.serviceName), this.credentialService);
}
this.keytar = keytar;
}
async saveCredential(id: string, key: string): Promise<void> {
if (key.length > 2500) { // Windows limitation
throw new Error('Key length is longer than 2500 chars');
}
if (id.includes(separator)) {
throw new Error('Separator included in ID');
}
try {
return await this.keytar.setPassword(this.serviceName, id, key);
} catch (ex) {
console.log(`Adding key failed: ${ex}`);
}
}
async getCredential(id: string): Promise<string | undefined> {
try {
const result = await this.keytar.getPassword(this.serviceName, id);
if (result === null) {
return undefined;
}
return result;
} catch (ex) {
console.log(`Getting key failed: ${ex}`);
return undefined;
}
}
async clearCredential(id: string): Promise<boolean> {
try {
return await this.keytar.deletePassword(this.serviceName, id);
} catch (ex) {
console.log(`Clearing key failed: ${ex}`);
return false;
}
}
async findCredentials(prefix: string): Promise<{ account: string, password: string }[]> {
try {
return await this.keytar.getPasswords(`${this.serviceName}${separator}${prefix}`);
} catch (ex) {
console.log(`Finding credentials failed: ${ex}`);
return undefined;
}
}
}

View File

@@ -120,6 +120,7 @@ export function registerAzureResourceCommands(appContext: AppContext, tree: Azur
subscriptions.push(...await subscriptionService.getSubscriptions(accountNode.account, new TokenCredentials(token, tokenType)));
}
} catch (error) {
this.account.isStale = true;
throw new AzureResourceCredentialError(localize('azure.resource.selectsubscriptions.credentialError', "Failed to get credential for account {0}. Please refresh the account.", this.account.key.accountId), error);
}
}

View File

@@ -0,0 +1,94 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as should from 'should';
import * as os from 'os';
import 'mocha';
import { PromptFailedResult, AccountKey } from 'azdata';
import { AzureAuth, AccessToken, RefreshToken, TokenClaims } from '../../../account-provider/auths/azureAuth';
import { AzureAccount, AzureAuthType } from '../../../account-provider/interfaces';
import providerSettings from '../../../account-provider/providerSettings';
import { SimpleTokenCache } from '../../../account-provider/simpleTokenCache';
import { CredentialsTestProvider } from '../../stubs/credentialsTestProvider';
class BasicAzureAuth extends AzureAuth {
public login(): Promise<AzureAccount | PromptFailedResult> {
throw new Error('Method not implemented.');
}
public autoOAuthCancelled(): Promise<void> {
throw new Error('Method not implemented.');
}
}
let baseAuth: AzureAuth;
const accountKey: AccountKey = {
accountId: 'SomeAccountKey',
providerId: 'providerId',
};
const accessToken: AccessToken = {
key: accountKey.accountId,
token: '123'
};
const refreshToken: RefreshToken = {
key: accountKey.accountId,
token: '321'
};
const resourceId = 'resource';
const tenantId = 'tenant';
// These tests don't work on Linux systems because gnome-keyring doesn't like running on headless machines.
describe('AccountProvider.AzureAuth', function (): void {
beforeEach(async function (): Promise<void> {
const tokenCache = new SimpleTokenCache('testTokenService', os.tmpdir(), true, new CredentialsTestProvider());
await tokenCache.init();
baseAuth = new BasicAzureAuth(providerSettings[0].metadata, tokenCache, undefined, AzureAuthType.AuthCodeGrant, 'Auth Code Grant');
});
it('Basic token set and get', async function (): Promise<void> {
await baseAuth.setCachedToken(accountKey, accessToken, refreshToken);
const result = await baseAuth.getCachedToken(accountKey);
should(JSON.stringify(result.accessToken)).be.equal(JSON.stringify(accessToken));
should(JSON.stringify(result.refreshToken)).be.equal(JSON.stringify(refreshToken));
});
it('Token set and get with tenant and resource id', async function (): Promise<void> {
await baseAuth.setCachedToken(accountKey, accessToken, refreshToken, resourceId, tenantId);
let result = await baseAuth.getCachedToken(accountKey, resourceId, tenantId);
should(JSON.stringify(result.accessToken)).be.equal(JSON.stringify(accessToken));
should(JSON.stringify(result.refreshToken)).be.equal(JSON.stringify(refreshToken));
await baseAuth.clearCredentials(accountKey);
result = await baseAuth.getCachedToken(accountKey, resourceId, tenantId);
should(result).be.undefined();
});
it('Token set with resource ID and get without tenant and resource id', async function (): Promise<void> {
await baseAuth.setCachedToken(accountKey, accessToken, refreshToken, resourceId, tenantId);
const result = await baseAuth.getCachedToken(accountKey);
should(JSON.stringify(result)).be.undefined();
should(JSON.stringify(result)).be.undefined();
});
it('Create an account object', async function (): Promise<void> {
const tokenClaims = {
idp: 'live.com',
name: 'TestAccount',
} as TokenClaims;
const account = baseAuth.createAccount(tokenClaims, 'someKey', undefined);
should(account.properties.azureAuthType).be.equal(AzureAuthType.AuthCodeGrant);
should(account.key.accountId).be.equal('someKey');
should(account.properties.isMsAccount).be.equal(true);
});
});