golangtutorial

Implementing OAuth With golang.org/x/oauth2

Tired of building user registration forms and managing password hashes? Wish your users could just log in with their existing Google or GitHub accounts? Welcome to the wonderful world of OAuth!

OAuth is like a digital bouncer that lets users grant your application limited, secure access to their information (like email or profile picture) without ever sharing their actual passwords with you. It's a win-win: enhanced security for everyone, and a smoother, more familiar login experience for your users.

In this guide, we're going to dive deep into implementing OAuth2 in your Golang application using the fantastic golang.org/x/oauth2 package. We'll specifically tackle Google and GitHub, two of the most popular identity providers. By the end, you'll have a fully functional OAuth setup, making your app's authentication process both seamless and secure.

The OAuth Dance: A Four-Step Choreography

Implementing OAuth involves a well-defined sequence of steps. Think of it as a four-part dance:

  1. Setting up Your Dance Card (OAuth Configurations): Preparing the credentials and settings your app needs to talk to Google or GitHub.
  2. Sending Invitations (Redirect to OAuth Page): Directing your users to Google or GitHub's login page, where they grant (or deny) permission.
  3. Receiving RSVP (Callback Handler): Google or GitHub sending your user back to your app with a special code, indicating success (or failure).
  4. Getting the Guest List (Retrieving User Data): Exchanging that special code for an access token, and then using that token to fetch the user's public profile information (like email, name, avatar).

The Main Event: Setting the Stage

First things first, let's lay out the basic structure of our Go application. This includes our main entry point (main function) and some crucial structs to hold user data.

package main

import (
	"context"      // For context.Background()
	"crypto/rand"  // For generating random state strings
	"encoding/base64" // For encoding the state string
	"encoding/json" // For decoding JSON responses from OAuth providers
	"errors"       // For creating custom errors
	"fmt"          // For formatted I/O
	"log/slog"     // For structured logging
	"net/http"     // Go's standard HTTP library
	"os"           // For accessing environment variables (API keys)
	"strings"      // For string manipulation (GitHub name parsing)
	"time"         // For cookie expiration

	"github.com/go-chi/chi/v5" // A popular Go HTTP router
	"golang.org/x/oauth2"      // The core OAuth2 package
	"golang.org/x/oauth2/github" // GitHub-specific OAuth2 endpoint configuration
	"golang.org/x/oauth2/google" // Google-specific OAuth2 endpoint configuration
	// Note: "database/sql" is imported in the original but not used in this example.
	// Removed it to keep the example minimal.
)

// User represents our application's internal user model.
type User struct {
	ID        int
	Email     string
	FirstName string
	LastName  string
	AvatarURL string
}

// GoogleUser maps to the JSON response from Google's user info endpoint.
type GoogleUser struct {
	Email     string `json:"email"`
	FirstName string `json:"given_name"`
	LastName  string `json:"family_name"`
	AvatarURL string `json:"picture"`
}

// GithubUser maps to the JSON response from GitHub's user endpoint.
type GithubUser struct {
	Email     string `json:"email"`      // May be empty if not public
	Name      string `json:"name"`       // Full name
	AvatarURL string `json:"avatar_url"`
}

// GetFirstNameLastName attempts to split GitHub's 'Name' field into first and last names.
func (gu *GithubUser) GetFirstNameLastName() (string, string) {
	split := strings.Split(gu.Name, " ")
	if len(split) == 1 {
		return gu.Name, "" // Only one part, assume it's the first name
	}
	if len(split) == 2 {
		return split[0], split[1] // Simple split for two parts
	}
	// For more than two parts, assume first part is first name, last part is last name
	return split[0], split[len(split)-1]
}

func main() {
	r := chi.NewRouter()

	// Define our OAuth routes:
	// /oauth/{provider} -> initiates the OAuth flow
	// /oauth/{provider}/callback -> handles the callback from the OAuth provider
	r.Route("/oauth", func(r chi.Router) {
		r.Route("/{provider}", func(r chi.Router) {
			r.Get("/", GetOAuthFlow)
			r.Get("/callback", GetOAuthCallback)
		})
	})

	slog.Info("Server starting", "port", ":8080")
	http.ListenAndServe(":8080", r)
}

