UNPKG

@azure/msal-browser

Version:
1,037 lines (967 loc) 36.4 kB
/* * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. */ import { AuthorizationCodeClient, CommonEndSessionRequest, UrlString, AuthError, IPerformanceClient, Logger, ICrypto, ProtocolMode, PerformanceEvents, Constants, invokeAsync, invoke, PkceCodes, CommonAuthorizationUrlRequest, } from "@azure/msal-common/browser"; import { initializeAuthorizationRequest, StandardInteractionClient, } from "./StandardInteractionClient.js"; import * as BrowserPerformanceEvents from "../telemetry/BrowserPerformanceEvents.js"; import { EventType } from "../event/EventType.js"; import { InteractionType, ApiId, BrowserConstants, } from "../utils/BrowserConstants.js"; import { EndSessionPopupRequest } from "../request/EndSessionPopupRequest.js"; import { NavigationOptions } from "../navigation/NavigationOptions.js"; import * as BrowserUtils from "../utils/BrowserUtils.js"; import { PopupRequest } from "../request/PopupRequest.js"; import { createBrowserAuthError, BrowserAuthErrorCodes, } from "../error/BrowserAuthError.js"; import { INavigationClient } from "../navigation/INavigationClient.js"; import { EventHandler } from "../event/EventHandler.js"; import { BrowserCacheManager } from "../cache/BrowserCacheManager.js"; import { BrowserConfiguration } from "../config/Configuration.js"; import { PopupWindowAttributes } from "../request/PopupWindowAttributes.js"; import { EventError } from "../event/EventMessage.js"; import { AuthenticationResult } from "../response/AuthenticationResult.js"; import * as ResponseHandler from "../response/ResponseHandler.js"; import * as Authorize from "../protocol/Authorize.js"; import { generatePkceCodes } from "../crypto/PkceGenerator.js"; import { isPlatformAuthAllowed } from "../broker/nativeBroker/PlatformAuthProvider.js"; import { generateEarKey } from "../crypto/BrowserCrypto.js"; import { IPlatformAuthHandler } from "../broker/nativeBroker/IPlatformAuthHandler.js"; import { clearCacheOnLogout, getDiscoveredAuthority, initializeServerTelemetryManager, } from "./BaseInteractionClient.js"; import { validateRequestMethod } from "../request/RequestHelpers.js"; export type PopupParams = { popup?: Window | null; popupName: string; popupWindowAttributes: PopupWindowAttributes; popupWindowParent: Window; }; export class PopupClient extends StandardInteractionClient { private currentWindow: Window | undefined; protected nativeStorage: BrowserCacheManager; constructor( config: BrowserConfiguration, storageImpl: BrowserCacheManager, browserCrypto: ICrypto, logger: Logger, eventHandler: EventHandler, navigationClient: INavigationClient, performanceClient: IPerformanceClient, nativeStorageImpl: BrowserCacheManager, correlationId: string, platformAuthHandler?: IPlatformAuthHandler ) { super( config, storageImpl, browserCrypto, logger, eventHandler, navigationClient, performanceClient, correlationId, platformAuthHandler ); this.nativeStorage = nativeStorageImpl; this.eventHandler = eventHandler; } /** * Acquires tokens by opening a popup window to the /authorize endpoint of the authority * @param request * @param pkceCodes */ acquireToken( request: PopupRequest, pkceCodes?: PkceCodes ): Promise<AuthenticationResult> { let popupParams: PopupParams | undefined = undefined; try { const popupName = this.generatePopupName( request.scopes || Constants.OIDC_DEFAULT_SCOPES, request.authority || this.config.auth.authority ); popupParams = { popupName, popupWindowAttributes: request.popupWindowAttributes || {}, popupWindowParent: request.popupWindowParent ?? window, }; this.performanceClient.addFields( { isAsyncPopup: !this.config.system.navigatePopups }, this.correlationId ); // navigatePopups flag is false. Acquires token without first opening popup. Popup will be opened later asynchronously. if (!this.config.system.navigatePopups) { this.logger.verbose( "navigatePopups set to false, acquiring token", this.correlationId ); // Passes on popup position and dimensions if in request return this.acquireTokenPopupAsync( request, popupParams, pkceCodes ); } else { // navigatePopups flag is set to true. Opens popup before acquiring token. // Pre-validate request method to avoid opening popup if the request is invalid const validatedRequest: PopupRequest = { ...request, httpMethod: validateRequestMethod( request, this.config.system.protocolMode ), }; this.logger.verbose( "navigatePopups set to true, opening popup before acquiring token", this.correlationId ); popupParams.popup = this.openSizedPopup( "about:blank", popupParams ); return this.acquireTokenPopupAsync( validatedRequest, popupParams, pkceCodes ); } } catch (e) { return Promise.reject(e); } } /** * Clears local cache for the current user then opens a popup window prompting the user to sign-out of the server * @param logoutRequest */ logout(logoutRequest?: EndSessionPopupRequest): Promise<void> { try { this.logger.verbose("logoutPopup called", this.correlationId); const validLogoutRequest = this.initializeLogoutRequest(logoutRequest); const popupParams: PopupParams = { popupName: this.generateLogoutPopupName(validLogoutRequest), popupWindowAttributes: logoutRequest?.popupWindowAttributes || {}, popupWindowParent: logoutRequest?.popupWindowParent ?? window, }; const authority = logoutRequest && logoutRequest.authority; const mainWindowRedirectUri = logoutRequest && logoutRequest.mainWindowRedirectUri; // navigatePopups flag set to false. Acquires token without first opening popup. Popup will be opened later asynchronously. if (!this.config.system.navigatePopups) { this.logger.verbose( "navigatePopups set to false", this.correlationId ); // Passes on popup position and dimensions if in request return this.logoutPopupAsync( validLogoutRequest, popupParams, authority, mainWindowRedirectUri ); } else { // navigatePopups flag is set to true. Opens popup before logging out. this.logger.verbose( "navigatePopups set to true, opening popup", this.correlationId ); popupParams.popup = this.openSizedPopup( "about:blank", popupParams ); return this.logoutPopupAsync( validLogoutRequest, popupParams, authority, mainWindowRedirectUri ); } } catch (e) { // Since this function is synchronous we need to reject return Promise.reject(e); } } /** * Helper which obtains an access_token for your API via opening a popup window in the user's browser * @param request * @param popupParams * @param pkceCodes * * @returns A promise that is fulfilled when this function has completed, or rejected if an error was raised. */ protected async acquireTokenPopupAsync( request: PopupRequest, popupParams: PopupParams, pkceCodes?: PkceCodes ): Promise<AuthenticationResult> { this.logger.verbose( "acquireTokenPopupAsync called", this.correlationId ); const validRequest = await invokeAsync( initializeAuthorizationRequest, BrowserPerformanceEvents.StandardInteractionClientInitializeAuthorizationRequest, this.logger, this.performanceClient, this.correlationId )( request, InteractionType.Popup, this.config, this.browserCrypto, this.browserStorage, this.logger, this.performanceClient, this.correlationId ); /* * Skip pre-connect for async popups to reduce time between user interaction and popup window creation to avoid * popup from being blocked by browsers with shorter popup timers */ if (popupParams.popup) { BrowserUtils.preconnect(validRequest.authority); } const isPlatformBroker = isPlatformAuthAllowed( this.config, this.logger, this.correlationId, this.platformAuthProvider, request.authenticationScheme ); validRequest.platformBroker = isPlatformBroker; if (this.config.system.protocolMode === ProtocolMode.EAR) { return this.executeEarFlow(validRequest, popupParams, pkceCodes); } else { return this.executeCodeFlow(validRequest, popupParams, pkceCodes); } } /** * Executes auth code + PKCE flow * @param request * @param popupParams * @param pkceCodes * @returns */ async executeCodeFlow( request: CommonAuthorizationUrlRequest, popupParams: PopupParams, pkceCodes?: PkceCodes ): Promise<AuthenticationResult> { const correlationId = request.correlationId; const serverTelemetryManager = initializeServerTelemetryManager( ApiId.acquireTokenPopup, this.config.auth.clientId, this.correlationId, this.browserStorage, this.logger ); const pkce = pkceCodes || (await invokeAsync( generatePkceCodes, BrowserPerformanceEvents.GeneratePkceCodes, this.logger, this.performanceClient, correlationId )(this.performanceClient, this.logger, correlationId)); const popupRequest = { ...request, codeChallenge: pkce.challenge, }; try { // Initialize the client const authClient: AuthorizationCodeClient = await invokeAsync( this.createAuthCodeClient.bind(this), BrowserPerformanceEvents.StandardInteractionClientCreateAuthCodeClient, this.logger, this.performanceClient, correlationId )({ serverTelemetryManager, requestAuthority: popupRequest.authority, requestAzureCloudOptions: popupRequest.azureCloudOptions, requestExtraQueryParameters: popupRequest.extraQueryParameters, account: popupRequest.account, }); if (popupRequest.httpMethod === Constants.HttpMethod.POST) { return await this.executeCodeFlowWithPost( popupRequest, popupParams, authClient, pkce.verifier ); } else { // Create acquire token url. const navigateUrl = await invokeAsync( Authorize.getAuthCodeRequestUrl, PerformanceEvents.GetAuthCodeUrl, this.logger, this.performanceClient, correlationId )( this.config, authClient.authority, popupRequest, this.logger, this.performanceClient ); // Show the UI once the url has been created. Get the window handle for the popup. const popupWindow: Window = this.initiateAuthRequest( navigateUrl, popupParams ); this.eventHandler.emitEvent( EventType.POPUP_OPENED, correlationId, InteractionType.Popup, { popupWindow }, null ); // Wait for the redirect bridge response const responseString = await BrowserUtils.waitForBridgeResponse( this.config.system.popupBridgeTimeout, this.logger, this.browserCrypto, request, this.performanceClient ); const serverParams = invoke( ResponseHandler.deserializeResponse, BrowserPerformanceEvents.DeserializeResponse, this.logger, this.performanceClient, this.correlationId )( responseString, this.config.auth.OIDCOptions.responseMode, this.logger, this.correlationId ); return await invokeAsync( Authorize.handleResponseCode, BrowserPerformanceEvents.HandleResponseCode, this.logger, this.performanceClient, correlationId )( request, serverParams, pkce.verifier, ApiId.acquireTokenPopup, this.config, authClient, this.browserStorage, this.nativeStorage, this.eventHandler, this.logger, this.performanceClient, this.platformAuthProvider ); } } catch (e) { // Close the synchronous popup if an error is thrown before the window unload event is registered popupParams.popup?.close(); if (e instanceof AuthError) { (e as AuthError).setCorrelationId(this.correlationId); serverTelemetryManager.cacheFailedRequest(e); } throw e; } } /** * Executes EAR flow * @param request */ async executeEarFlow( request: CommonAuthorizationUrlRequest, popupParams: PopupParams, pkceCodes?: PkceCodes ): Promise<AuthenticationResult> { const { correlationId, authority, azureCloudOptions, extraQueryParameters, account, } = request; // Get the frame handle for the silent request const discoveredAuthority = await invokeAsync( getDiscoveredAuthority, BrowserPerformanceEvents.StandardInteractionClientGetDiscoveredAuthority, this.logger, this.performanceClient, correlationId )( this.config, this.correlationId, this.performanceClient, this.browserStorage, this.logger, authority, azureCloudOptions, extraQueryParameters, account ); const earJwk = await invokeAsync( generateEarKey, BrowserPerformanceEvents.GenerateEarKey, this.logger, this.performanceClient, correlationId )(); const pkce = pkceCodes || (await invokeAsync( generatePkceCodes, BrowserPerformanceEvents.GeneratePkceCodes, this.logger, this.performanceClient, correlationId )(this.performanceClient, this.logger, correlationId)); const popupRequest = { ...request, earJwk: earJwk, codeChallenge: pkce.challenge, }; const popupWindow = popupParams.popup || this.openPopup("about:blank", popupParams); const form = await Authorize.getEARForm( popupWindow.document, this.config, discoveredAuthority, popupRequest, this.logger, this.performanceClient ); form.submit(); // 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( BrowserUtils.waitForBridgeResponse, BrowserPerformanceEvents.SilentHandlerMonitorIframeForHash, this.logger, this.performanceClient, correlationId )( this.config.system.popupBridgeTimeout, this.logger, this.browserCrypto, popupRequest, this.performanceClient ); const serverParams = invoke( ResponseHandler.deserializeResponse, BrowserPerformanceEvents.DeserializeResponse, this.logger, this.performanceClient, this.correlationId )( responseString, this.config.auth.OIDCOptions.responseMode, this.logger, this.correlationId ); if (!serverParams.ear_jwe && serverParams.code) { const authClient = await invokeAsync( this.createAuthCodeClient.bind(this), BrowserPerformanceEvents.StandardInteractionClientCreateAuthCodeClient, this.logger, this.performanceClient, correlationId )({ serverTelemetryManager: initializeServerTelemetryManager( ApiId.acquireTokenPopup, this.config.auth.clientId, correlationId, this.browserStorage, this.logger ), requestAuthority: request.authority, requestAzureCloudOptions: request.azureCloudOptions, requestExtraQueryParameters: request.extraQueryParameters, account: request.account, authority: discoveredAuthority, }); return invokeAsync( Authorize.handleResponseCode, BrowserPerformanceEvents.HandleResponseCode, this.logger, this.performanceClient, correlationId )( popupRequest, serverParams, pkce.verifier, ApiId.acquireTokenPopup, this.config, authClient, this.browserStorage, this.nativeStorage, this.eventHandler, this.logger, this.performanceClient, this.platformAuthProvider ); } else { return invokeAsync( Authorize.handleResponseEAR, BrowserPerformanceEvents.HandleResponseEar, this.logger, this.performanceClient, correlationId )( popupRequest, serverParams, ApiId.acquireTokenPopup, this.config, discoveredAuthority, this.browserStorage, this.nativeStorage, this.eventHandler, this.logger, this.performanceClient, this.platformAuthProvider ); } } async executeCodeFlowWithPost( request: CommonAuthorizationUrlRequest, popupParams: PopupParams, authClient: AuthorizationCodeClient, pkceVerifier: string ): Promise<AuthenticationResult> { const correlationId = request.correlationId; // Get the frame handle for the silent request const discoveredAuthority = await invokeAsync( getDiscoveredAuthority, BrowserPerformanceEvents.StandardInteractionClientGetDiscoveredAuthority, this.logger, this.performanceClient, correlationId )( this.config, this.correlationId, this.performanceClient, this.browserStorage, this.logger ); const popupWindow = popupParams.popup || this.openPopup("about:blank", popupParams); const form = await Authorize.getCodeForm( popupWindow.document, this.config, discoveredAuthority, request, this.logger, this.performanceClient ); form.submit(); // 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( BrowserUtils.waitForBridgeResponse, BrowserPerformanceEvents.SilentHandlerMonitorIframeForHash, this.logger, this.performanceClient, correlationId )( this.config.system.popupBridgeTimeout, this.logger, this.browserCrypto, request, this.performanceClient ); const serverParams = invoke( ResponseHandler.deserializeResponse, BrowserPerformanceEvents.DeserializeResponse, this.logger, this.performanceClient, this.correlationId )( responseString, this.config.auth.OIDCOptions.responseMode, this.logger, this.correlationId ); return invokeAsync( Authorize.handleResponseCode, BrowserPerformanceEvents.HandleResponseCode, this.logger, this.performanceClient, correlationId )( request, serverParams, pkceVerifier, ApiId.acquireTokenPopup, this.config, authClient, this.browserStorage, this.nativeStorage, this.eventHandler, this.logger, this.performanceClient, this.platformAuthProvider ); } /** * * @param validRequest * @param popupName * @param requestAuthority * @param popup * @param mainWindowRedirectUri * @param popupWindowAttributes */ protected async logoutPopupAsync( validRequest: CommonEndSessionRequest, popupParams: PopupParams, requestAuthority?: string, mainWindowRedirectUri?: string ): Promise<void> { this.logger.verbose("logoutPopupAsync called", this.correlationId); this.eventHandler.emitEvent( EventType.LOGOUT_START, this.correlationId, InteractionType.Popup, validRequest ); const serverTelemetryManager = initializeServerTelemetryManager( ApiId.logoutPopup, this.config.auth.clientId, this.correlationId, this.browserStorage, this.logger ); try { // Clear cache on logout await clearCacheOnLogout( this.browserStorage, this.browserCrypto, this.logger, this.correlationId, validRequest.account ); // Initialize the client const authClient = await invokeAsync( this.createAuthCodeClient.bind(this), BrowserPerformanceEvents.StandardInteractionClientCreateAuthCodeClient, this.logger, this.performanceClient, this.correlationId )({ serverTelemetryManager, requestAuthority: requestAuthority, account: validRequest.account || undefined, }); try { authClient.authority.endSessionEndpoint; } catch { if ( validRequest.account?.homeAccountId && validRequest.postLogoutRedirectUri && authClient.authority.protocolMode === ProtocolMode.OIDC ) { this.eventHandler.emitEvent( EventType.LOGOUT_SUCCESS, validRequest.correlationId, InteractionType.Popup, validRequest ); if (mainWindowRedirectUri) { const navigationOptions: NavigationOptions = { apiId: ApiId.logoutPopup, timeout: this.config.system.redirectNavigationTimeout, noHistory: false, }; const absoluteUrl = UrlString.getAbsoluteUrl( mainWindowRedirectUri, BrowserUtils.getCurrentUri() ); await this.navigationClient.navigateInternal( absoluteUrl, navigationOptions ); } popupParams.popup?.close(); return; } } // Create logout string and navigate user window to logout. const logoutUri: string = authClient.getLogoutUri(validRequest); this.eventHandler.emitEvent( EventType.LOGOUT_SUCCESS, validRequest.correlationId, InteractionType.Popup, validRequest ); // Open the popup window to requestUrl. const popupWindow = this.openPopup(logoutUri, popupParams); this.eventHandler.emitEvent( EventType.POPUP_OPENED, validRequest.correlationId, InteractionType.Popup, { popupWindow }, null ); await BrowserUtils.waitForBridgeResponse( this.config.system.popupBridgeTimeout, this.logger, this.browserCrypto, validRequest, this.performanceClient ).catch(() => { // Swallow any errors related to monitoring the window. Server logout is best effort }); if (mainWindowRedirectUri) { const navigationOptions: NavigationOptions = { apiId: ApiId.logoutPopup, timeout: this.config.system.redirectNavigationTimeout, noHistory: false, }; const absoluteUrl = UrlString.getAbsoluteUrl( mainWindowRedirectUri, BrowserUtils.getCurrentUri() ); this.logger.verbose( "Redirecting main window to url specified in the request", this.correlationId ); this.logger.verbosePii( `Redirecting main window to: '${absoluteUrl}'`, this.correlationId ); await this.navigationClient.navigateInternal( absoluteUrl, navigationOptions ); } else { this.logger.verbose( "No main window navigation requested", this.correlationId ); } } catch (e) { // Close the synchronous popup if an error is thrown before the window unload event is registered popupParams.popup?.close(); if (e instanceof AuthError) { (e as AuthError).setCorrelationId(this.correlationId); serverTelemetryManager.cacheFailedRequest(e); } this.eventHandler.emitEvent( EventType.LOGOUT_FAILURE, this.correlationId, InteractionType.Popup, null, e as EventError ); this.eventHandler.emitEvent( EventType.LOGOUT_END, this.correlationId, InteractionType.Popup ); throw e; } this.eventHandler.emitEvent( EventType.LOGOUT_END, this.correlationId, InteractionType.Popup ); } /** * Opens a popup window with given request Url. * @param requestUrl */ initiateAuthRequest(requestUrl: string, params: PopupParams): Window { // Check that request url is not empty. if (requestUrl) { this.logger.infoPii( `Navigate to: '${requestUrl}'`, this.correlationId ); // Open the popup window to requestUrl. return this.openPopup(requestUrl, params); } else { // Throw error if request URL is empty. this.logger.error("Navigate url is empty", this.correlationId); throw createBrowserAuthError( BrowserAuthErrorCodes.emptyNavigateUri ); } } /** * @hidden * * Configures popup window for login. * * @param urlNavigate * @param title * @param popUpWidth * @param popUpHeight * @param popupWindowAttributes * @ignore * @hidden */ openPopup(urlNavigate: string, popupParams: PopupParams): Window { try { let popupWindow; // Popup window passed in, setting url to navigate to if (popupParams.popup) { popupWindow = popupParams.popup; this.logger.verbosePii( `Navigating popup window to: '${urlNavigate}'`, this.correlationId ); popupWindow.location.assign(urlNavigate); } else if (typeof popupParams.popup === "undefined") { // Popup will be undefined if it was not passed in this.logger.verbosePii( `Opening popup window to: '${urlNavigate}'`, this.correlationId ); popupWindow = this.openSizedPopup(urlNavigate, popupParams); } // Popup will be null if popups are blocked if (!popupWindow) { throw createBrowserAuthError( BrowserAuthErrorCodes.emptyWindowError ); } if (popupWindow.focus) { popupWindow.focus(); } this.currentWindow = popupWindow; return popupWindow; } catch (e) { this.logger.error( `error opening popup '${(e as AuthError).message}'`, this.correlationId ); throw createBrowserAuthError( BrowserAuthErrorCodes.popupWindowError ); } } /** * Helper function to set popup window dimensions and position * @param urlNavigate * @param popupName * @param popupWindowAttributes * @returns */ openSizedPopup( urlNavigate: string, { popupName, popupWindowAttributes, popupWindowParent }: PopupParams ): Window | null { /** * adding winLeft and winTop to account for dual monitor * using screenLeft and screenTop for IE8 and earlier */ const winLeft = popupWindowParent.screenLeft ? popupWindowParent.screenLeft : popupWindowParent.screenX; const winTop = popupWindowParent.screenTop ? popupWindowParent.screenTop : popupWindowParent.screenY; /** * window.innerWidth displays browser window"s height and width excluding toolbars * using document.documentElement.clientWidth for IE8 and earlier */ const winWidth = popupWindowParent.innerWidth || document.documentElement.clientWidth || document.body.clientWidth; const winHeight = popupWindowParent.innerHeight || document.documentElement.clientHeight || document.body.clientHeight; let width = popupWindowAttributes.popupSize?.width; let height = popupWindowAttributes.popupSize?.height; let top = popupWindowAttributes.popupPosition?.top; let left = popupWindowAttributes.popupPosition?.left; if (!width || width < 0 || width > winWidth) { this.logger.verbose( "Default popup window width used. Window width not configured or invalid.", this.correlationId ); width = BrowserConstants.POPUP_WIDTH; } if (!height || height < 0 || height > winHeight) { this.logger.verbose( "Default popup window height used. Window height not configured or invalid.", this.correlationId ); height = BrowserConstants.POPUP_HEIGHT; } if (!top || top < 0 || top > winHeight) { this.logger.verbose( "Default popup window top position used. Window top not configured or invalid.", this.correlationId ); top = Math.max( 0, winHeight / 2 - BrowserConstants.POPUP_HEIGHT / 2 + winTop ); } if (!left || left < 0 || left > winWidth) { this.logger.verbose( "Default popup window left position used. Window left not configured or invalid.", this.correlationId ); left = Math.max( 0, winWidth / 2 - BrowserConstants.POPUP_WIDTH / 2 + winLeft ); } return popupWindowParent.open( urlNavigate, popupName, `width=${width}, height=${height}, top=${top}, left=${left}, scrollbars=yes` ); } /** * Generates the name for the popup based on the client id and request * @param clientId * @param request */ generatePopupName(scopes: Array<string>, authority: string): string { return `${BrowserConstants.POPUP_NAME_PREFIX}.${ this.config.auth.clientId }.${scopes.join("-")}.${authority}.${this.correlationId}`; } /** * Generates the name for the popup based on the client id and request for logouts * @param clientId * @param request */ generateLogoutPopupName(request: CommonEndSessionRequest): string { const homeAccountId = request.account && request.account.homeAccountId; return `${BrowserConstants.POPUP_NAME_PREFIX}.${this.config.auth.clientId}.${homeAccountId}.${this.correlationId}`; } }