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: '

Processing authentication...

', + standalone: false +}) +export class RedirectComponent implements OnInit { + ngOnInit(): void { + // Call broadcastResponseToMainFrame when component initializes + broadcastResponseToMainFrame().catch((error: Error) => { + console.error('Error broadcasting response to main frame:', error); + }); + } +} diff --git a/samples/msal-angular-samples/angular-modules-sample/src/index.html b/samples/msal-angular-samples/angular-modules-sample/src/index.html index 329e3e8fed..b68bbad4ae 100644 --- a/samples/msal-angular-samples/angular-modules-sample/src/index.html +++ b/samples/msal-angular-samples/angular-modules-sample/src/index.html @@ -11,7 +11,6 @@ - - \ No newline at end of file + diff --git a/samples/msal-angular-samples/angular-modules-sample/test/home.spec.ts b/samples/msal-angular-samples/angular-modules-sample/test/home.spec.ts index 0c5d91af51..bb3c098c47 100644 --- a/samples/msal-angular-samples/angular-modules-sample/test/home.spec.ts +++ b/samples/msal-angular-samples/angular-modules-sample/test/home.spec.ts @@ -140,12 +140,8 @@ describe('/ (Home Page)', () => { if (!popupPage) { throw new Error('Popup window was not opened'); } - const popupWindowClosed = new Promise((resolve) => - popupPage.once('close', resolve) - ); await enterCredentials(popupPage, screenshot, username, accountPwd); - await popupWindowClosed; await page.waitForSelector("xpath/.//p[contains(., 'Login successful!')]", { timeout: 3000, diff --git a/samples/msal-angular-samples/angular-standalone-sample/src/app/app.config.ts b/samples/msal-angular-samples/angular-standalone-sample/src/app/app.config.ts index 4a5f2142f9..b5259cad9c 100644 --- a/samples/msal-angular-samples/angular-standalone-sample/src/app/app.config.ts +++ b/samples/msal-angular-samples/angular-standalone-sample/src/app/app.config.ts @@ -46,7 +46,7 @@ export function MSALInstanceFactory(): IPublicClientApplication { auth: { clientId: environment.msalConfig.auth.clientId, authority: environment.msalConfig.auth.authority, - redirectUri: '/', + redirectUri: '/redirect', postLogoutRedirectUri: '/', }, cache: { diff --git a/samples/msal-angular-samples/angular-standalone-sample/src/app/app.routes.ts b/samples/msal-angular-samples/angular-standalone-sample/src/app/app.routes.ts index 0d2b68124e..7c5d9b9782 100644 --- a/samples/msal-angular-samples/angular-standalone-sample/src/app/app.routes.ts +++ b/samples/msal-angular-samples/angular-standalone-sample/src/app/app.routes.ts @@ -2,9 +2,14 @@ import { Routes } from '@angular/router'; import { FailedComponent } from './failed/failed.component'; import { HomeComponent } from './home/home.component'; import { ProfileComponent } from './profile/profile.component'; +import { RedirectComponent } from './redirect/redirect.component'; import { MsalGuard } from '@azure/msal-angular'; export const routes: Routes = [ + { + path: 'redirect', + component: RedirectComponent, + }, { path: 'profile', component: ProfileComponent, diff --git a/samples/msal-angular-samples/angular-standalone-sample/src/app/redirect/redirect.component.ts b/samples/msal-angular-samples/angular-standalone-sample/src/app/redirect/redirect.component.ts new file mode 100644 index 0000000000..cecf71729d --- /dev/null +++ b/samples/msal-angular-samples/angular-standalone-sample/src/app/redirect/redirect.component.ts @@ -0,0 +1,17 @@ +import { Component, OnInit } from '@angular/core'; +import { broadcastResponseToMainFrame } from '@azure/msal-browser/redirect-bridge'; + +@Component({ + selector: 'app-redirect', + standalone: true, + template: '

Processing authentication...

', +}) +export class RedirectComponent implements OnInit { + ngOnInit(): void { + // Call broadcastResponseToMainFrame when component initializes + broadcastResponseToMainFrame().catch((error: Error) => { + console.error('Error broadcasting response to main frame:', error); + }); + } +} + diff --git a/samples/msal-angular-samples/angular-standalone-sample/test/home.spec.ts b/samples/msal-angular-samples/angular-standalone-sample/test/home.spec.ts index 0c5d91af51..bb3c098c47 100644 --- a/samples/msal-angular-samples/angular-standalone-sample/test/home.spec.ts +++ b/samples/msal-angular-samples/angular-standalone-sample/test/home.spec.ts @@ -140,12 +140,8 @@ describe('/ (Home Page)', () => { if (!popupPage) { throw new Error('Popup window was not opened'); } - const popupWindowClosed = new Promise((resolve) => - popupPage.once('close', resolve) - ); await enterCredentials(popupPage, screenshot, username, accountPwd); - await popupWindowClosed; await page.waitForSelector("xpath/.//p[contains(., 'Login successful!')]", { timeout: 3000, diff --git a/samples/msal-browser-samples/COOP/app/auth.js b/samples/msal-browser-samples/COOP/app/auth.js new file mode 100644 index 0000000000..bc9465699d --- /dev/null +++ b/samples/msal-browser-samples/COOP/app/auth.js @@ -0,0 +1,91 @@ +// Browser check variables +// If you support IE, our recommendation is that you sign-in using Redirect APIs +// If you as a developer are testing using Edge InPrivate mode, please add "isEdge" to the if check +const ua = window.navigator.userAgent; +const msie = ua.indexOf("MSIE "); +const msie11 = ua.indexOf("Trident/"); +const msedge = ua.indexOf("Edge/"); +const isIE = msie > 0 || msie11 > 0; +const isEdge = msedge > 0; + +let signInType; +let accountId = ""; + +// Create the main myMSALObj instance +// configuration parameters are located at authConfig.js +const myMSALObj = new msal.PublicClientApplication(msalConfig); + +myMSALObj.initialize(); + +function handleResponse(resp) { + if (resp !== null) { + accountId = resp.account.homeAccountId; + myMSALObj.setActiveAccount(resp.account); + showWelcomeMessage(resp.account); + + if (resp.idToken) { + // Remove the login button completely + const loginButton = document.getElementById("loginPopup"); + if (loginButton) { + loginButton.remove(); + } + + const ssoButton = document.getElementById("sso"); + if (ssoButton) { + ssoButton.remove(); + } + + // Also display in UI + const successDiv = document.getElementById("successAuthCode"); + if (successDiv) { + successDiv.innerHTML = ` +
+
✅ Authentication Successful!
+

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

+

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

+

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

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

+
+
+ + +

+
+
+ + +
+
+ +
+
+
+
+ + + + + + + + + + + + + diff --git a/samples/msal-browser-samples/COOP/app/redirect.html b/samples/msal-browser-samples/COOP/app/redirect.html new file mode 100644 index 0000000000..de0f168930 --- /dev/null +++ b/samples/msal-browser-samples/COOP/app/redirect.html @@ -0,0 +1,18 @@ + + + + + + Redirect Page + + +

Processing authentication...

