question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Question: Authentication flow

See original GitHub issue

I’m trying to implement user authentication with following requirements:

  • Sign-in can be triggered by user action
  • Auth token must be refreshed after some delay upon successful sign-in
  • If there is token stored in localStorage on app start then we must refresh it immediately

Here’s my attempt:

function* authentication() {
  let refreshDelay = null;

  let token = JSON.parse(localStorage.getItem('authToken'));

  if (token) 
    refreshDelay = call(delay, 0);  // instant refresh

  while (true) {
    const {action} = yield race({
      action: take([SIGN_IN, SIGN_OUT]),
      delay: refreshDelay || call(waitForever)
    });
    refreshDelay = null;

    if (action && action.type === SIGN_OUT) {
      localStorage.removeItem('authToken');
      continue;
    }

    try {
      token = yield action == null ? auth.refreshToken(token) : auth.signIn();
      localStorage.setItem('authToken', JSON.stringify(token));
      yield put(authSuccess(token));
      refreshDelay = call(delay, token.expires_in);
    } catch (e) {
      localStorage.removeItem('authToken');
      yield put(authFailure(e));
    }
  }
}

This code works well, but I wonder if there is more elegant solution for handling refresh. Currently it’s too difficult to track refreshDelay effect. I thought about extracting refresh to separate saga that waits for AUTH_SUCCESS action and then sets up a delay, but in this case I won’t be able to cancel scheduled refresh if e.g. user signs out.

Issue Analytics

  • State:closed
  • Created 8 years ago
  • Comments:46 (20 by maintainers)

github_iconTop GitHub Comments

291reactions
yelouaficommented, Jul 26, 2017

The following is my opinionated approach

First, I dont recommend calling service directly within sagas, it’d be better to use declarative calls. It makes possible testing all the operational logic inside the generator as explained in the declarative effects section

So I’ll first refactor localStorage calls into some isolated service

// Side effects Services
function getAuthToken() {
  return JSON.parse(localStorage.getItem('authToken'))
}

function setAuthToken(token) {
  localStorage.setItem('authToken', JSON.stringify(token))
}

function removeAuthToken() {
  localStorage.removeItem('authToken')
}

Another remark is regarding action watching flow. It’d be easier if you exploit the Structured programming benefits offered by generators. I’ll illustrate with a simple example, suppose our flow is just this simple sequence

SIGN_IN -> AUTHORIZE -> SIGN_OUT

Instead of doing something like

while(true) {
  const action = take([SIGN_IN, SIGN_OUT])

  if(action.type === SIGN_IN)
     const token = yield call(authorize)
     yield call(setAuthToken) // save to local storage
     yield put(authSuccess, token)
  else
     yield call(removeAuthToken)
     yield put(signout)
  }
}

You can exploit the fact that SIGN_IN and SIGN_OUT fire always in sequence and never concurrently and offload the flow control burden (where are we in the program right now ?) to the underlying generator runtime (I simplify to illustrate the concept, I ll introduce concurrency next)

function* authFlowSaga() {
  while(true) {
    // first expect a SIGN_IN
    const {credentials} = yield take(SIGN_IN)
    const token = yield call(authorize, credentials)

    // followed by a SIGN_OUT
    yield take(SIGN_OUT)
    yield call(signout)
  }
}

// reusable subroutines. Avoid duplicating code inside the main Saga
function* authorize(credentialsOrToken) {
  // call the remote authorization service
  const token = yield call(authService, credentialsOrToken)
  yield call(setAuthToken, token) // save to local storage
  yield put(authSuccess, token) // notify the store
  return token
}

function* signout(error) {
  yield call(removeAuthToken) // remove the token from localStorage
  yield put( actions.signout(error)  ) // notify th store
}

Before introducing concurrency, let’s first introduce the refresh cycles, when the token expires w’ll send again a request to the server to get a new token. so our sequence will become now

SIGN_IN -> AUTHORIZE -> REFRESH* -> SIGN_OUT

REFRESH* means many refreshes i.e. a loop in the generator

We’re still forgetting concurrency to keep things simple and progress step by step

function* authFlowSaga() {
  while(true) {
    const {credentials} = yield take(SIGN_IN)
    let token = yield call(authorize, credentials)

    // refresh authorization tokens on expiration
    while(true) {
      yield wait(token.expires_in)
      token = call(authorize, token)
    }

    yield take(SIGN_OUT)
    yield call(signout)
  }
}

But we’ve an issue there, the refresh loop executes forever, because there is no breaking condition. If the user signed out between 2 refreshes we’ve to break the loop. So the breaking condition is the SIGN_OUT action. Also the SIGN_OUT action is concurrent to the next expiration delay, So we have to introduce a race between the 2 events

