-
Notifications
You must be signed in to change notification settings - Fork 248
fix(UserManager): handle concurrent token refresh requests via leader election #434
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 {}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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}`); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Still needs to decide what key should be best for the |
||
let user : User | null = null; | ||
|
||
broadcastChannel.addEventListener("message", (event : MessageEvent<User>) => { | ||
DASPRiD marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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); | ||
} | ||
Comment on lines
+292
to
+295
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The way that this code handles competing requests seems leaky. It looks like when two tabs both ask for a lock on the refresh token state, they can still both send refresh tokens sequentially. It seems to make an assumption that sending a message informing the other tab of the new user via the broadcast channel is a synchronous operation, but if this were the case, no web locks would be necessary at all. I doubt that there will ever be a case where releasing and re-obtaining the lock takes longer than sending a BroadcastChannel message, but I think the possibility is there. Instead, I think it would be better to use the Web Locks API to perform leader election: const topic = `refresh_token_${state.refresh_token}`;
const broadcastChannel = new BroadcastChannel(topic);
const bcUserPromise = new Promise(resolve => broadcastChannel.addEventListener(ev => resolve(ev.data)));
const user = await navigator.locks.request(topic, { ifAvailable: false }, async (lock) => {
// a null `lock` implies that this tab lost in leader election
if (lock) {
const user = await refreshUser();
await this.storeUser(user);
broadcastChannel.postMessage(user);
return user;
} else {
return await bcUserPromise;
}
});
broadcastChannel.close();
this._events.load(user); There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I like this implementation best. @kherock, just a few questions:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I didn't think about the scenario of the tab being interrupted, but more realistically, my code doesn't handle when the leader encounters an error either.
I think think finding a "proper" lock key to use in a separate issue. My hunch is that we should lock onto There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In instances where sessionStorage isn't shared, how would two tabs obtain the same refresh token in the first place? Also, see this comment: #434 (comment) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @DASPRiD, I'm using neither LocalStorage nor SessionStorage, I'm keeping everything in memory. But as I see it, this concurrency issue exists no matter the type of storage, so I would opt for a way to solve it once and for all (and not only for LocalStorage). Why do you think your proposal does only cover LocalStorage ? I was convinced that simply using a common lock key would be enough to solved it for SessionStorage/MemoryStorage too. Don't you think ? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Actually no, this issue does not affect memory storage, as memory storage is always bound to it's own session without sharing it with any other process. The problem with SessionStorage is the part about session storage duplication when a tab is duplicated. In that instance, they will share the same session (from the perspective of the IDP), but not from their own perspective. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @kherock Actually, I do see an issue with using client ID + sub as the lock key: It would block unrelated tabs with different sessions but same client ID + sub from performing their refresh, that's why I was using the refresh token. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ok, then we are talking about two different concurrency issues.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Well yeah, the issue is absolutely on their side. Solving it with a shared lock key like that is a hacky way to solve your issue though. Especially since it would only really solve it as long as your are using local storage. If you are using anything else (based on Kheros asked change of not using the BroadcastChannel), this would not help with your issue. It would, as mentioned above, also introduce the issue that it would block possibly unrelated tabs from performing their refresh. I really only see one way here to address your issue without affecting all other users:
@kherock Actually, instead of using the refresh token, we could also add a randomly generated ID to the store which is used as common identifier for the lock, but when Although to ultimately address their issue, we'd need to use the BroadcastChannel. Thinking about that, we could add a delay of maybe 250ms or so into the leader function. That would address any possible common race condition. We probably might not even need such a long delay. |
||
|
||
await this.storeUser(user); | ||
this._events.load(user); | ||
return user; | ||
}, | ||
); | ||
} | ||
|
||
/** | ||
|
Uh oh!
There was an error while loading. Please reload this page.