Important Note: For a real application, you'd initialize a database connection and pass it around. Here, we're focusing purely on the OAuth flow.


Chapter 1: Setting Up Your Dance Card (OAuth Configurations)

Before your app can chat with Google or GitHub, you need to tell it who you are. This involves creating oauth2.Config structs, which hold your application's client ID, client secret, redirect URL, and the specific permissions (scopes) you're requesting.

Where to get Client IDs and Secrets:

  • Google: Go to Google Cloud Console, create a new project, navigate to "APIs & Services" -> "Credentials," and create an "OAuth client ID" for a "Web application."
  • GitHub: Go to your GitHub settings -> "Developer settings" -> "OAuth Apps" or "GitHub Apps."

It's crucial to store these Client IDs and Client Secrets as environment variables (e.g., GOOGLE_CLIENT_ID, GITHUB_CLIENT_SECRET) and NEVER hardcode them in your application.

// Global OAuth config variables. They are initialized once.
var googleOAuthConfig *oauth2.Config
var githubOAuthConfig *oauth2.Config

// Constants for API endpoints to fetch user data after successful authentication.
const (
	oauthGoogleUserInfoURL   = "https://www.googleapis.com/oauth2/v2/userinfo?access_token="
	oauthGithubUserURL       = "https://api.github.com/user"
	oauthGithubUserEmailsURL = "https://api.github.com/user/emails" // Needed for private emails
)

// newGoogleOAuthConfig initializes the OAuth2 configuration for Google.
func newGoogleOAuthConfig() *oauth2.Config {
	return &oauth2.Config{
		RedirectURL:  "http://localhost:8080/oauth/google/callback", // Where Google sends the user back
		ClientID:     os.Getenv("GOOGLE_CLIENT_ID"),                  // Get from Google Cloud Console
		ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),              // Get from Google Cloud Console
		Scopes: []string{ // Permissions we're asking for
			"https://www.googleapis.com/auth/userinfo.email",
			"https://www.googleapis.com/auth/userinfo.profile",
		},
		Endpoint: google.Endpoint, // Google's OAuth2 endpoints
	}
}

// newGithubOAuthConfig initializes the OAuth2 configuration for GitHub.
func newGithubOAuthConfig() *oauth2.Config {
	return &oauth2.Config{
		RedirectURL:  "http://localhost:8080/oauth/github/callback", // Where GitHub sends the user back
		ClientID:     os.Getenv("GITHUB_CLIENT_ID"),                  // Get from GitHub Developer settings
		ClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"),              // Get from GitHub Developer settings
		Scopes: []string{ // Permissions we're asking for
			"read:user", "user:email", // Read public profile and user's email addresses
		},
		Endpoint: github.Endpoint, // GitHub's OAuth2 endpoints
	}
}

Chapter 2: Sending Invitations (HTTP Handlers)

Now, let's create the HTTP handlers that kick off the OAuth flow. When a user clicks "Login with Google" or "Login with GitHub," they'll hit our GetOAuthFlow endpoint. This handler will then redirect them to the respective OAuth provider's login page.

A crucial security step here is generating an OAuth state cookie. This random string is sent to the OAuth provider and then returned to us in the callback. We verify it to prevent CSRF (Cross-Site Request Forgery) attacks. It's like a secret handshake to ensure the callback is legitimate.

// GetOAuthFlow redirects users to the chosen OAuth provider's login page.
func GetOAuthFlow(w http.ResponseWriter, r *http.Request) {
	provider := chi.URLParam(r, "provider") // Get provider name from URL (e.g., "google", "github")

	var config *oauth2.Config
	switch provider {
	case "google":
		if googleOAuthConfig == nil { // Initialize config if not already done
			googleOAuthConfig = newGoogleOAuthConfig()
		}
		config = googleOAuthConfig
	case "github":
		if githubOAuthConfig == nil { // Initialize config if not already done
			githubOAuthConfig = newGithubOAuthConfig()
		}
		config = githubOAuthConfig
	default:
		slog.Warn("Unknown OAuth provider requested", "provider", provider)
		http.Error(w, "Unknown OAuth provider", http.StatusBadRequest)
		return
	}

	// Generate a unique state string and set it as a cookie.
	// This prevents CSRF attacks.
	oauthState := generateOAuthStateCookie(w)

	// Construct the URL to the OAuth provider's authorization page
	// and redirect the user's browser.
	url := config.AuthCodeURL(oauthState)
	http.Redirect(w, r, url, http.StatusTemporaryRedirect) // 307 redirect
}

