Skip to content

Commit b9d5c01

Browse files
committed
fix(UserManager): handle concurrent token refresh requests via leader election
Introduces leader election for concurrent token refresh requests. Gracefully falls back when Web Lock API is not available. Closes #430
1 parent c9fcaa0 commit b9d5c01

File tree

6 files changed

+222
-13
lines changed

6 files changed

+222
-13
lines changed

package-lock.json

Lines changed: 18 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/LockManager.d.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// This is temporary until oidc-client-ts updates to a newer TypeScript version.
2+
// @see https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/1291
3+
declare global {
4+
interface Navigator {
5+
locks : LockManager;
6+
}
7+
8+
interface LockManager {
9+
request<T>(
10+
name : string,
11+
callback : (lock? : Lock) => Promise<T> | T
12+
) : Promise<T>;
13+
14+
request<T>(
15+
name : string,
16+
options : LockOptions,
17+
callback : (lock? : Lock) => Promise<T> | T
18+
) : Promise<T>;
19+
20+
query() : Promise<LockManagerSnapshot>;
21+
}
22+
23+
type LockMode = "shared" | "exclusive";
24+
25+
interface LockOptions {
26+
mode? : LockMode;
27+
ifAvailable? : boolean;
28+
steal? : boolean;
29+
signal? : AbortSignal;
30+
}
31+
32+
interface LockManagerSnapshot {
33+
held : LockInfo[];
34+
pending : LockInfo[];
35+
}
36+
37+
interface LockInfo {
38+
name : string;
39+
mode : LockMode;
40+
clientId : string;
41+
}
42+
43+
interface Lock {
44+
name : string;
45+
mode : LockMode;
46+
}
47+
}
48+
49+
export {};

src/UserManager.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,34 @@ describe("UserManager", () => {
427427
}),
428428
);
429429
});
430+
431+
it("should only perform one refresh concurrently", async () => {
432+
// arrange
433+
const user = new User({
434+
access_token: "access_token",
435+
token_type: "token_type",
436+
refresh_token: "refresh_token",
437+
profile: {
438+
sub: "sub",
439+
nickname: "Nick",
440+
} as UserProfile,
441+
});
442+
443+
const useRefreshTokenSpy = jest.spyOn(subject["_client"], "useRefreshToken").mockResolvedValue({
444+
access_token: "new_access_token",
445+
profile: {
446+
sub: "sub",
447+
nickname: "Nicholas",
448+
},
449+
} as unknown as SigninResponse);
450+
subject["_loadUser"] = jest.fn().mockResolvedValue(user);
451+
452+
// act
453+
const refreshedUsers = await Promise.all([subject.signinSilent(), subject.signinSilent()]);
454+
expect(refreshedUsers[0]).toHaveProperty("access_token", "new_access_token");
455+
expect(refreshedUsers[1]).toHaveProperty("access_token", "new_access_token");
456+
expect(useRefreshTokenSpy).toBeCalledTimes(1);
457+
});
430458
});
431459

