Skip to content

Instantly share code, notes, and snippets.

@dispix
Last active July 22, 2024 10:27
Show Gist options
  • 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,
]
@dispix
Copy link
Author

dispix commented Apr 24, 2017

@Andarist Thanks for the comments ! The parseError function is actually sync so there's no need for a yield. However, thank you for the advice about the generators in the closure. I updated the Gist a bit so one of them doesn't exist anymore, however I put the second out of the function.

Edit: I read your comment way too fast. I updated the Gist to yield the parseError function, thanks for the insight :)

@patrickkempff
Copy link

patrickkempff commented Jun 28, 2017

@dispix thank you for this great example! Would you mind sharing the basic flow of the actions as well?

@letalumil
Copy link

@dispix thank you for sharing the gist with us! It helped a lot to clarify the process.
The only thing I'm struggling is how to use saga methods in the onSuccess/onError callbacks? I'd like to use put there, but I can't since the yield is not available there. I tried to use channels but still, have no luck.

It'd be great if you could share some usage example from another saga perspective.

@gn-ley
Copy link

gn-ley commented Jan 9, 2018

Why not throwing a Exception on a request error? And success data will be returned?

I am relatively new to Saga, but success/error callbacks seem to not a 'saga' way to do it. Correct me if I am wrong.

The saga documentation is suggesting a try/catch approach or a returned object with success or error property.

@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