// generateOAuthStateCookie creates a random string and sets it as an HTTP-only cookie.
// This state is used to verify the OAuth callback.
func generateOAuthStateCookie(w http.ResponseWriter) string {
	expiration := time.Now().Add(1 * time.Hour) // Cookie expires in 1 hour
	b := make([]byte, 16)
	_, err := rand.Read(b) // Generate 16 random bytes
	if err != nil {
		slog.Error("Failed to generate random bytes for OAuth state", "err", err)
		// In a real app, you might handle this more robustly,
		// but for now, we'll proceed as it's a security rather than functional error.
	}
	state := base64.URLEncoding.EncodeToString(b) // Encode bytes to a URL-safe string
	cookie := http.Cookie{
		Name:    "oauthstate", // Name of the cookie
		Value:   state,        // The random state string
		Expires: expiration,
		HttpOnly: true,         // Important: Make it HTTP-only to prevent client-side script access
		Secure:   true,         // Important: Only send over HTTPS in production
		SameSite: http.SameSiteLaxMode, // Recommended for CSRF protection
	}
	http.SetCookie(w, &cookie) // Send the cookie to the user's browser

	return state
}

Chapter 3: Receiving RSVP (Callback Handlers)

After the user interacts with the OAuth provider, they're redirected back to your application's callback endpoint. This is where the real magic happens: we verify the state cookie, exchange the authorization code for an access token, and then use that token to fetch the user's profile data.

// GetOAuthCallback handles the callback from OAuth providers (Google/GitHub).
func GetOAuthCallback(w http.ResponseWriter, r *http.Request) {
	provider := chi.URLParam(r, "provider")
	var user User         // Our internal User model
	var redirectPath string // Where to redirect the user after processing
	var err error

	switch provider {
	case "google":
		user, redirectPath, err = handleGoogleCallback(w, r)
	case "github":
		user, redirectPath, err = handleGithubCallback(w, r)
	default:
		slog.Warn("Unknown OAuth provider in callback", "provider", provider)
		http.Redirect(w, r, "/login", http.StatusSeeOther) // Redirect to login on unknown provider
		return
	}

	if err != nil {
		slog.Error("Error handling OAuth callback", "provider", provider, "err", err)
		http.Redirect(w, r, "/login?error=oauth_failed", http.StatusSeeOther) // Redirect to login with error
		return
	}

	// At this point, 'user' is an instance of our internal User struct
	// populated with data from the OAuth provider.
	// You would typically:
	// 1. Check if a user with this email already exists in your database.
	// 2. If yes, sign them in (e.g., create a session cookie for them).
	// 3. If no, create a new user record in your database and then sign them in.

	slog.Info("OAuth callback successful", "provider", provider, "user_email", user.Email)

	// For demonstration, we'll just redirect to the determined path.
	// In a real app, this redirect would happen after successful login/registration.
	http.Redirect(w, r, redirectPath, http.StatusSeeOther)
}

Fetching User Data: Google vs. GitHub

The process of fetching user data differs slightly between providers because their API structures vary.

Google: The Straightforward Path

Google typically provides all the basic user info (email, name, picture) in a single endpoint.

