Using the Spotify API with Firebase to build an album-centric music manager

Gareth Cronin
6 min readMay 8, 2023

--

When I’m building tiny apps, I avoid creating a middle “application” tier when I can. As I explained in a story a while back, calling the Firebase API directly from my usual React and Material UI Progressive Web App front end is more than adequate for basic user data storage. When the business logic gets more complex, I do build APIs with Google Cloud Functions, triggered Firebase functions, and AWS Batch for even longer running jobs.

For my latest project I wanted to identify my users with their Spotify account, and securely store their data in Firebase, without them needing to create a separate account for my app. The end result is LPify. I haven’t put it through the app approval process yet, so you’ll need to let me know your email address if you want to join my beta!

LPify at lpify.click

The “business” problem

For this use case, I wanted to build an album-centric way of listening to Spotify. Spotify’s user experience is primarily focussed on playlists and tracks, which is great some of the time, but, I often find I want to recreate the good old days of flipping through CDs and albums then listening to an album the way it was made to be listened to: all the way through.

The initial feature set is:

  • Present the user with a choice of their library (saved tracks and albums) and all of their created and followed playlists
  • Show the library and playlists as lists of albums — for playlists, this means the album that each track comes from
  • Sorting by album release date, popularity, artist, and title
  • Filtering by album type (including or excluding compilations and singles/EPs)

Spotify’s API has a solid developer experience. It’s RESTful, rich, and has plenty of handy deeply linked URL values to make it easier to navigate.

Getting Spotify and Firebase to co-operate

When I’ve used Firebase in the past, I’ve used the built in email and Google authentication support. Along with the other big social media platforms, these methods are deeply integrated into the Firebase platform and are straightforward to integrate. The Firebase Auth UI components make them even easier by providing the front end glue as well. Unfortunately it’s not quite that easy for other identity platforms, but Firebase does support custom identity providers.

Firebase will accept an arbitrary string identifier, and produce a custom token that a web UI can use to authenticate with. This means it’s possible to use a Firebase Function (with the convenient implicit Firebase admin SDK authentication) to generate a custom token for a given Spotify username.

I searched around and found many boilerplate examples of all of this working in practice, but I was unable to get any of them working well. Part of the problem was that I wanted to use the latest Firebase 9 Web API and React 18 in TypeScript, and most examples were tightly coupled to older versions.

The other problem is that OAuth is a bit of a pig. As the title says in of this blog post I read recently: “why is OAuth still hard in 2023?”. There were a range of patterns in the React+Spotify+Firebase examples I found, but none had a solid implementation of using a persistent refresh token to provide a “remember me” style authentication.

My Spotify and Firebase authentication approach

After experimenting with the three supported Spotify OAuth flows, I went with the authorisation code flow. It’s well documented in the Spotify dev docs. Because the authentication API calls need the client secret, it can only be used where that can be held securely, so that’s where the Firebase Functions come in.

I’ve used React Simple OAuth2 Login before: it does what it says on the tin. Once configured, it’ll listen to a login button of your choice and take care of the basic popup and state flow that an OAuth2 flow requires. There is also a pretty good starting example for the server side of the Spotify authorisation code flow in their repo!

On first sign-in, my app needs to send the user off to the Spotify login dialog, which redirects back to the main app URL, providing an authorisation code. I have a Firebase function that takes this and collects a refresh token, access token, and generates the Firebase auth token. Firebase Functions supports environment config values, so this is where I stored the Spotify Client ID and secrete.

First sign in flow

The code for the Firebase Function (built on Express) looks like this (note the handy use of the origin header so the function works in local dev as well):

