18 KiB
Lesson 7 — Login with Google (OAuth2)
New Go concepts in this lesson: anonymous structs for one-off JSON shapes,
crypto/randvsmath/rand, working with an*http.Clientreturned 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)
- Go to the Google Cloud Console, create a project if needed.
- Create an OAuth 2.0 Client ID (Application type: Web application).
- Add an Authorized redirect URI:
http://localhost:8080/auth/google/callback. - 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 (RedirectURL— must 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(NOTmath/rand!) generates cryptographically secure random bytes, unpredictable even if an attacker knows previous outputs. This matters becausestateis a security mechanism, not just a random label.math/randis fine for games/simulations, never for anything security-sensitive.- Why
statematters: 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 thatstatematches 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, presentingcodeplus yourClientSecret(proving it's really your registered app), and gets back anoauth2.Token.googleOAuthConfig.Client(r.Context(), token)— returns a regular*http.Client, pre-configured to automatically attach the access token as anAuthorization: Bearer ...header on every request — no manual header handling needed.io.ReadAll(resp.Body)thenjson.Unmarshal(body, &googleUser)— slightly different fromjson.NewDecoder(r.Body).Decode(&x)used elsewhere. Both work;Unmarshalneeds the full byte slice up front (henceReadAllfirst),Decodestreams directly. Either is fine for small responses — you'll see both styles in real Go code.defer resp.Body.Close()— same rule asdefer 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 stringonly 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 viah.sessions.Put(...)scopes it correctly per-visitor. h.sessions.Remove(...)— a new scs method: removes a single key from the session (as opposed toDestroy, which wipes the whole session).stateis 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.