Skip to content

Commit b7536a1

Browse files
authored
EAR Flow Tests (#7670)
Adds tests for the EAR flow
1 parent 6ec8fd1 commit b7536a1

12 files changed

+846
-19
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "patch",
3+
"comment": "Export additional function internally for use in tests",
4+
"packageName": "@azure/msal-browser",
5+
"email": "[email protected]",
6+
"dependentChangeType": "patch"
7+
}

lib/msal-browser/src/crypto/BrowserCrypto.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ export async function generateEarKey(): Promise<string> {
247247
* @param earJwk
248248
* @returns
249249
*/
250-
async function importEarKey(earJwk: string): Promise<CryptoKey> {
250+
export async function importEarKey(earJwk: string): Promise<CryptoKey> {
251251
const b64DecodedJwk = base64Decode(earJwk);
252252
const jwkJson = JSON.parse(b64DecodedJwk);
253253
const rawKey = jwkJson.k;

lib/msal-browser/test/app/PublicClientApplication.spec.ts

+13-5
Original file line numberDiff line numberDiff line change
@@ -170,8 +170,6 @@ const testRequest: CommonAuthorizationUrlRequest = {
170170
};
171171

172172
describe("PublicClientApplication.ts Class Unit Tests", () => {
173-
// eslint-disable-next-line @typescript-eslint/no-var-requires
174-
globalThis.MessageChannel = require("worker_threads").MessageChannel; // jsdom does not include an implementation for MessageChannel
175173
let pca: PublicClientApplication;
176174
let browserStorage: BrowserCacheManager;
177175
beforeEach(async () => {
@@ -244,8 +242,6 @@ describe("PublicClientApplication.ts Class Unit Tests", () => {
244242
});
245243

246244
describe("initialize tests", () => {
247-
globalThis.MessageChannel = require("worker_threads").MessageChannel; // jsdom does not include an implementation for MessageChannel
248-
249245
beforeEach(() => {
250246
jest.spyOn(MessageEvent.prototype, "source", "get").mockReturnValue(
251247
window
@@ -2351,7 +2347,11 @@ describe("PublicClientApplication.ts Class Unit Tests", () => {
23512347
beforeEach(async () => {
23522348
const popupWindow = {
23532349
...window,
2350+
location: {
2351+
assign: () => {},
2352+
},
23542353
close: () => {},
2354+
focus: () => {},
23552355
};
23562356
// @ts-ignore
23572357
jest.spyOn(window, "open").mockReturnValue(popupWindow);
@@ -3064,10 +3064,14 @@ describe("PublicClientApplication.ts Class Unit Tests", () => {
30643064
TEST_CONFIG.TOKEN_TYPE_BEARER as AuthenticationScheme,
30653065
};
30663066

3067+
jest.spyOn(
3068+
PopupClient.prototype,
3069+
"monitorPopupForHash"
3070+
).mockRejectedValue("Not important for this test");
3071+
30673072
try {
30683073
await testPca.acquireTokenPopup(request);
30693074
} catch (e) {}
3070-
30713075
expect(spyPreGeneratePkceCodes).toHaveBeenCalledTimes(2);
30723076
expect(spyPopupClientAcquireToken).toHaveBeenCalledWith(
30733077
request,
@@ -3120,6 +3124,10 @@ describe("PublicClientApplication.ts Class Unit Tests", () => {
31203124
TEST_CONFIG.TOKEN_TYPE_BEARER as AuthenticationScheme,
31213125
};
31223126

3127+
jest.spyOn(
3128+
PopupClient.prototype,
3129+
"monitorPopupForHash"
3130+
).mockRejectedValue("Not important for this test");
31233131
try {
31243132
await testPca.acquireTokenPopup(request);
31253133
} catch (e) {}

lib/msal-browser/test/broker/NativeMessageHandler.spec.ts

-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ describe("NativeMessageHandler Tests", () => {
2222
let postMessageSpy: jest.SpyInstance;
2323
let mcPort: MessagePort;
2424
let cryptoInterface: CryptoOps;
25-
globalThis.MessageChannel = require("worker_threads").MessageChannel; // jsdom does not include an implementation for MessageChannel
2625

2726
beforeEach(() => {
2827
postMessageSpy = jest.spyOn(window, "postMessage");
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import {
2+
decryptEarResponse,
3+
generateEarKey,
4+
} from "../../src/crypto/BrowserCrypto.js";
5+
import { base64Decode } from "../../src/encode/Base64Decode.js";
6+
import { base64Encode, urlEncodeArr } from "../../src/encode/Base64Encode.js";
7+
import { BrowserAuthError, BrowserAuthErrorCodes } from "../../src/index.js";
8+
import {
9+
generateValidEarJWE,
10+
TEST_TOKEN_RESPONSE,
11+
validEarJWE,
12+
validEarJWK,
13+
} from "../utils/StringConstants.js";
14+
15+
describe("BrowserCrypto Tests", () => {
16+
describe("generateEarKey", () => {
17+
it("Returns Base64 encoded string of ear_jwk", async () => {
18+
const key = await window.crypto.subtle.generateKey(
19+
{ name: "AES-GCM", length: 256 },
20+
true,
21+
["encrypt", "decrypt"]
22+
);
23+
const rawKey = await window.crypto.subtle.exportKey("raw", key);
24+
const keyStr = urlEncodeArr(new Uint8Array(rawKey));
25+
26+
const genKeySpy = jest
27+
.spyOn(window.crypto.subtle, "generateKey")
28+
.mockResolvedValue(key);
29+
const exportKeySpy = jest
30+
.spyOn(window.crypto.subtle, "exportKey")
31+
.mockResolvedValue(rawKey);
32+
const encodedJwk = await generateEarKey();
33+
expect(genKeySpy).toHaveBeenCalledWith(
34+
{ name: "AES-GCM", length: 256 },
35+
true,
36+
["encrypt", "decrypt"]
37+
);
38+
expect(exportKeySpy).toHaveBeenCalledWith("raw", key);
39+
40+
const decodedJwk = base64Decode(encodedJwk);
41+
const jwk = JSON.parse(decodedJwk);
42+
43+
expect(jwk.alg).toEqual("dir");
44+
expect(jwk.kty).toEqual("oct");
45+
expect(jwk.k).toEqual(keyStr);
46+
});
47+
});
48+
49+
describe("decryptEarResponse", () => {
50+
it("Throws if ear_jwe has fewer than 5 parts", (done) => {
51+
decryptEarResponse(validEarJWK, "header.iv.ciphertext.tag").catch(
52+
(e) => {
53+
expect(e).toBeInstanceOf(BrowserAuthError);
54+
expect(e.errorCode).toBe(
55+
BrowserAuthErrorCodes.failedToDecryptEarResponse
56+
);
57+
expect(e.subError).toBe("jwe_length");
58+
done();
59+
}
60+
);
61+
});
62+
63+
it("Throws if ear_jwe has more than 5 parts", (done) => {
64+
decryptEarResponse(
65+
validEarJWK,
66+
"header..iv..ciphertext..tag"
67+
).catch((e) => {
68+
expect(e).toBeInstanceOf(BrowserAuthError);
69+
expect(e.errorCode).toBe(
70+
BrowserAuthErrorCodes.failedToDecryptEarResponse
71+
);
72+
expect(e.subError).toBe("jwe_length");
73+
done();
74+
});
75+
});
76+
77+
it("Throws if earJwk does not have a 'k' property", (done) => {
78+
const encodedJwk = base64Encode(
79+
JSON.stringify({ alg: "dir", kty: "oct", key: "testKey" })
80+
);
81+
decryptEarResponse(encodedJwk, validEarJWE).catch((e) => {
82+
expect(e).toBeInstanceOf(BrowserAuthError);
83+
expect(e.errorCode).toBe(
84+
BrowserAuthErrorCodes.failedToDecryptEarResponse
85+
);
86+
expect(e.subError).toBe("import_key");
87+
done();
88+
});
89+
});
90+
91+
it("Throws if earJwk cannot be B64 decoded", (done) => {
92+
decryptEarResponse(
93+
JSON.stringify({ alg: "dir", kty: "oct", k: "testKey" }),
94+
validEarJWE
95+
).catch((e) => {
96+
expect(e).toBeInstanceOf(BrowserAuthError);
97+
expect(e.errorCode).toBe(
98+
BrowserAuthErrorCodes.failedToDecryptEarResponse
99+
);
100+
expect(e.subError).toBe("import_key");
101+
done();
102+
});
103+
});
104+
105+
it("Throws if earJwk is not a JSON object", async () => {
106+
decryptEarResponse("notJSON", validEarJWE).catch((e) => {
107+
expect(e).toBeInstanceOf(BrowserAuthError);
108+
expect(e.errorCode).toBe(
109+
BrowserAuthErrorCodes.failedToDecryptEarResponse
110+
);
111+
expect(e.subError).toBe("import_key");
112+
});
113+
});
114+
115+
it("Throws if earJwk 'k' property is not a raw encryption key", (done) => {
116+
decryptEarResponse(
117+
base64Encode(
118+
JSON.stringify({ alg: "dir", kty: "oct", k: "testKey" })
119+
),
120+
validEarJWE
121+
).catch((e) => {
122+
expect(e).toBeInstanceOf(BrowserAuthError);
123+
expect(e.errorCode).toBe(
124+
BrowserAuthErrorCodes.failedToDecryptEarResponse
125+
);
126+
expect(e.subError).toBe("import_key");
127+
done();
128+
});
129+
});
130+
131+
it("Throws if ear_jwe cannot be decrypted with the provided key", (done) => {
132+
generateEarKey().then((jwk: string) => {
133+
decryptEarResponse(jwk, validEarJWE).catch((e) => {
134+
expect(e).toBeInstanceOf(BrowserAuthError);
135+
expect(e.errorCode).toBe(
136+
BrowserAuthErrorCodes.failedToDecryptEarResponse
137+
);
138+
expect(e.subError).toBe("decrypt");
139+
done();
140+
});
141+
});
142+
});
143+
144+
it("Successfully decrypts ear_jwe with given earJwk", async () => {
145+
const jwe = await generateValidEarJWE(
146+
JSON.stringify(TEST_TOKEN_RESPONSE.body),
147+
validEarJWK
148+
);
149+
const decryptedString = await decryptEarResponse(validEarJWK, jwe);
150+
expect(JSON.parse(decryptedString)).toEqual(
151+
TEST_TOKEN_RESPONSE.body
152+
);
153+
});
154+
});
155+
});

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

-2
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,6 @@ const testAccessTokenEntity: AccessTokenEntity = {
110110
};
111111

112112
describe("NativeInteractionClient Tests", () => {
113-
globalThis.MessageChannel = require("worker_threads").MessageChannel; // jsdom does not include an implementation for MessageChannel
114-
115113
let pca: PublicClientApplication;
116114
let nativeInteractionClient: NativeInteractionClient;
117115

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

+73-2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ import {
1717
TEST_SSH_VALUES,
1818
TEST_TOKEN_RESPONSE,
1919
ID_TOKEN_CLAIMS,
20+
validEarJWK,
21+
getTestAuthenticationResult,
22+
validEarJWE,
2023
} from "../utils/StringConstants.js";
2124
import {
2225
Constants,
@@ -53,14 +56,16 @@ import {
5356
BrowserAuthError,
5457
createBrowserAuthError,
5558
BrowserAuthErrorMessage,
59+
BrowserAuthErrorCodes,
5660
} from "../../src/error/BrowserAuthError.js";
5761
import { InteractionHandler } from "../../src/interaction_handler/InteractionHandler.js";
5862
import { getDefaultPerformanceClient } from "../utils/TelemetryUtils.js";
5963
import { AuthenticationResult } from "../../src/response/AuthenticationResult.js";
6064
import { BrowserCacheManager } from "../../src/cache/BrowserCacheManager.js";
61-
import { BrowserAuthErrorCodes, BrowserUtils } from "../../src/index.js";
65+
import * as BrowserUtils from "../../src/utils/BrowserUtils.js";
6266
import { FetchClient } from "../../src/network/FetchClient.js";
6367
import { TestTimeUtils } from "msal-test-utils";
68+
import { PopupRequest } from "../../src/request/PopupRequest.js";
6469

6570
const testPopupWondowDefaults = {
6671
height: BrowserConstants.POPUP_HEIGHT,
@@ -70,7 +75,6 @@ const testPopupWondowDefaults = {
7075
};
7176

7277
describe("PopupClient", () => {
73-
globalThis.MessageChannel = require("worker_threads").MessageChannel; // jsdom does not include an implementation for MessageChannel
7478
let popupClient: PopupClient;
7579
let pca: PublicClientApplication;
7680
let browserCacheManager: BrowserCacheManager;
@@ -825,6 +829,73 @@ describe("PopupClient", () => {
825829
expect(e).toEqual(testError);
826830
}
827831
});
832+
833+
describe("EAR Flow Tests", () => {
834+
let popupWindow: Window;
835+
beforeAll(() => {
836+
jest.useFakeTimers();
837+
});
838+
839+
afterAll(() => {
840+
jest.useRealTimers();
841+
});
842+
843+
beforeEach(async () => {
844+
pca = new PublicClientApplication({
845+
auth: {
846+
clientId: TEST_CONFIG.MSAL_CLIENT_ID,
847+
protocolMode: ProtocolMode.EAR,
848+
},
849+
});
850+
await pca.initialize();
851+
852+
jest.spyOn(BrowserCrypto, "generateEarKey").mockResolvedValue(
853+
validEarJWK
854+
);
855+
popupWindow = {
856+
...window,
857+
//@ts-ignore
858+
location: {
859+
assign: () => {},
860+
},
861+
focus: () => {},
862+
close: () => {},
863+
};
864+
});
865+
866+
it("Invokes EAR flow when protocolMode is set to EAR", async () => {
867+
const validRequest: PopupRequest = {
868+
authority: TEST_CONFIG.validAuthority,
869+
scopes: ["openid", "profile", "offline_access"],
870+
correlationId: TEST_CONFIG.CORRELATION_ID,
871+
redirectUri: window.location.href,
872+
state: TEST_STATE_VALUES.USER_STATE,
873+
nonce: ID_TOKEN_CLAIMS.nonce,
874+
};
875+
jest.spyOn(ProtocolUtils, "setRequestState").mockReturnValue(
876+
TEST_STATE_VALUES.TEST_STATE_POPUP
877+
);
878+
jest.spyOn(
879+
PopupClient.prototype,
880+
"openSizedPopup"
881+
).mockReturnValue(popupWindow);
882+
const earFormSpy = jest
883+
.spyOn(HTMLFormElement.prototype, "submit")
884+
.mockImplementation(() => {
885+
// Suppress navigation
886+
});
887+
jest.spyOn(
888+
PopupClient.prototype,
889+
"monitorPopupForHash"
890+
).mockResolvedValue(
891+
`#ear_jwe=${validEarJWE}&state=${TEST_STATE_VALUES.TEST_STATE_POPUP}`
892+
);
893+
894+
const result = await pca.acquireTokenPopup(validRequest);
895+
expect(result).toEqual(getTestAuthenticationResult());
896+
expect(earFormSpy).toHaveBeenCalled();
897+
});
898+
});
828899
});
829900

830901
describe("logout", () => {

0 commit comments

Comments
 (0)