From debf1c95bd9ac7bfa222d06fd596fd84a7aaa4ca Mon Sep 17 00:00:00 2001 From: Hector Morales Date: Tue, 25 Feb 2025 15:20:15 -0800 Subject: [PATCH 1/3] Update popup flow --- lib/msal-browser/src/interaction_client/PopupClient.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/msal-browser/src/interaction_client/PopupClient.ts b/lib/msal-browser/src/interaction_client/PopupClient.ts index c94c4019e2..0054512722 100644 --- a/lib/msal-browser/src/interaction_client/PopupClient.ts +++ b/lib/msal-browser/src/interaction_client/PopupClient.ts @@ -112,7 +112,8 @@ export class PopupClient extends StandardInteractionClient { { isAsyncPopup: this.config.system.asyncPopups }, this.correlationId ); - + console.log("Chrome Identity: ", window.chrome.identity); + debugger; // asyncPopups flag is true. Acquires token without first opening popup. Popup will be opened later asynchronously. if (this.config.system.asyncPopups) { this.logger.verbose("asyncPopups set to true, acquiring token"); From e05629b729655e4fb74e543b6a1e6a8773b00e8c Mon Sep 17 00:00:00 2001 From: Hector Morales Date: Sun, 2 Mar 2025 20:24:55 -0800 Subject: [PATCH 2/3] Add minimum working loginExtension --- lib/msal-browser/package.json | 1 + .../src/app/IPublicClientApplication.ts | 8 + .../src/app/PublicClientApplication.ts | 11 + lib/msal-browser/src/app/PublicClientNext.ts | 4 + .../src/controllers/IController.ts | 2 + .../controllers/NestedAppAuthController.ts | 4 + .../src/controllers/StandardController.ts | 172 ++++++++++ .../UnknownOperatingContextController.ts | 6 + .../BrowserExtensionClient.ts | 316 ++++++++++++++++++ .../src/interaction_client/PopupClient.ts | 5 +- .../src/response/ResponseHandler.ts | 2 + lib/msal-browser/tsconfig.json | 3 +- .../telemetry/performance/PerformanceEvent.ts | 3 + package-lock.json | 37 +- .../app/customizable-e2e-test/testConfig.json | 2 +- 15 files changed, 568 insertions(+), 8 deletions(-) create mode 100644 lib/msal-browser/src/interaction_client/BrowserExtensionClient.ts diff --git a/lib/msal-browser/package.json b/lib/msal-browser/package.json index 888a242d16..24a8e1b54d 100644 --- a/lib/msal-browser/package.json +++ b/lib/msal-browser/package.json @@ -83,6 +83,7 @@ "@rollup/plugin-node-resolve": "^15.0.1", "@rollup/plugin-terser": "^0.4.0", "@rollup/plugin-typescript": "^11.0.0", + "@types/chrome": "^0.0.306", "@types/jest": "^29.5.0", "@types/node": "^20.3.1", "dotenv": "^8.2.0", diff --git a/lib/msal-browser/src/app/IPublicClientApplication.ts b/lib/msal-browser/src/app/IPublicClientApplication.ts index 39a046c022..7522a51fdf 100644 --- a/lib/msal-browser/src/app/IPublicClientApplication.ts +++ b/lib/msal-browser/src/app/IPublicClientApplication.ts @@ -58,6 +58,7 @@ export interface IPublicClientApplication { handleRedirectPromise(hash?: string): Promise; loginPopup(request?: PopupRequest): Promise; loginRedirect(request?: RedirectRequest): Promise; + loginExtension(request?: RedirectRequest): Promise; logout(logoutRequest?: EndSessionRequest): Promise; logoutRedirect(logoutRequest?: EndSessionRequest): Promise; logoutPopup(logoutRequest?: EndSessionPopupRequest): Promise; @@ -154,6 +155,13 @@ export const stubbedPublicClientApplication: IPublicClientApplication = { ) ); }, + loginExtension: () => { + return Promise.reject( + createBrowserConfigurationAuthError( + BrowserConfigurationAuthErrorCodes.stubbedPublicClientApplicationCalled + ) + ); + }, logout: () => { return Promise.reject( createBrowserConfigurationAuthError( diff --git a/lib/msal-browser/src/app/PublicClientApplication.ts b/lib/msal-browser/src/app/PublicClientApplication.ts index e9b9e8e36f..1178c75c85 100644 --- a/lib/msal-browser/src/app/PublicClientApplication.ts +++ b/lib/msal-browser/src/app/PublicClientApplication.ts @@ -284,6 +284,17 @@ export class PublicClientApplication implements IPublicClientApplication { return this.controller.loginPopup(request); } + /** + * Use when initiating the login process from a browser extension + * @param request + * + */ + loginExtension( + request?: PopupRequest | undefined + ): Promise { + return this.controller.loginExtension(request); + } + /** * Use when initiating the login process by redirecting the user's browser to the authorization endpoint. This function redirects the page, so * any code that follows this function will not execute. diff --git a/lib/msal-browser/src/app/PublicClientNext.ts b/lib/msal-browser/src/app/PublicClientNext.ts index fd11e940cd..4d9afd06eb 100644 --- a/lib/msal-browser/src/app/PublicClientNext.ts +++ b/lib/msal-browser/src/app/PublicClientNext.ts @@ -320,6 +320,10 @@ export class PublicClientNext implements IPublicClientApplication { return this.controller.loginRedirect(request); } + loginExtension(request?: RedirectRequest): Promise { + return this.controller.loginExtension(request); + } + /** * Deprecated logout function. Use logoutRedirect or logoutPopup instead * @param logoutRequest diff --git a/lib/msal-browser/src/controllers/IController.ts b/lib/msal-browser/src/controllers/IController.ts index 856f4abc38..0ee9456bcb 100644 --- a/lib/msal-browser/src/controllers/IController.ts +++ b/lib/msal-browser/src/controllers/IController.ts @@ -80,6 +80,8 @@ export interface IController { loginRedirect(request?: RedirectRequest): Promise; + loginExtension(request?: RedirectRequest): Promise; + logout(logoutRequest?: EndSessionRequest): Promise; logoutRedirect(logoutRequest?: EndSessionRequest): Promise; diff --git a/lib/msal-browser/src/controllers/NestedAppAuthController.ts b/lib/msal-browser/src/controllers/NestedAppAuthController.ts index 609302c140..dd1bf80eeb 100644 --- a/lib/msal-browser/src/controllers/NestedAppAuthController.ts +++ b/lib/msal-browser/src/controllers/NestedAppAuthController.ts @@ -772,6 +772,10 @@ export class NestedAppAuthController implements IController { throw NestedAppAuthError.createUnsupportedError(); } // eslint-disable-next-line @typescript-eslint/no-unused-vars + loginExtension(request?: RedirectRequest | undefined): Promise { + throw NestedAppAuthError.createUnsupportedError(); + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars logout(logoutRequest?: EndSessionRequest | undefined): Promise { throw NestedAppAuthError.createUnsupportedError(); } diff --git a/lib/msal-browser/src/controllers/StandardController.ts b/lib/msal-browser/src/controllers/StandardController.ts index 9378993af0..39d20ff73a 100644 --- a/lib/msal-browser/src/controllers/StandardController.ts +++ b/lib/msal-browser/src/controllers/StandardController.ts @@ -87,6 +87,7 @@ import { createNewGuid } from "../crypto/BrowserCrypto.js"; import { initializeSilentRequest } from "../request/RequestHelpers.js"; import { InitializeApplicationRequest } from "../request/InitializeApplicationRequest.js"; import { generatePkceCodes } from "../crypto/PkceGenerator.js"; +import { BrowserExtensionClient } from "../interaction_client/BrowserExtensionClient.js"; function getAccountType( account?: AccountInfo @@ -734,6 +735,149 @@ export class StandardController implements IController { // #endregion + acquireTokenExtension(request: RedirectRequest): Promise { + const correlationId = this.getRequestCorrelationId(request); + const atExtensionMeasurement = this.performanceClient.startMeasurement( + PerformanceEvents.AcquireTokenExtension, + correlationId + ); + atExtensionMeasurement.add({ + scenarioId: request.scenarioId, + accountType: getAccountType(request.account), + }); + try { + this.logger.verbose("acquireTokenExtension called"); + preflightCheck(this.initialized, atExtensionMeasurement); + this.browserStorage.setInteractionInProgress(true); + } catch (e) { + // Since this function is syncronous we need to reject + return Promise.reject(e); + } + + // If logged in, emit acquire token events + const loggedInAccounts = this.getAllAccounts(); + if (loggedInAccounts.length > 0) { + this.eventHandler.emitEvent( + EventType.ACQUIRE_TOKEN_START, + InteractionType.Popup, + request + ); + } else { + this.eventHandler.emitEvent( + EventType.LOGIN_START, + InteractionType.Popup, + request + ); + } + + let result: Promise; + const pkce = this.getPreGeneratedPkceCodes(correlationId); + + if (this.canUsePlatformBroker(request)) { + result = this.acquireTokenNative( + { + ...request, + correlationId, + }, + ApiId.acquireTokenPopup + ) + .then((response) => { + this.browserStorage.setInteractionInProgress(false); + atExtensionMeasurement.end({ + success: true, + isNativeBroker: true, + accountType: getAccountType(response.account), + }); + return response; + }) + .catch((e: AuthError) => { + if ( + e instanceof NativeAuthError && + isFatalNativeAuthError(e) + ) { + this.nativeExtensionProvider = undefined; // If extension gets uninstalled during session prevent future requests from continuing to attempt + const popupClient = + this.createPopupClient(correlationId); + return popupClient.acquireToken(request, pkce); + } else if (e instanceof InteractionRequiredAuthError) { + this.logger.verbose( + "acquireTokenPopup - Resolving interaction required error thrown by native broker by falling back to web flow" + ); + const popupClient = + this.createPopupClient(correlationId); + return popupClient.acquireToken(request, pkce); + } + this.browserStorage.setInteractionInProgress(false); + throw e; + }); + } else { + const extensionClient = this.createExtensionClient(correlationId); + result = extensionClient.acquireToken(request, pkce); + } + + return result + .then((result) => { + /* + * If logged in, emit acquire token events + */ + const isLoggingIn = + loggedInAccounts.length < this.getAllAccounts().length; + if (isLoggingIn) { + this.eventHandler.emitEvent( + EventType.LOGIN_SUCCESS, + InteractionType.Popup, + result + ); + } else { + this.eventHandler.emitEvent( + EventType.ACQUIRE_TOKEN_SUCCESS, + InteractionType.Popup, + result + ); + } + + atExtensionMeasurement.end({ + success: true, + accessTokenSize: result.accessToken.length, + idTokenSize: result.idToken.length, + accountType: getAccountType(result.account), + }); + return result; + }) + .catch((e: Error) => { + if (loggedInAccounts.length > 0) { + this.eventHandler.emitEvent( + EventType.ACQUIRE_TOKEN_FAILURE, + InteractionType.Popup, + null, + e + ); + } else { + this.eventHandler.emitEvent( + EventType.LOGIN_FAILURE, + InteractionType.Popup, + null, + e + ); + } + + atExtensionMeasurement.end( + { + success: false, + }, + e + ); + + // Since this function is syncronous we need to reject + return Promise.reject(e); + }) + .finally( + () => + this.config.system.asyncPopups && + this.preGeneratePkceCodes(correlationId) + ); + } + // #region Popup Flow /** @@ -1634,6 +1778,25 @@ export class StandardController implements IController { ); } + /** + * Returns new instance of the Popup Interaction Client + * @param correlationId + */ + public createExtensionClient(correlationId?: string): BrowserExtensionClient { + return new BrowserExtensionClient( + this.config, + this.browserStorage, + this.browserCrypto, + this.logger, + this.eventHandler, + this.navigationClient, + this.performanceClient, + this.nativeInternalStorage, + this.nativeExtensionProvider, + correlationId + ); + } + /** * Returns new instance of the Redirect Interaction Client * @param correlationId @@ -1931,6 +2094,15 @@ export class StandardController implements IController { }); } + loginExtension(request?: RedirectRequest): Promise { + const correlationId: string = this.getRequestCorrelationId(request); + this.logger.verbose("loginExtension called", correlationId); + return this.acquireTokenExtension({ + correlationId, + ...(request || DEFAULT_REQUEST), + }); + } + /** * Silently acquire an access token for a given set of scopes. Returns currently processing promise if parallel requests are made. * diff --git a/lib/msal-browser/src/controllers/UnknownOperatingContextController.ts b/lib/msal-browser/src/controllers/UnknownOperatingContextController.ts index 4ea6330785..53e4c06250 100644 --- a/lib/msal-browser/src/controllers/UnknownOperatingContextController.ts +++ b/lib/msal-browser/src/controllers/UnknownOperatingContextController.ts @@ -266,6 +266,12 @@ export class UnknownOperatingContextController implements IController { return {} as Promise; } // eslint-disable-next-line @typescript-eslint/no-unused-vars + loginExtension(request?: RedirectRequest | undefined): Promise { + blockAPICallsBeforeInitialize(this.initialized); + blockNonBrowserEnvironment(); + return {} as Promise; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars logout(logoutRequest?: EndSessionRequest | undefined): Promise { blockAPICallsBeforeInitialize(this.initialized); blockNonBrowserEnvironment(); diff --git a/lib/msal-browser/src/interaction_client/BrowserExtensionClient.ts b/lib/msal-browser/src/interaction_client/BrowserExtensionClient.ts new file mode 100644 index 0000000000..549c3efc63 --- /dev/null +++ b/lib/msal-browser/src/interaction_client/BrowserExtensionClient.ts @@ -0,0 +1,316 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { + CommonAuthorizationCodeRequest, + AuthorizationCodeClient, + ThrottlingUtils, + AuthError, + ProtocolUtils, + PerformanceEvents, + IPerformanceClient, + Logger, + ICrypto, + invokeAsync, + invoke, + PkceCodes, +} from "@azure/msal-common/browser"; +import { StandardInteractionClient } from "./StandardInteractionClient.js"; +import { + InteractionType, + ApiId, +} from "../utils/BrowserConstants.js"; +import { PopupRequest } from "../request/PopupRequest.js"; +import { NativeInteractionClient } from "./NativeInteractionClient.js"; +import { NativeMessageHandler } from "../broker/nativeBroker/NativeMessageHandler.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 { InteractionHandler } from "../interaction_handler/InteractionHandler.js"; +import { AuthenticationResult } from "../response/AuthenticationResult.js"; +import * as ResponseHandler from "../response/ResponseHandler.js"; + +export class BrowserExtensionClient 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, + nativeMessageHandler?: NativeMessageHandler, + correlationId?: string + ) { + super( + config, + storageImpl, + browserCrypto, + logger, + eventHandler, + navigationClient, + performanceClient, + nativeMessageHandler, + correlationId + ); + // Properly sets this reference for the unload event. + this.unloadWindow = this.unloadWindow.bind(this); + this.nativeStorage = nativeStorageImpl; + } + + /** + * Acquires tokens by opening a popup window to the /authorize endpoint of the authority + * @param request + * @param pkceCodes + */ + acquireToken( + request: PopupRequest, + pkceCodes?: PkceCodes + ): Promise { + try { + const chromeIdentity = (window.chrome || (window as any)['browser']).identity; + if (chromeIdentity) { + this.logger.verbose("chrome.identity API is available, acquiring token using Manifest V3 Webflow"); + return this.acquireTokenExtensionAsync( + request, + pkceCodes, + ) + + } + return Promise.reject("chrome.identity API is not available"); + } 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(): Promise { + return Promise.reject("API not implemented"); + } + + /** + * Helper which obtains an access_token for your API via opening a popup window in the user's browser + * @param request + * @param pkceCodes + * + * @returns A promise that is fulfilled when this function has completed, or rejected if an error was raised. + */ + protected async acquireTokenExtensionAsync( + request: PopupRequest, + pkceCodes?: PkceCodes, + ): Promise { + this.logger.verbose("acquireTokenExtensionAsync called"); + const serverTelemetryManager = this.initializeServerTelemetryManager( + ApiId.acquireTokenPopup + ); + + const validRequest = await invokeAsync( + this.initializeAuthorizationRequest.bind(this), + PerformanceEvents.StandardInteractionClientInitializeAuthorizationRequest, + this.logger, + this.performanceClient, + this.correlationId + )(request, InteractionType.Popup); + + try { + // Create auth code request and generate PKCE params + const authCodeRequest: CommonAuthorizationCodeRequest = + await invokeAsync( + this.initializeAuthorizationCodeRequest.bind(this), + PerformanceEvents.StandardInteractionClientInitializeAuthorizationCodeRequest, + this.logger, + this.performanceClient, + this.correlationId + )(validRequest, pkceCodes); + + // Initialize the client + const authClient: AuthorizationCodeClient = await invokeAsync( + this.createAuthCodeClient.bind(this), + PerformanceEvents.StandardInteractionClientCreateAuthCodeClient, + this.logger, + this.performanceClient, + this.correlationId + )({ + serverTelemetryManager, + requestAuthority: validRequest.authority, + requestAzureCloudOptions: validRequest.azureCloudOptions, + requestExtraQueryParameters: validRequest.extraQueryParameters, + account: validRequest.account, + }); + + const isPlatformBroker = + NativeMessageHandler.isPlatformBrokerAvailable( + this.config, + this.logger, + this.nativeMessageHandler, + request.authenticationScheme + ); + // Start measurement for server calls with native brokering enabled + let fetchNativeAccountIdMeasurement; + if (isPlatformBroker) { + fetchNativeAccountIdMeasurement = + this.performanceClient.startMeasurement( + PerformanceEvents.FetchAccountIdWithNativeBroker, + request.correlationId + ); + } + + // Create acquire token url. + const navigateUrl = await authClient.getAuthCodeUrl({ + ...validRequest, + platformBroker: isPlatformBroker, + }); + + const interactionHandler = new InteractionHandler( + authClient, + this.browserStorage, + authCodeRequest, + this.logger, + this.performanceClient + ); + + const responseString = await (window.chrome || (window as any)['browser']).identity.launchWebAuthFlow({ + url: navigateUrl, + interactive: true + }); + + const responseHash = responseString?.substring(responseString.indexOf("#") + 1); + + const serverParams = invoke( + ResponseHandler.deserializeResponse, + PerformanceEvents.DeserializeResponse, + this.logger, + this.performanceClient, + this.correlationId + )( + responseHash || "", + this.config.auth.OIDCOptions.serverResponseType, + this.logger + ); + // Remove throttle if it exists + ThrottlingUtils.removeThrottle( + this.browserStorage, + this.config.auth.clientId, + authCodeRequest + ); + + if (serverParams.accountId) { + this.logger.verbose( + "Account id found in hash, calling WAM for token" + ); + // end measurement for server call with native brokering enabled + if (fetchNativeAccountIdMeasurement) { + fetchNativeAccountIdMeasurement.end({ + success: true, + isNativeBroker: true, + }); + } + + if (!this.nativeMessageHandler) { + throw createBrowserAuthError( + BrowserAuthErrorCodes.nativeConnectionNotEstablished + ); + } + const nativeInteractionClient = new NativeInteractionClient( + this.config, + this.browserStorage, + this.browserCrypto, + this.logger, + this.eventHandler, + this.navigationClient, + ApiId.acquireTokenPopup, + this.performanceClient, + this.nativeMessageHandler, + serverParams.accountId, + this.nativeStorage, + validRequest.correlationId + ); + const { userRequestState } = ProtocolUtils.parseRequestState( + this.browserCrypto, + validRequest.state + ); + return await nativeInteractionClient.acquireToken({ + ...validRequest, + state: userRequestState, + prompt: undefined, // Server should handle the prompt, ideally native broker can do this part silently + }); + } + console.log(serverParams); + debugger; + // Handle response from hash string. + const result = await interactionHandler.handleCodeResponse( + serverParams, + validRequest + ); + + return result; + } catch (e) { + // Close the synchronous popup if an error is thrown before the window unload event is registered + + if (e instanceof AuthError) { + (e as AuthError).setCorrelationId(this.correlationId); + serverTelemetryManager.cacheFailedRequest(e); + } + throw e; + } + } + + /** + * + * @param validRequest + * @param popupName + * @param requestAuthority + * @param popup + * @param mainWindowRedirectUri + * @param popupWindowAttributes + */ + protected async logoutPopupAsync( + ): Promise { + return Promise.reject("API not implemented"); + } + + /** + * Opens a popup window with given request Url. + * @param requestUrl + */ + initiateAuthRequest(requestUrl: string): void { + // Check that request url is not empty. + if (requestUrl) { + this.logger.infoPii(`Navigate to: ${requestUrl}`); + } else { + // Throw error if request URL is empty. + this.logger.error("Navigate url is empty"); + throw createBrowserAuthError( + BrowserAuthErrorCodes.emptyNavigateUri + ); + } + } + + /** + * Event callback to unload main window. + */ + unloadWindow(e: Event): void { + this.browserStorage.cleanRequestByInteractionType( + InteractionType.Popup + ); + if (this.currentWindow) { + this.currentWindow.close(); + } + // Guarantees browser unload will happen, so no other errors will be thrown. + e.preventDefault(); + } +} diff --git a/lib/msal-browser/src/interaction_client/PopupClient.ts b/lib/msal-browser/src/interaction_client/PopupClient.ts index 0054512722..cdb5c380bf 100644 --- a/lib/msal-browser/src/interaction_client/PopupClient.ts +++ b/lib/msal-browser/src/interaction_client/PopupClient.ts @@ -112,8 +112,7 @@ export class PopupClient extends StandardInteractionClient { { isAsyncPopup: this.config.system.asyncPopups }, this.correlationId ); - console.log("Chrome Identity: ", window.chrome.identity); - debugger; + // asyncPopups flag is true. Acquires token without first opening popup. Popup will be opened later asynchronously. if (this.config.system.asyncPopups) { this.logger.verbose("asyncPopups set to true, acquiring token"); @@ -203,7 +202,7 @@ export class PopupClient extends StandardInteractionClient { protected async acquireTokenPopupAsync( request: PopupRequest, popupParams: PopupParams, - pkceCodes?: PkceCodes + pkceCodes?: PkceCodes, ): Promise { this.logger.verbose("acquireTokenPopupAsync called"); const serverTelemetryManager = this.initializeServerTelemetryManager( diff --git a/lib/msal-browser/src/response/ResponseHandler.ts b/lib/msal-browser/src/response/ResponseHandler.ts index 84e405f343..9bd6e53cb1 100644 --- a/lib/msal-browser/src/response/ResponseHandler.ts +++ b/lib/msal-browser/src/response/ResponseHandler.ts @@ -23,6 +23,8 @@ export function deserializeResponse( ): ServerAuthorizationCodeResponse { // Deserialize hash fragment response parameters. const serverParams = UrlUtils.getDeserializedResponse(responseString); + console.log(serverParams); + debugger; if (!serverParams) { if (!UrlUtils.stripLeadingHashOrQuery(responseString)) { // Hash or Query string is empty diff --git a/lib/msal-browser/tsconfig.json b/lib/msal-browser/tsconfig.json index b0f6adbdd8..9f4a1b5de5 100644 --- a/lib/msal-browser/tsconfig.json +++ b/lib/msal-browser/tsconfig.json @@ -17,7 +17,8 @@ "resolveJsonModule": true, "types": [ "node", - "jest" + "jest", + "chrome" ] }, "include": [ diff --git a/lib/msal-common/src/telemetry/performance/PerformanceEvent.ts b/lib/msal-common/src/telemetry/performance/PerformanceEvent.ts index a8171a02c4..591efb64b6 100644 --- a/lib/msal-common/src/telemetry/performance/PerformanceEvent.ts +++ b/lib/msal-common/src/telemetry/performance/PerformanceEvent.ts @@ -40,6 +40,8 @@ export const PerformanceEvents = { */ AcquireTokenPopup: "acquireTokenPopup", + AcquireTokenExtension: "acquireTokenExtension", + /** * acquireTokenPreRedirect (msal-browser). * First part of the redirect flow. @@ -328,6 +330,7 @@ export const PerformanceEventAbbreviations: ReadonlyMap = [PerformanceEvents.AcquireTokenSilent, "ATS"], [PerformanceEvents.AcquireTokenSilentAsync, "ATSAsync"], [PerformanceEvents.AcquireTokenPopup, "ATPopup"], + [PerformanceEvents.AcquireTokenExtension, "ATExt"], [PerformanceEvents.AcquireTokenRedirect, "ATRedirect"], [ PerformanceEvents.CryptoOptsGetPublicKeyThumbprint, diff --git a/package-lock.json b/package-lock.json index b2c50c58b9..a2110f525e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -365,6 +365,7 @@ "@rollup/plugin-node-resolve": "^15.0.1", "@rollup/plugin-terser": "^0.4.0", "@rollup/plugin-typescript": "^11.0.0", + "@types/chrome": "^0.0.306", "@types/jest": "^29.5.0", "@types/node": "^20.3.1", "dotenv": "^8.2.0", @@ -14705,6 +14706,16 @@ "@types/responselike": "^1.0.0" } }, + "node_modules/@types/chrome": { + "version": "0.0.306", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.306.tgz", + "integrity": "sha512-95kgcqvTNcaZCXmx/kIKY6uo83IaRNT3cuPxYqlB2Iu+HzKDCP4t7TUe7KhJijTdibcvn+SzziIcfSLIlgRnhQ==", + "dev": true, + "dependencies": { + "@types/filesystem": "*", + "@types/har-format": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -14794,6 +14805,21 @@ "@types/express": "*" } }, + "node_modules/@types/filesystem": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz", + "integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==", + "dev": true, + "dependencies": { + "@types/filewriter": "*" + } + }, + "node_modules/@types/filewriter": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz", + "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==", + "dev": true + }, "node_modules/@types/fs-extra": { "version": "9.0.13", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", @@ -14812,6 +14838,12 @@ "@types/node": "*" } }, + "node_modules/@types/har-format": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz", + "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==", + "dev": true + }, "node_modules/@types/history": { "version": "4.7.11", "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", @@ -44242,10 +44274,9 @@ }, "node_modules/vite": { "version": "4.5.9", - "resolved": "https://identitydivision.pkgs.visualstudio.com/fac9d424-53d2-45c0-91b5-ef6ba7a6bf26/_packaging/dd15892d-fc68-4d1c-93a5-090f3b303f31/npm/registry/vite/-/vite-4.5.9.tgz", - "integrity": "sha1-9N/UxClXQ7UMPj+Q33mNcN5pnk8=", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.9.tgz", + "integrity": "sha512-qK9W4xjgD3gXbC0NmdNFFnVFLMWSNiR3swj957yutwzzN16xF/E7nmtAyp1rT9hviDroQANjE4HK3H4WqWdFtw==", "dev": true, - "license": "MIT", "peer": true, "dependencies": { "esbuild": "^0.18.10", 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 03e2e663d2..db759e5fc7 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 @@ -1 +1 @@ -{"msalConfig":{"auth":{"clientId":"b5c2e510-4a17-4feb-b219-e55aa5b74144","authority":"https://login.microsoftonline.com/common"},"cache":{"cacheLocation":"memoryStorage","storeAuthStateInCookie":true},"system":{"allowPlatformBroker":false}},"request":{"scopes":["User.Read"]}} \ No newline at end of file +{"msalConfig":{"auth":{"clientId":"b5c2e510-4a17-4feb-b219-e55aa5b74144","authority":"https://login.microsoftonline.com/common"},"cache":{"cacheLocation":"sessionStorage","storeAuthStateInCookie":false},"system":{"allowPlatformBroker":false}},"request":{"scopes":["User.Read"]}} \ No newline at end of file From 511b922e998ce050bcceafeddc5c37efa792f8a5 Mon Sep 17 00:00:00 2001 From: Hector Morales Date: Sun, 13 Apr 2025 13:35:53 -0700 Subject: [PATCH 3/3] Latest POC --- .../src/app/PublicClientApplication.ts | 29 + lib/msal-browser/src/cache/LocalStorage.ts | 2 +- .../controllers/BrowserExtensionController.ts | 1212 +++++++++++++++++ .../src/controllers/ControllerFactory.ts | 9 +- lib/msal-browser/src/crypto/BrowserCrypto.ts | 24 +- .../src/error/BrowserAuthError.ts | 2 + .../src/error/BrowserAuthErrorCodes.ts | 1 + lib/msal-browser/src/index.ts | 1 + .../BrowserExtensionClient.ts | 7 +- .../BrowserExtensionOperatingContext.ts | 50 + .../src/response/ResponseHandler.ts | 3 +- lib/msal-browser/src/utils/BrowserUtils.ts | 24 + 12 files changed, 1352 insertions(+), 12 deletions(-) create mode 100644 lib/msal-browser/src/controllers/BrowserExtensionController.ts create mode 100644 lib/msal-browser/src/operatingcontext/BrowserExtensionOperatingContext.ts diff --git a/lib/msal-browser/src/app/PublicClientApplication.ts b/lib/msal-browser/src/app/PublicClientApplication.ts index 1178c75c85..fcfcb38dc2 100644 --- a/lib/msal-browser/src/app/PublicClientApplication.ts +++ b/lib/msal-browser/src/app/PublicClientApplication.ts @@ -35,6 +35,8 @@ import { NestedAppAuthController } from "../controllers/NestedAppAuthController. import { NestedAppOperatingContext } from "../operatingcontext/NestedAppOperatingContext.js"; import { InitializeApplicationRequest } from "../request/InitializeApplicationRequest.js"; import { EventType } from "../event/EventType.js"; +import { BrowserExtensionOperatingContext } from "../operatingcontext/BrowserExtensionOperatingContext.js"; +import { BrowserExtensionController } from "../controllers/BrowserExtensionController.js"; /** * The PublicClientApplication class is the object exposed by the library to perform authentication and authorization functions in Single Page Applications @@ -468,6 +470,33 @@ export async function createNestablePublicClientApplication( return createStandardPublicClientApplication(configuration); } +/** + * creates BrowserExtensionController and passes it to the PublicClientApplication, + * falls back to StandardController if BrowserExtensionController is not available + * + * @param configuration + * @returns IPublicClientApplication + * + */ +export async function createBrowserExtensionPublicClientApplication( + configuration: Configuration +): Promise { + const browserExtension = new BrowserExtensionOperatingContext(configuration); + await browserExtension.initialize(); + + if (browserExtension.isAvailable()) { + const controller = new BrowserExtensionController(browserExtension); + const browserExtensionPCA = new PublicClientApplication( + configuration, + controller + ); + await browserExtensionPCA.initialize(); + return browserExtensionPCA; + } + + return createStandardPublicClientApplication(configuration); +} + /** * creates PublicClientApplication using StandardController * diff --git a/lib/msal-browser/src/cache/LocalStorage.ts b/lib/msal-browser/src/cache/LocalStorage.ts index e55fcf3e18..eddc55c5f1 100644 --- a/lib/msal-browser/src/cache/LocalStorage.ts +++ b/lib/msal-browser/src/cache/LocalStorage.ts @@ -63,7 +63,7 @@ export class LocalStorage implements IWindowStorage { logger: Logger, performanceClient: IPerformanceClient ) { - if (!window.localStorage) { + if (!window?.localStorage) { throw createBrowserConfigurationAuthError( BrowserConfigurationAuthErrorCodes.storageNotSupported ); diff --git a/lib/msal-browser/src/controllers/BrowserExtensionController.ts b/lib/msal-browser/src/controllers/BrowserExtensionController.ts new file mode 100644 index 0000000000..c10477d606 --- /dev/null +++ b/lib/msal-browser/src/controllers/BrowserExtensionController.ts @@ -0,0 +1,1212 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { Logger, IPerformanceClient, ICrypto, DEFAULT_CRYPTO_IMPLEMENTATION, AccountFilter, AccountInfo, CommonAuthorizationUrlRequest, CommonSilentFlowRequest, PerformanceCallbackFunction, Constants, BaseAuthRequest, PerformanceEvents, PkceCodes, InProgressPerformanceEvent, RequestThumbprint, AuthError, InteractionRequiredAuthError, InteractionRequiredAuthErrorCodes, ClientAuthErrorCodes, createClientAuthError, PromptValue } from "@azure/msal-common/browser"; +import { BrowserCacheManager, DEFAULT_BROWSER_CACHE_MANAGER } from "../cache/BrowserCacheManager.js"; +import { ITokenCache } from "../cache/ITokenCache.js"; +import { BrowserConfiguration } from "../config/Configuration.js"; +import { CryptoOps } from "../crypto/CryptoOps.js"; +import { EventHandler } from "../event/EventHandler.js"; +import { EventCallbackFunction } from "../event/EventMessage.js"; +import { EventType } from "../event/EventType.js"; +import { INavigationClient } from "../navigation/INavigationClient.js"; +import { UnknownOperatingContext } from "../operatingcontext/UnknownOperatingContext.js"; +import { AuthorizationCodeRequest } from "../request/AuthorizationCodeRequest.js"; +import { ClearCacheRequest } from "../request/ClearCacheRequest.js"; +import { EndSessionPopupRequest } from "../request/EndSessionPopupRequest.js"; +import { EndSessionRequest } from "../request/EndSessionRequest.js"; +import { PopupRequest } from "../request/PopupRequest.js"; +import { RedirectRequest } from "../request/RedirectRequest.js"; +import { SilentRequest } from "../request/SilentRequest.js"; +import { SsoSilentRequest } from "../request/SsoSilentRequest.js"; +import { ApiId, CacheLookupPolicy, DEFAULT_REQUEST, InteractionType, WrapperSKU } from "../utils/BrowserConstants.js"; +import { blockAPICallsBeforeInitialize, blockNonBrowserEnvironment, invokeAsync } from "../utils/BrowserUtils.js"; +import { IController } from "./IController.js"; +import { BaseOperatingContext } from "../operatingcontext/BaseOperatingContext.js"; +import { InitializeApplicationRequest } from "../request/InitializeApplicationRequest.js"; +import { createNewGuid } from "../crypto/BrowserCrypto.js"; +import { NativeMessageHandler } from "../broker/nativeBroker/NativeMessageHandler.js"; +import { generatePkceCodes } from "../crypto/PkceGenerator.js"; +import { AuthenticationResult } from "../response/AuthenticationResult.js"; +import { BrowserUtils } from "../index.js"; +import { BrowserAuthErrorCodes, createBrowserAuthError } from "../error/BrowserAuthError.js"; +import { initializeSilentRequest } from "../request/RequestHelpers.js"; +import { isFatalNativeAuthError, NativeAuthError } from "../error/NativeAuthError.js"; +import { SilentCacheClient } from "../interaction_client/SilentCacheClient.js"; +import { SilentRefreshClient } from "../interaction_client/SilentRefreshClient.js"; +import * as AccountManager from "../cache/AccountManager.js"; +import { PopupClient } from "../interaction_client/PopupClient.js"; +import { BrowserExtensionClient } from "../interaction_client/BrowserExtensionClient.js"; + +// TODO: Dedupe with StandardController +function getAccountType( + account?: AccountInfo +): "AAD" | "MSA" | "B2C" | undefined { + const idTokenClaims = account?.idTokenClaims; + if (idTokenClaims?.tfp || idTokenClaims?.acr) { + return "B2C"; + } + + if (!idTokenClaims?.tid) { + return undefined; + } else if (idTokenClaims?.tid === "9188040d-6c67-4c5b-b112-36a304b66dad") { + return "MSA"; + } + return "AAD"; +} + +// TODO: Dedupe with StandardController +function preflightCheck( + initialized: boolean, + performanceEvent: InProgressPerformanceEvent +) { + try { + BrowserUtils.preflightCheckExtension(initialized); + } catch (e) { + performanceEvent.end({ success: false }, e); + throw e; + } +} + +export class BrowserExtensionController implements IController { +// OperatingContext + protected readonly operatingContext: UnknownOperatingContext; + + // Logger + protected logger: Logger; + + // Storage interface implementation + protected readonly browserStorage: BrowserCacheManager; + + // Input configuration by developer/user + protected readonly config: BrowserConfiguration; + + // Performance telemetry client + protected readonly performanceClient: IPerformanceClient; + + // Event handler + private readonly eventHandler: EventHandler; + + // Crypto interface implementation + protected readonly browserCrypto: ICrypto; + + // Navigation interface implementation + protected navigationClient: INavigationClient; + + // Flag to indicate if in browser environment + protected isBrowserEnvironment: boolean; + + // Flag representing whether or not the initialize API has been called and completed + protected initialized: boolean = false; + + // Active requests + private activeSilentTokenRequests: Map< + string, + Promise + >; + + private ssoSilentMeasurement?: InProgressPerformanceEvent; + private acquireTokenByCodeAsyncMeasurement?: InProgressPerformanceEvent; + + // Native Extension Provider + protected nativeExtensionProvider: NativeMessageHandler | undefined; + + private pkceCode: PkceCodes | undefined; + nativeInternalStorage: BrowserCacheManager; + + constructor(operatingContext: UnknownOperatingContext) { + this.operatingContext = operatingContext; + + this.isBrowserEnvironment = + this.operatingContext.isBrowserEnvironment(); + + this.config = operatingContext.getConfig(); + + this.logger = operatingContext.getLogger(); + + this.navigationClient = this.config.system.navigationClient; + + // Initialize performance client + this.performanceClient = this.config.telemetry.client; + + // Initialize the crypto class. + this.browserCrypto = this.isBrowserEnvironment + ? new CryptoOps(this.logger, this.performanceClient) + : DEFAULT_CRYPTO_IMPLEMENTATION; + + this.eventHandler = new EventHandler(this.logger); + + // Initialize the browser storage class. + this.browserStorage = this.isBrowserEnvironment + ? new BrowserCacheManager( + this.config.auth.clientId, + this.config.cache, + this.browserCrypto, + this.logger, + this.performanceClient, + this.eventHandler, + undefined + ) + : DEFAULT_BROWSER_CACHE_MANAGER( + this.config.auth.clientId, + this.logger, + this.performanceClient, + this.eventHandler + ); + + this.activeSilentTokenRequests = new Map(); + + // Register listener functions + this.trackPageVisibility = this.trackPageVisibility.bind(this); + + // Register listener functions + this.trackPageVisibilityWithMeasurement = + this.trackPageVisibilityWithMeasurement.bind(this); + } + + static async createController( + operatingContext: BaseOperatingContext, + request?: InitializeApplicationRequest + ): Promise { + const controller = new BrowserExtensionController(operatingContext); + await controller.initialize(request); + return controller; + }; + + private trackPageVisibility(correlationId?: string): void { + if (!correlationId) { + return; + } + this.logger.info("Perf: Visibility change detected"); + this.performanceClient.incrementFields( + { visibilityChangeCount: 1 }, + correlationId + ); + } + + private trackPageVisibilityWithMeasurement(): void { + const measurement = + this.ssoSilentMeasurement || + this.acquireTokenByCodeAsyncMeasurement; + if (!measurement) { + return; + } + + this.logger.info( + "Perf: Visibility change detected in ", + measurement.event.name + ); + measurement.increment({ + visibilityChangeCount: 1, + }); + } + + /** + * Generates a correlation id for a request if none is provided. + * + * @protected + * @param {?Partial} [request] + * @returns {string} + */ + protected getRequestCorrelationId( + request?: Partial + ): string { + if (request?.correlationId) { + return request.correlationId; + } + + if (this.isBrowserEnvironment) { + return createNewGuid(); + } + + /* + * Included for fallback for non-browser environments, + * and to ensure this method always returns a string. + */ + return Constants.EMPTY_STRING; + } + + getBrowserStorage(): BrowserCacheManager { + return this.browserStorage; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getAccount(accountFilter: AccountFilter): AccountInfo | null { + return null; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getAccountByHomeId(homeAccountId: string): AccountInfo | null { + return null; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getAccountByLocalId(localAccountId: string): AccountInfo | null { + return null; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getAccountByUsername(username: string): AccountInfo | null { + return null; + } + + /** + * Returns all the accounts in the cache that match the optional filter. If no filter is provided, all accounts are returned. + * @param accountFilter - (Optional) filter to narrow down the accounts returned + * @returns Array of AccountInfo objects in cache + */ + getAllAccounts(accountFilter?: AccountFilter): AccountInfo[] { + return AccountManager.getAllAccounts( + this.logger, + this.browserStorage, + true, + accountFilter + ); + } +/** + * Initializer function to perform async startup tasks such as connecting to WAM extension + * @param request {?InitializeApplicationRequest} correlation id + */ + async initialize(request?: InitializeApplicationRequest): Promise { + this.logger.trace("initialize called"); + if (this.initialized) { + this.logger.info( + "initialize has already been called, exiting early." + ); + return; + } + + const initCorrelationId = + request?.correlationId || this.getRequestCorrelationId(); + const allowPlatformBroker = this.config.system.allowPlatformBroker; + const initMeasurement = this.performanceClient.startMeasurement( + PerformanceEvents.InitializeClientApplication, + initCorrelationId + ); + this.eventHandler.emitEvent(EventType.INITIALIZE_START); + + await invokeAsync( + this.browserStorage.initialize.bind(this.browserStorage), + PerformanceEvents.InitializeCache, + this.logger, + this.performanceClient, + initCorrelationId + )(initCorrelationId); + + if (allowPlatformBroker) { + try { + this.nativeExtensionProvider = + await NativeMessageHandler.createProvider( + this.logger, + this.config.system.nativeBrokerHandshakeTimeout, + this.performanceClient + ); + } catch (e) { + this.logger.verbose(e as string); + } + } + + if (!this.config.cache.claimsBasedCachingEnabled) { + this.logger.verbose( + "Claims-based caching is disabled. Clearing the previous cache with claims" + ); + + await invokeAsync( + this.browserStorage.clearTokensAndKeysWithClaims.bind( + this.browserStorage + ), + PerformanceEvents.ClearTokensAndKeysWithClaims, + this.logger, + this.performanceClient, + initCorrelationId + )(this.performanceClient, initCorrelationId); + } + + this.config.system.asyncPopups && + (await this.preGeneratePkceCodes(initCorrelationId)); + this.initialized = true; + this.eventHandler.emitEvent(EventType.INITIALIZE_END); + initMeasurement.end({ + allowPlatformBroker: allowPlatformBroker, + success: true, + }); + } + + /** + * Pre-generates PKCE codes and stores it in local variable + * @param correlationId + */ + private async preGeneratePkceCodes(correlationId: string): Promise { + this.logger.verbose("Generating new PKCE codes"); + this.pkceCode = await invokeAsync( + generatePkceCodes, + PerformanceEvents.GeneratePkceCodes, + this.logger, + this.performanceClient, + correlationId + )(this.performanceClient, this.logger, correlationId); + return Promise.resolve(); + } + + /** + * Provides pre-generated PKCE codes, if any + * @param correlationId + */ + private getPreGeneratedPkceCodes( + correlationId: string + ): PkceCodes | undefined { + this.logger.verbose("Attempting to pick up pre-generated PKCE codes"); + const res = this.pkceCode ? { ...this.pkceCode } : undefined; + this.pkceCode = undefined; + this.logger.verbose( + `${res ? "Found" : "Did not find"} pre-generated PKCE codes` + ); + this.performanceClient.addFields( + { usePreGeneratedPkce: !!res }, + correlationId + ); + return res; + } + + /** + * Get the native accountId from the account + * @param request + * @returns + */ + public getNativeAccountId( + request: RedirectRequest | PopupRequest | SsoSilentRequest + ): string { + const account = + request.account || + this.getAccount({ + loginHint: request.loginHint, + sid: request.sid, + }) || + this.getActiveAccount(); + + return (account && account.nativeAccountId) || ""; + } + + /** + * Returns new instance of the Popup Interaction Client + * @param correlationId + */ + public createPopupClient(correlationId?: string): PopupClient { + return new PopupClient( + this.config, + this.browserStorage, + this.browserCrypto, + this.logger, + this.eventHandler, + this.navigationClient, + this.performanceClient, + this.nativeInternalStorage, + this.nativeExtensionProvider, + correlationId + ); + } + + /** + * Returns new instance of the Popup Interaction Client + * @param correlationId + */ + public createExtensionClient(correlationId?: string): BrowserExtensionClient { + return new BrowserExtensionClient( + this.config, + this.browserStorage, + this.browserCrypto, + this.logger, + this.eventHandler, + this.navigationClient, + this.performanceClient, + this.nativeInternalStorage, + this.nativeExtensionProvider, + correlationId + ); + } + + /** + * Returns boolean indicating if this request can use the platform broker + * @param request + */ + public canUsePlatformBroker( + request: RedirectRequest | PopupRequest | SsoSilentRequest, + accountId?: string + ): boolean { + this.logger.trace("canUsePlatformBroker called"); + if ( + !NativeMessageHandler.isPlatformBrokerAvailable( + this.config, + this.logger, + this.nativeExtensionProvider, + request.authenticationScheme + ) + ) { + this.logger.trace( + "canUsePlatformBroker: isPlatformBrokerAvailable returned false, returning false" + ); + return false; + } + + if (request.prompt) { + switch (request.prompt) { + case PromptValue.NONE: + case PromptValue.CONSENT: + case PromptValue.LOGIN: + this.logger.trace( + "canUsePlatformBroker: prompt is compatible with platform broker flow" + ); + break; + default: + this.logger.trace( + `canUsePlatformBroker: prompt = ${request.prompt} is not compatible with platform broker flow, returning false` + ); + return false; + } + } + + if (!accountId && !this.getNativeAccountId(request)) { + this.logger.trace( + "canUsePlatformBroker: nativeAccountId is not available, returning false" + ); + return false; + } + + return true; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async acquireTokenPopup(request: PopupRequest): Promise { + const correlationId = this.getRequestCorrelationId(request); + const atExtensionMeasurement = this.performanceClient.startMeasurement( + PerformanceEvents.AcquireTokenExtension, + correlationId + ); + atExtensionMeasurement.add({ + scenarioId: request.scenarioId, + accountType: getAccountType(request.account), + }); + try { + this.logger.verbose("acquireTokenExtension called"); + preflightCheck(this.initialized, atExtensionMeasurement); + this.browserStorage.setInteractionInProgress(true); + } catch (e) { + // Since this function is syncronous we need to reject + return Promise.reject(e); + } + + // If logged in, emit acquire token events + const loggedInAccounts = this.getAllAccounts(); + if (loggedInAccounts.length > 0) { + this.eventHandler.emitEvent( + EventType.ACQUIRE_TOKEN_START, + InteractionType.Popup, + request + ); + } else { + this.eventHandler.emitEvent( + EventType.LOGIN_START, + InteractionType.Popup, + request + ); + } + + let result: Promise; + const pkce = this.getPreGeneratedPkceCodes(correlationId); + + if (this.canUsePlatformBroker(request)) { + result = this.acquireTokenNative( + { + ...request, + correlationId, + }, + ApiId.acquireTokenPopup + ) + .then((response) => { + this.browserStorage.setInteractionInProgress(false); + atExtensionMeasurement.end({ + success: true, + isNativeBroker: true, + accountType: getAccountType(response.account), + }); + return response; + }) + .catch((e: AuthError) => { + if ( + e instanceof NativeAuthError && + isFatalNativeAuthError(e) + ) { + this.nativeExtensionProvider = undefined; // If extension gets uninstalled during session prevent future requests from continuing to attempt + const popupClient = + this.createPopupClient(correlationId); + return popupClient.acquireToken(request, pkce); + } else if (e instanceof InteractionRequiredAuthError) { + this.logger.verbose( + "acquireTokenPopup - Resolving interaction required error thrown by native broker by falling back to web flow" + ); + const popupClient = + this.createPopupClient(correlationId); + return popupClient.acquireToken(request, pkce); + } + this.browserStorage.setInteractionInProgress(false); + throw e; + }); + } else { + const extensionClient = this.createExtensionClient(correlationId); + result = extensionClient.acquireToken(request, pkce); + } + + return result + .then((result) => { + /* + * If logged in, emit acquire token events + */ + const isLoggingIn = + loggedInAccounts.length < this.getAllAccounts().length; + if (isLoggingIn) { + this.eventHandler.emitEvent( + EventType.LOGIN_SUCCESS, + InteractionType.Popup, + result + ); + } else { + this.eventHandler.emitEvent( + EventType.ACQUIRE_TOKEN_SUCCESS, + InteractionType.Popup, + result + ); + } + + atExtensionMeasurement.end({ + success: true, + accessTokenSize: result.accessToken.length, + idTokenSize: result.idToken.length, + accountType: getAccountType(result.account), + }); + return result; + }) + .catch((e: Error) => { + if (loggedInAccounts.length > 0) { + this.eventHandler.emitEvent( + EventType.ACQUIRE_TOKEN_FAILURE, + InteractionType.Popup, + null, + e + ); + } else { + this.eventHandler.emitEvent( + EventType.LOGIN_FAILURE, + InteractionType.Popup, + null, + e + ); + } + + atExtensionMeasurement.end( + { + success: false, + }, + e + ); + + // Since this function is syncronous we need to reject + return Promise.reject(e); + }) + .finally( + () => + this.config.system.asyncPopups && + this.preGeneratePkceCodes(correlationId) + ); + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + acquireTokenRedirect(request: RedirectRequest): Promise { + blockAPICallsBeforeInitialize(this.initialized); + blockNonBrowserEnvironment(); + return Promise.resolve(); + } + + async acquireTokenSilent( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + request: SilentRequest + ): Promise { + const correlationId = this.getRequestCorrelationId(request); + const atsMeasurement = this.performanceClient.startMeasurement( + PerformanceEvents.AcquireTokenSilent, + correlationId + ); + atsMeasurement.add({ + cacheLookupPolicy: request.cacheLookupPolicy, + scenarioId: request.scenarioId, + }); + + preflightCheck(this.initialized, atsMeasurement); + this.logger.verbose("acquireTokenSilent called", correlationId); + + const account = request.account || this.getActiveAccount(); + if (!account) { + throw createBrowserAuthError(BrowserAuthErrorCodes.noAccountError); + } + atsMeasurement.add({ accountType: getAccountType(account) }); + + const thumbprint: RequestThumbprint = { + clientId: this.config.auth.clientId, + authority: request.authority || Constants.EMPTY_STRING, + scopes: request.scopes, + homeAccountIdentifier: account.homeAccountId, + claims: request.claims, + authenticationScheme: request.authenticationScheme, + resourceRequestMethod: request.resourceRequestMethod, + resourceRequestUri: request.resourceRequestUri, + shrClaims: request.shrClaims, + sshKid: request.sshKid, + shrOptions: request.shrOptions, + }; + const silentRequestKey = JSON.stringify(thumbprint); + + const cachedResponse = + this.activeSilentTokenRequests.get(silentRequestKey); + if (typeof cachedResponse === "undefined") { + this.logger.verbose( + "acquireTokenSilent called for the first time, storing active request", + correlationId + ); + + const response = invokeAsync( + this.acquireTokenSilentAsync.bind(this), + PerformanceEvents.AcquireTokenSilentAsync, + this.logger, + this.performanceClient, + correlationId + )( + { + ...request, + correlationId, + }, + account + ) + .then((result) => { + this.activeSilentTokenRequests.delete(silentRequestKey); + atsMeasurement.end({ + success: true, + fromCache: result.fromCache, + isNativeBroker: result.fromNativeBroker, + cacheLookupPolicy: request.cacheLookupPolicy, + accessTokenSize: result.accessToken.length, + idTokenSize: result.idToken.length, + }); + return result; + }) + .catch((error: Error) => { + this.activeSilentTokenRequests.delete(silentRequestKey); + atsMeasurement.end( + { + success: false, + }, + error + ); + throw error; + }); + this.activeSilentTokenRequests.set(silentRequestKey, response); + return { + ...(await response), + state: request.state, + }; + } else { + this.logger.verbose( + "acquireTokenSilent has been called previously, returning the result from the first call", + correlationId + ); + // Discard measurements for memoized calls, as they are usually only a couple of ms and will artificially deflate metrics + atsMeasurement.discard(); + return { + ...(await cachedResponse), + state: request.state, + }; + } + } + + /** + * Silently acquire an access token for a given set of scopes. Will use cached token if available, otherwise will attempt to acquire a new token from the network via refresh token. + * @param {@link (SilentRequest:type)} + * @param {@link (AccountInfo:type)} + * @returns {Promise.} - a promise that is fulfilled when this function has completed, or rejected if an error was raised. Returns the {@link AuthResponse} + */ + protected async acquireTokenSilentAsync( + request: SilentRequest & { correlationId: string }, + account: AccountInfo + ): Promise { + const trackPageVisibility = () => + this.trackPageVisibility(request.correlationId); + this.performanceClient.addQueueMeasurement( + PerformanceEvents.AcquireTokenSilentAsync, + request.correlationId + ); + + this.eventHandler.emitEvent( + EventType.ACQUIRE_TOKEN_START, + InteractionType.Silent, + request + ); + + if (request.correlationId) { + this.performanceClient.incrementFields( + { visibilityChangeCount: 0 }, + request.correlationId + ); + } + + // document.addEventListener("visibilitychange", trackPageVisibility); + + const silentRequest = await invokeAsync( + initializeSilentRequest, + PerformanceEvents.InitializeSilentRequest, + this.logger, + this.performanceClient, + request.correlationId + )(request, account, this.config, this.performanceClient, this.logger); + const cacheLookupPolicy = + request.cacheLookupPolicy || CacheLookupPolicy.Default; + + const result = this.acquireTokenSilentNoIframe( + silentRequest, + cacheLookupPolicy + ).catch(async (refreshTokenError: AuthError) => { + // Error cannot be silently resolved or iframe renewal is not allowed, interaction required + throw refreshTokenError; + }); + + return result + .then((response) => { + this.eventHandler.emitEvent( + EventType.ACQUIRE_TOKEN_SUCCESS, + InteractionType.Silent, + response + ); + if (request.correlationId) { + this.performanceClient.addFields( + { + fromCache: response.fromCache, + isNativeBroker: response.fromNativeBroker, + }, + request.correlationId + ); + } + + return response; + }) + .catch((tokenRenewalError: Error) => { + this.eventHandler.emitEvent( + EventType.ACQUIRE_TOKEN_FAILURE, + InteractionType.Silent, + null, + tokenRenewalError + ); + throw tokenRenewalError; + }) + .finally(() => { + document.removeEventListener( + "visibilitychange", + trackPageVisibility + ); + }); + } + + /** + * AcquireTokenSilent without the iframe fallback. This is used to enable the correct fallbacks in cases where there's a potential for multiple silent requests to be made in parallel and prevent those requests from making concurrent iframe requests. + * @param silentRequest + * @param cacheLookupPolicy + * @returns + */ + private async acquireTokenSilentNoIframe( + silentRequest: CommonSilentFlowRequest, + cacheLookupPolicy: CacheLookupPolicy + ): Promise { + if ( + NativeMessageHandler.isPlatformBrokerAvailable( + this.config, + this.logger, + this.nativeExtensionProvider, + silentRequest.authenticationScheme + ) && + silentRequest.account.nativeAccountId + ) { + this.logger.verbose( + "acquireTokenSilent - attempting to acquire token from native platform" + ); + return this.acquireTokenNative( + silentRequest, + ApiId.acquireTokenSilent_silentFlow + ).catch(async (e: AuthError) => { + // If native token acquisition fails for availability reasons fallback to web flow + if (e instanceof NativeAuthError && isFatalNativeAuthError(e)) { + this.logger.verbose( + "acquireTokenSilent - native platform unavailable, falling back to web flow" + ); + this.nativeExtensionProvider = undefined; // Prevent future requests from continuing to attempt + + // Cache will not contain tokens, given that previous WAM requests succeeded. Skip cache and RT renewal and go straight to iframe renewal + throw createClientAuthError( + ClientAuthErrorCodes.tokenRefreshRequired + ); + } + throw e; + }); + } else { + this.logger.verbose( + "acquireTokenSilent - attempting to acquire token from web flow" + ); + return invokeAsync( + this.acquireTokenFromCache.bind(this), + PerformanceEvents.AcquireTokenFromCache, + this.logger, + this.performanceClient, + silentRequest.correlationId + )(silentRequest, cacheLookupPolicy).catch( + (cacheError: AuthError) => { + if (cacheLookupPolicy === CacheLookupPolicy.AccessToken) { + throw cacheError; + } + + this.eventHandler.emitEvent( + EventType.ACQUIRE_TOKEN_NETWORK_START, + InteractionType.Silent, + silentRequest + ); + + return invokeAsync( + this.acquireTokenByRefreshToken.bind(this), + PerformanceEvents.AcquireTokenByRefreshToken, + this.logger, + this.performanceClient, + silentRequest.correlationId + )(silentRequest, cacheLookupPolicy); + } + ); + } + } + + /** + * Attempt to acquire an access token from the cache + * @param silentCacheClient SilentCacheClient + * @param commonRequest CommonSilentFlowRequest + * @param silentRequest SilentRequest + * @returns A promise that, when resolved, returns the access token + */ + protected async acquireTokenFromCache( + commonRequest: CommonSilentFlowRequest, + cacheLookupPolicy: CacheLookupPolicy + ): Promise { + this.performanceClient.addQueueMeasurement( + PerformanceEvents.AcquireTokenFromCache, + commonRequest.correlationId + ); + switch (cacheLookupPolicy) { + case CacheLookupPolicy.Default: + case CacheLookupPolicy.AccessToken: + case CacheLookupPolicy.AccessTokenAndRefreshToken: + const silentCacheClient = this.createSilentCacheClient( + commonRequest.correlationId + ); + return invokeAsync( + silentCacheClient.acquireToken.bind(silentCacheClient), + PerformanceEvents.SilentCacheClientAcquireToken, + this.logger, + this.performanceClient, + commonRequest.correlationId + )(commonRequest); + default: + throw createClientAuthError( + ClientAuthErrorCodes.tokenRefreshRequired + ); + } + } + + /** + * Returns new instance of the Silent Cache Interaction Client + */ + protected createSilentCacheClient( + correlationId?: string + ): SilentCacheClient { + return new SilentCacheClient( + this.config, + this.browserStorage, + this.browserCrypto, + this.logger, + this.eventHandler, + this.navigationClient, + this.performanceClient, + this.nativeExtensionProvider, + correlationId + ); + } + + /** + * Returns new instance of the Silent Refresh Interaction Client + */ + protected createSilentRefreshClient( + correlationId?: string + ): SilentRefreshClient { + return new SilentRefreshClient( + this.config, + this.browserStorage, + this.browserCrypto, + this.logger, + this.eventHandler, + this.navigationClient, + this.performanceClient, + this.nativeExtensionProvider, + correlationId + ); + } + + /** + * Attempt to acquire an access token via a refresh token + * @param commonRequest CommonSilentFlowRequest + * @param cacheLookupPolicy CacheLookupPolicy + * @returns A promise that, when resolved, returns the access token + */ + public async acquireTokenByRefreshToken( + commonRequest: CommonSilentFlowRequest, + cacheLookupPolicy: CacheLookupPolicy + ): Promise { + this.performanceClient.addQueueMeasurement( + PerformanceEvents.AcquireTokenByRefreshToken, + commonRequest.correlationId + ); + switch (cacheLookupPolicy) { + case CacheLookupPolicy.Default: + case CacheLookupPolicy.AccessTokenAndRefreshToken: + case CacheLookupPolicy.RefreshToken: + case CacheLookupPolicy.RefreshTokenAndNetwork: + const silentRefreshClient = this.createSilentRefreshClient( + commonRequest.correlationId + ); + + return invokeAsync( + silentRefreshClient.acquireToken.bind(silentRefreshClient), + PerformanceEvents.SilentRefreshClientAcquireToken, + this.logger, + this.performanceClient, + commonRequest.correlationId + )(commonRequest); + default: + throw createClientAuthError( + ClientAuthErrorCodes.tokenRefreshRequired + ); + } + } + + acquireTokenByCode( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + request: AuthorizationCodeRequest + ): Promise { + blockAPICallsBeforeInitialize(this.initialized); + blockNonBrowserEnvironment(); + return {} as Promise; + } + acquireTokenNative( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + request: + | PopupRequest + | SilentRequest + | Partial< + Omit< + CommonAuthorizationUrlRequest, + | "responseMode" + | "codeChallenge" + | "codeChallengeMethod" + | "requestedClaimsHash" + | "platformBroker" + > + >, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + apiId: ApiId, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + accountId?: string | undefined + ): Promise { + blockAPICallsBeforeInitialize(this.initialized); + blockNonBrowserEnvironment(); + return {} as Promise; + } + + addEventCallback( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + callback: EventCallbackFunction, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + eventTypes?: Array + ): string | null { + return null; + } + removeEventCallback( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + callbackId: string + ): void {} + // eslint-disable-next-line @typescript-eslint/no-unused-vars + addPerformanceCallback(callback: PerformanceCallbackFunction): string { + blockAPICallsBeforeInitialize(this.initialized); + blockNonBrowserEnvironment(); + return ""; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + removePerformanceCallback(callbackId: string): boolean { + blockAPICallsBeforeInitialize(this.initialized); + blockNonBrowserEnvironment(); + return true; + } + enableAccountStorageEvents(): void { + blockAPICallsBeforeInitialize(this.initialized); + blockNonBrowserEnvironment(); + } + disableAccountStorageEvents(): void { + blockAPICallsBeforeInitialize(this.initialized); + blockNonBrowserEnvironment(); + } + + handleRedirectPromise( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + hash?: string | undefined + ): Promise { + blockAPICallsBeforeInitialize(this.initialized); + return Promise.resolve(null); + } + loginPopup( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + request?: PopupRequest | undefined + ): Promise { + // TODO: Add preflight checks and initialization + const correlationId: string = this.getRequestCorrelationId(request); + this.logger.verbose("loginPopup called", correlationId); + return this.acquireTokenPopup({ + correlationId, + ...(request || DEFAULT_REQUEST), + }); + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + loginRedirect(request?: RedirectRequest | undefined): Promise { + blockAPICallsBeforeInitialize(this.initialized); + blockNonBrowserEnvironment(); + return {} as Promise; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + loginExtension(request?: RedirectRequest | undefined): Promise { + blockAPICallsBeforeInitialize(this.initialized); + blockNonBrowserEnvironment(); + return {} as Promise; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + logout(logoutRequest?: EndSessionRequest | undefined): Promise { + blockAPICallsBeforeInitialize(this.initialized); + blockNonBrowserEnvironment(); + return {} as Promise; + } + logoutRedirect( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + logoutRequest?: EndSessionRequest | undefined + ): Promise { + blockAPICallsBeforeInitialize(this.initialized); + blockNonBrowserEnvironment(); + return {} as Promise; + } + logoutPopup( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + logoutRequest?: EndSessionPopupRequest | undefined + ): Promise { + blockAPICallsBeforeInitialize(this.initialized); + blockNonBrowserEnvironment(); + return {} as Promise; + } + ssoSilent( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + request: Partial< + Omit< + CommonAuthorizationUrlRequest, + | "responseMode" + | "codeChallenge" + | "codeChallengeMethod" + | "requestedClaimsHash" + | "platformBroker" + > + > + ): Promise { + blockAPICallsBeforeInitialize(this.initialized); + blockNonBrowserEnvironment(); + return {} as Promise; + } + getTokenCache(): ITokenCache { + blockAPICallsBeforeInitialize(this.initialized); + blockNonBrowserEnvironment(); + return {} as ITokenCache; + } + getLogger(): Logger { + return this.logger; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + setLogger(logger: Logger): void { + blockAPICallsBeforeInitialize(this.initialized); + blockNonBrowserEnvironment(); + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + setActiveAccount(account: AccountInfo | null): void { + blockAPICallsBeforeInitialize(this.initialized); + blockNonBrowserEnvironment(); + } + /** + * Gets the currently active account + */ + getActiveAccount(): AccountInfo | null { + return AccountManager.getActiveAccount(this.browserStorage); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + initializeWrapperLibrary(sku: WrapperSKU, version: string): void { + this.browserStorage.setWrapperMetadata(sku, version); + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + setNavigationClient(navigationClient: INavigationClient): void { + blockAPICallsBeforeInitialize(this.initialized); + blockNonBrowserEnvironment(); + } + getConfiguration(): BrowserConfiguration { + return this.config; + } + isBrowserEnv(): boolean { + blockAPICallsBeforeInitialize(this.initialized); + blockNonBrowserEnvironment(); + return true; + } + getBrowserCrypto(): ICrypto { + blockAPICallsBeforeInitialize(this.initialized); + blockNonBrowserEnvironment(); + return {} as ICrypto; + } + getPerformanceClient(): IPerformanceClient { + blockAPICallsBeforeInitialize(this.initialized); + blockNonBrowserEnvironment(); + return {} as IPerformanceClient; + } + getRedirectResponse(): Map> { + blockAPICallsBeforeInitialize(this.initialized); + blockNonBrowserEnvironment(); + return {} as Map>; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async clearCache(logoutRequest?: ClearCacheRequest): Promise { + blockAPICallsBeforeInitialize(this.initialized); + blockNonBrowserEnvironment(); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async hydrateCache( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + result: AuthenticationResult, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + request: + | SilentRequest + | SsoSilentRequest + | RedirectRequest + | PopupRequest + ): Promise { + blockAPICallsBeforeInitialize(this.initialized); + blockNonBrowserEnvironment(); + } +} diff --git a/lib/msal-browser/src/controllers/ControllerFactory.ts b/lib/msal-browser/src/controllers/ControllerFactory.ts index dc647fb06f..5417bcaa88 100644 --- a/lib/msal-browser/src/controllers/ControllerFactory.ts +++ b/lib/msal-browser/src/controllers/ControllerFactory.ts @@ -10,6 +10,8 @@ import { Configuration } from "../config/Configuration.js"; import { StandardController } from "./StandardController.js"; import { NestedAppAuthController } from "./NestedAppAuthController.js"; import { InitializeApplicationRequest } from "../request/InitializeApplicationRequest.js"; +import { BrowserExtensionOperatingContext } from "../operatingcontext/BrowserExtensionOperatingContext.js"; +import { BrowserExtensionController } from "./BrowserExtensionController.js"; export async function createV3Controller( config: Configuration, @@ -26,17 +28,20 @@ export async function createController( ): Promise { const standard = new StandardOperatingContext(config); const nestedApp = new NestedAppOperatingContext(config); + const extensionApp = new BrowserExtensionOperatingContext(config); - const operatingContexts = [standard.initialize(), nestedApp.initialize()]; + const operatingContexts = [standard.initialize(), nestedApp.initialize(), extensionApp.initialize()]; await Promise.all(operatingContexts); if (nestedApp.isAvailable() && config.auth.supportsNestedAppAuth) { return NestedAppAuthController.createController(nestedApp); + } else if (extensionApp.isAvailable()) { + return BrowserExtensionController.createController(extensionApp); } else if (standard.isAvailable()) { return StandardController.createController(standard); } else { - // Since neither of the actual operating contexts are available keep the UnknownOperatingContextController + // Since none of the actual operating contexts are available keep the UnknownOperatingContextController return null; } } diff --git a/lib/msal-browser/src/crypto/BrowserCrypto.ts b/lib/msal-browser/src/crypto/BrowserCrypto.ts index f281920218..728aa770d1 100644 --- a/lib/msal-browser/src/crypto/BrowserCrypto.ts +++ b/lib/msal-browser/src/crypto/BrowserCrypto.ts @@ -105,7 +105,16 @@ export async function sha256Digest( * @param dataBuffer */ export function getRandomValues(dataBuffer: Uint8Array): Uint8Array { - return window.crypto.getRandomValues(dataBuffer); + if (typeof window !== "undefined") { + return window.crypto.getRandomValues(dataBuffer); + } else if (chrome && crypto) { + // Chrome extension environment + return crypto.getRandomValues(dataBuffer); + } + throw createBrowserAuthError( + BrowserAuthErrorCodes.cryptoNonExistent, + SUBTLE_SUBERROR + ); } /** @@ -113,8 +122,17 @@ export function getRandomValues(dataBuffer: Uint8Array): Uint8Array { * @returns {number} */ function getRandomUint32(): number { - window.crypto.getRandomValues(UINT32_ARR); - return UINT32_ARR[0]; + if(typeof window !== "undefined") { + // Browser environment + window.crypto.getRandomValues(UINT32_ARR); + return UINT32_ARR[0]; + } else if (chrome && crypto) { + // Chrome extension environment + return crypto.getRandomValues(UINT32_ARR)[0]; + } else { + return 0; + } + } /** diff --git a/lib/msal-browser/src/error/BrowserAuthError.ts b/lib/msal-browser/src/error/BrowserAuthError.ts index 1cbc966270..df146a604a 100644 --- a/lib/msal-browser/src/error/BrowserAuthError.ts +++ b/lib/msal-browser/src/error/BrowserAuthError.ts @@ -59,6 +59,8 @@ export const BrowserAuthErrorMessages = { [BrowserAuthErrorCodes.invalidCacheType]: "Invalid cache type", [BrowserAuthErrorCodes.nonBrowserEnvironment]: "Login and token requests are not supported in non-browser environments.", + [BrowserAuthErrorCodes.nonExtensionEnvironment]: + "Login and token requests are not supported in non-extension environments.", [BrowserAuthErrorCodes.databaseNotOpen]: "Database is not open!", [BrowserAuthErrorCodes.noNetworkConnectivity]: "No network connectivity. Check your internet connection.", diff --git a/lib/msal-browser/src/error/BrowserAuthErrorCodes.ts b/lib/msal-browser/src/error/BrowserAuthErrorCodes.ts index 8ba75a665a..f577f07a69 100644 --- a/lib/msal-browser/src/error/BrowserAuthErrorCodes.ts +++ b/lib/msal-browser/src/error/BrowserAuthErrorCodes.ts @@ -32,6 +32,7 @@ export const noCachedAuthorityError = "no_cached_authority_error"; export const authRequestNotSetError = "auth_request_not_set_error"; export const invalidCacheType = "invalid_cache_type"; export const nonBrowserEnvironment = "non_browser_environment"; +export const nonExtensionEnvironment = "non_extension_environment"; export const databaseNotOpen = "database_not_open"; export const noNetworkConnectivity = "no_network_connectivity"; export const postRequestFailed = "post_request_failed"; diff --git a/lib/msal-browser/src/index.ts b/lib/msal-browser/src/index.ts index db8b912914..266512268b 100644 --- a/lib/msal-browser/src/index.ts +++ b/lib/msal-browser/src/index.ts @@ -14,6 +14,7 @@ export { BrowserUtils }; export { PublicClientApplication, createNestablePublicClientApplication, + createBrowserExtensionPublicClientApplication, createStandardPublicClientApplication, } from "./app/PublicClientApplication.js"; export { PublicClientNext } from "./app/PublicClientNext.js"; diff --git a/lib/msal-browser/src/interaction_client/BrowserExtensionClient.ts b/lib/msal-browser/src/interaction_client/BrowserExtensionClient.ts index 549c3efc63..cc306df380 100644 --- a/lib/msal-browser/src/interaction_client/BrowserExtensionClient.ts +++ b/lib/msal-browser/src/interaction_client/BrowserExtensionClient.ts @@ -79,8 +79,8 @@ export class BrowserExtensionClient extends StandardInteractionClient { pkceCodes?: PkceCodes ): Promise { try { - const chromeIdentity = (window.chrome || (window as any)['browser']).identity; - if (chromeIdentity) { + const chromeIdentity = chrome.identity; + if (typeof chromeIdentity !== "undefined") { this.logger.verbose("chrome.identity API is available, acquiring token using Manifest V3 Webflow"); return this.acquireTokenExtensionAsync( request, @@ -249,8 +249,7 @@ export class BrowserExtensionClient extends StandardInteractionClient { prompt: undefined, // Server should handle the prompt, ideally native broker can do this part silently }); } - console.log(serverParams); - debugger; + // Handle response from hash string. const result = await interactionHandler.handleCodeResponse( serverParams, diff --git a/lib/msal-browser/src/operatingcontext/BrowserExtensionOperatingContext.ts b/lib/msal-browser/src/operatingcontext/BrowserExtensionOperatingContext.ts new file mode 100644 index 0000000000..14bc0c9d43 --- /dev/null +++ b/lib/msal-browser/src/operatingcontext/BrowserExtensionOperatingContext.ts @@ -0,0 +1,50 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { BaseOperatingContext } from "./BaseOperatingContext.js"; + +export class BrowserExtensionOperatingContext extends BaseOperatingContext { + /* + * TODO: Once we have determine the bundling code return here to specify the name of the bundle + * containing the implementation for this operating context + */ + static readonly MODULE_NAME: string = ""; + + /** + * Unique identifier for the operating context + */ + static readonly ID: string = "BrowserExtensionOperatingContext"; + + /** + * Return the module name. Intended for use with import() to enable dynamic import + * of the implementation associated with this operating context + * @returns + */ + getModuleName(): string { + return BrowserExtensionOperatingContext.MODULE_NAME; + } + + /** + * Returns the unique identifier for this operating context + * @returns string + */ + getId(): string { + return BrowserExtensionOperatingContext.ID; + } + + /** + * Checks whether the operating context is available. + * Confirms that the code is running an extension service worker. This is required. + * @returns Promise indicating whether this operating context is currently available. + */ + async initialize(): Promise { + this.available = typeof chrome !== "undefined"; + return this.available; + /* + * NOTE: The standard context is available as long as there is a window. If/when we split out WAM from Browser + * We can move the current contents of the initialize method to here and verify that the WAM extension is available + */ + } +} diff --git a/lib/msal-browser/src/response/ResponseHandler.ts b/lib/msal-browser/src/response/ResponseHandler.ts index 9bd6e53cb1..74e77f9c69 100644 --- a/lib/msal-browser/src/response/ResponseHandler.ts +++ b/lib/msal-browser/src/response/ResponseHandler.ts @@ -23,8 +23,7 @@ export function deserializeResponse( ): ServerAuthorizationCodeResponse { // Deserialize hash fragment response parameters. const serverParams = UrlUtils.getDeserializedResponse(responseString); - console.log(serverParams); - debugger; + if (!serverParams) { if (!UrlUtils.stripLeadingHashOrQuery(responseString)) { // Hash or Query string is empty diff --git a/lib/msal-browser/src/utils/BrowserUtils.ts b/lib/msal-browser/src/utils/BrowserUtils.ts index 34a96f5fd3..3973928e86 100644 --- a/lib/msal-browser/src/utils/BrowserUtils.ts +++ b/lib/msal-browser/src/utils/BrowserUtils.ts @@ -129,6 +129,14 @@ export function blockNonBrowserEnvironment(): void { } } +export function blockNonExtensionEnvironment(): void { + if (typeof chrome === "undefined") { + throw createBrowserAuthError( + BrowserAuthErrorCodes.nonExtensionEnvironment + ); + } +} + /** * Throws error if initialize hasn't been called * @param initialized @@ -159,6 +167,22 @@ export function preflightCheck(initialized: boolean): void { blockAPICallsBeforeInitialize(initialized); } +// TODO: Dedupe with above +/** + * Helper to validate app environment before making an auth request + * @param initialized + */ +export function preflightCheckExtension(initialized: boolean): void { + // Block request if not in browser environment + blockNonExtensionEnvironment(); + + // 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