+ + + + + + diff --git a/samples/msal-browser-samples/COOP/app/ui.js b/samples/msal-browser-samples/COOP/app/ui.js new file mode 100644 index 0000000000..90e83fd5d7 --- /dev/null +++ b/samples/msal-browser-samples/COOP/app/ui.js @@ -0,0 +1,19 @@ +// Select DOM elements to work with +const signInButton = document.getElementById("SignIn"); +const popupButton = document.getElementById("popup"); +const redirectButton = document.getElementById("redirect"); +const cardDiv = document.getElementById("card-div"); + +function showWelcomeMessage(account) { + // Reconfiguring DOM elements + signInButton.setAttribute('class', "btn btn-success dropdown-toggle"); + signInButton.innerHTML = "Sign Out"; + popupButton.setAttribute('onClick', "signOut(this.id)"); + popupButton.innerHTML = "Sign Out with Popup"; + redirectButton.setAttribute('onClick', "signOut(this.id)"); + redirectButton.innerHTML = "Sign Out with Redirect"; +} + +// onLoad is now only called from redirect.html, not from the main page +// Removed DOMContentLoaded listener to prevent running on main page + diff --git a/samples/msal-browser-samples/COOP/jest.config.js b/samples/msal-browser-samples/COOP/jest.config.js new file mode 100644 index 0000000000..ddf4593104 --- /dev/null +++ b/samples/msal-browser-samples/COOP/jest.config.js @@ -0,0 +1,16 @@ +const fs = require("fs"); +const path = require("path"); + +const APP_DIR = path.join(__dirname, 'app'); + +// if a sample is specified, only run tests for that sample +const sampleFolders = process.argv.find(arg => arg.startsWith('--sample=')) + ? [path.join(APP_DIR, process.argv.find(arg => arg.startsWith('--sample='))?.split('=')[1])] + : fs.readdirSync(APP_DIR, { withFileTypes: true }) + .filter(file => file.isDirectory() && file.name !== "node_modules" && fs.existsSync(path.resolve(APP_DIR, file.name, "jest.config.js"))) + .map(file => path.join(APP_DIR, file.name)); + +module.exports = { + projects: sampleFolders, + testTimeout: 30000 +}; diff --git a/samples/msal-browser-samples/COOP/package.json b/samples/msal-browser-samples/COOP/package.json new file mode 100644 index 0000000000..50a9c3864e --- /dev/null +++ b/samples/msal-browser-samples/COOP/package.json @@ -0,0 +1,48 @@ +{ + "name": "msal-browser-popup-coop", + "version": "1.0.0", + "license": "MIT", + "private": true, + "main": "server.js", + "scripts": { + "start": "node server.js", + "start:https": "node server.js -h", + "start:server:https": "node sts/lmso_server.js -h", + "test": "npx playwright test", + "generate:certs": "openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 7 -nodes -subj /CN=localhost" + }, + "dependencies": { + "@azure/msal-node": "^5.0.0-alpha.0", + "cors": "^2.8.5", + "express": "^4.19.2", + "path": "^0.11.14" + }, + "devDependencies": { + "e2e-test-utils": "file:../../e2eTestUtils", + "@playwright/test": "^1.31.1", + "@types/node": "^24.10.0", + "@types/jest": "^29.5.0", + "@types/react": "^19.1.3", + "@types/react-dom": "^19.1.3", + "@typescript-eslint/eslint-plugin": "^5.0.0", + "@typescript-eslint/parser": "^5.0.0", + "@vercel/webpack-asset-relocator-loader": "1.7.3", + "autoprefixer": "^10.4.13", + "css-loader": "^6.0.0", + "electron": "22.3.25", + "eslint": "^8.0.1", + "eslint-plugin-import": "^2.25.0", + "fork-ts-checker-webpack-plugin": "^7.2.1", + "jest": "^29.5.0", + "node-loader": "^2.0.0", + "postcss": "^8.4.31", + "postcss-loader": "^4.2.0", + "sass": "^1.55.0", + "sass-loader": "^10.1.1", + "style-loader": "^3.0.0", + "ts-jest": "^29.1.0", + "ts-loader": "^9.2.2", + "ts-node": "^10.0.0", + "typescript": "~4.5.4" + } +} diff --git a/samples/msal-browser-samples/COOP/playwright.config.ts b/samples/msal-browser-samples/COOP/playwright.config.ts new file mode 100644 index 0000000000..47add86290 --- /dev/null +++ b/samples/msal-browser-samples/COOP/playwright.config.ts @@ -0,0 +1,96 @@ +import { PlaywrightTestConfig, devices } from "@playwright/test"; +import { RETRY_TIMES } from "e2e-test-utils"; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// import path from 'path'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +const config: PlaywrightTestConfig = { + testDir: "./test", + maxFailures: 2, + /* Run tests in files in parallel */ + //fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + //forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: RETRY_TIMES, + /* Opt out of parallel tests on CI. */ + //workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: "html", + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('')`. */ + baseURL: "https://localhost:30662", + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", + headless: true, + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + + // { + // name: "firefox", + // use: { ...devices["Desktop Firefox"] }, + // }, + + // { + // name: "webkit", + // use: { ...devices["Desktop Safari"] }, + // }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + timeout: 20000, + globalTimeout: 80000, + + /* Run your local dev servers before starting the tests */ + webServer: [ + { + command: "npm run start:https", + url: "https://localhost:30662", + reuseExistingServer: !process.env.CI, + ignoreHTTPSErrors: true, + }, + { + command: "npm run start:server:https", + url: "https://localhost:30663", + reuseExistingServer: !process.env.CI, + ignoreHTTPSErrors: true, + }, + ], +}; + +export default config; diff --git a/samples/msal-browser-samples/COOP/server.js b/samples/msal-browser-samples/COOP/server.js new file mode 100644 index 0000000000..217b32caea --- /dev/null +++ b/samples/msal-browser-samples/COOP/server.js @@ -0,0 +1,79 @@ +/* +* Copyright (c) Microsoft. All rights reserved. Licensed under the MIT license. +* See LICENSE in the source repository root for complete license information. +*/ +const express = require('express'); +const morgan = require('morgan'); +const fs = require('fs'); +const path = require('path'); +const argv = require("yargs") + .usage("Usage: $0 -p [PORT] -https") + .alias("p", "port") + .alias("h", "https") + .describe("port", "(Optional) Port Number - default is 30662") + .describe("https", "(Optional) Serve over https") + .strict() + .argv; + + +const DEFAULT_PORT = 30662; +const APP_DIR = __dirname + `/app`; + +//initialize express. +const app = express(); + +// Initialize variables. +let port = DEFAULT_PORT; // -p {PORT} || 30662; +if (argv.p) { + port = argv.p; +} + +let logHttpRequests = true; + +// Set the front-end folder to serve public assets. +app.use("/lib", express.static(path.join(__dirname, "../../../lib/msal-browser/lib"))); + +// Serve static files from app directory (for .js, .css, etc.) +app.use(express.static(APP_DIR)); + +if (logHttpRequests) { + // Configure morgan module to log all requests. + app.use(morgan('dev')); +} + +// set up a route for redirect.html. When using popup and silent APIs, +// we recommend setting the redirectUri to a blank page or a page that does not implement MSAL. +app.get("/redirect", function (req, res) { + res.sendFile(path.join(APP_DIR + "/redirect.html")); +}); + +// Set up a route for index.html (catch-all at the end). +app.get('*', function (req, res) { + res.sendFile(path.join(APP_DIR + '/index.html')); +}); + +// Start the server. +if (argv.https) { + const https = require('https'); + + /** + * Secrets should never be hardcoded. The dotenv npm package can be used to store secrets or certificates + * in a .env file (located in project's root directory) that should be included in .gitignore to prevent + * accidental uploads of the secrets. + * + * Certificates can also be read-in from files via NodeJS's fs module. However, they should never be + * stored in the project's directory. Production apps should fetch certificates from + * Azure KeyVault (https://azure.microsoft.com/products/key-vault), or other secure key vaults. + * + * Please see "Certificates and Secrets" (https://learn.microsoft.com/azure/active-directory/develop/security-best-practices-for-app-registration#certificates-and-secrets) + * for more information. + */ + const privateKey = fs.readFileSync('./key.pem', 'utf8'); + const certificate = fs.readFileSync('./cert.pem', 'utf8'); + const credentials = { key: privateKey, cert: certificate }; + const httpsServer = https.createServer(credentials, app); + httpsServer.listen(port); +} else { + app.listen(port); +} +console.log(`Listening on port ${port}...`); diff --git a/samples/msal-browser-samples/COOP/sts/lmso_server.js b/samples/msal-browser-samples/COOP/sts/lmso_server.js new file mode 100644 index 0000000000..64c6436eab --- /dev/null +++ b/samples/msal-browser-samples/COOP/sts/lmso_server.js @@ -0,0 +1,150 @@ +/* +* Copyright (c) Microsoft. All rights reserved. Licensed under the MIT license. +* See LICENSE in the source repository root for complete license information. +*/ +const express = require('express'); +const cors = require('cors'); +const morgan = require('morgan'); +const fs = require('fs'); +const path = require('path'); +const argv = require("yargs") + .usage("Usage: $0 -p [PORT] -https") + .alias("p", "port") + .alias("h", "https") + .describe("port", "(Optional) Port Number - default is 30663") + .describe("https", "(Optional) Serve over https") + .strict() + .argv; + + +const DEFAULT_PORT = 30663; +const APP_DIR = __dirname; + +//initialize express. +const app = express(); + +// Parse JSON and URL-encoded bodies +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +// Enable CORS for requests from localhost:30662 +app.use(cors({ + origin: 'https://localhost:30662', + credentials: true +})); + +// Initialize variables. +let port = DEFAULT_PORT; // -p {PORT} || 30663; +if (argv.p) { + port = argv.p; +} + +let logHttpRequests = true; + + +app.use(express.static(__dirname, { + setHeaders: (res) => { + res.set('Cross-Origin-Opener-Policy', 'same-origin'); + } +})); + +// OAuth2 authorization endpoint - serve the login page +app.get('/:tenantId/oauth2/v2.0/authorize', function (req, res) { + res.set('Cross-Origin-Opener-Policy', 'same-origin'); + res.sendFile(path.join(APP_DIR + '/sts_index.html')); +}); + +// OAuth2 token endpoint - exchange auth code for tokens +app.post('/:tenantId/oauth2/v2.0/token', function (req, res) { + console.log('[STS TOKEN] Token request received'); + console.log('[STS TOKEN] Body:', req.body); + + const { code, grant_type, client_id } = req.body; + + if (grant_type !== 'authorization_code') { + return res.status(400).json({ error: 'unsupported_grant_type' }); + } + + // Decode the auth code to verify it + try { + const authCodeData = JSON.parse(atob(code)); + console.log('[STS TOKEN] Auth code data:', authCodeData); + + // Generate mock tokens + const idTokenPayload = { + aud: client_id, + iss: `https://localhost:30663/${req.params.tenantId}/v2.0`, + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 3600, + sub: "test-subject-id", + name: "Test User", + preferred_username: "test@example.com", + tid: req.params.tenantId, + nonce: authCodeData.nonce, // Include nonce from auth code + ver: "2.0" + }; + + console.log('[STS TOKEN] ID Token payload:', idTokenPayload); + + // Create simple JWT-like structure (header.payload.signature) + const header = btoa(JSON.stringify({ typ: "JWT", alg: "RS256" })); + const payload = btoa(JSON.stringify(idTokenPayload)); + const signature = btoa("mock_signature"); + const id_token = `${header}.${payload}.${signature}`; + + const tokenResponse = { + token_type: "Bearer", + scope: "openid profile offline_access", + expires_in: 3600, + ext_expires_in: 3600, + access_token: btoa(JSON.stringify({ token: "mock_access_token", timestamp: Date.now() })), + refresh_token: btoa(JSON.stringify({ token: "mock_refresh_token", timestamp: Date.now() })), + id_token: id_token, + client_info: btoa(JSON.stringify({ uid: "test-uid", utid: req.params.tenantId })) + }; + + console.log('[STS TOKEN] Returning token response'); + res.json(tokenResponse); + + } catch (error) { + console.error('[STS TOKEN] Error:', error); + res.status(400).json({ error: 'invalid_grant', error_description: 'Invalid authorization code' }); + } +}); + +// Set up a route for index.html (catch-all at the end). +app.get('*', function (req, res) { + res.set('Cross-Origin-Opener-Policy', 'same-origin'); + res.sendFile(path.join(APP_DIR + '/sts_index.html')); +}); + +if (logHttpRequests) { + // Configure morgan module to log all requests. + app.use(morgan('dev')); +} + +// Start the server. +if (argv.https) { + const https = require('https'); + + /** + * Secrets should never be hardcoded. The dotenv npm package can be used to store secrets or certificates + * in a .env file (located in project's root directory) that should be included in .gitignore to prevent + * accidental uploads of the secrets. + * + * Certificates can also be read-in from files via NodeJS's fs module. However, they should never be + * stored in the project's directory. Production apps should fetch certificates from + * Azure KeyVault (https://azure.microsoft.com/products/key-vault), or other secure key vaults. + * + * Please see "Certificates and Secrets" (https://learn.microsoft.com/azure/active-directory/develop/security-best-practices-for-app-registration#certificates-and-secrets) + * for more information. + */ + const privateKey = fs.readFileSync(path.join(__dirname, '../key.pem'), 'utf8'); + const certificate = fs.readFileSync(path.join(__dirname, '../cert.pem'), 'utf8'); + const credentials = { key: privateKey, cert: certificate }; + const httpsServer = https.createServer(credentials, app); + httpsServer.listen(port); +} else { + app.listen(port); +} +console.log(`Listening on port ${port}...`); diff --git a/samples/msal-browser-samples/COOP/sts/package.json b/samples/msal-browser-samples/COOP/sts/package.json new file mode 100644 index 0000000000..64f2a675a0 --- /dev/null +++ b/samples/msal-browser-samples/COOP/sts/package.json @@ -0,0 +1,22 @@ +{ + "name": "lmso-popup-coop", + "version": "1.0.0", + "license": "MIT", + "private": true, + "main": "lmso_server.js", + "scripts": { + "start": "node lmso_server.js", + "start:https": "node lmso_server.js -h", + "test": "npx playwright test", + "generate:certs": "openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 7 -nodes -subj /CN=localhost" + }, + "dependencies": { + "@azure/msal-node": "^5.0.0-alpha.0", + "cors": "^2.8.5", + "express": "^4.19.2", + "path": "^0.11.14" + }, + "devDependencies": { + "@playwright/test": "^1.30.0" + } +} diff --git a/samples/msal-browser-samples/COOP/sts/sts_index.html b/samples/msal-browser-samples/COOP/sts/sts_index.html new file mode 100644 index 0000000000..2d2fdbcad7 --- /dev/null +++ b/samples/msal-browser-samples/COOP/sts/sts_index.html @@ -0,0 +1,32 @@ + + + + + + + LMSO + + + + + + + + +

Authenticating...

+ + + + + + + + + \ No newline at end of file diff --git a/samples/msal-browser-samples/COOP/sts/ui.js b/samples/msal-browser-samples/COOP/sts/ui.js new file mode 100644 index 0000000000..2afa9ae68d --- /dev/null +++ b/samples/msal-browser-samples/COOP/sts/ui.js @@ -0,0 +1,72 @@ +window.name = "STS Window"; +const channel = new BroadcastChannel('sts-channel'); + +function performAuthentication() { + + console.log("STS: Performing authentication (simulated)"); + console.log("STS: Adding 2 second delay..."); + + // Since the STS response is mocked, the popup opens and closes before the e2e tests can capture a screenshot. Add a 2 second delay to capture popup screenshot before the popup closes itself. + setTimeout(() => { + continueAuthentication(); + }, 2000); +} + +function continueAuthentication() { + console.log("STS: window.opener", window.opener); + console.log("STS: window.location.search", window.location.search); + + // Parse the original authorization request parameters + const urlParams = new URLSearchParams(window.location.search); + const redirectUri = urlParams.get('redirect_uri'); + + console.log("STS: redirect_uri from params:", redirectUri); + console.log("STS: All URL params:", Array.from(urlParams.entries())); + + const finalRedirectUri = redirectUri || "https://localhost:30662/redirect"; + console.log("STS: Final redirect URI:", finalRedirectUri); + + // Extract nonce for inclusion in auth code + const nonce = urlParams.get('nonce'); + + // Generate a base64-encoded auth code that looks realistic and includes nonce + const authCodePayload = { + tenant: "72f988bf-86f1-41af-91ab-2d7cd011db47", + user: "test@example.com", + timestamp: Date.now(), + nonce: nonce // Store nonce in auth code so token endpoint can use it + }; + const simulated_auth_code = btoa(JSON.stringify(authCodePayload)); + + console.log("STS: Authentication successful, returning auth code:", simulated_auth_code); + navigatetoJSApp(simulated_auth_code, finalRedirectUri, urlParams); +} + +// Make function globally accessible +window.performAuthentication = performAuthentication; + +function navigatetoJSApp(simulated_auth_code, redirectUri, urlParams) { + // Build redirect URL + let redirectUrl = redirectUri; + redirectUrl += (redirectUrl.includes('?') ? '&' : '?') + 'code=' + encodeURIComponent(simulated_auth_code); + + for (const param of ["state", "popup", "nonce"]) { + const paramValue = urlParams.get(param); + if (paramValue) { + redirectUrl += `&${param}=` + encodeURIComponent(paramValue); + } + } + + // Add client_info if requested + const clientInfo = urlParams.get('client_info'); + if (clientInfo === '1') { + // Generate a base64-encoded client info + const clientInfoPayload = { uid: "test-uid", utid: "72f988bf-86f1-41af-91ab-2d7cd011db47" }; + redirectUrl += '&client_info=' + encodeURIComponent(btoa(JSON.stringify(clientInfoPayload))); + } + + console.log("STS: Redirecting to:", redirectUrl); + + // Redirect back to the app + window.location.replace(redirectUrl); +} diff --git a/samples/msal-browser-samples/COOP/test/home.spec.ts b/samples/msal-browser-samples/COOP/test/home.spec.ts new file mode 100644 index 0000000000..ab1cdc575f --- /dev/null +++ b/samples/msal-browser-samples/COOP/test/home.spec.ts @@ -0,0 +1,123 @@ +import { chromium, Browser, Page, test, expect, Frame } from "@playwright/test"; +import { ScreenShotElectron } from "e2e-test-utils"; + +const LOCAL_SCREENSHOT_FOLDER = `${__dirname}/screenshots`; + +let browser: Browser; +let browserPage: Page; + +test.beforeEach(async () => { + browser = await chromium.launch({ + args: ["--allow-insecure-localhost"], + }); + browserPage = await browser.newPage(); + await browserPage.goto("/"); +}); + +test.afterEach(async () => { + await browserPage.close(); +}); + +test.afterAll(async () => { + await browser.close(); +}); + +test("Home page - Popup and Sso Silent buttons are loaded on home-page", async () => { + const testName = "homePageLoad"; + console.log(`${LOCAL_SCREENSHOT_FOLDER}/${testName}`); + const screenshot = new ScreenShotElectron( + `${LOCAL_SCREENSHOT_FOLDER}/${testName}` + ); + await screenshot.takeScreenshot(browserPage, "Page loaded"); + + const popupButton = await browserPage.waitForSelector("#loginPopup"); + const ssoButton = await browserPage.waitForSelector("#sso"); + + expect(popupButton).not.toBeNull(); + expect(ssoButton).not.toBeNull(); +}); + +test("Popup Login Flow - Successful authentication and token acquisition", async () => { + const testName = "popupLoginFlow"; + const screenshot = new ScreenShotElectron( + `${LOCAL_SCREENSHOT_FOLDER}/${testName}` + ); + + await screenshot.takeScreenshot(browserPage, "App loaded"); + + // Click the popup login button + const loginButton = await browserPage.waitForSelector("#loginPopup"); + const newPopupWindowPromise = new Promise((resolve) => + browserPage.once("popup", resolve) + ); + await loginButton.click(); + await screenshot.takeScreenshot(browserPage, "Login button clicked"); + await browserPage.waitForTimeout(1000); + + const popupPage = await newPopupWindowPromise; + if (!popupPage) { + throw new Error("Popup window was not opened"); + } + await screenshot.takeScreenshot(popupPage, "Popup opened"); + + await browserPage.waitForSelector("#successAuthCode", { timeout: 3000 }); + await browserPage.waitForSelector("#successMsg", { timeout: 3000 }); + + await screenshot.takeScreenshot( + browserPage, + "Login successful - Welcome message displayed" + ); + + // Verify account info is displayed + const successMessage = await browserPage.textContent("#successAuthCode"); + console.log("Welcome message:", successMessage); + expect(successMessage).toContain("Authentication Successful"); + expect(successMessage).toContain("Test User"); +}); + +test("ssoSilent Token Acquisition", async () => { + const testName = "ssoSilentTokenAcquisition"; + const screenshot = new ScreenShotElectron( + `${LOCAL_SCREENSHOT_FOLDER}/${testName}` + ); + + await screenshot.takeScreenshot(browserPage, "Initial page load"); + + //add iframe listener + const silentIframe = new Promise((resolve) => { + browserPage.once("frameattached", (frame) => { + resolve(frame); + console.log("Frame attached:", frame.url()); + }); + }); + + // Click the SSO silent button + const ssoButton = await browserPage.waitForSelector("#sso"); + await ssoButton.click(); + await screenshot.takeScreenshot(browserPage, "SSO button clicked"); + + //wait for the iframe to be detected + const frame = await silentIframe; + + if (!frame) { + throw new Error("Silent iframe was not opened"); + } + // Verify the iframe exists + expect(frame).not.toBeNull(); + console.log("Silent iframe frame object:", frame.url()); + expect(frame.url()).toContain("/authorize"); + + await browserPage.waitForSelector("#successAuthCode", { timeout: 3000 }); + await browserPage.waitForSelector("#successMsg", { timeout: 3000 }); + + await screenshot.takeScreenshot( + browserPage, + "Silent token acquisition completed" + ); + + // Verify account info is displayed + const successMessage = await browserPage.textContent("#successAuthCode"); + console.log("Welcome message:", successMessage); + expect(successMessage).toContain("Authentication Successful"); + expect(successMessage).toContain("Test User"); +}); diff --git a/samples/msal-browser-samples/COOP/tsconfig.base.json b/samples/msal-browser-samples/COOP/tsconfig.base.json new file mode 100644 index 0000000000..d519609f49 --- /dev/null +++ b/samples/msal-browser-samples/COOP/tsconfig.base.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "suppressImplicitAnyIndexErrors": true, + "sourceMap": true, + "declaration": true, + "experimentalDecorators": true, + "importHelpers": true, + "noImplicitAny": true + }, + "compileOnSave": false, + "buildOnSave": false +} diff --git a/samples/msal-browser-samples/COOP/tsconfig.json b/samples/msal-browser-samples/COOP/tsconfig.json new file mode 100644 index 0000000000..5e6695a0fa --- /dev/null +++ b/samples/msal-browser-samples/COOP/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "moduleResolution": "node", + "module": "commonjs", + "target": "es5", + "lib": [ + "es2015", + "dom", + "es2015.promise" + ], + "allowUnusedLabels": false, + "noImplicitReturns": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "types": [ + "node", + "jest" + ] + }, + "include": [ + "./app/**/test/*.spec.ts", + "./app/testUtils.ts" + ], + "exclude": [ + "node_modules" + ] +} \ No newline at end of file diff --git a/samples/msal-browser-samples/ExpressSample/public/css/styles.css b/samples/msal-browser-samples/ExpressSample/public/css/styles.css index c62a2e22f0..797c2c326a 100644 --- a/samples/msal-browser-samples/ExpressSample/public/css/styles.css +++ b/samples/msal-browser-samples/ExpressSample/public/css/styles.css @@ -843,4 +843,90 @@ main { height: 35px; margin-right: 0.75rem; } -} \ No newline at end of file +} + +/* Popup Warning Alert */ +.popup-warning { + position: fixed; + bottom: 20px; + right: 20px; + z-index: 1050; + max-width: 400px; + background-color: #d1ecf1; + border: 1px solid #bee5eb; + border-left: 4px solid #17a2b8; + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + animation: slideIn 0.3s ease-out; +} + +.popup-warning-content { + display: flex; + align-items: flex-start; + padding: 1rem; +} + +.popup-warning-icon { + font-size: 1.5rem; + margin-right: 0.75rem; + flex-shrink: 0; +} + +.popup-warning-text { + flex: 1; +} + +.popup-warning-text strong { + display: block; + color: #0c5460; + margin-bottom: 0.25rem; + font-size: 1rem; +} + +.popup-warning-text p { + color: #0c5460; + font-size: 0.875rem; + margin: 0; + line-height: 1.4; +} + +@keyframes slideIn { + from { + transform: translateY(100%); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +/* Retry Modal Specific Styles */ +.retry-question { + margin-top: 1rem; + font-weight: 500; +} + +.warning-box { + background-color: #fff3cd; + border: 1px solid #ffeaa7; + border-left: 4px solid #ffc107; + padding: 1rem; + border-radius: 4px; + margin-top: 1rem; +} + +.warning-box strong { + color: #856404; +} + +/* Responsive adjustments for popup warning */ +@media (max-width: 768px) { + .popup-warning { + bottom: 10px; + right: 10px; + left: 10px; + max-width: none; + } +} + diff --git a/samples/msal-browser-samples/ExpressSample/public/js/app.js b/samples/msal-browser-samples/ExpressSample/public/js/app.js index b4d7af9cce..fab3f835f4 100644 --- a/samples/msal-browser-samples/ExpressSample/public/js/app.js +++ b/samples/msal-browser-samples/ExpressSample/public/js/app.js @@ -4,12 +4,14 @@ */ // Main application entry point -import { +import { initializeMsal, signInPopup, signInRedirect, signOutPopup, signOutRedirect, + handleRetry, + handleCancelRetry, msalInstance } from './auth.js'; import { toggleDropdown, closeAllDropdowns, updateUI } from './ui.js'; @@ -25,18 +27,18 @@ function setupEventListeners() { const signInDropdown = document.getElementById('signInDropdown'); const signInPopupBtn = document.getElementById('signInPopup'); const signInRedirectBtn = document.getElementById('signInRedirect'); - + // Account dropdown (for authenticated users) const accountButton = document.getElementById('accountButton'); const accountDropdown = document.getElementById('accountDropdown'); const switchAccountBtn = document.getElementById('switchAccount'); const signOutPopupBtn = document.getElementById('signOutPopup'); const signOutRedirectBtn = document.getElementById('signOutRedirect'); - + // Account picker modal const accountPickerModal = document.getElementById('accountPickerModal'); const modalClose = document.querySelector('.modal-close'); - + // Toggle sign-in dropdown if (signInButton && signInDropdown) { signInButton.addEventListener('click', function(e) { @@ -47,7 +49,7 @@ function setupEventListeners() { signInButton.style.display = ''; } - + // Toggle account dropdown if (accountButton && accountDropdown) { accountButton.addEventListener('click', function(e) { @@ -56,7 +58,7 @@ function setupEventListeners() { toggleDropdown(accountButton.parentElement); }); } - + // Close dropdowns when clicking outside document.addEventListener('click', function(e) { const dropdowns = document.querySelectorAll('.dropdown'); @@ -68,7 +70,7 @@ function setupEventListeners() { } }); }); - + // Handle keyboard navigation for dropdowns document.addEventListener('keydown', function(e) { if (e.key === 'Escape') { @@ -76,7 +78,7 @@ function setupEventListeners() { closeAccountPickerModal(); } }); - + // Sign in event handlers if (signInPopupBtn) { signInPopupBtn.addEventListener('click', function(e) { @@ -85,7 +87,7 @@ function setupEventListeners() { signInPopup(); }); } - + if (signInRedirectBtn) { signInRedirectBtn.addEventListener('click', function(e) { e.preventDefault(); @@ -93,25 +95,25 @@ function setupEventListeners() { signInRedirect(); }); } - + // Profile page sign in buttons (may not exist on all pages) const profileSignInPopupBtn = document.getElementById('profileSignInPopup'); const profileSignInRedirectBtn = document.getElementById('profileSignInRedirect'); - + if (profileSignInPopupBtn) { profileSignInPopupBtn.addEventListener('click', function(e) { e.preventDefault(); signInPopup(); }); } - + if (profileSignInRedirectBtn) { profileSignInRedirectBtn.addEventListener('click', function(e) { e.preventDefault(); signInRedirect(); }); } - + // Profile page refresh button (may not exist on all pages) const refreshProfileBtn = document.getElementById('refreshProfileBtn'); if (refreshProfileBtn) { @@ -124,7 +126,7 @@ function setupEventListeners() { } }); } - + // Account management event handlers if (switchAccountBtn) { switchAccountBtn.addEventListener('click', function(e) { @@ -133,7 +135,7 @@ function setupEventListeners() { showAccountPickerModal(); }); } - + if (signOutPopupBtn) { signOutPopupBtn.addEventListener('click', function(e) { e.preventDefault(); @@ -141,7 +143,7 @@ function setupEventListeners() { signOutPopup(); }); } - + if (signOutRedirectBtn) { signOutRedirectBtn.addEventListener('click', function(e) { e.preventDefault(); @@ -149,12 +151,12 @@ function setupEventListeners() { signOutRedirect(); }); } - + // Modal event handlers if (modalClose) { modalClose.addEventListener('click', closeAccountPickerModal); } - + if (accountPickerModal) { accountPickerModal.addEventListener('click', function(e) { if (e.target === accountPickerModal) { @@ -162,16 +164,48 @@ function setupEventListeners() { } }); } - + + // Retry modal event handlers + const retryBtn = document.getElementById('retryButton'); + const cancelRetryBtn = document.getElementById('cancelRetryButton'); + const retryModal = document.getElementById('retry-modal'); + const retryModalClose = document.querySelector('#retry-modal .modal-close'); + + if (retryBtn) { + retryBtn.addEventListener('click', function(e) { + e.preventDefault(); + handleRetry(); + }); + } + + if (cancelRetryBtn) { + cancelRetryBtn.addEventListener('click', function(e) { + e.preventDefault(); + handleCancelRetry(); + }); + } + + if (retryModalClose) { + retryModalClose.addEventListener('click', handleCancelRetry); + } + + if (retryModal) { + retryModal.addEventListener('click', function(e) { + if (e.target === retryModal) { + handleCancelRetry(); + } + }); + } + // Setup SPA Navigation setupSPANavigation(); } // DOM ready function -document.addEventListener('DOMContentLoaded', async function() { +document.addEventListener('DOMContentLoaded', async function() { // Validate environment variables first and show warning if needed const envValid = validateEnvironmentVariables(); - + // Only proceed with MSAL initialization if environment is properly configured if (!envValid) { console.warn('MSAL initialization skipped due to missing environment variables'); @@ -179,16 +213,16 @@ document.addEventListener('DOMContentLoaded', async function() { setupEventListeners(); return; } - + // Initialize MSAL await initializeMsal(); - + // Refresh authentication state (this will also call updateUI) updateUI(msalInstance.getActiveAccount()); - + // Setup all event listeners setupEventListeners(); - + // Handle initial route await handleRouting(); }); diff --git a/samples/msal-browser-samples/ExpressSample/public/js/auth.js b/samples/msal-browser-samples/ExpressSample/public/js/auth.js index c7f5be86d3..efe9ca89e3 100644 --- a/samples/msal-browser-samples/ExpressSample/public/js/auth.js +++ b/samples/msal-browser-samples/ExpressSample/public/js/auth.js @@ -12,6 +12,9 @@ import { createMsalConfig, loginRequest } from './authConfig.js'; // MSAL instance export let msalInstance; +// Retry state tracking +let retryRequested = false; + // Initialize MSAL export async function initializeMsal() { try { @@ -56,14 +59,36 @@ export async function handleProtectedRouteAuth(path) { // Sign in with popup export async function signInPopup() { + // Show warning message when popup is about to open + showPopupWarning(); + try { - const response = await msalInstance.loginPopup(loginRequest); + const response = await msalInstance.loginPopup({ + ...loginRequest, + // Only override if user explicitly clicked retry + overrideInteractionInProgress: retryRequested + }); + + // Hide warning on success + hidePopupWarning(); + retryRequested = false; + msalInstance.setActiveAccount(response.account); updateUI(response.account); showSuccess('Successfully signed in!'); } catch (error) { - console.error('Popup sign in failed:', error); - showError('Sign in failed: ' + error.message); + // Hide warning on error + hidePopupWarning(); + + if (error.errorCode === 'interaction_in_progress') { + // Show retry modal - let user decide whether to retry + showRetryModal(); + } else { + // Reset retry flag for other errors + retryRequested = false; + console.error('Popup sign in failed:', error); + showError('Sign in failed: ' + error.message); + } } } @@ -126,3 +151,57 @@ export async function getAccessToken() { throw error; }); } + +/** + * Show warning message during popup authentication + */ +function showPopupWarning() { + const warningDiv = document.getElementById('popup-warning'); + if (warningDiv) { + warningDiv.style.display = 'block'; + } +} + +/** + * Hide warning message + */ +function hidePopupWarning() { + const warningDiv = document.getElementById('popup-warning'); + if (warningDiv) { + warningDiv.style.display = 'none'; + } +} + +/** + * Show retry modal for interaction_in_progress error + */ +function showRetryModal() { + const modal = document.getElementById('retry-modal'); + if (modal) { + modal.style.display = 'block'; + } +} + +/** + * Handle user clicking retry button + */ +export function handleRetry() { + retryRequested = true; // User explicitly requested retry + const modal = document.getElementById('retry-modal'); + if (modal) { + modal.style.display = 'none'; + } + signInPopup(); +} + +/** + * Handle user canceling retry + */ +export function handleCancelRetry() { + retryRequested = false; + const modal = document.getElementById('retry-modal'); + if (modal) { + modal.style.display = 'none'; + } +} + diff --git a/samples/msal-browser-samples/ExpressSample/public/redirect.html b/samples/msal-browser-samples/ExpressSample/public/redirect.html new file mode 100644 index 0000000000..183b87e242 --- /dev/null +++ b/samples/msal-browser-samples/ExpressSample/public/redirect.html @@ -0,0 +1,19 @@ + + + + + + Redirect Page + + +

Processing authentication...

+ + + + + + + diff --git a/samples/msal-browser-samples/ExpressSample/server.js b/samples/msal-browser-samples/ExpressSample/server.js index 4443e5df5b..7c615d6164 100644 --- a/samples/msal-browser-samples/ExpressSample/server.js +++ b/samples/msal-browser-samples/ExpressSample/server.js @@ -41,16 +41,18 @@ app.use(express.json()); // Parse JSON bodies // Serve MSAL library from local build app.use('/lib/msal-browser', express.static(path.join(__dirname, '../../../lib/msal-browser/lib'))); +const localBuildName = 'Local Build'; +const localBuildDebugName = 'Local Build (Debug)'; // Dynamic MSAL version management let currentMsalVersion = 'local'; // Default to local build let availableVersions = { 'local': { - name: 'Local Build', + name: localBuildName, path: '/lib/msal-browser/msal-browser.min.js', description: 'Locally built version from repository' }, 'local-debug': { - name: 'Local Build (Debug)', + name: localBuildDebugName, path: '/lib/msal-browser/msal-browser.js', description: 'Locally built debug version from repository' }, @@ -183,11 +185,12 @@ function getCurrentVersionInfo() { } // Helper function to pass environment variables and version info to templates -const getEnvConfig = () => { +const getEnvConfig = (version) => { + const majorVersion = parseInt(version.info.name.match(/v(\d+)\.(?:x|\d+)/)?.[1] || "0", 10); const config = { CLIENT_ID: process.env.CLIENT_ID, AUTHORITY: process.env.AUTHORITY, - REDIRECT_URI: process.env.REDIRECT_URI, + REDIRECT_URI: version.info.name === localBuildName || version.info.name === localBuildDebugName || majorVersion >= 5 ? `${process.env.REDIRECT_URI}/redirect` : process.env.REDIRECT_URI, POST_LOGOUT_REDIRECT_URI: process.env.POST_LOGOUT_REDIRECT_URI }; @@ -214,7 +217,7 @@ const getEnvConfig = () => { // Enhanced environment config with version info const getEnvConfigWithVersion = () => ({ - ...getEnvConfig(), + ...getEnvConfig(getCurrentVersionInfo()), version: getCurrentVersionInfo() }); @@ -334,6 +337,10 @@ app.get('/', (req, res) => { }); }); +app.get('/redirect', (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'redirect.html')); +}); + app.get('/profile', (req, res) => { res.render('profile', { title: 'MSAL Express Sample - Profile', diff --git a/samples/msal-browser-samples/ExpressSample/test/test-helpers.ts b/samples/msal-browser-samples/ExpressSample/test/test-helpers.ts index 689e533896..b114692109 100644 --- a/samples/msal-browser-samples/ExpressSample/test/test-helpers.ts +++ b/samples/msal-browser-samples/ExpressSample/test/test-helpers.ts @@ -6,21 +6,21 @@ import { /** * Checks that tokens can be retrieved from the cache - * @param page - * @param screenshot + * @param page + * @param screenshot */ export async function verifyCacheWasUsed(page: puppeteer.Page, screenshot: Screenshot) { // Track network requests to verify cached tokens are used const networkRequests: puppeteer.HTTPRequest[] = []; page.on('request', (request) => { // Track all requests to authentication endpoints - if (request.url().includes('login.microsoftonline.com') || - request.url().includes('/token') || + if (request.url().includes('login.microsoftonline.com') || + request.url().includes('/token') || request.url().includes('/authorize')) { networkRequests.push(request); } }); - + if (!page.url().endsWith("profile")) { await page.locator("a#viewProfileButton").click(); await screenshot.takeScreenshot(page, "Profile button clicked"); @@ -39,9 +39,9 @@ export async function verifyCacheWasUsed(page: puppeteer.Page, screenshot: Scree /** * Helper to switch to a different version - * @param version - * @param page - * @param screenshot + * @param version + * @param page + * @param screenshot */ export async function switchToVersion(version: string, page: puppeteer.Page, screenshot: Screenshot) { let versionSearchText = version; @@ -83,7 +83,7 @@ export async function switchToVersion(version: string, page: puppeteer.Page, scr const selectedVersion = await page.locator(`span#currentVersionText`) .filter((value) => {return !!value.textContent && !value.textContent.startsWith("Switching")}) .map(value => value.textContent || "") - .setTimeout(2000) + .setTimeout(3000) .wait(); expect(selectedVersion).toContain(versionSearchText); await screenshot.takeScreenshot(page, `${version} version selected`); @@ -91,16 +91,16 @@ export async function switchToVersion(version: string, page: puppeteer.Page, scr /** * Sign a user in - * @param page - * @param screenshot - * @param username - * @param accountPwd + * @param page + * @param screenshot + * @param username + * @param accountPwd * @param ssoExpected Whether SSO is expected (if true credentials won't be entered) */ export async function signIn( - page: puppeteer.Page, - screenshot: Screenshot, - username: string, + page: puppeteer.Page, + screenshot: Screenshot, + username: string, accountPwd: string, ssoExpected: boolean = false ) { diff --git a/samples/msal-browser-samples/ExpressSample/views/layouts/main.hbs b/samples/msal-browser-samples/ExpressSample/views/layouts/main.hbs index fcd5d9340d..051ddd1120 100644 --- a/samples/msal-browser-samples/ExpressSample/views/layouts/main.hbs +++ b/samples/msal-browser-samples/ExpressSample/views/layouts/main.hbs @@ -12,7 +12,7 @@
- + - +
- +
- +
+ + + + + + - + - + - + diff --git a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/aadAuthConfig.json b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/aadAuthConfig.json index 5c7795cd33..0d36b81406 100644 --- a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/aadAuthConfig.json +++ b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/aadAuthConfig.json @@ -2,7 +2,8 @@ "msalConfig": { "auth": { "clientId": "b5c2e510-4a17-4feb-b219-e55aa5b74144", - "authority": "https://login.microsoftonline.com/common" + "authority": "https://login.microsoftonline.com/common", + "redirectUri": "/redirect" }, "cache": { "cacheLocation": "sessionStorage" @@ -16,4 +17,4 @@ "User.Read" ] } -} \ No newline at end of file +} diff --git a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/aadMultiTenantAuthConfig.json b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/aadMultiTenantAuthConfig.json index c63d83c02c..75f3753e0c 100644 --- a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/aadMultiTenantAuthConfig.json +++ b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/aadMultiTenantAuthConfig.json @@ -2,7 +2,8 @@ "msalConfig": { "auth": { "clientId": "b5c2e510-4a17-4feb-b219-e55aa5b74144", - "authority": "https://login.microsoftonline.com/f645ad92-e38d-4d1a-b510-d1b09a74a8ca" + "authority": "https://login.microsoftonline.com/f645ad92-e38d-4d1a-b510-d1b09a74a8ca", + "redirectUri": "/redirect" }, "cache": { "cacheLocation": "sessionStorage" @@ -26,4 +27,4 @@ "authority": "https://login.microsoftonline.com/8e44f19d-bbab-4a82-b76b-4cd0a6fbc97a" } } -} \ No newline at end of file +} diff --git a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/aadTenantedAuthConfig.json b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/aadTenantedAuthConfig.json index bd2a17cd71..20ef8b8da3 100644 --- a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/aadTenantedAuthConfig.json +++ b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/aadTenantedAuthConfig.json @@ -2,7 +2,8 @@ "msalConfig": { "auth": { "clientId": "b5c2e510-4a17-4feb-b219-e55aa5b74144", - "authority": "https://login.microsoftonline.com/f645ad92-e38d-4d1a-b510-d1b09a74a8ca" + "authority": "https://login.microsoftonline.com/f645ad92-e38d-4d1a-b510-d1b09a74a8ca", + "redirectUri": "/redirect" }, "cache": { "cacheLocation": "sessionStorage" @@ -16,4 +17,4 @@ "User.Read" ] } -} \ No newline at end of file +} diff --git a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/b2cAuthConfig.json b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/b2cAuthConfig.json index 5eb872a9bc..9b9afbcc73 100644 --- a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/b2cAuthConfig.json +++ b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/b2cAuthConfig.json @@ -5,7 +5,8 @@ "authority": "https://msidlabb2c.b2clogin.com/msidlabb2c.onmicrosoft.com/B2C_1_SISOPolicy/", "knownAuthorities": [ "msidlabb2c.b2clogin.com" - ] + ], + "redirectUri": "/redirect" }, "cache": { "cacheLocation": "sessionStorage" @@ -19,4 +20,4 @@ "https://msidlabb2c.onmicrosoft.com/4c837770-7a2b-471e-aafa-3328d04a23b1/read" ] } -} \ No newline at end of file +} diff --git a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/localStorageAuthConfig.json b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/localStorageAuthConfig.json index 07338ec644..3cbfc44467 100644 --- a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/localStorageAuthConfig.json +++ b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/localStorageAuthConfig.json @@ -2,7 +2,8 @@ "msalConfig": { "auth": { "clientId": "b5c2e510-4a17-4feb-b219-e55aa5b74144", - "authority": "https://login.microsoftonline.com/common" + "authority": "https://login.microsoftonline.com/common", + "redirectUri": "/redirect" }, "cache": { "cacheLocation": "localStorage" @@ -16,4 +17,4 @@ "User.Read" ] } -} \ No newline at end of file +} diff --git a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/memStorageAuthConfig.json b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/memStorageAuthConfig.json index eb120a7590..56a0743b0e 100644 --- a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/memStorageAuthConfig.json +++ b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/authConfigs/memStorageAuthConfig.json @@ -2,7 +2,8 @@ "msalConfig": { "auth": { "clientId": "b5c2e510-4a17-4feb-b219-e55aa5b74144", - "authority": "https://login.microsoftonline.com/common" + "authority": "https://login.microsoftonline.com/common", + "redirectUri": "/redirect" }, "cache": { "cacheLocation": "memoryStorage" @@ -16,4 +17,4 @@ "User.Read" ] } -} \ No newline at end of file +} diff --git a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/redirect.html b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/redirect.html new file mode 100644 index 0000000000..b26f473552 --- /dev/null +++ b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/redirect.html @@ -0,0 +1,19 @@ + + + + + + Redirect Page + + +

