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..03a15b56d6
--- /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](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
new file mode 100644
index 0000000000..625d380523
--- /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](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 0923b0e1b0..5568b5c021 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
@@ -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`
@@ -558,106 +558,56 @@ 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?
-### `popup_window_error`
-
-- Error opening popup window. This can happen if you are using IE or if popups are blocked in the browser.
-
-### `empty_window_error`
-
-- window.open returned null or undefined window object.
-
-### `user_cancelled`
-
-- User cancelled the flow.
-
-### `monitor_popup_timeout`
-
-- Token acquisition in popup failed due to timeout.
-
-### `monitor_window_timeout`
-
-- Token acquisition in iframe failed due to timeout.
-
-**Error Messages**:
+### `interaction_in_progress_cancelled`
-- Token acquisition in iframe failed due to timeout.
+- The current interaction was cancelled by a new interaction request with `overrideInteractionInProgress` set to `true`.
-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:
+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.
-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`.
+**When This Occurs:**
-**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 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.
+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
-✔️ 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.
-
-You can do this on a per request basis, for example:
+**Example:**
```javascript
-msalInstance.acquireTokenSilent({
- scopes: ["User.Read"],
- redirectUri: "http://localhost:3000/blank.html",
-});
-```
-
-Remember that you will need to register this new `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
+// First popup request starts
+const request1 = { scopes: ["User.Read"] };
+const promise1 = msalInstance.acquireTokenPopup(request1);
-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:
+// 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);
-1. Stop making requests for a short time before trying again.
-1. Invoke an interactive API, such as `acquireTokenPopup` or `acquireTokenRedirect`.
+// promise1 will reject with interaction_in_progress_cancelled
+// promise2 will proceed normally
+```
-##### X-Frame-Options Deny
+**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.
-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.
+### `popup_window_error`
-✔️ 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.
+- Error opening popup window. This can happen if you are using IE or if popups are blocked in the browser.
-Some B2C flows are expected to throw this error due to their need for user interaction. These flows include:
+### `empty_window_error`
-- Password reset
-- Profile edit
-- Sign up
-- Some custom policies depending on how they are configured
+- window.open returned null or undefined window object.
-##### Network Latency
+### `user_cancelled`
-Another potential reason the identity provider may not redirect back to your application in time may be that there is some extra network latency.
+- User cancelled the flow.
-✔️ 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.
-```javascript
-const msalConfig = {
- auth: {
- 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
- },
-};
-```
+### `redirect_bridge_empty_response`
-> [!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.
+- The redirect bridge returned an empty response, indicating the redirect bridge script may have been modified or replaced.
### `redirect_in_iframe`
@@ -668,14 +618,14 @@ const msalConfig = {
- 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",
});
```
@@ -861,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 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
+
+**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/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/apiReview/msal-browser.api.md b/lib/msal-browser/apiReview/msal-browser.api.md
index c3420d8149..f56b445a18 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';
@@ -225,11 +226,11 @@ declare namespace BrowserAuthErrorCodes {
unableToParseState,
stateInteractionTypeMismatch,
interactionInProgress,
+ interactionInProgressCancelled,
popupWindowError,
emptyWindowError,
userCancelled,
- monitorPopupTimeout,
- monitorWindowTimeout,
+ redirectBridgeEmptyResponse,
redirectInIframe,
blockIframeReload,
blockNestedPopups,
@@ -264,7 +265,8 @@ declare namespace BrowserAuthErrorCodes {
failedToBuildHeaders,
failedToParseHeaders,
failedToDecryptEarResponse,
- timedOut
+ timedOut,
+ emptyResponse
}
}
export { BrowserAuthErrorCodes }
@@ -384,15 +386,13 @@ 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;
allowPlatformBroker?: boolean;
nativeBrokerHandshakeTimeout?: number;
- pollIntervalMilliseconds?: number;
protocolMode?: ProtocolMode;
};
@@ -406,10 +406,13 @@ export type BrowserTelemetryOptions = {
declare namespace BrowserUtils {
export {
+ parseAuthResponseFromUrl,
clearHash,
replaceHash,
isInIframe,
isInPopup,
+ cancelPendingBridgeResponse,
+ waitForBridgeResponse,
getCurrentUri,
getHomepage,
blockReloadInHiddenIframes,
@@ -452,6 +455,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
@@ -544,6 +552,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)
@@ -755,6 +768,11 @@ export { InProgressPerformanceEvent }
// @public (undocumented)
const interactionInProgress = "interaction_in_progress";
+// 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 interactionInProgressCancelled = "interaction_in_progress_cancelled";
+
export { InteractionRequiredAuthError }
export { InteractionRequiredAuthErrorCodes }
@@ -996,16 +1014,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)
@@ -1081,6 +1089,40 @@ 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;
+ hasResponseInHash: boolean;
+ hasResponseInQuery: boolean;
+ libraryState: {
+ id: string;
+ meta: Record;
+ };
+};
+
export { PerformanceCallbackFunction }
export { PerformanceEvent }
@@ -1112,6 +1154,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)
@@ -1247,6 +1290,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) "redirectInIframe" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -1440,6 +1488,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 (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)
// 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 +1510,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: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/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 a2d003b2ff..51d32fdec5 100644
--- a/lib/msal-browser/docs/login-user.md
+++ b/lib/msal-browser/docs/login-user.md
@@ -172,16 +172,191 @@ 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:
+
+**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);
+```
-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)
+**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.
+
+> **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/docs/v4-migration.md b/lib/msal-browser/docs/v4-migration.md
index 9d9476f411..eae25de543 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.
#### `asyncPopups`
@@ -289,7 +290,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 +304,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 +315,97 @@ 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 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 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. 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 page that initiated the redirect with the authentication response in the URL
+
+#### How It Works
+
+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:
+ - For **popup/silent flows**: Broadcasts the response to the main window via BroadcastChannel API
+ - 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
+
+##### 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 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).
+
+
## Behavioral Breaking Changes
### Event types and InteractionStatus changes
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 315ac1051c..5314f92ec9 100644
--- a/lib/msal-browser/rollup.config.js
+++ b/lib/msal-browser/rollup.config.js
@@ -211,4 +211,96 @@ export default [
})] : []),
],
},
+ {
+ // Redirect Bridge - ES module build
+ input: "src/redirect_bridge/index.ts",
+ output: {
+ dir: "dist/redirect-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.redirect-bridge.build.json",
+ }),
+ ],
+ },
+ {
+ // Redirect Bridge - UMD build
+ input: "src/redirect_bridge/index.ts",
+ output: [
+ {
+ dir: "lib/redirect-bridge",
+ format: "umd",
+ name: "msalRedirectBridge",
+ banner: fileHeader,
+ inlineDynamicImports: true,
+ sourcemap: true,
+ entryFileNames: "msal-redirect-bridge.js",
+ },
+ ],
+ plugins: [
+ nodeResolve({
+ browser: true,
+ resolveOnly: ["@azure/msal-common", "tslib"],
+ }),
+ typescript({
+ typescript: require("typescript"),
+ tsconfig: "tsconfig.build.json",
+ sourceMap: true,
+ compilerOptions: {
+ outDir: "lib/redirect-bridge/types",
+ declaration: false,
+ declarationMap: false,
+ },
+ }),
+ ],
+ },
+ {
+ // Redirect Bridge - UMD minified build
+ input: "src/redirect_bridge/index.ts",
+ output: [
+ {
+ dir: "lib/redirect-bridge",
+ format: "umd",
+ name: "msalRedirectBridge",
+ entryFileNames: "msal-redirect-bridge.min.js",
+ banner: useStrictHeader,
+ inlineDynamicImports: true,
+ sourcemap: false,
+ },
+ ],
+ plugins: [
+ nodeResolve({
+ browser: true,
+ resolveOnly: ["@azure/msal-common", "tslib"],
+ }),
+ typescript({
+ typescript: require("typescript"),
+ tsconfig: "tsconfig.redirect-bridge.build.json",
+ sourceMap: false,
+ compilerOptions: {
+ outDir: "lib/redirect-bridge/types",
+ declaration: false,
+ declarationMap: false,
+ },
+ }),
+ terser({
+ output: {
+ preamble: libraryHeader,
+ comments: false,
+ },
+ }),
+ ],
+ },
];
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/config/Configuration.ts b/lib/msal-browser/src/config/Configuration.ts
index 58072777fa..f9dae551ff 100644
--- a/lib/msal-browser/src/config/Configuration.ts
+++ b/lib/msal-browser/src/config/Configuration.ts
@@ -22,14 +22,10 @@ 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";
-import * as BrowserUtils from "../utils/BrowserUtils.js";
// Default timeout for popup windows and iframes in milliseconds
export const DEFAULT_POPUP_TIMEOUT_MS = 60000;
@@ -129,17 +125,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.
- */
- windowHashTimeout?: 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 a popup using BroadcastChannel
*/
- iframeHashTimeout?: number;
+ popupBridgeTimeout?: number;
/**
- * Sets the timeout for waiting for a response hash in an iframe or popup
+ * Sets the timeout for waiting for response from an iframe using BroadcastChannel
*/
- loadFrameTimeout?: number;
+ iframeBridgeTimeout?: number;
/**
* Time to wait for redirection to occur before resolving promise
*/
@@ -160,10 +152,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.
*/
@@ -240,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: {
@@ -282,12 +272,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,
@@ -295,7 +283,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/controllers/StandardController.ts b/lib/msal-browser/src/controllers/StandardController.ts
index bd15e834b0..73f56c0bc5 100644
--- a/lib/msal-browser/src/controllers/StandardController.ts
+++ b/lib/msal-browser/src/controllers/StandardController.ts
@@ -750,7 +750,9 @@ export class StandardController implements IController {
);
this.browserStorage.setInteractionInProgress(
true,
- INTERACTION_TYPE.SIGNIN
+ INTERACTION_TYPE.SIGNIN,
+ 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 31ee9fc9e5..ce8aff37a5 100644
--- a/lib/msal-browser/src/error/BrowserAuthErrorCodes.ts
+++ b/lib/msal-browser/src/error/BrowserAuthErrorCodes.ts
@@ -15,11 +15,12 @@ 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 interactionInProgressCancelled =
+ "interaction_in_progress_cancelled";
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 redirectBridgeEmptyResponse = "redirect_bridge_empty_response";
export const redirectInIframe = "redirect_in_iframe";
export const blockIframeReload = "block_iframe_reload";
export const blockNestedPopups = "block_nested_popups";
@@ -61,3 +62,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/interaction_client/PopupClient.ts b/lib/msal-browser/src/interaction_client/PopupClient.ts
index c1f290bbe2..f921f0af0a 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";
import { validateRequestMethod } from "../request/RequestHelpers.js";
export type PopupParams = {
@@ -93,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;
}
@@ -369,15 +366,12 @@ 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,
+ // Wait for the redirect bridge response
+ const responseString = await BrowserUtils.waitForBridgeResponse(
+ this.config.system.popupBridgeTimeout,
this.logger,
- this.unloadWindow,
- this.correlationId
+ this.browserCrypto,
+ request
);
const serverParams = invoke(
@@ -497,19 +491,16 @@ 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,
correlationId
)(
- popupWindow,
- popupParams.popupWindowParent,
- this.config.auth.OIDCOptions.responseMode,
- this.config.system.pollIntervalMilliseconds,
+ this.config.system.popupBridgeTimeout,
this.logger,
- this.unloadWindow,
- correlationId
+ this.browserCrypto,
+ popupRequest
);
const serverParams = invoke(
@@ -628,19 +619,16 @@ 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,
correlationId
)(
- popupWindow,
- popupParams.popupWindowParent,
- this.config.auth.OIDCOptions.responseMode,
- this.config.system.pollIntervalMilliseconds,
+ this.config.system.popupBridgeTimeout,
this.logger,
- this.unloadWindow,
- this.correlationId
+ this.browserCrypto,
+ request
);
const serverParams = invoke(
@@ -786,14 +774,11 @@ export class PopupClient extends StandardInteractionClient {
null
);
- await monitorPopupForHash(
- popupWindow,
- popupParams.popupWindowParent,
- this.config.auth.OIDCOptions.responseMode,
- this.config.system.pollIntervalMilliseconds,
+ await BrowserUtils.waitForBridgeResponse(
+ this.config.system.popupBridgeTimeout,
this.logger,
- this.unloadWindow,
- this.correlationId
+ this.browserCrypto,
+ validRequest
).catch(() => {
// Swallow any errors related to monitoring the window. Server logout is best effort
});
@@ -919,10 +904,6 @@ export class PopupClient extends StandardInteractionClient {
popupWindow.focus();
}
this.currentWindow = popupWindow;
- popupParams.popupWindowParent.addEventListener(
- "beforeunload",
- this.unloadWindow
- );
return popupWindow;
} catch (e) {
@@ -1020,17 +1001,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/interaction_client/SilentIframeClient.ts b/lib/msal-browser/src/interaction_client/SilentIframeClient.ts
index 8f43487896..ba5789ca8b 100644
--- a/lib/msal-browser/src/interaction_client/SilentIframeClient.ts
+++ b/lib/msal-browser/src/interaction_client/SilentIframeClient.ts
@@ -38,7 +38,6 @@ import {
initiateCodeRequest,
initiateCodeFlowWithPost,
initiateEarRequest,
- monitorIframeForHash,
} from "../interaction_handler/SilentHandler.js";
import { SsoSilentRequest } from "../request/SsoSilentRequest.js";
import { AuthenticationResult } from "../response/AuthenticationResult.js";
@@ -277,7 +276,7 @@ export class SilentIframeClient extends StandardInteractionClient {
earJwk: earJwk,
codeChallenge: pkceCodes.challenge,
};
- const msalFrame = await invokeAsync(
+ await invokeAsync(
initiateEarRequest,
BrowserPerformanceEvents.SilentHandlerInitiateAuthRequest,
this.logger,
@@ -292,21 +291,17 @@ 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.iframeBridgeTimeout,
this.logger,
- correlationId,
- responseType
+ this.browserCrypto,
+ request
);
const serverParams = invoke(
@@ -419,10 +414,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,
@@ -452,7 +445,7 @@ export class SilentIframeClient extends StandardInteractionClient {
);
// Get the frame handle for the silent request
- msalFrame = await invokeAsync(
+ await invokeAsync(
initiateCodeRequest,
BrowserPerformanceEvents.SilentHandlerInitiateAuthRequest,
this.logger,
@@ -462,22 +455,20 @@ 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.
+ // Wait for response from the redirect bridge.
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.iframeBridgeTimeout,
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 35a983e365..79ae953b6e 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";
@@ -16,10 +15,7 @@ import {
createBrowserAuthError,
BrowserAuthErrorCodes,
} from "../error/BrowserAuthError.js";
-import {
- BrowserConfiguration,
- DEFAULT_IFRAME_TIMEOUT_MS,
-} from "../config/Configuration.js";
+import { BrowserConfiguration } from "../config/Configuration.js";
import { getCodeForm, getEARForm } from "../protocol/Authorize.js";
/**
@@ -94,80 +90,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`
@@ -205,14 +127,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/protocol/Authorize.ts b/lib/msal-browser/src/protocol/Authorize.ts
index e05fc54305..d8864d7c32 100644
--- a/lib/msal-browser/src/protocol/Authorize.ts
+++ b/lib/msal-browser/src/protocol/Authorize.ts
@@ -347,7 +347,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/redirect_bridge/index.ts b/lib/msal-browser/src/redirect_bridge/index.ts
new file mode 100644
index 0000000000..2f37d6593e
--- /dev/null
+++ b/lib/msal-browser/src/redirect_bridge/index.ts
@@ -0,0 +1,131 @@
+/*
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License.
+ */
+
+import { parseAuthResponseFromUrl } from "../utils/BrowserUtils.js";
+import * as BrowserUtils from "../utils/BrowserUtils.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 {NavigationClient} navigationClient - Optional navigation client for redirect scenario.
+ *
+ * @returns {Promise} A promise that resolves when the response has been broadcast and cleanup is complete.
+ *
+ * @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
+): Promise {
+ let parsedResponse;
+ try {
+ parsedResponse = parseAuthResponseFromUrl();
+ } catch (error) {
+ // 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 {
+ payload,
+ urlHash,
+ urlQuery,
+ hasResponseInHash,
+ hasResponseInQuery,
+ libraryState,
+ } = parsedResponse;
+
+ const { id, meta } = libraryState;
+
+ if (meta["interactionType"] === InteractionType.Redirect) {
+ const navClient = navigationClient || new NavigationClient();
+ const navigationOptions: NavigationOptions = {
+ apiId: ApiId.handleRedirectPromise,
+ noHistory: true,
+ timeout: DEFAULT_REDIRECT_TIMEOUT_MS,
+ };
+
+ 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
+ }
+
+ // 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()
+ }${fullUrlResponse}`;
+ await navClient.navigateInternal(homepage, navigationOptions);
+
+ // Do NOT clear URL for redirect flow - we're navigating away anyway
+ return;
+ }
+
+ // 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({
+ v: 1,
+ payload,
+ });
+ channel.close();
+ try {
+ window.close();
+ } catch {}
+}
diff --git a/lib/msal-browser/src/request/PopupRequest.ts b/lib/msal-browser/src/request/PopupRequest.ts
index 6936f04385..e88035fe8e 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. **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<
@@ -48,4 +49,5 @@ export type PopupRequest = Partial<
scopes: Array;
popupWindowAttributes?: PopupWindowAttributes;
popupWindowParent?: Window;
+ overrideInteractionInProgress?: boolean;
};
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/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 4a1789d031..76dad56172 100644
--- a/lib/msal-browser/src/utils/BrowserUtils.ts
+++ b/lib/msal-browser/src/utils/BrowserUtils.ts
@@ -9,18 +9,137 @@ import {
invokeAsync,
UrlUtils,
RequestParameterBuilder,
+ ICrypto,
+ Logger,
+ CommonAuthorizationUrlRequest,
+ CommonEndSessionRequest,
+ ProtocolUtils,
+ AuthError,
} from "@azure/msal-common/browser";
import {
createBrowserAuthError,
BrowserAuthErrorCodes,
} from "../error/BrowserAuthError.js";
-import { BrowserConstants, 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 } 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 {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;
+ payload: string;
+ urlHash: string;
+ urlQuery: string;
+ hasResponseInHash: boolean;
+ hasResponseInQuery: boolean;
+ 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;
+
+ // 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) {
+ 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 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;
+ payload = `${queryContent}${hashContent}`;
+ params = new URLSearchParams(payload);
+ }
+
+ if (!payload || !params) {
+ 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"
+ );
+ }
+
+ const { libraryState } = ProtocolUtils.parseRequestState(
+ base64Decode,
+ state
+ );
+
+ const { id, meta } = libraryState;
+ if (!id || !meta) {
+ throw new AuthError(
+ BrowserAuthErrorCodes.unableToParseState,
+ "Missing state 'id' and/or 'meta' attributes"
+ );
+ }
+
+ return {
+ params,
+ payload,
+ urlHash,
+ urlQuery,
+ hasResponseInHash,
+ hasResponseInQuery,
+ libraryState: {
+ id,
+ meta,
+ },
+ };
+}
/**
* Clears hash from window url.
@@ -58,13 +177,120 @@ 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
- );
+ if (isInIframe()) {
+ return false;
+ }
+
+ 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;
+ }
+}
+
+/**
+ * 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 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.
+ */
+
+// 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.interactionInProgressCancelled
+ )
+ );
+
+ activeBridgeMonitor = null;
+ }
+}
+
+export async function waitForBridgeResponse(
+ timeoutMs: number,
+ logger: Logger,
+ browserCrypto: ICrypto,
+ request: CommonAuthorizationUrlRequest | CommonEndSessionRequest
+): Promise {
+ return new Promise((resolve, reject) => {
+ logger.verbose(
+ "BrowserUtils.waitForBridgeResponse - started",
+ request.correlationId
+ );
+
+ const { libraryState } = ProtocolUtils.parseRequestState(
+ browserCrypto.base64Decode,
+ request.state || ""
+ );
+ const channel = new BroadcastChannel(libraryState.id);
+ let responseString: string | undefined = undefined;
+
+ const timeoutId = window.setTimeout(() => {
+ // Clear the active monitor
+ activeBridgeMonitor = null;
+
+ channel.close();
+ reject(
+ createBrowserAuthError(
+ BrowserAuthErrorCodes.timedOut,
+ "redirect_bridge_timeout"
+ )
+ );
+ }, 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) {
+ resolve(responseString);
+ } else {
+ reject(createBrowserAuthError(redirectBridgeEmptyResponse));
+ }
+ };
+ });
}
// #endregion
diff --git a/lib/msal-browser/src/utils/PopupUtils.ts b/lib/msal-browser/src/utils/PopupUtils.ts
deleted file mode 100644
index 7b950b5c7d..0000000000
--- a/lib/msal-browser/src/utils/PopupUtils.ts
+++ /dev/null
@@ -1,117 +0,0 @@
-/*
- * Copyright (c) Microsoft Corporation. All rights reserved.
- * Licensed under the MIT License.
- */
-
-import { Constants, Logger } 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 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
- * @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(
- popupWindow: Window,
- popupWindowParent: Window,
- responseMode: Constants.ResponseMode,
- pollIntervalMilliseconds: number,
- logger: Logger,
- unloadWindow: (e: Event) => void,
- correlationId: string
-): Promise {
- return new Promise((resolve, reject) => {
- logger.verbose(
- "PopupHandler.monitorPopupForHash - polling started",
- correlationId
- );
-
- 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);
- }).finally(() => {
- cleanPopup(popupWindow, popupWindowParent, unloadWindow);
- });
-}
-
-/**
- * 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 6daad8588f..30019d4bc0 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,
@@ -84,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";
@@ -203,9 +201,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 +267,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(() => {
@@ -1866,18 +1883,14 @@ 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";
+ Object.defineProperty(window, "location", {
+ configurable: true,
+ enumerable: true,
+ writable: true,
+ value: new URL(
+ `http://localhost?state=${TEST_STATE_VALUES.TEST_STATE_POPUP}`
+ ),
+ });
jest.spyOn(BrowserUtils, "isInIframe").mockReturnValue(false);
pca.acquireTokenRedirect({ scopes: ["openid"] })
@@ -1894,8 +1907,19 @@ describe("PublicClientApplication.ts Class Unit Tests", () => {
done();
})
.finally(() => {
- window.name = oldWindowName;
- window.opener = oldWindowOpener;
+ Object.defineProperty(window, "location", {
+ value: {
+ hash: "",
+ origin: "https://localhost:8081",
+ pathname: "/",
+ search: "",
+ href: "https://localhost:8081/index.html",
+ protocol: "http:",
+ hostname: "localhost",
+ port: "8081",
+ },
+ writable: true,
+ });
});
});
@@ -2973,19 +2997,14 @@ 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";
+ Object.defineProperty(window, "location", {
+ configurable: true,
+ enumerable: true,
+ writable: true,
+ value: new URL(
+ `http://localhost?state=${TEST_STATE_VALUES.TEST_STATE_POPUP}`
+ ),
+ });
pca.acquireTokenPopup({ scopes: ["openid"] })
.catch((e) => {
@@ -3001,8 +3020,19 @@ describe("PublicClientApplication.ts Class Unit Tests", () => {
done();
})
.finally(() => {
- window.name = oldWindowName;
- window.opener = oldWindowOpener;
+ Object.defineProperty(window, "location", {
+ value: {
+ hash: "",
+ origin: "https://localhost:8081",
+ pathname: "/",
+ search: "",
+ href: "https://localhost:8081/index.html",
+ protocol: "http:",
+ hostname: "localhost",
+ port: "8081",
+ },
+ writable: true,
+ });
});
});
@@ -3088,7 +3118,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"
);
@@ -3145,7 +3175,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 {
@@ -3357,6 +3387,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: [],
};
@@ -5209,9 +5248,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,
@@ -5276,9 +5312,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,
@@ -5457,9 +5490,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,
@@ -6345,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 () => {
@@ -7190,6 +7219,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/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/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 72723121e3..ee059226a9 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,
@@ -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";
@@ -67,7 +66,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,
@@ -76,10 +74,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: {
@@ -115,6 +124,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(() => {
@@ -389,7 +404,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(
@@ -515,7 +530,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(
@@ -620,7 +635,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(
@@ -642,7 +657,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({
@@ -660,7 +677,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"
);
@@ -722,7 +739,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(
@@ -750,15 +767,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"
@@ -940,9 +955,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"
@@ -952,7 +964,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}`
);
@@ -982,7 +997,10 @@ 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(
@@ -1534,7 +1552,6 @@ describe("PopupClient", () => {
jest.spyOn(PopupClient.prototype, "openPopup").mockReturnValue(
popupWindow
);
- jest.spyOn(PopupUtils, "cleanPopup").mockImplementation();
jest.spyOn(
NavigationClient.prototype,
"navigateInternal"
@@ -1561,7 +1578,6 @@ describe("PopupClient", () => {
jest.spyOn(PopupClient.prototype, "openPopup").mockReturnValue(
popupWindow
);
- jest.spyOn(PopupUtils, "cleanPopup").mockImplementation();
popupClient.logout().then(() => {
done();
@@ -1614,11 +1630,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"
@@ -1866,277 +1877,186 @@ 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
+ describe("waitForBridgeResponse", () => {
+ it("resolves when BroadcastChannel receives hash response", async () => {
+ const testLibraryState = { id: "test-channel-id" };
+ const clientImpl = popupClient as any;
+ const testState = ProtocolUtils.setRequestState(
+ clientImpl.browserCrypto,
+ "",
+ testLibraryState
);
- 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("monitorPopupForHash", () => {
- it("throws if popup is closed", (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,
- 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);
- });
+ // Mock waitForBridgeResponse to simulate receiving a message
+ jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockResolvedValue(
+ "code=testCode&state=testState"
+ );
- 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 clientImpl = popupClient as any;
- PopupUtils.monitorPopupForHash(
- popup,
- window,
- clientImpl.config.auth.OIDCOptions.responseMode,
- clientImpl.config.system.pollIntervalMilliseconds,
+ const response = await BrowserUtils.waitForBridgeResponse(
+ 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: () => {},
+ 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
+ );
+
+ 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",
};
- pca = new PublicClientApplication({
- auth: {
- clientId: TEST_CONFIG.MSAL_CLIENT_ID,
- },
- system: {
- windowHashTimeout: 10,
- },
- });
+ // Mock waitForBridgeResponse to simulate receiving a message
+ jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockResolvedValue(
+ "code=authCode&state=testState456"
+ );
- await pca.initialize();
+ const response = await BrowserUtils.waitForBridgeResponse(
+ 5000,
+ clientImpl.logger,
+ clientImpl.browserCrypto,
+ request
+ );
- //PCA implementation moved to controller
- pca = (pca as any).controller;
+ expect(response).toEqual("code=authCode&state=testState456");
+ });
- //@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
- );
+ it("throws timeout error if BroadcastChannel receives no response", async () => {
+ const testLibraryState = { id: "test-channel-timeout-id" };
const clientImpl = popupClient as any;
- const result = await PopupUtils.monitorPopupForHash(
- popup as Window,
- window,
- clientImpl.config.auth.OIDCOptions.responseMode,
- clientImpl.config.system.pollIntervalMilliseconds,
- clientImpl.logger,
- clientImpl.unloadWindow,
- TEST_CONFIG.CORRELATION_ID
- ).catch((e) => {
- expect(e.errorCode).toEqual(
- BrowserAuthErrorCodes.monitorPopupTimeout
- );
- });
- });
+ const testState = ProtocolUtils.setRequestState(
+ clientImpl.browserCrypto,
+ "",
+ testLibraryState
+ );
- it("returns hash", (done) => {
- const popup = {
- location: {
- href: "http://localhost/#/code=hello",
- hash: "#code=hello",
- },
- history: {
- replaceState: () => {
- return;
- },
- },
- close: () => {},
+ 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.timedOut,
+ "redirect_bridge_timeout"
+ )
+ );
- 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(
+ 100,
+ clientImpl.logger,
+ clientImpl.browserCrypto,
+ request
+ )
+ ).rejects.toMatchObject({
+ errorCode: BrowserAuthErrorCodes.timedOut,
+ subError: "redirect_bridge_timeout",
});
+ });
- 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,
- clientImpl.config.system.pollIntervalMilliseconds,
+ 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(
+ 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,
- clientImpl.config.system.pollIntervalMilliseconds,
+ const promise2 = BrowserUtils.waitForBridgeResponse(
+ 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");
});
});
@@ -2345,10 +2265,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", () => {
diff --git a/lib/msal-browser/test/interaction_client/RedirectClient.spec.ts b/lib/msal-browser/test/interaction_client/RedirectClient.spec.ts
index 777705acda..7dd72a1d41 100644
--- a/lib/msal-browser/test/interaction_client/RedirectClient.spec.ts
+++ b/lib/msal-browser/test/interaction_client/RedirectClient.spec.ts
@@ -32,7 +32,6 @@ import {
CommonAuthorizationCodeRequest,
CommonAuthorizationUrlRequest,
AuthorizationCodeClient,
- ProtocolUtils,
Logger,
LogLevel,
NetworkResponse,
@@ -48,6 +47,7 @@ import {
ProtocolMode,
AccountEntityUtils,
Constants,
+ ProtocolUtils,
} from "@azure/msal-common/browser";
import * as BrowserUtils from "../../src/utils/BrowserUtils.js";
import {
@@ -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(() => {
@@ -532,7 +551,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;
@@ -674,7 +693,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;
@@ -739,7 +758,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;
@@ -777,7 +796,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;
@@ -919,7 +938,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;
@@ -1076,7 +1095,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;
@@ -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-browser/test/interaction_client/SilentIframeClient.spec.ts b/lib/msal-browser/test/interaction_client/SilentIframeClient.spec.ts
index ae7d6cf2b2..a7a9dcad8d 100644
--- a/lib/msal-browser/test/interaction_client/SilentIframeClient.spec.ts
+++ b/lib/msal-browser/test/interaction_client/SilentIframeClient.spec.ts
@@ -55,14 +55,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({
@@ -100,6 +111,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(() => {
@@ -158,7 +177,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(
@@ -210,9 +229,10 @@ describe("SilentIframeClient", () => {
AuthorizeProtocol,
"getAuthCodeRequestUrl"
).mockResolvedValue(testNavUrl);
- jest.spyOn(SilentHandler, "monitorIframeForHash").mockRejectedValue(
+ jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockRejectedValue(
createBrowserAuthError(
- BrowserAuthErrorCodes.monitorWindowTimeout
+ BrowserAuthErrorCodes.timedOut,
+ "redirect_bridge_timeout"
)
);
jest.spyOn(PkceGenerator, "generatePkceCodes").mockResolvedValue({
@@ -227,7 +247,8 @@ describe("SilentIframeClient", () => {
.mockImplementation((e) => {
expect(e).toMatchObject(
createBrowserAuthError(
- BrowserAuthErrorCodes.monitorWindowTimeout
+ BrowserAuthErrorCodes.timedOut,
+ "redirect_bridge_timeout"
)
);
});
@@ -252,7 +273,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({
@@ -323,7 +344,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(
@@ -393,7 +414,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(
@@ -506,7 +527,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(
@@ -609,7 +630,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(
@@ -641,7 +662,7 @@ describe("SilentIframeClient", () => {
});
it("Throws hash empty error", (done) => {
- jest.spyOn(SilentHandler, "monitorIframeForHash").mockResolvedValue(
+ jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockResolvedValue(
""
);
silentIframeClient
@@ -659,7 +680,7 @@ describe("SilentIframeClient", () => {
});
it("Throws hashDoesNotContainKnownProperties error", (done) => {
- jest.spyOn(SilentHandler, "monitorIframeForHash").mockResolvedValue(
+ jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockResolvedValue(
"myCustomHash"
);
silentIframeClient
@@ -759,7 +780,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
@@ -811,7 +832,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
@@ -913,7 +934,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
@@ -1015,7 +1036,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
@@ -1120,7 +1141,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(
@@ -1204,8 +1225,8 @@ describe("SilentIframeClient", () => {
beforeEach(() => {
jest.spyOn(
- SilentHandler,
- "monitorIframeForHash"
+ BrowserUtils,
+ "waitForBridgeResponse"
).mockResolvedValue(TEST_HASHES.TEST_SUCCESS_CODE_HASH_SILENT);
jest.spyOn(
InteractionHandler.prototype,
@@ -1267,12 +1288,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,
@@ -1396,13 +1414,9 @@ describe("SilentIframeClient", () => {
validEarJWK
);
- jest.spyOn(ProtocolUtils, "setRequestState").mockReturnValue(
- TEST_STATE_VALUES.TEST_STATE_SILENT
- );
-
jest.spyOn(
- SilentHandler,
- "monitorIframeForHash"
+ BrowserUtils,
+ "waitForBridgeResponse"
).mockResolvedValue(
`#ear_jwe=${validEarJWE}&state=${TEST_STATE_VALUES.TEST_STATE_SILENT}`
);
@@ -1425,8 +1439,8 @@ describe("SilentIframeClient", () => {
);
jest.spyOn(
- SilentHandler,
- "monitorIframeForHash"
+ BrowserUtils,
+ "waitForBridgeResponse"
).mockResolvedValue(
`#code=validCode&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 1e2a340264..5265ccdfa6 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;
@@ -75,120 +85,188 @@ 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,
- DEFAULT_POLL_INTERVAL_MS,
- performanceClient,
+ // Mock waitForBridgeResponse to simulate receiving a message
+ jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockResolvedValue(
+ "code=testCode&state=testState"
+ );
+
+ const response = await BrowserUtils.waitForBridgeResponse(
+ 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,
- DEFAULT_POLL_INTERVAL_MS,
- performanceClient,
+ // Mock waitForBridgeResponse to simulate receiving a message
+ jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockResolvedValue(
+ "code=authCode&state=testState456"
+ );
+
+ const response = await BrowserUtils.waitForBridgeResponse(
+ 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,
- DEFAULT_POLL_INTERVAL_MS,
- performanceClient,
- browserRequestLogger,
- RANDOM_TEST_GUID,
- Constants.ResponseMode.FRAGMENT
- ).then((hash: string) => {
- expect(hash).toEqual("#code=hello");
- done();
+ // Mock waitForBridgeResponse to simulate a timeout error
+ jest.spyOn(BrowserUtils, "waitForBridgeResponse").mockRejectedValue(
+ createBrowserAuthError(
+ BrowserAuthErrorCodes.timedOut,
+ "redirect_bridge_timeout"
+ )
+ );
+
+ await expect(
+ BrowserUtils.waitForBridgeResponse(
+ 100,
+ browserRequestLogger,
+ browserCrypto,
+ request
+ )
+ ).rejects.toMatchObject({
+ errorCode: BrowserAuthErrorCodes.timedOut,
+ subError: "redirect_bridge_timeout",
});
+ });
+
+ 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_IFRAME_TIMEOUT_MS,
+ browserRequestLogger,
+ browserCrypto,
+ request1
+ );
+
+ const promise2 = BrowserUtils.waitForBridgeResponse(
+ 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/redirect_bridge/broadcastResponseToMainFrame.spec.ts b/lib/msal-browser/test/redirect_bridge/broadcastResponseToMainFrame.spec.ts
new file mode 100644
index 0000000000..c3d89989ab
--- /dev/null
+++ b/lib/msal-browser/test/redirect_bridge/broadcastResponseToMainFrame.spec.ts
@@ -0,0 +1,620 @@
+/*
+ * 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 { parseAuthResponseFromUrl } from "../../src/utils/BrowserUtils.js";
+import {
+ TEST_HASHES,
+ TEST_STATE_VALUES,
+ RANDOM_TEST_GUID,
+} from "../utils/StringConstants.js";
+
+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
+ 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 history.replaceState
+ mockHistoryReplaceState = jest.fn();
+ window.history.replaceState = mockHistoryReplaceState;
+
+ // 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 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();
+ mockSessionStorage = {};
+ });
+
+ afterAll(() => {
+ // Restore original location
+ (window as any).location = originalLocation;
+ });
+
+ 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(
+ "No auth payload found on URL (hash or query)"
+ );
+
+ expect(mockHistoryReplaceState).toHaveBeenCalled();
+ });
+
+ 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(mockHistoryReplaceState).toHaveBeenCalled();
+ });
+
+ 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(mockHistoryReplaceState).toHaveBeenCalled();
+ });
+ });
+
+ 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(mockHistoryReplaceState).toHaveBeenCalled();
+
+ // 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(mockHistoryReplaceState).toHaveBeenCalled();
+
+ // 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(mockHistoryReplaceState).toHaveBeenCalled();
+
+ // 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),
+ })
+ );
+
+ // 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();
+ });
+
+ 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;
+
+ await broadcastResponseToMainFrame();
+
+ // Verify navigation was called with cached URL from sessionStorage
+ expect(mockNavigationClient.navigateInternal).toHaveBeenCalledWith(
+ expect.stringContaining(cachedOriginUrl),
+ expect.any(Object)
+ );
+ });
+
+ 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");
+ }
+ );
+
+ await broadcastResponseToMainFrame();
+
+ // Should still navigate successfully using homepage fallback
+ expect(mockNavigationClient.navigateInternal).toHaveBeenCalled();
+ expect(mockHistoryReplaceState).not.toHaveBeenCalled();
+ });
+
+ 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 clientId in session storage means no cached origin URL 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(mockHistoryReplaceState).toHaveBeenCalled();
+ expect(window.close).toHaveBeenCalled();
+ });
+
+ it("handles hybrid query + hash response", async () => {
+ const testClientId = "hybrid-client-id";
+
+ // 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();
+
+ // 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("&code=test_code");
+ });
+
+ 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(mockHistoryReplaceState).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(mockHistoryReplaceState).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(mockHistoryReplaceState).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(mockHistoryReplaceState).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("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 = "#";
+
+ await expect(broadcastResponseToMainFrame()).rejects.toThrow(
+ "No auth payload found on URL (hash or query)"
+ );
+ });
+ });
+
+ 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(mockHistoryReplaceState).toHaveBeenCalled();
+ });
+ });
+
+ 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(mockHistoryReplaceState).toHaveBeenCalled();
+ });
+
+ 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(mockHistoryReplaceState).toHaveBeenCalled();
+ });
+
+ it("clears hash after successful broadcast", async () => {
+ window.location.hash = TEST_HASHES.TEST_SUCCESS_CODE_HASH_POPUP;
+
+ await broadcastResponseToMainFrame();
+
+ expect(mockHistoryReplaceState).toHaveBeenCalled();
+ });
+ });
+});
+
+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: "",
+ };
+ });
+
+ 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("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");
+ });
+
+ 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(
+ "No auth payload found on URL (hash or query)"
+ );
+ });
+
+ 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 3381ad7ea5..02abc29f94 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 };
@@ -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);
});
@@ -130,4 +105,286 @@ 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 interactionInProgressCancelled 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 interactionInProgressCancelled error
+ await expect(waitPromise).rejects.toMatchObject({
+ errorCode: BrowserAuthErrorCodes.interactionInProgressCancelled,
+ });
+
+ 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.interactionInProgressCancelled,
+ });
+
+ 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.interactionInProgressCancelled,
+ });
+
+ // 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.interactionInProgressCancelled,
+ });
+
+ // 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.timedOut,
+ subError: "redirect_bridge_timeout",
+ });
+
+ // Try to cancel after timeout - should do nothing
+ BrowserUtils.cancelPendingBridgeResponse(
+ logger,
+ TEST_CONFIG.CORRELATION_ID
+ );
+
+ jest.useRealTimers();
+ });
+ });
});
diff --git a/lib/msal-browser/tsconfig.redirect-bridge.build.json b/lib/msal-browser/tsconfig.redirect-bridge.build.json
new file mode 100644
index 0000000000..43d902c2ea
--- /dev/null
+++ b/lib/msal-browser/tsconfig.redirect-bridge.build.json
@@ -0,0 +1,8 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "rootDir": "./src",
+ "outDir": "./dist/redirect-bridge",
+ },
+ "include": ["src"]
+}
diff --git a/lib/msal-common/apiReview/msal-common.api.md b/lib/msal-common/apiReview/msal-common.api.md
index bfc0c2f6f9..5cea9d8458 100644
--- a/lib/msal-common/apiReview/msal-common.api.md
+++ b/lib/msal-common/apiReview/msal-common.api.md
@@ -2242,6 +2242,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)
@@ -3261,6 +3268,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)
//
@@ -3621,19 +3635,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)
@@ -4127,6 +4134,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)
@@ -4765,9 +4780,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: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
diff --git a/lib/msal-common/src/exports-common.ts b/lib/msal-common/src/exports-common.ts
index 6b0cdca41c..d13a17192d 100644
--- a/lib/msal-common/src/exports-common.ts
+++ b/lib/msal-common/src/exports-common.ts
@@ -161,11 +161,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 9bdfc8eb86..eb1f6098eb 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";
@@ -51,6 +51,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.
@@ -238,7 +239,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;
+};
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"}`);
diff --git a/package-lock.json b/package-lock.json
index efc498c6e8..ebb905d10a 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,210 @@
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
+ "samples/msal-browser-samples/COOP": {
+ "name": "msal-browser-popup-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.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": {
+ "version": "0.11.14",
+ "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/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-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: '