From 9e2a13006fb32a5c02bcba9ef37070079fb40912 Mon Sep 17 00:00:00 2001 From: Thomas Norling Date: Thu, 23 Oct 2025 10:34:52 -0700 Subject: [PATCH 1/3] EAR auth code fallback --- .../src/interaction_client/PopupClient.ts | 94 ++++++++++++++----- .../src/interaction_client/RedirectClient.ts | 15 ++- .../interaction_client/SilentIframeClient.ts | 87 +++++++++++++---- .../StandardInteractionClient.ts | 29 +++--- lib/msal-browser/src/protocol/Authorize.ts | 7 ++ .../test/protocol/Authorize.spec.ts | 9 ++ 6 files changed, 188 insertions(+), 53 deletions(-) diff --git a/lib/msal-browser/src/interaction_client/PopupClient.ts b/lib/msal-browser/src/interaction_client/PopupClient.ts index 4939499a68..029fc66cc0 100644 --- a/lib/msal-browser/src/interaction_client/PopupClient.ts +++ b/lib/msal-browser/src/interaction_client/PopupClient.ts @@ -243,7 +243,7 @@ export class PopupClient extends StandardInteractionClient { validRequest.platformBroker = isPlatformBroker; if (this.config.auth.protocolMode === ProtocolMode.EAR) { - return this.executeEarFlow(validRequest, popupParams); + return this.executeEarFlow(validRequest, popupParams, pkceCodes); } else { return this.executeCodeFlow(validRequest, popupParams, pkceCodes); } @@ -389,7 +389,8 @@ export class PopupClient extends StandardInteractionClient { */ async executeEarFlow( request: CommonAuthorizationUrlRequest, - popupParams: PopupParams + popupParams: PopupParams, + pkceCodes?: PkceCodes ): Promise { const correlationId = request.correlationId; // Get the frame handle for the silent request @@ -413,9 +414,20 @@ export class PopupClient extends StandardInteractionClient { this.performanceClient, correlationId )(); + const pkce = + pkceCodes || + (await invokeAsync( + generatePkceCodes, + PerformanceEvents.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); @@ -451,25 +463,65 @@ export class PopupClient extends StandardInteractionClient { this.logger ); - return invokeAsync( - Authorize.handleResponseEAR, - PerformanceEvents.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 - ); + if (!serverParams.ear_jwe && serverParams.code) { + const authClient = await invokeAsync( + this.createAuthCodeClient.bind(this), + PerformanceEvents.StandardInteractionClientCreateAuthCodeClient, + this.logger, + this.performanceClient, + correlationId + )({ + serverTelemetryManager: this.initializeServerTelemetryManager( + ApiId.acquireTokenPopup + ), + requestAuthority: request.authority, + requestAzureCloudOptions: request.azureCloudOptions, + requestExtraQueryParameters: request.extraQueryParameters, + account: request.account, + authority: discoveredAuthority, + }); + + return invokeAsync( + Authorize.handleResponseCode, + PerformanceEvents.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, + PerformanceEvents.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( diff --git a/lib/msal-browser/src/interaction_client/RedirectClient.ts b/lib/msal-browser/src/interaction_client/RedirectClient.ts index e7ab2b4bf2..94dfb624c5 100644 --- a/lib/msal-browser/src/interaction_client/RedirectClient.ts +++ b/lib/msal-browser/src/interaction_client/RedirectClient.ts @@ -272,11 +272,24 @@ export class RedirectClient extends StandardInteractionClient { this.performanceClient, correlationId )(); + const pkceCodes = await invokeAsync( + generatePkceCodes, + PerformanceEvents.GeneratePkceCodes, + this.logger, + this.performanceClient, + correlationId + )(this.performanceClient, this.logger, correlationId); + const redirectRequest = { ...request, earJwk: earJwk, + codeChallenge: pkceCodes.challenge, }; - this.browserStorage.cacheAuthorizeRequest(redirectRequest); + + this.browserStorage.cacheAuthorizeRequest( + redirectRequest, + pkceCodes.verifier + ); const form = await Authorize.getEARForm( document, diff --git a/lib/msal-browser/src/interaction_client/SilentIframeClient.ts b/lib/msal-browser/src/interaction_client/SilentIframeClient.ts index 6b81bfde38..f3855580ac 100644 --- a/lib/msal-browser/src/interaction_client/SilentIframeClient.ts +++ b/lib/msal-browser/src/interaction_client/SilentIframeClient.ts @@ -235,9 +235,17 @@ export class SilentIframeClient extends StandardInteractionClient { this.performanceClient, correlationId )(); + const pkceCodes = await invokeAsync( + generatePkceCodes, + PerformanceEvents.GeneratePkceCodes, + this.logger, + this.performanceClient, + correlationId + )(this.performanceClient, this.logger, correlationId); const silentRequest = { ...request, earJwk: earJwk, + codeChallenge: pkceCodes.challenge, }; const msalFrame = await invokeAsync( initiateEarRequest, @@ -279,25 +287,66 @@ export class SilentIframeClient extends StandardInteractionClient { correlationId )(responseString, responseType, this.logger); - return invokeAsync( - Authorize.handleResponseEAR, - PerformanceEvents.HandleResponseEar, - this.logger, - this.performanceClient, - correlationId - )( - silentRequest, - serverParams, - this.apiId, - this.config, - discoveredAuthority, - this.browserStorage, - this.nativeStorage, - this.eventHandler, - this.logger, - this.performanceClient, - this.platformAuthProvider - ); + if (!serverParams.ear_jwe && serverParams.code) { + // If server doesn't support EAR, they may fallback to auth code flow instead + const authClient = await invokeAsync( + this.createAuthCodeClient.bind(this), + PerformanceEvents.StandardInteractionClientCreateAuthCodeClient, + this.logger, + this.performanceClient, + correlationId + )({ + serverTelemetryManager: this.initializeServerTelemetryManager( + this.apiId + ), + requestAuthority: request.authority, + requestAzureCloudOptions: request.azureCloudOptions, + requestExtraQueryParameters: request.extraQueryParameters, + account: request.account, + authority: discoveredAuthority, + }); + + return invokeAsync( + Authorize.handleResponseCode, + PerformanceEvents.HandleResponseCode, + this.logger, + this.performanceClient, + correlationId + )( + silentRequest, + serverParams, + pkceCodes.verifier, + this.apiId, + this.config, + authClient, + this.browserStorage, + this.nativeStorage, + this.eventHandler, + this.logger, + this.performanceClient, + this.platformAuthProvider + ); + } else { + return invokeAsync( + Authorize.handleResponseEAR, + PerformanceEvents.HandleResponseEar, + this.logger, + this.performanceClient, + correlationId + )( + silentRequest, + serverParams, + this.apiId, + this.config, + discoveredAuthority, + this.browserStorage, + this.nativeStorage, + this.eventHandler, + this.logger, + this.performanceClient, + this.platformAuthProvider + ); + } } /** diff --git a/lib/msal-browser/src/interaction_client/StandardInteractionClient.ts b/lib/msal-browser/src/interaction_client/StandardInteractionClient.ts index a36be14462..25b2c0e6bb 100644 --- a/lib/msal-browser/src/interaction_client/StandardInteractionClient.ts +++ b/lib/msal-browser/src/interaction_client/StandardInteractionClient.ts @@ -20,6 +20,7 @@ import { BaseAuthRequest, StringDict, CommonAuthorizationUrlRequest, + Authority, } from "@azure/msal-common/browser"; import { BaseInteractionClient } from "./BaseInteractionClient.js"; import { @@ -186,6 +187,7 @@ export abstract class StandardInteractionClient extends BaseInteractionClient { requestAzureCloudOptions?: AzureCloudOptions; requestExtraQueryParameters?: StringDict; account?: AccountInfo; + authority?: Authority; }): Promise { this.performanceClient.addQueueMeasurement( PerformanceEvents.StandardInteractionClientCreateAuthCodeClient, @@ -222,6 +224,7 @@ export abstract class StandardInteractionClient extends BaseInteractionClient { requestAzureCloudOptions?: AzureCloudOptions; requestExtraQueryParameters?: StringDict; account?: AccountInfo; + authority?: Authority; }): Promise { const { serverTelemetryManager, @@ -235,18 +238,20 @@ export abstract class StandardInteractionClient extends BaseInteractionClient { PerformanceEvents.StandardInteractionClientGetClientConfiguration, this.correlationId ); - const discoveredAuthority = await invokeAsync( - this.getDiscoveredAuthority.bind(this), - PerformanceEvents.StandardInteractionClientGetDiscoveredAuthority, - this.logger, - this.performanceClient, - this.correlationId - )({ - requestAuthority, - requestAzureCloudOptions, - requestExtraQueryParameters, - account, - }); + const discoveredAuthority = + params.authority || + (await invokeAsync( + this.getDiscoveredAuthority.bind(this), + PerformanceEvents.StandardInteractionClientGetDiscoveredAuthority, + this.logger, + this.performanceClient, + this.correlationId + )({ + requestAuthority, + requestAzureCloudOptions, + requestExtraQueryParameters, + account, + })); const logger = this.config.system.loggerOptions; return { diff --git a/lib/msal-browser/src/protocol/Authorize.ts b/lib/msal-browser/src/protocol/Authorize.ts index 1656845a6c..199615498a 100644 --- a/lib/msal-browser/src/protocol/Authorize.ts +++ b/lib/msal-browser/src/protocol/Authorize.ts @@ -203,6 +203,13 @@ export async function getEARForm( ); RequestParameterBuilder.addEARParameters(parameters, request.earJwk); + // Also add codeChallenge as backup in case EAR is not supported + RequestParameterBuilder.addCodeChallengeParams( + parameters, + request.codeChallenge, + Constants.S256_CODE_CHALLENGE_METHOD + ); + const queryParams = new Map(); RequestParameterBuilder.addExtraQueryParameters( queryParams, diff --git a/lib/msal-browser/test/protocol/Authorize.spec.ts b/lib/msal-browser/test/protocol/Authorize.spec.ts index f5878c4255..f29be90dca 100644 --- a/lib/msal-browser/test/protocol/Authorize.spec.ts +++ b/lib/msal-browser/test/protocol/Authorize.spec.ts @@ -69,6 +69,7 @@ describe("Authorize Protocol Tests", () => { nonce: ID_TOKEN_CLAIMS.nonce, responseMode: ResponseMode.FRAGMENT, earJwk: validEarJWK, + codeChallenge: "code-challenge", extraQueryParameters: { extraKey1: "extraVal1", extraKey2: "extraVal2", @@ -180,6 +181,14 @@ describe("Authorize Protocol Tests", () => { AADServerParamKeys.EAR_JWE_CRYPTO, "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0" ); + checkInputProperties( + AADServerParamKeys.CODE_CHALLENGE, + validRequest.codeChallenge! + ); + checkInputProperties( + AADServerParamKeys.CODE_CHALLENGE_METHOD, + "S256" + ); checkInputProperties( AADServerParamKeys.X_CLIENT_SKU, BrowserConstants.MSAL_SKU From 1b33c6c1af38edc312c26be233a0e715702bd1c6 Mon Sep 17 00:00:00 2001 From: Thomas Norling Date: Wed, 29 Oct 2025 16:13:58 -0700 Subject: [PATCH 2/3] tests --- .../interaction_client/PopupClient.spec.ts | 37 +++++++++++++++++++ .../interaction_client/RedirectClient.spec.ts | 31 ++++++++++++++++ .../SilentIframeClient.spec.ts | 25 +++++++++++++ 3 files changed, 93 insertions(+) diff --git a/lib/msal-browser/test/interaction_client/PopupClient.spec.ts b/lib/msal-browser/test/interaction_client/PopupClient.spec.ts index d81ae7e4fb..94c1d51e83 100644 --- a/lib/msal-browser/test/interaction_client/PopupClient.spec.ts +++ b/lib/msal-browser/test/interaction_client/PopupClient.spec.ts @@ -999,6 +999,43 @@ describe("PopupClient", () => { expect(earFormSpy).toHaveBeenCalled(); }); + it("EAR flow falls back to Auth Code if service returns code instead of ear_jwe", async () => { + const validRequest: PopupRequest = { + authority: TEST_CONFIG.validAuthority, + scopes: ["openid", "profile", "offline_access"], + correlationId: TEST_CONFIG.CORRELATION_ID, + redirectUri: window.location.href, + state: TEST_STATE_VALUES.USER_STATE, + nonce: ID_TOKEN_CLAIMS.nonce, + }; + jest.spyOn(ProtocolUtils, "setRequestState").mockReturnValue( + TEST_STATE_VALUES.TEST_STATE_POPUP + ); + jest.spyOn( + PopupClient.prototype, + "openSizedPopup" + ).mockReturnValue(popupWindow); + const earFormSpy = jest + .spyOn(HTMLFormElement.prototype, "submit") + .mockImplementation(() => { + // Suppress navigation + }); + jest.spyOn( + PopupClient.prototype, + "monitorPopupForHash" + ).mockResolvedValue( + `#code=validCode&state=${TEST_STATE_VALUES.TEST_STATE_POPUP}` + ); + jest.spyOn( + AuthorizeProtocol, + "handleResponseCode" + ).mockResolvedValue(getTestAuthenticationResult()); + + const result = await pca.acquireTokenPopup(validRequest); + expect(result).toEqual(getTestAuthenticationResult()); + expect(earFormSpy).toHaveBeenCalled(); + }); + it("throws error when ProtocolMode is set to EAR and httpMethod is set to GET", async () => { const validRequest: PopupRequest = { authority: TEST_CONFIG.validAuthority, diff --git a/lib/msal-browser/test/interaction_client/RedirectClient.spec.ts b/lib/msal-browser/test/interaction_client/RedirectClient.spec.ts index 3398ee9dfd..3b86b0fe6f 100644 --- a/lib/msal-browser/test/interaction_client/RedirectClient.spec.ts +++ b/lib/msal-browser/test/interaction_client/RedirectClient.spec.ts @@ -3066,6 +3066,37 @@ describe("RedirectClient", () => { pca.acquireTokenRedirect(validRequest).catch(() => {}); }); + it("EAR flow falls back to Auth Code if service returns code instead of ear_jwe", (done) => { + const validRequest: RedirectRequest = { + authority: TEST_CONFIG.validAuthority, + scopes: ["openid", "profile", "offline_access"], + correlationId: TEST_CONFIG.CORRELATION_ID, + redirectUri: window.location.href, + state: TEST_STATE_VALUES.USER_STATE, + nonce: ID_TOKEN_CLAIMS.nonce, + }; + jest.spyOn(ProtocolUtils, "setRequestState").mockReturnValue( + TEST_STATE_VALUES.TEST_STATE_REDIRECT + ); + jest.spyOn( + AuthorizeProtocol, + "handleResponseCode" + ).mockResolvedValue(getTestAuthenticationResult()); + jest.spyOn(HTMLFormElement.prototype, "submit").mockImplementation( + () => { + // Supress navigation + pca.handleRedirectPromise( + `#code=validCode&state=${TEST_STATE_VALUES.TEST_STATE_REDIRECT}` + ).then((result) => { + expect(result).toEqual(getTestAuthenticationResult()); + done(); + }); + } + ); + + pca.acquireTokenRedirect(validRequest).catch(() => {}); + }); + it("Throws a timeout error if the form post failed to redirect within the alloted time", async () => { const validRequest: RedirectRequest = { scopes: ["openid", "profile", "offline_access"], diff --git a/lib/msal-browser/test/interaction_client/SilentIframeClient.spec.ts b/lib/msal-browser/test/interaction_client/SilentIframeClient.spec.ts index 14b2be7bf9..0b5b752856 100644 --- a/lib/msal-browser/test/interaction_client/SilentIframeClient.spec.ts +++ b/lib/msal-browser/test/interaction_client/SilentIframeClient.spec.ts @@ -1470,6 +1470,31 @@ describe("SilentIframeClient", () => { expect(earFormSpy).toHaveBeenCalled(); }); + it("EAR flow falls back to Auth Code if service returns code instead of ear_jwe", async () => { + jest.restoreAllMocks(); + jest.spyOn(ProtocolUtils, "setRequestState").mockReturnValue( + TEST_STATE_VALUES.TEST_STATE_SILENT + ); + + jest.spyOn( + SilentHandler, + "monitorIframeForHash" + ).mockResolvedValue( + `#code=validCode&state=${TEST_STATE_VALUES.TEST_STATE_SILENT}` + ); + jest.spyOn( + AuthorizeProtocol, + "handleResponseCode" + ).mockResolvedValue(getTestAuthenticationResult()); + const earFormSpy = jest + .spyOn(SilentHandler, "initiateEarRequest") + .mockResolvedValue(document.createElement("iframe")); + + const result = await pca.ssoSilent(validRequest); + expect(result).toEqual(getTestAuthenticationResult()); + expect(earFormSpy).toHaveBeenCalled(); + }); + it("throws if protocolMode is set to EAR and httpMethod is set to GET", async () => { await expect( pca.ssoSilent({ From 7b976c07be00c4cd908321180a4fdb60b7459e2c Mon Sep 17 00:00:00 2001 From: Thomas Norling Date: Wed, 29 Oct 2025 16:15:12 -0700 Subject: [PATCH 3/3] Change files --- ...-msal-browser-4584bec2-2724-4645-aac5-73cf620cedd7.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 change/@azure-msal-browser-4584bec2-2724-4645-aac5-73cf620cedd7.json diff --git a/change/@azure-msal-browser-4584bec2-2724-4645-aac5-73cf620cedd7.json b/change/@azure-msal-browser-4584bec2-2724-4645-aac5-73cf620cedd7.json new file mode 100644 index 0000000000..e7419fd826 --- /dev/null +++ b/change/@azure-msal-browser-4584bec2-2724-4645-aac5-73cf620cedd7.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "EAR flow falls back to auth code when /authorize returns code #8111", + "packageName": "@azure/msal-browser", + "email": "thomas.norling@microsoft.com", + "dependentChangeType": "patch" +}