Google OAuth Implementation with Golang

Oauth, source: https://www.varonis.com/blog/what-is-oauth

What you'll learn:

In this article/tutorial, you will be learning about concepts such as OAuth, Single Sign-On and how to implement Google OAuth with Golang.

Prerequisites:

  • Familiarity with Go

  • Go installed on your local machine or cloud

  • Access to a Google Cloud Console

The code base for this article/tutorial can be found here.

Definition and Background Info:

OAuth (Open Authorization) is an open standard and authorization framework that allows the secure delegation of limited access to resources on behalf of an end-user to a third-party application or service without sharing the user's credentials.

A simple scenario or use case for OAuth is this: an end-user (let's say David), needs to make use of a social media management app (Client Application), an app which performs social media analytics without revealing his social media platform verification details to the management app. The client app redirects him to the social media platform (which in this case is the authorization server), where he logs in. The platform asks David for permission to give the management app access to his data and upon approval, issues a special key (access token) to the app. Now, the app can manage David's social media data without needing his password, ensuring security and convenience.

OAuth overview, source: https://docs.oracle.com/cd/E50612_01/doc.11122/oauth_guide/content/oauth_intro.html

OAuth is one of the protocols used for single sign-on (SSO), a process by which a user can log in to multiple applications using a single set of credentials. This is a feature that eliminates a wide range of problems such as password fatigue (which occurs when the user needs to remember different sets of credentials for different applications) and security risks of password storage (breaches in applications storing user credentials).

Google-OAuth: the Project

Initialize a new Go project using the go mod init command. Replace <DavidHODs> in this command and subsequent commands and codes with your GitHub username.

go mod init github.com/DavidHODs/Google-OAuth

Create three folders; controllers (where we'll store files containing write endpoints logics), assets (where we'll store HTML and CSS files), utils (where we'll store files containing helpful functions and data structures) and a main.go file.

We'll be using gorilla mux as our HTTP router, so install it with the go get command in your terminal

go get github.com/gorilla/mux

Paste the following code into main.go file to set up our router

package main

import (
    "flag"
    "fmt"
    "log"
    "net/http"
    "time"

    "github.com/gorilla/mux"
)

func main() {
    router := mux.NewRouter()
    srv := &http.Server{
        Handler: router,

        // you can replace 8007 with any other available port
        Addr: "127.0.0.1:8007",

        // enforces timeouts for created servers
        WriteTimeout: 15 * time.Second,
        ReadTimeout:  15 * time.Second,
    }

    fmt.Printf("listening on port: %s\n", srv.Addr)

    log.Fatal(srv.ListenAndServe())
}

Running go run main.go command in the terminal should give something akin to what's in the screenshot if all is well and good.

We'll be using OAuth2 for Go, a package containing a client implementation for OAuth 2.0 spec. Run the following commands in the terminal to install the necessary packages.

go get golang.org/x/oauth2
go get golang.org/x/oauth2/google

In our controller's folder, create a controllers.go file. In this file, we'll create four functions: GoogleSignOn() to sign in with the oauth2 package, Redirect() which we'll link to a specified URI on the Google console, getUserData() to parse the data returned from the Google API console and init(), a special function that will be automatically executed before the main() function of our Go program. It will allow us to give the OAuth package the necessary details needed for its operation).

Create a utils.go file in our utils package and create a variable OAuthgolang of type *oauth2.Config which we'll make use of in our controllers.go file.

package utils

import "golang.org/x/oauth2"

var OAuthgolang *oauth2.Config

In controllers.go, create an init function to load oauth2 config structure.

package controllers

import (
    "github.com/DavidHODs/Google-OAuth/utils"
    "golang.org/x/oauth2"
    "golang.org/x/oauth2/google"
)

func init() {
    utils.OAuthgolang = &oauth2.Config{
        RedirectURL:  "",
        ClientID:     "",
        ClientSecret: "",
        // scopes limits the access given to a token. this scope returns just the user info of the 
        // signed in email address
        Scopes:       []string{"https://www.googleapis.com/auth/userinfo.email"},
        Endpoint:     google.Endpoint, //Endpoint is Google's OAuth 2.0 default endpoint
    }
}

Let's head over to Google Cloud Console to create a credential to get the ClientID, ClientSecret and RedirectURL. Click on Create a Project and name it something like "Dev Needs" (can be anything really). Click on API & Services on the sidebar and go to the OAuth consent screen. Create an app, set the user type to external and create a test user. Click on Credentials on the sidebar, click on Create credentials and then OAuth client id. Set the application type to web application, give it a name and set the authorized URI to something like "http://localhost:8007/callback".

After you click on Create, download the JSON file containing the necessary credentials.

In the root of our project folder, create two files, a gitignore and .env file. In our gitignore file, simply add ".env", this will tell Git not to include .env file in the list of changes when we run commands like "git status" or "git add." This prevents the accidental addition of unnecessary files to the repository.

In .env file, add the credential details in the downloaded JSON file.

Let's create an utility function in utis.go to load our .env variables. For this we'll need GoDotEnv, a package which loads environment variables from a .env file. In your terminal run

go get github.com/joho/godotenv

Then paste the following function in utils.go. Remember to add "os" and "github.com/joho/godotenv" to the list of imports if your IDE does not do so.

// LoadFile returns the value of a specified key from .env file
func LoadFile(key string) string {
    err := godotenv.Load("./.env")
    if err != nil {
        log.Fatal("error loading .env file")
    }

    return os.Getenv(key)
}

You can now update the init() function in controllers.go

package controllers

import (
    "github.com/DavidHODs/Google-OAuth/utils"
    "golang.org/x/oauth2"
    "golang.org/x/oauth2/google"
)

// init loads the neccessary configuration details required by oauth2 package.
func init() {
    utils.OAuthgolang = &oauth2.Config{
        RedirectURL:  utils.LoadFile("REDIRECT_URL"),
        ClientID:     utils.LoadFile("CLIENT_ID"),
        ClientSecret: utils.LoadFile("CLIENT_SECRET"),
        // scopes limits the access given to a token. this scope returns just the user info of the
        // signed in email address
        Scopes:   []string{"https://www.googleapis.com/auth/userinfo.email"},
        Endpoint: google.Endpoint, //Endpoint is Google's OAuth 2.0 default endpoint
    }
}

In controllers.go, a GoogleSignOn() function is created to return a URL to the consent page created on the Google Cloud Console. A randomly generated string via a utils function called TokenString() is attached to the URL as a security measure. The URL is parsed and the attached token is checked to see if it matches the token our TokenString() function generated during the redirect callback.

Session, which refers to a mechanism used to maintain state and store data related to a specific user's interactions with a web application across multiple HTTP requests is used to save the generated string and passed to the function where the URL will be parsed.

// controllers.go

// Through GoogleSignOn, a URL is returned to the consent page created on Google Console. A security token string is provided which will be parsed and verified during redirect callback.
func GoogleSignOn(res http.ResponseWriter, req *http.Request) {
    tokenString, err := utils.TokenString()
    if err != nil {
        fmt.Fprintf(res, "error: could not generate random token string: %v", err)
    }

    // creates a new session 
    session, err := utils.Store.Get(req, "tokenSession")
    if err != nil {
        fmt.Fprintf(res, "error: %v", err)
    }

    // saves the generated token string into the created session; uses tokenStringKey as the key 
    session.Values["tokenStringKey"] = tokenString
    session.Save(req, res)

    // returns a URL with attached tokenString 
    url := utils.OAuthgolang.AuthCodeURL(tokenString)
    http.Redirect(res, req, url, http.StatusTemporaryRedirect)
}
// utils.go
// add "crypto/rand" and "github.com/gorilla/sessions" to the list of imports
// run go get github.com/gorilla/sessions in your terminal
// update your .env file to include TOKEN_SECRET, the value can be anything

var (
    OAuthgolang *oauth2.Config
    Store       = sessions.NewCookieStore([]byte(LoadFile("TOKEN_SECRET")))
)

const (
    tokenSet    = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
    tokenLength = 15
)

// GenerateRandomString generates a random string of the specified length(15).
func TokenString() (string, error) {
    charsetLength := len(tokenSet)

    randomBytes := make([]byte, tokenLength)
    _, err := rand.Read(randomBytes)
    if err != nil {
        return "", err
    }

    for i := 0; i < tokenLength; i++ {
        randomBytes[i] = tokenSet[int(randomBytes[i])%charsetLength]
    }

    return string(randomBytes), nil
}

We can now implement two functions, Callback() controllers.go and GetUserData() in utils.go. After the implementation of GoogleSignOn(), the Cloud Console redirects to /callback which triggers the implementation of Callback() and GetUserData() functions which verifies authorization request and unmarshals returned user data into our created OAuthData data structure.

// controllers.go

// Callback is triggered when Google Cloud Console redirects to /callback. Coupled with GetUserData
// it verifies authorization request and unmarshals returned user data into our created OAuthData data structure 
func Callback(res http.ResponseWriter, req *http.Request) {
    state := req.FormValue("state")
    code := req.FormValue("code")

    // returns the created session
    session, err := utils.Store.Get(req, "tokenSession")
    if err != nil {
        fmt.Fprintf(res, "error: %v", err)
    }

    // returns the value of tokenStringKey
    dataToken, ok := session.Values["tokenStringKey"].(string)
    if !ok {
        dataToken = "token not found in the session"
    }

    data, err := utils.GetUserData(state, code, dataToken)
    if err != nil {
        log.Fatal(err)
    }

    // the session cookie is deleted immediately
    session.Options.MaxAge = -1
    session.Save(req, res)

    var authStruct utils.OAuthData

    // Google Cloud Console returns a JSON structure containing "id",,"email", "verified_email" and "picture"
    // this converts the JSON structure into our created OAuthData structure
    err = json.Unmarshal([]byte(data), &authStruct)
    if err != nil {
        fmt.Fprintf(res, "error: %v", err)
    }

    // returns a response a response based on verification success or failure
    status := authStruct.Verified_email
    if status {
        fmt.Fprintf(res, "success: %s is a verified user\n", authStruct.Email)
    } else {
        fmt.Fprint(res, "failed verification")
    }
}
//utils.go

type OAuthData struct {
    Id             string `json:"id"`
    Email          string `json:"email"`
    Verified_email bool   `json:"verified_email"`
    Picture        string `json:"picture"`
}

// GetUserData validates verification request and returns data of verified google user
func GetUserData(state, code, tokenCode string) ([]byte, error) {
    // compares the generated token string to the token retrieved from the parsed URL
    if state != tokenCode {
        return nil, errors.New("invalid user")
    }

    // converts authorization code into a token
    token, err := OAuthgolang.Exchange(context.Background(), code)
    if err != nil {
        return nil, err
    }

    response, err := http.Get("https://www.googleapis.com/oauth2/v2/userinfo?access_token=" + token.AccessToken)
    if err != nil {
        return nil, err
    }

    // this is done to prevent memory leakage
    defer response.Body.Close()

    data, err := io.ReadAll(response.Body)
    if err != nil {
        return nil, err
    }

    // returns data of verified google user
    return data, nil
}

Create an index.html file in the assets folder, and paste the following html codes in it. Create a RenderPage() function to render it and update the main.go file to try things out.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" type="text/css" href="./style.css">
    <title>OAuth</title>
</head>
  <h1>Google OAuth Implementation (for Single Sign-On) with Golang</h1>
  <br>
  <br>
  <br>
  <br>
    <div>
        <form action="">
          <div>
              <a href="/google-sso">
                <i style="color: white; background-color: #dd4b39; padding: 12px; font-size: 17px;">Login with Google+</i>
              </a>
            </div>
          </div>
        </form>
      </div>
</body>
</html>
// controllers.go
// include "text/template" to the list of imports

// RenderPage renders a simple HTML page to try out Google Sign-On 
func RenderPage(res http.ResponseWriter, req *http.Request) {
    tmpl, err := template.ParseFiles("assets/index.html")
    if err != nil {
        http.Error(res, err.Error(), http.StatusInternalServerError)
        return
    }

    err = tmpl.Execute(res, nil)
    if err != nil {
        http.Error(res, err.Error(), http.StatusInternalServerError)
        return
    }
}
// main.go

package main

import (
    "fmt"
    "log"
    "net/http"
    "time"

    "github.com/DavidHODs/Google-OAuth/controllers"
    "github.com/gorilla/mux"
)

func main() {
    router := mux.NewRouter()

    router.HandleFunc("/", controllers.RenderPage)
    router.HandleFunc("/google-sso", controllers.GoogleSignOn)
    router.HandleFunc("/callback", controllers.Callback)

    srv := &http.Server{
        Handler: router,

        // you can replace 8007 with any other available port
        Addr: "127.0.0.1:8007",

        // enforces timeouts for created servers
        WriteTimeout: 15 * time.Second,
        ReadTimeout:  15 * time.Second,
    }

    fmt.Printf("listening on port: %s\n", srv.Addr)

    log.Fatal(srv.ListenAndServe())
}

Finally, run go run main.go in your terminal to see this project in action.