app.post('/token', async (req, res) => {
const { code } = req.body;
const authString = Buffer
.from(`${functions.config().spotify.client_id}:${functions.config().spotify.client_secret}`)
.toString('base64');
const body = qs.stringify({
code,
redirect_uri: req.headers.origin,
grant_type: 'authorization_code',
});
const headers = {
'authorization': `Basic ${authString}`,
'content-type': 'application/x-www-form-urlencoded;charset=utf-8'
};
try {
const response = await fetch('https://accounts.spotify.com/api/token', {
method: 'POST',
headers,
body
});
const data = await response.json();
const { access_token, refresh_token } = data;
const spotifyResponse = await fetch('https://api.spotify.com/v1/me', {
headers: {
Authorization: `Bearer ${access_token}`
}
});
const spotifyUserProfile = await spotifyResponse.json();
const id = spotifyUserProfile.id;
const firebase_token = await getAuth().createCustomToken(id);
res.json({ access_token, refresh_token, firebase_token });
} catch (err) {
console.error('Error while requesting a token', err.response.data);
res.status(500).json({
error: err.message,
});
}
});

The refresh token is long-lived, but the Firebase token and access token expire after an hour. I put the refresh token in local browser storage, and store the expiry time for the other two tokens in React state. When the access tokens expire, the app calls a second Firebase function that uses the refresh token to get a new token. The code is very similar:

app.post('/refresh', async (req, res) => {
const { refresh_token } = req.body;
const authString = Buffer
.from(`${functions.config().spotify.client_id}:${functions.config().spotify.client_secret}`)
.toString('base64');
const payload = qs.stringify({
refresh_token,
redirect_uri: req.headers.origin,
grant_type: 'refresh_token',
});
try {
const response = await fetch('https://accounts.spotify.com/api/token', {
method: 'POST',
headers: {
'authorization': `Basic ${authString}`,
'content-type': 'application/x-www-form-urlencoded;charset=utf-8'
},
body: payload
});
const data = await response.json();
const { access_token } = data;

const spotifyResponse = await fetch('https://api.spotify.com/v1/me', {
headers: {
Authorization: `Bearer ${access_token}`
}
});
const spotifyUserProfile = await spotifyResponse.json();
const id = spotifyUserProfile.id;
const firebase_token = await getAuth().createCustomToken(id);
res.json({ access_token, refresh_token, firebase_token });
} catch (err) {
console.error('Error while refreshing a token', err.response.data);
res.status(500).json({
error: err.message,
});
}

});

The user’s data is protected by using Firebase Firestore rules that only allow access to a part of the store that matches the authenticated user’s Firebase/Spotify ID:

rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /users/{userId}/{documents=**} {
allow read, write: if request.auth != null && request.auth.uid == userId
}
}
}

Managing the authentication state

It is very annoying when a click throws a user into an authentication dialog unnecessarily, so I wanted to make the management of authentication state as frictionless as possible.

I came up with a small state machine, modelled in a React useState() hook so that every time the user selects a different data source (e.g. a playlist), the app checks the tokens and refreshes them if required.

In the case that it reaches the “NO_REFRESH_TOKEN” state (e.g. on first use), it shows the login button connected to React Simple OAuth2 Login:

{(appState === AppState.NO_REFRESH_TOKEN || appState === AppState.LOGIN_FAILED) && <OAuth2Login
authorizationUrl="https://accounts.spotify.com/authorize"
responseType="code"
clientId="<MY CLIENT ID>"
redirectUri={currentUrlNoTrailingSlash()}
onSuccess={onSuccess}
onFailure={onFailure}
render={({ onClick }) =>
<Button variant="contained" sx={{ mt: 2 }} onClick={onClick}>Login with Spotify</Button>
}
extraParams={{ scope: 'user-library-read,playlist-read-private,playlist-read-collaborative' }}
/>}

Conclusion

It all sounds nice and simple when I write it down here, but it took a lot of trial and error to find a combination of approaches and components that met my needs! I’m hopeful that it’ll stand up as a general pattern for building add-ons on platforms with Firebase where the authentication isn’t out of the box.

My plan from here is to add enrichment of the album data using Firebase Functions that trigger on writes and interrogate other Spotify APIs.

--

--

Gareth Cronin

Technology leader in Auckland, New Zealand: start-up founder, father of two, maker of t-shirts and small software products