UNPKG

47.8 kBPlain TextView Raw
1/*
2 * Copyright 2021 Inrupt Inc.
3 *
4 * Permission is hereby granted, free of charge, to any person obtaining a copy
5 * of this software and associated documentation files (the "Software"), to deal in
6 * the Software without restriction, including without limitation the rights to use,
7 * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
8 * Software, and to permit persons to whom the Software is furnished to do so,
9 * subject to the following conditions:
10 *
11 * The above copyright notice and this permission notice shall be included in
12 * all copies or substantial portions of the Software.
13 *
14 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
15 * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
16 * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
17 * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
18 * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
19 * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
20 */
21
22import { jest, it, describe, expect } from "@jest/globals";
23import {
24 ISessionInfo,
25 StorageUtility,
26 USER_SESSION_PREFIX,
27} from "@inrupt/solid-client-authn-core";
28import { mockClientAuthentication } from "../src/__mocks__/ClientAuthentication";
29import { Session } from "../src/Session";
30import { mockStorage } from "../../core/src/storage/__mocks__/StorageUtility";
31import { LocalStorageMock } from "../src/storage/__mocks__/LocalStorage";
32import { mockSessionInfoManager } from "../src/sessionInfo/__mocks__/SessionInfoManager";
33import { KEY_CURRENT_SESSION, KEY_CURRENT_URL } from "../src/constant";
34
35/* eslint-disable @typescript-eslint/ban-ts-comment */
36
37const mockLocalStorage = (stored: Record<string, string>) => {
38 Object.defineProperty(window, "localStorage", {
39 value: new LocalStorageMock(stored),
40 writable: true,
41 });
42};
43
44const mockLocation = (mockedLocation: string) => {
45 // We can't simply do 'window.location.href = defaultLocation;', as that
46 // causes our test environment to try to navigate to the new location (as
47 // a browser would do). So instead we need to reset 'window.location' as a
48 // whole, and reset it's value after our test. We also need to
49 // replace the 'history' object, otherwise this error happens: "SecurityError:
50 // replaceState cannot update history to a URL which differs in components other
51 // than in path, query, or fragment.""
52
53 // (window as any) is used to override the window type definition and
54 // allow location to be written.
55 // eslint-disable-next-line @typescript-eslint/no-explicit-any
56 delete (window as any).location;
57 // eslint-disable-next-line @typescript-eslint/no-explicit-any
58 delete (window as any).history.replaceState;
59
60 // Set our window's location to our test value.
61 window.location = {
62 href: mockedLocation,
63 } as Location;
64 window.history.replaceState = jest.fn();
65};
66
67jest.mock("../src/iframe");
68
69describe("Session", () => {
70 describe("constructor", () => {
71 it("accepts an empty config", async () => {
72 const mySession = new Session({});
73 expect(mySession.info.isLoggedIn).toEqual(false);
74 expect(mySession.info.sessionId).toBeDefined();
75 });
76
77 it("accepts no config", async () => {
78 const mySession = new Session();
79 expect(mySession.info.isLoggedIn).toEqual(false);
80 expect(mySession.info.sessionId).toBeDefined();
81 });
82
83 it("does not generate a session ID if one is provided", () => {
84 const mySession = new Session({}, "mySession");
85 expect(mySession.info.sessionId).toEqual("mySession");
86 });
87
88 it("accepts input storage", async () => {
89 const insecureStorage = mockStorage({});
90 const secureStorage = mockStorage({});
91 const mySession = new Session({
92 insecureStorage,
93 secureStorage,
94 });
95 const clearSecureStorage = jest.spyOn(secureStorage, "delete");
96 const clearInsecureStorage = jest.spyOn(insecureStorage, "delete");
97 await mySession.logout();
98 expect(clearSecureStorage).toHaveBeenCalled();
99 expect(clearInsecureStorage).toHaveBeenCalled();
100 });
101
102 it("accepts session info", () => {
103 const mySession = new Session({
104 sessionInfo: {
105 sessionId: "mySession",
106 isLoggedIn: false,
107 webId: "https://some.webid",
108 },
109 });
110 expect(mySession.info.isLoggedIn).toEqual(false);
111 expect(mySession.info.sessionId).toEqual("mySession");
112 expect(mySession.info.webId).toEqual("https://some.webid");
113 });
114 });
115
116 describe("login", () => {
117 it("wraps up ClientAuthentication login", () => {
118 const clientAuthentication = mockClientAuthentication();
119 const clientAuthnLogin = jest.spyOn(clientAuthentication, "login");
120 const mySession = new Session({ clientAuthentication });
121 // login never resolves if there are no errors,
122 // because a login redirects the user away from the page:
123 // eslint-disable-next-line no-void
124 void mySession.login({});
125 expect(clientAuthnLogin).toHaveBeenCalled();
126 });
127
128 it("Uses the token type provided (if any)", () => {
129 const clientAuthentication = mockClientAuthentication();
130 const clientAuthnLogin = jest.spyOn(clientAuthentication, "login");
131 const mySession = new Session({ clientAuthentication });
132 // login never resolves if there are no errors,
133 // because a login redirects the user away from the page:
134 // eslint-disable-next-line no-void
135 void mySession.login({
136 tokenType: "Bearer",
137 });
138 expect(clientAuthnLogin).toHaveBeenCalledWith(
139 expect.objectContaining({
140 tokenType: "Bearer",
141 })
142 );
143 });
144
145 it("preserves a binding to its Session instance", () => {
146 const clientAuthentication = mockClientAuthentication();
147 const clientAuthnLogin = jest.spyOn(clientAuthentication, "login");
148 const mySession = new Session({ clientAuthentication });
149 const objectWithLogin = {
150 login: mySession.login,
151 };
152 // login never resolves if there are no errors,
153 // because a login redirects the user away from the page:
154 // eslint-disable-next-line no-void
155 void objectWithLogin.login({});
156 expect(clientAuthnLogin).toHaveBeenCalled();
157 });
158 });
159
160 describe("logout", () => {
161 it("wraps up ClientAuthentication logout", async () => {
162 const clientAuthentication = mockClientAuthentication();
163 const clientAuthnLogout = jest.spyOn(clientAuthentication, "logout");
164 const mySession = new Session({ clientAuthentication });
165 await mySession.logout();
166 expect(clientAuthnLogout).toHaveBeenCalled();
167 });
168
169 it("preserves a binding to its Session instance", async () => {
170 const clientAuthentication = mockClientAuthentication();
171 const clientAuthnLogout = jest.spyOn(clientAuthentication, "logout");
172 const mySession = new Session({ clientAuthentication });
173 const objectWithLogout = {
174 logout: mySession.logout,
175 };
176 await objectWithLogout.logout();
177 expect(clientAuthnLogout).toHaveBeenCalled();
178 });
179
180 it("updates the session's info", async () => {
181 const clientAuthentication = mockClientAuthentication();
182 const mySession = new Session({ clientAuthentication });
183 mySession.info.isLoggedIn = true;
184 await mySession.logout();
185 expect(mySession.info.isLoggedIn).toEqual(false);
186 });
187 });
188
189 describe("fetch", () => {
190 it("wraps up ClientAuthentication fetch if logged in", async () => {
191 window.fetch = jest.fn();
192 const clientAuthentication = mockClientAuthentication();
193 const clientAuthnFetch = jest.spyOn(clientAuthentication, "fetch");
194 const mySession = new Session({ clientAuthentication });
195 mySession.info.isLoggedIn = true;
196 await mySession.fetch("https://some.url");
197 expect(clientAuthnFetch).toHaveBeenCalled();
198 });
199
200 it("preserves a binding to its Session instance", async () => {
201 window.fetch = jest.fn();
202 const clientAuthentication = mockClientAuthentication();
203 const clientAuthnFetch = jest.spyOn(clientAuthentication, "fetch");
204 const mySession = new Session({ clientAuthentication });
205 mySession.info.isLoggedIn = true;
206 const objectWithFetch = {
207 fetch: mySession.fetch,
208 };
209 await objectWithFetch.fetch("https://some.url");
210 expect(clientAuthnFetch).toHaveBeenCalled();
211 });
212
213 it("does not rebind window.fetch if logged out", async () => {
214 window.fetch = jest.fn();
215 const clientAuthentication = mockClientAuthentication();
216 const mySession = new Session({ clientAuthentication });
217 await mySession.fetch("https://some.url/");
218 expect(window.fetch).toHaveBeenCalled();
219 expect((window.fetch as jest.Mock).mock.instances).toEqual([window]);
220 });
221 });
222
223 describe("handleIncomingRedirect", () => {
224 it("uses current window location as default redirect URL", async () => {
225 mockLocation("https://some.url");
226 const clientAuthentication = mockClientAuthentication();
227 const incomingRedirectHandler = jest.spyOn(
228 clientAuthentication,
229 "handleIncomingRedirect"
230 );
231
232 const mySession = new Session({ clientAuthentication });
233 await mySession.handleIncomingRedirect();
234 expect(incomingRedirectHandler).toHaveBeenCalledWith("https://some.url");
235 });
236
237 it("wraps up ClientAuthentication handleIncomingRedirect", async () => {
238 mockLocation("https://some.url");
239 const clientAuthentication = mockClientAuthentication();
240 const incomingRedirectHandler = jest.spyOn(
241 clientAuthentication,
242 "handleIncomingRedirect"
243 );
244 const mySession = new Session({ clientAuthentication });
245 await mySession.handleIncomingRedirect("https://some.url");
246 expect(incomingRedirectHandler).toHaveBeenCalled();
247 });
248
249 it("updates the session's info if relevant", async () => {
250 const clientAuthentication = mockClientAuthentication();
251 clientAuthentication.handleIncomingRedirect = jest.fn(
252 async (_url: string) => {
253 return {
254 isLoggedIn: true,
255 sessionId: "a session ID",
256 webId: "https://some.webid#them",
257 };
258 }
259 );
260 const mySession = new Session({ clientAuthentication });
261 expect(mySession.info.isLoggedIn).toEqual(false);
262 await mySession.handleIncomingRedirect("https://some.url");
263 expect(mySession.info.isLoggedIn).toEqual(true);
264 expect(mySession.info.sessionId).toEqual("a session ID");
265 expect(mySession.info.webId).toEqual("https://some.webid#them");
266 });
267
268 it("directly returns the session's info if already logged in", async () => {
269 const clientAuthentication = mockClientAuthentication();
270 clientAuthentication.handleIncomingRedirect = jest.fn(
271 async (_url: string) => {
272 return {
273 isLoggedIn: true,
274 sessionId: "a session ID",
275 webId: "https://some.webid#them",
276 };
277 }
278 );
279 const mySession = new Session({ clientAuthentication });
280 await mySession.handleIncomingRedirect("https://some.url");
281 expect(mySession.info.isLoggedIn).toEqual(true);
282 await mySession.handleIncomingRedirect("https://some.url");
283 // The second request should not hit the wrapped function
284 expect(clientAuthentication.handleIncomingRedirect).toHaveBeenCalledTimes(
285 1
286 );
287 });
288
289 it("logs the session out when its tokens expire", async () => {
290 jest.useFakeTimers();
291 const MOCK_TIMESTAMP = 10000;
292 jest.spyOn(Date, "now").mockReturnValueOnce(MOCK_TIMESTAMP);
293 const clientAuthentication = mockClientAuthentication();
294 const incomingRedirectHandler = jest.spyOn(
295 clientAuthentication,
296 "handleIncomingRedirect"
297 );
298 incomingRedirectHandler.mockResolvedValueOnce({
299 isLoggedIn: true,
300 sessionId: "Arbitrary session ID",
301 expirationDate: MOCK_TIMESTAMP + 1337,
302 });
303 const mySession = new Session({ clientAuthentication });
304 await mySession.handleIncomingRedirect("https://some.url");
305 expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 1337);
306 expect(mySession.info.isLoggedIn).toBe(true);
307
308 // Usually, we'd be able to use `jest.runAllTimers()` here. However,
309 // logout happens asynchronously. While this is inconsequential in
310 // running apps (the timeout complets asynchronously anyway), here, we can
311 // take advantage that we return the Promise from the callback in
312 // `setTimeout`, so that we can `await` it in this test before checking
313 // whether logout was successful:
314 const expireTimeout = (
315 setTimeout as unknown as jest.Mock<typeof setTimeout>
316 ).mock.calls[0][0];
317 await expireTimeout();
318 expect(mySession.info.isLoggedIn).toBe(false);
319 });
320
321 it("leaves the session's info unchanged if no session is obtained after redirect", async () => {
322 const clientAuthentication = mockClientAuthentication();
323 clientAuthentication.handleIncomingRedirect = jest.fn(
324 async (_url: string) => undefined
325 );
326 const mySession = new Session({ clientAuthentication }, "mySession");
327 await mySession.handleIncomingRedirect("https://some.url");
328 expect(mySession.info.isLoggedIn).toEqual(false);
329 expect(mySession.info.sessionId).toEqual("mySession");
330 });
331
332 it("prevents from hitting the token endpoint twice with the same auth code", async () => {
333 const clientAuthentication = mockClientAuthentication();
334 const obtainedSession: ISessionInfo = {
335 isLoggedIn: true,
336 sessionId: "mySession",
337 };
338 let continueAfterSecondRequest: (value?: unknown) => void;
339 const blockingResponse = new Promise((resolve, _reject) => {
340 continueAfterSecondRequest = resolve;
341 });
342 const blockingRequest = async (): Promise<ISessionInfo> => {
343 await blockingResponse;
344 return obtainedSession;
345 };
346 // The ClientAuthn's handleIncomingRedirect will only return when the
347 // second Session's handleIncomingRedirect has been called.
348 clientAuthentication.handleIncomingRedirect = jest.fn(blockingRequest);
349 const mySession = new Session({ clientAuthentication });
350 const firstTokenRequest = mySession.handleIncomingRedirect(
351 "https://my.app/?code=someCode&state=arizona"
352 );
353 const secondTokenRequest = mySession.handleIncomingRedirect(
354 "https://my.app/?code=someCode&state=arizona"
355 );
356 // We know that it has been set by the call to `blockingRequest`.
357 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
358 continueAfterSecondRequest!();
359 const tokenRequests = await Promise.all([
360 firstTokenRequest,
361 secondTokenRequest,
362 ]);
363 // One of the two token requests should not have reached the token endpoint
364 // because the other was pending.
365 expect(tokenRequests).toContain(undefined);
366 });
367
368 it("preserves a binding to its Session instance", async () => {
369 const clientAuthentication = mockClientAuthentication();
370 const incomingRedirectHandler = jest.spyOn(
371 clientAuthentication,
372 "handleIncomingRedirect"
373 );
374 const mySession = new Session({ clientAuthentication });
375 const objectWithHandleIncomingRedirect = {
376 handleIncomingRedirect: mySession.handleIncomingRedirect,
377 };
378 await objectWithHandleIncomingRedirect.handleIncomingRedirect(
379 "https://some.url"
380 );
381 expect(incomingRedirectHandler).toHaveBeenCalled();
382 });
383
384 // This workaround will be removed after we've settled on an API that allows us to silently
385 // re-activate the user's session after a refresh:
386 describe("(using the temporary workaround for losing a session after refresh)", () => {
387 it("does not report the user to be logged in when the workaround cookie is not present, and just executes the redirect", async () => {
388 const clientAuthentication = mockClientAuthentication();
389 clientAuthentication.handleIncomingRedirect = jest.fn(
390 async (_url: string) => undefined
391 );
392 const mySession = new Session({ clientAuthentication });
393 await mySession.handleIncomingRedirect({
394 url: "https://some.url",
395 useEssSession: true,
396 });
397 expect(mySession.info.isLoggedIn).toEqual(false);
398 expect(mySession.info.webId).toBeUndefined();
399 expect(
400 clientAuthentication.handleIncomingRedirect
401 ).toHaveBeenCalledTimes(1);
402 // The workaround should be enabled by default
403 expect(
404 window.localStorage.getItem("tmp-resource-server-session-enabled")
405 ).toEqual("true");
406 });
407
408 it("re-initialises the user's WebID without redirection if the proper cookie is set", async () => {
409 mockLocalStorage({
410 "tmp-resource-server-session-info": JSON.stringify({
411 webId: "https://my.pod/profile#me",
412 sessions: {
413 "https://my.pod/": { expiration: 9000000000000 },
414 },
415 }),
416 });
417 const clientAuthentication = mockClientAuthentication();
418 clientAuthentication.handleIncomingRedirect = jest.fn();
419 const mySession = new Session({ clientAuthentication });
420 await mySession.handleIncomingRedirect({
421 url: "https://some.url",
422 useEssSession: true,
423 });
424 expect(mySession.info.isLoggedIn).toEqual(true);
425 expect(mySession.info.webId).toEqual("https://my.pod/profile#me");
426 expect(
427 clientAuthentication.handleIncomingRedirect
428 ).toHaveBeenCalledTimes(0);
429 // The workaround should be enabled by default
430 expect(
431 window.localStorage.getItem("tmp-resource-server-session-enabled")
432 ).toEqual("true");
433 });
434
435 it("does not attempt to use the workaround if the long-term solution (silent refresh) is enabled", async () => {
436 mockLocalStorage({
437 "tmp-resource-server-session-info": JSON.stringify({
438 webId: "https://my.pod/profile#me",
439 sessions: {
440 "https://my.pod/": { expiration: 9000000000000 },
441 },
442 }),
443 });
444 const clientAuthentication = mockClientAuthentication();
445 clientAuthentication.handleIncomingRedirect = jest.fn();
446 const mySession = new Session({ clientAuthentication });
447 await mySession.handleIncomingRedirect({
448 url: "https://some.url",
449 restorePreviousSession: true,
450 });
451 expect(mySession.info.isLoggedIn).toBe(false);
452 expect(mySession.info.webId).toBeUndefined();
453 expect(
454 clientAuthentication.handleIncomingRedirect
455 ).toHaveBeenCalledTimes(1);
456 expect(
457 window.localStorage.getItem("tmp-resource-server-session-enabled")
458 ).toEqual("false");
459 });
460
461 it("overrides the workaround if both silent refresh and the ESS session are enabled", async () => {
462 mockLocalStorage({
463 "tmp-resource-server-session-info": JSON.stringify({
464 webId: "https://my.pod/profile#me",
465 sessions: {
466 "https://my.pod/": { expiration: 9000000000000 },
467 },
468 }),
469 });
470 const clientAuthentication = mockClientAuthentication();
471 clientAuthentication.handleIncomingRedirect = jest.fn();
472 const mySession = new Session({ clientAuthentication });
473 await mySession.handleIncomingRedirect({
474 url: "https://some.url",
475 restorePreviousSession: true,
476 useEssSession: true,
477 });
478 expect(mySession.info.isLoggedIn).toBe(false);
479 expect(mySession.info.webId).toBeUndefined();
480 expect(
481 clientAuthentication.handleIncomingRedirect
482 ).toHaveBeenCalledTimes(1);
483 expect(
484 window.localStorage.getItem("tmp-resource-server-session-enabled")
485 ).toEqual("false");
486 });
487
488 it("does not attempt to use the workaround if it is not explicitly enabled", async () => {
489 mockLocalStorage({
490 "tmp-resource-server-session-info": JSON.stringify({
491 webId: "https://my.pod/profile#me",
492 sessions: {
493 "https://my.pod/": { expiration: 9000000000000 },
494 },
495 }),
496 });
497 const clientAuthentication = mockClientAuthentication();
498 clientAuthentication.handleIncomingRedirect = jest.fn();
499 const mySession = new Session({ clientAuthentication });
500 await mySession.handleIncomingRedirect({
501 url: "https://some.url",
502 });
503 expect(mySession.info.isLoggedIn).toBe(false);
504 expect(mySession.info.webId).toBeUndefined();
505 expect(
506 clientAuthentication.handleIncomingRedirect
507 ).toHaveBeenCalledTimes(1);
508 expect(
509 window.localStorage.getItem("tmp-resource-server-session-enabled")
510 ).toEqual("false");
511 });
512
513 it("does not attempt to use the workaround if it is explicitly disabled", async () => {
514 mockLocalStorage({
515 "tmp-resource-server-session-info": JSON.stringify({
516 webId: "https://my.pod/profile#me",
517 sessions: {
518 "https://my.pod/": { expiration: 9000000000000 },
519 },
520 }),
521 });
522 const clientAuthentication = mockClientAuthentication();
523 clientAuthentication.handleIncomingRedirect = jest.fn();
524 const mySession = new Session({ clientAuthentication });
525 await mySession.handleIncomingRedirect({
526 url: "https://some.url",
527 useEssSession: false,
528 });
529 expect(mySession.info.isLoggedIn).toBe(false);
530 expect(mySession.info.webId).toBeUndefined();
531 expect(
532 clientAuthentication.handleIncomingRedirect
533 ).toHaveBeenCalledTimes(1);
534 expect(
535 window.localStorage.getItem("tmp-resource-server-session-enabled")
536 ).toEqual("false");
537 });
538
539 it("does not mark an almost-expired session as logged in", async () => {
540 mockLocalStorage({
541 "tmp-resource-server-session-info": JSON.stringify({
542 webId: "https://my.pod/profile#me",
543 sessions: {
544 "https://my.pod/": { expiration: Date.now() + 10000 },
545 },
546 }),
547 });
548
549 const clientAuthentication = mockClientAuthentication();
550 clientAuthentication.handleIncomingRedirect = jest.fn();
551 const mySession = new Session({ clientAuthentication });
552 await mySession.handleIncomingRedirect({
553 url: "https://some.url",
554 useEssSession: true,
555 });
556 expect(mySession.info.isLoggedIn).toEqual(false);
557 expect(mySession.info.webId).toBeUndefined();
558 expect(
559 clientAuthentication.handleIncomingRedirect
560 ).toHaveBeenCalledTimes(1);
561 });
562
563 it("logs you in even if your Solid Identity Provider is not your Resource server", async () => {
564 mockLocalStorage({
565 "tmp-resource-server-session-info": JSON.stringify({
566 webId: "https://my.pod/profile#me",
567 sessions: {
568 "https://not.my.pod/": { expiration: 9000000000000 },
569 },
570 }),
571 });
572 const clientAuthentication = mockClientAuthentication();
573 clientAuthentication.handleIncomingRedirect = jest.fn();
574 const mySession = new Session({ clientAuthentication });
575 await mySession.handleIncomingRedirect({
576 url: "https://some.url",
577 useEssSession: true,
578 });
579 expect(mySession.info.isLoggedIn).toEqual(true);
580 expect(mySession.info.webId).toEqual("https://my.pod/profile#me");
581 expect(
582 clientAuthentication.handleIncomingRedirect
583 ).toHaveBeenCalledTimes(0);
584 });
585
586 it("does not log the user in if the data in the storage was invalid", async () => {
587 mockLocalStorage({
588 "tmp-resource-server-session-info": JSON.stringify({
589 webId: "https://my.pod/profile#me",
590 // `sessions` key is intentionally missing
591 }),
592 });
593 const clientAuthentication = mockClientAuthentication();
594 clientAuthentication.handleIncomingRedirect = jest.fn();
595 const mySession = new Session({ clientAuthentication });
596 await mySession.handleIncomingRedirect({
597 url: "https://some.url",
598 useEssSession: true,
599 });
600 expect(mySession.info.isLoggedIn).toEqual(false);
601 expect(mySession.info.webId).toBeUndefined();
602 expect(
603 clientAuthentication.handleIncomingRedirect
604 ).toHaveBeenCalledTimes(1);
605 });
606 });
607
608 it("posts the redirect IRI to the parent if in an iframe", async () => {
609 // Pretend we are in an iframe.
610 const frameElement = jest.spyOn(window, "frameElement", "get");
611 frameElement.mockReturnValueOnce({} as Element);
612
613 const mySession = new Session({}, "mySession");
614 const iframe = jest.requireMock("../src/iframe");
615 // eslint-disable-next-line @typescript-eslint/no-explicit-any
616 const postIri = jest.spyOn(iframe as any, "postRedirectUrlToParent");
617 await mySession.handleIncomingRedirect({
618 url: "https://some.redirect.url?code=someCode&state=someState",
619 });
620 expect(postIri).toHaveBeenCalledWith(
621 "https://some.redirect.url?code=someCode&state=someState"
622 );
623 });
624 });
625
626 describe("silent authentication", () => {
627 it("does nothing if no previous session is available", async () => {
628 mockLocalStorage({});
629 mockLocation("https://mock.current/location");
630 const mockedStorage = new StorageUtility(
631 mockStorage({}),
632 mockStorage({})
633 );
634 const clientAuthentication = mockClientAuthentication({
635 sessionInfoManager: mockSessionInfoManager(mockedStorage),
636 });
637 const incomingRedirectPromise = Promise.resolve();
638 clientAuthentication.handleIncomingRedirect = jest
639 .fn()
640 .mockReturnValue(
641 incomingRedirectPromise
642 ) as typeof clientAuthentication.handleIncomingRedirect;
643 const mySession = new Session({ clientAuthentication });
644 // eslint-disable-next-line no-void
645 void mySession.handleIncomingRedirect({
646 url: "https://some.redirect/url",
647 restorePreviousSession: true,
648 });
649 expect(window.localStorage.getItem(KEY_CURRENT_URL)).toBeNull();
650 });
651
652 it("saves current window location if we have stored ID Token", async () => {
653 const sessionId = "mySilentSession";
654 mockLocalStorage({
655 [KEY_CURRENT_SESSION]: sessionId,
656 });
657 mockLocation("https://mock.current/location");
658 const mockedStorage = new StorageUtility(
659 mockStorage({
660 [`${USER_SESSION_PREFIX}:${sessionId}`]: {
661 isLoggedIn: "true",
662 },
663 }),
664 mockStorage({
665 [`${USER_SESSION_PREFIX}:${sessionId}`]: {
666 idToken: "value doesn't matter",
667 },
668 })
669 );
670 const clientAuthentication = mockClientAuthentication({
671 sessionInfoManager: mockSessionInfoManager(mockedStorage),
672 });
673 const incomingRedirectPromise = Promise.resolve();
674 clientAuthentication.handleIncomingRedirect = jest
675 .fn()
676 .mockReturnValueOnce(
677 incomingRedirectPromise
678 ) as typeof clientAuthentication.handleIncomingRedirect;
679 const validateCurrentSessionPromise = Promise.resolve(
680 "https://some.issuer/"
681 );
682 clientAuthentication.validateCurrentSession = jest
683 .fn()
684 .mockReturnValue(
685 validateCurrentSessionPromise
686 ) as typeof clientAuthentication.validateCurrentSession;
687
688 const mySession = new Session({ clientAuthentication });
689 // eslint-disable-next-line no-void
690 void mySession.handleIncomingRedirect({
691 url: "https://some.redirect/url",
692 restorePreviousSession: true,
693 });
694 await incomingRedirectPromise;
695 await validateCurrentSessionPromise;
696 expect(window.localStorage.getItem(KEY_CURRENT_URL)).toEqual(
697 "https://mock.current/location"
698 );
699 });
700
701 it("does nothing if no ID token is available", async () => {
702 const sessionId = "mySilentSession";
703 mockLocalStorage({
704 [KEY_CURRENT_SESSION]: sessionId,
705 });
706 mockLocation("https://mock.current/location");
707 const mockedStorage = new StorageUtility(
708 mockStorage({
709 [`${USER_SESSION_PREFIX}:${sessionId}`]: {
710 isLoggedIn: "true",
711 },
712 }),
713 mockStorage({
714 [`${USER_SESSION_PREFIX}:${sessionId}`]: {
715 clientId: "value doesn't matter",
716 },
717 })
718 );
719 const clientAuthentication = mockClientAuthentication({
720 sessionInfoManager: mockSessionInfoManager(mockedStorage),
721 });
722 clientAuthentication.handleIncomingRedirect = (
723 jest
724 // eslint-disable-next-line @typescript-eslint/no-explicit-any
725 .fn() as any
726 ).mockResolvedValue(
727 undefined
728 ) as typeof clientAuthentication.handleIncomingRedirect;
729 const mySession = new Session({ clientAuthentication });
730 // eslint-disable-next-line no-void
731 void mySession.handleIncomingRedirect({
732 url: "https://some.redirect/url",
733 restorePreviousSession: true,
734 });
735 expect(window.localStorage.getItem(KEY_CURRENT_URL)).toBeNull();
736 });
737
738 it("triggers silent authentication if a valid ID token is stored", async () => {
739 const sessionId = "mySession";
740 mockLocalStorage({
741 [KEY_CURRENT_SESSION]: sessionId,
742 });
743 mockLocation("https://mock.current/location");
744 const mockedStorage = new StorageUtility(
745 mockStorage({
746 [`${USER_SESSION_PREFIX}:${sessionId}`]: {
747 isLoggedIn: "true",
748 },
749 }),
750 mockStorage({})
751 );
752 const clientAuthentication = mockClientAuthentication({
753 sessionInfoManager: mockSessionInfoManager(mockedStorage),
754 });
755 const validateCurrentSessionPromise = Promise.resolve({
756 issuer: "https://some.issuer",
757 clientAppId: "some client ID",
758 clientAppSecret: "some client secret",
759 redirectUrl: "https://some.redirect/url",
760 tokenType: "DPoP",
761 });
762 clientAuthentication.validateCurrentSession = jest
763 .fn()
764 .mockReturnValue(
765 validateCurrentSessionPromise
766 ) as typeof clientAuthentication.validateCurrentSession;
767 const incomingRedirectPromise = Promise.resolve();
768 clientAuthentication.handleIncomingRedirect = jest
769 .fn()
770 .mockReturnValueOnce(
771 incomingRedirectPromise
772 ) as typeof clientAuthentication.handleIncomingRedirect;
773 clientAuthentication.login = jest.fn();
774
775 const mySession = new Session({ clientAuthentication });
776 // eslint-disable-next-line no-void
777 void mySession.handleIncomingRedirect({
778 url: "https://some.redirect/url",
779 restorePreviousSession: true,
780 });
781 await incomingRedirectPromise;
782 await validateCurrentSessionPromise;
783 expect(clientAuthentication.login).toHaveBeenCalledWith({
784 sessionId: "mySession",
785 tokenType: "DPoP",
786 oidcIssuer: "https://some.issuer",
787 prompt: "none",
788 clientId: "some client ID",
789 clientSecret: "some client secret",
790 redirectUrl: "https://some.redirect/url",
791 inIframe: false,
792 });
793 });
794
795 it("resolves handleIncomingRedirect if silent authentication could not be started", async () => {
796 const sessionId = "mySession";
797 mockLocalStorage({
798 [KEY_CURRENT_SESSION]: sessionId,
799 });
800 mockLocation("https://mock.current/location");
801 const mockedStorage = new StorageUtility(
802 mockStorage({
803 [`${USER_SESSION_PREFIX}:${sessionId}`]: {
804 isLoggedIn: "true",
805 },
806 }),
807 mockStorage({})
808 );
809 const clientAuthentication = mockClientAuthentication({
810 sessionInfoManager: mockSessionInfoManager(mockedStorage),
811 });
812 const validateCurrentSessionPromise = Promise.resolve(null);
813 clientAuthentication.validateCurrentSession = jest
814 .fn()
815 .mockReturnValue(
816 validateCurrentSessionPromise
817 ) as typeof clientAuthentication.validateCurrentSession;
818 const incomingRedirectPromise = Promise.resolve();
819 clientAuthentication.handleIncomingRedirect = jest
820 .fn()
821 .mockReturnValueOnce(
822 incomingRedirectPromise
823 ) as typeof clientAuthentication.handleIncomingRedirect;
824 clientAuthentication.login = jest.fn();
825
826 const mySession = new Session({ clientAuthentication });
827 const handleIncomingRedirectPromise = mySession.handleIncomingRedirect({
828 url: "https://arbitrary.redirect/url",
829 restorePreviousSession: true,
830 });
831 await incomingRedirectPromise;
832 await validateCurrentSessionPromise;
833 await expect(handleIncomingRedirectPromise).resolves.not.toBeNull();
834 });
835
836 it("does nothing if the developer has not explicitly enabled silent authentication", async () => {
837 const sessionId = "mySession";
838 mockLocalStorage({
839 [KEY_CURRENT_SESSION]: sessionId,
840 });
841 mockLocation("https://mock.current/location");
842 const mockedStorage = new StorageUtility(
843 mockStorage({
844 [`${USER_SESSION_PREFIX}:${sessionId}`]: {
845 isLoggedIn: "true",
846 },
847 }),
848 mockStorage({})
849 );
850 const clientAuthentication = mockClientAuthentication({
851 sessionInfoManager: mockSessionInfoManager(mockedStorage),
852 });
853 clientAuthentication.validateCurrentSession = (
854 jest
855 // eslint-disable-next-line @typescript-eslint/no-explicit-any
856 .fn() as any
857 ).mockResolvedValue({
858 issuer: "https://some.issuer",
859 clientAppId: "some client ID",
860 clientAppSecret: "some client secret",
861 redirectUrl: "https://some.redirect/url",
862 }) as typeof clientAuthentication.validateCurrentSession;
863 clientAuthentication.handleIncomingRedirect =
864 // eslint-disable-next-line @typescript-eslint/no-explicit-any
865 (jest.fn() as any).mockResolvedValue(
866 undefined
867 ) as typeof clientAuthentication.handleIncomingRedirect;
868 clientAuthentication.login = jest.fn();
869
870 const mySession = new Session({ clientAuthentication });
871 // eslint-disable-next-line no-void
872 void mySession.handleIncomingRedirect("https://some.redirect/url");
873 expect(window.localStorage.getItem(KEY_CURRENT_URL)).toBeNull();
874 expect(clientAuthentication.login).not.toHaveBeenCalled();
875 });
876
877 it("does nothing if the developer has disabled silent authentication", async () => {
878 const sessionId = "mySession";
879 mockLocalStorage({
880 [KEY_CURRENT_SESSION]: sessionId,
881 });
882 mockLocation("https://mock.current/location");
883 const mockedStorage = new StorageUtility(
884 mockStorage({
885 [`${USER_SESSION_PREFIX}:${sessionId}`]: {
886 isLoggedIn: "true",
887 },
888 }),
889 mockStorage({})
890 );
891 const clientAuthentication = mockClientAuthentication({
892 sessionInfoManager: mockSessionInfoManager(mockedStorage),
893 });
894 clientAuthentication.validateCurrentSession = (
895 jest
896 // eslint-disable-next-line @typescript-eslint/no-explicit-any
897 .fn() as any
898 ).mockResolvedValue({
899 issuer: "https://some.issuer",
900 clientAppId: "some client ID",
901 clientAppSecret: "some client secret",
902 redirectUrl: "https://some.redirect/url",
903 });
904 clientAuthentication.handleIncomingRedirect = (
905 jest
906 // eslint-disable-next-line @typescript-eslint/no-explicit-any
907 .fn() as any
908 ).mockResolvedValue(undefined);
909 clientAuthentication.login = jest.fn();
910
911 const mySession = new Session({ clientAuthentication });
912 // eslint-disable-next-line no-void
913 void mySession.handleIncomingRedirect({
914 url: "https://some.redirect/url",
915 restorePreviousSession: false,
916 });
917 expect(window.localStorage.getItem(KEY_CURRENT_URL)).toBeNull();
918 expect(clientAuthentication.login).not.toHaveBeenCalled();
919 });
920 });
921
922 describe("onLogin", () => {
923 it("calls the registered callback on login", async () => {
924 const myCallback = jest.fn();
925 const clientAuthentication = mockClientAuthentication();
926 clientAuthentication.handleIncomingRedirect = (
927 jest
928 // eslint-disable-next-line @typescript-eslint/no-explicit-any
929 .fn() as any
930 ).mockResolvedValue({
931 isLoggedIn: true,
932 sessionId: "a session ID",
933 webId: "https://some.webid#them",
934 });
935 mockLocalStorage({});
936 const mySession = new Session({ clientAuthentication });
937 mySession.onLogin(myCallback);
938 await mySession.handleIncomingRedirect("https://some.url");
939 expect(myCallback).toHaveBeenCalled();
940 });
941
942 it("does not call the registered callback if login isn't successful", async () => {
943 const failCallback = (): void => {
944 throw new Error(
945 "Should *NOT* call callback - this means test has failed!"
946 );
947 };
948 const clientAuthentication = mockClientAuthentication();
949 clientAuthentication.handleIncomingRedirect = jest.fn(
950 async (_url: string) => {
951 return {
952 isLoggedIn: false,
953 sessionId: "a session ID",
954 webId: "https://some.webid#them",
955 };
956 }
957 );
958 const mySession = new Session({ clientAuthentication });
959 mySession.onLogin(failCallback);
960 await expect(
961 mySession.handleIncomingRedirect("https://some.url")
962 ).resolves.not.toThrow();
963 });
964
965 it("sets the appropriate information before calling the callback", async () => {
966 const clientAuthentication = mockClientAuthentication();
967 clientAuthentication.handleIncomingRedirect = jest.fn(
968 async (_url: string) => {
969 return {
970 isLoggedIn: true,
971 sessionId: "a session ID",
972 webId: "https://some.webid#them",
973 };
974 }
975 );
976 const mySession = new Session({ clientAuthentication });
977 const myCallback = jest.fn((): void => {
978 expect(mySession.info.webId).toBe("https://some.webid#them");
979 });
980 mySession.onLogin(myCallback);
981 await mySession.handleIncomingRedirect("https://some.url");
982 expect(myCallback).toHaveBeenCalled();
983 // Verify that the conditional assertion has been called
984 expect.assertions(2);
985 });
986 });
987
988 describe("onLogout", () => {
989 // The `done` callback is used in order to make sure the callback passed to
990 // our event handler is called. If it is not, the test times out, which is why
991 // no additional assertion is required.
992 // eslint-disable-next-line jest/expect-expect, jest/no-done-callback
993 it("calls the registered callback on logout", async (done) => {
994 const myCallback = (): void => {
995 if (done) {
996 done();
997 }
998 };
999 const mySession = new Session({
1000 clientAuthentication: mockClientAuthentication(),
1001 });
1002
1003 mySession.onLogout(myCallback);
1004 await mySession.logout();
1005 });
1006 });
1007
1008 describe("onSessionRestore", () => {
1009 it("calls the registered callback on session restore", async () => {
1010 // Set our window's location to our test value.
1011 const defaultLocation = "https://coolSite.com/resource";
1012 const currentLocation = "https://coolSite.com/redirect";
1013
1014 // This pretends we have previously triggered silent authentication and stored
1015 // the location.
1016 mockLocalStorage({
1017 [KEY_CURRENT_URL]: defaultLocation,
1018 });
1019 // This acts as the URL the user has been redirected to.
1020 mockLocation(currentLocation);
1021 // This pretends the login is successful.
1022 const clientAuthentication = mockClientAuthentication();
1023 clientAuthentication.handleIncomingRedirect = (
1024 jest
1025 // eslint-disable-next-line @typescript-eslint/no-explicit-any
1026 .fn() as any
1027 ).mockResolvedValue({
1028 isLoggedIn: true,
1029 sessionId: "a session ID",
1030 webId: "https://some.webid#them",
1031 });
1032
1033 const mySession = new Session({
1034 clientAuthentication,
1035 });
1036 const myCallback = (urlBeforeRestore: string): void => {
1037 expect(urlBeforeRestore).toEqual(defaultLocation);
1038 };
1039
1040 mySession.onSessionRestore(myCallback);
1041 await mySession.handleIncomingRedirect(currentLocation);
1042
1043 // This verifies that the callback has been called
1044 expect.assertions(1);
1045 });
1046 });
1047
1048 describe("on tokenRenewal signal", () => {
1049 it("triggers silent authentication in an iframe when receiving the signal", async () => {
1050 const sessionId = "mySession";
1051 mockLocalStorage({
1052 [KEY_CURRENT_SESSION]: sessionId,
1053 });
1054 mockLocation("https://mock.current/location");
1055 const mockedStorage = new StorageUtility(
1056 mockStorage({
1057 [`${USER_SESSION_PREFIX}:${sessionId}`]: {
1058 isLoggedIn: "true",
1059 },
1060 }),
1061 mockStorage({})
1062 );
1063 const clientAuthentication = mockClientAuthentication({
1064 sessionInfoManager: mockSessionInfoManager(mockedStorage),
1065 });
1066 const validateCurrentSessionPromise = Promise.resolve({
1067 issuer: "https://some.issuer",
1068 clientAppId: "some client ID",
1069 clientAppSecret: "some client secret",
1070 redirectUrl: "https://some.redirect/url",
1071 tokenType: "DPoP",
1072 });
1073 clientAuthentication.validateCurrentSession = jest
1074 .fn()
1075 .mockReturnValue(
1076 validateCurrentSessionPromise
1077 ) as typeof clientAuthentication.validateCurrentSession;
1078 const incomingRedirectPromise = Promise.resolve();
1079 clientAuthentication.handleIncomingRedirect = jest
1080 .fn()
1081 .mockReturnValueOnce(
1082 incomingRedirectPromise
1083 ) as typeof clientAuthentication.handleIncomingRedirect;
1084 clientAuthentication.login = jest.fn();
1085
1086 const mySession = new Session({ clientAuthentication }, sessionId);
1087 // Send the signal to the session
1088 mySession.emit("tokenRenewal");
1089 await incomingRedirectPromise;
1090 await validateCurrentSessionPromise;
1091 expect(clientAuthentication.login).toHaveBeenCalledWith({
1092 sessionId: "mySession",
1093 tokenType: "DPoP",
1094 oidcIssuer: "https://some.issuer",
1095 prompt: "none",
1096 clientId: "some client ID",
1097 clientSecret: "some client secret",
1098 redirectUrl: "https://some.redirect/url",
1099 inIframe: true,
1100 });
1101 });
1102
1103 it("sets the updated session info after silently refreshing", async () => {
1104 const clientAuthentication = mockClientAuthentication();
1105 const incomingRedirectPromise = Promise.resolve({
1106 isLoggedIn: true,
1107 webId: "https://some.pod/profile#me",
1108 sessionId: "someSessionId",
1109 expirationDate: 961106400,
1110 });
1111 clientAuthentication.handleIncomingRedirect = jest
1112 .fn()
1113 .mockReturnValueOnce(
1114 incomingRedirectPromise
1115 ) as typeof clientAuthentication.handleIncomingRedirect;
1116
1117 const windowAddEventListener = jest.spyOn(window, "addEventListener");
1118 // ../src/iframe is mocked for other tests,
1119 // but we need `setupIframeListener` to actually be executed
1120 // so that the callback gets called:
1121 // eslint-disable-next-line @typescript-eslint/no-explicit-any
1122 const iframeMock = jest.requireMock("../src/iframe") as any;
1123 // eslint-disable-next-line @typescript-eslint/no-explicit-any
1124 const iframeActual = jest.requireActual("../src/iframe") as any;
1125 iframeMock.setupIframeListener.mockImplementationOnce(
1126 iframeActual.setupIframeListener
1127 );
1128 const mySession = new Session({ clientAuthentication });
1129
1130 // `window.addEventListener` gets called once with ("message", handler)
1131 // — get that handler.
1132 const messageEventHandler = windowAddEventListener.mock
1133 .calls[0][1] as EventListener;
1134 const mockedEvent = {
1135 origin: window.location.origin,
1136 source: null,
1137 data: {
1138 redirectUrl: "http://arbitrary.com",
1139 },
1140 } as MessageEvent;
1141 // This handler will call `clientAuthentication.handleIncomingRedirect`,
1142 // which will return our sessionInfo values:
1143 messageEventHandler(mockedEvent);
1144
1145 await incomingRedirectPromise;
1146 expect(mySession.info.webId).toBe("https://some.pod/profile#me");
1147 expect(mySession.info.sessionId).toBe("someSessionId");
1148 expect(mySession.info.expirationDate).toBe(961106400);
1149 });
1150
1151 it("does not change the existing session if silent authentication failed", async () => {
1152 const clientAuthentication = mockClientAuthentication();
1153 const incomingRedirectPromise = Promise.resolve({
1154 isLoggedIn: false,
1155 });
1156 clientAuthentication.handleIncomingRedirect = jest
1157 .fn()
1158 .mockReturnValueOnce(
1159 incomingRedirectPromise
1160 ) as typeof clientAuthentication.handleIncomingRedirect;
1161
1162 const windowAddEventListener = jest.spyOn(window, "addEventListener");
1163 // ../src/iframe is mocked for other tests,
1164 // but we need `setupIframeListener` to actually be executed
1165 // so that the callback gets called:
1166 // eslint-disable-next-line @typescript-eslint/no-explicit-any
1167 const iframeMock = jest.requireMock("../src/iframe") as any;
1168 // eslint-disable-next-line @typescript-eslint/no-explicit-any
1169 const iframeActual = jest.requireActual("../src/iframe") as any;
1170 iframeMock.setupIframeListener.mockImplementationOnce(
1171 iframeActual.setupIframeListener
1172 );
1173 const mySession = new Session({ clientAuthentication });
1174 // The `any` assertion is necessary because Session.info is not meant to
1175 // be written to; we only do so for tests to pretend we have an existing
1176 // logged-in session that remains logged in after failed silent
1177 // authentication.
1178 // eslint-disable-next-line @typescript-eslint/no-explicit-any
1179 (mySession as any).info = {
1180 isLoggedIn: true,
1181 webId: "https://some.pod/profile#me",
1182 sessionId: "someSessionId",
1183 expirationDate: 961106400,
1184 };
1185
1186 // `window.addEventListener` gets called once with ("message", handler)
1187 // — get that handler.
1188 const messageEventHandler = windowAddEventListener.mock
1189 .calls[0][1] as EventListener;
1190 const mockedEvent = {
1191 origin: window.location.origin,
1192 source: null,
1193 data: {
1194 redirectUrl: "http://arbitrary.com",
1195 },
1196 } as MessageEvent;
1197 // This handler will call `clientAuthentication.handleIncomingRedirect`,
1198 // which will return our sessionInfo values:
1199 messageEventHandler(mockedEvent);
1200
1201 await incomingRedirectPromise;
1202 expect(mySession.info.webId).toBe("https://some.pod/profile#me");
1203 expect(mySession.info.sessionId).toBe("someSessionId");
1204 expect(mySession.info.expirationDate).toBe(961106400);
1205 });
1206 });
1207});