DEV Community

Giles Webberley
Giles Webberley

Posted on

Getting to grips with PKCE Spotify authentication in a React js app

Hi there, this is my first article so please excuse me if there are silly errors but do please let me know if you have any suggestions.

The subject of this is dealing with the new authentication restrictions for using the Spotify Web API, including the fact that you cannot have localhost as a redirectURI any more. If you've been looking around you'll probably have faced the same issues as me, all of the tutorials tell you how to use the 'implicit grant' workflow and that has been deprecated in 2025. Although the documentation (https://developer.spotify.com/documentation/web-api) is quite good, it's also been quite a challenge for me to get to grips with as there are so many concepts that are new to me so I'm here to explain how I've got it working.

Dealing with the localhost ban for redirectURI in my Vite React project

I'm sure, having been through those other tutorials to no avail, that you already understand about going to the Spotify Developers Dashboard to get your clientID by entering some information; however that will include the first challenge I faced.
I used Vite to set up my project which is React with Javascript (not TypeScript as I am still just doing little projects for myself and I feel like, having some experience of strongly typed languages, that I can pick up TypeScript when I get to that point in my learning btw) and as you'll probably be used to these tend to run on http://localhost:[PORT]. This is no good when developing with Spotify as they only allow 'loopback addresses' and https. It seems, from looking around, that the IP address 'behind' localhost is actually http://127.0.0.1 and the port that Vite tends to select is 5173 in my case at least, this means that the 'loopback address' of my development environment is http://127.0.0.1:5173 so enter that into the redirectURI field when setting up in the Spotify Developers Dashboard. This won't work yet, and after fiddling around with using --host in my npm run dev terminal command with no success, I discovered that I could simply set it up with my vite.config.js with a little line in the defineConfig() call so the file ends up looking like this -

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import eslint from 'vite-plugin-eslint';
//I tend to set up a constants file to avoid mistyped strings etc and in
//there I have export const HOST = '127.0.0.1' and export const PORT = 5173
import { HOST, PORT } from './src/utils/constants';

//Trying out the new React Compiler which is what the babel stuff is about
export default defineConfig({
  plugins: [
    react({
      babel: {
        plugins: [['babel-plugin-react-compiler']],
      },
    }),
    eslint(),
  ],
//This is the important bit
  server: { host: HOST, port: PORT },
});
Enter fullscreen mode Exit fullscreen mode

Now when you run the terminal command npm run dev it will run on the 'loopback address' we were aiming for. It took me hours to work this out and was very frustrating but baby steps and little successes hopefully lead to great strides - now was a time to stop for the day and get myself a celebratory beer :)

NOTE - It's been several weeks since I felt the urge to write this article, I was super excited to have seemingly got it all working. Before I put it all down I played with the system I'd developed and found that it had a few bugs. I struggled to understand why it seemed to work perfectly and then it didn't so I went on a journey of discovery via research, fiddling around and logging everything so I could spot where it was falling over, and I also used the AI assistant in VS Code; between us we got to the problem. I then decided that I was going to use Tanstack Query because it deals with caching queries which I found essential for getting a user name and image for my playlist page as I was getting shut-out because I was making too many requests (429 status code btw). This then highlighted a race condition that existed and so I went back round the houses to work out a solution. Anyways, let's get back into this...

Set up some constants and get the code challenge working

I used to code for a living back in the early noughties and some habits have come back to me including quite often using a constants.js file for system wide variables that are used in a few places. It's especially useful for when you're dealing with strings as it avoids little spelling mistakes and allows auto-complete to help you out, but we'll come back to that in a minute.

When I was trying to use the existing tutorials I build little custom hooks so I was in that mindset when I created useCodeChallenge.jsx (by the way I write extensive comments whilst developing so that I can come back to projects to relearn what I'd discovered, I'll leave them in here cos they help explain my choices) -

