Howdy! The subject of this post is going to be a quick run through of how to authenticate your Go web application for interacting with the Reddit API.

Bit of a foreword, there are a few existing (rather powerful) libraries out there that probably already do what you want: graw and mira. If you need to do something a little more sophisticated like streaming and reacting to events as they happen, then look into leveraging one of these libraries. For the purposes of this post, I just want to show a simple OAuth flow (using the code flow).

Here’s a few relevant links I’ll be referencing in this post:

This last one in particular is especially relevant. It shows the OAuth flow using a Python/Flask webserver; this post will show you how to do the same thing using a simple Go webserver. TL;DR link to the Github Gist.

Ok, without further ado, let’s jump in! The first thing we need to do is create/setup/register our app with Reddit. I’m not going to go over this process, but at the end, you should have a CLIENT_ID and CLIENT_SECRET, and you should have associated a REDIRECT_URL with your app. While we’re at it, you should come up with a string you can use for your app’s User-Agent header. According to the docs, you should pick something unique and descriptive, including the target platform, a unique application identifier, a version string, and your username as contact information, in the following format: <platform>:<app ID>:<version string> (by /u/<reddit username>). If you don’t follow the suggested naming practices, you’ll likely get some 4XX response when you try to authenticate. Also, that just about wraps up the env dependencies we’re going to inject, so let’s stick those in a .env file:

CLIENT_ID=your_client_id_str
CLIENT_SECRET=your_client_secret_str
REDIRECT_URI=http://localhost:8080/authorize_callback
USER_AGENT=macos:webapp:v1.2.3 (by /u/your-reddit-handle)

Now we can write some Go code. Let’s start off with the func main():

var (
	CLIENT_ID     = os.Getenv("CLIENT_ID")
	CLIENT_SECRET = os.Getenv("CLIENT_SECRET")
	REDIRECT_URI  = os.Getenv("REDIRECT_URI")
	USER_AGENT    = os.Getenv("USER_AGENT")
    // we'll use this "state" stuff in a bit
	states        = make(map[string]struct{})
	stateMutex    = &sync.RWMutex{}
)

func main() {
	http.Handle("/homepage", homepage())
	http.Handle("/authorize_callback", authorize_callback())
	fmt.Println("Listening on localhost:8080...")
	http.ListenAndServe(":8080", nil)
}

We have a simple webserver with two routes: /homepage and /authorize_callback. Lets define our /homepage handler:

func homepage() http.Handler {
	type response struct {
		Message string `json:"message,omitempty"`
		Error   string `json:"error,omitempty"`
	}
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		http.Redirect(w, r, make_authorization_url(), http.StatusSeeOther)
	})
}

Here we’re just redirecting to the authorization URL. This is the first part of the OAuth flow; it will ask the client to authenticate with Reddit, and then authorize our application.

func make_authorization_url() string {
    // store the state
	state := uuid.NewString()
	save_created_state(state)

	// construct the url
	params := url.Values{}
	params.Add("client_id", CLIENT_ID)
	params.Add("response_type", "code")
	params.Add("state", state)
	params.Add("redirect_uri", REDIRECT_URI)
	params.Add("duration", "temporary")
	params.Add("scope", "identity")

	authURL := "https://ssl.reddit.com/api/v1/authorize"
	url := fmt.Sprintf("%s?%s", authURL, params.Encode())
	return url
}

// helper func to store the random state
func save_created_state(state string) {
	stateMutex.Lock()
	defer stateMutex.Unlock()
	states[state] = struct{}{}
}

// helper func to validate state (used in our callback below)
func is_valid_state(state string) bool {
	stateMutex.Lock()
	defer stateMutex.Unlock()
	if _, ok := states[state]; !ok {
		return false
	}
	delete(states, state)
	return true
}

If all goes well, the client (browser) is redirected to to our REDIRECT_URL, which is handled by our /authorization_callback handler. This handler has a bit more going on, but we’ll step through it. The handler expects the request to have certain fields set as query parameters in the URL: error, state, and code. The error field indicates problems the auth server may have encountered, the state field should match our randomly generated state field from above, and the code field is a string we can trade in to a server to get an access_token that we can use to make authenticated requests against the Reddit API. Note that “trade in” is an apt analogy; the code can only be used once (per the OAuth spec).

