Reddit API Authentication with Go

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 theCLIENT_ID
andCLIENT_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 (notssl.reddit.com
). - We’re sending an
Authorization
header withbearer [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)
}