From 65a4a56fd7311d059124bfb2d6a0b451d584e610 Mon Sep 17 00:00:00 2001 From: Amir Omidi Date: Thu, 21 May 2020 00:03:21 -0700 Subject: [PATCH] Prompt for consent when interaction is required (#10373) * Reprompt for consent * Remove unused imports * Fix typos * Change the tenant we re-auth too * Handle the prompt properly * prompt for consent dialog * Information message modal * product name * Change message per PM feedback --- .../src/account-provider/auths/azureAuth.ts | 82 ++++++++++++----- .../auths/azureAuthCodeGrant.ts | 87 +++++++++++-------- .../account-provider/auths/azureDeviceCode.ts | 7 +- .../account-provider/auths/azureAuth.test.ts | 13 ++- 4 files changed, 126 insertions(+), 63 deletions(-) diff --git a/extensions/azurecore/src/account-provider/auths/azureAuth.ts b/extensions/azurecore/src/account-provider/auths/azureAuth.ts index bcb8aa0ecb..6d5cc7a3cb 100644 --- a/extensions/azurecore/src/account-provider/auths/azureAuth.ts +++ b/extensions/azurecore/src/account-provider/auths/azureAuth.ts @@ -16,7 +16,8 @@ import { AzureAccount, Resource, AzureAuthType, - Subscription + Subscription, + Deferred } from '../interfaces'; import { SimpleTokenCache } from '../simpleTokenCache'; @@ -89,7 +90,7 @@ export interface TokenClaims { // https://docs.microsoft.com/en-us/azure/active- ver: string; } -export type TokenRefreshResponse = { accessToken: AccessToken, refreshToken: RefreshToken, tokenClaims: TokenClaims }; +export type TokenRefreshResponse = { accessToken: AccessToken, refreshToken: RefreshToken, tokenClaims: TokenClaims, expiresOn: string }; export abstract class AzureAuth implements vscode.Disposable { protected readonly memdb = new MemoryDatabase(); @@ -136,6 +137,8 @@ export abstract class AzureAuth implements vscode.Disposable { public abstract async autoOAuthCancelled(): Promise; + public abstract async promptForConsent(resourceId: string, tenant: string): Promise<{ tokenRefreshResponse: TokenRefreshResponse, authCompleteDeferred: Deferred } | undefined>; + public dispose() { } public async refreshAccess(oldAccount: azdata.Account): Promise { @@ -336,7 +339,7 @@ export abstract class AzureAuth implements vscode.Disposable { return tenants; } catch (ex) { console.log(ex); - throw new Error('Error retreiving tenant information'); + throw new Error('Error retrieving tenant information'); } } @@ -368,33 +371,50 @@ export abstract class AzureAuth implements vscode.Disposable { allSubs.push(...subscriptions); } catch (ex) { console.log(ex); - throw new Error('Error retreiving subscription information'); + throw new Error('Error retrieving subscription information'); } } return allSubs; } - protected async getToken(postData: { [key: string]: string }, tenant = this.commonTenant, resourceId: string = ''): Promise { + protected async getToken(postData: { [key: string]: string }, tenant = this.commonTenant, resourceId: string = '', resourceEndpoint: string = ''): Promise { try { - const tokenUrl = `${this.loginEndpointUrl}${tenant}/oauth2/token`; + let refreshResponse: TokenRefreshResponse; - const tokenResponse = await this.makePostRequest(tokenUrl, postData); - const tokenClaims = this.getTokenClaims(tokenResponse.data.access_token); + 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, - }; + 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 + }; + const expiresOn = tokenResponse.data.expires_on; - const refreshToken: RefreshToken = { - token: tokenResponse.data.refresh_token, - key: accessToken.key - }; - - return { accessToken, refreshToken, tokenClaims }; + refreshResponse = { accessToken, refreshToken, tokenClaims, expiresOn }; + } catch (ex) { + if (ex?.response?.data?.error === 'interaction_required') { + const shouldOpenLink = await this.openConsentDialog(tenant, resourceId); + if (shouldOpenLink === true) { + const { tokenRefreshResponse, authCompleteDeferred } = await this.promptForConsent(resourceEndpoint, tenant); + refreshResponse = tokenRefreshResponse; + authCompleteDeferred.resolve(); + } else { + vscode.window.showInformationMessage(localize('azure.noConsentToReauth', "The authentication failed since Azure Data Studio was unable to open re-authentication page.")); + } + } else { + return undefined; + } + } + this.memdb.set(this.createMemdbString(refreshResponse.accessToken.key, tenant, resourceId), refreshResponse.expiresOn); + return refreshResponse; } catch (err) { const msg = localize('azure.noToken', "Retrieving the Azure token failed. Please sign in again."); vscode.window.showErrorMessage(msg); @@ -402,6 +422,28 @@ export abstract class AzureAuth implements vscode.Disposable { } } + private async openConsentDialog(tenantId: string, resourceId: string): Promise { + interface ConsentMessageItem extends vscode.MessageItem { + booleanResult: boolean; + } + + const openItem: ConsentMessageItem = { + title: localize('open', "Open"), + booleanResult: true + }; + + const closeItem: ConsentMessageItem = { + title: localize('cancel', "Cancel"), + isCloseAffordance: true, + booleanResult: false + }; + + const messageBody = localize('azurecore.consentDialog.body', "Your tenant {0} requires you to re-authenticate again to access {1} resources. Press Open to start the authentication process.", tenantId, resourceId); + const result = await vscode.window.showInformationMessage(messageBody, { modal: true }, openItem, closeItem); + + return result.booleanResult; + } + protected getTokenClaims(accessToken: string): TokenClaims | undefined { try { const split = accessToken.split('.'); @@ -423,7 +465,7 @@ export abstract class AzureAuth implements vscode.Disposable { postData.resource = resource.endpoint; } - const getTokenResponse = await this.getToken(postData, tenant?.id, resource?.id); + const getTokenResponse = await this.getToken(postData, tenant?.id, resource?.id, resource?.endpoint); const accessToken = getTokenResponse?.accessToken; const refreshToken = getTokenResponse?.refreshToken; diff --git a/extensions/azurecore/src/account-provider/auths/azureAuthCodeGrant.ts b/extensions/azurecore/src/account-provider/auths/azureAuthCodeGrant.ts index 0083d8d0d3..92b1527c33 100644 --- a/extensions/azurecore/src/account-provider/auths/azureAuthCodeGrant.ts +++ b/extensions/azurecore/src/account-provider/auths/azureAuthCodeGrant.ts @@ -56,11 +56,52 @@ export class AzureAuthCodeGrant extends AzureAuth { super(metadata, tokenCache, context, uriEventEmitter, AzureAuthType.AuthCodeGrant, AzureAuthCodeGrant.USER_FRIENDLY_NAME); } + public async promptForConsent(resourceEndpoint: string, tenant: string = this.commonTenant): Promise<{ tokenRefreshResponse: TokenRefreshResponse, authCompleteDeferred: Deferred } | undefined> { + let authCompleteDeferred: Deferred; + let authCompletePromise = new Promise((resolve, reject) => authCompleteDeferred = { resolve, reject }); + + let authResponse: AuthCodeResponse; + if (vscode.env.uiKind === vscode.UIKind.Web) { + authResponse = await this.loginWithoutLocalServer(resourceEndpoint, tenant); + } else { + authResponse = await this.loginWithLocalServer(authCompletePromise, resourceEndpoint, tenant); + } + + let tokenClaims: TokenClaims; + let accessToken: AccessToken; + let refreshToken: RefreshToken; + let expiresOn: string; + + try { + const { accessToken: at, refreshToken: rt, tokenClaims: tc, expiresOn: eo } = await this.getTokenWithAuthCode(authResponse.authCode, authResponse.codeVerifier, this.redirectUri); + tokenClaims = tc; + accessToken = at; + refreshToken = rt; + expiresOn = eo; + } catch (ex) { + if (ex.msg) { + vscode.window.showErrorMessage(ex.msg); + } + console.log(ex); + } + + if (!accessToken) { + const msg = localize('azure.tokenFail', "Failure when retrieving tokens."); + authCompleteDeferred.reject(new Error(msg)); + throw Error('Failure when retrieving tokens'); + } + + return { + tokenRefreshResponse: { accessToken, refreshToken, tokenClaims, expiresOn }, + authCompleteDeferred + }; + } + public async autoOAuthCancelled(): Promise { return this.server.shutdown(); } - public async loginWithLocalServer(authCompletePromise: Promise): Promise { + public async loginWithLocalServer(authCompletePromise: Promise, resourceId: string, tenant: string = this.commonTenant): Promise { this.server = new SimpleWebServer(); const nonce = crypto.randomBytes(16).toString('base64'); let serverPort: string; @@ -90,9 +131,9 @@ export class AzureAuthCodeGrant extends AzureAuth { prompt: 'select_account', code_challenge_method: 'S256', code_challenge: codeChallenge, - resource: this.metadata.settings.signInResourceId + resource: resourceId }; - loginUrl = `${this.loginEndpointUrl}${this.commonTenant}/oauth2/authorize?${qs.stringify(loginQuery)}`; + loginUrl = `${this.loginEndpointUrl}${tenant}/oauth2/authorize?${qs.stringify(loginQuery)}`; } await vscode.env.openExternal(vscode.Uri.parse(`http://localhost:${serverPort}/signin?nonce=${encodeURIComponent(nonce)}`)); @@ -105,7 +146,7 @@ export class AzureAuthCodeGrant extends AzureAuth { }; } - public async loginWithoutLocalServer(): Promise { + public async loginWithoutLocalServer(resourceId: string, tenant: string = this.commonTenant): Promise { const callbackUri = await vscode.env.asExternalUri(vscode.Uri.parse(`${vscode.env.uriScheme}://microsoft.azurecore`)); const nonce = crypto.randomBytes(16).toString('base64'); const port = (callbackUri.authority.match(/:([0-9]*)$/) || [])[1] || (callbackUri.scheme === 'https' ? 443 : 80); @@ -123,10 +164,10 @@ export class AzureAuthCodeGrant extends AzureAuth { prompt: 'select_account', code_challenge_method: 'S256', code_challenge: codeChallenge, - resource: this.metadata.settings.signInResourceId + resource: resourceId }; - const signInUrl = `${this.loginEndpointUrl}${this.commonTenant}/oauth2/authorize?${qs.stringify(loginQuery)}`; + const signInUrl = `${this.loginEndpointUrl}${tenant}/oauth2/authorize?${qs.stringify(loginQuery)}`; await vscode.env.openExternal(vscode.Uri.parse(signInUrl)); const authCode = await this.handleCodeResponse(state); @@ -159,38 +200,8 @@ export class AzureAuthCodeGrant extends AzureAuth { } public async login(): Promise { - - let authCompleteDeferred: Deferred; - let authCompletePromise = new Promise((resolve, reject) => authCompleteDeferred = { resolve, reject }); - - let authResponse: AuthCodeResponse; - if (vscode.env.uiKind === vscode.UIKind.Web) { - authResponse = await this.loginWithoutLocalServer(); - } else { - authResponse = await this.loginWithLocalServer(authCompletePromise); - } - - let tokenClaims: TokenClaims; - let accessToken: AccessToken; - let refreshToken: RefreshToken; - - try { - const { accessToken: at, refreshToken: rt, tokenClaims: tc } = await this.getTokenWithAuthCode(authResponse.authCode, authResponse.codeVerifier, this.redirectUri); - tokenClaims = tc; - accessToken = at; - refreshToken = rt; - } catch (ex) { - if (ex.msg) { - 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 { tokenRefreshResponse, authCompleteDeferred } = await this.promptForConsent(this.metadata.settings.signInResourceId); + const { accessToken, refreshToken, tokenClaims } = tokenRefreshResponse; const tenants = await this.getTenants(accessToken); diff --git a/extensions/azurecore/src/account-provider/auths/azureDeviceCode.ts b/extensions/azurecore/src/account-provider/auths/azureDeviceCode.ts index ce434674a9..18c5890d90 100644 --- a/extensions/azurecore/src/account-provider/auths/azureDeviceCode.ts +++ b/extensions/azurecore/src/account-provider/auths/azureDeviceCode.ts @@ -10,7 +10,7 @@ import { AzureAuth, TokenClaims, AccessToken, - RefreshToken + RefreshToken, } from './azureAuth'; @@ -57,6 +57,11 @@ export class AzureDeviceCode extends AzureAuth { } + public async promptForConsent(resourceId: string, tenant: string = this.commonTenant): Promise { + vscode.window.showErrorMessage(localize('azure.deviceCodeDoesNotSupportConsent', "Device code authentication does not support prompting for consent. Switch the authentication method in settings to code grant.")); + return undefined; + } + public async login(): Promise { try { const uri = `${this.loginEndpointUrl}/${this.commonTenant}/oauth2/devicecode`; diff --git a/extensions/azurecore/src/test/account-provider/auths/azureAuth.test.ts b/extensions/azurecore/src/test/account-provider/auths/azureAuth.test.ts index 604c70fc50..d270976796 100644 --- a/extensions/azurecore/src/test/account-provider/auths/azureAuth.test.ts +++ b/extensions/azurecore/src/test/account-provider/auths/azureAuth.test.ts @@ -8,17 +8,22 @@ 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 { AzureAuth, AccessToken, RefreshToken, TokenClaims, TokenRefreshResponse } from '../../../account-provider/auths/azureAuth'; +import { AzureAccount, AzureAuthType, Deferred } 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 { + public async login(): Promise { throw new Error('Method not implemented.'); } - public autoOAuthCancelled(): Promise { + + public async autoOAuthCancelled(): Promise { + throw new Error('Method not implemented.'); + } + + public async promptForConsent(): Promise<{ tokenRefreshResponse: TokenRefreshResponse, authCompleteDeferred: Deferred } | undefined> { throw new Error('Method not implemented.'); } }