loading...
Cover image for How to OAuth from the Command Line

How to OAuth from the Command Line

lauravuo profile image Laura Vuorenoja ・4 min read

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:

Alt Text

  1. The user launches the app from the command line
  2. The client program starts the temporary server
  3. The client program launches the browser to the API authentication page.
  4. The user authenticates in the browser and authorizes the client application to access the API on her behalf.
  5. The authorization server redirects the request to the predefined redirect URL (localhost).
  6. The client program parses the redirect request and receives the authorization code.
  7. The client program exchanges the authorization code to the API access token calling the authorization server endpoint.
  8. 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"))
}
Enter fullscreen mode Exit fullscreen mode

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

Discussion

pic
Editor guide
Collapse
eevajonnapanula profile image
Eevis (she/her)

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 😄

Collapse
lauravuo profile image
Laura Vuorenoja Author

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.

Collapse
matrixx profile image
Saija Saarenpää

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!

Collapse
lauravuo profile image
Laura Vuorenoja Author

Thanks Saija! I think as you have background in C/C++ also, you and go will become friends quickly ☺️