Files
go-simple-api/lessons/lesson-07-google-oauth.md
T
2026-07-16 10:13:46 +03:30

18 KiB

Lesson 7 — Login with Google (OAuth2)

New Go concepts in this lesson: anonymous structs for one-off JSON shapes, crypto/rand vs math/rand, working with an *http.Client returned by a library. Builds on JSON basics and error handling from the Go Basics lessons — nothing fundamentally new at the language level, but a new external flow (OAuth2) to understand conceptually.

What OAuth2 actually does here

Your app never sees the user's Google password. Instead, your app redirects the user to Google, the user logs into Google and approves "let this app see your email," and Google redirects back to your app with a temporary code. Your app exchanges that code (server-to-server, never visible to the browser) for an access token, then uses that token to ask Google "who is this user?" This is called the Authorization Code flow — the standard, secure OAuth2 pattern.

Setting up Google credentials (one-time, outside the code)

  1. Go to the Google Cloud Console, create a project if needed.
  2. Create an OAuth 2.0 Client ID (Application type: Web application).
  3. Add an Authorized redirect URI: http://localhost:8080/auth/google/callback.
  4. You'll get a Client ID and Client Secret — treat the secret like a password, never commit it to git.

Part A — standalone playground

mkdir ~/go-playground/oauth-demo && cd ~/go-playground/oauth-demo
go mod init oauth-demo
go get golang.org/x/oauth2@latest

main.go

package main

import (
	"crypto/rand"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"

	"golang.org/x/oauth2"
	"golang.org/x/oauth2/google"
)

// 1. oauth2.Config describes everything needed to talk to Google's OAuth
// system. Fill in your own Client ID/Secret from the Cloud Console.
var googleOAuthConfig = &oauth2.Config{
	ClientID:     "YOUR_CLIENT_ID.apps.googleusercontent.com",
	ClientSecret: "YOUR_CLIENT_SECRET",
	RedirectURL:  "http://localhost:4000/auth/google/callback",
	Scopes:       []string{"https://www.googleapis.com/auth/userinfo.email"},
	Endpoint:     google.Endpoint,
}

// In real code this state should be stored per-session, not a global -
// we use a global here ONLY to keep this playground minimal.
var expectedState string

func main() {
	http.HandleFunc("/login", loginHandler)
	http.HandleFunc("/auth/google/callback", callbackHandler)

	log.Println("visit http://localhost:4000/login")
	log.Fatal(http.ListenAndServe(":4000", nil))
}

func loginHandler(w http.ResponseWriter, r *http.Request) {
	// 2. Generate a random "state" value - CSRF protection: we'll check
	// that the state Google sends back matches what we generated, proving
	// the callback really came from a login WE initiated.
	state, err := generateState()
	if err != nil {
		http.Error(w, "failed to generate state", http.StatusInternalServerError)
		return
	}
	expectedState = state

	// 3. AuthCodeURL builds the full URL to Google's consent screen,
	// embedding our client ID, redirect URL, scopes, and state.
	url := googleOAuthConfig.AuthCodeURL(state)

	// 4. Send the browser there.
	http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}

func callbackHandler(w http.ResponseWriter, r *http.Request) {
	// 5. Google redirects back here with ?state=...&code=... in the URL.
	if r.URL.Query().Get("state") != expectedState {
		http.Error(w, "invalid state", http.StatusBadRequest)
		return
	}

	code := r.URL.Query().Get("code")
	if code == "" {
		http.Error(w, "missing code", http.StatusBadRequest)
		return
	}

	// 6. Exchange the temporary code for an actual access token. This is
	// a direct server-to-server HTTPS call to Google - the code is
	// single-use and expires quickly.
	token, err := googleOAuthConfig.Exchange(r.Context(), code)
	if err != nil {
		http.Error(w, "code exchange failed", http.StatusInternalServerError)
		return
	}

	// 7. Use the access token to call Google's userinfo endpoint and find
	// out who actually logged in.
	client := googleOAuthConfig.Client(r.Context(), token)
	resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
	if err != nil {
		http.Error(w, "failed to fetch user info", http.StatusInternalServerError)
		return
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		http.Error(w, "failed to read user info", http.StatusInternalServerError)
		return
	}

	var googleUser struct {
		ID    string `json:"id"`
		Email string `json:"email"`
	}
	if err := json.Unmarshal(body, &googleUser); err != nil {
		http.Error(w, "failed to parse user info", http.StatusInternalServerError)
		return
	}

	fmt.Fprintf(w, "logged in as: id=%s email=%s\n", googleUser.ID, googleUser.Email)
}

