diff --git a/src/Common/CosmosClient.ts b/src/Common/CosmosClient.ts index f286f3fbc..f7a4fbfc5 100644 --- a/src/Common/CosmosClient.ts +++ b/src/Common/CosmosClient.ts @@ -27,7 +27,7 @@ export const tokenProvider = async (requestInfo: Cosmos.RequestInfo) => { ); if (!userContext.aadToken) { logConsoleError( - `AAD token does not exist. Please click on "Login for Entra ID" button prior to performing Entra ID RBAC operations`, + `AAD token does not exist. Please use the "Login for Entra ID" button in the Toolbar prior to performing Entra ID RBAC operations`, ); return null; } diff --git a/src/Explorer/Explorer.tsx b/src/Explorer/Explorer.tsx index da21f0cf8..c8c341e7e 100644 --- a/src/Explorer/Explorer.tsx +++ b/src/Explorer/Explorer.tsx @@ -10,7 +10,7 @@ import { getCopilotEnabled, isCopilotFeatureRegistered } from "Explorer/QueryCop import { IGalleryItem } from "Juno/JunoClient"; import { scheduleRefreshDatabaseResourceToken } from "Platform/Fabric/FabricUtil"; import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility"; -import { acquireTokenWithMsal, getMsalInstance } from "Utils/AuthorizationUtils"; +import { acquireMsalTokenForAccount } from "Utils/AuthorizationUtils"; import { allowedNotebookServerUrls, validateEndpoint } from "Utils/EndpointUtils"; import { update } from "Utils/arm/generatedClients/cosmos/databaseAccounts"; import { useQueryCopilot } from "hooks/useQueryCopilot"; @@ -259,25 +259,8 @@ export default class Explorer { public async openLoginForEntraIDPopUp(): Promise { if (userContext.databaseAccount.properties?.documentEndpoint) { - const hrefEndpoint = new URL(userContext.databaseAccount.properties.documentEndpoint).href.replace( - /\/$/, - "/.default", - ); - const msalInstance = await getMsalInstance(); - try { - const response = await msalInstance.loginPopup({ - redirectUri: configContext.msalRedirectURI, - scopes: [], - }); - localStorage.setItem("cachedTenantId", response.tenantId); - const cachedAccount = msalInstance.getAllAccounts()?.[0]; - msalInstance.setActiveAccount(cachedAccount); - const aadToken = await acquireTokenWithMsal(msalInstance, { - forceRefresh: true, - scopes: [hrefEndpoint], - authority: `${configContext.AAD_ENDPOINT}${localStorage.getItem("cachedTenantId")}`, - }); + const aadToken = await acquireMsalTokenForAccount(userContext.databaseAccount, false); updateUserContext({ aadToken: aadToken }); useDataPlaneRbac.setState({ aadTokenUpdated: true }); } catch (error) { diff --git a/src/Explorer/Panes/SettingsPane/SettingsPane.tsx b/src/Explorer/Panes/SettingsPane/SettingsPane.tsx index 92b2f9ad1..12b5a8c47 100644 --- a/src/Explorer/Panes/SettingsPane/SettingsPane.tsx +++ b/src/Explorer/Panes/SettingsPane/SettingsPane.tsx @@ -1,3 +1,7 @@ +import { + AuthError as msalAuthError, + BrowserAuthErrorMessage as msalBrowserAuthErrorMessage, +} from "@azure/msal-browser"; import { Checkbox, ChoiceGroup, @@ -5,8 +9,6 @@ import { IChoiceGroupOption, ISpinButtonStyles, IToggleStyles, - MessageBar, - MessageBarType, Position, SpinButton, Toggle, @@ -30,6 +32,7 @@ import { } from "Shared/StorageUtility"; import * as StringUtility from "Shared/StringUtility"; import { updateUserContext, userContext } from "UserContext"; +import { acquireMsalTokenForAccount } from "Utils/AuthorizationUtils"; import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils"; import * as PriorityBasedExecutionUtils from "Utils/PriorityBasedExecutionUtils"; import { getReadOnlyKeys, listKeys } from "Utils/arm/generatedClients/cosmos/databaseAccounts"; @@ -108,7 +111,6 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({ ? LocalStorageUtility.getEntryString(StorageKey.DataPlaneRbacEnabled) : Constants.RBACOptions.setAutomaticRBACOption, ); - const [showDataPlaneRBACWarning, setShowDataPlaneRBACWarning] = useState(false); const [ruThresholdEnabled, setRUThresholdEnabled] = useState(isRUThresholdEnabled()); const [ruThreshold, setRUThreshold] = useState(getRUThreshold()); @@ -203,6 +205,24 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({ hasDataPlaneRbacSettingChanged: true, }); useDataPlaneRbac.setState({ dataPlaneRbacEnabled: true }); + try { + const aadToken = await acquireMsalTokenForAccount(userContext.databaseAccount, true); + updateUserContext({ aadToken: aadToken }); + useDataPlaneRbac.setState({ aadTokenUpdated: true }); + } catch (authError) { + if ( + authError instanceof msalAuthError && + authError.errorCode === msalBrowserAuthErrorMessage.popUpWindowError.code + ) { + logConsoleError( + `We were unable to establish authorization for this account, due to pop-ups being disabled in the browser.\nPlease enable pop-ups for this site and click on "Login for Entra ID" button`, + ); + } else { + logConsoleError( + `"Failed to acquire authorization token automatically. Please click on "Login for Entra ID" button to enable Entra ID RBAC operations`, + ); + } + } } else { updateUserContext({ dataPlaneRbacEnabled: false, @@ -347,13 +367,6 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({ option: IChoiceGroupOption, ): void => { setEnableDataPlaneRBACOption(option.key); - - const shouldShowWarning = - (option.key === Constants.RBACOptions.setTrueRBACOption || - (option.key === Constants.RBACOptions.setAutomaticRBACOption && - userContext.databaseAccount.properties.disableLocalAuth === true)) && - !useDataPlaneRbac.getState().aadTokenUpdated; - setShowDataPlaneRBACWarning(shouldShowWarning); }; const handleOnRUThresholdToggleChange = (ev: React.MouseEvent, checked?: boolean): void => { @@ -528,17 +541,6 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
- {showDataPlaneRBACWarning && configContext.platform === Platform.Portal && ( - setShowDataPlaneRBACWarning(false)} - dismissButtonAriaLabel="Close" - > - Please click on "Login for Entra ID RBAC" button prior to performing Entra ID RBAC - operations - - )}
Choose Automatic to enable Entra ID RBAC automatically. True/False to force enable/disable Entra ID RBAC. diff --git a/src/Utils/AuthorizationUtils.ts b/src/Utils/AuthorizationUtils.ts index 35fd4ed39..8cb5580ba 100644 --- a/src/Utils/AuthorizationUtils.ts +++ b/src/Utils/AuthorizationUtils.ts @@ -1,11 +1,12 @@ import * as msal from "@azure/msal-browser"; -import { Action } from "Shared/Telemetry/TelemetryConstants"; +import { Action, ActionModifiers } from "Shared/Telemetry/TelemetryConstants"; import { AuthType } from "../AuthType"; import * as Constants from "../Common/Constants"; import * as Logger from "../Common/Logger"; import { configContext } from "../ConfigContext"; +import { DatabaseAccount } from "../Contracts/DataModels"; import * as ViewModels from "../Contracts/ViewModels"; -import { traceFailure } from "../Shared/Telemetry/TelemetryProcessor"; +import { trace, traceFailure } from "../Shared/Telemetry/TelemetryProcessor"; import { userContext } from "../UserContext"; export function getAuthorizationHeader(): ViewModels.AuthorizationTokenHeaderMetadata { @@ -64,7 +65,83 @@ export async function getMsalInstance() { return msalInstance; } -export async function acquireTokenWithMsal(msalInstance: msal.IPublicClientApplication, request: msal.SilentRequest) { +export async function acquireMsalTokenForAccount( + account: DatabaseAccount, + silent: boolean = false, + user_hint?: string, +) { + if (userContext.databaseAccount.properties?.documentEndpoint === undefined) { + throw new Error("Database account has no document endpoint defined"); + } + const hrefEndpoint = new URL(userContext.databaseAccount.properties.documentEndpoint).href.replace( + /\/+$/, + "/.default", + ); + const msalInstance = await getMsalInstance(); + const knownAccounts = msalInstance.getAllAccounts(); + // If user_hint is provided, we will try to use it to find the account. + // If no account is found, we will use the current active account or first account in the list. + const msalAccount = + knownAccounts?.filter((account) => account.username === user_hint)[0] ?? + msalInstance.getActiveAccount() ?? + knownAccounts?.[0]; + + if (!msalAccount) { + // If no account was found, we need to sign in. + // This will eventually throw InteractionRequiredAuthError if silent is true, we won't handle it here. + const loginRequest = { + scopes: [hrefEndpoint], + loginHint: user_hint, + }; + try { + if (silent) { + // We can try to use SSO between different apps to avoid showing a popup. + // With a hint provided, this should work in most cases. + // See https://learn.microsoft.com/en-us/entra/identity-platform/msal-js-sso#sso-between-different-apps + try { + const loginResponse = await msalInstance.ssoSilent(loginRequest); + return loginResponse.accessToken; + } catch (silentError) { + trace(Action.SignInAad, ActionModifiers.Mark, { + request: JSON.stringify(loginRequest), + acquireTokenType: silent ? "silent" : "interactive", + errorMessage: JSON.stringify(silentError), + }); + } + } + // If silent acquisition failed, we need to show a popup. + // Passing prompt: "none" will still show a popup but not perform a full sign-in. + // This will only work if the user has already signed in and the session is still valid. + // See https://learn.microsoft.com/en-us/entra/identity-platform/msal-js-prompt-behavior#interactive-requests-with-promptnone + // The hint will be used to pre-fill the username field in the popup if silent is false. + const loginResponse = await msalInstance.loginPopup({ prompt: silent ? "none" : "login", ...loginRequest }); + return loginResponse.accessToken; + } catch (error) { + traceFailure(Action.SignInAad, { + request: JSON.stringify(loginRequest), + acquireTokenType: silent ? "silent" : "interactive", + errorMessage: JSON.stringify(error), + }); + throw error; + } + } else { + msalInstance.setActiveAccount(msalAccount); + } + + const tokenRequest = { + account: msalAccount || null, + forceRefresh: true, + scopes: [hrefEndpoint], + authority: `${configContext.AAD_ENDPOINT}${msalAccount.tenantId}`, + }; + return acquireTokenWithMsal(msalInstance, tokenRequest, silent); +} + +export async function acquireTokenWithMsal( + msalInstance: msal.IPublicClientApplication, + request: msal.SilentRequest, + silent: boolean = false, +) { const tokenRequest = { account: msalInstance.getActiveAccount() || null, ...request, @@ -74,7 +151,7 @@ export async function acquireTokenWithMsal(msalInstance: msal.IPublicClientAppli // attempt silent acquisition first return (await msalInstance.acquireTokenSilent(tokenRequest)).accessToken; } catch (silentError) { - if (silentError instanceof msal.InteractionRequiredAuthError) { + if (silentError instanceof msal.InteractionRequiredAuthError && silent === false) { try { // The error indicates that we need to acquire the token interactively. // This will display a pop-up to re-establish authorization. If user does not diff --git a/src/hooks/useKnockoutExplorer.ts b/src/hooks/useKnockoutExplorer.ts index 08afeb65f..590b9195b 100644 --- a/src/hooks/useKnockoutExplorer.ts +++ b/src/hooks/useKnockoutExplorer.ts @@ -41,7 +41,12 @@ import { import { extractFeatures } from "../Platform/Hosted/extractFeatures"; import { DefaultExperienceUtility } from "../Shared/DefaultExperienceUtility"; import { Node, PortalEnv, updateUserContext, userContext } from "../UserContext"; -import { acquireTokenWithMsal, getAuthorizationHeader, getMsalInstance } from "../Utils/AuthorizationUtils"; +import { + acquireMsalTokenForAccount, + acquireTokenWithMsal, + getAuthorizationHeader, + getMsalInstance, +} from "../Utils/AuthorizationUtils"; import { isInvalidParentFrameOrigin, shouldProcessMessage } from "../Utils/MessageValidation"; import { getReadOnlyKeys, listKeys } from "../Utils/arm/generatedClients/cosmos/databaseAccounts"; import { applyExplorerBindings } from "../applyExplorerBindings"; @@ -575,6 +580,22 @@ async function configurePortal(): Promise { "Explorer/configurePortal", ); await fetchAndUpdateKeys(subscriptionId, resourceGroup, account.name); + } else { + Logger.logInfo( + `Trying to silently acquire MSAL token for ${userContext.apiType} account ${account.name}`, + "Explorer/configurePortal", + ); + try { + const aadToken = await acquireMsalTokenForAccount(userContext.databaseAccount, true); + updateUserContext({ aadToken: aadToken }); + useDataPlaneRbac.setState({ aadTokenUpdated: true }); + } catch (authError) { + Logger.logWarning( + `Failed to silently acquire authorization token from MSAL: ${authError} for ${userContext.apiType} account ${account}`, + "Explorer/configurePortal", + ); + logConsoleError("Failed to silently acquire authorization token: " + authError); + } } updateUserContext({ dataPlaneRbacEnabled });