This week I came up with an idea about a personal Christmas radio. I am an eager listener of Christmas tunes and every year I try to find some new favorite songs. I decided to code me a little helper that would ease the discovery process and automatically add new Christmas songs to my chosen Spotify playlist.
However, I encountered an authentication-related problem when implementing the functionality. Accessing the Spotify user APIs (such as modifying the playlists on behalf of the user) requires permission both from Spotify and the user. The user's permission is acquired through browser interaction (user logs in to Spotify and authorizes the app in the web UI), but my app was designed to work from the command line. So the problem was how to make the command line program interact with the browser flow?
The Spotify API authentication is implemented according to the popular OAuth 2.0 specification. I decided to use the authorization code flow that would suit best for my purposes. The flow has two parts: first, the client application (my radio app) directs the user to an authorization server that handles the user authentication. Then the authorization server directs the request back to the client application (to a predefined redirect URI). With this redirect is delivered a code that the client application can use for requesting the actual API access token from another endpoint.
Although this protocol may sound a bit complex at first, it provides important security benefits: the user's credentials are never shared with the client application. And on the other hand, the final access token is delivered directly to the client application. This way it's not being exposed to the browser or the user and actually, only the client application can receive the token.
So the solution was to use a temporary localhost webserver. The server lives only as long as the redirect endpoint is called and the authorization code is received. The following figure describes the steps:
- The user launches the app from the command line
- The client program starts the temporary server
- The client program launches the browser to the API authentication page.
- The user authenticates in the browser and authorizes the client application to access the API on her behalf.
- The authorization server redirects the request to the predefined redirect URL (localhost).
- The client program parses the redirect request and receives the authorization code.
- The client program exchanges the authorization code to the API access token calling the authorization server endpoint.
- The client program receives the API access token and can make API requests on behalf of the user.
I used golang to implement the app and the sample code is attached here:
package main
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"math/rand"
"net/http"
"net/url"
"os"
"github.com/pkg/browser"
)
type AuthResponse struct {
AccessToken string `json:"access_token"`
}
func fetchUserToken() string {
const (
redirectURL = "http://localhost:4321"
spotifyLoginURL = "https://accounts.spotify.com/authorize?client_id=%s&response_type=code&redirect_uri=%s&scope=%s&state=%s"
)
var (
clientID = os.Getenv("SPOTIFY_CLIENT_ID")
clientSecret = os.Getenv("SPOTIFY_CLIENT_SECRET")
authHeader = fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(clientID+":"+clientSecret)))
)
if clientID == "" && clientSecret == "" {
panic(fmt.Errorf("spotify client ID and secret missing"))
}
// authorization code - received in callback
code := ""
// local state parameter for cross-site request forgery prevention
state := fmt.Sprint(rand.Int())
// scope of the access: we want to modify user's playlists
scope := "playlist-modify-public&playlist-modify-private"
// loginURL
path := fmt.Sprintf(spotifyLoginURL, clientID, redirectURL, scope, state)
// channel for signaling that server shutdown can be done
messages := make(chan bool)
// callback handler, redirect from authentication is handled here
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// check that the state parameter matches
if s, ok := r.URL.Query()["state"]; ok && s[0] == state {
// code is received as query parameter
if codes, ok := r.URL.Query()["code"]; ok && len(codes) == 1 {
// save code and signal shutdown
code = codes[0]
messages <- true
}
}
// redirect user's browser to spotify home page
http.Redirect(w, r, "https://www.spotify.com/", http.StatusSeeOther)
})
// open user's browser to login page
if err := browser.OpenURL(path); err != nil {
panic(fmt.Errorf("failed to open browser for authentication %s", err.Error()))
}
server := &http.Server{Addr: ":4321"}
// go routine for shutting down the server
go func() {
okToClose := <-messages
if okToClose {
if err := server.Shutdown(context.Background()); err != nil {
log.Println("Failed to shutdown server", err)
}
}
}()
// start listening for callback - we don't continue until server is shut down
log.Println(server.ListenAndServe())
// authentication complete - fetch the access token
params := url.Values{}
params.Add("grant_type", "authorization_code")
params.Add("code", code)
params.Add("redirect_uri", redirectURL)
data, err := doPostRequest(
"https://accounts.spotify.com/api/token",
params,
authHeader,
)
if err == nil {
response := AuthResponse{}
if err = json.Unmarshal(data, &response); err == nil {
// happy end: token parsed successfully
return response.AccessToken
}
}
panic(fmt.Errorf("unable to acquire Spotify user token"))
}
Go provides quite nice tools for implementing this kind of concurrency handling that is needed here: the localhost server is shut down using goroutines and channels. I encourage you to check them out if Go is something new for you.
So, now I have the access token. Now I need just to make the functionality for adding the songs š
P.S. If you want to mess around with the Spotify API, remember first to register your application to get the client id and secret.
Photo by Steve Halama on Unsplash
Top comments (6)
I'm loving the idea about a personal Christmas radio!
I'm not that familiar with Go (except trough following the struggles of my spouse working with it š ) but seeing the Go's
panic
-function always makes me smile šHaha š Yes, the panic function is an easy way to report that something went unexpectedly wrong. Especially to this sort of little cmdline app it suits well.
This was very inspiring to read. OAuth is familiar as Iāve used it many times, mostly with Qt. One time I tried Qtās unmaintained security module to do the job for me, but that was a mistake. That time I struggled the most. The implementation using Go looks very interesting, I really should try that out. Thanks for sharing your process!
Thanks Saija! I think as you have background in C/C++ also, you and go will become friends quickly āŗļø
Very helpful article -- was able to implement a client for Zoom. Thank you!
Thanks Tom! I'm glad it was usefult :)