diff --git a/.vs/slnx.sqlite b/.vs/slnx.sqlite deleted file mode 100644 index 2891048dc..000000000 Binary files a/.vs/slnx.sqlite and /dev/null differ diff --git a/package-lock.json b/package-lock.json index 5f90efb72..27fef4d40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@azure/cosmos": "4.7.0", "@azure/cosmos-language-service": "0.0.5", "@azure/identity": "4.5.0", - "@azure/msal-browser": "2.14.2", + "@azure/msal-browser": "^5.2.0", "@babel/plugin-proposal-class-properties": "7.12.1", "@babel/plugin-proposal-decorators": "7.12.12", "@fluentui/react": "8.119.0", @@ -590,21 +590,22 @@ "license": "0BSD" }, "node_modules/@azure/msal-browser": { - "version": "2.14.2", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.6.0.tgz", + "integrity": "sha512-LLqyAtpQNfnATQKnplg/dKJaigxGaaMPrp003ZWGnWwsAmmtzk7xcHEVykCu/4FMyyIfn66NPPzxS9DHrg/UOA==", "license": "MIT", "dependencies": { - "@azure/msal-common": "^4.3.0" + "@azure/msal-common": "16.4.0" }, "engines": { "node": ">=0.8.0" } }, "node_modules/@azure/msal-common": { - "version": "4.5.1", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.4.0.tgz", + "integrity": "sha512-twXt09PYtj1PffNNIAzQlrBd0DS91cdA6i1gAfzJ6BnPM4xNk5k9q/5xna7jLIjU3Jnp0slKYtucshGM8OGNAw==", "license": "MIT", - "dependencies": { - "debug": "^4.1.1" - }, "engines": { "node": ">=0.8.0" } @@ -15860,6 +15861,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "optional": true, "os": [ diff --git a/package.json b/package.json index 9a34e6a5c..5e71c1b74 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "@azure/cosmos": "4.7.0", "@azure/cosmos-language-service": "0.0.5", "@azure/identity": "4.5.0", - "@azure/msal-browser": "2.14.2", + "@azure/msal-browser": "^5.2.0", "@babel/plugin-proposal-class-properties": "7.12.1", "@babel/plugin-proposal-decorators": "7.12.12", "@fluentui/react": "8.119.0", diff --git a/src/Explorer/Explorer.tsx b/src/Explorer/Explorer.tsx index 55553b852..95b58b108 100644 --- a/src/Explorer/Explorer.tsx +++ b/src/Explorer/Explorer.tsx @@ -280,7 +280,7 @@ export default class Explorer { updateUserContext({ aadToken: aadToken }); useDataPlaneRbac.setState({ aadTokenUpdated: true }); } catch (error) { - if (error instanceof msal.AuthError && error.errorCode === msal.BrowserAuthErrorMessage.popUpWindowError.code) { + if (error instanceof msal.AuthError && error.errorCode === msal.BrowserAuthErrorCodes.popupWindowError) { 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 try again", ); diff --git a/src/Explorer/Panes/SettingsPane/SettingsPane.tsx b/src/Explorer/Panes/SettingsPane/SettingsPane.tsx index d6324d52b..5b2806648 100644 --- a/src/Explorer/Panes/SettingsPane/SettingsPane.tsx +++ b/src/Explorer/Panes/SettingsPane/SettingsPane.tsx @@ -1,7 +1,4 @@ -import { - AuthError as msalAuthError, - BrowserAuthErrorMessage as msalBrowserAuthErrorMessage, -} from "@azure/msal-browser"; +import { AuthError as msalAuthError, BrowserAuthErrorCodes as msalBrowserAuthErrorCodes } from "@azure/msal-browser"; import { Checkbox, ChoiceGroup, @@ -305,7 +302,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({ } catch (authError) { if ( authError instanceof msalAuthError && - authError.errorCode === msalBrowserAuthErrorMessage.popUpWindowError.code + authError.errorCode === msalBrowserAuthErrorCodes.popupWindowError ) { logConsoleError(t(Keys.panes.settings.popupsDisabledError)); } else { diff --git a/src/Utils/AuthorizationUtils.test.ts b/src/Utils/AuthorizationUtils.test.ts index 96250a9a5..993b6b166 100644 --- a/src/Utils/AuthorizationUtils.test.ts +++ b/src/Utils/AuthorizationUtils.test.ts @@ -1,12 +1,14 @@ import { AuthType } from "../AuthType"; import * as Constants from "../Common/Constants"; -import { resetConfigContext, updateConfigContext } from "../ConfigContext"; +import { resetConfigContext } from "../ConfigContext"; import { ApiType, updateUserContext, userContext } from "../UserContext"; import * as AuthorizationUtils from "./AuthorizationUtils"; jest.mock("../Explorer/Explorer"); jest.mock("@azure/msal-browser", () => ({ PublicClientApplication: jest.fn().mockImplementation((config) => ({ _config: config, + initialize: jest.fn().mockResolvedValue(undefined), + handleRedirectPromise: jest.fn().mockResolvedValue(null), })), })); @@ -138,41 +140,27 @@ describe("AuthorizationUtils", () => { }); describe("getMsalInstance()", () => { - const originalHostname = window.location.hostname; + const originalNodeEnv = process.env.NODE_ENV; afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; resetConfigContext(); - Object.defineProperty(window, "location", { - value: { ...window.location, hostname: originalHostname }, - writable: true, - }); }); - it("should use configContext.msalRedirectURI when set", async () => { - updateConfigContext({ msalRedirectURI: "https://dataexplorer-preview.azurewebsites.net/" }); + it("should use dev redirect bridge URL in development mode", async () => { + process.env.NODE_ENV = "development"; const instance = await AuthorizationUtils.getMsalInstance(); // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((instance as any)._config.auth.redirectUri).toBe("https://dataexplorer-preview.azurewebsites.net/"); + expect((instance as any)._config.auth.redirectUri).toBe( + "https://dataexplorer-dev.azurewebsites.net/redirectBridge.html", + ); }); - it("should use dev redirect URI on localhost", async () => { - Object.defineProperty(window, "location", { - value: { ...window.location, hostname: "localhost" }, - writable: true, - }); + it("should use origin-based redirect bridge URL in production", async () => { + process.env.NODE_ENV = "production"; const instance = await AuthorizationUtils.getMsalInstance(); // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((instance as any)._config.auth.redirectUri).toBe("https://dataexplorer-dev.azurewebsites.net"); - }); - - it("should not set redirect URI in non-localhost production", async () => { - Object.defineProperty(window, "location", { - value: { ...window.location, hostname: "cosmos.azure.com" }, - writable: true, - }); - const instance = await AuthorizationUtils.getMsalInstance(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((instance as any)._config.auth.redirectUri).toBeUndefined(); + expect((instance as any)._config.auth.redirectUri).toBe("http://localhost/redirectBridge.html"); }); }); }); diff --git a/src/Utils/AuthorizationUtils.ts b/src/Utils/AuthorizationUtils.ts index a3ba22107..14fd7c04c 100644 --- a/src/Utils/AuthorizationUtils.ts +++ b/src/Utils/AuthorizationUtils.ts @@ -51,6 +51,12 @@ export function decryptJWTToken(token: string) { } export async function getMsalInstance() { + // Compute the redirect bridge URL for MSAL v5 COOP handling + const redirectBridgeUrl = + process.env.NODE_ENV === "development" + ? "https://dataexplorer-dev.azurewebsites.net/redirectBridge.html" + : `${window.location.origin}/redirectBridge.html`; + const msalConfig: msal.Configuration = { cache: { cacheLocation: "localStorage", @@ -58,16 +64,16 @@ export async function getMsalInstance() { auth: { authority: `${configContext.AAD_ENDPOINT}organizations`, clientId: "203f1145-856a-4232-83d4-a43568fba23d", + // MSAL v5 requires redirect bridge for popup/silent flows + redirectUri: redirectBridgeUrl, }, }; - if (configContext.msalRedirectURI) { - msalConfig.auth.redirectUri = configContext.msalRedirectURI; - } else if (process.env.NODE_ENV === "development" || window.location.hostname === "localhost") { - msalConfig.auth.redirectUri = "https://dataexplorer-dev.azurewebsites.net"; - } - const msalInstance = new msal.PublicClientApplication(msalConfig); + // v3+ requires explicit initialization before using MSAL APIs + await msalInstance.initialize(); + // Handle any redirect response (e.g., after logoutRedirect) to clear interaction state + await msalInstance.handleRedirectPromise(); return msalInstance; } diff --git a/src/hooks/useAADAuth.ts b/src/hooks/useAADAuth.ts index 6a927dca0..062455793 100644 --- a/src/hooks/useAADAuth.ts +++ b/src/hooks/useAADAuth.ts @@ -58,10 +58,15 @@ export function useAADAuth(config?: ConfigContext): ReturnType { if (!msalInstance || !config) { return; } + // Use redirect bridge for MSAL v5 COOP handling + const redirectBridgeUrl = + process.env.NODE_ENV === "development" + ? "https://dataexplorer-dev.azurewebsites.net/redirectBridge.html" + : `${window.location.origin}/redirectBridge.html`; try { const response = await msalInstance.loginPopup({ - redirectUri: config.msalRedirectURI, + redirectUri: redirectBridgeUrl, scopes: [], }); setLoggedIn(); @@ -81,7 +86,12 @@ export function useAADAuth(config?: ConfigContext): ReturnType { } setLoggedOut(); localStorage.removeItem("cachedTenantId"); - msalInstance.logoutRedirect(); + // Redirect back to the hosted explorer after logout + const postLogoutRedirectUri = + process.env.NODE_ENV === "development" + ? "https://dataexplorer-dev.azurewebsites.net/hostedExplorer.html" + : `${window.location.origin}`; + msalInstance.logoutRedirect({ postLogoutRedirectUri }); }, [msalInstance]); const switchTenant = React.useCallback( @@ -89,9 +99,14 @@ export function useAADAuth(config?: ConfigContext): ReturnType { if (!msalInstance || !config) { return; } + // Use redirect bridge for MSAL v5 COOP handling + const redirectBridgeUrl = + process.env.NODE_ENV === "development" + ? "https://dataexplorer-dev.azurewebsites.net/redirectBridge.html" + : `${window.location.origin}/redirectBridge.html`; try { const response = await msalInstance.loginPopup({ - redirectUri: config.msalRedirectURI, + redirectUri: redirectBridgeUrl, authority: `${config.AAD_ENDPOINT}${id}`, scopes: [], }); @@ -120,7 +135,7 @@ export function useAADAuth(config?: ConfigContext): ReturnType { setArmToken(armToken); setAuthFailure(null); } catch (error) { - if (error instanceof msal.AuthError && error.errorCode === msal.BrowserAuthErrorMessage.popUpWindowError.code) { + if (error instanceof msal.AuthError && error.errorCode === msal.BrowserAuthErrorCodes.popupWindowError) { // This error can occur when acquireTokenWithMsal() has attempted to acquire token interactively // and user has popups disabled in browser. This fails as the popup is not the result of a explicit user // action. In this case, we display the failure and a link to repeat the operation. Clicking on the diff --git a/src/redirectBridge.html b/src/redirectBridge.html new file mode 100644 index 000000000..7f4ada149 --- /dev/null +++ b/src/redirectBridge.html @@ -0,0 +1,14 @@ + + + + + + Authentication Redirect + + + +
+

Processing authentication...

+
+ + diff --git a/src/redirectBridge.ts b/src/redirectBridge.ts new file mode 100644 index 000000000..946d0975a --- /dev/null +++ b/src/redirectBridge.ts @@ -0,0 +1,15 @@ +/** + * MSAL COOP Redirect Bridge + * + * This page handles the authentication response from the Identity Provider (IdP) + * and broadcasts it to the main application frame. Required for msal-browser v5+ + * to securely handle auth responses when the IdP sets Cross-Origin-Opener-Policy headers. + * + * Security Note: This file must be bundled with your application, NOT loaded from a CDN. + * + */ +import { broadcastResponseToMainFrame } from "@azure/msal-browser/redirect-bridge"; + +broadcastResponseToMainFrame().catch((error: unknown) => { + console.error("MSAL redirect bridge error:", error); +}); diff --git a/src/types/msal-browser-redirect-bridge.d.ts b/src/types/msal-browser-redirect-bridge.d.ts new file mode 100644 index 000000000..33d10d4a5 --- /dev/null +++ b/src/types/msal-browser-redirect-bridge.d.ts @@ -0,0 +1,11 @@ +// Type declarations for @azure/msal-browser subpath exports +// Required because tsconfig uses moduleResolution: "node" which doesn't support exports field + +declare module "@azure/msal-browser/redirect-bridge" { + /** + * Processes the authentication response from the redirect URL. + * For SSO and popup scenarios broadcasts it to the main frame. + * For redirect scenario navigates to the home page. + */ + export function broadcastResponseToMainFrame(navigationClient?: unknown): Promise; +} diff --git a/webpack.config.js b/webpack.config.js index 2b00b9f2b..3f1056546 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -116,6 +116,7 @@ module.exports = function (_env = {}, argv = {}) { galleryViewer: "./src/GalleryViewer/GalleryViewer.tsx", selfServe: "./src/SelfServe/SelfServe.tsx", connectToGitHub: "./src/GitHub/GitHubConnector.ts", + redirectBridge: "./src/redirectBridge.ts", ...(mode !== "production" && { testExplorer: "./test/testExplorer/TestExplorer.ts" }), ...(mode !== "production" && { searchableDropdownFixture: "./test/component-fixtures/searchableDropdown/SearchableDropdownFixture.tsx", @@ -168,6 +169,11 @@ module.exports = function (_env = {}, argv = {}) { template: "src/SelfServe/selfServe.html", chunks: ["selfServe"], }), + new HtmlWebpackPlugin({ + filename: "redirectBridge.html", + template: "src/redirectBridge.html", + chunks: ["redirectBridge"], + }), ...(mode !== "production" ? [ new HtmlWebpackPlugin({