diff --git a/e2e/node/e2e-test.spec.ts b/e2e/node/e2e-test.spec.ts index 9bf648b796..1221fe51bf 100644 --- a/e2e/node/e2e-test.spec.ts +++ b/e2e/node/e2e-test.spec.ts @@ -274,4 +274,30 @@ describe("Session events", () => { expect(logoutFunc).toHaveBeenCalledTimes(1); expect(expiredFunc).toHaveBeenCalledTimes(1); }); -}); + + it("sends a session status changed event on login, logout, and session expiration", async() => { + let sessionStatusChangeFunc: () => void; + sessionStatusChangeFunc = jest.fn(); + session.events.on(EVENTS.SESSION_STATUS_CHANGE, sessionStatusChangeFunc); + + expect(session.info.isLoggedIn).toBe(true); + + if (typeof session.info.expirationDate !== "number") { + throw new Error("Cannot determine session expiration date"); + } + const expiresIn = session.info.expirationDate - Date.now(); + await new Promise((resolve) => { + setTimeout(resolve, expiresIn); + }); + + expect(loginFunc).toHaveBeenCalledTimes(1); + expect(logoutFunc).toHaveBeenCalledTimes(0); + expect(expiredFunc).toHaveBeenCalledTimes(1); + expect(sessionStatusChangeFunc).toHaveBeenCalledTimes(2); + await session.logout(); + expect(loginFunc).toHaveBeenCalledTimes(1); + expect(logoutFunc).toHaveBeenCalledTimes(1); + expect(expiredFunc).toHaveBeenCalledTimes(1); + expect(sessionStatusChangeFunc).toHaveBeenCalledTimes(3); + }) +}); \ No newline at end of file diff --git a/packages/browser/src/Session.spec.ts b/packages/browser/src/Session.spec.ts index af03cb3452..01bf168688 100644 --- a/packages/browser/src/Session.spec.ts +++ b/packages/browser/src/Session.spec.ts @@ -982,6 +982,47 @@ describe("Session", () => { }); }); + describe("login and logout", () => { + it("calls the registered callback on login and logout", async () => { + const myCallback = jest.fn(); + const clientAuthentication = mockClientAuthentication(); + const mySession = new Session({ + clientAuthentication, + }); + + clientAuthentication.handleIncomingRedirect = jest + .fn() + .mockResolvedValue({ + isLoggedIn: true, + sessionId: "a session ID", + webId: "https://some.webid#them", + }); + mockLocalStorage({}); + + mySession.events.on(EVENTS.SESSION_STATUS_CHANGE, myCallback); + await mySession.handleIncomingRedirect("https://some.url"); + expect(myCallback).toHaveBeenCalledTimes(1); + await mySession.logout(); + expect(myCallback).toHaveBeenCalledTimes(2); + + }) + + it("does not call the registered callback if login isn't successful", async () => { + const myCallback = jest.fn(); + const clientAuthentication = mockClientAuthentication(); + clientAuthentication.handleIncomingRedirect = jest + .fn() + .mockResolvedValue({ + isLoggedIn: true, + sessionId: "a session ID", + webId: "https://some.webid#them", + }); + const mySession = new Session({ clientAuthentication }); + mySession.events.on(EVENTS.SESSION_STATUS_CHANGE, myCallback); + expect(myCallback).not.toHaveBeenCalled(); + }); + }) + describe("sessionRestore", () => { it("calls the registered callback on session restore", async () => { // Set our window's location to our test value. diff --git a/packages/browser/src/Session.ts b/packages/browser/src/Session.ts index f07fcc7283..4f4bc792e2 100644 --- a/packages/browser/src/Session.ts +++ b/packages/browser/src/Session.ts @@ -212,13 +212,28 @@ export class Session extends EventEmitter implements IHasSessionEventListener { // enable silent refresh. The current session ID specifically stored in 'localStorage' // (as opposed to using our storage abstraction layer) because it is only // used in a browser-specific mechanism. - this.events.on(EVENTS.LOGIN, () => - window.localStorage.setItem(KEY_CURRENT_SESSION, this.info.sessionId), - ); + this.events.on(EVENTS.LOGIN, () => { + // You have to use the semicolon on this next line + // Because the underlying JS interpreter cannot interpret whether + // the EventEmitter cast is an IIFE or different token. + window.localStorage.setItem(KEY_CURRENT_SESSION, this.info.sessionId); + (this.events as EventEmitter).emit(EVENTS.SESSION_STATUS_CHANGE) + }); + + this.events.on(EVENTS.LOGOUT, () => { + (this.events as EventEmitter).emit(EVENTS.SESSION_STATUS_CHANGE); + }) + + this.events.on(EVENTS.SESSION_EXPIRED, () => { + this.internalLogout(false); + (this.events as EventEmitter).emit(EVENTS.SESSION_STATUS_CHANGE); + }); + + this.events.on(EVENTS.ERROR, () => { + this.internalLogout(false); + }); - this.events.on(EVENTS.SESSION_EXPIRED, () => this.internalLogout(false)); - this.events.on(EVENTS.ERROR, () => this.internalLogout(false)); } /** diff --git a/packages/core/src/SessionEventListener.ts b/packages/core/src/SessionEventListener.ts index 3f73715abd..f8ad105ed0 100644 --- a/packages/core/src/SessionEventListener.ts +++ b/packages/core/src/SessionEventListener.ts @@ -57,7 +57,10 @@ type FALLBACK_ARGS = { // Prevents from using a SessionEventEmitter as an aritrary EventEmitter. listener: never; }; - +type SESSION_STATUS_CHANGE_ARGS = { + eventName: typeof EVENTS.SESSION_STATUS_CHANGE; + listener: () => void; +} export interface ISessionEventListener extends EventEmitter { /** * Register a listener called on successful login. @@ -77,6 +80,15 @@ export interface ISessionEventListener extends EventEmitter { eventName: LOGOUT_ARGS["eventName"], listener: LOGOUT_ARGS["listener"], ): this; + /** + * Register a listener called on a successful login or logout. + * @param eventName The login and logout event name. + * @param listener The callback called on a successful login and logout. + */ + on( + eventName: SESSION_STATUS_CHANGE_ARGS["eventName"], + listener: SESSION_STATUS_CHANGE_ARGS["listener"] + ): this; /** * Register a listener called on session expiration. * @param eventName The session expiration event name. @@ -159,6 +171,15 @@ export interface ISessionEventListener extends EventEmitter { eventName: LOGOUT_ARGS["eventName"], listener: LOGOUT_ARGS["listener"], ): this; + /** + * Register a listener called on a successful login or logout. + * @param eventName The login and logout event name. + * @param listener The callback called on a successful login or logout. + */ + addListener( + eventName: SESSION_STATUS_CHANGE_ARGS["eventName"], + listener: SESSION_STATUS_CHANGE_ARGS["listener"] + ): this; /** * Register a listener called on session expiration. * @param eventName The session expiration event name. @@ -241,6 +262,15 @@ export interface ISessionEventListener extends EventEmitter { eventName: LOGOUT_ARGS["eventName"], listener: LOGOUT_ARGS["listener"], ): this; + /** + * Register a listener called on the next successful login or logout. + * @param eventName The login and logout event name. + * @param listener The callback called on the next successful login or logout. + */ + once( + eventName: SESSION_STATUS_CHANGE_ARGS["eventName"], + listener: SESSION_STATUS_CHANGE_ARGS["listener"] + ): this; /** * Register a listener called on the next session expiration. * @param eventName The session expiration event name. @@ -324,6 +354,15 @@ export interface ISessionEventListener extends EventEmitter { eventName: LOGOUT_ARGS["eventName"], listener: LOGOUT_ARGS["listener"], ): this; + /** + * Unregister a listener called on a successful login or logout. + * @param eventName The login and logout event name. + * @param listener The callback to unregister. + */ + off( + eventName: SESSION_STATUS_CHANGE_ARGS["eventName"], + listener: SESSION_STATUS_CHANGE_ARGS["listener"] + ): this; /** * Unegister a listener called on session expiration. * @param eventName The session expiration event name. @@ -405,6 +444,15 @@ export interface ISessionEventListener extends EventEmitter { eventName: LOGOUT_ARGS["eventName"], listener: LOGOUT_ARGS["listener"], ): this; + /** + * Unregister a listener called on a successful login or logout. + * @param eventName The login and logout event name + * @param listener The callback to unregister + */ + removeListener( + eventName: SESSION_STATUS_CHANGE_ARGS["eventName"], + listener: SESSION_STATUS_CHANGE_ARGS["listener"] + ): this; /** * Unegister a listener called on session expiration. * @param eventName The session expiration event name. diff --git a/packages/core/src/constant.ts b/packages/core/src/constant.ts index 98f34f3714..9584600f70 100644 --- a/packages/core/src/constant.ts +++ b/packages/core/src/constant.ts @@ -36,6 +36,7 @@ export const EVENTS = { ERROR: "error", LOGIN: "login", LOGOUT: "logout", + SESSION_STATUS_CHANGE: "sessionStatusChange", NEW_REFRESH_TOKEN: "newRefreshToken", SESSION_EXPIRED: "sessionExpired", SESSION_EXTENDED: "sessionExtended", diff --git a/packages/node/src/Session.spec.ts b/packages/node/src/Session.spec.ts index 03f618066c..d51b873ddd 100644 --- a/packages/node/src/Session.spec.ts +++ b/packages/node/src/Session.spec.ts @@ -488,9 +488,28 @@ describe("Session", () => { const mySession = new Session({ clientAuthentication }); mySession.events.on(EVENTS.LOGIN, myCallback); await mySession.handleIncomingRedirect("https://some.url"); - expect(myCallback).toHaveBeenCalled(); + expect(myCallback).toHaveBeenCalledTimes(1); }); + it("does not call the registered cb on logout", async () => { + const myCallback = jest.fn(); + const clientAuthentication = mockClientAuthentication(); + clientAuthentication.handleIncomingRedirect = jest + .fn() + .mockResolvedValue({ + isLoggedIn: true, + sessionId: "a session ID", + webId: "https://some.webid#them", + }); + const mySession = new Session({ clientAuthentication }); + mySession.events.on(EVENTS.LOGIN, myCallback); + await mySession.handleIncomingRedirect("https://some.url"); + expect(myCallback).toHaveBeenCalledTimes(1); + + await mySession.logout(); + expect(myCallback).toHaveBeenCalledTimes(1); + }) + it("does not call the registered callback if login isn't successful", async () => { const myCallback = jest.fn(); const clientAuthentication = mockClientAuthentication(); @@ -536,10 +555,52 @@ describe("Session", () => { mySession.events.on(EVENTS.LOGOUT, myCallback); await mySession.logout(); - expect(myCallback).toHaveBeenCalled(); + expect(myCallback).toHaveBeenCalledTimes(1); }); + }); + describe("login and logout", () => { + it("calls the registered callback on login and logout", async () => { + const myCallback = jest.fn(); + const clientAuthentication = mockClientAuthentication(); + const mySession = new Session({ + clientAuthentication, + }); + + clientAuthentication.handleIncomingRedirect = jest + .fn() + .mockResolvedValue({ + isLoggedIn: true, + sessionId: "a session ID", + webId: "https://some.webid#them", + }); + + mySession.events.on(EVENTS.SESSION_STATUS_CHANGE, myCallback) + await mySession.handleIncomingRedirect("https://some.url"); + expect(myCallback).toHaveBeenCalledTimes(1); + + await mySession.logout(); + expect(myCallback).toHaveBeenCalledTimes(2); + }) + + it("does not call the registered callback if login isn't successful", async () => { + const myCallback = jest.fn(); + const clientAuthentication = mockClientAuthentication(); + clientAuthentication.handleIncomingRedirect = jest + .fn() + .mockResolvedValue({ + isLoggedIn: true, + sessionId: "a session ID", + webId: "https://some.webid#them", + }); + const mySession = new Session({ clientAuthentication }); + mySession.events.on(EVENTS.SESSION_STATUS_CHANGE, myCallback); + + expect(myCallback).not.toHaveBeenCalled(); + }); + }) + describe("sessionExpired", () => { it("calls the provided callback when receiving the appropriate event", async () => { const myCallback = jest.fn(); diff --git a/packages/node/src/Session.ts b/packages/node/src/Session.ts index 878345f6ab..32417e12b6 100644 --- a/packages/node/src/Session.ts +++ b/packages/node/src/Session.ts @@ -186,10 +186,16 @@ export class Session extends EventEmitter implements IHasSessionEventListener { this.lastTimeoutHandle = timeoutHandle; }); + this.events.on(EVENTS.LOGIN, () => (this.events as EventEmitter).emit(EVENTS.SESSION_STATUS_CHANGE)); + this.events.on(EVENTS.LOGOUT, () => (this.events as EventEmitter).emit(EVENTS.SESSION_STATUS_CHANGE)); this.events.on(EVENTS.ERROR, () => this.internalLogout(false)); - this.events.on(EVENTS.SESSION_EXPIRED, () => this.internalLogout(false)); + this.events.on(EVENTS.SESSION_EXPIRED, () => { + this.internalLogout(false); + (this.events as EventEmitter).emit(EVENTS.SESSION_STATUS_CHANGE) + }); } + /** * Triggers the login process. Note that this method will redirect the user away from your app. *