Skip to content

Commit 8c5f422

Browse files
authored
EAR auth code fallback (#8111)
This pull request introduces enhancements to the authentication flow to support fallback from Encrypted Authorize Response (EAR) to standard authorization code flow, working around lack of support for symmetric EAR in MSA. **Authentication flow fallback and PKCE improvements:** * Updated `PopupClient` and `SilentIframeClient` to detect when the server does not support EAR and automatically fallback to the authorization code flow, passing the PKCE verifier as needed. This ensures authentication succeeds even if EAR is unsupported. [[1]](diffhunk://#diff-3f43afd5556603a80064728bd701519ec2e22979f09ae6095b7fdea0507ad593R466-R504) [[2]](diffhunk://#diff-379febb046eaaa641bafb36c0a72f4c585eda5881b889dd8942919112539e5faR290-R329) * Refactored PKCE code generation and propagation: PKCE codes are now generated and passed through the EAR flow across all clients (`PopupClient`, `RedirectClient`, `SilentIframeClient`). The code challenge is included in requests and cached with the verifier for later use. [[1]](diffhunk://#diff-3f43afd5556603a80064728bd701519ec2e22979f09ae6095b7fdea0507ad593L392-R393) [[2]](diffhunk://#diff-3f43afd5556603a80064728bd701519ec2e22979f09ae6095b7fdea0507ad593R417-R430) [[3]](diffhunk://#diff-06ec3818a1cb128320c6ece84eed04190a54c03a09a455ca2d5c6947e29d5de1R275-R292) [[4]](diffhunk://#diff-379febb046eaaa641bafb36c0a72f4c585eda5881b889dd8942919112539e5faR238-R248) **Protocol and test updates:** * Modified the protocol logic in `Authorize.ts` to always include the PKCE code challenge in EAR requests as a backup, improving compatibility with servers that may not support EAR. * Updated protocol tests to verify that the code challenge and method are correctly included in authorization requests, ensuring test coverage for the new fallback and PKCE logic. [[1]](diffhunk://#diff-383f979b9a05a2d7fe02052138e8087f6ca2167e78d44dc4d41c391463d4da04R72) [[2]](diffhunk://#diff-383f979b9a05a2d7fe02052138e8087f6ca2167e78d44dc4d41c391463d4da04R184-R191)
1 parent bfd1f6b commit 8c5f422

File tree

10 files changed

+288
-53
lines changed

10 files changed

+288
-53
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "patch",
3+
"comment": "EAR flow falls back to auth code when /authorize returns code #8111",
4+
"packageName": "@azure/msal-browser",
5+
"email": "thomas.norling@microsoft.com",
6+
"dependentChangeType": "patch"
7+
}

lib/msal-browser/src/interaction_client/PopupClient.ts

Lines changed: 73 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ export class PopupClient extends StandardInteractionClient {
243243
validRequest.platformBroker = isPlatformBroker;
244244

245245
if (this.config.auth.protocolMode === ProtocolMode.EAR) {
246-
return this.executeEarFlow(validRequest, popupParams);
246+
return this.executeEarFlow(validRequest, popupParams, pkceCodes);
247247
} else {
248248
return this.executeCodeFlow(validRequest, popupParams, pkceCodes);
249249
}
@@ -389,7 +389,8 @@ export class PopupClient extends StandardInteractionClient {
389389
*/
390390
async executeEarFlow(
391391
request: CommonAuthorizationUrlRequest,
392-
popupParams: PopupParams
392+
popupParams: PopupParams,
393+
pkceCodes?: PkceCodes
393394
): Promise<AuthenticationResult> {
394395
const correlationId = request.correlationId;
395396
// Get the frame handle for the silent request
@@ -413,9 +414,20 @@ export class PopupClient extends StandardInteractionClient {
413414
this.performanceClient,
414415
correlationId
415416
)();
417+
const pkce =
418+
pkceCodes ||
419+
(await invokeAsync(
420+
generatePkceCodes,
421+
PerformanceEvents.GeneratePkceCodes,
422+
this.logger,
423+
this.performanceClient,
424+
correlationId
425+
)(this.performanceClient, this.logger, correlationId));
426+
416427
const popupRequest = {
417428
...request,
418429
earJwk: earJwk,
430+
codeChallenge: pkce.challenge,
419431
};
420432
const popupWindow =
421433
popupParams.popup || this.openPopup("about:blank", popupParams);
@@ -451,25 +463,65 @@ export class PopupClient extends StandardInteractionClient {
451463
this.logger
452464
);
453465

454-
return invokeAsync(
455-
Authorize.handleResponseEAR,
456-
PerformanceEvents.HandleResponseEar,
457-
this.logger,
458-
this.performanceClient,
459-
correlationId
460-
)(
461-
popupRequest,
462-
serverParams,
463-
ApiId.acquireTokenPopup,
464-
this.config,
465-
discoveredAuthority,
466-
this.browserStorage,
467-
this.nativeStorage,
468-
this.eventHandler,
469-
this.logger,
470-
this.performanceClient,
471-
this.platformAuthProvider
472-
);
466+
if (!serverParams.ear_jwe && serverParams.code) {
467+
const authClient = await invokeAsync(
468+
this.createAuthCodeClient.bind(this),
469+
PerformanceEvents.StandardInteractionClientCreateAuthCodeClient,
470+
this.logger,
471+
this.performanceClient,
472+
correlationId
473+
)({
474+
serverTelemetryManager: this.initializeServerTelemetryManager(
475+
ApiId.acquireTokenPopup
476+
),
477+
requestAuthority: request.authority,
478+
requestAzureCloudOptions: request.azureCloudOptions,
479+
requestExtraQueryParameters: request.extraQueryParameters,
480+
account: request.account,
481+
authority: discoveredAuthority,
482+
});
483+
484+
return invokeAsync(
485+
Authorize.handleResponseCode,
486+
PerformanceEvents.HandleResponseCode,
487+
this.logger,
488+
this.performanceClient,
489+
correlationId
490+
)(
491+
popupRequest,
492+
serverParams,
493+
pkce.verifier,
494+
ApiId.acquireTokenPopup,
495+
this.config,
496+
authClient,
497+
this.browserStorage,
498+
this.nativeStorage,
499+
this.eventHandler,
500+
this.logger,
501+
this.performanceClient,
502+
this.platformAuthProvider
503+
);
504+
} else {
505+
return invokeAsync(
506+
Authorize.handleResponseEAR,
507+
PerformanceEvents.HandleResponseEar,
508+
this.logger,
509+
this.performanceClient,
510+
correlationId
511+
)(
512+
popupRequest,
513+
serverParams,
514+
ApiId.acquireTokenPopup,
515+
this.config,
516+
discoveredAuthority,
517+
this.browserStorage,
518+
this.nativeStorage,
519+
this.eventHandler,
520+
this.logger,
521+
this.performanceClient,
522+
this.platformAuthProvider
523+
);
524+
}
473525
}
474526

475527
async executeCodeFlowWithPost(

lib/msal-browser/src/interaction_client/RedirectClient.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,11 +272,24 @@ export class RedirectClient extends StandardInteractionClient {
272272
this.performanceClient,
273273
correlationId
274274
)();
275+
const pkceCodes = await invokeAsync(
276+
generatePkceCodes,
277+
PerformanceEvents.GeneratePkceCodes,
278+
this.logger,
279+
this.performanceClient,
280+
correlationId
281+
)(this.performanceClient, this.logger, correlationId);
282+
275283
const redirectRequest = {
276284
...request,
277285
earJwk: earJwk,
286+
codeChallenge: pkceCodes.challenge,
278287
};
279-
this.browserStorage.cacheAuthorizeRequest(redirectRequest);
288+
289+
this.browserStorage.cacheAuthorizeRequest(
290+
redirectRequest,
291+
pkceCodes.verifier
292+
);
280293

281294
const form = await Authorize.getEARForm(
282295
document,

lib/msal-browser/src/interaction_client/SilentIframeClient.ts

Lines changed: 68 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -235,9 +235,17 @@ export class SilentIframeClient extends StandardInteractionClient {
235235
this.performanceClient,
236236
correlationId
237237
)();
238+
const pkceCodes = await invokeAsync(
239+
generatePkceCodes,
240+
PerformanceEvents.GeneratePkceCodes,
241+
this.logger,
242+
this.performanceClient,
243+
correlationId
244+
)(this.performanceClient, this.logger, correlationId);
238245
const silentRequest = {
239246
...request,
240247
earJwk: earJwk,
248+
codeChallenge: pkceCodes.challenge,
241249
};
242250
const msalFrame = await invokeAsync(
243251
initiateEarRequest,
@@ -279,25 +287,66 @@ export class SilentIframeClient extends StandardInteractionClient {
279287
correlationId
280288
)(responseString, responseType, this.logger);
281289

282-
return invokeAsync(
283-
Authorize.handleResponseEAR,
284-
PerformanceEvents.HandleResponseEar,
285-
this.logger,
286-
this.performanceClient,
287-
correlationId
288-
)(
289-
silentRequest,
290-
serverParams,
291-
this.apiId,
292-
this.config,
293-
discoveredAuthority,
294-
this.browserStorage,
295-
this.nativeStorage,
296-
this.eventHandler,
297-
this.logger,
298-
this.performanceClient,
299-
this.platformAuthProvider
300-
);
290+
if (!serverParams.ear_jwe && serverParams.code) {
291+
// If server doesn't support EAR, they may fallback to auth code flow instead
292+
const authClient = await invokeAsync(
293+
this.createAuthCodeClient.bind(this),
294+
PerformanceEvents.StandardInteractionClientCreateAuthCodeClient,
295+
this.logger,
296+
this.performanceClient,
297+
correlationId
298+
)({
299+
serverTelemetryManager: this.initializeServerTelemetryManager(
300+
this.apiId
301+
),
302+
requestAuthority: request.authority,
303+
requestAzureCloudOptions: request.azureCloudOptions,
304+
requestExtraQueryParameters: request.extraQueryParameters,
305+
account: request.account,
306+
authority: discoveredAuthority,
307+
});
308+
309+
return invokeAsync(
310+
Authorize.handleResponseCode,
311+
PerformanceEvents.HandleResponseCode,
312+
this.logger,
313+
this.performanceClient,
314+
correlationId
315+
)(
316+
silentRequest,
317+
serverParams,
318+
pkceCodes.verifier,
319+
this.apiId,
320+
this.config,
321+
authClient,
322+
this.browserStorage,
323+
this.nativeStorage,
324+
this.eventHandler,
325+
this.logger,
326+
this.performanceClient,
327+
this.platformAuthProvider
328+
);
329+
} else {
330+
return invokeAsync(
331+
Authorize.handleResponseEAR,
332+
PerformanceEvents.HandleResponseEar,
333+
this.logger,
334+
this.performanceClient,
335+
correlationId
336+
)(
337+
silentRequest,
338+
serverParams,
339+
this.apiId,
340+
this.config,
341+
discoveredAuthority,
342+
this.browserStorage,
343+
this.nativeStorage,
344+
this.eventHandler,
345+
this.logger,
346+
this.performanceClient,
347+
this.platformAuthProvider
348+
);
349+
}
301350
}
302351

303352
/**

lib/msal-browser/src/interaction_client/StandardInteractionClient.ts

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
BaseAuthRequest,
2121
StringDict,
2222
CommonAuthorizationUrlRequest,
23+
Authority,
2324
} from "@azure/msal-common/browser";
2425
import { BaseInteractionClient } from "./BaseInteractionClient.js";
2526
import {
@@ -186,6 +187,7 @@ export abstract class StandardInteractionClient extends BaseInteractionClient {
186187
requestAzureCloudOptions?: AzureCloudOptions;
187188
requestExtraQueryParameters?: StringDict;
188189
account?: AccountInfo;
190+
authority?: Authority;
189191
}): Promise<AuthorizationCodeClient> {
190192
this.performanceClient.addQueueMeasurement(
191193
PerformanceEvents.StandardInteractionClientCreateAuthCodeClient,
@@ -222,6 +224,7 @@ export abstract class StandardInteractionClient extends BaseInteractionClient {
222224
requestAzureCloudOptions?: AzureCloudOptions;
223225
requestExtraQueryParameters?: StringDict;
224226
account?: AccountInfo;
227+
authority?: Authority;
225228
}): Promise<ClientConfiguration> {
226229
const {
227230
serverTelemetryManager,
@@ -235,18 +238,20 @@ export abstract class StandardInteractionClient extends BaseInteractionClient {
235238
PerformanceEvents.StandardInteractionClientGetClientConfiguration,
236239
this.correlationId
237240
);
238-
const discoveredAuthority = await invokeAsync(
239-
this.getDiscoveredAuthority.bind(this),
240-
PerformanceEvents.StandardInteractionClientGetDiscoveredAuthority,
241-
this.logger,
242-
this.performanceClient,
243-
this.correlationId
244-
)({
245-
requestAuthority,
246-
requestAzureCloudOptions,
247-
requestExtraQueryParameters,
248-
account,
249-
});
241+
const discoveredAuthority =
242+
params.authority ||
243+
(await invokeAsync(
244+
this.getDiscoveredAuthority.bind(this),
245+
PerformanceEvents.StandardInteractionClientGetDiscoveredAuthority,
246+
this.logger,
247+
this.performanceClient,
248+
this.correlationId
249+
)({
250+
requestAuthority,
251+
requestAzureCloudOptions,
252+
requestExtraQueryParameters,
253+
account,
254+
}));
250255
const logger = this.config.system.loggerOptions;
251256

252257
return {

lib/msal-browser/src/protocol/Authorize.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,13 @@ export async function getEARForm(
203203
);
204204
RequestParameterBuilder.addEARParameters(parameters, request.earJwk);
205205

206+
// Also add codeChallenge as backup in case EAR is not supported
207+
RequestParameterBuilder.addCodeChallengeParams(
208+
parameters,
209+
request.codeChallenge,
210+
Constants.S256_CODE_CHALLENGE_METHOD
211+
);
212+
206213
const queryParams = new Map<string, string>();
207214
RequestParameterBuilder.addExtraQueryParameters(
208215
queryParams,

lib/msal-browser/test/interaction_client/PopupClient.spec.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -999,6 +999,43 @@ describe("PopupClient", () => {
999999
expect(earFormSpy).toHaveBeenCalled();
10001000
});
10011001

1002+
it("EAR flow falls back to Auth Code if service returns code instead of ear_jwe", async () => {
1003+
const validRequest: PopupRequest = {
1004+
authority: TEST_CONFIG.validAuthority,
1005+
scopes: ["openid", "profile", "offline_access"],
1006+
correlationId: TEST_CONFIG.CORRELATION_ID,
1007+
redirectUri: window.location.href,
1008+
state: TEST_STATE_VALUES.USER_STATE,
1009+
nonce: ID_TOKEN_CLAIMS.nonce,
1010+
};
1011+
jest.spyOn(ProtocolUtils, "setRequestState").mockReturnValue(
1012+
TEST_STATE_VALUES.TEST_STATE_POPUP
1013+
);
1014+
jest.spyOn(
1015+
PopupClient.prototype,
1016+
"openSizedPopup"
1017+
).mockReturnValue(popupWindow);
1018+
const earFormSpy = jest
1019+
.spyOn(HTMLFormElement.prototype, "submit")
1020+
.mockImplementation(() => {
1021+
// Suppress navigation
1022+
});
1023+
jest.spyOn(
1024+
PopupClient.prototype,
1025+
"monitorPopupForHash"
1026+
).mockResolvedValue(
1027+
`#code=validCode&state=${TEST_STATE_VALUES.TEST_STATE_POPUP}`
1028+
);
1029+
jest.spyOn(
1030+
AuthorizeProtocol,
1031+
"handleResponseCode"
1032+
).mockResolvedValue(getTestAuthenticationResult());
1033+
1034+
const result = await pca.acquireTokenPopup(validRequest);
1035+
expect(result).toEqual(getTestAuthenticationResult());
1036+
expect(earFormSpy).toHaveBeenCalled();
1037+
});
1038+
10021039
it("throws error when ProtocolMode is set to EAR and httpMethod is set to GET", async () => {
10031040
const validRequest: PopupRequest = {
10041041
authority: TEST_CONFIG.validAuthority,

0 commit comments

Comments
 (0)