From 0e9e1c4da485b0b825d7584e9ebd7dee0b724575 Mon Sep 17 00:00:00 2001 From: Konstantin Shabelko Date: Wed, 29 Oct 2025 17:10:21 -0400 Subject: [PATCH 01/45] - Update popup client with a minimal functionality to handle popup COOP scenario - Update COOP sample to mock authorize and token endpoints --- lib/msal-browser/rollup.config.js | 91 +++++++++++ .../src/interaction_client/PopupClient.ts | 37 ++--- lib/msal-browser/src/popup_bridge/index.ts | 67 ++++++++ lib/msal-browser/src/utils/BrowserUtils.ts | 8 +- lib/msal-browser/src/utils/PopupUtils.ts | 120 +++++++------- .../tsconfig.rollup-bridge.build.json | 8 + .../popup-coop/app/auth.js | 79 +++++++++ .../popup-coop/app/authConfig.js | 87 ++++++++++ .../popup-coop/app/graph.js | 64 ++++++++ .../popup-coop/app/index.html | 98 ++++++++++++ .../popup-coop/app/redirect.html | 27 ++++ .../msal-browser-samples/popup-coop/app/ui.js | 73 +++++++++ .../popup-coop/jest.config.js | 16 ++ .../popup-coop/package.json | 23 +++ .../msal-browser-samples/popup-coop/server.js | 76 +++++++++ .../popup-coop/sts/lmso_server.js | 150 ++++++++++++++++++ .../popup-coop/sts/package.json | 22 +++ .../popup-coop/sts/sts_index.html | 32 ++++ .../msal-browser-samples/popup-coop/sts/ui.js | 64 ++++++++ .../popup-coop/tsconfig.base.json | 12 ++ .../popup-coop/tsconfig.json | 28 ++++ 21 files changed, 1094 insertions(+), 88 deletions(-) create mode 100644 lib/msal-browser/src/popup_bridge/index.ts create mode 100644 lib/msal-browser/tsconfig.rollup-bridge.build.json create mode 100644 samples/msal-browser-samples/popup-coop/app/auth.js create mode 100644 samples/msal-browser-samples/popup-coop/app/authConfig.js create mode 100644 samples/msal-browser-samples/popup-coop/app/graph.js create mode 100644 samples/msal-browser-samples/popup-coop/app/index.html create mode 100644 samples/msal-browser-samples/popup-coop/app/redirect.html create mode 100644 samples/msal-browser-samples/popup-coop/app/ui.js create mode 100644 samples/msal-browser-samples/popup-coop/jest.config.js create mode 100644 samples/msal-browser-samples/popup-coop/package.json create mode 100644 samples/msal-browser-samples/popup-coop/server.js create mode 100644 samples/msal-browser-samples/popup-coop/sts/lmso_server.js create mode 100644 samples/msal-browser-samples/popup-coop/sts/package.json create mode 100644 samples/msal-browser-samples/popup-coop/sts/sts_index.html create mode 100644 samples/msal-browser-samples/popup-coop/sts/ui.js create mode 100644 samples/msal-browser-samples/popup-coop/tsconfig.base.json create mode 100644 samples/msal-browser-samples/popup-coop/tsconfig.json diff --git a/lib/msal-browser/rollup.config.js b/lib/msal-browser/rollup.config.js index 315ac1051c..911b5a00fb 100644 --- a/lib/msal-browser/rollup.config.js +++ b/lib/msal-browser/rollup.config.js @@ -211,4 +211,95 @@ export default [ })] : []), ], }, + { + // Popup Bridge - ES module build + input: "src/popup_bridge/index.ts", + output: { + dir: "dist/popup-bridge", + preserveModules: true, + preserveModulesRoot: "src", + format: "es", + entryFileNames: "[name].mjs", + banner: fileHeader, + sourcemap: true, + }, + treeshake: { + moduleSideEffects: false, + propertyReadSideEffects: false, + }, + external: ["@azure/msal-common/browser"], + plugins: [ + typescript({ + typescript: require("typescript"), + tsconfig: "tsconfig.rollup-bridge.build.json", + }), + ], + }, + { + // Popup Bridge - UMD build + input: "src/popup_bridge/index.ts", + output: [ + { + dir: "lib/popup-bridge", + format: "umd", + name: "msalPopupBridge", + banner: fileHeader, + inlineDynamicImports: true, + sourcemap: true, + entryFileNames: "msal-popup-bridge.js", + }, + ], + plugins: [ + nodeResolve({ + browser: true, + resolveOnly: ["@azure/msal-common", "tslib"], + }), + typescript({ + typescript: require("typescript"), + tsconfig: "tsconfig.build.json", + sourceMap: true, + compilerOptions: { + outDir: "lib/popup-bridge/types", + declaration: false, + declarationMap: false, + }, + }), + ], + }, + { + // Popup Bridge - UMD minified build + input: "src/popup_bridge/index.ts", + output: [ + { + dir: "lib/popup-bridge", + format: "umd", + name: "msalPopupBridge", + entryFileNames: "msal-popup-bridge.min.js", + banner: useStrictHeader, + inlineDynamicImports: true, + sourcemap: false, + }, + ], + plugins: [ + nodeResolve({ + browser: true, + resolveOnly: ["@azure/msal-common", "tslib"], + }), + typescript({ + typescript: require("typescript"), + tsconfig: "tsconfig.rollup-bridge.build.json", + sourceMap: false, + compilerOptions: { + outDir: "lib/popup-bridge/types", + declaration: false, + declarationMap: false, + }, + }), + terser({ + output: { + preamble: libraryHeader, + }, + }), + ], + }, ]; diff --git a/lib/msal-browser/src/interaction_client/PopupClient.ts b/lib/msal-browser/src/interaction_client/PopupClient.ts index 033b7986d2..e5abb495aa 100644 --- a/lib/msal-browser/src/interaction_client/PopupClient.ts +++ b/lib/msal-browser/src/interaction_client/PopupClient.ts @@ -322,6 +322,9 @@ export class PopupClient extends StandardInteractionClient { account: popupRequest.account, }); + popupRequest.extraQueryParameters = popupRequest.extraQueryParameters || {}; + popupRequest.extraQueryParameters["popup"] = "true"; + // Create acquire token url. const navigateUrl = await invokeAsync( Authorize.getAuthCodeRequestUrl, @@ -349,16 +352,13 @@ export class PopupClient extends StandardInteractionClient { null ); - // Monitor the window for the hash. Return the string value and close the popup when the hash is received. Default timeout is 60 seconds. - const responseString = await monitorPopupForHash( - popupWindow, - popupParams.popupWindowParent, - this.config.auth.OIDCOptions.responseMode, - this.config.system.pollIntervalMilliseconds, - this.logger, - this.unloadWindow, - this.correlationId - ); + // Monitor the window for the hash. Return the string value and close the popup when the hash is received. Default timeout is 60 seconds. + const responseString = await monitorPopupForHash( + this.config.system.pollIntervalMilliseconds, + this.logger, + this.browserCrypto, + request + ); const serverParams = invoke( ResponseHandler.deserializeResponse, @@ -470,15 +470,7 @@ export class PopupClient extends StandardInteractionClient { this.logger, this.performanceClient, correlationId - )( - popupWindow, - popupParams.popupWindowParent, - this.config.auth.OIDCOptions.responseMode, - this.config.system.pollIntervalMilliseconds, - this.logger, - this.unloadWindow, - correlationId - ); + )(this.config.system.pollIntervalMilliseconds, this.logger, this.browserCrypto, popupRequest); const serverParams = invoke( ResponseHandler.deserializeResponse, @@ -623,13 +615,10 @@ export class PopupClient extends StandardInteractionClient { ); await monitorPopupForHash( - popupWindow, - popupParams.popupWindowParent, - this.config.auth.OIDCOptions.responseMode, this.config.system.pollIntervalMilliseconds, this.logger, - this.unloadWindow, - this.correlationId + this.browserCrypto, + validRequest ).catch(() => { // Swallow any errors related to monitoring the window. Server logout is best effort }); diff --git a/lib/msal-browser/src/popup_bridge/index.ts b/lib/msal-browser/src/popup_bridge/index.ts new file mode 100644 index 0000000000..c5511ac146 --- /dev/null +++ b/lib/msal-browser/src/popup_bridge/index.ts @@ -0,0 +1,67 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { Logger, LogLevel, ProtocolUtils, StubPerformanceClient } from "@azure/msal-common/browser"; +import { CryptoOps } from "../crypto/CryptoOps.js"; +import { isInPopup } from "../utils/BrowserUtils.js"; + +export async function sendPopupPayloadToMainFrame( +): Promise { + const logger = new Logger({ + logLevel: LogLevel.Info, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + loggerCallback: (level, message, containsPii) => { + // eslint-disable-next-line no-console + console.log(message); + return; + } + }); + + logger.info("Popup bridge is called", ""); + + if (!isInPopup()) { + logger.info("Popup bridge. Not a popup", ""); + return; + } + + // 1) Determine which URL container carries the payload + const hasHash = !!window.location.hash && window.location.hash.length > 1; + const raw = hasHash ? window.location.hash : window.location.search; + if (!raw) { + logger.info("No auth payload found on URL (hash or query)", ""); + return; + } + + // Strip leading ? / # + const payload = raw.substring(1); + const params = new URLSearchParams(payload); + + const state = params.get("state"); + if (!state) { + logger.info("Missing state on redirect URL", ""); + return; + } + + const { libraryState } = ProtocolUtils.parseRequestState( + new CryptoOps(logger, new StubPerformanceClient(), true), + state + ); + + const { id } = libraryState; + if (!id) { + throw new Error("State is missing id attribute"); + } + + // 4) Send the raw URL payload to the main frame + const channel = new BroadcastChannel(id); + channel.postMessage({ + v: 1, + state, + payload, + }); + channel.close(); + + try { window.close(); } catch {} +} diff --git a/lib/msal-browser/src/utils/BrowserUtils.ts b/lib/msal-browser/src/utils/BrowserUtils.ts index 4a1789d031..17269d2e45 100644 --- a/lib/msal-browser/src/utils/BrowserUtils.ts +++ b/lib/msal-browser/src/utils/BrowserUtils.ts @@ -58,13 +58,7 @@ export function isInIframe(): boolean { * Returns boolean of whether or not the current window is a popup opened by msal */ export function isInPopup(): boolean { - return ( - typeof window !== "undefined" && - !!window.opener && - window.opener !== window && - typeof window.name === "string" && - window.name.indexOf(`${BrowserConstants.POPUP_NAME_PREFIX}.`) === 0 - ); + return new URLSearchParams(location.search).get('popup') === "true" || new URLSearchParams(location.hash).get('popup') === "true"; } // #endregion diff --git a/lib/msal-browser/src/utils/PopupUtils.ts b/lib/msal-browser/src/utils/PopupUtils.ts index 7b950b5c7d..d027b22c72 100644 --- a/lib/msal-browser/src/utils/PopupUtils.ts +++ b/lib/msal-browser/src/utils/PopupUtils.ts @@ -3,11 +3,7 @@ * Licensed under the MIT License. */ -import { Constants, Logger } from "@azure/msal-common/browser"; -import { - BrowserAuthErrorCodes, - createBrowserAuthError, -} from "../error/BrowserAuthError.js"; +import { CommonAuthorizationUrlRequest, CommonEndSessionRequest, ICrypto, Logger, ProtocolUtils } from "@azure/msal-common/browser"; /** * Monitors a popup window for a URL change to the same origin as the parent application. @@ -16,13 +12,10 @@ import { * URL is detected, extracts the response string (query or hash) and resolves the promise. * Performs cleanup by closing the popup and removing event listeners when done. * - * @param popupWindow - The popup window to monitor for navigation. - * @param popupWindowParent - The parent window that opened the popup. - * @param responseMode - The response mode to use when extracting the response string (query or hash). * @param pollIntervalMilliseconds - The interval, in milliseconds, at which to poll the popup window. * @param logger - Logger instance for logging monitoring events. - * @param unloadWindow - Event handler to remove from the parent window on cleanup. - * @param correlationId + * @param browserCrypto - brow + * @param request * @returns Promise - Resolves with the response string (query or hash) from the popup window, * or rejects if the popup is closed before a response is received. * @@ -31,68 +24,81 @@ import { * Cleanup process: On completion (success or failure), closes the popup and removes the unload event listener from the parent window. */ export async function monitorPopupForHash( - popupWindow: Window, - popupWindowParent: Window, - responseMode: Constants.ResponseMode, pollIntervalMilliseconds: number, logger: Logger, - unloadWindow: (e: Event) => void, - correlationId: string + browserCrypto: ICrypto, + request: CommonAuthorizationUrlRequest | CommonEndSessionRequest ): Promise { - return new Promise((resolve, reject) => { + return new Promise((resolve) => { logger.verbose( "PopupHandler.monitorPopupForHash - polling started", - correlationId + request.correlationId ); + const { libraryState } = ProtocolUtils.parseRequestState(browserCrypto, request.state || ""); + const channel = new BroadcastChannel( libraryState.id ); + let responseString: string | undefined = undefined; + channel.onmessage = (event) => { + responseString = event.data.payload; + logger.warning(`Received a string from the popup = ${responseString}`, "") + } + const intervalId = setInterval(() => { // Window is closed - if (popupWindow.closed) { - logger.error( - "PopupHandler.monitorPopupForHash - window closed", - correlationId - ); - clearInterval(intervalId); - reject( - createBrowserAuthError(BrowserAuthErrorCodes.userCancelled) - ); + if (!responseString) { return; } - let href = ""; - try { - /* - * Will throw if cross origin, - * which should be caught and ignored - * since we need the interval to keep running while on STS UI. - */ - href = popupWindow.location.href; - } catch (e) {} - - // Don't process blank pages or cross domain - if (!href || href === "about:blank") { - return; - } clearInterval(intervalId); - - let responseString = ""; - if (popupWindow) { - if (responseMode === Constants.ResponseMode.QUERY) { - responseString = popupWindow.location.search; - } else { - responseString = popupWindow.location.hash; - } - } - - logger.verbose( - "PopupHandler.monitorPopupForHash - popup window is on same origin as caller", - correlationId - ); - resolve(responseString); }, pollIntervalMilliseconds); - }).finally(() => { - cleanPopup(popupWindow, popupWindowParent, unloadWindow); + + // const intervalId = setInterval(() => { + // // Window is closed + // if (popupWindow.closed) { + // logger.error( + // "PopupHandler.monitorPopupForHash - window closed", + // correlationId + // ); + // clearInterval(intervalId); + // reject( + // createBrowserAuthError(BrowserAuthErrorCodes.userCancelled) + // ); + // return; + // } + // + // let href = ""; + // try { + // /* + // * Will throw if cross origin, + // * which should be caught and ignored + // * since we need the interval to keep running while on STS UI. + // */ + // href = popupWindow.location.href; + // } catch (e) {} + // + // // Don't process blank pages or cross domain + // if (!href || href === "about:blank") { + // return; + // } + // clearInterval(intervalId); + // + // let responseString = ""; + // if (popupWindow) { + // if (responseMode === Constants.ResponseMode.QUERY) { + // responseString = popupWindow.location.search; + // } else { + // responseString = popupWindow.location.hash; + // } + // } + // + // logger.verbose( + // "PopupHandler.monitorPopupForHash - popup window is on same origin as caller", + // correlationId + // ); + // + // resolve(responseString); + // }, pollIntervalMilliseconds); }); } diff --git a/lib/msal-browser/tsconfig.rollup-bridge.build.json b/lib/msal-browser/tsconfig.rollup-bridge.build.json new file mode 100644 index 0000000000..edfadaccd6 --- /dev/null +++ b/lib/msal-browser/tsconfig.rollup-bridge.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist/popup-bridge", + }, + "include": ["src"] +} diff --git a/samples/msal-browser-samples/popup-coop/app/auth.js b/samples/msal-browser-samples/popup-coop/app/auth.js new file mode 100644 index 0000000000..397a4e0922 --- /dev/null +++ b/samples/msal-browser-samples/popup-coop/app/auth.js @@ -0,0 +1,79 @@ +// Browser check variables +// If you support IE, our recommendation is that you sign-in using Redirect APIs +// If you as a developer are testing using Edge InPrivate mode, please add "isEdge" to the if check +const ua = window.navigator.userAgent; +const msie = ua.indexOf("MSIE "); +const msie11 = ua.indexOf("Trident/"); +const msedge = ua.indexOf("Edge/"); +const isIE = msie > 0 || msie11 > 0; +const isEdge = msedge > 0; + +let signInType; +let accountId = ""; + +// Create the main myMSALObj instance +// configuration parameters are located at authConfig.js +const myMSALObj = new msal.PublicClientApplication(msalConfig); + +myMSALObj.initialize(); + +function handleResponse(resp) { + if (resp !== null) { + accountId = resp.account.homeAccountId; + myMSALObj.setActiveAccount(resp.account); + showWelcomeMessage(resp.account); + + // Display success message with token info + console.log("[AUTH] Authentication successful! Response:", resp); + + if (resp.idToken) { + // Remove the login button completely + const loginButton = document.getElementById("openStsPopup"); + if (loginButton) { + loginButton.remove(); + } + + // Also display in UI + const successDiv = document.getElementById("successAuthCode"); + if (successDiv) { + successDiv.innerHTML = ` +
+
✅ Authentication Successful!
+

User: ${resp.account.name || resp.account.username}

+

ID Token: ${resp.idToken.substring(0, 30)}...

+

Token expires: ${new Date(resp.expiresOn).toLocaleString()}

+
+ `; + } + } + } else { + // need to call getAccount here? + const currentAccounts = myMSALObj.getAllAccounts(); + if (!currentAccounts || currentAccounts.length < 1) { + return; + } else if (currentAccounts.length > 1) { + // Add choose account code here + } else if (currentAccounts.length === 1) { + const activeAccount = currentAccounts[0]; + myMSALObj.setActiveAccount(activeAccount); + accountId = activeAccount.homeAccountId; + showWelcomeMessage(activeAccount); + } + } +} + +function logoutPopup(interactionType) { + const logoutRequest = { + account: myMSALObj.getAccountByHomeId(accountId) + }; + + myMSALObj.logoutPopup(logoutRequest).then(() => { + window.location.reload(); + }); +} + +async function loginPopup(request, account) { + return myMSALObj.acquireTokenPopup(request).then(handleResponse).catch((error) => { + console.error(error); + }); +} diff --git a/samples/msal-browser-samples/popup-coop/app/authConfig.js b/samples/msal-browser-samples/popup-coop/app/authConfig.js new file mode 100644 index 0000000000..126c44e6e0 --- /dev/null +++ b/samples/msal-browser-samples/popup-coop/app/authConfig.js @@ -0,0 +1,87 @@ +// Config object to be passed to Msal on creation +const msalConfig = { + auth: { + clientId: "8015f5e0-0370-427c-9b0d-d834189ffdd0", + authority: + "https://localhost:30663/72f988bf-86f1-41af-91ab-2d7cd011db47", + redirectUri: "https://localhost:30662/redirect", + knownAuthorities: ["localhost:30663"], + cloudDiscoveryMetadata: JSON.stringify({ + tenant_discovery_endpoint: "https://localhost:30663/72f988bf-86f1-41af-91ab-2d7cd011db47/v2.0/.well-known/openid-configuration", + metadata: [ + { + preferred_network: "localhost:30663", + preferred_cache: "localhost:30663", + aliases: ["localhost:30663", "login.microsoftonline.com"] + } + ] + }), + authorityMetadata: JSON.stringify({ + authorization_endpoint: "https://localhost:30663/72f988bf-86f1-41af-91ab-2d7cd011db47/oauth2/v2.0/authorize", + token_endpoint: "https://localhost:30663/72f988bf-86f1-41af-91ab-2d7cd011db47/oauth2/v2.0/token", + issuer: "https://localhost:30663/72f988bf-86f1-41af-91ab-2d7cd011db47/v2.0", + end_session_endpoint: "https://localhost:30663/72f988bf-86f1-41af-91ab-2d7cd011db47/oauth2/v2.0/logout" + }), + }, + cache: { + cacheLocation: "sessionStorage", // This configures where your cache will be stored + storeAuthStateInCookie: false, // Set this to "true" if you are having issues on IE11 or Edge + }, + system: { + loggerOptions: { + logLevel: msal.LogLevel.Trace, + loggerCallback: (level, message, containsPii) => { + if (containsPii) { + return; + } + switch (level) { + case msal.LogLevel.Error: + console.error(message); + return; + case msal.LogLevel.Info: + console.info(message); + return; + case msal.LogLevel.Verbose: + console.debug(message); + return; + case msal.LogLevel.Warning: + console.warn(message); + return; + default: + console.log(message); + return; + } + }, + }, + pollIntervalMilliseconds: 0, + }, + telemetry: { + application: { + appName: "MSAL Browser V2 Default Sample", + appVersion: "1.0.0", + }, + }, +}; + +// Add here scopes for id token to be used at MS Identity Platform endpoints. +const loginRequest = { + scopes: ["User.Read"], +}; + +// Add here the endpoints for MS Graph API services you would like to use. +const graphConfig = { + graphMeEndpoint: "https://graph.microsoft.com/v1.0/me", + graphMailEndpoint: "https://graph.microsoft.com/v1.0/me/messages", +}; + +// Add here scopes for access token to be used at MS Graph API endpoints. +const tokenRequest = { + scopes: ["Mail.Read"], + forceRefresh: false, // Set this to "true" to skip a cached token and go to the server to get a new token +}; + +const silentRequest = { + scopes: ["openid", "profile", "User.Read", "Mail.Read"], +}; + +const logoutRequest = {}; diff --git a/samples/msal-browser-samples/popup-coop/app/graph.js b/samples/msal-browser-samples/popup-coop/app/graph.js new file mode 100644 index 0000000000..db64c7931d --- /dev/null +++ b/samples/msal-browser-samples/popup-coop/app/graph.js @@ -0,0 +1,64 @@ +// Helper function to call MS Graph API endpoint +// using authorization bearer token scheme +function callMSGraph(endpoint, accessToken, callback) { + const headers = new Headers(); + const bearer = `Bearer ${accessToken}`; + + headers.append("Authorization", bearer); + + const options = { + method: "GET", + headers: headers + }; + + console.log('request made to Graph API at: ' + new Date().toString()); + + fetch(endpoint, options) + .then(response => response.json()) + .then(response => callback(response, endpoint)) + .catch(error => console.log(error)); +} + +async function seeProfile() { + const currentAcc = myMSALObj.getAccountByHomeId(accountId); + if (currentAcc) { + const response = await loginPopup(loginRequest, currentAcc).catch(error => { + console.log(error); + }); + callMSGraph(graphConfig.graphMeEndpoint, response.accessToken, updateUI); + profileButton.style.display = 'none'; + } +} + +async function readMail() { + const currentAcc = myMSALObj.getAccountByHomeId(accountId); + if (currentAcc) { + const response = await loginPopup(tokenRequest, currentAcc).catch(error => { + console.log(error); + }); + callMSGraph(graphConfig.graphMailEndpoint, response.accessToken, updateUI); + mailButton.style.display = 'none'; + } +} + +async function seeProfileRedirect() { + const currentAcc = myMSALObj.getAccountByHomeId(accountId); + if (currentAcc) { + const response = await getTokenRedirect(loginRequest, currentAcc).catch(error => { + console.log(error); + }); + callMSGraph(graphConfig.graphMeEndpoint, response.accessToken, updateUI); + profileButton.style.display = 'none'; + } +} + +async function readMailRedirect() { + const currentAcc = myMSALObj.getAccountByHomeId(accountId); + if (currentAcc) { + const response = await getTokenRedirect(tokenRequest, currentAcc).catch(error => { + console.log(error); + }); + callMSGraph(graphConfig.graphMailEndpoint, response.accessToken, updateUI); + mailButton.style.display = 'none'; + } +} diff --git a/samples/msal-browser-samples/popup-coop/app/index.html b/samples/msal-browser-samples/popup-coop/app/index.html new file mode 100644 index 0000000000..741be75d65 --- /dev/null +++ b/samples/msal-browser-samples/popup-coop/app/index.html @@ -0,0 +1,98 @@ + + + + + + + Quickstart | MSAL.JS Vanilla JavaScript SPA + + + + + + + + + + + + + +
+
Vanilla JavaScript SPA calling MS Graph API with MSAL.JS
+
+
+
+
+
+
+
+
+ +
+

+
+
+ + +
+
+
+ + +
+
+ +
+
+
+
+ + + + + + + + + + + + + + diff --git a/samples/msal-browser-samples/popup-coop/app/redirect.html b/samples/msal-browser-samples/popup-coop/app/redirect.html new file mode 100644 index 0000000000..6d4217224e --- /dev/null +++ b/samples/msal-browser-samples/popup-coop/app/redirect.html @@ -0,0 +1,27 @@ + + + + + + Redirect Page + + +

Processing authentication...

+ + + + + + diff --git a/samples/msal-browser-samples/popup-coop/app/ui.js b/samples/msal-browser-samples/popup-coop/app/ui.js new file mode 100644 index 0000000000..cebd1bc0bb --- /dev/null +++ b/samples/msal-browser-samples/popup-coop/app/ui.js @@ -0,0 +1,73 @@ +// Select DOM elements to work with +const welcomeDiv = document.getElementById("WelcomeMessage"); +const signInButton = document.getElementById("SignIn"); +const popupButton = document.getElementById("popup"); +const redirectButton = document.getElementById("redirect"); +const cardDiv = document.getElementById("card-div"); +const mailButton = document.getElementById("readMail"); +const profileButton = document.getElementById("seeProfile"); +const profileDiv = document.getElementById("profile-div"); + +function showWelcomeMessage(account) { + // Reconfiguring DOM elements + //welcomeDiv.innerHTML = `Welcome ${account.username}`; + signInButton.setAttribute('class', "btn btn-success dropdown-toggle"); + signInButton.innerHTML = "Sign Out"; + popupButton.setAttribute('onClick', "signOut(this.id)"); + popupButton.innerHTML = "Sign Out with Popup"; + redirectButton.setAttribute('onClick', "signOut(this.id)"); + redirectButton.innerHTML = "Sign Out with Redirect"; +} + +function updateUI(data, endpoint) { + console.log('Graph API responded at: ' + new Date().toString()); + + if (endpoint === graphConfig.graphMeEndpoint) { + const title = document.createElement('p'); + title.innerHTML = "Title: " + data.jobTitle; + const email = document.createElement('p'); + email.innerHTML = "Mail: " + data.mail; + const phone = document.createElement('p'); + phone.innerHTML = "Phone: " + data.businessPhones[0]; + const address = document.createElement('p'); + address.innerHTML = "Location: " + data.officeLocation; + profileDiv.appendChild(title); + profileDiv.appendChild(email); + profileDiv.appendChild(phone); + profileDiv.appendChild(address); + } else if (endpoint === graphConfig.graphMailEndpoint) { + if (data.value.length < 1) { + alert("Your mailbox is empty!") + } else { + const tabList = document.getElementById("list-tab"); + const tabContent = document.getElementById("nav-tabContent"); + + data.value.map((d, i) => { + // Keeping it simple + if (i < 10) { + const listItem = document.createElement("a"); + listItem.setAttribute("class", "list-group-item list-group-item-action") + listItem.setAttribute("id", "list" + i + "list") + listItem.setAttribute("data-toggle", "list") + listItem.setAttribute("href", "#list" + i) + listItem.setAttribute("role", "tab") + listItem.setAttribute("aria-controls", i) + listItem.innerHTML = d.subject; + tabList.appendChild(listItem) + + const contentItem = document.createElement("div"); + contentItem.setAttribute("class", "tab-pane fade") + contentItem.setAttribute("id", "list" + i) + contentItem.setAttribute("role", "tabpanel") + contentItem.setAttribute("aria-labelledby", "list" + i + "list") + contentItem.innerHTML = " from: " + d.from.emailAddress.address + "

" + d.bodyPreview + "..."; + tabContent.appendChild(contentItem); + } + }); + } + } +} + +// onLoad is now only called from redirect.html, not from the main page +// Removed DOMContentLoaded listener to prevent running on main page + diff --git a/samples/msal-browser-samples/popup-coop/jest.config.js b/samples/msal-browser-samples/popup-coop/jest.config.js new file mode 100644 index 0000000000..ddf4593104 --- /dev/null +++ b/samples/msal-browser-samples/popup-coop/jest.config.js @@ -0,0 +1,16 @@ +const fs = require("fs"); +const path = require("path"); + +const APP_DIR = path.join(__dirname, 'app'); + +// if a sample is specified, only run tests for that sample +const sampleFolders = process.argv.find(arg => arg.startsWith('--sample=')) + ? [path.join(APP_DIR, process.argv.find(arg => arg.startsWith('--sample='))?.split('=')[1])] + : fs.readdirSync(APP_DIR, { withFileTypes: true }) + .filter(file => file.isDirectory() && file.name !== "node_modules" && fs.existsSync(path.resolve(APP_DIR, file.name, "jest.config.js"))) + .map(file => path.join(APP_DIR, file.name)); + +module.exports = { + projects: sampleFolders, + testTimeout: 30000 +}; diff --git a/samples/msal-browser-samples/popup-coop/package.json b/samples/msal-browser-samples/popup-coop/package.json new file mode 100644 index 0000000000..931f08c88a --- /dev/null +++ b/samples/msal-browser-samples/popup-coop/package.json @@ -0,0 +1,23 @@ +{ + "name": "msal-browser-popup-coop", + "version": "1.0.0", + "license": "MIT", + "private": true, + "main": "server.js", + "scripts": { + "start": "node server.js", + "start:https": "node server.js -h", + "start:server:https": "node sts/lmso_server.js -h", + "test": "npx playwright test", + "generate:certs": "openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 7 -nodes -subj /CN=localhost" + }, + "dependencies": { + "@azure/msal-node": "^5.0.0-alpha.0", + "cors": "^2.8.5", + "express": "^4.19.2", + "path": "^0.11.14" + }, + "devDependencies": { + "@playwright/test": "^1.30.0" + } +} diff --git a/samples/msal-browser-samples/popup-coop/server.js b/samples/msal-browser-samples/popup-coop/server.js new file mode 100644 index 0000000000..0bae81cad4 --- /dev/null +++ b/samples/msal-browser-samples/popup-coop/server.js @@ -0,0 +1,76 @@ +/* +* Copyright (c) Microsoft. All rights reserved. Licensed under the MIT license. +* See LICENSE in the source repository root for complete license information. +*/ +const express = require('express'); +const morgan = require('morgan'); +const fs = require('fs'); +const path = require('path'); +const argv = require("yargs") + .usage("Usage: $0 -p [PORT] -https") + .alias("p", "port") + .alias("h", "https") + .describe("port", "(Optional) Port Number - default is 30662") + .describe("https", "(Optional) Serve over https") + .strict() + .argv; + + +const DEFAULT_PORT = 30662; +const APP_DIR = __dirname + `/app`; + +//initialize express. +const app = express(); + +// Initialize variables. +let port = DEFAULT_PORT; // -p {PORT} || 30662; +if (argv.p) { + port = argv.p; +} + +let logHttpRequests = true; + +// Set the front-end folder to serve public assets. +app.use("/lib", express.static(path.join(__dirname, "../../../lib/msal-browser/lib"))); + +if (logHttpRequests) { + // Configure morgan module to log all requests. + app.use(morgan('dev')); +} + +// set up a route for redirect.html. When using popup and silent APIs, +// we recommend setting the redirectUri to a blank page or a page that does not implement MSAL. +app.get("/redirect", function (req, res) { + res.sendFile(path.join(APP_DIR + "/redirect.html")); +}); + +// Set up a route for index.html. +app.get('*', function (req, res) { + res.sendFile(path.join(APP_DIR + '/index.html')); +}); + +// Start the server. +if (argv.https) { + const https = require('https'); + + /** + * Secrets should never be hardcoded. The dotenv npm package can be used to store secrets or certificates + * in a .env file (located in project's root directory) that should be included in .gitignore to prevent + * accidental uploads of the secrets. + * + * Certificates can also be read-in from files via NodeJS's fs module. However, they should never be + * stored in the project's directory. Production apps should fetch certificates from + * Azure KeyVault (https://azure.microsoft.com/products/key-vault), or other secure key vaults. + * + * Please see "Certificates and Secrets" (https://learn.microsoft.com/azure/active-directory/develop/security-best-practices-for-app-registration#certificates-and-secrets) + * for more information. + */ + const privateKey = fs.readFileSync('./key.pem', 'utf8'); + const certificate = fs.readFileSync('./cert.pem', 'utf8'); + const credentials = { key: privateKey, cert: certificate }; + const httpsServer = https.createServer(credentials, app); + httpsServer.listen(port); +} else { + app.listen(port); +} +console.log(`Listening on port ${port}...`); diff --git a/samples/msal-browser-samples/popup-coop/sts/lmso_server.js b/samples/msal-browser-samples/popup-coop/sts/lmso_server.js new file mode 100644 index 0000000000..64c6436eab --- /dev/null +++ b/samples/msal-browser-samples/popup-coop/sts/lmso_server.js @@ -0,0 +1,150 @@ +/* +* Copyright (c) Microsoft. All rights reserved. Licensed under the MIT license. +* See LICENSE in the source repository root for complete license information. +*/ +const express = require('express'); +const cors = require('cors'); +const morgan = require('morgan'); +const fs = require('fs'); +const path = require('path'); +const argv = require("yargs") + .usage("Usage: $0 -p [PORT] -https") + .alias("p", "port") + .alias("h", "https") + .describe("port", "(Optional) Port Number - default is 30663") + .describe("https", "(Optional) Serve over https") + .strict() + .argv; + + +const DEFAULT_PORT = 30663; +const APP_DIR = __dirname; + +//initialize express. +const app = express(); + +// Parse JSON and URL-encoded bodies +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +// Enable CORS for requests from localhost:30662 +app.use(cors({ + origin: 'https://localhost:30662', + credentials: true +})); + +// Initialize variables. +let port = DEFAULT_PORT; // -p {PORT} || 30663; +if (argv.p) { + port = argv.p; +} + +let logHttpRequests = true; + + +app.use(express.static(__dirname, { + setHeaders: (res) => { + res.set('Cross-Origin-Opener-Policy', 'same-origin'); + } +})); + +// OAuth2 authorization endpoint - serve the login page +app.get('/:tenantId/oauth2/v2.0/authorize', function (req, res) { + res.set('Cross-Origin-Opener-Policy', 'same-origin'); + res.sendFile(path.join(APP_DIR + '/sts_index.html')); +}); + +// OAuth2 token endpoint - exchange auth code for tokens +app.post('/:tenantId/oauth2/v2.0/token', function (req, res) { + console.log('[STS TOKEN] Token request received'); + console.log('[STS TOKEN] Body:', req.body); + + const { code, grant_type, client_id } = req.body; + + if (grant_type !== 'authorization_code') { + return res.status(400).json({ error: 'unsupported_grant_type' }); + } + + // Decode the auth code to verify it + try { + const authCodeData = JSON.parse(atob(code)); + console.log('[STS TOKEN] Auth code data:', authCodeData); + + // Generate mock tokens + const idTokenPayload = { + aud: client_id, + iss: `https://localhost:30663/${req.params.tenantId}/v2.0`, + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 3600, + sub: "test-subject-id", + name: "Test User", + preferred_username: "test@example.com", + tid: req.params.tenantId, + nonce: authCodeData.nonce, // Include nonce from auth code + ver: "2.0" + }; + + console.log('[STS TOKEN] ID Token payload:', idTokenPayload); + + // Create simple JWT-like structure (header.payload.signature) + const header = btoa(JSON.stringify({ typ: "JWT", alg: "RS256" })); + const payload = btoa(JSON.stringify(idTokenPayload)); + const signature = btoa("mock_signature"); + const id_token = `${header}.${payload}.${signature}`; + + const tokenResponse = { + token_type: "Bearer", + scope: "openid profile offline_access", + expires_in: 3600, + ext_expires_in: 3600, + access_token: btoa(JSON.stringify({ token: "mock_access_token", timestamp: Date.now() })), + refresh_token: btoa(JSON.stringify({ token: "mock_refresh_token", timestamp: Date.now() })), + id_token: id_token, + client_info: btoa(JSON.stringify({ uid: "test-uid", utid: req.params.tenantId })) + }; + + console.log('[STS TOKEN] Returning token response'); + res.json(tokenResponse); + + } catch (error) { + console.error('[STS TOKEN] Error:', error); + res.status(400).json({ error: 'invalid_grant', error_description: 'Invalid authorization code' }); + } +}); + +// Set up a route for index.html (catch-all at the end). +app.get('*', function (req, res) { + res.set('Cross-Origin-Opener-Policy', 'same-origin'); + res.sendFile(path.join(APP_DIR + '/sts_index.html')); +}); + +if (logHttpRequests) { + // Configure morgan module to log all requests. + app.use(morgan('dev')); +} + +// Start the server. +if (argv.https) { + const https = require('https'); + + /** + * Secrets should never be hardcoded. The dotenv npm package can be used to store secrets or certificates + * in a .env file (located in project's root directory) that should be included in .gitignore to prevent + * accidental uploads of the secrets. + * + * Certificates can also be read-in from files via NodeJS's fs module. However, they should never be + * stored in the project's directory. Production apps should fetch certificates from + * Azure KeyVault (https://azure.microsoft.com/products/key-vault), or other secure key vaults. + * + * Please see "Certificates and Secrets" (https://learn.microsoft.com/azure/active-directory/develop/security-best-practices-for-app-registration#certificates-and-secrets) + * for more information. + */ + const privateKey = fs.readFileSync(path.join(__dirname, '../key.pem'), 'utf8'); + const certificate = fs.readFileSync(path.join(__dirname, '../cert.pem'), 'utf8'); + const credentials = { key: privateKey, cert: certificate }; + const httpsServer = https.createServer(credentials, app); + httpsServer.listen(port); +} else { + app.listen(port); +} +console.log(`Listening on port ${port}...`); diff --git a/samples/msal-browser-samples/popup-coop/sts/package.json b/samples/msal-browser-samples/popup-coop/sts/package.json new file mode 100644 index 0000000000..64f2a675a0 --- /dev/null +++ b/samples/msal-browser-samples/popup-coop/sts/package.json @@ -0,0 +1,22 @@ +{ + "name": "lmso-popup-coop", + "version": "1.0.0", + "license": "MIT", + "private": true, + "main": "lmso_server.js", + "scripts": { + "start": "node lmso_server.js", + "start:https": "node lmso_server.js -h", + "test": "npx playwright test", + "generate:certs": "openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 7 -nodes -subj /CN=localhost" + }, + "dependencies": { + "@azure/msal-node": "^5.0.0-alpha.0", + "cors": "^2.8.5", + "express": "^4.19.2", + "path": "^0.11.14" + }, + "devDependencies": { + "@playwright/test": "^1.30.0" + } +} diff --git a/samples/msal-browser-samples/popup-coop/sts/sts_index.html b/samples/msal-browser-samples/popup-coop/sts/sts_index.html new file mode 100644 index 0000000000..2d2fdbcad7 --- /dev/null +++ b/samples/msal-browser-samples/popup-coop/sts/sts_index.html @@ -0,0 +1,32 @@ + + + + + + + LMSO + + + + + + + + +

Authenticating...

+ + + + + + + + + \ No newline at end of file diff --git a/samples/msal-browser-samples/popup-coop/sts/ui.js b/samples/msal-browser-samples/popup-coop/sts/ui.js new file mode 100644 index 0000000000..c5d8848338 --- /dev/null +++ b/samples/msal-browser-samples/popup-coop/sts/ui.js @@ -0,0 +1,64 @@ +window.name = "STS Window"; +const channel = new BroadcastChannel('sts-channel'); + +let stsPopupWindow = null; + +function performAuthentication() { + console.log("STS: Performing authentication (simulated)"); + console.log("STS: window.opener", window.opener); + console.log("STS: window.location.search", window.location.search); + + // Parse the original authorization request parameters + const urlParams = new URLSearchParams(window.location.search); + const redirectUri = urlParams.get('redirect_uri'); + + console.log("STS: redirect_uri from params:", redirectUri); + console.log("STS: All URL params:", Array.from(urlParams.entries())); + + const finalRedirectUri = redirectUri || "https://localhost:30662/redirect"; + console.log("STS: Final redirect URI:", finalRedirectUri); + + // Extract nonce for inclusion in auth code + const nonce = urlParams.get('nonce'); + + // Generate a base64-encoded auth code that looks realistic and includes nonce + const authCodePayload = { + tenant: "72f988bf-86f1-41af-91ab-2d7cd011db47", + user: "test@example.com", + timestamp: Date.now(), + nonce: nonce // Store nonce in auth code so token endpoint can use it + }; + const simulated_auth_code = btoa(JSON.stringify(authCodePayload)); + + console.log("STS: Authentication successful, returning auth code:", simulated_auth_code); + navigatetoJSApp(simulated_auth_code, finalRedirectUri, urlParams); +} + +// Make function globally accessible +window.performAuthentication = performAuthentication; + +function navigatetoJSApp(simulated_auth_code, redirectUri, urlParams) { + // Build redirect URL + let redirectUrl = redirectUri; + redirectUrl += (redirectUrl.includes('?') ? '&' : '?') + 'code=' + encodeURIComponent(simulated_auth_code); + + for (const param of ["state", "popup", "nonce"]) { + const paramValue = urlParams.get(param); + if (paramValue) { + redirectUrl += `&${param}=` + encodeURIComponent(paramValue); + } + } + + // Add client_info if requested + const clientInfo = urlParams.get('client_info'); + if (clientInfo === '1') { + // Generate a base64-encoded client info + const clientInfoPayload = { uid: "test-uid", utid: "72f988bf-86f1-41af-91ab-2d7cd011db47" }; + redirectUrl += '&client_info=' + encodeURIComponent(btoa(JSON.stringify(clientInfoPayload))); + } + + console.log("STS: Redirecting to:", redirectUrl); + + // Redirect back to the app + window.location.replace(redirectUrl); +} diff --git a/samples/msal-browser-samples/popup-coop/tsconfig.base.json b/samples/msal-browser-samples/popup-coop/tsconfig.base.json new file mode 100644 index 0000000000..d519609f49 --- /dev/null +++ b/samples/msal-browser-samples/popup-coop/tsconfig.base.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "suppressImplicitAnyIndexErrors": true, + "sourceMap": true, + "declaration": true, + "experimentalDecorators": true, + "importHelpers": true, + "noImplicitAny": true + }, + "compileOnSave": false, + "buildOnSave": false +} diff --git a/samples/msal-browser-samples/popup-coop/tsconfig.json b/samples/msal-browser-samples/popup-coop/tsconfig.json new file mode 100644 index 0000000000..5e6695a0fa --- /dev/null +++ b/samples/msal-browser-samples/popup-coop/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "moduleResolution": "node", + "module": "commonjs", + "target": "es5", + "lib": [ + "es2015", + "dom", + "es2015.promise" + ], + "allowUnusedLabels": false, + "noImplicitReturns": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "types": [ + "node", + "jest" + ] + }, + "include": [ + "./app/**/test/*.spec.ts", + "./app/testUtils.ts" + ], + "exclude": [ + "node_modules" + ] +} \ No newline at end of file From 8da953b54c106350a6700194d03ad82a0d681623 Mon Sep 17 00:00:00 2001 From: Konstantin Shabelko Date: Thu, 30 Oct 2025 08:48:54 -0400 Subject: [PATCH 02/45] - Update popup client with a minimal functionality to handle popup COOP scenario - Update COOP sample to mock authorize and token endpoints --- lib/msal-browser/src/popup_bridge/index.ts | 10 ++++++++++ samples/msal-browser-samples/popup-coop/app/index.html | 1 - samples/msal-browser-samples/popup-coop/server.js | 5 ++++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/lib/msal-browser/src/popup_bridge/index.ts b/lib/msal-browser/src/popup_bridge/index.ts index c5511ac146..f5bce394e5 100644 --- a/lib/msal-browser/src/popup_bridge/index.ts +++ b/lib/msal-browser/src/popup_bridge/index.ts @@ -44,6 +44,16 @@ export async function sendPopupPayloadToMainFrame( return; } + // 2) Remove the response from URL for security + logger.info("Removing auth response from URL", ""); + if (hasHash) { + // Clear hash + window.history.replaceState(null, "", window.location.pathname + window.location.search); + } else { + // Clear query string + window.history.replaceState(null, "", window.location.pathname); + } + const { libraryState } = ProtocolUtils.parseRequestState( new CryptoOps(logger, new StubPerformanceClient(), true), state diff --git a/samples/msal-browser-samples/popup-coop/app/index.html b/samples/msal-browser-samples/popup-coop/app/index.html index 741be75d65..1a2b42e725 100644 --- a/samples/msal-browser-samples/popup-coop/app/index.html +++ b/samples/msal-browser-samples/popup-coop/app/index.html @@ -7,7 +7,6 @@ Quickstart | MSAL.JS Vanilla JavaScript SPA - Date: Thu, 30 Oct 2025 17:06:07 -0400 Subject: [PATCH 03/45] - Update popup client with a minimal functionality to handle popup COOP scenario - Update COOP sample to mock authorize and token endpoints --- lib/msal-browser/rollup.config.js | 1 + .../src/interaction_client/PopupClient.ts | 6 +- lib/msal-browser/src/utils/PopupUtils.ts | 72 ++++++------------- .../msal-browser-samples/popup-coop/sts/ui.js | 2 - 4 files changed, 26 insertions(+), 55 deletions(-) diff --git a/lib/msal-browser/rollup.config.js b/lib/msal-browser/rollup.config.js index 911b5a00fb..0c914fa749 100644 --- a/lib/msal-browser/rollup.config.js +++ b/lib/msal-browser/rollup.config.js @@ -298,6 +298,7 @@ export default [ terser({ output: { preamble: libraryHeader, + comments: false, }, }), ], diff --git a/lib/msal-browser/src/interaction_client/PopupClient.ts b/lib/msal-browser/src/interaction_client/PopupClient.ts index e5abb495aa..c1f4a43630 100644 --- a/lib/msal-browser/src/interaction_client/PopupClient.ts +++ b/lib/msal-browser/src/interaction_client/PopupClient.ts @@ -355,9 +355,10 @@ export class PopupClient extends StandardInteractionClient { // Monitor the window for the hash. Return the string value and close the popup when the hash is received. Default timeout is 60 seconds. const responseString = await monitorPopupForHash( this.config.system.pollIntervalMilliseconds, + this.config.system.windowHashTimeout, this.logger, this.browserCrypto, - request + request, ); const serverParams = invoke( @@ -470,7 +471,7 @@ export class PopupClient extends StandardInteractionClient { this.logger, this.performanceClient, correlationId - )(this.config.system.pollIntervalMilliseconds, this.logger, this.browserCrypto, popupRequest); + )(this.config.system.pollIntervalMilliseconds, this.config.system.windowHashTimeout, this.logger, this.browserCrypto, popupRequest); const serverParams = invoke( ResponseHandler.deserializeResponse, @@ -616,6 +617,7 @@ export class PopupClient extends StandardInteractionClient { await monitorPopupForHash( this.config.system.pollIntervalMilliseconds, + this.config.system.windowHashTimeout, this.logger, this.browserCrypto, validRequest diff --git a/lib/msal-browser/src/utils/PopupUtils.ts b/lib/msal-browser/src/utils/PopupUtils.ts index d027b22c72..f9c987ee01 100644 --- a/lib/msal-browser/src/utils/PopupUtils.ts +++ b/lib/msal-browser/src/utils/PopupUtils.ts @@ -4,6 +4,7 @@ */ import { CommonAuthorizationUrlRequest, CommonEndSessionRequest, ICrypto, Logger, ProtocolUtils } from "@azure/msal-common/browser"; +import { BrowserAuthErrorCodes, createBrowserAuthError } from "../error/BrowserAuthError.js"; /** * Monitors a popup window for a URL change to the same origin as the parent application. @@ -13,9 +14,10 @@ import { CommonAuthorizationUrlRequest, CommonEndSessionRequest, ICrypto, Logger * Performs cleanup by closing the popup and removing event listeners when done. * * @param pollIntervalMilliseconds - The interval, in milliseconds, at which to poll the popup window. + * @param timeoutMs - popup timeout, ms. * @param logger - Logger instance for logging monitoring events. - * @param browserCrypto - brow - * @param request + * @param browserCrypto - browser crypto. + * @param request - popup request. * @returns Promise - Resolves with the response string (query or hash) from the popup window, * or rejects if the popup is closed before a response is received. * @@ -25,11 +27,12 @@ import { CommonAuthorizationUrlRequest, CommonEndSessionRequest, ICrypto, Logger */ export async function monitorPopupForHash( pollIntervalMilliseconds: number, + timeoutMs: number, logger: Logger, browserCrypto: ICrypto, - request: CommonAuthorizationUrlRequest | CommonEndSessionRequest + request: CommonAuthorizationUrlRequest | CommonEndSessionRequest, ): Promise { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { logger.verbose( "PopupHandler.monitorPopupForHash - polling started", request.correlationId @@ -43,6 +46,19 @@ export async function monitorPopupForHash( logger.warning(`Received a string from the popup = ${responseString}`, "") } + /* + * Polling for iframes can be purely timing based, + * since we don't need to account for interaction. + */ + const timeoutId = window.setTimeout(() => { + window.clearInterval(intervalId); + reject( + createBrowserAuthError( + BrowserAuthErrorCodes.monitorWindowTimeout + ) + ); + }, timeoutMs); + const intervalId = setInterval(() => { // Window is closed if (!responseString) { @@ -50,55 +66,9 @@ export async function monitorPopupForHash( } clearInterval(intervalId); + clearTimeout(timeoutId); resolve(responseString); }, pollIntervalMilliseconds); - - // const intervalId = setInterval(() => { - // // Window is closed - // if (popupWindow.closed) { - // logger.error( - // "PopupHandler.monitorPopupForHash - window closed", - // correlationId - // ); - // clearInterval(intervalId); - // reject( - // createBrowserAuthError(BrowserAuthErrorCodes.userCancelled) - // ); - // return; - // } - // - // let href = ""; - // try { - // /* - // * Will throw if cross origin, - // * which should be caught and ignored - // * since we need the interval to keep running while on STS UI. - // */ - // href = popupWindow.location.href; - // } catch (e) {} - // - // // Don't process blank pages or cross domain - // if (!href || href === "about:blank") { - // return; - // } - // clearInterval(intervalId); - // - // let responseString = ""; - // if (popupWindow) { - // if (responseMode === Constants.ResponseMode.QUERY) { - // responseString = popupWindow.location.search; - // } else { - // responseString = popupWindow.location.hash; - // } - // } - // - // logger.verbose( - // "PopupHandler.monitorPopupForHash - popup window is on same origin as caller", - // correlationId - // ); - // - // resolve(responseString); - // }, pollIntervalMilliseconds); }); } diff --git a/samples/msal-browser-samples/popup-coop/sts/ui.js b/samples/msal-browser-samples/popup-coop/sts/ui.js index c5d8848338..ad220cbb87 100644 --- a/samples/msal-browser-samples/popup-coop/sts/ui.js +++ b/samples/msal-browser-samples/popup-coop/sts/ui.js @@ -1,8 +1,6 @@ window.name = "STS Window"; const channel = new BroadcastChannel('sts-channel'); -let stsPopupWindow = null; - function performAuthentication() { console.log("STS: Performing authentication (simulated)"); console.log("STS: window.opener", window.opener); From b785cbbc8567b0007d028c3625f3c5635bb8e06e Mon Sep 17 00:00:00 2001 From: Konstantin Shabelko Date: Fri, 31 Oct 2025 14:52:46 -0400 Subject: [PATCH 04/45] - Code size optimizations --- .../src/interaction_client/PopupClient.ts | 27 +-- lib/msal-browser/src/popup_bridge/index.ts | 112 ++++++------- lib/msal-browser/src/protocol/Authorize.ts | 2 +- .../src/utils/BrowserProtocolUtils.ts | 2 +- lib/msal-browser/src/utils/BrowserUtils.ts | 5 +- lib/msal-browser/src/utils/PopupUtils.ts | 25 ++- lib/msal-common/src/exports-common.ts | 7 +- .../src/response/ResponseHandler.ts | 5 +- lib/msal-common/src/utils/ProtocolUtils.ts | 158 ++++++++---------- lib/msal-common/src/utils/StateTypes.ts | 20 +++ 10 files changed, 182 insertions(+), 181 deletions(-) create mode 100644 lib/msal-common/src/utils/StateTypes.ts diff --git a/lib/msal-browser/src/interaction_client/PopupClient.ts b/lib/msal-browser/src/interaction_client/PopupClient.ts index c1f4a43630..d3f881e418 100644 --- a/lib/msal-browser/src/interaction_client/PopupClient.ts +++ b/lib/msal-browser/src/interaction_client/PopupClient.ts @@ -322,9 +322,6 @@ export class PopupClient extends StandardInteractionClient { account: popupRequest.account, }); - popupRequest.extraQueryParameters = popupRequest.extraQueryParameters || {}; - popupRequest.extraQueryParameters["popup"] = "true"; - // Create acquire token url. const navigateUrl = await invokeAsync( Authorize.getAuthCodeRequestUrl, @@ -352,14 +349,14 @@ export class PopupClient extends StandardInteractionClient { null ); - // Monitor the window for the hash. Return the string value and close the popup when the hash is received. Default timeout is 60 seconds. - const responseString = await monitorPopupForHash( - this.config.system.pollIntervalMilliseconds, - this.config.system.windowHashTimeout, - this.logger, - this.browserCrypto, - request, - ); + // Monitor the window for the hash. Return the string value and close the popup when the hash is received. Default timeout is 60 seconds. + const responseString = await monitorPopupForHash( + this.config.system.pollIntervalMilliseconds, + this.config.system.windowHashTimeout, + this.logger, + this.browserCrypto, + request + ); const serverParams = invoke( ResponseHandler.deserializeResponse, @@ -471,7 +468,13 @@ export class PopupClient extends StandardInteractionClient { this.logger, this.performanceClient, correlationId - )(this.config.system.pollIntervalMilliseconds, this.config.system.windowHashTimeout, this.logger, this.browserCrypto, popupRequest); + )( + this.config.system.pollIntervalMilliseconds, + this.config.system.windowHashTimeout, + this.logger, + this.browserCrypto, + popupRequest + ); const serverParams = invoke( ResponseHandler.deserializeResponse, diff --git a/lib/msal-browser/src/popup_bridge/index.ts b/lib/msal-browser/src/popup_bridge/index.ts index f5bce394e5..9c59f96b17 100644 --- a/lib/msal-browser/src/popup_bridge/index.ts +++ b/lib/msal-browser/src/popup_bridge/index.ts @@ -3,75 +3,67 @@ * Licensed under the MIT License. */ -import { Logger, LogLevel, ProtocolUtils, StubPerformanceClient } from "@azure/msal-common/browser"; -import { CryptoOps } from "../crypto/CryptoOps.js"; +import { ProtocolUtils } from "@azure/msal-common/browser"; import { isInPopup } from "../utils/BrowserUtils.js"; +import { base64Decode } from "../encode/Base64Decode.js"; -export async function sendPopupPayloadToMainFrame( -): Promise { - const logger = new Logger({ - logLevel: LogLevel.Info, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - loggerCallback: (level, message, containsPii) => { - // eslint-disable-next-line no-console - console.log(message); - return; +export async function sendPopupPayloadToMainFrame(): Promise { + try { + if (!isInPopup()) { + throw new Error("Window is not a popup"); } - }); - logger.info("Popup bridge is called", ""); - - if (!isInPopup()) { - logger.info("Popup bridge. Not a popup", ""); - return; - } + // 1) Determine which URL container carries the payload + const hasHash = + !!window.location.hash && window.location.hash.length > 1; + const raw = hasHash ? window.location.hash : window.location.search; + if (!raw) { + throw new Error("No auth payload found on URL (hash or query)"); + } - // 1) Determine which URL container carries the payload - const hasHash = !!window.location.hash && window.location.hash.length > 1; - const raw = hasHash ? window.location.hash : window.location.search; - if (!raw) { - logger.info("No auth payload found on URL (hash or query)", ""); - return; - } + // Strip leading ? / # + const payload = raw.substring(1); + const params = new URLSearchParams(payload); - // Strip leading ? / # - const payload = raw.substring(1); - const params = new URLSearchParams(payload); + const state = params.get("state"); + if (!state) { + throw new Error("Missing state on redirect URL"); + } - const state = params.get("state"); - if (!state) { - logger.info("Missing state on redirect URL", ""); - return; - } + // 2) Remove the response from URL for security + if (hasHash) { + // Clear hash + window.history.replaceState( + null, + "", + window.location.pathname + window.location.search + ); + } else { + // Clear query string + window.history.replaceState(null, "", window.location.pathname); + } - // 2) Remove the response from URL for security - logger.info("Removing auth response from URL", ""); - if (hasHash) { - // Clear hash - window.history.replaceState(null, "", window.location.pathname + window.location.search); - } else { - // Clear query string - window.history.replaceState(null, "", window.location.pathname); - } + const { libraryState } = ProtocolUtils.parseRequestState( + base64Decode, + state + ); - const { libraryState } = ProtocolUtils.parseRequestState( - new CryptoOps(logger, new StubPerformanceClient(), true), - state - ); + const { id } = libraryState; + if (!id) { + throw new Error("State is missing id attribute"); + } - const { id } = libraryState; - if (!id) { - throw new Error("State is missing id attribute"); + // 4) Send the raw URL payload to the main frame + const channel = new BroadcastChannel(id); + channel.postMessage({ + v: 1, + state, + payload, + }); + channel.close(); + } finally { + try { + window.close(); + } catch {} } - - // 4) Send the raw URL payload to the main frame - const channel = new BroadcastChannel(id); - channel.postMessage({ - v: 1, - state, - payload, - }); - channel.close(); - - try { window.close(); } catch {} } diff --git a/lib/msal-browser/src/protocol/Authorize.ts b/lib/msal-browser/src/protocol/Authorize.ts index 6330c48bed..af753bbf1f 100644 --- a/lib/msal-browser/src/protocol/Authorize.ts +++ b/lib/msal-browser/src/protocol/Authorize.ts @@ -288,7 +288,7 @@ export async function handleResponsePlatformBroker( request.correlationId ); const { userRequestState } = ProtocolUtils.parseRequestState( - browserCrypto, + browserCrypto.base64Decode, request.state ); return invokeAsync( diff --git a/lib/msal-browser/src/utils/BrowserProtocolUtils.ts b/lib/msal-browser/src/utils/BrowserProtocolUtils.ts index 4a6271c131..75c46299f0 100644 --- a/lib/msal-browser/src/utils/BrowserProtocolUtils.ts +++ b/lib/msal-browser/src/utils/BrowserProtocolUtils.ts @@ -31,7 +31,7 @@ export function extractBrowserRequestState( try { const requestStateObj: RequestStateObject = - ProtocolUtils.parseRequestState(browserCrypto, state); + ProtocolUtils.parseRequestState(browserCrypto.base64Decode, state); return requestStateObj.libraryState.meta as BrowserStateObject; } catch (e) { throw createClientAuthError(ClientAuthErrorCodes.invalidState); diff --git a/lib/msal-browser/src/utils/BrowserUtils.ts b/lib/msal-browser/src/utils/BrowserUtils.ts index 17269d2e45..12d65667d4 100644 --- a/lib/msal-browser/src/utils/BrowserUtils.ts +++ b/lib/msal-browser/src/utils/BrowserUtils.ts @@ -58,7 +58,10 @@ export function isInIframe(): boolean { * Returns boolean of whether or not the current window is a popup opened by msal */ export function isInPopup(): boolean { - return new URLSearchParams(location.search).get('popup') === "true" || new URLSearchParams(location.hash).get('popup') === "true"; + return ( + new URLSearchParams(location.search).has("client_info") || + new URLSearchParams(location.hash).has("client_info") + ); } // #endregion diff --git a/lib/msal-browser/src/utils/PopupUtils.ts b/lib/msal-browser/src/utils/PopupUtils.ts index f9c987ee01..bfccd4c541 100644 --- a/lib/msal-browser/src/utils/PopupUtils.ts +++ b/lib/msal-browser/src/utils/PopupUtils.ts @@ -3,8 +3,17 @@ * Licensed under the MIT License. */ -import { CommonAuthorizationUrlRequest, CommonEndSessionRequest, ICrypto, Logger, ProtocolUtils } from "@azure/msal-common/browser"; -import { BrowserAuthErrorCodes, createBrowserAuthError } from "../error/BrowserAuthError.js"; +import { + CommonAuthorizationUrlRequest, + CommonEndSessionRequest, + ICrypto, + Logger, + ProtocolUtils, +} from "@azure/msal-common/browser"; +import { + BrowserAuthErrorCodes, + createBrowserAuthError, +} from "../error/BrowserAuthError.js"; /** * Monitors a popup window for a URL change to the same origin as the parent application. @@ -30,7 +39,7 @@ export async function monitorPopupForHash( timeoutMs: number, logger: Logger, browserCrypto: ICrypto, - request: CommonAuthorizationUrlRequest | CommonEndSessionRequest, + request: CommonAuthorizationUrlRequest | CommonEndSessionRequest ): Promise { return new Promise((resolve, reject) => { logger.verbose( @@ -38,13 +47,15 @@ export async function monitorPopupForHash( request.correlationId ); - const { libraryState } = ProtocolUtils.parseRequestState(browserCrypto, request.state || ""); - const channel = new BroadcastChannel( libraryState.id ); + const { libraryState } = ProtocolUtils.parseRequestState( + browserCrypto.base64Decode, + request.state || "" + ); + const channel = new BroadcastChannel(libraryState.id); let responseString: string | undefined = undefined; channel.onmessage = (event) => { responseString = event.data.payload; - logger.warning(`Received a string from the popup = ${responseString}`, "") - } + }; /* * Polling for iframes can be purely timing based, diff --git a/lib/msal-common/src/exports-common.ts b/lib/msal-common/src/exports-common.ts index 74578bb92e..edc8c42a22 100644 --- a/lib/msal-common/src/exports-common.ts +++ b/lib/msal-common/src/exports-common.ts @@ -160,11 +160,8 @@ export * as Constants from "./utils/Constants.js"; export { StringUtils } from "./utils/StringUtils.js"; export { StringDict } from "./utils/MsalTypes.js"; -export { - ProtocolUtils, - RequestStateObject, - LibraryStateObject, -} from "./utils/ProtocolUtils.js"; +export { RequestStateObject, LibraryStateObject } from "./utils/StateTypes.js"; +export * as ProtocolUtils from "./utils/ProtocolUtils.js"; export * from "./utils/FunctionWrappers.js"; export { ServerTelemetryManager } from "./telemetry/server/ServerTelemetryManager.js"; export { ServerTelemetryRequest } from "./telemetry/server/ServerTelemetryRequest.js"; diff --git a/lib/msal-common/src/response/ResponseHandler.ts b/lib/msal-common/src/response/ResponseHandler.ts index 1d327c3d38..d9d26ed817 100644 --- a/lib/msal-common/src/response/ResponseHandler.ts +++ b/lib/msal-common/src/response/ResponseHandler.ts @@ -24,7 +24,7 @@ import { } from "../error/InteractionRequiredAuthError.js"; import { CacheRecord } from "../cache/entities/CacheRecord.js"; import { CacheManager } from "../cache/CacheManager.js"; -import { ProtocolUtils, RequestStateObject } from "../utils/ProtocolUtils.js"; +import * as ProtocolUtils from "../utils/ProtocolUtils.js"; import * as Constants from "../utils/Constants.js"; import { PopTokenGenerator } from "../crypto/PopTokenGenerator.js"; import { AppMetadataEntity } from "../cache/entities/AppMetadataEntity.js"; @@ -47,6 +47,7 @@ import * as CacheHelpers from "../cache/utils/CacheHelpers.js"; import * as TimeUtils from "../utils/TimeUtils.js"; import * as AccountEntityUtils from "../cache/utils/AccountEntityUtils.js"; import { IPerformanceClient } from "../telemetry/performance/IPerformanceClient.js"; +import { RequestStateObject } from "../utils/StateTypes.js"; /** * Class that handles response parsing. @@ -234,7 +235,7 @@ export class ResponseHandler { let requestStateObj: RequestStateObject | undefined; if (!!authCodePayload && !!authCodePayload.state) { requestStateObj = ProtocolUtils.parseRequestState( - this.cryptoObj, + this.cryptoObj.base64Decode, authCodePayload.state ); } diff --git a/lib/msal-common/src/utils/ProtocolUtils.ts b/lib/msal-common/src/utils/ProtocolUtils.ts index 2b9a1d71f8..43fadfc00b 100644 --- a/lib/msal-common/src/utils/ProtocolUtils.ts +++ b/lib/msal-common/src/utils/ProtocolUtils.ts @@ -9,112 +9,86 @@ import { ClientAuthErrorCodes, createClientAuthError, } from "../error/ClientAuthError.js"; +import { LibraryStateObject, RequestStateObject } from "./StateTypes.js"; /** - * Type which defines the object that is stringified, encoded and sent in the state value. - * Contains the following: - * - id - unique identifier for this request - * - ts - timestamp for the time the request was made. Used to ensure that token expiration is not calculated incorrectly. - * - platformState - string value sent from the platform. + * Appends user state with random guid, or returns random guid. + * @param cryptoObj + * @param userState + * @param meta */ -export type LibraryStateObject = { - id: string; - meta?: Record; -}; - -/** - * Type which defines the stringified and encoded object sent to the service in the authorize request. - */ -export type RequestStateObject = { - userRequestState: string; - libraryState: LibraryStateObject; -}; +export function setRequestState( + cryptoObj: ICrypto, + userState?: string, + meta?: Record +): string { + const libraryState = generateLibraryState(cryptoObj, meta); + return userState + ? `${libraryState}${RESOURCE_DELIM}${userState}` + : libraryState; +} /** - * Class which provides helpers for OAuth 2.0 protocol specific values + * Generates the state value used by the common library. + * @param cryptoObj + * @param meta */ -export class ProtocolUtils { - /** - * Appends user state with random guid, or returns random guid. - * @param userState - * @param randomGuid - */ - static setRequestState( - cryptoObj: ICrypto, - userState?: string, - meta?: Record - ): string { - const libraryState = ProtocolUtils.generateLibraryState( - cryptoObj, - meta - ); - return userState - ? `${libraryState}${RESOURCE_DELIM}${userState}` - : libraryState; +export function generateLibraryState( + cryptoObj: ICrypto, + meta?: Record +): string { + if (!cryptoObj) { + throw createClientAuthError(ClientAuthErrorCodes.noCryptoObject); } - /** - * Generates the state value used by the common library. - * @param randomGuid - * @param cryptoObj - */ - static generateLibraryState( - cryptoObj: ICrypto, - meta?: Record - ): string { - if (!cryptoObj) { - throw createClientAuthError(ClientAuthErrorCodes.noCryptoObject); - } + // Create a state object containing a unique id and the timestamp of the request creation + const stateObj: LibraryStateObject = { + id: cryptoObj.createNewGuid(), + }; - // Create a state object containing a unique id and the timestamp of the request creation - const stateObj: LibraryStateObject = { - id: cryptoObj.createNewGuid(), - }; + if (meta) { + stateObj.meta = meta; + } - if (meta) { - stateObj.meta = meta; - } + const stateString = JSON.stringify(stateObj); - const stateString = JSON.stringify(stateObj); + return cryptoObj.base64Encode(stateString); +} - return cryptoObj.base64Encode(stateString); +/** + * Parses the state into the RequestStateObject, which contains the LibraryState info and the state passed by the user. + * @param base64Decode + * @param state + */ +export function parseRequestState( + base64Decode: (input: string) => string, + state: string +): RequestStateObject { + if (!base64Decode) { + throw createClientAuthError(ClientAuthErrorCodes.noCryptoObject); } - /** - * Parses the state into the RequestStateObject, which contains the LibraryState info and the state passed by the user. - * @param state - * @param cryptoObj - */ - static parseRequestState( - cryptoObj: ICrypto, - state: string - ): RequestStateObject { - if (!cryptoObj) { - throw createClientAuthError(ClientAuthErrorCodes.noCryptoObject); - } - - if (!state) { - throw createClientAuthError(ClientAuthErrorCodes.invalidState); - } + if (!state) { + throw createClientAuthError(ClientAuthErrorCodes.invalidState); + } - try { - // Split the state between library state and user passed state and decode them separately - const splitState = state.split(RESOURCE_DELIM); - const libraryState = splitState[0]; - const userState = - splitState.length > 1 - ? splitState.slice(1).join(RESOURCE_DELIM) - : ""; - const libraryStateString = cryptoObj.base64Decode(libraryState); - const libraryStateObj = JSON.parse( - libraryStateString - ) as LibraryStateObject; - return { - userRequestState: userState || "", - libraryState: libraryStateObj, - }; - } catch (e) { - throw createClientAuthError(ClientAuthErrorCodes.invalidState); - } + try { + // Split the state between library state and user passed state and decode them separately + const splitState = state.split(RESOURCE_DELIM); + const libraryState = splitState[0]; + const userState = + splitState.length > 1 + ? splitState.slice(1).join(RESOURCE_DELIM) + : ""; + const libraryStateString = base64Decode(libraryState); + const libraryStateObj = JSON.parse( + libraryStateString + ) as LibraryStateObject; + return { + userRequestState: userState || "", + libraryState: libraryStateObj, + }; + } catch (e) { + throw createClientAuthError(ClientAuthErrorCodes.invalidState); } } diff --git a/lib/msal-common/src/utils/StateTypes.ts b/lib/msal-common/src/utils/StateTypes.ts new file mode 100644 index 0000000000..68381b0c3b --- /dev/null +++ b/lib/msal-common/src/utils/StateTypes.ts @@ -0,0 +1,20 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +/** + * Type which defines library state + */ +export type LibraryStateObject = { + id: string; + meta?: Record; +}; + +/** + * Type which defines the stringified and encoded state object sent to the service in the authorize request. + */ +export type RequestStateObject = { + userRequestState: string; + libraryState: LibraryStateObject; +}; From 125588ffbe4a1e14e752f80616c5df306d85b74d Mon Sep 17 00:00:00 2001 From: Konstantin Shabelko Date: Mon, 3 Nov 2025 14:36:20 -0500 Subject: [PATCH 05/45] - Code optimizations --- lib/msal-browser/src/popup_bridge/index.ts | 1 - lib/msal-browser/src/utils/BrowserUtils.ts | 7 ++++--- lib/msal-browser/src/utils/PopupUtils.ts | 4 +++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/msal-browser/src/popup_bridge/index.ts b/lib/msal-browser/src/popup_bridge/index.ts index 9c59f96b17..c3e05c98ae 100644 --- a/lib/msal-browser/src/popup_bridge/index.ts +++ b/lib/msal-browser/src/popup_bridge/index.ts @@ -57,7 +57,6 @@ export async function sendPopupPayloadToMainFrame(): Promise { const channel = new BroadcastChannel(id); channel.postMessage({ v: 1, - state, payload, }); channel.close(); diff --git a/lib/msal-browser/src/utils/BrowserUtils.ts b/lib/msal-browser/src/utils/BrowserUtils.ts index 12d65667d4..06175c207e 100644 --- a/lib/msal-browser/src/utils/BrowserUtils.ts +++ b/lib/msal-browser/src/utils/BrowserUtils.ts @@ -14,7 +14,7 @@ import { createBrowserAuthError, BrowserAuthErrorCodes, } from "../error/BrowserAuthError.js"; -import { BrowserConstants, BrowserCacheLocation } from "./BrowserConstants.js"; +import { BrowserCacheLocation } from "./BrowserConstants.js"; import * as BrowserCrypto from "../crypto/BrowserCrypto.js"; import { BrowserConfigurationAuthErrorCodes, @@ -59,8 +59,9 @@ export function isInIframe(): boolean { */ export function isInPopup(): boolean { return ( - new URLSearchParams(location.search).has("client_info") || - new URLSearchParams(location.hash).has("client_info") + !isInIframe() && + (new URLSearchParams(location.search).has("client_info") || + new URLSearchParams(location.hash).has("client_info")) ); } diff --git a/lib/msal-browser/src/utils/PopupUtils.ts b/lib/msal-browser/src/utils/PopupUtils.ts index bfccd4c541..79be97d390 100644 --- a/lib/msal-browser/src/utils/PopupUtils.ts +++ b/lib/msal-browser/src/utils/PopupUtils.ts @@ -63,9 +63,10 @@ export async function monitorPopupForHash( */ const timeoutId = window.setTimeout(() => { window.clearInterval(intervalId); + channel.close(); reject( createBrowserAuthError( - BrowserAuthErrorCodes.monitorWindowTimeout + BrowserAuthErrorCodes.monitorPopupTimeout ) ); }, timeoutMs); @@ -78,6 +79,7 @@ export async function monitorPopupForHash( clearInterval(intervalId); clearTimeout(timeoutId); + channel.close(); resolve(responseString); }, pollIntervalMilliseconds); }); From 7eb65d7c347c97ebab94ee7a1460d03cfc14c0f2 Mon Sep 17 00:00:00 2001 From: Konstantin Shabelko Date: Tue, 4 Nov 2025 16:56:59 -0500 Subject: [PATCH 06/45] - Refactor silent client to use redirect bridge for both popup and SSO scenarios. - Update COOP sample with additional SSO functionality --- lib/msal-browser/rollup.config.js | 30 +++---- .../src/interaction_client/PopupClient.ts | 7 +- .../interaction_client/SilentIframeClient.ts | 28 +++---- .../src/interaction_handler/SilentHandler.ts | 76 ----------------- .../index.ts | 20 +---- lib/msal-browser/src/utils/BrowserUtils.ts | 60 ++++++++++++++ lib/msal-browser/src/utils/PopupUtils.ts | 82 ------------------- .../tsconfig.rollup-bridge.build.json | 2 +- .../{popup-coop => COOP}/app/auth.js | 18 ++-- .../{popup-coop => COOP}/app/authConfig.js | 0 .../{popup-coop => COOP}/app/index.html | 12 ++- .../COOP/app/redirect.html | 18 ++++ samples/msal-browser-samples/COOP/app/ui.js | 21 +++++ .../{popup-coop => COOP}/jest.config.js | 0 .../{popup-coop => COOP}/package.json | 0 .../{popup-coop => COOP}/server.js | 0 .../{popup-coop => COOP}/sts/lmso_server.js | 0 .../{popup-coop => COOP}/sts/package.json | 0 .../{popup-coop => COOP}/sts/sts_index.html | 0 .../{popup-coop => COOP}/sts/ui.js | 0 .../{popup-coop => COOP}/tsconfig.base.json | 0 .../{popup-coop => COOP}/tsconfig.json | 0 .../popup-coop/app/graph.js | 64 --------------- .../popup-coop/app/redirect.html | 27 ------ .../msal-browser-samples/popup-coop/app/ui.js | 73 ----------------- 25 files changed, 151 insertions(+), 387 deletions(-) rename lib/msal-browser/src/{popup_bridge => redirect_bridge}/index.ts (71%) rename samples/msal-browser-samples/{popup-coop => COOP}/app/auth.js (88%) rename samples/msal-browser-samples/{popup-coop => COOP}/app/authConfig.js (100%) rename samples/msal-browser-samples/{popup-coop => COOP}/app/index.html (87%) create mode 100644 samples/msal-browser-samples/COOP/app/redirect.html create mode 100644 samples/msal-browser-samples/COOP/app/ui.js rename samples/msal-browser-samples/{popup-coop => COOP}/jest.config.js (100%) rename samples/msal-browser-samples/{popup-coop => COOP}/package.json (100%) rename samples/msal-browser-samples/{popup-coop => COOP}/server.js (100%) rename samples/msal-browser-samples/{popup-coop => COOP}/sts/lmso_server.js (100%) rename samples/msal-browser-samples/{popup-coop => COOP}/sts/package.json (100%) rename samples/msal-browser-samples/{popup-coop => COOP}/sts/sts_index.html (100%) rename samples/msal-browser-samples/{popup-coop => COOP}/sts/ui.js (100%) rename samples/msal-browser-samples/{popup-coop => COOP}/tsconfig.base.json (100%) rename samples/msal-browser-samples/{popup-coop => COOP}/tsconfig.json (100%) delete mode 100644 samples/msal-browser-samples/popup-coop/app/graph.js delete mode 100644 samples/msal-browser-samples/popup-coop/app/redirect.html delete mode 100644 samples/msal-browser-samples/popup-coop/app/ui.js diff --git a/lib/msal-browser/rollup.config.js b/lib/msal-browser/rollup.config.js index 0c914fa749..ff7b30e6cc 100644 --- a/lib/msal-browser/rollup.config.js +++ b/lib/msal-browser/rollup.config.js @@ -212,10 +212,10 @@ export default [ ], }, { - // Popup Bridge - ES module build - input: "src/popup_bridge/index.ts", + // Redirect Bridge - ES module build + input: "src/redirect_bridge/index.ts", output: { - dir: "dist/popup-bridge", + dir: "dist/redirect-bridge", preserveModules: true, preserveModulesRoot: "src", format: "es", @@ -236,17 +236,17 @@ export default [ ], }, { - // Popup Bridge - UMD build - input: "src/popup_bridge/index.ts", + // Redirect Bridge - UMD build + input: "src/redirect_bridge/index.ts", output: [ { - dir: "lib/popup-bridge", + dir: "lib/redirect-bridge", format: "umd", - name: "msalPopupBridge", + name: "msalRedirectBridge", banner: fileHeader, inlineDynamicImports: true, sourcemap: true, - entryFileNames: "msal-popup-bridge.js", + entryFileNames: "msal-redirect-bridge.js", }, ], plugins: [ @@ -259,7 +259,7 @@ export default [ tsconfig: "tsconfig.build.json", sourceMap: true, compilerOptions: { - outDir: "lib/popup-bridge/types", + outDir: "lib/redirect-bridge/types", declaration: false, declarationMap: false, }, @@ -267,14 +267,14 @@ export default [ ], }, { - // Popup Bridge - UMD minified build - input: "src/popup_bridge/index.ts", + // Redirect Bridge - UMD minified build + input: "src/redirect_bridge/index.ts", output: [ { - dir: "lib/popup-bridge", + dir: "lib/redirect-bridge", format: "umd", - name: "msalPopupBridge", - entryFileNames: "msal-popup-bridge.min.js", + name: "msalRedirectBridge", + entryFileNames: "msal-redirect-bridge.min.js", banner: useStrictHeader, inlineDynamicImports: true, sourcemap: false, @@ -290,7 +290,7 @@ export default [ tsconfig: "tsconfig.rollup-bridge.build.json", sourceMap: false, compilerOptions: { - outDir: "lib/popup-bridge/types", + outDir: "lib/redirect-bridge/types", declaration: false, declarationMap: false, }, diff --git a/lib/msal-browser/src/interaction_client/PopupClient.ts b/lib/msal-browser/src/interaction_client/PopupClient.ts index d3f881e418..200609d1da 100644 --- a/lib/msal-browser/src/interaction_client/PopupClient.ts +++ b/lib/msal-browser/src/interaction_client/PopupClient.ts @@ -56,7 +56,6 @@ import { getDiscoveredAuthority, initializeServerTelemetryManager, } from "./BaseInteractionClient.js"; -import { monitorPopupForHash } from "../utils/PopupUtils.js"; export type PopupParams = { popup?: Window | null; @@ -350,7 +349,7 @@ export class PopupClient extends StandardInteractionClient { ); // Monitor the window for the hash. Return the string value and close the popup when the hash is received. Default timeout is 60 seconds. - const responseString = await monitorPopupForHash( + const responseString = await BrowserUtils.waitForBridgeResponse( this.config.system.pollIntervalMilliseconds, this.config.system.windowHashTimeout, this.logger, @@ -463,7 +462,7 @@ export class PopupClient extends StandardInteractionClient { // Monitor the popup for the hash. Return the string value and close the popup when the hash is received. Default timeout is 60 seconds. const responseString = await invokeAsync( - monitorPopupForHash, + BrowserUtils.waitForBridgeResponse, BrowserPerformanceEvents.SilentHandlerMonitorIframeForHash, this.logger, this.performanceClient, @@ -618,7 +617,7 @@ export class PopupClient extends StandardInteractionClient { null ); - await monitorPopupForHash( + await BrowserUtils.waitForBridgeResponse( this.config.system.pollIntervalMilliseconds, this.config.system.windowHashTimeout, this.logger, diff --git a/lib/msal-browser/src/interaction_client/SilentIframeClient.ts b/lib/msal-browser/src/interaction_client/SilentIframeClient.ts index a290b49abb..4907c7aaf7 100644 --- a/lib/msal-browser/src/interaction_client/SilentIframeClient.ts +++ b/lib/msal-browser/src/interaction_client/SilentIframeClient.ts @@ -37,7 +37,6 @@ import { import { initiateCodeRequest, initiateEarRequest, - monitorIframeForHash, } from "../interaction_handler/SilentHandler.js"; import { SsoSilentRequest } from "../request/SsoSilentRequest.js"; import { AuthenticationResult } from "../response/AuthenticationResult.js"; @@ -268,7 +267,7 @@ export class SilentIframeClient extends StandardInteractionClient { ...request, earJwk: earJwk, }; - const msalFrame = await invokeAsync( + await invokeAsync( initiateEarRequest, BrowserPerformanceEvents.SilentHandlerInitiateAuthRequest, this.logger, @@ -283,21 +282,18 @@ export class SilentIframeClient extends StandardInteractionClient { ); const responseType = this.config.auth.OIDCOptions.responseMode; - // Monitor the window for the hash. Return the string value and close the popup when the hash is received. Default timeout is 60 seconds. const responseString = await invokeAsync( - monitorIframeForHash, + BrowserUtils.waitForBridgeResponse, BrowserPerformanceEvents.SilentHandlerMonitorIframeForHash, this.logger, this.performanceClient, correlationId )( - msalFrame, - this.config.system.iframeHashTimeout, this.config.system.pollIntervalMilliseconds, - this.performanceClient, + this.config.system.iframeHashTimeout, this.logger, - correlationId, - responseType + this.browserCrypto, + request ); const serverParams = invoke( @@ -380,7 +376,7 @@ export class SilentIframeClient extends StandardInteractionClient { ); // Get the frame handle for the silent request - const msalFrame = await invokeAsync( + await invokeAsync( initiateCodeRequest, BrowserPerformanceEvents.SilentHandlerInitiateAuthRequest, this.logger, @@ -390,21 +386,21 @@ export class SilentIframeClient extends StandardInteractionClient { const responseType = this.config.auth.OIDCOptions.responseMode; // Monitor the window for the hash. Return the string value and close the popup when the hash is received. Default timeout is 60 seconds. + const responseString = await invokeAsync( - monitorIframeForHash, + BrowserUtils.waitForBridgeResponse, BrowserPerformanceEvents.SilentHandlerMonitorIframeForHash, this.logger, this.performanceClient, correlationId )( - msalFrame, - this.config.system.iframeHashTimeout, this.config.system.pollIntervalMilliseconds, - this.performanceClient, + this.config.system.iframeHashTimeout, this.logger, - correlationId, - responseType + this.browserCrypto, + request ); + const serverParams = invoke( ResponseHandler.deserializeResponse, BrowserPerformanceEvents.DeserializeResponse, diff --git a/lib/msal-browser/src/interaction_handler/SilentHandler.ts b/lib/msal-browser/src/interaction_handler/SilentHandler.ts index 05cf1cf9b1..49b485c2a5 100644 --- a/lib/msal-browser/src/interaction_handler/SilentHandler.ts +++ b/lib/msal-browser/src/interaction_handler/SilentHandler.ts @@ -7,7 +7,6 @@ import { Logger, IPerformanceClient, invoke, - Constants, Authority, CommonAuthorizationUrlRequest, } from "@azure/msal-common/browser"; @@ -18,7 +17,6 @@ import { } from "../error/BrowserAuthError.js"; import { BrowserConfiguration, - DEFAULT_IFRAME_TIMEOUT_MS, } from "../config/Configuration.js"; import { getEARForm } from "../protocol/Authorize.js"; @@ -71,80 +69,6 @@ export async function initiateEarRequest( return frame; } -/** - * Monitors an iframe content window until it loads a url with a known hash, or hits a specified timeout. - * @param iframe - * @param timeout - */ -export async function monitorIframeForHash( - iframe: HTMLIFrameElement, - timeout: number, - pollIntervalMilliseconds: number, - performanceClient: IPerformanceClient, - logger: Logger, - correlationId: string, - responseType: Constants.ResponseMode -): Promise { - return new Promise((resolve, reject) => { - if (timeout < DEFAULT_IFRAME_TIMEOUT_MS) { - logger.warning( - `system.loadFrameTimeout or system.iframeHashTimeout set to lower (${timeout}ms) than the default (${DEFAULT_IFRAME_TIMEOUT_MS}ms). This may result in timeouts.`, - correlationId - ); - } - - /* - * Polling for iframes can be purely timing based, - * since we don't need to account for interaction. - */ - const timeoutId = window.setTimeout(() => { - window.clearInterval(intervalId); - reject( - createBrowserAuthError( - BrowserAuthErrorCodes.monitorWindowTimeout - ) - ); - }, timeout); - - const intervalId = window.setInterval(() => { - let href: string = ""; - const contentWindow = iframe.contentWindow; - try { - /* - * Will throw if cross origin, - * which should be caught and ignored - * since we need the interval to keep running while on STS UI. - */ - href = contentWindow ? contentWindow.location.href : ""; - } catch (e) {} - - if (!href || href === "about:blank") { - return; - } - - let responseString = ""; - if (contentWindow) { - if (responseType === Constants.ResponseMode.QUERY) { - responseString = contentWindow.location.search; - } else { - responseString = contentWindow.location.hash; - } - } - window.clearTimeout(timeoutId); - window.clearInterval(intervalId); - resolve(responseString); - }, pollIntervalMilliseconds); - }).finally(() => { - invoke( - removeHiddenIframe, - BrowserPerformanceEvents.RemoveHiddenIframe, - logger, - performanceClient, - correlationId - )(iframe); - }); -} - /** * @hidden * Loads the iframe synchronously when the navigateTimeFrame is set to `0` diff --git a/lib/msal-browser/src/popup_bridge/index.ts b/lib/msal-browser/src/redirect_bridge/index.ts similarity index 71% rename from lib/msal-browser/src/popup_bridge/index.ts rename to lib/msal-browser/src/redirect_bridge/index.ts index c3e05c98ae..f544afb148 100644 --- a/lib/msal-browser/src/popup_bridge/index.ts +++ b/lib/msal-browser/src/redirect_bridge/index.ts @@ -4,15 +4,11 @@ */ import { ProtocolUtils } from "@azure/msal-common/browser"; -import { isInPopup } from "../utils/BrowserUtils.js"; +import { clearHash } from "../utils/BrowserUtils.js"; import { base64Decode } from "../encode/Base64Decode.js"; -export async function sendPopupPayloadToMainFrame(): Promise { +export async function broadcastResponseToMainFrame(): Promise { try { - if (!isInPopup()) { - throw new Error("Window is not a popup"); - } - // 1) Determine which URL container carries the payload const hasHash = !!window.location.hash && window.location.hash.length > 1; @@ -31,17 +27,7 @@ export async function sendPopupPayloadToMainFrame(): Promise { } // 2) Remove the response from URL for security - if (hasHash) { - // Clear hash - window.history.replaceState( - null, - "", - window.location.pathname + window.location.search - ); - } else { - // Clear query string - window.history.replaceState(null, "", window.location.pathname); - } + clearHash(window); const { libraryState } = ProtocolUtils.parseRequestState( base64Decode, diff --git a/lib/msal-browser/src/utils/BrowserUtils.ts b/lib/msal-browser/src/utils/BrowserUtils.ts index 06175c207e..f7c9bd826d 100644 --- a/lib/msal-browser/src/utils/BrowserUtils.ts +++ b/lib/msal-browser/src/utils/BrowserUtils.ts @@ -9,6 +9,11 @@ import { invokeAsync, UrlUtils, RequestParameterBuilder, + ICrypto, + Logger, + CommonAuthorizationUrlRequest, + CommonEndSessionRequest, + ProtocolUtils, } from "@azure/msal-common/browser"; import { createBrowserAuthError, @@ -65,6 +70,61 @@ export function isInPopup(): boolean { ); } +/** + * Await a response from a redirect bridge using BroadcastChannel. + * This unified function works for both popup and iframe scenarios by listening on a + * BroadcastChannel for the server payload. + * + * @param pollIntervalMilliseconds - The interval, in milliseconds, at which to poll for responses. + * @param timeoutMs - Timeout in milliseconds. + * @param logger - Logger instance for logging monitoring events. + * @param browserCrypto - Browser crypto instance for decoding state. + * @param request - The authorization or end session request. + * @returns Promise - Resolves with the response string (query or hash) from the window. + */ +export async function waitForBridgeResponse( + pollIntervalMilliseconds: number, + timeoutMs: number, + logger: Logger, + browserCrypto: ICrypto, + request: CommonAuthorizationUrlRequest | CommonEndSessionRequest, +): Promise { + return new Promise((resolve, reject) => { + logger.verbose( + "BrowserUtils.monitorWindowForHash - monitoring started", + request.correlationId + ); + + const { libraryState } = ProtocolUtils.parseRequestState( + browserCrypto.base64Decode, + request.state || "" + ); + const channel = new BroadcastChannel(libraryState.id); + let responseString: string | undefined = undefined; + channel.onmessage = (event) => { + responseString = event.data.payload; + }; + + const timeoutId = window.setTimeout(() => { + window.clearInterval(intervalId); + channel.close(); + reject(createBrowserAuthError("")); + }, timeoutMs); + + const intervalId = setInterval(() => { + // Response not yet received + if (!responseString) { + return; + } + + clearInterval(intervalId); + clearTimeout(timeoutId); + channel.close(); + resolve(responseString); + }, pollIntervalMilliseconds); + }); +} + // #endregion /** diff --git a/lib/msal-browser/src/utils/PopupUtils.ts b/lib/msal-browser/src/utils/PopupUtils.ts index 79be97d390..60b0f62f12 100644 --- a/lib/msal-browser/src/utils/PopupUtils.ts +++ b/lib/msal-browser/src/utils/PopupUtils.ts @@ -3,88 +3,6 @@ * Licensed under the MIT License. */ -import { - CommonAuthorizationUrlRequest, - CommonEndSessionRequest, - ICrypto, - Logger, - ProtocolUtils, -} from "@azure/msal-common/browser"; -import { - BrowserAuthErrorCodes, - createBrowserAuthError, -} from "../error/BrowserAuthError.js"; - -/** - * Monitors a popup window for a URL change to the same origin as the parent application. - * Polls the popup at a specified interval until it is redirected back to the application, - * closed by the user, or a navigation to a same-origin URL is detected. Once a same-origin - * URL is detected, extracts the response string (query or hash) and resolves the promise. - * Performs cleanup by closing the popup and removing event listeners when done. - * - * @param pollIntervalMilliseconds - The interval, in milliseconds, at which to poll the popup window. - * @param timeoutMs - popup timeout, ms. - * @param logger - Logger instance for logging monitoring events. - * @param browserCrypto - browser crypto. - * @param request - popup request. - * @returns Promise - Resolves with the response string (query or hash) from the popup window, - * or rejects if the popup is closed before a response is received. - * - * Monitoring behavior: Polls the popup window at the specified interval to detect navigation to a same-origin URL. - * Timeout handling: If the popup is closed before a response is detected, the promise is rejected with a user cancellation error. - * Cleanup process: On completion (success or failure), closes the popup and removes the unload event listener from the parent window. - */ -export async function monitorPopupForHash( - pollIntervalMilliseconds: number, - timeoutMs: number, - logger: Logger, - browserCrypto: ICrypto, - request: CommonAuthorizationUrlRequest | CommonEndSessionRequest -): Promise { - return new Promise((resolve, reject) => { - logger.verbose( - "PopupHandler.monitorPopupForHash - polling started", - request.correlationId - ); - - const { libraryState } = ProtocolUtils.parseRequestState( - browserCrypto.base64Decode, - request.state || "" - ); - const channel = new BroadcastChannel(libraryState.id); - let responseString: string | undefined = undefined; - channel.onmessage = (event) => { - responseString = event.data.payload; - }; - - /* - * Polling for iframes can be purely timing based, - * since we don't need to account for interaction. - */ - const timeoutId = window.setTimeout(() => { - window.clearInterval(intervalId); - channel.close(); - reject( - createBrowserAuthError( - BrowserAuthErrorCodes.monitorPopupTimeout - ) - ); - }, timeoutMs); - - const intervalId = setInterval(() => { - // Window is closed - if (!responseString) { - return; - } - - clearInterval(intervalId); - clearTimeout(timeoutId); - channel.close(); - resolve(responseString); - }, pollIntervalMilliseconds); - }); -} - /** * Performs cleanup operations after popup authentication. * Closes the popup window and removes the 'beforeunload' event listener from the parent window. diff --git a/lib/msal-browser/tsconfig.rollup-bridge.build.json b/lib/msal-browser/tsconfig.rollup-bridge.build.json index edfadaccd6..43d902c2ea 100644 --- a/lib/msal-browser/tsconfig.rollup-bridge.build.json +++ b/lib/msal-browser/tsconfig.rollup-bridge.build.json @@ -2,7 +2,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "rootDir": "./src", - "outDir": "./dist/popup-bridge", + "outDir": "./dist/redirect-bridge", }, "include": ["src"] } diff --git a/samples/msal-browser-samples/popup-coop/app/auth.js b/samples/msal-browser-samples/COOP/app/auth.js similarity index 88% rename from samples/msal-browser-samples/popup-coop/app/auth.js rename to samples/msal-browser-samples/COOP/app/auth.js index 397a4e0922..bd99f1bd08 100644 --- a/samples/msal-browser-samples/popup-coop/app/auth.js +++ b/samples/msal-browser-samples/COOP/app/auth.js @@ -23,16 +23,18 @@ function handleResponse(resp) { myMSALObj.setActiveAccount(resp.account); showWelcomeMessage(resp.account); - // Display success message with token info - console.log("[AUTH] Authentication successful! Response:", resp); - if (resp.idToken) { // Remove the login button completely - const loginButton = document.getElementById("openStsPopup"); + const loginButton = document.getElementById("loginPopup"); if (loginButton) { loginButton.remove(); } - + + const ssoButton = document.getElementById("sso"); + if (ssoButton) { + ssoButton.remove(); + } + // Also display in UI const successDiv = document.getElementById("successAuthCode"); if (successDiv) { @@ -77,3 +79,9 @@ async function loginPopup(request, account) { console.error(error); }); } + +async function sso(request) { + return myMSALObj.ssoSilent(request).then(handleResponse).catch((error) => { + console.error(error); + }); +} diff --git a/samples/msal-browser-samples/popup-coop/app/authConfig.js b/samples/msal-browser-samples/COOP/app/authConfig.js similarity index 100% rename from samples/msal-browser-samples/popup-coop/app/authConfig.js rename to samples/msal-browser-samples/COOP/app/authConfig.js diff --git a/samples/msal-browser-samples/popup-coop/app/index.html b/samples/msal-browser-samples/COOP/app/index.html similarity index 87% rename from samples/msal-browser-samples/popup-coop/app/index.html rename to samples/msal-browser-samples/COOP/app/index.html index 1a2b42e725..8f1b28a1a3 100644 --- a/samples/msal-browser-samples/popup-coop/app/index.html +++ b/samples/msal-browser-samples/COOP/app/index.html @@ -40,12 +40,11 @@
-
Vanilla JavaScript SPA calling MS Graph API with MSAL.JS
+
MSAL.js COOP sample


@@ -59,10 +58,10 @@
Vanilla JavaScript SPA calling MS Graph API


-
- - -
+
+ + +

@@ -91,7 +90,6 @@
Vanilla JavaScript SPA calling MS Graph API - diff --git a/samples/msal-browser-samples/COOP/app/redirect.html b/samples/msal-browser-samples/COOP/app/redirect.html new file mode 100644 index 0000000000..7c035bab33 --- /dev/null +++ b/samples/msal-browser-samples/COOP/app/redirect.html @@ -0,0 +1,18 @@ + + + + + + Redirect Page + + +

Processing authentication...

+ + + + + + diff --git a/samples/msal-browser-samples/COOP/app/ui.js b/samples/msal-browser-samples/COOP/app/ui.js new file mode 100644 index 0000000000..9de6b2416a --- /dev/null +++ b/samples/msal-browser-samples/COOP/app/ui.js @@ -0,0 +1,21 @@ +// Select DOM elements to work with +const welcomeDiv = document.getElementById("WelcomeMessage"); +const signInButton = document.getElementById("SignIn"); +const popupButton = document.getElementById("popup"); +const redirectButton = document.getElementById("redirect"); +const cardDiv = document.getElementById("card-div"); + +function showWelcomeMessage(account) { + // Reconfiguring DOM elements + //welcomeDiv.innerHTML = `Welcome ${account.username}`; + signInButton.setAttribute('class', "btn btn-success dropdown-toggle"); + signInButton.innerHTML = "Sign Out"; + popupButton.setAttribute('onClick', "signOut(this.id)"); + popupButton.innerHTML = "Sign Out with Popup"; + redirectButton.setAttribute('onClick', "signOut(this.id)"); + redirectButton.innerHTML = "Sign Out with Redirect"; +} + +// onLoad is now only called from redirect.html, not from the main page +// Removed DOMContentLoaded listener to prevent running on main page + diff --git a/samples/msal-browser-samples/popup-coop/jest.config.js b/samples/msal-browser-samples/COOP/jest.config.js similarity index 100% rename from samples/msal-browser-samples/popup-coop/jest.config.js rename to samples/msal-browser-samples/COOP/jest.config.js diff --git a/samples/msal-browser-samples/popup-coop/package.json b/samples/msal-browser-samples/COOP/package.json similarity index 100% rename from samples/msal-browser-samples/popup-coop/package.json rename to samples/msal-browser-samples/COOP/package.json diff --git a/samples/msal-browser-samples/popup-coop/server.js b/samples/msal-browser-samples/COOP/server.js similarity index 100% rename from samples/msal-browser-samples/popup-coop/server.js rename to samples/msal-browser-samples/COOP/server.js diff --git a/samples/msal-browser-samples/popup-coop/sts/lmso_server.js b/samples/msal-browser-samples/COOP/sts/lmso_server.js similarity index 100% rename from samples/msal-browser-samples/popup-coop/sts/lmso_server.js rename to samples/msal-browser-samples/COOP/sts/lmso_server.js diff --git a/samples/msal-browser-samples/popup-coop/sts/package.json b/samples/msal-browser-samples/COOP/sts/package.json similarity index 100% rename from samples/msal-browser-samples/popup-coop/sts/package.json rename to samples/msal-browser-samples/COOP/sts/package.json diff --git a/samples/msal-browser-samples/popup-coop/sts/sts_index.html b/samples/msal-browser-samples/COOP/sts/sts_index.html similarity index 100% rename from samples/msal-browser-samples/popup-coop/sts/sts_index.html rename to samples/msal-browser-samples/COOP/sts/sts_index.html diff --git a/samples/msal-browser-samples/popup-coop/sts/ui.js b/samples/msal-browser-samples/COOP/sts/ui.js similarity index 100% rename from samples/msal-browser-samples/popup-coop/sts/ui.js rename to samples/msal-browser-samples/COOP/sts/ui.js diff --git a/samples/msal-browser-samples/popup-coop/tsconfig.base.json b/samples/msal-browser-samples/COOP/tsconfig.base.json similarity index 100% rename from samples/msal-browser-samples/popup-coop/tsconfig.base.json rename to samples/msal-browser-samples/COOP/tsconfig.base.json diff --git a/samples/msal-browser-samples/popup-coop/tsconfig.json b/samples/msal-browser-samples/COOP/tsconfig.json similarity index 100% rename from samples/msal-browser-samples/popup-coop/tsconfig.json rename to samples/msal-browser-samples/COOP/tsconfig.json diff --git a/samples/msal-browser-samples/popup-coop/app/graph.js b/samples/msal-browser-samples/popup-coop/app/graph.js deleted file mode 100644 index db64c7931d..0000000000 --- a/samples/msal-browser-samples/popup-coop/app/graph.js +++ /dev/null @@ -1,64 +0,0 @@ -// Helper function to call MS Graph API endpoint -// using authorization bearer token scheme -function callMSGraph(endpoint, accessToken, callback) { - const headers = new Headers(); - const bearer = `Bearer ${accessToken}`; - - headers.append("Authorization", bearer); - - const options = { - method: "GET", - headers: headers - }; - - console.log('request made to Graph API at: ' + new Date().toString()); - - fetch(endpoint, options) - .then(response => response.json()) - .then(response => callback(response, endpoint)) - .catch(error => console.log(error)); -} - -async function seeProfile() { - const currentAcc = myMSALObj.getAccountByHomeId(accountId); - if (currentAcc) { - const response = await loginPopup(loginRequest, currentAcc).catch(error => { - console.log(error); - }); - callMSGraph(graphConfig.graphMeEndpoint, response.accessToken, updateUI); - profileButton.style.display = 'none'; - } -} - -async function readMail() { - const currentAcc = myMSALObj.getAccountByHomeId(accountId); - if (currentAcc) { - const response = await loginPopup(tokenRequest, currentAcc).catch(error => { - console.log(error); - }); - callMSGraph(graphConfig.graphMailEndpoint, response.accessToken, updateUI); - mailButton.style.display = 'none'; - } -} - -async function seeProfileRedirect() { - const currentAcc = myMSALObj.getAccountByHomeId(accountId); - if (currentAcc) { - const response = await getTokenRedirect(loginRequest, currentAcc).catch(error => { - console.log(error); - }); - callMSGraph(graphConfig.graphMeEndpoint, response.accessToken, updateUI); - profileButton.style.display = 'none'; - } -} - -async function readMailRedirect() { - const currentAcc = myMSALObj.getAccountByHomeId(accountId); - if (currentAcc) { - const response = await getTokenRedirect(tokenRequest, currentAcc).catch(error => { - console.log(error); - }); - callMSGraph(graphConfig.graphMailEndpoint, response.accessToken, updateUI); - mailButton.style.display = 'none'; - } -} diff --git a/samples/msal-browser-samples/popup-coop/app/redirect.html b/samples/msal-browser-samples/popup-coop/app/redirect.html deleted file mode 100644 index 6d4217224e..0000000000 --- a/samples/msal-browser-samples/popup-coop/app/redirect.html +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - Redirect Page - - -

Processing authentication...

- - - - - - diff --git a/samples/msal-browser-samples/popup-coop/app/ui.js b/samples/msal-browser-samples/popup-coop/app/ui.js deleted file mode 100644 index cebd1bc0bb..0000000000 --- a/samples/msal-browser-samples/popup-coop/app/ui.js +++ /dev/null @@ -1,73 +0,0 @@ -// Select DOM elements to work with -const welcomeDiv = document.getElementById("WelcomeMessage"); -const signInButton = document.getElementById("SignIn"); -const popupButton = document.getElementById("popup"); -const redirectButton = document.getElementById("redirect"); -const cardDiv = document.getElementById("card-div"); -const mailButton = document.getElementById("readMail"); -const profileButton = document.getElementById("seeProfile"); -const profileDiv = document.getElementById("profile-div"); - -function showWelcomeMessage(account) { - // Reconfiguring DOM elements - //welcomeDiv.innerHTML = `Welcome ${account.username}`; - signInButton.setAttribute('class', "btn btn-success dropdown-toggle"); - signInButton.innerHTML = "Sign Out"; - popupButton.setAttribute('onClick', "signOut(this.id)"); - popupButton.innerHTML = "Sign Out with Popup"; - redirectButton.setAttribute('onClick', "signOut(this.id)"); - redirectButton.innerHTML = "Sign Out with Redirect"; -} - -function updateUI(data, endpoint) { - console.log('Graph API responded at: ' + new Date().toString()); - - if (endpoint === graphConfig.graphMeEndpoint) { - const title = document.createElement('p'); - title.innerHTML = "Title: " + data.jobTitle; - const email = document.createElement('p'); - email.innerHTML = "Mail: " + data.mail; - const phone = document.createElement('p'); - phone.innerHTML = "Phone: " + data.businessPhones[0]; - const address = document.createElement('p'); - address.innerHTML = "Location: " + data.officeLocation; - profileDiv.appendChild(title); - profileDiv.appendChild(email); - profileDiv.appendChild(phone); - profileDiv.appendChild(address); - } else if (endpoint === graphConfig.graphMailEndpoint) { - if (data.value.length < 1) { - alert("Your mailbox is empty!") - } else { - const tabList = document.getElementById("list-tab"); - const tabContent = document.getElementById("nav-tabContent"); - - data.value.map((d, i) => { - // Keeping it simple - if (i < 10) { - const listItem = document.createElement("a"); - listItem.setAttribute("class", "list-group-item list-group-item-action") - listItem.setAttribute("id", "list" + i + "list") - listItem.setAttribute("data-toggle", "list") - listItem.setAttribute("href", "#list" + i) - listItem.setAttribute("role", "tab") - listItem.setAttribute("aria-controls", i) - listItem.innerHTML = d.subject; - tabList.appendChild(listItem) - - const contentItem = document.createElement("div"); - contentItem.setAttribute("class", "tab-pane fade") - contentItem.setAttribute("id", "list" + i) - contentItem.setAttribute("role", "tabpanel") - contentItem.setAttribute("aria-labelledby", "list" + i + "list") - contentItem.innerHTML = " from: " + d.from.emailAddress.address + "

" + d.bodyPreview + "..."; - tabContent.appendChild(contentItem); - } - }); - } - } -} - -// onLoad is now only called from redirect.html, not from the main page -// Removed DOMContentLoaded listener to prevent running on main page - From 1be20efa59c26ebb4f8dd65ca85265274cf239ee Mon Sep 17 00:00:00 2001 From: Konstantin Shabelko Date: Tue, 4 Nov 2025 16:57:24 -0500 Subject: [PATCH 07/45] - Refactor silent client to use redirect bridge for both popup and SSO scenarios. - Update COOP sample with additional SSO functionality --- samples/msal-browser-samples/COOP/app/redirect.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/msal-browser-samples/COOP/app/redirect.html b/samples/msal-browser-samples/COOP/app/redirect.html index 7c035bab33..54dacdda48 100644 --- a/samples/msal-browser-samples/COOP/app/redirect.html +++ b/samples/msal-browser-samples/COOP/app/redirect.html @@ -12,7 +12,7 @@ From 2b46d6a0bc1d9c25f1c52a72700f90781b7ece5a Mon Sep 17 00:00:00 2001 From: Konstantin Shabelko Date: Tue, 4 Nov 2025 18:23:58 -0500 Subject: [PATCH 08/45] Update package-lock.json --- package-lock.json | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 614ebe962e..0bd26467bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19936,7 +19936,6 @@ "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "dev": true, "dependencies": { "object-assign": "^4", "vary": "^1" @@ -30790,6 +30789,10 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "node_modules/msal-browser-popup-coop": { + "resolved": "samples/msal-browser-samples/COOP", + "link": true + }, "node_modules/msal-browser-testing-sample": { "resolved": "samples/msal-browser-samples/TestingSample", "link": true @@ -55795,6 +55798,24 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, + "samples/msal-browser-samples/COOP": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@azure/msal-node": "^5.0.0-alpha.0", + "cors": "^2.8.5", + "express": "^4.19.2", + "path": "^0.11.14" + }, + "devDependencies": { + "@playwright/test": "^1.30.0" + } + }, + "samples/msal-browser-samples/COOP/node_modules/path": { + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/path/-/path-0.11.14.tgz", + "integrity": "sha512-CzEXTDgcEfa0yqMe+DJCSbEB5YCv4JZoic5xulBNFF2ifIMjNrTWbNSPNhgKfSo0MjneGIx9RLy4pCFuZPaMSQ==" + }, "samples/msal-browser-samples/ExpressSample": { "name": "express-sample", "version": "1.0.0", From da8168d3e1e0c784d3cd1df2fc3b38f38c5bd04a Mon Sep 17 00:00:00 2001 From: Konstantin Shabelko Date: Thu, 6 Nov 2025 16:11:12 -0500 Subject: [PATCH 09/45] - Update unit tests. - Update config params. - Add bridge error description. --- docs/errors.md | 63 +-- .../apiReview/msal-browser.api.md | 32 +- lib/msal-browser/src/config/Configuration.ts | 22 +- .../src/error/BrowserAuthErrorCodes.ts | 3 +- .../src/interaction_client/PopupClient.ts | 6 +- .../interaction_client/SilentIframeClient.ts | 4 +- .../src/interaction_handler/SilentHandler.ts | 15 +- lib/msal-browser/src/utils/BrowserUtils.ts | 9 +- .../test/app/PublicClientApplication.spec.ts | 102 +++-- .../test/config/Configuration.spec.ts | 63 +-- .../interaction_client/PopupClient.spec.ts | 406 ++++++++---------- .../interaction_client/RedirectClient.spec.ts | 14 +- .../SilentIframeClient.spec.ts | 67 +-- .../interaction_handler/SilentHandler.spec.ts | 273 +++++++----- .../test/utils/BrowserUtils.spec.ts | 25 -- lib/msal-common/apiReview/msal-common.api.md | 155 +++---- .../test/utils/ProtocolUtils.spec.ts | 10 +- 17 files changed, 643 insertions(+), 626 deletions(-) diff --git a/docs/errors.md b/docs/errors.md index a03d28f171..451a3e0088 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -567,43 +567,53 @@ If you are unable to figure out why this error is being thrown please [open an i - User cancelled the flow. -### `monitor_popup_timeout` +### `redirect_bridge_timeout` -- Token acquisition in popup failed due to timeout. +- Communication with the redirect page (popup or iframe) timed out while waiting for authentication response. -### `monitor_window_timeout` +**Error Messages**: -- Token acquisition in iframe failed due to timeout. +- Token acquisition in popup failed due to timeout. +- Token acquisition in iframe failed due to timeout. -**Error Messages**: +This error can be thrown when calling `ssoSilent`, `acquireTokenSilent`, `acquireTokenPopup` or `loginPopup` when the redirect bridge script fails to send the authentication response back to the main window within the configured timeout period. This typically occurs for the following reasons: -- Token acquisition in iframe failed due to timeout. +1. The page you use as your `redirectUri` is not loading the `msal-redirect-bridge.js` script +1. The redirect page is removing or manipulating the hash before the bridge script can process it +1. The redirect page is automatically navigating to a different page before the bridge can communicate the response +1. Your identity provider is being slow to redirect back to your `redirectUri` (network latency) +1. You are being throttled by your identity provider due to too many requests in a short period -This error can be thrown when calling `ssoSilent`, `acquireTokenSilent`, `acquireTokenPopup` or `loginPopup` and there are several reasons this could happen. These are a few of the most common: +**Resolution Steps:** -1. The page you use as your `redirectUri` is removing or manipulating the hash -1. The page you use as your `redirectUri` is automatically navigating to a different page -1. You are being throttled by your identity provider. The identity provider may throttle clients that make too many similar requests in a short period of time. Never implement an endless retry mechanism or retry more than once. Attempts to retry non-network errors typically yield the same result. See [throttling guide](#Throttling) for more details. -1. Your identity provider did not redirect back to your `redirectUri`. +✔️ **Ensure the redirect bridge script is loaded:** -**Important**: If your application uses a router library (e.g. React Router, Angular Router), please make sure it does not strip the hash or auto-redirect while MSAL token acquisition is in progress. If possible, it is best if your `redirectUri` page does not invoke the router at all. +Your `redirectUri` page must include the redirect bridge script to enable communication back to the main window: -#### Issues caused by the redirectUri page +```html + + + + Redirect + + + + + + +``` -When you make a silent call, in some cases, an iframe will be opened and will navigate to your identity provider's authorization page. After the identity provider has authorized the user it will redirect the iframe back to the `redirectUri` with the authorization code or error information in the hash fragment. The MSAL instance running in the frame or window that originally made the request will extract this response hash and process it. If your `redirectUri` is removing or manipulating this hash or navigating to a different page before MSAL has extracted it you will receive this timeout error. +**Important**: If your application uses a router library (e.g. React Router, Angular Router), please make sure it does not strip the hash or auto-redirect while MSAL token acquisition is in progress. If possible, it is best if your `redirectUri` page does not invoke the router at all. -✔️ To solve this problem you should ensure that the page you use as your `redirectUri` is not doing any of these things, at the very least, when loaded in a popup or iframe. We recommend using a blank page as your `redirectUri` for silent and popup flows to ensure none of these things can occur. +#### Issues caused by the redirectUri page -You can do this on a per request basis, for example: +When you make a silent call, in some cases, an iframe will be opened and will navigate to your identity provider's authorization page. After the identity provider has authorized the user it will redirect the iframe back to the `redirectUri` with the authorization code or error information in the hash fragment. The MSAL redirect bridge running in the iframe will broadcast response to MSAL instance running in the frame or window that originally made the request. If your `redirectUri` is removing or manipulating this hash or navigating to a different page before MSAL redirect bridge has extracted it you will receive this timeout error. -```javascript -msalInstance.acquireTokenSilent({ - scopes: ["User.Read"], - redirectUri: "http://localhost:3000/blank.html", -}); -``` +✔️ To solve this problem you should ensure that the page you use as your `redirectUri` is not doing any of these things, at the very least, when loaded in a popup or iframe. -Remember that you will need to register this new `redirectUri` on your App Registration. +Remember that you will need to register `redirectUri` on your App Registration. **Notes regarding Angular and React:** @@ -638,7 +648,7 @@ Some B2C flows are expected to throw this error due to their need for user inter Another potential reason the identity provider may not redirect back to your application in time may be that there is some extra network latency. -✔️ The default timeout is about 10 seconds and should be sufficient in most cases, however, if your identity provider is taking longer than that to redirect you can increase this timeout in the MSAL config with either the `iframeHashTimeout`, `windowHashTimeout` or `loadFrameTimeout` configuration parameters. +✔️ The default timeout is about 10 seconds and should be sufficient in most cases, however, if your identity provider is taking longer than that to redirect you can increase this timeout in the MSAL config with either the `iframeBridgeTimeout` or `popupBridgeTimeout` configuration parameters. ```javascript const msalConfig = { @@ -646,9 +656,8 @@ const msalConfig = { clientId: "your-client-id", }, system: { - windowHashTimeout: 9000, // Applies just to popup calls - In milliseconds - iframeHashTimeout: 9000, // Applies just to silent calls - In milliseconds - loadFrameTimeout: 9000, // Applies to both silent and popup calls - In milliseconds + popupBridgeTimeout: 50000, // Applies just to popup calls - In milliseconds + iframeBridgeTimeout: 9000, // Applies just to silent calls - In milliseconds }, }; ``` diff --git a/lib/msal-browser/apiReview/msal-browser.api.md b/lib/msal-browser/apiReview/msal-browser.api.md index de67b9c7c7..1790556a97 100644 --- a/lib/msal-browser/apiReview/msal-browser.api.md +++ b/lib/msal-browser/apiReview/msal-browser.api.md @@ -25,6 +25,7 @@ import { CommonEndSessionRequest } from '@azure/msal-common/browser'; import { CommonSilentFlowRequest } from '@azure/msal-common/browser'; import { Constants } from '@azure/msal-common/browser'; import { ExternalTokenResponse } from '@azure/msal-common/browser'; +import { ICrypto } from '@azure/msal-common/browser'; import { IdTokenClaims } from '@azure/msal-common/browser'; import { ILoggerCallback } from '@azure/msal-common/browser'; import { INetworkModule } from '@azure/msal-common/browser'; @@ -236,8 +237,7 @@ declare namespace BrowserAuthErrorCodes { popupWindowError, emptyWindowError, userCancelled, - monitorPopupTimeout, - monitorWindowTimeout, + redirectBridgeTimeout, redirectInIframe, blockIframeReload, blockNestedPopups, @@ -383,9 +383,8 @@ export type BrowserSystemOptions = SystemOptions & { loggerOptions?: LoggerOptions; networkClient?: INetworkModule; navigationClient?: INavigationClient; - windowHashTimeout?: number; - iframeHashTimeout?: number; - loadFrameTimeout?: number; + popupBridgeTimeout?: number; + iframeBridgeTimeout?: number; redirectNavigationTimeout?: number; navigatePopups?: boolean; allowRedirectInIframe?: boolean; @@ -409,6 +408,7 @@ declare namespace BrowserUtils { replaceHash, isInIframe, isInPopup, + waitForBridgeResponse, getCurrentUri, getHomepage, blockReloadInHiddenIframes, @@ -1055,16 +1055,6 @@ export class MemoryStorage implements IWindowStorage { setUserData(key: string, value: T): Promise; } -// Warning: (ae-missing-release-tag) "monitorPopupTimeout" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -const monitorPopupTimeout = "monitor_popup_timeout"; - -// Warning: (ae-missing-release-tag) "monitorWindowTimeout" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -const monitorWindowTimeout = "monitor_window_timeout"; - // Warning: (ae-missing-release-tag) "nativeConnectionNotEstablished" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1380,6 +1370,11 @@ export class PublicClientNext implements IPublicClientApplication { ssoSilent(request: SsoSilentRequest): Promise; } +// Warning: (ae-missing-release-tag) "redirectBridgeTimeout" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +const redirectBridgeTimeout = "redirect_bridge_timeout"; + // Warning: (ae-missing-release-tag) "redirectInIframe" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1567,6 +1562,11 @@ const userCancelled = "user_cancelled"; // @public (undocumented) export const version = "5.0.0-alpha.0"; +// Warning: (ae-missing-release-tag) "waitForBridgeResponse" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +function waitForBridgeResponse(pollIntervalMilliseconds: number, timeoutMs: number, logger: Logger, browserCrypto: ICrypto, request: CommonAuthorizationUrlRequest | CommonEndSessionRequest): Promise; + // Warning: (ae-missing-release-tag) "WrapperSKU" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // Warning: (ae-missing-release-tag) "WrapperSKU" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -1595,7 +1595,7 @@ export type WrapperSKU = (typeof WrapperSKU)[keyof typeof WrapperSKU]; // src/cache/LocalStorage.ts:366:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/cache/LocalStorage.ts:429:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/cache/LocalStorage.ts:460:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/config/Configuration.ts:211:5 - (ae-forgotten-export) The symbol "InternalAuthOptions" needs to be exported by the entry point index.d.ts +// src/config/Configuration.ts:207:5 - (ae-forgotten-export) The symbol "InternalAuthOptions" needs to be exported by the entry point index.d.ts // src/event/EventHandler.ts:114:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/event/EventHandler.ts:141:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/index.ts:8:12 - (tsdoc-characters-after-block-tag) The token "@azure" looks like a TSDoc tag but contains an invalid character "/"; if it is not a tag, use a backslash to escape the "@" diff --git a/lib/msal-browser/src/config/Configuration.ts b/lib/msal-browser/src/config/Configuration.ts index 58072777fa..6494e06292 100644 --- a/lib/msal-browser/src/config/Configuration.ts +++ b/lib/msal-browser/src/config/Configuration.ts @@ -129,17 +129,13 @@ export type BrowserSystemOptions = SystemOptions & { */ navigationClient?: INavigationClient; /** - * Sets the timeout for waiting for a response hash in a popup. Will take precedence over loadFrameTimeout if both are set. + * Sets the timeout for waiting for response from a popup using BroadcastChannel */ - windowHashTimeout?: number; + popupBridgeTimeout?: number; /** - * Sets the timeout for waiting for a response hash in an iframe. Will take precedence over loadFrameTimeout if both are set. + * Sets the timeout for waiting for response from an iframe using BroadcastChannel */ - iframeHashTimeout?: number; - /** - * Sets the timeout for waiting for a response hash in an iframe or popup - */ - loadFrameTimeout?: number; + iframeBridgeTimeout?: number; /** * Time to wait for redirection to occur before resolving promise */ @@ -282,12 +278,10 @@ export function buildConfiguration( ? new FetchClient() : StubbedNetworkModule, navigationClient: new NavigationClient(), - loadFrameTimeout: 0, - // If loadFrameTimeout is provided, use that as default. - windowHashTimeout: - userInputSystem?.loadFrameTimeout || DEFAULT_POPUP_TIMEOUT_MS, - iframeHashTimeout: - userInputSystem?.loadFrameTimeout || DEFAULT_IFRAME_TIMEOUT_MS, + popupBridgeTimeout: + userInputSystem?.popupBridgeTimeout || DEFAULT_POPUP_TIMEOUT_MS, + iframeBridgeTimeout: + userInputSystem?.iframeBridgeTimeout || DEFAULT_IFRAME_TIMEOUT_MS, redirectNavigationTimeout: DEFAULT_REDIRECT_TIMEOUT_MS, allowRedirectInIframe: false, navigatePopups: true, diff --git a/lib/msal-browser/src/error/BrowserAuthErrorCodes.ts b/lib/msal-browser/src/error/BrowserAuthErrorCodes.ts index 31ee9fc9e5..34a20fe6c1 100644 --- a/lib/msal-browser/src/error/BrowserAuthErrorCodes.ts +++ b/lib/msal-browser/src/error/BrowserAuthErrorCodes.ts @@ -18,8 +18,7 @@ export const interactionInProgress = "interaction_in_progress"; export const popupWindowError = "popup_window_error"; export const emptyWindowError = "empty_window_error"; export const userCancelled = "user_cancelled"; -export const monitorPopupTimeout = "monitor_popup_timeout"; -export const monitorWindowTimeout = "monitor_window_timeout"; +export const redirectBridgeTimeout = "redirect_bridge_timeout"; export const redirectInIframe = "redirect_in_iframe"; export const blockIframeReload = "block_iframe_reload"; export const blockNestedPopups = "block_nested_popups"; diff --git a/lib/msal-browser/src/interaction_client/PopupClient.ts b/lib/msal-browser/src/interaction_client/PopupClient.ts index 200609d1da..bc1b6ca3a6 100644 --- a/lib/msal-browser/src/interaction_client/PopupClient.ts +++ b/lib/msal-browser/src/interaction_client/PopupClient.ts @@ -351,7 +351,7 @@ export class PopupClient extends StandardInteractionClient { // Monitor the window for the hash. Return the string value and close the popup when the hash is received. Default timeout is 60 seconds. const responseString = await BrowserUtils.waitForBridgeResponse( this.config.system.pollIntervalMilliseconds, - this.config.system.windowHashTimeout, + this.config.system.popupBridgeTimeout, this.logger, this.browserCrypto, request @@ -469,7 +469,7 @@ export class PopupClient extends StandardInteractionClient { correlationId )( this.config.system.pollIntervalMilliseconds, - this.config.system.windowHashTimeout, + this.config.system.popupBridgeTimeout, this.logger, this.browserCrypto, popupRequest @@ -619,7 +619,7 @@ export class PopupClient extends StandardInteractionClient { await BrowserUtils.waitForBridgeResponse( this.config.system.pollIntervalMilliseconds, - this.config.system.windowHashTimeout, + this.config.system.popupBridgeTimeout, this.logger, this.browserCrypto, validRequest diff --git a/lib/msal-browser/src/interaction_client/SilentIframeClient.ts b/lib/msal-browser/src/interaction_client/SilentIframeClient.ts index 4907c7aaf7..d9e79e2902 100644 --- a/lib/msal-browser/src/interaction_client/SilentIframeClient.ts +++ b/lib/msal-browser/src/interaction_client/SilentIframeClient.ts @@ -290,7 +290,7 @@ export class SilentIframeClient extends StandardInteractionClient { correlationId )( this.config.system.pollIntervalMilliseconds, - this.config.system.iframeHashTimeout, + this.config.system.iframeBridgeTimeout, this.logger, this.browserCrypto, request @@ -395,7 +395,7 @@ export class SilentIframeClient extends StandardInteractionClient { correlationId )( this.config.system.pollIntervalMilliseconds, - this.config.system.iframeHashTimeout, + this.config.system.iframeBridgeTimeout, this.logger, this.browserCrypto, request diff --git a/lib/msal-browser/src/interaction_handler/SilentHandler.ts b/lib/msal-browser/src/interaction_handler/SilentHandler.ts index 49b485c2a5..4cbd499d94 100644 --- a/lib/msal-browser/src/interaction_handler/SilentHandler.ts +++ b/lib/msal-browser/src/interaction_handler/SilentHandler.ts @@ -15,9 +15,7 @@ import { createBrowserAuthError, BrowserAuthErrorCodes, } from "../error/BrowserAuthError.js"; -import { - BrowserConfiguration, -} from "../config/Configuration.js"; +import { BrowserConfiguration } from "../config/Configuration.js"; import { getEARForm } from "../protocol/Authorize.js"; /** @@ -105,14 +103,3 @@ function createHiddenIframe(): HTMLIFrameElement { return authFrame; } - -/** - * @hidden - * Removes a hidden iframe from the page. - * @ignore - */ -function removeHiddenIframe(iframe: HTMLIFrameElement): void { - if (document.body === iframe.parentNode) { - document.body.removeChild(iframe); - } -} diff --git a/lib/msal-browser/src/utils/BrowserUtils.ts b/lib/msal-browser/src/utils/BrowserUtils.ts index f7c9bd826d..3e8fd37795 100644 --- a/lib/msal-browser/src/utils/BrowserUtils.ts +++ b/lib/msal-browser/src/utils/BrowserUtils.ts @@ -26,6 +26,7 @@ import { createBrowserConfigurationAuthError, } from "../error/BrowserConfigurationAuthError.js"; import type { BrowserConfiguration } from "../config/Configuration.js"; +import { redirectBridgeTimeout } from "../error/BrowserAuthErrorCodes.js"; /** * Clears hash from window url. @@ -66,7 +67,7 @@ export function isInPopup(): boolean { return ( !isInIframe() && (new URLSearchParams(location.search).has("client_info") || - new URLSearchParams(location.hash).has("client_info")) + new URLSearchParams(location.hash).has("client_info")) ); } @@ -87,11 +88,11 @@ export async function waitForBridgeResponse( timeoutMs: number, logger: Logger, browserCrypto: ICrypto, - request: CommonAuthorizationUrlRequest | CommonEndSessionRequest, + request: CommonAuthorizationUrlRequest | CommonEndSessionRequest ): Promise { return new Promise((resolve, reject) => { logger.verbose( - "BrowserUtils.monitorWindowForHash - monitoring started", + "BrowserUtils.waitForBridgeResponse - started", request.correlationId ); @@ -108,7 +109,7 @@ export async function waitForBridgeResponse( const timeoutId = window.setTimeout(() => { window.clearInterval(intervalId); channel.close(); - reject(createBrowserAuthError("")); + reject(createBrowserAuthError(redirectBridgeTimeout)); }, timeoutMs); const intervalId = setInterval(() => { diff --git a/lib/msal-browser/test/app/PublicClientApplication.spec.ts b/lib/msal-browser/test/app/PublicClientApplication.spec.ts index 92e4cd0649..64aa919fe8 100644 --- a/lib/msal-browser/test/app/PublicClientApplication.spec.ts +++ b/lib/msal-browser/test/app/PublicClientApplication.spec.ts @@ -50,7 +50,6 @@ import { AccountEntityUtils, Constants, } from "@azure/msal-common/browser"; -import * as BrowserPerformanceEvents from "../../src/telemetry/BrowserPerformanceEvents.js"; import { ApiId, BrowserCacheLocation, @@ -203,9 +202,20 @@ const testRequest: CommonAuthorizationUrlRequest = { nonce: ID_TOKEN_CLAIMS.nonce, }; +jest.mock("@azure/msal-common/browser", () => ({ + ...jest.requireActual("@azure/msal-common/browser"), + ProtocolUtils: { + ...jest.requireActual("@azure/msal-common/browser").ProtocolUtils, + setRequestState: jest.fn(), + }, +})); + describe("PublicClientApplication.ts Class Unit Tests", () => { let pca: PublicClientApplication; let browserStorage: BrowserCacheManager; + let mockSetRequestState: jest.MockedFunction< + typeof ProtocolUtils.setRequestState + >; beforeEach(async () => { pca = new PublicClientApplication({ auth: { @@ -258,6 +268,14 @@ describe("PublicClientApplication.ts Class Unit Tests", () => { }; return authorityMetadata; }); + + mockSetRequestState = + ProtocolUtils.setRequestState as jest.MockedFunction< + typeof ProtocolUtils.setRequestState + >; + mockSetRequestState.mockReturnValue( + TEST_STATE_VALUES.TEST_STATE_SILENT + ); }); afterEach(() => { @@ -1897,18 +1915,15 @@ describe("PublicClientApplication.ts Class Unit Tests", () => { }); it("throws error if called in a popup", (done) => { - const oldWindowOpener = window.opener; - const oldWindowName = window.name; - const newWindow = { - ...window, - }; - - // @ts-ignore - delete window.opener; - // @ts-ignore - delete window.name; - window.opener = newWindow; - window.name = "msal.testPopup"; + const originalWindowUrl = window.URL; + Object.defineProperty(window, "location", { + configurable: true, + enumerable: true, + writable: true, + value: new URL( + `http://localhost?client_info=${TEST_DATA_CLIENT_INFO.TEST_RAW_CLIENT_INFO}` + ), + }); jest.spyOn(BrowserUtils, "isInIframe").mockReturnValue(false); pca.acquireTokenRedirect({ scopes: ["openid"] }) @@ -1925,8 +1940,11 @@ describe("PublicClientApplication.ts Class Unit Tests", () => { done(); }) .finally(() => { - window.name = oldWindowName; - window.opener = oldWindowOpener; + Object.defineProperty(window, "location", { + configurable: true, + enumerable: true, + value: { ...originalWindowUrl, href: "localhost" }, + }); }); }); @@ -3004,19 +3022,15 @@ describe("PublicClientApplication.ts Class Unit Tests", () => { }); it("throws error if called in a popup", (done) => { - const oldWindowOpener = window.opener; - const oldWindowName = window.name; - - const newWindow = { - ...window, - }; - - // @ts-ignore - delete window.opener; - // @ts-ignore - delete window.name; - window.opener = newWindow; - window.name = "msal.testPopup"; + const originalWindowUrl = window.URL; + Object.defineProperty(window, "location", { + configurable: true, + enumerable: true, + writable: true, + value: new URL( + `http://localhost?client_info=${TEST_DATA_CLIENT_INFO.TEST_RAW_CLIENT_INFO}` + ), + }); pca.acquireTokenPopup({ scopes: ["openid"] }) .catch((e) => { @@ -3032,8 +3046,11 @@ describe("PublicClientApplication.ts Class Unit Tests", () => { done(); }) .finally(() => { - window.name = oldWindowName; - window.opener = oldWindowOpener; + Object.defineProperty(window, "location", { + configurable: true, + enumerable: true, + value: { ...originalWindowUrl, href: "localhost" }, + }); }); }); @@ -3119,7 +3136,7 @@ describe("PublicClientApplication.ts Class Unit Tests", () => { TEST_CONFIG.TOKEN_TYPE_BEARER as Constants.AuthenticationScheme, }; - jest.spyOn(PopupUtils, "monitorPopupForHash").mockRejectedValue( + jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockRejectedValue( "Not important for this test" ); @@ -3176,7 +3193,7 @@ describe("PublicClientApplication.ts Class Unit Tests", () => { TEST_CONFIG.TOKEN_TYPE_BEARER as Constants.AuthenticationScheme, }; - jest.spyOn(PopupUtils, "monitorPopupForHash").mockRejectedValue( + jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockRejectedValue( "Not important for this test" ); try { @@ -3388,6 +3405,15 @@ describe("PublicClientApplication.ts Class Unit Tests", () => { }); it("does not mutate request correlation id", async () => { + pca = new PublicClientApplication({ + auth: { + clientId: TEST_CONFIG.MSAL_CLIENT_ID, + }, + system: { + iframeBridgeTimeout: 100, + }, + }); + const request: SilentRequest = { scopes: [], }; @@ -5240,9 +5266,6 @@ describe("PublicClientApplication.ts Class Unit Tests", () => { jest.spyOn(BrowserCrypto, "createNewGuid").mockReturnValue( RANDOM_TEST_GUID ); - jest.spyOn(ProtocolUtils, "setRequestState").mockReturnValue( - TEST_STATE_VALUES.TEST_STATE_SILENT - ); const CommonSilentFlowRequest: SilentRequest = { scopes: ["User.Read"], account: testAccount, @@ -5307,9 +5330,6 @@ describe("PublicClientApplication.ts Class Unit Tests", () => { idTokenClaims: { ...testIdTokenClaims }, }; - jest.spyOn(ProtocolUtils, "setRequestState").mockReturnValue( - TEST_STATE_VALUES.TEST_STATE_SILENT - ); const silentRequest: SilentRequest = { scopes: ["User.Read"], account: testAccount, @@ -5488,9 +5508,6 @@ describe("PublicClientApplication.ts Class Unit Tests", () => { }, }; - jest.spyOn(ProtocolUtils, "setRequestState").mockReturnValue( - TEST_STATE_VALUES.TEST_STATE_SILENT - ); const silentRequest: SilentRequest = { scopes: ["User.Read"], account: testAccount, @@ -7221,6 +7238,9 @@ describe("PublicClientApplication.ts Class Unit Tests", () => { telemetry: { client: new BrowserPerformanceClient(testAppConfig), }, + system: { + iframeBridgeTimeout: 100, + }, }; pca = new PublicClientApplication(config); performanceClient = config.telemetry.client; diff --git a/lib/msal-browser/test/config/Configuration.spec.ts b/lib/msal-browser/test/config/Configuration.spec.ts index 8b76c62ddf..45d880fbbf 100644 --- a/lib/msal-browser/test/config/Configuration.spec.ts +++ b/lib/msal-browser/test/config/Configuration.spec.ts @@ -58,12 +58,12 @@ describe("Configuration.ts Class Unit Tests", () => { false ); expect(emptyConfig.system?.networkClient).toBeDefined(); - expect(emptyConfig.system?.windowHashTimeout).toBeDefined(); - expect(emptyConfig.system?.windowHashTimeout).toBe( + expect(emptyConfig.system?.popupBridgeTimeout).toBeDefined(); + expect(emptyConfig.system?.popupBridgeTimeout).toBe( DEFAULT_POPUP_TIMEOUT_MS ); - expect(emptyConfig.system?.iframeHashTimeout).toBeDefined(); - expect(emptyConfig.system?.iframeHashTimeout).toBe( + expect(emptyConfig.system?.iframeBridgeTimeout).toBeDefined(); + expect(emptyConfig.system?.iframeBridgeTimeout).toBe( DEFAULT_IFRAME_TIMEOUT_MS ); expect(emptyConfig.system?.tokenRenewalOffsetSeconds).toBe(300); @@ -87,59 +87,22 @@ describe("Configuration.ts Class Unit Tests", () => { expect(config.system?.allowPlatformBroker).toBe(true); }); - it("sets timeouts with loadFrameTimeout", () => { + it("sets bridge timeouts", () => { const config: Configuration = buildConfiguration( { auth: { clientId: TEST_CONFIG.MSAL_CLIENT_ID, }, system: { - loadFrameTimeout: 100, + iframeBridgeTimeout: 5000, + popupBridgeTimeout: 50000, }, }, true ); - expect(config.system?.iframeHashTimeout).toBe(100); - expect(config.system?.windowHashTimeout).toBe(100); - }); - - it("sets timeouts with hash timeouts", () => { - const config: Configuration = buildConfiguration( - { - auth: { - clientId: TEST_CONFIG.MSAL_CLIENT_ID, - }, - system: { - iframeHashTimeout: 5000, - windowHashTimeout: 50000, - }, - }, - true - ); - - expect(config.system?.iframeHashTimeout).toBe(5000); - expect(config.system?.windowHashTimeout).toBe(50000); - }); - - it("sets timeouts with loadFrameTimeout and hash timeouts", () => { - const config: Configuration = buildConfiguration( - { - auth: { - clientId: TEST_CONFIG.MSAL_CLIENT_ID, - }, - system: { - iframeHashTimeout: 6001, - windowHashTimeout: 6002, - loadFrameTimeout: 500, - }, - }, - true - ); - - expect(config.system?.iframeHashTimeout).toBe(6001); - expect(config.system?.windowHashTimeout).toBe(6002); - expect(config.system?.loadFrameTimeout).toBe(500); + expect(config.system?.iframeBridgeTimeout).toBe(5000); + expect(config.system?.popupBridgeTimeout).toBe(50000); }); it("Tests logger", () => { @@ -238,7 +201,7 @@ describe("Configuration.ts Class Unit Tests", () => { cacheLocation: BrowserCacheLocation.LocalStorage, }, system: { - windowHashTimeout: TEST_POPUP_TIMEOUT_MS, + popupBridgeTimeout: TEST_POPUP_TIMEOUT_MS, tokenRenewalOffsetSeconds: TEST_OFFSET, loggerOptions: { loggerCallback: testLoggerCallback, @@ -265,8 +228,10 @@ describe("Configuration.ts Class Unit Tests", () => { expect(newConfig.cache?.cacheLocation).toBe("localStorage"); // System config checks expect(newConfig.system).not.toBeNull(); - expect(newConfig.system?.windowHashTimeout).not.toBeNull(); - expect(newConfig.system?.windowHashTimeout).toBe(TEST_POPUP_TIMEOUT_MS); + expect(newConfig.system?.popupBridgeTimeout).not.toBeNull(); + expect(newConfig.system?.popupBridgeTimeout).toBe( + TEST_POPUP_TIMEOUT_MS + ); expect(newConfig.system?.tokenRenewalOffsetSeconds).not.toBeNull(); expect(newConfig.system?.tokenRenewalOffsetSeconds).toBe(TEST_OFFSET); expect(newConfig.system?.loggerOptions).not.toBeNull(); diff --git a/lib/msal-browser/test/interaction_client/PopupClient.spec.ts b/lib/msal-browser/test/interaction_client/PopupClient.spec.ts index 79ee9ced4d..b01fbb8fc3 100644 --- a/lib/msal-browser/test/interaction_client/PopupClient.spec.ts +++ b/lib/msal-browser/test/interaction_client/PopupClient.spec.ts @@ -33,10 +33,10 @@ import { ClientConfigurationErrorCodes, CommonAuthorizationCodeRequest, AuthError, - ProtocolUtils, ProtocolMode, Constants, -} from "@azure/msal-common"; + ProtocolUtils, +} from "@azure/msal-common/browser"; import { TemporaryCacheKeys, ApiId, @@ -75,10 +75,21 @@ const testPopupWondowDefaults = { left: 270.5, }; +jest.mock("@azure/msal-common/browser", () => ({ + ...jest.requireActual("@azure/msal-common/browser"), + ProtocolUtils: { + ...jest.requireActual("@azure/msal-common/browser").ProtocolUtils, + setRequestState: jest.fn(), + }, +})); + describe("PopupClient", () => { let popupClient: PopupClient; let pca: PublicClientApplication; let browserCacheManager: BrowserCacheManager; + let mockSetRequestState: jest.MockedFunction< + typeof ProtocolUtils.setRequestState + >; beforeEach(async () => { pca = new PublicClientApplication({ auth: { @@ -114,6 +125,12 @@ describe("PopupClient", () => { pca.nativeInternalStorage, TEST_CONFIG.CORRELATION_ID ); + + mockSetRequestState = + ProtocolUtils.setRequestState as jest.MockedFunction< + typeof ProtocolUtils.setRequestState + >; + mockSetRequestState.mockReturnValue(TEST_STATE_VALUES.TEST_STATE_POPUP); }); afterEach(() => { @@ -388,7 +405,7 @@ describe("PopupClient", () => { expect(requestUrl).toEqual(testNavUrl); return window; }); - jest.spyOn(PopupUtils, "monitorPopupForHash").mockResolvedValue( + jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockResolvedValue( TEST_HASHES.TEST_SUCCESS_NATIVE_ACCOUNT_ID_POPUP ); jest.spyOn( @@ -514,7 +531,7 @@ describe("PopupClient", () => { expect(requestUrl).toEqual(testNavUrl); return window; }); - jest.spyOn(PopupUtils, "monitorPopupForHash").mockResolvedValue( + jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockResolvedValue( TEST_HASHES.TEST_SUCCESS_NATIVE_ACCOUNT_ID_POPUP ); jest.spyOn( @@ -619,7 +636,7 @@ describe("PopupClient", () => { expect(requestUrl).toEqual(testNavUrl); return window; }); - jest.spyOn(PopupUtils, "monitorPopupForHash").mockResolvedValue( + jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockResolvedValue( TEST_HASHES.TEST_SUCCESS_CODE_HASH_POPUP ); jest.spyOn( @@ -641,7 +658,9 @@ describe("PopupClient", () => { }); it("throws hash_empty_error if popup returns to redirectUri without a hash", (done) => { - jest.spyOn(PopupUtils, "monitorPopupForHash").mockResolvedValue(""); + jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockResolvedValue( + "" + ); popupClient .acquireToken({ @@ -659,7 +678,7 @@ describe("PopupClient", () => { }); it("throws hash_does_not_contain_known_properties error if popup returns to redirectUri with unrecognized params in the hash", (done) => { - jest.spyOn(PopupUtils, "monitorPopupForHash").mockResolvedValue( + jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockResolvedValue( "#fakeKey=fakeValue&anotherFakeKey=anotherFakeValue" ); @@ -680,15 +699,13 @@ describe("PopupClient", () => { describe("storeInCache tests", () => { beforeEach(() => { - jest.spyOn(ProtocolUtils, "setRequestState").mockReturnValue( - TEST_STATE_VALUES.TEST_STATE_POPUP - ); jest.spyOn(PopupClient.prototype, "openPopup").mockReturnValue( window ); - jest.spyOn(PopupUtils, "monitorPopupForHash").mockResolvedValue( - TEST_HASHES.TEST_SUCCESS_CODE_HASH_POPUP - ); + jest.spyOn( + BrowserUtils, + "waitForBridgeResponse" + ).mockResolvedValue(TEST_HASHES.TEST_SUCCESS_CODE_HASH_POPUP); jest.spyOn( FetchClient.prototype, "sendPostRequestAsync" @@ -870,9 +887,6 @@ describe("PopupClient", () => { state: TEST_STATE_VALUES.USER_STATE, nonce: ID_TOKEN_CLAIMS.nonce, }; - jest.spyOn(ProtocolUtils, "setRequestState").mockReturnValue( - TEST_STATE_VALUES.TEST_STATE_POPUP - ); jest.spyOn( PopupClient.prototype, "openSizedPopup" @@ -882,7 +896,10 @@ describe("PopupClient", () => { .mockImplementation(() => { // Suppress navigation }); - jest.spyOn(PopupUtils, "monitorPopupForHash").mockResolvedValue( + jest.spyOn( + BrowserUtils, + "waitForBridgeResponse" + ).mockResolvedValue( `#ear_jwe=${validEarJWE}&state=${TEST_STATE_VALUES.TEST_STATE_POPUP}` ); @@ -1779,240 +1796,189 @@ describe("PopupClient", () => { }); }); - describe("monitorPopupForHash", () => { - it("throws if popup is closed", (done) => { - const popup: Window = { - //@ts-ignore - location: { - href: "about:blank", - hash: "", - }, - close: () => {}, - closed: false, - }; + describe("waitForBridgeResponse", () => { + it("resolves when BroadcastChannel receives hash response", async () => { + const testLibraryState = { id: "test-channel-id" }; const clientImpl = popupClient as any; - PopupUtils.monitorPopupForHash( - popup, - window, - clientImpl.config.auth.OIDCOptions.responseMode, - clientImpl.config.system.pollIntervalMilliseconds, - clientImpl.logger, - clientImpl.unloadWindow, - TEST_CONFIG.CORRELATION_ID - ).catch((error) => { - expect(error.errorCode).toEqual("user_cancelled"); - done(); - }); - - setTimeout(() => { - //@ts-ignore - popup.closed = true; - }, 50); - }); + const testState = ProtocolUtils.setRequestState( + clientImpl.browserCrypto, + "", + testLibraryState + ); - it("resolves when popup is same origin and has a hash", (done) => { - const popup: Window = { - //@ts-ignore - location: { - href: "about:blank", - hash: "", - }, - close: () => {}, - closed: false, + const request: CommonAuthorizationUrlRequest = { + scopes: ["openid"], + state: testState, + correlationId: TEST_CONFIG.CORRELATION_ID, + authority: TEST_CONFIG.validAuthority, + redirectUri: TEST_URIS.TEST_REDIR_URI, + responseMode: "fragment", + codeChallenge: "challenge", + codeChallengeMethod: "S256", + nonce: "test-nonce", }; - const clientImpl = popupClient as any; - PopupUtils.monitorPopupForHash( - popup, - window, - clientImpl.config.auth.OIDCOptions.responseMode, + + // Mock waitForBridgeResponse to simulate receiving a message + jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockResolvedValue( + "code=testCode&state=testState" + ); + + const response = await BrowserUtils.waitForBridgeResponse( clientImpl.config.system.pollIntervalMilliseconds, + 5000, clientImpl.logger, - clientImpl.unloadWindow, - TEST_CONFIG.CORRELATION_ID - ).then((hash) => { - expect(hash).toEqual("code=testCode"); - done(); - }); + clientImpl.browserCrypto, + request + ); - setTimeout(() => { - popup.location.href = "http://localhost"; - popup.location.hash = "code=testCode"; - }, 50); + expect(response).toEqual("code=testCode&state=testState"); }); - it("throws timeout if popup is same origin but no hash is present", async () => { - const popup = { - location: { - href: "http://localhost", - hash: "", - }, - close: () => {}, - }; - - pca = new PublicClientApplication({ - auth: { - clientId: TEST_CONFIG.MSAL_CLIENT_ID, - }, - system: { - windowHashTimeout: 10, - }, - }); - - await pca.initialize(); + it("resolves when BroadcastChannel receives query response", async () => { + const testLibraryState = { id: "test-channel-query-id" }; + const clientImpl = popupClient as any; + const testState = ProtocolUtils.setRequestState( + clientImpl.browserCrypto, + "", + testLibraryState + ); - //PCA implementation moved to controller - pca = (pca as any).controller; + const request: CommonAuthorizationUrlRequest = { + scopes: ["openid"], + state: testState, + correlationId: TEST_CONFIG.CORRELATION_ID, + authority: TEST_CONFIG.validAuthority, + redirectUri: TEST_URIS.TEST_REDIR_URI, + responseMode: "query", + codeChallenge: "challenge", + codeChallengeMethod: "S256", + nonce: "test-nonce", + }; - //@ts-ignore - popupClient = new PopupClient( - //@ts-ignore - pca.config, - //@ts-ignore - pca.browserStorage, - //@ts-ignore - pca.browserCrypto, - //@ts-ignore - pca.logger, - //@ts-ignore - pca.eventHandler, - //@ts-ignore - pca.navigationClient, - //@ts-ignore - pca.performanceClient, - //@ts-ignore - pca.nativeInternalStorage, - TEST_CONFIG.CORRELATION_ID + // Mock waitForBridgeResponse to simulate receiving a message + jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockResolvedValue( + "code=authCode&state=testState456" ); - const clientImpl = popupClient as any; - const result = await PopupUtils.monitorPopupForHash( - popup as Window, - window, - clientImpl.config.auth.OIDCOptions.responseMode, + + const response = await BrowserUtils.waitForBridgeResponse( clientImpl.config.system.pollIntervalMilliseconds, + 5000, clientImpl.logger, - clientImpl.unloadWindow, - TEST_CONFIG.CORRELATION_ID - ).catch((e) => { - expect(e.errorCode).toEqual( - BrowserAuthErrorCodes.monitorPopupTimeout - ); - }); + clientImpl.browserCrypto, + request + ); + + expect(response).toEqual("code=authCode&state=testState456"); }); - it("returns hash", (done) => { - const popup = { - location: { - href: "http://localhost/#/code=hello", - hash: "#code=hello", - }, - history: { - replaceState: () => { - return; - }, - }, - close: () => {}, + it("throws timeout error if BroadcastChannel receives no response", async () => { + const testLibraryState = { id: "test-channel-timeout-id" }; + const clientImpl = popupClient as any; + const testState = ProtocolUtils.setRequestState( + clientImpl.browserCrypto, + "", + testLibraryState + ); + + const request: CommonAuthorizationUrlRequest = { + scopes: ["openid"], + state: testState, + correlationId: TEST_CONFIG.CORRELATION_ID, + authority: TEST_CONFIG.validAuthority, + redirectUri: TEST_URIS.TEST_REDIR_URI, + responseMode: "fragment", + codeChallenge: "challenge", + codeChallengeMethod: "S256", + nonce: "test-nonce", }; - const clientImpl = popupClient as any; - PopupUtils.monitorPopupForHash( - popup as unknown as Window, - window, - clientImpl.config.auth.OIDCOptions.responseMode, - clientImpl.config.system.pollIntervalMilliseconds, - clientImpl.logger, - clientImpl.unloadWindow, - TEST_CONFIG.CORRELATION_ID - ).then((hash: string) => { - expect(hash).toEqual("#code=hello"); - done(); - }); - }); + // Mock waitForBridgeResponse to simulate a timeout error + jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockRejectedValue( + createBrowserAuthError( + BrowserAuthErrorCodes.redirectBridgeTimeout + ) + ); - it("returns server code response in query form when responseMode in OIDCOptions is query", async () => { - pca = new PublicClientApplication({ - auth: { - clientId: TEST_CONFIG.MSAL_CLIENT_ID, - OIDCOptions: { responseMode: "query" }, - }, - system: { - protocolMode: ProtocolMode.OIDC, - }, + await expect( + BrowserUtils.waitForBridgeResponse( + clientImpl.config.system.pollIntervalMilliseconds, + 100, + clientImpl.logger, + clientImpl.browserCrypto, + request + ) + ).rejects.toMatchObject({ + errorCode: BrowserAuthErrorCodes.redirectBridgeTimeout, }); + }); - await pca.initialize(); - - //Implementation of PCA was moved to controller. - pca = (pca as any).controller; + it("handles multiple concurrent BroadcastChannel responses correctly", async () => { + const testLibraryState1 = { id: "test-channel-concurrent-1" }; + const testLibraryState2 = { id: "test-channel-concurrent-2" }; + const clientImpl = popupClient as any; - popupClient = new PopupClient( - //@ts-ignore - pca.config, - //@ts-ignore - pca.browserStorage, - //@ts-ignore - pca.browserCrypto, - //@ts-ignore - pca.logger, - //@ts-ignore - pca.eventHandler, - //@ts-ignore - pca.navigationClient, - //@ts-ignore - pca.performanceClient, - //@ts-ignore - pca.nativeInternalStorage, - TEST_CONFIG.CORRELATION_ID + const testState1 = ProtocolUtils.setRequestState( + clientImpl.browserCrypto, + "", + testLibraryState1 + ); + const testState2 = ProtocolUtils.setRequestState( + clientImpl.browserCrypto, + "", + testLibraryState2 ); - const popup = { - location: { - href: TEST_URIS.TEST_QUERY_CODE_RESPONSE, - search: "?code=authCode", - }, - history: { - replaceState: () => { - return; - }, - }, - close: () => {}, + const request1: CommonAuthorizationUrlRequest = { + scopes: ["openid"], + state: testState1, + correlationId: TEST_CONFIG.CORRELATION_ID, + authority: TEST_CONFIG.validAuthority, + redirectUri: TEST_URIS.TEST_REDIR_URI, + responseMode: "fragment", + codeChallenge: "challenge1", + codeChallengeMethod: "S256", + nonce: "test-nonce-1", }; - const clientImpl = popupClient as any; - const result = await PopupUtils.monitorPopupForHash( - popup as unknown as Window, - window, - clientImpl.config.auth.OIDCOptions.responseMode, + const request2: CommonAuthorizationUrlRequest = { + scopes: ["profile"], + state: testState2, + correlationId: TEST_CONFIG.CORRELATION_ID, + authority: TEST_CONFIG.validAuthority, + redirectUri: TEST_URIS.TEST_REDIR_URI, + responseMode: "fragment", + codeChallenge: "challenge2", + codeChallengeMethod: "S256", + nonce: "test-nonce-2", + }; + + // Mock waitForBridgeResponse to return different responses based on the request state + jest.spyOn(BrowserUtils, "waitForBridgeResponse") + .mockResolvedValueOnce("code=code1&state=state1") + .mockResolvedValueOnce("code=code2&state=state2"); + + const promise1 = BrowserUtils.waitForBridgeResponse( clientImpl.config.system.pollIntervalMilliseconds, + 5000, clientImpl.logger, - clientImpl.unloadWindow, - TEST_CONFIG.CORRELATION_ID + clientImpl.browserCrypto, + request1 ); - expect(result).toEqual("?code=authCode"); - }); - it("closed", (done) => { - const popup = { - location: { - href: "http://localhost", - hash: "", - }, - close: () => {}, - closed: true, - }; - - const clientImpl = popupClient as any; - PopupUtils.monitorPopupForHash( - popup as unknown as Window, - window, - clientImpl.config.auth.OIDCOptions.responseMode, + const promise2 = BrowserUtils.waitForBridgeResponse( clientImpl.config.system.pollIntervalMilliseconds, + 5000, clientImpl.logger, - clientImpl.unloadWindow, - TEST_CONFIG.CORRELATION_ID - ).catch((error: AuthError) => { - expect(error.errorCode).toEqual("user_cancelled"); - done(); - }); + clientImpl.browserCrypto, + request2 + ); + + const [response1, response2] = await Promise.all([ + promise1, + promise2, + ]); + expect(response1).toEqual("code=code1&state=state1"); + expect(response2).toEqual("code=code2&state=state2"); }); }); diff --git a/lib/msal-browser/test/interaction_client/RedirectClient.spec.ts b/lib/msal-browser/test/interaction_client/RedirectClient.spec.ts index 20491e8013..f788aefcb5 100644 --- a/lib/msal-browser/test/interaction_client/RedirectClient.spec.ts +++ b/lib/msal-browser/test/interaction_client/RedirectClient.spec.ts @@ -31,7 +31,6 @@ import { CommonAuthorizationCodeRequest, CommonAuthorizationUrlRequest, AuthorizationCodeClient, - ProtocolUtils, Logger, LogLevel, NetworkResponse, @@ -48,6 +47,7 @@ import { AccountEntityUtils, Constants, } from "@azure/msal-common/browser"; +import * as ProtocolUtils from "../../../msal-common/src/utils/ProtocolUtils.js"; import * as BrowserUtils from "../../src/utils/BrowserUtils.js"; import { TemporaryCacheKeys, @@ -530,7 +530,7 @@ describe("RedirectClient", () => { const stateString = TEST_STATE_VALUES.TEST_STATE_REDIRECT; const browserCrypto = new CryptoOps(new Logger({})); const stateId = ProtocolUtils.parseRequestState( - browserCrypto, + browserCrypto.base64Decode, stateString ).libraryState.id; @@ -672,7 +672,7 @@ describe("RedirectClient", () => { const stateString = TEST_STATE_VALUES.TEST_STATE_REDIRECT; const browserCrypto = new CryptoOps(new Logger({})); const stateId = ProtocolUtils.parseRequestState( - browserCrypto, + browserCrypto.base64Decode, stateString ).libraryState.id; @@ -737,7 +737,7 @@ describe("RedirectClient", () => { const stateString = TEST_STATE_VALUES.TEST_STATE_REDIRECT; const browserCrypto = new CryptoOps(new Logger({})); const stateId = ProtocolUtils.parseRequestState( - browserCrypto, + browserCrypto.base64Decode, stateString ).libraryState.id; @@ -775,7 +775,7 @@ describe("RedirectClient", () => { const stateString = TEST_STATE_VALUES.TEST_STATE_REDIRECT; const browserCrypto = new CryptoOps(new Logger({})); const stateId = ProtocolUtils.parseRequestState( - browserCrypto, + browserCrypto.base64Decode, stateString ).libraryState.id; @@ -917,7 +917,7 @@ describe("RedirectClient", () => { const stateString = TEST_STATE_VALUES.TEST_STATE_REDIRECT; const browserCrypto = new CryptoOps(new Logger({})); const stateId = ProtocolUtils.parseRequestState( - browserCrypto, + browserCrypto.base64Decode, stateString ).libraryState.id; @@ -1074,7 +1074,7 @@ describe("RedirectClient", () => { const stateString = TEST_STATE_VALUES.TEST_STATE_REDIRECT; const browserCrypto = new CryptoOps(new Logger({})); const stateId = ProtocolUtils.parseRequestState( - browserCrypto, + browserCrypto.base64Decode, stateString ).libraryState.id; diff --git a/lib/msal-browser/test/interaction_client/SilentIframeClient.spec.ts b/lib/msal-browser/test/interaction_client/SilentIframeClient.spec.ts index 06e710a46d..0e0561275e 100644 --- a/lib/msal-browser/test/interaction_client/SilentIframeClient.spec.ts +++ b/lib/msal-browser/test/interaction_client/SilentIframeClient.spec.ts @@ -53,14 +53,25 @@ import { import { FetchClient } from "../../src/network/FetchClient.js"; import { TestTimeUtils } from "msal-test-utils"; import { AuthenticationResult } from "../../src/response/AuthenticationResult.js"; -import { SsoSilentRequest } from "../../src/index.js"; +import { BrowserUtils, SsoSilentRequest } from "../../src/index.js"; import * as StandardInteractionClientExports from "../../src/interaction_client/StandardInteractionClient.js"; +jest.mock("@azure/msal-common/browser", () => ({ + ...jest.requireActual("@azure/msal-common/browser"), + ProtocolUtils: { + ...jest.requireActual("@azure/msal-common/browser").ProtocolUtils, + setRequestState: jest.fn(), + }, +})); + describe("SilentIframeClient", () => { let silentIframeClient: SilentIframeClient; let pca: PublicClientApplication; let browserCacheManager: BrowserCacheManager; let clientProperties: SilentIframeClient; + let mockSetRequestState: jest.MockedFunction< + typeof ProtocolUtils.setRequestState + >; beforeEach(() => { pca = new PublicClientApplication({ @@ -98,6 +109,14 @@ describe("SilentIframeClient", () => { ); clientProperties = silentIframeClient as any; + + mockSetRequestState = + ProtocolUtils.setRequestState as jest.MockedFunction< + typeof ProtocolUtils.setRequestState + >; + mockSetRequestState.mockReturnValue( + TEST_STATE_VALUES.TEST_STATE_SILENT + ); }); afterEach(() => { @@ -156,7 +175,7 @@ describe("SilentIframeClient", () => { AuthorizeProtocol, "getAuthCodeRequestUrl" ).mockResolvedValue(testNavUrl); - jest.spyOn(SilentHandler, "monitorIframeForHash").mockResolvedValue( + jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockResolvedValue( TEST_HASHES.TEST_SUCCESS_CODE_HASH_SILENT ); jest.spyOn( @@ -208,9 +227,9 @@ describe("SilentIframeClient", () => { AuthorizeProtocol, "getAuthCodeRequestUrl" ).mockResolvedValue(testNavUrl); - jest.spyOn(SilentHandler, "monitorIframeForHash").mockRejectedValue( + jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockRejectedValue( createBrowserAuthError( - BrowserAuthErrorCodes.monitorWindowTimeout + BrowserAuthErrorCodes.redirectBridgeTimeout ) ); jest.spyOn(PkceGenerator, "generatePkceCodes").mockResolvedValue({ @@ -225,7 +244,7 @@ describe("SilentIframeClient", () => { .mockImplementation((e) => { expect(e).toMatchObject( createBrowserAuthError( - BrowserAuthErrorCodes.monitorWindowTimeout + BrowserAuthErrorCodes.redirectBridgeTimeout ) ); }); @@ -250,7 +269,7 @@ describe("SilentIframeClient", () => { errorCode: "Unexpected error", errorDesc: "Unexpected error", }; - jest.spyOn(SilentHandler, "monitorIframeForHash").mockRejectedValue( + jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockRejectedValue( testError ); jest.spyOn(PkceGenerator, "generatePkceCodes").mockResolvedValue({ @@ -321,7 +340,7 @@ describe("SilentIframeClient", () => { AuthorizeProtocol, "getAuthCodeRequestUrl" ).mockResolvedValue(testNavUrl); - jest.spyOn(SilentHandler, "monitorIframeForHash").mockResolvedValue( + jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockResolvedValue( TEST_HASHES.TEST_SUCCESS_CODE_HASH_SILENT ); jest.spyOn( @@ -391,7 +410,7 @@ describe("SilentIframeClient", () => { AuthorizeProtocol, "getAuthCodeRequestUrl" ).mockResolvedValue(testNavUrl); - jest.spyOn(SilentHandler, "monitorIframeForHash").mockResolvedValue( + jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockResolvedValue( TEST_HASHES.TEST_SUCCESS_CODE_HASH_SILENT ); jest.spyOn( @@ -504,7 +523,7 @@ describe("SilentIframeClient", () => { AuthorizeProtocol, "getAuthCodeRequestUrl" ).mockResolvedValue(testNavUrl); - jest.spyOn(SilentHandler, "monitorIframeForHash").mockResolvedValue( + jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockResolvedValue( TEST_HASHES.TEST_SUCCESS_NATIVE_ACCOUNT_ID_SILENT ); jest.spyOn( @@ -607,7 +626,7 @@ describe("SilentIframeClient", () => { AuthorizeProtocol, "getAuthCodeRequestUrl" ).mockResolvedValue(testNavUrl); - jest.spyOn(SilentHandler, "monitorIframeForHash").mockResolvedValue( + jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockResolvedValue( TEST_HASHES.TEST_SUCCESS_NATIVE_ACCOUNT_ID_SILENT ); jest.spyOn( @@ -639,7 +658,7 @@ describe("SilentIframeClient", () => { }); it("Throws hash empty error", (done) => { - jest.spyOn(SilentHandler, "monitorIframeForHash").mockResolvedValue( + jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockResolvedValue( "" ); silentIframeClient @@ -657,7 +676,7 @@ describe("SilentIframeClient", () => { }); it("Throws hashDoesNotContainKnownProperties error", (done) => { - jest.spyOn(SilentHandler, "monitorIframeForHash").mockResolvedValue( + jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockResolvedValue( "myCustomHash" ); silentIframeClient @@ -757,7 +776,7 @@ describe("SilentIframeClient", () => { AuthorizeProtocol, "getAuthCodeRequestUrl" ).mockResolvedValue(testNavUrl); - jest.spyOn(SilentHandler, "monitorIframeForHash").mockResolvedValue( + jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockResolvedValue( TEST_HASHES.TEST_SUCCESS_CODE_HASH_SILENT ); const sendPostRequestSpy = jest @@ -809,7 +828,7 @@ describe("SilentIframeClient", () => { AuthorizeProtocol, "getAuthCodeRequestUrl" ).mockResolvedValue(testNavUrl); - jest.spyOn(SilentHandler, "monitorIframeForHash").mockResolvedValue( + jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockResolvedValue( TEST_HASHES.TEST_SUCCESS_CODE_HASH_SILENT ); const sendPostRequestSpy = jest @@ -911,7 +930,7 @@ describe("SilentIframeClient", () => { account: testAccount, tokenType: Constants.AuthenticationScheme.BEARER, }; - jest.spyOn(SilentHandler, "monitorIframeForHash").mockResolvedValue( + jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockResolvedValue( TEST_HASHES.TEST_SUCCESS_CODE_HASH_SILENT ); const handleCodeResponseSpy = jest @@ -1013,7 +1032,7 @@ describe("SilentIframeClient", () => { AuthorizeProtocol, "getAuthCodeRequestUrl" ).mockResolvedValue(testNavUrl); - jest.spyOn(SilentHandler, "monitorIframeForHash").mockResolvedValue( + jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockResolvedValue( TEST_HASHES.TEST_SUCCESS_CODE_HASH_SILENT ); const handleCodeResponseSpy = jest @@ -1118,7 +1137,7 @@ describe("SilentIframeClient", () => { AuthorizeProtocol, "getAuthCodeRequestUrl" ).mockResolvedValue(testNavUrl); - jest.spyOn(SilentHandler, "monitorIframeForHash").mockResolvedValue( + jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockResolvedValue( TEST_HASHES.TEST_SUCCESS_CODE_HASH_SILENT ); jest.spyOn( @@ -1153,12 +1172,9 @@ describe("SilentIframeClient", () => { describe("storeInCache tests", () => { beforeEach(() => { - jest.spyOn(ProtocolUtils, "setRequestState").mockReturnValue( - TEST_STATE_VALUES.TEST_STATE_SILENT - ); jest.spyOn( - SilentHandler, - "monitorIframeForHash" + BrowserUtils, + "waitForBridgeResponse" ).mockResolvedValue(TEST_HASHES.TEST_SUCCESS_CODE_HASH_SILENT); jest.spyOn( FetchClient.prototype, @@ -1283,15 +1299,12 @@ describe("SilentIframeClient", () => { state: TEST_STATE_VALUES.USER_STATE, nonce: ID_TOKEN_CLAIMS.nonce, }; - jest.spyOn(ProtocolUtils, "setRequestState").mockReturnValue( - TEST_STATE_VALUES.TEST_STATE_SILENT - ); const earFormSpy = jest .spyOn(SilentHandler, "initiateEarRequest") .mockResolvedValue(document.createElement("iframe")); jest.spyOn( - SilentHandler, - "monitorIframeForHash" + BrowserUtils, + "waitForBridgeResponse" ).mockResolvedValue( `#ear_jwe=${validEarJWE}&state=${TEST_STATE_VALUES.TEST_STATE_SILENT}` ); diff --git a/lib/msal-browser/test/interaction_handler/SilentHandler.spec.ts b/lib/msal-browser/test/interaction_handler/SilentHandler.spec.ts index d7dfb03776..2612815b8a 100644 --- a/lib/msal-browser/test/interaction_handler/SilentHandler.spec.ts +++ b/lib/msal-browser/test/interaction_handler/SilentHandler.spec.ts @@ -8,15 +8,25 @@ import { LoggerOptions, IPerformanceClient, Constants, + ProtocolUtils, + CommonAuthorizationUrlRequest, + ICrypto, } from "@azure/msal-common"; import * as SilentHandler from "../../src/interaction_handler/SilentHandler.js"; -import { testNavUrl, RANDOM_TEST_GUID } from "../utils/StringConstants.js"; +import { + testNavUrl, + RANDOM_TEST_GUID, + TEST_CONFIG, + TEST_URIS, +} from "../utils/StringConstants.js"; import { BrowserAuthError, createBrowserAuthError, BrowserAuthErrorCodes, } from "../../src/error/BrowserAuthError.js"; import { StubPerformanceClient } from "@azure/msal-common/browser"; +import * as BrowserUtils from "../../src/utils/BrowserUtils.js"; +import { CryptoOps } from "../../src/crypto/CryptoOps.js"; const DEFAULT_IFRAME_TIMEOUT_MS = 6000; const DEFAULT_POLL_INTERVAL_MS = 30; @@ -63,120 +73,191 @@ describe("SilentHandler.ts Unit Tests", () => { }); }); - describe("monitorIframeForHash", () => { - it("times out", (done) => { - const iframe = { - contentWindow: { - // @ts-ignore - location: null, // example of scenario that would never otherwise resolve - }, + describe("waitForBridgeResponse", () => { + let browserCrypto: ICrypto; + + beforeEach(() => { + browserCrypto = new CryptoOps(browserRequestLogger as any); + }); + + it("resolves when BroadcastChannel receives hash response", async () => { + const testLibraryState = { id: "test-channel-id" }; + const testState = ProtocolUtils.setRequestState( + browserCrypto, + "", + testLibraryState + ); + + const request: CommonAuthorizationUrlRequest = { + scopes: ["openid"], + state: testState, + correlationId: TEST_CONFIG.CORRELATION_ID, + authority: TEST_CONFIG.validAuthority, + redirectUri: TEST_URIS.TEST_REDIR_URI, + responseMode: "fragment", + codeChallenge: "challenge", + codeChallengeMethod: "S256", + nonce: "test-nonce", }; - SilentHandler.monitorIframeForHash( - // @ts-ignore - iframe, - 500, + // Mock waitForBridgeResponse to simulate receiving a message + jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockResolvedValue( + "code=testCode&state=testState" + ); + + const response = await BrowserUtils.waitForBridgeResponse( DEFAULT_POLL_INTERVAL_MS, - performanceClient, + DEFAULT_IFRAME_TIMEOUT_MS, browserRequestLogger, - RANDOM_TEST_GUID, - Constants.ResponseMode.FRAGMENT - ).catch((e) => { - expect(e).toBeInstanceOf(BrowserAuthError); - expect(e).toMatchObject( - createBrowserAuthError( - BrowserAuthErrorCodes.monitorWindowTimeout - ) - ); - done(); - }); + browserCrypto, + request + ); + + expect(response).toEqual("code=testCode&state=testState"); }); - it("times out when event loop is suspended", (done) => { - jest.setTimeout(5000); + it("resolves when BroadcastChannel receives query response", async () => { + const testLibraryState = { id: "test-channel-query-id" }; + const testState = ProtocolUtils.setRequestState( + browserCrypto, + "", + testLibraryState + ); - const iframe = { - contentWindow: { - location: { - href: "about:blank", - hash: "", - }, - }, + const request: CommonAuthorizationUrlRequest = { + scopes: ["openid"], + state: testState, + correlationId: TEST_CONFIG.CORRELATION_ID, + authority: TEST_CONFIG.validAuthority, + redirectUri: TEST_URIS.TEST_REDIR_URI, + responseMode: "query", + codeChallenge: "challenge", + codeChallengeMethod: "S256", + nonce: "test-nonce", }; - SilentHandler.monitorIframeForHash( - // @ts-ignore - iframe, - 2000, + // Mock waitForBridgeResponse to simulate receiving a message + jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockResolvedValue( + "code=authCode&state=testState456" + ); + + const response = await BrowserUtils.waitForBridgeResponse( DEFAULT_POLL_INTERVAL_MS, - performanceClient, + DEFAULT_IFRAME_TIMEOUT_MS, browserRequestLogger, - RANDOM_TEST_GUID, - Constants.ResponseMode.FRAGMENT - ).catch((e) => { - expect(e).toBeInstanceOf(BrowserAuthError); - expect(e).toMatchObject( - createBrowserAuthError( - BrowserAuthErrorCodes.monitorWindowTimeout - ) - ); - done(); - }); + browserCrypto, + request + ); - setTimeout(() => { - iframe.contentWindow.location = { - href: "http://localhost/#/code=hello", - hash: "#code=hello", - }; - }, 1600); - - /** - * This code mimics the JS event loop being synchonously paused (e.g. tab suspension) midway through polling the iframe. - * If the event loop is suspended for longer than the configured timeout, - * the polling operation should throw an error for a timeout. - */ - const startPauseDelay = 200; - const pauseDuration = 3000; - setTimeout(() => { - Atomics.wait( - new Int32Array(new SharedArrayBuffer(4)), - 0, - 0, - pauseDuration - ); - }, startPauseDelay); + expect(response).toEqual("code=authCode&state=testState456"); }); - it("returns hash", (done) => { - const iframe = { - contentWindow: { - location: { - href: "about:blank", - hash: "", - }, - }, + it("throws timeout error if BroadcastChannel receives no response", async () => { + const testLibraryState = { id: "test-channel-timeout-id" }; + const testState = ProtocolUtils.setRequestState( + browserCrypto, + "", + testLibraryState + ); + + const request: CommonAuthorizationUrlRequest = { + scopes: ["openid"], + state: testState, + correlationId: TEST_CONFIG.CORRELATION_ID, + authority: TEST_CONFIG.validAuthority, + redirectUri: TEST_URIS.TEST_REDIR_URI, + responseMode: "fragment", + codeChallenge: "challenge", + codeChallengeMethod: "S256", + nonce: "test-nonce", }; - SilentHandler.monitorIframeForHash( - // @ts-ignore - iframe, - 1000, + // Mock waitForBridgeResponse to simulate a timeout error + jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockRejectedValue( + createBrowserAuthError( + BrowserAuthErrorCodes.redirectBridgeTimeout + ) + ); + + await expect( + BrowserUtils.waitForBridgeResponse( + DEFAULT_POLL_INTERVAL_MS, + 100, + browserRequestLogger, + browserCrypto, + request + ) + ).rejects.toMatchObject({ + errorCode: BrowserAuthErrorCodes.redirectBridgeTimeout, + }); + }); + + it("handles multiple concurrent BroadcastChannel responses correctly", async () => { + const testLibraryState1 = { id: "test-channel-concurrent-1" }; + const testLibraryState2 = { id: "test-channel-concurrent-2" }; + + const testState1 = ProtocolUtils.setRequestState( + browserCrypto, + "", + testLibraryState1 + ); + const testState2 = ProtocolUtils.setRequestState( + browserCrypto, + "", + testLibraryState2 + ); + + const request1: CommonAuthorizationUrlRequest = { + scopes: ["openid"], + state: testState1, + correlationId: TEST_CONFIG.CORRELATION_ID, + authority: TEST_CONFIG.validAuthority, + redirectUri: TEST_URIS.TEST_REDIR_URI, + responseMode: "fragment", + codeChallenge: "challenge1", + codeChallengeMethod: "S256", + nonce: "test-nonce-1", + }; + + const request2: CommonAuthorizationUrlRequest = { + scopes: ["profile"], + state: testState2, + correlationId: TEST_CONFIG.CORRELATION_ID, + authority: TEST_CONFIG.validAuthority, + redirectUri: TEST_URIS.TEST_REDIR_URI, + responseMode: "fragment", + codeChallenge: "challenge2", + codeChallengeMethod: "S256", + nonce: "test-nonce-2", + }; + + // Mock waitForBridgeResponse to return different responses based on the request state + jest.spyOn(BrowserUtils, "waitForBridgeResponse") + .mockResolvedValueOnce("code=code1&state=state1") + .mockResolvedValueOnce("code=code2&state=state2"); + + const promise1 = BrowserUtils.waitForBridgeResponse( DEFAULT_POLL_INTERVAL_MS, - performanceClient, + DEFAULT_IFRAME_TIMEOUT_MS, browserRequestLogger, - RANDOM_TEST_GUID, - Constants.ResponseMode.FRAGMENT - ).then((hash: string) => { - expect(hash).toEqual("#code=hello"); - done(); - }); + browserCrypto, + request1 + ); + + const promise2 = BrowserUtils.waitForBridgeResponse( + DEFAULT_POLL_INTERVAL_MS, + DEFAULT_IFRAME_TIMEOUT_MS, + browserRequestLogger, + browserCrypto, + request2 + ); - setTimeout(() => { - iframe.contentWindow.location = { - href: "http://localhost/#code=hello", - hash: "#code=hello", - }; - }, 500); + const [response1, response2] = await Promise.all([ + promise1, + promise2, + ]); + expect(response1).toEqual("code=code1&state=state1"); + expect(response2).toEqual("code=code2&state=state2"); }); }); }); diff --git a/lib/msal-browser/test/utils/BrowserUtils.spec.ts b/lib/msal-browser/test/utils/BrowserUtils.spec.ts index 3381ad7ea5..0676407210 100644 --- a/lib/msal-browser/test/utils/BrowserUtils.spec.ts +++ b/lib/msal-browser/test/utils/BrowserUtils.spec.ts @@ -63,31 +63,6 @@ describe("BrowserUtils.ts Function Unit Tests", () => { expect(BrowserUtils.isInIframe()).toBe(true); }); - it("isInPopup() returns false if window is undefined", () => { - // @ts-ignore - jest.spyOn(global, "window", "get").mockReturnValue(undefined); - expect(BrowserUtils.isInPopup()).toBe(false); - }); - - it("isInPopup() returns false if window opener is not the same as the current window but window name does not starts with 'msal.'", () => { - window.opener = { ...window }; - window.name = "non-msal-popup"; - expect(BrowserUtils.isInPopup()).toBe(false); - }); - - it("isInPopup() returns false if window opener is the same as the current window", () => { - window.opener = window; - window.name = "msal."; - expect(BrowserUtils.isInPopup()).toBe(false); - }); - - it("isInPopup() returns true if window opener is not the same as the current window and the window name starts with 'msal.'", () => { - expect(BrowserUtils.isInPopup()).toBe(false); - window.opener = { ...window }; - window.name = "msal.popupwindow"; - expect(BrowserUtils.isInPopup()).toBe(true); - }); - it("getCurrentUri() returns current location uri of browser", () => { expect(BrowserUtils.getCurrentUri()).toBe(TEST_URIS.TEST_REDIR_URI); }); diff --git a/lib/msal-common/apiReview/msal-common.api.md b/lib/msal-common/apiReview/msal-common.api.md index f8277db35a..ab3ce62fa6 100644 --- a/lib/msal-common/apiReview/msal-common.api.md +++ b/lib/msal-common/apiReview/msal-common.api.md @@ -883,7 +883,6 @@ const authTimeNotFound = "auth_time_not_found"; declare namespace AuthToken { export { extractTokenClaims, - isKmsi, getJWSPayload, checkMaxAge } @@ -1136,13 +1135,13 @@ export abstract class CacheManager implements ICacheManager { generateAuthorityMetadataCacheKey(authority: string): string; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen abstract generateCredentialKey(credential: CredentialEntity): string; - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' getAccessToken(account: AccountInfo, request: BaseAuthRequest, tokenKeys?: TokenKeys, targetRealm?: string): AccessTokenEntity | null; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen @@ -1174,14 +1173,14 @@ export abstract class CacheManager implements ICacheManager { getBaseAccountInfo(accountFilter: AccountFilter, correlationId: string): AccountInfo | null; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen getIdToken(account: AccountInfo, correlationId: string, tokenKeys?: TokenKeys, targetRealm?: string): IdTokenEntity | null; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen abstract getIdTokenCredential(idTokenKey: string, correlationId: string): IdTokenEntity | null; @@ -1190,13 +1189,13 @@ export abstract class CacheManager implements ICacheManager { abstract getKeys(): string[]; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' getRefreshToken(account: AccountInfo, familyRT: boolean, correlationId: string, tokenKeys?: TokenKeys): RefreshTokenEntity | null; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen @@ -1238,18 +1237,18 @@ export abstract class CacheManager implements ICacheManager { // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen removeRefreshToken(key: string, correlationId: string): void; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' - saveCacheRecord(cacheRecord: CacheRecord, correlationId: string, kmsi: boolean, storeInCache?: StoreInCache): Promise; + // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + saveCacheRecord(cacheRecord: CacheRecord, correlationId: string, storeInCache?: StoreInCache): Promise; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - abstract setAccessTokenCredential(accessToken: AccessTokenEntity, correlationId: string, kmsi: boolean): Promise; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + abstract setAccessTokenCredential(accessToken: AccessTokenEntity, correlationId: string): Promise; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - abstract setAccount(account: AccountEntity, correlationId: string, kmsi: boolean): Promise; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + abstract setAccount(account: AccountEntity, correlationId: string): Promise; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen abstract setAppMetadata(appMetadata: AppMetadataEntity, correlationId: string): void; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen @@ -1258,10 +1257,10 @@ export abstract class CacheManager implements ICacheManager { abstract setAuthorityMetadata(key: string, value: AuthorityMetadataEntity, correlationId: string): void; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - abstract setIdTokenCredential(idToken: IdTokenEntity, correlationId: string, kmsi: boolean): Promise; + abstract setIdTokenCredential(idToken: IdTokenEntity, correlationId: string): Promise; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - abstract setRefreshTokenCredential(refreshToken: RefreshTokenEntity, correlationId: string, kmsi: boolean): Promise; + abstract setRefreshTokenCredential(refreshToken: RefreshTokenEntity, correlationId: string): Promise; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen @@ -2220,6 +2219,13 @@ function generateAuthorityMetadataExpiresAt(): number; // @public function generateHomeAccountId(serverClientInfo: string, authType: AuthorityType, logger: Logger, cryptoObj: ICrypto, correlationId: string, idTokenClaims?: TokenClaims): string; +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "generateLibraryState" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +function generateLibraryState(cryptoObj: ICrypto, meta?: Record): string; + // Warning: (ae-incompatible-release-tags) The symbol "getAccountInfo" is marked as @public, but its signature references "AccountEntity" which is marked as @internal // Warning: (ae-incompatible-release-tags) The symbol "getAccountInfo" is marked as @public, but its signature references "AccountEntity" which is marked as @internal // Warning: (ae-missing-release-tag) "getAccountInfo" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -2799,12 +2805,6 @@ export interface ISerializableTokenCache { // @public function isIdTokenEntity(entity: object): entity is IdTokenEntity; -// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// Warning: (ae-missing-release-tag) "isKmsi" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public -function isKmsi(idTokenClaims: TokenClaims): boolean; - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (ae-missing-release-tag) "isRefreshTokenEntity" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -3222,6 +3222,13 @@ const OPENID_SCOPE = "openid"; // @public (undocumented) const openIdConfigError = "openid_config_error"; +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "parseRequestState" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +function parseRequestState(base64Decode: (input: string) => string, state: string): RequestStateObject; + // Warning: (ae-missing-release-tag) "PasswordGrantConstants" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // Warning: (ae-missing-release-tag) "PasswordGrantConstants" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -3581,19 +3588,12 @@ export const ProtocolMode: { // @public (undocumented) export type ProtocolMode = (typeof ProtocolMode)[keyof typeof ProtocolMode]; -// Warning: (ae-missing-release-tag) "ProtocolUtils" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public -export class ProtocolUtils { - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - static generateLibraryState(cryptoObj: ICrypto, meta?: Record): string; - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - static parseRequestState(cryptoObj: ICrypto, state: string): RequestStateObject; - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - static setRequestState(cryptoObj: ICrypto, userState?: string, meta?: Record): string; +declare namespace ProtocolUtils { + export { + setRequestState, + generateLibraryState, + parseRequestState + } } // Warning: (ae-missing-release-tag) "REDIRECT_URI" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -3829,10 +3829,10 @@ const RESPONSE_TYPE = "response_type"; // @internal export class ResponseHandler { constructor(clientId: string, cacheStorage: CacheManager, cryptoObj: ICrypto, logger: Logger, performanceClient: IPerformanceClient, serializableCache: ISerializableTokenCache | null, persistencePlugin: ICachePlugin | null); - // Warning: (tsdoc-undefined-tag) The TSDoc tag "@AuthenticationResult" is not defined in this configuration + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-undefined-tag) The TSDoc tag "@CacheRecord" is not defined in this configuration // Warning: (tsdoc-undefined-tag) The TSDoc tag "@IdToken" is not defined in this configuration - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-undefined-tag) The TSDoc tag "@AuthenticationResult" is not defined in this configuration // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen @@ -4064,6 +4064,14 @@ export type ServerTelemetryRequest = { // @public (undocumented) const SESSION_STATE = "session_state"; +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "setRequestState" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +function setRequestState(cryptoObj: ICrypto, userState?: string, meta?: Record): string; + // Warning: (ae-missing-release-tag) "SetUserData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -4353,7 +4361,6 @@ type TokenClaims = { upn?: string; preferred_username?: string; login_hint?: string; - signin_state?: Array; emails?: string[]; name?: string; nonce?: string; @@ -4616,42 +4623,42 @@ const X_MS_LIB_CAPABILITY_VALUE: string; // src/authority/Authority.ts:802:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/authority/Authority.ts:1000:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/authority/AuthorityOptions.ts:25:5 - (ae-forgotten-export) The symbol "CloudInstanceDiscoveryResponse" needs to be exported by the entry point index.d.ts -// src/cache/CacheManager.ts:340:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:341:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:613:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1577:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1578:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1592:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1593:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1613:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:333:5 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:337:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:605:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1568:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1569:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1583:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1584:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1604:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1605:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/cache/CacheManager.ts:1614:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1623:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1624:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1640:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1641:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1655:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1656:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1694:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1695:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1709:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1710:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1721:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1722:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1733:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1734:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1745:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1746:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1763:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1764:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1788:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1789:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1808:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1809:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1828:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1829:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1615:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1631:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1632:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1646:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1647:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1685:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1686:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1700:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1701:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1712:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1713:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1724:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1725:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1736:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1737:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1754:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1755:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1779:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1780:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1799:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1800:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1817:5 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1819:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1831:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1832:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/cache/CacheManager.ts:1840:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1841:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1849:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/cache/entities/AccountEntity.ts:49:5 - (ae-forgotten-export) The symbol "DataBoundary" needs to be exported by the entry point index.d.ts // src/cache/utils/CacheTypes.ts:93:53 - (tsdoc-escape-greater-than) The ">" character should be escaped using a backslash to avoid confusion with an HTML tag // src/cache/utils/CacheTypes.ts:93:43 - (tsdoc-malformed-html-name) Invalid HTML element: An HTML name must be an ASCII letter followed by zero or more letters, digits, or hyphens @@ -4669,9 +4676,9 @@ const X_MS_LIB_CAPABILITY_VALUE: string; // src/index.ts:8:12 - (tsdoc-characters-after-block-tag) The token "@azure" looks like a TSDoc tag but contains an invalid character "/"; if it is not a tag, use a backslash to escape the "@" // src/index.ts:8:4 - (tsdoc-undefined-tag) The TSDoc tag "@module" is not defined in this configuration // src/request/AuthenticationHeaderParser.ts:74:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/response/ResponseHandler.ts:345:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/response/ResponseHandler.ts:346:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/response/ResponseHandler.ts:347:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/response/ResponseHandler.ts:339:5 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/response/ResponseHandler.ts:339:5 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/response/ResponseHandler.ts:341:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/telemetry/performance/PerformanceClient.ts:714:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/telemetry/performance/PerformanceClient.ts:714:15 - (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' // src/telemetry/performance/PerformanceClient.ts:726:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen diff --git a/lib/msal-common/test/utils/ProtocolUtils.spec.ts b/lib/msal-common/test/utils/ProtocolUtils.spec.ts index c2cb1a3fc6..d88282f12d 100644 --- a/lib/msal-common/test/utils/ProtocolUtils.spec.ts +++ b/lib/msal-common/test/utils/ProtocolUtils.spec.ts @@ -1,4 +1,4 @@ -import { ProtocolUtils } from "../../src/utils/ProtocolUtils.js"; +import * as ProtocolUtils from "../../src/utils/ProtocolUtils.js"; import { RANDOM_TEST_GUID } from "../test_kit/StringConstants.js"; import { ICrypto } from "../../src/crypto/ICrypto.js"; import { RESOURCE_DELIM } from "../../src/utils/Constants.js"; @@ -45,7 +45,7 @@ describe("ProtocolUtils.ts Class Unit Tests", () => { it("parseRequestState() throws error if given state is null or empty", () => { expect(() => - ProtocolUtils.parseRequestState(cryptoInterface, "") + ProtocolUtils.parseRequestState(cryptoInterface.base64Decode, "") ).toThrow(ClientAuthErrorCodes.invalidState); expect(() => @@ -56,7 +56,7 @@ describe("ProtocolUtils.ts Class Unit Tests", () => { it("parseRequestState() returns empty userRequestState if no resource delimiter found in state string", () => { const requestState = ProtocolUtils.parseRequestState( - cryptoInterface, + cryptoInterface.base64Decode, cryptoInterface.base64Encode(decodedLibState) ); expect(requestState.userRequestState).toHaveLength(0); @@ -64,7 +64,7 @@ describe("ProtocolUtils.ts Class Unit Tests", () => { it("parseRequestState() correctly splits the state by the resource delimiter", () => { const requestState = ProtocolUtils.parseRequestState( - cryptoInterface, + cryptoInterface.base64Decode, testState ); expect(requestState.userRequestState).toBe(userState); @@ -72,7 +72,7 @@ describe("ProtocolUtils.ts Class Unit Tests", () => { it("parseRequestState returns user state without decoding", () => { const requestState = ProtocolUtils.parseRequestState( - cryptoInterface, + cryptoInterface.base64Decode, `${encodedLibState}${RESOURCE_DELIM}${"test%25u00f1"}` ); expect(requestState.userRequestState).toBe(`${"test%25u00f1"}`); From afae280fadc936aab2ed17b2172e4f0e88c03b74 Mon Sep 17 00:00:00 2001 From: Konstantin Shabelko Date: Thu, 6 Nov 2025 17:10:05 -0500 Subject: [PATCH 10/45] Fix post-merge errors. --- .../apiReview/msal-browser.api.md | 32 ++--- .../src/interaction_client/PopupClient.ts | 16 +-- .../interaction_client/SilentIframeClient.ts | 23 ++-- .../test/app/PublicClientApplication.spec.ts | 30 +++-- .../interaction_client/PopupClient.spec.ts | 3 +- .../interaction_client/RedirectClient.spec.ts | 27 +++-- lib/msal-common/apiReview/msal-common.api.md | 114 ++++++++++-------- 7 files changed, 139 insertions(+), 106 deletions(-) diff --git a/lib/msal-browser/apiReview/msal-browser.api.md b/lib/msal-browser/apiReview/msal-browser.api.md index c3420d8149..1e97ffd444 100644 --- a/lib/msal-browser/apiReview/msal-browser.api.md +++ b/lib/msal-browser/apiReview/msal-browser.api.md @@ -23,6 +23,7 @@ import { CommonEndSessionRequest } from '@azure/msal-common/browser'; import { CommonSilentFlowRequest } from '@azure/msal-common/browser'; import { Constants } from '@azure/msal-common/browser'; import { ExternalTokenResponse } from '@azure/msal-common/browser'; +import { ICrypto } from '@azure/msal-common/browser'; import { IdTokenClaims } from '@azure/msal-common/browser'; import { ILoggerCallback } from '@azure/msal-common/browser'; import { INetworkModule } from '@azure/msal-common/browser'; @@ -228,8 +229,7 @@ declare namespace BrowserAuthErrorCodes { popupWindowError, emptyWindowError, userCancelled, - monitorPopupTimeout, - monitorWindowTimeout, + redirectBridgeTimeout, redirectInIframe, blockIframeReload, blockNestedPopups, @@ -384,9 +384,8 @@ export type BrowserSystemOptions = SystemOptions & { loggerOptions?: LoggerOptions; networkClient?: INetworkModule; navigationClient?: INavigationClient; - windowHashTimeout?: number; - iframeHashTimeout?: number; - loadFrameTimeout?: number; + popupBridgeTimeout?: number; + iframeBridgeTimeout?: number; redirectNavigationTimeout?: number; navigatePopups?: boolean; allowRedirectInIframe?: boolean; @@ -410,6 +409,7 @@ declare namespace BrowserUtils { replaceHash, isInIframe, isInPopup, + waitForBridgeResponse, getCurrentUri, getHomepage, blockReloadInHiddenIframes, @@ -996,16 +996,6 @@ export class MemoryStorage implements IWindowStorage { setUserData(key: string, value: T): Promise; } -// Warning: (ae-missing-release-tag) "monitorPopupTimeout" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -const monitorPopupTimeout = "monitor_popup_timeout"; - -// Warning: (ae-missing-release-tag) "monitorWindowTimeout" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -const monitorWindowTimeout = "monitor_window_timeout"; - // Warning: (ae-missing-release-tag) "nativeConnectionNotEstablished" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1247,6 +1237,11 @@ export class PublicClientApplication implements IPublicClientApplication { ssoSilent(request: SsoSilentRequest): Promise; } +// Warning: (ae-missing-release-tag) "redirectBridgeTimeout" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +const redirectBridgeTimeout = "redirect_bridge_timeout"; + // Warning: (ae-missing-release-tag) "redirectInIframe" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1440,6 +1435,11 @@ const userCancelled = "user_cancelled"; // @public (undocumented) export const version = "5.0.0-alpha.0"; +// Warning: (ae-missing-release-tag) "waitForBridgeResponse" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +function waitForBridgeResponse(pollIntervalMilliseconds: number, timeoutMs: number, logger: Logger, browserCrypto: ICrypto, request: CommonAuthorizationUrlRequest | CommonEndSessionRequest): Promise; + // Warning: (ae-missing-release-tag) "WrapperSKU" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // Warning: (ae-missing-release-tag) "WrapperSKU" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -1457,7 +1457,7 @@ export type WrapperSKU = (typeof WrapperSKU)[keyof typeof WrapperSKU]; // src/cache/LocalStorage.ts:366:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/cache/LocalStorage.ts:429:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/cache/LocalStorage.ts:460:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/config/Configuration.ts:211:5 - (ae-forgotten-export) The symbol "InternalAuthOptions" needs to be exported by the entry point index.d.ts +// src/config/Configuration.ts:207:5 - (ae-forgotten-export) The symbol "InternalAuthOptions" needs to be exported by the entry point index.d.ts // src/event/EventHandler.ts:114:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/event/EventHandler.ts:141:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/index.ts:8:12 - (tsdoc-characters-after-block-tag) The token "@azure" looks like a TSDoc tag but contains an invalid character "/"; if it is not a tag, use a backslash to escape the "@" diff --git a/lib/msal-browser/src/interaction_client/PopupClient.ts b/lib/msal-browser/src/interaction_client/PopupClient.ts index fd88106c01..7ac8036dcb 100644 --- a/lib/msal-browser/src/interaction_client/PopupClient.ts +++ b/lib/msal-browser/src/interaction_client/PopupClient.ts @@ -368,14 +368,14 @@ export class PopupClient extends StandardInteractionClient { null ); - // Monitor the window for the hash. Return the string value and close the popup when the hash is received. Default timeout is 60 seconds. - const responseString = await BrowserUtils.waitForBridgeResponse( - this.config.system.pollIntervalMilliseconds, - this.config.system.popupBridgeTimeout, - this.logger, - this.browserCrypto, - request - ); + // Wait for the redirect bridge response + const responseString = await BrowserUtils.waitForBridgeResponse( + this.config.system.pollIntervalMilliseconds, + this.config.system.popupBridgeTimeout, + this.logger, + this.browserCrypto, + request + ); const serverParams = invoke( ResponseHandler.deserializeResponse, diff --git a/lib/msal-browser/src/interaction_client/SilentIframeClient.ts b/lib/msal-browser/src/interaction_client/SilentIframeClient.ts index 91c88f7a22..28934180da 100644 --- a/lib/msal-browser/src/interaction_client/SilentIframeClient.ts +++ b/lib/msal-browser/src/interaction_client/SilentIframeClient.ts @@ -362,10 +362,8 @@ export class SilentIframeClient extends StandardInteractionClient { codeChallenge: pkceCodes.challenge, }; - let msalFrame: HTMLIFrameElement; - if (request.httpMethod === Constants.HttpMethod.POST) { - msalFrame = await invokeAsync( + await invokeAsync( initiateCodeFlowWithPost, BrowserPerformanceEvents.SilentHandlerInitiateAuthRequest, this.logger, @@ -394,17 +392,18 @@ export class SilentIframeClient extends StandardInteractionClient { this.performanceClient ); - // Get the frame handle for the silent request - await invokeAsync( - initiateCodeRequest, - BrowserPerformanceEvents.SilentHandlerInitiateAuthRequest, - this.logger, - this.performanceClient, - correlationId - )(navigateUrl, this.performanceClient, this.logger, correlationId); + // Get the frame handle for the silent request + await invokeAsync( + initiateCodeRequest, + BrowserPerformanceEvents.SilentHandlerInitiateAuthRequest, + this.logger, + this.performanceClient, + correlationId + )(navigateUrl, this.performanceClient, this.logger, correlationId); + } const responseType = this.config.auth.OIDCOptions.responseMode; - // Monitor the window for the hash. Return the string value and close the popup when the hash is received. Default timeout is 60 seconds. + // Wait for response from the redirect bridge. const responseString = await invokeAsync( BrowserUtils.waitForBridgeResponse, BrowserPerformanceEvents.SilentHandlerMonitorIframeForHash, diff --git a/lib/msal-browser/test/app/PublicClientApplication.spec.ts b/lib/msal-browser/test/app/PublicClientApplication.spec.ts index 49d90b501d..484230c3ea 100644 --- a/lib/msal-browser/test/app/PublicClientApplication.spec.ts +++ b/lib/msal-browser/test/app/PublicClientApplication.spec.ts @@ -1884,7 +1884,6 @@ describe("PublicClientApplication.ts Class Unit Tests", () => { }); it("throws error if called in a popup", (done) => { - const originalWindowUrl = window.URL; Object.defineProperty(window, "location", { configurable: true, enumerable: true, @@ -1910,9 +1909,17 @@ describe("PublicClientApplication.ts Class Unit Tests", () => { }) .finally(() => { Object.defineProperty(window, "location", { - configurable: true, - enumerable: true, - value: { ...originalWindowUrl, href: "localhost" }, + value: { + hash: "", + origin: "https://localhost:8081", + pathname: "/", + search: "", + href: "https://localhost:8081/index.html", + protocol: "http:", + hostname: "localhost", + port: "8081", + }, + writable: true, }); }); }); @@ -2991,7 +2998,6 @@ describe("PublicClientApplication.ts Class Unit Tests", () => { }); it("throws error if called in a popup", (done) => { - const originalWindowUrl = window.URL; Object.defineProperty(window, "location", { configurable: true, enumerable: true, @@ -3016,9 +3022,17 @@ describe("PublicClientApplication.ts Class Unit Tests", () => { }) .finally(() => { Object.defineProperty(window, "location", { - configurable: true, - enumerable: true, - value: { ...originalWindowUrl, href: "localhost" }, + value: { + hash: "", + origin: "https://localhost:8081", + pathname: "/", + search: "", + href: "https://localhost:8081/index.html", + protocol: "http:", + hostname: "localhost", + port: "8081", + }, + writable: true, }); }); }); diff --git a/lib/msal-browser/test/interaction_client/PopupClient.spec.ts b/lib/msal-browser/test/interaction_client/PopupClient.spec.ts index 88fce232f3..ffea14c1cb 100644 --- a/lib/msal-browser/test/interaction_client/PopupClient.spec.ts +++ b/lib/msal-browser/test/interaction_client/PopupClient.spec.ts @@ -67,7 +67,6 @@ import { TestTimeUtils } from "msal-test-utils"; import { PopupRequest } from "../../src/request/PopupRequest.js"; import { version } from "../../src/packageMetadata.js"; import * as CacheKeys from "../../src/cache/CacheKeys.js"; -import * as Authorize from "../../src/protocol/Authorize.js"; const testPopupWondowDefaults = { height: BrowserConstants.POPUP_HEIGHT, @@ -741,7 +740,7 @@ describe("PopupClient", () => { account: testAccount, tokenType: Constants.AuthenticationScheme.BEARER, }; - jest.spyOn(PopupUtils, "monitorPopupForHash").mockResolvedValue( + jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockResolvedValue( TEST_HASHES.TEST_SUCCESS_CODE_HASH_POPUP ); jest.spyOn( diff --git a/lib/msal-browser/test/interaction_client/RedirectClient.spec.ts b/lib/msal-browser/test/interaction_client/RedirectClient.spec.ts index 6d4a4fc3e6..b434a8827d 100644 --- a/lib/msal-browser/test/interaction_client/RedirectClient.spec.ts +++ b/lib/msal-browser/test/interaction_client/RedirectClient.spec.ts @@ -47,8 +47,8 @@ import { ProtocolMode, AccountEntityUtils, Constants, + ProtocolUtils, } from "@azure/msal-common/browser"; -import * as ProtocolUtils from "../../../msal-common/src/utils/ProtocolUtils.js"; import * as BrowserUtils from "../../src/utils/BrowserUtils.js"; import { TemporaryCacheKeys, @@ -88,6 +88,14 @@ import { BrowserPerformanceClient } from "../../src/telemetry/BrowserPerformance import { version } from "../../src/packageMetadata.js"; import * as CacheKeys from "../../src/cache/CacheKeys.js"; +jest.mock("@azure/msal-common/browser", () => ({ + ...jest.requireActual("@azure/msal-common/browser"), + ProtocolUtils: { + ...jest.requireActual("@azure/msal-common/browser").ProtocolUtils, + setRequestState: jest.fn(), + }, +})); + const cacheConfig = { cacheLocation: BrowserCacheLocation.SessionStorage, cacheRetentionDays: 5, @@ -123,6 +131,9 @@ describe("RedirectClient", () => { let browserStorage: BrowserCacheManager; let pca: PublicClientApplication; let rootMeasurement: InProgressPerformanceEvent; + let mockSetRequestState: jest.MockedFunction< + typeof ProtocolUtils.setRequestState + >; beforeEach(async () => { pca = new PublicClientApplication({ @@ -180,6 +191,14 @@ describe("RedirectClient", () => { rootMeasurement = new BrowserPerformanceClient( pca.getConfiguration() ).startMeasurement("test-measurement", "test-correlation-id"); + + mockSetRequestState = + ProtocolUtils.setRequestState as jest.MockedFunction< + typeof ProtocolUtils.setRequestState + >; + mockSetRequestState.mockReturnValue( + TEST_STATE_VALUES.TEST_STATE_REDIRECT + ); }); afterEach(() => { @@ -2158,9 +2177,6 @@ describe("RedirectClient", () => { describe("storeInCache tests", () => { beforeEach(() => { - jest.spyOn(ProtocolUtils, "setRequestState").mockReturnValue( - TEST_STATE_VALUES.TEST_STATE_REDIRECT - ); jest.spyOn( FetchClient.prototype, "sendPostRequestAsync" @@ -3236,9 +3252,6 @@ describe("RedirectClient", () => { state: TEST_STATE_VALUES.USER_STATE, nonce: ID_TOKEN_CLAIMS.nonce, }; - jest.spyOn(ProtocolUtils, "setRequestState").mockReturnValue( - TEST_STATE_VALUES.TEST_STATE_REDIRECT - ); jest.spyOn(HTMLFormElement.prototype, "submit").mockImplementation( () => { // Supress navigation diff --git a/lib/msal-common/apiReview/msal-common.api.md b/lib/msal-common/apiReview/msal-common.api.md index 8f63e37802..48173eca86 100644 --- a/lib/msal-common/apiReview/msal-common.api.md +++ b/lib/msal-common/apiReview/msal-common.api.md @@ -874,6 +874,7 @@ const authTimeNotFound = "auth_time_not_found"; declare namespace AuthToken { export { extractTokenClaims, + isKmsi, getJWSPayload, checkMaxAge } @@ -1127,12 +1128,12 @@ export abstract class CacheManager implements ICacheManager { generateAuthorityMetadataCacheKey(authority: string): string; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen abstract generateCredentialKey(credential: CredentialEntity): string; - // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' - // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' getAccessToken(account: AccountInfo, request: BaseAuthRequest, tokenKeys?: TokenKeys, targetRealm?: string): AccessTokenEntity | null; @@ -1165,14 +1166,14 @@ export abstract class CacheManager implements ICacheManager { getBaseAccountInfo(accountFilter: AccountFilter, correlationId: string): AccountInfo | null; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' - // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' - // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' - // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' getIdToken(account: AccountInfo, correlationId: string, tokenKeys?: TokenKeys, targetRealm?: string): IdTokenEntity | null; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen abstract getIdTokenCredential(idTokenKey: string, correlationId: string): IdTokenEntity | null; @@ -1181,12 +1182,12 @@ export abstract class CacheManager implements ICacheManager { abstract getKeys(): string[]; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' - // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' - // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' getRefreshToken(account: AccountInfo, familyRT: boolean, correlationId: string, tokenKeys?: TokenKeys): RefreshTokenEntity | null; @@ -1229,18 +1230,18 @@ export abstract class CacheManager implements ICacheManager { // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen removeRefreshToken(key: string, correlationId: string): void; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - saveCacheRecord(cacheRecord: CacheRecord, correlationId: string, storeInCache?: StoreInCache): Promise; + // Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' + saveCacheRecord(cacheRecord: CacheRecord, correlationId: string, kmsi: boolean, storeInCache?: StoreInCache): Promise; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - abstract setAccessTokenCredential(accessToken: AccessTokenEntity, correlationId: string): Promise; + abstract setAccessTokenCredential(accessToken: AccessTokenEntity, correlationId: string, kmsi: boolean): Promise; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - abstract setAccount(account: AccountEntity, correlationId: string): Promise; + abstract setAccount(account: AccountEntity, correlationId: string, kmsi: boolean): Promise; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen abstract setAppMetadata(appMetadata: AppMetadataEntity, correlationId: string): void; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen @@ -1249,10 +1250,10 @@ export abstract class CacheManager implements ICacheManager { abstract setAuthorityMetadata(key: string, value: AuthorityMetadataEntity, correlationId: string): void; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - abstract setIdTokenCredential(idToken: IdTokenEntity, correlationId: string): Promise; + abstract setIdTokenCredential(idToken: IdTokenEntity, correlationId: string, kmsi: boolean): Promise; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen - abstract setRefreshTokenCredential(refreshToken: RefreshTokenEntity, correlationId: string): Promise; + abstract setRefreshTokenCredential(refreshToken: RefreshTokenEntity, correlationId: string, kmsi: boolean): Promise; // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen @@ -2815,6 +2816,12 @@ export interface ISerializableTokenCache { // @public function isIdTokenEntity(entity: object): entity is IdTokenEntity; +// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// Warning: (ae-missing-release-tag) "isKmsi" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +function isKmsi(idTokenClaims: TokenClaims): boolean; + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (ae-missing-release-tag) "isRefreshTokenEntity" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -3839,10 +3846,10 @@ const RESPONSE_TYPE = "response_type"; // @internal export class ResponseHandler { constructor(clientId: string, cacheStorage: CacheManager, cryptoObj: ICrypto, logger: Logger, performanceClient: IPerformanceClient, serializableCache: ISerializableTokenCache | null, persistencePlugin: ICachePlugin | null); - // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (tsdoc-undefined-tag) The TSDoc tag "@AuthenticationResult" is not defined in this configuration // Warning: (tsdoc-undefined-tag) The TSDoc tag "@CacheRecord" is not defined in this configuration // Warning: (tsdoc-undefined-tag) The TSDoc tag "@IdToken" is not defined in this configuration - // Warning: (tsdoc-undefined-tag) The TSDoc tag "@AuthenticationResult" is not defined in this configuration + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen @@ -4371,6 +4378,7 @@ type TokenClaims = { upn?: string; preferred_username?: string; login_hint?: string; + signin_state?: Array; emails?: string[]; name?: string; nonce?: string; @@ -4633,42 +4641,42 @@ const X_MS_LIB_CAPABILITY_VALUE: string; // src/authority/Authority.ts:802:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/authority/Authority.ts:1000:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/authority/AuthorityOptions.ts:25:5 - (ae-forgotten-export) The symbol "CloudInstanceDiscoveryResponse" needs to be exported by the entry point index.d.ts -// src/cache/CacheManager.ts:333:5 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:337:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:605:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1568:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1569:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1583:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1584:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1604:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1605:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:340:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:341:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:613:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1577:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1578:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1592:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1593:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1613:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/cache/CacheManager.ts:1614:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1615:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1631:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1632:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1646:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1647:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1685:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1686:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1700:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1701:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1712:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1713:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1724:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1725:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1736:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1737:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1754:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1755:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1779:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1780:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1799:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1800:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1817:5 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1819:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1831:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/cache/CacheManager.ts:1832:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1623:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1624:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1640:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1641:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1655:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1656:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1694:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1695:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1709:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1710:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1721:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1722:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1733:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1734:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1745:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1746:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1763:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1764:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1788:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1789:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1808:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1809:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1828:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1829:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/cache/CacheManager.ts:1840:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1841:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/cache/CacheManager.ts:1849:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/cache/entities/AccountEntity.ts:49:5 - (ae-forgotten-export) The symbol "DataBoundary" needs to be exported by the entry point index.d.ts // src/cache/utils/CacheTypes.ts:93:53 - (tsdoc-escape-greater-than) The ">" character should be escaped using a backslash to avoid confusion with an HTML tag // src/cache/utils/CacheTypes.ts:93:43 - (tsdoc-malformed-html-name) Invalid HTML element: An HTML name must be an ASCII letter followed by zero or more letters, digits, or hyphens @@ -4686,9 +4694,9 @@ const X_MS_LIB_CAPABILITY_VALUE: string; // src/index.ts:8:12 - (tsdoc-characters-after-block-tag) The token "@azure" looks like a TSDoc tag but contains an invalid character "/"; if it is not a tag, use a backslash to escape the "@" // src/index.ts:8:4 - (tsdoc-undefined-tag) The TSDoc tag "@module" is not defined in this configuration // src/request/AuthenticationHeaderParser.ts:74:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/response/ResponseHandler.ts:339:5 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/response/ResponseHandler.ts:339:5 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/response/ResponseHandler.ts:341:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/response/ResponseHandler.ts:346:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/response/ResponseHandler.ts:347:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/response/ResponseHandler.ts:348:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/telemetry/performance/PerformanceClient.ts:714:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/telemetry/performance/PerformanceClient.ts:714:15 - (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}' // src/telemetry/performance/PerformanceClient.ts:726:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen From e7e5c5469c4cc1496173ed0c3a22dd3e9f9b6577 Mon Sep 17 00:00:00 2001 From: Konstantin Shabelko Date: Thu, 6 Nov 2025 17:35:23 -0500 Subject: [PATCH 11/45] Change files --- ...-msal-browser-3b6b0844-0608-4d31-bba7-7c7177ad52f1.json | 7 +++++++ ...e-msal-common-cde5a6b6-5532-4445-945e-4ca192b96714.json | 7 +++++++ 2 files changed, 14 insertions(+) create mode 100644 change/@azure-msal-browser-3b6b0844-0608-4d31-bba7-7c7177ad52f1.json create mode 100644 change/@azure-msal-common-cde5a6b6-5532-4445-945e-4ca192b96714.json diff --git a/change/@azure-msal-browser-3b6b0844-0608-4d31-bba7-7c7177ad52f1.json b/change/@azure-msal-browser-3b6b0844-0608-4d31-bba7-7c7177ad52f1.json new file mode 100644 index 0000000000..ef8a5f4986 --- /dev/null +++ b/change/@azure-msal-browser-3b6b0844-0608-4d31-bba7-7c7177ad52f1.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Implement redirect bridge to support COOP #8118", + "packageName": "@azure/msal-browser", + "email": "kshabelko@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@azure-msal-common-cde5a6b6-5532-4445-945e-4ca192b96714.json b/change/@azure-msal-common-cde5a6b6-5532-4445-945e-4ca192b96714.json new file mode 100644 index 0000000000..e05d50ad97 --- /dev/null +++ b/change/@azure-msal-common-cde5a6b6-5532-4445-945e-4ca192b96714.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Implement redirect bridge to support COOP #8118", + "packageName": "@azure/msal-common", + "email": "kshabelko@microsoft.com", + "dependentChangeType": "patch" +} From 40b8e3e5c953a618df1f3b86d27515146c95cde4 Mon Sep 17 00:00:00 2001 From: Konstantin Shabelko Date: Fri, 7 Nov 2025 14:30:36 -0500 Subject: [PATCH 12/45] Add redirectBridgeEmptyResponse error code and update waitForBridgeResponse handling --- .../apiReview/msal-browser.api.md | 11 ++++++-- lib/msal-browser/src/config/Configuration.ts | 10 +------ .../src/error/BrowserAuthErrorCodes.ts | 1 + .../src/interaction_client/PopupClient.ts | 4 --- .../interaction_client/SilentIframeClient.ts | 2 -- .../src/utils/BrowserConstants.ts | 4 --- lib/msal-browser/src/utils/BrowserUtils.ts | 28 ++++++++----------- .../interaction_client/PopupClient.spec.ts | 5 ---- .../interaction_handler/SilentHandler.spec.ts | 5 ---- 9 files changed, 22 insertions(+), 48 deletions(-) diff --git a/lib/msal-browser/apiReview/msal-browser.api.md b/lib/msal-browser/apiReview/msal-browser.api.md index 1e97ffd444..721664a2de 100644 --- a/lib/msal-browser/apiReview/msal-browser.api.md +++ b/lib/msal-browser/apiReview/msal-browser.api.md @@ -230,6 +230,7 @@ declare namespace BrowserAuthErrorCodes { emptyWindowError, userCancelled, redirectBridgeTimeout, + redirectBridgeEmptyResponse, redirectInIframe, blockIframeReload, blockNestedPopups, @@ -391,7 +392,6 @@ export type BrowserSystemOptions = SystemOptions & { allowRedirectInIframe?: boolean; allowPlatformBroker?: boolean; nativeBrokerHandshakeTimeout?: number; - pollIntervalMilliseconds?: number; protocolMode?: ProtocolMode; }; @@ -1237,6 +1237,11 @@ export class PublicClientApplication implements IPublicClientApplication { ssoSilent(request: SsoSilentRequest): Promise; } +// Warning: (ae-missing-release-tag) "redirectBridgeEmptyResponse" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +const redirectBridgeEmptyResponse = "redirect_bridge_empty_response"; + // Warning: (ae-missing-release-tag) "redirectBridgeTimeout" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1438,7 +1443,7 @@ export const version = "5.0.0-alpha.0"; // Warning: (ae-missing-release-tag) "waitForBridgeResponse" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public -function waitForBridgeResponse(pollIntervalMilliseconds: number, timeoutMs: number, logger: Logger, browserCrypto: ICrypto, request: CommonAuthorizationUrlRequest | CommonEndSessionRequest): Promise; +function waitForBridgeResponse(timeoutMs: number, logger: Logger, browserCrypto: ICrypto, request: CommonAuthorizationUrlRequest | CommonEndSessionRequest): Promise; // Warning: (ae-missing-release-tag) "WrapperSKU" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // Warning: (ae-missing-release-tag) "WrapperSKU" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -1457,7 +1462,7 @@ export type WrapperSKU = (typeof WrapperSKU)[keyof typeof WrapperSKU]; // src/cache/LocalStorage.ts:366:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/cache/LocalStorage.ts:429:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/cache/LocalStorage.ts:460:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/config/Configuration.ts:207:5 - (ae-forgotten-export) The symbol "InternalAuthOptions" needs to be exported by the entry point index.d.ts +// src/config/Configuration.ts:202:5 - (ae-forgotten-export) The symbol "InternalAuthOptions" needs to be exported by the entry point index.d.ts // src/event/EventHandler.ts:114:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/event/EventHandler.ts:141:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/index.ts:8:12 - (tsdoc-characters-after-block-tag) The token "@azure" looks like a TSDoc tag but contains an invalid character "/"; if it is not a tag, use a backslash to escape the "@" diff --git a/lib/msal-browser/src/config/Configuration.ts b/lib/msal-browser/src/config/Configuration.ts index 6494e06292..c6b8e62e09 100644 --- a/lib/msal-browser/src/config/Configuration.ts +++ b/lib/msal-browser/src/config/Configuration.ts @@ -22,10 +22,7 @@ import { Logger, Constants, } from "@azure/msal-common/browser"; -import { - BrowserCacheLocation, - BrowserConstants, -} from "../utils/BrowserConstants.js"; +import { BrowserCacheLocation } from "../utils/BrowserConstants.js"; import { INavigationClient } from "../navigation/INavigationClient.js"; import { NavigationClient } from "../navigation/NavigationClient.js"; import { FetchClient } from "../network/FetchClient.js"; @@ -156,10 +153,6 @@ export type BrowserSystemOptions = SystemOptions & { * Sets the timeout for waiting for the native broker handshake to resolve */ nativeBrokerHandshakeTimeout?: number; - /** - * Sets the interval length in milliseconds for polling the location attribute in popup windows (default is 30ms) - */ - pollIntervalMilliseconds?: number; /** * Enum that represents the protocol that msal follows. Used for configuring proper endpoints. */ @@ -289,7 +282,6 @@ export function buildConfiguration( nativeBrokerHandshakeTimeout: userInputSystem?.nativeBrokerHandshakeTimeout || DEFAULT_NATIVE_BROKER_HANDSHAKE_TIMEOUT_MS, - pollIntervalMilliseconds: BrowserConstants.DEFAULT_POLL_INTERVAL_MS, protocolMode: ProtocolMode.AAD, }; diff --git a/lib/msal-browser/src/error/BrowserAuthErrorCodes.ts b/lib/msal-browser/src/error/BrowserAuthErrorCodes.ts index 34a20fe6c1..63de62eaf7 100644 --- a/lib/msal-browser/src/error/BrowserAuthErrorCodes.ts +++ b/lib/msal-browser/src/error/BrowserAuthErrorCodes.ts @@ -19,6 +19,7 @@ export const popupWindowError = "popup_window_error"; export const emptyWindowError = "empty_window_error"; export const userCancelled = "user_cancelled"; export const redirectBridgeTimeout = "redirect_bridge_timeout"; +export const redirectBridgeEmptyResponse = "redirect_bridge_empty_response"; export const redirectInIframe = "redirect_in_iframe"; export const blockIframeReload = "block_iframe_reload"; export const blockNestedPopups = "block_nested_popups"; diff --git a/lib/msal-browser/src/interaction_client/PopupClient.ts b/lib/msal-browser/src/interaction_client/PopupClient.ts index 7ac8036dcb..fd216fa2b6 100644 --- a/lib/msal-browser/src/interaction_client/PopupClient.ts +++ b/lib/msal-browser/src/interaction_client/PopupClient.ts @@ -370,7 +370,6 @@ export class PopupClient extends StandardInteractionClient { // Wait for the redirect bridge response const responseString = await BrowserUtils.waitForBridgeResponse( - this.config.system.pollIntervalMilliseconds, this.config.system.popupBridgeTimeout, this.logger, this.browserCrypto, @@ -489,7 +488,6 @@ export class PopupClient extends StandardInteractionClient { this.performanceClient, correlationId )( - this.config.system.pollIntervalMilliseconds, this.config.system.popupBridgeTimeout, this.logger, this.browserCrypto, @@ -574,7 +572,6 @@ export class PopupClient extends StandardInteractionClient { this.performanceClient, correlationId )( - this.config.system.pollIntervalMilliseconds, this.config.system.popupBridgeTimeout, this.logger, this.browserCrypto, @@ -725,7 +722,6 @@ export class PopupClient extends StandardInteractionClient { ); await BrowserUtils.waitForBridgeResponse( - this.config.system.pollIntervalMilliseconds, this.config.system.popupBridgeTimeout, this.logger, this.browserCrypto, diff --git a/lib/msal-browser/src/interaction_client/SilentIframeClient.ts b/lib/msal-browser/src/interaction_client/SilentIframeClient.ts index 28934180da..7bba01660b 100644 --- a/lib/msal-browser/src/interaction_client/SilentIframeClient.ts +++ b/lib/msal-browser/src/interaction_client/SilentIframeClient.ts @@ -290,7 +290,6 @@ export class SilentIframeClient extends StandardInteractionClient { this.performanceClient, correlationId )( - this.config.system.pollIntervalMilliseconds, this.config.system.iframeBridgeTimeout, this.logger, this.browserCrypto, @@ -411,7 +410,6 @@ export class SilentIframeClient extends StandardInteractionClient { this.performanceClient, correlationId )( - this.config.system.pollIntervalMilliseconds, this.config.system.iframeBridgeTimeout, this.logger, this.browserCrypto, diff --git a/lib/msal-browser/src/utils/BrowserConstants.ts b/lib/msal-browser/src/utils/BrowserConstants.ts index 4ceb5ea4b4..db89a035b7 100644 --- a/lib/msal-browser/src/utils/BrowserConstants.ts +++ b/lib/msal-browser/src/utils/BrowserConstants.ts @@ -31,10 +31,6 @@ export const BrowserConstants = { * Name of the popup window starts with */ POPUP_NAME_PREFIX: "msal", - /** - * Default popup monitor poll interval in milliseconds - */ - DEFAULT_POLL_INTERVAL_MS: 30, /** * Msal-browser SKU */ diff --git a/lib/msal-browser/src/utils/BrowserUtils.ts b/lib/msal-browser/src/utils/BrowserUtils.ts index 3e8fd37795..45fabbf3db 100644 --- a/lib/msal-browser/src/utils/BrowserUtils.ts +++ b/lib/msal-browser/src/utils/BrowserUtils.ts @@ -26,7 +26,10 @@ import { createBrowserConfigurationAuthError, } from "../error/BrowserConfigurationAuthError.js"; import type { BrowserConfiguration } from "../config/Configuration.js"; -import { redirectBridgeTimeout } from "../error/BrowserAuthErrorCodes.js"; +import { + redirectBridgeEmptyResponse, + redirectBridgeTimeout, +} from "../error/BrowserAuthErrorCodes.js"; /** * Clears hash from window url. @@ -76,7 +79,6 @@ export function isInPopup(): boolean { * This unified function works for both popup and iframe scenarios by listening on a * BroadcastChannel for the server payload. * - * @param pollIntervalMilliseconds - The interval, in milliseconds, at which to poll for responses. * @param timeoutMs - Timeout in milliseconds. * @param logger - Logger instance for logging monitoring events. * @param browserCrypto - Browser crypto instance for decoding state. @@ -84,7 +86,6 @@ export function isInPopup(): boolean { * @returns Promise - Resolves with the response string (query or hash) from the window. */ export async function waitForBridgeResponse( - pollIntervalMilliseconds: number, timeoutMs: number, logger: Logger, browserCrypto: ICrypto, @@ -102,27 +103,22 @@ export async function waitForBridgeResponse( ); const channel = new BroadcastChannel(libraryState.id); let responseString: string | undefined = undefined; - channel.onmessage = (event) => { - responseString = event.data.payload; - }; const timeoutId = window.setTimeout(() => { - window.clearInterval(intervalId); channel.close(); reject(createBrowserAuthError(redirectBridgeTimeout)); }, timeoutMs); - const intervalId = setInterval(() => { - // Response not yet received - if (!responseString) { - return; - } - - clearInterval(intervalId); + channel.onmessage = (event) => { + responseString = event.data.payload; clearTimeout(timeoutId); channel.close(); - resolve(responseString); - }, pollIntervalMilliseconds); + if (responseString) { + resolve(responseString); + } else { + reject(createBrowserAuthError(redirectBridgeEmptyResponse)); + } + }; }); } diff --git a/lib/msal-browser/test/interaction_client/PopupClient.spec.ts b/lib/msal-browser/test/interaction_client/PopupClient.spec.ts index ffea14c1cb..e40595f3c8 100644 --- a/lib/msal-browser/test/interaction_client/PopupClient.spec.ts +++ b/lib/msal-browser/test/interaction_client/PopupClient.spec.ts @@ -1913,7 +1913,6 @@ describe("PopupClient", () => { ); const response = await BrowserUtils.waitForBridgeResponse( - clientImpl.config.system.pollIntervalMilliseconds, 5000, clientImpl.logger, clientImpl.browserCrypto, @@ -1950,7 +1949,6 @@ describe("PopupClient", () => { ); const response = await BrowserUtils.waitForBridgeResponse( - clientImpl.config.system.pollIntervalMilliseconds, 5000, clientImpl.logger, clientImpl.browserCrypto, @@ -1990,7 +1988,6 @@ describe("PopupClient", () => { await expect( BrowserUtils.waitForBridgeResponse( - clientImpl.config.system.pollIntervalMilliseconds, 100, clientImpl.logger, clientImpl.browserCrypto, @@ -2047,7 +2044,6 @@ describe("PopupClient", () => { .mockResolvedValueOnce("code=code2&state=state2"); const promise1 = BrowserUtils.waitForBridgeResponse( - clientImpl.config.system.pollIntervalMilliseconds, 5000, clientImpl.logger, clientImpl.browserCrypto, @@ -2055,7 +2051,6 @@ describe("PopupClient", () => { ); const promise2 = BrowserUtils.waitForBridgeResponse( - clientImpl.config.system.pollIntervalMilliseconds, 5000, clientImpl.logger, clientImpl.browserCrypto, diff --git a/lib/msal-browser/test/interaction_handler/SilentHandler.spec.ts b/lib/msal-browser/test/interaction_handler/SilentHandler.spec.ts index 2612815b8a..1a2f0a4117 100644 --- a/lib/msal-browser/test/interaction_handler/SilentHandler.spec.ts +++ b/lib/msal-browser/test/interaction_handler/SilentHandler.spec.ts @@ -106,7 +106,6 @@ describe("SilentHandler.ts Unit Tests", () => { ); const response = await BrowserUtils.waitForBridgeResponse( - DEFAULT_POLL_INTERVAL_MS, DEFAULT_IFRAME_TIMEOUT_MS, browserRequestLogger, browserCrypto, @@ -142,7 +141,6 @@ describe("SilentHandler.ts Unit Tests", () => { ); const response = await BrowserUtils.waitForBridgeResponse( - DEFAULT_POLL_INTERVAL_MS, DEFAULT_IFRAME_TIMEOUT_MS, browserRequestLogger, browserCrypto, @@ -181,7 +179,6 @@ describe("SilentHandler.ts Unit Tests", () => { await expect( BrowserUtils.waitForBridgeResponse( - DEFAULT_POLL_INTERVAL_MS, 100, browserRequestLogger, browserCrypto, @@ -237,7 +234,6 @@ describe("SilentHandler.ts Unit Tests", () => { .mockResolvedValueOnce("code=code2&state=state2"); const promise1 = BrowserUtils.waitForBridgeResponse( - DEFAULT_POLL_INTERVAL_MS, DEFAULT_IFRAME_TIMEOUT_MS, browserRequestLogger, browserCrypto, @@ -245,7 +241,6 @@ describe("SilentHandler.ts Unit Tests", () => { ); const promise2 = BrowserUtils.waitForBridgeResponse( - DEFAULT_POLL_INTERVAL_MS, DEFAULT_IFRAME_TIMEOUT_MS, browserRequestLogger, browserCrypto, From 2a28584f792f68f30b4f43f50b37335aa2e0ee1c Mon Sep 17 00:00:00 2001 From: Konstantin Shabelko Date: Tue, 11 Nov 2025 11:27:34 -0500 Subject: [PATCH 13/45] - Update redirect page for samples - Add redirect-bridge config to package.json --- lib/msal-browser/package.json | 10 ++++++++ lib/msal-browser/rollup.config.js | 4 ++-- ...on => tsconfig.redirect-bridge.build.json} | 0 .../COOP/app/redirect.html | 2 +- .../app/default/redirect.html | 24 ++++++++++++++----- .../app/facebook-sample/redirect.html | 24 ++++++++++++++----- .../react-router-sample/public/redirect.html | 24 ++++++++++++++----- 7 files changed, 67 insertions(+), 21 deletions(-) rename lib/msal-browser/{tsconfig.rollup-bridge.build.json => tsconfig.redirect-bridge.build.json} (100%) diff --git a/lib/msal-browser/package.json b/lib/msal-browser/package.json index d688c1c2b4..6b50c487a6 100644 --- a/lib/msal-browser/package.json +++ b/lib/msal-browser/package.json @@ -37,6 +37,16 @@ "default": "./lib/custom-auth-path/msal-custom-auth.cjs" } }, + "./redirect-bridge": { + "import": { + "types": "./dist/redirect-bridge/redirect_bridge/index.d.ts", + "default": "./dist/redirect-bridge/redirect_bridge/index.mjs" + }, + "require": { + "types": "./lib/redirect-bridge/types/redirect_bridge/index.d.ts", + "default": "./lib/redirect-bridge/msal-redirect-bridge.js" + } + }, ".": { "import": { "types": "./dist/index.d.ts", diff --git a/lib/msal-browser/rollup.config.js b/lib/msal-browser/rollup.config.js index ff7b30e6cc..5314f92ec9 100644 --- a/lib/msal-browser/rollup.config.js +++ b/lib/msal-browser/rollup.config.js @@ -231,7 +231,7 @@ export default [ plugins: [ typescript({ typescript: require("typescript"), - tsconfig: "tsconfig.rollup-bridge.build.json", + tsconfig: "tsconfig.redirect-bridge.build.json", }), ], }, @@ -287,7 +287,7 @@ export default [ }), typescript({ typescript: require("typescript"), - tsconfig: "tsconfig.rollup-bridge.build.json", + tsconfig: "tsconfig.redirect-bridge.build.json", sourceMap: false, compilerOptions: { outDir: "lib/redirect-bridge/types", diff --git a/lib/msal-browser/tsconfig.rollup-bridge.build.json b/lib/msal-browser/tsconfig.redirect-bridge.build.json similarity index 100% rename from lib/msal-browser/tsconfig.rollup-bridge.build.json rename to lib/msal-browser/tsconfig.redirect-bridge.build.json diff --git a/samples/msal-browser-samples/COOP/app/redirect.html b/samples/msal-browser-samples/COOP/app/redirect.html index 54dacdda48..de0f168930 100644 --- a/samples/msal-browser-samples/COOP/app/redirect.html +++ b/samples/msal-browser-samples/COOP/app/redirect.html @@ -11,7 +11,7 @@ diff --git a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/default/redirect.html b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/default/redirect.html index dcc8b0ed7d..de0f168930 100644 --- a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/default/redirect.html +++ b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/default/redirect.html @@ -1,6 +1,18 @@ - \ No newline at end of file + + + + + + Redirect Page + + +

Processing authentication...

+ + + + + + diff --git a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/facebook-sample/redirect.html b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/facebook-sample/redirect.html index dcc8b0ed7d..de0f168930 100644 --- a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/facebook-sample/redirect.html +++ b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/facebook-sample/redirect.html @@ -1,6 +1,18 @@ - \ No newline at end of file + + + + + + Redirect Page + + +

Processing authentication...

+ + + + + + diff --git a/samples/msal-react-samples/react-router-sample/public/redirect.html b/samples/msal-react-samples/react-router-sample/public/redirect.html index dcc8b0ed7d..de0f168930 100644 --- a/samples/msal-react-samples/react-router-sample/public/redirect.html +++ b/samples/msal-react-samples/react-router-sample/public/redirect.html @@ -1,6 +1,18 @@ - \ No newline at end of file + + + + + + Redirect Page + + +

Processing authentication...

+ + + + + + From afe6890f520d78c62f13cfada61d3e2831d6938e Mon Sep 17 00:00:00 2001 From: Konstantin Shabelko Date: Tue, 11 Nov 2025 19:26:17 -0500 Subject: [PATCH 14/45] - Remove redundant "unloadWindow" listener - Remove redundant "cleanPopup" function --- .../apiReview/msal-browser.api.md | 2 +- .../src/interaction_client/PopupClient.ts | 17 ------- lib/msal-browser/src/utils/PopupUtils.ts | 24 --------- .../test/app/PublicClientApplication.spec.ts | 2 - .../interaction_client/PopupClient.spec.ts | 49 ------------------- 5 files changed, 1 insertion(+), 93 deletions(-) delete mode 100644 lib/msal-browser/src/utils/PopupUtils.ts diff --git a/lib/msal-browser/apiReview/msal-browser.api.md b/lib/msal-browser/apiReview/msal-browser.api.md index 721664a2de..7caa13cb91 100644 --- a/lib/msal-browser/apiReview/msal-browser.api.md +++ b/lib/msal-browser/apiReview/msal-browser.api.md @@ -1462,7 +1462,7 @@ export type WrapperSKU = (typeof WrapperSKU)[keyof typeof WrapperSKU]; // src/cache/LocalStorage.ts:366:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/cache/LocalStorage.ts:429:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/cache/LocalStorage.ts:460:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/config/Configuration.ts:202:5 - (ae-forgotten-export) The symbol "InternalAuthOptions" needs to be exported by the entry point index.d.ts +// src/config/Configuration.ts:200:5 - (ae-forgotten-export) The symbol "InternalAuthOptions" needs to be exported by the entry point index.d.ts // src/event/EventHandler.ts:114:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/event/EventHandler.ts:141:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/index.ts:8:12 - (tsdoc-characters-after-block-tag) The token "@azure" looks like a TSDoc tag but contains an invalid character "/"; if it is not a tag, use a backslash to escape the "@" diff --git a/lib/msal-browser/src/interaction_client/PopupClient.ts b/lib/msal-browser/src/interaction_client/PopupClient.ts index fd216fa2b6..235488d8ad 100644 --- a/lib/msal-browser/src/interaction_client/PopupClient.ts +++ b/lib/msal-browser/src/interaction_client/PopupClient.ts @@ -92,8 +92,6 @@ export class PopupClient extends StandardInteractionClient { correlationId, platformAuthHandler ); - // Properly sets this reference for the unload event. - this.unloadWindow = this.unloadWindow.bind(this); this.nativeStorage = nativeStorageImpl; this.eventHandler = eventHandler; } @@ -851,10 +849,6 @@ export class PopupClient extends StandardInteractionClient { popupWindow.focus(); } this.currentWindow = popupWindow; - popupParams.popupWindowParent.addEventListener( - "beforeunload", - this.unloadWindow - ); return popupWindow; } catch (e) { @@ -952,17 +946,6 @@ export class PopupClient extends StandardInteractionClient { ); } - /** - * Event callback to unload main window. - */ - unloadWindow(e: Event): void { - if (this.currentWindow) { - this.currentWindow.close(); - } - // Guarantees browser unload will happen, so no other errors will be thrown. - e.preventDefault(); - } - /** * Generates the name for the popup based on the client id and request * @param clientId diff --git a/lib/msal-browser/src/utils/PopupUtils.ts b/lib/msal-browser/src/utils/PopupUtils.ts deleted file mode 100644 index 60b0f62f12..0000000000 --- a/lib/msal-browser/src/utils/PopupUtils.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. - */ - -/** - * Performs cleanup operations after popup authentication. - * Closes the popup window and removes the 'beforeunload' event listener from the parent window. - * - * @param popupWindow - The popup window to be closed. - * @param popupWindowParent - The parent window from which the event listener will be removed. - * @param unloadWindow - The event handler function to remove from the parent window's 'beforeunload' event. - */ -export function cleanPopup( - popupWindow: Window, - popupWindowParent: Window, - unloadWindow: (e: Event) => void -): void { - // Close window. - popupWindow.close(); - - // Remove window unload function - popupWindowParent.removeEventListener("beforeunload", unloadWindow); -} diff --git a/lib/msal-browser/test/app/PublicClientApplication.spec.ts b/lib/msal-browser/test/app/PublicClientApplication.spec.ts index 484230c3ea..84c557e317 100644 --- a/lib/msal-browser/test/app/PublicClientApplication.spec.ts +++ b/lib/msal-browser/test/app/PublicClientApplication.spec.ts @@ -83,7 +83,6 @@ import { import * as BrowserUtils from "../../src/utils/BrowserUtils.js"; import { RedirectClient } from "../../src/interaction_client/RedirectClient.js"; import { PopupClient } from "../../src/interaction_client/PopupClient.js"; -import * as PopupUtils from "../../src/utils/PopupUtils.js"; import { SilentCacheClient } from "../../src/interaction_client/SilentCacheClient.js"; import { SilentRefreshClient } from "../../src/interaction_client/SilentRefreshClient.js"; import { SilentAuthCodeClient } from "../../src/interaction_client/SilentAuthCodeClient.js"; @@ -6376,7 +6375,6 @@ describe("PublicClientApplication.ts Class Unit Tests", () => { PopupClient.prototype, "openSizedPopup" ).mockReturnValue(popupWindow); - jest.spyOn(PopupUtils, "cleanPopup").mockImplementation(); }); it("Clears active account on logoutRedirect with no account", async () => { diff --git a/lib/msal-browser/test/interaction_client/PopupClient.spec.ts b/lib/msal-browser/test/interaction_client/PopupClient.spec.ts index e40595f3c8..fb0e7c1010 100644 --- a/lib/msal-browser/test/interaction_client/PopupClient.spec.ts +++ b/lib/msal-browser/test/interaction_client/PopupClient.spec.ts @@ -47,7 +47,6 @@ import * as PkceGenerator from "../../src/crypto/PkceGenerator.js"; import * as AuthorizeProtocol from "../../src/protocol/Authorize.js"; import { NavigationClient } from "../../src/navigation/NavigationClient.js"; import { EndSessionPopupRequest } from "../../src/request/EndSessionPopupRequest.js"; -import * as PopupUtils from "../../src/utils/PopupUtils.js"; import { PopupClient } from "../../src/interaction_client/PopupClient.js"; import { PlatformAuthInteractionClient } from "../../src/interaction_client/PlatformAuthInteractionClient.js"; import { PlatformAuthExtensionHandler } from "../../src/broker/nativeBroker/PlatformAuthExtensionHandler.js"; @@ -1516,7 +1515,6 @@ describe("PopupClient", () => { jest.spyOn(PopupClient.prototype, "openPopup").mockReturnValue( popupWindow ); - jest.spyOn(PopupUtils, "cleanPopup").mockImplementation(); jest.spyOn( NavigationClient.prototype, "navigateInternal" @@ -1543,7 +1541,6 @@ describe("PopupClient", () => { jest.spyOn(PopupClient.prototype, "openPopup").mockReturnValue( popupWindow ); - jest.spyOn(PopupUtils, "cleanPopup").mockImplementation(); popupClient.logout().then(() => { done(); @@ -1596,11 +1593,6 @@ describe("PopupClient", () => { jest.spyOn(PopupClient.prototype, "openPopup").mockReturnValue( popupWindow ); - jest.spyOn(PopupUtils, "cleanPopup").mockImplementation((popup) => { - window.sessionStorage.removeItem( - `${CacheKeys.PREFIX}.${TemporaryCacheKeys.INTERACTION_STATUS_KEY}` - ); - }); jest.spyOn( NavigationClient.prototype, "navigateInternal" @@ -1848,43 +1840,6 @@ describe("PopupClient", () => { }); }); - describe("unloadWindow", () => { - it("closes window and removes temporary cache", (done) => { - // @ts-ignore - pca.browserStorage.setTemporaryCache( - TemporaryCacheKeys.INTERACTION_STATUS_KEY, - BrowserConstants.INTERACTION_IN_PROGRESS_VALUE, - true - ); - const popupWindow: Window = { - ...window, - //@ts-ignore - location: { - assign: () => {}, - }, - focus: () => {}, - close: () => { - // @ts-ignore - expect( - //@ts-ignore - pca.browserStorage.getTemporaryCache( - TemporaryCacheKeys.INTERACTION_STATUS_KEY - ) - ).toBe(null); - done(); - }, - }; - const popupParams = { - popupName: "name", - popupWindowAttributes: {}, - popup: popupWindow, - popupWindowParent: window, - }; - popupClient.openPopup("http://localhost", popupParams); - popupClient.unloadWindow(new Event("test")); - }); - }); - describe("waitForBridgeResponse", () => { it("resolves when BroadcastChannel receives hash response", async () => { const testLibraryState = { id: "test-channel-id" }; @@ -2271,10 +2226,6 @@ describe("PopupClient", () => { "name", expect.anything() ); - expect(popupWindowParent.addEventListener).toHaveBeenCalledWith( - "beforeunload", - expect.anything() - ); }); it("throws error if no popup passed in but window.open returns null", () => { From 49b3506082c94bb05134c7e711514501a871271b Mon Sep 17 00:00:00 2001 From: Konstantin Shabelko Date: Wed, 19 Nov 2025 10:26:52 -0500 Subject: [PATCH 15/45] - Add navigation to broadcastResponseToMainFrame - Update isInPopup function to check for API type in state --- lib/msal-browser/src/redirect_bridge/index.ts | 112 +++++++++++------- lib/msal-browser/src/utils/BrowserUtils.ts | 34 +++++- .../test/app/PublicClientApplication.spec.ts | 4 +- 3 files changed, 101 insertions(+), 49 deletions(-) diff --git a/lib/msal-browser/src/redirect_bridge/index.ts b/lib/msal-browser/src/redirect_bridge/index.ts index f544afb148..ffe31710f5 100644 --- a/lib/msal-browser/src/redirect_bridge/index.ts +++ b/lib/msal-browser/src/redirect_bridge/index.ts @@ -6,49 +6,79 @@ import { ProtocolUtils } from "@azure/msal-common/browser"; import { clearHash } from "../utils/BrowserUtils.js"; import { base64Decode } from "../encode/Base64Decode.js"; +import * as BrowserUtils from "../utils/BrowserUtils.js"; +import { ApiId, InteractionType } from "../utils/BrowserConstants.js"; +import { NavigationOptions } from "../navigation/NavigationOptions.js"; +import { DEFAULT_REDIRECT_TIMEOUT_MS } from "../config/Configuration.js"; +import { NavigationClient } from "../navigation/NavigationClient.js"; -export async function broadcastResponseToMainFrame(): Promise { - try { - // 1) Determine which URL container carries the payload - const hasHash = - !!window.location.hash && window.location.hash.length > 1; - const raw = hasHash ? window.location.hash : window.location.search; - if (!raw) { - throw new Error("No auth payload found on URL (hash or query)"); - } - - // Strip leading ? / # - const payload = raw.substring(1); - const params = new URLSearchParams(payload); - - const state = params.get("state"); - if (!state) { - throw new Error("Missing state on redirect URL"); - } - - // 2) Remove the response from URL for security +/** + * 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 + * + * @param {string} navigateToUrl - Optional URL to navigate to for redirect scenario. + * If not provided, defaults to the application's homepage. + * + * @returns {Promise} A promise that resolves when the response has been broadcast and cleanup is complete. + * + * @throws {Error} If no authentication payload is found in the URL (hash or query string). + * @throws {Error} If the state parameter is missing from the redirect URL. + * @throws {Error} If the state is missing required 'id' or 'meta' attributes. + */ +export async function broadcastResponseToMainFrame( + navigateToUrl?: string +): Promise { + // 1) Determine which URL container carries the payload + const hasHash = !!window.location.hash && window.location.hash.length > 1; + const hash = hasHash ? window.location.hash : window.location.search; + if (!hash) { + throw new Error("No auth payload found on URL (hash or query)"); + } + + // Strip leading ? / # + const payload = hash.substring(1); + const params = new URLSearchParams(payload); + + const state = params.get("state"); + if (!state) { + clearHash(window); + throw new Error("Missing state on redirect URL"); + } + + const { libraryState } = ProtocolUtils.parseRequestState( + base64Decode, + state + ); + + const { id, meta } = libraryState; + if (!id || !meta) { clearHash(window); + throw new Error("Missing state 'id' and/or 'meta' attributes"); + } - const { libraryState } = ProtocolUtils.parseRequestState( - base64Decode, - state - ); - - const { id } = libraryState; - if (!id) { - throw new Error("State is missing id attribute"); - } - - // 4) Send the raw URL payload to the main frame - const channel = new BroadcastChannel(id); - channel.postMessage({ - v: 1, - payload, - }); - channel.close(); - } finally { - try { - window.close(); - } catch {} + if (meta && meta["interactionType"] === InteractionType.Redirect) { + const navigationClient = new NavigationClient(); + const navigationOptions: NavigationOptions = { + apiId: ApiId.handleRedirectPromise, + noHistory: true, + timeout: DEFAULT_REDIRECT_TIMEOUT_MS, + }; + const homepage = `${ + navigateToUrl || BrowserUtils.getHomepage() + }${hash}`; + await navigationClient.navigateInternal(homepage, navigationOptions); } + + clearHash(window); + // Send the raw URL payload to the main frame + const channel = new BroadcastChannel(id); + channel.postMessage({ + v: 1, + payload, + }); + channel.close(); + try { + window.close(); + } catch {} } diff --git a/lib/msal-browser/src/utils/BrowserUtils.ts b/lib/msal-browser/src/utils/BrowserUtils.ts index 45fabbf3db..adf490e187 100644 --- a/lib/msal-browser/src/utils/BrowserUtils.ts +++ b/lib/msal-browser/src/utils/BrowserUtils.ts @@ -19,17 +19,18 @@ import { createBrowserAuthError, BrowserAuthErrorCodes, } from "../error/BrowserAuthError.js"; -import { BrowserCacheLocation } from "./BrowserConstants.js"; +import { BrowserCacheLocation, InteractionType } from "./BrowserConstants.js"; import * as BrowserCrypto from "../crypto/BrowserCrypto.js"; import { BrowserConfigurationAuthErrorCodes, createBrowserConfigurationAuthError, } from "../error/BrowserConfigurationAuthError.js"; -import type { BrowserConfiguration } from "../config/Configuration.js"; +import { BrowserConfiguration } from "../config/Configuration.js"; import { redirectBridgeEmptyResponse, redirectBridgeTimeout, } from "../error/BrowserAuthErrorCodes.js"; +import { base64Decode } from "../encode/Base64Decode.js"; /** * Clears hash from window url. @@ -67,11 +68,32 @@ export function isInIframe(): boolean { * Returns boolean of whether or not the current window is a popup opened by msal */ export function isInPopup(): boolean { - return ( - !isInIframe() && - (new URLSearchParams(location.search).has("client_info") || - new URLSearchParams(location.hash).has("client_info")) + if (isInIframe()) { + return false; + } + + const hasHash = !!window.location.hash && window.location.hash.length > 1; + const hash = hasHash ? window.location.hash : window.location.search; + if (!hash) { + return false; + } + + // Strip leading ? / # + const payload = hash.substring(1); + const params = new URLSearchParams(payload); + + const state = params.get("state"); + if (!state) { + return false; + } + + const { libraryState } = ProtocolUtils.parseRequestState( + base64Decode, + state ); + + const { meta } = libraryState; + return !!(meta && meta["interactionType"] === InteractionType.Popup); } /** diff --git a/lib/msal-browser/test/app/PublicClientApplication.spec.ts b/lib/msal-browser/test/app/PublicClientApplication.spec.ts index 84c557e317..30019d4bc0 100644 --- a/lib/msal-browser/test/app/PublicClientApplication.spec.ts +++ b/lib/msal-browser/test/app/PublicClientApplication.spec.ts @@ -1888,7 +1888,7 @@ describe("PublicClientApplication.ts Class Unit Tests", () => { enumerable: true, writable: true, value: new URL( - `http://localhost?client_info=${TEST_DATA_CLIENT_INFO.TEST_RAW_CLIENT_INFO}` + `http://localhost?state=${TEST_STATE_VALUES.TEST_STATE_POPUP}` ), }); @@ -3002,7 +3002,7 @@ describe("PublicClientApplication.ts Class Unit Tests", () => { enumerable: true, writable: true, value: new URL( - `http://localhost?client_info=${TEST_DATA_CLIENT_INFO.TEST_RAW_CLIENT_INFO}` + `http://localhost?state=${TEST_STATE_VALUES.TEST_STATE_POPUP}` ), }); From 2895e2010f06497b91886df069aabea138f757ad Mon Sep 17 00:00:00 2001 From: Konstantin Shabelko Date: Wed, 19 Nov 2025 10:36:35 -0500 Subject: [PATCH 16/45] - Add error description. --- docs/errors.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/errors.md b/docs/errors.md index 199b0d6690..7099f61202 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -263,7 +263,7 @@ This error occurs when MSAL.js surpasses the allotted storage limit when attempt - Authority mismatch error. Authority provided in login request or PublicClientApplication config does not match the environment of the provided account. Please use a matching account or make an interactive request to login to this authority. ### `invalid_request_method_for_EAR` -- The EAR protocol cannot be used with HTTP method `GET`. The `httpMethod` parameter in all requests using `protocolMode: ProtocolMode.EAR` must be either unset or `"POST"`/`HttpMethod.POST`. +- The EAR protocol cannot be used with HTTP method `GET`. The `httpMethod` parameter in all requests using `protocolMode: ProtocolMode.EAR` must be either unset or `"POST"`/`HttpMethod.POST`. ## Interaction required errors @@ -668,6 +668,10 @@ const msalConfig = { > [!IMPORTANT] > Please consult the [Troubleshooting Single-Sign On](https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/FAQ.md#troubleshooting-single-sign-on) section of the MSAL Browser FAQ if you are having trouble with the `ssoSilent` API. +### `redirect_bridge_empty_response` + +- The redirect bridge returned an empty, indicating the redirect bridge script may have been modified or replaced. + ### `redirect_in_iframe` - Redirects are not supported for iframed or brokered applications. Please ensure you are using MSAL.js in a top frame of the window if using the redirect APIs, or use the popup APIs. From 076df1d1a8a6bbd3f428eb9c8794cc738eb64ab8 Mon Sep 17 00:00:00 2001 From: Konstantin Shabelko Date: Wed, 19 Nov 2025 14:14:15 -0500 Subject: [PATCH 17/45] - Resolve dep cycle - Add more tests --- .../apiReview/msal-browser.api.md | 2 +- lib/msal-browser/src/config/Configuration.ts | 5 +- lib/msal-browser/src/redirect_bridge/index.ts | 1 + .../broadcastResponseToMainFrame.spec.ts | 245 ++++++++++++++++++ 4 files changed, 250 insertions(+), 3 deletions(-) create mode 100644 lib/msal-browser/test/redirect_bridge/broadcastResponseToMainFrame.spec.ts diff --git a/lib/msal-browser/apiReview/msal-browser.api.md b/lib/msal-browser/apiReview/msal-browser.api.md index 7caa13cb91..b6447be1c3 100644 --- a/lib/msal-browser/apiReview/msal-browser.api.md +++ b/lib/msal-browser/apiReview/msal-browser.api.md @@ -1462,7 +1462,7 @@ export type WrapperSKU = (typeof WrapperSKU)[keyof typeof WrapperSKU]; // src/cache/LocalStorage.ts:366:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/cache/LocalStorage.ts:429:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/cache/LocalStorage.ts:460:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/config/Configuration.ts:200:5 - (ae-forgotten-export) The symbol "InternalAuthOptions" needs to be exported by the entry point index.d.ts +// src/config/Configuration.ts:199:5 - (ae-forgotten-export) The symbol "InternalAuthOptions" needs to be exported by the entry point index.d.ts // src/event/EventHandler.ts:114:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/event/EventHandler.ts:141:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/index.ts:8:12 - (tsdoc-characters-after-block-tag) The token "@azure" looks like a TSDoc tag but contains an invalid character "/"; if it is not a tag, use a backslash to escape the "@" diff --git a/lib/msal-browser/src/config/Configuration.ts b/lib/msal-browser/src/config/Configuration.ts index c6b8e62e09..f9dae551ff 100644 --- a/lib/msal-browser/src/config/Configuration.ts +++ b/lib/msal-browser/src/config/Configuration.ts @@ -26,7 +26,6 @@ import { BrowserCacheLocation } from "../utils/BrowserConstants.js"; import { INavigationClient } from "../navigation/INavigationClient.js"; import { NavigationClient } from "../navigation/NavigationClient.js"; import { FetchClient } from "../network/FetchClient.js"; -import * as BrowserUtils from "../utils/BrowserUtils.js"; // Default timeout for popup windows and iframes in milliseconds export const DEFAULT_POPUP_TIMEOUT_MS = 60000; @@ -229,7 +228,9 @@ export function buildConfiguration( cloudDiscoveryMetadata: "", authorityMetadata: "", redirectUri: - typeof window !== "undefined" ? BrowserUtils.getCurrentUri() : "", + typeof window !== "undefined" && window.location + ? window.location.href.split("?")[0].split("#")[0] + : "", postLogoutRedirectUri: "", clientCapabilities: [], OIDCOptions: { diff --git a/lib/msal-browser/src/redirect_bridge/index.ts b/lib/msal-browser/src/redirect_bridge/index.ts index ffe31710f5..2904c4eafe 100644 --- a/lib/msal-browser/src/redirect_bridge/index.ts +++ b/lib/msal-browser/src/redirect_bridge/index.ts @@ -68,6 +68,7 @@ export async function broadcastResponseToMainFrame( navigateToUrl || BrowserUtils.getHomepage() }${hash}`; await navigationClient.navigateInternal(homepage, navigationOptions); + return; } clearHash(window); diff --git a/lib/msal-browser/test/redirect_bridge/broadcastResponseToMainFrame.spec.ts b/lib/msal-browser/test/redirect_bridge/broadcastResponseToMainFrame.spec.ts new file mode 100644 index 0000000000..c61d0c1ace --- /dev/null +++ b/lib/msal-browser/test/redirect_bridge/broadcastResponseToMainFrame.spec.ts @@ -0,0 +1,245 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { broadcastResponseToMainFrame } from "../../src/redirect_bridge/index.js"; +import { NavigationClient } from "../../src/navigation/NavigationClient.js"; +import { clearHash } from "../../src/utils/BrowserUtils.js"; +import { + TEST_HASHES, + TEST_STATE_VALUES, + RANDOM_TEST_GUID, +} from "../utils/StringConstants.js"; + +jest.mock("../../src/utils/BrowserUtils.js"); +jest.mock("../../src/navigation/NavigationClient.js"); + +describe("broadcastResponseToMainFrame", () => { + let mockNavigationClient: jest.Mocked; + + beforeEach(() => { + // Mock window.close + window.close = jest.fn(); + + // Mock NavigationClient + mockNavigationClient = { + navigateInternal: jest.fn().mockResolvedValue(undefined), + } as any; + (NavigationClient as unknown as jest.Mock).mockImplementation( + () => mockNavigationClient + ); + + // Mock clearHash + (clearHash as jest.Mock).mockImplementation(() => {}); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + window.location.hash = ""; + }); + + describe("Error cases", () => { + it("throws error when no hash or query string is present", async () => { + window.location.hash = ""; + + await expect(broadcastResponseToMainFrame()).rejects.toThrow( + "No auth payload found on URL (hash or query)" + ); + }); + + it("throws error when state parameter is missing from hash", async () => { + window.location.hash = "#code=testCode&client_info=testClientInfo"; + + await expect(broadcastResponseToMainFrame()).rejects.toThrow( + "Missing state on redirect URL" + ); + + expect(clearHash).toHaveBeenCalledWith(window); + }); + + it("throws error when state is missing 'id' attribute", async () => { + const invalidState = btoa( + JSON.stringify({ meta: { interactionType: "popup" } }) + ); + window.location.hash = `#code=testCode&state=${invalidState}`; + + await expect(broadcastResponseToMainFrame()).rejects.toThrow( + "Missing state 'id' and/or 'meta' attributes" + ); + + expect(clearHash).toHaveBeenCalledWith(window); + }); + + it("throws error when state is missing 'meta' attribute", async () => { + const invalidState = btoa(JSON.stringify({ id: RANDOM_TEST_GUID })); + window.location.hash = `#code=testCode&state=${invalidState}`; + + await expect(broadcastResponseToMainFrame()).rejects.toThrow( + "Missing state 'id' and/or 'meta' attributes" + ); + + expect(clearHash).toHaveBeenCalledWith(window); + }); + }); + + describe("Success cases - Popup/Silent flow", () => { + it("broadcasts response for popup flow from hash", async () => { + window.location.hash = TEST_HASHES.TEST_SUCCESS_CODE_HASH_POPUP; + + await broadcastResponseToMainFrame(); + + // Verify hash was cleared (indicates broadcast path was taken) + expect(clearHash).toHaveBeenCalledWith(window); + + // Verify window.close was called + expect(window.close).toHaveBeenCalled(); + + // Verify navigation was NOT called for popup + expect( + mockNavigationClient.navigateInternal + ).not.toHaveBeenCalled(); + }); + + it("broadcasts response for silent flow from hash", async () => { + window.location.hash = TEST_HASHES.TEST_SUCCESS_CODE_HASH_SILENT; + + await broadcastResponseToMainFrame(); + + // Verify hash was cleared (indicates broadcast path was taken) + expect(clearHash).toHaveBeenCalledWith(window); + + // Verify window.close was called + expect(window.close).toHaveBeenCalled(); + + // Verify navigation was NOT called for silent + expect( + mockNavigationClient.navigateInternal + ).not.toHaveBeenCalled(); + }); + + it("handles error responses in hash", async () => { + // Create error hash with POPUP state (not redirect) so it broadcasts + const errorHashWithPopupState = `#error=error_code&error_description=msal+error+description&state=${TEST_STATE_VALUES.TEST_STATE_POPUP}`; + window.location.hash = errorHashWithPopupState; + + await broadcastResponseToMainFrame(); + + // Should still broadcast the error response (verify via hash clearing) + expect(clearHash).toHaveBeenCalledWith(window); + + // Verify window.close was called + expect(window.close).toHaveBeenCalled(); + + // Verify navigation was NOT called + expect( + mockNavigationClient.navigateInternal + ).not.toHaveBeenCalled(); + }); + }); + + describe("Success cases - Redirect flow", () => { + it("navigates to homepage for redirect flow and does NOT broadcast", async () => { + window.location.hash = TEST_HASHES.TEST_SUCCESS_CODE_HASH_REDIRECT; + + await broadcastResponseToMainFrame(); + + // Verify navigation was called with homepage + hash + expect(NavigationClient).toHaveBeenCalled(); + expect(mockNavigationClient.navigateInternal).toHaveBeenCalledWith( + expect.stringContaining( + TEST_HASHES.TEST_SUCCESS_CODE_HASH_REDIRECT + ), + expect.objectContaining({ + apiId: expect.any(Number), + noHistory: true, + timeout: expect.any(Number), + }) + ); + + // Hash should NOT be cleared for redirect (early return before clearHash) + expect(clearHash).not.toHaveBeenCalled(); + + // window.close should NOT be called for redirect (early return) + expect(window.close).not.toHaveBeenCalled(); + }); + + it("navigates to custom URL for redirect flow when navigateToUrl is provided", async () => { + const customUrl = "https://localhost:8081/custom-page.html"; + window.location.hash = TEST_HASHES.TEST_SUCCESS_CODE_HASH_REDIRECT; + + await broadcastResponseToMainFrame(customUrl); + + // Verify navigation was called with custom URL + hash + expect(mockNavigationClient.navigateInternal).toHaveBeenCalledWith( + `${customUrl}${TEST_HASHES.TEST_SUCCESS_CODE_HASH_REDIRECT}`, + expect.objectContaining({ + apiId: expect.any(Number), + noHistory: true, + timeout: expect.any(Number), + }) + ); + + // Hash should NOT be cleared for redirect + expect(clearHash).not.toHaveBeenCalled(); + + // window.close should NOT be called for redirect + expect(window.close).not.toHaveBeenCalled(); + }); + }); + + describe("Edge cases", () => { + it("handles hash with only # character", async () => { + window.location.hash = "#"; + + await expect(broadcastResponseToMainFrame()).rejects.toThrow( + "No auth payload found on URL (hash or query)" + ); + }); + + it("does not throw when window.close() fails for popup/silent flows", async () => { + window.location.hash = TEST_HASHES.TEST_SUCCESS_CODE_HASH_POPUP; + + // Mock window.close to throw an error + window.close = jest.fn(() => { + throw new Error("Cannot close window"); + }); + + // Should not throw despite window.close failing + await expect( + broadcastResponseToMainFrame() + ).resolves.toBeUndefined(); + + // Verify clearHash was still called (indicates broadcast completed) + expect(clearHash).toHaveBeenCalledWith(window); + }); + }); + + describe("Hash clearing", () => { + it("clears hash before throwing error when state is missing", async () => { + window.location.hash = "#code=testCode"; + + await expect(broadcastResponseToMainFrame()).rejects.toThrow(); + + expect(clearHash).toHaveBeenCalledWith(window); + }); + + it("clears hash before throwing error when state attributes are missing", async () => { + const invalidState = btoa(JSON.stringify({ id: RANDOM_TEST_GUID })); + window.location.hash = `#code=testCode&state=${invalidState}`; + + await expect(broadcastResponseToMainFrame()).rejects.toThrow(); + + expect(clearHash).toHaveBeenCalledWith(window); + }); + + it("clears hash after successful broadcast", async () => { + window.location.hash = TEST_HASHES.TEST_SUCCESS_CODE_HASH_POPUP; + + await broadcastResponseToMainFrame(); + + expect(clearHash).toHaveBeenCalledWith(window); + }); + }); +}); From 5cb8ca87dadf0f894e65218947423bddc6cb69c5 Mon Sep 17 00:00:00 2001 From: Konstantin Shabelko Date: Wed, 19 Nov 2025 16:50:28 -0500 Subject: [PATCH 18/45] - Add support for overriding ongoing interactions in popup flows --- docs/errors.md | 34 +++ .../apiReview/msal-browser.api.md | 15 +- .../src/cache/BrowserCacheManager.ts | 43 ++- .../src/controllers/StandardController.ts | 3 +- .../src/error/BrowserAuthErrorCodes.ts | 2 + lib/msal-browser/src/request/PopupRequest.ts | 2 + lib/msal-browser/src/utils/BrowserUtils.ts | 49 +++ .../test/cache/BrowserCacheManager.spec.ts | 139 +++++++++ .../test/utils/BrowserUtils.spec.ts | 289 +++++++++++++++++- 9 files changed, 560 insertions(+), 16 deletions(-) diff --git a/docs/errors.md b/docs/errors.md index 7099f61202..79dd9d7f79 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -558,6 +558,40 @@ If you are unable to figure out why this error is being thrown please [open an i - Refresh the page. Does the error go away? - Open your application in a new tab. Does the error go away? +### `interaction_in_progress_overridden` + +- The current interaction was overridden by a new interaction request with `overrideInteractionInProgress` set to `true`. + +This error is thrown when an existing popup interaction is cancelled because a new popup request was initiated with the `overrideInteractionInProgress` flag set to `true`. This is not necessarily an error condition - it indicates that the previous interaction was intentionally cancelled to allow a new one to proceed. + +**When This Occurs:** + +This error is thrown for the **previous/cancelled** interaction when: +1. A popup interaction is in progress (e.g., `acquireTokenPopup`) +2. A new popup request is made with `overrideInteractionInProgress: true` +3. The library cancels the pending interaction and starts the new one + +**Example:** + +```javascript +// First popup request starts +const request1 = { scopes: ["User.Read"] }; +const promise1 = msalInstance.acquireTokenPopup(request1); + +// User closes the popup or something goes wrong +// App decides to retry with override flag +const request2 = { + scopes: ["User.Read"], + overrideInteractionInProgress: true // Override the previous interaction +}; +const promise2 = msalInstance.acquireTokenPopup(request2); + +// promise1 will reject with interaction_in_progress_overridden +// promise2 will proceed normally +``` + +**Note:** This error should only be seen when you explicitly use the `overrideInteractionInProgress` flag. Under normal circumstances, concurrent interaction attempts will throw `interaction_in_progress` instead. + ### `popup_window_error` - Error opening popup window. This can happen if you are using IE or if popups are blocked in the browser. diff --git a/lib/msal-browser/apiReview/msal-browser.api.md b/lib/msal-browser/apiReview/msal-browser.api.md index b6447be1c3..53c8efe61a 100644 --- a/lib/msal-browser/apiReview/msal-browser.api.md +++ b/lib/msal-browser/apiReview/msal-browser.api.md @@ -226,6 +226,7 @@ declare namespace BrowserAuthErrorCodes { unableToParseState, stateInteractionTypeMismatch, interactionInProgress, + interactionInProgressOverridden, popupWindowError, emptyWindowError, userCancelled, @@ -409,6 +410,7 @@ declare namespace BrowserUtils { replaceHash, isInIframe, isInPopup, + cancelPendingBridgeResponse, waitForBridgeResponse, getCurrentUri, getHomepage, @@ -452,6 +454,11 @@ export type CacheOptions = { cacheRetentionDays?: number; }; +// Warning: (ae-missing-release-tag) "cancelPendingBridgeResponse" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +function cancelPendingBridgeResponse(logger: Logger, correlationId: string): void; + // Warning: (ae-missing-release-tag) "ClearCacheRequest" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public @@ -755,6 +762,11 @@ export { InProgressPerformanceEvent } // @public (undocumented) const interactionInProgress = "interaction_in_progress"; +// Warning: (ae-missing-release-tag) "interactionInProgressOverridden" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +const interactionInProgressOverridden = "interaction_in_progress_overridden"; + export { InteractionRequiredAuthError } export { InteractionRequiredAuthErrorCodes } @@ -1102,6 +1114,7 @@ export type PopupRequest = Partial; popupWindowAttributes?: PopupWindowAttributes; popupWindowParent?: Window; + overrideInteractionInProgress?: boolean; }; // Warning: (ae-missing-release-tag) "PopupSize" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -1442,7 +1455,7 @@ export const version = "5.0.0-alpha.0"; // Warning: (ae-missing-release-tag) "waitForBridgeResponse" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public +// @public (undocumented) function waitForBridgeResponse(timeoutMs: number, logger: Logger, browserCrypto: ICrypto, request: CommonAuthorizationUrlRequest | CommonEndSessionRequest): Promise; // Warning: (ae-missing-release-tag) "WrapperSKU" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/lib/msal-browser/src/cache/BrowserCacheManager.ts b/lib/msal-browser/src/cache/BrowserCacheManager.ts index 463f22ef77..2000e7b0e3 100644 --- a/lib/msal-browser/src/cache/BrowserCacheManager.ts +++ b/lib/msal-browser/src/cache/BrowserCacheManager.ts @@ -66,6 +66,7 @@ import { base64Encode } from "../encode/Base64Encode.js"; import { CookieStorage } from "./CookieStorage.js"; import { getAccountKeys, getTokenKeys } from "./CacheHelpers.js"; import { EventType } from "../event/EventType.js"; +import * as BrowserUtils from "../utils/BrowserUtils.js"; import { EventHandler } from "../event/EventHandler.js"; import { clearHash } from "../utils/BrowserUtils.js"; import { version } from "../packageMetadata.js"; @@ -2215,23 +2216,41 @@ export class BrowserCacheManager extends CacheManager { setInteractionInProgress( inProgress: boolean, - type: INTERACTION_TYPE = INTERACTION_TYPE.SIGNIN + type: INTERACTION_TYPE = INTERACTION_TYPE.SIGNIN, + allowOverride: boolean = false, + correlationId: string = "" ): void { // Ensure we don't overwrite interaction in progress for a different clientId const key = `${CacheKeys.PREFIX}.${TemporaryCacheKeys.INTERACTION_STATUS_KEY}`; if (inProgress) { - if (this.getInteractionInProgress()) { - throw createBrowserAuthError( - BrowserAuthErrorCodes.interactionInProgress - ); - } else { - // No interaction is in progress - this.setTemporaryCache( - key, - JSON.stringify({ clientId: this.clientId, type }), - false - ); + const existingInteraction = this.getInteractionInProgress(); + if (existingInteraction) { + if (allowOverride) { + this.logger.warning( + `Overriding existing interaction_in_progress for clientId: ${existingInteraction.clientId}, type: ${existingInteraction.type}`, + correlationId + ); + + // Cancel any active bridge monitor from the previous interaction + BrowserUtils.cancelPendingBridgeResponse( + this.logger, + correlationId + ); + + // Clear existing interaction to allow new one + this.removeTemporaryItem(key); + } else { + throw createBrowserAuthError( + BrowserAuthErrorCodes.interactionInProgress + ); + } } + // Set new interaction + this.setTemporaryCache( + key, + JSON.stringify({ clientId: this.clientId, type }), + false + ); } else if ( !inProgress && this.getInteractionInProgress()?.clientId === this.clientId diff --git a/lib/msal-browser/src/controllers/StandardController.ts b/lib/msal-browser/src/controllers/StandardController.ts index bd15e834b0..e8d49a308a 100644 --- a/lib/msal-browser/src/controllers/StandardController.ts +++ b/lib/msal-browser/src/controllers/StandardController.ts @@ -750,7 +750,8 @@ export class StandardController implements IController { ); this.browserStorage.setInteractionInProgress( true, - INTERACTION_TYPE.SIGNIN + INTERACTION_TYPE.SIGNIN, + request.overrideInteractionInProgress ); } catch (e) { // Since this function is syncronous we need to reject diff --git a/lib/msal-browser/src/error/BrowserAuthErrorCodes.ts b/lib/msal-browser/src/error/BrowserAuthErrorCodes.ts index 63de62eaf7..b244740041 100644 --- a/lib/msal-browser/src/error/BrowserAuthErrorCodes.ts +++ b/lib/msal-browser/src/error/BrowserAuthErrorCodes.ts @@ -15,6 +15,8 @@ export const hashDoesNotContainKnownProperties = export const unableToParseState = "unable_to_parse_state"; export const stateInteractionTypeMismatch = "state_interaction_type_mismatch"; export const interactionInProgress = "interaction_in_progress"; +export const interactionInProgressOverridden = + "interaction_in_progress_overridden"; export const popupWindowError = "popup_window_error"; export const emptyWindowError = "empty_window_error"; export const userCancelled = "user_cancelled"; diff --git a/lib/msal-browser/src/request/PopupRequest.ts b/lib/msal-browser/src/request/PopupRequest.ts index 6936f04385..e6a0a6992d 100644 --- a/lib/msal-browser/src/request/PopupRequest.ts +++ b/lib/msal-browser/src/request/PopupRequest.ts @@ -32,6 +32,7 @@ import { PopupWindowAttributes } from "./PopupWindowAttributes.js"; * - nonce - A value included in the request that is returned in the id token. A randomly generated unique value is typically used to mitigate replay attacks. * - popupWindowAttributes - Optional popup window attributes. popupSize with height and width, and popupPosition with top and left can be set. * - popupWindowParent - Optional window object to use as the parent when opening popup windows. Uses global `window` if not given. + * - overrideInteractionInProgress - Optional flag to allow overriding an existing interaction_in_progress state for popup flows. When set to true, if another interaction is in progress, it will be cancelled and the new popup flow will proceed. This is useful when a user has cancelled a popup or when recovering from errors. Default is false. */ export type PopupRequest = Partial< @@ -48,4 +49,5 @@ export type PopupRequest = Partial< scopes: Array; popupWindowAttributes?: PopupWindowAttributes; popupWindowParent?: Window; + overrideInteractionInProgress?: boolean; }; diff --git a/lib/msal-browser/src/utils/BrowserUtils.ts b/lib/msal-browser/src/utils/BrowserUtils.ts index adf490e187..1e34fcc585 100644 --- a/lib/msal-browser/src/utils/BrowserUtils.ts +++ b/lib/msal-browser/src/utils/BrowserUtils.ts @@ -107,6 +107,41 @@ export function isInPopup(): boolean { * @param request - The authorization or end session request. * @returns Promise - Resolves with the response string (query or hash) from the window. */ + +// Track the active bridge monitor to allow cancellation when overriding interactions +let activeBridgeMonitor: { + timeoutId: number; + channel: BroadcastChannel; + reject: (reason?: unknown) => void; +} | null = null; + +/** + * Cancels the pending bridge response monitor if one exists. + * This is called when overrideInteractionInProgress is used to cancel + * any pending popup interaction before starting a new one. + */ +export function cancelPendingBridgeResponse( + logger: Logger, + correlationId: string +): void { + if (activeBridgeMonitor) { + logger.verbose( + "BrowserUtils.cancelPendingBridgeResponse - Cancelling pending bridge monitor", + correlationId + ); + + clearTimeout(activeBridgeMonitor.timeoutId); + activeBridgeMonitor.channel.close(); + activeBridgeMonitor.reject( + createBrowserAuthError( + BrowserAuthErrorCodes.interactionInProgressOverridden + ) + ); + + activeBridgeMonitor = null; + } +} + export async function waitForBridgeResponse( timeoutMs: number, logger: Logger, @@ -127,12 +162,26 @@ export async function waitForBridgeResponse( let responseString: string | undefined = undefined; const timeoutId = window.setTimeout(() => { + // Clear the active monitor + activeBridgeMonitor = null; + channel.close(); reject(createBrowserAuthError(redirectBridgeTimeout)); }, timeoutMs); + // Track this monitor so it can be cancelled if needed + activeBridgeMonitor = { + timeoutId, + channel, + reject, + }; + channel.onmessage = (event) => { responseString = event.data.payload; + + // Clear the active monitor + activeBridgeMonitor = null; + clearTimeout(timeoutId); channel.close(); if (responseString) { diff --git a/lib/msal-browser/test/cache/BrowserCacheManager.spec.ts b/lib/msal-browser/test/cache/BrowserCacheManager.spec.ts index 2592e9a8a3..0ff99262b4 100644 --- a/lib/msal-browser/test/cache/BrowserCacheManager.spec.ts +++ b/lib/msal-browser/test/cache/BrowserCacheManager.spec.ts @@ -4120,6 +4120,145 @@ describe("BrowserCacheManager tests", () => { ) ).toBeNull(); }); + + it("throws error when interaction is already in progress without override flag", () => { + const perfClient = new BrowserPerformanceClient({ + auth: { + clientId: TEST_CONFIG.MSAL_CLIENT_ID, + }, + }); + const cacheManager = new BrowserCacheManager( + TEST_CONFIG.MSAL_CLIENT_ID, + cacheConfig, + browserCrypto, + logger, + perfClient, + new EventHandler() + ); + + cacheManager.setInteractionInProgress(true); + + expect(() => { + cacheManager.setInteractionInProgress(true); + }).toThrow(); + }); + + it("allows override when allowOverride flag is true", () => { + const perfClient = new BrowserPerformanceClient({ + auth: { + clientId: TEST_CONFIG.MSAL_CLIENT_ID, + }, + }); + const cacheManager = new BrowserCacheManager( + TEST_CONFIG.MSAL_CLIENT_ID, + cacheConfig, + browserCrypto, + logger, + perfClient, + new EventHandler() + ); + + // Set initial interaction + cacheManager.setInteractionInProgress( + true, + INTERACTION_TYPE.SIGNIN + ); + expect( + cacheManager.getInteractionInProgress()?.type + ).toEqual(INTERACTION_TYPE.SIGNIN); + + // Override with new interaction type + cacheManager.setInteractionInProgress( + true, + INTERACTION_TYPE.SIGNIN, + true + ); + expect( + cacheManager.getInteractionInProgress()?.type + ).toEqual(INTERACTION_TYPE.SIGNIN); + expect( + cacheManager.getInteractionInProgress()?.clientId + ).toEqual(TEST_CONFIG.MSAL_CLIENT_ID); + }); + + it("calls cancelPendingBridgeResponse when overriding interaction", () => { + const perfClient = new BrowserPerformanceClient({ + auth: { + clientId: TEST_CONFIG.MSAL_CLIENT_ID, + }, + }); + const cacheManager = new BrowserCacheManager( + TEST_CONFIG.MSAL_CLIENT_ID, + cacheConfig, + browserCrypto, + logger, + perfClient, + new EventHandler() + ); + + // Mock BrowserUtils + const BrowserUtils = require("../../src/utils/BrowserUtils.js"); + const cancelSpy = jest.spyOn( + BrowserUtils, + "cancelPendingBridgeResponse" + ); + + // Set initial interaction + cacheManager.setInteractionInProgress(true); + + // Override interaction + cacheManager.setInteractionInProgress( + true, + INTERACTION_TYPE.SIGNIN, + true + ); + + // Verify cancelPendingBridgeResponse was called + expect(cancelSpy).toHaveBeenCalledWith(logger, ""); + + cancelSpy.mockRestore(); + }); + + it("logs warning when overriding interaction", () => { + const perfClient = new BrowserPerformanceClient({ + auth: { + clientId: TEST_CONFIG.MSAL_CLIENT_ID, + }, + }); + const cacheManager = new BrowserCacheManager( + TEST_CONFIG.MSAL_CLIENT_ID, + cacheConfig, + browserCrypto, + logger, + perfClient, + new EventHandler() + ); + + const warningSpy = jest.spyOn(logger, "warning"); + + // Set initial interaction + cacheManager.setInteractionInProgress( + true, + INTERACTION_TYPE.SIGNIN + ); + + // Override interaction + cacheManager.setInteractionInProgress( + true, + INTERACTION_TYPE.SIGNIN, + true + ); + + // Verify warning was logged + expect(warningSpy).toHaveBeenCalledWith( + expect.stringContaining( + "Overriding existing interaction_in_progress" + ), + "" + ); + + warningSpy.mockRestore(); + }); }); }); }); diff --git a/lib/msal-browser/test/utils/BrowserUtils.spec.ts b/lib/msal-browser/test/utils/BrowserUtils.spec.ts index 0676407210..e94ac0ecea 100644 --- a/lib/msal-browser/test/utils/BrowserUtils.spec.ts +++ b/lib/msal-browser/test/utils/BrowserUtils.spec.ts @@ -3,12 +3,12 @@ * Licensed under the MIT License. */ -import { TEST_CONFIG, TEST_URIS } from "./StringConstants"; +import { TEST_CONFIG, TEST_URIS } from "./StringConstants.js"; import { BrowserUtils, BrowserAuthError, BrowserAuthErrorCodes, -} from "../../src"; +} from "../../src/index.js"; describe("BrowserUtils.ts Function Unit Tests", () => { const oldWindow = { ...window }; @@ -105,4 +105,289 @@ describe("BrowserUtils.ts Function Unit Tests", () => { jest.runAllTimers(); expect(document.querySelector("link")).toBeFalsy(); }); + + describe("cancelPendingBridgeResponse", () => { + it("does nothing when no monitor is active", () => { + const logger = { + verbose: jest.fn(), + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), + } as any; + + BrowserUtils.cancelPendingBridgeResponse( + logger, + TEST_CONFIG.CORRELATION_ID + ); + + expect(logger.verbose).not.toHaveBeenCalled(); + }); + + it("cancels active bridge monitor and rejects with interactionInProgressOverridden error", async () => { + const logger = { + verbose: jest.fn(), + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), + } as any; + + const browserCrypto = { + base64Decode: (input: string) => atob(input), + } as any; + + const state = btoa(JSON.stringify({ id: "test-id-123" })); + const request = { + state, + correlationId: "test-correlation-id", + } as any; + + // Start a bridge response wait + const waitPromise = BrowserUtils.waitForBridgeResponse( + 5000, + logger, + browserCrypto, + request + ); + + // Cancel it + BrowserUtils.cancelPendingBridgeResponse( + logger, + TEST_CONFIG.CORRELATION_ID + ); + + // Should reject with interactionInProgressOverridden error + await expect(waitPromise).rejects.toMatchObject({ + errorCode: + BrowserAuthErrorCodes.interactionInProgressOverridden, + }); + + expect(logger.verbose).toHaveBeenCalledWith( + expect.stringContaining("Cancelling pending bridge monitor"), + TEST_CONFIG.CORRELATION_ID + ); + }); + + it("clears timeout when cancelling", async () => { + jest.useFakeTimers(); + const logger = { + verbose: jest.fn(), + } as any; + + const browserCrypto = { + base64Decode: (input: string) => atob(input), + } as any; + + const state = btoa(JSON.stringify({ id: "test-id-456" })); + const request = { + state, + correlationId: "test-correlation-id", + } as any; + + const clearTimeoutSpy = jest.spyOn(window, "clearTimeout"); + + // Start waiting + const waitPromise = BrowserUtils.waitForBridgeResponse( + 5000, + logger, + browserCrypto, + request + ); + + // Cancel + BrowserUtils.cancelPendingBridgeResponse( + logger, + TEST_CONFIG.CORRELATION_ID + ); + + // Verify clearTimeout was called + expect(clearTimeoutSpy).toHaveBeenCalled(); + + await expect(waitPromise).rejects.toMatchObject({ + errorCode: + BrowserAuthErrorCodes.interactionInProgressOverridden, + }); + + jest.useRealTimers(); + }); + + it("closes BroadcastChannel when cancelling", async () => { + const logger = { + verbose: jest.fn(), + } as any; + + const browserCrypto = { + base64Decode: (input: string) => atob(input), + } as any; + + const state = btoa(JSON.stringify({ id: "test-id-789" })); + const request = { + state, + correlationId: "test-correlation-id", + } as any; + + // Start waiting + const waitPromise = BrowserUtils.waitForBridgeResponse( + 5000, + logger, + browserCrypto, + request + ); + + // Cancel - this should close the BroadcastChannel internally + BrowserUtils.cancelPendingBridgeResponse( + logger, + TEST_CONFIG.CORRELATION_ID + ); + + // Verify the promise was rejected with the correct error + await expect(waitPromise).rejects.toMatchObject({ + errorCode: + BrowserAuthErrorCodes.interactionInProgressOverridden, + }); + + // Verify verbose logging occurred for cancellation + expect(logger.verbose).toHaveBeenCalledWith( + expect.stringContaining("Cancelling pending bridge monitor"), + TEST_CONFIG.CORRELATION_ID + ); + }); + }); + + describe("waitForBridgeResponse with cancellation", () => { + it("can be cancelled before timeout", async () => { + jest.useFakeTimers(); + const logger = { + verbose: jest.fn(), + } as any; + + const browserCrypto = { + base64Decode: (input: string) => atob(input), + } as any; + + const state = btoa(JSON.stringify({ id: "cancel-test-id" })); + const request = { + state, + correlationId: "test-correlation-id", + } as any; + + const waitPromise = BrowserUtils.waitForBridgeResponse( + 10000, + logger, + browserCrypto, + request + ); + + // Cancel before timeout + jest.advanceTimersByTime(1000); + BrowserUtils.cancelPendingBridgeResponse( + logger, + TEST_CONFIG.CORRELATION_ID + ); + + await expect(waitPromise).rejects.toMatchObject({ + errorCode: + BrowserAuthErrorCodes.interactionInProgressOverridden, + }); + + // Advance time to when timeout would have fired + jest.advanceTimersByTime(10000); + + jest.useRealTimers(); + }); + + it("clears active monitor on successful response", async () => { + const logger = { + verbose: jest.fn(), + } as any; + + const browserCrypto = { + base64Decode: (input: string) => atob(input), + } as any; + + const channelId = "success-test-id"; + const state = btoa(JSON.stringify({ id: channelId })); + const request = { + state, + correlationId: "test-correlation-id", + } as any; + + // Start the wait + const waitPromise = BrowserUtils.waitForBridgeResponse( + 5000, + logger, + browserCrypto, + request + ); + + // Simulate successful response by posting to the BroadcastChannel + // We need to do this after waitForBridgeResponse creates the channel + await new Promise((resolve) => setTimeout(resolve, 10)); // Small delay to ensure channel is set up + + const channel = new BroadcastChannel(channelId); + channel.postMessage({ + v: 1, + payload: "code=test&state=test", + }); + channel.close(); + + const result = await waitPromise; + expect(result).toBe("code=test&state=test"); + + // Try to cancel after completion - should do nothing since monitor is cleared + BrowserUtils.cancelPendingBridgeResponse( + logger, + TEST_CONFIG.CORRELATION_ID + ); + + // Verify verbose was not called for cancellation (monitor already cleared) + expect(logger.verbose).toHaveBeenCalledWith( + "BrowserUtils.waitForBridgeResponse - started", + "test-correlation-id" + ); + // Should not have been called with the cancellation message + expect(logger.verbose).not.toHaveBeenCalledWith( + expect.stringContaining("Cancelling pending bridge monitor"), + expect.anything() + ); + }); + + it("clears active monitor on timeout", async () => { + jest.useFakeTimers(); + const logger = { + verbose: jest.fn(), + } as any; + + const browserCrypto = { + base64Decode: (input: string) => atob(input), + } as any; + + const state = btoa(JSON.stringify({ id: "timeout-test-id" })); + const request = { + state, + correlationId: "test-correlation-id", + } as any; + + const waitPromise = BrowserUtils.waitForBridgeResponse( + 1000, + logger, + browserCrypto, + request + ); + + // Advance time to trigger timeout + jest.advanceTimersByTime(1001); + + await expect(waitPromise).rejects.toMatchObject({ + errorCode: BrowserAuthErrorCodes.redirectBridgeTimeout, + }); + + // Try to cancel after timeout - should do nothing + BrowserUtils.cancelPendingBridgeResponse( + logger, + TEST_CONFIG.CORRELATION_ID + ); + + jest.useRealTimers(); + }); + }); }); From 6636748cf5490569322d4b605a6b006cc02a191e Mon Sep 17 00:00:00 2001 From: Konstantin Shabelko Date: Thu, 20 Nov 2025 18:55:36 -0500 Subject: [PATCH 19/45] - Update core e2e tests --- .../src/app/app.config.ts | 2 +- .../src/app/app.routes.ts | 5 ++ .../src/app/redirect/redirect.component.ts | 17 +++++++ .../test/home.spec.ts | 4 -- .../ExpressSample/public/redirect.html | 19 ++++++++ .../ExpressSample/server.js | 10 ++-- .../app/default/redirect.html | 24 +++------- .../app/facebook-sample/redirect.html | 24 +++------- .../nextjs-sample/pages/_app.js | 17 +++++++ .../nextjs-sample/pages/redirect.js | 22 +++++++++ .../nextjs-sample/src/authConfig.js | 2 +- .../nextjs-sample/test/home.spec.ts | 4 -- .../nextjs-sample/test/profile.spec.ts | 6 +-- .../react-router-sample/.env.development | 6 +-- .../react-router-sample/.env.e2e | 2 +- .../react-router-sample/public/redirect.html | 18 -------- .../react-router-sample/src/App.js | 11 ++++- .../react-router-sample/src/authConfig.js | 2 +- .../src/pages/ProfileRawContext.jsx | 19 ++++---- .../ProfileUseMsalAuthenticationHook.jsx | 5 +- .../src/pages/Redirect.jsx | 20 ++++++++ .../src/ui-components/SignInButton.jsx | 8 +--- .../react-router-sample/test/home.spec.ts | 4 -- .../react-router-sample/test/profile.spec.ts | 46 ------------------- .../test/profileRawContext.spec.ts | 4 -- 25 files changed, 147 insertions(+), 154 deletions(-) create mode 100644 samples/msal-angular-samples/angular-standalone-sample/src/app/redirect/redirect.component.ts create mode 100644 samples/msal-browser-samples/ExpressSample/public/redirect.html create mode 100644 samples/msal-react-samples/nextjs-sample/pages/redirect.js delete mode 100644 samples/msal-react-samples/react-router-sample/public/redirect.html create mode 100644 samples/msal-react-samples/react-router-sample/src/pages/Redirect.jsx diff --git a/samples/msal-angular-samples/angular-standalone-sample/src/app/app.config.ts b/samples/msal-angular-samples/angular-standalone-sample/src/app/app.config.ts index 4a5f2142f9..b5259cad9c 100644 --- a/samples/msal-angular-samples/angular-standalone-sample/src/app/app.config.ts +++ b/samples/msal-angular-samples/angular-standalone-sample/src/app/app.config.ts @@ -46,7 +46,7 @@ export function MSALInstanceFactory(): IPublicClientApplication { auth: { clientId: environment.msalConfig.auth.clientId, authority: environment.msalConfig.auth.authority, - redirectUri: '/', + redirectUri: '/redirect', postLogoutRedirectUri: '/', }, cache: { diff --git a/samples/msal-angular-samples/angular-standalone-sample/src/app/app.routes.ts b/samples/msal-angular-samples/angular-standalone-sample/src/app/app.routes.ts index 0d2b68124e..7c5d9b9782 100644 --- a/samples/msal-angular-samples/angular-standalone-sample/src/app/app.routes.ts +++ b/samples/msal-angular-samples/angular-standalone-sample/src/app/app.routes.ts @@ -2,9 +2,14 @@ import { Routes } from '@angular/router'; import { FailedComponent } from './failed/failed.component'; import { HomeComponent } from './home/home.component'; import { ProfileComponent } from './profile/profile.component'; +import { RedirectComponent } from './redirect/redirect.component'; import { MsalGuard } from '@azure/msal-angular'; export const routes: Routes = [ + { + path: 'redirect', + component: RedirectComponent, + }, { path: 'profile', component: ProfileComponent, diff --git a/samples/msal-angular-samples/angular-standalone-sample/src/app/redirect/redirect.component.ts b/samples/msal-angular-samples/angular-standalone-sample/src/app/redirect/redirect.component.ts new file mode 100644 index 0000000000..cecf71729d --- /dev/null +++ b/samples/msal-angular-samples/angular-standalone-sample/src/app/redirect/redirect.component.ts @@ -0,0 +1,17 @@ +import { Component, OnInit } from '@angular/core'; +import { broadcastResponseToMainFrame } from '@azure/msal-browser/redirect-bridge'; + +@Component({ + selector: 'app-redirect', + standalone: true, + template: '

Processing authentication...

', +}) +export class RedirectComponent implements OnInit { + ngOnInit(): void { + // Call broadcastResponseToMainFrame when component initializes + broadcastResponseToMainFrame().catch((error: Error) => { + console.error('Error broadcasting response to main frame:', error); + }); + } +} + diff --git a/samples/msal-angular-samples/angular-standalone-sample/test/home.spec.ts b/samples/msal-angular-samples/angular-standalone-sample/test/home.spec.ts index 0c5d91af51..bb3c098c47 100644 --- a/samples/msal-angular-samples/angular-standalone-sample/test/home.spec.ts +++ b/samples/msal-angular-samples/angular-standalone-sample/test/home.spec.ts @@ -140,12 +140,8 @@ describe('/ (Home Page)', () => { if (!popupPage) { throw new Error('Popup window was not opened'); } - const popupWindowClosed = new Promise((resolve) => - popupPage.once('close', resolve) - ); await enterCredentials(popupPage, screenshot, username, accountPwd); - await popupWindowClosed; await page.waitForSelector("xpath/.//p[contains(., 'Login successful!')]", { timeout: 3000, diff --git a/samples/msal-browser-samples/ExpressSample/public/redirect.html b/samples/msal-browser-samples/ExpressSample/public/redirect.html new file mode 100644 index 0000000000..183b87e242 --- /dev/null +++ b/samples/msal-browser-samples/ExpressSample/public/redirect.html @@ -0,0 +1,19 @@ + + + + + + Redirect Page + + +

Processing authentication...

+ + + + + + + diff --git a/samples/msal-browser-samples/ExpressSample/server.js b/samples/msal-browser-samples/ExpressSample/server.js index 4443e5df5b..ef59f21b3b 100644 --- a/samples/msal-browser-samples/ExpressSample/server.js +++ b/samples/msal-browser-samples/ExpressSample/server.js @@ -183,11 +183,11 @@ function getCurrentVersionInfo() { } // Helper function to pass environment variables and version info to templates -const getEnvConfig = () => { +const getEnvConfig = (version) => { const config = { CLIENT_ID: process.env.CLIENT_ID, AUTHORITY: process.env.AUTHORITY, - REDIRECT_URI: process.env.REDIRECT_URI, + REDIRECT_URI: parseInt(version.current.substr(0,1)) < 5 ? process.env.REDIRECT_URI : `${process.env.REDIRECT_URI}/redirect`, POST_LOGOUT_REDIRECT_URI: process.env.POST_LOGOUT_REDIRECT_URI }; @@ -214,7 +214,7 @@ const getEnvConfig = () => { // Enhanced environment config with version info const getEnvConfigWithVersion = () => ({ - ...getEnvConfig(), + ...getEnvConfig(getCurrentVersionInfo()), version: getCurrentVersionInfo() }); @@ -334,6 +334,10 @@ app.get('/', (req, res) => { }); }); +app.get('/redirect', (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'redirect.html')); +}); + app.get('/profile', (req, res) => { res.render('profile', { title: 'MSAL Express Sample - Profile', diff --git a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/default/redirect.html b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/default/redirect.html index de0f168930..dcc8b0ed7d 100644 --- a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/default/redirect.html +++ b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/default/redirect.html @@ -1,18 +1,6 @@ - - - - - - Redirect Page - - -

Processing authentication...

- - - - - - + \ No newline at end of file diff --git a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/facebook-sample/redirect.html b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/facebook-sample/redirect.html index de0f168930..dcc8b0ed7d 100644 --- a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/facebook-sample/redirect.html +++ b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/facebook-sample/redirect.html @@ -1,18 +1,6 @@ - - - - - - Redirect Page - - -

Processing authentication...

- - - - - - + \ No newline at end of file diff --git a/samples/msal-react-samples/nextjs-sample/pages/_app.js b/samples/msal-react-samples/nextjs-sample/pages/_app.js index 4262dd9090..475a3c8190 100644 --- a/samples/msal-react-samples/nextjs-sample/pages/_app.js +++ b/samples/msal-react-samples/nextjs-sample/pages/_app.js @@ -43,6 +43,23 @@ export default function MyApp({ Component, emotionCache = clientSideEmotionCache const navigationClient = new CustomNavigationClient(router); msalInstance.setNavigationClient(navigationClient); + const isRedirectPage = router.pathname === '/redirect'; + + if (isRedirectPage) { + return ( + + + MSAL-React Next.js Sample + + + + + + + + ); + } + return ( diff --git a/samples/msal-react-samples/nextjs-sample/pages/redirect.js b/samples/msal-react-samples/nextjs-sample/pages/redirect.js new file mode 100644 index 0000000000..03d8e75d7c --- /dev/null +++ b/samples/msal-react-samples/nextjs-sample/pages/redirect.js @@ -0,0 +1,22 @@ +// This page serves the redirect bridge for MSAL authentication +// It's excluded from MsalProvider wrapper in _app.js to prevent MSAL from processing the hash +import { useEffect } from "react"; +import { broadcastResponseToMainFrame } from "@azure/msal-browser/redirect-bridge"; + +export default function Redirect() { + useEffect(() => { + // Call broadcastResponseToMainFrame when component mounts + broadcastResponseToMainFrame().catch((error) => { + console.error("Error broadcasting response to main frame:", error); + }); + }, []); + + return ( +
+

Processing authentication...

+
+ ); +} + + + diff --git a/samples/msal-react-samples/nextjs-sample/src/authConfig.js b/samples/msal-react-samples/nextjs-sample/src/authConfig.js index 41fa666d10..bf3e6881f8 100644 --- a/samples/msal-react-samples/nextjs-sample/src/authConfig.js +++ b/samples/msal-react-samples/nextjs-sample/src/authConfig.js @@ -3,7 +3,7 @@ export const msalConfig = { auth: { clientId: "b5c2e510-4a17-4feb-b219-e55aa5b74144", authority: "https://login.microsoftonline.com/common", - redirectUri: "/", + redirectUri: "/redirect", postLogoutRedirectUri: "/" }, system: { diff --git a/samples/msal-react-samples/nextjs-sample/test/home.spec.ts b/samples/msal-react-samples/nextjs-sample/test/home.spec.ts index ce991a323e..651df6d650 100644 --- a/samples/msal-react-samples/nextjs-sample/test/home.spec.ts +++ b/samples/msal-react-samples/nextjs-sample/test/home.spec.ts @@ -123,12 +123,8 @@ describe("/ (Home Page)", () => { if (!popupPage) { throw new Error('Popup window was not opened'); } - const popupWindowClosed = new Promise((resolve) => - popupPage.once("close", resolve) - ); await enterCredentials(popupPage, screenshot, username, accountPwd); - await popupWindowClosed; await page.waitForSelector("xpath/.//header[contains(., 'Welcome,')]"); await screenshot.takeScreenshot(page, "Popup closed"); diff --git a/samples/msal-react-samples/nextjs-sample/test/profile.spec.ts b/samples/msal-react-samples/nextjs-sample/test/profile.spec.ts index 218eaafe50..5860c7ed61 100644 --- a/samples/msal-react-samples/nextjs-sample/test/profile.spec.ts +++ b/samples/msal-react-samples/nextjs-sample/test/profile.spec.ts @@ -132,12 +132,8 @@ describe("/profile", () => { if (!popupPage) { throw new Error('Popup window was not opened'); } - const popupWindowClosed = new Promise((resolve) => - popupPage.once("close", resolve) - ); await enterCredentials(popupPage, screenshot, username, accountPwd); - await popupWindowClosed; await page.waitForSelector("xpath/.//header[contains(., 'Welcome,')]"); await screenshot.takeScreenshot(page, "Popup closed"); @@ -155,7 +151,7 @@ describe("/profile", () => { // Go to protected page await page.goto(`http://localhost:${port}/profile`); - + // Wait for Graph data to display await page.waitForSelector("xpath/.//div/ul/li[contains(., 'Name')]"); await screenshot.takeScreenshot(page, "Graph data acquired"); diff --git a/samples/msal-react-samples/react-router-sample/.env.development b/samples/msal-react-samples/react-router-sample/.env.development index 3a02ab9257..97ffc4e941 100644 --- a/samples/msal-react-samples/react-router-sample/.env.development +++ b/samples/msal-react-samples/react-router-sample/.env.development @@ -3,8 +3,4 @@ BROWSER=none REACT_APP_CLIENT_ID=ENTER_CLIENT_ID_HERE REACT_APP_AUTHORITY=https://login.microsoftonline.com/ENTER_TENANT_ID_HERE - -# When using popup and silent APIs, we recommend setting the redirectUri to a blank page -# or a page that does not implement MSAL. For more information, -# https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/login-user.md#redirecturi-considerations -REACT_APP_POPUP_REDIRECT_URI=/redirect \ No newline at end of file +REACT_APP_REDIRECT_URI=/redirect diff --git a/samples/msal-react-samples/react-router-sample/.env.e2e b/samples/msal-react-samples/react-router-sample/.env.e2e index 382b77aaa1..09fa875088 100644 --- a/samples/msal-react-samples/react-router-sample/.env.e2e +++ b/samples/msal-react-samples/react-router-sample/.env.e2e @@ -3,4 +3,4 @@ BROWSER=none REACT_APP_CLIENT_ID=b5c2e510-4a17-4feb-b219-e55aa5b74144 REACT_APP_AUTHORITY=https://login.microsoftonline.com/common -REACT_APP_POPUP_REDIRECT_URI=/ \ No newline at end of file +REACT_APP_REDIRECT_URI=/redirect diff --git a/samples/msal-react-samples/react-router-sample/public/redirect.html b/samples/msal-react-samples/react-router-sample/public/redirect.html deleted file mode 100644 index de0f168930..0000000000 --- a/samples/msal-react-samples/react-router-sample/public/redirect.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - Redirect Page - - -

Processing authentication...

- - - - - - diff --git a/samples/msal-react-samples/react-router-sample/src/App.js b/samples/msal-react-samples/react-router-sample/src/App.js index 363c8ab3c2..6756321e28 100644 --- a/samples/msal-react-samples/react-router-sample/src/App.js +++ b/samples/msal-react-samples/react-router-sample/src/App.js @@ -1,4 +1,4 @@ -import { Routes, Route, useNavigate } from "react-router-dom"; +import { Routes, Route, useNavigate, useLocation } from "react-router-dom"; // Material-UI imports import Grid from "@mui/material/Grid"; @@ -11,6 +11,7 @@ import { PageLayout } from "./ui-components/PageLayout"; import { Home } from "./pages/Home"; import { Profile } from "./pages/Profile"; import { Logout } from "./pages/Logout"; +import { Redirect } from "./pages/Redirect"; // Class-based equivalents of "Profile" component import { ProfileWithMsal } from "./pages/ProfileWithMsal"; @@ -20,9 +21,17 @@ import { ProfileUseMsalAuthenticationHook } from "./pages/ProfileUseMsalAuthenti function App({ pca }) { // The next 3 lines are optional. This is how you configure MSAL to take advantage of the router's navigate functions when MSAL redirects between pages in your app const navigate = useNavigate(); + const location = useLocation(); const navigationClient = new CustomNavigationClient(navigate); pca.setNavigationClient(navigationClient); + // Don't wrap redirect page in MsalProvider to prevent MSAL from consuming the auth response + const isRedirectPage = location.pathname === '/redirect'; + + if (isRedirectPage) { + return ; + } + return ( diff --git a/samples/msal-react-samples/react-router-sample/src/authConfig.js b/samples/msal-react-samples/react-router-sample/src/authConfig.js index fbd983d578..0e4e565f1c 100644 --- a/samples/msal-react-samples/react-router-sample/src/authConfig.js +++ b/samples/msal-react-samples/react-router-sample/src/authConfig.js @@ -16,7 +16,7 @@ export const msalConfig = { auth: { clientId: process.env.REACT_APP_CLIENT_ID, authority: process.env.REACT_APP_AUTHORITY, - redirectUri: "/", + redirectUri: process.env.REACT_APP_REDIRECT_URI, postLogoutRedirectUri: "/", onRedirectNavigate: () => !BrowserUtils.isInIframe() }, diff --git a/samples/msal-react-samples/react-router-sample/src/pages/ProfileRawContext.jsx b/samples/msal-react-samples/react-router-sample/src/pages/ProfileRawContext.jsx index 146d5ed917..3347d27e19 100644 --- a/samples/msal-react-samples/react-router-sample/src/pages/ProfileRawContext.jsx +++ b/samples/msal-react-samples/react-router-sample/src/pages/ProfileRawContext.jsx @@ -51,7 +51,7 @@ class ProfileContent extends Component { componentDidUpdate() { this.setGraphData(); } - + render() { return ( @@ -62,23 +62,22 @@ class ProfileContent extends Component { } /** - * This class is using "withMsal" HOC. It passes down the msalContext + * This class is using "withMsal" HOC. It passes down the msalContext * as a prop to its children. */ class Profile extends Component { render() { - + const authRequest = { - ...loginRequest, - redirectUri: process.env.REACT_APP_POPUP_REDIRECT_URI // e.g. /redirect + ...loginRequest }; return ( - @@ -87,4 +86,4 @@ class Profile extends Component { } } -export const ProfileRawContext = Profile \ No newline at end of file +export const ProfileRawContext = Profile diff --git a/samples/msal-react-samples/react-router-sample/src/pages/ProfileUseMsalAuthenticationHook.jsx b/samples/msal-react-samples/react-router-sample/src/pages/ProfileUseMsalAuthenticationHook.jsx index e332c9557e..43bdf3fe11 100644 --- a/samples/msal-react-samples/react-router-sample/src/pages/ProfileUseMsalAuthenticationHook.jsx +++ b/samples/msal-react-samples/react-router-sample/src/pages/ProfileUseMsalAuthenticationHook.jsx @@ -17,7 +17,6 @@ const ProfileContent = () => { const [graphData, setGraphData] = useState(null); const { result, error } = useMsalAuthentication(InteractionType.Popup, { ...loginRequest, - redirectUri: process.env.REACT_APP_POPUP_REDIRECT_URI, // e.g. /redirect }); useEffect(() => { @@ -35,7 +34,7 @@ const ProfileContent = () => { callMsGraph().then(response => setGraphData(response)); } }, [error, result, graphData]); - + if (error) { return ; } @@ -49,4 +48,4 @@ const ProfileContent = () => { export function ProfileUseMsalAuthenticationHook() { return -}; \ No newline at end of file +}; diff --git a/samples/msal-react-samples/react-router-sample/src/pages/Redirect.jsx b/samples/msal-react-samples/react-router-sample/src/pages/Redirect.jsx new file mode 100644 index 0000000000..dd4529aaf3 --- /dev/null +++ b/samples/msal-react-samples/react-router-sample/src/pages/Redirect.jsx @@ -0,0 +1,20 @@ +// This page serves the redirect bridge for MSAL authentication +// It's excluded from MsalProvider wrapper in App.js to prevent MSAL from processing the hash +import { useEffect } from "react"; +import { broadcastResponseToMainFrame } from "@azure/msal-browser/redirect-bridge"; + +export function Redirect() { + useEffect(() => { + // Call broadcastResponseToMainFrame when component mounts + broadcastResponseToMainFrame().catch((error) => { + console.error("Error broadcasting response to main frame:", error); + }); + }, []); + + return ( +
+

Processing authentication...

+
+ ); +} + diff --git a/samples/msal-react-samples/react-router-sample/src/ui-components/SignInButton.jsx b/samples/msal-react-samples/react-router-sample/src/ui-components/SignInButton.jsx index dc792b840a..894342c854 100644 --- a/samples/msal-react-samples/react-router-sample/src/ui-components/SignInButton.jsx +++ b/samples/msal-react-samples/react-router-sample/src/ui-components/SignInButton.jsx @@ -15,14 +15,8 @@ export const SignInButton = () => { setAnchorEl(null); if (loginType === "popup") { - /** - * When using popup and silent APIs, we recommend setting the redirectUri to a blank page or a page - * that does not implement MSAL. Keep in mind that all redirect routes must be registered with the application - * For more information, visit: https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/login-user.md#redirecturi-considerations - */ instance.loginPopup({ ...loginRequest, - redirectUri: process.env.REACT_APP_POPUP_REDIRECT_URI, // e.g. /redirect }); } else if (loginType === "redirect") { instance.loginRedirect(loginRequest); @@ -57,4 +51,4 @@ export const SignInButton = () => {
) -}; \ No newline at end of file +}; diff --git a/samples/msal-react-samples/react-router-sample/test/home.spec.ts b/samples/msal-react-samples/react-router-sample/test/home.spec.ts index a8f0540fc5..eb2c005709 100644 --- a/samples/msal-react-samples/react-router-sample/test/home.spec.ts +++ b/samples/msal-react-samples/react-router-sample/test/home.spec.ts @@ -121,12 +121,8 @@ describe("/ (Home Page)", () => { if (!popupPage) { throw new Error('Popup window was not opened'); } - const popupWindowClosed = new Promise((resolve) => - popupPage.once("close", resolve) - ); await enterCredentials(popupPage, screenshot, username, accountPwd); - await popupWindowClosed; await page.waitForSelector("xpath/.//header[contains(., 'Welcome,')]", { timeout: 3000, }); diff --git a/samples/msal-react-samples/react-router-sample/test/profile.spec.ts b/samples/msal-react-samples/react-router-sample/test/profile.spec.ts index e38be2d133..4c9de8b3e5 100644 --- a/samples/msal-react-samples/react-router-sample/test/profile.spec.ts +++ b/samples/msal-react-samples/react-router-sample/test/profile.spec.ts @@ -89,12 +89,8 @@ describe("/profile", () => { if (!popupPage) { throw new Error('Popup window was not opened'); } - const popupWindowClosed = new Promise((resolve) => - popupPage.once("close", resolve) - ); await enterCredentials(popupPage, screenshot, username, accountPwd); - await popupWindowClosed; // Wait for Graph data to display await page.waitForSelector("xpath/.//div/ul/li[contains(., 'Name')]", { @@ -142,12 +138,8 @@ describe("/profile", () => { if (!popupPage) { throw new Error('Popup window was not opened'); } - const popupWindowClosed = new Promise((resolve) => - popupPage.once("close", resolve) - ); await enterCredentials(popupPage, screenshot, username, accountPwd); - await popupWindowClosed; await page.waitForSelector("xpath/.//header[contains(., 'Welcome,')]", { timeout: 3000, }); @@ -175,42 +167,4 @@ describe("/profile", () => { // Verify tokens are in cache await verifyTokenStore(BrowserCache, ["User.Read"]); }); - - it("MsalAuthenticationTemplate - renders loading component when popup is open, then error component when loginPopup is cancelled", async () => { - const testName = "MsalAuthenticationTemplateError"; - const screenshot = new Screenshot( - `${SCREENSHOT_BASE_FOLDER_NAME}/${testName}` - ); - await screenshot.takeScreenshot(page, "Home page loaded"); - - // Navigate to /profile and expect popup to be opened without interaction - const newPopupWindowPromise = new Promise((resolve) => - page.once("popup", resolve) - ); - await page.goto(`http://localhost:${port}/profile`); - await screenshot.takeScreenshot(page, "Profile page loaded"); - const popupPage = await newPopupWindowPromise; - if (!popupPage) { - throw new Error('Popup window was not opened'); - } - const popupWindowClosed = new Promise((resolve) => - popupPage.once("close", resolve) - ); - - // Wait until the popup has navigated to login page - await popupPage.waitForNavigation({ waitUntil: "networkidle0" }); - - await page.waitForSelector( - "xpath/.//h6[contains(., 'Authentication in progress...')]" - ); - await screenshot.takeScreenshot(page, "Loading component rendered"); - - await popupPage.close(); - await popupWindowClosed; - - await page.waitForSelector( - "xpath/.//h6[contains(., 'An Error Occurred: user_cancelled')]" - ); - await screenshot.takeScreenshot(page, "Error component rendered"); - }); }); diff --git a/samples/msal-react-samples/react-router-sample/test/profileRawContext.spec.ts b/samples/msal-react-samples/react-router-sample/test/profileRawContext.spec.ts index daca110064..a43746998a 100644 --- a/samples/msal-react-samples/react-router-sample/test/profileRawContext.spec.ts +++ b/samples/msal-react-samples/react-router-sample/test/profileRawContext.spec.ts @@ -89,12 +89,8 @@ describe("/profileRawContext", () => { if (!popupPage) { throw new Error('Popup window was not opened'); } - const popupWindowClosed = new Promise((resolve) => - popupPage.once("close", resolve) - ); await enterCredentials(popupPage, screenshot, username, accountPwd); - await popupWindowClosed; // Wait for Graph data to display await page.waitForSelector("xpath/.//div/ul/li[contains(., 'Name')]", { From 48e01bfb96019e9c8a8b9e51ab43b35410086f2a Mon Sep 17 00:00:00 2001 From: Konstantin Shabelko Date: Fri, 21 Nov 2025 14:33:30 -0500 Subject: [PATCH 20/45] - Address review comments - Add COOP migration guide --- ...-3b6b0844-0608-4d31-bba7-7c7177ad52f1.json | 2 +- ...-cde5a6b6-5532-4445-945e-4ca192b96714.json | 2 +- docs/errors.md | 8 +- .../apiReview/msal-browser.api.md | 49 ++- lib/msal-browser/docs/v4-migration.md | 92 ++++- .../src/controllers/StandardController.ts | 3 +- .../src/error/BrowserAuthErrorCodes.ts | 4 +- lib/msal-browser/src/redirect_bridge/index.ts | 74 ++-- lib/msal-browser/src/request/PopupRequest.ts | 32 +- lib/msal-browser/src/utils/BrowserUtils.ts | 109 +++++- .../broadcastResponseToMainFrame.spec.ts | 370 +++++++++++++++++- .../test/utils/BrowserUtils.spec.ts | 16 +- 12 files changed, 664 insertions(+), 97 deletions(-) diff --git a/change/@azure-msal-browser-3b6b0844-0608-4d31-bba7-7c7177ad52f1.json b/change/@azure-msal-browser-3b6b0844-0608-4d31-bba7-7c7177ad52f1.json index ef8a5f4986..03a15b56d6 100644 --- a/change/@azure-msal-browser-3b6b0844-0608-4d31-bba7-7c7177ad52f1.json +++ b/change/@azure-msal-browser-3b6b0844-0608-4d31-bba7-7c7177ad52f1.json @@ -1,6 +1,6 @@ { "type": "minor", - "comment": "Implement redirect bridge to support COOP #8118", + "comment": "Implement redirect bridge to support COOP [#8118](https://github.com/AzureAD/microsoft-authentication-library-for-js/pull/8118)", "packageName": "@azure/msal-browser", "email": "kshabelko@microsoft.com", "dependentChangeType": "patch" diff --git a/change/@azure-msal-common-cde5a6b6-5532-4445-945e-4ca192b96714.json b/change/@azure-msal-common-cde5a6b6-5532-4445-945e-4ca192b96714.json index e05d50ad97..625d380523 100644 --- a/change/@azure-msal-common-cde5a6b6-5532-4445-945e-4ca192b96714.json +++ b/change/@azure-msal-common-cde5a6b6-5532-4445-945e-4ca192b96714.json @@ -1,6 +1,6 @@ { "type": "minor", - "comment": "Implement redirect bridge to support COOP #8118", + "comment": "Implement redirect bridge to support COOP [#8118](https://github.com/AzureAD/microsoft-authentication-library-for-js/pull/8118)", "packageName": "@azure/msal-common", "email": "kshabelko@microsoft.com", "dependentChangeType": "patch" diff --git a/docs/errors.md b/docs/errors.md index 79dd9d7f79..be51952d9e 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -558,9 +558,9 @@ If you are unable to figure out why this error is being thrown please [open an i - Refresh the page. Does the error go away? - Open your application in a new tab. Does the error go away? -### `interaction_in_progress_overridden` +### `interaction_in_progress_cancelled` -- The current interaction was overridden by a new interaction request with `overrideInteractionInProgress` set to `true`. +- The current interaction was cancelled by a new interaction request with `overrideInteractionInProgress` set to `true`. This error is thrown when an existing popup interaction is cancelled because a new popup request was initiated with the `overrideInteractionInProgress` flag set to `true`. This is not necessarily an error condition - it indicates that the previous interaction was intentionally cancelled to allow a new one to proceed. @@ -586,7 +586,7 @@ const request2 = { }; const promise2 = msalInstance.acquireTokenPopup(request2); -// promise1 will reject with interaction_in_progress_overridden +// promise1 will reject with interaction_in_progress_cancelled // promise2 will proceed normally ``` @@ -704,7 +704,7 @@ const msalConfig = { ### `redirect_bridge_empty_response` -- The redirect bridge returned an empty, indicating the redirect bridge script may have been modified or replaced. +- The redirect bridge returned an empty response, indicating the redirect bridge script may have been modified or replaced. ### `redirect_in_iframe` diff --git a/lib/msal-browser/apiReview/msal-browser.api.md b/lib/msal-browser/apiReview/msal-browser.api.md index 53c8efe61a..a43ddfbcf4 100644 --- a/lib/msal-browser/apiReview/msal-browser.api.md +++ b/lib/msal-browser/apiReview/msal-browser.api.md @@ -226,7 +226,7 @@ declare namespace BrowserAuthErrorCodes { unableToParseState, stateInteractionTypeMismatch, interactionInProgress, - interactionInProgressOverridden, + interactionInProgressCancelled, popupWindowError, emptyWindowError, userCancelled, @@ -406,6 +406,7 @@ export type BrowserTelemetryOptions = { declare namespace BrowserUtils { export { + parseAuthResponseFromUrl, clearHash, replaceHash, isInIframe, @@ -762,10 +763,10 @@ export { InProgressPerformanceEvent } // @public (undocumented) const interactionInProgress = "interaction_in_progress"; -// Warning: (ae-missing-release-tag) "interactionInProgressOverridden" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "interactionInProgressCancelled" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -const interactionInProgressOverridden = "interaction_in_progress_overridden"; +const interactionInProgressCancelled = "interaction_in_progress_cancelled"; export { InteractionRequiredAuthError } @@ -1083,6 +1084,38 @@ const noTokenRequestCacheError = "no_token_request_cache_error"; // @public (undocumented) export const OIDC_DEFAULT_SCOPES: string[]; +// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag +// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" +// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag +// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" +// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag +// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" +// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag +// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" +// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag +// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" +// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag +// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" +// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag +// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" +// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag +// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" +// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag +// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" +// Warning: (ae-missing-release-tag) "parseAuthResponseFromUrl" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +function parseAuthResponseFromUrl(): { + params: URLSearchParams; + payload: string; + urlHash: string; + urlQuery: string; + libraryState: { + id: string; + meta: Record; + }; +}; + export { PerformanceCallbackFunction } export { PerformanceEvent } @@ -1107,6 +1140,16 @@ export type PopupPosition = { left: number; }; +// Warning: (tsdoc-code-fence-opening-indent) The opening backtick for a code fence must appear at the start of the line +// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" +// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag +// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" +// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" +// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" +// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag +// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag +// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag +// Warning: (tsdoc-code-fence-opening-indent) The opening backtick for a code fence must appear at the start of the line // Warning: (ae-missing-release-tag) "PopupRequest" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public diff --git a/lib/msal-browser/docs/v4-migration.md b/lib/msal-browser/docs/v4-migration.md index 9d9476f411..b28517cb36 100644 --- a/lib/msal-browser/docs/v4-migration.md +++ b/lib/msal-browser/docs/v4-migration.md @@ -289,7 +289,7 @@ const authRequest = { scopes: ["SAMPLE_SCOPE"], extraQueryParamters: { // Will be sent in query string to /authorize and /token - "dc": "DC_VALUE", + "dc": "DC_VALUE", "slice": "SLICE_VALUE" }, extraParameters: { @@ -303,7 +303,7 @@ const authRequest = { httpMethod: "POST", // default is "GET" -> Determines method for "/authorize" call. Calls to "/token" are always POST extraQueryParamters: { // Will be sent in query string to /authorize and /token - "dc": "DC_VALUE", + "dc": "DC_VALUE", "slice": "SLICE_VALUE" }, extraParameters: { @@ -314,6 +314,94 @@ const authRequest = { > Note: In cases where MSAL determines `extraParameters` must be encoded into the URL string, `extraParameters` will be merged with `extraQueryParams` in a way that will cause same-named parameters to be overwritten. In these cases, the value for the parameter in `extraParameters` will take precedence over the value in the `extraQueryParams`. +### Cross-Origin-Opener-Policy (COOP) Support + +MSAL Browser v5 introduces built-in support for Cross-Origin-Opener-Policy (COOP), which enhances security by isolating your browsing context. When COOP is enabled on your application, popup and iframe-based authentication flows are restricted. MSAL v5 provides a redirect bridge mechanism to handle authentication in COOP-enabled environments. + +#### What Changed + +When your application has COOP headers enabled (e.g., `Cross-Origin-Opener-Policy: same-origin`), traditional popup and silent iframe authentication flows will fail because the authentication window cannot communicate back to the main application window. MSAL v5 solves this by introducing a redirect bridge pattern. + +**All authentication flows** (`acquireTokenSilent()`, `ssoSilent()`, `loginPopup()`, and `loginRedirect()`) now use the redirect bridge when COOP is enabled. The redirect bridge handles the authentication response differently based on the flow: + +- **Popup and silent flows**: The redirect bridge broadcasts the authentication response to the main application window using the BroadcastChannel API +- **Redirect flow**: The redirect bridge navigates back to your application's home page with the authentication response in the URL + +#### How It Works + +1. **Main application**: Your COOP-enabled application initiates authentication using `loginPopup()`, `ssoSilent()`, or `loginRedirect()` +2. **Redirect**: MSAL opens a popup/iframe/window to an authority page +3. **Authentication flow**: The authority page completes the OAuth flow and receives the auth response +4. **Response handling**: The redirect page uses the new `broadcastResponseToMainFrame()` function which: + - For **popup/silent flows**: Broadcasts the response to the main window via BroadcastChannel API + - For **redirect flows**: Navigates to your application's home page with the auth response +5. **Token acquisition**: The main application receives the response and completes token acquisition + +#### Migration Steps + +##### 1. Set up your redirect URI page + +Create a separate HTML page (e.g., `redirect.html`) that will serve as your redirect bridge. **This page must NOT include COOP headers.** + +```html + + + + + Redirect + + + + + +``` + +##### 2. Configure your web server + +Ensure your redirect URI page is served **without** COOP headers, while your main application pages have COOP enabled: + +```javascript +// Example using Express.js +app.get("/redirect", (req, res) => { + // DO NOT set COOP headers for redirect page + res.sendFile(__dirname + "/redirect.html"); +}); + +app.get("*", (req, res) => { + // Set COOP headers for all other pages + res.setHeader("Cross-Origin-Opener-Policy", "same-origin"); + res.sendFile(__dirname + "/index.html"); +}); +``` + +##### 3. Update your MSAL configuration + +Set the `redirectUri` in your MSAL configuration to point to your redirect bridge page: + +```javascript +const msalConfig = { + auth: { + clientId: "{your-client-id}", + authority: "https://login.microsoftonline.com/common", + redirectUri: "https://{your-app-home-page}/redirect", // Point to your redirect bridge page + }, +}; + +const pca = new PublicClientApplication(msalConfig); +``` + +- For a complete working example of COOP support with the redirect bridge, see the [COOP Sample Application](https://github.com/AzureAD/microsoft-authentication-library-for-js/tree/dev/samples/msal-browser-samples/COOP). +- For more details on COOP and security considerations, see the [Cross-Origin-Opener-Policy documentation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cross-Origin-Opener-Policy). + + ## Behavioral Breaking Changes ### Event types and InteractionStatus changes diff --git a/lib/msal-browser/src/controllers/StandardController.ts b/lib/msal-browser/src/controllers/StandardController.ts index e8d49a308a..73f56c0bc5 100644 --- a/lib/msal-browser/src/controllers/StandardController.ts +++ b/lib/msal-browser/src/controllers/StandardController.ts @@ -751,7 +751,8 @@ export class StandardController implements IController { this.browserStorage.setInteractionInProgress( true, INTERACTION_TYPE.SIGNIN, - request.overrideInteractionInProgress + request.overrideInteractionInProgress, + correlationId ); } catch (e) { // Since this function is syncronous we need to reject diff --git a/lib/msal-browser/src/error/BrowserAuthErrorCodes.ts b/lib/msal-browser/src/error/BrowserAuthErrorCodes.ts index b244740041..a40acb7c51 100644 --- a/lib/msal-browser/src/error/BrowserAuthErrorCodes.ts +++ b/lib/msal-browser/src/error/BrowserAuthErrorCodes.ts @@ -15,8 +15,8 @@ export const hashDoesNotContainKnownProperties = export const unableToParseState = "unable_to_parse_state"; export const stateInteractionTypeMismatch = "state_interaction_type_mismatch"; export const interactionInProgress = "interaction_in_progress"; -export const interactionInProgressOverridden = - "interaction_in_progress_overridden"; +export const interactionInProgressCancelled = + "interaction_in_progress_cancelled"; export const popupWindowError = "popup_window_error"; export const emptyWindowError = "empty_window_error"; export const userCancelled = "user_cancelled"; diff --git a/lib/msal-browser/src/redirect_bridge/index.ts b/lib/msal-browser/src/redirect_bridge/index.ts index 2904c4eafe..8c6bedfd4f 100644 --- a/lib/msal-browser/src/redirect_bridge/index.ts +++ b/lib/msal-browser/src/redirect_bridge/index.ts @@ -3,22 +3,24 @@ * Licensed under the MIT License. */ -import { ProtocolUtils } from "@azure/msal-common/browser"; -import { clearHash } from "../utils/BrowserUtils.js"; -import { base64Decode } from "../encode/Base64Decode.js"; +import { parseAuthResponseFromUrl } from "../utils/BrowserUtils.js"; import * as BrowserUtils from "../utils/BrowserUtils.js"; -import { ApiId, InteractionType } from "../utils/BrowserConstants.js"; +import { + ApiId, + InteractionType, + TemporaryCacheKeys, +} from "../utils/BrowserConstants.js"; import { NavigationOptions } from "../navigation/NavigationOptions.js"; import { DEFAULT_REDIRECT_TIMEOUT_MS } from "../config/Configuration.js"; import { NavigationClient } from "../navigation/NavigationClient.js"; +import { PREFIX } from "../cache/CacheKeys.js"; /** * 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 * - * @param {string} navigateToUrl - Optional URL to navigate to for redirect scenario. - * If not provided, defaults to the application's homepage. + * @param {NavigationClient} navigationClient - Optional navigation client for redirect scenario. * * @returns {Promise} A promise that resolves when the response has been broadcast and cleanup is complete. * @@ -27,51 +29,53 @@ import { NavigationClient } from "../navigation/NavigationClient.js"; * @throws {Error} If the state is missing required 'id' or 'meta' attributes. */ export async function broadcastResponseToMainFrame( - navigateToUrl?: string + navigationClient?: NavigationClient ): Promise { - // 1) Determine which URL container carries the payload - const hasHash = !!window.location.hash && window.location.hash.length > 1; - const hash = hasHash ? window.location.hash : window.location.search; - if (!hash) { - throw new Error("No auth payload found on URL (hash or query)"); - } - - // Strip leading ? / # - const payload = hash.substring(1); - const params = new URLSearchParams(payload); - - const state = params.get("state"); - if (!state) { - clearHash(window); - throw new Error("Missing state on redirect URL"); + let parsedResponse; + try { + parsedResponse = parseAuthResponseFromUrl(); + } catch (error) { + // Clear hash before re-throwing parse errors + BrowserUtils.clearHash(window); + throw error; } - const { libraryState } = ProtocolUtils.parseRequestState( - base64Decode, - state - ); + const { params, payload, urlHash, urlQuery, libraryState } = parsedResponse; const { id, meta } = libraryState; - if (!id || !meta) { - clearHash(window); - throw new Error("Missing state 'id' and/or 'meta' attributes"); - } - if (meta && meta["interactionType"] === InteractionType.Redirect) { - const navigationClient = new NavigationClient(); + if (meta["interactionType"] === InteractionType.Redirect) { + const navClient = navigationClient || new NavigationClient(); const navigationOptions: NavigationOptions = { apiId: ApiId.handleRedirectPromise, noHistory: true, timeout: DEFAULT_REDIRECT_TIMEOUT_MS, }; + + /* + * Retrieve the original navigation URL from sessionStorage + */ + let navigateToUrl = ""; + const clientId = params.get("client_id"); + if (clientId) { + try { + const cacheKey = `${PREFIX}.${clientId}.${TemporaryCacheKeys.ORIGIN_URI}`; + navigateToUrl = window.sessionStorage.getItem(cacheKey) || ""; + } catch (e) { + // SessionStorage access may fail in some contexts, use default + } + } + + // Reconstruct full URL with auth response + const fullUrlHash = `${urlQuery}${urlHash}`; const homepage = `${ navigateToUrl || BrowserUtils.getHomepage() - }${hash}`; - await navigationClient.navigateInternal(homepage, navigationOptions); + }${fullUrlHash}`; + await navClient.navigateInternal(homepage, navigationOptions); return; } - clearHash(window); + BrowserUtils.clearHash(window); // Send the raw URL payload to the main frame const channel = new BroadcastChannel(id); channel.postMessage({ diff --git a/lib/msal-browser/src/request/PopupRequest.ts b/lib/msal-browser/src/request/PopupRequest.ts index e6a0a6992d..9a7e9d6399 100644 --- a/lib/msal-browser/src/request/PopupRequest.ts +++ b/lib/msal-browser/src/request/PopupRequest.ts @@ -32,7 +32,37 @@ import { PopupWindowAttributes } from "./PopupWindowAttributes.js"; * - nonce - A value included in the request that is returned in the id token. A randomly generated unique value is typically used to mitigate replay attacks. * - popupWindowAttributes - Optional popup window attributes. popupSize with height and width, and popupPosition with top and left can be set. * - popupWindowParent - Optional window object to use as the parent when opening popup windows. Uses global `window` if not given. - * - overrideInteractionInProgress - Optional flag to allow overriding an existing interaction_in_progress state for popup flows. When set to true, if another interaction is in progress, it will be cancelled and the new popup flow will proceed. This is useful when a user has cancelled a popup or when recovering from errors. Default is false. + * - overrideInteractionInProgress - Optional flag to allow overriding an existing interaction_in_progress state for popup flows. + * + * **WARNING**: Use with caution! Setting this to true will cancel any pending popup interaction and start a new one. + * This can lead to unexpected behavior if not handled carefully. + * + * When set to true: + * - If another popup interaction is currently in progress, it will be forcefully cancelled + * - The pending interaction will reject with an `interaction_in_progress_cancelled` error + * - The new popup flow will proceed immediately + * + * Valid use cases: + * - Recovering from errors where the user cancelled a popup (popup was closed without completing auth) + * - Implementing custom error recovery flows + * - Providing a "retry" mechanism after a failed popup interaction + * + * Default: false + * + * Example usage: + * ```typescript + * try { + * await msalInstance.loginPopup(loginRequest); + * } catch (error) { + * if (error.errorCode === 'interaction_in_progress') { + * // User wants to retry - override the existing interaction + * await msalInstance.loginPopup({ + * ...loginRequest, + * overrideInteractionInProgress: true + * }); + * } + * } + * ``` */ export type PopupRequest = Partial< diff --git a/lib/msal-browser/src/utils/BrowserUtils.ts b/lib/msal-browser/src/utils/BrowserUtils.ts index 1e34fcc585..e33836bffb 100644 --- a/lib/msal-browser/src/utils/BrowserUtils.ts +++ b/lib/msal-browser/src/utils/BrowserUtils.ts @@ -32,6 +32,87 @@ import { } from "../error/BrowserAuthErrorCodes.js"; import { base64Decode } from "../encode/Base64Decode.js"; +/** + * Extracts and parses the authentication response from URL (hash and/or query string). + * This is a shared utility used across multiple components in msal-browser. + * + * @returns {Object} An object containing the parsed state information and URL parameters. + * @returns {URLSearchParams} params - The parsed URL parameters from the payload. + * @returns {string} payload - The combined query string and hash content. + * @returns {string} urlHash - The original URL hash. + * @returns {string} urlQuery - The original URL query string. + * @returns {LibraryStateObject} libraryState - The decoded library state from the state parameter. + * + * @throws {Error} If no authentication payload is found in the URL. + * @throws {Error} If the state parameter is missing. + * @throws {Error} If the state is missing required 'id' or 'meta' attributes. + */ +export function parseAuthResponseFromUrl(): { + params: URLSearchParams; + payload: string; + urlHash: string; + urlQuery: string; + libraryState: { + id: string; + meta: Record; + }; +} { + // Extract both hash and query string to support hybrid response format + const urlHash = window.location.hash; + const urlQuery = window.location.search; + + // Combine query string and hash for full payload + let payload = ""; + if (urlQuery && urlQuery.length > 1) { + // Verify it starts with '?' before stripping + if (urlQuery.charAt(0) === "?") { + payload = urlQuery.substring(1); + } else { + payload = urlQuery; + } + } + if (urlHash && urlHash.length > 1) { + // Verify it starts with '#' before stripping + const hashContent = + urlHash.charAt(0) === "#" ? urlHash.substring(1) : urlHash; + // Append hash fragment + payload = payload ? `${payload}${hashContent}` : hashContent; + } + + if (!payload) { + throw new Error("No auth payload found on URL (hash or query)"); + } + + // Parse state from URL parameters + const params = new URLSearchParams(payload); + + const state = params.get("state"); + if (!state) { + throw new Error("Missing state on redirect URL"); + } + + const { libraryState } = ProtocolUtils.parseRequestState( + base64Decode, + state + ); + + const { id, meta } = libraryState; + if (!id || !meta) { + throw new Error("Missing state 'id' and/or 'meta' attributes"); + } + + return { + params, + payload, + urlHash, + urlQuery, + libraryState: { + id, + meta, + }, + }; +} + /** * Clears hash from window url. */ @@ -72,28 +153,14 @@ export function isInPopup(): boolean { return false; } - const hasHash = !!window.location.hash && window.location.hash.length > 1; - const hash = hasHash ? window.location.hash : window.location.search; - if (!hash) { - return false; - } - - // Strip leading ? / # - const payload = hash.substring(1); - const params = new URLSearchParams(payload); - - const state = params.get("state"); - if (!state) { + try { + const { libraryState } = parseAuthResponseFromUrl(); + const { meta } = libraryState; + return meta["interactionType"] === InteractionType.Popup; + } catch (e) { + // If parsing fails (no state, invalid URL, etc.), we're not in a popup return false; } - - const { libraryState } = ProtocolUtils.parseRequestState( - base64Decode, - state - ); - - const { meta } = libraryState; - return !!(meta && meta["interactionType"] === InteractionType.Popup); } /** @@ -134,7 +201,7 @@ export function cancelPendingBridgeResponse( activeBridgeMonitor.channel.close(); activeBridgeMonitor.reject( createBrowserAuthError( - BrowserAuthErrorCodes.interactionInProgressOverridden + BrowserAuthErrorCodes.interactionInProgressCancelled ) ); diff --git a/lib/msal-browser/test/redirect_bridge/broadcastResponseToMainFrame.spec.ts b/lib/msal-browser/test/redirect_bridge/broadcastResponseToMainFrame.spec.ts index c61d0c1ace..a8e4b07de1 100644 --- a/lib/msal-browser/test/redirect_bridge/broadcastResponseToMainFrame.spec.ts +++ b/lib/msal-browser/test/redirect_bridge/broadcastResponseToMainFrame.spec.ts @@ -5,20 +5,41 @@ import { broadcastResponseToMainFrame } from "../../src/redirect_bridge/index.js"; import { NavigationClient } from "../../src/navigation/NavigationClient.js"; -import { clearHash } from "../../src/utils/BrowserUtils.js"; +import { + clearHash, + parseAuthResponseFromUrl, +} from "../../src/utils/BrowserUtils.js"; import { TEST_HASHES, TEST_STATE_VALUES, RANDOM_TEST_GUID, } from "../utils/StringConstants.js"; -jest.mock("../../src/utils/BrowserUtils.js"); +jest.mock("../../src/utils/BrowserUtils.js", () => ({ + ...jest.requireActual("../../src/utils/BrowserUtils.js"), + clearHash: jest.fn(), +})); jest.mock("../../src/navigation/NavigationClient.js"); describe("broadcastResponseToMainFrame", () => { let mockNavigationClient: jest.Mocked; + let mockSessionStorage: { [key: string]: string }; + let originalLocation: Location; + + beforeAll(() => { + // Save original location + originalLocation = window.location; + }); beforeEach(() => { + // Mock window.location with a fresh object for each test + delete (window as any).location; + (window as any).location = { + ...originalLocation, + hash: "", + search: "", + }; + // Mock window.close window.close = jest.fn(); @@ -32,12 +53,37 @@ describe("broadcastResponseToMainFrame", () => { // Mock clearHash (clearHash as jest.Mock).mockImplementation(() => {}); + + // Mock sessionStorage + mockSessionStorage = {}; + Object.defineProperty(window, "sessionStorage", { + value: { + getItem: jest.fn( + (key: string) => mockSessionStorage[key] || null + ), + setItem: jest.fn((key: string, value: string) => { + mockSessionStorage[key] = value; + }), + removeItem: jest.fn((key: string) => { + delete mockSessionStorage[key]; + }), + clear: jest.fn(() => { + mockSessionStorage = {}; + }), + }, + writable: true, + }); }); afterEach(() => { jest.clearAllMocks(); jest.restoreAllMocks(); - window.location.hash = ""; + mockSessionStorage = {}; + }); + + afterAll(() => { + // Restore original location + (window as any).location = originalLocation; }); describe("Error cases", () => { @@ -165,27 +211,168 @@ describe("broadcastResponseToMainFrame", () => { expect(window.close).not.toHaveBeenCalled(); }); - it("navigates to custom URL for redirect flow when navigateToUrl is provided", async () => { - const customUrl = "https://localhost:8081/custom-page.html"; + it("uses sessionStorage URL when client_id is present", async () => { + const testClientId = "test-client-id-123"; + const cachedOriginUrl = "https://localhost:8081/custom-page.html"; + + // Set up sessionStorage with cached origin URL + mockSessionStorage[`msal.${testClientId}.request.origin`] = + cachedOriginUrl; + window.location.hash = TEST_HASHES.TEST_SUCCESS_CODE_HASH_REDIRECT; - await broadcastResponseToMainFrame(customUrl); + // Mock URLSearchParams to return our test client_id + const originalURLSearchParams = global.URLSearchParams; + global.URLSearchParams = jest.fn().mockImplementation((query) => { + const params = new originalURLSearchParams(query); + params.get = jest.fn((key) => { + if (key === "client_id") return testClientId; + return new originalURLSearchParams(query).get(key); + }); + return params; + }) as any; - // Verify navigation was called with custom URL + hash + await broadcastResponseToMainFrame(); + + // Verify navigation was called with cached URL from sessionStorage expect(mockNavigationClient.navigateInternal).toHaveBeenCalledWith( - `${customUrl}${TEST_HASHES.TEST_SUCCESS_CODE_HASH_REDIRECT}`, - expect.objectContaining({ - apiId: expect.any(Number), - noHistory: true, - timeout: expect.any(Number), - }) + expect.stringContaining(cachedOriginUrl), + expect.any(Object) + ); + + global.URLSearchParams = originalURLSearchParams; + }); + + it("uses custom NavigationClient when provided", async () => { + const customNavClient = { + navigateInternal: jest.fn().mockResolvedValue(undefined), + } as any; + + window.location.hash = TEST_HASHES.TEST_SUCCESS_CODE_HASH_REDIRECT; + + await broadcastResponseToMainFrame(customNavClient); + + // Verify custom navigation client was used, not the default + expect(customNavClient.navigateInternal).toHaveBeenCalled(); + expect( + mockNavigationClient.navigateInternal + ).not.toHaveBeenCalled(); + }); + + it("falls back to homepage when sessionStorage access fails", async () => { + window.location.hash = TEST_HASHES.TEST_SUCCESS_CODE_HASH_REDIRECT; + + // Make sessionStorage.getItem throw + jest.spyOn(window.sessionStorage, "getItem").mockImplementation( + () => { + throw new Error("SessionStorage unavailable"); + } ); - // Hash should NOT be cleared for redirect + await broadcastResponseToMainFrame(); + + // Should still navigate successfully using homepage fallback + expect(mockNavigationClient.navigateInternal).toHaveBeenCalled(); expect(clearHash).not.toHaveBeenCalled(); + }); - // window.close should NOT be called for redirect - expect(window.close).not.toHaveBeenCalled(); + it("falls back to homepage when client_id is not in URL", async () => { + window.location.hash = TEST_HASHES.TEST_SUCCESS_CODE_HASH_REDIRECT; + + await broadcastResponseToMainFrame(); + + // Should navigate using homepage since no client_id means no sessionStorage lookup + expect(mockNavigationClient.navigateInternal).toHaveBeenCalled(); + const callArgs = ( + mockNavigationClient.navigateInternal as jest.Mock + ).mock.calls[0][0]; + expect(callArgs).toContain( + TEST_HASHES.TEST_SUCCESS_CODE_HASH_REDIRECT + ); + }); + }); + + describe("Hybrid response format (query + hash)", () => { + it("handles query string only response", async () => { + window.location.search = `?state=${TEST_STATE_VALUES.TEST_STATE_POPUP}&code=test_code`; + window.location.hash = ""; + + await broadcastResponseToMainFrame(); + + expect(clearHash).toHaveBeenCalled(); + expect(window.close).toHaveBeenCalled(); + }); + + it("handles hybrid query + hash response", async () => { + const testClientId = "hybrid-client-id"; + window.location.search = `?state=${TEST_STATE_VALUES.TEST_STATE_REDIRECT}&code=test_code&client_id=${testClientId}`; + window.location.hash = "#app_hash_fragment"; + + await broadcastResponseToMainFrame(); + + // For redirect flow, should navigate with full query + hash + expect(mockNavigationClient.navigateInternal).toHaveBeenCalled(); + const callArgs = ( + mockNavigationClient.navigateInternal as jest.Mock + ).mock.calls[0][0]; + expect(callArgs).toContain("?state="); + expect(callArgs).toContain("#app_hash_fragment"); + }); + + it("strips leading ? from query string", async () => { + window.location.search = `?state=${TEST_STATE_VALUES.TEST_STATE_POPUP}&code=test_code`; + window.location.hash = ""; + + await broadcastResponseToMainFrame(); + + // Should successfully parse state (indicates ? was stripped) + expect(clearHash).toHaveBeenCalled(); + }); + + it("strips leading # from hash", async () => { + window.location.hash = `#state=${TEST_STATE_VALUES.TEST_STATE_POPUP}&code=test_code`; + + await broadcastResponseToMainFrame(); + + // Should successfully parse state (indicates # was stripped) + expect(clearHash).toHaveBeenCalled(); + }); + + it("handles query string without leading ?", async () => { + window.location.search = `state=${TEST_STATE_VALUES.TEST_STATE_POPUP}&code=test_code`; + window.location.hash = ""; + + await broadcastResponseToMainFrame(); + + // Should still work even without leading ? + expect(clearHash).toHaveBeenCalled(); + }); + + it("handles hash without leading #", async () => { + window.location.hash = `state=${TEST_STATE_VALUES.TEST_STATE_POPUP}&code=test_code`; + + await broadcastResponseToMainFrame(); + + // Should still work even without leading # + expect(clearHash).toHaveBeenCalled(); + }); + + it("throws when both query and hash are empty", async () => { + window.location.search = ""; + window.location.hash = ""; + + await expect(broadcastResponseToMainFrame()).rejects.toThrow( + "No auth payload found on URL (hash or query)" + ); + }); + + it("throws when query and hash contain only delimiters", async () => { + window.location.search = "?"; + window.location.hash = "#"; + + await expect(broadcastResponseToMainFrame()).rejects.toThrow( + "No auth payload found on URL (hash or query)" + ); }); }); @@ -243,3 +430,154 @@ describe("broadcastResponseToMainFrame", () => { }); }); }); + +describe("parseAuthResponseFromUrl", () => { + let originalLocation: Location; + + beforeAll(() => { + originalLocation = window.location; + }); + + beforeEach(() => { + // Mock window.location with a fresh object for each test + delete (window as any).location; + (window as any).location = { + ...originalLocation, + hash: "", + search: "", + }; + + // Mock clearHash + (clearHash as jest.Mock).mockImplementation(() => {}); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + (window as any).location = originalLocation; + }); + + it("parses auth response from hash only", () => { + window.location.hash = TEST_HASHES.TEST_SUCCESS_CODE_HASH_POPUP; + + const result = parseAuthResponseFromUrl(); + + expect(result.urlHash).toBe(TEST_HASHES.TEST_SUCCESS_CODE_HASH_POPUP); + expect(result.urlQuery).toBe(""); + expect(result.payload).toContain("state="); + expect(result.params.get("state")).toBeTruthy(); + expect(result.libraryState.id).toBeTruthy(); + expect(result.libraryState.meta).toBeTruthy(); + }); + + it("parses auth response from query string only", () => { + window.location.search = `?state=${TEST_STATE_VALUES.TEST_STATE_POPUP}&code=test_code`; + window.location.hash = ""; + + const result = parseAuthResponseFromUrl(); + + expect(result.urlQuery).toContain("?state="); + expect(result.urlHash).toBe(""); + expect(result.payload).toContain("state="); + expect(result.params.get("state")).toBe( + TEST_STATE_VALUES.TEST_STATE_POPUP + ); + expect(result.libraryState.meta["interactionType"]).toBe("popup"); + }); + + it("parses hybrid response (query + hash)", () => { + window.location.search = `?state=${TEST_STATE_VALUES.TEST_STATE_REDIRECT}&code=test_code`; + window.location.hash = "#app_fragment"; + + const result = parseAuthResponseFromUrl(); + + expect(result.urlQuery).toContain("?state="); + expect(result.urlHash).toBe("#app_fragment"); + expect(result.payload).toContain("state="); + expect(result.payload).toContain("app_fragment"); + expect(result.libraryState.meta["interactionType"]).toBe("redirect"); + }); + + it("strips leading ? from query string", () => { + window.location.search = `?state=${TEST_STATE_VALUES.TEST_STATE_POPUP}&code=test_code`; + + const result = parseAuthResponseFromUrl(); + + // Payload should not have leading ? + expect(result.payload.charAt(0)).not.toBe("?"); + expect(result.payload).toContain("state="); + }); + + it("strips leading # from hash", () => { + window.location.hash = `#state=${TEST_STATE_VALUES.TEST_STATE_POPUP}&code=test_code`; + + const result = parseAuthResponseFromUrl(); + + // Payload should not have leading # (when hash is the only content) + expect(result.payload).toContain("state="); + expect(result.params.get("state")).toBe( + TEST_STATE_VALUES.TEST_STATE_POPUP + ); + }); + + it("handles query without leading ?", () => { + window.location.search = `state=${TEST_STATE_VALUES.TEST_STATE_POPUP}&code=test_code`; + + const result = parseAuthResponseFromUrl(); + + expect(result.payload).toContain("state="); + expect(result.params.get("state")).toBe( + TEST_STATE_VALUES.TEST_STATE_POPUP + ); + }); + + it("handles hash without leading #", () => { + window.location.hash = `state=${TEST_STATE_VALUES.TEST_STATE_POPUP}&code=test_code`; + + const result = parseAuthResponseFromUrl(); + + expect(result.payload).toContain("state="); + expect(result.params.get("state")).toBe( + TEST_STATE_VALUES.TEST_STATE_POPUP + ); + }); + + it("throws when both query and hash are empty", () => { + window.location.search = ""; + window.location.hash = ""; + + expect(() => parseAuthResponseFromUrl()).toThrow( + "No auth payload found on URL (hash or query)" + ); + }); + + it("throws when state parameter is missing", () => { + window.location.hash = "#code=testCode&client_info=testClientInfo"; + + expect(() => parseAuthResponseFromUrl()).toThrow( + "Missing state on redirect URL" + ); + }); + + it("throws when state is missing id attribute", () => { + const invalidState = btoa( + JSON.stringify({ meta: { interactionType: "popup" } }) + ); + window.location.hash = `#code=testCode&state=${invalidState}`; + + expect(() => parseAuthResponseFromUrl()).toThrow( + "Missing state 'id' and/or 'meta' attributes" + ); + }); + + it("throws when state is missing meta attribute", () => { + const invalidState = btoa(JSON.stringify({ id: RANDOM_TEST_GUID })); + window.location.hash = `#code=testCode&state=${invalidState}`; + + expect(() => parseAuthResponseFromUrl()).toThrow( + "Missing state 'id' and/or 'meta' attributes" + ); + }); +}); diff --git a/lib/msal-browser/test/utils/BrowserUtils.spec.ts b/lib/msal-browser/test/utils/BrowserUtils.spec.ts index e94ac0ecea..9eba429f38 100644 --- a/lib/msal-browser/test/utils/BrowserUtils.spec.ts +++ b/lib/msal-browser/test/utils/BrowserUtils.spec.ts @@ -123,7 +123,7 @@ describe("BrowserUtils.ts Function Unit Tests", () => { expect(logger.verbose).not.toHaveBeenCalled(); }); - it("cancels active bridge monitor and rejects with interactionInProgressOverridden error", async () => { + it("cancels active bridge monitor and rejects with interactionInProgressCancelled error", async () => { const logger = { verbose: jest.fn(), info: jest.fn(), @@ -155,10 +155,9 @@ describe("BrowserUtils.ts Function Unit Tests", () => { TEST_CONFIG.CORRELATION_ID ); - // Should reject with interactionInProgressOverridden error + // Should reject with interactionInProgressCancelled error await expect(waitPromise).rejects.toMatchObject({ - errorCode: - BrowserAuthErrorCodes.interactionInProgressOverridden, + errorCode: BrowserAuthErrorCodes.interactionInProgressCancelled, }); expect(logger.verbose).toHaveBeenCalledWith( @@ -203,8 +202,7 @@ describe("BrowserUtils.ts Function Unit Tests", () => { expect(clearTimeoutSpy).toHaveBeenCalled(); await expect(waitPromise).rejects.toMatchObject({ - errorCode: - BrowserAuthErrorCodes.interactionInProgressOverridden, + errorCode: BrowserAuthErrorCodes.interactionInProgressCancelled, }); jest.useRealTimers(); @@ -241,8 +239,7 @@ describe("BrowserUtils.ts Function Unit Tests", () => { // Verify the promise was rejected with the correct error await expect(waitPromise).rejects.toMatchObject({ - errorCode: - BrowserAuthErrorCodes.interactionInProgressOverridden, + errorCode: BrowserAuthErrorCodes.interactionInProgressCancelled, }); // Verify verbose logging occurred for cancellation @@ -285,8 +282,7 @@ describe("BrowserUtils.ts Function Unit Tests", () => { ); await expect(waitPromise).rejects.toMatchObject({ - errorCode: - BrowserAuthErrorCodes.interactionInProgressOverridden, + errorCode: BrowserAuthErrorCodes.interactionInProgressCancelled, }); // Advance time to when timeout would have fired From b42be72eab020683fb955aaae79cc2a96d8fec6b Mon Sep 17 00:00:00 2001 From: Konstantin Shabelko Date: Fri, 21 Nov 2025 14:58:03 -0500 Subject: [PATCH 21/45] - Update COOP migration guide --- lib/msal-browser/docs/v4-migration.md | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/msal-browser/docs/v4-migration.md b/lib/msal-browser/docs/v4-migration.md index b28517cb36..3738f20cb8 100644 --- a/lib/msal-browser/docs/v4-migration.md +++ b/lib/msal-browser/docs/v4-migration.md @@ -217,6 +217,7 @@ The following parameters were deprecated in MSAL Browser v4 and have been remove 1. The `protocolMode` parameter has been moved to `SystemOptions` from `BrowserAuthOptions` in Configuration. There are no changes to its options or functionality. 1. The `navigateFrameWait` parameter has been removed. This was previously needed by older browsers which are no longer supported by MSAL.js. +1. The `iframeHashTimeout` and `windowHashTimeout` parameters have been replaced with `iframeBridgeTimeout` and `popupBridgeTimeout` respectively. These timeouts now control how long to wait for a response from the redirect bridge via BroadcastChannel API when COOP is enabled. #### `asyncPopups` From 15086a0aebb845b6fb1b41be982f44e93fb6d34a Mon Sep 17 00:00:00 2001 From: Konstantin Shabelko Date: Fri, 21 Nov 2025 15:03:49 -0500 Subject: [PATCH 22/45] - Update error doc --- docs/errors.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/errors.md b/docs/errors.md index be51952d9e..69b8b617bf 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -613,7 +613,15 @@ const promise2 = msalInstance.acquireTokenPopup(request2); - Token acquisition in popup failed due to timeout. - Token acquisition in iframe failed due to timeout. -This error can be thrown when calling `ssoSilent`, `acquireTokenSilent`, `acquireTokenPopup` or `loginPopup` when the redirect bridge script fails to send the authentication response back to the main window within the configured timeout period. This typically occurs for the following reasons: +This error can be thrown when calling `ssoSilent`, `acquireTokenSilent`, `acquireTokenPopup` or `loginPopup` when the redirect bridge script fails to send the authentication response back to the main window within the configured timeout period. + +**What is the redirect bridge?** + +The redirect bridge is a mechanism that enables authentication flows in COOP (Cross-Origin-Opener-Policy) enabled applications. When COOP headers are present, popup and iframe windows cannot directly communicate with the main application window. The redirect bridge solves this by using the BroadcastChannel API to transmit authentication responses from the redirect page back to the main window. For more details on COOP support and the redirect bridge, see the [COOP Migration Guide](../lib/msal-browser/docs/v4-migration.md#cross-origin-opener-policy-coop-support). + +**Common Causes:** + +This timeout typically occurs for the following reasons: 1. The page you use as your `redirectUri` is not loading the `msal-redirect-bridge.js` script 1. The redirect page is removing or manipulating the hash before the bridge script can process it From 27803492c6ecc2e61d83336446ffdcaaf88f1a32 Mon Sep 17 00:00:00 2001 From: Konstantin Shabelko Date: Fri, 21 Nov 2025 17:46:59 -0500 Subject: [PATCH 23/45] - Handle hybrid URL response format --- .../apiReview/msal-browser.api.md | 2 + lib/msal-browser/src/redirect_bridge/index.ts | 54 +++++++-- lib/msal-browser/src/utils/BrowserUtils.ts | 49 +++++--- .../broadcastResponseToMainFrame.spec.ts | 111 ++++++++++++------ 4 files changed, 158 insertions(+), 58 deletions(-) diff --git a/lib/msal-browser/apiReview/msal-browser.api.md b/lib/msal-browser/apiReview/msal-browser.api.md index a43ddfbcf4..31e859fb62 100644 --- a/lib/msal-browser/apiReview/msal-browser.api.md +++ b/lib/msal-browser/apiReview/msal-browser.api.md @@ -1110,6 +1110,8 @@ function parseAuthResponseFromUrl(): { payload: string; urlHash: string; urlQuery: string; + hasResponseInHash: boolean; + hasResponseInQuery: boolean; libraryState: { id: string; meta: Record; diff --git a/lib/msal-browser/src/redirect_bridge/index.ts b/lib/msal-browser/src/redirect_bridge/index.ts index 8c6bedfd4f..e0c7349074 100644 --- a/lib/msal-browser/src/redirect_bridge/index.ts +++ b/lib/msal-browser/src/redirect_bridge/index.ts @@ -35,12 +35,26 @@ export async function broadcastResponseToMainFrame( try { parsedResponse = parseAuthResponseFromUrl(); } catch (error) { - // Clear hash before re-throwing parse errors - BrowserUtils.clearHash(window); + // Clear hash and query string before re-throwing parse errors + if (typeof window.history.replaceState === "function") { + window.history.replaceState( + null, + "", + `${window.location.origin}${window.location.pathname}` + ); + } throw error; } - const { params, payload, urlHash, urlQuery, libraryState } = parsedResponse; + const { + params, + payload, + urlHash, + urlQuery, + hasResponseInHash, + hasResponseInQuery, + libraryState, + } = parsedResponse; const { id, meta } = libraryState; @@ -66,16 +80,42 @@ export async function broadcastResponseToMainFrame( } } - // Reconstruct full URL with auth response - const fullUrlHash = `${urlQuery}${urlHash}`; + // Reconstruct full URL with auth response (preserve original format) + let fullUrlResponse = ""; + if (hasResponseInHash && hasResponseInQuery) { + // Hybrid format + fullUrlResponse = `${urlQuery}${urlHash}`; + } else if (hasResponseInHash) { + // Hash only + fullUrlResponse = urlHash; + } else { + // Query only + fullUrlResponse = urlQuery; + } + const homepage = `${ navigateToUrl || BrowserUtils.getHomepage() - }${fullUrlHash}`; + }${fullUrlResponse}`; await navClient.navigateInternal(homepage, navigationOptions); + + // Do NOT clear URL for redirect flow - we're navigating away anyway return; } - BrowserUtils.clearHash(window); + // Clear only the part(s) containing the auth response from redirect bridge URL + if (typeof window.history.replaceState === "function") { + let newUrl = `${window.location.origin}${window.location.pathname}`; + // Preserve hash if it didn't contain the response + if (!hasResponseInHash && urlHash) { + newUrl += urlHash; + } + // Preserve query if it didn't contain the response + if (!hasResponseInQuery && urlQuery) { + newUrl += urlQuery; + } + window.history.replaceState(null, "", newUrl); + } + // Send the raw URL payload to the main frame const channel = new BroadcastChannel(id); channel.postMessage({ diff --git a/lib/msal-browser/src/utils/BrowserUtils.ts b/lib/msal-browser/src/utils/BrowserUtils.ts index e33836bffb..6ae1ec3663 100644 --- a/lib/msal-browser/src/utils/BrowserUtils.ts +++ b/lib/msal-browser/src/utils/BrowserUtils.ts @@ -52,6 +52,8 @@ export function parseAuthResponseFromUrl(): { payload: string; urlHash: string; urlQuery: string; + hasResponseInHash: boolean; + hasResponseInQuery: boolean; libraryState: { id: string; meta: Record; @@ -61,31 +63,48 @@ export function parseAuthResponseFromUrl(): { const urlHash = window.location.hash; const urlQuery = window.location.search; - // Combine query string and hash for full payload + // Determine which part contains the auth response by checking for 'state' parameter + let hasResponseInHash = false; + let hasResponseInQuery = false; let payload = ""; + let params: URLSearchParams | undefined = undefined; + + if (urlHash && urlHash.length > 1) { + const hashContent = + urlHash.charAt(0) === "#" ? urlHash.substring(1) : urlHash; + const hashParams = new URLSearchParams(hashContent); + if (hashParams.has("state")) { + hasResponseInHash = true; + payload = hashContent; + params = hashParams; + } + } + if (urlQuery && urlQuery.length > 1) { - // Verify it starts with '?' before stripping - if (urlQuery.charAt(0) === "?") { - payload = urlQuery.substring(1); - } else { - payload = urlQuery; + const queryContent = + urlQuery.charAt(0) === "?" ? urlQuery.substring(1) : urlQuery; + const queryParams = new URLSearchParams(queryContent); + if (queryParams.has("state")) { + hasResponseInQuery = true; + payload = queryContent; + params = queryParams; } } - if (urlHash && urlHash.length > 1) { - // Verify it starts with '#' before stripping + + // If response is in both, combine them (hybrid format) + if (hasResponseInHash && hasResponseInQuery) { + const queryContent = + urlQuery.charAt(0) === "?" ? urlQuery.substring(1) : urlQuery; const hashContent = urlHash.charAt(0) === "#" ? urlHash.substring(1) : urlHash; - // Append hash fragment - payload = payload ? `${payload}${hashContent}` : hashContent; + payload = `${queryContent}${hashContent}`; + params = new URLSearchParams(payload); } - if (!payload) { + if (!payload || !params) { throw new Error("No auth payload found on URL (hash or query)"); } - // Parse state from URL parameters - const params = new URLSearchParams(payload); - const state = params.get("state"); if (!state) { throw new Error("Missing state on redirect URL"); @@ -106,6 +125,8 @@ export function parseAuthResponseFromUrl(): { payload, urlHash, urlQuery, + hasResponseInHash, + hasResponseInQuery, libraryState: { id, meta, diff --git a/lib/msal-browser/test/redirect_bridge/broadcastResponseToMainFrame.spec.ts b/lib/msal-browser/test/redirect_bridge/broadcastResponseToMainFrame.spec.ts index a8e4b07de1..1ac8ee5cb0 100644 --- a/lib/msal-browser/test/redirect_bridge/broadcastResponseToMainFrame.spec.ts +++ b/lib/msal-browser/test/redirect_bridge/broadcastResponseToMainFrame.spec.ts @@ -5,26 +5,20 @@ import { broadcastResponseToMainFrame } from "../../src/redirect_bridge/index.js"; import { NavigationClient } from "../../src/navigation/NavigationClient.js"; -import { - clearHash, - parseAuthResponseFromUrl, -} from "../../src/utils/BrowserUtils.js"; +import { parseAuthResponseFromUrl } from "../../src/utils/BrowserUtils.js"; import { TEST_HASHES, TEST_STATE_VALUES, RANDOM_TEST_GUID, } from "../utils/StringConstants.js"; -jest.mock("../../src/utils/BrowserUtils.js", () => ({ - ...jest.requireActual("../../src/utils/BrowserUtils.js"), - clearHash: jest.fn(), -})); jest.mock("../../src/navigation/NavigationClient.js"); describe("broadcastResponseToMainFrame", () => { let mockNavigationClient: jest.Mocked; let mockSessionStorage: { [key: string]: string }; let originalLocation: Location; + let mockHistoryReplaceState: jest.Mock; beforeAll(() => { // Save original location @@ -40,6 +34,10 @@ describe("broadcastResponseToMainFrame", () => { search: "", }; + // Mock history.replaceState + mockHistoryReplaceState = jest.fn(); + window.history.replaceState = mockHistoryReplaceState; + // Mock window.close window.close = jest.fn(); @@ -51,9 +49,6 @@ describe("broadcastResponseToMainFrame", () => { () => mockNavigationClient ); - // Mock clearHash - (clearHash as jest.Mock).mockImplementation(() => {}); - // Mock sessionStorage mockSessionStorage = {}; Object.defineProperty(window, "sessionStorage", { @@ -99,10 +94,10 @@ describe("broadcastResponseToMainFrame", () => { window.location.hash = "#code=testCode&client_info=testClientInfo"; await expect(broadcastResponseToMainFrame()).rejects.toThrow( - "Missing state on redirect URL" + "No auth payload found on URL (hash or query)" ); - expect(clearHash).toHaveBeenCalledWith(window); + expect(mockHistoryReplaceState).toHaveBeenCalled(); }); it("throws error when state is missing 'id' attribute", async () => { @@ -115,7 +110,7 @@ describe("broadcastResponseToMainFrame", () => { "Missing state 'id' and/or 'meta' attributes" ); - expect(clearHash).toHaveBeenCalledWith(window); + expect(mockHistoryReplaceState).toHaveBeenCalled(); }); it("throws error when state is missing 'meta' attribute", async () => { @@ -126,7 +121,7 @@ describe("broadcastResponseToMainFrame", () => { "Missing state 'id' and/or 'meta' attributes" ); - expect(clearHash).toHaveBeenCalledWith(window); + expect(mockHistoryReplaceState).toHaveBeenCalled(); }); }); @@ -137,7 +132,7 @@ describe("broadcastResponseToMainFrame", () => { await broadcastResponseToMainFrame(); // Verify hash was cleared (indicates broadcast path was taken) - expect(clearHash).toHaveBeenCalledWith(window); + expect(mockHistoryReplaceState).toHaveBeenCalled(); // Verify window.close was called expect(window.close).toHaveBeenCalled(); @@ -154,7 +149,7 @@ describe("broadcastResponseToMainFrame", () => { await broadcastResponseToMainFrame(); // Verify hash was cleared (indicates broadcast path was taken) - expect(clearHash).toHaveBeenCalledWith(window); + expect(mockHistoryReplaceState).toHaveBeenCalled(); // Verify window.close was called expect(window.close).toHaveBeenCalled(); @@ -173,7 +168,7 @@ describe("broadcastResponseToMainFrame", () => { await broadcastResponseToMainFrame(); // Should still broadcast the error response (verify via hash clearing) - expect(clearHash).toHaveBeenCalledWith(window); + expect(mockHistoryReplaceState).toHaveBeenCalled(); // Verify window.close was called expect(window.close).toHaveBeenCalled(); @@ -204,8 +199,8 @@ describe("broadcastResponseToMainFrame", () => { }) ); - // Hash should NOT be cleared for redirect (early return before clearHash) - expect(clearHash).not.toHaveBeenCalled(); + // URL should NOT be cleared for redirect flow (we're navigating away) + expect(mockHistoryReplaceState).not.toHaveBeenCalled(); // window.close should NOT be called for redirect (early return) expect(window.close).not.toHaveBeenCalled(); @@ -273,7 +268,7 @@ describe("broadcastResponseToMainFrame", () => { // Should still navigate successfully using homepage fallback expect(mockNavigationClient.navigateInternal).toHaveBeenCalled(); - expect(clearHash).not.toHaveBeenCalled(); + expect(mockHistoryReplaceState).not.toHaveBeenCalled(); }); it("falls back to homepage when client_id is not in URL", async () => { @@ -299,7 +294,7 @@ describe("broadcastResponseToMainFrame", () => { await broadcastResponseToMainFrame(); - expect(clearHash).toHaveBeenCalled(); + expect(mockHistoryReplaceState).toHaveBeenCalled(); expect(window.close).toHaveBeenCalled(); }); @@ -316,7 +311,7 @@ describe("broadcastResponseToMainFrame", () => { mockNavigationClient.navigateInternal as jest.Mock ).mock.calls[0][0]; expect(callArgs).toContain("?state="); - expect(callArgs).toContain("#app_hash_fragment"); + expect(callArgs).toContain("&code=test_code"); }); it("strips leading ? from query string", async () => { @@ -326,7 +321,7 @@ describe("broadcastResponseToMainFrame", () => { await broadcastResponseToMainFrame(); // Should successfully parse state (indicates ? was stripped) - expect(clearHash).toHaveBeenCalled(); + expect(mockHistoryReplaceState).toHaveBeenCalled(); }); it("strips leading # from hash", async () => { @@ -335,7 +330,7 @@ describe("broadcastResponseToMainFrame", () => { await broadcastResponseToMainFrame(); // Should successfully parse state (indicates # was stripped) - expect(clearHash).toHaveBeenCalled(); + expect(mockHistoryReplaceState).toHaveBeenCalled(); }); it("handles query string without leading ?", async () => { @@ -345,7 +340,7 @@ describe("broadcastResponseToMainFrame", () => { await broadcastResponseToMainFrame(); // Should still work even without leading ? - expect(clearHash).toHaveBeenCalled(); + expect(mockHistoryReplaceState).toHaveBeenCalled(); }); it("handles hash without leading #", async () => { @@ -354,7 +349,7 @@ describe("broadcastResponseToMainFrame", () => { await broadcastResponseToMainFrame(); // Should still work even without leading # - expect(clearHash).toHaveBeenCalled(); + expect(mockHistoryReplaceState).toHaveBeenCalled(); }); it("throws when both query and hash are empty", async () => { @@ -366,6 +361,47 @@ describe("broadcastResponseToMainFrame", () => { ); }); + it("preserves hash fragment when auth response is in query string (popup/silent flow)", async () => { + // Scenario: redirectUri is https://contoso.com/redirect#myReplyUrl + // Auth response comes in query string, hash should be preserved + window.location.search = `?state=${TEST_STATE_VALUES.TEST_STATE_POPUP}&code=test_code`; + window.location.hash = "#myReplyUrl"; + + await broadcastResponseToMainFrame(); + + // Should clear query string but preserve hash + expect(mockHistoryReplaceState).toHaveBeenCalledWith( + null, + "", + expect.stringContaining("#myReplyUrl") + ); + expect(mockHistoryReplaceState).toHaveBeenCalledWith( + null, + "", + expect.not.stringContaining("?state=") + ); + }); + + it("preserves query string when auth response is in hash (popup/silent flow)", async () => { + // Scenario: redirectUri has query params, auth response in hash + window.location.search = "?app_param=value"; + window.location.hash = `#state=${TEST_STATE_VALUES.TEST_STATE_POPUP}&code=test_code`; + + await broadcastResponseToMainFrame(); + + // Should clear hash but preserve query string + expect(mockHistoryReplaceState).toHaveBeenCalledWith( + null, + "", + expect.stringContaining("?app_param=value") + ); + expect(mockHistoryReplaceState).toHaveBeenCalledWith( + null, + "", + expect.not.stringContaining("#state=") + ); + }); + it("throws when query and hash contain only delimiters", async () => { window.location.search = "?"; window.location.hash = "#"; @@ -399,17 +435,17 @@ describe("broadcastResponseToMainFrame", () => { ).resolves.toBeUndefined(); // Verify clearHash was still called (indicates broadcast completed) - expect(clearHash).toHaveBeenCalledWith(window); + expect(mockHistoryReplaceState).toHaveBeenCalled(); }); }); - describe("Hash clearing", () => { + describe("URL clearing", () => { it("clears hash before throwing error when state is missing", async () => { window.location.hash = "#code=testCode"; await expect(broadcastResponseToMainFrame()).rejects.toThrow(); - expect(clearHash).toHaveBeenCalledWith(window); + expect(mockHistoryReplaceState).toHaveBeenCalled(); }); it("clears hash before throwing error when state attributes are missing", async () => { @@ -418,7 +454,7 @@ describe("broadcastResponseToMainFrame", () => { await expect(broadcastResponseToMainFrame()).rejects.toThrow(); - expect(clearHash).toHaveBeenCalledWith(window); + expect(mockHistoryReplaceState).toHaveBeenCalled(); }); it("clears hash after successful broadcast", async () => { @@ -426,7 +462,7 @@ describe("broadcastResponseToMainFrame", () => { await broadcastResponseToMainFrame(); - expect(clearHash).toHaveBeenCalledWith(window); + expect(mockHistoryReplaceState).toHaveBeenCalled(); }); }); }); @@ -446,9 +482,6 @@ describe("parseAuthResponseFromUrl", () => { hash: "", search: "", }; - - // Mock clearHash - (clearHash as jest.Mock).mockImplementation(() => {}); }); afterEach(() => { @@ -496,7 +529,11 @@ describe("parseAuthResponseFromUrl", () => { expect(result.urlQuery).toContain("?state="); expect(result.urlHash).toBe("#app_fragment"); expect(result.payload).toContain("state="); - expect(result.payload).toContain("app_fragment"); + expect(result.payload).toContain("code=test_code"); + // Hash fragment without state should NOT be in payload + expect(result.payload).not.toContain("app_fragment"); + expect(result.hasResponseInQuery).toBe(true); + expect(result.hasResponseInHash).toBe(false); expect(result.libraryState.meta["interactionType"]).toBe("redirect"); }); @@ -557,7 +594,7 @@ describe("parseAuthResponseFromUrl", () => { window.location.hash = "#code=testCode&client_info=testClientInfo"; expect(() => parseAuthResponseFromUrl()).toThrow( - "Missing state on redirect URL" + "No auth payload found on URL (hash or query)" ); }); From def925eb68b95b7294f864735fce8fa7ecb6d9c3 Mon Sep 17 00:00:00 2001 From: Konstantin Shabelko Date: Fri, 21 Nov 2025 18:17:07 -0500 Subject: [PATCH 24/45] - Update migration doc --- lib/msal-browser/docs/v4-migration.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/msal-browser/docs/v4-migration.md b/lib/msal-browser/docs/v4-migration.md index 3738f20cb8..eb45e56b50 100644 --- a/lib/msal-browser/docs/v4-migration.md +++ b/lib/msal-browser/docs/v4-migration.md @@ -317,11 +317,13 @@ const authRequest = { ### Cross-Origin-Opener-Policy (COOP) Support -MSAL Browser v5 introduces built-in support for Cross-Origin-Opener-Policy (COOP), which enhances security by isolating your browsing context. When COOP is enabled on your application, popup and iframe-based authentication flows are restricted. MSAL v5 provides a redirect bridge mechanism to handle authentication in COOP-enabled environments. +MSAL Browser v5 introduces built-in support for Cross-Origin-Opener-Policy (COOP), which enhances security by isolating browsing contexts. When the authentication service (Microsoft Entra ID or Azure AD B2C) returns COOP headers, traditional popup and silent iframe authentication flows are restricted. MSAL v5 provides a redirect bridge mechanism to handle authentication in COOP-enabled environments. + +**Note:** Microsoft Entra ID (formerly Azure AD) has COOP enabled by default. For Azure AD B2C, COOP availability depends on your backend configuration and the authentication endpoints being used. #### What Changed -When your application has COOP headers enabled (e.g., `Cross-Origin-Opener-Policy: same-origin`), traditional popup and silent iframe authentication flows will fail because the authentication window cannot communicate back to the main application window. MSAL v5 solves this by introducing a redirect bridge pattern. +When COOP headers are present on the authentication service response (e.g., `Cross-Origin-Opener-Policy: same-origin`), traditional popup and silent iframe authentication flows will fail because the authentication window cannot communicate back to the main application window. MSAL v5 solves this by introducing a redirect bridge pattern. **All authentication flows** (`acquireTokenSilent()`, `ssoSilent()`, `loginPopup()`, and `loginRedirect()`) now use the redirect bridge when COOP is enabled. The redirect bridge handles the authentication response differently based on the flow: From 5ffe9842e8ea9e3eba12eddd04105054ffea2253 Mon Sep 17 00:00:00 2001 From: Konstantin Date: Mon, 24 Nov 2025 13:34:26 -0500 Subject: [PATCH 25/45] Update docs/errors.md Co-authored-by: Sameera Gajjarapu --- docs/errors.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/errors.md b/docs/errors.md index 69b8b617bf..0b14708560 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -693,7 +693,7 @@ Some B2C flows are expected to throw this error due to their need for user inter Another potential reason the identity provider may not redirect back to your application in time may be that there is some extra network latency. -✔️ The default timeout is about 10 seconds and should be sufficient in most cases, however, if your identity provider is taking longer than that to redirect you can increase this timeout in the MSAL config with either the `iframeBridgeTimeout` or `popupBridgeTimeout` configuration parameters. +✔️ The default timeout is about 10 seconds and should be sufficient in most cases, however, if your identity provider is taking longer than that to redirect, you can increase this timeout in the MSAL config with either the `iframeBridgeTimeout` (for aquireTokenSilent() or ssoSilent()) or `popupBridgeTimeout` (acquireTokenPopup()) configuration parameters. ```javascript const msalConfig = { From ba93bd272e8a86e53a620bb767ed45991b0e988d Mon Sep 17 00:00:00 2001 From: Konstantin Date: Mon, 24 Nov 2025 13:35:15 -0500 Subject: [PATCH 26/45] Update lib/msal-browser/docs/v4-migration.md Co-authored-by: Thomas Norling --- lib/msal-browser/docs/v4-migration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/msal-browser/docs/v4-migration.md b/lib/msal-browser/docs/v4-migration.md index eb45e56b50..fa522141d0 100644 --- a/lib/msal-browser/docs/v4-migration.md +++ b/lib/msal-browser/docs/v4-migration.md @@ -332,7 +332,7 @@ When COOP headers are present on the authentication service response (e.g., `Cro #### How It Works -1. **Main application**: Your COOP-enabled application initiates authentication using `loginPopup()`, `ssoSilent()`, or `loginRedirect()` +1. **Main application**: Your application initiates authentication using `loginPopup()`, `ssoSilent()`, or `loginRedirect()` 2. **Redirect**: MSAL opens a popup/iframe/window to an authority page 3. **Authentication flow**: The authority page completes the OAuth flow and receives the auth response 4. **Response handling**: The redirect page uses the new `broadcastResponseToMainFrame()` function which: From f786341416cf11dc430b0d6eb3d34efffb355026 Mon Sep 17 00:00:00 2001 From: Konstantin Date: Mon, 24 Nov 2025 13:35:42 -0500 Subject: [PATCH 27/45] Update lib/msal-browser/docs/v4-migration.md Co-authored-by: Thomas Norling --- lib/msal-browser/docs/v4-migration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/msal-browser/docs/v4-migration.md b/lib/msal-browser/docs/v4-migration.md index fa522141d0..68fdf4b66d 100644 --- a/lib/msal-browser/docs/v4-migration.md +++ b/lib/msal-browser/docs/v4-migration.md @@ -325,7 +325,7 @@ MSAL Browser v5 introduces built-in support for Cross-Origin-Opener-Policy (COOP When COOP headers are present on the authentication service response (e.g., `Cross-Origin-Opener-Policy: same-origin`), traditional popup and silent iframe authentication flows will fail because the authentication window cannot communicate back to the main application window. MSAL v5 solves this by introducing a redirect bridge pattern. -**All authentication flows** (`acquireTokenSilent()`, `ssoSilent()`, `loginPopup()`, and `loginRedirect()`) now use the redirect bridge when COOP is enabled. The redirect bridge handles the authentication response differently based on the flow: +**All authentication flows** (`acquireTokenSilent()`, `ssoSilent()`, `loginPopup()`, and `loginRedirect()`) now use the redirect bridge. The redirect bridge handles the authentication response differently based on the flow: - **Popup and silent flows**: The redirect bridge broadcasts the authentication response to the main application window using the BroadcastChannel API - **Redirect flow**: The redirect bridge navigates back to your application's home page with the authentication response in the URL From 7d7bdd39fe392fb4c46c97a8ce972e4d8b9ed11d Mon Sep 17 00:00:00 2001 From: Konstantin Date: Mon, 24 Nov 2025 13:35:55 -0500 Subject: [PATCH 28/45] Update lib/msal-browser/docs/v4-migration.md Co-authored-by: Thomas Norling --- lib/msal-browser/docs/v4-migration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/msal-browser/docs/v4-migration.md b/lib/msal-browser/docs/v4-migration.md index 68fdf4b66d..b1eeed3353 100644 --- a/lib/msal-browser/docs/v4-migration.md +++ b/lib/msal-browser/docs/v4-migration.md @@ -328,7 +328,7 @@ When COOP headers are present on the authentication service response (e.g., `Cro **All authentication flows** (`acquireTokenSilent()`, `ssoSilent()`, `loginPopup()`, and `loginRedirect()`) now use the redirect bridge. The redirect bridge handles the authentication response differently based on the flow: - **Popup and silent flows**: The redirect bridge broadcasts the authentication response to the main application window using the BroadcastChannel API -- **Redirect flow**: The redirect bridge navigates back to your application's home page with the authentication response in the URL +- **Redirect flow**: The redirect bridge navigates back to your application's page that initiated the redirect with the authentication response in the URL #### How It Works From 0aadd91d0646d4a78ff5b7369919d5231c68c6a9 Mon Sep 17 00:00:00 2001 From: Konstantin Date: Mon, 24 Nov 2025 13:37:59 -0500 Subject: [PATCH 29/45] Update lib/msal-browser/docs/v4-migration.md Co-authored-by: Thomas Norling --- lib/msal-browser/docs/v4-migration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/msal-browser/docs/v4-migration.md b/lib/msal-browser/docs/v4-migration.md index b1eeed3353..25bd619fa3 100644 --- a/lib/msal-browser/docs/v4-migration.md +++ b/lib/msal-browser/docs/v4-migration.md @@ -217,7 +217,7 @@ The following parameters were deprecated in MSAL Browser v4 and have been remove 1. The `protocolMode` parameter has been moved to `SystemOptions` from `BrowserAuthOptions` in Configuration. There are no changes to its options or functionality. 1. The `navigateFrameWait` parameter has been removed. This was previously needed by older browsers which are no longer supported by MSAL.js. -1. The `iframeHashTimeout` and `windowHashTimeout` parameters have been replaced with `iframeBridgeTimeout` and `popupBridgeTimeout` respectively. These timeouts now control how long to wait for a response from the redirect bridge via BroadcastChannel API when COOP is enabled. +1. The `iframeHashTimeout` and `windowHashTimeout` parameters have been replaced with `iframeBridgeTimeout` and `popupBridgeTimeout` respectively. These timeouts now control how long to wait for a response from the redirect bridge via BroadcastChannel API. #### `asyncPopups` From 4c73bbd91788474b730b0203fedf8069ac1514e2 Mon Sep 17 00:00:00 2001 From: Konstantin Date: Mon, 24 Nov 2025 13:53:31 -0500 Subject: [PATCH 30/45] Update lib/msal-browser/docs/v4-migration.md Co-authored-by: Thomas Norling --- lib/msal-browser/docs/v4-migration.md | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/msal-browser/docs/v4-migration.md b/lib/msal-browser/docs/v4-migration.md index 25bd619fa3..d3a2707aae 100644 --- a/lib/msal-browser/docs/v4-migration.md +++ b/lib/msal-browser/docs/v4-migration.md @@ -401,7 +401,6 @@ const msalConfig = { const pca = new PublicClientApplication(msalConfig); ``` -- For a complete working example of COOP support with the redirect bridge, see the [COOP Sample Application](https://github.com/AzureAD/microsoft-authentication-library-for-js/tree/dev/samples/msal-browser-samples/COOP). - For more details on COOP and security considerations, see the [Cross-Origin-Opener-Policy documentation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cross-Origin-Opener-Policy). From 18b1e4f7b31b13060fff8266da2e19f172ad015c Mon Sep 17 00:00:00 2001 From: Konstantin Shabelko Date: Mon, 24 Nov 2025 14:56:31 -0500 Subject: [PATCH 31/45] - Pick up clientId from the session storage interaction key instead of the URI. --- lib/msal-browser/src/redirect_bridge/index.ts | 18 +++++----- .../broadcastResponseToMainFrame.spec.ts | 34 +++++++++---------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/lib/msal-browser/src/redirect_bridge/index.ts b/lib/msal-browser/src/redirect_bridge/index.ts index e0c7349074..b714ef95c8 100644 --- a/lib/msal-browser/src/redirect_bridge/index.ts +++ b/lib/msal-browser/src/redirect_bridge/index.ts @@ -47,7 +47,6 @@ export async function broadcastResponseToMainFrame( } const { - params, payload, urlHash, urlQuery, @@ -66,19 +65,20 @@ export async function broadcastResponseToMainFrame( timeout: DEFAULT_REDIRECT_TIMEOUT_MS, }; - /* - * Retrieve the original navigation URL from sessionStorage - */ let navigateToUrl = ""; - const clientId = params.get("client_id"); - if (clientId) { + const interactionKey = `${PREFIX}.${TemporaryCacheKeys.INTERACTION_STATUS_KEY}}`; try { - const cacheKey = `${PREFIX}.${clientId}.${TemporaryCacheKeys.ORIGIN_URI}`; - navigateToUrl = window.sessionStorage.getItem(cacheKey) || ""; + /* + * Retrieve the original navigation URL from sessionStorage + */ + const { clientId } = JSON.parse(window.sessionStorage.getItem(interactionKey) || ""); + if (clientId) { + const cacheKey = `${PREFIX}.${clientId}.${TemporaryCacheKeys.ORIGIN_URI}`; + navigateToUrl = window.sessionStorage.getItem(cacheKey) || ""; + } } catch (e) { // SessionStorage access may fail in some contexts, use default } - } // Reconstruct full URL with auth response (preserve original format) let fullUrlResponse = ""; diff --git a/lib/msal-browser/test/redirect_bridge/broadcastResponseToMainFrame.spec.ts b/lib/msal-browser/test/redirect_bridge/broadcastResponseToMainFrame.spec.ts index 1ac8ee5cb0..c3d89989ab 100644 --- a/lib/msal-browser/test/redirect_bridge/broadcastResponseToMainFrame.spec.ts +++ b/lib/msal-browser/test/redirect_bridge/broadcastResponseToMainFrame.spec.ts @@ -206,27 +206,22 @@ describe("broadcastResponseToMainFrame", () => { expect(window.close).not.toHaveBeenCalled(); }); - it("uses sessionStorage URL when client_id is present", async () => { + it("uses sessionStorage URL when client_id is present in interaction status", async () => { const testClientId = "test-client-id-123"; const cachedOriginUrl = "https://localhost:8081/custom-page.html"; + // Set up sessionStorage with interaction status containing clientId and type + mockSessionStorage[`msal.interaction.status}`] = JSON.stringify({ + clientId: testClientId, + type: "redirect", + }); + // Set up sessionStorage with cached origin URL mockSessionStorage[`msal.${testClientId}.request.origin`] = cachedOriginUrl; window.location.hash = TEST_HASHES.TEST_SUCCESS_CODE_HASH_REDIRECT; - // Mock URLSearchParams to return our test client_id - const originalURLSearchParams = global.URLSearchParams; - global.URLSearchParams = jest.fn().mockImplementation((query) => { - const params = new originalURLSearchParams(query); - params.get = jest.fn((key) => { - if (key === "client_id") return testClientId; - return new originalURLSearchParams(query).get(key); - }); - return params; - }) as any; - await broadcastResponseToMainFrame(); // Verify navigation was called with cached URL from sessionStorage @@ -234,8 +229,6 @@ describe("broadcastResponseToMainFrame", () => { expect.stringContaining(cachedOriginUrl), expect.any(Object) ); - - global.URLSearchParams = originalURLSearchParams; }); it("uses custom NavigationClient when provided", async () => { @@ -271,12 +264,12 @@ describe("broadcastResponseToMainFrame", () => { expect(mockHistoryReplaceState).not.toHaveBeenCalled(); }); - it("falls back to homepage when client_id is not in URL", async () => { + it("falls back to homepage when client_id is not in interaction status", async () => { window.location.hash = TEST_HASHES.TEST_SUCCESS_CODE_HASH_REDIRECT; await broadcastResponseToMainFrame(); - // Should navigate using homepage since no client_id means no sessionStorage lookup + // Should navigate using homepage since no clientId in session storage means no cached origin URL lookup expect(mockNavigationClient.navigateInternal).toHaveBeenCalled(); const callArgs = ( mockNavigationClient.navigateInternal as jest.Mock @@ -300,7 +293,14 @@ describe("broadcastResponseToMainFrame", () => { it("handles hybrid query + hash response", async () => { const testClientId = "hybrid-client-id"; - window.location.search = `?state=${TEST_STATE_VALUES.TEST_STATE_REDIRECT}&code=test_code&client_id=${testClientId}`; + + // Set up sessionStorage with interaction status containing clientId and type + mockSessionStorage[`msal.interaction.status}`] = JSON.stringify({ + clientId: testClientId, + type: "redirect", + }); + + window.location.search = `?state=${TEST_STATE_VALUES.TEST_STATE_REDIRECT}&code=test_code`; window.location.hash = "#app_hash_fragment"; await broadcastResponseToMainFrame(); From 348c75ed51d9521bfa7233d28ba3112ed4d51f82 Mon Sep 17 00:00:00 2001 From: Konstantin Shabelko Date: Mon, 24 Nov 2025 14:57:15 -0500 Subject: [PATCH 32/45] - Formatting --- lib/msal-browser/src/redirect_bridge/index.ts | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/lib/msal-browser/src/redirect_bridge/index.ts b/lib/msal-browser/src/redirect_bridge/index.ts index b714ef95c8..145e986c39 100644 --- a/lib/msal-browser/src/redirect_bridge/index.ts +++ b/lib/msal-browser/src/redirect_bridge/index.ts @@ -67,18 +67,20 @@ export async function broadcastResponseToMainFrame( let navigateToUrl = ""; const interactionKey = `${PREFIX}.${TemporaryCacheKeys.INTERACTION_STATUS_KEY}}`; - try { - /* - * Retrieve the original navigation URL from sessionStorage - */ - const { clientId } = JSON.parse(window.sessionStorage.getItem(interactionKey) || ""); - if (clientId) { - const cacheKey = `${PREFIX}.${clientId}.${TemporaryCacheKeys.ORIGIN_URI}`; - navigateToUrl = window.sessionStorage.getItem(cacheKey) || ""; - } - } catch (e) { - // SessionStorage access may fail in some contexts, use default + try { + /* + * Retrieve the original navigation URL from sessionStorage + */ + const { clientId } = JSON.parse( + window.sessionStorage.getItem(interactionKey) || "" + ); + if (clientId) { + const cacheKey = `${PREFIX}.${clientId}.${TemporaryCacheKeys.ORIGIN_URI}`; + navigateToUrl = window.sessionStorage.getItem(cacheKey) || ""; } + } catch (e) { + // SessionStorage access may fail in some contexts, use default + } // Reconstruct full URL with auth response (preserve original format) let fullUrlResponse = ""; From d45e1d446b34e7843c9ef20d8da1d7fc80848623 Mon Sep 17 00:00:00 2001 From: Konstantin Shabelko Date: Mon, 24 Nov 2025 15:21:08 -0500 Subject: [PATCH 33/45] - Use `AuthError` to throw in the redirect bridge - Fix unit tests --- lib/msal-browser/src/error/BrowserAuthErrorCodes.ts | 1 + lib/msal-browser/src/redirect_bridge/index.ts | 6 +++--- lib/msal-browser/src/utils/BrowserUtils.ts | 13 +++++++------ .../test/interaction_client/PopupClient.spec.ts | 2 +- .../interaction_client/SilentIframeClient.spec.ts | 4 ++-- 5 files changed, 14 insertions(+), 12 deletions(-) diff --git a/lib/msal-browser/src/error/BrowserAuthErrorCodes.ts b/lib/msal-browser/src/error/BrowserAuthErrorCodes.ts index a40acb7c51..3e42fef364 100644 --- a/lib/msal-browser/src/error/BrowserAuthErrorCodes.ts +++ b/lib/msal-browser/src/error/BrowserAuthErrorCodes.ts @@ -63,3 +63,4 @@ export const failedToBuildHeaders = "failed_to_build_headers"; export const failedToParseHeaders = "failed_to_parse_headers"; export const failedToDecryptEarResponse = "failed_to_decrypt_ear_response"; export const timedOut = "timed_out"; +export const emptyResponse = "empty_response"; diff --git a/lib/msal-browser/src/redirect_bridge/index.ts b/lib/msal-browser/src/redirect_bridge/index.ts index 145e986c39..2f37d6593e 100644 --- a/lib/msal-browser/src/redirect_bridge/index.ts +++ b/lib/msal-browser/src/redirect_bridge/index.ts @@ -24,9 +24,9 @@ import { PREFIX } from "../cache/CacheKeys.js"; * * @returns {Promise} A promise that resolves when the response has been broadcast and cleanup is complete. * - * @throws {Error} If no authentication payload is found in the URL (hash or query string). - * @throws {Error} If the state parameter is missing from the redirect URL. - * @throws {Error} If the state is missing required 'id' or 'meta' attributes. + * @throws {AuthError} If no authentication payload is found in the URL (hash or query string). + * @throws {AuthError} If the state parameter is missing from the redirect URL. + * @throws {AuthError} If the state is missing required 'id' or 'meta' attributes. */ export async function broadcastResponseToMainFrame( navigationClient?: NavigationClient diff --git a/lib/msal-browser/src/utils/BrowserUtils.ts b/lib/msal-browser/src/utils/BrowserUtils.ts index 6ae1ec3663..5b9b397d11 100644 --- a/lib/msal-browser/src/utils/BrowserUtils.ts +++ b/lib/msal-browser/src/utils/BrowserUtils.ts @@ -14,6 +14,7 @@ import { CommonAuthorizationUrlRequest, CommonEndSessionRequest, ProtocolUtils, + AuthError, } from "@azure/msal-common/browser"; import { createBrowserAuthError, @@ -43,9 +44,9 @@ import { base64Decode } from "../encode/Base64Decode.js"; * @returns {string} urlQuery - The original URL query string. * @returns {LibraryStateObject} libraryState - The decoded library state from the state parameter. * - * @throws {Error} If no authentication payload is found in the URL. - * @throws {Error} If the state parameter is missing. - * @throws {Error} If the state is missing required 'id' or 'meta' attributes. + * @throws {AuthError} If no authentication payload is found in the URL. + * @throws {AuthError} If the state parameter is missing. + * @throws {AuthError} If the state is missing required 'id' or 'meta' attributes. */ export function parseAuthResponseFromUrl(): { params: URLSearchParams; @@ -102,12 +103,12 @@ export function parseAuthResponseFromUrl(): { } if (!payload || !params) { - throw new Error("No auth payload found on URL (hash or query)"); + throw new AuthError(BrowserAuthErrorCodes.emptyResponse, "No auth payload found on URL (hash or query)"); } const state = params.get("state"); if (!state) { - throw new Error("Missing state on redirect URL"); + throw new AuthError(BrowserAuthErrorCodes.noStateInHash, "Missing state on redirect URL"); } const { libraryState } = ProtocolUtils.parseRequestState( @@ -117,7 +118,7 @@ export function parseAuthResponseFromUrl(): { const { id, meta } = libraryState; if (!id || !meta) { - throw new Error("Missing state 'id' and/or 'meta' attributes"); + throw new AuthError(BrowserAuthErrorCodes.unableToParseState, "Missing state 'id' and/or 'meta' attributes"); } return { diff --git a/lib/msal-browser/test/interaction_client/PopupClient.spec.ts b/lib/msal-browser/test/interaction_client/PopupClient.spec.ts index ad66dd337a..05f19aea30 100644 --- a/lib/msal-browser/test/interaction_client/PopupClient.spec.ts +++ b/lib/msal-browser/test/interaction_client/PopupClient.spec.ts @@ -997,7 +997,7 @@ describe("PopupClient", () => { .mockImplementation(() => { // Suppress navigation }); - jest.spyOn(PopupUtils, "monitorPopupForHash").mockResolvedValue( + jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockResolvedValue( `#code=validCode&state=${TEST_STATE_VALUES.TEST_STATE_POPUP}` ); jest.spyOn( diff --git a/lib/msal-browser/test/interaction_client/SilentIframeClient.spec.ts b/lib/msal-browser/test/interaction_client/SilentIframeClient.spec.ts index ef914a0e35..2f242b1b1c 100644 --- a/lib/msal-browser/test/interaction_client/SilentIframeClient.spec.ts +++ b/lib/msal-browser/test/interaction_client/SilentIframeClient.spec.ts @@ -1437,8 +1437,8 @@ describe("SilentIframeClient", () => { ); jest.spyOn( - SilentHandler, - "monitorIframeForHash" + BrowserUtils, + "waitForBridgeResponse" ).mockResolvedValue( `#code=validCode&state=${TEST_STATE_VALUES.TEST_STATE_SILENT}` ); From 2644d40e6cab976e29bd116b323a72f4ea0c04c5 Mon Sep 17 00:00:00 2001 From: Konstantin Shabelko Date: Mon, 24 Nov 2025 15:33:32 -0500 Subject: [PATCH 34/45] - Update API doc --- lib/msal-browser/apiReview/msal-browser.api.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/msal-browser/apiReview/msal-browser.api.md b/lib/msal-browser/apiReview/msal-browser.api.md index 31e859fb62..523380d313 100644 --- a/lib/msal-browser/apiReview/msal-browser.api.md +++ b/lib/msal-browser/apiReview/msal-browser.api.md @@ -266,7 +266,8 @@ declare namespace BrowserAuthErrorCodes { failedToBuildHeaders, failedToParseHeaders, failedToDecryptEarResponse, - timedOut + timedOut, + emptyResponse } } export { BrowserAuthErrorCodes } @@ -552,6 +553,11 @@ const earJwkEmpty = "ear_jwk_empty"; // @public (undocumented) const emptyNavigateUri = "empty_navigate_uri"; +// Warning: (ae-missing-release-tag) "emptyResponse" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +const emptyResponse = "empty_response"; + // Warning: (ae-missing-release-tag) "emptyWindowError" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) From 25996a098b3a309e5ab34a63c02c4246dfe0627a Mon Sep 17 00:00:00 2001 From: Konstantin Shabelko Date: Mon, 24 Nov 2025 16:42:55 -0500 Subject: [PATCH 35/45] - Update e2e tests - Fix formatting --- lib/msal-browser/src/utils/BrowserUtils.ts | 15 +++++++-- .../interaction_client/PopupClient.spec.ts | 5 ++- samples/e2eTestUtils/src/TestUtils.ts | 17 ++++------ .../authConfigs/aadAuthConfig.json | 5 +-- .../authConfigs/aadMultiTenantAuthConfig.json | 5 +-- .../authConfigs/aadTenantedAuthConfig.json | 5 +-- .../authConfigs/b2cAuthConfig.json | 5 +-- .../authConfigs/localStorageAuthConfig.json | 5 +-- .../authConfigs/memStorageAuthConfig.json | 5 +-- .../app/customizable-e2e-test/redirect.html | 19 +++++++++++ .../test/localStorage.spec.ts | 32 ++----------------- .../app/customizable-e2e-test/testConfig.json | 5 +-- .../typescript-sample/src/App.tsx | 13 ++++++-- .../typescript-sample/src/authConfig.ts | 2 +- .../typescript-sample/src/pages/Redirect.tsx | 20 ++++++++++++ 15 files changed, 96 insertions(+), 62 deletions(-) create mode 100644 samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/redirect.html create mode 100644 samples/msal-react-samples/typescript-sample/src/pages/Redirect.tsx diff --git a/lib/msal-browser/src/utils/BrowserUtils.ts b/lib/msal-browser/src/utils/BrowserUtils.ts index 5b9b397d11..03c7804dc8 100644 --- a/lib/msal-browser/src/utils/BrowserUtils.ts +++ b/lib/msal-browser/src/utils/BrowserUtils.ts @@ -103,12 +103,18 @@ export function parseAuthResponseFromUrl(): { } if (!payload || !params) { - throw new AuthError(BrowserAuthErrorCodes.emptyResponse, "No auth payload found on URL (hash or query)"); + throw new AuthError( + BrowserAuthErrorCodes.emptyResponse, + "No auth payload found on URL (hash or query)" + ); } const state = params.get("state"); if (!state) { - throw new AuthError(BrowserAuthErrorCodes.noStateInHash, "Missing state on redirect URL"); + throw new AuthError( + BrowserAuthErrorCodes.noStateInHash, + "Missing state on redirect URL" + ); } const { libraryState } = ProtocolUtils.parseRequestState( @@ -118,7 +124,10 @@ export function parseAuthResponseFromUrl(): { const { id, meta } = libraryState; if (!id || !meta) { - throw new AuthError(BrowserAuthErrorCodes.unableToParseState, "Missing state 'id' and/or 'meta' attributes"); + throw new AuthError( + BrowserAuthErrorCodes.unableToParseState, + "Missing state 'id' and/or 'meta' attributes" + ); } return { diff --git a/lib/msal-browser/test/interaction_client/PopupClient.spec.ts b/lib/msal-browser/test/interaction_client/PopupClient.spec.ts index 05f19aea30..86044c6e17 100644 --- a/lib/msal-browser/test/interaction_client/PopupClient.spec.ts +++ b/lib/msal-browser/test/interaction_client/PopupClient.spec.ts @@ -997,7 +997,10 @@ describe("PopupClient", () => { .mockImplementation(() => { // Suppress navigation }); - jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockResolvedValue( + jest.spyOn( + BrowserUtils, + "waitForBridgeResponse" + ).mockResolvedValue( `#code=validCode&state=${TEST_STATE_VALUES.TEST_STATE_POPUP}` ); jest.spyOn( diff --git a/samples/e2eTestUtils/src/TestUtils.ts b/samples/e2eTestUtils/src/TestUtils.ts index cf29e715dd..492a9de963 100644 --- a/samples/e2eTestUtils/src/TestUtils.ts +++ b/samples/e2eTestUtils/src/TestUtils.ts @@ -261,7 +261,7 @@ export async function enterCredentials( await page.waitForNavigation(WAIT_FOR_NAVIGATION_CONFIG).catch(() => {}); await fillUsername(page, screenshot, username); await clickSubmitButton(page, screenshot); - + // agce: which type of account do you want to use try { await page.waitForSelector(HtmlSelectors.AAD_TITLE, { timeout: 1000 }); @@ -276,15 +276,15 @@ export async function enterCredentials( } catch (e) { // } - + await fillPassword(page, screenshot, accountPwd); await clickSubmitButton(page, screenshot); - + if (page.isClosed() || page.url().startsWith(SAMPLE_HOME_URL)) { return; } await screenshot.takeScreenshot(page, "passwordSubmitted"); - + // agce: check if the "help us protect your account" dialog appears try { const selector = @@ -294,7 +294,7 @@ export async function enterCredentials( } catch (e) { // continue } - + // keep me signed in page try { await screenshot.takeScreenshot(page, "keepMeSignedInPage"); @@ -302,7 +302,7 @@ export async function enterCredentials( } catch (e) { return; } - + // agce: private tenant sign in page try { await screenshot.takeScreenshot(page, "privateTenantSignInPage"); @@ -493,11 +493,6 @@ export async function waitForReturnToApp( popupPage?: Page, popupWindowClosed?: Promise ): Promise { - if (popupPage && popupWindowClosed) { - // Wait until popup window closes and see that we are logged in - await popupWindowClosed; - } - // Wait for token acquisition await page.waitForSelector("#scopes-acquired"); await screenshot.takeScreenshot(page, "samplePageLoggedIn"); diff --git a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/aadAuthConfig.json b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/aadAuthConfig.json index 5c7795cd33..0d36b81406 100644 --- a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/aadAuthConfig.json +++ b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/aadAuthConfig.json @@ -2,7 +2,8 @@ "msalConfig": { "auth": { "clientId": "b5c2e510-4a17-4feb-b219-e55aa5b74144", - "authority": "https://login.microsoftonline.com/common" + "authority": "https://login.microsoftonline.com/common", + "redirectUri": "/redirect" }, "cache": { "cacheLocation": "sessionStorage" @@ -16,4 +17,4 @@ "User.Read" ] } -} \ No newline at end of file +} diff --git a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/aadMultiTenantAuthConfig.json b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/aadMultiTenantAuthConfig.json index c63d83c02c..75f3753e0c 100644 --- a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/aadMultiTenantAuthConfig.json +++ b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/aadMultiTenantAuthConfig.json @@ -2,7 +2,8 @@ "msalConfig": { "auth": { "clientId": "b5c2e510-4a17-4feb-b219-e55aa5b74144", - "authority": "https://login.microsoftonline.com/f645ad92-e38d-4d1a-b510-d1b09a74a8ca" + "authority": "https://login.microsoftonline.com/f645ad92-e38d-4d1a-b510-d1b09a74a8ca", + "redirectUri": "/redirect" }, "cache": { "cacheLocation": "sessionStorage" @@ -26,4 +27,4 @@ "authority": "https://login.microsoftonline.com/8e44f19d-bbab-4a82-b76b-4cd0a6fbc97a" } } -} \ No newline at end of file +} diff --git a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/aadTenantedAuthConfig.json b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/aadTenantedAuthConfig.json index bd2a17cd71..20ef8b8da3 100644 --- a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/aadTenantedAuthConfig.json +++ b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/aadTenantedAuthConfig.json @@ -2,7 +2,8 @@ "msalConfig": { "auth": { "clientId": "b5c2e510-4a17-4feb-b219-e55aa5b74144", - "authority": "https://login.microsoftonline.com/f645ad92-e38d-4d1a-b510-d1b09a74a8ca" + "authority": "https://login.microsoftonline.com/f645ad92-e38d-4d1a-b510-d1b09a74a8ca", + "redirectUri": "/redirect" }, "cache": { "cacheLocation": "sessionStorage" @@ -16,4 +17,4 @@ "User.Read" ] } -} \ No newline at end of file +} diff --git a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/b2cAuthConfig.json b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/b2cAuthConfig.json index 5eb872a9bc..9b9afbcc73 100644 --- a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/b2cAuthConfig.json +++ b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/b2cAuthConfig.json @@ -5,7 +5,8 @@ "authority": "https://msidlabb2c.b2clogin.com/msidlabb2c.onmicrosoft.com/B2C_1_SISOPolicy/", "knownAuthorities": [ "msidlabb2c.b2clogin.com" - ] + ], + "redirectUri": "/redirect" }, "cache": { "cacheLocation": "sessionStorage" @@ -19,4 +20,4 @@ "https://msidlabb2c.onmicrosoft.com/4c837770-7a2b-471e-aafa-3328d04a23b1/read" ] } -} \ No newline at end of file +} diff --git a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/localStorageAuthConfig.json b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/localStorageAuthConfig.json index 07338ec644..3cbfc44467 100644 --- a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/localStorageAuthConfig.json +++ b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/localStorageAuthConfig.json @@ -2,7 +2,8 @@ "msalConfig": { "auth": { "clientId": "b5c2e510-4a17-4feb-b219-e55aa5b74144", - "authority": "https://login.microsoftonline.com/common" + "authority": "https://login.microsoftonline.com/common", + "redirectUri": "/redirect" }, "cache": { "cacheLocation": "localStorage" @@ -16,4 +17,4 @@ "User.Read" ] } -} \ No newline at end of file +} diff --git a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/memStorageAuthConfig.json b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/memStorageAuthConfig.json index eb120a7590..56a0743b0e 100644 --- a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/memStorageAuthConfig.json +++ b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/memStorageAuthConfig.json @@ -2,7 +2,8 @@ "msalConfig": { "auth": { "clientId": "b5c2e510-4a17-4feb-b219-e55aa5b74144", - "authority": "https://login.microsoftonline.com/common" + "authority": "https://login.microsoftonline.com/common", + "redirectUri": "/redirect" }, "cache": { "cacheLocation": "memoryStorage" @@ -16,4 +17,4 @@ "User.Read" ] } -} \ No newline at end of file +} diff --git a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/redirect.html b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/redirect.html new file mode 100644 index 0000000000..b26f473552 --- /dev/null +++ b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/redirect.html @@ -0,0 +1,19 @@ + + + + + + Redirect Page + + +

Processing authentication...

+ + + + + + + diff --git a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/test/localStorage.spec.ts b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/test/localStorage.spec.ts index 8061c1ba68..2a713d70d0 100644 --- a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/test/localStorage.spec.ts +++ b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/test/localStorage.spec.ts @@ -157,34 +157,6 @@ describe("LocalStorage Tests", function () { }); }); - it("Closing popup before login resolves clears cache", async () => { - const testName = "popupCloseWindow"; - const screenshot = new Screenshot( - `${SCREENSHOT_BASE_FOLDER_NAME}/${testName}` - ); - const [popupPage, popupWindowClosed] = await clickLoginPopup( - screenshot, - page - ); - await popupPage.waitForNavigation({ waitUntil: "networkidle0" }).catch(() => {}); - await popupPage.close(); - // Wait until popup window closes - await popupWindowClosed; - // Wait for processing - await storagePoller(async () => { - // Temporary Cache always uses sessionStorage - const sessionBrowserStorage = new BrowserCacheUtils( - page, - "sessionStorage" - ); - const sessionStorage = - await sessionBrowserStorage.getWindowStorage(); - const localStorage = await BrowserCache.getWindowStorage(); - expect(Object.keys(localStorage).length).toEqual(2); // Telemetry - expect(Object.keys(sessionStorage).length).toEqual(0); - }, ONE_SECOND_IN_MS); - }); - it.skip("Logging in on one tab updates cache/UI in another tab", async () => { const testName = "multi-tab"; const screenshot = new Screenshot( @@ -233,7 +205,7 @@ describe("LocalStorage Tests", function () { await tab2.waitForFunction(checkSignInState, {}, true); await tab2.waitForSelector("#acquireTokenSilent"); await screenshot.takeScreenshot(tab2, "tab2SignedIn"); - + await tab2.click("#acquireTokenSilent"); await tab2.waitForSelector("#fromCache"); await screenshot.takeScreenshot(tab2, "tab2AcquiredToken"); @@ -252,7 +224,7 @@ describe("LocalStorage Tests", function () { } else if (fromCacheEl.includes("false")) { return false; } - + throw `fromCache element cannot be found or has unexpected value. Value: ${fromCacheEl}`; }; diff --git a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/testConfig.json b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/testConfig.json index eb120a7590..56a0743b0e 100644 --- a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/testConfig.json +++ b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/testConfig.json @@ -2,7 +2,8 @@ "msalConfig": { "auth": { "clientId": "b5c2e510-4a17-4feb-b219-e55aa5b74144", - "authority": "https://login.microsoftonline.com/common" + "authority": "https://login.microsoftonline.com/common", + "redirectUri": "/redirect" }, "cache": { "cacheLocation": "memoryStorage" @@ -16,4 +17,4 @@ "User.Read" ] } -} \ No newline at end of file +} diff --git a/samples/msal-react-samples/typescript-sample/src/App.tsx b/samples/msal-react-samples/typescript-sample/src/App.tsx index 1dbc6859bf..70f091c63f 100644 --- a/samples/msal-react-samples/typescript-sample/src/App.tsx +++ b/samples/msal-react-samples/typescript-sample/src/App.tsx @@ -1,4 +1,4 @@ -import { Routes, Route, useNavigate } from "react-router-dom"; +import { Routes, Route, useNavigate, useLocation } from "react-router-dom"; // Material-UI imports import Grid from "@mui/material/Grid"; @@ -11,6 +11,7 @@ import { CustomNavigationClient } from "./utils/NavigationClient"; import { PageLayout } from "./ui-components/PageLayout"; import { Home } from "./pages/Home"; import { Profile } from "./pages/Profile"; +import { Redirect } from "./pages/Redirect"; type AppProps = { pca: IPublicClientApplication; @@ -19,9 +20,17 @@ type AppProps = { function App({ pca }: AppProps) { // The next 3 lines are optional. This is how you configure MSAL to take advantage of the router's navigate functions when MSAL redirects between pages in your app const navigate = useNavigate(); + const location = useLocation(); const navigationClient = new CustomNavigationClient(navigate); pca.setNavigationClient(navigationClient); + // Don't wrap redirect page in MsalProvider to prevent MSAL from consuming the auth response + const isRedirectPage = location.pathname === "/redirect"; + + if (isRedirectPage) { + return ; + } + return ( @@ -42,4 +51,4 @@ function Pages() { ); } -export default App; \ No newline at end of file +export default App; diff --git a/samples/msal-react-samples/typescript-sample/src/authConfig.ts b/samples/msal-react-samples/typescript-sample/src/authConfig.ts index 605c14cba2..0962909604 100644 --- a/samples/msal-react-samples/typescript-sample/src/authConfig.ts +++ b/samples/msal-react-samples/typescript-sample/src/authConfig.ts @@ -5,7 +5,7 @@ export const msalConfig: Configuration = { auth: { clientId: "b5c2e510-4a17-4feb-b219-e55aa5b74144", authority: "https://login.microsoftonline.com/common", - redirectUri: "/", + redirectUri: "/redirect", postLogoutRedirectUri: "/", }, system: { diff --git a/samples/msal-react-samples/typescript-sample/src/pages/Redirect.tsx b/samples/msal-react-samples/typescript-sample/src/pages/Redirect.tsx new file mode 100644 index 0000000000..1cfff4d35c --- /dev/null +++ b/samples/msal-react-samples/typescript-sample/src/pages/Redirect.tsx @@ -0,0 +1,20 @@ +// This page serves the redirect bridge for MSAL authentication +// It's excluded from MsalProvider wrapper in App.tsx to prevent MSAL from processing the hash +import { useEffect } from "react"; +import { broadcastResponseToMainFrame } from "@azure/msal-browser/redirect-bridge"; + +export function Redirect() { + useEffect(() => { + // Call broadcastResponseToMainFrame when component mounts + broadcastResponseToMainFrame().catch((error) => { + console.error("Error broadcasting response to main frame:", error); + }); + }, []); + + return ( +
+

Processing authentication...

+
+ ); +} + From 9a7bd3dd767d4109f3ec2a825b62df163e1656ea Mon Sep 17 00:00:00 2001 From: Konstantin Shabelko Date: Mon, 24 Nov 2025 19:18:05 -0500 Subject: [PATCH 36/45] - Update e2e tests --- .../src/app/app-routing.module.ts | 10 ++++++-- .../src/app/app.component.html | 8 +++---- .../src/app/app.component.ts | 13 ++++++++-- .../src/app/app.module.ts | 11 +++++---- .../src/app/redirect/redirect.component.ts | 16 +++++++++++++ .../angular-modules-sample/src/index.html | 3 +-- .../angular-modules-sample/test/home.spec.ts | 4 ---- .../msal-react-samples/b2c-sample/src/App.js | 24 +++++++++++++------ .../b2c-sample/src/authConfig.js | 2 +- .../b2c-sample/src/pages/Redirect.jsx | 20 ++++++++++++++++ 10 files changed, 84 insertions(+), 27 deletions(-) create mode 100644 samples/msal-angular-samples/angular-modules-sample/src/app/redirect/redirect.component.ts create mode 100644 samples/msal-react-samples/b2c-sample/src/pages/Redirect.jsx diff --git a/samples/msal-angular-samples/angular-modules-sample/src/app/app-routing.module.ts b/samples/msal-angular-samples/angular-modules-sample/src/app/app-routing.module.ts index 0cec40dbf7..6e02f15ca7 100644 --- a/samples/msal-angular-samples/angular-modules-sample/src/app/app-routing.module.ts +++ b/samples/msal-angular-samples/angular-modules-sample/src/app/app-routing.module.ts @@ -5,8 +5,13 @@ import { BrowserUtils } from '@azure/msal-browser'; import { ProfileComponent } from './profile/profile.component'; import { HomeComponent } from './home/home.component'; import { FailedComponent } from './failed/failed.component'; +import { RedirectComponent } from './redirect/redirect.component'; const routes: Routes = [ + { + path: 'redirect', + component: RedirectComponent, + }, { path: 'profile', component: ProfileComponent, @@ -24,8 +29,9 @@ const routes: Routes = [ @NgModule({ imports: [RouterModule.forRoot(routes, { - // Don't perform initial navigation in iframes or popups - initialNavigation: !BrowserUtils.isInIframe() && !BrowserUtils.isInPopup() ? 'enabledNonBlocking' : 'disabled' // Set to enabledBlocking to use Angular Universal + // Enable navigation for redirect bridge to work in popups + // Only disable navigation in iframes (not in popups) + initialNavigation: !BrowserUtils.isInIframe() ? 'enabledNonBlocking' : 'disabled' })], exports: [RouterModule] }) diff --git a/samples/msal-angular-samples/angular-modules-sample/src/app/app.component.html b/samples/msal-angular-samples/angular-modules-sample/src/app/app.component.html index 281f11c3f5..27f4e40262 100644 --- a/samples/msal-angular-samples/angular-modules-sample/src/app/app.component.html +++ b/samples/msal-angular-samples/angular-modules-sample/src/app/app.component.html @@ -1,4 +1,4 @@ - + {{ title }}
@@ -18,7 +18,7 @@
-
- - + +
+
diff --git a/samples/msal-angular-samples/angular-modules-sample/src/app/app.component.ts b/samples/msal-angular-samples/angular-modules-sample/src/app/app.component.ts index b67493f49e..ce30c1b44e 100644 --- a/samples/msal-angular-samples/angular-modules-sample/src/app/app.component.ts +++ b/samples/msal-angular-samples/angular-modules-sample/src/app/app.component.ts @@ -13,6 +13,7 @@ import { filter, takeUntil } from 'rxjs/operators'; export class AppComponent implements OnInit, OnDestroy { title = 'Angular Modules Sample - MSAL Angular'; isIframe = false; + isRedirectRoute = false; loginDisplay = false; private readonly _destroying$ = new Subject(); @@ -21,12 +22,20 @@ export class AppComponent implements OnInit, OnDestroy { private authService: MsalService, private msalBroadcastService: MsalBroadcastService ) { - + } - async ngOnInit(): Promise { + async ngOnInit(): Promise { this.isIframe = window !== window.parent && !window.opener; // Remove this line to use Angular Universal + // Only initialize MSAL if we're NOT on the redirect route + // The redirect route should be handled by the RedirectComponent which calls broadcastResponseToMainFrame + this.isRedirectRoute = window.location.pathname === '/redirect'; + if (!this.isRedirectRoute) { + // Initialize MSAL and handle redirect responses + this.authService.handleRedirectObservable().subscribe(); + } + this.msalBroadcastService.msalSubject$ .pipe( filter( diff --git a/samples/msal-angular-samples/angular-modules-sample/src/app/app.module.ts b/samples/msal-angular-samples/angular-modules-sample/src/app/app.module.ts index 6971f8db50..429ecf3b5f 100644 --- a/samples/msal-angular-samples/angular-modules-sample/src/app/app.module.ts +++ b/samples/msal-angular-samples/angular-modules-sample/src/app/app.module.ts @@ -11,6 +11,7 @@ import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; import { HomeComponent } from './home/home.component'; import { ProfileComponent } from './profile/profile.component'; +import { RedirectComponent } from './redirect/redirect.component'; import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; import { @@ -31,7 +32,6 @@ import { MSAL_INSTANCE, MSAL_INTERCEPTOR_CONFIG, MsalGuardConfiguration, - MsalRedirectComponent, } from '@azure/msal-angular'; import { FailedComponent } from './failed/failed.component'; import { environment } from 'src/environments/environment'; @@ -45,7 +45,7 @@ export function MSALInstanceFactory(): IPublicClientApplication { auth: { clientId: environment.msalConfig.auth.clientId, authority: environment.msalConfig.auth.authority, - redirectUri: '/', + redirectUri: '/redirect', postLogoutRedirectUri: '/', }, cache: { @@ -85,14 +85,15 @@ export function MSALGuardConfigFactory(): MsalGuardConfiguration { }; } -@NgModule({ +@NgModule({ declarations: [ AppComponent, HomeComponent, ProfileComponent, + RedirectComponent, FailedComponent, ], - bootstrap: [AppComponent, MsalRedirectComponent], + bootstrap: [AppComponent], imports: [ BrowserModule, NoopAnimationsModule, // Animations cause delay which interfere with E2E tests @@ -101,7 +102,7 @@ export function MSALGuardConfigFactory(): MsalGuardConfiguration { MatToolbarModule, MatListModule, MatMenuModule, - MsalModule], + MsalModule], providers: [ { provide: HTTP_INTERCEPTORS, diff --git a/samples/msal-angular-samples/angular-modules-sample/src/app/redirect/redirect.component.ts b/samples/msal-angular-samples/angular-modules-sample/src/app/redirect/redirect.component.ts new file mode 100644 index 0000000000..a69b7c8194 --- /dev/null +++ b/samples/msal-angular-samples/angular-modules-sample/src/app/redirect/redirect.component.ts @@ -0,0 +1,16 @@ +import { Component, OnInit } from '@angular/core'; +import { broadcastResponseToMainFrame } from '@azure/msal-browser/redirect-bridge'; + +@Component({ + selector: 'app-redirect', + template: '

Processing authentication...

', + standalone: false +}) +export class RedirectComponent implements OnInit { + ngOnInit(): void { + // Call broadcastResponseToMainFrame when component initializes + broadcastResponseToMainFrame().catch((error: Error) => { + console.error('Error broadcasting response to main frame:', error); + }); + } +} diff --git a/samples/msal-angular-samples/angular-modules-sample/src/index.html b/samples/msal-angular-samples/angular-modules-sample/src/index.html index 329e3e8fed..b68bbad4ae 100644 --- a/samples/msal-angular-samples/angular-modules-sample/src/index.html +++ b/samples/msal-angular-samples/angular-modules-sample/src/index.html @@ -11,7 +11,6 @@ - - \ No newline at end of file + diff --git a/samples/msal-angular-samples/angular-modules-sample/test/home.spec.ts b/samples/msal-angular-samples/angular-modules-sample/test/home.spec.ts index 0c5d91af51..bb3c098c47 100644 --- a/samples/msal-angular-samples/angular-modules-sample/test/home.spec.ts +++ b/samples/msal-angular-samples/angular-modules-sample/test/home.spec.ts @@ -140,12 +140,8 @@ describe('/ (Home Page)', () => { if (!popupPage) { throw new Error('Popup window was not opened'); } - const popupWindowClosed = new Promise((resolve) => - popupPage.once('close', resolve) - ); await enterCredentials(popupPage, screenshot, username, accountPwd); - await popupWindowClosed; await page.waitForSelector("xpath/.//p[contains(., 'Login successful!')]", { timeout: 3000, diff --git a/samples/msal-react-samples/b2c-sample/src/App.js b/samples/msal-react-samples/b2c-sample/src/App.js index 3f921713ad..5b0f5e5154 100644 --- a/samples/msal-react-samples/b2c-sample/src/App.js +++ b/samples/msal-react-samples/b2c-sample/src/App.js @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { Routes, Route, useNavigate } from "react-router-dom"; +import { Routes, Route, useNavigate, useLocation } from "react-router-dom"; // Material-UI imports import Grid from "@mui/material/Grid"; @@ -13,10 +13,20 @@ import { PageLayout } from "./ui-components/PageLayout"; import { Home } from "./pages/Home"; import { Profile } from "./pages/Profile"; import { Logout } from "./pages/Logout"; +import { Redirect } from "./pages/Redirect"; import { b2cPolicies, loginRequest } from "./authConfig"; function App({ pca }) { + const location = useLocation(); + + // Don't wrap redirect page in MsalProvider to prevent MSAL from consuming the auth response + const isRedirectPage = location.pathname === "/redirect"; + + if (isRedirectPage) { + return ; + } + return ( @@ -32,7 +42,7 @@ function App({ pca }) { /** * This component is optional. This is how you configure MSAL to take advantage of the router's navigate functions when MSAL redirects between pages in your app - */ + */ function ClientSideNavigation({ pca, children }) { const navigate = useNavigate(); const navigationClient = new CustomNavigationClient(navigate); @@ -59,8 +69,8 @@ function Pages() { const callbackId = instance.addEventCallback((event) => { if (event.eventType === EventType.ACQUIRE_TOKEN_SUCCESS && event.payload) { /** - * For the purpose of setting an active account for UI update, we want to consider only the auth - * response resulting from SUSI flow. "tfp" claim in the id token tells us the policy (NOTE: legacy + * For the purpose of setting an active account for UI update, we want to consider only the auth + * response resulting from SUSI flow. "tfp" claim in the id token tells us the policy (NOTE: legacy * policies may use "acr" instead of "tfp"). To learn more about B2C tokens, visit: * https://docs.microsoft.com/en-us/azure/active-directory-b2c/tokens-overview */ @@ -74,14 +84,14 @@ function Pages() { && account.idTokenClaims['tfp'] === b2cPolicies.names.signUpSignIn ); - + let signUpSignInFlowRequest = { scopes: [...loginRequest.scopes], authority: b2cPolicies.authorities.signUpSignIn.authority, account: originalSignInAccount, prompt: PromptValue.NONE }; - + // To get the updated account information instance.acquireTokenPopup(signUpSignInFlowRequest).then(() => { setStatus("update success") @@ -95,7 +105,7 @@ function Pages() { instance.removeEventCallback(callbackId); } } - // eslint-disable-next-line + // eslint-disable-next-line }, []); return ( diff --git a/samples/msal-react-samples/b2c-sample/src/authConfig.js b/samples/msal-react-samples/b2c-sample/src/authConfig.js index c42fca939c..96e1d94e41 100644 --- a/samples/msal-react-samples/b2c-sample/src/authConfig.js +++ b/samples/msal-react-samples/b2c-sample/src/authConfig.js @@ -38,7 +38,7 @@ export const msalConfig = { clientId: "e3b9ad76-9763-4827-b088-80c7a7888f79", authority: b2cPolicies.authorities.signUpSignIn.authority, knownAuthorities: [b2cPolicies.authorityDomain], - redirectUri: "/", + redirectUri: "/redirect", postLogoutRedirectUri: "/", onRedirectNavigate: () => !BrowserUtils.isInIframe() }, diff --git a/samples/msal-react-samples/b2c-sample/src/pages/Redirect.jsx b/samples/msal-react-samples/b2c-sample/src/pages/Redirect.jsx new file mode 100644 index 0000000000..dd4529aaf3 --- /dev/null +++ b/samples/msal-react-samples/b2c-sample/src/pages/Redirect.jsx @@ -0,0 +1,20 @@ +// This page serves the redirect bridge for MSAL authentication +// It's excluded from MsalProvider wrapper in App.js to prevent MSAL from processing the hash +import { useEffect } from "react"; +import { broadcastResponseToMainFrame } from "@azure/msal-browser/redirect-bridge"; + +export function Redirect() { + useEffect(() => { + // Call broadcastResponseToMainFrame when component mounts + broadcastResponseToMainFrame().catch((error) => { + console.error("Error broadcasting response to main frame:", error); + }); + }, []); + + return ( +
+

Processing authentication...

+
+ ); +} + From 9c32ec004244b341a69770c3cde313c67177120f Mon Sep 17 00:00:00 2001 From: Konstantin Shabelko Date: Mon, 24 Nov 2025 20:24:06 -0500 Subject: [PATCH 37/45] - Add visual examples on how to handle interaction override flag. --- lib/msal-browser/docs/login-user.md | 125 ++++++++++++++++++ lib/msal-browser/src/request/PopupRequest.ts | 32 +---- .../ExpressSample/public/css/styles.css | 88 +++++++++++- .../ExpressSample/public/js/app.js | 84 ++++++++---- .../ExpressSample/public/js/auth.js | 85 +++++++++++- .../ExpressSample/views/layouts/main.hbs | 66 ++++++--- .../src/ui-components/SignInButton.jsx | 103 ++++++++++++++- 7 files changed, 504 insertions(+), 79 deletions(-) diff --git a/lib/msal-browser/docs/login-user.md b/lib/msal-browser/docs/login-user.md index a2d003b2ff..589dfa1178 100644 --- a/lib/msal-browser/docs/login-user.md +++ b/lib/msal-browser/docs/login-user.md @@ -182,6 +182,131 @@ msalInstance.loginPopup({ }); ``` +## Handling popup `interaction_in_progress` errors + +For popup flows, you can use the `overrideInteractionInProgress` flag to cancel a pending interaction and start a new one. This is useful for recovery scenarios where the user cancelled a popup or an interaction failed. + +> **Note:** This feature is **only available for popup flows** and is **not supported for redirect flows**. With the COOP (Cross-Origin-Opener-Policy) header, the traditional `window.opener` connection is severed, allowing popup windows to communicate with the main frame only via BroadcastChannel. + +**WARNING**: Use with extreme caution! Setting this to `true` will forcefully cancel any pending popup interaction. + +**When set to `true`:** +- If another popup interaction is currently in progress, it will be forcefully cancelled +- The pending interaction will reject with an `interaction_in_progress_cancelled` error +- The new popup flow will proceed immediately + +**Valid use cases:** +- Recovering from errors where the user cancelled a popup (popup was closed without completing auth) +- Implementing custom error recovery flows +- Providing a "retry" mechanism after a failed popup interaction + +**Default:** `false` + +### Important: Only Use on Button Click + +**Do NOT automatically retry** when catching an `interaction_in_progress` error. The override should **only** be triggered by an explicit user action (such as clicking a "Retry" button). Automatically overriding interactions can lead to: +- Race conditions between multiple authentication flows +- Unexpected cancellations of legitimate authentication attempts +- Poor user experience with authentication flows starting and stopping unexpectedly + +### Example: Proper error handling with user-triggered retry + +For complete implementations with visual feedback, see: +- **[ExpressSample](../../../samples/msal-browser-samples/ExpressSample)** - Demonstrates JavaScript implementation with custom CSS +- **[React Router Sample](../../../samples/msal-react-samples/react-router-sample)** - Demonstrates React implementation with Material-UI components + +Both samples demonstrate: +- Warning message displayed during popup authentication +- Retry modal/dialog with clear explanation when `interaction_in_progress` error occurs +- Proper state management for user-triggered retry +- Production-ready UI components + +```typescript +// State to track if user wants to retry +let userWantsRetry = false; + +// Button click handler +async function handleLoginClick() { + try { + const loginRequest = { + scopes: ["user.read"] + }; + + // If user explicitly clicked retry, override the existing interaction + if (userWantsRetry) { + loginRequest.overrideInteractionInProgress = true; + userWantsRetry = false; // Reset flag + } + + const response = await msalInstance.loginPopup(loginRequest); + // Handle successful login + } catch (error) { + if (error.errorCode === 'interaction_in_progress') { + // Show retry button to user - DO NOT automatically retry + showRetryButton(); + } else { + // Handle other errors + console.error(error); + } + } +} + +// Retry button click handler +function handleRetryClick() { + userWantsRetry = true; // Set flag for next login attempt + handleLoginClick(); // User explicitly requested retry +} +``` + +### Example: React component with user-triggered retry + + +```jsx +function LoginButton() { + const { instance } = useMsal(); + const [showRetry, setShowRetry] = useState(false); + const [retryRequested, setRetryRequested] = useState(false); + + const handleLogin = async () => { + try { + const loginRequest = { + scopes: ["user.read"], + // Only override if user clicked the retry button + overrideInteractionInProgress: retryRequested + }; + + setRetryRequested(false); // Reset retry flag + + const response = await instance.loginPopup(loginRequest); + setShowRetry(false); + } catch (error) { + if (error.errorCode === 'interaction_in_progress') { + // Show retry button - let user decide whether to retry + setShowRetry(true); + } else { + console.error(error); + } + } + }; + + const handleRetry = () => { + setRetryRequested(true); // User explicitly requested retry + handleLogin(); + }; + + return ( +
+ + {showRetry && ( + + )} +
+ ); +} +``` + # Next Steps Learn how to [acquire and use an access token](./acquire-token.md)! diff --git a/lib/msal-browser/src/request/PopupRequest.ts b/lib/msal-browser/src/request/PopupRequest.ts index 9a7e9d6399..e88035fe8e 100644 --- a/lib/msal-browser/src/request/PopupRequest.ts +++ b/lib/msal-browser/src/request/PopupRequest.ts @@ -32,37 +32,7 @@ import { PopupWindowAttributes } from "./PopupWindowAttributes.js"; * - nonce - A value included in the request that is returned in the id token. A randomly generated unique value is typically used to mitigate replay attacks. * - popupWindowAttributes - Optional popup window attributes. popupSize with height and width, and popupPosition with top and left can be set. * - popupWindowParent - Optional window object to use as the parent when opening popup windows. Uses global `window` if not given. - * - overrideInteractionInProgress - Optional flag to allow overriding an existing interaction_in_progress state for popup flows. - * - * **WARNING**: Use with caution! Setting this to true will cancel any pending popup interaction and start a new one. - * This can lead to unexpected behavior if not handled carefully. - * - * When set to true: - * - If another popup interaction is currently in progress, it will be forcefully cancelled - * - The pending interaction will reject with an `interaction_in_progress_cancelled` error - * - The new popup flow will proceed immediately - * - * Valid use cases: - * - Recovering from errors where the user cancelled a popup (popup was closed without completing auth) - * - Implementing custom error recovery flows - * - Providing a "retry" mechanism after a failed popup interaction - * - * Default: false - * - * Example usage: - * ```typescript - * try { - * await msalInstance.loginPopup(loginRequest); - * } catch (error) { - * if (error.errorCode === 'interaction_in_progress') { - * // User wants to retry - override the existing interaction - * await msalInstance.loginPopup({ - * ...loginRequest, - * overrideInteractionInProgress: true - * }); - * } - * } - * ``` + * - overrideInteractionInProgress - Optional flag to allow overriding an existing interaction_in_progress state for popup flows. **WARNING**: Use with caution! For usage details and examples, see the [login-user.md](../../../docs/login-user.md#handling-interaction_in_progress-errors) documentation. */ export type PopupRequest = Partial< diff --git a/samples/msal-browser-samples/ExpressSample/public/css/styles.css b/samples/msal-browser-samples/ExpressSample/public/css/styles.css index c62a2e22f0..797c2c326a 100644 --- a/samples/msal-browser-samples/ExpressSample/public/css/styles.css +++ b/samples/msal-browser-samples/ExpressSample/public/css/styles.css @@ -843,4 +843,90 @@ main { height: 35px; margin-right: 0.75rem; } -} \ No newline at end of file +} + +/* Popup Warning Alert */ +.popup-warning { + position: fixed; + bottom: 20px; + right: 20px; + z-index: 1050; + max-width: 400px; + background-color: #d1ecf1; + border: 1px solid #bee5eb; + border-left: 4px solid #17a2b8; + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + animation: slideIn 0.3s ease-out; +} + +.popup-warning-content { + display: flex; + align-items: flex-start; + padding: 1rem; +} + +.popup-warning-icon { + font-size: 1.5rem; + margin-right: 0.75rem; + flex-shrink: 0; +} + +.popup-warning-text { + flex: 1; +} + +.popup-warning-text strong { + display: block; + color: #0c5460; + margin-bottom: 0.25rem; + font-size: 1rem; +} + +.popup-warning-text p { + color: #0c5460; + font-size: 0.875rem; + margin: 0; + line-height: 1.4; +} + +@keyframes slideIn { + from { + transform: translateY(100%); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +/* Retry Modal Specific Styles */ +.retry-question { + margin-top: 1rem; + font-weight: 500; +} + +.warning-box { + background-color: #fff3cd; + border: 1px solid #ffeaa7; + border-left: 4px solid #ffc107; + padding: 1rem; + border-radius: 4px; + margin-top: 1rem; +} + +.warning-box strong { + color: #856404; +} + +/* Responsive adjustments for popup warning */ +@media (max-width: 768px) { + .popup-warning { + bottom: 10px; + right: 10px; + left: 10px; + max-width: none; + } +} + diff --git a/samples/msal-browser-samples/ExpressSample/public/js/app.js b/samples/msal-browser-samples/ExpressSample/public/js/app.js index b4d7af9cce..fab3f835f4 100644 --- a/samples/msal-browser-samples/ExpressSample/public/js/app.js +++ b/samples/msal-browser-samples/ExpressSample/public/js/app.js @@ -4,12 +4,14 @@ */ // Main application entry point -import { +import { initializeMsal, signInPopup, signInRedirect, signOutPopup, signOutRedirect, + handleRetry, + handleCancelRetry, msalInstance } from './auth.js'; import { toggleDropdown, closeAllDropdowns, updateUI } from './ui.js'; @@ -25,18 +27,18 @@ function setupEventListeners() { const signInDropdown = document.getElementById('signInDropdown'); const signInPopupBtn = document.getElementById('signInPopup'); const signInRedirectBtn = document.getElementById('signInRedirect'); - + // Account dropdown (for authenticated users) const accountButton = document.getElementById('accountButton'); const accountDropdown = document.getElementById('accountDropdown'); const switchAccountBtn = document.getElementById('switchAccount'); const signOutPopupBtn = document.getElementById('signOutPopup'); const signOutRedirectBtn = document.getElementById('signOutRedirect'); - + // Account picker modal const accountPickerModal = document.getElementById('accountPickerModal'); const modalClose = document.querySelector('.modal-close'); - + // Toggle sign-in dropdown if (signInButton && signInDropdown) { signInButton.addEventListener('click', function(e) { @@ -47,7 +49,7 @@ function setupEventListeners() { signInButton.style.display = ''; } - + // Toggle account dropdown if (accountButton && accountDropdown) { accountButton.addEventListener('click', function(e) { @@ -56,7 +58,7 @@ function setupEventListeners() { toggleDropdown(accountButton.parentElement); }); } - + // Close dropdowns when clicking outside document.addEventListener('click', function(e) { const dropdowns = document.querySelectorAll('.dropdown'); @@ -68,7 +70,7 @@ function setupEventListeners() { } }); }); - + // Handle keyboard navigation for dropdowns document.addEventListener('keydown', function(e) { if (e.key === 'Escape') { @@ -76,7 +78,7 @@ function setupEventListeners() { closeAccountPickerModal(); } }); - + // Sign in event handlers if (signInPopupBtn) { signInPopupBtn.addEventListener('click', function(e) { @@ -85,7 +87,7 @@ function setupEventListeners() { signInPopup(); }); } - + if (signInRedirectBtn) { signInRedirectBtn.addEventListener('click', function(e) { e.preventDefault(); @@ -93,25 +95,25 @@ function setupEventListeners() { signInRedirect(); }); } - + // Profile page sign in buttons (may not exist on all pages) const profileSignInPopupBtn = document.getElementById('profileSignInPopup'); const profileSignInRedirectBtn = document.getElementById('profileSignInRedirect'); - + if (profileSignInPopupBtn) { profileSignInPopupBtn.addEventListener('click', function(e) { e.preventDefault(); signInPopup(); }); } - + if (profileSignInRedirectBtn) { profileSignInRedirectBtn.addEventListener('click', function(e) { e.preventDefault(); signInRedirect(); }); } - + // Profile page refresh button (may not exist on all pages) const refreshProfileBtn = document.getElementById('refreshProfileBtn'); if (refreshProfileBtn) { @@ -124,7 +126,7 @@ function setupEventListeners() { } }); } - + // Account management event handlers if (switchAccountBtn) { switchAccountBtn.addEventListener('click', function(e) { @@ -133,7 +135,7 @@ function setupEventListeners() { showAccountPickerModal(); }); } - + if (signOutPopupBtn) { signOutPopupBtn.addEventListener('click', function(e) { e.preventDefault(); @@ -141,7 +143,7 @@ function setupEventListeners() { signOutPopup(); }); } - + if (signOutRedirectBtn) { signOutRedirectBtn.addEventListener('click', function(e) { e.preventDefault(); @@ -149,12 +151,12 @@ function setupEventListeners() { signOutRedirect(); }); } - + // Modal event handlers if (modalClose) { modalClose.addEventListener('click', closeAccountPickerModal); } - + if (accountPickerModal) { accountPickerModal.addEventListener('click', function(e) { if (e.target === accountPickerModal) { @@ -162,16 +164,48 @@ function setupEventListeners() { } }); } - + + // Retry modal event handlers + const retryBtn = document.getElementById('retryButton'); + const cancelRetryBtn = document.getElementById('cancelRetryButton'); + const retryModal = document.getElementById('retry-modal'); + const retryModalClose = document.querySelector('#retry-modal .modal-close'); + + if (retryBtn) { + retryBtn.addEventListener('click', function(e) { + e.preventDefault(); + handleRetry(); + }); + } + + if (cancelRetryBtn) { + cancelRetryBtn.addEventListener('click', function(e) { + e.preventDefault(); + handleCancelRetry(); + }); + } + + if (retryModalClose) { + retryModalClose.addEventListener('click', handleCancelRetry); + } + + if (retryModal) { + retryModal.addEventListener('click', function(e) { + if (e.target === retryModal) { + handleCancelRetry(); + } + }); + } + // Setup SPA Navigation setupSPANavigation(); } // DOM ready function -document.addEventListener('DOMContentLoaded', async function() { +document.addEventListener('DOMContentLoaded', async function() { // Validate environment variables first and show warning if needed const envValid = validateEnvironmentVariables(); - + // Only proceed with MSAL initialization if environment is properly configured if (!envValid) { console.warn('MSAL initialization skipped due to missing environment variables'); @@ -179,16 +213,16 @@ document.addEventListener('DOMContentLoaded', async function() { setupEventListeners(); return; } - + // Initialize MSAL await initializeMsal(); - + // Refresh authentication state (this will also call updateUI) updateUI(msalInstance.getActiveAccount()); - + // Setup all event listeners setupEventListeners(); - + // Handle initial route await handleRouting(); }); diff --git a/samples/msal-browser-samples/ExpressSample/public/js/auth.js b/samples/msal-browser-samples/ExpressSample/public/js/auth.js index c7f5be86d3..efe9ca89e3 100644 --- a/samples/msal-browser-samples/ExpressSample/public/js/auth.js +++ b/samples/msal-browser-samples/ExpressSample/public/js/auth.js @@ -12,6 +12,9 @@ import { createMsalConfig, loginRequest } from './authConfig.js'; // MSAL instance export let msalInstance; +// Retry state tracking +let retryRequested = false; + // Initialize MSAL export async function initializeMsal() { try { @@ -56,14 +59,36 @@ export async function handleProtectedRouteAuth(path) { // Sign in with popup export async function signInPopup() { + // Show warning message when popup is about to open + showPopupWarning(); + try { - const response = await msalInstance.loginPopup(loginRequest); + const response = await msalInstance.loginPopup({ + ...loginRequest, + // Only override if user explicitly clicked retry + overrideInteractionInProgress: retryRequested + }); + + // Hide warning on success + hidePopupWarning(); + retryRequested = false; + msalInstance.setActiveAccount(response.account); updateUI(response.account); showSuccess('Successfully signed in!'); } catch (error) { - console.error('Popup sign in failed:', error); - showError('Sign in failed: ' + error.message); + // Hide warning on error + hidePopupWarning(); + + if (error.errorCode === 'interaction_in_progress') { + // Show retry modal - let user decide whether to retry + showRetryModal(); + } else { + // Reset retry flag for other errors + retryRequested = false; + console.error('Popup sign in failed:', error); + showError('Sign in failed: ' + error.message); + } } } @@ -126,3 +151,57 @@ export async function getAccessToken() { throw error; }); } + +/** + * Show warning message during popup authentication + */ +function showPopupWarning() { + const warningDiv = document.getElementById('popup-warning'); + if (warningDiv) { + warningDiv.style.display = 'block'; + } +} + +/** + * Hide warning message + */ +function hidePopupWarning() { + const warningDiv = document.getElementById('popup-warning'); + if (warningDiv) { + warningDiv.style.display = 'none'; + } +} + +/** + * Show retry modal for interaction_in_progress error + */ +function showRetryModal() { + const modal = document.getElementById('retry-modal'); + if (modal) { + modal.style.display = 'block'; + } +} + +/** + * Handle user clicking retry button + */ +export function handleRetry() { + retryRequested = true; // User explicitly requested retry + const modal = document.getElementById('retry-modal'); + if (modal) { + modal.style.display = 'none'; + } + signInPopup(); +} + +/** + * Handle user canceling retry + */ +export function handleCancelRetry() { + retryRequested = false; + const modal = document.getElementById('retry-modal'); + if (modal) { + modal.style.display = 'none'; + } +} + diff --git a/samples/msal-browser-samples/ExpressSample/views/layouts/main.hbs b/samples/msal-browser-samples/ExpressSample/views/layouts/main.hbs index fcd5d9340d..051ddd1120 100644 --- a/samples/msal-browser-samples/ExpressSample/views/layouts/main.hbs +++ b/samples/msal-browser-samples/ExpressSample/views/layouts/main.hbs @@ -12,7 +12,7 @@
- + - +
- +
- +
+ + + + + + - + - + - + diff --git a/samples/msal-react-samples/react-router-sample/src/ui-components/SignInButton.jsx b/samples/msal-react-samples/react-router-sample/src/ui-components/SignInButton.jsx index 894342c854..521b50ca7d 100644 --- a/samples/msal-react-samples/react-router-sample/src/ui-components/SignInButton.jsx +++ b/samples/msal-react-samples/react-router-sample/src/ui-components/SignInButton.jsx @@ -3,26 +3,70 @@ import { useMsal } from "@azure/msal-react"; import Button from "@mui/material/Button"; import MenuItem from '@mui/material/MenuItem'; import Menu from '@mui/material/Menu'; +import Alert from '@mui/material/Alert'; +import AlertTitle from '@mui/material/AlertTitle'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogContentText from '@mui/material/DialogContentText'; +import DialogTitle from '@mui/material/DialogTitle'; import { loginRequest } from "../authConfig"; export const SignInButton = () => { const { instance } = useMsal(); const [anchorEl, setAnchorEl] = useState(null); + const [showRetryDialog, setShowRetryDialog] = useState(false); + const [retryRequested, setRetryRequested] = useState(false); + const [showPopupWarning, setShowPopupWarning] = useState(false); const open = Boolean(anchorEl); - const handleLogin = (loginType) => { + const handleLogin = async (loginType) => { setAnchorEl(null); if (loginType === "popup") { - instance.loginPopup({ - ...loginRequest, - }); + // Show warning when popup is about to open + setShowPopupWarning(true); + + try { + await instance.loginPopup({ + ...loginRequest, + // Only override if user explicitly clicked retry + overrideInteractionInProgress: retryRequested + }); + + // Hide warning on success + setShowPopupWarning(false); + setRetryRequested(false); + } catch (error) { + // Hide warning on error + setShowPopupWarning(false); + + if (error.errorCode === 'interaction_in_progress') { + // Show retry dialog - let user decide whether to retry + setShowRetryDialog(true); + } else { + // Reset retry flag for other errors + setRetryRequested(false); + console.error(error); + } + } } else if (loginType === "redirect") { instance.loginRedirect(loginRequest); } } + const handleRetry = () => { + setShowRetryDialog(false); + setRetryRequested(true); // User explicitly requested retry + handleLogin("popup"); + } + + const handleCancelRetry = () => { + setShowRetryDialog(false); + setRetryRequested(false); + } + return (
+ + +
) }; From a5572f0f5e33610c33ae9d3803442e81b3b57db1 Mon Sep 17 00:00:00 2001 From: Konstantin Shabelko Date: Mon, 24 Nov 2025 20:38:00 -0500 Subject: [PATCH 38/45] - Update API doc --- lib/msal-browser/apiReview/msal-browser.api.md | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/lib/msal-browser/apiReview/msal-browser.api.md b/lib/msal-browser/apiReview/msal-browser.api.md index 523380d313..7b6099838e 100644 --- a/lib/msal-browser/apiReview/msal-browser.api.md +++ b/lib/msal-browser/apiReview/msal-browser.api.md @@ -1148,16 +1148,6 @@ export type PopupPosition = { left: number; }; -// Warning: (tsdoc-code-fence-opening-indent) The opening backtick for a code fence must appear at the start of the line -// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" -// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag -// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" -// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" -// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" -// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag -// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag -// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag -// Warning: (tsdoc-code-fence-opening-indent) The opening backtick for a code fence must appear at the start of the line // Warning: (ae-missing-release-tag) "PopupRequest" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public From cfef9f89809cf785c43bfad19ca29f2db0fc6cd1 Mon Sep 17 00:00:00 2001 From: Konstantin Shabelko Date: Tue, 25 Nov 2025 16:17:54 -0500 Subject: [PATCH 39/45] - Update redirect URI switch for express sample. --- samples/msal-browser-samples/ExpressSample/server.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/samples/msal-browser-samples/ExpressSample/server.js b/samples/msal-browser-samples/ExpressSample/server.js index ef59f21b3b..7c615d6164 100644 --- a/samples/msal-browser-samples/ExpressSample/server.js +++ b/samples/msal-browser-samples/ExpressSample/server.js @@ -41,16 +41,18 @@ app.use(express.json()); // Parse JSON bodies // Serve MSAL library from local build app.use('/lib/msal-browser', express.static(path.join(__dirname, '../../../lib/msal-browser/lib'))); +const localBuildName = 'Local Build'; +const localBuildDebugName = 'Local Build (Debug)'; // Dynamic MSAL version management let currentMsalVersion = 'local'; // Default to local build let availableVersions = { 'local': { - name: 'Local Build', + name: localBuildName, path: '/lib/msal-browser/msal-browser.min.js', description: 'Locally built version from repository' }, 'local-debug': { - name: 'Local Build (Debug)', + name: localBuildDebugName, path: '/lib/msal-browser/msal-browser.js', description: 'Locally built debug version from repository' }, @@ -184,10 +186,11 @@ function getCurrentVersionInfo() { // Helper function to pass environment variables and version info to templates const getEnvConfig = (version) => { + const majorVersion = parseInt(version.info.name.match(/v(\d+)\.(?:x|\d+)/)?.[1] || "0", 10); const config = { CLIENT_ID: process.env.CLIENT_ID, AUTHORITY: process.env.AUTHORITY, - REDIRECT_URI: parseInt(version.current.substr(0,1)) < 5 ? process.env.REDIRECT_URI : `${process.env.REDIRECT_URI}/redirect`, + REDIRECT_URI: version.info.name === localBuildName || version.info.name === localBuildDebugName || majorVersion >= 5 ? `${process.env.REDIRECT_URI}/redirect` : process.env.REDIRECT_URI, POST_LOGOUT_REDIRECT_URI: process.env.POST_LOGOUT_REDIRECT_URI }; From be545c8334e977b0de8d0618cc9d206c1fc4c7fc Mon Sep 17 00:00:00 2001 From: Konstantin Shabelko Date: Tue, 25 Nov 2025 17:50:48 -0500 Subject: [PATCH 40/45] - Address review comments --- docs/errors.md | 214 +++++++++--------- .../apiReview/msal-browser.api.md | 8 +- .../src/error/BrowserAuthErrorCodes.ts | 1 - lib/msal-browser/src/utils/BrowserUtils.ts | 12 +- .../interaction_client/PopupClient.spec.ts | 6 +- .../SilentIframeClient.spec.ts | 6 +- .../interaction_handler/SilentHandler.spec.ts | 6 +- .../test/utils/BrowserUtils.spec.ts | 3 +- 8 files changed, 131 insertions(+), 125 deletions(-) diff --git a/docs/errors.md b/docs/errors.md index 0b14708560..2d9e932e45 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -604,111 +604,6 @@ const promise2 = msalInstance.acquireTokenPopup(request2); - User cancelled the flow. -### `redirect_bridge_timeout` - -- Communication with the redirect page (popup or iframe) timed out while waiting for authentication response. - -**Error Messages**: - -- Token acquisition in popup failed due to timeout. -- Token acquisition in iframe failed due to timeout. - -This error can be thrown when calling `ssoSilent`, `acquireTokenSilent`, `acquireTokenPopup` or `loginPopup` when the redirect bridge script fails to send the authentication response back to the main window within the configured timeout period. - -**What is the redirect bridge?** - -The redirect bridge is a mechanism that enables authentication flows in COOP (Cross-Origin-Opener-Policy) enabled applications. When COOP headers are present, popup and iframe windows cannot directly communicate with the main application window. The redirect bridge solves this by using the BroadcastChannel API to transmit authentication responses from the redirect page back to the main window. For more details on COOP support and the redirect bridge, see the [COOP Migration Guide](../lib/msal-browser/docs/v4-migration.md#cross-origin-opener-policy-coop-support). - -**Common Causes:** - -This timeout typically occurs for the following reasons: - -1. The page you use as your `redirectUri` is not loading the `msal-redirect-bridge.js` script -1. The redirect page is removing or manipulating the hash before the bridge script can process it -1. The redirect page is automatically navigating to a different page before the bridge can communicate the response -1. Your identity provider is being slow to redirect back to your `redirectUri` (network latency) -1. You are being throttled by your identity provider due to too many requests in a short period - -**Resolution Steps:** - -✔️ **Ensure the redirect bridge script is loaded:** - -Your `redirectUri` page must include the redirect bridge script to enable communication back to the main window: - -```html - - - - Redirect - - - - - - -``` - -**Important**: If your application uses a router library (e.g. React Router, Angular Router), please make sure it does not strip the hash or auto-redirect while MSAL token acquisition is in progress. If possible, it is best if your `redirectUri` page does not invoke the router at all. - -#### Issues caused by the redirectUri page - -When you make a silent call, in some cases, an iframe will be opened and will navigate to your identity provider's authorization page. After the identity provider has authorized the user it will redirect the iframe back to the `redirectUri` with the authorization code or error information in the hash fragment. The MSAL redirect bridge running in the iframe will broadcast response to MSAL instance running in the frame or window that originally made the request. If your `redirectUri` is removing or manipulating this hash or navigating to a different page before MSAL redirect bridge has extracted it you will receive this timeout error. - -✔️ To solve this problem you should ensure that the page you use as your `redirectUri` is not doing any of these things, at the very least, when loaded in a popup or iframe. - -Remember that you will need to register `redirectUri` on your App Registration. - -**Notes regarding Angular and React:** - -- If you are using `@azure/msal-angular` your `redirectUri` page should not be protected by the `MsalGuard`. -- If you are using `@azure/msal-react` your `redirectUri` page should not render the `MsalAuthenticationComponent` or use the `useMsalAuthentication` hook. - -#### Issues caused by the Identity Provider - -#### Throttling - -One of the most common reasons this error can be thrown is that your application has gotten stuck in a loop or made too many token requests in a short amount of time. When this happens the identity provider may throttle subsequent requests for a short time which will result in not being redirected back to your `redirectUri` and ultimately this error. - -✔️ To resolve throttling based issues you have 2 options: - -1. Stop making requests for a short time before trying again. -1. Invoke an interactive API, such as `acquireTokenPopup` or `acquireTokenRedirect`. - -##### X-Frame-Options Deny - -You can also get this error if the Identity Provider fails to redirect back to your application. In silent scenarios this error is sometimes accompanied by an X-Frame-Options: Deny error indicating that your identity provider is attempting to either show you an error message or is expecting interaction. - -✔️ The X-Frame-Options error will usually have a url in it and opening this url in a new tab may help you discern what is happening. If interaction is required consider using an interactive API instead. If an error is being displayed, address the error. - -Some B2C flows are expected to throw this error due to their need for user interaction. These flows include: - -- Password reset -- Profile edit -- Sign up -- Some custom policies depending on how they are configured - -##### Network Latency - -Another potential reason the identity provider may not redirect back to your application in time may be that there is some extra network latency. - -✔️ The default timeout is about 10 seconds and should be sufficient in most cases, however, if your identity provider is taking longer than that to redirect, you can increase this timeout in the MSAL config with either the `iframeBridgeTimeout` (for aquireTokenSilent() or ssoSilent()) or `popupBridgeTimeout` (acquireTokenPopup()) configuration parameters. - -```javascript -const msalConfig = { - auth: { - clientId: "your-client-id", - }, - system: { - popupBridgeTimeout: 50000, // Applies just to popup calls - In milliseconds - iframeBridgeTimeout: 9000, // Applies just to silent calls - In milliseconds - }, -}; -``` - -> [!IMPORTANT] -> Please consult the [Troubleshooting Single-Sign On](https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/FAQ.md#troubleshooting-single-sign-on) section of the MSAL Browser FAQ if you are having trouble with the `ssoSilent` API. ### `redirect_bridge_empty_response` @@ -916,6 +811,115 @@ msalInstance.acquireTokenSilent(); // This will also no longer throw this error If this error is thrown from `acquireTokenRedirect` it means your application failed to redirect to your identity provider's /authorize endpoint in time. Review the network trace to identify potential causes. +#### redirect_bridge_timeout (suberror) + +**Error Code**: `timed_out` +**SubError**: `redirect_bridge_timeout` + +Communication with the redirect page (popup or iframe) timed out while waiting for authentication response. + +**Error Messages**: + +- Token acquisition in popup failed due to timeout. +- Token acquisition in iframe failed due to timeout. + +This suberror is thrown when calling `ssoSilent`, `acquireTokenSilent`, `acquireTokenPopup` or `loginPopup` when the redirect bridge script fails to send the authentication response back to the main window within the configured timeout period. + +**What is the redirect bridge?** + +The redirect bridge is a mechanism that enables authentication flows in COOP (Cross-Origin-Opener-Policy) enabled applications. When COOP headers are present, popup and iframe windows cannot directly communicate with the main application window. The redirect bridge solves this by using the BroadcastChannel API to transmit authentication responses from the redirect page back to the main window. For more details on COOP support and the redirect bridge, see the [COOP Migration Guide](../lib/msal-browser/docs/v4-migration.md#cross-origin-opener-policy-coop-support). + +**Common Causes:** + +This timeout typically occurs for the following reasons: + +1. The page you use as your `redirectUri` is not loading the `msal-redirect-bridge.js` script +1. The redirect page is removing or manipulating the hash before the bridge script can process it +1. The redirect page is automatically navigating to a different page before the bridge can communicate the response +1. Your identity provider is being slow to redirect back to your `redirectUri` (network latency) +1. You are being throttled by your identity provider due to too many requests in a short period + +**Resolution Steps:** + +✔️ **Ensure the redirect bridge script is loaded:** + +Your `redirectUri` page must include the redirect bridge script to enable communication back to the main window: + +```html + + + + Redirect + + + + + + +``` + +**Important**: If your application uses a router library (e.g. React Router, Angular Router), please make sure it does not strip the hash or auto-redirect while MSAL token acquisition is in progress. If possible, it is best if your `redirectUri` page does not invoke the router at all. + +**Issues caused by the redirectUri page:** + +When you make a silent call, in some cases, an iframe will be opened and will navigate to your identity provider's authorization page. After the identity provider has authorized the user it will redirect the iframe back to the `redirectUri` with the authorization code or error information in the hash fragment. The MSAL redirect bridge running in the iframe will broadcast response to MSAL instance running in the frame or window that originally made the request. If your `redirectUri` is removing or manipulating this hash or navigating to a different page before MSAL redirect bridge has extracted it you will receive this timeout error. + +✔️ To solve this problem you should ensure that the page you use as your `redirectUri` is not doing any of these things. + +Remember that you will need to register `redirectUri` on your App Registration. We recommend using the HTML code shown above as the content for your registered redirect page. + +**Notes regarding Angular and React:** + +- If you are using `@azure/msal-angular` your `redirectUri` page should not be protected by the `MsalGuard`. +- If you are using `@azure/msal-react` your `redirectUri` page should not render the `MsalAuthenticationComponent` or use the `useMsalAuthentication` hook. + +**Issues caused by the Identity Provider:** + +**Throttling:** + +One of the most common reasons this error can be thrown is that your application has gotten stuck in a loop or made too many token requests in a short amount of time. When this happens the identity provider may throttle subsequent requests for a short time which will result in not being redirected back to your `redirectUri` and ultimately this error. + +✔️ To resolve throttling based issues you have 2 options: + +1. Stop making requests for a short time before trying again. +1. Invoke an interactive API, such as `acquireTokenPopup` or `acquireTokenRedirect`. + +**X-Frame-Options Deny:** + +You can also get this error if the Identity Provider fails to redirect back to your application. In silent scenarios this error is sometimes accompanied by an X-Frame-Options: Deny error indicating that your identity provider is attempting to either show you an error message or is expecting interaction. + +✔️ The X-Frame-Options error will usually have a url in it and opening this url in a new tab may help you discern what is happening. If interaction is required consider using an interactive API instead. If an error is being displayed, address the error. + +Some B2C flows are expected to throw this error due to their need for user interaction. These flows include: + +- Password reset +- Profile edit +- Sign up +- Some custom policies depending on how they are configured + +**Network Latency:** + +Another potential reason the identity provider may not redirect back to your application in time may be that there is some extra network latency. + +✔️ The default timeout is about 10 seconds and should be sufficient in most cases, however, if your identity provider is taking longer than that to redirect, you can increase this timeout in the MSAL config with either the `iframeBridgeTimeout` (for aquireTokenSilent() or ssoSilent()) or `popupBridgeTimeout` (acquireTokenPopup()) configuration parameters. + +```javascript +const msalConfig = { + auth: { + clientId: "your-client-id", + }, + system: { + popupBridgeTimeout: 50000, // Applies just to popup calls - In milliseconds + iframeBridgeTimeout: 9000, // Applies just to silent calls - In milliseconds + }, +}; +``` + +> [!IMPORTANT] +> Please consult the [Troubleshooting Single-Sign On](https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/FAQ.md#troubleshooting-single-sign-on) section of the MSAL Browser FAQ if you are having trouble with the `ssoSilent` API. + ## Browser configuration errors ### `storage_not_supported` diff --git a/lib/msal-browser/apiReview/msal-browser.api.md b/lib/msal-browser/apiReview/msal-browser.api.md index 7b6099838e..47177a1d5b 100644 --- a/lib/msal-browser/apiReview/msal-browser.api.md +++ b/lib/msal-browser/apiReview/msal-browser.api.md @@ -230,7 +230,6 @@ declare namespace BrowserAuthErrorCodes { popupWindowError, emptyWindowError, userCancelled, - redirectBridgeTimeout, redirectBridgeEmptyResponse, redirectInIframe, blockIframeReload, @@ -1091,9 +1090,9 @@ const noTokenRequestCacheError = "no_token_request_cache_error"; export const OIDC_DEFAULT_SCOPES: string[]; // Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag -// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" // Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag // Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" +// Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" // Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag // Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" // Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag @@ -1296,11 +1295,6 @@ export class PublicClientApplication implements IPublicClientApplication { // @public (undocumented) const redirectBridgeEmptyResponse = "redirect_bridge_empty_response"; -// Warning: (ae-missing-release-tag) "redirectBridgeTimeout" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -const redirectBridgeTimeout = "redirect_bridge_timeout"; - // Warning: (ae-missing-release-tag) "redirectInIframe" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/lib/msal-browser/src/error/BrowserAuthErrorCodes.ts b/lib/msal-browser/src/error/BrowserAuthErrorCodes.ts index 3e42fef364..ce8aff37a5 100644 --- a/lib/msal-browser/src/error/BrowserAuthErrorCodes.ts +++ b/lib/msal-browser/src/error/BrowserAuthErrorCodes.ts @@ -20,7 +20,6 @@ export const interactionInProgressCancelled = export const popupWindowError = "popup_window_error"; export const emptyWindowError = "empty_window_error"; export const userCancelled = "user_cancelled"; -export const redirectBridgeTimeout = "redirect_bridge_timeout"; export const redirectBridgeEmptyResponse = "redirect_bridge_empty_response"; export const redirectInIframe = "redirect_in_iframe"; export const blockIframeReload = "block_iframe_reload"; diff --git a/lib/msal-browser/src/utils/BrowserUtils.ts b/lib/msal-browser/src/utils/BrowserUtils.ts index 03c7804dc8..76dad56172 100644 --- a/lib/msal-browser/src/utils/BrowserUtils.ts +++ b/lib/msal-browser/src/utils/BrowserUtils.ts @@ -27,10 +27,7 @@ import { createBrowserConfigurationAuthError, } from "../error/BrowserConfigurationAuthError.js"; import { BrowserConfiguration } from "../config/Configuration.js"; -import { - redirectBridgeEmptyResponse, - redirectBridgeTimeout, -} from "../error/BrowserAuthErrorCodes.js"; +import { redirectBridgeEmptyResponse } from "../error/BrowserAuthErrorCodes.js"; import { base64Decode } from "../encode/Base64Decode.js"; /** @@ -264,7 +261,12 @@ export async function waitForBridgeResponse( activeBridgeMonitor = null; channel.close(); - reject(createBrowserAuthError(redirectBridgeTimeout)); + reject( + createBrowserAuthError( + BrowserAuthErrorCodes.timedOut, + "redirect_bridge_timeout" + ) + ); }, timeoutMs); // Track this monitor so it can be cancelled if needed diff --git a/lib/msal-browser/test/interaction_client/PopupClient.spec.ts b/lib/msal-browser/test/interaction_client/PopupClient.spec.ts index 86044c6e17..ee059226a9 100644 --- a/lib/msal-browser/test/interaction_client/PopupClient.spec.ts +++ b/lib/msal-browser/test/interaction_client/PopupClient.spec.ts @@ -1974,7 +1974,8 @@ describe("PopupClient", () => { // Mock waitForBridgeResponse to simulate a timeout error jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockRejectedValue( createBrowserAuthError( - BrowserAuthErrorCodes.redirectBridgeTimeout + BrowserAuthErrorCodes.timedOut, + "redirect_bridge_timeout" ) ); @@ -1986,7 +1987,8 @@ describe("PopupClient", () => { request ) ).rejects.toMatchObject({ - errorCode: BrowserAuthErrorCodes.redirectBridgeTimeout, + errorCode: BrowserAuthErrorCodes.timedOut, + subError: "redirect_bridge_timeout", }); }); diff --git a/lib/msal-browser/test/interaction_client/SilentIframeClient.spec.ts b/lib/msal-browser/test/interaction_client/SilentIframeClient.spec.ts index 2f242b1b1c..a7a9dcad8d 100644 --- a/lib/msal-browser/test/interaction_client/SilentIframeClient.spec.ts +++ b/lib/msal-browser/test/interaction_client/SilentIframeClient.spec.ts @@ -231,7 +231,8 @@ describe("SilentIframeClient", () => { ).mockResolvedValue(testNavUrl); jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockRejectedValue( createBrowserAuthError( - BrowserAuthErrorCodes.redirectBridgeTimeout + BrowserAuthErrorCodes.timedOut, + "redirect_bridge_timeout" ) ); jest.spyOn(PkceGenerator, "generatePkceCodes").mockResolvedValue({ @@ -246,7 +247,8 @@ describe("SilentIframeClient", () => { .mockImplementation((e) => { expect(e).toMatchObject( createBrowserAuthError( - BrowserAuthErrorCodes.redirectBridgeTimeout + BrowserAuthErrorCodes.timedOut, + "redirect_bridge_timeout" ) ); }); diff --git a/lib/msal-browser/test/interaction_handler/SilentHandler.spec.ts b/lib/msal-browser/test/interaction_handler/SilentHandler.spec.ts index c394956da5..5265ccdfa6 100644 --- a/lib/msal-browser/test/interaction_handler/SilentHandler.spec.ts +++ b/lib/msal-browser/test/interaction_handler/SilentHandler.spec.ts @@ -185,7 +185,8 @@ describe("SilentHandler.ts Unit Tests", () => { // Mock waitForBridgeResponse to simulate a timeout error jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockRejectedValue( createBrowserAuthError( - BrowserAuthErrorCodes.redirectBridgeTimeout + BrowserAuthErrorCodes.timedOut, + "redirect_bridge_timeout" ) ); @@ -197,7 +198,8 @@ describe("SilentHandler.ts Unit Tests", () => { request ) ).rejects.toMatchObject({ - errorCode: BrowserAuthErrorCodes.redirectBridgeTimeout, + errorCode: BrowserAuthErrorCodes.timedOut, + subError: "redirect_bridge_timeout", }); }); diff --git a/lib/msal-browser/test/utils/BrowserUtils.spec.ts b/lib/msal-browser/test/utils/BrowserUtils.spec.ts index 9eba429f38..02abc29f94 100644 --- a/lib/msal-browser/test/utils/BrowserUtils.spec.ts +++ b/lib/msal-browser/test/utils/BrowserUtils.spec.ts @@ -374,7 +374,8 @@ describe("BrowserUtils.ts Function Unit Tests", () => { jest.advanceTimersByTime(1001); await expect(waitPromise).rejects.toMatchObject({ - errorCode: BrowserAuthErrorCodes.redirectBridgeTimeout, + errorCode: BrowserAuthErrorCodes.timedOut, + subError: "redirect_bridge_timeout", }); // Try to cancel after timeout - should do nothing From f5b340c618affb8f65291171d86851b8a50163b0 Mon Sep 17 00:00:00 2001 From: Konstantin Shabelko Date: Tue, 25 Nov 2025 18:00:29 -0500 Subject: [PATCH 41/45] - Update API doc --- lib/msal-browser/apiReview/msal-browser.api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/msal-browser/apiReview/msal-browser.api.md b/lib/msal-browser/apiReview/msal-browser.api.md index 47177a1d5b..f56b445a18 100644 --- a/lib/msal-browser/apiReview/msal-browser.api.md +++ b/lib/msal-browser/apiReview/msal-browser.api.md @@ -1089,9 +1089,9 @@ const noTokenRequestCacheError = "no_token_request_cache_error"; // @public (undocumented) export const OIDC_DEFAULT_SCOPES: string[]; -// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag // Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag // Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" +// Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag // Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" // Warning: (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag // Warning: (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@" From 7c088edd68a7db1b09a4e0f184b83cb37652478c Mon Sep 17 00:00:00 2001 From: Konstantin Shabelko Date: Tue, 25 Nov 2025 18:17:30 -0500 Subject: [PATCH 42/45] - Increase version switch timeout for express sample --- .../ExpressSample/test/test-helpers.ts | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/samples/msal-browser-samples/ExpressSample/test/test-helpers.ts b/samples/msal-browser-samples/ExpressSample/test/test-helpers.ts index 689e533896..b114692109 100644 --- a/samples/msal-browser-samples/ExpressSample/test/test-helpers.ts +++ b/samples/msal-browser-samples/ExpressSample/test/test-helpers.ts @@ -6,21 +6,21 @@ import { /** * Checks that tokens can be retrieved from the cache - * @param page - * @param screenshot + * @param page + * @param screenshot */ export async function verifyCacheWasUsed(page: puppeteer.Page, screenshot: Screenshot) { // Track network requests to verify cached tokens are used const networkRequests: puppeteer.HTTPRequest[] = []; page.on('request', (request) => { // Track all requests to authentication endpoints - if (request.url().includes('login.microsoftonline.com') || - request.url().includes('/token') || + if (request.url().includes('login.microsoftonline.com') || + request.url().includes('/token') || request.url().includes('/authorize')) { networkRequests.push(request); } }); - + if (!page.url().endsWith("profile")) { await page.locator("a#viewProfileButton").click(); await screenshot.takeScreenshot(page, "Profile button clicked"); @@ -39,9 +39,9 @@ export async function verifyCacheWasUsed(page: puppeteer.Page, screenshot: Scree /** * Helper to switch to a different version - * @param version - * @param page - * @param screenshot + * @param version + * @param page + * @param screenshot */ export async function switchToVersion(version: string, page: puppeteer.Page, screenshot: Screenshot) { let versionSearchText = version; @@ -83,7 +83,7 @@ export async function switchToVersion(version: string, page: puppeteer.Page, scr const selectedVersion = await page.locator(`span#currentVersionText`) .filter((value) => {return !!value.textContent && !value.textContent.startsWith("Switching")}) .map(value => value.textContent || "") - .setTimeout(2000) + .setTimeout(3000) .wait(); expect(selectedVersion).toContain(versionSearchText); await screenshot.takeScreenshot(page, `${version} version selected`); @@ -91,16 +91,16 @@ export async function switchToVersion(version: string, page: puppeteer.Page, scr /** * Sign a user in - * @param page - * @param screenshot - * @param username - * @param accountPwd + * @param page + * @param screenshot + * @param username + * @param accountPwd * @param ssoExpected Whether SSO is expected (if true credentials won't be entered) */ export async function signIn( - page: puppeteer.Page, - screenshot: Screenshot, - username: string, + page: puppeteer.Page, + screenshot: Screenshot, + username: string, accountPwd: string, ssoExpected: boolean = false ) { From 22be0a0051b8782cba704c8957b409d5382fe12b Mon Sep 17 00:00:00 2001 From: Konstantin Shabelko Date: Wed, 26 Nov 2025 11:45:33 -0500 Subject: [PATCH 43/45] - Update redirect URI documentation --- docs/errors.md | 6 +-- lib/msal-browser/FAQ.md | 4 +- lib/msal-browser/docs/iframe-usage.md | 8 ++-- lib/msal-browser/docs/initialization.md | 6 ++- lib/msal-browser/docs/login-user.md | 60 ++++++++++++++++++++++--- lib/msal-browser/docs/v4-migration.md | 4 +- 6 files changed, 72 insertions(+), 16 deletions(-) diff --git a/docs/errors.md b/docs/errors.md index 2d9e932e45..fa3ad08d41 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -337,7 +337,7 @@ This error occurs when MSAL.js surpasses the allotted storage limit when attempt This error occurs when the page you use as your redirectUri is removing the hash, or auto-redirecting to another page. This most commonly happens when the application implements a router which navigates to another route, dropping the hash. -To resolve this error we recommend using a dedicated redirectUri page which is not subject to the router. For silent and popup calls it's best to use a blank page. If this is not possible please make sure the router does not navigate while MSAL token acquisition is in progress. You can do this by detecting if your application is loaded in an iframe for silent calls, in a popup for popup calls or by awaiting `handleRedirectPromise` for redirect calls. +To resolve this error we recommend using a dedicated redirectUri page that implements the MSAL redirect bridge. This page should not include any router logic that could interfere with hash handling. For detailed setup instructions, see the [redirectUri considerations](../lib/msal-browser/docs/login-user.md#redirecturi-considerations). Please make sure the router does not navigate while MSAL token acquisition is in progress. You can do this by detecting if your application is loaded in an iframe for silent calls, in a popup for popup calls or by awaiting `handleRedirectPromise` for redirect calls. ### `no_state_in_hash` @@ -618,14 +618,14 @@ const promise2 = msalInstance.acquireTokenPopup(request2); - Request was blocked inside an iframe because MSAL detected an authentication response. This error is thrown when calling `ssoSilent` or `acquireTokenSilent` and the page used as your `redirectUri` is attempting to invoke a login or acquireToken function. -Our recommended mitigation for this is to set your `redirectUri` to a blank page that does not implement MSAL when invoking silent APIs. This will also have the added benefit of improving performance as the hidden iframe doesn't need to render your page. +Our recommended mitigation for this is to set your `redirectUri` to a dedicated page that implements the MSAL redirect bridge and does not invoke any MSAL APIs. This will also have the added benefit of improving performance as the hidden iframe doesn't need to render your page. For setup instructions, see [RedirectUri Considerations](../lib/msal-browser/docs/login-user.md#redirecturi-considerations). ✔️ You can do this on a per request basis, for example: ```javascript msalInstance.acquireTokenSilent({ scopes: ["User.Read"], - redirectUri: "http://localhost:3000/blank.html", + redirectUri: "http://localhost:3000/redirect", }); ``` diff --git a/lib/msal-browser/FAQ.md b/lib/msal-browser/FAQ.md index 5700d47934..7e1468801f 100644 --- a/lib/msal-browser/FAQ.md +++ b/lib/msal-browser/FAQ.md @@ -282,7 +282,9 @@ When you attempt to authenticate MSAL will navigate to your IDP's sign in page e ### RedirectUri for popup and silent flows -When using popup and silent APIs we recommend setting the `redirectUri` to a blank page, a page that does not implement MSAL, or a page that does not itself require a user be authenticated. This will help prevent potential issues as well as improve performance. If your application is only using popup and silent APIs you can set this on the `PublicClientApplication` config. If your application also needs to support redirect APIs you can set the `redirectUri` on a per request basis. +When using popup and silent APIs, the `redirectUri` must point to a dedicated page that implements the MSAL redirect bridge. This page handles the authentication response and communicates it back to the main application using the BroadcastChannel API. If your application is only using popup and silent APIs you can set this on the `PublicClientApplication` config. If your application also needs to support redirect APIs you can set the `redirectUri` on a per request basis. + +For detailed setup instructions, see [redirectUri considerations](./docs/login-user.md#redirecturi-considerations). ### RedirectUri for redirect flows diff --git a/lib/msal-browser/docs/iframe-usage.md b/lib/msal-browser/docs/iframe-usage.md index 0bc103c6bc..c0af762ca7 100644 --- a/lib/msal-browser/docs/iframe-usage.md +++ b/lib/msal-browser/docs/iframe-usage.md @@ -25,7 +25,7 @@ Iframed and parent apps with the same-origin may have access to the same MSAL.js ### Apps with cross-origin -Iframed and parent apps with cross-origin can make use of the [ssoSilent()](./login-user.md#silent-login-with-ssosilent) API to achieve single sign-on. To do so, the parent app should pass down either an **account**, a **loginHint** (username) or a **session id** (sid) to the iframed app. +Iframed and parent apps with cross-origin can make use of the [ssoSilent()](./login-user.md#silent-login-with-ssosilent) API to achieve single sign-on. To do so, the parent app should pass down either an **account**, a **loginHint** (username) or a **session id** (sid) to the iframed app. Apps can attempt to use `ssoSilent` without any of the above parameters. However be aware that there are [additional considerations](./login-user.md#silent-login-with-ssosilent) when using `ssoSilent` without providing any information about the user's session. @@ -40,7 +40,7 @@ const myMSALObj = new msal.PublicClientApplication({ auth: { clientId: "ENTER_CLIENT_ID", authority: "https://login.microsoftonline.com/ENTER_TENANT_ID", - redirectUri: "/redirect", // set to a blank page for handling auth code response via popups + redirectUri: "/redirect", // must point to a page that implements the redirect bridge }, cache: { cacheLocation: "localStorage", // set your cache location to local storage @@ -48,7 +48,7 @@ const myMSALObj = new msal.PublicClientApplication({ }); window.onload = () => { - + const urlParams = new URLSearchParams(window.location.search); const sid = urlParams.get("sid"); @@ -72,7 +72,7 @@ const myMSALObj = new msal.PublicClientApplication({ auth: { clientId: "ENTER_CLIENT_ID", authority: "https://login.microsoftonline.com/ENTER_TENANT_ID", - redirectUri: "/redirect", // set to a blank page for handling auth code response via popups + redirectUri: "/redirect", // must point to a page that implements the redirect bridge }, cache: { cacheLocation: "localStorage", // set your cache location to local storage diff --git a/lib/msal-browser/docs/initialization.md b/lib/msal-browser/docs/initialization.md index d0152734d7..cf971d9e0d 100644 --- a/lib/msal-browser/docs/initialization.md +++ b/lib/msal-browser/docs/initialization.md @@ -184,11 +184,13 @@ The popup APIs use ES6 Promises that resolve when the authentication flow in the #### RedirectUri Considerations -When using popup APIs we recommend setting the `redirectUri` to a blank page or a page that does not implement MSAL. This will help prevent potential issues as well as improve performance. If your application is only using popup and silent APIs you can set this on the `PublicClientApplication` config. If your application also needs to support redirect APIs you can set the `redirectUri` on a per request basis: +When using popup APIs, the `redirectUri` must point to a dedicated page that implements the MSAL redirect bridge. This page handles the authentication response and communicates it back to the main application. + +For detailed guidance on setting up the redirect page, see [RedirectUri considerations](./login-user.md#redirecturi-considerations). ```javascript msalInstance.loginPopup({ - redirectUri: "http://localhost:3000/blank.html", + redirectUri: "http://localhost:3000/redirect", }); ``` diff --git a/lib/msal-browser/docs/login-user.md b/lib/msal-browser/docs/login-user.md index 589dfa1178..51d32fdec5 100644 --- a/lib/msal-browser/docs/login-user.md +++ b/lib/msal-browser/docs/login-user.md @@ -172,16 +172,66 @@ This indicates that the server could not determine which account to sign into, a ## RedirectUri Considerations -When using popup and silent APIs we recommend setting the `redirectUri` to a blank page or a page that does not implement MSAL. This will help prevent potential issues as well as improve performance. If your application is only using popup and silent APIs you can set this on the `PublicClientApplication` config. If your application also needs to support redirect APIs you can set the `redirectUri` on a per request basis. For more information, see the [React Router](../../../samples/msal-react-samples/react-router-sample) sample: +**All authentication flows now require a dedicated redirect page** that implements the MSAL redirect bridge. This is necessary to support COOP (Cross-Origin-Opener-Policy) headers and enable secure communication between popup/iframe windows and the main application. + +### Setting up the redirect page + +Your `redirectUri` must point to a dedicated page that loads the redirect bridge script. This page should: + +1. **Load the redirect bridge script** - This script handles communication with the main window +2. **Not implement any MSAL logic** - The redirect page should only run the bridge script +3. **Not include routing logic** - Avoid router libraries that might interfere with hash handling +4. **Be registered in your App Registration** - The URI must match exactly what's registered in Azure portal + +**Example redirect page:** + +```html + + + + Redirect + + +

Processing authentication...

+ + + + +``` + +### Configuration + +You can set the `redirectUri` globally in your MSAL configuration or on a per-request basis: -Note: This does not apply for `loginRedirect` or `acquireTokenRedirect`. When using those APIs please see the directions on handling redirects [here](./initialization.md#redirect-apis) +**Global configuration:** + +```javascript +const msalConfig = { + auth: { + clientId: "your-client-id", + authority: "https://login.microsoftonline.com/common", + redirectUri: "http://localhost:3000/redirect" + } +}; + +const msalInstance = new PublicClientApplication(msalConfig); +``` + +**Per-request configuration:** ```javascript msalInstance.loginPopup({ - redirectUri: "http://localhost:3000/blank.html", + scopes: ["user.read"], + redirectUri: "http://localhost:3000/redirect" }); ``` +For more information and complete sample implementations, see: +- [React Router Sample](../../../samples/msal-react-samples/react-router-sample) +- [Express Sample](../../../samples/msal-browser-samples/ExpressSample) + ## Handling popup `interaction_in_progress` errors For popup flows, you can use the `overrideInteractionInProgress` flag to cancel a pending interaction and start a new one. This is useful for recovery scenarios where the user cancelled a popup or an interaction failed. @@ -212,8 +262,8 @@ For popup flows, you can use the `overrideInteractionInProgress` flag to cancel ### Example: Proper error handling with user-triggered retry For complete implementations with visual feedback, see: -- **[ExpressSample](../../../samples/msal-browser-samples/ExpressSample)** - Demonstrates JavaScript implementation with custom CSS -- **[React Router Sample](../../../samples/msal-react-samples/react-router-sample)** - Demonstrates React implementation with Material-UI components +- [ExpressSample](../../../samples/msal-browser-samples/ExpressSample) - Demonstrates JavaScript implementation with custom CSS +- [React Router Sample](../../../samples/msal-react-samples/react-router-sample) - Demonstrates React implementation with Material-UI components Both samples demonstrate: - Warning message displayed during popup authentication diff --git a/lib/msal-browser/docs/v4-migration.md b/lib/msal-browser/docs/v4-migration.md index d3a2707aae..eae25de543 100644 --- a/lib/msal-browser/docs/v4-migration.md +++ b/lib/msal-browser/docs/v4-migration.md @@ -337,7 +337,7 @@ When COOP headers are present on the authentication service response (e.g., `Cro 3. **Authentication flow**: The authority page completes the OAuth flow and receives the auth response 4. **Response handling**: The redirect page uses the new `broadcastResponseToMainFrame()` function which: - For **popup/silent flows**: Broadcasts the response to the main window via BroadcastChannel API - - For **redirect flows**: Navigates to your application's home page with the auth response + - For **redirect flows**: Navigates to the page where the `acquireTokenRedirect` is initiated from with the auth response 5. **Token acquisition**: The main application receives the response and completes token acquisition #### Migration Steps @@ -401,6 +401,8 @@ const msalConfig = { const pca = new PublicClientApplication(msalConfig); ``` +- For more details on redirect URI configuration, see [here](./login-user.md#redirecturi-considerations) +- For more details on handling popup interaction_in_progress errors, see [here](./login-user.md#handling-popup-interaction_in_progress-errors) - For more details on COOP and security considerations, see the [Cross-Origin-Opener-Policy documentation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cross-Origin-Opener-Policy). From ce57d1c9ba7bcf7e3872ed60b32db3eeed4213f9 Mon Sep 17 00:00:00 2001 From: Konstantin Date: Mon, 1 Dec 2025 16:54:50 -0500 Subject: [PATCH 44/45] Update docs/errors.md Co-authored-by: Hector Morales --- docs/errors.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/errors.md b/docs/errors.md index fa3ad08d41..5568b5c021 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -834,7 +834,7 @@ The redirect bridge is a mechanism that enables authentication flows in COOP (Cr This timeout typically occurs for the following reasons: 1. The page you use as your `redirectUri` is not loading the `msal-redirect-bridge.js` script -1. The redirect page is removing or manipulating the hash before the bridge script can process it +1. The redirect page is removing or manipulating the URL hash before the bridge script can process it 1. The redirect page is automatically navigating to a different page before the bridge can communicate the response 1. Your identity provider is being slow to redirect back to your `redirectUri` (network latency) 1. You are being throttled by your identity provider due to too many requests in a short period From 53d9d6b516a0816eb5c4127b71ee06ac8d1254dd Mon Sep 17 00:00:00 2001 From: Lalima Sharda Date: Mon, 1 Dec 2025 14:41:27 -0800 Subject: [PATCH 45/45] popup coop e2e tests (#8165) --- package-lock.json | 188 +++++++++++++++++- samples/msal-browser-samples/COOP/app/auth.js | 16 +- .../msal-browser-samples/COOP/app/index.html | 4 +- samples/msal-browser-samples/COOP/app/ui.js | 2 - .../msal-browser-samples/COOP/package.json | 27 ++- .../COOP/playwright.config.ts | 96 +++++++++ samples/msal-browser-samples/COOP/sts/ui.js | 10 + .../COOP/test/home.spec.ts | 123 ++++++++++++ 8 files changed, 454 insertions(+), 12 deletions(-) create mode 100644 samples/msal-browser-samples/COOP/playwright.config.ts create mode 100644 samples/msal-browser-samples/COOP/test/home.spec.ts diff --git a/package-lock.json b/package-lock.json index 2ec655cc25..ebb905d10a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55799,6 +55799,7 @@ } }, "samples/msal-browser-samples/COOP": { + "name": "msal-browser-popup-coop", "version": "1.0.0", "license": "MIT", "dependencies": { @@ -55808,7 +55809,127 @@ "path": "^0.11.14" }, "devDependencies": { - "@playwright/test": "^1.30.0" + "@playwright/test": "^1.31.1", + "@types/jest": "^29.5.0", + "@types/node": "^24.10.0", + "@types/react": "^19.1.3", + "@types/react-dom": "^19.1.3", + "@typescript-eslint/eslint-plugin": "^5.0.0", + "@typescript-eslint/parser": "^5.0.0", + "@vercel/webpack-asset-relocator-loader": "1.7.3", + "autoprefixer": "^10.4.13", + "css-loader": "^6.0.0", + "e2e-test-utils": "file:../../e2eTestUtils", + "electron": "22.3.25", + "eslint": "^8.0.1", + "eslint-plugin-import": "^2.25.0", + "fork-ts-checker-webpack-plugin": "^7.2.1", + "jest": "^29.5.0", + "node-loader": "^2.0.0", + "postcss": "^8.4.31", + "postcss-loader": "^4.2.0", + "sass": "^1.55.0", + "sass-loader": "^10.1.1", + "style-loader": "^3.0.0", + "ts-jest": "^29.1.0", + "ts-loader": "^9.2.2", + "ts-node": "^10.0.0", + "typescript": "~4.5.4" + } + }, + "samples/msal-browser-samples/COOP/node_modules/@types/node": { + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "samples/msal-browser-samples/COOP/node_modules/@vercel/webpack-asset-relocator-loader": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@vercel/webpack-asset-relocator-loader/-/webpack-asset-relocator-loader-1.7.3.tgz", + "integrity": "sha512-vizrI18v8Lcb1PmNNUBz7yxPxxXoOeuaVEjTG9MjvDrphjiSxFZrRJ5tIghk+qdLFRCXI5HBCshgobftbmrC5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve": "^1.10.0" + } + }, + "samples/msal-browser-samples/COOP/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "samples/msal-browser-samples/COOP/node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, "samples/msal-browser-samples/COOP/node_modules/path": { @@ -55816,6 +55937,71 @@ "resolved": "https://registry.npmjs.org/path/-/path-0.11.14.tgz", "integrity": "sha512-CzEXTDgcEfa0yqMe+DJCSbEB5YCv4JZoic5xulBNFF2ifIMjNrTWbNSPNhgKfSo0MjneGIx9RLy4pCFuZPaMSQ==" }, + "samples/msal-browser-samples/COOP/node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "samples/msal-browser-samples/COOP/node_modules/typescript": { + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.5.tgz", + "integrity": "sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "samples/msal-browser-samples/COOP/node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, "samples/msal-browser-samples/ExpressSample": { "name": "express-sample", "version": "1.0.0", diff --git a/samples/msal-browser-samples/COOP/app/auth.js b/samples/msal-browser-samples/COOP/app/auth.js index bd99f1bd08..bc9465699d 100644 --- a/samples/msal-browser-samples/COOP/app/auth.js +++ b/samples/msal-browser-samples/COOP/app/auth.js @@ -39,7 +39,7 @@ function handleResponse(resp) { const successDiv = document.getElementById("successAuthCode"); if (successDiv) { successDiv.innerHTML = ` -
+
✅ Authentication Successful!

User: ${resp.account.name || resp.account.username}

ID Token: ${resp.idToken.substring(0, 30)}...

@@ -64,14 +64,18 @@ function handleResponse(resp) { } } -function logoutPopup(interactionType) { +function signOut(interactionType) { const logoutRequest = { - account: myMSALObj.getAccountByHomeId(accountId) + account: myMSALObj.getAccount({accountId}) }; - myMSALObj.logoutPopup(logoutRequest).then(() => { - window.location.reload(); - }); + if (interactionType === "popup") { + myMSALObj.logoutPopup(logoutRequest).then(() => { + window.location.reload(); + }); + } else { + myMSALObj.logoutRedirect(logoutRequest); + } } async function loginPopup(request, account) { diff --git a/samples/msal-browser-samples/COOP/app/index.html b/samples/msal-browser-samples/COOP/app/index.html index 8f1b28a1a3..e1613788ae 100644 --- a/samples/msal-browser-samples/COOP/app/index.html +++ b/samples/msal-browser-samples/COOP/app/index.html @@ -59,8 +59,8 @@
MSAL.js COOP sample


- - + +

diff --git a/samples/msal-browser-samples/COOP/app/ui.js b/samples/msal-browser-samples/COOP/app/ui.js index 9de6b2416a..90e83fd5d7 100644 --- a/samples/msal-browser-samples/COOP/app/ui.js +++ b/samples/msal-browser-samples/COOP/app/ui.js @@ -1,5 +1,4 @@ // Select DOM elements to work with -const welcomeDiv = document.getElementById("WelcomeMessage"); const signInButton = document.getElementById("SignIn"); const popupButton = document.getElementById("popup"); const redirectButton = document.getElementById("redirect"); @@ -7,7 +6,6 @@ const cardDiv = document.getElementById("card-div"); function showWelcomeMessage(account) { // Reconfiguring DOM elements - //welcomeDiv.innerHTML = `Welcome ${account.username}`; signInButton.setAttribute('class', "btn btn-success dropdown-toggle"); signInButton.innerHTML = "Sign Out"; popupButton.setAttribute('onClick', "signOut(this.id)"); diff --git a/samples/msal-browser-samples/COOP/package.json b/samples/msal-browser-samples/COOP/package.json index 931f08c88a..50a9c3864e 100644 --- a/samples/msal-browser-samples/COOP/package.json +++ b/samples/msal-browser-samples/COOP/package.json @@ -18,6 +18,31 @@ "path": "^0.11.14" }, "devDependencies": { - "@playwright/test": "^1.30.0" + "e2e-test-utils": "file:../../e2eTestUtils", + "@playwright/test": "^1.31.1", + "@types/node": "^24.10.0", + "@types/jest": "^29.5.0", + "@types/react": "^19.1.3", + "@types/react-dom": "^19.1.3", + "@typescript-eslint/eslint-plugin": "^5.0.0", + "@typescript-eslint/parser": "^5.0.0", + "@vercel/webpack-asset-relocator-loader": "1.7.3", + "autoprefixer": "^10.4.13", + "css-loader": "^6.0.0", + "electron": "22.3.25", + "eslint": "^8.0.1", + "eslint-plugin-import": "^2.25.0", + "fork-ts-checker-webpack-plugin": "^7.2.1", + "jest": "^29.5.0", + "node-loader": "^2.0.0", + "postcss": "^8.4.31", + "postcss-loader": "^4.2.0", + "sass": "^1.55.0", + "sass-loader": "^10.1.1", + "style-loader": "^3.0.0", + "ts-jest": "^29.1.0", + "ts-loader": "^9.2.2", + "ts-node": "^10.0.0", + "typescript": "~4.5.4" } } diff --git a/samples/msal-browser-samples/COOP/playwright.config.ts b/samples/msal-browser-samples/COOP/playwright.config.ts new file mode 100644 index 0000000000..47add86290 --- /dev/null +++ b/samples/msal-browser-samples/COOP/playwright.config.ts @@ -0,0 +1,96 @@ +import { PlaywrightTestConfig, devices } from "@playwright/test"; +import { RETRY_TIMES } from "e2e-test-utils"; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// import path from 'path'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +const config: PlaywrightTestConfig = { + testDir: "./test", + maxFailures: 2, + /* Run tests in files in parallel */ + //fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + //forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: RETRY_TIMES, + /* Opt out of parallel tests on CI. */ + //workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: "html", + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('')`. */ + baseURL: "https://localhost:30662", + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", + headless: true, + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + + // { + // name: "firefox", + // use: { ...devices["Desktop Firefox"] }, + // }, + + // { + // name: "webkit", + // use: { ...devices["Desktop Safari"] }, + // }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + timeout: 20000, + globalTimeout: 80000, + + /* Run your local dev servers before starting the tests */ + webServer: [ + { + command: "npm run start:https", + url: "https://localhost:30662", + reuseExistingServer: !process.env.CI, + ignoreHTTPSErrors: true, + }, + { + command: "npm run start:server:https", + url: "https://localhost:30663", + reuseExistingServer: !process.env.CI, + ignoreHTTPSErrors: true, + }, + ], +}; + +export default config; diff --git a/samples/msal-browser-samples/COOP/sts/ui.js b/samples/msal-browser-samples/COOP/sts/ui.js index ad220cbb87..2afa9ae68d 100644 --- a/samples/msal-browser-samples/COOP/sts/ui.js +++ b/samples/msal-browser-samples/COOP/sts/ui.js @@ -2,7 +2,17 @@ window.name = "STS Window"; const channel = new BroadcastChannel('sts-channel'); function performAuthentication() { + console.log("STS: Performing authentication (simulated)"); + console.log("STS: Adding 2 second delay..."); + + // Since the STS response is mocked, the popup opens and closes before the e2e tests can capture a screenshot. Add a 2 second delay to capture popup screenshot before the popup closes itself. + setTimeout(() => { + continueAuthentication(); + }, 2000); +} + +function continueAuthentication() { console.log("STS: window.opener", window.opener); console.log("STS: window.location.search", window.location.search); diff --git a/samples/msal-browser-samples/COOP/test/home.spec.ts b/samples/msal-browser-samples/COOP/test/home.spec.ts new file mode 100644 index 0000000000..ab1cdc575f --- /dev/null +++ b/samples/msal-browser-samples/COOP/test/home.spec.ts @@ -0,0 +1,123 @@ +import { chromium, Browser, Page, test, expect, Frame } from "@playwright/test"; +import { ScreenShotElectron } from "e2e-test-utils"; + +const LOCAL_SCREENSHOT_FOLDER = `${__dirname}/screenshots`; + +let browser: Browser; +let browserPage: Page; + +test.beforeEach(async () => { + browser = await chromium.launch({ + args: ["--allow-insecure-localhost"], + }); + browserPage = await browser.newPage(); + await browserPage.goto("/"); +}); + +test.afterEach(async () => { + await browserPage.close(); +}); + +test.afterAll(async () => { + await browser.close(); +}); + +test("Home page - Popup and Sso Silent buttons are loaded on home-page", async () => { + const testName = "homePageLoad"; + console.log(`${LOCAL_SCREENSHOT_FOLDER}/${testName}`); + const screenshot = new ScreenShotElectron( + `${LOCAL_SCREENSHOT_FOLDER}/${testName}` + ); + await screenshot.takeScreenshot(browserPage, "Page loaded"); + + const popupButton = await browserPage.waitForSelector("#loginPopup"); + const ssoButton = await browserPage.waitForSelector("#sso"); + + expect(popupButton).not.toBeNull(); + expect(ssoButton).not.toBeNull(); +}); + +test("Popup Login Flow - Successful authentication and token acquisition", async () => { + const testName = "popupLoginFlow"; + const screenshot = new ScreenShotElectron( + `${LOCAL_SCREENSHOT_FOLDER}/${testName}` + ); + + await screenshot.takeScreenshot(browserPage, "App loaded"); + + // Click the popup login button + const loginButton = await browserPage.waitForSelector("#loginPopup"); + const newPopupWindowPromise = new Promise((resolve) => + browserPage.once("popup", resolve) + ); + await loginButton.click(); + await screenshot.takeScreenshot(browserPage, "Login button clicked"); + await browserPage.waitForTimeout(1000); + + const popupPage = await newPopupWindowPromise; + if (!popupPage) { + throw new Error("Popup window was not opened"); + } + await screenshot.takeScreenshot(popupPage, "Popup opened"); + + await browserPage.waitForSelector("#successAuthCode", { timeout: 3000 }); + await browserPage.waitForSelector("#successMsg", { timeout: 3000 }); + + await screenshot.takeScreenshot( + browserPage, + "Login successful - Welcome message displayed" + ); + + // Verify account info is displayed + const successMessage = await browserPage.textContent("#successAuthCode"); + console.log("Welcome message:", successMessage); + expect(successMessage).toContain("Authentication Successful"); + expect(successMessage).toContain("Test User"); +}); + +test("ssoSilent Token Acquisition", async () => { + const testName = "ssoSilentTokenAcquisition"; + const screenshot = new ScreenShotElectron( + `${LOCAL_SCREENSHOT_FOLDER}/${testName}` + ); + + await screenshot.takeScreenshot(browserPage, "Initial page load"); + + //add iframe listener + const silentIframe = new Promise((resolve) => { + browserPage.once("frameattached", (frame) => { + resolve(frame); + console.log("Frame attached:", frame.url()); + }); + }); + + // Click the SSO silent button + const ssoButton = await browserPage.waitForSelector("#sso"); + await ssoButton.click(); + await screenshot.takeScreenshot(browserPage, "SSO button clicked"); + + //wait for the iframe to be detected + const frame = await silentIframe; + + if (!frame) { + throw new Error("Silent iframe was not opened"); + } + // Verify the iframe exists + expect(frame).not.toBeNull(); + console.log("Silent iframe frame object:", frame.url()); + expect(frame.url()).toContain("/authorize"); + + await browserPage.waitForSelector("#successAuthCode", { timeout: 3000 }); + await browserPage.waitForSelector("#successMsg", { timeout: 3000 }); + + await screenshot.takeScreenshot( + browserPage, + "Silent token acquisition completed" + ); + + // Verify account info is displayed + const successMessage = await browserPage.textContent("#successAuthCode"); + console.log("Welcome message:", successMessage); + expect(successMessage).toContain("Authentication Successful"); + expect(successMessage).toContain("Test User"); +});