diff --git a/src/LockManager.d.ts b/src/LockManager.d.ts new file mode 100644 index 000000000..e12aa6682 --- /dev/null +++ b/src/LockManager.d.ts @@ -0,0 +1,49 @@ +// This is temporary until oidc-client-ts updates to a newer TypeScript version. +// @see https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/1291 +declare global { + interface Navigator { + locks : LockManager; + } + + interface LockManager { + request( + name : string, + callback : (lock? : Lock) => Promise | T + ) : Promise; + + request( + name : string, + options : LockOptions, + callback : (lock? : Lock) => Promise | T + ) : Promise; + + query() : Promise; + } + + type LockMode = "shared" | "exclusive"; + + interface LockOptions { + mode? : LockMode; + ifAvailable? : boolean; + steal? : boolean; + signal? : AbortSignal; + } + + interface LockManagerSnapshot { + held : LockInfo[]; + pending : LockInfo[]; + } + + interface LockInfo { + name : string; + mode : LockMode; + clientId : string; + } + + interface Lock { + name : string; + mode : LockMode; + } +} + +export {}; diff --git a/src/UserManager.test.ts b/src/UserManager.test.ts index a0bd5add4..5160830ad 100644 --- a/src/UserManager.test.ts +++ b/src/UserManager.test.ts @@ -427,6 +427,69 @@ describe("UserManager", () => { }), ); }); + + it("should only perform one refresh concurrently", async () => { + // arrange + const user = new User({ + access_token: "access_token", + token_type: "token_type", + refresh_token: "refresh_token", + profile: { + sub: "sub", + nickname: "Nick", + } as UserProfile, + }); + + const useRefreshTokenSpy = jest.spyOn(subject["_client"], "useRefreshToken").mockResolvedValue({ + access_token: "new_access_token", + profile: { + sub: "sub", + nickname: "Nicholas", + }, + } as unknown as SigninResponse); + subject["_loadUser"] = jest.fn().mockResolvedValue(user); + + // act + const refreshedUsers = await Promise.all([subject.signinSilent(), subject.signinSilent()]); + expect(refreshedUsers[0]).toHaveProperty("access_token", "new_access_token"); + expect(refreshedUsers[1]).toHaveProperty("access_token", "new_access_token"); + expect(useRefreshTokenSpy).toBeCalledTimes(1); + }); + + it("should not fail when Web Locks API is unavailable", async () => { + // arrange + const user = new User({ + access_token: "access_token", + token_type: "token_type", + refresh_token: "refresh_token", + profile: { + sub: "sub", + nickname: "Nick", + } as UserProfile, + }); + + const useRefreshTokenSpy = jest.spyOn(subject["_client"], "useRefreshToken").mockResolvedValue({ + access_token: "new_access_token", + profile: { + sub: "sub", + nickname: "Nicholas", + }, + } as unknown as SigninResponse); + subject["_loadUser"] = jest.fn().mockResolvedValue(user); + + const originalLocks = globalThis.navigator.locks; + // @ts-expect-error It is normally disallowed to do this, fine for the test though. + globalThis.navigator.locks = undefined; + + // act + try { + const refreshedUser = await subject.signinSilent(); + expect(refreshedUser).toHaveProperty("access_token", "new_access_token"); + expect(useRefreshTokenSpy).toBeCalledTimes(1); + } finally { + globalThis.navigator.locks = originalLocks; + } + }); }); describe("signinSilentCallback", () => { diff --git a/src/UserManager.ts b/src/UserManager.ts index 68cc8a2f5..4fb0ae1d2 100644 --- a/src/UserManager.ts +++ b/src/UserManager.ts @@ -263,15 +263,42 @@ export class UserManager { } protected async _useRefreshToken(state: RefreshState): Promise { - const response = await this._client.useRefreshToken({ - state, - timeoutInSeconds: this.settings.silentRequestTimeoutInSeconds, + const refreshUser = async (): Promise => { + const response = await this._client.useRefreshToken({ + state, + timeoutInSeconds: this.settings.silentRequestTimeoutInSeconds, + }); + return new User({ ...state, ...response }); + }; + + if (!navigator.locks) { + // Legacy option for older browser which don't support `navigator.locks`. + const user = await refreshUser(); + await this.storeUser(user); + this._events.load(user); + return user; + } + + const broadcastChannel = new BroadcastChannel(`refresh_token_${state.refresh_token}`); + let user : User | null = null; + + broadcastChannel.addEventListener("message", (event : MessageEvent) => { + user = event.data; }); - const user = new User({ ...state, ...response }); - await this.storeUser(user); - this._events.load(user); - return user; + return await navigator.locks.request( + `refresh_token_${state.refresh_token}`, + async () => { + if (!user) { + user = await refreshUser(); + } + + broadcastChannel.postMessage(user); + await this.storeUser(user); + this._events.load(user); + return user; + }, + ); } /** diff --git a/test/setup.ts b/test/setup.ts index 140094615..58a1e6e8d 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -1,6 +1,97 @@ import { Log } from "../src"; -beforeAll(() => { +// While NodeJs 15.4 has an experimental implementation, it is not API compatible with the browser version. +class BroadcastChannelPolyfill { + public onmessage = null; + public onmessageerror = null; + private static _eventTargets: Record = {}; + + public constructor(public readonly name: string) { + if (!(name in BroadcastChannelPolyfill._eventTargets)) { + BroadcastChannelPolyfill._eventTargets[name] = new EventTarget(); + } + } + + public close(): void { + // no-op + } + + public dispatchEvent(): boolean { + return true; + } + + public postMessage(message: unknown): void { + const messageEvent = new Event("message") as Event & { data : unknown }; + messageEvent.data = message; + BroadcastChannelPolyfill._eventTargets[this.name].dispatchEvent(messageEvent); + } + + public addEventListener( + type: K, + listener: (this: BroadcastChannel, ev: BroadcastChannelEventMap[K]) => unknown, + options?: boolean | AddEventListenerOptions, + ): void; + public addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ): void { + BroadcastChannelPolyfill._eventTargets[this.name].addEventListener("message", listener, options); + } + + public removeEventListener( + type: K, + listener: (this: BroadcastChannel, ev: BroadcastChannelEventMap[K]) => unknown, + options?: boolean | EventListenerOptions, + ): void; + public removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions, + ): void { + BroadcastChannelPolyfill._eventTargets[this.name].removeEventListener("message", listener, options); + } +} + +globalThis.BroadcastChannel = BroadcastChannelPolyfill; + +class LockManagerPolyfill { + private _locks: Set = new Set(); + + public async request( + name: string, + options: LockOptions | ((lock?: Lock) => Promise | T), + callback?: (lock?: Lock) => Promise | T, + ): Promise { + if (options instanceof Function) { + callback = options; + options = {}; + } + + while (this._locks.has(name)) { + await new Promise(resolve => setTimeout(resolve, 10)); + } + + this._locks.add(name); + + try { + return await callback!({ name, mode: options.mode ?? "exclusive" }); + } finally { + this._locks.delete(name); + } + } + + public async query(): Promise { + return await Promise.resolve({ + held: [], + pending: [], + }); + } +} + +globalThis.navigator.locks = new LockManagerPolyfill(); + +beforeAll(async () => { globalThis.fetch = jest.fn(); const unload = () => window.dispatchEvent(new Event("unload")); diff --git a/tsconfig.build.json b/tsconfig.build.json index 7f1ee278d..69398da5f 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -11,6 +11,6 @@ "emitDeclarationOnly": true, "tsBuildInfoFile": "tsconfig.build.tsbuildinfo" }, - "files": ["src/index.ts"], + "files": ["src/index.ts", "src/LockManager.d.ts"], "include": [] }