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" ) // GoogleOAuthHandler implements "Sign in with Google" using the OAuth2 // Authorization Code flow: // // 1. GET /auth/google/login - we redirect the browser to Google. // 2. User logs into Google and approves access, entirely on Google's own // site - our server is not involved in that step at all. // 3. GET /auth/google/callback - Google redirects the browser back to us // with a temporary ?code=..., which we exchange (server-to-server, // never visible to the browser) for an access token, then use that // token to ask Google who the user is. 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} } // oauthStateSessionKey is where we temporarily stash the CSRF-protection // "state" value between the /login redirect and the /callback request. const oauthStateSessionKey = "oauth_state" // Login handles GET /auth/google/login: builds Google's consent-screen URL // and redirects the browser there. 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 } // We store "state" in the visitor's session (not a package-level // variable!) so it's correctly scoped per-visitor even with many // concurrent users starting the login flow at the same time. It // survives because the session cookie is already set on the browser // before login - sessions work for anonymous visitors too, they just // don't have a UserIDKey set yet. h.sessions.Put(r.Context(), oauthStateSessionKey, state) // AuthCodeURL builds the full URL to Google's consent screen, // embedding our client ID, redirect URL, requested scopes, and state. url := h.config.AuthCodeURL(state) http.Redirect(w, r, url, http.StatusTemporaryRedirect) } // Callback handles GET /auth/google/callback: Google redirects the browser // here with ?state=...&code=... after the user approves access. func (h *GoogleOAuthHandler) Callback(w http.ResponseWriter, r *http.Request) { // CSRF protection: confirm the state Google sent back matches the one // WE generated for this specific login attempt. Without this check, an // attacker could craft their own callback link using their own // Google account and trick a victim into using it, potentially linking // the attacker's Google account to the victim's session. expectedState := h.sessions.GetString(r.Context(), oauthStateSessionKey) if expectedState == "" || r.URL.Query().Get("state") != expectedState { writeError(w, http.StatusBadRequest, "invalid oauth state") return } // The state value is single-use - remove it from the session now that // we've validated it, so it can't be replayed. h.sessions.Remove(r.Context(), oauthStateSessionKey) code := r.URL.Query().Get("code") if code == "" { writeError(w, http.StatusBadRequest, "missing code") return } // Server-to-server call to Google: exchange the temporary, single-use // code for a real access token. This request includes our // ClientSecret, proving to Google that it's really our registered // application making the request. 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 } // h.config.Client returns a regular *http.Client, pre-configured to // automatically attach the access token as an "Authorization: Bearer // ..." header on every request it makes - no manual header handling // needed. 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 } // Same session-fixation defense and "store only the ID" pattern as // the password-based Login handler in auth.go. 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 account. // There are three possible cases: // // 1. No user exists with this email yet -> create a brand new user, // Google-only (empty PasswordHash). // 2. A user already exists with this email AND already has this // Google account linked -> just return them. // 3. A user already exists with this email but registered via password // (no GoogleID yet) -> link this Google account to that existing // user, so they can log in either way going forward. 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 intentionally left empty - this user can only // log in via Google unless they later set a password (not // implemented in this learning project, but would be a // natural next feature). } if createErr := h.userRepo.Create(r.Context(), newUser); createErr != nil { return nil, createErr } return newUser, nil } if err != nil { return nil, err } // A user with this email already exists. 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 } // generateState creates a cryptographically random, URL-safe string used // as the OAuth2 "state" CSRF-protection parameter. // // Note this uses crypto/rand, NOT math/rand - crypto/rand is suitable for // security-sensitive randomness (unpredictable even to an attacker who // knows previous outputs), while math/rand is not. func generateState() (string, error) { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { return "", err } return base64.URLEncoding.EncodeToString(b), nil }