Implementing OAuth2 with golang.org/x/oauth2 in Golang

Implementing OAuth in Golang for Google and GitHub can significantly enhance your application’s security by enabling secure, delegated access to user resources. OAuth, a widely adopted authorization framework, allows users to grant third-party applications limited access to their resources without exposing their credentials. This article will guide you through the process of integrating OAuth in your Golang application, specifically focusing on Google and GitHub as identity providers. This step-by-step tutorial will provide you with the necessary tools and understanding to implement OAuth seamlessly and securely. By the end, you’ll have a fully functional OAuth setup that ensures a smooth and secure user authentication experience.

To implement OAuth2 in our website, we’ll be using the golang.org/x/oauth2 package.

OAuth flow

There are 4 important parts to implementing OAuth:

  1. Creating OAuth configurations
  2. Creating a HTTP handler to redirect users to OAuth pages
  3. Creating a HTTP handler to handle callbacks from OAuth providers
  4. Retrieving user data from the OAuth provider (email, name, avatar images)

The main function

First, let’s implement the main entrypoint of our application, with the necessary imports and some structs defined:

package main

import (
	"context"
	"crypto/rand"
	"database/sql"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"os"
	"time"

	"github.com/go-chi/chi/v5"
	"golang.org/x/oauth2"
	"golang.org/x/oauth2/github"
	"golang.org/x/oauth2/google"
)

type User struct {
	ID        int
	Email     string
    FirstName string
    LastName  string
    AvatarURL string
}

type GoogleUser struct {
	Email     string `json:"email"`
    FirstName string `json:"given_name"`
    LastName  string `json:"family_name"`
    AvatarURL string `json:"picture"`
}

type GithubUser struct {
	Email     string `json:"email"`
	Name      string `json:"name"`
    AvatarURL string `json:"avatar_url"`
}

func (gu *GithubUser) GetFirstNameLastName() (string, string) {
	split := strings.Split(gu.Name, " ")
	if len(split) == 1 {
		return gu.Name, ""
	}
	if len(split) == 2 {
		return split[0], split[1]
	}
	return split[0], split[len(split)-1]
}

func main() {
    r := chi.NewRouter()
    
    r.Route("/oauth", func(r chi.Router) {
		r.Route("/{provider}", func(r chi.Router) {
			r.Get("/", GetOAuthFlow)
			r.Get("/callback", GetOAuthCallback)
		})
	})
    
    http.ListenAndServe(":8080", r)
}

OAuth configurations

Then, we’ll create a couple functions to for creating OAuth configurations for both Google and Github, as well as a few constants for defining necessary URLs:

var googleOAuthConfig *oauth2.Config
var githubOAuthConfig *oauth2.Config

const (
    oauthGoogleUserInfoURL   = "https://www.googleapis.com/oauth2/v2/userinfo?access_token="
    oauthGithubUserURL       = "https://api.github.com/user"
    oauthGithubUserEmailsURL = "https://api.github.com/user/emails"
)

func newGoogleOAuthConfig() *oauth2.Config {
	return &oauth2.Config{
        RedirectURL:  "http://localhost:8080/oauth/google/callback",
        ClientID:     os.Getenv("GOOGLE_CLIENT_ID"),
        ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
		Scopes: []string{
			"https://www.googleapis.com/auth/userinfo.email",
			"https://www.googleapis.com/auth/userinfo.profile",
		},
		Endpoint: google.Endpoint,
	}
}

func newGithubOAuthConfig() *oauth2.Config {
	return &oauth2.Config{
        RedirectURL:  "http://localhost:8080/oauth/github/callback",
        ClientID:     os.Getenv("GITHUB_CLIENT_ID"),
        ClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"),
		Scopes: []string{
			"read:user", "user:email",
		},
		Endpoint: github.Endpoint,
	}
}

HTTP handlers

We’ll need to create a HTTP handler that returns a specific OAuth flow depending on which provider is selected. This example uses go-chi to retrieve URL parameters from the request.

func GetOAuthFlow(w http.ResponseWriter, r *http.Request) {
	provider := chi.URLParam(r, "provider")
	switch provider {
	case "google":
		if googleOAuthConfig == nil {
            googleOAuthConfig = newGoogleOAuthConfig()
		}
        oauthState := generateOAuthStateCookie(w)
        url := googleOAuthConfig.AuthCodeURL(oauthState)
        http.Redirect(w, r, url, http.StatusTemporaryRedirect)
	case "github":
		if githubOAuthConfig == nil {
            githubOAuthConfig = newGithubOAuthConfig()
		}
        oauthState := generateOAuthStateCookie(w)
        url := githubOAuthConfig.AuthCodeURL(oauthState)
        http.Redirect(w, r, url, http.StatusTemporaryRedirect)
	default:
		// handle the case of an unknown provider
		return
	}
}

func generateOAuthStateCookie(w http.ResponseWriter) string {
	var expiration = time.Now().Add(1 * time.Hour)
	b := make([]byte, 16)
	rand.Read(b)
	state := base64.URLEncoding.EncodeToString(b)
	cookie := http.Cookie{
		Name:    "oauthstate",
		Value:   state,
		Expires: expiration,
	}
    http.SetCookie(w, &cookie)

	return state
}

This handler uses a switch statement to select a OAuth provider based on the URL parameter found in the request. It then creates a new OAuth configuration in case they have not yet been created. We also have a helper function to generate a OAuth state cookie that will be used to validate that the authentication callback later on comes has the same state cookie.

Callback handlers

We’ll also need to create HTTP handlers that receives callback requests from the OAuth provider:

