Skip to content

Commit

Permalink
fix(UserManager): handle concurrent token refresh requests via leader…
Browse files Browse the repository at this point in the history
… election

Introduces leader election for concurrent token refresh requests. Gracefully
falls back when Web Lock API is not available.

Closes #430
  • Loading branch information
DASPRiD committed Mar 25, 2022
1 parent c9fcaa0 commit 14436d1
Show file tree
Hide file tree
Showing 5 changed files with 239 additions and 9 deletions.
49 changes: 49 additions & 0 deletions src/LockManager.d.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
name : string,
callback : (lock? : Lock) => Promise<T> | T
) : Promise<T>;

request<T>(
name : string,
options : LockOptions,
callback : (lock? : Lock) => Promise<T> | T
) : Promise<T>;

query() : Promise<LockManagerSnapshot>;
}

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 {};
63 changes: 63 additions & 0 deletions src/UserManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
41 changes: 34 additions & 7 deletions src/UserManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,15 +263,42 @@ export class UserManager {
}

protected async _useRefreshToken(state: RefreshState): Promise<User> {
const response = await this._client.useRefreshToken({
state,
timeoutInSeconds: this.settings.silentRequestTimeoutInSeconds,
const refreshUser = async (): Promise<User> => {
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>) => {
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;
},
);
}

/**
Expand Down
93 changes: 92 additions & 1 deletion test/setup.ts
Original file line number Diff line number Diff line change
@@ -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<string, EventTarget> = {};

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<K extends keyof BroadcastChannelEventMap>(
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<K extends keyof BroadcastChannelEventMap>(
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<string> = new Set();

public async request<T>(
name: string,
options: LockOptions | ((lock?: Lock) => Promise<T> | T),
callback?: (lock?: Lock) => Promise<T> | T,
): Promise<T> {
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<LockManagerSnapshot> {
return await Promise.resolve({
held: [],
pending: [],
});
}
}

globalThis.navigator.locks = new LockManagerPolyfill();

beforeAll(async () => {
globalThis.fetch = jest.fn();

const unload = () => window.dispatchEvent(new Event("unload"));
Expand Down
2 changes: 1 addition & 1 deletion tsconfig.build.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@
"emitDeclarationOnly": true,
"tsBuildInfoFile": "tsconfig.build.tsbuildinfo"
},
"files": ["src/index.ts"],
"files": ["src/index.ts", "src/LockManager.d.ts"],
"include": []
}

0 comments on commit 14436d1

Please sign in to comment.