import { useRef } from 'react';
export function useCodeChallenge() {
  //We don't want to cause any rerenders here so I'm not going to use useState, and it would be great if it were persistent hence the use of useRef
  const code = useRef('');
  const error = useRef('');
  //we'll check whether we have already got a code or error first before trying to parse the url - this is important as we don't want to keep resetting the refs on every render
  if (error.current) {
    console.error(`Error returned from Spotify auth: ${error.current}`);
    //I was setting these to an empty string but maybe I should follow the lead of Supabase and Spotify and set it to null instead?
    code.current = null;
    //we don't throw an actual error as we want to leave it open to be dealt with by the component using this hook...
  } else if (code.current) {
    error.current = null;
    //THIS COMMENT IS WRONG HOWEVER I'M LEAVING IT FOR REFERENCE AND LEARNINGS - Having spent 2 days trying to get Spotify to accept my code and return a token I tried to clear the code from the url and it seems to have worked!! However this now thinks we have no code and so it goes back to the Spotify code grabbing functionality - I will try to early return if the code ref is already set....of course not because that makes the useRef conditional which is very much not allowed!!
    //SOLUTION - The problem was actually that I was sending [object Promise] as the code challenge because I was not awaiting the sha256 function in my apiSpotify!!
  } else {
    //we have not got a code or an error yet so we'll parse the url params to see if we have been redirected back from Spotify with either
    const urlParams = new URLSearchParams(window.location.search);
    code.current = urlParams.get('code');
    error.current = urlParams.get('error');
  }

  return { code: code.current, error: error.current };
}
Enter fullscreen mode Exit fullscreen mode

Maybe I'm getting ahead of myself here but basically this checks if we have visited the Spotify authorisation page (the one where it get's you to accept the 'scope' required by the app, yknow, like if it's ok to access your playlists for example) and then extracts either the error (if the user has declined) or the code-challenge if it has all gone well. We'll be using this in our Auth component which is where a useEffect runs to go through the steps involved. Essentially this auth workflow consists of creating a random code (which is referred to as the 'code verifier') then encrypt (or 'hash') that with the SHA256 algorithm to produce our 'code challenge' which we send to Spotify to get an 'auth code' which we can swap for an 'access token' (check out the documentation at Link)

I should mention that I use localStorage for the various tokens and codes as it is the way it appeared to be done in the documentation. I would love to hear from people who understand security concerns as to whether that could be done in a better way?

Let's get back to some code. All of my api is in a file called apiSpotify.js (in a 'services' folder in the 'src' folder) and, as I mentioned earlier, I have a constants.js file (in my 'utils' folder, again, it's inside the 'src' folder) for storage keys and the such. Obviously you can adapt these for yourself but essentially it looks something like this -

export const HOST = '127.0.0.1';
export const PORT = 5173;
export const REDIRECT_URI = `http://${HOST}:${PORT}/auth`; //for testing, this means you can simply change it when you deploy your project to whatever your live address ends up as eg 'https://example.com/auth'
export const AUTH_ENDPOINT = new URL('https://accounts.spotify.com/authorize');//a URL object so that we can add search parameters etc
export const SCOPE =
  'user-read-private user-read-email playlist-read-private playlist-read-collaborative'; // check out what scopes you require for your own project
//Then all of my string keys for reasons I described earlier
export const CODE_VERIFIER_STORAGE_KEY = 'code-verifier';
export const CODE_CHALLENGE_STORAGE_KEY = 'code-challenge';
export const ACCESS_TOKEN_STORAGE_KEY = 'access-token';
export const REFRESH_TOKEN_STORAGE_KEY = 'refresh-token';
export const AUTH_CODE_STORAGE_KEY = 'auth_code';
export const EXPIRATION_TIME_STORAGE_KEY = 'token-expiration-time';
export const AUTH_PATH = '/auth';
Enter fullscreen mode Exit fullscreen mode

Then we import them into the top of our apiSpotify file -

import {
  REDIRECT_URI,
  AUTH_ENDPOINT,
  SCOPE,
  CODE_VERIFIER_STORAGE_KEY,
  ACCESS_TOKEN_STORAGE_KEY,
  CODE_CHALLENGE_STORAGE_KEY,
  REFRESH_TOKEN_STORAGE_KEY,
  EXPIRATION_TIME_STORAGE_KEY,
  AUTH_CODE_STORAGE_KEY,
} from '../utils/constants.js';
Enter fullscreen mode Exit fullscreen mode