func authorize_callback() http.Handler {
	type response struct {
		Message string `json:"message,omitempty"`
		Error   string `json:"error,omitempty"`
	}
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		cb_err := r.URL.Query().Get("error")
		if cb_err != "" {
			resp := response{Error: "Error in callback request."}
			w.WriteHeader(http.StatusForbidden)
			json.NewEncoder(w).Encode(resp)
			return
		}

		state := r.URL.Query().Get("state")
		if !is_valid_state(state) {
			resp := response{Error: "Invalid state."}
			w.WriteHeader(http.StatusForbidden)
			json.NewEncoder(w).Encode(resp)
			return
		}

		code := r.URL.Query().Get("code")
		token, err := get_token(code)
		if err != nil {
			resp := response{Error: fmt.Sprintf("Failed to get access_token: %s", err)}
			w.WriteHeader(http.StatusInternalServerError)
			json.NewEncoder(w).Encode(resp)
			return
		}

		username, err := get_username(token)
		if err != nil {
			resp := response{Error: fmt.Sprintf("Failed to get username from token (%s): %s", token, err)}
			w.WriteHeader(http.StatusInternalServerError)
			json.NewEncoder(w).Encode(resp)
			return
		}
		resp := response{Message: fmt.Sprintf("Your username is %s", username)}
		w.WriteHeader(http.StatusOK)
		json.NewEncoder(w).Encode(resp)
		return
	})
}

Ok, so most of the above was just parsing those fields; now we can exchange our code for an access_token. We do this by hitting the /api/v1/access_token route. Note some subtleties here:

  • We’re sending an Authorization header with HTTP Basic Authentication using the CLIENT_ID and CLIENT_SECRET.
  • We’re making a POST request.
  • The data is URL encoded in the request body.

Other than that, this is pretty simple; we’re making a request and return the access_token we get back (if everything worked).

func get_token(code string) (string, error) {
	// helper for HTTP Basic Authentication
	basicAuth := func(u, p string) string {
		auth := fmt.Sprintf("%s:%s", u, p)
		return base64.StdEncoding.EncodeToString([]byte(auth))
	}

	// construct request body
	body := url.Values{}
	body.Set("grant_type", "authorization_code")
	body.Set("code", code)
	body.Set("redirect_uri", REDIRECT_URI)

	// send the request
	req, err := http.NewRequest(http.MethodPost, "https://ssl.reddit.com/api/v1/access_token", strings.NewReader(body.Encode()))
	if err != nil {
		return "", fmt.Errorf("Failed to initialize request.")
	}
	req.Header.Add("Authorization", fmt.Sprintf("Basic %s", basicAuth(CLIENT_ID, CLIENT_SECRET)))
	req.Header.Add("User-Agent", USER_AGENT)
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return "", fmt.Errorf("Failed to make request to access token endpoint.")
	}
	defer resp.Body.Close()

	// decode the response
	var data struct {
		AccessToken string `json:"access_token"`
	}
	rb, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return "", fmt.Errorf("Failed to read response body.")
	}
	err = json.Unmarshal(rb, &data)
	if err != nil || data.AccessToken == "" {
		return "", fmt.Errorf("Failed to parse access token from response.")
	}
	return data.AccessToken, nil
}

Cool, so now we’ve got our access_token we can use to make authenticated requests. Now what? Let’s take a look at our get_username function. Things to note here:

  • Now that our OAuth flow is done, we’re using the oauth.reddit.com domain (not ssl.reddit.com).
  • We’re sending an Authorization header with bearer [access_token].
func get_username(token string) (string, error) {
	// send the request
	req, err := http.NewRequest(http.MethodGet, "https://oauth.reddit.com/api/v1/me", bytes.NewReader([]byte{}))
	if err != nil {
		return "", fmt.Errorf("Failed to initialize request.")
	}
	req.Header.Add("Authorization", fmt.Sprintf("bearer %s", token))
	req.Header.Add("User-Agent", USER_AGENT)
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return "", fmt.Errorf("Failed to get username from Reddit API")
	}
	defer resp.Body.Close()

	// decode the response
	rb, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return "", fmt.Errorf("Failed to read response body.")
	}
	var data struct {
		Username string `json:"name"`
	}
	err = json.Unmarshal(rb, &data)
	if err != nil || data.Username == "" {
		return "", fmt.Errorf("Failed to get username.")
	}
	return data.Username, nil
}

Great, so if all went well with that, our Reddit username will get returned to the calling function and written to the client (browser) as part of our JSON response. That pretty much brings us up to date with the simple Python webserver example I mentioned above. There’s a bit more you can do here using refresh tokens, but the docs cover that pretty clearly; if you’ve followed along through this guide it should be pretty easy to do. As a final point of reference, here’s the complete main.go:

package main

import (
	"bytes"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"io/ioutil"
	"net/http"
	"net/url"
	"os"
	"strings"
	"sync"

	"github.com/google/uuid"
)

var (
	CLIENT_ID     = os.Getenv("CLIENT_ID")
	CLIENT_SECRET = os.Getenv("CLIENT_SECRET")
	REDIRECT_URI  = os.Getenv("REDIRECT_URI")
	USER_AGENT    = os.Getenv("USER_AGENT")
	states        = make(map[string]struct{})
	stateMutex    = &sync.RWMutex{}
)

func save_created_state(state string) {
	stateMutex.Lock()
	defer stateMutex.Unlock()
	states[state] = struct{}{}
}

