OAuth in a Chrome extension

Earlier this year Spotify made two changes which broke all my web apps that are built on the Spotify API:

  1. All requests to the API must now be authenticated.
  2. 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:

For an extension to be able to make requests to an OAuth2-authenticated endpoint, the following steps need to happen:

  1. The extension’s manifest file must have a key property (generated for this extension from a private key?).
  2. The extension’s manifest file must specify the “identity” permission (and probably “storage”, for storing the access token):
    "permissions": ["identity", "storage"]
  3. The extension must have a background property, specifying the script to be run when the browser starts up:
    "background": {
      "scripts": ["event.js"],
      "persistent": false
    }
  4. 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.