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
This commit is contained in:
Amir Omidi
2020-05-21 00:03:21 -07:00
committed by GitHub
parent efb890697c
commit 65a4a56fd7
4 changed files with 126 additions and 63 deletions

View File

@@ -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<void>;
public abstract async promptForConsent(resourceId: string, tenant: string): Promise<{ tokenRefreshResponse: TokenRefreshResponse, authCompleteDeferred: Deferred<void> } | undefined>;
public dispose() { }
public async refreshAccess(oldAccount: azdata.Account): Promise<azdata.Account> {
@@ -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<TokenRefreshResponse | undefined> {
protected async getToken(postData: { [key: string]: string }, tenant = this.commonTenant, resourceId: string = '', resourceEndpoint: string = ''): Promise<TokenRefreshResponse | undefined> {
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<boolean> {
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;

View File

@@ -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<void> } | undefined> {
let authCompleteDeferred: Deferred<void>;
let authCompletePromise = new Promise<void>((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<void> {
return this.server.shutdown();
}
public async loginWithLocalServer(authCompletePromise: Promise<void>): Promise<AuthCodeResponse | undefined> {
public async loginWithLocalServer(authCompletePromise: Promise<void>, resourceId: string, tenant: string = this.commonTenant): Promise<AuthCodeResponse | undefined> {
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<AuthCodeResponse | undefined> {
public async loginWithoutLocalServer(resourceId: string, tenant: string = this.commonTenant): Promise<AuthCodeResponse | undefined> {
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<azdata.Account | azdata.PromptFailedResult> {
let authCompleteDeferred: Deferred<void>;
let authCompletePromise = new Promise<void>((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);

View File

@@ -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<undefined> {
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<AzureAccount | azdata.PromptFailedResult> {
try {
const uri = `${this.loginEndpointUrl}/${this.commonTenant}/oauth2/devicecode`;

View File

@@ -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<AzureAccount | PromptFailedResult> {
public async login(): Promise<AzureAccount | PromptFailedResult> {
throw new Error('Method not implemented.');
}
public autoOAuthCancelled(): Promise<void> {
public async autoOAuthCancelled(): Promise<void> {
throw new Error('Method not implemented.');
}
public async promptForConsent(): Promise<{ tokenRefreshResponse: TokenRefreshResponse, authCompleteDeferred: Deferred<void> } | undefined> {
throw new Error('Method not implemented.');
}
}