func GetOAuthCallback(w http.ResponseWriter, r *http.Request) {
	provider := chi.URLParam(r, "provider")
	var user User
	var redirectPath string
	var err error
	switch provider {
	case "google":
		user, redirectPath, err = handleGoogleCallback(w, r)
	case "github":
		user, redirectPath, err = handleGithubCallback(w, r)
	}
	if err != nil {
		slog.Error("err handling google oauth callback", "err", err)
        http.Redirect(w, r, "/login", http.StatusSeeOther)
		return
	}

	// "user" is an instance of User that can be used to
    // create a new user or sign in an existing user

    // create a session cookie to keep the user signed in

    http.Redirect(w, r, redirectPath, http.StatusSeeOther)
}

Here again we use a switch statement to handle callbacks from different providers in their respective functions. Next, we’ll implement the functions to retrieve user data from Google and Github. In case of Google, the function is slightly simpler, so we’ll start there:

func handleGoogleCallback(w http.ResponseWriter, r *http.Request) (User, string, error) {
	u := User{}
	path := "/"
    oauthState, err := r.Cookie("oauthstate")

	if err != nil || r.FormValue("state") != oauthState.Value {
		slog.Error("invalid oauth google state")
        http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
		return u, path, errors.New("invalid OAuth Google state")
	}

	user, err := h.getGoogleUserData(r.FormValue("code"))
	if err != nil {
		return u, path, fmt.Errorf("err getting user data from google: %+v", err)
	}

	u.FirstName = user.FirstName
	u.LastName = user.LastName
	u.Email = user.Email
	u.AvatarURL = user.AvatarURL

	return u, path, nil
}

func getGoogleUserData(code string) (GoogleUser, error) {
    gu := GoogleUser{}
	token, err := googleOAuthConfig.Exchange(context.Background(), code)
	if err != nil {
		return gu, err
	}
	response, err := http.Get(oauthGoogleUserInfoURL + token.AccessToken)
	if err != nil {
		return gu, err
	}
	defer response.Body.Close()

	err = json.NewDecoder(response.Body).Decode(&gu)
	return gu, err
}

The function first validates our OAuth state cookie, then attempts to retrieve user data from Google. We can then use the data to convert it into a user model of our own (User).

In order to do the same with Github, we must go through the same flow, but in addition to that, we might have to retrieve the user’s email separately, as the user’s profile in Github might not have a public email address. To retrieve the primary email address of a Github user, we need to make a separate request. The request returns all of the user’s emails available on Github, but we’ll only be interested in the email that is marked is primary.

func handleGithubCallback(w http.ResponseWriter, r *http.Request) (User, string, error) {
	u := User{}
	path := "/"
	var err error
    oauthState, err := r.Cookie("oauthstate")

	if err != nil || r.FormValue("state") != oauthState.Value {
		slog.Error("invalid oauth github state")
        http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
		return u, path, errors.New("invalid OAuth Github state")
	}

	code := r.URL.Query().Get("code")
	token, err := githubOAuthConfig.Exchange(context.Background(), code)
	if err != nil {
		return u, path, err
	}
    gu, err := getGithubUser(token.AccessToken)
	if err != nil {
		return u, path, err
	}

	u.Email = gu.Email
	u.FirstName, u.LastName = gu.GetFirstNameLastName()
	u.AvatarURL = gu.AvatarURL

	return u, path, nil
}

func getGithubUser(accessToken string) (GithubUser, error) {
    gu, err := getGithubUserData(accessToken)
	if err != nil {
		return gu, err
	}

	if gu.Email == "" {
		// if github user's email is not set public, we must retrieve
		// the email from another endpoint
        githubEmail, err := getUserEmailFromGithub(accessToken)
		if err != nil {
			return gu, err
		}
        gu.Email = githubEmail
	}

	return gu, nil
}

func getGithubUserData(accessToken string) (GithubUser, error) {
    gu := GithubUser{}

    req, err := http.NewRequest(http.MethodGet, oauthGithubUserURL, nil)
	if err != nil {
		return gu, err
	}
    req.Header.Set("Authorization", fmt.Sprintf("token %s", accessToken))

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return gu, err
	}

	if err := json.NewDecoder(res.Body).Decode(&gu); err != nil {
		return gu, err
	}
	return gu, nil
}

func getUserEmailFromGithub(accessToken string) (string, error) {
    req, err := http.NewRequest(http.MethodGet, oauthGithubUserEmailsURL, nil)
	if err != nil {
		return "", err
	}
    req.Header.Set("Authorization", fmt.Sprintf("token %s", accessToken))

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return "", err
	}

    responseEmails := []struct {
		// contains other fields as well, but these
		// are the only one's we're interested in
		Email   string `json:"email"`
		Primary bool   `json:"primary"`
	}{}

	if err := json.NewDecoder(res.Body).Decode(&responseEmails); err != nil {
		return "", err
	}

	for _, re := range responseEmails {
		if re.Primary {
			return re.Email, nil
		}
	}

	return "", errors.New("no primary email found")
}

Conclusion

Implementing OAuth in your Golang application for Google and GitHub authentication not only enhances security but also improves the user experience by providing a seamless login process. Throughout this article, we’ve walked through setting up OAuth credentials, handling the OAuth flow, and securely accessing user data. By leveraging the robust capabilities of OAuth, you can ensure that your application adheres to best practices in authentication and authorization. With your newfound knowledge, you’re well-equipped to integrate OAuth with Google and Github, as well as other providers, such as Facebook and LinkedIn, to further customize the authentication process to meet your specific needs. As security and user convenience continue to be paramount in software development, mastering OAuth in Golang will prove to be an invaluable skill in your developer toolkit.

Sign up or log in to start commenting