432460
describe("signinSilentCallback", () => {

src/UserManager.ts

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -263,15 +263,42 @@ export class UserManager {
263263
}
264264

265265
protected async _useRefreshToken(state: RefreshState): Promise<User> {
266-
const response = await this._client.useRefreshToken({
267-
state,
268-
timeoutInSeconds: this.settings.silentRequestTimeoutInSeconds,
266+
const refreshUser = async (): Promise<User> => {
267+
const response = await this._client.useRefreshToken({
268+
state,
269+
timeoutInSeconds: this.settings.silentRequestTimeoutInSeconds,
270+
});
271+
return new User({ ...state, ...response });
272+
};
273+
274+
if (!navigator.locks) {
275+
// Legacy option for older browser which don't support `navigator.locks`.
276+
const user = await refreshUser();
277+
await this.storeUser(user);
278+
this._events.load(user);
279+
return user;
280+
}
281+
282+
const broadcastChannel = new BroadcastChannel(`refresh_token_${state.refresh_token}`);
283+
let user : User | null = null;
284+
285+
broadcastChannel.addEventListener("message", (event : MessageEvent<User>) => {
286+
user = event.data;
269287
});
270-
const user = new User({ ...state, ...response });
271288

272-
await this.storeUser(user);
273-
this._events.load(user);
274-
return user;
289+
return await navigator.locks.request(
290+
`refresh_token_${state.refresh_token}`,
291+
async () => {
292+
if (!user) {
293+
user = await refreshUser();
294+
}
295+
296+
broadcastChannel.postMessage(user);
297+
await this.storeUser(user);
298+
this._events.load(user);
299+
return user;
300+
},
301+
);
275302
}
276303

277304
/**

test/setup.ts

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,97 @@
11
import { Log } from "../src";
22

3-
beforeAll(() => {
3+
// While NodeJs 15.4 has an experimental implementation, it is not API compatible with the browser version.
4+
class BroadcastChannelPolyfill {
5+
public onmessage = null;
6+
public onmessageerror = null;
7+
private static _eventTargets: Record<string, EventTarget> = {};
8+
9+
public constructor(public readonly name: string) {
10+
if (!(name in BroadcastChannelPolyfill._eventTargets)) {
11+
BroadcastChannelPolyfill._eventTargets[name] = new EventTarget();
12+
}
13+
}
14+
15+
public close(): void {
16+
// no-op
17+
}
18+
19+
public dispatchEvent(): boolean {
20+
return true;
21+
}
22+
23+
public postMessage(message: unknown): void {
24+
const messageEvent = new Event("message") as Event & { data : unknown };
25+
messageEvent.data = message;
26+
BroadcastChannelPolyfill._eventTargets[this.name].dispatchEvent(messageEvent);
27+
}
28+
29+
public addEventListener<K extends keyof BroadcastChannelEventMap>(
30+
type: K,
31+
listener: (this: BroadcastChannel, ev: BroadcastChannelEventMap[K]) => unknown,
32+
options?: boolean | AddEventListenerOptions,
33+
): void;
34+
public addEventListener(
35+
type: string,
36+
listener: EventListenerOrEventListenerObject,
37+
options?: boolean | AddEventListenerOptions,
38+
): void {
39+
BroadcastChannelPolyfill._eventTargets[this.name].addEventListener("message", listener, options);
40+
}
41+
42+
public removeEventListener<K extends keyof BroadcastChannelEventMap>(
43+
type: K,
44+
listener: (this: BroadcastChannel, ev: BroadcastChannelEventMap[K]) => unknown,
45+
options?: boolean | EventListenerOptions,
46+
): void;
47+
removeEventListener(
48+
type: string,
49+
listener: EventListenerOrEventListenerObject,
50+
options?: boolean | EventListenerOptions,
51+
): void {
52+
BroadcastChannelPolyfill._eventTargets[this.name].removeEventListener("message", listener, options);
53+
}
54+
}
55+
56+
global.BroadcastChannel = BroadcastChannelPolyfill;
57+
58+
class LockManagerPolyfill {
59+
private _locks: Set<string> = new Set();
60+
61+
public async request<T>(
62+
name: string,
63+
options: LockOptions | ((lock?: Lock) => Promise<T> | T),
64+
callback?: (lock?: Lock) => Promise<T> | T,
65+
): Promise<T> {
66+
if (options instanceof Function) {
67+
callback = options;
68+
options = {};
69+
}
70+
71+
while (this._locks.has(name)) {
72+
await new Promise(resolve => setTimeout(resolve, 10));
73+
}
74+
75+
this._locks.add(name);
76+
77+
try {
78+
return await callback!({ name, mode: options.mode ?? "exclusive" });
79+
} finally {
80+
this._locks.delete(name);
81+
}
82+
}
83+
84+
public async query(): Promise<LockManagerSnapshot> {
85+
return await Promise.resolve({
86+
held: [],
87+
pending: [],
88+
});
89+
}
90+
}
91+
92+
global.navigator.locks = new LockManagerPolyfill();
93+
94+
beforeAll(async () => {
495
globalThis.fetch = jest.fn();
596

697
const unload = () => window.dispatchEvent(new Event("unload"));

tsconfig.build.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,6 @@
1111
"emitDeclarationOnly": true,
1212
"tsBuildInfoFile": "tsconfig.build.tsbuildinfo"
1313
},
14-
"files": ["src/index.ts"],
14+
"files": ["src/index.ts", "src/LockManager.d.ts"],
1515
"include": []
1616
}

0 commit comments

Comments
 (0)