Reputation: 10033
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?
Upvotes: 4
Views: 3836
Reputation: 35473
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.Upvotes: 5
Reputation: 159
props will not updates on the child, because for a child component they are like immutable options: https://github.com/uberVU/react-guide/blob/master/props-vs-state.md
So you will need some ways to re-render the Child component.
The Child component has already been constructed so will not update and re-render. So you will need to use "getDerivedStateFromProps()" as a replacement from the deprecated "componentWillReceiveProps" function inside the Child component, so that when receiving updated props from the parent the child will re-render, like this:
class Child extends Component {
state = {
spotifyToken: null,
};
static getDerivedStateFromProps(props, state) {
console.log("updated props", props.spotifyToken);
if (props.spotifyToken !== state.spotifyToken) {
return {
spotifyToken: props.spotifyToken,
};
}
// Return null if the state hasn't changed
return null;
}
getTrack(event) {
const {userId} = this.props
const options = {
url: `${process.env.REACT_APP_WEB_SERVICE_URL}/get-tracks/${userId}/${this.state.spotifyToken}`,
method: 'get',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${window.localStorage.authToken}`
}
};
return axios(options)
.then((res) => {
console.log(res.data.message)
console.log(options.url)
})
.catch((error) => { console.log(error); });
}
};
Note that in the getTrack function you will use the Child state value and not the props.
Upvotes: 0
Reputation: 10676
If you want to force a rerender of your Child
component when the state of the parent component changes, you can use the key
prop. Use the spotify token as the key. When the spotify token is re-fetched and updated, it will remount your child component with the new token as well:
<Route exact path='/child' render={() => (
<Child
isAuthenticated={this.state.isAuthenticated}
isAuthorizedWithSpotify={this.state.isAuthorizedWithSpotify}
spotifyToken={this.state.spotifyToken}
key={this.state.spotifyToken}
handleCheckToken={this.handleCheckToken}
userId={this.state.id}
/>
)} />
Though this may reset any internal state that you had in your child component, as it is essentially unmounting and remounting the Child
.
The key
prop is a special prop used in React components. React uses keys to determine whether or not a component is unique, from one component to another, or from one render to another. They are typically used when mapping an array to a set of components, but can be used in this context as well. The react docs have an excellent explanation. Basically when the key prop of a component changes, it tells React that this is now a new component, and therefore must be completely rerendered. So when you fetch a new spotifyToken
, and assign that new token as the key, React will completely remount the Child
with the new props. Hopefully that makes things more clear.
The key prop will not be available from within your Child
- this.props.key
inside of your child will not be useful to try to access. But in your case, you are passing the same value to the spotifyToken
inside the Child
, so you'll have the value available there. Its common to use another prop with the same value as key
when that value is needed in the child component.
Upvotes: 0