Files
go-simple-api/lessons/lesson-07-google-oauth.md
T

543 lines
18 KiB
Markdown
Raw Normal View History

2026-07-16 10:13:46 +03:30
# 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](https://console.cloud.google.com/apis/credentials), 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
```bash
mkdir ~/go-playground/oauth-demo && cd ~/go-playground/oauth-demo
go mod init oauth-demo
go get golang.org/x/oauth2@latest
```
**`main.go`**
```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 a `type 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:**
```bash
go get golang.org/x/oauth2@latest
```
**Extend `internal/config/config.go`:**
```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`:
```go
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:
```go
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`:**
```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`:**
```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:
```go
r := router.New(logger, db, sessions, cfg)
```
## Try it
```bash
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:
```bash
# 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:
```bash
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.