Note: This site is currently "Under construction". I'm migrating to a new version of my site building software. Lots of things are in a state of disrepair as a result (for example, footnote links aren't working). It's all part of the process of building in public. Most things should still be readable though.

Spotify API: Vanilla JavaScript Authorization Code Flow Login Example with PKCE

This is bare-bones code for logging into the Spotify Web API with the PKCE OAuth2 flow. It's bascially straight from the docs but rolled into a single example that also pulls the user id to verify things are working.

Note: The reason this example gets so may scopes is because they are used for the rest of the examples on the site.

Example

JavaScript: Config

const clientId = 'cd3e61bde252419da1e9b1051947a9a4'
const redirectUri = 'http://localhost:3300/pages/2zzlhz3m/'
const verify_token_key = "spotify_example_verify_token"
const access_token_key = "spotify_example_access_token"
const user_id_key = "spotify_example_user_id"
const scopes = [
  'user-read-private',
  'user-read-email',
  'user-top-read',
  'user-follow-read',
  'user-library-read',
  'user-library-modify',
  'playlist-modify-public',
  'playlist-modify-private',
  'playlist-read-private',
  'ugc-image-upload',
  'playlist-read-collaborative',
  'user-read-playback-state',
  'user-modify-playback-state',
  'user-read-currently-playing',
  'user-read-recently-played',
]
const scope = scopes.join(" ")

JavaScript: Main Script

const generateRandomString = (length) => {
  const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
  const values = crypto.getRandomValues(new Uint8Array(length))
  return values.reduce((acc, x) => acc + possible[x % possible.length], "")
}

const codeVerifier  = generateRandomString(64)

const sha256 = async (plain) => {
  const encoder = new TextEncoder()
  const data = encoder.encode(plain)
  return window.crypto.subtle.digest('SHA-256', data)
}

const base64encode = (input) => {
  return btoa(String.fromCharCode(...new Uint8Array(input)))
    .replace(/=/g, '')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
}

const startLogin = async () => {
  const hashed = await sha256(codeVerifier)
  const codeChallenge = base64encode(hashed)
  const authUrl = new URL("https://accounts.spotify.com/authorize")
  window.localStorage.setItem(verify_token_key, 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()
}

const getToken = async (code) => {
  let codeVerifier = localStorage.getItem(verify_token_key)
  const payload = {
    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,
    }),
  }
  const url = new URL("https://accounts.spotify.com/api/token")
  const body = await fetch(url, payload)
  const response = await body.json()
  localStorage.setItem(access_token_key, response.access_token)
  localStorage.removeItem(verify_token_key)
  window.location.href = redirectUri
}

const getData = async (method, endpoint) => {
  /****************************************i**
     NOTE: This is a sample an only set up to 
     work with simple GET requests 
  ********************************************/
  const payload = {
    method: method,
    headers: {
      'Authorization': `Bearer  ${localStorage.getItem(access_token_key)}`
    }
  }
  const body = await fetch(`https://api.spotify.com/v1${endpoint}`, payload)
  const response = await body.json()
  return response
}

const launchApp = async () => {
  while (spotifySpace.children.length > 0) {
    spotifySpace.children[0].remove()
  }
  const logoutButton = document.createElement("button")
  logoutButton.innerText = "log out"
  logoutButton.addEventListener("click", doLogout)
  spotifySpace.appendChild(logoutButton)
  const user_data = await getData('GET', '/me')
  console.log(user_data)
  localStorage.setItem(user_id_key, user_data.id)
  spotifyId.innerHTML = user_data.id
}

const doLogout = () => {
  localStorage.removeItem(access_token_key)
  localStorage.removeItem(user_id_key)
  switchToLogin()
}

document.addEventListener('DOMContentLoaded', () => {
  if (localStorage.getItem(access_token_key)) {
    /* TODO: Check if token has expired */
    /* TODO: And deal with refresh tokens */
    launchApp()
  }
  else {
    const urlParams = new URLSearchParams(window.location.search);
    let code = urlParams.get('code');
    /* TODO: Check for error here */
    if (code) {
      getToken(code)
    } 
    else {
      switchToLogin()
    }
  }
})


const switchToLogin = () => {
  while (spotifySpace.children.length > 0) {
    spotifySpace.children[0].remove()
  }
  const loginButton = document.createElement("button")
  loginButton.innerText = "log in"
  loginButton.addEventListener("click", startLogin)
  spotifySpace.appendChild(loginButton)
  spotifyId.innerHTML = "Not logged in" 
}