function* authFlowSaga() {
  while(true) {
    const {credentials} = yield take(SIGN_IN)
    let token = yield call(authorize, credentials)

    let userSignedOut
    while( !userSignedOut ) {
      const {expired} = yield race({
        expired : wait(token.expires_in),
        signout : take(SIGN_OUT)
      })

      // token expired first
      if(expired)
        token = yield call(authorize, token)
      // user signed out before token expiration
      else {
        userSignedOut = true // breaks the loop and wait for SIGN_IN again
        yield call(signout)
      }
    }
  }
}

But there are 2 othe issues, first the authorize saga may fail if the remote server responded with an error (e.g. invalid credentials, network error …). And second, there is another concurrency issue, what if the user signed out in the middle of a refresh request/response cycle ? We’d have to cancel the ongoing authorization operation.

So first, we’ve to refactor our authorize saga

function* authorize(credentialsOrToken) {
  const {response} = yield race({
    response: call(authService, credentialsOrToken), 
    signout : take(SIGN_OUT)
  })

  // server responded (with Success) before user signed out
  if(response && response.token) {
    yield call(setAuthToken, response.token) // save to local storage
    yield put(authSuccess, response.token)
    return response.token
  } 
  // user signed out before server response OR server responded first but with error
  else {
    yield call(signout, response ? response.error : 'User signed out')
    return null
  }
}

Now if a remote authorization fails, we signout the user and return null as token. So we’ve also to refactor our main Saga to take into account the failure (null return value)

function* authFlowSaga() {
  while(true) {
    const {credentials} = yield take(SIGN_IN)
    let token = yield call(authorize, credentials)
    // authorization failed, wait the next signing
    if(!token) 
        continue

    let userSignedOut
    while( !userSignedOut ) {
      const {expired} = yield race({
        expired : wait(token.expires_in),
        signout : take(SIGN_OUT)
      })

      // token expired first
      if(expired) {
        token = yield call(authorize, token)
        // authorization failed, either by the server or the user signout
        if(!token) {
          userSignedOut = true // breaks the loop
          yield call(signout)
        }
      } 
      // user signed out before token expiration
      else {
        userSignedOut = true // breaks the loop
        yield call(signout)
      }
    }
  }
}

Now, we can think of the left requirement. What if there is a token already in local storage ? We’ll simply skip the take(SIGN_IN) step and refresh immerdiately

function* authFlowSaga() {

  let token = yield call(getAuthToken) // retreive from local storage
  token.expires_in = 0

  while(true) {
    if(!token) {
      let {credentials} = yield take(SIGN_IN)
      token = yield call(authorize, credentials)
    }

   // ... rest pf code unchanged
}

So IMO we should follow as much as we can the following

  • isolate side effects functions (api calls, dom storage, …) into separate services. This includes JSON.parse or stringify, or also wrapping api call results into {result} or {errors}
  • implement your flow step by step, starting by the simplest assumptions, then progressively introduce more requirements (concurrency, failure). The code will emerge naturally from this iterative process.
  • follow the Structured Programming approach. An if test makes only sens if we’re waiting for concurrent effects (using race) or conditional results (success or error)
  • Your code flow should reflect closely the corresponding flow of events. If you know 2 events will fire in sequence (e.g. SIGN_IN then SIGN_OUT) then write 2 consecutive takes (take(SIGN_IN) then take(SIGN_OUT)). Use race only if there are concurrent events.

As I said, the main benefit of using Generators is that it allows to leverage the power of Structured Programming and routine/subroutine approach. do you think that humans could write such complex programs using only goto jumps ?

21reactions
youknowriadcommented, Dec 24, 2015

@yelouafi what an answer 😄 this could make a good blog (medium) post I think to show the strength of Sagas

Read more comments on GitHub >

github_iconTop Results From Across the Web

Security questions authentication method - Azure
Learn about using security questions in Azure Active Directory to help improve and secure sign-in events.
Read more >
User pool authentication flow - Amazon Cognito
To verify the identity of users, modern authentication flows incorporate new challenge ... It can use custom challenges such as CAPTCHA or secret...
Read more >
7 Must-Ask Questions When Selecting An Authentication ...
Are you in the process of reviewing your authentication procedure for your customers? Struggling to determine the most effective solution for your needs?...
Read more >
What is challenge-response authentication?
Static challenges are requests that can be satisfied using the same answer or process every time. A static challenge includes the password recovery...
Read more >
Best Practices for Choosing Good Security Questions
Security questions can add an extra layer of certainty to your authentication process. Security questions are an alternative way of identifying your consumers ......
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found