UNPKG

@azure/msal-browser

Version:
493 lines (442 loc) 15.9 kB
/* * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. */ import { UrlString, invoke, invokeAsync, UrlUtils, RequestParameterBuilder, ICrypto, IPerformanceClient, InProgressPerformanceEvent, Logger, CommonAuthorizationUrlRequest, CommonEndSessionRequest, ProtocolUtils, } from "@azure/msal-common/browser"; import * as BrowserPerformanceEvents from "../telemetry/BrowserPerformanceEvents.js"; import { createBrowserAuthError, BrowserAuthErrorCodes, } from "../error/BrowserAuthError.js"; import { BrowserCacheLocation, InteractionType } from "./BrowserConstants.js"; import * as BrowserCrypto from "../crypto/BrowserCrypto.js"; import { BrowserConfigurationAuthErrorCodes, createBrowserConfigurationAuthError, } from "../error/BrowserConfigurationAuthError.js"; import { BrowserConfiguration, BrowserExperimentalOptions, } 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<string, string>; }; } { // 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 createBrowserAuthError(BrowserAuthErrorCodes.emptyResponse); } const state = params.get("state"); if (!state) { throw createBrowserAuthError(BrowserAuthErrorCodes.noStateInHash); } const { libraryState } = ProtocolUtils.parseRequestState( base64Decode, state ); const { id, meta } = libraryState; if (!id || !meta) { throw createBrowserAuthError( BrowserAuthErrorCodes.unableToParseState, "missing_library_state" ); } return { params, payload, urlHash, urlQuery, hasResponseInHash, hasResponseInQuery, libraryState: { id, meta, }, }; } /** * Clears hash from window url. */ export function clearHash(contentWindow: Window): void { // Office.js sets history.replaceState to null contentWindow.location.hash = ""; if (typeof contentWindow.history.replaceState === "function") { // Full removes "#" from url contentWindow.history.replaceState( null, "", `${contentWindow.location.origin}${contentWindow.location.pathname}${contentWindow.location.search}` ); } } /** * Replaces current hash with hash from provided url */ export function replaceHash(url: string): void { const urlParts = url.split("#"); urlParts.shift(); // Remove part before the hash window.location.hash = urlParts.length > 0 ? urlParts.join("#") : ""; } /** * Returns boolean of whether the current window is in an iframe or not. */ export function isInIframe(): boolean { return window.parent !== window; } /** * Returns boolean of whether or not the current window is a popup opened by msal */ export function isInPopup(): boolean { 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<string> - 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, performanceClient: IPerformanceClient, experimentalConfig?: BrowserExperimentalOptions ): Promise<string> { return new Promise<string>((resolve, reject) => { logger.verbose( "BrowserUtils.waitForBridgeResponse - started", request.correlationId ); const correlationId = request.correlationId; performanceClient.addFields( { redirectBridgeTimeoutMs: timeoutMs, lateResponseExperimentEnabled: experimentalConfig?.iframeTimeoutTelemetry || false, }, correlationId ); const { libraryState } = ProtocolUtils.parseRequestState( browserCrypto.base64Decode, request.state || "" ); const channel = new BroadcastChannel(libraryState.id); let responseString: string | undefined = undefined; let timedOut = false; let lateTimeoutId: number | undefined; let lateMeasurement: InProgressPerformanceEvent | undefined; const timeoutId = window.setTimeout(() => { // Clear the active monitor activeBridgeMonitor = null; if (experimentalConfig?.iframeTimeoutTelemetry) { lateMeasurement = performanceClient.startMeasurement( BrowserPerformanceEvents.WaitForBridgeLateResponse, correlationId ); timedOut = true; lateTimeoutId = window.setTimeout(() => { lateMeasurement?.end({ success: false }); clearTimeout(lateTimeoutId); channel.close(); }, 60000); // 60s late response timeout to allow for telemetry of late responses } else { 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; const messageVersion = event?.data && typeof event.data.v === "number" ? event.data.v : undefined; if (timedOut) { lateMeasurement?.end({ success: responseString ? true : false, }); clearTimeout(lateTimeoutId); channel.close(); return; } performanceClient.addFields( { redirectBridgeMessageVersion: messageVersion, }, correlationId ); // Clear the active monitor activeBridgeMonitor = null; clearTimeout(timeoutId); channel.close(); if (responseString) { resolve(responseString); } else { reject(createBrowserAuthError(redirectBridgeEmptyResponse)); } }; }); } // #endregion /** * Returns current window URL as redirect uri */ export function getCurrentUri(): string { return typeof window !== "undefined" && window.location ? window.location.href.split("?")[0].split("#")[0] : ""; } /** * Gets the homepage url for the current window location. */ export function getHomepage(): string { const currentUrl = new UrlString(window.location.href); const urlComponents = currentUrl.getUrlComponents(); return `${urlComponents.Protocol}//${urlComponents.HostNameAndPort}/`; } /** * Throws error if we have completed an auth and are * attempting another auth request inside an iframe. */ export function blockReloadInHiddenIframes(): void { const isResponseHash = UrlUtils.getDeserializedResponse( window.location.hash ); // return an error if called from the hidden iframe created by the msal js silent calls if (isResponseHash && isInIframe()) { throw createBrowserAuthError(BrowserAuthErrorCodes.blockIframeReload); } } /** * Block redirect operations in iframes unless explicitly allowed * @param interactionType Interaction type for the request * @param allowRedirectInIframe Config value to allow redirects when app is inside an iframe */ export function blockRedirectInIframe(allowRedirectInIframe: boolean): void { if (isInIframe() && !allowRedirectInIframe) { // If we are not in top frame, we shouldn't redirect. This is also handled by the service. throw createBrowserAuthError(BrowserAuthErrorCodes.redirectInIframe); } } /** * Block redirectUri loaded in popup from calling AcquireToken APIs */ export function blockAcquireTokenInPopups(): void { // Popups opened by msal popup APIs are given a name that starts with "msal." if (isInPopup()) { throw createBrowserAuthError(BrowserAuthErrorCodes.blockNestedPopups); } } /** * Throws error if token requests are made in non-browser environment * @param isBrowserEnvironment Flag indicating if environment is a browser. */ export function blockNonBrowserEnvironment(): void { if (typeof window === "undefined") { throw createBrowserAuthError( BrowserAuthErrorCodes.nonBrowserEnvironment ); } } /** * Throws error if initialize hasn't been called * @param initialized */ export function blockAPICallsBeforeInitialize(initialized: boolean): void { if (!initialized) { throw createBrowserAuthError( BrowserAuthErrorCodes.uninitializedPublicClientApplication ); } } /** * Helper to validate app environment before making an auth request * @param initialized */ export function preflightCheck(initialized: boolean): void { // Block request if not in browser environment blockNonBrowserEnvironment(); // Block auth requests inside a hidden iframe blockReloadInHiddenIframes(); // Block redirectUri opened in a popup from calling MSAL APIs blockAcquireTokenInPopups(); // Block token acquisition before initialize has been called blockAPICallsBeforeInitialize(initialized); } /** * Helper to validate app enviornment before making redirect request * @param initialized * @param config */ export function redirectPreflightCheck( initialized: boolean, config: BrowserConfiguration ): void { preflightCheck(initialized); blockRedirectInIframe(config.system.allowRedirectInIframe); // Block redirects if memory storage is enabled if (config.cache.cacheLocation === BrowserCacheLocation.MemoryStorage) { throw createBrowserConfigurationAuthError( BrowserConfigurationAuthErrorCodes.inMemRedirectUnavailable ); } } /** * Adds a preconnect link element to the header which begins DNS resolution and SSL connection in anticipation of the /token request * @param loginDomain Authority domain, including https protocol e.g. https://login.microsoftonline.com * @returns */ export function preconnect(authority: string): void { const link = document.createElement("link"); link.rel = "preconnect"; link.href = new URL(authority).origin; link.crossOrigin = "anonymous"; document.head.appendChild(link); // The browser will close connection if not used within a few seconds, remove element from the header after 10s window.setTimeout(() => { try { document.head.removeChild(link); } catch {} }, 10000); // 10s Timeout } /** * Wrapper function that creates a UUID v7 from the current timestamp. * @returns {string} */ export function createGuid(): string { return BrowserCrypto.createNewGuid(); } export { invoke }; export { invokeAsync }; export const addClientCapabilitiesToClaims = RequestParameterBuilder.addClientCapabilitiesToClaims;