// handleGoogleCallback performs the Google-specific callback logic.
func handleGoogleCallback(w http.ResponseWriter, r *http.Request) (User, string, error) {
	appUser := User{}
	redirectPath := "/dashboard" // Default path after successful login

	// 1. Validate the OAuth state cookie to prevent CSRF.
	oauthState, err := r.Cookie("oauthstate")
	if err != nil {
		slog.Error("OAuth state cookie not found for Google callback", "err", err)
		return appUser, redirectPath, errors.New("OAuth state cookie not found")
	}
	if r.FormValue("state") != oauthState.Value {
		slog.Error("Invalid OAuth state for Google callback", "expected", oauthState.Value, "got", r.FormValue("state"))
		return appUser, redirectPath, errors.New("Invalid OAuth Google state")
	}

	// 2. Exchange the authorization code for an access token.
	// This is the core of the OAuth flow.
	token, err := googleOAuthConfig.Exchange(context.Background(), r.FormValue("code"))
	if err != nil {
		return appUser, redirectPath, fmt.Errorf("error exchanging Google code for token: %w", err)
	}

	// 3. Use the access token to fetch user profile data from Google's API.
	googleUser, err := getGoogleUserData(token.AccessToken)
	if err != nil {
		return appUser, redirectPath, fmt.Errorf("error getting user data from Google: %w", err)
	}

	// 4. Map Google's user data to our internal User model.
	appUser.FirstName = googleUser.FirstName
	appUser.LastName = googleUser.LastName
	appUser.Email = googleUser.Email
	appUser.AvatarURL = googleUser.AvatarURL

	return appUser, redirectPath, nil
}

// getGoogleUserData fetches user profile information from Google's UserInfo API.
func getGoogleUserData(accessToken string) (GoogleUser, error) {
	gu := GoogleUser{}

	// Make an HTTP GET request to Google's user info endpoint with the access token.
	response, err := http.Get(oauthGoogleUserInfoURL + accessToken)
	if err != nil {
		return gu, fmt.Errorf("failed to make HTTP request to Google user info: %w", err)
	}
	defer response.Body.Close() // Ensure the response body is closed

	if response.StatusCode != http.StatusOK {
		return gu, fmt.Errorf("Google user info API returned non-OK status: %d", response.StatusCode)
	}

	// Decode the JSON response into our GoogleUser struct.
	err = json.NewDecoder(response.Body).Decode(&gu)
	if err != nil {
		return gu, fmt.Errorf("failed to decode Google user info JSON: %w", err)
	}
	return gu, nil
}

GitHub: The Slightly More Complex Path

GitHub can be a bit trickier because a user's primary email might not be public on their profile. If it's not, we need to make a second API call to a different endpoint (/user/emails) to fetch their primary email address.

// handleGithubCallback performs the GitHub-specific callback logic.
func handleGithubCallback(w http.ResponseWriter, r *http.Request) (User, string, error) {
	appUser := User{}
	redirectPath := "/dashboard" // Default path after successful login

	// 1. Validate the OAuth state cookie.
	oauthState, err := r.Cookie("oauthstate")
	if err != nil {
		slog.Error("OAuth state cookie not found for GitHub callback", "err", err)
		return appUser, redirectPath, errors.New("OAuth state cookie not found")
	}
	if r.FormValue("state") != oauthState.Value {
		slog.Error("Invalid OAuth state for GitHub callback", "expected", oauthState.Value, "got", r.FormValue("state"))
		return appUser, redirectPath, errors.New("Invalid OAuth GitHub state")
	}

	// 2. Exchange the authorization code for an access token.
	code := r.URL.Query().Get("code") // Get the code from the URL query parameter
	token, err := githubOAuthConfig.Exchange(context.Background(), code)
	if err != nil {
		return appUser, redirectPath, fmt.Errorf("error exchanging GitHub code for token: %w", err)
	}

	// 3. Fetch user profile data from GitHub's main user API.
	githubUser, err := getGithubUser(token.AccessToken) // This function handles email fallback
	if err != nil {
		return appUser, redirectPath, fmt.Errorf("error getting user data from GitHub: %w", err)
	}

	// 4. Map GitHub's user data to our internal User model.
	appUser.Email = githubUser.Email
	appUser.FirstName, appUser.LastName = githubUser.GetFirstNameLastName() // Use helper for name parsing
	appUser.AvatarURL = githubUser.AvatarURL

	return appUser, redirectPath, nil
}

