The Security Implications of Token-Based Authorization
Token-based authentication employs a token to access backend services. However, the more frequently a token is used, the higher the risk of it being compromised. To improve security, a prevalent approach is using a "refresh token". Instead of relying on a single token to access the API throughout a session, two distinct tokens are used.
Understanding Access and Refresh Tokens
Post the login phase, users are provided with two tokens: the Access Token and the Refresh Token. The authorization service issues these tokens, along with some associated metadata.
{
"access_token": "<access_token>",
"refresh_token": "<refresh_token>",
"expiring_time": "<expiring_time>",
"user_id": "<user_id>"
}
The Access Token, primarily for API access, is more vulnerable to exposure. Thus, it has a set expiration time (e.g., 30 minutes). When it expires, the Refresh Token is used to procure a new Access Token.
Streamlining the Implementation
The primary goal in frontend design is to shield users from the complexities of the token-refreshing process. Some guides suggest renewing the token just before it expires. However, this doesn't account for all scenarios.
For instance, if the backend loses its login status due to updates or replication issues, tokens can expire prematurely. Consequently, clients might lose access, compelling users to re-login, which isn't ideal.
We'll now delve into a method to navigate these challenges, ensuring a seamless user experience.
Refresh Token Based on Status, Not Just Time
Every API call is examined. If a token has expired, a specific error informs us. Using AWS Cognito service as an illustration:
// Status Code 401
{
"errors" : [ {
"errorType" : "UnauthorizedException",
"message" : "Valid authorization header not provided."
} ]
}
We can inspect the error and intercept it before it affects our client-side operations. For this, we utilize "axios".
Axios offers an Interceptor function. It allows for additional procedures before sending requests or after receiving responses.
axios.interceptors.response.use(function (response) {
// Any status code that lie within the range of 2xx cause this function to trigger
// Do something with response data
return response;
}, function (error) {
// Any status codes that falls outside the range of 2xx cause this function to trigger
// Do something with response error
return Promise.reject(error);
});
In the response phase, an interceptor is set up to manage token expiration issues, and it tries to fetch a new access token. If successful, the original request is resent. This ensures that, during daily operations with our axios instance, we rarely confront authorization challenges.
Below is the detailed structure of our refresh token interceptor:
import type { AxiosInstance, AxiosRequestConfig, AxiosError } from 'axios';
interface TaskItem {
originalRequest: AxiosRequestConfig;
resolve: <T>(value?: T | PromiseLike<T>) => void;
reject: (reason?: any) => void;
}
interface Config {
axiosInstance: AxiosInstance;
authorizeHeaderName: string;
checkIfIsAuthErr: (status: number, data: any) => boolean;
/**
* Handler refresh logic, resolve with the new token str
*/
refreshTokenHandler: () => Promise<string>;
onRefreshTokenFailed?: () => void;
}
type AxiosRefreshToken = (
refreshConfig: Config,
) => (err: AxiosError) => Promise<Promise<any>>;
const createAxiosAuthErrHandler: AxiosRefreshToken = (refreshConfig) => {
const {
axiosInstance,
authorizeHeaderName,
checkIfIsAuthErr,
refreshTokenHandler,
onRefreshTokenFailed,
} = refreshConfig;
let isRefreshing = false;
const tasks: TaskItem[] = [];
const axiosRefreshTokenInterceptor = async (
err: AxiosError,
): Promise<Promise<any>> => {
// reject if it not the axios error
if (!err.response) throw err;
const {
config,
response: { status, data },
} = err;
if (!checkIfIsAuthErr(status, data)) throw err;
if (!isRefreshing) {
isRefreshing = true;
try {
const newAccessToken = await refreshTokenHandler();
isRefreshing = false;
tasks.forEach((item) => {
if (!item.originalRequest.headers) return;
item.originalRequest.headers[authorizeHeaderName] = newAccessToken;
item.resolve(axiosInstance(item.originalRequest));
});
} catch (refreshErr: InstanceType<Error>) {
isRefreshing = false;
tasks.forEach((item) => {
item.reject(refreshErr);
});
//onRefreshTokenFailed?.();
}
}
return new Promise((resolve, reject) => {
tasks.push({
originalRequest: config,
resolve,
reject,
});
});
};
return axiosRefreshTokenInterceptor;
};
export { createAxiosAuthErrHandler };
Upon facing an expiration issue, we don't immediately convey the rejection to our operational layer. Instead, we issue a new promise, postponing its execution until a new Access Token is received. If token refresh fails, appropriate actions (like resetting the login session or retrying the login) are initiated.
All we need to do now is register the interceptor with Axios:
const axiosRefreshTokenInterceptor = createAxiosAuthErrHandler({
axiosInstance: fetchInstance,
authorizeHeaderName: AUTH_HEADER,
checkIfIsAuthErr: (status, data) =>
status === 401 &&
data?.errors?.[0]?.errorType === 'UnauthorizedException',
refreshTokenHandler: refreshLogin,
onRefreshTokenFailed: oauthLogin,
});
const interceptor = fetchInstance.interceptors.response.use(
(response) => response,
async (err: AxiosError) => {
const res = await axiosRefreshTokenInterceptor(err);
return res;
},
);
Conclusion
This article elucidated a prevalent challenge in client-side development. All measures detailed here aim to enhance user and developer experiences alike. Developers are thus relieved from fretting over token expiration during their routine tasks. We hope you found this informative and helpful!
Top comments (0)