I'm having a problem configuring Authorization Code with PKCE Flow with the Spotify API. At the moment I'm making the request for the access token, while this is happening, a re-render or something additional happens that causes the component to be reassembled and the Request User Authorization and Request Access Token process starts again. The strangest thing is that the first process does finish and I get the access token, but when there is a second process occurring at the same time, an error of this type is generated:
{"error":"invalid_grant","error_description":"Invalid authorization code"}
then the access token I obtained is no longer useful. I searched everywhere for which component or state generated a new render... I search component by component, and none generate new renders or change of state at that precise moment
const clientID = 'id from spotify dashboard'
const redirectURI = "http://localhost:3000";
const tokenEndpoint = "https://accounts.spotify.com/api/token";
const authUrl = new URL("https://accounts.spotify.com/authorize")
const scope = [
'playlist-modify-public',
'playlist-read-private',
'playlist-modify-private',
'user-read-email'
]
const Spotify = {
currentToken: {
get access_token(){
return localStorage.getItem('access_token')
},
get refresh_token(){
return localStorage.getItem('refresh_token')
},
get expires_in(){
return localStorage.getItem('expires_in')
},
get expires(){
return localStorage.getItem('expires')
},
save(response){
const { access_token, refresh_token, expires_in } = response;
localStorage.setItem('access_token', access_token);
localStorage.setItem('refresh_token', refresh_token);
localStorage.setItem('expires_in', expires_in);
const now = new Date();
const expiry = new Date(now.getTime() + (expires_in * 1000));
localStorage.setItem('expires', expiry);
}
},
// utilities functions at the bottom
async redirectToauthorize(){
const codeVerifier = this.generateRandomString(64);
sessionStorage.setItem('debug_codeVerifier', codeVerifier); //debug
const codeChallenge = await this.generateCodeChallenge(codeVerifier);
sessionStorage.setItem('debug_codeChallenge', codeChallenge); //debug
window.localStorage.setItem('code_verifier', codeVerifier);
const params = {
response_type: 'code',
client_id: clientID,
scope,
code_challenge_method: 'S256',
code_challenge: codeChallenge,
redirect_uri: redirectURI,
};
authUrl.search = new URLSearchParams(params).toString();
window.location.href = authUrl.toString();
},
async handleRedirectAfterAuthorization() {
console.log("handleRedirectAfterAuthorization called"); //debug
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
console.log(`code before the if in handle ${code}`)//debug
if (code) {
console.log(`auth code inside handleredirect ${code}`) //debug
const token = await this.getToken(code);
if(token){
this.currentToken.save(token);
this.removeCodeFromURL();
console.log(`currentToken token saved ${localStorage.getItem('access_token')}`)//debug
console.log(`currentToken refresh token saved ${localStorage.getItem('refresh_token')}`)//debug
console.log(`currentToken token saved ${localStorage.getItem('expires_in')}`)//debug
console.log(`currentToken token saved ${localStorage.getItem('expires')}`)//debug
return true;
} else {
console.error("Token not obtained in handleRedirectAfterAuthorization");
return false;
}
}
},
async getToken(code) {
const codeVerifier = localStorage.getItem('code_verifier');
console.log(`code from calling of getToken in handleRedirect: ${code}`) //debug
**try {
const response = await fetch(tokenEndpoint,{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: clientID,
grant_type: 'authorization_code',
code,
redirect_uri: redirectURI,
code_verifier: codeVerifier,
}),
});
if (!response.ok){
console.error('Response status:', response.status);
console.error('Response status text:', response.statusText);
const errorResponse = await response.text();
console.error('Response body:', errorResponse);
return null;
}
const data = await response.json();
console.log('Complete response obtained:', data); //debug
return data;
} catch (error) {
console.log('Error fetching the token in getToken:', error)
return null;
} **
},
removeCodeFromURL() {
const url = new URL(window.location.href);
url.searchParams.delete("code");
const updatedUrl = url.toString();
window.history.replaceState({}, document.title, updatedUrl);
console.log(`URL cleaned up: ${updatedUrl}`); // debug
},
async getAccessToken() {
if (!this.currentToken.access_token){
this.redirectToauthorize()
}
if(Date.now() > this.currentToken.expiry.getTime()){
await this.getRefreshToken()
return this.currentToken.access_token;
}
return this.currentToken.access_token;
},
async getRefreshToken() {
const response = await fetch(tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: this.currentToken.refresh_token,
client_id: clientID
}),
});
const token = await response.json();
this.currentToken.save(token)
},
logOutAction() {
localStorage.clear();
window.location.href = redirectURI;
},
// utilities methods//
generateRandomString(length) {
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let text = '';
for (let i = 0; i < length; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
},
async generateCodeChallenge(codeVerifier) {
const hashed = await this.sha256(codeVerifier);
return this.base64urlencode(hashed);
},
async sha256(plain) {
const encoder = new TextEncoder();
const data = encoder.encode(plain);
return window.crypto.subtle.digest('SHA-256', data);
},
base64urlencode(a) {
return btoa(String.fromCharCode.apply(null, new Uint8Array(a)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
};
export default Spotify;
And here is the useState:
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
console.log('this component is being assembled...')//debug
if (code && !localStorage.getItem('access_token')) {
Spotify.handleRedirectAfterAuthorization().then((tokenGotten) => {
if (tokenGotten) {
setAuthorize(true);
Spotify.getUsername(tokenGotten).then(username => {
setUsername(username);
});
Spotify.getUserPlaylists().then((arrayOfSavedPlaylist)=>{
setSavedPlaylists(arrayOfSavedPlaylist)
});
} else {
console.log('there is not token')
}
});
}
}, []);
I tried to follow the process flow using console.log and there I noticed that before the request access token process is completed the component is mounted again and the request starts again. Using the same console.log I noticed that the first request completes and the second request generates the error. I want to understand what the new rendering generates and why it happens while the access token is being required. I search component by component, and none generate new renders at that moment or state changes