// getGithubUser fetches user profile and possibly their primary email from GitHub APIs.
func getGithubUser(accessToken string) (GithubUser, error) {
	gu, err := getGithubUserData(accessToken) // First try to get user profile
	if err != nil {
		return gu, err
	}

	// If the user's public email is not available on their profile,
	// make a separate request to get their primary email.
	if gu.Email == "" {
		githubEmail, err := getUserEmailFromGithub(accessToken)
		if err != nil {
			slog.Warn("Could not retrieve primary email from GitHub", "err", err, "github_login", gu.Name)
			// Return original user data even if email fetch fails, or handle as an error
		} else {
			gu.Email = githubEmail
		}
	}
	return gu, nil
}

// getGithubUserData fetches basic user profile information from GitHub's /user endpoint.
func getGithubUserData(accessToken string) (GithubUser, error) {
	gu := GithubUser{}

	// Create an HTTP request with the Authorization header.
	req, err := http.NewRequest(http.MethodGet, oauthGithubUserURL, nil)
	if err != nil {
		return gu, fmt.Errorf("failed to create HTTP request for GitHub user data: %w", err)
	}
	// GitHub requires an Authorization header with 'token <ACCESS_TOKEN>'
	req.Header.Set("Authorization", fmt.Sprintf("token %s", accessToken))

	res, err := http.DefaultClient.Do(req) // Execute the request
	if err != nil {
		return gu, fmt.Errorf("failed to execute HTTP request to GitHub user data: %w", err)
	}
	defer res.Body.Close()

	if res.StatusCode != http.StatusOK {
		return gu, fmt.Errorf("GitHub user data API returned non-OK status: %d", res.StatusCode)
	}

	// Decode the JSON response.
	if err := json.NewDecoder(res.Body).Decode(&gu); err != nil {
		return gu, fmt.Errorf("failed to decode GitHub user data JSON: %w", err)
	}
	return gu, nil
}

// getUserEmailFromGithub fetches the user's primary email address from GitHub's /user/emails endpoint.
func getUserEmailFromGithub(accessToken string) (string, error) {
	req, err := http.NewRequest(http.MethodGet, oauthGithubUserEmailsURL, nil)
	if err != nil {
		return "", fmt.Errorf("failed to create HTTP request for GitHub user emails: %w", err)
	}
	req.Header.Set("Authorization", fmt.Sprintf("token %s", accessToken))

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return "", fmt.Errorf("failed to execute HTTP request to GitHub user emails: %w", err)
	}
	defer res.Body.Close()

	if res.StatusCode != http.StatusOK {
		return "", fmt.Errorf("GitHub user emails API returned non-OK status: %d", res.StatusCode)
	}

	// GitHub returns a list of email objects. We need to find the one marked as 'primary'.
	responseEmails := []struct {
		Email   string `json:"email"`
		Primary bool   `json:"primary"`
		// Other fields like 'verified', 'visibility' might be present but not needed here.
	}{}

	if err := json.NewDecoder(res.Body).Decode(&responseEmails); err != nil {
		return "", fmt.Errorf("failed to decode GitHub user emails JSON: %w", err)
	}

	for _, re := range responseEmails {
		if re.Primary {
			return re.Email, nil // Return the primary email
		}
	}

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

Conclusion: You've Got the Keys!

Congratulations! You've successfully navigated the OAuth dance. Implementing OAuth in your Golang application for Google and GitHub not only significantly enhances security (no storing user passwords!) but also massively improves the user experience by offering seamless, familiar login options.

You've learned how to:

  • Configure OAuth clients for different providers.
  • Initiate the OAuth flow by redirecting users.
  • Handle callbacks securely by verifying the state parameter.
  • Exchange authorization codes for access tokens.
  • Fetch valuable user data (email, name, avatar) from both Google and GitHub, including handling GitHub's slightly unique email retrieval.

This foundation is solid. You can now extend this knowledge to integrate with other OAuth providers like Facebook, LinkedIn, or any custom OAuth2 server. Mastering OAuth in Golang is a powerful skill, ensuring your application is both secure and user-friendly.

What's the next step for your application's authentication journey?