mirror of
https://github.com/ckaczor/azuredatastudio.git
synced 2026-02-10 10:12:34 -05:00
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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user