func generateState() (string, error) {
	b := make([]byte, 16)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	return base64.URLEncoding.EncodeToString(b), nil
}

Run it, plug in your real Client ID/Secret, then visit http://localhost:4000/login in a real browser (curl can't drive Google's login page).

Line by line:

  • oauth2.Config — a struct holding everything needed to run the flow: your app's identity (ClientID/ClientSecret), where Google sends the user back (RedirectURLmust exactly match the Cloud Console registration), what data you're requesting (Scopes), and which provider's endpoints to use (Endpoint: google.Endpoint, a predefined value pointing at Google's real auth/token URLs).
  • var googleUser struct { ID string \json:"id"`; Email string `json:"email"` }— this is an **anonymous struct**: a struct type defined inline, without atype Name struct` declaration, used because we only need this shape once, right here, to decode one specific JSON response.
  • generateState()crypto/rand (NOT math/rand!) generates cryptographically secure random bytes, unpredictable even if an attacker knows previous outputs. This matters because state is a security mechanism, not just a random label. math/rand is fine for games/simulations, never for anything security-sensitive.
  • Why state matters: without it, an attacker could craft their own malicious callback link using their own Google account's code, trick a victim into clicking it while logged into your app, and potentially link the attacker's Google account to the victim's session. Checking that state matches what we generated closes that hole.
  • googleOAuthConfig.AuthCodeURL(state) — builds the actual Google consent-screen URL; you never hand-construct this.
  • http.Redirect(w, r, url, http.StatusTemporaryRedirect) — sends an HTTP 302-style response telling the browser "go here instead." The browser follows it to Google.
  • googleOAuthConfig.Exchange(r.Context(), code) — server-to-server: your Go program makes an HTTPS POST to Google, presenting code plus your ClientSecret (proving it's really your registered app), and gets back an oauth2.Token.
  • googleOAuthConfig.Client(r.Context(), token) — returns a regular *http.Client, pre-configured to automatically attach the access token as an Authorization: Bearer ... header on every request — no manual header handling needed.
  • io.ReadAll(resp.Body) then json.Unmarshal(body, &googleUser) — slightly different from json.NewDecoder(r.Body).Decode(&x) used elsewhere. Both work; Unmarshal needs the full byte slice up front (hence ReadAll first), Decode streams directly. Either is fine for small responses — you'll see both styles in real Go code.
  • defer resp.Body.Close() — same rule as defer rows.Close() from Lesson 3: any "reader" resource (HTTP body, SQL rows, open file) should always be closed when you're done with it.

Try it end to end, confirm you see your real Google email printed back. Try mangling the state value in the URL manually and confirm "invalid state."

Part B — apply it to the project

Add the dependency:

go get golang.org/x/oauth2@latest

Extend internal/config/config.go:

type Config struct {
	Port string

	DBHost     string
	DBPort     string
	DBUser     string
	DBPassword string
	DBName     string

	RedisAddr string

	GoogleClientID     string
	GoogleClientSecret string
	GoogleRedirectURL  string
}

func Load() Config {
	return Config{
		Port: getEnv("PORT", "8080"),

		DBHost:     getEnv("DB_HOST", "127.0.0.1"),
		DBPort:     getEnv("DB_PORT", "3306"),
		DBUser:     getEnv("DB_USER", "root"),
		DBPassword: getEnv("DB_PASSWORD", "devpass"),
		DBName:     getEnv("DB_NAME", "go_simple_api"),

		RedisAddr: getEnv("REDIS_ADDR", "127.0.0.1:6379"),

		GoogleClientID:     getEnv("GOOGLE_CLIENT_ID", ""),
		GoogleClientSecret: getEnv("GOOGLE_CLIENT_SECRET", ""),
		GoogleRedirectURL:  getEnv("GOOGLE_REDIRECT_URL", "http://localhost:8080/auth/google/callback"),
	}
}

Add to .env:

GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-client-secret
GOOGLE_REDIRECT_URL=http://localhost:8080/auth/google/callback

internal/oauth/google.go — builds the *oauth2.Config from our app's Config:

package oauth

import (
	"golang.org/x/oauth2"
	"golang.org/x/oauth2/google"

	"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
)

func NewGoogleConfig(cfg config.Config) *oauth2.Config {
	return &oauth2.Config{
		ClientID:     cfg.GoogleClientID,
		ClientSecret: cfg.GoogleClientSecret,
		RedirectURL:  cfg.GoogleRedirectURL,
		Scopes:       []string{"https://www.googleapis.com/auth/userinfo.email"},
		Endpoint:     google.Endpoint,
	}
}

internal/handlers/oauth_google.go — the real handler, reusing the flow from Part A but wired into our session/repository system:

package handlers

import (
	"crypto/rand"
	"encoding/base64"
	"encoding/json"
	"errors"
	"io"
	"log/slog"
	"net/http"

	"github.com/alexedwards/scs/v2"
	"golang.org/x/oauth2"

	"git.hamidsoltani.com/hamid/go-simple-api/internal/models"
	"git.hamidsoltani.com/hamid/go-simple-api/internal/session"
)

type GoogleOAuthHandler struct {
	config   *oauth2.Config
	userRepo *models.UserRepository
	sessions *scs.SessionManager
	logger   *slog.Logger
}

func NewGoogleOAuthHandler(config *oauth2.Config, userRepo *models.UserRepository, sessions *scs.SessionManager, logger *slog.Logger) *GoogleOAuthHandler {
	return &GoogleOAuthHandler{config: config, userRepo: userRepo, sessions: sessions, logger: logger}
}

const oauthStateSessionKey = "oauth_state"

func (h *GoogleOAuthHandler) Login(w http.ResponseWriter, r *http.Request) {
	state, err := generateState()
	if err != nil {
		h.logger.Error("generate oauth state failed", "error", err)
		writeError(w, http.StatusInternalServerError, "internal error")
		return
	}

	// Store state in the SESSION instead of a global variable (Part A cut
	// this corner deliberately, having no session system yet). This
	// means state survives correctly even with multiple users hitting
	// /auth/google/login concurrently.
	h.sessions.Put(r.Context(), oauthStateSessionKey, state)

	url := h.config.AuthCodeURL(state)
	http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}

func (h *GoogleOAuthHandler) Callback(w http.ResponseWriter, r *http.Request) {
	expectedState := h.sessions.GetString(r.Context(), oauthStateSessionKey)
	if expectedState == "" || r.URL.Query().Get("state") != expectedState {
		writeError(w, http.StatusBadRequest, "invalid oauth state")
		return
	}
	h.sessions.Remove(r.Context(), oauthStateSessionKey) // one-time use

	code := r.URL.Query().Get("code")
	if code == "" {
		writeError(w, http.StatusBadRequest, "missing code")
		return
	}

	token, err := h.config.Exchange(r.Context(), code)
	if err != nil {
		h.logger.Error("oauth exchange failed", "error", err)
		writeError(w, http.StatusInternalServerError, "internal error")
		return
	}

	client := h.config.Client(r.Context(), token)
	resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
	if err != nil {
		h.logger.Error("fetch google userinfo failed", "error", err)
		writeError(w, http.StatusInternalServerError, "internal error")
		return
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		h.logger.Error("read google userinfo failed", "error", err)
		writeError(w, http.StatusInternalServerError, "internal error")
		return
	}

	var googleUser struct {
		ID    string `json:"id"`
		Email string `json:"email"`
	}
	if err := json.Unmarshal(body, &googleUser); err != nil {
		h.logger.Error("parse google userinfo failed", "error", err)
		writeError(w, http.StatusInternalServerError, "internal error")
		return
	}

	user, err := h.findOrCreateGoogleUser(r, googleUser.ID, googleUser.Email)
	if err != nil {
		h.logger.Error("find or create google user failed", "error", err)
		writeError(w, http.StatusInternalServerError, "internal error")
		return
	}

	if err := h.sessions.RenewToken(r.Context()); err != nil {
		h.logger.Error("renew token failed", "error", err)
		writeError(w, http.StatusInternalServerError, "internal error")
		return
	}
	h.sessions.Put(r.Context(), session.UserIDKey, user.ID)

	writeJSON(w, http.StatusOK, map[string]any{
		"id":    user.ID,
		"email": user.Email,
	})
}

// findOrCreateGoogleUser links a Google identity to a local user. Three
// cases: an existing user with this email but no google_id yet (link
// it), a user already linked to this google_id (just fetch it), or
// nobody with this email exists yet (create a new user).
func (h *GoogleOAuthHandler) findOrCreateGoogleUser(r *http.Request, googleID, email string) (*models.User, error) {
	existing, err := h.userRepo.FindByEmail(r.Context(), email)

	if errors.Is(err, models.ErrUserNotFound) {
		newUser := &models.User{
			Email:    email,
			GoogleID: googleID,
			// PasswordHash stays empty - this user can only log in via Google.
		}
		if createErr := h.userRepo.Create(r.Context(), newUser); createErr != nil {
			return nil, createErr
		}
		return newUser, nil
	}
	if err != nil {
		return nil, err
	}

	// User exists by email. If they haven't linked Google yet, link it now.
	if existing.GoogleID == "" {
		if linkErr := h.userRepo.SetGoogleID(r.Context(), existing.ID, googleID); linkErr != nil {
			return nil, linkErr
		}
		existing.GoogleID = googleID
	}

	return existing, nil
}

func generateState() (string, error) {
	b := make([]byte, 16)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	return base64.URLEncoding.EncodeToString(b), nil
}

Notable differences from Part A, and why:

  • State stored in the session, not a global variable. Part A's var expectedState string only works for one user at a time — a real server handles many concurrent users, and a shared global would let one user's login attempt clobber another's state. Storing it via h.sessions.Put(...) scopes it correctly per-visitor.
  • h.sessions.Remove(...) — a new scs method: removes a single key from the session (as opposed to Destroy, which wipes the whole session). state is only needed for this one round trip, so we clean it up immediately after checking it.
  • h.findOrCreateGoogleUser(...) — the account linking logic. Three distinct paths, each built entirely from repository methods you already know from Lesson 4/5.

Add one more repository method — internal/models/user_repository.go:

func (r *UserRepository) SetGoogleID(ctx context.Context, userID int, googleID string) error {
	_, err := r.db.ExecContext(ctx,
		"UPDATE users SET google_id = ? WHERE id = ?", googleID, userID,
	)
	if err != nil {
		return fmt.Errorf("set google id: %w", err)
	}
	return nil
}

Update internal/router/router.go:

package router

import (
	"database/sql"
	"log/slog"
	"time"

	"github.com/alexedwards/scs/v2"
	"github.com/go-chi/chi/v5"
	chimw "github.com/go-chi/chi/v5/middleware"

	"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
	"git.hamidsoltani.com/hamid/go-simple-api/internal/handlers"
	"git.hamidsoltani.com/hamid/go-simple-api/internal/middleware"
	"git.hamidsoltani.com/hamid/go-simple-api/internal/models"
	"git.hamidsoltani.com/hamid/go-simple-api/internal/oauth"
)

func New(logger *slog.Logger, db *sql.DB, sessions *scs.SessionManager, cfg config.Config) *chi.Mux {
	r := chi.NewRouter()

	r.Use(chimw.RequestID)
	r.Use(middleware.RequestLogger(logger))
	r.Use(chimw.Recoverer)
	r.Use(chimw.Timeout(60 * time.Second))
	r.Use(sessions.LoadAndSave)

	r.Get("/health", handlers.Health)

	userRepo := models.NewUserRepository(db)
	authHandler := handlers.NewAuthHandler(userRepo, sessions, logger)

	r.Post("/register", authHandler.Register)
	r.Post("/login", authHandler.Login)
	r.Post("/logout", authHandler.Logout)
	r.Get("/me", authHandler.Me)

	googleConfig := oauth.NewGoogleConfig(cfg)
	googleHandler := handlers.NewGoogleOAuthHandler(googleConfig, userRepo, sessions, logger)

	r.Get("/auth/google/login", googleHandler.Login)
	r.Get("/auth/google/callback", googleHandler.Callback)

	return r
}

New now also takes cfg config.Config (needed to build the Google OAuth config).

Update cmd/api/main.go — change the router.New(...) call:

r := router.New(logger, db, sessions, cfg)

Try it

go run ./cmd/api

Visit http://localhost:8080/auth/google/login in a browser. After approving, you should land on /auth/google/callback and see JSON back with your id/email, plus a session cookie:

# copy the session_id cookie value from your browser's devtools
curl -b "session_id=<paste-cookie-value-here>" http://localhost:8080/me

Confirm in MySQL:

docker exec -it mysql-api mysql -uroot -pdevpass go_simple_api -e "SELECT id, email, google_id FROM users;"

Once a full Google login round-trip works, move to Lesson 8 — auth middleware & route protection.