|
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, 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?