Earlier this year Spotify made two changes which broke all my web apps that are built on the Spotify API:
- All requests to the API must now be authenticated.
- The
trackset
API, which allowed “ephemeral” playlists to be created from a list of track IDs, was removed.
Now the only way for an app to create a playlist for a user is to sign them in to Spotify, ask for permission to read and write their playlists, then add a playlist permanently to their library.
One of the apps to be updated is a Chrome extension that turns BBC Radio tracklists into Spotify playlists. Updating this required knowing several pieces of information:
Content and background scripts
A Chrome extension has two separate compartments, which can only communicate by sending messages to each other:
- The content script runs in a sandbox on a web page, and has the same permissions as the web page.
- The event (background) script can access secrets and use browser APIs that are only available to extensions.
For an extension to be able to make requests to an OAuth2-authenticated endpoint, the following steps need to happen:
- The extension’s manifest file must have a
key
property (generated for this extension from a private key?). - The extension’s manifest file must specify the “identity” permission (and probably “storage”, for storing the access token):
"permissions": ["identity", "storage"]
- The extension must have a
background
property, specifying the script to be run when the browser starts up:
"background": { "scripts": ["event.js"], "persistent": false }
- The extension must have a
content_scripts
property, specifying the script to be run when a matching web page has loaded:
"content_scripts": [ { "matches" : [ "http://www.bbc.co.uk/programmes/*" ], "js" : ["content.js"] } ]
Messaging
The content script should extract whatever information it needs from the page, then pass that in a message to the background script:
chrome.runtime.sendMessage(data, result => {
// do something with result
})
The background script should listen for messages from the content script, call the API, then send the response back to the content script:
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
handleRequest(request, sendResponse)
return true // needed for asynchronous responses
})
It’s important that the background script returns true
, so that the connection is held open until the asynchronous processing has finished.
The background script is responsible for getting an access token from the OAuth2 endpoint:
const redirect_uri = chrome.identity.getRedirectURL('oauth2')
const authorize = () => new Promise((resolve, reject) => {
const state = randomState()
chrome.identity.launchWebAuthFlow({
url: 'https://accounts.spotify.com/authorize?' + buildQueryString({
client_id,
redirect_uri,
response_type: 'token',
scope: 'playlist-modify-private',
state
}),
interactive: true,
}, redirectURL => {
const params = new URLSearchParams(redirectURL.split('#')[1])
if (params.get('state') !== state) {
throw new Error('Invalid state')
}
const accessToken = params.get('access_token')
chrome.storage.local.set({ accessToken }, () => {
resolve(accessToken)
})
})
})
The background script is also responsible for making authenticated calls to the API:
const api = async (url, options = {}) => {
const accessToken = await getToken()
options.headers = {
...options.headers,
Authorization: 'Bearer ' + accessToken,
}
const response = await fetch('https://api.spotify.com/v1' + url, options)
const data = await response.json()
if (data.error) {
console.error(data.error)
if (data.error.status === 400) {
authorize() // TODO: retry
}
return
}
return data
}
Join everything together and you end up with a Chrome extension that can authenticate the current user, extract content from a web page and create a playlist in the user’s Spotify library.