Next we want to grab the client-id that we generated whilst setting up the project in the Spotify developers dashboard. We don't need the client-secret as that was for the implicit-grant workflow that has been deprecated so it's safe to put that into your code (although keeping it in a .env file is how I did it - check out a tutorial about how to work with them in Vite as it's yet another thing to cover)

So the next line of apiSpotify is -

//using .env.local and this is how you access those variables within Vite
const CLIENT_ID = import.meta.env.VITE_SPOTIFY_CLIENT_ID;
Enter fullscreen mode Exit fullscreen mode

(Be aware, if you use Netlify this may not work as expected so simply initialise the variable with your client-id string, apparently it's completely safe)

The next chunk is all about creating the code-challenge, code-verifier, etc

//first generate a random string as the code-verifier with this code from Spotify docs https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow
const generateRandomString = (length) => {
  const possible =
    'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  //now fill a typed array with 'length' amount of secure random values
  const values = crypto.getRandomValues(new Uint8Array(length));
  //then map those values to characters from the possible string
  return values.reduce((acc, x) => acc + possible[x % possible.length], '');
};

//Next we need to create the code-challenge, it needs to be hashed using SHA256 and then base64 encoded
async function sha256(plain) {
  const encoder = new TextEncoder();
  const data = encoder.encode(plain);
  return await window.crypto.subtle.digest('SHA-256', data);
}

//base64 encode the hash
function base64encode(input) {
  return btoa(String.fromCharCode(...new Uint8Array(input)))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

//These were all defined in the main thread and so were being recreated between sending the code challenge and requesting the token with the verifier - moving them here and calling within the gotoAuth function seems to have solved the problem
async function createCodeChallengeWithVerifier() {
  const codeVerifier = generateRandomString(64);
  //using await avoids the codeChallenge being saved as [object Promise] which is what I had been sending to Spotify!!
  const codeChallenge = await sha256(codeVerifier)
    .then((hashed) => {
      //now return the base64 encoded hash so that it will be stored in the variable codeChallenge...
      return base64encode(hashed);
    })
    .catch((e) => {
      throw new Error(`Error generating code challenge: ${e}`);
    });

  //Now we'll pop our code challenge in local storage to compare later
  window.localStorage.setItem(CODE_VERIFIER_STORAGE_KEY, codeVerifier);
  window.localStorage.setItem(CODE_CHALLENGE_STORAGE_KEY, codeChallenge);
}
Enter fullscreen mode Exit fullscreen mode

(I'm just using .then() and .catch() as it's very brief and compact but I got into using try-catch as I went on developing the codebase, as you'll see...)

Hopefully the comments are enough to guide you through all this but I would suggest that you look into how all of these functions and processes work as I found it extremely interesting and it helped me to understand a lot of features that I had never used or learned about before. You'll see why we store them in localStorage when I share the Auth component but it's also to make sure we don't do any of these processes more than once as you'll see in the next block.

Time to get an auth code from Spotify which we can swap for an access token

Now we're ready to do the first step of this process, namely to get an auth-code back from Spotify which we can swap for an access token, with this function -

//This creates the code challenge and verifier then redirects to Spotify for authorisation code which we can use to request an access token
export async function gotoSpotifyAuth() {
  //returns a promise so we need to wait for it to resolve (or reject, but that is unlikely given what it does)...
  try {
    await createCodeChallengeWithVerifier();
  } catch (error) {
    throw new Error(
      `gotoSpotifyAuth failed to create a code challenge correctly: ${error}`
    );
  }

  const codeChallenge = window.localStorage.getItem(CODE_CHALLENGE_STORAGE_KEY);
  //next we build our request string
  const params = {
    client_id: CLIENT_ID,
    response_type: 'code',
    redirect_uri: REDIRECT_URI,
    scope: SCOPE,
    code_challenge_method: 'S256',
    code_challenge: codeChallenge,
  };
  //as the AUTH_ENDPOINT is a URL object we can set the search params directly
  AUTH_ENDPOINT.search = new URLSearchParams(params).toString();
  //then redirect to the modified AUTH_ENDPOINT (namely with the params added to the URL object)
  window.location.href = AUTH_ENDPOINT.toString();
}
Enter fullscreen mode Exit fullscreen mode

Let's build our Auth component that uses our authorisation api logic

When Spotify responds to this by redirecting back to us with a code param it's where our useCodeChallenge hook comes into play. Let's move away from the api for a moment and introduce our Auth component that is coordinating the process. We simply have to navigate to this route (I'm using react-router btw because at the beginning I assumed the 'loader' capabilities would come in handy, which they did) and then it get's the access token and navigates to our app (in my case a page that lists all of the users playlists)

Auth.jsx

import { useEffect, useState } from 'react';
import { gotoSpotifyAuth, requestToken } from '../services/apiSpotify';
import { useCodeChallenge } from '../hooks/useCodeChallenge';
import {
  ACCESS_TOKEN_STORAGE_KEY,
  AUTH_CODE_STORAGE_KEY,
  REDIRECT_URI,
} from '../utils/constants';
import { useNavigate } from 'react-router-dom';
import Spinner from '../ui/Spinner';
import Header from '../ui/Header';

function Auth() {
  console.log('Home component rendered');
  //code is the ref.current - if no value then we have not gone to spotify for an auth code, if it has a value then it is the code that has been returned
  const { code, error } = useCodeChallenge();
  // fatality is where we capture any errors that are thrown so we can display the details to the user
  const [fatality, setFatality] = useState(null);
  // as we're using react-router
  const navigate = useNavigate();

  //btw, if you want a spinner to show whilst anything is loading then just put the html and css in your index.html 'root' element 
  useEffect(() => {
    //little check to see if we're online before trying any of this and return to the homepage (Landing) if not
    if (!navigator.onLine) {
      navigate('/');
    }
    //if we have already got the code then this function 'swaps' it for the actual access token - used in two places hence extracting it to it's own function
    async function getTokenWithCode(code) {
      try {
        await requestToken(code);
        console.log(`Token successfully requested`);
        //use the replace so the user can't click back to this page
        navigate('/playlists', { replace: true });
      } catch (error) {
        setFatality(
          `Error requesting token within getTokenWithCode. Error: ${error}`
        );
        //clear up any previous attempts if an error is thrown
        window.localStorage.removeItem(AUTH_CODE_STORAGE_KEY);
      }
    }

    async function handleAuthFlow() {
      //we have come back to Auth although we already have an access token so just navigate to playlists
      if (window.localStorage.getItem(ACCESS_TOKEN_STORAGE_KEY) && !error) {
        console.log(`Access token already exists - navigating to playlists`);
        navigate('/playlists', { replace: true });
        // I use early returns as I find it more readable
        return;
      }
      //we have gone to the spotify auth and got our code which we can 'swap' for a token
      const authCodeFromStorage = window.localStorage.getItem(
        AUTH_CODE_STORAGE_KEY
      );
      //check that we haven't got an access token already
      if (
        authCodeFromStorage &&
        !window.localStorage.getItem(ACCESS_TOKEN_STORAGE_KEY)
      ) {
        await getTokenWithCode(authCodeFromStorage);
        return;
      }
      //we've had a code returned to us so save it for the next cycle
      if (code && !window.localStorage.getItem(AUTH_CODE_STORAGE_KEY)) {
        window.localStorage.setItem(AUTH_CODE_STORAGE_KEY, code);
        // window.location.reload(); - no this is not the correct way, we'll just try to get the token immediately
        // remove ?code=... from the url without reload
        const url = new URL(window.location.href);
        url.searchParams.delete('code');
        // clear the code from history and logs so it's a bit more secure
        window.history.replaceState(
          {},
          document.title,
          url.pathname + url.search
        );

        // immediately start exchanging code for token
        await getTokenWithCode(code);
        return;
      }

      //we have not gone to spotify for an auth code yet so we cannot get our token yet
      else if (!code && !error) {
        console.log(
          `No code found in Home so calling gotoSpotifyAuth() to redirect user to Spotify login/auth page`
        );
        //gotoSpotifyAuth can throw an error so we'll want to catch that
        try {
          gotoSpotifyAuth();
        } catch (error) {
          setFatality(
            'Error thrown by gotoSpotifyAuth whilst being called from Home:',
            error
          );
        }
      }
    }
    //and now of course we call the function we've just created
    handleAuthFlow();
  }, [code, error, navigate]);

  if (error) {
    return (
      <main className="app-layout">
        <Header />
        <div className="error-content">
          <h1>Please accept the usage of Spotify to use this app</h1>
          <h2>We received an error message from Spotify: {error}</h2>
          <button onClick={() => window.location.replace(REDIRECT_URI)}>
            Try Again
          </button>
        </div>
      </main>
    );
  }

  if (fatality) {
    return (
      <main className="app-layout">
        <Header />
        <div className="error-content">
          <h1>
            We're very sorry but something has gone wrong whilst trying to log
            into Spotify. This could be due to network problems or a server
            error
          </h1>
          <h2>We received an error message: {fatality}</h2>
          <button onClick={() => window.location.replace(REDIRECT_URI)}>
            Try Again
          </button>
        </div>
      </main>
    );
  }

  return <Spinner />;
}

export default Auth;

Enter fullscreen mode Exit fullscreen mode

That's it, we are basically using the local storage variables as flags to work through the process essentially allowing our api and frontend to communicate (I tend to think of programming as characters having conversations with each other, just my thing :/).

Get our access token and deal with expiration and using the refresh token

Now we're really getting somewhere in terms of getting an access token by using our api method called requestToken(); here it is inside the apiSpotify file -

//Once we've got the code from spotify we use this to 'swap' it for an access token
export async function requestToken(code) {
  //this should have been stored during execution of gotoSpotifyAuth along with the code that has been passed in here
  const codeVerifier = window.localStorage.getItem(CODE_VERIFIER_STORAGE_KEY);
  const url = 'https://accounts.spotify.com/api/token';
  //whilst developing this it was all working until I enabled network throttling at which point it failed. After a day of searching and trying to troubleshoot with a bit of AI assistance I discovered that the serialisation process involved in using URLSearchParams was taking a moment longer somehow (which I don't fully understand tbh). By creating the serailised body first and then passing that in as the body it seems to work fine even with throttling enabled - This is an important lesson which I had no idea about until this point.
  const body = new URLSearchParams({
    client_id: CLIENT_ID,
    grant_type: 'authorization_code',
    code: code,
    redirect_uri: REDIRECT_URI,
    code_verifier: codeVerifier,
  }).toString();

  const payload = {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: body,
  };

  try {
    //see these methods below
    const response = await fetchPayloadResponse(url, payload);
    await setAccessTokenStorage(response);
    //clear up the now defunct code based keys (they expire after 10 minutes anyway)
    window.localStorage.removeItem(CODE_VERIFIER_STORAGE_KEY);
    window.localStorage.removeItem(CODE_CHALLENGE_STORAGE_KEY);
    window.localStorage.removeItem(AUTH_CODE_STORAGE_KEY);
    return response.access_token;
  } catch (error) {
    throw new Error(`requestToken failed with error: ${error}`);
  }
}

//because I'm copying and pasting some stuff I'll extract it into functions
//first a function to handle fetch requests with a payload and deal with errors
async function fetchPayloadResponse(url, payload) {
  const result = await fetch(url, payload);
  //to fix the error refreshing caused by react-query and improving the error messages
  const text = await result.text();
  let body;
  try {
    body = text ? JSON.parse(text) : null;
  } catch {
    body = text;
  }

  if (!result.ok) {
    console.error('Fetch failed:', result.status, body);
    throw new Error(
      `fetchPayloadResponse error: status=${
        result.status
      } body=${JSON.stringify(body)}`
    );
  }

  if (body && body.error) {
    throw new Error(
      `Response error from fetchPayloadResponse: ${body.error} - ${
        body.error_description || ''
      }`
    );
  }

  return body;
}

//this is used when the access token is returned from spotify and adds the expiration time to local storage so it can be tested against when consuming the token and refresh if it's getting close to expiring
async function setAccessTokenStorage(response) {
  if (!response)
    return Promise.reject(
      new Error(`Set access token was called with an empty response`)
    );
  window.localStorage.setItem(ACCESS_TOKEN_STORAGE_KEY, response.access_token);
  window.localStorage.setItem(
    REFRESH_TOKEN_STORAGE_KEY,
    response.refresh_token
  );
  //to deal with the access token expiring we'll store the expiration time and then we can compare against it within the useAccessToken function
  const expirationTime = new Date().getTime() + response.expires_in * 1000; //expires_in is in seconds!
  window.localStorage.setItem(EXPIRATION_TIME_STORAGE_KEY, expirationTime);
  return Promise.resolve('setAccessTokenStorage successful');
}
Enter fullscreen mode Exit fullscreen mode

As you'll have noticed our access token doesn't come on it's own, it has a limited lifetime (which is returned as expires_in property of the response object) and a refresh token (which is returned as refresh_token property of the response object) which can be swapped for a new token when the original one expires. We'll get into this in a moment because it's an important factor in how you make the user experience less 'bumpy'.

Essentially you send this access token with every request you make to the Spotify API and I wanted it to automagically refresh if it was expired, hence adding the EXPIRATION_TIME_STORAGE_KEY to our local storage variables. I decided the best way to implement this behaviour was to make every api method (like getPlaylists() for example) use a getAccessToken function which checks if it is still valid or if it needs refreshing.

A little note about using Tanstack (react) Query with this api

This is where I came across problems once I started using Tanstack Query as it was leading to what I understand to be called 'race conditions'. I discovered that you can add an 'enabled' property to your queries which stops them trying to execute a query when it's going to fail, for this api I created a function called isLoggedIn that returns a Boolean and acts as the value of the enabled property. As an example here is my useUser query-hook, and you'll see the isLoggedIn definition in the apiSpotify continued below -

import { useQuery } from '@tanstack/react-query';
import {
  getUserProfile,
  isLoggedIn,
} from '../services/apiSpotify';

export function useUser(id = null) {
  const userKey = id === null ? 'me' : id;
  let enableCheck = isLoggedIn();
  const {
    status,
    fetchStatus,
    data: user,
    error,
  } = useQuery({
    queryKey: ['user', userKey],
    queryFn: () => getUserProfile(id),
    enabled: enableCheck,
    useErrorBoundary: true,
  });

  return { status, fetchStatus, user, error };
}
Enter fullscreen mode Exit fullscreen mode

I think the comments describe what's going on here and I'll include one of my api methods that uses it so you can see what to do. So, here it is, the part of the process that looks after using the access token and refreshing it if it's about to expire, along with the isLoggedIn method used by Tanstack Query's enabled flag.
apiSpotify.js (cont)

//fixing the refresh token races - react-query is using unrefreshed tokens so this was my solution, a single promise that will be shared by all simultaneous calls to refresh the token (a bit like a mutex apparently although I seem to remember from my days of playing with multithreaded code that a mutex is more about locking than sharing a resource)
let refreshingPromise = null;

async function refreshAccessToken() {
  //if we're already in the process of refreshing then return the ongoing promise
  if (refreshingPromise) {
    console.log(
      'refreshAccessToken has been called again while the refreshingPromise already exists'
    );
    return refreshingPromise;
  }
  //otherwise create the promise that is expected by the call to this function with an immediately invoked function implementation
  refreshingPromise = (async () => {
    const refreshToken = window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY);
    if (!refreshToken) {
      refreshingPromise = null;
      throw new Error('No refresh token available for refreshAccessToken');
    }
    console.log('Refreshing access token...');
    const url = 'https://accounts.spotify.com/api/token';
    const body = new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: CLIENT_ID,
    }).toString();
    const payload = {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      body: body,
    };
    const response = await fetchPayloadResponse(url, payload);
    await setAccessTokenStorage(response);
    refreshingPromise = null;
    return response.access_token;
  })().catch((error) => {
    //clear the promise on failure so it doesn't block future attempts
    refreshingPromise = null;
    throw error;
  });

  return refreshingPromise;
}

//I need to work out how to deal with the access token expiring and refresh it - apparently you receive a 401 error when expired at which point we'll want to use the refresh token to get a new one. Instead of doing that I'm going to save the UTC expiration time and simply compare that against 'now' in milliseconds
//rather than grab the access token whenever it's needed I'm going to use this function which will check the expiration time and refresh if needed before returning the token
//THIS DOES NOT THROW AN ERROR BUT INSTEAD SIMPLY RETURNS FALSE / ACCESS TOKEN
export async function getAccessToken() {
  console.log('getAccessToken called....');
  let refreshNeeded;
  try {
    refreshNeeded = isTokenExpiring();
  } catch (error) {
    console.error(error);
    return false;
  }
  //check whether there's at least 5 minutes left on the token
  if (refreshNeeded) {
    //token is expired or about to expire so we need to get a new one
    console.log(`Access token expired or about to expire - refreshing`);
    try {
      await refreshAccessToken();
    } catch (error) {
      console.error(`getAccessToken failed to refresh with error: ${error}`);
      return false;
    }
  }
  const accessToken = window.localStorage.getItem(ACCESS_TOKEN_STORAGE_KEY);
  if (!accessToken) {
    console.error('No access token stored after refreshing');
    return false;
  }
  return accessToken;
}

//thought about using this for Tanstack Query enabled but that can only use a Boolean 
function isTokenExpiring() {
  const now = Number(new Date().getTime());
  const safe = 5000 * 60 + now; //5 minutes safety margin
  const expire = Number(
    window.localStorage.getItem(EXPIRATION_TIME_STORAGE_KEY)
  );
  //I've just made a user context which requests the user profile before we're logged in so we'll check whether the expiration time has been set as a way to know if we are logged in or not - I'll return false if not logged in rather than throwing an error - UPDATE now throwing an error because the token doesn't need refreshing if there's no expire time but instead is being called when it shouldn't be, and the user problem has been taken care of elsewhere.
  if (!expire) {
    throw new Error(`No expiration time found - user not logged in`);
  }
  //true if token needs refreshing
  return expire - safe <= 0;
}

//I want to be able to protect the playlists and playlist component so just want a little function that checks if we have logged in or are trying to go to a link
export function isLoggedIn() {
  if (!window.localStorage.getItem(ACCESS_TOKEN_STORAGE_KEY)) {
    return false;
  }
  return true;
}


//to allow users to 'log out' we'll just clear local storage of token related stuff
export function logoutUser() {
  window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
  window.localStorage.removeItem(REFRESH_TOKEN_STORAGE_KEY);
  window.localStorage.removeItem(EXPIRATION_TIME_STORAGE_KEY);
}
Enter fullscreen mode Exit fullscreen mode

Putting all our hard work to use

And finally that example of how I use all of this, here we get user information either for the logged in user or for another user (which I use when putting the name and image next to tracks in playlists that were added by another user)

//Just came back to this and it kinda froze which made me have another look at error handling and throwing so rather than silently fail by returning null I decided I should be throwing errors
//adding get by id for get the added-by user in tracklist
export async function getUserProfile(id = null) {
  //This is what I described in my Dev article - a single point for looking after getting/refreshing our access token
  const accessToken = await getAccessToken();
  if (!accessToken) {
    throw new Error(`No access token available - cannot fetch user profile`);
    // return null;
  }
  //if no id it's for the logged in user so we use the /me path
  const url = !id
    ? 'https://api.spotify.com/v1/me'
    : `https://api.spotify.com/v1/users/${id}`;
  //a little reminder - GET requests do not have a body so if you see extra stuff in the documentation (like limit or offset) they need to go in the URL as query params
  const payload = {
    method: 'GET',
    headers: {
      Authorization: `Bearer ${accessToken}`,
    },
  };
  try {
    const result = await fetchPayloadResponse(url, payload);
    return result;
  } catch (error) {
    throw new Error(`getUserProfile failed with error: ${error}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Thanks for reading - a quick sign off

Phoof, blimey, that was a lot. I won't go on any further to how I use all of this but I really hope it helps you to get the basic structure to develop your own Spotify based web-app without having to go through all of the disappointment when things don't 'just work as expected'.

As I said at the beginning, I'm quite new to all of this so please do let me know if there's anything I could/should do better/differently and also a quick thank you if you use it yourself, perhaps even let me know what you used it for.

All the best everyone, and good luck in whatever you do x

Top comments (0)