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

Add middleware to schedule sign-out #2904

Merged
merged 11 commits into from
Jan 22, 2021
Merged

Add middleware to schedule sign-out #2904

merged 11 commits into from
Jan 22, 2021

Conversation

jimbo
Copy link
Contributor

@jimbo jimbo commented Dec 8, 2020

Description

When a user signs in, we get a token that we store in both app state (Redux store's user slice) and local storage (signin_token, namespaced). This token has a limited lifetime before it becomes invalid (currently hardcoded to one hour), and an invalid token is removed from storage the first time it is accessed after expiration.

Unfortunately, the storage interface can't notify app state when the token expires or when it removes an expired token, since it doesn't have access to the dispatch function. This leads app state and storage to fall out of sync, such that components start to throw errors when app state tells them the user is signed in but the token has actually been invalidated.

This PR adds a middleware that schedules the app state's signOut workflow to run whenever a token is set (sign-in or re-auth). Just as the token expires, the app will sign the user out, clear the token and all user state, and refresh the page. In the future, we can remove the page refresh, but that will require some amount of component rework and regression.

Related Issue

PWA-1105

Acceptance

Verification Stakeholders

Specification

Verification Steps

  1. Sign in to a user account.
  2. Open developer tools and edit the signin_token in storage to have a ttl of 30000 (30 seconds).
  3. Refresh your browser tab (to simulate having waited approximately one hour).
  4. Wait 30 seconds.
  5. Verify that you are automatically signed out and the page automatically refreshes.
  6. Verify that there are no JS errors in the console.
  7. Repeat this process on the checkout page.

Screenshots / Screen Captures (if appropriate)

Checklist

  • I have added tests to cover my changes, if necessary.
  • I have added translations for new strings, if necessary.
  • I have updated the documentation accordingly, if necessary.

@PWAStudioBot
Copy link
Contributor

PWAStudioBot commented Dec 8, 2020

Fails
🚫 A version label is required. A maintainer must add one.
Messages
📖

Associated JIRA tickets: PWA-1105.

📖 DangerCI Failures related to missing labels/description/linked issues/etc will persist until the next push or next nightly build run (assuming they are fixed).
📖

Access a deployed version of this PR here. Make sure to wait for the "pwa-pull-request-deploy" job to complete.

Generated by 🚫 dangerJS against 2ab4ad0

@sirugh sirugh self-requested a review December 14, 2020 16:55
const CLEAR_TOKEN = userActions.clearToken.toString();
const GET_DETAILS = userActions.getDetails.request.toString();

const isSigningIn = type => type === SET_TOKEN || type === GET_DETAILS;
Copy link
Contributor

Choose a reason for hiding this comment

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

Why || GET_DETAILS? Is this a safeguard against tokens invalidating between the token set and the details fetch?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

When a user starts up the application, if they have a valid token in storage, we sign them in. Since they already have a token, a SET_TOKEN action is never dispatched. Instead, the first user action is GET_DETAILS.

Copy link
Contributor

Choose a reason for hiding this comment

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

So it's really "is signing in or is getting user details". And in both cases we set a timer based on the time remaining on the TTL. Got it.

const scheduleSignOut = store => next => action => {
const { dispatch } = store;

if (isSigningIn(action.type)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

So when we sign in, we create a timer to remove the token based on the TTL of the token just set? So this is similar to what getItem does but is also able to dispatch the signOut action and clear the token from redux (persisted as M2_VENIA_BROWSER_PERSISTENCE__signin_token).

The current iteration of the auth link also uses the getItem mechanism which should delete the token if expired.

Both seem to handle similar situations related to client-side invalidation of the token, but I still don't know if we gracefully handle a user token that invalidates server-side before the client-side expiry.

Copy link
Contributor Author

@jimbo jimbo Dec 14, 2020

Choose a reason for hiding this comment

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

Correct, getItem can't dispatch signOut, so the Redux store contains stale state (isSignedIn: true) when there's an expired token or no token in storage.

This PR doesn't handle a server-side invalidation, but I believe those are going to be much less frequent. Scheduled expiration should be the 99% case.

Copy link
Contributor

Choose a reason for hiding this comment

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

This PR doesn't handle a server-side invalidation, but I believe those are going to be much less frequent. Scheduled expiration should be the 99% case.

Right, Tommy's solution gets us there, assuming the error messages are correct. That said, I don't know enough about auth invalidation so I'll trust that you when you say that a timeout expiry is the most common scenario.

Copy link
Contributor

@sirugh sirugh left a comment

Choose a reason for hiding this comment

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

We need to solve for the cache clearing just like we do in the other sign out handlers. :(

@jimbo jimbo marked this pull request as ready for review January 6, 2021 23:00
@jimbo
Copy link
Contributor Author

jimbo commented Jan 6, 2021

We need to solve for the cache clearing just like we do in the other sign out handlers. :(

The signOut async action now calls the other cache clearing functions. 👍

cartApi.getCartDetails({
apolloClient,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Passing things that can't be serialized, such as functions or objects containing functions, in action payloads is an anti-pattern in Redux. I understand the thought behind putting apolloClient in the payload, but we shouldn't have been doing this.


return async function thunk(dispatch, getState) {
return async function thunk(dispatch, getState, { apolloClient }) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Now that our thunk middleware includes an extraArgument object with the Apollo client, we can access it from any thunk.

@@ -23,6 +25,8 @@ export const signOut = (payload = {}) =>
await dispatch(clearToken());
await dispatch(actions.reset());
await clearCheckoutDataFromStorage();
await clearCartDataFromCache(apolloClient);
await clearCustomerDataFromCache(apolloClient);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Here, we clear the rest of the cache data.

Copy link
Contributor

@sirugh sirugh Jan 7, 2021

Choose a reason for hiding this comment

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

Beautiful - plan on applying this to the other actions like cart?

Also, now that signOut handles this, can we remove it from the sign out handlers like here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It looks like maybe we have to add it to the signIn async action as well in order to remove it from other handlers.

import thunk from 'redux-thunk';

export const extraArgument = {};
export default thunk.withExtraArgument(extraArgument);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fortunately for us, redux-thunk has already considered our exact situation and provides a method that makes it easy to provide our API client to every thunk.

apolloClient = apollo.client;
} else {
apolloClient = new ApolloClient({
const apolloClient = useMemo(() => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This should always have been memoized. Performing side effects in the body of a render function (component) has always been an anti-pattern; here, the original code was creating multiple Apollo clients as Adapter is executed several times during initial rendering.

An easy mistake to make, but a very important one to avoid.

await apolloClient.persistor.restore();

// attach the Apollo client to the Redux store
attachClient(apolloClient);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the only real change to this file. Ideally, we would create the Apollo client at the same time we create the Redux store and provide it directly to the middleware, but since we've delegated client creation to Adapter, we have to wait and add it here.

// Clear the cart data from apollo client if we get here and
// have an apolloClient.
if (apolloClient) {
await clearCartDataFromCache(apolloClient);
Copy link
Contributor

Choose a reason for hiding this comment

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

Why did we remove this?

sirugh
sirugh previously approved these changes Jan 8, 2021
Copy link
Contributor

@sirugh sirugh left a comment

Choose a reason for hiding this comment

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

Excellent work on this solution. And nice find on the adapter bug.

I do have concerns about alerting the user that they are being signed out, but I believe we are going to handle that detail in another ticket. The main point of this effort was to not leave the user in a stuck state where their token was invalid but not cleared.

@dpatil-magento
Copy link
Contributor

dpatil-magento commented Jan 14, 2021

@jimbo Pls check 2 observations -

  1. Even if user is actively working is session, they would be automatically logged out after 60mins? (I tested it by changing ttl to 100 secs).
  2. On Mobile I kept session open for 60mins and dont see automatic sign-out but on refresh it did signout. And on trying to click Proceed to Checkout after session has expired > User get error Toast and after 2-3 secs user gets auto signed-out.
    Note - Desktop chrome browser/simulator is working fine.

@dpatil-magento
Copy link
Contributor

dpatil-magento commented Jan 15, 2021

@jimbo Looks like issue is happening on desktop browser as well, if you leave idle for an hour then auto sign-out does not happen unless new call is made.

Image from Gyazo

@dpatil-magento
Copy link
Contributor

QA Approved.

@dpatil-magento dpatil-magento merged commit 7dd0edd into develop Jan 22, 2021
@dpatil-magento dpatil-magento deleted the jimbo/PWA-1105 branch January 22, 2021 16:51
@jimbo jimbo added the version: Minor This changeset includes functionality added in a backwards compatible manner. label Jan 26, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
pkg:peregrine pkg:venia-ui Progress: done version: Minor This changeset includes functionality added in a backwards compatible manner.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants