In my app, I have an access token (Spotify's) which must be valid at all times. When this access token expires, the app must hit a refresh token endpoint, and fetch another access token, every 60 minutes.
Authorize functions
For security reasons, these 2 calls, to /get_token and /refresh_token are dealt with python, server-side, and states are currently being handled at my Parent App.jsx, like so:
class App extends Component {
constructor() {
super();
this.state = {
users: [],
isAuthenticated: false,
isAuthorizedWithSpotify: false,
spotifyToken: '',
isTokenExpired:false,
isTokenRefreshed:false,
renewing: false,
id: '',
};
componentDidMount() {
this.userId(); //<--- this.getSpotifyToken() is called here, inside then(), after async call;
};
getSpotifyToken(event) {
const options = {
url: `${process.env.REACT_APP_WEB_SERVICE_URL}/get_token/${this.state.id}`,
method: 'get',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${window.localStorage.authToken}`,
}
};
// needed for sending cookies
axios.defaults.withCredentials = true
return axios(options)
.then((res) => {
console.log(res.data)
this.setState({
spotifyToken: res.data.access_token,
isTokenExpired: res.data.token_expired // <--- jwt returns expiration from server
})
// if token has expired, refresh it
if (this.state.isTokenExpired === true){
console.log('Access token was refreshed')
this.refreshSpotifyToken();
}
})
.catch((error) => { console.log(error); });
};
refreshSpotifyToken(event) {
const options = {
url: `${process.env.REACT_APP_WEB_SERVICE_URL}/refresh_token/${this.state.id}`,
method: 'get',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${window.localStorage.authToken}`,
}
};
axios.defaults.withCredentials = true
return axios(options)
.then((res) => {
console.log(res.data)
this.setState({
spotifyToken: res.data.access_token,
isTokenRefreshed: res.data.token_refreshed,
isTokenExpired: false,
isAuthorizedWithSpotify: true
})
})
.catch((error) => { console.log(error); });
};
Then, I pass this.props.spotifyToken to all my Child components, where requests are made with the access token, and it all works fine.
Watcher Function
The problem is that, when the app stays idle on a given page for more than 60 minutes and the user makes a request, this will find the access token expired, and its state will not be updated, so the request will be denied.
In order to solve this, I thought about having, at App.jsx, a watcher function tracking token expiration time on the background, like so:
willTokenExpire = () => {
const accessToken = this.state.spotifyToken;
console.log('access_token in willTokenExpire', accessToken)
const expirationTime = 3600
const token = { accessToken, expirationTime } // { accessToken, expirationTime }
const threshold = 300 // 300s = 5 minute threshold for token expiration
const hasToken = token && token.spotifyToken
const now = (Date.now() / 1000) + threshold
console.log('NOW', now)
if(now > token.expirationTime){this.getSpotifyToken();}
return !hasToken || (now > token.expirationTime)
}
handleCheckToken = () => {
if (this.willTokenExpire()) {
this.setState({ renewing: true })
}
}
and:
shouldComponentUpdate(nextProps, nextState) {
return this.state.renewing !== nextState.renewing
}
componentDidMount() {
this.userId();
this.timeInterval = setInterval(this.handleCheckToken, 20000)
};
Child component
Then, from render() in Parent App.jsx, I would pass handleCheckToken() as a callback function, as well as this.props.spotifyToken, to the child component which might be idle, like so:
<Route exact path='/tracks' render={() => (
<Track
isAuthenticated={this.state.isAuthenticated}
isAuthorizedWithSpotify={this.state.isAuthorizedWithSpotify}
spotifyToken={this.state.spotifyToken}
handleCheckToken={this.handleCheckToken}
userId={this.state.id}
/>
)} />
and in the Child component, I would have:
class Tracks extends Component{
constructor (props) {
super(props);
this.state = {
playlist:[],
youtube_urls:[],
artists:[],
titles:[],
spotifyToken: this.props.spotifyToken
};
};
componentDidMount() {
if (this.props.isAuthenticated) {
this.props.handleCheckToken();
};
};
and a call where the valid, updated spotifyToken is needed, like so:
getTrack(event) {
const {userId} = this.props
const options = {
url: `${process.env.REACT_APP_WEB_SERVICE_URL}/get-tracks/${userId}/${this.props.spotifyToken}`,
method: 'get',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${window.localStorage.authToken}`
}
};
return axios(options)
.then((res) => {
console.log(res.data.message)
})
.catch((error) => { console.log(error); });
};
But this is not working.
At regular intervals, new tokens are being fetched with handleCheckToken, even if I'm idle at Child. But if I make the request after 60 minutes, in Child, this.props.spotifyToken being passed is expired, so props is not being passed down to Child.jsx correctly.
What am I missing?
You are talking about exchanging refreshToken to accessToken mechanism and I think that you over complicated it.
A background, I've a similar setup, login generates an accessToken (valid for 10 mins) and a refreshToken as a cookie on the refreshToken end point (not necessary).
Then all my components are using a simple api service (which is a wrapper around Axios) in order to make ajax requests to the server.
All of my end points are expecting to get a valid accessToken, if it expired, they returns 401 with an expiration message.
My Axios has a response interceptor which check if the response is with status 401 & the special message, if so, it makes a request to the refreshToken endpoint, if the refreshToken is valid (expires after 12 hours) it returns an accessToken, otherwise returns 403.
The interceptor gets the new accessToken and retries (max 3 times) the previous failed request.
The cool think is that in this way, accessToken can be saved in memory (not localStorage, since it is exposed to XSS attack). I save it on my api service, so, no Component handles anything related to tokens at all.
The other cool think is that it is valid for a full page reload as well, because if the user has a valid cookie with a refreshToken, the first api will fail with 401, and the entire mechanism will work, otherwise, it will fail.
// ApiService.js
import Axios from 'axios';
class ApiService {
constructor() {
this.axios = Axios.create();
this.axios.interceptors.response.use(null, this.authInterceptor);
this.get = this.axios.get.bind(this.axios);
this.post = this.axios.post.bind(this.axios);
}
async login(username, password) {
const { accessToken } = await this.axios.post('/api/login', {
username,
password,
});
this.setAccessToken(accessToken);
return accessToken; // return it to the component that invoked it to store in some state
}
async getTrack(userId, spotifyToken) {
return this.axios.get(
`${process.env.REACT_APP_WEB_SERVICE_URL}/get-tracks/${userId}/${spotifyToken}`
);
}
async updateAccessToken() {
const { accessToken } = await this.axios.post(`/api/auth/refresh-token`, {});
this.setAccessToken(accessToken);
}
async authInterceptor(error) {
error.config.retries = error.config.retries || {
count: 0,
};
if (this.isUnAuthorizedError(error) && this.shouldRetry(error.config)) {
await this.updateAccessToken(); // refresh the access token
error.config.retries.count += 1;
return this.axios.rawRequest(error.config); // if succeed re-fetch the original request with the updated accessToken
}
return Promise.reject(error);
}
isUnAuthorizedError(error) {
return error.config && error.response && error.response.status === 401;
}
shouldRetry(config) {
return config.retries.count < 3;
}
setAccessToken(accessToken) {
this.axios.defaults.headers.common.Authorization = `Bearer ${accessToken}`; // assign all requests to use new accessToken
}
}
export const apiService = new ApiService(); // this is a single instance of the service, each import of this file will get it
This mechanism is based on this article
Now with this ApiService you can create a single instance and import it in each Component that whats to make an api request.
import {apiService} from '../ApiService';
class Tracks extends React.Component {
constructor(props) {
super(props);
this.state = {
playlist: [],
youtube_urls: [],
artists: [],
titles: [],
spotifyToken: this.props.spotifyToken,
};
}
async componentDidMount() {
if (this.props.isAuthenticated) {
const {userId, spotifyToken} = this.props;
const tracks = await apiService.getTracks(userId, spotifyToken);
this.setState({tracks});
} else {
this.setState({tracks: []});
}
}
render() {
return null;
}
}
ApiService it is created in the "module" of the file (at the end you can see the new ApiService), after that you just importing this exported instance to all the places that need to make an api call.If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With