func is_valid_state(state string) bool {
	stateMutex.Lock()
	defer stateMutex.Unlock()
	if _, ok := states[state]; !ok {
		return false
	}
	delete(states, state)
	return true
}

func make_authorization_url() string {
	state := uuid.NewString()
	save_created_state(state)

	// construct the url
	params := url.Values{}
	params.Add("client_id", CLIENT_ID)
	params.Add("response_type", "code")
	params.Add("state", state)
	params.Add("redirect_uri", REDIRECT_URI)
	params.Add("duration", "temporary")
	params.Add("scope", "identity")

	authURL := "https://ssl.reddit.com/api/v1/authorize"
	url := fmt.Sprintf("%s?%s", authURL, params.Encode())
	return url
}

func homepage() http.Handler {
	type response struct {
		Message string `json:"message,omitempty"`
		Error   string `json:"error,omitempty"`
	}
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		http.Redirect(w, r, make_authorization_url(), http.StatusSeeOther)
	})
}

func authorize_callback() http.Handler {
	type response struct {
		Message string `json:"message,omitempty"`
		Error   string `json:"error,omitempty"`
	}
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		cb_err := r.URL.Query().Get("error")
		if cb_err != "" {
			resp := response{Error: "Error in callback request."}
			w.WriteHeader(http.StatusForbidden)
			json.NewEncoder(w).Encode(resp)
			return
		}

		state := r.URL.Query().Get("state")
		if !is_valid_state(state) {
			resp := response{Error: "Invalid state."}
			w.WriteHeader(http.StatusForbidden)
			json.NewEncoder(w).Encode(resp)
			return
		}

		code := r.URL.Query().Get("code")
		token, err := get_token(code)
		if err != nil {
			resp := response{Error: fmt.Sprintf("Failed to get access_token: %s", err)}
			w.WriteHeader(http.StatusInternalServerError)
			json.NewEncoder(w).Encode(resp)
			return
		}

		username, err := get_username(token)
		if err != nil {
			resp := response{Error: fmt.Sprintf("Failed to get username from token (%s): %s", token, err)}
			w.WriteHeader(http.StatusInternalServerError)
			json.NewEncoder(w).Encode(resp)
			return
		}
		resp := response{Message: fmt.Sprintf("Your username is %s", username)}
		w.WriteHeader(http.StatusOK)
		json.NewEncoder(w).Encode(resp)
		return
	})
}

func get_token(code string) (string, error) {
	// helper for HTTP Basic Authentication
	basicAuth := func(u, p string) string {
		auth := fmt.Sprintf("%s:%s", u, p)
		return base64.StdEncoding.EncodeToString([]byte(auth))
	}

	// construct request body
	body := url.Values{}
	body.Set("grant_type", "authorization_code")
	body.Set("code", code)
	body.Set("redirect_uri", REDIRECT_URI)

	// send the request
	req, err := http.NewRequest(http.MethodPost, "https://ssl.reddit.com/api/v1/access_token", strings.NewReader(body.Encode()))
	if err != nil {
		return "", fmt.Errorf("Failed to initialize request.")
	}
	req.Header.Add("Authorization", fmt.Sprintf("Basic %s", basicAuth(CLIENT_ID, CLIENT_SECRET)))
	req.Header.Add("User-Agent", USER_AGENT)
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return "", fmt.Errorf("Failed to make request to access token endpoint.")
	}
	defer resp.Body.Close()

	// decode the response
	var data struct {
		AccessToken string `json:"access_token"`
	}
	rb, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return "", fmt.Errorf("Failed to read response body.")
	}
	err = json.Unmarshal(rb, &data)
	if err != nil || data.AccessToken == "" {
		return "", fmt.Errorf("Failed to parse access token from response.")
	}
	return data.AccessToken, nil
}

func get_username(token string) (string, error) {
	// send the request
	req, err := http.NewRequest(http.MethodGet, "https://oauth.reddit.com/api/v1/me", bytes.NewReader([]byte{}))
	if err != nil {
		return "", fmt.Errorf("Failed to initialize request.")
	}
	req.Header.Add("Authorization", fmt.Sprintf("bearer %s", token))
	req.Header.Add("User-Agent", USER_AGENT)
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return "", fmt.Errorf("Failed to get username from Reddit API")
	}
	defer resp.Body.Close()

	// decode the response
	rb, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return "", fmt.Errorf("Failed to read response body.")
	}
	var data struct {
		Username string `json:"name"`
	}
	err = json.Unmarshal(rb, &data)
	if err != nil || data.Username == "" {
		return "", fmt.Errorf("Failed to get username.")
	}
	return data.Username, nil
}

func main() {
	http.Handle("/homepage", homepage())
	http.Handle("/authorize_callback", authorize_callback())
	fmt.Println("Listening on localhost:8080...")
	http.ListenAndServe(":8080", nil)
}