Processing authentication...

+ + + + + + + diff --git a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/test/localStorage.spec.ts b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/test/localStorage.spec.ts index 8061c1ba68..2a713d70d0 100644 --- a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/test/localStorage.spec.ts +++ b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/test/localStorage.spec.ts @@ -157,34 +157,6 @@ describe("LocalStorage Tests", function () { }); }); - it("Closing popup before login resolves clears cache", async () => { - const testName = "popupCloseWindow"; - const screenshot = new Screenshot( - `${SCREENSHOT_BASE_FOLDER_NAME}/${testName}` - ); - const [popupPage, popupWindowClosed] = await clickLoginPopup( - screenshot, - page - ); - await popupPage.waitForNavigation({ waitUntil: "networkidle0" }).catch(() => {}); - await popupPage.close(); - // Wait until popup window closes - await popupWindowClosed; - // Wait for processing - await storagePoller(async () => { - // Temporary Cache always uses sessionStorage - const sessionBrowserStorage = new BrowserCacheUtils( - page, - "sessionStorage" - ); - const sessionStorage = - await sessionBrowserStorage.getWindowStorage(); - const localStorage = await BrowserCache.getWindowStorage(); - expect(Object.keys(localStorage).length).toEqual(2); // Telemetry - expect(Object.keys(sessionStorage).length).toEqual(0); - }, ONE_SECOND_IN_MS); - }); - it.skip("Logging in on one tab updates cache/UI in another tab", async () => { const testName = "multi-tab"; const screenshot = new Screenshot( @@ -233,7 +205,7 @@ describe("LocalStorage Tests", function () { await tab2.waitForFunction(checkSignInState, {}, true); await tab2.waitForSelector("#acquireTokenSilent"); await screenshot.takeScreenshot(tab2, "tab2SignedIn"); - + await tab2.click("#acquireTokenSilent"); await tab2.waitForSelector("#fromCache"); await screenshot.takeScreenshot(tab2, "tab2AcquiredToken"); @@ -252,7 +224,7 @@ describe("LocalStorage Tests", function () { } else if (fromCacheEl.includes("false")) { return false; } - + throw `fromCache element cannot be found or has unexpected value. Value: ${fromCacheEl}`; }; diff --git a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/testConfig.json b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/testConfig.json index eb120a7590..56a0743b0e 100644 --- a/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/testConfig.json +++ b/samples/msal-browser-samples/VanillaJSTestApp2.0/app/customizable-e2e-test/testConfig.json @@ -2,7 +2,8 @@ "msalConfig": { "auth": { "clientId": "b5c2e510-4a17-4feb-b219-e55aa5b74144", - "authority": "https://login.microsoftonline.com/common" + "authority": "https://login.microsoftonline.com/common", + "redirectUri": "/redirect" }, "cache": { "cacheLocation": "memoryStorage" @@ -16,4 +17,4 @@ "User.Read" ] } -} \ No newline at end of file +} diff --git a/samples/msal-react-samples/b2c-sample/src/App.js b/samples/msal-react-samples/b2c-sample/src/App.js index 3f921713ad..5b0f5e5154 100644 --- a/samples/msal-react-samples/b2c-sample/src/App.js +++ b/samples/msal-react-samples/b2c-sample/src/App.js @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { Routes, Route, useNavigate } from "react-router-dom"; +import { Routes, Route, useNavigate, useLocation } from "react-router-dom"; // Material-UI imports import Grid from "@mui/material/Grid"; @@ -13,10 +13,20 @@ import { PageLayout } from "./ui-components/PageLayout"; import { Home } from "./pages/Home"; import { Profile } from "./pages/Profile"; import { Logout } from "./pages/Logout"; +import { Redirect } from "./pages/Redirect"; import { b2cPolicies, loginRequest } from "./authConfig"; function App({ pca }) { + const location = useLocation(); + + // Don't wrap redirect page in MsalProvider to prevent MSAL from consuming the auth response + const isRedirectPage = location.pathname === "/redirect"; + + if (isRedirectPage) { + return ; + } + return ( @@ -32,7 +42,7 @@ function App({ pca }) { /** * This component is optional. This is how you configure MSAL to take advantage of the router's navigate functions when MSAL redirects between pages in your app - */ + */ function ClientSideNavigation({ pca, children }) { const navigate = useNavigate(); const navigationClient = new CustomNavigationClient(navigate); @@ -59,8 +69,8 @@ function Pages() { const callbackId = instance.addEventCallback((event) => { if (event.eventType === EventType.ACQUIRE_TOKEN_SUCCESS && event.payload) { /** - * For the purpose of setting an active account for UI update, we want to consider only the auth - * response resulting from SUSI flow. "tfp" claim in the id token tells us the policy (NOTE: legacy + * For the purpose of setting an active account for UI update, we want to consider only the auth + * response resulting from SUSI flow. "tfp" claim in the id token tells us the policy (NOTE: legacy * policies may use "acr" instead of "tfp"). To learn more about B2C tokens, visit: * https://docs.microsoft.com/en-us/azure/active-directory-b2c/tokens-overview */ @@ -74,14 +84,14 @@ function Pages() { && account.idTokenClaims['tfp'] === b2cPolicies.names.signUpSignIn ); - + let signUpSignInFlowRequest = { scopes: [...loginRequest.scopes], authority: b2cPolicies.authorities.signUpSignIn.authority, account: originalSignInAccount, prompt: PromptValue.NONE }; - + // To get the updated account information instance.acquireTokenPopup(signUpSignInFlowRequest).then(() => { setStatus("update success") @@ -95,7 +105,7 @@ function Pages() { instance.removeEventCallback(callbackId); } } - // eslint-disable-next-line + // eslint-disable-next-line }, []); return ( diff --git a/samples/msal-react-samples/b2c-sample/src/authConfig.js b/samples/msal-react-samples/b2c-sample/src/authConfig.js index c42fca939c..96e1d94e41 100644 --- a/samples/msal-react-samples/b2c-sample/src/authConfig.js +++ b/samples/msal-react-samples/b2c-sample/src/authConfig.js @@ -38,7 +38,7 @@ export const msalConfig = { clientId: "e3b9ad76-9763-4827-b088-80c7a7888f79", authority: b2cPolicies.authorities.signUpSignIn.authority, knownAuthorities: [b2cPolicies.authorityDomain], - redirectUri: "/", + redirectUri: "/redirect", postLogoutRedirectUri: "/", onRedirectNavigate: () => !BrowserUtils.isInIframe() }, diff --git a/samples/msal-react-samples/b2c-sample/src/pages/Redirect.jsx b/samples/msal-react-samples/b2c-sample/src/pages/Redirect.jsx new file mode 100644 index 0000000000..dd4529aaf3 --- /dev/null +++ b/samples/msal-react-samples/b2c-sample/src/pages/Redirect.jsx @@ -0,0 +1,20 @@ +// This page serves the redirect bridge for MSAL authentication +// It's excluded from MsalProvider wrapper in App.js to prevent MSAL from processing the hash +import { useEffect } from "react"; +import { broadcastResponseToMainFrame } from "@azure/msal-browser/redirect-bridge"; + +export function Redirect() { + useEffect(() => { + // Call broadcastResponseToMainFrame when component mounts + broadcastResponseToMainFrame().catch((error) => { + console.error("Error broadcasting response to main frame:", error); + }); + }, []); + + return ( +
+

Processing authentication...

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

Processing authentication...

+
+ ); +} + + + diff --git a/samples/msal-react-samples/nextjs-sample/src/authConfig.js b/samples/msal-react-samples/nextjs-sample/src/authConfig.js index 41fa666d10..bf3e6881f8 100644 --- a/samples/msal-react-samples/nextjs-sample/src/authConfig.js +++ b/samples/msal-react-samples/nextjs-sample/src/authConfig.js @@ -3,7 +3,7 @@ export const msalConfig = { auth: { clientId: "b5c2e510-4a17-4feb-b219-e55aa5b74144", authority: "https://login.microsoftonline.com/common", - redirectUri: "/", + redirectUri: "/redirect", postLogoutRedirectUri: "/" }, system: { diff --git a/samples/msal-react-samples/nextjs-sample/test/home.spec.ts b/samples/msal-react-samples/nextjs-sample/test/home.spec.ts index ce991a323e..651df6d650 100644 --- a/samples/msal-react-samples/nextjs-sample/test/home.spec.ts +++ b/samples/msal-react-samples/nextjs-sample/test/home.spec.ts @@ -123,12 +123,8 @@ describe("/ (Home Page)", () => { if (!popupPage) { throw new Error('Popup window was not opened'); } - const popupWindowClosed = new Promise((resolve) => - popupPage.once("close", resolve) - ); await enterCredentials(popupPage, screenshot, username, accountPwd); - await popupWindowClosed; await page.waitForSelector("xpath/.//header[contains(., 'Welcome,')]"); await screenshot.takeScreenshot(page, "Popup closed"); diff --git a/samples/msal-react-samples/nextjs-sample/test/profile.spec.ts b/samples/msal-react-samples/nextjs-sample/test/profile.spec.ts index 218eaafe50..5860c7ed61 100644 --- a/samples/msal-react-samples/nextjs-sample/test/profile.spec.ts +++ b/samples/msal-react-samples/nextjs-sample/test/profile.spec.ts @@ -132,12 +132,8 @@ describe("/profile", () => { if (!popupPage) { throw new Error('Popup window was not opened'); } - const popupWindowClosed = new Promise((resolve) => - popupPage.once("close", resolve) - ); await enterCredentials(popupPage, screenshot, username, accountPwd); - await popupWindowClosed; await page.waitForSelector("xpath/.//header[contains(., 'Welcome,')]"); await screenshot.takeScreenshot(page, "Popup closed"); @@ -155,7 +151,7 @@ describe("/profile", () => { // Go to protected page await page.goto(`http://localhost:${port}/profile`); - + // Wait for Graph data to display await page.waitForSelector("xpath/.//div/ul/li[contains(., 'Name')]"); await screenshot.takeScreenshot(page, "Graph data acquired"); diff --git a/samples/msal-react-samples/react-router-sample/.env.development b/samples/msal-react-samples/react-router-sample/.env.development index 3a02ab9257..97ffc4e941 100644 --- a/samples/msal-react-samples/react-router-sample/.env.development +++ b/samples/msal-react-samples/react-router-sample/.env.development @@ -3,8 +3,4 @@ BROWSER=none REACT_APP_CLIENT_ID=ENTER_CLIENT_ID_HERE REACT_APP_AUTHORITY=https://login.microsoftonline.com/ENTER_TENANT_ID_HERE - -# When using popup and silent APIs, we recommend setting the redirectUri to a blank page -# or a page that does not implement MSAL. For more information, -# https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/login-user.md#redirecturi-considerations -REACT_APP_POPUP_REDIRECT_URI=/redirect \ No newline at end of file +REACT_APP_REDIRECT_URI=/redirect diff --git a/samples/msal-react-samples/react-router-sample/.env.e2e b/samples/msal-react-samples/react-router-sample/.env.e2e index 382b77aaa1..09fa875088 100644 --- a/samples/msal-react-samples/react-router-sample/.env.e2e +++ b/samples/msal-react-samples/react-router-sample/.env.e2e @@ -3,4 +3,4 @@ BROWSER=none REACT_APP_CLIENT_ID=b5c2e510-4a17-4feb-b219-e55aa5b74144 REACT_APP_AUTHORITY=https://login.microsoftonline.com/common -REACT_APP_POPUP_REDIRECT_URI=/ \ No newline at end of file +REACT_APP_REDIRECT_URI=/redirect diff --git a/samples/msal-react-samples/react-router-sample/public/redirect.html b/samples/msal-react-samples/react-router-sample/public/redirect.html deleted file mode 100644 index dcc8b0ed7d..0000000000 --- a/samples/msal-react-samples/react-router-sample/public/redirect.html +++ /dev/null @@ -1,6 +0,0 @@ - \ No newline at end of file diff --git a/samples/msal-react-samples/react-router-sample/src/App.js b/samples/msal-react-samples/react-router-sample/src/App.js index 363c8ab3c2..6756321e28 100644 --- a/samples/msal-react-samples/react-router-sample/src/App.js +++ b/samples/msal-react-samples/react-router-sample/src/App.js @@ -1,4 +1,4 @@ -import { Routes, Route, useNavigate } from "react-router-dom"; +import { Routes, Route, useNavigate, useLocation } from "react-router-dom"; // Material-UI imports import Grid from "@mui/material/Grid"; @@ -11,6 +11,7 @@ import { PageLayout } from "./ui-components/PageLayout"; import { Home } from "./pages/Home"; import { Profile } from "./pages/Profile"; import { Logout } from "./pages/Logout"; +import { Redirect } from "./pages/Redirect"; // Class-based equivalents of "Profile" component import { ProfileWithMsal } from "./pages/ProfileWithMsal"; @@ -20,9 +21,17 @@ import { ProfileUseMsalAuthenticationHook } from "./pages/ProfileUseMsalAuthenti function App({ pca }) { // The next 3 lines are optional. This is how you configure MSAL to take advantage of the router's navigate functions when MSAL redirects between pages in your app const navigate = useNavigate(); + const location = useLocation(); const navigationClient = new CustomNavigationClient(navigate); pca.setNavigationClient(navigationClient); + // Don't wrap redirect page in MsalProvider to prevent MSAL from consuming the auth response + const isRedirectPage = location.pathname === '/redirect'; + + if (isRedirectPage) { + return ; + } + return ( diff --git a/samples/msal-react-samples/react-router-sample/src/authConfig.js b/samples/msal-react-samples/react-router-sample/src/authConfig.js index fbd983d578..0e4e565f1c 100644 --- a/samples/msal-react-samples/react-router-sample/src/authConfig.js +++ b/samples/msal-react-samples/react-router-sample/src/authConfig.js @@ -16,7 +16,7 @@ export const msalConfig = { auth: { clientId: process.env.REACT_APP_CLIENT_ID, authority: process.env.REACT_APP_AUTHORITY, - redirectUri: "/", + redirectUri: process.env.REACT_APP_REDIRECT_URI, postLogoutRedirectUri: "/", onRedirectNavigate: () => !BrowserUtils.isInIframe() }, diff --git a/samples/msal-react-samples/react-router-sample/src/pages/ProfileRawContext.jsx b/samples/msal-react-samples/react-router-sample/src/pages/ProfileRawContext.jsx index 146d5ed917..3347d27e19 100644 --- a/samples/msal-react-samples/react-router-sample/src/pages/ProfileRawContext.jsx +++ b/samples/msal-react-samples/react-router-sample/src/pages/ProfileRawContext.jsx @@ -51,7 +51,7 @@ class ProfileContent extends Component { componentDidUpdate() { this.setGraphData(); } - + render() { return ( @@ -62,23 +62,22 @@ class ProfileContent extends Component { } /** - * This class is using "withMsal" HOC. It passes down the msalContext + * This class is using "withMsal" HOC. It passes down the msalContext * as a prop to its children. */ class Profile extends Component { render() { - + const authRequest = { - ...loginRequest, - redirectUri: process.env.REACT_APP_POPUP_REDIRECT_URI // e.g. /redirect + ...loginRequest }; return ( - @@ -87,4 +86,4 @@ class Profile extends Component { } } -export const ProfileRawContext = Profile \ No newline at end of file +export const ProfileRawContext = Profile diff --git a/samples/msal-react-samples/react-router-sample/src/pages/ProfileUseMsalAuthenticationHook.jsx b/samples/msal-react-samples/react-router-sample/src/pages/ProfileUseMsalAuthenticationHook.jsx index e332c9557e..43bdf3fe11 100644 --- a/samples/msal-react-samples/react-router-sample/src/pages/ProfileUseMsalAuthenticationHook.jsx +++ b/samples/msal-react-samples/react-router-sample/src/pages/ProfileUseMsalAuthenticationHook.jsx @@ -17,7 +17,6 @@ const ProfileContent = () => { const [graphData, setGraphData] = useState(null); const { result, error } = useMsalAuthentication(InteractionType.Popup, { ...loginRequest, - redirectUri: process.env.REACT_APP_POPUP_REDIRECT_URI, // e.g. /redirect }); useEffect(() => { @@ -35,7 +34,7 @@ const ProfileContent = () => { callMsGraph().then(response => setGraphData(response)); } }, [error, result, graphData]); - + if (error) { return ; } @@ -49,4 +48,4 @@ const ProfileContent = () => { export function ProfileUseMsalAuthenticationHook() { return -}; \ No newline at end of file +}; diff --git a/samples/msal-react-samples/react-router-sample/src/pages/Redirect.jsx b/samples/msal-react-samples/react-router-sample/src/pages/Redirect.jsx new file mode 100644 index 0000000000..dd4529aaf3 --- /dev/null +++ b/samples/msal-react-samples/react-router-sample/src/pages/Redirect.jsx @@ -0,0 +1,20 @@ +// This page serves the redirect bridge for MSAL authentication +// It's excluded from MsalProvider wrapper in App.js to prevent MSAL from processing the hash +import { useEffect } from "react"; +import { broadcastResponseToMainFrame } from "@azure/msal-browser/redirect-bridge"; + +export function Redirect() { + useEffect(() => { + // Call broadcastResponseToMainFrame when component mounts + broadcastResponseToMainFrame().catch((error) => { + console.error("Error broadcasting response to main frame:", error); + }); + }, []); + + return ( +
+

Processing authentication...

+
+ ); +} + diff --git a/samples/msal-react-samples/react-router-sample/src/ui-components/SignInButton.jsx b/samples/msal-react-samples/react-router-sample/src/ui-components/SignInButton.jsx index dc792b840a..521b50ca7d 100644 --- a/samples/msal-react-samples/react-router-sample/src/ui-components/SignInButton.jsx +++ b/samples/msal-react-samples/react-router-sample/src/ui-components/SignInButton.jsx @@ -3,32 +3,70 @@ import { useMsal } from "@azure/msal-react"; import Button from "@mui/material/Button"; import MenuItem from '@mui/material/MenuItem'; import Menu from '@mui/material/Menu'; +import Alert from '@mui/material/Alert'; +import AlertTitle from '@mui/material/AlertTitle'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogContentText from '@mui/material/DialogContentText'; +import DialogTitle from '@mui/material/DialogTitle'; import { loginRequest } from "../authConfig"; export const SignInButton = () => { const { instance } = useMsal(); const [anchorEl, setAnchorEl] = useState(null); + const [showRetryDialog, setShowRetryDialog] = useState(false); + const [retryRequested, setRetryRequested] = useState(false); + const [showPopupWarning, setShowPopupWarning] = useState(false); const open = Boolean(anchorEl); - const handleLogin = (loginType) => { + const handleLogin = async (loginType) => { setAnchorEl(null); if (loginType === "popup") { - /** - * When using popup and silent APIs, we recommend setting the redirectUri to a blank page or a page - * that does not implement MSAL. Keep in mind that all redirect routes must be registered with the application - * For more information, visit: https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/login-user.md#redirecturi-considerations - */ - instance.loginPopup({ - ...loginRequest, - redirectUri: process.env.REACT_APP_POPUP_REDIRECT_URI, // e.g. /redirect - }); + // Show warning when popup is about to open + setShowPopupWarning(true); + + try { + await instance.loginPopup({ + ...loginRequest, + // Only override if user explicitly clicked retry + overrideInteractionInProgress: retryRequested + }); + + // Hide warning on success + setShowPopupWarning(false); + setRetryRequested(false); + } catch (error) { + // Hide warning on error + setShowPopupWarning(false); + + if (error.errorCode === 'interaction_in_progress') { + // Show retry dialog - let user decide whether to retry + setShowRetryDialog(true); + } else { + // Reset retry flag for other errors + setRetryRequested(false); + console.error(error); + } + } } else if (loginType === "redirect") { instance.loginRedirect(loginRequest); } } + const handleRetry = () => { + setShowRetryDialog(false); + setRetryRequested(true); // User explicitly requested retry + handleLogin("popup"); + } + + const handleCancelRetry = () => { + setShowRetryDialog(false); + setRetryRequested(false); + } + return (
+ + +
) -}; \ No newline at end of file +}; diff --git a/samples/msal-react-samples/react-router-sample/test/home.spec.ts b/samples/msal-react-samples/react-router-sample/test/home.spec.ts index a8f0540fc5..eb2c005709 100644 --- a/samples/msal-react-samples/react-router-sample/test/home.spec.ts +++ b/samples/msal-react-samples/react-router-sample/test/home.spec.ts @@ -121,12 +121,8 @@ describe("/ (Home Page)", () => { if (!popupPage) { throw new Error('Popup window was not opened'); } - const popupWindowClosed = new Promise((resolve) => - popupPage.once("close", resolve) - ); await enterCredentials(popupPage, screenshot, username, accountPwd); - await popupWindowClosed; await page.waitForSelector("xpath/.//header[contains(., 'Welcome,')]", { timeout: 3000, }); diff --git a/samples/msal-react-samples/react-router-sample/test/profile.spec.ts b/samples/msal-react-samples/react-router-sample/test/profile.spec.ts index e38be2d133..4c9de8b3e5 100644 --- a/samples/msal-react-samples/react-router-sample/test/profile.spec.ts +++ b/samples/msal-react-samples/react-router-sample/test/profile.spec.ts @@ -89,12 +89,8 @@ describe("/profile", () => { if (!popupPage) { throw new Error('Popup window was not opened'); } - const popupWindowClosed = new Promise((resolve) => - popupPage.once("close", resolve) - ); await enterCredentials(popupPage, screenshot, username, accountPwd); - await popupWindowClosed; // Wait for Graph data to display await page.waitForSelector("xpath/.//div/ul/li[contains(., 'Name')]", { @@ -142,12 +138,8 @@ describe("/profile", () => { if (!popupPage) { throw new Error('Popup window was not opened'); } - const popupWindowClosed = new Promise((resolve) => - popupPage.once("close", resolve) - ); await enterCredentials(popupPage, screenshot, username, accountPwd); - await popupWindowClosed; await page.waitForSelector("xpath/.//header[contains(., 'Welcome,')]", { timeout: 3000, }); @@ -175,42 +167,4 @@ describe("/profile", () => { // Verify tokens are in cache await verifyTokenStore(BrowserCache, ["User.Read"]); }); - - it("MsalAuthenticationTemplate - renders loading component when popup is open, then error component when loginPopup is cancelled", async () => { - const testName = "MsalAuthenticationTemplateError"; - const screenshot = new Screenshot( - `${SCREENSHOT_BASE_FOLDER_NAME}/${testName}` - ); - await screenshot.takeScreenshot(page, "Home page loaded"); - - // Navigate to /profile and expect popup to be opened without interaction - const newPopupWindowPromise = new Promise((resolve) => - page.once("popup", resolve) - ); - await page.goto(`http://localhost:${port}/profile`); - await screenshot.takeScreenshot(page, "Profile page loaded"); - const popupPage = await newPopupWindowPromise; - if (!popupPage) { - throw new Error('Popup window was not opened'); - } - const popupWindowClosed = new Promise((resolve) => - popupPage.once("close", resolve) - ); - - // Wait until the popup has navigated to login page - await popupPage.waitForNavigation({ waitUntil: "networkidle0" }); - - await page.waitForSelector( - "xpath/.//h6[contains(., 'Authentication in progress...')]" - ); - await screenshot.takeScreenshot(page, "Loading component rendered"); - - await popupPage.close(); - await popupWindowClosed; - - await page.waitForSelector( - "xpath/.//h6[contains(., 'An Error Occurred: user_cancelled')]" - ); - await screenshot.takeScreenshot(page, "Error component rendered"); - }); }); diff --git a/samples/msal-react-samples/react-router-sample/test/profileRawContext.spec.ts b/samples/msal-react-samples/react-router-sample/test/profileRawContext.spec.ts index daca110064..a43746998a 100644 --- a/samples/msal-react-samples/react-router-sample/test/profileRawContext.spec.ts +++ b/samples/msal-react-samples/react-router-sample/test/profileRawContext.spec.ts @@ -89,12 +89,8 @@ describe("/profileRawContext", () => { if (!popupPage) { throw new Error('Popup window was not opened'); } - const popupWindowClosed = new Promise((resolve) => - popupPage.once("close", resolve) - ); await enterCredentials(popupPage, screenshot, username, accountPwd); - await popupWindowClosed; // Wait for Graph data to display await page.waitForSelector("xpath/.//div/ul/li[contains(., 'Name')]", { diff --git a/samples/msal-react-samples/typescript-sample/src/App.tsx b/samples/msal-react-samples/typescript-sample/src/App.tsx index 1dbc6859bf..70f091c63f 100644 --- a/samples/msal-react-samples/typescript-sample/src/App.tsx +++ b/samples/msal-react-samples/typescript-sample/src/App.tsx @@ -1,4 +1,4 @@ -import { Routes, Route, useNavigate } from "react-router-dom"; +import { Routes, Route, useNavigate, useLocation } from "react-router-dom"; // Material-UI imports import Grid from "@mui/material/Grid"; @@ -11,6 +11,7 @@ import { CustomNavigationClient } from "./utils/NavigationClient"; import { PageLayout } from "./ui-components/PageLayout"; import { Home } from "./pages/Home"; import { Profile } from "./pages/Profile"; +import { Redirect } from "./pages/Redirect"; type AppProps = { pca: IPublicClientApplication; @@ -19,9 +20,17 @@ type AppProps = { function App({ pca }: AppProps) { // The next 3 lines are optional. This is how you configure MSAL to take advantage of the router's navigate functions when MSAL redirects between pages in your app const navigate = useNavigate(); + const location = useLocation(); const navigationClient = new CustomNavigationClient(navigate); pca.setNavigationClient(navigationClient); + // Don't wrap redirect page in MsalProvider to prevent MSAL from consuming the auth response + const isRedirectPage = location.pathname === "/redirect"; + + if (isRedirectPage) { + return ; + } + return ( @@ -42,4 +51,4 @@ function Pages() { ); } -export default App; \ No newline at end of file +export default App; diff --git a/samples/msal-react-samples/typescript-sample/src/authConfig.ts b/samples/msal-react-samples/typescript-sample/src/authConfig.ts index 605c14cba2..0962909604 100644 --- a/samples/msal-react-samples/typescript-sample/src/authConfig.ts +++ b/samples/msal-react-samples/typescript-sample/src/authConfig.ts @@ -5,7 +5,7 @@ export const msalConfig: Configuration = { auth: { clientId: "b5c2e510-4a17-4feb-b219-e55aa5b74144", authority: "https://login.microsoftonline.com/common", - redirectUri: "/", + redirectUri: "/redirect", postLogoutRedirectUri: "/", }, system: { diff --git a/samples/msal-react-samples/typescript-sample/src/pages/Redirect.tsx b/samples/msal-react-samples/typescript-sample/src/pages/Redirect.tsx new file mode 100644 index 0000000000..1cfff4d35c --- /dev/null +++ b/samples/msal-react-samples/typescript-sample/src/pages/Redirect.tsx @@ -0,0 +1,20 @@ +// This page serves the redirect bridge for MSAL authentication +// It's excluded from MsalProvider wrapper in App.tsx to prevent MSAL from processing the hash +import { useEffect } from "react"; +import { broadcastResponseToMainFrame } from "@azure/msal-browser/redirect-bridge"; + +export function Redirect() { + useEffect(() => { + // Call broadcastResponseToMainFrame when component mounts + broadcastResponseToMainFrame().catch((error) => { + console.error("Error broadcasting response to main frame:", error); + }); + }, []); + + return ( +
+

Processing authentication...

+
+ ); +} +