Skip to content
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

Enable cross tabs communication to sync authState #478

Merged
merged 25 commits into from
Oct 8, 2020

Conversation

shuowu
Copy link
Contributor

@shuowu shuowu commented Sep 18, 2020

Changes:

  • Make storage change in the renew process as atomic operation to prevent others tabs read false-positive states when calling updateAuthState()
  • Listen on storage event in TokenManager
    • reset expire timeouts base on new tokens in storage
    • compare old&new values, then emit events for downstream (authStateManager) to consume
  • Handle unreliable localStorage issues for IE (wait for 1s then read)

lib/TokenManager.ts Outdated Show resolved Hide resolved
lib/TokenManager.ts Outdated Show resolved Hide resolved
expect(instance._emitEventsForCrossTabsStorageUpdate).not.toHaveBeenCalled();
});

describe('_emitEventsForCrossTabsStorageUpdate', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way to test to make sure that we don't cause extra calls? I don't see anything in the code that's wrong, but a test making sure that an emitted event doesn't cause a chain of events continuously bouncing between tabs would help future-proof the code.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extra calls may happen, as we still want to keep autoRenew running in each single tab. Then we handle it in AuthStateManager to throttle the emitted events by comparing the state and cancelablePromise. https://github.com/okta/okta-auth-js/pull/484/files#diff-c87977be240b146c8cc1ab3c11a6cc66R71
Also, the events just triggered by storage change, but it will not trigger another storage change, then we should be save from the infinite loop.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall, yes, extra calls may happen. I'm just saying we should test to make sure that if only one call is expected (because artificial test situation) only one call happens.

That helps make sure we don't shoot ourselves in the foot later (as you say, currently it won't happen. I want to make sure later changes don't break that assumption).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with the future-proof point. I just don't feel it's easy to simulate the multiple tabs env in unit test, if we just trigger the function by emitting a StorageEvent, I don't think it can help too much.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, that's more involved than I was thinking. More of:

  • test that when we make a change, we only emit exactly one event, not more than one
  • test that when we get an event that doesn't require a change, we don't emit an event

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

StorageEvent can only be triggered outside the current tab, maybe I can try wdio as Aaron suggested.

test that when we get an event that doesn't require a change, we don't emit an event

yep 👍 , looks like more tests are needed.

@shuowu shuowu requested a review from swiftone September 23, 2020 21:45
expect(name).toEqual('message');
listener = fn;
// expect(name).toEqual('message');
listeners.message = fn;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be

if (name === 'message') {
  listeners.message = fn;
}|

@aarongranick-okta
Copy link
Contributor

At a high level, I'm not sure we should call it "cross tab communication" since this will also keep multiple instances within a tab in sync, if they have tokens in memory (such as authState). It's handling "storage events", if this storage is shared between tabs then it will affect instances in multiple tabs. But even within a single tab using sessionStorage, this logic still applies.

@swiftone
Copy link
Contributor

since this will also keep multiple instances within a tab in sync

Will it? I don't believe the event will be received by other code in the same tab that emits the code.

Object.keys(newTokens).forEach(key => {
const oldToken = oldTokens[key];
const newToken = newTokens[key];
if (JSON.stringify(oldToken) !== JSON.stringify(newTokens)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did you mean newToken (instead of newTokens) ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for catching it, dammit auto-completion...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's why I don't use auto-completion, and make boutique, artisanal typos...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That said, if the above didn't trigger a test failure, that signals a test we should add...

@@ -117,14 +142,23 @@ function setExpireEventTimeoutAll(sdk, tokenMgmtRef, storage) {
}
}

function add(sdk, tokenMgmtRef, storage, key, token: Token) {
var tokenStorage = storage.getStorage();
function resetExpireEventTimeoutAll(sdk, tokenMgmtRef, storage) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add a comment explaining what clearing and re-adding the timeouts does

// LocalStorage cross tabs update is not synced in IE, set a 1s timer to read latest value
// https://stackoverflow.com/questions/24077117/localstorage-in-win8-1-ie11-does-not-synchronize
if (isIE11OrLess()) {
setTimeout(() => emitEventsAndResetExpireEventTimeouts(), 1000);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm concerned what may happen within that 1 second. Will we be emitting adding/removed events that could clobber more recent, authoritative values? Should we check that the current value in storage is the newToken?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, I am thinking how about attach a timestamp to the emitted events, if by any chance an old event comes to updateAuthState, just ignore it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^ smart catch

Copy link
Contributor

@aarongranick-okta aarongranick-okta left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need more test coverage on this.
I would like to see how this works with authState: Calling tokenManager.add() should not cause more than one authState event.
I would also like to see testing around this ie11 delay, lets think of all the edges that could be exposed by waiting 1 second.
Our wdio E2E framework does support multiple tabs. I would like to see two app instances started in separate tabs and verify that the cross tab communication is working as we expect.

});
}

getAuthState(): AuthState {
return this._authState;
}

updateAuthState({ event, key, token }: UpdateAuthStateOptions = {}): void {
updateAuthState({ event, key, token, timestamp }: UpdateAuthStateOptions = { timestamp: Date.now() }): void {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not think this function should take any parameters or options. None of these parameters should affect the output. Is the output state a function of an event or a key? All of these params (except for timestamp) appear to be here for logging purposes. I would prefer this type of logging was done outside of this method. If an event handler has been fired, that handler can log a message before calling updateAuthState. updateAuthState itself should not take any parameters or return any value, since it should calculate the same state no matter who calls it nor however frequently it is called. Two identical calls in a row should produce the same output state.

Adding time-based logic with timestamp and Date.now() greatly increases the complexity and breaks the assumption of repeatability / idempotence since two subsequent calls with the same parameters would have different results. With the time logic, each call causes an update to internal state.

Calling this method should not produce side-effects other than emitting a state change event with the calculated state object, which is a function of stored token values. It should not matter when I call the method, the current state will always be a reflection of current storage

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can still track the timestamp, but instead of tracking it in updateAuthState, we can manage it when AuthStateManager gets the events, then make the decision there.
In this way, the internal evaluation can still benefit from the tracked timestamp, and the external evaluation won't break the assumption of repeatability/idempotence.

Base automatically changed from dev-4.1 to master October 7, 2020 17:41
@shuowu-okta shuowu-okta force-pushed the sw-cross-tabs-comm-330042 branch from 37f19fb to 3ceea90 Compare October 7, 2020 18:28
};
if (preToken) {
assert(JSON.stringify(preToken) === JSON.stringify(tabTokenMap[handle]));
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should there be an else { here? We only need/want to set preToken once, right?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should not really matter, any unequal will fail the test.


it('should update tokens cross tabs', async () => {
// // login in the latest opened tab
// await loginPopup();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

comments can be removed?

this._setLogOptions({ event: 'added', key, token });
// "added" event is emitted in both add and renew process
// Only listen on "added" event to update auth state
const shouldUpdateAuthState = (timestamp: number): boolean => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we actually need this logic? If updateAuthState is called repeatedly or unnecessarily it should not cause any negative side-effects. If it produces an identical state to the current state it will not emit a change, so will it actually matter if an "older" event was the reason it is being called?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so will it actually matter if an "older" event was the reason it is being called

Yes, the logic here is to handle the 1s delay hack for IE.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah its a hack to call the events later, but does that actually cause a problem? And if it doesn't cause a problem why do we need extra logic to suppress the calls?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other than the cross tabs update, each tab is still running autoRenew process by itself. In this case, the delayed event will override the value from the tab's own autoRenew result if it comes later.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand "override the value". Isn't it safe to call updateAuthState at any time? It will read tokens from storage and, if these are different than what is currently in the authState, then it will emit a change event. If there is no change in the state, then nothing is emitted and it is essentially a no-op. If 1 second after an auto renew, the values in storage have changed, then we want this change event, right?


// LocalStorage cross tabs update is not synced in IE, set a 1s timer to read latest value
// https://stackoverflow.com/questions/24077117/localstorage-in-win8-1-ie11-does-not-synchronize
if (isIE11OrLess()) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about having this function always called in a setTimeout, but have the time value be configurable. We can set it by default to 0 unless IE11 where we set it by default to 1000. But we could also manually set it to a value in our test/sample apps to verify that the logic works as we expect without needing to fake IE11

@shuowu-okta shuowu-okta force-pushed the sw-cross-tabs-comm-330042 branch from b788356 to a9263dd Compare October 7, 2020 22:36
@codecov-io
Copy link

Codecov Report

Merging #478 into master will increase coverage by 0.17%.
The diff coverage is 98.97%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master     #478      +/-   ##
==========================================
+ Coverage   90.83%   91.00%   +0.17%     
==========================================
  Files          33       32       -1     
  Lines        1767     1834      +67     
  Branches      391      406      +15     
==========================================
+ Hits         1605     1669      +64     
- Misses        162      165       +3     
Impacted Files Coverage Δ
lib/AuthStateManager.ts 89.32% <92.85%> (-4.23%) ⬇️
lib/TokenManager.ts 98.14% <100.00%> (+0.52%) ⬆️

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update d3cb262...b671d9a. Read the comment docs.

@@ -303,6 +405,10 @@ export class TokenManager {
options.storage = 'cookie';
}

if (isIE11OrLess()) {
options._storageEventDelay = 1000;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be a default if running on IE11, it should not override the value we have set

Copy link
Contributor

@aarongranick-okta aarongranick-okta left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove timestamp logic on events, then 🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants