Skip to content

Instantly share code, notes, and snippets.

@dispix
Last active February 15, 2021 16:23
Show Gist options
  • Star 39 You must be signed in to star a gist
  • Fork 10 You must be signed in to fork a gist
  • Save dispix/5a9c990bd6eea4b7f9a93fe93722baa8 to your computer and use it in GitHub Desktop.
Save dispix/5a9c990bd6eea4b7f9a93fe93722baa8 to your computer and use it in GitHub Desktop.
OAUTH2 Authentication and token management with redux-saga

Revision 5

  • Fix error parsing

Revision 4

  • Add missing yield in the login function

Revision 3

  • Add CHANGELOG.md

Revision 2

  • Extract the needRefresh generator from the fetchListener method

Revision 1

  • Remove unnecessary exports
  • Place the named exports at the end of the file for clarity
  • The fetchListener is not called by dispatching a specific action anymore. Instead, it is exported and should be called with the redux-saga call effect. This allows the saga to be cancellable.
import { delay } from 'redux-saga'
import { call, put, select, take, race, takeEvery } from 'redux-saga/effects'
import request, { constructRequest } from 'utils/request'
import { setTokens, clearTokens } from 'utils/localStorage'
import { setError } from 'containers/App/actions'
import { authorize, refresh } from './authentication'
import { makeSelectTokens, makeSelectHasUser, makeSelectRefreshing } from './selectors'
import {
TOKEN_VALIDATION_START,
TOKEN_REFRESH_SUCCESS,
TOKEN_REFRESH_ERROR,
AUTH_START,
LOGOUT,
FETCH,
} from './constants'
import {
tokenValidationSuccess,
tokenValidationError,
tokenRefreshStart,
tokenRefreshSuccess,
authSuccess,
authFail,
authClear,
} from './actions'
/**
* The saga flow for authentication. Starts with either a direct login (with
* login/password) or a validation from the token stored in the local storage.
* Once the authentication is valid, listens for calls to refresh the access token
* until the user logs out.
* @return {Generator}
*/
function* authFlowSaga() {
const hasUser = yield select(makeSelectHasUser())
while (!hasUser) {
yield call(loggedOutFlowSaga)
}
if (hasUser) {
yield takeEvery(LOGOUT, logout)
}
}
/**
* Authentication starts either with classic login or with tokens fetched from
* localStorage
* @return {Generator}
*/
function* loggedOutFlowSaga() {
const { credentials, tokens } = yield race({
credentials: take(AUTH_START),
tokens: take(TOKEN_VALIDATION_START),
})
if (credentials) yield call(login, credentials.payload.login, credentials.payload.password)
if (tokens) yield call(authenticate)
yield call(authFlowSaga)
}
/**
* API login request/response handler
* @param {String} username
* @param {String} password
* @return {Generator}
*/
function* login(username, password) {
try {
const tokens = yield authorize(username, password)
setTokens(tokens)
yield put(authSuccess(tokens))
yield call(authenticate)
} catch (err) {
const error = yield parseError(err)
yield put(authFail(error))
}
}
/**
* User logout, deletes all tokens from local storage and update the store.
* @return {Generator}
*/
function* logout() {
clearTokens()
yield put(authClear())
yield call(authFlowSaga)
}
/**
* API authentication request/response handler. Used to validate the access token
* and/or get the user object. If an `invalid_token` error is returned, tries to
* refresh the access token before throwing.
* @return {Generator}
*/
function* authenticate() {
const onError = (error) => error.statusCode >= 500
? put(tokenValidationError(error))
: call(logout)
yield makeAuthenticatedRequest({
payload: {
url: '/me',
options: { method: 'GET' },
onSuccess: (response) => put(tokenValidationSuccess(response)),
onError,
},
})
}
/**
* Listen all FETCH action and start an authenticated request (i.e. with an access
* token and a refresh mecanism).
* @return {Generator}
*/
function* fetchListener(action) {
const shouldRefresh = yield call(needRefresh)
if (!shouldRefresh) yield call(makeAuthenticatedRequest, action)
if (shouldRefresh) {
const error = yield call(refreshTokens)
if (!error) {
// Because we are listening TOKEN_REFRESH_SUCCESS in a middleware, we need
// to delay our reaction to the event to make sure it hit the store. Otherwise
// we may end-up using the old tokens in our authenticated request.
yield delay(50)
yield call(makeAuthenticatedRequest, action)
}
}
}
/**
* Checks if the access token needs to be refreshed by comparing the expiration
* date with the current date.
* @return {Bool}
*/
function* needRefresh() {
const { accessTokenExpiresAt } = yield select(makeSelectTokens())
const accessExpiration = (new Date(accessTokenExpiresAt)).getTime()
return Date.now() >= accessExpiration
}
/**
* API Refresh token request/response handler. If the application has already ask
* for new tokens, wait for the completion of the first call and return (this prevents
* multiple refresh calls that may be fired by different authenticated requests).
* @return {Generator}
*/
function* refreshTokens() {
const isRefreshing = yield select(makeSelectRefreshing())
// If the application is already waiting for a new set of tokens, wait for the
// completion of that request instead of creating a new one.
if (isRefreshing) {
const { error } = yield race({
success: TOKEN_REFRESH_SUCCESS,
error: TOKEN_REFRESH_ERROR,
})
return error
}
// Dispatch an action indicating that the application is waiting for new tokens.
yield put(tokenRefreshStart())
try {
const { refreshToken } = yield select(makeSelectTokens())
const tokens = yield call(refresh, refreshToken)
setTokens(tokens)
yield put(tokenRefreshSuccess(tokens))
return null
} catch (err) {
yield call(logout)
return err
}
}
/**
* Make a signed api call with refresh token process support. The action.payload
* must be structured like the example bellow.
* ex: {
* payload: {
* url: '/me', << can be absolute or relative url
* options: { << request fetch options
* method: 'GET',
* },
* onSuccess: tokenValidationSuccess, << action to dispatch on resolve
* onError: tokenValidationError, << action to dispatch on reject
* },
* }
* @param {Object} action
* @return {Generator}
*/
function* makeAuthenticatedRequest(action, accessToken) {
// Check for a specific outdated access token error. If the error matches, the
// saga will try to refresh the access token then retry the initial request if
// the refresh succeeds.
const isAccessExpired = (error) => error.error && error.message && error.statusCode
&& error.statusCode === 401
&& error.error === 'Unauthorized'
&& error.message === 'Invalid token: access token has expired'
const tokens = yield select(makeSelectTokens())
const { payload } = action
const { url, params } = constructRequest(
payload.url,
payload.options,
accessToken || tokens.accessToken
)
try {
const response = yield request(url, params)
yield payload.onSuccess(response)
} catch (err) {
const error = yield parseError(err)
if (isAccessExpired(error)) {
const refreshError = yield call(refreshTokens)
if (!refreshError) {
yield makeAuthenticatedRequest(action)
}
} else {
// 50x errors are handled by the root container, as these are specific server
// issues and are not page-specific.
yield error.statusCode >= 500
? put(setError(error))
: payload.onError(error)
}
}
}
/**
* If the errors is formatted by the server, tranforms it to a JS object. Otherwise,
* pass the raw error.
* @param {Object} error
* @return {Generator}
*/
function* parseError(error) {
let parsed
try {
parsed = yield error.response.json()
} catch (err) {
parsed = error.response
? { status: error.response.status, message: error.response.statusText }
: { name: error.name, message: error.message }
}
return parsed
}
// All sagas to be loaded
export fetchListener
export default [
authFlowSaga,
]
@juanda99
Copy link

juanda99 commented Feb 22, 2018

@dispix, thanks for your code! Great example.
Just two minor issues...
Don't you miss TAKE in https://gist.github.com/dispix/5a9c990bd6eea4b7f9a93fe93722baa8#file-sagas-js-L158-L159?
The other one:
FETCH constant is not used (I guess it comes from previous fetchListener implementation)

One question, isn't it clear with while (true) so you don't have to call this generator again after logout & loggedOutFlowSaga?

function* authFlowSaga() {
  while (true) {
    const hasUser = yield select(makeSelectHasUser())

    while (!hasUser) {
      yield call(loggedOutFlowSaga)
    }

    if (hasUser) {
      yield take(LOGOUT, logout)
    }
  }
}

@Absvep
Copy link

Absvep commented Dec 4, 2018

@dispix Thanks! Do you have a working git repo where one can test those sagas? Best Regards

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment